lib/png/src/encode/test.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const flate = std.compress.flate;
3 const subject = @import("root.zig");
4 const Compression = subject.Compression;
5 const ImageView = subject.ImageView;
6 const Plan = subject.Plan;
7 const Storage = subject.Storage;
8 const Scratch = subject.Scratch;
9 const signature = subject.signature;
10 const Encoding = subject.Encoding;
11 const bytesFromImage = subject.bytesFromImage;
12
13 const TestChunk = struct {
14 data: []const u8,
15 next_offset: usize,
16 };
17
18 const TestImage = struct {
19 pixels: []u8,
20 width: usize,
21 height: usize,
22 };
23
24 fn testReadChunk(encoded: []const u8, offset: usize, expected_type: *const [4]u8) !TestChunk {
25 const length = std.mem.readInt(u32, encoded[offset..][0..4], .big);
26 const chunk_type = encoded[offset + 4 ..][0..4];
27 const data = encoded[offset + 8 ..][0..length];
28 const crc_offset = offset + 8 + length;
29 var crc = std.hash.Crc32.init();
30 crc.update(chunk_type);
31 crc.update(data);
32 try std.testing.expectEqualSlices(u8, expected_type, chunk_type);
33 try std.testing.expectEqual(
34 crc.final(),
35 std.mem.readInt(u32, encoded[crc_offset..][0..4], .big),
36 );
37 return .{ .data = data, .next_offset = crc_offset + 4 };
38 }
39
40 fn testDecodeRgba8(allocator: std.mem.Allocator, encoded: []const u8) !TestImage {
41 try std.testing.expectEqualSlices(u8, &signature, encoded[0..signature.len]);
42 const ihdr = try testReadChunk(encoded, signature.len, "IHDR");
43 const width: usize = std.mem.readInt(u32, ihdr.data[0..4], .big);
44 const height: usize = std.mem.readInt(u32, ihdr.data[4..8], .big);
45 const source_bpp: usize = switch (ihdr.data[9]) {
46 2 => 3,
47 6 => 4,
48 else => return error.UnsupportedFormat,
49 };
50 const idat = try testReadChunk(encoded, ihdr.next_offset, "IDAT");
51 const raw_len = height * (width * source_bpp + 1);
52 const raw = try testInflateZlib(allocator, idat.data, raw_len);
53 defer allocator.free(raw);
54 const pixels = try allocator.alloc(u8, width * height * 4);
55 errdefer allocator.free(pixels);
56 try testExpandRows(pixels, raw, width, height, source_bpp);
57 return .{ .pixels = pixels, .width = width, .height = height };
58 }
59
60 fn testExpandRows(pixels: []u8, raw: []const u8, width: usize, height: usize, bpp: usize) !void {
61 const row_bytes = width * 4;
62 const source_row_bytes = width * bpp;
63 var y: usize = 0;
64 while (y < height) : (y += 1) {
65 const line = raw[y * (source_row_bytes + 1) ..][0 .. source_row_bytes + 1];
66 try std.testing.expectEqual(@as(u8, 0), line[0]);
67 if (bpp == 4) {
68 @memcpy(pixels[y * row_bytes ..][0..row_bytes], line[1..]);
69 continue;
70 }
71 var x: usize = 0;
72 while (x < width) : (x += 1) {
73 const source = 1 + x * 3;
74 const dest = y * row_bytes + x * 4;
75 @memcpy(pixels[dest..][0..3], line[source..][0..3]);
76 pixels[dest + 3] = 0xff;
77 }
78 }
79 }
80
81 fn testInflateZlib(
82 allocator: std.mem.Allocator,
83 compressed: []const u8,
84 expected_len: usize,
85 ) ![]u8 {
86 var reader: std.Io.Reader = .fixed(compressed);
87 const window = try allocator.alloc(u8, flate.max_window_len);
88 defer allocator.free(window);
89 var decompress = flate.Decompress.init(&reader, .zlib, window);
90 const raw = try decompress.reader.allocRemaining(allocator, .limited(expected_len + 1));
91 errdefer allocator.free(raw);
92 try std.testing.expectEqual(expected_len, raw.len);
93 return raw;
94 }
95
96 test "encoder emits RGBA scanlines in compressed zlib IDAT" {
97 const pixels = [_]u8{ 0x10, 0x20, 0x30, 0xff, 0x40, 0x50, 0x60, 0x80 };
98 var encoded = try Encoding.init(std.testing.allocator, .{
99 .rgba8 = &pixels,
100 .width = 2,
101 .height = 1,
102 });
103 defer encoded.deinit(std.testing.allocator);
104 const ihdr = try testReadChunk(encoded.bytes, signature.len, "IHDR");
105 try std.testing.expectEqual(@as(u8, 6), ihdr.data[9]);
106 const idat = try testReadChunk(encoded.bytes, ihdr.next_offset, "IDAT");
107 try std.testing.expectEqualSlices(u8, flate.Container.header(.zlib), idat.data[0..2]);
108 const raw = try testInflateZlib(std.testing.allocator, idat.data, 9);
109 defer std.testing.allocator.free(raw);
110 try std.testing.expectEqualSlices(u8, &.{0}, raw[0..1]);
111 try std.testing.expectEqualSlices(u8, &pixels, raw[1..9]);
112 }
113
114 test "opaque image encodes as RGB and decodes as RGBA" {
115 const pixels = [_]u8{ 0x10, 0x20, 0x30, 0xff, 0x40, 0x50, 0x60, 0xff };
116 var encoded = try Encoding.init(std.testing.allocator, .{
117 .rgba8 = &pixels,
118 .width = 2,
119 .height = 1,
120 });
121 defer encoded.deinit(std.testing.allocator);
122 const ihdr = try testReadChunk(encoded.bytes, signature.len, "IHDR");
123 try std.testing.expectEqual(@as(u8, 2), ihdr.data[9]);
124 const decoded = try testDecodeRgba8(std.testing.allocator, encoded.bytes);
125 defer std.testing.allocator.free(decoded.pixels);
126 try std.testing.expectEqualSlices(u8, &pixels, decoded.pixels);
127 }
128
129 test "compressed and stored images survive decode roundtrips" {
130 const width: usize = 257;
131 const height: usize = 91;
132 const pixels = try std.testing.allocator.alloc(u8, width * height * 4);
133 defer std.testing.allocator.free(pixels);
134 for (pixels, 0..) |*byte, index| byte.* = @truncate(index *% 17 +% 31);
135 inline for (.{ Compression.compressed, Compression.stored }) |compression| {
136 var encoded = try Encoding.init(std.testing.allocator, .{
137 .rgba8 = pixels,
138 .width = @intCast(width),
139 .height = @intCast(height),
140 .options = .{ .compression = compression },
141 });
142 defer encoded.deinit(std.testing.allocator);
143 const decoded = try testDecodeRgba8(std.testing.allocator, encoded.bytes);
144 defer std.testing.allocator.free(decoded.pixels);
145 try std.testing.expectEqualSlices(u8, pixels, decoded.pixels);
146 }
147 }
148
149 test "repetitive screenshots compress below raw scanline size" {
150 const width: usize = 128;
151 const height: usize = 96;
152 const pixels = try std.testing.allocator.alloc(u8, width * height * 4);
153 defer std.testing.allocator.free(pixels);
154 for (0..width * height) |pixel| {
155 const x = pixel % width;
156 const y = pixel / width;
157 const bright = (x / 12 + y / 16) % 7 == 0;
158 pixels[pixel * 4 ..][0..4].* = .{
159 if (bright) 0xe8 else 0x18,
160 if (bright) 0xee else 0x1b,
161 if (bright) 0xf4 else 0x20,
162 0xff,
163 };
164 }
165 var encoded = try Encoding.init(std.testing.allocator, .{
166 .rgba8 = pixels,
167 .width = @intCast(width),
168 .height = @intCast(height),
169 });
170 defer encoded.deinit(std.testing.allocator);
171 try std.testing.expect(encoded.bytes.len < height * (width * 4 + 1) / 4);
172 }
173
174 test "PNG encode planning accepts the maximum and rejects max plus one" {
175 comptime {
176 @stardustClaim(
177 @import("alloc_phase").capacity.witness(@import("./root.zig").Storage, "png_encode_boundaries"),
178 null,
179 null,
180 null,
181 null,
182 null,
183 null,
184 );
185 }
186
187 const pixels = [_]u8{ 1, 2, 3, 255, 4, 5, 6, 255 };
188 const image = ImageView{ .rgba8 = &pixels, .width = 2, .height = 1 };
189 var scratch: Scratch = undefined;
190 _ = try Plan.inspect(image, .{ .image_pixels = 2 }, &scratch);
191 try std.testing.expectError(
192 error.ImagePixelCapacityExceeded,
193 Plan.inspect(image, .{ .image_pixels = 1 }, &scratch),
194 );
195 try std.testing.expectError(error.InvalidDimensions, Plan.inspect(
196 .{ .rgba8 = &.{}, .width = 0, .height = 1 },
197 .{ .image_pixels = 0 },
198 &scratch,
199 ));
200 try std.testing.expectError(error.PixelSizeMismatch, Plan.inspect(
201 .{ .rgba8 = pixels[0..4], .width = 2, .height = 1 },
202 .{ .image_pixels = 2 },
203 &scratch,
204 ));
205 }
206
207 test "PNG encode storage rejects concurrent use and resets after encode" {
208 comptime {
209 @stardustClaim(
210 @import("alloc_phase").capacity.witness(@import("./root.zig").Storage, "png_encode_reuse"),
211 null,
212 null,
213 null,
214 null,
215 null,
216 null,
217 );
218 }
219
220 const pixels = [_]u8{ 1, 2, 3, 255 };
221 const image = ImageView{ .rgba8 = &pixels, .width = 1, .height = 1 };
222 var scratch: Scratch = undefined;
223 var storage = try Storage.init(std.testing.allocator, .{
224 .image = image,
225 .bounds = .{ .image_pixels = 1 },
226 .scratch = &scratch,
227 });
228 defer storage.deinit(std.testing.allocator);
229 storage.activate();
230 const first = try bytesFromImage(&storage, image);
231 const hash = std.hash.Wyhash.hash(0, first);
232 try std.testing.expectError(error.EncodeStorageInUse, bytesFromImage(&storage, image));
233 storage.reset();
234 const second = try bytesFromImage(&storage, image);
235 defer storage.reset();
236 try std.testing.expectEqual(@intFromPtr(first.ptr), @intFromPtr(second.ptr));
237 try std.testing.expectEqual(hash, std.hash.Wyhash.hash(0, second));
238 }
239
240 test "PNG encode rejects changed input before output mutation" {
241 const pixels = [_]u8{ 1, 2, 3, 255 };
242 const image = ImageView{ .rgba8 = &pixels, .width = 1, .height = 1 };
243 var scratch: Scratch = undefined;
244 var storage = try Storage.init(std.testing.allocator, .{
245 .image = image,
246 .bounds = .{ .image_pixels = 1 },
247 .scratch = &scratch,
248 });
249 defer storage.deinit(std.testing.allocator);
250 storage.activate();
251 @memset(storage.output, 0xa5);
252 const output_hash = std.hash.Wyhash.hash(0, storage.output);
253 const changed = [_]u8{ 4, 5, 6, 255 };
254 try std.testing.expectError(error.EncodeInputMismatch, bytesFromImage(&storage, .{
255 .rgba8 = &changed,
256 .width = 1,
257 .height = 1,
258 }));
259 try std.testing.expectEqual(output_hash, std.hash.Wyhash.hash(0, storage.output));
260 _ = try bytesFromImage(&storage, image);
261 storage.reset();
262 }
263
264 test "Activated PNG encode storage performs no backing allocation" {
265 comptime {
266 @stardustClaim(
267 @import("alloc_phase").capacity.witness(@import("./root.zig").Storage, "png_encode_sealed"),
268 null,
269 null,
270 null,
271 null,
272 null,
273 null,
274 );
275 }
276
277 var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
278 const pixels = [_]u8{ 1, 2, 3, 255 };
279 const image = ImageView{ .rgba8 = &pixels, .width = 1, .height = 1 };
280 var scratch: Scratch = undefined;
281 var storage = try Storage.init(counting.allocator(), .{
282 .image = image,
283 .bounds = .{ .image_pixels = 1 },
284 .scratch = &scratch,
285 });
286 defer storage.deinit(counting.allocator());
287 storage.activate();
288 const bytes = try bytesFromImage(&storage, image);
289 defer storage.reset();
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(storage.status().output_bytes, bytes.len);
293 }
294
295 test "encoding allocation baseline" {
296 const width = 320;
297 const height = 180;
298 const pixels = try std.testing.allocator.alloc(u8, width * height * 4);
299 defer std.testing.allocator.free(pixels);
300 for (0..width * height) |pixel| {
301 pixels[pixel * 4] = @intCast((pixel % 16) * 16);
302 pixels[pixel * 4 + 1] = @intCast((15 - (pixel % 16)) * 16);
303 pixels[pixel * 4 + 2] = 128;
304 pixels[pixel * 4 + 3] = 255;
305 }
306 inline for (.{
307 .{ Compression.compressed, @as(usize, 1_087), @as(u64, 3_511_672_791_450_278_533) },
308 .{ Compression.stored, @as(usize, 173_058), @as(u64, 10_578_869_899_696_217_280) },
309 }) |expected| {
310 var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
311 var encoded = try Encoding.init(counting.allocator(), .{
312 .rgba8 = pixels,
313 .width = width,
314 .height = height,
315 .options = .{ .compression = expected[0] },
316 });
317 try std.testing.expectEqual(expected[1], encoded.bytes.len);
318 try std.testing.expectEqual(expected[2], std.hash.Wyhash.hash(0, encoded.bytes));
319 try std.testing.expectEqual(@as(usize, 1), counting.alloc_index);
320 try std.testing.expectEqual(@as(usize, 0), counting.resize_index);
321 try std.testing.expectEqual(encoded.status().storage_bytes, counting.allocated_bytes);
322 encoded.deinit(counting.allocator());
323 try std.testing.expectEqual(counting.allocated_bytes, counting.freed_bytes);
324 }
325 }