diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CHANGELOG.md | 53 | ||||
| -rw-r--r-- | src/crash.zig | 2 | ||||
| -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-- | src/tagline.zig | 2 |
7 files changed, 410 insertions, 45 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 9406ceab..24b0c9fb 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,6 +1,29 @@ # Changelog -## 0.0.15 +## 0.16 + +- Releases are numbered 0.N from here on; this changelog's earlier 0.0.N + headings read 0.N. +- `pardes -` reads stdin into a `+Pager` pane, and a terminal's `PAGER` and + `GIT_PAGER` are `pardes -` unless your environment sets them: `git log` + and `man` open as panes, and a paging command through `pty/run` no longer + waits. Command panes page through `cat`. `Pager off` keeps your own pager. +- A command run from a pane gets `$winid`, that pane's serial, as acme's do. +- A PDF page taller than a raster is drawn at the size it is shown, in bands + around the screen, instead of being squeezed into 4096 rows and enlarged: + tall pages are sharp, and memory stays bounded. +- A diff line's look, whether clicked, written to `look` or written back + through `event`, goes through one expansion; a blank context line works. +- `log` stats 0 and 9ns opens files of length 0 as streams, so `cat` follows + the log through a mount. +- A second `--detach=NAME` while NAME runs says so. `/index` escapes control + bytes. `file:0:0` is refused. An event reader of a closed pane reads EOF. + `+Unsaved` lands in a directory that exists. `pardes NEW` refuses a + directory it may not write. +- Docs: a "Setting up your environment" chapter (9ns, the editor and pager, + yazi, shells, language servers, fonts, plan9port) after the guide. + +## 0.15 - The documentation is one Typst corpus in `docs/typ/`: a guide (a short walkthrough: modes, buttons, tags, the active column, looks, terminals, @@ -15,7 +38,7 @@ filled, keys as one pill per press (`Ctrl+w`, `g›g`), and its pairs as tables. -## 0.0.14 +## 0.14 - A pane's `tagexec` reaches an event reader holding the pane as an `x` record, as a click in its tag does. @@ -37,7 +60,7 @@ list's), `event` takes one reader, and bash's `printf` writes a mounted ctl one line at a time. -## 0.0.13 +## 0.13 - A one-page A4 cheatsheet, built from Typst sources in `docs/typ/` (`zig build cheatsheet`); a test fails when it names a word, key or tag that no longer exists. - A look on a diff line is the look of the `path:line` it names, exactly as @@ -46,7 +69,7 @@ hand-selected path would, and the diff's own path finding and its messages are gone. -## 0.0.12 +## 0.12 - A selection deep in a very long line no longer slows every frame: a 5 MB line with the selection at its end redraws in milliseconds, not seconds. @@ -71,7 +94,7 @@ program as its buttons 2 and 3. B1 and the wheel stay the program's, and Shift keeps them pardes's. -## 0.0.11 +## 0.11 - A look on a diff names a file as the repository does: git's `a/`/`b/` (or mnemonic `c/`, `i/`, `w/`) is dropped only where both paths of the @@ -79,7 +102,7 @@ plain `diff -u`'s names are as written, and a missing file is said by that name (`Look: src/x.zig: no such file here`). -## 0.0.10 +## 0.10 - A 9P write never moves dot's end: scrolling written text into view moves only the view, and dot shifts as acme's textinsert shifts it, so a `sel` @@ -96,7 +119,7 @@ - A terminal's word looks share one `+Search` per directory. `Save path` logs its `save`. Restore logs the absolute path it read. -## 0.0.9 +## 0.9 - A terminal's program that tracks the mouse gets its clicks, drags and motion as well as its wheel, in the format it asked for; hold Shift for @@ -119,7 +142,7 @@ bytes) keeps its cut last line for the next write, so `seq … > exec` runs every line once. -## 0.0.8 +## 0.8 - `~` and `~/` mean the home directory wherever a path is taken: look (`~/x:12`, `~/x:/re/`), `name`, `Save`, `ThemeFile`, DumpDir, Restore. @@ -149,7 +172,7 @@ is refused. `Kill X` with nothing running is a message, not a failure. An event write-back needs no trailing newline. -## 0.0.7 +## 0.7 - `lapis` draws its cells unblended and each plaque pixel once: about a third less GPU time a frame at 4K, pixel for pixel the same. `lapis_plain` @@ -175,7 +198,7 @@ - `Lspwhy` explains the last language-server query, whatever its kind. - Bulk body writes over 9P are about 15x faster per 60 KB write. -## 0.0.6 +## 0.6 - A middle or right sweep in a column tag or the workspace tag runs or looks up the swept text on release, highlighted while it is held, as in a pane's @@ -201,7 +224,7 @@ - The GUI always embeds its shader sources; the prebuilt GLSL copies are gone. `lspprobe` is `zig build lspbench -- probe`. -## 0.0.5 +## 0.5 - Over a slow link the terminal build keeps at most one frame in flight: it asks the terminal for a status report after each frame and draws the @@ -225,7 +248,7 @@ - The docs and the 9P skill are shorter, with one home per fact. `zig build monkey-gui` runs the GUI monkey. -## 0.0.4 +## 0.4 - Drive the editor over 9P the way acme is driven. Panes take sam addresses (`/re/`, `#n`, `$-1`, `0/re/`), `L:C` and inclusive `L:C-L2:C2` @@ -343,7 +366,7 @@ with `-Dembed-sources=true`. The tree code moved from `src/fs.zig` to `src/ninep/`. -## 0.0.3 +## 0.3 - Add pinned tree-sitter declaration context, with optional compact tagline styling and separators between discontinuous source lines. @@ -366,7 +389,7 @@ - Detached sessions now use protocol version 6. Update the server and attached clients together. -## 0.0.2 +## 0.2 - A panic is written down somewhere it survives. Every crash this program has ever had went to stderr and nowhere else, and stderr is the one place it @@ -532,7 +555,7 @@ curated three the floor refuses 12, 16 and 7 of 256 keys, where a WCAG body-text 4.5 would refuse 61, 154 and 91 and flatten the palette. -## 0.0.1 +## 0.1 - Pane taglines sit where they do in the SDL window. The macOS shell had its own copy of the band geometry and it only ever centred the shorter band in its diff --git a/src/crash.zig b/src/crash.zig index 99822aad..34dc1b3d 100644 --- a/src/crash.zig +++ b/src/crash.zig @@ -47,7 +47,7 @@ pub fn setDir(path: []const u8) void { /// Built at comptime out of the two halves main.zig's `--version` prints, and /// for the same reason: there is nothing here to format at runtime. A build -/// from a tarball says `pardes 0.0.1` rather than inventing a revision. +/// from a tarball says `pardes 0.1` rather than inventing a revision. const build_id = if (pardes.commit) |c| "pardes " ++ pardes.version ++ " (" ++ c ++ ")" else 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/src/tagline.zig b/src/tagline.zig index dc7d10d4..9bd49eef 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -389,7 +389,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void { t.normal.clear(); } -/// A dump's tag tail, what was typed after the name: a dump from 0.0.4 on +/// A dump's tag tail, what was typed after the name: a dump from 0.4 on /// writes it, and an older one's saved tag is not read. pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { const tail = src.tag_tail orelse return; |
