diff options
Diffstat (limited to 'src/pdf.zig')
| -rw-r--r-- | src/pdf.zig | 517 |
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]); +} |
