summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-15 13:46:50 -0300
committerGabriel Schneider <[email protected]>2026-09-15 17:24:42 -0300
commit6d7b506c8a380e4dd97f337590ca079d810a360a (patch)
tree71075c8c4383df25c6ecd5140ef75063a897b444 /src
parent13d0d107a06d7ac5fb5f8e86c747e54603f51b4e (diff)
downloadpardes-6d7b506c8a380e4dd97f337590ca079d810a360a.tar.gz
pardes-6d7b506c8a380e4dd97f337590ca079d810a360a.zip
Follow embedded PDF links through Look
Diffstat (limited to 'src')
-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
15 files changed, 572 insertions, 31 deletions
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}`);