fix(highlight): support Japanese token matching

This commit is contained in:
Richard R 2026-06-06 10:37:55 -06:00
parent facbda2477
commit 3f15636e0e
7 changed files with 223 additions and 113 deletions

View file

@ -6,6 +6,7 @@ import type { Rendition } from 'epubjs';
import {
buildMonotonicWordToTokenMap,
buildWordHighlightCacheKey,
resolveAlignmentWordSourceRange,
tokenizeCanonicalSegment,
type EpubCanonicalWordToken,
} from '@/lib/client/epub/epub-word-highlight';
@ -118,6 +119,40 @@ export function useEPUBHighlighting({
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return;
const alignmentRange = resolveAlignmentWordSourceRange(segment, words[wordIndex]);
if (
alignmentRange
&& alignmentRange.sourceStart >= resolved.startOffset
&& alignmentRange.sourceEnd <= resolved.endOffset
) {
const wordRange = createRangeFromMappedOffsets(
resolved.map,
alignmentRange.sourceStart,
alignmentRange.sourceEnd,
);
if (wordRange) {
try {
const wordCfi = resolved.map.content.cfiFromRange(wordRange);
currentWordHighlightCfiRef.current = wordCfi;
renditionRef.current.annotations.add(
'highlight',
wordCfi,
{},
() => { },
'',
{
fill: 'var(--accent)',
'fill-opacity': '0.4',
'mix-blend-mode': 'multiply',
}
);
return;
} catch (error) {
console.error('Error highlighting EPUB word from alignment offsets:', error);
}
}
}
const cacheKey = buildWordHighlightCacheKey(segment, alignment, language);
if (wordHighlightMapCacheRef.current?.key !== cacheKey) {
const tokens = tokenizeCanonicalSegment(segment, language);

View file

@ -1,5 +1,5 @@
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
import type { TTSSentenceAlignment } from '@/types/tts';
import type { TTSSentenceAlignment, TTSSentenceWord } from '@/types/tts';
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
export type EpubCanonicalWordToken = {
@ -11,6 +11,20 @@ export type EpubCanonicalWordToken = {
export const normalizeWordForHighlight = (text: string): string =>
normalizeUnicodeToken(text);
export const resolveAlignmentWordSourceRange = (
segment: CanonicalTtsSegment,
word: TTSSentenceWord,
): { sourceStart: number; sourceEnd: number } | null => {
const { charStart, charEnd } = word;
if (!Number.isInteger(charStart) || !Number.isInteger(charEnd)) return null;
if (charStart < 0 || charEnd <= charStart || charEnd > segment.text.length) return null;
return {
sourceStart: segment.startAnchor.offset + charStart,
sourceEnd: segment.startAnchor.offset + charEnd,
};
};
export const tokenizeCanonicalSegment = (
segment: CanonicalTtsSegment,
language?: string,

View file

@ -0,0 +1,101 @@
import { CmpStr } from 'cmpstr';
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
export interface HighlightTokenMatchResult {
bestStart: number;
bestEnd: number;
rating: number;
lengthDiff: number;
}
export function findBestHighlightTokenMatch(
patternTokens: string[],
tokenTexts: string[],
): HighlightTokenMatchResult {
const cleanPatternTokens = patternTokens.map((token) => token.trim()).filter(Boolean);
const cleanPattern = cleanPatternTokens.join('');
const patternLen = cleanPattern.length;
const responseBase: HighlightTokenMatchResult = {
bestStart: -1,
bestEnd: -1,
rating: 0,
lengthDiff: Number.POSITIVE_INFINITY,
};
if (!patternLen || !tokenTexts.length) return responseBase;
const patternTokenCount = cleanPatternTokens.length;
const minWindowTokens = Math.max(1, Math.floor(patternTokenCount * 0.6));
const maxWindowTokens = Math.max(
minWindowTokens,
Math.ceil(patternTokenCount * 1.4),
);
let bestStart = -1;
let bestEnd = -1;
let bestRating = 0;
let bestLengthDiff = Number.POSITIVE_INFINITY;
for (let start = 0; start < tokenTexts.length; start += 1) {
let combined = '';
for (
let offset = 0;
offset < maxWindowTokens && start + offset < tokenTexts.length;
offset += 1
) {
const token = tokenTexts[start + offset];
combined += token;
const windowSize = offset + 1;
if (windowSize < minWindowTokens) continue;
if (combined.length > patternLen * 2) break;
const similarity = cmp.compare(combined, cleanPattern);
const lengthDiff = Math.abs(combined.length - patternLen);
const lengthPenalty = lengthDiff / patternLen;
const adjustedRating = similarity * (1 - lengthPenalty * 0.3);
let boostedRating = adjustedRating;
const windowTokens = tokenTexts.slice(start, start + windowSize);
const maxPrefixCheck = Math.min(
windowTokens.length,
cleanPatternTokens.length,
5,
);
let prefixMatches = 0;
for (let i = 0; i < maxPrefixCheck; i += 1) {
const tokenSim = cmp.compare(windowTokens[i], cleanPatternTokens[i]);
if (tokenSim < 0.8) break;
prefixMatches += 1;
}
if (prefixMatches > 0) {
const prefixRatio = prefixMatches / maxPrefixCheck;
boostedRating = adjustedRating * (1 + prefixRatio * 0.25);
}
if (
boostedRating > bestRating
|| (
Math.abs(boostedRating - bestRating) < 1e-3
&& lengthDiff < bestLengthDiff
)
) {
bestRating = boostedRating;
bestLengthDiff = lengthDiff;
bestStart = start;
bestEnd = start + offset;
}
}
}
return {
bestStart,
bestEnd,
rating: bestRating,
lengthDiff: bestLengthDiff,
};
}

View file

@ -1,13 +1,11 @@
/// <reference lib="webworker" />
import { CmpStr } from 'cmpstr';
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
import { findBestHighlightTokenMatch } from './pdf-highlight-match';
interface TokenMatchRequest {
id: string;
type: 'tokenMatch';
pattern: string;
patternTokens: string[];
tokenTexts: string[];
}
@ -39,112 +37,14 @@ self.onmessage = (event: MessageEvent<TokenMatchRequest>) => {
const data = event.data;
if (!data || data.type !== 'tokenMatch') return;
const { id, pattern, tokenTexts } = data;
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
const patternLen = cleanPattern.length;
const responseBase: TokenMatchResponse = {
id,
type: 'tokenMatchResult',
bestStart: -1,
bestEnd: -1,
rating: 0,
lengthDiff: Number.POSITIVE_INFINITY,
};
if (!patternLen || !tokenTexts.length) {
(self as unknown as DedicatedWorkerGlobalScope).postMessage(responseBase);
return;
}
const patternTokens = cleanPattern.split(' ').filter(Boolean);
const patternTokenCount = patternTokens.length || 1;
const minWindowTokens = Math.max(1, Math.floor(patternTokenCount * 0.6));
const maxWindowTokens = Math.max(
minWindowTokens,
Math.ceil(patternTokenCount * 1.4)
);
let bestStart = -1;
let bestEnd = -1;
let bestRating = 0;
let bestLengthDiff = Number.POSITIVE_INFINITY;
for (let start = 0; start < tokenTexts.length; start++) {
let combined = '';
for (
let offset = 0;
offset < maxWindowTokens && start + offset < tokenTexts.length;
offset++
) {
const token = tokenTexts[start + offset];
combined = combined ? `${combined} ${token}` : token;
const windowSize = offset + 1;
if (windowSize < minWindowTokens) continue;
if (combined.length > patternLen * 2) break;
const similarity = cmp.compare(combined, cleanPattern);
const lengthDiff = Math.abs(combined.length - patternLen);
const lengthPenalty = lengthDiff / patternLen;
const adjustedRating = similarity * (1 - lengthPenalty * 0.3);
// Prefix-alignment boost:
// Favour windows whose first few tokens closely match the beginning
// of the pattern, so we are less likely to cut off the first 12 words.
let boostedRating = adjustedRating;
if (patternTokens.length > 0) {
const windowTokens = tokenTexts.slice(start, start + windowSize);
const maxPrefixCheck = Math.min(
windowTokens.length,
patternTokens.length,
5
);
let prefixMatches = 0;
for (let i = 0; i < maxPrefixCheck; i++) {
const tokenText = windowTokens[i];
const patternToken = patternTokens[i];
// Require reasonably strong similarity for a prefix match
const tokenSim = cmp.compare(tokenText, patternToken);
if (tokenSim >= 0.8) {
prefixMatches++;
} else {
// Stop at the first non-matching leading token
break;
}
}
if (prefixMatches > 0) {
const prefixRatio = prefixMatches / maxPrefixCheck;
const PREFIX_BOOST_FACTOR = 0.25; // up to +25% boost
boostedRating = adjustedRating * (1 + prefixRatio * PREFIX_BOOST_FACTOR);
}
}
if (
boostedRating > bestRating ||
(Math.abs(boostedRating - bestRating) < 1e-3 &&
lengthDiff < bestLengthDiff)
) {
bestRating = boostedRating;
bestLengthDiff = lengthDiff;
bestStart = start;
bestEnd = start + offset;
}
}
}
const { id, patternTokens, tokenTexts } = data;
const result = findBestHighlightTokenMatch(patternTokens, tokenTexts);
const response: TokenMatchResponse = {
...responseBase,
bestStart,
bestEnd,
rating: bestRating,
lengthDiff: bestLengthDiff,
id,
type: 'tokenMatchResult',
...result,
};
(self as unknown as DedicatedWorkerGlobalScope).postMessage(response);
};
};

View file

@ -13,7 +13,7 @@ const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
interface HighlightTokenMatchRequest {
id: string;
type: 'tokenMatch';
pattern: string;
patternTokens: string[];
tokenTexts: string[];
}
@ -46,7 +46,7 @@ function getHighlightWorker(): Worker | null {
}
function runHighlightTokenMatch(
pattern: string,
patternTokens: string[],
tokenTexts: string[]
): Promise<HighlightTokenMatchResponse | null> {
const worker = getHighlightWorker();
@ -71,7 +71,7 @@ function runHighlightTokenMatch(
const message: HighlightTokenMatchRequest = {
id,
type: 'tokenMatch',
pattern,
patternTokens,
tokenTexts,
};
worker.postMessage(message);
@ -612,9 +612,10 @@ export function highlightPattern(
};
const tokenTexts = tokens.map((t) => t.text);
const patternTokens = segmentWords(cleanPattern, options?.language).map((token) => token.text);
// Fire-and-forget async worker call; UI thread returns immediately
runHighlightTokenMatch(cleanPattern, tokenTexts)
runHighlightTokenMatch(patternTokens, tokenTexts)
.then((result) => {
if (seq !== highlightPatternSeq) return;
if (!result || result.bestStart === -1) {

View file

@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest';
import {
buildMonotonicWordToTokenMap,
resolveAlignmentWordSourceRange,
tokenizeCanonicalSegment,
} from '../../src/lib/client/epub/epub-word-highlight';
import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan';
@ -38,6 +39,35 @@ describe('EPUB word highlight mapping', () => {
expect(chinese.map((token) => token.norm).join('')).toBe('这是中文');
});
test('resolves Japanese alignment chunks directly from character offsets', () => {
const japanese = segment('これは日本語です。', 25);
const word: TTSSentenceAlignment['words'][number] = {
text: 'これは',
startSec: 0,
endSec: 0.5,
charStart: 0,
charEnd: 3,
};
expect(resolveAlignmentWordSourceRange(japanese, word)).toEqual({
sourceStart: 25,
sourceEnd: 28,
});
});
test('rejects invalid alignment character offsets so token mapping can be used', () => {
const japanese = segment('これは日本語です。', 25);
const word: TTSSentenceAlignment['words'][number] = {
text: '範囲外',
startSec: 0,
endSec: 0.5,
charStart: 20,
charEnd: 23,
};
expect(resolveAlignmentWordSourceRange(japanese, word)).toBeNull();
});
test('tokenizes canonical segment words with source offsets', () => {
const tokens = tokenizeCanonicalSegment(segment('"Hello," she said.', 12));

View file

@ -0,0 +1,29 @@
import { describe, expect, test } from 'vitest';
import { findBestHighlightTokenMatch } from '../../src/lib/client/pdf-highlight-match';
import { segmentWords } from '../../src/lib/shared/language';
describe('PDF highlight token matching', () => {
test('matches a complete Japanese sentence using locale-aware token count', () => {
const sentence = 'これは日本語です。';
const patternTokens = segmentWords(sentence, 'ja').map((token) => token.text);
const tokenTexts = ['前文', ...patternTokens, '次文'];
expect(findBestHighlightTokenMatch(patternTokens, tokenTexts)).toMatchObject({
bestStart: 1,
bestEnd: patternTokens.length,
lengthDiff: 0,
});
});
test('matches spaced Latin text without relying on whitespace tokens', () => {
expect(findBestHighlightTokenMatch(
['hello', 'world'],
['before', 'hello', 'world', 'after'],
)).toMatchObject({
bestStart: 1,
bestEnd: 2,
lengthDiff: 0,
});
});
});