fix(highlight): support Japanese token matching
This commit is contained in:
parent
facbda2477
commit
3f15636e0e
7 changed files with 223 additions and 113 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
101
src/lib/client/pdf-highlight-match.ts
Normal file
101
src/lib/client/pdf-highlight-match.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 1–2 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);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
29
tests/unit/pdf-highlight-match.vitest.spec.ts
Normal file
29
tests/unit/pdf-highlight-match.vitest.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue