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/book-text.test.ts (4037 bytes)

1 import fc from 'fast-check';
2 import { describe, expect, it } from 'vitest';
3 import type { OcrToken } from '../src/align';
4 import { bookText, joinWords } from '../src/book-text';
5 
6 const box = { x0: 0, y0: 0, x1: 1, y1: 1 };
7 
8 /** The non-blank lines of a book text, trimmed. */
9 function paragraphs(text: string): string[] {
10   return text
11     .split('\n')
12     .map((l) => l.trim())
13     .filter((l) => l.length > 0);
14 }
15 
16 /** Tokens for one page: `lines` is a list of lines, each a list of words. */
17 function pageTokens(page: number, lines: readonly (readonly string[])[]): OcrToken[] {
18   const out: OcrToken[] = [];
19   lines.forEach((words, li) => {
20     words.forEach((word, wi) => {
21       for (const ch of word) {
22         out.push({
23           text: ch,
24           page,
25           line: page * 1000 + li,
26           word: page * 1000 + li * 100 + wi,
27           bbox: box,
28         });
29       }
30     });
31   });
32   return out;
33 }
34 
35 describe('bookText', () => {
36   it('joins symbols into words, words into lines, and separates pages with a blank line', () => {
37     const tokens = [
38       ...pageTokens(0, [
39         ['The', 'sun'],
40         ['rose', 'up'],
41       ]),
42       ...pageTokens(1, [['朝の', '光']]),
43     ];
44     expect(bookText(tokens)).toBe('The sun\nrose up\n\n朝の光\n');
45   });
46 
47   it('puts a space between words unless both sides are CJK', () => {
48     expect(joinWords(['吾輩', 'は', '猫'])).toBe('吾輩は猫');
49     expect(joinWords(['The', 'sun'])).toBe('The sun');
50     expect(joinWords(['東京', 'Tokyo', '駅'])).toBe('東京 Tokyo 駅');
51     expect(joinWords(['名前', '。', 'まだ'])).toBe('名前。まだ');
52   });
53 
54   it('puts no space around the prolonged sound mark and the other kana marks', () => {
55     // OCR put ー in a word of its own. Its Script is Common, not Katakana.
56     const words = ['ニャ', 'ー', '泣いていた事だけは記憶している。'];
57     expect(joinWords(words)).toBe('ニャー泣いていた事だけは記憶している。');
58     expect(bookText(pageTokens(0, [words]))).toBe('ニャー泣いていた事だけは記憶している。\n');
59     for (const mark of ['ー', '・', '゛', '゜', '々', '〆']) {
60       expect(joinWords(['ア', mark, 'イ'])).toBe(`ア${mark}イ`);
61     }
62   });
63 
64   it('puts no space between words of Han, kana, ー and ・, outside the BMP too', () => {
65     // Each list of words is one split of the string that the words make.
66     const word = fc.stringMatching(
67       /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー・]{1,6}$/u,
68     );
69     fc.assert(
70       fc.property(fc.array(word, { minLength: 1, maxLength: 8 }), (words) => {
71         expect(joinWords(words)).toBe(words.join(''));
72       }),
73     );
74   });
75 
76   it('returns an empty string for no tokens', () => {
77     expect(bookText([])).toBe('');
78   });
79 
80   it('treats tokens without a word id as one word per line', () => {
81     const tokens: OcrToken[] = [
82       { text: 'a', page: 0, line: 0, bbox: box },
83       { text: 'b', page: 0, line: 0, bbox: box },
84       { text: 'c', page: 0, line: 1, bbox: box },
85     ];
86     expect(bookText(tokens)).toBe('ab\nc\n');
87   });
88 
89   it('drops lines that hold only whitespace', () => {
90     const tokens: OcrToken[] = [
91       { text: ' ', page: 0, line: 0, bbox: box },
92       { text: 'x', page: 0, line: 1, bbox: box },
93     ];
94     expect(bookText(tokens)).toBe('x\n');
95   });
96 
97   it('round trips through paragraphs: every OCR line once, in order', () => {
98     // A word: letters only, so a word never holds a space or a line break.
99     const word = fc.stringMatching(/^[\p{L}\p{N}]{1,6}$/u);
100     const line = fc.array(word, { minLength: 1, maxLength: 5 });
101     const page = fc.array(line, { minLength: 1, maxLength: 6 });
102     const book = fc.array(page, { maxLength: 4 });
103     fc.assert(
104       fc.property(book, (pages) => {
105         const tokens = pages.flatMap((lines, p) => pageTokens(p, lines));
106         const expected = pages.flatMap((lines) => lines.map(joinWords));
107         expect(paragraphs(bookText(tokens))).toEqual(expected);
108       }),
109     );
110   });
111 });