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: [](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:  — 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
/