From ec19caddb60e748115adb562b46d007598dcdb95 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 21:28:49 -0300 Subject: render PDFs as a continuous vertical page strip --- test/image_harness.zig | 286 +++++++++++++++++++++++-------------------------- 1 file changed, 137 insertions(+), 149 deletions(-) (limited to 'test/image_harness.zig') diff --git a/test/image_harness.zig b/test/image_harness.zig index f51839ae..4b1c527c 100644 --- a/test/image_harness.zig +++ b/test/image_harness.zig @@ -234,78 +234,79 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) 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. It must use Kitty's explicit column - // scaling rather than Vaxis contain. The PDF shares this test session with - // the initial shell pane, making its body slightly narrower than the page: - // the full source is retained and vertically letterboxed by a pixel offset. + // 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) + 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 source_h = kittyParam(width_apc, 'h') orelse return error.KittyPdfWidthCropMissing; - const pixel_y = kittyParam(width_apc, 'Y') orelse return error.KittyPdfWidthLetterboxMissing; - if (source_h != 64 or pixel_y == 0 or kittyParam(width_apc, 'c') == null) { - std.debug.print("unexpected Kitty fit-width letterbox: {s}\n", .{width_apc}); - return error.KittyPdfWidthLetterboxWrong; - } - - // Placement-only state reuses the terminal image. Middle-click the - // visible PdfFit word in the PDF tag, wait for a fit-height placement - // with explicit rows and a full source image, and prove that no second - // transmit happened. - const transmit_before_fit = std.mem.count(u8, h.raw.items, transmit); - const fit_raw_start = h.raw.items.len; - const tag = try h.screenText(); - defer gpa.free(tag); - const fit_at = std.mem.indexOf(u8, tag, "PdfFit") orelse - return error.PdfFitMissing; - const line_at = std.mem.lastIndexOfScalar(u8, tag[0..fit_at], '\n'); - const mouse_col = fit_at - if (line_at) |at| at + 1 else 0; - const mouse_row = std.mem.count(u8, tag[0..fit_at], "\n"); - const click = try std.fmt.allocPrint( - arena, - "\x1b[<1;{d};{d}M\x1b[<1;{d};{d}m", - .{ mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1 }, - ); - try h.send(click); - const fit_deadline = eh.nowMs() + 3000; - while (eh.nowMs() < fit_deadline) { - _ = try h.pumpOnce(50); - if (kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') != null) break; - } - try h.expectContains("pdf 1/4 height PdfFit", "PdfFit did not expose the active height fit"); - 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 (kittyParam(height_apc, 'x') != 0 or kittyParam(height_apc, 'y') != 0 or - height_crop_w == 0 or height_crop_w >= 96 or kittyParam(height_apc, 'h') != 64 or - kittyParam(height_apc, 'r') == null) - return error.KittyPdfHeightPlacementWrong; - if (std.mem.count(u8, h.raw.items, transmit) != transmit_before_fit) - return error.KittyPdfFitRetransmitted; - - // Same pane, different pixels: page navigation must delete the cached - // Kitty image and transmit the new revision, not keep showing page one. + 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 second_deadline = eh.nowMs() + 5000; - while (eh.nowMs() < second_deadline) { + 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); - const deleted = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=d,d=I,i=") != null; - if (deleted and std.mem.count(u8, h.raw.items, transmit) >= 2 and h.contains("pdf 2/4 height PdfFit")) break; - } - try h.expectRawContains("\x1b_Ga=d,d=I,i=", "page navigation did not release the old Kitty PDF image"); - if (std.mem.count(u8, h.raw.items, transmit) < 2) return error.KittyPdfRetransmitMissing; - try h.expectContains("pdf 2/4 height PdfFit", "j did not retain height fit on PDF page two"); - - // Exercise the semantic seam too: return to page one, search an exact - // occurrence across the document, then let n Look the page-two result. - // That Look must change the page and therefore produce another pixel - // generation. - try h.send("k"); - try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "k did not return to PDF page one at height fit"); + 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); @@ -313,85 +314,30 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) 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 height PdfFit")) break; + 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 height PdfFit", "n did not Look the PDF search result at page two"); + 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; - // Kitty turns a larger wheel offset into adjacent SGR wheel presses. The - // TTY shell may drain those presses in one input batch, but every native - // PDF page transition must be rastered before the next press consumes the - // new page's geometry. Distinct page heights make that ordering observable - // in the actual Kitty transmit headers: page 2 is 96x64, page 3 96x67, - // and page 4 96x69. This is also a direct one-detent check before the - // burst and a direction-reversal check after it. - const wheel_up = try std.fmt.allocPrint(arena, "\x1b[<64;{d};{d}M", .{ mouse_col + 1, mouse_row + 1 }); - const wheel_down = try std.fmt.allocPrint(arena, "\x1b[<65;{d};{d}M", .{ mouse_col + 1, mouse_row + 1 }); - const page_three_transmit = "\x1b_Gf=32,s=96,v=67,i="; - const page_four_transmit = "\x1b_Gf=32,s=96,v=69,i="; - - try h.send(wheel_up); - try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "wheel-up did not return to PDF page one"); - const single_start = h.raw.items.len; - try h.send(wheel_down); - try h.expectWaitFor("pdf 2/4 height PdfFit", 5000, "one wheel detent did not advance exactly one PDF page"); - try h.pump(100); - if (std.mem.indexOf(u8, h.raw.items[single_start..], page_three_transmit) != null) - return error.KittyPdfSingleWheelSkippedPage; - - try h.send(wheel_up); - try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "wheel-up did not reset the burst fixture"); - const burst_start = h.raw.items.len; - const forward_burst = try std.fmt.allocPrint( - arena, - "\x1b[<65;{d};{d}M\x1b[<65;{d};{d}M\x1b[<65;{d};{d}M", - .{ mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1 }, - ); - try h.send(forward_burst); - try h.expectWaitFor("pdf 4/4 height PdfFit", 8000, "forward Kitty wheel burst did not reach PDF page four"); - const forward_raw = h.raw.items[burst_start..]; - const page_two_at = std.mem.indexOf(u8, forward_raw, transmit) orelse - return error.KittyPdfBurstSkippedPageTwoRender; - const page_three_at = std.mem.indexOf(u8, forward_raw, page_three_transmit) orelse - return error.KittyPdfBurstSkippedPageThreeRender; - const page_four_at = std.mem.indexOf(u8, forward_raw, page_four_transmit) orelse - return error.KittyPdfBurstSkippedPageFourRender; - if (!(page_two_at < page_three_at and page_three_at < page_four_at)) - return error.KittyPdfBurstRenderOrderWrong; - - const reverse_start = h.raw.items.len; - const reverse_burst = try std.fmt.allocPrint( - arena, - "\x1b[<64;{d};{d}M\x1b[<64;{d};{d}M\x1b[<65;{d};{d}M\x1b[<64;{d};{d}M", - .{ - mouse_col + 1, mouse_row + 1, - mouse_col + 1, mouse_row + 1, - mouse_col + 1, mouse_row + 1, - mouse_col + 1, mouse_row + 1, - }, - ); - try h.send(reverse_burst); - try h.expectWaitFor("pdf 2/4 height PdfFit", 8000, "mixed-direction Kitty wheel burst did not return to PDF page two"); - const reverse_deadline = eh.nowMs() + 5000; - while (eh.nowMs() < reverse_deadline) { - const emitted = h.raw.items[reverse_start..]; - if (std.mem.count(u8, emitted, page_three_transmit) >= 2 and - std.mem.count(u8, emitted, transmit) >= 2) break; + // 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 reverse_raw = h.raw.items[reverse_start..]; - const reverse_three_1 = std.mem.indexOf(u8, reverse_raw, page_three_transmit) orelse - return error.KittyPdfReverseBurstSkippedPageThreeRender; - const reverse_two_1 = std.mem.indexOfPos(u8, reverse_raw, reverse_three_1 + page_three_transmit.len, transmit) orelse - return error.KittyPdfReverseBurstSkippedPageTwoRender; - const reverse_three_2 = std.mem.indexOfPos(u8, reverse_raw, reverse_two_1 + transmit.len, page_three_transmit) orelse - return error.KittyPdfDirectionChangeSkippedPageThreeRender; - const reverse_two_2 = std.mem.indexOfPos(u8, reverse_raw, reverse_three_2 + page_three_transmit.len, transmit) orelse - return error.KittyPdfDirectionChangeSkippedPageTwoRender; - if (!(reverse_three_1 < reverse_two_1 and reverse_two_1 < reverse_three_2 and reverse_three_2 < reverse_two_2)) - return error.KittyPdfReverseBurstRenderOrderWrong; - - std.debug.print("PDF harness ok: Kitty fit/cache + page/search + metered wheel transitions\n", .{}); + 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 { @@ -474,11 +420,23 @@ fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !v 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); - // The narrow PDF pane makes fit-width vertically letterboxed. Fit-height - // keeps roughly the same colored width but 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. + // 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); @@ -486,19 +444,28 @@ fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !v 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 or - fit_height.min_y >= fit_width.min_y) - { + 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; } - // Remove the old capture before the key so merely rereading page one's - // file cannot satisfy page two. The app replaces this path atomically. + // 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); - std.debug.print("PDF harness ok: SDL fit geometry + red->green ({d}->{d} pixels)\n", .{ fit_width.count, green.count }); + 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 { @@ -542,6 +509,27 @@ fn waitForCaptureBounds( 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 { -- cgit v1.3