From a2cdc575ef79df605ac8e9345be827591fd44ecd Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 9 Jun 2026 09:01:48 -0600 Subject: [PATCH] fix(highlight): improve URL replacement spread and sentence alignment fallback Distribute replacement characters across original URL spans to preserve positional mapping in highlights. In sentence alignment, add fallback to avoid misaligned word highlights when the sentence cannot be located, ensuring robustness in TTS word highlighting. --- src/lib/client/highlight-char-map.ts | 10 ++++++++-- src/lib/client/html/highlight.ts | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/lib/client/highlight-char-map.ts b/src/lib/client/highlight-char-map.ts index a5abcfe..54e9814 100644 --- a/src/lib/client/highlight-char-map.ts +++ b/src/lib/client/highlight-char-map.ts @@ -44,8 +44,14 @@ const replaceMappedUrls = (tokens: MappedChar[]): MappedChar[] const anchor = tokens[start] ?? tokens[Math.max(0, end - 1)]; if (anchor) { const replacement = `- (link to ${match[1]}) -`; - for (const char of replacement) { - replaced.push(cloneMappedChar(char, anchor)); + // Spread the replacement characters across the original URL span so the + // mapped positions keep their positional spread instead of collapsing the + // whole URL onto a single DOM anchor. + const originalLength = Math.max(1, end - start); + for (let i = 0; i < replacement.length; i += 1) { + const sourceIndex = Math.min(end - 1, start + Math.floor((i * originalLength) / replacement.length)); + const source = tokens[sourceIndex] ?? anchor; + replaced.push(cloneMappedChar(replacement[i], source)); } } cursor = end; diff --git a/src/lib/client/html/highlight.ts b/src/lib/client/html/highlight.ts index baec7d8..f9a35f6 100644 --- a/src/lib/client/html/highlight.ts +++ b/src/lib/client/html/highlight.ts @@ -326,17 +326,27 @@ export function highlightHtmlWord( // wider than the spoken sentence; rebasing keeps the alignment char offsets // accurate regardless. if (sentenceState.alignment !== alignment || sentenceState.base === null) { - sentenceState.alignment = alignment; const found = sentenceState.text.indexOf(alignment.sentence); - sentenceState.base = found >= 0 ? found : 0; + if (found < 0) { + // Sentence not located in the wrap — fail closed (leave only the sentence + // highlight) rather than snapping the word highlight to offset 0. Don't + // commit the alignment so the next tick retries the lookup. + sentenceState.base = null; + return false; + } + sentenceState.alignment = alignment; + sentenceState.base = found; } + const base = sentenceState.base; + if (base === null) return false; + const word = words[wordIndex]; const { charStart, charEnd } = word; if (!Number.isInteger(charStart) || !Number.isInteger(charEnd) || charEnd <= charStart) return false; - const start = Math.max(0, sentenceState.base + charStart); - const end = Math.min(sentenceState.chars.length, sentenceState.base + charEnd); + const start = Math.max(0, base + charStart); + const end = Math.min(sentenceState.chars.length, base + charEnd); if (end <= start) return false; wordWraps = wrapCharRange(sentenceState.chars, start, end, HTML_WORD_CLASS);