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