Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


test/align.test.ts (10108 bytes)

1 import fc from 'fast-check';
2 import { describe, expect, it } from 'vitest';
3 import { alignCuesToTokens, buildStream, createAligner, normalizeText } from '../src/align';
4 import { ENG2 } from '../fixtures/sample-text';
5 import { books, char, grams, pagesOfCue, readPages, type Token } from './book';
6 
7 /** One token for each character of `text`, all on `page`. */
8 function tokensOf(text: string, page = 0): Token[] {
9   return [...text].map((ch) => ({ text: ch, page }));
10 }
11 
12 /** The opening of A Tale of Two Cities, as cues. */
13 const TWO_CITIES = [
14   'It was the best of times,',
15   'it was the worst of times,',
16   'it was the age of wisdom,',
17   'it was the age of foolishness,',
18   'it was the epoch of belief,',
19   'it was the epoch of incredulity,',
20   'it was the season of Light,',
21   'it was the season of Darkness,',
22 ];
23 
24 describe('normalizeText', () => {
25   it('drops spaces and punctuation and folds case', () => {
26     expect(normalizeText('Hello, World!  ')).toBe('helloworld');
27   });
28 
29   it('folds full-width forms with NFKC', () => {
30     expect(normalizeText('ABC123')).toBe('abc123');
31   });
32 
33   it('keeps kana, kanji and the long vowel mark', () => {
34     expect(normalizeText('コーヒーを飲む。')).toBe('コーヒーを飲む');
35   });
36 
37   it('gives a stable result when case folding moves combining marks', () => {
38     // fast-check found these inputs. The case map and the filter left the
39     // combining marks out of canonical order, so a second pass changed the text.
40     for (const s of ['\u{1D157}\u{1D165}\u{FC5E}', '\u{130}\u{1D15E}']) {
41       const once = normalizeText(s);
42       expect(normalizeText(once)).toBe(once);
43     }
44   });
45 });
46 
47 describe('alignCuesToTokens on one page', () => {
48   it('maps exact text to exact spans', () => {
49     const cues = ['thequick', 'brownfox', 'jumps'];
50     expect(alignCuesToTokens(cues, tokensOf(cues.join('')))).toEqual([
51       { start: 0, end: 8, matched: true },
52       { start: 8, end: 16, matched: true },
53       { start: 16, end: 21, matched: true },
54     ]);
55   });
56 
57   it('tolerates OCR substitutions and drops', () => {
58     // "rn" read as "m", "l" read as "1", one char lost.
59     const cues = ['themorningsun', 'wasclearandbright'];
60     const spans = alignCuesToTokens(cues, tokensOf('thernorningsun' + 'wasc1earandbrigt'));
61     expect(spans[0]).toEqual({ start: 0, end: 14, matched: true });
62     expect(spans[1]).toEqual({ start: 14, end: 30, matched: true });
63   });
64 
65   it('skips OCR text that no cue covers, such as a page header', () => {
66     const cues = ['firstline', 'secondline'];
67     const spans = alignCuesToTokens(
68       cues,
69       tokensOf('chapterone' + 'firstline' + 'pagenumber12' + 'secondline'),
70     );
71     expect(spans[0]).toEqual({ start: 10, end: 19, matched: true });
72     expect(spans[1]).toEqual({ start: 31, end: 41, matched: true });
73   });
74 
75   it('leaves a cue that is not on the page unmatched, with an empty span', () => {
76     const cues = ['abcdefgh', 'zzzzzzzz', 'ijklmnop'];
77     const spans = alignCuesToTokens(cues, tokensOf('abcdefgh' + 'qqqqqqqq' + 'ijklmnop'));
78     expect(spans).toEqual([
79       { start: 0, end: 8, matched: true },
80       { start: 0, end: 0, matched: false },
81       { start: 16, end: 24, matched: true },
82     ]);
83   });
84 
85   it('aligns Japanese text at character level with OCR noise', () => {
86     const cues = ['吾輩は猫である', '名前はまだ無い', 'どこで生れたか'];
87     // 猫 read as 描, one char doubled, one char lost.
88     const spans = alignCuesToTokens(
89       cues,
90       tokensOf('吾輩は描である' + '名前はまだだ無い' + 'どこで生れか'),
91     );
92     expect(spans[0]).toEqual({ start: 0, end: 7, matched: true });
93     expect(spans[1]).toEqual({ start: 7, end: 15, matched: true });
94     expect(spans[2]).toEqual({ start: 15, end: 21, matched: true });
95   });
96 
97   it('returns empty spans for empty input', () => {
98     expect(alignCuesToTokens([], tokensOf('abc'))).toEqual([]);
99     expect(alignCuesToTokens(['abc'], [])).toEqual([{ start: 0, end: 0, matched: false }]);
100   });
101 
102   it('runs the full pipeline on word tokens with punctuation tokens', () => {
103     const tokens = ['It', 'was', 'a', 'dark', 'and', 'stormy', 'night', '.', 'The', 'rain'].map(
104       (text) => ({ text, page: 0 }),
105     );
106     const spans = alignCuesToTokens(['It was a dark', 'and stormy night.', 'The rain'], tokens);
107     expect(spans).toEqual([
108       { start: 0, end: 4, matched: true },
109       { start: 4, end: 7, matched: true },
110       { start: 8, end: 10, matched: true },
111     ]);
112   });
113 
114   it('works with one token per character, as for vertical Japanese', () => {
115     const spans = alignCuesToTokens(
116       ['春はあけぼの', 'やうやう白くなりゆく山ぎは'],
117       tokensOf('春はあけぼの。やうやう白くなりゆく山ぎは'),
118     );
119     expect(spans[0]).toEqual({ start: 0, end: 6, matched: true });
120     expect(spans[1]).toEqual({ start: 7, end: 20, matched: true });
121   });
122 
123   it('refuses tokens that are not in page order', () => {
124     const tokens = [
125       { text: 'a', page: 1 },
126       { text: 'b', page: 0 },
127     ];
128     expect(() => alignCuesToTokens(['ab'], tokens)).toThrow(/page order/);
129   });
130 });
131 
132 describe('buildStream', () => {
133   it('maps chars back to the tokens that hold them', () => {
134     const stream = buildStream(['The', 'quick,', '—', 'fox']);
135     expect(stream.chars).toBe('thequickfox');
136     expect(stream.tokenOf).toEqual([0, 0, 0, 1, 1, 1, 1, 1, 3, 3, 3]);
137   });
138 });
139 
140 describe('createAligner', () => {
141   const text = normalizeText(TWO_CITIES.join(''));
142   const cues = text.match(/.{1,20}/g)!;
143 
144   it('indexes the tokens of the read pages, joined in page order', () => {
145     const pages = [tokensOf(text.slice(0, 60), 0), undefined, tokensOf(text.slice(60), 2)];
146     const spans = createAligner(cues).spans(pages);
147     expect(spans.map((s) => s.matched)).toEqual(cues.map(() => true));
148     expect(spans[0]).toEqual({ start: 0, end: 20, matched: true });
149     expect(spans[3]).toEqual({ start: 60, end: 80, matched: true });
150   });
151 
152   it('leaves the cues of an unread page unmatched', () => {
153     const pages = [undefined, undefined, tokensOf(text.slice(60), 2)];
154     const spans = createAligner(cues).spans(pages);
155     expect(spans.slice(0, 3).map((s) => s.matched)).toEqual([false, false, false]);
156     expect(spans[3]).toEqual({ start: 0, end: 20, matched: true });
157   });
158 
159   it('matches no cue of subtitles for another text (regression)', () => {
160     // The opening of A Tale of Two Cities over the two pages of the
161     // sample-eng-2p fixture. The texts share no 8-character run, so no
162     // page has an anchor. One diff of all cues matched every cue on chance
163     // runs of two characters.
164     const pages = ENG2.pages.map((lines, p) => tokensOf(lines.join(' '), p));
165     const pageGrams = grams(ENG2.pages.flat().join(''));
166     expect([...grams(TWO_CITIES.join(''))].filter((g) => pageGrams.has(g))).toEqual([]);
167 
168     const spans = alignCuesToTokens(TWO_CITIES, pages.flat());
169     expect(spans.filter((s) => s.matched)).toEqual([]);
170   });
171 
172   it('matches no cue to a wrong page when only 3 pages in the middle are read (regression)', () => {
173     // The measured defect in small form: a book of 20 pages with 5% OCR
174     // noise, and only pages 9 to 11 read. One diff of all cues against
175     // those pages matched cues to wrong places.
176     const [book] = fc.sample(
177       books({ cues: [220, 220], cueLength: [15, 40], pageLength: [300, 300], noise: 0.05 }),
178       { seed: 3, numRuns: 1 },
179     );
180     const read = book!.pages.map((_, p) => p >= 9 && p <= 11);
181     const pages = readPages(book!, read);
182     const tokens = pages.flatMap((p) => p ?? []);
183     const spans = alignCuesToTokens(book!.cues, tokens);
184 
185     const wrong: number[] = [];
186     const missed: number[] = [];
187     book!.cues.forEach((_, i) => {
188       const own = pagesOfCue(book!, i);
189       const span = spans[i]!;
190       const onOwnPages = tokens.slice(span.start, span.end).every((t) => own.has(t.page));
191       if (span.matched && !onOwnPages) wrong.push(i);
192       if ([...own].every((p) => read[p]) && !(span.matched && onOwnPages)) missed.push(i);
193     });
194     expect(wrong).toEqual([]);
195     expect(missed).toEqual([]);
196   });
197 });
198 
199 // The bounds leave room for a busy machine: the aligner takes about 1 s and
200 // 30 ms here, and took 4.3 s once under heavy load. The old global diff took
201 // 13 s for the full book, and the same again for each page, so the test still
202 // catches a return to it.
203 describe('createAligner speed', () => {
204   it('aligns a book of 300 000 characters in under 8 s, and one more page in under 500 ms', () => {
205     const LENGTH = 300_000;
206     const PAGE = 600;
207     // 5% noise: 3 codes in 60 are a drop, a substitution and an insertion.
208     const codes = [...'k'.repeat(57), 'd', 's', 'i'];
209     const [sample] = fc.sample(
210       fc.tuple(
211         fc.string({ unit: char, minLength: LENGTH, maxLength: LENGTH }),
212         fc.string({ unit: fc.constantFrom(...codes), minLength: LENGTH, maxLength: LENGTH }),
213       ),
214       { seed: 1, numRuns: 1 },
215     );
216     const [text, noise] = sample!;
217     const cues = text.match(/.{1,40}/g)!;
218     const pages: Token[][] = [];
219     for (let i = 0; i < LENGTH; i++) {
220       const page = Math.floor(i / PAGE);
221       const tokens = (pages[page] ??= []);
222       const ch = text[i]!;
223       const other = text[(i + 1) % LENGTH]!;
224       if (noise[i] === 's') tokens.push({ text: other, page });
225       else if (noise[i] === 'i') tokens.push({ text: other, page }, { text: ch, page });
226       else if (noise[i] !== 'd') tokens.push({ text: ch, page });
227     }
228 
229     let t = performance.now();
230     const aligner = createAligner(cues);
231     const spans = aligner.spans(pages);
232     const all = performance.now() - t;
233     // Read one page again: a new token array, as after a change of the OCR language.
234     pages[250] = [...pages[250]!];
235     t = performance.now();
236     aligner.spans(pages);
237     const one = performance.now() - t;
238 
239     expect(spans.filter((s) => s.matched).length).toBeGreaterThanOrEqual(0.99 * cues.length);
240     expect(all).toBeLessThan(8000);
241     expect(one).toBeLessThan(500);
242   }, 30_000);
243 });