diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index 00d441b..d3f6e55 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -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(null); const [isLoading, setIsLoading] = useState(true); - const [activeSidebar, setActiveSidebar] = useState(null); + const [activeSidebar, setActiveSidebar] = useState(null); const [containerHeight, setContainerHeight] = useState('auto'); const [padPct, setPadPct] = useState(100); // 0..100 (100 = full width) const [maxPadPx, setMaxPadPx] = useState(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 (
@@ -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() { } />
- {isLoading ? ( + {isLoading || !currDocData ? (
) : (
- +
)}
+ {canExportAudiobook && ( + setActiveSidebar((prev) => isOpen ? 'audiobook' : (prev === 'audiobook' ? null : prev))} + documentType="html" + documentId={id as string} + onGenerateAudiobook={handleGenerateAudiobook} + onRegenerateChapter={handleRegenerateChapter} + /> + )} {isAtLimit ? (
diff --git a/src/app/(app)/html/[id]/useHtmlDocument.ts b/src/app/(app)/html/[id]/useHtmlDocument.ts index 22dec1f..e8e854d 100644 --- a/src/app/(app)/html/[id]/useHtmlDocument.ts +++ b/src/app/(app)/html/[id]/useHtmlDocument.ts @@ -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; clearCurrDoc: () => void; + createFullAudioBook: ( + onProgress: (progress: number) => void, + signal?: AbortSignal, + onChapterComplete?: (chapter: TTSAudiobookChapter) => void, + providedBookId?: string, + format?: TTSAudiobookFormat, + settings?: AudiobookGenerationSettings, + retryOptions?: TTSRetryOptions, + ) => Promise; + regenerateChapter: ( + chapterIndex: number, + bookId: string, + format: TTSAudiobookFormat, + signal: AbortSignal, + settings?: AudiobookGenerationSettings, + retryOptions?: TTSRetryOptions, + ) => Promise; +} + +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(); const [currDocName, setCurrDocName] = useState(); - const [currDocText, setCurrDocText] = useState(); + 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(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 => { + 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 => { + 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, + ], ); } diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 7cc0c2f..96191fe 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -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': diff --git a/src/app/globals.css b/src/app/globals.css index 2b661aa..7c8b7db 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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%; diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index f15828f..314393e 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -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, diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index c586d0a..651751f 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -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]" >
- {!html && ( -
+
+ {!html && ( updateConfigKey('skipBlank', checked)} variant="flat" /> + )} - updateConfigKey('smartSentenceSplitting', checked)} - variant="flat" + updateConfigKey('smartSentenceSplitting', checked)} + variant="flat" + /> + +
+ String(value)} + onChange={(value) => { + const next = clampSegmentPreloadDepth(value); + setLocalPreloadDepth(next); + void updateConfigKey('segmentPreloadDepthPages', next); + }} /> -
- String(value)} - onChange={(value) => { - const next = clampSegmentPreloadDepth(value); - setLocalPreloadDepth(next); - void updateConfigKey('segmentPreloadDepthPages', next); - }} - /> + String(value)} + onChange={(value) => { + const next = clampSegmentPreloadSentenceLookahead(value); + setLocalSentenceLookahead(next); + void updateConfigKey('segmentPreloadSentenceLookahead', next); + }} + /> - String(value)} - onChange={(value) => { - const next = clampSegmentPreloadSentenceLookahead(value); - setLocalSentenceLookahead(next); - void updateConfigKey('segmentPreloadSentenceLookahead', next); - }} - /> - - String(value)} - onChange={(value) => { - const next = clampTtsSegmentMaxBlockLength(value); - setLocalMaxBlockLength(next); - void updateConfigKey('ttsSegmentMaxBlockLength', next); - }} - /> -
-
- )} + String(value)} + onChange={(value) => { + const next = clampTtsSegmentMaxBlockLength(value); + setLocalMaxBlockLength(next); + void updateConfigKey('ttsSegmentMaxBlockLength', next); + }} + /> +
+ {!epub && !html && (
)} + + {html && ( +
+ updateConfigKey('htmlHighlightEnabled', checked)} + variant="flat" + /> + updateConfigKey('htmlWordHighlightEnabled', checked)} + variant="flat" + /> +
+ )}
); diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 085f836..1e79ccc 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -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'; diff --git a/src/components/views/HTMLViewer.tsx b/src/components/views/HTMLViewer.tsx index 2657191..d67c551 100644 --- a/src/components/views/HTMLViewer.tsx +++ b/src/components/views/HTMLViewer.tsx @@ -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(null); +export function HTMLViewer({ + className = '', + blocks, + isTxt, + isLoading = false, +}: HTMLViewerProps) { + const scrollRef = useRef(null); + const contentRef = useRef(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[]>([]); + const wordSeqRef = useRef(0); + const wordTimeoutsRef = useRef[]>([]); + + 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 ; } - // Check if the file is a txt file - const isTxtFile = currDocName?.toLowerCase().endsWith('.txt'); - return ( -
-
-
- {isTxtFile ? ( - currDocData - ) : ( - - {currDocData} - - )} +
+
+
+ {blocks.map((block) => ( +
+ {isTxt ? ( +
{block.raw}
+ ) : ( + {block.raw} + )} +
+ ))}
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index fc7bee6..2fb4154 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -50,6 +50,8 @@ interface ConfigContextType { pdfWordHighlightEnabled: boolean; epubHighlightEnabled: boolean; epubWordHighlightEnabled: boolean; + htmlHighlightEnabled: boolean; + htmlWordHighlightEnabled: boolean; } const ConfigContext = createContext(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} diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 1350c9f..fa4b428 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -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)) { diff --git a/src/lib/client/audiobooks/adapters/html.ts b/src/lib/client/audiobooks/adapters/html.ts new file mode 100644 index 0000000..9880c5a --- /dev/null +++ b/src/lib/client/audiobooks/adapters/html.ts @@ -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; + }, + }; +} diff --git a/src/lib/client/dexie.ts b/src/lib/client/dexie.ts index 44143f9..6f505ae 100644 --- a/src/lib/client/dexie.ts +++ b/src/lib/client/dexie.ts @@ -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, }; diff --git a/src/lib/client/html/blocks.ts b/src/lib/client/html/blocks.ts new file mode 100644 index 0000000..18c52ce --- /dev/null +++ b/src/lib/client/html/blocks.ts @@ -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> = {}, + ) => { + 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 `` + // 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 `` 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 / / ) + .replace(/<[^>]+>/g, ''); +} diff --git a/src/lib/client/html/highlight.ts b/src/lib/client/html/highlight.ts new file mode 100644 index 0000000..104765d --- /dev/null +++ b/src/lib/client/html/highlight.ts @@ -0,0 +1,441 @@ +/** + * HTML / TXT / MD reader highlight layer. + * + * Wraps text in `` 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(); + 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(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(n + 1).fill(Number.POSITIVE_INFINITY), + ); + const bt: number[][] = Array.from({ length: m + 1 }, () => + new Array(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' }); + } +} diff --git a/src/lib/shared/tts-locator.ts b/src/lib/shared/tts-locator.ts index e37d383..54db024 100644 --- a/src/lib/shared/tts-locator.ts +++ b/src/lib/shared/tts-locator.ts @@ -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 diff --git a/src/types/config.ts b/src/types/config.ts index 29d55e0..7601b06 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -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', diff --git a/src/types/user-state.ts b/src/types/user-state.ts index d011f82..7a0e275 100644 --- a/src/types/user-state.ts +++ b/src/types/user-state.ts @@ -24,6 +24,8 @@ export const SYNCED_PREFERENCE_KEYS = [ 'pdfWordHighlightEnabled', 'epubHighlightEnabled', 'epubWordHighlightEnabled', + 'htmlHighlightEnabled', + 'htmlWordHighlightEnabled', ] as const; export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number]; diff --git a/tests/unit/html-audiobook-adapter.spec.ts b/tests/unit/html-audiobook-adapter.spec.ts new file mode 100644 index 0000000..15c1a31 --- /dev/null +++ b/tests/unit/html-audiobook-adapter.spec.ts @@ -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); + }); +}); diff --git a/tests/unit/html-blocks.spec.ts b/tests/unit/html-blocks.spec.ts new file mode 100644 index 0000000..533d153 --- /dev/null +++ b/tests/unit/html-blocks.spec.ts @@ -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 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 HTML tags', () => { + expect(mdToPlainText('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.'); + }); +}); diff --git a/tests/unit/tts-locator.spec.ts b/tests/unit/tts-locator.spec.ts index 1cc1d8e..19e0eee 100644 --- a/tests/unit/tts-locator.spec.ts +++ b/tests/unit/tts-locator.spec.ts @@ -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);