a73x

1acfbe4b

Add vendored minimal RGBA8 PNG codec

a73x   2026-04-17 11:26

Commit message
Add vendored minimal RGBA8 PNG codec

Supports only the narrow slice we need (RGBA8 non-interlaced with
filter type 0). Used by --capture for output and by imgdiff for
reading goldens.

The encoder writes zlib-wrapped DEFLATE stored blocks (type 0, no
compression) to avoid the incomplete std.compress.flate encoder in
Zig 0.15. The decoder uses std.compress.flate.Decompress with the
new Zig 0.15 Reader/Writer API. Two unit tests: roundtrip and
rejection of non-RGBA (RGB colour type 2) inputs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

build.zig
Old New
@@ -268,4 +268,20 @@ pub fn build(b: *std.Build) void {
268 .root_module = renderer_test_mod, 268 .root_module = renderer_test_mod,
269 }); 269 });
270 test_step.dependOn(&b.addRunArtifact(renderer_tests).step); 270 test_step.dependOn(&b.addRunArtifact(renderer_tests).step);
271
272 // png module — vendored minimal RGBA8 PNG codec
273 const png_mod = b.createModule(.{
274 .root_source_file = b.path("src/png.zig"),
275 .target = target,
276 .optimize = optimize,
277 });
278 exe_mod.addImport("png", png_mod);
279
280 const png_test_mod = b.createModule(.{
281 .root_source_file = b.path("src/png.zig"),
282 .target = target,
283 .optimize = optimize,
284 });
285 const png_tests = b.addTest(.{ .root_module = png_test_mod });
286 test_step.dependOn(&b.addRunArtifact(png_tests).step);
271 } 287 }
src/png.zig
Old New
@@ -0,0 +1,261 @@
1 const std = @import("std");
2
3 pub const Image = struct {
4 width: u32,
5 height: u32,
6 pixels: []u8, // RGBA8, row-major, width*height*4 bytes
7
8 pub fn deinit(self: *Image, alloc: std.mem.Allocator) void {
9 alloc.free(self.pixels);
10 self.* = undefined;
11 }
12 };
13
14 pub const EncodeError = error{ OutOfMemory, WriteFailed };
15 pub const DecodeError = error{
16 OutOfMemory,
17 InvalidPng,
18 UnsupportedPng, // only RGBA8 non-interlaced is supported
19 CorruptChunk,
20 };
21
22 const signature = [_]u8{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
23
24 fn adler32(data: []const u8) u32 {
25 var a: u32 = 1;
26 var b: u32 = 0;
27 for (data) |byte| {
28 a = (a + byte) % 65521;
29 b = (b + a) % 65521;
30 }
31 return (b << 16) | a;
32 }
33
34 fn writeChunk(writer: anytype, chunk_type: *const [4]u8, payload: []const u8) EncodeError!void {
35 writer.writeInt(u32, @intCast(payload.len), .big) catch return error.WriteFailed;
36 writer.writeAll(chunk_type) catch return error.WriteFailed;
37 writer.writeAll(payload) catch return error.WriteFailed;
38 var crc = std.hash.Crc32.init();
39 crc.update(chunk_type);
40 crc.update(payload);
41 writer.writeInt(u32, crc.final(), .big) catch return error.WriteFailed;
42 }
43
44 /// Build a zlib stream wrapping the `filtered` data using DEFLATE stored
45 /// blocks (type 0, no compression). This is always valid PNG and avoids
46 /// dependency on the std.compress.flate encoder, which is incomplete in
47 /// Zig 0.15.
48 fn buildZlibStored(alloc: std.mem.Allocator, filtered: []const u8) EncodeError![]u8 {
49 // zlib header: CMF=0x78 (deflate, window=32K), FLG=0x01 (no dict, level=0,
50 // fcheck makes CMF*256+FLG divisible by 31: 0x7801 % 31 == 0).
51 const zlib_header = [_]u8{ 0x78, 0x01 };
52
53 // DEFLATE stored block layout:
54 // 1 byte: BFINAL | (BTYPE << 1) — BTYPE=00 for stored
55 // 2 bytes: LEN (little-endian u16)
56 // 2 bytes: NLEN (one's complement of LEN, little-endian)
57 // LEN bytes: data
58 //
59 // Maximum single stored block payload is 65535 bytes.
60 const max_block: usize = 65535;
61 const actual_blocks: usize = if (filtered.len == 0) 1 else (filtered.len + max_block - 1) / max_block;
62 // Header per block: 5 bytes. Total deflate stream bytes:
63 const deflate_len = actual_blocks * 5 + filtered.len;
64
65 // Full buffer: zlib_header(2) + deflate + adler32(4)
66 const total = 2 + deflate_len + 4;
67 const buf = alloc.alloc(u8, total) catch return error.OutOfMemory;
68 errdefer alloc.free(buf);
69
70 var pos: usize = 0;
71 buf[pos] = zlib_header[0];
72 pos += 1;
73 buf[pos] = zlib_header[1];
74 pos += 1;
75
76 var src_pos: usize = 0;
77 var block_idx: usize = 0;
78 while (block_idx < actual_blocks) : (block_idx += 1) {
79 const remaining = filtered.len - src_pos;
80 const block_len: u16 = @intCast(@min(remaining, max_block));
81 const is_final = block_idx == actual_blocks - 1;
82 const bfinal: u8 = if (is_final) 0x01 else 0x00;
83 buf[pos] = bfinal; // BFINAL=is_final, BTYPE=00
84 pos += 1;
85 std.mem.writeInt(u16, buf[pos..][0..2], block_len, .little);
86 pos += 2;
87 const nlen: u16 = ~block_len;
88 std.mem.writeInt(u16, buf[pos..][0..2], nlen, .little);
89 pos += 2;
90 @memcpy(buf[pos..][0..block_len], filtered[src_pos..][0..block_len]);
91 pos += block_len;
92 src_pos += block_len;
93 }
94
95 // Adler-32 of the uncompressed (filtered) data, big-endian
96 std.mem.writeInt(u32, buf[pos..][0..4], adler32(filtered), .big);
97 pos += 4;
98 std.debug.assert(pos == total);
99
100 return buf;
101 }
102
103 pub fn encode(alloc: std.mem.Allocator, img: Image, writer: anytype) EncodeError!void {
104 std.debug.assert(img.pixels.len == @as(usize, img.width) * img.height * 4);
105
106 writer.writeAll(&signature) catch return error.WriteFailed;
107
108 var ihdr: [13]u8 = undefined;
109 std.mem.writeInt(u32, ihdr[0..4], img.width, .big);
110 std.mem.writeInt(u32, ihdr[4..8], img.height, .big);
111 ihdr[8] = 8; // bit depth
112 ihdr[9] = 6; // colour type = RGBA
113 ihdr[10] = 0; // compression method
114 ihdr[11] = 0; // filter method
115 ihdr[12] = 0; // interlace method = none
116 try writeChunk(writer, "IHDR", &ihdr);
117
118 const row_bytes = @as(usize, img.width) * 4;
119 const filtered_len = (row_bytes + 1) * img.height;
120 const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory;
121 defer alloc.free(filtered);
122
123 // Filter type 0 (None) per row
124 var y: u32 = 0;
125 while (y < img.height) : (y += 1) {
126 const src_off = @as(usize, y) * row_bytes;
127 const dst_off = @as(usize, y) * (row_bytes + 1);
128 filtered[dst_off] = 0; // filter byte
129 @memcpy(filtered[dst_off + 1 ..][0..row_bytes], img.pixels[src_off..][0..row_bytes]);
130 }
131
132 const compressed = try buildZlibStored(alloc, filtered);
133 defer alloc.free(compressed);
134
135 try writeChunk(writer, "IDAT", compressed);
136 try writeChunk(writer, "IEND", &.{});
137 }
138
139 pub fn decode(alloc: std.mem.Allocator, bytes: []const u8) DecodeError!Image {
140 if (bytes.len < signature.len + 8) return error.InvalidPng;
141 if (!std.mem.eql(u8, bytes[0..signature.len], &signature)) return error.InvalidPng;
142
143 var cursor: usize = signature.len;
144 var width: u32 = 0;
145 var height: u32 = 0;
146 var idat_accum: std.ArrayList(u8) = .empty;
147 defer idat_accum.deinit(alloc);
148 var seen_ihdr = false;
149 var seen_iend = false;
150
151 while (cursor + 8 <= bytes.len and !seen_iend) {
152 const len = std.mem.readInt(u32, bytes[cursor..][0..4], .big);
153 cursor += 4;
154 const ctype = bytes[cursor..][0..4];
155 cursor += 4;
156 if (cursor + len + 4 > bytes.len) return error.CorruptChunk;
157 const payload = bytes[cursor..][0..len];
158 cursor += len;
159 cursor += 4; // skip CRC
160
161 if (std.mem.eql(u8, ctype, "IHDR")) {
162 if (payload.len != 13) return error.InvalidPng;
163 width = std.mem.readInt(u32, payload[0..4], .big);
164 height = std.mem.readInt(u32, payload[4..8], .big);
165 // bit depth=8, colour type=6 (RGBA), interlace=0
166 if (payload[8] != 8 or payload[9] != 6 or payload[12] != 0)
167 return error.UnsupportedPng;
168 seen_ihdr = true;
169 } else if (std.mem.eql(u8, ctype, "IDAT")) {
170 if (!seen_ihdr) return error.InvalidPng;
171 idat_accum.appendSlice(alloc, payload) catch return error.OutOfMemory;
172 } else if (std.mem.eql(u8, ctype, "IEND")) {
173 seen_iend = true;
174 }
175 }
176
177 if (!seen_ihdr or !seen_iend) return error.InvalidPng;
178 // zlib stream: 2-byte header + deflate body + 4-byte adler32
179 if (idat_accum.items.len < 6) return error.InvalidPng;
180 // Strip the 2-byte zlib header and 4-byte adler32 footer to get raw deflate
181 const deflate_data = idat_accum.items[2 .. idat_accum.items.len - 4];
182
183 const row_bytes = @as(usize, width) * 4;
184 const filtered_len = (row_bytes + 1) * @as(usize, height);
185 const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory;
186 defer alloc.free(filtered);
187
188 // Decompress using std.compress.flate.Decompress with the new Zig 0.15 API.
189 // The indirect vtable (used when a window buffer is provided) fills its
190 // internal buffer on each vtable call and returns 0; the caller must loop,
191 // draining the buffer on alternate calls.
192 {
193 var in_reader: std.Io.Reader = .fixed(deflate_data);
194 var decomp_buf: [std.compress.flate.max_window_len]u8 = undefined;
195 var decomp: std.compress.flate.Decompress = .init(&in_reader, .raw, &decomp_buf);
196
197 var dst_writer: std.Io.Writer = .fixed(filtered);
198 var written: usize = 0;
199 while (written < filtered_len) {
200 const n = decomp.reader.stream(&dst_writer, .unlimited) catch |err| switch (err) {
201 error.EndOfStream => break,
202 else => return error.CorruptChunk,
203 };
204 written += n;
205 if (n == 0 and decomp.reader.seek == decomp.reader.end) break;
206 }
207 if (written != filtered_len) return error.CorruptChunk;
208 }
209
210 const pixels = alloc.alloc(u8, @as(usize, width) * height * 4) catch return error.OutOfMemory;
211 errdefer alloc.free(pixels);
212
213 var row: u32 = 0;
214 while (row < height) : (row += 1) {
215 const dst_off = @as(usize, row) * row_bytes;
216 const src_off = @as(usize, row) * (row_bytes + 1);
217 if (filtered[src_off] != 0) return error.UnsupportedPng; // only filter type 0
218 @memcpy(pixels[dst_off..][0..row_bytes], filtered[src_off + 1 ..][0..row_bytes]);
219 }
220
221 return .{ .width = width, .height = height, .pixels = pixels };
222 }
223
224 test "encode then decode roundtrip recovers pixels" {
225 const alloc = std.testing.allocator;
226 var src_pixels = [_]u8{
227 0xff, 0x00, 0x00, 0xff, 0x00, 0xff, 0x00, 0xff,
228 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
229 };
230 const src = Image{ .width = 2, .height = 2, .pixels = &src_pixels };
231
232 var buf: std.ArrayList(u8) = .empty;
233 defer buf.deinit(alloc);
234 try encode(alloc, src, buf.writer(alloc));
235
236 var decoded = try decode(alloc, buf.items[0..]);
237 defer decoded.deinit(alloc);
238
239 try std.testing.expectEqual(@as(u32, 2), decoded.width);
240 try std.testing.expectEqual(@as(u32, 2), decoded.height);
241 try std.testing.expectEqualSlices(u8, &src_pixels, decoded.pixels);
242 }
243
244 test "decode rejects RGB (non-alpha) PNGs with UnsupportedPng" {
245 const alloc = std.testing.allocator;
246 var bytes: std.ArrayList(u8) = .empty;
247 defer bytes.deinit(alloc);
248 try bytes.appendSlice(alloc, &signature);
249 var ihdr: [13]u8 = undefined;
250 std.mem.writeInt(u32, ihdr[0..4], 1, .big);
251 std.mem.writeInt(u32, ihdr[4..8], 1, .big);
252 ihdr[8] = 8;
253 ihdr[9] = 2; // colour type 2 = RGB (not RGBA)
254 ihdr[10] = 0;
255 ihdr[11] = 0;
256 ihdr[12] = 0;
257 try writeChunk(bytes.writer(alloc), "IHDR", &ihdr);
258 try writeChunk(bytes.writer(alloc), "IEND", &.{});
259
260 try std.testing.expectError(error.UnsupportedPng, decode(alloc, bytes.items[0..]));
261 }