summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/pardes.zig31
-rw-r--r--src/pdf.zig95
-rw-r--r--src/pdf_bridge.h2
3 files changed, 119 insertions, 9 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
};