diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-03 10:02:00 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 7452ff1caf51942ba9561acf2aa284d50435b8c3 (patch) | |
| tree | 5be0d58e16b3ee9810377131f1d83c06f10b69bf | |
| parent | cd4a5b991104a2dc96a4bb7390451fb384376a9a (diff) | |
| download | pardes-7452ff1caf51942ba9561acf2aa284d50435b8c3.tar.gz pardes-7452ff1caf51942ba9561acf2aa284d50435b8c3.zip | |
optimize and harness PDF sections navigation
| -rw-r--r-- | build.zig | 28 | ||||
| -rw-r--r-- | src/pardes.zig | 178 | ||||
| -rw-r--r-- | test/image_harness.zig | 93 | ||||
| -rw-r--r-- | test/pdf_sections_bench.zig | 550 |
4 files changed, 809 insertions, 40 deletions
@@ -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); +} |
