diff --git a/README.md b/README.md index 32b57ce..b8be978 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ BMP | ✅ | ✅ | QOI | ✅ | ✅ | GIF | ✅ | | SVG | ✅ | | +PPM | ✅ | ✅ | ### Font file formats diff --git a/src/pixie.nim b/src/pixie.nim index 6af2bc6..28e4094 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -1,13 +1,14 @@ import bumpy, chroma, flatty/binny, os, pixie/common, pixie/contexts, pixie/fileformats/bmp, pixie/fileformats/gif, pixie/fileformats/jpg, - pixie/fileformats/png, pixie/fileformats/qoi, pixie/fileformats/svg, - pixie/fonts, pixie/images, pixie/masks, pixie/paints, pixie/paths, strutils, vmath + pixie/fileformats/png, pixie/fileformats/ppm, pixie/fileformats/qoi, + 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 type FileFormat* = enum - ffPng, ffBmp, ffJpg, ffGif, ffQoi + ffPng, ffBmp, ffJpg, ffGif, ffQoi, ffPpm converter autoStraightAlpha*(c: ColorRGBX): ColorRGBA {.inline, raises: [].} = ## 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) elif data.len > (14+8) and data.readStr(0, 4) == qoiSignature: decodeQoi(data) + elif data.len > 9 and data.readStr(0, 2) in ppmSignatures: + decodePpm(data) else: raise newException(PixieError, "Unsupported image file format") @@ -69,6 +72,8 @@ proc encodeImage*(image: Image, fileFormat: FileFormat): string {.raises: [Pixie image.encodeQoi() of ffGif: raise newException(PixieError, "Unsupported file format") + of ffPpm: + image.encodePpm() proc encodeMask*(mask: Mask, fileFormat: FileFormat): string {.raises: [PixieError].} = ## Encodes a mask into memory. @@ -85,6 +90,7 @@ proc writeFile*(image: Image, filePath: string) {.raises: [PixieError].} = of ".bmp": ffBmp of ".jpg", ".jpeg": ffJpg of ".qoi": ffQoi + of ".ppm": ffPpm else: raise newException(PixieError, "Unsupported file extension") @@ -100,6 +106,7 @@ proc writeFile*(mask: Mask, filePath: string) {.raises: [PixieError].} = of ".bmp": ffBmp of ".jpg", ".jpeg": ffJpg of ".qoi": ffQoi + of ".ppm": ffPpm else: raise newException(PixieError, "Unsupported file extension") diff --git a/src/pixie/fileformats/ppm.nim b/src/pixie/fileformats/ppm.nim new file mode 100644 index 0000000..7562c18 --- /dev/null +++ b/src/pixie/fileformats/ppm.nim @@ -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) diff --git a/tests/all.nim b/tests/all.nim index 69850f8..03ecbcf 100644 --- a/tests/all.nim +++ b/tests/all.nim @@ -10,6 +10,7 @@ import test_paints, test_paths, test_png, + test_ppm, test_qoi, test_svg, ../examples/text, diff --git a/tests/fileformats/ppm/feep.p3.hidepth.master.ppm b/tests/fileformats/ppm/feep.p3.hidepth.master.ppm new file mode 100644 index 0000000..4678865 --- /dev/null +++ b/tests/fileformats/ppm/feep.p3.hidepth.master.ppm @@ -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 diff --git a/tests/fileformats/ppm/feep.p3.hidepth.ppm b/tests/fileformats/ppm/feep.p3.hidepth.ppm new file mode 100644 index 0000000..d2cbd0d Binary files /dev/null and b/tests/fileformats/ppm/feep.p3.hidepth.ppm differ diff --git a/tests/fileformats/ppm/feep.p3.master.ppm b/tests/fileformats/ppm/feep.p3.master.ppm new file mode 100644 index 0000000..221e752 --- /dev/null +++ b/tests/fileformats/ppm/feep.p3.master.ppm @@ -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 diff --git a/tests/fileformats/ppm/feep.p3.ppm b/tests/fileformats/ppm/feep.p3.ppm new file mode 100644 index 0000000..d2cbd0d Binary files /dev/null and b/tests/fileformats/ppm/feep.p3.ppm differ diff --git a/tests/fileformats/ppm/feep.p6.master.ppm b/tests/fileformats/ppm/feep.p6.master.ppm new file mode 100644 index 0000000..d2cbd0d Binary files /dev/null and b/tests/fileformats/ppm/feep.p6.master.ppm differ diff --git a/tests/fileformats/ppm/feep.p6.ppm b/tests/fileformats/ppm/feep.p6.ppm new file mode 100644 index 0000000..d2cbd0d Binary files /dev/null and b/tests/fileformats/ppm/feep.p6.ppm differ diff --git a/tests/test_ppm.nim b/tests/test_ppm.nim new file mode 100644 index 0000000..d041c12 --- /dev/null +++ b/tests/test_ppm.nim @@ -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