diff --git a/apps/readest-app/src/__tests__/document/tts.test.ts b/apps/readest-app/src/__tests__/document/tts.test.ts new file mode 100644 index 00000000..a92283ce --- /dev/null +++ b/apps/readest-app/src/__tests__/document/tts.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { textWalker } from 'foliate-js/text-walker.js'; +import { TTS } from 'foliate-js/tts.js'; + +beforeAll(() => { + if (typeof CSS === 'undefined' || !CSS.escape) { + Object.defineProperty(globalThis, 'CSS', { + value: { + escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'), + }, + writable: true, + }); + } +}); + +const createHTMLDoc = (bodyHTML: string, attrs: Record = {}): Document => { + const parser = new DOMParser(); + const attrStr = Object.entries(attrs) + .map(([k, v]) => ` ${k}="${v}"`) + .join(''); + const html = `${bodyHTML}`; + return parser.parseFromString(html, 'text/html'); +}; + +const createXHTMLDoc = (bodyHTML: string, attrs: Record = {}): Document => { + const parser = new DOMParser(); + const attrStr = Object.entries(attrs) + .map(([k, v]) => ` ${k}="${v}"`) + .join(''); + const xml = + `` + + `${bodyHTML}`; + return parser.parseFromString(xml, 'application/xhtml+xml'); +}; + +const createPlainHTMLDoc = (html: string): Document => { + const parser = new DOMParser(); + return parser.parseFromString(html, 'text/html'); +}; + +/** Strip all XML tags to get plain text content from SSML */ +const stripTags = (ssml: string): string => ssml.replace(/<[^>]+\/?>/g, '').trim(); + +const highlight = vi.fn(); + +describe('TTS', () => { + describe('plain HTML document', () => { + it('should init and generate SSML for a plain HTML doc without doctype or lang', () => { + const doc = createPlainHTMLDoc(`

Hello world

`); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toBe('Hello world'); + }); + }); + + describe('document with lang attribute', () => { + it('should init and generate SSML with lang on html element', () => { + const doc = createHTMLDoc('

Hello world

', { lang: 'en' }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toBe('Hello world'); + }); + + it('should init and generate SSML with xml:lang on XHTML element', () => { + const doc = createXHTMLDoc('

Hello world

', { + xmlns: 'http://www.w3.org/1999/xhtml', + 'xml:lang': 'en', + }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toBe('Hello world'); + }); + + it('should propagate lang into SSML speak element', () => { + const doc = createHTMLDoc('

Hello world

', { lang: 'fr' }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(ssml).toContain('xml:lang="fr"'); + }); + }); + + describe('document without lang or xml:lang', () => { + it('should init and generate SSML for HTML doc without any lang', () => { + const doc = createHTMLDoc('

Hello world

'); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toBe('Hello world'); + }); + + it('should init and generate SSML for XHTML doc with xmlns but no lang', () => { + const doc = createXHTMLDoc('

Hello world

', { + xmlns: 'http://www.w3.org/1999/xhtml', + }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toBe('Hello world'); + }); + + it('should navigate with next() on doc without lang', () => { + const doc = createHTMLDoc('

First paragraph

Second paragraph

'); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml1 = tts.start(); + const ssml2 = tts.next(); + + expect(ssml1).toBeTruthy(); + expect(stripTags(ssml1!)).toContain('First paragraph'); + if (ssml2) { + expect(stripTags(ssml2)).toContain('Second paragraph'); + } + }); + + it('should generate SSML with sentence granularity on doc without lang', () => { + const doc = createHTMLDoc('

First sentence. Second sentence.

'); + const tts = new TTS(doc, textWalker, undefined, highlight, 'sentence'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toContain('First sentence'); + }); + + it('should not include xml:lang on speak element when doc has no lang', () => { + const doc = createHTMLDoc('

No lang content

'); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + const speakMatch = ssml!.match(/]*>/); + expect(speakMatch).toBeTruthy(); + expect(speakMatch![0]).not.toContain('xml:lang'); + }); + }); + + describe('document without xmlns namespace declarations', () => { + it('should init and generate SSML for XHTML doc without xmlns', () => { + // XHTML without xmlns="http://www.w3.org/1999/xhtml" causes doc.body to be null + const doc = createXHTMLDoc('

Hello world

'); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toBe('Hello world'); + }); + + it('should init and generate SSML for XHTML doc without xmlns but with xml:lang', () => { + const doc = createXHTMLDoc('

Hello world

', { + 'xml:lang': 'en', + }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toBe('Hello world'); + }); + + it('should init and generate SSML for XHTML doc without epub namespace', () => { + // Missing xmlns:epub="http://www.idpf.org/2007/ops" should not prevent TTS + const doc = createXHTMLDoc('

Hello world

', { + xmlns: 'http://www.w3.org/1999/xhtml', + }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toBe('Hello world'); + }); + }); + + describe('document without both lang and xmlns', () => { + it('should init and generate SSML for bare XHTML doc', () => { + // No xmlns and no lang: both issues combined + const doc = createXHTMLDoc('

Bare document content

'); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(stripTags(ssml!)).toBe('Bare document content'); + }); + + it('should handle multiple blocks in bare XHTML doc', () => { + const doc = createXHTMLDoc('

First block

Second block

Third block

'); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml1 = tts.start(); + expect(ssml1).toBeTruthy(); + + const ssml2 = tts.next(); + if (ssml2) { + expect(ssml2).toBeTruthy(); + } + }); + }); + + describe('SSML output correctness', () => { + it('should produce valid SSML with speak root element', () => { + const doc = createHTMLDoc('

Test content

', { lang: 'en' }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(ssml).toContain(''); + }); + + it('should include mark elements in SSML output', () => { + const doc = createHTMLDoc('

Some text with words

', { lang: 'en' }); + const tts = new TTS(doc, textWalker, undefined, highlight, 'word'); + const ssml = tts.start(); + + expect(ssml).toBeTruthy(); + expect(ssml).toContain(' + `${body}`; + +// SSML without xml:lang on speak element (output from TTS when document has no lang) +const ssmlNoLang = (body: string) => + `${body}`; + +describe('parseSSMLLang', () => { + it('should extract lang from xml:lang attribute', () => { + const ssml = ssmlWithLang('en', 'Hello'); + expect(parseSSMLLang(ssml)).toBe('en'); + }); + + it('should normalize lang case (lowercase-UPPERCASE)', () => { + const ssml = ssmlWithLang('en-us', 'Hello'); + expect(parseSSMLLang(ssml)).toBe('en-US'); + }); + + it('should default to en when no xml:lang is present', () => { + const ssml = ssmlNoLang('Hello'); + expect(parseSSMLLang(ssml)).toBe('en'); + }); + + it('should use primaryLang when no xml:lang and primaryLang differs from en', () => { + const ssml = ssmlNoLang('Bonjour'); + expect(parseSSMLLang(ssml, 'fr')).toBe('fr'); + }); + + it('should use primaryLang when xml:lang is en but primaryLang differs', () => { + const ssml = ssmlWithLang('en', 'Hola mundo'); + expect(parseSSMLLang(ssml, 'es')).toBe('es'); + }); + + it('should keep document lang when it matches primaryLang', () => { + const ssml = ssmlWithLang('fr', 'Bonjour'); + expect(parseSSMLLang(ssml, 'fr')).toBe('fr'); + }); + + it('should keep non-en document lang even when primaryLang is different', () => { + const ssml = ssmlWithLang('de', 'Hallo Welt'); + expect(parseSSMLLang(ssml, 'fr')).toBe('de'); + }); + + it('should default to en when no xml:lang and no primaryLang', () => { + const ssml = ssmlNoLang('Hello'); + expect(parseSSMLLang(ssml)).toBe('en'); + }); + + it('should infer CJK lang from script when lang is en', () => { + const ssml = ssmlNoLang('こんにちは'); + expect(parseSSMLLang(ssml)).toBe('ja'); + }); + + it('should infer Chinese from script when no lang', () => { + const ssml = ssmlNoLang('你好世界'); + expect(parseSSMLLang(ssml)).toBe('zh'); + }); + + it('should infer Korean from script when no lang', () => { + const ssml = ssmlNoLang('안녕하세요'); + expect(parseSSMLLang(ssml)).toBe('ko'); + }); +}); + +describe('parseSSMLMarks', () => { + describe('with lang in SSML', () => { + it('should parse marks from SSML with lang', () => { + const ssml = ssmlWithLang('en', 'Hello world'); + const { plainText, marks } = parseSSMLMarks(ssml); + + expect(plainText).toBe('Hello world'); + expect(marks).toHaveLength(2); + expect(marks[0]).toMatchObject({ name: '0', text: 'Hello ', language: 'en' }); + expect(marks[1]).toMatchObject({ name: '1', text: 'world', language: 'en' }); + }); + + it('should assign correct offsets', () => { + const ssml = ssmlWithLang('en', 'First Second'); + const { marks } = parseSSMLMarks(ssml); + + expect(marks[0]).toMatchObject({ offset: 0, text: 'First ' }); + expect(marks[1]).toMatchObject({ offset: 6, text: 'Second' }); + }); + + it('should handle lang blocks within SSML', () => { + const ssml = ssmlWithLang( + 'en', + 'Hello Bonjour', + ); + const { marks } = parseSSMLMarks(ssml); + + expect(marks[0]).toMatchObject({ text: 'Hello ', language: 'en' }); + expect(marks[1]).toMatchObject({ text: 'Bonjour', language: 'fr' }); + }); + + it('should restore lang after closing lang block', () => { + const ssml = ssmlWithLang( + 'en', + 'Hello Welt world', + ); + const { marks } = parseSSMLMarks(ssml); + + expect(marks[0]!.language).toBe('en'); + expect(marks[1]!.language).toBe('de'); + expect(marks[2]!.language).toBe('en'); + }); + }); + + describe('without lang in SSML (no lang document)', () => { + it('should fall back to en when no lang and no primaryLang', () => { + const ssml = ssmlNoLang('Hello world'); + const { plainText, marks } = parseSSMLMarks(ssml); + + expect(plainText).toBe('Hello world'); + expect(marks).toHaveLength(2); + expect(marks[0]!.language).toBe('en'); + expect(marks[1]!.language).toBe('en'); + }); + + it('should use primaryLang when no lang in SSML', () => { + const ssml = ssmlNoLang('Bonjour monde'); + const { marks } = parseSSMLMarks(ssml, 'fr'); + + expect(marks[0]!.language).toBe('fr'); + expect(marks[1]!.language).toBe('fr'); + }); + + it('should use primaryLang with region code when no lang in SSML', () => { + const ssml = ssmlNoLang('Hola mundo'); + const { marks } = parseSSMLMarks(ssml, 'es-MX'); + + expect(marks[0]!.language).toBe('es'); + expect(marks[1]!.language).toBe('es'); + }); + + it('should infer CJK language from text when no lang and no primaryLang', () => { + const ssml = ssmlNoLang('こんにちは'); + const { marks } = parseSSMLMarks(ssml); + + expect(marks[0]!.language).toBe('ja'); + }); + + it('should infer Chinese from text when no lang and no primaryLang', () => { + const ssml = ssmlNoLang('你好世界'); + const { marks } = parseSSMLMarks(ssml); + + expect(marks[0]!.language).toBe('zh'); + }); + + it('should infer Korean from text when no lang and no primaryLang', () => { + const ssml = ssmlNoLang('안녕하세요'); + const { marks } = parseSSMLMarks(ssml); + + expect(marks[0]!.language).toBe('ko'); + }); + }); + + describe('edge cases', () => { + it('should skip punctuation-only marks', () => { + const ssml = ssmlWithLang( + 'en', + 'Hello ... world', + ); + const { marks } = parseSSMLMarks(ssml); + + const names = marks.map((m) => m.name); + expect(names).toContain('0'); + expect(names).not.toContain('1'); + expect(names).toContain('2'); + }); + + it('should handle empty SSML body', () => { + const ssml = ssmlWithLang('en', ''); + const { plainText, marks } = parseSSMLMarks(ssml); + + expect(plainText).toBe(''); + expect(marks).toHaveLength(0); + }); + + it('should handle SSML with only whitespace text', () => { + const ssml = ssmlWithLang('en', ' '); + const { marks } = parseSSMLMarks(ssml); + + expect(marks).toHaveLength(0); + }); + + it('should handle emphasis tags without losing text', () => { + const ssml = ssmlWithLang('en', 'Important text'); + const { plainText } = parseSSMLMarks(ssml); + + expect(plainText).toContain('Important'); + expect(plainText).toContain('text'); + }); + }); +}); diff --git a/packages/foliate-js b/packages/foliate-js index 5a8e4b31..e327bfd1 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 5a8e4b314489f829359431a4fbbae3f00560d9a6 +Subproject commit e327bfd1ea396f43b0362da5c14d3ee0a416ca91