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/hit-test.test.ts (10137 bytes)

1 import fc from 'fast-check';
2 import { describe, expect, it } from 'vitest';
3 import type { OcrToken } from '../src/align';
4 import { isCjk } from '../src/book-text';
5 import {
6   LOOKUP_LENGTH,
7   lookupText,
8   scanText,
9   TAP_CSS,
10   tapTolerance,
11   tokenAt,
12 } from '../src/hit-test';
13 
14 /** Tokens for one page: `lines` laid out left to right, 10 px per character. */
15 function tokensOf(lines: string[], page = 0): OcrToken[] {
16   const tokens: OcrToken[] = [];
17   lines.forEach((line, row) => {
18     [...line].forEach((ch, col) => {
19       tokens.push({
20         text: ch,
21         page,
22         line: page * 100 + row,
23         bbox: { x0: col * 10, y0: row * 20, x1: col * 10 + 10, y1: row * 20 + 20 },
24       });
25     });
26   });
27   return tokens;
28 }
29 
30 describe('tokenAt', () => {
31   const tokens = [...tokensOf(['abc', 'de'], 0), ...tokensOf(['xyz'], 1)];
32 
33   it('finds the token under the point', () => {
34     expect(tokenAt(tokens, 0, 15, 5, 5)).toBe(1);
35     expect(tokenAt(tokens, 0, 5, 25, 5)).toBe(3);
36   });
37 
38   it('finds the nearest token within the tolerance', () => {
39     expect(tokenAt(tokens, 0, 33, 10, 5)).toBe(2);
40     expect(tokenAt(tokens, 0, 40, 10, 5)).toBe(-1);
41   });
42 
43   it('prefers the box with the nearest center when boxes overlap', () => {
44     // OCR gave 輩 a box that covers は and 猫 too.
45     const wide: OcrToken[] = [
46       { text: '輩', page: 0, line: 0, bbox: { x0: 212, y0: 228, x1: 418, y1: 265 } },
47       { text: 'は', page: 0, line: 0, bbox: { x0: 235, y0: 222, x1: 282, y1: 279 } },
48       { text: '猫', page: 0, line: 0, bbox: { x0: 281, y0: 222, x1: 336, y1: 279 } },
49     ];
50     expect(tokenAt(wide, 0, 310, 250, 5)).toBe(2);
51     expect(tokenAt(wide, 0, 250, 250, 5)).toBe(1);
52     expect(tokenAt(wide, 0, 400, 250, 5)).toBe(0);
53   });
54 
55   it('looks only at the given page', () => {
56     expect(tokenAt(tokens, 1, 5, 5, 5)).toBe(5);
57     expect(tokenAt(tokens, 2, 5, 5, 5)).toBe(-1);
58   });
59 });
60 
61 describe('tapTolerance', () => {
62   it('converts the CSS pixels of a finger to page pixels', () => {
63     expect(tapTolerance(1600, 400)).toBe(TAP_CSS * 4);
64     expect(tapTolerance(1600, 800)).toBe(TAP_CSS * 2);
65   });
66 
67   it('gets smaller when the page is zoomed in', () => {
68     expect(tapTolerance(1600, 400, 2)).toBe(TAP_CSS * 2);
69     expect(tapTolerance(1600, 400, 0.5)).toBe(TAP_CSS * 4);
70   });
71 
72   it('finds a character 20 CSS pixels from a tap on a phone', () => {
73     // A 1600-pixel page on a phone, 344 CSS pixels wide. The old tolerance,
74     // 1.5% of the page width, was 24 page pixels: about 5 CSS pixels.
75     const page = tokensOf(['abc']);
76     const scale = 1600 / 344;
77     const x = page[2]!.bbox.x1 + 20 * scale;
78     expect(tokenAt(page, 0, x, 10, 0.015 * 1600)).toBe(-1);
79     expect(tokenAt(page, 0, x, 10, tapTolerance(1600, 344))).toBe(2);
80   });
81 });
82 
83 /** Tokens of one line of words, with a word id per word. Spaces are not tokens. */
84 function wordTokens(words: string[], page = 0, line = 0): OcrToken[] {
85   const tokens: OcrToken[] = [];
86   let col = 0;
87   words.forEach((w, wi) => {
88     for (const ch of w) {
89       tokens.push({
90         text: ch,
91         page,
92         line,
93         word: wi,
94         bbox: { x0: col * 10, y0: 0, x1: col * 10 + 10, y1: 20 },
95       });
96       col++;
97     }
98     col++;
99   });
100   return tokens;
101 }
102 
103 /** Tokens of a page of text lines, a word id per word. A space separates two words. */
104 function pageTokens(lines: string[], page = 0): OcrToken[] {
105   return lines.flatMap((line, row) => wordTokens(line.split(' '), page, page * 100 + row));
106 }
107 
108 /** Index of the token of the `n`-th `ch` (from 0) on line `line`. */
109 function tapOn(tokens: readonly OcrToken[], line: number, ch: string, n = 0): number {
110   let seen = 0;
111   for (let i = 0; i < tokens.length; i++) {
112     const t = tokens[i]!;
113     if (t.line === line && t.text === ch && seen++ === n) return i;
114   }
115   throw new Error(`No ${ch} number ${n} on line ${line}`);
116 }
117 
118 describe('scanText', () => {
119   it('reads to the end of the line, up to the limit', () => {
120     const tokens = tokensOf(['吾輩は猫である', '名前はまだ無い']);
121     expect(scanText(tokens, 3)).toBe('猫である');
122     expect(scanText(tokens, 0, 3)).toBe('吾輩は');
123     expect(scanText(tokens, 99)).toBe('');
124   });
125 
126   it('puts a space between words, and the spaces do not count', () => {
127     const tokens = wordTokens(['who', 'answered', 'with']);
128     expect(scanText(tokens, 0)).toBe('who answered with');
129     expect(scanText(tokens, 1)).toBe('ho answered with');
130     expect(scanText(tokens, 0, 10)).toBe('who answere');
131     expect(scanText(wordTokens(['吾輩', 'は', 'cat']), 0)).toBe('吾輩は cat');
132   });
133 });
134 
135 describe('lookupText', () => {
136   it('reads to the end of the line', () => {
137     const tokens = tokensOf(['吾輩は猫である', '名前はまだ無い']);
138     expect(lookupText(tokens, 2)).toBe('は猫である');
139   });
140 
141   it('adds the next line when the line ends within four characters', () => {
142     const tokens = tokensOf(['吾輩は猫である', '名前はまだ無い']);
143     expect(lookupText(tokens, 4)).toBe('である名前はまだ無い');
144     expect(lookupText(tokens, 3)).toBe('猫である名前はまだ無い');
145   });
146 
147   it('puts no space around ー when OCR gives it a word of its own', () => {
148     const tokens = wordTokens(['ニャ', 'ー', '泣いていた事だけは記憶している。']);
149     expect(lookupText(tokens, 0)).toBe('ニャー泣いていた事だけは記憶している。');
150   });
151 
152   it('joins a short line and the next line with a space', () => {
153     const tokens = [...wordTokens(['the', 'end'], 0, 0), ...wordTokens(['of', 'it'], 0, 1)];
154     expect(lookupText(tokens, 3)).toBe('end of it');
155   });
156 
157   it('does not cross to the next page', () => {
158     const tokens = [...wordTokens(['abc', 'def'], 0, 0), ...wordTokens(['ghi', 'jkl'], 1, 100)];
159     expect(lookupText(tokens, 4)).toBe('def');
160     expect(lookupText(tokens, 10)).toBe('jkl');
161   });
162 
163   it('stops at the lookup length', () => {
164     const tokens = tokensOf(['a'.repeat(60), 'b'.repeat(60)]);
165     expect(lookupText(tokens, 0)).toBe('a'.repeat(LOOKUP_LENGTH));
166     // A line break is a word break: a space goes between the two lines.
167     const wrapped = [
168       ...wordTokens(['a'.repeat(58), 'aa'], 0, 0),
169       ...wordTokens(['b'.repeat(60)], 0, 1),
170     ];
171     expect(lookupText(wrapped, 58)).toBe('aa ' + 'b'.repeat(LOOKUP_LENGTH - 2));
172   });
173 
174   it('starts at the first letter of a word that is not CJK', () => {
175     const tokens = [
176       ...pageTokens([
177         'The morning sun rose over the quiet hills,',
178         'and the village below began to stir.',
179         'Far away, a bell rang nine slow times.',
180         'with nothing more than light and sound.',
181       ]),
182       ...pageTokens(['It rolled across the fields and woke the geese,'], 1),
183     ];
184     expect(lookupText(tokens, tapOn(tokens, 0, 'r'))).toBe(
185       'morning sun rose over the quiet hills,',
186     );
187     expect(lookupText(tokens, tapOn(tokens, 2, 'e'))).toBe('bell rang nine slow times.');
188     expect(lookupText(tokens, tapOn(tokens, 100, 't'))).toBe(
189       'It rolled across the fields and woke the geese,',
190     );
191     // A punctuation mark in a word also gives the word.
192     expect(lookupText(tokens, tapOn(tokens, 0, ','))).toBe('hills,');
193     expect(lookupText(tokens, tapOn(tokens, 3, '.'))).toBe('sound.');
194   });
195 
196   it('stops at a CJK character when it looks for the start of a word', () => {
197     // Japanese OCR can give one word for a whole line.
198     const tokens = wordTokens(['今日は2026年9月26日です。']);
199     expect(lookupText(tokens, tapOn(tokens, 0, '6'))).toBe('2026年9月26日です。');
200     expect(lookupText(tokens, tapOn(tokens, 0, '9'))).toBe('9月26日です。');
201   });
202 
203   it('skips punctuation at the start, and gives nothing when only punctuation is left', () => {
204     const tokens = tokensOf([
205       '吾輩は猫である。名前はまだ無い。',
206       'どこで生れたかとんと見当がつかぬ。',
207       '人間中で一番獰悪な種族であったそうだ。',
208     ]);
209     expect(lookupText(tokens, tapOn(tokens, 0, '。'))).toBe('名前はまだ無い。');
210     expect(lookupText(tokens, tapOn(tokens, 0, '。', 1))).toBe('');
211     expect(lookupText(tokens, tapOn(tokens, 2, '。'))).toBe('');
212     const quoted = wordTokens(['"Hello,"', 'she', 'said.']);
213     expect(lookupText(quoted, 0)).toBe('Hello," she said.');
214   });
215 
216   it('adds no next line after the end of a sentence', () => {
217     const tokens = tokensOf([
218       '吾輩は猫である。名前はまだ無い。',
219       'どこで生れたかとんと見当がつかぬ。',
220     ]);
221     expect(lookupText(tokens, tapOn(tokens, 0, '無'))).toBe('無い。');
222     const quote = tokensOf(['彼は言った「猫だ」', '次の行']);
223     expect(lookupText(quote, tapOn(quote, 0, '猫'))).toBe('猫だ」');
224     const english = [...wordTokens(['the', 'end.'], 0, 0), ...wordTokens(['Then', 'more.'], 0, 1)];
225     expect(lookupText(english, 3)).toBe('end.');
226   });
227 
228   it('never returns more than the lookup length, and starts at the tapped CJK character', () => {
229     const line = fc.stringMatching(/^[\p{L}]{1,50}$/u);
230     fc.assert(
231       fc.property(fc.array(line, { minLength: 1, maxLength: 4 }), fc.nat(), (lines, pick) => {
232         const tokens = tokensOf(lines);
233         const start = pick % tokens.length;
234         const text = lookupText(tokens, start);
235         expect([...text.replace(/ /g, '')].length).toBeLessThanOrEqual(LOOKUP_LENGTH);
236         if (isCjk(tokens[start]!.text)) expect(text.startsWith(tokens[start]!.text)).toBe(true);
237       }),
238     );
239   });
240 
241   it('starts at the first letter of the tapped Latin word', () => {
242     const word = fc.stringMatching(/^[A-Za-z]{1,10}$/);
243     fc.assert(
244       fc.property(
245         fc.array(word, { minLength: 1, maxLength: 8 }),
246         fc.nat(),
247         fc.nat(),
248         (words, pickWord, pickLetter) => {
249           const k = pickWord % words.length;
250           const tap = words.slice(0, k).join('').length + (pickLetter % words[k]!.length);
251           const text = lookupText(wordTokens(words), tap);
252           expect(text.split(' ')[0]).toBe(words[k]);
253         },
254       ),
255     );
256   });
257 });