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/line-boxes.test.ts (8041 bytes)

1 import fc from 'fast-check';
2 import { describe, expect, it } from 'vitest';
3 import type { OcrToken, TokenSpan } from '../src/align';
4 import { lineBoxes, padBox, SIDE_PAD, type Box } from '../src/line-boxes';
5 
6 function token(page: number, line: number, x0: number, y0: number): OcrToken {
7   return { text: 'a', page, line, bbox: { x0, y0, x1: x0 + 10, y1: y0 + 20 } };
8 }
9 
10 const tokens = [
11   token(0, 0, 0, 0),
12   token(0, 0, 10, 2),
13   token(0, 1, 0, 30),
14   token(1, 100000, 0, 0),
15   token(1, 100000, 10, 0),
16 ];
17 
18 /**
19  * Tokens of text lines, one token per character, 10 px per character and
20  * 20 px per line. A space separates two words and gets no token.
21  */
22 function textTokens(lines: string[], page = 0): OcrToken[] {
23   const out: OcrToken[] = [];
24   let word = 0;
25   lines.forEach((text, row) => {
26     [...text].forEach((ch, col) => {
27       if (ch === ' ') {
28         word++;
29         return;
30       }
31       const x0 = col * 10;
32       const y0 = row * 20;
33       out.push({ text: ch, page, line: row, word, bbox: { x0, y0, x1: x0 + 10, y1: y0 + 20 } });
34     });
35     word++;
36   });
37   return out;
38 }
39 
40 /** A matched span of the tokens `from` to `to`, `to` not included. */
41 function spanOf(from: number, to: number): TokenSpan {
42   return { start: from, end: to, matched: true };
43 }
44 
45 describe('lineBoxes', () => {
46   it('gives one box for each line, the union of its token boxes', () => {
47     expect(lineBoxes(tokens, { start: 0, end: 3, matched: true }, 0)).toEqual([
48       { x0: 0, y0: 0, x1: 20, y1: 22 },
49       { x0: 0, y0: 30, x1: 10, y1: 50 },
50     ]);
51   });
52 
53   it('leaves out the tokens on other pages', () => {
54     expect(lineBoxes(tokens, { start: 2, end: 5, matched: true }, 1)).toEqual([
55       { x0: 0, y0: 0, x1: 20, y1: 20 },
56     ]);
57   });
58 
59   it('skips token indices past the end of the tokens', () => {
60     // A span from the alignment before the pages were read again: the
61     // token list is now shorter than the span.
62     expect(lineBoxes(tokens.slice(0, 2), { start: 0, end: 5, matched: true }, 0)).toEqual([
63       { x0: 0, y0: 0, x1: 20, y1: 22 },
64     ]);
65   });
66 
67   it('grows to the comma and the last letter that the alignment dropped', () => {
68     // "quiet hills," with the span to "hill": the alignment dropped "s,".
69     const line = textTokens(['quiet hills,']);
70     expect(line.map((t) => t.text).join('')).toBe('quiethills,');
71     const box = lineBoxes(line, spanOf(0, 9), 0);
72     expect(box).toEqual([{ x0: 0, y0: 0, x1: 120, y1: 20 }]);
73   });
74 
75   it('grows to the start of the first word', () => {
76     // The span starts at "ills", in the middle of "hills".
77     const line = textTokens(['the hills rose']);
78     expect(lineBoxes(line, spanOf(4, 8), 0)).toEqual([{ x0: 40, y0: 0, x1: 90, y1: 20 }]);
79   });
80 
81   it('does not grow over the next word or the next line', () => {
82     const page = textTokens(['one two', 'three']);
83     // The span is "two": the box stays on "two".
84     expect(lineBoxes(page, spanOf(3, 6), 0)).toEqual([{ x0: 40, y0: 0, x1: 70, y1: 20 }]);
85   });
86 
87   it('grows over CJK punctuation, but not over a CJK letter of the same OCR word', () => {
88     // Japanese OCR gave one word for the whole line.
89     const line = textTokens(['吾輩は猫である。名前は']);
90     // The span is 猫である: 。 joins the box, 名前は does not.
91     expect(lineBoxes(line, spanOf(3, 7), 0)).toEqual([{ x0: 30, y0: 0, x1: 80, y1: 20 }]);
92     // The span is 名前: the 。 before it ends the cue before, and は is a letter.
93     expect(lineBoxes(line, spanOf(8, 10), 0)).toEqual([{ x0: 80, y0: 0, x1: 100, y1: 20 }]);
94     // The span is 猫だ: the brackets around it join.
95     const quote = textTokens(['彼は「猫だ」と言った']);
96     expect(lineBoxes(quote, spanOf(3, 5), 0)).toEqual([{ x0: 20, y0: 0, x1: 60, y1: 20 }]);
97   });
98 
99   it('grows over CJK punctuation that OCR gave a word of its own', () => {
100     // Tesseract jpn on sample-jpn: the 。 at the end of the line is a word.
101     const words = ['吾輩は', '猫である', '。', '名前はまだ無い', '。'];
102     const line: OcrToken[] = [];
103     words.forEach((w, word) => {
104       for (const ch of w) {
105         const x0 = line.length * 10;
106         line.push({ text: ch, page: 0, line: 0, word, bbox: { x0, y0: 0, x1: x0 + 10, y1: 20 } });
107       }
108     });
109     // The span is 名前はまだ無い: the last 。 joins.
110     expect(lineBoxes(line, spanOf(8, 15), 0)).toEqual([{ x0: 80, y0: 0, x1: 160, y1: 20 }]);
111     // The span is 猫である: the 。 after it joins, は before it does not.
112     expect(lineBoxes(line, spanOf(3, 7), 0)).toEqual([{ x0: 30, y0: 0, x1: 80, y1: 20 }]);
113   });
114 
115   it('pads the sides by a quarter of the line height when that is more than the padding', () => {
116     // The phone case: a page 1600 px wide on a phone of 360 CSS px. 2 CSS px
117     // are 9 page px, and the line box of a scan is 56 px high. Tesseract
118     // symbol boxes ended 10 to 18 px before the ink of the last letter.
119     const line: OcrToken[] = [...'hills,'].map((ch, i) => ({
120       text: ch,
121       page: 0,
122       line: 0,
123       word: 0,
124       bbox: { x0: 100 + 25 * i, y0: 300, x1: 125 + 25 * i, y1: 356 },
125     }));
126     const boxes = lineBoxes(line, spanOf(0, 6), 0).map((b) => padBox(b, 9));
127     expect(boxes).toEqual([{ x0: 86, y0: 291, x1: 264, y1: 365 }]);
128   });
129 
130   it('gives the box of the whole words of the span on each line', () => {
131     // Latin words and punctuation: a whole word of each span token is in the
132     // box. A slow reference: the union of the tokens of the span and of the
133     // tokens that share a line and a word id with a span token.
134     const word = fc.stringMatching(/^[a-z,.;!?"]{1,6}$/);
135     const text = fc.array(fc.array(word, { minLength: 1, maxLength: 5 }), {
136       minLength: 1,
137       maxLength: 4,
138     });
139     const input = text.chain((lines) => {
140       const page = textTokens(lines.map((words) => words.join(' ')));
141       return fc.tuple(
142         fc.constant(page),
143         fc.nat({ max: page.length - 1 }),
144         fc.nat({ max: page.length - 1 }),
145       );
146     });
147     fc.assert(
148       fc.property(input, ([page, a, b]) => {
149         const span = spanOf(Math.min(a, b), Math.max(a, b) + 1);
150         const inSpan = page.slice(span.start, span.end);
151         const expected = new Map<number, Box>();
152         for (const t of page) {
153           const keep = inSpan.some((s) => s === t || (s.line === t.line && s.word === t.word));
154           if (!keep) continue;
155           const box = expected.get(t.line);
156           if (!box) expected.set(t.line, { ...t.bbox });
157           else {
158             box.x0 = Math.min(box.x0, t.bbox.x0);
159             box.y0 = Math.min(box.y0, t.bbox.y0);
160             box.x1 = Math.max(box.x1, t.bbox.x1);
161             box.y1 = Math.max(box.y1, t.bbox.y1);
162           }
163         }
164         expect(lineBoxes(page, span, 0)).toEqual([...expected.values()]);
165       }),
166     );
167   });
168 });
169 
170 describe('padBox', () => {
171   it('pads each side by the padding when the line is low', () => {
172     // A quarter of the 20 px line is 5 px, less than the padding.
173     expect(padBox({ x0: 0, y0: 0, x1: 120, y1: 20 }, 6)).toEqual({
174       x0: -6,
175       y0: -6,
176       x1: 126,
177       y1: 26,
178     });
179   });
180 
181   it('holds the box, pads the top and the bottom by the padding, and each side by the larger rule', () => {
182     const box = fc
183       .tuple(
184         fc.integer({ min: -2000, max: 2000 }),
185         fc.integer({ min: -2000, max: 2000 }),
186         fc.nat({ max: 2000 }),
187         fc.nat({ max: 400 }),
188       )
189       .map(([x0, y0, w, h]) => ({ x0, y0, x1: x0 + w, y1: y0 + h }));
190     // Whole pixels, so the sums are exact.
191     fc.assert(
192       fc.property(box, fc.nat({ max: 50 }), (b, pad) => {
193         const out = padBox(b, pad);
194         const side = Math.max(pad, SIDE_PAD * (b.y1 - b.y0));
195         expect(out).toEqual({ x0: b.x0 - side, y0: b.y0 - pad, x1: b.x1 + side, y1: b.y1 + pad });
196         expect(b.x0 - out.x0).toBeGreaterThanOrEqual(pad);
197         expect(out.x1 - b.x1).toBeGreaterThanOrEqual(pad);
198         expect(out.x1 - b.x1).toBeGreaterThanOrEqual(SIDE_PAD * (b.y1 - b.y0));
199       }),
200     );
201   });
202 });