diff options
Diffstat (limited to 'src/pdf_view.zig')
| -rw-r--r-- | src/pdf_view.zig | 175 |
1 files changed, 168 insertions, 7 deletions
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); |
