diff --git a/src/viewer/annotation-layer.css b/src/viewer/annotation-layer.css index 0611b2b..1ce80b5 100644 --- a/src/viewer/annotation-layer.css +++ b/src/viewer/annotation-layer.css @@ -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; } @@ -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; } } diff --git a/src/viewer/navigation/clickable-reference-links.js b/src/viewer/navigation/clickable-reference-links.js index 02510ac..a01dc09 100644 --- a/src/viewer/navigation/clickable-reference-links.js +++ b/src/viewer/navigation/clickable-reference-links.js @@ -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, @@ -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; diff --git a/tests/clickable-reference-links.test.mjs b/tests/clickable-reference-links.test.mjs index a517d61..9777a72 100644 --- a/tests/clickable-reference-links.test.mjs +++ b/tests/clickable-reference-links.test.mjs @@ -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 = { @@ -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; + } +});