summaryrefslogtreecommitdiff
path: root/src/pdf.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-15 13:46:50 -0300
committerGabriel Schneider <[email protected]>2026-09-15 17:24:42 -0300
commit6d7b506c8a380e4dd97f337590ca079d810a360a (patch)
tree71075c8c4383df25c6ecd5140ef75063a897b444 /src/pdf.zig
parent13d0d107a06d7ac5fb5f8e86c747e54603f51b4e (diff)
downloadpardes-6d7b506c8a380e4dd97f337590ca079d810a360a.tar.gz
pardes-6d7b506c8a380e4dd97f337590ca079d810a360a.zip
Follow embedded PDF links through Look
Diffstat (limited to 'src/pdf.zig')
-rw-r--r--src/pdf.zig179
1 files changed, 179 insertions, 0 deletions
diff --git a/src/pdf.zig b/src/pdf.zig
index a12be0b1..052d34db 100644
--- a/src/pdf.zig
+++ b/src/pdf.zig
@@ -654,6 +654,20 @@ pub const OutlineDestination = union(enum) {
external: []const u8,
};
+pub const Link = struct {
+ page: usize,
+ quad: Quad,
+ /// Owned independently of the document's current-page cache. External
+ /// destination slices point into this same allocation.
+ uri: []u8,
+ destination: OutlineDestination,
+
+ pub fn deinit(link: *Link, gpa: std.mem.Allocator) void {
+ gpa.free(link.uri);
+ link.* = undefined;
+ }
+};
+
pub const OutlineEntry = struct {
/// Zero for a root row; rows are in stable pre-order depth-first order.
depth: u8,
@@ -733,6 +747,54 @@ pub const Document = struct {
return .{ .handle = handle, .pages = @intCast(page_count) };
}
+ /// Link annotations are loaded only for the pointed page, separately from
+ /// text/raster work. Returned URIs survive cache eviction and page changes.
+ pub fn linkAt(document: *Document, gpa: std.mem.Allocator, page: usize, point: Point) !?Link {
+ const page_number = try document.checkedPage(page);
+ if (!validPoint(point)) return null;
+ var raw: c.pardes_pdf_link = std.mem.zeroes(c.pardes_pdf_link);
+ if (c.pardes_pdf_link_at(document.handle, page_number, point, &raw) != c.PARDES_PDF_OK)
+ return error.LinkFailed;
+ if (raw.uri == null or raw.uri_len == 0) return null;
+ if (raw.uri_len > c.PARDES_PDF_MAX_LINK_URI_BYTES or !validQuad(raw.quad)) return error.BadLink;
+ const borrowed: [*]const u8 = @ptrCast(raw.uri);
+ const text = borrowed[0..raw.uri_len];
+ if (!std.unicode.utf8ValidateSlice(text) or std.mem.trim(u8, text, " ").len == 0 or
+ std.ascii.eqlIgnoreCase(text, "file://")) return null;
+ for (text) |byte| if (byte < ' ') return null;
+ const uri = try gpa.dupe(u8, text);
+ errdefer gpa.free(uri);
+ const destination: OutlineDestination = switch (raw.destination_kind) {
+ c.PARDES_PDF_OUTLINE_DESTINATION_INTERNAL => blk: {
+ if (raw.page < 0 or @as(usize, @intCast(raw.page)) >= document.pages or
+ raw.has_x > 1 or raw.has_y > 1 or
+ (raw.has_x != 0 and !std.math.isFinite(raw.x)) or
+ (raw.has_y != 0 and !std.math.isFinite(raw.y))) return error.BadLink;
+ break :blk .{ .internal = .{
+ .page = @intCast(raw.page),
+ .x = if (raw.has_x != 0) raw.x else null,
+ .y = if (raw.has_y != 0) raw.y else null,
+ } };
+ },
+ c.PARDES_PDF_OUTLINE_DESTINATION_EXTERNAL => .{ .external = uri },
+ else => return error.BadLink,
+ };
+ return .{ .page = page, .quad = raw.quad, .uri = uri, .destination = destination };
+ }
+
+ pub fn copyRectangle(document: *Document, gpa: std.mem.Allocator, page: usize, quad: Quad) ![]u8 {
+ const page_number = try document.checkedPage(page);
+ if (!validQuad(quad)) return error.InvalidPoint;
+ var text: c.pardes_pdf_owned_text = std.mem.zeroes(c.pardes_pdf_owned_text);
+ if (c.pardes_pdf_copy_rectangle(document.handle, page_number, quad, &text) != c.PARDES_PDF_OK)
+ return error.SelectionFailed;
+ defer c.pardes_pdf_drop_owned_text(document.handle, text.handle);
+ if (text.len == 0) return &.{};
+ if (text.data == null) return error.BadText;
+ const source: [*]const u8 = @ptrCast(text.data);
+ return gpa.dupe(u8, source[0..text.len]);
+ }
+
/// Load and flatten the PDF-native outline/bookmarks. MuPDF's temporary
/// tree and the bridge's flat view are both dropped before this returns.
pub fn outline(
@@ -1336,6 +1398,123 @@ pub fn makeNoOutlineTestPdf(gpa: std.mem.Allocator) ![]u8 {
return makeOffsetRotatedPdf(gpa);
}
+fn emitLinksPdf(bytes: *PdfBytes, offsets: []usize, _: void) !void {
+ const stream = "BT /F1 6 Tf 20 240 Td (target.txt) Tj ET\n" ++
+ "BT /F1 6 Tf 20 120 Td (https://example.com/same) Tj ET\n" ++
+ "BT /F1 6 Tf 20 60 Td (label) Tj ET\n";
+ try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
+ try beginPdfObject(bytes, offsets, 1);
+ try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 2);
+ try bytes.appendSlice("<< /Type /Pages /Count 2 /Kids [3 0 R 4 0 R] >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 3);
+ try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R /Annots [7 0 R 8 0 R 9 0 R 10 0 R] >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 4);
+ try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << >> >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 5);
+ try bytes.appendSlice("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 6);
+ try bytes.print("<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ stream.len, stream });
+ try beginPdfObject(bytes, offsets, 7);
+ try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 230 120 260] /Border [0 0 0] /A << /S /URI /URI (https://example.com/embedded) >> >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 8);
+ try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 170 120 200] /Border [0 0 0] /Dest [4 0 R /XYZ 12 34 null] >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 9);
+ try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 110 190 140] /Border [0 0 0] /A << /S /URI /URI (https://example.com/same) >> >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 10);
+ try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 50 120 80] /Border [0 0 0] /A << /S /URI /URI (https://example.com/only) >> >>\nendobj\n");
+ try finishGeneratedPdf(bytes, offsets);
+}
+
+/// TEST-ONLY: external, blank internal, identical-label URI, and link-only URI.
+pub fn makeLinksTestPdf(gpa: std.mem.Allocator) ![]u8 {
+ var offsets: [11]usize = undefined;
+ return allocateGeneratedPdf(gpa, &offsets, {}, emitLinksPdf);
+}
+
+test "PDF links own URIs across page eviction and preserve internal coordinates" {
+ const gpa = std.testing.allocator;
+ start(gpa);
+ defer stop();
+ const fixture = try makeLinksTestPdf(gpa);
+ defer gpa.free(fixture);
+ var document = try Document.openBytes(fixture);
+ defer document.deinit();
+ var external = (try document.linkAt(gpa, 0, .{ .x = 0.3, .y = 0.18 })) orelse return error.NoLink;
+ defer external.deinit(gpa);
+ try std.testing.expectEqualStrings("https://example.com/embedded", external.uri);
+ try std.testing.expectEqualStrings(external.uri, external.destination.external);
+ try std.testing.expectApproxEqAbs(@as(f32, 0.1), external.quad.ul.x, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 40.0 / 300.0), external.quad.ul.y, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 0.6), external.quad.lr.x, 0.0001);
+ const label = try document.copyRectangle(gpa, 0, external.quad);
+ defer gpa.free(label);
+ try std.testing.expectEqualStrings("target.txt", std.mem.trim(u8, label, "\r\n "));
+ var internal = (try document.linkAt(gpa, 0, .{ .x = 0.3, .y = 0.38 })) orelse return error.NoLink;
+ defer internal.deinit(gpa);
+ try std.testing.expectEqual(@as(usize, 1), internal.destination.internal.page);
+ try std.testing.expectApproxEqAbs(@as(f32, 12), internal.destination.internal.x.?, 0.001);
+ try std.testing.expectApproxEqAbs(@as(f32, 266), internal.destination.internal.y.?, 0.001);
+ const blank = try document.copyRectangle(gpa, 0, internal.quad);
+ defer gpa.free(blank);
+ try std.testing.expectEqualStrings("", blank);
+ try std.testing.expect(try document.linkAt(gpa, 1, .{ .x = 0.3, .y = 0.18 }) == null);
+ try std.testing.expectEqualStrings("https://example.com/embedded", external.uri);
+ var again = (try document.linkAt(gpa, 0, .{ .x = 0.3, .y = 0.18 })) orelse return error.NoLink;
+ defer again.deinit(gpa);
+ try std.testing.expectEqualStrings(external.uri, again.uri);
+ var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
+ try std.testing.expectError(error.OutOfMemory, document.linkAt(failing.allocator(), 0, .{ .x = 0.3, .y = 0.18 }));
+ try std.testing.expectError(error.PageOutOfRange, document.linkAt(gpa, 2, .{ .x = 0.3, .y = 0.18 }));
+ for ([_]Point{ .{ .x = -0.1, .y = 0.18 }, .{ .x = 0.3, .y = 1.1 }, .{ .x = std.math.nan(f32), .y = 0.18 } }) |point|
+ try std.testing.expect(try document.linkAt(gpa, 0, point) == null);
+ try std.testing.expect(try document.linkAt(gpa, 0, .{ .x = 0.99, .y = 0.99 }) == null);
+}
+
+fn emitRotatedLinkPdf(bytes: *PdfBytes, offsets: []usize, action: []const u8) !void {
+ try bytes.appendSlice("%PDF-1.7\n");
+ try beginPdfObject(bytes, offsets, 1);
+ try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 2);
+ try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 3);
+ try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [100 200 300 400] /CropBox [120 220 280 370] /Rotate 90 /Resources << >> /Annots [4 0 R] >>\nendobj\n");
+ try beginPdfObject(bytes, offsets, 4);
+ try bytes.print("<< /Type /Annot /Subtype /Link /Rect [120 220 160 270] /Border [0 0 0] {s} >>\nendobj\n", .{action});
+ try finishGeneratedPdf(bytes, offsets);
+}
+
+test "PDF links normalize cropped rotated annotations and ignore unsupported actions" {
+ const gpa = std.testing.allocator;
+ start(gpa);
+ defer stop();
+ const actions = [_][]const u8{
+ "/A << /S /URI /URI (https://example.com/rotated) >>",
+ "/A << /S /URI /URI () >>",
+ "/A << /S /JavaScript /JS (ignored) >>",
+ "/Dest /missing_destination",
+ };
+ for (actions, 0..) |action, index| {
+ var offsets: [5]usize = undefined;
+ const fixture = try allocateGeneratedPdf(gpa, &offsets, action, emitRotatedLinkPdf);
+ defer gpa.free(fixture);
+ var document = try Document.openBytes(fixture);
+ defer document.deinit();
+ var found = try document.linkAt(gpa, 0, .{ .x = 0.1, .y = 0.1 });
+ defer if (found) |*item| item.deinit(gpa);
+ if (index != 0) {
+ try std.testing.expect(found == null);
+ continue;
+ }
+ const link = found orelse return error.NoRotatedLink;
+ try std.testing.expectApproxEqAbs(@as(f32, 0), link.quad.ul.x, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 0), link.quad.ul.y, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 50.0 / 150.0), link.quad.lr.x, 0.0001);
+ try std.testing.expectApproxEqAbs(@as(f32, 40.0 / 160.0), link.quad.lr.y, 0.0001);
+ try std.testing.expect(try document.linkAt(gpa, 0, .{ .x = 0.7, .y = 0.7 }) == null);
+ }
+}
+
fn emitTooDeepOutlinePdf(bytes: *PdfBytes, offsets: []usize, levels: usize) !void {
const first_outline_item = 5;
try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");