feat(html): add HTML/TXT/MD sentence and word highlighting, settings, and audiobook export support

Introduce sentence and word highlighting for HTML/TXT/MD documents, including new config options (`htmlHighlightEnabled`, `htmlWordHighlightEnabled`) and synced user preferences. Update the HTML viewer to support these highlights using custom spans, and add corresponding CSS styles. Extend document settings to allow toggling these features. Implement block-level locator sorting and display improvements. Add audiobook export support for HTML documents, including adapter and pipeline integration.

- Add `htmlHighlightEnabled` and `htmlWordHighlightEnabled` to config, user state, and Dexie storage
- Update HTML viewer to apply and manage highlights for sentences and words
- Add CSS for HTML highlight classes and scroll margin
- Extend document settings UI for HTML highlight toggles
- Support HTML audiobook export in modal and pipeline
- Improve block locator sorting and sidebar display for HTML
- Add HTML audiobook adapter and block parsing utilities
This commit is contained in:
Richard R 2026-05-17 11:14:02 -06:00
parent 92762fe858
commit d90e48aaf3
20 changed files with 1755 additions and 95 deletions

View file

@ -14,18 +14,33 @@ import { resolveDocumentId } from '@/lib/client/dexie';
import { DocumentHeaderMenu } from '@/components/documents/DocumentHeaderMenu';
import { SegmentsSidebar } from '@/components/reader/SegmentsSidebar';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import { useHtmlDocument } from './useHtmlDocument';
export default function HTMLPage() {
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
const { id } = useParams();
const router = useRouter();
const { setCurrentDocument, currDocData, currDocName, clearCurrDoc } = useHtmlDocument();
const htmlState = useHtmlDocument();
const {
setCurrentDocument,
currDocData,
currDocName,
blocks,
isTxt,
clearCurrDoc,
createFullAudioBook,
regenerateChapter,
} = htmlState;
const { stop } = useTTS();
const { isAtLimit } = useAuthRateLimit();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'segments'>(null);
const [activeSidebar, setActiveSidebar] = useState<null | 'settings' | 'segments' | 'audiobook'>(null);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
const [maxPadPx, setMaxPadPx] = useState<number>(0);
@ -110,6 +125,24 @@ export default function HTMLPage() {
return () => window.removeEventListener('resize', compute);
}, []);
const handleGenerateAudiobook = useCallback(async (
onProgress: (progress: number) => void,
signal: AbortSignal,
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
settings: AudiobookGenerationSettings,
) => {
return createFullAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings);
}, [createFullAudioBook, id]);
const handleRegenerateChapter = useCallback(async (
chapterIndex: number,
bookId: string,
settings: AudiobookGenerationSettings,
signal: AbortSignal,
) => {
return regenerateChapter(chapterIndex, bookId, settings.format, signal, settings);
}, [regenerateChapter]);
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
@ -153,8 +186,11 @@ export default function HTMLPage() {
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
onOpenSettings={() => setActiveSidebar((prev) => prev === 'settings' ? null : 'settings')}
onOpenSegments={() => setActiveSidebar((prev) => prev === 'segments' ? null : 'segments')}
onOpenAudiobook={() => setActiveSidebar((prev) => prev === 'audiobook' ? null : 'audiobook')}
isSettingsOpen={activeSidebar === 'settings'}
isSegmentsOpen={activeSidebar === 'segments'}
isAudiobookOpen={activeSidebar === 'audiobook'}
showAudiobookExport={canExportAudiobook}
minZoom={0}
maxZoom={100}
/>
@ -162,16 +198,31 @@ export default function HTMLPage() {
}
/>
<div className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? (
{isLoading || !currDocData ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px` }}>
<HTMLViewer className="h-full" currDocData={currDocData} currDocName={currDocName} />
<HTMLViewer
className="h-full"
blocks={blocks}
isTxt={isTxt}
isLoading={isLoading}
/>
</div>
)}
</div>
{canExportAudiobook && (
<AudiobookExportModal
isOpen={activeSidebar === 'audiobook'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))}
documentType="html"
documentId={id as string}
onGenerateAudiobook={handleGenerateAudiobook}
onRegenerateChapter={handleRegenerateChapter}
/>
)}
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">

View file

@ -3,33 +3,106 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { getDocumentMetadata } from '@/lib/client/api/documents';
import { ensureCachedDocument } from '@/lib/client/cache/documents';
import { parseHtmlBlocks, type HtmlBlock } from '@/lib/client/html/blocks';
import { createHtmlAudiobookSourceAdapter } from '@/lib/client/audiobooks/adapters/html';
import { regenerateAudiobookChapter, runAudiobookGeneration } from '@/lib/client/audiobooks/pipeline';
import type {
AudiobookGenerationSettings,
TTSRetryOptions,
} from '@/types/client';
import type {
TTSAudiobookChapter,
TTSAudiobookFormat,
} from '@/types/tts';
interface HtmlDocumentState {
export interface HtmlDocumentState {
currDocData: string | undefined;
currDocName: string | undefined;
currDocText: string | undefined;
blocks: HtmlBlock[];
isTxt: boolean;
setCurrentDocument: (id: string) => Promise<void>;
clearCurrDoc: () => void;
createFullAudioBook: (
onProgress: (progress: number) => void,
signal?: AbortSignal,
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
providedBookId?: string,
format?: TTSAudiobookFormat,
settings?: AudiobookGenerationSettings,
retryOptions?: TTSRetryOptions,
) => Promise<string>;
regenerateChapter: (
chapterIndex: number,
bookId: string,
format: TTSAudiobookFormat,
signal: AbortSignal,
settings?: AudiobookGenerationSettings,
retryOptions?: TTSRetryOptions,
) => Promise<TTSAudiobookChapter>;
}
function isTxtName(name: string | undefined | null): boolean {
return !!name && name.toLowerCase().endsWith('.txt');
}
/**
* Concatenate every block's plain text into one TTS source. We treat the
* entire HTML/TXT/MD document as a single "page" with a flat sequence of
* segments (sentence indices), so playback advances naturally through the
* doc without any per-block locator bookkeeping.
*/
function buildFullDocumentText(blocks: HtmlBlock[]): string {
return blocks
.map((b) => b.plainText)
.filter((t) => t && t.trim())
.join('\n\n');
}
export function useHtmlDocument(): HtmlDocumentState {
const { setText: setTTSText, stop } = useTTS();
const setTTSTextRef = useRef(setTTSText);
const { setText: setTTSText, stop, setIsEPUB } = useTTS();
const {
apiKey,
baseUrl,
providerRef,
smartSentenceSplitting,
ttsSegmentMaxBlockLength,
} = useConfig();
const [currDocData, setCurrDocData] = useState<string>();
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
const isTxt = useMemo(() => isTxtName(currDocName), [currDocName]);
const blocks = useMemo(
() => (currDocData !== undefined ? parseHtmlBlocks(currDocData, isTxt) : []),
[currDocData, isTxt],
);
const currDocText = useMemo(() => buildFullDocumentText(blocks), [blocks]);
// HTML reader is not an EPUB reader.
useEffect(() => {
setTTSTextRef.current = setTTSText;
}, [setTTSText]);
setIsEPUB(false);
}, [setIsEPUB]);
// Feed the entire document into TTS once it's parsed. The TTS context owns
// sentence splitting + sequential advancement from there.
const lastFedDocRef = useRef<string | null>(null);
useEffect(() => {
if (!currDocText) return;
const key = `${currDocData ?? ''}::${currDocText.length}`;
if (lastFedDocRef.current === key) return;
lastFedDocRef.current = key;
setTTSText(currDocText);
}, [currDocText, currDocData, setTTSText]);
const clearCurrDoc = useCallback(() => {
setCurrDocData(undefined);
setCurrDocName(undefined);
setCurrDocText(undefined);
lastFedDocRef.current = null;
stop();
}, [stop]);
@ -49,22 +122,110 @@ export function useHtmlDocument(): HtmlDocumentState {
setCurrDocName(doc.name);
setCurrDocData(doc.data);
setCurrDocText(doc.data);
setTTSTextRef.current(doc.data);
} catch (error) {
console.error('Failed to get HTML document:', error);
clearCurrDoc();
}
}, [clearCurrDoc]);
const audiobookAdapter = useMemo(
() =>
createHtmlAudiobookSourceAdapter({
blocks,
isTxt,
smartSentenceSplitting,
maxBlockLength: ttsSegmentMaxBlockLength,
}),
[blocks, isTxt, smartSentenceSplitting, ttsSegmentMaxBlockLength],
);
const createFullAudioBook = useCallback(
async (
onProgress: (progress: number) => void,
signal?: AbortSignal,
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
providedBookId?: string,
format: TTSAudiobookFormat = 'mp3',
settings?: AudiobookGenerationSettings,
retryOptions?: TTSRetryOptions,
): Promise<string> => {
try {
return await runAudiobookGeneration({
adapter: audiobookAdapter,
apiKey,
baseUrl,
defaultProvider: providerRef,
onProgress,
signal,
onChapterComplete,
providedBookId,
format,
settings,
retryOptions,
});
} catch (error) {
console.error('Error creating audiobook:', error);
throw error;
}
},
[audiobookAdapter, apiKey, baseUrl, providerRef],
);
const regenerateChapter = useCallback(
async (
chapterIndex: number,
bookId: string,
format: TTSAudiobookFormat,
signal: AbortSignal,
settings?: AudiobookGenerationSettings,
retryOptions?: TTSRetryOptions,
): Promise<TTSAudiobookChapter> => {
try {
return await regenerateAudiobookChapter({
adapter: audiobookAdapter,
chapterIndex,
bookId,
format,
signal,
apiKey,
baseUrl,
defaultProvider: providerRef,
settings,
retryOptions,
});
} catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
throw new Error('Chapter regeneration cancelled');
}
console.error('Error regenerating chapter:', error);
throw error;
}
},
[audiobookAdapter, apiKey, baseUrl, providerRef],
);
return useMemo(
() => ({
currDocData,
currDocName,
currDocText,
blocks,
isTxt,
setCurrentDocument,
clearCurrDoc,
createFullAudioBook,
regenerateChapter,
}),
[currDocData, currDocName, currDocText, setCurrentDocument, clearCurrDoc],
[
currDocData,
currDocName,
currDocText,
blocks,
isTxt,
setCurrentDocument,
clearCurrDoc,
createFullAudioBook,
regenerateChapter,
],
);
}

View file

@ -142,6 +142,8 @@ function sanitizePreferencesPatch(
case 'pdfWordHighlightEnabled':
case 'epubHighlightEnabled':
case 'epubWordHighlightEnabled':
case 'htmlHighlightEnabled':
case 'htmlWordHighlightEnabled':
if (typeof value === 'boolean') out[key] = value;
break;
case 'savedVoices':

View file

@ -321,6 +321,28 @@ h1, h2, h3, h4, h5, h6 {
100% { opacity: 0.8; }
}
/* Block container scroll target keeps anchor scroll-into-view aligned with
the page padding when the reader has top header chrome. */
.openreader-html-block {
scroll-margin-top: 4rem;
scroll-margin-bottom: 4rem;
}
/* HTML/TXT/MD reader sentence + word highlights. These are applied by
`src/lib/client/html/highlight.ts` wrapping the matched text in spans, so
they survive any browser quirks around the (still-spotty) CSS Custom
Highlight API. */
.openreader-html-highlight-sentence {
background-color: color-mix(in srgb, var(--accent) 22%, transparent);
border-radius: 2px;
padding: 0 1px;
}
.openreader-html-highlight-word {
background-color: color-mix(in srgb, var(--accent) 55%, transparent);
border-radius: 2px;
padding: 0 1px;
}
/* Static prism gradient divider (no animation) */
.prism-divider {
width: 100%;

View file

@ -24,7 +24,7 @@ import type { AudiobookGenerationSettings } from '@/types/client';
interface AudiobookExportModalProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
documentType: 'epub' | 'pdf';
documentType: 'epub' | 'pdf' | 'html';
documentId: string;
onGenerateAudiobook: (
onProgress: (progress: number) => void,

View file

@ -95,6 +95,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
epubHighlightEnabled,
pdfWordHighlightEnabled,
epubWordHighlightEnabled,
htmlHighlightEnabled,
htmlWordHighlightEnabled,
} = useConfig();
const [localMargins, setLocalMargins] = useState({
header: headerMargin,
@ -161,12 +163,12 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
panelClassName="w-full sm:w-[30rem]"
>
<div className="space-y-4">
{!html && (
<Section
title="Playback Flow"
subtitle="Segment and queue behavior."
variant="flat"
>
<Section
title="Playback Flow"
subtitle="Segment and queue behavior."
variant="flat"
>
{!html && (
<ToggleRow
label="Skip blank pages"
description="Skip pages with no readable text."
@ -174,64 +176,64 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
onChange={(checked) => updateConfigKey('skipBlank', checked)}
variant="flat"
/>
)}
<ToggleRow
label="Smart sentence splitting"
description="Merge fragments across pages/sections."
checked={smartSentenceSplitting}
onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)}
variant="flat"
<ToggleRow
label="Smart sentence splitting"
description="Merge fragments across pages/sections."
checked={smartSentenceSplitting}
onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)}
variant="flat"
/>
<div className="space-y-3 pt-1">
<RangeSetting
label="Segment preload depth"
value={localPreloadDepth}
min={SEGMENT_PRELOAD_DEPTH_MIN}
max={SEGMENT_PRELOAD_DEPTH_MAX}
step={1}
description="Upcoming pages/locations to queue."
formatter={(value) => String(value)}
onChange={(value) => {
const next = clampSegmentPreloadDepth(value);
setLocalPreloadDepth(next);
void updateConfigKey('segmentPreloadDepthPages', next);
}}
/>
<div className="space-y-3 pt-1">
<RangeSetting
label="Segment preload depth"
value={localPreloadDepth}
min={SEGMENT_PRELOAD_DEPTH_MIN}
max={SEGMENT_PRELOAD_DEPTH_MAX}
step={1}
description="Upcoming pages/locations to queue."
formatter={(value) => String(value)}
onChange={(value) => {
const next = clampSegmentPreloadDepth(value);
setLocalPreloadDepth(next);
void updateConfigKey('segmentPreloadDepthPages', next);
}}
/>
<RangeSetting
label="Segment lookahead per page/location"
value={localSentenceLookahead}
min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN}
max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX}
step={1}
description="Segments to prepare per queued page/section."
formatter={(value) => String(value)}
onChange={(value) => {
const next = clampSegmentPreloadSentenceLookahead(value);
setLocalSentenceLookahead(next);
void updateConfigKey('segmentPreloadSentenceLookahead', next);
}}
/>
<RangeSetting
label="Segment lookahead per page/location"
value={localSentenceLookahead}
min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN}
max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX}
step={1}
description="Segments to prepare per queued page/section."
formatter={(value) => String(value)}
onChange={(value) => {
const next = clampSegmentPreloadSentenceLookahead(value);
setLocalSentenceLookahead(next);
void updateConfigKey('segmentPreloadSentenceLookahead', next);
}}
/>
<RangeSetting
label="TTS segment max block length"
value={localMaxBlockLength}
min={TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN}
max={TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX}
step={TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP}
description="Max characters per TTS segment block."
valueWidth="w-14"
formatter={(value) => String(value)}
onChange={(value) => {
const next = clampTtsSegmentMaxBlockLength(value);
setLocalMaxBlockLength(next);
void updateConfigKey('ttsSegmentMaxBlockLength', next);
}}
/>
</div>
</Section>
)}
<RangeSetting
label="TTS segment max block length"
value={localMaxBlockLength}
min={TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN}
max={TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX}
step={TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP}
description="Max characters per TTS segment block."
valueWidth="w-14"
formatter={(value) => String(value)}
onChange={(value) => {
const next = clampTtsSegmentMaxBlockLength(value);
setLocalMaxBlockLength(next);
void updateConfigKey('ttsSegmentMaxBlockLength', next);
}}
/>
</div>
</Section>
{!epub && !html && (
<Section
@ -352,6 +354,30 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
/>
</Section>
)}
{html && (
<Section
title="Text & Markdown Highlighting"
subtitle="Playback highlighting in text/markdown mode."
variant="flat"
>
<ToggleRow
label="Highlight text during playback"
description="Highlight the current sentence in the rendered text."
checked={htmlHighlightEnabled}
onChange={(checked) => updateConfigKey('htmlHighlightEnabled', checked)}
variant="flat"
/>
<ToggleRow
label="Word-by-word highlighting"
description={`Highlight words using timing data${!canWordHighlight ? ' (disabled by config)' : ''}.`}
checked={htmlWordHighlightEnabled && htmlHighlightEnabled}
disabled={!htmlHighlightEnabled || !canWordHighlight}
onChange={(checked) => updateConfigKey('htmlWordHighlightEnabled', checked)}
variant="flat"
/>
</Section>
)}
</div>
</ReaderSidebarShell>
);

View file

@ -117,6 +117,9 @@ function formatLocatorGroupLabel(locator: TTSSegmentLocator | null): string {
return `Page ${Math.floor(locator.page)} · PDF`;
}
if (isHtmlLocator(locator)) {
if (/^\d+$/.test(locator.location)) {
return `Block ${locator.location} · HTML`;
}
return `${locator.location} · HTML`;
}
return 'Unknown location';

View file

@ -1,37 +1,217 @@
'use client';
import { useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import type { HtmlBlock } from '@/lib/client/html/blocks';
import {
clearHtmlSentenceHighlight,
clearHtmlWordHighlight,
highlightHtmlSentence,
highlightHtmlWord,
scrollSentenceIntoView,
} from '@/lib/client/html/highlight';
interface HTMLViewerProps {
className?: string;
currDocData?: string;
currDocName?: string;
blocks: HtmlBlock[];
isTxt: boolean;
isLoading?: boolean;
}
export function HTMLViewer({ className = '', currDocData, currDocName }: HTMLViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
export function HTMLViewer({
className = '',
blocks,
isTxt,
isLoading = false,
}: HTMLViewerProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
if (!currDocData) {
const {
currentSentence,
currentSentenceAlignment,
currentWordIndex,
} = useTTS();
const { htmlHighlightEnabled, htmlWordHighlightEnabled } = useConfig();
// ---- Sentence highlight scheduling -------------------------------------
// Mirrors PDFViewer: schedule + retry via setTimeout, with a sequence
// counter so stale retries from a previous sentence are aborted as soon as
// the user (or TTS) moves on. The cleanup only cancels pending timeouts —
// it does NOT clear the wrap. Wrap removal happens at the top of the next
// effect run, which keeps Strict Mode's mount→unmount→mount cycle from
// wiping the very first highlight.
const sentenceSeqRef = useRef(0);
const sentenceTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const wordSeqRef = useRef(0);
const wordTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearSentenceTimeouts = useCallback(() => {
for (const t of sentenceTimeoutsRef.current) clearTimeout(t);
sentenceTimeoutsRef.current = [];
}, []);
const scheduleSentence = useCallback((fn: () => void, ms: number) => {
const t = setTimeout(fn, ms);
sentenceTimeoutsRef.current.push(t);
}, []);
const clearWordTimeouts = useCallback(() => {
for (const t of wordTimeoutsRef.current) clearTimeout(t);
wordTimeoutsRef.current = [];
}, []);
const scheduleWord = useCallback((fn: () => void, ms: number) => {
const t = setTimeout(fn, ms);
wordTimeoutsRef.current.push(t);
}, []);
// Sentence highlight.
// The `blocks` dep ensures we re-attempt when ReactMarkdown rerenders for
// a new document (so the FIRST sentence after load gets a fresh tokenize).
useEffect(() => {
clearSentenceTimeouts();
if (!htmlHighlightEnabled || !currentSentence) {
// Invalidate any in-flight retries from a previous sentence and wipe
// whatever wrap is currently on screen — there's nothing valid to draw.
sentenceSeqRef.current += 1;
clearHtmlSentenceHighlight();
return;
}
// New highlight pass — bump the sequence so any retries still pending
// from a previous sentence short-circuit before touching the DOM.
const seq = ++sentenceSeqRef.current;
const tryApply = (attempt: number) => {
if (seq !== sentenceSeqRef.current) return;
const container = contentRef.current;
if (!container) {
if (attempt < 10) scheduleSentence(() => tryApply(attempt + 1), 75);
return;
}
// Clear any prior wrap right before we try to apply a new one. Doing it
// here (not in cleanup) avoids a Strict-Mode-induced wipe of the very
// first highlight.
clearHtmlSentenceHighlight();
const matched = highlightHtmlSentence(container, currentSentence);
if (matched) {
scrollSentenceIntoView(scrollRef.current);
return;
}
// DOM tokens couldn't satisfy the sentence yet (rare — async font load,
// late ReactMarkdown commit, etc.). Try again shortly.
if (attempt < 10) {
scheduleSentence(() => tryApply(attempt + 1), 75);
}
};
// Small initial defer so React's commit + browser layout finish before
// we walk the DOM. Matches the cadence PDFViewer uses.
scheduleSentence(() => tryApply(0), 30);
return () => {
clearSentenceTimeouts();
};
}, [
htmlHighlightEnabled,
currentSentence,
blocks,
clearSentenceTimeouts,
scheduleSentence,
]);
// Word highlight is layered inside the current sentence wrap. Same
// scheduling pattern as the sentence effect.
useEffect(() => {
clearWordTimeouts();
if (!htmlHighlightEnabled || !htmlWordHighlightEnabled) {
wordSeqRef.current += 1;
clearHtmlWordHighlight();
return;
}
if (
!currentSentenceAlignment ||
currentWordIndex === null ||
currentWordIndex === undefined ||
currentWordIndex < 0
) {
wordSeqRef.current += 1;
clearHtmlWordHighlight();
return;
}
const seq = ++wordSeqRef.current;
const tryApplyWord = (attempt: number) => {
if (seq !== wordSeqRef.current) return;
const container = contentRef.current;
if (!container) {
if (attempt < 8) scheduleWord(() => tryApplyWord(attempt + 1), 60);
return;
}
const ok = highlightHtmlWord(container, currentSentenceAlignment, currentWordIndex);
if (!ok && attempt < 8) {
// Sentence wrap may not have settled yet — retry briefly so the very
// first word of the very first sentence isn't dropped.
scheduleWord(() => tryApplyWord(attempt + 1), 60);
}
};
tryApplyWord(0);
return () => {
clearWordTimeouts();
};
}, [
htmlHighlightEnabled,
htmlWordHighlightEnabled,
currentSentenceAlignment,
currentWordIndex,
clearWordTimeouts,
scheduleWord,
]);
// Real cleanup on unmount — clear timeouts and tear down any leftover
// wraps. The empty deps ensure this only fires when HTMLViewer itself
// unmounts (route change, etc.), not on every prop/context update.
useEffect(() => {
return () => {
clearSentenceTimeouts();
clearWordTimeouts();
clearHtmlSentenceHighlight();
clearHtmlWordHighlight();
};
}, [clearSentenceTimeouts, clearWordTimeouts]);
if (isLoading || !blocks.length) {
return <DocumentSkeleton />;
}
// Check if the file is a txt file
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
return (
<div className={`flex flex-col h-full ${className}`} ref={containerRef}>
<div className="flex-1 overflow-auto">
<div className={`html-container min-w-full px-4 py-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose prose-base'}`}>
{isTxtFile ? (
currDocData
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currDocData}
</ReactMarkdown>
)}
<div className={`flex flex-col h-full ${className}`}>
<div ref={scrollRef} className="flex-1 overflow-auto">
<div
ref={contentRef}
className={`html-container min-w-full px-4 py-4 ${isTxt ? 'font-mono text-sm' : 'prose prose-base'}`}
>
{blocks.map((block) => (
<div
key={block.anchorId}
id={block.anchorId}
data-block-kind={block.kind}
className="openreader-html-block"
>
{isTxt ? (
<pre className="whitespace-pre-wrap font-mono text-sm m-0">{block.raw}</pre>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{block.raw}</ReactMarkdown>
)}
</div>
))}
</div>
</div>
</div>

View file

@ -50,6 +50,8 @@ interface ConfigContextType {
pdfWordHighlightEnabled: boolean;
epubHighlightEnabled: boolean;
epubWordHighlightEnabled: boolean;
htmlHighlightEnabled: boolean;
htmlWordHighlightEnabled: boolean;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
@ -358,6 +360,8 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled,
htmlHighlightEnabled,
htmlWordHighlightEnabled,
} = config || APP_CONFIG_DEFAULTS;
const providerType = useMemo(
() => resolveEffectiveProviderType({
@ -491,7 +495,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
pdfHighlightEnabled,
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled
epubWordHighlightEnabled,
htmlHighlightEnabled,
htmlWordHighlightEnabled,
}}>
{children}
</ConfigContext.Provider>

View file

@ -1036,6 +1036,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
// For PDFs and other documents, check page bounds
if (!isEPUB) {
// The HTML reader treats the entire document as a single page, so
// `currDocPages` is left `undefined`. In that mode there's nowhere to
// turn to — when we run past the last sentence, just stop playback.
// (We do this before the page-bound checks below so the `< undefined`
// / `>= undefined` NaN comparisons don't silently swallow the end.)
if (currDocPages === undefined) {
if (nextIndex >= sentences.length) {
setIsPlaying(false);
}
return;
}
// Handle next/previous page transitions
if ((nextIndex >= sentences.length && currDocPageNumber < currDocPages!) ||
(nextIndex < 0 && currDocPageNumber > 1)) {

View file

@ -0,0 +1,126 @@
import type { AudiobookSourceAdapter, PreparedAudiobookChapter } from '@/lib/client/audiobooks/pipeline';
import { normalizeTextForTts } from '@/lib/shared/nlp';
import type { HtmlBlock } from '@/lib/client/html/blocks';
interface HtmlAudiobookAdapterOptions {
blocks: HtmlBlock[];
isTxt: boolean;
smartSentenceSplitting: boolean;
maxBlockLength?: number;
/**
* For markdown: any heading with `headingLevel <= chapterHeadingLevel`
* begins a new chapter. Defaults to 2 (h1/h2).
*/
chapterHeadingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
/**
* Fallback chapter size (in blocks) used when no headings exist (MD) or for
* TXT documents.
*/
fallbackBlocksPerChapter?: number;
}
interface ChapterDraft {
title: string;
blocks: HtmlBlock[];
}
function buildChapterDrafts({
blocks,
isTxt,
chapterHeadingLevel = 2,
fallbackBlocksPerChapter = 50,
}: HtmlAudiobookAdapterOptions): ChapterDraft[] {
if (!blocks.length) return [];
if (!isTxt) {
const hasChapterHeadings = blocks.some(
(b) => b.kind === 'heading' && (b.headingLevel ?? 6) <= chapterHeadingLevel,
);
if (hasChapterHeadings) {
const drafts: ChapterDraft[] = [];
let current: ChapterDraft | null = null;
const prelude: HtmlBlock[] = [];
for (const block of blocks) {
const isChapterStart =
block.kind === 'heading' && (block.headingLevel ?? 6) <= chapterHeadingLevel;
if (isChapterStart) {
if (current) drafts.push(current);
current = {
title: block.headingText?.trim() || `Chapter ${drafts.length + 1}`,
blocks: [block],
};
continue;
}
if (!current) {
prelude.push(block);
continue;
}
current.blocks.push(block);
}
if (prelude.length) {
drafts.unshift({ title: 'Introduction', blocks: prelude });
}
if (current) drafts.push(current);
return drafts;
}
}
// Fallback: chunk by N blocks
const drafts: ChapterDraft[] = [];
for (let i = 0; i < blocks.length; i += fallbackBlocksPerChapter) {
drafts.push({
title: `Part ${drafts.length + 1}`,
blocks: blocks.slice(i, i + fallbackBlocksPerChapter),
});
}
return drafts;
}
function chapterText(
draft: ChapterDraft,
smartSentenceSplitting: boolean,
maxBlockLength?: number,
): string {
const joined = draft.blocks
.map((b) => b.plainText)
.filter((t) => t && t.trim())
.join('\n\n');
if (!joined) return '';
return smartSentenceSplitting ? normalizeTextForTts(joined, { maxBlockLength }) : joined;
}
function preparedChapters(options: HtmlAudiobookAdapterOptions): PreparedAudiobookChapter[] {
const drafts = buildChapterDrafts(options);
const out: PreparedAudiobookChapter[] = [];
for (const draft of drafts) {
const text = chapterText(draft, options.smartSentenceSplitting, options.maxBlockLength);
if (!text.trim()) continue;
out.push({
index: out.length,
title: draft.title,
text,
});
}
return out;
}
export function createHtmlAudiobookSourceAdapter(options: HtmlAudiobookAdapterOptions): AudiobookSourceAdapter {
return {
noContentMessage: 'No text content found in document',
noAudioGeneratedMessage: 'No audio was generated from the document content',
prepareChapters: async () => preparedChapters(options),
prepareChapter: async (chapterIndex: number) => {
const chapters = preparedChapters(options);
const chapter = chapters[chapterIndex];
if (!chapter) {
throw new Error('Invalid chapter index');
}
return chapter;
},
};
}

View file

@ -212,6 +212,10 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
raw.epubHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubHighlightEnabled,
epubWordHighlightEnabled:
raw.epubWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubWordHighlightEnabled,
htmlHighlightEnabled:
raw.htmlHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.htmlHighlightEnabled,
htmlWordHighlightEnabled:
raw.htmlWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.htmlWordHighlightEnabled,
firstVisit: raw.firstVisit === 'true',
documentListState,
};

View file

@ -0,0 +1,284 @@
/**
* Block-level parser for markdown and plain-text documents used by the HTML
* reader. The reader assigns one stable anchor per top-level block so each
* block can become a TTS segment locator (`{ readerType: 'html', location }`)
* and so sentence/word highlights have a scoped DOM root.
*
* The parser is intentionally hand-rolled and line-based: we don't need a full
* mdast tree, just block boundaries plus a TTS-clean `plainText`. ReactMarkdown
* still renders the raw markdown for each block, so inline formatting
* (bold/italic/links/code) round-trips visually.
*/
export type HtmlBlockKind =
| 'heading'
| 'paragraph'
| 'list'
| 'blockquote'
| 'code'
| 'table'
| 'hr';
export interface HtmlBlock {
index: number;
anchorId: string;
kind: HtmlBlockKind;
raw: string;
plainText: string;
headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
headingText?: string;
}
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
const SETEXT_H1_RE = /^=+\s*$/;
const SETEXT_H2_RE = /^-{2,}\s*$/;
const HR_RE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
const FENCE_RE = /^(\s*)(```+|~~~+)(.*)$/;
const BLOCKQUOTE_RE = /^\s{0,3}>/;
const LIST_RE = /^\s*(?:[-*+]|\d+[.)])\s+/;
const TABLE_SEP_RE = /^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)+\|?\s*$/;
const TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
export function anchorIdForIndex(index: number): string {
return `b-${index.toString().padStart(4, '0')}`;
}
export function splitMarkdownBlocks(source: string): HtmlBlock[] {
const lines = source.replace(/\r\n?/g, '\n').split('\n');
const blocks: HtmlBlock[] = [];
let i = 0;
const pushBlock = (
kind: HtmlBlockKind,
rawLines: string[],
extra: Partial<Pick<HtmlBlock, 'headingLevel' | 'headingText'>> = {},
) => {
const raw = rawLines.join('\n').replace(/\s+$/u, '');
if (!raw) return;
const plainText = mdToPlainText(raw, kind);
const index = blocks.length;
blocks.push({
index,
anchorId: anchorIdForIndex(index),
kind,
raw,
plainText,
...extra,
});
};
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i += 1;
continue;
}
const fence = FENCE_RE.exec(line);
if (fence) {
const marker = fence[2];
const start = i;
i += 1;
while (i < lines.length) {
const ll = lines[i];
const closing = FENCE_RE.exec(ll);
i += 1;
if (closing && closing[2].startsWith(marker[0]) && closing[2].length >= marker.length && !closing[3].trim()) {
break;
}
}
pushBlock('code', lines.slice(start, i));
continue;
}
if (HR_RE.test(line)) {
pushBlock('hr', [line]);
i += 1;
continue;
}
const heading = HEADING_RE.exec(line);
if (heading) {
const level = Math.min(6, heading[1].length) as 1 | 2 | 3 | 4 | 5 | 6;
const text = heading[2].replace(/\s+#+\s*$/, '').trim();
pushBlock('heading', [line], { headingLevel: level, headingText: text });
i += 1;
continue;
}
// Setext heading: previous-line text + underline of = or -
if (i + 1 < lines.length && lines[i].trim() && (SETEXT_H1_RE.test(lines[i + 1]) || SETEXT_H2_RE.test(lines[i + 1]))) {
const level: 1 | 2 = SETEXT_H1_RE.test(lines[i + 1]) ? 1 : 2;
const text = lines[i].trim();
pushBlock('heading', [lines[i], lines[i + 1]], { headingLevel: level, headingText: text });
i += 2;
continue;
}
if (BLOCKQUOTE_RE.test(line)) {
const start = i;
while (i < lines.length && (BLOCKQUOTE_RE.test(lines[i]) || (lines[i].trim() && !HEADING_RE.test(lines[i]) && !HR_RE.test(lines[i])))) {
i += 1;
}
pushBlock('blockquote', lines.slice(start, i));
continue;
}
if (LIST_RE.test(line)) {
const start = i;
while (i < lines.length) {
const ll = lines[i];
if (!ll.trim()) {
// peek: if next line continues the list, include the blank line; otherwise stop
if (i + 1 < lines.length && (LIST_RE.test(lines[i + 1]) || /^\s+\S/.test(lines[i + 1]))) {
i += 1;
continue;
}
break;
}
if (LIST_RE.test(ll) || /^\s+\S/.test(ll)) {
i += 1;
continue;
}
break;
}
pushBlock('list', lines.slice(start, i));
continue;
}
if (TABLE_ROW_RE.test(line) && i + 1 < lines.length && TABLE_SEP_RE.test(lines[i + 1])) {
const start = i;
i += 2;
while (i < lines.length && TABLE_ROW_RE.test(lines[i])) {
i += 1;
}
pushBlock('table', lines.slice(start, i));
continue;
}
// Paragraph: consume until blank line or a new block-level construct
const start = i;
while (i < lines.length) {
const ll = lines[i];
if (!ll.trim()) break;
if (HEADING_RE.test(ll) || HR_RE.test(ll) || FENCE_RE.test(ll) || BLOCKQUOTE_RE.test(ll) || LIST_RE.test(ll)) break;
if (i + 1 < lines.length && (SETEXT_H1_RE.test(lines[i + 1]) || SETEXT_H2_RE.test(lines[i + 1])) && i > start) break;
i += 1;
}
pushBlock('paragraph', lines.slice(start, i));
}
return blocks;
}
export function splitTxtBlocks(source: string): HtmlBlock[] {
const normalized = source.replace(/\r\n?/g, '\n');
const rawBlocks = normalized.split(/\n{2,}/);
const blocks: HtmlBlock[] = [];
for (const raw of rawBlocks) {
const trimmed = raw.replace(/\s+$/u, '');
if (!trimmed.trim()) continue;
const index = blocks.length;
blocks.push({
index,
anchorId: anchorIdForIndex(index),
kind: 'paragraph',
raw: trimmed,
plainText: trimmed.replace(/\s+/g, ' ').trim(),
});
}
return blocks;
}
export function parseHtmlBlocks(source: string, isTxt: boolean): HtmlBlock[] {
return isTxt ? splitTxtBlocks(source) : splitMarkdownBlocks(source);
}
/**
* Strip markdown formatting from a block to produce text suitable for TTS.
*
* This is intentionally conservative we keep semantic words but remove
* structural noise (heading hashes, list bullets, fence markers, link URLs).
*/
export function mdToPlainText(raw: string, kind: HtmlBlockKind): string {
if (kind === 'hr') return '';
let text = raw;
if (kind === 'code') {
text = text
.split('\n')
.filter((line) => !FENCE_RE.test(line))
.join('\n');
}
if (kind === 'heading') {
text = text.replace(HEADING_RE, '$2').replace(/\s+#+\s*$/, '');
// setext form: drop the underline line
text = text
.split('\n')
.filter((line) => !SETEXT_H1_RE.test(line) && !SETEXT_H2_RE.test(line))
.join('\n')
.trim();
}
if (kind === 'blockquote') {
text = text
.split('\n')
.map((line) => line.replace(/^\s{0,3}>\s?/, ''))
.join(' ');
}
if (kind === 'list') {
text = text
.split('\n')
.map((line) => line.replace(LIST_RE, '').replace(/^\s+/, ''))
.join(' ');
}
if (kind === 'table') {
text = text
.split('\n')
.filter((line) => !TABLE_SEP_RE.test(line))
.map((line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').replace(/\|/g, ' '))
.join(' ');
}
// Inline markdown stripping (run for every kind except code, where contents
// are read verbatim).
if (kind !== 'code') {
text = stripInlineMarkdown(text);
}
return text.replace(/\s+/g, ' ').trim();
}
function stripInlineMarkdown(text: string): string {
return text
// Image-link wrappers: [![alt](badge.svg)](https://...) — common for
// shields/license/CI badges. The visible DOM is just an `<a><img></a>`
// with no text node, so keeping the alt text in `plainText` would cause
// TTS to read words ("GitHub License", "build passing", …) that the
// sentence-highlight pattern matcher can't find in the rendered DOM,
// and the whole first-segment match falls below threshold. Drop them.
.replace(/\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)/g, '')
// Standalone images: ![alt](url) — same reasoning. The browser renders
// an `<img>` with no text content; alt is only read by AT, not TTS.
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
// Reference-style images: ![alt][ref] — drop for the same reason.
.replace(/!\[[^\]]*\]\[[^\]]*\]/g, '')
// links: [label](url) → label (label IS rendered as visible link text)
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
// reference-style links: [label][ref] → label
.replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1')
// bold/italic markers around words: **x**, *x*, __x__, _x_
.replace(/(\*\*|__)(.+?)\1/g, '$2')
.replace(/(\*|_)(.+?)\1/g, '$2')
// inline code: `x` → x
.replace(/`([^`]+)`/g, '$1')
// strikethrough: ~~x~~ → x
.replace(/~~(.+?)~~/g, '$1')
// stray html tags (also strips inline <img> / <svg> / <picture>)
.replace(/<[^>]+>/g, '');
}

View file

@ -0,0 +1,441 @@
/**
* HTML / TXT / MD reader highlight layer.
*
* Wraps text in `<span class="openreader-html-highlight-...">` so the visible
* background change works in every browser (no CSS Custom Highlight API
* dependency). The HTMLViewer renders the document once and never re-runs
* ReactMarkdown until a new doc is loaded, so wrapping DOM nodes is safe.
*
* Two layers:
* - `SENTENCE` softer translucent background covering the current TTS
* sentence
* - `WORD` saturated background on the currently-spoken word
*
* Word-to-DOM alignment is done via Needleman-Wunsch (same approach the PDF
* reader uses) so DOM token counts that diverge from whisper's word count
* still produce a smooth, monotonic word highlight rather than a proportional
* approximation that snaps around when the counts disagree.
*/
import { CmpStr } from 'cmpstr';
import type { TTSSentenceAlignment } from '@/types/tts';
export const HTML_SENTENCE_CLASS = 'openreader-html-highlight-sentence';
export const HTML_WORD_CLASS = 'openreader-html-highlight-word';
interface DomToken {
textNode: Text;
startOffset: number;
endOffset: number;
norm: string;
}
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
let sentenceWraps: HTMLSpanElement[] = [];
let wordWraps: HTMLSpanElement[] = [];
/**
* Per-sentence state used by the word highlighter. Built once when the
* sentence wrap is applied and then read by every word-advance event, so we
* don't re-walk the DOM or re-run the DP on every whisper tick.
*/
interface SentenceState {
sentence: string;
// DOM tokens inside the wrapped sentence, captured AFTER the sentence wrap
// is in place. Stable across word wrap/unwrap cycles because clear() calls
// `parent.normalize()` which restores the original text-node structure.
wordTokens: DomToken[];
// For an alignment we've already seen, the cached wordIndex → tokenIndex map.
alignment: TTSSentenceAlignment | null;
wordToToken: number[] | null;
}
let sentenceState: SentenceState | null = null;
function normalizeWord(word: string): string {
return word
.toLowerCase()
.replace(/[\p{P}\p{S}]+/gu, '')
.trim();
}
function tokenizePattern(pattern: string): string[] {
const out: string[] = [];
const wordRe = /\S+/g;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(pattern)) !== null) {
const norm = normalizeWord(m[0]);
if (norm) out.push(norm);
}
return out;
}
function unwrap(span: HTMLSpanElement): void {
const parent = span.parentNode;
if (!parent) return;
while (span.firstChild) {
parent.insertBefore(span.firstChild, span);
}
parent.removeChild(span);
if (typeof (parent as Element).normalize === 'function') {
(parent as Element).normalize();
}
}
export function clearHtmlSentenceHighlight(): void {
// Word wraps live inside sentence wraps. Tear them down first so we don't
// orphan them in the DOM when the sentence wrap is unwrapped — otherwise
// collectDomTokens would later refuse to walk those text nodes (they'd
// still be inside a highlight-class span) and the NEXT sentence highlight
// would silently miss matching tokens.
clearHtmlWordHighlight();
for (const span of sentenceWraps) unwrap(span);
sentenceWraps = [];
// A sentence clear also invalidates the word-mapping cache; the new
// sentence will get its own state when highlightHtmlSentence runs again.
sentenceState = null;
}
export function clearHtmlWordHighlight(): void {
for (const span of wordWraps) unwrap(span);
wordWraps = [];
}
function isHighlightWrapper(node: Node | null): node is HTMLSpanElement {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
const el = node as Element;
if (el.tagName !== 'SPAN') return false;
return el.classList.contains(HTML_SENTENCE_CLASS) || el.classList.contains(HTML_WORD_CLASS);
}
function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean } = { skipHighlightWraps: true }): DomToken[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
const tag = parent.tagName;
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') return NodeFilter.FILTER_REJECT;
if (opts.skipHighlightWraps) {
let ancestor: Node | null = parent;
while (ancestor && ancestor !== root) {
if (isHighlightWrapper(ancestor)) return NodeFilter.FILTER_REJECT;
ancestor = ancestor.parentNode;
}
}
return node.nodeValue && node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
const tokens: DomToken[] = [];
let current: Node | null = walker.nextNode();
while (current) {
const textNode = current as Text;
const text = textNode.nodeValue || '';
const wordRe = /\S+/g;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(text)) !== null) {
const norm = normalizeWord(m[0]);
if (!norm) continue;
tokens.push({
textNode,
startOffset: m.index,
endOffset: m.index + m[0].length,
norm,
});
}
current = walker.nextNode();
}
return tokens;
}
/**
* Walk only inside the current sentence wrap spans (used after the sentence
* wrap is applied; lets us index just the words *within* the highlighted
* sentence rather than the whole document).
*/
function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
const tokens: DomToken[] = [];
for (const wrap of wraps) {
const walker = document.createTreeWalker(wrap, NodeFilter.SHOW_TEXT);
let current: Node | null = walker.nextNode();
while (current) {
const t = current as Text;
const text = t.nodeValue || '';
const wordRe = /\S+/g;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(text)) !== null) {
const norm = normalizeWord(m[0]);
if (!norm) continue;
tokens.push({
textNode: t,
startOffset: m.index,
endOffset: m.index + m[0].length,
norm,
});
}
current = walker.nextNode();
}
}
return tokens;
}
function findBestWindow(tokens: DomToken[], patternTokens: string[]): { start: number; end: number } | null {
if (!tokens.length || !patternTokens.length) return null;
const pLen = patternTokens.length;
let bestStart = -1;
let bestEnd = -1;
let bestScore = 0;
for (let i = 0; i + Math.max(1, Math.ceil(pLen * 0.5)) - 1 < tokens.length; i += 1) {
if (tokens[i].norm !== patternTokens[0]) continue;
let matches = 1;
let domCursor = i + 1;
for (let p = 1; p < pLen && domCursor < tokens.length; p += 1) {
let stepped = false;
for (let k = 0; k < 3 && domCursor + k < tokens.length; k += 1) {
if (tokens[domCursor + k].norm === patternTokens[p]) {
matches += 1;
domCursor += k + 1;
stepped = true;
break;
}
}
if (!stepped) domCursor += 1;
}
const end = Math.min(tokens.length - 1, domCursor - 1);
const score = matches / pLen;
if (score > bestScore) {
bestScore = score;
bestStart = i;
bestEnd = end;
if (score >= 0.95) break;
}
}
if (bestScore >= 0.5 && bestStart !== -1) {
return { start: bestStart, end: bestEnd };
}
return null;
}
function wrapTokenRange(tokens: DomToken[], start: number, end: number, className: string): HTMLSpanElement[] {
const perNode = new Map<Text, { start: number; end: number }>();
for (let i = start; i <= end; i += 1) {
const t = tokens[i];
const existing = perNode.get(t.textNode);
if (existing) {
existing.start = Math.min(existing.start, t.startOffset);
existing.end = Math.max(existing.end, t.endOffset);
} else {
perNode.set(t.textNode, { start: t.startOffset, end: t.endOffset });
}
}
const wraps: HTMLSpanElement[] = [];
for (const [textNode, { start: s, end: e }] of perNode) {
const parent = textNode.parentNode;
if (!parent) continue;
try {
let target: Text = textNode;
if (s > 0) {
target = target.splitText(s);
}
const innerLen = e - s;
if (innerLen < target.length) {
target.splitText(innerLen);
}
const span = document.createElement('span');
span.className = className;
parent.insertBefore(span, target);
span.appendChild(target);
wraps.push(span);
} catch {
// skip any text node that can't be split (already wrapped, detached, etc.)
}
}
return wraps;
}
export function highlightHtmlSentence(
container: HTMLElement | null | undefined,
sentence: string | null | undefined,
): boolean {
clearHtmlSentenceHighlight();
if (!container || !sentence?.trim()) return false;
const patternTokens = tokenizePattern(sentence);
if (!patternTokens.length) return false;
const domTokens = collectDomTokens(container);
if (!domTokens.length) return false;
const win = findBestWindow(domTokens, patternTokens);
if (!win) return false;
sentenceWraps = wrapTokenRange(domTokens, win.start, win.end, HTML_SENTENCE_CLASS);
if (!sentenceWraps.length) return false;
// Capture the per-token DOM map AFTER the sentence wrap is in place so we
// can look up individual word tokens without re-walking the doc.
sentenceState = {
sentence,
wordTokens: collectTokensInsideWraps(sentenceWraps),
alignment: null,
wordToToken: null,
};
return true;
}
/**
* Build a wordIndex tokenIndex map via Needleman-Wunsch alignment between
* whisper's word list and the DOM tokens inside the sentence. Mirrors the
* approach in `src/lib/client/pdf.ts#highlightWordIndex` so PDF and HTML
* highlights behave the same way under count mismatches (contractions,
* stripped punctuation, missing whitespace, etc.).
*/
function buildAlignmentMap(
alignment: TTSSentenceAlignment,
domTokens: DomToken[],
): number[] {
const words = alignment.words || [];
const wordToToken = new Array<number>(words.length).fill(-1);
const domFiltered: { tokenIndex: number; norm: string }[] = [];
for (let i = 0; i < domTokens.length; i += 1) {
const norm = domTokens[i].norm;
if (norm) domFiltered.push({ tokenIndex: i, norm });
}
const ttsFiltered: { wordIndex: number; norm: string }[] = [];
for (let i = 0; i < words.length; i += 1) {
const norm = normalizeWord(words[i].text);
if (norm) ttsFiltered.push({ wordIndex: i, norm });
}
const m = domFiltered.length;
const n = ttsFiltered.length;
if (!m || !n) return wordToToken;
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(Number.POSITIVE_INFINITY),
);
const bt: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0),
); // 0=diag (substitute), 1=up (skip dom), 2=left (skip tts)
dp[0][0] = 0;
const GAP_COST = 0.7;
for (let i = 0; i <= m; i += 1) {
for (let j = 0; j <= n; j += 1) {
if (i > 0 && j > 0) {
const a = domFiltered[i - 1].norm;
const b = ttsFiltered[j - 1].norm;
const sim = a === b ? 1 : cmp.compare(a, b);
const cand = dp[i - 1][j - 1] + (1 - sim);
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 0;
}
}
if (i > 0) {
const cand = dp[i - 1][j] + GAP_COST;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 1;
}
}
if (j > 0) {
const cand = dp[i][j - 1] + GAP_COST;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 2;
}
}
}
}
let i = m;
let j = n;
while (i > 0 || j > 0) {
const move = bt[i][j];
if (i > 0 && j > 0 && move === 0) {
const domIdx = domFiltered[i - 1].tokenIndex;
const ttsIdx = ttsFiltered[j - 1].wordIndex;
if (wordToToken[ttsIdx] === -1) wordToToken[ttsIdx] = domIdx;
i -= 1;
j -= 1;
} else if (i > 0 && (move === 1 || j === 0)) {
i -= 1;
} else if (j > 0 && (move === 2 || i === 0)) {
j -= 1;
} else {
break;
}
}
// Forward-fill, then backward-fill, so every wordIndex has a nearest known
// DOM token. This keeps the word highlight stable when whisper emits a
// word that didn't survive normalization (e.g. an apostrophe-only token).
let lastSeen = -1;
for (let k = 0; k < wordToToken.length; k += 1) {
if (wordToToken[k] !== -1) lastSeen = wordToToken[k];
else if (lastSeen !== -1) wordToToken[k] = lastSeen;
}
let nextSeen = -1;
for (let k = wordToToken.length - 1; k >= 0; k -= 1) {
if (wordToToken[k] !== -1) nextSeen = wordToToken[k];
else if (nextSeen !== -1) wordToToken[k] = nextSeen;
}
return wordToToken;
}
export function highlightHtmlWord(
container: HTMLElement | null | undefined,
alignment: TTSSentenceAlignment | undefined,
wordIndex: number | null | undefined,
): boolean {
// Always tear down the previous word wrap first. The `unwrap` call
// normalizes the parent, restoring the post-sentence-wrap text-node
// structure that `sentenceState.wordTokens` points at, so the cached map
// stays valid across consecutive word advances.
clearHtmlWordHighlight();
if (!container || !alignment) return false;
if (wordIndex === null || wordIndex === undefined || wordIndex < 0) return false;
if (!sentenceState || !sentenceState.wordTokens.length) return false;
if (!sentenceWraps.length) return false;
const words = alignment.words || [];
if (!words.length || wordIndex >= words.length) return false;
// (Re)build the alignment map when this is a new alignment object.
if (sentenceState.alignment !== alignment || !sentenceState.wordToToken) {
sentenceState.alignment = alignment;
sentenceState.wordToToken = buildAlignmentMap(alignment, sentenceState.wordTokens);
}
const tokenIndex = sentenceState.wordToToken[wordIndex];
if (tokenIndex === undefined || tokenIndex < 0) return false;
if (tokenIndex >= sentenceState.wordTokens.length) return false;
wordWraps = wrapTokenRange(sentenceState.wordTokens, tokenIndex, tokenIndex, HTML_WORD_CLASS);
return wordWraps.length > 0;
}
/**
* Scroll the first sentence wrapper into view if it's not already visible.
* Cheap idempotent call after highlightHtmlSentence.
*/
export function scrollSentenceIntoView(container: HTMLElement | null | undefined): void {
if (!container || !sentenceWraps.length) return;
const first = sentenceWraps[0];
const containerRect = container.getBoundingClientRect();
const rect = first.getBoundingClientRect();
const above = rect.top < containerRect.top + 40;
const below = rect.bottom > containerRect.bottom - 40;
if (above || below) {
first.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}

View file

@ -115,6 +115,14 @@ export function compareSegmentLocators(
return Math.floor(a.page) - Math.floor(b.page);
}
if (isHtmlLocator(a) && isHtmlLocator(b)) {
// When both locations look like positive integers (HTML reader blocks),
// compare numerically so "10" sorts after "2" instead of between "1" and
// "2". Falls back to lexicographic for legacy free-form locations.
const an = /^\d+$/.test(a.location) ? Number(a.location) : NaN;
const bn = /^\d+$/.test(b.location) ? Number(b.location) : NaN;
if (Number.isFinite(an) && Number.isFinite(bn)) {
return an - bn;
}
return a.location.localeCompare(b.location);
}
// One or both are legacy/draft — fall back to grouped-key compare so the

View file

@ -79,6 +79,8 @@ export interface AppConfigValues {
pdfWordHighlightEnabled: boolean;
epubHighlightEnabled: boolean;
epubWordHighlightEnabled: boolean;
htmlHighlightEnabled: boolean;
htmlWordHighlightEnabled: boolean;
firstVisit: boolean;
documentListState: DocumentListState;
privacyAccepted: boolean;
@ -127,6 +129,8 @@ export function getAppConfigDefaults(): AppConfigValues {
pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
epubHighlightEnabled: true,
epubWordHighlightEnabled: wordHighlightEnabledByDefault,
htmlHighlightEnabled: true,
htmlWordHighlightEnabled: wordHighlightEnabledByDefault,
firstVisit: false,
documentListState: {
sortBy: 'name',

View file

@ -24,6 +24,8 @@ export const SYNCED_PREFERENCE_KEYS = [
'pdfWordHighlightEnabled',
'epubHighlightEnabled',
'epubWordHighlightEnabled',
'htmlHighlightEnabled',
'htmlWordHighlightEnabled',
] as const;
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];

View file

@ -0,0 +1,162 @@
import { expect, test } from '@playwright/test';
import { createHtmlAudiobookSourceAdapter } from '../../src/lib/client/audiobooks/adapters/html';
import { parseHtmlBlocks, type HtmlBlock } from '../../src/lib/client/html/blocks';
const blocksFromMd = (src: string): HtmlBlock[] => parseHtmlBlocks(src, false);
const blocksFromTxt = (src: string): HtmlBlock[] => parseHtmlBlocks(src, true);
test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', () => {
test('starts a new chapter at each h1/h2 heading by default', async () => {
const blocks = blocksFromMd(
[
'# Alpha',
'',
'First chapter body.',
'',
'## Beta',
'',
'Second chapter body.',
'',
'### Gamma',
'',
'Subsection — should NOT begin a new chapter (h3 by default).',
'',
'## Delta',
'',
'Third chapter body.',
].join('\n'),
);
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
smartSentenceSplitting: false,
});
const chapters = await adapter.prepareChapters();
expect(chapters.map((c) => c.title)).toEqual(['Alpha', 'Beta', 'Delta']);
// Subsection content lands inside its parent chapter (Beta).
expect(chapters[1].text).toContain('Subsection');
});
test('treats blocks before the first heading as an "Introduction" chapter', async () => {
const blocks = blocksFromMd(
['Top-of-doc paragraph.', '', '# First Heading', '', 'Body.'].join('\n'),
);
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
smartSentenceSplitting: false,
});
const chapters = await adapter.prepareChapters();
expect(chapters.map((c) => c.title)).toEqual(['Introduction', 'First Heading']);
expect(chapters[0].text).toContain('Top-of-doc paragraph.');
expect(chapters[1].text).toContain('Body.');
});
test('falls back to "Part N" chunks when markdown has no chapter-level headings', async () => {
// No #/## headings → fallback path. Use a small fallback size for the
// test so we don't have to generate 50+ blocks.
const blocks = blocksFromMd(
Array.from({ length: 7 }, (_, i) => `Paragraph ${i + 1}.`).join('\n\n'),
);
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
smartSentenceSplitting: false,
fallbackBlocksPerChapter: 3,
});
const chapters = await adapter.prepareChapters();
expect(chapters.map((c) => c.title)).toEqual(['Part 1', 'Part 2', 'Part 3']);
// 7 blocks / 3 per chapter → 3, 3, 1.
expect(chapters[0].text).toContain('Paragraph 1.');
expect(chapters[0].text).toContain('Paragraph 3.');
expect(chapters[1].text).toContain('Paragraph 4.');
expect(chapters[1].text).toContain('Paragraph 6.');
expect(chapters[2].text).toContain('Paragraph 7.');
expect(chapters[2].text).not.toContain('Paragraph 6.');
});
test('honors a custom chapterHeadingLevel (e.g. h1 only)', async () => {
const blocks = blocksFromMd(
['# Alpha', '', 'A body.', '', '## Beta', '', 'B body.', '', '# Gamma', '', 'G body.'].join(
'\n',
),
);
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
smartSentenceSplitting: false,
chapterHeadingLevel: 1,
});
const chapters = await adapter.prepareChapters();
// h2 (Beta) is absorbed into the Alpha chapter when only h1 starts chapters.
expect(chapters.map((c) => c.title)).toEqual(['Alpha', 'Gamma']);
expect(chapters[0].text).toContain('Beta');
expect(chapters[0].text).toContain('B body.');
});
});
test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => {
test('chunks TXT documents into "Part N" of 50 blocks by default', async () => {
const blocks = blocksFromTxt(
Array.from({ length: 120 }, (_, i) => `Block ${i + 1}.`).join('\n\n'),
);
expect(blocks.length).toBe(120);
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: true,
smartSentenceSplitting: false,
});
const chapters = await adapter.prepareChapters();
expect(chapters.map((c) => c.title)).toEqual(['Part 1', 'Part 2', 'Part 3']);
// First chapter contains blocks 1..50, last contains 101..120.
expect(chapters[0].text).toContain('Block 1.');
expect(chapters[0].text).toContain('Block 50.');
expect(chapters[0].text).not.toContain('Block 51.');
expect(chapters[2].text).toContain('Block 101.');
expect(chapters[2].text).toContain('Block 120.');
});
test('ignores headings in TXT mode (everything goes through the Part-N path)', async () => {
// A line that LOOKS like a markdown heading in a .txt file is still
// just text — we shouldn't carve a chapter at it.
const blocks = blocksFromTxt('# Looks like a heading\n\nBut TXT mode treats it as a paragraph.');
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: true,
smartSentenceSplitting: false,
});
const chapters = await adapter.prepareChapters();
expect(chapters.length).toBe(1);
expect(chapters[0].title).toBe('Part 1');
});
});
test.describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => {
test('returns the same chapter by index that prepareChapters lists', async () => {
const blocks = blocksFromMd(['# A', '', 'one', '', '## B', '', 'two'].join('\n'));
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
smartSentenceSplitting: false,
});
const list = await adapter.prepareChapters();
const second = await adapter.prepareChapter(1);
expect(second.title).toBe(list[1].title);
expect(second.text).toBe(list[1].text);
});
test('throws on out-of-range chapter index', async () => {
const blocks = blocksFromMd('# Only\n\nbody.');
const adapter = createHtmlAudiobookSourceAdapter({
blocks,
isTxt: false,
smartSentenceSplitting: false,
});
await expect(adapter.prepareChapter(42)).rejects.toThrow(/invalid chapter index/i);
});
});

View file

@ -0,0 +1,148 @@
import { expect, test } from '@playwright/test';
import {
mdToPlainText,
parseHtmlBlocks,
splitMarkdownBlocks,
splitTxtBlocks,
} from '../../src/lib/client/html/blocks';
test.describe('parseHtmlBlocks (markdown)', () => {
test('splits headings, paragraphs, and lists into separate blocks', () => {
const src = [
'# Title',
'',
'First paragraph of text.',
'',
'- one',
'- two',
'',
'## Subhead',
'',
'Second paragraph.',
].join('\n');
const blocks = parseHtmlBlocks(src, false);
expect(blocks.map((b) => b.kind)).toEqual([
'heading',
'paragraph',
'list',
'heading',
'paragraph',
]);
expect(blocks[0].headingLevel).toBe(1);
expect(blocks[0].headingText).toBe('Title');
expect(blocks[3].headingLevel).toBe(2);
expect(blocks[3].headingText).toBe('Subhead');
});
test('assigns stable padded anchor ids in document order', () => {
const src = '# A\n\nB\n\nC';
const blocks = parseHtmlBlocks(src, false);
expect(blocks.map((b) => b.anchorId)).toEqual(['b-0000', 'b-0001', 'b-0002']);
});
test('treats fenced code blocks as a single block and preserves content verbatim', () => {
const src = ['Intro.', '', '```ts', 'const x = 1;', 'const y = 2;', '```', '', 'Outro.'].join(
'\n',
);
const blocks = parseHtmlBlocks(src, false);
expect(blocks.map((b) => b.kind)).toEqual(['paragraph', 'code', 'paragraph']);
// Code plainText keeps the inner lines as-is (fence lines stripped).
expect(blocks[1].plainText).toContain('const x = 1;');
expect(blocks[1].plainText).toContain('const y = 2;');
// Fence markers themselves shouldn't survive into plainText.
expect(blocks[1].plainText).not.toContain('```');
});
});
test.describe('parseHtmlBlocks (txt)', () => {
test('splits on blank-line boundaries and preserves intra-block whitespace', () => {
const src = 'First block\nmore.\n\nSecond block.\n\n\nThird block.';
const blocks = parseHtmlBlocks(src, true);
expect(blocks.length).toBe(3);
expect(blocks[0].kind).toBe('paragraph');
expect(blocks[0].plainText).toBe('First block more.');
expect(blocks[1].plainText).toBe('Second block.');
expect(blocks[2].plainText).toBe('Third block.');
});
test('skips empty blocks produced by trailing newlines', () => {
const blocks = splitTxtBlocks('A\n\n\n\n');
expect(blocks.length).toBe(1);
expect(blocks[0].plainText).toBe('A');
});
});
test.describe('mdToPlainText badge/image stripping', () => {
// The bug: badge alt text was being kept in plainText. Since the rendered
// DOM is just an <img> with no text node, the sentence-highlight pattern
// matcher couldn't find those words and the WHOLE first-segment match
// silently dropped below threshold. These tests are the regression guard.
test('drops image-link wrappers entirely ([![alt](badge)](link) → "")', () => {
expect(
mdToPlainText(
'[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)',
'paragraph',
),
).toBe('');
});
test('drops standalone images entirely (![alt](url) → "")', () => {
expect(mdToPlainText('![cover](cover.jpg)', 'paragraph')).toBe('');
});
test('drops reference-style images (![alt][ref] → "")', () => {
expect(mdToPlainText('![cover][cover-ref]', 'paragraph')).toBe('');
});
test('drops inline <img> HTML tags', () => {
expect(mdToPlainText('<img src="x.png" alt="x"/>', 'paragraph')).toBe('');
});
test('keeps surrounding text when a paragraph mixes badges and prose', () => {
const out = mdToPlainText(
'[![Build](build.svg)](ci) Welcome to the project. ![logo](logo.png) Read on.',
'paragraph',
);
// Badge / image syntax gone; visible prose preserved (with collapsed
// whitespace from the trim/normalize pass).
expect(out).toBe('Welcome to the project. Read on.');
});
test('still extracts visible label text from regular links', () => {
// Links DO render visible text in the DOM, so the label must survive.
expect(mdToPlainText('See the [docs](https://example.com) for more.', 'paragraph')).toBe(
'See the docs for more.',
);
});
test('strips inline markdown emphasis but keeps the words', () => {
expect(mdToPlainText('A **bold** and *italic* and `code` word.', 'paragraph')).toBe(
'A bold and italic and code word.',
);
});
});
test.describe('buildFullDocumentText-style integration (badge-only blocks)', () => {
// If a paragraph is composed only of badges, its plainText is empty after
// stripping. The reader filters empty plainText out of the TTS source
// (`useHtmlDocument#buildFullDocumentText`), so badge blocks don't generate
// a phantom segment that would later fail to highlight.
test('badge-only paragraphs collapse to empty plainText', () => {
const src = [
'# Project',
'',
'[![License](license.svg)](LICENSE) [![Build](build.svg)](ci)',
'',
'Real description here.',
].join('\n');
const blocks = splitMarkdownBlocks(src);
expect(blocks.length).toBe(3);
const [heading, badgesBlock, description] = blocks;
expect(heading.plainText).toBe('Project');
expect(badgesBlock.plainText.trim()).toBe('');
expect(description.plainText).toBe('Real description here.');
});
});

View file

@ -112,6 +112,24 @@ test.describe('compareSegmentLocators', () => {
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
});
test('orders HTML locators by location numerically when both look like integers', () => {
// Same regression class as the EPUB spineIndex test: "2" must come BEFORE
// "10" in the segments sidebar, not after it (lexicographic compare would
// sort "10" between "1" and "2").
const a: TTSSegmentLocator = { readerType: 'html', location: '2' };
const b: TTSSegmentLocator = { readerType: 'html', location: '10' };
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
expect(compareSegmentLocators(b, a)).toBeGreaterThan(0);
});
test('falls back to lexicographic compare for free-form HTML locations', () => {
// Legacy / non-numeric HTML locations (e.g. anchor ids) should still
// produce a stable ordering.
const a: TTSSegmentLocator = { readerType: 'html', location: '#alpha' };
const b: TTSSegmentLocator = { readerType: 'html', location: '#beta' };
expect(compareSegmentLocators(a, b)).toBeLessThan(0);
});
test('null locators sort last', () => {
expect(compareSegmentLocators(null, { readerType: 'pdf', page: 1 })).toBeGreaterThan(0);
expect(compareSegmentLocators({ readerType: 'pdf', page: 1 }, null)).toBeLessThan(0);