Optimize and clean up the pipeline merge (v1.12.85)

Speed: OCR renders only the heading band (was full page at 2.5x),
Tesseract worker freed after extraction, name-underline index cached
instead of rebuilt per segment, constant regexes hoisted, old drafts
migrate segment page numbers once at load (single-path feed renderer).

Quality: global [hidden]{display:none!important} ends the empty-box bug
class; racy deferred cast-restore + _readerSuppressCastRestore flag
replaced by a synchronous, caller-wins restore; card collapse defaults
move to data-collapse-default markup; duplicated join/colour/alias/LLM-
target helpers now delegate to their canonical implementations; dead
reader state removed; stepper hide-guard fixed for the merged Source key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-03 21:52:33 +02:00
parent 9d80ec27e7
commit cfe15a72f5
11 changed files with 176 additions and 175 deletions

View File

@ -9,6 +9,25 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.12.85] — 2026-07-03
### Performance
- **Heading OCR renders only the top band, not the whole page** — the OCR pass rasterized every full page at 2.5× scale (~6M pixels for A4) and then cropped ~10% of it; the canvas is now sized to the band itself so the remaining ~90% is never rendered or allocated. Also: the Tesseract worker (tens of MB of WASM/language data) is now terminated after extraction instead of living for the whole session, and the top-gap scan no longer allocates a throwaway array per page.
- **Casting feed name-underlining no longer rebuilds its name index per segment** — the list of character names/aliases and their compiled regexes was recomputed for every rendered segment (O(segments × records) over a full book); it's now cached and invalidated only when the roster or character records actually change. The dialogue-splitting fallback also compiled a constant regex once per sentence-ending character; hoisted to a module constant.
- **Old casting drafts migrate their page numbers once at load** — drafts saved before segments carried a `.page` field were re-deriving page boundaries via text search on every feed redraw; they're now stamped once when the draft is applied, and the renderer is single-path.
### Changed
- **`[hidden]` now always hides, globally** — one root rule (`[hidden]{display:none!important}`) replaces the per-component patches this bug class kept requiring (`.ab-char-bar`, `.wf-stepper`, `.reader-extract-banner`, `.ab-castpanel-inline`, and ~20 others individually). New components can no longer reintroduce the empty-box-while-hidden bug.
- **Removed the racy cast-panel view override** — navigating to Read Aloud restored an active casting session via a deferred `setTimeout` that overrode whatever view the caller had just chosen, which needed a global suppress flag (`_readerSuppressCastRestore`) set from two unrelated places to defeat. The restore is now synchronous and respects an explicit view request, so the flag is gone and callers simply win by calling `showReaderView()` after `navTo()`. This also fixes the sidebar "Reader" item landing in the casting view instead of the reader while a cast was active.
- **Cards can declare their collapse default in markup**`data-collapse-default="closed"` on a card is now read by the generic collapse mechanism, replacing reader.js writing another module's localStorage key derived from the card's heading text (which would have silently broken on any heading rename).
- **Deduplicated helpers** — casting's segment-join, colour (hue/hex/normalize), and alias-splitting logic now delegate to the canonical implementations (`audiobookJoinSegmentText`, `clNormalizeColor`/`clHslToHex`/`clNameHue`, `clSplitIdentityTokens`) instead of maintaining byte-identical private copies; the status bar's LLM endpoint resolution is now a single shared `statusLlmTarget()` instead of two identical inline copies.
- **Removed dead state**`readerState.pdfParsing`/`pdfParsedPages`/`pdfParseTotal` (never set, only reset — including a toast suffix that could never appear) and the write-only `readerState.textExtracted` flag.
### Fixed
- **Pipeline stepper never hid on a fresh session** — the "show only when a document is loaded" guard still checked for the old `'pdf'` step key after the PDF/Text merge into "Source", so the always-available Source stop kept the strip permanently visible.
---
## [1.12.84] — 2026-07-03 ## [1.12.84] — 2026-07-03
### Fixed ### Fixed

View File

@ -1 +1 @@
1.12.84 1.12.85

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.12.84"> <meta name="app-version" content="1.12.85">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.12.84"> <link rel="stylesheet" href="/static/style.css?v=1.12.85">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.12.84"></script> <script src="/static/loader.js?v=1.12.85"></script>
</body> </body>
</html> </html>

View File

@ -652,7 +652,10 @@ window.initCollapsibleCards = function initCollapsibleCards() {
card.dataset.colInit = '1'; card.dataset.colInit = '1';
const key = 'card-' + slug(h2.textContent); const key = 'card-' + slug(h2.textContent);
const open = load()[key] !== false; // default: open // A card can opt into starting collapsed via data-collapse-default="closed";
// a stored user choice always wins over the default.
const stored = load()[key];
const open = stored !== undefined ? stored !== false : card.dataset.collapseDefault !== 'closed';
// Prepend a rotating chevron inside the h2 // Prepend a rotating chevron inside the h2
const chev = document.createElement('span'); const chev = document.createElement('span');

View File

@ -165,10 +165,6 @@ function audiobookFindReturnedSegmentSequence(targetSeg, returned, used) {
return null; return null;
} }
// Same hue algorithm as library.js _charHue so avatar colours match across views
function _abCharHue(name) {
return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0));
}
// String coercer (mirrors library.js _libStr) // String coercer (mirrors library.js _libStr)
function _abStr(v) { function _abStr(v) {
if (v == null) return ''; if (v == null) return '';
@ -228,6 +224,32 @@ function _abCastMarkdown(data) {
].join('\n'); ].join('\n');
} }
// One-time migration for drafts saved before segments carried a .page number:
// re-derive each segment's page from the draft's pageMarks via the old
// forward-only offset search and stamp it on. Mirrors the legacy render-time
// fallback exactly (segments before the first mark stay unstamped → unlabeled
// leading card), but runs once at draft load instead of on every redraw, so
// the feed renderer stays single-path.
function _abStampSegmentPages(segs, pageMarks, text) {
if (!Array.isArray(segs) || !segs.length || segs.some(s => s && s.page != null)) return;
const marks = (pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0));
const src = text || '';
if (!marks.length || !src) return;
let markIdx = 0, searchPos = 0, cur = null;
if (marks[0].offset <= 2) { cur = marks[0].page + 1; markIdx = 1; }
for (const s of segs) {
const probe = String(s.text || '').trim().slice(0, 24);
const at = probe ? src.indexOf(probe, searchPos) : -1;
const pos = at >= 0 ? at : searchPos;
if (at >= 0) searchPos = at + probe.length;
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
cur = marks[markIdx].page + 1;
markIdx++;
}
if (cur != null) s.page = cur;
}
}
function _abSaveDraft(segs, roster, text, done, total) { function _abSaveDraft(segs, roster, text, done, total) {
const bookId = _abBookId(); const bookId = _abBookId();
const payload = { const payload = {
@ -515,6 +537,12 @@ const AB_QUOTE_PAIRS = {
'「': '」', '『': '』', '': '', '': '', '': '', '': '' '「': '」', '『': '』', '': '', '': '', '': '', '': ''
}; };
// Constant pattern — compiled once, not per sentence-ending character scanned.
const _AB_UNCLOSED_TAG_RE = new RegExp(
'^\\s*(?:[,;:-]\\s*)?(?:' + AB_SPEECH_VERBS + '|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|I|we|you)\\s+' + AB_SPEECH_VERBS + ')\\b',
'i'
);
function _abUnclosedQuoteEnd(raw, closeIdx) { function _abUnclosedQuoteEnd(raw, closeIdx) {
const limit = closeIdx >= 0 ? closeIdx : raw.length; const limit = closeIdx >= 0 ? closeIdx : raw.length;
for (let i = 0; i < limit; i++) { for (let i = 0; i < limit; i++) {
@ -522,11 +550,7 @@ function _abUnclosedQuoteEnd(raw, closeIdx) {
const quote = raw.slice(0, i + 1).trim(); const quote = raw.slice(0, i + 1).trim();
if (quote.length < 2) continue; if (quote.length < 2) continue;
const after = raw.slice(i + 1, Math.min(limit, i + 140)); const after = raw.slice(i + 1, Math.min(limit, i + 140));
const tag = new RegExp( if (_AB_UNCLOSED_TAG_RE.test(after)) return i + 1;
'^\\s*(?:[,;:-]\\s*)?(?:' + AB_SPEECH_VERBS + '|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|I|we|you)\\s+' + AB_SPEECH_VERBS + ')\\b',
'i'
);
if (tag.test(after)) return i + 1;
} }
if (closeIdx < 0) { if (closeIdx < 0) {
const m = raw.match(/^([\s\S]{2,180}?[.!?])(?:\s|$)/); const m = raw.match(/^([\s\S]{2,180}?[.!?])(?:\s|$)/);
@ -715,20 +739,12 @@ function audiobookProgress(total) {
}; };
} }
function _abHslToHex(h, s, l) { // Delegates to the character library's canonical colour implementation
s /= 100; l /= 100; // (clNormalizeColor / clHslToHex / clNameHue in characters-library.js) so
const k = n => (n + h / 30) % 12; // avatar colours can't drift between views; only the 'Narrator' default for a
const a = s * Math.min(l, 1 - l); // missing name lives here.
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return '#' + [f(0), f(8), f(4)].map(x => Math.round(255 * x).toString(16).padStart(2, '0')).join('');
}
function _abNormalizeColor(color, fallbackName) { function _abNormalizeColor(color, fallbackName) {
const c = String(color || '').trim(); return clNormalizeColor(color, fallbackName || 'Narrator');
if (/^#[0-9a-f]{6}$/i.test(c)) return c;
if (/^#[0-9a-f]{3}$/i.test(c)) return '#' + c.slice(1).split('').map(ch => ch + ch).join('');
const hue = _abCharHue(fallbackName || 'Narrator');
return _abHslToHex(hue, 58, 43);
} }
function _abDefaultCharacterColor(name) { function _abDefaultCharacterColor(name) {
@ -1146,28 +1162,22 @@ STRIKTE FORMAT- UND TEXTREGELN:
const feed = panel.querySelector('#ab-cv-feed'), chars = panel.querySelector('#ab-cv-chars'); const feed = panel.querySelector('#ab-cv-feed'), chars = panel.querySelector('#ab-cv-chars');
const roster = new Map(); // name -> { count, color } const roster = new Map(); // name -> { count, color }
const characterRecords = new Map(); // lower-case name -> character-library record const characterRecords = new Map(); // lower-case name -> character-library record
const AB_ALIAS_MAX_TOKENS = 12; // Like clIdentityNames (characters-library.js) but preserves original case —
const AB_ALIAS_MAX_CHARS = 500; // highlightText dedups and colour-keys by the display-cased name. The token
const splitIdentityTokens = (v, opts = {}) => { // splitting itself is the library's canonical clSplitIdentityTokens.
const raw = _abStr(v);
const parts = raw
.split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i)
.map(x => x.trim())
.filter(Boolean)
.filter(x => x.length <= 80 && !/^needs?:/i.test(x) && !/^complete$/i.test(x));
if (opts.aliases && (raw.length > AB_ALIAS_MAX_CHARS || parts.length > AB_ALIAS_MAX_TOKENS)) return [];
return parts.slice(0, opts.aliases ? AB_ALIAS_MAX_TOKENS : undefined);
};
const identityNames = (recOrSheet) => { const identityNames = (recOrSheet) => {
const s = recOrSheet?.sheet || recOrSheet || {}; const s = recOrSheet?.sheet || recOrSheet || {};
const out = new Set(); const out = new Set();
const add = (v, opts = {}) => splitIdentityTokens(v, opts).forEach(x => out.add(x)); const add = (v, opts = {}) => clSplitIdentityTokens(v, opts).forEach(x => out.add(x));
add(recOrSheet?.name || s.name); add(recOrSheet?.name || s.name);
['aliases', 'first_name', 'last_name', 'full_name', 'title'].forEach(k => add(s[k], { aliases: k === 'aliases' })); ['aliases', 'first_name', 'last_name', 'full_name', 'title'].forEach(k => add(s[k], { aliases: k === 'aliases' }));
return [...out]; return [...out];
}; };
let _hlVer = 0; // bumped whenever character records change
let _hlCache = { ver: -1, rosterSize: -1, list: [] };
const registerCharacterRecord = (rec) => { const registerCharacterRecord = (rec) => {
if (!rec?.name) return; if (!rec?.name) return;
_hlVer++;
for (const n of identityNames(rec)) characterRecords.set(n.toLowerCase(), rec); for (const n of identityNames(rec)) characterRecords.set(n.toLowerCase(), rec);
}; };
const recordForName = (name) => characterRecords.get(String(name || '').toLowerCase()) || null; const recordForName = (name) => characterRecords.get(String(name || '').toLowerCase()) || null;
@ -1191,18 +1201,33 @@ STRIKTE FORMAT- UND TEXTREGELN:
const highlightText = (text) => { const highlightText = (text) => {
if (!text) return ''; if (!text) return '';
let html = escHtml(text); let html = escHtml(text);
const extraNames = []; // The name list + compiled regexes are invariant between segments until the
new Set([...characterRecords.values()]).forEach(rec => extraNames.push(...identityNames(rec))); // roster or character records change — rebuilding them per segment made
const names = [...new Set([...roster.keys(), ...extraNames])] // rendering O(segments × records) over a whole book. Colours stay live via
.filter(n => n.toLowerCase() !== 'narrator' && !/^Unknown|Unbekannt/i.test(n)) // colorFor at replace time.
.sort((a, b) => b.length - a.length); if (_hlCache.ver !== _hlVer || _hlCache.rosterSize !== roster.size) {
for (const name of names) { const extraNames = [];
if (name.length < 2) continue; new Set([...characterRecords.values()]).forEach(rec => extraNames.push(...identityNames(rec)));
const safeName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const seen = new Set();
const regex = new RegExp(`\\b(${safeName})\\b`, 'gi'); const names = [];
html = html.replace(regex, (match) => { for (const n of [...roster.keys(), ...extraNames]) {
return `<span style="border-bottom: 2px solid ${colorFor(name)}; font-weight: 600;">${match}</span>`; const k = n.toLowerCase();
}); if (n.length < 2 || k === 'narrator' || /^unknown|unbekannt/i.test(k) || seen.has(k)) continue;
seen.add(k);
names.push(n);
}
names.sort((a, b) => b.length - a.length);
_hlCache = {
ver: _hlVer,
rosterSize: roster.size,
list: names.map(name => ({
name,
regex: new RegExp(`\\b(${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\b`, 'gi'),
})),
};
}
for (const { name, regex } of _hlCache.list) {
html = html.replace(regex, (match) => `<span style="border-bottom: 2px solid ${colorFor(name)}; font-weight: 600;">${match}</span>`);
} }
return html; return html;
}; };
@ -1336,7 +1361,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
const sh = rec.sheet || {}; const sh = rec.sheet || {};
const charColor = setCharacterColor(rec.name, rec.color || sh.color || colorFor(rec.name, rec)); const charColor = setCharacterColor(rec.name, rec.color || sh.color || colorFor(rec.name, rec));
const accent2 = _abHslToHex((_abCharHue(rec.name) + 40) % 360, 58, 30); const accent2 = clHslToHex((clNameHue(rec.name) + 40) % 360, 58, 30);
const tier = String(sh.tier || '').toLowerCase(); const tier = String(sh.tier || '').toLowerCase();
const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : 'Nebenfigur'; const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : 'Nebenfigur';
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
@ -1848,43 +1873,18 @@ STRIKTE FORMAT- UND TEXTREGELN:
const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : ''; const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : '';
feed.innerHTML = ''; feed.innerHTML = '';
_abCurPage = null; _abCurPage = null;
// Segments cast since the .page-stamping fix carry their own page number — // Segments carry their own page number — stamped at cast time for new
// use that directly instead of re-guessing offsets from text, which used to // casts, and back-filled once at draft load by _abStampSegmentPages for
// silently merge pages whenever a segment's text didn't substring-match // drafts saved before the .page field existed. Rendering directly from it
// exactly (LLM cleanup, dehyphenation, short lines) after navigating away // (instead of re-guessing offsets from text on every redraw) is what fixed
// from the casting panel and back. Older drafts without a .page field fall // pages silently merging after navigating away from the panel and back.
// back to the previous offset-matching approach. let lastPage = null;
const hasPageField = (segs || []).some(s => s.page != null); for (const s of segs || []) {
if (hasPageField) { if (s.page != null && s.page !== lastPage) {
let lastPage = null; _abNewPage('Page ' + s.page, s.page);
for (const s of segs || []) { lastPage = s.page;
if (s.page != null && s.page !== lastPage) {
_abNewPage('Page ' + s.page, s.page);
lastPage = s.page;
}
_abPage().appendChild(_abRowFromSegment(s));
}
} else {
const marks = (_audiobook.pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0));
const src = _audiobook.lastText || '';
let markIdx = 0, searchPos = 0;
if (marks.length && marks[0].offset <= 2) {
_abNewPage('Page ' + (marks[0].page + 1), marks[0].page + 1);
markIdx = 1;
}
for (const s of segs || []) {
if (src && markIdx < marks.length) {
const probe = String(s.text || '').trim().slice(0, 24);
const at = probe ? src.indexOf(probe, searchPos) : -1;
const pos = at >= 0 ? at : searchPos;
if (at >= 0) searchPos = at + probe.length;
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
_abNewPage('Page ' + (marks[markIdx].page + 1), marks[markIdx].page + 1);
markIdx++;
}
}
_abPage().appendChild(_abRowFromSegment(s));
} }
_abPage().appendChild(_abRowFromSegment(s));
} }
_abRecountRoster(segs || []); _abRecountRoster(segs || []);
_abClearHL(); _abClearHL();
@ -2046,16 +2046,6 @@ STRIKTE FORMAT- UND TEXTREGELN:
const _abIsUnknownSeg = (s) => !s?.speaker || /^Unknown|Unbekannt/i.test(s.speaker); const _abIsUnknownSeg = (s) => !s?.speaker || /^Unknown|Unbekannt/i.test(s.speaker);
const _abIsNarrSeg = (s) => s?.type !== 'dialogue' || !s.speaker || /^Narrator$/i.test(s.speaker); const _abIsNarrSeg = (s) => s?.type !== 'dialogue' || !s.speaker || /^Narrator$/i.test(s.speaker);
const _abJoinSegmentText = (a, b) => {
const left = String(a || '');
const right = String(b || '');
if (!left) return right;
if (!right) return left;
if (/\s$/.test(left) || /^\s/.test(right)) return left + right;
if (/^[,.;:!?»«”"')\]]/.test(right)) return left + right;
if (/[([{„“"']$/.test(left)) return left + right;
return left + ' ' + right;
};
const _abMergedSegment = (a, b) => { const _abMergedSegment = (a, b) => {
let base = a; let base = a;
if (_abIsUnknownSeg(a) && !_abIsUnknownSeg(b)) base = b; if (_abIsUnknownSeg(a) && !_abIsUnknownSeg(b)) base = b;
@ -2066,7 +2056,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
speaker, speaker,
type, type,
emotion: type === 'dialogue' ? (base.emotion || a.emotion || b.emotion || '') : '', emotion: type === 'dialogue' ? (base.emotion || a.emotion || b.emotion || '') : '',
text: _abJoinSegmentText(a.text || '', b.text || ''), text: audiobookJoinSegmentText(a.text || '', b.text || ''),
}; };
}; };
const _abMergeByRow = (row, dir) => { const _abMergeByRow = (row, dir) => {
@ -2908,6 +2898,7 @@ async function audiobookOpenCastView() {
const _applyDraft = (draft, view, source) => { const _applyDraft = (draft, view, source) => {
const draftDone = Number.isFinite(Number(draft.done)) ? Number(draft.done) : 0; const draftDone = Number.isFinite(Number(draft.done)) ? Number(draft.done) : 0;
const draftTotal = Number.isFinite(Number(draft.total)) ? Number(draft.total) : 0; const draftTotal = Number.isFinite(Number(draft.total)) ? Number(draft.total) : 0;
_abStampSegmentPages(draft.segments, draft.pageMarks, text); // pre-.page drafts: stamp once so the renderer stays single-path
_audiobook.segments = draft.segments; _audiobook.segments = draft.segments;
_audiobook.roster = draft.roster || []; _audiobook.roster = draft.roster || [];
_audiobook.lastText = text; _audiobook.lastText = text;

View File

@ -94,10 +94,10 @@ async function libraryRenderBooks() {
const id = el.dataset.id, title = el.dataset.title; const id = el.dataset.id, title = el.dataset.title;
el.addEventListener('click', function (e) { el.addEventListener('click', function (e) {
if (e.target.closest('.reh-book-act')) return; if (e.target.closest('.reh-book-act')) return;
// Show the reader on the PDF view immediately and suppress the stale-cast // Show the reader on the PDF view immediately — an explicit start view
// restore so a freshly-opened book never lands in an empty casting panel. // also skips nav.js's cast-session restore, so a freshly-opened book
// never lands in a stale casting panel.
window._readerStartView = 'main'; window._readerStartView = 'main';
window._readerSuppressCastRestore = true;
if (typeof navTo === 'function') navTo('s-reader'); if (typeof navTo === 'function') navTo('s-reader');
if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(id); if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(id);
}); });

View File

@ -48,32 +48,11 @@ const readerState = {
savedId: null, // server library doc id when this doc came from / was saved to the library 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) 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 sourceUploaded: false, // the source document has been uploaded to the server
pdfParsing: false, ocrWorker: null, // lazily-created Tesseract.js worker (terminated after each extraction)
pdfParsedPages: 0,
pdfParseTotal: 0,
ocrWorker: null, // lazily-created Tesseract.js worker, reused across imports
synthStarted: false, // true once any unit leaves 'pending' — gates the red "not synthesised" tint synthStarted: false, // true once any unit leaves 'pending' — gates the red "not synthesised" tint
textExtracted: false, // PDF pages are loaded/visible but getTextContent()+OCR hasn't run yet until this flips true
}; };
window.readerState = readerState; window.readerState = readerState;
// Default the "Voice & synthesis settings" card to collapsed the first time
// anyone visits — it's secondary until you actually have a document loaded,
// and was one of several boxes making the empty-state reader feel cluttered.
// Uses the same localStorage-key convention as the generic card-collapse
// mechanism (ai-backends.js, which runs later in a background batch) — only
// sets a default, never overrides a choice you've already made.
(function () {
try {
const KEY = 'card-collapse-v1';
const s = JSON.parse(localStorage.getItem(KEY) || '{}');
if (s['card-voice-synthesis-settings'] === undefined) {
s['card-voice-synthesis-settings'] = false;
localStorage.setItem(KEY, JSON.stringify(s));
}
} catch (_) {}
})();
const READER_RESUME_KEY = 'reader-resume'; const READER_RESUME_KEY = 'reader-resume';
const READER_FMT = 'mp3'; // synthesise + store as MP3 (compact; decodeAudioData plays it) 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_BUF_WINDOW = 3; // keep decoded PCM only for ±N units around the playhead
@ -197,10 +176,9 @@ function readerFinishLoadedDocument({ resume = true, successPrefix = 'Loaded' }
const resumed = resume && readerLoadResume(); const resumed = resume && readerLoadResume();
readerUpdateProgress(); readerUpdateProgress();
readerHighlightSentence(readerState.sentences[readerState.idx]); readerHighlightSentence(readerState.sentences[readerState.idx]);
const parsingNote = readerState.pdfParsing ? ' · indexing continues in background' : '';
toast(resumed toast(resumed
? 'Resumed at sentence ' + (readerState.idx + 1) + ' / ' + readerState.sentences.length ? 'Resumed at sentence ' + (readerState.idx + 1) + ' / ' + readerState.sentences.length
: successPrefix + ' · ' + readerState.sentences.length + ' sentences' + parsingNote + ' — press play', 'success'); : successPrefix + ' · ' + readerState.sentences.length + ' sentences — press play', 'success');
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source'); if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source');
return true; return true;
} }
@ -305,10 +283,6 @@ function readerResetDoc() {
readerState.savedId = null; readerState.savedId = null;
readerState.savedAudioIdx = new Set(); readerState.savedAudioIdx = new Set();
readerState.sourceUploaded = false; readerState.sourceUploaded = false;
readerState.pdfParsing = false;
readerState.pdfParsedPages = 0;
readerState.pdfParseTotal = 0;
readerState.textExtracted = false;
const extractBanner = $('reader-extract-banner'); if (extractBanner) extractBanner.hidden = true; const extractBanner = $('reader-extract-banner'); if (extractBanner) extractBanner.hidden = true;
if (readerState.io) { readerState.io.disconnect(); readerState.io = null; } if (readerState.io) { readerState.io.disconnect(); readerState.io = null; }
readerState.synthRunning = false; readerState.synthRunning = false;
@ -411,20 +385,18 @@ async function readerGetOcrWorker() {
// synthetic word entries (same shape as real getTextContent words) spread // synthetic word entries (same shape as real getTextContent words) spread
// across the band so they slot into the normal sentence/highlight pipeline. // across the band so they slot into the normal sentence/highlight pipeline.
async function readerOcrPageHeading(page, base, pageIdx, gapPx) { async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
let full, crop; let crop;
try { try {
const worker = await readerGetOcrWorker(); const worker = await readerGetOcrWorker();
const viewport = page.getViewport({ scale: READER_OCR_RENDER_SCALE }); const viewport = page.getViewport({ scale: READER_OCR_RENDER_SCALE });
full = document.createElement('canvas'); // The band is at the top of the page, so a canvas that is only cropH tall
full.width = viewport.width; // captures it directly — the 2D context clips the rest of the render, so
full.height = viewport.height; // we never allocate or rasterize the remaining ~90% of the page.
await page.render({ canvasContext: full.getContext('2d'), viewport }).promise;
const cropH = Math.max(Math.round(gapPx * READER_OCR_RENDER_SCALE), 1); const cropH = Math.max(Math.round(gapPx * READER_OCR_RENDER_SCALE), 1);
crop = document.createElement('canvas'); crop = document.createElement('canvas');
crop.width = full.width; crop.width = viewport.width;
crop.height = cropH; crop.height = cropH;
crop.getContext('2d').drawImage(full, 0, 0, full.width, cropH, 0, 0, full.width, cropH); await page.render({ canvasContext: crop.getContext('2d'), viewport }).promise;
const { data } = await worker.recognize(crop); const { data } = await worker.recognize(crop);
const text = (data.text || '').replace(/\s+/g, ' ').trim(); const text = (data.text || '').replace(/\s+/g, ' ').trim();
@ -445,7 +417,6 @@ async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
console.warn('Heading OCR failed', e); console.warn('Heading OCR failed', e);
return []; return [];
} finally { } finally {
if (full) { full.width = 0; full.height = 0; }
if (crop) { crop.width = 0; crop.height = 0; } if (crop) { crop.width = 0; crop.height = 0; }
} }
} }
@ -469,7 +440,6 @@ async function readerLoadPdfSkeleton(file) {
if (seq !== readerState._seq) return null; if (seq !== readerState._seq) return null;
readerState.mode = 'pdf'; readerState.mode = 'pdf';
readerState.pdfDoc = pdf; readerState.pdfDoc = pdf;
readerState.textExtracted = false;
const doc = $('reader-doc'); const doc = $('reader-doc');
const nPages = pdf.numPages; const nPages = pdf.numPages;
@ -553,7 +523,10 @@ async function readerExtractPdfText(loaded) {
// A tall text-free band at the top of the page likely holds an image // A tall text-free band at the top of the page likely holds an image
// heading (chapter title graphics) that getTextContent() can't see. // heading (chapter title graphics) that getTextContent() can't see.
if (ocrHeadingsEnabled) { if (ocrHeadingsEnabled) {
const gapPx = pageWords.length ? Math.min(base.height, ...pageWords.map(w => w.top)) : base.height; // 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)) { if (gapPx > Math.max(base.height * READER_OCR_MIN_GAP_RATIO, READER_OCR_MIN_GAP_PX)) {
const ocrWords = await readerOcrPageHeading(page, base, pageIdx, gapPx); const ocrWords = await readerOcrPageHeading(page, base, pageIdx, gapPx);
if (seq !== readerState._seq) return; if (seq !== readerState._seq) return;
@ -576,7 +549,12 @@ async function readerExtractPdfText(loaded) {
await new Promise(r => setTimeout(r, 0)); await new Promise(r => setTimeout(r, 0));
} }
progress.done(); progress.done();
readerState.textExtracted = true; // 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 // Convenience wrapper used by restore paths (library reopen) where extraction

View File

@ -102,11 +102,21 @@ function statusActiveStt() {
}; };
} }
function statusActiveLlm() { // Resolve the effective LLM endpoint+model (app settings, falling back to the
// rehearser's or conversation's pickers) — shared by the status chip and the
// engine health check so the resolution order can't drift between them.
function statusLlmTarget() {
let settings = {}; let settings = {};
try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {} try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {}
const url = statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || ''); return {
const model = settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || ''; url: statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || ''),
model: settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '',
apiKey: settings.llm_api_key || '',
};
}
function statusActiveLlm() {
const { url, model } = statusLlmTarget();
const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model; const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model;
const ok = !!url && same && STATUS_LLM_CACHE.ok; const ok = !!url && same && STATUS_LLM_CACHE.ok;
return { return {
@ -137,18 +147,14 @@ function updateStatusBar() {
async function refreshStatusBarEngines({ force = false } = {}) { async function refreshStatusBarEngines({ force = false } = {}) {
updateStatusBar(); updateStatusBar();
let settings = {}; const { url, model, apiKey } = statusLlmTarget();
try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {}
const url = statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || '');
const model = settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '';
if (!url || STATUS_LLM_CACHE.pending) return; if (!url || STATUS_LLM_CACHE.pending) return;
const fresh = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model && (Date.now() - STATUS_LLM_CACHE.checked) < 30000; const fresh = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model && (Date.now() - STATUS_LLM_CACHE.checked) < 30000;
if (fresh && !force) return; if (fresh && !force) return;
STATUS_LLM_CACHE.pending = true; STATUS_LLM_CACHE.pending = true;
try { try {
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url); let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
const key = settings.llm_api_key || ''; if (apiKey) fetchUrl += '&api_key=' + encodeURIComponent(apiKey);
if (key) fetchUrl += '&api_key=' + encodeURIComponent(key);
const data = await fetch(fetchUrl).then(r => r.json()); const data = await fetch(fetchUrl).then(r => r.json());
const models = Array.isArray(data.models) ? data.models : []; const models = Array.isArray(data.models) ? data.models : [];
STATUS_LLM_CACHE.url = url; STATUS_LLM_CACHE.url = url;
@ -704,11 +710,8 @@ const WF_STEPS = [
function workflowCrumbGo(key) { function workflowCrumbGo(key) {
if (key === 'source') { if (key === 'source') {
// navTo('s-reader') auto-restores an active casting panel a tick later // navTo's cast-session restore is synchronous, so the explicit view choice
// (nav.js's showSection) — without suppressing that, it silently undoes // below simply wins by running after it.
// the showReaderView('main') below and clicking "Source" looked like
// it did nothing.
window._readerSuppressCastRestore = true;
if (typeof navTo === 'function') navTo('s-reader'); if (typeof navTo === 'function') navTo('s-reader');
if (typeof showReaderView === 'function') showReaderView('main'); if (typeof showReaderView === 'function') showReaderView('main');
} else if (key === 'cast') { } else if (key === 'cast') {
@ -735,7 +738,9 @@ function refreshWorkflowCrumbs(active) {
const title = window.readerState?.title || window.rehState?.title || ''; const title = window.readerState?.title || window.rehState?.title || '';
const containers = document.querySelectorAll('.wf-stepper'); const containers = document.querySelectorAll('.wf-stepper');
if (!containers.length) return; if (!containers.length) return;
const anyState = WF_STEPS.some(st => st.key !== 'pdf' && st.enabled()); // Hide the strip until some stage beyond the always-available "Source" is
// reachable — otherwise a fresh session shows a stepper with one live stop.
const anyState = WF_STEPS.some(st => st.key !== 'source' && st.enabled());
containers.forEach(el => { containers.forEach(el => {
if (!anyState) { el.innerHTML = ''; el.hidden = true; return; } if (!anyState) { el.innerHTML = ''; el.hidden = true; return; }
el.hidden = false; el.hidden = false;

View File

@ -145,20 +145,20 @@
if (!SECTIONS.includes(sectionId)) sectionId = 's-voices'; if (!SECTIONS.includes(sectionId)) sectionId = 's-voices';
// Leaving the Read Aloud reader: stop playback so audio doesn't keep running // Leaving the Read Aloud reader: stop playback so audio doesn't keep running
if (sectionId !== 's-reader' && typeof window.readerStop === 'function') window.readerStop(); if (sectionId !== 's-reader' && typeof window.readerStop === 'function') window.readerStop();
// Capture before readerOnShow (async) eventually consumes it: a caller that
// requested a specific reader view via _readerStartView must not have that
// choice overridden by the cast-session restore below.
var _explicitReaderView = window._readerStartView;
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow(); if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
// If a cast session panel is active, restore it instead of showing the main PDF // If a cast session panel is active, restore it instead of showing the main
// view — UNLESS we're opening a fresh book (which sets _readerSuppressCastRestore), // PDF view. Synchronous on purpose: callers that want a different view just
// in which case a leftover panel must not hijack the reader into an empty cast view. // call showReaderView(...) after navTo() and their choice wins — no deferred
if (sectionId === 's-reader') { // override racing whatever the caller set (the old setTimeout version hijacked
if (window._readerSuppressCastRestore) { // the view a tick later and needed a global suppress flag to defeat).
window._readerSuppressCastRestore = false; if (sectionId === 's-reader' && !_explicitReaderView) {
} else { var _castPnl = document.getElementById('reader-audiobook-panel');
var _castPnl = document.getElementById('reader-audiobook-panel'); if (_castPnl && _castPnl.querySelector('#ab-cv-feed') && typeof window.showReaderView === 'function') {
if (_castPnl && _castPnl.querySelector('#ab-cv-feed')) { window.showReaderView('cast');
setTimeout(function () {
if (typeof window.showReaderView === 'function') window.showReaderView('cast');
}, 0);
}
} }
} }
if (sectionId === 's-library' && typeof window.libraryRender === 'function') window.libraryRender(window._libraryView || 'books'); if (sectionId === 's-library' && typeof window.libraryRender === 'function') window.libraryRender(window._libraryView || 'books');

View File

@ -12,7 +12,7 @@
<div id="reader-main-view"> <div id="reader-main-view">
<!-- ① Import + controls (collapsible) ───────────────────────────── --> <!-- ① Import + controls (collapsible) ───────────────────────────── -->
<div class="card reader-config-card" id="reader-config-card" style="padding:14px"> <div class="card reader-config-card" id="reader-config-card" data-collapse-default="closed" style="padding:14px">
<h2><span class="mdi mdi-tune-variant"></span> Voice &amp; synthesis settings</h2> <h2><span class="mdi mdi-tune-variant"></span> Voice &amp; synthesis settings</h2>
<div class="engine-setup-row" style="margin-bottom:8px"> <div class="engine-setup-row" style="margin-bottom:8px">
<div class="engine-setup-col" style="flex:1"> <div class="engine-setup-col" style="flex:1">

View File

@ -1,3 +1,11 @@
/* [hidden] must always hide
Any component that sets `display:flex/grid/block` on its base selector beats
the UA's `[hidden]{display:none}` at equal specificity, making the element
render as an empty box while "hidden". This bit .ab-char-bar, .wf-stepper,
.reader-extract-banner, .ab-castpanel-inline and ~20 others individually
one root rule ends the whole bug class. */
[hidden] { display: none !important; }
/* ── MDI icon integration ────────────────────────────────────────────────── */ /* ── MDI icon integration ────────────────────────────────────────────────── */
.mdi { .mdi {
vertical-align: -0.125em; vertical-align: -0.125em;
@ -1615,7 +1623,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
direction; state lives wherever it was created (readerState/_audiobook/ direction; state lives wherever it was created (readerState/_audiobook/
rehState), so jumping around never destroys it. */ rehState), so jumping around never destroys it. */
.wf-stepper { display: flex; align-items: flex-start; gap: 0; padding: 16px 20px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; overflow-x: auto; } .wf-stepper { display: flex; align-items: flex-start; gap: 0; padding: 16px 20px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; overflow-x: auto; }
.wf-stepper[hidden] { display: none; }
.wf-stepper-title { align-self: center; font-weight: 700; color: var(--text); font-size: 14px; white-space: nowrap; margin-right: 18px; padding-right: 18px; border-right: 1px solid var(--border); flex-shrink: 0; max-width: 240px; overflow: hidden; text-overflow: ellipsis; } .wf-stepper-title { align-self: center; font-weight: 700; color: var(--text); font-size: 14px; white-space: nowrap; margin-right: 18px; padding-right: 18px; border-right: 1px solid var(--border); flex-shrink: 0; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
.wf-step { .wf-step {
display: flex; flex-direction: column; align-items: center; gap: 5px; display: flex; flex-direction: column; align-items: center; gap: 5px;
@ -4899,7 +4906,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
display: flex; align-items: center; gap: 14px; margin: 12px 0; padding: 14px 16px; display: flex; align-items: center; gap: 14px; margin: 12px 0; padding: 14px 16px;
border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel);
} }
.reader-extract-banner[hidden] { display: none; }
.reader-extract-icon { font-size: 30px; color: var(--accent); flex-shrink: 0; } .reader-extract-icon { font-size: 30px; color: var(--accent); flex-shrink: 0; }
.reader-extract-info { flex: 1; min-width: 160px; } .reader-extract-info { flex: 1; min-width: 160px; }
.reader-extract-info strong { display: block; color: var(--text); font-size: 14px; } .reader-extract-info strong { display: block; color: var(--text); font-size: 14px; }
@ -5037,7 +5043,6 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
min-height: 420px; min-height: 420px;
overflow: hidden; overflow: hidden;
} }
.ab-castpanel-inline[hidden] { display: none !important; }
.ab-cv-body { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 12px; padding: 12px; flex: 1; min-height: 0; min-width: 0; transition: grid-template-columns .15s; } .ab-cv-body { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 12px; padding: 12px; flex: 1; min-height: 0; min-width: 0; transition: grid-template-columns .15s; }
.ab-cv-body.side-collapsed { grid-template-columns: minmax(0, 1fr) 44px; } .ab-cv-body.side-collapsed { grid-template-columns: minmax(0, 1fr) 44px; }