From 9902f9a3a29c626de6bf7968ef139b4f0a47b253 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:22:03 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=8E=A8=20Theme=20generated=20PDF=20hy?= =?UTF-8?q?perlink=20highlights?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the viewer's selected accent color for generated table-of-contents and inferred-reference link hover/focus treatments without changing native PDF hyperlinks. Add regression coverage. Co-authored-by: Codex Web --- src/viewer/annotation-layer.css | 12 ++++++------ tests/clickable-reference-links.test.mjs | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) 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/tests/clickable-reference-links.test.mjs b/tests/clickable-reference-links.test.mjs index a517d61..d3bea5a 100644 --- a/tests/clickable-reference-links.test.mjs +++ b/tests/clickable-reference-links.test.mjs @@ -64,3 +64,27 @@ 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/); +}); From 90e73cb11df2e777c507fe9207f05257bd86792f Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:30:46 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A9=20Link=20written=20HTTP=20URLs?= =?UTF-8?q?=20in=20PDF=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recognize visible HTTP and HTTPS addresses, including split PDF text fragments, and render them as securely opened themed anchors. Preserve native annotations, trim sentence punctuation, and add regression coverage. Co-authored-by: Codex Web --- .../navigation/clickable-reference-links.js | 138 ++++++++++++++++-- tests/clickable-reference-links.test.mjs | 119 ++++++++++++++- 2 files changed, 241 insertions(+), 16 deletions(-) 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 d3bea5a..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 = { @@ -88,3 +88,120 @@ test("generated hyperlinks use the theme accent without restyling native PDF lin 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; + } +});