diff options
| -rw-r--r-- | src/pardes.zig | 31 | ||||
| -rw-r--r-- | src/pdf.zig | 95 | ||||
| -rw-r--r-- | src/pdf_bridge.h | 2 | ||||
| -rw-r--r-- | test/pdf_sections_bench.zig | 2 |
4 files changed, 120 insertions, 10 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 324a9603..471817f7 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1259,9 +1259,9 @@ test "PDF section rows preserve DFS ordinals and sanitise hierarchy" { const rows = try PdfSectionRows.render(std.testing.allocator, "/tmp/manual.pdf", &entries); defer std.testing.allocator.free(rows); try std.testing.expectEqualStrings( - "/tmp/manual.pdf:3:1 [untitled]\n" ++ - "/tmp/manual.pdf:3:2 Child Title Café Next\n" ++ - "/tmp/manual.pdf:1:3 [empty title]\n" ++ + "manual.pdf:3:1 [untitled]\n" ++ + "manual.pdf:3:2 Child Title Café Next\n" ++ + "manual.pdf:1:3 [empty title]\n" ++ "https://example.com/manual External\n" ++ "https://example.com/deep Linked branch\n" ++ "https://example.com/deep Deep link\n", @@ -1373,7 +1373,7 @@ test "PdfSections output, Look, and n/N share exact cached outline destinations" "{s}:2:2 Café 子\n" ++ "{s}:3:3 [empty title]\n" ++ "https://example.com/manual External\n", - .{ path, path, path }, + .{ "outline.pdf", "outline.pdf", "outline.pdf" }, ); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, output.file.?.content); @@ -3229,7 +3229,7 @@ pub const PdfSectionRows = if (pdf_enabled) struct { /// Resolve every rendered row in one DFS pass. `resolve` above stays the /// public single-ordinal policy used by Look; bulk materialisation avoids /// rescanning the same descendant chain for every structural ancestor. - /// The one-usize-per-entry table is transient (32 KiB at MuPDF's 4096 + /// The one-usize-per-entry table is transient (64 KiB at MuPDF's 8192 /// outline-item limit) and stores source ordinals, so URI slices continue /// to borrow from the document-owned outline instead of being copied. fn resolveOrdinals( @@ -3310,6 +3310,10 @@ pub const PdfSectionRows = if (pdf_enabled) struct { path: []const u8, entries: []const pdf_impl.OutlineEntry, ) ![]u8 { + // The output pane's directory is the PDF's directory, so a basename + // resolves to the same document while leaving room for the actual + // chapter title. An absolute prefix can consume the entire picker. + const target = std.fs.path.basename(path); const ordinals = try resolveOrdinals(gpa, entries); defer gpa.free(ordinals); var out: std.ArrayList(u8) = .empty; @@ -3320,7 +3324,7 @@ pub const PdfSectionRows = if (pdf_enabled) struct { const destination = usableDestination(entries[resolved].destination) orelse unreachable; switch (destination) { .internal => |internal| try out.print(gpa, "{s}:{d}:{d} ", .{ - path, + target, internal.page + 1, ordinal + 1, }), @@ -10552,17 +10556,28 @@ pub const Pardes = struct { if (at.line == 0 or at.col == 0 or at.end_line != 0 or !look.isPdfPath(path)) return false; + // Internal section rows use the basename so their titles remain + // visible. Resolve that token from +PdfSections' document directory + // before matching an open PDF or opening it again. + var joined_path: [4096]u8 = undefined; + const output_dir = paneDir(output); + const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/"; + const target_path = if (std.fs.path.isAbsolute(path)) + path + else + std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true; + var target_id: ?usize = null; for (p.panes, 0..) |slot, i| { const candidate = slot orelse continue; - if (candidate.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) { + if (candidate.pdf) |pv| if (std.mem.eql(u8, pv.path, target_path)) { target_id = i; break; }; } if (target_id == null) { const free = p.freeSlot() orelse return true; - const fresh = p.openPdfView(free, path, 0) catch return true; + const fresh = p.openPdfView(free, target_path, 0) catch return true; p.placeDoc(output_id, free, fresh); p.computeGeom(); target_id = free; diff --git a/src/pdf.zig b/src/pdf.zig index 4a671315..84d6a4e5 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -28,6 +28,8 @@ pub const max_owned_raster_bytes: usize = @as(usize, absolute_max_render_dimensi /// MuPDF's per-document eviction cache ceiling. The owned RGBA render buffer /// is allocated separately by Pardes and does not count against this store. pub const store_limit_bytes: usize = c.PARDES_PDF_STORE_LIMIT_BYTES; +/// Maximum native outline rows accepted atomically from one document. +pub const max_outline_items: usize = c.PARDES_PDF_MAX_OUTLINE_ITEMS; test "MuPDF document cache has an explicit conservative ceiling" { try std.testing.expectEqual(@as(usize, 64 * 1024 * 1024), store_limit_bytes); @@ -996,6 +998,43 @@ fn makeTooDeepOutlinePdf(gpa: std.mem.Allocator) ![]u8 { return finishGeneratedPdf(&bytes, gpa, offsets); } +fn makeWideOutlinePdf(gpa: std.mem.Allocator, items: usize) ![]u8 { + if (items == 0) return error.EmptyOutlineFixture; + const first_outline_item = 5; + const object_count = try std.math.add(usize, first_outline_item, items); + 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 {d} 0 R /Count {d} >>\nendobj\n", .{ + first_outline_item + items - 1, + items, + }); + + for (0..items) |ordinal| { + const number = first_outline_item + ordinal; + try beginPdfObject(&bytes, gpa, offsets, number); + try bytes.print(gpa, "<< /Title (Section {d}) /Parent 4 0 R /Dest [3 0 R /Fit]", .{ordinal}); + if (ordinal != 0) + try bytes.print(gpa, " /Prev {d} 0 R", .{number - 1}); + if (ordinal + 1 < items) + try bytes.print(gpa, " /Next {d} 0 R", .{number + 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(); @@ -1061,6 +1100,62 @@ test "PDF outline is a stable owned DFS view with native destinations" { } } +test "PDF outline accepts manual-sized trees and rejects item overflow atomically" { + const manual_items = 4105; + try std.testing.expect(max_outline_items >= manual_items); + + var manual_tmp = std.testing.tmpDir(.{}); + defer manual_tmp.cleanup(); + const manual_fixture = try makeWideOutlinePdf(std.testing.allocator, manual_items); + defer std.testing.allocator.free(manual_fixture); + try manual_tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "manual-outline.pdf", + .data = manual_fixture, + }); + var manual_path_buffer: [256]u8 = undefined; + const manual_path = try std.fmt.bufPrint( + &manual_path_buffer, + ".zig-cache/tmp/{s}/manual-outline.pdf", + .{manual_tmp.sub_path}, + ); + var manual_document = try Document.open(manual_path); + defer manual_document.deinit(); + var outline = try manual_document.outline(std.testing.allocator); + defer outline.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, manual_items), outline.entries.len); + try std.testing.expectEqualStrings("Section 0", outline.entries[0].title.?); + try std.testing.expectEqualStrings("Section 4104", outline.entries[manual_items - 1].title.?); + try std.testing.expectEqual(@as(usize, 0), outline.entries[manual_items - 1].destination.internal.page); + + var overflow_tmp = std.testing.tmpDir(.{}); + defer overflow_tmp.cleanup(); + const overflow_fixture = try makeWideOutlinePdf(std.testing.allocator, max_outline_items + 1); + defer std.testing.allocator.free(overflow_fixture); + try overflow_tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "overflow-outline.pdf", + .data = overflow_fixture, + }); + var overflow_path_buffer: [256]u8 = undefined; + const overflow_path = try std.fmt.bufPrint( + &overflow_path_buffer, + ".zig-cache/tmp/{s}/overflow-outline.pdf", + .{overflow_tmp.sub_path}, + ); + var overflow_document = try Document.open(overflow_path); + defer overflow_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(overflow_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); +} + test "PDF outline absence and hostile depth are atomic and repeatable" { var plain_tmp = std.testing.tmpDir(.{}); defer plain_tmp.cleanup(); diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h index c85873e1..bda1eea1 100644 --- a/src/pdf_bridge.h +++ b/src/pdf_bridge.h @@ -145,7 +145,7 @@ enum { 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_ITEMS = 8192, PARDES_PDF_MAX_OUTLINE_DEPTH = 64, PARDES_PDF_MAX_OUTLINE_BYTES = 4 * 1024 * 1024 }; diff --git a/test/pdf_sections_bench.zig b/test/pdf_sections_bench.zig index bb3fc827..21327c9f 100644 --- a/test/pdf_sections_bench.zig +++ b/test/pdf_sections_bench.zig @@ -19,7 +19,7 @@ pub const std_options: std.Options = .{ .log_level = .err }; const gpa = std.heap.smp_allocator; const cached_batch = 128; const step_batch = 256; -const near_limit_items = 4096; +const near_limit_items = pdf.max_outline_items; const near_limit_depth = 64; const Config = struct { |
