summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/pdf.zig128
-rw-r--r--src/pdf_bridge.c76
-rw-r--r--src/pdf_bridge.h19
-rw-r--r--src/pdf_view.zig175
-rw-r--r--test/pdf.zig168
5 files changed, 537 insertions, 29 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,
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);