diff --git a/apps/readest-app/src/__tests__/document/pdf-tts.test.ts b/apps/readest-app/src/__tests__/document/pdf-tts.test.ts index 8856f522..b19393f7 100644 --- a/apps/readest-app/src/__tests__/document/pdf-tts.test.ts +++ b/apps/readest-app/src/__tests__/document/pdf-tts.test.ts @@ -19,10 +19,16 @@ const highlight = vi.fn(); /** * Build a document that mimics a rendered PDF page with text layer, * matching the structure that pdf.js produces in the iframe. + * When withLineBreaks is true, inserts
between spans (matching real PDF.js output). */ -const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Document => { +const createPDFTextLayerDoc = ( + textSpans: string[], + annotationText?: string, + withLineBreaks?: boolean, +): Document => { const parser = new DOMParser(); - const spans = textSpans.map((t) => `${t}`).join(''); + const separator = withLineBreaks ? '
' : ''; + const spans = textSpans.map((t) => `${t}`).join(separator); const annotation = annotationText ? `
${annotationText}
` : '
'; @@ -38,7 +44,7 @@ const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Do /** Node filter matching what TTSController uses for PDFs */ const pdfNodeFilter = createRejectFilter({ - tags: ['rt', 'canvas'], + tags: ['rt', 'canvas', 'br'], classes: ['annotationLayer'], contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }], }); @@ -101,6 +107,183 @@ describe('PDF TTS', () => { }); }); + describe('TTS with PDF line breaks (br elements)', () => { + it('should not produce SSML break elements for br tags in PDF text layer', () => { + const doc = createPDFTextLayerDoc( + ['Alice was beginning to get very ', 'tired of sitting by her sister '], + undefined, + true, + ); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + // The SSML should NOT contain elements for PDF line breaks + expect(ssml).not.toMatch(//); + // But the text should still be continuous + const text = stripTags(ssml!); + expect(text).toContain('Alice'); + expect(text).toContain('tired'); + }); + + it('should read through PDF line breaks without interruption', () => { + const doc = createPDFTextLayerDoc( + [ + 'This is the first line of a paragraph ', + 'and this continues on the second line ', + 'ending on the third line.', + ], + undefined, + true, + ); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + const text = stripTags(ssml!); + // All text should be in a single block without breaks + expect(text).toContain('first line'); + expect(text).toContain('second line'); + expect(text).toContain('third line'); + // No SSML break elements + expect(ssml).not.toMatch(//); + }); + }); + + describe('PDF sentence-level block splitting', () => { + it('should split multiple sentences into separate TTS blocks', () => { + const doc = createPDFTextLayerDoc([ + 'Alice was beginning to get very tired. ', + 'She had nothing to do. ', + 'The day was warm and sunny.', + ]); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const blocks: string[] = []; + let ssml = tts.start(); + while (ssml) { + blocks.push(stripTags(ssml)); + ssml = tts.next(); + } + + // Each sentence should be its own block + expect(blocks.length).toBe(3); + expect(blocks[0]).toContain('Alice was beginning'); + expect(blocks[1]).toContain('She had nothing'); + expect(blocks[2]).toContain('The day was warm'); + }); + + it('should handle sentences that span across multiple spans', () => { + const doc = createPDFTextLayerDoc([ + 'Alice was beginning to get very ', + 'tired of sitting by her sister. She had ', + 'nothing to do.', + ]); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const blocks: string[] = []; + let ssml = tts.start(); + while (ssml) { + blocks.push(stripTags(ssml)); + ssml = tts.next(); + } + + expect(blocks.length).toBe(2); + expect(blocks[0]).toContain('tired of sitting'); + expect(blocks[1]).toContain('nothing to do'); + }); + + it('should handle a single sentence as one block', () => { + const doc = createPDFTextLayerDoc(['Just one sentence here.']); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const blocks: string[] = []; + let ssml = tts.start(); + while (ssml) { + blocks.push(stripTags(ssml)); + ssml = tts.next(); + } + + expect(blocks.length).toBe(1); + expect(blocks[0]).toContain('Just one sentence here'); + }); + + it('should handle sentences with br elements between lines', () => { + const doc = createPDFTextLayerDoc( + [ + 'First sentence on line one. ', + 'Second sentence starts here ', + 'and continues on line three.', + ], + undefined, + true, + ); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const blocks: string[] = []; + let ssml = tts.start(); + while (ssml) { + const text = stripTags(ssml); + blocks.push(text); + // No break elements in any block + expect(ssml).not.toMatch(//); + ssml = tts.next(); + } + + expect(blocks.length).toBe(2); + expect(blocks[0]).toContain('First sentence'); + expect(blocks[1]).toContain('Second sentence'); + expect(blocks[1]).toContain('line three'); + }); + + it('should produce word marks within each sentence block', () => { + const doc = createPDFTextLayerDoc(['Hello world. Goodbye world.']); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const ssml = tts.start(); + expect(ssml).toBeTruthy(); + expect(ssml).toContain(' { + // Sentence boundary falls in the middle of span 2: + // "Alice was beginning to get " + "very tired. She had nothing " + "to do." + // Sentence 1: "Alice was beginning to get very tired. " + // Sentence 2: "She had nothing to do." + const doc = createPDFTextLayerDoc([ + 'Alice was beginning to get ', + 'very tired. She had nothing ', + 'to do.', + ]); + const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word'); + + const ssml1 = tts.start(); + expect(ssml1).toBeTruthy(); + const text1 = stripTags(ssml1!); + // First block must contain ONLY sentence 1 words + expect(text1).toContain('Alice'); + expect(text1).toContain('tired'); + expect(text1).not.toContain('She'); + expect(text1).not.toContain('nothing'); + + const ssml2 = tts.next(); + expect(ssml2).toBeTruthy(); + const text2 = stripTags(ssml2!); + // Second block must contain ONLY sentence 2 words + expect(text2).toContain('She'); + expect(text2).toContain('nothing'); + expect(text2).not.toContain('Alice'); + expect(text2).not.toContain('tired'); + }); + }); + describe('PDF node filter', () => { it('should reject canvas elements', () => { const canvas = document.createElement('canvas'); diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index 22cf5dca..bb179293 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -194,13 +194,14 @@ export class TTSController extends EventTarget { doc, textWalker, createRejectFilter({ - tags: ['rt'], + tags: ['rt', 'canvas', 'br'], + classes: ['annotationLayer'], contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }], }), this.#getHighlighter(), granularity, ); - console.log(`Initialized TTS for section ${sectionIndex}`); + console.log(`[TTS] Initialized TTS for section ${sectionIndex}`); return true; } @@ -314,7 +315,7 @@ export class TTSController extends EventTarget { this.#currentSpeakPromise = new Promise(async (resolve, reject) => { try { - console.log('TTS speak'); + console.log('[TTS] speak'); this.state = 'playing'; signal.addEventListener('abort', () => { @@ -333,7 +334,7 @@ export class TTSController extends EventTarget { await this.stop(); } } - console.log('no SSML, skipping for', this.#nossmlCnt); + console.log('[TTS] no SSML, skipping for', this.#nossmlCnt); return; } else { this.#nossmlCnt = 0; diff --git a/packages/foliate-js b/packages/foliate-js index d3baa52d..ec7e16aa 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit d3baa52d05047b2aa02779294f02a81fac855188 +Subproject commit ec7e16aa483429701c7f4f19ab909ecae79dfd58