commit
cc5c699d0e
11 changed files with 188 additions and 3 deletions
|
@ -37,6 +37,7 @@ BMP | ✅ | ✅ |
|
||||||
QOI | ✅ | ✅ |
|
QOI | ✅ | ✅ |
|
||||||
GIF | ✅ | |
|
GIF | ✅ | |
|
||||||
SVG | ✅ | |
|
SVG | ✅ | |
|
||||||
|
PPM | ✅ | ✅ |
|
||||||
|
|
||||||
### Font file formats
|
### Font file formats
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import bumpy, chroma, flatty/binny, os, pixie/common, pixie/contexts,
|
import bumpy, chroma, flatty/binny, os, pixie/common, pixie/contexts,
|
||||||
pixie/fileformats/bmp, pixie/fileformats/gif, pixie/fileformats/jpg,
|
pixie/fileformats/bmp, pixie/fileformats/gif, pixie/fileformats/jpg,
|
||||||
pixie/fileformats/png, pixie/fileformats/qoi, pixie/fileformats/svg,
|
pixie/fileformats/png, pixie/fileformats/ppm, pixie/fileformats/qoi,
|
||||||
pixie/fonts, pixie/images, pixie/masks, pixie/paints, pixie/paths, strutils, vmath
|
pixie/fileformats/svg, pixie/fonts, pixie/images, pixie/masks, pixie/paints,
|
||||||
|
pixie/paths, strutils, vmath
|
||||||
|
|
||||||
export bumpy, chroma, common, contexts, fonts, images, masks, paints, paths, vmath
|
export bumpy, chroma, common, contexts, fonts, images, masks, paints, paths, vmath
|
||||||
|
|
||||||
type
|
type
|
||||||
FileFormat* = enum
|
FileFormat* = enum
|
||||||
ffPng, ffBmp, ffJpg, ffGif, ffQoi
|
ffPng, ffBmp, ffJpg, ffGif, ffQoi, ffPpm
|
||||||
|
|
||||||
converter autoStraightAlpha*(c: ColorRGBX): ColorRGBA {.inline, raises: [].} =
|
converter autoStraightAlpha*(c: ColorRGBX): ColorRGBA {.inline, raises: [].} =
|
||||||
## Convert a premultiplied alpha RGBA to a straight alpha RGBA.
|
## Convert a premultiplied alpha RGBA to a straight alpha RGBA.
|
||||||
|
@ -32,6 +33,8 @@ proc decodeImage*(data: string | seq[uint8]): Image {.raises: [PixieError].} =
|
||||||
decodeGif(data)
|
decodeGif(data)
|
||||||
elif data.len > (14+8) and data.readStr(0, 4) == qoiSignature:
|
elif data.len > (14+8) and data.readStr(0, 4) == qoiSignature:
|
||||||
decodeQoi(data)
|
decodeQoi(data)
|
||||||
|
elif data.len > 9 and data.readStr(0, 2) in ppmSignatures:
|
||||||
|
decodePpm(data)
|
||||||
else:
|
else:
|
||||||
raise newException(PixieError, "Unsupported image file format")
|
raise newException(PixieError, "Unsupported image file format")
|
||||||
|
|
||||||
|
@ -69,6 +72,8 @@ proc encodeImage*(image: Image, fileFormat: FileFormat): string {.raises: [Pixie
|
||||||
image.encodeQoi()
|
image.encodeQoi()
|
||||||
of ffGif:
|
of ffGif:
|
||||||
raise newException(PixieError, "Unsupported file format")
|
raise newException(PixieError, "Unsupported file format")
|
||||||
|
of ffPpm:
|
||||||
|
image.encodePpm()
|
||||||
|
|
||||||
proc encodeMask*(mask: Mask, fileFormat: FileFormat): string {.raises: [PixieError].} =
|
proc encodeMask*(mask: Mask, fileFormat: FileFormat): string {.raises: [PixieError].} =
|
||||||
## Encodes a mask into memory.
|
## Encodes a mask into memory.
|
||||||
|
@ -85,6 +90,7 @@ proc writeFile*(image: Image, filePath: string) {.raises: [PixieError].} =
|
||||||
of ".bmp": ffBmp
|
of ".bmp": ffBmp
|
||||||
of ".jpg", ".jpeg": ffJpg
|
of ".jpg", ".jpeg": ffJpg
|
||||||
of ".qoi": ffQoi
|
of ".qoi": ffQoi
|
||||||
|
of ".ppm": ffPpm
|
||||||
else:
|
else:
|
||||||
raise newException(PixieError, "Unsupported file extension")
|
raise newException(PixieError, "Unsupported file extension")
|
||||||
|
|
||||||
|
@ -100,6 +106,7 @@ proc writeFile*(mask: Mask, filePath: string) {.raises: [PixieError].} =
|
||||||
of ".bmp": ffBmp
|
of ".bmp": ffBmp
|
||||||
of ".jpg", ".jpeg": ffJpg
|
of ".jpg", ".jpeg": ffJpg
|
||||||
of ".qoi": ffQoi
|
of ".qoi": ffQoi
|
||||||
|
of ".ppm": ffPpm
|
||||||
else:
|
else:
|
||||||
raise newException(PixieError, "Unsupported file extension")
|
raise newException(PixieError, "Unsupported file extension")
|
||||||
|
|
||||||
|
|
142
src/pixie/fileformats/ppm.nim
Normal file
142
src/pixie/fileformats/ppm.nim
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
import chroma, flatty/binny, pixie/common, pixie/images, std/strutils
|
||||||
|
|
||||||
|
# See: http://netpbm.sourceforge.net/doc/ppm.html
|
||||||
|
|
||||||
|
const ppmSignatures* = @["P3", "P6"]
|
||||||
|
|
||||||
|
type
|
||||||
|
PpmHeader = object
|
||||||
|
version: string
|
||||||
|
width, height, maxVal, dataOffset: int
|
||||||
|
|
||||||
|
template failInvalid() =
|
||||||
|
raise newException(PixieError, "Invalid PPM data")
|
||||||
|
|
||||||
|
proc decodeHeader(data: string): PpmHeader {.raises: [PixieError].} =
|
||||||
|
if data.len <= 10: # Each part + whitespace
|
||||||
|
raise newException(PixieError, "Invalid PPM file header")
|
||||||
|
|
||||||
|
var commentMode, readWhitespace: bool
|
||||||
|
var i, readFields: int
|
||||||
|
var field: string
|
||||||
|
while readFields < 4:
|
||||||
|
let c = readUint8(data, i).char
|
||||||
|
if c == '#':
|
||||||
|
commentMode = true
|
||||||
|
elif c == '\n':
|
||||||
|
commentMode = false
|
||||||
|
if not commentMode:
|
||||||
|
if c in Whitespace and not readWhitespace:
|
||||||
|
inc readFields
|
||||||
|
readWhitespace = true
|
||||||
|
try:
|
||||||
|
case readFields:
|
||||||
|
of 1:
|
||||||
|
result.version = field
|
||||||
|
of 2:
|
||||||
|
result.width = parseInt(field)
|
||||||
|
of 3:
|
||||||
|
result.height = parseInt(field)
|
||||||
|
of 4:
|
||||||
|
result.maxVal = parseInt(field)
|
||||||
|
else:
|
||||||
|
discard
|
||||||
|
except ValueError: failInvalid()
|
||||||
|
field = ""
|
||||||
|
elif not (c in Whitespace):
|
||||||
|
field.add(c)
|
||||||
|
readWhitespace = false
|
||||||
|
inc i
|
||||||
|
|
||||||
|
result.dataOffset = i
|
||||||
|
|
||||||
|
proc decodeP6Data(data: string, maxVal: int): seq[ColorRGBX] {.raises: [].} =
|
||||||
|
let needsUint16 = maxVal > 0xFF
|
||||||
|
|
||||||
|
result = newSeq[ColorRGBX](
|
||||||
|
if needsUint16: data.len div 6
|
||||||
|
else: data.len div 3
|
||||||
|
)
|
||||||
|
|
||||||
|
# Let's calculate the real maximum value multiplier.
|
||||||
|
# rgbx() accepts a maximum value of 0xFF. Most of the time,
|
||||||
|
# maxVal is set to 0xFF as well, so in most cases it is 1
|
||||||
|
let valueMultiplier = 0xFF / maxVal
|
||||||
|
|
||||||
|
# if comparison in for loops is expensive, so let's unroll it
|
||||||
|
if not needsUint16:
|
||||||
|
for i in 0 ..< result.len:
|
||||||
|
let
|
||||||
|
red = (readUint8(data, i + (i * 2)).float * valueMultiplier + 0.5).uint8
|
||||||
|
green = (readUint8(data, i + 1 + (i * 2)).float * valueMultiplier + 0.5).uint8
|
||||||
|
blue = (readUint8(data, i + 2 + (i * 2)).float * valueMultiplier + 0.5).uint8
|
||||||
|
result[i] = rgbx(red, green, blue, 0xFF)
|
||||||
|
else:
|
||||||
|
for i in 0 ..< result.len:
|
||||||
|
let
|
||||||
|
red = (readUint16(data, i + (i * 5)).swap.float * valueMultiplier + 0.5).uint8
|
||||||
|
green = (readUint16(data, i + 2 + (i * 5)).swap.float * valueMultiplier + 0.5).uint8
|
||||||
|
blue = (readUint16(data, i + 4 + (i * 5)).swap.float * valueMultiplier + 0.5).uint8
|
||||||
|
result[i] = rgbx(red, green, blue, 0xFF)
|
||||||
|
|
||||||
|
proc decodeP3Data(data: string, maxVal: int): seq[ColorRGBX] {.raises: [PixieError].} =
|
||||||
|
let needsUint16 = maxVal > 0xFF
|
||||||
|
let maxLen = (
|
||||||
|
if needsUint16: data.splitWhitespace.len * 2
|
||||||
|
else: data.splitWhitespace.len
|
||||||
|
)
|
||||||
|
var p6data = newStringOfCap(maxLen)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not needsUint16:
|
||||||
|
for line in data.splitLines():
|
||||||
|
for sample in line.split('#', 1)[0].splitWhitespace():
|
||||||
|
p6data.add(parseInt(sample).chr)
|
||||||
|
else:
|
||||||
|
for line in data.splitLines():
|
||||||
|
for sample in line.split('#', 1)[0].splitWhitespace():
|
||||||
|
p6data.addUint16(parseInt(sample).uint16.swap)
|
||||||
|
except ValueError: failInvalid()
|
||||||
|
|
||||||
|
result = decodeP6Data(p6data, maxVal)
|
||||||
|
|
||||||
|
proc decodePpm*(data: string): Image {.raises: [PixieError].} =
|
||||||
|
## Decodes Portable Pixel Map data into an Image.
|
||||||
|
|
||||||
|
let header = decodeHeader(data)
|
||||||
|
|
||||||
|
if not (header.version in ppmSignatures): failInvalid()
|
||||||
|
if 0 > header.maxVal or header.maxVal > 0xFFFF: failInvalid()
|
||||||
|
|
||||||
|
result = newImage(header.width, header.height)
|
||||||
|
result.data = (
|
||||||
|
if header.version == "P3":
|
||||||
|
decodeP3Data(data[header.dataOffset .. ^1], header.maxVal)
|
||||||
|
else: decodeP6Data(data[header.dataOffset .. ^1], header.maxVal)
|
||||||
|
)
|
||||||
|
|
||||||
|
proc decodePpm*(data: seq[uint8]): Image {.inline, raises: [PixieError].} =
|
||||||
|
## Decodes Portable Pixel Map data into an Image.
|
||||||
|
decodePpm(cast[string](data))
|
||||||
|
|
||||||
|
proc encodePpm*(image: Image): string {.raises: [].} =
|
||||||
|
## Encodes an image into the PPM file format (version P6).
|
||||||
|
|
||||||
|
# PPM header
|
||||||
|
result.add("P6") # The header field used to identify the PPM
|
||||||
|
result.add("\n") # Newline
|
||||||
|
result.add($image.width)
|
||||||
|
result.add(" ") # Space
|
||||||
|
result.add($image.height)
|
||||||
|
result.add("\n") # Newline
|
||||||
|
result.add("255") # Max color value
|
||||||
|
result.add("\n") # Newline
|
||||||
|
|
||||||
|
# PPM image data
|
||||||
|
for y in 0 ..< image.height:
|
||||||
|
for x in 0 ..< image.width:
|
||||||
|
let rgb = image[x, y]
|
||||||
|
# Alpha channel is ignored
|
||||||
|
result.addUint8(rgb.r)
|
||||||
|
result.addUint8(rgb.g)
|
||||||
|
result.addUint8(rgb.b)
|
|
@ -10,6 +10,7 @@ import
|
||||||
test_paints,
|
test_paints,
|
||||||
test_paths,
|
test_paths,
|
||||||
test_png,
|
test_png,
|
||||||
|
test_ppm,
|
||||||
test_qoi,
|
test_qoi,
|
||||||
test_svg,
|
test_svg,
|
||||||
../examples/text,
|
../examples/text,
|
||||||
|
|
8
tests/fileformats/ppm/feep.p3.hidepth.master.ppm
Normal file
8
tests/fileformats/ppm/feep.p3.hidepth.master.ppm
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
P3
|
||||||
|
# feep.p3.hidepth.master.ppm
|
||||||
|
4 4
|
||||||
|
7919 # prime number chosen for test purposes
|
||||||
|
0 0 0 0 0 0 0 0 0 7919 0 7919
|
||||||
|
0 0 0 0 7919 3695 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 7919 3695 0 0 0
|
||||||
|
7919 0 7919 0 0 0 0 0 0 0 0 0
|
BIN
tests/fileformats/ppm/feep.p3.hidepth.ppm
Normal file
BIN
tests/fileformats/ppm/feep.p3.hidepth.ppm
Normal file
Binary file not shown.
8
tests/fileformats/ppm/feep.p3.master.ppm
Normal file
8
tests/fileformats/ppm/feep.p3.master.ppm
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
P3
|
||||||
|
# feep.p3.master.ppm
|
||||||
|
4 4
|
||||||
|
15
|
||||||
|
0 0 0 0 0 0 0 0 0 15 0 15
|
||||||
|
0 0 0 0 15 7 0 0 0 0 0 0
|
||||||
|
0 0 0 0 0 0 0 15 7 0 0 0
|
||||||
|
15 0 15 0 0 0 0 0 0 0 0 0
|
BIN
tests/fileformats/ppm/feep.p3.ppm
Normal file
BIN
tests/fileformats/ppm/feep.p3.ppm
Normal file
Binary file not shown.
BIN
tests/fileformats/ppm/feep.p6.master.ppm
Normal file
BIN
tests/fileformats/ppm/feep.p6.master.ppm
Normal file
Binary file not shown.
BIN
tests/fileformats/ppm/feep.p6.ppm
Normal file
BIN
tests/fileformats/ppm/feep.p6.ppm
Normal file
Binary file not shown.
18
tests/test_ppm.nim
Normal file
18
tests/test_ppm.nim
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import pixie/fileformats/ppm
|
||||||
|
|
||||||
|
block:
|
||||||
|
for format in @["p3", "p6"]:
|
||||||
|
let image = decodePpm(readFile(
|
||||||
|
"tests/fileformats/ppm/feep." & $format & ".master.ppm"
|
||||||
|
))
|
||||||
|
writeFile("tests/fileformats/ppm/feep." & $format & ".ppm", encodePpm(image))
|
||||||
|
|
||||||
|
let image = decodePpm(readFile(
|
||||||
|
"tests/fileformats/ppm/feep.p3.hidepth.master.ppm"
|
||||||
|
))
|
||||||
|
writeFile("tests/fileformats/ppm/feep.p3.hidepth.ppm", encodePpm(image))
|
||||||
|
|
||||||
|
# produced output should be identical to P6 master
|
||||||
|
let p6Master = readFile("tests/fileformats/ppm/feep.p6.master.ppm")
|
||||||
|
for image in @["p3", "p6", "p3.hidepth"]:
|
||||||
|
doAssert readFile("tests/fileformats/ppm/feep." & $image & ".ppm") == p6Master
|
Loading…
Reference in a new issue