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 }