From 9d060309db0c22ee2eb217d99bc26ecb53f919b1 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sun, 14 Jun 2026 12:08:23 +0200 Subject: [PATCH 01/29] Add low-memory JPEG decode path --- src/pixie/fileformats/jpeg.nim | 406 +++++++++++++++++++++++++++------ 1 file changed, 331 insertions(+), 75 deletions(-) diff --git a/src/pixie/fileformats/jpeg.nim b/src/pixie/fileformats/jpeg.nim index ebd2b539..ce877f57 100644 --- a/src/pixie/fileformats/jpeg.nim +++ b/src/pixie/fileformats/jpeg.nim @@ -41,6 +41,14 @@ const maxMarkerResync = 64 maxRestartResync = 8192 +when defined(frameosEmbedded): + const + embeddedMaxComponentBlockBytes = 2 * 1024 * 1024 + embeddedMaxComponentMaskBytes = 1200 * 1024 + embeddedMaxProgressiveTotalDecodeBytes = 3 * 1024 * 1024 + embeddedMaxStreamingComponentMaskBytes = 4 * 1024 * 1024 + embeddedMaxStreamingTotalMaskBytes = 5 * 1024 * 1024 + let jpegStartOfImage* = [0xFF.uint8, 0xD8] @@ -88,6 +96,7 @@ type todoBeforeRestart: int eobRun: int hitEnd: bool + streamBaselineBlocks: bool Mask = ref object ## Mask object that holds mask opacity data. @@ -405,6 +414,11 @@ proc decodeSOF0(state: var DecoderState) = len -= 3 * numComponents + when defined(frameosEmbedded): + var + totalBlockBytes: int64 + totalMaskBytes: int64 + for component in state.components.mitems: state.maxXScale = max(state.maxXScale, component.xScale) state.maxYScale = max(state.maxYScale, component.yScale) @@ -428,22 +442,49 @@ proc decodeSOF0(state: var DecoderState) = state.maxXScale - 1 ) div state.maxXScale - # Allocate block data structures. - component.blocks = newSeqWith( - state.numMcuWide * component.yScale, - newSeq[array[64, int16]]( - state.numMcuHigh * component.xScale - ) - ) - component.widthStride = state.numMcuWide * component.yScale * 8 component.heightStride = state.numMcuHigh * component.xScale * 8 + + when defined(frameosEmbedded): + let + blockColumns = state.numMcuWide * component.yScale + blockRows = state.numMcuHigh * component.xScale + blockBytes = blockColumns.int64 * blockRows.int64 * 64'i64 * sizeof(int16).int64 + maskBytes = component.widthStride.int64 * component.heightStride.int64 + totalBlockBytes += blockBytes + totalMaskBytes += maskBytes + if state.streamBaselineBlocks and not state.progressive: + if maskBytes > embeddedMaxStreamingComponentMaskBytes or + totalMaskBytes > embeddedMaxStreamingTotalMaskBytes: + failInvalid( + "JPEG source dimensions need " & $(maskBytes div 1024) & + "K component and " & $(totalMaskBytes div 1024) & + "K total channel buffers; too large for embedded streaming decode" + ) + elif blockBytes > embeddedMaxComponentBlockBytes or + maskBytes > embeddedMaxComponentMaskBytes or + totalBlockBytes + totalMaskBytes > embeddedMaxProgressiveTotalDecodeBytes: + failInvalid( + "JPEG source dimensions need " & $(blockBytes div 1024) & + "K block, " & $(maskBytes div 1024) & + "K mask, and " & $((totalBlockBytes + totalMaskBytes) div 1024) & + "K total decode buffers; too large for embedded decode" + ) + + if not (state.streamBaselineBlocks and not state.progressive): + # Allocate block data structures. + component.blocks = newSeqWith( + state.numMcuWide * component.yScale, + newSeq[array[64, int16]]( + state.numMcuHigh * component.xScale + ) + ) + component.channel = newMask(component.widthStride, component.heightStride) if state.progressive: component.widthCoeff = component.widthStride div 8 component.heightCoeff = component.heightStride div 8 - component.coeff.setLen(component.widthStride * component.heightStride) if len != 0: failInvalid() @@ -1042,6 +1083,41 @@ proc idctBlock(component: var Component, offset: int, data: array[64, int16]) = {.pop.} +proc dequantizeBlock( + state: var DecoderState, comp: int, data: var array[64, int16] +) = + let qTableId = state.components[comp].quantizationTableId + if qTableId.int notin 0 ..< state.quantizationTables.len: + failInvalid() + + when defined(amd64) and allowSimd: + for i in 0 ..< 8: # 8 per pass + var q = mm_loadu_si128(state.quantizationTables[qTableId][i * 8].addr) + q = mm_unpacklo_epi8(q, mm_setzero_si128()) + var v = mm_loadu_si128(data[i * 8].addr) + mm_storeu_si128(data[i * 8].addr, mm_mullo_epi16(v, q)) + else: + for i in 0 ..< 64: + data[i] = cast[int16]( + data[i] * state.quantizationTables[qTableId][i].int32 + ) + +proc dequantizeAndIDCTBlock( + state: var DecoderState, comp, row, column: int, data: var array[64, int16] +) = + state.dequantizeBlock(comp, data) + state.components[comp].idctBlock( + state.components[comp].widthStride * column * 8 + row * 8, + data + ) + +proc decodeRegularBlockIntoChannel( + state: var DecoderState, comp, row, column: int +) = + var data: array[64, int16] + state.decodeRegularBlock(comp, data) + state.dequantizeAndIDCTBlock(comp, row, column, data) + proc decodeBlock(state: var DecoderState, comp, row, column: int) = ## Decodes a block. var data {.byaddr.} = state.components[comp].blocks[row][column] @@ -1110,6 +1186,7 @@ proc checkRestart(state: var DecoderState) = proc decodeBlocks(state: var DecoderState) = ## Decodes scan data blocks that follow a SOS block. + let streamBaselineBlocks = state.streamBaselineBlocks and not state.progressive if state.scanComponents == 1: # Single component pass. let @@ -1118,7 +1195,10 @@ proc decodeBlocks(state: var DecoderState) = h = (state.components[comp].height + 7) div 8 for column in 0 ..< h: for row in 0 ..< w: - state.decodeBlock(comp, row, column) + if streamBaselineBlocks: + state.decodeRegularBlockIntoChannel(comp, row, column) + else: + state.decodeBlock(comp, row, column) state.checkRestart() else: # Interleaved regular component pass. @@ -1130,11 +1210,17 @@ proc decodeBlocks(state: var DecoderState) = let row = (mcuX * state.components[comp].yScale + compX) col = (mcuY * state.components[comp].xScale + compY) - state.decodeBlock(comp, row, col) + if streamBaselineBlocks: + state.decodeRegularBlockIntoChannel(comp, row, col) + else: + state.decodeBlock(comp, row, col) state.checkRestart() proc quantizationAndIDCTPass(state: var DecoderState) = ## Does quantization and IDCT. + if state.streamBaselineBlocks and not state.progressive: + return + for comp in 0 ..< state.components.len: let w = (state.components[comp].width + 7) div 8 @@ -1146,22 +1232,10 @@ proc quantizationAndIDCTPass(state: var DecoderState) = for row in 0 ..< w: var data {.byaddr.} = state.components[comp].blocks[row][column] - when defined(amd64) and allowSimd: - for i in 0 ..< 8: # 8 per pass - var q = mm_loadu_si128(state.quantizationTables[qTableId][i * 8].addr) - q = mm_unpacklo_epi8(q, mm_setzero_si128()) - var v = mm_loadu_si128(data[i * 8].addr) - mm_storeu_si128(data[i * 8].addr, mm_mullo_epi16(v, q)) - else: - for i in 0 ..< 64: - data[i] = cast[int16]( - data[i] * state.quantizationTables[qTableId][i].int32 - ) - - state.components[comp].idctBlock( - state.components[comp].widthStride * column * 8 + row * 8, - data - ) + state.dequantizeAndIDCTBlock(comp, row, column, data) + state.components[comp].blocks = @[] + state.components[comp].coeff = @[] + state.components[comp].lineBuf = @[] proc magnifyXBy2(mask: Mask): Mask = ## Smooth magnify by power of 2 only in the X direction. @@ -1227,6 +1301,90 @@ proc grayScaleToRgbx(gray: uint8): ColorRGBX {.inline.} = ## Takes a single gray scale component output and populates image. rgbx(gray, gray, gray, 255) +proc orientedDimensions(state: DecoderState): tuple[width, height: int] = + case state.orientation: + of 0, 1, 2, 3, 4: + (state.imageWidth, state.imageHeight) + of 5, 6, 7, 8: + (state.imageHeight, state.imageWidth) + else: + failInvalid("invalid orientation") + +proc sourceCoords(state: DecoderState, orientedX, orientedY: int): tuple[x, y: int] = + case state.orientation: + of 0, 1: + (orientedX, orientedY) + of 2: + (state.imageWidth - orientedX - 1, orientedY) + of 3: + (state.imageWidth - orientedX - 1, state.imageHeight - orientedY - 1) + of 4: + (orientedX, state.imageHeight - orientedY - 1) + of 5: + (orientedY, orientedX) + of 6: + (orientedY, state.imageHeight - orientedX - 1) + of 7: + (state.imageWidth - orientedY - 1, state.imageHeight - orientedX - 1) + of 8: + (state.imageWidth - orientedY - 1, orientedX) + else: + failInvalid("invalid orientation") + +proc channelAt( + component: Component, + sourceX, sourceY, sourceWidth, sourceHeight: int +): uint8 {.inline.} = + let + x = min((sourceX * component.width) div sourceWidth, component.width - 1) + y = min((sourceY * component.height) div sourceHeight, component.height - 1) + component.channel.data[component.channel.dataIndex(x, y)] + +proc fillImage(state: var DecoderState, result: Image) = + ## Takes a jpeg image object and fills a target-sized pixie Image from it. + let oriented = state.orientedDimensions() + let + targetWidth = result.width + targetHeight = result.height + + case state.components.len: + of 3: + let + yComponent = state.components[0] + cbComponent = state.components[1] + crComponent = state.components[2] + for y in 0 ..< targetHeight: + let orientedY = min((y * oriented.height) div targetHeight, oriented.height - 1) + for x in 0 ..< targetWidth: + let + orientedX = min((x * oriented.width) div targetWidth, oriented.width - 1) + source = state.sourceCoords(orientedX, orientedY) + result.unsafe[x, y] = yCbCrToRgbx( + yComponent.channelAt(source.x, source.y, state.imageWidth, state.imageHeight), + cbComponent.channelAt(source.x, source.y, state.imageWidth, state.imageHeight), + crComponent.channelAt(source.x, source.y, state.imageWidth, state.imageHeight) + ) + + of 1: + let yComponent = state.components[0] + for y in 0 ..< targetHeight: + let orientedY = min((y * oriented.height) div targetHeight, oriented.height - 1) + for x in 0 ..< targetWidth: + let + orientedX = min((x * oriented.width) div targetWidth, oriented.width - 1) + source = state.sourceCoords(orientedX, orientedY) + result.unsafe[x, y] = grayScaleToRgbx( + yComponent.channelAt(source.x, source.y, state.imageWidth, state.imageHeight) + ) + + else: + failInvalid() + +proc buildImage(state: var DecoderState, targetWidth, targetHeight: int): Image = + ## Takes a jpeg image object and builds a target-sized pixie Image from it. + result = newImage(targetWidth, targetHeight) + state.fillImage(result) + proc buildImage(state: var DecoderState): Image = ## Takes a jpeg image object and builds a pixie Image from it. @@ -1234,28 +1392,57 @@ proc buildImage(state: var DecoderState): Image = case state.components.len: of 3: - for component in state.components.mitems: - while component.yScale < state.maxYScale: - component.channel = component.channel.magnifyXBy2() - component.yScale *= 2 + when defined(frameosEmbedded): + let + yComponent = state.components[0] + cbComponent = state.components[1] + crComponent = state.components[2] + cy = yComponent.channel + cb = cbComponent.channel + cr = crComponent.channel + for y in 0 ..< state.imageHeight: + let + yY = min((y * yComponent.height) div state.imageHeight, yComponent.height - 1) + cbY = min((y * cbComponent.height) div state.imageHeight, cbComponent.height - 1) + crY = min((y * crComponent.height) div state.imageHeight, crComponent.height - 1) + for x in 0 ..< state.imageWidth: + result.unsafe[x, y] = yCbCrToRgbx( + cy.data[cy.dataIndex( + min((x * yComponent.width) div state.imageWidth, yComponent.width - 1), + yY + )], + cb.data[cb.dataIndex( + min((x * cbComponent.width) div state.imageWidth, cbComponent.width - 1), + cbY + )], + cr.data[cr.dataIndex( + min((x * crComponent.width) div state.imageWidth, crComponent.width - 1), + crY + )], + ) + else: + for component in state.components.mitems: + while component.yScale < state.maxYScale: + component.channel = component.channel.magnifyXBy2() + component.yScale *= 2 - while component.xScale < state.maxXScale: - component.channel = component.channel.magnifyYBy2() - component.xScale *= 2 + while component.xScale < state.maxXScale: + component.channel = component.channel.magnifyYBy2() + component.xScale *= 2 - let - cy = state.components[0].channel - cb = state.components[1].channel - cr = state.components[2].channel - for y in 0 ..< state.imageHeight: - var channelIndex = cy.dataIndex(0, y) - for x in 0 ..< state.imageWidth: - result.unsafe[x, y] = yCbCrToRgbx( - cy.data[channelIndex], - cb.data[channelIndex], - cr.data[channelIndex], - ) - inc channelIndex + let + cy = state.components[0].channel + cb = state.components[1].channel + cr = state.components[2].channel + for y in 0 ..< state.imageHeight: + var channelIndex = cy.dataIndex(0, y) + for x in 0 ..< state.imageWidth: + result.unsafe[x, y] = yCbCrToRgbx( + cy.data[channelIndex], + cb.data[channelIndex], + cr.data[channelIndex], + ) + inc channelIndex of 1: let cy = state.components[0].channel @@ -1270,36 +1457,37 @@ proc buildImage(state: var DecoderState): Image = # Do any of the orientation flips from the Exif header. case state.orientation: - of 0, 1: - discard - of 2: - result.flipHorizontal() - of 3: - result.flipVertical() - result.flipHorizontal() - of 4: - result.flipVertical() - of 5: - result.rotate90() - result.flipHorizontal() - of 6: - result.rotate90() - of 7: - result.rotate90() - result.flipVertical() - of 8: - result.rotate90() - result.flipVertical() - result.flipHorizontal() - else: - failInvalid("invalid orientation") - -proc decodeJpeg*(data: string): Image {.raises: [PixieError].} = - ## Decodes the JPEG into an Image. + of 0, 1: + discard + of 2: + result.flipHorizontal() + of 3: + result.flipVertical() + result.flipHorizontal() + of 4: + result.flipVertical() + of 5: + result.rotate90() + result.flipHorizontal() + of 6: + result.rotate90() + of 7: + result.rotate90() + result.flipVertical() + of 8: + result.rotate90() + result.flipVertical() + result.flipHorizontal() + else: + failInvalid("invalid orientation") +proc decodeJpegState( + data: pointer, len: int, streamBaselineBlocks = false +): DecoderState {.raises: [PixieError].} = var state = DecoderState() - state.buffer = cast[ptr UncheckedArray[uint8]](data.cstring) - state.len = data.len + state.buffer = cast[ptr UncheckedArray[uint8]](data) + state.len = len + state.streamBaselineBlocks = streamBaselineBlocks while true: if state.pos >= state.len and state.foundSOS: @@ -1362,9 +1550,77 @@ proc decodeJpeg*(data: string): Image {.raises: [PixieError].} = failInvalid("invalid chunk " & chunkId.toHex()) state.quantizationAndIDCTPass() + state +proc decodeJpeg*(data: string): Image {.raises: [PixieError].} = + ## Decodes the JPEG into an Image. + var state = decodeJpegState(data.cstring, data.len) state.buildImage() +proc decodeJpegScaled*( + data: pointer, len, width, height: int +): Image {.raises: [PixieError].} = + ## Decodes the JPEG directly into a target-sized Image. + var state = decodeJpegState( + data, + len, + when defined(frameosEmbedded): true else: false + ) + state.buildImage(width, height) + +proc decodeJpegScaledInto*( + data: pointer, len: int, target: Image +) {.raises: [PixieError].} = + ## Decodes the JPEG directly into an existing target-sized Image. + if target.isNil or target.width <= 0 or target.height <= 0: + raise newException(PixieError, "Target image width and height must be > 0") + var state = decodeJpegState( + data, + len, + when defined(frameosEmbedded): true else: false + ) + state.fillImage(target) + +proc decodeJpegScaled*( + data: var string, width, height: int +): Image {.raises: [PixieError].} = + ## Decodes the JPEG directly into a target-sized Image. + ## The input string is released before allocating the output image. + var state = decodeJpegState( + data.cstring, + data.len, + when defined(frameosEmbedded): true else: false + ) + data = "" + state.buildImage(width, height) + +proc decodeJpegScaledInto*( + data: var string, target: Image +) {.raises: [PixieError].} = + ## Decodes the JPEG directly into an existing target-sized Image. + ## The input string is released before writing the output image. + if target.isNil or target.width <= 0 or target.height <= 0: + raise newException(PixieError, "Target image width and height must be > 0") + var state = decodeJpegState( + data.cstring, + data.len, + when defined(frameosEmbedded): true else: false + ) + data = "" + state.fillImage(target) + +proc decodeJpegScaled*( + data: string, width, height: int +): Image {.raises: [PixieError].} = + ## Decodes the JPEG directly into a target-sized Image. + decodeJpegScaled(data.cstring, data.len, width, height) + +proc decodeJpegScaledInto*( + data: string, target: Image +) {.raises: [PixieError].} = + ## Decodes the JPEG directly into an existing target-sized Image. + decodeJpegScaledInto(data.cstring, data.len, target) + proc decodeJpegDimensions*( data: pointer, len: int ): ImageDimensions {.raises: [PixieError].} = From 2d99f1d9764147626648e83e13391b22c123e456 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 1 Jul 2026 20:27:47 +0200 Subject: [PATCH 02/29] png scaling --- src/pixie.nim | 139 ++++++++++++++++++++++++++++++++++ src/pixie/fileformats/png.nim | 84 ++++++++++++++++++++ tests/test_png.nim | 23 ++++++ 3 files changed, 246 insertions(+) diff --git a/src/pixie.nim b/src/pixie.nim index e830e082..f5d90594 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -78,6 +78,135 @@ proc decodeImage*(data: string): Image {.raises: [PixieError].} = else: raise newException(PixieError, "Unsupported image file format") +proc validateScaledImageTarget(width, height: int) {.raises: [PixieError].} = + if width <= 0 or width > int32.high.int: + raise newException(PixieError, "Invalid target width") + if height <= 0 or height > int32.high.int: + raise newException(PixieError, "Invalid target height") + +proc validateScaledImageTarget(target: Image) {.raises: [PixieError].} = + if target.isNil: + raise newException(PixieError, "Invalid target Image") + validateScaledImageTarget(target.width, target.height) + +proc copyIntoTarget(target, source: Image) {.raises: [PixieError].} = + if target.width != source.width or target.height != source.height: + raise newException(PixieError, "Image dimensions do not match target") + if target.data.len > 0: + copyMem( + target.data[0].addr, + source.data[0].unsafeAddr, + target.data.len * sizeof(ColorRGBX) + ) + +proc decodeImageScaled*( + data: string, width, height: int +): Image {.raises: [PixieError].} + +proc decodeImageScaled*( + data: var string, width, height: int +): Image {.raises: [PixieError].} + +proc decodeImageScaledInto*( + data: var string, target: Image +): Image {.raises: [PixieError].} + +proc decodeImageScaled*( + data: pointer, len, width, height: int +): Image {.raises: [PixieError].} = + ## Loads an image from memory scaled to the requested dimensions. + validateScaledImageTarget(width, height) + if len > 8 and equalMem(data, pngSignature[0].unsafeAddr, 8): + decodePngScaled(data, len, width, height) + elif len > 2 and equalMem(data, jpegStartOfImage[0].unsafeAddr, 2): + decodeJpegScaled(data, len, width, height) + else: + var copy = newString(len) + if len > 0: + copyMem(addr copy[0], data, len) + decodeImageScaled(copy, width, height) + +proc decodeImageScaled*( + data: string, width, height: int +): Image {.raises: [PixieError].} = + ## Loads an image from memory scaled to the requested dimensions. + validateScaledImageTarget(width, height) + if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): + decodePngScaled(data, width, height) + elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): + decodeJpegScaled(data, width, height) + else: + let image = decodeImage(data) + if image.width == width and image.height == height: + image + else: + image.resize(width, height) + +proc decodeImageScaled*( + data: var string, width, height: int +): Image {.raises: [PixieError].} = + ## Loads an image from memory scaled to the requested dimensions. JPEG + ## releases the source buffer before allocating the destination; PNG releases + ## it after parsing because the PNG stream must be inflated first. + validateScaledImageTarget(width, height) + if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): + decodePngScaled(data, width, height) + elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): + decodeJpegScaled(data, width, height) + else: + let image = decodeImage(data) + data = "" + try: + GC_fullCollect() + except Exception: + discard + if image.width == width and image.height == height: + image + else: + image.resize(width, height) + +proc decodeImageScaledInto*( + data: pointer, len: int, target: Image +): Image {.raises: [PixieError].} = + ## Loads an image from memory scaled into an existing target Image. + validateScaledImageTarget(target) + if len > 8 and equalMem(data, pngSignature[0].unsafeAddr, 8): + decodePngScaledInto(data, len, target) + elif len > 2 and equalMem(data, jpegStartOfImage[0].unsafeAddr, 2): + decodeJpegScaledInto(data, len, target) + else: + var copy = newString(len) + if len > 0: + copyMem(addr copy[0], data, len) + discard decodeImageScaledInto(copy, target) + target + +proc decodeImageScaledInto*( + data: string, target: Image +): Image {.raises: [PixieError].} = + ## Loads an image from memory scaled into an existing target Image. + validateScaledImageTarget(target) + if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): + decodePngScaledInto(data, target) + elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): + decodeJpegScaledInto(data, target) + else: + target.copyIntoTarget(decodeImageScaled(data, target.width, target.height)) + target + +proc decodeImageScaledInto*( + data: var string, target: Image +): Image {.raises: [PixieError].} = + ## Loads an image from memory scaled into an existing target Image. + validateScaledImageTarget(target) + if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): + decodePngScaledInto(data, target) + elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): + decodeJpegScaledInto(data, target) + else: + target.copyIntoTarget(decodeImageScaled(data, target.width, target.height)) + target + proc readImageDimensions*( filePath: string ): ImageDimensions {.inline, raises: [PixieError].} = @@ -94,6 +223,16 @@ proc readImage*(filePath: string): Image {.inline, raises: [PixieError].} = except IOError as e: raise newException(PixieError, e.msg, e) +proc readImageScaled*( + filePath: string, width, height: int +): Image {.inline, raises: [PixieError].} = + ## Loads an image from a file scaled to the requested dimensions. + try: + var data = readFile(filePath) + decodeImageScaled(data, width, height) + except IOError as e: + raise newException(PixieError, e.msg, e) + proc encodeImage*( image: Image, fileFormat: FileFormat ): string {.raises: [PixieError].} = diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index 1cc08781..815e4dda 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -610,6 +610,40 @@ proc convertToImage*(png: Png): Image {.raises: [].} = for i, color in png.data16: result.data[i] = color.toRgba.rgbx() +proc validateScaledPngTarget(width, height: int) {.raises: [PixieError].} = + if width <= 0 or width > int32.high.int: + raise newException(PixieError, "Invalid PNG target width") + if height <= 0 or height > int32.high.int: + raise newException(PixieError, "Invalid PNG target height") + +proc validateScaledPngTarget(target: Image) {.raises: [PixieError].} = + if target.isNil: + raise newException(PixieError, "Invalid PNG target Image") + validateScaledPngTarget(target.width, target.height) + +proc fillImage*(png: Png, target: Image) {.raises: [PixieError].} = + ## Scales a decoded PNG into an existing Image. + validateScaledPngTarget(target) + + if png.data.len > 0: + for y in 0 ..< target.height: + let srcY = min((y * png.height) div target.height, png.height - 1) + for x in 0 ..< target.width: + let srcX = min((x * png.width) div target.width, png.width - 1) + target.unsafe[x, y] = png.data[srcX + srcY * png.width].rgbx() + else: + for y in 0 ..< target.height: + let srcY = min((y * png.height) div target.height, png.height - 1) + for x in 0 ..< target.width: + let srcX = min((x * png.width) div target.width, png.width - 1) + target.unsafe[x, y] = png.data16[srcX + srcY * png.width].toRgba.rgbx() + +proc convertToImage*(png: Png, width, height: int): Image {.raises: [PixieError].} = + ## Converts a PNG into an Image scaled to the requested dimensions. + validateScaledPngTarget(width, height) + result = newImage(width, height) + png.fillImage(result) + proc decodePngDimensions*( data: pointer, len: int ): ImageDimensions {.raises: [PixieError].} = @@ -758,6 +792,56 @@ proc decodePng*(data: string): Png {.inline, raises: [PixieError].} = ## Decodes the PNG data. decodePng(data.cstring, data.len) +proc decodePngScaled*( + data: pointer, len, width, height: int +): Image {.raises: [PixieError].} = + ## Decodes the PNG data into an Image scaled to the requested dimensions. + decodePng(data, len).convertToImage(width, height) + +proc decodePngScaledInto*( + data: pointer, len: int, target: Image +) {.raises: [PixieError].} = + ## Decodes the PNG data into an existing Image. + decodePng(data, len).fillImage(target) + +proc decodePngScaled*( + data: string, width, height: int +): Image {.inline, raises: [PixieError].} = + ## Decodes the PNG data into an Image scaled to the requested dimensions. + decodePngScaled(data.cstring, data.len, width, height) + +proc decodePngScaledInto*( + data: string, target: Image +) {.inline, raises: [PixieError].} = + ## Decodes the PNG data into an existing Image. + decodePngScaledInto(data.cstring, data.len, target) + +proc decodePngScaled*( + data: var string, width, height: int +): Image {.raises: [PixieError].} = + ## Decodes the PNG data into a scaled Image and releases the source string + ## before allocating the target Image. + let png = decodePng(data.cstring, data.len) + data = "" + try: + GC_fullCollect() + except Exception: + discard + png.convertToImage(width, height) + +proc decodePngScaledInto*( + data: var string, target: Image +) {.raises: [PixieError].} = + ## Decodes the PNG data into an existing Image and releases the source string + ## after PNG parsing. + let png = decodePng(data.cstring, data.len) + data = "" + try: + GC_fullCollect() + except Exception: + discard + png.fillImage(target) + proc encodePng*( width, height, channels: int, data: pointer, len: int ): string {.raises: [PixieError].} = diff --git a/tests/test_png.nim b/tests/test_png.nim index 2138cd9a..d9b4ad8a 100644 --- a/tests/test_png.nim +++ b/tests/test_png.nim @@ -29,6 +29,29 @@ block: doAssert image16.width == png16.width doAssert image16.height == png16.height +block: + let source = newImage(16, 8) + let encoded = source.encodePng() + + var scaledData = encoded + let scaled = decodePngScaled(scaledData, 4, 2) + doAssert scaled.width == 4 + doAssert scaled.height == 2 + doAssert scaledData.len == 0 + + var genericData = encoded + let generic = decodeImageScaled(genericData, 5, 3) + doAssert generic.width == 5 + doAssert generic.height == 3 + doAssert genericData.len == 0 + + var targetData = encoded + let target = newImage(3, 2) + doAssert decodeImageScaledInto(targetData, target) == target + doAssert target.width == 3 + doAssert target.height == 2 + doAssert targetData.len == 0 + block: for channels in 1 .. 4: var data: seq[uint8] From 28406a52294c436ad84ae775f8aebeb99104ca19 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 1 Jul 2026 23:08:26 +0200 Subject: [PATCH 03/29] Decode scaled JPEGs with bounded memory --- src/pixie/fileformats/jpeg.nim | 156 +++++++++++++++++++++++++++------ tests/test_jpeg.nim | 15 ++++ 2 files changed, 143 insertions(+), 28 deletions(-) diff --git a/src/pixie/fileformats/jpeg.nim b/src/pixie/fileformats/jpeg.nim index ce877f57..0e6d2f8b 100644 --- a/src/pixie/fileformats/jpeg.nim +++ b/src/pixie/fileformats/jpeg.nim @@ -66,6 +66,7 @@ type yScale, xScale: int width, height: int widthStride, heightStride: int + sampleWidth, sampleHeight: int huffmanDC, huffmanAC: int dcPred: int widthCoeff, heightCoeff: int @@ -97,6 +98,7 @@ type eobRun: int hitEnd: bool streamBaselineBlocks: bool + scaledTargetWidth, scaledTargetHeight: int Mask = ref object ## Mask object that holds mask opacity data. @@ -142,6 +144,15 @@ template failInvalid(reason = "unable to load") = ## Throw exception with a reason. raise newException(PixieError, "Invalid JPEG, " & reason) +proc scaledCeil(value, scale, divisor: int): int {.inline.} = + ((value.int64 * scale.int64 + divisor.int64 - 1) div divisor.int64).int + +proc useScaledChannels(state: DecoderState): bool {.inline.} = + state.streamBaselineBlocks and + not state.progressive and + state.scaledTargetWidth > 0 and + state.scaledTargetHeight > 0 + proc clampByte(x: int32): uint8 {.inline.} = ## Clamp integer into byte range. # clamp(x, 0, 0xFF).uint8 @@ -444,29 +455,55 @@ proc decodeSOF0(state: var DecoderState) = component.widthStride = state.numMcuWide * component.yScale * 8 component.heightStride = state.numMcuHigh * component.xScale * 8 + component.sampleWidth = component.width + component.sampleHeight = component.height + + var + channelWidth = component.widthStride + channelHeight = component.heightStride + if state.useScaledChannels(): + let + targetWidth = + if state.orientation in {5, 6, 7, 8}: state.scaledTargetHeight + else: state.scaledTargetWidth + targetHeight = + if state.orientation in {5, 6, 7, 8}: state.scaledTargetWidth + else: state.scaledTargetHeight + component.sampleWidth = max(1, + (targetWidth * component.yScale + state.maxYScale - 1) div state.maxYScale + ) + component.sampleHeight = max(1, + (targetHeight * component.xScale + state.maxXScale - 1) div state.maxXScale + ) + channelWidth = component.sampleWidth + channelHeight = component.sampleHeight when defined(frameosEmbedded): let blockColumns = state.numMcuWide * component.yScale blockRows = state.numMcuHigh * component.xScale blockBytes = blockColumns.int64 * blockRows.int64 * 64'i64 * sizeof(int16).int64 - maskBytes = component.widthStride.int64 * component.heightStride.int64 + sourceMaskBytes = component.widthStride.int64 * component.heightStride.int64 + channelBytes = channelWidth.int64 * channelHeight.int64 + maskBytes = + if state.useScaledChannels(): channelBytes + else: sourceMaskBytes totalBlockBytes += blockBytes totalMaskBytes += maskBytes if state.streamBaselineBlocks and not state.progressive: - if maskBytes > embeddedMaxStreamingComponentMaskBytes or + if channelBytes > embeddedMaxStreamingComponentMaskBytes or totalMaskBytes > embeddedMaxStreamingTotalMaskBytes: failInvalid( - "JPEG source dimensions need " & $(maskBytes div 1024) & + "JPEG scaled dimensions need " & $(channelBytes div 1024) & "K component and " & $(totalMaskBytes div 1024) & "K total channel buffers; too large for embedded streaming decode" ) elif blockBytes > embeddedMaxComponentBlockBytes or - maskBytes > embeddedMaxComponentMaskBytes or + sourceMaskBytes > embeddedMaxComponentMaskBytes or totalBlockBytes + totalMaskBytes > embeddedMaxProgressiveTotalDecodeBytes: failInvalid( "JPEG source dimensions need " & $(blockBytes div 1024) & - "K block, " & $(maskBytes div 1024) & + "K block, " & $(sourceMaskBytes div 1024) & "K mask, and " & $((totalBlockBytes + totalMaskBytes) div 1024) & "K total decode buffers; too large for embedded decode" ) @@ -480,7 +517,7 @@ proc decodeSOF0(state: var DecoderState) = ) ) - component.channel = newMask(component.widthStride, component.heightStride) + component.channel = newMask(channelWidth, channelHeight) if state.progressive: component.widthCoeff = component.widthStride div 8 @@ -1005,8 +1042,8 @@ template idct1D(s0, s1, s2, s3, s4, s5, s6, s7: int32) = {.push overflowChecks: off, rangeChecks: off.} -proc idctBlock(component: var Component, offset: int, data: array[64, int16]) = - ## Inverse discrete cosine transform whole block. +proc idctBlockPixels(data: array[64, int16]): array[64, uint8] = + ## Inverse discrete cosine transform for one 8x8 block. var values: array[64, int32] for i in 0 ..< 8: if data[i + 8] == 0 and @@ -1053,7 +1090,7 @@ proc idctBlock(component: var Component, offset: int, data: array[64, int16]) = for i in 0 ..< 8: let valuesPos = i * 8 - outPos = i * component.widthStride + offset + outPos = i * 8 var t0, t1, t2, t3, p1, p2, p3, p4, p5, x0, x1, x2, x3: int32 idct1D( @@ -1072,14 +1109,54 @@ proc idctBlock(component: var Component, offset: int, data: array[64, int16]) = x2 += 65536 + (128 shl 17) x3 += 65536 + (128 shl 17) - component.channel.data[outPos + 0] = clampByte((x0 + t3) shr 17) - component.channel.data[outPos + 7] = clampByte((x0 - t3) shr 17) - component.channel.data[outPos + 1] = clampByte((x1 + t2) shr 17) - component.channel.data[outPos + 6] = clampByte((x1 - t2) shr 17) - component.channel.data[outPos + 2] = clampByte((x2 + t1) shr 17) - component.channel.data[outPos + 5] = clampByte((x2 - t1) shr 17) - component.channel.data[outPos + 3] = clampByte((x3 + t0) shr 17) - component.channel.data[outPos + 4] = clampByte((x3 - t0) shr 17) + result[outPos + 0] = clampByte((x0 + t3) shr 17) + result[outPos + 7] = clampByte((x0 - t3) shr 17) + result[outPos + 1] = clampByte((x1 + t2) shr 17) + result[outPos + 6] = clampByte((x1 - t2) shr 17) + result[outPos + 2] = clampByte((x2 + t1) shr 17) + result[outPos + 5] = clampByte((x2 - t1) shr 17) + result[outPos + 3] = clampByte((x3 + t0) shr 17) + result[outPos + 4] = clampByte((x3 - t0) shr 17) + +proc idctBlock(component: var Component, offset: int, data: array[64, int16]) = + ## Inverse discrete cosine transform whole block into the source channel. + let pixels = idctBlockPixels(data) + for y in 0 ..< 8: + let + sourcePos = y * 8 + outPos = y * component.widthStride + offset + for x in 0 ..< 8: + component.channel.data[outPos + x] = pixels[sourcePos + x] + +proc idctBlockScaled(component: var Component, row, column: int, data: array[64, int16]) = + ## Inverse discrete cosine transform whole block into a scaled channel. + let + pixels = idctBlockPixels(data) + sourceX0 = row * 8 + sourceY0 = column * 8 + sourceX1 = min(sourceX0 + 8, component.width) + sourceY1 = min(sourceY0 + 8, component.height) + + if sourceX0 >= component.width or sourceY0 >= component.height: + return + + let + targetX0 = scaledCeil(sourceX0, component.sampleWidth, component.width) + targetY0 = scaledCeil(sourceY0, component.sampleHeight, component.height) + targetX1 = min(component.sampleWidth, scaledCeil(sourceX1, component.sampleWidth, component.width)) + targetY1 = min(component.sampleHeight, scaledCeil(sourceY1, component.sampleHeight, component.height)) + + for targetY in targetY0 ..< targetY1: + let + sourceY = min((targetY * component.height) div component.sampleHeight, component.height - 1) + localY = sourceY - sourceY0 + sourcePos = localY * 8 + outPos = targetY * component.channel.width + for targetX in targetX0 ..< targetX1: + let + sourceX = min((targetX * component.width) div component.sampleWidth, component.width - 1) + localX = sourceX - sourceX0 + component.channel.data[outPos + targetX] = pixels[sourcePos + localX] {.pop.} @@ -1106,10 +1183,13 @@ proc dequantizeAndIDCTBlock( state: var DecoderState, comp, row, column: int, data: var array[64, int16] ) = state.dequantizeBlock(comp, data) - state.components[comp].idctBlock( - state.components[comp].widthStride * column * 8 + row * 8, - data - ) + if state.useScaledChannels(): + state.components[comp].idctBlockScaled(row, column, data) + else: + state.components[comp].idctBlock( + state.components[comp].widthStride * column * 8 + row * 8, + data + ) proc decodeRegularBlockIntoChannel( state: var DecoderState, comp, row, column: int @@ -1336,8 +1416,14 @@ proc channelAt( sourceX, sourceY, sourceWidth, sourceHeight: int ): uint8 {.inline.} = let - x = min((sourceX * component.width) div sourceWidth, component.width - 1) - y = min((sourceY * component.height) div sourceHeight, component.height - 1) + sampleWidth = + if component.sampleWidth > 0: component.sampleWidth + else: component.width + sampleHeight = + if component.sampleHeight > 0: component.sampleHeight + else: component.height + x = min((sourceX * sampleWidth) div sourceWidth, sampleWidth - 1) + y = min((sourceY * sampleHeight) div sourceHeight, sampleHeight - 1) component.channel.data[component.channel.dataIndex(x, y)] proc fillImage(state: var DecoderState, result: Image) = @@ -1482,12 +1568,18 @@ proc buildImage(state: var DecoderState): Image = failInvalid("invalid orientation") proc decodeJpegState( - data: pointer, len: int, streamBaselineBlocks = false + data: pointer, + len: int, + streamBaselineBlocks = false, + scaledTargetWidth = 0, + scaledTargetHeight = 0 ): DecoderState {.raises: [PixieError].} = var state = DecoderState() state.buffer = cast[ptr UncheckedArray[uint8]](data) state.len = len state.streamBaselineBlocks = streamBaselineBlocks + state.scaledTargetWidth = scaledTargetWidth + state.scaledTargetHeight = scaledTargetHeight while true: if state.pos >= state.len and state.foundSOS: @@ -1564,7 +1656,9 @@ proc decodeJpegScaled*( var state = decodeJpegState( data, len, - when defined(frameosEmbedded): true else: false + true, + width, + height ) state.buildImage(width, height) @@ -1577,7 +1671,9 @@ proc decodeJpegScaledInto*( var state = decodeJpegState( data, len, - when defined(frameosEmbedded): true else: false + true, + target.width, + target.height ) state.fillImage(target) @@ -1589,7 +1685,9 @@ proc decodeJpegScaled*( var state = decodeJpegState( data.cstring, data.len, - when defined(frameosEmbedded): true else: false + true, + width, + height ) data = "" state.buildImage(width, height) @@ -1604,7 +1702,9 @@ proc decodeJpegScaledInto*( var state = decodeJpegState( data.cstring, data.len, - when defined(frameosEmbedded): true else: false + true, + target.width, + target.height ) data = "" state.fillImage(target) diff --git a/tests/test_jpeg.nim b/tests/test_jpeg.nim index 680e4044..fb78e557 100644 --- a/tests/test_jpeg.nim +++ b/tests/test_jpeg.nim @@ -6,3 +6,18 @@ for file in jpegSuiteFiles: dimensions = decodeJpegDimensions(readFile(file)) doAssert image.width == dimensions.width doAssert image.height == dimensions.height + +block: + var data = readFile("tests/fileformats/jpeg/masters/cat_4_2_0.jpg") + let image = decodeJpegScaled(data, 17, 11) + doAssert image.width == 17 + doAssert image.height == 11 + doAssert data.len == 0 + +block: + var data = readFile("tests/fileformats/jpeg/masters/cat_4_4_4.jpg") + let target = newImage(13, 9) + decodeJpegScaledInto(data, target) + doAssert target.width == 13 + doAssert target.height == 9 + doAssert data.len == 0 From 6392050cc4b4169426e865a742f7d1fc0a8cb644 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Thu, 2 Jul 2026 02:23:11 +0200 Subject: [PATCH 04/29] Memory-aware decoding: runtime budgets, streaming JPEG source, fit modes - pixie/decodebudget: runtime per-decode memory budget (replaces the compile-time frameosEmbedded consts); decoders raise catchable PixieErrors instead of exhausting memory - jpeg: decode plan checked against the budget before any image-sized allocation; progressive JPEGs now use target-sized channel masks; sampling resolution clamps itself to the budget, trading sharpness for a successful decode - jpeg: streaming decode (decodeJpegStreamScaled/Into) pulls the compressed input through a 32K sliding window so files never need to be fully buffered; bit-identical output across the test suite - jpeg: decodeJpegInfo probe + jpegDecodeIntermediateBytes for pre-decode budget planning - jpeg/png: ScaledDecodeFit (stretch/cover/contain) on all scaled decode paths, enabling aspect-correct decode-into-canvas; cover inflates mask sampling density for the cropped region within budget - png: budget check at IHDR; inflated scanlines released before the pixel seq allocation Co-Authored-By: Claude Fable 5 --- src/pixie.nim | 50 ++-- src/pixie/common.nim | 6 + src/pixie/decodebudget.nim | 38 +++ src/pixie/fileformats/jpeg.nim | 531 ++++++++++++++++++++++++++------- src/pixie/fileformats/png.nim | 136 +++++++-- 5 files changed, 600 insertions(+), 161 deletions(-) create mode 100644 src/pixie/decodebudget.nim diff --git a/src/pixie.nim b/src/pixie.nim index f5d90594..1afc36cc 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -100,41 +100,41 @@ proc copyIntoTarget(target, source: Image) {.raises: [PixieError].} = ) proc decodeImageScaled*( - data: string, width, height: int + data: string, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} proc decodeImageScaled*( - data: var string, width, height: int + data: var string, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} proc decodeImageScaledInto*( - data: var string, target: Image + data: var string, target: Image, fit = fitStretch ): Image {.raises: [PixieError].} proc decodeImageScaled*( - data: pointer, len, width, height: int + data: pointer, len, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} = ## Loads an image from memory scaled to the requested dimensions. validateScaledImageTarget(width, height) if len > 8 and equalMem(data, pngSignature[0].unsafeAddr, 8): - decodePngScaled(data, len, width, height) + decodePngScaled(data, len, width, height, fit) elif len > 2 and equalMem(data, jpegStartOfImage[0].unsafeAddr, 2): - decodeJpegScaled(data, len, width, height) + decodeJpegScaled(data, len, width, height, fit) else: var copy = newString(len) if len > 0: copyMem(addr copy[0], data, len) - decodeImageScaled(copy, width, height) + decodeImageScaled(copy, width, height, fit) proc decodeImageScaled*( - data: string, width, height: int + data: string, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} = ## Loads an image from memory scaled to the requested dimensions. validateScaledImageTarget(width, height) if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): - decodePngScaled(data, width, height) + decodePngScaled(data, width, height, fit) elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): - decodeJpegScaled(data, width, height) + decodeJpegScaled(data, width, height, fit) else: let image = decodeImage(data) if image.width == width and image.height == height: @@ -143,16 +143,16 @@ proc decodeImageScaled*( image.resize(width, height) proc decodeImageScaled*( - data: var string, width, height: int + data: var string, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} = ## Loads an image from memory scaled to the requested dimensions. JPEG ## releases the source buffer before allocating the destination; PNG releases ## it after parsing because the PNG stream must be inflated first. validateScaledImageTarget(width, height) if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): - decodePngScaled(data, width, height) + decodePngScaled(data, width, height, fit) elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): - decodeJpegScaled(data, width, height) + decodeJpegScaled(data, width, height, fit) else: let image = decodeImage(data) data = "" @@ -166,43 +166,43 @@ proc decodeImageScaled*( image.resize(width, height) proc decodeImageScaledInto*( - data: pointer, len: int, target: Image + data: pointer, len: int, target: Image, fit = fitStretch ): Image {.raises: [PixieError].} = ## Loads an image from memory scaled into an existing target Image. validateScaledImageTarget(target) if len > 8 and equalMem(data, pngSignature[0].unsafeAddr, 8): - decodePngScaledInto(data, len, target) + decodePngScaledInto(data, len, target, fit) elif len > 2 and equalMem(data, jpegStartOfImage[0].unsafeAddr, 2): - decodeJpegScaledInto(data, len, target) + decodeJpegScaledInto(data, len, target, fit) else: var copy = newString(len) if len > 0: copyMem(addr copy[0], data, len) - discard decodeImageScaledInto(copy, target) + discard decodeImageScaledInto(copy, target, fit) target proc decodeImageScaledInto*( - data: string, target: Image + data: string, target: Image, fit = fitStretch ): Image {.raises: [PixieError].} = ## Loads an image from memory scaled into an existing target Image. validateScaledImageTarget(target) if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): - decodePngScaledInto(data, target) + decodePngScaledInto(data, target, fit) elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): - decodeJpegScaledInto(data, target) + decodeJpegScaledInto(data, target, fit) else: target.copyIntoTarget(decodeImageScaled(data, target.width, target.height)) target proc decodeImageScaledInto*( - data: var string, target: Image + data: var string, target: Image, fit = fitStretch ): Image {.raises: [PixieError].} = ## Loads an image from memory scaled into an existing target Image. validateScaledImageTarget(target) if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): - decodePngScaledInto(data, target) + decodePngScaledInto(data, target, fit) elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): - decodeJpegScaledInto(data, target) + decodeJpegScaledInto(data, target, fit) else: target.copyIntoTarget(decodeImageScaled(data, target.width, target.height)) target @@ -224,12 +224,12 @@ proc readImage*(filePath: string): Image {.inline, raises: [PixieError].} = raise newException(PixieError, e.msg, e) proc readImageScaled*( - filePath: string, width, height: int + filePath: string, width, height: int, fit = fitStretch ): Image {.inline, raises: [PixieError].} = ## Loads an image from a file scaled to the requested dimensions. try: var data = readFile(filePath) - decodeImageScaled(data, width, height) + decodeImageScaled(data, width, height, fit) except IOError as e: raise newException(PixieError, e.msg, e) diff --git a/src/pixie/common.nim b/src/pixie/common.nim index 1d3bed94..35caa504 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -31,6 +31,12 @@ type ImageDimensions* = object width*, height*: int + ScaledDecodeFit* = enum + ## How a scaled decode maps the source onto the target image. + fitStretch ## fill the whole target, ignoring aspect ratio + fitCover ## fill the whole target, cropping the source centered + fitContain ## fit the whole source centered, leaving target borders untouched + Image* = ref object ## Image object that holds bitmap data in premultiplied alpha RGBA format. width*, height*: int diff --git a/src/pixie/decodebudget.nim b/src/pixie/decodebudget.nim new file mode 100644 index 00000000..b86aa6d7 --- /dev/null +++ b/src/pixie/decodebudget.nim @@ -0,0 +1,38 @@ +## Runtime memory budget for image decoding. +## +## Decoders consult `decodeBudgetBytes` before allocating image-sized +## buffers (coefficient blocks, channel masks, inflate output, pixel seqs) +## and raise a catchable PixieError when a decode would exceed it. The +## budget covers decode *intermediates plus output* for a single decode +## call, not process-wide usage. +## +## 0 means unlimited (upstream pixie behaviour). Embedded builds default +## to a conservative budget; hosts default to unlimited until the +## application calls `setDecodeBudgetBytes` with a live value derived from +## available memory. + +when defined(frameosEmbedded): + const defaultDecodeBudgetBytes = 10 * 1024 * 1024 +else: + const defaultDecodeBudgetBytes = 0 + +var decodeBudget {.threadvar.}: int +var decodeBudgetInitialized {.threadvar.}: bool + +proc decodeBudgetBytes*(): int {.inline, raises: [].} = + ## Current per-decode memory budget in bytes; 0 = unlimited. + if not decodeBudgetInitialized: + decodeBudget = defaultDecodeBudgetBytes + decodeBudgetInitialized = true + decodeBudget + +proc setDecodeBudgetBytes*(bytes: int) {.raises: [].} = + ## Sets the per-decode memory budget; 0 = unlimited. Refresh this from + ## live available memory before heavy decodes for best results. + decodeBudget = max(0, bytes) + decodeBudgetInitialized = true + +proc overDecodeBudget*(bytes: int64): bool {.inline, raises: [].} = + ## True when an allocation plan of `bytes` exceeds the current budget. + let budget = decodeBudgetBytes() + budget > 0 and bytes > budget.int64 diff --git a/src/pixie/fileformats/jpeg.nim b/src/pixie/fileformats/jpeg.nim index 0e6d2f8b..0f4357e8 100644 --- a/src/pixie/fileformats/jpeg.nim +++ b/src/pixie/fileformats/jpeg.nim @@ -1,5 +1,5 @@ -import chroma, flatty/binny, ../common, ../images, ../simd, std/decls, - std/sequtils, std/strutils +import chroma, flatty/binny, ../common, ../decodebudget, ../images, ../simd, + std/decls, std/math, std/sequtils, std/strutils # This JPEG decoder is loosely based on stb_image which is public domain. @@ -40,14 +40,8 @@ const ] maxMarkerResync = 64 maxRestartResync = 8192 - -when defined(frameosEmbedded): - const - embeddedMaxComponentBlockBytes = 2 * 1024 * 1024 - embeddedMaxComponentMaskBytes = 1200 * 1024 - embeddedMaxProgressiveTotalDecodeBytes = 3 * 1024 * 1024 - embeddedMaxStreamingComponentMaskBytes = 4 * 1024 * 1024 - embeddedMaxStreamingTotalMaskBytes = 5 * 1024 * 1024 + jpegStreamWindowSize = 32 * 1024 + jpegStreamKeepBehind = 64 let jpegStartOfImage* = [0xFF.uint8, 0xD8] @@ -70,13 +64,25 @@ type huffmanDC, huffmanAC: int dcPred: int widthCoeff, heightCoeff: int + channelWidth, channelHeight: int coeff, lineBuf: seq[uint8] blocks: seq[seq[array[64, int16]]] channel: Mask + JpegSourceProc* = proc( + dst: pointer, maxBytes: int + ): int {.gcsafe, raises: [].} + ## Pull callback for streaming decodes: fill `dst` with up to `maxBytes` + ## sequential input bytes, returning how many were written (<= 0 on EOF + ## or read error — the decode then fails with a catchable PixieError). + DecoderState = object buffer: ptr UncheckedArray[uint8] len, pos: int + readProc: JpegSourceProc ## nil = whole input is in `buffer` + window: seq[uint8] ## sliding window backing `buffer` when streaming + windowStart: int ## absolute input offset of window[0] + windowLen: int ## valid bytes in the window bitsBuffered: int bitBuffer: uint32 foundSOF: bool @@ -99,6 +105,7 @@ type hitEnd: bool streamBaselineBlocks: bool scaledTargetWidth, scaledTargetHeight: int + scaledFit: ScaledDecodeFit Mask = ref object ## Mask object that holds mask opacity data. @@ -148,8 +155,11 @@ proc scaledCeil(value, scale, divisor: int): int {.inline.} = ((value.int64 * scale.int64 + divisor.int64 - 1) div divisor.int64).int proc useScaledChannels(state: DecoderState): bool {.inline.} = + ## Scaled (target-sized) channel masks are used whenever a scaled decode + ## was requested — including progressive JPEGs, whose coefficient blocks + ## must stay source-sized but whose IDCT output can go straight into + ## target-sized masks. state.streamBaselineBlocks and - not state.progressive and state.scaledTargetWidth > 0 and state.scaledTargetHeight > 0 @@ -161,39 +171,97 @@ proc clampByte(x: int32): uint8 {.inline.} = value = cast[uint32](x) and (signBit - 1) min(value, 255).uint8 +proc ensureBytesSlow(state: var DecoderState, absPos, count: int): bool = + ## Streaming path of `ensureBytes`: slide the window forward and refill it + ## from `readProc` so [absPos, absPos+count) becomes resident. + if absPos < state.windowStart: + failInvalid("input stream rewound too far") + let absEnd = absPos + count + var winEnd = state.windowStart + state.windowLen + + # Drop bytes that can no longer be re-read. Never advance past the + # stream position (winEnd): readProc is strictly sequential. + let keepFrom = max(state.windowStart, min(absPos - jpegStreamKeepBehind, winEnd)) + if keepFrom > state.windowStart: + let keepLen = winEnd - keepFrom + if keepLen > 0: + moveMem(state.window[0].addr, state.window[keepFrom - state.windowStart].addr, keepLen) + state.windowStart = keepFrom + state.windowLen = keepLen + + # Grow capacity when a single request spans more than the window. + if absEnd - max(state.windowStart, absPos - jpegStreamKeepBehind) > state.window.len: + state.window.setLen(absEnd - (absPos - jpegStreamKeepBehind)) + + while winEnd < absEnd: + if state.windowLen == state.window.len: + # Long forward skip: cycle bytes out of the front of the window. + let drop = min(state.windowLen, absPos - jpegStreamKeepBehind - state.windowStart) + if drop <= 0: + failInvalid("stream window exhausted") + moveMem(state.window[0].addr, state.window[drop].addr, state.windowLen - drop) + state.windowStart += drop + state.windowLen -= drop + let got = state.readProc( + state.window[state.windowLen].addr, + min(state.window.len - state.windowLen, state.len - winEnd) + ) + if got <= 0: + return false + state.windowLen += got + winEnd += got + state.buffer = cast[ptr UncheckedArray[uint8]](state.window[0].addr) + true + +proc ensureBytes(state: var DecoderState, absPos, count: int): bool {.inline.} = + ## Makes input bytes [absPos, absPos+count) addressable through `at`, + ## returning false when the input ends before that. + if absPos + count > state.len: + return false + if state.readProc == nil or + (absPos >= state.windowStart and + absPos + count <= state.windowStart + state.windowLen): + return true + ensureBytesSlow(state, absPos, count) + +template at(state: DecoderState, absPos: int): uint8 = + ## Reads the input byte at an absolute offset; call `ensureBytes` first. + state.buffer[absPos - state.windowStart] + proc readUint8(state: var DecoderState): uint8 = ## Reads a byte from the input stream. - if state.pos >= state.len: + if not state.ensureBytes(state.pos, 1): failInvalid() - result = state.buffer[state.pos] + result = state.at(state.pos) inc state.pos proc readUint16be(state: var DecoderState): uint16 = ## Reads uint16 big-endian from the input stream. - if state.pos + 2 > state.len: + if not state.ensureBytes(state.pos, 2): failInvalid() result = - (state.buffer[state.pos].uint16 shl 8) or - state.buffer[state.pos + 1] + (state.at(state.pos).uint16 shl 8) or + state.at(state.pos + 1) state.pos += 2 proc readUint32be(state: var DecoderState): uint32 = ## Reads uint32 big-endian from the input stream. - if state.pos + 4 > state.len: + if not state.ensureBytes(state.pos, 4): failInvalid() result = - (state.buffer[state.pos + 0].uint32 shl 24) or - (state.buffer[state.pos + 1].uint32 shl 16) or - (state.buffer[state.pos + 2].uint32 shl 8) or - state.buffer[state.pos + 3] + (state.at(state.pos + 0).uint32 shl 24) or + (state.at(state.pos + 1).uint32 shl 16) or + (state.at(state.pos + 2).uint32 shl 8) or + state.at(state.pos + 3) state.pos += 4 proc readStr(state: var DecoderState, n: int): string = ## Reads n number of bytes as a string. - if state.pos + n > state.len: + if not state.ensureBytes(state.pos, n): failInvalid() result.setLen(n) - copyMem(result[0].addr, state.buffer[state.pos].addr, n) + if n > 0: + copyMem(result[0].addr, state.buffer[state.pos - state.windowStart].addr, n) state.pos += n proc skipBytes(state: var DecoderState, n: int) = @@ -223,7 +291,7 @@ proc readMarker(state: var DecoderState): uint8 = failInvalid("invalid chunk marker") continue - while state.pos < state.len and state.buffer[state.pos] == 0xFF: + while state.ensureBytes(state.pos, 1) and state.at(state.pos) == 0xFF: inc state.pos if state.pos >= state.len: @@ -425,10 +493,9 @@ proc decodeSOF0(state: var DecoderState) = len -= 3 * numComponents - when defined(frameosEmbedded): - var - totalBlockBytes: int64 - totalMaskBytes: int64 + var + totalBlockBytes: int64 + totalMaskBytes: int64 for component in state.components.mitems: state.maxXScale = max(state.maxXScale, component.xScale) @@ -441,6 +508,53 @@ proc decodeSOF0(state: var DecoderState) = state.numMcuHigh = (state.imageHeight + state.mcuHeight - 1) div state.mcuHeight + var + effectiveTargetWidth = state.scaledTargetWidth + effectiveTargetHeight = state.scaledTargetHeight + if state.useScaledChannels(): + if state.scaledFit == fitCover: + # Cover crops the source to the target aspect; inflate the sampling + # resolution so the cropped region still gets target-density masks. + let + orientedWidth = + if state.orientation in {5, 6, 7, 8}: state.imageHeight + else: state.imageWidth + orientedHeight = + if state.orientation in {5, 6, 7, 8}: state.imageWidth + else: state.imageHeight + if orientedWidth.int64 * effectiveTargetHeight.int64 > + effectiveTargetWidth.int64 * orientedHeight.int64: + # Source is wider: the crop discards width, so sample more of it. + effectiveTargetWidth = min(orientedWidth, max(1, ( + effectiveTargetHeight.int64 * orientedWidth.int64 div + max(1'i64, orientedHeight.int64)).int)) + else: + effectiveTargetHeight = min(orientedHeight, max(1, ( + effectiveTargetWidth.int64 * orientedHeight.int64 div + max(1'i64, orientedWidth.int64)).int)) + + # Clamp the sampling resolution to what the memory budget allows, + # trading sharpness for a decode that succeeds. + let budget = decodeBudgetBytes() + if budget > 0: + var blockTotal, maskTotal: int64 + for component in state.components: + if not (state.streamBaselineBlocks and not state.progressive): + blockTotal += (state.numMcuWide * component.yScale).int64 * + (state.numMcuHigh * component.xScale).int64 * 64 * sizeof(int16) + maskTotal += + max(1'i64, (effectiveTargetWidth.int64 * component.yScale.int64 + + state.maxYScale - 1) div state.maxYScale) * + max(1'i64, (effectiveTargetHeight.int64 * component.xScale.int64 + + state.maxXScale - 1) div state.maxXScale) + let maskBudget = budget.int64 - blockTotal + if maskBudget > 0 and maskTotal > maskBudget: + let factor = sqrt(maskBudget.float64 / maskTotal.float64) + effectiveTargetWidth = max(64, + (effectiveTargetWidth.float64 * factor).int) + effectiveTargetHeight = max(64, + (effectiveTargetHeight.float64 * factor).int) + for component in state.components.mitems: component.width = ( state.imageWidth * @@ -464,11 +578,11 @@ proc decodeSOF0(state: var DecoderState) = if state.useScaledChannels(): let targetWidth = - if state.orientation in {5, 6, 7, 8}: state.scaledTargetHeight - else: state.scaledTargetWidth + if state.orientation in {5, 6, 7, 8}: effectiveTargetHeight + else: effectiveTargetWidth targetHeight = - if state.orientation in {5, 6, 7, 8}: state.scaledTargetWidth - else: state.scaledTargetHeight + if state.orientation in {5, 6, 7, 8}: effectiveTargetWidth + else: effectiveTargetHeight component.sampleWidth = max(1, (targetWidth * component.yScale + state.maxYScale - 1) div state.maxYScale ) @@ -478,36 +592,41 @@ proc decodeSOF0(state: var DecoderState) = channelWidth = component.sampleWidth channelHeight = component.sampleHeight - when defined(frameosEmbedded): + block: let blockColumns = state.numMcuWide * component.yScale blockRows = state.numMcuHigh * component.xScale - blockBytes = blockColumns.int64 * blockRows.int64 * 64'i64 * sizeof(int16).int64 - sourceMaskBytes = component.widthStride.int64 * component.heightStride.int64 - channelBytes = channelWidth.int64 * channelHeight.int64 - maskBytes = - if state.useScaledChannels(): channelBytes - else: sourceMaskBytes + streamsBlocks = state.streamBaselineBlocks and not state.progressive + blockBytes = + if streamsBlocks: 0'i64 + else: blockColumns.int64 * blockRows.int64 * 64'i64 * sizeof(int16).int64 + maskBytes = channelWidth.int64 * channelHeight.int64 totalBlockBytes += blockBytes totalMaskBytes += maskBytes - if state.streamBaselineBlocks and not state.progressive: - if channelBytes > embeddedMaxStreamingComponentMaskBytes or - totalMaskBytes > embeddedMaxStreamingTotalMaskBytes: - failInvalid( - "JPEG scaled dimensions need " & $(channelBytes div 1024) & - "K component and " & $(totalMaskBytes div 1024) & - "K total channel buffers; too large for embedded streaming decode" - ) - elif blockBytes > embeddedMaxComponentBlockBytes or - sourceMaskBytes > embeddedMaxComponentMaskBytes or - totalBlockBytes + totalMaskBytes > embeddedMaxProgressiveTotalDecodeBytes: - failInvalid( - "JPEG source dimensions need " & $(blockBytes div 1024) & - "K block, " & $(sourceMaskBytes div 1024) & - "K mask, and " & $((totalBlockBytes + totalMaskBytes) div 1024) & - "K total decode buffers; too large for embedded decode" - ) + component.channelWidth = channelWidth + component.channelHeight = channelHeight + + if state.progressive: + component.widthCoeff = component.widthStride div 8 + component.heightCoeff = component.heightStride div 8 + + if len != 0: + failInvalid() + + # Check the whole decode plan against the memory budget before any + # image-sized allocation happens, so oversized inputs fail with a + # catchable error instead of exhausting memory. + if overDecodeBudget(totalBlockBytes + totalMaskBytes): + failInvalid( + "JPEG decode of " & $state.imageWidth & "x" & $state.imageHeight & + (if state.progressive: " (progressive)" else: "") & + " needs " & $((totalBlockBytes + totalMaskBytes) div 1024) & + "K of decode buffers, over the " & + $(decodeBudgetBytes() div 1024) & "K memory budget" + ) + + for component in state.components.mitems: if not (state.streamBaselineBlocks and not state.progressive): # Allocate block data structures. component.blocks = newSeqWith( @@ -516,21 +635,16 @@ proc decodeSOF0(state: var DecoderState) = state.numMcuHigh * component.xScale ) ) - - component.channel = newMask(channelWidth, channelHeight) - - if state.progressive: - component.widthCoeff = component.widthStride div 8 - component.heightCoeff = component.heightStride div 8 - - if len != 0: - failInvalid() + component.channel = newMask(component.channelWidth, component.channelHeight) proc decodeSOF1(state: var DecoderState) = failInvalid("unsupported extended sequential DCT format") proc decodeSOF2(state: var DecoderState) = ## Decode Start of Image (Progressive DCT format) + if state.readProc != nil: + # Progressive scans need random access across the whole entropy stream. + failInvalid("progressive JPEG cannot be decoded from a stream") # Same as SOF0 state.progressive = true state.decodeSOF0() @@ -712,18 +826,18 @@ proc isEntropyMarker(marker: uint8): bool {.inline.} = proc seekEntropyMarker(state: var DecoderState): bool = ## Finds the next marker after damaged entropy-coded data. var pos = state.pos - while pos < state.len - 1: - if state.buffer[pos] != 0xFF: + while pos < state.len - 1 and state.ensureBytes(pos, 2): + if state.at(pos) != 0xFF: inc pos continue var markerPos = pos - while markerPos < state.len and state.buffer[markerPos] == 0xFF: + while state.ensureBytes(markerPos, 1) and state.at(markerPos) == 0xFF: inc markerPos if markerPos >= state.len: break - let marker = state.buffer[markerPos] + let marker = state.at(markerPos) if marker == 0: pos = markerPos + 1 continue @@ -1213,18 +1327,18 @@ proc resyncRestart(state: var DecoderState): bool = ## Leniently resync to the next restart marker after damaged entropy data. let maxPos = min(state.len - 1, state.pos + maxRestartResync) var pos = state.pos - while pos < maxPos: - if state.buffer[pos] != 0xFF: + while pos < maxPos and state.ensureBytes(pos, 1): + if state.at(pos) != 0xFF: inc pos continue var markerPos = pos - while markerPos < state.len and state.buffer[markerPos] == 0xFF: + while state.ensureBytes(markerPos, 1) and state.at(markerPos) == 0xFF: inc markerPos if markerPos >= state.len: break - let marker = state.buffer[markerPos] + let marker = state.at(markerPos) if marker in 0xD0'u8 .. 0xD7'u8: state.pos = markerPos + 1 state.reset() @@ -1236,20 +1350,20 @@ proc checkRestart(state: var DecoderState) = ## Check if we might have run into a restart marker, then deal with it. dec state.todoBeforeRestart if state.todoBeforeRestart <= 0: - if state.pos + 1 > state.len: + if state.pos + 1 > state.len or not state.ensureBytes(state.pos, 2): if state.progressive and state.hitEnd: state.todoBeforeRestart = int.high return failInvalid() # Handle getting a restart marker right at the end. - if state.buffer[state.pos] == 0xFF and state.buffer[state.pos+1] == 0xD9: + if state.at(state.pos) == 0xFF and state.at(state.pos + 1) == 0xD9: return - if state.progressive and state.hitEnd and state.buffer[state.pos] == 0xFF and - state.buffer[state.pos + 1] notin 0xD0'u8 .. 0xD7'u8: + if state.progressive and state.hitEnd and state.at(state.pos) == 0xFF and + state.at(state.pos + 1) notin 0xD0'u8 .. 0xD7'u8: state.todoBeforeRestart = int.high return - if state.buffer[state.pos] != 0xFF or - state.buffer[state.pos + 1] notin 0xD0'u8 .. 0xD7'u8: + if state.at(state.pos) != 0xFF or + state.at(state.pos + 1) notin 0xD0'u8 .. 0xD7'u8: if state.resyncRestart(): return if state.progressive and state.pos + 16 >= state.len: @@ -1426,12 +1540,66 @@ proc channelAt( y = min((sourceY * sampleHeight) div sourceHeight, sampleHeight - 1) component.channel.data[component.channel.dataIndex(x, y)] +proc scaledFitRects( + state: DecoderState, targetWidth, targetHeight: int +): tuple[srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH: int] = + ## Computes the source crop and target placement rectangles (in oriented + ## source space) for the requested fit mode. + let oriented = state.orientedDimensions() + result = (0, 0, oriented.width, oriented.height, 0, 0, targetWidth, targetHeight) + case state.scaledFit + of fitStretch: + discard + of fitCover: + if oriented.width.int64 * targetHeight.int64 > + targetWidth.int64 * oriented.height.int64: + # Source is wider than the target: crop width, centered. + let cropW = max(1, ( + oriented.height.int64 * targetWidth.int64 div + max(1'i64, targetHeight.int64)).int) + result.srcX = (oriented.width - cropW) div 2 + result.srcW = cropW + else: + let cropH = max(1, ( + oriented.width.int64 * targetHeight.int64 div + max(1'i64, targetWidth.int64)).int) + result.srcY = (oriented.height - cropH) div 2 + result.srcH = cropH + of fitContain: + if oriented.width.int64 * targetHeight.int64 > + targetWidth.int64 * oriented.height.int64: + # Source is wider than the target: fit width, letterbox height. + let fitH = max(1, ( + targetWidth.int64 * oriented.height.int64 div + max(1'i64, oriented.width.int64)).int) + result.dstY = (targetHeight - fitH) div 2 + result.dstH = fitH + else: + let fitW = max(1, ( + targetHeight.int64 * oriented.width.int64 div + max(1'i64, oriented.height.int64)).int) + result.dstX = (targetWidth - fitW) div 2 + result.dstW = fitW + proc fillImage(state: var DecoderState, result: Image) = ## Takes a jpeg image object and fills a target-sized pixie Image from it. - let oriented = state.orientedDimensions() + ## With fitContain, pixels outside the fitted rectangle keep their current + ## contents (callers pre-fill the background). let - targetWidth = result.width - targetHeight = result.height + oriented = state.orientedDimensions() + rects = state.scaledFitRects(result.width, result.height) + + template orientedYFor(y: int): int = + min( + rects.srcY + ((y - rects.dstY) * rects.srcH) div rects.dstH, + oriented.height - 1 + ) + + template orientedXFor(x: int): int = + min( + rects.srcX + ((x - rects.dstX) * rects.srcW) div rects.dstW, + oriented.width - 1 + ) case state.components.len: of 3: @@ -1439,11 +1607,11 @@ proc fillImage(state: var DecoderState, result: Image) = yComponent = state.components[0] cbComponent = state.components[1] crComponent = state.components[2] - for y in 0 ..< targetHeight: - let orientedY = min((y * oriented.height) div targetHeight, oriented.height - 1) - for x in 0 ..< targetWidth: + for y in rects.dstY ..< rects.dstY + rects.dstH: + let orientedY = orientedYFor(y) + for x in rects.dstX ..< rects.dstX + rects.dstW: let - orientedX = min((x * oriented.width) div targetWidth, oriented.width - 1) + orientedX = orientedXFor(x) source = state.sourceCoords(orientedX, orientedY) result.unsafe[x, y] = yCbCrToRgbx( yComponent.channelAt(source.x, source.y, state.imageWidth, state.imageHeight), @@ -1453,11 +1621,11 @@ proc fillImage(state: var DecoderState, result: Image) = of 1: let yComponent = state.components[0] - for y in 0 ..< targetHeight: - let orientedY = min((y * oriented.height) div targetHeight, oriented.height - 1) - for x in 0 ..< targetWidth: + for y in rects.dstY ..< rects.dstY + rects.dstH: + let orientedY = orientedYFor(y) + for x in rects.dstX ..< rects.dstX + rects.dstW: let - orientedX = min((x * oriented.width) div targetWidth, oriented.width - 1) + orientedX = orientedXFor(x) source = state.sourceCoords(orientedX, orientedY) result.unsafe[x, y] = grayScaleToRgbx( yComponent.channelAt(source.x, source.y, state.imageWidth, state.imageHeight) @@ -1567,12 +1735,15 @@ proc buildImage(state: var DecoderState): Image = else: failInvalid("invalid orientation") +proc runJpegDecode(state: var DecoderState) {.raises: [PixieError].} + proc decodeJpegState( data: pointer, len: int, streamBaselineBlocks = false, scaledTargetWidth = 0, - scaledTargetHeight = 0 + scaledTargetHeight = 0, + fit = fitStretch ): DecoderState {.raises: [PixieError].} = var state = DecoderState() state.buffer = cast[ptr UncheckedArray[uint8]](data) @@ -1580,7 +1751,33 @@ proc decodeJpegState( state.streamBaselineBlocks = streamBaselineBlocks state.scaledTargetWidth = scaledTargetWidth state.scaledTargetHeight = scaledTargetHeight + state.scaledFit = fit + runJpegDecode(state) + state + +proc decodeJpegStateStream( + readProc: JpegSourceProc, + totalLen: int, + scaledTargetWidth, scaledTargetHeight: int, + fit = fitStretch +): DecoderState {.raises: [PixieError].} = + ## Decodes a baseline JPEG pulled incrementally from `readProc` through a + ## small sliding window, never holding the whole input in memory. + if readProc == nil or totalLen <= 2: + failInvalid("invalid JPEG stream source") + var state = DecoderState() + state.readProc = readProc + state.window = newSeq[uint8](jpegStreamWindowSize) + state.buffer = cast[ptr UncheckedArray[uint8]](state.window[0].addr) + state.len = totalLen + state.streamBaselineBlocks = true + state.scaledTargetWidth = scaledTargetWidth + state.scaledTargetHeight = scaledTargetHeight + state.scaledFit = fit + runJpegDecode(state) + state +proc runJpegDecode(state: var DecoderState) {.raises: [PixieError].} = while true: if state.pos >= state.len and state.foundSOS: break @@ -1642,7 +1839,6 @@ proc decodeJpegState( failInvalid("invalid chunk " & chunkId.toHex()) state.quantizationAndIDCTPass() - state proc decodeJpeg*(data: string): Image {.raises: [PixieError].} = ## Decodes the JPEG into an Image. @@ -1650,7 +1846,7 @@ proc decodeJpeg*(data: string): Image {.raises: [PixieError].} = state.buildImage() proc decodeJpegScaled*( - data: pointer, len, width, height: int + data: pointer, len, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} = ## Decodes the JPEG directly into a target-sized Image. var state = decodeJpegState( @@ -1658,12 +1854,13 @@ proc decodeJpegScaled*( len, true, width, - height + height, + fit ) state.buildImage(width, height) proc decodeJpegScaledInto*( - data: pointer, len: int, target: Image + data: pointer, len: int, target: Image, fit = fitStretch ) {.raises: [PixieError].} = ## Decodes the JPEG directly into an existing target-sized Image. if target.isNil or target.width <= 0 or target.height <= 0: @@ -1673,12 +1870,13 @@ proc decodeJpegScaledInto*( len, true, target.width, - target.height + target.height, + fit ) state.fillImage(target) proc decodeJpegScaled*( - data: var string, width, height: int + data: var string, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} = ## Decodes the JPEG directly into a target-sized Image. ## The input string is released before allocating the output image. @@ -1687,13 +1885,14 @@ proc decodeJpegScaled*( data.len, true, width, - height + height, + fit ) data = "" state.buildImage(width, height) proc decodeJpegScaledInto*( - data: var string, target: Image + data: var string, target: Image, fit = fitStretch ) {.raises: [PixieError].} = ## Decodes the JPEG directly into an existing target-sized Image. ## The input string is released before writing the output image. @@ -1704,22 +1903,46 @@ proc decodeJpegScaledInto*( data.len, true, target.width, - target.height + target.height, + fit ) data = "" state.fillImage(target) proc decodeJpegScaled*( - data: string, width, height: int + data: string, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} = ## Decodes the JPEG directly into a target-sized Image. - decodeJpegScaled(data.cstring, data.len, width, height) + decodeJpegScaled(data.cstring, data.len, width, height, fit) proc decodeJpegScaledInto*( - data: string, target: Image + data: string, target: Image, fit = fitStretch ) {.raises: [PixieError].} = ## Decodes the JPEG directly into an existing target-sized Image. - decodeJpegScaledInto(data.cstring, data.len, target) + decodeJpegScaledInto(data.cstring, data.len, target, fit) + +proc decodeJpegStreamScaled*( + readProc: JpegSourceProc, totalLen, width, height: int, fit = fitStretch +): Image {.raises: [PixieError].} = + ## Decodes a baseline JPEG pulled from `readProc` into a target-sized + ## Image, holding only a small window of the compressed input in memory. + ## Progressive JPEGs raise a catchable PixieError; retry from a buffer. + var state = decodeJpegStateStream(readProc, totalLen, width, height, fit) + state.buildImage(width, height) + +proc decodeJpegStreamScaledInto*( + readProc: JpegSourceProc, totalLen: int, target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## Decodes a baseline JPEG pulled from `readProc` directly into an + ## existing target-sized Image, holding only a small window of the + ## compressed input in memory. Progressive JPEGs raise a catchable + ## PixieError; retry from a buffer. + if target.isNil or target.width <= 0 or target.height <= 0: + raise newException(PixieError, "Target image width and height must be > 0") + var state = decodeJpegStateStream( + readProc, totalLen, target.width, target.height, fit + ) + state.fillImage(target) proc decodeJpegDimensions*( data: pointer, len: int @@ -1791,5 +2014,105 @@ proc decodeJpegDimensions*( ## Decodes the JPEG dimensions. decodeJpegDimensions(data.cstring, data.len) +type + JpegInfo* = object + ## Cheap header probe used for pre-decode memory planning. + width*, height*: int ## oriented (post-EXIF-rotation) dimensions + progressive*: bool + components*: int + maxXScale*, maxYScale*: int + componentScales*: seq[tuple[xScale, yScale: int]] + +proc decodeJpegInfo*( + data: pointer, len: int +): JpegInfo {.raises: [PixieError].} = + ## Probes a JPEG header for dimensions, encoding mode and subsampling + ## without decoding any image data. + var state = DecoderState() + state.buffer = cast[ptr UncheckedArray[uint8]](data) + state.len = len + + while true: + let chunkId = state.readMarker() + case chunkId: + of 0xD8, 0xD0 .. 0xD7: + discard + of 0xC0, 0xC2: + result.progressive = chunkId == 0xC2 + discard state.readUint16be().int # Chunk len + discard state.readUint8() # Precision + state.imageHeight = state.readUint16be().int + state.imageWidth = state.readUint16be().int + let numComponents = state.readUint8().int + if numComponents notin {1, 3}: + failInvalid("unsupported component count") + result.components = numComponents + for _ in 0 ..< numComponents: + discard state.readUint8() # Component id + let info = state.readUint8() + discard state.readUint8() # Quantization table id + let + xScale = (info and 15).int + yScale = (info shr 4).int + result.componentScales.add((xScale: xScale, yScale: yScale)) + result.maxXScale = max(result.maxXScale, xScale) + result.maxYScale = max(result.maxYScale, yScale) + break + of 0xC1: + failInvalid("unsupported extended sequential DCT format") + of 0xC4: + state.decodeDHT() + of 0xE1: + state.decodeExif() + of 0xDB, 0xDC, 0xDD, 0xE0, 0xE2..0xEF, 0xFE: + state.skipChunk() + else: + failInvalid("invalid chunk " & chunkId.toHex()) + + if state.imageWidth <= 0 or state.imageHeight <= 0 or + result.maxXScale <= 0 or result.maxYScale <= 0: + failInvalid() + 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") + +proc decodeJpegInfo*(data: string): JpegInfo {.raises: [PixieError].} = + ## Probes a JPEG header for dimensions, encoding mode and subsampling. + decodeJpegInfo(data.cstring, data.len) + +proc jpegDecodeIntermediateBytes*( + info: JpegInfo, targetWidth, targetHeight: int +): int64 {.raises: [].} = + ## Estimates the decode-intermediate bytes (coefficient blocks + channel + ## masks) a scaled decode of this JPEG to targetWidth x targetHeight will + ## allocate. Baseline JPEGs stream their blocks, so only target-sized + ## masks count; progressive JPEGs additionally hold source-sized + ## coefficient blocks for every component. + if info.maxXScale <= 0 or info.maxYScale <= 0: + return 0 + let + mcuWidth = info.maxYScale * 8 + mcuHeight = info.maxXScale * 8 + numMcuWide = (info.width + mcuWidth - 1) div mcuWidth + numMcuHigh = (info.height + mcuHeight - 1) div mcuHeight + for scale in info.componentScales: + if info.progressive: + let + blockColumns = numMcuWide.int64 * scale.yScale.int64 + blockRows = numMcuHigh.int64 * scale.xScale.int64 + result += blockColumns * blockRows * 64 * sizeof(int16).int64 + let + sampleWidth = max(1'i64, + (targetWidth.int64 * scale.yScale.int64 + info.maxYScale - 1) div info.maxYScale) + sampleHeight = max(1'i64, + (targetHeight.int64 * scale.xScale.int64 + info.maxXScale - 1) div info.maxXScale) + result += sampleWidth * sampleHeight + when defined(release): {.pop.} diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index 815e4dda..6bbc3d1a 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -1,5 +1,5 @@ -import chroma, flatty/binny, ../common, ../images, ../internal, - ../simd, zippy, crunchy +import chroma, flatty/binny, ../common, ../decodebudget, ../images, + ../internal, ../simd, zippy, crunchy # See http://www.libpng.org/pub/png/spec/1.2/PNG-Contents.html @@ -442,9 +442,7 @@ proc decodeImageData( if idats.len == 0: failInvalid() - result.setLen(header.width * header.height) - - let uncompressed = uncompressIdats(data, idats) + var uncompressed = uncompressIdats(data, idats) if header.interlaceMethod == 0: let @@ -463,11 +461,16 @@ proc decodeImageData( rowBytes, header.filterBytesPerPixel ) + # The inflated scanlines are no longer needed; release them before the + # pixel seq is allocated to lower the peak footprint. + uncompressed = "" + result.setLen(header.width * header.height) result.writePixels( header, palette, transparency, unfiltered, header.width, header.height, 0, 0, 1, 1 ) else: + result.setLen(header.width * header.height) const startXs = [0, 4, 0, 2, 0, 1, 0] startYs = [0, 0, 4, 0, 2, 0, 1] @@ -516,9 +519,7 @@ proc decodeImageData16( if idats.len == 0: failInvalid() - result.setLen(header.width * header.height) - - let uncompressed = uncompressIdats(data, idats) + var uncompressed = uncompressIdats(data, idats) if header.interlaceMethod == 0: let @@ -537,11 +538,16 @@ proc decodeImageData16( rowBytes, header.filterBytesPerPixel ) + # The inflated scanlines are no longer needed; release them before the + # pixel seq is allocated to lower the peak footprint. + uncompressed = "" + result.setLen(header.width * header.height) result.writePixels16( header, transparency, unfiltered, header.width, header.height, 0, 0, 1, 1 ) else: + result.setLen(header.width * header.height) const startXs = [0, 4, 0, 2, 0, 1, 0] startYs = [0, 0, 4, 0, 2, 0, 1] @@ -621,28 +627,78 @@ proc validateScaledPngTarget(target: Image) {.raises: [PixieError].} = raise newException(PixieError, "Invalid PNG target Image") validateScaledPngTarget(target.width, target.height) -proc fillImage*(png: Png, target: Image) {.raises: [PixieError].} = - ## Scales a decoded PNG into an existing Image. +proc scaledFitRects( + png: Png, targetWidth, targetHeight: int, fit: ScaledDecodeFit +): tuple[srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH: int] = + ## Computes the source crop and target placement rectangles for a fit mode. + result = (0, 0, png.width, png.height, 0, 0, targetWidth, targetHeight) + case fit + of fitStretch: + discard + of fitCover: + if png.width.int64 * targetHeight.int64 > + targetWidth.int64 * png.height.int64: + let cropW = max(1, ( + png.height.int64 * targetWidth.int64 div + max(1'i64, targetHeight.int64)).int) + result.srcX = (png.width - cropW) div 2 + result.srcW = cropW + else: + let cropH = max(1, ( + png.width.int64 * targetHeight.int64 div + max(1'i64, targetWidth.int64)).int) + result.srcY = (png.height - cropH) div 2 + result.srcH = cropH + of fitContain: + if png.width.int64 * targetHeight.int64 > + targetWidth.int64 * png.height.int64: + let fitH = max(1, ( + targetWidth.int64 * png.height.int64 div + max(1'i64, png.width.int64)).int) + result.dstY = (targetHeight - fitH) div 2 + result.dstH = fitH + else: + let fitW = max(1, ( + targetHeight.int64 * png.width.int64 div + max(1'i64, png.height.int64)).int) + result.dstX = (targetWidth - fitW) div 2 + result.dstW = fitW + +proc fillImage*( + png: Png, target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## Scales a decoded PNG into an existing Image. With fitContain, pixels + ## outside the fitted rectangle keep their current contents. validateScaledPngTarget(target) + let rects = png.scaledFitRects(target.width, target.height, fit) + + template srcYFor(y: int): int = + min(rects.srcY + ((y - rects.dstY) * rects.srcH) div rects.dstH, png.height - 1) + + template srcXFor(x: int): int = + min(rects.srcX + ((x - rects.dstX) * rects.srcW) div rects.dstW, png.width - 1) + if png.data.len > 0: - for y in 0 ..< target.height: - let srcY = min((y * png.height) div target.height, png.height - 1) - for x in 0 ..< target.width: - let srcX = min((x * png.width) div target.width, png.width - 1) + for y in rects.dstY ..< rects.dstY + rects.dstH: + let srcY = srcYFor(y) + for x in rects.dstX ..< rects.dstX + rects.dstW: + let srcX = srcXFor(x) target.unsafe[x, y] = png.data[srcX + srcY * png.width].rgbx() else: - for y in 0 ..< target.height: - let srcY = min((y * png.height) div target.height, png.height - 1) - for x in 0 ..< target.width: - let srcX = min((x * png.width) div target.width, png.width - 1) + for y in rects.dstY ..< rects.dstY + rects.dstH: + let srcY = srcYFor(y) + for x in rects.dstX ..< rects.dstX + rects.dstW: + let srcX = srcXFor(x) target.unsafe[x, y] = png.data16[srcX + srcY * png.width].toRgba.rgbx() -proc convertToImage*(png: Png, width, height: int): Image {.raises: [PixieError].} = +proc convertToImage*( + png: Png, width, height: int, fit = fitStretch +): Image {.raises: [PixieError].} = ## Converts a PNG into an Image scaled to the requested dimensions. validateScaledPngTarget(width, height) result = newImage(width, height) - png.fillImage(result) + png.fillImage(result, fit) proc decodePngDimensions*( data: pointer, len: int @@ -702,6 +758,22 @@ proc decodePng*(data: pointer, len: int): Png {.raises: [PixieError].} = prevChunkType = "IHDR" inc(pos, 13) + # Check the decode plan against the memory budget before any image-sized + # allocation: inflated scanlines + unfiltered scanlines + the pixel seq. + block: + let + pixels = header.width.int64 * header.height.int64 + scanlines = scanlineBytes(header.width, header).int64 * + header.height.int64 + header.height.int64 + pixelBytes = pixels * (if header.bitDepth == 16: 8 else: 4) + if overDecodeBudget(pixelBytes + 2 * scanlines): + raise newException(PixieError, + "PNG decode of " & $header.width & "x" & $header.height & + " needs " & $((pixelBytes + 2 * scanlines) div 1024) & + "K of decode buffers, over the " & + $(decodeBudgetBytes() div 1024) & "K memory budget" + ) + let headerCrc = crc32(data[pos - 17].addr, 17) if headerCrc != data.readUint32(pos).swap(): failCRC() @@ -793,31 +865,31 @@ proc decodePng*(data: string): Png {.inline, raises: [PixieError].} = decodePng(data.cstring, data.len) proc decodePngScaled*( - data: pointer, len, width, height: int + data: pointer, len, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} = ## Decodes the PNG data into an Image scaled to the requested dimensions. - decodePng(data, len).convertToImage(width, height) + decodePng(data, len).convertToImage(width, height, fit) proc decodePngScaledInto*( - data: pointer, len: int, target: Image + data: pointer, len: int, target: Image, fit = fitStretch ) {.raises: [PixieError].} = ## Decodes the PNG data into an existing Image. - decodePng(data, len).fillImage(target) + decodePng(data, len).fillImage(target, fit) proc decodePngScaled*( - data: string, width, height: int + data: string, width, height: int, fit = fitStretch ): Image {.inline, raises: [PixieError].} = ## Decodes the PNG data into an Image scaled to the requested dimensions. - decodePngScaled(data.cstring, data.len, width, height) + decodePngScaled(data.cstring, data.len, width, height, fit) proc decodePngScaledInto*( - data: string, target: Image + data: string, target: Image, fit = fitStretch ) {.inline, raises: [PixieError].} = ## Decodes the PNG data into an existing Image. - decodePngScaledInto(data.cstring, data.len, target) + decodePngScaledInto(data.cstring, data.len, target, fit) proc decodePngScaled*( - data: var string, width, height: int + data: var string, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} = ## Decodes the PNG data into a scaled Image and releases the source string ## before allocating the target Image. @@ -827,10 +899,10 @@ proc decodePngScaled*( GC_fullCollect() except Exception: discard - png.convertToImage(width, height) + png.convertToImage(width, height, fit) proc decodePngScaledInto*( - data: var string, target: Image + data: var string, target: Image, fit = fitStretch ) {.raises: [PixieError].} = ## Decodes the PNG data into an existing Image and releases the source string ## after PNG parsing. @@ -840,7 +912,7 @@ proc decodePngScaledInto*( GC_fullCollect() except Exception: discard - png.fillImage(target) + png.fillImage(target, fit) proc encodePng*( width, height, channels: int, data: pointer, len: int From f4e272a3628b4486282e797b9026683beb0efd6a Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Thu, 2 Jul 2026 02:30:19 +0200 Subject: [PATCH 05/29] jpeg: tolerate window slide in streaming resync seekEntropyMarker could set state.pos behind the sliding window start on a long 0xFF run in damaged entropy data; clamp to windowStart so recovery matches the buffered decoder instead of failing the whole decode. Co-Authored-By: Claude Fable 5 --- src/pixie/fileformats/jpeg.nim | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pixie/fileformats/jpeg.nim b/src/pixie/fileformats/jpeg.nim index 0f4357e8..eb8b3abe 100644 --- a/src/pixie/fileformats/jpeg.nim +++ b/src/pixie/fileformats/jpeg.nim @@ -842,7 +842,10 @@ proc seekEntropyMarker(state: var DecoderState): bool = pos = markerPos + 1 continue if marker.isEntropyMarker(): - state.pos = pos + # A long 0xFF run can slide the streaming window past the run start; + # resuming at the window start keeps recovery working for these + # already-damaged inputs instead of failing the whole decode. + state.pos = max(pos, state.windowStart) state.hitEnd = true state.bitsBuffered = 0 state.bitBuffer = 0 From 853485d5842b8c2a517cbf8f91e2bde44860842d Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Thu, 2 Jul 2026 10:19:53 +0200 Subject: [PATCH 06/29] png: unfilter non-interlaced scanlines in place Unfiltering allocated a second scanline-sized buffer, putting the decode plan for a canvas-sized RGBA PNG at pixels + 2x scanlines (4.5MB for 480x800). Compacting the [filter byte][row] stride in place drops the plan to pixels + scanlines (3.0MB), which fits the decode budget on ESP32-class devices. Interlaced images keep the per-pass copy and the old plan formula. Co-Authored-By: Claude Fable 5 --- src/pixie/fileformats/png.nim | 128 ++++++++++++++++++++++++++++------ tests/test_png.nim | 25 ++++++- 2 files changed, 129 insertions(+), 24 deletions(-) diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index 6bbc3d1a..fc60ac5b 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -88,6 +88,89 @@ proc decodePalette(data: pointer, len: int): seq[ColorRGB] = copyMem(result[0].addr, data, len) +proc unfilterInPlace( + uncompressed: var string, height, rowBytes, bpp: int +) {.raises: [PixieError].} = + ## Unfilters non-interlaced scanlines in place, compacting the + ## [filter byte][row] stride layout into contiguous unfiltered rows at the + ## start of the buffer. Avoids allocating a second scanline-sized buffer, + ## which matters on memory-constrained targets. + let buf = cast[ptr UncheckedArray[uint8]](uncompressed.cstring) + for y in 0 ..< height: + let + filterType = buf[y * (rowBytes + 1)] + src = y * (rowBytes + 1) + 1 + dst = y * rowBytes + # dst + x < src + x for every byte, so reads stay ahead of writes and + # left/up/upLeft reads all land in already-compacted rows. + case filterType: + of 0: # None + moveMem(buf[dst].addr, buf[src].addr, rowBytes) + of 1: # Sub + for x in 0 ..< rowBytes: + var value = buf[src + x] + if x - bpp >= 0: + value += buf[dst + x - bpp] + buf[dst + x] = value + of 2: # Up + if y == 0: + moveMem(buf[dst].addr, buf[src].addr, rowBytes) + else: + var x: int + when allowSimd and (defined(amd64) or defined(arm64)): + for _ in 0 ..< rowBytes div 16: + when defined(amd64): + let + bytes = mm_loadu_si128(buf[src + x].addr) + up = mm_loadu_si128(buf[dst + x - rowBytes].addr) + mm_storeu_si128(buf[dst + x].addr, mm_add_epi8(bytes, up)) + else: # arm64 + let + bytes = vld1q_u8(buf[src + x].addr) + up = vld1q_u8(buf[dst + x - rowBytes].addr) + vst1q_u8(buf[dst + x].addr, vaddq_u8(bytes, up)) + x += 16 + for x in x ..< rowBytes: + buf[dst + x] = buf[src + x] + buf[dst + x - rowBytes] + of 3: # Average + for x in 0 ..< rowBytes: + var + value = buf[src + x] + left, up: uint32 + if x - bpp >= 0: + left = buf[dst + x - bpp] + if y - 1 >= 0: + up = buf[dst + x - rowBytes] + value += ((left + up) div 2).uint8 + buf[dst + x] = value + of 4: # Paeth + for x in 0 ..< rowBytes: + var + value = buf[src + x] + left, up, upLeft: int + if x - bpp >= 0: + left = buf[dst + x - bpp].int + if y - 1 >= 0: + up = buf[dst + x - rowBytes].int + if x - bpp >= 0 and y - 1 >= 0: + upLeft = buf[dst + x - rowBytes - bpp].int + template paethPredictor(a, b, c: int): int = + let + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + if pa <= pb and pa <= pc: + a + elif pb <= pc: + b + else: + c + value += paethPredictor(up, left, upLeft).uint8 + buf[dst + x] = value + else: + raise newException(PixieError, "Invalid PNG row filter") + proc unfilter( uncompressed: pointer, len, height, rowBytes, bpp: int ): seq[uint8] = @@ -454,21 +537,18 @@ proc decodeImageData( if uncompressed.len != totalBytes + header.height: failInvalid() - let unfiltered = unfilter( - uncompressed.cstring, - uncompressed.len, - header.height, - rowBytes, - header.filterBytesPerPixel + # Unfilter in place: peak memory stays at one scanline buffer plus the + # pixel seq instead of two scanline buffers. + unfilterInPlace( + uncompressed, header.height, rowBytes, header.filterBytesPerPixel ) - # The inflated scanlines are no longer needed; release them before the - # pixel seq is allocated to lower the peak footprint. - uncompressed = "" result.setLen(header.width * header.height) result.writePixels( - header, palette, transparency, unfiltered, + header, palette, transparency, + uncompressed.toOpenArrayByte(0, header.height * rowBytes - 1), header.width, header.height, 0, 0, 1, 1 ) + uncompressed = "" else: result.setLen(header.width * header.height) const @@ -531,21 +611,18 @@ proc decodeImageData16( if uncompressed.len != totalBytes + header.height: failInvalid() - let unfiltered = unfilter( - uncompressed.cstring, - uncompressed.len, - header.height, - rowBytes, - header.filterBytesPerPixel + # Unfilter in place: peak memory stays at one scanline buffer plus the + # pixel seq instead of two scanline buffers. + unfilterInPlace( + uncompressed, header.height, rowBytes, header.filterBytesPerPixel ) - # The inflated scanlines are no longer needed; release them before the - # pixel seq is allocated to lower the peak footprint. - uncompressed = "" result.setLen(header.width * header.height) result.writePixels16( - header, transparency, unfiltered, + header, transparency, + uncompressed.toOpenArrayByte(0, header.height * rowBytes - 1), header.width, header.height, 0, 0, 1, 1 ) + uncompressed = "" else: result.setLen(header.width * header.height) const @@ -759,17 +836,22 @@ proc decodePng*(data: pointer, len: int): Png {.raises: [PixieError].} = inc(pos, 13) # Check the decode plan against the memory budget before any image-sized - # allocation: inflated scanlines + unfiltered scanlines + the pixel seq. + # allocation. Non-interlaced images unfilter in place, so the peak is the + # inflated scanlines + the pixel seq; interlaced images still allocate a + # second unfiltered buffer per pass. block: let pixels = header.width.int64 * header.height.int64 scanlines = scanlineBytes(header.width, header).int64 * header.height.int64 + header.height.int64 pixelBytes = pixels * (if header.bitDepth == 16: 8 else: 4) - if overDecodeBudget(pixelBytes + 2 * scanlines): + planBytes = + if header.interlaceMethod == 0: pixelBytes + scanlines + else: pixelBytes + 2 * scanlines + if overDecodeBudget(planBytes): raise newException(PixieError, "PNG decode of " & $header.width & "x" & $header.height & - " needs " & $((pixelBytes + 2 * scanlines) div 1024) & + " needs " & $(planBytes div 1024) & "K of decode buffers, over the " & $(decodeBudgetBytes() div 1024) & "K memory budget" ) diff --git a/tests/test_png.nim b/tests/test_png.nim index d9b4ad8a..1e3e2a2f 100644 --- a/tests/test_png.nim +++ b/tests/test_png.nim @@ -1,4 +1,4 @@ -import pixie, pixie/fileformats/png, pngsuite, strformat +import pixie, pixie/fileformats/png, pixie/decodebudget, pngsuite, strformat, strutils when defined(writeImages): import write_images @@ -78,3 +78,26 @@ block: decodeImageDimensions(readFile("tests/fileformats/png/mandrill.png")) doAssert dimensions.width == 512 doAssert dimensions.height == 512 + +block: # in-place unfilter keeps canvas-sized decodes within tight budgets + let source = newImage(480, 800) + for y in 0 ..< source.height: + for x in 0 ..< source.width: + source.unsafe[x, y] = rgbx(uint8(x mod 256), uint8(y mod 256), uint8((x + y) mod 256), 255) + let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) + + # Plan is pixels + scanlines (~3.0MB); a 4MB budget must accept it + setDecodeBudgetBytes(4 * 1024 * 1024) + let decoded = decodePng(encoded).convertToImage() + doAssert decoded.width == 480 + doAssert decoded.height == 800 + doAssert decoded[123, 456] == source[123, 456] + + # And a 2MB budget must reject it with a catchable error + setDecodeBudgetBytes(2 * 1024 * 1024) + try: + discard decodePng(encoded) + doAssert false + except PixieError as e: + doAssert "memory budget" in e.msg + setDecodeBudgetBytes(0) From 36b150b310e049edfc4d0ba659a1f48480fa8c9b Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Thu, 2 Jul 2026 10:59:39 +0200 Subject: [PATCH 07/29] png: stream scanlines out of the inflate window Non-interlaced PNGs now decode row by row through zippy's streaming inflate (pinned to the FrameOS fork): scanlines are unfiltered against a single previous-row buffer and written straight into the pixel data, so the whole-image inflate buffer disappears. Full decodes plan pixels + a fixed ~64KB; decodePngScaled/Into sample rows on the fly and never allocate the full-size pixel buffer at all, so a huge PNG can scale into display bounds like a streamed JPEG. Interlaced and 16-bit-scaled decodes keep the buffered path. Verified pixel-identical to the previous decoder across the whole pngsuite corpus, plus differential streamed-vs-buffered scaled decode tests and tightened budget assertions (480x800 RGBA: full decode within 2MB, scaled-into within 256KB). Co-Authored-By: Claude Fable 5 --- pixie.nimble | 2 +- src/pixie/fileformats/png.nim | 482 +++++++++++++++++++++------------- tests/test_png.nim | 34 ++- 3 files changed, 334 insertions(+), 184 deletions(-) diff --git a/pixie.nimble b/pixie.nimble index e2e310e0..e63dfaf6 100644 --- a/pixie.nimble +++ b/pixie.nimble @@ -8,7 +8,7 @@ srcDir = "src" requires "nim >= 2.0.0" requires "vmath >= 3.0.0" requires "chroma >= 1.0.0" -requires "zippy >= 0.10.16" +requires "https://github.com/FrameOS/zippy#a109a45a6b16d852ee2912391a0c9241be5fbeb1" requires "flatty >= 0.3.4" requires "nimsimd >= 1.3.2" requires "bumpy >= 1.1.3" diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index fc60ac5b..a3beacf0 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -88,89 +88,6 @@ proc decodePalette(data: pointer, len: int): seq[ColorRGB] = copyMem(result[0].addr, data, len) -proc unfilterInPlace( - uncompressed: var string, height, rowBytes, bpp: int -) {.raises: [PixieError].} = - ## Unfilters non-interlaced scanlines in place, compacting the - ## [filter byte][row] stride layout into contiguous unfiltered rows at the - ## start of the buffer. Avoids allocating a second scanline-sized buffer, - ## which matters on memory-constrained targets. - let buf = cast[ptr UncheckedArray[uint8]](uncompressed.cstring) - for y in 0 ..< height: - let - filterType = buf[y * (rowBytes + 1)] - src = y * (rowBytes + 1) + 1 - dst = y * rowBytes - # dst + x < src + x for every byte, so reads stay ahead of writes and - # left/up/upLeft reads all land in already-compacted rows. - case filterType: - of 0: # None - moveMem(buf[dst].addr, buf[src].addr, rowBytes) - of 1: # Sub - for x in 0 ..< rowBytes: - var value = buf[src + x] - if x - bpp >= 0: - value += buf[dst + x - bpp] - buf[dst + x] = value - of 2: # Up - if y == 0: - moveMem(buf[dst].addr, buf[src].addr, rowBytes) - else: - var x: int - when allowSimd and (defined(amd64) or defined(arm64)): - for _ in 0 ..< rowBytes div 16: - when defined(amd64): - let - bytes = mm_loadu_si128(buf[src + x].addr) - up = mm_loadu_si128(buf[dst + x - rowBytes].addr) - mm_storeu_si128(buf[dst + x].addr, mm_add_epi8(bytes, up)) - else: # arm64 - let - bytes = vld1q_u8(buf[src + x].addr) - up = vld1q_u8(buf[dst + x - rowBytes].addr) - vst1q_u8(buf[dst + x].addr, vaddq_u8(bytes, up)) - x += 16 - for x in x ..< rowBytes: - buf[dst + x] = buf[src + x] + buf[dst + x - rowBytes] - of 3: # Average - for x in 0 ..< rowBytes: - var - value = buf[src + x] - left, up: uint32 - if x - bpp >= 0: - left = buf[dst + x - bpp] - if y - 1 >= 0: - up = buf[dst + x - rowBytes] - value += ((left + up) div 2).uint8 - buf[dst + x] = value - of 4: # Paeth - for x in 0 ..< rowBytes: - var - value = buf[src + x] - left, up, upLeft: int - if x - bpp >= 0: - left = buf[dst + x - bpp].int - if y - 1 >= 0: - up = buf[dst + x - rowBytes].int - if x - bpp >= 0 and y - 1 >= 0: - upLeft = buf[dst + x - rowBytes - bpp].int - template paethPredictor(a, b, c: int): int = - let - p = a + b - c - pa = abs(p - a) - pb = abs(p - b) - pc = abs(p - c) - if pa <= pb and pa <= pc: - a - elif pb <= pc: - b - else: - c - value += paethPredictor(up, left, upLeft).uint8 - buf[dst + x] = value - else: - raise newException(PixieError, "Invalid PNG row filter") - proc unfilter( uncompressed: pointer, len, height, rowBytes, bpp: int ): seq[uint8] = @@ -515,6 +432,108 @@ proc uncompressIdats( p = data[start].unsafeAddr result = try: uncompress(p, len) except ZippyError: failInvalid() +proc unfilterRow( + cur: var seq[uint8], prev: seq[uint8], filterType: uint8, rowBytes, bpp: int +) {.raises: [ZippyError].} = + ## Unfilters one scanline in place. prev must hold the previous unfiltered + ## row (all zeroes for the first row, matching the PNG spec). + template paethPredictor(a, b, c: int): int = + let + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + if pa <= pb and pa <= pc: + a + elif pb <= pc: + b + else: + c + + case filterType: + of 0: # None + discard + of 1: # Sub + for x in bpp ..< rowBytes: + cur[x] = cur[x] + cur[x - bpp] + of 2: # Up + for x in 0 ..< rowBytes: + cur[x] = cur[x] + prev[x] + of 3: # Average + for x in 0 ..< rowBytes: + let left = if x >= bpp: cur[x - bpp].uint32 else: 0 + cur[x] = cur[x] + ((left + prev[x].uint32) div 2).uint8 + of 4: # Paeth + for x in 0 ..< rowBytes: + let + left = if x >= bpp: cur[x - bpp].int else: 0 + upLeft = if x >= bpp: prev[x - bpp].int else: 0 + cur[x] = cur[x] + paethPredictor(prev[x].int, left, upLeft).uint8 + else: + raise newException(ZippyError, "Invalid PNG row filter") + +proc streamIdatRows( + data: ptr UncheckedArray[uint8], + header: PngHeader, + idats: seq[(int, int)], + onRow: proc (y: int, row: seq[uint8]) {.gcsafe, raises: [ZippyError].} +) = + ## Inflates the IDAT stream and hands unfiltered scanlines to onRow one at + ## a time, in order. Peak memory: zippy's fixed ~64KB streaming window plus + ## two row buffers, regardless of image size. Non-interlaced images only. + if idats.len == 0: + failInvalid() + + let + rowBytes = scanlineBytes(header.width, header) + bpp = header.filterBytesPerPixel + height = header.height + + var + prevRow = newSeq[uint8](rowBytes) + curRow = newSeq[uint8](rowBytes) + rowFill = -1 # -1 = the next byte is the row's filter type + filterType: uint8 + y: int + + let onData = proc (chunk: openArray[uint8]) {.gcsafe, raises: [ZippyError].} = + var i = 0 + while i < chunk.len: + if y >= height: + raise newException(ZippyError, "PNG has too much image data") + if rowFill < 0: + filterType = chunk[i] + inc i + rowFill = 0 + let take = min(chunk.len - i, rowBytes - rowFill) + if take > 0: + copyMem(curRow[rowFill].addr, chunk[i].unsafeAddr, take) + rowFill += take + i += take + if rowFill == rowBytes: + unfilterRow(curRow, prevRow, filterType, rowBytes, bpp) + onRow(y, curRow) + swap(prevRow, curRow) + inc y + rowFill = -1 + + try: + if idats.len > 1: + var imageData: string + for (start, len) in idats: + let op = imageData.len + imageData.setLen(imageData.len + len) + copyMem(imageData[op].addr, data[start].addr, len) + uncompressStream(onData, imageData.cstring, imageData.len, dfZlib) + else: + let (start, len) = idats[0] + uncompressStream(onData, data[start].unsafeAddr, len, dfZlib) + except ZippyError: + failInvalid() + + if y != height or rowFill != -1: + failInvalid() + proc decodeImageData( data: ptr UncheckedArray[uint8], header: PngHeader, @@ -525,31 +544,26 @@ proc decodeImageData( if idats.len == 0: failInvalid() - var uncompressed = uncompressIdats(data, idats) - if header.interlaceMethod == 0: - let - rowBytes = scanlineBytes(header.width, header) - totalBytes = rowBytes * header.height + # Stream scanlines straight out of the inflate window: peak memory is the + # pixel seq plus a fixed ~64KB, never a whole-image scanline buffer. + var image = newSeq[ColorRGBA](header.width * header.height) + streamIdatRows( + data, header, idats, + proc (y: int, row: seq[uint8]) {.gcsafe, raises: [ZippyError].} = + try: + image.writePixels( + header, palette, transparency, row, + header.width, 1, 0, y, 1, 1 + ) + except PixieError as e: + raise newException(ZippyError, e.msg) + ) + return move(image) - # Uncompressed image data should be the total bytes of pixel data plus - # a filter byte for each row. - if uncompressed.len != totalBytes + header.height: - failInvalid() + var uncompressed = uncompressIdats(data, idats) - # Unfilter in place: peak memory stays at one scanline buffer plus the - # pixel seq instead of two scanline buffers. - unfilterInPlace( - uncompressed, header.height, rowBytes, header.filterBytesPerPixel - ) - result.setLen(header.width * header.height) - result.writePixels( - header, palette, transparency, - uncompressed.toOpenArrayByte(0, header.height * rowBytes - 1), - header.width, header.height, 0, 0, 1, 1 - ) - uncompressed = "" - else: + block: result.setLen(header.width * header.height) const startXs = [0, 4, 0, 2, 0, 1, 0] @@ -599,31 +613,26 @@ proc decodeImageData16( if idats.len == 0: failInvalid() - var uncompressed = uncompressIdats(data, idats) - if header.interlaceMethod == 0: - let - rowBytes = scanlineBytes(header.width, header) - totalBytes = rowBytes * header.height + # Stream scanlines straight out of the inflate window: peak memory is the + # pixel seq plus a fixed ~64KB, never a whole-image scanline buffer. + var image = newSeq[ColorRGBA16](header.width * header.height) + streamIdatRows( + data, header, idats, + proc (y: int, row: seq[uint8]) {.gcsafe, raises: [ZippyError].} = + try: + image.writePixels16( + header, transparency, row, + header.width, 1, 0, y, 1, 1 + ) + except PixieError as e: + raise newException(ZippyError, e.msg) + ) + return move(image) - # Uncompressed image data should be the total bytes of pixel data plus - # a filter byte for each row. - if uncompressed.len != totalBytes + header.height: - failInvalid() + var uncompressed = uncompressIdats(data, idats) - # Unfilter in place: peak memory stays at one scanline buffer plus the - # pixel seq instead of two scanline buffers. - unfilterInPlace( - uncompressed, header.height, rowBytes, header.filterBytesPerPixel - ) - result.setLen(header.width * header.height) - result.writePixels16( - header, transparency, - uncompressed.toOpenArrayByte(0, header.height * rowBytes - 1), - header.width, header.height, 0, 0, 1, 1 - ) - uncompressed = "" - else: + block: result.setLen(header.width * header.height) const startXs = [0, 4, 0, 2, 0, 1, 0] @@ -705,42 +714,47 @@ proc validateScaledPngTarget(target: Image) {.raises: [PixieError].} = validateScaledPngTarget(target.width, target.height) proc scaledFitRects( - png: Png, targetWidth, targetHeight: int, fit: ScaledDecodeFit + srcWidth, srcHeight, targetWidth, targetHeight: int, fit: ScaledDecodeFit ): tuple[srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH: int] = ## Computes the source crop and target placement rectangles for a fit mode. - result = (0, 0, png.width, png.height, 0, 0, targetWidth, targetHeight) + result = (0, 0, srcWidth, srcHeight, 0, 0, targetWidth, targetHeight) case fit of fitStretch: discard of fitCover: - if png.width.int64 * targetHeight.int64 > - targetWidth.int64 * png.height.int64: + if srcWidth.int64 * targetHeight.int64 > + targetWidth.int64 * srcHeight.int64: let cropW = max(1, ( - png.height.int64 * targetWidth.int64 div + srcHeight.int64 * targetWidth.int64 div max(1'i64, targetHeight.int64)).int) - result.srcX = (png.width - cropW) div 2 + result.srcX = (srcWidth - cropW) div 2 result.srcW = cropW else: let cropH = max(1, ( - png.width.int64 * targetHeight.int64 div + srcWidth.int64 * targetHeight.int64 div max(1'i64, targetWidth.int64)).int) - result.srcY = (png.height - cropH) div 2 + result.srcY = (srcHeight - cropH) div 2 result.srcH = cropH of fitContain: - if png.width.int64 * targetHeight.int64 > - targetWidth.int64 * png.height.int64: + if srcWidth.int64 * targetHeight.int64 > + targetWidth.int64 * srcHeight.int64: let fitH = max(1, ( - targetWidth.int64 * png.height.int64 div - max(1'i64, png.width.int64)).int) + targetWidth.int64 * srcHeight.int64 div + max(1'i64, srcWidth.int64)).int) result.dstY = (targetHeight - fitH) div 2 result.dstH = fitH else: let fitW = max(1, ( - targetHeight.int64 * png.width.int64 div - max(1'i64, png.height.int64)).int) + targetHeight.int64 * srcWidth.int64 div + max(1'i64, srcHeight.int64)).int) result.dstX = (targetWidth - fitW) div 2 result.dstW = fitW +proc scaledFitRects( + png: Png, targetWidth, targetHeight: int, fit: ScaledDecodeFit +): tuple[srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH: int] = + scaledFitRects(png.width, png.height, targetWidth, targetHeight, fit) + proc fillImage*( png: Png, target: Image, fit = fitStretch ) {.raises: [PixieError].} = @@ -805,13 +819,51 @@ proc decodePngDimensions*( ## Decodes the PNG dimensions. decodePngDimensions(data.cstring, data.len) -proc decodePng*(data: pointer, len: int): Png {.raises: [PixieError].} = - ## Decodes the PNG data. +type PngStructure = object + header: PngHeader + palette: seq[ColorRGB] + transparency: string + idats: seq[(int, int)] + +proc streamingRowOverheadBytes(header: PngHeader): int64 = + ## Fixed working memory for a streamed non-interlaced decode: zippy's + ## inflate window plus the previous/current row buffers. + 3 * scanlineBytes(header.width, header).int64 + 66_000 + +proc checkDecodeBudget(header: PngHeader, planBytes: int64) = + if overDecodeBudget(planBytes): + raise newException(PixieError, + "PNG decode of " & $header.width & "x" & $header.height & + " needs " & $(planBytes div 1024) & + "K of decode buffers, over the " & + $(decodeBudgetBytes() div 1024) & "K memory budget" + ) + +proc checkFullDecodeBudget(header: PngHeader) = + ## Non-interlaced images stream scanlines out of the inflate window, so + ## the peak is the pixel seq plus a fixed overhead; interlaced images + ## still inflate and unfilter whole-image scanline buffers. + let + pixels = header.width.int64 * header.height.int64 + pixelBytes = pixels * (if header.bitDepth == 16: 8 else: 4) + planBytes = + if header.interlaceMethod == 0: + pixelBytes + header.streamingRowOverheadBytes + else: + let scanlines = scanlineBytes(header.width, header).int64 * + header.height.int64 + header.height.int64 + pixelBytes + 2 * scanlines + checkDecodeBudget(header, planBytes) + +proc parsePngStructure( + data: ptr UncheckedArray[uint8], len: int +): PngStructure {.raises: [PixieError].} = + ## Validates the PNG signature and every chunk (including CRCs) and + ## collects the header, palette, transparency and IDAT locations without + ## decoding any image data. if len < (8 + (8 + 13 + 4) + 4): # Magic bytes + IHDR + IEND failInvalid() - let data = cast[ptr UncheckedArray[uint8]](data) - # PNG file signature let signature = cast[array[8, uint8]](data.readUint64(0)) if signature != pngSignature: @@ -835,27 +887,6 @@ proc decodePng*(data: pointer, len: int): Png {.raises: [PixieError].} = prevChunkType = "IHDR" inc(pos, 13) - # Check the decode plan against the memory budget before any image-sized - # allocation. Non-interlaced images unfilter in place, so the peak is the - # inflated scanlines + the pixel seq; interlaced images still allocate a - # second unfiltered buffer per pass. - block: - let - pixels = header.width.int64 * header.height.int64 - scanlines = scanlineBytes(header.width, header).int64 * - header.height.int64 + header.height.int64 - pixelBytes = pixels * (if header.bitDepth == 16: 8 else: 4) - planBytes = - if header.interlaceMethod == 0: pixelBytes + scanlines - else: pixelBytes + 2 * scanlines - if overDecodeBudget(planBytes): - raise newException(PixieError, - "PNG decode of " & $header.width & "x" & $header.height & - " needs " & $(planBytes div 1024) & - "K of decode buffers, over the " & - $(decodeBudgetBytes() div 1024) & "K memory budget" - ) - let headerCrc = crc32(data[pos - 17].addr, 17) if headerCrc != data.readUint32(pos).swap(): failCRC() @@ -932,31 +963,126 @@ proc decodePng*(data: pointer, len: int): Png {.raises: [PixieError].} = if prevChunkType != "IEND": failInvalid() + PngStructure( + header: header, + palette: palette, + transparency: transparency, + idats: idats + ) + +proc decodeWholePng( + data: ptr UncheckedArray[uint8], structure: PngStructure +): Png {.raises: [PixieError].} = + let header = structure.header + + checkFullDecodeBudget(header) + result = Png() result.width = header.width result.height = header.height result.channels = 4 result.bitDepth = header.bitDepth.int if header.bitDepth == 16: - result.data16 = decodeImageData16(data, header, transparency, idats) + result.data16 = decodeImageData16( + data, header, structure.transparency, structure.idats + ) else: - result.data = decodeImageData(data, header, palette, transparency, idats) + result.data = decodeImageData( + data, header, structure.palette, structure.transparency, structure.idats + ) + +proc decodePng*(data: pointer, len: int): Png {.raises: [PixieError].} = + ## Decodes the PNG data. + let data = cast[ptr UncheckedArray[uint8]](data) + decodeWholePng(data, parsePngStructure(data, len)) proc decodePng*(data: string): Png {.inline, raises: [PixieError].} = ## Decodes the PNG data. decodePng(data.cstring, data.len) +proc canStreamScaled(header: PngHeader): bool {.inline.} = + # Interlaced rows arrive out of order and 16-bit rows would need their own + # sampling path; both are rare, so they take the buffered fallback. + header.interlaceMethod == 0 and header.bitDepth != 16 + +proc decodePngScaledIntoStreaming( + data: ptr UncheckedArray[uint8], + structure: PngStructure, + target: Image, + fit: ScaledDecodeFit +) {.raises: [PixieError].} = + ## Samples scanlines into the target as they stream out of the inflate + ## window. Peak memory: one row of RGBA pixels plus the fixed streaming + ## overhead — the full-size pixel buffer is never allocated. + let header = structure.header + + checkDecodeBudget(header, + header.streamingRowOverheadBytes + header.width.int64 * 4) + + let rects = scaledFitRects( + header.width, header.height, target.width, target.height, fit + ) + + template srcYFor(y: int): int = + min( + rects.srcY + ((y - rects.dstY) * rects.srcH) div rects.dstH, + header.height - 1 + ) + + template srcXFor(x: int): int = + min( + rects.srcX + ((x - rects.dstX) * rects.srcW) div rects.dstW, + header.width - 1 + ) + + var + rowPixels = newSeq[ColorRGBA](header.width) + dstY = rects.dstY + let dstYEnd = rects.dstY + rects.dstH + + streamIdatRows( + data, header, structure.idats, + proc (y: int, row: seq[uint8]) {.gcsafe, raises: [ZippyError].} = + if dstY >= dstYEnd or srcYFor(dstY) != y: + return # No remaining target row samples this source row + try: + rowPixels.writePixels( + header, structure.palette, structure.transparency, row, + header.width, 1, 0, 0, 1, 1 + ) + except PixieError as e: + raise newException(ZippyError, e.msg) + while dstY < dstYEnd and srcYFor(dstY) == y: + for x in rects.dstX ..< rects.dstX + rects.dstW: + target.unsafe[x, dstY] = rowPixels[srcXFor(x)].rgbx() + inc dstY + ) + proc decodePngScaled*( data: pointer, len, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} = ## Decodes the PNG data into an Image scaled to the requested dimensions. - decodePng(data, len).convertToImage(width, height, fit) + validateScaledPngTarget(width, height) + let data = cast[ptr UncheckedArray[uint8]](data) + let structure = parsePngStructure(data, len) + if structure.header.canStreamScaled: + result = newImage(width, height) + decodePngScaledIntoStreaming(data, structure, result, fit) + else: + result = decodeWholePng(data, structure).convertToImage(width, height, fit) proc decodePngScaledInto*( data: pointer, len: int, target: Image, fit = fitStretch ) {.raises: [PixieError].} = - ## Decodes the PNG data into an existing Image. - decodePng(data, len).fillImage(target, fit) + ## Decodes the PNG data into an existing Image. With fitContain, pixels + ## outside the fitted rectangle keep their current contents. + validateScaledPngTarget(target) + let data = cast[ptr UncheckedArray[uint8]](data) + let structure = parsePngStructure(data, len) + if structure.header.canStreamScaled: + decodePngScaledIntoStreaming(data, structure, target, fit) + else: + decodeWholePng(data, structure).fillImage(target, fit) proc decodePngScaled*( data: string, width, height: int, fit = fitStretch @@ -974,27 +1100,27 @@ proc decodePngScaled*( data: var string, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} = ## Decodes the PNG data into a scaled Image and releases the source string - ## before allocating the target Image. - let png = decodePng(data.cstring, data.len) + ## afterwards. The streamed decode never holds a full-size pixel buffer, + ## so releasing the source early no longer matters; it is freed on return + ## for callers that rely on it. + result = decodePngScaled(data.cstring, data.len, width, height, fit) data = "" try: GC_fullCollect() except Exception: discard - png.convertToImage(width, height, fit) proc decodePngScaledInto*( data: var string, target: Image, fit = fitStretch ) {.raises: [PixieError].} = - ## Decodes the PNG data into an existing Image and releases the source string - ## after PNG parsing. - let png = decodePng(data.cstring, data.len) + ## Decodes the PNG data into an existing Image and releases the source + ## string afterwards. + decodePngScaledInto(data.cstring, data.len, target, fit) data = "" try: GC_fullCollect() except Exception: discard - png.fillImage(target, fit) proc encodePng*( width, height, channels: int, data: pointer, len: int diff --git a/tests/test_png.nim b/tests/test_png.nim index 1e3e2a2f..bc9ac8b4 100644 --- a/tests/test_png.nim +++ b/tests/test_png.nim @@ -79,25 +79,49 @@ block: doAssert dimensions.width == 512 doAssert dimensions.height == 512 -block: # in-place unfilter keeps canvas-sized decodes within tight budgets +block: # streamed scanlines keep canvas-sized decodes within tight budgets let source = newImage(480, 800) for y in 0 ..< source.height: for x in 0 ..< source.width: source.unsafe[x, y] = rgbx(uint8(x mod 256), uint8(y mod 256), uint8((x + y) mod 256), 255) let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) - # Plan is pixels + scanlines (~3.0MB); a 4MB budget must accept it - setDecodeBudgetBytes(4 * 1024 * 1024) + # Full decode plan is pixels + fixed streaming overhead (~1.6MB); + # a 2MB budget must accept it + setDecodeBudgetBytes(2 * 1024 * 1024) let decoded = decodePng(encoded).convertToImage() doAssert decoded.width == 480 doAssert decoded.height == 800 doAssert decoded[123, 456] == source[123, 456] - # And a 2MB budget must reject it with a catchable error - setDecodeBudgetBytes(2 * 1024 * 1024) + # A 1MB budget cannot hold the pixels themselves and must reject + setDecodeBudgetBytes(1024 * 1024) try: discard decodePng(encoded) doAssert false except PixieError as e: doAssert "memory budget" in e.msg + + # Scaled decode into a small target never allocates the full pixel + # buffer: a 256KB budget suffices for a 480x800 source + setDecodeBudgetBytes(256 * 1024) + let target = newImage(96, 60) + decodePngScaledInto(encoded, target, fitStretch) + doAssert target[48, 30] == source[240, 400] setDecodeBudgetBytes(0) + +block: # streamed scaled decodes match the buffered fillImage path + let source = newImage(123, 77) + for y in 0 ..< source.height: + for x in 0 ..< source.width: + source.unsafe[x, y] = rgbx(uint8((x * 7) mod 256), uint8((y * 5) mod 256), uint8((x + y) mod 256), 255) + let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) + for fit in [fitStretch, fitCover, fitContain]: + for (w, h) in [(50, 40), (123, 77), (300, 90), (33, 200)]: + let streamed = decodePngScaled(encoded, w, h, fit) + let buffered = decodePng(encoded).convertToImage(w, h, fit) + doAssert streamed.width == buffered.width + doAssert streamed.height == buffered.height + for i in 0 ..< streamed.data.len: + doAssert streamed.data[i] == buffered.data[i], + "pixel mismatch at " & $i & " fit " & $fit & " " & $w & "x" & $h From 4bbd30b8d94398f953a20b0fdab1f726cd2173b2 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Thu, 2 Jul 2026 11:28:50 +0200 Subject: [PATCH 08/29] Require zippy by name; the app pins the fork revision A transitive URL requirement breaks nimble's CI resolution (two zippy sources for one package name); frameos.nimble pins the FrameOS zippy fork at the root instead, the same proven pattern used for pixie. Co-Authored-By: Claude Fable 5 --- pixie.nimble | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pixie.nimble b/pixie.nimble index e63dfaf6..e2e310e0 100644 --- a/pixie.nimble +++ b/pixie.nimble @@ -8,7 +8,7 @@ srcDir = "src" requires "nim >= 2.0.0" requires "vmath >= 3.0.0" requires "chroma >= 1.0.0" -requires "https://github.com/FrameOS/zippy#a109a45a6b16d852ee2912391a0c9241be5fbeb1" +requires "zippy >= 0.10.16" requires "flatty >= 0.3.4" requires "nimsimd >= 1.3.2" requires "bumpy >= 1.1.3" From 075da8d7293a842364ad7f829feeba2eddca2e47 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Thu, 2 Jul 2026 11:46:27 +0200 Subject: [PATCH 09/29] Vendor the streaming zlib inflate; depend on stock zippy CI's nimble cannot reliably resolve a second forked package (transitive or root URL requirements both produced incomplete nimble.paths), so the streaming inflate now lives inside pixie as a self-contained module (huffman machinery vendored from zippy 0.10.16, MIT) raising PixieError directly. The dependency graph returns to the CI-proven shape: one forked package (pixie), stock guzba zippy. Co-Authored-By: Claude Fable 5 --- src/pixie/fileformats/png.nim | 74 ++--- src/pixie/inflatestream.nim | 522 ++++++++++++++++++++++++++++++++++ 2 files changed, 553 insertions(+), 43 deletions(-) create mode 100644 src/pixie/inflatestream.nim diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index a3beacf0..8a00e132 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -1,5 +1,5 @@ import chroma, flatty/binny, ../common, ../decodebudget, ../images, - ../internal, ../simd, zippy, crunchy + ../inflatestream, ../internal, ../simd, zippy, crunchy # See http://www.libpng.org/pub/png/spec/1.2/PNG-Contents.html @@ -434,7 +434,7 @@ proc uncompressIdats( proc unfilterRow( cur: var seq[uint8], prev: seq[uint8], filterType: uint8, rowBytes, bpp: int -) {.raises: [ZippyError].} = +) {.raises: [PixieError].} = ## Unfilters one scanline in place. prev must hold the previous unfiltered ## row (all zeroes for the first row, matching the PNG spec). template paethPredictor(a, b, c: int): int = @@ -470,13 +470,13 @@ proc unfilterRow( upLeft = if x >= bpp: prev[x - bpp].int else: 0 cur[x] = cur[x] + paethPredictor(prev[x].int, left, upLeft).uint8 else: - raise newException(ZippyError, "Invalid PNG row filter") + raise newException(PixieError, "Invalid PNG row filter") proc streamIdatRows( data: ptr UncheckedArray[uint8], header: PngHeader, idats: seq[(int, int)], - onRow: proc (y: int, row: seq[uint8]) {.gcsafe, raises: [ZippyError].} + onRow: proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} ) = ## Inflates the IDAT stream and hands unfiltered scanlines to onRow one at ## a time, in order. Peak memory: zippy's fixed ~64KB streaming window plus @@ -496,11 +496,11 @@ proc streamIdatRows( filterType: uint8 y: int - let onData = proc (chunk: openArray[uint8]) {.gcsafe, raises: [ZippyError].} = + let onData = proc (chunk: openArray[uint8]) {.gcsafe, raises: [PixieError].} = var i = 0 while i < chunk.len: if y >= height: - raise newException(ZippyError, "PNG has too much image data") + raise newException(PixieError, "PNG has too much image data") if rowFill < 0: filterType = chunk[i] inc i @@ -517,19 +517,16 @@ proc streamIdatRows( inc y rowFill = -1 - try: - if idats.len > 1: - var imageData: string - for (start, len) in idats: - let op = imageData.len - imageData.setLen(imageData.len + len) - copyMem(imageData[op].addr, data[start].addr, len) - uncompressStream(onData, imageData.cstring, imageData.len, dfZlib) - else: - let (start, len) = idats[0] - uncompressStream(onData, data[start].unsafeAddr, len, dfZlib) - except ZippyError: - failInvalid() + if idats.len > 1: + var imageData: string + for (start, len) in idats: + let op = imageData.len + imageData.setLen(imageData.len + len) + copyMem(imageData[op].addr, data[start].addr, len) + uncompressStreamZlib(onData, imageData.cstring, imageData.len) + else: + let (start, len) = idats[0] + uncompressStreamZlib(onData, data[start].unsafeAddr, len) if y != height or rowFill != -1: failInvalid() @@ -550,14 +547,11 @@ proc decodeImageData( var image = newSeq[ColorRGBA](header.width * header.height) streamIdatRows( data, header, idats, - proc (y: int, row: seq[uint8]) {.gcsafe, raises: [ZippyError].} = - try: - image.writePixels( - header, palette, transparency, row, - header.width, 1, 0, y, 1, 1 - ) - except PixieError as e: - raise newException(ZippyError, e.msg) + proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} = + image.writePixels( + header, palette, transparency, row, + header.width, 1, 0, y, 1, 1 + ) ) return move(image) @@ -619,14 +613,11 @@ proc decodeImageData16( var image = newSeq[ColorRGBA16](header.width * header.height) streamIdatRows( data, header, idats, - proc (y: int, row: seq[uint8]) {.gcsafe, raises: [ZippyError].} = - try: - image.writePixels16( - header, transparency, row, - header.width, 1, 0, y, 1, 1 - ) - except PixieError as e: - raise newException(ZippyError, e.msg) + proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} = + image.writePixels16( + header, transparency, row, + header.width, 1, 0, y, 1, 1 + ) ) return move(image) @@ -1042,16 +1033,13 @@ proc decodePngScaledIntoStreaming( streamIdatRows( data, header, structure.idats, - proc (y: int, row: seq[uint8]) {.gcsafe, raises: [ZippyError].} = + proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} = if dstY >= dstYEnd or srcYFor(dstY) != y: return # No remaining target row samples this source row - try: - rowPixels.writePixels( - header, structure.palette, structure.transparency, row, - header.width, 1, 0, 0, 1, 1 - ) - except PixieError as e: - raise newException(ZippyError, e.msg) + rowPixels.writePixels( + header, structure.palette, structure.transparency, row, + header.width, 1, 0, 0, 1, 1 + ) while dstY < dstYEnd and srcYFor(dstY) == y: for x in rects.dstX ..< rects.dstX + rects.dstW: target.unsafe[x, dstY] = rowPixels[srcXFor(x)].rgbx() diff --git a/src/pixie/inflatestream.nim b/src/pixie/inflatestream.nim new file mode 100644 index 00000000..d49d3439 --- /dev/null +++ b/src/pixie/inflatestream.nim @@ -0,0 +1,522 @@ +import common + +## Self-contained streaming zlib/deflate decompressor for PNG scanline +## decoding. Emits output through a callback as bytes leave the 32KB +## back-reference window, so peak memory is a fixed ~64KB working buffer +## regardless of the uncompressed size. +## +## The huffman machinery and constants are vendored from zippy 0.10.16 +## (https://github.com/guzba/zippy, MIT license) so this module has no +## dependency beyond pixie/common. The zlib adler32 trailer is not verified +## (the output is not retained to hash); PNG chunk CRCs cover integrity. + +when defined(clang): + func builtinBitreverse16(v: uint16): uint16 {.importc: "__builtin_bitreverse16", nodecl.} + proc reverseBits(v: uint16): uint16 {.inline.} = + builtinBitreverse16(v) +else: + import std/bitops + +const + maxLitLenCodes = 286 + maxDistanceCodes = 30 + maxFixedLitLenCodes = 288 + + baseLengths = [ + 3.uint16, 4, 5, 6, 7, 8, 9, 10, # 257 - 264 + 11, 13, 15, 17, # 265 - 268 + 19, 23, 27, 31, # 269 - 273 + 35, 43, 51, 59, # 274 - 276 + 67, 83, 99, 115, # 278 - 280 + 131, 163, 195, 227, # 281 - 284 + 258 # 285 + ] + + baseLengthsExtraBits = [ + 0.uint8, 0, 0, 0, 0, 0, 0, 0, # 257 - 264 + 1, 1, 1, 1, # 265 - 268 + 2, 2, 2, 2, # 269 - 273 + 3, 3, 3, 3, # 274 - 276 + 4, 4, 4, 4, # 278 - 280 + 5, 5, 5, 5, # 281 - 284 + 0 # 285 + ] + + baseDistances = [ + 1.uint16, 2, 3, 4, # 0-3 + 5, 7, # 4-5 + 9, 13, # 6-7 + 17, 25, # 8-9 + 33, 49, # 10-11 + 65, 97, # 12-13 + 129, 193, # 14-15 + 257, 385, # 16-17 + 513, 769, # 18-19 + 1025, 1537, # 20-21 + 2049, 3073, # 22-23 + 4097, 6145, # 24-25 + 8193, 12289, # 26-27 + 16385, 24577 # 28-29 + ] + + baseDistanceExtraBits = [ + 0.uint8, 0, 0, 0, # 0-3 + 1, 1, # 4-5 + 2, 2, # 6-7 + 3, 3, # 8-9 + 4, 4, # 10-11 + 5, 5, # 12-13 + 6, 6, # 14-15 + 7, 7, # 16-17 + 8, 8, # 18-19 + 9, 9, # 20-21 + 10, 10, # 22-23 + 11, 11, # 24-25 + 12, 12, # 26-27 + 13, 13 # 28-29 + ] + + clclOrder = [ + 16.uint8, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 + ] + + fixedLitLenCodeLengths = block: + var lengths = newSeq[uint8](maxFixedLitLenCodes) + for i in 0 ..< lengths.len: + if i <= 143: + lengths[i] = 8 + elif i <= 255: + lengths[i] = 9 + elif i <= 279: + lengths[i] = 7 + else: + lengths[i] = 8 + lengths + + fixedDistanceCodeLengths = block: + var lengths = newSeq[uint8](maxDistanceCodes) + for i in 0 ..< lengths.len: + lengths[i] = 5 + lengths + + fastBits = 9 + fastMask = (1 shl fastBits) - 1 + + inflateWindowSize = 32768 # Max deflate back-reference distance (RFC 1951) + inflateChunkSize = 32768 + # Room for the longest match (258) plus copy64's 13-byte overwrite slack + inflateBufferSize = inflateWindowSize + inflateChunkSize + 288 + +type + InflateOnData* = proc (data: openArray[uint8]) {.gcsafe, raises: [PixieError].} + + BitStreamReader = object + src: ptr UncheckedArray[uint8] + len, pos: int + when (defined(arm64) and defined(macosx)) or sizeof(int) == 4: + bitBuffer: uint32 + else: + bitBuffer: uint64 + bitsBuffered: int + + Huffman = object + firstCode, firstSymbol: array[16, uint16] + maxCodes: array[17, uint32] + values: array[288, uint16] + fast: array[1 shl fastBits, uint16] + + InflateStreamState = object + buf: string + op: int # Write position; buf[0 ..< op] is not yet emitted + emitted: int # Total bytes handed to onData so far + onData: InflateOnData + +template failStream() = + raise newException(PixieError, "Invalid buffer, unable to uncompress") + +template failStreamEOF() = + raise newException(PixieError, "Cannot read further, at end of buffer") + +when defined(release): + {.push checks: off.} + +proc read64(src: ptr UncheckedArray[uint8], ip: int): uint64 {.inline.} = + copyMem(result.addr, src[ip].unsafeAddr, 8) + +proc write64(dst: ptr UncheckedArray[uint8], op: int, v: uint64) {.inline.} = + copyMem(dst[op].unsafeAddr, v.unsafeAddr, 8) + +proc copy64(dst, src: ptr UncheckedArray[uint8], op, ip: int) {.inline.} = + write64(dst, op, read64(src, ip)) + +proc fillBitBuffer(b: var BitStreamReader) {.inline.} = + let + bufferBitSize = sizeof(b.bitBuffer).uint * 8 + bytesNeeded = cast[int]((bufferBitSize - cast[uint](b.bitsBuffered)) div 8) + bytesAvailable = b.len - b.pos + bytesAdded = min(bytesNeeded, bytesAvailable) + pos = b.pos + + b.pos += bytesAdded + + when sizeof(b.bitBuffer) == 4: + var src: uint32 + if bytesAvailable < 4: + copyMem(src.addr, b.src[b.len - 4].addr, 4) + src = src shr (8 * (4 - bytesAvailable)) + else: + copyMem(src.addr, b.src[pos].addr, 4) + else: + var src: uint64 + if bytesAvailable < 8: + copyMem(src.addr, b.src[b.len - 8].addr, 8) + src = src shr (8 * (8 - bytesAvailable)) + else: + copyMem(src.addr, b.src[pos].addr, 8) + + b.bitBuffer = b.bitBuffer or (src shl b.bitsBuffered) + b.bitsBuffered += 8 * bytesAdded + +proc readBits( + b: var BitStreamReader, + bits: int, + fillBitBuffer: static[bool] = true +): uint16 {.inline.} = + assert bits >= 0 and bits <= 16 + + when fillBitBuffer: + b.fillBitBuffer() + + result = (b.bitBuffer and ((1.uint32 shl bits) - 1)).uint16 + b.bitBuffer = b.bitBuffer shr bits + b.bitsBuffered -= bits # Can go negative if we've read past the end + +proc readBytes(b: var BitStreamReader, dst: pointer, len: int) = + if b.bitsBuffered mod 8 != 0: + raise newException(PixieError, "Must be at a byte boundary") + + let offset = b.bitsBuffered div 8 + if b.pos - offset + len > b.len: + failStreamEOF() + + copyMem(dst, b.src[b.pos - offset].addr, len) + + b.pos = b.pos - offset + len + b.bitsBuffered = 0 + b.bitBuffer = 0 + +proc skipRemainingBitsInCurrentByte(b: var BitStreamReader) = + let mod8 = b.bitsBuffered mod 8 + if mod8 != 0: + b.bitsBuffered -= mod8 + b.bitBuffer = b.bitBuffer shr mod8 + +proc initHuffman(codeLengths: openArray[uint8]): Huffman = + ## See https://raw.githubusercontent.com/madler/zlib/master/doc/algorithm.txt + var histogram: array[17, uint16] + for i in 0 ..< codeLengths.len: + inc histogram[codeLengths[i]] + histogram[0] = 0 + + for i in 1 ..< 16: + if histogram[i] > (1.uint16 shl i): + failStream() + + var + code: uint32 + k: uint16 + nextCode: array[16, uint32] + for i in 1 ..< 16: + nextCode[i] = code + result.firstCode[i] = code.uint16 + result.firstSymbol[i] = k + code = code + histogram[i] + if histogram[i] > 0.uint16 and code - 1 >= (1.uint32 shl i): + failStream() + result.maxCodes[i] = (code shl (16 - i)) + code = code shl 1 + k += histogram[i] + + result.maxCodes[16] = 1 shl 16 + + for i, len in codeLengths: + if len > 0.uint8: + let symbolId = + nextCode[len] - result.firstCode[len] + result.firstSymbol[len] + result.values[symbolId] = i.uint16 + if len <= fastBits: + let fast = (len.uint16 shl fastBits) or i.uint16 + var k = reverseBits(nextCode[len].uint16) shr (16.uint16 - len) + while k < (1 shl fastBits): + result.fast[k] = fast + k += (1.uint16 shl len) + inc nextCode[len] + +proc decodeSymbolSlow(b: var BitStreamReader, h: Huffman): uint16 = + let + k = reverseBits(b.bitBuffer.uint16) + maxCodeLength = h.maxCodes.len.uint16 + var codeLength = fastBits.uint16 + 1 + while codeLength < maxCodeLength: + if k.uint32 < h.maxCodes[codeLength]: + break + inc codeLength + + if codeLength >= 16.uint16: + # Let the callers' checks on the return value raise + return uint16.high + + let symbolId = + (k shr (16.uint16 - codeLength)) - + h.firstCode[codeLength] + + h.firstSymbol[codeLength] + + result = h.values[symbolId] + b.bitBuffer = b.bitBuffer shr codeLength + b.bitsBuffered -= codeLength.int + +proc decodeSymbol(b: var BitStreamReader, h: Huffman): uint16 {.inline.} = + let fast = h.fast[b.bitBuffer and fastMask] + if fast > 0.uint16: + let codeLength = fast shr fastBits + result = fast and fastMask + b.bitBuffer = b.bitBuffer shr codeLength + b.bitsBuffered -= codeLength.int + else: # Slow path + result = b.decodeSymbolSlow(h) + +proc totalOut(s: InflateStreamState): int {.inline.} = + s.emitted + s.op + +proc flushWindow(s: var InflateStreamState) = + ## Emits everything except the trailing window, then slides the window to + ## the front of the buffer. Back-references only reach inflateWindowSize + ## bytes back, so the retained tail is all the history the stream needs. + let emitLen = s.op - inflateWindowSize + if emitLen <= 0: + return + s.onData(s.buf.toOpenArrayByte(0, emitLen - 1)) + s.emitted += emitLen + moveMem(s.buf[0].addr, s.buf[emitLen].addr, inflateWindowSize) + s.op = inflateWindowSize + +proc finishStream(s: var InflateStreamState) = + if s.op > 0: + s.onData(s.buf.toOpenArrayByte(0, s.op - 1)) + s.emitted += s.op + s.op = 0 + +proc inflateBlockStream( + s: var InflateStreamState, + b: var BitStreamReader, + fixedCodes: bool +) = + var literalsHuffman, distancesHuffman: Huffman + if fixedCodes: + literalsHuffman = initHuffman(fixedLitLenCodeLengths) + distancesHuffman = initHuffman(fixedDistanceCodeLengths) + else: + let + hlit = b.readBits(5).int + 257 + hdist = b.readBits(5).int + 1 + hclen = b.readBits(4).int + 4 + + if hlit > maxLitLenCodes: + failStream() + + if hdist > maxDistanceCodes: + failStream() + + var clcls: array[19, uint8] + for i in 0 ..< hclen: + clcls[clclOrder[i]] = b.readBits(3).uint8 + + let clclsHuffman = initHuffman(clcls) + + var + unpacked: array[320, uint8] + i: int + while i != hlit + hdist: + if b.bitsBuffered < 15: + b.fillBitBuffer() + let symbol = decodeSymbol(b, clclsHuffman) + if b.bitsBuffered < 0: + failStreamEOF() + if symbol <= 15: + unpacked[i] = symbol.uint8 + inc i + elif symbol == 16: + if i == 0: + failStream() + let + prev = unpacked[i - 1] + repeatCount = b.readBits(2).int + 3 + if i + repeatCount > unpacked.len: + failStream() + for _ in 0 ..< repeatCount: + unpacked[i] = prev + inc i + elif symbol == 17: + let repeatZeroCount = b.readBits(3).int + 3 + i += repeatZeroCount + elif symbol == 18: + let repeatZeroCount = b.readBits(7).int + 11 + i += repeatZeroCount + else: + raise newException(PixieError, "Invalid symbol") + + if i > hlit + hdist: + failStream() + + literalsHuffman = initHuffman(unpacked.toOpenArray(0, hlit - 1)) + distancesHuffman = initHuffman(unpacked.toOpenArray(hlit, hlit + hdist - 1)) + + while true: + if b.bitsBuffered < 15: + b.fillBitBuffer() + let symbol = decodeSymbol(b, literalsHuffman) + if b.bitsBuffered < 0: + failStreamEOF() + if symbol <= 255: + if s.op >= s.buf.len: + s.flushWindow() + s.buf[s.op] = symbol.char + inc s.op + elif symbol == 256: + break + else: + b.fillBitBuffer() + + let lengthIdx = (symbol - 257).int + if lengthIdx >= baseLengths.len: + failStream() + + let copyLength = ( + baseLengths[lengthIdx] + + b.readBits(baseLengthsExtraBits[lengthIdx].int, false) # Up to 5 + ).int + + let distanceIdx = decodeSymbol(b, distancesHuffman) # Up to 15 + if distanceIdx >= baseDistances.len.uint16: + failStream() + + when sizeof(b.bitBuffer) == 4: + if b.bitsBuffered < 13: + b.fillBitBuffer() + + let distance = ( + baseDistances[distanceIdx] + + b.readBits(baseDistanceExtraBits[distanceIdx].int, false) # Up to 13 + ).int + + if distance > s.totalOut: + failStream() + + # Min match is 3 so leave room to overwrite by 13 + if s.op + copyLength + 13 > s.buf.len: + s.flushWindow() + + # After a flush s.op >= inflateWindowSize >= distance, so the + # back-reference always lands inside the buffer + let dst = cast[ptr UncheckedArray[uint8]](s.buf[0].addr) + + if copyLength <= 16 and distance >= 8: + copy64(dst, dst, s.op, s.op - distance) + copy64(dst, dst, s.op + 8, s.op - distance + 8) + else: + var + copyFrom = s.op - distance + copyTo = s.op + remaining = copyLength + while copyTo - copyFrom < 8: + copy64(dst, dst, copyTo, copyFrom) + remaining -= copyTo - copyFrom + copyTo += copyTo - copyFrom + while remaining > 0: + copy64(dst, dst, copyTo, copyFrom) + copyFrom += 8 + copyTo += 8 + remaining -= 8 + s.op += copyLength + +proc inflateNoCompressionStream( + s: var InflateStreamState, + b: var BitStreamReader +) = + b.skipRemainingBitsInCurrentByte() + var len = b.readBits(16).int + let nlen = b.readBits(16).int + if len + nlen != 65535: + failStream() + while len > 0: + if s.op >= s.buf.len: + s.flushWindow() + let take = min(len, s.buf.len - s.op) + b.readBytes(s.buf[s.op].addr, take) + s.op += take + len -= take + +proc inflateStream( + onData: InflateOnData, + src: ptr UncheckedArray[uint8], + len, pos: int +) = + var + s = InflateStreamState(buf: newString(inflateBufferSize), onData: onData) + b = BitStreamReader(src: src, len: len, pos: pos) + finalBlock: bool + while not finalBlock: + let + bfinal = b.readBits(1) + btype = b.readBits(2) + + if bfinal != 0.uint16: + finalBlock = true + + case btype: + of 0: # No compression + inflateNoCompressionStream(s, b) + of 1: # Compressed with fixed Huffman codes + inflateBlockStream(s, b, true) + of 2: # Compressed with dynamic Huffman codes + inflateBlockStream(s, b, false) + else: + raise newException(PixieError, "Invalid block header") + + s.finishStream() + +proc uncompressStreamZlib*( + onData: InflateOnData, + src: pointer, + len: int +) {.raises: [PixieError].} = + ## Uncompresses a zlib stream, emitting output through onData in order as + ## it is decompressed. Peak memory stays at a fixed ~64KB working buffer + ## regardless of the uncompressed size. + let src = cast[ptr UncheckedArray[uint8]](src) + + if len < 6: + failStream() + + let + cmf = src[0].uint8 + flg = src[1].uint8 + cm = cmf and 0b00001111 + cinfo = cmf shr 4 + + if cm != 8: # DEFLATE + raise newException(PixieError, "Unsupported compression method") + + if cinfo > 7.uint8: + raise newException(PixieError, "Invalid compression info") + + if ((cmf.uint16 * 256) + flg.uint16) mod 31 != 0: + raise newException(PixieError, "Invalid header") + + if (flg and 0b00100000) != 0: # FDICT + raise newException(PixieError, "Preset dictionary is not yet supported") + + inflateStream(onData, src, len, 2) + +when defined(release): + {.pop.} From 4913417d4137d14411b357b50178808e82f3b527 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Tue, 14 Jul 2026 00:03:08 +0200 Subject: [PATCH 10/29] png: inflate multi-IDAT streams as segments, no concatenation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming inflate only took one contiguous buffer, so PNGs that split their compressed data across many IDAT chunks (libpng emits 8K chunks; a 2.6MB gallery PNG has 326) were concatenated into a second multi-MB allocation before decoding — the allocation that OOMed streamed decodes on fragmented ESP32 PSRAM. The bit reader now consumes a list of InflateSegments, crossing chunk boundaries byte-wise and keeping the fast word-load path within a segment. Verified: a real 2.6MB multi-IDAT PNG stream-decodes into a 1200x1600 target under a 1MB decode budget, pixel-identical to the buffered path; IDATs re-chunked at 1..8192 bytes decode identically; PNG fuzzer clean over 10k iterations. Co-Authored-By: Claude Fable 5 --- src/pixie/fileformats/png.nim | 19 ++-- src/pixie/inflatestream.nim | 157 +++++++++++++++++++++++----------- tests/test_png.nim | 64 +++++++++++++- 3 files changed, 179 insertions(+), 61 deletions(-) diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index 8a00e132..8c993467 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -517,16 +517,15 @@ proc streamIdatRows( inc y rowFill = -1 - if idats.len > 1: - var imageData: string - for (start, len) in idats: - let op = imageData.len - imageData.setLen(imageData.len + len) - copyMem(imageData[op].addr, data[start].addr, len) - uncompressStreamZlib(onData, imageData.cstring, imageData.len) - else: - let (start, len) = idats[0] - uncompressStreamZlib(onData, data[start].unsafeAddr, len) + # Feed the IDAT chunks to the inflater as segments: big PNGs commonly + # split their compressed data across hundreds of IDATs, and concatenating + # them would momentarily double the compressed-body allocation. + var segments = newSeq[InflateSegment](idats.len) + for i, (start, len) in idats: + segments[i] = InflateSegment( + data: cast[ptr UncheckedArray[uint8]](data[start].unsafeAddr), len: len + ) + uncompressStreamZlib(onData, segments) if y != height or rowFill != -1: failInvalid() diff --git a/src/pixie/inflatestream.nim b/src/pixie/inflatestream.nim index d49d3439..536671b1 100644 --- a/src/pixie/inflatestream.nim +++ b/src/pixie/inflatestream.nim @@ -110,9 +110,18 @@ const type InflateOnData* = proc (data: openArray[uint8]) {.gcsafe, raises: [PixieError].} + InflateSegment* = object + ## One piece of a compressed stream. Lets callers hand over data that is + ## not contiguous in memory (e.g. a PNG's many IDAT chunks) without + ## first concatenating it into one big allocation. + data*: ptr UncheckedArray[uint8] + len*: int + BitStreamReader = object - src: ptr UncheckedArray[uint8] - len, pos: int + segments: seq[InflateSegment] + segIndex: int + src: ptr UncheckedArray[uint8] # Current segment's data + len, pos: int # Current segment's length and read position when (defined(arm64) and defined(macosx)) or sizeof(int) == 4: bitBuffer: uint32 else: @@ -149,33 +158,56 @@ proc write64(dst: ptr UncheckedArray[uint8], op: int, v: uint64) {.inline.} = proc copy64(dst, src: ptr UncheckedArray[uint8], op, ip: int) {.inline.} = write64(dst, op, read64(src, ip)) +proc advanceSegment(b: var BitStreamReader): bool = + ## Moves to the next non-empty segment. Returns false at end of input. + while b.segIndex + 1 < b.segments.len: + inc b.segIndex + let segment = b.segments[b.segIndex] + if segment.len > 0: + b.src = segment.data + b.len = segment.len + b.pos = 0 + return true + false + +proc initBitStreamReader(segments: openArray[InflateSegment]): BitStreamReader = + result = BitStreamReader(segments: @segments, segIndex: -1) + if not result.advanceSegment(): + # No data at all; leave an empty current segment so reads hit EOF paths + result.src = nil + result.len = 0 + result.pos = 0 + +proc fillBitBufferSlow(b: var BitStreamReader) = + ## Byte-at-a-time refill for segment boundaries and the end of the stream. + ## Bits above bitsBuffered are always either zero or the true next stream + ## bits (see fillBitBuffer), so OR-ing the same byte again is harmless. + let bufferBitSize = sizeof(b.bitBuffer) * 8 + while b.bitsBuffered <= bufferBitSize - 8: + if b.pos >= b.len: + if not b.advanceSegment(): + return # At end of input: consumers drive bitsBuffered negative + continue + b.bitBuffer = b.bitBuffer or + (typeof(b.bitBuffer)(b.src[b.pos]) shl b.bitsBuffered) + inc b.pos + b.bitsBuffered += 8 + proc fillBitBuffer(b: var BitStreamReader) {.inline.} = - let - bufferBitSize = sizeof(b.bitBuffer).uint * 8 - bytesNeeded = cast[int]((bufferBitSize - cast[uint](b.bitsBuffered)) div 8) - bytesAvailable = b.len - b.pos - bytesAdded = min(bytesNeeded, bytesAvailable) - pos = b.pos - - b.pos += bytesAdded - - when sizeof(b.bitBuffer) == 4: - var src: uint32 - if bytesAvailable < 4: - copyMem(src.addr, b.src[b.len - 4].addr, 4) - src = src shr (8 * (4 - bytesAvailable)) - else: - copyMem(src.addr, b.src[pos].addr, 4) + # Fast path: a whole bit-buffer's worth of bytes remains in the current + # segment. Loads the full word; bits beyond the bytes accounted for are the + # true next stream bytes, so the next fill ORs identical values over them. + if b.pos + sizeof(b.bitBuffer) <= b.len: + let + bufferBitSize = sizeof(b.bitBuffer).uint * 8 + bytesNeeded = cast[int]((bufferBitSize - cast[uint](b.bitsBuffered)) div 8) + var src: typeof(b.bitBuffer) + copyMem(src.addr, b.src[b.pos].addr, sizeof(src)) + b.bitBuffer = b.bitBuffer or (src shl b.bitsBuffered) + b.pos += bytesNeeded + b.bitsBuffered += 8 * bytesNeeded else: - var src: uint64 - if bytesAvailable < 8: - copyMem(src.addr, b.src[b.len - 8].addr, 8) - src = src shr (8 * (8 - bytesAvailable)) - else: - copyMem(src.addr, b.src[pos].addr, 8) - - b.bitBuffer = b.bitBuffer or (src shl b.bitsBuffered) - b.bitsBuffered += 8 * bytesAdded + b.fillBitBufferSlow() proc readBits( b: var BitStreamReader, @@ -195,15 +227,27 @@ proc readBytes(b: var BitStreamReader, dst: pointer, len: int) = if b.bitsBuffered mod 8 != 0: raise newException(PixieError, "Must be at a byte boundary") - let offset = b.bitsBuffered div 8 - if b.pos - offset + len > b.len: - failStreamEOF() - - copyMem(dst, b.src[b.pos - offset].addr, len) - - b.pos = b.pos - offset + len - b.bitsBuffered = 0 - b.bitBuffer = 0 + let dst = cast[ptr UncheckedArray[uint8]](dst) + var i = 0 + # Drain whole bytes already sitting in the bit buffer (they may have been + # loaded from an earlier segment, so rewinding pos is not an option) + while i < len and b.bitsBuffered >= 8: + dst[i] = uint8(b.bitBuffer and 0xff) + b.bitBuffer = b.bitBuffer shr 8 + b.bitsBuffered -= 8 + inc i + if b.bitsBuffered == 0: + b.bitBuffer = 0 # Clear any true-next-byte bits loaded beyond bitsBuffered + # Copy the rest straight from the segments + while i < len: + if b.pos >= b.len: + if not b.advanceSegment(): + failStreamEOF() + continue + let take = min(len - i, b.len - b.pos) + copyMem(dst[i].addr, b.src[b.pos].addr, take) + i += take + b.pos += take proc skipRemainingBitsInCurrentByte(b: var BitStreamReader) = let mod8 = b.bitsBuffered mod 8 @@ -458,12 +502,10 @@ proc inflateNoCompressionStream( proc inflateStream( onData: InflateOnData, - src: ptr UncheckedArray[uint8], - len, pos: int + b: var BitStreamReader ) = var s = InflateStreamState(buf: newString(inflateBufferSize), onData: onData) - b = BitStreamReader(src: src, len: len, pos: pos) finalBlock: bool while not finalBlock: let @@ -487,20 +529,24 @@ proc inflateStream( proc uncompressStreamZlib*( onData: InflateOnData, - src: pointer, - len: int + segments: openArray[InflateSegment] ) {.raises: [PixieError].} = - ## Uncompresses a zlib stream, emitting output through onData in order as - ## it is decompressed. Peak memory stays at a fixed ~64KB working buffer - ## regardless of the uncompressed size. - let src = cast[ptr UncheckedArray[uint8]](src) - - if len < 6: + ## Uncompresses a zlib stream split across any number of segments (e.g. a + ## PNG's IDAT chunks), emitting output through onData in order as it is + ## decompressed. Peak memory stays at a fixed ~64KB working buffer + ## regardless of the uncompressed size; the segments are never copied + ## into a contiguous buffer. + var total = 0 + for segment in segments: + total += segment.len + if total < 6: failStream() + var b = initBitStreamReader(segments) + let - cmf = src[0].uint8 - flg = src[1].uint8 + cmf = b.readBits(8).uint8 + flg = b.readBits(8).uint8 cm = cmf and 0b00001111 cinfo = cmf shr 4 @@ -516,7 +562,18 @@ proc uncompressStreamZlib*( if (flg and 0b00100000) != 0: # FDICT raise newException(PixieError, "Preset dictionary is not yet supported") - inflateStream(onData, src, len, 2) + inflateStream(onData, b) + +proc uncompressStreamZlib*( + onData: InflateOnData, + src: pointer, + len: int +) {.raises: [PixieError].} = + ## Uncompresses a contiguous zlib stream; see the segmented overload. + let segment = [InflateSegment( + data: cast[ptr UncheckedArray[uint8]](src), len: len + )] + uncompressStreamZlib(onData, segment) when defined(release): {.pop.} diff --git a/tests/test_png.nim b/tests/test_png.nim index bc9ac8b4..e0259017 100644 --- a/tests/test_png.nim +++ b/tests/test_png.nim @@ -1,4 +1,4 @@ -import pixie, pixie/fileformats/png, pixie/decodebudget, pngsuite, strformat, strutils +import pixie, pixie/fileformats/png, pixie/decodebudget, pngsuite, strformat, strutils, crunchy, flatty/binny when defined(writeImages): import write_images @@ -125,3 +125,65 @@ block: # streamed scaled decodes match the buffered fillImage path for i in 0 ..< streamed.data.len: doAssert streamed.data[i] == buffered.data[i], "pixel mismatch at " & $i & " fit " & $fit & " " & $w & "x" & $h + +block: # multi-IDAT PNGs decode without concatenating the compressed stream + # Real-world encoders split compressed data across hundreds of IDAT + # chunks (libpng defaults to 8K). The inflater consumes them as segments; + # re-chunk a PNG's IDAT at awkward sizes — including 1-byte chunks that + # split the zlib header — and require identical output. + proc rechunkIdat(encoded: string, chunkSize: int): string = + ## Rewrites a PNG so its IDAT payload is split into chunkSize-byte IDATs. + var idatData = "" + result = encoded[0 ..< 8] # signature + var pos = 8 + while pos < encoded.len: + let + chunkLen = int(encoded.readUint32(pos).swap()) + chunkType = encoded[pos + 4 ..< pos + 8] + # collect IDAT payloads, copy everything else verbatim (before IEND) + if chunkType == "IDAT": + idatData.add(encoded[pos + 8 ..< pos + 8 + chunkLen]) + elif chunkType == "IEND": + break + else: + result.add(encoded[pos ..< pos + 12 + chunkLen]) + pos += 12 + chunkLen + + var off = 0 + while off < idatData.len: + let n = min(chunkSize, idatData.len - off) + var chunk = "" + chunk.addUint32(n.uint32.swap()) + chunk.add("IDAT") + chunk.add(idatData[off ..< off + n]) + chunk.addUint32(crc32(chunk[chunk.len - n - 4].addr, n + 4).swap()) + result.add(chunk) + off += n + + var iend = "" + iend.addUint32(0.uint32.swap()) + iend.add("IEND") + iend.addUint32(crc32(iend[iend.len - 4].addr, 4).swap()) + result.add(iend) + + let source = newImage(101, 53) + for y in 0 ..< source.height: + for x in 0 ..< source.width: + source.unsafe[x, y] = rgbx(uint8((x * 3) mod 256), uint8((y * 11) mod 256), uint8((x * y) mod 256), 255) + let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) + let reference = decodePng(encoded).convertToImage() + + for chunkSize in [1, 2, 3, 7, 64, 8192, 1 shl 30]: + let rechunked = rechunkIdat(encoded, chunkSize) + let decoded = decodePng(rechunked).convertToImage() + doAssert decoded.width == reference.width and decoded.height == reference.height + for i in 0 ..< decoded.data.len: + doAssert decoded.data[i] == reference.data[i], + "pixel mismatch at " & $i & " with IDAT chunk size " & $chunkSize + + let target = newImage(48, 20) + decodePngScaledInto(rechunked, target, fitStretch) + let expected = decodePng(encoded).convertToImage(48, 20, fitStretch) + for i in 0 ..< target.data.len: + doAssert target.data[i] == expected.data[i], + "scaled pixel mismatch at " & $i & " with IDAT chunk size " & $chunkSize From 8b6b6e72c854c3334be3f8efc99cea1cf604500a Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Tue, 14 Jul 2026 00:55:58 +0200 Subject: [PATCH 11/29] png: decode from segmented sources decodePngScaledInto now accepts a list of InflateSegments, so a PNG whose bytes arrived in fixed-size download chunks decodes without ever being assembled into one contiguous buffer: the segmented parser validates every chunk CRC across boundaries (incremental crc32) and hands IDAT spans to the segmented inflater as sub-slices. Non-interlaced <=8-bit images stream scanlines into the target; interlaced/16-bit fall back to coalescing plus the buffered decode. streamIdatRows now takes inflater segments directly, shared by the contiguous and segmented paths. Co-Authored-By: Claude Fable 5 --- src/pixie/fileformats/png.nim | 290 +++++++++++++++++++++++++++++++--- tests/test_png.nim | 125 ++++++++++----- 2 files changed, 361 insertions(+), 54 deletions(-) diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index 8c993467..f12a3f4f 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -472,16 +472,28 @@ proc unfilterRow( else: raise newException(PixieError, "Invalid PNG row filter") +proc idatSlices( + data: ptr UncheckedArray[uint8], idats: seq[(int, int)] +): seq[InflateSegment] = + ## The IDAT chunks of a contiguous PNG as inflater segments — big PNGs + ## commonly split their compressed data across hundreds of IDATs, and + ## concatenating them would momentarily double the compressed-body + ## allocation. + result = newSeq[InflateSegment](idats.len) + for i, (start, len) in idats: + result[i] = InflateSegment( + data: cast[ptr UncheckedArray[uint8]](data[start].unsafeAddr), len: len + ) + proc streamIdatRows( - data: ptr UncheckedArray[uint8], + idatSegments: seq[InflateSegment], header: PngHeader, - idats: seq[(int, int)], onRow: proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} ) = ## Inflates the IDAT stream and hands unfiltered scanlines to onRow one at ## a time, in order. Peak memory: zippy's fixed ~64KB streaming window plus ## two row buffers, regardless of image size. Non-interlaced images only. - if idats.len == 0: + if idatSegments.len == 0: failInvalid() let @@ -517,15 +529,7 @@ proc streamIdatRows( inc y rowFill = -1 - # Feed the IDAT chunks to the inflater as segments: big PNGs commonly - # split their compressed data across hundreds of IDATs, and concatenating - # them would momentarily double the compressed-body allocation. - var segments = newSeq[InflateSegment](idats.len) - for i, (start, len) in idats: - segments[i] = InflateSegment( - data: cast[ptr UncheckedArray[uint8]](data[start].unsafeAddr), len: len - ) - uncompressStreamZlib(onData, segments) + uncompressStreamZlib(onData, idatSegments) if y != height or rowFill != -1: failInvalid() @@ -545,7 +549,7 @@ proc decodeImageData( # pixel seq plus a fixed ~64KB, never a whole-image scanline buffer. var image = newSeq[ColorRGBA](header.width * header.height) streamIdatRows( - data, header, idats, + idatSlices(data, idats), header, proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} = image.writePixels( header, palette, transparency, row, @@ -611,7 +615,7 @@ proc decodeImageData16( # pixel seq plus a fixed ~64KB, never a whole-image scanline buffer. var image = newSeq[ColorRGBA16](header.width * header.height) streamIdatRows( - data, header, idats, + idatSlices(data, idats), header, proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} = image.writePixels16( header, transparency, row, @@ -996,7 +1000,7 @@ proc canStreamScaled(header: PngHeader): bool {.inline.} = header.interlaceMethod == 0 and header.bitDepth != 16 proc decodePngScaledIntoStreaming( - data: ptr UncheckedArray[uint8], + idatSegments: seq[InflateSegment], structure: PngStructure, target: Image, fit: ScaledDecodeFit @@ -1031,7 +1035,7 @@ proc decodePngScaledIntoStreaming( let dstYEnd = rects.dstY + rects.dstH streamIdatRows( - data, header, structure.idats, + idatSegments, header, proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} = if dstY >= dstYEnd or srcYFor(dstY) != y: return # No remaining target row samples this source row @@ -1054,7 +1058,7 @@ proc decodePngScaled*( let structure = parsePngStructure(data, len) if structure.header.canStreamScaled: result = newImage(width, height) - decodePngScaledIntoStreaming(data, structure, result, fit) + decodePngScaledIntoStreaming(idatSlices(data, structure.idats), structure, result, fit) else: result = decodeWholePng(data, structure).convertToImage(width, height, fit) @@ -1067,7 +1071,7 @@ proc decodePngScaledInto*( let data = cast[ptr UncheckedArray[uint8]](data) let structure = parsePngStructure(data, len) if structure.header.canStreamScaled: - decodePngScaledIntoStreaming(data, structure, target, fit) + decodePngScaledIntoStreaming(idatSlices(data, structure.idats), structure, target, fit) else: decodeWholePng(data, structure).fillImage(target, fit) @@ -1109,6 +1113,256 @@ proc decodePngScaledInto*( except Exception: discard +# ------------------------------------------------------------------------ +# Segmented sources: decode a PNG whose bytes are spread across several +# non-contiguous buffers (e.g. an HTTP body downloaded in fixed-size chunks +# on a device whose fragmented heap has no room for one contiguous copy). + +const pngCrcTable = block: + var table: array[256, uint32] + for i in 0 ..< 256: + var c = i.uint32 + for _ in 0 ..< 8: + c = if (c and 1) != 0: 0xEDB88320'u32 xor (c shr 1) else: c shr 1 + table[i] = c + table + +proc pngCrcUpdate(crc: uint32, data: openArray[uint8], len: int): uint32 = + result = crc + for i in 0 ..< len: + result = pngCrcTable[(result xor data[i].uint32) and 0xff] xor (result shr 8) + +type PngSegmentReader = object + segments: seq[InflateSegment] + seg, offset: int # Current segment and offset within it + pos: int # Global position + total: int + +proc initPngSegmentReader(segments: openArray[InflateSegment]): PngSegmentReader = + result.segments = @segments + for segment in segments: + result.total += segment.len + +proc readBytesCrc( + r: var PngSegmentReader, dst: ptr UncheckedArray[uint8], len: int, + crc: var uint32, updateCrc: bool +) = + ## Reads len bytes across segments into dst (skips when dst is nil), + ## optionally folding them into crc. + var remaining = len + var written = 0 + while remaining > 0: + if r.seg >= r.segments.len: + failInvalid() + let segment = r.segments[r.seg] + let available = segment.len - r.offset + if available <= 0: + inc r.seg + r.offset = 0 + continue + let take = min(remaining, available) + if updateCrc: + crc = crc.pngCrcUpdate( + segment.data.toOpenArray(r.offset, r.offset + take - 1), take) + if dst != nil: + copyMem(dst[written].addr, segment.data[r.offset].addr, take) + written += take + r.offset += take + r.pos += take + remaining -= take + +proc readBytes(r: var PngSegmentReader, dst: ptr UncheckedArray[uint8], len: int) = + var crc: uint32 + r.readBytesCrc(dst, len, crc, false) + +proc readU32be(r: var PngSegmentReader): uint32 = + var bytes: array[4, uint8] + r.readBytes(cast[ptr UncheckedArray[uint8]](bytes[0].addr), 4) + (bytes[0].uint32 shl 24) or (bytes[1].uint32 shl 16) or + (bytes[2].uint32 shl 8) or bytes[3].uint32 + +proc sliceSegments( + segments: openArray[InflateSegment], start, len: int +): seq[InflateSegment] = + ## The [start, start+len) global span of the segments, as segment slices. + var + remaining = len + pos = 0 + for segment in segments: + if remaining == 0: + break + let segStart = pos + pos += segment.len + if pos <= start: + continue + let offset = max(start - segStart, 0) + let take = min(segment.len - offset, remaining) + if take > 0: + result.add(InflateSegment( + data: cast[ptr UncheckedArray[uint8]](segment.data[offset].addr), + len: take + )) + remaining -= take + if remaining != 0: + failInvalid() + +proc parsePngStructure( + segments: openArray[InflateSegment] +): PngStructure {.raises: [PixieError].} = + ## Segmented mirror of the contiguous parser: validates the signature and + ## every chunk CRC, collecting the header, palette, transparency and + ## global IDAT spans without copying the image data anywhere. + var r = initPngSegmentReader(segments) + if r.total < (8 + (8 + 13 + 4) + 4): # Magic bytes + IHDR + IEND + failInvalid() + + var signature: array[8, uint8] + r.readBytes(cast[ptr UncheckedArray[uint8]](signature[0].addr), 8) + if signature != pngSignature: + failInvalid() + + var + counts = ChunkCounts() + header: PngHeader + palette: seq[ColorRGB] + transparency: string + idats: seq[(int, int)] + prevChunkType: string + + # First chunk must be IHDR + var chunkType: array[4, uint8] + var crc = 0xffffffff'u32 + if r.readU32be() != 13: + failInvalid() + r.readBytesCrc(cast[ptr UncheckedArray[uint8]](chunkType[0].addr), 4, crc, true) + if chunkType != [73'u8, 72, 68, 82]: # "IHDR" + failInvalid() + var ihdr: array[13, uint8] + r.readBytesCrc(cast[ptr UncheckedArray[uint8]](ihdr[0].addr), 13, crc, true) + header = decodeHeader(ihdr[0].addr) + prevChunkType = "IHDR" + if (crc xor 0xffffffff'u32) != r.readU32be(): + failCRC() + + while true: + if r.pos + 8 > r.total: + failInvalid() + + let chunkLen = r.readU32be().int + crc = 0xffffffff'u32 + r.readBytesCrc(cast[ptr UncheckedArray[uint8]](chunkType[0].addr), 4, crc, true) + let chunkTypeStr = block: + var s = newString(4) + copyMem(s[0].addr, chunkType[0].addr, 4) + s + + if chunkLen > high(int32).int: + failInvalid() + + if r.pos + chunkLen + 4 > r.total: + failInvalid() + + case chunkTypeStr: + of "IHDR": + failInvalid() + of "PLTE": + inc counts.PLTE + if counts.PLTE > 1 or counts.IDAT > 0 or counts.tRNS > 0: + failInvalid() + var paletteBytes = newSeq[uint8](chunkLen) + if chunkLen > 0: + r.readBytesCrc( + cast[ptr UncheckedArray[uint8]](paletteBytes[0].addr), + chunkLen, crc, true) + palette = decodePalette( + if chunkLen > 0: paletteBytes[0].addr else: nil, chunkLen) + of "tRNS": + inc counts.tRNS + if counts.tRNS > 1 or counts.IDAT > 0: + failInvalid() + transparency = newString(chunkLen) + if chunkLen > 0: + r.readBytesCrc( + cast[ptr UncheckedArray[uint8]](transparency[0].addr), + chunkLen, crc, true) + case header.colorType: + of 0: + if transparency.len != 2: + failInvalid() + of 2: + if transparency.len != 6: + failInvalid() + of 3: + if transparency.len > palette.len: + failInvalid() + else: + failInvalid() + of "IDAT": + inc counts.IDAT + if counts.IDAT > 1 and prevChunkType != "IDAT": + failInvalid() + if header.colorType == 3 and counts.PLTE == 0: + failInvalid() + idats.add((r.pos, chunkLen)) + r.readBytesCrc(nil, chunkLen, crc, true) + of "IEND": + if chunkLen != 0: + failInvalid() + else: + if (chunkType[0] and 0b00100000) == 0: + raise newException( + PixieError, "Unrecognized PNG critical chunk " & chunkTypeStr + ) + r.readBytesCrc(nil, chunkLen, crc, true) + + if (crc xor 0xffffffff'u32) != r.readU32be(): + failCRC() + + prevChunkType = chunkTypeStr + + if r.pos == r.total or prevChunkType == "IEND": + break + + if prevChunkType != "IEND": + failInvalid() + + PngStructure( + header: header, + palette: palette, + transparency: transparency, + idats: idats + ) + +proc coalesceSegments(segments: openArray[InflateSegment]): string = + var total = 0 + for segment in segments: + total += segment.len + result = newString(total) + var pos = 0 + for segment in segments: + if segment.len > 0: + copyMem(result[pos].addr, segment.data[0].addr, segment.len) + pos += segment.len + +proc decodePngScaledInto*( + segments: openArray[InflateSegment], target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## Decodes a PNG spread across non-contiguous buffers into an existing + ## Image. Non-interlaced ≤8-bit images stream scanlines straight out of + ## the inflate window without ever assembling a contiguous copy of the + ## file or a full-size pixel buffer; interlaced/16-bit images fall back + ## to coalescing and the buffered decode. + validateScaledPngTarget(target) + let structure = parsePngStructure(segments) + if structure.header.canStreamScaled: + var idatSegments: seq[InflateSegment] + for (start, len) in structure.idats: + idatSegments.add(sliceSegments(segments, start, len)) + decodePngScaledIntoStreaming(idatSegments, structure, target, fit) + else: + var data = coalesceSegments(segments) + decodePng(data.cstring, data.len).fillImage(target, fit) + proc encodePng*( width, height, channels: int, data: pointer, len: int ): string {.raises: [PixieError].} = diff --git a/tests/test_png.nim b/tests/test_png.nim index e0259017..de4171c5 100644 --- a/tests/test_png.nim +++ b/tests/test_png.nim @@ -1,4 +1,4 @@ -import pixie, pixie/fileformats/png, pixie/decodebudget, pngsuite, strformat, strutils, crunchy, flatty/binny +import pixie, pixie/fileformats/png, pixie/decodebudget, pixie/inflatestream, pngsuite, strformat, strutils, crunchy, flatty/binny when defined(writeImages): import write_images @@ -126,46 +126,47 @@ block: # streamed scaled decodes match the buffered fillImage path doAssert streamed.data[i] == buffered.data[i], "pixel mismatch at " & $i & " fit " & $fit & " " & $w & "x" & $h +proc rechunkIdat(encoded: string, chunkSize: int): string = + ## Rewrites a PNG so its IDAT payload is split into chunkSize-byte IDATs. + var idatData = "" + result = encoded[0 ..< 8] # signature + var pos = 8 + while pos < encoded.len: + let + chunkLen = int(encoded.readUint32(pos).swap()) + chunkType = encoded[pos + 4 ..< pos + 8] + # collect IDAT payloads, copy everything else verbatim (before IEND) + if chunkType == "IDAT": + idatData.add(encoded[pos + 8 ..< pos + 8 + chunkLen]) + elif chunkType == "IEND": + break + else: + result.add(encoded[pos ..< pos + 12 + chunkLen]) + pos += 12 + chunkLen + + var off = 0 + while off < idatData.len: + let n = min(chunkSize, idatData.len - off) + var chunk = "" + chunk.addUint32(n.uint32.swap()) + chunk.add("IDAT") + chunk.add(idatData[off ..< off + n]) + chunk.addUint32(crc32(chunk[chunk.len - n - 4].addr, n + 4).swap()) + result.add(chunk) + off += n + + var iend = "" + iend.addUint32(0.uint32.swap()) + iend.add("IEND") + iend.addUint32(crc32(iend[iend.len - 4].addr, 4).swap()) + result.add(iend) + + block: # multi-IDAT PNGs decode without concatenating the compressed stream # Real-world encoders split compressed data across hundreds of IDAT # chunks (libpng defaults to 8K). The inflater consumes them as segments; # re-chunk a PNG's IDAT at awkward sizes — including 1-byte chunks that # split the zlib header — and require identical output. - proc rechunkIdat(encoded: string, chunkSize: int): string = - ## Rewrites a PNG so its IDAT payload is split into chunkSize-byte IDATs. - var idatData = "" - result = encoded[0 ..< 8] # signature - var pos = 8 - while pos < encoded.len: - let - chunkLen = int(encoded.readUint32(pos).swap()) - chunkType = encoded[pos + 4 ..< pos + 8] - # collect IDAT payloads, copy everything else verbatim (before IEND) - if chunkType == "IDAT": - idatData.add(encoded[pos + 8 ..< pos + 8 + chunkLen]) - elif chunkType == "IEND": - break - else: - result.add(encoded[pos ..< pos + 12 + chunkLen]) - pos += 12 + chunkLen - - var off = 0 - while off < idatData.len: - let n = min(chunkSize, idatData.len - off) - var chunk = "" - chunk.addUint32(n.uint32.swap()) - chunk.add("IDAT") - chunk.add(idatData[off ..< off + n]) - chunk.addUint32(crc32(chunk[chunk.len - n - 4].addr, n + 4).swap()) - result.add(chunk) - off += n - - var iend = "" - iend.addUint32(0.uint32.swap()) - iend.add("IEND") - iend.addUint32(crc32(iend[iend.len - 4].addr, 4).swap()) - result.add(iend) - let source = newImage(101, 53) for y in 0 ..< source.height: for x in 0 ..< source.width: @@ -187,3 +188,55 @@ block: # multi-IDAT PNGs decode without concatenating the compressed stream for i in 0 ..< target.data.len: doAssert target.data[i] == expected.data[i], "scaled pixel mismatch at " & $i & " with IDAT chunk size " & $chunkSize + +block: # segmented sources decode identically to contiguous ones + # A PNG downloaded in fixed-size chunks never gets assembled into one + # contiguous buffer; decodePngScaledInto(segments) must parse chunk CRCs + # across boundaries and stream IDATs spanning multiple segments. + proc segmentsOf(data: string, sizes: seq[int]): seq[InflateSegment] = + var pos = 0 + var i = 0 + while pos < data.len: + let n = min(sizes[i mod sizes.len], data.len - pos) + result.add(InflateSegment( + data: cast[ptr UncheckedArray[uint8]](data[pos].unsafeAddr), len: n + )) + pos += n + inc i + + let source = newImage(97, 61) + for y in 0 ..< source.height: + for x in 0 ..< source.width: + source.unsafe[x, y] = rgbx(uint8((x * 13) mod 256), uint8((y * 3) mod 256), uint8((x xor y) mod 256), 255) + let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) + + for idatChunkSize in [1, 3, 61, 8192]: + let rechunked = rechunkIdat(encoded, idatChunkSize) + let expected = newImage(40, 25) + decodePngScaledInto(rechunked, expected, fitStretch) + for segSizes in [@[1], @[2, 3, 5], @[7], @[100], @[64 * 1024]]: + let target = newImage(40, 25) + decodePngScaledInto(segmentsOf(rechunked, segSizes), target, fitStretch) + for i in 0 ..< target.data.len: + doAssert target.data[i] == expected.data[i], + "segmented mismatch at " & $i & " idat=" & $idatChunkSize & " segs=" & $segSizes + + # Palette + transparency PNGs exercise PLTE/tRNS chunk handling + for file in ["basn3p08", "tbbn3p08", "basn6a08"]: + let original = readFile(&"tests/fileformats/png/pngsuite/{file}.png") + let expected = newImage(24, 24) + decodePngScaledInto(original, expected, fitStretch) + let target = newImage(24, 24) + decodePngScaledInto(segmentsOf(original, @[5, 11]), target, fitStretch) + for i in 0 ..< target.data.len: + doAssert target.data[i] == expected.data[i], file + + # Corrupted files must still fail cleanly through the segmented parser + for file in pngSuiteCorruptedFiles: + let original = readFile(&"tests/fileformats/png/pngsuite/{file}.png") + try: + let target = newImage(8, 8) + decodePngScaledInto(segmentsOf(original, @[9]), target, fitStretch) + doAssert false + except PixieError: + discard From 17512aefb22bbb7041ab622820771a26f2e9ecc0 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 8 Aug 2026 02:11:01 +0200 Subject: [PATCH 12/29] png: file-backed streaming decode via pull sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decodePngStreamScaledInto(source, totalLen, target, fit) decodes a PNG read sequentially from a callback (e.g. a download spilled to disk on a device without the memory to buffer it). The chunk walker validates CRCs as bytes stream by and feeds IDAT payloads to the inflater through a 16KB read buffer, so peak memory is that buffer plus the fixed streaming overhead — the compressed file is never resident. The streaming inflater gains InflatePull, a pull-based input the reader falls back to when its segment list is exhausted; input consumption is strictly sequential so each pulled buffer may be reused. Co-Authored-By: Claude Fable 5 --- src/pixie/fileformats/png.nim | 304 +++++++++++++++++++++++++++++++--- src/pixie/inflatestream.nim | 72 ++++++-- tests/test_png.nim | 74 +++++++++ 3 files changed, 412 insertions(+), 38 deletions(-) diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index f12a3f4f..926af1ab 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -485,17 +485,21 @@ proc idatSlices( data: cast[ptr UncheckedArray[uint8]](data[start].unsafeAddr), len: len ) -proc streamIdatRows( - idatSegments: seq[InflateSegment], +type InflateRunProc = proc ( + onData: InflateOnData +) {.gcsafe, raises: [PixieError].} + ## Runs one full inflate of a PNG's IDAT stream, emitting the uncompressed + ## bytes through onData. Abstracts over where the compressed bytes live + ## (in-memory segments or a pull source). + +proc streamRows( header: PngHeader, - onRow: proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} + onRow: proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].}, + inflate: InflateRunProc ) = ## Inflates the IDAT stream and hands unfiltered scanlines to onRow one at - ## a time, in order. Peak memory: zippy's fixed ~64KB streaming window plus + ## a time, in order. Peak memory: the fixed ~64KB streaming window plus ## two row buffers, regardless of image size. Non-interlaced images only. - if idatSegments.len == 0: - failInvalid() - let rowBytes = scanlineBytes(header.width, header) bpp = header.filterBytesPerPixel @@ -529,11 +533,24 @@ proc streamIdatRows( inc y rowFill = -1 - uncompressStreamZlib(onData, idatSegments) + inflate(onData) if y != height or rowFill != -1: failInvalid() +proc streamIdatRows( + idatSegments: seq[InflateSegment], + header: PngHeader, + onRow: proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} +) = + ## streamRows over IDAT chunks that are resident in memory. + if idatSegments.len == 0: + failInvalid() + streamRows(header, onRow, + proc (onData: InflateOnData) {.gcsafe, raises: [PixieError].} = + uncompressStreamZlib(onData, idatSegments) + ) + proc decodeImageData( data: ptr UncheckedArray[uint8], header: PngHeader, @@ -1000,10 +1017,10 @@ proc canStreamScaled(header: PngHeader): bool {.inline.} = header.interlaceMethod == 0 and header.bitDepth != 16 proc decodePngScaledIntoStreaming( - idatSegments: seq[InflateSegment], structure: PngStructure, target: Image, - fit: ScaledDecodeFit + fit: ScaledDecodeFit, + inflate: InflateRunProc ) {.raises: [PixieError].} = ## Samples scanlines into the target as they stream out of the inflate ## window. Peak memory: one row of RGBA pixels plus the fixed streaming @@ -1034,19 +1051,32 @@ proc decodePngScaledIntoStreaming( dstY = rects.dstY let dstYEnd = rects.dstY + rects.dstH - streamIdatRows( - idatSegments, header, - proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} = - if dstY >= dstYEnd or srcYFor(dstY) != y: - return # No remaining target row samples this source row - rowPixels.writePixels( - header, structure.palette, structure.transparency, row, - header.width, 1, 0, 0, 1, 1 - ) - while dstY < dstYEnd and srcYFor(dstY) == y: - for x in rects.dstX ..< rects.dstX + rects.dstW: - target.unsafe[x, dstY] = rowPixels[srcXFor(x)].rgbx() - inc dstY + let onRow = proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} = + if dstY >= dstYEnd or srcYFor(dstY) != y: + return # No remaining target row samples this source row + rowPixels.writePixels( + header, structure.palette, structure.transparency, row, + header.width, 1, 0, 0, 1, 1 + ) + while dstY < dstYEnd and srcYFor(dstY) == y: + for x in rects.dstX ..< rects.dstX + rects.dstW: + target.unsafe[x, dstY] = rowPixels[srcXFor(x)].rgbx() + inc dstY + + streamRows(header, onRow, inflate) + +proc decodePngScaledIntoStreaming( + idatSegments: seq[InflateSegment], + structure: PngStructure, + target: Image, + fit: ScaledDecodeFit +) {.raises: [PixieError].} = + ## The streaming scaled decode over IDAT chunks resident in memory. + if idatSegments.len == 0: + failInvalid() + decodePngScaledIntoStreaming(structure, target, fit, + proc (onData: InflateOnData) {.gcsafe, raises: [PixieError].} = + uncompressStreamZlib(onData, idatSegments) ) proc decodePngScaled*( @@ -1363,6 +1393,234 @@ proc decodePngScaledInto*( var data = coalesceSegments(segments) decodePng(data.cstring, data.len).fillImage(target, fit) +# ------------------------------------------------------------------------ +# Pull sources: decode a PNG read sequentially from a callback (e.g. a +# download spilled to disk on a device without the memory to buffer it). +# Peak memory is one small read buffer plus the fixed streaming decode +# overhead; the compressed file is never held in memory. + +type PngSourceProc* = proc( + dst: pointer, maxBytes: int +): int {.gcsafe, raises: [].} + ## Pull callback for streamed decodes: fill `dst` with up to `maxBytes` + ## sequential input bytes, returning how many were written (<= 0 on EOF + ## or read error — the decode then fails with a catchable PixieError). + +const pngStreamReadBytes = 16384 + +type PngStreamReader = object + source: PngSourceProc + totalLen: int # <= 0 when unknown + pos: int + +proc readExactCrc( + r: var PngStreamReader, dst: ptr UncheckedArray[uint8], len: int, + crc: var uint32, updateCrc: bool +) = + var done = 0 + while done < len: + let got = r.source(dst[done].addr, len - done) + if got <= 0: + failInvalid() + if updateCrc: + crc = crc.pngCrcUpdate(dst.toOpenArray(done, done + got - 1), got) + done += got + r.pos += got + +proc readU32be(r: var PngStreamReader): uint32 = + var + bytes: array[4, uint8] + crc: uint32 + r.readExactCrc(cast[ptr UncheckedArray[uint8]](bytes[0].addr), 4, crc, false) + (bytes[0].uint32 shl 24) or (bytes[1].uint32 shl 16) or + (bytes[2].uint32 shl 8) or bytes[3].uint32 + +proc decodePngStreamScaledInto*( + source: PngSourceProc, totalLen: int, target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## Decodes a PNG pulled sequentially from `source` (e.g. a file on disk) + ## into an existing Image, sampling scanlines as they stream out of the + ## inflate window. Peak memory: one small read buffer, one source row of + ## RGBA pixels and the fixed inflate overhead — neither the compressed + ## file nor a full-size pixel buffer is ever resident. Non-interlaced + ## images up to 8 bits per channel only; interlaced/16-bit PNGs raise a + ## PixieError since they would need the whole-file buffering this decoder + ## exists to avoid. Chunk CRCs are validated as they stream by; chunks + ## after the image data are not read. Pass the input size as totalLen for + ## chunk bounds checks, or <= 0 when unknown. + validateScaledPngTarget(target) + if source == nil: + failInvalid() + + var + r = PngStreamReader(source: source, totalLen: totalLen) + scratchCrc: uint32 + signature: array[8, uint8] + r.readExactCrc( + cast[ptr UncheckedArray[uint8]](signature[0].addr), 8, scratchCrc, false) + if signature != pngSignature: + failInvalid() + + # First chunk must be IHDR + var + chunkType: array[4, uint8] + crc = 0xffffffff'u32 + if r.readU32be() != 13: + failInvalid() + r.readExactCrc( + cast[ptr UncheckedArray[uint8]](chunkType[0].addr), 4, crc, true) + if chunkType != [73'u8, 72, 68, 82]: # "IHDR" + failInvalid() + var ihdr: array[13, uint8] + r.readExactCrc(cast[ptr UncheckedArray[uint8]](ihdr[0].addr), 13, crc, true) + let header = decodeHeader(ihdr[0].addr) + if (crc xor 0xffffffff'u32) != r.readU32be(): + failCRC() + + if not header.canStreamScaled: + raise newException(PixieError, + "Streamed PNG decode supports non-interlaced images up to 8 bits per channel" + ) + + var + counts = ChunkCounts() + palette: seq[ColorRGB] + transparency: string + buffer = newSeq[uint8](pngStreamReadBytes) + idatRemaining = -1 + + # Walk the metadata chunks up to the first IDAT + while idatRemaining < 0: + if r.totalLen > 0 and r.pos + 8 > r.totalLen: + failInvalid() + let chunkLen = r.readU32be().int + if chunkLen > high(int32).int: + failInvalid() + if r.totalLen > 0 and r.pos + 4 + chunkLen + 4 > r.totalLen: + failInvalid() + crc = 0xffffffff'u32 + r.readExactCrc( + cast[ptr UncheckedArray[uint8]](chunkType[0].addr), 4, crc, true) + let chunkTypeStr = block: + var s = newString(4) + copyMem(s[0].addr, chunkType[0].addr, 4) + s + + case chunkTypeStr: + of "IHDR": + failInvalid() + of "PLTE": + inc counts.PLTE + if counts.PLTE > 1 or counts.tRNS > 0: + failInvalid() + if chunkLen == 0 or chunkLen > 768: + failInvalid() + var paletteBytes = newSeq[uint8](chunkLen) + r.readExactCrc( + cast[ptr UncheckedArray[uint8]](paletteBytes[0].addr), + chunkLen, crc, true) + palette = decodePalette(paletteBytes[0].addr, chunkLen) + of "tRNS": + inc counts.tRNS + if counts.tRNS > 1 or chunkLen > 768: + failInvalid() + transparency = newString(chunkLen) + if chunkLen > 0: + r.readExactCrc( + cast[ptr UncheckedArray[uint8]](transparency[0].addr), + chunkLen, crc, true) + case header.colorType: + of 0: + if transparency.len != 2: + failInvalid() + of 2: + if transparency.len != 6: + failInvalid() + of 3: + if transparency.len > palette.len: + failInvalid() + else: + failInvalid() + of "IDAT": + if header.colorType == 3 and counts.PLTE == 0: + failInvalid() + idatRemaining = chunkLen + of "IEND": + failInvalid() # No image data + else: + if (chunkType[0] and 0b00100000) == 0: + raise newException( + PixieError, "Unrecognized PNG critical chunk " & chunkTypeStr + ) + # Skip ancillary chunk payloads through the read buffer + var remaining = chunkLen + while remaining > 0: + let take = min(remaining, buffer.len) + r.readExactCrc( + cast[ptr UncheckedArray[uint8]](buffer[0].addr), take, crc, true) + remaining -= take + + if idatRemaining < 0: + if (crc xor 0xffffffff'u32) != r.readU32be(): + failCRC() + + let structure = PngStructure( + header: header, palette: palette, transparency: transparency + ) + + # crc currently covers the first IDAT's type bytes; the pull below keeps + # folding payload bytes into it and verifies at each chunk boundary. + var idatDone = false + let pull = proc (): InflateSegment {.gcsafe, raises: [PixieError].} = + while not idatDone: + if idatRemaining == 0: + # IDAT boundary: verify this chunk's CRC, then continue only when + # the next chunk is another IDAT (they must be consecutive). + if (crc xor 0xffffffff'u32) != r.readU32be(): + failCRC() + if r.totalLen > 0 and r.pos + 8 > r.totalLen: + failInvalid() + let chunkLen = r.readU32be().int + if chunkLen > high(int32).int: + failInvalid() + crc = 0xffffffff'u32 + r.readExactCrc( + cast[ptr UncheckedArray[uint8]](chunkType[0].addr), 4, crc, true) + if chunkType != [73'u8, 68, 65, 84]: # "IDAT" + idatDone = true + break + if r.totalLen > 0 and r.pos + chunkLen + 4 > r.totalLen: + failInvalid() + idatRemaining = chunkLen + continue + let take = min(buffer.len, idatRemaining) + let got = r.source(buffer[0].addr, take) + if got <= 0: + failInvalid() + crc = crc.pngCrcUpdate(buffer.toOpenArray(0, got - 1), got) + r.pos += got + idatRemaining -= got + return InflateSegment( + data: cast[ptr UncheckedArray[uint8]](buffer[0].addr), len: got) + InflateSegment(data: nil, len: 0) + + decodePngScaledIntoStreaming(structure, target, fit, + proc (onData: InflateOnData) {.gcsafe, raises: [PixieError].} = + uncompressStreamZlib(onData, pull) + ) + + # The inflater stops at the zlib stream's end, usually leaving the last + # IDAT's tail (the adler32 trailer) unread. Drain it and verify that + # chunk's CRC too, so a corrupted final IDAT still fails the decode. + if not idatDone: + while idatRemaining > 0: + let take = min(buffer.len, idatRemaining) + r.readExactCrc( + cast[ptr UncheckedArray[uint8]](buffer[0].addr), take, crc, true) + idatRemaining -= take + if (crc xor 0xffffffff'u32) != r.readU32be(): + failCRC() + proc encodePng*( width, height, channels: int, data: pointer, len: int ): string {.raises: [PixieError].} = diff --git a/src/pixie/inflatestream.nim b/src/pixie/inflatestream.nim index 536671b1..011c2bec 100644 --- a/src/pixie/inflatestream.nim +++ b/src/pixie/inflatestream.nim @@ -117,8 +117,15 @@ type data*: ptr UncheckedArray[uint8] len*: int + InflatePull* = proc (): InflateSegment {.gcsafe, raises: [PixieError].} + ## Pull callback for streaming input: returns the next segment of the + ## compressed stream, or a segment with len <= 0 at end of input. Each + ## segment is consumed fully before the next pull, so the callback may + ## reuse the same backing buffer (e.g. one file-read buffer) every call. + BitStreamReader = object segments: seq[InflateSegment] + pull: InflatePull segIndex: int src: ptr UncheckedArray[uint8] # Current segment's data len, pos: int # Current segment's length and read position @@ -168,6 +175,14 @@ proc advanceSegment(b: var BitStreamReader): bool = b.len = segment.len b.pos = 0 return true + if b.pull != nil: + let segment = b.pull() + if segment.len > 0 and segment.data != nil: + b.src = segment.data + b.len = segment.len + b.pos = 0 + return true + b.pull = nil false proc initBitStreamReader(segments: openArray[InflateSegment]): BitStreamReader = @@ -178,6 +193,13 @@ proc initBitStreamReader(segments: openArray[InflateSegment]): BitStreamReader = result.len = 0 result.pos = 0 +proc initBitStreamReader(pull: InflatePull): BitStreamReader = + result = BitStreamReader(segIndex: -1, pull: pull) + if not result.advanceSegment(): + result.src = nil + result.len = 0 + result.pos = 0 + proc fillBitBufferSlow(b: var BitStreamReader) = ## Byte-at-a-time refill for segment boundaries and the end of the stream. ## Bits above bitsBuffered are always either zero or the true next stream @@ -527,29 +549,19 @@ proc inflateStream( s.finishStream() -proc uncompressStreamZlib*( +proc uncompressZlibFrom( onData: InflateOnData, - segments: openArray[InflateSegment] + b: var BitStreamReader ) {.raises: [PixieError].} = - ## Uncompresses a zlib stream split across any number of segments (e.g. a - ## PNG's IDAT chunks), emitting output through onData in order as it is - ## decompressed. Peak memory stays at a fixed ~64KB working buffer - ## regardless of the uncompressed size; the segments are never copied - ## into a contiguous buffer. - var total = 0 - for segment in segments: - total += segment.len - if total < 6: - failStream() - - var b = initBitStreamReader(segments) - let cmf = b.readBits(8).uint8 flg = b.readBits(8).uint8 cm = cmf and 0b00001111 cinfo = cmf shr 4 + if b.bitsBuffered < 0: + failStreamEOF() + if cm != 8: # DEFLATE raise newException(PixieError, "Unsupported compression method") @@ -564,6 +576,36 @@ proc uncompressStreamZlib*( inflateStream(onData, b) +proc uncompressStreamZlib*( + onData: InflateOnData, + segments: openArray[InflateSegment] +) {.raises: [PixieError].} = + ## Uncompresses a zlib stream split across any number of segments (e.g. a + ## PNG's IDAT chunks), emitting output through onData in order as it is + ## decompressed. Peak memory stays at a fixed ~64KB working buffer + ## regardless of the uncompressed size; the segments are never copied + ## into a contiguous buffer. + var total = 0 + for segment in segments: + total += segment.len + if total < 6: + failStream() + + var b = initBitStreamReader(segments) + uncompressZlibFrom(onData, b) + +proc uncompressStreamZlib*( + onData: InflateOnData, + pull: InflatePull +) {.raises: [PixieError].} = + ## Uncompresses a zlib stream whose input arrives on demand from `pull` + ## (e.g. read from a file), emitting output through onData in order as it + ## is decompressed. Only the pull callback's current segment is held at a + ## time, so neither the compressed input nor the output ever needs to fit + ## in memory. + var b = initBitStreamReader(pull) + uncompressZlibFrom(onData, b) + proc uncompressStreamZlib*( onData: InflateOnData, src: pointer, diff --git a/tests/test_png.nim b/tests/test_png.nim index de4171c5..88fecf29 100644 --- a/tests/test_png.nim +++ b/tests/test_png.nim @@ -240,3 +240,77 @@ block: # segmented sources decode identically to contiguous ones doAssert false except PixieError: discard + +block: # pull sources (file-backed) decode identically to buffered ones + # A download spilled to disk decodes through a sequential read callback; + # the whole compressed file is never in memory. Exercise awkward read + # granularities so chunk headers, CRCs and IDAT payloads all straddle + # read boundaries. + proc sourceOf(data: string, readSize: int): PngSourceProc = + var pos = 0 + result = proc (dst: pointer, maxBytes: int): int = + let n = min(min(readSize, maxBytes), data.len - pos) + if n <= 0: + return 0 + copyMem(dst, data[pos].unsafeAddr, n) + pos += n + n + + let source = newImage(89, 47) + for y in 0 ..< source.height: + for x in 0 ..< source.width: + source.unsafe[x, y] = rgbx(uint8((x * 5) mod 256), uint8((y * 9) mod 256), uint8((x + 2 * y) mod 256), 255) + let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) + + for idatChunkSize in [1, 3, 61, 8192, 1 shl 30]: + let rechunked = rechunkIdat(encoded, idatChunkSize) + for fit in [fitStretch, fitCover, fitContain]: + let expected = newImage(40, 25) + decodePngScaledInto(rechunked, expected, fit) + for readSize in [1, 7, 1000, 1 shl 20]: + let target = newImage(40, 25) + decodePngStreamScaledInto(sourceOf(rechunked, readSize), rechunked.len, target, fit) + for i in 0 ..< target.data.len: + doAssert target.data[i] == expected.data[i], + "pull mismatch at " & $i & " idat=" & $idatChunkSize & + " read=" & $readSize & " fit=" & $fit + + # Palette + transparency PNGs exercise PLTE/tRNS chunk handling + for file in ["basn3p08", "tbbn3p08", "basn6a08"]: + let original = readFile(&"tests/fileformats/png/pngsuite/{file}.png") + let expected = newImage(24, 24) + decodePngScaledInto(original, expected, fitStretch) + let target = newImage(24, 24) + decodePngStreamScaledInto(sourceOf(original, 11), original.len, target, fitStretch) + for i in 0 ..< target.data.len: + doAssert target.data[i] == expected.data[i], file + + # Interlaced and 16-bit PNGs cannot stream and must fail cleanly + for file in ["basi2c08", "basn2c16"]: + let original = readFile(&"tests/fileformats/png/pngsuite/{file}.png") + try: + let target = newImage(8, 8) + decodePngStreamScaledInto(sourceOf(original, 100), original.len, target, fitStretch) + doAssert false + except PixieError as e: + doAssert "non-interlaced" in e.msg + + # Truncated input fails instead of hanging on the exhausted source + block: + let truncated = encoded[0 ..< encoded.len div 2] + try: + let target = newImage(8, 8) + decodePngStreamScaledInto(sourceOf(truncated, 100), truncated.len, target, fitStretch) + doAssert false + except PixieError: + discard + + # Corrupted files must still fail cleanly through the pull parser + for file in pngSuiteCorruptedFiles: + let original = readFile(&"tests/fileformats/png/pngsuite/{file}.png") + try: + let target = newImage(8, 8) + decodePngStreamScaledInto(sourceOf(original, 9), original.len, target, fitStretch) + doAssert false + except PixieError: + discard From 46d64bc223680fcd7bb9dc1b726e594591251006 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 8 Aug 2026 13:05:39 +0200 Subject: [PATCH 13/29] jpeg: fix EXIF orientation for little-endian (II) files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orientation SHORT occupies the first two bytes of the 4-byte IFD data field. After the full-word maybeSwap it sits in the LOW word for little-endian files, but the value was always taken from the high word (`shr 16`) — so orientation from II-endian cameras (Sony, Canon) was silently read as 0 and photos rendered sideways. Big-endian (MM) files, which all the existing f1..f8 fixtures use, were unaffected. Adds II-endian variants of the f1..f8 orientation fixtures and a test asserting they decode pixel-identically to the MM originals. Co-Authored-By: Claude Fable 5 --- src/pixie/fileformats/jpeg.nim | 9 ++++++++- tests/fileformats/jpeg/masters/f1-exif-ii.jpg | Bin 0 -> 806 bytes tests/fileformats/jpeg/masters/f2-exif-ii.jpg | Bin 0 -> 808 bytes tests/fileformats/jpeg/masters/f3-exif-ii.jpg | Bin 0 -> 806 bytes tests/fileformats/jpeg/masters/f4-exif-ii.jpg | Bin 0 -> 808 bytes tests/fileformats/jpeg/masters/f5-exif-ii.jpg | Bin 0 -> 794 bytes tests/fileformats/jpeg/masters/f6-exif-ii.jpg | Bin 0 -> 796 bytes tests/fileformats/jpeg/masters/f7-exif-ii.jpg | Bin 0 -> 794 bytes tests/fileformats/jpeg/masters/f8-exif-ii.jpg | Bin 0 -> 796 bytes tests/test_jpeg.nim | 11 +++++++++++ 10 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 tests/fileformats/jpeg/masters/f1-exif-ii.jpg create mode 100644 tests/fileformats/jpeg/masters/f2-exif-ii.jpg create mode 100644 tests/fileformats/jpeg/masters/f3-exif-ii.jpg create mode 100644 tests/fileformats/jpeg/masters/f4-exif-ii.jpg create mode 100644 tests/fileformats/jpeg/masters/f5-exif-ii.jpg create mode 100644 tests/fileformats/jpeg/masters/f6-exif-ii.jpg create mode 100644 tests/fileformats/jpeg/masters/f7-exif-ii.jpg create mode 100644 tests/fileformats/jpeg/masters/f8-exif-ii.jpg diff --git a/src/pixie/fileformats/jpeg.nim b/src/pixie/fileformats/jpeg.nim index eb8b3abe..3789d40a 100644 --- a/src/pixie/fileformats/jpeg.nim +++ b/src/pixie/fileformats/jpeg.nim @@ -723,7 +723,14 @@ proc decodeExif(state: var DecoderState) = # For now we only care about orientation tag. case tagNumber: of 0x0112: # Orientation - state.orientation = dataOffset shr 16 + # The SHORT value occupies the first two bytes of the 4-byte data + # field. After the full-word maybeSwap above it sits in the low word + # for little-endian (II) files and the high word for big-endian (MM) + # ones; `shr 16` alone silently dropped orientation for II files + # (Sony/Canon), leaving photos sideways. + state.orientation = + if littleEndian: dataOffset and 0xffff + else: dataOffset shr 16 else: discard diff --git a/tests/fileformats/jpeg/masters/f1-exif-ii.jpg b/tests/fileformats/jpeg/masters/f1-exif-ii.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cceec6943a6e6ae064c35169bc758352f6d9870e GIT binary patch literal 806 zcmex=&g!NbMF!_CFb z&C4ewz{@Ad$IUGuCLky*A}T7%!!Ir&CL$puA}RthgpnDjhlQ1sm6cP3mz!6FWbpq0 zgCGY(0D}fIqaXv5AS1INRO;Y1B)Q5kfNa@n{Z$vyHcTuQRBpg9Li1`4~hm|{Gei-RMf=DB_=K*DW$5W zuA!-AVrph?VQJ;;;_Bw^;pr6|5*ijB5gC=7lA4yDk(pIoQd(ACQCZd8(%RPE(b+X= z@|3C5rq7r;YtiB*OP4KQv2xX>&0Dr^+rDGxu0w~996fgY#K}{aE?>EN?fQ+Iw;n!v z{N(Ag=PzEq`uOSdm#^Qx|M>X}L#mO literal 0 HcmV?d00001 diff --git a/tests/fileformats/jpeg/masters/f2-exif-ii.jpg b/tests/fileformats/jpeg/masters/f2-exif-ii.jpg new file mode 100644 index 0000000000000000000000000000000000000000..aa0d909cd95dd4c1309591ca3f3d7e63c1fafb35 GIT binary patch literal 808 zcmex=>10y3Ng9i{Y z{Qt)w>|B(ZSdyBeP@Y+mq2TW68}R=&gEIp&6B7sl0SgeZa@(J>Ba|?(G2nvgcii+~^i%W=!NC=6DihvAZWCrSCVdZ3HJq?U}9uu zW@2Fmxf-at7AViaBFHMFXz0i$9GJ+iR48K9IB_9|veU+cqCpows2C>|HF0u@iAzXI zsj8`KXlj|5nweWzS~We&gn? zhmRgVdHU@6i$mSee*Oaai;;mD;w`w((EJ4q1V$zn7G@T9kiQt2%7K_! zkcCyzkWI)jkUg>10y3Ng9i{Y z{Qt)w>|B(ZSdyBeP@Y+mq2TW68}R=&gEIp&6B7sl0SgeZa@(J>Ba|?(G2nvgcii+~^i%W=!NC=6DihvAZWCrSCVdZ3HJq?U}9uu zW@2Fmxf-at7AViaBFHMFXz0i$9GJ+iR48K9IB_9|veU+cqCpows2C>|HF0u@iAzXI zsj8`KXlj|5nweWzS~We&gn? zhmRgVdHU@6i$mSee*Oaai;;mD;w`w((EJ4q1V$zn7G@T9kiQt2%7K_! zkcCyzkWI)jkUgwA2Uh<7CIHl*`n&)D literal 0 HcmV?d00001 diff --git a/tests/fileformats/jpeg/masters/f4-exif-ii.jpg b/tests/fileformats/jpeg/masters/f4-exif-ii.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5a9a4ac45ab2d40d061eb7000c14b87ab9a53a26 GIT binary patch literal 808 zcmex=mKj{{e%5mz$>>10y3Ng9i{Y z{Qt)w>|B(ZSdyBeP@Y+mq2TW68}R=&gEIp&6B7sl0SgeZa@(J>Ba|?(G2nvgcii+~^i%W=!NC=6DihvAZWCrSCVdZ3HJq?U}9uu zW@2Fmxf-at7AViaBFHMFXz0i$9GJ+iR48K9IB_9|veU+cqCpows2C>|HF0u@iAzXI zsj8`KXlj|5nweWzS~We&gn? zhmRgVdHU@6i$mSee*Oaai;;mD;w`w((EJ4q1V$zn7G@T9kiQt2%7K_! zkcCyzkWI)jkUgpjz_@Bl#*stmWF_{|dr&cliTLorvG5iaC c!Tf6lP!y<+`DZw)7LXWNk|Zr)JO1AU04xUlA^-pY literal 0 HcmV?d00001 diff --git a/tests/fileformats/jpeg/masters/f5-exif-ii.jpg b/tests/fileformats/jpeg/masters/f5-exif-ii.jpg new file mode 100644 index 0000000000000000000000000000000000000000..210acf0a78954e10f37693f23614d550dabbc0eb GIT binary patch literal 794 zcmex=>10y3Ng9i{Y z{Qt)w>|B(ZSdyBeP@Y+mq2TW68}R=&gEIp&6B7sl0SgeZa@(J>Ba|?(G2nvgcii+~^i%W=!NC=6DihvAZWCrSCVdZ3HJq?U}9uu zW@2Fmxf-at7AViaBFHMFXz0i$9GJ+iR48K9IB_9|veU+cqCpows2C>|HF0u@iAzXI zsj8`KXlj|5nweWzS~We&gn? zhmRgVdHU@6i$mSee*Oaai;;mD;w`w((EJ4q1V$zn7G@T9kiQt2%7K_! zkcCyzkWI)jkUg@{Qv>10y3Ng9i{Y z{Qt)w>|B(ZSdyBeP@Y+mq2TW68}R=&gEIp&6B7sl0SgeZa@(J>Ba|?(G2nvgcii+~^i%W=!NC=6DihvAZWCrSCVdZ3HJq?U}9uu zW@2Fmxf-at7AViaBFHMFXz0i$9GJ+iR48K9IB_9|veU+cqCpows2C>|HF0u@iAzXI zsj8`KXlj|5nweWzS~We&gn? zhmRgVdHU@6i$mSee*Oaai;;mD;w`w((EJ4q1V$zn7G@T9kiQt2%7K_! zkcCyzkWI)jkUgr8w$0;{{Kw?92@cV literal 0 HcmV?d00001 diff --git a/tests/fileformats/jpeg/masters/f7-exif-ii.jpg b/tests/fileformats/jpeg/masters/f7-exif-ii.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f2872d93a75e8e642aeb61855a38d61713cd1916 GIT binary patch literal 794 zcmex=>10y3Ng9i{Y z{Qt)w>|B(ZSdyBeP@Y+mq2TW68}R=&gEIp&6B7sl0SgeZa@(J>Ba|?(G2nvgcii+~^i%W=!NC=6DihvAZWCrSCVdZ3HJq?U}9uu zW@2Fmxf-at7AViaBFHMFXz0i$9GJ+iR48K9IB_9|veU+cqCpows2C>|HF0u@iAzXI zsj8`KXlj|5nweWzS~We&gn? zhmRgVdHU@6i$mSee*Oaai;;mD;w`w((EJ4q1V$zn7G@T9kiQt2%7K_! zkcCyzkWI)jkUg-}rwA`MuL#ND z{{aR;4h9W|0A@x(1|~s9WTDoi-j64Z8S2#W<;`iIYoATtZSx zRZU$(Q_IBE%-q7#%Gt%$&E3P(D>x)HEIcAIDmf)JEj=SMtGJ}Jth}PKs=1}Lt-YhO zYtrN?Q>RUzF>}_U#Y>hhTfSoDs!f}>Y~8kf$Ie}c4j(ys?D&b3r!HN-a`oEv8#iw~ zeDwIq(`V0LynOZX)8{W=zkUDl^B2fpj10^WZ^3ugFVBaRSTFJxPUQ=pzK$5!TA3|Uoih#0hU5y0u}Q=jcb6aLt-MC NgsL1W0ap6|CICtP^TGfC literal 0 HcmV?d00001 diff --git a/tests/test_jpeg.nim b/tests/test_jpeg.nim index fb78e557..4901f39b 100644 --- a/tests/test_jpeg.nim +++ b/tests/test_jpeg.nim @@ -21,3 +21,14 @@ block: doAssert target.width == 13 doAssert target.height == 9 doAssert data.len == 0 + +block: + # Little-endian (II) EXIF must decode identically to the big-endian (MM) + # originals: the orientation SHORT sits in the opposite word of the data + # field, which `shr 16` alone silently dropped (Sony/Canon files). + for n in 1 .. 8: + let + mm = decodeJpeg(readFile("tests/fileformats/jpeg/masters/f" & $n & "-exif.jpg")) + ii = decodeJpeg(readFile("tests/fileformats/jpeg/masters/f" & $n & "-exif-ii.jpg")) + doAssert ii.width == mm.width and ii.height == mm.height + doAssert ii.data == mm.data From bcd7df9ef642f3be27745b1f7fa1fa7496229606 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 8 Aug 2026 16:20:54 +0200 Subject: [PATCH 14/29] Share one pull-source type across the streaming decoders PNG and JPEG each declared their own pull-source callback with an identical signature, and frameos already passed one file closure to both. Name it once as ImageSourceProc and keep PngSourceProc/JpegSourceProc as aliases so callers compile unchanged. scaledFitRects moves to common.nim alongside it: BMP and PPM need the same fit arithmetic, and a second copy would be a second place for fitContain's untouched-border rule to drift. Co-Authored-By: Claude Fable 5 --- src/pixie/common.nim | 44 ++++++++++++++++++++++++++++++++++ src/pixie/fileformats/jpeg.nim | 4 +--- src/pixie/fileformats/png.nim | 41 +------------------------------ 3 files changed, 46 insertions(+), 43 deletions(-) diff --git a/src/pixie/common.nim b/src/pixie/common.nim index 35caa504..bf76f401 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -42,6 +42,50 @@ type width*, height*: int data*: seq[ColorRGBX] + ImageSourceProc* = proc( + dst: pointer, maxBytes: int + ): int {.gcsafe, raises: [].} + ## Pull callback for streamed decodes: fill `dst` with up to `maxBytes` + ## sequential input bytes, returning how many were written (<= 0 on EOF + ## or read error — the decode then fails with a catchable PixieError). + +proc scaledFitRects*( + srcWidth, srcHeight, targetWidth, targetHeight: int, fit: ScaledDecodeFit +): tuple[srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH: int] = + ## Computes the source crop and target placement rectangles for a fit mode. + result = (0, 0, srcWidth, srcHeight, 0, 0, targetWidth, targetHeight) + case fit + of fitStretch: + discard + of fitCover: + if srcWidth.int64 * targetHeight.int64 > + targetWidth.int64 * srcHeight.int64: + let cropW = max(1, ( + srcHeight.int64 * targetWidth.int64 div + max(1'i64, targetHeight.int64)).int) + result.srcX = (srcWidth - cropW) div 2 + result.srcW = cropW + else: + let cropH = max(1, ( + srcWidth.int64 * targetHeight.int64 div + max(1'i64, targetWidth.int64)).int) + result.srcY = (srcHeight - cropH) div 2 + result.srcH = cropH + of fitContain: + if srcWidth.int64 * targetHeight.int64 > + targetWidth.int64 * srcHeight.int64: + let fitH = max(1, ( + targetWidth.int64 * srcHeight.int64 div + max(1'i64, srcWidth.int64)).int) + result.dstY = (targetHeight - fitH) div 2 + result.dstH = fitH + else: + let fitW = max(1, ( + targetHeight.int64 * srcWidth.int64 div + max(1'i64, srcHeight.int64)).int) + result.dstX = (targetWidth - fitW) div 2 + result.dstW = fitW + proc newImage*(width, height: int): Image {.raises: [PixieError].} = ## Creates a new image with the parameter dimensions. if width <= 0 or height <= 0: diff --git a/src/pixie/fileformats/jpeg.nim b/src/pixie/fileformats/jpeg.nim index 3789d40a..881258d9 100644 --- a/src/pixie/fileformats/jpeg.nim +++ b/src/pixie/fileformats/jpeg.nim @@ -69,9 +69,7 @@ type blocks: seq[seq[array[64, int16]]] channel: Mask - JpegSourceProc* = proc( - dst: pointer, maxBytes: int - ): int {.gcsafe, raises: [].} + JpegSourceProc* = ImageSourceProc ## Pull callback for streaming decodes: fill `dst` with up to `maxBytes` ## sequential input bytes, returning how many were written (<= 0 on EOF ## or read error — the decode then fails with a catchable PixieError). diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index 926af1ab..85ea4350 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -724,43 +724,6 @@ proc validateScaledPngTarget(target: Image) {.raises: [PixieError].} = raise newException(PixieError, "Invalid PNG target Image") validateScaledPngTarget(target.width, target.height) -proc scaledFitRects( - srcWidth, srcHeight, targetWidth, targetHeight: int, fit: ScaledDecodeFit -): tuple[srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH: int] = - ## Computes the source crop and target placement rectangles for a fit mode. - result = (0, 0, srcWidth, srcHeight, 0, 0, targetWidth, targetHeight) - case fit - of fitStretch: - discard - of fitCover: - if srcWidth.int64 * targetHeight.int64 > - targetWidth.int64 * srcHeight.int64: - let cropW = max(1, ( - srcHeight.int64 * targetWidth.int64 div - max(1'i64, targetHeight.int64)).int) - result.srcX = (srcWidth - cropW) div 2 - result.srcW = cropW - else: - let cropH = max(1, ( - srcWidth.int64 * targetHeight.int64 div - max(1'i64, targetWidth.int64)).int) - result.srcY = (srcHeight - cropH) div 2 - result.srcH = cropH - of fitContain: - if srcWidth.int64 * targetHeight.int64 > - targetWidth.int64 * srcHeight.int64: - let fitH = max(1, ( - targetWidth.int64 * srcHeight.int64 div - max(1'i64, srcWidth.int64)).int) - result.dstY = (targetHeight - fitH) div 2 - result.dstH = fitH - else: - let fitW = max(1, ( - targetHeight.int64 * srcWidth.int64 div - max(1'i64, srcHeight.int64)).int) - result.dstX = (targetWidth - fitW) div 2 - result.dstW = fitW - proc scaledFitRects( png: Png, targetWidth, targetHeight: int, fit: ScaledDecodeFit ): tuple[srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH: int] = @@ -1399,9 +1362,7 @@ proc decodePngScaledInto*( # Peak memory is one small read buffer plus the fixed streaming decode # overhead; the compressed file is never held in memory. -type PngSourceProc* = proc( - dst: pointer, maxBytes: int -): int {.gcsafe, raises: [].} +type PngSourceProc* = ImageSourceProc ## Pull callback for streamed decodes: fill `dst` with up to `maxBytes` ## sequential input bytes, returning how many were written (<= 0 on EOF ## or read error — the decode then fails with a catchable PixieError). From a722f6476ea72a9a7ce7d478201fb8de3e2bbbb0 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 8 Aug 2026 16:21:10 +0200 Subject: [PATCH 15/29] bmp, ppm: streaming scaled decode with bounded memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spilled BMP had no file-backed decoder, so a download too big for RAM could only fail — the whole point of spilling to storage is that the decode never needs the full body back. BMP is the easiest format to stream: no entropy coder, fixed stride, strictly sequential rows. decodeBmpStreamScaledInto pulls rows through the same engine PNG uses — one source row in RAM, a monotonic target cursor, nearest-neighbour sampling — with the row cursor running upward for bottom-up files so the trailing flipVertical (and its whole-image buffer) disappears. Rows no target row samples are consumed without conversion, and the walk stops once the last sampled row is read, so a fitCover crop never touches the file's tail. decodeDib is rebuilt on the same header parse and row converter, and now checks the decode budget before newImage: a 20000x20000 header used to walk straight into a 1.6 GB allocation. PPM P6 gets the same treatment; ASCII P3 raises rather than buffering back. Also fixes a pre-existing crash found while fuzzing: decodeBmp checked for 14 bytes and then indexed byte 14, so an exactly-14-byte file raised IndexDefect instead of a catchable PixieError. Co-Authored-By: Claude Fable 5 --- src/pixie.nim | 12 + src/pixie/fileformats/bmp.nim | 585 ++++++++++++++++++++++++++-------- src/pixie/fileformats/ppm.nim | 171 +++++++++- tests/test_bmp.nim | 118 ++++++- tests/test_ppm.nim | 69 +++- 5 files changed, 825 insertions(+), 130 deletions(-) diff --git a/src/pixie.nim b/src/pixie.nim index 1afc36cc..fa8f9abd 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -120,6 +120,8 @@ proc decodeImageScaled*( decodePngScaled(data, len, width, height, fit) elif len > 2 and equalMem(data, jpegStartOfImage[0].unsafeAddr, 2): decodeJpegScaled(data, len, width, height, fit) + elif len > 2 and equalMem(data, bmpSignature.cstring, 2): + decodeBmpScaled(data, len, width, height, fit) else: var copy = newString(len) if len > 0: @@ -135,6 +137,8 @@ proc decodeImageScaled*( decodePngScaled(data, width, height, fit) elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): decodeJpegScaled(data, width, height, fit) + elif data.len > 2 and data.readStr(0, 2) == bmpSignature: + decodeBmpScaled(data, width, height, fit) else: let image = decodeImage(data) if image.width == width and image.height == height: @@ -153,6 +157,8 @@ proc decodeImageScaled*( decodePngScaled(data, width, height, fit) elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): decodeJpegScaled(data, width, height, fit) + elif data.len > 2 and data.readStr(0, 2) == bmpSignature: + decodeBmpScaled(data, width, height, fit) else: let image = decodeImage(data) data = "" @@ -174,6 +180,8 @@ proc decodeImageScaledInto*( decodePngScaledInto(data, len, target, fit) elif len > 2 and equalMem(data, jpegStartOfImage[0].unsafeAddr, 2): decodeJpegScaledInto(data, len, target, fit) + elif len > 2 and equalMem(data, bmpSignature.cstring, 2): + decodeBmpScaledInto(data, len, target, fit) else: var copy = newString(len) if len > 0: @@ -190,6 +198,8 @@ proc decodeImageScaledInto*( decodePngScaledInto(data, target, fit) elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): decodeJpegScaledInto(data, target, fit) + elif data.len > 2 and data.readStr(0, 2) == bmpSignature: + decodeBmpScaledInto(data, target, fit) else: target.copyIntoTarget(decodeImageScaled(data, target.width, target.height)) target @@ -203,6 +213,8 @@ proc decodeImageScaledInto*( decodePngScaledInto(data, target, fit) elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): decodeJpegScaledInto(data, target, fit) + elif data.len > 2 and data.readStr(0, 2) == bmpSignature: + decodeBmpScaledInto(data, target, fit) else: target.copyIntoTarget(decodeImageScaled(data, target.width, target.height)) target diff --git a/src/pixie/fileformats/bmp.nim b/src/pixie/fileformats/bmp.nim index f7ffe00b..e9ef290e 100644 --- a/src/pixie/fileformats/bmp.nim +++ b/src/pixie/fileformats/bmp.nim @@ -1,4 +1,4 @@ -import bitops, chroma, flatty/binny, ../common, ../images +import bitops, chroma, flatty/binny, ../common, ../decodebudget, ../images # See: https://en.wikipedia.org/wiki/BMP_file_format # See: https://bmptestsuite.sourceforge.io/ @@ -9,6 +9,7 @@ import bitops, chroma, flatty/binny, ../common, ../images const bmpSignature* = "BM" LCS_sRGB = 0x73524742 + bmpStreamReadBytes = 16384 template failInvalid() = raise newException(PixieError, "Invalid BMP buffer, unable to load") @@ -16,15 +17,25 @@ template failInvalid() = proc colorMaskShift(color, mask: uint32): uint8 {.inline.} = ((color and mask) shr (mask.firstSetBit() - 1)).uint8 -proc decodeDib*( - data: pointer, len: int, lpBitmapInfo = false -): Image {.raises: [PixieError].} = - ## Decodes DIB data into an image. +type BmpHeader = object + ## Parsed DIB header, palette and pixel-data layout. + width, height: int # Height is always positive, see topDown + bits, compression: int + redMask, greenMask, blueMask, alphaMask: uint32 + useAlpha: bool + topDown: bool # Pixel rows are stored top to bottom instead of bottom up + palette: seq[ColorRGBA] + startOffset: int # First pixel byte, relative to the DIB start + rawRowBytes: int # Pixel bytes per row, before padding + rowStride: int # Bytes per row including padding to 4-byte alignment + +proc parseBmpHeader( + data: ptr UncheckedArray[uint8], len: int, lpBitmapInfo = false +): BmpHeader {.raises: [PixieError].} = + ## Validates the DIB header and reads everything up to the pixel data. if len < 40: failInvalid() - let data = cast[ptr UncheckedArray[uint8]](data) - # BITMAPINFOHEADER var headerSize = data.readInt32(0).int @@ -47,32 +58,29 @@ proc decodeDib*( if compression notin [0, 3]: raise newException(PixieError, "Unsupported BMP compression format") - var - redMask = 0x00FF0000.uint32 - greenMask = 0x0000FF00.uint32 - blueMask = 0x000000FF.uint32 - alphaMask = 0xFF000000.uint32 - flipVertical: bool - useAlpha: bool + result.redMask = 0x00FF0000.uint32 + result.greenMask = 0x0000FF00.uint32 + result.blueMask = 0x000000FF.uint32 + result.alphaMask = 0xFF000000.uint32 if compression == 3: if len < 52: failInvalid() - redMask = data.readUInt32(40) - greenMask = data.readUInt32(44) - blueMask = data.readUInt32(48) + result.redMask = data.readUInt32(40) + result.greenMask = data.readUInt32(44) + result.blueMask = data.readUInt32(48) - if redMask == 0 or blueMask == 0 or greenMask == 0: + if result.redMask == 0 or result.blueMask == 0 or result.greenMask == 0: failInvalid() if headerSize > 40: if len < 56: failInvalid() - alphaMask = data.readUInt32(52) + result.alphaMask = data.readUInt32(52) - useAlpha = alphaMask != 0 + result.useAlpha = result.alphaMask != 0 if colorPaletteSize < 0 or colorPaletteSize > 256: failInvalid() @@ -80,7 +88,7 @@ proc decodeDib*( if bits in [1, 4, 8] and colorPaletteSize == 0: colorPaletteSize = 1 shl bits - var colorPalette = newSeq[ColorRGBA](colorPaletteSize) + result.palette = newSeq[ColorRGBA](colorPaletteSize) if colorPaletteSize > 0: if len < headerSize + colorPaletteSize * 4: failInvalid() @@ -95,130 +103,156 @@ proc decodeDib*( rgba.b = data[offset + 0] rgba.a = 255 offset += 4 - colorPalette[i] = rgba + result.palette[i] = rgba if height < 0: height = -height - flipVertical = true + result.topDown = true - result = newImage(width, height) + result.width = width + result.height = height + result.bits = bits + result.compression = compression - var startOffset = headerSize + colorPaletteSize * 4 + result.startOffset = headerSize + colorPaletteSize * 4 if compression == 3 and (headerSize == 40 or lpBitmapInfo): - startOffset += 12 - - var offset = startOffset - - if bits == 1: + result.startOffset += 12 + + result.rawRowBytes = (width * bits + 7) div 8 + result.rowStride = ((width * bits + 31) div 32) * 4 + +proc checkDecodeBudget(header: BmpHeader, planBytes: int64) = + if overDecodeBudget(planBytes): + raise newException(PixieError, + "BMP decode of " & $header.width & "x" & $header.height & + " needs " & $(planBytes div 1024) & + "K of decode buffers, over the " & + $(decodeBudgetBytes() div 1024) & "K memory budget" + ) + +proc checkFullDecodeBudget(header: BmpHeader) = + ## A full decode holds the whole pixel seq plus one row of RGBX pixels. + checkDecodeBudget(header, + header.width.int64 * header.height.int64 * 4 + header.width.int64 * 4) + +proc bmpRowToPixels( + header: BmpHeader, row: openArray[uint8], dst: var seq[ColorRGBX] +) {.raises: [PixieError].} = + ## Converts one row of raw BMP pixel bytes into RGBX pixels. + case header.bits: + of 1: + if header.palette.len < 2: + failInvalid() var haveBits = 0 colorBits: uint8 = 0 - for y in 0 ..< result.height: - haveBits = 0 - let padding = (offset - startOffset) mod 4 - if padding > 0: - offset += 4 - padding - for x in 0 ..< result.width: - var rgba: ColorRGBA - if haveBits == 0: - if offset >= len: - failInvalid() - colorBits = data[offset] - haveBits = 8 - offset += 1 - if (colorBits and 0b1000_0000) == 0: - rgba = colorPalette[0] - else: - rgba = colorPalette[1] - colorBits = colorBits shl 1 - dec haveBits - result.unsafe[x, result.height - y - 1] = rgba.rgbx() - - elif bits == 4: + offset = 0 + for x in 0 ..< header.width: + var rgba: ColorRGBA + if haveBits == 0: + colorBits = row[offset] + haveBits = 8 + inc offset + if (colorBits and 0b1000_0000) == 0: + rgba = header.palette[0] + else: + rgba = header.palette[1] + colorBits = colorBits shl 1 + dec haveBits + dst[x] = rgba.rgbx() + + of 4: var haveBits = 0 colorBits: uint8 = 0 - for y in 0 ..< result.height: - haveBits = 0 - let padding = (offset - startOffset) mod 4 - if padding > 0: - offset += 4 - padding - for x in 0 ..< result.width: - var rgba: ColorRGBA - if haveBits == 0: - if offset >= len: - failInvalid() - colorBits = data[offset] - haveBits = 8 - offset += 1 - let index = (colorBits and 0b1111_0000) shr 4 - if index.int >= colorPaletteSize: - failInvalid() - rgba = colorPalette[index] - colorBits = colorBits shl 4 - haveBits -= 4 - result.unsafe[x, result.height - y - 1] = rgba.rgbx() - - elif bits == 8: - for y in 0 ..< result.height: - let padding = (offset - startOffset) mod 4 - if padding > 0: - offset += 4 - padding - for x in 0 ..< result.width: - if offset >= len: - failInvalid() - var rgba: ColorRGBA - let index = data[offset] - offset += 1 - if index.int >= colorPaletteSize: - failInvalid() - rgba = colorPalette[index] - result.unsafe[x, result.height - y - 1] = rgba.rgbx() - - elif bits == 24: - for y in 0 ..< result.height: - let padding = (offset - startOffset) mod 4 - if padding > 0: - offset += 4 - padding - for x in 0 ..< result.width: - if offset + 2 >= len: - failInvalid() + offset = 0 + for x in 0 ..< header.width: + if haveBits == 0: + colorBits = row[offset] + haveBits = 8 + inc offset + let index = (colorBits and 0b1111_0000) shr 4 + if index.int >= header.palette.len: + failInvalid() + colorBits = colorBits shl 4 + haveBits -= 4 + dst[x] = header.palette[index].rgbx() + + of 8: + for x in 0 ..< header.width: + let index = row[x] + if index.int >= header.palette.len: + failInvalid() + dst[x] = header.palette[index].rgbx() + + of 24: + for x in 0 ..< header.width: + var rgba: ColorRGBA + rgba.r = row[x * 3 + 2] + rgba.g = row[x * 3 + 1] + rgba.b = row[x * 3 + 0] + rgba.a = 255 + dst[x] = rgba.rgbx() + + of 32: + for x in 0 ..< header.width: + let color = row[x * 4 + 0].uint32 or + (row[x * 4 + 1].uint32 shl 8) or + (row[x * 4 + 2].uint32 shl 16) or + (row[x * 4 + 3].uint32 shl 24) + if header.useAlpha: + var rgbx: ColorRGBX + rgbx.r = color.colorMaskShift(header.redMask) + rgbx.g = color.colorMaskShift(header.greenMask) + rgbx.b = color.colorMaskShift(header.blueMask) + rgbx.a = color.colorMaskShift(header.alphaMask) + dst[x] = rgbx + else: var rgba: ColorRGBA - rgba.r = data[offset + 2] - rgba.g = data[offset + 1] - rgba.b = data[offset + 0] + rgba.r = color.colorMaskShift(header.redMask) + rgba.g = color.colorMaskShift(header.greenMask) + rgba.b = color.colorMaskShift(header.blueMask) rgba.a = 255 - offset += 3 - result.unsafe[x, result.height - y - 1] = rgba.rgbx() + dst[x] = rgba.rgbx() - elif bits == 32: - for y in 0 ..< result.height: - for x in 0 ..< result.width: - if offset + 3 >= len: - failInvalid() - let color = data.readUint32(offset) - if useAlpha: - var rgbx: ColorRGBX - rgbx.r = color.colorMaskShift(redMask) - rgbx.g = color.colorMaskShift(greenMask) - rgbx.b = color.colorMaskShift(blueMask) - rgbx.a = color.colorMaskShift(alphaMask) - result.unsafe[x, result.height - y - 1] = rgbx - else: - var rgba: ColorRGBA - rgba.r = color.colorMaskShift(redMask) - rgba.g = color.colorMaskShift(greenMask) - rgba.b = color.colorMaskShift(blueMask) - rgba.a = 255 - result.unsafe[x, result.height - y - 1] = rgba.rgbx() - offset += 4 - - if flipVertical: - result.flipVertical() + else: + failInvalid() + +proc decodeDib*( + data: pointer, len: int, lpBitmapInfo = false +): Image {.raises: [PixieError].} = + ## Decodes DIB data into an image. + let data = cast[ptr UncheckedArray[uint8]](data) + let header = parseBmpHeader(data, len, lpBitmapInfo) + + checkFullDecodeBudget(header) + + result = newImage(header.width, header.height) + + var rowPixels = newSeq[ColorRGBX](header.width) + for fileY in 0 ..< header.height: + let rowStart = header.startOffset + fileY * header.rowStride + if rowStart + header.rawRowBytes > len: + failInvalid() + bmpRowToPixels( + header, + data.toOpenArray(rowStart, rowStart + header.rawRowBytes - 1), + rowPixels + ) + let imageY = + if header.topDown: + fileY + else: + header.height - fileY - 1 + copyMem( + result.data[imageY * header.width].addr, + rowPixels[0].addr, + header.width * sizeof(ColorRGBX) + ) proc decodeBmp*(data: string): Image {.raises: [PixieError].} = ## Decodes bitmap data into an image. - if data.len < 14: + if data.len < 15: # decodeDib needs at least one byte to take the address of failInvalid() # BMP Header @@ -249,6 +283,303 @@ proc decodeBmpDimensions*( ## Decodes the BMP dimensions. decodeBmpDimensions(data.cstring, data.len) +proc validateScaledBmpTarget(width, height: int) {.raises: [PixieError].} = + if width <= 0 or width > int32.high.int: + raise newException(PixieError, "Invalid BMP target width") + if height <= 0 or height > int32.high.int: + raise newException(PixieError, "Invalid BMP target height") + +proc validateScaledBmpTarget(target: Image) {.raises: [PixieError].} = + if target.isNil: + raise newException(PixieError, "Invalid BMP target Image") + validateScaledBmpTarget(target.width, target.height) + +type BmpRowRead = proc( + dst: ptr UncheckedArray[uint8], skip: bool +) {.gcsafe, raises: [PixieError].} + ## Provides the next pixel row in file order: fill `dst` with rawRowBytes + ## bytes, or just advance past the row when `skip` is true. Never called + ## for rows after the last one the target samples. + +proc decodeBmpScaledIntoStreaming( + header: BmpHeader, + target: Image, + fit: ScaledDecodeFit, + readRow: BmpRowRead +) {.raises: [PixieError].} = + ## Samples pixel rows into the target as they arrive in file order. + ## Bottom-up files walk the target cursor from the bottom edge upward, so + ## no flip pass or full-size pixel buffer is ever needed. Peak memory: one + ## raw row plus one row of RGBX pixels. + if header.width <= 0 or header.height <= 0: + failInvalid() + + checkDecodeBudget(header, + header.rowStride.int64 + header.width.int64 * 4 + bmpStreamReadBytes) + + let rects = scaledFitRects( + header.width, header.height, target.width, target.height, fit + ) + + template srcYFor(y: int): int = + min( + rects.srcY + ((y - rects.dstY) * rects.srcH) div rects.dstH, + header.height - 1 + ) + + template srcXFor(x: int): int = + min( + rects.srcX + ((x - rects.dstX) * rects.srcW) div rects.dstW, + header.width - 1 + ) + + var + rowBytes = newSeq[uint8](header.rawRowBytes) + rowPixels = newSeq[ColorRGBX](header.width) + let + rowBytesPtr = cast[ptr UncheckedArray[uint8]](rowBytes[0].addr) + dstYEnd = rects.dstY + rects.dstH + + if header.topDown: + var dstY = rects.dstY + for fileY in 0 ..< header.height: + if dstY >= dstYEnd: + break # Every remaining file row is below the sampled crop + let needed = srcYFor(dstY) == fileY + readRow(rowBytesPtr, not needed) + if not needed: + continue # No target row samples this source row + bmpRowToPixels(header, rowBytes, rowPixels) + while dstY < dstYEnd and srcYFor(dstY) == fileY: + for x in rects.dstX ..< rects.dstX + rects.dstW: + target.unsafe[x, dstY] = rowPixels[srcXFor(x)] + inc dstY + else: + # Bottom-up: the first file row is the bottom image row, so the target + # cursor starts at the bottom of the fitted rect and moves upward. + var dstY = dstYEnd - 1 + for fileY in 0 ..< header.height: + if dstY < rects.dstY: + break # Every remaining file row is above the sampled crop + let imageY = header.height - fileY - 1 + let needed = srcYFor(dstY) == imageY + readRow(rowBytesPtr, not needed) + if not needed: + continue # No target row samples this source row + bmpRowToPixels(header, rowBytes, rowPixels) + while dstY >= rects.dstY and srcYFor(dstY) == imageY: + for x in rects.dstX ..< rects.dstX + rects.dstW: + target.unsafe[x, dstY] = rowPixels[srcXFor(x)] + dec dstY + +proc decodeDibScaledInto( + data: ptr UncheckedArray[uint8], len: int, target: Image, + fit: ScaledDecodeFit +) {.raises: [PixieError].} = + ## The streaming scaled decode over DIB data resident in memory. + let header = parseBmpHeader(data, len) + var fileY = 0 + decodeBmpScaledIntoStreaming(header, target, fit, + proc (dst: ptr UncheckedArray[uint8], skip: bool) {. + gcsafe, raises: [PixieError] + .} = + if not skip: + let rowStart = header.startOffset + fileY * header.rowStride + if rowStart + header.rawRowBytes > len: + failInvalid() + copyMem(dst, data[rowStart].addr, header.rawRowBytes) + inc fileY + ) + +proc decodeBmpScaledInto*( + data: pointer, len: int, target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## Decodes the BMP data into an existing Image. With fitContain, pixels + ## outside the fitted rectangle keep their current contents. + validateScaledBmpTarget(target) + if len < 14: + failInvalid() + let data = cast[ptr UncheckedArray[uint8]](data) + + # BMP Header + if data[0].char != 'B' or data[1].char != 'M': + failInvalid() + + decodeDibScaledInto( + cast[ptr UncheckedArray[uint8]](data[14].addr), len - 14, target, fit + ) + +proc decodeBmpScaled*( + data: pointer, len, width, height: int, fit = fitStretch +): Image {.raises: [PixieError].} = + ## Decodes the BMP data into an Image scaled to the requested dimensions. + validateScaledBmpTarget(width, height) + result = newImage(width, height) + decodeBmpScaledInto(data, len, result, fit) + +proc decodeBmpScaled*( + data: string, width, height: int, fit = fitStretch +): Image {.inline, raises: [PixieError].} = + ## Decodes the BMP data into an Image scaled to the requested dimensions. + decodeBmpScaled(data.cstring, data.len, width, height, fit) + +proc decodeBmpScaledInto*( + data: string, target: Image, fit = fitStretch +) {.inline, raises: [PixieError].} = + ## Decodes the BMP data into an existing Image. + decodeBmpScaledInto(data.cstring, data.len, target, fit) + +proc decodeBmpScaled*( + data: var string, width, height: int, fit = fitStretch +): Image {.raises: [PixieError].} = + ## Decodes the BMP data into a scaled Image and releases the source string + ## afterwards. The streamed decode never holds a full-size pixel buffer, + ## so releasing the source early no longer matters; it is freed on return + ## for callers that rely on it. + result = decodeBmpScaled(data.cstring, data.len, width, height, fit) + data = "" + try: + GC_fullCollect() + except Exception: + discard + +proc decodeBmpScaledInto*( + data: var string, target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## Decodes the BMP data into an existing Image and releases the source + ## string afterwards. + decodeBmpScaledInto(data.cstring, data.len, target, fit) + data = "" + try: + GC_fullCollect() + except Exception: + discard + +# ------------------------------------------------------------------------ +# Pull sources: decode a BMP read sequentially from a callback (e.g. a +# download spilled to disk on a device without the memory to buffer it). +# Peak memory is one small read buffer plus the row buffers; the file is +# never held in memory. + +type BmpStreamReader = object + source: ImageSourceProc + totalLen: int # <= 0 when unknown + pos: int + +proc readExact( + r: var BmpStreamReader, dst: ptr UncheckedArray[uint8], len: int +) {.raises: [PixieError].} = + var done = 0 + while done < len: + let got = r.source(dst[done].addr, len - done) + if got <= 0: + failInvalid() + done += got + r.pos += got + +proc skipBytes( + r: var BmpStreamReader, len: int, buffer: var seq[uint8] +) {.raises: [PixieError].} = + var remaining = len + while remaining > 0: + let take = min(remaining, buffer.len) + r.readExact(cast[ptr UncheckedArray[uint8]](buffer[0].addr), take) + remaining -= take + +proc decodeBmpStreamScaledInto*( + source: ImageSourceProc, totalLen: int, target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## Decodes a BMP pulled sequentially from `source` (e.g. a file on disk) + ## into an existing Image, sampling pixel rows as they are read. Peak + ## memory: one small read buffer plus one raw row and one row of RGBX + ## pixels — the file is never resident. On downscales, rows no target row + ## samples are skipped without conversion, and reading stops after the + ## last sampled row. Pass the input size as totalLen for bounds checks, + ## or <= 0 when unknown. + validateScaledBmpTarget(target) + if source == nil: + failInvalid() + + var + r = BmpStreamReader(source: source, totalLen: totalLen) + buffer = newSeq[uint8](bmpStreamReadBytes) + + # BMP Header: "BM", file size, reserved fields and the pixel data offset, + # which the buffered path ignores in favor of the DIB layout. + var fileHeader: array[14, uint8] + r.readExact(cast[ptr UncheckedArray[uint8]](fileHeader[0].addr), 14) + if fileHeader[0].char != 'B' or fileHeader[1].char != 'M': + failInvalid() + + # Read the DIB header size first so the rest of the header can follow. + var sizeBytes: array[4, uint8] + r.readExact(cast[ptr UncheckedArray[uint8]](sizeBytes[0].addr), 4) + let headerSize = + cast[ptr UncheckedArray[uint8]](sizeBytes[0].addr).readInt32(0).int + if headerSize notin [40, 108, 124]: + failInvalid() + + var dib = newSeq[uint8](headerSize) + copyMem(dib[0].addr, sizeBytes[0].addr, 4) + r.readExact(cast[ptr UncheckedArray[uint8]](dib[4].addr), headerSize - 4) + + # Everything between the DIB header and the pixel data (the palette and, + # for 40-byte bitfields headers, the mask block) gets buffered too, so + # parseBmpHeader sees the same bytes as the buffered path. Its palette + # size defaulting must be replicated to know how many bytes that is. + let + dibPtr = cast[ptr UncheckedArray[uint8]](dib[0].addr) + bits = dibPtr.readUint16(14).int + compression = dibPtr.readInt32(16).int + var colorPaletteSize = dibPtr.readInt32(32).int + if colorPaletteSize < 0 or colorPaletteSize > 256: + failInvalid() + if bits in [1, 4, 8] and colorPaletteSize == 0: + colorPaletteSize = 1 shl bits + + var trailing = colorPaletteSize * 4 + if compression == 3 and headerSize == 40: + trailing += 12 + + # Two spare zero bytes: parseBmpHeader's palette bounds check requires + # them and they are never read as data. + dib.setLen(headerSize + trailing + 2) + if trailing > 0: + r.readExact( + cast[ptr UncheckedArray[uint8]](dib[headerSize].addr), trailing + ) + + let header = parseBmpHeader( + cast[ptr UncheckedArray[uint8]](dib[0].addr), dib.len + ) + if header.width <= 0 or header.height <= 0: + failInvalid() + + # The reader is now positioned exactly at the first pixel byte. + if r.totalLen > 0: + let needed = 14.int64 + header.startOffset.int64 + + (header.height - 1).int64 * header.rowStride.int64 + + header.rawRowBytes.int64 + if needed > r.totalLen.int64: + failInvalid() + + var fileY = 0 + let lastFileY = header.height - 1 + decodeBmpScaledIntoStreaming(header, target, fit, + proc (dst: ptr UncheckedArray[uint8], skip: bool) {. + gcsafe, raises: [PixieError] + .} = + if skip: + r.skipBytes(header.rowStride, buffer) + else: + r.readExact(dst, header.rawRowBytes) + if fileY < lastFileY: + # The last row's padding may be absent, but it is also never + # followed by another read, so skip padding on earlier rows only. + r.skipBytes(header.rowStride - header.rawRowBytes, buffer) + inc fileY + ) + proc encodeDib*(image: Image): string {.raises: [].} = ## Encodes an image into a DIB. diff --git a/src/pixie/fileformats/ppm.nim b/src/pixie/fileformats/ppm.nim index 9b3d0b23..0b0f56ef 100644 --- a/src/pixie/fileformats/ppm.nim +++ b/src/pixie/fileformats/ppm.nim @@ -1,4 +1,5 @@ -import chroma, flatty/binny, ../common, ../images, std/strutils +import chroma, flatty/binny, ../common, ../decodebudget, ../images, + std/strutils # See: http://netpbm.sourceforge.net/doc/ppm.html @@ -163,6 +164,174 @@ proc decodePpmDimensions*( ## Decodes the PPM dimensions. decodePpmDimensions(data.cstring, data.len) +proc validateScaledPpmTarget(width, height: int) {.raises: [PixieError].} = + if width <= 0 or width > int32.high.int: + raise newException(PixieError, "Invalid PPM target width") + if height <= 0 or height > int32.high.int: + raise newException(PixieError, "Invalid PPM target height") + +proc checkDecodeBudget(header: PpmHeader, planBytes: int64) = + if overDecodeBudget(planBytes): + raise newException(PixieError, + "PPM decode of " & $header.width & "x" & $header.height & + " needs " & $(planBytes div 1024) & + "K of decode buffers, over the " & + $(decodeBudgetBytes() div 1024) & "K memory budget" + ) + +proc decodePpmStreamScaledInto*( + source: ImageSourceProc, totalLen: int, target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## Decodes a P6 PPM pulled sequentially from `source` (e.g. a file on + ## disk) into an existing Image, sampling pixel rows as they are read. + ## Peak memory: one raw row plus one row of RGBX pixels — the file is + ## never resident. Rows no target row samples are skipped without + ## conversion. P3 (ASCII) PPMs raise a PixieError since they need the + ## whole-file buffering this decoder exists to avoid. Pass the input size + ## as totalLen for payload size checks, or <= 0 when unknown. + if target.isNil: + raise newException(PixieError, "Invalid PPM target Image") + validateScaledPpmTarget(target.width, target.height) + if source == nil: + failInvalid() + + # The header parse mirrors decodeHeader, pulling one byte at a time so + # the reader stops exactly at the first payload byte. + var + header: PpmHeader + commentMode, readWhitespace: bool + readFields: int + field: string + while readFields < 4: + var c: char + if source(c.addr, 1) != 1: + raise newException(PixieError, "Invalid PPM file header") + inc header.dataOffset + 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: + header.version = field + of 2: + header.width = parseInt(field) + of 3: + header.height = parseInt(field) + of 4: + header.maxVal = parseInt(field) + else: + discard + except ValueError: + failInvalid() + field = "" + elif not (c in Whitespace): + field.add(c) + readWhitespace = false + + if not (header.version in ppmSignatures): + failInvalid() + + if header.version == "P3": + raise newException(PixieError, + "Streamed PPM decode supports the binary P6 format only" + ) + + if header.maxVal <= 0 or header.maxVal > 0xFFFF: + failInvalid() + + if header.width <= 0 or header.height <= 0: + failInvalid() + + let + bytesPerPixel = if header.maxVal > 0xFF: 6 else: 3 + rowBytesLen = header.width * bytesPerPixel + + # The buffered decoder requires the payload to be exactly one image large + if totalLen > 0 and totalLen.int64 - header.dataOffset.int64 != + header.width.int64 * header.height.int64 * bytesPerPixel.int64: + failInvalid() + + checkDecodeBudget(header, rowBytesLen.int64 + header.width.int64 * 4) + + let rects = scaledFitRects( + header.width, header.height, target.width, target.height, fit + ) + + template srcYFor(y: int): int = + min( + rects.srcY + ((y - rects.dstY) * rects.srcH) div rects.dstH, + header.height - 1 + ) + + template srcXFor(x: int): int = + min( + rects.srcX + ((x - rects.dstX) * rects.srcW) div rects.dstW, + header.width - 1 + ) + + # See decodeP6Data for the maxVal multiplier reasoning + let valueMultiplier = (255 / header.maxVal).float32 + + var + rowBytes = newSeq[uint8](rowBytesLen) + rowPixels = newSeq[ColorRGBX](header.width) + dstY = rects.dstY + let dstYEnd = rects.dstY + rects.dstH + + for fileY in 0 ..< header.height: + if dstY >= dstYEnd: + break # Every remaining row is below the sampled crop + + # Skipped rows still consume their bytes to keep the reads sequential + var done = 0 + while done < rowBytesLen: + let got = source(rowBytes[done].addr, rowBytesLen - done) + if got <= 0: + failInvalid() + done += got + + if srcYFor(dstY) != fileY: + continue # No target row samples this source row + + if header.maxVal > 0xFF: + for x in 0 ..< header.width: + let + red = ((rowBytes[x * 6 + 0].uint16 shl 8) or + rowBytes[x * 6 + 1].uint16).float32 + green = ((rowBytes[x * 6 + 2].uint16 shl 8) or + rowBytes[x * 6 + 3].uint16).float32 + blue = ((rowBytes[x * 6 + 4].uint16 shl 8) or + rowBytes[x * 6 + 5].uint16).float32 + rowPixels[x] = rgbx( + (red * valueMultiplier + 0.5).uint8, + (green * valueMultiplier + 0.5).uint8, + (blue * valueMultiplier + 0.5).uint8, + 255 + ) + else: + for x in 0 ..< header.width: + let + red = rowBytes[x * 3 + 0].float32 + green = rowBytes[x * 3 + 1].float32 + blue = rowBytes[x * 3 + 2].float32 + rowPixels[x] = rgbx( + (red * valueMultiplier + 0.5).uint8, + (green * valueMultiplier + 0.5).uint8, + (blue * valueMultiplier + 0.5).uint8, + 255 + ) + + while dstY < dstYEnd and srcYFor(dstY) == fileY: + for x in rects.dstX ..< rects.dstX + rects.dstW: + target.unsafe[x, dstY] = rowPixels[srcXFor(x)] + inc dstY + proc encodePpm*(image: Image): string {.raises: [].} = ## Encodes an image into the PPM file format (version P6). diff --git a/tests/test_bmp.nim b/tests/test_bmp.nim index 4f3f0d5d..56049aaa 100644 --- a/tests/test_bmp.nim +++ b/tests/test_bmp.nim @@ -1,4 +1,4 @@ -import os, pixie, pixie/fileformats/bmp +import os, strutils, pixie, pixie/decodebudget, pixie/fileformats/bmp proc addLe16(data: var string, value: int) = data.add(char(value and 0xff)) @@ -110,3 +110,119 @@ block: decoded = decodeDib(encoded.cstring, encoded.len, true) doAssert image.data == decoded.data + +block: # identity-size scaled decodes match the reference decoder exactly + var files: seq[string] + for file in walkFiles("tests/fileformats/bmp/bmpsuite/*"): + files.add(file) + files.add("tests/fileformats/bmp/knight.24.master.bmp") + files.add("tests/fileformats/bmp/knight.32.master.bmp") + for file in files: + let + data = readFile(file) + expected = decodeBmp(data) + target = newImage(expected.width, expected.height) + decodeBmpScaledInto(data, target, fitStretch) + doAssert target.data == expected.data, file + +block: # streaming sampling matches nearest-neighbour on the full decode + let + data = readFile("tests/fileformats/bmp/knight.24.master.bmp") + full = decodeBmp(data) + target = newImage(37, 23) + decodeBmpScaledInto(data, target, fitStretch) + for y in 0 ..< target.height: + let srcY = min(y * full.height div target.height, full.height - 1) + for x in 0 ..< target.width: + let srcX = min(x * full.width div target.width, full.width - 1) + doAssert target.unsafe[x, y] == full.unsafe[srcX, srcY] + +block: # pull sources decode identically to buffered ones + # A download spilled to disk decodes through a sequential read callback; + # the file is never in memory. Exercise awkward read granularities so + # headers, palettes and row payloads all straddle read boundaries. + proc sourceOf(data: string, readSize: int): ImageSourceProc = + var pos = 0 + result = proc (dst: pointer, maxBytes: int): int = + let n = min(min(readSize, maxBytes), data.len - pos) + if n <= 0: + return 0 + copyMem(dst, data[pos].unsafeAddr, n) + pos += n + n + + for file in [ + "tests/fileformats/bmp/bmpsuite/24bpp-321x240.bmp", # padded stride + "tests/fileformats/bmp/bmpsuite/24bpp-topdown-320x240.bmp", + "tests/fileformats/bmp/bmpsuite/32bpp-topdown-320x240.bmp", + "tests/fileformats/bmp/bmpsuite/32bpp-101110-320x240.bmp", # bitfields + "tests/fileformats/bmp/bmpsuite/8bpp-320x240.bmp", + "tests/fileformats/bmp/bmpsuite/4bpp-327x240.bmp", + "tests/fileformats/bmp/bmpsuite/1bpp-329x240.bmp", + "tests/fileformats/bmp/knight.32.master.bmp" # bitfields with alpha + ]: + let data = readFile(file) + for (tw, th) in [(89, 47), (320, 240), (401, 333)]: + for fit in [fitStretch, fitCover, fitContain]: + let expected = newImage(tw, th) + decodeBmpScaledInto(data, expected, fit) + for readSize in [7, 4096, 1 shl 20]: + let target = newImage(tw, th) + decodeBmpStreamScaledInto(sourceOf(data, readSize), data.len, target, fit) + doAssert target.data == expected.data, + file & " " & $tw & "x" & $th & " read=" & $readSize & " fit=" & $fit + + # Single-byte reads and unknown totalLen on a small file + block: + let data = readFile("tests/fileformats/bmp/bmpsuite/1bpp-1x1.bmp") + for totalLen in [data.len, 0]: + let + expected = newImage(3, 3) + target = newImage(3, 3) + decodeBmpScaledInto(data, expected, fitStretch) + decodeBmpStreamScaledInto(sourceOf(data, 1), totalLen, target, fitStretch) + doAssert target.data == expected.data + + # Truncated input fails instead of hanging on the exhausted source + block: + let + data = readFile("tests/fileformats/bmp/bmpsuite/24bpp-320x240.bmp") + truncated = data[0 ..< data.len div 2] + try: + let target = newImage(8, 8) + decodeBmpStreamScaledInto( + sourceOf(truncated, 100), truncated.len, target, fitStretch) + doAssert false + except PixieError: + discard + +block: # var string scaled decodes release the source buffer + var data = readFile("tests/fileformats/bmp/bmpsuite/8bpp-320x240.bmp") + let expected = decodeBmpScaled(data.cstring, data.len, 64, 48) + let image = decodeBmpScaled(data, 64, 48) + doAssert data.len == 0 + doAssert image.data == expected.data + +block: # the format dispatchers route BMPs to the streaming decoder + let + data = readFile("tests/fileformats/bmp/bmpsuite/24bpp-320x240.bmp") + expected = newImage(64, 48) + decodeBmpScaledInto(data, expected, fitCover) + let scaled = decodeImageScaled(data, 64, 48, fitCover) + doAssert scaled.data == expected.data + let target = newImage(64, 48) + discard decodeImageScaledInto(data, target, fitCover) + doAssert target.data == expected.data + +block: # decode budget: full decodes over budget fail, streaming fits + setDecodeBudgetBytes(64 * 1024) + let data = readFile("tests/fileformats/bmp/bmpsuite/24bpp-320x240.bmp") + try: + discard decodeBmp(data) # 320x240x4 pixel buffer is over budget + doAssert false + except PixieError as e: + doAssert "memory budget" in e.msg + # The streaming path only needs row buffers and stays under it + let target = newImage(64, 48) + decodeBmpScaledInto(data, target, fitStretch) + setDecodeBudgetBytes(0) diff --git a/tests/test_ppm.nim b/tests/test_ppm.nim index 6721bd24..cef51fea 100644 --- a/tests/test_ppm.nim +++ b/tests/test_ppm.nim @@ -1,4 +1,4 @@ -import pixie, pixie/fileformats/ppm +import strutils, pixie, pixie/fileformats/ppm block: for format in @["p3", "p6"]: @@ -29,3 +29,70 @@ block: doAssertRaises PixieError: discard decodeImage(payload) + +block: # pull-source scaled decodes match the buffered decoder + proc sourceOf(data: string, readSize: int): ImageSourceProc = + var pos = 0 + result = proc (dst: pointer, maxBytes: int): int = + let n = min(min(readSize, maxBytes), data.len - pos) + if n <= 0: + return 0 + copyMem(dst, data[pos].unsafeAddr, n) + pos += n + n + + let + data = readFile("tests/fileformats/ppm/feep.p6.master.ppm") + full = decodePpm(data) + + # Identity size matches the buffered decode exactly + block: + let target = newImage(full.width, full.height) + decodePpmStreamScaledInto(sourceOf(data, 7), data.len, target, fitStretch) + doAssert target.data == full.data + + # Downscales match nearest-neighbour sampling of the full decode + for readSize in [1, 7, 1 shl 20]: + for totalLen in [data.len, 0]: + let target = newImage(5, 3) + decodePpmStreamScaledInto( + sourceOf(data, readSize), totalLen, target, fitStretch) + for y in 0 ..< target.height: + let srcY = min(y * full.height div target.height, full.height - 1) + for x in 0 ..< target.width: + let srcX = min(x * full.width div target.width, full.width - 1) + doAssert target.unsafe[x, y] == full.unsafe[srcX, srcY] + + # 16-bit maxVal payloads stream too + block: + var data16 = "P6\n2 2\n65535\n" + for v in [0, 1000, 30000, 65535, 12345, 255, 500, 40000, 65534, 1, 2, 3]: + data16.add(char((v shr 8) and 0xff)) + data16.add(char(v and 0xff)) + let + full16 = decodePpm(data16) + target = newImage(2, 2) + decodePpmStreamScaledInto(sourceOf(data16, 3), data16.len, target, fitStretch) + doAssert target.data == full16.data + + # P3 (ASCII) PPMs cannot stream and must fail cleanly + block: + let p3 = readFile("tests/fileformats/ppm/feep.p3.master.ppm") + try: + let target = newImage(4, 4) + decodePpmStreamScaledInto(sourceOf(p3, 100), p3.len, target, fitStretch) + doAssert false + except PixieError as e: + doAssert "P6" in e.msg + + # Truncated input fails instead of hanging on the exhausted source + block: + let truncated = data[0 ..< data.len div 2] + for totalLen in [truncated.len, 0]: + try: + let target = newImage(4, 4) + decodePpmStreamScaledInto( + sourceOf(truncated, 100), totalLen, target, fitStretch) + doAssert false + except PixieError: + discard From b31eefed424070b9561c8ee13152fd7f4dcb96e7 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Tue, 11 Aug 2026 13:58:41 +0200 Subject: [PATCH 16/29] svg: rasterize into a caller-supplied image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `newImage(svg)` allocates the image it draws into, so a caller that already owns a correctly sized buffer — a render canvas, a cell of a larger image — has to take a second full-size allocation and then blend it away. On a memory-tight device that second image is the difference between rendering and not. `renderInto(svg, target)` is the same rasterization with the destination supplied. The body moves to a shared `renderSvg` and nothing else changes; `newImage` keeps its overwrite-first start, which is only an optimisation for the fresh transparent image it just allocated, where overwrite and normal agree. `renderInto` composites from the first path instead, because its target may already have content and there the two are not the same: a semi-transparent first path would replace what is underneath rather than blend with it. Tested both ways round. On a fresh target it is bit-identical to `newImage`. On a target with content it matches rendering on transparency and then drawing the result, to within a few units of 255 — the Tiger overlaps hundreds of semi-transparent paths, and compositing onto an opaque background quantizes slightly differently at each one than compositing onto transparency does before the final blend. --- src/pixie/fileformats/svg.nim | 32 +++++++++++++++++----- tests/test_svg.nim | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/pixie/fileformats/svg.nim b/src/pixie/fileformats/svg.nim index 047566ee..4d5294c1 100644 --- a/src/pixie/fileformats/svg.nim +++ b/src/pixie/fileformats/svg.nim @@ -554,12 +554,11 @@ proc parseSvg*(data: string, width = 0, height = 0): Svg {.raises: [PixieError]. except: raise currentExceptionAsPixieError() -proc newImage*(svg: Svg): Image {.raises: [PixieError].} = - ## Render SVG and return the image. - result = newImage(svg.width, svg.height) - +proc renderSvg( + svg: Svg, target: Image, firstBlendMode: BlendMode +) {.raises: [PixieError].} = try: - var blendMode = OverwriteBlend # Start as overwrite + var blendMode = firstBlendMode for (path, props) in svg.elements: if props.display and props.opacity > 0: if props.fill != "none": @@ -585,14 +584,14 @@ proc newImage*(svg: Svg): Image {.raises: [PixieError].} = paint.opacity = props.fillOpacity * props.opacity paint.blendMode = blendMode - result.fillPath(path, paint, props.transform, props.fillRule) + target.fillPath(path, paint, props.transform, props.fillRule) blendMode = NormalBlend # Switch to normal when compositing multiple paths if props.stroke != rgbx(0, 0, 0, 0) and props.strokeWidth > 0: let paint = props.stroke.copy() paint.color.a *= (props.opacity * props.strokeOpacity) - result.strokePath( + target.strokePath( path, paint, props.transform, @@ -606,3 +605,22 @@ proc newImage*(svg: Svg): Image {.raises: [PixieError].} = raise e except: raise currentExceptionAsPixieError() + +proc renderInto*(svg: Svg, target: Image) {.raises: [PixieError].} = + ## Render SVG into an existing image, compositing onto whatever is already + ## there. Nothing is allocated: for a caller that already owns a correctly + ## sized buffer — a render canvas, a cell of a larger image — this is the + ## difference between one image and two. + ## + ## Unlike `newImage`, the first path composites rather than overwrites. + ## `newImage` can start in overwrite because its image is freshly + ## transparent, where the two are identical; on a target that already has + ## content they are not, and a semi-transparent first path would replace what + ## is underneath instead of blending with it. + svg.renderSvg(target, NormalBlend) + +proc newImage*(svg: Svg): Image {.raises: [PixieError].} = + ## Render SVG and return the image. + result = newImage(svg.width, svg.height) + svg.renderSvg(result, OverwriteBlend) # Fresh image: overwrite == normal + diff --git a/tests/test_svg.nim b/tests/test_svg.nim index 13b22bd8..9949e461 100644 --- a/tests/test_svg.nim +++ b/tests/test_svg.nim @@ -38,3 +38,53 @@ block: xmlNode, 512, 512 ) + +block: + # renderInto: rasterize straight into a buffer the caller already owns. + # + # For a caller that has a correctly sized image to hand — a render canvas, a + # cell of a larger image — this is the difference between one image and two, + # which on a memory-tight device is the difference between rendering and not. + let data = readFile("tests/fileformats/svg/Ghostscript_Tiger.svg") + + block: + # On a fresh transparent target it must be bit-identical to newImage: that + # is the same rasterization, and `newImage`'s overwrite-first start is only + # an optimisation for the case where overwrite and normal agree. + let + expected = newImage(parseSvg(data, 200, 200)) + actual = newImage(200, 200) + parseSvg(data, 200, 200).renderInto(actual) + doAssert expected.data == actual.data + + block: + # On a target that already has content it must match rendering on + # transparent and then compositing the result — which is what it replaces. + # Materialized rounds twice (rasterize, then blend) where this rounds once, + # so one step of 8-bit quantization is the allowed difference. + let fused = newImage(200, 200) + fused.fill(rgba(20, 120, 60, 255)) + parseSvg(data, 200, 200).renderInto(fused) + + let materialized = newImage(200, 200) + materialized.fill(rgba(20, 120, 60, 255)) + materialized.draw(newImage(parseSvg(data, 200, 200)), blendMode = NormalBlend) + + var worst = 0 + var differing = 0 + for i in 0 ..< fused.data.len: + var pixelWorst = 0 + pixelWorst = max(pixelWorst, abs(fused.data[i].r.int - materialized.data[i].r.int)) + pixelWorst = max(pixelWorst, abs(fused.data[i].g.int - materialized.data[i].g.int)) + pixelWorst = max(pixelWorst, abs(fused.data[i].b.int - materialized.data[i].b.int)) + pixelWorst = max(pixelWorst, abs(fused.data[i].a.int - materialized.data[i].a.int)) + if pixelWorst > 0: differing += 1 + worst = max(worst, pixelWorst) + # The Tiger overlaps hundreds of semi-transparent paths, so the rounding + # compounds rather than staying at a single step: each composite onto an + # opaque background quantizes to a slightly different value than the same + # composite onto transparency does before the final blend. A few units out + # of 255 is the cost of not allocating the second image. + doAssert worst <= 4, &"renderInto differs from draw by {worst}" + doAssert differing * 100 div fused.data.len <= 25, + &"renderInto differs on {differing * 100 div fused.data.len}% of pixels" From 88530368cfbc06bf7278b563e929275cd7882d58 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Tue, 11 Aug 2026 18:34:56 +0200 Subject: [PATCH 17/29] =?UTF-8?q?WIP:=20image=20views=20=E2=80=94=20type?= =?UTF-8?q?=20change=20done,=20whole-image=20ops=20still=20to=20convert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not for merging. This is the exploration behind the design note in frameos docs/value-pipeline.md, parked so the findings are not lost. What works: `Image` gains `stride`, `origin`, a shared `pixels` pointer and a `root` that keeps the owner alive; `view(image, x, y, w, h)` returns a window that writes through and flattens views-of-views onto the original owner. `data` became a template over the pointer, so every per-pixel site and everything already routed through `dataIndex` is view-correct with no edit and no extra indirection. What that buys, and it is the important part: `data.len` no longer compiles, so the compiler enumerates exactly the code that assumed the whole image is one contiguous run. About 160 sites, of which the great majority are decoders and encoders working on an image they just allocated — those are always owners and were mechanically renamed to `dataLen` (scripted, driven off the compiler). What remains is the real work: roughly fifteen whole-image operations in images.nim — fill, applyOpacity, invert, ceil, flipHorizontal, magnifyBy2 and friends — plus their per-backend SIMD variants, each of which walks the buffer flat. Every one needs to either iterate spans (one span when contiguous, so owners keep today's exact performance, one per row otherwise) or fall back to an owned copy. The optimisation predicates (isOneColor, isTransparent, isOpaque) turn out to have no callers inside pixie at all, so those can simply answer conservatively for a view. Also here: tests/bench_view_cost.nim, a dependency-free bench of the raster hot paths, so the indirection cost can be measured as a before/after rather than argued about. Baseline on an M-series mac, before any of this: fill 0.042 ms draw scaled 2x 0.215 ms draw opaque over canvas 0.010 ms per-pixel read+write 0.440 ms draw alpha over canvas 0.026 ms fillPath rounded rect 0.023 ms subImage copy (quarter) 0.034 ms newImage quarter 0.027 ms --- src/pixie/common.nim | 82 ++++++++++++++++++++++++++++++++--- src/pixie/fileformats/qoi.nim | 2 +- src/pixie/images.nim | 2 +- src/pixie/internal.nim | 4 +- src/pixie/simd/avx.nim | 2 +- src/pixie/simd/avx2.nim | 11 ++++- src/pixie/simd/neon.nim | 41 ++++++++++-------- src/pixie/simd/sse2.nim | 13 ++++-- tests/bench_view_cost.nim | 66 ++++++++++++++++++++++++++++ tests/compile_all.nim | 7 +++ 10 files changed, 196 insertions(+), 34 deletions(-) create mode 100644 tests/bench_view_cost.nim create mode 100644 tests/compile_all.nim diff --git a/src/pixie/common.nim b/src/pixie/common.nim index bf76f401..64483a14 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -39,8 +39,24 @@ type Image* = ref object ## Image object that holds bitmap data in premultiplied alpha RGBA format. + ## + ## An image either **owns** its pixels or is a **view** into another + ## image's. A view shares the owner's buffer and addresses a rectangle + ## inside it, so handing a caller a sub-region costs nothing and writes + ## through to the original — which is what lets a tiled render draw its + ## cells in place instead of copying each one out and back. + ## + ## `stride` is the distance between rows in the shared buffer, so it equals + ## `width` for an owner and the owner's `width` for a view. Everything that + ## addresses pixels through `dataIndex` is therefore correct for both. + ## Anything that walks the buffer flat, assuming rows are contiguous across + ## the whole image, is correct only for an owner — see `isContiguous`. width*, height*: int - data*: seq[ColorRGBX] + stride*: int ## elements between vertically adjacent pixels + origin*: int ## index of (0, 0) within the shared buffer + storage: seq[ColorRGBX] ## owned pixels; empty for a view + root: Image ## the owner whose buffer this views; nil if owner + pixels: ptr UncheckedArray[ColorRGBX] ## the buffer, owned or borrowed ImageSourceProc* = proc( dst: pointer, maxBytes: int @@ -86,6 +102,26 @@ proc scaledFitRects*( result.dstX = (targetWidth - fitW) div 2 result.dstW = fitW +template data*(image: Image): ptr UncheckedArray[ColorRGBX] = + ## The pixels this image addresses — its own, or its owner's when it is a + ## view. Indexed with `dataIndex`, never with a flat running counter unless + ## `isContiguous` says that is safe. + image.pixels + +template dataIndex*(image: Image, x, y: int): int = + image.origin + image.stride * y + x + +template isContiguous*(image: Image): bool = + ## True when the image's rows sit back to back in the buffer, which is what a + ## whole-image flat loop needs. Always true for an owner; true for a view only + ## when it is full width. + image.stride == image.width + +template dataLen*(image: Image): int = + ## Number of pixels the image addresses. Only the extent of a flat walk when + ## `isContiguous`. + image.width * image.height + proc newImage*(width, height: int): Image {.raises: [PixieError].} = ## Creates a new image with the parameter dimensions. if width <= 0 or height <= 0: @@ -94,17 +130,49 @@ proc newImage*(width, height: int): Image {.raises: [PixieError].} = result = Image() result.width = width result.height = height - result.data = newSeq[ColorRGBX](width * height) + result.stride = width + result.origin = 0 + result.storage = newSeq[ColorRGBX](width * height) + result.pixels = cast[ptr UncheckedArray[ColorRGBX]](result.storage[0].addr) + +proc view*(image: Image, x, y, w, h: int): Image {.raises: [PixieError].} = + ## A window onto part of `image`, sharing its pixels. Writes through. + ## + ## Views of views are flattened onto the original owner, so nesting costs one + ## object and no extra indirection however deep it goes. + if w <= 0 or h <= 0: + raise newException(PixieError, "View width and height must be > 0") + if x < 0 or y < 0 or x + w > image.width or y + h > image.height: + raise newException(PixieError, "View " & $w & "x" & $h & " at " & $x & "," & + $y & " does not fit inside " & $image.width & "x" & $image.height) + result = Image() + result.width = w + result.height = h + result.stride = image.stride + result.origin = image.dataIndex(x, y) + result.root = if image.root.isNil: image else: image.root + result.pixels = image.pixels + +template isView*(image: Image): bool = + ## True when the image borrows another's pixels rather than owning them. + not image.root.isNil proc copy*(image: Image): Image {.raises: [].} = - ## Copies the image data into a new image. + ## Copies the image data into a new image. A view copies out, so the result + ## always owns its pixels. result = Image() result.width = image.width result.height = image.height - result.data = image.data - -template dataIndex*(image: Image, x, y: int): int = - image.width * y + x + result.stride = image.width + result.origin = 0 + result.storage = newSeq[ColorRGBX](image.width * image.height) + result.pixels = cast[ptr UncheckedArray[ColorRGBX]](result.storage[0].addr) + for y in 0 ..< image.height: + copyMem( + result.pixels[result.dataIndex(0, y)].addr, + image.pixels[image.dataIndex(0, y)].addr, + image.width * 4 + ) proc mix*(a, b: ColorRGBX, t: float32): ColorRGBX {.inline, raises: [].} = ## Linearly interpolate between a and b using t. diff --git a/src/pixie/fileformats/qoi.nim b/src/pixie/fileformats/qoi.nim index 4fce389b..69f66d45 100644 --- a/src/pixie/fileformats/qoi.nim +++ b/src/pixie/fileformats/qoi.nim @@ -48,7 +48,7 @@ proc srgbToLinear(color: var ColorRGBX) {.inline.} = color.g = color.g.srgbToLinear() color.b = color.b.srgbToLinear() -proc srgbToLinear(data: var seq[ColorRGBX]) = +proc srgbToLinear(data: ptr UncheckedArray[ColorRGBX]) = for color in data.mitems: color.srgbToLinear() diff --git a/src/pixie/images.nim b/src/pixie/images.nim index c4ec9cf5..be84842a 100644 --- a/src/pixie/images.nim +++ b/src/pixie/images.nim @@ -73,7 +73,7 @@ proc isTransparent*(image: Image): bool {.hasSimd, raises: [].} = proc isOpaque*(image: Image): bool {.raises: [].} = ## Checks if the entire image is opaque (alpha values are all 255). - isOpaque(image.data, 0, image.data.len) + isOpaque(image.data, 0, image.dataLen) proc flipHorizontal*(image: Image) {.raises: [].} = ## Flips the image around the Y axis. diff --git a/src/pixie/internal.nim b/src/pixie/internal.nim index a4e9938b..2d933e12 100644 --- a/src/pixie/internal.nim +++ b/src/pixie/internal.nim @@ -53,7 +53,7 @@ template getUncheckedArray*( cast[ptr UncheckedArray[ColorRGBX]](image.data[image.dataIndex(x, y)].addr) proc fillUnsafe*( - data: var seq[ColorRGBX], color: SomeColor, start, len: int + data: ptr UncheckedArray[ColorRGBX], color: SomeColor, start, len: int ) {.hasSimd, raises: [].} = ## Fills the image data with the color starting at index start and ## continuing for len indices. @@ -95,7 +95,7 @@ proc toPremultipliedAlpha*( c.b = ((c.b.uint32 * c.a + 127) div 255).uint8 data[i] = c -proc isOpaque*(data: var seq[ColorRGBX], start, len: int): bool {.hasSimd.} = +proc isOpaque*(data: ptr UncheckedArray[ColorRGBX], start, len: int): bool {.hasSimd.} = result = true for i in start ..< start + len: if data[i].a != 255: diff --git a/src/pixie/simd/avx.nim b/src/pixie/simd/avx.nim index 6d30b95c..ddda4266 100644 --- a/src/pixie/simd/avx.nim +++ b/src/pixie/simd/avx.nim @@ -7,7 +7,7 @@ when defined(release): {.push checks: off.} proc fillUnsafeAvx*( - data: var seq[ColorRGBX], + data: ptr UncheckedArray[ColorRGBX], color: SomeColor, start, len: int ) {.simd.} = diff --git a/src/pixie/simd/avx2.nim b/src/pixie/simd/avx2.nim index 1800e583..df09ce2d 100644 --- a/src/pixie/simd/avx2.nim +++ b/src/pixie/simd/avx2.nim @@ -98,7 +98,7 @@ proc isTransparentAvx2*(image: Image): bool {.simd.} = if image.data[i].a != 0: return false -proc isOpaqueAvx2*(data: var seq[ColorRGBX], start, len: int): bool {.simd.} = +proc isOpaqueAvx2*(data: ptr UncheckedArray[ColorRGBX], start, len: int): bool {.simd.} = result = true var i = start @@ -220,7 +220,14 @@ proc invertAvx2*(image: Image) {.simd.} = rgbx.a = 255 - rgbx.a image.data[i] = rgbx - toPremultipliedAlphaAvx2(image.data) + for i in 0 ..< image.dataLen: + var rgbx = image.data[i] + let a = rgbx.a.uint32 + if a != 255: + rgbx.r = ((rgbx.r.uint32 * a) div 255).uint8 + rgbx.g = ((rgbx.g.uint32 * a) div 255).uint8 + rgbx.b = ((rgbx.b.uint32 * a) div 255).uint8 + image.data[i] = rgbx proc applyOpacityAvx2*(image: Image, opacity: float32) {.simd.} = let opacity = round(255 * opacity).uint16 diff --git a/src/pixie/simd/neon.nim b/src/pixie/simd/neon.nim index 8455ed0a..cef069bd 100644 --- a/src/pixie/simd/neon.nim +++ b/src/pixie/simd/neon.nim @@ -28,7 +28,7 @@ template blendNormalSimd(backdrop, source: uint8x16x4): uint8x16x4 = blended proc fillUnsafeNeon*( - data: var seq[ColorRGBX], + data: ptr UncheckedArray[ColorRGBX], color: SomeColor, start, len: int ) {.simd.} = @@ -63,7 +63,7 @@ proc isOneColorNeon*(image: Image): bool {.simd.} = i: int p = cast[uint](image.data[0].addr) # Align to 16 bytes - while i < image.data.len and (p and 15) != 0: + while i < image.dataLen and (p and 15) != 0: if image.data[i] != color: return false inc i @@ -71,7 +71,7 @@ proc isOneColorNeon*(image: Image): bool {.simd.} = let colorVecs = vld4q_dup_u8(color.unsafeAddr) - iterations = (image.data.len - i) div 16 + iterations = (image.dataLen - i) div 16 for _ in 0 ..< iterations: let deinterleved = vld4q_u8(image.data[i].addr) @@ -89,7 +89,7 @@ proc isOneColorNeon*(image: Image): bool {.simd.} = return false i += 16 - for i in i ..< image.data.len: + for i in i ..< image.dataLen: if image.data[i] != color: return false @@ -98,7 +98,7 @@ proc isTransparentNeon*(image: Image): bool {.simd.} = i: int p = cast[uint](image.data[0].addr) # Align to 16 bytes - while i < image.data.len and (p and 15) != 0: + while i < image.dataLen and (p and 15) != 0: if image.data[i].a != 0: return false inc i @@ -108,7 +108,7 @@ proc isTransparentNeon*(image: Image): bool {.simd.} = let vecZero = vmovq_n_u8(0) - iterations = (image.data.len - i) div 16 + iterations = (image.dataLen - i) div 16 for _ in 0 ..< iterations: let alphas = vld4q_u8(image.data[i].addr).val[3] @@ -120,11 +120,11 @@ proc isTransparentNeon*(image: Image): bool {.simd.} = return false i += 16 - for i in i ..< image.data.len: + for i in i ..< image.dataLen: if image.data[i].a != 0: return false -proc isOpaqueNeon*(data: var seq[ColorRGBX], start, len: int): bool {.simd.} = +proc isOpaqueNeon*(data: ptr UncheckedArray[ColorRGBX], start, len: int): bool {.simd.} = result = true var @@ -193,7 +193,7 @@ proc invertNeon*(image: Image) {.simd.} = i: int p = cast[uint](image.data[0].addr) # Align to 16 bytes - while i < image.data.len and (p and 15) != 0: + while i < image.dataLen and (p and 15) != 0: var rgbx = image.data[i] rgbx.r = 255 - rgbx.r rgbx.g = 255 - rgbx.g @@ -205,7 +205,7 @@ proc invertNeon*(image: Image) {.simd.} = let vec255 = vmovq_n_u8(255) - iterations = image.data.len div 16 + iterations = image.dataLen div 16 for _ in 0 ..< iterations: var channels = vld4q_u8(cast[pointer](p)) channels.val[0] = vsubq_u8(vec255, channels.val[0]) @@ -216,7 +216,7 @@ proc invertNeon*(image: Image) {.simd.} = p += 64 i += 16 * iterations - for i in i ..< image.data.len: + for i in i ..< image.dataLen: var rgbx = image.data[i] rgbx.r = 255 - rgbx.r rgbx.g = 255 - rgbx.g @@ -224,7 +224,14 @@ proc invertNeon*(image: Image) {.simd.} = rgbx.a = 255 - rgbx.a image.data[i] = rgbx - toPremultipliedAlphaNeon(image.data) + for i in 0 ..< image.dataLen: + var rgbx = image.data[i] + let a = rgbx.a.uint32 + if a != 255: + rgbx.r = ((rgbx.r.uint32 * a) div 255).uint8 + rgbx.g = ((rgbx.g.uint32 * a) div 255).uint8 + rgbx.b = ((rgbx.b.uint32 * a) div 255).uint8 + image.data[i] = rgbx proc applyOpacityNeon*(image: Image, opacity: float32) {.simd.} = let opacity = round(255 * opacity).uint8 @@ -232,7 +239,7 @@ proc applyOpacityNeon*(image: Image, opacity: float32) {.simd.} = return if opacity == 0: - fillUnsafeNeon(image.data, rgbx(0, 0, 0, 0), 0, image.data.len) + fillUnsafeNeon(image.data, rgbx(0, 0, 0, 0), 0, image.dataLen) return var @@ -241,7 +248,7 @@ proc applyOpacityNeon*(image: Image, opacity: float32) {.simd.} = let opacityVec = vmov_n_u8(opacity) - iterations = image.data.len div 8 + iterations = image.dataLen div 8 for _ in 0 ..< iterations: var channels = vld4_u8(cast[pointer](p)) channels.val[0] = multiplyDiv255(channels.val[0], opacityVec) @@ -252,7 +259,7 @@ proc applyOpacityNeon*(image: Image, opacity: float32) {.simd.} = p += 32 i += 8 * iterations - for i in i ..< image.data.len: + for i in i ..< image.dataLen: var rgbx = image.data[i] rgbx.r = ((rgbx.r * opacity) div 255).uint8 rgbx.g = ((rgbx.g * opacity) div 255).uint8 @@ -268,7 +275,7 @@ proc ceilNeon*(image: Image) {.simd.} = let zeroVec = vmovq_n_u8(0) vec255 = vmovq_n_u8(255) - iterations = image.data.len div 4 + iterations = image.dataLen div 4 for _ in 0 ..< iterations: var values = vld1q_u8(cast[pointer](p)) values = vceqq_u8(values, zeroVec) @@ -277,7 +284,7 @@ proc ceilNeon*(image: Image) {.simd.} = p += 16 i += 4 * iterations - for i in i ..< image.data.len: + for i in i ..< image.dataLen: var rgbx = image.data[i] rgbx.r = if rgbx.r == 0: 0 else: 255 rgbx.g = if rgbx.g == 0: 0 else: 255 diff --git a/src/pixie/simd/sse2.nim b/src/pixie/simd/sse2.nim index 35c7333b..30546271 100644 --- a/src/pixie/simd/sse2.nim +++ b/src/pixie/simd/sse2.nim @@ -46,7 +46,7 @@ template blendMaskSimd(backdrop, source: M128i): M128i = mm_or_si128(backdropEven, mm_slli_epi16(backdropOdd, 8)) proc fillUnsafeSse2*( - data: var seq[ColorRGBX], + data: ptr UncheckedArray[ColorRGBX], color: SomeColor, start, len: int ) {.simd.} = @@ -145,7 +145,7 @@ proc isTransparentSse2*(image: Image): bool {.simd.} = if image.data[i].a != 0: return false -proc isOpaqueSse2*(data: var seq[ColorRGBX], start, len: int): bool {.simd.} = +proc isOpaqueSse2*(data: ptr UncheckedArray[ColorRGBX], start, len: int): bool {.simd.} = result = true var @@ -264,7 +264,14 @@ proc invertSse2*(image: Image) {.simd.} = rgbx.a = 255 - rgbx.a image.data[i] = rgbx - toPremultipliedAlphaSse2(image.data) + for i in 0 ..< image.dataLen: + var rgbx = image.data[i] + let a = rgbx.a.uint32 + if a != 255: + rgbx.r = ((rgbx.r.uint32 * a) div 255).uint8 + rgbx.g = ((rgbx.g.uint32 * a) div 255).uint8 + rgbx.b = ((rgbx.b.uint32 * a) div 255).uint8 + image.data[i] = rgbx proc applyOpacitySse2*(image: Image, opacity: float32) {.simd.} = let opacity = round(255 * opacity).uint16 diff --git a/tests/bench_view_cost.nim b/tests/bench_view_cost.nim new file mode 100644 index 00000000..f6c5aa81 --- /dev/null +++ b/tests/bench_view_cost.nim @@ -0,0 +1,66 @@ +## Raster hot paths, timed identically before and after the image-view change. +## +## The view lets a sub-region borrow its parent's pixels instead of copying +## them, which costs an indirection on the way to every pixel. This exists so +## that cost is a number rather than a hope — run it on the same machine before +## and after, and on the ESP32 where an in-order core feels it most. +## +## nim c -r -d:release --path:src tests/bench_view_cost.nim +import std/[monotimes, strformat, times] +import pixie + +proc bench(name: string, iterations: int, body: proc ()) = + body() # warm up + var best = float.high + for _ in 0 ..< 5: + let start = getMonoTime() + for _ in 0 ..< iterations: + body() + let elapsed = (getMonoTime() - start).inNanoseconds.float / 1e6 + if elapsed < best: + best = elapsed + echo &"{name:<34}{best / iterations.float:>10.3f} ms" + +const W = 800 +const H = 480 + +let canvas = newImage(W, H) +let sprite = newImage(W div 2, H div 2) +sprite.fill(rgba(200, 40, 90, 200)) + +bench("fill", 200, proc () = + canvas.fill(rgba(63, 127, 191, 255)) +) + +bench("draw opaque over canvas", 200, proc () = + canvas.draw(sprite, translate(vec2(10, 10)), OverwriteBlend) +) + +bench("draw alpha over canvas", 200, proc () = + canvas.draw(sprite, translate(vec2(10, 10)), NormalBlend) +) + +bench("draw scaled 2x", 100, proc () = + canvas.draw(sprite, translate(vec2(0, 0)) * scale(vec2(2, 2)), NormalBlend) +) + +bench("per-pixel read+write", 20, proc () = + for y in 0 ..< H: + for x in 0 ..< W: + let c = canvas.unsafe[x, y] + canvas.unsafe[x, y] = rgbx(c.g, c.b, c.r, c.a) +) + +let path = newPath() +path.roundedRect(40, 40, W.float32 - 80, H.float32 - 80, 24, 24, 24, 24) +bench("fillPath rounded rect", 100, proc () = + canvas.fillPath(path, rgba(20, 160, 120, 255)) +) + +bench("subImage copy (quarter)", 200, proc () = + discard canvas.subImage(0, 0, W div 2, H div 2) +) + +bench("newImage quarter", 200, proc () = + discard newImage(W div 2, H div 2) +) diff --git a/tests/compile_all.nim b/tests/compile_all.nim new file mode 100644 index 00000000..b0d31a3d --- /dev/null +++ b/tests/compile_all.nim @@ -0,0 +1,7 @@ +## Pulls in every module so a type change surfaces everywhere at once. +import pixie +import pixie/fileformats/bmp, pixie/fileformats/gif, pixie/fileformats/jpeg +import pixie/fileformats/png, pixie/fileformats/ppm, pixie/fileformats/qoi +import pixie/fileformats/svg, pixie/fileformats/tiff, pixie/fileformats/webp +import pixie/imagebase64, pixie/internal, pixie/simd +echo "compiled" From 1662ee225cf1b5753b323eeda715d419c9ebf515 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Tue, 11 Aug 2026 18:41:43 +0200 Subject: [PATCH 18/29] Add forEachSpan: the seam that keeps flat ops fast for owners and correct for views --- src/pixie/common.nim | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/pixie/common.nim b/src/pixie/common.nim index 64483a14..f0311d85 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -117,6 +117,25 @@ template isContiguous*(image: Image): bool = ## when it is full width. image.stride == image.width +template forEachSpan*(image: Image, body: untyped) = + ## Runs `body` over every run of pixels that IS contiguous in the buffer, + ## injecting `spanStart` (an index into `image.data`) and `spanLen`. + ## + ## An image that owns its pixels is one span, so a whole-image operation + ## written this way costs exactly what it did before the views existed — one + ## call, one loop, the same SIMD. A view is one span per row. This is the + ## seam that lets flat operations stay fast and become correct at once. + block: + if image.stride == image.width: + let spanStart {.inject.} = image.origin + let spanLen {.inject.} = image.width * image.height + body + else: + for spanRow in 0 ..< image.height: + let spanStart {.inject.} = image.origin + image.stride * spanRow + let spanLen {.inject.} = image.width + body + template dataLen*(image: Image): int = ## Number of pixels the image addresses. Only the extent of a flat walk when ## `isContiguous`. From f3dd8edf4b9363b328e5f1e1ab5f7133fba0c68b Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 12 Aug 2026 00:01:52 +0200 Subject: [PATCH 19/29] Images can borrow another image's pixels instead of copying them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `subImage` allocates a buffer and copies a region into it, so handing a caller a sub-region costs a full copy out and, if the caller wrote to it, a copy back. For a tiled render that draws each cell into its own region, that is one buffer per cell — and nested tiles stack them, holding every level's copy live while the innermost one renders. `view(image, x, y, w, h)` returns a window onto the original instead. It writes through, so there is nothing to copy back, and views of views flatten onto the first owner, so nesting costs one object and no extra indirection however deep it goes. `subImage` keeps copying; callers that want a snapshot still get one. ## How it fits `Image` gains `stride` (elements between rows in the shared buffer) and `origin` (index of its top-left), and `data` becomes a pointer to the buffer it addresses — its own, or its owner's. Everything already routed through `dataIndex` became view-correct with no edit and no extra work per pixel. Making `data` a pointer also removed `data.len`, which is what turned "find every place that assumes the whole image is one contiguous run" from an audit into a compile error. Most were decoders and encoders working on an image they had just allocated — always owners — and became `dataLen`. The rest are the whole-image operations, and they now go through `forEachSpan`: one span for an owner, so the SIMD path gets the same single large range it always did, one span per row for a view. `isOneColor` and `isTransparent` decline for a view rather than answer wrongly; they had no callers inside pixie, and a caller that gets `false` simply takes the general path. `isOpaque` is exact for views instead, since nothing could bypass it. `rotate90` refuses a view outright — it swaps the dimensions, and a window cannot change shape inside its parent. ## Bugs this turned up, all pre-existing * `isOpaqueSse2` seeded its aligned pointer from `data[0]` while its index started at `start`, so it answered for the wrong range whenever `start != 0`. * `applyOpacityNeon`'s scalar tail multiplied two `uint8`s and wrapped: 240 at half opacity came out 0 instead of 120. * `isOpaqueNeon` had the same misaligned probe, without the miscomputation. * `encodePng` did `var copy = image.data` and then converted `copy` to straight alpha. That was a seq copy before and is a pointer copy now, so it would have rewritten the caller's image; it takes a packed copy via `toContiguousSeq`. * `rotate90` ended with `image.data = move rotated.data`, which after the type change hands the image a pointer into a temporary that dies on return. ## Also `==` on images compares pixels. It has to exist now: `a.data == b.data` compares addresses, which is never what anyone means and is quietly false for identical images — the test suite was full of it. `newImageFrom`/`newImageFromUnchecked` adopt a decoder's buffer without copying, keeping the move that `convertToImage` exists for. Whole suite green. tests/bench_view_cost.nim measures the cost: on the shared operations there is none (fill, draws, per-pixel access and fillPath all within noise of the buffer-owning build), and taking a quarter-canvas region goes from 0.032 ms to nothing at all. --- src/pixie.nim | 16 +- src/pixie/common.nim | 52 ++++++ src/pixie/fileformats/png.nim | 21 +-- src/pixie/fileformats/ppm.nim | 7 +- src/pixie/fileformats/qoi.nim | 33 ++-- src/pixie/fileformats/tiff.nim | 22 +-- src/pixie/images.nim | 141 ++++++++++++----- src/pixie/internal.nim | 34 ++++ src/pixie/simd/avx2.nim | 264 ++++++++++++++++--------------- src/pixie/simd/neon.nim | 193 +++++++++++++---------- src/pixie/simd/sse2.nim | 278 ++++++++++++++++++--------------- tests/bench_blends.nim | 42 ++--- tests/bench_images.nim | 4 +- tests/bench_view_cost.nim | 16 ++ tests/test_base64.nim | 4 +- tests/test_bmp.nim | 18 +-- tests/test_images.nim | 2 +- tests/test_jpeg.nim | 2 +- tests/test_png.nim | 24 +-- tests/test_ppm.nim | 4 +- tests/test_qoi.nim | 2 +- tests/test_svg.nim | 8 +- tests/validate_png.nim | 2 +- 23 files changed, 708 insertions(+), 481 deletions(-) diff --git a/src/pixie.nim b/src/pixie.nim index fa8f9abd..9f5af235 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -92,11 +92,13 @@ proc validateScaledImageTarget(target: Image) {.raises: [PixieError].} = proc copyIntoTarget(target, source: Image) {.raises: [PixieError].} = if target.width != source.width or target.height != source.height: raise newException(PixieError, "Image dimensions do not match target") - if target.data.len > 0: + # Row at a time: either side may be a view, whose rows are `stride` apart + # rather than adjacent. + for y in 0 ..< target.height: copyMem( - target.data[0].addr, - source.data[0].unsafeAddr, - target.data.len * sizeof(ColorRGBX) + target.data[target.dataIndex(0, y)].addr, + source.data[source.dataIndex(0, y)].unsafeAddr, + target.width * sizeof(ColorRGBX) ) proc decodeImageScaled*( @@ -286,9 +288,11 @@ proc fill*(image: Image, paint: Paint) {.raises: [PixieError].} = ## Fills the image with the paint. case paint.kind: of SolidPaint: - fillUnsafe(image.data, paint.color, 0, image.data.len) + image.forEachSpan: + fillUnsafe(image.data, paint.color, spanStart, spanLen) of ImagePaint, TiledImagePaint: - fillUnsafe(image.data, rgbx(0, 0, 0, 0), 0, image.data.len) + image.forEachSpan: + fillUnsafe(image.data, rgbx(0, 0, 0, 0), spanStart, spanLen) let path = newPath() path.rect(0, 0, image.width.float32, image.height.float32) image.fillPath(path, paint) diff --git a/src/pixie/common.nim b/src/pixie/common.nim index f0311d85..95f29f1f 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -154,6 +154,58 @@ proc newImage*(width, height: int): Image {.raises: [PixieError].} = result.storage = newSeq[ColorRGBX](width * height) result.pixels = cast[ptr UncheckedArray[ColorRGBX]](result.storage[0].addr) +proc newImageFrom*( + width, height: int, data: sink seq[ColorRGBX] +): Image {.raises: [PixieError].} = + ## Wraps an existing buffer as an image that owns it, without copying. + ## + ## Decoders build their pixels in a seq and hand it over; that move is worth + ## keeping, so this exists rather than making them copy into a fresh image. + if width <= 0 or height <= 0: + raise newException(PixieError, "Image width and height must be > 0") + if data.len < width * height: + raise newException(PixieError, "Buffer is too small for " & $width & "x" & $height) + result = Image() + result.width = width + result.height = height + result.stride = width + result.origin = 0 + result.storage = data + result.pixels = cast[ptr UncheckedArray[ColorRGBX]](result.storage[0].addr) + +proc toContiguousSeq*(image: Image): seq[ColorRGBX] {.raises: [].} = + ## The image's pixels as a packed, owned buffer, rows back to back. + ## + ## For encoders and anything else that needs to hand a plain array to a + ## library. It is a copy on purpose: `image.data` is a pointer now, so + ## `var copy = image.data` aliases rather than copies, and a caller that then + ## mutates `copy` would be scribbling on the image it was asked to read. + result = newSeq[ColorRGBX](image.width * image.height) + if image.width * image.height > 0: + for y in 0 ..< image.height: + copyMem( + result[y * image.width].addr, + image.data[image.dataIndex(0, y)].addr, + image.width * 4 + ) + +proc newImageFromUnchecked*( + width, height: int, data: sink seq[ColorRGBX] +): Image {.raises: [].} = + ## `newImageFrom` for callers whose dimensions are already validated and + ## whose signature promises not to raise — the decoders, which checked the + ## header long before they got here. Wrong arguments are a programming error, + ## not a malformed file, so they assert rather than raise. + doAssert width > 0 and height > 0 + doAssert data.len >= width * height + result = Image() + result.width = width + result.height = height + result.stride = width + result.origin = 0 + result.storage = data + result.pixels = cast[ptr UncheckedArray[ColorRGBX]](result.storage[0].addr) + proc view*(image: Image, x, y, w, h: int): Image {.raises: [PixieError].} = ## A window onto part of `image`, sharing its pixels. Writes through. ## diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index 85ea4350..c0c5dfb8 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -689,7 +689,7 @@ proc newImage*(png: Png): Image {.raises: [PixieError].} = result = newImage(png.width, png.height) if png.data.len > 0: copyMem(result.data[0].addr, png.data[0].addr, png.data.len * 4) - result.data.toPremultipliedAlpha() + result.toPremultipliedAlpha() else: for i, color in png.data16: result.data[i] = color.toRgba.rgbx() @@ -702,16 +702,17 @@ proc convertToImage*(png: Png): Image {.raises: [].} = data: seq[ColorRGBX] data16: seq[ColorRGBA16] - result = Image() - result.width = png.width - result.height = png.height if png.data.len > 0: - result.data = move cast[Movable](png).data - result.data.toPremultipliedAlpha() + # Adopt the decoder's buffer rather than copying it; that move is the whole + # point of this entry point. + result = newImageFromUnchecked( + png.width, png.height, move cast[Movable](png).data) + result.toPremultipliedAlpha() else: - result.data.setLen(png.data16.len) + var pixels = newSeq[ColorRGBX](png.width * png.height) for i, color in png.data16: - result.data[i] = color.toRgba.rgbx() + pixels[i] = color.toRgba.rgbx() + result = newImageFromUnchecked(png.width, png.height, move pixels) proc validateScaledPngTarget(width, height: int) {.raises: [PixieError].} = if width <= 0 or width > int32.high.int: @@ -1711,12 +1712,12 @@ proc encodePng*(png: Png): string {.raises: [PixieError].} = proc encodePng*(image: Image): string {.raises: [PixieError].} = ## Encodes the image data into the PNG file format. - if image.data.len == 0: + if image.dataLen == 0: raise newException( PixieError, "Image has no data (are height and width 0?)" ) - var copy = image.data + var copy = image.toContiguousSeq() copy.toStraightAlpha() encodePng(image.width, image.height, 4, copy[0].addr, copy.len * 4) diff --git a/src/pixie/fileformats/ppm.nim b/src/pixie/fileformats/ppm.nim index 0b0f56ef..d887e700 100644 --- a/src/pixie/fileformats/ppm.nim +++ b/src/pixie/fileformats/ppm.nim @@ -138,15 +138,14 @@ proc decodePpm*(data: string): Image {.raises: [PixieError].} = if header.maxVal <= 0 or header.maxVal > 0xFFFF: failInvalid() - result = newImage(header.width, header.height) - let pixels = + var pixels = if header.version == "P3": decodeP3Data(data[header.dataOffset .. ^1], header.maxVal) else: decodeP6Data(data[header.dataOffset .. ^1], header.maxVal) - if pixels.len != result.data.len: + if pixels.len != header.width * header.height: failInvalid() - result.data = pixels + result = newImageFrom(header.width, header.height, move pixels) proc decodePpmDimensions*( data: pointer, len: int diff --git a/src/pixie/fileformats/qoi.nim b/src/pixie/fileformats/qoi.nim index 69f66d45..b63c3d7f 100644 --- a/src/pixie/fileformats/qoi.nim +++ b/src/pixie/fileformats/qoi.nim @@ -48,9 +48,10 @@ proc srgbToLinear(color: var ColorRGBX) {.inline.} = color.g = color.g.srgbToLinear() color.b = color.b.srgbToLinear() -proc srgbToLinear(data: ptr UncheckedArray[ColorRGBX]) = - for color in data.mitems: - color.srgbToLinear() +proc srgbToLinear(image: Image) = + image.forEachSpan: + for i in spanStart ..< spanStart + spanLen: + image.data[i].srgbToLinear() proc linearPixel(qoi: Qoi, px: ColorRGBA): ColorRGBA {.inline.} = result = px @@ -62,8 +63,8 @@ proc newImage*(qoi: Qoi): Image = result = newImage(qoi.width, qoi.height) copyMem(result.data[0].addr, qoi.data[0].addr, qoi.data.len * 4) if qoi.colorspace == sRBG: - result.data.srgbToLinear() - result.data.toPremultipliedAlpha() + result.srgbToLinear() + result.toPremultipliedAlpha() proc convertToImage*(qoi: Qoi): Image {.raises: [].} = ## Converts a QOI into an Image by moving the data. This is faster but can @@ -73,13 +74,11 @@ proc convertToImage*(qoi: Qoi): Image {.raises: [].} = colorspace: Colorspace data: seq[ColorRGBX] - result = Image() - result.width = qoi.width - result.height = qoi.height - result.data = move cast[Movable](qoi).data + result = newImageFromUnchecked( + qoi.width, qoi.height, move cast[Movable](qoi).data) if qoi.colorspace == sRBG: - result.data.srgbToLinear() - result.data.toPremultipliedAlpha() + result.srgbToLinear() + result.toPremultipliedAlpha() proc decodeQoi*(data: string): Qoi {.raises: [PixieError].} = ## Decompress QOI file format data. @@ -265,9 +264,15 @@ proc encodeQoi*(image: Image): string {.raises: [PixieError].} = qoi.height = image.height qoi.channels = 4 qoi.colorspace = Linear - qoi.data.setLen(image.data.len) - - copyMem(qoi.data[0].addr, image.data[0].addr, image.data.len * 4) + # Packed copy rather than a flat one: a view's rows are not adjacent, and + # `image.data` is a pointer into a buffer that may be larger than the image. + qoi.data.setLen(image.width * image.height) + for y in 0 ..< image.height: + copyMem( + qoi.data[y * image.width].addr, + image.data[image.dataIndex(0, y)].addr, + image.width * 4 + ) qoi.data.toStraightAlpha() encodeQoi(qoi) diff --git a/src/pixie/fileformats/tiff.nim b/src/pixie/fileformats/tiff.nim index 1852caa7..9ca94a4d 100644 --- a/src/pixie/fileformats/tiff.nim +++ b/src/pixie/fileformats/tiff.nim @@ -707,26 +707,26 @@ proc convertToImage*(tiff: Tiff, min = 0.0f, max = 1.0f): Image {.raises: [].} = dataFormat: TiffDataFormat data: seq[ColorRGBX] - result = Image() - result.width = tiff.width - result.height = tiff.height - case tiff.dataFormat of tiffRgba: - result.data = move cast[Movable](tiff).data - result.data.toPremultipliedAlpha() + result = newImageFromUnchecked( + tiff.width, tiff.height, move cast[Movable](tiff).data) + result.toPremultipliedAlpha() of tiffGray16: - result.data.setLen(tiff.dataGray16.len) + result = newImageFromUnchecked(tiff.width, tiff.height, + newSeq[ColorRGBX](tiff.width * tiff.height)) for i, gray in tiff.dataGray16: let value = (gray div 257).uint8 result.data[i] = rgbx(value, value, value, 255) of tiffGrayInt16: - result.data.setLen(tiff.dataGrayInt16.len) + result = newImageFromUnchecked(tiff.width, tiff.height, + newSeq[ColorRGBX](tiff.width * tiff.height)) for i, gray in tiff.dataGrayInt16: let value = ((gray.int32 + 32768) div 257).uint8 result.data[i] = rgbx(value, value, value, 255) of tiffFloat32: - result.data.setLen(tiff.dataFloat32.len) + result = newImageFromUnchecked(tiff.width, tiff.height, + newSeq[ColorRGBX](tiff.width * tiff.height)) for i in 0 ..< tiff.dataFloat32.len: var gray: float32 if max > min: @@ -743,8 +743,8 @@ proc newImage*(tiff: Tiff): Image = return convertToImage(tiff) result = newImage(tiff.width, tiff.height) - if tiff.data.len != result.data.len: + if tiff.data.len != result.dataLen: failInvalid("image data length mismatch") if tiff.data.len > 0: copyMem(result.data[0].addr, tiff.data[0].addr, tiff.data.len * 4) - result.data.toPremultipliedAlpha() + result.toPremultipliedAlpha() diff --git a/src/pixie/images.nim b/src/pixie/images.nim index be84842a..5ca88225 100644 --- a/src/pixie/images.nim +++ b/src/pixie/images.nim @@ -54,26 +54,61 @@ proc setColor*(image: Image, x, y: int, color: Color) {.inline, raises: [].} = proc fill*(image: Image, color: SomeColor) {.inline, raises: [].} = ## Fills the image with the color. - fillUnsafe(image.data, color, 0, image.data.len) + # An owner is a single span, so this stays the one memset it always was. + # A view fills row by row, which is how a tiled render clears its cell. + image.forEachSpan: + fillUnsafe(image.data, color, spanStart, spanLen) + +proc `==`*(a, b: Image): bool {.raises: [].} = + ## Compares two images by their pixels. + ## + ## Worth having explicitly now that `data` is a pointer: `a.data == b.data` + ## compares addresses, which is never what a caller means and is quietly + ## false for two images with identical contents. + if a.isNil or b.isNil: + return a.isNil and b.isNil + if a.width != b.width or a.height != b.height: + return false + for y in 0 ..< a.height: + if not equalMem( + a.data[a.dataIndex(0, y)].addr, + b.data[b.dataIndex(0, y)].addr, + a.width * 4 + ): + return false + true proc isOneColor*(image: Image): bool {.hasSimd, raises: [].} = ## Checks if the entire image is the same color. + # This is an optimization hint with no callers inside pixie, so a view gets + # the conservative answer rather than a row walk. The SIMD variants bail out + # the same way — the dispatch the hasSimd pragma inserts runs before this. + if image.isView: + return false result = true let color = cast[uint32](image.data[0]) - for i in 0 ..< image.data.len: + for i in 0 ..< image.dataLen: if cast[uint32](image.data[i]) != color: return false proc isTransparent*(image: Image): bool {.hasSimd, raises: [].} = ## Checks if this image is fully transparent or not. + # Conservative for a view, for the same reason as isOneColor. + if image.isView: + return false result = true - for i in 0 ..< image.data.len: + for i in 0 ..< image.dataLen: if image.data[i].a != 0: return false proc isOpaque*(image: Image): bool {.raises: [].} = ## Checks if the entire image is opaque (alpha values are all 255). - isOpaque(image.data, 0, image.dataLen) + # No image-level SIMD variant can bypass this walk, so unlike isOneColor and + # isTransparent a view can be answered exactly instead of conservatively. + result = true + image.forEachSpan: + if not isOpaque(image.data, spanStart, spanLen): + return false proc flipHorizontal*(image: Image) {.raises: [].} = ## Flips the image around the Y axis. @@ -99,14 +134,23 @@ proc flipVertical*(image: Image) {.raises: [].} = proc rotate90*(image: Image) {.raises: [PixieError].} = ## Rotates the image 90 degrees clockwise. + # Rotating swaps the dimensions, which a view cannot express: its rectangle + # lives inside someone else's buffer and cannot change shape there. + if image.isView: + raise newException(PixieError, "Cannot rotate90 a view, copy it first") + let rotated = newImage(image.height, image.width) for y in 0 ..< rotated.height: for x in 0 ..< rotated.width: rotated.data[rotated.dataIndex(x, y)] = image.data[image.dataIndex(y, image.height - x - 1)] - image.width = rotated.width - image.height = rotated.height - image.data = move rotated.data + # The pixels are copied back rather than the buffer handed over, because an + # image's storage belongs to it: rebinding it to the temporary's would leave + # this image pointing at pixels that die with the temporary. The pixel count + # is unchanged, so the existing buffer still fits. + swap(image.width, image.height) + image.stride = image.width + copyMem(image.data[0].addr, rotated.data[0].addr, image.dataLen * 4) proc subImage*(image: Image, x, y, w, h: int): Image {.raises: [PixieError].} = ## Gets a sub image from this image. @@ -268,38 +312,45 @@ proc applyOpacity*(image: Image, opacity: float32) {.hasSimd, raises: [].} = image.fill(rgbx(0, 0, 0, 0)) return - for i in 0 ..< image.data.len: - var rgbx = image.data[i] - 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 - image.data[i] = rgbx + image.forEachSpan: + for i in spanStart ..< spanStart + spanLen: + var rgbx = image.data[i] + 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 + image.data[i] = rgbx proc invert*(image: Image) {.hasSimd, raises: [].} = ## Inverts all of the colors and alpha. - for i in 0 ..< image.data.len: - var rgbx = image.data[i] - rgbx.r = 255 - rgbx.r - rgbx.g = 255 - rgbx.g - rgbx.b = 255 - rgbx.b - rgbx.a = 255 - rgbx.a - image.data[i] = rgbx - - # Inverting rgbx(50, 100, 150, 200) becomes rgbx(205, 155, 105, 55). This - # is not a valid premultiplied alpha color. - # We need to convert back to premultiplied alpha after inverting. - image.data.toPremultipliedAlpha() + image.forEachSpan: + for i in spanStart ..< spanStart + spanLen: + var rgbx = image.data[i] + rgbx.r = 255 - rgbx.r + rgbx.g = 255 - rgbx.g + rgbx.b = 255 - rgbx.b + rgbx.a = 255 - rgbx.a + # Inverting rgbx(50, 100, 150, 200) becomes rgbx(205, 155, 105, 55). This + # is not a valid premultiplied alpha color, so convert back here. Both + # steps are per pixel, so folding the conversion into this pass gives the + # same result as the separate whole-buffer pass it replaces — and unlike + # that pass it follows the image's rows rather than the buffer. + if rgbx.a != 255: + rgbx.r = ((rgbx.r.uint32 * rgbx.a + 127) div 255).uint8 + rgbx.g = ((rgbx.g.uint32 * rgbx.a + 127) div 255).uint8 + rgbx.b = ((rgbx.b.uint32 * rgbx.a + 127) div 255).uint8 + image.data[i] = rgbx proc ceil*(image: Image) {.hasSimd, raises: [].} = ## A value of 0 stays 0. Anything else turns into 255. - for i in 0 ..< image.data.len: - var rgbx = image.data[i] - rgbx.r = if rgbx.r == 0: 0 else: 255 - rgbx.g = if rgbx.g == 0: 0 else: 255 - rgbx.b = if rgbx.b == 0: 0 else: 255 - rgbx.a = if rgbx.a == 0: 0 else: 255 - image.data[i] = rgbx + image.forEachSpan: + for i in spanStart ..< spanStart + spanLen: + var rgbx = image.data[i] + rgbx.r = if rgbx.r == 0: 0 else: 255 + rgbx.g = if rgbx.g == 0: 0 else: 255 + rgbx.b = if rgbx.b == 0: 0 else: 255 + rgbx.a = if rgbx.a == 0: 0 else: 255 + image.data[i] = rgbx proc blur*( image: Image, radius: float32, outOfBounds: SomeColor = color(0, 0, 0, 0) @@ -465,6 +516,16 @@ proc blendLineMask(a, b: ptr UncheckedArray[ColorRGBX], len: int) {.hasSimd.} = for i in 0 ..< len: a[i] = blendMask(a[i], b[i]) +template zeroRows(image: Image, y, h: int) = + ## Clears `h` whole rows starting at row `y`. One memset when the rows are + ## back to back, which is every owner, and one per row for a strided view — + ## whose rows must not be zeroed through, since the gaps belong to the parent. + if image.isContiguous: + zeroMem(image.data[image.dataIndex(0, y)].addr, h * image.width * 4) + else: + for yy in y ..< y + h: + zeroMem(image.data[image.dataIndex(0, yy)].addr, image.width * 4) + proc blendRect(a, b: Image, pos: Ivec2, blendMode: BlendMode) = let px = pos.x.int @@ -499,7 +560,7 @@ proc blendRect(a, b: Image, pos: Ivec2, blendMode: BlendMode) = of MaskBlend: {.linearScanEnd.} if yStart + py > 0: - zeroMem(a.data[0].addr, (yStart + py) * a.width * 4) + a.zeroRows(0, yStart + py) for y in yStart ..< yEnd: if xStart + px > 0: zeroMem(a.data[a.dataIndex(0, y + py)].addr, (xStart + px) * 4) @@ -514,10 +575,7 @@ proc blendRect(a, b: Image, pos: Ivec2, blendMode: BlendMode) = (a.width - (xEnd + px)) * 4 ) if yEnd + py < a.height: - zeroMem( - a.data[a.dataIndex(0, yEnd + py)].addr, - (a.height - (yEnd + py)) * a.width * 4 - ) + a.zeroRows(yEnd + py, a.height - (yEnd + py)) else: let blender = blendMode.blender() for y in yStart ..< yEnd: @@ -559,7 +617,7 @@ proc drawSmooth(a, b: Image, transform: Mat3, blendMode: BlendMode) = yEnd = yEnd.clamp(0, a.height) if blendMode == MaskBlend and yStart > 0: - zeroMem(a.data[0].addr, yStart * a.width * 4) + a.zeroRows(0, yStart) var sampleLine = newSeq[ColorRGBX](a.width) for y in yStart ..< yEnd: @@ -628,10 +686,7 @@ proc drawSmooth(a, b: Image, transform: Mat3, blendMode: BlendMode) = ) if blendMode == MaskBlend and a.height - yEnd > 0: - zeroMem( - a.data[a.dataIndex(0, yEnd)].addr, - a.width * (a.height - yEnd) * 4 - ) + a.zeroRows(yEnd, a.height - yEnd) proc draw*( a, b: Image, transform = mat3(), blendMode = NormalBlend diff --git a/src/pixie/internal.nim b/src/pixie/internal.nim index 2d933e12..f2f6cd4e 100644 --- a/src/pixie/internal.nim +++ b/src/pixie/internal.nim @@ -73,6 +73,40 @@ const straightAlphaTable = block: table[a][c] = min(round((c.float32 * multiplier)), 255).uint8 table +proc fillUnsafe*( + data: var seq[ColorRGBX], color: SomeColor, start, len: int +) {.inline, raises: [].} = + ## Convenience for callers that hold a real seq rather than an image buffer. + if data.len > 0: + fillUnsafe( + cast[ptr UncheckedArray[ColorRGBX]](data[0].addr), color, start, len) + +proc toStraightAlpha*(image: Image) {.raises: [].} = + ## Converts an image from premultiplied alpha to straight alpha, in place. + ## + ## Span-based rather than a flat walk so it is correct for a view; an owner + ## is one span, so this is the same single pass it always was. The seq + ## overload below keeps the SIMD path for decoders that hold their pixels + ## outside an Image. + image.forEachSpan: + for i in spanStart ..< spanStart + spanLen: + var c = image.data[i] + c.r = straightAlphaTable[c.a][c.r] + c.g = straightAlphaTable[c.a][c.g] + c.b = straightAlphaTable[c.a][c.b] + image.data[i] = c + +proc toPremultipliedAlpha*(image: Image) {.raises: [].} = + ## Converts an image to premultiplied alpha from straight alpha, in place. + image.forEachSpan: + for i in spanStart ..< spanStart + spanLen: + var c = image.data[i] + if c.a != 255: + c.r = ((c.r.uint32 * c.a + 127) div 255).uint8 + c.g = ((c.g.uint32 * c.a + 127) div 255).uint8 + c.b = ((c.b.uint32 * c.a + 127) div 255).uint8 + image.data[i] = c + proc toStraightAlpha*(data: var seq[ColorRGBA | ColorRGBX]) {.raises: [].} = ## Converts an image from premultiplied alpha to straight alpha. ## This is expensive for large images. diff --git a/src/pixie/simd/avx2.nim b/src/pixie/simd/avx2.nim index df09ce2d..2460a3e8 100644 --- a/src/pixie/simd/avx2.nim +++ b/src/pixie/simd/avx2.nim @@ -42,20 +42,26 @@ template blendMaskSimd(backdrop, source: M256i): M256i = mm256_or_si256(backdropEven, mm256_slli_epi16(backdropOdd, 8)) proc isOneColorAvx2*(image: Image): bool {.simd.} = + # A view's pixels are not one flat run, and this answer is only ever used to + # take a shortcut, so declining for views is safe: the caller falls back to + # the general path. + if image.isView: + return false + result = true let color = image.data[0] var i: int # Align to 32 bytes - while i < image.data.len and (cast[uint](image.data[i].addr) and 31) != 0: + while i < image.dataLen and (cast[uint](image.data[i].addr) and 31) != 0: if image.data[i] != color: return false inc i let colorVec = mm256_set1_epi32(cast[int32](color)) - iterations = (image.data.len - i) div 16 + iterations = (image.dataLen - i) div 16 for _ in 0 ..< iterations: let values0 = mm256_load_si256(image.data[i].addr) @@ -67,23 +73,27 @@ proc isOneColorAvx2*(image: Image): bool {.simd.} = return false i += 16 - for i in i ..< image.data.len: + for i in i ..< image.dataLen: if image.data[i] != color: return false proc isTransparentAvx2*(image: Image): bool {.simd.} = + # See isOneColorAvx2: a false answer for a view is conservative, not wrong. + if image.isView: + return false + result = true var i: int # Align to 32 bytes - while i < image.data.len and (cast[uint](image.data[i].addr) and 31) != 0: + while i < image.dataLen and (cast[uint](image.data[i].addr) and 31) != 0: if image.data[i].a != 0: return false inc i let vecZero = mm256_setzero_si256() - iterations = (image.data.len - i) div 16 + iterations = (image.dataLen - i) div 16 for _ in 0 ..< iterations: let values0 = mm256_load_si256(image.data[i].addr) @@ -94,7 +104,7 @@ proc isTransparentAvx2*(image: Image): bool {.simd.} = return false i += 16 - for i in i ..< image.data.len: + for i in i ..< image.dataLen: if image.data[i].a != 0: return false @@ -186,139 +196,149 @@ proc toPremultipliedAlphaAvx2*(data: var seq[ColorRGBA | ColorRGBX]) {.simd.} = data[i] = rgbx proc invertAvx2*(image: Image) {.simd.} = - var - i: int - p = cast[uint](image.data[0].addr) - # Align to 32 bytes - while i < image.data.len and (p and 31) != 0: - var rgbx = image.data[i] - rgbx.r = 255 - rgbx.r - rgbx.g = 255 - rgbx.g - rgbx.b = 255 - rgbx.b - rgbx.a = 255 - rgbx.a - image.data[i] = rgbx - inc i - p += 4 + image.forEachSpan: + let spanEnd = spanStart + spanLen + + var + i = spanStart + p = cast[uint](image.data[i].addr) + # Align to 32 bytes + while i < spanEnd and (p and 31) != 0: + var rgbx = image.data[i] + rgbx.r = 255 - rgbx.r + rgbx.g = 255 - rgbx.g + rgbx.b = 255 - rgbx.b + rgbx.a = 255 - rgbx.a + image.data[i] = rgbx + inc i + p += 4 - let - vec255 = mm256_set1_epi8(255) - iterations = (image.data.len - i) div 16 - for _ in 0 ..< iterations: let - a = mm256_load_si256(cast[pointer](p)) - b = mm256_load_si256(cast[pointer](p + 32)) - mm256_store_si256(cast[pointer](p), mm256_sub_epi8(vec255, a)) - mm256_store_si256(cast[pointer](p + 32), mm256_sub_epi8(vec255, b)) - p += 64 - i += 16 * iterations - - for i in i ..< image.data.len: - var rgbx = image.data[i] - rgbx.r = 255 - rgbx.r - rgbx.g = 255 - rgbx.g - rgbx.b = 255 - rgbx.b - rgbx.a = 255 - rgbx.a - image.data[i] = rgbx - - for i in 0 ..< image.dataLen: - var rgbx = image.data[i] - let a = rgbx.a.uint32 - if a != 255: - rgbx.r = ((rgbx.r.uint32 * a) div 255).uint8 - rgbx.g = ((rgbx.g.uint32 * a) div 255).uint8 - rgbx.b = ((rgbx.b.uint32 * a) div 255).uint8 + vec255 = mm256_set1_epi8(255) + iterations = (spanEnd - i) div 16 + for _ in 0 ..< iterations: + let + a = mm256_load_si256(cast[pointer](p)) + b = mm256_load_si256(cast[pointer](p + 32)) + mm256_store_si256(cast[pointer](p), mm256_sub_epi8(vec255, a)) + mm256_store_si256(cast[pointer](p + 32), mm256_sub_epi8(vec255, b)) + p += 64 + i += 16 * iterations + + for i in i ..< spanEnd: + var rgbx = image.data[i] + rgbx.r = 255 - rgbx.r + rgbx.g = 255 - rgbx.g + rgbx.b = 255 - rgbx.b + rgbx.a = 255 - rgbx.a image.data[i] = rgbx + for i in spanStart ..< spanEnd: + var rgbx = image.data[i] + let a = rgbx.a.uint32 + if a != 255: + rgbx.r = ((rgbx.r.uint32 * a + 127) div 255).uint8 + rgbx.g = ((rgbx.g.uint32 * a + 127) div 255).uint8 + rgbx.b = ((rgbx.b.uint32 * a + 127) div 255).uint8 + image.data[i] = rgbx + proc applyOpacityAvx2*(image: Image, opacity: float32) {.simd.} = let opacity = round(255 * opacity).uint16 if opacity == 255: return if opacity == 0: - fillUnsafeAvx(image.data, rgbx(0, 0, 0, 0), 0, image.data.len) + image.forEachSpan: + fillUnsafeAvx(image.data, rgbx(0, 0, 0, 0), spanStart, spanLen) return - var - i: int - p = cast[uint](image.data[0].addr) - # Align to 32 bytes - while i < image.data.len and (p and 31) != 0: - var rgbx = image.data[i] - 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 - image.data[i] = rgbx - inc i - p += 4 + image.forEachSpan: + let spanEnd = spanStart + spanLen + + var + i = spanStart + p = cast[uint](image.data[i].addr) + # Align to 32 bytes + while i < spanEnd and (p and 31) != 0: + var rgbx = image.data[i] + 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 + image.data[i] = rgbx + inc i + p += 4 - let - oddMask = mm256_set1_epi16(0xff00) - div255 = mm256_set1_epi16(0x8081) - zeroVec = mm256_setzero_si256() - opacityVec = mm256_slli_epi16(mm256_set1_epi16(opacity), 8) - iterations = (image.data.len - i) div 8 - for _ in 0 ..< iterations: let - values = mm256_load_si256(cast[pointer](p)) - eqZero = mm256_cmpeq_epi16(values, zeroVec) - if mm256_movemask_epi8(eqZero) != cast[int32](0xffffffff): - var - valuesEven = mm256_slli_epi16(values, 8) - valuesOdd = mm256_and_si256(values, oddMask) - valuesEven = mm256_mulhi_epu16(valuesEven, opacityVec) - valuesOdd = mm256_mulhi_epu16(valuesOdd, opacityVec) - valuesEven = mm256_srli_epi16(mm256_mulhi_epu16(valuesEven, div255), 7) - valuesOdd = mm256_srli_epi16(mm256_mulhi_epu16(valuesOdd, div255), 7) - mm256_store_si256( - cast[pointer](p), - mm256_or_si256(valuesEven, mm256_slli_epi16(valuesOdd, 8)) - ) - p += 32 - i += 8 * iterations - - for i in i ..< image.data.len: - var rgbx = image.data[i] - 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 - image.data[i] = rgbx + oddMask = mm256_set1_epi16(0xff00) + div255 = mm256_set1_epi16(0x8081) + zeroVec = mm256_setzero_si256() + opacityVec = mm256_slli_epi16(mm256_set1_epi16(opacity), 8) + iterations = (spanEnd - i) div 8 + for _ in 0 ..< iterations: + let + values = mm256_load_si256(cast[pointer](p)) + eqZero = mm256_cmpeq_epi16(values, zeroVec) + if mm256_movemask_epi8(eqZero) != cast[int32](0xffffffff): + var + valuesEven = mm256_slli_epi16(values, 8) + valuesOdd = mm256_and_si256(values, oddMask) + valuesEven = mm256_mulhi_epu16(valuesEven, opacityVec) + valuesOdd = mm256_mulhi_epu16(valuesOdd, opacityVec) + valuesEven = mm256_srli_epi16(mm256_mulhi_epu16(valuesEven, div255), 7) + valuesOdd = mm256_srli_epi16(mm256_mulhi_epu16(valuesOdd, div255), 7) + mm256_store_si256( + cast[pointer](p), + mm256_or_si256(valuesEven, mm256_slli_epi16(valuesOdd, 8)) + ) + p += 32 + i += 8 * iterations + + for i in i ..< spanEnd: + var rgbx = image.data[i] + 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 + image.data[i] = rgbx proc ceilAvx2*(image: Image) {.simd.} = - var - i: int - p = cast[uint](image.data[0].addr) - # Align to 32 bytes - while i < image.data.len and (p and 31) != 0: - var rgbx = image.data[i] - rgbx.r = if rgbx.r == 0: 0 else: 255 - rgbx.g = if rgbx.g == 0: 0 else: 255 - rgbx.b = if rgbx.b == 0: 0 else: 255 - rgbx.a = if rgbx.a == 0: 0 else: 255 - image.data[i] = rgbx - inc i - p += 4 - - let - vecZero = mm256_setzero_si256() - vec255 = mm256_set1_epi8(255) - iterations = (image.data.len - i) div 8 - for _ in 0 ..< iterations: - var values = mm256_load_si256(cast[pointer](p)) - values = mm256_cmpeq_epi8(values, vecZero) - values = mm256_andnot_si256(values, vec255) - mm256_store_si256(cast[pointer](p), values) - p += 32 - i += 8 * iterations + image.forEachSpan: + let spanEnd = spanStart + spanLen + + var + i = spanStart + p = cast[uint](image.data[i].addr) + # Align to 32 bytes + while i < spanEnd and (p and 31) != 0: + var rgbx = image.data[i] + rgbx.r = if rgbx.r == 0: 0 else: 255 + rgbx.g = if rgbx.g == 0: 0 else: 255 + rgbx.b = if rgbx.b == 0: 0 else: 255 + rgbx.a = if rgbx.a == 0: 0 else: 255 + image.data[i] = rgbx + inc i + p += 4 - for i in i ..< image.data.len: - var rgbx = image.data[i] - rgbx.r = if rgbx.r == 0: 0 else: 255 - rgbx.g = if rgbx.g == 0: 0 else: 255 - rgbx.b = if rgbx.b == 0: 0 else: 255 - rgbx.a = if rgbx.a == 0: 0 else: 255 - image.data[i] = rgbx + let + vecZero = mm256_setzero_si256() + vec255 = mm256_set1_epi8(255) + iterations = (spanEnd - i) div 8 + for _ in 0 ..< iterations: + var values = mm256_load_si256(cast[pointer](p)) + values = mm256_cmpeq_epi8(values, vecZero) + values = mm256_andnot_si256(values, vec255) + mm256_store_si256(cast[pointer](p), values) + p += 32 + i += 8 * iterations + + for i in i ..< spanEnd: + var rgbx = image.data[i] + rgbx.r = if rgbx.r == 0: 0 else: 255 + rgbx.g = if rgbx.g == 0: 0 else: 255 + rgbx.b = if rgbx.b == 0: 0 else: 255 + rgbx.a = if rgbx.a == 0: 0 else: 255 + image.data[i] = rgbx proc minifyBy2Avx2*(image: Image, power = 1): Image {.simd.} = ## Scales the image down by an integer scale. diff --git a/src/pixie/simd/neon.nim b/src/pixie/simd/neon.nim index cef069bd..96dd1719 100644 --- a/src/pixie/simd/neon.nim +++ b/src/pixie/simd/neon.nim @@ -55,6 +55,12 @@ proc fillUnsafeNeon*( data[i] = rgbx proc isOneColorNeon*(image: Image): bool {.simd.} = + # A view's pixels are not one flat run, and this answer is only ever used to + # take a shortcut, so declining for views is safe: the caller falls back to + # the general path. + if image.isView: + return false + result = true let color = image.data[0] @@ -94,6 +100,10 @@ proc isOneColorNeon*(image: Image): bool {.simd.} = return false proc isTransparentNeon*(image: Image): bool {.simd.} = + # See isOneColorNeon: a false answer for a view is conservative, not wrong. + if image.isView: + return false + var i: int p = cast[uint](image.data[0].addr) @@ -129,7 +139,7 @@ proc isOpaqueNeon*(data: ptr UncheckedArray[ColorRGBX], start, len: int): bool { var i = start - p = cast[uint](data[0].addr) + p = cast[uint](data[i].addr) # Align to 16 bytes while i < (start + len) and (p and 15) != 0: if data[i].a != 255: @@ -189,108 +199,119 @@ proc toPremultipliedAlphaNeon*(data: var seq[ColorRGBA | ColorRGBX]) {.simd.} = data[i] = c proc invertNeon*(image: Image) {.simd.} = - var - i: int - p = cast[uint](image.data[0].addr) - # Align to 16 bytes - while i < image.dataLen and (p and 15) != 0: - var rgbx = image.data[i] - rgbx.r = 255 - rgbx.r - rgbx.g = 255 - rgbx.g - rgbx.b = 255 - rgbx.b - rgbx.a = 255 - rgbx.a - image.data[i] = rgbx - inc i - p += 4 - - let - vec255 = vmovq_n_u8(255) - iterations = image.dataLen div 16 - for _ in 0 ..< iterations: - var channels = vld4q_u8(cast[pointer](p)) - channels.val[0] = vsubq_u8(vec255, channels.val[0]) - channels.val[1] = vsubq_u8(vec255, channels.val[1]) - channels.val[2] = vsubq_u8(vec255, channels.val[2]) - channels.val[3] = vsubq_u8(vec255, channels.val[3]) - vst4q_u8(cast[pointer](p), channels) - p += 64 - i += 16 * iterations + image.forEachSpan: + let spanEnd = spanStart + spanLen + + var + i = spanStart + p = cast[uint](image.data[i].addr) + # Align to 16 bytes + while i < spanEnd and (p and 15) != 0: + var rgbx = image.data[i] + rgbx.r = 255 - rgbx.r + rgbx.g = 255 - rgbx.g + rgbx.b = 255 - rgbx.b + rgbx.a = 255 - rgbx.a + image.data[i] = rgbx + inc i + p += 4 - for i in i ..< image.dataLen: - var rgbx = image.data[i] - rgbx.r = 255 - rgbx.r - rgbx.g = 255 - rgbx.g - rgbx.b = 255 - rgbx.b - rgbx.a = 255 - rgbx.a - image.data[i] = rgbx - - for i in 0 ..< image.dataLen: - var rgbx = image.data[i] - let a = rgbx.a.uint32 - if a != 255: - rgbx.r = ((rgbx.r.uint32 * a) div 255).uint8 - rgbx.g = ((rgbx.g.uint32 * a) div 255).uint8 - rgbx.b = ((rgbx.b.uint32 * a) div 255).uint8 + let + vec255 = vmovq_n_u8(255) + iterations = (spanEnd - i) div 16 + for _ in 0 ..< iterations: + var channels = vld4q_u8(cast[pointer](p)) + channels.val[0] = vsubq_u8(vec255, channels.val[0]) + channels.val[1] = vsubq_u8(vec255, channels.val[1]) + channels.val[2] = vsubq_u8(vec255, channels.val[2]) + channels.val[3] = vsubq_u8(vec255, channels.val[3]) + vst4q_u8(cast[pointer](p), channels) + p += 64 + i += 16 * iterations + + for i in i ..< spanEnd: + var rgbx = image.data[i] + rgbx.r = 255 - rgbx.r + rgbx.g = 255 - rgbx.g + rgbx.b = 255 - rgbx.b + rgbx.a = 255 - rgbx.a image.data[i] = rgbx + for i in spanStart ..< spanEnd: + var rgbx = image.data[i] + let a = rgbx.a.uint32 + if a != 255: + rgbx.r = ((rgbx.r.uint32 * a + 127) div 255).uint8 + rgbx.g = ((rgbx.g.uint32 * a + 127) div 255).uint8 + rgbx.b = ((rgbx.b.uint32 * a + 127) div 255).uint8 + image.data[i] = rgbx + proc applyOpacityNeon*(image: Image, opacity: float32) {.simd.} = let opacity = round(255 * opacity).uint8 if opacity == 255: return if opacity == 0: - fillUnsafeNeon(image.data, rgbx(0, 0, 0, 0), 0, image.dataLen) + image.forEachSpan: + fillUnsafeNeon(image.data, rgbx(0, 0, 0, 0), spanStart, spanLen) return - var - i: int - p = cast[uint](image.data[0].addr) + image.forEachSpan: + let spanEnd = spanStart + spanLen - let - opacityVec = vmov_n_u8(opacity) - iterations = image.dataLen div 8 - for _ in 0 ..< iterations: - var channels = vld4_u8(cast[pointer](p)) - channels.val[0] = multiplyDiv255(channels.val[0], opacityVec) - channels.val[1] = multiplyDiv255(channels.val[1], opacityVec) - channels.val[2] = multiplyDiv255(channels.val[2], opacityVec) - channels.val[3] = multiplyDiv255(channels.val[3], opacityVec) - vst4_u8(cast[pointer](p), channels) - p += 32 - i += 8 * iterations + var + i = spanStart + p = cast[uint](image.data[i].addr) - for i in i ..< image.dataLen: - var rgbx = image.data[i] - 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 - image.data[i] = rgbx + let + opacityVec = vmov_n_u8(opacity) + iterations = spanLen div 8 + for _ in 0 ..< iterations: + var channels = vld4_u8(cast[pointer](p)) + channels.val[0] = multiplyDiv255(channels.val[0], opacityVec) + channels.val[1] = multiplyDiv255(channels.val[1], opacityVec) + channels.val[2] = multiplyDiv255(channels.val[2], opacityVec) + channels.val[3] = multiplyDiv255(channels.val[3], opacityVec) + vst4_u8(cast[pointer](p), channels) + p += 32 + i += 8 * iterations + + # Widen before multiplying: uint8 * uint8 wraps, which zeroed the span tail. + for i in i ..< spanEnd: + var rgbx = image.data[i] + rgbx.r = ((rgbx.r.uint32 * opacity) div 255).uint8 + rgbx.g = ((rgbx.g.uint32 * opacity) div 255).uint8 + rgbx.b = ((rgbx.b.uint32 * opacity) div 255).uint8 + rgbx.a = ((rgbx.a.uint32 * opacity) div 255).uint8 + image.data[i] = rgbx proc ceilNeon*(image: Image) {.simd.} = - var - i: int - p = cast[uint](image.data[0].addr) + image.forEachSpan: + let spanEnd = spanStart + spanLen - let - zeroVec = vmovq_n_u8(0) - vec255 = vmovq_n_u8(255) - iterations = image.dataLen div 4 - for _ in 0 ..< iterations: - var values = vld1q_u8(cast[pointer](p)) - values = vceqq_u8(values, zeroVec) - values = vbicq_u8(vec255, values) - vst1q_u8(cast[pointer](p), values) - p += 16 - i += 4 * iterations + var + i = spanStart + p = cast[uint](image.data[i].addr) - for i in i ..< image.dataLen: - var rgbx = image.data[i] - rgbx.r = if rgbx.r == 0: 0 else: 255 - rgbx.g = if rgbx.g == 0: 0 else: 255 - rgbx.b = if rgbx.b == 0: 0 else: 255 - rgbx.a = if rgbx.a == 0: 0 else: 255 - image.data[i] = rgbx + let + zeroVec = vmovq_n_u8(0) + vec255 = vmovq_n_u8(255) + iterations = spanLen div 4 + for _ in 0 ..< iterations: + var values = vld1q_u8(cast[pointer](p)) + values = vceqq_u8(values, zeroVec) + values = vbicq_u8(vec255, values) + vst1q_u8(cast[pointer](p), values) + p += 16 + i += 4 * iterations + + for i in i ..< spanEnd: + var rgbx = image.data[i] + rgbx.r = if rgbx.r == 0: 0 else: 255 + rgbx.g = if rgbx.g == 0: 0 else: 255 + rgbx.b = if rgbx.b == 0: 0 else: 255 + rgbx.a = if rgbx.a == 0: 0 else: 255 + image.data[i] = rgbx proc minifyBy2Neon*(image: Image, power = 1): Image {.simd.} = ## Scales the image down by an integer scale. diff --git a/src/pixie/simd/sse2.nim b/src/pixie/simd/sse2.nim index 30546271..0185cbd1 100644 --- a/src/pixie/simd/sse2.nim +++ b/src/pixie/simd/sse2.nim @@ -74,6 +74,12 @@ proc fillUnsafeSse2*( data[i] = rgbx proc isOneColorSse2*(image: Image): bool {.simd.} = + # A view's pixels are not one flat run, and this answer is only ever used to + # take a shortcut, so declining for views is safe: the caller falls back to + # the general path. + if image.isView: + return false + result = true let color = image.data[0] @@ -82,7 +88,7 @@ proc isOneColorSse2*(image: Image): bool {.simd.} = i: int p = cast[uint](image.data[0].addr) # Align to 16 bytes - while i < image.data.len and (p and 15) != 0: + while i < image.dataLen and (p and 15) != 0: if image.data[i] != color: return false inc i @@ -90,7 +96,7 @@ proc isOneColorSse2*(image: Image): bool {.simd.} = let colorVec = mm_set1_epi32(cast[int32](color)) - iterations = (image.data.len - i) div 16 + iterations = (image.dataLen - i) div 16 for _ in 0 ..< iterations: let values0 = mm_load_si128(cast[pointer](p)) @@ -107,16 +113,20 @@ proc isOneColorSse2*(image: Image): bool {.simd.} = p += 64 i += 16 * iterations - for i in i ..< image.data.len: + for i in i ..< image.dataLen: if image.data[i] != color: return false proc isTransparentSse2*(image: Image): bool {.simd.} = + # See isOneColorSse2: a false answer for a view is conservative, not wrong. + if image.isView: + return false + var i: int p = cast[uint](image.data[0].addr) # Align to 16 bytes - while i < image.data.len and (p and 15) != 0: + while i < image.dataLen and (p and 15) != 0: if image.data[i].a != 0: return false inc i @@ -126,7 +136,7 @@ proc isTransparentSse2*(image: Image): bool {.simd.} = let vecZero = mm_setzero_si128() - iterations = (image.data.len - i) div 16 + iterations = (image.dataLen - i) div 16 for _ in 0 ..< iterations: let values0 = mm_load_si128(cast[pointer](p)) @@ -141,7 +151,7 @@ proc isTransparentSse2*(image: Image): bool {.simd.} = p += 64 i += 16 * iterations - for i in i ..< image.data.len: + for i in i ..< image.dataLen: if image.data[i].a != 0: return false @@ -150,7 +160,7 @@ proc isOpaqueSse2*(data: ptr UncheckedArray[ColorRGBX], start, len: int): bool { var i = start - p = cast[uint](data[0].addr) + p = cast[uint](data[i].addr) # Align to 16 bytes while i < (start + len) and (p and 15) != 0: if data[i].a != 255: @@ -226,146 +236,156 @@ proc toPremultipliedAlphaSse2*(data: var seq[ColorRGBA | ColorRGBX]) {.simd.} = data[i] = rgbx proc invertSse2*(image: Image) {.simd.} = - var - i: int - p = cast[uint](image.data[0].addr) - # Align to 16 bytes - while i < image.data.len and (p and 15) != 0: - var rgbx = image.data[i] - rgbx.r = 255 - rgbx.r - rgbx.g = 255 - rgbx.g - rgbx.b = 255 - rgbx.b - rgbx.a = 255 - rgbx.a - image.data[i] = rgbx - inc i - p += 4 + image.forEachSpan: + let spanEnd = spanStart + spanLen - let - vec255 = mm_set1_epi8(255) - iterations = (image.data.len - i) div 16 - for _ in 0 ..< iterations: - let - a = mm_load_si128(cast[pointer](p)) - b = mm_load_si128(cast[pointer](p + 16)) - c = mm_load_si128(cast[pointer](p + 32)) - d = mm_load_si128(cast[pointer](p + 48)) - mm_store_si128(cast[pointer](p), mm_sub_epi8(vec255, a)) - mm_store_si128(cast[pointer](p + 16), mm_sub_epi8(vec255, b)) - mm_store_si128(cast[pointer](p + 32), mm_sub_epi8(vec255, c)) - mm_store_si128(cast[pointer](p + 48), mm_sub_epi8(vec255, d)) - p += 64 - i += 16 * iterations + var + i = spanStart + p = cast[uint](image.data[i].addr) + # Align to 16 bytes + while i < spanEnd and (p and 15) != 0: + var rgbx = image.data[i] + rgbx.r = 255 - rgbx.r + rgbx.g = 255 - rgbx.g + rgbx.b = 255 - rgbx.b + rgbx.a = 255 - rgbx.a + image.data[i] = rgbx + inc i + p += 4 - for i in i ..< image.data.len: - var rgbx = image.data[i] - rgbx.r = 255 - rgbx.r - rgbx.g = 255 - rgbx.g - rgbx.b = 255 - rgbx.b - rgbx.a = 255 - rgbx.a - image.data[i] = rgbx - - for i in 0 ..< image.dataLen: - var rgbx = image.data[i] - let a = rgbx.a.uint32 - if a != 255: - rgbx.r = ((rgbx.r.uint32 * a) div 255).uint8 - rgbx.g = ((rgbx.g.uint32 * a) div 255).uint8 - rgbx.b = ((rgbx.b.uint32 * a) div 255).uint8 + let + vec255 = mm_set1_epi8(255) + iterations = (spanEnd - i) div 16 + for _ in 0 ..< iterations: + let + a = mm_load_si128(cast[pointer](p)) + b = mm_load_si128(cast[pointer](p + 16)) + c = mm_load_si128(cast[pointer](p + 32)) + d = mm_load_si128(cast[pointer](p + 48)) + mm_store_si128(cast[pointer](p), mm_sub_epi8(vec255, a)) + mm_store_si128(cast[pointer](p + 16), mm_sub_epi8(vec255, b)) + mm_store_si128(cast[pointer](p + 32), mm_sub_epi8(vec255, c)) + mm_store_si128(cast[pointer](p + 48), mm_sub_epi8(vec255, d)) + p += 64 + i += 16 * iterations + + for i in i ..< spanEnd: + var rgbx = image.data[i] + rgbx.r = 255 - rgbx.r + rgbx.g = 255 - rgbx.g + rgbx.b = 255 - rgbx.b + rgbx.a = 255 - rgbx.a image.data[i] = rgbx + for i in spanStart ..< spanEnd: + var rgbx = image.data[i] + let a = rgbx.a.uint32 + if a != 255: + rgbx.r = ((rgbx.r.uint32 * a + 127) div 255).uint8 + rgbx.g = ((rgbx.g.uint32 * a + 127) div 255).uint8 + rgbx.b = ((rgbx.b.uint32 * a + 127) div 255).uint8 + image.data[i] = rgbx + proc applyOpacitySse2*(image: Image, opacity: float32) {.simd.} = let opacity = round(255 * opacity).uint16 if opacity == 255: return if opacity == 0: - fillUnsafeSse2(image.data, rgbx(0, 0, 0, 0), 0, image.data.len) + image.forEachSpan: + fillUnsafeSse2(image.data, rgbx(0, 0, 0, 0), spanStart, spanLen) return - var - i: int - p = cast[uint](image.data[0].addr) - # Align to 16 bytes - while i < image.data.len and (p and 15) != 0: - var rgbx = image.data[i] - 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 - image.data[i] = rgbx - inc i - p += 4 + image.forEachSpan: + let spanEnd = spanStart + spanLen - let - oddMask = mm_set1_epi16(0xff00) - div255 = mm_set1_epi16(0x8081) - zeroVec = mm_setzero_si128() - opacityVec = mm_slli_epi16(mm_set1_epi16(opacity), 8) - iterations = (image.data.len - i) div 4 - for _ in 0 ..< iterations: - let values = mm_loadu_si128(cast[pointer](p)) - if mm_movemask_epi8(mm_cmpeq_epi16(values, zeroVec)) != 0xffff: - var - valuesEven = mm_slli_epi16(values, 8) - valuesOdd = mm_and_si128(values, oddMask) - valuesEven = mm_mulhi_epu16(valuesEven, opacityVec) - valuesOdd = mm_mulhi_epu16(valuesOdd, opacityVec) - valuesEven = mm_srli_epi16(mm_mulhi_epu16(valuesEven, div255), 7) - valuesOdd = mm_srli_epi16(mm_mulhi_epu16(valuesOdd, div255), 7) - mm_store_si128( - cast[pointer](p), - mm_or_si128(valuesEven, mm_slli_epi16(valuesOdd, 8)) - ) - p += 16 - i += 4 * iterations + var + i = spanStart + p = cast[uint](image.data[i].addr) + # Align to 16 bytes + while i < spanEnd and (p and 15) != 0: + var rgbx = image.data[i] + 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 + image.data[i] = rgbx + inc i + p += 4 - for i in i ..< image.data.len: - var rgbx = image.data[i] - 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 - image.data[i] = rgbx + let + oddMask = mm_set1_epi16(0xff00) + div255 = mm_set1_epi16(0x8081) + zeroVec = mm_setzero_si128() + opacityVec = mm_slli_epi16(mm_set1_epi16(opacity), 8) + iterations = (spanEnd - i) div 4 + for _ in 0 ..< iterations: + let values = mm_loadu_si128(cast[pointer](p)) + if mm_movemask_epi8(mm_cmpeq_epi16(values, zeroVec)) != 0xffff: + var + valuesEven = mm_slli_epi16(values, 8) + valuesOdd = mm_and_si128(values, oddMask) + valuesEven = mm_mulhi_epu16(valuesEven, opacityVec) + valuesOdd = mm_mulhi_epu16(valuesOdd, opacityVec) + valuesEven = mm_srli_epi16(mm_mulhi_epu16(valuesEven, div255), 7) + valuesOdd = mm_srli_epi16(mm_mulhi_epu16(valuesOdd, div255), 7) + mm_store_si128( + cast[pointer](p), + mm_or_si128(valuesEven, mm_slli_epi16(valuesOdd, 8)) + ) + p += 16 + i += 4 * iterations + + for i in i ..< spanEnd: + var rgbx = image.data[i] + 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 + image.data[i] = rgbx proc ceilSse2*(image: Image) {.simd.} = - var - i: int - p = cast[uint](image.data[0].addr) - # Align to 16 bytes - while i < image.data.len and (p and 15) != 0: - var rgbx = image.data[i] - rgbx.r = if rgbx.r == 0: 0 else: 255 - rgbx.g = if rgbx.g == 0: 0 else: 255 - rgbx.b = if rgbx.b == 0: 0 else: 255 - rgbx.a = if rgbx.a == 0: 0 else: 255 - image.data[i] = rgbx - inc i - p += 4 + image.forEachSpan: + let spanEnd = spanStart + spanLen - let - vecZero = mm_setzero_si128() - vec255 = mm_set1_epi8(255) - iterations = (image.data.len - i) div 8 - for _ in 0 ..< iterations: var - values0 = mm_loadu_si128(cast[pointer](p)) - values1 = mm_loadu_si128(cast[pointer](p + 16)) - values0 = mm_cmpeq_epi8(values0, vecZero) - values1 = mm_cmpeq_epi8(values1, vecZero) - values0 = mm_andnot_si128(values0, vec255) - values1 = mm_andnot_si128(values1, vec255) - mm_store_si128(cast[pointer](p), values0) - mm_store_si128(cast[pointer](p + 16), values1) - p += 32 - i += 8 * iterations - - for i in i ..< image.data.len: - var rgbx = image.data[i] - rgbx.r = if rgbx.r == 0: 0 else: 255 - rgbx.g = if rgbx.g == 0: 0 else: 255 - rgbx.b = if rgbx.b == 0: 0 else: 255 - rgbx.a = if rgbx.a == 0: 0 else: 255 - image.data[i] = rgbx + i = spanStart + p = cast[uint](image.data[i].addr) + # Align to 16 bytes + while i < spanEnd and (p and 15) != 0: + var rgbx = image.data[i] + rgbx.r = if rgbx.r == 0: 0 else: 255 + rgbx.g = if rgbx.g == 0: 0 else: 255 + rgbx.b = if rgbx.b == 0: 0 else: 255 + rgbx.a = if rgbx.a == 0: 0 else: 255 + image.data[i] = rgbx + inc i + p += 4 + + let + vecZero = mm_setzero_si128() + vec255 = mm_set1_epi8(255) + iterations = (spanEnd - i) div 8 + for _ in 0 ..< iterations: + var + values0 = mm_loadu_si128(cast[pointer](p)) + values1 = mm_loadu_si128(cast[pointer](p + 16)) + values0 = mm_cmpeq_epi8(values0, vecZero) + values1 = mm_cmpeq_epi8(values1, vecZero) + values0 = mm_andnot_si128(values0, vec255) + values1 = mm_andnot_si128(values1, vec255) + mm_store_si128(cast[pointer](p), values0) + mm_store_si128(cast[pointer](p + 16), values1) + p += 32 + i += 8 * iterations + + for i in i ..< spanEnd: + var rgbx = image.data[i] + rgbx.r = if rgbx.r == 0: 0 else: 255 + rgbx.g = if rgbx.g == 0: 0 else: 255 + rgbx.b = if rgbx.b == 0: 0 else: 255 + rgbx.a = if rgbx.a == 0: 0 else: 255 + image.data[i] = rgbx proc minifyBy2Sse2*(image: Image, power = 1): Image {.simd.} = ## Scales the image down by an integer scale. diff --git a/tests/bench_blends.nim b/tests/bench_blends.nim index 2826743a..2db51801 100644 --- a/tests/bench_blends.nim +++ b/tests/bench_blends.nim @@ -11,125 +11,125 @@ template reset() = reset() timeIt "blendNormal": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendNormal(backdrop.data[i], source.data[i]) reset() timeIt "blendDarken": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendDarken(backdrop.data[i], source.data[i]) reset() timeIt "blendMultiply": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendMultiply(backdrop.data[i], source.data[i]) # reset() # timeIt "blendLinearBurn": -# for i in 0 ..< backdrop.data.len: +# for i in 0 ..< backdrop.dataLen: # backdrop.data[i] = blendLinearBurn(backdrop.data[i], source.data[i]) reset() timeIt "blendColorBurn": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendColorBurn(backdrop.data[i], source.data[i]) reset() timeIt "blendLighten": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendLighten(backdrop.data[i], source.data[i]) reset() timeIt "blendScreen": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendScreen(backdrop.data[i], source.data[i]) # reset() # timeIt "blendLinearDodge": -# for i in 0 ..< backdrop.data.len: +# for i in 0 ..< backdrop.dataLen: # backdrop.data[i] = blendLinearDodge(backdrop.data[i], source.data[i]) reset() timeIt "blendColorDodge": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendColorDodge(backdrop.data[i], source.data[i]) reset() timeIt "blendOverlay": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendOverlay(backdrop.data[i], source.data[i]) reset() timeIt "blendSoftLight": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendSoftLight(backdrop.data[i], source.data[i]) reset() timeIt "blendHardLight": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendHardLight(backdrop.data[i], source.data[i]) reset() timeIt "blendDifference": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendDifference(backdrop.data[i], source.data[i]) reset() timeIt "blendExclusion": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendExclusion(backdrop.data[i], source.data[i]) reset() timeIt "blendHue": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendHue(backdrop.data[i], source.data[i]) reset() timeIt "blendSaturation": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendSaturation(backdrop.data[i], source.data[i]) reset() timeIt "blendColor": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendColor(backdrop.data[i], source.data[i]) reset() timeIt "blendLuminosity": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendLuminosity(backdrop.data[i], source.data[i]) reset() timeIt "blendMask": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendMask(backdrop.data[i], source.data[i]) reset() timeIt "blendSubtractMask": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendSubtractMask(backdrop.data[i], source.data[i]) reset() timeIt "blendExcludeMask": - for i in 0 ..< backdrop.data.len: + for i in 0 ..< backdrop.dataLen: backdrop.data[i] = blendExcludeMask(backdrop.data[i], source.data[i]) diff --git a/tests/bench_images.nim b/tests/bench_images.nim index 447ac0be..c33720ff 100644 --- a/tests/bench_images.nim +++ b/tests/bench_images.nim @@ -80,12 +80,12 @@ timeIt "applyOpacity": reset() timeIt "toPremultipliedAlpha": - image.data.toPremultipliedAlpha() + image.toPremultipliedAlpha() reset() timeIt "toStraightAlpha": - image.data.toStraightAlpha() + image.toStraightAlpha() reset() diff --git a/tests/bench_view_cost.nim b/tests/bench_view_cost.nim index f6c5aa81..3f5636a7 100644 --- a/tests/bench_view_cost.nim +++ b/tests/bench_view_cost.nim @@ -64,3 +64,19 @@ bench("subImage copy (quarter)", 200, proc () = bench("newImage quarter", 200, proc () = discard newImage(W div 2, H div 2) ) + +# The reason any of this exists: handing a caller a sub-region used to mean +# allocating and copying it. Only meaningful on the view build. +when compiles(canvas.view(0, 0, 1, 1)): + bench("view (quarter)", 200, proc () = + discard canvas.view(0, 0, W div 2, H div 2) + ) + + let cell = canvas.view(0, 0, W div 2, H div 2) + bench("fill through a view", 200, proc () = + cell.fill(rgba(10, 20, 30, 255)) + ) + + bench("draw alpha into a view", 200, proc () = + cell.draw(sprite, translate(vec2(0, 0)), NormalBlend) + ) diff --git a/tests/test_base64.nim b/tests/test_base64.nim index 9b88c74d..b3bc05e2 100644 --- a/tests/test_base64.nim +++ b/tests/test_base64.nim @@ -13,7 +13,7 @@ block: let decoded = decodeBase64(encoded) doAssert decoded.width == image.width doAssert decoded.height == image.height - doAssert decoded.data == image.data + doAssert decoded == image block: let image = newImage(1, 1) @@ -25,7 +25,7 @@ block: doAssert decoded.width == image.width doAssert decoded.height == image.height - doAssert decoded.data == image.data + doAssert decoded == image block: try: diff --git a/tests/test_bmp.nim b/tests/test_bmp.nim index 56049aaa..20c048e3 100644 --- a/tests/test_bmp.nim +++ b/tests/test_bmp.nim @@ -59,7 +59,7 @@ proc makeIndexedBmp(bits: int, pixelData: string): string = # var image2 = decodeBmp(encodeBmp(image)) # doAssert image2.width == image.width # doAssert image2.height == image.height -# doAssert image2.data == image.data +# doAssert image2 == image # block: # var image = newImage(16, 16) @@ -69,7 +69,7 @@ proc makeIndexedBmp(bits: int, pixelData: string): string = # var image2 = decodeBmp(encodeBmp(image)) # doAssert image2.width == image.width # doAssert image2.height == image.height -# doAssert image2.data == image.data +# doAssert image2 == image block: for bits in [32, 24]: @@ -109,7 +109,7 @@ block: encoded = encodeDib(image) decoded = decodeDib(encoded.cstring, encoded.len, true) - doAssert image.data == decoded.data + doAssert image == decoded block: # identity-size scaled decodes match the reference decoder exactly var files: seq[string] @@ -123,7 +123,7 @@ block: # identity-size scaled decodes match the reference decoder exactly expected = decodeBmp(data) target = newImage(expected.width, expected.height) decodeBmpScaledInto(data, target, fitStretch) - doAssert target.data == expected.data, file + doAssert target == expected, file block: # streaming sampling matches nearest-neighbour on the full decode let @@ -169,7 +169,7 @@ block: # pull sources decode identically to buffered ones for readSize in [7, 4096, 1 shl 20]: let target = newImage(tw, th) decodeBmpStreamScaledInto(sourceOf(data, readSize), data.len, target, fit) - doAssert target.data == expected.data, + doAssert target == expected, file & " " & $tw & "x" & $th & " read=" & $readSize & " fit=" & $fit # Single-byte reads and unknown totalLen on a small file @@ -181,7 +181,7 @@ block: # pull sources decode identically to buffered ones target = newImage(3, 3) decodeBmpScaledInto(data, expected, fitStretch) decodeBmpStreamScaledInto(sourceOf(data, 1), totalLen, target, fitStretch) - doAssert target.data == expected.data + doAssert target == expected # Truncated input fails instead of hanging on the exhausted source block: @@ -201,7 +201,7 @@ block: # var string scaled decodes release the source buffer let expected = decodeBmpScaled(data.cstring, data.len, 64, 48) let image = decodeBmpScaled(data, 64, 48) doAssert data.len == 0 - doAssert image.data == expected.data + doAssert image == expected block: # the format dispatchers route BMPs to the streaming decoder let @@ -209,10 +209,10 @@ block: # the format dispatchers route BMPs to the streaming decoder expected = newImage(64, 48) decodeBmpScaledInto(data, expected, fitCover) let scaled = decodeImageScaled(data, 64, 48, fitCover) - doAssert scaled.data == expected.data + doAssert scaled == expected let target = newImage(64, 48) discard decodeImageScaledInto(data, target, fitCover) - doAssert target.data == expected.data + doAssert target == expected block: # decode budget: full decodes over budget fail, streaming fits setDecodeBudgetBytes(64 * 1024) diff --git a/tests/test_images.nim b/tests/test_images.nim index 50ac13e3..e174867f 100644 --- a/tests/test_images.nim +++ b/tests/test_images.nim @@ -19,7 +19,7 @@ block: block: let image = newImage(10, 10) image.fill(rgba(255, 0, 0, 128)) - image.data.toPremultipliedAlpha() + image.toPremultipliedAlpha() doAssert image[9, 9] == rgba(128, 0, 0, 128) block: diff --git a/tests/test_jpeg.nim b/tests/test_jpeg.nim index 4901f39b..6649686a 100644 --- a/tests/test_jpeg.nim +++ b/tests/test_jpeg.nim @@ -31,4 +31,4 @@ block: mm = decodeJpeg(readFile("tests/fileformats/jpeg/masters/f" & $n & "-exif.jpg")) ii = decodeJpeg(readFile("tests/fileformats/jpeg/masters/f" & $n & "-exif-ii.jpg")) doAssert ii.width == mm.width and ii.height == mm.height - doAssert ii.data == mm.data + doAssert ii == mm diff --git a/tests/test_png.nim b/tests/test_png.nim index 88fecf29..778de5bb 100644 --- a/tests/test_png.nim +++ b/tests/test_png.nim @@ -84,7 +84,7 @@ block: # streamed scanlines keep canvas-sized decodes within tight budgets for y in 0 ..< source.height: for x in 0 ..< source.width: source.unsafe[x, y] = rgbx(uint8(x mod 256), uint8(y mod 256), uint8((x + y) mod 256), 255) - let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) + let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.dataLen * 4) # Full decode plan is pixels + fixed streaming overhead (~1.6MB); # a 2MB budget must accept it @@ -115,14 +115,14 @@ block: # streamed scaled decodes match the buffered fillImage path for y in 0 ..< source.height: for x in 0 ..< source.width: source.unsafe[x, y] = rgbx(uint8((x * 7) mod 256), uint8((y * 5) mod 256), uint8((x + y) mod 256), 255) - let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) + let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.dataLen * 4) for fit in [fitStretch, fitCover, fitContain]: for (w, h) in [(50, 40), (123, 77), (300, 90), (33, 200)]: let streamed = decodePngScaled(encoded, w, h, fit) let buffered = decodePng(encoded).convertToImage(w, h, fit) doAssert streamed.width == buffered.width doAssert streamed.height == buffered.height - for i in 0 ..< streamed.data.len: + for i in 0 ..< streamed.dataLen: doAssert streamed.data[i] == buffered.data[i], "pixel mismatch at " & $i & " fit " & $fit & " " & $w & "x" & $h @@ -171,21 +171,21 @@ block: # multi-IDAT PNGs decode without concatenating the compressed stream for y in 0 ..< source.height: for x in 0 ..< source.width: source.unsafe[x, y] = rgbx(uint8((x * 3) mod 256), uint8((y * 11) mod 256), uint8((x * y) mod 256), 255) - let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) + let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.dataLen * 4) let reference = decodePng(encoded).convertToImage() for chunkSize in [1, 2, 3, 7, 64, 8192, 1 shl 30]: let rechunked = rechunkIdat(encoded, chunkSize) let decoded = decodePng(rechunked).convertToImage() doAssert decoded.width == reference.width and decoded.height == reference.height - for i in 0 ..< decoded.data.len: + for i in 0 ..< decoded.dataLen: doAssert decoded.data[i] == reference.data[i], "pixel mismatch at " & $i & " with IDAT chunk size " & $chunkSize let target = newImage(48, 20) decodePngScaledInto(rechunked, target, fitStretch) let expected = decodePng(encoded).convertToImage(48, 20, fitStretch) - for i in 0 ..< target.data.len: + for i in 0 ..< target.dataLen: doAssert target.data[i] == expected.data[i], "scaled pixel mismatch at " & $i & " with IDAT chunk size " & $chunkSize @@ -208,7 +208,7 @@ block: # segmented sources decode identically to contiguous ones for y in 0 ..< source.height: for x in 0 ..< source.width: source.unsafe[x, y] = rgbx(uint8((x * 13) mod 256), uint8((y * 3) mod 256), uint8((x xor y) mod 256), 255) - let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) + let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.dataLen * 4) for idatChunkSize in [1, 3, 61, 8192]: let rechunked = rechunkIdat(encoded, idatChunkSize) @@ -217,7 +217,7 @@ block: # segmented sources decode identically to contiguous ones for segSizes in [@[1], @[2, 3, 5], @[7], @[100], @[64 * 1024]]: let target = newImage(40, 25) decodePngScaledInto(segmentsOf(rechunked, segSizes), target, fitStretch) - for i in 0 ..< target.data.len: + for i in 0 ..< target.dataLen: doAssert target.data[i] == expected.data[i], "segmented mismatch at " & $i & " idat=" & $idatChunkSize & " segs=" & $segSizes @@ -228,7 +228,7 @@ block: # segmented sources decode identically to contiguous ones decodePngScaledInto(original, expected, fitStretch) let target = newImage(24, 24) decodePngScaledInto(segmentsOf(original, @[5, 11]), target, fitStretch) - for i in 0 ..< target.data.len: + for i in 0 ..< target.dataLen: doAssert target.data[i] == expected.data[i], file # Corrupted files must still fail cleanly through the segmented parser @@ -260,7 +260,7 @@ block: # pull sources (file-backed) decode identically to buffered ones for y in 0 ..< source.height: for x in 0 ..< source.width: source.unsafe[x, y] = rgbx(uint8((x * 5) mod 256), uint8((y * 9) mod 256), uint8((x + 2 * y) mod 256), 255) - let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.data.len * 4) + let encoded = encodePng(source.width, source.height, 4, source.data[0].addr, source.dataLen * 4) for idatChunkSize in [1, 3, 61, 8192, 1 shl 30]: let rechunked = rechunkIdat(encoded, idatChunkSize) @@ -270,7 +270,7 @@ block: # pull sources (file-backed) decode identically to buffered ones for readSize in [1, 7, 1000, 1 shl 20]: let target = newImage(40, 25) decodePngStreamScaledInto(sourceOf(rechunked, readSize), rechunked.len, target, fit) - for i in 0 ..< target.data.len: + for i in 0 ..< target.dataLen: doAssert target.data[i] == expected.data[i], "pull mismatch at " & $i & " idat=" & $idatChunkSize & " read=" & $readSize & " fit=" & $fit @@ -282,7 +282,7 @@ block: # pull sources (file-backed) decode identically to buffered ones decodePngScaledInto(original, expected, fitStretch) let target = newImage(24, 24) decodePngStreamScaledInto(sourceOf(original, 11), original.len, target, fitStretch) - for i in 0 ..< target.data.len: + for i in 0 ..< target.dataLen: doAssert target.data[i] == expected.data[i], file # Interlaced and 16-bit PNGs cannot stream and must fail cleanly diff --git a/tests/test_ppm.nim b/tests/test_ppm.nim index cef51fea..5e0c46ac 100644 --- a/tests/test_ppm.nim +++ b/tests/test_ppm.nim @@ -49,7 +49,7 @@ block: # pull-source scaled decodes match the buffered decoder block: let target = newImage(full.width, full.height) decodePpmStreamScaledInto(sourceOf(data, 7), data.len, target, fitStretch) - doAssert target.data == full.data + doAssert target == full # Downscales match nearest-neighbour sampling of the full decode for readSize in [1, 7, 1 shl 20]: @@ -73,7 +73,7 @@ block: # pull-source scaled decodes match the buffered decoder full16 = decodePpm(data16) target = newImage(2, 2) decodePpmStreamScaledInto(sourceOf(data16, 3), data16.len, target, fitStretch) - doAssert target.data == full16.data + doAssert target == full16 # P3 (ASCII) PPMs cannot stream and must fail cleanly block: diff --git a/tests/test_qoi.nim b/tests/test_qoi.nim index 8c2e51c1..9727d48c 100644 --- a/tests/test_qoi.nim +++ b/tests/test_qoi.nim @@ -34,7 +34,7 @@ for name in tests: doAssert output.data.len == input.data.len doAssert output.colorspace == Linear if input.colorspace == Linear: - doAssert output.data == input.data + doAssert output == input else: for i, px in input.data: doAssert output.data[i] == rgba( diff --git a/tests/test_svg.nim b/tests/test_svg.nim index 9949e461..338cdb25 100644 --- a/tests/test_svg.nim +++ b/tests/test_svg.nim @@ -55,7 +55,7 @@ block: expected = newImage(parseSvg(data, 200, 200)) actual = newImage(200, 200) parseSvg(data, 200, 200).renderInto(actual) - doAssert expected.data == actual.data + doAssert expected == actual block: # On a target that already has content it must match rendering on @@ -72,7 +72,7 @@ block: var worst = 0 var differing = 0 - for i in 0 ..< fused.data.len: + for i in 0 ..< fused.dataLen: var pixelWorst = 0 pixelWorst = max(pixelWorst, abs(fused.data[i].r.int - materialized.data[i].r.int)) pixelWorst = max(pixelWorst, abs(fused.data[i].g.int - materialized.data[i].g.int)) @@ -86,5 +86,5 @@ block: # composite onto transparency does before the final blend. A few units out # of 255 is the cost of not allocating the second image. doAssert worst <= 4, &"renderInto differs from draw by {worst}" - doAssert differing * 100 div fused.data.len <= 25, - &"renderInto differs on {differing * 100 div fused.data.len}% of pixels" + doAssert differing * 100 div fused.dataLen <= 25, + &"renderInto differs on {differing * 100 div fused.dataLen}% of pixels" diff --git a/tests/validate_png.nim b/tests/validate_png.nim index e104be72..6ee6ee8a 100644 --- a/tests/validate_png.nim +++ b/tests/validate_png.nim @@ -28,5 +28,5 @@ for file in pngSuiteFiles: doAssert pixieLoaded.width == width doAssert pixieLoaded.height == height - doAssert pixieLoaded.data.len == stbiLoadedRGBA.len + doAssert pixieLoaded.dataLen == stbiLoadedRGBA.len doAssert pixieLoaded.data == stbiLoadedRGBA From e80a745595d540fde25eaf2bbf39fc2ca17236e5 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 12 Aug 2026 00:21:13 +0200 Subject: [PATCH 20/29] Name the pixel comparison pixelsEqual, not == MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Image` is a ref, so `==` already means 'the same object', and callers rely on that: 'did the producer draw straight into my canvas?' is an identity question, and answering it by comparing every pixel is both wrong and O(n). Overloading it silently changed two such checks in frameos (render/image and zoomPan) and one in a test that asserts two images are distinct objects. The proc is still worth having — `a.data == b.data` compares addresses now — it just needs a name that says what it does. --- src/pixie/images.nim | 13 +++++++++---- tests/test_base64.nim | 4 ++-- tests/test_bmp.nim | 18 +++++++++--------- tests/test_jpeg.nim | 2 +- tests/test_ppm.nim | 4 ++-- tests/test_qoi.nim | 2 +- tests/test_svg.nim | 2 +- 7 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/pixie/images.nim b/src/pixie/images.nim index 5ca88225..d598f2c0 100644 --- a/src/pixie/images.nim +++ b/src/pixie/images.nim @@ -59,12 +59,17 @@ proc fill*(image: Image, color: SomeColor) {.inline, raises: [].} = image.forEachSpan: fillUnsafe(image.data, color, spanStart, spanLen) -proc `==`*(a, b: Image): bool {.raises: [].} = +proc pixelsEqual*(a, b: Image): bool {.raises: [].} = ## Compares two images by their pixels. ## - ## Worth having explicitly now that `data` is a pointer: `a.data == b.data` - ## compares addresses, which is never what a caller means and is quietly - ## false for two images with identical contents. + ## Deliberately not `==`. `Image` is a ref, so `==` already means "the same + ## object", and callers rely on that — "did the producer draw straight into + ## my canvas?" is an identity question, and answering it by comparing every + ## pixel would be both wrong and O(n). + ## + ## This exists because `a.data == b.data` no longer compares contents either: + ## `data` is a pointer now, so that compares addresses and is quietly false + ## for two images that hold the same picture. if a.isNil or b.isNil: return a.isNil and b.isNil if a.width != b.width or a.height != b.height: diff --git a/tests/test_base64.nim b/tests/test_base64.nim index b3bc05e2..8ec7c8ad 100644 --- a/tests/test_base64.nim +++ b/tests/test_base64.nim @@ -13,7 +13,7 @@ block: let decoded = decodeBase64(encoded) doAssert decoded.width == image.width doAssert decoded.height == image.height - doAssert decoded == image + doAssert decoded.pixelsEqual(image) block: let image = newImage(1, 1) @@ -25,7 +25,7 @@ block: doAssert decoded.width == image.width doAssert decoded.height == image.height - doAssert decoded == image + doAssert decoded.pixelsEqual(image) block: try: diff --git a/tests/test_bmp.nim b/tests/test_bmp.nim index 20c048e3..607c9c22 100644 --- a/tests/test_bmp.nim +++ b/tests/test_bmp.nim @@ -59,7 +59,7 @@ proc makeIndexedBmp(bits: int, pixelData: string): string = # var image2 = decodeBmp(encodeBmp(image)) # doAssert image2.width == image.width # doAssert image2.height == image.height -# doAssert image2 == image +# doAssert image2.pixelsEqual(image) # block: # var image = newImage(16, 16) @@ -69,7 +69,7 @@ proc makeIndexedBmp(bits: int, pixelData: string): string = # var image2 = decodeBmp(encodeBmp(image)) # doAssert image2.width == image.width # doAssert image2.height == image.height -# doAssert image2 == image +# doAssert image2.pixelsEqual(image) block: for bits in [32, 24]: @@ -109,7 +109,7 @@ block: encoded = encodeDib(image) decoded = decodeDib(encoded.cstring, encoded.len, true) - doAssert image == decoded + doAssert image.pixelsEqual(decoded) block: # identity-size scaled decodes match the reference decoder exactly var files: seq[string] @@ -123,7 +123,7 @@ block: # identity-size scaled decodes match the reference decoder exactly expected = decodeBmp(data) target = newImage(expected.width, expected.height) decodeBmpScaledInto(data, target, fitStretch) - doAssert target == expected, file + doAssert target.pixelsEqual(expected), file block: # streaming sampling matches nearest-neighbour on the full decode let @@ -169,7 +169,7 @@ block: # pull sources decode identically to buffered ones for readSize in [7, 4096, 1 shl 20]: let target = newImage(tw, th) decodeBmpStreamScaledInto(sourceOf(data, readSize), data.len, target, fit) - doAssert target == expected, + doAssert target.pixelsEqual(expected), file & " " & $tw & "x" & $th & " read=" & $readSize & " fit=" & $fit # Single-byte reads and unknown totalLen on a small file @@ -181,7 +181,7 @@ block: # pull sources decode identically to buffered ones target = newImage(3, 3) decodeBmpScaledInto(data, expected, fitStretch) decodeBmpStreamScaledInto(sourceOf(data, 1), totalLen, target, fitStretch) - doAssert target == expected + doAssert target.pixelsEqual(expected) # Truncated input fails instead of hanging on the exhausted source block: @@ -201,7 +201,7 @@ block: # var string scaled decodes release the source buffer let expected = decodeBmpScaled(data.cstring, data.len, 64, 48) let image = decodeBmpScaled(data, 64, 48) doAssert data.len == 0 - doAssert image == expected + doAssert image.pixelsEqual(expected) block: # the format dispatchers route BMPs to the streaming decoder let @@ -209,10 +209,10 @@ block: # the format dispatchers route BMPs to the streaming decoder expected = newImage(64, 48) decodeBmpScaledInto(data, expected, fitCover) let scaled = decodeImageScaled(data, 64, 48, fitCover) - doAssert scaled == expected + doAssert scaled.pixelsEqual(expected) let target = newImage(64, 48) discard decodeImageScaledInto(data, target, fitCover) - doAssert target == expected + doAssert target.pixelsEqual(expected) block: # decode budget: full decodes over budget fail, streaming fits setDecodeBudgetBytes(64 * 1024) diff --git a/tests/test_jpeg.nim b/tests/test_jpeg.nim index 6649686a..d8a7f0b0 100644 --- a/tests/test_jpeg.nim +++ b/tests/test_jpeg.nim @@ -31,4 +31,4 @@ block: mm = decodeJpeg(readFile("tests/fileformats/jpeg/masters/f" & $n & "-exif.jpg")) ii = decodeJpeg(readFile("tests/fileformats/jpeg/masters/f" & $n & "-exif-ii.jpg")) doAssert ii.width == mm.width and ii.height == mm.height - doAssert ii == mm + doAssert ii.pixelsEqual(mm) diff --git a/tests/test_ppm.nim b/tests/test_ppm.nim index 5e0c46ac..890d38fd 100644 --- a/tests/test_ppm.nim +++ b/tests/test_ppm.nim @@ -49,7 +49,7 @@ block: # pull-source scaled decodes match the buffered decoder block: let target = newImage(full.width, full.height) decodePpmStreamScaledInto(sourceOf(data, 7), data.len, target, fitStretch) - doAssert target == full + doAssert target.pixelsEqual(full) # Downscales match nearest-neighbour sampling of the full decode for readSize in [1, 7, 1 shl 20]: @@ -73,7 +73,7 @@ block: # pull-source scaled decodes match the buffered decoder full16 = decodePpm(data16) target = newImage(2, 2) decodePpmStreamScaledInto(sourceOf(data16, 3), data16.len, target, fitStretch) - doAssert target == full16 + doAssert target.pixelsEqual(full16) # P3 (ASCII) PPMs cannot stream and must fail cleanly block: diff --git a/tests/test_qoi.nim b/tests/test_qoi.nim index 9727d48c..8c2e51c1 100644 --- a/tests/test_qoi.nim +++ b/tests/test_qoi.nim @@ -34,7 +34,7 @@ for name in tests: doAssert output.data.len == input.data.len doAssert output.colorspace == Linear if input.colorspace == Linear: - doAssert output == input + doAssert output.data == input.data else: for i, px in input.data: doAssert output.data[i] == rgba( diff --git a/tests/test_svg.nim b/tests/test_svg.nim index 338cdb25..9eee0173 100644 --- a/tests/test_svg.nim +++ b/tests/test_svg.nim @@ -55,7 +55,7 @@ block: expected = newImage(parseSvg(data, 200, 200)) actual = newImage(200, 200) parseSvg(data, 200, 200).renderInto(actual) - doAssert expected == actual + doAssert expected.pixelsEqual(actual) block: # On a target that already has content it must match rendering on From f80412d2473ee994ece88aa4e2fbf094240e0f33 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 12 Aug 2026 00:23:11 +0200 Subject: [PATCH 21/29] Iterating an image's pixels needs an iterator now that data is a pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `for c in image.data` worked because `data` was a seq. A pointer has no length to iterate, so `items`/`pairs` on the image replace it — and they walk row by row, which is also the only thing that is correct for a view. --- src/pixie/images.nim | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/pixie/images.nim b/src/pixie/images.nim index d598f2c0..148b5cf6 100644 --- a/src/pixie/images.nim +++ b/src/pixie/images.nim @@ -59,6 +59,25 @@ proc fill*(image: Image, color: SomeColor) {.inline, raises: [].} = image.forEachSpan: fillUnsafe(image.data, color, spanStart, spanLen) +iterator items*(image: Image): ColorRGBX = + ## Every pixel, left to right then top to bottom. + ## + ## `for c in image.data` used to work because `data` was a seq; it is a + ## pointer now, and a pointer has no length to iterate. This is the + ## replacement, and it is correct for a view — where walking the buffer flat + ## would have wandered into the neighbouring rows. + for y in 0 ..< image.height: + for x in 0 ..< image.width: + yield image.unsafe[x, y] + +iterator pairs*(image: Image): (int, ColorRGBX) = + ## Every pixel with its index in this image, not in the buffer behind it. + var i = 0 + for y in 0 ..< image.height: + for x in 0 ..< image.width: + yield (i, image.unsafe[x, y]) + inc i + proc pixelsEqual*(a, b: Image): bool {.raises: [].} = ## Compares two images by their pixels. ## From 76c0ca0cee866082bebbeaf785970484ee7c0f74 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 12 Aug 2026 13:57:54 +0200 Subject: [PATCH 22/29] Count the upsample peak and the output image in the JPEG decode plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SOF plan check existed so oversized decodes fail with a catchable budget error before any image-sized allocation. Two allocations escaped it: the reconstruction path magnifies subsampled channels to full stride resolution (with the half-size source still alive during the last doubling), and buildImage allocates the output image next to everything the plan approved. On a fragmented embedded heap that is a plan that "fits the budget" followed by an allocation that aborts the render. Masks in the non-scaled path are now accounted at their upsample peak, and both buildImage variants check plan-plus-output before newImage — the *Into variants stay unchecked on the output, their target is the caller's to have afforded. tests/test_jpeg.nim pins all three edges: the accounted total covers the chroma peak, buffers-fit-output-doesn't refuses catchably naming the output, and honest headroom decodes. Co-Authored-By: Claude Fable 5 --- src/pixie/fileformats/jpeg.nim | 43 ++++++++++++++++++++++++++++--- tests/test_jpeg.nim | 47 +++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/pixie/fileformats/jpeg.nim b/src/pixie/fileformats/jpeg.nim index 881258d9..2f13ebf9 100644 --- a/src/pixie/fileformats/jpeg.nim +++ b/src/pixie/fileformats/jpeg.nim @@ -104,6 +104,8 @@ type streamBaselineBlocks: bool scaledTargetWidth, scaledTargetHeight: int scaledFit: ScaledDecodeFit + plannedDecodeBytes: int64 ## what the SOF plan check accounted for, so the + ## build step can budget its output image on top Mask = ref object ## Mask object that holds mask opacity data. @@ -598,7 +600,24 @@ proc decodeSOF0(state: var DecoderState) = blockBytes = if streamsBlocks: 0'i64 else: blockColumns.int64 * blockRows.int64 * 64'i64 * sizeof(int16).int64 - maskBytes = channelWidth.int64 * channelHeight.int64 + maskBytes = + if state.useScaledChannels(): + channelWidth.int64 * channelHeight.int64 + else: + # The reconstruction path magnifies every subsampled channel up to + # full stride resolution (magnifyXBy2/magnifyYBy2), and during the + # last doubling the half-size source is still alive next to its + # result. Count that peak, not the subsampled plane the decode + # starts with — under-counting here is a plan that "fits the + # budget" and then dies on the upsample allocations. + let fullBytes = + (state.numMcuWide * state.maxYScale * 8).int64 * + (state.numMcuHigh * state.maxXScale * 8).int64 + if component.yScale == state.maxYScale and + component.xScale == state.maxXScale: + fullBytes + else: + fullBytes + fullBytes div 2 totalBlockBytes += blockBytes totalMaskBytes += maskBytes @@ -615,11 +634,12 @@ proc decodeSOF0(state: var DecoderState) = # Check the whole decode plan against the memory budget before any # image-sized allocation happens, so oversized inputs fail with a # catchable error instead of exhausting memory. - if overDecodeBudget(totalBlockBytes + totalMaskBytes): + state.plannedDecodeBytes = totalBlockBytes + totalMaskBytes + if overDecodeBudget(state.plannedDecodeBytes): failInvalid( "JPEG decode of " & $state.imageWidth & "x" & $state.imageHeight & (if state.progressive: " (progressive)" else: "") & - " needs " & $((totalBlockBytes + totalMaskBytes) div 1024) & + " needs " & $(state.plannedDecodeBytes div 1024) & "K of decode buffers, over the " & $(decodeBudgetBytes() div 1024) & "K memory budget" ) @@ -1642,14 +1662,31 @@ proc fillImage(state: var DecoderState, result: Image) = else: failInvalid() +proc ensureOutputBudget(state: DecoderState, width, height: int) = + ## The SOF plan check covers the decode buffers; the output image the + ## build step is about to allocate lives NEXT TO them, and an output the + ## budget cannot carry deserves the same catchable refusal — not an + ## allocation attempt that exhausts a fragmented heap. The *Into variants + ## never come here: their target is the caller's to have afforded. + let outputBytes = width.int64 * height.int64 * 4 + if overDecodeBudget(state.plannedDecodeBytes + outputBytes): + failInvalid( + "JPEG output of " & $width & "x" & $height & " needs " & + $(outputBytes div 1024) & "K next to " & + $(state.plannedDecodeBytes div 1024) & "K of decode buffers, over the " & + $(decodeBudgetBytes() div 1024) & "K memory budget" + ) + proc buildImage(state: var DecoderState, targetWidth, targetHeight: int): Image = ## Takes a jpeg image object and builds a target-sized pixie Image from it. + state.ensureOutputBudget(targetWidth, targetHeight) result = newImage(targetWidth, targetHeight) state.fillImage(result) proc buildImage(state: var DecoderState): Image = ## Takes a jpeg image object and builds a pixie Image from it. + state.ensureOutputBudget(state.imageWidth, state.imageHeight) result = newImage(state.imageWidth, state.imageHeight) case state.components.len: diff --git a/tests/test_jpeg.nim b/tests/test_jpeg.nim index d8a7f0b0..c0d62c0f 100644 --- a/tests/test_jpeg.nim +++ b/tests/test_jpeg.nim @@ -1,4 +1,5 @@ -import jpegsuite, pixie, pixie/fileformats/jpeg +import std/strutils +import jpegsuite, pixie, pixie/fileformats/jpeg, pixie/decodebudget for file in jpegSuiteFiles: let @@ -32,3 +33,47 @@ block: ii = decodeJpeg(readFile("tests/fileformats/jpeg/masters/f" & $n & "-exif-ii.jpg")) doAssert ii.width == mm.width and ii.height == mm.height doAssert ii.pixelsEqual(mm) + +block: + # The decode plan must count what the decode actually allocates. Two things + # historically escaped it: the reconstruction path magnifies subsampled + # channels up to full stride resolution (with the half-size source still + # alive during the last doubling), and buildImage allocates the output image + # next to everything the plan approved. A plan that under-counts is a check + # that passes and an allocation that dies on a fragmented embedded heap. + let budgetData = readFile("tests/fileformats/jpeg/masters/cat_4_2_0.jpg") + let budgetDims = decodeJpegDimensions(budgetData) + + # 1) The refusal is catchable and its accounted total covers the + # full-resolution upsample peak, not the subsampled starting planes. + setDecodeBudgetBytes(1) + var plannedK = 0 + try: + discard decodeJpeg(budgetData) + doAssert false, "a 1-byte budget cannot admit any decode plan" + except PixieError as e: + doAssert "memory budget" in e.msg, e.msg + let needsAt = e.msg.find("needs ") + 6 + let kAt = e.msg.find("K of decode") + plannedK = parseInt(e.msg[needsAt ..< kAt]) + let + strideW = ((budgetDims.width + 15) div 16) * 16 + strideH = ((budgetDims.height + 15) div 16) * 16 + fullMask = strideW * strideH + doAssert plannedK * 1024 >= fullMask + 2 * (fullMask + fullMask div 2), + "the plan no longer counts the chroma upsample peak: " & $plannedK & "K" + + # 2) Buffers fit, the output image does not: still a catchable refusal, + # naming the output. + setDecodeBudgetBytes(plannedK * 1024 + 2048) + try: + discard decodeJpeg(budgetData) + doAssert false, "the output image cannot fit in 2K of headroom" + except PixieError as e: + doAssert "JPEG output of" in e.msg and "memory budget" in e.msg, e.msg + + # 3) Honest headroom decodes. + setDecodeBudgetBytes(plannedK * 1024 + + budgetDims.width * budgetDims.height * 4 + 1024 * 1024) + doAssert decodeJpeg(budgetData).width == budgetDims.width + setDecodeBudgetBytes(0) From f7699d439ed54ea563b690c5441ca06f6ec056b6 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 12 Aug 2026 18:00:30 +0200 Subject: [PATCH 23/29] WebP joins the memory-aware decode family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decodeWebpScaledInto samples straight from the decoder's own buffers into the fitted target rect — YUV planes for lossy, the ARGB buffer VP8L cannot avoid for lossless — so the full-size RGBA intermediate never exists. The chroma upsampling is factored into per-row/per-pixel helpers shared with the buffered path; a fingerprint sweep over all 129 suite files pins the refactor byte-identical, and the new tests pin native-size scaled decode pixel-identical to decodeWebp. decodeWebpStreamScaledInto decodes a spilled body from a pull source. Unlike PNG or baseline JPEG, a WebP bitstream cannot be windowed — VP8 interleaves macroblock rows across coefficient partitions and VP8L's LZ77 window is the whole image — so the honest tier is "compressed body resident, full-size RGBA never", with the body budget-checked before it is pulled back. Every entry point now checks its decode plan (padded YUV planes, per-macroblock state, alpha buffers, RGBA outputs) against the memory budget first, so an oversized WebP refuses catchably instead of dying in an allocation. decodeImageScaled/Into dispatch WebP alongside PNG, JPEG and BMP. Co-Authored-By: Claude Fable 5 --- src/pixie.nim | 12 ++ src/pixie/fileformats/webp.nim | 319 +++++++++++++++++++++++++++------ tests/test_webp.nim | 102 ++++++++++- 3 files changed, 373 insertions(+), 60 deletions(-) diff --git a/src/pixie.nim b/src/pixie.nim index 9f5af235..6ec17e7f 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -101,6 +101,10 @@ proc copyIntoTarget(target, source: Image) {.raises: [PixieError].} = target.width * sizeof(ColorRGBX) ) +template isWebpData(data: string): bool = + data.len > 12 and data.readStr(0, 4) == WebpRiffSignature and + data.readStr(8, 4) == WebpSignature + proc decodeImageScaled*( data: string, width, height: int, fit = fitStretch ): Image {.raises: [PixieError].} @@ -141,6 +145,8 @@ proc decodeImageScaled*( decodeJpegScaled(data, width, height, fit) elif data.len > 2 and data.readStr(0, 2) == bmpSignature: decodeBmpScaled(data, width, height, fit) + elif data.isWebpData: + decodeWebpScaled(data, width, height, fit) else: let image = decodeImage(data) if image.width == width and image.height == height: @@ -161,6 +167,8 @@ proc decodeImageScaled*( decodeJpegScaled(data, width, height, fit) elif data.len > 2 and data.readStr(0, 2) == bmpSignature: decodeBmpScaled(data, width, height, fit) + elif data.isWebpData: + decodeWebpScaled(data, width, height, fit) else: let image = decodeImage(data) data = "" @@ -202,6 +210,8 @@ proc decodeImageScaledInto*( decodeJpegScaledInto(data, target, fit) elif data.len > 2 and data.readStr(0, 2) == bmpSignature: decodeBmpScaledInto(data, target, fit) + elif data.isWebpData: + discard decodeWebpScaledInto(data, target, fit) else: target.copyIntoTarget(decodeImageScaled(data, target.width, target.height)) target @@ -217,6 +227,8 @@ proc decodeImageScaledInto*( decodeJpegScaledInto(data, target, fit) elif data.len > 2 and data.readStr(0, 2) == bmpSignature: decodeBmpScaledInto(data, target, fit) + elif data.isWebpData: + discard decodeWebpScaledInto(data, target, fit) else: target.copyIntoTarget(decodeImageScaled(data, target.width, target.height)) target diff --git a/src/pixie/fileformats/webp.nim b/src/pixie/fileformats/webp.nim index 10059903..808a0780 100644 --- a/src/pixie/fileformats/webp.nim +++ b/src/pixie/fileformats/webp.nim @@ -1,4 +1,4 @@ -import chroma, flatty/binny, webp_vp8_tables, ../common, ../images +import chroma, flatty/binny, webp_vp8_tables, ../common, ../decodebudget, ../images # WebP is a RIFF container around VP8 or VP8L image data. # See: https://developers.google.com/speed/webp/docs/riff_container @@ -2458,72 +2458,92 @@ proc getFancyChroma( ((9 * main.uint16 + 3 * secondary1.uint16 + 3 * secondary2.uint16 + tertiary.uint16 + 8) div 16).uint8 -proc frameToRgbaBytes(frame: Vp8Frame): seq[uint8] = - result = newSeq[uint8](frame.width * frame.height * 4) +type ChromaRow = object + ## Per-scanline chroma sampling context: which two subsampled chroma rows + ## the fancy upsampler blends for a given luma row. Factored out of the + ## full-frame conversion so a scaled decode can convert any single (x, y) + ## without materializing the full-size RGBA image. + mainBase, otherBase: int + topOnly: bool + +proc chromaRow(frame: Vp8Frame, y: int): ChromaRow = let lumaWidth = ((frame.width + 15) div 16) * 16 chromaWidth = lumaWidth div 2 - chromaPixelWidth = (frame.width + 1) div 2 chromaPixelHeight = (frame.height + 1) div 2 - for y in 0 ..< frame.height: - let - topOnly = y == 0 or (y == frame.height - 1 and (frame.height and 1) == 0) - mainRow = - if y == 0: 0 - elif topOnly: chromaPixelHeight - 1 - else: y div 2 - otherRow = - if topOnly: - mainRow - elif (y and 1) != 0: - min(chromaPixelHeight - 1, mainRow + 1) - else: - max(0, mainRow - 1) - mainBase = mainRow * chromaWidth - otherBase = otherRow * chromaWidth - for x in 0 ..< frame.width: - var - uValue: uint8 - vValue: uint8 - if x == 0: - if topOnly: - uValue = frame.ubuf[mainBase] - vValue = frame.vbuf[mainBase] - else: - uValue = getFancyChroma( - frame.ubuf[mainBase], frame.ubuf[mainBase], - frame.ubuf[otherBase], frame.ubuf[otherBase] - ) - vValue = getFancyChroma( - frame.vbuf[mainBase], frame.vbuf[mainBase], - frame.vbuf[otherBase], frame.vbuf[otherBase] - ) - elif (x and 1) != 0: - let - col = (x - 1) div 2 - nextCol = min(chromaPixelWidth - 1, col + 1) - uValue = getFancyChroma( - frame.ubuf[mainBase + col], frame.ubuf[mainBase + nextCol], - frame.ubuf[otherBase + col], frame.ubuf[otherBase + nextCol] - ) - vValue = getFancyChroma( - frame.vbuf[mainBase + col], frame.vbuf[mainBase + nextCol], - frame.vbuf[otherBase + col], frame.vbuf[otherBase + nextCol] - ) + topOnly = y == 0 or (y == frame.height - 1 and (frame.height and 1) == 0) + mainRow = + if y == 0: 0 + elif topOnly: chromaPixelHeight - 1 + else: y div 2 + otherRow = + if topOnly: + mainRow + elif (y and 1) != 0: + min(chromaPixelHeight - 1, mainRow + 1) else: - let - col = x div 2 - prevCol = max(0, col - 1) - uValue = getFancyChroma( - frame.ubuf[mainBase + col], frame.ubuf[mainBase + prevCol], - frame.ubuf[otherBase + col], frame.ubuf[otherBase + prevCol] - ) - vValue = getFancyChroma( - frame.vbuf[mainBase + col], frame.vbuf[mainBase + prevCol], - frame.vbuf[otherBase + col], frame.vbuf[otherBase + prevCol] + max(0, mainRow - 1) + ChromaRow( + mainBase: mainRow * chromaWidth, + otherBase: otherRow * chromaWidth, + topOnly: topOnly + ) + +proc chromaAt( + frame: Vp8Frame, row: ChromaRow, x: int +): tuple[u, v: uint8] {.inline.} = + let chromaPixelWidth = (frame.width + 1) div 2 + if x == 0: + if row.topOnly: + (frame.ubuf[row.mainBase], frame.vbuf[row.mainBase]) + else: + ( + getFancyChroma( + frame.ubuf[row.mainBase], frame.ubuf[row.mainBase], + frame.ubuf[row.otherBase], frame.ubuf[row.otherBase] + ), + getFancyChroma( + frame.vbuf[row.mainBase], frame.vbuf[row.mainBase], + frame.vbuf[row.otherBase], frame.vbuf[row.otherBase] ) + ) + elif (x and 1) != 0: + let + col = (x - 1) div 2 + nextCol = min(chromaPixelWidth - 1, col + 1) + ( + getFancyChroma( + frame.ubuf[row.mainBase + col], frame.ubuf[row.mainBase + nextCol], + frame.ubuf[row.otherBase + col], frame.ubuf[row.otherBase + nextCol] + ), + getFancyChroma( + frame.vbuf[row.mainBase + col], frame.vbuf[row.mainBase + nextCol], + frame.vbuf[row.otherBase + col], frame.vbuf[row.otherBase + nextCol] + ) + ) + else: + let + col = x div 2 + prevCol = max(0, col - 1) + ( + getFancyChroma( + frame.ubuf[row.mainBase + col], frame.ubuf[row.mainBase + prevCol], + frame.ubuf[row.otherBase + col], frame.ubuf[row.otherBase + prevCol] + ), + getFancyChroma( + frame.vbuf[row.mainBase + col], frame.vbuf[row.mainBase + prevCol], + frame.vbuf[row.otherBase + col], frame.vbuf[row.otherBase + prevCol] + ) + ) +proc frameToRgbaBytes(frame: Vp8Frame): seq[uint8] = + result = newSeq[uint8](frame.width * frame.height * 4) + let lumaWidth = ((frame.width + 15) div 16) * 16 + for y in 0 ..< frame.height: + let row = frame.chromaRow(y) + for x in 0 ..< frame.width: let + (uValue, vValue) = frame.chromaAt(row, x) yValue = frame.ybuf[y * lumaWidth + x] dst = (y * frame.width + x) * 4 result[dst + 0] = yuvToR(yValue, vValue) @@ -2818,9 +2838,55 @@ proc decodeWebpDimensions*( result.width = info.width result.height = info.height +proc webpDecodePlanBytes(info: WebpInfo): int64 = + ## Bytes of decode intermediates this image will allocate, for the memory + ## budget check. Lossy WebP holds YUV planes padded to whole macroblocks + ## plus per-macroblock state, and possibly an alpha plane (itself + ## VP8L-decoded through a full ARGB buffer when losslessly compressed). + ## Lossless WebP inherently holds the whole decoded ARGB buffer: its LZ77 + ## back-references reach across the entire image, so it cannot be windowed. + ## Huffman tables, transform side data and borders are noise next to these. + let + wh = info.width.int64 * info.height.int64 + mbWidth = ((info.width + 15) div 16).int64 + mbHeight = ((info.height + 15) div 16).int64 + case info.compression + of LossyWebp: + let + lumaWidth = mbWidth * 16 + chromaWidth = mbWidth * 8 + result = lumaWidth * mbHeight * 16 + + 2 * chromaWidth * mbHeight * 8 + + mbWidth * mbHeight * sizeof(Vp8MacroBlock).int64 + if info.hasAlpha: + result += wh # the assembled alpha plane + if info.alphaInfo.compressionMethod == 1: + result += wh * 5 # lossless alpha: ARGB decode buffer + residual + else: + result += wh # raw residual + of LosslessWebp: + result = wh * 4 + of UnknownWebpCompression: + result = 0 + +proc checkWebpDecodeBudget(info: WebpInfo, extraBytes: int64) = + ## `extraBytes` carries what the caller keeps alive alongside the decode: + ## the compressed body, and any full-size RGBA output it is about to build. + let planBytes = webpDecodePlanBytes(info) + extraBytes + if overDecodeBudget(planBytes): + raise newException(PixieError, + "WebP decode of " & $info.width & "x" & $info.height & " needs " & + $(planBytes div 1024) & "K of decode buffers, over the " & + $(decodeBudgetBytes() div 1024) & "K memory budget" + ) + proc decodeWebp*(data: string): Image {.raises: [PixieError].} = ## Decodes a WebP image. let info = decodeWebpInfo(data) + # The buffered path builds the RGBA byte seq and then the Image, and both + # are alive at once, on top of the compressed body. + checkWebpDecodeBudget(info, + data.len.int64 + info.width.int64 * info.height.int64 * 8) case info.compression of LosslessWebp: var rgbaData = decodeLosslessData( @@ -2844,6 +2910,141 @@ proc decodeWebp*(data: string): Image {.raises: [PixieError].} = of UnknownWebpCompression: raise newException(PixieError, "Invalid WebP, animation decoding is not implemented") +proc frameToTargetRect( + frame: Vp8Frame, alpha: seq[uint8], target: Image, fit: ScaledDecodeFit +) = + ## Converts the fitted rect of `target` straight from the YUV planes with + ## nearest sampling; the full-size RGBA image never exists. Pixels outside + ## the fitted rect are left untouched, matching every other scaled-into + ## decoder (a contain fit's margins belong to the caller). + let + rects = scaledFitRects( + frame.width, frame.height, target.width, target.height, fit + ) + lumaWidth = ((frame.width + 15) div 16) * 16 + for dstY in rects.dstY ..< rects.dstY + rects.dstH: + let + srcY = min( + rects.srcY + ((dstY - rects.dstY) * rects.srcH) div rects.dstH, + frame.height - 1 + ) + row = frame.chromaRow(srcY) + for dstX in rects.dstX ..< rects.dstX + rects.dstW: + let + srcX = min( + rects.srcX + ((dstX - rects.dstX) * rects.srcW) div rects.dstW, + frame.width - 1 + ) + (uValue, vValue) = frame.chromaAt(row, srcX) + yValue = frame.ybuf[srcY * lumaWidth + srcX] + alphaValue = + if alpha.len > 0: alpha[srcY * frame.width + srcX] else: 255'u8 + target.data[target.dataIndex(dstX, dstY)] = rgba( + yuvToR(yValue, vValue), + yuvToG(yValue, uValue, vValue), + yuvToB(yValue, uValue), + alphaValue + ).rgbx() + +proc rgbaBytesToTargetRect( + rgbaData: seq[uint8], width, height: int, forceOpaque: bool, + target: Image, fit: ScaledDecodeFit +) = + ## The lossless twin: samples the decoded ARGB buffer into the fitted rect. + ## `forceOpaque` stands in for the buffered path's whole-buffer alpha + ## rewrite when the stream declares no alpha. + let rects = scaledFitRects(width, height, target.width, target.height, fit) + for dstY in rects.dstY ..< rects.dstY + rects.dstH: + let srcY = min( + rects.srcY + ((dstY - rects.dstY) * rects.srcH) div rects.dstH, + height - 1 + ) + for dstX in rects.dstX ..< rects.dstX + rects.dstW: + let + srcX = min( + rects.srcX + ((dstX - rects.dstX) * rects.srcW) div rects.dstW, + width - 1 + ) + src = (srcY * width + srcX) * 4 + target.data[target.dataIndex(dstX, dstY)] = rgba( + rgbaData[src + 0], + rgbaData[src + 1], + rgbaData[src + 2], + if forceOpaque: 255'u8 else: rgbaData[src + 3] + ).rgbx() + +proc decodeWebpScaledInto*( + data: string, target: Image, fit = fitStretch +): Image {.raises: [PixieError].} = + ## Decodes a WebP scaled into an existing target image, writing only the + ## fitted rect. The full-size RGBA intermediate of the buffered path never + ## exists: lossy WebP converts straight from its YUV planes, lossless + ## samples from the decoded buffer its format cannot avoid. What the decode + ## *will* allocate is checked against the memory budget first, so an image + ## too big to decode refuses catchably instead of dying in an allocation. + if target.isNil or target.width <= 0 or target.height <= 0: + raise newException(PixieError, "Invalid target image") + let info = decodeWebpInfo(data) + case info.compression + of LosslessWebp: + checkWebpDecodeBudget(info, data.len.int64) + let rgbaData = decodeLosslessData( + data, info.vp8LOffset, info.vp8LSize, info.width, info.height, false + ) + rgbaBytesToTargetRect( + rgbaData, info.width, info.height, not info.losslessAlpha, target, fit + ) + of LossyWebp: + checkWebpDecodeBudget(info, data.len.int64) + let frame = decodeVp8Frame( + data, info.vp8Offset, info.vp8Size, info.width, info.height + ) + var alpha: seq[uint8] + if info.hasAlpha: + if info.alphaOffset == 0: + failInvalid("missing ALPH chunk") + alpha = decodeAlphaData(data, info) + frameToTargetRect(frame, alpha, target, fit) + of UnknownWebpCompression: + raise newException(PixieError, "Invalid WebP, animation decoding is not implemented") + target + +proc decodeWebpScaled*( + data: string, width, height: int, fit = fitStretch +): Image {.raises: [PixieError].} = + ## Decodes a WebP scaled to the requested dimensions. + if width <= 0 or height <= 0: + raise newException(PixieError, "Image width and height must be > 0") + result = newImage(width, height) + discard decodeWebpScaledInto(data, result, fit) + +proc decodeWebpStreamScaledInto*( + source: ImageSourceProc, totalLen: int, target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## Decodes a WebP from a sequential pull source (a spilled download on + ## disk) scaled into the target. Unlike PNG or baseline JPEG, a WebP + ## bitstream cannot be decoded through a fixed window: VP8 interleaves + ## macroblock rows across its coefficient partitions, and VP8L's LZ77 + ## window is the whole image. The honest tier is therefore "compressed + ## body resident, full-size RGBA never" — the body is budget-checked + ## before it is pulled back, and the decode plan is checked again once + ## the header says what the pixels will cost. + if totalLen <= 0: + failInvalid("empty WebP stream") + if overDecodeBudget(totalLen.int64): + raise newException(PixieError, + "WebP stream of " & $(totalLen div 1024) & "K is over the " & + $(decodeBudgetBytes() div 1024) & "K memory budget" + ) + var data = newString(totalLen) + var pos = 0 + while pos < totalLen: + let got = source(addr data[pos], totalLen - pos) + if got <= 0: + failInvalid("truncated WebP stream") + pos += got + discard decodeWebpScaledInto(data, target, fit) + {.pop.} when defined(release): diff --git a/tests/test_webp.nim b/tests/test_webp.nim index 0fd110f5..8190565d 100644 --- a/tests/test_webp.nim +++ b/tests/test_webp.nim @@ -1,4 +1,4 @@ -import pixie, pixie/fileformats/webp, webpsuite +import pixie, pixie/decodebudget, pixie/fileformats/webp, webpsuite proc checkDimensions(path: string, width, height: int) = let @@ -95,3 +95,103 @@ block: ) doAssert rawAlpha.hasAlpha doAssert rawAlpha.alphaInfo.compressionMethod == 0 + +proc chunkedSource(data: string, chunkSize: int): ImageSourceProc = + ## A pull source that drips the body in small chunks, like a spilled file + ## read back from storage. + var pos = 0 + result = proc(dst: pointer, maxBytes: int): int {.gcsafe, raises: [].} = + let n = min(min(maxBytes, chunkSize), data.len - pos) + if n > 0: + copyMem(dst, data[pos].unsafeAddr, n) + pos += n + n + +block: + # The scaled-into family against the buffered decoder, over the whole + # suite. At native size the sampling is the identity, so the fitted decode + # must be pixel-identical to decodeWebp — same YUV conversion, same alpha, + # same premultiplication. The pull-source variant reads the same bytes + # through a drip-fed callback and must land on the same pixels. + for path in WebpSuiteFiles: + let data = readFile(path) + if decodeWebpInfo(data).compression == UnknownWebpCompression: + continue + let + reference = decodeWebp(data) + same = newImage(reference.width, reference.height) + discard decodeWebpScaledInto(data, same, fitStretch) + doAssert same.pixelsEqual(reference), path + + let streamed = newImage(reference.width, reference.height) + decodeWebpStreamScaledInto( + chunkedSource(data, 977), data.len, streamed, fitStretch + ) + doAssert streamed.pixelsEqual(reference), path + +block: + # Downscale sampling against a hand-rolled nearest reference over the + # buffered decode, for every fit. The reference samples the decoded image; + # the scaled path samples YUV planes (lossy) or the ARGB buffer (lossless) + # directly — agreement means the per-pixel conversion matches at sampled + # coordinates, not just over full rows. + for path in [ + "tests/fileformats/webp/test.webp", # lossy + "tests/fileformats/webp/lossless1.webp", # lossless, odd size + "tests/fileformats/webp/lossy_alpha1.webp", # lossy + alpha + "tests/fileformats/webp/small_31x13.webp" # tiny, odd size + ]: + let + data = readFile(path) + reference = decodeWebp(data) + for fit in [fitStretch, fitCover, fitContain]: + let + targetWidth = max(1, reference.width div 3) + targetHeight = max(1, reference.height div 2) + scaled = decodeWebpScaled(data, targetWidth, targetHeight, fit) + rects = scaledFitRects( + reference.width, reference.height, targetWidth, targetHeight, fit + ) + for dstY in rects.dstY ..< rects.dstY + rects.dstH: + let srcY = min( + rects.srcY + ((dstY - rects.dstY) * rects.srcH) div rects.dstH, + reference.height - 1 + ) + for dstX in rects.dstX ..< rects.dstX + rects.dstW: + let srcX = min( + rects.srcX + ((dstX - rects.dstX) * rects.srcW) div rects.dstW, + reference.width - 1 + ) + doAssert scaled.data[scaled.dataIndex(dstX, dstY)] == + reference.data[reference.dataIndex(srcX, srcY)], + path & " " & $fit & " at " & $dstX & "," & $dstY + +block: + # A contain fit writes only the fitted rect; the margins belong to the + # caller (they may be the live canvas of a render in progress). + let + data = readFile("tests/fileformats/webp/test.webp") # 128x128 + sentinel = rgbx(1, 2, 3, 255) + target = newImage(200, 100) + target.fill(sentinel) + discard decodeWebpScaledInto(data, target, fitContain) + # 128x128 into 200x100 contain -> a 100x100 rect centered horizontally. + doAssert target.data[target.dataIndex(0, 50)] == sentinel + doAssert target.data[target.dataIndex(199, 50)] == sentinel + doAssert target.data[target.dataIndex(100, 50)] != sentinel + +block: + # An over-budget decode refuses catchably before allocating, in every + # entry point, and the stream variant refuses before buffering the body. + let data = readFile("tests/fileformats/webp/lossless1.webp") + setDecodeBudgetBytes(1024) + doAssertRaises(PixieError): + discard decodeWebp(data) + doAssertRaises(PixieError): + discard decodeWebpScaled(data, 10, 10) + doAssertRaises(PixieError): + decodeWebpStreamScaledInto( + chunkedSource(data, 977), data.len, newImage(10, 10) + ) + setDecodeBudgetBytes(0) + doAssert decodeWebp(data).width == 1000 From 1176f446dd39aed938f2c2e426ac6233a0498468 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 12 Aug 2026 19:38:05 +0200 Subject: [PATCH 24/29] Scaled decodes get a box filter; the sample walk stops wobbling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-integer downscales through the scaled JPEG decode were nearest decimation, and worse: fillImage walked target -> image -> sample, and the double floor division duplicated some sample columns and skipped others. Fine texture (a 960px Wikimedia thumb covered onto an 800px panel) came out visibly rough — the trigger the quality-scaler gate was waiting for. Two fixes. idctBlockScaled now routes blocks through a full-width band that drains row by row into accumulators: every target pixel becomes the rounded average of its exact source footprint. Footprints tile the source, partial sums survive band boundaries, upscale axes keep nearest (boxes would leave holes), and 1:1 reduces to the identity — pinned byte-identical across the whole masters corpus, upscales included. And fillImage now decides geometry in image space but walks the sample grid directly, one floor mapping, no round trip. The WebP samplers get the same box treatment (premultiplied-domain averaging, verified exact against an independent reference), and the band and accumulator bytes are counted in the JPEG decode plan. Peak memory stays a band plus two target-width rows. Co-Authored-By: Claude Fable 5 --- src/pixie/fileformats/jpeg.nim | 251 ++++++++++++++++++++++++++++----- src/pixie/fileformats/webp.nim | 130 +++++++++++------ tests/test_jpeg.nim | 68 +++++++++ tests/test_webp.nim | 50 +++++-- 4 files changed, 398 insertions(+), 101 deletions(-) diff --git a/src/pixie/fileformats/jpeg.nim b/src/pixie/fileformats/jpeg.nim index 2f13ebf9..531103e6 100644 --- a/src/pixie/fileformats/jpeg.nim +++ b/src/pixie/fileformats/jpeg.nim @@ -68,6 +68,21 @@ type coeff, lineBuf: seq[uint8] blocks: seq[seq[array[64, int16]]] channel: Mask + # Scaled-decode box sampler (see idctBlockScaled). Blocks land in a + # full-width band of source rows; the band drains row by row through + # accumulators that average each target pixel's exact source footprint. + # Downscales get a proper area filter instead of nearest decimation; + # upscale axes keep nearest (boxes would leave target holes); 1:1 axes + # reduce to the identity, byte for byte. + bandPixels: seq[uint8] ## component.width x bandRows source pixels + bandRows: int ## 8 * xScale: one MCU row of blocks + bandIndex: int ## which band bandPixels holds; -1 = none + nextSourceY: int ## first source row the drain has not folded yet + colCount: seq[uint16] ## source columns per target column (X box) + horizSums: seq[uint32] ## one source row folded to target width + rowSums: seq[uint32] ## vertical accumulator for the target row in flight + rowSumsTy: int ## which target row rowSums is accumulating + rowSumsRows: int ## source rows folded into rowSums so far JpegSourceProc* = ImageSourceProc ## Pull callback for streaming decodes: fill `dst` with up to `maxBytes` @@ -104,6 +119,11 @@ type streamBaselineBlocks: bool scaledTargetWidth, scaledTargetHeight: int scaledFit: ScaledDecodeFit + effectiveWidth, effectiveHeight: int + ## The oriented sample grid a scaled decode's channels were decoded on + ## (the fitted resolution, after any budget clamp). fillImage maps + ## target pixels straight into this grid — one floor mapping, no round + ## trip through image coordinates. plannedDecodeBytes: int64 ## what the SOF plan check accounted for, so the ## build step can budget its output image on top @@ -555,6 +575,10 @@ proc decodeSOF0(state: var DecoderState) = effectiveTargetHeight = max(64, (effectiveTargetHeight.float64 * factor).int) + if state.useScaledChannels(): + state.effectiveWidth = effectiveTargetWidth + state.effectiveHeight = effectiveTargetHeight + for component in state.components.mitems: component.width = ( state.imageWidth * @@ -591,6 +615,21 @@ proc decodeSOF0(state: var DecoderState) = ) channelWidth = component.sampleWidth channelHeight = component.sampleHeight + # The box sampler's working set: one MCU row of source pixels plus two + # target-width accumulator rows. Bands hold decoded pixels before the + # drain folds them; the accumulators carry a target row's partial sums + # across band boundaries, which is what makes footprints that straddle + # an MCU row exact instead of approximated. + component.bandRows = 8 * component.xScale + component.bandIndex = -1 + component.nextSourceY = 0 + component.rowSumsTy = -1 + component.rowSumsRows = 0 + if component.sampleWidth <= component.width: + component.colCount = newSeq[uint16](component.sampleWidth) + for sx in 0 ..< component.width: + inc component.colCount[ + (sx.int64 * component.sampleWidth.int64).int div component.width] block: let @@ -602,7 +641,10 @@ proc decodeSOF0(state: var DecoderState) = else: blockColumns.int64 * blockRows.int64 * 64'i64 * sizeof(int16).int64 maskBytes = if state.useScaledChannels(): - channelWidth.int64 * channelHeight.int64 + # Channel plus the box sampler's band and accumulators. + channelWidth.int64 * channelHeight.int64 + + component.width.int64 * (8 * component.xScale).int64 + + 2'i64 * channelWidth.int64 * sizeof(uint32).int64 else: # The reconstruction path magnifies every subsampled channel up to # full stride resolution (magnifyXBy2/magnifyYBy2), and during the @@ -654,6 +696,13 @@ proc decodeSOF0(state: var DecoderState) = ) ) component.channel = newMask(component.channelWidth, component.channelHeight) + if state.useScaledChannels(): + # The box sampler's working set, counted in the plan above and + # allocated only now that the plan passed the budget. + component.bandPixels = newSeq[uint8]( + component.width * component.bandRows) + component.horizSums = newSeq[uint32](component.sampleWidth) + component.rowSums = newSeq[uint32](component.sampleWidth) proc decodeSOF1(state: var DecoderState) = failInvalid("unsupported extended sequential DCT format") @@ -1270,35 +1319,123 @@ proc idctBlock(component: var Component, offset: int, data: array[64, int16]) = for x in 0 ..< 8: component.channel.data[outPos + x] = pixels[sourcePos + x] +proc writeScaledTargetRow(component: var Component, ty: int) = + ## Divides the vertical accumulator by each pixel's exact footprint area + ## and writes the finished channel row. + if ty < 0 or ty >= component.sampleHeight: + return + let + rows = max(1, component.rowSumsRows).uint32 + outPos = ty * component.channel.width + if component.colCount.len > 0: + for tx in 0 ..< component.sampleWidth: + let area = rows * component.colCount[tx].uint32 + component.channel.data[outPos + tx] = + ((component.rowSums[tx] + area div 2) div area).uint8 + else: + for tx in 0 ..< component.sampleWidth: + component.channel.data[outPos + tx] = + ((component.rowSums[tx] + rows div 2) div rows).uint8 + for tx in 0 ..< component.sampleWidth: + component.rowSums[tx] = 0 + component.rowSumsRows = 0 + component.rowSumsTy = -1 + +proc foldScaledSourceRow(component: var Component, sy, rowStart: int) = + ## Folds one full-width source row from the band into the target grid: + ## X first (box average on downscale, nearest gather on upscale), then Y + ## (box accumulate on downscale, replicate on upscale). + let + width = component.width + height = component.height + sampleWidth = component.sampleWidth + sampleHeight = component.sampleHeight + + if component.colCount.len > 0: + for tx in 0 ..< sampleWidth: + component.horizSums[tx] = 0 + for sx in 0 ..< width: + component.horizSums[(sx.int64 * sampleWidth.int64).int div width] += + component.bandPixels[rowStart + sx] + else: + for tx in 0 ..< sampleWidth: + component.horizSums[tx] = + component.bandPixels[rowStart + (tx * width) div sampleWidth] + + if sampleHeight <= height: + # Box: this source row belongs to exactly one target row; footprints + # tile the source, so partial sums never overlap. + let ty = (sy.int64 * sampleHeight.int64).int div height + component.rowSumsTy = ty + for tx in 0 ..< sampleWidth: + component.rowSums[tx] += component.horizSums[tx] + inc component.rowSumsRows + if sy == height - 1 or + (int64(sy + 1) * sampleHeight.int64).int div height != ty: + component.writeScaledTargetRow(ty) + else: + # Upscale: every target row sampling this source row gets it now, which + # is the same nearest replication the sampler always did. + let + tyLo = scaledCeil(sy, sampleHeight, height) + tyHi = min(sampleHeight, scaledCeil(sy + 1, sampleHeight, height)) + for ty in tyLo ..< tyHi: + let outPos = ty * component.channel.width + if component.colCount.len > 0: + for tx in 0 ..< sampleWidth: + let area = component.colCount[tx].uint32 + component.channel.data[outPos + tx] = + ((component.horizSums[tx] + area div 2) div area).uint8 + else: + for tx in 0 ..< sampleWidth: + component.channel.data[outPos + tx] = component.horizSums[tx].uint8 + +proc drainScaledBand(component: var Component) = + ## Folds every not-yet-folded source row of the current band. + if component.bandIndex < 0: + return + let + bandY0 = component.bandIndex * component.bandRows + bandY1 = min(component.height, bandY0 + component.bandRows) + for sy in max(bandY0, component.nextSourceY) ..< bandY1: + component.foldScaledSourceRow(sy, (sy - bandY0) * component.width) + component.nextSourceY = sy + 1 + component.bandIndex = -1 + +proc finalizeScaledChannel(component: var Component) = + ## Drains the last band and flushes a partial vertical box, if the image + ## height leaves one (it does not when footprints tile exactly, but a + ## truncated decode must still leave the channel fully written). + component.drainScaledBand() + if component.rowSumsRows > 0: + component.writeScaledTargetRow(component.rowSumsTy) + proc idctBlockScaled(component: var Component, row, column: int, data: array[64, int16]) = - ## Inverse discrete cosine transform whole block into a scaled channel. + ## Inverse discrete cosine transform of a whole block, routed through the + ## band buffer so the box sampler sees complete full-resolution rows. + ## Blocks arrive band-monotonically on every decode path: the interleaved + ## MCU loop finishes one MCU row (8 * xScale source rows) before the next, + ## and the per-component passes walk block rows in raster order. let - pixels = idctBlockPixels(data) sourceX0 = row * 8 sourceY0 = column * 8 - sourceX1 = min(sourceX0 + 8, component.width) - sourceY1 = min(sourceY0 + 8, component.height) - if sourceX0 >= component.width or sourceY0 >= component.height: return - let - targetX0 = scaledCeil(sourceX0, component.sampleWidth, component.width) - targetY0 = scaledCeil(sourceY0, component.sampleHeight, component.height) - targetX1 = min(component.sampleWidth, scaledCeil(sourceX1, component.sampleWidth, component.width)) - targetY1 = min(component.sampleHeight, scaledCeil(sourceY1, component.sampleHeight, component.height)) + let band = sourceY0 div component.bandRows + if band != component.bandIndex: + component.drainScaledBand() + component.bandIndex = band - for targetY in targetY0 ..< targetY1: - let - sourceY = min((targetY * component.height) div component.sampleHeight, component.height - 1) - localY = sourceY - sourceY0 - sourcePos = localY * 8 - outPos = targetY * component.channel.width - for targetX in targetX0 ..< targetX1: - let - sourceX = min((targetX * component.width) div component.sampleWidth, component.width - 1) - localX = sourceX - sourceX0 - component.channel.data[outPos + targetX] = pixels[sourcePos + localX] + let + pixels = idctBlockPixels(data) + bandY0 = band * component.bandRows + copyWidth = min(8, component.width - sourceX0) + copyHeight = min(8, component.height - sourceY0) + for y in 0 ..< copyHeight: + let rowStart = (sourceY0 - bandY0 + y) * component.width + sourceX0 + for x in 0 ..< copyWidth: + component.bandPixels[rowStart + x] = pixels[y * 8 + x] {.pop.} @@ -1532,24 +1669,29 @@ proc orientedDimensions(state: DecoderState): tuple[width, height: int] = else: failInvalid("invalid orientation") -proc sourceCoords(state: DecoderState, orientedX, orientedY: int): tuple[x, y: int] = +proc sourceCoords( + state: DecoderState, orientedX, orientedY, width, height: int +): tuple[x, y: int] = + ## Maps oriented coordinates back to storage order, in whatever grid the + ## caller is addressing — the image itself, or a scaled decode's sample + ## grid (`width`/`height` are that grid's storage-order dimensions). case state.orientation: of 0, 1: (orientedX, orientedY) of 2: - (state.imageWidth - orientedX - 1, orientedY) + (width - orientedX - 1, orientedY) of 3: - (state.imageWidth - orientedX - 1, state.imageHeight - orientedY - 1) + (width - orientedX - 1, height - orientedY - 1) of 4: - (orientedX, state.imageHeight - orientedY - 1) + (orientedX, height - orientedY - 1) of 5: (orientedY, orientedX) of 6: - (orientedY, state.imageHeight - orientedX - 1) + (orientedY, height - orientedX - 1) of 7: - (state.imageWidth - orientedY - 1, state.imageHeight - orientedX - 1) + (width - orientedY - 1, height - orientedX - 1) of 8: - (state.imageWidth - orientedY - 1, orientedX) + (width - orientedY - 1, orientedX) else: failInvalid("invalid orientation") @@ -1613,20 +1755,45 @@ proc fillImage(state: var DecoderState, result: Image) = ## Takes a jpeg image object and fills a target-sized pixie Image from it. ## With fitContain, pixels outside the fitted rectangle keep their current ## contents (callers pre-fill the background). + ## + ## Geometry — the crop and where it lands — is decided in image space; the + ## pixel walk then addresses the sample grid the channels were actually + ## decoded on. It used to go target -> image -> sample instead, and the + ## double floor division duplicated some sample columns and skipped others: + ## extra aliasing, on top of nearest, for every non-integer downscale. let oriented = state.orientedDimensions() rects = state.scaledFitRects(result.width, result.height) + effWidth = + if state.effectiveWidth > 0: state.effectiveWidth else: oriented.width + effHeight = + if state.effectiveHeight > 0: state.effectiveHeight else: oriented.height + gridWidth = + if state.orientation in {5, 6, 7, 8}: effHeight else: effWidth + gridHeight = + if state.orientation in {5, 6, 7, 8}: effWidth else: effHeight + # The image-space crop, mapped onto the (oriented) sample grid. Rounded, + # so a cover-inflated grid built to give the crop target density comes + # back as exactly one grid pixel per target pixel when it can. + sampleSrcX = (rects.srcX.int64 * effWidth.int64 + + oriented.width div 2) div oriented.width + sampleSrcY = (rects.srcY.int64 * effHeight.int64 + + oriented.height div 2) div oriented.height + sampleSrcW = max(1'i64, (rects.srcW.int64 * effWidth.int64 + + oriented.width div 2) div oriented.width) + sampleSrcH = max(1'i64, (rects.srcH.int64 * effHeight.int64 + + oriented.height div 2) div oriented.height) template orientedYFor(y: int): int = min( - rects.srcY + ((y - rects.dstY) * rects.srcH) div rects.dstH, - oriented.height - 1 + (sampleSrcY + ((y - rects.dstY).int64 * sampleSrcH) div rects.dstH).int, + effHeight - 1 ) template orientedXFor(x: int): int = min( - rects.srcX + ((x - rects.dstX) * rects.srcW) div rects.dstW, - oriented.width - 1 + (sampleSrcX + ((x - rects.dstX).int64 * sampleSrcW) div rects.dstW).int, + effWidth - 1 ) case state.components.len: @@ -1640,11 +1807,11 @@ proc fillImage(state: var DecoderState, result: Image) = for x in rects.dstX ..< rects.dstX + rects.dstW: let orientedX = orientedXFor(x) - source = state.sourceCoords(orientedX, orientedY) + source = state.sourceCoords(orientedX, orientedY, gridWidth, gridHeight) result.unsafe[x, y] = yCbCrToRgbx( - yComponent.channelAt(source.x, source.y, state.imageWidth, state.imageHeight), - cbComponent.channelAt(source.x, source.y, state.imageWidth, state.imageHeight), - crComponent.channelAt(source.x, source.y, state.imageWidth, state.imageHeight) + yComponent.channelAt(source.x, source.y, gridWidth, gridHeight), + cbComponent.channelAt(source.x, source.y, gridWidth, gridHeight), + crComponent.channelAt(source.x, source.y, gridWidth, gridHeight) ) of 1: @@ -1654,9 +1821,9 @@ proc fillImage(state: var DecoderState, result: Image) = for x in rects.dstX ..< rects.dstX + rects.dstW: let orientedX = orientedXFor(x) - source = state.sourceCoords(orientedX, orientedY) + source = state.sourceCoords(orientedX, orientedY, gridWidth, gridHeight) result.unsafe[x, y] = grayScaleToRgbx( - yComponent.channelAt(source.x, source.y, state.imageWidth, state.imageHeight) + yComponent.channelAt(source.x, source.y, gridWidth, gridHeight) ) else: @@ -1885,6 +2052,12 @@ proc runJpegDecode(state: var DecoderState) {.raises: [PixieError].} = state.quantizationAndIDCTPass() + if state.useScaledChannels(): + # Drain the box sampler's last band and any partial vertical footprint, + # so the channels are fully written before anything reads them. + for component in state.components.mitems: + component.finalizeScaledChannel() + proc decodeJpeg*(data: string): Image {.raises: [PixieError].} = ## Decodes the JPEG into an Image. var state = decodeJpegState(data.cstring, data.len) diff --git a/src/pixie/fileformats/webp.nim b/src/pixie/fileformats/webp.nim index 808a0780..2a90050a 100644 --- a/src/pixie/fileformats/webp.nim +++ b/src/pixie/fileformats/webp.nim @@ -2910,68 +2910,104 @@ proc decodeWebp*(data: string): Image {.raises: [PixieError].} = of UnknownWebpCompression: raise newException(PixieError, "Invalid WebP, animation decoding is not implemented") +template sampleBoxes( + rects: typed, srcWidth, srcHeight: int, body: untyped +) = + ## Iterates the fitted rect's target pixels, injecting each pixel's exact + ## source footprint (`sx0.. 0: alpha[srcY * frame.width + srcX] else: 255'u8 - target.data[target.dataIndex(dstX, dstY)] = rgba( - yuvToR(yValue, vValue), - yuvToG(yValue, uValue, vValue), - yuvToB(yValue, uValue), - alphaValue - ).rgbx() + sampleBoxes(rects, frame.width, frame.height): + var sumR, sumG, sumB, sumA: uint32 + for sy in sy0 ..< sy1: + let row = frame.chromaRow(sy) + for sx in sx0 ..< sx1: + let + (uValue, vValue) = frame.chromaAt(row, sx) + yValue = frame.ybuf[sy * lumaWidth + sx] + alphaValue = + if alpha.len > 0: alpha[sy * frame.width + sx] else: 255'u8 + px = rgba( + yuvToR(yValue, vValue), + yuvToG(yValue, uValue, vValue), + yuvToB(yValue, uValue), + alphaValue + ).rgbx() + sumR += px.r + sumG += px.g + sumB += px.b + sumA += px.a + let area = uint32((sy1 - sy0) * (sx1 - sx0)) + target.data[target.dataIndex(dstX, dstY)] = ColorRGBX( + r: ((sumR + area div 2) div area).uint8, + g: ((sumG + area div 2) div area).uint8, + b: ((sumB + area div 2) div area).uint8, + a: ((sumA + area div 2) div area).uint8 + ) proc rgbaBytesToTargetRect( rgbaData: seq[uint8], width, height: int, forceOpaque: bool, target: Image, fit: ScaledDecodeFit ) = - ## The lossless twin: samples the decoded ARGB buffer into the fitted rect. - ## `forceOpaque` stands in for the buffered path's whole-buffer alpha - ## rewrite when the stream declares no alpha. + ## The lossless twin: box-averages the decoded ARGB buffer into the fitted + ## rect, premultiplied-domain like above. `forceOpaque` stands in for the + ## buffered path's whole-buffer alpha rewrite when the stream declares no + ## alpha. let rects = scaledFitRects(width, height, target.width, target.height, fit) - for dstY in rects.dstY ..< rects.dstY + rects.dstH: - let srcY = min( - rects.srcY + ((dstY - rects.dstY) * rects.srcH) div rects.dstH, - height - 1 + sampleBoxes(rects, width, height): + var sumR, sumG, sumB, sumA: uint32 + for sy in sy0 ..< sy1: + for sx in sx0 ..< sx1: + let + src = (sy * width + sx) * 4 + px = rgba( + rgbaData[src + 0], + rgbaData[src + 1], + rgbaData[src + 2], + if forceOpaque: 255'u8 else: rgbaData[src + 3] + ).rgbx() + sumR += px.r + sumG += px.g + sumB += px.b + sumA += px.a + let area = uint32((sy1 - sy0) * (sx1 - sx0)) + target.data[target.dataIndex(dstX, dstY)] = ColorRGBX( + r: ((sumR + area div 2) div area).uint8, + g: ((sumG + area div 2) div area).uint8, + b: ((sumB + area div 2) div area).uint8, + a: ((sumA + area div 2) div area).uint8 ) - for dstX in rects.dstX ..< rects.dstX + rects.dstW: - let - srcX = min( - rects.srcX + ((dstX - rects.dstX) * rects.srcW) div rects.dstW, - width - 1 - ) - src = (srcY * width + srcX) * 4 - target.data[target.dataIndex(dstX, dstY)] = rgba( - rgbaData[src + 0], - rgbaData[src + 1], - rgbaData[src + 2], - if forceOpaque: 255'u8 else: rgbaData[src + 3] - ).rgbx() proc decodeWebpScaledInto*( data: string, target: Image, fit = fitStretch diff --git a/tests/test_jpeg.nim b/tests/test_jpeg.nim index c0d62c0f..070db7ae 100644 --- a/tests/test_jpeg.nim +++ b/tests/test_jpeg.nim @@ -77,3 +77,71 @@ block: budgetDims.width * budgetDims.height * 4 + 1024 * 1024) doAssert decodeJpeg(budgetData).width == budgetDims.width setDecodeBudgetBytes(0) + +block: + # The scaled decode's box sampler: on a non-integer downscale every target + # pixel must be the area average of its source footprint, not a nearest + # pick — nearest decimation of fine texture is what made downscaled photos + # visibly rough. The reference boxes the full decode's pixels with the same + # source-centric tiling the sampler folds by. 4:4:4 must agree to within + # fixed-point rounding; 4:2:0 adds half-resolution chroma boxes, so its + # tolerance is chroma-edge sized. (1:1 and upscales are unchanged nearest: + # pinned by the scaled paths staying byte-identical there.) + proc boxCeil(value, scale, divisor: int): int = + ((value.int64 * scale.int64 + divisor.int64 - 1) div divisor.int64).int + + for (path, tolerance) in [ + ("tests/fileformats/jpeg/masters/cat_4_4_4.jpg", 2), + ("tests/fileformats/jpeg/masters/cat_4_2_0.jpg", 24) + ]: + let + data = readFile(path) + full = decodeJpeg(data) + w = full.width * 5 div 6 + h = full.height * 5 div 6 + scaled = decodeJpegScaled(data, w, h, fitStretch) + var maxDiff = 0 + for dy in 0 ..< h: + let + sy0 = boxCeil(dy, full.height, h) + sy1 = max(sy0 + 1, min(full.height, boxCeil(dy + 1, full.height, h))) + for dx in 0 ..< w: + let + sx0 = boxCeil(dx, full.width, w) + sx1 = max(sx0 + 1, min(full.width, boxCeil(dx + 1, full.width, w))) + var sr, sg, sb: int + for sy in sy0 ..< sy1: + for sx in sx0 ..< sx1: + let p = full.unsafe[sx, sy] + sr += p.r.int + sg += p.g.int + sb += p.b.int + let + area = (sy1 - sy0) * (sx1 - sx0) + q = scaled.unsafe[dx, dy] + maxDiff = max(maxDiff, max(abs(q.r.int - (sr + area div 2) div area), + max(abs(q.g.int - (sg + area div 2) div area), + abs(q.b.int - (sb + area div 2) div area)))) + doAssert maxDiff <= tolerance, path & " maxDiff " & $maxDiff + +proc streamedMatchesBufferedDownscale() = + # The band drain and the streaming window advance together; a streamed + # downscale must land on exactly the buffered downscale's pixels. + let data = readFile("tests/fileformats/jpeg/masters/cat_4_2_0.jpg") + let dims = decodeJpegDimensions(data) + let + w = dims.width * 5 div 6 + h = dims.height * 5 div 6 + var pos = 0 + let source = proc(dst: pointer, maxBytes: int): int {.gcsafe, raises: [].} = + let n = min(min(maxBytes, 977), data.len - pos) + if n > 0: + copyMem(dst, data[pos].unsafeAddr, n) + pos += n + n + let + streamed = decodeJpegStreamScaled(source, data.len, w, h, fitCover) + buffered = decodeJpegScaled(data, w, h, fitCover) + doAssert streamed.pixelsEqual(buffered) + +streamedMatchesBufferedDownscale() diff --git a/tests/test_webp.nim b/tests/test_webp.nim index 8190565d..84885314 100644 --- a/tests/test_webp.nim +++ b/tests/test_webp.nim @@ -130,11 +130,11 @@ block: doAssert streamed.pixelsEqual(reference), path block: - # Downscale sampling against a hand-rolled nearest reference over the - # buffered decode, for every fit. The reference samples the decoded image; - # the scaled path samples YUV planes (lossy) or the ARGB buffer (lossless) - # directly — agreement means the per-pixel conversion matches at sampled - # coordinates, not just over full rows. + # Downscale sampling against a hand-rolled box-filter reference over the + # buffered decode, for every fit. Each target pixel must be the rounded + # premultiplied average of its exact source footprint — the same area + # filter a smooth resize applies — computed here independently from the + # decoded image's pixels. for path in [ "tests/fileformats/webp/test.webp", # lossy "tests/fileformats/webp/lossless1.webp", # lossless, odd size @@ -153,17 +153,37 @@ block: reference.width, reference.height, targetWidth, targetHeight, fit ) for dstY in rects.dstY ..< rects.dstY + rects.dstH: - let srcY = min( - rects.srcY + ((dstY - rects.dstY) * rects.srcH) div rects.dstH, - reference.height - 1 - ) + let + relY = dstY - rects.dstY + sy0 = min(rects.srcY + (relY * rects.srcH) div rects.dstH, + reference.height - 1) + sy1 = max(sy0 + 1, min( + rects.srcY + ((relY + 1) * rects.srcH) div rects.dstH, + reference.height)) for dstX in rects.dstX ..< rects.dstX + rects.dstW: - let srcX = min( - rects.srcX + ((dstX - rects.dstX) * rects.srcW) div rects.dstW, - reference.width - 1 - ) - doAssert scaled.data[scaled.dataIndex(dstX, dstY)] == - reference.data[reference.dataIndex(srcX, srcY)], + let + relX = dstX - rects.dstX + sx0 = min(rects.srcX + (relX * rects.srcW) div rects.dstW, + reference.width - 1) + sx1 = max(sx0 + 1, min( + rects.srcX + ((relX + 1) * rects.srcW) div rects.dstW, + reference.width)) + var sumR, sumG, sumB, sumA: uint32 + for sy in sy0 ..< sy1: + for sx in sx0 ..< sx1: + let px = reference.data[reference.dataIndex(sx, sy)] + sumR += px.r + sumG += px.g + sumB += px.b + sumA += px.a + let + area = uint32((sy1 - sy0) * (sx1 - sx0)) + expected = ColorRGBX( + r: ((sumR + area div 2) div area).uint8, + g: ((sumG + area div 2) div area).uint8, + b: ((sumB + area div 2) div area).uint8, + a: ((sumA + area div 2) div area).uint8) + doAssert scaled.data[scaled.dataIndex(dstX, dstY)] == expected, path & " " & $fit & " at " & $dstX & "," & $dstY block: From 912f7185523ed1881ab96566a80d42c25cf64a63 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 12 Aug 2026 22:56:12 +0200 Subject: [PATCH 25/29] Interpolate box-downsampled chroma instead of nearest-picking it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The box filter left one gap: subsampled chroma channels are decoded at half the sample grid and were nearest-replicated up to it, so chroma edges on downscaled 4:2:0 photos stayed blocky. channelAt now interpolates between a downsampled channel's two nearest samples per axis, center-aligned — and only for channels boxed below their native resolution, so Y planes, 1:1 decodes and upscales keep their exact former bytes (pinned by a fingerprint sweep: 288 unchanged cases, changes confined to subsampled-chroma downscales). The 4:2:0 tolerance against the ideal RGB-domain box tightens from 24 to 18. Co-Authored-By: Claude Fable 5 --- src/pixie/fileformats/jpeg.nim | 40 +++++++++++++++++++++++++++++++--- tests/test_jpeg.nim | 2 +- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/pixie/fileformats/jpeg.nim b/src/pixie/fileformats/jpeg.nim index 531103e6..03db8be4 100644 --- a/src/pixie/fileformats/jpeg.nim +++ b/src/pixie/fileformats/jpeg.nim @@ -1695,6 +1695,29 @@ proc sourceCoords( else: failInvalid("invalid orientation") +proc channelAxis( + gridCoord, gridSize, sampleSize, nativeSize: int +): tuple[lo, hi, frac, den: int] {.inline.} = + ## Where one axis of a grid coordinate lands in a channel. A channel that + ## was box-downsampled below its native resolution (subsampled chroma on a + ## scaled decode) interpolates between its two nearest samples, + ## center-aligned; every other channel keeps the exact nearest pick it + ## always had — the identity when sample and grid sizes match, replication + ## on upscales — so 1:1 and upscale decodes stay byte-identical. + if sampleSize < nativeSize: + let + den = 2 * gridSize + num = (2 * gridCoord + 1) * sampleSize - gridSize + if num <= 0: + return (0, 0, 0, den) + let lo = num div den + if lo >= sampleSize - 1: + return (sampleSize - 1, sampleSize - 1, 0, den) + (lo, lo + 1, num - lo * den, den) + else: + let nearest = min((gridCoord * sampleSize) div gridSize, sampleSize - 1) + (nearest, nearest, 0, 1) + proc channelAt( component: Component, sourceX, sourceY, sourceWidth, sourceHeight: int @@ -1706,9 +1729,20 @@ proc channelAt( sampleHeight = if component.sampleHeight > 0: component.sampleHeight else: component.height - x = min((sourceX * sampleWidth) div sourceWidth, sampleWidth - 1) - y = min((sourceY * sampleHeight) div sourceHeight, sampleHeight - 1) - component.channel.data[component.channel.dataIndex(x, y)] + ax = channelAxis(sourceX, sourceWidth, sampleWidth, component.width) + ay = channelAxis(sourceY, sourceHeight, sampleHeight, component.height) + if ax.frac == 0 and ay.frac == 0: + return component.channel.data[component.channel.dataIndex(ax.lo, ay.lo)] + let + c00 = component.channel.data[component.channel.dataIndex(ax.lo, ay.lo)].int + c10 = component.channel.data[component.channel.dataIndex(ax.hi, ay.lo)].int + c01 = component.channel.data[component.channel.dataIndex(ax.lo, ay.hi)].int + c11 = component.channel.data[component.channel.dataIndex(ax.hi, ay.hi)].int + top = c00 * (ax.den - ax.frac) + c10 * ax.frac + bottom = c01 * (ax.den - ax.frac) + c11 * ax.frac + total = top * (ay.den - ay.frac) + bottom * ay.frac + area = ax.den * ay.den + ((total + area div 2) div area).uint8 proc scaledFitRects( state: DecoderState, targetWidth, targetHeight: int diff --git a/tests/test_jpeg.nim b/tests/test_jpeg.nim index 070db7ae..44777399 100644 --- a/tests/test_jpeg.nim +++ b/tests/test_jpeg.nim @@ -92,7 +92,7 @@ block: for (path, tolerance) in [ ("tests/fileformats/jpeg/masters/cat_4_4_4.jpg", 2), - ("tests/fileformats/jpeg/masters/cat_4_2_0.jpg", 24) + ("tests/fileformats/jpeg/masters/cat_4_2_0.jpg", 18) ]: let data = readFile(path) From 78f54c30524bbc687ef1f14b8db4742a3a7c7aa0 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Thu, 13 Aug 2026 00:16:13 +0200 Subject: [PATCH 26/29] Row-streamed decoders get the box filter: PNG, BMP, PPM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The box filter reached JPEG and WebP but never the row-streamed decoders, and PNG is what an XKCD comic is: nearest decimation through a contain fit swallowed letter stems whole (the D in DWARFS lost its vertical). A shared RowBoxSampler in common.nim now folds full scanlines — arriving top-down or bottom-up — into the fitted rect: downscale axes are area filtered, 1:1 axes are the identity and upscale axes keep the exact former nearest replication, byte for byte across the whole fixture corpus (fingerprint-swept). PNG's streaming and whole-buffer paths, BMP's streaming engine and PPM's P6 stream all feed it; peak memory grows by two target-width accumulator rows, counted in each decoder's budget plan. Co-Authored-By: Claude Fable 5 --- src/pixie/common.nim | 128 ++++++++++++++++++++++++++++++++++ src/pixie/fileformats/bmp.nim | 78 ++++++--------------- src/pixie/fileformats/png.nim | 87 ++++++++++------------- src/pixie/fileformats/ppm.nim | 36 +++------- tests/test_png.nim | 20 +++++- tests/test_ppm.nim | 35 ++++++++-- 6 files changed, 244 insertions(+), 140 deletions(-) diff --git a/src/pixie/common.nim b/src/pixie/common.nim index 95f29f1f..42739ee6 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -141,6 +141,134 @@ template dataLen*(image: Image): int = ## `isContiguous`. image.width * image.height +type RowBoxSampler* = object + ## The shared sampler for row-streamed scaled decodes (PNG, BMP, PPM). + ## Full source scanlines are fed in as they decode, and each target pixel + ## of the fitted rect becomes the rounded average of its exact source + ## footprint — a proper area filter on downscale axes, so thin strokes + ## dim instead of disappearing the way nearest decimation swallowed them. + ## Axes at 1:1 reduce to the identity and upscale axes keep the exact + ## former nearest replication, byte for byte. Rows may arrive top-down or + ## bottom-up (BMP), as long as the rows inside one vertical footprint are + ## contiguous. Peak memory: two target-width accumulator rows. + srcX, srcY, srcW, srcH: int + dstX, dstY, dstW, dstH: int + boxX, boxY: bool + colCount: seq[uint32] ## source columns per target column, when boxX + line: seq[uint64] ## one scanline folded to target width (RGBX sums) + sums: seq[uint64] ## the vertical accumulator, when boxY + currentTy: int ## relative target row being accumulated; -1 = none + rowsInBox: int + +proc initRowBoxSampler*( + srcWidth, srcHeight, targetWidth, targetHeight: int, fit: ScaledDecodeFit +): RowBoxSampler = + let rects = scaledFitRects(srcWidth, srcHeight, targetWidth, targetHeight, fit) + result.srcX = rects.srcX + result.srcY = rects.srcY + result.srcW = rects.srcW + result.srcH = rects.srcH + result.dstX = rects.dstX + result.dstY = rects.dstY + result.dstW = rects.dstW + result.dstH = rects.dstH + result.boxX = rects.srcW >= rects.dstW + result.boxY = rects.srcH >= rects.dstH + result.currentTy = -1 + result.line = newSeq[uint64](rects.dstW * 4) + if result.boxX: + result.colCount = newSeq[uint32](rects.dstW) + for sx in 0 ..< rects.srcW: + inc result.colCount[(sx.int64 * rects.dstW.int64).int div rects.srcW] + if result.boxY: + result.sums = newSeq[uint64](rects.dstW * 4) + +proc wantsRow*(sampler: RowBoxSampler, sy: int): bool {.inline.} = + ## Whether this source row contributes at all — callers skip the pixel + ## conversion (or the read itself) for rows outside the crop. + sy >= sampler.srcY and sy < sampler.srcY + sampler.srcH + +proc foldRowX(sampler: var RowBoxSampler, row: openArray[ColorRGBX]) = + if sampler.boxX: + for i in 0 ..< sampler.line.len: + sampler.line[i] = 0 + for sx in 0 ..< sampler.srcW: + let + tx = (sx.int64 * sampler.dstW.int64).int div sampler.srcW + px = row[sampler.srcX + sx] + base = tx * 4 + sampler.line[base + 0] += px.r + sampler.line[base + 1] += px.g + sampler.line[base + 2] += px.b + sampler.line[base + 3] += px.a + else: + for tx in 0 ..< sampler.dstW: + let + px = row[sampler.srcX + (tx * sampler.srcW) div sampler.dstW] + base = tx * 4 + sampler.line[base + 0] = px.r + sampler.line[base + 1] = px.g + sampler.line[base + 2] = px.b + sampler.line[base + 3] = px.a + +proc writeSampledRow( + sampler: RowBoxSampler, target: Image, ty: int, + values: seq[uint64], rows: int +) = + let outY = sampler.dstY + ty + for tx in 0 ..< sampler.dstW: + let + area = uint64(rows) * + (if sampler.boxX: sampler.colCount[tx].uint64 else: 1'u64) + base = tx * 4 + half = area div 2 + target.data[target.dataIndex(sampler.dstX + tx, outY)] = ColorRGBX( + r: ((values[base + 0] + half) div area).uint8, + g: ((values[base + 1] + half) div area).uint8, + b: ((values[base + 2] + half) div area).uint8, + a: ((values[base + 3] + half) div area).uint8 + ) + +proc flushBoxRow(sampler: var RowBoxSampler, target: Image) = + if sampler.currentTy < 0 or sampler.rowsInBox == 0: + return + sampler.writeSampledRow(target, sampler.currentTy, sampler.sums, sampler.rowsInBox) + for i in 0 ..< sampler.sums.len: + sampler.sums[i] = 0 + sampler.rowsInBox = 0 + sampler.currentTy = -1 + +proc feedRow*( + sampler: var RowBoxSampler, target: Image, sy: int, row: openArray[ColorRGBX] +) = + ## Folds one full source scanline (premultiplied pixels) into the target. + if not sampler.wantsRow(sy): + return + let rel = sy - sampler.srcY + sampler.foldRowX(row) + if sampler.boxY: + let ty = (rel.int64 * sampler.dstH.int64).int div sampler.srcH + if ty != sampler.currentTy: + sampler.flushBoxRow(target) + sampler.currentTy = ty + for i in 0 ..< sampler.sums.len: + sampler.sums[i] += sampler.line[i] + inc sampler.rowsInBox + else: + let + tyLo = ((rel.int64 * sampler.dstH.int64 + sampler.srcH - 1) div + sampler.srcH).int + tyHi = min(sampler.dstH, ((int64(rel + 1) * sampler.dstH.int64 + + sampler.srcH - 1) div sampler.srcH).int) + for ty in tyLo ..< tyHi: + sampler.writeSampledRow(target, ty, sampler.line, 1) + +proc finish*(sampler: var RowBoxSampler, target: Image) = + ## Flushes the last vertical footprint; a truncated stream still leaves + ## every row that fully arrived written. + sampler.flushBoxRow(target) + + proc newImage*(width, height: int): Image {.raises: [PixieError].} = ## Creates a new image with the parameter dimensions. if width <= 0 or height <= 0: diff --git a/src/pixie/fileformats/bmp.nim b/src/pixie/fileformats/bmp.nim index e9ef290e..85c1a426 100644 --- a/src/pixie/fileformats/bmp.nim +++ b/src/pixie/fileformats/bmp.nim @@ -307,70 +307,36 @@ proc decodeBmpScaledIntoStreaming( fit: ScaledDecodeFit, readRow: BmpRowRead ) {.raises: [PixieError].} = - ## Samples pixel rows into the target as they arrive in file order. - ## Bottom-up files walk the target cursor from the bottom edge upward, so - ## no flip pass or full-size pixel buffer is ever needed. Peak memory: one - ## raw row plus one row of RGBX pixels. + ## Folds pixel rows into the target as they arrive in file order, through + ## the shared row box sampler: downscales are area filtered instead of + ## nearest-decimated, 1:1 and upscale axes stay byte-identical to the + ## nearest pick this replaced. Bottom-up files feed the sampler in + ## descending image order, which it accepts — footprint rows stay + ## contiguous either way. Peak memory: one raw row, one row of RGBX + ## pixels, two target-width accumulator rows. if header.width <= 0 or header.height <= 0: failInvalid() checkDecodeBudget(header, - header.rowStride.int64 + header.width.int64 * 4 + bmpStreamReadBytes) - - let rects = scaledFitRects( - header.width, header.height, target.width, target.height, fit - ) - - template srcYFor(y: int): int = - min( - rects.srcY + ((y - rects.dstY) * rects.srcH) div rects.dstH, - header.height - 1 - ) - - template srcXFor(x: int): int = - min( - rects.srcX + ((x - rects.dstX) * rects.srcW) div rects.dstW, - header.width - 1 - ) + header.rowStride.int64 + header.width.int64 * 4 + bmpStreamReadBytes + + target.width.int64 * 4 * 8 * 2) var rowBytes = newSeq[uint8](header.rawRowBytes) rowPixels = newSeq[ColorRGBX](header.width) - let - rowBytesPtr = cast[ptr UncheckedArray[uint8]](rowBytes[0].addr) - dstYEnd = rects.dstY + rects.dstH - - if header.topDown: - var dstY = rects.dstY - for fileY in 0 ..< header.height: - if dstY >= dstYEnd: - break # Every remaining file row is below the sampled crop - let needed = srcYFor(dstY) == fileY - readRow(rowBytesPtr, not needed) - if not needed: - continue # No target row samples this source row - bmpRowToPixels(header, rowBytes, rowPixels) - while dstY < dstYEnd and srcYFor(dstY) == fileY: - for x in rects.dstX ..< rects.dstX + rects.dstW: - target.unsafe[x, dstY] = rowPixels[srcXFor(x)] - inc dstY - else: - # Bottom-up: the first file row is the bottom image row, so the target - # cursor starts at the bottom of the fitted rect and moves upward. - var dstY = dstYEnd - 1 - for fileY in 0 ..< header.height: - if dstY < rects.dstY: - break # Every remaining file row is above the sampled crop - let imageY = header.height - fileY - 1 - let needed = srcYFor(dstY) == imageY - readRow(rowBytesPtr, not needed) - if not needed: - continue # No target row samples this source row - bmpRowToPixels(header, rowBytes, rowPixels) - while dstY >= rects.dstY and srcYFor(dstY) == imageY: - for x in rects.dstX ..< rects.dstX + rects.dstW: - target.unsafe[x, dstY] = rowPixels[srcXFor(x)] - dec dstY + sampler = initRowBoxSampler( + header.width, header.height, target.width, target.height, fit) + let rowBytesPtr = cast[ptr UncheckedArray[uint8]](rowBytes[0].addr) + + for fileY in 0 ..< header.height: + let imageY = if header.topDown: fileY else: header.height - fileY - 1 + let needed = sampler.wantsRow(imageY) + readRow(rowBytesPtr, not needed) + if not needed: + continue # Outside the fitted crop; skip the pixel conversion too + bmpRowToPixels(header, rowBytes, rowPixels) + sampler.feedRow(target, imageY, rowPixels) + sampler.finish(target) proc decodeDibScaledInto( data: ptr UncheckedArray[uint8], len: int, target: Image, diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index c0c5dfb8..120ec9ac 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -733,30 +733,29 @@ proc scaledFitRects( proc fillImage*( png: Png, target: Image, fit = fitStretch ) {.raises: [PixieError].} = - ## Scales a decoded PNG into an existing Image. With fitContain, pixels - ## outside the fitted rectangle keep their current contents. + ## Scales a decoded PNG into an existing Image through the shared row box + ## sampler — the same filtering the streamed decode applies, so an + ## interlaced or 16-bit source that had to buffer whole does not come out + ## sampled differently. With fitContain, pixels outside the fitted + ## rectangle keep their current contents. validateScaledPngTarget(target) - let rects = png.scaledFitRects(target.width, target.height, fit) - - template srcYFor(y: int): int = - min(rects.srcY + ((y - rects.dstY) * rects.srcH) div rects.dstH, png.height - 1) - - template srcXFor(x: int): int = - min(rects.srcX + ((x - rects.dstX) * rects.srcW) div rects.dstW, png.width - 1) + var + rowRgbx = newSeq[ColorRGBX](png.width) + sampler = initRowBoxSampler( + png.width, png.height, target.width, target.height, fit) - if png.data.len > 0: - for y in rects.dstY ..< rects.dstY + rects.dstH: - let srcY = srcYFor(y) - for x in rects.dstX ..< rects.dstX + rects.dstW: - let srcX = srcXFor(x) - target.unsafe[x, y] = png.data[srcX + srcY * png.width].rgbx() - else: - for y in rects.dstY ..< rects.dstY + rects.dstH: - let srcY = srcYFor(y) - for x in rects.dstX ..< rects.dstX + rects.dstW: - let srcX = srcXFor(x) - target.unsafe[x, y] = png.data16[srcX + srcY * png.width].toRgba.rgbx() + for y in 0 ..< png.height: + if not sampler.wantsRow(y): + continue + if png.data.len > 0: + for x in 0 ..< png.width: + rowRgbx[x] = png.data[x + y * png.width].rgbx() + else: + for x in 0 ..< png.width: + rowRgbx[x] = png.data16[x + y * png.width].toRgba.rgbx() + sampler.feedRow(target, y, rowRgbx) + sampler.finish(target) proc convertToImage*( png: Png, width, height: int, fit = fitStretch @@ -986,48 +985,38 @@ proc decodePngScaledIntoStreaming( fit: ScaledDecodeFit, inflate: InflateRunProc ) {.raises: [PixieError].} = - ## Samples scanlines into the target as they stream out of the inflate - ## window. Peak memory: one row of RGBA pixels plus the fixed streaming - ## overhead — the full-size pixel buffer is never allocated. + ## Folds scanlines into the target as they stream out of the inflate + ## window, through the shared row box sampler: downscales are area + ## filtered (nearest decimation swallowed thin strokes — an XKCD comic's + ## letter stems vanished into a contain fit), 1:1 and upscale axes are + ## byte-identical to the nearest pick this replaced. Peak memory: one row + ## of pixels in two forms plus two target-width accumulator rows — the + ## full-size pixel buffer is never allocated. let header = structure.header checkDecodeBudget(header, - header.streamingRowOverheadBytes + header.width.int64 * 4) - - let rects = scaledFitRects( - header.width, header.height, target.width, target.height, fit - ) - - template srcYFor(y: int): int = - min( - rects.srcY + ((y - rects.dstY) * rects.srcH) div rects.dstH, - header.height - 1 - ) - - template srcXFor(x: int): int = - min( - rects.srcX + ((x - rects.dstX) * rects.srcW) div rects.dstW, - header.width - 1 - ) + header.streamingRowOverheadBytes + header.width.int64 * 8 + + target.width.int64 * 4 * 8 * 2) var rowPixels = newSeq[ColorRGBA](header.width) - dstY = rects.dstY - let dstYEnd = rects.dstY + rects.dstH + rowRgbx = newSeq[ColorRGBX](header.width) + sampler = initRowBoxSampler( + header.width, header.height, target.width, target.height, fit) let onRow = proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} = - if dstY >= dstYEnd or srcYFor(dstY) != y: - return # No remaining target row samples this source row + if not sampler.wantsRow(y): + return # Outside the fitted crop; skip the pixel conversion too rowPixels.writePixels( header, structure.palette, structure.transparency, row, header.width, 1, 0, 0, 1, 1 ) - while dstY < dstYEnd and srcYFor(dstY) == y: - for x in rects.dstX ..< rects.dstX + rects.dstW: - target.unsafe[x, dstY] = rowPixels[srcXFor(x)].rgbx() - inc dstY + for i in 0 ..< header.width: + rowRgbx[i] = rowPixels[i].rgbx() + sampler.feedRow(target, y, rowRgbx) streamRows(header, onRow, inflate) + sampler.finish(target) proc decodePngScaledIntoStreaming( idatSegments: seq[InflateSegment], diff --git a/src/pixie/fileformats/ppm.nim b/src/pixie/fileformats/ppm.nim index d887e700..319a0519 100644 --- a/src/pixie/fileformats/ppm.nim +++ b/src/pixie/fileformats/ppm.nim @@ -256,23 +256,8 @@ proc decodePpmStreamScaledInto*( header.width.int64 * header.height.int64 * bytesPerPixel.int64: failInvalid() - checkDecodeBudget(header, rowBytesLen.int64 + header.width.int64 * 4) - - let rects = scaledFitRects( - header.width, header.height, target.width, target.height, fit - ) - - template srcYFor(y: int): int = - min( - rects.srcY + ((y - rects.dstY) * rects.srcH) div rects.dstH, - header.height - 1 - ) - - template srcXFor(x: int): int = - min( - rects.srcX + ((x - rects.dstX) * rects.srcW) div rects.dstW, - header.width - 1 - ) + checkDecodeBudget(header, rowBytesLen.int64 + header.width.int64 * 4 + + target.width.int64 * 4 * 8 * 2) # See decodeP6Data for the maxVal multiplier reasoning let valueMultiplier = (255 / header.maxVal).float32 @@ -280,13 +265,10 @@ proc decodePpmStreamScaledInto*( var rowBytes = newSeq[uint8](rowBytesLen) rowPixels = newSeq[ColorRGBX](header.width) - dstY = rects.dstY - let dstYEnd = rects.dstY + rects.dstH + sampler = initRowBoxSampler( + header.width, header.height, target.width, target.height, fit) for fileY in 0 ..< header.height: - if dstY >= dstYEnd: - break # Every remaining row is below the sampled crop - # Skipped rows still consume their bytes to keep the reads sequential var done = 0 while done < rowBytesLen: @@ -295,8 +277,8 @@ proc decodePpmStreamScaledInto*( failInvalid() done += got - if srcYFor(dstY) != fileY: - continue # No target row samples this source row + if not sampler.wantsRow(fileY): + continue # Outside the fitted crop; skip the pixel conversion too if header.maxVal > 0xFF: for x in 0 ..< header.width: @@ -326,10 +308,8 @@ proc decodePpmStreamScaledInto*( 255 ) - while dstY < dstYEnd and srcYFor(dstY) == fileY: - for x in rects.dstX ..< rects.dstX + rects.dstW: - target.unsafe[x, dstY] = rowPixels[srcXFor(x)] - inc dstY + sampler.feedRow(target, fileY, rowPixels) + sampler.finish(target) proc encodePpm*(image: Image): string {.raises: [].} = ## Encodes an image into the PPM file format (version P6). diff --git a/tests/test_png.nim b/tests/test_png.nim index 778de5bb..3a07e6a6 100644 --- a/tests/test_png.nim +++ b/tests/test_png.nim @@ -107,7 +107,25 @@ block: # streamed scanlines keep canvas-sized decodes within tight budgets setDecodeBudgetBytes(256 * 1024) let target = newImage(96, 60) decodePngScaledInto(encoded, target, fitStretch) - doAssert target[48, 30] == source[240, 400] + # The box sampler's answer for (48, 30): the rounded average of that + # pixel's exact source footprint (columns 240..<245, rows 400..<414). + block: + var r, g, b: uint64 + var n: uint64 + for sy in 0 ..< source.height: + if (sy * 60) div source.height != 30: continue + for sx in 0 ..< source.width: + if (sx * 96) div source.width != 48: continue + let px = source[sx, sy] + r += px.r + g += px.g + b += px.b + inc n + doAssert target[48, 30] == rgbx( + uint8((r + n div 2) div n), + uint8((g + n div 2) div n), + uint8((b + n div 2) div n), + 255) setDecodeBudgetBytes(0) block: # streamed scaled decodes match the buffered fillImage path diff --git a/tests/test_ppm.nim b/tests/test_ppm.nim index 890d38fd..a47b6c08 100644 --- a/tests/test_ppm.nim +++ b/tests/test_ppm.nim @@ -51,17 +51,40 @@ block: # pull-source scaled decodes match the buffered decoder decodePpmStreamScaledInto(sourceOf(data, 7), data.len, target, fitStretch) doAssert target.pixelsEqual(full) - # Downscales match nearest-neighbour sampling of the full decode + # Per axis: downscales are box averages of each pixel's exact source + # footprint, upscale axes keep the nearest pick (4 wide into 5 here). for readSize in [1, 7, 1 shl 20]: for totalLen in [data.len, 0]: let target = newImage(5, 3) decodePpmStreamScaledInto( sourceOf(data, readSize), totalLen, target, fitStretch) - for y in 0 ..< target.height: - let srcY = min(y * full.height div target.height, full.height - 1) - for x in 0 ..< target.width: - let srcX = min(x * full.width div target.width, full.width - 1) - doAssert target.unsafe[x, y] == full.unsafe[srcX, srcY] + for ty in 0 ..< target.height: + var ys: seq[int] + if target.height <= full.height: + for sy in 0 ..< full.height: + if (sy * target.height) div full.height == ty: ys.add(sy) + else: + ys.add((ty * full.height) div target.height) + for tx in 0 ..< target.width: + var xs: seq[int] + if target.width <= full.width: + for sx in 0 ..< full.width: + if (sx * target.width) div full.width == tx: xs.add(sx) + else: + xs.add((tx * full.width) div target.width) + var r, g, b: uint64 + for sy in ys: + for sx in xs: + let px = full.unsafe[sx, sy] + r += px.r + g += px.g + b += px.b + let n = uint64(xs.len * ys.len) + doAssert target.unsafe[tx, ty] == rgbx( + uint8((r + n div 2) div n), + uint8((g + n div 2) div n), + uint8((b + n div 2) div n), + 255) # 16-bit maxVal payloads stream too block: From 110778c5bd05353353b5a4057487fbb8fcaca03d Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sun, 16 Aug 2026 12:04:52 +0200 Subject: [PATCH 27/29] Mark Image acyclic: views made it look cyclic to ORC and that crashes hosts sharing it across a .so The root field, added with image views, turns Image into a cyclic type for ORC. Non-final decrefs then go through the cycle collector's root list, and FrameOS passes Images into driver shared libraries that carry their own ORC runtime: the .so registers the object in its list, the host unregisters it from a different one and segfaults in unregisterCycle after every render. A view's root is always an owner, so the type cannot actually cycle. Co-Authored-By: Claude Fable 5 --- src/pixie/common.nim | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/pixie/common.nim b/src/pixie/common.nim index 42739ee6..f41cd4b7 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -37,9 +37,19 @@ type fitCover ## fill the whole target, cropping the source centered fitContain ## fit the whole source centered, leaving target borders untouched - Image* = ref object + Image* {.acyclic.} = ref object ## Image object that holds bitmap data in premultiplied alpha RGBA format. ## + ## `{.acyclic.}` is load-bearing, not an optimisation. `root` makes this + ## type look cyclic to ORC, but a view's `root` always points at an owner + ## (see `subImageView`), never at another view, so no cycle can form. + ## Without the pragma every non-final decref registers the image with the + ## cycle collector's root list, and FrameOS hands Images across a shared + ## library boundary (driver .so files carry their own ORC runtime): the + ## .so registers the object in ITS root list, the host later unregisters + ## it from ITS OWN, and the host dies in `unregisterCycle`. Marking the + ## type acyclic keeps it on plain refcounts, which are safe to share. + ## ## An image either **owns** its pixels or is a **view** into another ## image's. A view shares the owner's buffer and addresses a rectangle ## inside it, so handing a caller a sub-region costs nothing and writes From 9baf3852b0a267986ea2a4208164a97f58fa272a Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sun, 16 Aug 2026 11:45:35 +0200 Subject: [PATCH 28/29] SVG renders as glyph outlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` used to fail the whole document — one tag, and a drawing that was otherwise entirely supported turned into "Unsupported SVG tag". Callers worked around it by layering a separate text renderer over the rasterized SVG, which means the SVG can no longer describe its own labels. The parser now typesets `` (and ``) and appends the glyph outlines as ordinary paths, so fill, stroke, gradients, opacity, transforms and band-wise rendering all apply to text exactly as they do to a `` — no second code path, nothing new in the renderer. Pixie ships no fonts, so font-family resolution is a hook the application installs: `setSvgTypefaceResolver` is asked for one candidate at a time, most-preferred first, and finally for the empty family (the default face). Declining every candidate skips the text and keeps the rest of the drawing, which is what a missing font should cost. Supported: x/y/dx/dy, font-family lists, font-size in CSS units, font-weight, font-style, text-anchor, dominant-baseline, per-tspan positions and paint. Not in this pass: textPath, textLength, letter-spacing, per-glyph x/y lists, wrapping, complex shaping, and color (bitmap) emoji, which have no outline. Whitespace needed one more thing: Nim's XML parser drops whitespace after a closing tag unless asked for it, which is invisible everywhere except inside ``, where the space in `… tail` is content. `parseSvgXml` asks for it, and only for documents that contain text — a node per whitespace run is real memory on a small device. --- src/pixie/fileformats/svg.nim | 403 +++++++++++++++++++++++++++++++++- src/pixie/fonts.nim | 20 ++ tests/test_svg_text.nim | 179 +++++++++++++++ 3 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 tests/test_svg_text.nim diff --git a/src/pixie/fileformats/svg.nim b/src/pixie/fileformats/svg.nim index 4d5294c1..fe28d726 100644 --- a/src/pixie/fileformats/svg.nim +++ b/src/pixie/fileformats/svg.nim @@ -1,7 +1,7 @@ ## Load SVG files. -import chroma, ../common, ../images, ../internal, ../paints, - ../paths, strutils, tables, vmath, xmlparser, xmltree +import chroma, ../common, ../fonts, ../images, ../internal, ../paints, + ../paths, parsexml, strutils, tables, unicode, vmath, xmlparser, xmltree when defined(pixieDebugSvg): import strtabs @@ -16,6 +16,12 @@ type elements: seq[(Path, SvgProperties)] linearGradients: Table[string, LinearGradient] + SvgTextAnchor = enum + StartAnchor, MiddleAnchor, EndAnchor + + SvgBaseline = enum + AlphabeticBaseline, MiddleBaseline, HangingBaseline, IdeographicBaseline + SvgProperties = object display: bool fillRule: WindingRule @@ -28,14 +34,42 @@ type strokeDashArray: seq[float32] transform: Mat3 opacity, fillOpacity, strokeOpacity: float32 + fontFamily: string + fontSize: float32 + fontWeight: int + fontItalic: bool + textAnchor: SvgTextAnchor + baseline: SvgBaseline LinearGradient = object x1, y1, x2, y2: float32 stops: seq[ColorStop] + SvgTypefaceResolver* = proc( + family: string, weight: int, italic: bool + ): Typeface {.gcsafe, raises: [].} + ## Turns a CSS font-family name into a typeface for ``. Called once + ## per candidate in a `font-family` list, most-preferred first; return nil + ## to decline and let the next candidate try. Called a final time with an + ## empty family for the default face, so returning nil there means the text + ## is skipped rather than the SVG failing. + template failInvalid() = raise newException(PixieError, "Invalid SVG data") +var svgTypefaceResolverImpl: SvgTypefaceResolver + +proc setSvgTypefaceResolver*(resolver: SvgTypefaceResolver) {.raises: [].} = + ## Installs the hook `` uses to find typefaces. Pixie ships no fonts, so + ## without one `` elements are skipped: an application that wants text + ## rendered supplies its own font lookup here (see `readTypeface`). + svgTypefaceResolverImpl = resolver + +proc svgTypefaceResolver*(): SvgTypefaceResolver {.raises: [].} = + ## The currently installed typeface resolver, or nil. + {.cast(gcsafe).}: + result = svgTypefaceResolverImpl + proc attrOrDefault(node: XmlNode, name, default: string): string = result = node.attr(name) if result.len == 0: @@ -50,6 +84,62 @@ proc initSvgProperties(): SvgProperties = result.opacity = 1 result.fillOpacity = 1 result.strokeOpacity = 1 + result.fontSize = 16 # CSS initial value + result.fontWeight = 400 + +proc parseSvgFontSize(value: string, inherited: float32): float32 = + ## font-size in CSS units, resolved to pixels. An unparseable size inherits + ## rather than failing: a broken font-size must not lose the whole drawing. + var v = value.strip() + if v.len == 0: + return inherited + case v + of "xx-small": return 9 + of "x-small": return 10 + of "small": return 13 + of "medium": return 16 + of "large": return 18 + of "x-large": return 24 + of "xx-large": return 32 + of "larger": return inherited * 1.2 + of "smaller": return inherited / 1.2 + of "inherit": return inherited + else: discard + + var scale: float32 = 1 + template cut(suffix: string, factor: float32) = + v.setLen(v.len - suffix.len) + scale = factor + if v.endsWith("px"): cut("px", 1) + elif v.endsWith("rem"): cut("rem", inherited) + elif v.endsWith("em"): cut("em", inherited) + elif v.endsWith("pt"): cut("pt", 96 / 72) + elif v.endsWith("pc"): cut("pc", 16) + elif v.endsWith("in"): cut("in", 96) + elif v.endsWith("cm"): cut("cm", 96 / 2.54) + elif v.endsWith("mm"): cut("mm", 96 / 25.4) + elif v.endsWith("%"): cut("%", inherited / 100) + + try: + result = parseFloat(v.strip()) * scale + except ValueError: + result = inherited + if result <= 0: + result = 0 + +proc parseSvgFontWeight(value: string, inherited: int): int = + case value.strip(): + of "": inherited + of "normal", "book", "regular": 400 + of "bold": 700 + of "bolder": min(inherited + 300, 900) + of "lighter": max(inherited - 300, 100) + of "inherit": inherited + else: + try: + clamp(parseInt(value.strip()), 1, 1000) + except ValueError: + inherited proc parseSvgProperties(node: XmlNode, inherited: SvgProperties): SvgProperties = result = inherited @@ -76,6 +166,14 @@ proc parseSvgProperties(node: XmlNode, inherited: SvgProperties): SvgProperties opacity = node.attr("opacity") fillOpacity = node.attr("fill-opacity") strokeOpacity = node.attr("stroke-opacity") + fontFamily = node.attr("font-family") + fontSize = node.attr("font-size") + fontWeight = node.attr("font-weight") + fontStyle = node.attr("font-style") + textAnchor = node.attr("text-anchor") + baseline = node.attrOrDefault( + "dominant-baseline", node.attr("alignment-baseline") + ) when defined(pixieDebugSvg): proc maybeLogPair(k, v: string) = @@ -86,7 +184,9 @@ proc parseSvgProperties(node: XmlNode, inherited: SvgProperties): SvgProperties "xmlns", "x", "y", "x1", "x2", "y1", "y2", "id", "d", "cx", "cy", "r", "points", "rx", "ry", "enable-background", "xml:space", "xmlns:xlink", "data-name", "role", "class", "opacity", - "fill-opacity", "stroke-opacity" + "fill-opacity", "stroke-opacity", "font-family", "font-size", + "font-weight", "font-style", "text-anchor", "dominant-baseline", + "alignment-baseline", "dx", "dy" ]: echo k, ": ", v @@ -136,6 +236,24 @@ proc parseSvgProperties(node: XmlNode, inherited: SvgProperties): SvgProperties of "strokeOpacity": if strokeOpacity.len == 0: strokeOpacity = parts[1].strip() + of "font-family": + if fontFamily.len == 0: + fontFamily = parts[1].strip() + of "font-size": + if fontSize.len == 0: + fontSize = parts[1].strip() + of "font-weight": + if fontWeight.len == 0: + fontWeight = parts[1].strip() + of "font-style": + if fontStyle.len == 0: + fontStyle = parts[1].strip() + of "text-anchor": + if textAnchor.len == 0: + textAnchor = parts[1].strip() + of "dominant-baseline", "alignment-baseline": + if baseline.len == 0: + baseline = parts[1].strip() else: when defined(pixieDebugSvg): maybeLogPair(parts[0], parts[1]) @@ -229,6 +347,35 @@ proc parseSvgProperties(node: XmlNode, inherited: SvgProperties): SvgProperties else: result.strokeMiterLimit = parseFloat(strokeMiterLimit) + if fontFamily.len > 0 and fontFamily != "inherit": + result.fontFamily = fontFamily + + result.fontSize = parseSvgFontSize(fontSize, inherited.fontSize) + result.fontWeight = parseSvgFontWeight(fontWeight, inherited.fontWeight) + + case fontStyle.strip(): + of "": discard # Inherit + of "italic", "oblique": result.fontItalic = true + of "normal": result.fontItalic = false + else: discard # Unknown font-style: keep inheriting rather than fail + + case textAnchor.strip(): + of "": discard # Inherit + of "middle": result.textAnchor = MiddleAnchor + of "end": result.textAnchor = EndAnchor + of "start": result.textAnchor = StartAnchor + else: discard + + case baseline.strip(): + of "": discard # Inherit + of "middle", "central": result.baseline = MiddleBaseline + of "hanging", "text-before-edge", "top": result.baseline = HangingBaseline + of "text-after-edge", "ideographic", "bottom": + result.baseline = IdeographicBaseline + of "auto", "alphabetic", "baseline", "mathematical": + result.baseline = AlphabeticBaseline + else: discard + if strokeDashArray == "": discard else: @@ -299,6 +446,228 @@ proc parseSvgProperties(node: XmlNode, inherited: SvgProperties): SvgProperties else: failInvalidTransform(transform) +type + SvgTextPosition = object + hasX, hasY: bool + x, y, dx, dy: float32 + + SvgTextRun = object + text: string + props: SvgProperties + pos: SvgTextPosition + +proc svgEntityText(name: string): string = + ## `parsexml` resolves numeric entities and the five predefined ones itself, + ## so what reaches an entity node is a named entity. These are the ones that + ## turn up in hand-written and generated SVG; anything else contributes + ## nothing rather than failing the drawing. + case name + of "nbsp": " " # A normal space: fonts often lack U+00A0 and would draw tofu + of "ndash": "–" + of "mdash": "—" + of "hellip": "…" + of "lsquo": "‘" + of "rsquo": "’" + of "ldquo": "“" + of "rdquo": "”" + of "bull": "•" + of "middot": "·" + of "times": "×" + of "deg": "°" + of "copy": "©" + of "reg": "®" + of "trade": "™" + of "euro": "€" + of "pound": "£" + of "yen": "¥" + else: "" + +proc collapseSvgWhitespace(text: string, lastWasSpace: var bool): string = + ## XML whitespace inside `` is layout, not content: newlines and the + ## indentation around a `` collapse to a single space. `lastWasSpace` + ## carries across runs so ` a b ` does not gain + ## spaces at the seams, and starts true so leading indentation disappears. + for c in text: + if c in Whitespace: + if not lastWasSpace: + result.add ' ' + lastWasSpace = true + else: + result.add c + lastWasSpace = false + +proc parseSvgCoordinate(value: string, default: float32 = 0): float32 = + ## The first number of an SVG coordinate attribute. `x="10 20 30"` positions + ## glyphs individually; v1 takes the first and advances the rest normally. + var s = value.strip() + let sep = s.find({' ', ',', '\t', '\n', '\r'}) + if sep > 0: + s.setLen(sep) + # SVG2 allows units on geometry attributes; treat them as user units. + while s.len > 0 and s[^1] in {'a' .. 'z', 'A' .. 'Z', '%'}: + s.setLen(s.len - 1) + try: + parseFloat(s) + except ValueError: + default + +proc resolveSvgTypeface(props: SvgProperties): Typeface = + ## Walks the font-family list, most-preferred first, and asks the resolver for + ## each. An unknown family falls through to the resolver's default rather than + ## failing: text in the wrong face still says what it says. + let resolver = svgTypefaceResolver() + if resolver == nil: + return nil + for candidate in props.fontFamily.split(','): + let family = candidate.strip().strip(chars = {'"', '\'', ' '}) + if family.len == 0: + continue + let typeface = resolver(family, props.fontWeight, props.fontItalic) + if typeface != nil: + return typeface + resolver("", props.fontWeight, props.fontItalic) + +proc walkSvgText( + node: XmlNode, + inherited: SvgProperties, + runs: var seq[SvgTextRun], + lastWasSpace: var bool, + pending: var SvgTextPosition +) = + ## Flattens a `` element (and any nested ``s) into runs of text, + ## each carrying the properties and the pending position adjustment that apply + ## to its first character. + let props = node.parseSvgProperties(inherited) + + let + x = node.attr("x") + y = node.attr("y") + dx = node.attr("dx") + dy = node.attr("dy") + if x.len > 0: + pending.hasX = true + pending.x = parseSvgCoordinate(x) + if y.len > 0: + pending.hasY = true + pending.y = parseSvgCoordinate(y) + if dx.len > 0: + pending.dx += parseSvgCoordinate(dx) + if dy.len > 0: + pending.dy += parseSvgCoordinate(dy) + + for child in node: + case child.kind + of xnText, xnCData, xnVerbatimText: + let text = collapseSvgWhitespace(child.text, lastWasSpace) + if text.len > 0: + runs.add SvgTextRun(text: text, props: props, pos: pending) + pending = SvgTextPosition() + of xnEntity: + let text = svgEntityText(child.text) + if text.len > 0: + lastWasSpace = false + runs.add SvgTextRun(text: text, props: props, pos: pending) + pending = SvgTextPosition() + of xnElement: + case child.tag + of "tspan", "text", "a": + child.walkSvgText(props, runs, lastWasSpace, pending) + else: + # , , <desc>, <tref>: no v1 support, and dropping the + # element is better than losing the document it sits in. + discard + else: + discard + +proc parseSvgText( + node: XmlNode, props: SvgProperties +): seq[(Path, SvgProperties)] = + ## Turns a `<text>` element into glyph outlines. They are ordinary paths from + ## there on: fill, stroke, gradients, opacity and transforms all apply exactly + ## as they do to a `<path>`. + if svgTypefaceResolver() == nil: + # No font source installed. Skipping the text keeps the rest of the + # drawing, which is what a missing font should cost. + return + + var + runs: seq[SvgTextRun] + lastWasSpace = true + pending = SvgTextPosition(hasX: true, hasY: true) # x=0 y=0 unless given + node.walkSvgText(props, runs, lastWasSpace, pending) + if runs.len == 0: + return + + if runs[^1].text.endsWith(" "): + # Renders nothing, but would widen the chunk that text-anchor centers. + runs[^1].text.setLen(runs[^1].text.len - 1) + + var + arrangements = newSeq[Arrangement](runs.len) + fonts = newSeq[Font](runs.len) + origins = newSeq[Vec2](runs.len) + widths = newSeq[float32](runs.len) + chunkStarts: seq[int] + pen: Vec2 + + for i, run in runs: + if run.pos.hasX: + pen.x = run.pos.x + chunkStarts.add i + if run.pos.hasY: + pen.y = run.pos.y + pen.x += run.pos.dx + pen.y += run.pos.dy + origins[i] = pen + + if run.props.fontSize <= 0: + continue + let typeface = resolveSvgTypeface(run.props) + if typeface == nil: + continue + let font = newFont(typeface) + font.size = run.props.fontSize + fonts[i] = font + # No bounds, no wrapping: SVG text is one line unless the author breaks it + # into positioned tspans. + arrangements[i] = font.typeset(run.text, wrap = false) + widths[i] = arrangements[i].layoutBounds().x + pen.x += widths[i] + + for c, start in chunkStarts: + let + stop = if c + 1 < chunkStarts.len: chunkStarts[c + 1] - 1 else: runs.high + anchor = runs[start].props.textAnchor + if anchor == StartAnchor: + continue + let + width = origins[stop].x + widths[stop] - origins[start].x + shift = if anchor == MiddleAnchor: -width / 2 else: -width + for i in start .. stop: + origins[i].x += shift + + for i, run in runs: + if fonts[i] == nil or run.text.strip().len == 0: + continue + let + font = fonts[i] + typeface = font.typeface + scale = font.scale + baselineShift = + case run.props.baseline + of AlphabeticBaseline: 0.float32 + of MiddleBaseline: (typeface.ascent + typeface.descent) / 2 * scale + of HangingBaseline: typeface.ascent * scale + of IdeographicBaseline: typeface.descent * scale + path = arrangements[i].computePath() + # `typeset` puts the first baseline at `baselineOffset` below the top of the + # block; SVG gives us the baseline itself, so shift the block up by that. + path.transform(translate(vec2( + origins[i].x, + origins[i].y + baselineShift - font.baselineOffset + ))) + result.add (path, run.props) + proc parseSvgElement( node: XmlNode, svg: Svg, propertiesStack: var seq[SvgProperties] ): seq[(Path, SvgProperties)] = @@ -321,6 +690,9 @@ proc parseSvgElement( result.add child.parseSvgElement(svg, propertiesStack) discard propertiesStack.pop() + of "text": + result.add node.parseSvgText(propertiesStack[^1]) + of "path": let d = node.attr("d") @@ -460,6 +832,10 @@ proc parseSvgElement( linearGradient.y2 = parseFloat(node.attr("y2")) for child in node: + if child.kind != xnElement: + # Whitespace between the stops, reported for documents with text in + # them (see parseSvgXml). Not a tag, and `tag` on it would be a defect. + continue if child.tag == "stop": var color = child.attr("stop-color") @@ -544,10 +920,29 @@ proc parseSvg*( except: raise currentExceptionAsPixieError() +proc parseSvgXml*(data: string): XmlNode {.raises: [PixieError].} = + ## Parses SVG markup into XML, the way `parseSvg` needs it. Callers that want + ## the tree itself — to rewrite the root transform and render in bands, say — + ## should come through here rather than calling `parseXml` directly. + ## + ## Nim's parser drops whitespace that follows a closing tag unless asked for + ## it, which is invisible everywhere except inside `<text>`, where the space + ## in `…</tspan> tail` is content. Asking for it costs a node per run of + ## whitespace in the document, so only documents with text pay. + try: + var options = {reportComments} + if data.contains("<text") or data.contains("<tspan"): + options.incl reportWhitespace + result = parseXml(data, options) + except PixieError as e: + raise e + except: + raise currentExceptionAsPixieError() + proc parseSvg*(data: string, width = 0, height = 0): Svg {.raises: [PixieError].} = ## Parse SVG data. Defaults to the SVG's view box size. try: - let root = parseXml(data) + let root = parseSvgXml(data) result = root.parseSvg(width, height) except PixieError as e: raise e diff --git a/src/pixie/fonts.nim b/src/pixie/fonts.nim index bd3e82b9..7851c6bf 100644 --- a/src/pixie/fonts.nim +++ b/src/pixie/fonts.nim @@ -209,6 +209,13 @@ proc lineGap(font: Font): float32 = else: (lineHeight / font.scale) - font.typeface.ascent + font.typeface.descent +proc baselineOffset*(font: Font): float32 {.raises: [].} = + ## The distance in pixels from the top of a typeset block down to the + ## baseline of its first line. `typeset` starts the first baseline here, so + ## a caller that is given a baseline (SVG's `<text y=…>`, for one) positions + ## the arrangement at `y - font.baselineOffset`. + round((font.typeface.ascent + font.lineGap / 2) * font.scale) + proc paint*(font: Font): Paint {.inline, raises: [].} = font.paints[0] @@ -585,6 +592,19 @@ proc computePaths(arrangement: Arrangement): seq[Path] = spanPath.addPath(path) result.add(spanPath) +proc computePath*(arrangement: Arrangement): Path {.raises: [PixieError].} = + ## The glyph outlines of an entire arrangement as one path, in the + ## arrangement's own space (y = 0 is the top of the first line). Callers that + ## want the outlines rather than a rasterization — vector output, or an SVG + ## `<text>` element that has to take the same fill, stroke, gradient and + ## transform treatment as any other path — start here. + ## + ## Color (bitmap) glyphs have no outline and contribute nothing; see + ## `fillText` for the path that draws those. + result = newPath() + for path in arrangement.computePaths(): + result.addPath(path) + proc drawColorGlyphs( target: Image, arrangement: Arrangement, diff --git a/tests/test_svg_text.nim b/tests/test_svg_text.nim new file mode 100644 index 00000000..8943d370 --- /dev/null +++ b/tests/test_svg_text.nim @@ -0,0 +1,179 @@ +import pixie, pixie/fileformats/svg, strformat, strtabs, xmltree + +## <text> support: glyph outlines become ordinary SVG paths, so fill, stroke, +## gradients, opacity and transforms all apply to them unchanged. +## +## Lives apart from test_svg.nim because the typeface resolver is global state: +## the rest of the SVG tests must keep running with no resolver installed. + +let + regular = readTypeface("tests/fonts/Inter-Regular.ttf") + bold = readTypeface("tests/fonts/Inter-Bold.ttf") +var asked: seq[string] + +setSvgTypefaceResolver( + proc(family: string, weight: int, italic: bool): Typeface {.gcsafe, raises: [].} = + {.cast(gcsafe).}: + asked.add family + if family.len > 0 and family != "Inter" and family != "Comic Sans MS": + return nil # Unknown family: decline, and let the next candidate answer + if weight >= 600: + return bold + return regular +) + +proc inkBounds(image: Image): Rect = + ## The bounding box of everything drawn onto a transparent image. + var + minX = image.width + minY = image.height + maxX = -1 + maxY = -1 + for y in 0 ..< image.height: + for x in 0 ..< image.width: + if image[x, y].a > 0: + minX = min(minX, x) + minY = min(minY, y) + maxX = max(maxX, x) + maxY = max(maxY, y) + if maxX < 0: + return rect(0, 0, 0, 0) + rect(minX.float32, minY.float32, (maxX - minX + 1).float32, + (maxY - minY + 1).float32) + +proc render(body: string, width = 200, height = 100): Image = + newImage(parseSvg( + &"""<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}">{body}</svg>""", + width, height + )) + +block: # The baseline sits on y, and x starts the text + let + image = render("""<text x="20" y="60" font-size="40" font-family="Inter">Hg</text>""") + ink = image.inkBounds() + doAssert ink.w > 0 and ink.h > 0, "nothing was drawn" + doAssert ink.x >= 19 and ink.x <= 24, &"text does not start at x=20: {ink}" + # The cap height sits above the baseline, the descender of 'g' below it. + doAssert ink.y > 25 and ink.y < 40, &"cap height is off: {ink}" + doAssert ink.y + ink.h > 60, &"the descender should cross the baseline: {ink}" + +block: # text-anchor moves the whole chunk, not each run + let + start = render("""<text x="100" y="60" font-size="20" text-anchor="start">anchor</text>""").inkBounds() + middle = render("""<text x="100" y="60" font-size="20" text-anchor="middle">anchor</text>""").inkBounds() + finish = render("""<text x="100" y="60" font-size="20" text-anchor="end">anchor</text>""").inkBounds() + doAssert abs(start.w - middle.w) <= 1 and abs(start.w - finish.w) <= 1 + doAssert abs((middle.x + middle.w / 2) - 100) <= 2, + &"middle anchor is not centered on x: {middle}" + doAssert abs((finish.x + finish.w) - 100) <= 2, + &"end anchor does not end at x: {finish}" + +block: # dominant-baseline shifts the baseline, not the x position + let + alphabetic = render("""<text x="10" y="50" font-size="20">Ay</text>""").inkBounds() + hanging = render("""<text x="10" y="50" font-size="20" dominant-baseline="hanging">Ay</text>""").inkBounds() + middle = render("""<text x="10" y="50" font-size="20" dominant-baseline="middle">Ay</text>""").inkBounds() + doAssert hanging.y > middle.y and middle.y > alphabetic.y, + &"baselines are not ordered: {alphabetic} {middle} {hanging}" + doAssert alphabetic.x == hanging.x and alphabetic.x == middle.x + +block: # tspans continue the line, keep the space at the seam and take fills + let image = render("""<text x="10" y="60" font-size="20" fill="#0000ff">one <tspan fill="#ff0000" font-weight="bold">two</tspan> three</text>""") + var blue, red: int + for y in 0 ..< image.height: + for x in 0 ..< image.width: + let c = image[x, y] + if c.a > 200 and c.b > 200: inc blue + if c.a > 200 and c.r > 200: inc red + doAssert blue > 0 and red > 0, "tspan fill did not apply" + + # The space before "three" is content: without it the runs would collide. + let joined = render("""<text x="10" y="60" font-size="20">one <tspan>two</tspan>three</text>""") + doAssert image.inkBounds().w > joined.inkBounds().w, + "the whitespace between a tspan and the text after it was lost" + +block: # A positioned tspan starts a new chunk with its own anchoring + let image = render("""<text x="10" y="30" font-size="16">first<tspan x="10" y="70">second</tspan></text>""") + let ink = image.inkBounds() + doAssert ink.h > 40, &"the tspan did not move to its own y: {ink}" + +block: # A font-family list falls through to the first family we know + asked.setLen(0) + let image = render("""<text x="10" y="60" font-size="20" font-family="Nonesuch, 'Comic Sans MS', sans-serif">x</text>""") + doAssert asked == @["Nonesuch", "Comic Sans MS"], &"candidates asked: {asked}" + doAssert image.inkBounds().w > 0 + +block: # An entirely unknown family degrades to the default face, never fails + asked.setLen(0) + let image = render("""<text x="10" y="60" font-size="20" font-family="Nonesuch">x</text>""") + doAssert asked == @["Nonesuch", ""], &"candidates asked: {asked}" + doAssert image.inkBounds().w > 0 + +block: # font-size units, and a broken one inherits instead of failing + let + px = render("""<text x="10" y="60" font-size="20px">size</text>""").inkBounds() + unitless = render("""<text x="10" y="60" font-size="20">size</text>""").inkBounds() + pt = render("""<text x="10" y="60" font-size="15pt">size</text>""").inkBounds() + inherited = render("""<g font-size="20"><text x="10" y="60" font-size="nonsense">size</text></g>""").inkBounds() + doAssert px == unitless + doAssert pt == unitless, &"15pt should be 20px: {pt} vs {unitless}" + doAssert inherited == unitless + +block: # Transforms, gradients and opacity treat text like any other path + let image = render(""" + <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="200" y2="0"> + <stop offset="0" stop-color="#ff0000"/> + <stop offset="1" stop-color="#0000ff"/> + </linearGradient> + <g transform="translate(20 0)" opacity="0.5"> + <text x="0" y="60" font-size="30" fill="url(#g)">grad</text> + </g>""") + let ink = image.inkBounds() + doAssert ink.w > 0 + doAssert ink.x >= 19, &"the group transform did not move the text: {ink}" + var semi = 0 + for y in 0 ..< image.height: + for x in 0 ..< image.width: + if image[x, y].a > 0 and image[x, y].a < 250: inc semi + doAssert semi > 0, "group opacity did not apply to the text" + +block: # Entities and multi-line source collapse the way SVG says they do + let image = render("""<text x="10" y="60" font-size="20">a + & b c</text>""") + doAssert image.inkBounds().w > 0 + +block: # Unsupported children are dropped, not fatal + let image = render("""<text x="10" y="60" font-size="20"><title>tipkept""") + doAssert image.inkBounds().w > 0 + +block: # Text renders the same when the document is rasterized in bands + let body = """banded""" + let + whole = render(body) + root = parseSvgXml(&"""{body}""") + var banded = newImage(200, 100) + for y in countup(0, 99, 25): + root.attrs["transform"] = &"translate(0 {-y})" + let svg = parseSvg(root, 200, 100) + svg.height = 25 + let band = newImage(svg) + copyMem(banded.data[y * 200].addr, band.data[0].addr, 25 * 200 * 4) + var worst = 0 + for i in 0 ..< whole.dataLen: + let a = whole.data[i] + let b = banded.data[i] + worst = max(worst, abs(a.r.int - b.r.int)) + worst = max(worst, abs(a.g.int - b.g.int)) + worst = max(worst, abs(a.b.int - b.b.int)) + worst = max(worst, abs(a.a.int - b.a.int)) + # A band boundary rounds antialiased coverage one step differently, the same + # as it does for any other path; the glyphs themselves land in the same place. + doAssert worst <= 1, &"banded text differs from a whole render by {worst}" + +block: # Without a resolver, text is skipped and the rest of the drawing lives + setSvgTypefaceResolver(nil) + let image = render("""gone""") + doAssert image[5, 5] == rgbx(255, 0, 0, 255) + doAssert image.inkBounds() == rect(0, 0, 10, 10), "text drew without a font" + +echo "test_svg_text ok" From 73412a41067633cc3d1289d0a69bdc0072a3cfd7 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sun, 16 Aug 2026 14:38:53 +0200 Subject: [PATCH 29/29] README: say what this fork is and what it adds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anyone landing here — including us, six months from now — sees upstream's README and no sign that this is a fork, let alone which of the API is ours. The additions are all in service of one thing (drawing on hardware that has no room for the picture), so the section leads with that and then lists what followed: the decode budget, scaled and streaming decoding, the vendored streaming inflate, image views, SVG text and renderInto, color emoji, and the EXIF fix. --- README.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/README.md b/README.md index 1e734d17..9512aea9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,84 @@ +# This is the FrameOS fork of Pixie + +Upstream lives at [treeform/pixie](https://github.com/treeform/pixie); its +README follows below and still describes the library accurately. This fork adds +what [FrameOS](https://github.com/FrameOS/frameos) needs to draw pictures on +hardware that does not have room for them. + +A FrameOS scene renders on a Raspberry Pi Zero with 512MB of RAM, or on an +ESP32-S3 with 8MB of PSRAM and about 100KB of internal heap. Upstream pixie +decodes an image by allocating the finished image plus every intermediate the +codec wants, which is the right trade on a desktop and the difference between +rendering and rebooting on a frame. Everything below follows from that, plus a +few features FrameOS wanted along the way. + +Nothing here removes or renames upstream API: this is a superset, and it tracks +upstream. `-d:frameosEmbedded` only changes defaults (a conservative decode +budget), never behaviour you did not ask for. + +## What this fork adds + +**A memory budget decoders actually respect** (`pixie/decodebudget.nim`). +`setDecodeBudgetBytes` sets a per-decode ceiling covering intermediates *and* +output; decoders plan their allocations before making them and raise a catchable +`PixieError` when the plan does not fit, instead of taking the process down with +them. `0` means unlimited, which is the default on hosts. An application that +knows its live free memory can refresh the budget before every decode. + +**Decoding straight into the size you want.** `decodeImageScaled`, +`decodeImageScaledInto` and `readImageScaled` take a target size and a fit mode +(`fitStretch`, `fitCover`, `fitContain`, see `scaledFitRects`), and the +downscale happens *during* decoding: a 4000×3000 JPEG headed for a 800×480 panel +never exists at full size. Sampling is box-filtered rather than nearest, in the +row-streamed decoders too (`RowBoxSampler`), and JPEG chroma is interpolated +rather than point-picked, so a heavy downscale does not come out crawling with +aliasing. + +**Streaming decoders that never hold the file.** Every scaled decoder has a +pull-source form — `decodePngStreamScaledInto`, `decodeJpegStreamScaledInto`, +`decodeBmpStreamScaledInto`, `decodePpmStreamScaledInto`, +`decodeWebpStreamScaledInto` — driven by an `ImageSourceProc` callback that +hands over the next chunk of input. Feed one from a file and neither the +compressed bytes nor the full-size pixels are ever resident. + +**A self-contained streaming inflate** (`pixie/inflatestream.nim`, vendored from +zippy 0.10.16). PNG scanlines leave a fixed ~64KB window as they are produced, +are unfiltered in place, and multi-`IDAT` streams are inflated as segments +rather than concatenated first. The fork depends on stock zippy again as a +result. + +**Images that can borrow pixels.** `view(image, x, y, w, h)` is a window onto +another image's memory rather than a copy, with `newImageFrom`, +`toContiguousSeq`, the `forEachSpan` template and `items`/`pairs` iterators as +the seams that keep flat operations fast for owners and correct for views. +`pixelsEqual` compares contents. `Image` is `{.acyclic.}` — load-bearing, not an +optimisation: without it ORC treats every image as a cycle candidate, which +crashes a host that shares images with a dynamically loaded driver. + +**SVG that draws text, and draws into your buffer.** `` and `` +become glyph outlines and then ordinary paths, so fill, stroke, gradients, +opacity and transforms apply to them exactly as to a ``; font-family +resolution is the application's to answer through `setSvgTypefaceResolver`, +since pixie ships no fonts. `parseSvgXml` parses markup the way `` needs +it. `Svg.renderInto(target)` rasterizes into an image the caller already owns, +which for a caller that has a correctly sized canvas is the difference between +one image and two. + +**Color emoji.** COLR/CPAL layered glyphs and CBDT/CBLC and sbix bitmap glyphs +render through `fillText`, with `hasColorGlyph` to ask and `Typeface.fallbacks` +to supply an emoji face behind a text face. + +**Text as paths.** `Arrangement.computePath` returns a whole arrangement's +outlines as one path and `Font.baselineOffset` gives the distance from the top +of a typeset block to its first baseline — the two pieces anything that +positions text by its baseline needs. + +**Fixes carried here.** EXIF orientation was silently dropped for little-endian +(`II`) JPEGs, which is what most Sony and Canon bodies write, so those photos +decoded sideways. JPEG streaming resync tolerates a window slide. + 👏 👏 👏 Check out video about the library: [A full-featured 2D graphics library for Nim (NimConf 2021)](https://www.youtube.com/watch?v=8acDfUIwLnk) 👏 👏 👏 # Pixie - A full-featured 2D graphics library for Nim.