tts-voice-creator-clone-and.../static/js/reader.js
mARTin-B78 ea50267c30 Add unified Studio casting workflow and fix voice/casting pipeline bugs
Introduces the new Studio section (Source -> Characters -> Voices ->
Perform & Export) that reuses the existing Read Aloud/Library/Script
Rehearsal code via DOM reparenting instead of duplicating it, and rolls up
a long tail of bugs found while producing a real audiobook through it:
umlaut-eating name sanitizers, a voice picker that mispositioned itself and
capped results at 60, PDF pagination silently breaking on trimmed \f
markers, a race letting stale audio keep playing after a new line was
clicked, an alias-overlap bug that could silently redirect a voice/image
save onto the wrong character, voice design failing outright during brief
TTS backend restarts instead of retrying, sparse cast entries defaulting to
English/wrong gender, and a reassigned voice never reaching an already-open
Stage session or invalidating its cached audio. Also adds a persistent
per-line audio cache, audiobook export browsing/download, and an inline
voice-design prompt editor. Full details in CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 02:03:56 +02:00

2166 lines
99 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Read Aloud — PDF/TXT document reader with live word highlighting ────────
//
// Imports a PDF (rendered to canvas via pdf.js with a highlight overlay on the
// real text) or a plain-text file, then reads it sentence-by-sentence with a
// chosen voice/backend/speed while highlighting the word being spoken.
//
// Per-sentence synthesis state is colour-coded: red = not synthesised,
// yellow = synthesising, green = ready (cached), blue = currently reading.
// PDF view supports zoom (fit-width / fit-height / two-page / ±). Reading
// position is remembered so the same document resumes where it left off.
//
// Reuses existing globals: loadPdfJs() + computeWordTimings() (rehearser.js),
// fetchTtsPreviewBlob() (tts-preview.js), ttsBackendOptions()/availableTtsBackends()
// (settings.js), and $ / escHtml / toast (utils.js).
const readerState = {
mode: null, // 'pdf' | 'text'
title: '',
pdfDoc: null,
pages: [], // [{ pageDiv, overlay, page, base, rendered, renderTask }]
sentences: [], // [{ text, words:[{page,x,top,w,h} | {el}], status, _stat }]
idx: 0,
readingIdx: -1, // the single unit currently shown as "reading" (avoids full scans)
playing: false,
speed: 1,
scale: 1,
zoomMode:'fit-width', // 'fit-width' | 'fit-height' | 'two' | 'custom'
audioCtx: null,
currentSource: null,
raf: null,
blobCache: new Map(), // idx -> Blob
bufCache: new Map(), // idx -> AudioBuffer
gainCache: new Map(), // idx -> normalisation gain
unitsByPage: new Map(),// page -> [unit indices] for lazy status-box creation
baseSentences: [], // raw per-sentence units (before chunk grouping)
chunkMode: 'sentence',// 'sentence' | 'paragraph' | 'page' — synthesis chunk size
normalize: true, // even out loudness between chunks on playback
io: null, // IntersectionObserver for lazy page raster
_seq: 0, // import generation guard
synthRunning: false,
synthCancel: false,
selecting: false, // click-to-select-range mode (stays on until Done/Esc)
selStart: null,
selEnd: null,
selAnchored: false, // true after the first click, waiting for the end click
fileBlob: null, // original imported file (Blob) — kept so PDFs can be re-rendered
docText: '', // original text (for text-mode docs)
savedId: null, // server library doc id when this doc came from / was saved to the library
savedAudioIdx: new Set(), // unit indices whose audio is already on the server (incremental save / lazy fetch)
sourceUploaded: false, // the source document has been uploaded to the server
ocrWorker: null, // lazily-created Tesseract.js worker (terminated after each extraction)
synthStarted: false, // true once any unit leaves 'pending' — gates the red "not synthesised" tint
};
window.readerState = readerState;
const READER_RESUME_KEY = 'reader-resume';
const READER_FMT = 'mp3'; // synthesise + store as MP3 (compact; decodeAudioData plays it)
const READER_BUF_WINDOW = 3; // keep decoded PCM only for ±N units around the playhead
const READER_CANVAS_MARGIN = 2200; // px; raster canvases beyond this from view are released
const READER_IMPORT_YIELD_EVERY = 350;
const READER_PDF_INITIAL_PAGES = 16;
const READER_PDF_INITIAL_MAX_WITHOUT_TEXT = 64;
const READER_OCR_LANGS = 'deu+eng'; // headline OCR: fixed dual-pack (covers German prose + digits/Latin chapter numerals)
const READER_OCR_MIN_GAP_RATIO = 0.08; // treat a text-free band at the top of a page as a possible image heading once it's this tall...
const READER_OCR_MIN_GAP_PX = 40; // ...or at least this many px (whichever is larger)
const READER_OCR_RENDER_SCALE = 2.5; // rasterization DPI multiplier fed to Tesseract
const READER_OCR_MIN_CONFIDENCE = 40; // discard OCR guesses below this confidence (0-100)
function readerCtx() {
if (!readerState.audioCtx) readerState.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (readerState.audioCtx.state === 'suspended') readerState.audioCtx.resume().catch(() => {});
return readerState.audioCtx;
}
function readerYield() {
return new Promise(resolve => setTimeout(resolve, 0));
}
// ── Section enter / leave hooks (wired from nav.js) ─────────────────────────
window.readerOnShow = async function () {
const sel = $('reader-backend-select');
if (sel && (!sel.value || sel.options.length <= 1)) {
// Backends may not have loaded yet (or loaded before this section existed) —
// fetch them on demand so the dropdown fills instead of staying on "Checking…".
if (typeof availableTtsBackends === 'function' && !availableTtsBackends().length &&
typeof refreshTtsBackendAvailability === 'function') {
try { await refreshTtsBackendAvailability(); } catch (_) {}
}
if (typeof availableTtsBackends === 'function' && availableTtsBackends().length) {
sel.innerHTML = ttsBackendOptions(sel.value);
sel.disabled = false;
}
}
readerUpdateBackendHint();
if (window.VoicePicker) VoicePicker.upgrade('reader-voice-select');
readerRenderLibrary();
};
window.readerStop = function () { readerSaveResume(); readerPersistProgress(); readerStopPlayback(); };
// ── Backend + voice pickers ─────────────────────────────────────────────────
async function readerFetchVoices() {
const btn = $('reader-fetch-voices-btn');
const backend = $('reader-backend-select')?.value;
if (!backend) { toast('No available TTS backend', 'error'); return; }
if (btn) btn.disabled = true;
try {
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
const ids = (Array.isArray(raw) ? raw : []).map(v => (typeof v === 'string' ? v : (v.id || v.name || v.voice_id))).filter(Boolean);
const sel = $('reader-voice-select'); if (!sel) return;
const prev = sel.value;
if (window.VoicePicker) {
VoicePicker.upgrade('reader-voice-select');
VoicePicker.populate('reader-voice-select', ids);
if (prev && ids.includes(prev)) VoicePicker.setValue('reader-voice-select', prev);
else if (ids.length) VoicePicker.setValue('reader-voice-select', ids[0]);
} else {
sel.innerHTML = '<option value="">— select a voice —</option>';
ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); });
if (prev && ids.includes(prev)) sel.value = prev;
}
toast('Fetched ' + ids.length + ' voices', 'success');
} catch (e) { toast('Fetch failed: ' + (e.message || e), 'error'); }
finally { if (btn) btn.disabled = false; }
}
// Guidance for steady long-form narration (addresses voice drift between chunks):
// some backends re-sample timbre per request; flag those and suggest remedies.
function readerUpdateBackendHint() {
const el = $('reader-backend-hint'); if (!el) return;
const id = $('reader-backend-select')?.value || '';
const b = (typeof backendById === 'function') ? backendById(id) : null;
const steady = id && ((b && b.style_aware) || /design|custom|kokoro|magpie/i.test(id));
el.innerHTML = steady
? '<span class="mdi mdi-check-circle-outline"></span> Good for long-form narration. For maximum steadiness, increase “Voice consistency”.'
: '<span class="mdi mdi-information-outline"></span> Cloned / zero-shot voices re-sample each chunk, so the voice can drift between sections. To steady it: raise “Voice consistency” to paragraph/page, set a fixed <b>Seed</b> with a low <b>Temperature</b> (e.g. 0.3), keep “Normalise loudness” on, or pick a style-aware backend.';
el.classList.toggle('reader-hint-warn', !steady);
}
// ── Import ──────────────────────────────────────────────────────────────────
function readerTitleFromText(text) {
const first = String(text || '').split(/\r?\n/).map(x => x.trim()).find(Boolean) || 'Pasted text';
return first.replace(/\s+/g, ' ').slice(0, 80) || 'Pasted text';
}
function readerUpdateToolbarVisibility(loaded = !!readerState.sentences.length) {
const toolbar = $('reader-toolbar');
if (toolbar) toolbar.hidden = !loaded;
const pdfOnly = readerState.mode === 'pdf';
const zoom = $('reader-zoom'); if (zoom) zoom.hidden = !pdfOnly;
const legend = document.querySelector('.reader-legend'); if (legend) legend.hidden = !pdfOnly;
}
function readerFinishLoadedDocument({ resume = true, successPrefix = 'Loaded' } = {}) {
if (!readerState.sentences.length) { toast('No readable text found in this document', 'error'); return false; }
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
$('reader-transport').hidden = false;
$('reader-dropzone').hidden = true;
const importArea = $('reader-import-area'); if (importArea) importArea.hidden = true;
readerUpdateToolbarVisibility(true);
$('reader-synthbar').hidden = false;
const pr = $('reader-page-range');
if (pr) {
pr.hidden = readerState.mode !== 'pdf';
if (readerState.mode === 'pdf') {
const n = readerState.pages.length;
const pf = $('reader-page-from'), pt = $('reader-page-to');
if (pf) { pf.max = n; pf.value = 1; }
if (pt) { pt.max = n; pt.value = n; }
}
}
readerUpdateScopeLabel();
const resumed = resume && readerLoadResume();
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[readerState.idx]);
toast(resumed
? 'Resumed at sentence ' + (readerState.idx + 1) + ' / ' + readerState.sentences.length
: successPrefix + ' · ' + readerState.sentences.length + ' sentences — press play', 'success');
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source');
return true;
}
async function readerImportFile(file) {
if (!file) return;
const name = (file.name || '').toLowerCase();
readerResetDoc();
readerState.title = file.name.replace(/\.[^.]+$/, '');
readerState.fileBlob = file;
const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = readerState.title;
try {
if (name.endsWith('.pdf')) {
toast('Loading PDF…', 'success');
const loaded = await readerLoadPdfSkeleton(file);
if (!loaded) return;
readerShowExtractPrompt(loaded);
} else {
const text = await file.text();
readerState.docText = text;
await readerLoadText(text);
readerFinishLoadedDocument({ successPrefix: 'Loaded' });
}
} catch (e) { toast('Import failed: ' + (e.message || e), 'error'); }
}
// Shown after a fresh PDF import: pages are already visible (rasterized from
// the skeleton stage) but nothing is readable/searchable/castable yet until
// the user explicitly extracts text — this is the pipeline's "PDF → Text"
// step, and it's where the OCR-headings choice belongs since it only matters
// once you're about to extract.
function readerShowExtractPrompt(loaded) {
const dz = $('reader-dropzone'); if (dz) dz.hidden = true;
const importArea = $('reader-import-area'); if (importArea) importArea.hidden = true;
readerUpdateToolbarVisibility(true);
const banner = $('reader-extract-banner');
if (banner) {
banner.hidden = false;
const pc = $('reader-extract-pagecount'); if (pc) pc.textContent = String(loaded.nPages);
}
const btn = $('reader-extract-btn');
if (btn) btn.onclick = () => readerRunExtraction(loaded);
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source');
toast('PDF loaded · ' + loaded.nPages + ' pages — click "Extract Text" when ready', 'success');
}
async function readerRunExtraction(loaded) {
const btn = $('reader-extract-btn');
const original = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Extracting…'; }
try {
await readerExtractPdfText(loaded);
const banner = $('reader-extract-banner'); if (banner) banner.hidden = true;
readerFinishLoadedDocument({ resume: false, successPrefix: 'Extracted' });
} catch (e) {
toast('Text extraction failed: ' + (e.message || e), 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = original; }
}
}
async function readerImportPastedText() {
const ta = $('reader-paste-text');
const text = (ta?.value || '').trim();
if (!text) { toast('Paste some text first', 'error'); ta?.focus(); return; }
const btn = $('reader-paste-load');
if (btn) btn.disabled = true;
readerResetDoc();
readerState.title = readerTitleFromText(text);
readerState.docText = text;
readerState.fileBlob = null;
readerState.sourceUploaded = false;
const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = readerState.title;
try {
await readerLoadText(text);
readerFinishLoadedDocument({ resume: false, successPrefix: 'Loaded pasted text' });
} catch (e) {
toast('Import failed: ' + (e.message || e), 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function readerResetDoc() {
readerStopPlayback();
readerState._seq++;
readerState.mode = null;
readerState.pdfDoc = null;
readerState.pages = [];
readerState.sentences = [];
readerState.idx = 0;
readerState.readingIdx = -1;
readerState.scale = 1;
readerState.zoomMode = 'fit-width';
readerState.blobCache.clear();
readerState.bufCache.clear();
readerState.gainCache.clear();
readerState.unitsByPage = new Map();
readerState.baseSentences = [];
readerState.fileBlob = null;
readerState.docText = '';
readerState.savedId = null;
readerState.savedAudioIdx = new Set();
readerState.sourceUploaded = false;
const extractBanner = $('reader-extract-banner'); if (extractBanner) extractBanner.hidden = true;
if (readerState.io) { readerState.io.disconnect(); readerState.io = null; }
readerState.synthRunning = false;
readerState.synthCancel = false;
readerState.synthStarted = false;
readerSetSelecting(false);
readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false;
const sb = $('reader-synthbar'); if (sb) sb.hidden = true;
const tr = $('reader-transport'); if (tr) tr.hidden = true;
readerUpdateToolbarVisibility(false);
const sp = $('reader-synth-prog'); if (sp) sp.hidden = true;
const si = $('reader-sel-info'); if (si) si.hidden = true;
const doc = $('reader-doc'); if (doc) { doc.innerHTML = ''; doc.classList.remove('two-page', 'reader-selecting', 'reader-synth-started'); }
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source');
const importArea = $('reader-import-area'); if (importArea) importArea.hidden = false;
readerClearSearch({ clearInput: true });
}
// Build sentence list from a flat array of word objects (each carries geometry).
// PDF fonts sometimes render a closing quote/bracket glyph as its own
// separate text-content item (confirmed live on a scanned book, for the
// German closing guillemet «) — joining every word with an unconditional
// leading space then bakes a stray space in right before it ("hier. «"
// instead of "hier.«"), which the casting LLM then faithfully preserves
// since it's instructed to reproduce the exact source text. Punctuation
// that only ever closes something never wants a space before it.
const READER_NO_LEADING_SPACE_RE = /^[«"'’”)\]]+$/;
function readerBuildSentences(words) {
const sentences = [];
let cur = null;
const endRe = /[.!?]["'”’)\]]?$/;
const abbrev = /^(mr|mrs|ms|dr|prof|sr|jr|vs|etc|e\.g|i\.e|no|vol|st|fig)\.?$/i;
for (const w of words) {
// A detected paragraph break (readerMarkParagraphBreaks, PDF only) always
// ends the current sentence, even mid-punctuation, so the boundary isn't
// lost — it's carried as .paraStart for audiobookScopeText/TTS pacing.
if (w.para && cur && cur.words.length) { sentences.push(cur); cur = null; }
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, paraStart: !!w.para };
cur.words.push(w);
cur.text += (cur.text && !READER_NO_LEADING_SPACE_RE.test(w.text) ? ' ' : '') + w.text;
const isEnd = endRe.test(w.text) && !abbrev.test(w.text.replace(/[^a-z.]/gi, ''));
if ((isEnd && cur.words.length >= 2) || cur.words.length >= 45) {
sentences.push(cur); cur = null;
}
}
if (cur && cur.words.length) sentences.push(cur);
return sentences;
}
// Group base sentences into synthesis units. Larger units = one continuous
// generation = steadier voice (fewer seams where timbre/prosody can drift).
// • sentence — one unit per sentence (most responsive)
// • paragraph — merge sentences within a paragraph (text) / page (PDF), ~500 chars
// • page — merge a whole PDF page (or ~2000-char blocks of text)
function readerGroupUnits(base, mode) {
if (mode === 'sentence' || !base.length) {
return base.map(s => ({ text: s.text, words: s.words, status: 'pending', _stat: null, paraStart: !!s.paraStart }));
}
const maxChars = mode === 'page' ? 2000 : 500;
const isPdf = readerState.mode === 'pdf';
const keyOf = s => isPdf ? (s.words[0]?.page ?? 0) : (s.words[0]?.para ?? 0);
const units = [];
let cur = null;
for (const s of base) {
const k = keyOf(s);
// boundary break: paragraph mode breaks on para/page change; page mode breaks
// only on PDF page change (text page mode flows by size alone)
const boundary = cur && (
(mode === 'paragraph' && k !== cur._key) ||
(mode === 'page' && isPdf && k !== cur._key)
);
const tooLong = cur && cur.words.length && (cur.text.length + s.text.length + 1 > maxChars);
if (boundary || tooLong) { units.push(cur); cur = null; }
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, _key: k, paraStart: !!s.paraStart };
// A real paragraph break inside a merged (paragraph/page) unit is kept as
// a blank line rather than collapsed to a space, so casting/synthesis can
// still tell paragraphs apart even when several are merged into one unit.
cur.text += !cur.text ? s.text : (s.paraStart ? '\n\n' : ' ') + s.text;
cur.words.push(...s.words);
}
if (cur) units.push(cur);
return units;
}
// Flag the first word of each line that starts a new paragraph, by grouping
// words into visual lines (shared `top`) and comparing consecutive lines'
// vertical gap against the page's typical single-line spacing — a gap clearly
// bigger than normal (paragraph spacing, or the gap after a heading/image)
// marks a paragraph break. This is the only place paragraph structure is
// ever recovered for PDFs; downstream (readerBuildSentences, audiobookScopeText)
// just honours the `.para` flag it sets.
function readerMarkParagraphBreaks(pageWords) {
if (!pageWords.length) return;
const lines = [];
let curLine = null, curTop = null;
for (const w of pageWords) {
if (curLine && Math.abs(w.top - curTop) < w.h * 0.4) curLine.push(w);
else { curLine = [w]; curTop = w.top; lines.push(curLine); }
}
lines[0][0].para = true; // first line on the page always starts a paragraph
if (lines.length < 3) return;
const gaps = [];
for (let i = 1; i < lines.length; i++) gaps.push(lines[i][0].top - lines[i - 1][0].top);
const sorted = [...gaps].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)] || 0;
if (median <= 0) return;
for (let i = 1; i < lines.length; i++) {
if (lines[i][0].top - lines[i - 1][0].top > median * 1.35) lines[i][0].para = true;
}
}
// ── Headline OCR: recover chapter titles baked into the PDF as images ───────
// pdf.js getTextContent() only ever returns real text glyphs, so a headline
// drawn as a raster/vector graphic (common with stylised chapter title pages)
// is otherwise invisible to the reader. When a page's real text starts well
// below its top edge, we assume the gap holds such an image, rasterize just
// that band, and OCR it with a locally-vendored Tesseract.js worker.
async function loadTesseract() {
if (window.Tesseract) return;
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '/static/js/tesseract/tesseract.min.js';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
}
async function readerGetOcrWorker() {
if (readerState.ocrWorker) return readerState.ocrWorker;
await loadTesseract();
readerState.ocrWorker = await Tesseract.createWorker(READER_OCR_LANGS, 1, {
workerPath: '/static/js/tesseract/worker.min.js',
corePath: '/static/js/tesseract/tesseract-core-simd-lstm.wasm.js',
langPath: '/static/js/tesseract/lang',
gzip: true,
});
return readerState.ocrWorker;
}
// Races `promise` against a timer, rejecting instead of leaving the caller
// awaiting forever. A hung (never-settling, not rejecting) render/OCR call
// on one page — confirmed live as the PDF import progress bar getting stuck
// on a single page indefinitely, most likely an image-heavy or blank page
// wedging the Tesseract worker — silently stalled the entire sequential
// extraction loop with no error and no way to recover short of reloading.
function _readerTimeout(promise, ms, label) {
return new Promise(function (resolve, reject) {
const t = setTimeout(function () { reject(new Error((label || 'operation') + ' timed out after ' + ms + 'ms')); }, ms);
promise.then(function (v) { clearTimeout(t); resolve(v); }, function (e) { clearTimeout(t); reject(e); });
});
}
// Rasterize the top `gapPx` (scale-1 px) of `page` and OCR it, returning
// synthetic word entries (same shape as real getTextContent words) spread
// across the band so they slot into the normal sentence/highlight pipeline.
async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
let crop;
try {
const worker = await readerGetOcrWorker();
const viewport = page.getViewport({ scale: READER_OCR_RENDER_SCALE });
// The band is at the top of the page, so a canvas that is only cropH tall
// captures it directly — the 2D context clips the rest of the render, so
// we never allocate or rasterize the remaining ~90% of the page.
const cropH = Math.max(Math.round(gapPx * READER_OCR_RENDER_SCALE), 1);
crop = document.createElement('canvas');
crop.width = viewport.width;
crop.height = cropH;
await _readerTimeout(page.render({ canvasContext: crop.getContext('2d'), viewport }).promise, 15000, 'Page render');
const { data } = await _readerTimeout(worker.recognize(crop), 20000, 'Heading OCR');
const text = (data.text || '').replace(/\s+/g, ' ').trim();
if (!text || (data.confidence ?? 0) < READER_OCR_MIN_CONFIDENCE) return [];
const tokens = text.split(' ').filter(Boolean);
const bandH = Math.max(gapPx - 4, 10);
const avgCharW = Math.max((base.width - 16) / text.length, 4);
const words = [];
let x = 8;
for (const tok of tokens) {
const w = Math.max(tok.length * avgCharW, 10);
words.push({ page: pageIdx, x, top: 4, w, h: bandH, text: tok, ocr: true });
x += w + avgCharW;
}
return words;
} catch (e) {
console.warn('Heading OCR failed', e);
return [];
} finally {
if (crop) { crop.width = 0; crop.height = 0; }
}
}
// ── PDF import: build scale-1 geometry; render lazily at current scale ───────
// Loading a PDF is two real stages so the pipeline stepper's "PDF" and "Text"
// stops mean something: readerLoadPdfSkeleton() opens the file and rasterizes
// pages (fast, no text layer yet — you can already scroll/zoom/look at it),
// then readerExtractPdfText() is a separate, explicit action that runs
// getTextContent() + heading OCR + sentence-building. A freshly-imported PDF
// stops after the skeleton and shows an "Extract Text" prompt; reopening an
// already-known document (library restore) runs both stages back to back via
// readerLoadPdf() since there's nothing to "decide" on a document you've
// already extracted before.
async function readerLoadPdfSkeleton(file) {
await loadPdfJs();
const seq = readerState._seq;
const ab = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: ab }).promise;
if (seq !== readerState._seq) return null;
readerState.mode = 'pdf';
readerState.pdfDoc = pdf;
const doc = $('reader-doc');
const nPages = pdf.numPages;
// Pre-fetch page 1 geometry to define scale/zoom quickly for skeletons
const page1 = await pdf.getPage(1);
if (seq !== readerState._seq) return null;
const base1 = page1.getViewport({ scale: 1 });
// Render empty skeleton pages instantly so the user can scroll
for (let p = 1; p <= nPages; p++) {
const pageDiv = document.createElement('div');
pageDiv.className = 'reader-page';
// set initial size so scrollbar reflects full document size instantly
pageDiv.style.width = base1.width + 'px';
pageDiv.style.height = base1.height + 'px';
const overlay = document.createElement('div');
overlay.className = 'reader-overlay';
pageDiv.appendChild(overlay);
doc.appendChild(pageDiv);
// Use base1 as placeholder geometry until actual page is parsed
readerState.pages.push({ pageDiv, overlay, page: null, base: base1, rendered: false, renderTask: null });
if (p % 50 === 0) await readerYield();
}
// Apply zoom and rasterize immediately so the user sees the book!
readerApplyZoom('fit-width');
readerSetupLazyRaster();
return { pdf, page1, nPages };
}
async function readerExtractPdfText(loaded) {
const seq = readerState._seq;
const pdf = loaded.pdf, page1 = loaded.page1, nPages = loaded.nPages;
const progress = readerShowParseProgress(nPages);
const ocrHeadingsEnabled = $('reader-ocr-headings')?.checked !== false;
// Sentences/units are built incrementally per page (page-local) and appended.
// We yield on every page to keep the browser buttery smooth for scrolling.
for (let p = 1; p <= nPages; p++) {
// If page1 was already fetched, reuse it
const page = p === 1 ? page1 : await pdf.getPage(p);
if (seq !== readerState._seq) { progress.done(); return; }
const base = page.getViewport({ scale: 1 }); // actual geometry reference
const pageIdx = p - 1;
const pgState = readerState.pages[pageIdx];
// Update the skeleton with actual page data
pgState.page = page;
pgState.base = base;
pgState.pageDiv.style.width = (base.width * readerState.scale) + 'px';
pgState.pageDiv.style.height = (base.height * readerState.scale) + 'px';
if (readerIsPageNearView(pgState)) readerRenderPage(pageIdx);
// Per-word geometry in scale-1 device px (top-left origin) → multiply by scale later
let content;
try {
content = await _readerTimeout(page.getTextContent(), 20000, 'Page text extraction');
} catch (e) {
console.warn('getTextContent failed/timed out on page', p, e);
content = { items: [] };
}
if (seq !== readerState._seq) return;
await readerYield();
const pageWords = [];
for (let itemIdx = 0; itemIdx < content.items.length; itemIdx++) {
const item = content.items[itemIdx];
const str = (item.str || '');
if (!str.trim()) continue;
const m = pdfjsLib.Util.transform(base.transform, item.transform);
const h = Math.hypot(m[2], m[3]) || Math.hypot(m[0], m[1]) || 12;
const x = m[4];
const top = m[5] - h;
const w = (item.width || 0);
const tokens = str.split(/(\s+)/); // keep spaces for width proportion
const totalLen = str.length || 1;
let offX = x;
for (const tok of tokens) {
const tw = (tok.length / totalLen) * w;
if (tok.trim()) pageWords.push({ page: pageIdx, x: offX, top, w: Math.max(tw, 2), h, text: tok });
offX += tw;
}
if (itemIdx && itemIdx % READER_IMPORT_YIELD_EVERY === 0) await readerYield();
}
// A tall text-free band at the top of the page likely holds an image
// heading (chapter title graphics) that getTextContent() can't see.
if (ocrHeadingsEnabled) {
// Plain loop, not Math.min(...map()) — spreading a word-dense page's
// array into arguments allocates per page and can overflow the arg limit.
let gapPx = base.height;
for (const w of pageWords) if (w.top < gapPx) gapPx = w.top;
if (gapPx > Math.max(base.height * READER_OCR_MIN_GAP_RATIO, READER_OCR_MIN_GAP_PX)) {
const ocrWords = await readerOcrPageHeading(page, base, pageIdx, gapPx);
if (seq !== readerState._seq) return;
if (ocrWords.length) pageWords.unshift(...ocrWords);
}
}
readerMarkParagraphBreaks(pageWords);
// Build + append this page's sentences and units
const pageBase = readerBuildSentences(pageWords);
const pageUnits = readerGroupUnits(pageBase, readerState.chunkMode);
const startUnit = readerState.sentences.length;
readerState.baseSentences.push(...pageBase);
readerState.sentences.push(...pageUnits);
const idxs = [];
for (let u = startUnit; u < readerState.sentences.length; u++) idxs.push(u);
readerState.unitsByPage.set(pageIdx, idxs);
// Yield to the event loop EVERY page so scrolling remains 60fps
progress.update(p);
await new Promise(r => setTimeout(r, 0));
}
progress.done();
// Free the Tesseract worker's WASM/lang-data memory (tens of MB) now that
// extraction is over; the next import recreates it lazily.
if (readerState.ocrWorker) {
try { readerState.ocrWorker.terminate(); } catch (_) {}
readerState.ocrWorker = null;
}
}
// Convenience wrapper used by restore paths (library reopen) where extraction
// should just happen automatically — there's nothing to decide on a document
// you've already read and extracted before.
async function readerLoadPdf(file) {
const loaded = await readerLoadPdfSkeleton(file);
if (!loaded) return;
await readerExtractPdfText(loaded);
}
// Floating parse-progress pill for big PDFs — sticky at ~mid-height of the
// document pane and sized to be legible from across the room, since a 300-page
// extraction can take a while and this is the only "still working" signal.
function readerShowParseProgress(total) {
const doc = $('reader-doc'); if (!doc) return { update() {}, done() {} };
const el = document.createElement('div');
el.className = 'reader-parsing';
el.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:26px;"></span> <span>Reading PDF… page 0 / ' + total + '</span>';
// Fixed to the viewport (not sticky within the doc pane) — sticky's
// top:40vh drifted toward the bottom on long documents since its
// containing block grew with every page appended, instead of staying
// anchored a fixed distance from the actual visible top of the screen.
el.style.cssText = 'position:fixed; top:80px; left:50%; transform:translateX(-50%); z-index:100; background:var(--accent); color:#fff; padding:14px 28px; border-radius:32px; font-size:18px; display:inline-flex; align-items:center; gap:12px; box-shadow:0 6px 24px rgba(0,0,0,.35); font-weight:700;';
doc.insertBefore(el, doc.firstChild);
const label = el.querySelector('span:last-child');
return {
update(p) { if (label) label.textContent = 'Reading PDF… page ' + p + ' / ' + total; },
done() { el.remove(); },
};
}
// ── Zoom ─────────────────────────────────────────────────────────────────────
function readerComputeScale(mode) {
const doc = $('reader-doc');
if (!doc || !readerState.pages.length) return 1;
let maxW = 0, maxH = 0;
for (const p of readerState.pages) {
if (p.base) {
if (p.base.width > maxW) maxW = p.base.width;
if (p.base.height > maxH) maxH = p.base.height;
}
}
if (!maxW) return 1;
const padW = 36, gap = 16;
const availW = (doc.clientWidth || 900) - padW;
const availH = (doc.clientHeight || 600) - 36;
if (mode === 'fit-width') return Math.max(0.2, availW / maxW);
if (mode === 'fit-height') return Math.max(0.2, availH / maxH);
if (mode === 'two') return Math.max(0.2, (availW - gap) / 2 / maxW);
return readerState.scale;
}
function readerApplyZoom(mode) {
if (!readerState.pages.length) return;
if (mode) readerState.zoomMode = mode;
if (mode && mode !== 'custom') readerState.scale = readerComputeScale(mode);
const scale = readerState.scale;
const doc = $('reader-doc');
doc.classList.toggle('two-page', readerState.zoomMode === 'two');
readerState.pages.forEach(pg => {
pg.pageDiv.style.width = (pg.base.width * scale) + 'px';
pg.pageDiv.style.height = (pg.base.height * scale) + 'px';
const old = pg.pageDiv.querySelector('canvas.reader-canvas');
if (old) old.remove();
if (pg.renderTask) { try { pg.renderTask.cancel(); } catch (_) {} pg.renderTask = null; }
pg.rendered = false;
});
// reposition only the status boxes that actually exist (lazy/visible pages)
readerState.pages.forEach(pg => pg.overlay.querySelectorAll('.reader-stat').forEach(el => {
const i = parseInt(el.dataset.si); if (!isNaN(i)) readerPaintStatus(i);
}));
readerPaintSearchHighlights();
if (readerState.idx < readerState.sentences.length) readerHighlightSentence(readerState.sentences[readerState.idx]);
const pct = $('reader-zoom-pct'); if (pct) pct.textContent = Math.round(scale * 100) + '%';
readerRenderVisible();
}
function readerSetupLazyRaster() {
if (readerState.io) readerState.io.disconnect();
readerState.io = new IntersectionObserver(entries => {
entries.forEach(en => {
if (!en.isIntersecting) return;
const idx = readerState.pages.findIndex(p => p.pageDiv === en.target);
if (idx >= 0) readerRenderPage(idx);
});
}, { root: $('reader-doc'), rootMargin: '600px 0px' });
readerState.pages.forEach(p => readerState.io.observe(p.pageDiv));
}
function readerRenderVisible() {
const doc = $('reader-doc'); if (!doc) return;
readerState.pages.forEach((pg, i) => {
if (readerIsPageNearView(pg, 800)) readerRenderPage(i);
});
readerEvictCanvases();
}
function readerIsPageNearView(pg, margin = 800) {
const doc = $('reader-doc');
if (!doc || !pg?.pageDiv) return false;
const dr = doc.getBoundingClientRect();
const r = pg.pageDiv.getBoundingClientRect();
return r.bottom > dr.top - margin && r.top < dr.bottom + margin;
}
// Release canvas bitmaps for pages far outside the viewport so a long book's
// raster memory stays bounded; they re-render when scrolled back into view.
function readerEvictCanvases(dr) {
const doc = $('reader-doc'); if (!doc) return;
dr = dr || doc.getBoundingClientRect();
readerState.pages.forEach(pg => {
if (!pg.rendered) return;
const r = pg.pageDiv.getBoundingClientRect();
if (r.bottom < dr.top - READER_CANVAS_MARGIN || r.top > dr.bottom + READER_CANVAS_MARGIN) {
if (pg.renderTask) { try { pg.renderTask.cancel(); } catch (_) {} pg.renderTask = null; }
const c = pg.pageDiv.querySelector('canvas.reader-canvas');
if (c) c.remove();
pg.rendered = false; // allow re-raster when it returns to view
}
});
}
// A canvas sized directly from page-size * zoom with no upper bound can
// demand a multi-hundred-MB backing buffer on an oversized page (some
// scanned/cover pages declare a MediaBox far larger than a normal printed
// page) at high zoom — confirmed live as a full tab crash (Chromium
// "SIGTRAP") zooming a cover page to ~400%. Independent of the zoom-button
// cap (which only bounds the requested scale, not the page's own intrinsic
// size), clamp the actual rendered backing-buffer resolution to a safe
// pixel budget and let the canvas's CSS size — not its buffer size — carry
// the visual zoom; an oversized page just renders a bit softer instead of
// crashing the tab.
// Shrinking the OUTPUT canvas (as of 1.17.46/47) assumes the render cost
// scales with the target size — confirmed live that it doesn't always:
// the tab still hung at high zoom. If PDF.js is decoding an embedded
// image at ITS OWN native resolution before any downscaling happens, that
// decode runs synchronously on the main thread before our code (including
// the render timeout) ever gets control back — nothing awaitable can
// rescue a hang that already owns the thread. The only actually reliable
// mitigation is to keep the requested scale small enough that this page's
// embedded resource is never decoded at a pathological resolution in the
// first place, so these budgets are deliberately much smaller than before.
const READER_MAX_CANVAS_PIXELS = 2000000; // ~2MP
const READER_MAX_CANVAS_SIDE = 1800;
async function readerRenderPage(idx) {
const pg = readerState.pages[idx];
if (!pg || pg.rendered || !pg.page) return;
pg.rendered = true; // claim immediately to avoid double render
const scale = readerState.scale;
const viewport = pg.page.getViewport({ scale });
let renderViewport = viewport;
const overArea = viewport.width * viewport.height > READER_MAX_CANVAS_PIXELS;
const overSide = viewport.width > READER_MAX_CANVAS_SIDE || viewport.height > READER_MAX_CANVAS_SIDE;
if (overArea || overSide) {
let fit = overArea ? Math.sqrt(READER_MAX_CANVAS_PIXELS / (viewport.width * viewport.height)) : 1;
fit = Math.min(fit, READER_MAX_CANVAS_SIDE / Math.max(viewport.width, viewport.height));
renderViewport = pg.page.getViewport({ scale: scale * fit });
}
const canvas = document.createElement('canvas');
canvas.className = 'reader-canvas';
canvas.width = Math.floor(renderViewport.width);
canvas.height = Math.floor(renderViewport.height);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
pg.pageDiv.insertBefore(canvas, pg.overlay);
readerCreatePageStatus(idx); // create this page's status boxes lazily (cheap for big books)
try {
pg.renderTask = pg.page.render({ canvasContext: canvas.getContext('2d'), viewport: renderViewport });
// Even a capped-resolution canvas can sit on a page whose embedded
// image is itself huge (some codecs decode at native resolution before
// any downscaling) — a timeout here is the actual guarantee against a
// hung tab, independent of whatever the real bottleneck turns out to
// be. Cancelling leaves the page unrendered so scrolling past and back
// can retry it rather than leaving a permanently broken page.
await _readerTimeout(pg.renderTask.promise, 15000, 'Page render');
} catch (_) {
pg.rendered = false; canvas.remove();
try { pg.renderTask?.cancel(); } catch (_) {}
}
finally { pg.renderTask = null; }
}
// ── Text import: render word spans, highlight via classes ────────────────────
async function readerLoadText(text) {
readerState.mode = 'text';
readerState.docText = text; // keep the source so it can be saved to the library
const doc = $('reader-doc');
const pane = document.createElement('div');
pane.className = 'reader-text';
doc.appendChild(pane);
const words = [];
const paras = text.replace(/\r\n/g, '\n').split(/\n{2,}/);
const frag = document.createDocumentFragment();
let renderedWords = 0;
for (let pi = 0; pi < paras.length; pi++) {
const para = paras[pi];
const pEl = document.createElement('p');
pEl.className = 'reader-text-para';
const lines = para.split('\n');
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
const toks = line.trim().split(/\s+/);
for (const tok of toks) {
if (!tok) continue;
const span = document.createElement('span');
span.className = 'reader-word';
span.textContent = tok;
pEl.appendChild(span);
pEl.appendChild(document.createTextNode(' '));
words.push({ el: span, text: tok, page: 0, para: pi });
renderedWords++;
if (renderedWords % READER_IMPORT_YIELD_EVERY === 0) await readerYield();
}
if (li < lines.length - 1) pEl.appendChild(document.createElement('br'));
}
frag.appendChild(pEl);
if (pi && pi % 20 === 0) await readerYield();
}
pane.appendChild(frag);
readerState.baseSentences = readerBuildSentences(words);
await readerYield();
readerState.sentences = readerGroupUnits(readerState.baseSentences, readerState.chunkMode);
pane.addEventListener('click', e => {
const span = e.target.closest('.reader-word'); if (!span) return;
const si = readerState.sentences.findIndex(s => s.words.some(w => w.el === span));
if (si < 0) return;
if (readerState.selecting) readerPickSentence(si);
else readerJumpTo(si);
});
}
// ── Per-sentence synthesis-state visualization ───────────────────────────────
function readerSentBBox(sentence) {
const pageIdx = sentence.words[0]?.page ?? 0;
const on = sentence.words.filter(w => w.page === pageIdx);
return {
page: pageIdx,
x: Math.min(...on.map(w => w.x)),
top: Math.min(...on.map(w => w.top)),
w: Math.max(...on.map(w => w.x + w.w)) - Math.min(...on.map(w => w.x)),
h: Math.max(...on.map(w => w.top + w.h)) - Math.min(...on.map(w => w.top)),
};
}
// Index units by their page so status boxes can be created lazily per page
// (creating thousands of overlay divs up front would jank big books).
function readerBuildUnitIndex() {
readerState.unitsByPage = new Map();
if (readerState.mode !== 'pdf') return;
readerState.sentences.forEach((s, i) => {
const pg = s.words[0]?.page ?? 0;
if (!readerState.unitsByPage.has(pg)) readerState.unitsByPage.set(pg, []);
readerState.unitsByPage.get(pg).push(i);
});
}
// Create + paint the status boxes for one page (called when the page rasters).
function readerCreatePageStatus(pageIdx) {
if (readerState.mode !== 'pdf') return;
const pg = readerState.pages[pageIdx]; if (!pg) return;
const idxs = readerState.unitsByPage?.get(pageIdx) || [];
for (const i of idxs) {
const s = readerState.sentences[i];
if (!s || s._stat) continue;
const el = document.createElement('div');
el.className = 'reader-stat';
el.dataset.si = i;
pg.overlay.appendChild(el);
s._stat = el;
readerPaintStatus(i);
}
}
// effective colour: reading wins, else cached/synth/pending
function readerEffectiveStatus(s) { return s.status; }
function readerSetStatus(idx, status) {
const s = readerState.sentences[idx]; if (!s) return;
s.status = status;
if (status !== 'pending' && !readerState.synthStarted) {
readerState.synthStarted = true;
$('reader-doc')?.classList.add('reader-synth-started');
}
readerPaintStatus(idx);
}
function readerPaintStatus(idx) {
const s = readerState.sentences[idx]; if (!s) return;
const eff = readerEffectiveStatus(s);
if (readerState.mode === 'pdf') {
if (!s._stat) return;
const b = readerSentBBox(s);
const scale = readerState.scale;
Object.assign(s._stat.style, { left: (b.x * scale) + 'px', top: (b.top * scale) + 'px', width: (b.w * scale) + 'px', height: (b.h * scale) + 'px' });
s._stat.className = 'reader-stat reader-stat-' + eff;
} else {
s.words.forEach(w => {
if (!w.el) return;
w.el.classList.remove('stat-pending', 'stat-synth', 'stat-ready', 'stat-reading');
w.el.classList.add('stat-' + eff);
});
}
}
// ── Active reading highlight (word + scroll) ─────────────────────────────────
let _readerWordBox = null;
function readerEnsureBox() {
if (!_readerWordBox) { _readerWordBox = document.createElement('div'); _readerWordBox.className = 'reader-hl-word'; }
}
function readerClearHighlights() {
if (readerState.mode === 'text') {
document.querySelectorAll('.reader-word.is-word').forEach(el => el.classList.remove('is-word'));
} else if (_readerWordBox) {
_readerWordBox.hidden = true;
}
}
// Scroll ONLY the #reader-doc pane (never the window) so the active text stays
// inside the visible frame and not under the app header. Keeps the target at
// ~38% from the top when it would otherwise be clipped top or bottom.
function readerEnsureVisible(el) {
const doc = $('reader-doc'); if (!doc || !el) return;
const dr = doc.getBoundingClientRect();
const er = el.getBoundingClientRect();
if (er.top < dr.top + 70 || er.bottom > dr.bottom - 50) {
const delta = (er.top - dr.top) - doc.clientHeight * 0.38;
doc.scrollTo({ top: doc.scrollTop + delta, behavior: 'smooth' });
}
}
function readerHighlightSentence(sentence) {
if (!sentence) return;
if (readerState.mode === 'text') readerEnsureVisible(sentence.words[0]?.el);
else if (sentence._stat) readerEnsureVisible(sentence._stat);
}
function readerHighlightWord(sentence, wi) {
const w = sentence.words[wi]; if (!w) return;
if (readerState.mode === 'text') {
document.querySelectorAll('.reader-word.is-word').forEach(el => el.classList.remove('is-word'));
if (w.el) { w.el.classList.add('is-word'); readerEnsureVisible(w.el); }
return;
}
readerEnsureBox();
const pg = readerState.pages[w.page]; if (!pg) return;
const scale = readerState.scale;
if (_readerWordBox.parentNode !== pg.overlay) pg.overlay.appendChild(_readerWordBox);
Object.assign(_readerWordBox.style, { left: (w.x * scale) + 'px', top: (w.top * scale) + 'px', width: (w.w * scale) + 'px', height: (w.h * scale) + 'px' });
_readerWordBox.hidden = false;
readerEnsureVisible(_readerWordBox);
}
// ── Scope: select range / page range / all + batch synthesis ─────────────────
// Select mode STAYS ON until the user explicitly finishes (Done button / Esc).
// Step 1: click a start sentence (the "anchor"). Step 2: click the end sentence.
// The anchor pulses while waiting; after a range is set, the next click starts a
// fresh anchor so you can keep refining without leaving the mode.
function readerSetSelecting(on) {
readerState.selecting = !!on;
readerState.selAnchored = false;
const doc = $('reader-doc'); if (doc) doc.classList.toggle('reader-selecting', readerState.selecting);
const btn = $('reader-select-toggle'); if (btn) btn.classList.toggle('active', readerState.selecting);
const lbl = $('reader-select-label'); if (lbl) lbl.textContent = readerState.selecting ? 'Done' : 'Select range';
const ic = $('reader-select-icon'); if (ic) ic.className = 'mdi ' + (readerState.selecting ? 'mdi-check' : 'mdi-cursor-default-click-outline');
readerSelHint(readerState.selecting ? 'Click the <b>start</b> sentence…' : '');
if (!readerState.selecting) readerPaintSelection(); // drop the transient anchor styling
}
function readerSelHint(html) {
const h = $('reader-sel-hint'); if (!h) return;
h.innerHTML = html; h.hidden = !html;
}
function readerPickSentence(si) {
if (!readerState.selAnchored) {
// start a fresh range
readerState.selStart = si; readerState.selEnd = si; readerState.selAnchored = true;
readerSelHint('Start at sentence <b>' + (si + 1) + '</b> — now click the <b>end</b> sentence');
} else {
readerState.selEnd = si; readerState.selAnchored = false;
const r = readerSelRange();
readerSelHint('Selected <b>' + (r[1] - r[0] + 1) + '</b> sentences — click a new start, or <b>Done</b>');
}
readerPaintSelection();
readerUpdateScopeLabel();
}
function readerClearSelection() {
readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false;
readerPaintSelection();
readerUpdateScopeLabel();
if (readerState.selecting) readerSelHint('Click the <b>start</b> sentence…');
}
function readerSelRange() {
if (readerState.selStart === null || readerState.selEnd === null) return null;
return [Math.min(readerState.selStart, readerState.selEnd), Math.max(readerState.selStart, readerState.selEnd)];
}
function readerPaintSelection() {
const range = readerSelRange();
const inSel = i => range && i >= range[0] && i <= range[1];
// While waiting for the second click, the lone start sentence is the "anchor".
const anchorI = (readerState.selAnchored && range && range[0] === range[1]) ? range[0] : -1;
readerState.sentences.forEach((s, i) => {
const sel = inSel(i) && i !== anchorI;
const anchor = i === anchorI;
if (readerState.mode === 'pdf') {
if (s._stat) { s._stat.classList.toggle('sel', sel); s._stat.classList.toggle('sel-anchor', anchor); }
} else {
s.words.forEach(w => { if (w.el) { w.el.classList.toggle('in-sel', sel); w.el.classList.toggle('sel-anchor', anchor); } });
}
});
}
// Indices to act on: an active selection wins, else PDF page range, else everything.
function readerScopeIndices() {
const all = readerState.sentences.map((_, i) => i);
const range = readerSelRange();
if (range) return all.filter(i => i >= range[0] && i <= range[1]);
if (readerState.mode === 'pdf') {
const n = readerState.pages.length;
let from = parseInt($('reader-page-from')?.value) || 1;
let to = parseInt($('reader-page-to')?.value) || n;
from = Math.max(1, Math.min(from, n));
to = Math.max(from, Math.min(to, n));
return all.filter(i => { const p = (readerState.sentences[i].words[0]?.page ?? 0) + 1; return p >= from && p <= to; });
}
return all;
}
function readerUpdateScopeLabel() {
const scopeEl = $('reader-synth-scope');
const range = readerSelRange();
if (scopeEl) {
if (range) scopeEl.textContent = 'selection';
else if (readerState.mode === 'pdf') scopeEl.textContent = 'pages';
else scopeEl.textContent = 'all';
}
const info = $('reader-sel-info'), txt = $('reader-sel-text');
if (info) info.hidden = !range;
if (txt && range) txt.textContent = 'sentences ' + (range[0] + 1) + '' + (range[1] + 1) + ' (' + (range[1] - range[0] + 1) + ')';
}
// Synthesise the given sentence indices (skips already-cached). Returns
// {done, failed} — `failed` used to be swallowed entirely (a sentence whose
// TTS call errored just got its status reverted to 'pending' and `done` was
// incremented anyway), so a mid-book backend blip silently produced a
// merged/exported file with paragraphs missing and no indication anything
// had gone wrong. Callers now get the failed-index list back and must
// surface it instead of reporting a clean success.
async function readerSynthIndices(targets) {
targets = targets.filter(i => !readerState.blobCache.has(i));
if (!targets.length || readerState.synthRunning) return { done: 0, failed: [] };
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice) { toast('Pick a voice first', 'error'); return { done: 0, failed: [] }; }
if (!backend) { toast('No TTS backend selected', 'error'); return { done: 0, failed: [] }; }
const instruct = $('reader-instruct')?.value.trim() || '';
readerState.synthRunning = true; readerState.synthCancel = false;
const prog = $('reader-synth-prog'); if (prog) prog.hidden = false;
const total = targets.length; let done = 0;
const failed = [];
const update = () => {
const f = $('reader-synth-fill'); if (f) f.style.width = (done / total * 100) + '%';
const l = $('reader-synth-label'); if (l) l.textContent = done + ' / ' + total;
};
update();
try {
const queue = targets.slice();
const worker = async () => {
while (queue.length && !readerState.synthCancel) {
const i = queue.shift();
if (readerState.blobCache.has(i)) { done++; update(); continue; }
if (readerState.sentences[i].status === 'pending') readerSetStatus(i, 'synth');
try {
const blob = await fetchTtsPreviewBlob(voice, readerState.sentences[i].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(i, blob);
if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'ready');
} catch (e) {
failed.push(i);
if (readerState.sentences[i]?.status === 'synth') readerSetStatus(i, 'pending');
console.error('[reader] synth failed for sentence', i, e);
}
done++; update();
}
};
const N = Math.min(2, targets.length); // bounded concurrency — don't overload the engine
await Promise.all(Array.from({ length: N }, worker));
} finally {
readerState.synthRunning = false;
if (prog) prog.hidden = true;
}
return { done, failed };
}
async function readerSynthAll() {
const targets = readerScopeIndices().filter(i => !readerState.blobCache.has(i));
if (!targets.length) { toast('Selected range is already synthesised', 'success'); return; }
const { done, failed } = await readerSynthIndices(targets);
if (readerState.synthCancel) { toast('Synthesis cancelled (' + done + ' done)', 'error'); return; }
if (failed.length) { toast(done - failed.length + ' / ' + done + ' sentences synthesised — ' + failed.length + ' failed, see red markers', 'error'); return; }
toast('Synthesised ' + done + ' sentences', 'success');
}
// ── Export as MP3 (per page, or per sentence) with meaningful filenames ──────
function readerSafeName(s) {
return (s || 'audio').replace(/[\/\\:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60) || 'audio';
}
function readerPad(n) { return String(n).padStart(2, '0'); }
function readerPageOf(i) { return readerState.mode === 'pdf' ? (readerState.sentences[i].words[0]?.page ?? 0) + 1 : 1; }
function readerDownload(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
}
const _readerDelay = ms => new Promise(r => setTimeout(r, ms));
async function readerExport(mode) {
if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
const indices = readerScopeIndices();
const missing = indices.filter(i => !readerState.blobCache.has(i));
let failedCount = 0;
if (missing.length) {
toast('Synthesising ' + missing.length + ' missing sentence(s) before export…', 'success');
const { failed } = await readerSynthIndices(missing);
if (readerState.synthCancel) { toast('Export cancelled', 'error'); return; }
failedCount = failed.length;
}
const ready = indices.filter(i => readerState.blobCache.has(i));
if (!ready.length) { toast('Nothing to export', 'error'); return; }
// A segment that fails synthesis used to just vanish from the merged/
// exported file with no trace — the export "succeeded" while quietly
// missing paragraphs. Block the export instead: better a clear stop than a
// corrupted audiobook that only reveals the gap when someone listens.
if (failedCount) {
toast(failedCount + ' sentence(s) failed to synthesise — fix them (see red markers) before exporting, or missing audio will silently drop from the file', 'error');
return;
}
const title = readerSafeName(readerState.title);
if (mode === 'sentence') {
const perPage = {};
for (const i of ready) {
const pg = readerPageOf(i);
perPage[pg] = (perPage[pg] || 0) + 1;
const name = readerState.mode === 'pdf'
? `${title} - p${readerPad(pg)} - ${readerPad(perPage[pg])}.mp3`
: `${title} - ${readerPad(perPage[pg])}.mp3`;
readerDownload(readerState.blobCache.get(i), name);
await _readerDelay(350);
}
toast('Exported ' + ready.length + ' MP3 files', 'success');
return;
}
// mode === 'page' → one combined MP3 per page (text mode → one file for the doc)
const byPage = new Map();
ready.forEach(i => { const pg = readerPageOf(i); if (!byPage.has(pg)) byPage.set(pg, []); byPage.get(pg).push(i); });
let files = 0;
for (const [pg, idxs] of [...byPage.entries()].sort((a, b) => a[0] - b[0])) {
const blob = new Blob(idxs.map(i => readerState.blobCache.get(i)), { type: 'audio/mpeg' });
const name = readerState.mode === 'pdf' ? `${title} - p${readerPad(pg)}.mp3` : `${title}.mp3`;
readerDownload(blob, name);
files++;
await _readerDelay(400);
}
toast('Exported ' + files + (readerState.mode === 'pdf' ? ' page MP3 file(s)' : ' MP3'), 'success');
}
// ── Chunk mode (voice consistency) ───────────────────────────────────────────
function readerRechunk(mode) {
readerState.chunkMode = mode;
if (!readerState.baseSentences.length) return;
readerStopPlayback();
// drop synthesised audio (chunk text changed) and old per-unit status boxes
readerState.blobCache.clear(); readerState.bufCache.clear(); readerState.gainCache.clear();
readerState.savedAudioIdx = new Set(); // chunking changed → previously-saved audio no longer aligns
readerState.pages.forEach(p => p.overlay.querySelectorAll('.reader-stat').forEach(e => e.remove()));
readerClearSelection();
readerState.sentences = readerGroupUnits(readerState.baseSentences, mode);
readerBuildUnitIndex();
// recreate status boxes only for pages already rendered (rest come on raster)
readerState.pages.forEach((p, i) => { if (p.rendered) readerCreatePageStatus(i); });
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
readerState.idx = 0;
readerUpdateScopeLabel();
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[0]);
toast('Voice consistency: ' + mode + ' — audio cleared, re-synthesise', 'success');
}
// ── Generation params (seed / temperature) + loudness normalisation ──────────
function readerGenParams() {
const out = {};
const seed = $('reader-seed')?.value.trim();
const temp = $('reader-temp')?.value.trim();
const nspd = $('reader-tts-speed')?.value.trim();
if (seed !== '' && seed != null && !isNaN(+seed)) out.seed = parseInt(seed, 10);
if (temp !== '' && temp != null && !isNaN(+temp)) out.temperature = parseFloat(temp);
if (nspd !== '' && nspd != null && !isNaN(+nspd) && parseFloat(nspd) !== 1) out.speed = parseFloat(nspd);
return Object.keys(out).length ? out : null;
}
// RMS-based gain so each chunk plays at a similar perceived loudness (clamped,
// peak-limited to avoid clipping). Cached per unit.
function readerComputeGain(idx, buf) {
if (readerState.gainCache.has(idx)) return readerState.gainCache.get(idx);
const ch = buf.getChannelData(0);
const step = Math.max(1, Math.floor(ch.length / 8000));
let sum = 0, n = 0, peak = 1e-6;
for (let i = 0; i < ch.length; i += step) { const v = ch[i]; sum += v * v; n++; if (Math.abs(v) > peak) peak = Math.abs(v); }
const rms = Math.sqrt(sum / Math.max(1, n)) || 0.0001;
let gain = 0.10 / rms; // target RMS ≈ 0.10
gain = Math.max(0.5, Math.min(gain, 4));
gain = Math.min(gain, 0.99 / peak); // never clip
readerState.gainCache.set(idx, gain);
return gain;
}
// ── Playback ─────────────────────────────────────────────────────────────────
// Fetch a chunk's audio that's already persisted on the server (synced device).
async function readerFetchServerAudio(idx) {
if (!readerState.savedId || !readerState.savedAudioIdx.has(idx)) return null;
try {
const r = await fetch(`${READER_API}/${readerState.savedId}/audio/${idx}`);
if (r.ok) { const b = await r.blob(); readerState.blobCache.set(idx, b); return b; }
} catch (_) {}
return null;
}
async function readerGetBuffer(idx) {
if (readerState.bufCache.has(idx)) return readerState.bufCache.get(idx);
let blob = readerState.blobCache.get(idx) || await readerFetchServerAudio(idx);
if (!blob) {
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice) throw new Error('Pick a voice first');
if (!backend) throw new Error('No TTS backend selected');
const instruct = $('reader-instruct')?.value.trim() || '';
if (readerState.sentences[idx].status !== 'reading') readerSetStatus(idx, 'synth');
blob = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(idx, blob);
if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready');
}
const buf = await readerCtx().decodeAudioData(await blob.arrayBuffer());
readerState.bufCache.set(idx, buf);
return buf;
}
// Keep decoded PCM (large) only for a small window around the playhead; the
// compact MP3 blobs stay cached so re-entry just re-decodes (cheap).
function readerEvictBuffers(center) {
if (readerState.bufCache.size <= READER_BUF_WINDOW * 2 + 1) return;
for (const i of readerState.bufCache.keys()) {
if (Math.abs(i - center) > READER_BUF_WINDOW) readerState.bufCache.delete(i);
}
}
// Fetch the next chunk's audio AND pre-decode it so playback starts gaplessly.
async function readerPrefetch(idx) {
if (idx < 0 || idx >= readerState.sentences.length) return;
if (!readerState.blobCache.has(idx)) {
// prefer audio already persisted on the server, else synthesise
if (!(await readerFetchServerAudio(idx))) {
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice || !backend) return;
const instruct = $('reader-instruct')?.value.trim() || '';
if (readerState.sentences[idx].status === 'pending') readerSetStatus(idx, 'synth');
try {
const b = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(idx, b);
if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready');
} catch (_) { if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'pending'); return; }
}
}
// pre-decode (only matters while it's the immediate look-ahead within the window)
if (!readerState.bufCache.has(idx) && Math.abs(idx - readerState.idx) <= READER_BUF_WINDOW) {
try { readerState.bufCache.set(idx, await readerCtx().decodeAudioData(await readerState.blobCache.get(idx).arrayBuffer())); }
catch (_) {}
}
}
// Revert the single "reading" unit to its idle colour (no full-array scan).
function readerClearReading() {
const i = readerState.readingIdx;
if (i >= 0 && i < readerState.sentences.length && readerState.sentences[i].status === 'reading') {
readerSetStatus(i, readerState.blobCache.has(i) ? 'ready' : 'pending');
}
readerState.readingIdx = -1;
}
async function readerPlayCurrent() {
if (!readerState.playing) return;
if (readerState.idx >= readerState.sentences.length) { readerStopPlayback(); readerState.idx = 0; readerUpdateProgress(); return; }
const idx = readerState.idx;
const sentence = readerState.sentences[idx];
readerUpdateProgress();
readerSaveResume();
readerState.readingIdx = idx;
readerSetStatus(idx, 'reading');
readerHighlightSentence(sentence);
let buf;
try { buf = await readerGetBuffer(idx); }
catch (e) { toast(e.message || String(e), 'error'); readerSetStatus(idx, 'pending'); readerStopPlayback(); return; }
if (!readerState.playing || readerState.idx !== idx) return;
readerEvictBuffers(idx);
const timings = computeWordTimings(sentence.text, buf.duration);
const ctx = readerCtx();
readerStopSource();
const src = ctx.createBufferSource();
src.buffer = buf;
src.playbackRate.value = readerState.speed;
if (readerState.normalize) {
const g = ctx.createGain();
g.gain.value = readerComputeGain(idx, buf);
src.connect(g); g.connect(ctx.destination);
} else {
src.connect(ctx.destination);
}
readerState.currentSource = src;
const t0 = ctx.currentTime;
readerPrefetch(idx + 1);
src.onended = () => {
if (readerState.currentSource !== src) return;
readerState.currentSource = null;
if (readerState.raf) { cancelAnimationFrame(readerState.raf); readerState.raf = null; }
readerSetStatus(idx, 'ready'); // synthesised → green
if (readerState.readingIdx === idx) readerState.readingIdx = -1;
if (!readerState.playing) return;
readerState.idx++;
readerPlayCurrent();
};
src.start(0);
const tick = () => {
if (readerState.currentSource !== src) return;
const elapsed = (ctx.currentTime - t0) * readerState.speed;
let active = 0;
for (let i = timings.length - 1; i >= 0; i--) { if (elapsed >= timings[i].start) { active = i; break; } }
readerHighlightWord(sentence, Math.min(active, sentence.words.length - 1));
readerState.raf = requestAnimationFrame(tick);
};
readerState.raf = requestAnimationFrame(tick);
}
function readerStopSource() {
if (readerState.currentSource) { try { readerState.currentSource.onended = null; readerState.currentSource.stop(0); } catch (_) {} readerState.currentSource = null; }
if (readerState.raf) { cancelAnimationFrame(readerState.raf); readerState.raf = null; }
}
function readerStopPlayback() {
readerState.playing = false;
readerStopSource();
readerClearHighlights();
// a sentence left mid-read reverts to its cached/uncached colour
readerClearReading();
readerUpdatePlayBtn();
if (typeof window.setNavBusy === 'function') window.setNavBusy('s-reader', false);
}
function readerPlay() {
if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
if (!$('reader-voice-select')?.value) { toast('Pick a voice first', 'error'); return; }
readerCtx();
readerState.playing = true;
readerUpdatePlayBtn();
if (typeof window.setNavBusy === 'function') window.setNavBusy('s-reader', true);
readerPlayCurrent();
}
function readerPause() {
readerState.playing = false;
readerStopSource();
readerSaveResume();
readerPersistProgress();
readerClearReading();
readerUpdatePlayBtn();
}
function readerJumpTo(idx) {
readerState.idx = Math.max(0, Math.min(idx, readerState.sentences.length - 1));
readerStopSource();
readerClearHighlights();
readerClearReading();
readerUpdateProgress();
readerSaveResume();
if (readerState.playing) readerPlayCurrent();
else readerHighlightSentence(readerState.sentences[readerState.idx]);
}
function readerUpdatePlayBtn() {
const btn = $('reader-play'); if (!btn) return;
const ic = btn.querySelector('.mdi');
if (ic) ic.className = 'mdi ' + (readerState.playing ? 'mdi-pause' : 'mdi-play');
}
function readerUpdateProgress() {
const total = readerState.sentences.length;
const cur = total ? readerState.idx + 1 : 0;
const lbl = $('reader-progress-label'); if (lbl) lbl.textContent = cur + ' / ' + total;
const fill = $('reader-progress-fill'); if (fill) fill.style.width = (total ? (readerState.idx / total) * 100 : 0) + '%';
}
// ── Resume (persist last position per document title) ────────────────────────
function readerSaveResume() {
if (!readerState.title || !readerState.sentences.length) return;
try { localStorage.setItem(READER_RESUME_KEY, JSON.stringify({ title: readerState.title, idx: readerState.idx, total: readerState.sentences.length })); } catch (_) {}
}
function readerLoadResume() {
try {
const r = JSON.parse(localStorage.getItem(READER_RESUME_KEY) || 'null');
if (r && r.title === readerState.title && r.idx > 0 && r.idx < readerState.sentences.length) {
readerState.idx = r.idx;
return true;
}
} catch (_) {}
return false;
}
// ── Library (server-side under the config volume → syncs across devices) ──────
// Each book lives at <config>/reader_library/<id>/ (meta.json + source + audio/).
// Audio is fetched per-unit on demand so opening a book is instant on any device.
const READER_API = '/api/reader/docs';
function readerSyntheticCount() {
let n = 0;
for (let i = 0; i < readerState.sentences.length; i++) if (readerState.blobCache.has(i)) n++;
return n;
}
async function readerSaveLibrary() {
if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
if (readerState.mode === 'pdf' && !readerState.fileBlob) { toast('Original PDF unavailable to save', 'error'); return; }
const meta = {
title: readerState.title || 'Untitled',
kind: readerState.mode,
idx: readerState.idx,
voice: $('reader-voice-select')?.value || '',
backend: $('reader-backend-select')?.value || '',
speed: readerState.speed,
instruct: $('reader-instruct')?.value.trim() || '',
chunkMode: readerState.chunkMode,
seed: $('reader-seed')?.value.trim() || '',
temperature: $('reader-temp')?.value.trim() || '',
tts_speed: parseFloat($('reader-tts-speed')?.value) || 1,
normalize: readerState.normalize,
sentenceCount: readerState.sentences.length,
pageCount: readerState.pages.length,
synthCount: readerSyntheticCount(),
updated: new Date().toISOString(),
};
const btn = $('reader-save-lib'); if (btn) btn.disabled = true;
try {
if (readerState.savedId) meta.id = readerState.savedId;
const r = await fetch(READER_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const fresh = !readerState.savedId;
readerState.savedId = (await r.json()).id;
// upload the source document once (it's needed to re-render on another device)
if (fresh || !readerState.sourceUploaded) {
const ext = readerState.mode === 'pdf' ? 'pdf' : 'txt';
const body = readerState.mode === 'pdf' ? readerState.fileBlob : new Blob([readerState.docText || ''], { type: 'text/plain' });
const sr = await fetch(`${READER_API}/${readerState.savedId}/source?ext=${ext}`, { method: 'PUT', body });
if (sr.ok) readerState.sourceUploaded = true;
}
// upload ONLY chunks not already on the server (incremental)
let uploaded = 0;
for (let i = 0; i < readerState.sentences.length; i++) {
if (readerState.blobCache.has(i) && !readerState.savedAudioIdx.has(i)) {
const ar = await fetch(`${READER_API}/${readerState.savedId}/audio/${i}`, { method: 'PUT', body: readerState.blobCache.get(i) });
if (ar.ok) { readerState.savedAudioIdx.add(i); uploaded++; }
}
}
toast('Saved (' + meta.synthCount + ' / ' + meta.sentenceCount + ' synthesised' + (uploaded ? ', +' + uploaded + ' new' : '') + ')', 'success');
readerRenderLibrary();
} catch (e) { toast('Save failed: ' + (e.message || e), 'error'); }
finally { if (btn) btn.disabled = false; }
}
// Light update of just the reading position (called on stop/pause/leave)
async function readerPersistProgress() {
if (!readerState.savedId) return;
try {
await fetch(`${READER_API}/${readerState.savedId}/progress`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idx: readerState.idx, updated: new Date().toISOString() }),
});
} catch (_) {}
}
async function readerDeleteLibraryDoc(id) {
try {
await fetch(`${READER_API}/${id}`, { method: 'DELETE' });
if (readerState.savedId === id) { readerState.savedId = null; readerState.savedAudioIdx = new Set(); readerState.sourceUploaded = false; }
readerRenderLibrary();
} catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); }
}
async function readerOpenLibraryDoc(id) {
let rec;
try { const r = await fetch(`${READER_API}/${id}`); if (!r.ok) throw new Error(r.statusText); rec = await r.json(); }
catch (e) { toast('Open failed: ' + (e.message || e), 'error'); return; }
readerResetDoc();
readerState.title = rec.title;
const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = rec.title;
// restore controls (chunk mode must be set BEFORE the document is grouped)
readerState.chunkMode = rec.chunkMode || 'sentence';
if ($('reader-chunk-mode')) $('reader-chunk-mode').value = readerState.chunkMode;
if ($('reader-seed')) $('reader-seed').value = rec.seed || '';
if ($('reader-temp')) $('reader-temp').value = rec.temperature || '';
if ($('reader-tts-speed') && typeof rec.tts_speed === 'number') $('reader-tts-speed').value = rec.tts_speed;
readerState.normalize = rec.normalize !== false;
if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize;
if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend);
if (rec.voice && $('reader-voice-select')) readerSetSelectValue('reader-voice-select', rec.voice);
if (typeof rec.speed === 'number') {
readerState.speed = rec.speed;
if ($('reader-speed')) $('reader-speed').value = rec.speed;
const sl = $('reader-speed-label'); if (sl) sl.textContent = rec.speed.toFixed(2).replace(/0$/, '') + '×';
}
if ($('reader-instruct')) $('reader-instruct').value = rec.instruct || '';
// fetch the source document from the server
let sourceBlob = null;
try { const sr = await fetch(`${READER_API}/${id}/source`); if (sr.ok) sourceBlob = await sr.blob(); } catch (_) {}
try {
if (rec.kind === 'pdf') {
if (!sourceBlob) {
// PDF upload failed at save time — let user re-supply the file
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = '.pdf';
inp.onchange = async () => {
const file = inp.files[0]; if (!file) return;
const put = await fetch(`${READER_API}/${id}/source?ext=pdf`, { method: 'PUT', body: file });
if (!put.ok) { toast('Re-upload failed', 'error'); return; }
toast('PDF restored — opening…', 'success');
readerState.savedId = id; readerState.sourceUploaded = true;
readerState.fileBlob = file;
await readerLoadPdf(file);
};
inp.click();
toast('PDF source missing — please re-select the original file', 'error');
return;
}
readerState.fileBlob = sourceBlob;
// No "Opening book…" toast here — the "Reading PDF… page N / total"
// progress pill (readerShowParseProgress, inside readerLoadPdf) appears
// within the same instant and says the same thing with more detail, so
// the two used to visibly stack on top of each other.
await readerLoadPdf(sourceBlob);
} else {
const text = sourceBlob ? await sourceBlob.text() : (rec.text || '');
readerState.docText = text;
await readerLoadText(text);
}
} catch (e) { toast('Open failed: ' + (e.message || e), 'error'); return; }
if (!readerState.sentences.length) { toast('Document had no readable text', 'error'); return; }
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
readerState.savedId = id;
readerState.sourceUploaded = true; // already on the server
readerState.savedAudioIdx = new Set();
// mark which units already have audio on the server (fetched lazily on play)
if (rec.sentenceCount === readerState.sentences.length) {
(rec.audioIdx || []).forEach(i => { if (i < readerState.sentences.length) { readerState.savedAudioIdx.add(i); readerSetStatus(i, 'ready'); } });
} else if ((rec.audioIdx || []).length) {
toast('Document changed — re-synthesis needed', 'error');
}
readerState.idx = Math.max(0, Math.min(rec.idx || 0, readerState.sentences.length - 1));
$('reader-transport').hidden = false;
$('reader-dropzone').hidden = true;
const importArea = $('reader-import-area'); if (importArea) importArea.hidden = true;
readerUpdateToolbarVisibility(true);
$('reader-synthbar').hidden = false;
const pr = $('reader-page-range');
if (pr) {
pr.hidden = readerState.mode !== 'pdf';
if (readerState.mode === 'pdf') {
const n = readerState.pages.length;
if ($('reader-page-from')) { $('reader-page-from').max = n; $('reader-page-from').value = 1; }
if ($('reader-page-to')) { $('reader-page-to').max = n; $('reader-page-to').value = n; }
}
}
readerUpdateScopeLabel();
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[readerState.idx]);
toast('Opened "' + rec.title + '" · resuming at sentence ' + (readerState.idx + 1), 'success');
if (typeof window.navReaderView === 'function') window.navReaderView('main');
else showReaderView('main');
}
function readerSetSelectValue(id, value) {
const sel = $(id); if (!sel) return;
if (![...sel.options].some(o => o.value === value)) {
const o = document.createElement('option'); o.value = o.textContent = value; sel.appendChild(o);
}
sel.value = value;
if (id === 'reader-voice-select' && window.VoicePicker) VoicePicker.setValue(id, value);
}
async function readerRenderLibrary() {
const card = $('reader-library-card'), list = $('reader-lib-list');
if (!card || !list) return;
let all = [];
try { const r = await fetch(READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
if (!all.length) { list.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--subtext);">No saved books yet.</div>'; return; }
list.innerHTML = all.map(rec => {
const total = rec.sentenceCount || 0;
const synth = rec.synthCount || 0;
const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
const synthPct = total ? Math.round((synth / total) * 100) : 0;
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
let h = 0;
const titleStr = rec.title || 'Untitled';
for (let i = 0; i < titleStr.length; i++) h = (h * 31 + titleStr.charCodeAt(i)) % 360;
const h2 = (h + 40) % 360;
const c1 = `hsl(${h}, 55%, 42%)`, c2 = `hsl(${h2}, 58%, 30%)`;
// A timestamp busts the cache if the cover was updated
const coverUrl = `${READER_API}/${rec.id}/cover?t=${new Date(rec.updated||Date.now()).getTime()}`;
const bgStyle = rec.hasCover ? `style="background-image: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.8) 100%), url('${coverUrl}'); background-size: cover; background-position: center; color: #fff;"` : `style="--bk1:${c1};--bk2:${c2}"`;
return `<div class="reh-book reader-book" data-id="${rec.id}" title="${escHtml(rec.title)}" ${bgStyle}>
<div class="reh-book-actions">
<button class="reh-book-act reader-book-del" data-del="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
</div>
<div class="reh-book-title">${escHtml(rec.title)}</div>
<div class="reh-book-meta">
${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''}
<div class="reh-book-progress" title="${synthPct}% audio"><div style="width:${synthPct}%"></div></div>
<div style="margin-top:4px;opacity:.8"><span class="mdi ${rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline'}"></span> ${readPct}% read · ${date}</div>
</div>
</div>`;
}).join('');
list.querySelectorAll('.reader-book').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.reader-book-del')) return;
readerOpenLibraryDoc(el.dataset.id);
});
const delBtn = el.querySelector('.reader-book-del');
if (delBtn) {
delBtn.addEventListener('click', e => {
e.stopPropagation();
if (el.querySelector('.reader-book-del-confirm')) return;
const confirmOverlay = document.createElement('div');
confirmOverlay.className = 'reader-book-del-confirm';
confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;';
confirmOverlay.innerHTML = `
<div style="font-weight:600; margin-bottom:8px; font-size:14px;">Delete Book?</div>
<div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">Audio files will be removed.</div>
<div style="display:flex; gap:8px;">
<button class="btn-secondary btn-sm" id="btn-cancel-del" style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:6px 12px;">Cancel</button>
<button class="btn-primary btn-sm" id="btn-confirm-del" style="background:var(--red,#ef4444); border:none; color:#fff; padding:6px 12px;">Delete</button>
</div>
`;
confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
ce.stopPropagation();
confirmOverlay.remove();
});
confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
ce.stopPropagation();
confirmOverlay.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:24px;"></span>';
try {
const r = await fetch(`${READER_API}/${el.dataset.id}`, { method: 'DELETE' });
if (!r.ok) throw new Error('Failed to delete book');
toast('Book deleted', 'success');
readerRenderLibrary();
} catch (err) {
toast(err.message, 'error');
confirmOverlay.remove();
}
});
el.appendChild(confirmOverlay);
});
}
// Drag & drop for cover image
el.addEventListener('dragover', e => {
if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files')) {
e.preventDefault();
el.style.borderColor = 'var(--accent)';
}
});
el.addEventListener('dragleave', () => { el.style.borderColor = ''; });
el.addEventListener('drop', async e => {
if (!(e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files'))) return;
e.preventDefault(); e.stopPropagation();
el.style.borderColor = '';
const f = e.dataTransfer.files?.[0];
if (!f || !f.type.startsWith('image/')) { toast('Please drop an image file', 'error'); return; }
try {
const id = el.dataset.id;
const r = await fetch(`${READER_API}/${id}/cover`, { method: 'PUT', body: f });
if (!r.ok) throw new Error(r.statusText);
// Touch the updated timestamp so the cache buster updates on re-render
await fetch(`${READER_API}/${id}/progress`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updated: new Date().toISOString() }),
});
toast('Cover image updated', 'success');
readerRenderLibrary();
} catch (err) {
toast('Failed to upload cover: ' + (err.message || err), 'error');
}
});
});
list.querySelectorAll('.reader-book-del').forEach(btn => btn.addEventListener('click', e => {
e.stopPropagation();
if (confirm('Delete this saved document and its audio?')) readerDeleteLibraryDoc(btn.dataset.del);
}));
}
// ── Wiring ───────────────────────────────────────────────────────────────────
$('reader-file-input')?.addEventListener('change', function () {
const f = this.files?.[0]; this.value = '';
readerImportFile(f);
});
$('reader-paste-load')?.addEventListener('click', readerImportPastedText);
$('reader-paste-text')?.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
readerImportPastedText();
}
});
$('reader-fetch-voices-btn')?.addEventListener('click', readerFetchVoices);
$('reader-backend-select')?.addEventListener('change', () => {
const sel = $('reader-voice-select'); if (sel) sel.innerHTML = '<option value="">— fetch voices —</option>';
if (window.VoicePicker) {
VoicePicker.upgrade('reader-voice-select');
VoicePicker.populate('reader-voice-select', []);
}
readerUpdateBackendHint();
});
$('reader-chunk-mode')?.addEventListener('change', function () { readerRechunk(this.value); });
$('reader-normalize')?.addEventListener('change', function () { readerState.normalize = this.checked; });
$('reader-play')?.addEventListener('click', () => { readerState.playing ? readerPause() : readerPlay(); });
$('reader-stop')?.addEventListener('click', () => { readerStopPlayback(); readerState.idx = 0; readerSaveResume(); readerUpdateProgress(); });
$('reader-prev')?.addEventListener('click', () => readerJumpTo(readerState.idx - 1));
$('reader-next')?.addEventListener('click', () => readerJumpTo(readerState.idx + 1));
$('reader-speed')?.addEventListener('input', function () {
readerState.speed = parseFloat(this.value) || 1;
const lbl = $('reader-speed-label'); if (lbl) lbl.textContent = readerState.speed.toFixed(2).replace(/0$/, '') + '×';
if (readerState.currentSource) readerState.currentSource.playbackRate.value = readerState.speed;
});
// Zoom controls
$('reader-zoom-fitw')?.addEventListener('click', () => readerApplyZoom('fit-width'));
$('reader-zoom-fith')?.addEventListener('click', () => readerApplyZoom('fit-height'));
$('reader-zoom-two') ?.addEventListener('click', () => readerApplyZoom('two'));
$('reader-zoom-in') ?.addEventListener('click', () => { readerState.scale = Math.min(4, readerState.scale * 1.2); readerApplyZoom('custom'); });
$('reader-zoom-out') ?.addEventListener('click', () => { readerState.scale = Math.max(0.2, readerState.scale / 1.2); readerApplyZoom('custom'); });
// ── Document search ──────────────────────────────────────────────────────────
let _readerSearchTerm = '';
let _readerSearchHits = [];
let _readerSearchCurrent = -1;
let _readerSearchTimer = null;
const READER_SEARCH_MAX_HITS = 2500;
function readerSearchTextMap(words) {
let text = '';
const spans = [];
for (let i = 0; i < words.length; i++) {
const raw = String(words[i]?.text || '').trim();
if (!raw) continue;
if (text) text += ' ';
const start = text.length;
text += raw.toLowerCase();
spans.push({ wordIndex: i, start, end: text.length });
}
return { text, spans };
}
function readerFindSearchGroups(sentence, term) {
const words = sentence?.words || [];
const needle = String(term || '').trim().replace(/\s+/g, ' ').toLowerCase();
if (!needle) return [];
const mapped = readerSearchTextMap(words);
const groups = [];
let pos = 0;
while ((pos = mapped.text.indexOf(needle, pos)) !== -1) {
const end = pos + needle.length;
const idxs = mapped.spans
.filter(sp => sp.end > pos && sp.start < end)
.map(sp => sp.wordIndex);
if (idxs.length) groups.push(idxs);
pos = Math.max(pos + 1, end);
}
return groups;
}
function readerRemoveSearchHighlights() {
document.querySelectorAll('.reader-search-hit').forEach(el => el.remove());
document.querySelectorAll('.reader-word.search-hit, .reader-word.search-current').forEach(el => {
el.classList.remove('search-hit', 'search-current');
});
}
function readerSetSearchStatus(text) {
const st = $('reader-search-status');
if (st) st.textContent = text || '';
}
function readerClearSearch({ clearInput = false } = {}) {
_readerSearchTerm = '';
_readerSearchHits = [];
_readerSearchCurrent = -1;
readerRemoveSearchHighlights();
readerSetSearchStatus('');
if (clearInput) {
const inp = $('reader-search');
if (inp) inp.value = '';
}
}
function readerCollectSearchHits(term) {
const hits = [];
const sents = readerState.sentences || [];
for (let si = 0; si < sents.length; si++) {
const s = sents[si];
const groups = readerFindSearchGroups(s, term);
for (const wordIndexes of groups) {
const page = s.words?.[wordIndexes[0]]?.page ?? 0;
hits.push({ sentenceIndex: si, page, wordIndexes, nodes: [] });
if (hits.length >= READER_SEARCH_MAX_HITS) return hits;
}
}
return hits;
}
function readerPaintSearchHighlights() {
readerRemoveSearchHighlights();
if (!_readerSearchTerm || !_readerSearchHits.length) return;
const scale = readerState.scale || 1;
_readerSearchHits.forEach((hit, hi) => {
const s = readerState.sentences[hit.sentenceIndex];
if (!s) return;
hit.nodes = [];
for (const wi of hit.wordIndexes) {
const w = s.words?.[wi];
if (!w) continue;
if (readerState.mode === 'pdf') {
const pg = readerState.pages[w.page];
if (!pg?.overlay) continue;
const el = document.createElement('div');
el.className = 'reader-search-hit' + (hi === _readerSearchCurrent ? ' search-current' : '');
Object.assign(el.style, {
left: (w.x * scale) + 'px',
top: (w.top * scale) + 'px',
width: (Math.max(w.w, 2) * scale) + 'px',
height: (Math.max(w.h, 2) * scale) + 'px',
});
pg.overlay.appendChild(el);
hit.nodes.push(el);
} else if (w.el) {
w.el.classList.add('search-hit');
if (hi === _readerSearchCurrent) w.el.classList.add('search-current');
hit.nodes.push(w.el);
}
}
});
}
function readerUpdateSearchStatus() {
if (!_readerSearchTerm) { readerSetSearchStatus(''); return; }
if (!readerState.sentences?.length) { readerSetSearchStatus('No text loaded'); return; }
if (!_readerSearchHits.length) { readerSetSearchStatus('0/0'); return; }
const current = _readerSearchCurrent >= 0 ? _readerSearchCurrent + 1 : 1;
const hit = _readerSearchHits[Math.max(0, _readerSearchCurrent)];
const capped = _readerSearchHits.length >= READER_SEARCH_MAX_HITS ? '+' : '';
const page = readerState.mode === 'pdf' ? ' · p.' + ((hit?.page ?? 0) + 1) : '';
readerSetSearchStatus(current + '/' + _readerSearchHits.length + capped + page);
}
function readerJumpToSearchHit(idx) {
if (!_readerSearchHits.length) return;
_readerSearchCurrent = (idx + _readerSearchHits.length) % _readerSearchHits.length;
readerPaintSearchHighlights();
readerUpdateSearchStatus();
const hit = _readerSearchHits[_readerSearchCurrent];
const node = hit?.nodes?.[0];
if (node) {
readerEnsureVisible(node);
} else if (readerState.mode === 'pdf') {
const pg = readerState.pages[hit.page];
if (pg?.pageDiv) {
readerRenderPage(hit.page);
pg.pageDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
function readerSearchApply(term, { jump = true } = {}) {
term = String(term || '').trim();
if (!term) { readerClearSearch(); return; }
_readerSearchTerm = term;
_readerSearchHits = readerCollectSearchHits(term);
_readerSearchCurrent = _readerSearchHits.length ? 0 : -1;
readerPaintSearchHighlights();
readerUpdateSearchStatus();
if (jump && _readerSearchHits.length) readerJumpToSearchHit(0);
}
function readerSearchStep(direction) {
const term = $('reader-search')?.value.trim() || _readerSearchTerm;
if (!_readerSearchHits.length || term !== _readerSearchTerm) {
readerSearchApply(term, { jump: true });
return;
}
readerJumpToSearchHit(_readerSearchCurrent + (direction === -1 ? -1 : 1));
}
$('reader-search')?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); readerSearchStep(e.shiftKey ? -1 : 1); }
if (e.key === 'Escape') { readerClearSearch({ clearInput: true }); }
});
$('reader-search')?.addEventListener('input', e => {
clearTimeout(_readerSearchTimer);
const term = e.target.value.trim();
if (!term) { readerClearSearch(); return; }
_readerSearchTimer = setTimeout(() => readerSearchApply(term, { jump: true }), 160);
});
$('reader-search-prev')?.addEventListener('click', () => readerSearchStep(-1));
$('reader-search-next')?.addEventListener('click', () => readerSearchStep(1));
$('reader-search-clear')?.addEventListener('click', () => readerClearSearch({ clearInput: true }));
// Library + export
$('reader-save-lib')?.addEventListener('click', readerSaveLibrary);
$('reader-export-btn')?.addEventListener('click', () => readerExport($('reader-export-mode')?.value || 'page'));
// Batch synthesis + range selection
$('reader-synth-all')?.addEventListener('click', readerSynthAll);
$('reader-synth-cancel')?.addEventListener('click', () => { readerState.synthCancel = true; });
$('reader-select-toggle')?.addEventListener('click', () => {
if (readerState.selecting) readerSetSelecting(false); // "Done"
else { readerClearSelection(); readerSetSelecting(true); }
});
$('reader-sel-clear')?.addEventListener('click', readerClearSelection);
// Esc leaves select mode
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && readerState.selecting) { readerSetSelecting(false); }
});
$('reader-page-from')?.addEventListener('change', readerUpdateScopeLabel);
$('reader-page-to') ?.addEventListener('change', readerUpdateScopeLabel);
// Click a PDF sentence's status box to set a selection point (in select mode)
$('reader-doc')?.addEventListener('click', e => {
if (!readerState.selecting || readerState.mode !== 'pdf') return;
const stat = e.target.closest('.reader-stat'); if (!stat) return;
const si = parseInt(stat.dataset.si);
if (!isNaN(si)) readerPickSentence(si);
});
// Re-raster pages that scroll into view (zoom invalidates cached canvases)
$('reader-doc')?.addEventListener('scroll', (function () {
let t = null;
return () => { if (t) return; t = setTimeout(() => { t = null; readerRenderVisible(); }, 120); };
})());
// Keep fit-width/height/two correct on window resize
window.addEventListener('resize', (function () {
let t = null;
return () => { if (readerState.mode !== 'pdf' || readerState.zoomMode === 'custom') return; clearTimeout(t); t = setTimeout(() => readerApplyZoom(readerState.zoomMode), 200); };
})());
// Drag & drop over the dropzone + the whole reader doc
(function setupReaderDropzone() {
const targets = ['reader-dropzone', 'reader-doc'].map(id => $(id)).filter(Boolean);
targets.forEach(el => {
el.addEventListener('dragover', e => { if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files')) { e.preventDefault(); el.classList.add('dragover'); } });
el.addEventListener('dragleave', () => el.classList.remove('dragover'));
el.addEventListener('drop', e => {
if (!(e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files'))) return;
e.preventDefault(); el.classList.remove('dragover');
readerImportFile(e.dataTransfer.files?.[0]);
});
});
})();
window.showReaderView = function (view) {
const main = $('reader-main-view');
const cast = $('reader-audiobook-panel');
const chars = $('reader-charsheets-panel');
const lib = $('reader-library-card');
if (main) main.hidden = (view !== 'main');
if (cast) {
cast.hidden = (view !== 'cast');
if (view !== 'cast') {
cast.style.display = '';
cast.style.flexDirection = '';
cast.style.minHeight = '';
}
if (view === 'cast' && !cast.innerHTML.trim()) {
cast.innerHTML = '<div style="padding:60px 20px; text-align:center; color:var(--subtext);"><span class="mdi mdi-drama-masks" style="font-size:48px; opacity:0.5; margin-bottom:16px; display:block;"></span><h3 style="margin:0 0 8px 0; color:var(--text);">No active casting session</h3><p style="margin:0;">Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.</p><br><button class="btn-primary" onclick="navReaderView(\'main\')">Go to Reader</button></div>';
cast.className = 'ab-castpanel-inline card';
}
}
if (chars) {
chars.hidden = (view !== 'chars');
if (view === 'chars' && !chars.innerHTML.trim()) {
chars.innerHTML = '<div class="card" style="padding:24px; min-height:260px; display:flex; flex-direction:column; gap:12px; justify-content:center;">'
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>'
+ '<p class="card-subtitle" style="margin:0;">This stage will show the live passage-by-passage extraction view, prompt editor, and generated sheets once you start a cast.</p>'
+ '<div class="btn-row" style="margin-top:4px;">'
+ '<button class="btn-primary btn-sm" onclick="if (typeof csForReader === \'function\') csForReader();"><span class="mdi mdi-account-details-outline"></span> Cast from Reader</button>'
+ '<button class="btn-secondary btn-sm" onclick="if (typeof csForRehearser === \'function\') csForRehearser();"><span class="mdi mdi-theater"></span> Cast from Rehearser</button>'
+ '<button class="btn-secondary btn-sm" onclick="if (typeof navLibraryView === \'function\') navLibraryView(\'characters\');"><span class="mdi mdi-folder-account-outline"></span> Go to Cast</button>'
+ '</div></div>';
chars.className = '';
}
}
if (lib) {
lib.hidden = (view !== 'library');
if (view === 'library') {
lib.classList.remove('card'); // Remove card styling so it takes full width/height naturally
lib.style.boxShadow = 'none';
lib.style.border = 'none';
lib.style.background = 'transparent';
readerRenderLibrary(); // Re-fetch on open
}
}
if (typeof refreshWorkflowCrumbs === 'function') {
refreshWorkflowCrumbs(view === 'cast' ? 'cast' : view === 'chars' ? 'chars' : 'source');
}
};
window.navReaderView = function (view) {
window.showReaderView(view);
};
window.readerJumpToPage = function (pageNum) {
const pg = parseInt(pageNum, 10);
if (!pg) return Promise.resolve(false);
if (typeof navTo === 'function') navTo('s-reader');
window.showReaderView('main');
let tries = 0;
const waitFrame = () => new Promise(resolve => requestAnimationFrame(resolve));
const jump = async () => {
await waitFrame();
const pageState = readerState.pages?.[pg - 1];
const pageDiv = pageState?.pageDiv;
if (pageDiv && !pageDiv.hidden) {
await readerRenderPage(pg - 1);
pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
readerRenderVisible();
return true;
}
if (tries++ < 20) {
await new Promise(resolve => setTimeout(resolve, 100));
return jump();
}
if (typeof toast === 'function') toast('Source page is not loaded in Reader yet', 'info');
return false;
};
return jump();
};
window.readerOnShow = async function () {
const sel = $('reader-backend-select');
if (sel && (!sel.value || sel.options.length <= 1)) {
if (typeof availableTtsBackends === 'function' && !availableTtsBackends().length &&
typeof refreshTtsBackendAvailability === 'function') {
try { await refreshTtsBackendAvailability(); } catch (_) {}
}
if (typeof availableTtsBackends === 'function' && availableTtsBackends().length) {
sel.innerHTML = ttsBackendOptions(sel.value);
sel.disabled = false;
}
}
readerUpdateBackendHint();
if (window.VoicePicker) VoicePicker.upgrade('reader-voice-select');
readerRenderLibrary();
if (window._readerStartView) {
window.showReaderView(window._readerStartView);
window._readerStartView = null;
}
_readerEnsureImportVisible();
};
// #reader-config-card ("Voice & synthesis settings") also holds the only
// paste/drag-and-drop import controls (#reader-import-area) — collapsed by
// default (data-collapse-default="closed"), which is fine once a document is
// loaded, but leaves a first-time or post-delete user staring at an empty
// page with no visible way to import anything (confirmed live: deleting the
// current book landed on an empty box with the import controls hidden inside
// the collapsed card above it). data-collapse-default is only read once at
// initCollapsibleCards() init time — this mirrors its `apply(true)` directly
// rather than trying to re-trigger that logic, and never persists to
// localStorage, so a user's own collapse choice made once a document IS
// loaded is left alone.
function _readerEnsureImportVisible() {
if (readerState.sentences.length) return;
const card = document.getElementById('reader-config-card');
const body = card ? card.querySelector('.card-col-body') : null;
if (body && body.hidden) {
body.hidden = false;
card.classList.remove('card-col-closed');
}
}