summaryrefslogtreecommitdiff
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
parenta774cad033fdc371af9f89b9a787a30ea017481c (diff)
downloadpardes-a69cd4a7ef5c4527e3a7cd325d49b6bf445bd810.tar.gz
pardes-a69cd4a7ef5c4527e3a7cd325d49b6bf445bd810.zip
expose MuPDF PDF outline metadata
-rw-r--r--src/pdf.zig372
-rw-r--r--src/pdf_bridge.c298
-rw-r--r--src/pdf_bridge.h60
3 files changed, 729 insertions, 1 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,
diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c
index 202ef4f7..e0969018 100644
--- a/src/pdf_bridge.c
+++ b/src/pdf_bridge.c
@@ -859,3 +859,301 @@ pardes_pdf_drop_owned_text(pardes_pdf_document *document, void *text)
if (document != NULL && text != NULL)
fz_free(document->ctx, text);
}
+
+typedef struct pardes_pdf_outline_measurement {
+ size_t item_count;
+ size_t bytes_len;
+ int status;
+} pardes_pdf_outline_measurement;
+
+static int
+pardes_pdf_outline_destination(const fz_outline *node, fz_context *ctx)
+{
+ if (node->page.chapter >= 0 && node->page.page >= 0)
+ return PARDES_PDF_OUTLINE_DESTINATION_INTERNAL;
+ if (node->page.chapter >= 0 || node->page.page >= 0)
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "PDF outline has a partially resolved destination");
+ if (node->uri != NULL && fz_is_external_link(ctx, node->uri))
+ return PARDES_PDF_OUTLINE_DESTINATION_EXTERNAL;
+ return PARDES_PDF_OUTLINE_DESTINATION_NONE;
+}
+
+static size_t
+pardes_pdf_bounded_outline_string(
+ fz_context *ctx,
+ const char *value,
+ size_t remaining,
+ int *status)
+{
+ size_t len;
+
+ if (value == NULL)
+ return 0;
+ len = strnlen(value, remaining + 1);
+ if (len > remaining) {
+ *status = PARDES_PDF_LIMIT_EXCEEDED;
+ fz_throw(ctx, FZ_ERROR_LIMIT,
+ "PDF outline metadata byte limit exceeded");
+ }
+ return len;
+}
+
+static void
+pardes_pdf_measure_outline(
+ fz_context *ctx,
+ const fz_outline *node,
+ unsigned int depth,
+ pardes_pdf_outline_measurement *measurement)
+{
+ for (; node != NULL; node = node->next) {
+ size_t remaining;
+ int destination;
+
+ if (depth >= PARDES_PDF_MAX_OUTLINE_DEPTH ||
+ measurement->item_count >= PARDES_PDF_MAX_OUTLINE_ITEMS) {
+ measurement->status = PARDES_PDF_LIMIT_EXCEEDED;
+ fz_throw(ctx, FZ_ERROR_LIMIT, "PDF outline limit exceeded");
+ }
+ ++measurement->item_count;
+
+ remaining = PARDES_PDF_MAX_OUTLINE_BYTES - measurement->bytes_len;
+ measurement->bytes_len += pardes_pdf_bounded_outline_string(
+ ctx, node->title, remaining, &measurement->status);
+
+ destination = pardes_pdf_outline_destination(node, ctx);
+ if (destination == PARDES_PDF_OUTLINE_DESTINATION_EXTERNAL) {
+ remaining = PARDES_PDF_MAX_OUTLINE_BYTES -
+ measurement->bytes_len;
+ measurement->bytes_len += pardes_pdf_bounded_outline_string(
+ ctx, node->uri, remaining, &measurement->status);
+ }
+
+ if (node->down != NULL)
+ pardes_pdf_measure_outline(
+ ctx, node->down, depth + 1, measurement);
+ }
+}
+
+static void
+pardes_pdf_flatten_outline(
+ pardes_pdf_document *document,
+ const fz_outline *node,
+ unsigned int depth,
+ pardes_pdf_outline_item *items,
+ unsigned char *bytes,
+ size_t item_count,
+ size_t bytes_len,
+ size_t *item_at,
+ size_t *byte_at)
+{
+ fz_context *ctx = document->ctx;
+
+ for (; node != NULL; node = node->next) {
+ pardes_pdf_outline_item *item;
+ int destination;
+ size_t len;
+
+ if (*item_at >= item_count)
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "PDF outline changed while flattening");
+ item = &items[(*item_at)++];
+ memset(item, 0, sizeof(*item));
+ item->depth = depth;
+ item->page = -1;
+ item->is_open = node->is_open != 0;
+ item->flags = (unsigned char)node->flags;
+ item->r = (unsigned char)node->r;
+ item->g = (unsigned char)node->g;
+ item->b = (unsigned char)node->b;
+
+ if (node->title != NULL) {
+ len = strlen(node->title);
+ if (*byte_at > bytes_len || len > bytes_len - *byte_at)
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "PDF outline title changed while flattening");
+ item->title_present = 1;
+ item->title_offset = *byte_at;
+ item->title_len = len;
+ memcpy(bytes + *byte_at, node->title, len);
+ *byte_at += len;
+ }
+
+ destination = pardes_pdf_outline_destination(node, ctx);
+ item->destination_kind = destination;
+ if (destination == PARDES_PDF_OUTLINE_DESTINATION_INTERNAL) {
+ item->page = fz_page_number_from_location(
+ ctx, document->doc, node->page);
+ if (item->page < 0 || item->page >= document->page_count)
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "PDF outline resolved outside the document");
+ item->has_x = isfinite(node->x);
+ item->has_y = isfinite(node->y);
+ if (item->has_x)
+ item->x = node->x;
+ if (item->has_y)
+ item->y = node->y;
+ } else if (destination == PARDES_PDF_OUTLINE_DESTINATION_EXTERNAL) {
+ len = strlen(node->uri);
+ if (*byte_at > bytes_len || len > bytes_len - *byte_at)
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "PDF outline URI changed while flattening");
+ item->uri_offset = *byte_at;
+ item->uri_len = len;
+ memcpy(bytes + *byte_at, node->uri, len);
+ *byte_at += len;
+ }
+
+ if (node->down != NULL)
+ pardes_pdf_flatten_outline(document, node->down, depth + 1,
+ items, bytes, item_count, bytes_len, item_at, byte_at);
+ }
+}
+
+static unsigned char
+pardes_pdf_outline_color(fz_context *ctx, float value)
+{
+ if (!isfinite(value) || value < 0.0f || value > 255.0f)
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "PDF outline has an invalid style color");
+ return (unsigned char)(value + 0.5f);
+}
+
+/*
+ * MuPDF 1.27's generic fz_load_outline adapter does not copy the iterator's
+ * style fields into fz_outline. Overlay them from a second, allocation-light
+ * iterator pass while retaining the loaded tree as the destination authority.
+ */
+static void
+pardes_pdf_apply_outline_styles(
+ fz_context *ctx,
+ fz_outline_iterator *iterator,
+ unsigned int depth,
+ pardes_pdf_outline_item *items,
+ size_t item_count,
+ size_t *item_at)
+{
+ int moved;
+
+ do {
+ fz_outline_item *style = fz_outline_iterator_item(ctx, iterator);
+ pardes_pdf_outline_item *item;
+
+ if (style == NULL)
+ return;
+ if (*item_at >= item_count || items[*item_at].depth != depth ||
+ style->flags < 0 || style->flags > 127)
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "PDF outline changed while reading styles");
+ item = &items[(*item_at)++];
+ item->flags = (unsigned char)style->flags;
+ item->r = pardes_pdf_outline_color(ctx, style->r);
+ item->g = pardes_pdf_outline_color(ctx, style->g);
+ item->b = pardes_pdf_outline_color(ctx, style->b);
+
+ moved = fz_outline_iterator_down(ctx, iterator);
+ if (moved == FZ_OUTLINE_ITERATOR_AT_ITEM)
+ pardes_pdf_apply_outline_styles(ctx, iterator, depth + 1,
+ items, item_count, item_at);
+ if (moved >= 0)
+ (void)fz_outline_iterator_up(ctx, iterator);
+ } while (fz_outline_iterator_next(ctx, iterator) ==
+ FZ_OUTLINE_ITERATOR_AT_ITEM);
+}
+
+int
+pardes_pdf_load_outline(
+ pardes_pdf_document *document,
+ pardes_pdf_outline_result *out)
+{
+ fz_context *ctx;
+ fz_outline *outline = NULL;
+ fz_outline_iterator *iterator = NULL;
+ unsigned char *block = NULL;
+ pardes_pdf_outline_measurement measurement = {0};
+ size_t item_bytes;
+ size_t total_bytes;
+ size_t item_at = 0;
+ size_t byte_at = 0;
+ size_t style_at = 0;
+ int status = PARDES_PDF_ERROR;
+
+ if (document == NULL || out == NULL)
+ return PARDES_PDF_ERROR;
+ memset(out, 0, sizeof(*out));
+ ctx = document->ctx;
+ measurement.status = PARDES_PDF_ERROR;
+
+ fz_var(outline);
+ fz_var(iterator);
+ fz_var(block);
+ fz_var(measurement);
+ fz_var(status);
+ fz_try(ctx)
+ {
+ outline = fz_load_outline(ctx, document->doc);
+ pardes_pdf_measure_outline(ctx, outline, 0, &measurement);
+ if (measurement.item_count >
+ SIZE_MAX / sizeof(pardes_pdf_outline_item) ||
+ measurement.bytes_len > SIZE_MAX - measurement.item_count *
+ sizeof(pardes_pdf_outline_item)) {
+ measurement.status = PARDES_PDF_LIMIT_EXCEEDED;
+ fz_throw(ctx, FZ_ERROR_LIMIT,
+ "PDF outline allocation size overflow");
+ }
+ item_bytes = measurement.item_count *
+ sizeof(pardes_pdf_outline_item);
+ total_bytes = item_bytes + measurement.bytes_len;
+ if (total_bytes != 0) {
+ block = fz_malloc(ctx, total_bytes);
+ pardes_pdf_flatten_outline(document, outline, 0,
+ (pardes_pdf_outline_item *)block, block + item_bytes,
+ measurement.item_count, measurement.bytes_len,
+ &item_at, &byte_at);
+ if (item_at != measurement.item_count ||
+ byte_at != measurement.bytes_len)
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "PDF outline changed while flattening");
+ iterator = fz_new_outline_iterator(ctx, document->doc);
+ if (iterator != NULL) {
+ pardes_pdf_apply_outline_styles(ctx, iterator, 0,
+ (pardes_pdf_outline_item *)block,
+ measurement.item_count, &style_at);
+ if (style_at != measurement.item_count)
+ fz_throw(ctx, FZ_ERROR_FORMAT,
+ "PDF outline changed while reading styles");
+ }
+ }
+ status = PARDES_PDF_OK;
+ }
+ fz_always(ctx)
+ {
+ fz_drop_outline_iterator(ctx, iterator);
+ fz_drop_outline(ctx, outline);
+ }
+ fz_catch(ctx)
+ {
+ if (measurement.status != PARDES_PDF_LIMIT_EXCEEDED)
+ fz_report_error(ctx);
+ fz_free(ctx, block);
+ memset(out, 0, sizeof(*out));
+ return measurement.status == PARDES_PDF_LIMIT_EXCEEDED ?
+ PARDES_PDF_LIMIT_EXCEEDED : PARDES_PDF_ERROR;
+ }
+
+ out->handle = block;
+ out->items = (const pardes_pdf_outline_item *)block;
+ out->item_count = measurement.item_count;
+ out->bytes = block == NULL ? NULL : block + item_bytes;
+ out->bytes_len = measurement.bytes_len;
+ return status;
+}
+
+void
+pardes_pdf_drop_outline_result(
+ pardes_pdf_document *document,
+ void *outline)
+{
+ if (document != NULL && outline != NULL)
+ fz_free(document->ctx, outline);
+}
diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h
index 17323a29..c85873e1 100644
--- a/src/pdf_bridge.h
+++ b/src/pdf_bridge.h
@@ -88,6 +88,46 @@ typedef struct pardes_pdf_owned_text {
size_t len;
} pardes_pdf_owned_text;
+typedef enum pardes_pdf_outline_destination_kind {
+ PARDES_PDF_OUTLINE_DESTINATION_NONE = 0,
+ PARDES_PDF_OUTLINE_DESTINATION_INTERNAL = 1,
+ PARDES_PDF_OUTLINE_DESTINATION_EXTERNAL = 2
+} pardes_pdf_outline_destination_kind;
+
+/*
+ * One pre-order depth-first row. Offsets address the result's byte arena;
+ * title_present distinguishes a missing title from a present empty title.
+ * Internal page numbers are zero-based. has_x/has_y preserve MuPDF's NAN
+ * sentinel for viewing coordinates that the outline destination omits.
+ */
+typedef struct pardes_pdf_outline_item {
+ size_t title_offset;
+ size_t title_len;
+ size_t uri_offset;
+ size_t uri_len;
+ unsigned int depth;
+ int page;
+ float x;
+ float y;
+ unsigned char title_present;
+ unsigned char has_x;
+ unsigned char has_y;
+ unsigned char is_open;
+ unsigned char flags;
+ unsigned char r;
+ unsigned char g;
+ unsigned char b;
+ int destination_kind;
+} pardes_pdf_outline_item;
+
+typedef struct pardes_pdf_outline_result {
+ void *handle;
+ const pardes_pdf_outline_item *items;
+ size_t item_count;
+ const unsigned char *bytes;
+ size_t bytes_len;
+} pardes_pdf_outline_result;
+
enum {
PARDES_PDF_OK = 0,
PARDES_PDF_ERROR = -1,
@@ -102,7 +142,12 @@ enum {
* truncated result. Selection uses one extra private probe slot so an
* exact-at-the-limit result remains distinguishable from truncation.
*/
- PARDES_PDF_MAX_RESULT_QUADS = 65536
+ PARDES_PDF_MAX_RESULT_QUADS = 65536,
+
+ /* Outline conversion is atomic: crossing any bound returns no rows. */
+ PARDES_PDF_MAX_OUTLINE_ITEMS = 4096,
+ PARDES_PDF_MAX_OUTLINE_DEPTH = 64,
+ PARDES_PDF_MAX_OUTLINE_BYTES = 4 * 1024 * 1024
};
pardes_pdf_document *pardes_pdf_open(const char *path, int *page_count);
@@ -194,6 +239,19 @@ int pardes_pdf_copy_selection(
);
void pardes_pdf_drop_owned_text(pardes_pdf_document *document, void *text);
+/*
+ * Flatten fz_load_outline's tree in stable document-order DFS. The borrowed
+ * table and byte arena remain valid until drop_outline_result.
+ */
+int pardes_pdf_load_outline(
+ pardes_pdf_document *document,
+ pardes_pdf_outline_result *out
+);
+void pardes_pdf_drop_outline_result(
+ pardes_pdf_document *document,
+ void *outline
+);
+
#ifdef __cplusplus
}
#endif