diff --git a/bindings/bindings.nim b/bindings/bindings.nim index 83e0758..b47c7f6 100644 --- a/bindings/bindings.nim +++ b/bindings/bindings.nim @@ -93,6 +93,9 @@ exportObject ColorStop: exportObject TextMetrics: discard +exportObject ImageDimensions: + discard + exportSeq seq[float32]: discard @@ -310,7 +313,10 @@ exportRefObject Context: isPointInStroke(Context, Path, float32, float32) exportProcs: + decodeImage + decodeImageDimensions readImage + readImageDimensions readmask readTypeface readFont diff --git a/pixie.nimble b/pixie.nimble index c14ed57..fa8ec21 100644 --- a/pixie.nimble +++ b/pixie.nimble @@ -8,7 +8,7 @@ srcDir = "src" requires "nim >= 1.4.8" requires "vmath >= 1.1.4" requires "chroma >= 0.2.5" -requires "zippy >= 0.9.9" +requires "zippy >= 0.9.11" requires "flatty >= 0.3.3" requires "nimsimd >= 1.0.0" requires "bumpy >= 1.1.1" diff --git a/src/pixie.nim b/src/pixie.nim index 6fc8608..2b31dfb 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -18,6 +18,25 @@ converter autoPremultipliedAlpha*(c: ColorRGBA): ColorRGBX {.inline, raises: []. ## Convert a straight alpha RGBA to a premultiplied alpha RGBA. c.rgbx() +proc decodeImageDimensions*( + data: string +): ImageDimensions {.raises: [PixieError].} = + ## Decodes an image's dimensions from memory. + if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): + decodePngDimensions(data) + elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): + decodeJpegDimensions(data) + elif data.len > 2 and data.readStr(0, 2) == bmpSignature: + decodeBmpDimensions(data) + elif data.len > 6 and data.readStr(0, 6) in gifSignatures: + decodeGifDimensions(data) + elif data.len > (14+8) and data.readStr(0, 4) == qoiSignature: + decodeQoiDimensions(data) + elif data.len > 9 and data.readStr(0, 2) in ppmSignatures: + decodePpmDimensions(data) + else: + raise newException(PixieError, "Unsupported image file format") + proc decodeImage*(data: string): Image {.raises: [PixieError].} = ## Loads an image from memory. if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): @@ -45,6 +64,15 @@ proc decodeMask*(data: string): Mask {.raises: [PixieError].} = else: raise newException(PixieError, "Unsupported mask file format") +proc readImageDimensions*( + filePath: string +): ImageDimensions {.inline, raises: [PixieError].} = + ## Loads an image from a file. + try: + decodeImageDimensions(readFile(filePath)) + except IOError as e: + raise newException(PixieError, e.msg, e) + proc readImage*(filePath: string): Image {.inline, raises: [PixieError].} = ## Loads an image from a file. try: diff --git a/src/pixie/common.nim b/src/pixie/common.nim index 73c7e7a..7441461 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -28,6 +28,9 @@ type SubtractMaskBlend ## Inverse mask ExcludeMaskBlend + ImageDimensions* = object + width*, height*: int + proc mix*(a, b: uint8, t: float32): uint8 {.inline, raises: [].} = ## Linearly interpolate between a and b using t. let t = round(t * 255).uint32 diff --git a/src/pixie/fileformats/bmp.nim b/src/pixie/fileformats/bmp.nim index 9f7cc52..44e7f7c 100644 --- a/src/pixie/fileformats/bmp.nim +++ b/src/pixie/fileformats/bmp.nim @@ -227,6 +227,21 @@ proc decodeBmp*(data: string): Image {.raises: [PixieError].} = decodeDib(data[14].unsafeAddr, data.len - 14) +proc decodeBmpDimensions*( + data: string +): ImageDimensions {.raises: [PixieError].} = + ## Decodes the BMP dimensions. + + if data.len < 26: + failInvalid() + + # BMP Header + if data[0 .. 1] != "BM": + failInvalid() + + result.width = data.readInt32(18).int + result.height = abs(data.readInt32(22)).int + proc encodeBmp*(image: Image): string {.raises: [].} = ## Encodes an image into the BMP file format. diff --git a/src/pixie/fileformats/gif.nim b/src/pixie/fileformats/gif.nim index 1b89302..a8dcd8e 100644 --- a/src/pixie/fileformats/gif.nim +++ b/src/pixie/fileformats/gif.nim @@ -178,5 +178,18 @@ proc decodeGif*(data: string): Image {.raises: [PixieError].} = else: raise newException(PixieError, "Invalid GIF block type") +proc decodeGifDimensions*( + data: string +): ImageDimensions {.raises: [PixieError].} = + ## Decodes the GIF dimensions. + if data.len < 10: + failInvalid() + + if data[0 .. 5] notin gifSignatures: + raise newException(PixieError, "Invalid GIF file signature") + + result.width = data.readInt16(6).int + result.height = data.readInt16(8).int + when defined(release): {.pop.} diff --git a/src/pixie/fileformats/jpeg.nim b/src/pixie/fileformats/jpeg.nim index 22bf277..c91fcf9 100644 --- a/src/pixie/fileformats/jpeg.nim +++ b/src/pixie/fileformats/jpeg.nim @@ -68,6 +68,7 @@ type len, pos: int bitsBuffered: int bitBuffer: uint32 + foundSOF: bool imageHeight, imageWidth: int progressive: bool quantizationTables: array[4, array[64, uint8]] @@ -243,6 +244,10 @@ proc decodeDHT(state: var DecoderState) = proc decodeSOF0(state: var DecoderState) = ## Decode start of Frame + if state.foundSOF: + failInvalid() + state.foundSOF = true + var len = state.readUint16be().int - 2 let precision = state.readUint8() @@ -1056,7 +1061,7 @@ proc decodeJpeg*(data: string): Image {.raises: [PixieError].} = state.decodeDHT() of 0xD8: # SOI - Start of Image - continue + discard of 0xD9: # EOI - End of Image break @@ -1094,5 +1099,63 @@ proc decodeJpeg*(data: string): Image {.raises: [PixieError].} = state.buildImage() +proc decodeJpegDimensions*( + data: string +): ImageDimensions {.raises: [PixieError].} = + ## Decodes the JPEG dimensions. + + var state = DecoderState() + state.buffer = cast[ptr UncheckedArray[uint8]](data.cstring) + state.len = data.len + + while true: + if state.readUint8() != 0xFF: + failInvalid("invalid chunk marker") + + let chunkId = state.readUint8() + case chunkId: + of 0xD8: + # SOI - Start of Image + discard + of 0xC0: + # Start Of Frame (Baseline DCT) + state.decodeSOF0() + break + of 0xC1: + # Start Of Frame (Extended sequential DCT) + state.decodeSOF1() + break + of 0xC2: + # Start Of Frame (Progressive DCT) + state.decodeSOF2() + break + of 0xDB: + # Define Quantization Table(s) + state.skipChunk() + of 0XE0: + # Application-specific + state.skipChunk() + of 0xE1: + # Exif/APP1 + state.decodeExif() + of 0xE2..0xEF: + # Application-specific + state.skipChunk() + of 0xFE: + # Comment + state.skipChunk() + else: + failInvalid("invalid chunk " & chunkId.toHex()) + + case state.orientation: + of 0, 1, 2, 3, 4: + result.width = state.imageWidth + result.height = state.imageHeight + of 5, 6, 7, 8: + result.width = state.imageHeight + result.height = state.imageWidth + else: + failInvalid("invalid orientation") + when defined(release): {.pop.} diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index 30e3442..eb2abc9 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -90,7 +90,7 @@ proc decodePalette(data: string): seq[ColorRGB] = result.setLen(data.len div 3) for i in 0 ..< data.len div 3: - result[i] = cast[ptr ColorRGB](data[i * 3].unsafeAddr)[] + copyMem(result[i].addr, data[i * 3].unsafeAddr, 3) proc unfilter( uncompressed: string, height, rowBytes, bpp: int @@ -262,21 +262,20 @@ proc decodeImageData( # While we can read an extra byte safely, do so. Much faster. for i in 0 ..< header.height * header.width - 1: - var rgba = cast[ptr ColorRGBA](unfiltered[i * 3].unsafeAddr)[] - rgba.a = 255 - if rgba == special: - rgba.a = 0 - result[i] = rgba + copyMem(result[i].addr, unfiltered[i * 3].unsafeAddr, 4) + result[i].a = 255 + if result[i] == special: + result[i].a = 0 else: # While we can read an extra byte safely, do so. Much faster. + # var rgba: ColorRGBA for i in 0 ..< header.height * header.width - 1: - var rgba = cast[ptr ColorRGBA](unfiltered[i * 3].unsafeAddr)[] - rgba.a = 255 - result[i] = rgba + copyMem(result[i].addr, unfiltered[i * 3].unsafeAddr, 4) + result[i].a = 255 - let - lastOffset = header.height * header.width - 1 - rgb = cast[ptr array[3, uint8]](unfiltered[lastOffset * 3].unsafeAddr)[] + let lastOffset = header.height * header.width - 1 + var rgb: array[3, uint8] + copyMem(rgb.addr, unfiltered[lastOffset * 3].unsafeAddr, 3) var rgba = ColorRGBA(r: rgb[0], g: rgb[1], b: rgb[2], a: 255) if rgba == special: rgba.a = 0 @@ -342,6 +341,26 @@ proc newImage*(png: Png): Image {.raises: [PixieError].} = copyMem(result.data[0].addr, png.data[0].addr, png.data.len * 4) result.data.toPremultipliedAlpha() +proc decodePngDimensions*( + data: string +): ImageDimensions {.raises: [PixieError].} = + ## Decodes the PNG dimensions. + if data.len < (8 + (8 + 13 + 4) + 4): # Magic bytes + IHDR + IEND + failInvalid() + + # PNG file signature + let signature = cast[array[8, uint8]](data.readUint64(0)) + if signature != pngSignature: + failInvalid() + + # First chunk must be IHDR + if data.readUint32(8).swap() != 13 or data.readStr(12, 4) != "IHDR": + failInvalid() + + let header = decodeHeader(data[16 ..< 16 + 13]) + result.width = header.width + result.height = header.height + proc decodePng*(data: string): Png {.raises: [PixieError].} = ## Decodes the PNG data. if data.len < (8 + (8 + 13 + 4) + 4): # Magic bytes + IHDR + IEND diff --git a/src/pixie/fileformats/ppm.nim b/src/pixie/fileformats/ppm.nim index 23c849f..104ca3c 100644 --- a/src/pixie/fileformats/ppm.nim +++ b/src/pixie/fileformats/ppm.nim @@ -135,6 +135,14 @@ proc decodePpm*(data: string): Image {.raises: [PixieError].} = else: decodeP6Data(data[header.dataOffset .. ^1], header.maxVal) +proc decodePpmDimensions*( + data: string +): ImageDimensions {.raises: [PixieError].} = + ## Decodes the PPM dimensions. + let header = decodeHeader(data) + result.width = header.width + result.height = header.height + proc encodePpm*(image: Image): string {.raises: [].} = ## Encodes an image into the PPM file format (version P6). diff --git a/src/pixie/fileformats/qoi.nim b/src/pixie/fileformats/qoi.nim index bc6c7f9..1d6fa4e 100644 --- a/src/pixie/fileformats/qoi.nim +++ b/src/pixie/fileformats/qoi.nim @@ -121,6 +121,16 @@ proc decodeQoi*(data: string): Qoi {.raises: [PixieError].} = raise newException(PixieError, "Invalid QOI padding") inc(p) +proc decodeQoiDimensions*( + data: string +): ImageDimensions {.raises: [PixieError].} = + ## Decodes the QOI dimensions. + if data.len <= 12 or data[0 .. 3] != qoiSignature: + raise newException(PixieError, "Invalid QOI header") + + result.width = data.readUint32(4).swap().int + result.height = data.readUint32(8).swap().int + proc encodeQoi*(qoi: Qoi): string {.raises: [PixieError].} = ## Encodes raw QOI pixels to the QOI file format. diff --git a/src/pixie/images.nim b/src/pixie/images.nim index a99c62d..9605bff 100644 --- a/src/pixie/images.nim +++ b/src/pixie/images.nim @@ -304,14 +304,13 @@ proc minifyBy2*(image: Image, power = 1): Image {.raises: [PixieError].} = b = src.unsafe[x * 2 + 1, y * 2 + 0] c = src.unsafe[x * 2 + 1, y * 2 + 1] d = src.unsafe[x * 2 + 0, y * 2 + 1] - rgba = rgbx( + mixed = rgbx( ((a.r.uint32 + b.r + c.r + d.r) div 4).uint8, ((a.g.uint32 + b.g + c.g + d.g) div 4).uint8, ((a.b.uint32 + b.b + c.b + d.b) div 4).uint8, ((a.a.uint32 + b.a + c.a + d.a) div 4).uint8 ) - - result.unsafe[x, y] = rgba + result.unsafe[x, y] = mixed if srcWidthIsOdd: let rgbx = mix( @@ -382,6 +381,8 @@ proc magnifyBy2*(image: Image, power = 1): Image {.raises: [PixieError].} = proc applyOpacity*(target: Image | Mask, opacity: float32) {.raises: [].} = ## Multiplies alpha of the image by opacity. let opacity = round(255 * opacity).uint16 + if opacity == 255: + return if opacity == 0: when type(target) is Image: @@ -434,12 +435,12 @@ proc applyOpacity*(target: Image | Mask, opacity: float32) {.raises: [].} = when type(target) is Image: for j in i div 4 ..< target.data.len: - var rgba = target.data[j] - rgba.r = ((rgba.r * opacity) div 255).uint8 - rgba.g = ((rgba.g * opacity) div 255).uint8 - rgba.b = ((rgba.b * opacity) div 255).uint8 - rgba.a = ((rgba.a * opacity) div 255).uint8 - target.data[j] = rgba + var rgbx = target.data[j] + rgbx.r = ((rgbx.r * opacity) div 255).uint8 + rgbx.g = ((rgbx.g * opacity) div 255).uint8 + rgbx.b = ((rgbx.b * opacity) div 255).uint8 + rgbx.a = ((rgbx.a * opacity) div 255).uint8 + target.data[j] = rgbx else: for j in i ..< target.data.len: target.data[j] = ((target.data[j] * opacity) div 255).uint8 diff --git a/src/pixie/internal.nim b/src/pixie/internal.nim index ead70e4..e5cb53e 100644 --- a/src/pixie/internal.nim +++ b/src/pixie/internal.nim @@ -71,11 +71,11 @@ proc fillUnsafe*( else: when sizeof(int) == 8: # Fill 8 bytes at a time when possible - let + var u32 = cast[uint32](rgbx) u64 = cast[uint64]([u32, u32]) for _ in 0 ..< len div 2: - cast[ptr uint64](data[i].addr)[] = u64 + copyMem(data[i].addr, u64.addr, 8) i += 2 # Fill whatever is left the slow way for j in i ..< start + len: diff --git a/tests/benchmark_gif.nim b/tests/benchmark_gif.nim index 78bc373..3100a90 100644 --- a/tests/benchmark_gif.nim +++ b/tests/benchmark_gif.nim @@ -2,5 +2,5 @@ import benchy, pixie/fileformats/gif let data = readFile("tests/fileformats/gif/audrey.gif") -timeIt "pixie decode": - keep decodeGif(data) +timeIt "gif decode": + discard decodeGif(data) diff --git a/tests/test_bmp.nim b/tests/test_bmp.nim index 6eeeaef..157d6b6 100644 --- a/tests/test_bmp.nim +++ b/tests/test_bmp.nim @@ -1,4 +1,4 @@ -import chroma, os, pixie, pixie/fileformats/bmp, strutils +import os, pixie/fileformats/bmp # block: # var image = newImage(4, 2) @@ -32,9 +32,9 @@ import chroma, os, pixie, pixie/fileformats/bmp, strutils block: for bits in [32, 24]: - let image = decodeBmp(readFile( - "tests/fileformats/bmp/knight." & $bits & ".master.bmp" - )) + let + path = "tests/fileformats/bmp/knight." & $bits & ".master.bmp" + image = decodeBmp(readFile(path)) writeFile("tests/fileformats/bmp/knight." & $bits & ".bmp", encodeBmp(image)) block: @@ -46,5 +46,9 @@ block: block: for file in walkFiles("tests/fileformats/bmp/bmpsuite/*"): # echo file - let image = decodeBmp(readFile(file)) + let + image = decodeBmp(readFile(file)) + dimensions = decodeBmpDimensions(readFile(file)) #image.writeFile(file.replace("bmpsuite", "output") & ".png") + doAssert image.width == dimensions.width + doAssert image.height == dimensions.height diff --git a/tests/test_gif.nim b/tests/test_gif.nim index ceb811b..7d0581e 100644 --- a/tests/test_gif.nim +++ b/tests/test_gif.nim @@ -1,13 +1,37 @@ import pixie, pixie/fileformats/gif -var img = decodeGIF(readFile("tests/fileformats/gif/3x5.gif")) -img.writeFile("tests/fileformats/gif/3x5.png") +block: + let + path = "tests/fileformats/gif/3x5.gif" + image = decodeGIF(readFile(path)) + dimensions = decodeGifDimensions(readFile(path)) + image.writeFile("tests/fileformats/gif/3x5.png") + doAssert image.width == dimensions.width + doAssert image.height == dimensions.height -var img2 = decodeGIF(readFile("tests/fileformats/gif/audrey.gif")) -img2.writeFile("tests/fileformats/gif/audrey.png") +block: + let + path = "tests/fileformats/gif/audrey.gif" + image = decodeGIF(readFile(path)) + dimensions = decodeGifDimensions(readFile(path)) + image.writeFile("tests/fileformats/gif/audrey.png") + doAssert image.width == dimensions.width + doAssert image.height == dimensions.height -var img3 = decodeGIF(readFile("tests/fileformats/gif/sunflower.gif")) -img3.writeFile("tests/fileformats/gif/sunflower.png") +block: + let + path = "tests/fileformats/gif/sunflower.gif" + image = decodeGIF(readFile(path)) + dimensions = decodeGifDimensions(readFile(path)) + image.writeFile("tests/fileformats/gif/sunflower.png") + doAssert image.width == dimensions.width + doAssert image.height == dimensions.height -var img4 = readImage("tests/fileformats/gif/sunflower.gif") -doAssert img3.data == img4.data +block: + let + path = "tests/fileformats/gif/sunflower.gif" + image = decodeGIF(readFile(path)) + dimensions = decodeGifDimensions(readFile(path)) + image.writeFile("tests/fileformats/gif/sunflower.png") + doAssert image.width == dimensions.width + doAssert image.height == dimensions.height diff --git a/tests/test_images.nim b/tests/test_images.nim index 2e0fcd6..12e7d67 100644 --- a/tests/test_images.nim +++ b/tests/test_images.nim @@ -203,6 +203,17 @@ block: image.fill(rgba(255, 255, 255, 255)) doAssert not image.isTransparent() +block: + let image = newImage(100, 100) + image.fill(rgba(255, 255, 255, 255)) + doAssert image.isOpaque() + +block: + let image = newImage(100, 100) + image.fill(rgba(255, 255, 255, 255)) + image[9, 13] = rgbx(250, 250, 250, 250) + doAssert not image.isOpaque() + block: let a = newImage(400, 400) let b = newImage(156, 434) diff --git a/tests/test_jpeg.nim b/tests/test_jpeg.nim index d8750c6..680e404 100644 --- a/tests/test_jpeg.nim +++ b/tests/test_jpeg.nim @@ -1,4 +1,8 @@ -import jpegsuite, pixie +import jpegsuite, pixie, pixie/fileformats/jpeg for file in jpegSuiteFiles: - let img = readImage(file) + let + image = readImage(file) + dimensions = decodeJpegDimensions(readFile(file)) + doAssert image.width == dimensions.width + doAssert image.height == dimensions.height diff --git a/tests/test_png.nim b/tests/test_png.nim index 0b61060..9e158f9 100644 --- a/tests/test_png.nim +++ b/tests/test_png.nim @@ -33,3 +33,9 @@ block: block: discard readImage("tests/fileformats/png/trailing_data.png") + +block: + let dimensions = + decodeImageDimensions(readFile("tests/fileformats/png/mandrill.png")) + doAssert dimensions.width == 512 + doAssert dimensions.height == 512 diff --git a/tests/test_ppm.nim b/tests/test_ppm.nim index 578b85d..f668edf 100644 --- a/tests/test_ppm.nim +++ b/tests/test_ppm.nim @@ -2,13 +2,22 @@ import pixie/fileformats/ppm block: for format in @["p3", "p6"]: - let image = decodePpm(readFile( - "tests/fileformats/ppm/feep." & $format & ".master.ppm" - )) + let + path = "tests/fileformats/ppm/feep." & $format & ".master.ppm" + image = decodePpm(readFile(path)) + dimensions = decodePpmDimensions(readFile(path)) writeFile("tests/fileformats/ppm/feep." & $format & ".ppm", encodePpm(image)) + doAssert image.width == dimensions.width + doAssert image.height == dimensions.height - let image = decodePpm(readFile("tests/fileformats/ppm/feep.p3.hidepth.master.ppm")) +block: + let + path = "tests/fileformats/ppm/feep.p3.hidepth.master.ppm" + image = decodePpm(readFile(path)) + dimensions = decodePpmDimensions(readFile(path)) writeFile("tests/fileformats/ppm/feep.p3.hidepth.ppm", encodePpm(image)) + doAssert image.width == dimensions.width + doAssert image.height == dimensions.height # produced output should be identical to P6 master let p6Master = readFile("tests/fileformats/ppm/feep.p6.master.ppm") diff --git a/tests/test_qoi.nim b/tests/test_qoi.nim index b8acd0d..8949fd0 100644 --- a/tests/test_qoi.nim +++ b/tests/test_qoi.nim @@ -4,14 +4,19 @@ const tests = ["testcard", "testcard_rgba"] for name in tests: let - input = readImage("tests/fileformats/qoi/" & name & ".qoi") + path = "tests/fileformats/qoi/" & name & ".qoi" + input = readImage(path) control = readImage("tests/fileformats/qoi/" & name & ".png") + dimensions = decodeQoiDimensions(readFile(path)) doAssert input.data == control.data, "input mismatch of " & name + doAssert input.width == dimensions.width + doAssert input.height == dimensions.height discard encodeQoi(control) for name in tests: let - input = decodeQoi(readFile("tests/fileformats/qoi/" & name & ".qoi")) + path = "tests/fileformats/qoi/" & name & ".qoi" + input = decodeQoi(readFile(path)) output = decodeQoi(encodeQoi(input)) doAssert output.data.len == input.data.len doAssert output.data == input.data