diff --git a/CHANGELOG.md b/CHANGELOG.md index a1bb505..95cf775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.9.2] — 2026-06-26 + +### Fixed +- **Audiobook casting — pagination preserved on save**: saving (or opening) a cast audiobook as a Script Rehearsal now keeps the book's **page breaks**. Read-aloud page boundaries are tracked through casting and re-emitted as `\f` markers at the nearest segment boundary, so the Rehearser paginates the saved script to match the source PDF instead of producing one continuous flow. + +--- + ## [1.9.1] — 2026-06-26 ### Fixed diff --git a/VERSION b/VERSION index 9ab8337..8fdcf38 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.1 +1.9.2 diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 7754f1a..d7e4cf8 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -97,10 +97,33 @@ function audiobookLlmModel() { return $('reh-llm-model')?.value || ''; } function audiobookLang() { return $('reh-design-lang')?.value || ''; } // Gather the plain text of the current reader scope (selection > page range > all). +// Also records PDF page boundaries as character offsets in the returned text +// (_audiobook.pageMarks) so saving to the Rehearser can reconstruct page breaks. function audiobookScopeText() { + _audiobook.pageMarks = []; if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return ''; - const raw = readerScopeIndices().map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim(); - return audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching + const idxs = readerScopeIndices(); + const anchors = []; // {page, anchor} — first sentence of each new page + let lastPage = null; + for (const i of idxs) { + const u = readerState.sentences[i]; + const pg = u?.words?.[0]?.page; + if (pg != null && pg !== lastPage) { + anchors.push({ page: pg, anchor: (u.text || '').trim().slice(0, 40) }); + lastPage = pg; + } + } + const raw = idxs.map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim(); + const text = audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching + // Locate each page anchor in the final text → page-break offsets. + let from = 0; + for (const a of anchors) { + const probe = audiobookDehyphenate(a.anchor).slice(0, 24); + if (!probe) continue; + const pos = text.indexOf(probe, from); + if (pos >= 0) { _audiobook.pageMarks.push({ offset: pos, page: a.page }); from = pos; } + } + return text; } // ── Progress overlay ───────────────────────────────────────────────────────── @@ -1246,11 +1269,32 @@ function audiobookApplyPreviewAndOpen() { // Build a rehearser script (CAPS speaker + line; narration as plain paragraphs) // and a parallel list of per-dialogue-line emotions (same order as dialog lines). +// Inserts \f page-break markers at the segment boundary nearest each source PDF +// page start (so saved/opened scripts keep the book's pagination). Page positions +// are realigned per-segment against the source text to avoid cumulative drift. function audiobookBuildScript(segments) { let script = ''; const emotions = []; + const marks = _audiobook.pageMarks || []; + const src = _audiobook.lastText || ''; + let markIdx = 0, searchPos = 0; + // The first page mark sits at the start of the document — no leading break. + if (marks.length && marks[0].offset <= 2) markIdx = 1; + for (const s of segments) { const t = (s.text || '').trim(); if (!t) continue; + // Where does this segment sit in the source text? (verbatim narration matches + // exactly; dialogue text — quotes stripped — still occurs in the source.) + if (src && markIdx < marks.length) { + const probe = t.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) { + if (script.trim()) script += '\n\f\n'; // page break before this segment + markIdx++; + } + } const isDialogue = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator'; if (isDialogue) { script += '\n' + s.speaker.toUpperCase() + '\n' + t + '\n';