Sans-I/O image codecs for z2d surfaces: BMP in and out, with an optional fork+seccomp sandbox for the decoders
  • Zig 96.1%
  • Python 3.1%
  • Nix 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 7f5fe621a1
All checks were successful
test / nix (push) Successful in 17m46s
test / test (push) Successful in 22m14s
test / docs (push) Successful in 4m53s
Carry a WebP's colour profile and orientation
The last of the container: `ICCP` and `EXIF` reach
`DecodeOptions.icc` and `DecodeOptions.orientation`, the same
out-parameters JPEG and PNG already fill.

What makes WebP different is where the two chunks are. `ICCP` comes
before the image data, so the walk that reads the header meets it on
its way past. `EXIF` comes *after* --- that is what the format says ---
and `probe` stops at the first image chunk, which is what makes it
cheap. So `probe` can never report a WebP's orientation and reports
`.top_left` for every one of them, and only a `decode` that was asked
for the orientation walks past the picture to look.

That walk is the only place in this library that keeps reading a
container after it has the image, so it is gated on somebody wanting
what is out there: stepping over a chunk on a stream means reading it,
and reading to the end of an animation to learn which way up it goes
is a poor trade for a caller who never asked. Nothing is decoded by it
--- an `ANMF` is a chunk like any other to a walk that does not
descend into it --- so it costs the bytes it discards and no more.

`Sink` is what carries the two out-parameters into the walk, and it is
separate from `DecodeOptions` because `probe` has no allocator and
passes null: with nowhere to put a chunk, the chunk is not read at
all.

The fixtures are written by `webpmux -set`, so which side of the image
data each chunk lands on is libwebp's answer rather than this
project's --- a fixture that got that wrong would prove the decoder
right about the wrong file. Pillow confirms the profile and the
orientation come back out of all three. There is a fourth carrying an
`ICCP` that is not a profile, because the rule for metadata is that a
malformed block is dropped and never an error: the picture still
decodes and the caller is handed nothing, rather than sixty-four bytes
of nonsense to write into the next file.

Also fixes a heading in the README that had been written twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdXttGjXJxdbBPZQQNyR6h
2026-09-20 00:14:41 -05:00
.forgejo/workflows Check the decoders against the wuffs corpus, without vendoring it 2026-09-18 13:33:03 -05:00
corpus Read lossy WebP, and the alpha that travels beside it 2026-09-19 23:23:23 -05:00
LICENSES Sans-I/O image codecs for z2d surfaces, starting with BMP 2026-09-17 15:38:37 -05:00
src Carry a WebP's colour profile and orientation 2026-09-20 00:14:41 -05:00
tests Carry a WebP's colour profile and orientation 2026-09-20 00:14:41 -05:00
tools Carry a WebP's colour profile and orientation 2026-09-20 00:14:41 -05:00
.gitignore Write lossless WebP 2026-09-19 15:07:58 -05:00
build.zig Take the container and the lossy bitstream out of this repository 2026-09-19 14:40:35 -05:00
build.zig.zon Read lossy WebP, and the alpha that travels beside it 2026-09-19 23:23:23 -05:00
build.zig.zon.nix Read lossy WebP, and the alpha that travels beside it 2026-09-19 23:23:23 -05:00
flake.lock Sans-I/O image codecs for z2d surfaces, starting with BMP 2026-09-17 15:38:37 -05:00
flake.nix Read WebP: the container and the lossless bitstream 2026-09-19 11:00:29 -05:00
package.nix Sans-I/O image codecs for z2d surfaces, starting with BMP 2026-09-17 15:38:37 -05:00
README.md Carry a WebP's colour profile and orientation 2026-09-20 00:14:41 -05:00
REUSE.toml Write lossless WebP 2026-09-19 15:07:58 -05:00

z2dimg

Image files in and out of z2d surfaces, for Zig 0.16. It reads and writes through a *std.Io.Reader and a *std.Io.Writer you supply and performs no I/O of its own — and because the decoders therefore make no system calls at all, one can be run in a forked process that seccomp has reduced to four of them.

const z2dimg = @import("z2dimg");

var buf: [4096]u8 = undefined;
var file_reader = file.reader(io, &buf);

var surface = try z2dimg.decode(gpa, &file_reader.interface, .{});
defer surface.deinit(gpa);

and back out again:

try z2dimg.encode(gpa, &surface, &file_writer.interface, .{ .png = .{} });

The tag of the options is the format, so there is no separate format argument to contradict them.

reading writing
BMP — Windows and OS/2 bitmaps yes, including embedded JPEG and PNG yes
PNG yes, including APNG yes, including APNG
JPEG yes baseline and progressive
XBM — X BitMap yes yes
XPM — X PixMap yes yes, without quantising
GIF yes, animated yes, animated, without quantising
TGA — Truevision yes, every type true colour, optionally run-length encoded
PBM PGM PPM PAM — Netpbm yes, all seven layouts yes, plain or raw
PCX — ZSoft PC Paintbrush yes, every shape 24-bit
ICO CUR — Windows icons yes, both entry shapes several images
WebP lossless and lossy, still and animated lossless, still

A format that is recognised but has no codec fails with error.Unsupported, never with error.UnknownFormat: the library can tell a PNG from a stray file and says so.

EXIF orientation is read from JPEG, PNG and WebP, and written to JPEG and PNG; nothing else about EXIF is read or written. See Orientation. ICC colour profiles are carried between JPEG and PNG without being interpreted, and read out of a WebP; see Colour profiles.

The API documentation is generated from the doc comments, which carry most of the explanation of why each format is the shape it is.

Where this lives

The repository lives in three places that carry the same history. The Forgejo instance at https://git.jcollie.dev/jeff/z2dimg is the web-visible one:

$ git clone https://git.jcollie.dev/jeff/z2dimg.git

it is mirrored on Tangled at https://tangled.org/jcollie.dev/z2dimg, and it is also on the Radicle network, where the repository's identifier is

rad:z3DUKtY1MbjoYCPVf9VFuhmmf2Gh4

and rad clone rad:z3DUKtY1MbjoYCPVf9VFuhmmf2Gh4 fetches it from any node that seeds it. Any of the three is the whole project.

Adding it to a project

$ zig fetch --save git+https://git.jcollie.dev/jeff/z2dimg.git
const z2dimg_dep = b.dependency("z2dimg", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("z2dimg", z2dimg_dep.module("z2dimg"));

z2d comes with it, pinned in build.zig.zon, and the surfaces this library hands back are ordinary z2d surfaces — draw on them, composite them, hand them to a Context.

Sans-I/O

Nothing here opens, closes, reads or writes a file, and nothing here takes an Io. A decoder is handed a *std.Io.Reader and an encoder a *std.Io.Writer, and where those get their bytes is the calling program's business: a file, a socket, a slice already in memory, a decompressor, a test fixture.

That buys three things. The library is indifferent to which Io implementation the program chose — threads, fibers, io_uring — because it never performs I/O to need one. It is testable without a filesystem: every test in it runs against Io.Reader.fixed over a byte slice, which is why they are fast and why the fuzz loop gets through a few million inputs a minute per target. And the decoders are pure, which is the part that matters most: a decoder here is a function from bytes to pixels that makes no system call of any kind. That is what makes the sandbox below possible, and cheap.

The one obligation it puts on a caller is that the reader's buffer must be at least z2dimg.min_buffer_len bytes — eight — so that the format signature can be examined without being consumed. A reader with a smaller one is an error rather than a panic.

Limits

A header is a handful of bytes an attacker wrote, and one of the numbers in it is how much memory to allocate. Being sans-I/O makes that worse rather than better: there is no file on disk whose size bounds the claim, only a stream that may be arbitrarily long or may be a pipe that never ends. So the bound comes from the calling program, which is the only party that knows what it is willing to spend.

var surface = try z2dimg.decode(gpa, reader, .{
    .limits = .{ .max_width = 4096, .max_height = 4096, .max_pixels = 1 << 22 },
});

The defaults are sized for a program showing images to a person: nothing a camera or a screenshot produces is refused, and a malicious file cannot ask for a terabyte. max_pixels is the one that matters, because width and height can each be modest while their product is not, and it is the product that gets allocated. Something decoding avatars off the network should lower all three a long way; Limits.unlimited exists for a batch converter with a machine to itself.

probe is the cheap half of this: it reads the header, says how large the image is and what it will decode to, and stops.

const info = try z2dimg.probe(reader, .{});
if (info.pixelCount() > budget) return error.TooBig;

It consumes the header, since a *Io.Reader cannot be rewound. To probe and then decode the same stream, use a codec's two-step decoder — bmp.Decoder, whose init reads the headers and whose decode goes on from there.

Premultiplied alpha

Worth knowing about, because it is silent when it goes wrong.

Every image format stores straight alpha: a half-transparent red is (255, 0, 0, 128), the colour at full strength alongside the coverage that applies to it. z2d's compositor wants premultiplied alpha, where the same pixel is (128, 0, 0, 128). Handing a straight-alpha value to z2d does not fail; it composites wrongly, and the symptom is a halo of too-bright colour around anything semi-transparent.

So the conversion happens exactly once, in z2dimg.pixels, and no codec in this library writes to a surface buffer directly. pixels.Rgba8 is the straight-alpha value codecs speak, and it is deliberately not one of z2d's pixel types: keeping them distinct is what stops the two being mixed up by the type system staying quiet. The multiplication itself is delegated to z2d, so this library's rounding is z2d's rounding by construction.

One consequence is worth stating plainly: a decode-then-encode round trip is not byte-exact for an image with alpha. Premultiplying and demultiplying are truncating divisions, so a channel comes back within two of where it started, and at very low alpha the straight-alpha value it is reconstructed into can differ wildly — at an alpha of 2 a straight channel carries barely one bit of real information. Opaque images round-trip exactly.

BMP

The simplest of the three formats and the least tidy. Not one format but a family: a fourteen-byte file header unchanged since 1990, then a device-independent bitmap header that exists in eight sizes, where the size is the only thing that says which one it is.

All of it is read:

  • HeadersBITMAPCOREHEADER (12), both OS22XBITMAPHEADERs (16 and 64), BITMAPINFOHEADER (40), V2 (52), V3 (56), V4 (108) and V5 (124).
  • Depths — 1, 4, 8, 16, 24 and 32 bits per pixel, indexed below 16.
  • EncodingsBI_RGB, BI_RLE8, BI_RLE4, BI_BITFIELDS and BI_ALPHABITFIELDS, with arbitrary channel masks at 16 and 32 bits.
  • Both row orders, bottom-up and top-down.
  • BI_JPEG and BI_PNG, which are not bitmaps at all — see below.

What is refused, as error.Unsupported rather than guessed at, is the OS/2 headers' own compression values 3 and 4, which are a 1-bit Huffman encoding and a 24-bit run-length encoding. Those numbers are BI_BITFIELDS and BI_JPEG to Windows, so which encoding a 4 means depends on which header it is in; the two tables are chosen between rather than tried in turn, because trying Windows first makes the OS/2 reading unreachable whenever the Windows one happens to be invalid.

A BMP that is really a JPEG or a PNG

BI_JPEG and BI_PNG do not describe pixels. The bytes where the pixel data would be are a complete JPEG or PNG file, and everything else the header says — depth, masks, palette — means nothing; the depth field is zero. Microsoft added them so a printer driver could hand a printer an image it already had, and says plainly that they are not valid for a bitmap in a device context. So they are for spooling rather than displaying, and almost nothing writes one.

They are read by handing the file to the decoder that can read it, which this library happens to have. The caller's options pass through, so an embedded JPEG's orientation and colour profile come out of a BMP exactly as they would out of the same JPEG alone, and an embedded PNG keeps its alpha. Nothing recursive can come of it: the only two formats a BMP may embed are the two that cannot embed anything.

One wrinkle is worth stating because it is visible from the outside. probe answers from the BMP header — it is all that can be known without reading into the embedded file, and a stream cannot be rewound, so reading further would spend the bytes the embedded decoder needs. decode therefore holds the embedded image to the dimensions the header claimed and refuses a file where they disagree, so that the two can never say different things about one file.

Writing one is not offered. A JPEG inside a BMP is a spooler's format, and a caller who wants a JPEG can ask for a JPEG.

A few decisions a reader may want to disagree with, each made where the specification is silent and implementations differ:

  • The fourth byte of an RGBQUAD palette entry is reserved and is not treated as alpha, however much it looks like one. Files written by tools that thought otherwise exist, and honouring it would turn every one of their images transparent.
  • A 32-bit BI_RGB image is opaque unless a V3-or-later header supplies an alpha mask of exactly 0xff000000. BI_RGB fixes the colour channels at the top three bytes, so that is the only alpha mask consistent with it; any other value is a header contradicting itself, and fuzzing turns up masks sitting on top of green within seconds.
  • The palette is bounded by where the pixel data starts, not only by what the header asks for. A BITMAPCOREHEADER has no palette-count field at all, so its palette is defined as the full 1 << bpp entries — yet files exist whose bfOffBits leaves room for four, and reading 256 from one of them runs off the end of an image that is otherwise perfectly good.
  • Palette indices past the end of a short palette are opaque black rather than an error; they turn up in real files.
  • An RLE stream that stops without its end-of-bitmap escape is accepted and what was decoded is kept. Truncated RLE bitmaps are common, and the alternative is throwing away an image that is all there but for its last two bytes.

On the way out, the default follows the surface: one with an alpha channel becomes a 32-bit BI_BITFIELDS image under a BITMAPV4HEADER — a bare BITMAPINFOHEADER has nowhere to say that the top byte is alpha, and readers given one are entitled to ignore it — and one without becomes 24-bit BI_RGB under a BITMAPINFOHEADER.

depth overrides that, which matters when the reader on the other end parses a fixed header rather than a format:

try z2dimg.encode(gpa, &surface, writer, .{ .bmp = .{
    .depth = .rgb24,
    .background = .rgb(0, 0, 0),
} });

.rgb24 always writes the oldest and most universally understood BMP there is: a 54-byte header and three bytes a pixel. The Elgato Stream Deck Original and Mini take exactly that and nothing else, and since a z2d Context draws into a premultiplied RGBA surface, auto would hand them a V4 header they do not read; a test pins those 54 bytes against the reference image from python-elgato-streamdeck so that it cannot drift. .rgba32 forces the other direction.

Transparency that the chosen depth cannot hold is composited onto background rather than discarded, because discarding is almost never what anyone means: a red at a tenth opacity has colour channels saying "red", and dropping the alpha turns a faint tint into a solid block. The default background is black, which for a premultiplied surface is exactly the value already stored, so the common case costs nothing and loses nothing.

The encoder allocates nothing: one row is held on the stack at a time.

PNG

Where BMP is a header and then the pixels, PNG is a container: a signature and then length-tagged, CRC-checked chunks. Inside IDAT is a zlib stream, and inside that is not the image but a filtered form of it, every scanline prefixed by a byte naming one of five predictors. That is where most of PNG's compression comes from — the filters turn a gradient into a run of near-zero bytes and deflate encodes those to almost nothing.

All of it is read: every colour type, every bit depth each of them allows (1, 2, 4, 8 and 16), all five filters, both interlace methods, PLTE, tRNS, and the CRC on every chunk including the last. Sixteen-bit samples are scaled down to the eight a z2d surface holds. Ancillary chunks are skipped with their checksums verified on the way past; an unrecognised critical chunk is refused, which is what the case of a chunk's first letter is there to tell a decoder.

The decode is a three-deep stack of readers, and nothing buffers the compressed image:

  the caller's reader        bytes of the file
    └─ IdatReader            the IDAT payloads, concatenated, CRCs checked
         └─ flate.Decompress the zlib stream, inflated
              └─ scanlines   a filter byte and a row, over and over

The zlib stream is split across however many IDAT chunks the encoder felt like, and the split can fall in the middle of a deflate symbol, so reassembling it first would be the obvious approach and would mean holding the whole compressed image. IdatReader is about forty lines and removes the need.

Reading as far as the first IDAT before reporting anything is deliberate. IHDR does not say whether an image has transparency: a greyscale or truecolour image with no alpha channel can still be transparent through a tRNS chunk sitting between the two. A decoder that chose its surface from IHDR alone would pick one with nowhere to put the alpha and drop it in silence — which is exactly what an early version of this did, until a fixture caught it.

On the way out: eight-bit truecolour with alpha, eight-bit truecolour without, or eight-bit greyscale-with-alpha for a surface that has only an alpha channel. That is lossless for every surface z2d has. filter chooses a predictor or .adaptive to pick one per row by the heuristic the specification suggests, and level trades size against time.

try z2dimg.encode(gpa, &surface, writer, .{ .png = .{
    .level = .best,
    .filter = .adaptive,
} });

JPEG

The only one of the three that is a compression format rather than a container around pixels. BMP stores samples; PNG predicts a sample from its neighbours and deflates the difference; JPEG throws information away — converts to a colour space where the eye is least sensitive to what is about to be lost, discards three quarters of the colour resolution, transforms each 8×8 block into frequencies, divides those by a table chosen to zero the ones nobody will miss, and Huffman-codes what is left.

Read: baseline and extended sequential (SOF0, SOF1) and progressive (SOF2) at 8 bits; greyscale, YCbCr, and 4-component CMYK and YCCK as Adobe writes them; any sampling factors, so 4:4:4, 4:2:2, 4:2:0 and the unusual ones all work; restart intervals; and scans in any arrangement the format allows. Refused as error.Unsupported: arithmetic coding (SOF9 on — patented until 2003 and implemented by almost nobody), lossless and hierarchical modes, and 12-bit samples.

Two choices are worth calling out.

The decoder holds coefficients rather than streaming blocks. A baseline JPEG could be decoded a block at a time and written straight out; a progressive one cannot, because its coefficients arrive across several scans a few bits at a time and nothing can be transformed until the last scan touching it has been read. Doing it that way for baseline too costs 2 · samples bytes and buys one code path where there would otherwise be two — the second getting far less exercise while having to agree with the first about dequantisation, zig-zag order, restarts and sampling factors.

The encoder is built the same way round and for the same reason. It transforms and quantises every block before it codes any of them, which costs the same 2 · samples bytes and buys the two things that need more than one look at a block: Huffman tables measured from the image, and progressive scans, each of which carries a slice of every coefficient in it.

Chroma is upsampled by interpolation, not replication. Nothing about upsampling is normative, and replication is much simpler. It is also visibly worse — chroma follows the block grid and photographs get a faint quilting across every colour edge — and it put this decoder's worst-case disagreement with libjpeg at 55 counts. Interpolating with the weights that put sample centres where they belong reproduces libjpeg's own "fancy" upsampling, and brought that to 3.

libjpeg only has that interpolating path in two situations, and replicates otherwise, so the two implementations part company in two places — both established by experiment rather than assumed:

  • Sampling factors above two. libjpeg has an h2v1 and an h2v2 fancy upsampler and nothing else, so 4:1:1, 4:4:0-with-a-factor-of-four and the like are replicated there. Proof: for those files djpeg and djpeg -nosmooth — libjpeg with fancy upsampling switched off — produce byte-identical output, while for a 4:2:0 file they differ.
  • Images four pixels wide or narrower, where jdsample.c guards the path with downsampled_width > 2 because its edge handling needs a sample on each side.

Everything else agrees to within 3. A differential run over 120 randomly generated JPEGs found every disagreement above six counts to be a narrow image and none at all above that width, and the fixture suite carries seven files built with cjpeg at sampling factors Pillow cannot write, including 4:1:1, 3:1 and three different factors in one image. The test works out which bound applies by reading the sampling factors out of each file's frame header, so adding a fixture cannot quietly pick the wrong one.

On the way out: 8-bit YCbCr, sequential or progressive — which is to say an ordinary JPEG that every decoder in the world reads. All four chroma ratios are available, restart intervals, and Huffman tables computed from the image.

try z2dimg.encode(gpa, &surface, writer, .{ .jpeg = .{
    .quality = 90,
    .chroma = .horizontal,   // 4:2:2; also .full, .vertical, .half, .factors
    .restart_interval = 16,  // MCUs between restart markers, 0 for none
    .tables = .optimal,      // or .annex_k for the specification's examples
    .mode = .progressive,    // or .baseline
    .colour = .greyscale,    // or .ycbcr, or .auto to follow the surface
    .quant = null,           // or tables of your own, instead of `quality`
    .script = null,          // or progressive scans of your own
    .trellis = true,         // choose coefficients by cost, not by nearness
} });

A restart interval is independently decodable — it begins on a byte boundary with the DC predictors reset — so a decoder meeting corrupt data resynchronises at the next marker and loses one interval rather than the rest of the image, and one that wants to can split the scan across threads. The cost is a couple of bytes per interval and the coding efficiency given up by resetting the predictors, so a whole MCU row or two is the usual choice rather than a handful.

Sampling factors

The four named ratios cover almost every JPEG ever written. .factors is the general case, for the rest of what the decoder here reads:

.chroma = .{ .factors = .{
    .luma = .{ .h = 4, .v = 1 },   // 4:1:1, which DV and some camcorders use
} },
.chroma = .{ .factors = .{
    .luma = .{ .h = 2, .v = 2 },   // the two chroma components sampled
    .cb = .{ .h = 2, .v = 1 },     // differently from each other, which no
    .cr = .{ .h = 1, .v = 1 },     // named ratio can say at all
} },

JPEG states these as "how many blocks of this component are in an MCU", so a component sampled less finely carries the smaller number — 4:2:0 is luma 2×2 against chroma 1×1. The numbers are the reciprocal of what the name says, which is exactly why the four names are worth having.

Spelling a named ratio out produces the same file byte for byte, so the names are shorthand rather than a second path. Chroma.valid says whether a set can be written and encode refuses one that cannot; the four rules are the format's or the arithmetic's rather than this encoder's preference. Every factor is 1 to 4. Luma is sampled at least as finely as either chroma component, since chroma sampled more finely spends bits on the channel the eye is least sensitive to. Luma's factors divide exactly by each chroma component's, so a chroma sample covers a whole number of luma samples — a ratio of 3 against 2 is legal in the format and would mean averaging a fraction of a sample, which this refuses rather than approximates. And an MCU comes to ten blocks or fewer, which is the specification's own limit.

Components

colour decides how many components the file has. .auto, the default, follows the surface: one with colour becomes .ycbcr and one that has only an alpha channel becomes .greyscale. Either can be asked for outright.

.greyscale from a colour surface writes the luma and throws the chroma away, using the same coefficients that would have produced the Y of a three-component file — so the greyscale image is the colour one with the colour removed, not a different rendering of it. It saves a good deal more than a third: the chroma of a photograph is mostly flat and codes cheaply, but it still costs two tables and its share of every MCU.

.ycbcr from an alpha-only surface writes that channel as brightness with both chroma components flat. That is a grey image in three components — larger than .greyscale and carrying no more — which is why it is offered rather than refused: the caller may need three components for something downstream, and the library has no business deciding they do not.

Nothing writes CMYK or YCCK, although the decoder reads both. That is not an asymmetry to fix. A z2d.Surface is ARGB, XRGB, RGB, RGBA or alpha — there is no CMYK in it — so a caller could never hand this library CMYK data to write, and writing it would mean inventing a conversion from RGB. Without a colour profile that conversion is a guess, and a file that claims a colour space it does not have is worse than no file. The decoder reads CMYK because such files exist; writing it would mean creating them.

Quantisation tables

quant replaces the Annex K tables scaled by quality, which is then ignored. For a caller who has tables worth more than a number between 1 and 100: measured for a particular kind of image, taken from another encoder to match its output, or tuned against a quality metric.

They are given in raster order, not the zig-zag order DQT stores them in, because raster is the order the transform divides by and so the order the numbers are about; the encoder reorders them on the way into the file. An entry below 1 or above 255 is refused with error.Unsupported rather than clamped — a table silently changed is a file that does not say what the caller asked for — and Quant.valid answers the same question without encoding anything.

The scale is a line through the space these tables span. Its top is exactly a table of ones, so asking for that by hand writes the same file byte for byte as quality = 100. What the scale cannot reach is anything that moves the two tables independently: there is no quality that keeps every bit of the luma while throwing most of the chroma away, and that is the reason the option exists.

Choosing coefficients by what they cost

An ordinary quantiser rounds each coefficient to the nearest multiple of its divisor. That is the best answer to the wrong question: what a JPEG wants is the cheapest block that still looks like the original, and the cheapest is often not the nearest.

The entropy coder does not price coefficients independently. A coefficient costs a symbol naming how many zeroes precede it and how many bits its value needs — so a ±1 sitting alone among zeroes costs several bits and splits a run of zeroes, making its neighbours dearer, while the same ±1 among other non-zero coefficients costs little. And the last non-zero coefficient in a block is special: everything after it is free, dismissed by one end-of-block symbol, so moving it earlier deletes a dozen coefficients for the price of one.

trellis = true searches each block for the cheapest choice instead, by a dynamic program over which position holds the last non-zero coefficient. Nothing about the file changes: what comes out is an ordinary JPEG that any decoder reads, with different numbers in it. It costs about a sixth more time.

Whether it is worth it, honestly

The question is not whether the search makes a smaller file — anything that throws detail away does — but whether it makes a smaller file than turning the quality down to the same fidelity would. Measured against the quality scale on a 512×384 photograph, at the default price per bit:

quality searched plain, at the same RMSE
50 11273 11703 3.7%
75 20397 20645 1.2%
85 31163 31402 0.8%
95 75337 76148 1.1%

So: a real gain, largest at low quality where there is the most run structure to exploit, and a good deal smaller than the marketing around this technique suggests. Progressive mode gets the same — within a tenth of a percent at every quality tried — even though the search prices its symbols with the sequential coder's tables, which is worth knowing because there was no reason in advance to expect the estimate to hold there. Two things learned getting there are worth writing down, because both were surprises:

The theoretical price per bit is wrong. High-rate quantisation theory puts the slope of the distortion-rate curve at about 0.115 in these units, and at that price the search loses to simply lowering the quality — by 12% at quality 85 and 28% at 95. The theory assumes the quantiser sits at its rate-distortion operating point, and the Annex K tables do not: they are perceptually weighted, quantising high frequencies far more coarsely than minimising squared error would. The default of 0.02 is measured, not derived.

Refining the rate model makes it worse. The search prices symbols with the Annex K tables while the file carries tables measured from what the search chose, which is a circle: the obvious fix is to search, build the real tables, and search again with those. Measured, that costs 0.1 to 2.8 percentage points almost everywhere — so the code does one pass with Annex K, and this paragraph exists to save the next person the experiment.

Huffman tables

tables chooses where the Huffman tables come from, and .optimal is the default.

.annex_k writes the example tables of the specification's Annex K, which are the ones almost every JPEG in the world carries: they were measured once, on a set of photographs, and every encoder since has copied them. They cost nothing to produce and fit a photograph reasonably. They fit anything else poorly, and on a small image the table definitions themselves — about four hundred bytes of them — can be most of the file.

.optimal counts every symbol the scan will use and builds tables that code exactly those, by the procedure of Annex K.2, which is Huffman's algorithm with the two constraints JPEG puts on a table: no code longer than sixteen bits, and no code that is all ones, since a decoder reading past the end of the scan sees padding bits and an all-ones code would decode as one more symbol rather than running out. It is what cjpeg -optimize does, and it costs a second pass over the coefficients.

On a 512×384 image the saving is 2.9% to 9.7%, largest at low quality and at 4:2:0 where the scan is small enough for the table definitions to matter; on a 24×16 fixture it is closer to half the file. cjpeg -optimize saves 7.9% where this saves 7.7% on the same pixels at the same settings, which is the check worth making — the two implementations are finding tables of the same quality.

Against cjpeg -optimize -dct float on that image this encoder is within 0.22%, and within 0.02% at quality 75, with the same RMSE against the source to three decimal places. It was 2% to 6% larger until the quantiser was fixed; see Rounding once.

Progressive

mode = .progressive writes ten scans instead of one — six for a greyscale image — each carrying a slice of the precision or of the spectrum, so that a decoder reading the file as it arrives has a whole blurred image after the first scan and sharpens it with each of the rest. The scan script is libjpeg's jpeg_simple_progression, which is the one the format settled on: the DC coefficients less their bottom bit, then the low AC coefficients of luma, then all of chroma, then the rest of luma, then the bottom bit of everything. Chroma comes before the rest of luma because a grey image looks worse than a soft one.

Every scan measures its own Huffman tables, and tables is ignored in this mode rather than respected: the Annex K tables define no code for the end-of-block-run symbols a progressive scan is largely made of, so they are not a table a progressive file could be written with at all.

Whether it is smaller depends on the image. A band of coefficients coded on its own has statistics its own table fits well, and ten table definitions is around a kilobyte. On the 512×384 image above it is 3.2% to 4.7% larger at quality 50 and 5.9% to 6.9% smaller at quality 95 — and cjpeg -progressive moves the same way by the same amounts on the same pixels, 3.7% larger and 5.2% smaller, which is the check worth making. For a small image it is simply larger.

Scans of your own

script replaces the default. A Scan names the components it carries, the band of the spectrum in zig-zag order, and the two point transforms: al is how far right this scan shifts the coefficients, and ah is what the previous scan of the same band used, so a scan with ah other than zero is a refinement.

// Everything in one pass, with no successive approximation: the shortest
// complete script there is, and the furthest from the default.
const plain = [_]z2dimg.jpeg.Scan{
    .{ .components = &.{ 0, 1, 2 }, .ss = 0, .se = 0,  .ah = 0, .al = 0 },
    .{ .components = &.{0},         .ss = 1, .se = 63, .ah = 0, .al = 0 },
    .{ .components = &.{1},         .ss = 1, .se = 63, .ah = 0, .al = 0 },
    .{ .components = &.{2},         .ss = 1, .se = 63, .ah = 0, .al = 0 },
};

checkScript says whether a script can be written and names the first thing wrong with it; encode refuses one that cannot with error.Unsupported. Every rule it enforces is the specification's — a scan carries the DC coefficient alone or an AC band alone, an AC scan carries exactly one component, components appear in SOF order, successive approximation descends one bit at a time without skipping or repeating — except the last, which is a choice worth stating.

A script must send every coefficient down to its bottom bit. A progressive file that stops early is perfectly legal; that is what the format is for, and a decoder shows what it has. But a script that stops early is asking this encoder to throw away part of the image without saying so, and quietly writing a file that does not round-trip is worse than refusing one. There is deliberately no way to ask for an incomplete file.

The default script is not always the smallest. On the 64×48 pattern the cross-check uses, at 4:2:0, the default writes 1550 bytes where plain above writes 1408 and a nine-scan variant writes 1452 — the ten scans buy what a decoder can show early, and on a small image they cost about a tenth of the file to do it.

The verification is exact rather than approximate, and it is repeatable:

$ zig build samples
$ python3 tools/check_samples.py zig-out/samples

tools/samples.zig writes a baseline and a progressive file for each of thirteen cases — every chroma ratio, sizes down to 1×1, restart intervals, the quality extremes, greyscale — and the script decodes both with Pillow and holds them to being byte-identical. They must be: the two files carry identical coefficients and differ only in how they are spelled, so a tolerance would hide the only thing worth checking. It is a check the Zig test suite cannot make, since a Zig test cannot run Pillow, and the test suite otherwise checks this encoder against this decoder — which would agree with itself about a mistake they shared. It runs in CI.

One behaviour differs from the other two encoders and is worth knowing. A surface that has only an alpha channel becomes a single-component greyscale JPEG carrying that alpha as brightness. bmp and png can keep the coverage in an alpha channel and so write it as transparent black; JPEG has no alpha channel to keep it in, so the choice is between putting the one channel the surface has into the one channel the file has, and writing a black rectangle. Transparency on a colour surface is composited onto background, as in bmp.

The inverse DCT is written as the definition rather than as a factorisation: two 8×8 matrix multiplies, 1024 multiply-adds a block. Loeffler's factorisation would take a 1-D pass from 64 multiplies to 11, and libjpeg's jpeg_idct_islow is that in 13-bit fixed point — but a factorised IDCT is a wall of constants whose correctness is not apparent from reading it, and one that is subtly wrong makes images that look fine until they do not. This one is checked against a direct evaluation of the formula at every one of the 64 basis functions, and computed in f32 it is more accurate than what it is compared against.

Rounding once

Worth writing down, because it cost 2% to 6% of every file for a while and nothing about the output looked wrong.

The transform produced f32 coefficients and rounded them to whole numbers; the quantiser then divided those whole numbers by the table and rounded again. Rounding twice is not the same as rounding once. The first rounding's error is at most half a count, but it does not disappear — it survives the division, and whenever a coefficient's true value sits near the boundary between two quantised values, that half a count carries it across. The coefficients that move are overwhelmingly the ones that should have been zero and are now ±1, which is the expensive kind: each one breaks a run of zeroes and costs several bits of its own.

It was invisible in the obvious places. The images were right, the round-trip tests passed, and the RMSE against the source was normal — the extra coefficients are small and add detail that is not wrong, merely not worth its bytes. What showed it up was measuring size and fidelity together against cjpeg and finding the fidelity identical while the size was not: bytes buying nothing.

The diagnostic that named it was quality 100. There the divisors are all ones, rounding twice is the same as rounding once, and the gap vanished — it was negative, this encoder being 53 bytes smaller on a 110KB file. A gap that appears only when the divisors are greater than one is a gap in the division, which ruled out the transform and the entropy coder in one measurement and left nowhere else to look.

idct.forward now returns the coefficients unrounded and the quantiser rounds once, which is what libjpeg's float path does. On a 512×384 photograph the greyscale case went from 5.3% larger than cjpeg to 0.05% smaller.

TGA

Eighteen bytes of header and then the pixels, mostly. Every type it has is read: colour-mapped, true-colour and greyscale, each either uncompressed or run-length encoded, at depths of 8, 15, 16, 24 and 32 bits. Rows are bottom-up unless bit 5 of the image descriptor says otherwise, which is BMP's convention and the same source of upside-down images.

It has no signature

The file begins with its header and the header begins with a length byte, so there is nothing to match. Version 2.0 added a footer — TRUEVISION-XFILE. in the last twenty-six bytes — which is a signature at the wrong end of the file for a decoder that reads forwards and never seeks.

So detect answers .tga from a structural check, and only after every real signature has been tried: the colour map type must be 0 or 1, the image type one of the seven that exist, the depth one of the five, a file claiming no colour map must say nothing about one and a file claiming one must say something consistent, and the reserved bits of the image descriptor must be zero. That rejects essentially everything that is not a TGA while accepting every TGA, which is the best available answer — but it is a heuristic, it is documented as one, and a caller who knows what it has should say so with decodeAs.

The alpha byte that is not alpha

A 32-bit TGA has eight bits left over and whether they are alpha or padding is genuinely ambiguous. Version 2.0 added an attributes field to say which and almost nothing writes it, so there are files in the world whose fourth byte is zero throughout and which every reader that honours it renders as blank.

The rule here: the alpha bits in the image descriptor are believed, and a 32-bit image whose alpha is zero in every pixel is treated as opaque. The second part is a guess. It is the guess every other decoder makes, because the alternative is handing back nothing at all.

Writing one

True-colour, 24 bits a pixel, or 32 when the surface has transparency. No colour map, for the reason gif and xpm give. rle turns on the run-length encoding, which is off by default because on a photograph it makes the file slightly larger.

The format allows a run to cross from one row into the next; this encoder does not do that. It saves a byte or two and makes the encoder stateful across the whole image, and keeping each row self-contained is far easier to be sure of. The decoder handles runs that cross rows, because other encoders write them.

Verified against ImageMagick

Both directions, and in every type the format has: files ImageMagick wrote as uncompressed true colour, true colour with alpha, run-length encoded, colour mapped and greyscale are fixtures here with the pixels it decodes them to, and it reads this library's output — plain and run-length encoded — back identically.

The alpha fixture compares with one count of slack on the colour channels, which is worth knowing what it means: a z2d.Surface holds colours multiplied by their alpha, so a straight-alpha value read from a file, stored and read back has been through a divide and a multiply in eight bits. A red of 128 at an alpha of 128 is stored as 64 and comes back as 127. ImageMagick keeps straight alpha throughout and has no such loss. It is this library's pixel model, not a disagreement about the format.

GIF

A file is a logical screen, a palette, and then a sequence of blocks: images, extensions describing how to show them, and a trailer. The extensions are where most of the format's later life happened — the animation everybody knows it for is a graphic control extension in front of each frame and an application extension nobody standardised saying how many times to loop.

This decodes the first frame and stops. A decode here answers with one z2d.Surface, and an animation is a sequence of surfaces with delays and disposal rules between them — a different shape of answer, and one this library's API has nowhere to put. The first frame is what a still-image decoder shows and what almost every GIF in the wild contains only one of.

The surface is the logical screen, which a frame need not fill: a frame has its own position and size, and what it does not cover is left transparent rather than filled with the background colour the screen descriptor names. That index is unreliable — writers leave it zero — and transparent composites correctly whatever a caller does next.

Interlaced frames are read: four passes, every eighth row from 0, then every eighth from 4, then every fourth from 2, then every second from 1. The trap is that the passes are not all the same height, so a decoder counting rows rather than mapping them gets the bottom of the image wrong — which is why the test for it uses a height of nine rather than a multiple of eight.

The one thing about GIF's LZW

The code width grows at a different moment for the encoder and the decoder, and this is the whole of why GIF implementations are hard to get right.

An encoder assigns a dictionary entry with every code it emits. A decoder cannot complete that entry until it has read the code after it — the entry's last byte is the first byte of the next string — so the decoder's dictionary is permanently one entry behind. Both must nonetheless change width between the same two codes, so the encoder's condition is the decoder's shifted by one:

// decompressing            // compressing
next_code >= (1 << bits)    next_code > (1 << bits)

Getting this wrong on both sides at once produces a codec that agrees with itself and with nothing else in the world — which is exactly what happened here. The round-trip tests passed and a real file from ImageMagick did not, which is the only reason it was caught. That is what an external reference is for.

Two more corners have their own tests: a code that names the entry it is about to create, which is the case every LZW implementation gets wrong once, and a palette of two, where the first free code will not fit in the starting width.

Writing one

Nothing here reduces colours, for the same reason xpm does not — and GIF's 256-colour palette makes that bite sooner, since a photograph will not fit and should be a PNG or a JPEG. Transparency is an index rather than a channel, so partial alpha is rounded at half.

Verified against ImageMagick

Both directions. A file ImageMagick wrote — including an interlaced one at a height that makes the passes uneven — is a fixture here with every pixel it decodes to, and ImageMagick reads this library's output back identically.

XBM

An X BitMap is a one-bit image that is also a C source file:

#define cursor_width 8
#define cursor_height 8
static unsigned char cursor_bits[] = {
   0x3c, 0x42, 0xa5, 0x81, 0xa5, 0x99, 0x42, 0x3c };

That is the whole format. It was meant to be #included into an X client and compiled, which is why it has no header, no signature and no magic number — it is not a container at all, and everything below follows from that.

One bit a pixel, and the least significant bit of each byte is the leftmost pixel, which is the opposite of what a reader expects and the single most common mistake made with the format. Each row starts on a fresh byte. The original X10 form declared static short and padded rows to sixteen bits instead of eight; files like it still exist, so the declaration is read rather than assumed. signed char was the declaration for years, so values above 127 appear as negative numbers and are parsed as such.

Which colour a set bit is

Two widely used decoders disagree about this, which is worth knowing before comparing any of them. A set bit selects the foreground, and the foreground of a monochrome image is its ink, so this library reads a set bit as black. ImageMagick does the same: asked to draw black points at x = 0, 3 and 6 of a row it writes 0x49, which is bits 0, 3 and 6. Pillow reads that same byte as white.

Both were tried on a file holding a single set bit and they genuinely disagree. Anyone comparing this library against Pillow will find every XBM inverted, and that is neither library being broken. The test that pins it says so.

Why it is barely detected

detect returns .xbm only when the first thing in the file is #define and that define's name ends in _width. There is nothing else to look for — an XBM and any other C header begin with the same eight characters, and claiming every #include file in a source tree is an image would be far worse than not claiming any of them.

The rule is deliberately "the first thing in the file" rather than "somewhere near the start". A comment may legally precede the defines and the decoder accepts one, but a comment can be any length, so no detection window is wide enough to see past every one of them — and a detector whose answer depends on how chatty the generator was is worse than one whose rule can be stated. A file with a comment in front of its defines is read with decodeAs.

This is also why min_buffer_len is 64 rather than the 8 the binary formats would need: that is what reaches _width for any name a person would write.

Writing one

try z2dimg.encode(gpa, &surface, writer, .{ .xbm = .{
    .name = "cursor",     // becomes the C identifier, so it has to be one
    .threshold = 128,     // darker than this becomes a set bit
    .x_hot = 4,           // optional, for a cursor
    .y_hot = 4,
} });

Everything but coverage is lost — the image becomes light and dark about threshold, measured as luminance by the same coefficients the JPEG encoder uses for Y. A caller who did not mean that should be writing a PNG. Transparency counts as light: a transparent pixel has no colour to be dark, and a surface holds its colours premultiplied, so treating it by luminance alone would put a solid block where the caller meant nothing.

A name that is not a C identifier is error.Unsupported rather than quietly corrected, because a file that does not compile is not an XBM.

XPM

An X PixMap is a palette image that is also a C source file:

/* XPM */
static char * cross[] = {
"5 5 2 1",
"  c None",
". c black",
"..#..", "..#..", "#####", "..#..", "..#.."
};

Like XBM it was written to be compiled into an X client rather than opened. Unlike XBM it has colours, transparency, and a header line saying how it is shaped: width height colours chars-per-pixel, optionally with a hotspot.

Chars-per-pixel is the part with no analogue elsewhere. A pixel is n characters, so an image with three hundred colours is still plain text if two characters are spent on each pixel. The colour strings begin with exactly that many characters including spaces, which is why the commonest colour in an image is usually spelled with them, and why a parser cannot skip leading whitespace on those lines. This library reads up to four characters a pixel; the format sets no limit, but the lookup table is sized from it.

A colour string may give several colours. After the characters come pairs of a key and a value: c for colour, g for greyscale, g4 for four-level greyscale, m for monochrome, s for a symbolic name. One image can serve a colour screen and a black-and-white one. This takes c and falls back through g, g4 and m; s is a name for a colour rather than a colour and is never used.

A colour value may contain spaces. c navy blue is one colour and the only thing that ends it is another key — so the rest of the line is split into words, and a word is a key only if it is exactly one of the five and something follows it. That is what libXpm does and there is no better rule available.

The colour names

Most real XPM files name their colours rather than spelling them in hex, so src/xpm/colours.zig is X.Org's rgb.txt — 676 colours after the aliases are folded together — generated by tools/gen_x11_colours.py. X matches a name without regard to case or spaces, so navy blue, NavyBlue and navyblue are one colour and appear once.

It matters that this is X's table and not the web's: X's gray is #BEBEBE and CSS's is #808080, and there is a test pinning the difference, because a table taken from the wrong place would be right for hundreds of names and quietly wrong for that one.

A colour this cannot resolve leaves that palette entry black rather than refusing the file, and a pixel spelled with characters the palette never declared is transparent, which is what libXpm does.

Writing one

try z2dimg.encode(gpa, &surface, writer, .{ .xpm = .{
    .name = "cross",      // becomes the C identifier, so it has to be one
    .max_colours = 4096,  // more than this is refused, not quantised
} });

Nothing here reduces colours. An image with more distinct colours than max_colours is error.Unsupported rather than quantised, because quantising is a whole discipline: a palette chosen badly is worse than no file, and a caller who wants one has opinions about how it should be chosen that this library has no way to ask for. XPM is for icons and line art, where the colours are few and are exactly the ones somebody picked; a photograph should be a PNG.

Transparency is written as None, the only kind XPM has — a pixel is fully transparent or fully opaque, so partial alpha is rounded at half. The colour of what survives is un-premultiplied first: a surface holds colours multiplied by their alpha, so a mostly-opaque red is stored darker than it looks, and writing the stored value would darken every soft edge in the image.

Verified against ImageMagick

Both directions. ImageMagick reads this library's output to the same pixels, and a file ImageMagick wrote — comments, trailing space in the values line and all — is a fixture in the test suite. It is the reference for both X formats rather than Pillow, for the reason the XBM section gives.

Netpbm

Four names, seven magic numbers, one family, and between them the smallest formats here that are still formats.

magic name samples a pixel how they are written
P1 PBM one bit decimal digits
P2 PGM one decimal digits
P3 PPM three decimal digits
P4 PBM one bit packed, eight to a byte
P5 PGM one raw
P6 PPM three raw
P7 PAM one to four raw

The header is whitespace-separated numbers, and # begins a comment that runs to the end of the line. A comment may appear anywhere in the header, including between the width and the height, which is why this reads the header a token at a time rather than a line at a time — a line-oriented parser gets

P2
2 # halfway through the size
1
255
10 200

wrong, and that is a legal file.

PAM is the later, tidier one: KEY value lines ended by ENDHDR, with a DEPTH saying how many samples a pixel has and a TUPLTYPE naming what they mean. The depth is what this library believes, since the depth is what says how many bytes a pixel occupies; the tuple type only names it, and a reader that trusted the name over the count would disagree with the file about where the next pixel starts. PAM is the only one of the four that can carry an alpha channel, and is what encode writes when asked for alpha.

One is black, in exactly one of them

In PBM, and only in PBM, a set bit is black. Every other member of the family counts up from black, so a PGM sample of zero is black and maxval is white — and then PBM, the same family and the same header, inverts. PAM inverts back: a TUPLTYPE BLACKANDWHITE image with MAXVAL 1 reads zero as black like everything else, so the same two colours are written two opposite ways depending on which member of the family is carrying them.

The other half of that confusion is bit order. A packed PBM row puts the most significant bit of the first byte at the left of the image. XBM puts the least significant one there. The two formats are both one bit a pixel, both packed eight to a byte, and they disagree — so the byte 0x60 draws two dots in the middle of a four-pixel PBM row and draws nothing at all in an XBM one. Nothing in either file says which convention it follows; it is the format, and there is no way to detect a mistake from the file.

Both of those are asserted in tests/netpbm.zig against files netpbm's own tools wrote, because they are the two places where a decoder that is self-consistent can still be wrong about every file in the world.

Sixteen-bit samples

A maxval above 255 means two bytes a sample, big-endian. A z2d surface holds eight bits a channel, so those are scaled rather than shifted — (v * 255 + maxval / 2) / maxval — so that maxval itself comes out 255 exactly. A shift by eight gives 254 instead, and a whole image is then a shade too dark.

The encoder will write sixteen-bit samples if asked. It buys no precision, since the surface it is reading has eight bits; it is there because something downstream may insist on the wider form.

No alpha, and what becomes of it

PBM, PGM and PPM have no alpha channel. A surface with one is flattened onto white on the way out, since something has to be behind a transparent pixel and white is what a page is. PAM keeps the channel instead.

Writing one

try z2dimg.encode(gpa, &surface, w, .{ .ppm = .{} });              // P6
try z2dimg.encode(gpa, &surface, w, .{ .ppm = .{ .binary = false } }); // P3
try z2dimg.encode(gpa, &surface, w, .{ .pgm = .{} });              // P5
try z2dimg.encode(gpa, &surface, w, .{ .pbm = .{ .threshold = 128 } }); // P4
try z2dimg.encode(gpa, &surface, w, .{ .pam = .{} });              // P7

Each of the four is its own arm of EncodeOptions, because each has its own question to answer: a bitmap needs a threshold, a greymap and a pixmap need a maxval, and a PAM needs to know how many channels to keep. The plain forms are wrapped at seventy columns, which the specification requires.

Verified against netpbm

The fixtures in tests/netpbm_fixtures.zig are written by the netpbm tools themselves — pnmtopnm, ppmtopgm, pamditherbw, pamdepth, pamtopam, pamstack — and the pixels expected of them are read out of netpbm's own plain output rather than out of this decoder. Regenerate them with

$ nix shell nixpkgs#netpbm --command python3 tools/gen_netpbm_fixtures.py

Two of the tests go further and compare files byte for byte: the P6 and the P7 this library writes are the same bytes netpbm wrote for the same image, header included.

That this is done against another implementation rather than against a round trip is not ceremony. The GIF decoder here round-tripped its own output perfectly while disagreeing with every other implementation in the world about when the LZW code width grows; a self-consistent codec is consistent with itself and nothing else.

Animation

Animated GIF and APNG are read frame by frame, and so is any other image — a still picture is an animation of one frame, so a caller after "the frames of this file" need not first ask what the file is.

var frames = try z2dimg.decodeAnimation(gpa, &reader, .{});
defer frames.deinit(gpa);
while (try frames.next(gpa)) |frame| {
    // frame.surface  — this frame alone, un-composited
    // frames.canvasSurface() — what is on screen at this moment
}

Both halves come out, and you pay for the half you ask for. A frame is the rectangle as the file stored it: its own pixels, its own size, with nothing of any other frame in it. That is what a frame extractor or a transcoder wants, and it is emphatically not what a viewer wants — most frames of a real animation are a small patch of changed pixels that means nothing alone. gifplayer-muybridge.gif in the test corpus is 380 frames, some of them five pixels by three. The canvas is those frames drawn onto each other in order, which is the viewer's half.

Frames come out un-composited because a frame is decoded into its own surface before it can be composited — so the un-composited half is not an extra feature, it is the intermediate the other half is built from. Options.composite = false turns the canvas off, and then none is allocated and nothing is composited, which makes frame extraction the cheaper of the two.

Writing one

var enc = try z2dimg.encodeAnimation(w, .{ .gif = .{ .width = 64, .height = 48 } });
defer enc.deinit(gpa);
while (try frames.next(gpa)) |frame| try enc.frame(gpa, frame);
try enc.finish();

Frames go out exactly as they are given — the rectangle, delay, disposal and blend on each anim.Frame are what the file records. Nothing is analysed and nothing is optimised, and that is the point rather than a shortfall: it makes a decode and an encode a lossless pipe. An animation through both keeps the sub-rectangles the original had instead of becoming a stack of full-canvas pictures. A caller generating frames from scratch, with no rectangles to give, gets full frames — computing dirty rectangles for it would be a different feature.

Both formats want the canvas size before any frame, and APNG wants the frame count too, because acTL precedes the image data and a sans-I/O encoder writes forward into something it cannot seek. So those are declared up front, and finish refuses a count that did not come true — writing one this library's own decoder would reject is not a thing an encoder should be able to do.

Supply palette when writing a GIF and you know the whole animation. GIF puts its global colour table in the header, before any frame, so a streaming encoder has only the first frame to build one from — and every later frame using a colour that frame lacked must carry a table of its own, up to 768 bytes each. Transcoding muybridge.gif without one produced a file twice the size of the original, more than half of it palettes; with the union of the frames' colours passed in, the same transcode came out fifteen bytes larger than the file it started from.

APNG needs no such help: it is truecolour and has no palette to share.

Disposal, which is where these go wrong

A frame carries a rule for what happens to the canvas after that frame has been shown — leave it, clear the frame's rectangle, or put back what was underneath. Applying it at the end of the frame it belongs to rather than at the start of the next one gives the same picture for "leave it" and a canvas that is blank when it should not be for the other two. It is the classic way to get an animated GIF subtly wrong, and tests/anim.zig has a test named after it.

"Restore to background colour" is the GIF specification's name for clearing, and it is not what it means here. APNG says transparent outright, and for GIF wuffs makes honouring the declared background colour an opt-in quirk whose own documentation describes the default it departs from as "always being transparent black". This library takes that default — for disposal and for the canvas a first frame does not cover.

Pillow disagrees, and it is worth knowing which way. Pillow fills uncovered canvas and disposed rectangles with the palette entry the screen descriptor names, so a GIF whose first frame does not cover the whole screen decodes differently there than here. That divergence is deliberate and is why the GIF fixtures generated against Pillow elsewhere in this project are all single frames covering the whole screen, where the question cannot arise. The disposal expectations in tests/anim.zig were taken from Pillow for the cases where the two do agree — previous restoring what was underneath, checked against Pillow on the same bytes.

The loop count is not the number in the file

GIF's loop count excludes the first play — a file saying 2 is played three times — and a GIF with no loop count at all is played exactly once, which is a third case its own encoding cannot distinguish from "twice". APNG counts plays directly. anim.Info.loop_count is APNG's meaning for both, so a caller looping on it is right for either format instead of right for one and off by one for the other. Checked against wuffs, whose NIA footer records three for the file whose GIF extension says two.

Asking before you decode

probe says what the header knows, and it has three answers rather than two:

switch ((try z2dimg.probe(&reader, .{})).animation) {
    .none => {},           // a still picture, settled
    .unknown => {},        // the format animates; the header does not say
    .declared => |a| {},   // it said: a.loop_count, a.frames
}

The third case is the one worth having, and an optional would have hidden it. A PNG settles the question either way, because acTL is required to precede IDAT and a probe reads that far — so a PNG without one is a still picture and nothing later in the file can change that. A GIF cannot settle it at all. There is nowhere in a GIF's header for a frame count, and its loop count lives in an extension that may sit anywhere, so the only way to learn either is to walk the file — which is exactly what probe exists not to do. Saying "not animated" there would be a guess dressed as an answer, and it is the same guess for a GIF that turns out to be one frame as for one that turns out to be forty.

Where the header does commit to a number, playing the file has to produce that number; the fuzz target asserts it on every input, because a probe a caller trusted and a decode that contradicted it would be worse than a probe that said nothing.

Frames are clipped to the screen

A frame may declare itself larger than the logical screen or sitting off the edge of it, and files exist where it does. The screen is the only rectangle probe checked against the limits, so materialising a frame at its declared size would let ten bytes of header ask for four gigabytes that probe had already called reasonable. anim.Frame.surface therefore holds the part that lands on the canvas, and anim.Frame.width and .height record what the file actually asked for.

Limits gains two budgets for this: max_frames, because a frame costs ten bytes to declare and everything to decode, and max_total_pixels, summed over every frame. max_pixels bounds one picture; these bound the work, and they are different questions.

The compositing is z2d's

z2d.Surface.composite is a PorterDuff compositor, and the three operators this needs — src, src_over and clear — are exactly APNG's two blend ops and GIF's frame drawing. It also ignores the part of a source that falls outside the destination, which is the clipping rule both formats need. So src/anim.zig is a state machine over three calls rather than an implementation of blending.

APNG

Three things about APNG are worth knowing, and all three are places to get it wrong.

A frame's data is its own zlib stream, spread over fdAT chunks the way the still image's is spread over IDATs. The reader that walks chunk boundaries, checks their CRCs and hands on the compressed bytes takes a chunk type for exactly this reason.

Every fcTL and fdAT shares one sequence counter that must go up by exactly one each time. That counter is the only thing tying a frame's data to the header describing it, so a file that repeats or reorders numbers is a file that can make a decoder draw one frame's pixels into another frame's rectangle. It is checked on every chunk, and acTL's declared frame count is checked against how many actually arrive.

Whether the default image is part of the animation is not a field. It is carried entirely by whether an fcTL came before the IDAT: with one, the IDAT is frame zero; without one, the IDAT is a still picture for readers that do not know APNG and the animation is the fdAT frames alone. Getting that backwards drops or duplicates a frame.

That last one is where this library and Pillow part company on a count. artificial-png/apng-skip-idat.png puts its first fcTL after the IDAT; acTL declares two frames and two arrive. Pillow reports three, exposing as a frame a default image that wuffs' own commentary on the file says "is not displayed". The two frames this library does emit are pixel-identical to Pillow's second and third.

A frame that hangs off the edge of the canvas is refused here, where a GIF frame in the same position is clipped. That is not an inconsistency: APNG forbids it outright, and GIF files that break the equivalent rule are common enough that every decoder accepts them. The same reasoning splits the disposal values — APNG specifies three of three, so a fourth is a broken file, while GIF leaves four of its eight undefined and files use them to mean nothing.

The delay is a fraction, numerator over denominator, and a denominator of zero means hundredths of a second — so the obvious division is also a division by zero on a value real files carry.

Verified against wuffs

corpus/ holds animated-red-blue.gif and animated-red-blue.apng against animated-red-blue.nia — wuffs' animated uncompressed format, which carries the composited pixels of every frame and each frame's duration, in one file. Every frame must match exactly and every delay must match exactly, and the loop count must match the footer.

The APNG is the better test of the two, and it is worth saying why: a GIF frame is always drawn over what is there, so a GIF exercises one blend rule and, in that file, one disposal. The APNG of the same animation uses dispose-to-previous and both blend operations, so the shared state machine is checked properly by it and only sampled by the GIF.

That one comparison checks the un-composited half too, transitively: the canvas is built out of the frames this library hands back, so frames that were wrong could not composite into a canvas that is right.

The encoders are checked the same way round, through a third implementation. An animation is decoded here, written back out, and the result handed to Pillow: for both formats, and for a GIF transcoded to APNG and an APNG to GIF, Pillow's pixels are wuffs' pixels exactly. Across muybridge.gif and gifplayer-muybridge.gif — 395 frames — a transcode and the original show Pillow the same thing, pixel for pixel.

PCX

ZSoft's PC Paintbrush format, and a tour of 1980s graphics hardware. A 128-byte header, then rows of run-length-encoded bytes — and then seven different ideas of what a pixel is, because the format grew alongside the display adapters it was written for and kept a shape for each.

Two header fields decide: bits_per_pixel, which is per plane, and planes. Their product is the depth.

bits planes what it is
1 1 monochrome
1 4 sixteen colours, EGA, one bit in each of four planes
2 1 four colours, CGA
4 1 sixteen colours packed two to a byte
8 1 two hundred and fifty-six colours
8 3 true colour, a plane each for red, green and blue
8 4 true colour and an alpha plane, which almost nothing writes

The planar forms are not historical curiosities. ImageMagick still writes 1×4 for any image of sixteen colours or fewer, today. A row there is four separate bit-planes one after another, and a pixel's index is assembled a bit at a time from the same position in each — which is how an EGA addressed its memory and the reason the shape exists at all.

Two places the colours live

Sixteen colours or fewer take the 48-byte palette in the header. Two hundred and fifty-six take a 768-byte palette after the image data, behind a 0x0C marker, because a header already fixed at 128 bytes had nowhere to put one. Files carry both: ImageMagick writes a trailing 256-entry palette onto a four-plane file whose first sixteen entries repeat the header's, so a decoder should read the header one and not depend on the other being there.

A version 3 file declares that it has no palette at all, and means the sixteen default EGA colours — which are written out in pcx.default_ega because there is nowhere else to get them.

The monochrome palette that says nothing

Pillow writes a one-bit PCX with sixteen palette entries of zero, and then renders the set bit white itself. Believing that palette makes the image one flat black rectangle. A palette whose two entries are the same colour cannot tell the two values apart, so it is not a palette: for one bit in one plane this reads black and white instead. Scoped there deliberately — a four-plane file names real colours and is believed.

Runs that cross rows, and bytes that look like counts

The specification says a run does not span scanlines. Encoders exist that let one, so the image data is decoded as a single continuous stream and cut into rows afterwards, which reads both kinds and cannot desynchronise on either.

Going the other way, a byte with its top two bits set is a count, so a literal that happens to look like one has to be written as a run of one — two bytes to say what would have been one. An encoder that wrote it plain would produce a file that decodes as a run of something else entirely.

Writing one

Version 5, eight bits in each of three planes: the one shape of the seven that needs no palette and loses nothing. The paletted forms would mean quantising, which nothing here does. PCX has no alpha, so a surface with one is flattened onto white.

encoded = false writes the rows uncompressed, which the format allows — with a warning worth repeating: Pillow cannot read an unencoded PCX, and reports a buffer overrun, while ImageMagick reads one happily. The default keeps to the beaten path.

Verified against Pillow and ImageMagick

Neither tool alone writes every shape. Pillow writes the monochrome, greyscale, paletted and true-colour forms; ImageMagick writes the planar EGA one that Pillow will not. Two more — four bits packed, and a version 3 file — are written by neither and are built by hand in tools/gen_pcx_fixtures.py, which is also the only way to have them at all. For those two the expectation is the builder's rather than a reader's, since Pillow refuses both with "unknown PCX mode"; that is weaker evidence and is said so in the fixtures.

ICO and CUR

Windows icons, which are containers rather than images: a six-byte directory, an entry per image, and then the images. The entries are alternatives — the same icon at several sizes and depths for a system to pick from — so decode has to choose one. It takes the largest, breaking ties on depth, because that is what a caller asking for "the icon" means; a directory is conventionally ordered smallest first, so taking the first would usually hand back a 16×16. ico.Decoder exposes the directory for a caller who wants a particular size.

An entry holds one of two things, told apart by looking. A PNG, complete with its signature, which Vista introduced for the large sizes and which Pillow writes even at 16×16. Or a DIB — a BMP from the BITMAPINFOHEADER onwards, with no file header, since the entry already said where the image is and how long. Two things about that DIB are traps.

The doubled height

The DIB's height is twice the icon's, because the pixels are followed by an AND mask: one bit per pixel saying which are transparent, from before there was an alpha channel. Handing that header to a bitmap decoder unaltered asks it to read the mask as more rows of colour, which it cannot, the mask being one bit a pixel and the colour not.

The alpha a BMP does not have

A 32-bit DIB in an icon carries alpha in its top byte. A 32-bit BMP with a 40-byte header does not: the format says those eight bits are unused, and this library's BMP decoder reads them that way, correctly. The two disagree because the icon convention was never written into the bitmap one.

So the header is rewritten rather than reinterpreted: a 40-byte header at 32 bits becomes the 56-byte BITMAPV3INFOHEADER that says what the icon means — the same fields plus the four channel masks, alpha at the top byte — and the pixel data is untouched. Everything else about the DIB, which is to say eight header versions, every depth, the palettes, the bitfields and both run-length encodings, stays the bitmap decoder's and is not written twice.

One consequence worth stating: an entry always decodes into a surface that can hold transparency, whatever its own depth would have chosen. A four-bit bitmap has no alpha and the BMP decoder rightly gives back a surface with nowhere to put any — but an icon's transparency is in the mask, applied afterwards, and it would have had nowhere to go.

It begins like a TGA

The detection here is shaped by a collision. A TGA with no identification field and no colour map begins with two zero bytes, and its image type of 1 or 2 then sits exactly where an icon's type does — so the two formats agree in every byte either offers as a signature. The first directory entry is therefore read as well: its reserved byte must be zero, its image cannot be empty, and the image cannot begin inside the directory that points at it. Those are fields a TGA is using for its colour map and its dimensions, and they are what separate the two. The cost is that an icon is not recognised from fewer than 22 bytes, which is no loss — a directory declaring an image without room for the entry describing it is not a file anything could read.

Writing one

var enc: z2dimg.ico.Encoder = .init(.{});
defer enc.deinit(gpa);
try enc.image(gpa, &small);
try enc.image(gpa, &large);
try enc.finish(w);

Everything is held until finish, and it has to be: the directory comes first and carries each image's length and offset, neither of which is known until the image has been encoded. An icon is small and this is the only arrangement a forward-only writer allows. encode writes a single-image icon for the uniform entry point.

Images are written as 32-bit DIBs, and as embedded PNGs at 256×256 and above — which is where the line has to be drawn, since the directory records each side in a single byte and the DIB form has no way to be larger than the one value that byte cannot hold.

WebP

A RIFF container — twelve bytes, then chunks — over two unrelated bitstreams, and that split is the whole shape of the format.

VP8L, the lossless one, is prefix codes, a colour cache, backward references and four reversible transforms. VP8, the lossy one, is a video codec's keyframe path: a boolean arithmetic decoder, coefficient probability tables, intra prediction, an inverse DCT and a loop filter. Both are read, still images and animations alike.

A third thing sits beside them. VP8 is a video codec and video is opaque, so a lossy WebP that is not opaque carries its transparency in an ALPH chunk of its own — a plane of one byte a pixel, row-filtered, and usually compressed as a lossless WebP image with the values hidden in its green channel. A transparent lossy file is therefore two decodes, and the one thing about it that is not lossy is the alpha.

Only the lossless half is written. WebP is the first format here that is read more than it is written, and that had to be said in a way the compiler enforces rather than left as an omission; see below.

Two of the three pieces live elsewhere

Neither the container nor the lossy bitstream is in this repository, and neither should be.

RIFF is what WAV and AVI are made of too — a twelve-byte header naming a form type, and then chunks of a four-character identifier and a length — and it has nothing to do with images. It is zig-riff, which handles the even-length padding, LIST nesting, the big-endian RIFX variant and RF64 (by refusing it by name), and which WebP uses through one extra operation that this format needed: ANMF is a container that is not a LIST, so descend takes a chunk's header from whatever the caller has already read rather than assuming a four-character list type.

VP8 is a video codec that this format happens to carry one keyframe of, and is zig-vp8. It decodes a keyframe to three planes of samples and converts those to pixels, and what it produces is bit for bit what libwebp produces — which is the only exact claim a lossy decoder can make, since the file it was given already differs from the picture that was encoded. It knows nothing about RIFF, alpha or surfaces.

What is left in src/webp.zig is the part that is actually about WebP: which chunks mean what, and how a frame becomes a surface.

What the lossless bitstream is

Four ideas, and each of them is decoded by the same machinery:

  • Prefix codes, five per pixel — one for green, which doubles as the backward-reference lengths and the colour-cache indices, then red, blue, alpha and distance. The stream is read least significant bit first and each code most significant bit of the code first, which are opposite conventions and both correct; this is DEFLATE's arrangement.
  • An entropy image that says which set of five codes a block of pixels uses, so the statistics can vary across a picture.
  • A colour cache of recently used colours, addressed by a hash.
  • Four reversible transforms — a spatial predictor with fourteen modes, a cross-channel colour transform, subtract-green, and a palette — declared at the front and undone at the end in the reverse of the order they were read.

And the thing worth knowing before reading any of it: four of those are themselves VP8L images. The entropy image, the predictor's per-block modes, the colour transform's per-block coefficients and the palette are all decoded by the same function as the picture, at a lower resolution, with the payload in whichever channel that particular thing uses.

The recursion that implies is bounded at exactly two levels, and the bound is stated as a call graph rather than as a counter: decodeTop is the only function that reads transforms or an entropy image, and the only caller of decodeSub, which reads neither. A third level is not something the decoder declines to do — it is something it cannot say.

Where a few bytes buy a large allocation

Every format here has one place where a small header asks for a large allocation, and Limits exists for those. WebP's is not the image size.

The number of prefix-code groups is one more than the largest value in the entropy image, and that value is two bytes of a pixel. So a three-pixel entropy image may legitimately declare sixty-five thousand groups, at about six kilobytes of table each for a hundred and thirty bytes of input — a fifty-to-one amplification with nothing in the format bounding it.

Two things answer it. Only the groups some pixel actually names are stored; the rest still have to be read, since they sit one after another in the stream with nowhere to seek to, but reading and discarding them costs nothing. And every symbol any code stores is drawn through a single budget, derived from Limits.max_pixels with a floor so that a caller who lowered that for thumbnails is not refused a legitimate small file. Overrunning it is error.ImageTooLarge rather than error.InvalidData: the file is well formed and the budget refused it.

Animation

An ANMF frame maps onto this library's animation model with nothing left over, which is the return on having built that model for GIF and APNG rather than for either:

ANMF anim
frame X and Y, stored halved Frame.x, Frame.y
width and height, minus one Frame.width, Frame.height
duration, in milliseconds Frame.delay_ms
blending method 0 / 1 Blend.over / Blend.source
disposal method 0 / 1 Dispose.keep / Dispose.background

There is no dispose-to-previous, so a third of the disposal state machine goes unused. ANIM's loop count already counts plays with zero meaning forever, which is exactly what anim.Info.loop_count means — so unlike GIF it needs no conversion at all.

decode is written in terms of the animation, the way gif.decode is: play one frame onto a canvas, and the canvas is the answer. A file with no ANMF in it is an animation of one frame covering the canvas, which costs nothing to say and saves writing the still path twice.

Three refusals are worth naming because each closes a gap between entry points rather than a hole in a file:

  • An ANMF in a file whose VP8X did not set the animation flag is refused. probe has already told a caller that this does not animate, and a decoder that then played two frames would be contradicting it. APNG gates fcTL on acTL for the same reason and was fixed for the same bug.
  • A frame must fit inside the canvas, and a frame's bitstream must declare the size its ANMF declared. The first is held against the canvas rather than against Limits, and the second is checked before the limits are — a bitstream claiming sixteen thousand pixels inside a frame that declared nine is a malformed file, not a large one, and answering ImageTooLarge would have probe promise something small and decode refuse it as big.
  • An ALPH chunk in a file whose VP8X did not set the alpha flag is refused. Same shape again: probe reported an opaque surface, so a decoder that then produced transparency would be contradicting it, and quietly discarding the alpha instead would be worse than either.

Sandboxing one

sandbox.decode runs a decoder on a fixed arena of Limits.max_pixels * 4 + Options.working_bytes, and this is the one decoder here that needs a whole second image's worth of scratch. A backward reference may reach anywhere earlier in the picture, so there is no streaming form of VP8L: the ARGB buffer has to exist alongside the surface being filled from it, which is eight bytes a pixel at the peak where every other decoder here allocates the surface and little else. A lossy frame is no cheaper for a different reason — three planes of samples, then the RGBA they convert to, then the surface — and a transparent one adds the alpha plane and the lossless image it was decoded from.

The default eight megabytes of working memory therefore covers an image of about half max_pixels. A caller sandboxing WebP at the full budget should raise working_bytes to match.

The order things are allocated in matters here and is deliberate. The child runs on an arena that can only give back the block it handed out most recently, so the surface is allocated first and the scratch above it — which leaves the residue four bytes a pixel rather than eight when the scratch is freed.

Metadata sits on both sides of the pixels

ICCP comes before the image data and EXIF after it, which the format requires and which has a consequence worth saying out loud: probe can never report a WebP's orientation. probe stops at the first image chunk — that is what makes it cheap — and EXIF is past it, so Info.orientation is always .top_left for a WebP however the file is turned. The real answer comes from passing DecodeOptions.orientation to decode, which keeps walking the container after it has the picture.

This is the only decoder here that reads a container after the image, and it does it only when a caller asked for something that lives out there: stepping over a chunk on a stream means reading it, and reading to the end of an animation to find out which way up it goes is a poor trade for a caller who never asked. Nothing is decoded by that walk — an ANMF is a chunk like any other to a walk that does not descend into it — so it costs the bytes it discards and no more.

The profile is the raw thing, unlike PNG's iCCP, which deflates it behind a name; there is nothing to inflate and no name to skip. Both readers follow the rule the rest of the library follows for metadata: a malformed profile or a malformed TIFF block is dropped, never an error, because a camera that wrote a broken block fifteen years ago still wrote a perfectly good photograph. A profile that is not shaped like one never reaches the caller, so there is no way to be handed sixty-four bytes of nonsense to write into the next file.

The flags byte reads backwards

VP8X holds its feature bits in one byte, and the specification's prose numbers those bits most significant first — so a careful reading of it produces exactly the inverse of what every implementation uses. The constants in src/webp.zig are libwebp's: animation 0x02, XMP 0x04, Exif 0x08, alpha 0x10, ICC 0x20.

The alpha bit is a hint

Both VP8X and the VP8L header carry a bit saying the image has transparency, and in both places the specification says it should be zero when every pixel is opaque rather than that it must be. This library takes it at its word, because probe and decode have to agree about the surface type and the hint is all probe can see — which is also what libwebp reports and so what Pillow shows. animated-red-blue.webp is the illustration: its VP8X claims alpha while every one of its frames disclaims it, because the container is describing the file and not any particular frame.

Transparency for a codec that has none

VP8 is a video codec and video is opaque, so a lossy WebP keeps its transparency in an ALPH chunk in front of the frame: one header byte, then a plane of one byte a pixel the same size as the frame. Two things about it are worth knowing.

The plane is usually a lossless WebP image of its own, with the alpha values in the green channel and the other three left at zero — which is not an oddity for its own sake, it is what lets the subtract-green transform and the colour cache do anything for a single-channel image. So src/webp.zig reads it by handing the bytes to the same vp8l decoder the VP8L chunk uses, minus the five-byte header the frame has already made unnecessary, and keeps one channel of what comes back. The other option the format allows is the plane exactly as it is, uncompressed.

Then it is unfiltered, by one of four fixed rules over bytes — none, from the left, from above, or left plus above minus the corner between them. It is not the predictor transform next door and does not share any code with it: four rules over bytes rather than fourteen over pixels.

The consequence worth stating is that a lossy WebP's alpha is not lossy. That makes it checkable against ground truth where nothing else in the file is, and the corpus does exactly that: hippopotamus.masked-with-muybridge.lossy.webp is held against the PNG of the same picture on the alpha channel alone, 1008 pixels, many of them partly transparent, every one exact.

Writing one

Lossless only, and still images only. A lossy encoder is a different kind of work from a lossy decoder: decoding has one right answer and encoding has a great many, and choosing between them is rate control and mode decision rather than anything this library is about. Reading one is what an image library owes its callers; writing one is a project of its own.

What it does is a real VP8L encoder rather than a conforming minimum:

  • Prefix codes built from the image's own histograms, length-limited to fifteen bits. Huffman's algorithm does not respect a limit — frequencies growing like the Fibonacci numbers produce a code as long as the alphabet — so the length distribution is fixed up afterwards and the shortest codes are handed to the most frequent symbols at the end.
  • Backward references, found with a chained hash table over three-pixel sequences. The chain is followed from the most recent match backwards, which finds the nearest match first — and near is also cheap, since the first hundred and twenty distance codes name a neighbourhood rather than a number.
  • A colour cache, sized by the number of pixels, because every entry costs a symbol in the green alphabet whether anything uses it or not.
  • Subtract-green, chosen by measuring rather than assumed: both forms of the red and blue histograms are built and the one that codes to fewer bits wins.

A code of one symbol spends no bits at all, which is why an opaque alpha channel costs nothing and a picture of one colour codes to under two hundred bytes however large it is.

Against libwebp at its default effort, on the corpus:

image this libwebp ratio
bricks-nodither 160×120 12,118 10,814 1.12
hat 90×112 25,442 22,152 1.15
hippopotamus 36×28 2,270 1,880 1.21
harvesters 1165×859 2,392,584 1,466,150 1.63

The gap that remains is the predictor transform, which is what libwebp spends on a photograph and this does not write yet. The decoder here reads it, so the files are read correctly either way; they are simply larger than they need to be.

Orientation

A camera turned on its side records the picture the way the sensor saw it and writes down which way the camera was held. Ignore that and the photograph is displayed sideways, so this library reads it — and reads nothing else about EXIF, and writes none of it.

const info = try z2dimg.probe(&reader, .{});
if (info.orientation.swapsDimensions()) {
    // What is on screen is `info.height` by `info.width`.
}

probe answers from the header alone. For a caller who has already decided to decode, DecodeOptions takes somewhere to put the answer, because a sans-I/O decoder is handed a stream it cannot rewind and asking twice would mean buffering the whole image. EncodeOptions takes one to write, so that the tag survives a conversion:

WebP is the exception to the first sentence. Its EXIF chunk goes after the image data, so probe — which stops at the first image chunk, and is cheap because it does — cannot reach it and reports .top_left for every WebP. Only DecodeOptions.orientation gets the real answer there, and only then does the decoder walk past the picture to look. Read is all: this library writes the simple lossless WebP, which has nowhere to put an EXIF chunk.

var orientation: z2dimg.exif.Orientation = undefined;
var surface = try z2dimg.decode(gpa, &reader, .{ .orientation = &orientation });

try z2dimg.encode(gpa, &surface, writer, .{ .png = .{ .orientation = orientation } });

Writing is the same one tag and nothing else — twenty-six bytes, as a JPEG APP1 segment or a PNG eXIf chunk. Every other tag a camera wrote is lost by a decode here, and building a block that carried only orientation while claiming to be the original's metadata would be worse than saying so. Null writes no tag at all, which differs from .top_left only in whether the file carries one; both say the same thing to a viewer.

The surface is always the image as stored, and writing the tag does not rotate anything either: an encoder asked for .right_top writes .right_top and makes no claim about the pixels beside it, so a caller who has already turned the image the right way up wants .top_left or nothing. The orientation is a correction to apply, not a description of what was done. Orientation.transform() gives that correction as something to do rather than a tag to look up — a mirror and a rotation, in that order — since every caller otherwise works the same table out for itself. Nothing here rotates anything: which of the caller's own coordinate systems the correction belongs in is not this library's business.

A file with no EXIF reports .top_left, the identity, which is deliberately indistinguishable from a file that says .top_left itself. The question a caller has is how to display the image and the answer is the same either way.

Where it comes from, and where it lives in this tree

EXIF is a TIFF file with no image in it, carried inside some other file's metadata. JPEG puts it in an APP1 segment behind the marker Exif\0\0; PNG Third Edition puts the same block in an eXIf chunk. So src/exif.zig belongs to neither codec: it is handed a block and knows nothing about markers or chunks, which is also what would make it liftable into a library of its own if it ever grew past reading one tag.

It reads the block in a single forward pass. A TIFF is a structure of offsets and reading one usually means seeking about inside it, but a sans-I/O decoder has neither a reader it may seek nor a reason to buffer a block that may be megabytes of thumbnail. An offset that points backwards is not followed — a real file's IFD0 follows its header immediately — and the cost is that a deliberately shuffled block reports nothing rather than being chased around.

A malformed block is not an error. Every structural problem answers "no orientation", never a failure. Metadata is decoration: a camera that wrote a broken APP1 fifteen years ago still wrote a perfectly good photograph, and refusing to decode it would serve nobody. Only the underlying stream failing propagates.

tools/check_samples.py holds the writing to libjpeg and libpng the same way it holds the colour profiles: all eight orientations into a baseline JPEG, a progressive JPEG and a PNG, and Pillow reads all twenty-four back as written.

The fixtures in tests/exif_fixtures.zig are eighteen files Pillow wrote — all eight orientations in both formats, and one of each with no EXIF at all — with what Pillow reads back out of its own output recorded beside each, so the expected value is libjpeg and libpng's answer rather than a restatement of this library's. There is a fuzz target as well, since metadata is the part of a file that no decoder validates and every decoder walks past.

Colour profiles

An ICC profile says what the numbers in an image mean — which real colours those bytes stand for. This library does nothing with that. z2d has no colour management, so there is nothing here that could apply a profile, and pretending otherwise would be worse than saying so.

What it can do is not lose it. A program converting a JPEG to a PNG should hand back a file that still says what its colours mean; one that silently dropped the profile would turn a wide-gamut photograph into a washed-out one the first time it passed through. So the profile comes out as bytes and goes back in as bytes, unread:

var profile: std.ArrayList(u8) = .empty;
defer profile.deinit(gpa);

var surface = try z2dimg.decode(gpa, &reader, .{ .icc = &profile });
defer surface.deinit(gpa);

try z2dimg.encode(gpa, &surface, writer, .{ .png = .{ .icc = profile.items } });

The bytes are appended with the allocator the decode was given and the caller owns them. A file with no profile leaves the list empty, so a list that started empty and ended empty means there was nothing to carry.

Where a profile lives, and what it costs

JPEG has no segment large enough. A profile is commonly a few kilobytes and can be megabytes, and an APP2 segment holds at most 65533 bytes — so the ICC specification's own Annex B defines how to split one across several, each beginning ICC_PROFILE\0 and then a one-based piece number and a count. That embedding belongs to ICC rather than to JPEG, which is why src/icc.zig belongs to neither codec. PNG has iCCP, which is a name, a compression method, and the profile deflated, in one chunk whatever the size. WebP has ICCP, which is the simplest of the three: the profile as it is, in one chunk, with nothing wrapped around it — and it is read only, since this library writes the simple lossless form of WebP and that shape has no VP8X to declare a profile beside.

WebP is read but not written, for the reason above: the simple lossless file this library writes has nowhere to put a profile, and writing the extended form to carry one would mean declaring a VP8X and everything that implies about the rest of the file.

BMP is not covered. A BITMAPV5HEADER can carry one, but the header gives an offset to the profile rather than the profile itself and writers put it after the pixel data, so reaching it means knowing where the cursor is in a stream that has already been read past — which a decoder that only ever seeks forward cannot do. Asking a BMP for a profile leaves the list empty.

A profile is memory allocated on the strength of a length field, which is exactly the shape of claim Limits exists to bound, so max_icc_bytes bounds it. The default is what a JPEG can hold at all, 255 pieces of 65519 bytes, since a larger profile could not have come out of one. A profile over the budget is dropped and the image still decodes: a profile too large is a reason to lose the profile, not the photograph. A caller who asks for no profile allocates nothing for one.

A profile that is not one is dropped

As with EXIF, a broken block is not a reason to refuse a good photograph. But unlike EXIF this is passed on rather than read, so carrying a malformed profile through would put a malformed profile in the output for other readers to reject. Two checks decide, both from the profile's own header: the first four bytes are its size and must be the size it actually is — which is what catches a truncated profile and one reassembled from pieces that went missing — and bytes 36 to 40 are the signature acsp. Anything else is dropped on the way in and refused with error.Unsupported on the way out.

Pieces have to arrive in order. The specification numbers them and does not promise an order, and libjpeg sorts them — but it can, because it holds every marker in memory before looking at any of them. A decoder reading a stream once cannot leave a gap for a piece that has not come yet, and no encoder writes them out of order; one that did would have its profile dropped rather than reassembled wrongly.

tools/check_samples.py holds this to libjpeg and libpng: it writes a 3KB profile and a 140KB one — the second split across three APP2 segments — into a baseline JPEG, a progressive JPEG and a PNG, and Pillow reads all six back byte for byte.

A standard library bug you should know about

Zig 0.16.0's inflater panics on malformed input. Thirty-two bytes of rubbish handed straight to std.compress.flate.Decompress are enough; tools/std_flate_panic.zig is the reproducer, and no part of this library is involved in it.

The cause is a single comparison in tossBitsShort, which adds the count of already-consumed bits where it should subtract it:

-    if (d.input.bufferedLen() * 8 + d.consumed_bits < n) return error.EndOfStream;
+    if (d.input.bufferedLen() * 8 < @as(usize, n) + d.consumed_bits) return error.EndOfStream;

It therefore believes there is more input than there is, and tossBits then discards more bytes than are buffered; Reader.toss asserts seek <= end and the process dies. The neighbouring peekBitsEnding computes the same quantity correctly, which is what makes it look like a slip. The same input reaches a second, quite different-looking panic — Writer.unreachableRebase, by way of writeBytePreserve — depending only on the optimisation mode, and the one-line fix resolves both.

It matters here because PNG's IDAT is a zlib stream and this library inflates it with that decompressor, so on an unpatched toolchain decoding an untrusted PNG can panic, and a panic cannot be caught. The fuzz loop found it about a minute after the PNG target existed.

The devshell carries the fix, alongside the one for the fuzzing test runner — flake.nix explains both, and each uses --replace-fail so that the day Zig ships either one the build fails here rather than silently patching something else. So nix develop and CI are not exposed.

A consumer building against a stock 0.16.0 is. Until the fix lands upstream, a program decoding PNGs it did not write should either carry the same patch or use sandbox.decode, where a panicking child is error.DecoderCrashed in the parent rather than a dead program.

Sandboxing

Image decoders are where memory-safety bugs live. They parse attacker-chosen binary structures, do arithmetic on lengths that came out of those structures, and are reached by every program that displays a picture from anywhere. Zig's bounds and overflow checks catch a great deal of that in a safe build and none of it in ReleaseFast, and neither build catches a logic error that reads the wrong part of a buffer it is entitled to read.

So the decoder can be run somewhere it can do no harm even if it is wrong:

var image = try z2dimg.sandbox.decode(gpa, reader, .{});
defer image.deinit();

// image.surface is an ordinary z2d.Surface.

This follows glycin, GNOME's image loading library, which runs each format's loader as a separate sandboxed process and passes the pixels back through shared memory. The mechanism here is much smaller — one fork, one seccomp filter, one memfd — because the decoders it is protecting are already pure. glycin has to permit its loaders a good deal, because they are ordinary programs that open files. A sans-I/O decoder needs nothing permitted at all.

  parent                              child
  ------                              -----
  read the input into memory
  memfd_create + ftruncate
  mmap MAP_SHARED  ─────────────────▶ inherited across fork()
  pipe2
  fork()  ──────────────────────────▶ prctl(PR_SET_NO_NEW_PRIVS)
                                      seccomp(SET_MODE_FILTER)
                                      ── nothing further is permitted ──
                                      decode, allocating from the mapping
                                      write(pipe, reply)
                                      exit_group(0)
  waitpid   ◀────────────────────────
  read the reply, validate it
  munmap the unused tail
  wrap the pixels as a z2d.Surface

Under the default strict profile the filter permits four system calls: write, exit_group, exit and rt_sigreturn. Not openat, not socket, not execve, not mmap — the memory was reserved before the filter went on, and the child allocates out of the shared mapping with a FixedBufferAllocator, so the surface it decodes into is already in memory the parent can see and there is nothing to copy back. The input is copied in before the fork rather than streamed, because a child that could still read its input would need a descriptor an attacker might redirect, and would introduce a deadlock between two processes each writing to a pipe the other is not draining.

Animations in the sandbox

sandbox.decodeAnimation streams frames out of the forked process one at a time, which is what keeps a thousand-frame animation inside a one-frame budget: the child resets its allocator between frames, so what has to fit in the shared mapping is the largest frame and the decoder's working memory rather than the sum of every frame.

That costs the one property the still-image path leans on — that the child is dead before the parent looks at the pixels. Here it is alive between frames, waiting to be told it may write the next one over the last. Every shape check survives, so a malicious reply still cannot make the parent read outside the mapping; what is given up is that a frame's contents may change while the parent is looking at them, which is a torn picture and not a memory-safety failure. The module has always said that a subverted decoder's pixels are the attacker's anyway.

The child does not composite. It is run with compositing off and hands back un-composited frames; the canvas is built in the parent, out of frames whose shape has already been checked. Less runs inside the sandbox and less is trusted from it — a child that lies about a frame can make the canvas wrong, but it cannot make the compositing go wrong, because the compositing is not its. The canvas size the child claims is checked against the caller's Limits before the parent allocates anything on the strength of it.

A caller that stops reading half way leaves the child blocked for ever waiting for an acknowledgement, so deinit kills it rather than waiting for it. Closing the pipes would also do it, but only if the child co-operates, and that is the one process here that might not.

There is a fifth call in one place, and it is confined. A decoder handing its result back a frame at a time has to wait for the parent between frames, and waiting means read — so the streaming profile permits it, and permits it on one descriptor only. That is possible because a descriptor is an integer rather than a pointer, which is the one kind of argument a seccomp filter can compare; the child moves the pipe onto a fixed number with dup2 before the filter goes on, so that the number the filter tests can be a constant. A read on any other descriptor kills the process exactly as openat would, which src/sandbox.zig asserts directly by running two children that differ only in the integer they name.

What the child is stripped of before the filter goes on. A forked child keeps everything the parent had, because CLOEXEC means nothing to a process that never execs — so four things are taken away first, all of them free in syscall surface because all of them happen before there is a filter to stop them.

Its inherited descriptors are closed. This is the one that was a real hole: a decoder cannot open a socket under any profile here, but write is a call it has, and a subverted one could put attacker-controlled bytes into a connection the parent already had. The pipes are moved to fixed numbers, everything above them is closed with close_range, and the strict profiles then permit write on the reply descriptor and no other. The two halves make each other worth having: closing takes away what could be written to, and the filter takes away the ability to name anything else.

Processor time, through RLIMIT_CPU — a decoder stuck in a loop is killed by the kernel, so the parent needs no clock of its own, and time spent waiting on a pipe does not count towards it. Core dumps, through RLIMIT_CORE, so a crash cannot write the shared mapping out to disk. And dumpability, through PR_SET_DUMPABLE, which stops another process of the same user attaching with ptrace to read that mapping.

What it protects against: a decoder subverted into doing something other than decoding. Reading a file, opening a socket, running a program, attaching to another process — each dies at the attempt, as error.SandboxViolation. And a decoder that crashes: a segmentation fault in the child is error.DecoderCrashed in the parent, not a dead program.

What it does not do is make the pixels trustworthy. A subverted decoder can still write what it likes into the shared mapping, because writing there is its job. What the parent validates is the shape of the reply — that the buffer is inside the mapping, correctly aligned, and exactly the length the stated dimensions require — so a malicious reply cannot make the parent read out of bounds. The contents of a valid-looking buffer are the attacker's if the decoder was theirs.

It costs a fork and a waitpid per image, plus the page faults for the pixels: tens of microseconds against a decode measured in milliseconds for anything but a tiny image. It is Linux-only and 64-bit-only, and sandbox.decode returns error.SandboxUnavailable elsewhere rather than quietly decoding unsandboxed — a security feature that turns itself off is worse than one that was never there.

Testing

$ zig build test          # unit tests, round trips, and the fuzz corpus
$ zig build check         # compile the tools nothing else builds
$ zig build fuzz-run      # the fuzz loop, a minute a target
$ cd corpus && zig build run   # the wuffs image corpus, fetched on demand

Every BMP fixture is assembled in the test rather than read from a file. A checked-in .bmp is a wall of hex that says nothing about which of its fields is under test, where infoHeader(.{ .bpp = 16, .compression = bitfields }) says exactly that.

The PNG and JPEG fixtures work the other way round, and are the stronger test. Twenty-six files are generated by tools/mkpng.py, a small Python encoder with explicit control over every choice the format offers, and each is opened with Pillow and compared against its expected pixels before being written into tests/png_fixtures.zig. A fixture Pillow disagrees with never reaches the repository. That matters because a round-trip test only proves an encoder and a decoder agree with each other, which they will even if both are wrong the same way; these prove the decoder agrees with an implementation that has never seen this code. The encoder was checked in the same direction, by hand — every surface format it can write, read back by Pillow.

The JPEG encoder was checked the same way: every option it offers, written out and read back by Pillow, which agreed with this library's own decoding of the same bytes to within 2 counts — and reported the alpha-only case as mode L, which is the part that would have been easy to get subtly wrong.

The fifteen JPEG fixtures are the same idea inverted: Pillow encodes each one with the options its note names — baseline and progressive, 4:4:4, 4:2:2 and 4:2:0, greyscale, restart markers, optimised Huffman tables, dimensions that do not divide by the MCU size — and then decodes its own output, and those pixels are what the test expects. The comparison has a tolerance because it must: JPEG defines IDCT accuracy requirements rather than an exact transform, so two conforming decoders are entitled to differ. Measured across all fifteen, the worst single channel is 3 and the worst mean over an image is 0.14, and the bounds are set just above that — tight enough that anything actually wrong moves them by tens.

The animation tests in tests/anim.zig are assembled rather than checked in: each GIF is built from named pieces, so a test says which field it is about instead of holding a wall of hex. The LZW payloads are the one part that cannot be written by hand, and they were generated by a small encoder and cross-checked against wuffs' own artificial-gif files, whose one-pixel payload is byte for byte the one here.

The later formats are fixtured the same way, each against whichever implementation is the reference for it: ImageMagick for XBM, XPM, GIF and TGA, and netpbm's own tools for PBM, PGM, PPM and PAM, through tools/gen_netpbm_fixtures.py. The rule is the same every time — the expected pixels are read out of the other implementation's output, never out of this one's — and it has paid for itself at least once already. The GIF decoder round-tripped its own output perfectly while disagreeing with every other implementation in the world about when the LZW code width grows, and nothing but a real file from another encoder was ever going to say so.

The fuzz targets in tests/fuzz.zig assert properties rather than examples: that a decode terminates, allocates within its limits and leaks nothing; that anything it accepts survives being written out and read again; and that probe and decode never disagree about how large an image is, since a caller is invited to trust the cheap answer before paying for the expensive one. The seccomp module's tests fork a child, install the strict filter, and check that an ordinary getpid kills it and an ordinary write does not — the claim the whole sandbox rests on, made directly.

zig build fuzz --fuzz is what those targets were written for, and Zig 0.16.0 cannot usefully run it: a test executable will not compile in fuzz mode without a patched standard library, and the fuzzer's coverage table comes back empty even with one. flake.nix carries the patch and says why. zig build fuzz-run is the loop that works — no coverage feedback, but a corpus of real files to mutate, which for an image format is doing most of the work anyway: a BMP is two bytes of signature, a fourteen-byte file header and a length-tagged DIB header, and random bytes get past none of that.

The wuffs corpus

corpus/ is a second project, with its own build.zig, that checks these decoders against Google wuffs' test images.

$ cd corpus
$ zig build run          # fetches wuffs the first time, then checks it
$ zig build run -- -v    # and says what every case is for

None of it is checked in. wuffs is a sixteen megabyte dependency pinned by commit hash and marked lazy, so nothing is downloaded until that run step is actually asked for, and nothing that merely builds this library has to know the corpus exists. That is also why it is a separate package rather than another test module: keeping it out of the library's own manifest keeps it out of everything generated from that manifest. The library comes to it by path, from the checkout it sits inside, so the two are always the same code.

It is a different kind of test from everything in tests/. Those fixtures are generated — a script writes an image, another implementation reads it back, and what that implementation reports becomes the expectation — which proves this library agrees with Pillow, ImageMagick and netpbm about files those tools wrote. It cannot prove anything about a file none of them would ever write, and those are the files that break decoders. Half of wuffs' corpus is exactly that: hand-built corner cases, each with a paragraph of commentary saying which ambiguity in the format it pins down.

Four kinds of claim are made, in increasing order of what they are worth:

  • against NIE. wuffs ships hippopotamus.nie, its own uncompressed format — a sixteen byte header and then raw BGRA — holding the exact pixels of a 36×28 image that is also present as a BMP, two PNGs and a PPM. Pillow confirms all four hold precisely those pixels, so anything but an exact match is this library's fault.
  • agreement between encodings. Several lossless encodings of one image must decode to identical pixels. This needs no external truth at all, which is the point: bricks-nodither as a BMP, a PNG, a TGA and a GIF runs four separate decoders here over one picture, and any one of them being wrong shows up as a disagreement. It is the cheapest strong test in the project, and it is what finally gave TGA and GIF an exact check rather than a tolerance.
  • agreement on the alpha alone. This exists for one file and earns its place: a lossy WebP's colour is lossy and its transparency is not, because VP8 is opaque and the alpha travels beside the frame as a lossless image. So hippopotamus.masked-with-muybridge.lossy.webp is held against the PNG of the same picture on the alpha channel — 1008 pixels, many of them partly transparent, every one exact — in a file where nothing else can be checked at all.
  • accepted or refused. For the corner cases, where the claim is only that a file is decoded or rejected rather than crashing or hanging.

Nothing compares a lossy encoding against ground truth. A JPEG of the hippopotamus differs from the NIE by up to 29 counts a channel and a GIF of it by up to 21, and neither number says anything about this decoder: both are the encoder's loss, and quantising to 256 colours and then reproducing that palette exactly is a correct GIF decode that differs from the original. A tolerance wide enough to admit that is wide enough to admit a real bug. The lossy WebPs are here on the same terms: what holds that decoder to the bit is dwebp, in zig-vp8's own tests, where the comparison is against another decoder reading the same file rather than against the picture encoded.

It found three, immediately. A JPEG decoder that multiplied max_pixels by four and so panicked on every JPEG in existence when handed Limits.unlimited; a GIF frame of zero area refused as malformed when it should draw nothing; and a GIF refused over an invalid LZW code that sat after a frame the decoder had already filled. The first is the worst of the three — a crash, in a library whose whole argument is that decoders are where memory-safety bugs live — and no amount of fuzzing was going to find it, because the fuzzer always passes bounded limits.

The ways decoders get broken

tests/hardening.zig is a test per class of vulnerability that has actually been found in a real image decoder, organised by attack rather than by format — because the same mistake turns up in each format's decoder wearing different clothes, and a suite arranged by format hides that.

what it pins where it has gone wrong
a truncated file's output does not depend on what was in memory CVE-2008-0420, a BMP decoder leaking the process's memory into the image; CVE-2013-6629, libjpeg emitting uninitialised bytes
a run-length run longer than its row is clipped, not written the libnsbmp heap overflow; CVE-2013-3663
a run-length delta jumping past the image saturates the same class, the other escape
run-length data that never ends still terminates decoders that looped on it
run-length encoding at a depth it cannot describe is refused what Ladybird added after incompatible pairs reached its pixel decoders
a palette index past the end of the palette is a colour, not a read the libpng png_do_quantize over-read
tRNS describing more entries than the palette has several libpng bugs indexing one chunk by another's length
image data that decompresses to less than a row CVE-2014-0333, an IDAT loop that never ended
a colour profile chunk claiming more than it holds the png_handle_iCCP failures
dimensions whose product overflows CVE-2018-20330, a miscomputed buffer size
a scan naming a Huffman table never defined the libjpeg null-pointer crashes
twelve-bit samples CVE-2023-2804, reachable only at that precision

Most of these cannot go wrong here the way they went wrong in C. A slice index is bounds-checked, so what was disclosure is a panic — but a panic in a decoder is a crash in whatever opened the file, so the standard these tests hold to is that a malformed file is refused, not that it fails safely.

Every one of them passed the first time they were run, which is the expected outcome and also a reason to distrust them: a test that has never failed may be testing nothing. So the clamp that keeps a run-length run inside its row was deliberately removed to check — the test panics with index out of bounds: index 4, len 4, which is precisely the heap overflow, caught by the bounds check and by the test.

The fuzz targets cover the same ground continuously and far more widely; these exist because a fuzzer finds what it finds, and a named case that is checked on every build says which specific mistakes were considered.

Building

The Zig dependency is fetched by Zig; everything else comes from Nix.

$ nix develop
$ zig build test

nix build builds the package the other way, with zon2nix turning build.zig.zon into a Nix expression so that the build itself needs no network. Adding, removing or updating a dependency is the whole of regenerating that file:

$ nix develop -c zon2nix --16 --nix=build.zig.zon.nix build.zig.zon

Licence

MIT, and the project follows REUSE: reuse lint passes, every file carries its own SPDX-FileCopyrightText and SPDX-License-Identifier, and the ones that cannot are covered by REUSE.toml. z2d itself is MPL-2.0 and is a dependency rather than a derivative.

References cited

The first three are the format. [MS-WMF] is the only freely published document that defines BitmapFileHeader, BitmapInfoHeader, BitmapV4Header and the Compression enumeration normatively rather than describing them, and is what the header parsing implements; the GDI pages supply the row-padding rule, the meaning of a negative height, and the LCS_sRGB constant this library stamps into the V4 headers it writes.

Wikipedia is fourth because it is the most complete public account of the BMP family as it exists rather than as Microsoft documents it — the table of header lengths that identify each version, the OS/2 variants at 16 and 64 bytes, and the fact that OS/2 gave compression values 3 and 4 to a Huffman encoding and a 24-bit run-length encoding where Windows has BI_BITFIELDS and BI_JPEG. Refusing those two rather than guessing at them is the whole of what reading it changed.

The W3C specification is PNG as it now stands, and is what the codec here implements: the chunk layout, the five colour types and the depths each allows, the five filters and the exact tie-breaking of the Paeth predictor, the Adam7 tables, and the rule that the case of a chunk type's first letter says whether an unrecognised chunk may be skipped. RFC 2083 is the same format as it was first published and is kept because a good deal of writing about PNG still cites it. The two compression RFCs are the layer underneath: 1951 is the deflate bitstream, 1950 the zlib wrapper around it with the Adler-32 that a truncated IDAT run fails — std.compress.flate implements both, and this library only has to know which container to ask for.

The next four are the sandbox. The kernel's own document is the one that says a filter cannot dereference a pointer argument — which is why this is built around a decoder that needs no files rather than a filter that polices which files it opens — and why PR_SET_NO_NEW_PRIVS has to come first. seccomp(2) supplies the detail that x86-64's x32 ABI shares an architecture token with ordinary x86-64 and sets bit 30 of every call number, which is why the filter refuses that range explicitly rather than by accident. McCanne and Jacobson define the machine a seccomp filter is a program for, down to the rule that a jump is counted from the instruction after it, which is the arithmetic in src/sandbox/seccomp.zig and what its tests check.

T.81 is JPEG, and is the authority for the marker structure, the zig-zag order, canonical Huffman coding, progressive mode's spectral selection and successive approximation — Annex G, and Figure G.7 in particular, which is the refinement scan that interleaves correction bits with runs of zeroes — restart intervals, and Annex K, which supplies both the example tables that almost every encoder uses unchanged and, in K.2, the procedure for building a table from an image's own statistics. T.83 is why the JPEG tests compare with a tolerance at all: it specifies accuracy requirements for the inverse DCT rather than an exact transform, deliberately, so that the fast factorisations would stay conforming. T.871 is what T.81 leaves out — JPEG codes components and says nothing about what they mean, and JFIF is the convention that three of them are YCbCr, with the coefficients this decoder converts by.

Four things in the JPEG codec come from reading libjpeg rather than any specification: that Adobe's APP14 marker is the only thing that says how four components are to be read, and that Adobe writes CMYK inverted; that the default chroma upsampling is a triangle filter; the buffering a refinement scan's correction bits need, which T.81 describes as a data flow and libjpeg solves as a program; and the ten-scan script this encoder writes progressive files with, which is jpeg_simple_progression and is what the format settled on rather than anything the specification prescribes. Pillow is the referee for every fixture in the test suite, in both directions.

The ICC specification is cited for two things, neither of which is colour: the sixteen bytes of profile header this library checks before carrying one — the size field and the acsp signature — and Annex B, which defines how a profile too large for one JPEG segment is split across several. Everything else in it describes transforms this library has no opinion about.

Exif and TIFF 6.0 are the two halves of the metadata this library reads. TIFF is the container — the byte order mark, the magic 42, an image file directory of twelve-byte entries each naming a tag, a type, a count and either a value or where to find it — and Exif is what the tags mean, of which this reads exactly one: Orientation, tag 0x0112, which is a SHORT and therefore sits inside its own entry rather than at an offset, which is the whole reason a forward-only pass can read it. The W3C PNG specification above is the third piece, since it is what defines the eXIf chunk that carries the same block in a PNG and says it holds the block without the Exif\0\0 marker a JPEG puts in front of it.

The XPM manual is the format, and the only place several of its awkward details are written down: that a colour string may carry a value for each of five display kinds, that a value may contain spaces and is ended only by the next key, and that the characters spelling a pixel include the leading spaces a parser would otherwise skip. rgb.txt is the colour names it refers to, taken from X.Org rather than from a web palette — the two agree on most names and disagree on gray, which is #BEBEBE to X and #808080 to CSS.

The two GIF specifications are the format, and 89a is what this library writes: 87a is cited alongside it because both signatures are read and the difference between them is exactly the blocks 89a added — the graphic control extension this library reads a transparent index out of, and the application and comment extensions it skips. Neither document states the one rule that matters most in practice, which is when the LZW code width grows; Welch's paper is where the algorithm is described, and The one thing about GIF's LZW above says what the two sides of it disagree about.

The Truevision specification is TGA, and is cited for the parts a file cannot tell you itself: that there is no signature, what the eighteen header bytes mean, which of the image types are defined, and that the attribute bits field in the image descriptor says how many of the alpha byte's bits are real — which is the field almost no writer fills in, and the reason this library has to guess.

ImageMagick is the reference the XBM, XPM, GIF and TGA fixtures are checked against, in both directions.

wuffs is cited twice over. Its test corpus is what corpus/ checks these decoders against, and the commentary beside each of its hand-built files — which decoding policies exist for a GIF frame that falls outside its screen, and which real decoder picked each one — is documentation of the formats that exists nowhere else. The NIE specification is the second: a sixteen byte header and raw BGRA, which is the whole format, and which is why wuffs can ship exact pixels beside an image without shipping a decoder to read them with.

The four Netpbm format pages are the specification, such as it is: there is no standards body behind these and the man pages are the normative text. They are cited for the three things that are not guessable — that a maxval above 255 means two bytes a sample in most significant byte first order, that exactly one whitespace character separates a raw header from its samples, and that in PBM alone a set bit means black. The PAM page is cited separately for the rule this library follows about DEPTH and TUPLTYPE: the depth is normative and the tuple type is a name for it, which is why a TUPLTYPE this library has never heard of is read anyway.

Netpbm itself, the implementation, is the reference the test fixtures are generated from, for the reason the Netpbm section gives.

The PCX technical reference is the format, and is cited for the two things a file cannot tell you itself: that bits_per_pixel counts bits per plane, so that the depth is its product with the plane count and the seven combinations are seven different memory layouts; and that the 256-colour palette lives after the image data behind a 0x0C marker, because the 128-byte header was fixed before anyone needed one. It is also where the version numbers are enumerated, including the version 3 that means "no palette" and so means the default EGA colours.

The icon documentation is cited for the two things that make an ICO more than a directory of bitmaps: that an entry's DIB declares twice the height it has, the second half being the AND mask, and that the entry carries no BITMAPFILEHEADER because the directory already holds what it would have said. It does not document the convention that a 32-bit entry's top byte is alpha, which is the one thing about the format that most needs documenting and is known only from what every icon and every reader actually does.

The two WebP specifications are cited for opposite reasons. The lossless one is complete and is what the decoder was written from, down to the hundred-and-twenty-entry distance table, which is taken from it verbatim rather than transcribed because the order is not derivable and one wrong entry would corrupt only the files that happened to use that code. The container one is cited with libwebp because it is not sufficient on its own: its prose numbers the VP8X flag bits in the opposite order from the values every implementation uses, so the constants come from the source and the specification supplies everything else. The container document is also where the ALPH chunk is defined — the filtering rules, the header byte, and the fact that the compressed form is a headerless lossless image with the values in its green channel.

The lossy bitstream is not cited here because it is not read here: RFC 6386 is zig-vp8's reference and is cited in that project, which is the whole point of it being a separate project.

glycin is the design the sandbox follows, and reading it is the quickest way to see what the sans-I/O core buys: the same idea, with a great deal less that has to be permitted. z2d is the library on the other side of the bridge, and its premultiplied pixel formats are the reason src/pixels.zig exists at all.