From 6d7b506c8a380e4dd97f337590ca079d810a360a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 13:46:50 -0300 Subject: Follow embedded PDF links through Look --- test/pdf.zig | 161 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) (limited to 'test/pdf.zig') 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; -- cgit v1.3