From 3975791ecb0a849a9b3beface1f9ef3ed9f0505f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 23:42:59 -0300 Subject: pdf-scroll-bench takes --cell WxH, --slow-frames N and one tall page, and reports the rasters' peak bytes A single tall page is four screenfuls of strip as much as four pages are, so the gate asks for the strip, not the page count. --cell 16x32 is the same window on a 2x display, and peak_kib is the most the pane's rasters, clean copies and spares held after any frame. Co-Authored-By: Claude Opus 5.5 --- test/pdf_scroll_bench.zig | 106 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 74 insertions(+), 32 deletions(-) (limited to 'test') diff --git a/test/pdf_scroll_bench.zig b/test/pdf_scroll_bench.zig index a4511dbe..166fa809 100644 --- a/test/pdf_scroll_bench.zig +++ b/test/pdf_scroll_bench.zig @@ -4,6 +4,7 @@ //! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --json //! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --warmup 1 --reps 15 //! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --path other.pdf +//! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --cell 16x32 a 2x display //! zig build pdf-scroll-bench -Doptimize=Debug -- --reps 1 the diagnosis //! zig build pdf-scroll-bench -Dtracy=~/05-genizah/tracy the zones //! @@ -62,7 +63,7 @@ const gpa = std.heap.smp_allocator; // is what the tty shell actually ships. const bench_cols: u16 = 120; const bench_rows: u16 = 40; -const bench_cell_pixels: pardes.CellPixels = .{ .w = 8, .h = 16 }; +const default_cell_pixels: pardes.CellPixels = .{ .w = 8, .h = 16 }; /// config.wheel_rows is one cell per notch, so a notch is `cell_pixels.h` /// display pixels. 192 notches is ~3072px — between two and three fit-width @@ -74,7 +75,7 @@ const slow_ticks_per_frame: usize = 1; /// The control row is a fixed wall of frames rather than a whole-document /// traversal: one notch per frame over a real document is tens of thousands of /// frames, and 240 already crosses several page boundaries at reading speed. -const slow_frames: usize = 240; +const default_slow_frames: usize = 240; /// Guard against a pathological document turning one row into a multi-minute /// run. Deterministic either way: the frame count is derived from layout. const max_frames: usize = 2048; @@ -91,6 +92,10 @@ const Config = struct { /// Override `max_frames` for one run: a 5000-page manual is 2000 frames of /// travel per round, which is a fine scoreboard and a poor edit-run loop. frames: ?usize = null, + /// One cell in display pixels: 16x32 is the same window on a 2x display. + cell: pardes.CellPixels = default_cell_pixels, + /// Frames of the one-notch control row. + slow_frames: usize = default_slow_frames, }; /// The bit-exact description of one scrolling run. `visual` is the invariant; @@ -120,6 +125,9 @@ const Result = struct { total_ns: u64, rasterizations: u64, rasters_retained: u64, + /// Most bytes the pane's rasters held after any frame: every slot's + /// pixels, its clean copy, and the spare buffers kept for reuse. + peak_raster_bytes: u64, identity: Identity, }; @@ -174,13 +182,13 @@ pub fn main(init: std.process.Init) !void { // One untimed probe boot supplies the strip height every plan is sized // from, so the frame counts below are a property of the document and the // viewport rather than of the machine. - const travel = probeTravel(config.path); + const travel = probeTravel(config); const cap = config.frames orelse max_frames; const results = [_]Result{ - try measure(config, planFor(.fling_down, travel, cap)), - try measure(config, planFor(.fling_reverse, travel, cap)), - try measure(config, planFor(.slow_scroll, travel, cap)), + try measure(config, planFor(.fling_down, travel, cap, config.slow_frames)), + try measure(config, planFor(.fling_reverse, travel, cap, config.slow_frames)), + try measure(config, planFor(.slow_scroll, travel, cap, config.slow_frames)), }; if (config.json) @@ -207,9 +215,19 @@ fn parseArgs(args: []const []const u8) Config { } else if (std.mem.eql(u8, args[i], "--frames") and i + 1 < args.len) { i += 1; config.frames = parseCount("--frames", args[i]); + } else if (std.mem.eql(u8, args[i], "--slow-frames") and i + 1 < args.len) { + i += 1; + config.slow_frames = parseCount("--slow-frames", args[i]); + } else if (std.mem.eql(u8, args[i], "--cell") and i + 1 < args.len) { + i += 1; + var it = std.mem.splitScalar(u8, args[i], 'x'); + const w = parseCount("--cell", it.next() orelse ""); + const h = parseCount("--cell", it.next() orelse ""); + if (w > std.math.maxInt(u16) or h > std.math.maxInt(u16)) fatal("--cell {s} is too large", .{args[i]}); + config.cell = .{ .w = @intCast(w), .h = @intCast(h) }; } else fatal( "usage: pardes-pdf-scroll-bench [--json] [--warmup N] [--reps N]" ++ - " [--path FILE] [--frames N]", + " [--path FILE] [--frames N] [--slow-frames N] [--cell WxH]", .{}, ); } @@ -226,28 +244,35 @@ const Travel = struct { /// same benchmark and the report must not pretend otherwise. pages: usize, strip_px: u64, + /// The first frame of a fresh pane: opening the document and rasterizing + /// what it shows first. One sample, from the probe boot. + first_frame_ns: u64, }; -fn probeTravel(path: []const u8) Travel { - const core = bootCore(path) catch |err| +fn probeTravel(config: Config) Travel { + const path = config.path; + const core = bootCore(config) catch |err| fatal("cannot boot a PDF pane on {s}: {t}", .{ path, err }); defer core.deinit(); var frame_arena = std.heap.ArenaAllocator.init(gpa); defer frame_arena.deinit(); + const started = nowNs(); _ = core.render(frame_arena.allocator()) catch |err| fatal("first render failed: {t}", .{err}); drainEffects(core); + const first_frame_ns = nowNs() -| started; const pane = core.panes[0] orelse fatal("PDF pane disappeared", .{}); if (pane.pdf == null) fatal("{s} did not open as a PDF", .{path}); const pv = &pane.pdf.?; - if (pv.page_count < 4) - fatal("{s} has {d} pages; a fling benchmark needs at least 4", .{ path, pv.page_count }); if (!pv.layout_valid or pv.document_height == 0) fatal("the pixel strip never laid out; is native_images/cell_pixels wired?", .{}); // Exactly the clamp scrollPdfDocument enforces, so the last fling frame // lands on the document end instead of adding a no-op frame to the row. - const viewport_h: u64 = @as(u64, bench_rows -| pardes.BOX_H) * bench_cell_pixels.h; - const notch: f64 = @floatFromInt(bench_cell_pixels.h); + const viewport_h: u64 = @as(u64, bench_rows -| pardes.BOX_H) * config.cell.h; + // Four screenfuls of strip, in pages or in one tall page. + if (pv.document_height < 4 * viewport_h) + fatal("{s} is {d}px of strip; a fling benchmark needs at least four screenfuls", .{ path, pv.document_height }); + const notch: f64 = @floatFromInt(config.cell.h); if (!std.math.isFinite(notch) or notch <= 0) fatal("a notch of {d} display pixels cannot size a fling", .{notch}); const strip = @as(f64, @floatFromInt(pv.document_height -| viewport_h)); @@ -258,6 +283,7 @@ fn probeTravel(path: []const u8) Travel { .per_frame = notch * @as(f64, @floatFromInt(fling_ticks_per_frame)), .pages = pv.page_count, .strip_px = pv.document_height, + .first_frame_ns = first_frame_ns, }; } @@ -267,7 +293,7 @@ fn probeTravel(path: []const u8) Travel { /// The cap is applied before any arithmetic on the count, so a document the /// float division cannot describe still produces a runnable plan instead of an /// overflow. -fn planFor(scenario: Scenario, travel: Travel, cap: usize) Plan { +fn planFor(scenario: Scenario, travel: Travel, cap: usize, slow: usize) Plan { const wanted = travel.max_scroll / travel.per_frame; const whole: usize = if (std.math.isFinite(wanted) and wanted >= 1) @intFromFloat(@min(@as(f64, @floatFromInt(cap)), @ceil(wanted))) @@ -282,15 +308,15 @@ fn planFor(scenario: Scenario, travel: Travel, cap: usize) Plan { .frames = @max(2, down), .down_frames = @max(1, down / 2), }, - .slow_scroll => .{ .scenario = scenario, .frames = slow_frames, .down_frames = slow_frames }, + .slow_scroll => .{ .scenario = scenario, .frames = slow, .down_frames = slow }, }; } /// A pane on a real multi-page document with the native pixel-strip path live /// and a genuine pixel viewport delivered the way a shell delivers one. -fn bootCore(path: []const u8) !*pardes.Pardes { +fn bootCore(config: Config) !*pardes.Pardes { const core = try pardes.Pardes.init(gpa, .{ - .file = path, + .file = config.path, .cols = bench_cols, .rows = bench_rows, }); @@ -299,7 +325,7 @@ fn bootCore(path: []const u8) !*pardes.Pardes { core.update(.{ .resize = .{ .cols = bench_cols, .rows = bench_rows, - .cell_pixels = bench_cell_pixels, + .cell_pixels = config.cell, } }); drainEffects(core); return core; @@ -318,9 +344,10 @@ fn measure(config: Config, plan: Plan) !Result { var expected: ?Identity = null; var rasterizations: u64 = 0; var rasters_retained: u64 = 0; + var peak_raster_bytes: u64 = 0; for (0..config.warmup + config.reps) |round| { - const core = try bootCore(config.path); + const core = try bootCore(config); defer core.deinit(); var frame_arena = std.heap.ArenaAllocator.init(gpa); defer frame_arena.deinit(); @@ -355,6 +382,7 @@ fn measure(config: Config, plan: Plan) !Result { const elapsed = nowNs() -| started; total += elapsed; recorder.observe(frame, surface); + peak_raster_bytes = @max(peak_raster_bytes, rasterBytes(pv)); // The same frame boundary src/tty/tty.zig marks, so a Tracy capture // of this benchmark reads like a capture of the real shell. pardes.frameMark(); @@ -388,10 +416,19 @@ fn measure(config: Config, plan: Plan) !Result { .total_ns = totals[totals.len / 2], .rasterizations = rasterizations, .rasters_retained = rasters_retained, + .peak_raster_bytes = peak_raster_bytes, .identity = expected.?, }; } +/// What the pane's rasters hold right now (untimed: outside the frame). +fn rasterBytes(pv: anytype) u64 { + var total: u64 = 0; + for (pv.rasters[0..pv.rasters_len]) |raster| total += raster.rgba.len + raster.clean.len; + for (pv.spare[0..pv.spare_len]) |spare| total += spare.len; + return total; +} + /// Accumulates the two checksums across one run. Raster generations are /// rebased on the counter's value at run start so the trace describes the /// ORDER textures were produced in rather than absolute counter values, which @@ -545,23 +582,24 @@ fn reportText(config: Config, travel: Travel, results: []const Result) void { std.debug.print( "{s}: {d} pages, {d}px strip at {d}x{d} cells of {d}x{d}px\n", .{ - config.path, travel.pages, travel.strip_px, bench_cols, - bench_rows, bench_cell_pixels.w, bench_cell_pixels.h, + config.path, travel.pages, travel.strip_px, bench_cols, + bench_rows, config.cell.w, config.cell.h, }, ); + std.debug.print("first frame of a fresh pane: {d}us\n", .{travel.first_frame_ns / 1000}); std.debug.print( "warmup: {d}, sampled reps: {d}; every number is ONE FRAME (notch batch + render + drain)\n\n", .{ config.warmup, config.reps }, ); - std.debug.print("{s:<16} {s:>6} {s:>7} {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>11} {s:>7} {s:>5} {s:<16} {s}\n", .{ - "scenario", "frames", "notches", "min", - "median", "p90", "p99", "max", - "total_us", "rasters", "kept", "visual", - "transport", + std.debug.print("{s:<16} {s:>6} {s:>7} {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>11} {s:>7} {s:>5} {s:>9} {s:<16} {s}\n", .{ + "scenario", "frames", "notches", "min", + "median", "p90", "p99", "max", + "total_us", "rasters", "kept", "peak_kib", + "visual", "transport", }); std.debug.print("{s}\n", .{"-" ** 160}); for (results) |result| std.debug.print( - "{s:<16} {d:>6} {d:>7} {d:>10} {d:>10} {d:>10} {d:>10} {d:>10} {d:>11} {d:>7} {d:>5} {x:0>16} {x:0>16}\n", + "{s:<16} {d:>6} {d:>7} {d:>10} {d:>10} {d:>10} {d:>10} {d:>10} {d:>11} {d:>7} {d:>5} {d:>9} {x:0>16} {x:0>16}\n", .{ result.name, result.frames, @@ -574,12 +612,14 @@ fn reportText(config: Config, travel: Travel, results: []const Result) void { result.total_ns / 1000, result.rasterizations, result.rasters_retained, + result.peak_raster_bytes / 1024, result.identity.visual, result.identity.transport, }, ); std.debug.print( - "\n`rasters` counts MuPDF page rasterizations over one run; `kept` is the retained raster count at the end.\n" ++ + "\n`rasters` counts MuPDF page rasterizations over one run; `kept` is the retained raster count at the end;\n" ++ + "`peak_kib` is the most the pane's rasters, clean copies and spares held after any frame.\n" ++ "`visual` pins presented pixels and placement and must never change; `transport` traces texture uploads.\n", .{}, ); @@ -592,16 +632,17 @@ fn reportJson(io: std.Io, config: Config, travel: Travel, results: []const Resul var out: std.Io.Writer = .fixed(&storage); out.print( "{{\"benchmark\":\"pardes-pdf-scroll\",\"build\":\"{s}\",\"path\":\"{s}\"," ++ - "\"pages\":{d},\"strip_px\":{d},\"warmup\":{d},\"reps\":{d},\"results\":[", + "\"pages\":{d},\"strip_px\":{d},\"first_frame_ns\":{d},\"warmup\":{d},\"reps\":{d},\"results\":[", .{ - bench_config.core_optimize, config.path, travel.pages, - travel.strip_px, config.warmup, config.reps, + bench_config.core_optimize, config.path, travel.pages, + travel.strip_px, travel.first_frame_ns, config.warmup, + config.reps, }, ) catch return; for (results, 0..) |result, i| out.print( "{s}{{\"scenario\":\"{s}\",\"frames\":{d},\"notches_per_frame\":{d}," ++ "\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d},\"p99_ns\":{d},\"max_ns\":{d},\"total_ns\":{d}," ++ - "\"rasterizations\":{d},\"rasters_retained\":{d},\"images\":{d}," ++ + "\"rasterizations\":{d},\"rasters_retained\":{d},\"peak_raster_bytes\":{d},\"images\":{d}," ++ "\"visual\":\"{x:0>16}\",\"transport\":\"{x:0>16}\"}}", .{ if (i == 0) "" else ",", @@ -616,6 +657,7 @@ fn reportJson(io: std.Io, config: Config, travel: Travel, results: []const Resul result.total_ns, result.rasterizations, result.rasters_retained, + result.peak_raster_bytes, result.identity.images, result.identity.visual, result.identity.transport, -- cgit v1.3 From 65262f4a033d382426c7f58d57a9b38e45918558 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 23:54:30 -0300 Subject: A PDF page taller than one raster is drawn at the size it is shown, in bands around the screen, not squeezed into 4096 rows and blown up The render geometry (pdf_bridge.c) clamped a page's LONGEST side to max_dimension: 4096 in the SDL policy, 1200 in Kitty's. A page 841.89 x 4818.9 pt, fit to a 1570 px wide pane, came out 715 x 4096 and was drawn 2.2 times too large at 1x, 4.4 times at 2x; Kitty's raster was 210 px wide. Every request now caps a raster ROW at max_dimension, and caps the height too only while that keeps the page at least as large as it is shown (RenderRequest.display_width/height, set from the fit). A tall page fit to width is rasterized at the pane's own width, in SDL, and at Kitty's 96 dpi as any other page is there. Such a page is not one buffer. A raster over 8192 rows (a texture's and Kitty's 10000-pixel limit) or over 64 MiB is banded (pdf_view.zig): its slot holds the rows on screen and a screenful either side, in 512-row chunks, and is rendered again, keeping the chunks it has, once the screen comes within half a screenful of its edge. Each chunk comes from its own render reaching 32 rows past both edges, so a row of the page is the same however it was scrolled to, and no chunk shows its clip edge: band rows are within 3 levels of a whole-page render (108 pixels of 45 million on the reported PDF). Highlights paint over the band's clean rows as over a page's, and the band uses the page's CTM, so search, selection and pointer geometry are unchanged. A display-list render now culls to the band (a scissor in page space). No pixel changes, but a frame that renders new rows of the reported page at 2x fell from 33-43 ms to 13-16 ms (pdf-scroll-bench, --cell 16x32). design.pdf's scroll-bench pixels are identical. Co-Authored-By: Claude Opus 5.5 --- src/pdf.zig | 128 ++++++++++++++++++++++++++++++++++++++-- src/pdf_bridge.c | 76 +++++++++++++++++++----- src/pdf_bridge.h | 19 +++++- src/pdf_view.zig | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- test/pdf.zig | 168 +++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 537 insertions(+), 29 deletions(-) (limited to 'test') diff --git a/src/pdf.zig b/src/pdf.zig index 8ddca497..d98d5ff2 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -66,13 +66,17 @@ test "custom allocator owns a complete PDF document lifecycle" { } /// One raster-quality request. `minimum_*` asks MuPDF for enough source pixels -/// to avoid backend upscaling; `max_dimension` remains the hard allocation -/// ceiling for hostile page sizes and very large displays. +/// to avoid backend upscaling; `max_dimension` caps a raster row always, and +/// the page's height unless that would draw it below `display_*`, the size +/// the page is shown at (0: no floor). A page taller than that is never one +/// allocation: its owner renders bands of it (`Raster.band`). pub const RenderRequest = struct { dpi: u16 = 144, minimum_width: u32 = 0, minimum_height: u32 = 0, max_dimension: u16 = 1600, + display_width: u32 = 0, + display_height: u32 = 0, pub fn eql(a: RenderRequest, b: RenderRequest) bool { return std.meta.eql(a, b); @@ -81,8 +85,12 @@ pub const RenderRequest = struct { pub const default_render_request: RenderRequest = .{}; pub const absolute_max_render_dimension: u16 = 4096; +/// The most one render buffer holds: a whole page, or one band of a page +/// taller than this. pub const max_owned_raster_bytes: usize = @as(usize, absolute_max_render_dimension) * absolute_max_render_dimension * 4; +/// The tallest page raster any request produces (rows), whatever it is shown at. +pub const max_raster_rows: usize = c.PARDES_PDF_MAX_RASTER_ROWS; /// MuPDF's per-document eviction cache ceiling. The owned RGBA render buffer /// is allocated separately by Pardes and does not count against this store. pub const store_limit_bytes: usize = c.PARDES_PDF_STORE_LIMIT_BYTES; @@ -895,11 +903,13 @@ pub const Document = struct { bounded.minimum_width, bounded.minimum_height, bounded.max_dimension, + bounded.display_width, + bounded.display_height, &raw_layout, ) != c.PARDES_PDF_OK) return error.RenderFailed; - const layout = try checkedRasterLayout(raw_layout); - if (layout.len > max_owned_raster_bytes) return error.PixmapTooLarge; - return layout; + // The page's shape, not an allocation: a page taller than + // `max_owned_raster_bytes` allows is rendered a band at a time. + return checkedRasterLayout(raw_layout); } /// Rasterize `band` of a page into `rgba`, which MUST be exactly @@ -932,6 +942,7 @@ pub const Document = struct { if (rgba.len != band.len) return error.BadPixmap; if (band.height == 0 or band.y + band.height > raster.height) return error.BadPixmap; + if (band.len > max_owned_raster_bytes) return error.PixmapTooLarge; const bounded = try boundedRequest(document, page, request); if (highlights.len > c.PARDES_PDF_MAX_RESULT_QUADS) return error.RenderFailed; const highlight_ptr: ?[*]const c.pardes_pdf_highlight = @@ -943,6 +954,8 @@ pub const Document = struct { bounded.minimum_width, bounded.minimum_height, bounded.max_dimension, + bounded.display_width, + bounded.display_height, highlight_ptr, highlights.len, rgba.ptr, @@ -982,6 +995,8 @@ pub const Document = struct { bounded.minimum_width, bounded.minimum_height, bounded.max_dimension, + bounded.display_width, + bounded.display_height, if (highlights.len == 0) null else @ptrCast(highlights.ptr), highlights.len, rgba.ptr, @@ -1015,6 +1030,8 @@ pub const Document = struct { bounded.minimum_width, bounded.minimum_height, bounded.max_dimension, + bounded.display_width, + bounded.display_height, if (highlights.len == 0) null else @ptrCast(highlights.ptr), highlights.len, &row_start, @@ -1028,6 +1045,8 @@ pub const Document = struct { minimum_width: c_int, minimum_height: c_int, max_dimension: c_int, + display_width: c_int, + display_height: c_int, }; fn boundedRequest( @@ -1051,9 +1070,15 @@ pub const Document = struct { @as(u32, @intCast(std.math.maxInt(c_int))), )), .max_dimension = request.max_dimension, + .display_width = clampInt(request.display_width), + .display_height = clampInt(request.display_height), }; } + fn clampInt(value: u32) c_int { + return @intCast(@min(value, @as(u32, @intCast(std.math.maxInt(c_int))))); + } + fn renderInternal( document: *Document, gpa: std.mem.Allocator, @@ -1062,6 +1087,7 @@ pub const Document = struct { highlights: ?[]const Highlight, ) !Render { const raster = try document.measureRenderAt(page, request); + if (raster.len > max_owned_raster_bytes) return error.PixmapTooLarge; const rgba = try gpa.alloc(u8, raster.len); errdefer gpa.free(rgba); try document.renderIntoAt( @@ -1453,6 +1479,94 @@ pub fn makeLinksTestPdf(gpa: std.mem.Allocator) ![]u8 { return allocateGeneratedPdf(gpa, &offsets, {}, emitLinksPdf); } +/// The tall test page: 600 x 9000 pt, fifteen A4 pages' height on one. +pub const tall_test_page: PageSize = .{ .width = 600, .height = 9000 }; + +fn emitTallPdf(bytes: *PdfBytes, offsets: []usize, _: void) !void { + // A labelled row every 100 pt, each with a slanting rule, so a band + // edge or a seam between chunks cuts through text and through lines. + var storage: [16 * 1024]u8 = undefined; + var stream: std.Io.Writer = .fixed(&storage); + var row: usize = 0; + while (row * 100 + 160 < 9000) : (row += 1) { + const y = 9000 - 60 - row * 100; + stream.print( + "BT /F1 28 Tf 40 {d} Td (Row {d}) Tj ET\n0.2 0.3 0.9 RG 3 w 20 {d} m 580 {d} l S\n", + .{ y, row, y - 20, y - 80 }, + ) catch return error.GeneratedPdfTooLarge; + } + stream.writeAll("BT /F1 28 Tf 40 40 Td (bottommost) Tj ET\n") catch return error.GeneratedPdfTooLarge; + const content = stream.buffered(); + try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"); + try beginPdfObject(bytes, offsets, 1); + try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 2); + try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 3); + try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 9000] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 4); + try bytes.print("<< /Length {d} >>\nstream\n", .{content.len}); + try bytes.appendSlice(content); + try bytes.appendSlice("endstream\nendobj\n"); + try beginPdfObject(bytes, offsets, 5); + try bytes.appendSlice("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); + try finishGeneratedPdf(bytes, offsets); +} + +/// TEST-ONLY: one page fifteen times as tall as it is wide (`tall_test_page`), +/// "Row N" every 100 pt from the top and "bottommost" at its foot. +pub fn makeTallTestPdf(gpa: std.mem.Allocator) ![]u8 { + var offsets: [6]usize = undefined; + return allocateGeneratedPdf(gpa, &offsets, {}, emitTallPdf); +} + +test "a tall page rasterizes at the size it is shown, however many max_dimensions tall" { + const bytes = try makeTallTestPdf(std.testing.allocator); + defer std.testing.allocator.free(bytes); + var document = try Document.openBytes(bytes); + defer document.deinit(); + const size = try document.pageSize(0); + try std.testing.expectEqual(tall_test_page.width, size.width); + try std.testing.expectEqual(tall_test_page.height, size.height); + + // Shown 960 px wide (fit width): 960 px a row, 14400 rows, though that is + // far past max_dimension. The old clamp of the longest side made this + // 273 x 4096, drawn 3.5 times too large. + const shown: RenderRequest = .{ .dpi = 192, .minimum_width = 960, .minimum_height = 600, .max_dimension = 4096, .display_width = 960 }; + const tall = try document.measureRenderAt(0, shown); + try std.testing.expectEqual(@as(usize, 960), tall.width); + try std.testing.expectEqual(@as(usize, 14400), tall.height); + // Kitty's 96 dpi, as any page has it, not 1200 rows' worth. + const kitty = try document.measureRenderAt(0, .{ .dpi = 96, .max_dimension = 1200, .display_width = 944 }); + try std.testing.expectEqual(@as(usize, 800), kitty.width); + try std.testing.expectEqual(@as(usize, 12000), kitty.height); + // The dpi never takes it past what is shown: that would be bands of + // pixels nobody sees. + const small = try document.measureRenderAt(0, .{ .dpi = 192, .max_dimension = 4096, .display_width = 300 }); + try std.testing.expectEqual(@as(usize, 300), small.width); + // Shown whole (fit height), the height cap holds: the page is 600 px + // tall on screen and 4096 rows is already more. + const whole = try document.measureRenderAt(0, .{ .dpi = 192, .minimum_width = 960, .minimum_height = 600, .max_dimension = 4096, .display_height = 600 }); + try std.testing.expectEqual(@as(usize, 4096), whole.height); + // Without a display size, the old budget. + const blind = try document.measureRenderAt(0, .{ .dpi = 192, .minimum_width = 960, .max_dimension = 4096 }); + try std.testing.expectEqual(@as(usize, 4096), blind.height); + // A row is never longer than max_dimension, whatever is shown. + const wide = try document.measureRenderAt(0, .{ .dpi = 192, .minimum_width = 10_000, .max_dimension = 4096, .display_width = 10_000 }); + try std.testing.expectEqual(@as(usize, 4096), wide.width); + // One buffer is bounded even when the page is not: a whole render of a + // page past it is refused, a band of it is not. + const poster: RenderRequest = .{ .dpi = 72, .minimum_width = 4096, .max_dimension = 4096, .display_width = 4096 }; + const huge = try document.measureRenderAt(0, poster); + try std.testing.expect(huge.len > max_owned_raster_bytes); + try std.testing.expectError(error.PixmapTooLarge, document.renderAt(std.testing.allocator, 0, poster)); + const band = huge.band(huge.height - 100, 100); + const rows = try std.testing.allocator.alloc(u8, band.len); + defer std.testing.allocator.free(rows); + try document.renderIntoAt(0, poster, huge, band, &.{}, rows); + try expectOpaque(rows); +} + test "PDF links own URIs across page eviction and preserve internal coordinates" { const gpa = std.testing.allocator; start(gpa); @@ -1830,6 +1944,8 @@ test "RGBA allocation and buffer-validation failures leave the document renderab default_render_request.minimum_width, default_render_request.minimum_height, default_render_request.max_dimension, + 0, + 0, &raw_layout, )); const layout = try checkedRasterLayout(raw_layout); @@ -1844,6 +1960,8 @@ test "RGBA allocation and buffer-validation failures leave the document renderab default_render_request.minimum_width, default_render_request.minimum_height, default_render_request.max_dimension, + 0, + 0, null, 0, scratch.ptr, diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c index 2c723dbb..6fdab46c 100644 --- a/src/pdf_bridge.c +++ b/src/pdf_bridge.c @@ -481,6 +481,8 @@ pardes_pdf_render_geometry( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, fz_matrix *ctm_out, fz_irect *bbox_out) { @@ -488,23 +490,38 @@ pardes_pdf_render_geometry( fz_rect bounds; float page_width; float page_height; - float longest; float scale; + float shown = 0.0f; pardes_pdf_cache_page(document, page_number, 0); bounds = document->cached_bounds; page_width = bounds.x1 - bounds.x0; page_height = bounds.y1 - bounds.y0; - longest = fmaxf(page_width, page_height); - if (!(longest > 0.0f)) + if (!(page_width > 0.0f) || !(page_height > 0.0f)) fz_throw(document->ctx, FZ_ERROR_FORMAT, "PDF page has empty bounds"); scale = (float)dpi / 72.0f; if (minimum_width > 0) scale = fmaxf(scale, (float)minimum_width / page_width); if (minimum_height > 0) scale = fmaxf(scale, (float)minimum_height / page_height); - if (longest * scale > (float)max_dimension) - scale = (float)max_dimension / longest; + /* + * A row of the raster is never longer than max_dimension: every band of + * the page carries whole rows. The height is held to it too, as the + * budget of one whole-page raster, but never below the scale the page is + * shown at: a page taller than that (a poster, a long web capture) is + * drawn in bands at the display's own resolution (pdf_view.zig) instead + * of being squeezed into max_dimension rows and blown up again, blurred. + */ + if (page_width * scale > (float)max_dimension) + scale = (float)max_dimension / page_width; + if (display_width > 0) + shown = fmaxf(shown, (float)display_width / page_width); + if (display_height > 0) + shown = fmaxf(shown, (float)display_height / page_height); + if (page_height * scale > (float)max_dimension) + scale = fminf(scale, fmaxf((float)max_dimension / page_height, shown)); + if (page_height * scale > (float)PARDES_PDF_MAX_RASTER_ROWS) + scale = (float)PARDES_PDF_MAX_RASTER_ROWS / page_height; /* * Keep this identical to fz_new_pixmap_from_page/display_list: transform @@ -526,6 +543,8 @@ pardes_pdf_measure_render( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, pardes_pdf_raster_layout *out) { fz_context *ctx; @@ -541,14 +560,16 @@ pardes_pdf_measure_render( memset(out, 0, sizeof(*out)); if (document == NULL || page_number < 0 || page_number >= document->page_count || dpi < 1 || - minimum_width < 0 || minimum_height < 0 || max_dimension < 1) + minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || + display_width < 0 || display_height < 0) return PARDES_PDF_ERROR; ctx = document->ctx; fz_try(ctx) { pardes_pdf_render_geometry(document, page_number, dpi, - minimum_width, minimum_height, max_dimension, &ctm, &bbox); + minimum_width, minimum_height, max_dimension, + display_width, display_height, &ctm, &bbox); width = fz_irect_width(bbox); height = fz_irect_height(bbox); if (width > INT_MAX / 4) @@ -629,6 +650,8 @@ pardes_pdf_render_into( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, unsigned char *samples, @@ -649,6 +672,7 @@ pardes_pdf_render_into( if (document == NULL || page_number < 0 || page_number >= document->page_count || dpi < 1 || minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || + display_width < 0 || display_height < 0 || (highlight_count != 0 && highlights == NULL) || highlight_count > PARDES_PDF_MAX_RESULT_QUADS || samples == NULL || width < 1 || height < 1 || stride < 1 || width > INT_MAX / 4 || @@ -664,7 +688,8 @@ pardes_pdf_render_into( fz_try(ctx) { pardes_pdf_render_geometry(document, page_number, dpi, - minimum_width, minimum_height, max_dimension, &ctm, &bbox); + minimum_width, minimum_height, max_dimension, + display_width, display_height, &ctm, &bbox); if (fz_irect_width(bbox) != width || fz_irect_height(bbox) != height) fz_throw(ctx, FZ_ERROR_ARGUMENT, @@ -676,9 +701,13 @@ pardes_pdf_render_into( * discards everything outside these rows. The rows are NOT always * bit-identical to the full page's: rows at the band's edges, and a * resampled image anywhere in it, can differ (measured on - * docs/registry.pdf and docs/design.pdf). Good enough for a band that - * is only shown while flinging; never use a band to patch rows into a - * whole-page raster -- `pardes_pdf_paint_highlights` exists for that. + * docs/registry.pdf and docs/design.pdf), and an edge crossing the + * clip is stepped from a different start along its whole length, + * a level or two off. Good enough for a band shown while flinging, + * and for the chunks of a page too tall to render whole (pdf_view.zig + * renders those past their edges and keeps the middle); never use a + * band to patch rows into a whole-page raster -- + * `pardes_pdf_paint_highlights` exists for that. */ bbox.y0 += band_y; bbox.y1 = bbox.y0 + band_height; @@ -708,10 +737,21 @@ pardes_pdf_render_into( if (document->cached_display_list != NULL || document->display_list_candidate_page_number == page_number) { + /* + * Only the nodes that reach the band: a band of a tall page + * skips the rest of the page instead of handing every node to + * the draw device to clip away. The scissor is in page space + * (the list runs untransformed under the device's CTM), a pixel + * wider than the band all round. Culling never changes a pixel: + * a node outside it draws nothing here. + */ + fz_rect scissor = fz_rect_from_irect(bbox); + scissor.x0 -= 1; scissor.y0 -= 1; scissor.x1 += 1; scissor.y1 += 1; + scissor = fz_transform_rect(scissor, fz_invert_matrix(ctm)); pardes_pdf_cache_display_list(document); device = fz_new_draw_device(ctx, ctm, pixmap); fz_run_display_list(ctx, document->cached_display_list, device, - fz_identity, fz_infinite_rect, NULL); + fz_identity, scissor, NULL); } else { device = fz_new_draw_device(ctx, ctm, pixmap); fz_run_page(ctx, document->cached_page, device, fz_identity, NULL); @@ -767,6 +807,8 @@ pardes_pdf_paint_highlights( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, unsigned char *samples, @@ -786,6 +828,7 @@ pardes_pdf_paint_highlights( if (document == NULL || page_number < 0 || page_number >= document->page_count || dpi < 1 || minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || + display_width < 0 || display_height < 0 || (highlight_count != 0 && highlights == NULL) || highlight_count > PARDES_PDF_MAX_RESULT_QUADS || samples == NULL || width < 1 || height < 1 || width > INT_MAX / 4 || stride != width * 4 || @@ -802,7 +845,8 @@ pardes_pdf_paint_highlights( fz_try(ctx) { pardes_pdf_render_geometry(document, page_number, dpi, - minimum_width, minimum_height, max_dimension, &ctm, &bbox); + minimum_width, minimum_height, max_dimension, + display_width, display_height, &ctm, &bbox); if (fz_irect_width(bbox) != width || fz_irect_height(bbox) != height) fz_throw(ctx, FZ_ERROR_ARGUMENT, "PDF raster layout changed between measure and paint"); @@ -835,6 +879,8 @@ pardes_pdf_highlight_rows( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, int *row_start, @@ -853,6 +899,7 @@ pardes_pdf_highlight_rows( if (document == NULL || page_number < 0 || page_number >= document->page_count || dpi < 1 || minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || + display_width < 0 || display_height < 0 || (highlight_count != 0 && highlights == NULL)) return PARDES_PDF_ERROR; @@ -860,7 +907,8 @@ pardes_pdf_highlight_rows( fz_try(ctx) { pardes_pdf_render_geometry(document, page_number, dpi, - minimum_width, minimum_height, max_dimension, &ctm, &bbox); + minimum_width, minimum_height, max_dimension, + display_width, display_height, &ctm, &bbox); for (i = 0; i < highlight_count; ++i) { fz_rect r; if (highlights[i].rgba[3] == 0 || diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h index e3514437..29d87bff 100644 --- a/src/pdf_bridge.h +++ b/src/pdf_bridge.h @@ -183,7 +183,10 @@ enum { PARDES_PDF_MAX_OUTLINE_ITEMS = 8192, PARDES_PDF_MAX_OUTLINE_DEPTH = 64, PARDES_PDF_MAX_OUTLINE_BYTES = 4 * 1024 * 1024, - PARDES_PDF_MAX_LINK_URI_BYTES = 64 * 1024 + PARDES_PDF_MAX_LINK_URI_BYTES = 64 * 1024, + + /* No page raster is taller than this, whatever it is shown at. */ + PARDES_PDF_MAX_RASTER_ROWS = 1 << 20 }; pardes_pdf_document *pardes_pdf_open(const char *path, int *page_count); @@ -199,7 +202,11 @@ int pardes_pdf_get_page_size( /* * Start at dpi, raise the uniform scale until both optional minimum pixel - * dimensions are met, then clamp the page's longest side to max_dimension. + * dimensions are met, then clamp the page's width to max_dimension, and its + * height too unless that would draw it smaller than the optional display size + * (the width or height it is shown at): a tall page is then as tall as it is + * shown, and its owner renders it in bands. Every function below takes the + * same request and computes the same raster from it. * Measuring may populate the page cache, but never advances adaptive display * list promotion; only a successful render_into does that. */ @@ -210,6 +217,8 @@ int pardes_pdf_measure_render( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, pardes_pdf_raster_layout *out ); @@ -233,6 +242,8 @@ int pardes_pdf_render_into( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, unsigned char *samples, @@ -257,6 +268,8 @@ int pardes_pdf_paint_highlights( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, unsigned char *samples, @@ -280,6 +293,8 @@ int pardes_pdf_highlight_rows( int minimum_width, int minimum_height, int max_dimension, + int display_width, + int display_height, const pardes_pdf_highlight *highlights, size_t highlight_count, int *row_start, diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 6c137202..09c06356 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -39,6 +39,20 @@ const raster_max = 256; const raster_spare = 4; pub const page_gap_px: u32 = 8; const band_grain: usize = 64; +/// The tallest raster one buffer carries: within every GPU's texture size +/// and Kitty's 10000-pixel image limit (kitty and ghostty both). A page +/// taller than this, or than `pdf.max_owned_raster_bytes` allows, is BANDED: +/// it is held as the rows around what is on screen and rendered in chunks. +const max_band_rows: usize = 8192; +/// A banded page is rendered in chunks of this many rows, each from its own +/// render of the chunk and `chunk_overlap` rows of the page beyond both of +/// its edges, which are thrown away. A row of the page therefore always comes +/// from the same render, however the reader scrolled to it, and never from +/// the edge of one: MuPDF's rows at the edge of a clip can differ from the +/// same rows rendered whole (see `pardes_pdf_render_into`), which, butted +/// against the next chunk, would be a seam. +const chunk_rows: usize = 512; +const chunk_overlap: usize = 32; pub const FitMode = if (enabled) enum { width, height } else void; pub const TintMode = if (enabled) pdf.TintMode else void; @@ -802,13 +816,18 @@ const VisibleRows = struct { pixel_offset_y: f32, }; -pub fn renderRequest(viewport: Viewport, policy: RasterPolicy) RenderRequest { +/// The raster a pane asks for. The page is shown `viewport.pixel_w` wide +/// (fit width) or `pixel_h` tall (fit height), and the raster is never +/// smaller than that, however tall the page: see `pdf.RenderRequest`. +pub fn renderRequest(viewport: Viewport, policy: RasterPolicy, fit: FitMode) RenderRequest { if (comptime !enabled) return; return .{ .dpi = policy.dpi, .minimum_width = if (policy.match_viewport) viewport.pixel_w else 0, .minimum_height = if (policy.match_viewport) viewport.pixel_h else 0, .max_dimension = policy.max_dimension, + .display_width = if (fit == .width) viewport.pixel_w else 0, + .display_height = if (fit == .height) viewport.pixel_h else 0, }; } @@ -1052,13 +1071,34 @@ fn flinging(state: *const State, viewport: Viewport) bool { return state.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h)); } +/// The most rows one buffer of this page holds. +fn bandCap(shape: pdf.Raster) usize { + return @min(max_band_rows, pdf.max_owned_raster_bytes / @max(shape.stride, 1)); +} + +/// A page too tall for one buffer: see `max_band_rows`. +pub fn isBanded(shape: pdf.Raster) bool { + if (comptime !enabled) return false; + return shape.height > bandCap(shape); +} + +/// What a banded page's buffer is asked to hold. `target` is what a render +/// covers: the rows on screen and a screenful either side, so scrolling finds +/// the next rows ready (none while flinging, which only wants this frame's). +/// `keep` is what the buffer must still cover to be kept: half a screenful +/// either side, so a render happens once per half screenful scrolled, not on +/// every frame. +const Reach = enum { target, keep }; + fn wantedBand( state: *const State, viewport: Viewport, page: usize, shape: pdf.Raster, is_flinging: bool, + reach: Reach, ) pdf.Raster.Band { + if (isBanded(shape)) return chunkBand(state, viewport, page, shape, is_flinging, reach); if (!is_flinging) return shape.wholePage(); const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse return shape.wholePage(); @@ -1068,6 +1108,117 @@ fn wantedBand( return shape.band(first, last * band_grain - first); } +fn chunkBand( + state: *const State, + viewport: Viewport, + page: usize, + shape: pdf.Raster, + is_flinging: bool, + reach: Reach, +) pdf.Raster.Band { + const rows = visibleRows(state, viewport, page, shape.width, shape.height) orelse + return shape.band(0, chunk_rows); + const seen: usize = rows.y1 - rows.y0; + const margin: usize = if (is_flinging) 0 else switch (reach) { + .target => seen, + .keep => seen / 2, + }; + const top = @as(usize, rows.y0) -| margin; + const bottom = @min(shape.height, @as(usize, rows.y1) + margin); + if (reach == .keep) return shape.band(top, bottom - top); + var first = top / chunk_rows * chunk_rows; + var last = @min(shape.height, (bottom + chunk_rows - 1) / chunk_rows * chunk_rows); + const cap = bandCap(shape) / chunk_rows * chunk_rows; + if (last - first > cap) { + // The margins give way: what is on screen always fits. + first = @as(usize, rows.y0) / chunk_rows * chunk_rows; + last = @min(shape.height, first + cap); + } + return shape.band(first, last - first); +} + +/// Fill `fresh` with the rows `want` of a banded page. Chunks the slot +/// already holds, under this request and tint and with these highlights +/// (`reuse`), are copied; the rest are rendered (`renderChunk`), then +/// highlighted and tinted as `reconcile` does a whole page. `slot.clean` +/// becomes the new rows' clean copy when `keep_clean`. +fn fillBanded( + state: *State, + gpa: std.mem.Allocator, + slot: *const Raster, + request: RenderRequest, + shape: pdf.Raster, + want: pdf.Raster.Band, + highlights: []const Highlight, + tint_key: TintKey, + fresh: []u8, + keep_clean: bool, + reuse: bool, + clean_out: *[]u8, +) bool { + const tz = tracy.zone(@src(), "pdf.fill_banded"); + defer tz.end(); + const stride = shape.stride; + const clean: []u8 = if (keep_clean) gpa.alloc(u8, fresh.len) catch return false else &.{}; + var filled = false; + defer if (!filled and clean.len > 0) gpa.free(clean); + const held = reuse and slot.rgba.len == slot.band_h * stride and + (!keep_clean or slot.clean.len == slot.rgba.len); + var scratch: []u8 = &.{}; + defer if (scratch.len > 0) gpa.free(scratch); + const end = want.y + want.height; + var y = want.y; + while (y < end) { + const rows = @min(chunk_rows - y % chunk_rows, end - y); + const at = (y - want.y) * stride; + const out = fresh[at..][0 .. rows * stride]; + if (held and slot.band_y <= y and y + rows <= slot.band_y + slot.band_h) { + const from = (y - slot.band_y) * stride; + @memcpy(out, slot.rgba[from..][0..out.len]); + if (keep_clean) @memcpy(clean[at..][0..out.len], slot.clean[from..][0..out.len]); + } else { + if (scratch.len == 0) + scratch = gpa.alloc(u8, (chunk_rows + 2 * chunk_overlap) * stride) catch return false; + const target = if (keep_clean) clean[at..][0..out.len] else out; + renderChunk(state, request, shape, slot.page, y, if (keep_clean) &.{} else highlights, target, scratch) catch + return false; + if (keep_clean) { + @memcpy(out, target); + state.document.paintHighlightsAt(slot.page, request, shape, shape.band(y, rows), highlights, out) catch + return false; + } + pdf.tintRgba(out, tint_key.mode, tint_key.colors) catch return false; + } + y += rows; + } + filled = true; + clean_out.* = clean; + return true; +} + +/// The `out.len / stride` rows of a banded page from row `y` (a chunk or +/// its end), cut from a render reaching `chunk_overlap` rows past both. +fn renderChunk( + state: *State, + request: RenderRequest, + shape: pdf.Raster, + page: usize, + y: usize, + highlights: []const Highlight, + out: []u8, + scratch: []u8, +) !void { + const tz = tracy.zone(@src(), "pdf.render_chunk"); + defer tz.end(); + const rows = out.len / shape.stride; + const top = y -| chunk_overlap; + const bottom = @min(shape.height, y + rows + chunk_overlap); + const band = shape.band(top, bottom - top); + const buf = scratch[0..band.len]; + try state.document.renderIntoAtWithPaper(page, request, shape, band, highlights, buf, pardes.platform == .macos); + @memcpy(out, buf[(y - top) * shape.stride ..][0..out.len]); +} + fn reconcile( state: *State, gpa: std.mem.Allocator, @@ -1113,10 +1264,13 @@ fn reconcile( slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or slot.rgba.len == 0 or slot.band_h == 0; const uncovered = !base_stale and uncovered: { - const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging); + const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging, .keep); break :uncovered slot.band_y > want.y or slot.band_y + slot.band_h < want.y + want.height; }; + // A banded page moving on keeps the rows it has, if they are exactly + // what a render would make now. + const reuse = !base_stale and slot.baked_valid and sameHighlights(slot.baked, page_highlights); if (!base_stale and !uncovered) { if (slot.tried and slot.decorated == decorated and !(highlights.live and !sameHighlights(slot.baked, page_highlights))) continue; @@ -1140,14 +1294,21 @@ fn reconcile( break :shape state.document.measureRenderAt(page, request) catch null; }; const shape = shape_or_null orelse continue; - const want = wantedBand(state, viewport, page, shape, is_flinging); + const want = wantedBand(state, viewport, page, shape, is_flinging, .target); const fresh = state.claimRgba(gpa, want.len) orelse continue; - const filled = filled: { + // A highlighted page keeps its clean rows for repaints. + const keep_clean = pardes.platform != .macos and (decorated or slot.clean.len > 0); + const filled = if (isBanded(shape)) banded: { + var clean: []u8 = &.{}; + if (!fillBanded(state, gpa, slot, request, shape, want, page_highlights, tint_key, fresh, keep_clean, reuse, &clean)) + break :banded false; + if (slot.clean.len > 0) gpa.free(slot.clean); + slot.clean = clean; + break :banded true; + } else filled: { { const tz_render = tracy.zone(@src(), "pdf.render_into"); defer tz_render.end(); - // A highlighted page keeps its clean rows for repaints. - const keep_clean = pardes.platform != .macos and (decorated or slot.clean.len > 0); state.document.renderIntoAtWithPaper( page, request, @@ -1330,7 +1491,7 @@ pub fn renderFrame( ensureLayout(state, viewport); state.resolveSearch(gpa); const highlights = buildHighlights(state, arena, highlight_input) catch Highlights{}; - const request = renderRequest(viewport, policy); + const request = renderRequest(viewport, policy, state.fit); var visible = visiblePages(state, viewport); reconcile(state, gpa, request, tint_key, highlights, visible, viewport); diff --git a/test/pdf.zig b/test/pdf.zig index fe084cc1..6864f247 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -730,6 +730,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { const request = panes.Pdf.renderRequest( panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport, pdf_raster_policy, + pane.pdf.?.fit, ); try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi); try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension); @@ -1119,6 +1120,171 @@ test "a fling's banded pages show the reader exactly what whole pages would" { } } +/// MuPDF clips the edges of a path to the pixmap, and an edge that crosses a +/// band's clip is stepped from a different start: along its whole length in +/// the band, its antialiased pixels can be a level or two off the whole +/// page's. Never more. +const band_tolerance: u8 = 4; + +fn expectNearRows(expected: []const u8, got: []const u8) !void { + try std.testing.expectEqual(expected.len, got.len); + var worst: u8 = 0; + for (expected, got) |a, b| worst = @max(worst, if (a > b) a - b else b - a); + if (worst > band_tolerance) { + std.debug.print("band rows are {d} levels off the whole page's\n", .{worst}); + return error.TestExpectedEqual; + } +} + +test "a tall page is drawn at the display's resolution, in bands that are the whole page's rows" { + if (!pdf_enabled or platform == .web) return; + + // The page is fifteen A4 heights tall. Squeezed whole into the raster + // budget it was a third as wide as the pane and blown up, blurred; it is + // drawn instead at the pane's own resolution, a band around the screen at + // a time, and every band row must be the row a whole-page render makes + // (to `band_tolerance`), and the same row however it was scrolled to. + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const bytes = try pdf_impl.makeTallTestPdf(gpa); + defer gpa.free(bytes); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "tall.pdf", .data = bytes }); + var dir_buf: [4096]u8 = undefined; + const dir_len = try tmp.dir.realPath(std.testing.io, &dir_buf); + const path = try std.fmt.allocPrint(gpa, "{s}/tall.pdf", .{dir_buf[0..dir_len]}); + defer gpa.free(path); + const p = try Pardes.init(gpa, .{ .file = path, .cols = 120, .rows = 40 }); + defer p.deinit(); + p.native_images = true; + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + + const viewport = panes.Pdf.paneViewport(p, pane) orelse return error.MissingPdfViewport; + const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy, pv.fit); + const shape = try pv.document.measureRenderAt(0, request); + if (pdf_raster_policy.match_viewport) { + // The rendered scale IS the screen's: one raster pixel per pixel of + // the pane's width, so nothing is scaled up. + try std.testing.expectEqual(@as(usize, viewport.pixel_w), shape.width); + } else { + // Kitty's policy is a dpi, kept low for the wire; the tall page gets + // it as every other page does, not a quarter of it. + try std.testing.expectEqual( + @as(usize, @intFromFloat(pdf_impl.tall_test_page.width)) * pdf_raster_policy.dpi / 72, + shape.width, + ); + } + try std.testing.expect(panes.Pdf.isBanded(shape)); + + // The reference: the whole page in one render, before highlights and + // tint, and tinted as the pane tints. + const plain = try gpa.alloc(u8, shape.len); + defer gpa.free(plain); + try pv.document.renderIntoAt(0, request, shape, shape.wholePage(), &.{}, plain); + const whole = try gpa.dupe(u8, plain); + defer gpa.free(whole); + try pdf_impl.tintRgba(whole, pv.tint, panes.Pdf.tintColors(p)); + + const max_scroll = @as(f64, @floatFromInt(pv.document_height - viewport.pixel_h)); + var middle: []u8 = &.{}; + defer gpa.free(middle); + var middle_y: usize = 0; + for ([_]f64{ 0, @round(max_scroll / 2), max_scroll }, 0..) |at, step| { + _ = panes.Pdf.scrollPane(p, pane, at - pv.document_scroll_y); + pv.scroll_travel = 0; // read, not flung + _ = frame.reset(.retain_capacity); + const surface = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), surface.nimages); + const place = surface.images[0].?; + const geometry = place.native.geometry orelse return error.MissingPdfGeometry; + const raster = panes.Pdf.rasterForPage(pv, 0) orelse return error.MissingPdfRaster; + // Bounded: a band, never the page, and within one buffer's budget. + try std.testing.expectEqual(shape.width, place.iw); + try std.testing.expect(raster.band_h < raster.ih); + try std.testing.expect(place.rgba.len <= pdf_impl.max_owned_raster_bytes); + // Prefetched: a screenful beyond what is shown, both ways, where the + // page goes on. + const seen_top = raster.band_y + geometry.src.y; + const seen_end = seen_top + geometry.src.h; + try std.testing.expect(raster.band_y <= seen_top -| geometry.src.h); + try std.testing.expect(raster.band_y + raster.band_h >= @min(raster.ih, seen_end + geometry.src.h)); + // ...and every row of the band is the whole page's row. + const stride = shape.stride; + try expectNearRows(whole[raster.band_y * stride ..][0 .. raster.band_h * stride], place.rgba); + if (step == 1) { + middle = try gpa.dupe(u8, place.rgba); + middle_y = raster.band_y; + } + } + + // Straight back to the middle from the foot: the rows rendered afresh + // are the very rows rendered on the way down, so no seam moves with the + // path a reader took. + _ = panes.Pdf.scrollPane(p, pane, @round(max_scroll / 2) - pv.document_scroll_y); + pv.scroll_travel = 0; + _ = frame.reset(.retain_capacity); + const again = (try p.render(frame.allocator())).images[0].?; + const again_y = panes.Pdf.rasterForPage(pv, 0).?.band_y; + const stride = shape.stride; + const common_top = @max(middle_y, again_y); + const common_end = @min(middle_y + middle.len / stride, again_y + again.rgba.len / stride); + try std.testing.expect(common_end > common_top); + try std.testing.expectEqualSlices( + u8, + middle[(common_top - middle_y) * stride .. (common_end - middle_y) * stride], + again.rgba[(common_top - again_y) * stride .. (common_end - again_y) * stride], + ); + _ = panes.Pdf.scrollPane(p, pane, max_scroll - pv.document_scroll_y); + pv.scroll_travel = 0; + _ = frame.reset(.retain_capacity); + const foot = try gpa.dupe(u8, (try p.render(frame.allocator())).images[0].?.rgba); + defer gpa.free(foot); + + // At the foot of the page, a search hit: its highlight lands on exactly + // the rows and pixels it would on the whole page, and a pointer over the + // word finds the word. + try pv.setSearchQuery(gpa, "bottommost"); + _ = frame.reset(.retain_capacity); + const lit = try p.render(frame.allocator()); + const place = lit.images[0].?; + const raster = panes.Pdf.rasterForPage(pv, 0) orelse return error.MissingPdfRaster; + const highlights = try panes.Pdf.buildHighlights(pv, frame.allocator(), panes.Pdf.highlightInput(p, 0, pane)); + const marks = highlights.forPage(0); + try std.testing.expect(marks.len > 0); + const rows = try pv.document.highlightRows(0, request, marks); + try std.testing.expect(rows.start >= raster.band_y and rows.end <= raster.band_y + raster.band_h); + // Exactly the highlight's rows changed, and nothing else... + try std.testing.expectEqual(foot.len, place.rgba.len); + const from = (rows.start - raster.band_y) * stride; + const to = (rows.end - raster.band_y) * stride; + try std.testing.expectEqualSlices(u8, foot[0..from], place.rgba[0..from]); + try std.testing.expectEqualSlices(u8, foot[to..], place.rgba[to..]); + try std.testing.expect(!std.mem.eql(u8, foot[from..to], place.rgba[from..to])); + // ...to what the whole page painted with them is. + const expected = try gpa.dupe(u8, plain[rows.start * stride .. rows.end * stride]); + defer gpa.free(expected); + try pv.document.paintHighlightsAt(0, request, shape, shape.band(rows.start, rows.end - rows.start), marks, expected); + try pdf_impl.tintRgba(expected, pv.tint, panes.Pdf.tintColors(p)); + try expectNearRows(expected, place.rgba[from..to]); + + const quad = pv.search_results.?.quads[0].quad; + const left = @min(quad.ul.x, quad.ll.x); + const right = @max(quad.ur.x, quad.lr.x); + const top = @min(quad.ul.y, quad.ur.y); + const bottom = @max(quad.ll.y, quad.lr.y); + const rect = p.rects[0]; + var inside: usize = 0; + for (rect.y..rect.y + rect.h) |row| for (rect.x..rect.x + rect.w) |col| { + const point = panes.Pdf.panePointAtPage(p, pane, 0, @intCast(col), @intCast(row), false) orelse continue; + if (point.x >= left and point.x <= right and point.y >= top and point.y <= bottom) inside += 1; + }; + try std.testing.expect(inside > 0); +} + /// The pixels a backend samples out of one placement: the source rectangle, /// row by row, at the texture's own stride. Test-only, and the one operation /// that makes "same picture" mean something when the textures differ in shape. @@ -1306,7 +1472,7 @@ test "PDF continuous strip renders every intersecting short page" { const viewport = panes.Pdf.ensurePaneLayout(p, pane).?; const visible = panes.Pdf.visiblePages(pv, viewport); try std.testing.expectEqual(pv.page_count, visible.len); - const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy); + const request = panes.Pdf.renderRequest(viewport, pdf_raster_policy, pv.fit); try std.testing.expect(pv.page_count <= pv.rasters.len); for (0..pv.page_count) |page| { const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4); -- cgit v1.3