From 071d967f8ff18c8eb581764a8759dcfebbef2e78 Mon Sep 17 00:00:00 2001
From: Richard Roberson
Date: Sat, 15 Nov 2025 14:06:44 -0700
Subject: [PATCH] feat(pdf): modernize text highlighting and parsing for
fluidity
- Offload computationally intensive text matching for real-time highlighting to a dedicated Web Worker, ensuring the main thread remains responsive during playback.
- Implement a new overlay-based highlighting system that renders independently of the PDF's text layer, providing smoother and more reliable visual feedback without interfering with document rendering.
- Introduce a new setting allowing users to enable or disable real-time text highlighting in PDFs, offering personalized control over the reading interface.
- Upgrade the underlying text comparison algorithm to Dice similarity for more accurate and context-aware matching of spoken words to on-screen text, improving synchronization precision.
- Improve sentence boundary detection, especially for quoted dialogue and complex structures, by enhancing the NLP processing logic, leading to a more natural audio-text flow.
---
src/components/DocumentSettings.tsx | 19 +-
src/components/PDFViewer.tsx | 58 +----
src/contexts/ConfigContext.tsx | 27 +-
src/contexts/PDFContext.tsx | 3 +-
src/utils/nlp.ts | 30 +--
src/utils/pdf.ts | 387 +++++++++++++++++++++++++---
src/utils/pdfHighlightWorker.ts | 135 ++++++++++
7 files changed, 545 insertions(+), 114 deletions(-)
create mode 100644 src/utils/pdfHighlightWorker.ts
diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx
index 72ac593..49d1d42 100644
--- a/src/components/DocumentSettings.tsx
+++ b/src/components/DocumentSettings.tsx
@@ -32,7 +32,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
footerMargin,
leftMargin,
rightMargin,
- updateConfigKey
+ updateConfigKey,
+ pdfHighlightEnabled,
} = useConfig();
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
@@ -327,6 +328,22 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
)}
+ {!epub && !html && (
+
+
+ updateConfigKey('pdfHighlightEnabled', e.target.checked)}
+ className="form-checkbox h-4 w-4 text-accent rounded border-muted"
+ />
+ Highlight text during playback
+
+
+ Show visual highlighting in the PDF viewer while TTS is reading.
+
+
+ )}
{epub && (
diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx
index d8f9d66..c95a6de 100644
--- a/src/components/PDFViewer.tsx
+++ b/src/components/PDFViewer.tsx
@@ -25,13 +25,11 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const { containerWidth } = usePDFResize(containerRef);
// Config context
- const { viewType } = useConfig();
+ const { viewType, pdfHighlightEnabled } = useConfig();
// TTS context
const {
currentSentence,
- stopAndPlayFromIndex,
- isProcessing,
skipToLocation,
} = useTTS();
@@ -39,7 +37,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const {
highlightPattern,
clearHighlights,
- handleTextClick,
onDocumentLoadSuccess,
currDocData,
currDocPages,
@@ -47,51 +44,6 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
currDocPage,
} = usePDF();
- // Add static styles once during component mount
- useEffect(() => {
- const styleElement = document.createElement('style');
- styleElement.textContent = `
- .react-pdf__Page__textContent span {
- cursor: pointer;
- transition: background-color 0.2s ease;
- }
- .react-pdf__Page__textContent span:hover {
- background-color: rgba(255, 255, 0, 0.2) !important;
- }
- `;
- document.head.appendChild(styleElement);
- return () => {
- styleElement.remove();
- };
- }, []);
-
- useEffect(() => {
- /*
- * Sets up click event listeners for text selection in the PDF.
- * Cleans up by removing the event listener when component unmounts.
- *
- * Dependencies:
- * - pdfText: Re-run when the extracted text content changes
- * - handleTextClick: Function from context that could change
- * - stopAndPlayFromIndex: Function from context that could change
- */
- const container = containerRef.current;
- if (!container) return;
- if (!currDocText) return;
-
- const handleClick = (event: MouseEvent) => handleTextClick(
- event,
- currDocText,
- containerRef as RefObject,
- stopAndPlayFromIndex,
- isProcessing
- );
- container.addEventListener('click', handleClick);
- return () => {
- container.removeEventListener('click', handleClick);
- };
- }, [currDocText, handleTextClick, stopAndPlayFromIndex, isProcessing]);
-
useEffect(() => {
/*
* Handles highlighting the current sentence being read by TTS.
@@ -103,7 +55,11 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
* - highlightPattern: Function from context that could change
* - clearHighlights: Function from context that could change
*/
- if (!currDocText) return;
+
+ if (!currDocText || !pdfHighlightEnabled) {
+ clearHighlights();
+ return;
+ }
const highlightTimeout = setTimeout(() => {
if (containerRef.current) {
@@ -115,7 +71,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
clearTimeout(highlightTimeout);
clearHighlights();
};
- }, [currDocText, currentSentence, highlightPattern, clearHighlights]);
+ }, [currDocText, currentSentence, highlightPattern, clearHighlights, pdfHighlightEnabled]);
// Add page dimensions state
const [pageWidth, setPageWidth] = useState(595); // default A4 width
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx
index 8b6f3c3..cb427c6 100644
--- a/src/contexts/ConfigContext.tsx
+++ b/src/contexts/ConfigContext.tsx
@@ -30,6 +30,7 @@ type ConfigValues = {
ttsInstructions: string;
savedVoices: SavedVoices;
smartSentenceSplitting: boolean;
+ pdfHighlightEnabled: boolean;
};
/** Interface defining the configuration context shape and functionality */
@@ -55,6 +56,7 @@ interface ConfigContextType {
updateConfigKey: (key: K, value: ConfigValues[K]) => Promise;
isLoading: boolean;
isDBReady: boolean;
+ pdfHighlightEnabled: boolean;
}
const ConfigContext = createContext(undefined);
@@ -76,14 +78,15 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const [skipBlank, setSkipBlank] = useState(true);
const [epubTheme, setEpubTheme] = useState(false);
const [smartSentenceSplitting, setSmartSentenceSplitting] = useState(true);
- const [headerMargin, setHeaderMargin] = useState(0.07);
- const [footerMargin, setFooterMargin] = useState(0.07);
- const [leftMargin, setLeftMargin] = useState(0.07);
- const [rightMargin, setRightMargin] = useState(0.07);
+ const [headerMargin, setHeaderMargin] = useState(0.0);
+ const [footerMargin, setFooterMargin] = useState(0.0);
+ const [leftMargin, setLeftMargin] = useState(0.0);
+ const [rightMargin, setRightMargin] = useState(0.0);
const [ttsProvider, setTTSProvider] = useState('custom-openai');
const [ttsModel, setTTSModel] = useState('kokoro');
const [ttsInstructions, setTTSInstructions] = useState('');
const [savedVoices, setSavedVoices] = useState({});
+ const [pdfHighlightEnabled, setPdfHighlightEnabled] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [isDBReady, setIsDBReady] = useState(false);
@@ -115,6 +118,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const cachedTTSModel = await getItem('ttsModel');
const cachedTTSInstructions = await getItem('ttsInstructions');
const cachedSavedVoices = await getItem('savedVoices');
+ const cachedPdfHighlightEnabled = await getItem('pdfHighlightEnabled');
// Migration logic: infer provider and baseUrl for returning users
let inferredProvider = cachedTTSProvider || '';
@@ -200,6 +204,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const finalModel = cachedTTSModel || (inferredProvider === 'openai' ? 'tts-1' : inferredProvider === 'deepinfra' ? 'hexgrad/Kokoro-82M' : 'kokoro');
setTTSModel(finalModel);
setTTSInstructions(cachedTTSInstructions || '');
+ setPdfHighlightEnabled(cachedPdfHighlightEnabled === 'false' ? false : true);
// Restore voice for current provider-model if available in savedVoices
const voiceKey = getVoiceKey(inferredProvider || 'custom-openai', finalModel);
@@ -219,8 +224,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if (cachedSmartSentenceSplitting === null) {
await setItem('smartSentenceSplitting', 'true');
}
- if (cachedHeaderMargin === null) await setItem('headerMargin', '0.07');
- if (cachedFooterMargin === null) await setItem('footerMargin', '0.07');
+ if (cachedHeaderMargin === null) await setItem('headerMargin', '0.0');
+ if (cachedFooterMargin === null) await setItem('footerMargin', '0.0');
if (cachedLeftMargin === null) await setItem('leftMargin', '0.0');
if (cachedRightMargin === null) await setItem('rightMargin', '0.0');
if (cachedTTSProvider === null && inferredProvider) {
@@ -247,6 +252,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
// Always ensure voice is not stored standalone - only in savedVoices
await removeItem('voice');
+ if (cachedPdfHighlightEnabled === null) {
+ await setItem('pdfHighlightEnabled', 'true');
+ }
+
} catch (error) {
console.error('Error initializing:', error);
} finally {
@@ -379,6 +388,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
case 'ttsInstructions':
setTTSInstructions(value as string);
break;
+ case 'pdfHighlightEnabled':
+ setPdfHighlightEnabled(value as boolean);
+ break;
}
}
} catch (error) {
@@ -411,7 +423,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
updateConfig,
updateConfigKey,
isLoading,
- isDBReady
+ isDBReady,
+ pdfHighlightEnabled
}}>
{children}
diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx
index b54bd43..6a53b8f 100644
--- a/src/contexts/PDFContext.tsx
+++ b/src/contexts/PDFContext.tsx
@@ -62,7 +62,8 @@ interface PDFContextType {
pdfText: string,
containerRef: RefObject,
stopAndPlayFromIndex: (index: number) => void,
- isProcessing: boolean
+ isProcessing: boolean,
+ enableHighlight?: boolean
) => void;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise;
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', onProgress: (progress: number) => void, signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
diff --git a/src/utils/nlp.ts b/src/utils/nlp.ts
index 3727820..13f0167 100644
--- a/src/utils/nlp.ts
+++ b/src/utils/nlp.ts
@@ -7,7 +7,7 @@
import nlp from 'compromise';
-const MAX_BLOCK_LENGTH = 300;
+const MAX_BLOCK_LENGTH = 450;
/**
* Preprocesses text for audio generation by cleaning up various text artifacts
@@ -79,13 +79,8 @@ export const processTextToSentences = (text: string): string[] => {
return [];
}
- if (text.length <= MAX_BLOCK_LENGTH) {
- // Single sentence preprocessing
- const cleanedText = preprocessSentenceForAudio(text);
- return [cleanedText];
- }
-
- // Full text splitting into sentences
+ // Always use the full splitting logic so we consistently respect
+ // sentence boundaries and quoted dialogue, even for shorter texts.
return splitIntoSentences(text);
};
@@ -160,20 +155,18 @@ const countDoubleQuotes = (s: string): number => {
return matches ? matches.length : 0;
};
-const countCurlySingleQuotes = (s: string): number => {
- const matches = s.match(/[‘’]/g);
- return matches ? matches.length : 0;
-};
-
-const countStandaloneStraightSingles = (s: string): number => {
+// Replace the old curly single-quote counter and standalone-straight counter with a unified, context-aware counter
+const countNonApostropheSingleQuotes = (s: string): number => {
let count = 0;
for (let i = 0; i < s.length; i++) {
- if (s[i] === "'") {
+ const ch = s[i];
+ if (ch === "'" || ch === '‘' || ch === '’') {
const prev = i > 0 ? s[i - 1] : '';
const next = i + 1 < s.length ? s[i + 1] : '';
const isPrevAlphaNum = /[A-Za-z0-9]/.test(prev);
const isNextAlphaNum = /[A-Za-z0-9]/.test(next);
- // Count only when not clearly an apostrophe inside a word (e.g., don't)
+ // Treat as a real quote mark only when it's not clearly an apostrophe
+ // between two alphanumeric characters (e.g., don't, WizardLM’s).
if (!(isPrevAlphaNum && isNextAlphaNum)) {
count++;
}
@@ -191,7 +184,10 @@ const mergeQuotedDialogue = (rawSentences: string[]): string[] => {
for (const s of rawSentences) {
const t = s.trim();
const dblCount = countDoubleQuotes(t);
- const singleCount = countCurlySingleQuotes(t) + countStandaloneStraightSingles(t);
+ // Use the new context-aware single-quote counter so curly apostrophes
+ // inside words don't incorrectly toggle quote state and merge large
+ // regions of plain prose into one block.
+ const singleCount = countNonApostropheSingleQuotes(t);
if (insideDouble || insideSingle) {
buffer = buffer ? `${buffer} ${t}` : t;
diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts
index 9f717ae..3ae3a88 100644
--- a/src/utils/pdf.ts
+++ b/src/utils/pdf.ts
@@ -5,7 +5,76 @@ import "core-js/proposals/promise-with-resolvers";
import { processTextToSentences } from '@/utils/nlp';
import { CmpStr } from 'cmpstr';
-const cmp = CmpStr.create().setMetric( 'levenshtein' ).setFlags( 'i' );
+const cmp = CmpStr.create().setMetric( 'dice' ).setFlags( 'itw' );
+
+// Worker coordination for offloading highlight token matching
+interface HighlightTokenMatchRequest {
+ id: string;
+ type: 'tokenMatch';
+ pattern: 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('pdfHighlightWorker.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(
+ pattern: 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',
+ pattern,
+ tokenTexts,
+ };
+ worker.postMessage(message);
+ });
+}
// Function to detect if we need to use legacy build
function shouldUseLegacyBuild() {
@@ -202,6 +271,14 @@ export function clearHighlights() {
element.style.backgroundColor = '';
element.style.opacity = '1';
});
+
+ const overlays = document.querySelectorAll('.pdf-text-highlight-overlay');
+ overlays.forEach((node) => {
+ const element = node as HTMLElement;
+ if (element.parentElement) {
+ element.parentElement.removeChild(element);
+ }
+ });
}
export function findBestTextMatch(
@@ -256,55 +333,268 @@ export function highlightPattern(
clearHighlights();
if (!pattern?.trim()) return;
-
- const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
const container = containerRef.current;
if (!container) return;
- const textNodes = container.querySelectorAll('.react-pdf__Page__textContent span');
- const allText = Array.from(textNodes).map((node) => ({
- element: node as HTMLElement,
- text: (node.textContent || '').trim(),
- })).filter((node) => node.text.length > 0);
+ const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
+ if (!cleanPattern) return;
- const containerRect = container.getBoundingClientRect();
- const visibleTop = container.scrollTop;
- const visibleBottom = visibleTop + containerRect.height;
- const bufferSize = containerRect.height;
+ const spanNodes = Array.from(
+ container.querySelectorAll('.react-pdf__Page__textContent span')
+ ) as HTMLElement[];
- const visibleNodes = allText.filter(({ element }) => {
- const rect = element.getBoundingClientRect();
- const elementTop = rect.top - containerRect.top + container.scrollTop;
- return elementTop >= (visibleTop - bufferSize) && elementTop <= (visibleBottom + bufferSize);
+ if (!spanNodes.length) return;
+
+ type Token = {
+ spanIndex: number;
+ textNode: Text;
+ text: string;
+ startOffset: number;
+ endOffset: number;
+ };
+
+ const tokens: Token[] = [];
+
+ 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 || '';
+ const wordRegex = /\S+/g;
+ let match: RegExpExecArray | null;
+
+ while ((match = wordRegex.exec(textContent)) !== null) {
+ const word = match[0];
+ tokens.push({
+ spanIndex,
+ textNode,
+ text: word,
+ startOffset: match.index,
+ endOffset: match.index + word.length,
+ });
+ }
});
- let bestMatch = findBestTextMatch(visibleNodes, cleanPattern, cleanPattern.length * 2);
+ if (!tokens.length) return;
- if (bestMatch.rating < 0.3) {
- bestMatch = findBestTextMatch(allText, cleanPattern, cleanPattern.length * 2);
- }
+ const patternLen = cleanPattern.length;
- const similarityThreshold = bestMatch.lengthDiff < cleanPattern.length * 0.3 ? 0.3 : 0.5;
+ // 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;
+ }> = [];
- if (bestMatch.rating >= similarityThreshold) {
- bestMatch.elements.forEach((element) => {
- element.style.backgroundColor = 'grey';
- element.style.opacity = '0.4';
- });
+ let bestStart = -1;
+ let bestEnd = -1;
+ let bestRating = 0;
+ let bestLengthDiff = Infinity;
- if (bestMatch.elements.length > 0) {
- const element = bestMatch.elements[0];
- const elementRect = element.getBoundingClientRect();
- const elementTop = elementRect.top - containerRect.top + container.scrollTop;
+ if (tokenMatch) {
+ bestStart = tokenMatch.bestStart;
+ bestEnd = tokenMatch.bestEnd;
+ bestRating = tokenMatch.rating;
+ bestLengthDiff = tokenMatch.lengthDiff;
+ }
- if (elementTop < visibleTop || elementTop > visibleBottom) {
- container.scrollTo({
- top: elementTop - containerRect.height / 3,
- behavior: 'smooth',
+ const hasTokenMatch = bestStart !== -1;
+ const similarityThreshold =
+ bestLengthDiff < patternLen * 0.3 ? 0.3 : 0.5;
+
+ if (hasTokenMatch && bestRating >= similarityThreshold) {
+ 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,
+ });
+ });
+ }
+
+ // Fallback: if token-level matching failed, use span-based fuzzy matching
+ if (!highlightRanges.length) {
+ const spanEntries = spanNodes
+ .map((node) => ({
+ element: node as HTMLElement,
+ text: (node.textContent || '').trim(),
+ }))
+ .filter((entry) => entry.text.length > 0);
+
+ const containerRect = container.getBoundingClientRect();
+ const visibleTop = container.scrollTop;
+ const visibleBottom = visibleTop + containerRect.height;
+ const bufferSize = containerRect.height;
+
+ const visibleNodes = spanEntries.filter(({ element }) => {
+ const rect = element.getBoundingClientRect();
+ const elementTop =
+ rect.top - containerRect.top + container.scrollTop;
+ return (
+ elementTop >= visibleTop - bufferSize &&
+ elementTop <= visibleBottom + bufferSize
+ );
+ });
+
+ let bestMatch = findBestTextMatch(
+ visibleNodes,
+ cleanPattern,
+ cleanPattern.length * 2
+ );
+
+ if (bestMatch.rating < 0.3) {
+ bestMatch = findBestTextMatch(
+ spanEntries,
+ cleanPattern,
+ cleanPattern.length * 2
+ );
+ }
+
+ const spanSimilarityThreshold =
+ bestMatch.lengthDiff < cleanPattern.length * 0.3 ? 0.3 : 0.5;
+
+ if (bestMatch.rating >= spanSimilarityThreshold) {
+ bestMatch.elements.forEach((element) => {
+ const node = element.firstChild;
+ if (!node || node.nodeType !== Node.TEXT_NODE) return;
+ const textNode = node as Text;
+ const content = textNode.textContent || '';
+ if (!content) return;
+
+ highlightRanges.push({
+ textNode,
+ startOffset: 0,
+ endOffset: content.length,
+ span: element,
+ });
});
}
}
- }
+
+ 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 pageLayer = span.closest(
+ '.react-pdf__Page__textContent'
+ ) as HTMLElement | null;
+ if (!pageLayer) return;
+
+ const pageRect = pageLayer.getBoundingClientRect();
+ 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.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`;
+ pageLayer.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);
+
+ // Fire-and-forget async worker call; UI thread returns immediately
+ runHighlightTokenMatch(cleanPattern, tokenTexts)
+ .then((result) => {
+ if (!result || result.bestStart === -1) {
+ // No worker result or no good match; rely on span-level fallback
+ applyHighlightFromTokens(null);
+ } else {
+ applyHighlightFromTokens({
+ bestStart: result.bestStart,
+ bestEnd: result.bestEnd,
+ rating: result.rating,
+ lengthDiff: result.lengthDiff,
+ });
+ }
+ })
+ .catch((error) => {
+ console.error(
+ 'Error in PDF highlight worker, falling back to span-based matching:',
+ error
+ );
+ applyHighlightFromTokens(null);
+ });
}
// Text Click Handler
@@ -313,7 +603,8 @@ export function handleTextClick(
pdfText: string,
containerRef: React.RefObject,
stopAndPlayFromIndex: (index: number) => void,
- isProcessing: boolean
+ isProcessing: boolean,
+ enableHighlight = true
) {
if (isProcessing) return;
@@ -337,6 +628,26 @@ export function handleTextClick(
if (!contextText?.trim()) return;
const cleanContext = contextText.trim().replace(/\s+/g, ' ');
+
+ // Fast path when highlight overlays are disabled:
+ // avoid expensive span-level fuzzy matching and just map
+ // the clicked context to a sentence using cheap string checks.
+ if (!enableHighlight) {
+ const sentences = processTextToSentences(pdfText);
+ const idx = sentences.findIndex((sentence) => {
+ const cleanSentence = sentence.trim().replace(/\s+/g, ' ');
+ return (
+ cleanSentence.includes(cleanContext) ||
+ cleanContext.includes(cleanSentence)
+ );
+ });
+
+ if (idx !== -1) {
+ stopAndPlayFromIndex(idx);
+ }
+ return;
+ }
+
const allText = Array.from(parentElement.querySelectorAll('span')).map((node) => ({
element: node as HTMLElement,
text: (node.textContent || '').trim(),
@@ -363,7 +674,9 @@ export function handleTextClick(
const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence);
if (sentenceIndex !== -1) {
stopAndPlayFromIndex(sentenceIndex);
- highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef);
+ if (enableHighlight) {
+ highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef);
+ }
}
}
}
diff --git a/src/utils/pdfHighlightWorker.ts b/src/utils/pdfHighlightWorker.ts
new file mode 100644
index 0000000..ac00223
--- /dev/null
+++ b/src/utils/pdfHighlightWorker.ts
@@ -0,0 +1,135 @@
+///
+
+import { CmpStr } from 'cmpstr';
+
+const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
+
+interface TokenMatchRequest {
+ id: string;
+ type: 'tokenMatch';
+ pattern: string;
+ tokenTexts: string[];
+}
+
+interface TokenMatchResponse {
+ id: string;
+ type: 'tokenMatchResult';
+ bestStart: number;
+ bestEnd: number;
+ rating: number;
+ lengthDiff: number;
+}
+
+self.onmessage = (event: MessageEvent) => {
+ const data = event.data;
+ if (!data || data.type !== 'tokenMatch') return;
+
+ const { id, pattern, tokenTexts } = data;
+
+ const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
+ const patternLen = cleanPattern.length;
+
+ const responseBase: TokenMatchResponse = {
+ id,
+ type: 'tokenMatchResult',
+ bestStart: -1,
+ bestEnd: -1,
+ rating: 0,
+ lengthDiff: Number.POSITIVE_INFINITY,
+ };
+
+ if (!patternLen || !tokenTexts.length) {
+ (self as unknown as DedicatedWorkerGlobalScope).postMessage(responseBase);
+ return;
+ }
+
+ const patternTokens = cleanPattern.split(' ').filter(Boolean);
+ const patternTokenCount = patternTokens.length || 1;
+
+ const minWindowTokens = Math.max(1, Math.floor(patternTokenCount * 0.6));
+ const maxWindowTokens = Math.max(
+ minWindowTokens,
+ Math.ceil(patternTokenCount * 1.4)
+ );
+
+ let bestStart = -1;
+ let bestEnd = -1;
+ let bestRating = 0;
+ let bestLengthDiff = Number.POSITIVE_INFINITY;
+
+ for (let start = 0; start < tokenTexts.length; start++) {
+ let combined = '';
+
+ for (
+ let offset = 0;
+ offset < maxWindowTokens && start + offset < tokenTexts.length;
+ offset++
+ ) {
+ const token = tokenTexts[start + offset];
+ combined = combined ? `${combined} ${token}` : token;
+
+ const windowSize = offset + 1;
+ if (windowSize < minWindowTokens) continue;
+ if (combined.length > patternLen * 2) break;
+
+ const similarity = cmp.compare(combined, cleanPattern);
+ const lengthDiff = Math.abs(combined.length - patternLen);
+ const lengthPenalty = lengthDiff / patternLen;
+ const adjustedRating = similarity * (1 - lengthPenalty * 0.3);
+
+ // Prefix-alignment boost:
+ // Favour windows whose first few tokens closely match the beginning
+ // of the pattern, so we are less likely to cut off the first 1–2 words.
+ let boostedRating = adjustedRating;
+ if (patternTokens.length > 0) {
+ const windowTokens = tokenTexts.slice(start, start + windowSize);
+ const maxPrefixCheck = Math.min(
+ windowTokens.length,
+ patternTokens.length,
+ 5
+ );
+
+ let prefixMatches = 0;
+ for (let i = 0; i < maxPrefixCheck; i++) {
+ const tokenText = windowTokens[i];
+ const patternToken = patternTokens[i];
+ // Require reasonably strong similarity for a prefix match
+ const tokenSim = cmp.compare(tokenText, patternToken);
+ if (tokenSim >= 0.8) {
+ prefixMatches++;
+ } else {
+ // Stop at the first non-matching leading token
+ break;
+ }
+ }
+
+ if (prefixMatches > 0) {
+ const prefixRatio = prefixMatches / maxPrefixCheck;
+ const PREFIX_BOOST_FACTOR = 0.25; // up to +25% boost
+ boostedRating = adjustedRating * (1 + prefixRatio * PREFIX_BOOST_FACTOR);
+ }
+ }
+
+ if (
+ boostedRating > bestRating ||
+ (Math.abs(boostedRating - bestRating) < 1e-3 &&
+ lengthDiff < bestLengthDiff)
+ ) {
+ bestRating = boostedRating;
+ bestLengthDiff = lengthDiff;
+ bestStart = start;
+ bestEnd = start + offset;
+ }
+ }
+ }
+
+ const response: TokenMatchResponse = {
+ ...responseBase,
+ bestStart,
+ bestEnd,
+ rating: bestRating,
+ lengthDiff: bestLengthDiff,
+ };
+
+ (self as unknown as DedicatedWorkerGlobalScope).postMessage(response);
+};
\ No newline at end of file