commit 93d5609893242161f62f93c7075d1d414e35f5dd truex <truex@equwal.com> 2026-09-26 13:36:57 -0700 Start a lookup at the word, and skip punctuation A tap inside an English word sent the rest of the line from the tapped letter, for example "rning sun rose over the quiet hills,". The dictionary looks up the longest match at the start of the text, so it looked up "rning". A tap on a character that is not CJK now starts the lookup at the first letter of its word. The search for the start of the word stops at a CJK character, because Japanese OCR can give one word for a whole line. A CJK character still starts the lookup where the tap lands. Punctuation and symbols at the start of the lookup text are now skipped. When only punctuation is left on the line, the lookup text is empty. Before, a tap on the last "。" of a line sent "。" and the next line. The next line now follows a short line only when the line does not end a sentence (。.!?.!?」』). Before, "無い。" took the next line.
src/hit-test.ts | 47 +++++++++++++++++++---- test/hit-test.test.ts | 103 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 136 insertions(+), 14 deletions(-)
diff --git a/src/hit-test.ts b/src/hit-test.ts index deeb7a6..ea37133 100644 --- a/src/hit-test.ts +++ b/src/hit-test.ts @@ -5,7 +5,7 @@ // 3. `lookupText` is the text that goes to the dictionary app. import type { OcrToken } from './align'; -import { joinWords } from './book-text'; +import { isCjk, joinWords } from './book-text'; /** Longest scan string, in characters. */ export const SCAN_LENGTH = 16; @@ -13,9 +13,18 @@ export const SCAN_LENGTH = 16; /** Longest lookup text, in characters. */ export const LOOKUP_LENGTH = 40; -/** When the line ends within this many characters, the next line is added. */ +/** + * When the line ends within this many characters, the next line is added, + * unless the line ends a sentence. + */ export const LOOKUP_SHORT_LINE = 4; +/** A token of punctuation or symbols only. A lookup does not start with one. */ +const PUNCTUATION = /^[\p{P}\p{S}]+$/u; + +/** Text that ends a sentence. No next line follows it in a lookup. */ +const SENTENCE_END = /[。.!?.!?」』]$/u; + /** Distance from a point to a box. Zero inside the box. */ function boxDistance(box: OcrToken['bbox'], x: number, y: number): number { const dx = Math.max(box.x0 - x, 0, x - box.x1); @@ -94,16 +103,38 @@ function nextLine(tokens: readonly OcrToken[], start: number): number { } /** - * The text for a dictionary lookup: from the tapped character to the end - * of its line. When the line ends within `LOOKUP_SHORT_LINE` characters, - * the next line of the same page follows, so a word that wraps is whole. + * Index of the first token of the word of token `i`: the same word id on + * the same line. The search stops at a CJK token, because Japanese OCR can + * give one word for a whole line. */ -export function lookupText(tokens: readonly OcrToken[], start: number): string { - const first = tokens[start]; +function wordStart(tokens: readonly OcrToken[], i: number): number { + const { line, word } = tokens[i]!; + while (i > 0) { + const prev = tokens[i - 1]!; + if (prev.line !== line || prev.word !== word || isCjk(prev.text)) break; + i--; + } + return i; +} + +/** + * The text for a dictionary lookup, to the end of the line. A CJK + * character starts it where the tap lands, because a dictionary app scans + * from there. Another character starts it at the first letter of its + * word. Punctuation and symbols at the start are skipped. When only they + * are left on the line, the text is empty. When the line ends within + * `LOOKUP_SHORT_LINE` characters and does not end a sentence, the next + * line of the same page follows, so a word that wraps is whole. + */ +export function lookupText(tokens: readonly OcrToken[], tapped: number): string { + const first = tokens[tapped]; if (!first) return ''; + let start = isCjk(first.text) ? tapped : wordStart(tokens, tapped); + while (tokens[start]?.line === first.line && PUNCTUATION.test(tokens[start]!.text)) start++; + if (tokens[start]?.line !== first.line) return ''; const text = scanText(tokens, start, LOOKUP_LENGTH); const length = [...text.replace(/ /g, '')].length; - if (length > LOOKUP_SHORT_LINE) return text; + if (length > LOOKUP_SHORT_LINE || SENTENCE_END.test(text)) return text; const next = nextLine(tokens, start); if (next < 0 || tokens[next]!.page !== first.page) return text; return joinWords([text, scanText(tokens, next, LOOKUP_LENGTH - length)]); diff --git a/test/hit-test.test.ts b/test/hit-test.test.ts index 41155ab..5651a86 100644 --- a/test/hit-test.test.ts +++ b/test/hit-test.test.ts @@ -1,6 +1,7 @@ import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; import type { OcrToken } from '../src/align'; +import { isCjk } from '../src/book-text'; import { LOOKUP_LENGTH, lookupText, scanText, tokenAt } from '../src/hit-test'; /** Tokens for one page: `lines` laid out left to right, 10 px per character. */ @@ -70,6 +71,21 @@ function wordTokens(words: string[], page = 0, line = 0): OcrToken[] { return tokens; } +/** Tokens of a page of text lines, a word id per word. A space separates two words. */ +function pageTokens(lines: string[], page = 0): OcrToken[] { + return lines.flatMap((line, row) => wordTokens(line.split(' '), page, page * 100 + row)); +} + +/** Index of the token of the `n`-th `ch` (from 0) on line `line`. */ +function tapOn(tokens: readonly OcrToken[], line: number, ch: string, n = 0): number { + let seen = 0; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]!; + if (t.line === line && t.text === ch && seen++ === n) return i; + } + throw new Error(`No ${ch} number ${n} on line ${line}`); +} + describe('scanText', () => { it('reads to the end of the line, up to the limit', () => { const tokens = tokensOf(['吾輩は猫である', '名前はまだ無い']); @@ -110,19 +126,77 @@ describe('lookupText', () => { }); it('does not cross to the next page', () => { - const tokens = [...tokensOf(['abcdef'], 0), ...tokensOf(['ghijkl'], 1)]; - expect(lookupText(tokens, 4)).toBe('ef'); - expect(lookupText(tokens, 10)).toBe('kl'); + const tokens = [...wordTokens(['abc', 'def'], 0, 0), ...wordTokens(['ghi', 'jkl'], 1, 100)]; + expect(lookupText(tokens, 4)).toBe('def'); + expect(lookupText(tokens, 10)).toBe('jkl'); }); it('stops at the lookup length', () => { const tokens = tokensOf(['a'.repeat(60), 'b'.repeat(60)]); expect(lookupText(tokens, 0)).toBe('a'.repeat(LOOKUP_LENGTH)); // A line break is a word break: a space goes between the two lines. - expect(lookupText(tokens, 58)).toBe('aa ' + 'b'.repeat(LOOKUP_LENGTH - 2)); + const wrapped = [ + ...wordTokens(['a'.repeat(58), 'aa'], 0, 0), + ...wordTokens(['b'.repeat(60)], 0, 1), + ]; + expect(lookupText(wrapped, 58)).toBe('aa ' + 'b'.repeat(LOOKUP_LENGTH - 2)); + }); + + it('starts at the first letter of a word that is not CJK', () => { + const tokens = [ + ...pageTokens([ + 'The morning sun rose over the quiet hills,', + 'and the village below began to stir.', + 'Far away, a bell rang nine slow times.', + 'with nothing more than light and sound.', + ]), + ...pageTokens(['It rolled across the fields and woke the geese,'], 1), + ]; + expect(lookupText(tokens, tapOn(tokens, 0, 'r'))).toBe( + 'morning sun rose over the quiet hills,', + ); + expect(lookupText(tokens, tapOn(tokens, 2, 'e'))).toBe('bell rang nine slow times.'); + expect(lookupText(tokens, tapOn(tokens, 100, 't'))).toBe( + 'It rolled across the fields and woke the geese,', + ); + // A punctuation mark in a word also gives the word. + expect(lookupText(tokens, tapOn(tokens, 0, ','))).toBe('hills,'); + expect(lookupText(tokens, tapOn(tokens, 3, '.'))).toBe('sound.'); }); - it('never returns more than the lookup length, and always starts with the tapped character', () => { + it('stops at a CJK character when it looks for the start of a word', () => { + // Japanese OCR can give one word for a whole line. + const tokens = wordTokens(['今日は2026年9月26日です。']); + expect(lookupText(tokens, tapOn(tokens, 0, '6'))).toBe('2026年9月26日です。'); + expect(lookupText(tokens, tapOn(tokens, 0, '9'))).toBe('9月26日です。'); + }); + + it('skips punctuation at the start, and gives nothing when only punctuation is left', () => { + const tokens = tokensOf([ + '吾輩は猫である。名前はまだ無い。', + 'どこで生れたかとんと見当がつかぬ。', + '人間中で一番獰悪な種族であったそうだ。', + ]); + expect(lookupText(tokens, tapOn(tokens, 0, '。'))).toBe('名前はまだ無い。'); + expect(lookupText(tokens, tapOn(tokens, 0, '。', 1))).toBe(''); + expect(lookupText(tokens, tapOn(tokens, 2, '。'))).toBe(''); + const quoted = wordTokens(['"Hello,"', 'she', 'said.']); + expect(lookupText(quoted, 0)).toBe('Hello," she said.'); + }); + + it('adds no next line after the end of a sentence', () => { + const tokens = tokensOf([ + '吾輩は猫である。名前はまだ無い。', + 'どこで生れたかとんと見当がつかぬ。', + ]); + expect(lookupText(tokens, tapOn(tokens, 0, '無'))).toBe('無い。'); + const quote = tokensOf(['彼は言った「猫だ」', '次の行']); + expect(lookupText(quote, tapOn(quote, 0, '猫'))).toBe('猫だ」'); + const english = [...wordTokens(['the', 'end.'], 0, 0), ...wordTokens(['Then', 'more.'], 0, 1)]; + expect(lookupText(english, 3)).toBe('end.'); + }); + + it('never returns more than the lookup length, and starts at the tapped CJK character', () => { const line = fc.stringMatching(/^[\p{L}]{1,50}$/u); fc.assert( fc.property(fc.array(line, { minLength: 1, maxLength: 4 }), fc.nat(), (lines, pick) => { @@ -130,8 +204,25 @@ describe('lookupText', () => { const start = pick % tokens.length; const text = lookupText(tokens, start); expect([...text.replace(/ /g, '')].length).toBeLessThanOrEqual(LOOKUP_LENGTH); - expect(text.startsWith(tokens[start]!.text)).toBe(true); + if (isCjk(tokens[start]!.text)) expect(text.startsWith(tokens[start]!.text)).toBe(true); }), ); }); + + it('starts at the first letter of the tapped Latin word', () => { + const word = fc.stringMatching(/^[A-Za-z]{1,10}$/); + fc.assert( + fc.property( + fc.array(word, { minLength: 1, maxLength: 8 }), + fc.nat(), + fc.nat(), + (words, pickWord, pickLetter) => { + const k = pickWord % words.length; + const tap = words.slice(0, k).join('').length + (pickLetter % words[k]!.length); + const text = lookupText(wordTokens(words), tap); + expect(text.split(' ')[0]).toBe(words[k]); + }, + ), + ); + }); });