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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/pdf_bridge.c | 298 ++++++++++++++++++++++++++++++++++++++++++++ src/pdf_bridge.h | 60 ++++++++- 3 files changed, 729 insertions(+), 1 deletion(-) (limited to 'src') 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, 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 -- cgit v1.3