diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-14 22:38:50 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-15 11:58:29 -0300 |
| commit | be2a9957708cbf0c478ca861c4a1f0f227bbfe10 (patch) | |
| tree | 1abf5cb65057048ebfbce7fc427289a437a693f4 /src/pdf.zig | |
| parent | f67fec978a9296c651ec06bd2f43686d34ff86ee (diff) | |
| download | pardes-be2a9957708cbf0c478ca861c4a1f0f227bbfe10.tar.gz pardes-be2a9957708cbf0c478ca861c4a1f0f227bbfe10.zip | |
pdf: continuous scroll bench harness and per-frame render path
Diffstat (limited to 'src/pdf.zig')
| -rw-r--r-- | src/pdf.zig | 146 |
1 files changed, 139 insertions, 7 deletions
diff --git a/src/pdf.zig b/src/pdf.zig index c1faafc5..b7e12fe8 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -894,18 +894,22 @@ pub const Document = struct { return layout; } - /// Rasterize into `rgba`, which MUST be exactly `raster.len` bytes from a - /// matching `measureRenderAt` with the same request. On any failure the - /// buffer's contents are unspecified and it still belongs to the caller. + /// Rasterize `band` of a page into `rgba`, which MUST be exactly + /// `band.len` bytes for a `band` taken from the same `measureRenderAt` + /// shape and request. On any failure the buffer's contents are unspecified + /// and it still belongs to the caller. pub fn renderIntoAt( document: *Document, page: usize, request: RenderRequest, raster: Raster, + band: Raster.Band, highlights: []const Highlight, rgba: []u8, ) !void { - if (rgba.len != raster.len) return error.BadPixmap; + if (rgba.len != band.len) return error.BadPixmap; + if (band.height == 0 or band.y + band.height > raster.height) + return error.BadPixmap; 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 = @@ -924,6 +928,8 @@ pub const Document = struct { @intCast(raster.width), @intCast(raster.height), @intCast(raster.stride), + @intCast(band.y), + @intCast(band.height), ) != c.PARDES_PDF_OK) return error.RenderFailed; } @@ -968,7 +974,14 @@ pub const Document = struct { const raster = try document.measureRenderAt(page, request); const rgba = try gpa.alloc(u8, raster.len); errdefer gpa.free(rgba); - try document.renderIntoAt(page, request, raster, highlights orelse &.{}, rgba); + try document.renderIntoAt( + page, + request, + raster, + raster.wholePage(), + highlights orelse &.{}, + rgba, + ); return .{ .rgba = rgba, .width = raster.width, .height = raster.height }; } @@ -1130,13 +1143,40 @@ fn validQuad(quad: Quad) bool { } /// The exact shape of the raster a render request produces. `len` is the -/// packed RGBA byte count `renderIntoAt` demands, so a caller can match a -/// retired buffer against it before deciding to allocate. +/// packed RGBA byte count a FULL-page `renderIntoAt` demands, so a caller can +/// match a retired buffer against it before deciding to allocate. pub const Raster = struct { width: usize, height: usize, stride: usize, len: usize, + + /// The rows a reader can actually see are usually a fraction of a page: + /// flinging through a manual, a page is on screen for one frame showing a + /// couple of hundred of its twelve hundred rows. `Band` is that fraction, + /// and rendering one is the same rendering clipped — see the bridge's + /// comment and the test that proves the rows are identical. + pub const Band = struct { + y: usize, + height: usize, + /// bytes of a `y`/`height` band at this raster's stride + len: usize, + }; + + /// The whole page as a band, which is what a reader at rest gets. + pub fn wholePage(raster: Raster) Band { + return .{ .y = 0, .height = raster.height, .len = raster.len }; + } + + /// Clamp a wanted row range to the page and return it as a band. A range + /// that misses the page entirely comes back as its nearest single row + /// rather than as an error: the caller is describing a viewport, and a + /// viewport that has just left a page is not a malformed request. + pub fn band(raster: Raster, first_row: usize, rows: usize) Band { + const y = @min(first_row, raster.height - 1); + const height = @min(@max(rows, 1), raster.height - y); + return .{ .y = y, .height = height, .len = raster.stride * height }; + } }; fn checkedRasterLayout(raw: c.pardes_pdf_raster_layout) !Raster { @@ -1604,6 +1644,8 @@ test "RGBA allocation and buffer-validation failures leave the document renderab raw_layout.width, raw_layout.height, raw_layout.stride, + 0, + raw_layout.height, )); for (scratch) |byte| try std.testing.expectEqual(@as(u8, 0xa5), byte); @@ -1618,6 +1660,81 @@ test "RGBA allocation and buffer-validation failures leave the document renderab try expectOpaque(repeated.rgba); } +test "a band's rows are the full page's rows, and highlights land in them" { + // THE claim the fast-scroll path rests on: rendering rows [y, y+h) of a + // page is the same rendering, clipped — not a different one. If MuPDF ever + // let the pixmap's bbox change antialiasing, hinting or subpixel placement, + // a fling would show pixels a reader could tell apart from a rested view, + // and this test is what refuses to let that happen quietly. + var document = try Document.open("docs/design.pdf"); + defer document.deinit(); + + const request: RenderRequest = .{ .dpi = 96, .max_dimension = 1200 }; + const raster = try document.measureRenderAt(0, request); + const whole = try std.testing.allocator.alloc(u8, raster.len); + defer std.testing.allocator.free(whole); + try document.renderIntoAt(0, request, raster, raster.wholePage(), &.{}, whole); + + // Odd offsets and an odd height on purpose: a band that starts on a row + // MuPDF would never choose itself is exactly where an off-by-one in the + // bbox arithmetic would show up. + const cases = [_]struct { y: usize, h: usize }{ + .{ .y = 0, .h = 1 }, + .{ .y = 1, .h = 37 }, + .{ .y = raster.height / 3, .h = 101 }, + .{ .y = raster.height / 2, .h = raster.height / 2 }, + .{ .y = raster.height - 1, .h = 1 }, + }; + for (cases) |case| { + const band = raster.band(case.y, case.h); + const rows = try std.testing.allocator.alloc(u8, band.len); + defer std.testing.allocator.free(rows); + try document.renderIntoAt(0, request, raster, band, &.{}, rows); + const from = band.y * raster.stride; + try std.testing.expectEqualSlices(u8, whole[from..][0..band.len], rows); + try expectOpaque(rows); + } + + // A baked highlight is drawn under the same CTM, so it has to land on the + // same rows through a band as it does through a whole page. + const highlights = [_]Highlight{.{ + .quad = .{ + .ul = .{ .x = 0.1, .y = 0.4 }, + .ur = .{ .x = 0.9, .y = 0.4 }, + .ll = .{ .x = 0.1, .y = 0.6 }, + .lr = .{ .x = 0.9, .y = 0.6 }, + }, + .kind = .search, + .rgba = .{ 0x30, 0x80, 0xf0, 0x80 }, + }}; + const marked_whole = try std.testing.allocator.alloc(u8, raster.len); + defer std.testing.allocator.free(marked_whole); + try document.renderIntoAt(0, request, raster, raster.wholePage(), &highlights, marked_whole); + const marked_band = raster.band(raster.height / 3, raster.height / 3); + const marked_rows = try std.testing.allocator.alloc(u8, marked_band.len); + defer std.testing.allocator.free(marked_rows); + try document.renderIntoAt(0, request, raster, marked_band, &highlights, marked_rows); + const marked_from = marked_band.y * raster.stride; + try std.testing.expectEqualSlices( + u8, + marked_whole[marked_from..][0..marked_band.len], + marked_rows, + ); + // ...and the highlight really is in the band that was compared, or the + // comparison above would be two identical plain renders agreeing. + try std.testing.expect(!std.mem.eql( + u8, + whole[marked_from..][0..marked_band.len], + marked_rows, + )); + + // A band request that misses the page is a viewport that has left it, not + // a malformed call: it clamps instead of failing. + const past_end = raster.band(raster.height + 500, 64); + try std.testing.expect(past_end.y < raster.height); + try std.testing.expectEqual(@as(usize, 1), past_end.height); +} + test "MuPDF search returns normalized oriented quads and word selection text" { var document = try Document.open("docs/design.pdf"); defer document.deinit(); @@ -1706,6 +1823,21 @@ test "offset crop and rotation keep normalized highlights pixel-aligned" { try std.testing.expectEqual(@as(usize, 300), plain.width); try std.testing.expectEqual(@as(usize, 320), plain.height); + // The SIZE comes off the page object rather than a loaded page (see + // pardes_pdf_get_page_size), and this is the fixture that would catch it + // being a different answer: an offset CropBox plus /Rotate 90, where a + // MediaBox reading, an untransformed cropbox or a missed rotation all give + // plausible-looking wrong numbers. The rendered raster above is what + // fz_bound_page produces, so the strip's layout has to agree with it. + const size = try document.pageSize(0); + try std.testing.expectEqual(@as(f32, 150), size.width); + try std.testing.expectEqual(@as(f32, 160), size.height); + try std.testing.expectApproxEqAbs( + @as(f32, @floatFromInt(plain.width)) / @as(f32, @floatFromInt(plain.height)), + size.width / size.height, + 0.001, + ); + const kitty = try document.renderAt(std.testing.allocator, 0, .{ .dpi = 96, .max_dimension = 1200, |
