1acfbe4b
Add vendored minimal RGBA8 PNG codec
a73x 2026-04-17 11:26
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>
diff --git a/build.zig b/build.zig index 7c8c808..17f576b 100644 --- a/build.zig +++ b/build.zig @@ -268,4 +268,20 @@ pub fn build(b: *std.Build) void { .root_module = renderer_test_mod, }); test_step.dependOn(&b.addRunArtifact(renderer_tests).step); // png module — vendored minimal RGBA8 PNG codec const png_mod = b.createModule(.{ .root_source_file = b.path("src/png.zig"), .target = target, .optimize = optimize, }); exe_mod.addImport("png", png_mod); const png_test_mod = b.createModule(.{ .root_source_file = b.path("src/png.zig"), .target = target, .optimize = optimize, }); const png_tests = b.addTest(.{ .root_module = png_test_mod }); test_step.dependOn(&b.addRunArtifact(png_tests).step); } diff --git a/src/png.zig b/src/png.zig new file mode 100644 index 0000000..a9774f2 --- /dev/null +++ b/src/png.zig @@ -0,0 +1,261 @@ const std = @import("std"); pub const Image = struct { width: u32, height: u32, pixels: []u8, // RGBA8, row-major, width*height*4 bytes pub fn deinit(self: *Image, alloc: std.mem.Allocator) void { alloc.free(self.pixels); self.* = undefined; } }; pub const EncodeError = error{ OutOfMemory, WriteFailed }; pub const DecodeError = error{ OutOfMemory, InvalidPng, UnsupportedPng, // only RGBA8 non-interlaced is supported CorruptChunk, }; const signature = [_]u8{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; fn adler32(data: []const u8) u32 { var a: u32 = 1; var b: u32 = 0; for (data) |byte| { a = (a + byte) % 65521; b = (b + a) % 65521; } return (b << 16) | a; } fn writeChunk(writer: anytype, chunk_type: *const [4]u8, payload: []const u8) EncodeError!void { writer.writeInt(u32, @intCast(payload.len), .big) catch return error.WriteFailed; writer.writeAll(chunk_type) catch return error.WriteFailed; writer.writeAll(payload) catch return error.WriteFailed; var crc = std.hash.Crc32.init(); crc.update(chunk_type); crc.update(payload); writer.writeInt(u32, crc.final(), .big) catch return error.WriteFailed; } /// Build a zlib stream wrapping the `filtered` data using DEFLATE stored /// blocks (type 0, no compression). This is always valid PNG and avoids /// dependency on the std.compress.flate encoder, which is incomplete in /// Zig 0.15. fn buildZlibStored(alloc: std.mem.Allocator, filtered: []const u8) EncodeError![]u8 { // zlib header: CMF=0x78 (deflate, window=32K), FLG=0x01 (no dict, level=0, // fcheck makes CMF*256+FLG divisible by 31: 0x7801 % 31 == 0). const zlib_header = [_]u8{ 0x78, 0x01 }; // DEFLATE stored block layout: // 1 byte: BFINAL | (BTYPE << 1) — BTYPE=00 for stored // 2 bytes: LEN (little-endian u16) // 2 bytes: NLEN (one's complement of LEN, little-endian) // LEN bytes: data // // Maximum single stored block payload is 65535 bytes. const max_block: usize = 65535; const actual_blocks: usize = if (filtered.len == 0) 1 else (filtered.len + max_block - 1) / max_block; // Header per block: 5 bytes. Total deflate stream bytes: const deflate_len = actual_blocks * 5 + filtered.len; // Full buffer: zlib_header(2) + deflate + adler32(4) const total = 2 + deflate_len + 4; const buf = alloc.alloc(u8, total) catch return error.OutOfMemory; errdefer alloc.free(buf); var pos: usize = 0; buf[pos] = zlib_header[0]; pos += 1; buf[pos] = zlib_header[1]; pos += 1; var src_pos: usize = 0; var block_idx: usize = 0; while (block_idx < actual_blocks) : (block_idx += 1) { const remaining = filtered.len - src_pos; const block_len: u16 = @intCast(@min(remaining, max_block)); const is_final = block_idx == actual_blocks - 1; const bfinal: u8 = if (is_final) 0x01 else 0x00; buf[pos] = bfinal; // BFINAL=is_final, BTYPE=00 pos += 1; std.mem.writeInt(u16, buf[pos..][0..2], block_len, .little); pos += 2; const nlen: u16 = ~block_len; std.mem.writeInt(u16, buf[pos..][0..2], nlen, .little); pos += 2; @memcpy(buf[pos..][0..block_len], filtered[src_pos..][0..block_len]); pos += block_len; src_pos += block_len; } // Adler-32 of the uncompressed (filtered) data, big-endian std.mem.writeInt(u32, buf[pos..][0..4], adler32(filtered), .big); pos += 4; std.debug.assert(pos == total); return buf; } pub fn encode(alloc: std.mem.Allocator, img: Image, writer: anytype) EncodeError!void { std.debug.assert(img.pixels.len == @as(usize, img.width) * img.height * 4); writer.writeAll(&signature) catch return error.WriteFailed; var ihdr: [13]u8 = undefined; std.mem.writeInt(u32, ihdr[0..4], img.width, .big); std.mem.writeInt(u32, ihdr[4..8], img.height, .big); ihdr[8] = 8; // bit depth ihdr[9] = 6; // colour type = RGBA ihdr[10] = 0; // compression method ihdr[11] = 0; // filter method ihdr[12] = 0; // interlace method = none try writeChunk(writer, "IHDR", &ihdr); const row_bytes = @as(usize, img.width) * 4; const filtered_len = (row_bytes + 1) * img.height; const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory; defer alloc.free(filtered); // Filter type 0 (None) per row var y: u32 = 0; while (y < img.height) : (y += 1) { const src_off = @as(usize, y) * row_bytes; const dst_off = @as(usize, y) * (row_bytes + 1); filtered[dst_off] = 0; // filter byte @memcpy(filtered[dst_off + 1 ..][0..row_bytes], img.pixels[src_off..][0..row_bytes]); } const compressed = try buildZlibStored(alloc, filtered); defer alloc.free(compressed); try writeChunk(writer, "IDAT", compressed); try writeChunk(writer, "IEND", &.{}); } pub fn decode(alloc: std.mem.Allocator, bytes: []const u8) DecodeError!Image { if (bytes.len < signature.len + 8) return error.InvalidPng; if (!std.mem.eql(u8, bytes[0..signature.len], &signature)) return error.InvalidPng; var cursor: usize = signature.len; var width: u32 = 0; var height: u32 = 0; var idat_accum: std.ArrayList(u8) = .empty; defer idat_accum.deinit(alloc); var seen_ihdr = false; var seen_iend = false; while (cursor + 8 <= bytes.len and !seen_iend) { const len = std.mem.readInt(u32, bytes[cursor..][0..4], .big); cursor += 4; const ctype = bytes[cursor..][0..4]; cursor += 4; if (cursor + len + 4 > bytes.len) return error.CorruptChunk; const payload = bytes[cursor..][0..len]; cursor += len; cursor += 4; // skip CRC if (std.mem.eql(u8, ctype, "IHDR")) { if (payload.len != 13) return error.InvalidPng; width = std.mem.readInt(u32, payload[0..4], .big); height = std.mem.readInt(u32, payload[4..8], .big); // bit depth=8, colour type=6 (RGBA), interlace=0 if (payload[8] != 8 or payload[9] != 6 or payload[12] != 0) return error.UnsupportedPng; seen_ihdr = true; } else if (std.mem.eql(u8, ctype, "IDAT")) { if (!seen_ihdr) return error.InvalidPng; idat_accum.appendSlice(alloc, payload) catch return error.OutOfMemory; } else if (std.mem.eql(u8, ctype, "IEND")) { seen_iend = true; } } if (!seen_ihdr or !seen_iend) return error.InvalidPng; // zlib stream: 2-byte header + deflate body + 4-byte adler32 if (idat_accum.items.len < 6) return error.InvalidPng; // Strip the 2-byte zlib header and 4-byte adler32 footer to get raw deflate const deflate_data = idat_accum.items[2 .. idat_accum.items.len - 4]; const row_bytes = @as(usize, width) * 4; const filtered_len = (row_bytes + 1) * @as(usize, height); const filtered = alloc.alloc(u8, filtered_len) catch return error.OutOfMemory; defer alloc.free(filtered); // Decompress using std.compress.flate.Decompress with the new Zig 0.15 API. // The indirect vtable (used when a window buffer is provided) fills its // internal buffer on each vtable call and returns 0; the caller must loop, // draining the buffer on alternate calls. { var in_reader: std.Io.Reader = .fixed(deflate_data); var decomp_buf: [std.compress.flate.max_window_len]u8 = undefined; var decomp: std.compress.flate.Decompress = .init(&in_reader, .raw, &decomp_buf); var dst_writer: std.Io.Writer = .fixed(filtered); var written: usize = 0; while (written < filtered_len) { const n = decomp.reader.stream(&dst_writer, .unlimited) catch |err| switch (err) { error.EndOfStream => break, else => return error.CorruptChunk, }; written += n; if (n == 0 and decomp.reader.seek == decomp.reader.end) break; } if (written != filtered_len) return error.CorruptChunk; } const pixels = alloc.alloc(u8, @as(usize, width) * height * 4) catch return error.OutOfMemory; errdefer alloc.free(pixels); var row: u32 = 0; while (row < height) : (row += 1) { const dst_off = @as(usize, row) * row_bytes; const src_off = @as(usize, row) * (row_bytes + 1); if (filtered[src_off] != 0) return error.UnsupportedPng; // only filter type 0 @memcpy(pixels[dst_off..][0..row_bytes], filtered[src_off + 1 ..][0..row_bytes]); } return .{ .width = width, .height = height, .pixels = pixels }; } test "encode then decode roundtrip recovers pixels" { const alloc = std.testing.allocator; var src_pixels = [_]u8{ 0xff, 0x00, 0x00, 0xff, 0x00, 0xff, 0x00, 0xff, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, }; const src = Image{ .width = 2, .height = 2, .pixels = &src_pixels }; var buf: std.ArrayList(u8) = .empty; defer buf.deinit(alloc); try encode(alloc, src, buf.writer(alloc)); var decoded = try decode(alloc, buf.items[0..]); defer decoded.deinit(alloc); try std.testing.expectEqual(@as(u32, 2), decoded.width); try std.testing.expectEqual(@as(u32, 2), decoded.height); try std.testing.expectEqualSlices(u8, &src_pixels, decoded.pixels); } test "decode rejects RGB (non-alpha) PNGs with UnsupportedPng" { const alloc = std.testing.allocator; var bytes: std.ArrayList(u8) = .empty; defer bytes.deinit(alloc); try bytes.appendSlice(alloc, &signature); var ihdr: [13]u8 = undefined; std.mem.writeInt(u32, ihdr[0..4], 1, .big); std.mem.writeInt(u32, ihdr[4..8], 1, .big); ihdr[8] = 8; ihdr[9] = 2; // colour type 2 = RGB (not RGBA) ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; try writeChunk(bytes.writer(alloc), "IHDR", &ihdr); try writeChunk(bytes.writer(alloc), "IEND", &.{}); try std.testing.expectError(error.UnsupportedPng, decode(alloc, bytes.items[0..])); }