summaryrefslogtreecommitdiff
path: root/src/pdf.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-03 09:16:50 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commita69cd4a7ef5c4527e3a7cd325d49b6bf445bd810 (patch)
tree6a689cdea0f46ed520e5347fbf18ca7303943ae9 /src/pdf.zig
parenta774cad033fdc371af9f89b9a787a30ea017481c (diff)
downloadpardes-a69cd4a7ef5c4527e3a7cd325d49b6bf445bd810.tar.gz
pardes-a69cd4a7ef5c4527e3a7cd325d49b6bf445bd810.zip
expose MuPDF PDF outline metadata
Diffstat (limited to 'src/pdf.zig')
-rw-r--r--src/pdf.zig372
1 files changed, 372 insertions, 0 deletions
diff --git a/src/pdf.zig b/src/pdf.zig
index 305bb89d..3e07f4bc 100644
--- a/src/pdf.zig
+++ b/src/pdf.zig
@@ -389,6 +389,53 @@ pub const Selection = struct {
}
};
+pub const OutlineInternalDestination = struct {
+ /// Zero-based document page number.
+ page: usize,
+ /// MuPDF page-space viewing coordinates; null when the PDF destination
+ /// omits that axis (for example, a Fit destination omits both).
+ x: ?f32,
+ y: ?f32,
+};
+
+pub const OutlineDestination = union(enum) {
+ none,
+ internal: OutlineInternalDestination,
+ external: []const u8,
+};
+
+pub const OutlineEntry = struct {
+ /// Zero for a root row; rows are in stable pre-order depth-first order.
+ depth: u8,
+ /// Null preserves a missing /Title; a present empty title is "".
+ title: ?[]const u8,
+ is_open: bool,
+ flags: u8,
+ color: [3]u8,
+ destination: OutlineDestination,
+
+ pub fn isBold(entry: OutlineEntry) bool {
+ return entry.flags & 1 != 0;
+ }
+
+ pub fn isItalic(entry: OutlineEntry) bool {
+ return entry.flags & 2 != 0;
+ }
+};
+
+/// All title and external-URI slices point into `bytes`. This fixed two-allocation
+/// representation avoids one allocation per row while keeping deinit deterministic.
+pub const Outline = struct {
+ entries: []OutlineEntry,
+ bytes: []u8,
+
+ pub fn deinit(outline: *Outline, gpa: std.mem.Allocator) void {
+ gpa.free(outline.entries);
+ gpa.free(outline.bytes);
+ outline.* = undefined;
+ }
+};
+
test "oriented selection containment delegates to MuPDF quad geometry" {
const quads = [_]Quad{.{
.ul = .{ .x = 0.10, .y = 0.10 },
@@ -429,6 +476,98 @@ pub const Document = struct {
document.* = undefined;
}
+ /// 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(
+ document: *Document,
+ gpa: std.mem.Allocator,
+ ) !Outline {
+ var raw: c.pardes_pdf_outline_result =
+ std.mem.zeroes(c.pardes_pdf_outline_result);
+ const status = c.pardes_pdf_load_outline(document.handle, &raw);
+ if (status == c.PARDES_PDF_LIMIT_EXCEEDED)
+ return error.OutlineLimitExceeded;
+ if (status != c.PARDES_PDF_OK)
+ return error.OutlineFailed;
+ defer c.pardes_pdf_drop_outline_result(document.handle, raw.handle);
+
+ if (raw.item_count > c.PARDES_PDF_MAX_OUTLINE_ITEMS or
+ raw.bytes_len > c.PARDES_PDF_MAX_OUTLINE_BYTES or
+ (raw.item_count != 0 and raw.items == null) or
+ (raw.bytes_len != 0 and raw.bytes == null))
+ return error.BadOutline;
+
+ const bytes = try gpa.alloc(u8, raw.bytes_len);
+ errdefer gpa.free(bytes);
+ if (raw.bytes_len != 0) {
+ const source: [*]const u8 = @ptrCast(raw.bytes);
+ @memcpy(bytes, source[0..raw.bytes_len]);
+ }
+
+ const entries = try gpa.alloc(OutlineEntry, raw.item_count);
+ errdefer gpa.free(entries);
+ if (raw.item_count != 0) {
+ const source: [*]const c.pardes_pdf_outline_item =
+ @ptrCast(raw.items);
+ var previous_depth: u8 = 0;
+ for (entries, source[0..raw.item_count], 0..) |*entry, item, index| {
+ if (item.depth >= c.PARDES_PDF_MAX_OUTLINE_DEPTH or
+ item.depth > std.math.maxInt(u8) or
+ item.title_present > 1 or item.has_x > 1 or
+ item.has_y > 1 or item.is_open > 1)
+ return error.BadOutline;
+ const depth: u8 = @intCast(item.depth);
+ if ((index == 0 and depth != 0) or
+ (index != 0 and depth > previous_depth + 1))
+ return error.BadOutline;
+ previous_depth = depth;
+
+ const title = if (item.title_present != 0) title: {
+ const value = try outlineBytes(bytes, item.title_offset, item.title_len);
+ if (!std.unicode.utf8ValidateSlice(value))
+ return error.BadOutline;
+ break :title value;
+ } else title: {
+ if (item.title_offset != 0 or item.title_len != 0)
+ return error.BadOutline;
+ break :title null;
+ };
+
+ const destination: OutlineDestination = switch (item.destination_kind) {
+ c.PARDES_PDF_OUTLINE_DESTINATION_NONE => .none,
+ c.PARDES_PDF_OUTLINE_DESTINATION_INTERNAL => internal: {
+ if (item.page < 0 or
+ @as(usize, @intCast(item.page)) >= document.pages or
+ (item.has_x != 0 and !std.math.isFinite(item.x)) or
+ (item.has_y != 0 and !std.math.isFinite(item.y)))
+ return error.BadOutline;
+ break :internal .{ .internal = .{
+ .page = @intCast(item.page),
+ .x = if (item.has_x != 0) item.x else null,
+ .y = if (item.has_y != 0) item.y else null,
+ } };
+ },
+ c.PARDES_PDF_OUTLINE_DESTINATION_EXTERNAL => external: {
+ const uri = try outlineBytes(bytes, item.uri_offset, item.uri_len);
+ if (!std.unicode.utf8ValidateSlice(uri))
+ return error.BadOutline;
+ break :external .{ .external = uri };
+ },
+ else => return error.BadOutline,
+ };
+ entry.* = .{
+ .depth = depth,
+ .title = title,
+ .is_open = item.is_open != 0,
+ .flags = item.flags,
+ .color = .{ item.r, item.g, item.b },
+ .destination = destination,
+ };
+ }
+ }
+ return .{ .entries = entries, .bytes = bytes };
+ }
+
/// Return crop/rotation-aware page dimensions without allocating pixels.
pub fn pageSize(document: *Document, page: usize) !PageSize {
const page_number = try document.checkedPage(page);
@@ -671,6 +810,12 @@ pub const Document = struct {
}
};
+fn outlineBytes(bytes: []const u8, offset: usize, len: usize) ![]const u8 {
+ if (offset > bytes.len or len > bytes.len - offset)
+ return error.BadOutline;
+ return bytes[offset .. offset + len];
+}
+
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;
@@ -736,6 +881,233 @@ fn makeOffsetRotatedPdf(gpa: std.mem.Allocator) ![]u8 {
return bytes.toOwnedSlice(gpa);
}
+fn beginPdfObject(
+ bytes: *std.ArrayList(u8),
+ gpa: std.mem.Allocator,
+ offsets: []usize,
+ number: usize,
+) !void {
+ offsets[number] = bytes.items.len;
+ try bytes.print(gpa, "{d} 0 obj\n", .{number});
+}
+
+fn finishGeneratedPdf(
+ bytes: *std.ArrayList(u8),
+ gpa: std.mem.Allocator,
+ offsets: []const usize,
+) ![]u8 {
+ const xref = bytes.items.len;
+ try bytes.print(gpa, "xref\n0 {d}\n0000000000 65535 f \n", .{offsets.len});
+ for (offsets[1..]) |offset|
+ try bytes.print(gpa, "{d:0>10} 00000 n \n", .{offset});
+ try bytes.print(
+ gpa,
+ "trailer\n<< /Size {d} /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n",
+ .{ offsets.len, xref },
+ );
+ return bytes.toOwnedSlice(gpa);
+}
+
+fn makeOutlinePdf(gpa: std.mem.Allocator) ![]u8 {
+ var bytes: std.ArrayList(u8) = .empty;
+ errdefer bytes.deinit(gpa);
+ var offsets: [12]usize = @splat(0);
+
+ try bytes.appendSlice(gpa, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
+ try beginPdfObject(&bytes, gpa, &offsets, 1);
+ try bytes.appendSlice(gpa, "<< /Type /Catalog /Pages 2 0 R /Outlines 7 0 R /PageMode /UseOutlines >>\nendobj\n");
+ try beginPdfObject(&bytes, gpa, &offsets, 2);
+ try bytes.appendSlice(gpa, "<< /Type /Pages /Count 3 /Kids [3 0 R 4 0 R 5 0 R] >>\nendobj\n");
+ for (3..6) |page| {
+ try beginPdfObject(&bytes, gpa, &offsets, page);
+ try bytes.appendSlice(gpa, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << >> >>\nendobj\n");
+ }
+ try beginPdfObject(&bytes, gpa, &offsets, 6);
+ try bytes.appendSlice(gpa, "<< >>\nendobj\n");
+ try beginPdfObject(&bytes, gpa, &offsets, 7);
+ try bytes.appendSlice(gpa, "<< /Type /Outlines /First 8 0 R /Last 11 0 R /Count 4 >>\nendobj\n");
+ // Destinationless branch with a deliberately missing /Title.
+ try beginPdfObject(&bytes, gpa, &offsets, 8);
+ try bytes.appendSlice(gpa, "<< /Parent 7 0 R /First 9 0 R /Last 9 0 R /Next 10 0 R /Count 1 >>\nendobj\n");
+ // UTF-16BE "Café 子", with bold/italic + color metadata.
+ try beginPdfObject(&bytes, gpa, &offsets, 9);
+ try bytes.appendSlice(gpa, "<< /Title <FEFF00430061006600E900205B50> /Parent 8 0 R " ++
+ "/Dest [4 0 R /XYZ 12 34 null] /F 3 /C [0.2 0.4 0.6] >>\nendobj\n");
+ // A present empty title and a destination with only one usable axis.
+ try beginPdfObject(&bytes, gpa, &offsets, 10);
+ try bytes.appendSlice(gpa, "<< /Title () /Parent 7 0 R /Prev 8 0 R /Next 11 0 R " ++
+ "/Dest [5 0 R /FitH 70] >>\nendobj\n");
+ try beginPdfObject(&bytes, gpa, &offsets, 11);
+ try bytes.appendSlice(gpa, "<< /Title (External) /Parent 7 0 R /Prev 10 0 R " ++
+ "/A << /S /URI /URI (https://example.com/manual) >> >>\nendobj\n");
+
+ return finishGeneratedPdf(&bytes, gpa, &offsets);
+}
+
+fn makeTooDeepOutlinePdf(gpa: std.mem.Allocator) ![]u8 {
+ const levels = c.PARDES_PDF_MAX_OUTLINE_DEPTH + 1;
+ const first_outline_item = 5;
+ const object_count = first_outline_item + levels;
+ const offsets = try gpa.alloc(usize, object_count);
+ defer gpa.free(offsets);
+ @memset(offsets, 0);
+ var bytes: std.ArrayList(u8) = .empty;
+ errdefer bytes.deinit(gpa);
+
+ try bytes.appendSlice(gpa, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
+ try beginPdfObject(&bytes, gpa, offsets, 1);
+ try bytes.appendSlice(gpa, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>\nendobj\n");
+ try beginPdfObject(&bytes, gpa, offsets, 2);
+ try bytes.appendSlice(gpa, "<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n");
+ try beginPdfObject(&bytes, gpa, offsets, 3);
+ try bytes.appendSlice(gpa, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> >>\nendobj\n");
+ try beginPdfObject(&bytes, gpa, offsets, 4);
+ try bytes.print(gpa, "<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count {d} >>\nendobj\n", .{levels});
+
+ for (0..levels) |level| {
+ const number = first_outline_item + level;
+ try beginPdfObject(&bytes, gpa, offsets, number);
+ try bytes.print(gpa, "<< /Title (Level {d}) /Parent {d} 0 R", .{
+ level,
+ if (level == 0) 4 else number - 1,
+ });
+ if (level + 1 < levels) {
+ try bytes.print(gpa, " /First {d} 0 R /Last {d} 0 R /Count {d}", .{
+ number + 1,
+ number + 1,
+ levels - level - 1,
+ });
+ }
+ try bytes.appendSlice(gpa, " >>\nendobj\n");
+ }
+
+ return finishGeneratedPdf(&bytes, gpa, offsets);
+}
+
+test "PDF outline is a stable owned DFS view with native destinations" {
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try makeOutlinePdf(std.testing.allocator);
+ defer std.testing.allocator.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{
+ .sub_path = "outline.pdf",
+ .data = fixture,
+ });
+ var path_buffer: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(
+ &path_buffer,
+ ".zig-cache/tmp/{s}/outline.pdf",
+ .{tmp.sub_path},
+ );
+
+ var document = try Document.open(path);
+ defer document.deinit();
+ try std.testing.expectEqual(@as(usize, 3), document.pages);
+ for (0..2) |fail_index| {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{
+ .fail_index = fail_index,
+ });
+ try std.testing.expectError(
+ error.OutOfMemory,
+ document.outline(failing.allocator()),
+ );
+ }
+ for (0..2) |_| {
+ var outline = try document.outline(std.testing.allocator);
+ defer outline.deinit(std.testing.allocator);
+ try std.testing.expectEqual(@as(usize, 4), outline.entries.len);
+ try std.testing.expectEqualSlices(u8, &.{ 0, 1, 0, 0 }, &.{
+ outline.entries[0].depth,
+ outline.entries[1].depth,
+ outline.entries[2].depth,
+ outline.entries[3].depth,
+ });
+
+ try std.testing.expect(outline.entries[0].title == null);
+ try std.testing.expect(outline.entries[0].is_open);
+ try std.testing.expect(outline.entries[0].destination == .none);
+ try std.testing.expectEqualStrings("Café 子", outline.entries[1].title.?);
+ try std.testing.expect(outline.entries[1].isBold());
+ try std.testing.expect(outline.entries[1].isItalic());
+ try std.testing.expectEqual([3]u8{ 51, 102, 153 }, outline.entries[1].color);
+ const child = outline.entries[1].destination.internal;
+ try std.testing.expectEqual(@as(usize, 1), child.page);
+ try std.testing.expect(child.x != null and child.y != null);
+ try std.testing.expectApproxEqAbs(@as(f32, 12), child.x.?, 0.01);
+
+ try std.testing.expectEqualStrings("", outline.entries[2].title.?);
+ const fitted = outline.entries[2].destination.internal;
+ try std.testing.expectEqual(@as(usize, 2), fitted.page);
+ try std.testing.expect(fitted.x == null);
+ try std.testing.expect(fitted.y != null);
+
+ try std.testing.expectEqualStrings("External", outline.entries[3].title.?);
+ try std.testing.expectEqualStrings(
+ "https://example.com/manual",
+ outline.entries[3].destination.external,
+ );
+ }
+}
+
+test "PDF outline absence and hostile depth are atomic and repeatable" {
+ var plain_tmp = std.testing.tmpDir(.{});
+ defer plain_tmp.cleanup();
+ const plain_fixture = try makeOffsetRotatedPdf(std.testing.allocator);
+ defer std.testing.allocator.free(plain_fixture);
+ try plain_tmp.dir.writeFile(std.testing.io, .{
+ .sub_path = "no-outline.pdf",
+ .data = plain_fixture,
+ });
+ var plain_path_buffer: [256]u8 = undefined;
+ const plain_path = try std.fmt.bufPrint(
+ &plain_path_buffer,
+ ".zig-cache/tmp/{s}/no-outline.pdf",
+ .{plain_tmp.sub_path},
+ );
+ var plain_document = try Document.open(plain_path);
+ defer plain_document.deinit();
+ var absent = try plain_document.outline(std.testing.allocator);
+ defer absent.deinit(std.testing.allocator);
+ try std.testing.expectEqual(@as(usize, 0), absent.entries.len);
+ try std.testing.expectEqual(@as(usize, 0), absent.bytes.len);
+
+ var deep_tmp = std.testing.tmpDir(.{});
+ defer deep_tmp.cleanup();
+ const deep_fixture = try makeTooDeepOutlinePdf(std.testing.allocator);
+ defer std.testing.allocator.free(deep_fixture);
+ try deep_tmp.dir.writeFile(std.testing.io, .{
+ .sub_path = "too-deep-outline.pdf",
+ .data = deep_fixture,
+ });
+ var deep_path_buffer: [256]u8 = undefined;
+ const deep_path = try std.fmt.bufPrint(
+ &deep_path_buffer,
+ ".zig-cache/tmp/{s}/too-deep-outline.pdf",
+ .{deep_tmp.sub_path},
+ );
+ var deep_document = try Document.open(deep_path);
+ defer deep_document.deinit();
+ var raw: c.pardes_pdf_outline_result = undefined;
+ @memset(std.mem.asBytes(&raw), 0xa5);
+ try std.testing.expectEqual(
+ c.PARDES_PDF_LIMIT_EXCEEDED,
+ c.pardes_pdf_load_outline(deep_document.handle, &raw),
+ );
+ try std.testing.expect(raw.handle == null);
+ try std.testing.expect(raw.items == null);
+ try std.testing.expectEqual(@as(usize, 0), raw.item_count);
+ try std.testing.expect(raw.bytes == null);
+ try std.testing.expectEqual(@as(usize, 0), raw.bytes_len);
+ for (0..2) |_|
+ try std.testing.expectError(
+ error.OutlineLimitExceeded,
+ deep_document.outline(std.testing.allocator),
+ );
+ const size = try deep_document.pageSize(0);
+ try std.testing.expectEqual(@as(f32, 100), size.width);
+ try std.testing.expectEqual(@as(f32, 100), size.height);
+}
+
test "caller-owned RGBA layout is packed and overflow checked" {
const layout = try checkedRasterLayout(.{
.width = 2,