diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index cfcd958..0a20958 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -152,7 +152,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { return; } - if (currentWordIndex === null || currentWordIndex === undefined || currentWordIndex < 0) { + if (!currentSentence || currentWordIndex === null || currentWordIndex === undefined || currentWordIndex < 0) { clearWordHighlights(); return; } diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 2c03d1e..1644c77 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -37,7 +37,7 @@ import { useAudioContext } from '@/hooks/audio/useAudioContext'; import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry, generateTTS, alignAudio } from '@/lib/client'; -import { preprocessSentenceForAudio, splitTextToTtsBlocks } from '@/lib/nlp'; +import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; import { isKokoroModel } from '@/utils/voice'; import type { TTSLocation, @@ -385,8 +385,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } // Use the shared utility directly instead of making an API call - return splitTextToTtsBlocks(text); - }, []); + return isEPUB ? splitTextToTtsBlocksEPUB(text) : splitTextToTtsBlocks(text); + }, [isEPUB]); /** * Stops the current audio playback and clears the active Howl instance diff --git a/src/lib/nlp.ts b/src/lib/nlp.ts index 69cfe99..89f0c5e 100644 --- a/src/lib/nlp.ts +++ b/src/lib/nlp.ts @@ -7,7 +7,7 @@ import nlp from 'compromise'; -const MAX_BLOCK_LENGTH = 450; +export const MAX_BLOCK_LENGTH = 450; const splitOversizedText = (text: string, maxLen: number): string[] => { const normalized = text.replace(/\s+/g, ' ').trim(); @@ -182,6 +182,46 @@ export const splitTextToTtsBlocks = (text: string): string[] => { return blocks; }; +/** + * EPUB block splitting used where we want the produced sentences + * to closely match the original DOM text (for exact-match highlighting). + */ +export const splitTextToTtsBlocksEPUB = (text: string): string[] => { + const paragraphs = text.split(/\n+/); + const blocks: string[] = []; + + for (const paragraph of paragraphs) { + if (!paragraph.trim()) continue; + + const cleanedText = preprocessSentenceForAudio(paragraph); + const doc = nlp(cleanedText); + const rawSentences = doc.sentences().out('array') as string[]; + + const mergedSentences = mergeQuotedDialogue(rawSentences); + + let currentBlock = ''; + + for (const sentence of mergedSentences) { + const trimmedSentence = sentence.trim(); + + if (currentBlock && (currentBlock.length + trimmedSentence.length + 1) > MAX_BLOCK_LENGTH) { + blocks.push(currentBlock.trim()); + currentBlock = trimmedSentence; + } else { + currentBlock = currentBlock + ? `${currentBlock} ${trimmedSentence}` + : trimmedSentence; + } + } + + if (currentBlock) { + blocks.push(currentBlock.trim()); + } + } + + return blocks; +}; + /** * Normalizes text for single-shot TTS generation (e.g., a whole PDF page). * Uses the same logic as `splitTextToTtsBlocks`, but returns a single string. diff --git a/tests/unit/nlp.spec.ts b/tests/unit/nlp.spec.ts index 14dbd86..ada924f 100644 --- a/tests/unit/nlp.spec.ts +++ b/tests/unit/nlp.spec.ts @@ -2,150 +2,214 @@ import { test, expect } from '@playwright/test'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, - processTextWithMapping + splitTextToTtsBlocksEPUB, + normalizeTextForTts, + extractRawSentences, + processTextWithMapping, + MAX_BLOCK_LENGTH } from '../../src/lib/nlp'; +const PDF_MAX_BLOCK_LENGTH = MAX_BLOCK_LENGTH * 2; // splitTextToTtsBlocks can overflow to reach punctuation + +const expectNormalizedBlocks = (blocks: string[], maxLen = Number.POSITIVE_INFINITY) => { + for (const block of blocks) { + expect(block.trim().length).toBeGreaterThan(0); + expect(block.length).toBeLessThanOrEqual(maxLen); + expect(block).not.toMatch(/\n/); + expect(block).not.toMatch(/\s{2,}/); + } +}; + test.describe('preprocessSentenceForAudio', () => { - test('removes URLs', () => { - const input = 'Check out https://example.com/page for more info'; - const expected = 'Check out - (link to example.com) - for more info'; - expect(preprocessSentenceForAudio(input)).toBe(expected); - }); + test('normalizes common extraction artifacts', () => { + const cases: Array<{ input: string; expected: string }> = [ + { + input: 'Check out https://example.com/page for more info', + expected: 'Check out - (link to example.com) - for more info', + }, + { + input: 'This is a hyp- henated word', + expected: 'This is a hyphenated word', + }, + { + input: 'This is *bold* text', + expected: 'This is bold text', + }, + { + input: 'Multiple spaces', + expected: 'Multiple spaces', + }, + ]; - test('removes hyphenation', () => { - const input = 'This is a hyp- henated word'; - const expected = 'This is a hyphenated word'; - expect(preprocessSentenceForAudio(input)).toBe(expected); - }); - - test('removes asterisks', () => { - const input = 'This is *bold* text'; - const expected = 'This is bold text'; - expect(preprocessSentenceForAudio(input)).toBe(expected); - }); - - test('collapses whitespace', () => { - const input = 'Multiple spaces'; - const expected = 'Multiple spaces'; - expect(preprocessSentenceForAudio(input)).toBe(expected); + for (const { input, expected } of cases) { + expect(preprocessSentenceForAudio(input)).toBe(expected); + } }); }); -test.describe('splitTextToTtsBlocks', () => { - test('groups short sentences into single block', () => { - const input = 'First sentence. Second sentence.'; - const result = splitTextToTtsBlocks(input); - expect(result).toHaveLength(1); - expect(result[0]).toBe('First sentence. Second sentence.'); - }); - - test('merges quoted dialogue (double quotes)', () => { - const input = 'He said, "This should be one block." and walked away.'; - const result = splitTextToTtsBlocks(input); - expect(result).toHaveLength(1); - expect(result[0]).toBe('He said, "This should be one block." and walked away.'); - }); - - test('merges quoted dialogue (curly quotes)', () => { - const input = 'She replied, “This also should be merged.” then smiled.'; - const result = splitTextToTtsBlocks(input); - expect(result).toHaveLength(1); - expect(result[0]).toBe('She replied, “This also should be merged.” then smiled.'); - }); - - test('respects max block length for long text', () => { - // MAX_BLOCK_LENGTH is 450 in nlp.ts - // We construct distinct sentences. - // If we make sentences short enough individually but long enough combined, - // they should be grouped until the limit is reached. - - const sentence = 'A'.repeat(100) + '.'; // 101 chars - // 4 sentences = 404 chars + 3 spaces = 407 chars (< 450). Should fit in one block. - // 5 sentences = 505 chars + 4 spaces = 509 chars (> 450). Should split. - - const input = Array(5).fill(sentence).join(' '); - const result = splitTextToTtsBlocks(input); - - expect(result.length).toBeGreaterThan(1); - // The first block should contain as many as possible - expect(result[0].length).toBeLessThanOrEqual(450); - }); - - test('splits a single oversized sentence into multiple blocks', () => { - // Some PDF pages (e.g. research papers) can extract into one massive "sentence" - // with few or no punctuation marks; we still must respect MAX_BLOCK_LENGTH. - const input = Array(1200).fill('word').join(' '); // no punctuation - const result = splitTextToTtsBlocks(input); - expect(result.length).toBeGreaterThan(1); - for (const block of result) { - expect(block.length).toBeGreaterThan(0); - expect(block.length).toBeLessThanOrEqual(450); - } - }); - - test('splits extremely long unbroken tokens', () => { - const input = 'A'.repeat(1200); // no spaces, no punctuation - const result = splitTextToTtsBlocks(input); - expect(result.length).toBeGreaterThan(1); - for (const block of result) { - expect(block.length).toBeGreaterThan(0); - expect(block.length).toBeLessThanOrEqual(450); - } - }); - - test('prefers sentence punctuation when chunking long PDF-like text', () => { - const sentences = Array.from({ length: 80 }, (_, i) => - `Sentence ${i} has some filler words to keep the length varying slightly number ${i}.` - ); - // Simulate a common PDF extraction artifact: no whitespace after '.' before the next sentence. - const input = sentences.join(''); - const result = splitTextToTtsBlocks(input); - expect(result.length).toBeGreaterThan(1); - for (const block of result) { - expect(block.length).toBeGreaterThan(0); - expect(block.length).toBeLessThanOrEqual(450); - expect(block).toMatch(/[.!?]$/); - } +test.describe('splitTextToTtsBlocks (PDF-oriented)', () => { + test('returns [] for empty input', () => { + expect(splitTextToTtsBlocks('')).toEqual([]); + expect(splitTextToTtsBlocks(' ')).toEqual([]); + expect(splitTextToTtsBlocks('\n\n')).toEqual([]); }); test('does not treat single newlines as paragraph boundaries', () => { - // Many PDFs contain hard-wrapped lines; we should not break blocks/sentences - // just because of a newline. const input = 'The first line ends with a comma,\n' + 'but the sentence continues on the next line and ends here.\n' + 'And this is the second sentence.'; const result = splitTextToTtsBlocks(input); + expect(result).toHaveLength(1); + expectNormalizedBlocks(result, PDF_MAX_BLOCK_LENGTH); expect(result[0]).toBe( 'The first line ends with a comma, but the sentence continues on the next line and ends here. And this is the second sentence.' ); }); - test('allows long sentences to reach their ending punctuation', () => { - const longSentence = - `${'word '.repeat(110)}` + // ~550 chars before period - 'end.' + - ' Next.'; - const result = splitTextToTtsBlocks(longSentence); - // The first block should end at a period, not be cut mid-sentence at a space boundary. + test('treats blank lines (double newlines) as paragraph boundaries', () => { + const input = 'First paragraph.\n\nSecond paragraph.'; + const result = splitTextToTtsBlocks(input); + + expect(result.length).toBeGreaterThanOrEqual(2); + expectNormalizedBlocks(result, PDF_MAX_BLOCK_LENGTH); + }); + + test('repairs missing whitespace between sentences (common PDF artifact)', () => { + const input = 'This ends.Here starts.'; + const normalized = normalizeTextForTts(input); + expect(normalized).toContain('ends. Here'); + }); + + test('does not break decimals when repairing sentence boundaries', () => { + const input = 'Pi is 3.14.Next sentence.'; + const normalized = normalizeTextForTts(input); + expect(normalized).toContain('3.14'); + }); + + test('enforces max block length on long content', () => { + const sentence = `${'A'.repeat(100)}.`; // 101 chars + const input = Array(8).fill(sentence).join(' '); // guaranteed to exceed MAX_BLOCK_LENGTH + const result = splitTextToTtsBlocks(input); + expect(result.length).toBeGreaterThan(1); - expect(result[0].endsWith('.')).toBe(true); - expect(result[0].includes('end.')).toBe(true); + expectNormalizedBlocks(result, MAX_BLOCK_LENGTH); + }); + + test('splits oversized content with no punctuation', () => { + const input = Array(1200).fill('word').join(' '); + const result = splitTextToTtsBlocks(input); + + expect(result.length).toBeGreaterThan(1); + expectNormalizedBlocks(result, MAX_BLOCK_LENGTH); + }); + + test('splits extremely long unbroken tokens', () => { + const input = 'A'.repeat(1200); + const result = splitTextToTtsBlocks(input); + + expect(result.length).toBeGreaterThan(1); + expectNormalizedBlocks(result, MAX_BLOCK_LENGTH); + }); + + test('prefers sentence punctuation when chunking long PDF-like text', () => { + const sentences = Array.from( + { length: 80 }, + (_, i) => `Sentence ${i} has filler words to vary length slightly number ${i}.` + ); + const input = sentences.join(''); // no whitespace after '.' between sentences + const result = splitTextToTtsBlocks(input); + + expect(result.length).toBeGreaterThan(1); + expectNormalizedBlocks(result, PDF_MAX_BLOCK_LENGTH); + + // When sentence punctuation exists, blocks should usually end at punctuation/closers. + // This guards against regressions where we cut mid-word/mid-sentence too often. + for (const block of result) { + expect(block).toMatch(/[.!?]["'”’)\]]*$/); + } + }); + + test('allows a long sentence to extend to its ending punctuation', () => { + // Create a single sentence that exceeds MAX_BLOCK_LENGTH, but ends with a period + // within the forward-search overflow window. + const input = `${'word '.repeat(110)}end. Next.`; + const result = splitTextToTtsBlocks(input); + + expect(result.length).toBeGreaterThan(1); + // This case is specifically asserting we may exceed MAX_BLOCK_LENGTH to reach punctuation, + // but should still remain bounded by the overflow policy. + expectNormalizedBlocks(result, PDF_MAX_BLOCK_LENGTH); + expect(result[0]).toMatch(/end\.$/); + }); + + test('merges multi-sentence quoted dialogue', () => { + const input = 'He said, "First. Second." Then left.'; + const result = splitTextToTtsBlocks(input); + + expect(result).toHaveLength(1); + expectNormalizedBlocks(result, PDF_MAX_BLOCK_LENGTH); + expect(result[0]).toContain('"First. Second."'); + }); +}); + +test.describe('splitTextToTtsBlocksEPUB (highlight-friendly)', () => { + test('returns [] for empty input', () => { + expect(splitTextToTtsBlocksEPUB('')).toEqual([]); + expect(splitTextToTtsBlocksEPUB(' ')).toEqual([]); + expect(splitTextToTtsBlocksEPUB('\n\n')).toEqual([]); + }); + + test('treats single newlines as paragraph boundaries', () => { + const input = 'One.\nTwo.'; + const result = splitTextToTtsBlocksEPUB(input); + expect(result).toHaveLength(2); + expectNormalizedBlocks(result, MAX_BLOCK_LENGTH); + expect(result[0]).toBe('One.'); + expect(result[1]).toBe('Two.'); + }); +}); + +test.describe('normalizeTextForTts', () => { + test('returns a single normalized string without newlines', () => { + const input = 'Hello.\nWorld.\n\nNext paragraph.'; + const normalized = normalizeTextForTts(input); + expect(normalized).not.toMatch(/\n/); + expect(normalized).not.toMatch(/\s{2,}/); + expect(normalized.length).toBeGreaterThan(0); + }); +}); + +test.describe('extractRawSentences', () => { + test('returns [] for empty input', () => { + expect(extractRawSentences('')).toEqual([]); + }); + + test('returns sentence-like strings without preprocessing', () => { + const input = 'First sentence. Second sentence.'; + const result = extractRawSentences(input); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0]).toContain('First'); }); }); test.describe('processTextWithMapping', () => { - test('maps raw sentences to processed ones', () => { + test('returns mapping entries with valid indices', () => { const text = 'First (1). Second (2).'; const { processedSentences, rawSentences, sentenceMapping } = processTextWithMapping(text); expect(processedSentences.length).toBeGreaterThan(0); expect(rawSentences.length).toBeGreaterThan(0); expect(sentenceMapping).toHaveLength(processedSentences.length); - - // Check structure of mapping - expect(sentenceMapping[0]).toHaveProperty('processedIndex'); - expect(sentenceMapping[0]).toHaveProperty('rawIndices'); + + for (let i = 0; i < sentenceMapping.length; i++) { + const entry = sentenceMapping[i]; + expect(entry.processedIndex).toBe(i); + for (const rawIndex of entry.rawIndices) { + expect(rawIndex).toBeGreaterThanOrEqual(0); + expect(rawIndex).toBeLessThan(rawSentences.length); + } + } }); });