diff options
| -rw-r--r-- | src/pdf.zig | 128 | ||||
| -rw-r--r-- | src/pdf_bridge.c | 76 | ||||
| -rw-r--r-- | src/pdf_bridge.h | 19 | ||||
| -rw-r--r-- | src/pdf_view.zig | 175 | ||||
| -rw-r--r-- | test/pdf.zig | 168 |
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); |
