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. diff --git a/src/pixie.nim b/src/pixie.nim index e830e082..6ec17e7f 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -78,6 +78,161 @@ 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") + # 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[target.dataIndex(0, y)].addr, + source.data[source.dataIndex(0, y)].unsafeAddr, + 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].} + +proc decodeImageScaled*( + data: var string, width, height: int, fit = fitStretch +): Image {.raises: [PixieError].} + +proc decodeImageScaledInto*( + data: var string, target: Image, fit = fitStretch +): Image {.raises: [PixieError].} + +proc decodeImageScaled*( + 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, 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: + copyMem(addr copy[0], data, len) + decodeImageScaled(copy, width, height, fit) + +proc decodeImageScaled*( + 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, 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) + elif data.isWebpData: + decodeWebpScaled(data, width, height, fit) + 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, 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, 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) + elif data.isWebpData: + decodeWebpScaled(data, width, height, fit) + 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, 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, 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: + copyMem(addr copy[0], data, len) + discard decodeImageScaledInto(copy, target, fit) + target + +proc decodeImageScaledInto*( + 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, 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) + elif data.isWebpData: + discard decodeWebpScaledInto(data, target, fit) + else: + target.copyIntoTarget(decodeImageScaled(data, target.width, target.height)) + target + +proc decodeImageScaledInto*( + 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, 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) + elif data.isWebpData: + discard decodeWebpScaledInto(data, target, fit) + else: + target.copyIntoTarget(decodeImageScaled(data, target.width, target.height)) + target + proc readImageDimensions*( filePath: string ): ImageDimensions {.inline, raises: [PixieError].} = @@ -94,6 +249,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, 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, fit) + except IOError as e: + raise newException(PixieError, e.msg, e) + proc encodeImage*( image: Image, fileFormat: FileFormat ): string {.raises: [PixieError].} = @@ -135,9 +300,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 1d3bed94..f41cd4b7 100644 --- a/src/pixie/common.nim +++ b/src/pixie/common.nim @@ -31,10 +31,253 @@ type ImageDimensions* = object width*, height*: int - Image* = ref object + 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* {.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 + ## 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 + ): 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 + +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 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`. + 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. @@ -44,17 +287,101 @@ 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 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. + ## + ## 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/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/bmp.nim b/src/pixie/fileformats/bmp.nim index f7ffe00b..85c1a426 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,269 @@ 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].} = + ## 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 + + target.width.int64 * 4 * 8 * 2) + + var + rowBytes = newSeq[uint8](header.rawRowBytes) + rowPixels = newSeq[ColorRGBX](header.width) + 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, + 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/jpeg.nim b/src/pixie/fileformats/jpeg.nim index ebd2b539..03db8be4 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,6 +40,8 @@ const ] maxMarkerResync = 64 maxRestartResync = 8192 + jpegStreamWindowSize = 32 * 1024 + jpegStreamKeepBehind = 64 let jpegStartOfImage* = [0xFF.uint8, 0xD8] @@ -58,16 +60,42 @@ type yScale, xScale: int width, height: int widthStride, heightStride: int + sampleWidth, sampleHeight: int huffmanDC, huffmanAC: int dcPred: int widthCoeff, heightCoeff: int + channelWidth, channelHeight: int 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` + ## 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 @@ -88,6 +116,16 @@ type todoBeforeRestart: int eobRun: int hitEnd: bool + 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 Mask = ref object ## Mask object that holds mask opacity data. @@ -133,6 +171,18 @@ 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.} = + ## 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 + state.scaledTargetWidth > 0 and + state.scaledTargetHeight > 0 + proc clampByte(x: int32): uint8 {.inline.} = ## Clamp integer into byte range. # clamp(x, 0, 0xFF).uint8 @@ -141,39 +191,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) = @@ -203,7 +311,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: @@ -405,6 +513,10 @@ proc decodeSOF0(state: var DecoderState) = len -= 3 * numComponents + 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) @@ -416,6 +528,57 @@ 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) + + if state.useScaledChannels(): + state.effectiveWidth = effectiveTargetWidth + state.effectiveHeight = effectiveTargetHeight + for component in state.components.mitems: component.width = ( state.imageWidth * @@ -428,31 +591,127 @@ 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 - component.channel = newMask(component.widthStride, component.heightStride) + 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}: effectiveTargetHeight + else: effectiveTargetWidth + targetHeight = + if state.orientation in {5, 6, 7, 8}: effectiveTargetWidth + else: effectiveTargetHeight + 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 + # 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 + blockColumns = state.numMcuWide * component.yScale + blockRows = state.numMcuHigh * component.xScale + streamsBlocks = state.streamBaselineBlocks and not state.progressive + blockBytes = + if streamsBlocks: 0'i64 + else: blockColumns.int64 * blockRows.int64 * 64'i64 * sizeof(int16).int64 + maskBytes = + if state.useScaledChannels(): + # 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 + # 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 + + component.channelWidth = channelWidth + component.channelHeight = channelHeight 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() + # 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. + state.plannedDecodeBytes = totalBlockBytes + totalMaskBytes + if overDecodeBudget(state.plannedDecodeBytes): + failInvalid( + "JPEG decode of " & $state.imageWidth & "x" & $state.imageHeight & + (if state.progressive: " (progressive)" else: "") & + " needs " & $(state.plannedDecodeBytes 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( + state.numMcuWide * component.yScale, + newSeq[array[64, int16]]( + state.numMcuHigh * component.xScale + ) + ) + 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") 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() @@ -531,7 +790,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 @@ -634,23 +900,26 @@ 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 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 @@ -964,8 +1233,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 @@ -1012,7 +1281,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( @@ -1031,17 +1300,183 @@ 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 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 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 + sourceX0 = row * 8 + sourceY0 = column * 8 + if sourceX0 >= component.width or sourceY0 >= component.height: + return + + let band = sourceY0 div component.bandRows + if band != component.bandIndex: + component.drainScaledBand() + component.bandIndex = band + + 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.} +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) + 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 +) = + 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] @@ -1057,18 +1492,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() @@ -1080,20 +1515,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: @@ -1110,6 +1545,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 +1554,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 +1569,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 +1591,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,35 +1660,289 @@ 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, 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: + (width - orientedX - 1, orientedY) + of 3: + (width - orientedX - 1, height - orientedY - 1) + of 4: + (orientedX, height - orientedY - 1) + of 5: + (orientedY, orientedX) + of 6: + (orientedY, height - orientedX - 1) + of 7: + (width - orientedY - 1, height - orientedX - 1) + of 8: + (width - orientedY - 1, orientedX) + 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 +): uint8 {.inline.} = + let + sampleWidth = + if component.sampleWidth > 0: component.sampleWidth + else: component.width + sampleHeight = + if component.sampleHeight > 0: component.sampleHeight + else: component.height + 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 +): 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. + ## 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( + (sampleSrcY + ((y - rects.dstY).int64 * sampleSrcH) div rects.dstH).int, + effHeight - 1 + ) + + template orientedXFor(x: int): int = + min( + (sampleSrcX + ((x - rects.dstX).int64 * sampleSrcW) div rects.dstW).int, + effWidth - 1 + ) + + case state.components.len: + of 3: + let + yComponent = state.components[0] + cbComponent = state.components[1] + crComponent = state.components[2] + for y in rects.dstY ..< rects.dstY + rects.dstH: + let orientedY = orientedYFor(y) + for x in rects.dstX ..< rects.dstX + rects.dstW: + let + orientedX = orientedXFor(x) + source = state.sourceCoords(orientedX, orientedY, gridWidth, gridHeight) + result.unsafe[x, y] = yCbCrToRgbx( + 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: + let yComponent = state.components[0] + for y in rects.dstY ..< rects.dstY + rects.dstH: + let orientedY = orientedYFor(y) + for x in rects.dstX ..< rects.dstX + rects.dstW: + let + orientedX = orientedXFor(x) + source = state.sourceCoords(orientedX, orientedY, gridWidth, gridHeight) + result.unsafe[x, y] = grayScaleToRgbx( + yComponent.channelAt(source.x, source.y, gridWidth, gridHeight) + ) + + 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: 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,37 +1957,73 @@ 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 runJpegDecode(state: var DecoderState) {.raises: [PixieError].} + +proc decodeJpegState( + data: pointer, + len: int, + streamBaselineBlocks = false, + scaledTargetWidth = 0, + scaledTargetHeight = 0, + fit = fitStretch +): 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 + 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 @@ -1363,8 +2086,116 @@ proc decodeJpeg*(data: string): Image {.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) state.buildImage() +proc decodeJpegScaled*( + data: pointer, len, width, height: int, fit = fitStretch +): Image {.raises: [PixieError].} = + ## Decodes the JPEG directly into a target-sized Image. + var state = decodeJpegState( + data, + len, + true, + width, + height, + fit + ) + state.buildImage(width, height) + +proc decodeJpegScaledInto*( + 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: + raise newException(PixieError, "Target image width and height must be > 0") + var state = decodeJpegState( + data, + len, + true, + target.width, + target.height, + fit + ) + state.fillImage(target) + +proc decodeJpegScaled*( + 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. + var state = decodeJpegState( + data.cstring, + data.len, + true, + width, + height, + fit + ) + data = "" + state.buildImage(width, height) + +proc decodeJpegScaledInto*( + 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. + 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, + true, + target.width, + target.height, + fit + ) + data = "" + state.fillImage(target) + +proc decodeJpegScaled*( + 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, fit) + +proc decodeJpegScaledInto*( + data: string, target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## Decodes the JPEG directly into an existing target-sized Image. + 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 ): ImageDimensions {.raises: [PixieError].} = @@ -1435,5 +2266,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 1cc08781..120ec9ac 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, + ../inflatestream, ../internal, ../simd, zippy, crunchy # See http://www.libpng.org/pub/png/spec/1.2/PNG-Contents.html @@ -432,6 +432,125 @@ 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: [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 = + 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(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 + ) + +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].}, + inflate: InflateRunProc +) = + ## Inflates the IDAT stream and hands unfiltered scanlines to onRow one at + ## a time, in order. Peak memory: the fixed ~64KB streaming window plus + ## two row buffers, regardless of image size. Non-interlaced images only. + 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: [PixieError].} = + var i = 0 + while i < chunk.len: + if y >= height: + raise newException(PixieError, "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 + + 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, @@ -442,32 +561,24 @@ proc decodeImageData( if idats.len == 0: failInvalid() - result.setLen(header.width * header.height) - - let 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( + idatSlices(data, idats), header, + 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) - # 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) - let unfiltered = unfilter( - uncompressed.cstring, - uncompressed.len, - header.height, - rowBytes, - header.filterBytesPerPixel - ) - result.writePixels( - header, palette, transparency, unfiltered, - header.width, header.height, 0, 0, 1, 1 - ) - else: + block: + result.setLen(header.width * header.height) const startXs = [0, 4, 0, 2, 0, 1, 0] startYs = [0, 0, 4, 0, 2, 0, 1] @@ -516,32 +627,24 @@ proc decodeImageData16( if idats.len == 0: failInvalid() - result.setLen(header.width * header.height) - - let 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( + idatSlices(data, idats), header, + proc (y: int, row: seq[uint8]) {.gcsafe, raises: [PixieError].} = + image.writePixels16( + header, transparency, row, + header.width, 1, 0, y, 1, 1 + ) + ) + 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) - let unfiltered = unfilter( - uncompressed.cstring, - uncompressed.len, - header.height, - rowBytes, - header.filterBytesPerPixel - ) - result.writePixels16( - header, transparency, unfiltered, - header.width, header.height, 0, 0, 1, 1 - ) - else: + block: + result.setLen(header.width * header.height) const startXs = [0, 4, 0, 2, 0, 1, 0] startYs = [0, 0, 4, 0, 2, 0, 1] @@ -586,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() @@ -599,16 +702,68 @@ 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: + 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 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].} = + ## 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) + + var + rowRgbx = newSeq[ColorRGBX](png.width) + sampler = initRowBoxSampler( + png.width, png.height, target.width, target.height, fit) + + 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 +): Image {.raises: [PixieError].} = + ## Converts a PNG into an Image scaled to the requested dimensions. + validateScaledPngTarget(width, height) + result = newImage(width, height) + png.fillImage(result, fit) proc decodePngDimensions*( data: pointer, len: int @@ -638,13 +793,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: @@ -744,20 +937,641 @@ 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( + structure: PngStructure, + target: Image, + fit: ScaledDecodeFit, + inflate: InflateRunProc +) {.raises: [PixieError].} = + ## 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 * 8 + + target.width.int64 * 4 * 8 * 2) + + var + rowPixels = newSeq[ColorRGBA](header.width) + 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 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 + ) + 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], + 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*( + data: pointer, len, width, height: int, fit = fitStretch +): Image {.raises: [PixieError].} = + ## Decodes the PNG data into an Image scaled to the requested dimensions. + validateScaledPngTarget(width, height) + let data = cast[ptr UncheckedArray[uint8]](data) + let structure = parsePngStructure(data, len) + if structure.header.canStreamScaled: + result = newImage(width, height) + decodePngScaledIntoStreaming(idatSlices(data, structure.idats), 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. 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(idatSlices(data, structure.idats), structure, target, fit) + else: + decodeWholePng(data, structure).fillImage(target, fit) + +proc decodePngScaled*( + 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, fit) + +proc decodePngScaledInto*( + data: string, target: Image, fit = fitStretch +) {.inline, raises: [PixieError].} = + ## Decodes the PNG data into an existing Image. + decodePngScaledInto(data.cstring, data.len, target, fit) + +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 + ## 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 + +proc decodePngScaledInto*( + data: var string, target: Image, fit = fitStretch +) {.raises: [PixieError].} = + ## 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 + +# ------------------------------------------------------------------------ +# 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) + +# ------------------------------------------------------------------------ +# 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* = 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). + +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].} = @@ -887,12 +1701,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 9b3d0b23..319a0519 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 @@ -137,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 @@ -163,6 +163,154 @@ 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 + + target.width.int64 * 4 * 8 * 2) + + # See decodeP6Data for the maxVal multiplier reasoning + let valueMultiplier = (255 / header.maxVal).float32 + + var + rowBytes = newSeq[uint8](rowBytesLen) + rowPixels = newSeq[ColorRGBX](header.width) + sampler = initRowBoxSampler( + header.width, header.height, target.width, target.height, fit) + + for fileY in 0 ..< header.height: + # 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 not sampler.wantsRow(fileY): + continue # Outside the fitted crop; skip the pixel conversion too + + 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 + ) + + 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/src/pixie/fileformats/qoi.nim b/src/pixie/fileformats/qoi.nim index 4fce389b..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: var seq[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/svg.nim b/src/pixie/fileformats/svg.nim index 047566ee..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,22 +920,40 @@ 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 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 +979,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 +1000,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/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/fileformats/webp.nim b/src/pixie/fileformats/webp.nim index 10059903..2a90050a 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,177 @@ 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..<sx1` x `sy0..<sy1`, at least 1x1). On downscale + ## axes the footprints tile the crop — a proper area filter; an upscale + ## axis degenerates to the same 1x1 nearest pick the sampler always made. + for dstY {.inject.} in rects.dstY ..< rects.dstY + rects.dstH: + let + relY = dstY - rects.dstY + sy0 {.inject.} = min( + rects.srcY + (relY * rects.srcH) div rects.dstH, srcHeight - 1) + sy1 {.inject.} = max(sy0 + 1, min( + rects.srcY + ((relY + 1) * rects.srcH) div rects.dstH, srcHeight)) + for dstX {.inject.} in rects.dstX ..< rects.dstX + rects.dstW: + let + relX = dstX - rects.dstX + sx0 {.inject.} = min( + rects.srcX + (relX * rects.srcW) div rects.dstW, srcWidth - 1) + sx1 {.inject.} = max(sx0 + 1, min( + rects.srcX + ((relX + 1) * rects.srcW) div rects.dstW, srcWidth)) + body + +proc frameToTargetRect( + frame: Vp8Frame, alpha: seq[uint8], target: Image, fit: ScaledDecodeFit +) = + ## Converts the fitted rect of `target` straight from the YUV planes; the + ## full-size RGBA image never exists. Each target pixel box-averages its + ## source footprint in the premultiplied domain — what a smooth resize of + ## the materialized image would have done — so downscales stay clean + ## instead of aliasing. 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 + 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: 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) + 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 + ) + +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/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/src/pixie/images.nim b/src/pixie/images.nim index c4ec9cf5..148b5cf6 100644 --- a/src/pixie/images.nim +++ b/src/pixie/images.nim @@ -54,26 +54,85 @@ 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) + +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. + ## + ## 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: + 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.data.len) + # 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 +158,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 +336,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 +540,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 +584,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 +599,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 +641,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 +710,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/inflatestream.nim b/src/pixie/inflatestream.nim new file mode 100644 index 00000000..011c2bec --- /dev/null +++ b/src/pixie/inflatestream.nim @@ -0,0 +1,621 @@ +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].} + + 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 + + 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 + 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 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 + 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 = + 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 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 + ## 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.} = + # 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: + b.fillBitBufferSlow() + +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 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 + 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, + b: var BitStreamReader +) = + var + s = InflateStreamState(buf: newString(inflateBufferSize), onData: onData) + 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 uncompressZlibFrom( + onData: InflateOnData, + b: var BitStreamReader +) {.raises: [PixieError].} = + 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") + + 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, 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, + 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/src/pixie/internal.nim b/src/pixie/internal.nim index a4e9938b..f2f6cd4e 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. @@ -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. @@ -95,7 +129,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..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,11 +104,11 @@ 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 -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 @@ -186,41 +196,51 @@ 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 - - toPremultipliedAlphaAvx2(image.data) + 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 @@ -228,90 +248,97 @@ proc applyOpacityAvx2*(image: Image, opacity: float32) {.simd.} = 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 + 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 ..< 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 + 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 8455ed0a..96dd1719 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.} = @@ -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] @@ -63,7 +69,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 +77,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,16 +95,20 @@ 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 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) # 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 +118,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,16 +130,16 @@ 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 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,42 +199,52 @@ 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.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 - - let - vec255 = vmovq_n_u8(255) - iterations = image.data.len 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.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 - - toPremultipliedAlphaNeon(image.data) + 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 @@ -232,58 +252,66 @@ proc applyOpacityNeon*(image: Image, opacity: float32) {.simd.} = return if opacity == 0: - fillUnsafeNeon(image.data, rgbx(0, 0, 0, 0), 0, image.data.len) + 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.data.len 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 - - 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 + var + i = spanStart + p = cast[uint](image.data[i].addr) + + 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.data.len 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 ..< 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 + var + i = spanStart + p = cast[uint](image.data[i].addr) + + 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 35c7333b..0185cbd1 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.} = @@ -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,16 +151,16 @@ 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 -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 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,45 +236,55 @@ 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 - - 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 + image.forEachSpan: + let spanEnd = spanStart + spanLen - 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 + 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 - toPremultipliedAlphaSse2(image.data) + 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 @@ -272,93 +292,100 @@ proc applyOpacitySse2*(image: Image, opacity: float32) {.simd.} = 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 new file mode 100644 index 00000000..3f5636a7 --- /dev/null +++ b/tests/bench_view_cost.nim @@ -0,0 +1,82 @@ +## 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) +) + +# 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/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" 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 00000000..cceec694 Binary files /dev/null and b/tests/fileformats/jpeg/masters/f1-exif-ii.jpg differ 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 00000000..aa0d909c Binary files /dev/null and b/tests/fileformats/jpeg/masters/f2-exif-ii.jpg differ diff --git a/tests/fileformats/jpeg/masters/f3-exif-ii.jpg b/tests/fileformats/jpeg/masters/f3-exif-ii.jpg new file mode 100644 index 00000000..3ca7afa6 Binary files /dev/null and b/tests/fileformats/jpeg/masters/f3-exif-ii.jpg differ 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 00000000..5a9a4ac4 Binary files /dev/null and b/tests/fileformats/jpeg/masters/f4-exif-ii.jpg differ 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 00000000..210acf0a Binary files /dev/null and b/tests/fileformats/jpeg/masters/f5-exif-ii.jpg differ diff --git a/tests/fileformats/jpeg/masters/f6-exif-ii.jpg b/tests/fileformats/jpeg/masters/f6-exif-ii.jpg new file mode 100644 index 00000000..7903fe56 Binary files /dev/null and b/tests/fileformats/jpeg/masters/f6-exif-ii.jpg differ 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 00000000..f2872d93 Binary files /dev/null and b/tests/fileformats/jpeg/masters/f7-exif-ii.jpg differ diff --git a/tests/fileformats/jpeg/masters/f8-exif-ii.jpg b/tests/fileformats/jpeg/masters/f8-exif-ii.jpg new file mode 100644 index 00000000..1a1a07da Binary files /dev/null and b/tests/fileformats/jpeg/masters/f8-exif-ii.jpg differ diff --git a/tests/test_base64.nim b/tests/test_base64.nim index 9b88c74d..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.data == image.data + 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.data == image.data + doAssert decoded.pixelsEqual(image) block: try: diff --git a/tests/test_bmp.nim b/tests/test_bmp.nim index 4f3f0d5d..607c9c22 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)) @@ -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.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.data == image.data +# doAssert image2.pixelsEqual(image) block: for bits in [32, 24]: @@ -109,4 +109,120 @@ block: encoded = encodeDib(image) decoded = decodeDib(encoded.cstring, encoded.len, true) - doAssert image.data == decoded.data + doAssert image.pixelsEqual(decoded) + +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.pixelsEqual(expected), 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.pixelsEqual(expected), + 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.pixelsEqual(expected) + + # 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.pixelsEqual(expected) + +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.pixelsEqual(expected) + let target = newImage(64, 48) + discard decodeImageScaledInto(data, target, fitCover) + doAssert target.pixelsEqual(expected) + +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_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 680e4044..44777399 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 @@ -6,3 +7,141 @@ 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 + +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.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) + +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", 18) + ]: + 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_png.nim b/tests/test_png.nim index 2138cd9a..3a07e6a6 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, pixie/inflatestream, pngsuite, strformat, strutils, crunchy, flatty/binny when defined(writeImages): import write_images @@ -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] @@ -55,3 +78,257 @@ block: decodeImageDimensions(readFile("tests/fileformats/png/mandrill.png")) doAssert dimensions.width == 512 doAssert dimensions.height == 512 + +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.dataLen * 4) + + # 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] + + # 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) + # 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 + 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.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.dataLen: + 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. + 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.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.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.dataLen: + 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.dataLen * 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.dataLen: + 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.dataLen: + 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 + +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.dataLen * 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.dataLen: + 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.dataLen: + 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 diff --git a/tests/test_ppm.nim b/tests/test_ppm.nim index 6721bd24..a47b6c08 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,93 @@ 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.pixelsEqual(full) + + # 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 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: + 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.pixelsEqual(full16) + + # 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 diff --git a/tests/test_svg.nim b/tests/test_svg.nim index 13b22bd8..9eee0173 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.pixelsEqual(actual) + + 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.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)) + 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.dataLen <= 25, + &"renderInto differs on {differing * 100 div fused.dataLen}% of pixels" 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" diff --git a/tests/test_webp.nim b/tests/test_webp.nim index 0fd110f5..84885314 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,123 @@ 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 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 + "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 + 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 + 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: + # 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 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