src/hit-test.ts (5403 bytes)
1 // Tap on the page. Pure: no DOM. 2 // 3 // 1. `tokenAt` finds the token under a tap. 4 // 2. `scanText` reads that token and the ones after it on the same line. 5 // 3. `lookupText` is the text that goes to the dictionary app. 6 7 import type { OcrToken } from './align'; 8 import { isCjk, joinWords } from './book-text'; 9 10 /** Longest scan string, in characters. */ 11 export const SCAN_LENGTH = 16; 12 13 /** Longest lookup text, in characters. */ 14 export const LOOKUP_LENGTH = 40; 15 16 /** 17 * When the line ends within this many characters, the next line is added, 18 * unless the line ends a sentence. 19 */ 20 export const LOOKUP_SHORT_LINE = 4; 21 22 /** A token of punctuation or symbols only. A lookup does not start with one. */ 23 const PUNCTUATION = /^[\p{P}\p{S}]+$/u; 24 25 /** Text that ends a sentence. No next line follows it in a lookup. */ 26 const SENTENCE_END = /[。.!?.!?」』]$/u; 27 28 /** How far from a character a tap may land, in CSS pixels: about the size of a finger. */ 29 export const TAP_CSS = 24; 30 31 /** 32 * The tap tolerance in page pixels. The page is `pageWidth` pixels wide 33 * and shows `shownWidth` CSS pixels wide. A pinch zoom of `zoom` makes a 34 * CSS pixel bigger on the screen, so the finger covers fewer of them. 35 */ 36 export function tapTolerance(pageWidth: number, shownWidth: number, zoom = 1): number { 37 return (TAP_CSS * pageWidth) / (shownWidth * Math.max(1, zoom)); 38 } 39 40 /** Distance from a point to a box. Zero inside the box. */ 41 function boxDistance(box: OcrToken['bbox'], x: number, y: number): number { 42 const dx = Math.max(box.x0 - x, 0, x - box.x1); 43 const dy = Math.max(box.y0 - y, 0, y - box.y1); 44 return Math.hypot(dx, dy); 45 } 46 47 /** Distance from a point to the center of a box. */ 48 function centerDistance(box: OcrToken['bbox'], x: number, y: number): number { 49 return Math.hypot((box.x0 + box.x1) / 2 - x, (box.y0 + box.y1) / 2 - y); 50 } 51 52 /** 53 * Index of the token on `page` nearest to (x, y), or -1 when none is 54 * within `tolerance` pixels. Coordinates are in page pixels. OCR boxes 55 * overlap, so among boxes at the same distance the one with the nearest 56 * center wins. 57 */ 58 export function tokenAt( 59 tokens: readonly OcrToken[], 60 page: number, 61 x: number, 62 y: number, 63 tolerance: number, 64 ): number { 65 let best = -1; 66 let bestDist = tolerance; 67 let bestCenter = Infinity; 68 tokens.forEach((t, i) => { 69 if (t.page !== page) return; 70 const d = boxDistance(t.bbox, x, y); 71 if (d > bestDist) return; 72 const c = centerDistance(t.bbox, x, y); 73 if (d < bestDist || c < bestCenter) { 74 best = i; 75 bestDist = d; 76 bestCenter = c; 77 } 78 }); 79 return best; 80 } 81 82 /** 83 * Text of token `start` and the following tokens on its line, up to `max` 84 * characters. Spaces are not tokens: a space goes between two words, 85 * except between two CJK words, and does not count toward `max`. 86 */ 87 export function scanText(tokens: readonly OcrToken[], start: number, max = SCAN_LENGTH): string { 88 const first = tokens[start]; 89 if (!first) return ''; 90 const words: string[] = []; 91 let word = ''; 92 let wordId = first.word; 93 let length = 0; 94 for (let i = start; i < tokens.length && tokens[i]!.line === first.line; i++) { 95 const t = tokens[i]!; 96 if (length + t.text.length > max) break; 97 if (t.word !== wordId) { 98 words.push(word); 99 word = ''; 100 wordId = t.word; 101 } 102 word += t.text; 103 length += t.text.length; 104 } 105 words.push(word); 106 return joinWords(words); 107 } 108 109 /** Index of the first token after the line of token `start`, or -1 at the end. */ 110 function nextLine(tokens: readonly OcrToken[], start: number): number { 111 const line = tokens[start]!.line; 112 let i = start; 113 while (i < tokens.length && tokens[i]!.line === line) i++; 114 return i < tokens.length ? i : -1; 115 } 116 117 /** 118 * Index of the first token of the word of token `i`: the same word id on 119 * the same line. The search stops at a CJK token, because Japanese OCR can 120 * give one word for a whole line. 121 */ 122 function wordStart(tokens: readonly OcrToken[], i: number): number { 123 const { line, word } = tokens[i]!; 124 while (i > 0) { 125 const prev = tokens[i - 1]!; 126 if (prev.line !== line || prev.word !== word || isCjk(prev.text)) break; 127 i--; 128 } 129 return i; 130 } 131 132 /** 133 * The text for a dictionary lookup, to the end of the line. A CJK 134 * character starts it where the tap lands, because a dictionary app scans 135 * from there. Another character starts it at the first letter of its 136 * word. Punctuation and symbols at the start are skipped. When only they 137 * are left on the line, the text is empty. When the line ends within 138 * `LOOKUP_SHORT_LINE` characters and does not end a sentence, the next 139 * line of the same page follows, so a word that wraps is whole. 140 */ 141 export function lookupText(tokens: readonly OcrToken[], tapped: number): string { 142 const first = tokens[tapped]; 143 if (!first) return ''; 144 let start = isCjk(first.text) ? tapped : wordStart(tokens, tapped); 145 while (tokens[start]?.line === first.line && PUNCTUATION.test(tokens[start]!.text)) start++; 146 if (tokens[start]?.line !== first.line) return ''; 147 const text = scanText(tokens, start, LOOKUP_LENGTH); 148 const length = [...text.replace(/ /g, '')].length; 149 if (length > LOOKUP_SHORT_LINE || SENTENCE_END.test(text)) return text; 150 const next = nextLine(tokens, start); 151 if (next < 0 || tokens[next]!.page !== first.page) return text; 152 return joinWords([text, scanText(tokens, next, LOOKUP_LENGTH - length)]); 153 }