summaryrefslogtreecommitdiff
path: root/src/pdf.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 17:17:35 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit5961587b227e5fa097fb033e32c29da08a23fb18 (patch)
tree359883d5531e595d47f75824a77a0c1323973e2e /src/pdf.zig
parent487434a0b55fa7cf479f39481b51da0a7eda79e1 (diff)
downloadpardes-5961587b227e5fa097fb033e32c29da08a23fb18.tar.gz
pardes-5961587b227e5fa097fb033e32c29da08a23fb18.zip
tty/image: big harness + golden coverage pass
Diffstat (limited to 'src/pdf.zig')
-rw-r--r--src/pdf.zig517
1 files changed, 517 insertions, 0 deletions
diff --git a/src/pdf.zig b/src/pdf.zig
new file mode 100644
index 00000000..48b15cf1
--- /dev/null
+++ b/src/pdf.zig
@@ -0,0 +1,517 @@
+//! Exception-safe MuPDF runtime wrapper. MuPDF's public API throws with
+//! setjmp/longjmp; pdf_bridge.c catches every such throw before control returns
+//! to Zig. This module is present in native builds unless `-Dmupdf=false`.
+const std = @import("std");
+
+const c = @cImport({
+ @cInclude("pdf_bridge.h");
+});
+
+pub const max_render_dimension = 1600;
+/// 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;
+
+test "MuPDF document cache has an explicit conservative ceiling" {
+ try std.testing.expectEqual(@as(usize, 64 * 1024 * 1024), store_limit_bytes);
+}
+
+/// Page-space geometry is normalized to the page bounds, not a render. It
+/// therefore survives DPI changes and can be shared by Kitty and SDL.
+pub const Point = c.pardes_pdf_point;
+pub const Quad = c.pardes_pdf_quad;
+
+pub const HighlightKind = enum(c_int) {
+ custom = c.PARDES_PDF_HIGHLIGHT_CUSTOM,
+ search = c.PARDES_PDF_HIGHLIGHT_SEARCH,
+ selection = c.PARDES_PDF_HIGHLIGHT_SELECTION,
+};
+
+/// RGBA is straight (not premultiplied). `kind` remains available to the UI as
+/// semantic metadata; the caller-supplied RGBA is authoritative for rendering.
+pub const Highlight = extern struct {
+ quad: Quad,
+ rgba: [4]u8,
+ kind: HighlightKind,
+
+ pub fn init(quad: Quad, rgba: [4]u8, kind: HighlightKind) Highlight {
+ return .{ .quad = quad, .rgba = rgba, .kind = kind };
+ }
+};
+
+comptime {
+ if (@sizeOf(Highlight) != @sizeOf(c.pardes_pdf_highlight) or
+ @alignOf(Highlight) != @alignOf(c.pardes_pdf_highlight) or
+ @offsetOf(Highlight, "quad") != @offsetOf(c.pardes_pdf_highlight, "quad") or
+ @offsetOf(Highlight, "rgba") != @offsetOf(c.pardes_pdf_highlight, "rgba") or
+ @offsetOf(Highlight, "kind") != @offsetOf(c.pardes_pdf_highlight, "kind"))
+ @compileError("Highlight must match pardes_pdf_highlight's C ABI");
+}
+
+pub const Render = struct {
+ rgba: []u8,
+ width: usize,
+ height: usize,
+};
+
+pub const SearchQuad = struct {
+ quad: Quad,
+ /// Zero-based logical hit. One hit may have several oriented quads.
+ hit: usize,
+};
+
+pub const SearchResults = struct {
+ quads: []SearchQuad,
+ hit_count: usize,
+
+ pub fn deinit(results: *SearchResults, gpa: std.mem.Allocator) void {
+ gpa.free(results.quads);
+ results.* = undefined;
+ }
+};
+
+pub const Selection = struct {
+ quads: []Quad,
+ /// Word-snapped endpoints; pass these to `Document.copySelection`.
+ start: Point,
+ end: Point,
+
+ /// Hit-test without flattening oriented text quads to axis-aligned boxes.
+ /// The bridge delegates to MuPDF's geometry predicate and cannot throw.
+ pub fn contains(selection: *const Selection, point: Point) bool {
+ if (!validPoint(point)) return false;
+ for (selection.quads) |quad| {
+ if (c.pardes_pdf_point_inside_quad(point, quad) != 0) return true;
+ }
+ return false;
+ }
+
+ pub fn deinit(selection: *Selection, gpa: std.mem.Allocator) void {
+ gpa.free(selection.quads);
+ selection.* = undefined;
+ }
+};
+
+test "oriented selection containment delegates to MuPDF quad geometry" {
+ const quads = [_]Quad{.{
+ .ul = .{ .x = 0.10, .y = 0.10 },
+ .ur = .{ .x = 0.70, .y = 0.20 },
+ .ll = .{ .x = 0.20, .y = 0.80 },
+ .lr = .{ .x = 0.80, .y = 0.90 },
+ }};
+ const selection = Selection{
+ .quads = @constCast(quads[0..]),
+ .start = quads[0].ul,
+ .end = quads[0].lr,
+ };
+ try std.testing.expect(selection.contains(.{ .x = 0.45, .y = 0.50 }));
+ try std.testing.expect(!selection.contains(.{ .x = 0.05, .y = 0.85 }));
+ try std.testing.expect(!selection.contains(.{ .x = -0.1, .y = 0.5 }));
+}
+
+pub const Document = struct {
+ handle: *c.pardes_pdf_document,
+ pages: usize,
+
+ pub fn open(path: []const u8) !Document {
+ var path_buf: [4096]u8 = undefined;
+ const path_z = std.fmt.bufPrintSentinel(&path_buf, "{s}", .{path}, 0) catch
+ return error.PathTooLong;
+ var page_count: c_int = 0;
+ const handle = c.pardes_pdf_open(path_z.ptr, &page_count) orelse
+ return error.OpenFailed;
+ if (page_count < 1) {
+ c.pardes_pdf_close(handle);
+ return error.EmptyDocument;
+ }
+ return .{ .handle = handle, .pages = @intCast(page_count) };
+ }
+
+ pub fn deinit(document: *Document) void {
+ c.pardes_pdf_close(document.handle);
+ document.* = undefined;
+ }
+
+ /// Render one zero-based page, bounded before allocation. MuPDF supplies
+ /// row-strided RGB; Pardes' attachment boundary is packed straight RGBA.
+ pub fn render(document: *Document, gpa: std.mem.Allocator, page: usize) !Render {
+ return document.renderInternal(gpa, page, null);
+ }
+
+ /// Render and bake normalized oriented highlights through MuPDF's draw
+ /// device. An empty list takes the exact same plain-render path as render.
+ pub fn renderWithHighlights(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ highlights: []const Highlight,
+ ) !Render {
+ return document.renderInternal(gpa, page, highlights);
+ }
+
+ fn renderInternal(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ highlights: ?[]const Highlight,
+ ) !Render {
+ if (page >= document.pages or page > std.math.maxInt(c_int))
+ return error.PageOutOfRange;
+ var pixmap: c.pardes_pdf_pixmap = std.mem.zeroes(c.pardes_pdf_pixmap);
+ const status = if (highlights) |items|
+ if (items.len == 0)
+ c.pardes_pdf_render(
+ document.handle,
+ @intCast(page),
+ max_render_dimension,
+ &pixmap,
+ )
+ else
+ c.pardes_pdf_render_with_highlights(
+ document.handle,
+ @intCast(page),
+ max_render_dimension,
+ @ptrCast(items.ptr),
+ items.len,
+ &pixmap,
+ )
+ else
+ c.pardes_pdf_render(
+ document.handle,
+ @intCast(page),
+ max_render_dimension,
+ &pixmap,
+ );
+ if (status != c.PARDES_PDF_OK) return error.RenderFailed;
+ defer c.pardes_pdf_drop_pixmap(document.handle, pixmap.handle);
+
+ if (pixmap.samples == null or pixmap.width < 1 or pixmap.height < 1 or
+ pixmap.stride < 1 or pixmap.components != 3)
+ return error.BadPixmap;
+ const width: usize = @intCast(pixmap.width);
+ const height: usize = @intCast(pixmap.height);
+ const stride: usize = @intCast(pixmap.stride);
+ if (width > std.math.maxInt(usize) / 4 or
+ height > std.math.maxInt(usize) / (width * 4) or
+ height > std.math.maxInt(usize) / stride or
+ stride < width * 3)
+ return error.BadPixmap;
+
+ const rgba = try gpa.alloc(u8, width * height * 4);
+ errdefer gpa.free(rgba);
+ const samples: [*]const u8 = @ptrCast(pixmap.samples);
+ try expandRgb(rgba, samples[0 .. stride * height], width, height, stride);
+ return .{ .rgba = rgba, .width = width, .height = height };
+ }
+
+ /// Plain UTF-8-ish text projection for one zero-based page. MuPDF owns the
+ /// temporary buffer; callers receive an allocator-owned copy.
+ pub fn pageText(document: *Document, gpa: std.mem.Allocator, page: usize) ![]u8 {
+ if (page >= document.pages or page > std.math.maxInt(c_int))
+ return error.PageOutOfRange;
+ var text: c.pardes_pdf_text = std.mem.zeroes(c.pardes_pdf_text);
+ if (c.pardes_pdf_page_text(document.handle, @intCast(page), &text) != 0)
+ return error.TextFailed;
+ defer c.pardes_pdf_drop_text(document.handle, text.handle);
+ if (text.len == 0) return gpa.dupe(u8, "");
+ if (text.data == null) return error.BadText;
+ const bytes: [*]const u8 = @ptrCast(text.data);
+ return gpa.dupe(u8, bytes[0..text.len]);
+ }
+
+ /// Case-insensitive single-page search. MuPDF's iterative search owns a
+ /// retained reference to the cached structured-text page while running;
+ /// this wrapper copies every borrowed oriented quad before dropping it.
+ /// The C bridge doubles capacity up to 65,536 aggregate quads and returns
+ /// TooManyResults atomically rather than silently truncating beyond it.
+ pub fn search(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ needle: []const u8,
+ ) !SearchResults {
+ const page_number = try document.checkedPage(page);
+ if (std.mem.indexOfScalar(u8, needle, 0) != null)
+ return error.InvalidNeedle;
+ const needle_z = try gpa.dupeZ(u8, needle);
+ defer gpa.free(needle_z);
+
+ var found: c.pardes_pdf_search_result =
+ std.mem.zeroes(c.pardes_pdf_search_result);
+ const status = c.pardes_pdf_search_page(document.handle, page_number, needle_z.ptr, &found);
+ if (status == c.PARDES_PDF_LIMIT_EXCEEDED)
+ return error.TooManyResults;
+ if (status != c.PARDES_PDF_OK)
+ return error.SearchFailed;
+ defer c.pardes_pdf_drop_search_result(document.handle, found.handle);
+
+ if (found.quad_count != 0 and found.quads == null)
+ return error.BadGeometry;
+ const quads = try gpa.alloc(SearchQuad, found.quad_count);
+ errdefer gpa.free(quads);
+ if (found.quad_count != 0) {
+ const source: [*]const c.pardes_pdf_search_quad = @ptrCast(found.quads);
+ for (quads, source[0..found.quad_count]) |*dest, item| {
+ if (item.hit >= found.hit_count or !validQuad(item.quad))
+ return error.BadGeometry;
+ dest.* = .{ .quad = item.quad, .hit = item.hit };
+ }
+ }
+ return .{ .quads = quads, .hit_count = found.hit_count };
+ }
+
+ /// Word-snap two normalized page points and return allocator-owned,
+ /// orientation-preserving highlight geometry.
+ pub fn select(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ start: Point,
+ end: Point,
+ ) !Selection {
+ const page_number = try document.checkedPage(page);
+ if (!validPoint(start) or !validPoint(end)) return error.InvalidPoint;
+
+ var selected: c.pardes_pdf_selection =
+ std.mem.zeroes(c.pardes_pdf_selection);
+ const status = c.pardes_pdf_select(document.handle, page_number, start, end, &selected);
+ if (status == c.PARDES_PDF_LIMIT_EXCEEDED)
+ return error.TooManyResults;
+ if (status != c.PARDES_PDF_OK)
+ return error.SelectionFailed;
+ defer c.pardes_pdf_drop_selection(document.handle, selected.handle);
+
+ if (selected.quad_count != 0 and selected.quads == null)
+ return error.BadGeometry;
+ if (!validPoint(selected.start) or !validPoint(selected.end))
+ return error.BadGeometry;
+ const quads = try gpa.alloc(Quad, selected.quad_count);
+ errdefer gpa.free(quads);
+ if (selected.quad_count != 0) {
+ const source: [*]const Quad = @ptrCast(selected.quads);
+ for (quads, source[0..selected.quad_count]) |*dest, item| {
+ if (!validQuad(item)) return error.BadGeometry;
+ dest.* = item;
+ }
+ }
+ return .{
+ .quads = quads,
+ .start = selected.start,
+ .end = selected.end,
+ };
+ }
+
+ /// Copy UTF-8 text between normalized page points. For word selection,
+ /// pass the snapped endpoints returned by `select`. MuPDF's temporary
+ /// fz_malloc string is always freed after making the allocator-owned copy.
+ pub fn copySelection(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ page: usize,
+ start: Point,
+ end: Point,
+ ) ![]u8 {
+ const page_number = try document.checkedPage(page);
+ if (!validPoint(start) or !validPoint(end)) return error.InvalidPoint;
+
+ var text: c.pardes_pdf_owned_text =
+ std.mem.zeroes(c.pardes_pdf_owned_text);
+ if (c.pardes_pdf_copy_selection(
+ document.handle,
+ page_number,
+ start,
+ end,
+ &text,
+ ) != c.PARDES_PDF_OK) return error.TextFailed;
+ defer c.pardes_pdf_drop_owned_text(document.handle, text.handle);
+ if (text.len == 0) return gpa.dupe(u8, "");
+ if (text.data == null) return error.BadText;
+ const bytes: [*]const u8 = @ptrCast(text.data);
+ return gpa.dupe(u8, bytes[0..text.len]);
+ }
+
+ fn checkedPage(document: *const Document, page: usize) !c_int {
+ if (page >= document.pages or page > std.math.maxInt(c_int))
+ return error.PageOutOfRange;
+ return @intCast(page);
+ }
+};
+
+fn validPoint(point: Point) bool {
+ return std.math.isFinite(point.x) and std.math.isFinite(point.y) and
+ point.x >= 0 and point.x <= 1 and point.y >= 0 and point.y <= 1;
+}
+
+fn validQuad(quad: Quad) bool {
+ return validPoint(quad.ul) and validPoint(quad.ur) and
+ validPoint(quad.ll) and validPoint(quad.lr);
+}
+
+fn expandRgb(
+ rgba: []u8,
+ rgb: []const u8,
+ width: usize,
+ height: usize,
+ stride: usize,
+) !void {
+ if (rgba.len != width * height * 4 or stride < width * 3 or
+ rgb.len < stride * height)
+ return error.BadPixmap;
+ for (0..height) |y| {
+ const src = rgb[y * stride ..][0 .. width * 3];
+ const dst = rgba[y * width * 4 ..][0 .. width * 4];
+ for (0..width) |x| {
+ dst[x * 4 + 0] = src[x * 3 + 0];
+ dst[x * 4 + 1] = src[x * 3 + 1];
+ dst[x * 4 + 2] = src[x * 3 + 2];
+ dst[x * 4 + 3] = 0xff;
+ }
+ }
+}
+
+fn makeOffsetRotatedPdf(gpa: std.mem.Allocator) ![]u8 {
+ const stream = "q 1 0 0 rg 120 220 160 150 re f Q\n";
+ var bytes: std.ArrayList(u8) = .empty;
+ errdefer bytes.deinit(gpa);
+ var offsets: [5]usize = @splat(0);
+
+ try bytes.appendSlice(gpa, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
+ offsets[1] = bytes.items.len;
+ try bytes.appendSlice(gpa, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
+ offsets[2] = bytes.items.len;
+ try bytes.appendSlice(gpa, "2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
+ offsets[3] = bytes.items.len;
+ try bytes.appendSlice(gpa, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [100 200 300 400] " ++
+ "/CropBox [120 220 280 370] /Rotate 90 /Resources << >> " ++
+ "/Contents 4 0 R >>\nendobj\n");
+ offsets[4] = bytes.items.len;
+ try bytes.print(gpa, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ stream.len, stream });
+
+ const xref = bytes.items.len;
+ try bytes.appendSlice(gpa, "xref\n0 5\n0000000000 65535 f \n");
+ for (offsets[1..]) |offset|
+ try bytes.print(gpa, "{d:0>10} 00000 n \n", .{offset});
+ try bytes.print(gpa, "trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref});
+ return bytes.toOwnedSlice(gpa);
+}
+
+test "RGB pixmap expansion respects row stride and writes opaque RGBA" {
+ const rgb = [_]u8{
+ 1, 2, 3, 4, 5, 6, 99, 99,
+ 7, 8, 9, 10, 11, 12, 88, 88,
+ };
+ var rgba: [16]u8 = undefined;
+ try expandRgb(&rgba, &rgb, 2, 2, 8);
+ try std.testing.expectEqualSlices(u8, &.{
+ 1, 2, 3, 255, 4, 5, 6, 255,
+ 7, 8, 9, 255, 10, 11, 12, 255,
+ }, &rgba);
+}
+
+test "MuPDF search returns normalized oriented quads and word selection text" {
+ var document = try Document.open("docs/design.pdf");
+ defer document.deinit();
+
+ const projected = try document.pageText(std.testing.allocator, 0);
+ defer std.testing.allocator.free(projected);
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(projected, "Pardes") != null);
+ // Exercise transactional cache replacement before searching page zero
+ // again; a failed ownership handoff here tends to surface as a double drop.
+ if (document.pages > 1) {
+ const other_page = try document.pageText(std.testing.allocator, 1);
+ std.testing.allocator.free(other_page);
+ }
+
+ var found = try document.search(std.testing.allocator, 0, "Pardes");
+ defer found.deinit(std.testing.allocator);
+ try std.testing.expect(found.hit_count > 0);
+ try std.testing.expect(found.quads.len > 0);
+ for (found.quads) |item| {
+ try std.testing.expect(item.hit < found.hit_count);
+ try std.testing.expect(validQuad(item.quad));
+ }
+
+ const first = found.quads[0].quad;
+ var selected = try document.select(std.testing.allocator, 0, first.ul, first.lr);
+ defer selected.deinit(std.testing.allocator);
+ try std.testing.expect(selected.quads.len > 0);
+ try std.testing.expect(validPoint(selected.start));
+ try std.testing.expect(validPoint(selected.end));
+
+ const text = try document.copySelection(std.testing.allocator, 0, selected.start, selected.end);
+ defer std.testing.allocator.free(text);
+ try std.testing.expect(std.ascii.indexOfIgnoreCase(text, "Pardes") != null);
+}
+
+test "highlighted render changes pixels while plain render stays stable" {
+ var document = try Document.open("docs/design.pdf");
+ defer document.deinit();
+
+ const plain_before = try document.render(std.testing.allocator, 0);
+ defer std.testing.allocator.free(plain_before.rgba);
+ const whole_page = Quad{
+ .ul = .{ .x = 0, .y = 0 },
+ .ur = .{ .x = 1, .y = 0 },
+ .ll = .{ .x = 0, .y = 1 },
+ .lr = .{ .x = 1, .y = 1 },
+ };
+ const highlights = [_]Highlight{
+ Highlight.init(whole_page, .{ 255, 0, 0, 128 }, .custom),
+ };
+ const marked = try document.renderWithHighlights(std.testing.allocator, 0, &highlights);
+ defer std.testing.allocator.free(marked.rgba);
+ const plain_after = try document.render(std.testing.allocator, 0);
+ defer std.testing.allocator.free(plain_after.rgba);
+
+ try std.testing.expectEqual(plain_before.width, marked.width);
+ try std.testing.expectEqual(plain_before.height, marked.height);
+ try std.testing.expect(!std.mem.eql(u8, plain_before.rgba, marked.rgba));
+ try std.testing.expectEqualSlices(u8, plain_before.rgba, plain_after.rgba);
+}
+
+test "offset crop and rotation keep normalized highlights pixel-aligned" {
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try makeOffsetRotatedPdf(std.testing.allocator);
+ defer std.testing.allocator.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{
+ .sub_path = "offset-rotated.pdf",
+ .data = fixture,
+ });
+ var path_buffer: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(
+ &path_buffer,
+ ".zig-cache/tmp/{s}/offset-rotated.pdf",
+ .{tmp.sub_path},
+ );
+
+ var document = try Document.open(path);
+ defer document.deinit();
+ const plain = try document.render(std.testing.allocator, 0);
+ defer std.testing.allocator.free(plain.rgba);
+ // CropBox is 160x150 points and /Rotate 90 swaps its displayed axes.
+ try std.testing.expectEqual(@as(usize, 300), plain.width);
+ try std.testing.expectEqual(@as(usize, 320), plain.height);
+
+ const left_half = Quad{
+ .ul = .{ .x = 0, .y = 0 },
+ .ur = .{ .x = 0.5, .y = 0 },
+ .ll = .{ .x = 0, .y = 1 },
+ .lr = .{ .x = 0.5, .y = 1 },
+ };
+ const highlights = [_]Highlight{
+ Highlight.init(left_half, .{ 0, 0, 255, 255 }, .custom),
+ };
+ const marked = try document.renderWithHighlights(std.testing.allocator, 0, &highlights);
+ defer std.testing.allocator.free(marked.rgba);
+ try std.testing.expectEqual(plain.width, marked.width);
+ try std.testing.expectEqual(plain.height, marked.height);
+
+ const left = (plain.height / 2 * plain.width + plain.width / 4) * 4;
+ const right = (plain.height / 2 * plain.width + plain.width * 3 / 4) * 4;
+ try std.testing.expect(plain.rgba[left] > 240 and
+ plain.rgba[left + 1] < 10 and plain.rgba[left + 2] < 10);
+ try std.testing.expect(marked.rgba[left] < 10 and
+ marked.rgba[left + 1] < 10 and marked.rgba[left + 2] > 240);
+ try std.testing.expectEqualSlices(u8, plain.rgba[right .. right + 4], marked.rgba[right .. right + 4]);
+}