From 6d7b506c8a380e4dd97f337590ca079d810a360a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 13:46:50 -0300 Subject: Follow embedded PDF links through Look --- docs/design.typ | 9 ++ docs/detached.md | 4 +- src/config.zig | 1 + src/detached/client.zig | 3 + src/detached/server.zig | 3 +- src/detached/wire.zig | 46 +++++++++- src/gui/gui.zig | 17 +++- src/macos.zig | 6 ++ src/macos/Sources/PardesView.swift | 12 ++- src/macos/pardes.h | 2 + src/panes.zig | 29 +++++- src/pardes.zig | 148 +++++++++++++++++++++++++----- src/pdf.zig | 179 +++++++++++++++++++++++++++++++++++++ src/pdf_bridge.c | 121 +++++++++++++++++++++++++ src/pdf_bridge.h | 30 ++++++- src/web.zig | 5 ++ src/web/app.mjs | 1 + test/pdf.zig | 161 +++++++++++++++++++++++++++++++++ test/web_harness.mjs | 9 +- 19 files changed, 753 insertions(+), 33 deletions(-) diff --git a/docs/design.typ b/docs/design.typ index 88bb49d6..f6e2e240 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -1954,6 +1954,15 @@ palettes, ascii glyph set toggle). PDF (`-Dmupdf`, native default on): MuPDF rendering as one continuous page strip, real text search, mouse text selection, the document outline into `+PdfSections`, fit-width/fit-height and a themed duotone tint — the last three named in the pane's own live tag. +Embedded PDF links use the ordinary right-click Look gesture. Hovering one +shows an accent highlight and a pointing hand in graphical frontends; dragging +still selects text. Internal links reveal their page and anchor. When the +annotation's visible label resolves to a different Look location, a `+Links` +output lists the visible destination and the embedded destination for you to +choose with Look. Internal destinations in that list use `document.pdf:page`. +Labels that are not locations follow the embedded link directly; identical +destinations open once. Links use the same supported URL and file-location +rules as Look. Tutor: embedded text as file pane. *Language.* The native host snapshots each request and runs it outside the UI diff --git a/docs/detached.md b/docs/detached.md index 9b9ea4ef..4cb3d9d6 100644 --- a/docs/detached.md +++ b/docs/detached.md @@ -49,7 +49,9 @@ write session files or install file watches. `src/detached/wire.zig` owns the versioned frontend protocol. Frames are full grids or changes relative to each frontend's last queued frame. A new attachment receives a full grid. Output queues and per-poll work are bounded; -a lagging frontend cannot hold the session's event loop. +a lagging frontend cannot hold the session's event loop. Protocol version 3 +also carries the pointer shape, including updates when no grid cells change, +so PDF link hover uses the same pointing-hand cue in attached graphical clients. `zig build unit-test` covers encoding, session ownership, real frontend connections, worker completion and Restore. `zig build fs-test` drives diff --git a/src/config.zig b/src/config.zig index fea22609..6e88475d 100644 --- a/src/config.zig +++ b/src/config.zig @@ -386,6 +386,7 @@ pub const jumps_buffer = "+Jumps"; pub const themes_buffer = "+Themes"; pub const fonts_buffer = "+Fonts"; pub const pdf_sections_buffer = "+PdfSections"; +pub const pdf_links_buffer = "+Links"; pub const hover_buffer = "+Hover"; pub const lsp_buffer = "+Lsp"; pub const changelog_buffer = "+Changelog"; diff --git a/src/detached/client.zig b/src/detached/client.zig index 0350ecf7..affa070e 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -101,6 +101,7 @@ pub const Client = struct { /// `cols * rows` cells: what the session is showing right now. grid: std.ArrayListUnmanaged(pardes.Cell) = .empty, cursor: ?wire.Cursor = null, + pointer_shape: pardes.Surface.PointerShape = .arrow, in: std.ArrayListUnmanaged(u8) = .empty, out: std.ArrayListUnmanaged(u8) = .empty, /// Bytes of `in` belonging to the message `next` returned last. Compacted at @@ -311,6 +312,7 @@ pub const Client = struct { } try f.apply(c.grid.items); c.cursor = f.cursor; + c.pointer_shape = f.pointer_shape; }, // The session has ended. Left for the caller to act on, and the // descriptor stays open so `deinit` is the only place that closes. @@ -351,6 +353,7 @@ pub const Client = struct { c.cols = cols; c.rows = rows; c.cursor = null; + c.pointer_shape = .arrow; } /// Take everything the kernel is holding, not one chunk of it. The server diff --git a/src/detached/server.zig b/src/detached/server.zig index 9a47a562..9d83d3eb 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -717,11 +717,12 @@ pub const Session = struct { .{ .x = cur.x, .y = cur.y, .bar = cur.bar } else null; - const bytes = wire.encodeFrame( + const bytes = wire.encodeFramePointer( s.scratch.allocatedSlice()[0..want], surface.cols, surface.rows, cursor, + surface.pointer_shape, cells, prev, ) catch |err| { diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 989991e0..5087cfcd 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -3,7 +3,7 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); -pub const version: u16 = 2; +pub const version: u16 = 3; pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace }; @@ -91,6 +91,7 @@ const KindTag = enum(u8) { press = 0x00, release = 0x01, motion = 0x02, drag = 0 /// of semantics and one code path, and it is what makes a full frame of a /// mostly-empty grid cheap. pub const FrameKind = enum(u8) { full = 0x01, diff = 0x02 }; +const FrameHeader = enum(u8) { full = 0x01, diff = 0x02, full_link = 0x03, diff_link = 0x04 }; /// Bit per `CellStyle` bool, packed into one byte. Bit 7 is unassigned and a /// set bit 7 is a decode error: it is a byte this protocol cannot mean. @@ -153,6 +154,7 @@ pub const Cursor = struct { x: u16, y: u16, bar: bool }; /// union is passed by value, and the receiver already owns the grid the runs /// belong in. `apply` is the bounds-checked walk. pub const Frame = struct { + pointer_shape: pardes.Surface.PointerShape = .arrow, kind: FrameKind, cols: u16, rows: u16, @@ -586,6 +588,18 @@ pub fn encodeFrame( cursor: ?Cursor, cells: []const pardes.Cell, prev: []const pardes.Cell, +) Error![]const u8 { + return encodeFramePointer(out, cols, rows, cursor, .arrow, cells, prev); +} + +pub fn encodeFramePointer( + out: []u8, + cols: u16, + rows: u16, + cursor: ?Cursor, + pointer_shape: pardes.Surface.PointerShape, + cells: []const pardes.Cell, + prev: []const pardes.Cell, ) Error![]const u8 { // The protocol's ceiling, enforced by the ENCODER too, and BEFORE the // assert below so a caller can be told rather than tripped. `max_payload` @@ -597,7 +611,11 @@ pub fn encodeFrame( const full = prev.len != cells.len; var w: Writer = .init(out); const at = try beginMessage(&w, @intFromEnum(ServerTag.frame)); - try w.putByte(@intFromEnum(@as(FrameKind, if (full) .full else .diff))); + const header: FrameHeader = if (pointer_shape == .link) + (if (full) .full_link else .diff_link) + else + (if (full) .full else .diff); + try w.putByte(@intFromEnum(header)); try w.putU16(cols); try w.putU16(rows); try putCursor(&w, cursor, cols, rows); @@ -871,7 +889,11 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg { } }, .refuse => .{ .refuse = try r.getTag(Refusal) }, .frame => blk: { - const kind = try r.getTag(FrameKind); + const header = try r.getTag(FrameHeader); + const kind: FrameKind = switch (header) { + .full, .full_link => .full, + .diff, .diff_link => .diff, + }; const cols = try r.getCols(); const rows = try r.getRows(); const cursor = try getCursor(&r, cols, rows); @@ -885,6 +907,7 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg { r.i = r.bytes.len; break :blk .{ .frame = .{ .kind = kind, + .pointer_shape = if (header == .full_link or header == .diff_link) .link else .arrow, .cols = cols, .rows = rows, .cursor = cursor, @@ -1347,7 +1370,7 @@ test "detached wire: an unknown tag is refused, never guessed" { try testing.expectError(error.BadTag, decodeServer(@intFromEnum(ServerTag.refuse), &.{0x7f})); try testing.expectError(error.BadTag, decodeClient( @intFromEnum(ClientTag.mouse), - &.{ 0x09, 0x00, 0, 0, 0, 0, 0 }, + &.{ 0xff, 0x00, 0, 0, 0, 0, 0 }, )); } @@ -1436,6 +1459,21 @@ test "detached wire: a frame that lies about its runs cannot walk out of the gri }).apply(&small)); } +test "detached wire: link pointer survives full and unchanged grid frames" { + const cells = [_]pardes.Cell{.{}}; + var buffer: [256]u8 = undefined; + for ([_]pardes.Surface.PointerShape{ .link, .arrow, .link }) |shape| { + for ([_]bool{ true, false }) |full| { + const bytes = try encodeFramePointer(&buffer, 1, 1, null, shape, &cells, if (full) &.{} else &cells); + const packet = (try framed(bytes)).?; + const frame = (try decodeServer(packet.tag, packet.payload)).frame; + try std.testing.expectEqual(shape, frame.pointer_shape); + try std.testing.expectEqual(if (full) FrameKind.full else FrameKind.diff, frame.kind); + if (!full) try std.testing.expectEqual(@as(u32, 0), frame.nruns); + } + } +} + test "detached wire: a cursor outside the grid is refused, not painted" { // The one field of a frame a frontend indexes with rather than copies: // tty.zig moves the terminal's own cursor to `cursor.x`/`cursor.y`, and diff --git a/src/gui/gui.zig b/src/gui/gui.zig index a0f0a3e4..f18f527b 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1796,6 +1796,9 @@ const Gui = struct { scene_target_failed: bool = false, presented_scene: crt.Frame = .{}, pointer_present: bool = false, + arrow_cursor: ?*c.SDL_Cursor = null, + link_cursor: ?*c.SDL_Cursor = null, + pointer_shape: pardes.Surface.PointerShape = .arrow, pointer_mapped: bool = false, pointer_cell: ?MouseCell = null, @@ -2113,7 +2116,11 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u return error.SdlInit; }; if (native_wayland) log.info("Wayland opacity: background-only transparent surface", .{}); - if (c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1)) |cur| { + const arrow_cursor = c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1); + defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor); + const link_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER); + defer if (link_cursor) |cursor| c.SDL_DestroyCursor(cursor); + if (arrow_cursor) |cur| { _ = c.SDL_SetCursor(cur); } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse { @@ -2238,6 +2245,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u var g: Gui = .{ .window = window, + .arrow_cursor = arrow_cursor, + .link_cursor = link_cursor, .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, @@ -2571,6 +2580,7 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien .rows = client.rows, .cells = client.grid.items, .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, + .pointer_shape = client.pointer_shape, }; const chrome = frameChromeBg(&surface); _ = renderFrame( @@ -4742,6 +4752,11 @@ fn renderFrame( scene_effects: pardes.layout.SceneEffect, debug_on: bool, ) !bool { + if (surface.pointer_shape != g.pointer_shape) { + g.pointer_shape = surface.pointer_shape; + const cursor = if (g.pointer_shape == .link) g.link_cursor else g.arrow_cursor orelse c.SDL_GetDefaultCursor(); + if (cursor) |value| _ = c.SDL_SetCursor(value); + } g.scene_target_failed = false; const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return false; var command_consumed = false; diff --git a/src/macos.zig b/src/macos.zig index 5c523acd..7b71d9d5 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1827,6 +1827,11 @@ export fn pardes_cursor_x() i32 { return if (st.core.surface.cursor) |c| c.x else -1; } +export fn pardes_pointer_shape() u32 { + const st = &(state orelse return 0); + return @intFromEnum(st.core.surface.pointer_shape); +} + export fn pardes_cursor_y() i32 { const st = &(state orelse return -1); return if (st.core.surface.cursor) |c| c.y else -1; @@ -2281,6 +2286,7 @@ test "pardes.h declares every export the way it is defined" { try expectSameAbi(@TypeOf(c.pardes_cursor_x), @TypeOf(pardes_cursor_x)); try expectSameAbi(@TypeOf(c.pardes_cursor_y), @TypeOf(pardes_cursor_y)); try expectSameAbi(@TypeOf(c.pardes_cursor_bar), @TypeOf(pardes_cursor_bar)); + try expectSameAbi(@TypeOf(c.pardes_pointer_shape), @TypeOf(pardes_pointer_shape)); try expectSameAbi(@TypeOf(c.pardes_take_haptic), @TypeOf(pardes_take_haptic)); try expectSameAbi(@TypeOf(c.pardes_font_take), @TypeOf(pardes_font_take)); try expectSameAbi(@TypeOf(c.pardes_font_observe), @TypeOf(pardes_font_observe)); diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index bfc8a060..2160047d 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -809,6 +809,14 @@ final class PardesView: NSView { // whatever we do, so clipping would save fills and nothing else. Narrow // the row loops to the dirty band if that ever shows up in a profile. let count = pardes_frame() + let nextLinkPointer = pardes_pointer_shape() == 1 + if nextLinkPointer != linkPointer { + linkPointer = nextLinkPointer + window?.invalidateCursorRects(for: self) + if let window, bounds.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil)) { + (linkPointer ? NSCursor.pointingHand : NSCursor.iBeam).set() + } + } let scene = pardes_scene() let trackCount = Int(pardes_frame_panel_tracks()) let tracks: UnsafeBufferPointer @@ -1967,10 +1975,12 @@ final class PardesView: NSView { userInfo: nil)) } + private var linkPointer = false + override func resetCursorRects() { // Every cell in this view is text, including the tags. An arrow over a // grid you can sweep and click words in is the wrong affordance. - addCursorRect(bounds, cursor: .iBeam) + addCursorRect(bounds, cursor: linkPointer ? .pointingHand : .iBeam) } override func setFrameSize(_ newSize: NSSize) { diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 546273d9..f94e37eb 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -466,6 +466,8 @@ bool pardes_active_dirty(void); int32_t pardes_cursor_x(void); int32_t pardes_cursor_y(void); bool pardes_cursor_bar(void); +// Mouse pointer affordance: 0 = default text pointer, 1 = link hand. +uint32_t pardes_pointer_shape(void); // The Look or Exec the core performed since this was last asked, and clears // it. Call it once per pump, after pardes_tick — the input functions run the diff --git a/src/panes.zig b/src/panes.zig index ea733ea1..a66d8e21 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -2129,6 +2129,7 @@ pub const Output = struct { query: lsp.Kind, search, errors, + pdf_links, }; pub const max_arg = dump.max_origin_arg; @@ -2237,6 +2238,7 @@ pub const Output = struct { // rows are `location text`, so n/N walk them .search => .{ .name = config.search_buffer, .steps = true }, .errors => .{ .name = config.errors_buffer, .doc = true }, + .pdf_links => .{ .name = config.pdf_links_buffer, .steps = true }, .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable, .query => |k| switch (k) { .hover => .{ .name = config.hover_buffer }, @@ -2285,6 +2287,7 @@ pub const Output = struct { .query => |k| @tagName(k), .search => "/", .errors => config.errors_buffer, + .pdf_links => config.pdf_links_buffer, }; } @@ -2292,6 +2295,7 @@ pub const Output = struct { if (w.len == 0) return null; if (std.mem.eql(u8, w, "/")) return .search; if (std.mem.eql(u8, w, config.errors_buffer)) return .errors; + if (std.mem.eql(u8, w, config.pdf_links_buffer)) return .pdf_links; if (std.meta.stringToEnum(Builtin, w)) |b| if (builtins.registry.outputTraits(b) != null) return .{ .cmd = b }; if (std.meta.stringToEnum(lsp.Kind, w)) |k| return .{ .query = k }; @@ -2354,7 +2358,7 @@ pub const Output = struct { => {}, else => return anchor, }, - .errors => return anchor, + .errors, .pdf_links => return anchor, } if (content.len == 0) return anchor; const path_only = std.meta.eql(from, Origin{ .cmd = .Find }); @@ -2477,6 +2481,7 @@ pub const Output = struct { } } const format_locations = traits(from).steps and !traits(from).commands and + from != .pdf_links and !std.meta.eql(from, Origin{ .cmd = .Find }) and !std.meta.eql(from, Origin{ .query = .completion }); if (format_locations) { @@ -4941,8 +4946,10 @@ pub const Pdf = struct { page: usize, quads: []pdf.Quad, text: []u8, + link: ?pdf.Link = null, pub fn deinit(probe: *@This(), gpa: std.mem.Allocator) void { + if (probe.link) |*link| link.deinit(gpa); gpa.free(probe.quads); gpa.free(probe.text); probe.* = undefined; @@ -5427,6 +5434,23 @@ pub const Pdf = struct { const page = nativePageAtGridRow(core, pane, row) orelse return null; const point = panePointAtPage(core, pane, page, col, row, false) orelse return null; const state = &(pane.pdf orelse return null); + if (state.document.linkAt(core.pdf_gpa, page, point) catch null) |found| { + var link = found; + if (core.pdfLinkLocation(pane, link) != null) { + const quads = core.pdf_gpa.alloc(pdf.Quad, 1) catch { + link.deinit(core.pdf_gpa); + return null; + }; + const text = state.document.copyRectangle(core.pdf_gpa, page, link.quad) catch { + core.pdf_gpa.free(quads); + link.deinit(core.pdf_gpa); + return null; + }; + quads[0] = link.quad; + return .{ .page = page, .quads = quads, .text = text, .link = link }; + } + link.deinit(core.pdf_gpa); + } return probeWord(&state.document, core.pdf_gpa, page, point) catch null; } @@ -5618,11 +5642,12 @@ pub const Pdf = struct { else &.{}; const hover_page = if (hover_quads.len > 0) core.pdf_hover_preview.?.probe.page else null; + const link_hover = hover_quads.len > 0 and core.pdf_hover_preview.?.probe.link != null; const selection_color = core.theme().sel_bg; return .{ .hover_quads = hover_quads, .hover_page = hover_page, - .hover_color = selection_color, + .hover_color = if (link_hover) core.theme().box else selection_color, .selection_color = selection_color, }; } diff --git a/src/pardes.zig b/src/pardes.zig index dd7fc5ed..6e88974d 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3492,6 +3492,8 @@ test "pixel attachment fit and pan do not invalidate cached pixels" { /// 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 { + pub const PointerShape = enum(u8) { arrow, link }; + pointer_shape: PointerShape = .arrow, cols: u16 = 0, rows: u16 = 0, cells: []Cell = &.{}, @@ -11434,6 +11436,10 @@ pub const Pardes = struct { s.chorded, ); defer release.deinit(p.pdf_gpa); + if (release.probe) |*probe| if (probe.link != null) { + p.followPdfLink(s.id, probe); + return; + }; // A native PDF page has no byte offsets of its own to // report, so a scripted pane cannot intercept this one. if (release.action) |action| p.dispatchPointerBuiltin( @@ -11805,38 +11811,34 @@ pub const Pardes = struct { pane.setRange(text, 0, r, true); } - fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool { + fn openPaneTarget(p: *const Pardes, path: []const u8, at: look.Spot) ?look.Target { for (p.panes, 0..) |slot, i| { const tt = slot orelse continue; if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) { - p.active = i; - return true; + return .{ .pane = .{ .id = i, .at = .{} } }; }; if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) { - p.focusPaneLine(i, at, .center); - return true; + return .{ .pane = .{ .id = i, .at = at } }; }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; - p.focusPaneLine(i, at, .center); - return true; + return .{ .pane = .{ .id = i, .at = at } }; } - return false; + return null; } - pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void { - const pane = p.panes[id] orelse return; - p.noteHaptic(.look); - p.noteLookSource(id); + fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool { + const target = p.openPaneTarget(path, at) orelse return false; + p.focusPaneLine(target.pane.id, target.pane.at, .center); + return true; + } + + /// Resolve without changing focus or falling back to search. PDF links use + /// the same location rules as an ordinary Look before offering a choice. + fn resolveLookTarget(p: *Pardes, id: usize, txt: []const u8, realbuf: *[4096]u8) look.Target { const trimmed = std.mem.trim(u8, txt, " \t\r\n"); - if (config.commandWord(trimmed)) |cmd| { - if (p.execute(id, cmd)) |dst| p.active = dst; - return; - } - var realbuf: [4096]u8 = undefined; const pl = look.parsePathLine(trimmed); - if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; - if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.at)) return; + if (pl.path.len > 0) if (p.openPaneTarget(pl.path, pl.at)) |target| return target; var found: look.Target = .none; var seen: [MAX_PANES][]const u8 = undefined; var nseen: usize = 0; @@ -11852,11 +11854,106 @@ pub const Pardes = struct { if (pl.path.len > 0 and pl.path[0] != '/') { var joinbuf: [2048]u8 = undefined; if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j| - if (p.focusPaneByPath(j, pl.at)) return; + if (p.openPaneTarget(j, pl.at)) |target| return target; } - found = look.resolve(p, txt, dir, &realbuf); + found = look.resolve(p, txt, dir, realbuf); if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; } + return found; + } + + fn locationText(p: *Pardes, path: []const u8, at: look.Spot) ?[]const u8 { + if (std.mem.indexOfAny(u8, path, "\r\n") != null) return null; + const arena = p.scratch.allocator(); + if (at.line == 0) return arena.dupe(u8, path) catch null; + if (at.end_line != 0) { + if (at.col == 0 and at.end_col == 0) + return std.fmt.allocPrint(arena, "{s}:{d}-{d}", .{ path, at.line, at.end_line }) catch null; + return std.fmt.allocPrint(arena, "{s}:{d}:{d}-{d}:{d}", .{ path, at.line, at.col, at.end_line, at.end_col }) catch null; + } + if (at.col != 0) return std.fmt.allocPrint(arena, "{s}:{d}:{d}", .{ path, at.line, at.col }) catch null; + return std.fmt.allocPrint(arena, "{s}:{d}", .{ path, at.line }) catch null; + } + + fn canonicalLookLocation(p: *Pardes, id: usize, text: []const u8) ?[]const u8 { + const trimmed = std.mem.trim(u8, text, " \t\r\n"); + if (trimmed.len == 0) return null; + var realbuf: [4096]u8 = undefined; + switch (p.resolveLookTarget(id, trimmed, &realbuf)) { + .none => return null, + .url => |url| { + if (url.len > 256) return null; + for (url) |c| if (c <= 0x20 or c == 0x7f) return null; + return p.scratch.allocator().dupe(u8, url) catch null; + }, + .file => |file| return p.locationText(file.path, file.at), + .image => |iv| return p.locationText(iv.path, .{}), + .dir => |dir| return p.locationText(dir, .{}), + .pane => |target| { + if (target.id >= p.panes.len) return null; + const pane = p.panes[target.id] orelse return null; + if (pane.file) |file| return p.locationText(file.path, target.at); + if (comptime pdf_enabled) if (pane.pdf) |doc| return p.locationText(doc.path, target.at); + if (pane.image) |iv| return p.locationText(iv.path, target.at); + var path: [32]u8 = undefined; + return p.locationText(std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{target.id}) catch return null, target.at); + }, + } + } + + pub fn pdfLinkLocation(p: *Pardes, pane: *const Pane, link: if (pdf_enabled) pdf.Link else void) ?[]const u8 { + if (comptime !pdf_enabled) return null; + const state = pane.pdf orelse return null; + const id = p.paneIdOf(pane) orelse return null; + return switch (link.destination) { + .none => null, + .internal => |dest| if (dest.page < state.document.pages) + p.locationText(state.path, .{ .line = dest.page + 1 }) + else + null, + .external => |uri| p.canonicalLookLocation(id, uri), + }; + } + + fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) void { + if (comptime !pdf_enabled) return; + const pane = p.panes[id] orelse return; + const link = probe.link orelse return; + const target = p.pdfLinkLocation(pane, link) orelse return; + if (p.canonicalLookLocation(id, probe.text)) |visible| { + if (!std.mem.eql(u8, visible, target)) { + const content = std.fmt.allocPrint(p.gpa, "{s}\n{s}\n", .{ visible, target }) catch |err| + return p.reportError(id, "PDF link choices", err); + panes.Output.fillResults(p, id, std.fs.path.dirname(pane.pdf.?.path) orelse ".", .pdf_links, "", content, null) catch |err| + return p.reportError(id, "PDF link choices", err); + return; + } + } + switch (link.destination) { + .internal => |dest| { + p.noteHaptic(.look); + p.noteLookSource(id); + p.active = id; + panes.Pdf.revealOutlineDestination(p, pane, dest); + }, + .external => p.lookAt(id, target), + .none => {}, + } + } + + pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void { + const pane = p.panes[id] orelse return; + p.noteHaptic(.look); + p.noteLookSource(id); + const trimmed = std.mem.trim(u8, txt, " \t\r\n"); + if (config.commandWord(trimmed)) |cmd| { + if (p.execute(id, cmd)) |dst| p.active = dst; + return; + } + const pl = look.parsePathLine(trimmed); + if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; + var realbuf: [4096]u8 = undefined; + const found = p.resolveLookTarget(id, txt, &realbuf); switch (found) { .none => { const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; @@ -12619,6 +12716,15 @@ pub const Pardes = struct { s.cols = p.screen_w; s.rows = p.screen_h; s.cursor = null; + s.pointer_shape = .arrow; + if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| { + if (preview.probe.link != null and preview.pane < p.panes.len) { + if (p.panes[preview.pane]) |pane| { + if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane)) + s.pointer_shape = .link; + } + } + }; s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; diff --git a/src/pdf.zig b/src/pdf.zig index a12be0b1..052d34db 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -654,6 +654,20 @@ pub const OutlineDestination = union(enum) { external: []const u8, }; +pub const Link = struct { + page: usize, + quad: Quad, + /// Owned independently of the document's current-page cache. External + /// destination slices point into this same allocation. + uri: []u8, + destination: OutlineDestination, + + pub fn deinit(link: *Link, gpa: std.mem.Allocator) void { + gpa.free(link.uri); + link.* = undefined; + } +}; + pub const OutlineEntry = struct { /// Zero for a root row; rows are in stable pre-order depth-first order. depth: u8, @@ -733,6 +747,54 @@ pub const Document = struct { return .{ .handle = handle, .pages = @intCast(page_count) }; } + /// Link annotations are loaded only for the pointed page, separately from + /// text/raster work. Returned URIs survive cache eviction and page changes. + pub fn linkAt(document: *Document, gpa: std.mem.Allocator, page: usize, point: Point) !?Link { + const page_number = try document.checkedPage(page); + if (!validPoint(point)) return null; + var raw: c.pardes_pdf_link = std.mem.zeroes(c.pardes_pdf_link); + if (c.pardes_pdf_link_at(document.handle, page_number, point, &raw) != c.PARDES_PDF_OK) + return error.LinkFailed; + if (raw.uri == null or raw.uri_len == 0) return null; + if (raw.uri_len > c.PARDES_PDF_MAX_LINK_URI_BYTES or !validQuad(raw.quad)) return error.BadLink; + const borrowed: [*]const u8 = @ptrCast(raw.uri); + const text = borrowed[0..raw.uri_len]; + if (!std.unicode.utf8ValidateSlice(text) or std.mem.trim(u8, text, " ").len == 0 or + std.ascii.eqlIgnoreCase(text, "file://")) return null; + for (text) |byte| if (byte < ' ') return null; + const uri = try gpa.dupe(u8, text); + errdefer gpa.free(uri); + const destination: OutlineDestination = switch (raw.destination_kind) { + c.PARDES_PDF_OUTLINE_DESTINATION_INTERNAL => blk: { + if (raw.page < 0 or @as(usize, @intCast(raw.page)) >= document.pages or + raw.has_x > 1 or raw.has_y > 1 or + (raw.has_x != 0 and !std.math.isFinite(raw.x)) or + (raw.has_y != 0 and !std.math.isFinite(raw.y))) return error.BadLink; + break :blk .{ .internal = .{ + .page = @intCast(raw.page), + .x = if (raw.has_x != 0) raw.x else null, + .y = if (raw.has_y != 0) raw.y else null, + } }; + }, + c.PARDES_PDF_OUTLINE_DESTINATION_EXTERNAL => .{ .external = uri }, + else => return error.BadLink, + }; + return .{ .page = page, .quad = raw.quad, .uri = uri, .destination = destination }; + } + + pub fn copyRectangle(document: *Document, gpa: std.mem.Allocator, page: usize, quad: Quad) ![]u8 { + const page_number = try document.checkedPage(page); + if (!validQuad(quad)) return error.InvalidPoint; + var text: c.pardes_pdf_owned_text = std.mem.zeroes(c.pardes_pdf_owned_text); + if (c.pardes_pdf_copy_rectangle(document.handle, page_number, quad, &text) != c.PARDES_PDF_OK) + return error.SelectionFailed; + defer c.pardes_pdf_drop_owned_text(document.handle, text.handle); + if (text.len == 0) return &.{}; + if (text.data == null) return error.BadText; + const source: [*]const u8 = @ptrCast(text.data); + return gpa.dupe(u8, source[0..text.len]); + } + /// Load and flatten the PDF-native outline/bookmarks. MuPDF's temporary /// tree and the bridge's flat view are both dropped before this returns. pub fn outline( @@ -1336,6 +1398,123 @@ pub fn makeNoOutlineTestPdf(gpa: std.mem.Allocator) ![]u8 { return makeOffsetRotatedPdf(gpa); } +fn emitLinksPdf(bytes: *PdfBytes, offsets: []usize, _: void) !void { + const stream = "BT /F1 6 Tf 20 240 Td (target.txt) Tj ET\n" ++ + "BT /F1 6 Tf 20 120 Td (https://example.com/same) Tj ET\n" ++ + "BT /F1 6 Tf 20 60 Td (label) Tj ET\n"; + try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"); + try beginPdfObject(bytes, offsets, 1); + try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 2); + try bytes.appendSlice("<< /Type /Pages /Count 2 /Kids [3 0 R 4 0 R] >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 3); + try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R /Annots [7 0 R 8 0 R 9 0 R 10 0 R] >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 4); + try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Resources << >> >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 5); + try bytes.appendSlice("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 6); + try bytes.print("<< /Length {d} >>\nstream\n{s}endstream\nendobj\n", .{ stream.len, stream }); + try beginPdfObject(bytes, offsets, 7); + try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 230 120 260] /Border [0 0 0] /A << /S /URI /URI (https://example.com/embedded) >> >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 8); + try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 170 120 200] /Border [0 0 0] /Dest [4 0 R /XYZ 12 34 null] >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 9); + try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 110 190 140] /Border [0 0 0] /A << /S /URI /URI (https://example.com/same) >> >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 10); + try bytes.appendSlice("<< /Type /Annot /Subtype /Link /Rect [20 50 120 80] /Border [0 0 0] /A << /S /URI /URI (https://example.com/only) >> >>\nendobj\n"); + try finishGeneratedPdf(bytes, offsets); +} + +/// TEST-ONLY: external, blank internal, identical-label URI, and link-only URI. +pub fn makeLinksTestPdf(gpa: std.mem.Allocator) ![]u8 { + var offsets: [11]usize = undefined; + return allocateGeneratedPdf(gpa, &offsets, {}, emitLinksPdf); +} + +test "PDF links own URIs across page eviction and preserve internal coordinates" { + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + const fixture = try makeLinksTestPdf(gpa); + defer gpa.free(fixture); + var document = try Document.openBytes(fixture); + defer document.deinit(); + var external = (try document.linkAt(gpa, 0, .{ .x = 0.3, .y = 0.18 })) orelse return error.NoLink; + defer external.deinit(gpa); + try std.testing.expectEqualStrings("https://example.com/embedded", external.uri); + try std.testing.expectEqualStrings(external.uri, external.destination.external); + try std.testing.expectApproxEqAbs(@as(f32, 0.1), external.quad.ul.x, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 40.0 / 300.0), external.quad.ul.y, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 0.6), external.quad.lr.x, 0.0001); + const label = try document.copyRectangle(gpa, 0, external.quad); + defer gpa.free(label); + try std.testing.expectEqualStrings("target.txt", std.mem.trim(u8, label, "\r\n ")); + var internal = (try document.linkAt(gpa, 0, .{ .x = 0.3, .y = 0.38 })) orelse return error.NoLink; + defer internal.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), internal.destination.internal.page); + try std.testing.expectApproxEqAbs(@as(f32, 12), internal.destination.internal.x.?, 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 266), internal.destination.internal.y.?, 0.001); + const blank = try document.copyRectangle(gpa, 0, internal.quad); + defer gpa.free(blank); + try std.testing.expectEqualStrings("", blank); + try std.testing.expect(try document.linkAt(gpa, 1, .{ .x = 0.3, .y = 0.18 }) == null); + try std.testing.expectEqualStrings("https://example.com/embedded", external.uri); + var again = (try document.linkAt(gpa, 0, .{ .x = 0.3, .y = 0.18 })) orelse return error.NoLink; + defer again.deinit(gpa); + try std.testing.expectEqualStrings(external.uri, again.uri); + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + try std.testing.expectError(error.OutOfMemory, document.linkAt(failing.allocator(), 0, .{ .x = 0.3, .y = 0.18 })); + try std.testing.expectError(error.PageOutOfRange, document.linkAt(gpa, 2, .{ .x = 0.3, .y = 0.18 })); + for ([_]Point{ .{ .x = -0.1, .y = 0.18 }, .{ .x = 0.3, .y = 1.1 }, .{ .x = std.math.nan(f32), .y = 0.18 } }) |point| + try std.testing.expect(try document.linkAt(gpa, 0, point) == null); + try std.testing.expect(try document.linkAt(gpa, 0, .{ .x = 0.99, .y = 0.99 }) == null); +} + +fn emitRotatedLinkPdf(bytes: *PdfBytes, offsets: []usize, action: []const u8) !void { + try bytes.appendSlice("%PDF-1.7\n"); + try beginPdfObject(bytes, offsets, 1); + try bytes.appendSlice("<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 2); + try bytes.appendSlice("<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 3); + try bytes.appendSlice("<< /Type /Page /Parent 2 0 R /MediaBox [100 200 300 400] /CropBox [120 220 280 370] /Rotate 90 /Resources << >> /Annots [4 0 R] >>\nendobj\n"); + try beginPdfObject(bytes, offsets, 4); + try bytes.print("<< /Type /Annot /Subtype /Link /Rect [120 220 160 270] /Border [0 0 0] {s} >>\nendobj\n", .{action}); + try finishGeneratedPdf(bytes, offsets); +} + +test "PDF links normalize cropped rotated annotations and ignore unsupported actions" { + const gpa = std.testing.allocator; + start(gpa); + defer stop(); + const actions = [_][]const u8{ + "/A << /S /URI /URI (https://example.com/rotated) >>", + "/A << /S /URI /URI () >>", + "/A << /S /JavaScript /JS (ignored) >>", + "/Dest /missing_destination", + }; + for (actions, 0..) |action, index| { + var offsets: [5]usize = undefined; + const fixture = try allocateGeneratedPdf(gpa, &offsets, action, emitRotatedLinkPdf); + defer gpa.free(fixture); + var document = try Document.openBytes(fixture); + defer document.deinit(); + var found = try document.linkAt(gpa, 0, .{ .x = 0.1, .y = 0.1 }); + defer if (found) |*item| item.deinit(gpa); + if (index != 0) { + try std.testing.expect(found == null); + continue; + } + const link = found orelse return error.NoRotatedLink; + try std.testing.expectApproxEqAbs(@as(f32, 0), link.quad.ul.x, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 0), link.quad.ul.y, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 50.0 / 150.0), link.quad.lr.x, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 40.0 / 160.0), link.quad.lr.y, 0.0001); + try std.testing.expect(try document.linkAt(gpa, 0, .{ .x = 0.7, .y = 0.7 }) == null); + } +} + fn emitTooDeepOutlinePdf(bytes: *PdfBytes, offsets: []usize, levels: usize) !void { const first_outline_item = 5; try bytes.appendSlice("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"); diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c index 8e433fde..bdc7b891 100644 --- a/src/pdf_bridge.c +++ b/src/pdf_bridge.c @@ -27,6 +27,8 @@ struct pardes_pdf_document { fz_stext_page *cached_text; fz_display_list *cached_display_list; fz_rect cached_bounds; + fz_link *cached_links; + int cached_links_loaded; /* Promote only a page which is rendered twice without changing pages. */ int display_list_candidate_page_number; @@ -126,12 +128,15 @@ pardes_pdf_free_document(pardes_pdf_document *document) static void pardes_pdf_drop_cached_page(pardes_pdf_document *document) { + fz_drop_link(document->ctx, document->cached_links); fz_drop_display_list(document->ctx, document->cached_display_list); fz_drop_stext_page(document->ctx, document->cached_text); fz_drop_page(document->ctx, document->cached_page); document->cached_display_list = NULL; document->cached_text = NULL; document->cached_page = NULL; + document->cached_links = NULL; + document->cached_links_loaded = 0; document->cached_page_number = -1; document->display_list_candidate_page_number = -1; } @@ -993,6 +998,85 @@ pardes_pdf_point_inside_quad(pardes_pdf_point point, pardes_pdf_quad quad) fz_make_point(point.x, point.y), native) != 0; } +int +pardes_pdf_link_at( + pardes_pdf_document *document, + int page_number, + pardes_pdf_point point, + pardes_pdf_link *out) +{ + fz_context *ctx; + + if (out == NULL) + return PARDES_PDF_ERROR; + memset(out, 0, sizeof(*out)); + out->page = -1; + if (document == NULL || page_number < 0 || + page_number >= document->page_count) + return PARDES_PDF_ERROR; + if (!pardes_pdf_valid_point(point) || point.x < 0 || point.x > 1 || + point.y < 0 || point.y > 1) + return PARDES_PDF_OK; + + ctx = document->ctx; + fz_try(ctx) + { + fz_link *link; + fz_point page_point; + pardes_pdf_cache_page(document, page_number, 0); + if (!document->cached_links_loaded) { + document->cached_links = fz_load_links(ctx, document->cached_page); + document->cached_links_loaded = 1; + } + page_point = pardes_pdf_point_to_page(document, point); + for (link = document->cached_links; link != NULL; link = link->next) { + size_t len; + int destination_kind; + int destination_page = -1; + fz_link_dest destination = fz_make_link_dest_none(); + if (!pardes_pdf_valid_bounds(link->rect) || + !fz_is_point_inside_rect(page_point, link->rect) || + link->uri == NULL || link->uri[0] == 0) + continue; + for (len = 0; len <= PARDES_PDF_MAX_LINK_URI_BYTES && + link->uri[len] != 0; ++len) + ; + if (len > PARDES_PDF_MAX_LINK_URI_BYTES) + continue; + destination_kind = PARDES_PDF_OUTLINE_DESTINATION_EXTERNAL; + if (!fz_is_external_link(ctx, link->uri)) { + destination = fz_resolve_link_dest(ctx, document->doc, link->uri); + if (destination.loc.chapter >= 0 && destination.loc.page >= 0) + destination_page = fz_page_number_from_location( + ctx, document->doc, destination.loc); + if (destination_page >= 0 && destination_page < document->page_count) + destination_kind = PARDES_PDF_OUTLINE_DESTINATION_INTERNAL; + else if (link->uri[0] == '#') + continue; + } + out->quad = pardes_pdf_quad_from_page( + document, fz_quad_from_rect(link->rect)); + out->destination_kind = destination_kind; + if (destination_kind == PARDES_PDF_OUTLINE_DESTINATION_INTERNAL) { + out->page = destination_page; + out->has_x = isfinite(destination.x); + out->has_y = isfinite(destination.y); + if (out->has_x) out->x = destination.x; + if (out->has_y) out->y = destination.y; + } + out->uri = (const unsigned char *)link->uri; + out->uri_len = len; + break; + } + } + fz_catch(ctx) + { + memset(out, 0, sizeof(*out)); + return PARDES_PDF_ERROR; + } + return PARDES_PDF_OK; +} + int pardes_pdf_copy_selection( pardes_pdf_document *document, @@ -1041,6 +1125,43 @@ pardes_pdf_drop_owned_text(pardes_pdf_document *document, void *text) fz_free(document->ctx, text); } +int +pardes_pdf_copy_rectangle( + pardes_pdf_document *document, + int page_number, + pardes_pdf_quad quad, + pardes_pdf_owned_text *out) +{ + fz_context *ctx; + char *text = NULL; + fz_quad native; + if (document == NULL || out == NULL || page_number < 0 || + page_number >= document->page_count || !pardes_pdf_valid_quad(quad)) + 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); + native.ul = pardes_pdf_point_to_page(document, quad.ul); + native.ur = pardes_pdf_point_to_page(document, quad.ur); + native.ll = pardes_pdf_point_to_page(document, quad.ll); + native.lr = pardes_pdf_point_to_page(document, quad.lr); + text = fz_copy_rectangle(ctx, document->cached_text, + fz_rect_from_quad(native), 0); + } + fz_catch(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; +} + typedef struct pardes_pdf_outline_measurement { size_t item_count; size_t bytes_len; diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h index 81287578..8c73f688 100644 --- a/src/pdf_bridge.h +++ b/src/pdf_bridge.h @@ -149,6 +149,20 @@ typedef struct pardes_pdf_outline_result { size_t bytes_len; } pardes_pdf_outline_result; +/* URI borrows the current page's lazily loaded link list. Copy it before any + * other document operation. A NULL URI means the point has no usable link. */ +typedef struct pardes_pdf_link { + const unsigned char *uri; + size_t uri_len; + pardes_pdf_quad quad; + int destination_kind; + int page; + float x; + float y; + unsigned char has_x; + unsigned char has_y; +} pardes_pdf_link; + enum { PARDES_PDF_OK = 0, PARDES_PDF_ERROR = -1, @@ -168,7 +182,8 @@ enum { /* Outline conversion is atomic: crossing any bound returns no rows. */ PARDES_PDF_MAX_OUTLINE_ITEMS = 8192, PARDES_PDF_MAX_OUTLINE_DEPTH = 64, - PARDES_PDF_MAX_OUTLINE_BYTES = 4 * 1024 * 1024 + PARDES_PDF_MAX_OUTLINE_BYTES = 4 * 1024 * 1024, + PARDES_PDF_MAX_LINK_URI_BYTES = 64 * 1024 }; pardes_pdf_document *pardes_pdf_open(const char *path, int *page_count); @@ -282,6 +297,19 @@ void pardes_pdf_drop_outline_result( void *outline ); +int pardes_pdf_link_at( + pardes_pdf_document *document, + int page_number, + pardes_pdf_point point, + pardes_pdf_link *out +); +int pardes_pdf_copy_rectangle( + pardes_pdf_document *document, + int page_number, + pardes_pdf_quad quad, + pardes_pdf_owned_text *out +); + #ifdef __cplusplus } #endif diff --git a/src/web.zig b/src/web.zig index bceaaeeb..a1f3bad8 100644 --- a/src/web.zig +++ b/src/web.zig @@ -304,6 +304,11 @@ export fn pardes_cursor_x() i32 { return if (s.core.surface.cursor) |cursor| cursor.x else -1; } +export fn pardes_pointer_shape() u32 { + const s = &(state orelse return 0); + return @intFromEnum(s.core.surface.pointer_shape); +} + export fn pardes_cursor_y() i32 { const s = &(state orelse return -1); return if (s.core.surface.cursor) |cursor| cursor.y else -1; diff --git a/src/web/app.mjs b/src/web/app.mjs index be61de89..37d07eb7 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -41,6 +41,7 @@ export class DomRenderer { render() { const count = this.wasm.pardes_frame(); + this.root.style.cursor = this.wasm.pardes_pointer_shape() === 1 ? "pointer" : "text"; const cols = this.wasm.pardes_frame_cols(); const rows = this.wasm.pardes_frame_rows(); if (count !== cols * rows) throw new Error(`bad frame: ${count} cells for ${cols}x${rows}`); diff --git a/test/pdf.zig b/test/pdf.zig index a835c6ea..04b2e8b9 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -31,6 +31,167 @@ fn hasPdf(pane: *const pardes.Pane) bool { return if (comptime pdf_enabled) pane.pdf != null else false; } +const PdfLinkTests = struct { + const Cell = struct { col: u16, row: u16 }; + + fn init(tmp: std.testing.TmpDir) !*Pardes { + const bytes = try pdf_impl.makeLinksTestPdf(std.testing.allocator); + defer std.testing.allocator.free(bytes); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "links.pdf", .data = bytes }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "target.txt", .data = "chosen file\n" }); + var dir_buf: [4096]u8 = undefined; + const len = try tmp.dir.realPath(std.testing.io, &dir_buf); + const path = try std.fmt.allocPrint(std.testing.allocator, "{s}/links.pdf", .{dir_buf[0..len]}); + defer std.testing.allocator.free(path); + const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 100, .rows = 36 }); + p.native_images = true; + p.presentation.enabled = false; + p.panes[0].?.pdf.?.fit = .height; + return p; + } + + fn cellAt(p: *Pardes, x: f32, y: f32) !Cell { + const pane = p.panes[0].?; + const rect = p.rects[0]; + var best: ?Cell = null; + var distance: f32 = std.math.inf(f32); + for (rect.y..rect.y + rect.h) |row| for (rect.x..rect.x + rect.w) |col| { + const point = panes.Pdf.panePointAtPage(p, pane, 0, @intCast(col), @intCast(row), false) orelse continue; + const dx = point.x - x; + const dy = point.y - y; + const d = dx * dx + dy * dy; + if (d < distance) { + distance = d; + best = .{ .col = @intCast(col), .row = @intCast(row) }; + } + }; + return best orelse error.LinkOutsideViewport; + } + + fn textCell(p: *Pardes, text: []const u8) !Cell { + var found = try p.panes[0].?.pdf.?.document.search(p.gpa, 0, text); + defer found.deinit(p.gpa); + if (found.quads.len == 0) return error.MissingLinkFixtureText; + const q = found.quads[0].quad; + return cellAt(p, (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4, (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4); + } + + fn click(p: *Pardes, cell: Cell) void { + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = cell.col, .row = cell.row } }); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = cell.col, .row = cell.row } }); + } + + fn expectOpened(p: *Pardes, expected: ?[]const u8) !void { + var count: usize = 0; + while (p.nextEffect()) |effect| { + if (effect == .open_link) { + count += 1; + try std.testing.expectEqualStrings(expected orelse return error.UnexpectedExternalOpen, effect.open_link.slice()); + } + } + try std.testing.expectEqual(@as(usize, if (expected != null) 1 else 0), count); + } + + test "PDF links activate embedded URLs and blank internal annotations" { + if (!pdf_enabled or platform == .web) return; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const p = try init(tmp); + defer p.deinit(); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + while (p.nextEffect()) |_| {} + click(p, try textCell(p, "label")); + try expectOpened(p, "https://example.com/only"); + try std.testing.expectEqual(@as(usize, 0), p.panes[0].?.pdf.?.page); + click(p, try cellAt(p, 0.3, 0.38)); + try expectOpened(p, null); + try std.testing.expectEqual(@as(usize, 1), p.panes[0].?.pdf.?.page); + } + + test "PDF links offer distinct visible destinations and do not duplicate the same URL" { + if (!pdf_enabled or platform == .web) return; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const p = try init(tmp); + defer p.deinit(); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + while (p.nextEffect()) |_| {} + click(p, try textCell(p, "target.txt")); + try expectOpened(p, null); + const choices = for (p.panes, 0..) |slot, id| { + if (id == 0) continue; + if (slot) |pane| if (pane.file) |file| if (file.output != null) break pane; + } else return error.MissingPdfLinkChoices; + try std.testing.expect(std.mem.indexOf(u8, choices.file.?.content, "https://example.com/embedded") != null); + try std.testing.expect(std.mem.indexOf(u8, choices.file.?.content, "target.txt") != null); + const choices_id = for (p.panes, 0..) |slot, id| { + if (slot == choices) break id; + } else return error.MissingPdfLinkChoices; + p.lookAt(choices_id, "https://example.com/embedded"); + try expectOpened(p, "https://example.com/embedded"); + p.lookAt(choices_id, "target.txt"); + try std.testing.expectEqualStrings("chosen file\n", p.panes[p.active].?.file.?.content); + p.active = 0; + _ = try p.render(frame.allocator()); + click(p, try textCell(p, "https://example.com/same")); + try expectOpened(p, "https://example.com/same"); + } + + fn hover(p: *Pardes, cell: Cell) void { + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = cell.col, .row = cell.row } }); + for (0..(config.look_preview_delay_frames orelse 0)) |_| p.update(.tick); + } + + test "PDF links hover clears on leave page and reload while right drag selects text" { + if (!pdf_enabled or platform == .web) return; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const p = try init(tmp); + defer p.deinit(); + const pane = p.panes[0].?; + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + const plain = try p.render(frame.allocator()); + const plain_revision = plain.images[0].?.native.revision; + while (p.nextEffect()) |_| {} + const blank = try cellAt(p, 0.3, 0.38); + hover(p, blank); + const hovered = try p.render(frame.allocator()); + try std.testing.expectEqual(Surface.PointerShape.link, hovered.pointer_shape); + try std.testing.expect(hovered.images[0].?.native.revision != plain_revision); + const highlights = panes.Pdf.highlightInput(p, 0, pane); + try std.testing.expect(!std.meta.eql(highlights.hover_color, highlights.selection_color)); + try std.testing.expect(pane.pdf.?.selection == null); + try expectOpened(p, null); + p.update(.pointer_leave); + try std.testing.expectEqual(Surface.PointerShape.arrow, (try p.render(frame.allocator())).pointer_shape); + hover(p, blank); + panes.Pdf.setPage(p, pane, 1); + try std.testing.expectEqual(Surface.PointerShape.arrow, (try p.render(frame.allocator())).pointer_shape); + panes.Pdf.setPage(p, pane, 0); + _ = try p.render(frame.allocator()); + hover(p, try cellAt(p, 0.3, 0.38)); + try std.testing.expect(p.reloadWatchedFile(0, &.{})); + try std.testing.expectEqual(Surface.PointerShape.arrow, (try p.render(frame.allocator())).pointer_shape); + + const label = try textCell(p, "label"); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = label.col, .row = label.row } }); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .drag, .col = label.col + 3, .row = label.row } }); + p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = label.col + 3, .row = label.row } }); + try expectOpened(p, null); + try std.testing.expect(pane.pdf.?.selection != null); + try std.testing.expect(pane.pdf.?.selection_text.len > 0); + } +}; + +test { + _ = PdfLinkTests; +} + test "PDF mounted bytes own their source after the caller frees it" { if (!pdf_enabled or platform == .web) return; const gpa = std.testing.allocator; diff --git a/test/web_harness.mjs b/test/web_harness.mjs index aa6730e9..90dd9a19 100644 --- a/test/web_harness.mjs +++ b/test/web_harness.mjs @@ -39,7 +39,7 @@ async function main() { for (const name of [ "memory", "pardes_init", "pardes_frame", "pardes_frame_ptr", "pardes_key", - "pardes_mouse", "pardes_pointer_leave", "pardes_gui_tagline_font_percent", + "pardes_mouse", "pardes_pointer_leave", "pardes_pointer_shape", "pardes_gui_tagline_font_percent", "pardes_paste", "pardes_tick", "pardes_animation_tick", "pardes_animating", "pardes_should_quit", "pardes_deinit", ]) assert.ok(wasm[name], `missing wasm export ${name}`); @@ -59,6 +59,13 @@ async function main() { return renderer.render(); }; const first = renderer.render(); + assert.equal(root.style.cursor, "text"); + renderer.wasm = { ...wasm, pardes_pointer_shape: () => 1 }; + renderer.render(); + assert.equal(root.style.cursor, "pointer", "link hover should display the hand pointer"); + renderer.wasm = wasm; + renderer.render(); + assert.equal(root.style.cursor, "text", "leaving a link should restore the text pointer"); assert.equal(first.cols, 80); assert.equal(first.rows, 24); -- cgit v1.3