lib/png/src/decode/test.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const flate = std.compress.flate;
3 const png = @import("../root.zig");
4 const subject = @import("root.zig");
5 pub const fixture = @import("fixture.zig");
6 const encode = png.encode;
7 const Bounds = subject.Bounds;
8 const Storage = subject.Storage;
9 const Decoding = subject.Decoding;
10 const imageFromBytes = subject.imageFromBytes;
11
12 test "decode round-trips the encoder output" {
13 const width = 5;
14 const height = 3;
15 var source_pixels: [width * height * 4]u8 = undefined;
16 for (&source_pixels, 0..) |*byte, index| byte.* = @intCast((index * 7) % 251);
17 var encoded = try encode.Encoding.init(std.testing.allocator, .{
18 .rgba8 = &source_pixels,
19 .width = width,
20 .height = height,
21 });
22 defer encoded.deinit(std.testing.allocator);
23 var decoded = try Decoding.init(std.testing.allocator, encoded.bytes);
24 defer decoded.deinit(std.testing.allocator);
25 try std.testing.expectEqual(@as(u32, width), decoded.image.width);
26 try std.testing.expectEqual(@as(u32, height), decoded.image.height);
27 try std.testing.expectEqualSlices(u8, &source_pixels, decoded.image.rgba8);
28 }
29
30 test "decode reads a real indexed PNG past its ancillary chunks" {
31 const witness = @import("fixture.zig").indexed;
32 var decoded = try Decoding.init(std.testing.allocator, &witness);
33 defer decoded.deinit(std.testing.allocator);
34 try std.testing.expectEqual(@as(u32, 2), decoded.image.width);
35 try std.testing.expectEqual(@as(u32, 2), decoded.image.height);
36 try std.testing.expectEqualSlices(u8, &.{
37 255, 0, 0, 255, 0, 255, 0, 255,
38 0, 0, 255, 255, 255, 255, 255, 255,
39 }, decoded.image.rgba8);
40 }
41
42 test "PNG decode storage rejects concurrent use and resets after decode" {
43 comptime {
44 @stardustClaim(
45 @import("alloc_phase").capacity.witness(@import("./root.zig").Storage, "png_decode_reuse"),
46 null,
47 null,
48 null,
49 null,
50 null,
51 null,
52 );
53 }
54
55 const witness = @import("fixture.zig").indexed;
56 var storage = try Storage.init(std.testing.allocator, .{
57 .bytes = &witness,
58 .bounds = try Bounds.exact(&witness),
59 });
60 defer storage.deinit(std.testing.allocator);
61 storage.activate();
62 const first = try imageFromBytes(&storage, &witness);
63 try std.testing.expectError(error.DecodeStorageInUse, imageFromBytes(&storage, &witness));
64 const hash = std.hash.Wyhash.hash(0, first.rgba8);
65 storage.reset();
66 const second = try imageFromBytes(&storage, &witness);
67 defer storage.reset();
68 try std.testing.expectEqual(hash, std.hash.Wyhash.hash(0, second.rgba8));
69 }
70
71 test "Activated PNG decode storage performs no backing allocation" {
72 comptime {
73 @stardustClaim(
74 @import("alloc_phase").capacity.witness(@import("./root.zig").Storage, "png_decode_sealed"),
75 null,
76 null,
77 null,
78 null,
79 null,
80 null,
81 );
82 }
83
84 const witness = @import("fixture.zig").indexed;
85 var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
86 var storage = try Storage.init(counting.allocator(), .{
87 .bytes = &witness,
88 .bounds = try Bounds.exact(&witness),
89 });
90 defer storage.deinit(counting.allocator());
91 storage.activate();
92 const image = try imageFromBytes(&storage, &witness);
93 defer storage.reset();
94 try std.testing.expectEqual(@as(usize, 1), counting.alloc_index);
95 try std.testing.expectEqual(@as(usize, 0), counting.resize_index);
96 try std.testing.expectEqual(storage.status().rgba8_bytes, image.rgba8.len);
97 }
98
99 test "decode rejects bad input" {
100 try std.testing.expectError(error.InvalidSignature, Bounds.exact("not a png"));
101 }
102
103 test "bounded decoding rejects excess capacity before allocation" {
104 const witness = @import("fixture.zig").indexed;
105 var bounds = try Bounds.exact(&witness);
106 bounds.image_pixels -= 1;
107 var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
108 try std.testing.expectError(
109 error.ImagePixelCapacityExceeded,
110 Decoding.initWithBounds(counting.allocator(), &witness, bounds),
111 );
112 try std.testing.expectEqual(@as(usize, 0), counting.alloc_index);
113 }
114
115 test "decode expands RGB PNG rows to RGBA" {
116 const source = [_]u8{ 10, 20, 30, 40, 50, 60 };
117 const encoded = try testPngAlloc(std.testing.allocator, 2, 1, 8, 2, 3, &source, &.{}, &.{});
118 defer std.testing.allocator.free(encoded);
119 var decoded = try Decoding.init(std.testing.allocator, encoded);
120 defer decoded.deinit(std.testing.allocator);
121 try std.testing.expectEqualSlices(u8, &.{
122 10, 20, 30, 255,
123 40, 50, 60, 255,
124 }, decoded.image.rgba8);
125 }
126
127 test "decode expands grayscale alpha PNG rows to RGBA" {
128 const source = [_]u8{ 10, 128, 40, 255 };
129 const encoded = try testPngAlloc(std.testing.allocator, 2, 1, 8, 4, 2, &source, &.{}, &.{});
130 defer std.testing.allocator.free(encoded);
131 var decoded = try Decoding.init(std.testing.allocator, encoded);
132 defer decoded.deinit(std.testing.allocator);
133 try std.testing.expectEqualSlices(u8, &.{
134 10, 10, 10, 128,
135 40, 40, 40, 255,
136 }, decoded.image.rgba8);
137 }
138
139 test "decode expands an indexed PNG through its palette" {
140 const indices = [_]u8{0b00011011};
141 const palette = [_]u8{
142 255, 0, 0, 0, 255, 0,
143 0, 0, 255, 255, 255, 255,
144 };
145 const encoded = try testPngAlloc(
146 std.testing.allocator,
147 4,
148 1,
149 2,
150 3,
151 1,
152 &indices,
153 &palette,
154 &.{},
155 );
156 defer std.testing.allocator.free(encoded);
157 var decoded = try Decoding.init(std.testing.allocator, encoded);
158 defer decoded.deinit(std.testing.allocator);
159 try std.testing.expectEqualSlices(u8, &.{
160 255, 0, 0, 255, 0, 255, 0, 255,
161 0, 0, 255, 255, 255, 255, 255, 255,
162 }, decoded.image.rgba8);
163 }
164
165 test "decode honors palette transparency" {
166 const indices = [_]u8{0b00000001};
167 const palette = [_]u8{ 10, 20, 30, 40, 50, 60 };
168 const transparency = [_]u8{ 0, 128 };
169 const encoded = try testPngAlloc(
170 std.testing.allocator,
171 2,
172 1,
173 4,
174 3,
175 1,
176 &indices,
177 &palette,
178 &transparency,
179 );
180 defer std.testing.allocator.free(encoded);
181 var decoded = try Decoding.init(std.testing.allocator, encoded);
182 defer decoded.deinit(std.testing.allocator);
183 try std.testing.expectEqualSlices(u8, &.{
184 10, 20, 30, 0,
185 40, 50, 60, 128,
186 }, decoded.image.rgba8);
187 }
188
189 test "decode scales a 1-bit grayscale PNG" {
190 const source = [_]u8{0b10000000};
191 const encoded = try testPngAlloc(std.testing.allocator, 2, 1, 1, 0, 1, &source, &.{}, &.{});
192 defer std.testing.allocator.free(encoded);
193 var decoded = try Decoding.init(std.testing.allocator, encoded);
194 defer decoded.deinit(std.testing.allocator);
195 try std.testing.expectEqualSlices(u8, &.{
196 255, 255, 255, 255,
197 0, 0, 0, 255,
198 }, decoded.image.rgba8);
199 }
200
201 test "decode reduces a 16-bit truecolor PNG to eight bits" {
202 const source = [_]u8{ 0xab, 0xcd, 0x12, 0x34, 0xff, 0x00 };
203 const encoded = try testPngAlloc(std.testing.allocator, 1, 1, 16, 2, 3, &source, &.{}, &.{});
204 defer std.testing.allocator.free(encoded);
205 var decoded = try Decoding.init(std.testing.allocator, encoded);
206 defer decoded.deinit(std.testing.allocator);
207 try std.testing.expectEqualSlices(u8, &.{ 0xab, 0x12, 0xff, 255 }, decoded.image.rgba8);
208 }
209
210 test "decode requires a palette for indexed color" {
211 const source = [_]u8{0};
212 const encoded = try testPngAlloc(std.testing.allocator, 1, 1, 8, 3, 1, &source, &.{}, &.{});
213 defer std.testing.allocator.free(encoded);
214 try std.testing.expectError(error.MissingPalette, Bounds.exact(encoded));
215 }
216
217 test "decode streams consecutive IDAT chunks without concatenation" {
218 const source = [_]u8{ 10, 20, 30, 40, 50, 60 };
219 const encoded = try testPngAlloc(std.testing.allocator, 2, 1, 8, 2, 3, &source, &.{}, &.{});
220 defer std.testing.allocator.free(encoded);
221 const split = try splitIdatAlloc(std.testing.allocator, encoded);
222 defer std.testing.allocator.free(split);
223 var decoded = try Decoding.init(std.testing.allocator, split);
224 defer decoded.deinit(std.testing.allocator);
225 try std.testing.expectEqualSlices(u8, &.{
226 10, 20, 30, 255,
227 40, 50, 60, 255,
228 }, decoded.image.rgba8);
229 }
230
231 test "PNG decode rejects changed input before output mutation" {
232 const witness = @import("fixture.zig").indexed;
233 var storage = try Storage.init(std.testing.allocator, .{
234 .bytes = &witness,
235 .bounds = try Bounds.exact(&witness),
236 });
237 defer storage.deinit(std.testing.allocator);
238 storage.activate();
239 @memset(storage.output, 0xa5);
240 const output_hash = std.hash.Wyhash.hash(0, storage.output);
241 var changed = witness;
242 changed[changed.len - 1] ^= 1;
243 try std.testing.expectError(error.DecodeInputMismatch, imageFromBytes(&storage, &changed));
244 try std.testing.expectEqual(output_hash, std.hash.Wyhash.hash(0, storage.output));
245 _ = try imageFromBytes(&storage, &witness);
246 storage.reset();
247 }
248
249 test "PNG decode errors release storage before retry" {
250 const witness = @import("fixture.zig").indexed;
251 var corrupted = witness;
252 corrupted[try testChunkDataOffset(&corrupted, "IDAT")] = 0;
253 var storage = try Storage.init(std.testing.allocator, .{
254 .bytes = &corrupted,
255 .bounds = try Bounds.exact(&corrupted),
256 });
257 defer storage.deinit(std.testing.allocator);
258 storage.activate();
259 try std.testing.expectError(error.TruncatedImage, imageFromBytes(&storage, &corrupted));
260 try std.testing.expect(!storage.status().in_use);
261 try std.testing.expectError(error.TruncatedImage, imageFromBytes(&storage, &corrupted));
262 try std.testing.expect(!storage.status().in_use);
263 }
264
265 test "decoding allocation baseline" {
266 const width = 320;
267 const height = 180;
268 const source = try std.testing.allocator.alloc(u8, width * height * 4);
269 defer std.testing.allocator.free(source);
270 for (0..width * height) |pixel| {
271 source[pixel * 4] = @intCast((pixel % 16) * 16);
272 source[pixel * 4 + 1] = @intCast((15 - (pixel % 16)) * 16);
273 source[pixel * 4 + 2] = 128;
274 source[pixel * 4 + 3] = 255;
275 }
276 inline for (.{
277 .{ encode.Compression.compressed, @as(usize, 1_087) },
278 .{ encode.Compression.stored, @as(usize, 173_058) },
279 }) |expected| {
280 var encoded = try encode.Encoding.init(std.testing.allocator, .{
281 .rgba8 = source,
282 .width = width,
283 .height = height,
284 .options = .{ .compression = expected[0] },
285 });
286 defer encoded.deinit(std.testing.allocator);
287 try std.testing.expectEqual(expected[1], encoded.bytes.len);
288 var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
289 var decoded = try Decoding.init(counting.allocator(), encoded.bytes);
290 try std.testing.expectEqual(@as(usize, 1), counting.alloc_index);
291 try std.testing.expectEqual(@as(usize, 0), counting.resize_index);
292 try std.testing.expectEqual(decoded.status().storage_bytes, counting.allocated_bytes);
293 try std.testing.expectEqual(@as(usize, 297_856), decoded.status().storage_bytes);
294 try std.testing.expectEqual(
295 @as(u64, 10_254_699_363_429_783_698),
296 std.hash.Wyhash.hash(0, decoded.image.rgba8),
297 );
298 decoded.deinit(counting.allocator());
299 try std.testing.expectEqual(counting.allocated_bytes, counting.freed_bytes);
300 }
301 }
302
303 fn testPngAlloc(
304 allocator: std.mem.Allocator,
305 width: u32,
306 height: u32,
307 bit_depth: u8,
308 color_type: u8,
309 samples: usize,
310 source: []const u8,
311 palette: []const u8,
312 transparency: []const u8,
313 ) ![]u8 {
314 var output = std.Io.Writer.Allocating.init(allocator);
315 defer output.deinit();
316 try output.writer.writeAll(&encode.signature);
317 var header: [13]u8 = @splat(0);
318 std.mem.writeInt(u32, header[0..4], width, .big);
319 std.mem.writeInt(u32, header[4..8], height, .big);
320 header[8] = bit_depth;
321 header[9] = color_type;
322 try testWriteChunk(&output.writer, "IHDR", &header);
323 if (palette.len > 0) try testWriteChunk(&output.writer, "PLTE", palette);
324 if (transparency.len > 0) try testWriteChunk(&output.writer, "tRNS", transparency);
325 const row_bytes = (@as(usize, width) * samples * bit_depth + 7) / 8;
326 const raw = try allocator.alloc(u8, (row_bytes + 1) * @as(usize, height));
327 defer allocator.free(raw);
328 for (0..height) |y| {
329 const offset = y * (row_bytes + 1);
330 raw[offset] = 0;
331 @memcpy(raw[offset + 1 ..][0..row_bytes], source[y * row_bytes ..][0..row_bytes]);
332 }
333 var compressed = try std.Io.Writer.Allocating.initCapacity(allocator, 64);
334 defer compressed.deinit();
335 const window = try allocator.alloc(u8, flate.max_window_len);
336 defer allocator.free(window);
337 var compressor = try flate.Compress.init(
338 &compressed.writer,
339 window,
340 .zlib,
341 flate.Compress.Options.level_1,
342 );
343 try compressor.writer.writeAll(raw);
344 try compressor.finish();
345 try testWriteChunk(&output.writer, "IDAT", compressed.written());
346 try testWriteChunk(&output.writer, "IEND", &.{});
347 return try output.toOwnedSlice();
348 }
349
350 fn splitIdatAlloc(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
351 const data_offset = try testChunkDataOffset(bytes, "IDAT");
352 const chunk_offset = data_offset - 8;
353 const length: usize = @intCast(std.mem.readInt(u32, bytes[chunk_offset..][0..4], .big));
354 const split = @max(@as(usize, 1), length / 2);
355 var output = std.Io.Writer.Allocating.init(allocator);
356 defer output.deinit();
357 try output.writer.writeAll(bytes[0..chunk_offset]);
358 try testWriteChunk(&output.writer, "IDAT", bytes[data_offset..][0..split]);
359 try testWriteChunk(&output.writer, "IDAT", bytes[data_offset + split ..][0 .. length - split]);
360 try output.writer.writeAll(bytes[data_offset + length + 4 ..]);
361 return try output.toOwnedSlice();
362 }
363
364 fn testChunkDataOffset(bytes: []const u8, wanted: *const [4]u8) !usize {
365 var offset: usize = encode.signature.len;
366 while (bytes.len - offset >= 8) {
367 const length: usize = @intCast(std.mem.readInt(u32, bytes[offset..][0..4], .big));
368 const data_offset = offset + 8;
369 if (std.mem.eql(u8, bytes[offset + 4 ..][0..4], wanted)) return data_offset;
370 offset = data_offset + length + 4;
371 }
372 return error.InvalidChunk;
373 }
374
375 fn testWriteChunk(writer: *std.Io.Writer, kind: *const [4]u8, data: []const u8) !void {
376 try writer.writeInt(u32, @intCast(data.len), .big);
377 try writer.writeAll(kind);
378 try writer.writeAll(data);
379 var crc = std.hash.Crc32.init();
380 crc.update(kind);
381 crc.update(data);
382 try writer.writeInt(u32, crc.final(), .big);
383 }