summaryrefslogtreecommitdiff
path: root/src/pdf.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pdf.zig')
-rw-r--r--src/pdf.zig146
1 files changed, 139 insertions, 7 deletions
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,