Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/viewer/annotation-layer.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@
}

.printed-contents-link:hover {
background: rgb(100 150 240 / 14%);
outline: 1px solid rgb(100 150 240 / 32%);
background: color-mix(in srgb, var(--accent) 14%, transparent);
outline: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
}

.printed-contents-link:focus-visible {
outline: 2px solid Highlight;
outline: 2px solid var(--accent);
outline-offset: 1px;
}

Expand All @@ -73,11 +73,11 @@
pointer-events: auto;
}
.clickable-reference-link:hover {
background: rgb(100 150 240 / 14%);
outline: 1px solid rgb(100 150 240 / 32%);
background: color-mix(in srgb, var(--accent) 14%, transparent);
outline: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
}
.clickable-reference-link:focus-visible {
outline: 2px solid Highlight;
outline: 2px solid var(--accent);
}
html.clickable-references-disabled .clickable-reference-layer { display: none !important; }
@media print { .clickable-reference-layer { display: none !important; } }
138 changes: 123 additions & 15 deletions src/viewer/navigation/clickable-reference-links.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,105 @@ function superscriptMarker(items, index, viewport) {
kind: "footnote", number: Number(item.str.trim()) };
}

// Visible web addresses in a PDF are not always annotated as hyperlinks.
// Detect only HTTP(S) URLs; never execute schemes embedded in document text.
const WRITTEN_URL = /https?:\/\/[^\s<>"“”‘’]+/gi;

function trimWrittenUrl(value) {
let result = value;
while (result.length) {
const last = result.at(-1);
if (/[.,;:!?'"\u2019\u201d}]/.test(last)) {
result = result.slice(0, -1);
} else if (last === ")" || last === "]") {
const opening = last === ")" ? "(" : "[";
const opens = [...result].filter(char => char === opening).length;
const closes = [...result].filter(char => char === last).length;
if (closes <= opens) break; // Keep balanced parentheses in URL paths.
result = result.slice(0, -1);
} else {
break;
}
}
return result;
}

function separateTextRuns(previous, current) {
if (!Array.isArray(previous?.transform) || !Array.isArray(current?.transform)) return false;
const size = Math.max(Math.abs(previous.height || 0), Math.abs(current.height || 0), 2);
if (Math.abs(previous.transform[5] - current.transform[5]) > size * 0.6) return true;
// PDF.js sometimes omits an intervening space from adjacent text items.
// Do not combine a URL with a visually separated word or column.
return typeof previous.width === "number" &&
current.transform[4] - (previous.transform[4] + previous.width) > Math.max(2, size * 0.4);
}

export function findWrittenUrlReferences(items = []) {
const references = [];
let run = "";
let pieces = [];
let urlId = 0;

function flush() {
for (const match of run.matchAll(WRITTEN_URL)) {
// Reject embedded scheme-like substrings within an ordinary word.
if (match.index > 0 && /[\p{L}\p{N}_@]/u.test(run[match.index - 1])) continue;
const text = trimWrittenUrl(match[0]);
if (!text || text.length > 2048) continue;
let url;
try {
const parsed = new URL(text);
if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) continue;
url = parsed.href;
} catch {
continue;
}
const end = match.index + text.length;
const id = urlId++;
for (const piece of pieces) {
const start = Math.max(match.index, piece.start);
const stop = Math.min(end, piece.end);
if (start < stop) references.push({
kind: "url", url, urlId: id, itemIndex: piece.itemIndex,
start: start - piece.start, end: stop - piece.start,
});
}
}
run = "";
pieces = [];
}

for (const [itemIndex, item] of (items || []).entries()) {
if (typeof item?.str !== "string") {
flush();
continue;
}
const previous = pieces.length ? items[pieces.at(-1).itemIndex] : null;
if (previous && (previous.hasEOL || separateTextRuns(previous, item))) flush();
const start = run.length;
run += item.str;
pieces.push({ itemIndex, start, end: run.length });
if (item.hasEOL || run.length > 16384) flush();
}
flush();
return references;
}

export function findClickableReferences(items, { pageCount, sectionTargets = new Map(), viewport } = {}) {
const matches = [];
// Index confirmed note labels so ordinary body numbers remain selectable.
const labels = new Map((viewport ? footnotesForPage(items || [], viewport, 1) : [])
.filter(note => note.markerItemIndex >= 0)
.map(note => [note.markerItemIndex, note]));
const urlReferencesByItem = new Map();
for (const url of findWrittenUrlReferences(items)) {
if (!urlReferencesByItem.has(url.itemIndex)) urlReferencesByItem.set(url.itemIndex, []);
urlReferencesByItem.get(url.itemIndex).push(url);
}
for (const [itemIndex, item] of (items || []).entries()) {
const content = item?.str;
if (typeof content !== "string") continue;
const candidates = [];
const candidates = [...(urlReferencesByItem.get(itemIndex) || [])];
const note = labels.get(itemIndex);
const label = note && content.match(/^\s*(?:\[\s*\d+\s*\]|\(\s*\d+\s*\)|\d+[.)]?)(?=\s|$)/);
if (label) candidates.push({ itemIndex, start: 0, end: label[0].length,
Expand Down Expand Up @@ -132,31 +221,50 @@ export function createClickableReferenceLayer({
const nativeLinks = Array.from(annotationsLayer.querySelectorAll(".linkAnnotation a"))
.flatMap((anchor) => Array.from(anchor.getClientRects()));
const references = findClickableReferences(items, { pageCount, sectionTargets, viewport });
const placements = [];
const nativeUrlIds = new Set();
for (const ref of references) {
const span = textDivs[ref.itemIndex];
if (!span || span.textContent.length !== items[ref.itemIndex].str.length) continue;
const range = textRange(span, ref.start, ref.end);
if (!range) continue;
const title = ref.kind === "section" ? "Jump to section " + ref.label :
const rects = Array.from(range.getClientRects());
// If even part of a written URL already has an embedded PDF link,
// leave the entire URL to the native annotation, including split spans.
if (ref.kind === "url" && rects.some(rect => nativeLinks.some(link => overlap(rect, link)))) {
nativeUrlIds.add(ref.urlId);
}
placements.push({ ref, rects });
}
for (const { ref, rects } of placements) {
if (ref.kind === "url" && nativeUrlIds.has(ref.urlId)) continue;
const title = ref.kind === "url" ? "Open " + ref.url :
ref.kind === "section" ? "Jump to section " + ref.label :
"Jump to " + (ref.kind === "page" ? "page " : "footnote ") + ref.number;
for (const rect of range.getClientRects()) {
if (rect.width <= 0 || rect.height <= 0 || nativeLinks.some((link) => overlap(rect, link))) continue;
for (const rect of rects) {
if (rect.width <= 0 || rect.height <= 0 || nativeLinks.some(link => overlap(rect, link))) continue;
const left = Math.max(0, rect.left - pageRect.left);
const top = Math.max(0, rect.top - pageRect.top);
const width = Math.min(rect.width, pageRect.width - left);
const height = Math.min(rect.height, pageRect.height - top);
if (width <= 0 || height <= 0) continue;
const button = document.createElement("button");
button.type = "button";
button.className = "clickable-reference-link";
button.style.left = (left / pageRect.width * 100) + "%";
button.style.top = (top / pageRect.height * 100) + "%";
button.style.width = (width / pageRect.width * 100) + "%";
button.style.height = (height / pageRect.height * 100) + "%";
button.title = title;
button.setAttribute("aria-label", title);
button.addEventListener("click", () => onNavigate(ref));
layer.append(button);
const link = document.createElement(ref.kind === "url" ? "a" : "button");
if (ref.kind === "url") {
link.href = ref.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
} else {
link.type = "button";
link.addEventListener("click", () => onNavigate(ref));
}
link.className = "clickable-reference-link";
link.style.left = (left / pageRect.width * 100) + "%";
link.style.top = (top / pageRect.height * 100) + "%";
link.style.width = (width / pageRect.width * 100) + "%";
link.style.height = (height / pageRect.height * 100) + "%";
link.title = title;
link.setAttribute("aria-label", title);
layer.append(link);
}
}
return layer;
Expand Down
143 changes: 142 additions & 1 deletion tests/clickable-reference-links.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
findClickableReferences, referenceClicksEnabled,
findClickableReferences, findWrittenUrlReferences, createClickableReferenceLayer, referenceClicksEnabled,
} from "../src/viewer/navigation/clickable-reference-links.js";

const viewport = {
Expand Down Expand Up @@ -64,3 +64,144 @@ test("reference preference is in More PDF tools and only controls inferred links
// Embedded PDF links are still handled by PDF.js, not by this preference.
assert.match(viewer, /new AnnotationLayer\(\{/);
});

test("generated hyperlinks use the theme accent without restyling native PDF links", () => {
const css = readFileSync(new URL("../src/viewer/annotation-layer.css", import.meta.url), "utf8");
const hoverRules = [
css.match(/\.printed-contents-link:hover\s*\{([^}]*)\}/)?.[1],
css.match(/\.clickable-reference-link:hover\s*\{([^}]*)\}/)?.[1],
];
const focusRules = [
css.match(/\.printed-contents-link:focus-visible\s*\{([^}]*)\}/)?.[1],
css.match(/\.clickable-reference-link:focus-visible\s*\{([^}]*)\}/)?.[1],
];
for (const hover of hoverRules) {
assert.ok(hover, "generated link hover styling must exist");
assert.match(hover, /background:\s*color-mix\(in srgb, var\(--accent\) 14%, transparent\)/);
assert.match(hover, /outline:\s*1px solid color-mix\(in srgb, var\(--accent\) 32%, transparent\)/);
}
for (const focus of focusRules) {
assert.ok(focus, "generated link keyboard focus styling must exist");
assert.match(focus, /outline:\s*2px solid var\(--accent\)/);
}
// Keep native PDF.js link annotations independent of the accent treatment.
const nativeRule = css.match(/\.annotation-layer \.linkAnnotation\s*\{([^}]*)\}/)?.[1] ?? "";
assert.doesNotMatch(nativeRule, /--accent|color-mix|background|outline/);
});

test("plain HTTP and HTTPS text becomes an inferred link, with punctuation trimmed", () => {
const cases = [
["Read https://example.org/report.pdf.", "https://example.org/report.pdf"],
["See (https://example.org/path_(v2)).", "https://example.org/path_(v2)"],
["Visit http://localhost:4173/docs?q=1&v=2, thanks.", "http://localhost:4173/docs?q=1&v=2"],
["See https://example.com/a#section-2!", "https://example.com/a#section-2"],
];
for (const [text, expected] of cases) {
const refs = findWrittenUrlReferences([{ str: text }]);
assert.equal(refs.length, 1, text);
assert.equal(refs[0].kind, "url");
assert.equal(refs[0].url, expected);
assert.equal(text.slice(refs[0].start, refs[0].end), expected);
}
});

test("URL detection does not invent links from unsupported schemes or incomplete addresses", () => {
const items = [{ str: "ftp://example.org javascript:alert(1) https:// http:// no-url" }];
assert.deepEqual(findWrittenUrlReferences(items), []);
assert.deepEqual(findWrittenUrlReferences([{ str: "prefixhttps://example.org" }]), []);
});

test("adjacent PDF.js text fragments can jointly form a URL", () => {
const items = [
{ str: "Visit https://exa" },
{ str: "mple.org/docs" },
{ str: " for details.", hasEOL: true },
];
const refs = findWrittenUrlReferences(items);
assert.equal(refs.length, 2);
assert.deepEqual(refs.map(ref => [ref.itemIndex, ref.start, ref.end]), [
[0, 6, 17], [1, 0, 13],
]);
assert.ok(refs.every(ref => ref.url === "https://example.org/docs" && ref.urlId === refs[0].urlId));
assert.deepEqual(
findClickableReferences(items, { pageCount: 20, viewport }).filter(ref => ref.kind === "url"),
refs,
);
});

test("line breaks and distant text fragments do not extend a URL", () => {
assert.equal(findWrittenUrlReferences([
{ str: "https://example.com", hasEOL: true },
{ str: "unrelated", hasEOL: true },
])[0].url, "https://example.com/");
assert.equal(findWrittenUrlReferences([
{ str: "https://example.com", transform: [1, 0, 0, 12, 0, 300], height: 12, width: 115 },
{ str: "unrelated", transform: [1, 0, 0, 12, 250, 300], height: 12, width: 60 },
])[0].url, "https://example.com/");
});

test("written URLs render as secure anchors and never overlap native PDF links", () => {
const previousDocument = globalThis.document;
const previousNodeFilter = globalThis.NodeFilter;
let currentSpan;
globalThis.NodeFilter = { SHOW_TEXT: 4 };
globalThis.document = {
createElement(tag) {
return {
tag, style: {}, children: [],
append(child) { this.children.push(child); },
setAttribute(name, value) { this[name] = value; },
addEventListener() {},
};
},
createTreeWalker(span) {
currentSpan = span;
let visited = false;
return {
nextNode() {
if (visited) return null;
visited = true;
return { textContent: span.textContent };
},
};
},
createRange() {
return {
setStart() {},
setEnd() {},
getClientRects() { return [currentSpan.rect]; },
};
},
};
try {
const text = "https://example.org/";
const rect = { left: 30, top: 30, width: 120, height: 16, right: 150, bottom: 46 };
const params = {
pageElement: { getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 1000 }) },
items: [{ str: text }], textDivs: [{ textContent: text, rect }],
annotationsLayer: { querySelectorAll: () => [] },
viewport, pageCount: 3, sectionTargets: new Map(),
onNavigate: () => { throw new Error("URLs should use anchor navigation"); },
};
const layer = createClickableReferenceLayer(params);
assert.equal(layer.children.length, 1);
const link = layer.children[0];
assert.equal(link.tag, "a");
assert.equal(link.href, text);
assert.equal(link.target, "_blank");
assert.equal(link.rel, "noopener noreferrer");
assert.equal(link.className, "clickable-reference-link");
assert.equal(link["aria-label"], "Open " + text);

const nativeLayer = createClickableReferenceLayer({
...params,
annotationsLayer: { querySelectorAll: () => [{ getClientRects: () => [rect] }] },
});
assert.equal(nativeLayer.children.length, 0);
} finally {
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
if (previousNodeFilter === undefined) delete globalThis.NodeFilter;
else globalThis.NodeFilter = previousNodeFilter;
}
});
Loading