import { pdfjs } from 'react-pdf'; import { TextLayer } from 'pdfjs-dist'; import "core-js/proposals/promise-with-resolvers"; import type { TTSSentenceAlignment } from '@/types/tts'; import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf'; import type { TTSSegmentLocator } from '@/types/client'; import { segmentWords } from '@/lib/shared/language'; import { buildAlignmentTokenRanges, type HighlightTokenRange, } from '@/lib/client/highlight-token-alignment'; // Worker coordination for offloading highlight token matching interface HighlightTokenMatchRequest { id: string; type: 'tokenMatch'; patternTokens: string[]; tokenTexts: string[]; } interface HighlightTokenMatchResponse { id: string; type: 'tokenMatchResult'; bestStart: number; bestEnd: number; rating: number; lengthDiff: number; } let highlightWorker: Worker | null = null; function getHighlightWorker(): Worker | null { if (typeof window === 'undefined') return null; if (highlightWorker) return highlightWorker; try { highlightWorker = new Worker( new URL('pdf-highlight-worker.ts', import.meta.url), { type: 'module' } ); return highlightWorker; } catch (e) { console.error('Failed to initialize PDF highlight worker:', e); highlightWorker = null; return null; } } function runHighlightTokenMatch( patternTokens: string[], tokenTexts: string[] ): Promise { const worker = getHighlightWorker(); if (!worker) { return Promise.resolve(null); } const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; return new Promise((resolve) => { const handleMessage = (event: MessageEvent) => { const data = event.data as HighlightTokenMatchResponse; if (!data || data.id !== id || data.type !== 'tokenMatchResult') { return; } worker.removeEventListener('message', handleMessage as EventListener); resolve(data); }; worker.addEventListener('message', handleMessage as EventListener); const message: HighlightTokenMatchRequest = { id, type: 'tokenMatch', patternTokens, tokenTexts, }; worker.postMessage(message); }); } // Function to detect if we need to use legacy build function shouldUseLegacyBuild() { try { if (typeof window === 'undefined') return false; const ua = window.navigator.userAgent; const isSafari = /^((?!chrome|android).)*safari/i.test(ua); if (!isSafari) return false; // Extract Safari version - matches "Version/18" format const match = ua.match(/Version\/(\d+)/i); if (!match || !match[1]) return true; // If we can't determine version, use legacy to be safe const version = parseInt(match[1]); return version < 18; // Use legacy build for Safari versions equal or below 18 } catch (e) { console.error('Error detecting Safari version:', e); return false; } } // Function to initialize PDF worker function initPDFWorker() { try { if (typeof window !== 'undefined') { const useLegacy = shouldUseLegacyBuild(); // Use local worker file instead of unpkg const workerSrc = useLegacy ? new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs', import.meta.url).href : new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href; pdfjs.GlobalWorkerOptions.workerSrc = workerSrc; pdfjs.GlobalWorkerOptions.workerPort = null; } } catch (e) { console.error('Error setting PDF worker:', e); } } // Initialize the worker initPDFWorker(); // Patch TextLayer.render to treat cancelled renders as non-errors try { const textLayerProto = TextLayer?.prototype; const originalRender = textLayerProto?.render; if (typeof originalRender === 'function') { textLayerProto.render = async function patchedRender(...args) { const task = originalRender.apply(this, args); if (!task || typeof task.then !== 'function') return task; return task.catch((error) => { if (error && (error.name === 'AbortException' || error.name === 'RenderingCancelledException')) { return; } throw error; }); }; } } catch (e) { console.error('Error patching TextLayer.render:', e); } type PDFToken = { spanIndex: number; textNode: Text; text: string; startOffset: number; endOffset: number; }; let lastSpanNodes: HTMLElement[] = []; let lastTokens: PDFToken[] = []; let lastSentenceTokenWindow: { start: number; end: number } | null = null; let lastSentencePattern: string | null = null; let lastSentenceWordToTokenRangeMap: Array | null = null; function getOrCreateHighlightLayer(span: HTMLElement): { layer: HTMLElement; pageElement: HTMLElement; pageRect: DOMRect; } | null { const pageElement = span.closest('.react-pdf__Page') as HTMLElement | null; if (!pageElement) return null; let layer = pageElement.querySelector('.pdf-highlight-layer') as HTMLElement | null; if (!layer) { layer = document.createElement('div'); layer.className = 'pdf-highlight-layer'; pageElement.appendChild(layer); } layer.style.position = 'absolute'; layer.style.inset = '0'; layer.style.pointerEvents = 'none'; layer.style.zIndex = '4'; layer.style.overflow = 'hidden'; // Force a compositor layer to avoid Safari occasionally not painting // newly-added positioned overlays. layer.style.transform = 'translateZ(0)'; return { layer, pageElement, pageRect: pageElement.getBoundingClientRect() }; } // Highlighting functions let highlightPatternSeq = 0; type HighlightPatternOptions = { parsedDocument?: ParsedPdfDocument | null; locator?: TTSSegmentLocator | null; useBlockGeometryOnly?: boolean; language?: string; }; function getHighlightLayerForPage(pageElement: HTMLElement): { layer: HTMLElement; pageRect: DOMRect; } { let layer = pageElement.querySelector('.pdf-highlight-layer') as HTMLElement | null; if (!layer) { layer = document.createElement('div'); layer.className = 'pdf-highlight-layer'; pageElement.appendChild(layer); } layer.style.position = 'absolute'; layer.style.inset = '0'; layer.style.pointerEvents = 'none'; layer.style.zIndex = '4'; layer.style.overflow = 'hidden'; layer.style.transform = 'translateZ(0)'; return { layer, pageRect: pageElement.getBoundingClientRect() }; } function findRenderedPageElement(container: HTMLElement, pageNumber: number): HTMLElement | null { const direct = container.querySelector(`.react-pdf__Page[data-page-number="${pageNumber}"]`) as HTMLElement | null; if (direct) return direct; const pageNodes = Array.from(container.querySelectorAll('.react-pdf__Page')) as HTMLElement[]; for (const pageNode of pageNodes) { const attr = pageNode.getAttribute('data-page-number'); if (Number(attr) === pageNumber) return pageNode; } return null; } function highlightParsedBlockGeometry( containerRef: React.RefObject, parsedDocument: ParsedPdfDocument, locator: TTSSegmentLocator, ): boolean { if (locator.readerType !== 'pdf') return false; if (!locator.blockId) return false; const container = containerRef.current; if (!container) return false; let targetBlock: | ParsedPdfPage['blocks'][number] | null = null; for (const page of parsedDocument.pages) { const found = page.blocks.find((block) => block.id === locator.blockId); if (found) { targetBlock = found; break; } } if (!targetBlock) return false; let firstRect: { top: number; left: number } | null = null; let drewAny = false; for (const fragment of targetBlock.fragments) { const parsedPage = parsedDocument.pages.find((page) => page.pageNumber === fragment.page); if (!parsedPage || parsedPage.width <= 0 || parsedPage.height <= 0) continue; const pageElement = findRenderedPageElement(container, fragment.page); if (!pageElement) continue; const { layer, pageRect } = getHighlightLayerForPage(pageElement); const [x0, y0, x1, y1] = fragment.bbox; const left = (x0 / parsedPage.width) * pageRect.width; const top = (y0 / parsedPage.height) * pageRect.height; const width = ((x1 - x0) / parsedPage.width) * pageRect.width; const height = ((y1 - y0) / parsedPage.height) * pageRect.height; if (!(width > 0 && height > 0)) continue; const highlight = document.createElement('div'); highlight.className = 'pdf-text-highlight-overlay'; highlight.style.position = 'absolute'; highlight.style.backgroundColor = 'grey'; highlight.style.opacity = '0.4'; highlight.style.pointerEvents = 'none'; highlight.style.zIndex = '1'; highlight.style.left = `${left}px`; highlight.style.top = `${top}px`; highlight.style.width = `${width}px`; highlight.style.height = `${height}px`; layer.appendChild(highlight); if (!firstRect) { firstRect = { top: pageRect.top + top, left: pageRect.left + left }; } drewAny = true; } if (!drewAny || !firstRect) return drewAny; const containerRect = container.getBoundingClientRect(); const visibleTop = container.scrollTop; const visibleBottom = visibleTop + containerRect.height; const elementTop = firstRect.top - containerRect.top + container.scrollTop; if (elementTop < visibleTop || elementTop > visibleBottom) { container.scrollTo({ top: elementTop - containerRect.height / 3, behavior: 'smooth', }); } return true; } function resolveParsedBlock( parsedDocument: ParsedPdfDocument | null | undefined, locator: TTSSegmentLocator | null | undefined, ): ParsedPdfPage['blocks'][number] | null { if (!parsedDocument || !locator || locator.readerType !== 'pdf' || !locator.blockId) { return null; } for (const page of parsedDocument.pages) { const found = page.blocks.find((block) => block.id === locator.blockId); if (found) return found; } return null; } function isRectOverlap( a: { left: number; top: number; right: number; bottom: number }, b: { left: number; top: number; right: number; bottom: number }, ): boolean { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } function collectSpanNodesForParsedBlock( container: HTMLElement, parsedDocument: ParsedPdfDocument, locator: TTSSegmentLocator, ): HTMLElement[] | null { const block = resolveParsedBlock(parsedDocument, locator); if (!block) return null; const collected: HTMLElement[] = []; const seen = new Set(); for (const fragment of block.fragments) { const parsedPage = parsedDocument.pages.find((page) => page.pageNumber === fragment.page); if (!parsedPage || parsedPage.width <= 0 || parsedPage.height <= 0) continue; const pageElement = findRenderedPageElement(container, fragment.page); if (!pageElement) continue; const textLayer = pageElement.querySelector('.react-pdf__Page__textContent') as HTMLElement | null; if (!textLayer) continue; const pageRect = pageElement.getBoundingClientRect(); const [x0, y0, x1, y1] = fragment.bbox; const blockRect = { left: (x0 / parsedPage.width) * pageRect.width, top: (y0 / parsedPage.height) * pageRect.height, right: (x1 / parsedPage.width) * pageRect.width, bottom: (y1 / parsedPage.height) * pageRect.height, }; const spans = Array.from(textLayer.querySelectorAll('span')) as HTMLElement[]; for (const span of spans) { const node = span.firstChild; if (!node || node.nodeType !== Node.TEXT_NODE) continue; const rect = span.getBoundingClientRect(); const spanRect = { left: rect.left - pageRect.left, top: rect.top - pageRect.top, right: rect.right - pageRect.left, bottom: rect.bottom - pageRect.top, }; if (!isRectOverlap(spanRect, blockRect)) continue; if (seen.has(span)) continue; seen.add(span); collected.push(span); } } return collected.length > 0 ? collected : null; } export function clearHighlights() { const overlays = document.querySelectorAll('.pdf-text-highlight-overlay'); overlays.forEach((node) => { const element = node as HTMLElement; if (element.parentElement) { element.parentElement.removeChild(element); } }); const wordOverlays = document.querySelectorAll('.pdf-word-highlight-overlay'); wordOverlays.forEach((node) => { const element = node as HTMLElement; if (element.parentElement) { element.parentElement.removeChild(element); } }); } export function clearWordHighlights() { const wordOverlays = document.querySelectorAll('.pdf-word-highlight-overlay'); wordOverlays.forEach((node) => { const element = node as HTMLElement; if (element.parentElement) { element.parentElement.removeChild(element); } }); } export function highlightPattern( text: string, pattern: string, containerRef: React.RefObject, options?: HighlightPatternOptions, ) { const seq = ++highlightPatternSeq; clearHighlights(); if (!pattern?.trim()) return; const container = containerRef.current; if (!container) return; const cleanPattern = pattern.trim().replace(/\s+/g, ' '); if (!cleanPattern) return; lastSentencePattern = cleanPattern; lastSentenceWordToTokenRangeMap = null; lastSentenceTokenWindow = null; const parsedDocument = options?.parsedDocument ?? null; const locator = options?.locator ?? null; // Canonical path: parsed block locator is required for PDF sentence // highlighting. Avoid broad full-page text matching fallbacks. if (!parsedDocument || !locator || locator.readerType !== 'pdf' || !locator.blockId) { return; } const spanNodes = collectSpanNodesForParsedBlock(container, parsedDocument, locator) ?? []; if (!spanNodes.length) { if (options?.useBlockGeometryOnly) { highlightParsedBlockGeometry(containerRef, parsedDocument, locator); } return; } lastSpanNodes = spanNodes; const tokens: PDFToken[] = []; spanNodes.forEach((span, spanIndex) => { const node = span.firstChild; if (!node || node.nodeType !== Node.TEXT_NODE) return; const textNode = node as Text; const textContent = textNode.textContent || ''; for (const token of segmentWords(textContent, options?.language)) { tokens.push({ spanIndex, textNode, text: token.text, startOffset: token.start, endOffset: token.end, }); } }); if (!tokens.length) return; lastTokens = tokens; if (options?.useBlockGeometryOnly) { lastSentenceTokenWindow = { start: 0, end: tokens.length - 1, }; highlightParsedBlockGeometry(containerRef, parsedDocument, locator); return; } const patternLen = cleanPattern.length; // Core application of highlight logic once we know the best token window (if any) const applyHighlightFromTokens = ( tokenMatch: | { bestStart: number; bestEnd: number; rating: number; lengthDiff: number; } | null ) => { const highlightRanges: Array<{ textNode: Text; startOffset: number; endOffset: number; span: HTMLElement; }> = []; let bestStart = -1; let bestEnd = -1; let bestRating = 0; let bestLengthDiff = Infinity; if (tokenMatch) { bestStart = tokenMatch.bestStart; bestEnd = tokenMatch.bestEnd; bestRating = tokenMatch.rating; bestLengthDiff = tokenMatch.lengthDiff; } const hasTokenMatch = bestStart !== -1; const similarityThreshold = bestLengthDiff < patternLen * 0.3 ? 0.3 : 0.5; if (hasTokenMatch && bestRating >= similarityThreshold) { lastSentenceTokenWindow = { start: bestStart, end: bestEnd, }; const rangesBySpan = new Map< number, { startOffset: number; endOffset: number } >(); for (let i = bestStart; i <= bestEnd; i++) { const token = tokens[i]; const existing = rangesBySpan.get(token.spanIndex); if (!existing) { rangesBySpan.set(token.spanIndex, { startOffset: token.startOffset, endOffset: token.endOffset, }); } else { existing.startOffset = Math.min( existing.startOffset, token.startOffset ); existing.endOffset = Math.max( existing.endOffset, token.endOffset ); } } rangesBySpan.forEach(({ startOffset, endOffset }, spanIndex) => { const span = spanNodes[spanIndex]; const node = span.firstChild; if (!node || node.nodeType !== Node.TEXT_NODE) return; highlightRanges.push({ textNode: node as Text, startOffset, endOffset, span, }); }); } if (!highlightRanges.length) return; // Create overlay rectangles for each range, relative to its page text layer const scrollIntoViewRects: DOMRect[] = []; highlightRanges.forEach(({ textNode, startOffset, endOffset, span }) => { try { const range = document.createRange(); range.setStart(textNode, startOffset); range.setEnd(textNode, endOffset); const highlightTarget = getOrCreateHighlightLayer(span); if (!highlightTarget) return; const { layer: highlightLayer, pageRect } = highlightTarget; const rects = Array.from(range.getClientRects()); rects.forEach((rect) => { const highlight = document.createElement('div'); highlight.className = 'pdf-text-highlight-overlay'; highlight.style.position = 'absolute'; highlight.style.backgroundColor = 'grey'; highlight.style.opacity = '0.4'; highlight.style.pointerEvents = 'none'; highlight.style.zIndex = '1'; highlight.style.left = `${rect.left - pageRect.left}px`; highlight.style.top = `${rect.top - pageRect.top}px`; highlight.style.width = `${rect.width}px`; highlight.style.height = `${rect.height}px`; highlightLayer.appendChild(highlight); scrollIntoViewRects.push(rect); }); } catch { // If range creation fails for any reason, skip this segment } }); if (!scrollIntoViewRects.length) return; // Scroll the first highlighted rect into view if needed const containerRect = container.getBoundingClientRect(); const visibleTop = container.scrollTop; const visibleBottom = visibleTop + containerRect.height; const firstRect = scrollIntoViewRects[0]; const elementTop = firstRect.top - containerRect.top + container.scrollTop; if (elementTop < visibleTop || elementTop > visibleBottom) { container.scrollTo({ top: elementTop - containerRect.height / 3, behavior: 'smooth', }); } }; const tokenTexts = tokens.map((t) => t.text); const patternTokens = segmentWords(cleanPattern, options?.language).map((token) => token.text); // Fire-and-forget async worker call; UI thread returns immediately runHighlightTokenMatch(patternTokens, tokenTexts) .then((result) => { if (seq !== highlightPatternSeq) return; if (!result || result.bestStart === -1) { // No worker result or no good match; nothing to highlight applyHighlightFromTokens(null); } else { applyHighlightFromTokens({ bestStart: result.bestStart, bestEnd: result.bestEnd, rating: result.rating, lengthDiff: result.lengthDiff, }); } }) .catch((error) => { if (seq !== highlightPatternSeq) return; console.error( 'Error in PDF highlight worker; no highlights applied:', error ); applyHighlightFromTokens(null); }); } export function highlightWordIndex( alignment: TTSSentenceAlignment | undefined, wordIndex: number | null | undefined, sentence: string | null | undefined, containerRef: React.RefObject ) { clearWordHighlights(); if (!alignment) return; if (wordIndex === null || wordIndex === undefined || wordIndex < 0) { return; } const words = alignment.words || []; if (!words.length || wordIndex >= words.length) return; const container = containerRef.current; if (!container) return; if (!lastSentenceTokenWindow) return; if (!lastTokens.length || !lastSpanNodes.length) return; const cleanSentence = sentence && sentence.trim() ? sentence.trim().replace(/\s+/g, ' ') : null; if (!cleanSentence || !lastSentencePattern) return; if (cleanSentence !== lastSentencePattern) return; const start = lastSentenceTokenWindow.start; const end = lastSentenceTokenWindow.end; if (end < start) return; // Lazily build or refresh the shared mapping from alignment words to PDF // token ranges for this sentence window. if ( !lastSentenceWordToTokenRangeMap || lastSentenceWordToTokenRangeMap.length !== words.length ) { const relativeRanges = buildAlignmentTokenRanges( words, lastTokens.slice(start, end + 1).map((token) => token.text), { fillGaps: true }, ); lastSentenceWordToTokenRangeMap = relativeRanges.map((range) => ( range ? { start: range.start + start, end: range.end + start } : null )); } const tokenRange = lastSentenceWordToTokenRangeMap[wordIndex]; if (!tokenRange) return; for (let tokenIndex = tokenRange.start; tokenIndex <= tokenRange.end; tokenIndex += 1) { const token = lastTokens[tokenIndex]; const span = lastSpanNodes[token.spanIndex]; if (!span) continue; const node = token.textNode; if (!node || node.nodeType !== Node.TEXT_NODE) continue; try { const range = document.createRange(); range.setStart(node, token.startOffset); range.setEnd(node, token.endOffset); const highlightTarget = getOrCreateHighlightLayer(span); if (!highlightTarget) continue; const { layer: highlightLayer, pageRect } = highlightTarget; const rects = Array.from(range.getClientRects()); rects.forEach((rect) => { const highlight = document.createElement('div'); highlight.className = 'pdf-word-highlight-overlay'; highlight.style.position = 'absolute'; highlight.style.backgroundColor = 'var(--accent)'; highlight.style.opacity = '0.4'; highlight.style.pointerEvents = 'none'; highlight.style.left = `${rect.left - pageRect.left}px`; highlight.style.top = `${rect.top - pageRect.top}px`; highlight.style.width = `${rect.width}px`; highlight.style.height = `${rect.height}px`; highlight.style.zIndex = '2'; highlightLayer.appendChild(highlight); }); } catch { // Ignore range errors } } } // Debounce for PDF viewer export function debounce unknown>( func: T, wait: number ): (...args: Parameters) => void { let timeout: NodeJS.Timeout; return (...args: Parameters) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; }