summaryrefslogtreecommitdiff
path: root/src/pdf.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-14 13:40:29 -0300
committerGabriel Schneider <[email protected]>2026-08-15 11:57:13 -0300
commitf67fec978a9296c651ec06bd2f43686d34ff86ee (patch)
treecbf5568883e5888398e0887093fc5afc524fd54d /src/pdf.zig
parent9280c597b000eed661fd98793e182fdcb640f6cd (diff)
downloadpardes-f67fec978a9296c651ec06bd2f43686d34ff86ee.tar.gz
pardes-f67fec978a9296c651ec06bd2f43686d34ff86ee.zip
look: richer path/range parsing, pdf rendering, corner-drag and stepgrain snapshots
Diffstat (limited to 'src/pdf.zig')
-rw-r--r--src/pdf.zig303
1 files changed, 240 insertions, 63 deletions
diff --git a/src/pdf.zig b/src/pdf.zig
index 2a674172..c1faafc5 100644
--- a/src/pdf.zig
+++ b/src/pdf.zig
@@ -268,36 +268,111 @@ pub fn tintRgba(rgba: []u8, mode: TintMode, colors: TintColors) !void {
const pixels = std.mem.bytesAsSlice([4]u8, rgba);
switch (mode) {
.disabled => unreachable,
+ .full => tintPixels(pixels, .full, &targets),
+ .filtered => tintPixels(pixels, .filtered, &targets),
+ }
+}
+
+/// The whole rule for ONE pixel, and the reason the memo below is exact: the
+/// transform reads nothing but this packed word, and alpha rides through
+/// untouched, so the word is a complete cache key.
+inline fn tintPixel(word: u32, comptime mode: TintMode, targets: *const [256][3]u8) u32 {
+ var pixel: [4]u8 = @bitCast(word);
+ const red = pixel[0];
+ const green = pixel[1];
+ const blue = pixel[2];
+ const luminance: u8 = @intCast((@as(u32, red) * 77 +
+ @as(u32, green) * 150 +
+ @as(u32, blue) * 29 + 128) >> 8);
+ const target = targets[luminance];
+ switch (mode) {
+ .disabled => comptime unreachable,
.full => {
- for (pixels) |*pixel| {
- const red = pixel[0];
- const green = pixel[1];
- const blue = pixel[2];
- const luminance: u8 = @intCast((@as(u32, red) * 77 +
- @as(u32, green) * 150 +
- @as(u32, blue) * 29 + 128) >> 8);
- const target = targets[luminance];
- pixel[0] = target[0];
- pixel[1] = target[1];
- pixel[2] = target[2];
- }
+ pixel[0] = target[0];
+ pixel[1] = target[1];
+ pixel[2] = target[2];
},
.filtered => {
- for (pixels) |*pixel| {
- const red = pixel[0];
- const green = pixel[1];
- const blue = pixel[2];
- const luminance: u8 = @intCast((@as(u32, red) * 77 +
- @as(u32, green) * 150 +
- @as(u32, blue) * 29 + 128) >> 8);
- const target = targets[luminance];
- const residual_base: i16 = 255 - @as(i16, luminance);
- pixel[0] = filteredTintChannel(target[0], red, residual_base);
- pixel[1] = filteredTintChannel(target[1], green, residual_base);
- pixel[2] = filteredTintChannel(target[2], blue, residual_base);
- }
+ const residual_base: i16 = 255 - @as(i16, luminance);
+ pixel[0] = filteredTintChannel(target[0], red, residual_base);
+ pixel[1] = filteredTintChannel(target[1], green, residual_base);
+ pixel[2] = filteredTintChannel(target[2], blue, residual_base);
},
}
+ return @bitCast(pixel);
+}
+
+/// Alpha-less packed tint for every grayscale level. See tintWord: this one
+/// table serves BOTH modes, which is why it takes no mode.
+fn grayTintTable(targets: *const [256][3]u8) [256]u32 {
+ var table: [256]u32 = undefined;
+ for (&table, targets) |*word, target|
+ word.* = @bitCast([4]u8{ target[0], target[1], target[2], 0 });
+ return table;
+}
+
+const tint_alpha_mask: u32 = @bitCast([4]u8{ 0, 0, 0, 0xff });
+
+/// One pixel, with the two shortcuts that make a full-page tint affordable.
+///
+/// Rec. 601's integer weights sum to exactly 256, so for r == g == b the
+/// luminance dot product is the channel value itself, and `filtered`'s chroma
+/// residual is then exactly zero — both modes collapse to the themed target
+/// for that level. A text page's raster is overwhelmingly grayscale, so this
+/// arm carries almost every pixel at one table load. Colour falls through to
+/// the general rule, unchanged.
+inline fn tintWord(
+ word: u32,
+ comptime mode: TintMode,
+ targets: *const [256][3]u8,
+ gray: *const [256]u32,
+) u32 {
+ const pixel: [4]u8 = @bitCast(word);
+ if (pixel[0] == pixel[1] and pixel[1] == pixel[2])
+ return gray[pixel[0]] | (word & tint_alpha_mask);
+ return tintPixel(word, mode, targets);
+}
+
+/// Pixels per run test. Sixteen is four SSE2 registers: one branch says
+/// "this whole span repeats the previous pixel", which is the shape of the
+/// paper margins and of any flat fill.
+const tint_run_pixels = 16;
+
+/// A rasterized page is mostly flat — paper, then runs of one ink value — and
+/// the transform reads nothing but the packed word, so a one-entry memo tested
+/// a span at a time turns those runs into one comparison and one store. That,
+/// plus the grayscale collapse above, is what lets a fling afford to tint a
+/// whole freshly rasterized page inside a single frame. Colour-photographic
+/// content pays the span comparison and nothing else; the arithmetic is
+/// byte-for-byte the scalar rule in every arm.
+fn tintPixels(pixels: [][4]u8, comptime mode: TintMode, targets: *const [256][3]u8) void {
+ const gray = grayTintTable(targets);
+ const Run = @Vector(tint_run_pixels, u32);
+ var memo_key: u32 = undefined;
+ var memo_value: u32 = undefined;
+ var memo_valid = false;
+ var at: usize = 0;
+ while (at + tint_run_pixels <= pixels.len) : (at += tint_run_pixels) {
+ const span = pixels[at..][0..tint_run_pixels];
+ if (memo_valid) {
+ const words: Run = @bitCast(span.*);
+ if (@reduce(.And, words == @as(Run, @splat(memo_key)))) {
+ span.* = @bitCast(@as(Run, @splat(memo_value)));
+ continue;
+ }
+ }
+ for (span) |*pixel| {
+ memo_key = @bitCast(pixel.*);
+ memo_value = tintWord(memo_key, mode, targets, &gray);
+ pixel.* = @bitCast(memo_value);
+ }
+ memo_valid = true;
+ }
+ while (at < pixels.len) : (at += 1) {
+ const pixel = &pixels[at];
+ const word: u32 = @bitCast(pixel.*);
+ pixel.* = @bitCast(tintWord(word, mode, targets, &gray));
+ }
}
fn tintRgbaReference(rgba: []u8, mode: TintMode, colors: TintColors) !void {
@@ -403,6 +478,60 @@ test "optimized PDF tint matches scalar rule across deterministic broad samples"
}
}
+// The memo, its tint_run_pixels span test, and the grayscale collapse only
+// engage on repeated or achromatic pixels, and the span loop leaves a tail on
+// any pixel count that is not a multiple of tint_run_pixels. None of that is
+// visible to the random corpus above, so drive flat runs, run boundaries, and
+// every remainder against the reference rule directly.
+test "optimized PDF tint matches scalar rule across runs, boundaries, and every tail" {
+ const modes = [_]TintMode{ .full, .filtered };
+ const colors: TintColors = .{
+ .background = .{ 0x08, 0x12, 0x2a },
+ .foreground = .{ 0xbd, 0xa4, 0x71 },
+ };
+ // Paper, ink, antialiased edge, saturated colour, and a transparent pixel
+ // that shares its RGB with an opaque one — the memo key must include alpha
+ // or that pair would tint from one cached word.
+ const palette = [_][4]u8{
+ .{ 0xff, 0xff, 0xff, 0xff },
+ .{ 0x00, 0x00, 0x00, 0xff },
+ .{ 0x7f, 0x80, 0x81, 0xff },
+ .{ 0xff, 0x00, 0x00, 0xff },
+ .{ 0xff, 0xff, 0xff, 0x00 },
+ };
+ var state: u64 = 0x7061_7264_6573_5254;
+ var pixels: [37][4]u8 = undefined;
+ for (0..96) |round| {
+ // Alternate long flat runs against pixel-by-pixel churn so the block
+ // fast path, the per-pixel memo, and the miss path all run.
+ var run_left: usize = 0;
+ var current: [4]u8 = palette[round % palette.len];
+ for (&pixels) |*pixel| {
+ if (run_left == 0) {
+ run_left = 1 + (tintTestByte(&state) % 9);
+ current = if (tintTestByte(&state) & 3 == 0)
+ .{ tintTestByte(&state), tintTestByte(&state), tintTestByte(&state), tintTestByte(&state) }
+ else
+ palette[tintTestByte(&state) % palette.len];
+ }
+ run_left -= 1;
+ pixel.* = current;
+ }
+ for (0..pixels.len + 1) |count| {
+ const source = std.mem.sliceAsBytes(pixels[0..count]);
+ for (modes) |mode| {
+ var expected: [pixels.len * 4]u8 = undefined;
+ var actual: [pixels.len * 4]u8 = undefined;
+ @memcpy(expected[0..source.len], source);
+ @memcpy(actual[0..source.len], source);
+ try tintRgbaReference(expected[0..source.len], mode, colors);
+ try tintRgba(actual[0..source.len], mode, colors);
+ try std.testing.expectEqualSlices(u8, expected[0..source.len], actual[0..source.len]);
+ }
+ }
+ }
+}
+
test "PDF tint mode cycle is exact" {
try std.testing.expectEqual(TintMode.filtered, TintMode.disabled.next());
try std.testing.expectEqual(TintMode.full, TintMode.filtered.next());
@@ -740,62 +869,107 @@ pub const Document = struct {
return document.renderInternal(gpa, page, request, highlights);
}
- fn renderInternal(
+ /// Exactly the raster this request will produce, without producing it.
+ /// Split out of the render so a caller holding a retired buffer of the
+ /// same length can hand it straight back instead of making the allocator
+ /// fetch (and later return) fresh pages for every page it flies past.
+ pub fn measureRenderAt(
document: *Document,
- gpa: std.mem.Allocator,
page: usize,
request: RenderRequest,
- highlights: ?[]const Highlight,
- ) !Render {
- if (page >= document.pages or page > std.math.maxInt(c_int))
- return error.PageOutOfRange;
- if (request.dpi == 0 or request.max_dimension == 0 or
- request.max_dimension > absolute_max_render_dimension)
- return error.InvalidRenderRequest;
- const minimum_width: c_int = @intCast(@min(
- request.minimum_width,
- @as(u32, @intCast(std.math.maxInt(c_int))),
- ));
- const minimum_height: c_int = @intCast(@min(
- request.minimum_height,
- @as(u32, @intCast(std.math.maxInt(c_int))),
- ));
- const items: []const Highlight = highlights orelse &.{};
- if (items.len > c.PARDES_PDF_MAX_RESULT_QUADS)
- return error.RenderFailed;
+ ) !Raster {
+ const bounded = try boundedRequest(document, page, request);
var raw_layout: c.pardes_pdf_raster_layout = std.mem.zeroes(c.pardes_pdf_raster_layout);
if (c.pardes_pdf_measure_render(
document.handle,
@intCast(page),
- request.dpi,
- minimum_width,
- minimum_height,
- request.max_dimension,
+ bounded.dpi,
+ bounded.minimum_width,
+ bounded.minimum_height,
+ bounded.max_dimension,
&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;
+ }
- const rgba = try gpa.alloc(u8, layout.len);
- errdefer gpa.free(rgba);
+ /// 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.
+ pub fn renderIntoAt(
+ document: *Document,
+ page: usize,
+ request: RenderRequest,
+ raster: Raster,
+ highlights: []const Highlight,
+ rgba: []u8,
+ ) !void {
+ if (rgba.len != raster.len) 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 =
- if (items.len == 0) null else @ptrCast(items.ptr);
+ if (highlights.len == 0) null else @ptrCast(highlights.ptr);
if (c.pardes_pdf_render_into(
document.handle,
@intCast(page),
- request.dpi,
- minimum_width,
- minimum_height,
- request.max_dimension,
+ bounded.dpi,
+ bounded.minimum_width,
+ bounded.minimum_height,
+ bounded.max_dimension,
highlight_ptr,
- items.len,
+ highlights.len,
rgba.ptr,
rgba.len,
- @intCast(layout.width),
- @intCast(layout.height),
- @intCast(layout.stride),
+ @intCast(raster.width),
+ @intCast(raster.height),
+ @intCast(raster.stride),
) != c.PARDES_PDF_OK) return error.RenderFailed;
- return .{ .rgba = rgba, .width = layout.width, .height = layout.height };
+ }
+
+ const BoundedRequest = struct {
+ dpi: c_int,
+ minimum_width: c_int,
+ minimum_height: c_int,
+ max_dimension: c_int,
+ };
+
+ fn boundedRequest(
+ document: *Document,
+ page: usize,
+ request: RenderRequest,
+ ) !BoundedRequest {
+ if (page >= document.pages or page > std.math.maxInt(c_int))
+ return error.PageOutOfRange;
+ if (request.dpi == 0 or request.max_dimension == 0 or
+ request.max_dimension > absolute_max_render_dimension)
+ return error.InvalidRenderRequest;
+ return .{
+ .dpi = request.dpi,
+ .minimum_width = @intCast(@min(
+ request.minimum_width,
+ @as(u32, @intCast(std.math.maxInt(c_int))),
+ )),
+ .minimum_height = @intCast(@min(
+ request.minimum_height,
+ @as(u32, @intCast(std.math.maxInt(c_int))),
+ )),
+ .max_dimension = request.max_dimension,
+ };
+ }
+
+ fn renderInternal(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ request: RenderRequest,
+ highlights: ?[]const Highlight,
+ ) !Render {
+ 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);
+ return .{ .rgba = rgba, .width = raster.width, .height = raster.height };
}
/// Plain UTF-8-ish text projection for one zero-based page. MuPDF owns the
@@ -955,14 +1129,17 @@ fn validQuad(quad: Quad) bool {
validPoint(quad.ll) and validPoint(quad.lr);
}
-const RasterLayout = struct {
+/// 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.
+pub const Raster = struct {
width: usize,
height: usize,
stride: usize,
len: usize,
};
-fn checkedRasterLayout(raw: c.pardes_pdf_raster_layout) !RasterLayout {
+fn checkedRasterLayout(raw: c.pardes_pdf_raster_layout) !Raster {
if (raw.width < 1 or raw.height < 1 or raw.stride < 1)
return error.BadPixmap;
const width: usize = @intCast(raw.width);