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