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/sentence.test.ts (6305 bytes)

1 import fc from 'fast-check';
2 import { describe, expect, it } from 'vitest';
3 import type { OcrToken } from '../src/align';
4 import { SENTENCE_LENGTH, sentenceAround } from '../src/sentence';
5 
6 const box = { x0: 0, y0: 0, x1: 1, y1: 1 };
7 
8 /** Tokens for one page, one per character: `lines` is a list of lines, each a list of words. */
9 function pageTokens(page: number, lines: readonly (readonly string[])[]): OcrToken[] {
10   const out: OcrToken[] = [];
11   lines.forEach((words, li) => {
12     words.forEach((word, wi) => {
13       for (const ch of word) {
14         out.push({
15           text: ch,
16           page,
17           line: page * 1000 + li,
18           word: page * 1000 + li * 100 + wi,
19           bbox: box,
20         });
21       }
22     });
23   });
24   return out;
25 }
26 
27 /** The index of token number `n` (from 0) with the text `ch`. */
28 function find(tokens: readonly OcrToken[], ch: string, n = 0): number {
29   let seen = 0;
30   for (let i = 0; i < tokens.length; i++) {
31     if (tokens[i]!.text !== ch) continue;
32     if (seen === n) return i;
33     seen++;
34   }
35   throw new Error(`No token ${ch} number ${n}.`);
36 }
37 
38 describe('sentenceAround', () => {
39   // 吾輩は猫である。名前は / まだ無い。どこで生れたか
40   const jpn = pageTokens(0, [['吾輩は猫である。名前は'], ['まだ無い。どこで生れたか']]);
41 
42   // It was a dark / night. The rain fell.
43   const eng = pageTokens(0, [
44     ['It', 'was', 'a', 'dark'],
45     ['night.', 'The', 'rain', 'fell.'],
46   ]);
47 
48   it('reads a Japanese sentence over two lines, with no space at the line break', () => {
49     expect(sentenceAround(jpn, find(jpn, '前'))).toBe('名前はまだ無い。');
50   });
51 
52   it('reads an English sentence over two lines, with a space at the line break', () => {
53     expect(sentenceAround(eng, find(eng, 'k'))).toBe('It was a dark night.');
54     expect(sentenceAround(eng, find(eng, 'f'))).toBe('The rain fell.');
55   });
56 
57   it('gives the same sentence for a tap on its first and on its last character', () => {
58     expect(sentenceAround(jpn, find(jpn, '名'))).toBe('名前はまだ無い。');
59     expect(sentenceAround(jpn, find(jpn, '。', 1))).toBe('名前はまだ無い。');
60     expect(sentenceAround(jpn, find(jpn, '吾'))).toBe('吾輩は猫である。');
61     expect(sentenceAround(jpn, find(jpn, '。', 0))).toBe('吾輩は猫である。');
62     expect(sentenceAround(eng, find(eng, 'I'))).toBe('It was a dark night.');
63     expect(sentenceAround(eng, find(eng, '.', 0))).toBe('It was a dark night.');
64     expect(sentenceAround(eng, find(eng, 'T'))).toBe('The rain fell.');
65     expect(sentenceAround(eng, find(eng, '.', 1))).toBe('The rain fell.');
66   });
67 
68   it('runs to the edge of the page when no sentence end follows', () => {
69     expect(sentenceAround(jpn, find(jpn, 'ど'))).toBe('どこで生れたか');
70     const open = pageTokens(0, [['吾輩は猫で'], ['ある']]);
71     expect(sentenceAround(open, find(open, '猫'))).toBe('吾輩は猫である');
72     const words = pageTokens(0, [
73       ['no', 'end', 'on'],
74       ['this', 'page'],
75     ]);
76     expect(sentenceAround(words, find(words, 'h'))).toBe('no end on this page');
77   });
78 
79   it('stays on the page of the tapped token', () => {
80     const tokens = [...pageTokens(0, [['前の頁の文']]), ...pageTokens(1, [['次の頁の文。']])];
81     expect(sentenceAround(tokens, find(tokens, '前'))).toBe('前の頁の文');
82     expect(sentenceAround(tokens, find(tokens, '次'))).toBe('次の頁の文。');
83   });
84 
85   it('keeps a closing bracket with the sentence that it ends', () => {
86     const quotes = pageTokens(0, [['「おはよう。」「はい。」']]);
87     expect(sentenceAround(quotes, find(quotes, 'よ'))).toBe('「おはよう。」');
88     expect(sentenceAround(quotes, find(quotes, 'い'))).toBe('「はい。」');
89     const english = pageTokens(0, [['"Go', 'away."', 'He', 'left.']]);
90     expect(sentenceAround(english, find(english, 'G'))).toBe('"Go away."');
91     expect(sentenceAround(english, find(english, 'H'))).toBe('He left.');
92   });
93 
94   it('does not end a sentence at a closing bracket with no end mark before it', () => {
95     const said = pageTokens(0, [['「はい」と言った。次']]);
96     expect(sentenceAround(said, find(said, 'と'))).toBe('「はい」と言った。');
97   });
98 
99   it('does not end a sentence at a decimal point', () => {
100     const price = pageTokens(0, [['It', 'costs', '3.14', 'now.', 'Then', 'more.']]);
101     expect(sentenceAround(price, find(price, 'c'))).toBe('It costs 3.14 now.');
102   });
103 
104   it('cuts a long sentence around the tapped token', () => {
105     const long = pageTokens(0, [['あ'.repeat(150) + '猫' + 'い'.repeat(150) + '。']]);
106     expect(sentenceAround(long, find(long, '猫'), 21)).toBe(
107       'あ'.repeat(10) + '猫' + 'い'.repeat(10),
108     );
109     expect(sentenceAround(long, 0)).toBe('あ'.repeat(150) + '猫' + 'い'.repeat(49));
110     expect([...sentenceAround(long, 301)].length).toBe(SENTENCE_LENGTH);
111   });
112 
113   it('returns an empty string for a token that does not exist', () => {
114     expect(sentenceAround(jpn, jpn.length)).toBe('');
115     expect(sentenceAround([], 0)).toBe('');
116   });
117 
118   it('always holds the tapped token and is at most max characters', () => {
119     const ch = fc.constantFrom('吾', 'は', 'a', 'Z', '7', '。', '!', '.', '?', '」', '"');
120     const word = fc.array(ch, { minLength: 1, maxLength: 5 }).map((cs) => cs.join(''));
121     const line = fc.array(word, { minLength: 1, maxLength: 4 });
122     const page = fc.array(line, { minLength: 1, maxLength: 4 });
123     const book = fc.array(page, { minLength: 1, maxLength: 3 });
124     fc.assert(
125       fc.property(book, fc.nat(), fc.integer({ min: 1, max: 40 }), (pages, pick, max) => {
126         const tokens = pages.flatMap((lines, p) => pageTokens(p, lines));
127         const index = pick % tokens.length;
128         const tapped = tokens[index]!;
129         const sentence = sentenceAround(tokens, index, max);
130         expect(sentence).toContain(tapped.text);
131         expect([...sentence].length).toBeLessThanOrEqual(max);
132         // Without the spaces between words, the sentence is a piece of its page.
133         const pageChars = tokens
134           .filter((t) => t.page === tapped.page)
135           .map((t) => t.text)
136           .join('');
137         expect(pageChars).toContain(sentence.replace(/ /g, ''));
138       }),
139     );
140   });
141 });