diff --git a/CHANGELOG.md b/CHANGELOG.md
index aff7e1a..4031892 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,16 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
instructions but had never been created. All three now exist; the architecture SOP records the decisions that are easy
to get wrong and the limitations that remain, including that OCR-engine selection is not yet dispatched server-side.
+- **Magazine Reader UI.** The Studio sidebar gains a Magazines section with the full P1 flow: a library of magazine
+ projects, a contents tree grouped by section with per-article confidence, page range, listening estimate and audio
+ status, the Stage D review screen (tick/untick layout blocks, correct title and author, approve or reject), a reflowed
+ article view with click-to-seek sentence highlighting, a linked original-page view that shows the bounding box the
+ current sentence came from, and a podcast panel (2-3 host voice pickers, script generation, turn-by-turn playback with
+ highlighting). Verified with Playwright against the real backend contract: library listing, review pre-selection,
+ approval, sentence highlighting and PDF-page linking, and the podcast gate on unapproved articles, plus a full
+ navigation pass with zero console errors. Reuses the existing TTS preview endpoint for synthesis, so no new audio
+ pipeline was needed.
+
### Changed
- **Server-Side Layout Analysis For Magazines.** PDF text extraction for plain books stays in the browser, but magazine
layout analysis runs server-side: PyMuPDF exposes real font metrics and embedded-image rectangles that PDF.js does not,
diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md
index 6deba93..621b75d 100644
--- a/PROJECT_MAP.md
+++ b/PROJECT_MAP.md
@@ -5,7 +5,7 @@ Update in the same commit as the change it describes.
## Current phase
-Magazine projects, **P0 (backend) complete**. Audiobook/Rehearser/voice-library
+Magazine projects, **P0 complete, P1 mostly complete** (frontend reachable at Studio → Magazines). Audiobook/Rehearser/voice-library
features are mature and in daily use.
## Deployment
@@ -38,7 +38,7 @@ recreate; rebuild and push only when `Dockerfile` or `requirements.txt` change.
| Studio (phase wizard) | — | `studio.js` | mature |
| TTS engines / routing | `routes/tts.py`, `core/routing.py` | `engines.js` | mature |
| STT | `routes/stt.py` | — | mature |
-| **Magazines** | `core/magazine.py`, `routes/magazine.py` | *not started* | **P0 backend** |
+| **Magazines** | `core/magazine.py`, `routes/magazine.py` | `magazine.js`, `s-magazine.html` | **P0 + P1** |
## Data model
@@ -81,5 +81,6 @@ which is the writable volume.
## Next logical step
-Magazine P1: magazine overview with the contents tree, the article review
-screen, reflowed + PDF views, and sentence-highlighted playback.
+Magazine P1 remainder: article images/page crops shown during playback, a
+cross-article "play all" queue, and OCR-engine dispatch to the KDL/Baidu
+parsers (see architecture/magazine-ingestion.md). Otherwise P2 per TODO.md.
diff --git a/TODO.md b/TODO.md
index a574c55..080ec40 100644
--- a/TODO.md
+++ b/TODO.md
@@ -290,23 +290,27 @@ casting. The original PDF remains the visual source of truth while approved arti
### P0 — Magazine foundation
-- [~] Add a Magazine project type beside Books and Theater Plays. *(backend + API done; Studio UI not started)*
+- [x] Add a Magazine project type beside Books and Theater Plays.
- [~] Add PDF/JPG/PNG import with page previews and safe local storage. *(PDF + page renders done; JPG/PNG not done)*
- [x] Add OCR-engine selection from Engines > OCR & Document Parsing, with page-image input and structured output.
- [x] Detect contents/index pages and propose article titles, authors, and page ranges.
-- [~] Build a review screen where users approve or correct article boundaries before TTS. *(approve/edit API done and gating narration; screen not built)*
+- [x] Build a review screen where users approve or correct article boundaries before TTS.
- [x] Preserve source coordinates for every extracted block, image, caption, and page reference.
-- [x] Generate per-article podcast scripts from a connected LLM with two or three user-picked host voices.
- *(script generation + review gating done; multi-voice synthesis reuses the existing casting path, UI not built)*
+- [x] Generate per-article podcast scripts from a connected LLM with two or three user-picked host voices,
+ with a Studio panel to pick hosts, generate, and play the script back turn by turn.
### P1 — Magazine listening experience
-- [ ] Generate clean article HTML with headings, captions, figures, sidebars, and page links.
-- [ ] Generate narrator audio per article with resume/progress and a play-all queue.
-- [ ] Synchronize paragraph/sentence highlighting with playback and keep PDF and article views linked.
+- [~] Generate clean article HTML with headings, captions, figures, sidebars, and page links.
+ *(headings/byline/captions styled and page-linked; figures and sidebars are not rendered inline yet)*
+- [~] Generate narrator audio per article with resume/progress and a play-all queue.
+ *(on-demand narration + per-article resume done; no cross-article play-all queue yet)*
+- [x] Synchronize paragraph/sentence highlighting with playback and keep PDF and article views linked.
+ *(sentence-level; clicking a sentence or switching to the original-page view jumps to its source bbox)*
- [ ] Show article images or page crops during playback, with optional caption reading.
-- [ ] Add confidence warnings, extraction diffs, and manual text correction before audio generation.
+- [~] Add confidence warnings, extraction diffs, and manual text correction before audio generation.
+ *(confidence badges + block-level boundary editing done; no extraction diff view or free-text correction)*
### P2 — Advanced magazine intelligence
diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md
index e994206..81e7db3 100644
--- a/architecture/magazine-ingestion.md
+++ b/architecture/magazine-ingestion.md
@@ -1,6 +1,8 @@
# SOP — Magazine ingestion
-Status: **P0 complete (backend)**. P1 (listening workflow UI) not started.
+Status: **P0 complete. P1 mostly complete** — Studio -> Magazines has the full
+library/contents/review/read/podcast flow. Remaining P1: images shown during
+playback and a cross-article play-all queue.
## Why magazines are not books
@@ -122,6 +124,32 @@ than dropping the turn — an unrecognised label would otherwise leave a line wi
no voice. 2–3 hosts, each bound to a voice the user picks; synthesis reuses the
multi-speaker path the audiobook casting already uses.
+## Frontend
+
+`static/js/magazine.js` + `static/sections/s-magazine.html`, wired into the same
+lazy-module system as the other sections (`static/module-manifest.json`,
+`static/loader.js`, `static/nav.js`). Two independent section lists gate
+visibility (`loader.js` fetches the HTML, `nav.js` decides `navTo` will reveal
+it) — both needed updating; missing either one leaves the section perpetually
+hidden even though its HTML loaded correctly.
+
+State machine: `library -> contents -> review -> read`, plus a `pane` toggle
+(`reflow` | `pdf`) within `read`. Sentences are split from narratable blocks
+client-side, each tagged with its source `blockId`/`page`/`bbox`, so clicking a
+sentence or switching to the PDF pane draws the highlight rectangle from the
+same coordinate the layout pipeline recorded — no separate sync data.
+
+Reuses `/api/tts-preview` for both narration and podcast turns; no new audio
+pipeline. `magazine_articles` marks each article `has_podcast` so the read view
+skips a doomed status probe when nothing was generated, rather than a routine
+404 on every open.
+
+The pane toggle is a button *group* with `aria-pressed`, not `role="tab"` —
+this app's tab pattern would additionally require `aria-selected`, roving
+`tabindex` and arrow-key navigation, none of which plain click switching
+provides. `role="tab"` without that whole pattern fails WCAG 2.2 AA guidance
+and, in practice, hides the buttons from `getByRole('tab')`-oriented tooling.
+
## Tests
- [`tests/test_magazine.py`](../tests/test_magazine.py) — 34 tests on the pure
@@ -131,6 +159,10 @@ multi-speaker path the audiobook casting already uses.
- [`tests/test_magazine_routes.py`](../tests/test_magazine_routes.py) — 10 tests: path
traversal rejection, review gating, LLM script parsing including malformed
output.
+- [`tests/magazine.spec.js`](../tests/magazine.spec.js) — Playwright, backend mocked to the
+ real response contract: library listing, review pre-selection, approval,
+ sentence highlighting + PDF-page linking, the podcast approval gate, and a
+ full-flow console-error check.
Both run without PyMuPDF: the I/O functions import it lazily so the logic worth
testing needs no native dependency or sample PDF.
diff --git a/core/magazine.py b/core/magazine.py
index b4bf0c0..035a639 100644
--- a/core/magazine.py
+++ b/core/magazine.py
@@ -236,6 +236,18 @@ def find_continuation(text: str) -> tuple[int | None, int | None]:
return to_page, from_page
+def strip_continuation_markers(text: str) -> str:
+ """Remove '(continued on page N)' style cues before text is narrated.
+
+ These are a print layout convention -- meaningless, and slightly absurd,
+ spoken aloud ("open paren continued on page eight close paren"). The
+ layout store keeps the original text; only the narration copy is cleaned.
+ """
+ cleaned = _CONT_TO.sub("", text or "")
+ cleaned = _CONT_FROM.sub("", cleaned)
+ return re.sub(r"\s+", " ", cleaned).strip()
+
+
def classify_blocks(blocks: list[Block], page_height: float, page_width: float,
repeated: set[str] | None = None,
image_boxes: Sequence[Sequence[float]] = (),
@@ -833,7 +845,8 @@ def article_text(layout: dict[str, Any], article: dict[str, Any],
wanted = set(article.get("blocks") or [])
types = set(NARRATABLE) | ({BLOCK_CAPTION} if include_captions else set())
blocks = [
- b for b in layout.get("blocks", [])
+ dict(b, text=strip_continuation_markers(b["text"]))
+ for b in layout.get("blocks", [])
if b["id"] in wanted and b["type"] in types
]
blocks.sort(key=lambda b: (b["page"], b["reading_order"]))
diff --git a/routes/magazine.py b/routes/magazine.py
index b2d3748..5ddeb2c 100644
--- a/routes/magazine.py
+++ b/routes/magazine.py
@@ -209,8 +209,13 @@ async def magazine_layout(doc_id: str, page: int | None = None):
@router.get("/api/magazine/{doc_id}/articles")
async def magazine_articles(doc_id: str):
d, meta = _require_magazine(doc_id)
+ articles = mag.load_articles(d)
+ # Lets the frontend skip the per-article podcast GET when nothing was ever
+ # generated, instead of a 404 on every article open.
+ for a in articles:
+ a["has_podcast"] = (d / "podcast" / f"{a['id']}.json").is_file()
return {
- "articles": mag.load_articles(d),
+ "articles": articles,
"progress": meta.get("progress") or {},
"default_voice": meta.get("default_voice") or "",
"podcast": meta.get("podcast") or {"hosts": []},
diff --git a/static/index.html b/static/index.html
index d57ac41..beda017 100644
--- a/static/index.html
+++ b/static/index.html
@@ -182,6 +182,10 @@
Reader
Casting
+
+
+ Magazines
+
Script Rehearsal
@@ -330,6 +334,7 @@
+
diff --git a/static/js/magazine.js b/static/js/magazine.js
new file mode 100644
index 0000000..43ec55a
--- /dev/null
+++ b/static/js/magazine.js
@@ -0,0 +1,748 @@
+// Magazine reader — contents tree, article review, dual reading views,
+// sentence-highlighted playback and podcast mode.
+//
+// The canonical object is the ARTICLE, not the page (see
+// architecture/magazine-ingestion.md). Nothing is narrated until a human has
+// approved the detected boundaries, so the review screen is on the critical
+// path rather than an optional extra.
+//
+// Two views stay linked: the reflowed article (comfortable reading, sentence
+// highlighting) and the original PDF page (the visual source of truth). Every
+// block carries page + bbox, so either view can jump to the other.
+(function (root) {
+'use strict';
+
+const MAG = root.TTVC = root.TTVC || {};
+const state = {
+ docs: [],
+ doc: null, // { id, title, issue, ingest, ... }
+ articles: [],
+ layout: null, // { page_count, pages[], blocks[] }
+ article: null, // the article being read or reviewed
+ sentences: [], // [{ text, blockId, page, bbox }]
+ view: 'library', // library | contents | review | read
+ pane: 'reflow', // reflow | pdf
+ playing: false,
+ cancel: false,
+ idx: 0, // current sentence
+ audio: null,
+ voices: [],
+ podcast: null,
+};
+MAG.magazine = state;
+
+const $ = (id) => document.getElementById(id);
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+function say(msg, kind) { if (typeof toast === 'function') toast(msg, kind || ''); }
+
+async function api(path, opts) {
+ const r = await fetch(path, opts);
+ if (!r.ok) {
+ let detail = r.statusText;
+ try { detail = (await r.json()).detail || detail; } catch (_) {}
+ throw new Error(detail);
+ }
+ const type = r.headers.get('content-type') || '';
+ return type.includes('json') ? r.json() : r;
+}
+
+function fmtDuration(seconds) {
+ const s = Math.max(0, Math.round(seconds || 0));
+ if (s < 60) return s + ' sec';
+ const m = Math.round(s / 60);
+ return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h ' + (m % 60) + ' min';
+}
+
+function fmtPages(pages) {
+ if (!pages || !pages.length) return '—';
+ if (pages.length === 1) return 'p. ' + pages[0];
+ // Non-contiguous page lists are the norm once a feature jumps, and showing
+ // "18–88" for pages [18, 88] would misdescribe it as a 70-page article.
+ const runs = [];
+ let start = pages[0], prev = pages[0];
+ for (let i = 1; i <= pages.length; i++) {
+ if (pages[i] === prev + 1) { prev = pages[i]; continue; }
+ runs.push(start === prev ? String(start) : start + '–' + prev);
+ start = prev = pages[i];
+ }
+ return 'pp. ' + runs.join(', ');
+}
+
+// ── Library ──────────────────────────────────────────────────────────────────
+
+async function magLoadLibrary() {
+ const data = await api('/api/reader/docs');
+ state.docs = (data.docs || []).filter((d) => d.kind === 'magazine');
+ renderLibrary();
+}
+
+function renderLibrary() {
+ const host = $('mag-library-list');
+ if (!host) return;
+ if (!state.docs.length) {
+ host.innerHTML =
+ '
No magazines yet. Add one to get started — ' +
+ 'upload a PDF and the articles are detected for you.
Listen to a magazine one article at a time — from its true beginning to its true end, without adverts, sidebars or a neighbouring piece spliced into the middle.
+
+
+
+
+
+
+
Add a magazine or paper
+
+
+
+
+
+
+
Scanned issues work too — pages without selectable text are read with OCR.
+
+
+
Your magazines
+
+
+
+
+
+
+
+
Magazine
+
+
+
+
+
+
+
+
+
+
+
+
+
Review article boundaries
+
+
+
+
+
+
+
+
+
+
Article
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Podcast mode — discuss this article
+
Two or three hosts discuss the article for someone who has not read it. The script is written by the configured LLM from the article text; it will not invent facts, but it is a generated discussion, not the author’s words.