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 });