summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/design.typ9
-rw-r--r--docs/detached.md4
-rw-r--r--src/config.zig1
-rw-r--r--src/detached/client.zig3
-rw-r--r--src/detached/server.zig3
-rw-r--r--src/detached/wire.zig46
-rw-r--r--src/gui/gui.zig17
-rw-r--r--src/macos.zig6
-rw-r--r--src/macos/Sources/PardesView.swift12
-rw-r--r--src/macos/pardes.h2
-rw-r--r--src/panes.zig29
-rw-r--r--src/pardes.zig148
-rw-r--r--src/pdf.zig179
-rw-r--r--src/pdf_bridge.c121
-rw-r--r--src/pdf_bridge.h30
-rw-r--r--src/web.zig5
-rw-r--r--src/web/app.mjs1
-rw-r--r--test/pdf.zig161
-rw-r--r--test/web_harness.mjs9
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,
@@ -587,6 +589,18 @@ pub fn encodeFrame(
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`
// is derived from these two, so a larger grid is a frame this decoder
@@ -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<pardes_panel_track_s>
@@ -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;
}
@@ -994,6 +999,85 @@ pardes_pdf_point_inside_quad(pardes_pdf_point point, pardes_pdf_quad quad)
}
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,
int page_number,
@@ -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);