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 {
|
import {
|
||||||
buildMonotonicWordToTokenMap,
|
buildMonotonicWordToTokenMap,
|
||||||
buildWordHighlightCacheKey,
|
buildWordHighlightCacheKey,
|
||||||
|
resolveAlignmentWordSourceRange,
|
||||||
tokenizeCanonicalSegment,
|
tokenizeCanonicalSegment,
|
||||||
type EpubCanonicalWordToken,
|
type EpubCanonicalWordToken,
|
||||||
} from '@/lib/client/epub/epub-word-highlight';
|
} from '@/lib/client/epub/epub-word-highlight';
|
||||||
|
|
@ -118,6 +119,40 @@ export function useEPUBHighlighting({
|
||||||
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
|
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
|
||||||
if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return;
|
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);
|
const cacheKey = buildWordHighlightCacheKey(segment, alignment, language);
|
||||||
if (wordHighlightMapCacheRef.current?.key !== cacheKey) {
|
if (wordHighlightMapCacheRef.current?.key !== cacheKey) {
|
||||||
const tokens = tokenizeCanonicalSegment(segment, language);
|
const tokens = tokenizeCanonicalSegment(segment, language);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
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';
|
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
|
||||||
|
|
||||||
export type EpubCanonicalWordToken = {
|
export type EpubCanonicalWordToken = {
|
||||||
|
|
@ -11,6 +11,20 @@ export type EpubCanonicalWordToken = {
|
||||||
export const normalizeWordForHighlight = (text: string): string =>
|
export const normalizeWordForHighlight = (text: string): string =>
|
||||||
normalizeUnicodeToken(text);
|
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 = (
|
export const tokenizeCanonicalSegment = (
|
||||||
segment: CanonicalTtsSegment,
|
segment: CanonicalTtsSegment,
|
||||||
language?: string,
|
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" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
import { CmpStr } from 'cmpstr';
|
import { findBestHighlightTokenMatch } from './pdf-highlight-match';
|
||||||
|
|
||||||
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
|
|
||||||
|
|
||||||
interface TokenMatchRequest {
|
interface TokenMatchRequest {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'tokenMatch';
|
type: 'tokenMatch';
|
||||||
pattern: string;
|
patternTokens: string[];
|
||||||
tokenTexts: string[];
|
tokenTexts: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,111 +37,13 @@ self.onmessage = (event: MessageEvent<TokenMatchRequest>) => {
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
if (!data || data.type !== 'tokenMatch') return;
|
if (!data || data.type !== 'tokenMatch') return;
|
||||||
|
|
||||||
const { id, pattern, tokenTexts } = data;
|
const { id, patternTokens, tokenTexts } = data;
|
||||||
|
const result = findBestHighlightTokenMatch(patternTokens, tokenTexts);
|
||||||
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 response: TokenMatchResponse = {
|
const response: TokenMatchResponse = {
|
||||||
...responseBase,
|
id,
|
||||||
bestStart,
|
type: 'tokenMatchResult',
|
||||||
bestEnd,
|
...result,
|
||||||
rating: bestRating,
|
|
||||||
lengthDiff: bestLengthDiff,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(self as unknown as DedicatedWorkerGlobalScope).postMessage(response);
|
(self as unknown as DedicatedWorkerGlobalScope).postMessage(response);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
|
||||||
interface HighlightTokenMatchRequest {
|
interface HighlightTokenMatchRequest {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'tokenMatch';
|
type: 'tokenMatch';
|
||||||
pattern: string;
|
patternTokens: string[];
|
||||||
tokenTexts: string[];
|
tokenTexts: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +46,7 @@ function getHighlightWorker(): Worker | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function runHighlightTokenMatch(
|
function runHighlightTokenMatch(
|
||||||
pattern: string,
|
patternTokens: string[],
|
||||||
tokenTexts: string[]
|
tokenTexts: string[]
|
||||||
): Promise<HighlightTokenMatchResponse | null> {
|
): Promise<HighlightTokenMatchResponse | null> {
|
||||||
const worker = getHighlightWorker();
|
const worker = getHighlightWorker();
|
||||||
|
|
@ -71,7 +71,7 @@ function runHighlightTokenMatch(
|
||||||
const message: HighlightTokenMatchRequest = {
|
const message: HighlightTokenMatchRequest = {
|
||||||
id,
|
id,
|
||||||
type: 'tokenMatch',
|
type: 'tokenMatch',
|
||||||
pattern,
|
patternTokens,
|
||||||
tokenTexts,
|
tokenTexts,
|
||||||
};
|
};
|
||||||
worker.postMessage(message);
|
worker.postMessage(message);
|
||||||
|
|
@ -612,9 +612,10 @@ export function highlightPattern(
|
||||||
};
|
};
|
||||||
|
|
||||||
const tokenTexts = tokens.map((t) => t.text);
|
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
|
// Fire-and-forget async worker call; UI thread returns immediately
|
||||||
runHighlightTokenMatch(cleanPattern, tokenTexts)
|
runHighlightTokenMatch(patternTokens, tokenTexts)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (seq !== highlightPatternSeq) return;
|
if (seq !== highlightPatternSeq) return;
|
||||||
if (!result || result.bestStart === -1) {
|
if (!result || result.bestStart === -1) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildMonotonicWordToTokenMap,
|
buildMonotonicWordToTokenMap,
|
||||||
|
resolveAlignmentWordSourceRange,
|
||||||
tokenizeCanonicalSegment,
|
tokenizeCanonicalSegment,
|
||||||
} from '../../src/lib/client/epub/epub-word-highlight';
|
} from '../../src/lib/client/epub/epub-word-highlight';
|
||||||
import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan';
|
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('这是中文');
|
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', () => {
|
test('tokenizes canonical segment words with source offsets', () => {
|
||||||
const tokens = tokenizeCanonicalSegment(segment('"Hello," she said.', 12));
|
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