summaryrefslogtreecommitdiff
path: root/src/pdf.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 23:54:30 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:14:45 -0300
commit65262f4a033d382426c7f58d57a9b38e45918558 (patch)
tree1648841704fe89f39e108fa4507864f12083f22d /src/pdf.zig
parent3975791ecb0a849a9b3beface1f9ef3ed9f0505f (diff)
downloadpardes-65262f4a033d382426c7f58d57a9b38e45918558.tar.gz
pardes-65262f4a033d382426c7f58d57a9b38e45918558.zip
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 <[email protected]>
Diffstat (limited to 'src/pdf.zig')
-rw-r--r--src/pdf.zig128
1 files changed, 123 insertions, 5 deletions
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,