diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/pardes.zig | 373 | ||||
| -rw-r--r-- | src/pdf.zig | 146 | ||||
| -rw-r--r-- | src/pdf_bridge.c | 66 | ||||
| -rw-r--r-- | src/pdf_bridge.h | 12 |
4 files changed, 537 insertions, 60 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index af12cb96..3c236f34 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -41,6 +41,11 @@ pub const allocators = @import("allocators.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); +/// Tracy's frame boundary, re-exported so a host that is not a shell — the +/// fling benchmark — can delimit the same frames the tty loop delimits without +/// reaching around the core for src/tracy.zig and its build options. A no-op +/// unless -Dtracy names a Tracy checkout. +pub const frameMark = tracy.frameMark; pub const Platform = enum { tty, gui, web, macos }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); @@ -257,7 +262,14 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { // At a page boundary both page rasters coexist, the gap remains uncovered, // and a row step crosses it without snapping either page to an edge. + // + // This lands by ASSIGNMENT, which is a jump and not a fling — and the + // travel counter has to say so: the keys above scrolled two screenfuls + // without any frame in between to spend that distance, which no shell does + // (every wheel batch is followed by a draw). Left unspent it would make the + // frame below the first frame of a fling and hand it bands. const viewport = p.pdfViewport(pane).?; + pane.pdf.?.scroll_travel = 0; pane.pdf.?.document_scroll_y = @floatFromInt( pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2, ); @@ -430,6 +442,107 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint); } +test "a fling's banded pages show the reader exactly what whole pages would" { + if (!pdf_enabled or platform == .web) return; + + // The contract fast scrolling is allowed to change: HOW pixels are carried + // (a strip of a page instead of the page) but never WHICH pixels arrive. So + // the same frame is drawn twice — once at fling speed, once at reading + // speed — and every pixel inside every source rectangle must match, along + // with where on screen it goes. + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .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 = p.pdfViewport(pane) orelse return error.MissingPdfViewport; + // Land mid-page-boundary so the frame carries TWO pages, each showing a + // fraction of itself — the shape a fling actually produces. + const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3)); + const Shot = struct { + page: u32, + dst: image.PixelRect, + pixels: []u8, + }; + var shots: [8]Shot = undefined; + var shots_len: usize = 0; + defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels); + + // A fling: one frame's worth of wheel travel carrying the viewport more + // than a screenful, delivered through the real scroll path so the distance + // is counted the way a wheel batch counts it. + pv.document_scroll_y = 0; + pv.scroll_travel = 0; + try std.testing.expect(p.scrollPdfDocument(pane, landing)); + _ = frame.reset(.retain_capacity); + const flung = try p.render(frame.allocator()); + try std.testing.expect(flung.nimages >= 2); + var banded = false; + for (flung.images[0..flung.nimages]) |maybe| { + const place = maybe orelse continue; + const geometry = place.native.geometry orelse return error.MissingPdfGeometry; + const raster = Pardes.pdfRasterForPage(pv, place.native.page) orelse + return error.MissingPdfRaster; + if (raster.band_h < raster.ih) banded = true; + try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len); + shots[shots_len] = .{ + .page = place.native.page, + .dst = geometry.dst, + .pixels = try copySourceRect(gpa, place, geometry.src), + }; + shots_len += 1; + } + // ...and it really did band, or the comparison below is two identical + // whole-page renders agreeing with each other. + try std.testing.expect(banded); + // ...at reading speed: no travel at all since the frame above, so every + // page is rasterized whole again, and that is the picture the banded frame + // has to have matched. + _ = frame.reset(.retain_capacity); + const rested = try p.render(frame.allocator()); + try std.testing.expectEqual(shots_len, rested.nimages); + for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| { + const place = maybe orelse return error.MissingPdfPlacement; + const geometry = place.native.geometry orelse return error.MissingPdfGeometry; + const raster = Pardes.pdfRasterForPage(pv, place.native.page) orelse + return error.MissingPdfRaster; + try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest + try std.testing.expectEqual(shot.page, place.native.page); + try std.testing.expectEqual(shot.dst.x, geometry.dst.x); + try std.testing.expectEqual(shot.dst.y, geometry.dst.y); + try std.testing.expectEqual(shot.dst.w, geometry.dst.w); + try std.testing.expectEqual(shot.dst.h, geometry.dst.h); + const whole = try copySourceRect(gpa, place, geometry.src); + defer gpa.free(whole); + try std.testing.expectEqualSlices(u8, shot.pixels, whole); + } +} + +/// 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. +fn copySourceRect( + gpa: std.mem.Allocator, + place: ImagePlace, + src: image.PixelRect, +) ![]u8 { + const stride = place.iw * 4; + const row_len = @as(usize, src.w) * 4; + const out = try gpa.alloc(u8, row_len * src.h); + errdefer gpa.free(out); + var row: usize = 0; + while (row < src.h) : (row += 1) { + const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4; + @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]); + } + return out; +} + test "PDF normal adapter consumes unsupported actions and navigates page fallback" { if (!pdf_enabled or platform == .web) return; @@ -3708,8 +3821,18 @@ const PdfRaster = if (pdf_enabled) struct { valid: bool = false, page: usize = 0, rgba: []u8 = &.{}, + /// The FULL page raster's dimensions, whatever part of it `rgba` holds. + /// Everything that maps between the document and the screen — placement, + /// the mouse, panning, a search reveal — is arithmetic about the whole + /// page, and none of it wants to know that a fling only rasterized a + /// strip. iw: usize = 0, ih: usize = 0, + /// ...and the strip that is actually here: rows [band_y, band_y + band_h) + /// of that full raster, so `rgba.len == iw * band_h * 4`. A page at rest + /// is the whole page as one band. See `pdfWantedBand`. + band_y: usize = 0, + band_h: usize = 0, request: pdf_impl.RenderRequest = .{}, request_valid: bool = false, tried: bool = false, @@ -3775,6 +3898,15 @@ const PdfView = if (pdf_enabled) struct { layout_anchor_page: usize = 0, layout_anchor_fraction: f64 = 0, next_raster_revision: u32 = 0, + /// Display pixels this view has been SCROLLED by since the last frame it + /// drew — the fling signal, and the reason the core needs no clock: a wheel + /// batch that moves a screenful or more inside one frame lands on pages + /// nobody will see again, and only their visible rows are worth + /// rasterizing (see `pdfWantedBand`). Deliberately counts SCROLLING only: + /// a page jump, a search reveal or a resize moves the viewport just as far + /// but leaves the reader looking at one place, where whole pages are what + /// makes the frames after it free. + scroll_travel: f64 = 0, fit: PdfFitMode = .width, tint: PdfTintMode = .filtered, /// Normalized 0..65535 pan positions. NativeGeometry maps the extrema @@ -12005,6 +12137,8 @@ pub const Pardes = struct { /// are touched, and ordinary scrolling never enters this path. fn ensurePdfLayout(p: *Pardes, pane: *Pane, pv: *PdfView) ?PdfViewport { if (comptime !pdf_enabled) return null; + const tz = tracy.zone(@src(), "pdf.ensure_layout"); + defer tz.end(); const viewport = p.pdfViewport(pane) orelse return null; // Pane-local geometry changes (divider drags, splits, closes) do not // pass through Event.resize. Capture against the still-valid OLD @@ -12150,17 +12284,34 @@ pub const Pardes = struct { pv.render_revision = raster.revision; } - fn pdfPlacedGeometry( + /// Which rows of a page's FULL raster the viewport shows, and where they + /// land on screen. Split out of `pdfPlacedGeometry` because the reconcile + /// needs the same answer BEFORE a raster exists — it is what decides how + /// much of the page is worth rasterizing at all (see `pdfWantedBand`), and + /// two copies of this arithmetic would be two chances to place a band one + /// row away from the rows it rendered. + const PdfVisibleRows = struct { + base: image.NativeGeometry, + /// rows [y0, y1) of the full page raster + y0: u32, + y1: u32, + dst_y: u32, + dst_h: u32, + pixel_offset_y: f32, + }; + + fn pdfVisibleRows( pv: *const PdfView, - raster: *const PdfRaster, viewport: PdfViewport, page: usize, - ) ?PdfPlacedGeometry { + iw: usize, + ih: usize, + ) ?PdfVisibleRows { if (comptime !pdf_enabled) return null; const page_h = pv.page_heights[page]; const base = image.nativeGeometry( - raster.iw, - raster.ih, + iw, + ih, viewport.pixel_w, page_h, switch (pv.fit) { @@ -12199,21 +12350,48 @@ pub const Pardes = struct { ))); if (src_y1 <= src_y0) return null; return .{ + .base = base, + .y0 = src_y0, + .y1 = src_y1, + .dst_y = @intCast(visible_y), + .dst_h = @intCast(visible_bottom - visible_y), + .pixel_offset_y = -fractional, + }; + } + + /// The same rows, expressed against the raster that actually exists: a + /// band-rendered page holds only rows [band_y, band_y+band_h), so the + /// source rectangle a backend samples is shifted by the band's origin. + /// A raster that does not cover the visible rows places NOTHING rather + /// than sampling rows it never rendered — the reconcile is what keeps that + /// from happening, and this is the assertion that it did. + fn pdfPlacedGeometry( + pv: *const PdfView, + raster: *const PdfRaster, + viewport: PdfViewport, + page: usize, + ) ?PdfPlacedGeometry { + if (comptime !pdf_enabled) return null; + const rows = pdfVisibleRows(pv, viewport, page, raster.iw, raster.ih) orelse return null; + const band_y: u32 = @intCast(raster.band_y); + const band_end: u32 = @intCast(raster.band_y + raster.band_h); + if (rows.y0 < band_y or rows.y1 > band_end) return null; + return .{ .geometry = .{ .src = .{ - .x = base.src.x, - .y = src_y0, - .w = base.src.w, - .h = src_y1 - src_y0, + .x = rows.base.src.x, + .y = rows.y0 - band_y, + .w = rows.base.src.w, + .h = rows.y1 - rows.y0, }, .dst = .{ - .x = base.dst.x, - .y = @intCast(visible_y), - .w = base.dst.w, - .h = @intCast(visible_bottom - visible_y), + .x = rows.base.dst.x, + .y = rows.dst_y, + .w = rows.base.dst.w, + .h = rows.dst_h, }, }, - .pixel_offset_y = -fractional, + .pixel_offset_y = rows.pixel_offset_y, }; } @@ -12483,12 +12661,19 @@ pub const Pardes = struct { /// semantic search/selection state, but never evict unchanged page pixels. fn scrollPdfDocument(p: *Pardes, pane: *Pane, delta_pixels: f64) bool { if (comptime !pdf_enabled) return false; + const tz = tracy.zone(@src(), "pdf.scroll_notch"); + defer tz.end(); if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return false; const pv = &(pane.pdf orelse return false); const viewport = p.ensurePdfLayout(pane, pv) orelse return false; const max_scroll = @as(f64, @floatFromInt(pv.document_height -| viewport.pixel_h)); const next = std.math.clamp(pv.document_scroll_y + delta_pixels, 0, max_scroll); if (next == pv.document_scroll_y) return false; + // Travel this frame, for the fling test in drawPdf. Accumulated rather + // than compared against the last position because a wheel batch arrives + // as a hundred separate notches and it is their SUM inside one frame + // that says how fast the reader is going. + pv.scroll_travel += @abs(next - pv.document_scroll_y); pv.document_scroll_y = next; const active_page = pdfPageAtOffset(pv, next); if (active_page != pv.page) p.activatePdfPage(pane, active_page, false); @@ -14496,14 +14681,74 @@ pub const Pardes = struct { return page >= visible.first and page - visible.first < visible.len; } + /// How far a frame must travel before its pages are worth only their + /// visible rows. One screenful: at that speed a page is on screen for this + /// frame and gone by the next, so the rows outside the viewport are pixels + /// nobody will ever see. Below it, nothing changes — a reader nudging + /// through a document keeps getting whole pages, which is what makes the + /// following frames free. + fn pdfFlinging(pv: *const PdfView, viewport: PdfViewport) bool { + if (comptime !pdf_enabled) return false; + return pv.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h)); + } + + /// Band heights are rounded out to this many rows, so that consecutive + /// pages of a fling ask for buffers of the SAME length and the retired- + /// buffer relay can hand one page's bytes to the next instead of going back + /// to the allocator every frame. Rounding out also makes the covered rows + /// outlast a few frames of ordinary scrolling. + /// + /// 64 by measurement, not by taste: on the 5363-page Intel manual, grain 64 + /// runs a fling at a median 2.38-2.43 ms per frame against 2.49-2.52 at 128 + /// and 2.93-3.03 at 256 (rounding out pixels nobody sees), while 32 matches + /// 64's median with a visibly worse tail — the buffer lengths stop repeating + /// and the relay starts missing. 48 and 96 are indistinguishable from 64. + const pdf_band_grain: usize = 64; + + /// The rows of `page` worth rasterizing this frame: the whole page at + /// reading speed, and the visible strip (grain-rounded) during a fling. + fn pdfWantedBand( + pv: *const PdfView, + viewport: PdfViewport, + page: usize, + shape: pdf_impl.Raster, + flinging: bool, + ) pdf_impl.Raster.Band { + if (!flinging) return shape.wholePage(); + const rows = pdfVisibleRows(pv, viewport, page, shape.width, shape.height) orelse + return shape.wholePage(); + const grain = @max(@as(usize, 1), pdf_band_grain); + const first = (@as(usize, rows.y0) / grain) * grain; + const last = std.math.divCeil(usize, @as(usize, rows.y1), grain) catch + return shape.wholePage(); + return shape.band(first, last * grain - first); + } + + /// The raster shape a retained slot was rendered against. Identical to what + /// `measureRenderAt` would answer for the same request — which is exactly + /// why the reconcile may use it instead of asking, as long as the request + /// has not changed (a changed request is `stale` and re-measures). + fn pdfSlotShape(slot: *const PdfRaster) pdf_impl.Raster { + const stride = slot.iw * 4; + return .{ + .width = slot.iw, + .height = slot.ih, + .stride = stride, + .len = stride * slot.ih, + }; + } + fn reconcilePdfRasters( p: *Pardes, pv: *PdfView, request: pdf_impl.RenderRequest, highlights: []const PdfHighlight, visible: PdfVisiblePages, + viewport: PdfViewport, ) void { if (comptime !pdf_enabled) return; + const tz = tracy.zone(@src(), "pdf.reconcile"); + defer tz.end(); const tint_key = p.pdfTintKey(pv); // Remove first, and remove EVERYTHING outside the visible set: owned @@ -14526,6 +14771,7 @@ pub const Pardes = struct { if (index != pv.rasters_len) pv.rasters[index] = pv.rasters[pv.rasters_len]; } + const flinging = pdfFlinging(pv, viewport); var page = visible.first; const end = visible.first + visible.len; while (page < end) : (page += 1) { @@ -14538,45 +14784,75 @@ pub const Pardes = struct { } const slot = raster.?; const decorated = page == pv.page and highlights.len > 0; - if (!slot.tried or !slot.request_valid or !slot.request.eql(request) or - slot.decorated != decorated or slot.tint_key == null or - !slot.tint_key.?.eql(tint_key)) - { + // Everything that makes the pixels wrong rather than merely + // insufficient. Checked first and cheaply: a request or tint change + // means the raster's very dimensions may differ, so nothing below + // may reason about the band it holds. + const stale = !slot.tried or !slot.request_valid or + !slot.request.eql(request) or slot.decorated != decorated or + slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or + slot.rgba.len == 0 or slot.band_h == 0; + // ...and then coverage, against the raster ALREADY here — its + // full-page dimensions are this request's, or `stale` would be + // true. Asking MuPDF to measure again per frame per page is what + // this avoids: at reading speed a covered page must cost nothing. + const uncovered = !stale and uncovered: { + const want = pdfWantedBand(pv, viewport, page, pdfSlotShape(slot), flinging); + break :uncovered slot.band_y > want.y or + slot.band_y + slot.band_h < want.y + want.height; + }; + if (stale or uncovered) { slot.tried = true; slot.request = request; slot.request_valid = true; - // Measure first so a retired buffer of exactly this size can - // be reclaimed. Render into that separate buffer and swap - // only on complete success; the old pixels stay presentable - // until then and then become the next page's buffer. - if (pv.document.measureRenderAt(page, request) catch null) |shape| { - if (p.claimPdfRgba(pv, shape.len)) |fresh| { - const filled = filled: { + // Measure first so the band is a range of rows of THIS + // request's raster and a retired buffer can be reclaimed + // against a length that is already known. + const shape_or_null = shape: { + const tz_measure = tracy.zone(@src(), "pdf.measure"); + defer tz_measure.end(); + break :shape pv.document.measureRenderAt(page, request) catch null; + }; + const shape = shape_or_null orelse continue; + const want = pdfWantedBand(pv, viewport, page, shape, flinging); + // Render into a separate buffer and swap only on complete + // success; the old pixels stay presentable until then and then + // become the next page's buffer. + if (p.claimPdfRgba(pv, want.len)) |fresh| { + const filled = filled: { + { + const tz_render = tracy.zone(@src(), "pdf.render_into"); + defer tz_render.end(); pv.document.renderIntoAt( page, request, shape, + want, if (decorated) highlights else &.{}, fresh, ) catch break :filled false; - pdf_impl.tintRgba(fresh, tint_key.mode, tint_key.colors) catch - break :filled false; - break :filled true; - }; - if (!filled) { - p.retirePdfRgba(pv, fresh); - continue; } - p.retirePdfRgba(pv, slot.rgba); - slot.rgba = fresh; - slot.iw = shape.width; - slot.ih = shape.height; - slot.decorated = decorated; - slot.tint_key = tint_key; - pv.next_raster_revision +%= 1; - if (pv.next_raster_revision == 0) pv.next_raster_revision = 1; - slot.revision = pv.next_raster_revision; + const tz_tint = tracy.zone(@src(), "pdf.tint"); + defer tz_tint.end(); + pdf_impl.tintRgba(fresh, tint_key.mode, tint_key.colors) catch + break :filled false; + break :filled true; + }; + if (!filled) { + p.retirePdfRgba(pv, fresh); + continue; } + p.retirePdfRgba(pv, slot.rgba); + slot.rgba = fresh; + slot.iw = shape.width; + slot.ih = shape.height; + slot.band_y = want.y; + slot.band_h = want.height; + slot.decorated = decorated; + slot.tint_key = tint_key; + pv.next_raster_revision +%= 1; + if (pv.next_raster_revision == 0) pv.next_raster_revision = 1; + slot.revision = pv.next_raster_revision; } } if (page == pv.page and slot.rgba.len > 0) syncPdfRasterAliases(pv, slot); @@ -14589,6 +14865,8 @@ pub const Pardes = struct { /// scrolling a retained page changes geometry only. fn drawPdf(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) bool { if (comptime !pdf_enabled) return false; + const tz = tracy.zone(@src(), "pdf.draw"); + defer tz.end(); const pv = &(pane.pdf orelse return false); if (r.h <= BOX_H) return false; const viewport = p.ensurePdfLayout(pane, pv) orelse return false; @@ -14596,14 +14874,14 @@ pub const Pardes = struct { p.resolvePdfSearch(pv); const highlights = p.pdfHighlights(pv) catch &.{}; var visible = pdfVisiblePages(pv, viewport); - p.reconcilePdfRasters(pv, request, highlights, visible); + p.reconcilePdfRasters(pv, request, highlights, visible, viewport); p.rearmPdfRevealForViewport(pane, pv); p.revealPdfSearch(pane, pv); // Reveal can move within the current page; placements below use its // final document coordinate, but unchanged raster pixels are reused. visible = pdfVisiblePages(pv, viewport); - p.reconcilePdfRasters(pv, request, highlights, visible); + p.reconcilePdfRasters(pv, request, highlights, visible, viewport); const id: u8 = @intCast(for (p.panes, 0..) |slot, i| { if (slot == pane) break i; } else 0); @@ -14634,10 +14912,17 @@ pub const Pardes = struct { .h = r.h - BOX_H, .rgba = slot.rgba, .iw = slot.iw, - .ih = slot.ih, + // the TEXTURE is the band that is here, not the page it is cut + // from: rgba.len == iw * band_h * 4, and the placement's source + // rectangle is already band-local + .ih = slot.band_h, })) break; placed_any = true; } + // This frame has now shown whatever its travel earned; the next frame's + // speed is its own. Reset only when something was actually drawn: a pane + // too short to place a page has not spent the reader's scrolling. + if (placed_any) pv.scroll_travel = 0; return placed_any; } diff --git a/src/pdf.zig b/src/pdf.zig index c1faafc5..b7e12fe8 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -894,18 +894,22 @@ pub const Document = struct { return layout; } - /// Rasterize into `rgba`, which MUST be exactly `raster.len` bytes from a - /// matching `measureRenderAt` with the same request. On any failure the - /// buffer's contents are unspecified and it still belongs to the caller. + /// Rasterize `band` of a page into `rgba`, which MUST be exactly + /// `band.len` bytes for a `band` taken from the same `measureRenderAt` + /// shape and request. On any failure the buffer's contents are unspecified + /// and it still belongs to the caller. pub fn renderIntoAt( document: *Document, page: usize, request: RenderRequest, raster: Raster, + band: Raster.Band, highlights: []const Highlight, rgba: []u8, ) !void { - if (rgba.len != raster.len) return error.BadPixmap; + if (rgba.len != band.len) return error.BadPixmap; + if (band.height == 0 or band.y + band.height > raster.height) + return error.BadPixmap; 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 = @@ -924,6 +928,8 @@ pub const Document = struct { @intCast(raster.width), @intCast(raster.height), @intCast(raster.stride), + @intCast(band.y), + @intCast(band.height), ) != c.PARDES_PDF_OK) return error.RenderFailed; } @@ -968,7 +974,14 @@ pub const Document = struct { const raster = try document.measureRenderAt(page, request); const rgba = try gpa.alloc(u8, raster.len); errdefer gpa.free(rgba); - try document.renderIntoAt(page, request, raster, highlights orelse &.{}, rgba); + try document.renderIntoAt( + page, + request, + raster, + raster.wholePage(), + highlights orelse &.{}, + rgba, + ); return .{ .rgba = rgba, .width = raster.width, .height = raster.height }; } @@ -1130,13 +1143,40 @@ fn validQuad(quad: Quad) bool { } /// The exact shape of the raster a render request produces. `len` is the -/// packed RGBA byte count `renderIntoAt` demands, so a caller can match a -/// retired buffer against it before deciding to allocate. +/// packed RGBA byte count a FULL-page `renderIntoAt` demands, so a caller can +/// match a retired buffer against it before deciding to allocate. pub const Raster = struct { width: usize, height: usize, stride: usize, len: usize, + + /// The rows a reader can actually see are usually a fraction of a page: + /// flinging through a manual, a page is on screen for one frame showing a + /// couple of hundred of its twelve hundred rows. `Band` is that fraction, + /// and rendering one is the same rendering clipped — see the bridge's + /// comment and the test that proves the rows are identical. + pub const Band = struct { + y: usize, + height: usize, + /// bytes of a `y`/`height` band at this raster's stride + len: usize, + }; + + /// The whole page as a band, which is what a reader at rest gets. + pub fn wholePage(raster: Raster) Band { + return .{ .y = 0, .height = raster.height, .len = raster.len }; + } + + /// Clamp a wanted row range to the page and return it as a band. A range + /// that misses the page entirely comes back as its nearest single row + /// rather than as an error: the caller is describing a viewport, and a + /// viewport that has just left a page is not a malformed request. + pub fn band(raster: Raster, first_row: usize, rows: usize) Band { + const y = @min(first_row, raster.height - 1); + const height = @min(@max(rows, 1), raster.height - y); + return .{ .y = y, .height = height, .len = raster.stride * height }; + } }; fn checkedRasterLayout(raw: c.pardes_pdf_raster_layout) !Raster { @@ -1604,6 +1644,8 @@ test "RGBA allocation and buffer-validation failures leave the document renderab raw_layout.width, raw_layout.height, raw_layout.stride, + 0, + raw_layout.height, )); for (scratch) |byte| try std.testing.expectEqual(@as(u8, 0xa5), byte); @@ -1618,6 +1660,81 @@ test "RGBA allocation and buffer-validation failures leave the document renderab try expectOpaque(repeated.rgba); } +test "a band's rows are the full page's rows, and highlights land in them" { + // THE claim the fast-scroll path rests on: rendering rows [y, y+h) of a + // page is the same rendering, clipped — not a different one. If MuPDF ever + // let the pixmap's bbox change antialiasing, hinting or subpixel placement, + // a fling would show pixels a reader could tell apart from a rested view, + // and this test is what refuses to let that happen quietly. + var document = try Document.open("docs/design.pdf"); + defer document.deinit(); + + const request: RenderRequest = .{ .dpi = 96, .max_dimension = 1200 }; + const raster = try document.measureRenderAt(0, request); + const whole = try std.testing.allocator.alloc(u8, raster.len); + defer std.testing.allocator.free(whole); + try document.renderIntoAt(0, request, raster, raster.wholePage(), &.{}, whole); + + // Odd offsets and an odd height on purpose: a band that starts on a row + // MuPDF would never choose itself is exactly where an off-by-one in the + // bbox arithmetic would show up. + const cases = [_]struct { y: usize, h: usize }{ + .{ .y = 0, .h = 1 }, + .{ .y = 1, .h = 37 }, + .{ .y = raster.height / 3, .h = 101 }, + .{ .y = raster.height / 2, .h = raster.height / 2 }, + .{ .y = raster.height - 1, .h = 1 }, + }; + for (cases) |case| { + const band = raster.band(case.y, case.h); + const rows = try std.testing.allocator.alloc(u8, band.len); + defer std.testing.allocator.free(rows); + try document.renderIntoAt(0, request, raster, band, &.{}, rows); + const from = band.y * raster.stride; + try std.testing.expectEqualSlices(u8, whole[from..][0..band.len], rows); + try expectOpaque(rows); + } + + // A baked highlight is drawn under the same CTM, so it has to land on the + // same rows through a band as it does through a whole page. + const highlights = [_]Highlight{.{ + .quad = .{ + .ul = .{ .x = 0.1, .y = 0.4 }, + .ur = .{ .x = 0.9, .y = 0.4 }, + .ll = .{ .x = 0.1, .y = 0.6 }, + .lr = .{ .x = 0.9, .y = 0.6 }, + }, + .kind = .search, + .rgba = .{ 0x30, 0x80, 0xf0, 0x80 }, + }}; + const marked_whole = try std.testing.allocator.alloc(u8, raster.len); + defer std.testing.allocator.free(marked_whole); + try document.renderIntoAt(0, request, raster, raster.wholePage(), &highlights, marked_whole); + const marked_band = raster.band(raster.height / 3, raster.height / 3); + const marked_rows = try std.testing.allocator.alloc(u8, marked_band.len); + defer std.testing.allocator.free(marked_rows); + try document.renderIntoAt(0, request, raster, marked_band, &highlights, marked_rows); + const marked_from = marked_band.y * raster.stride; + try std.testing.expectEqualSlices( + u8, + marked_whole[marked_from..][0..marked_band.len], + marked_rows, + ); + // ...and the highlight really is in the band that was compared, or the + // comparison above would be two identical plain renders agreeing. + try std.testing.expect(!std.mem.eql( + u8, + whole[marked_from..][0..marked_band.len], + marked_rows, + )); + + // A band request that misses the page is a viewport that has left it, not + // a malformed call: it clamps instead of failing. + const past_end = raster.band(raster.height + 500, 64); + try std.testing.expect(past_end.y < raster.height); + try std.testing.expectEqual(@as(usize, 1), past_end.height); +} + test "MuPDF search returns normalized oriented quads and word selection text" { var document = try Document.open("docs/design.pdf"); defer document.deinit(); @@ -1706,6 +1823,21 @@ test "offset crop and rotation keep normalized highlights pixel-aligned" { try std.testing.expectEqual(@as(usize, 300), plain.width); try std.testing.expectEqual(@as(usize, 320), plain.height); + // The SIZE comes off the page object rather than a loaded page (see + // pardes_pdf_get_page_size), and this is the fixture that would catch it + // being a different answer: an offset CropBox plus /Rotate 90, where a + // MediaBox reading, an untransformed cropbox or a missed rotation all give + // plausible-looking wrong numbers. The rendered raster above is what + // fz_bound_page produces, so the strip's layout has to agree with it. + const size = try document.pageSize(0); + try std.testing.expectEqual(@as(f32, 150), size.width); + try std.testing.expectEqual(@as(f32, 160), size.height); + try std.testing.expectApproxEqAbs( + @as(f32, @floatFromInt(plain.width)) / @as(f32, @floatFromInt(plain.height)), + size.width / size.height, + 0.001, + ); + const kitty = try document.renderAt(std.testing.allocator, 0, .{ .dpi = 96, .max_dimension = 1200, diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c index 25870f20..bd72a54a 100644 --- a/src/pdf_bridge.c +++ b/src/pdf_bridge.c @@ -1,6 +1,7 @@ #include "pdf_bridge.h" #include <mupdf/fitz.h> +#include <mupdf/pdf.h> #include <math.h> #include <limits.h> @@ -380,6 +381,22 @@ pardes_pdf_close(pardes_pdf_document *document) atomic_fetch_sub(&pardes_pdf_active_documents, 1); } +/* + * A page's SIZE, without building a page. + * + * fz_bound_page(fz_load_page(n)) is the obvious spelling and it is what this + * used to do, but fz_load_page builds a whole pdf_page: it resolves the page + * dictionary, then loads and parses every link annotation on it. Laying out a + * 5363-page manual's strip asks for 5363 sizes, and profiling an open showed + * pdf_load_link_annots alone at 29% of the run — parsing links for pages + * nobody has looked at yet, to answer a question about their height. + * + * The page OBJECT answers it directly, and identically: fz_bound_page on a PDF + * is pdf_bound_page(FZ_CROP_BOX), which is pdf_page_obj_transform_box on + * page->obj followed by fz_transform_rect — exactly the two calls below, from + * exactly the same object. Non-PDF documents (cbz, xps, svg) have no page + * objects and keep the loading path. + */ int pardes_pdf_get_page_size( pardes_pdf_document *document, @@ -388,6 +405,7 @@ pardes_pdf_get_page_size( { fz_context *ctx; fz_rect bounds; + pdf_document *pdf; if (document == NULL || out == NULL || page_number < 0 || page_number >= document->page_count) @@ -397,8 +415,18 @@ pardes_pdf_get_page_size( ctx = document->ctx; fz_try(ctx) { - pardes_pdf_cache_page(document, page_number, 0); - bounds = document->cached_bounds; + pdf = pdf_specifics(ctx, document->doc); + if (pdf != NULL) { + fz_matrix page_ctm; + fz_rect cropbox; + pdf_obj *page_obj = pdf_lookup_page_obj(ctx, pdf, page_number); + pdf_page_obj_transform_box(ctx, page_obj, &cropbox, &page_ctm, + FZ_CROP_BOX); + bounds = fz_transform_rect(cropbox, page_ctm); + } else { + pardes_pdf_cache_page(document, page_number, 0); + bounds = document->cached_bounds; + } out->width = bounds.x1 - bounds.x0; out->height = bounds.y1 - bounds.y0; if (!(out->width > 0.0f) || !(out->height > 0.0f)) @@ -526,7 +554,9 @@ pardes_pdf_render_into( size_t samples_len, int width, int height, - int stride) + int stride, + int band_y, + int band_height) { fz_context *ctx; fz_pixmap *pixmap = NULL; @@ -542,9 +572,10 @@ pardes_pdf_render_into( (highlight_count != 0 && highlights == NULL) || highlight_count > PARDES_PDF_MAX_RESULT_QUADS || samples == NULL || width < 1 || height < 1 || stride < 1 || width > INT_MAX / 4 || - stride != width * 4 || - (size_t)height > SIZE_MAX / (size_t)stride || - samples_len != (size_t)stride * (size_t)height) + stride != width * 4 || band_y < 0 || band_height < 1 || + band_y > height - band_height || + (size_t)band_height > SIZE_MAX / (size_t)stride || + samples_len != (size_t)stride * (size_t)band_height) return PARDES_PDF_ERROR; ctx = document->ctx; @@ -559,17 +590,38 @@ pardes_pdf_render_into( fz_irect_height(bbox) != height) fz_throw(ctx, FZ_ERROR_ARGUMENT, "PDF raster layout changed between measure and render"); + /* + * The BAND: rows [band_y, band_y + band_height) of the page raster, + * and nothing else. A pixmap's bbox IS the draw device's clip, so the + * page runs under the same CTM it would for a full raster and MuPDF + * discards everything outside these rows. Every row inside them is + * therefore bit-identical to the same row of the full-page raster — + * "band rows equal full-page rows" in pdf.zig proves it, because the + * whole point of a band is that the reader cannot tell. + */ + bbox.y0 += band_y; + bbox.y1 = bbox.y0 + band_height; /* External samples are never marked FZ_PIXMAP_FLAG_FREE_SAMPLES. */ pixmap = fz_new_pixmap_with_bbox_and_data( ctx, fz_device_rgb(ctx), bbox, NULL, 1, samples); if (fz_pixmap_samples(ctx, pixmap) != samples || fz_pixmap_width(ctx, pixmap) != width || - fz_pixmap_height(ctx, pixmap) != height || + fz_pixmap_height(ctx, pixmap) != band_height || fz_pixmap_stride(ctx, pixmap) != stride || fz_pixmap_components(ctx, pixmap) != 4) fz_throw(ctx, FZ_ERROR_FORMAT, "MuPDF wrapped an unexpected RGBA layout"); + /* + * ponytail: this memset is ~9% of a fast scroll's profile and it has + * twice measured as unremovable. Filling with 32-byte vector stores + * instead (a memset of a multi-megabyte raster goes out through + * non-temporal stores, 7.4 GB/s against 41.7 for a store loop) changed + * a fling's median frame by nothing at all, and skipping the fill + * ENTIRELY changed it by 1-2%: the cache misses it is blamed for are + * paid either way by the glyph spans and the tint pass that walk the + * same buffer immediately afterwards. Leave it alone. + */ fz_clear_pixmap_with_value(ctx, pixmap, 0xFF); if (document->cached_display_list != NULL || diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h index 40eaa46c..a3d2de89 100644 --- a/src/pdf_bridge.h +++ b/src/pdf_bridge.h @@ -198,9 +198,15 @@ int pardes_pdf_measure_render( ); /* - * Rasterize directly into an exactly-sized caller-owned packed RGBA buffer. + * Rasterize one horizontal BAND of a page — rows [band_y, band_y+band_height) + * of the raster `pardes_pdf_measure_render` describes — directly into an + * exactly-sized caller-owned packed RGBA buffer, `stride * band_height` bytes. + * A full page is the band `(0, height)`. + * * MuPDF wraps but never owns or frees samples. Every successful pixel is * opaque, including the caller-supplied highlights composited onto the page. + * The CTM does not depend on the band, so a band's rows are bit-identical to + * the same rows of the whole page: it is a clip, not a different rendering. */ int pardes_pdf_render_into( pardes_pdf_document *document, @@ -215,7 +221,9 @@ int pardes_pdf_render_into( size_t samples_len, int width, int height, - int stride + int stride, + int band_y, + int band_height ); int pardes_pdf_page_text( |
