//! Native-image backend harness. //! //! zig build image-harness fake a Kitty terminal and check //! real transmit + placement APCs //! zig build image-harness -Dplatform=gui run the real SDL GPU renderer, //! capture a PPM, inspect pixels //! zig build pdf-harness render a generated multi-page PDF //! zig build pdf-harness -Dplatform=gui //! //! Unlike the text snapshot suite, both arms observe the thing the native //! backend actually emits. The TTY arm completes vaxis's asynchronous //! capability handshake over a PTY; the GUI arm consumes PARDES_TEST's GPU //! readback, so a PETSCII fallback cannot accidentally satisfy either one. const std = @import("std"); const libc = std.c; const eh = @import("e2e_harness.zig"); pub const std_options: std.Options = .{ .log_level = .err }; extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; extern "c" fn unsetenv(name: [*:0]const u8) c_int; const gpa = std.heap.page_allocator; const fixture_rgb = [3]u8{ 13, 77, 231 }; const pdf_red = [3]u8{ 255, 0, 0 }; const pdf_green = [3]u8{ 0, 255, 0 }; pub fn main(init: std.process.Init) !void { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const args = try init.minimal.args.toSlice(arena); if (args.len != 3 and args.len != 4) return error.BadArgs; const exe = try arena.dupeZ(u8, args[1]); const backend = args[2]; const pdf_mode = args.len == 4 and std.mem.eql(u8, args[3], "pdf"); if (args.len == 4 and !pdf_mode) return error.BadArgs; const base = try std.fmt.allocPrintSentinel(arena, "/tmp/pardes-image-harness-{d}", .{libc.getpid()}, 0); if (libc.mkdir(base, 0o755) != 0 and libc.errno(-1) != .EXIST) return error.MkdirFailed; defer cleanupBase(arena, base); _ = setenv("HOME", base, 1); // Do not let an exported developer XDG_CONFIG_HOME style or mutate the // native process under test; this nonexistent directory is test-owned. const config_home = try std.fmt.allocPrintSentinel(arena, "{s}/.config", .{base}, 0); _ = setenv("XDG_CONFIG_HOME", config_home, 1); _ = setenv("HISTFILE", "/dev/null", 1); _ = setenv("TERM", "xterm-256color", 1); _ = setenv("LC_ALL", "C", 1); if (std.mem.eql(u8, backend, "tty")) { if (pdf_mode) try runKittyPdf(arena, exe, base) else try runKitty(arena, exe, base); } else if (std.mem.eql(u8, backend, "gui")) { if (pdf_mode) try runGuiPdf(arena, exe, base) else try runGui(arena, exe, base); } else return error.BadArgs; } fn cleanupBase(arena: std.mem.Allocator, base: [:0]const u8) void { const names = [_][]const u8{ "native.ppm", "pages.pdf", "latest.ppm", "latest.ppm.tmp", ".bash_history" }; for (names) |name| { const path = std.fmt.allocPrintSentinel(arena, "{s}/{s}", .{ base, name }, 0) catch continue; _ = libc.unlink(path); } _ = libc.rmdir(base); } fn appendFmt(arena: std.mem.Allocator, out: *std.ArrayList(u8), comptime fmt: []const u8, args: anytype) !void { try out.appendSlice(arena, try std.fmt.allocPrint(arena, fmt, args)); } /// A self-contained, valid PDF fixture. The first two pages retain the red / /// green and search-text contract exercised below; two differently-sized /// trailing pages make skipped native renders visible in Kitty's transmit /// headers. Building it here keeps the backend test independent of mutool, /// fonts, and checked-in binary fixtures while the xref offsets remain exact. fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void { const first_stream = \\1 0 0 rg \\0 0 72 48 re \\f \\0 0 0 rg \\BT \\/F1 10 Tf \\4 20 Td \\(FIRST NEEDLE) Tj \\ET ; const second_stream = \\0 1 0 rg \\0 0 72 48 re \\f \\0 0 0 rg \\BT \\/F1 10 Tf \\4 20 Td \\(SECOND TARGET) Tj \\ET ; const third_stream = \\0 0 1 rg \\0 0 72 50 re \\f \\0 0 0 rg \\BT \\/F1 10 Tf \\4 20 Td \\(THIRD PAGE) Tj \\ET ; const fourth_stream = \\1 1 0 rg \\0 0 72 52 re \\f \\0 0 0 rg \\BT \\/F1 10 Tf \\4 20 Td \\(FOURTH PAGE) Tj \\ET ; var out: std.ArrayList(u8) = .empty; var offsets: [12]usize = @splat(0); try out.appendSlice(arena, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); offsets[1] = out.items.len; try out.appendSlice(arena, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); offsets[2] = out.items.len; try out.appendSlice(arena, "2 0 obj\n<< /Type /Pages /Count 4 /Kids [3 0 R 5 0 R 7 0 R 9 0 R] >>\nendobj\n"); offsets[3] = out.items.len; try out.appendSlice(arena, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 4 0 R >>\nendobj\n"); offsets[4] = out.items.len; try appendFmt(arena, &out, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ first_stream.len, first_stream }); offsets[5] = out.items.len; try out.appendSlice(arena, "5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 11 0 R >> >> /Contents 6 0 R >>\nendobj\n"); offsets[6] = out.items.len; try appendFmt(arena, &out, "6 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ second_stream.len, second_stream }); offsets[7] = out.items.len; try out.appendSlice(arena, "7 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 50] /Resources << /Font << /F1 11 0 R >> >> /Contents 8 0 R >>\nendobj\n"); offsets[8] = out.items.len; try appendFmt(arena, &out, "8 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ third_stream.len, third_stream }); offsets[9] = out.items.len; try out.appendSlice(arena, "9 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 52] /Resources << /Font << /F1 11 0 R >> >> /Contents 10 0 R >>\nendobj\n"); offsets[10] = out.items.len; try appendFmt(arena, &out, "10 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ fourth_stream.len, fourth_stream }); offsets[11] = out.items.len; try out.appendSlice(arena, "11 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); const xref = out.items.len; try out.appendSlice(arena, "xref\n0 12\n0000000000 65535 f \n"); for (offsets[1..]) |offset| try appendFmt(arena, &out, "{d:0>10} 00000 n \n", .{offset}); try appendFmt(arena, &out, "trailer\n<< /Size 12 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref}); try eh.writeFile(path, out.items); } fn writePpm(arena: std.mem.Allocator, path: [:0]const u8, width: usize, height: usize) !void { var out: std.ArrayList(u8) = .empty; try out.appendSlice(arena, try std.fmt.allocPrint(arena, "P6\n{d} {d}\n255\n", .{ width, height })); for (0..width * height) |_| try out.appendSlice(arena, &fixture_rgb); try eh.writeFile(path, out.items); } fn runKitty(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { _ = unsetenv("PARDES_TEST"); _ = unsetenv("PARDES_TEST_CAPTURE_DIR"); const image_path = try std.fmt.allocPrintSentinel(arena, "{s}/native.ppm", .{base}, 0); try writePpm(arena, image_path, 2, 2); var h = try eh.Harness.initArgs(gpa, exe, 12, 30, image_path); defer h.deinit(); try h.pump(150); try h.expectRawContains("\x1b_Gi=1,a=q\x1b\\", "app did not issue the Kitty graphics capability query"); // APC says graphics is present; DA1 is the last capability response and // closes the asynchronous handshake. Arrow-up is an ordinary queued event // that wakes the main loop even on a terminal which supplied no in-band // resize report, matching the documented idle-handshake edge case. try h.send("\x1b_Gi=1;OK\x1b\\\x1b[?1;2c\x1b[A"); const deadline = eh.nowMs() + 3000; while (eh.nowMs() < deadline) { _ = try h.pumpOnce(50); const sent = std.mem.indexOf(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") != null; const placed = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=p,i=") != null; if (sent and placed) break; } try h.expectRawContains("\x1b_Gf=32,s=2,v=2,i=", "decoded RGBA was not transmitted to the Kitty terminal"); try h.expectRawContains("\x1b_Ga=p,i=", "transmitted Kitty image was not placed in the pane body"); // SPC t p toggles the attachment off and back on. The first transition // must release terminal-side storage; the second must transmit again // instead of resurrecting a stale pane-slot handle. try h.send(" tp"); const delete_deadline = eh.nowMs() + 2000; while (eh.nowMs() < delete_deadline and std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=d,d=I,i=") == null) _ = try h.pumpOnce(50); try h.expectRawContains("\x1b_Ga=d,d=I,i=", "PETSCII toggle did not release the cached Kitty image"); try h.send(" tp"); const retransmit_deadline = eh.nowMs() + 2000; while (eh.nowMs() < retransmit_deadline and std.mem.count(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") < 2) _ = try h.pumpOnce(50); if (std.mem.count(u8, h.raw.items, "\x1b_Gf=32,s=2,v=2,i=") < 2) return error.KittyRetransmitMissing; std.debug.print("native image harness ok: kitty transmit + placement\n", .{}); } fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { _ = unsetenv("PARDES_TEST"); _ = unsetenv("PARDES_TEST_CAPTURE_DIR"); const pdf_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.pdf", .{base}, 0); try writePdf(arena, pdf_path); var h = try eh.Harness.initArgs(gpa, exe, 16, 80, pdf_path); defer h.deinit(); try h.pump(150); try h.expectRawContains("\x1b_Gi=1,a=q\x1b\\", "app did not issue the Kitty graphics capability query"); try h.send("\x1b_Gi=1;OK\x1b\\\x1b[?1;2c\x1b[A"); const transmit = "\x1b_Gf=32,s=96,v=64,i="; const first_deadline = eh.nowMs() + 5000; while (eh.nowMs() < first_deadline) { _ = try h.pumpOnce(50); const sent = std.mem.indexOf(u8, h.raw.items, transmit) != null; const placed = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=p,i=") != null; if (sent and placed and h.contains("pdf 1/4 width PdfFit")) break; } try h.expectRawContains(transmit, "first PDF page was not transmitted as the 96x64 Kitty raster"); try h.expectRawContains("\x1b_Ga=p,i=", "first PDF page was not placed in the pane body"); try h.expectContains("pdf 1/4 width PdfFit", "PDF pane did not expose its initial width fit"); // Fit-width is the initial PDF policy. The short fixture pages make the // first boundary visible immediately, so both page rasters must coexist. // Page one fills its own document box instead of being letterboxed inside // the whole pane viewport. const initial_transmits = std.mem.count(u8, h.raw.items, transmit); if (initial_transmits < 2) return error.KittyPdfBoundaryPageMissing; const width_apc = kittyPlacementApc(h.raw.items, 0) orelse return error.KittyPdfWidthPlacementMissing; const width_clip_h = kittyParam(width_apc, 'h') orelse return error.KittyPdfWidthCropWrong; if (kittyParam(width_apc, 'x') != 0 or kittyParam(width_apc, 'y') != 0 or kittyParam(width_apc, 'w') != 96 or width_clip_h == 0 or width_clip_h > 64 or kittyParam(width_apc, 'c') == null) { std.debug.print("unexpected Kitty fit-width placement: {s}\n", .{width_apc}); return error.KittyPdfWidthCropWrong; } const initial_aspect_h = (38 * 8 * width_clip_h + 95) / 96; if (((initial_aspect_h + 15) / 16) * 16 > 202) return error.KittyPdfWidthPlacementOverdraw; // A one-row document scroll changes placements, not pixel generations or // the active page. This is the cache-reuse seam the old page-at-a-time // harness could not exercise. const placements_before = std.mem.count(u8, h.raw.items, "\x1b_Ga=p,i="); const placement_start = h.raw.items.len; try h.send("j"); const placement_deadline = eh.nowMs() + 2000; while (eh.nowMs() < placement_deadline and std.mem.count(u8, h.raw.items, "\x1b_Ga=p,i=") <= placements_before) _ = try h.pumpOnce(50); if (std.mem.count(u8, h.raw.items, transmit) != initial_transmits) return error.KittyPdfPlacementScrollRetransmitted; try h.expectContains("pdf 1/4 width PdfFit", "one-row scroll snapped to another PDF page"); // The newly exposed page-two head is 29px tall and begins three pixels // into a cell. Kitty infers an integer row count when only c is supplied; // the crop must therefore stay within one 16px row, not ceil into the gap. const tail_deadline = eh.nowMs() + 2000; while (eh.nowMs() < tail_deadline and kittyPlacementApcWithParam(h.raw.items, placement_start, 'Y') == null) _ = try h.pumpOnce(50); const tail_apc = kittyPlacementApcWithParam(h.raw.items, placement_start, 'Y') orelse return error.KittyPdfSafeTailPlacementMissing; const tail_cols = kittyParam(tail_apc, 'c') orelse return error.KittyPdfSafeTailPlacementWrong; const tail_w = kittyParam(tail_apc, 'w') orelse return error.KittyPdfSafeTailPlacementWrong; const tail_h = kittyParam(tail_apc, 'h') orelse return error.KittyPdfSafeTailPlacementWrong; if (kittyParam(tail_apc, 'Y') != 3 or tail_cols != 38 or tail_w != 96) return error.KittyPdfSafeTailPlacementWrong; const tail_aspect_h = (tail_cols * 8 * tail_h + tail_w - 1) / tail_w; const tail_declared_h = ((tail_aspect_h + 15) / 16) * 16; if (tail_declared_h > 29) return error.KittyPdfTailOverpaintedGap; // A viewport step crosses the boundary continuously: page one leaves and // is deleted, page two remains cached, and newly intersecting page three // is transmitted. Distinct fixture heights identify the latter on wire. const page_step_start = h.raw.items.len; const page_three_transmit = "\x1b_Gf=32,s=96,v=67,i="; try h.send("\x06"); try h.expectWaitFor("pdf 2/4 width PdfFit", 5000, "continuous page-down did not enter PDF page two"); const page_step_deadline = eh.nowMs() + 5000; while (eh.nowMs() < page_step_deadline and std.mem.indexOf(u8, h.raw.items[page_step_start..], page_three_transmit) == null) _ = try h.pumpOnce(50); if (std.mem.indexOf(u8, h.raw.items[page_step_start..], "\x1b_Ga=d,d=I,i=") == null) return error.KittyPdfOffscreenPageNotReleased; if (std.mem.indexOf(u8, h.raw.items[page_step_start..], page_three_transmit) == null) return error.KittyPdfNewVisiblePageMissing; if (std.mem.count(u8, h.raw.items, transmit) != initial_transmits) return error.KittyPdfRetainedNeighborRetransmitted; // Search still addresses semantic pages inside the strip. Returning one // viewport, then revealing page two's hit, creates a decorated revision. try h.send("\x02"); try h.expectWaitFor("pdf 1/4 width PdfFit", 5000, "continuous page-up did not return to PDF page one"); try h.send("/SECOND\r"); try h.expectWaitFor("pages.pdf:2:1 SECOND", 5000, "PDF search did not preserve the exact page-two query occurrence"); const before_look = std.mem.count(u8, h.raw.items, transmit); try h.send("n"); const look_deadline = eh.nowMs() + 5000; while (eh.nowMs() < look_deadline) { _ = try h.pumpOnce(50); if (std.mem.count(u8, h.raw.items, transmit) > before_look and h.contains("pdf 2/4 width PdfFit")) break; } try h.expectContains("pdf 2/4 width PdfFit", "n did not reveal the PDF search result at page two"); if (std.mem.count(u8, h.raw.items, transmit) <= before_look) return error.KittyPdfLookRetransmitMissing; // PdfFit changes only placement. The active decorated raster remains the // same terminal image while height fit supplies an explicit row extent. const before_fit = std.mem.count(u8, h.raw.items, transmit); const fit_raw_start = h.raw.items.len; try h.send(" tz"); try h.expectWaitFor("pdf 2/4 height PdfFit", 5000, "PdfFit did not expose height fit"); const fit_deadline = eh.nowMs() + 3000; while (eh.nowMs() < fit_deadline and kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') == null) _ = try h.pumpOnce(50); const height_apc = kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') orelse return error.KittyPdfHeightPlacementMissing; const height_crop_w = kittyParam(height_apc, 'w') orelse return error.KittyPdfHeightPlacementWrong; if (height_crop_w == 0 or height_crop_w > 96 or kittyParam(height_apc, 'h') != 64) return error.KittyPdfHeightPlacementWrong; if (std.mem.count(u8, h.raw.items, transmit) != before_fit) return error.KittyPdfFitRetransmitted; std.debug.print("PDF harness ok: Kitty continuous boundary + exact cache + search/fit\n", .{}); } fn kittyPlacementApc(raw: []const u8, start: usize) ?[]const u8 { if (start > raw.len) return null; const relative = std.mem.indexOf(u8, raw[start..], "\x1b_Ga=p,i=") orelse return null; const begin = start + relative; const end = std.mem.indexOf(u8, raw[begin..], "\x1b\\") orelse return null; return raw[begin .. begin + end + 2]; } fn kittyPlacementApcWithParam(raw: []const u8, start: usize, key: u8) ?[]const u8 { var at = start; while (kittyPlacementApc(raw, at)) |apc| { if (kittyParam(apc, key) != null) return apc; const next = @intFromPtr(apc.ptr) - @intFromPtr(raw.ptr) + apc.len; if (next <= at or next > raw.len) return null; at = next; } return null; } fn kittyParam(apc: []const u8, key: u8) ?u32 { var i: usize = 0; while (i + 3 <= apc.len) : (i += 1) { if (apc[i] != ',' or apc[i + 1] != key or apc[i + 2] != '=') continue; const begin = i + 3; var end = begin; while (end < apc.len and std.ascii.isDigit(apc[end])) : (end += 1) {} if (end == begin) return null; return std.fmt.parseUnsigned(u32, apc[begin..end], 10) catch null; } return null; } fn runGui(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { const image_path = try std.fmt.allocPrintSentinel(arena, "{s}/native.ppm", .{base}, 0); const capture_path = try std.fmt.allocPrintSentinel(arena, "{s}/latest.ppm", .{base}, 0); try writePpm(arena, image_path, 32, 16); _ = libc.unlink(capture_path); _ = setenv("PARDES_TEST", "1", 1); _ = setenv("PARDES_TEST_CAPTURE_DIR", base, 1); _ = setenv("PARDES_TEST_COLS", "20", 1); _ = setenv("PARDES_TEST_ROWS", "10", 1); var h = try eh.Harness.initArgs(gpa, exe, 10, 20, image_path); defer h.deinit(); try h.pump(1200); const capture = readFile(gpa, capture_path) catch |err| { std.debug.print("SDL image harness produced no capture ({t}); raw child output: {s}\n", .{ err, h.raw.items }); return err; }; defer gpa.free(capture); const pixels = ppmPixels(capture) orelse return error.BadCapture; var exact: usize = 0; var i: usize = 0; while (i + 2 < pixels.len) : (i += 3) { if (std.mem.eql(u8, pixels[i .. i + 3], &fixture_rgb)) exact += 1; } // The source contributes 512 aligned pixels. Leave ample room for driver // filtering while still excluding glyph-art fallback and incidental UI // colors. if (exact < 200) { std.debug.print("SDL image harness found only {d} native-color pixels in the GPU capture\n", .{exact}); return error.NativePixelsMissing; } std.debug.print("native image harness ok: SDL GPU capture ({d} source pixels)\n", .{exact}); } fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { const pdf_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.pdf", .{base}, 0); const capture_path = try std.fmt.allocPrintSentinel(arena, "{s}/latest.ppm", .{base}, 0); try writePdf(arena, pdf_path); _ = libc.unlink(capture_path); _ = setenv("PARDES_TEST", "1", 1); _ = setenv("PARDES_TEST_CAPTURE_DIR", base, 1); _ = setenv("PARDES_TEST_COLS", "20", 1); _ = setenv("PARDES_TEST_ROWS", "10", 1); var h = try eh.Harness.initArgs(gpa, exe, 10, 20, pdf_path); defer h.deinit(); const fit_width = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); const initial_green = try waitForCaptureBounds(&h, capture_path, pdf_green, 500, 5000); try expectThemeGap(capture_path, fit_width, initial_green); // One row moves both native quads by the exact document distance. They // remain together on screen, separated by the same theme-colored gap; a // page-at-a-time implementation would replace red with green here. _ = libc.unlink(capture_path); try h.send("j"); const moved_red = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); const moved_green = try waitForCaptureBounds(&h, capture_path, pdf_green, 500, 5000); if (moved_red.max_y >= fit_width.max_y or moved_green.min_y >= initial_green.min_y) return error.SdlPdfContinuousStepDidNotMove; try expectThemeGap(capture_path, moved_red, moved_green); // Fit-height fills/crops the full body height. These are GPU readback // extents, so this exercises SDL's UV crop and destination rectangle // rather than merely observing the page color. _ = libc.unlink(capture_path); try h.send(" tz"); const fit_height = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); const width_delta = if (fit_width.width() > fit_height.width()) fit_width.width() - fit_height.width() else fit_height.width() - fit_width.width(); if (fit_height.height() < fit_width.height() * 2 or width_delta > 8) { std.debug.print("SDL PDF fit extents did not change as expected: width={any}, height={any}\n", .{ fit_width, fit_height }); return error.SdlPdfFitGeometryWrong; } // In height fit one row exposes page two at the bottom but keeps page one // active. Enough additional rows then move page one wholly offscreen and // leave page two at the top, proving continuous traversal in GPU output. _ = libc.unlink(capture_path); try h.send("j"); const height_red = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); const height_green = try waitForCaptureBounds(&h, capture_path, pdf_green, 200, 5000); try expectThemeGap(capture_path, height_red, height_green); _ = libc.unlink(capture_path); try h.send("jjjjjjjj"); const green = try waitForCaptureBounds(&h, capture_path, pdf_green, 500, 5000); const final_capture = try readFile(gpa, capture_path); defer gpa.free(final_capture); const final_pixels = ppmPixels(final_capture) orelse return error.BadCapture; if (countColor(final_pixels, pdf_red) != 0) return error.SdlPdfOffscreenPageStillDrawn; std.debug.print("PDF harness ok: SDL continuous strip + theme gap + red->green ({d}->{d} pixels)\n", .{ fit_width.count, green.count }); } const ColorBounds = struct { count: usize, min_x: usize, min_y: usize, max_x: usize, max_y: usize, fn width(bounds: ColorBounds) usize { return bounds.max_x - bounds.min_x + 1; } fn height(bounds: ColorBounds) usize { return bounds.max_y - bounds.min_y + 1; } }; fn waitForCaptureBounds( h: *eh.Harness, path: [:0]const u8, color: [3]u8, minimum: usize, timeout_ms: i64, ) !ColorBounds { const deadline = eh.nowMs() + timeout_ms; var best: usize = 0; while (eh.nowMs() < deadline) { if (readFile(gpa, path)) |capture| { defer gpa.free(capture); if (ppmView(capture)) |ppm| { if (colorBounds(ppm, color)) |bounds| { best = @max(best, bounds.count); if (bounds.count >= minimum) return bounds; } } } else |_| {} _ = try h.pumpOnce(50); } std.debug.print("SDL PDF harness found only {d} pixels of color {any}\n", .{ best, color }); return error.PdfPageColorMissing; } fn expectThemeGap(path: [:0]const u8, upper: ColorBounds, lower: ColorBounds) !void { if (lower.min_y <= upper.max_y + 1) return error.SdlPdfGapMissing; const gap = lower.min_y - upper.max_y - 1; if (gap < 7 or gap > 9) return error.SdlPdfGapWrongSize; const capture = try readFile(gpa, path); defer gpa.free(capture); const ppm = ppmView(capture) orelse return error.BadCapture; const y = upper.max_y + 1 + gap / 2; const x0 = upper.min_x + 2; const x1 = @min(upper.max_x, lower.max_x) -| 2; if (x0 > x1 or y >= ppm.height) return error.SdlPdfGapOutOfBounds; const first = ppm.pixels[(y * ppm.width + x0) * 3 ..][0..3]; if (std.mem.eql(u8, first, &pdf_red) or std.mem.eql(u8, first, &pdf_green)) return error.SdlPdfGapCovered; var x = x0 + 1; while (x <= x1) : (x += 1) { const pixel = ppm.pixels[(y * ppm.width + x) * 3 ..][0..3]; if (!std.mem.eql(u8, pixel, first)) return error.SdlPdfGapNotThemeBackground; } } const PpmView = struct { width: usize, height: usize, pixels: []const u8 }; fn colorBounds(ppm: PpmView, color: [3]u8) ?ColorBounds { var bounds = ColorBounds{ .count = 0, .min_x = std.math.maxInt(usize), .min_y = std.math.maxInt(usize), .max_x = 0, .max_y = 0, }; var pixel: usize = 0; while (pixel < ppm.width * ppm.height) : (pixel += 1) { const i = pixel * 3; if (!std.mem.eql(u8, ppm.pixels[i .. i + 3], &color)) continue; const x = pixel % ppm.width; const y = pixel / ppm.width; bounds.count += 1; bounds.min_x = @min(bounds.min_x, x); bounds.min_y = @min(bounds.min_y, y); bounds.max_x = @max(bounds.max_x, x); bounds.max_y = @max(bounds.max_y, y); } return if (bounds.count == 0) null else bounds; } fn countColor(pixels: []const u8, color: [3]u8) usize { var count: usize = 0; var i: usize = 0; while (i + 2 < pixels.len) : (i += 3) count += @intFromBool(std.mem.eql(u8, pixels[i .. i + 3], &color)); return count; } fn readFile(allocator: std.mem.Allocator, path: [:0]const u8) ![]u8 { const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(allocator); var buf: [8192]u8 = undefined; while (true) { const n = libc.read(fd, &buf, buf.len); if (n < 0) return error.ReadFailed; if (n == 0) break; try out.appendSlice(allocator, buf[0..@intCast(n)]); } return out.toOwnedSlice(allocator); } fn ppmPixels(bytes: []const u8) ?[]const u8 { return (ppmView(bytes) orelse return null).pixels; } fn ppmView(bytes: []const u8) ?PpmView { if (!std.mem.startsWith(u8, bytes, "P6\n")) return null; const dimensions_end = std.mem.indexOfScalarPos(u8, bytes, 3, '\n') orelse return null; var dimensions = std.mem.splitScalar(u8, bytes[3..dimensions_end], ' '); const width = std.fmt.parseUnsigned(usize, dimensions.next() orelse return null, 10) catch return null; const height = std.fmt.parseUnsigned(usize, dimensions.next() orelse return null, 10) catch return null; if (dimensions.next() != null) return null; const max_end = std.mem.indexOfScalarPos(u8, bytes, dimensions_end + 1, '\n') orelse return null; if (!std.mem.eql(u8, bytes[dimensions_end + 1 .. max_end], "255")) return null; const expected = std.math.mul(usize, width, height) catch return null; const byte_len = std.math.mul(usize, expected, 3) catch return null; if (bytes.len -| (max_end + 1) < byte_len) return null; return .{ .width = width, .height = height, .pixels = bytes[max_end + 1 ..][0..byte_len] }; }