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:
+ # , , , : 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 `` 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 ``.
+ 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 ``, where the space
+ ## in `… 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(" 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.. 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 ``, 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
+ ## `` 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
+
+## 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(
+ &"""""",
+ width, height
+ ))
+
+block: # The baseline sits on y, and x starts the text
+ let
+ image = render("""Hg""")
+ 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("""anchor""").inkBounds()
+ middle = render("""anchor""").inkBounds()
+ finish = render("""anchor""").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("""Ay""").inkBounds()
+ hanging = render("""Ay""").inkBounds()
+ middle = render("""Ay""").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("""one two three""")
+ 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("""one twothree""")
+ 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("""firstsecond""")
+ 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("""x""")
+ 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("""x""")
+ 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("""size""").inkBounds()
+ unitless = render("""size""").inkBounds()
+ pt = render("""size""").inkBounds()
+ inherited = render("""size""").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("""
+
+
+
+
+
+ grad
+ """)
+ 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("""a
+ & b c""")
+ doAssert image.inkBounds().w > 0
+
+block: # Unsupported children are dropped, not fatal
+ let image = render("""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(&"""""")
+ 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