diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 17:17:35 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 5961587b227e5fa097fb033e32c29da08a23fb18 (patch) | |
| tree | 359883d5531e595d47f75824a77a0c1323973e2e | |
| parent | 487434a0b55fa7cf479f39481b51da0a7eda79e1 (diff) | |
| download | pardes-5961587b227e5fa097fb033e32c29da08a23fb18.tar.gz pardes-5961587b227e5fa097fb033e32c29da08a23fb18.zip | |
tty/image: big harness + golden coverage pass
| -rw-r--r-- | build.zig | 61 | ||||
| -rw-r--r-- | build.zig.zon | 5 | ||||
| -rw-r--r-- | mupdf.zig | 291 | ||||
| -rw-r--r-- | next-steps.txt | 6 | ||||
| -rw-r--r-- | src/builtins.zig | 12 | ||||
| -rw-r--r-- | src/config.zig | 4 | ||||
| -rw-r--r-- | src/gui/gui.zig | 125 | ||||
| -rw-r--r-- | src/image.zig | 268 | ||||
| -rw-r--r-- | src/look.zig | 49 | ||||
| -rw-r--r-- | src/output_pane.zig | 7 | ||||
| -rw-r--r-- | src/pardes.zig | 1786 | ||||
| -rw-r--r-- | src/pdf.zig | 517 | ||||
| -rw-r--r-- | src/pdf_bridge.c | 686 | ||||
| -rw-r--r-- | src/pdf_bridge.h | 171 | ||||
| -rw-r--r-- | src/tty/tty.zig | 135 | ||||
| -rw-r--r-- | test/image_harness.zig | 356 | ||||
| -rw-r--r-- | test/snapshots/builtins.golden | 40 | ||||
| -rw-r--r-- | test/snapshots/leader.golden | 4 | ||||
| -rw-r--r-- | test/snapshots/lspdebug.golden | 12 |
19 files changed, 4387 insertions, 148 deletions
@@ -1,4 +1,5 @@ const std = @import("std"); +const mupdf_build = @import("mupdf.zig"); const snap_build = @import("build/snap.zig"); pub const Platform = enum { tty, gui, web }; @@ -27,6 +28,7 @@ pub fn build(b: *std.Build) void { const static = b.option(bool, "static", "statically link") orelse false; const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)"); const is_web = platform == .web; + const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web off; -Dmupdf=false disables)") orelse !is_web; const is_web_target = target.result.cpu.arch == .wasm32 and target.result.os.tag == .freestanding; // wasm: size is the budget const optimize = if (is_web) .ReleaseSmall else requested_optimize; @@ -47,6 +49,9 @@ pub fn build(b: *std.Build) void { // The browser shell is a freestanding wasm core plus ordinary web files. // JavaScript owns the loop and IO; HTML/CSS own rendering. 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"); + if (!enable_mupdf) + mupdf_check.dependOn(&b.addFail("mupdf-check 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 @@ -74,6 +79,7 @@ pub fn build(b: *std.Build) void { if (is_web and !is_web_target) return failBuild(b, web_step, "-Dplatform=web requires -Dtarget=wasm32-freestanding"); if (!is_web and (is_web_target or target.result.os.tag == .emscripten)) return failBuild(b, web_step, "wasm browser targets require -Dplatform=web"); if (platform == .web and dump_path == null) return failBuild(b, web_step, "-Dplatform=web requires -Ddump=<dump.zon> (the browser has no ptys; state replays from an embedded dump)"); + if (enable_mupdf and is_web) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends"); // build-time IO: slurps the grammars' highlights.scm queries, and reads // vendor/themes to find the theme sources @@ -151,6 +157,7 @@ pub fn build(b: *std.Build) void { opts.addOption(bool, "syntax_full_grammars", tree_sitter_grammars == .full); opts.addOption(bool, "enable_tracy", tracy != null); opts.addOption(bool, "zls_backend", zls_backend); + opts.addOption(bool, "mupdf", enable_mupdf); // Which ZLS is compiled in, for `SPC l i`. Kept next to the dependency it // names: the .zon pins a commit, and a status screen that cannot say WHICH // analyser answered is not worth opening. @@ -332,6 +339,44 @@ pub fn build(b: *std.Build) void { root_mod.addImport("mvzr", mvzr_mod); hx_core_mod.addImport("mvzr", mvzr_mod); + // MuPDF is the default native PDF engine, but remains a genuinely optional + // dependency: with -Dmupdf=false (and by default on web), the lazy source + // archive is not fetched, compiled, linked, or exposed to runtime code. + // mupdf.zig mirrors the 1.27.0 Makefile source graph with Zig's C compiler + // and deliberately enables only the PDF document handler for this first + // integration. + if (enable_mupdf) { + if (b.lazyDependency("mupdf", .{})) |mupdf_dep| { + const mupdf = mupdf_build.add(b, io, mupdf_dep, .{ + .target = target, + .optimize = c_optimize, + }); + const mupdf_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/pdf.zig"), + .link_libc = true, + }); + mupdf.linkTo(mupdf_mod); + mupdf_mod.addIncludePath(b.path("src")); + mupdf_mod.addCSourceFile(.{ + .file = b.path("src/pdf_bridge.c"), + .flags = &.{"-std=gnu11"}, + }); + root_mod.addImport("mupdf", mupdf_mod); + hx_core_mod.addImport("mupdf", mupdf_mod); + // Compile the @cImport + exception bridge and exercise the Zig + // wrapper's stride-aware RGB-to-RGBA boundary alongside the C + // end-to-end document probe below. + const mupdf_test = b.addTest(.{ .root_module = mupdf_mod }); + mupdf_check.dependOn(&b.addRunArtifact(mupdf_test).step); + mupdf_check.dependOn(mupdf_build.addProbe(b, mupdf, .{ + .target = target, + .pdf = b.path("docs/design.pdf"), + })); + } + } + // ghostty-vt and vaxis both depend on uucode, but Zig forbids one source // file in two modules — build a single uucode (our config, unpacked tables) // and hand it to both. Same trick as the prototype; see uucode_config.zig. @@ -583,6 +628,22 @@ pub fn build(b: *std.Build) void { run_image_harness.has_side_effects = true; b.step("image-harness", "exercise native Kitty/SDL image rendering end to end").dependOn(&run_image_harness.step); + // The PDF arm uses the same native-pixel observer but a generated, + // 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"); + if (enable_mupdf) { + const run_pdf_harness = b.addRunArtifact(image_harness); + run_pdf_harness.addArtifactArg(exe); + run_pdf_harness.addArg(if (platform == .gui) "gui" else "tty"); + run_pdf_harness.addArg("pdf"); + run_pdf_harness.has_side_effects = true; + pdf_harness_step.dependOn(&run_pdf_harness.step); + } else { + pdf_harness_step.dependOn(&b.addFail("pdf-harness is unavailable with -Dmupdf=false").step); + } + // helix differential harness: drive the sans-IO core headlessly over // JSON-Lines cases, one contract result line per case on stdout — // the pardes half of the pardes-vs-helix diff (helix's hx-harness on diff --git a/build.zig.zon b/build.zig.zon index 488dae7b..e557a3b1 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -160,6 +160,11 @@ .url = "git+https://github.com/mnemnion/mvzr#3efcfe339bd32e122c36b8f1db25b237f93a7ec2", .hash = "mvzr-0.3.9-ZSOky6t2AQCA7efmm5EYbPb5sOdbXa4EFsUgYbQ6hZv9", }, + .mupdf = .{ + .url = "https://mupdf.com/downloads/archive/mupdf-1.27.0-source.tar.gz", + .hash = "N-V-__8AAAa9gQo4C4cIEpZfRqQLBv-ZnRIB4NQqsMOy8bNx", + .lazy = true, + }, }, .minimum_zig_version = "0.16.0", .paths = .{""}, diff --git a/mupdf.zig b/mupdf.zig new file mode 100644 index 00000000..c80f678d --- /dev/null +++ b/mupdf.zig @@ -0,0 +1,291 @@ +const std = @import("std"); + +/// MuPDF 1.27.0's release archive does not publish a Zig build. Keep its +/// upstream source-of-truth (`Makelists`) and compile the selected C sources +/// as an ordinary Zig static library. This is intentionally a PDF-only first +/// slice: the application needs rendering and structured text, not MuPDF's +/// other document handlers, tools, Javascript engine, OCR, or writers. +pub const Result = struct { + dependency: *std.Build.Dependency, + library: *std.Build.Step.Compile, + + /// Give a Zig module the same preprocessor view as the library, expose the + /// public C headers to @cImport, and propagate the static link. + pub fn linkTo(self: Result, module: *std.Build.Module) void { + configureModule(module, self.dependency); + // MuPDF's public context headers select the lock-debugging contract + // from NDEBUG. Every consumer must therefore agree with the archive, + // even when its own Zig optimization/safety mode differs. + module.addCMacro("NDEBUG", "1"); + module.linkLibrary(self.library); + } +}; + +pub fn add(b: *std.Build, io: std.Io, dependency: *std.Build.Dependency, options: struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, +}) Result { + const module = b.createModule(.{ + .target = options.target, + .optimize = options.optimize, + .link_libc = true, + }); + configureModule(module, dependency); + // MuPDF's upstream release build defines NDEBUG independently of the + // optimizer. Without it, an optimized Zig build still enables Fitz's + // debug-locking/assertion paths. linkTo applies the same public-header + // contract to consumers; keeping it here also covers every archive source. + module.addCMacro("NDEBUG", "1"); + + // MuPDF itself uses wildcards for these two directories. The dependency + // is immutable and content-addressed, so discovering the flat C lists at + // configure time is deterministic; sorting prevents filesystem order from + // perturbing Zig's cache key. + module.addCSourceFiles(.{ + .root = dependency.path(""), + .files = flatCSources(b, io, dependency, "source/fitz"), + // MuPDF's C uses intentional patterns that Zig/Clang's trap-mode + // UBSan rejects in ReleaseSafe (opening any document traps in + // fz_drop_archive). Keep ReleaseSafe's optimization while leaving + // undefined-behaviour instrumentation off for this upstream layer. + .flags = &.{ "-std=gnu11", "-fno-sanitize=undefined" }, + }); + module.addCSourceFiles(.{ + .root = dependency.path(""), + .files = flatCSources(b, io, dependency, "source/pdf"), + .flags = &.{ "-std=gnu11", "-fno-sanitize=undefined" }, + }); + + // TOFU + TOFU_CJK leaves the Base-14 PDF fonts enabled. The official + // source release ships their generated C blobs, so no host-side objcopy or + // shell generator is involved. + module.addCSourceFiles(.{ + .root = dependency.path(""), + .files = flatCSources(b, io, dependency, "generated/resources/fonts/urw"), + // These are large byte arrays; optimization only wastes build time. + // This matches MuPDF's own generated-resource compilation rule. + .flags = &.{ "-std=gnu11", "-O0" }, + }); + + // Parse only the upstream variables we actually select. These lists are + // the same ones MuPDF's Makefile places in libmupdf-third.a; keeping the + // names in Makelists avoids a large, easy-to-stale duplicate in this file. + const makelists = readMakelists(b, io, dependency); + module.addCSourceFiles(.{ + .root = dependency.path(""), + .files = makeSources(b, makelists, "FREETYPE_SRC"), + .flags = &.{ + "-std=gnu11", + "-DFT_CONFIG_MODULES_H=\"slimftmodules.h\"", + "-DFT_CONFIG_OPTIONS_H=\"slimftoptions.h\"", + "-DFT2_BUILD_LIBRARY", + }, + }); + module.addCSourceFiles(.{ + .root = dependency.path(""), + .files = makeSources(b, makelists, "LIBJPEG_SRC"), + .flags = &.{"-std=gnu11"}, + }); + module.addCSourceFiles(.{ + .root = dependency.path(""), + .files = makeSources(b, makelists, "ZLIB_SRC"), + .flags = &.{ "-std=gnu11", "-DHAVE_UNISTD_H", "-DHAVE_STDARG_H" }, + }); + module.addCSourceFiles(.{ + .root = dependency.path(""), + .files = makeSources(b, makelists, "JBIG2DEC_SRC"), + .flags = &.{ + "-std=gnu11", + "-DHAVE_STDINT_H", + "-DJBIG_EXTERNAL_MEMENTO_H=\"mupdf/memento.h\"", + }, + }); + + const library = b.addLibrary(.{ + .name = "mupdf", + .linkage = .static, + .root_module = module, + }); + if (options.target.result.os.tag != .windows) + module.linkSystemLibrary("m", .{}); + + return .{ .dependency = dependency, .library = library }; +} + +/// Add a link-complete smoke test. Unlike merely producing an archive, this +/// exercises document registration, page loading, rasterization and search, +/// so an omitted source or codec symbol fails the build before application +/// integration starts. +pub fn addProbe(b: *std.Build, result: Result, options: struct { + target: std.Build.ResolvedTarget, + pdf: std.Build.LazyPath, +}) *std.Build.Step { + const generated = b.addWriteFiles(); + const source = generated.add("mupdf-probe.c", + \\#include <mupdf/fitz.h> + \\#include <stdio.h> + \\ + \\int main(int argc, char **argv) + \\{ + \\ fz_context *ctx = NULL; + \\ fz_document *doc = NULL; + \\ fz_page *page = NULL; + \\ fz_pixmap *pix = NULL; + \\ fz_stext_page *text = NULL; + \\ fz_search *search = NULL; + \\ fz_search_result search_result; + \\ int pages = 0, hit_count = 0, failed = 0; + \\ + \\ if (argc != 2) return 2; + \\ ctx = fz_new_context(NULL, NULL, FZ_STORE_DEFAULT); + \\ if (!ctx) return 3; + \\ fz_var(doc); + \\ fz_var(page); + \\ fz_var(pix); + \\ fz_var(text); + \\ fz_var(search); + \\ fz_try(ctx) + \\ { + \\ fz_register_document_handlers(ctx); + \\ doc = fz_open_document(ctx, argv[1]); + \\ pages = fz_count_pages(ctx, doc); + \\ if (pages < 1) fz_throw(ctx, FZ_ERROR_FORMAT, "empty document"); + \\ page = fz_load_page(ctx, doc, 0); + \\ pix = fz_new_pixmap_from_page(ctx, page, fz_scale(0.25f, 0.25f), fz_device_rgb(ctx), 0); + \\ text = fz_new_stext_page_from_page(ctx, page, NULL); + \\ search = fz_new_search(ctx); + \\ fz_search_set_options(ctx, search, FZ_SEARCH_IGNORE_CASE, "Pardes"); + \\ fz_feed_search(ctx, search, fz_keep_stext_page(ctx, text), 0); + \\ for (;;) + \\ { + \\ search_result = fz_search_forwards(ctx, search); + \\ if (search_result.reason == FZ_SEARCH_COMPLETE) break; + \\ if (search_result.reason == FZ_SEARCH_MORE_INPUT) + \\ fz_feed_search(ctx, search, NULL, search_result.u.more_input.seq_needed); + \\ else if (search_result.reason == FZ_SEARCH_MATCH) + \\ ++hit_count; + \\ else + \\ fz_throw(ctx, FZ_ERROR_FORMAT, "invalid iterative search state"); + \\ } + \\ if (!pix || pix->w < 1 || pix->h < 1 || hit_count < 1) + \\ fz_throw(ctx, FZ_ERROR_FORMAT, "render/search probe failed"); + \\ printf("MuPDF probe: %d pages, %d hit(s), first page %dx%d pixels\n", pages, hit_count, pix->w, pix->h); + \\ } + \\ fz_catch(ctx) + \\ { + \\ fz_report_error(ctx); + \\ failed = 1; + \\ } + \\ fz_drop_search(ctx, search); + \\ fz_drop_stext_page(ctx, text); + \\ fz_drop_pixmap(ctx, pix); + \\ fz_drop_page(ctx, page); + \\ fz_drop_document(ctx, doc); + \\ fz_drop_context(ctx); + \\ return failed; + \\} + ); + + const probe = b.addExecutable(.{ + .name = "pardes-mupdf-probe", + .root_module = b.createModule(.{ + .target = options.target, + .optimize = .ReleaseSafe, + .link_libc = true, + }), + }); + result.linkTo(probe.root_module); + probe.root_module.addCSourceFile(.{ .file = source, .flags = &.{"-std=gnu11"} }); + + const run = b.addRunArtifact(probe); + run.addFileArg(options.pdf); + run.expectExitCode(0); + return &run.step; +} + +fn configureModule(module: *std.Build.Module, dependency: *std.Build.Dependency) void { + module.addIncludePath(dependency.path("include")); + module.addIncludePath(dependency.path("source/fitz")); + module.addIncludePath(dependency.path("source/pdf")); + module.addIncludePath(dependency.path("thirdparty/freetype/include")); + module.addIncludePath(dependency.path("scripts/freetype")); + module.addIncludePath(dependency.path("thirdparty/libjpeg")); + module.addIncludePath(dependency.path("scripts/libjpeg")); + module.addIncludePath(dependency.path("thirdparty/zlib")); + module.addIncludePath(dependency.path("thirdparty/jbig2dec")); + // encode-jpx.c includes this header before its FZ_ENABLE_JPX guard. + module.addIncludePath(dependency.path("thirdparty/openjpeg/src/lib/openjp2")); + + inline for (.{ + .{ "FZ_ENABLE_PDF", "1" }, + .{ "FZ_ENABLE_XPS", "0" }, + .{ "FZ_ENABLE_SVG", "0" }, + .{ "FZ_ENABLE_CBZ", "0" }, + .{ "FZ_ENABLE_IMG", "0" }, + .{ "FZ_ENABLE_HTML", "0" }, + .{ "FZ_ENABLE_FB2", "0" }, + .{ "FZ_ENABLE_MOBI", "0" }, + .{ "FZ_ENABLE_EPUB", "0" }, + .{ "FZ_ENABLE_OFFICE", "0" }, + .{ "FZ_ENABLE_TXT", "0" }, + .{ "FZ_ENABLE_HTML_ENGINE", "0" }, + .{ "FZ_ENABLE_OCR_OUTPUT", "0" }, + .{ "FZ_ENABLE_ODT_OUTPUT", "0" }, + .{ "FZ_ENABLE_DOCX_OUTPUT", "0" }, + .{ "FZ_ENABLE_JS", "0" }, + .{ "FZ_ENABLE_BARCODE", "0" }, + .{ "FZ_ENABLE_BROTLI", "0" }, + .{ "FZ_ENABLE_ICC", "0" }, + .{ "FZ_ENABLE_JPX", "0" }, + }) |macro| module.addCMacro(macro[0], macro[1]); + module.addCMacro("OCR_DISABLED", "1"); + module.addCMacro("TOFU", "1"); + module.addCMacro("TOFU_CJK", "1"); +} + +fn flatCSources(b: *std.Build, io: std.Io, dependency: *std.Build.Dependency, relative_dir: []const u8) []const []const u8 { + var dir = std.Io.Dir.cwd().openDir(io, dependency.path(relative_dir).getPath(b), .{ .iterate = true }) catch + std.debug.panic("MuPDF source directory is missing: {s}", .{relative_dir}); + defer dir.close(io); + + var files: std.ArrayList([]const u8) = .empty; + var it = dir.iterate(); + while (it.next(io) catch @panic("read MuPDF source directory")) |entry| { + // Some filesystems report d_type as unknown. The selected directories + // are flat and immutable, so the suffix is the reliable predicate. + if (!std.mem.endsWith(u8, entry.name, ".c")) continue; + files.append(b.allocator, b.fmt("{s}/{s}", .{ relative_dir, entry.name })) catch @panic("OOM"); + } + std.mem.sort([]const u8, files.items, {}, lessThan); + if (files.items.len == 0) std.debug.panic("MuPDF source directory has no C files: {s}", .{relative_dir}); + return files.items; +} + +fn readMakelists(b: *std.Build, io: std.Io, dependency: *std.Build.Dependency) []const u8 { + return std.Io.Dir.cwd().readFileAlloc( + io, + dependency.path("Makelists").getPath(b), + b.allocator, + .limited(1024 * 1024), + ) catch @panic("read MuPDF Makelists"); +} + +fn makeSources(b: *std.Build, makelists: []const u8, variable: []const u8) []const []const u8 { + var files: std.ArrayList([]const u8) = .empty; + var lines = std.mem.splitScalar(u8, makelists, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (!std.mem.startsWith(u8, line, variable)) continue; + const assignment = std.mem.trimStart(u8, line[variable.len..], " \t"); + if (!std.mem.startsWith(u8, assignment, "+=")) continue; + const path = std.mem.trim(u8, assignment[2..], " \t\r"); + if (!std.mem.endsWith(u8, path, ".c")) continue; + files.append(b.allocator, b.dupe(path)) catch @panic("OOM"); + } + if (files.items.len == 0) std.debug.panic("MuPDF Makelists variable has no C sources: {s}", .{variable}); + return files.items; +} + +fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; +} diff --git a/next-steps.txt b/next-steps.txt new file mode 100644 index 00000000..1b6309d6 --- /dev/null +++ b/next-steps.txt @@ -0,0 +1,6 @@ +- Esc in normal mode toggles focus (the same functionality as spc+w+t). +- sdl backend no longer grabs mouse focus +- implement a real basic config file at ~/.config/pardes (use zig stdlib to locate these config dirs) which will execute the builtins in each line; like Font abc, Theme asdfd, this will execute before rendering so that the application won't flash or anything when styling changes. also if there's an error with a command, it will silently fail. +- implement ipc comms via unix sockets, this will be used to deatach and reattach to a pardes session . +- Add the New builtin that will open a new empty tempfile on the column that called it. +- reorder the top tagline so that Kill goes last and most useful commands come first. diff --git a/src/builtins.zig b/src/builtins.zig index 6a8518ea..578a60dd 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -289,6 +289,18 @@ pub const FontSel = struct { } }; +/// Toggle a native PDF between the reading-oriented fit-width view and the +/// whole-page-height view. Like Font on non-GUI builds, this declaration's +/// `run` deliberately has the wrong shape when MuPDF is disabled: `all()` then +/// omits it entirely, so the enum, Help and runtime binary contain no +/// PdfFit. +pub const PdfFit = struct { + pub const run = if (pardes.pdf_enabled) apply else {}; + fn apply(c: Ctx) void { + c.p.togglePdfFit(c.pane); + } +}; + // The image pane's three renderer toggles. They used to be words the image tag // printed and the execute dispatcher matched by hand; as builtins they are // executable anywhere, pressable under SPC and listed by `SPC ?`, the whole diff --git a/src/config.zig b/src/config.zig index 714ec177..cee3613e 100644 --- a/src/config.zig +++ b/src/config.zig @@ -159,6 +159,10 @@ pub const leader_path = paths: { table.set(.FontSel, "tf"); table.set(.Font, null); } + // PdfFit exists only in MuPDF builds (its run declaration deliberately + // changes shape when the feature is off), so name its toggle path through + // the same comptime branch rather than making feature-off enums mention it. + if (pardes.pdf_enabled) table.set(.PdfFit, "tz"); // ...and the property EnumArray.init used to give for free: every builtin // this build HAS is a builtin someone decided a path (or a null) for. for (std.enums.values(Builtin)) |b| if (table.get(b)) |p| { diff --git a/src/gui/gui.zig b/src/gui/gui.zig index c08c27fe..ef2422c5 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -198,10 +198,27 @@ const ImageInstance = extern struct { }; const NativeImage = struct { - serial: u32, + key: pardes.ImageCacheKey, texture: *c.SDL_GPUTexture, }; +fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) void { + if (comptime pardes.pdf_enabled) { + const cell_px_w: u16 = @intCast(@min(@max(1, cell_w), std.math.maxInt(u16))); + const cell_px_h: u16 = @intCast(@min(@max(1, cell_h), std.math.maxInt(u16))); + if (cols == core.screen_w and rows == core.screen_h and + cell_px_w == core.cell_pixels.w and cell_px_h == core.cell_pixels.h) return; + core.update(.{ .resize = .{ + .cols = cols, + .rows = rows, + .cell_pixels = .{ .w = cell_px_w, .h = cell_px_h }, + } }); + } else { + if (cols == core.screen_w and rows == core.screen_h) return; + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + } +} + // ---- touch: per-finger tracking + shared scroll/tap machines ---- const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; @@ -1303,8 +1320,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); - if (cols != core.screen_w or rows != core.screen_h) - core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + updateCoreResize(core, cols, rows, g.cell_w, g.cell_h); // 7. render — the scroll animation steps LAST before it, while // core.surface still holds the frame the last pass drew stepScroll(&g, core, gpa); @@ -1588,8 +1604,7 @@ fn webFrame(arg: ?*anyopaque) callconv(.c) void { _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); - if (cols != core.screen_w or rows != core.screen_h) - core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + updateCoreResize(core, cols, rows, g.cell_w, g.cell_h); // 4. render — scroll animation last before it, see runNative stepScroll(g, core, st.gpa); _ = st.frame_arena.reset(.retain_capacity); @@ -2762,11 +2777,42 @@ fn validImageBytes(place: pardes.ImagePlace) ?u32 { return @intCast(bytes); } +const NativeBounds = struct { w: u32, h: u32 }; + +fn nativeBounds(g: *const Gui, place: pardes.ImagePlace) ?NativeBounds { + const bound_w = std.math.mul(u32, place.w, g.cell_w) catch return null; + const bound_h = std.math.mul(u32, place.h, g.cell_h) catch return null; + return .{ .w = bound_w, .h = bound_h }; +} + +fn nativePlaceGeometry(g: *const Gui, place: pardes.ImagePlace) ?pardes.image.NativeGeometry { + if (comptime pardes.pdf_enabled) { + const bounds = nativeBounds(g, place) orelse return null; + return pardes.image.nativeGeometry( + place.iw, + place.ih, + bounds.w, + bounds.h, + place.native.fit, + place.native.pan_x, + place.native.pan_y, + ); + } + return null; +} + +fn nativePlaceDrawable(g: *const Gui, place: pardes.ImagePlace) bool { + if (comptime pardes.pdf_enabled) return nativePlaceGeometry(g, place) != null; + const bounds = nativeBounds(g, place) orelse return false; + const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h); + return fit.w != 0 and fit.h != 0; +} + fn uploadNativeTexture(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, place: pardes.ImagePlace) !void { const byte_len = validImageBytes(place) orelse return error.BadImage; const pane: usize = place.pane; if (g.native_images[pane]) |cached| { - if (cached.serial == place.serial) return; + if (cached.key.eql(place.cacheKey())) return; releaseNativeImage(g, pane); } @@ -2817,10 +2863,10 @@ fn uploadNativeTexture(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, place: pardes.Imag // allocation is never needed again, so do not retain a second full image // beside the texture for the life of the pane. c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); - g.native_images[pane] = .{ .serial = place.serial, .texture = texture }; + g.native_images[pane] = .{ .key = place.cacheKey(), .texture = texture }; } -/// Upload new pane generations and the small per-frame placement buffer. +/// Upload new pixel generations and the small per-frame placement buffer. /// Returns the number of image instances drawNativeImagesGpu will consume. fn prepareNativeImages( g: *Gui, @@ -2842,7 +2888,7 @@ fn prepareNativeImages( for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (g.native_images[place.pane]) |cached| { - if (cached.serial == place.serial) count += 1; + if (cached.key.eql(place.cacheKey()) and nativePlaceDrawable(g, place)) count += 1; } } if (count == 0) return 0; @@ -2855,28 +2901,41 @@ fn prepareNativeImages( for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; const cached = g.native_images[place.pane] orelse continue; - if (cached.serial != place.serial) continue; + if (!cached.key.eql(place.cacheKey())) continue; - const bound_w = @as(u32, place.w) * g.cell_w; - const bound_h = @as(u32, place.h) * g.cell_h; - const fit = pardes.image.contain(place.iw, place.ih, bound_w, bound_h); - if (fit.w == 0 or fit.h == 0) continue; - const px0 = @as(u32, place.x) * g.cell_w; - const py0 = @as(u32, place.y) * g.cell_h; - const x0 = (@as(f32, @floatFromInt(px0)) / win_w) * 2.0 - 1.0; - const x1 = (@as(f32, @floatFromInt(px0 + fit.w)) / win_w) * 2.0 - 1.0; - const y0 = 1.0 - (@as(f32, @floatFromInt(py0)) / win_h) * 2.0; - const y1 = 1.0 - (@as(f32, @floatFromInt(py0 + fit.h)) / win_h) * 2.0; - instances[idx] = .{ - .x0 = x0, - .y0 = y0, - .x1 = x1, - .y1 = y1, - .u0 = 0, - .v0 = 0, - .u1 = 1, - .v1 = 1, - }; + const body_x = @as(u32, place.x) * g.cell_w; + const body_y = @as(u32, place.y) * g.cell_h; + if (comptime pardes.pdf_enabled) { + const geometry = nativePlaceGeometry(g, place) orelse continue; + const px0 = body_x + geometry.dst.x; + const py0 = body_y + geometry.dst.y; + const image_w: f32 = @floatFromInt(place.iw); + const image_h: f32 = @floatFromInt(place.ih); + instances[idx] = .{ + .x0 = (@as(f32, @floatFromInt(px0)) / win_w) * 2.0 - 1.0, + .y0 = 1.0 - (@as(f32, @floatFromInt(py0)) / win_h) * 2.0, + .x1 = (@as(f32, @floatFromInt(px0 + geometry.dst.w)) / win_w) * 2.0 - 1.0, + .y1 = 1.0 - (@as(f32, @floatFromInt(py0 + geometry.dst.h)) / win_h) * 2.0, + .u0 = @as(f32, @floatFromInt(geometry.src.x)) / image_w, + .v0 = @as(f32, @floatFromInt(geometry.src.y)) / image_h, + .u1 = @as(f32, @floatFromInt(geometry.src.x + geometry.src.w)) / image_w, + .v1 = @as(f32, @floatFromInt(geometry.src.y + geometry.src.h)) / image_h, + }; + } else { + const bounds = nativeBounds(g, place) orelse continue; + const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h); + if (fit.w == 0 or fit.h == 0) continue; + instances[idx] = .{ + .x0 = (@as(f32, @floatFromInt(body_x)) / win_w) * 2.0 - 1.0, + .y0 = 1.0 - (@as(f32, @floatFromInt(body_y)) / win_h) * 2.0, + .x1 = (@as(f32, @floatFromInt(body_x + fit.w)) / win_w) * 2.0 - 1.0, + .y1 = 1.0 - (@as(f32, @floatFromInt(body_y + fit.h)) / win_h) * 2.0, + .u0 = 0, + .v0 = 0, + .u1 = 1, + .v1 = 1, + }; + } idx += 1; } c.SDL_UnmapGPUTransferBuffer(g.device, g.image_vxfer); @@ -2897,7 +2956,8 @@ fn drawNativeImagesGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surf for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; const cached = g.native_images[place.pane] orelse continue; - if (cached.serial != place.serial) continue; + if (!cached.key.eql(place.cacheKey())) continue; + if (!nativePlaceDrawable(g, place)) continue; const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = cached.texture, .sampler = g.atlas_sampler }; c.SDL_BindGPUFragmentSamplers(pass, 0, &sampler, 1); const binding = c.SDL_GPUBufferBinding{ .buffer = g.image_vbuf, .offset = idx * @sizeOf(ImageInstance) }; @@ -3190,8 +3250,7 @@ fn refitFont(g: *Gui, core: *pardes.Pardes) void { _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); - if (cols != core.screen_w or rows != core.screen_h) - core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + updateCoreResize(core, cols, rows, g.cell_w, g.cell_h); // ...and a smooth scroll in flight is measured in the OLD grid: scroll_rect // is a rect of the pane the last frame drew, and scroll_edge is a saved row diff --git a/src/image.zig b/src/image.zig index 1e7736c5..a9f0e227 100644 --- a/src/image.zig +++ b/src/image.zig @@ -5,6 +5,7 @@ const std = @import("std"); const zstbi = @import("zstbi"); const ghostty_vt = @import("ghostty-vt"); +const pdf_enabled = @import("pardes_config").mupdf; pub const petscii = @import("petscii.zig"); /// the terminal's own 16 ANSI colors — what the `terminal` palette mode @@ -25,46 +26,261 @@ const MAX_DIM: u32 = 1280; pub const PaletteMode = enum { commodore, terminal }; -/// Fit an image inside a pixel rectangle without changing its aspect ratio or -/// enlarging it. Native SDL uses this for the same "contain" policy the kitty -/// placement uses. Keeping the arithmetic here makes the backend geometry -/// testable without a GPU or an image-capable terminal. -pub const Fit = struct { w: u32, h: u32 }; +/// How a native pixel attachment is placed in its pane body. `contain` is the +/// historical image-pane policy: shrink only when necessary and keep the +/// image at the top left. The axis-specific modes may enlarge, letterbox the +/// unconstrained axis, or crop it when it overflows. +pub const NativeFit = if (pdf_enabled) enum { contain, width, height } else enum { contain }; -pub fn contain(iw: usize, ih: usize, max_w: u32, max_h: u32) Fit { - if (iw == 0 or ih == 0 or max_w == 0 or max_h == 0) return .{ .w = 0, .h = 0 }; +pub const PixelRect = struct { + x: u32 = 0, + y: u32 = 0, + w: u32 = 0, + h: u32 = 0, +}; - const src_w: u64 = @intCast(iw); - const src_h: u64 = @intCast(ih); - const bound_w: u64 = max_w; - const bound_h: u64 = max_h; - if (src_w <= bound_w and src_h <= bound_h) - return .{ .w = @intCast(src_w), .h = @intCast(src_h) }; +pub const PixelPoint = struct { x: u32, y: u32 }; - // Compare the two scale ratios without floating point. Whichever bound is - // tighter determines one exact dimension; the other is rounded down so it - // can never leak a pixel outside the pane body. - if (bound_w * src_h <= bound_h * src_w) { +/// Source pixels and their destination inside a pane body's pixel rectangle. +/// Both rectangles are half-open. Rounding is always toward the inside, so a +/// backend can draw this result without a backend-specific overflow clip. +pub const NativeGeometry = struct { + src: PixelRect, + dst: PixelRect, + + /// Map a destination pixel back into the retained source crop. This is the + /// same integer transform the texture rectangle describes; points in a + /// letterbox return null. + pub fn sourcePoint(self: NativeGeometry, x: u32, y: u32) ?PixelPoint { + if (self.src.w == 0 or self.src.h == 0 or self.dst.w == 0 or self.dst.h == 0) return null; + if (x < self.dst.x or y < self.dst.y) return null; + const dx = x - self.dst.x; + const dy = y - self.dst.y; + if (dx >= self.dst.w or dy >= self.dst.h) return null; return .{ - .w = max_w, - .h = @intCast(@max(1, src_h * bound_w / src_w)), + .x = self.src.x + @as(u32, @intCast(@as(u64, dx) * self.src.w / self.dst.w)), + .y = self.src.y + @as(u32, @intCast(@as(u64, dy) * self.src.h / self.dst.h)), + }; + } +}; + +fn panOffset(overflow: u32, pan: u16) u32 { + if (overflow == 0 or pan == 0) return 0; + if (pan == std.math.maxInt(u16)) return overflow; + // Round, rather than truncate, so the midpoint is the visual midpoint. + return @intCast((@as(u64, overflow) * pan + std.math.maxInt(u16) / 2) / std.math.maxInt(u16)); +} + +fn fitWidth(src_w: u32, src_h: u32, body_w: u32, body_h: u32, pan_y: u16) ?NativeGeometry { + const scaled_h = @as(u64, src_h) * body_w; + if (scaled_h <= @as(u64, body_h) * src_w) { + const full_h: u32 = @intCast(@max(1, scaled_h / src_w)); + return .{ + .src = .{ .w = src_w, .h = src_h }, + .dst = .{ .y = (body_h - full_h) / 2, .w = body_w, .h = full_h }, }; } + + // Retain the largest whole source-pixel crop which cannot extend beyond + // the body at this scale. The sub-pixel remainder becomes at most a small + // centered letterbox instead of leaking into an adjacent cell in Kitty. + const crop_h_wide = @as(u64, body_h) * src_w / body_w; + if (crop_h_wide == 0) return null; + const crop_h: u32 = @min(src_h, @as(u32, @intCast(crop_h_wide))); + const dst_h: u32 = @min(body_h, @max(1, @as(u32, @intCast(@as(u64, crop_h) * body_w / src_w)))); return .{ - .w = @intCast(@max(1, src_w * bound_h / src_h)), - .h = max_h, + .src = .{ + .y = panOffset(src_h - crop_h, pan_y), + .w = src_w, + .h = crop_h, + }, + .dst = .{ .y = (body_h - dst_h) / 2, .w = body_w, .h = dst_h }, }; } -test "native image contain keeps aspect, bounds, and small-image size" { +fn fitHeight(src_w: u32, src_h: u32, body_w: u32, body_h: u32, pan_x: u16) ?NativeGeometry { + const scaled_w = @as(u64, src_w) * body_h; + if (scaled_w <= @as(u64, body_w) * src_h) { + const full_w: u32 = @intCast(@max(1, scaled_w / src_h)); + return .{ + .src = .{ .w = src_w, .h = src_h }, + .dst = .{ .x = (body_w - full_w) / 2, .w = full_w, .h = body_h }, + }; + } + + const crop_w_wide = @as(u64, body_w) * src_h / body_h; + if (crop_w_wide == 0) return null; + const crop_w: u32 = @min(src_w, @as(u32, @intCast(crop_w_wide))); + const dst_w: u32 = @min(body_w, @max(1, @as(u32, @intCast(@as(u64, crop_w) * body_h / src_h)))); + return .{ + .src = .{ + .x = panOffset(src_w - crop_w, pan_x), + .w = crop_w, + .h = src_h, + }, + .dst = .{ .x = (body_w - dst_w) / 2, .w = dst_w, .h = body_h }, + }; +} + +/// Compute native image placement without floating point. Source dimensions +/// are limited to u32 because SDL's texture API and Kitty's crop parameters +/// cannot describe anything larger. Kitty applies the stricter u16 check at +/// its protocol boundary. +pub fn nativeGeometry( + iw: usize, + ih: usize, + body_pixel_w: u32, + body_pixel_h: u32, + fit: NativeFit, + pan_x: u16, + pan_y: u16, +) ?NativeGeometry { + if (comptime !pdf_enabled) { + return containGeometry(iw, ih, body_pixel_w, body_pixel_h); + } + if (iw == 0 or ih == 0 or body_pixel_w == 0 or body_pixel_h == 0 or + iw > std.math.maxInt(u32) or ih > std.math.maxInt(u32)) return null; + + const src_w: u32 = @intCast(iw); + const src_h: u32 = @intCast(ih); + return switch (fit) { + .width => fitWidth(src_w, src_h, body_pixel_w, body_pixel_h, pan_y), + .height => fitHeight(src_w, src_h, body_pixel_w, body_pixel_h, pan_x), + .contain => blk: { + if (src_w <= body_pixel_w and src_h <= body_pixel_h) break :blk .{ + .src = .{ .w = src_w, .h = src_h }, + .dst = .{ .w = src_w, .h = src_h }, + }; + + // Compare scale ratios without floating point. The tighter bound + // is exact; the other is rounded inward. Contain intentionally + // ignores pan and keeps the historical top-left placement. + const dst: PixelRect = if (@as(u64, body_pixel_w) * src_h <= @as(u64, body_pixel_h) * src_w) + .{ + .w = body_pixel_w, + .h = @intCast(@max(1, @as(u64, src_h) * body_pixel_w / src_w)), + } + else + .{ + .w = @intCast(@max(1, @as(u64, src_w) * body_pixel_h / src_h)), + .h = body_pixel_h, + }; + break :blk .{ .src = .{ .w = src_w, .h = src_h }, .dst = dst }; + }, + }; +} + +/// The ordinary image-pane policy, separate from the PDF-only fit/pan +/// machinery so feature-off backends retain their original contain-only path. +pub fn containGeometry(iw: usize, ih: usize, max_w: u32, max_h: u32) ?NativeGeometry { + if (iw == 0 or ih == 0 or max_w == 0 or max_h == 0 or + iw > std.math.maxInt(u32) or ih > std.math.maxInt(u32)) return null; + + const src_w: u32 = @intCast(iw); + const src_h: u32 = @intCast(ih); + if (src_w <= max_w and src_h <= max_h) return .{ + .src = .{ .w = src_w, .h = src_h }, + .dst = .{ .w = src_w, .h = src_h }, + }; + const dst: PixelRect = if (@as(u64, max_w) * src_h <= @as(u64, max_h) * src_w) + .{ + .w = max_w, + .h = @intCast(@max(1, @as(u64, src_h) * max_w / src_w)), + } + else + .{ + .w = @intCast(@max(1, @as(u64, src_w) * max_h / src_h)), + .h = max_h, + }; + return .{ .src = .{ .w = src_w, .h = src_h }, .dst = dst }; +} + +/// Compatibility helper for callers which need only the old contain size. +pub const Fit = struct { w: u32, h: u32 }; + +pub fn contain(iw: usize, ih: usize, max_w: u32, max_h: u32) Fit { + const geometry = containGeometry(iw, ih, max_w, max_h) orelse + return .{ .w = 0, .h = 0 }; + return .{ .w = geometry.dst.w, .h = geometry.dst.h }; +} + +test "native image contain keeps aspect, bounds, top-left, and small-image size" { try std.testing.expectEqual(Fit{ .w = 40, .h = 20 }, contain(400, 200, 40, 40)); try std.testing.expectEqual(Fit{ .w = 20, .h = 40 }, contain(200, 400, 40, 40)); try std.testing.expectEqual(Fit{ .w = 17, .h = 9 }, contain(17, 9, 40, 40)); try std.testing.expectEqual(Fit{ .w = 0, .h = 0 }, contain(17, 9, 0, 40)); - // Awkward ratios stay inside both bounds rather than rounding one pixel - // over them. - const odd = contain(403, 211, 101, 47); - try std.testing.expect(odd.w <= 101 and odd.h <= 47); + + const odd = containGeometry(403, 211, 101, 47).?; + try std.testing.expectEqual(PixelRect{ .w = 89, .h = 47 }, odd.dst); + try std.testing.expectEqual(PixelRect{ .w = 403, .h = 211 }, odd.src); +} + +test "native fit width handles portrait crop, pan extrema, and landscape letterbox" { + if (comptime !pdf_enabled) return; + const top = nativeGeometry(600, 1000, 800, 600, .width, 0, 0).?; + try std.testing.expectEqual(PixelRect{ .w = 600, .h = 450 }, top.src); + try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, top.dst); + + const middle = nativeGeometry(600, 1000, 800, 600, .width, 0, 32768).?; + try std.testing.expectEqual(@as(u32, 275), middle.src.y); + const bottom = nativeGeometry(600, 1000, 800, 600, .width, 65535, 65535).?; + try std.testing.expectEqual(@as(u32, 550), bottom.src.y); + + const landscape = nativeGeometry(1000, 600, 800, 600, .width, 0, 65535).?; + try std.testing.expectEqual(PixelRect{ .w = 1000, .h = 600 }, landscape.src); + try std.testing.expectEqual(PixelRect{ .y = 60, .w = 800, .h = 480 }, landscape.dst); +} + +test "native fit height handles portrait letterbox and landscape crop" { + if (comptime !pdf_enabled) return; + const portrait = nativeGeometry(600, 1000, 800, 600, .height, 65535, 0).?; + try std.testing.expectEqual(PixelRect{ .w = 600, .h = 1000 }, portrait.src); + try std.testing.expectEqual(PixelRect{ .x = 220, .w = 360, .h = 600 }, portrait.dst); + + const left = nativeGeometry(1000, 600, 800, 600, .height, 0, 0).?; + try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, left.src); + try std.testing.expectEqual(PixelRect{ .w = 800, .h = 600 }, left.dst); + const right = nativeGeometry(1000, 600, 800, 600, .height, 65535, 0).?; + try std.testing.expectEqual(@as(u32, 200), right.src.x); +} + +test "native fit odd ratios round inward and map points through letterboxes and crops" { + if (comptime !pdf_enabled) return; + const width = nativeGeometry(403, 211, 101, 47, .width, 0, 32768).?; + try std.testing.expectEqual(PixelRect{ .y = 12, .w = 403, .h = 187 }, width.src); + try std.testing.expectEqual(PixelRect{ .w = 101, .h = 46 }, width.dst); + const mapped = width.sourcePoint(100, 45).?; + try std.testing.expect(mapped.x < width.src.x + width.src.w); + try std.testing.expect(mapped.y < width.src.y + width.src.h); + try std.testing.expect(width.sourcePoint(101, 46) == null); + + const height = nativeGeometry(403, 211, 101, 47, .height, 32768, 0).?; + try std.testing.expectEqual(PixelRect{ .w = 403, .h = 211 }, height.src); + try std.testing.expectEqual(PixelRect{ .x = 6, .w = 89, .h = 47 }, height.dst); + try std.testing.expect(height.sourcePoint(5, 20) == null); + try std.testing.expect(height.sourcePoint(6, 20) != null); +} + +test "native geometry rejects dimensions which its backend contract cannot represent" { + try std.testing.expect(containGeometry(0, 10, 20, 20) == null); + if (@bitSizeOf(usize) > 32) try std.testing.expect(containGeometry(@as(usize, std.math.maxInt(u32)) + 1, 10, 20, 20) == null); + if (comptime pdf_enabled) { + try std.testing.expect(nativeGeometry(10, 10, 0, 20, .width, 0, 0) == null); + if (@bitSizeOf(usize) > 32) + try std.testing.expect(nativeGeometry(@as(usize, std.math.maxInt(u32)) + 1, 10, 20, 20, .height, 0, 0) == null); + } +} + +test "native geometry is total at extreme accepted aspect ratios" { + if (comptime !pdf_enabled) return; + const largest = std.math.maxInt(u32); + try std.testing.expect(nativeGeometry(1, largest, largest, 1, .width, 0, 65535) == null); + try std.testing.expect(nativeGeometry(largest, 1, 1, largest, .height, 65535, 0) == null); + + const wide = nativeGeometry(largest, 1, largest, 1, .width, 0, 0).?; + try std.testing.expectEqual(PixelRect{ .w = largest, .h = 1 }, wide.dst); + const tall = nativeGeometry(1, largest, 1, largest, .height, 0, 0).?; + try std.testing.expectEqual(PixelRect{ .w = 1, .h = largest }, tall.dst); } /// call once at startup / exit (stb_image's allocator shim) diff --git a/src/look.zig b/src/look.zig index 9df2b485..4a8cb894 100644 --- a/src/look.zig +++ b/src/look.zig @@ -16,6 +16,7 @@ const builtin = @import("builtin"); const libc = std.c; const pardes = @import("pardes.zig"); const config = @import("config.zig"); +const pdf_enabled = @import("pardes_config").mupdf; const embedded_sources = if (pardes.platform == .web) @import("embedded_sources") else struct { pub const Source = struct { path: []const u8, contents: []const u8 }; pub const all = [_]Source{}; @@ -164,10 +165,21 @@ test "parsePathLine: spots, ranges, and the paths that merely look like them" { } } +/// A file-like Look target has a rendering kind only in MuPDF builds. The +/// feature-off enum has no `pdf` tag at all, so `.pdf` is indistinguishable +/// from any other ordinary file before it reaches the core. +pub const FileKind = if (pdf_enabled) enum { text, pdf } else enum { text }; + +pub const FileTarget = struct { + path: []const u8, + at: Spot, + kind: FileKind = .text, +}; + pub const Target = union(enum) { none, dir: []const u8, // resolved absolute path, in caller's buf - file: struct { path: []const u8, at: Spot }, + file: FileTarget, image: struct { path: []const u8 }, url: []const u8, /// `@p7:10:5` — pane 7, line 10, column 5 (0 = unspecified). The one @@ -183,6 +195,36 @@ pub fn isImagePath(path: []const u8) bool { return false; } +pub fn isPdfPath(path: []const u8) bool { + if (comptime !pdf_enabled) return false; + return std.ascii.endsWithIgnoreCase(path, ".pdf"); +} + +test "PDF file kinds exist only in MuPDF-enabled builds" { + try std.testing.expectEqual(pdf_enabled, isPdfPath("manual.PDF")); + try std.testing.expect(!isPdfPath("manual.pdf.txt")); + try std.testing.expectEqual( + pdf_enabled, + std.meta.stringToEnum(FileKind, "pdf") != null, + ); + try std.testing.expect(std.meta.stringToEnum(std.meta.Tag(Target), "pdf") == null); +} + +test ".pdf Look paths are ordinary files when MuPDF is disabled" { + if (!platform_has_fs) return; + var realbuf: [4096]u8 = undefined; + const target = resolve("docs/design.pdf", ".", &realbuf); + switch (target) { + .file => |file| { + if (comptime pdf_enabled) + try std.testing.expectEqual(FileKind.pdf, file.kind) + else + try std.testing.expectEqual(FileKind.text, file.kind); + }, + else => return error.PdfDidNotResolveAsFile, + } +} + /// Resolve a looked-at word against the pane's directory. `realbuf` must /// outlive the returned Target (native paths point into it; web paths are /// process-lifetime slices in the embedded source archive). @@ -217,6 +259,11 @@ pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target const rp = realpath(jz.ptr, realbuf) orelse return .none; const resolved = std.mem.span(rp); if (isDir(rp)) return .{ .dir = resolved }; + if (comptime pdf_enabled) if (isPdfPath(resolved)) return .{ .file = .{ + .path = resolved, + .at = pl.at, + .kind = .pdf, + } }; if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } }; return .{ .file = .{ .path = resolved, .at = pl.at } }; } else { diff --git a/src/output_pane.zig b/src/output_pane.zig index 3308e22a..bd11a227 100644 --- a/src/output_pane.zig +++ b/src/output_pane.zig @@ -254,15 +254,18 @@ pub fn openJumps(p: *Pardes, id: usize) void { for (p.jumps[0..p.njumps]) |j| { const jp = p.panes[j.pane] orelse continue; var idbuf: [16]u8 = undefined; - const has_path = if (jp.file) |f| f.output == null else false; + const pdf_path: ?[]const u8 = if (comptime pardes.pdf_enabled) jp.pdfPath() else null; + const has_path = if (jp.file) |f| f.output == null else pdf_path != null; const loc: []const u8 = if (has_path) - jp.file.?.path + (if (jp.file) |f| f.path else pdf_path.?) else std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch continue; const what: []const u8 = if (jp.file) |f| std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r") else if (jp.image) |iv| iv.path + else if (pdf_path) |path| + path else jp.cwdSlice(); // one line of a file can be the whole file: cut it, but never mid diff --git a/src/pardes.zig b/src/pardes.zig index 956b0e25..86dcb97f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -31,6 +31,8 @@ const file_pane = @import("file_pane.zig"); const output_pane = @import("output_pane.zig"); const builtins = @import("builtins.zig"); pub const config = @import("config.zig"); +pub const pdf_enabled = @import("pardes_config").mupdf; +const pdf_impl = if (pdf_enabled) @import("mupdf") else struct {}; pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); @@ -73,6 +75,512 @@ pub const Loc = struct { col: u32, }; +test "MuPDF pane renders, navigates, searches, and round-trips its page" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(hasPdf(pane)); + try std.testing.expect(pane.pdf.?.page_count > 1); + try std.testing.expectEqual(PdfFitMode.width, pane.pdf.?.fit); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); + try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w); + try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h); + const initial_tag = try p.tagText(p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, "pdf 1/") != null); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null); + try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null); + + p.native_images = true; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const first = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), first.nimages); + const first_place = first.images[0].?; + try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4); + try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit); + + // The default reading view moves one visible cell row without replacing + // the page pixels. Fit/pan are placement state, not texture identity. + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); + try std.testing.expect(pane.pdf.?.pan_y > 0); + try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_revision); + _ = frame.reset(.retain_capacity); + const panned = try p.render(frame.allocator()); + try std.testing.expectEqual(first_place.native.revision, panned.images[0].?.native.revision); + try std.testing.expectEqual(pane.pdf.?.pan_y, panned.images[0].?.native.pan_y); + + const row_pan = pane.pdf.?.pan_y; + pane.pdf.?.pan_y = 0; + p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); + const half_pan = pane.pdf.?.pan_y; + try std.testing.expect(half_pan > row_pan); + pane.pdf.?.pan_y = 0; + p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); + try std.testing.expect(pane.pdf.?.pan_y >= half_pan); + try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_revision); + + // Crossing the fit-width bottom enters the next page at its top. Crossing + // back enters the previous one at its bottom, ready to keep reading up. + pane.pdf.?.pan_y = std.math.maxInt(u16); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); + _ = frame.reset(.retain_capacity); + const second = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), second.nimages); + try std.testing.expect(second.images[0].?.native.revision != first_place.native.revision); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); + try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_y); + + // PdfFit exists as a real builtin in this build. It resets placement but + // preserves the current page pixels; fit-height j/k remains page-wise. + const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse + return error.MissingPdfFitBuiltin; + const revision_before_toggle = pane.pdf.?.render_revision; + p.runBuiltin(fit_builtin, 0, "", null); + try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); + try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); + try std.testing.expectEqual(revision_before_toggle, pane.pdf.?.render_revision); + const height_tag = try p.tagText(p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, height_tag, "pdf 1/") != null); + try std.testing.expect(std.mem.indexOf(u8, height_tag, " width ") == null); + try std.testing.expect(std.mem.indexOf(u8, height_tag, " height ") != null); + try std.testing.expect(std.mem.indexOf(u8, height_tag, "PdfFit") != null); + for (p.panes) |slot| { + const other = slot orelse continue; + if (hasPdf(other)) continue; + p.togglePdfFit(other); // pane-scoped and deliberately inert here + try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); + break; + } + + // A fit-height landscape page exposes horizontal overflow to a horizontal + // wheel without rerasterizing. Use synthetic dimensions only for the + // geometry check; no frame is drawn while they differ from the buffer. + const saved_iw = pane.pdf.?.iw; + const saved_ih = pane.pdf.?.ih; + pane.pdf.?.iw = 2000; + pane.pdf.?.ih = 500; + p.pdfHorizontalWheel(pane, 1); + try std.testing.expect(pane.pdf.?.pan_x > 0); + try std.testing.expectEqual(revision_before_toggle, pane.pdf.?.render_revision); + pane.pdf.?.iw = saved_iw; + pane.pdf.?.ih = saved_ih; + pane.pdf.?.pan_x = 0; + + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); + + const revision_before_search = pane.pdf.?.render_revision; + p.runSearch(0, "Pardes", .text, .top); + try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); + try std.testing.expectEqual(revision_before_search, pane.pdf.?.render_revision); + const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; + const results = p.panes[results_id].?.file.?.content; + try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null); + try std.testing.expect(p.searchStep(0, 1)); + try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); + + // Search state is owned and untruncated, and changing pages invalidates + // only page-local state while retaining the document query. + const long_query = "a query deliberately longer than any tag display budget: " ++ + "012345678901234567890123456789012345678901234567890123456789" ++ + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + p.setPdfSearchQuery(&pane.pdf.?, long_query); + try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); + const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0; + p.setPdfPage(pane, next_page); + try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); + + try p.dumpState(); + const restored = try Pardes.initFromDump(gpa, .{}, p.dump_out.?); + defer restored.deinit(); + try std.testing.expect(hasPdf(restored.panes[0].?)); + try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page); +} + +test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 120, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + p.native_images = true; + + var page_hits = try pv.document.search(gpa, 0, "Pardes"); + defer page_hits.deinit(gpa); + try std.testing.expect(page_hits.hit_count >= 3); + + p.runSearch(0, "Pardes", .text, .top); + const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; + const rows = p.panes[results_id].?.file.?.content; + try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null); + try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null); + try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null); + + // Exaggerate the cell aspect only to make the three fixture hits occupy + // distinct fit-width crops. The search/reveal math must use the same + // reported metrics as placement, whatever a backend reports. + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = .{ .w = 16, .h = 4 }, + } }); + + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + try std.testing.expect(p.searchStep(0, 1)); + try std.testing.expectEqual(@as(usize, 0), pv.search_hit); + const first = try p.render(frame.allocator()); + const revision = first.images[0].?.native.revision; + const first_pan = pv.pan_y; + const first_results = pv.search_results orelse return error.MissingPdfPageSearch; + const first_quad = for (first_results.quads) |item| { + if (item.hit == 0) break item.quad; + } else return error.MissingFirstPdfHit; + const first_y = Pardes.normalizedPdfPixel( + (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4, + pv.ih, + ); + const first_geometry = p.pdfGeometry(pane) orelse return error.MissingPdfGeometry; + try std.testing.expect(first_y >= first_geometry.src.y and + first_y < first_geometry.src.y + first_geometry.src.h); + + try std.testing.expect(p.searchStep(0, 1)); + try std.testing.expectEqual(@as(usize, 1), pv.search_hit); + _ = frame.reset(.retain_capacity); + const second = try p.render(frame.allocator()); + try std.testing.expectEqual(revision, second.images[0].?.native.revision); + + try std.testing.expect(p.searchStep(0, 1)); + try std.testing.expectEqual(@as(usize, 2), pv.search_hit); + _ = frame.reset(.retain_capacity); + const third = try p.render(frame.allocator()); + try std.testing.expectEqual(revision, third.images[0].?.native.revision); + try std.testing.expect(pv.pan_y != first_pan); + const third_results = pv.search_results orelse return error.MissingPdfPageSearch; + const third_quad = for (third_results.quads) |item| { + if (item.hit == 2) break item.quad; + } else return error.MissingThirdPdfHit; + const third_y = Pardes.normalizedPdfPixel( + (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4, + pv.ih, + ); + const third_geometry = p.pdfGeometry(pane) orelse return error.MissingPdfGeometry; + try std.testing.expect(third_y >= third_geometry.src.y and + third_y < third_geometry.src.y + third_geometry.src.h); +} + +test "PDF native mouse selection, Look, and highlights share page geometry" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + + // A host without Kitty/native pixels keeps the projected-text contract: + // j/k can still change pages and a body drag remains a generic selection. + const fallback = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), fallback.nimages); + p.update(.{ .key = .{ .cp = 'j' } }); + try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); + p.update(.{ .key = .{ .cp = 'k' } }); + try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); + const fallback_rect = p.rects[0]; + const fallback_col = fallback_rect.x + config.GUTTER + 1; + const fallback_row = fallback_rect.y + BOX_H + 1; + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = fallback_col, .row = fallback_row } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = fallback_col + 4, .row = fallback_row } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = fallback_col + 4, .row = fallback_row } }); + try std.testing.expectEqual(.done, pane.sel[sel_slot].state); + try std.testing.expect(pane.pdf.?.selection == null); + pane.sel[sel_slot].state = .none; + + p.native_images = true; + _ = frame.reset(.retain_capacity); + const plain = try p.render(frame.allocator()); + const plain_revision = plain.images[0].?.native.revision; + + var found = try pane.pdf.?.document.search(gpa, 0, "Pardes"); + defer found.deinit(gpa); + const quad = found.quads[0].quad; + const nx = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4; + const ny = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4; + const pv = &pane.pdf.?; + const source_x: u32 = @intCast(@min( + pv.iw - 1, + @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(pv.iw)))), + )); + const source_y: u32 = @intCast(@min( + pv.ih - 1, + @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(pv.ih)))), + )); + + // Bring the known word into the fit-width crop, then invert the shared + // source/destination geometry to the nearest body cell. + var geometry = p.pdfGeometry(pane).?; + if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) { + const overflow = @as(u32, @intCast(pv.ih)) - geometry.src.h; + const wanted = @min(overflow, source_y -| geometry.src.h / 2); + pv.pan_y = if (overflow == 0) 0 else @intCast( + (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, + ); + geometry = p.pdfGeometry(pane).?; + } + const pixel_x = geometry.dst.x + @as(u32, @intCast( + @as(u64, source_x - geometry.src.x) * geometry.dst.w / geometry.src.w, + )); + const pixel_y = geometry.dst.y + @as(u32, @intCast( + @as(u64, source_y - geometry.src.y) * geometry.dst.h / geometry.src.h, + )); + const r = p.rects[0]; + const base_col: i32 = @intCast(r.x + config.GUTTER + pixel_x / p.cell_pixels.w); + const base_row: i32 = @intCast(r.y + BOX_H + pixel_y / p.cell_pixels.h); + + var selected_col: ?u16 = null; + var selected_row: u16 = 0; + const nearby = [_]i32{ 0, -1, 1, -2, 2 }; + find_word: for (nearby) |dy| for (nearby) |dx| { + const col: u16 = @intCast(std.math.clamp( + base_col + dx, + @as(i32, r.x + config.GUTTER), + @as(i32, r.x + r.w - 1), + )); + const row: u16 = @intCast(std.math.clamp( + base_row + dy, + @as(i32, r.y + BOX_H), + @as(i32, r.y + r.h - 1), + )); + if (!p.beginPdfSelection(pane, col, row)) continue; + if (std.ascii.indexOfIgnoreCase(pv.selection_text, "Pardes") != null) { + selected_col = col; + selected_row = row; + break :find_word; + } + }; + const word_col = selected_col orelse return error.PdfMouseMappingMissedWord; + try std.testing.expect(std.ascii.indexOfIgnoreCase(p.heldSelection(0).?, "Pardes") != null); + + _ = frame.reset(.retain_capacity); + const selected_frame = try p.render(frame.allocator()); + const selected_revision = selected_frame.images[0].?.native.revision; + try std.testing.expect(selected_revision != plain_revision); + + // Repeating an identical drag endpoint is a no-op: Kitty/SDL keep the + // same texture generation instead of retransmitting identical pixels. + try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row)); + try std.testing.expect(pv.render_tried); + _ = frame.reset(.retain_capacity); + const duplicate = try p.render(frame.allocator()); + try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision); + + // A native right-click resolves the same MuPDF-snapped word and feeds it + // to Look. Search highlights precede selection highlights so the live + // selection remains visually authoritative where they overlap. + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = word_col, .row = selected_row } }); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = word_col, .row = selected_row } }); + try std.testing.expect(std.ascii.indexOfIgnoreCase(pv.search_query, "Pardes") != null); + p.resolvePdfSearch(pv); + const highlights = try p.pdfHighlights(pv); + try std.testing.expect(highlights.len > 1); + try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind); + try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind); + const revision_before_reveal = pv.render_revision; + pv.pan_y = std.math.maxInt(u16); + p.revealPdfSearch(pane, pv); + const revealed_pan = pv.pan_y; + try std.testing.expect(revealed_pan != std.math.maxInt(u16)); + try std.testing.expectEqual(revision_before_reveal, pv.render_revision); + pv.pan_y = std.math.maxInt(u16); + p.revealPdfSearch(pane, pv); // one shot: subsequent manual pan stays put + try std.testing.expectEqual(std.math.maxInt(u16), pv.pan_y); + pv.pan_y = revealed_pan; + _ = frame.reset(.retain_capacity); + const searched = try p.render(frame.allocator()); + try std.testing.expect(searched.images[0].?.native.revision != selected_revision); + + // Fit and viewport changes can crop a hit which was already revealed. + // Both rearm the placement-only reveal without forcing new page pixels. + const revision_before_geometry_change = searched.images[0].?.native.revision; + pv.search_reveal_pending = false; + p.togglePdfFit(pane); + try std.testing.expect(pv.search_reveal_pending); + pv.search_reveal_pending = false; + p.togglePdfFit(pane); // restore the reading-width geometry + try std.testing.expect(pv.search_reveal_pending); + pv.search_reveal_pending = false; + const original_cell_pixels = p.cell_pixels; + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = p.cell_pixels, + } }); + try std.testing.expect(!pv.search_reveal_pending); + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = .{ + .w = original_cell_pixels.w, + .h = original_cell_pixels.h + 1, + }, + } }); + try std.testing.expect(pv.search_reveal_pending); + _ = frame.reset(.retain_capacity); + const geometry_changed = try p.render(frame.allocator()); + try std.testing.expectEqual( + revision_before_geometry_change, + geometry_changed.images[0].?.native.revision, + ); + p.update(.{ .resize = .{ + .cols = p.screen_w, + .rows = p.screen_h, + .cell_pixels = original_cell_pixels, + } }); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + + // A fresh modal range supersedes native selection, and the select-button's + // no-drag click retains its longstanding "clear selection" contract. + p.update(.{ .key = .{ .cp = 'v' } }); + try std.testing.expect(pv.selection == null); + try std.testing.expect(pane.vsel.active); + pane.vsel.active = false; + try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row)); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row } }); + p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = word_col, .row = selected_row } }); + try std.testing.expect(pv.selection == null); + try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row)); + + // Native drag events update MuPDF quads and copied text immediately, but + // keep the already transmitted page generation stable until release. + _ = frame.reset(.retain_capacity); + const before_drag = try p.render(frame.allocator()); + const before_drag_revision = before_drag.images[0].?.native.revision; + const drag_col = @min(r.x + r.w - 1, word_col +| 12); + const drag_row = @min(r.y + r.h - 1, selected_row +| 4); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .press, + .col = word_col, + .row = selected_row, + } }); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .drag, + .col = @min(drag_col, word_col +| 4), + .row = @min(drag_row, selected_row +| 2), + } }); + _ = frame.reset(.retain_capacity); + const during_first_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + before_drag_revision, + during_first_drag.images[0].?.native.revision, + ); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .drag, + .col = drag_col, + .row = drag_row, + } }); + try std.testing.expect(p.drag.select.pdf.selection_changed); + _ = frame.reset(.retain_capacity); + const during_second_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + before_drag_revision, + during_second_drag.images[0].?.native.revision, + ); + p.update(.{ .mouse = .{ + .button = config.select_button, + .kind = .release, + .col = drag_col, + .row = drag_row, + } }); + try std.testing.expect(!pv.render_tried); + _ = frame.reset(.retain_capacity); + const committed_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + before_drag_revision + 1, + committed_drag.images[0].?.native.revision, + ); + _ = frame.reset(.retain_capacity); + const stable_drag = try p.render(frame.allocator()); + try std.testing.expectEqual( + committed_drag.images[0].?.native.revision, + stable_drag.images[0].?.native.revision, + ); + + const saved_query = try gpa.dupe(u8, pv.search_query); + defer gpa.free(saved_query); + p.setPdfPage(pane, 1); + try std.testing.expect(pv.selection == null); + try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len); + try std.testing.expectEqualStrings(saved_query, pv.search_query); +} + +test "PDF native Exec release may delete its own pane safely" { + if (!pdf_enabled or platform == .web) return; + + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + const pv = &pane.pdf.?; + pv.selection_text = try gpa.dupe(u8, "Del"); + pv.drag_anchor = .{ .x = 0.5, .y = 0.5 }; + pv.drag_head = pv.drag_anchor; + + p.pdfDragRelease(pane, .{ + .id = 0, + .button = config.exec_button, + .pdf = .{ .native = true }, + }); + try std.testing.expect(p.panes[0] == null); +} + +test "feature-off .pdf argv opens an ordinary file pane" { + if (comptime pdf_enabled or platform == .web) return; + + const p = try Pardes.init(std.testing.allocator, .{ + .file = "docs/design.pdf", + .cols = 80, + .rows = 24, + }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(pane.file != null); + try std.testing.expect(pane.image == null); + try std.testing.expect(!hasPdf(pane)); +} + const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; @@ -89,6 +597,16 @@ const file_pane_tail = " " ++ config.file_pane_builtins_str; // sits with the other two comptime folds (builtin_rows, the topbar check). const Builtin = builtins.Builtin(); +test "PdfFit builtin and leader path follow the MuPDF feature gate" { + const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit"); + try std.testing.expectEqual(pdf_enabled, maybe_fit != null); + if (pdf_enabled) { + const path = config.leader_path.get(maybe_fit.?) orelse + return error.MissingPdfFitLeaderPath; + try std.testing.expectEqualStrings("tz", path); + } +} + /// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN /// declarations rather than a table beside them — a new special key names /// itself here, and a wrong name is impossible because there is only one. The @@ -390,13 +908,50 @@ pub const Cell = struct { } }; +/// One generation of a pixel attachment. `serial` identifies the pane for its +/// whole lifetime; `revision` identifies pixels rendered later by that same +/// pane (for example, a different PDF page or zoom level). Backends must use +/// both: pane slots are reused, while a live pane may replace its pixels. +pub const ImageCacheKey = if (pdf_enabled) struct { + serial: u32, + revision: u32, + + pub fn eql(a: @This(), b: @This()) bool { + return a.serial == b.serial and a.revision == b.revision; + } +} else struct { + serial: u32, + + pub fn eql(a: @This(), b: @This()) bool { + return a.serial == b.serial; + } +}; + +/// Native PDF sizing is deliberately semantic rather than a backend knob. +/// Width mode is the document-reading view (the page may be panned vertically); +/// height mode keeps one whole page-height visible and may pan wide pages. +/// The type remains available to shared core code, while PdfView itself and +/// the builtin which mutates it are compiled away with `-Dmupdf=false`. +const PdfFitMode = if (pdf_enabled) enum { width, height } else void; + +/// Dynamic placement exists only for native PDF pages. Static image panes need +/// only their pane identity, so feature-off builds carry a zero-bit payload. +pub const NativePlacement = if (pdf_enabled) struct { + revision: u32 = 0, + fit: image.NativeFit = .contain, + pan_x: u16 = 0, + pan_y: u16 = 0, +} else struct {}; + /// A pixel image riding the surface: the shell transmits/places it over the /// given cell rect (tty: Kitty graphics; SDL: alpha-blended GPU texture). +/// This is also the backend-neutral transport for rasterized PDF pages. pub const ImagePlace = struct { pane: u8, // cache slot - /// Pane slots are reused. This generation makes a cached GPU texture or + /// Pane slots are reused. This identity makes a cached GPU texture or /// kitty image unambiguously belong to the pane which supplied the bytes. serial: u32, + native: NativePlacement = .{}, x: u16, y: u16, w: u16, @@ -404,8 +959,57 @@ pub const ImagePlace = struct { rgba: []const u8, iw: usize, ih: usize, + + pub fn cacheKey(place: ImagePlace) ImageCacheKey { + if (comptime pdf_enabled) + return .{ .serial = place.serial, .revision = place.native.revision }; + return .{ .serial = place.serial }; + } }; +test "pixel attachment cache key follows both pane lifetime and rendered revision" { + if (comptime pdf_enabled) { + const first = ImageCacheKey{ .serial = 41, .revision = 3 }; + try std.testing.expect(first.eql(.{ .serial = 41, .revision = 3 })); + try std.testing.expect(!first.eql(.{ .serial = 41, .revision = 4 })); + try std.testing.expect(!first.eql(.{ .serial = 42, .revision = 3 })); + } else { + const first = ImageCacheKey{ .serial = 41 }; + try std.testing.expect(first.eql(.{ .serial = 41 })); + try std.testing.expect(!first.eql(.{ .serial = 42 })); + } + try std.testing.expectEqual(pdf_enabled, @hasField(ImageCacheKey, "revision")); + try std.testing.expectEqual(pdf_enabled, @hasField(NativePlacement, "fit")); + try std.testing.expectEqual(pdf_enabled, @hasField(CellPixels, "w")); + if (comptime !pdf_enabled) { + try std.testing.expectEqual(@as(usize, 4), @sizeOf(ImageCacheKey)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(NativePlacement)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(CellPixels)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfDrag)); + } +} + +test "pixel attachment fit and pan do not invalidate cached pixels" { + if (comptime !pdf_enabled) return; + var place = ImagePlace{ + .pane = 0, + .serial = 41, + .native = .{ .revision = 3 }, + .x = 0, + .y = 0, + .w = 1, + .h = 1, + .rgba = &.{ 0, 0, 0, 255 }, + .iw = 1, + .ih = 1, + }; + const key = place.cacheKey(); + place.native.fit = .width; + place.native.pan_x = 1234; + place.native.pan_y = 65535; + try std.testing.expect(key.eql(place.cacheKey())); +} + /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. pub const Surface = struct { @@ -570,10 +1174,25 @@ fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; } +/// Pixel dimensions are meaningful only to the native PDF placement path. +/// Keep resize events layout-only when that path is not part of the build. +pub const CellPixels = if (pdf_enabled) struct { + w: u16 = 8, + h: u16 = 16, +} else struct {}; + pub const Event = union(enum) { key: Key, mouse: Mouse, - resize: struct { cols: u16, rows: u16 }, + resize: struct { + cols: u16, + rows: u16, + /// Physical pixels in one grid cell, present only when native PDF + /// placement is compiled in. Defaults keep headless/core callers + /// useful and give terminals which cannot report pixels the + /// conventional 1:2 cell aspect. + cell_pixels: CellPixels = .{}, + }, output: struct { pane: u8, bytes: []const u8 }, eof: struct { pane: u8 }, /// a language query the shell ran on a worker has finished. `rows` is @@ -784,6 +1403,75 @@ pub const Image = struct { grid_ascii: bool = true, }; +const PdfPoint = if (pdf_enabled) pdf_impl.Point else void; +const PdfHighlight = if (pdf_enabled) pdf_impl.Highlight else void; + +/// A PDF pane is compiled out as a zero-bit `void` field when MuPDF is off. +/// With it enabled, the pane owns one live document, one lazy current-page +/// raster for SDL/Kitty, and one lazy text projection for non-pixel hosts and +/// normal Pardes search semantics. +const PdfView = if (pdf_enabled) struct { + path: []u8, + document: pdf_impl.Document, + page: usize = 0, + page_count: usize, + fit: PdfFitMode = .width, + /// Normalized 0..65535 pan positions. NativeGeometry maps the extrema + /// exactly onto the source overflow, independent of page or cell size. + pan_x: u16 = 0, + pan_y: u16 = 0, + rgba: []u8 = &.{}, + /// Search/selection geometry changed without changing fit or pan. The next + /// draw transactionally regenerates this one RGBA buffer with MuPDF's baked + /// highlights; a second full-page buffer is never retained after the swap. + highlights_dirty: bool = false, + search_reveal_pending: bool = false, + search_results: ?pdf_impl.SearchResults = null, + selection: ?pdf_impl.Selection = null, + selection_text: []u8 = &.{}, + selection_anchor: ?pdf_impl.Point = null, + selection_head: ?pdf_impl.Point = null, + drag_anchor: ?pdf_impl.Point = null, + drag_head: ?pdf_impl.Point = null, + iw: usize = 0, + ih: usize = 0, + render_tried: bool = false, + render_revision: u32 = 0, + text: []u8 = &.{}, + text_tried: bool = false, + text_scroll: usize = 0, + /// Full, untruncated PDF query. +Search records only a display/Look copy; + /// this survives page navigation while page-local quads are rebuilt. + search_query: []u8 = &.{}, + /// Zero-based logical MuPDF hit on the current page. Result rows encode + /// this as Spot.col + 1, letting same-page occurrences reveal distinctly. + search_hit: usize = 0, + /// Last viewport for which search_hit was revealed. Comparing this at + /// draw time covers layout/border changes without treating every frame + /// (or a manual pan) as a reason to snap back to the hit. + reveal_viewport_w: u32 = 0, + reveal_viewport_h: u32 = 0, + reveal_fit: PdfFitMode = .width, + reveal_viewport_valid: bool = false, +} else void; + +const PdfSlot = if (pdf_enabled) ?PdfView else void; + +fn hasPdf(pane: *const Pane) bool { + return if (comptime pdf_enabled) pane.pdf != null else false; +} + +fn hasPdfSelection(pane: *const Pane) bool { + return if (comptime pdf_enabled) + if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false + else + false; +} + +test "feature-off PDF pane state is zero-sized" { + if (!pdf_enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfSlot)); +} + pub const Pane = struct { vt: ghostty_vt.Terminal, stream: ghostty_vt.TerminalStream, @@ -805,6 +1493,7 @@ pub const Pane = struct { greet: bool = false, file: ?File = null, image: ?Image = null, + pdf: PdfSlot = if (pdf_enabled) null else {}, msel: LineSel = .{}, vsel: CharSel = .{}, /// MULTIPLE CURSORS. helix's Selection is a list of ranges plus a primary @@ -920,7 +1609,17 @@ pub const Pane = struct { } pub fn isTerminal(pane: *const Pane) bool { - return pane.file == null and pane.image == null; + return pane.file == null and pane.image == null and !hasPdf(pane); + } + + pub fn pdfPath(pane: *const Pane) ?[]const u8 { + if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.path; + return null; + } + + pub fn pdfPage(pane: *const Pane) ?usize { + if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.page; + return null; } /// Surface row of shell row `g`. The edit buffer's lines stand in for the @@ -947,6 +1646,7 @@ pub const Pane = struct { /// current scroll offset: file top line, or the scrollback offset pub fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); + if (comptime pdf_enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll); return pane.surfRow(@intCast(pane.vt.screens.active.pages.scrollbar().offset)); } @@ -959,6 +1659,16 @@ pub const Pane = struct { f.scroll = next; f.syntax_dirty = true; } + } else if (hasPdf(pane)) { + if (comptime pdf_enabled) { + const pv = &pane.pdf.?; + const max: i64 = @intCast(modal.lineCount(pv.text) -| 1); + pv.text_scroll = @intCast(std.math.clamp( + @as(i64, @intCast(pv.text_scroll)) + delta, + 0, + max, + )); + } } else { // the vt scrolls in SHELL rows; convert through the edit buffer const off: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); @@ -978,6 +1688,10 @@ pub const Pane = struct { // terminals overshoot harmlessly — the vt clamps at the live bottom var to = pane.cur_row + margin; if (pane.file) |*f| to = @min(to, @as(i32, @intCast(file_pane.nlines(pane.gpa, f) -| 1))); + if (comptime pdf_enabled) { + if (pane.pdf) |pv| + to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1))); + } pane.scrollBy(@max(0, to - last)); } // the horizontal mirror, files only: keep scroll_off columns of @@ -996,12 +1710,13 @@ pub const Pane = struct { /// the last navigable row: file -> last content line; terminal -> viewport bottom fn lastNavRow(pane: *Pane) i32 { if (pane.file) |*f| return @intCast(file_pane.nlines(pane.gpa, f) -| 1); + if (hasPdf(pane)) return @intCast(modal.lineCount(if (comptime pdf_enabled) pane.pdf.?.text else "") -| 1); return pane.scroll() + @as(i32, pane.rows) - 1; } fn pinCursor(pane: *Pane) void { if (pane.cur_pinned) return; - if (pane.file != null) { + if (pane.file != null or hasPdf(pane)) { pane.cur_row = pane.scroll(); pane.cur_col = 0; } else { @@ -1013,6 +1728,13 @@ pub const Pane = struct { } }; +const PdfDrag = if (pdf_enabled) struct { + native: bool = false, + /// Drag updates keep selection geometry/text live, but their expensive + /// baked raster highlight is committed once on release. + selection_changed: bool = false, +} else struct {}; + const Drag = union(enum) { none, /// `corner_idx` is what makes this a CORNER grab: the press landed on a @@ -1042,7 +1764,15 @@ const Drag = union(enum) { tag: struct { id: usize }, /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag — /// the drag's own release is then inert - select: struct { id: usize, button: Mouse.Button, chorded: bool = false, ctrl: bool = false }, + select: struct { + id: usize, + button: Mouse.Button, + chorded: bool = false, + ctrl: bool = false, + /// This gesture began over a usable native PDF raster. The payload is + /// zero-bit when PDF support is absent. + pdf: PdfDrag = .{}, + }, }; // The two border clamps, pulled out as plain arithmetic on plain numbers for @@ -1140,6 +1870,7 @@ pub const Pardes = struct { opts: Options, screen_w: u16, screen_h: u16, + cell_pixels: CellPixels = .{}, resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), @@ -1263,10 +1994,13 @@ pub const Pardes = struct { if (opts.file) |path| { // FILE argv boot: the doc focused in the left column, a terminal // on the launch directory in the right (files left, shells right) - _ = if (look.isImagePath(path)) - try p.openImageView(0, path) - else - try file_pane.open(p, 0, path, opts.file_line); + _ = initial_doc: { + if (comptime pdf_enabled) if (look.isPdfPath(path)) + break :initial_doc try p.openPdfView(0, path, opts.file_line); + if (look.isImagePath(path)) + break :initial_doc try p.openImageView(0, path); + break :initial_doc try file_pane.open(p, 0, path, opts.file_line); + }; const sh = try p.newPane(1); sh.greet = true; p.ncol = 2; @@ -1350,6 +2084,18 @@ pub const Pardes = struct { if (iv.raw.len > 0) p.gpa.free(iv.raw); if (iv.grid.len > 0) p.gpa.free(iv.grid); } + if (comptime pdf_enabled) { + if (pane.pdf) |*pv| { + p.gpa.free(pv.path); + if (pv.rgba.len > 0) p.gpa.free(pv.rgba); + if (pv.text.len > 0) p.gpa.free(pv.text); + if (pv.search_query.len > 0) p.gpa.free(pv.search_query); + if (pv.search_results) |*results| results.deinit(p.gpa); + if (pv.selection) |*selection| selection.deinit(p.gpa); + if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text); + pv.document.deinit(); + } + } if (pane.file) |*f| { // a watched file is going away: tell the shell to drop it. The id // comes from the slot, which every caller still has pointing at @@ -1476,8 +2222,31 @@ pub const Pardes = struct { pub fn update(p: *Pardes, ev: Event) void { switch (ev) { .resize => |sz| { - p.screen_w = sz.cols; - p.screen_h = sz.rows; + if (comptime pdf_enabled) { + var before: [MAX_PANES]?PdfViewport = @splat(null); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + if (pane.pdf != null) before[id] = p.pdfViewport(pane); + } + p.screen_w = sz.cols; + p.screen_h = sz.rows; + p.cell_pixels.w = @max(1, sz.cell_pixels.w); + p.cell_pixels.h = @max(1, sz.cell_pixels.h); + // Compare the effective per-pane pixel viewport, not the + // resize event itself: duplicate SIGWINCH notifications + // must not undo a reader's manual pan. + p.computeGeom(); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + if (pane.pdf) |*pv| { + if (!std.meta.eql(before[id], p.pdfViewport(pane))) + pv.search_reveal_pending = pv.search_query.len > 0; + } + } + } else { + p.screen_w = sz.cols; + p.screen_h = sz.rows; + } p.resize_count += 1; }, .output => |o| { @@ -1557,6 +2326,11 @@ pub const Pardes = struct { /// pane's. fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); + if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( + arena, + "pdf {d}/{d} {s} PdfFit {s}", + .{ pv.page + 1, pv.page_count, @tagName(pv.fit), pv.path }, + ); if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path}); if (pane.file) |f| return arena.dupe(u8, f.path); return arena.dupe(u8, pane.cwdSlice()); @@ -1943,6 +2717,8 @@ pub const Pardes = struct { /// the active modal selection as text (v range or x lines), if any fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 { + if (comptime pdf_enabled) if (p.native_images) if (pane.pdf) |pv| + if (pv.selection != null and pv.selection_text.len > 0) return pv.selection_text; if (pane.vsel.active) return p.vselText(pane); if (!pane.msel.active) return null; return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); @@ -1965,6 +2741,10 @@ pub const Pardes = struct { }; if (k > 0 and (i == first or (k > 1 and i == p.active))) continue; const t = p.panes[i] orelse continue; + if (comptime pdf_enabled) if (p.native_images) if (t.pdf) |pv| { + if (pv.selection != null and pv.selection_text.len > 0) + return pv.selection_text; + }; if (t.sel[sel_slot].state == .done) { if (p.selectionText(t, t.sel[sel_slot]) catch null) |tx| return tx; } else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) { @@ -2097,7 +2877,8 @@ pub const Pardes = struct { if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; pane.pinCursor(); - const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + const explicit = (p.native_images and hasPdfSelection(pane)) or + (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { if (p.currentSelText(pane)) |txt| { pane.vsel.active = false; @@ -2113,6 +2894,7 @@ pub const Pardes = struct { p.runBuiltin(cmd, p.active, "", word); return; } + if (pane.mode == .normal and p.pdfNavigationKey(pane, key)) return; // normal-mode `p`/`P` paste the core's yank register directly (helix: // the default register, NOT the system clipboard — most terminals // refuse the OSC 52 read, so a round trip would never come back). @@ -2127,7 +2909,18 @@ pub const Pardes = struct { return; } switch (pane.mode) { - .normal => p.handleNormal(pane, key), + .normal => { + const id = p.active; + const serial = pane.serial; + p.handleNormal(pane, key); + // A new keyboard/modal range supersedes a mouse-native PDF + // selection. Otherwise currentSelText would keep returning the + // stale MuPDF text and make v/x appear to select nothing. + const live = p.panes[id] orelse return; + if (live.serial == serial and hasPdfSelection(live) and + (live.vsel.active or live.msel.active or live.nsel > 0)) + p.clearPdfSelection(live); + }, .insert => { if (hit(key, config.escape)) { pane.mode = .normal; @@ -2400,6 +3193,16 @@ pub const Pardes = struct { row0: i32, // absolute row of lines[0] }; + fn ensurePdfText(p: *Pardes, pane: *Pane) ![]const u8 { + if (comptime !pdf_enabled) return error.PdfDisabled; + const pv = &(pane.pdf orelse return error.NotPdf); + if (!pv.text_tried) { + pv.text_tried = true; + pv.text = pv.document.pageText(p.gpa, pv.page) catch &.{}; + } + return pv.text; + } + /// The lines the cursor moves over, absolute rows. File: all content lines. /// Terminal: the whole history+active grid with the edit buffer's lines /// standing in for the rows it covers, so motions ride the scrollback and @@ -2421,6 +3224,13 @@ pub const Pardes = struct { } return .{ .lines = ls, .row0 = 0 }; } + if (hasPdf(pane)) { + const text = try p.ensurePdfText(pane); + var ls: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |line| try ls.append(arena, line); + return .{ .lines = ls.items, .row0 = 0 }; + } const rows = try term_pane.shellRows(p, pane); // the buffer may sit below the dump's last row (the grid's trailing // blanks are trimmed off), so run the loop to whichever ends later @@ -2463,6 +3273,7 @@ pub const Pardes = struct { /// cursor-lines dump (scratch-arena backed, same lifetime as pl) fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 { if (pane.file) |f| return f.content; + if (hasPdf(pane)) return p.ensurePdfText(pane); const arena = p.scratch.allocator(); var buf: std.ArrayList(u8) = .empty; for (pl.lines, 0..) |ln, i| { @@ -3708,6 +4519,76 @@ pub const Pardes = struct { // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- const Search = enum { text, find, grep }; + const SearchStart = enum { top, cursor }; + + fn paneDir(pane: *const Pane) []const u8 { + if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/"; + if (comptime pdf_enabled) if (pane.pdf) |pv| + return std.fs.path.dirname(pv.path) orelse "/"; + return pane.cwdSlice(); + } + + const PdfSearchResult = struct { rows: usize = 0, anchor: ?usize = null }; + + /// Ask MuPDF for each page's logical hits, so the result list and the + /// quads used to highlight/reveal can never disagree. Locations spell + /// `path:PAGE:HIT`, reusing Spot.col as a one-based page-local hit id. + /// The compact snippet is the query itself: pairing MuPDF geometry back to + /// extracted lines would be heuristic, and a plausible wrong line is less + /// honest than the exact occurrence identity already in the address. + fn searchPdf( + p: *Pardes, + pane: *Pane, + pat: []const u8, + start: SearchStart, + out: *std.ArrayList(u8), + ) PdfSearchResult { + if (comptime !pdf_enabled) return .{}; + const pv = &(pane.pdf orelse return .{}); + p.setPdfSearchQuery(pv, pat); + const arena = p.scratch.allocator(); + const shown = std.fs.path.basename(pv.path); + var result: PdfSearchResult = .{}; + const max_hits = 512; + var snippet_len = @min(pat.len, 200); + while (snippet_len > 0 and snippet_len < pat.len and pat[snippet_len] & 0xc0 == 0x80) + snippet_len -= 1; + const snippet = pat[0..snippet_len]; + for (0..pv.page_count) |page| { + if (result.rows >= max_hits) break; + var found = pv.document.search(p.gpa, page, pat) catch continue; + defer found.deinit(p.gpa); + + // A native right-click selection identifies the occurrence the + // user is already on. Compare hit centers against its oriented + // selection quads; the next step then advances past that hit. + const cursor_hit: ?usize = if (start == .cursor and page == pv.page) cursor: { + const selection = pv.selection orelse break :cursor null; + for (found.quads) |item| { + const q = item.quad; + const center: PdfPoint = .{ + .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4, + .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4, + }; + if (selection.contains(center)) break :cursor item.hit; + } + break :cursor null; + } else null; + + for (0..found.hit_count) |hit_index| { + if (result.rows >= max_hits) break; + if (start == .cursor and + (page < pv.page or (page == pv.page and cursor_hit != null and hit_index <= cursor_hit.?))) + result.anchor = result.rows; + const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ + shown, page + 1, hit_index + 1, snippet, + }) catch return result; + out.appendSlice(arena, row) catch return result; + result.rows += 1; + } + } + return result; + } /// `/` (and the Find builtin) on any pane: type the pattern into the tag /// tail after `marker` — the existing modal one-line editor, visible while @@ -3778,14 +4659,14 @@ pub const Pardes = struct { /// there and the first step goes to the next one (acme's button-3 walking a /// word). `/`, Find and Grep point at nothing, so their list is walked from /// the top, which is also the only place a list of OTHER files could start. - pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: enum { top, cursor }) void { + pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; const arena = p.scratch.allocator(); // where the pane lives: a file's directory, a shell's cwd — the walk // root, and the directory the results buffer is named in. - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const dir = paneDir(pane); var out: std.ArrayList(u8) = .empty; var nrows: usize = 0; var anchor: ?usize = null; @@ -3799,7 +4680,7 @@ pub const Pardes = struct { var nroots: usize = 0; for (p.panes) |slot| { const pp = slot orelse continue; - const d = if (pp.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pp.cwdSlice(); + const d = paneDir(pp); var covered = false; var k: usize = 0; while (k < nroots) { @@ -3819,7 +4700,11 @@ pub const Pardes = struct { nroots += 1; } for (roots[0..nroots]) |r| look.grep(arena, p.gpa, r, dir, pat, &out); - } else if (kind == .find) look.find(arena, dir, pat, &out) else { + } else if (kind == .find) look.find(arena, dir, pat, &out) else if (hasPdf(pane)) { + const found = p.searchPdf(pane, pat, start, &out); + nrows = found.rows; + anchor = found.anchor; + } else { const pl = p.paneCursorLines(pane) catch return; // a real file names itself; a terminal or an output buffer has no path const has_path = if (pane.file) |f| f.output == null else false; @@ -4104,10 +4989,10 @@ pub const Pardes = struct { /// you click anywhere and type there, which is what the insertion runs /// did by padding at render time). Insert-mode callers pass their column; /// everything else passes -1 and leaves the text alone. - /// Null if the pane holds no editable text (an image). + /// Null if the pane holds no editable text (an image or PDF projection). fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; - if (pane.image != null) return null; + if (pane.image != null or hasPdf(pane)) return null; const want_lo = @max(0, @min(lo, hi)); const want_hi = @max(want_lo, @max(lo, hi)); const fresh = pane.ovl == null; @@ -4176,6 +5061,7 @@ pub const Pardes = struct { const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { + if (hasPdf(pane)) return; pane.pinCursor(); // snapshot once per insert session (WITH the pre-insert selection) so // `u` undoes the whole session and restores what was selected @@ -5509,7 +6395,11 @@ pub const Pardes = struct { // the answer is never in doubt here _ = config.wheelTick(&p.wheel_guard, true); const id = hovered orelse return; - p.panes[id].?.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); + const pane = p.panes[id].?; + if (hasPdf(pane)) + p.pdfVerticalWheel(pane, if (m.button == .wheel_up) -1 else 1) + else + pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); }, .wheel_left, .wheel_right => { if (m.kind != .press) return; @@ -5520,10 +6410,13 @@ pub const Pardes = struct { if (!config.wheelTick(&p.wheel_guard, false)) return; const id = hovered orelse return; const pane = p.panes[id].?; - // ponytail: no right clamp — overscroll shows blank and the - // next cursor move or left wheel pulls it back - if (pane.file != null) + if (hasPdf(pane)) { + p.pdfHorizontalWheel(pane, if (m.button == .wheel_right) 1 else -1); + // ponytail: no right clamp — overscroll shows blank and the + // next cursor move or left wheel pulls it back + } else if (pane.file != null) { pane.hscroll = @max(0, pane.hscroll + (if (m.button == .wheel_right) config.wheel_cols else -config.wheel_cols)); + } }, config.select_button => switch (m.kind) { .press => { @@ -5609,11 +6502,18 @@ pub const Pardes = struct { const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; + const pdf_native = if (comptime pdf_enabled) p.pdfNativeReady(pane) else false; + if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, false); // Ctrl rides on the drag rather than firing here: the // click does not place the modal cursor until RELEASE // (dragRelease), and a goto asked at press time would // answer about wherever the cursor happened to be. - p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl } }; + p.drag = .{ .select = .{ + .id = id, + .button = config.select_button, + .ctrl = m.ctrl, + .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{}, + } }; } }, .drag => p.dragUpdate(mcol, mrow), @@ -5664,7 +6564,21 @@ pub const Pardes = struct { const c: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); pane.sel[@intFromEnum(m.button)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }; - p.drag = .{ .select = .{ .id = id, .button = m.button } }; + // Once a body gesture starts over a native PDF, keep + // it native even when the point misses a letterbox or + // selection fails. pdfDragRelease makes a null anchor + // inert instead of executing hidden projected text. + // Tag clicks remain ordinary text/builtin gestures. + const pdf_native = if (comptime pdf_enabled) + mrow >= r.y + BOX_H and p.pdfNativeReady(pane) + else + false; + if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, true); + p.drag = .{ .select = .{ + .id = id, + .button = m.button, + .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{}, + } }; } }, .drag => p.dragUpdate(mcol, mrow), @@ -5708,12 +6622,18 @@ pub const Pardes = struct { d.cur_x = mcol; d.cur_y = mrow; }, - .select => |s| { + .select => |*s| { if (p.panes[s.id]) |pane| { const r = p.rects[s.id]; const b = @intFromEnum(s.button); pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y); + if (comptime pdf_enabled) { + if (s.pdf.native) { + if (p.updatePdfSelection(pane, mcol, mrow, false)) + s.pdf.selection_changed = true; + } + } } }, .tag => |d| { @@ -5730,6 +6650,50 @@ pub const Pardes = struct { } } + fn pdfDragRelease(p: *Pardes, pane: *Pane, s: @FieldType(Drag, "select")) void { + if (comptime !pdf_enabled) return; + const pv = &(pane.pdf orelse return); + const b = @intFromEnum(s.button); + const sl = pane.sel[b]; + pane.sel[b].state = .none; // native quads, not the projected text grid + // Every native drag keeps its geometry/text live while holding the + // baked highlight stable. Commit before chord/Exec/Look dispatch; + // those paths may clear or even delete the pane. + if (s.pdf.selection_changed) p.invalidatePdfRaster(pv); + if (s.chorded) { + clearPdfDrag(pv); + return; + } + if (s.button == config.select_button) { + const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; + if (!dragged) { + p.clearPdfSelection(pane); + } + pane.cur_pinned = true; + pane.mode = .normal; + pane.msel.active = false; + pane.vsel.active = false; + pane.pending = 0; + pane.nsel = 0; + clearPdfDrag(pv); + return; + } + + if (pv.drag_anchor == null or pv.selection_text.len == 0) { + clearPdfDrag(pv); + return; + } + const text = pv.selection_text; + // Exec can run Del and free `pane`; end every pv access before the + // builtin dispatch so no deferred cleanup writes through dead state. + clearPdfDrag(pv); + const arg = p.chord_arg; + p.chord_arg = null; + defer if (arg) |a| p.gpa.free(a); + const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; + p.runBuiltin(cmd, s.id, "", p.withArg(text, arg)); + } + fn dragRelease(p: *Pardes, button: Mouse.Button) void { // a release of a button OTHER than the one driving a select drag is // the middle+left chord's left release: the middle drag keeps going @@ -5765,6 +6729,9 @@ pub const Pardes = struct { }, .select => |s| { const pane = p.panes[s.id] orelse return; + if (comptime pdf_enabled) { + if (s.pdf.native) return p.pdfDragRelease(pane, s); + } if (s.chorded) { // a cut/paste chord consumed this drag; the release is inert pane.sel[@intFromEnum(s.button)].state = .none; @@ -5893,6 +6860,16 @@ pub const Pardes = struct { fn chordCutPaste(p: *Pardes, cut: bool) void { const s = &p.drag.select; const pane = p.panes[s.id] orelse return; + if (comptime pdf_enabled) if (pane.pdf) |pv| { + if (!s.chorded) { + s.chorded = true; + pane.sel[sel_slot].state = .none; + } + // A PDF is immutable: its Cut-side chord is Snarf (copy), while + // Paste has no document mutation to perform. + if (cut and pv.selection_text.len > 0) p.setYank(pv.selection_text); + return; + }; if (pane.mode == .tty) { // tty: nothing can be cut — 1-2 stays inert; the pty owns the // screen. 1-3 pastes like a terminal emulator: the click is @@ -6143,7 +7120,7 @@ pub const Pardes = struct { const src = p.panes[src_id] orelse return; const src_h = p.rects[src_id].h; const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1; - const cur: u16 = if (src.file != null) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1; + const cur: u16 = if (!src.isTerminal()) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1; // cap keep so a content-full source still leaves the new pane a tag + // a few body rows (an Alt-n from a full shell was born 0 rows tall) const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); @@ -6185,7 +7162,7 @@ pub const Pardes = struct { var tty_id: usize = 0; for (0..p.col_n[col]) |k| { const cid = p.col_terms[col][k]; - if (p.panes[cid]) |ct| if (ct.file == null) { + if (p.panes[cid]) |ct| if (ct.isTerminal()) { n_tty += 1; tty_id = cid; }; @@ -6206,7 +7183,7 @@ pub const Pardes = struct { /// focus (execute keeps you where you were; look focuses). fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { for (p.panes, 0..) |slot, i| if (slot) |tt| { - if (tt.file != null or tt.image != null) continue; + if (!tt.isTerminal()) continue; if (std.mem.eql(u8, tt.cwdSlice(), dir)) return i; }; const free = p.freeSlot() orelse return null; @@ -6266,6 +7243,579 @@ pub const Pardes = struct { return pane; } + fn openPdfView(p: *Pardes, id: usize, path: []const u8, page_one_based: usize) !*Pane { + if (comptime !pdf_enabled) return error.PdfDisabled; + var document = try pdf_impl.Document.open(path); + errdefer document.deinit(); + const path_copy = try p.gpa.dupe(u8, path); + errdefer p.gpa.free(path_copy); + const pane = try p.newDocPane(id); + const page = if (page_one_based > 0) + @min(page_one_based - 1, document.pages - 1) + else + 0; + pane.pdf = .{ + .path = path_copy, + .document = document, + .page = page, + .page_count = document.pages, + }; + pane.cur_pinned = true; + return pane; + } + + /// Mark same-page pixels stale while retaining the one visible RGBA and + /// its dimensions. drawPdf swaps in a replacement only after MuPDF and the + /// allocation both succeed, so a failed highlight rerender never blanks a + /// page which was already visible. + fn invalidatePdfRaster(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + _ = p; + pv.render_tried = false; + } + + fn dropPdfRaster(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (pv.rgba.len > 0) p.gpa.free(pv.rgba); + pv.rgba = &.{}; + pv.render_tried = false; + } + + fn dropPdfSearchResults(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (pv.search_results) |*results| results.deinit(p.gpa); + pv.search_results = null; + } + + fn dropPdfSelection(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (pv.selection) |*selection| selection.deinit(p.gpa); + pv.selection = null; + if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text); + pv.selection_text = &.{}; + pv.selection_anchor = null; + pv.selection_head = null; + } + + fn clearPdfDrag(pv: *PdfView) void { + if (comptime !pdf_enabled) return; + pv.drag_anchor = null; + pv.drag_head = null; + } + + fn clearPdfSelection(p: *Pardes, pane: *Pane) void { + if (comptime !pdf_enabled) return; + const pv = &(pane.pdf orelse return); + const changed = pv.selection != null or pv.selection_text.len > 0; + p.dropPdfSelection(pv); + if (changed) p.invalidatePdfRaster(pv); + } + + /// Drop everything derived from one PDF page. The document and full search + /// query are semantic document state and survive; page search geometry, + /// selection, pixels, projected text and pans do not. + fn invalidatePdfPage(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + p.dropPdfRaster(pv); + p.dropPdfSearchResults(pv); + p.dropPdfSelection(pv); + clearPdfDrag(pv); + if (pv.text.len > 0) p.gpa.free(pv.text); + pv.iw = 0; + pv.ih = 0; + pv.text = &.{}; + pv.text_tried = false; + pv.text_scroll = 0; + pv.pan_x = 0; + pv.pan_y = 0; + pv.highlights_dirty = pv.search_query.len > 0; + pv.search_reveal_pending = pv.search_query.len > 0; + pv.search_hit = 0; + } + + /// Replace the full PDF query and discard only query-derived page state. + /// Resolution is lazy in drawPdf; fit and pan remain placement-only state. + fn setPdfSearchQuery(p: *Pardes, pv: *PdfView, query: []const u8) void { + if (comptime !pdf_enabled) return; + if (std.mem.eql(u8, pv.search_query, query)) return; + const owned = p.gpa.dupe(u8, query) catch return; + if (pv.search_query.len > 0) p.gpa.free(pv.search_query); + pv.search_query = owned; + pv.search_hit = 0; + p.dropPdfSearchResults(pv); + pv.highlights_dirty = query.len > 0; + pv.search_reveal_pending = query.len > 0; + p.invalidatePdfRaster(pv); + } + + fn resolvePdfSearch(p: *Pardes, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (!pv.highlights_dirty) return; + pv.highlights_dirty = false; + p.dropPdfSearchResults(pv); + if (pv.search_query.len == 0) return; + const results = pv.document.search(p.gpa, pv.page, pv.search_query) catch return; + pv.search_hit = if (results.hit_count == 0) + 0 + else + @min(pv.search_hit, results.hit_count - 1); + pv.search_results = results; + } + + fn normalizedPdfPixel(value: f32, dimension: usize) u32 { + const scaled = std.math.clamp(value, 0, 1) * + @as(f32, @floatFromInt(dimension)); + return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled)))); + } + + fn rearmPdfRevealForViewport(p: *Pardes, pane: *Pane, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (pv.search_query.len == 0) return; + const viewport = p.pdfViewport(pane) orelse return; + if (!pv.reveal_viewport_valid or + pv.reveal_viewport_w != viewport.pixel_w or + pv.reveal_viewport_h != viewport.pixel_h or + pv.reveal_fit != pv.fit) + pv.search_reveal_pending = true; + } + + fn revealPdfSearch(p: *Pardes, pane: *Pane, pv: *PdfView) void { + if (comptime !pdf_enabled) return; + if (!pv.search_reveal_pending) return; + const viewport = p.pdfViewport(pane) orelse return; + pv.reveal_viewport_w = viewport.pixel_w; + pv.reveal_viewport_h = viewport.pixel_h; + pv.reveal_fit = pv.fit; + pv.reveal_viewport_valid = true; + const results = pv.search_results orelse { + pv.search_reveal_pending = false; + return; + }; + if (results.hit_count == 0 or results.quads.len == 0) { + pv.search_reveal_pending = false; + return; + } + pv.search_hit = @min(pv.search_hit, results.hit_count - 1); + const q = for (results.quads) |item| { + if (item.hit == pv.search_hit) break item.quad; + } else { + pv.search_reveal_pending = false; + return; + }; + const geometry = p.pdfGeometry(pane) orelse return; + pv.search_reveal_pending = false; + const center_x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4; + const center_y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4; + const full: u32 = @intCast(switch (pv.fit) { + .width => pv.ih, + .height => pv.iw, + }); + const at = switch (pv.fit) { + .width => normalizedPdfPixel(center_y, pv.ih), + .height => normalizedPdfPixel(center_x, pv.iw), + }; + const crop_at = switch (pv.fit) { + .width => geometry.src.y, + .height => geometry.src.x, + }; + const crop = switch (pv.fit) { + .width => geometry.src.h, + .height => geometry.src.w, + }; + if (at >= crop_at and at < crop_at + crop) return; + const overflow = full -| crop; + if (overflow == 0) return; + const wanted = @min(overflow, at -| crop / 2); + const pan: u16 = @intCast( + (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, + ); + switch (pv.fit) { + .width => pv.pan_y = pan, + .height => pv.pan_x = pan, + } + } + + /// Change the visible PDF page and invalidate only page-derived caches. + /// The document/context survives, so navigation never reparses the file. + fn setPdfPage(p: *Pardes, pane: *Pane, page: usize) void { + if (comptime !pdf_enabled) return; + const pv = &(pane.pdf orelse return); + const next = @min(page, pv.page_count -| 1); + if (next == pv.page) return; + p.invalidatePdfPage(pv); + pv.page = next; + pane.cur_row = 0; + pane.cur_col = 0; + pane.vsel.active = false; + pane.msel.active = false; + pane.nsel = 0; + } + + /// Builtin entry point. Public only because builtins.zig is the command + /// registry; the PdfView representation remains private to this module. + pub fn togglePdfFit(p: *Pardes, pane: *Pane) void { + if (comptime !pdf_enabled) return; + _ = p; + const pv = &(pane.pdf orelse return); + pv.fit = if (pv.fit == .width) .height else .width; + pv.pan_x = 0; + pv.pan_y = 0; + pv.search_reveal_pending = pv.search_query.len > 0; + } + + fn stepPdfPage(p: *Pardes, pane: *Pane, delta: i64) void { + if (comptime !pdf_enabled) return; + const pv = pane.pdf orelse return; + const current: i64 = @intCast(pv.page); + const last: i64 = @intCast(pv.page_count -| 1); + p.setPdfPage(pane, @intCast(std.math.clamp(current + delta, 0, last))); + } + + const PdfViewport = struct { + pixel_w: u32, + pixel_h: u32, + }; + + const PdfPanAxis = enum { horizontal, vertical }; + const PdfPanResult = enum { moved, edge, unavailable }; + + /// The image shells receive a cell rectangle while NativeGeometry works + /// in pixels. Keep this conversion in core state so navigation and drawing + /// agree even when the font metrics change at runtime. + fn pdfViewport(p: *const Pardes, pane: *const Pane) ?PdfViewport { + const r = for (p.panes, 0..) |slot, i| { + if (slot == pane) break p.rects[i]; + } else return null; + const cols = r.w -| config.GUTTER; + const rows = r.h -| BOX_H; + if (cols == 0 or rows == 0) return null; + return .{ + .pixel_w = @as(u32, cols) * @as(u32, p.cell_pixels.w), + .pixel_h = @as(u32, rows) * @as(u32, p.cell_pixels.h), + }; + } + + fn pdfGeometry(p: *const Pardes, pane: *const Pane) ?image.NativeGeometry { + if (comptime !pdf_enabled) return null; + const pv = pane.pdf orelse return null; + const viewport = p.pdfViewport(pane) orelse return null; + return image.nativeGeometry( + pv.iw, + pv.ih, + viewport.pixel_w, + viewport.pixel_h, + switch (pv.fit) { + .width => .width, + .height => .height, + }, + pv.pan_x, + pv.pan_y, + ); + } + + fn pdfNativeReady(p: *const Pardes, pane: *const Pane) bool { + return p.native_images and p.pdfGeometry(pane) != null; + } + + /// Convert a grid mouse position to normalized page space through the + /// exact crop/destination transform used by both native backends. Presses + /// in a letterbox return null; a live drag clamps to the page edge so a + /// sweep can finish naturally outside the image. + fn pdfPointAt( + p: *const Pardes, + pane: *const Pane, + mcol: u16, + mrow: u16, + clamp_to_page: bool, + ) ?PdfPoint { + if (comptime !pdf_enabled) return null; + const pv = pane.pdf orelse return null; + const geometry = p.pdfGeometry(pane) orelse return null; + const r = for (p.panes, 0..) |slot, i| { + if (slot == pane) break p.rects[i]; + } else return null; + const body_x = @as(i64, r.x + config.GUTTER); + const body_y = @as(i64, r.y + BOX_H); + var px = (@as(i64, mcol) - body_x) * p.cell_pixels.w + p.cell_pixels.w / 2; + var py = (@as(i64, mrow) - body_y) * p.cell_pixels.h + p.cell_pixels.h / 2; + if (clamp_to_page) { + px = std.math.clamp( + px, + @as(i64, geometry.dst.x), + @as(i64, geometry.dst.x + geometry.dst.w - 1), + ); + py = std.math.clamp( + py, + @as(i64, geometry.dst.y), + @as(i64, geometry.dst.y + geometry.dst.h - 1), + ); + } else if (px < 0 or py < 0 or + px > std.math.maxInt(u32) or py > std.math.maxInt(u32)) return null; + const source = geometry.sourcePoint(@intCast(px), @intCast(py)) orelse return null; + const fw: f32 = @floatFromInt(pv.iw); + const fh: f32 = @floatFromInt(pv.ih); + return .{ + .x = (@as(f32, @floatFromInt(source.x)) + 0.5) / fw, + .y = (@as(f32, @floatFromInt(source.y)) + 0.5) / fh, + }; + } + + const PdfSelectionUpdate = enum { failed, unchanged, changed }; + + /// Transactionally replace the word-snapped selection and its owned text. + /// Drags defer the expensive baked-highlight raster until release; + /// snap-word execute/look presses may still request an immediate update. + fn setPdfSelection( + p: *Pardes, + pane: *Pane, + start: PdfPoint, + end: PdfPoint, + invalidate_raster: bool, + ) PdfSelectionUpdate { + if (comptime !pdf_enabled) return .failed; + const pv = &(pane.pdf orelse return .failed); + if (pv.selection != null and + pv.selection_anchor != null and pv.selection_head != null and + pv.selection_anchor.?.x == start.x and pv.selection_anchor.?.y == start.y and + pv.selection_head.?.x == end.x and pv.selection_head.?.y == end.y) return .unchanged; + var selection = pv.document.select(p.gpa, pv.page, start, end) catch return .failed; + const text = pv.document.copySelection( + p.gpa, + pv.page, + selection.start, + selection.end, + ) catch { + selection.deinit(p.gpa); + return .failed; + }; + + p.dropPdfSelection(pv); + pv.selection = selection; + pv.selection_text = text; + pv.selection_anchor = start; + pv.selection_head = end; + if (invalidate_raster) p.invalidatePdfRaster(pv); + return .changed; + } + + fn beginPdfDrag( + p: *Pardes, + pane: *Pane, + mcol: u16, + mrow: u16, + snap_word: bool, + ) bool { + if (comptime !pdf_enabled) return false; + const pv = &(pane.pdf orelse return false); + clearPdfDrag(pv); + const point = p.pdfPointAt(pane, mcol, mrow, false) orelse { + return false; + }; + pv.drag_anchor = point; + pv.drag_head = point; + if (!snap_word) return true; + if (pv.selection) |selection| if (selection.contains(point)) { + return true; + }; + if (p.setPdfSelection(pane, point, point, true) == .failed) { + // The old selection is intentionally transactional and survives, + // but this outside click must not execute that stale text. + clearPdfDrag(pv); + return false; + } + return true; + } + + fn beginPdfSelection(p: *Pardes, pane: *Pane, mcol: u16, mrow: u16) bool { + return p.beginPdfDrag(pane, mcol, mrow, true); + } + + fn updatePdfSelection( + p: *Pardes, + pane: *Pane, + mcol: u16, + mrow: u16, + invalidate_raster: bool, + ) bool { + if (comptime !pdf_enabled) return false; + const pv = &(pane.pdf orelse return false); + const anchor = pv.drag_anchor orelse return false; + const point = p.pdfPointAt(pane, mcol, mrow, true) orelse return false; + if (pv.drag_head) |head| if (head.x == point.x and head.y == point.y) return false; + const result = p.setPdfSelection(pane, anchor, point, invalidate_raster); + if (result != .failed) { + pv.drag_head = point; + } + return result == .changed; + } + + fn pdfHighlights(p: *Pardes, pv: *PdfView) ![]const PdfHighlight { + if (comptime !pdf_enabled) return &.{}; + var highlights: std.ArrayList(PdfHighlight) = .empty; + const arena = p.scratch.allocator(); + if (pv.search_results) |results| { + try highlights.ensureUnusedCapacity(arena, results.quads.len); + for (results.quads) |item| highlights.appendAssumeCapacity(pdf_impl.Highlight.init( + item.quad, + .{ 0xff, 0xd5, 0x4f, 0x70 }, + .search, + )); + } + if (pv.selection) |selection| { + try highlights.ensureUnusedCapacity(arena, selection.quads.len); + for (selection.quads) |quad| highlights.appendAssumeCapacity(pdf_impl.Highlight.init( + quad, + .{ 0x55, 0xa7, 0xff, 0x78 }, + .selection, + )); + } + return highlights.items; + } + + /// Move one normalized pan axis by a distance measured in DISPLAY pixels. + /// NativeGeometry supplies both the displayed and cropped extents, so this + /// remains exact for non-square cells and rounded page scaling. + fn panPdfPixels( + p: *Pardes, + pane: *Pane, + axis: PdfPanAxis, + direction: i8, + display_pixels: u32, + ) PdfPanResult { + if (comptime !pdf_enabled) return .unavailable; + const geometry = p.pdfGeometry(pane) orelse return .unavailable; + const pv = &(pane.pdf orelse return .unavailable); + const source_full: u32 = @intCast(switch (axis) { + .horizontal => pv.iw, + .vertical => pv.ih, + }); + const crop = switch (axis) { + .horizontal => geometry.src.w, + .vertical => geometry.src.h, + }; + const source_at = switch (axis) { + .horizontal => geometry.src.x, + .vertical => geometry.src.y, + }; + const displayed = @max(@as(u32, 1), switch (axis) { + .horizontal => geometry.dst.w, + .vertical => geometry.dst.h, + }); + const overflow = source_full -| crop; + if (overflow == 0 or + (direction < 0 and source_at == 0) or + (direction > 0 and source_at >= overflow)) return .edge; + + const source_step = @max( + @as(u64, 1), + (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed, + ); + const normalized_step: u32 = @intCast(@min( + @as(u64, std.math.maxInt(u16)), + @max( + @as(u64, 1), + (source_step * std.math.maxInt(u16) + overflow - 1) / overflow, + ), + )); + const position = switch (axis) { + .horizontal => &pv.pan_x, + .vertical => &pv.pan_y, + }; + if (direction > 0) { + position.* = @intCast(@min( + @as(u32, std.math.maxInt(u16)), + @as(u32, position.*) + normalized_step, + )); + } else { + position.* -|= @intCast(normalized_step); + } + return .moved; + } + + fn scaledPdfStep(base: u32, count: u32) u32 { + return @intCast(@min( + @as(u64, std.math.maxInt(u32)), + @as(u64, base) * @max(@as(u64, 1), count), + )); + } + + /// Fit-width reads down a tall raster, then crosses pages at its extrema. + /// Going backward deliberately enters the previous page at the bottom. + fn panPdfWidth(p: *Pardes, pane: *Pane, direction: i8, pixels: u32) void { + switch (p.panPdfPixels(pane, .vertical, direction, pixels)) { + .moved => {}, + // Before the first native frame (or after a render failure), do + // not swallow navigation: page-wise is the useful fallback. + .unavailable => p.stepPdfPage(pane, direction), + .edge => { + const pv = pane.pdf orelse return; + if (direction > 0) { + if (pv.page + 1 >= pv.page_count) return; + p.setPdfPage(pane, pv.page + 1); + } else { + if (pv.page == 0) return; + p.setPdfPage(pane, pv.page - 1); + pane.pdf.?.pan_y = std.math.maxInt(u16); + } + }, + } + } + + fn pdfVerticalWheel(p: *Pardes, pane: *Pane, direction: i8) void { + if (comptime !pdf_enabled) return; + const pv = pane.pdf orelse return; + if (!p.native_images or pv.fit == .height) { + p.stepPdfPage(pane, direction); + return; + } + const rows: u32 = @intCast(@max(1, config.wheel_rows)); + p.panPdfWidth(pane, direction, scaledPdfStep(p.cell_pixels.h, rows)); + } + + fn pdfHorizontalWheel(p: *Pardes, pane: *Pane, direction: i8) void { + if (comptime !pdf_enabled) return; + const pv = pane.pdf orelse return; + if (!p.native_images or pv.fit != .height) return; + const cols: u32 = @intCast(@max(1, config.wheel_cols)); + _ = p.panPdfPixels(pane, .horizontal, direction, scaledPdfStep(p.cell_pixels.w, cols)); + } + + /// In fit-height, vertical motions retain the old page-at-a-time model. + /// In fit-width they pan by a row, half viewport or full viewport before + /// crossing pages. Search, tags and global chords run before this hook. + fn pdfNavigationKey(p: *Pardes, pane: *Pane, key: Key) bool { + if (comptime !pdf_enabled) return false; + if (pane.pdf == null or pane.pending != 0) return false; + const row_down = hit(key, config.move_down); + const row_up = hit(key, config.move_up); + const half_down = hit(key, config.half_page_down); + const half_up = hit(key, config.half_page_up); + const page_down = hit(key, config.page_down); + const page_up = hit(key, config.page_up); + const down = row_down or half_down or page_down; + const up = row_up or half_up or page_up; + if (!down and !up) return false; + const count = @max(1, pane.count); + pane.count = 0; + const direction: i8 = if (down) 1 else -1; + if (!p.native_images or pane.pdf.?.fit == .height) { + const pages: i64 = @intCast(count); + p.stepPdfPage(pane, if (down) pages else -pages); + return true; + } + const viewport = p.pdfViewport(pane); + const base = if (row_down or row_up) + @as(u32, p.cell_pixels.h) + else if (half_down or half_up) + if (viewport) |v| @max(@as(u32, 1), v.pixel_h / 2) else @as(u32, p.cell_pixels.h) + else if (viewport) |v| + v.pixel_h + else + @as(u32, p.cell_pixels.h); + p.panPdfWidth(pane, direction, scaledPdfStep(base, count)); + return true; + } + /// stack a fresh doc pane at the top of the LEFT column (acme convention: /// files left, terminals right), halving ONLY the old top pane's slot so /// the rest of the column keeps its sizes; then evict a leftover pristine @@ -6296,6 +7846,20 @@ pub const Pardes = struct { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; + if (hasPdf(pane)) { + if (comptime pdf_enabled) { + if (at.line > 0) p.setPdfPage(pane, at.line - 1); + const pv = &pane.pdf.?; + // PDF search result rows use Spot.col as a one-based MuPDF + // logical hit id, not a text column. Rearming reveal moves + // only fit/pan; the page raster already contains every hit. + if (at.col > 0 and pv.search_query.len > 0) { + pv.search_hit = at.col - 1; + pv.search_reveal_pending = true; + } + } + return; + } if (at.line == 0) return; if (pane.file) |*f| { if (at.line > file_pane.nlines(p.gpa, f)) return; @@ -6352,6 +7916,10 @@ pub const Pardes = struct { p.active = i; return true; }; + if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) { + p.focusPaneLine(i, at); + return true; + }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; p.focusPaneLine(i, at); @@ -6406,7 +7974,7 @@ pub const Pardes = struct { const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane; if (n > 0 and k == id) continue; // tried first, skip the rerun const op = p.panes[k] orelse continue; - const dir = if (op.file) |f| (std.fs.path.dirname(f.path) orelse "/") else op.cwdSlice(); + const dir = paneDir(op); for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand; seen[nseen] = dir; nseen += 1; @@ -6472,6 +8040,13 @@ pub const Pardes = struct { p.active = free; }, .file => |target| { + if (comptime pdf_enabled) if (target.kind == .pdf) { + if (p.focusPaneByPath(target.path, target.at)) return; + const free = p.freeSlot() orelse return; + const nt = p.openPdfView(free, target.path, target.at.line) catch return; + p.placeDoc(id, free, nt); + return; + }; // focus an existing pane on this path (rescrolled), else open if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return; @@ -6561,6 +8136,8 @@ pub const Pardes = struct { // directory (found by cwd, else freshly forked). const dst = (if (pane.file) |f| p.ttyForDir(std.fs.path.dirname(f.path) orelse "/") + else if (hasPdf(pane)) + p.ttyForDir(paneDir(pane)) else id) orelse return null; term_pane.padOutputBelowEdits(p, dst); @@ -6602,7 +8179,7 @@ pub const Pardes = struct { pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; - if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null) { + if (p.panes[from_id]) |src| if (if (src.file) |f| output_pane.fileTraits(f.output).doc else src.image != null or hasPdf(src)) { src_id = from_id; }; // Opened from somewhere that is NOT a doc (a shell, a results list): @@ -6626,7 +8203,7 @@ pub const Pardes = struct { const j = p.jumps[n - 1]; const pp = p.panes[j.pane] orelse continue; if (pp.serial != j.serial) continue; - if (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null) { + if (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp)) { src_id = j.pane; break; } @@ -6637,7 +8214,7 @@ pub const Pardes = struct { // doc never claims a second column outranks knowing where you were, so // the old slot-order scan stays as the fallback. if (doc and src_id == null) for (p.panes, 0..) |sl, i| { - if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null)) { + if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp))) { src_id = i; break; }; @@ -6698,6 +8275,27 @@ pub const Pardes = struct { .origin = if (f.output) |o| output_pane.word(o.from) else "", .origin_arg = if (f.output) |*o| o.arg() else "", }, + } else if (hasPdf(pane)) blk: { + if (comptime !pdf_enabled) unreachable; + const pv = pane.pdf.?; + const bytes = look.readFile(arena, pv.path) catch ""; + // Keep the dump schema backwards-compatible: a raster-backed + // document rides the existing binary image record, while its + // `scroll` field is the zero-based PDF page. Restore inspects + // the extension and reconstructs the semantic PDF pane. + break :blk .{ + .kind = .image, + .tag = tag, + .body = body, + .scroll = pv.page, + .cols = pane.cols, + .rows = pane.rows, + .vweight = pane.vweight, + .image = .{ + .path = pv.path, + .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", + }, + }; } else if (pane.image) |iv| blk: { const bytes = look.readFile(arena, iv.path) catch ""; break :blk .{ @@ -6864,15 +8462,36 @@ pub const Pardes = struct { if (out == null) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); p.restoreTail(pane, src.tag); }, - .image => { + .image => restore_image: { const im = src.image.?; - const path = try gpa.dupe(u8, im.path); - errdefer gpa.free(path); - const pane = try p.newDocPane(i); const raw: []u8 = if (im.bytes_b64.len > 0) dump.decodeBytes(gpa, im.bytes_b64) catch &.{} else &.{}; + if (comptime pdf_enabled) if (look.isPdfPath(im.path)) { + if (p.openPdfView(i, im.path, src.scroll + 1) catch null) |pane| { + if (raw.len > 0) gpa.free(raw); + pane.cols = @max(1, src.cols); + pane.rows = @max(1, src.rows); + break :restore_image; + } + // If the source path is unavailable, retain its bytes + // as an ordinary file rather than dropping the pane. + const path = try gpa.dupe(u8, im.path); + errdefer gpa.free(path); + const content = if (raw.len > 0) raw else try gpa.dupe(u8, ""); + errdefer if (content.len > 0) gpa.free(content); + const pane = try p.newDocPane(i); + pane.file = .{ .path = path, .content = content }; + pane.cur_pinned = true; + pane.cols = @max(1, src.cols); + pane.rows = @max(1, src.rows); + p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); + break :restore_image; + }; + const path = try gpa.dupe(u8, im.path); + errdefer gpa.free(path); + const pane = try p.newDocPane(i); pane.image = .{ .path = path, .raw = raw }; pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); @@ -6975,8 +8594,8 @@ pub const Pardes = struct { const now: Loc = .{ .pane = @intCast(p.active), .serial = pane.serial, - .line = if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0, - .col = if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) else 0, + .line = if (pane.pdfPage()) |page| @intCast(page + 1) else if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0, + .col = if (pane.pdfPage() != null) 0 else if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) else 0, }; if (p.njumps > 0) { const at = &p.jumps[p.jcur]; @@ -7206,6 +8825,9 @@ pub const Pardes = struct { if (at.file) |*f| { sb_off = f.scroll; sb_total = file_pane.nlines(p.gpa, f); + } else if (at.pdfPage()) |page| { + sb_off = page; + sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; } else { const sb = at.vt.screens.active.pages.scrollbar(); sb_off = sb.offset; @@ -7345,6 +8967,15 @@ pub const Pardes = struct { if (pane.tag_col < tw) s.cursor = .{ .x = tx + pane.tag_col, .y = r.y, .bar = pane.mode == .insert }; } + // A native PDF page uses the same backend-neutral pixel attachment as + // an image. Without native pixels it deliberately falls through: its + // extracted text projection becomes an ordinary readable body. + if (hasPdf(pane) and p.native_images and p.drawPdf(pane, r, tx, tw)) { + s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); + s.fill(r.x + 1, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = pane_bg }); + return; + } + // image pane: the picture fills the body — petscii glyph art into the // cells, or a pixel attachment the shell places (kitty). Plain // thumbless gutter so it reads like any other pane. @@ -7527,6 +9158,10 @@ pub const Pardes = struct { .total = file_pane.nlines(p.gpa, f), .offset = f.scroll, .len = pane.rows, + } else if (pane.pdfPage()) |page| .{ + .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1, + .offset = page, + .len = 1, } else blk: { const gsb = pane.vt.screens.active.pages.scrollbar(); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; @@ -7542,6 +9177,62 @@ pub const Pardes = struct { } } + /// Render the current PDF page once and attach its stable RGBA until page + /// navigation invalidates it. MuPDF rendering is bounded to a 1600-pixel + /// longest side in pdf.zig, independent of malicious page dimensions. + fn drawPdf(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) bool { + if (comptime !pdf_enabled) return false; + const pv = &(pane.pdf orelse return false); + if (!pv.render_tried) { + pv.render_tried = true; + p.resolvePdfSearch(pv); + const rendered = render: { + const highlights = p.pdfHighlights(pv) catch break :render null; + const fresh = if (highlights.len > 0) + pv.document.renderWithHighlights(p.gpa, pv.page, highlights) catch break :render null + else + pv.document.render(p.gpa, pv.page) catch break :render null; + break :render fresh; + }; + if (rendered) |fresh| { + if (pv.rgba.len > 0) p.gpa.free(pv.rgba); + pv.rgba = fresh.rgba; + pv.iw = fresh.width; + pv.ih = fresh.height; + pv.render_revision +%= 1; + if (pv.render_revision == 0) pv.render_revision = 1; + } + } + p.rearmPdfRevealForViewport(pane, pv); + p.revealPdfSearch(pane, pv); + if (pv.rgba.len == 0 or r.h <= BOX_H) return false; + const id: u8 = @intCast(for (p.panes, 0..) |slot, i| { + if (slot == pane) break i; + } else 0); + p.surface.images[p.surface.nimages] = .{ + .pane = id, + .serial = pane.serial, + .native = .{ + .revision = pv.render_revision, + .fit = switch (pv.fit) { + .width => .width, + .height => .height, + }, + .pan_x = pv.pan_x, + .pan_y = pv.pan_y, + }, + .x = tx, + .y = r.y + BOX_H, + .w = tw, + .h = r.h - BOX_H, + .rgba = pv.rgba, + .iw = pv.iw, + .ih = pv.ih, + }; + p.surface.nimages += 1; + return true; + } + /// Image pane body: decode once (path via look, or dump-loaded raw bytes), /// rebuild the petscii grid when the size/toggles changed, then either /// blit the glyph art into cells or attach the pixels for the shell. @@ -7627,12 +9318,27 @@ pub const Pardes = struct { /// rows it covers — which is where a buffer holding more lines than those /// rows pushes the output below it down the screen. fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { - _ = p; if (pane.image != null) { const buf = try arena.alloc(u8, pane.rows -| 1); @memset(buf, '\n'); return buf; } + if (hasPdf(pane)) { + const text = try p.ensurePdfText(pane); + const scroll = if (comptime pdf_enabled) pane.pdf.?.text_scroll else 0; + var out: std.ArrayList(u8) = .empty; + var lines = std.mem.splitScalar(u8, text, '\n'); + var row: usize = 0; + var wrote: usize = 0; + while (lines.next()) |line| : (row += 1) { + if (row < scroll) continue; + if (wrote >= pane.rows -| BOX_H) break; + if (wrote > 0) try out.append(arena, '\n'); + try out.appendSlice(arena, line); + wrote += 1; + } + return out.items; + } if (pane.file) |*f| return file_pane.bodyText(arena, pane, f); return term_pane.bodyText(arena, pane); } diff --git a/src/pdf.zig b/src/pdf.zig new file mode 100644 index 00000000..48b15cf1 --- /dev/null +++ b/src/pdf.zig @@ -0,0 +1,517 @@ +//! Exception-safe MuPDF runtime wrapper. MuPDF's public API throws with +//! setjmp/longjmp; pdf_bridge.c catches every such throw before control returns +//! to Zig. This module is present in native builds unless `-Dmupdf=false`. +const std = @import("std"); + +const c = @cImport({ + @cInclude("pdf_bridge.h"); +}); + +pub const max_render_dimension = 1600; +/// 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; + +test "MuPDF document cache has an explicit conservative ceiling" { + try std.testing.expectEqual(@as(usize, 64 * 1024 * 1024), store_limit_bytes); +} + +/// Page-space geometry is normalized to the page bounds, not a render. It +/// therefore survives DPI changes and can be shared by Kitty and SDL. +pub const Point = c.pardes_pdf_point; +pub const Quad = c.pardes_pdf_quad; + +pub const HighlightKind = enum(c_int) { + custom = c.PARDES_PDF_HIGHLIGHT_CUSTOM, + search = c.PARDES_PDF_HIGHLIGHT_SEARCH, + selection = c.PARDES_PDF_HIGHLIGHT_SELECTION, +}; + +/// RGBA is straight (not premultiplied). `kind` remains available to the UI as +/// semantic metadata; the caller-supplied RGBA is authoritative for rendering. +pub const Highlight = extern struct { + quad: Quad, + rgba: [4]u8, + kind: HighlightKind, + + pub fn init(quad: Quad, rgba: [4]u8, kind: HighlightKind) Highlight { + return .{ .quad = quad, .rgba = rgba, .kind = kind }; + } +}; + +comptime { + if (@sizeOf(Highlight) != @sizeOf(c.pardes_pdf_highlight) or + @alignOf(Highlight) != @alignOf(c.pardes_pdf_highlight) or + @offsetOf(Highlight, "quad") != @offsetOf(c.pardes_pdf_highlight, "quad") or + @offsetOf(Highlight, "rgba") != @offsetOf(c.pardes_pdf_highlight, "rgba") or + @offsetOf(Highlight, "kind") != @offsetOf(c.pardes_pdf_highlight, "kind")) + @compileError("Highlight must match pardes_pdf_highlight's C ABI"); +} + +pub const Render = struct { + rgba: []u8, + width: usize, + height: usize, +}; + +pub const SearchQuad = struct { + quad: Quad, + /// Zero-based logical hit. One hit may have several oriented quads. + hit: usize, +}; + +pub const SearchResults = struct { + quads: []SearchQuad, + hit_count: usize, + + pub fn deinit(results: *SearchResults, gpa: std.mem.Allocator) void { + gpa.free(results.quads); + results.* = undefined; + } +}; + +pub const Selection = struct { + quads: []Quad, + /// Word-snapped endpoints; pass these to `Document.copySelection`. + start: Point, + end: Point, + + /// Hit-test without flattening oriented text quads to axis-aligned boxes. + /// The bridge delegates to MuPDF's geometry predicate and cannot throw. + pub fn contains(selection: *const Selection, point: Point) bool { + if (!validPoint(point)) return false; + for (selection.quads) |quad| { + if (c.pardes_pdf_point_inside_quad(point, quad) != 0) return true; + } + return false; + } + + pub fn deinit(selection: *Selection, gpa: std.mem.Allocator) void { + gpa.free(selection.quads); + selection.* = undefined; + } +}; + +test "oriented selection containment delegates to MuPDF quad geometry" { + const quads = [_]Quad{.{ + .ul = .{ .x = 0.10, .y = 0.10 }, + .ur = .{ .x = 0.70, .y = 0.20 }, + .ll = .{ .x = 0.20, .y = 0.80 }, + .lr = .{ .x = 0.80, .y = 0.90 }, + }}; + const selection = Selection{ + .quads = @constCast(quads[0..]), + .start = quads[0].ul, + .end = quads[0].lr, + }; + try std.testing.expect(selection.contains(.{ .x = 0.45, .y = 0.50 })); + try std.testing.expect(!selection.contains(.{ .x = 0.05, .y = 0.85 })); + try std.testing.expect(!selection.contains(.{ .x = -0.1, .y = 0.5 })); +} + +pub const Document = struct { + handle: *c.pardes_pdf_document, + pages: usize, + + pub fn open(path: []const u8) !Document { + var path_buf: [4096]u8 = undefined; + const path_z = std.fmt.bufPrintSentinel(&path_buf, "{s}", .{path}, 0) catch + return error.PathTooLong; + var page_count: c_int = 0; + const handle = c.pardes_pdf_open(path_z.ptr, &page_count) orelse + return error.OpenFailed; + if (page_count < 1) { + c.pardes_pdf_close(handle); + return error.EmptyDocument; + } + return .{ .handle = handle, .pages = @intCast(page_count) }; + } + + pub fn deinit(document: *Document) void { + c.pardes_pdf_close(document.handle); + document.* = undefined; + } + + /// Render one zero-based page, bounded before allocation. MuPDF supplies + /// row-strided RGB; Pardes' attachment boundary is packed straight RGBA. + pub fn render(document: *Document, gpa: std.mem.Allocator, page: usize) !Render { + return document.renderInternal(gpa, page, null); + } + + /// Render and bake normalized oriented highlights through MuPDF's draw + /// device. An empty list takes the exact same plain-render path as render. + pub fn renderWithHighlights( + document: *Document, + gpa: std.mem.Allocator, + page: usize, + highlights: []const Highlight, + ) !Render { + return document.renderInternal(gpa, page, highlights); + } + + fn renderInternal( + document: *Document, + gpa: std.mem.Allocator, + page: usize, + highlights: ?[]const Highlight, + ) !Render { + if (page >= document.pages or page > std.math.maxInt(c_int)) + return error.PageOutOfRange; + var pixmap: c.pardes_pdf_pixmap = std.mem.zeroes(c.pardes_pdf_pixmap); + const status = if (highlights) |items| + if (items.len == 0) + c.pardes_pdf_render( + document.handle, + @intCast(page), + max_render_dimension, + &pixmap, + ) + else + c.pardes_pdf_render_with_highlights( + document.handle, + @intCast(page), + max_render_dimension, + @ptrCast(items.ptr), + items.len, + &pixmap, + ) + else + c.pardes_pdf_render( + document.handle, + @intCast(page), + max_render_dimension, + &pixmap, + ); + if (status != c.PARDES_PDF_OK) return error.RenderFailed; + defer c.pardes_pdf_drop_pixmap(document.handle, pixmap.handle); + + if (pixmap.samples == null or pixmap.width < 1 or pixmap.height < 1 or + pixmap.stride < 1 or pixmap.components != 3) + return error.BadPixmap; + const width: usize = @intCast(pixmap.width); + const height: usize = @intCast(pixmap.height); + const stride: usize = @intCast(pixmap.stride); + if (width > std.math.maxInt(usize) / 4 or + height > std.math.maxInt(usize) / (width * 4) or + height > std.math.maxInt(usize) / stride or + stride < width * 3) + return error.BadPixmap; + + const rgba = try gpa.alloc(u8, width * height * 4); + errdefer gpa.free(rgba); + const samples: [*]const u8 = @ptrCast(pixmap.samples); + try expandRgb(rgba, samples[0 .. stride * height], width, height, stride); + return .{ .rgba = rgba, .width = width, .height = height }; + } + + /// Plain UTF-8-ish text projection for one zero-based page. MuPDF owns the + /// temporary buffer; callers receive an allocator-owned copy. + pub fn pageText(document: *Document, gpa: std.mem.Allocator, page: usize) ![]u8 { + if (page >= document.pages or page > std.math.maxInt(c_int)) + return error.PageOutOfRange; + var text: c.pardes_pdf_text = std.mem.zeroes(c.pardes_pdf_text); + if (c.pardes_pdf_page_text(document.handle, @intCast(page), &text) != 0) + return error.TextFailed; + defer c.pardes_pdf_drop_text(document.handle, text.handle); + if (text.len == 0) return gpa.dupe(u8, ""); + if (text.data == null) return error.BadText; + const bytes: [*]const u8 = @ptrCast(text.data); + return gpa.dupe(u8, bytes[0..text.len]); + } + + /// Case-insensitive single-page search. MuPDF's iterative search owns a + /// retained reference to the cached structured-text page while running; + /// this wrapper copies every borrowed oriented quad before dropping it. + /// The C bridge doubles capacity up to 65,536 aggregate quads and returns + /// TooManyResults atomically rather than silently truncating beyond it. + pub fn search( + document: *Document, + gpa: std.mem.Allocator, + page: usize, + needle: []const u8, + ) !SearchResults { + const page_number = try document.checkedPage(page); + if (std.mem.indexOfScalar(u8, needle, 0) != null) + return error.InvalidNeedle; + const needle_z = try gpa.dupeZ(u8, needle); + defer gpa.free(needle_z); + + var found: c.pardes_pdf_search_result = + std.mem.zeroes(c.pardes_pdf_search_result); + const status = c.pardes_pdf_search_page(document.handle, page_number, needle_z.ptr, &found); + if (status == c.PARDES_PDF_LIMIT_EXCEEDED) + return error.TooManyResults; + if (status != c.PARDES_PDF_OK) + return error.SearchFailed; + defer c.pardes_pdf_drop_search_result(document.handle, found.handle); + + if (found.quad_count != 0 and found.quads == null) + return error.BadGeometry; + const quads = try gpa.alloc(SearchQuad, found.quad_count); + errdefer gpa.free(quads); + if (found.quad_count != 0) { + const source: [*]const c.pardes_pdf_search_quad = @ptrCast(found.quads); + for (quads, source[0..found.quad_count]) |*dest, item| { + if (item.hit >= found.hit_count or !validQuad(item.quad)) + return error.BadGeometry; + dest.* = .{ .quad = item.quad, .hit = item.hit }; + } + } + return .{ .quads = quads, .hit_count = found.hit_count }; + } + + /// Word-snap two normalized page points and return allocator-owned, + /// orientation-preserving highlight geometry. + pub fn select( + document: *Document, + gpa: std.mem.Allocator, + page: usize, + start: Point, + end: Point, + ) !Selection { + const page_number = try document.checkedPage(page); + if (!validPoint(start) or !validPoint(end)) return error.InvalidPoint; + + var selected: c.pardes_pdf_selection = + std.mem.zeroes(c.pardes_pdf_selection); + const status = c.pardes_pdf_select(document.handle, page_number, start, end, &selected); + if (status == c.PARDES_PDF_LIMIT_EXCEEDED) + return error.TooManyResults; + if (status != c.PARDES_PDF_OK) + return error.SelectionFailed; + defer c.pardes_pdf_drop_selection(document.handle, selected.handle); + + if (selected.quad_count != 0 and selected.quads == null) + return error.BadGeometry; + if (!validPoint(selected.start) or !validPoint(selected.end)) + return error.BadGeometry; + const quads = try gpa.alloc(Quad, selected.quad_count); + errdefer gpa.free(quads); + if (selected.quad_count != 0) { + const source: [*]const Quad = @ptrCast(selected.quads); + for (quads, source[0..selected.quad_count]) |*dest, item| { + if (!validQuad(item)) return error.BadGeometry; + dest.* = item; + } + } + return .{ + .quads = quads, + .start = selected.start, + .end = selected.end, + }; + } + + /// Copy UTF-8 text between normalized page points. For word selection, + /// pass the snapped endpoints returned by `select`. MuPDF's temporary + /// fz_malloc string is always freed after making the allocator-owned copy. + pub fn copySelection( + document: *Document, + gpa: std.mem.Allocator, + page: usize, + start: Point, + end: Point, + ) ![]u8 { + const page_number = try document.checkedPage(page); + if (!validPoint(start) or !validPoint(end)) return error.InvalidPoint; + + var text: c.pardes_pdf_owned_text = + std.mem.zeroes(c.pardes_pdf_owned_text); + if (c.pardes_pdf_copy_selection( + document.handle, + page_number, + start, + end, + &text, + ) != c.PARDES_PDF_OK) return error.TextFailed; + defer c.pardes_pdf_drop_owned_text(document.handle, text.handle); + if (text.len == 0) return gpa.dupe(u8, ""); + if (text.data == null) return error.BadText; + const bytes: [*]const u8 = @ptrCast(text.data); + return gpa.dupe(u8, bytes[0..text.len]); + } + + fn checkedPage(document: *const Document, page: usize) !c_int { + if (page >= document.pages or page > std.math.maxInt(c_int)) + return error.PageOutOfRange; + return @intCast(page); + } +}; + +fn validPoint(point: Point) bool { + return std.math.isFinite(point.x) and std.math.isFinite(point.y) and + point.x >= 0 and point.x <= 1 and point.y >= 0 and point.y <= 1; +} + +fn validQuad(quad: Quad) bool { + return validPoint(quad.ul) and validPoint(quad.ur) and + validPoint(quad.ll) and validPoint(quad.lr); +} + +fn expandRgb( + rgba: []u8, + rgb: []const u8, + width: usize, + height: usize, + stride: usize, +) !void { + if (rgba.len != width * height * 4 or stride < width * 3 or + rgb.len < stride * height) + return error.BadPixmap; + for (0..height) |y| { + const src = rgb[y * stride ..][0 .. width * 3]; + const dst = rgba[y * width * 4 ..][0 .. width * 4]; + for (0..width) |x| { + dst[x * 4 + 0] = src[x * 3 + 0]; + dst[x * 4 + 1] = src[x * 3 + 1]; + dst[x * 4 + 2] = src[x * 3 + 2]; + dst[x * 4 + 3] = 0xff; + } + } +} + +fn makeOffsetRotatedPdf(gpa: std.mem.Allocator) ![]u8 { + const stream = "q 1 0 0 rg 120 220 160 150 re f Q\n"; + var bytes: std.ArrayList(u8) = .empty; + errdefer bytes.deinit(gpa); + var offsets: [5]usize = @splat(0); + + try bytes.appendSlice(gpa, "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); + offsets[1] = bytes.items.len; + try bytes.appendSlice(gpa, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + offsets[2] = bytes.items.len; + try bytes.appendSlice(gpa, "2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"); + offsets[3] = bytes.items.len; + try bytes.appendSlice(gpa, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [100 200 300 400] " ++ + "/CropBox [120 220 280 370] /Rotate 90 /Resources << >> " ++ + "/Contents 4 0 R >>\nendobj\n"); + offsets[4] = bytes.items.len; + try bytes.print(gpa, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ stream.len, stream }); + + const xref = bytes.items.len; + try bytes.appendSlice(gpa, "xref\n0 5\n0000000000 65535 f \n"); + for (offsets[1..]) |offset| + try bytes.print(gpa, "{d:0>10} 00000 n \n", .{offset}); + try bytes.print(gpa, "trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref}); + return bytes.toOwnedSlice(gpa); +} + +test "RGB pixmap expansion respects row stride and writes opaque RGBA" { + const rgb = [_]u8{ + 1, 2, 3, 4, 5, 6, 99, 99, + 7, 8, 9, 10, 11, 12, 88, 88, + }; + var rgba: [16]u8 = undefined; + try expandRgb(&rgba, &rgb, 2, 2, 8); + try std.testing.expectEqualSlices(u8, &.{ + 1, 2, 3, 255, 4, 5, 6, 255, + 7, 8, 9, 255, 10, 11, 12, 255, + }, &rgba); +} + +test "MuPDF search returns normalized oriented quads and word selection text" { + var document = try Document.open("docs/design.pdf"); + defer document.deinit(); + + const projected = try document.pageText(std.testing.allocator, 0); + defer std.testing.allocator.free(projected); + try std.testing.expect(std.ascii.indexOfIgnoreCase(projected, "Pardes") != null); + // Exercise transactional cache replacement before searching page zero + // again; a failed ownership handoff here tends to surface as a double drop. + if (document.pages > 1) { + const other_page = try document.pageText(std.testing.allocator, 1); + std.testing.allocator.free(other_page); + } + + var found = try document.search(std.testing.allocator, 0, "Pardes"); + defer found.deinit(std.testing.allocator); + try std.testing.expect(found.hit_count > 0); + try std.testing.expect(found.quads.len > 0); + for (found.quads) |item| { + try std.testing.expect(item.hit < found.hit_count); + try std.testing.expect(validQuad(item.quad)); + } + + const first = found.quads[0].quad; + var selected = try document.select(std.testing.allocator, 0, first.ul, first.lr); + defer selected.deinit(std.testing.allocator); + try std.testing.expect(selected.quads.len > 0); + try std.testing.expect(validPoint(selected.start)); + try std.testing.expect(validPoint(selected.end)); + + const text = try document.copySelection(std.testing.allocator, 0, selected.start, selected.end); + defer std.testing.allocator.free(text); + try std.testing.expect(std.ascii.indexOfIgnoreCase(text, "Pardes") != null); +} + +test "highlighted render changes pixels while plain render stays stable" { + var document = try Document.open("docs/design.pdf"); + defer document.deinit(); + + const plain_before = try document.render(std.testing.allocator, 0); + defer std.testing.allocator.free(plain_before.rgba); + const whole_page = Quad{ + .ul = .{ .x = 0, .y = 0 }, + .ur = .{ .x = 1, .y = 0 }, + .ll = .{ .x = 0, .y = 1 }, + .lr = .{ .x = 1, .y = 1 }, + }; + const highlights = [_]Highlight{ + Highlight.init(whole_page, .{ 255, 0, 0, 128 }, .custom), + }; + const marked = try document.renderWithHighlights(std.testing.allocator, 0, &highlights); + defer std.testing.allocator.free(marked.rgba); + const plain_after = try document.render(std.testing.allocator, 0); + defer std.testing.allocator.free(plain_after.rgba); + + try std.testing.expectEqual(plain_before.width, marked.width); + try std.testing.expectEqual(plain_before.height, marked.height); + try std.testing.expect(!std.mem.eql(u8, plain_before.rgba, marked.rgba)); + try std.testing.expectEqualSlices(u8, plain_before.rgba, plain_after.rgba); +} + +test "offset crop and rotation keep normalized highlights pixel-aligned" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const fixture = try makeOffsetRotatedPdf(std.testing.allocator); + defer std.testing.allocator.free(fixture); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "offset-rotated.pdf", + .data = fixture, + }); + var path_buffer: [256]u8 = undefined; + const path = try std.fmt.bufPrint( + &path_buffer, + ".zig-cache/tmp/{s}/offset-rotated.pdf", + .{tmp.sub_path}, + ); + + var document = try Document.open(path); + defer document.deinit(); + const plain = try document.render(std.testing.allocator, 0); + defer std.testing.allocator.free(plain.rgba); + // CropBox is 160x150 points and /Rotate 90 swaps its displayed axes. + try std.testing.expectEqual(@as(usize, 300), plain.width); + try std.testing.expectEqual(@as(usize, 320), plain.height); + + const left_half = Quad{ + .ul = .{ .x = 0, .y = 0 }, + .ur = .{ .x = 0.5, .y = 0 }, + .ll = .{ .x = 0, .y = 1 }, + .lr = .{ .x = 0.5, .y = 1 }, + }; + const highlights = [_]Highlight{ + Highlight.init(left_half, .{ 0, 0, 255, 255 }, .custom), + }; + const marked = try document.renderWithHighlights(std.testing.allocator, 0, &highlights); + defer std.testing.allocator.free(marked.rgba); + try std.testing.expectEqual(plain.width, marked.width); + try std.testing.expectEqual(plain.height, marked.height); + + const left = (plain.height / 2 * plain.width + plain.width / 4) * 4; + const right = (plain.height / 2 * plain.width + plain.width * 3 / 4) * 4; + try std.testing.expect(plain.rgba[left] > 240 and + plain.rgba[left + 1] < 10 and plain.rgba[left + 2] < 10); + try std.testing.expect(marked.rgba[left] < 10 and + marked.rgba[left + 1] < 10 and marked.rgba[left + 2] > 240); + try std.testing.expectEqualSlices(u8, plain.rgba[right .. right + 4], marked.rgba[right .. right + 4]); +} diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c new file mode 100644 index 00000000..a26fea55 --- /dev/null +++ b/src/pdf_bridge.c @@ -0,0 +1,686 @@ +#include "pdf_bridge.h" + +#include <mupdf/fitz.h> + +#include <math.h> +#include <stdint.h> +#include <stdlib.h> +#include <string.h> + +#define PARDES_PDF_INITIAL_RESULT_QUADS 64 + +struct pardes_pdf_document { + fz_context *ctx; + fz_document *doc; + int page_count; + + /* One UI thread owns a document. Page switches invalidate this pair. */ + int cached_page_number; + fz_page *cached_page; + fz_stext_page *cached_text; + fz_rect cached_bounds; +}; + +static void +pardes_pdf_drop_cached_page(pardes_pdf_document *document) +{ + fz_drop_stext_page(document->ctx, document->cached_text); + fz_drop_page(document->ctx, document->cached_page); + document->cached_text = NULL; + document->cached_page = NULL; + document->cached_page_number = -1; +} + +static int +pardes_pdf_valid_bounds(fz_rect bounds) +{ + return isfinite(bounds.x0) && isfinite(bounds.y0) && + isfinite(bounds.x1) && isfinite(bounds.y1) && + bounds.x1 > bounds.x0 && bounds.y1 > bounds.y0; +} + +/* + * Load into temporaries first, then replace the cache in one non-throwing + * commit. Thus a failed page/text load leaves the previous cached page valid. + */ +static void +pardes_pdf_cache_page( + pardes_pdf_document *document, + int page_number, + int require_text) +{ + fz_context *ctx = document->ctx; + fz_page *page = NULL; + fz_stext_page *text = NULL; + fz_rect bounds; + fz_stext_options options = {0}; + options.flags = FZ_STEXT_DEHYPHENATE; + + if (document->cached_page_number == page_number) { + if (require_text && document->cached_text == NULL) + document->cached_text = fz_new_stext_page_from_page( + ctx, document->cached_page, &options); + return; + } + + fz_var(page); + fz_var(text); + fz_try(ctx) + { + page = fz_load_page(ctx, document->doc, page_number); + bounds = fz_bound_page(ctx, page); + if (!pardes_pdf_valid_bounds(bounds)) + fz_throw(ctx, FZ_ERROR_FORMAT, "PDF page has empty bounds"); + if (require_text) + text = fz_new_stext_page_from_page(ctx, page, &options); + } + fz_catch(ctx) + { + fz_drop_stext_page(ctx, text); + fz_drop_page(ctx, page); + fz_rethrow(ctx); + } + + pardes_pdf_drop_cached_page(document); + document->cached_page_number = page_number; + document->cached_page = page; + document->cached_text = text; + document->cached_bounds = bounds; +} + +static float +pardes_pdf_clamp_unit(float value) +{ + if (value < 0.0f) + return 0.0f; + if (value > 1.0f) + return 1.0f; + return value; +} + +static int +pardes_pdf_valid_point(pardes_pdf_point point) +{ + return isfinite(point.x) && isfinite(point.y); +} + +static fz_point +pardes_pdf_point_to_page(pardes_pdf_document *document, pardes_pdf_point point) +{ + fz_rect bounds = document->cached_bounds; + float x = pardes_pdf_clamp_unit(point.x); + float y = pardes_pdf_clamp_unit(point.y); + return fz_make_point( + bounds.x0 + x * (bounds.x1 - bounds.x0), + bounds.y0 + y * (bounds.y1 - bounds.y0)); +} + +static pardes_pdf_point +pardes_pdf_point_from_page(pardes_pdf_document *document, fz_point point) +{ + fz_rect bounds = document->cached_bounds; + pardes_pdf_point result; + result.x = pardes_pdf_clamp_unit( + (point.x - bounds.x0) / (bounds.x1 - bounds.x0)); + result.y = pardes_pdf_clamp_unit( + (point.y - bounds.y0) / (bounds.y1 - bounds.y0)); + return result; +} + +static pardes_pdf_quad +pardes_pdf_quad_from_page(pardes_pdf_document *document, fz_quad quad) +{ + pardes_pdf_quad result; + result.ul = pardes_pdf_point_from_page(document, quad.ul); + result.ur = pardes_pdf_point_from_page(document, quad.ur); + result.ll = pardes_pdf_point_from_page(document, quad.ll); + result.lr = pardes_pdf_point_from_page(document, quad.lr); + return result; +} + +static int +pardes_pdf_valid_quad(pardes_pdf_quad quad) +{ + return pardes_pdf_valid_point(quad.ul) && + pardes_pdf_valid_point(quad.ur) && + pardes_pdf_valid_point(quad.ll) && + pardes_pdf_valid_point(quad.lr); +} + +static fz_quad +pardes_pdf_quad_to_page(pardes_pdf_document *document, pardes_pdf_quad quad) +{ + fz_quad result; + result.ul = pardes_pdf_point_to_page(document, quad.ul); + result.ur = pardes_pdf_point_to_page(document, quad.ur); + result.ll = pardes_pdf_point_to_page(document, quad.ll); + result.lr = pardes_pdf_point_to_page(document, quad.lr); + return result; +} + +static size_t +pardes_pdf_grown_capacity(size_t current, size_t needed, size_t maximum) +{ + size_t capacity = current == 0 ? PARDES_PDF_INITIAL_RESULT_QUADS : current; + while (capacity < needed) { + if (capacity > maximum / 2) { + capacity = maximum; + break; + } + capacity *= 2; + } + return capacity; +} + +pardes_pdf_document * +pardes_pdf_open(const char *path, int *page_count) +{ + pardes_pdf_document *state; + fz_context *ctx; + fz_document *doc = NULL; + int pages = 0; + + if (path == NULL || page_count == NULL) + return NULL; + + state = (pardes_pdf_document *)calloc(1, sizeof(*state)); + if (state == NULL) + return NULL; + state->cached_page_number = -1; + + ctx = fz_new_context(NULL, NULL, PARDES_PDF_STORE_LIMIT_BYTES); + if (ctx == NULL) { + free(state); + return NULL; + } + + fz_var(doc); + fz_try(ctx) + { + fz_register_document_handlers(ctx); + doc = fz_open_document(ctx, path); + pages = fz_count_pages(ctx, doc); + if (pages < 1) + fz_throw(ctx, FZ_ERROR_FORMAT, "PDF has no pages"); + } + fz_catch(ctx) + { + fz_report_error(ctx); + fz_drop_document(ctx, doc); + fz_drop_context(ctx); + free(state); + return NULL; + } + + state->ctx = ctx; + state->doc = doc; + state->page_count = pages; + *page_count = pages; + return state; +} + +void +pardes_pdf_close(pardes_pdf_document *document) +{ + if (document == NULL) + return; + pardes_pdf_drop_cached_page(document); + fz_drop_document(document->ctx, document->doc); + fz_drop_context(document->ctx); + free(document); +} + +int +pardes_pdf_render( + pardes_pdf_document *document, + int page_number, + int max_dimension, + pardes_pdf_pixmap *out) +{ + return pardes_pdf_render_with_highlights( + document, page_number, max_dimension, NULL, 0, out); +} + +int +pardes_pdf_render_with_highlights( + pardes_pdf_document *document, + int page_number, + int max_dimension, + const pardes_pdf_highlight *highlights, + size_t highlight_count, + pardes_pdf_pixmap *out) +{ + fz_context *ctx; + fz_pixmap *pixmap = NULL; + fz_device *device = NULL; + fz_path *path = NULL; + fz_matrix ctm; + fz_rect bounds; + float longest; + float scale = 2.0f; /* 144 dpi; PDF user space is 72 dpi. */ + size_t i; + + if (document == NULL || out == NULL || page_number < 0 || + page_number >= document->page_count || max_dimension < 1 || + (highlight_count != 0 && highlights == NULL) || + highlight_count > PARDES_PDF_MAX_RESULT_QUADS) + return PARDES_PDF_ERROR; + + memset(out, 0, sizeof(*out)); + ctx = document->ctx; + fz_var(pixmap); + fz_var(device); + fz_var(path); + fz_try(ctx) + { + pardes_pdf_cache_page(document, page_number, 0); + bounds = document->cached_bounds; + longest = fmaxf(bounds.x1 - bounds.x0, bounds.y1 - bounds.y0); + if (!(longest > 0.0f)) + fz_throw(ctx, FZ_ERROR_FORMAT, "PDF page has empty bounds"); + if (longest * scale > (float)max_dimension) + scale = (float)max_dimension / longest; + /* + * Unlike a bare scale, fz_transform_page translates non-zero crop + * boxes to the pixmap origin and rounds both dimensions coherently. + * The overlay draw device receives this exact CTM, so normalized page + * geometry and page pixels stay aligned for offset and rotated pages. + */ + ctm = fz_transform_page(bounds, scale * 72.0f, 0.0f); + + /* + * alpha=0 is intentional. MuPDF clears this RGB pixmap to opaque + * white before drawing. Asking for alpha=1 instead produces + * premultiplied transparent samples, while Pardes consumes straight + * RGBA after the Zig-side RGB expansion. + */ + pixmap = fz_new_pixmap_from_page( + ctx, document->cached_page, ctm, fz_device_rgb(ctx), 0); + if (fz_pixmap_width(ctx, pixmap) < 1 || + fz_pixmap_height(ctx, pixmap) < 1) + fz_throw(ctx, FZ_ERROR_FORMAT, "PDF page rendered empty"); + + if (highlight_count != 0) { + device = fz_new_draw_device(ctx, ctm, pixmap); + for (i = 0; i < highlight_count; ++i) { + fz_quad quad; + float color[3]; + float alpha; + + if (!pardes_pdf_valid_quad(highlights[i].quad)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, + "PDF highlight contains a non-finite point"); + if (highlights[i].rgba[3] == 0) + continue; + + /* Kind is semantic metadata; caller-supplied RGBA is final. */ + (void)highlights[i].kind; + quad = pardes_pdf_quad_to_page(document, highlights[i].quad); + color[0] = highlights[i].rgba[0] / 255.0f; + color[1] = highlights[i].rgba[1] / 255.0f; + color[2] = highlights[i].rgba[2] / 255.0f; + alpha = highlights[i].rgba[3] / 255.0f; + + path = fz_new_path(ctx); + fz_moveto(ctx, path, quad.ul.x, quad.ul.y); + fz_lineto(ctx, path, quad.ur.x, quad.ur.y); + fz_lineto(ctx, path, quad.lr.x, quad.lr.y); + fz_lineto(ctx, path, quad.ll.x, quad.ll.y); + fz_closepath(ctx, path); + fz_fill_path(ctx, device, path, 0, fz_identity, + fz_device_rgb(ctx), color, alpha, + fz_default_color_params); + fz_drop_path(ctx, path); + path = NULL; + } + fz_close_device(ctx, device); + } + } + fz_always(ctx) + { + fz_drop_path(ctx, path); + fz_drop_device(ctx, device); + } + fz_catch(ctx) + { + fz_report_error(ctx); + fz_drop_pixmap(ctx, pixmap); + return PARDES_PDF_ERROR; + } + + out->handle = pixmap; + out->samples = fz_pixmap_samples(ctx, pixmap); + out->width = fz_pixmap_width(ctx, pixmap); + out->height = fz_pixmap_height(ctx, pixmap); + out->stride = fz_pixmap_stride(ctx, pixmap); + out->components = fz_pixmap_components(ctx, pixmap); + return PARDES_PDF_OK; +} + +void +pardes_pdf_drop_pixmap(pardes_pdf_document *document, void *pixmap) +{ + if (document != NULL && pixmap != NULL) + fz_drop_pixmap(document->ctx, (fz_pixmap *)pixmap); +} + +int +pardes_pdf_page_text( + pardes_pdf_document *document, + int page_number, + pardes_pdf_text *out) +{ + fz_context *ctx; + fz_buffer *buffer = NULL; + unsigned char *data = NULL; + size_t len = 0; + + if (document == NULL || out == NULL || page_number < 0 || + page_number >= document->page_count) + return PARDES_PDF_ERROR; + + memset(out, 0, sizeof(*out)); + ctx = document->ctx; + fz_var(buffer); + fz_try(ctx) + { + pardes_pdf_cache_page(document, page_number, 1); + buffer = fz_new_buffer_from_stext_page(ctx, document->cached_text); + len = fz_buffer_storage(ctx, buffer, &data); + } + fz_catch(ctx) + { + fz_report_error(ctx); + fz_drop_buffer(ctx, buffer); + return PARDES_PDF_ERROR; + } + + out->handle = buffer; + out->data = data; + out->len = len; + return PARDES_PDF_OK; +} + +void +pardes_pdf_drop_text(pardes_pdf_document *document, void *buffer) +{ + if (document != NULL && buffer != NULL) + fz_drop_buffer(document->ctx, (fz_buffer *)buffer); +} + +int +pardes_pdf_search_page( + pardes_pdf_document *document, + int page_number, + const char *needle, + pardes_pdf_search_result *out) +{ + fz_context *ctx; + fz_search *search = NULL; + pardes_pdf_search_quad *items = NULL; + size_t capacity = 0; + size_t quad_count = 0; + size_t hit_count = 0; + int status = PARDES_PDF_ERROR; + + if (document == NULL || needle == NULL || out == NULL || page_number < 0 || + page_number >= document->page_count) + return PARDES_PDF_ERROR; + + memset(out, 0, sizeof(*out)); + ctx = document->ctx; + fz_var(search); + fz_var(items); + fz_var(status); + fz_try(ctx) + { + pardes_pdf_cache_page(document, page_number, 1); + search = fz_new_search(ctx); + fz_search_set_options( + ctx, search, FZ_SEARCH_IGNORE_CASE, needle); + + /* fz_feed_search takes ownership; retain the document's cache ref. */ + fz_feed_search(ctx, search, + fz_keep_stext_page(ctx, document->cached_text), page_number); + + for (;;) { + fz_search_result result = fz_search_forwards(ctx, search); + if (result.reason == FZ_SEARCH_COMPLETE) + break; + if (result.reason == FZ_SEARCH_MORE_INPUT) { + /* This is deliberately a one-page search. */ + fz_feed_search(ctx, search, NULL, + result.u.more_input.seq_needed); + continue; + } + if (result.reason == FZ_SEARCH_MATCH) { + fz_search_result_details *details = result.u.match.result; + size_t match_quads; + size_t needed; + size_t next_capacity; + size_t i; + + if (details == NULL || details->num_quads < 0 || + (details->num_quads > 0 && details->quads == NULL)) + fz_throw(ctx, FZ_ERROR_FORMAT, + "MuPDF returned malformed search geometry"); + if (hit_count >= PARDES_PDF_MAX_RESULT_QUADS) { + status = PARDES_PDF_LIMIT_EXCEEDED; + fz_throw(ctx, FZ_ERROR_LIMIT, + "PDF search result limit exceeded"); + } + + match_quads = (size_t)details->num_quads; + if (match_quads > PARDES_PDF_MAX_RESULT_QUADS - quad_count) { + status = PARDES_PDF_LIMIT_EXCEEDED; + fz_throw(ctx, FZ_ERROR_LIMIT, + "PDF search quad limit exceeded"); + } + needed = quad_count + match_quads; + if (needed > capacity) { + next_capacity = pardes_pdf_grown_capacity( + capacity, needed, PARDES_PDF_MAX_RESULT_QUADS); + items = fz_realloc_array( + ctx, items, next_capacity, pardes_pdf_search_quad); + capacity = next_capacity; + } + for (i = 0; i < match_quads; ++i) { + items[quad_count + i].quad = pardes_pdf_quad_from_page( + document, details->quads[i].quad); + items[quad_count + i].hit = hit_count; + } + quad_count = needed; + ++hit_count; + continue; + } + fz_throw(ctx, FZ_ERROR_FORMAT, + "MuPDF returned an unknown search state"); + } + status = PARDES_PDF_OK; + } + fz_always(ctx) + { + fz_drop_search(ctx, search); + } + fz_catch(ctx) + { + if (status != PARDES_PDF_LIMIT_EXCEEDED) + fz_report_error(ctx); + fz_free(ctx, items); + return status; + } + + out->handle = items; + out->quads = items; + out->quad_count = quad_count; + out->hit_count = hit_count; + return PARDES_PDF_OK; +} + +void +pardes_pdf_drop_search_result(pardes_pdf_document *document, void *result) +{ + if (document != NULL && result != NULL) + fz_free(document->ctx, result); +} + +int +pardes_pdf_select( + pardes_pdf_document *document, + int page_number, + pardes_pdf_point start, + pardes_pdf_point end, + pardes_pdf_selection *out) +{ + fz_context *ctx; + fz_quad *raw = NULL; + pardes_pdf_quad *items = NULL; + fz_point a; + fz_point b; + int capacity = PARDES_PDF_INITIAL_RESULT_QUADS; + int count = 0; + int status = PARDES_PDF_ERROR; + int i; + + if (document == NULL || out == NULL || page_number < 0 || + page_number >= document->page_count || + !pardes_pdf_valid_point(start) || !pardes_pdf_valid_point(end)) + return PARDES_PDF_ERROR; + + memset(out, 0, sizeof(*out)); + ctx = document->ctx; + fz_var(raw); + fz_var(items); + fz_var(status); + fz_try(ctx) + { + pardes_pdf_cache_page(document, page_number, 1); + a = pardes_pdf_point_to_page(document, start); + b = pardes_pdf_point_to_page(document, end); + (void)fz_snap_selection( + ctx, document->cached_text, &a, &b, FZ_SELECT_WORDS); + + raw = fz_malloc_array(ctx, capacity, fz_quad); + for (;;) { + int next_capacity; + count = fz_highlight_selection( + ctx, document->cached_text, a, b, raw, capacity); + if (count < 0) + fz_throw(ctx, FZ_ERROR_FORMAT, + "MuPDF returned an invalid selection count"); + if (count < capacity) + break; + + /* One extra slot distinguishes exactly-at-limit from truncation. */ + if (capacity >= PARDES_PDF_MAX_RESULT_QUADS + 1) { + status = PARDES_PDF_LIMIT_EXCEEDED; + fz_throw(ctx, FZ_ERROR_LIMIT, + "PDF selection quad limit exceeded"); + } + next_capacity = capacity * 2; + if (next_capacity > PARDES_PDF_MAX_RESULT_QUADS + 1) + next_capacity = PARDES_PDF_MAX_RESULT_QUADS + 1; + raw = fz_realloc_array(ctx, raw, next_capacity, fz_quad); + capacity = next_capacity; + } + if (count > PARDES_PDF_MAX_RESULT_QUADS) { + status = PARDES_PDF_LIMIT_EXCEEDED; + fz_throw(ctx, FZ_ERROR_LIMIT, + "PDF selection quad limit exceeded"); + } + + if (count != 0) + items = fz_malloc_array(ctx, (size_t)count, pardes_pdf_quad); + for (i = 0; i < count; ++i) + items[i] = pardes_pdf_quad_from_page(document, raw[i]); + status = PARDES_PDF_OK; + } + fz_always(ctx) + { + fz_free(ctx, raw); + } + fz_catch(ctx) + { + if (status != PARDES_PDF_LIMIT_EXCEEDED) + fz_report_error(ctx); + fz_free(ctx, items); + return status; + } + + out->handle = items; + out->quads = items; + out->quad_count = (size_t)count; + out->start = pardes_pdf_point_from_page(document, a); + out->end = pardes_pdf_point_from_page(document, b); + return PARDES_PDF_OK; +} + +void +pardes_pdf_drop_selection(pardes_pdf_document *document, void *selection) +{ + if (document != NULL && selection != NULL) + fz_free(document->ctx, selection); +} + +int +pardes_pdf_point_inside_quad(pardes_pdf_point point, pardes_pdf_quad quad) +{ + fz_quad native; + + if (!pardes_pdf_valid_point(point) || !pardes_pdf_valid_quad(quad)) + return 0; + native.ul = fz_make_point(quad.ul.x, quad.ul.y); + native.ur = fz_make_point(quad.ur.x, quad.ur.y); + native.ll = fz_make_point(quad.ll.x, quad.ll.y); + native.lr = fz_make_point(quad.lr.x, quad.lr.y); + return fz_is_point_inside_quad( + fz_make_point(point.x, point.y), native) != 0; +} + +int +pardes_pdf_copy_selection( + pardes_pdf_document *document, + int page_number, + pardes_pdf_point start, + pardes_pdf_point end, + pardes_pdf_owned_text *out) +{ + fz_context *ctx; + char *text = NULL; + fz_point a; + fz_point b; + + if (document == NULL || out == NULL || page_number < 0 || + page_number >= document->page_count || + !pardes_pdf_valid_point(start) || !pardes_pdf_valid_point(end)) + return PARDES_PDF_ERROR; + + memset(out, 0, sizeof(*out)); + ctx = document->ctx; + fz_var(text); + fz_try(ctx) + { + pardes_pdf_cache_page(document, page_number, 1); + a = pardes_pdf_point_to_page(document, start); + b = pardes_pdf_point_to_page(document, end); + text = fz_copy_selection(ctx, document->cached_text, a, b, 0); + } + fz_catch(ctx) + { + fz_report_error(ctx); + fz_free(ctx, text); + return PARDES_PDF_ERROR; + } + + out->handle = text; + out->data = (const unsigned char *)text; + out->len = text == NULL ? 0 : strlen(text); + return PARDES_PDF_OK; +} + +void +pardes_pdf_drop_owned_text(pardes_pdf_document *document, void *text) +{ + if (document != NULL && text != NULL) + fz_free(document->ctx, text); +} diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h new file mode 100644 index 00000000..85f477d0 --- /dev/null +++ b/src/pdf_bridge.h @@ -0,0 +1,171 @@ +#ifndef PARDES_PDF_BRIDGE_H +#define PARDES_PDF_BRIDGE_H + +#include <stddef.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * MuPDF reports failures with setjmp/longjmp through fz_try/fz_catch. Those + * jumps must never cross Zig frames, so every operation that can throw lives + * behind this deliberately small C boundary. + */ +typedef struct pardes_pdf_document pardes_pdf_document; + +typedef struct pardes_pdf_pixmap { + void *handle; + const unsigned char *samples; + int width; + int height; + int stride; + int components; +} pardes_pdf_pixmap; + +typedef struct pardes_pdf_text { + void *handle; + const unsigned char *data; + size_t len; +} pardes_pdf_text; + +/* + * All geometry crossing this boundary is normalized to the cached page's + * bounds. (0,0) is the page's top-left and (1,1) its bottom-right, regardless + * of the DPI used for a particular render. Corner names retain MuPDF's quad + * orientation; callers must not collapse them to an axis-aligned rectangle. + */ +typedef struct pardes_pdf_point { + float x; + float y; +} pardes_pdf_point; + +typedef struct pardes_pdf_quad { + pardes_pdf_point ul; + pardes_pdf_point ur; + pardes_pdf_point ll; + pardes_pdf_point lr; +} pardes_pdf_quad; + +typedef enum pardes_pdf_highlight_kind { + PARDES_PDF_HIGHLIGHT_CUSTOM = 0, + PARDES_PDF_HIGHLIGHT_SEARCH = 1, + PARDES_PDF_HIGHLIGHT_SELECTION = 2 +} pardes_pdf_highlight_kind; + +typedef struct pardes_pdf_highlight { + pardes_pdf_quad quad; + unsigned char rgba[4]; + int kind; +} pardes_pdf_highlight; + +typedef struct pardes_pdf_search_quad { + pardes_pdf_quad quad; + size_t hit; +} pardes_pdf_search_quad; + +typedef struct pardes_pdf_search_result { + void *handle; + const pardes_pdf_search_quad *quads; + size_t quad_count; + size_t hit_count; +} pardes_pdf_search_result; + +typedef struct pardes_pdf_selection { + void *handle; + const pardes_pdf_quad *quads; + size_t quad_count; + pardes_pdf_point start; + pardes_pdf_point end; +} pardes_pdf_selection; + +typedef struct pardes_pdf_owned_text { + void *handle; + const unsigned char *data; + size_t len; +} pardes_pdf_owned_text; + +enum { + PARDES_PDF_OK = 0, + PARDES_PDF_ERROR = -1, + PARDES_PDF_LIMIT_EXCEEDED = -2, + + /* Per-document MuPDF eviction cache; render buffers are separate. */ + PARDES_PDF_STORE_LIMIT_BYTES = 64 * 1024 * 1024, + + /* + * Search grows geometrically up to this aggregate count and then fails + * atomically with PARDES_PDF_LIMIT_EXCEEDED; it never returns a silently + * truncated result. Selection uses one extra private probe slot so an + * exact-at-the-limit result remains distinguishable from truncation. + */ + PARDES_PDF_MAX_RESULT_QUADS = 65536 +}; + +pardes_pdf_document *pardes_pdf_open(const char *path, int *page_count); +void pardes_pdf_close(pardes_pdf_document *document); + +int pardes_pdf_render( + pardes_pdf_document *document, + int page, + int max_dimension, + pardes_pdf_pixmap *out +); + +int pardes_pdf_render_with_highlights( + pardes_pdf_document *document, + int page, + int max_dimension, + const pardes_pdf_highlight *highlights, + size_t highlight_count, + pardes_pdf_pixmap *out +); +void pardes_pdf_drop_pixmap(pardes_pdf_document *document, void *pixmap); + +int pardes_pdf_page_text( + pardes_pdf_document *document, + int page, + pardes_pdf_text *out +); +void pardes_pdf_drop_text(pardes_pdf_document *document, void *buffer); + +/* Case-insensitive, single-page search using MuPDF 1.27's iterative API. */ +int pardes_pdf_search_page( + pardes_pdf_document *document, + int page, + const char *needle, + pardes_pdf_search_result *out +); +void pardes_pdf_drop_search_result(pardes_pdf_document *document, void *result); + +/* Word-snap two normalized points and return oriented highlight quads. */ +int pardes_pdf_select( + pardes_pdf_document *document, + int page, + pardes_pdf_point start, + pardes_pdf_point end, + pardes_pdf_selection *out +); +void pardes_pdf_drop_selection(pardes_pdf_document *document, void *selection); + +/* Pure/no-throw oriented-quad hit test in normalized page space. */ +int pardes_pdf_point_inside_quad( + pardes_pdf_point point, + pardes_pdf_quad quad +); + +/* The input endpoints are normally the snapped points returned above. */ +int pardes_pdf_copy_selection( + pardes_pdf_document *document, + int page, + pardes_pdf_point start, + pardes_pdf_point end, + pardes_pdf_owned_text *out +); +void pardes_pdf_drop_owned_text(pardes_pdf_document *document, void *text); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/tty/tty.zig b/src/tty/tty.zig index f0013020..60a2d958 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -91,6 +91,90 @@ const Pty = struct { reader: std.Io.Future(anyerror!void), }; +const KittyPlacement = struct { + cell_x: u16, + cell_y: u16, + options: vaxis.Image.DrawOptions, +}; + +fn terminalCellPixels(total_pixels: u16, cells: u16, fallback: u32) u32 { + if (total_pixels == 0 or cells == 0) return fallback; + return @max(1, @as(u32, total_pixels) / cells); +} + +fn updateCoreTerminalSize(core: *pardes.Pardes, cols: u16, rows: u16, pixel_w: u16, pixel_h: u16) void { + core.update(.{ .resize = .{ + .cols = cols, + .rows = rows, + .cell_pixels = .{ + .w = @intCast(terminalCellPixels(pixel_w, cols, 8)), + .h = @intCast(terminalCellPixels(pixel_h, rows, 16)), + }, + } }); +} + +/// Translate backend-neutral source/destination pixels into Kitty's source +/// crop plus cell-sized placement. Kitty can specify only one scaled axis +/// without distorting the image; the terminal derives the other axis. +fn kittyPlacement( + place: pardes.ImagePlace, + screen_cols: u16, + screen_rows: u16, + screen_pixel_w: u16, + screen_pixel_h: u16, +) ?KittyPlacement { + if (comptime pardes.pdf_enabled) { + if (place.native.fit == .contain) return null; + const cell_w = terminalCellPixels(screen_pixel_w, screen_cols, 8); + const cell_h = terminalCellPixels(screen_pixel_h, screen_rows, 16); + const body_w = std.math.mul(u32, place.w, cell_w) catch return null; + const body_h = std.math.mul(u32, place.h, cell_h) catch return null; + const geometry = pardes.image.nativeGeometry( + place.iw, + place.ih, + body_w, + body_h, + place.native.fit, + place.native.pan_x, + place.native.pan_y, + ) orelse return null; + + // Every Kitty protocol crop field is u16. Reject an attachment which + // the wire format cannot represent instead of truncating it into + // another part of the page. + const src_x = std.math.cast(u16, geometry.src.x) orelse return null; + const src_y = std.math.cast(u16, geometry.src.y) orelse return null; + const src_w = std.math.cast(u16, geometry.src.w) orelse return null; + const src_h = std.math.cast(u16, geometry.src.h) orelse return null; + const cell_x = std.math.cast(u16, geometry.dst.x / cell_w) orelse return null; + const cell_y = std.math.cast(u16, geometry.dst.y / cell_h) orelse return null; + if (cell_x >= place.w or cell_y >= place.h) return null; + const pixel_x = std.math.cast(u16, geometry.dst.x % cell_w) orelse return null; + const pixel_y = std.math.cast(u16, geometry.dst.y % cell_h) orelse return null; + + return .{ + .cell_x = cell_x, + .cell_y = cell_y, + .options = .{ + .clip_region = .{ .x = src_x, .y = src_y, .width = src_w, .height = src_h }, + .pixel_offset = if (pixel_x != 0 or pixel_y != 0) .{ .x = pixel_x, .y = pixel_y } else null, + .size = switch (place.native.fit) { + .width => .{ .cols = place.w }, + .height => .{ .rows = place.h }, + .contain => unreachable, + }, + }, + }; + } else { + return null; + } +} + +fn kittyImageRepresentable(place: pardes.ImagePlace) bool { + return place.iw > 0 and place.ih > 0 and + place.iw <= std.math.maxInt(u16) and place.ih <= std.math.maxInt(u16); +} + pub fn run(init: std.process.Init, opts: pardes.Options) !void { const io = init.io; const gpa = init.gpa; @@ -143,7 +227,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // pane unless this is in the env BEFORE bash starts (the rc is too late) if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); - const KittyCached = struct { serial: u32, image: vaxis.Image }; + const KittyCached = struct { key: pardes.ImageCacheKey, image: vaxis.Image }; var kitty_handles: [pardes.MAX_PANES]?KittyCached = @splat(null); defer for (&kitty_handles) |*slot| if (slot.*) |cached| { vx.freeImage(tty.writer(), cached.image.id); @@ -279,7 +363,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { .quit => stop = true, .winsize => |ws| { try vx.resize(gpa, tty.writer(), ws); - core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } }); + if (comptime pardes.pdf_enabled) + updateCoreTerminalSize(core, ws.cols, ws.rows, ws.x_pixel, ws.y_pixel) + else + core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } }); }, .pty_read => |pr| { core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); @@ -404,6 +491,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { nc.native_images = vx.caps.kitty_graphics; core.deinit(); core = nc; + if (comptime pardes.pdf_enabled) { + // A restored core did not receive the terminal's earlier + // winsize event. Reapply both the grid and physical cells + // before its first PDF frame so pointer/pan geometry stays + // identical to placement. + updateCoreTerminalSize(core, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix); + } } // live cwd for tags/look: cheap per-pane lookup, per frame @@ -451,15 +545,19 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } } tz_cells.end(); - // Pixel attachments (kitty graphics): transmit once per pane + // Pixel attachments (kitty graphics): transmit once per pixel // generation, then re-place every frame (placements aren't // persistent). A slot can be closed and reused for an unrelated pane, - // so the pane number by itself is not a cache key. + // and a live document can render new pixels, so neither the pane + // number nor its lifetime serial is a complete cache key by itself. var kitty_seen: [pardes.MAX_PANES]bool = @splat(false); for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; + if (comptime pardes.pdf_enabled) { + if (!kittyImageRepresentable(place)) continue; + } kitty_seen[place.pane] = true; - if (kitty_handles[place.pane]) |cached| if (cached.serial != place.serial) { + if (kitty_handles[place.pane]) |cached| if (!cached.key.eql(place.cacheKey())) { vx.freeImage(tty.writer(), cached.image.id); kitty_handles[place.pane] = null; }; @@ -469,12 +567,33 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { defer gpa.free(b64); _ = enc.encode(b64, place.rgba); if (vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null) |handle| - kitty_handles[place.pane] = .{ .serial = place.serial, .image = handle }; + kitty_handles[place.pane] = .{ .key = place.cacheKey(), .image = handle }; } else |_| {} } if (kitty_handles[place.pane]) |cached| { - const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); - cached.image.draw(child, .{ .scale = .contain }) catch {}; + if (comptime !pardes.pdf_enabled) { + const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); + cached.image.draw(child, .{ .scale = .contain }) catch {}; + } else { + if (place.native.fit == .contain) { + const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); + cached.image.draw(child, .{ .scale = .contain }) catch {}; + } else if (kittyPlacement( + place, + vx.screen.width, + vx.screen.height, + vx.screen.width_pix, + vx.screen.height_pix, + )) |placement| { + const child = win.child(.{ + .x_off = @as(i17, place.x) + placement.cell_x, + .y_off = @as(i17, place.y) + placement.cell_y, + .width = place.w - placement.cell_x, + .height = place.h - placement.cell_y, + }); + cached.image.draw(child, placement.options) catch {}; + } + } } } // Toggling PETSCII or closing a pane removes its attachment from the diff --git a/test/image_harness.zig b/test/image_harness.zig index 5ec192c2..390c14e8 100644 --- a/test/image_harness.zig +++ b/test/image_harness.zig @@ -4,6 +4,8 @@ //! real transmit + placement APCs //! zig build image-harness -Dplatform=gui run the real SDL GPU renderer, //! capture a PPM, inspect pixels +//! zig build pdf-harness render a generated two-page PDF +//! zig build pdf-harness -Dplatform=gui //! //! Unlike the text snapshot suite, both arms observe the thing the native //! backend actually emits. The TTY arm completes vaxis's asynchronous @@ -20,16 +22,20 @@ extern "c" fn unsetenv(name: [*:0]const u8) c_int; const gpa = std.heap.page_allocator; const fixture_rgb = [3]u8{ 13, 77, 231 }; +const pdf_red = [3]u8{ 255, 0, 0 }; +const pdf_green = [3]u8{ 0, 255, 0 }; pub fn main(init: std.process.Init) !void { var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const args = try init.minimal.args.toSlice(arena); - if (args.len != 3) return error.BadArgs; + if (args.len != 3 and args.len != 4) return error.BadArgs; const exe = try arena.dupeZ(u8, args[1]); const backend = args[2]; + const pdf_mode = args.len == 4 and std.mem.eql(u8, args[3], "pdf"); + if (args.len == 4 and !pdf_mode) return error.BadArgs; const base = try std.fmt.allocPrintSentinel(arena, "/tmp/pardes-image-harness-{d}", .{libc.getpid()}, 0); if (libc.mkdir(base, 0o755) != 0 and libc.errno(-1) != .EXIST) return error.MkdirFailed; defer cleanupBase(arena, base); @@ -39,14 +45,20 @@ pub fn main(init: std.process.Init) !void { _ = setenv("LC_ALL", "C", 1); if (std.mem.eql(u8, backend, "tty")) { - try runKitty(arena, exe, base); + if (pdf_mode) + try runKittyPdf(arena, exe, base) + else + try runKitty(arena, exe, base); } else if (std.mem.eql(u8, backend, "gui")) { - try runGui(arena, exe, base); + if (pdf_mode) + try runGuiPdf(arena, exe, base) + else + try runGui(arena, exe, base); } else return error.BadArgs; } fn cleanupBase(arena: std.mem.Allocator, base: [:0]const u8) void { - const names = [_][]const u8{ "native.ppm", "latest.ppm", "latest.ppm.tmp", ".bash_history" }; + const names = [_][]const u8{ "native.ppm", "pages.pdf", "latest.ppm", "latest.ppm.tmp", ".bash_history" }; for (names) |name| { const path = std.fmt.allocPrintSentinel(arena, "{s}/{s}", .{ base, name }, 0) catch continue; _ = libc.unlink(path); @@ -54,6 +66,65 @@ fn cleanupBase(arena: std.mem.Allocator, base: [:0]const u8) void { _ = libc.rmdir(base); } +fn appendFmt(arena: std.mem.Allocator, out: *std.ArrayList(u8), comptime fmt: []const u8, args: anytype) !void { + try out.appendSlice(arena, try std.fmt.allocPrint(arena, fmt, args)); +} + +/// A self-contained, valid PDF fixture. Page one is solid red and contains +/// FIRST NEEDLE; page two is solid green and contains SECOND TARGET. Building +/// it here keeps the backend test independent of mutool, fonts, and checked-in +/// binary fixtures while the xref offsets remain exact. +fn writePdf(arena: std.mem.Allocator, path: [:0]const u8) !void { + const first_stream = + \\1 0 0 rg + \\0 0 72 48 re + \\f + \\0 0 0 rg + \\BT + \\/F1 10 Tf + \\4 20 Td + \\(FIRST NEEDLE) Tj + \\ET + ; + const second_stream = + \\0 1 0 rg + \\0 0 72 48 re + \\f + \\0 0 0 rg + \\BT + \\/F1 10 Tf + \\4 20 Td + \\(SECOND TARGET) Tj + \\ET + ; + + var out: std.ArrayList(u8) = .empty; + var offsets: [8]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"); + offsets[2] = out.items.len; + try out.appendSlice(arena, "2 0 obj\n<< /Type /Pages /Count 2 /Kids [3 0 R 5 0 R] >>\nendobj\n"); + offsets[3] = out.items.len; + try out.appendSlice(arena, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>\nendobj\n"); + offsets[4] = out.items.len; + try appendFmt(arena, &out, "4 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ first_stream.len, first_stream }); + offsets[5] = out.items.len; + try out.appendSlice(arena, "5 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 48] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>\nendobj\n"); + offsets[6] = out.items.len; + try appendFmt(arena, &out, "6 0 obj\n<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ second_stream.len, second_stream }); + offsets[7] = out.items.len; + try out.appendSlice(arena, "7 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); + + const xref = out.items.len; + try out.appendSlice(arena, "xref\n0 8\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 8 /Root 1 0 R >>\nstartxref\n{d}\n%%EOF\n", .{xref}); + try eh.writeFile(path, out.items); +} + fn writePpm(arena: std.mem.Allocator, path: [:0]const u8, width: usize, height: usize) !void { var out: std.ArrayList(u8) = .empty; try out.appendSlice(arena, try std.fmt.allocPrint(arena, "P6\n{d} {d}\n255\n", .{ width, height })); @@ -104,6 +175,148 @@ fn runKitty(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !vo std.debug.print("native image harness ok: kitty transmit + placement\n", .{}); } +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); + try writePdf(arena, pdf_path); + + var h = try eh.Harness.initArgs(gpa, exe, 16, 80, pdf_path); + defer h.deinit(); + try h.pump(150); + try h.expectRawContains("\x1b_Gi=1,a=q\x1b\\", "app did not issue the Kitty graphics capability query"); + + try h.send("\x1b_Gi=1;OK\x1b\\\x1b[?1;2c\x1b[A"); + const transmit = "\x1b_Gf=32,s=144,v=96,i="; + const first_deadline = eh.nowMs() + 5000; + while (eh.nowMs() < first_deadline) { + _ = try h.pumpOnce(50); + const sent = std.mem.indexOf(u8, h.raw.items, transmit) != null; + const placed = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=p,i=") != null; + if (sent and placed and h.contains("pdf 1/2 width PdfFit")) break; + } + try h.expectRawContains(transmit, "first PDF page was not transmitted as 144x96 RGBA"); + try h.expectRawContains("\x1b_Ga=p,i=", "first PDF page was not placed in the pane body"); + try h.expectContains("pdf 1/2 width PdfFit", "PDF pane did not expose its initial width fit"); + + // Fit-width is the initial PDF policy. It must use Kitty's explicit column + // scaling rather than Vaxis contain. The PDF shares this test session with + // the initial shell pane, making its body slightly narrower than the page: + // the full source is retained and vertically letterboxed by a pixel offset. + const width_apc = kittyPlacementApc(h.raw.items, 0) orelse return error.KittyPdfWidthPlacementMissing; + if (kittyParam(width_apc, 'x') != 0 or kittyParam(width_apc, 'y') != 0 or + kittyParam(width_apc, 'w') != 144) + { + std.debug.print("unexpected Kitty fit-width placement: {s}\n", .{width_apc}); + return error.KittyPdfWidthCropWrong; + } + const source_h = kittyParam(width_apc, 'h') orelse return error.KittyPdfWidthCropMissing; + const pixel_y = kittyParam(width_apc, 'Y') orelse return error.KittyPdfWidthLetterboxMissing; + if (source_h != 96 or pixel_y == 0 or kittyParam(width_apc, 'c') == null) { + std.debug.print("unexpected Kitty fit-width letterbox: {s}\n", .{width_apc}); + return error.KittyPdfWidthLetterboxWrong; + } + + // Placement-only state reuses the terminal image. Middle-click the + // visible PdfFit word in the PDF tag, wait for a fit-height placement + // with explicit rows and a full source image, and prove that no second + // transmit happened. + const transmit_before_fit = std.mem.count(u8, h.raw.items, transmit); + const fit_raw_start = h.raw.items.len; + const tag = try h.screenText(); + defer gpa.free(tag); + const fit_at = std.mem.indexOf(u8, tag, "PdfFit") orelse + return error.PdfFitMissing; + const line_at = std.mem.lastIndexOfScalar(u8, tag[0..fit_at], '\n'); + const mouse_col = fit_at - if (line_at) |at| at + 1 else 0; + const mouse_row = std.mem.count(u8, tag[0..fit_at], "\n"); + const click = try std.fmt.allocPrint( + arena, + "\x1b[<1;{d};{d}M\x1b[<1;{d};{d}m", + .{ mouse_col + 1, mouse_row + 1, mouse_col + 1, mouse_row + 1 }, + ); + try h.send(click); + const fit_deadline = eh.nowMs() + 3000; + while (eh.nowMs() < fit_deadline) { + _ = try h.pumpOnce(50); + if (kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') != null) break; + } + try h.expectContains("pdf 1/2 height PdfFit", "PdfFit did not expose the active height fit"); + const height_apc = kittyPlacementApcWithParam(h.raw.items, fit_raw_start, 'r') orelse + return error.KittyPdfHeightPlacementMissing; + const height_crop_w = kittyParam(height_apc, 'w') orelse return error.KittyPdfHeightPlacementWrong; + if (kittyParam(height_apc, 'x') != 0 or kittyParam(height_apc, 'y') != 0 or + height_crop_w == 0 or height_crop_w >= 144 or kittyParam(height_apc, 'h') != 96 or + kittyParam(height_apc, 'r') == null) + return error.KittyPdfHeightPlacementWrong; + if (std.mem.count(u8, h.raw.items, transmit) != transmit_before_fit) + return error.KittyPdfFitRetransmitted; + + // Same pane, different pixels: page navigation must delete the cached + // Kitty image and transmit the new revision, not keep showing page one. + try h.send("j"); + const second_deadline = eh.nowMs() + 5000; + while (eh.nowMs() < second_deadline) { + _ = try h.pumpOnce(50); + const deleted = std.mem.indexOf(u8, h.raw.items, "\x1b_Ga=d,d=I,i=") != null; + if (deleted and std.mem.count(u8, h.raw.items, transmit) >= 2 and h.contains("pdf 2/2 height PdfFit")) break; + } + try h.expectRawContains("\x1b_Ga=d,d=I,i=", "page navigation did not release the old Kitty PDF image"); + if (std.mem.count(u8, h.raw.items, transmit) < 2) return error.KittyPdfRetransmitMissing; + try h.expectContains("pdf 2/2 height PdfFit", "j did not retain height fit on PDF page two"); + + // Exercise the semantic seam too: return to page one, search an exact + // occurrence across the document, then let n Look the page-two result. + // That Look must change the page and therefore produce another pixel + // generation. + try h.send("k"); + try h.expectWaitFor("pdf 1/2 height PdfFit", 5000, "k did not return to PDF page one at height fit"); + 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 before_look = std.mem.count(u8, h.raw.items, transmit); + try h.send("n"); + const look_deadline = eh.nowMs() + 5000; + while (eh.nowMs() < look_deadline) { + _ = try h.pumpOnce(50); + if (std.mem.count(u8, h.raw.items, transmit) > before_look and h.contains("pdf 2/2 height PdfFit")) break; + } + try h.expectContains("pdf 2/2 height PdfFit", "n did not Look the PDF search result at page two"); + if (std.mem.count(u8, h.raw.items, transmit) <= before_look) return error.KittyPdfLookRetransmitMissing; + std.debug.print("PDF harness ok: Kitty fit crop/cache + page revision + search Look\n", .{}); +} + +fn kittyPlacementApc(raw: []const u8, start: usize) ?[]const u8 { + if (start > raw.len) return null; + const relative = std.mem.indexOf(u8, raw[start..], "\x1b_Ga=p,i=") orelse return null; + const begin = start + relative; + const end = std.mem.indexOf(u8, raw[begin..], "\x1b\\") orelse return null; + return raw[begin .. begin + end + 2]; +} + +fn kittyPlacementApcWithParam(raw: []const u8, start: usize, key: u8) ?[]const u8 { + var at = start; + while (kittyPlacementApc(raw, at)) |apc| { + if (kittyParam(apc, key) != null) return apc; + const next = @intFromPtr(apc.ptr) - @intFromPtr(raw.ptr) + apc.len; + if (next <= at or next > raw.len) return null; + at = next; + } + return null; +} + +fn kittyParam(apc: []const u8, key: u8) ?u32 { + var i: usize = 0; + while (i + 3 <= apc.len) : (i += 1) { + if (apc[i] != ',' or apc[i + 1] != key or apc[i + 2] != '=') continue; + const begin = i + 3; + var end = begin; + while (end < apc.len and std.ascii.isDigit(apc[end])) : (end += 1) {} + if (end == begin) return null; + return std.fmt.parseUnsigned(u32, apc[begin..end], 10) catch null; + } + return null; +} + fn runGui(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { const image_path = try std.fmt.allocPrintSentinel(arena, "{s}/native.ppm", .{base}, 0); const capture_path = try std.fmt.allocPrintSentinel(arena, "{s}/latest.ppm", .{base}, 0); @@ -139,6 +352,120 @@ fn runGui(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void std.debug.print("native image harness ok: SDL GPU capture ({d} source pixels)\n", .{exact}); } +fn runGuiPdf(arena: std.mem.Allocator, exe: [:0]const u8, base: [:0]const u8) !void { + const pdf_path = try std.fmt.allocPrintSentinel(arena, "{s}/pages.pdf", .{base}, 0); + const capture_path = try std.fmt.allocPrintSentinel(arena, "{s}/latest.ppm", .{base}, 0); + try writePdf(arena, pdf_path); + _ = libc.unlink(capture_path); + _ = setenv("PARDES_TEST", "1", 1); + _ = setenv("PARDES_TEST_CAPTURE_DIR", base, 1); + _ = setenv("PARDES_TEST_COLS", "20", 1); + _ = setenv("PARDES_TEST_ROWS", "10", 1); + + var h = try eh.Harness.initArgs(gpa, exe, 10, 20, pdf_path); + defer h.deinit(); + const fit_width = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); + + // The narrow PDF pane makes fit-width vertically letterboxed. Fit-height + // keeps roughly the same colored width but fills/crops the full body + // height. These are GPU readback extents, so this exercises SDL's UV crop + // and destination rectangle rather than merely observing the page color. + _ = libc.unlink(capture_path); + try h.send(" tz"); + const fit_height = try waitForCaptureBounds(&h, capture_path, pdf_red, 500, 5000); + const width_delta = if (fit_width.width() > fit_height.width()) + fit_width.width() - fit_height.width() + else + fit_height.width() - fit_width.width(); + if (fit_height.height() < fit_width.height() * 2 or width_delta > 8 or + fit_height.min_y >= fit_width.min_y) + { + std.debug.print("SDL PDF fit extents did not change as expected: width={any}, height={any}\n", .{ fit_width, fit_height }); + return error.SdlPdfFitGeometryWrong; + } + + // Remove the old capture before the key so merely rereading page one's + // file cannot satisfy page two. The app replaces this path atomically. + _ = libc.unlink(capture_path); + try h.send("j"); + const green = try waitForCaptureBounds(&h, capture_path, pdf_green, 500, 5000); + std.debug.print("PDF harness ok: SDL fit geometry + red->green ({d}->{d} pixels)\n", .{ fit_width.count, green.count }); +} + +const ColorBounds = struct { + count: usize, + min_x: usize, + min_y: usize, + max_x: usize, + max_y: usize, + + fn width(bounds: ColorBounds) usize { + return bounds.max_x - bounds.min_x + 1; + } + + fn height(bounds: ColorBounds) usize { + return bounds.max_y - bounds.min_y + 1; + } +}; + +fn waitForCaptureBounds( + h: *eh.Harness, + path: [:0]const u8, + color: [3]u8, + minimum: usize, + timeout_ms: i64, +) !ColorBounds { + const deadline = eh.nowMs() + timeout_ms; + var best: usize = 0; + while (eh.nowMs() < deadline) { + if (readFile(gpa, path)) |capture| { + defer gpa.free(capture); + if (ppmView(capture)) |ppm| { + if (colorBounds(ppm, color)) |bounds| { + best = @max(best, bounds.count); + if (bounds.count >= minimum) return bounds; + } + } + } else |_| {} + _ = try h.pumpOnce(50); + } + std.debug.print("SDL PDF harness found only {d} pixels of color {any}\n", .{ best, color }); + return error.PdfPageColorMissing; +} + +const PpmView = struct { width: usize, height: usize, pixels: []const u8 }; + +fn colorBounds(ppm: PpmView, color: [3]u8) ?ColorBounds { + var bounds = ColorBounds{ + .count = 0, + .min_x = std.math.maxInt(usize), + .min_y = std.math.maxInt(usize), + .max_x = 0, + .max_y = 0, + }; + var pixel: usize = 0; + while (pixel < ppm.width * ppm.height) : (pixel += 1) { + const i = pixel * 3; + if (!std.mem.eql(u8, ppm.pixels[i .. i + 3], &color)) continue; + const x = pixel % ppm.width; + const y = pixel / ppm.width; + bounds.count += 1; + bounds.min_x = @min(bounds.min_x, x); + bounds.min_y = @min(bounds.min_y, y); + bounds.max_x = @max(bounds.max_x, x); + bounds.max_y = @max(bounds.max_y, y); + } + return if (bounds.count == 0) null else bounds; +} + +fn countColor(pixels: []const u8, color: [3]u8) usize { + var count: usize = 0; + var i: usize = 0; + while (i + 2 < pixels.len) : (i += 3) + count += @intFromBool(std.mem.eql(u8, pixels[i .. i + 3], &color)); + return count; +} + fn readFile(allocator: std.mem.Allocator, path: [:0]const u8) ![]u8 { const fd = libc.open(path, .{ .ACCMODE = .RDONLY }, @as(libc.mode_t, 0)); if (fd < 0) return error.OpenFailed; @@ -156,11 +483,20 @@ fn readFile(allocator: std.mem.Allocator, path: [:0]const u8) ![]u8 { } fn ppmPixels(bytes: []const u8) ?[]const u8 { + return (ppmView(bytes) orelse return null).pixels; +} + +fn ppmView(bytes: []const u8) ?PpmView { if (!std.mem.startsWith(u8, bytes, "P6\n")) return null; - var newlines: usize = 0; - for (bytes, 0..) |byte, i| if (byte == '\n') { - newlines += 1; - if (newlines == 3) return bytes[i + 1 ..]; - }; - return null; + const dimensions_end = std.mem.indexOfScalarPos(u8, bytes, 3, '\n') orelse return null; + var dimensions = std.mem.splitScalar(u8, bytes[3..dimensions_end], ' '); + const width = std.fmt.parseUnsigned(usize, dimensions.next() orelse return null, 10) catch return null; + const height = std.fmt.parseUnsigned(usize, dimensions.next() orelse return null, 10) catch return null; + if (dimensions.next() != null) return null; + const max_end = std.mem.indexOfScalarPos(u8, bytes, dimensions_end + 1, '\n') orelse return null; + if (!std.mem.eql(u8, bytes[dimensions_end + 1 .. max_end], "255")) return null; + const expected = std.math.mul(usize, width, height) catch return null; + const byte_len = std.math.mul(usize, expected, 3) catch return null; + if (bytes.len -| (max_end + 1) < byte_len) return null; + return .{ .width = width, .height = height, .pixels = bytes[max_end + 1 ..][0..byte_len] }; } diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden index 0a8f98da..2b8cf69a 100644 --- a/test/snapshots/builtins.golden +++ b/test/snapshots/builtins.golden @@ -153,7 +153,6 @@ | | | /tmp/pardes-snap/builtins/cwd/+Help Del -| 19 SPC l S WsSymbols | 20 SPC l a CodeAction | 21 SPC l d Diagnostics | 22 SPC l h SelectRefs @@ -172,15 +171,16 @@ | 35 SPC t p Petscii | 36 SPC t r Crt | 37 SPC t t ThemeSel -| 38 SPC w h Left C-w h, C-w left -| 39 SPC w j Down C-w j, C-w down -| 40 SPC w k Up C-w k, C-w up -| 41 SPC w l Right C-w l, C-w right -| 42 SPC w t Toggleterm -| 43 Look enter, right-click -| 44 Exec tab, middle-click -| 45 Theme -| 46 +| 38 SPC t z PdfFit +| 39 SPC w h Left C-w h, C-w left +| 40 SPC w j Down C-w j, C-w down +| 41 SPC w k Up C-w k, C-w up +| 42 SPC w l Right C-w l, C-w right +| 43 SPC w t Toggleterm +| 44 Look enter, right-click +| 45 Exec tab, middle-click +| 46 Theme +| 47 == snap not-a-picker grid=120x60 cursor=7,58 |Kill Newcol Tutor Debug NextColor Dump Find Grep Help | /tmp/pardes-snap/builtins/cwd/notes.txt Save Del /tmp/pardes-snap/builtins/cwd Del @@ -214,7 +214,6 @@ | | | /tmp/pardes-snap/builtins/cwd/+Help Del -| 19 SPC l S WsSymbols | 20 SPC l a CodeAction | 21 SPC l d Diagnostics | 22 SPC l h SelectRefs @@ -233,15 +232,16 @@ | 35 SPC t p Petscii | 36 SPC t r Crt | 37 SPC t t ThemeSel -| 38 SPC w h Left C-w h, C-w left -| 39 SPC w j Down C-w j, C-w down -| 40 SPC w k Up C-w k, C-w up -| 41 SPC w l Right C-w l, C-w right -| 42 SPC w t Toggleterm -| 43 Look enter, right-click -| 44 Exec tab, middle-click -| 45 Theme -| 46 +| 38 SPC t z PdfFit +| 39 SPC w h Left C-w h, C-w left +| 40 SPC w j Down C-w j, C-w down +| 41 SPC w k Up C-w k, C-w up +| 42 SPC w l Right C-w l, C-w right +| 43 SPC w t Toggleterm +| 44 Look enter, right-click +| 45 Exec tab, middle-click +| 46 Theme +| 47 == snap window-group grid=120x60 cursor=7,32 |Kill Newcol Tutor Debug NextColor Dump Find Grep Help | /tmp/pardes-snap/builtins/cwd/notes.txt Save Del /tmp/pardes-snap/builtins/cwd Del diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden index 29c2ccf1..8b7a76a3 100644 --- a/test/snapshots/leader.golden +++ b/test/snapshots/leader.golden @@ -400,8 +400,8 @@ | 8 SPC t p Petscii | 9 SPC t r Crt | 10 SPC t t ThemeSel -| 11 -| +| 11 SPC t z PdfFit +| 12 | | | diff --git a/test/snapshots/lspdebug.golden b/test/snapshots/lspdebug.golden index af208cf8..6a3caef3 100644 --- a/test/snapshots/lspdebug.golden +++ b/test/snapshots/lspdebug.golden @@ -203,16 +203,16 @@ | 3 zig lib dir: /usr/lib/zig [OK] | 4 offsets: utf-8 walk caps: 512 files, 2000 rows | 5 -| 6 dependency imports gd can follow (7): +| 6 dependency imports gd can follow (8): | 7 zls /home/goblin/00-projects/0x4200.cafe | 8 tree-sitter /home/goblin/00-projects/0x4200.cafe | 9 zstbi /home/goblin/00-projects/0x4200.cafe | 10 mvzr /home/goblin/00-projects/0x4200.cafe -| 11 ghostty-vt /home/goblin/00-projects/0x4200.cafe -| 12 vaxis /home/goblin/00-projects/0x4200.cafe -| 13 uucode /home/goblin/00-projects/0x4200.cafe -| 14 asked from: /tmp/pardes-snap/lspdebug/cwd/z.zig -| 15 +| 11 mupdf /home/goblin/00-projects/0x4200.cafe +| 12 ghostty-vt /home/goblin/00-projects/0x4200.cafe +| 13 vaxis /home/goblin/00-projects/0x4200.cafe +| 14 uucode /home/goblin/00-projects/0x4200.cafe +| 15 asked from: /tmp/pardes-snap/lspdebug/cwd/z.zig | /tmp/pardes-snap/lspdebug/cwd/+Lsp Del | 1 lsp explain — the definition query at byte 29 of z.zi | 2 |
