From a69cd4a7ef5c4527e3a7cd325d49b6bf445bd810 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 3 Aug 2026 09:16:50 -0300 Subject: expose MuPDF PDF outline metadata --- src/pdf.zig | 372 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) (limited to 'src/pdf.zig') 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 /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, -- cgit v1.3