Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9d06030
Add low-memory JPEG decode path
mariusandra Jun 14, 2026
2d99f1d
png scaling
mariusandra Jul 1, 2026
28406a5
Decode scaled JPEGs with bounded memory
mariusandra Jul 1, 2026
6392050
Memory-aware decoding: runtime budgets, streaming JPEG source, fit modes
mariusandra Jul 2, 2026
f4e272a
jpeg: tolerate window slide in streaming resync
mariusandra Jul 2, 2026
853485d
png: unfilter non-interlaced scanlines in place
mariusandra Jul 2, 2026
36b150b
png: stream scanlines out of the inflate window
mariusandra Jul 2, 2026
4bbd30b
Require zippy by name; the app pins the fork revision
mariusandra Jul 2, 2026
075da8d
Vendor the streaming zlib inflate; depend on stock zippy
mariusandra Jul 2, 2026
4913417
png: inflate multi-IDAT streams as segments, no concatenation
mariusandra Jul 13, 2026
8b6b6e7
png: decode from segmented sources
mariusandra Jul 13, 2026
17512ae
png: file-backed streaming decode via pull sources
mariusandra Aug 8, 2026
46d64bc
jpeg: fix EXIF orientation for little-endian (II) files
mariusandra Aug 8, 2026
bcd7df9
Share one pull-source type across the streaming decoders
mariusandra Aug 8, 2026
a722f64
bmp, ppm: streaming scaled decode with bounded memory
mariusandra Aug 8, 2026
b31eefe
svg: rasterize into a caller-supplied image
mariusandra Aug 11, 2026
8853036
WIP: image views — type change done, whole-image ops still to convert
mariusandra Aug 11, 2026
1662ee2
Add forEachSpan: the seam that keeps flat ops fast for owners and cor…
mariusandra Aug 11, 2026
f3dd8ed
Images can borrow another image's pixels instead of copying them
mariusandra Aug 11, 2026
e80a745
Name the pixel comparison pixelsEqual, not ==
mariusandra Aug 11, 2026
f80412d
Iterating an image's pixels needs an iterator now that data is a pointer
mariusandra Aug 11, 2026
76c0ca0
Count the upsample peak and the output image in the JPEG decode plan
mariusandra Aug 12, 2026
f7699d4
WebP joins the memory-aware decode family
mariusandra Aug 12, 2026
1176f44
Scaled decodes get a box filter; the sample walk stops wobbling
mariusandra Aug 12, 2026
912f718
Interpolate box-downsampled chroma instead of nearest-picking it
mariusandra Aug 12, 2026
78f54c3
Row-streamed decoders get the box filter: PNG, BMP, PPM
mariusandra Aug 12, 2026
110778c
Mark Image acyclic: views made it look cyclic to ORC and that crashes…
mariusandra Aug 16, 2026
9baf385
SVG <text> renders as glyph outlines
mariusandra Aug 16, 2026
73412a4
README: say what this fork is and what it adds
mariusandra Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,84 @@
<img src="docs/banner.png">

# 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.** `<text>` and `<tspan>`
become glyph outlines and then ordinary paths, so fill, stroke, gradients,
opacity and transforms apply to them exactly as to a `<path>`; font-family
resolution is the application's to answer through `setSvgTypefaceResolver`,
since pixie ships no fonts. `parseSvgXml` parses markup the way `<text>` 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.
Expand Down
171 changes: 169 additions & 2 deletions src/pixie.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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].} =
Expand All @@ -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].} =
Expand Down Expand Up @@ -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)
Expand Down
Loading