Computer Graphics · Graphics Tooling
PNG Chunk Framing and Scanline Filter Reconstruction
How PNG splits an image into type-tagged chunks around one deflate stream, and why the five scanline filters must be undone before pixels exist at all.
A PNG file is a chunk list wrapped around a deflate stream, and neither half makes sense alone. The chunk list carries the metadata that a decoder must have before it can interpret a single byte of pixel data — dimensions, bit depth, colour type, palette, transparency. The deflate stream carries the pixels, but not as pixels: it carries residuals, the output of a per-scanline differencing filter that exists purely to make the compressor’s job easier. Reading a PNG means parsing one structure to unlock the parameters needed to invert the other.
The eight-byte signature and what a decoder may assume
Every PNG begins with the fixed byte sequence 89 50 4E 47 0D 0A 1A 0A. The first byte is chosen because it has the high bit set, which distinguishes a PNG from a 7-bit text format on old transfer paths; the following bytes spell PNG in ASCII. The trailing 0D 0A is CRLF and the 1A is a DOS end-of-file character, so a file transferred in text mode — which would rewrite 0A into 0D 0A — becomes detectably corrupt rather than silently mangled.
After the signature, a decoder does no guessing. It reads chunks until it hits IEND, and the first chunk must be IHDR. That ordering requirement is what lets a decoder allocate anything at all, because IHDR carries the five parameters the rest of the pipeline is a function of.
Chunk framing: length, type, CRC
Each chunk is four fields in sequence: a 4-byte unsigned length in big-endian order, a 4-byte type, that many bytes of data, and a 4-byte CRC-32. The length field counts only the data portion, so a chunk occupies 12 + length bytes on disk, and the maximum legal length is $2^{31}-1$ — one bit is reserved so that length arithmetic in a signed 32-bit decoder cannot overflow.
The type is four ASCII letters, and the letters are themselves a bit field:
| Bit position | Meaning when set | Effect |
|---|---|---|
| Bit 5 of byte 0 (lowercase first letter) | Ancillary | Decoder may ignore the chunk |
| Bit 5 of byte 1 | Private | Not registered with the specification |
| Bit 5 of byte 2 | Reserved | Must be uppercase; a lowercase letter here is an error |
| Bit 5 of byte 3 | Safe to copy | May be copied even when the image data was modified |
The ancillary bit is the load-bearing one. IDAT, IHDR, PLTE, and IEND are critical; tEXt, gAMA, sRGB, pHYs, tRNS, and the rest are ancillary, and an editor that does not understand a chunk is permitted to drop it. That permission is why colour management through PNG is fragile: sRGB is ancillary, so a tool that rewrites the file without knowing the chunk can strip the colour space tag and leave the pixels untouched. The safe-to-copy bit exists to prevent exactly that class of loss for chunks whose meaning survives a pixel edit.
The CRC is CRC-32 with the polynomial used by ISO 3309 / ITU-T V.42, computed over the type bytes and the data bytes but not the length. It is a transmission check, not a security measure — an adversarial file can recompute it freely — and its real function is to make truncation and byte-shift damage visible at the point of detection instead of deep inside a decompressor.
Why filtering happens before deflate, not after
Deflate is a general-purpose compressor built on LZ77 back-references and Huffman coding. It finds repeated byte strings. Natural photographic or rendered imagery consists of smooth gradients where neighbouring pixels differ by small amounts, so the byte values are all different and there is almost nothing for LZ77 to match. What is highly repetitive is the difference between neighbours. PNG therefore transforms the image before compression so that the common case becomes a long run of small values, frequently zero.
The transform is per scanline, and the encoder picks one of five filter types for each row independently. The filter byte precedes the row’s data, so a scanline on disk is 1 + ceil(width * bits_per_pixel / 8) bytes. The value bpp used by the filters is the number of bytes per complete pixel, rounded up but never less than 1 — so an 8-bit grayscale image has bpp = 1, a 24-bit RGB image has bpp = 3, and a 1-bit image also has bpp = 1.
Write Raw(x) for the byte at offset $x$ in the current unfiltered scanline and Prior(x) for the byte at the same offset in the previously reconstructed scanline. The five filters, all computed modulo 256:
- None (0): the row is stored as-is.
- Sub (1):
Raw(x) - Raw(x - bpp). - Up (2):
Raw(x) - Prior(x). - Average (3):
Raw(x) - floor((Raw(x - bpp) + Prior(x)) / 2). - Paeth (4):
Raw(x) - PaethPredictor(Raw(x-bpp), Prior(x), Prior(x-bpp)).
Paeth predicts the byte from the one to the left $a$, the one above $b$, and the one above-left $c$. It computes $p = a + b - c$ and returns whichever of $a$, $b$, $c$ is closest to $p$, with ties broken in that order. The geometric reading: $p$ is where the value would land if the local gradient continued, and the predictor picks the neighbour that best agrees with that extrapolation. On a smooth diagonal edge Sub and Up each waste a nonzero residual; Paeth tends to produce zeros on both sides of the edge.
Reconstructing four filters into one loop
Because each row’s filter depends only on the row itself and the row above, reconstruction is a single pass in raster order with one scratch row. A decoder cannot process rows out of order, cannot parallelize across rows without first computing the rows above, and cannot skip the Prior buffer even for images whose every row uses Sub.
/* Undo the filter for one scanline in place. row points at the filter byte;
prev points at the previous reconstructed scanline (zeros for row 0),
or is NULL for the first row. stride is the byte width of a scanline. */
static void unfilter_row(unsigned char *row, const unsigned char *prev,
size_t stride, int bpp)
{
int type = row[0];
unsigned char *cur = row + 1;
for (size_t i = 0; i < stride; i++) {
int a = (i >= (size_t)bpp) ? cur[i - bpp] : 0; /* left */
int b = prev ? prev[i] : 0; /* above */
int c = (prev && i >= (size_t)bpp) ? prev[i - bpp] : 0;
switch (type) {
case 0: break;
case 1: cur[i] = (unsigned char)(cur[i] + a); break;
case 2: cur[i] = (unsigned char)(cur[i] + b); break;
case 3: cur[i] = (unsigned char)(cur[i] + ((a + b) >> 1)); break;
case 4: {
int p = a + b - c;
int pa = abs(p - a), pb = abs(p - b), pc = abs(p - c);
int pred = (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c);
cur[i] = (unsigned char)(cur[i] + pred);
break;
}
}
}
}
Two details in that loop are easy to get wrong. a must read from the reconstructed buffer, not the filtered one, which is why the loop overwrites in place and moves forward: by the time offset $i$ is processed, cur[i - bpp] already holds a decoded byte. And on row 0 the Prior row is defined as all zeros rather than as absent, so Up and Average and Paeth all degenerate gracefully.
The arithmetic is explicitly modulo 256 at every step. In C the assignment to unsigned char performs that reduction, but the intermediate in the Average filter must use integer division by two after the sum, not a signed shift, and the Paeth selector uses the absolute differences of signed values.
Adam7 turns one image into seven sub-images
Interlaced PNG does not store a progressively refined single raster. It splits the image into seven passes, each a complete image with its own dimensions and its own scanline filters, and concatenates them in the deflate stream in pass order. Each pass is defined by a starting offset and a step in each axis:
| Pass | Start $(x, y)$ | Step $x$ | Step $y$ | Pixels covered |
|---|---|---|---|---|
| 1 | (0, 0) | 8 | 8 | 1 in 64 |
| 2 | (4, 0) | 8 | 8 | 1 in 64 |
| 3 | (0, 4) | 4 | 8 | 1 in 32 |
| 4 | (2, 0) | 4 | 4 | 1 in 16 |
| 5 | (0, 2) | 2 | 4 | 1 in 8 |
| 6 | (1, 0) | 2 | 2 | 1 in 4 |
| 7 | (0, 1) | 1 | 2 | 1 in 2 |
The last pass has a step of one in $x$ and two in $y$, so it carries every odd row at full width — half the image’s pixels, and the only pass that is not horizontally subsampled.
Two consequences follow. The filter’s Prior row for a pass is the previous row of that pass, which is two or more image rows above for every pass except the last; a decoder that resets its Prior buffer between passes is correct, and one that carries it across passes produces garbage in the lower half of the image. And a pass whose width or height is zero is omitted entirely rather than encoded as an empty row set, so the pass count in a small image can be fewer than seven.
The layout optimizes for early preview: the first pass carries one pixel in 64 and can be painted as a coarse thumbnail after decoding roughly a sixty-fourth of the data. The cost is that partial decoding requires seven separate filter states, and that the total stream is slightly larger than the progressive one because each pass restarts deflate’s dictionary.
Where decoders diverge
Several behaviours are specified but routinely implemented loosely, and the differences matter when files move between tools.
IDATsplitting. The compressed stream may be cut into any number of consecutiveIDATchunks with arbitrary boundaries. A decoder must concatenate them before inflating; a decoder that inflates each chunk separately fails on files written by tools that split at 8 KiB boundaries.- Trailing bytes. After the final deflate block, the zlib stream carries an Adler-32 checksum. Some encoders leave extra bytes before
IEND. The specification forbids it; tolerant decoders accept it. - Filter type range. Only values 0–4 exist. A byte outside that range is a corrupt stream, and a decoder that masks it to
type & 3will silently produce plausible-looking noise. - Colour type 3 bit depths. An indexed image may use 1, 2, 4, or 8 bits per pixel. Sub-byte pixels are packed most-significant-bit first within each byte, and the filter operates on that packed representation, so
bpp = 1and the Sub filter differences adjacent bytes, not adjacent pixels. Expanding to one byte per index before unfiltering inverts the wrong transform. tRNSsemantics. For colour types 0 and 2 the chunk holds a single sample value treated as fully transparent, which cannot express partial alpha; for colour type 3 it holds one alpha byte per palette entry. Alpha therefore arrives from two structurally different places, and how the resulting channel layout is interpreted downstream is the subject of channel packing conventions.
The broader lesson generalizes past PNG. A container format that separates structural metadata from compressed payload forces a strict two-phase decode: parse the header tree, then interpret the payload through the parameters the header supplied. Getting the second phase right requires inverting whatever preconditioning the encoder applied for the compressor’s benefit — and every format in this space, from block-compressed GPU textures to tiled EXR, has some version of that step. The bit-rate accounting for one of those, GPU block compression, is set out in block-compressed texture bit rates.
References
- Portable Network Graphics (PNG) Specification (Second Edition) — W3C Recommendation, 2003
- RFC 1950: ZLIB Compressed Data Format Specification version 3.3 — IETF
- RFC 1951: DEFLATE Compressed Data Format Specification version 1.3 — IETF
- PNG: The Definitive Guide — Greg Roelofs, O'Reilly, 1999