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


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 }