summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig28
-rw-r--r--src/pardes.zig178
-rw-r--r--test/image_harness.zig93
-rw-r--r--test/pdf_sections_bench.zig550
4 files changed, 809 insertions, 40 deletions
diff --git a/build.zig b/build.zig
index ff2dddfc..4957adb4 100644
--- a/build.zig
+++ b/build.zig
@@ -51,10 +51,13 @@ pub fn build(b: *std.Build) void {
const web_step = b.step("web", "build the DOM browser shell into zig-out/web (-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>)");
const mupdf_check = b.step("mupdf-check", "compile, link, render, and search docs/design.pdf with MuPDF");
const pdf_bench_step = b.step("pdf-bench", "benchmark real MuPDF page rendering (-- [--json] [--reps N] [--warmup N] [--path FILE] [--page N] [--pages N])");
+ const pdf_sections_bench_step = b.step("pdf-sections-bench", "benchmark PDF outline/sections paths in ReleaseFast (-Doptimize=ReleaseFast -- [--json] [--reps N] [--warmup N])");
if (!enable_mupdf)
mupdf_check.dependOn(&b.addFail("mupdf-check is unavailable with -Dmupdf=false").step);
if (!enable_mupdf)
pdf_bench_step.dependOn(&b.addFail("pdf-bench is unavailable with -Dmupdf=false").step);
+ if (!enable_mupdf)
+ pdf_sections_bench_step.dependOn(&b.addFail("pdf-sections-bench is unavailable with -Dmupdf=false").step);
// Web LOOK has a tiny read-only source filesystem. Generate one Zig module
// on the host from every tracked .zig path + its current working-tree
@@ -408,6 +411,29 @@ pub fn build(b: *std.Build) void {
if (b.args) |args| run_pdf_bench.addArgs(args);
run_pdf_bench.setCwd(b.path("."));
pdf_bench_step.dependOn(&run_pdf_bench.step);
+
+ // Unlike the raster-only scoreboard above, this one drives the
+ // real core so cached +PdfSections reopen and n/N include their
+ // production state transitions. Pass -Doptimize=ReleaseFast so
+ // hx_core_mod and its MuPDF wrapper share the executable's mode.
+ const pdf_sections_bench = b.addExecutable(.{
+ .name = "pardes-pdf-sections-bench",
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = .ReleaseFast,
+ .root_source_file = b.path("test/pdf_sections_bench.zig"),
+ .link_libc = true,
+ }),
+ });
+ pdf_sections_bench.root_module.addImport("pardes", hx_core_mod);
+ pdf_sections_bench.root_module.addImport("mupdf", mupdf_mod);
+ const sections_bench_opts = b.addOptions();
+ sections_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast);
+ pdf_sections_bench.root_module.addOptions("pdf_sections_bench_config", sections_bench_opts);
+ const run_pdf_sections_bench = b.addRunArtifact(pdf_sections_bench);
+ if (b.args) |args| run_pdf_sections_bench.addArgs(args);
+ run_pdf_sections_bench.setCwd(b.path("."));
+ pdf_sections_bench_step.dependOn(&run_pdf_sections_bench.step);
}
}
@@ -666,7 +692,7 @@ pub fn build(b: *std.Build) void {
// searchable two-page document. Keep it a distinct opt-in step: the
// ordinary image harness stays identical when MuPDF is disabled, and
// asking for PDF coverage without the feature gets an explicit error.
- const pdf_harness_step = b.step("pdf-harness", "exercise PDF rendering, page replacement, and search Look end to end");
+ const pdf_harness_step = b.step("pdf-harness", "exercise continuous PDF rendering, search, and sections navigation end to end");
if (enable_mupdf) {
const run_pdf_harness = b.addRunArtifact(image_harness);
run_pdf_harness.addArtifactArg(exe);
diff --git a/src/pardes.zig b/src/pardes.zig
index 7243cf37..d490c6f3 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1271,6 +1271,67 @@ test "PDF section rows preserve DFS ordinals and sanitise hierarchy" {
);
try std.testing.expect(PdfSectionRows.resolve(&entries, 4) == null);
try std.testing.expect(PdfSectionRows.resolve(&entries, 5) == null);
+ const resolved = try PdfSectionRows.resolveOrdinals(std.testing.allocator, &entries);
+ defer std.testing.allocator.free(resolved);
+ for (entries, 0..) |_, ordinal| {
+ const single = PdfSectionRows.resolve(&entries, ordinal);
+ if (resolved[ordinal] == std.math.maxInt(usize)) {
+ try std.testing.expect(single == null);
+ } else {
+ const bulk = PdfSectionRows.usableDestination(entries[resolved[ordinal]].destination) orelse
+ return error.MissingBulkPdfSectionDestination;
+ try std.testing.expect(single != null);
+ try std.testing.expect(std.meta.eql(single.?, bulk));
+ }
+ }
+
+ // Every allocation site in the ordinal table and output growth remains
+ // atomic: the testing allocator sees each induced failure cleaned up
+ // before the first index at which the whole render can succeed.
+ var rendered = false;
+ for (0..64) |fail_index| {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{
+ .fail_index = fail_index,
+ });
+ const failure_gpa = failing.allocator();
+ const attempt = PdfSectionRows.render(failure_gpa, "/tmp/manual.pdf", &entries) catch |err| {
+ try std.testing.expectEqual(error.OutOfMemory, err);
+ continue;
+ };
+ failure_gpa.free(attempt);
+ rendered = true;
+ break;
+ }
+ try std.testing.expect(rendered);
+}
+
+test "bulk PDF section resolution matches single Look policy across DFS boundaries" {
+ if (!pdf_enabled) return;
+ const E = pdf_impl.OutlineEntry;
+ const entries = [_]E{
+ .{ .depth = 0, .title = "Resolved root", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = "Unsafe branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:unsafe" } },
+ .{ .depth = 2, .title = "Safe grandchild", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 1, .x = 4, .y = 8 } } },
+ .{ .depth = 1, .title = "Unresolved child", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 1, .title = "Sibling", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = null, .y = null } } },
+ .{ .depth = 0, .title = "Unresolved root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
+ .{ .depth = 0, .title = "Next root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 3, .x = null, .y = null } } },
+ };
+ const bulk = try PdfSectionRows.resolveOrdinals(std.testing.allocator, &entries);
+ defer std.testing.allocator.free(bulk);
+ const none = std.math.maxInt(usize);
+ try std.testing.expectEqualSlices(usize, &.{ 2, none, 2, none, 4, none, 6 }, bulk);
+ for (entries, 0..) |_, ordinal| {
+ const single = PdfSectionRows.resolve(&entries, ordinal);
+ if (bulk[ordinal] == none) {
+ try std.testing.expect(single == null);
+ } else {
+ const destination = PdfSectionRows.usableDestination(entries[bulk[ordinal]].destination) orelse
+ return error.MissingBoundaryPdfSectionDestination;
+ try std.testing.expect(single != null);
+ try std.testing.expect(std.meta.eql(single.?, destination));
+ }
+ }
}
test "PdfSections output, Look, and n/N share exact cached outline destinations" {
@@ -1330,6 +1391,19 @@ test "PdfSections output, Look, and n/N share exact cached outline destinations"
try std.testing.expectEqual(output_revision, p.panes[output_id].?.file.?.revision);
try std.testing.expectEqual(outline_ptr, pdf_pane.pdf.?.outline.?.entries.ptr);
+ // The clean remembered path is a pure re-arm: even an allocator which
+ // rejects its first request is never consulted, and the outline/content
+ // identities remain untouched.
+ var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
+ const ordinary_gpa = p.gpa;
+ p.gpa = failing.allocator();
+ p.openPdfSections(0);
+ p.gpa = ordinary_gpa;
+ try std.testing.expectEqual(@as(usize, 0), failing.alloc_index);
+ try std.testing.expect(!failing.has_induced_failure);
+ try std.testing.expectEqual(outline_ptr, pdf_pane.pdf.?.outline.?.entries.ptr);
+ try std.testing.expectEqual(output_revision, p.panes[output_id].?.file.?.revision);
+
// n and N execute the generated row through ordinary Look. The root and
// child intentionally resolve to the same exact XYZ destination.
p.active = 0;
@@ -3116,8 +3190,8 @@ const PdfView = if (pdf_enabled) struct {
/// Pure outline-to-row policy. Keeping destination resolution and title
/// sanitisation together makes the ordinal written into a row exactly the
/// ordinal Look later resolves; neither path invents a second flattened tree.
-const PdfSectionRows = if (pdf_enabled) struct {
- fn usableDestination(destination: pdf_impl.OutlineDestination) ?pdf_impl.OutlineDestination {
+pub const PdfSectionRows = if (pdf_enabled) struct {
+ pub fn usableDestination(destination: pdf_impl.OutlineDestination) ?pdf_impl.OutlineDestination {
return switch (destination) {
.internal => destination,
.external => |uri| if (safeHttpUri(uri)) destination else null,
@@ -3144,7 +3218,7 @@ const PdfSectionRows = if (pdf_enabled) struct {
/// A structural node goes to the first later DFS entry still below it
/// which carries a usable destination. Nodes with their own unusable URI
/// are not structural: omit them instead of silently changing their link.
- fn resolve(entries: []const pdf_impl.OutlineEntry, ordinal: usize) ?pdf_impl.OutlineDestination {
+ pub fn resolve(entries: []const pdf_impl.OutlineEntry, ordinal: usize) ?pdf_impl.OutlineDestination {
if (ordinal >= entries.len) return null;
const entry = entries[ordinal];
if (entry.destination != .none) return usableDestination(entry.destination);
@@ -3154,6 +3228,43 @@ const PdfSectionRows = if (pdf_enabled) struct {
return null;
}
+ /// 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
+ /// outline-item limit) and stores source ordinals, so URI slices continue
+ /// to borrow from the document-owned outline instead of being copied.
+ fn resolveOrdinals(
+ gpa: std.mem.Allocator,
+ entries: []const pdf_impl.OutlineEntry,
+ ) ![]usize {
+ const unresolved = std.math.maxInt(usize);
+ const ordinals = try gpa.alloc(usize, entries.len);
+ @memset(ordinals, unresolved);
+
+ const Pending = struct { depth: u8, ordinal: usize };
+ // OutlineEntry.depth is u8. A valid DFS path therefore cannot hold
+ // more than 256 simultaneously unresolved ancestors, independent of
+ // the tighter limit enforced by the MuPDF bridge.
+ var pending: [256]Pending = undefined;
+ var pending_len: usize = 0;
+ for (entries, 0..) |entry, ordinal| {
+ while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth)
+ pending_len -= 1;
+
+ if (usableDestination(entry.destination) != null) {
+ ordinals[ordinal] = ordinal;
+ for (pending[0..pending_len]) |ancestor|
+ ordinals[ancestor.ordinal] = ordinal;
+ pending_len = 0;
+ } else if (entry.destination == .none) {
+ pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal };
+ pending_len += 1;
+ }
+ }
+ return ordinals;
+ }
+
fn appendTitle(
out: *std.ArrayList(u8),
gpa: std.mem.Allocator,
@@ -3196,15 +3307,19 @@ const PdfSectionRows = if (pdf_enabled) struct {
if (!wrote) try out.appendSlice(gpa, "[empty title]");
}
- fn render(
+ pub fn render(
gpa: std.mem.Allocator,
path: []const u8,
entries: []const pdf_impl.OutlineEntry,
) ![]u8 {
+ const ordinals = try resolveOrdinals(gpa, entries);
+ defer gpa.free(ordinals);
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(gpa);
for (entries, 0..) |entry, ordinal| {
- const destination = resolve(entries, ordinal) orelse continue;
+ const resolved = ordinals[ordinal];
+ if (resolved == std.math.maxInt(usize)) continue;
+ const destination = usableDestination(entries[resolved].destination) orelse unreachable;
switch (destination) {
.internal => |internal| try out.print(gpa, "{s}:{d}:{d} ", .{
path,
@@ -3214,7 +3329,7 @@ const PdfSectionRows = if (pdf_enabled) struct {
.external => |uri| try out.print(gpa, "{s} ", .{uri}),
.none => unreachable,
}
- for (0..entry.depth) |_| try out.appendSlice(gpa, " ");
+ try out.appendNTimes(gpa, ' ', @as(usize, entry.depth) * 2);
try appendTitle(&out, gpa, entry.title);
try out.append(gpa, '\n');
}
@@ -9522,6 +9637,31 @@ pub const Pardes = struct {
};
}
+ /// Re-arm a remembered, unedited +PdfSections pane without consulting
+ /// MuPDF or rebuilding bytes which cannot have changed while its owning
+ /// document remains open. Serial + generated revision are the two cache
+ /// guards: a closed/reused slot or user edit falls through to a fresh
+ /// materialisation below.
+ fn rearmCleanPdfSections(p: *Pardes, id: usize, pane: *Pane, pv: *PdfView) bool {
+ const remembered = pv.sections_output orelse return false;
+ if (remembered.pane >= p.panes.len) return false;
+ const result = p.panes[remembered.pane] orelse return false;
+ if (result.serial != remembered.serial or !isPdfSectionsOutput(result)) return false;
+ const file = &result.file.?;
+ if (file.revision != remembered.revision) return false;
+
+ file.scroll = 0;
+ result.cur_row = 0;
+ result.cur_col = 0;
+ result.msel.active = false;
+ result.vsel.active = false;
+ result.nsel = 0;
+ pane.search_pane = remembered.pane;
+ pane.search_row = null;
+ p.active = id;
+ return true;
+ }
+
/// Lazily materialise this pane's cached MuPDF outline as a location list.
/// A clean live output is refreshed in place; an edited, closed, or reused
/// slot is never overwritten. Focus and n/N ownership stay on the PDF.
@@ -9529,37 +9669,13 @@ pub const Pardes = struct {
if (comptime pdf_enabled) {
const pane = p.panes[id] orelse return;
const pv = &(pane.pdf orelse return);
+ if (p.rearmCleanPdfSections(id, pane, pv)) return;
const entries: []const pdf_impl.OutlineEntry = if (p.ensurePdfOutline(pv)) |outline|
outline.entries
else
&.{};
const content = PdfSectionRows.render(p.gpa, pv.path, entries) catch return;
- if (pv.sections_output) |remembered| {
- if (remembered.pane < p.panes.len) if (p.panes[remembered.pane]) |result| {
- if (result.serial == remembered.serial and isPdfSectionsOutput(result)) {
- const file = &result.file.?;
- if (file.revision == remembered.revision) {
- if (std.mem.eql(u8, file.content, content))
- p.gpa.free(content)
- else
- file_pane.setContent(p, file, content);
- pv.sections_output.?.revision = file.revision;
- file.scroll = 0;
- result.cur_row = 0;
- result.cur_col = 0;
- result.msel.active = false;
- result.vsel.active = false;
- result.nsel = 0;
- pane.search_pane = remembered.pane;
- pane.search_row = null;
- p.active = id;
- return;
- }
- }
- };
- }
-
const free = p.freeSlot() orelse {
p.gpa.free(content);
return;
diff --git a/test/image_harness.zig b/test/image_harness.zig
index 4b1c527c..73142459 100644
--- a/test/image_harness.zig
+++ b/test/image_harness.zig
@@ -126,11 +126,11 @@ fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void {
;
var out: std.ArrayList(u8) = .empty;
- var offsets: [12]usize = @splat(0);
+ var offsets: [16]usize = @splat(0);
try out.appendSlice(arena, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n");
offsets[1] = out.items.len;
- try out.appendSlice(arena, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
+ try out.appendSlice(arena, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Outlines 12 0 R /PageMode /UseOutlines >>\nendobj\n");
offsets[2] = out.items.len;
try out.appendSlice(arena, "2 0 obj\n<< /Type /Pages /Count 4 /Kids [3 0 R 5 0 R 7 0 R 9 0 R] >>\nendobj\n");
offsets[3] = out.items.len;
@@ -151,12 +151,20 @@ fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void {
try appendFmt(arena, &out, "10 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ fourth_stream.len, fourth_stream });
offsets[11] = out.items.len;
try out.appendSlice(arena, "11 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
+ offsets[12] = out.items.len;
+ try out.appendSlice(arena, "12 0 obj\n<< /Type /Outlines /First 13 0 R /Last 15 0 R /Count 3 >>\nendobj\n");
+ offsets[13] = out.items.len;
+ try out.appendSlice(arena, "13 0 obj\n<< /Title (Upper) /Parent 12 0 R /First 14 0 R /Last 14 0 R /Next 15 0 R /Count 1 /Dest [3 0 R /XYZ 4 8 null] >>\nendobj\n");
+ offsets[14] = out.items.len;
+ try out.appendSlice(arena, "14 0 obj\n<< /Title (Lower) /Parent 13 0 R /Dest [3 0 R /XYZ 4 28 null] >>\nendobj\n");
+ offsets[15] = out.items.len;
+ try out.appendSlice(arena, "15 0 obj\n<< /Title (Page two) /Parent 12 0 R /Prev 13 0 R /Dest [5 0 R /XYZ 4 38 null] >>\nendobj\n");
const xref = out.items.len;
- try out.appendSlice(arena, "xref\n0 12\n0000000000 65535 f \n");
+ try out.appendSlice(arena, "xref\n0 16\n0000000000 65535 f \n");
for (offsets[1..]) |offset|
try appendFmt(arena, &out, "{d:0>10} 00000 n \n", .{offset});
- try appendFmt(arena, &out, "trailer\n<< /Size 12 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref});
+ try appendFmt(arena, &out, "trailer\n<< /Size 16 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref});
try eh.writeFile(path, out.items);
}
@@ -213,8 +221,12 @@ fn runKitty(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !vo
fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void {
_ = unsetenv("PARDES_TEST");
_ = unsetenv("PARDES_TEST_CAPTURE_DIR");
- const pdf_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.pdf", .{base}, 0);
+ _ = base;
+ // Keep the generated location token short enough that the 38-column PDF
+ // pane can visibly include each outline title after `path:page:ordinal`.
+ const pdf_path = try std.fmt.allocPrintSentinel(arena, "/tmp/p{d}.pdf", .{libc.getpid()}, 0);
try writePdf(arena, pdf_path);
+ defer _ = libc.unlink(pdf_path);
var h = try eh.Harness.initArgs(gpa, exe, 16, 80, pdf_path);
defer h.deinit();
@@ -308,7 +320,8 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
try h.send("\x02");
try h.expectWaitFor("pdf 1/4 width PdfFit", 5000, "continuous page-up did not return to PDF page one");
try h.send("/SECOND\r");
- try h.expectWaitFor("pages.pdf:2:1 SECOND", 5000, "PDF search did not preserve the exact page-two query occurrence");
+ const search_row = try std.fmt.allocPrint(arena, "{s}:2:1 SECOND", .{std.fs.path.basename(pdf_path)});
+ try h.expectWaitFor(search_row, 5000, "PDF search did not preserve the exact page-two query occurrence");
const before_look = std.mem.count(u8, h.raw.items, transmit);
try h.send("n");
const look_deadline = eh.nowMs() + 5000;
@@ -337,7 +350,47 @@ fn runKittyPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8)
if (std.mem.count(u8, h.raw.items, transmit) != before_fit)
return error.KittyPdfFitRetransmitted;
- std.debug.print("PDF harness ok: Kitty continuous boundary + exact cache + search/fit\n", .{});
+ // The generated PDF carries a real nested outline. PdfSections must show
+ // all three DFS rows in its output pane while leaving n/N ownership on
+ // the PDF. The first two destinations share page one but have distinct
+ // page-space y coordinates; their placement APCs must differ while the
+ // second reveal reuses exactly the rasters made visible by the first.
+ try h.send(" ts");
+ try h.expectWaitFor("Upper", 5000, "PdfSections did not expose the root outline row");
+ try h.expectContains("Lower", "PdfSections did not expose the nested outline row");
+ try h.expectContains("Page two", "PdfSections did not expose the other-page outline row");
+
+ const upper_start = h.raw.items.len;
+ try h.send("n");
+ try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "first PdfSections n did not reveal page one");
+ const upper_deadline = eh.nowMs() + 3000;
+ while (eh.nowMs() < upper_deadline and kittyPlacementApc(h.raw.items, upper_start) == null)
+ _ = try h.pumpOnce(50);
+ const upper_apc = kittyPlacementApc(h.raw.items, upper_start) orelse
+ return error.KittyPdfSectionsUpperPlacementMissing;
+ const upper_copy = try arena.dupe(u8, upper_apc);
+ try h.pump(150);
+ const same_page_transmits = std.mem.count(u8, h.raw.items, "\x1b_Gf=32,");
+
+ const lower_start = h.raw.items.len;
+ try h.send("n");
+ try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "nested PdfSections n left page one");
+ const lower_deadline = eh.nowMs() + 3000;
+ while (eh.nowMs() < lower_deadline and kittyPlacementApc(h.raw.items, lower_start) == null)
+ _ = try h.pumpOnce(50);
+ const lower_apc = kittyPlacementApc(h.raw.items, lower_start) orelse
+ return error.KittyPdfSectionsLowerPlacementMissing;
+ if (std.mem.eql(u8, upper_copy, lower_apc))
+ return error.KittyPdfSectionsSamePagePlacementUnchanged;
+ if (std.mem.count(u8, h.raw.items, "\x1b_Gf=32,") != same_page_transmits)
+ return error.KittyPdfSectionsSamePageRetransmitted;
+
+ try h.send("n");
+ try h.expectWaitFor("pdf 2/4 height PdfFit", 5000, "third PdfSections n did not reveal page two");
+ try h.send("N");
+ try h.expectWaitFor("pdf 1/4 height PdfFit", 5000, "PdfSections N did not return to the nested page-one row");
+
+ std.debug.print("PDF harness ok: Kitty continuous cache + search/fit + nested PdfSections n/N\n", .{});
}
fn kittyPlacementApc(raw: []const u8, start: usize) ?[]const u8 {
@@ -465,7 +518,31 @@ fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !v
defer gpa.free(final_capture);
const final_pixels = ppmPixels(final_capture) orelse return error.BadCapture;
if (countColor(final_pixels, pdf_red) != 0) return error.SdlPdfOffscreenPageStillDrawn;
- std.debug.print("PDF harness ok: SDL continuous strip + theme gap + red->green ({d}->{d} pixels)\n", .{ fit_width.count, green.count });
+
+ // Exercise the actual PdfSections output + Look stepping path in SDL too.
+ // The first two nested outline rows stay on red page one at different y
+ // positions, the third moves to green page two, and N returns exactly to
+ // the nested page-one destination.
+ _ = libc.unlink(capture_path);
+ try h.send("gg");
+ _ = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000);
+ _ = libc.unlink(capture_path);
+ try h.send(" ts");
+ _ = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000);
+ _ = libc.unlink(capture_path);
+ try h.send("n");
+ _ = try waitForCaptureBounds(&h, capture_path, pdf_red, 200, 5000);
+ _ = libc.unlink(capture_path);
+ try h.send("n");
+ _ = try waitForCaptureBounds(&h, capture_path, pdf_red, 100, 5000);
+ _ = libc.unlink(capture_path);
+ try h.send("n");
+ _ = try waitForCaptureBounds(&h, capture_path, pdf_green, 200, 5000);
+ _ = libc.unlink(capture_path);
+ try h.send("N");
+ _ = try waitForCaptureBounds(&h, capture_path, pdf_red, 100, 5000);
+
+ std.debug.print("PDF harness ok: SDL continuous strip + nested PdfSections red->green->red ({d}->{d} pixels)\n", .{ fit_width.count, green.count });
}
const ColorBounds = struct {
diff --git a/test/pdf_sections_bench.zig b/test/pdf_sections_bench.zig
new file mode 100644
index 00000000..bb3fc827
--- /dev/null
+++ b/test/pdf_sections_bench.zig
@@ -0,0 +1,550 @@
+//! ReleaseFast scoreboard for the PDF outline -> +PdfSections path.
+//!
+//! zig build pdf-sections-bench -Doptimize=ReleaseFast
+//! zig build pdf-sections-bench -Doptimize=ReleaseFast -- --json
+//! zig build pdf-sections-bench -Doptimize=ReleaseFast -- --warmup 5 --reps 31
+//!
+//! Setup, destruction, semantic checksums, and reporting are outside each
+//! timer. The two interactive scenarios batch tiny real-core operations and
+//! report nanoseconds per operation; `operations_per_sample` makes that
+//! normalization explicit in both output formats.
+const std = @import("std");
+const libc = std.c;
+const pardes = @import("pardes");
+const pdf = @import("mupdf");
+const bench_config = @import("pdf_sections_bench_config");
+
+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_depth = 64;
+
+const Config = struct {
+ json: bool = false,
+ warmup: usize = 5,
+ reps: usize = 31,
+};
+
+const Identity = struct {
+ items: u64,
+ bytes: u64,
+ checksum: u64,
+
+ fn eql(a: Identity, b: Identity) bool {
+ return a.items == b.items and a.bytes == b.bytes and
+ a.checksum == b.checksum;
+ }
+};
+
+const Sample = struct {
+ ns: u64,
+ identity: Identity,
+};
+
+const Result = struct {
+ name: []const u8,
+ operations_per_sample: usize,
+ min_ns: u64,
+ median_ns: u64,
+ p90_ns: u64,
+ max_ns: u64,
+ identity: Identity,
+};
+
+pub fn main(init: std.process.Init) !void {
+ if (!bench_config.release_fast_core)
+ fatal("the production core must be ReleaseFast; add -Doptimize=ReleaseFast", .{});
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ const config = parseArgs(args);
+
+ const fixture = try pdf.makeOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, "/tmp/pardes-pdf-sections-bench-{d}.pdf", .{libc.getpid()});
+ try writeFile(path, fixture);
+ defer unlink(path);
+
+ var document = try pdf.Document.open(path);
+ defer document.deinit();
+ var outline = try document.outline(gpa);
+ defer outline.deinit(gpa);
+
+ const synthetic = try makeWorstShape();
+ defer gpa.free(synthetic);
+
+ const results = [_]Result{
+ try measureOutline(path, config),
+ try measureMaterialize(path, outline.entries, config),
+ try measureCachedControl(path, config),
+ try measureCachedReopen(path, config),
+ try measureOrdinalStep(path, config),
+ try measureWorstShape(synthetic, config),
+ };
+
+ if (config.json)
+ reportJson(init.io, config, &results)
+ else
+ reportText(config, &results);
+}
+
+fn parseArgs(args: []const []const u8) Config {
+ var config: Config = .{};
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ if (std.mem.eql(u8, args[i], "--json")) {
+ config.json = true;
+ } else if (std.mem.eql(u8, args[i], "--warmup") and i + 1 < args.len) {
+ i += 1;
+ config.warmup = parseCount("--warmup", args[i]);
+ } else if (std.mem.eql(u8, args[i], "--reps") and i + 1 < args.len) {
+ i += 1;
+ config.reps = parseCount("--reps", args[i]);
+ } else fatal("usage: pardes-pdf-sections-bench [--json] [--warmup N] [--reps N]", .{});
+ }
+ return config;
+}
+
+fn parseCount(flag: []const u8, text: []const u8) usize {
+ const value = std.fmt.parseUnsigned(usize, text, 10) catch
+ fatal("{s} expects a positive integer", .{flag});
+ if (value == 0 or value > 10_000)
+ fatal("{s} must be in 1..10000", .{flag});
+ return value;
+}
+
+fn measureOutline(path: []const u8, config: Config) !Result {
+ var samples: std.ArrayList(u64) = .empty;
+ defer samples.deinit(gpa);
+ var expected: ?Identity = null;
+ for (0..config.warmup + config.reps) |round| {
+ // Document open/close is setup: this row isolates MuPDF outline load,
+ // bridge flattening, and the Zig-owned two-allocation copy.
+ var document = try pdf.Document.open(path);
+ defer document.deinit();
+ const started = nowNs();
+ var outline = try document.outline(gpa);
+ const elapsed = nowNs() -| started;
+ const identity = outlineIdentity(outline.entries);
+ outline.deinit(gpa);
+ try verifyIdentity("outline-load-flatten-real", &expected, identity, round);
+ if (round >= config.warmup) try samples.append(gpa, @max(1, elapsed));
+ }
+ return summarize("outline-load-flatten-real", 1, samples.items, expected.?);
+}
+
+fn measureMaterialize(
+ path: []const u8,
+ entries: []const pdf.OutlineEntry,
+ config: Config,
+) !Result {
+ var samples: std.ArrayList(u64) = .empty;
+ defer samples.deinit(gpa);
+ var expected: ?Identity = null;
+ for (0..config.warmup + config.reps) |round| {
+ const started = nowNs();
+ const content = try pardes.PdfSectionRows.render(gpa, path, entries);
+ const elapsed = nowNs() -| started;
+ const identity = contentIdentity(content);
+ gpa.free(content);
+ try verifyIdentity("sections-first-materialize", &expected, identity, round);
+ if (round >= config.warmup) try samples.append(gpa, @max(1, elapsed));
+ }
+ return summarize("sections-first-materialize", 1, samples.items, expected.?);
+}
+
+fn measureCachedReopen(path: []const u8, config: Config) !Result {
+ const core = try preparedCore(path);
+ defer core.deinit();
+ const prepared = preparedReopen(core);
+ var samples: std.ArrayList(u64) = .empty;
+ defer samples.deinit(gpa);
+ var expected: ?Identity = null;
+ for (0..config.warmup + config.reps) |round| {
+ var observed: u64 = 0;
+ const started = nowNs();
+ for (0..cached_batch) |operation| {
+ perturbReopen(prepared, operation);
+ // Without the barriers ReleaseFast can observe that every call
+ // leaves the same final fields and coalesce the whole batch into
+ // one re-arm. The empty asm emits no instruction; its portable
+ // memory clobber makes each production call's reads and writes
+ // independently observable to the optimizer.
+ compilerBarrier();
+ core.openPdfSections(0);
+ compilerBarrier();
+ observed +%= observeReopen(prepared);
+ }
+ const elapsed = nowNs() -| started;
+ var identity = sectionsOutputIdentity(core);
+ identity.checksum = mix(identity.checksum, observed);
+ try verifyIdentity("sections-clean-cached-reopen", &expected, identity, round);
+ if (round >= config.warmup)
+ try samples.append(gpa, @max(1, elapsed / cached_batch));
+ }
+ return summarize("sections-clean-cached-reopen", cached_batch, samples.items, expected.?);
+}
+
+/// Cost floor for the anti-elision workload above. This performs the same
+/// varying state writes, barriers, and observation but deliberately omits the
+/// core call. Rearming once after the timer keeps fixture state canonical.
+fn measureCachedControl(path: []const u8, config: Config) !Result {
+ const core = try preparedCore(path);
+ defer core.deinit();
+ const prepared = preparedReopen(core);
+ var samples: std.ArrayList(u64) = .empty;
+ defer samples.deinit(gpa);
+ var expected: ?Identity = null;
+ for (0..config.warmup + config.reps) |round| {
+ var observed: u64 = 0;
+ const started = nowNs();
+ for (0..cached_batch) |operation| {
+ perturbReopen(prepared, operation);
+ compilerBarrier();
+ compilerBarrier();
+ observed +%= observeReopen(prepared);
+ }
+ const elapsed = nowNs() -| started;
+ core.openPdfSections(0);
+ var identity = sectionsOutputIdentity(core);
+ identity.checksum = mix(identity.checksum, observed);
+ try verifyIdentity("sections-perturb-observe-control", &expected, identity, round);
+ if (round >= config.warmup)
+ try samples.append(gpa, @max(1, elapsed / cached_batch));
+ }
+ return summarize("sections-perturb-observe-control", cached_batch, samples.items, expected.?);
+}
+
+fn measureOrdinalStep(path: []const u8, config: Config) !Result {
+ const core = try preparedCore(path);
+ defer core.deinit();
+ const owner = core.panes[0].?;
+ var samples: std.ArrayList(u64) = .empty;
+ defer samples.deinit(gpa);
+ var expected: ?Identity = null;
+ for (0..config.warmup + config.reps) |round| {
+ owner.search_row = null;
+ core.active = 0;
+ // Untimed first step parks on row zero. Timed n/N then alternates the
+ // root and child, which share a page but carry exact y placement.
+ core.update(.{ .key = .{ .cp = 'n', .text = "n" } });
+ const started = nowNs();
+ for (0..step_batch) |step| {
+ compilerBarrier();
+ core.update(.{ .key = .{
+ .cp = if (step & 1 == 0) 'n' else 'N',
+ .text = if (step & 1 == 0) "n" else "N",
+ } });
+ compilerBarrier();
+ }
+ const elapsed = nowNs() -| started;
+ drainEffects(core);
+ const identity = stepIdentity(core);
+ try verifyIdentity("sections-ordinal-destination-step", &expected, identity, round);
+ if (round >= config.warmup)
+ try samples.append(gpa, @max(1, elapsed / step_batch));
+ }
+ return summarize("sections-ordinal-destination-step", step_batch, samples.items, expected.?);
+}
+
+fn measureWorstShape(entries: []const pdf.OutlineEntry, config: Config) !Result {
+ var samples: std.ArrayList(u64) = .empty;
+ defer samples.deinit(gpa);
+ var expected: ?Identity = null;
+ for (0..config.warmup + config.reps) |round| {
+ const started = nowNs();
+ const content = try pardes.PdfSectionRows.render(gpa, "/tmp/near-limit.pdf", entries);
+ const elapsed = nowNs() -| started;
+ const identity = contentIdentity(content);
+ gpa.free(content);
+ try verifyIdentity("rows-near-limit-worst-shape", &expected, identity, round);
+ if (round >= config.warmup) try samples.append(gpa, @max(1, elapsed));
+ }
+ return summarize("rows-near-limit-worst-shape", 1, samples.items, expected.?);
+}
+
+fn preparedCore(path: []const u8) !*pardes.Pardes {
+ const core = try pardes.Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ errdefer core.deinit();
+ drainEffects(core);
+ core.openPdfSections(0);
+ drainEffects(core);
+ _ = sectionsOutputIdentity(core);
+ return core;
+}
+
+const PreparedReopen = struct {
+ core: *pardes.Pardes,
+ owner: *pardes.Pane,
+ output: *pardes.Pane,
+ output_id: usize,
+};
+
+fn preparedReopen(core: *pardes.Pardes) PreparedReopen {
+ const owner = core.panes[0] orelse fatal("PDF pane disappeared", .{});
+ const output_id = owner.search_pane orelse fatal("+PdfSections was not armed", .{});
+ return .{
+ .core = core,
+ .owner = owner,
+ .output = core.panes[output_id] orelse fatal("+PdfSections pane disappeared", .{}),
+ .output_id = output_id,
+ };
+}
+
+/// The output bytes and generated revision stay clean. Only transient UI
+/// state varies, so every invocation has observable reset work while still
+/// exercising the production clean-output cache guard.
+fn perturbReopen(prepared: PreparedReopen, operation: usize) void {
+ const marker: i32 = @intCast(operation % 31 + 1);
+ const output_file = &prepared.output.file.?;
+ output_file.scroll = @intCast(operation % 17 + 1);
+ prepared.output.cur_row = marker;
+ prepared.output.cur_col = marker + 1;
+ prepared.output.msel.active = true;
+ prepared.output.vsel.active = true;
+ prepared.output.nsel = 1;
+ prepared.owner.search_pane = null;
+ prepared.owner.search_row = operation % 23;
+ prepared.core.active = prepared.output_id;
+}
+
+fn observeReopen(prepared: PreparedReopen) u64 {
+ const file = prepared.output.file.?;
+ var observed: u64 = file.scroll;
+ observed = observed *% 131 +% @as(u64, @intCast(@max(0, prepared.output.cur_row)));
+ observed = observed *% 131 +% @as(u64, @intCast(@max(0, prepared.output.cur_col)));
+ observed = observed *% 131 +% @intFromBool(prepared.output.msel.active);
+ observed = observed *% 131 +% @intFromBool(prepared.output.vsel.active);
+ observed = observed *% 131 +% prepared.output.nsel;
+ observed = observed *% 131 +% (prepared.owner.search_pane orelse std.math.maxInt(usize));
+ observed = observed *% 131 +% (prepared.owner.search_row orelse std.math.maxInt(usize));
+ observed = observed *% 131 +% prepared.core.active;
+ return observed;
+}
+
+fn drainEffects(core: *pardes.Pardes) void {
+ while (core.nextEffect()) |_| {}
+}
+
+fn sectionsOutputIdentity(core: *pardes.Pardes) Identity {
+ const owner = core.panes[0] orelse fatal("PDF pane disappeared", .{});
+ const output_id = owner.search_pane orelse fatal("+PdfSections was not armed", .{});
+ const output = core.panes[output_id] orelse fatal("+PdfSections pane disappeared", .{});
+ const file = output.file orelse fatal("+PdfSections has no file", .{});
+ var identity = contentIdentity(file.content);
+ identity.checksum = mix(identity.checksum, output.serial);
+ identity.checksum = mix(identity.checksum, file.revision);
+ identity.checksum = mix(identity.checksum, output_id);
+ return identity;
+}
+
+fn stepIdentity(core: *pardes.Pardes) Identity {
+ var identity = sectionsOutputIdentity(core);
+ const owner = core.panes[0].?;
+ const view = owner.pdf.?;
+ identity.checksum = mix(identity.checksum, view.page);
+ identity.checksum = mix(identity.checksum, @as(u64, @bitCast(view.document_scroll_y)));
+ identity.checksum = mix(identity.checksum, owner.search_row orelse std.math.maxInt(usize));
+ identity.checksum = mix(identity.checksum, view.render_revision);
+ return identity;
+}
+
+fn contentIdentity(content: []const u8) Identity {
+ return .{
+ .items = std.mem.count(u8, content, "\n"),
+ .bytes = content.len,
+ .checksum = std.hash.Wyhash.hash(0, content),
+ };
+}
+
+fn outlineIdentity(entries: []const pdf.OutlineEntry) Identity {
+ var checksum: u64 = 0;
+ var bytes: u64 = 0;
+ for (entries) |entry| {
+ checksum = mix(checksum, entry.depth);
+ checksum = mix(checksum, entry.flags);
+ checksum = mix(checksum, @intFromBool(entry.is_open));
+ checksum = std.hash.Wyhash.hash(checksum, &entry.color);
+ if (entry.title) |title| {
+ bytes += title.len;
+ checksum = std.hash.Wyhash.hash(checksum, title);
+ } else checksum = mix(checksum, std.math.maxInt(u64));
+ switch (entry.destination) {
+ .none => checksum = mix(checksum, 0),
+ .internal => |destination| {
+ checksum = mix(checksum, 1);
+ checksum = mix(checksum, destination.page);
+ checksum = mix(checksum, if (destination.x) |x| @as(u32, @bitCast(x)) else std.math.maxInt(u32));
+ checksum = mix(checksum, if (destination.y) |y| @as(u32, @bitCast(y)) else std.math.maxInt(u32));
+ },
+ .external => |uri| {
+ bytes += uri.len;
+ checksum = mix(checksum, 2);
+ checksum = std.hash.Wyhash.hash(checksum, uri);
+ },
+ }
+ }
+ return .{ .items = entries.len, .bytes = bytes, .checksum = checksum };
+}
+
+fn mix(seed: u64, value: anytype) u64 {
+ const Value = @TypeOf(value);
+ const Stable = switch (@typeInfo(Value)) {
+ .comptime_int => u64,
+ else => Value,
+ };
+ var stable: Stable = value;
+ return std.hash.Wyhash.hash(seed, std.mem.asBytes(&stable));
+}
+
+fn makeWorstShape() ![]pdf.OutlineEntry {
+ const entries = try gpa.alloc(pdf.OutlineEntry, near_limit_items);
+ for (entries, 0..) |*entry, ordinal| {
+ const depth: u8 = @intCast(ordinal % near_limit_depth);
+ entry.* = .{
+ .depth = depth,
+ .title = "Worst shape outline title",
+ .is_open = true,
+ .flags = 0,
+ .color = .{ 0, 0, 0 },
+ .destination = if (depth + 1 == near_limit_depth)
+ .{ .internal = .{
+ .page = ordinal / near_limit_depth,
+ .x = 11.25,
+ .y = 47.5,
+ } }
+ else
+ .none,
+ };
+ }
+ return entries;
+}
+
+fn verifyIdentity(
+ name: []const u8,
+ expected: *?Identity,
+ got: Identity,
+ round: usize,
+) !void {
+ if (expected.*) |want| {
+ if (!want.eql(got)) {
+ std.debug.print("pdf-sections-bench: unstable identity in {s} round {d}: expected {x:0>16}, got {x:0>16}\n", .{
+ name, round, want.checksum, got.checksum,
+ });
+ return error.UnstableIdentity;
+ }
+ } else expected.* = got;
+}
+
+fn summarize(
+ name: []const u8,
+ operations_per_sample: usize,
+ samples: []u64,
+ identity: Identity,
+) Result {
+ std.debug.assert(samples.len != 0);
+ std.mem.sort(u64, samples, {}, std.sort.asc(u64));
+ return .{
+ .name = name,
+ .operations_per_sample = operations_per_sample,
+ .min_ns = samples[0],
+ .median_ns = samples[samples.len / 2],
+ .p90_ns = samples[(samples.len * 9) / 10 -| 1],
+ .max_ns = samples[samples.len - 1],
+ .identity = identity,
+ };
+}
+
+fn nowNs() u64 {
+ var ts: std.c.timespec = undefined;
+ _ = std.c.clock_gettime(.MONOTONIC, &ts);
+ return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +|
+ @as(u64, @intCast(ts.nsec));
+}
+
+/// A host benchmark compiler barrier. MuPDF benchmarks are build-and-run
+/// steps, so they never target the freestanding web backend; an empty volatile
+/// asm with only the abstract memory clobber is valid on every native backend
+/// Zig can run here and costs no machine instruction.
+inline fn compilerBarrier() void {
+ asm volatile ("" ::: .{ .memory = true });
+}
+
+fn reportText(config: Config, results: []const Result) void {
+ std.debug.print("pardes PDF sections benchmark (ReleaseFast)\n", .{});
+ std.debug.print("warmup: {d}, sampled reps: {d}; medians are ns/op\n\n", .{ config.warmup, config.reps });
+ std.debug.print("{s:<42} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} {s}\n", .{
+ "scenario", "min", "median", "p90", "max", "items", "bytes", "checksum",
+ });
+ std.debug.print("{s}\n", .{"-" ** 124});
+ for (results) |result| std.debug.print(
+ "{s:<42} {d:>10} {d:>10} {d:>10} {d:>10} {d:>8} {d:>8} {x:0>16}\n",
+ .{
+ result.name,
+ result.min_ns,
+ result.median_ns,
+ result.p90_ns,
+ result.max_ns,
+ result.identity.items,
+ result.identity.bytes,
+ result.identity.checksum,
+ },
+ );
+ std.debug.print("\nBatched rows divide elapsed time by operations/sample: cached={d}, step={d}.\n", .{ cached_batch, step_batch });
+}
+
+fn reportJson(io: std.Io, config: Config, results: []const Result) void {
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ out.print(gpa, "{{\"benchmark\":\"pardes-pdf-sections\",\"build\":\"ReleaseFast\",\"warmup\":{d},\"reps\":{d},\"results\":[", .{
+ config.warmup, config.reps,
+ }) catch return;
+ for (results, 0..) |result, i| out.print(
+ gpa,
+ "{s}{{\"scenario\":\"{s}\",\"operations_per_sample\":{d},\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d},\"max_ns\":{d},\"items\":{d},\"bytes\":{d},\"checksum\":\"{x:0>16}\"}}",
+ .{
+ if (i == 0) "" else ",",
+ result.name,
+ result.operations_per_sample,
+ result.min_ns,
+ result.median_ns,
+ result.p90_ns,
+ result.max_ns,
+ result.identity.items,
+ result.identity.bytes,
+ result.identity.checksum,
+ },
+ ) catch return;
+ out.appendSlice(gpa, "]}\n") catch return;
+ std.Io.File.stdout().writeStreamingAll(io, out.items) catch {};
+}
+
+fn writeFile(path: []const u8, bytes: []const u8) !void {
+ var path_buf: [4096]u8 = undefined;
+ const path_z = try std.fmt.bufPrintSentinel(&path_buf, "{s}", .{path}, 0);
+ const fd = libc.open(path_z, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o600));
+ if (fd < 0) return error.OpenFailed;
+ defer _ = libc.close(fd);
+ var offset: usize = 0;
+ while (offset < bytes.len) {
+ const written = libc.write(fd, bytes.ptr + offset, bytes.len - offset);
+ if (written < 0) {
+ if (libc.errno(written) == .INTR) continue;
+ return error.WriteFailed;
+ }
+ offset += @intCast(written);
+ }
+}
+
+fn unlink(path: []const u8) void {
+ var path_buf: [4096]u8 = undefined;
+ const path_z = std.fmt.bufPrintSentinel(&path_buf, "{s}", .{path}, 0) catch return;
+ _ = libc.unlink(path_z);
+}
+
+fn fatal(comptime format: []const u8, args: anytype) noreturn {
+ std.debug.print("pdf-sections-bench: " ++ format ++ "\n", args);
+ std.process.exit(1);
+}