//! 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]); }