- Zig 96.1%
- Python 3.1%
- Nix 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| corpus | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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:
- Headers —
BITMAPCOREHEADER(12), bothOS22XBITMAPHEADERs (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.
- Encodings —
BI_RGB,BI_RLE8,BI_RLE4,BI_BITFIELDSandBI_ALPHABITFIELDS, with arbitrary channel masks at 16 and 32 bits. - Both row orders, bottom-up and top-down.
BI_JPEGandBI_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
RGBQUADpalette 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_RGBimage is opaque unless a V3-or-later header supplies an alpha mask of exactly0xff000000.BI_RGBfixes 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
BITMAPCOREHEADERhas no palette-count field at all, so its palette is defined as the full1 << bppentries — yet files exist whosebfOffBitsleaves 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
h2v1and anh2v2fancy 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 filesdjpeganddjpeg -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.cguards the path withdownsampled_width > 2because 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 Porter–Duff 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
ANMFin a file whoseVP8Xdid not set the animation flag is refused.probehas already told a caller that this does not animate, and a decoder that then played two frames would be contradicting it. APNG gatesfcTLonacTLfor 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
ANMFdeclared. The first is held against the canvas rather than againstLimits, 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 answeringImageTooLargewould haveprobepromise something small anddecoderefuse it as big. - An
ALPHchunk in a file whoseVP8Xdid not set the alpha flag is refused. Same shape again:probereported 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-noditheras 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.webpis 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
- Microsoft Corporation. [MS-WMF]: Windows Metafile Format. https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/4813e7fd-52d0-4f42-965f-228c8b7488d2
- Microsoft Corporation. Bitmap Storage. Win32 API documentation, Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/gdi/bitmap-storage
- Microsoft Corporation. BITMAPV5HEADER structure (wingdi.h). Win32 API documentation, Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header
- BMP file format. Wikipedia. https://en.wikipedia.org/wiki/BMP_file_format
- World Wide Web Consortium (W3C). Portable Network Graphics (PNG) Specification (Third Edition) (W3C Recommendation). https://www.w3.org/TR/png-3/
- Deutsch, L. P., & Gailly, J.-L. (1996, May). ZLIB Compressed Data Format Specification version 3.3 (RFC 1950). Internet Engineering Task Force. https://www.rfc-editor.org/info/rfc1950
- Deutsch, L. P. (1996, May). DEFLATE Compressed Data Format Specification version 1.3 (RFC 1951). Internet Engineering Task Force. https://www.rfc-editor.org/info/rfc1951
- Boutell, T. (1997, March). PNG (Portable Network Graphics) Specification Version 1.0 (RFC 2083). Internet Engineering Task Force. https://www.rfc-editor.org/info/rfc2083
- Seccomp BPF (SEcure COMPuting with filters). The Linux Kernel documentation. https://www.kernel.org/doc/html/latest/userspace-api/seccomp_filter.html
- Kerrisk, M. seccomp(2). Linux man-pages. https://man7.org/linux/man-pages/man2/seccomp.2.html
- Kerrisk, M. memfd_create(2). Linux man-pages. https://man7.org/linux/man-pages/man2/memfd_create.2.html
- Kerrisk, M. prctl(2). Linux man-pages. https://man7.org/linux/man-pages/man2/prctl.2.html
- McCanne, S., & Jacobson, V. (1993, January). The BSD Packet Filter: A New Architecture for User-level Packet Capture. In Proceedings of the USENIX Winter 1993 Conference (pp. 259–269). USENIX Association. https://www.tcpdump.org/papers/bpf-usenix93.pdf
- Herold, S. glycin. GNOME. https://gitlab.gnome.org/GNOME/glycin
- International Telecommunication Union. (1992, September). Information technology — Digital compression and coding of continuous-tone still images — Requirements and guidelines (ITU-T Rec. T.81 | ISO/IEC 10918-1). https://www.w3.org/Graphics/JPEG/itu-t81.pdf
- International Telecommunication Union. (1994, November). Information technology — Digital compression and coding of continuous-tone still images: Compliance testing (ITU-T Rec. T.83 | ISO/IEC 10918-2). https://www.itu.int/rec/T-REC-T.83
- International Telecommunication Union. (2011, May). Information technology — Digital compression and coding of continuous-tone still images: JPEG File Interchange Format (JFIF) (ITU-T Rec. T.871 | ISO/IEC 10918-5). https://www.itu.int/rec/T-REC-T.871
- International Color Consortium. (2010, December). Specification ICC.1:2010 (Profile version 4.3.0.0): Image technology colour management — Architecture, profile format, and data structure. https://www.color.org/specification/ICC1v43_2010-12.pdf
- Camera & Imaging Products Association. (2023, May). Exchangeable image file format for digital still cameras: Exif Version 3.0 (CIPA DC-008-Translation- 2023 | JEITA CP-3451F). https://www.cipa.jp/std/documents/download_e.html?DC-008-Translation-2023-E
- Adobe Developers Association. (1992, June). TIFF Revision 6.0. https://web.archive.org/web/20230315131931/https://www.adobe.io/content/dam/udp/en/open/standards/tiff/TIFF6.pdf
- Arnaud Le Hors. (1996, May). XPM Manual: The X PixMap Format (Version 3.4i). https://www.x.org/docs/XPM/xpm.pdf
- X.Org Foundation. rgb.txt, the X colour name database. https://gitlab.freedesktop.org/xorg/app/rgb/-/raw/master/rgb.txt
- CompuServe Incorporated. (1987, June). GIF: Graphics Interchange Format — A standard defining a mechanism for the storage and transmission of raster-based graphics information (Version 87a). https://www.w3.org/Graphics/GIF/spec-gif87.txt
- CompuServe Incorporated. (1990, July). Graphics Interchange Format (Version 89a). https://www.w3.org/Graphics/GIF/spec-gif89a.txt
- Welch, T. A. (1984, June). A Technique for High-Performance Data Compression. Computer, 17(6), 8–19. https://doi.org/10.1109/MC.1984.1659158
- Truevision, Inc. (1991, January). Truevision TGA File Format Specification (Version 2.0). https://web.archive.org/web/20230326043748/http://www.dca.fee.unicamp.br/~martino/disciplinas/ea978/tgaffs.pdf
- ImageMagick Studio LLC. ImageMagick. https://imagemagick.org/
- Tao, N., et al. Wuffs: Wrangling Untrusted File Formats Safely
(commit
f31d952). Google. https://github.com/google/wuffs - Tao, N. (2021, November). Naïve Image Formats: NIE, NII, NIA. Wuffs. https://github.com/google/wuffs/blob/main/doc/spec/nie-spec.md
- Poskanzer, J., et al. pbm — Netpbm bi-level image format. Netpbm. https://netpbm.sourceforge.net/doc/pbm.html
- Poskanzer, J., et al. pgm — Netpbm grayscale image format. Netpbm. https://netpbm.sourceforge.net/doc/pgm.html
- Poskanzer, J., et al. ppm — Netpbm color image format. Netpbm. https://netpbm.sourceforge.net/doc/ppm.html
- Poskanzer, J., et al. pam — Netpbm arbitrary image format. Netpbm. https://netpbm.sourceforge.net/doc/pam.html
- Poskanzer, J., et al. Netpbm (Version 11.15.3). https://netpbm.sourceforge.net/
- ZSoft Corporation. (1991, July). ZSoft PCX File Format Technical Reference Manual (Revision 5). https://www.fileformat.info/format/pcx/spec/a10e75307b3a4cc49c3bbe6db4c41fa2/view.htm
- Hornick, J. (1995, September 29). Icons. Microsoft Developer Network, Microsoft Corporation. https://learn.microsoft.com/en-us/previous-versions/ms997538(v=msdn.10)
- Google. WebP Container Specification. Google Developers. https://developers.google.com/speed/webp/docs/riff_container
- Google. WebP Lossless Bitstream Specification. Google Developers. https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification
- Google. libwebp. https://chromium.googlesource.com/webm/libwebp
- Ollie, J. C. zig-riff. https://git.jcollie.dev/jeff/zig-riff
- Ollie, J. C. zig-vp8. https://git.jcollie.dev/jeff/zig-vp8
- libjpeg-turbo. https://libjpeg-turbo.org/
- Clark, J. A., et al. Pillow. https://python-pillow.github.io/
- Marchesi, C. z2d (Version 0.12.2-pre, commit
6c4f0ca). https://github.com/vancluever/z2d
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.