Learn Zig Series (#167) - PNG Decoder in Zig
What will I learn?
- The actual byte layout of a PNG file: the 8-byte signature, the chunk framing (
length,type,data,CRC), and why that framing is such a pleasant thing to parse; - How to read the IHDR header into a proper Zig
struct, validating every field with real errors in stead of trusting the bytes; - Why a PNG's pixel data is zlib-compressed and how to inflate it with
std.compresswithout writing DEFLATE from scratch; - The part that trips everyone up the first time -- scanline filtering -- and how to reverse all five filter types (None, Sub, Up, Average, Paeth) to reconstruct the raw pixels;
- Wiring it into a single
decodethat hands you back anRgbabuffer ready for theFramebufferfrom episode 156; - Testing a decoder with round-trips and hand-built fixtures, a few honest performance notes, and how C, Rust and Go tackle the exact same problem.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org) -- the code here is written against Zig 0.16;
- The
Rgbapixel type and theFramebufferfrom episode 156, plus the PPM/BMP reading habits from episode 44; - Comfort with packed structs and bit work (episode 17), error unions (episode 4), and allocators (episode 7) -- today uses all three;
- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache
- Learn Zig Series (#124) - Consistent Hashing
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Zig Series (#130) - Mini Project: Database Engine - SQL Parser
- Learn Zig Series (#131) - Lexing a Simple Language
- Learn Zig Series (#132) - Recursive Descent Parsing
- Learn Zig Series (#133) - AST Design and Traversal
- Learn Zig Series (#134) - Type Checking
- Learn Zig Series (#135) - Bytecode Design
- Learn Zig Series (#136) - Stack-Based Virtual Machine
- Learn Zig Series (#137) - Closures and Upvalues
- Learn Zig Series (#138) - Garbage Collection: Mark and Sweep
- Learn Zig Series (#139) - Garbage Collection: Generational
- Learn Zig Series (#140) - JIT Compilation Basics
- Learn Zig Series (#141) - Regex: Thompson NFA
- Learn Zig Series (#142) - Regex: NFA to DFA
- Learn Zig Series (#143) - Regex: Matching Engine
- Learn Zig Series (#144) - Code Generation: AST to Machine Code
- Learn Zig Series (#145) - Register Allocation
- Learn Zig Series (#146) - Mini Project: Calculator - Lexer/Parser
- Learn Zig Series (#147) - Mini Project: Calculator - Interpreter
- Learn Zig Series (#148) - Mini Project: Calculator - Bytecode Compiler
- Learn Zig Series (#149) - Mini Project: Calculator - VM with Debugger
- Learn Zig Series (#150) - Mini Project: Lisp - Reader
- Learn Zig Series (#151) - Mini Project: Lisp - Evaluator
- Learn Zig Series (#152) - Mini Project: Lisp - Special Forms and Macros
- Learn Zig Series (#153) - Mini Project: Lisp - Standard Library
- Learn Zig Series (#154) - Mini Project: Regex Engine - NFA
- Learn Zig Series (#155) - Mini Project: Regex Engine - Matching
- Learn Zig Series (#156) - Framebuffer Basics
- Learn Zig Series (#157) - Line Drawing: Bresenham
- Learn Zig Series (#158) - Circle and Ellipse Rasterization
- Learn Zig Series (#159) - Polygon Filling: Scanline
- Learn Zig Series (#160) - 2D Transform Matrices
- Learn Zig Series (#161) - Double Buffering and Vsync
- Learn Zig Series (#162) - Sprite Rendering and Tile Maps
- Learn Zig Series (#163) - Bitmap Font Rendering
- Learn Zig Series (#164) - TrueType Parsing
- Learn Zig Series (#165) - Color Spaces: RGB, HSV, sRGB
- Learn Zig Series (#166) - Alpha Blending and Compositing
- Learn Zig Series (#167) - PNG Decoder in Zig (this post)
Learn Zig Series (#167) - PNG Decoder in Zig
For thirty-odd episodes now we have been generating pixels -- drawing lines, filling polygons, rasterizing glyphs, compositing translucent layers onto a framebuffer. I closed last episode by saying the next thing we do is pull real images in from disk, in the compressed forms the rest of the world actually ships around. Today we make good on that: we write a PNG decoder from the raw bytes up. By the end you will hand a .png file to a function and get back an Rgba buffer you can drop straight onto the Framebuffer from episode 156. No stb_image, no libpng -- we read the format ourselves and understand every byte we touch. Here we go!
But first, the three loose ends from the compositing episode.
Solutions to Episode 166 Exercises
Exercise 1 -- additive and multiply blend modes. Beyond Porter-Duff sit the "blend modes" every image editor ships. add clamps the sum (glows, fire, sparks); multiply darkens by folding one image into the other (shadows, tinting). Both are honest only in linear light, so we decode the channels, do the arithmetic, and re-encode -- exactly the discipline episode 165 hammered on:
const std = @import("std");
pub const RgbF = struct { r: f32, g: f32, b: f32 };
fn srgbToLinear(c: f32) f32 {
if (c <= 0.04045) return c / 12.92;
return std.math.pow(f32, (c + 0.055) / 1.055, 2.4);
}
fn linearToSrgb(c: f32) f32 {
if (c <= 0.0031308) return c * 12.92;
return 1.055 * std.math.pow(f32, c, 1.0 / 2.4) - 0.055;
}
fn perChannel(src: RgbF, dst: RgbF, comptime op: fn (f32, f32) f32) RgbF {
const mix = struct {
fn ch(s: f32, d: f32) f32 {
return linearToSrgb(op(srgbToLinear(s), srgbToLinear(d)));
}
};
return .{ .r = mix.ch(src.r, dst.r), .g = mix.ch(src.g, dst.g), .b = mix.ch(src.b, dst.b) };
}
pub fn add(src: RgbF, dst: RgbF) RgbF {
return perChannel(src, dst, struct {
fn f(s: f32, d: f32) f32 {
return @min(s + d, 1.0);
}
}.f);
}
pub fn multiply(src: RgbF, dst: RgbF) RgbF {
return perChannel(src, dst, struct {
fn f(s: f32, d: f32) f32 {
return s * d;
}
}.f);
}
test "multiply with white is identity, with black is black" {
const dst = RgbF{ .r = 0.4, .g = 0.7, .b = 0.2 };
const white = RgbF{ .r = 1, .g = 1, .b = 1 };
const black = RgbF{ .r = 0, .g = 0, .b = 0 };
const same = multiply(white, dst);
try std.testing.expectApproxEqAbs(dst.r, same.r, 0.002);
const dark = multiply(black, dst);
try std.testing.expectApproxEqAbs(@as(f32, 0.0), dark.g, 0.002);
}
Passing the operation in as a comptime fn means add and multiply share the whole decode/encode scaffold and differ by a single line -- the compiler inlines the callback, so there is no runtime cost for the abstraction. Multiply-by-white is the identity because 1 * x = x in linear light too, and multiply-by-black annihilates because 0 * x = 0. Those two laws are the cheapest possible sanity check.
Exercise 2 -- a dissolve operator. Not all transparency is a smooth fade. dissolve turns an alpha of 0.5 into a random half of the pixels being fully opaque and the rest fully transparent -- a grainy, stylised transition. The catch is that it must be stable frame to frame, or it shimmers. So we hash the coordinates (episode 22's hashing) with a per-run seed in stead of calling a real RNG:
const std = @import("std");
pub const RgbaF = struct { r: f32, g: f32, b: f32, a: f32 };
// A cheap, stable per-pixel hash -> a value in [0,1). Same (x,y,seed) always
// yields the same threshold, so the dissolve pattern does not shimmer.
fn pixelNoise(x: u32, y: u32, seed: u64) f32 {
var h: u64 = seed ^ (@as(u64, x) *% 0x9E3779B97F4A7C15);
h ^= @as(u64, y) *% 0xC2B2AE3D27D4EB4F;
h ^= h >> 29;
h *%= 0xBF58476D1CE4E5B9;
h ^= h >> 32;
const scaled: u32 = @truncate(h);
return @as(f32, @floatFromInt(scaled)) / 4294967296.0;
}
pub fn dissolve(src: RgbaF, dst: RgbaF, alpha: f32, x: u32, y: u32, seed: u64) RgbaF {
const a = std.math.clamp(alpha, 0.0, 1.0);
// Pixel is fully src if its stable noise falls under the coverage, else fully dst.
return if (pixelNoise(x, y, seed) < a) src else dst;
}
test "dissolve endpoints behave like identities" {
const src = RgbaF{ .r = 1, .g = 0, .b = 0, .a = 1 };
const dst = RgbaF{ .r = 0, .g = 0, .b = 1, .a = 1 };
// alpha 0 -> always dst, alpha 1 -> always src, for any coordinate
var y: u32 = 0;
while (y < 8) : (y += 1) {
var x: u32 = 0;
while (x < 8) : (x += 1) {
try std.testing.expectEqual(dst.b, dissolve(src, dst, 0.0, x, y, 42).b);
try std.testing.expectEqual(src.r, dissolve(src, dst, 1.0, x, y, 42).r);
}
}
}
The mixing constants are the same integer-hash tricks a good non-cryptographic hash uses -- multiply by a large odd number, xor-shift the high bits down, repeat. Because pixelNoise is a pure function of (x, y, seed), the exact same grain appears every frame until you change the seed, which is precisely what you want for a controllable transition.
Exercise 3 -- flatten a layer stack. With premultiplied over being associative, flattening an ordered slice of layers is just a fold from bottom to top. The associativity law then says any grouping of that fold gives the same answer, and the test puts that to work by splitting the stack and re-joining it:
const std = @import("std");
pub const Premul = struct { r: f32, g: f32, b: f32, a: f32 };
fn overPremul(src: Premul, dst: Premul) Premul {
const inv = 1.0 - src.a;
return .{
.r = src.r + dst.r * inv,
.g = src.g + dst.g * inv,
.b = src.b + dst.b * inv,
.a = src.a + dst.a * inv,
};
}
// Composite an ordered slice bottom-to-top. layers[0] is the bottom, layers[len-1] the top.
pub fn flatten(layers: []const Premul) Premul {
var acc = Premul{ .r = 0, .g = 0, .b = 0, .a = 0 };
for (layers) |layer| acc = overPremul(layer, acc);
return acc;
}
test "flattening in two groups equals flattening all at once" {
const stack = [_]Premul{
.{ .r = 0.0, .g = 0.0, .b = 0.5, .a = 1.0 },
.{ .r = 0.3, .g = 0.0, .b = 0.0, .a = 0.5 },
.{ .r = 0.0, .g = 0.4, .b = 0.0, .a = 0.4 },
.{ .r = 0.2, .g = 0.2, .b = 0.0, .a = 0.3 },
.{ .r = 0.0, .g = 0.0, .b = 0.1, .a = 0.6 },
};
const all = flatten(&stack);
const bottom = flatten(stack[0..2]);
const top = flatten(stack[2..]);
const joined = overPremul(top, bottom);
try std.testing.expectApproxEqAbs(all.r, joined.r, 0.0001);
try std.testing.expectApproxEqAbs(all.a, joined.a, 0.0001);
}
Note the fold direction -- overPremul(layer, acc) puts each new layer over the accumulated result, and we walk the slice from index 0 (bottom), so the last iteration lays the top layer down last. That "put the new one over the running total" is the whole trick, and associativity is what lets a compositor cache bottom and top separately and still get a pixel-identical joined. Right, the compositing chapter is closed. Now let us open a file.
What a PNG file actually is
A PNG is one of the most readable binary formats you will ever meet, which is a mercy after TrueType (episode 164). It is two things stacked: a fixed 8-byte signature, followed by a sequence of chunks, and nothing else. The signature is \x89PNG\r\n\x1a\n -- eight bytes chosen with real care. The high bit on the first byte catches transfers that stripped it; PNG is human-readable in a hex dump; the \r\n pair catches naive CRLF translation; the \x1a (Ctrl-Z) stops old DOS type from spewing the rest; and the trailing \n catches the reverse newline mangling. A format that defends itself against broken FTP clients in its first eight bytes is a format designed by people who had been burned.
Each chunk after that has the same four-part shape: a big-endian u32 length (of the data only), a 4-byte type (ASCII, like IHDR or IDAT), that many bytes of data, and a big-endian u32 CRC-32 over the type-plus-data. Everything multi-byte in PNG is big-endian ("network byte order"), which is why episode 21's networking habits transfer directly. The chunks we care about to get pixels on screen are three: IHDR (the header, always first), one or more IDAT (the compressed pixel stream), and IEND (a zero-length end marker). Everything else -- PLTE, gAMA, tEXt, pHYs -- we can skip, and the format tells us we may: a lower-case first letter means "ancillary, safe to ignore". Let me build a small reader that walks these chunks:
const std = @import("std");
pub const DecodeError = error{
BadSignature,
Truncated,
BadCrc,
MissingHeader,
UnsupportedFormat,
InvalidData,
};
pub const Chunk = struct {
kind: [4]u8,
data: []const u8,
};
pub const ChunkReader = struct {
bytes: []const u8,
pos: usize,
const signature = [8]u8{ 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n' };
pub fn init(bytes: []const u8) DecodeError!ChunkReader {
if (bytes.len < 8 or !std.mem.eql(u8, bytes[0..8], &signature))
return DecodeError.BadSignature;
return .{ .bytes = bytes, .pos = 8 };
}
fn readU32(self: *ChunkReader) DecodeError!u32 {
if (self.pos + 4 > self.bytes.len) return DecodeError.Truncated;
const v = std.mem.readInt(u32, self.bytes[self.pos..][0..4], .big);
self.pos += 4;
return v;
}
// Returns the next chunk, or null at end of stream. Verifies the CRC as it goes.
pub fn next(self: *ChunkReader) DecodeError!?Chunk {
if (self.pos == self.bytes.len) return null;
const len = try self.readU32();
if (self.pos + 4 + len + 4 > self.bytes.len) return DecodeError.Truncated;
const kind = self.bytes[self.pos..][0..4].*;
const data = self.bytes[self.pos + 4 ..][0 .. 4 + len]; // type + data, for the CRC
const stored_crc = std.mem.readInt(u32, self.bytes[self.pos + 4 + len ..][0..4], .big);
if (std.hash.crc.Crc32.hash(data) != stored_crc) return DecodeError.BadCrc;
self.pos += 4 + len + 4;
return Chunk{ .kind = kind, .data = data[4..] }; // strip the type back off for the caller
}
};
Two things are worth pausing on. First, std.mem.readInt(u32, ..., .big) reads a big-endian integer with the endianness spelled out as an argument -- no manual shift-and-or, no accidental little-endian bug on your x86 laptop. Second, the CRC is computed over the type and the data (that is the spec), so I keep them adjacent as data for the hash and only slice the 4-byte type off when handing the chunk back. Zig's slices make that framing arithmetic explicit and bounds-checkable, and the Truncated guard means a malformed file gives a clean error in stead of a panic. That is the whole point of episode 4's error unions: an untrusted file is a hostile file, and every read that could run off the end returns an error the caller must handle.
Parsing the header (IHDR)
The first chunk is always IHDR, and it is exactly 13 bytes: width and height as big-endian u32, then five single bytes -- bit depth, color type, compression method, filter method, and interlace flag. This is where Zig's type system earns its keep. Rather than pass five loose integers around, I decode into a real struct with an enum for the color type, and reject anything we do not support at parse time:
const std = @import("std");
pub const ColorType = enum(u8) {
grayscale = 0,
rgb = 2,
palette = 3,
grayscale_alpha = 4,
rgba = 6,
// How many samples per pixel this color type stores.
pub fn channels(self: ColorType) u8 {
return switch (self) {
.grayscale => 1,
.rgb => 3,
.palette => 1, // an index into PLTE
.grayscale_alpha => 2,
.rgba => 4,
};
}
};
pub const Header = struct {
width: u32,
height: u32,
bit_depth: u8,
color_type: ColorType,
interlace: u8,
pub fn parse(data: []const u8) DecodeError!Header {
if (data.len != 13) return DecodeError.InvalidData;
const width = std.mem.readInt(u32, data[0..4], .big);
const height = std.mem.readInt(u32, data[4..8], .big);
if (width == 0 or height == 0) return DecodeError.InvalidData;
const color_type = std.meta.intToEnum(ColorType, data[9]) catch
return DecodeError.UnsupportedFormat;
const bit_depth = data[8];
// This teaching decoder handles 8-bit truecolor with and without alpha only.
if (bit_depth != 8) return DecodeError.UnsupportedFormat;
if (color_type != .rgb and color_type != .rgba) return DecodeError.UnsupportedFormat;
if (data[12] != 0) return DecodeError.UnsupportedFormat; // no Adam7 interlacing here
return .{
.width = width,
.height = height,
.bit_depth = bit_depth,
.color_type = color_type,
.interlace = data[12],
};
}
pub fn bytesPerPixel(self: Header) usize {
return self.color_type.channels();
}
};
test "IHDR parse accepts truecolor, rejects nonsense" {
var data = [_]u8{0} ** 13;
std.mem.writeInt(u32, data[0..4], 2, .big); // width 2
std.mem.writeInt(u32, data[4..8], 1, .big); // height 1
data[8] = 8; // bit depth
data[9] = 6; // color type rgba
const h = try Header.parse(&data);
try std.testing.expectEqual(@as(u32, 2), h.width);
try std.testing.expectEqual(@as(usize, 4), h.bytesPerPixel());
data[9] = 99; // not a real color type
try std.testing.expectError(DecodeError.UnsupportedFormat, Header.parse(&data));
}
std.meta.intToEnum is the little gem here: it turns a raw byte into the enum only if it is a valid variant, and returns an error otherwise -- so a garbage color-type byte becomes an UnsupportedFormat error rather than an illegal enum value lurking in memory. I deliberately scope this decoder to 8-bit RGB and RGBA, non-interlaced, because that covers the overwhelming majority of PNGs you will meet and keeps us focused on the interesting machinery (filtering) in stead of drowning in the format's every option. The UnsupportedFormat errors are honest: we do not silently mis-decode a 16-bit or palettized image, we refuse it and say so. Extending to those is exactly what the exercises are for.
Inflating the pixel stream
Here is the part people expect to be the hard bit and it is, mercifully, not. The concatenated bytes of all IDAT chunks form a single zlib stream, and zlib is a thin wrapper (a 2-byte header, an Adler-32 checksum at the tail) around DEFLATE, the LZ77-plus-Huffman algorithm you also find in gzip and the ZIP format. Writing an inflate from scratch is a whole episode of its own -- and honestly not the best use of your time when the standard library already ships a correct, well-tested one. So we lean on std.compress. I gather the IDAT payloads into one buffer and inflate them in a single call:
const std = @import("std");
// Concatenate every IDAT chunk, then inflate the zlib stream into raw filtered scanlines.
fn inflateIdat(allocator: std.mem.Allocator, idat: []const u8, expected: usize) DecodeError![]u8 {
var stream = std.io.fixedBufferStream(idat);
var decompress = std.compress.zlib.decompressor(stream.reader());
// We know the exact decompressed size up front (see decode below), so allocate once.
const out = try allocator.alloc(u8, expected);
errdefer allocator.free(out);
const n = decompress.reader().readAll(out) catch return DecodeError.InvalidData;
if (n != expected) return DecodeError.InvalidData; // stream did not produce what IHDR promised
return out;
}
The lovely thing is that we know the exact decompressed length before we start: the header gives us width, height and channels, and each row is width * bytesPerPixel + 1 bytes (that + 1 is the per-row filter byte, coming up next). So we allocate the output buffer once, in one shot, in stead of growing it -- no reallocation churn in the hot path. errdefer frees it if anything downstream fails, which is the episode 7 discipline for "own it until you have safely handed it off". Comparing the produced length against the expected length is a cheap integrity check on top of the CRC and Adler-32 the format already carries -- belt, braces, and a second pair of braces.
Unfiltering: the part that trips everyone up
If you inflate the IDAT and squint at the bytes expecting pixels, you will be baffled, because PNG does not compress the pixels directly. Before compression, each scanline is filtered: transformed by a reversible predictor that turns the image into something with long runs and small deltas, which DEFLATE then squashes far better. Every row is prefixed with one filter-type byte (0 to 4) saying how that row was transformed, and our job on the way in is to reverse it. The five filters are None (0), Sub (1, predict from the pixel to the left), Up (2, from the pixel above), Average (3, from the mean of left and up), and Paeth (4, a clever nonlinear pick among left, up and up-left). The reconstruction is per-byte and refers to already-reconstructed neighbours:
const std = @import("std");
// The Paeth predictor: of left (a), up (b) and up-left (c), pick the one closest to
// the linear estimate a + b - c. Ties break toward a, then b. Pure integer math.
fn paeth(a: u8, b: u8, c: u8) u8 {
const p: i32 = @as(i32, a) + @as(i32, b) - @as(i32, c);
const pa = @abs(p - @as(i32, a));
const pb = @abs(p - @as(i32, b));
const pc = @abs(p - @as(i32, c));
if (pa <= pb and pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
// Reverse one scanline's filter in place. `cur` is the raw row (no filter byte),
// `prev` is the already-reconstructed row above (all zero for the first row), and
// `bpp` is bytes-per-pixel so "the pixel to the left" means bpp bytes back.
fn unfilter(filter: u8, cur: []u8, prev: []const u8, bpp: usize) DecodeError!void {
switch (filter) {
0 => {}, // None
1 => for (cur, 0..) |*x, i| { // Sub
const left = if (i >= bpp) cur[i - bpp] else 0;
x.* +%= left;
},
2 => for (cur, 0..) |*x, i| { // Up
x.* +%= prev[i];
},
3 => for (cur, 0..) |*x, i| { // Average
const left: u16 = if (i >= bpp) cur[i - bpp] else 0;
const up: u16 = prev[i];
x.* +%= @intCast((left + up) / 2);
},
4 => for (cur, 0..) |*x, i| { // Paeth
const left = if (i >= bpp) cur[i - bpp] else 0;
const up = prev[i];
const up_left = if (i >= bpp) prev[i - bpp] else 0;
x.* +%= paeth(left, up, up_left);
},
else => return DecodeError.InvalidData,
}
}
Every one of these is defined modulo 256 -- the encoder subtracted the predictor with wraparound, so we add it back with wraparound, which is Zig's +%= wrapping-add operator. That % is not decoration: a plain += would panic on overflow in a debug build the moment a byte crossed 255, and every real image crosses 255 constantly. This is Zig being honest about integer overflow (episode 17) and handing you the wrapping variant explicitly when you actually want it. The Paeth predictor is the one worth studying: it estimates each byte as left + up - upleft (the value that would make a little 2x2 gradient consistent) and then snaps to whichever of the three real neighbours is closest to that estimate. It is nonlinear, it is per-byte, and it is why PNG compresses photographic gradients so well.
Putting the decoder together
Now the pieces click. Walk the chunks, parse IHDR, collect IDAT, inflate, unfilter row by row, and emit Rgba pixels (padding RGB up to opaque RGBA so the output is always four channels and drops straight onto our framebuffer):
const std = @import("std");
pub const Image = struct {
width: u32,
height: u32,
pixels: []Rgba, // always RGBA, row-major, top-to-bottom
pub fn deinit(self: *Image, allocator: std.mem.Allocator) void {
allocator.free(self.pixels);
}
};
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub fn decode(allocator: std.mem.Allocator, bytes: []const u8) DecodeError!Image {
var reader = try ChunkReader.init(bytes);
var header: ?Header = null;
var idat = std.ArrayList(u8).init(allocator);
defer idat.deinit();
while (try reader.next()) |chunk| {
if (std.mem.eql(u8, &chunk.kind, "IHDR")) {
header = try Header.parse(chunk.data);
} else if (std.mem.eql(u8, &chunk.kind, "IDAT")) {
idat.appendSlice(chunk.data) catch return DecodeError.InvalidData;
} else if (std.mem.eql(u8, &chunk.kind, "IEND")) {
break;
}
// Any other chunk (ancillary or unknown) is simply ignored -- as the spec allows.
}
const h = header orelse return DecodeError.MissingHeader;
const bpp = h.bytesPerPixel();
const stride = @as(usize, h.width) * bpp; // bytes of pixel data per row (no filter byte)
const raw_len = (stride + 1) * h.height; // +1 filter byte per row
const raw = try inflateIdat(allocator, idat.items, raw_len);
defer allocator.free(raw);
const pixels = try allocator.alloc(Rgba, @as(usize, h.width) * h.height);
errdefer allocator.free(pixels);
// Scratch rows for the reconstruction; prev starts as all zeros (virtual row -1).
const prev = try allocator.alloc(u8, stride);
defer allocator.free(prev);
@memset(prev, 0);
const cur = try allocator.alloc(u8, stride);
defer allocator.free(cur);
var y: usize = 0;
while (y < h.height) : (y += 1) {
const row_start = y * (stride + 1);
const filter = raw[row_start];
@memcpy(cur, raw[row_start + 1 ..][0..stride]);
try unfilter(filter, cur, prev, bpp);
// Emit this row as RGBA, padding alpha to 255 when the source is RGB.
var x: usize = 0;
while (x < h.width) : (x += 1) {
const off = x * bpp;
pixels[y * h.width + x] = .{
.r = cur[off],
.g = cur[off + 1],
.b = cur[off + 2],
.a = if (bpp == 4) cur[off + 3] else 255,
};
}
@memcpy(prev, cur); // this row becomes the "above" row for the next one
}
return .{ .width = h.width, .height = h.height, .pixels = pixels };
}
The shape of this function is the whole lesson in Zig memory management. Every allocation has a matching defer or errdefer right next to it, so ownership is obvious at a glance and nothing leaks on any error path -- raw and the scratch rows are scratch (freed on the way out), while pixels is the one buffer we hand back, guarded by errdefer until the final return. The prev/cur two-row scheme means we only ever hold two scanlines of working state, not a full second copy of the image. And notice IHDR-before-IDAT is enforced structurally: header is an optional, and we orelse return DecodeError.MissingHeader before touching it, so a file with IDAT and no header is a clean error, not a null-dereference. That is the Zig way -- the awkward states are unrepresentable, or they are explicit errors.
Testing a decoder
You cannot unit-test a decoder against a real PNG without shipping a binary blob, and blobs make lousy tests -- opaque, un-diffable, and a pain to reason about. The better approach for a from-scratch codec is to test the pieces against hand-built byte fixtures, where you can see exactly what should come out. The signature check, the CRC path, the Paeth predictor and the unfilter step are all pure functions over small inputs, which is exactly what episode 12's TDD instincts want:
const std = @import("std");
test "signature rejection is immediate and specific" {
const not_png = [_]u8{ 0x89, 'P', 'N', 'G', 0, 0, 0, 0, 0xde, 0xad };
try std.testing.expectError(DecodeError.BadSignature, ChunkReader.init(¬_png));
}
test "Sub filter reconstructs from the left neighbour" {
// bpp = 3 (RGB). Encoder stored deltas; decoder adds the left pixel back.
var cur = [_]u8{ 10, 20, 30, 5, 5, 5 }; // second pixel is +5 from the first
const prev = [_]u8{0} ** 6;
try unfilter(1, &cur, &prev, 3);
try std.testing.expectEqualSlices(u8, &[_]u8{ 10, 20, 30, 15, 25, 35 }, &cur);
}
test "Paeth predictor picks the nearest neighbour and wraps" {
// Flat region: left == up == up-left == 100, estimate is 100, so predictor is 100.
try std.testing.expectEqual(@as(u8, 100), paeth(100, 100, 100));
// Up-filter wraps modulo 256 without panicking.
var cur = [_]u8{250};
const prev = [_]u8{10};
try unfilter(2, &cur, &prev, 1);
try std.testing.expectEqual(@as(u8, 4), cur[0]); // 250 + 10 = 260 -> 4
}
That last assertion is the one I would fight to keep: 250 + 10 reconstructing to 4 proves the wrapping arithmetic is doing its job, and if someone ever "cleans up" +%= into +=, this test fails loudly in stead of the decoder panicking on the first bright pixel of a real photo. Testing the invariant (wraparound) beats testing a whole decoded image, because it pins the exact behaviour that is easy to get subtly wrong.
Performance: where the time actually goes
Profile a naive PNG decoder (episode 34, always measure first) and you find the cost is not where beginners guess. It is not the chunk walking, and it is not the CRC. It is two things: inflate, which dominates on large images and is the standard library's problem to optimise, not ours; and the unfilter loop, which touches every single byte of the decompressed image with a data-dependent branch on the filter type. A few honest levers, in the order episode 34 would apply them:
- Specialise the hot loop by filter type. The
switch (filter)sits outside the per-byte loop already (good), so the branch is paid once per row, not once per byte. Do not undo that by moving the switch inside. - Unroll by bytes-per-pixel. For a fixed
bppof 3 or 4, acomptime-specialised unfilter (episode 14) lets the compiler drop theif (i >= bpp)bounds dance for all but the first pixel of the row -- a real win on the Sub and Paeth paths. - Vectorise Up and Average. The Up filter is a pure element-wise add of two rows, which is a textbook
@Vectorcase (episode 19); Average is nearly as friendly. Paeth resists vectorisation because of its per-byte branch -- that is the one that stays scalar. - Allocate once. We already do: one output buffer sized from the header, two scratch rows reused across the whole image. No per-row allocation, which is the single most common self-inflicted wound in naive decoders.
The meta-point is unchanged from episode 34: I would not reach for @Vector on the unfilter until a profile said the pixels were hot, because for a one-off icon load it is irrelevant, and for a photo the inflate dwarfs everything. Measure, then optimise the thing that is actually slow.
The same job in C, Rust and Go
The decoding logic is identical everywhere -- big-endian reads, CRC, inflate, unfilter -- so what differs is the posture toward memory and errors. In C you would almost certainly reach for stb_image.h, a single-header decoder that hands you a malloced buffer you must remember to stbi_image_free; writing the Paeth step by hand looks near-identical to ours but with no overflow checking at all -- unsigned char wraps silently, which is convenient here and a footgun everywhere else:
// C: unsigned arithmetic wraps by definition, so the Paeth add "just works" -- and the
// language gives you no help at all when wrapping is NOT what you meant elsewhere.
unsigned char paeth_recon(unsigned char cur, unsigned char a, unsigned char b, unsigned char c) {
int p = a + b - c, pa = abs(p - a), pb = abs(p - b), pc = abs(p - c);
unsigned char pred = (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c);
return cur + pred; /* wraps mod 256, silently */
}
Rust leans on the png crate, whose Decoder gives you a Reader and a next_frame that fills a byte buffer -- and the language forces the same overflow honesty Zig does, via wrapping_add:
// Rust: wrapping is explicit, exactly like Zig's +%. A plain `+` panics in debug on overflow.
fn paeth_recon(cur: u8, a: u8, b: u8, c: u8) -> u8 {
let p = a as i32 + b as i32 - c as i32;
let (pa, pb, pc) = ((p - a as i32).abs(), (p - b as i32).abs(), (p - c as i32).abs());
let pred = if pa <= pb && pa <= pc { a } else if pb <= pc { b } else { c };
cur.wrapping_add(pred)
}
Go has image/png in its standard library -- png.Decode(reader) returns an image.Image and an error, no third-party dependency at all, which is Go's whole "batteries included" philosophy on display:
// Go: byte arithmetic is mod 256 for uint8, so the reconstruction reads cleanly.
func paethRecon(cur, a, b, c uint8) uint8 {
p := int(a) + int(b) - int(c)
pa, pb, pc := abs(p-int(a)), abs(p-int(b)), abs(p-int(c))
var pred uint8
if pa <= pb && pa <= pc {
pred = a
} else if pb <= pc {
pred = b
} else {
pred = c
}
return cur + pred // uint8 addition wraps
}
The real difference is the standard-library posture again. Go ships a decoder; Rust and C reach for a well-worn crate or header; Zig gives you the inflate primitive and expects you to frame the format yourself. For most people, "just use image/png" is the right answer -- but doing it once by hand, as we just did, is what turns a format from magic into machinery. And Zig's specific edge shows in the overflow story: +%= says exactly what it means, where C wraps silently (fine here, dangerous in general) and Rust needs the verbose wrapping_add. Explicit over convenient, one more time.
Exercises
Support grayscale. Extend
Header.parseanddecodeto accept color type 0 (grayscale) and 4 (grayscale + alpha) at 8-bit depth, expanding each gray sample into equal R, G and B on output. Write a test with a hand-built 2x2 grayscale IHDR/IDAT fixture (you can store filter-0 rows so no unfiltering is needed) and assert the emittedRgbapixels haver == g == b.Read the ancillary
tEXtchunks. PNG stores metadata astEXtchunks: a keyword, a zero byte, then a value, all Latin-1. Add a pass that collects everytEXtchunk into astd.StringHashMap(episode 22) on the returnedImage, soimage.text.get("Software")works. Remember these are ancillary -- a file without any must still decode fine.Verify the zlib Adler-32. Our decoder trusts
std.compress.zlibto check the trailing Adler-32, but write your ownadler32(bytes: []const u8) u32from scratch (two rolling sums modulo 65521) and a test that it matches a known vector -- e.g. Adler-32 of the ASCII bytes of"Wikipedia"is0x11E60398. This is the checksum that guards the decompressed stream, and building it cements how zlib frames DEFLATE.
What we learned
- A PNG is a signature plus a chunk stream --
length,type,data,CRC-- and that self-describing framing (including lower-case = "safe to ignore") makes it one of the most pleasant binary formats to parse, especially withstd.mem.readInt(..., .big)doing the endianness for us; - The IHDR header decodes cleanly into a Zig
structwith anenumcolor type, andstd.meta.intToEnumplus explicitUnsupportedFormaterrors mean a hostile file is refused, never silently mis-decoded; - The pixel data is a zlib stream we inflate with
std.compressrather than reimplement -- and because IHDR tells us the exact decompressed size, we allocate the output buffer once, no growth churn; - Unfiltering is the real heart: five reversible predictors (None, Sub, Up, Average, Paeth), all defined modulo 256, which is precisely why Zig's wrapping
+%=matters and a plain+=would panic on the first bright pixel; - The decoder's memory story --
defer/errdeferbeside every allocation, a two-row scratch scheme, one buffer handed back -- is Zig's ownership discipline in miniature, and the awkward states (IDAT before IHDR) are explicit errors, not crashes; - Testing the pieces against tiny hand-built fixtures beats testing against opaque binary blobs, and C, Rust and Go all do the same decode -- differing mostly in whether the decoder lives in the standard library and how loudly the language admits that byte math wraps.
We now have real pictures coming in from disk as Rgba buffers, ready to blit with the compositing machinery from last episode. That closes a loop that has been open since episode 44: we can read images, operate on pixels, draw shapes and text, composite with correct alpha, and now ingest the format the whole web ships in. There is another huge family of image files out there built on a completely different idea -- not lossless prediction but lossy frequency-domain compression -- and it is the natural next thing to pull apart. Plenty still to build.
Bedankt en tot de volgende keer! ;-)