1acfbe4b
Add vendored minimal RGBA8 PNG codec
a73x 2026-04-17 11:26
Commit message
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 | } | ||