import * as PDFJS from "pdfjs-dist"; import { convertMatches, normalizeText } from "./text_utils"; import type { PDFPageProxy } from "pdfjs-dist/types/src/display/api"; import type { PageViewport } from "pdfjs-dist"; import type { Match, LinkMatch } from "../types"; function DOMRectToPDF( { width, height, left, top }: any, viewport: PageViewport, textLayerDiv: HTMLDivElement, ) { if (width === 0 || height === 0) { return null; } const pageBox = textLayerDiv.getBoundingClientRect(); const bottomLeft = viewport.convertToPdfPoint( left - pageBox.left, top - pageBox.top, ); const topRight = viewport.convertToPdfPoint( left - pageBox.left + width, top - pageBox.top + height, ); return PDFJS.Util.normalizeRect([ bottomLeft[0], bottomLeft[1], topRight[0], topRight[1], ]); } function calculateLinkPosition( range: Range, viewport: PageViewport, textLayerDiv: HTMLDivElement, ) { const rangeRects = range.getClientRects(); if (rangeRects.length === 1) { return { rect: DOMRectToPDF(rangeRects[0], viewport, textLayerDiv) }; } const rect = [Infinity, Infinity, -Infinity, -Infinity]; const quadPoints = []; let i = 0; for (const domRect of rangeRects) { const normalized = DOMRectToPDF(domRect, viewport, textLayerDiv); if (normalized === null) { continue; } quadPoints[i] = quadPoints[i + 4] = normalized[0]; quadPoints[i + 1] = quadPoints[i + 3] = normalized[3]; quadPoints[i + 2] = quadPoints[i + 6] = normalized[2]; quadPoints[i + 5] = quadPoints[i + 7] = normalized[1]; PDFJS.Util.rectBoundingBox( ...(normalized as [number, number, number, number]), rect, ); i += 8; } return { quadPoints: new Float32Array(quadPoints), rect }; } /** * Given a DOM node `container` and an index into its text contents `offset`, * returns a pair consisting of text node that the `offset` actually points * to, together with the offset relative to that text node. * When the offset points at the boundary between two node, the result will * point to the first text node in depth-first traversal order. * * For example, given this DOM: *

abcdefghi

* * textPosition(p, 0) -> [#text "abc", 0] (before `a`) * textPosition(p, 2) -> [#text "abc", 2] (between `b` and `c`) * textPosition(p, 3) -> [#text "abc", 3] (after `c`) * textPosition(p, 5) -> [#text "def", 2] (between `e` and `f`) * textPosition(p, 6) -> [#text "def", 3] (after `f`) */ function textPosition(container: any, offset: any) { let currentContainer = container; do { if (currentContainer.nodeType === Node.TEXT_NODE) { const currentLength = currentContainer.textContent.length; if (offset <= currentLength) { return [currentContainer, offset]; } offset -= currentLength; } else if (currentContainer.firstChild) { currentContainer = currentContainer.firstChild; continue; } while (!currentContainer.nextSibling && currentContainer !== container) { currentContainer = currentContainer.parentNode; } if (currentContainer !== container) { currentContainer = currentContainer.nextSibling; } } while (currentContainer !== container); throw new Error("Offset is bigger than container's contents length."); } function createLinkAnnotation( match: Match, url: string, viewport: PageViewport, textDivs: HTMLElement[], textLayerDiv: HTMLDivElement, id: number, ) { const { start, end } = match; const beginDiv = textDivs[start.idx]; const endDiv = textDivs[end.idx]; if (!beginDiv || !endDiv) { return null; } const range = new Range(); const [startNode, startOffset] = textPosition(beginDiv, start.offset); const [endNode, endOffset] = textPosition(endDiv, end.offset); range.setStart(startNode, startOffset); range.setEnd(endNode, endOffset); return { id: `inferred_link_${id}`, unsafeUrl: url, url, annotationType: PDFJS.AnnotationType.LINK, rotation: 0, ...calculateLinkPosition(range, viewport, textLayerDiv), // Populated in the annotationLayer to avoid unnecessary object creation, // since most inferred links overlap existing LinkAnnotations: borderStyle: null, }; } /** * Find all URLs and email addresses in the given text. * Regex can be tested and verified at https://regex101.com/r/rXoLiT/2 */ function findLinks(text: string): LinkMatch[] { // Reset regex lastIndex for fresh search const regex = /\b(?:https?:\/\/|mailto:|www\.)(?:[\S--[\p{P}<>]]|\/|[\S--[\[\]]]+[\S--[\p{P}<>]])+|(?=\p{L})[\S--[@\p{Ps}\p{Pe}<>]]+@([\S--[[\p{P}--\-]<>]]+(?:\.[\S--[[\p{P}--\-]<>]]+)+)/gmv; // Reset lastIndex before searching regex.lastIndex = 0; const links: LinkMatch[] = []; let match; while ((match = regex.exec(text)) !== null) { const [url, emailDomain] = match; let raw: string | undefined; if ( url.startsWith("www.") || url.startsWith("http://") || url.startsWith("https://") ) { raw = url; } else if (emailDomain) { // @ts-ignore const hostname = URL.parse(`http://${emailDomain}`)?.hostname; if (!hostname) { continue; } const numericTLDRegex = /\.\d+$/; if (numericTLDRegex.test(hostname)) { // Skip emails with a numeric TLD as domain. continue; } } raw ??= url.startsWith("mailto:") ? url : `mailto:${url}`; const absoluteURL = PDFJS.createValidAbsoluteUrl(raw, undefined, { addDefaultProtocol: true, }); if (absoluteURL) { links.push({ url: absoluteURL.href, index: match.index, length: url.length, }); } } return links; } /** * Process a PDF page to find and create link annotations for URLs and emails. * Returns an array of link annotation objects that can be used with the annotation layer. */ async function processLinks( page: PDFPageProxy, container: HTMLDivElement, viewport: PageViewport, ) { // Use consistent options for text content - NO marked content const textContent = await page.getTextContent({ disableNormalization: true, includeMarkedContent: true, }); const [text, diffs] = normalizeText(textContent); const links = findLinks(text); // Convert LinkMatch[] to the format expected by convertMatches from highlight.ts const matchesForConvert: (number | string)[][] = links.map((link) => [ link.index, link.length, link.url, ]); const convertedMatches = convertMatches( matchesForConvert, textContent, diffs, ); const annotations = []; if (convertedMatches.length > 0) { const mockTextContainer = document.createElement("div"); mockTextContainer.style.visibility = "hidden"; mockTextContainer.className = "textLayer"; container.parentElement?.appendChild(mockTextContainer); const mockTextLayer = new PDFJS.TextLayer({ textContentSource: page.streamTextContent(), container: mockTextContainer, viewport: viewport, }); await mockTextLayer.render(); const textDivs = mockTextLayer.textDivs; for (let i = 0; i < convertedMatches.length; i++) { const match = convertedMatches[i]; const url = links[i].url; const annotation = createLinkAnnotation( match, url, viewport, textDivs, mockTextContainer, page._pageIndex * 100 + i, ); if (annotation) { annotations.push(annotation); } } container.parentElement?.removeChild(mockTextContainer); } return annotations; } export { processLinks };