Release v1.53.2
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

This commit is contained in:
mARTin-B78 2026-09-24 07:44:16 +02:00
parent ba3b374246
commit b1c0197652
20 changed files with 1312 additions and 113 deletions

View File

@ -9,6 +9,26 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.53.2] — 2026-09-24
### Added
- **Magazine: exported MP3s can use a second voice for printed interviews.** When exactly two recurring speaker labels are detected, the narrator and optional interview voice alternate turns without reading the labels aloud; ordinary prose containing colons is left alone.
- **Magazine: generated-audio playlists now include a synchronized reading companion.** The playlist shows page, duration and played/partly-played/unplayed status, follows the current sentence and word using estimated timing, displays the article's figures alongside the transcript, remembers playback progress, and offers previous/play/next controls plus a progress display in driving mode.
- **Magazine: title and issue metadata can be edited after import.** Exported filenames use a parsed issue year/month when available, and a new “Regenerate all MP3s” action rebuilds approved audio after metadata, text, pronunciation or voice changes.
- **Magazine review: a page-level checkbox selects or clears every text and image region**, including an indeterminate state for partially selected pages.
### Changed
- **Magazine narration now performs conservative OCR and pronunciation cleanup without changing displayed source text.** Printed line-break hyphens are removed, known German/English abbreviations are expanded for speech, and dictionary-backed missing German umlauts are restored only when unambiguous.
- **Magazine page cards now show every article sharing the page with direct Review and Play actions**, and the article list is once again the default contents view.
- **Voice searches now include display names and tags** in both the voice library and shared voice pickers.
### Fixed
- **Magazine: paragraphs flowing from one column into the next no longer create a giant union box over both columns.** The semantic stage keeps one physical box per column while still merging fragments within each individual column.
- **Magazine: oversized drop caps no longer make vision OCR omit the first line of a paragraph.** Suspicious lowercase starts receive a guarded local-OCR second opinion that is accepted only when it substantially matches the existing transcription.
- **Magazine: internal headings on continuation pages no longer split a multi-page article into separate playlist items.**
---
## [1.53.0] — 2026-09-23
### Added

21
TODO.md
View File

@ -39,6 +39,27 @@ User-set priority for picking up feature work, in this order:
Deployment and the deferred multi-user initiative below are explicitly parked,
not part of this order.
### Magazine MP3 quality pass — set 2026-09-23
Work these in order; each item needs regression coverage against German and
English magazine text before it is marked complete.
1. [x] **Remove printed line-break hyphens before narration.** Fuse words split
only because they crossed a printed line, while preserving real compounds
such as `KI-Modell`.
2. [x] **Expand German and English acronyms for TTS.** Keep a maintainable
pronunciation database and expand whole tokens such as `KI`, `AI`, `LLM`
and `API` in spoken text without changing the displayed article.
3. [x] **Conservatively restore OCR-missed German umlauts.** Use a spellchecker
only where an umlaut variant is known and unambiguous; never rewrite names
or unknown technical vocabulary on a guess.
4. [x] **Keep multi-page articles as one playlist item.** Prefer contents-page
anchors and explicit continuation markers, and prevent running heads or
internal headings from starting a new article.
5. [x] **Support two-voice printed interviews.** Detect repeated speaker labels,
use the selected narrator and optional second interview voice, and render
the turns as one article.
### P0 — do first
- [x] **S — Stop provider API keys from reaching frontend JavaScript or logs.** Provider keys are masked in settings

View File

@ -1 +1 @@
1.53.0
1.53.2

View File

@ -50,6 +50,13 @@ PDF
→ human review Stage D, in the UI — nothing is narrated until approved
```
The semantic grouping stage keeps a logical paragraph split into separate
physical regions when it flows from one text column into the next. Joining the
text is semantically correct, but joining the rectangles creates a large union
box over unrelated copy between the two pieces (seen repeatedly on pages 111
and 114 of the measured c't issue). Fragments within one column are still
merged; only a genuine column transition remains as two boxes.
### Why server-side, not in the browser
Text extraction for plain PDFs runs client-side (PDF.js), and that stays. Layout

View File

@ -38,6 +38,7 @@ import unicodedata
import requests
from core.retry import request_with_retries
from core.pronunciations import normalize_ocr_text, prepare_text_for_tts
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any, Iterable, Sequence
@ -731,8 +732,22 @@ def group_articles(blocks: Sequence[Block],
if not any(b.type in NARRATABLE and b.type != BLOCK_TITLE
for b in by_page[page]):
continue
titles = [b for b in by_page[page] if b.type == BLOCK_TITLE
page_blocks = by_page[page]
titles = [b for b in page_blocks if b.type == BLOCK_TITLE
and looks_like_real_title(b.text)]
# A large subheading, chart title or pull-out on a continuation page
# is frequently classified as a title. It is not a new article when
# body copy already precedes it in reading order. Genuine page-level
# article leads either come first, or have a nearby byline/deck.
if titles:
title = titles[0]
before = [b for b in page_blocks
if b.reading_order < title.reading_order and b.type == BLOCK_BODY]
following = [b for b in page_blocks
if title.reading_order < b.reading_order <= title.reading_order + 3]
has_lead_context = any(b.type in (BLOCK_BYLINE, BLOCK_DECK) for b in following)
if before and not has_lead_context:
titles = []
if titles:
starts[page] = {
"title": _normalise(titles[0].text),
@ -1262,6 +1277,102 @@ def vision_ocr_page_chain(png_path: Path,
return None
_LOWERCASE_REGION_START = re.compile(r"^[a-zäöüß]")
_DETACHED_DROP_CAP = re.compile(r"^([A-ZÄÖÜ])\s+([a-zäöüß]{3,})\b")
def prefer_drop_cap_repair(original: str, candidate: str) -> str:
"""Use a local OCR reading when it demonstrably restores a missing first line.
Layout OCR models commonly treat an oversized initial as a separate visual
object, then begin the paragraph transcription on line two. The tell is a
body region beginning with a lower-case word fragment (``tert, dass`` in
``eingetrichtert, dass``). A crop read by a second OCR engine is accepted
only when most of the existing transcription still appears in it and it
adds an upper-case beginning; this prevents an ordinary lower-case page
continuation from being replaced by an unrelated, poorer reading.
"""
old = " ".join((original or "").split())
# Tesseract often emits the ordinary first-line letters *before* the
# tall initial because their tops are higher: ``ahrelang …\nJ tert …``.
# Put that isolated capital back at the front before comparing readings.
lines = [" ".join(line.split()) for line in (candidate or "").splitlines() if line.strip()]
if len(lines) >= 2 and _LOWERCASE_REGION_START.match(lines[0]):
cap_line = re.match(r"^([A-ZÄÖÜ])\s+(.+)$", lines[1])
if cap_line:
lines = [cap_line.group(1) + lines[0], cap_line.group(2), *lines[2:]]
new = " ".join(lines).lstrip("|¦[]{}() ")
new = _DETACHED_DROP_CAP.sub(r"\1\2", new, count=1)
if not old or not new or not _LOWERCASE_REGION_START.match(old) or not new[:1].isupper():
return old
if len(new) < max(40, int(len(old) * 0.85)) or ocr_quality_score(new) < 0.55:
return old
matcher = difflib.SequenceMatcher(None, old.lower(), new.lower(), autojunk=False)
matches = matcher.get_matching_blocks()
coverage = sum(m.size for m in matches) / max(1, len(old))
first_substantial = next((m for m in matches if m.size >= 8 and m.a <= 20), None)
if coverage < 0.65 or first_substantial is None or not (1 <= first_substantial.b <= 250):
return old
# Keep the trusted existing body verbatim and take only the missing
# prefix from the local reading. Besides avoiding a wholesale downgrade,
# this also prevents a crop touching the next paragraph by a few pixels
# from appending that paragraph a second time.
prefix = new[:first_substantial.b].rstrip()
if prefix.endswith("-"):
prefix = prefix[:-1] # printed line-break hyphen: eingetrich- + tert
return prefix + old
joins_word = prefix[-1:].isalnum() and old[:1].isalnum()
return prefix + ("" if joins_word or old[:1] in ",.;:!?" else " ") + old
def repair_drop_cap_omissions(png_path: Path, regions: list[dict[str, Any]],
lang: str = "deu+eng") -> int:
"""Re-read only suspicious paragraph crops with Tesseract.
This is intentionally a narrow second opinion, not a full second OCR pass:
normal regions cost nothing, and a replacement is made only through
:func:`prefer_drop_cap_repair`'s overlap guard.
"""
suspicious = [r for r in regions
if r.get("label") in {"text", "paragraph", "body"}
and len((r.get("text") or "").strip()) >= 80
and _LOWERCASE_REGION_START.match((r.get("text") or "").strip())]
if not suspicious:
return 0
try:
from PIL import Image
image = Image.open(png_path)
except Exception:
return 0
repaired = 0
try:
for region in suspicious:
x, y, w, h = (float(v) for v in region["bbox"])
pad = 6
crop = image.crop((max(0, int(x) - pad), max(0, int(y) - pad),
min(image.width, int(math.ceil(x + w)) + pad),
min(image.height, int(math.ceil(y + h)) + pad)))
with tempfile.NamedTemporaryFile(prefix="drop-cap-", suffix=".png", delete=False) as tmp:
crop_path = Path(tmp.name)
try:
crop.save(crop_path)
proc = subprocess.run(
["tesseract", str(crop_path), "stdout", "-l", lang, "--psm", "6"],
capture_output=True, text=True, timeout=60, check=True,
)
replacement = prefer_drop_cap_repair(region["text"], proc.stdout)
if replacement != " ".join(region["text"].split()):
region["text"] = replacement
repaired += 1
except (subprocess.SubprocessError, FileNotFoundError, OSError):
continue
finally:
crop_path.unlink(missing_ok=True)
finally:
image.close()
return repaired
# One line per visual type -- a chart needs "what trend", a table needs
# "what's listed", not the generic "what's shown" that suits a photo. Every
# variant still asks for one or two plain sentences: this is read aloud
@ -2102,6 +2213,7 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
regions = vision_ocr_page_chain(png, vision_engines,
timeout=vision_ocr_timeout)
if regions:
repair_drop_cap_omissions(png, regions, ocr_lang)
scale_x = rect.width / max(1.0, _png_width(png, fitz))
vision_blocks = build_vision_blocks(
regions, page_no, scale_x, id_prefix="b")
@ -2369,6 +2481,7 @@ def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int,
regions = vision_ocr_page_chain(png, engines, timeout=timeout)
if not regions:
return None
repair_drop_cap_omissions(png, regions)
scale_x = page_meta["width"] / max(1.0, _png_width(png, fitz))
new_blocks = build_vision_blocks(regions, page_no, scale_x, id_prefix="r")
@ -2431,11 +2544,81 @@ def region_looks_like_a_picture(bbox: Sequence[float], page_ink) -> bool:
def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: dict | None = None,
page_ink=None) -> list[dict]:
"""Group existing OCR fragments without allowing invented IDs or text."""
"""Group existing OCR fragments without allowing invented IDs or text.
A paragraph may continue from the bottom of one column at the top of the
next. The semantic model correctly recognises that as one logical
paragraph, but collapsing both OCR fragments into one rectangular bbox
paints the union over all of the unrelated text between them. Keep one
output region per physical column in that case; narration can still read
the adjacent regions continuously.
"""
known = {b["id"]: b for b in blocks}
used = set()
out = []
allowed = set(NARRATABLE) | set(VISUAL_TYPES) | {BLOCK_AD, BLOCK_SIDEBAR, BLOCK_CAPTION, BLOCK_FURNITURE, BLOCK_PULLQUOTE}
# Infer the grid from the ungrouped OCR boxes, while their real column
# geometry is still available. Doing this after forming a union is too
# late: the union itself bridges the gutter and hides the two columns.
semantic_spans: list[tuple[float, float]] = []
semantic_col_w = 0.0
if page_meta and page_meta.get("width"):
geometry_blocks = [
Block(id=b["id"], page=b["page"], bbox=list(b["bbox"]),
type=b["type"], text=b.get("text", ""))
for b in blocks
]
anchors, semantic_col_w = column_anchors(geometry_blocks, float(page_meta["width"]))
semantic_spans = [
(anchor, min(anchor + semantic_col_w, anchors[i + 1])
if i + 1 < len(anchors) else anchor + semantic_col_w)
for i, anchor in enumerate(anchors)
]
def physical_groups(members: list[dict], kind: str) -> list[list[dict]]:
"""Partition a logical body paragraph at genuine column boundaries."""
if kind != BLOCK_BODY or len(members) < 2 or len(semantic_spans) < 2:
return [members]
def overlap(member: dict, span: tuple[float, float]) -> float:
x, _, width, _ = member["bbox"]
return max(0.0, min(x + width, span[1]) - max(x, span[0]))
assignments = []
for member in members:
column = max(range(len(semantic_spans)),
key=lambda i: (overlap(member, semantic_spans[i]), -i))
# A wide standfirst or a tiny detached glyph is not evidence that
# a paragraph crosses columns. Each split member must occupy a
# useful fraction of its assigned text column.
if overlap(member, semantic_spans[column]) < 0.45 * min(
semantic_col_w, member["bbox"][2]):
return [members]
assignments.append(column)
if len(set(assignments)) < 2:
return [members]
grouped: dict[int, list[dict]] = {}
for member, column in zip(members, assignments):
grouped.setdefault(column, []).append(member)
return [sorted(grouped[column], key=lambda b: (b["bbox"][1], b["bbox"][0]))
for column in sorted(grouped)]
def append_group(members: list[dict], kind: str, label: str,
visual_box: list[float] | None, continues: bool) -> None:
x = min(b["bbox"][0] for b in members)
y = min(b["bbox"][1] for b in members)
right = max(b["bbox"][0] + b["bbox"][2] for b in members)
bottom = max(b["bbox"][1] + b["bbox"][3] for b in members)
separator = "\n" if kind in (BLOCK_CODE, BLOCK_TABLE) else " "
text = separator.join(b["text"] for b in members)
if kind in NARRATABLE:
text = re.sub(r"(?<=\w)-\s+(?=[a-zäöüß])", "", text)
out.append(dict(members[0], bbox=visual_box or [x, y, right - x, bottom - y], text=text,
type=kind, region_label=label, source="llm_layout",
reading_order=len(out), continues_from_previous_page=continues))
for region in regions:
if not isinstance(region, dict):
raise ValueError("Invalid semantic region")
@ -2478,18 +2661,10 @@ def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: d
raise ValueError("Cannot merge advertising and editorial text")
kind = BLOCK_AD
used.update(ids)
x = min(b["bbox"][0] for b in members)
y = min(b["bbox"][1] for b in members)
right = max(b["bbox"][0] + b["bbox"][2] for b in members)
bottom = max(b["bbox"][1] + b["bbox"][3] for b in members)
separator = "\n" if kind in (BLOCK_CODE, BLOCK_TABLE) else " "
text = separator.join(b["text"] for b in members)
if kind in NARRATABLE:
text = re.sub(r"(?<=\w)-\s+(?=[a-zäöüß])", "", text)
out.append(dict(members[0], bbox=visual_box or [x, y, right - x, bottom - y], text=text,
type=kind, region_label=label, source="llm_layout",
reading_order=len(out),
continues_from_previous_page=region.get("continues_from_previous_page") is True))
groups = physical_groups(members, kind)
for i, group in enumerate(groups):
append_group(group, kind, label, visual_box,
i == 0 and region.get("continues_from_previous_page") is True)
# Incomplete model output must not silently remove document content.
return out + [b for b in blocks if b["id"] not in used]
@ -3302,7 +3477,7 @@ 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 = [
dict(b, text=strip_continuation_markers(b["text"]))
dict(b, text=normalize_ocr_text(strip_continuation_markers(b["text"])))
for b in layout.get("blocks", [])
if b["id"] in wanted and b["type"] in types
]
@ -3334,8 +3509,50 @@ def split_sentences_for_tts(text: str) -> list[str]:
return [p.strip() for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()]
_INTERVIEW_LABEL_RE = re.compile(
r"^\s*([A-ZÄÖÜ][A-Za-zÄÖÜäöüß.'’\-]+(?:\s+[A-ZÄÖÜ][A-Za-zÄÖÜäöüß.'’\-]+){0,3})\s*:\s+(.+)$",
re.S,
)
def interview_tts_segments(blocks: Sequence[dict[str, Any]]) -> list[dict[str, str | None]]:
"""Return article text segments with speaker labels for a two-person interview.
Detection is intentionally strict: exactly two recurring labels must begin
paragraph blocks. Colons in ordinary prose therefore do not suddenly
switch voices. Speaker labels themselves are omitted from speech because
the voice change already conveys who is talking.
"""
parsed: list[tuple[str | None, str]] = []
counts: dict[str, int] = {}
labels: dict[str, str] = {}
for block in blocks:
text = (block.get("text") or "").strip()
if not text:
continue
match = _INTERVIEW_LABEL_RE.match(text)
if not match:
parsed.append((None, text))
continue
label, body = match.group(1).strip(), match.group(2).strip()
key = label.casefold()
labels.setdefault(key, label)
counts[key] = counts.get(key, 0) + 1
parsed.append((key, body))
recurring = [key for key, count in counts.items() if count >= 2]
if len(recurring) != 2:
return [{"speaker": None, "text": text} for _, text in parsed]
allowed = set(recurring)
return [
{"speaker": labels[speaker] if speaker in allowed else None, "text": text}
for speaker, text in parsed
]
def synthesize_article_audio(doc_dir: Path, article: dict[str, Any], layout: dict[str, Any],
voice: str, settings: dict[str, Any], *,
interview_voice: str = "",
album: str = "", cover_bytes: bytes | None = None,
cover_mime: str = "image/png", progress_cb=None) -> Path:
"""Render one article to a single MP3, tagged with title/artist/album,
@ -3351,12 +3568,27 @@ def synthesize_article_audio(doc_dir: Path, article: dict[str, Any], layout: dic
full_text = " ".join(b["text"] for b in blocks if (b.get("text") or "").strip()).strip()
if not full_text:
raise ValueError("Article has no narratable text -- approve it with at least one block ticked")
sentences = split_sentences_for_tts(full_text)
total = len(sentences)
segments = interview_tts_segments(blocks) if interview_voice else [
{"speaker": None, "text": full_text}
]
speakers = []
for segment in segments:
speaker = segment.get("speaker")
if speaker and speaker not in speakers:
speakers.append(speaker)
voice_for = {speaker: (voice if i == 0 else interview_voice)
for i, speaker in enumerate(speakers[:2])}
utterances: list[tuple[str, str]] = []
for segment in segments:
spoken = prepare_text_for_tts(str(segment["text"]))
segment_voice = voice_for.get(segment.get("speaker"), voice)
utterances.extend((sentence, segment_voice)
for sentence in split_sentences_for_tts(spoken))
total = len(utterances)
combined = AudioSegment.empty()
for i, sentence in enumerate(sentences):
audio_bytes, media_type = _tts_request_audio(sentence, voice, settings)
for i, (sentence, sentence_voice) in enumerate(utterances):
audio_bytes, media_type = _tts_request_audio(sentence, sentence_voice, settings)
fmt = "wav" if audio_bytes[:4] == b"RIFF" or "wav" in media_type.lower() else None
combined += AudioSegment.from_file(_io.BytesIO(audio_bytes), format=fmt)
if progress_cb:

81
core/pronunciations.json Normal file
View File

@ -0,0 +1,81 @@
{
"de": {
"KI": "Künstliche Intelligenz",
"AI": "Künstliche Intelligenz",
"ML": "maschinelles Lernen",
"LLM": "großes Sprachmodell",
"LLMs": "große Sprachmodelle",
"API": "Programmierschnittstelle",
"APIs": "Programmierschnittstellen",
"CPU": "Hauptprozessor",
"GPU": "Grafikprozessor",
"EU": "Europäische Union",
"USA": "Vereinigte Staaten",
"z. B.": "zum Beispiel",
"d. h.": "das heißt",
"u. a.": "unter anderem",
"bzw.": "beziehungsweise",
"ca.": "circa",
"Mio.": "Millionen",
"Mrd.": "Milliarden"
},
"en": {
"AI": "artificial intelligence",
"ML": "machine learning",
"LLM": "large language model",
"LLMs": "large language models",
"API": "application programming interface",
"APIs": "application programming interfaces",
"CPU": "central processing unit",
"GPU": "graphics processing unit",
"EU": "European Union",
"USA": "United States",
"e.g.": "for example",
"i.e.": "that is"
},
"de_umlaut_repairs": {
"ahnlich": "ähnlich",
"allmahlich": "allmählich",
"anderte": "änderte",
"anderung": "Änderung",
"arztlich": "ärztlich",
"außerst": "äußerst",
"bevolkerung": "Bevölkerung",
"buro": "Büro",
"burojob": "Bürojob",
"dafur": "dafür",
"durchschnittlich": "durchschnittlich",
"erklart": "erklärt",
"ermoglicht": "ermöglicht",
"fruher": "früher",
"funf": "fünf",
"fur": "für",
"großer": "größer",
"großte": "größte",
"großten": "größten",
"jahrlich": "jährlich",
"jungere": "jüngere",
"konnen": "können",
"konnte": "könnte",
"kunstlich": "künstlich",
"kunstliche": "künstliche",
"kunstlichen": "künstlichen",
"moglich": "möglich",
"mussen": "müssen",
"naturlich": "natürlich",
"offentlich": "öffentlich",
"personlich": "persönlich",
"prufen": "prüfen",
"schuler": "Schüler",
"uber": "über",
"ubrig": "übrig",
"verfugbar": "verfügbar",
"veroffentlicht": "veröffentlicht",
"vollig": "völlig",
"wahlt": "wählt",
"wahrend": "während",
"wurde": "würde",
"wurden": "würden",
"zukunftig": "zukünftig"
}
}

134
core/pronunciations.py Normal file
View File

@ -0,0 +1,134 @@
"""Conservative OCR cleanup and spoken-form expansion for magazine TTS."""
from __future__ import annotations
import itertools
import json
import re
from functools import lru_cache
from pathlib import Path
_DB_PATH = Path(__file__).with_name("pronunciations.json")
_WORD_RE = re.compile(r"\b[A-Za-zÄÖÜäöüß]{3,}\b")
_LINEBREAK_HYPHEN_RE = re.compile(
r"(?<=[A-Za-zÄÖÜäöüß])-(?P<gap>\s+)(?P<next>[a-zäöüß][A-Za-zÄÖÜäöüß]*)"
)
_SUSPENDED_COMPOUND_WORDS = {"und", "oder", "beziehungsweise", "sowie"}
_GERMAN_HINTS = {
"aber", "auch", "auf", "aus", "bei", "das", "dass", "der", "die", "ein",
"eine", "einer", "einen", "für", "hat", "ist", "mit", "nicht", "oder", "sich",
"sie", "und", "von", "werden", "wie", "zu",
}
@lru_cache(maxsize=1)
def pronunciation_db() -> dict:
try:
data = json.loads(_DB_PATH.read_text("utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def remove_linebreak_hyphens(text: str) -> str:
"""Fuse a word split at a printed line end, preserving real compounds."""
text = (text or "").replace("\u00ad", "")
def fuse(match: re.Match) -> str:
# ``Hardware- und Softwareentwicklung`` is a real suspended compound,
# not a printed line break. OCR historically flattened newlines to a
# space, so keep this small grammatical exception for saved layouts.
if match.group("next").casefold() in _SUSPENDED_COMPOUND_WORDS:
return match.group(0)
return match.group("next")
return _LINEBREAK_HYPHEN_RE.sub(fuse, text)
def detect_text_language(text: str) -> str:
words = re.findall(r"[A-Za-zÄÖÜäöüß]+", (text or "").lower())
if any(any(c in w for c in "äöüß") for w in words):
return "de"
hits = sum(1 for word in words if word in _GERMAN_HINTS)
return "de" if hits >= max(2, len(words) // 40) else "en"
@lru_cache(maxsize=1)
def _german_spellchecker():
try:
from spellchecker import SpellChecker
return SpellChecker(language="de", distance=1)
except Exception:
return None
def _match_case(source: str, replacement: str) -> str:
if source.isupper() and len(source) > 1:
return replacement.upper()
if source[:1].isupper():
return replacement[:1].upper() + replacement[1:]
return replacement
def _umlaut_variants(word: str) -> set[str]:
positions = [(i, {"a": "ä", "o": "ö", "u": "ü"}[c])
for i, c in enumerate(word) if c in "aou"]
if not positions or len(positions) > 5:
return set()
variants = set()
for count in range(1, len(positions) + 1):
for chosen in itertools.combinations(positions, count):
chars = list(word)
for i, replacement in chosen:
chars[i] = replacement
variants.add("".join(chars))
return variants
def restore_german_umlauts(text: str) -> str:
"""Restore only dictionary-backed, unambiguous missing umlauts."""
explicit = {str(k).lower(): str(v) for k, v in
(pronunciation_db().get("de_umlaut_repairs") or {}).items()}
checker = _german_spellchecker()
def replace(match: re.Match) -> str:
token = match.group(0)
lower = token.lower()
if any(c in lower for c in "äöüß") or token.isupper():
return token
if lower in explicit:
return _match_case(token, explicit[lower])
# An unknown title-cased token is more likely to be a person's name,
# product or place than a common noun. Explicit reviewed repairs above
# may still correct a sentence-opening word such as ``Jahrlich``.
if token != lower:
return token
if checker is None or lower in checker:
return token
known = [candidate for candidate in _umlaut_variants(lower) if candidate in checker]
if len(known) != 1:
return token
return _match_case(token, known[0])
return _WORD_RE.sub(replace, text or "")
def normalize_ocr_text(text: str, language: str = "auto") -> str:
text = remove_linebreak_hyphens(text)
lang = detect_text_language(text) if language == "auto" else language
return restore_german_umlauts(text) if lang == "de" else text
def expand_abbreviations(text: str, language: str = "auto") -> str:
lang = detect_text_language(text) if language == "auto" else language
mapping = pronunciation_db().get(lang) or {}
result = text or ""
# Longest first so ``LLMs`` wins over ``LLM`` and dotted abbreviations
# are consumed as one unit. Match tokens only, never substrings in names.
for short, spoken in sorted(mapping.items(), key=lambda item: len(item[0]), reverse=True):
pattern = r"(?<!\w)" + re.escape(short) + r"(?!\w)"
result = re.sub(pattern, spoken, result)
return result
def prepare_text_for_tts(text: str, language: str = "auto") -> str:
return expand_abbreviations(normalize_ocr_text(text, language), language)

View File

@ -10,3 +10,4 @@ Pillow>=10.3.0
yt-dlp>=2024.3.10
pymupdf>=1.24.0
mutagen>=1.47.0
pyspellchecker>=0.8.1

View File

@ -113,6 +113,27 @@ async def magazine_create(request: Request):
return {"id": doc_id}
@router.patch("/api/magazine/{doc_id}")
async def magazine_update_meta(doc_id: str, request: Request):
"""Edit title/issue/publisher after creation -- there was previously no
way to do this at all once the magazine had been created, which mattered
for real use: the exported MP3 filename includes the issue's year and
month, parsed from this field, and a magazine created without one (the
common case, since it's an optional field at upload time) could never
get that fixed short of re-uploading the whole thing."""
d, meta = _require_magazine(doc_id)
body = await request.json()
if "title" in body:
meta["title"] = str(body["title"]).strip()[:300] or "Untitled magazine"
if "issue" in body:
meta["issue"] = str(body["issue"]).strip()[:120]
if "publisher" in body:
meta["publisher"] = str(body["publisher"]).strip()[:120]
meta["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
_write_meta(d, meta)
return meta
# ── Stage A–C: ingestion ─────────────────────────────────────────────────────
# Character budget for one page's live ingest preview (see
@ -494,7 +515,18 @@ async def magazine_articles(doc_id: str):
# generated, instead of a 404 on every article open.
for a in articles:
a["has_podcast"] = (d / "podcast" / f"{a['id']}.json").is_file()
a["has_audio"] = (d / "audio" / f"{a['id']}.mp3").is_file()
audio_path = d / "audio" / f"{a['id']}.mp3"
a["has_audio"] = audio_path.is_file()
if a["has_audio"]:
try:
from mutagen import File as MutagenFile
parsed = MutagenFile(audio_path)
a["audio_seconds"] = round(float(parsed.info.length), 2) if parsed and parsed.info else 0
except Exception:
# Duration is presentational; a damaged/mid-write tag must not
# make the whole contents endpoint fail. The browser will read
# the duration from the audio element once playback starts.
a["audio_seconds"] = 0
return {
"articles": articles,
"progress": meta.get("progress") or {},
@ -593,6 +625,12 @@ async def magazine_progress(doc_id: str, request: Request):
entry["sentence"] = max(0, int(body["sentence"]))
if "seconds" in body:
entry["seconds"] = max(0, float(body["seconds"]))
if "duration" in body:
entry["duration"] = max(0, float(body["duration"]))
if "completed" in body:
entry["completed"] = bool(body["completed"])
if entry["completed"]:
entry["seconds"] = entry.get("duration", entry.get("seconds", 0))
progress[article_id] = entry
meta["progress"] = progress
meta["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@ -1680,6 +1718,7 @@ def _article_cover(d: Path, article: dict[str, Any]) -> tuple[bytes | None, str]
def _synthesize_article_audio_sync(doc_id: str, job_id: str, article_id: str, voice: str,
interview_voice: str = "",
total_hint: int | None = None, index: int | None = None,
count: int | None = None) -> Path:
d = _LIB / doc_id
@ -1703,14 +1742,16 @@ def _synthesize_article_audio_sync(doc_id: str, job_id: str, article_id: str, vo
return mag.synthesize_article_audio(
d, article, layout, voice, settings,
interview_voice=interview_voice,
album=meta.get("title") or "", cover_bytes=cover_bytes, cover_mime=cover_mime,
progress_cb=progress,
)
def _audio_generate_one_sync(doc_id: str, job_id: str, article_id: str, voice: str) -> None:
def _audio_generate_one_sync(doc_id: str, job_id: str, article_id: str, voice: str,
interview_voice: str = "") -> None:
try:
path = _synthesize_article_audio_sync(doc_id, job_id, article_id, voice)
path = _synthesize_article_audio_sync(doc_id, job_id, article_id, voice, interview_voice)
background_job_update(job_id, stage="done", done=True, detail="Audio ready",
result={"article": article_id, "file": path.name})
except Exception as exc: # noqa: BLE001 - surfaced to UI
@ -1725,11 +1766,13 @@ async def magazine_generate_article_audio(doc_id: str, article_id: str, request:
article_id = _article_id(article_id)
body = await request.json() if await request.body() else {}
voice = str(body.get("voice") or meta.get("default_voice") or "").strip()
interview_voice = str(body.get("interview_voice") or "").strip()
if not voice:
raise HTTPException(400, "No narrator voice given")
job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-article-audio", fingerprint=f"{doc_id}:{article_id}")
asyncio.create_task(asyncio.to_thread(_audio_generate_one_sync, doc_id, job_id, article_id, voice))
asyncio.create_task(asyncio.to_thread(
_audio_generate_one_sync, doc_id, job_id, article_id, voice, interview_voice))
return {"job_id": job_id}
@ -1741,12 +1784,59 @@ def _sanitize_filename_part(text: str) -> str:
return re.sub(r"\s+", " ", text)
_MONTH_NAMES_EN = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
_MONTH_LOOKUP = {
"jan": "January", "januar": "January", "january": "January",
"feb": "February", "februar": "February", "february": "February",
"mar": "March", "märz": "March", "maerz": "March", "march": "March",
"apr": "April", "april": "April",
"mai": "May", "may": "May",
"jun": "June", "juni": "June", "june": "June",
"jul": "July", "juli": "July", "july": "July",
"aug": "August", "august": "August",
"sep": "September", "sept": "September", "september": "September",
"oct": "October", "okt": "October", "oktober": "October", "october": "October",
"nov": "November", "november": "November",
"dec": "December", "dez": "December", "dezember": "December", "december": "December",
}
_ISSUE_YEAR_RE = re.compile(r"\b(19|20)\d{2}\b")
_ISSUE_NUMERIC_MONTH_RE = re.compile(r"\b(0?[1-9]|1[0-2])[/.-](19|20)\d{2}\b|\b(19|20)\d{2}[-/](0?[1-9]|1[0-2])\b")
def _parse_issue_year_month(issue: str) -> tuple[str, str]:
"""Best-effort year/month out of the free-text "Issue" field (e.g.
"September 2026", "09/2026", "2026-09") -- there's no structured date
anywhere else for a magazine, since "Issue" is optional at upload and
was, until now, impossible to add or fix afterward."""
issue = issue or ""
year_m = _ISSUE_YEAR_RE.search(issue)
year = year_m.group(0) if year_m else ""
month = ""
for word in re.findall(r"[A-Za-zÄÖÜäöü]+", issue):
canon = _MONTH_LOOKUP.get(word.lower())
if canon:
month = canon
break
if not month:
m = _ISSUE_NUMERIC_MONTH_RE.search(issue)
if m:
month_num = int(m.group(1) or m.group(4))
month = _MONTH_NAMES_EN[month_num - 1]
return year, month
def _article_download_filename(meta: dict, article: dict) -> str:
"""A human-readable name for an exported MP3 -- the on-disk file stays
named by article id (stable, filesystem-safe, never collides); this is
only what a download or a zip entry is called, since that's what a
person actually sees in their music/podcast player or file browser."""
parts = [meta.get("title") or "Magazine"]
person actually sees in their music/podcast player or file browser.
Year and month (parsed from the magazine's own "Issue" field) lead, so
a folder of exports from several issues sorts chronologically by
filename; either is silently omitted if "Issue" isn't set or isn't
parseable, rather than leaving a blank segment."""
year, month = _parse_issue_year_month(meta.get("issue") or "")
parts = [year, month, meta.get("title") or "Magazine"]
page = (article.get("pages") or [None])[0]
if page is not None:
parts.append(f"p{page}")
@ -1769,13 +1859,16 @@ async def magazine_article_audio_file(doc_id: str, article_id: str):
return FileResponse(path, media_type="audio/mpeg", filename=_article_download_filename(meta, article))
def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str) -> None:
def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str,
interview_voice: str = "") -> None:
ok, failed = [], []
total = len(article_ids)
for i, article_id in enumerate(article_ids, start=1):
background_job_update(job_id, detail=f"Article {i}/{total}…")
try:
_synthesize_article_audio_sync(doc_id, job_id, article_id, voice, index=i, count=total)
_synthesize_article_audio_sync(
doc_id, job_id, article_id, voice, interview_voice,
index=i, count=total)
ok.append(article_id)
except Exception as exc: # noqa: BLE001 - one bad article should not stop the rest
failed.append({"article": article_id, "error": str(exc)[:300]})
@ -1794,6 +1887,7 @@ async def magazine_generate_batch_audio(doc_id: str, request: Request):
d, meta = _require_magazine(doc_id)
body = await request.json() if await request.body() else {}
voice = str(body.get("voice") or meta.get("default_voice") or "").strip()
interview_voice = str(body.get("interview_voice") or "").strip()
if not voice:
raise HTTPException(400, "No narrator voice given")
requested = body.get("article_ids")
@ -1807,7 +1901,8 @@ async def magazine_generate_batch_audio(doc_id: str, request: Request):
raise HTTPException(400, "No approved articles to render")
job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-audio-batch", fingerprint=doc_id)
asyncio.create_task(asyncio.to_thread(_audio_generate_batch_sync, doc_id, job_id, article_ids, voice))
asyncio.create_task(asyncio.to_thread(
_audio_generate_batch_sync, doc_id, job_id, article_ids, voice, interview_voice))
return {"job_id": job_id, "count": len(article_ids)}

File diff suppressed because one or more lines are too long

View File

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

View File

@ -22,11 +22,17 @@ const state = {
sentences: [], // [{ text, blockId, page, bbox }]
images: [], // current article's figures, in reading order
view: 'library', // library | contents | review | read
contentsView: 'pages', // list | pages — how the article list itself is shown
contentsView: 'list', // list | pages — how the article list itself is shown
contentsSelectMode: false, // batch-select articles for bulk approve/reject
contentsSelected: new Set(),
pagesCols: 8, // "All pages" grid column count, user-adjustable 1-12
playlistCurrent: null, // article id of the MP3 playlist's current track
playlistSentences: [], // transcript aligned approximately to the exported MP3
playlistImages: [], // figures/tables/charts shown alongside that transcript
playlistLoadToken: 0, // prevents a slow text fetch replacing a newer track
playlistLastSaved: 0,
playlistHighlightIndex: -1,
playlistSwitching: false,
pane: 'reflow', // reflow | pdf
playing: false,
cancel: false,
@ -678,10 +684,9 @@ function renderContents() {
const host = $('mag-contents');
if (!host) return;
const title = $('mag-doc-title');
if (title && state.doc) {
title.textContent = (state.doc.title || 'Magazine') +
(state.doc.issue ? ' — ' + state.doc.issue : '');
}
if (title && state.doc && document.activeElement !== title) title.textContent = state.doc.title || 'Magazine';
const issueInput = $('mag-doc-issue');
if (issueInput && state.doc && document.activeElement !== issueInput) issueInput.value = state.doc.issue || '';
if (!state.articles.length) {
host.innerHTML = '<p class="muted">No articles yet. Run ingestion to detect them.</p>';
return;
@ -820,12 +825,15 @@ function renderPagesView(host) {
'<img class="mag-card-thumb" loading="lazy" alt="Page ' + p + '" src="/api/magazine/' + esc(state.doc.id) + '/pages/' + p + '">' +
'<span class="mag-card-title">Page ' + p + '</span>' +
(arts.length
? '<span class="mag-card-sub">' + esc(arts[0].title || 'Untitled') +
(arts.length > 1 ? ' <span class="muted">+' + (arts.length - 1) + ' more</span>' : '') + '</span>' +
'<div class="mag-toc-actions">' +
arts.map((a) => '<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')" title="' +
esc(a.title || 'Untitled') + '">' + (arts.length > 1 ? esc((a.title || '?').slice(0, 14)) : 'Review') + '</button>').join('') +
'</div>'
? '<div class="mag-page-articles">' + arts.map((a) =>
'<div class="mag-page-article">' +
'<span class="mag-page-article-title">' + esc(a.title || 'Untitled') + '</span>' +
'<span class="mag-page-article-actions">' +
'<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' +
(a.has_audio ? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')" ' +
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi mdi-play" aria-hidden="true"></span> Play</button>' : '') +
'</span>' +
'</div>').join('') + '</div>'
: '<span class="mag-pill">no article</span>' +
'<div class="mag-toc-actions"><button type="button" class="btn-sm" onclick="magReviewPage(' + p + ')">Review this page</button></div>') +
'</div></div>';
@ -843,6 +851,30 @@ function magSetPagesCols(n) {
if (grid) grid.style.setProperty('--cols', state.pagesCols);
}
// Editing the magazine's own title/issue was previously only possible at
// upload time -- "Issue" is optional there, so the common case left it
// permanently blank with no way to add it later. It matters beyond just
// display now: the exported MP3 filename's year/month come from parsing
// this field (see _parse_issue_year_month in routes/magazine.py).
async function magCommitDocField(el, field) {
const value = (field === 'title' ? el.textContent : el.value).replace(/\s+/g, ' ').trim();
const previous = state.doc[field];
if (value === (previous || '')) return;
if (field === 'title' && !value) { el.textContent = previous || 'Magazine'; return; }
state.doc[field] = value;
try {
await api('/api/magazine/' + state.doc.id, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value }),
});
say(field === 'title' ? 'Title updated' : 'Issue updated', 'ok');
} catch (e) {
state.doc[field] = previous;
if (field === 'title') el.textContent = previous || 'Magazine'; else el.value = previous || '';
say('Could not save ' + field + ': ' + e.message, 'err');
}
}
function magToggleContentsView() {
const i = MAG_CONTENTS_VIEW_CYCLE.indexOf(state.contentsView);
state.contentsView = MAG_CONTENTS_VIEW_CYCLE[(i + 1) % MAG_CONTENTS_VIEW_CYCLE.length];
@ -941,41 +973,49 @@ function renderContentsBulkBar() {
// ── Standalone MP3 export (title/author/cover/lyrics tags) ─────────────────
// For listening away from this app entirely -- a phone's own music/podcast
// player, a car head unit -- not the in-browser sentence-by-sentence player.
async function magGenerateAllAudio() {
async function magGenerateAllAudio(force = false) {
const voice = ($('mag-audio-voice')?.value || '').trim();
const interviewVoice = ($('mag-audio-interview-voice')?.value || '').trim();
if (!voice) return say('Pick a narrator voice for the export first', 'err');
// Skip whatever already has audio -- resuming after an aborted or
// partial run (confirmed live: a magazine this size can genuinely take
// a while, and killing the job partway through, deliberately or via a
// container restart, is a real scenario, not an edge case) should not
// mean re-synthesising articles that are already done.
const missingArticles = state.articles.filter((a) => a.status === 'approved' && !a.has_audio);
const missingArticles = state.articles.filter((a) => a.status === 'approved' && (force || !a.has_audio));
if (!missingArticles.length) return say('Every approved article already has audio', '');
const missing = missingArticles.map((a) => a.id);
const pageCount = state.doc.pageCount || 0;
const btn = $('mag-audio-export-btn');
const rebuildBtn = $('mag-audio-rebuild-btn');
const status = $('mag-audio-export-status');
const progressWrap = $('mag-audio-export-progress-wrap');
const progressBar = $('mag-audio-export-progress');
const progressCount = $('mag-audio-export-count');
if (btn) btn.disabled = true;
if (rebuildBtn) rebuildBtn.disabled = true;
if (progressWrap) progressWrap.hidden = false;
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/audio/batch', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice, article_ids: missing }),
body: JSON.stringify({ voice, interview_voice: interviewVoice, article_ids: missing }),
});
jobId = r.job_id;
} catch (e) {
if (btn) btn.disabled = false;
if (rebuildBtn) rebuildBtn.disabled = false;
if (progressWrap) progressWrap.hidden = true;
return say('Could not start audio export: ' + e.message, 'err');
}
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); }
catch (e) { if (btn) btn.disabled = false; return say('Could not check audio export: ' + e.message, 'err'); }
catch (e) {
if (btn) btn.disabled = false;
if (rebuildBtn) rebuildBtn.disabled = false;
return say('Could not check audio export: ' + e.message, 'err');
}
if (status) status.textContent = job.detail || 'Working…';
// The job's own detail text always leads with "N/Total" -- either
// "Article N/Total…" between articles, or "(N/Total) Title: sentence
@ -1001,6 +1041,7 @@ async function magGenerateAllAudio() {
await new Promise((r) => setTimeout(r, 1500));
}
if (btn) btn.disabled = false;
if (rebuildBtn) rebuildBtn.disabled = false;
if (status) status.textContent = '';
if (progressWrap) progressWrap.hidden = true;
await magRefreshArticles();
@ -1255,8 +1296,21 @@ function magReviewPageColumn(page, wrapId, interactive) {
pageHtml = '<p class="muted">No page to show.</p>';
}
const selectedCount = regions.filter((r) => r.kind === 'image'
? ownImages.has(r.id) : included.has(r.id)).length;
const allSelected = regions.length > 0 && selectedCount === regions.length;
let listHtml = '<div class="mag-blocks"' + (interactive
? ' onkeydown="magListKey(event)" onfocusin="magListFocus(event)" aria-describedby="mag-list-keys"' : '') + '>' +
'<div class="mag-blocks-head">' +
'<span>Region</span>' +
'<label class="mag-blocks-check-all" title="Include or exclude every detected region on this page">' +
'<input type="checkbox" data-mag-check-all data-selected="' + selectedCount + '" data-total="' + regions.length + '" ' +
(allSelected ? 'checked ' : '') + (regions.length ? '' : 'disabled ') +
'onchange="magReviewToggleAllPage(this, ' + page + ')">' +
'<span>Select all</span>' +
'</label>' +
'<span>Type</span><span>Content</span>' +
'</div>' +
(interactive ? '<p id="mag-list-keys" class="muted mag-list-keys">Keys: ↑/↓ move between regions · Space tick · X select · Enter edit text · R read again · M merge with the next region · Delete delete (Undo appears above)</p>' : '') +
(interactive ? magReviewLegendHtml() : '');
regions.forEach((r, i) => {
@ -1462,6 +1516,10 @@ function renderReview() {
'Approve &amp; Next</button>' +
'</div>';
host.innerHTML = html;
host.querySelectorAll('[data-mag-check-all]').forEach((input) => {
input.indeterminate = Number(input.dataset.selected) > 0 &&
Number(input.dataset.selected) < Number(input.dataset.total);
});
if (state.orderMode) {
$('mag-review-page-wrap')?.classList.add('is-ordering');
magOrderRefreshBar();
@ -1612,6 +1670,24 @@ function magReviewClearCurrentPage() {
say('All fields on this page cleared — select only the article content to keep.', 'ok');
}
// The header checkbox controls the complete region column for one page. It
// covers figures as well as text, matching the individual checkboxes below
// it; a partially selected page is rendered with the native indeterminate
// state so the header never implies that everything is included when it is
// not.
function magReviewToggleAllPage(input, page) {
if (!state.article || !state.layout) return;
if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []);
const blockIds = (state.layout.blocks || []).filter((b) => b.page === page).map((b) => b.id);
const imageIds = (state.layout.images || []).filter((img) => img.page === page).map((img) => img.id);
if (input.checked) blockIds.forEach((id) => _magReviewChecked.add(id));
else blockIds.forEach((id) => _magReviewChecked.delete(id));
const images = new Set(state.article.images || []);
imageIds.forEach((id) => input.checked ? images.add(id) : images.delete(id));
state.article.images = Array.from(images);
renderReview();
}
// "Clear all fields" above only deselects a page's regions from narration --
// the boxes themselves, right or wrong, stay. When the detected layout on a
// page is too tangled to fix by editing individual boxes, this deletes every
@ -2843,17 +2919,12 @@ async function magRead(articleId, opts) {
// already been exported still went through the live reader from scratch.
if (a.has_audio) {
magTogglePlaylist(true);
magPlaylistPlay(articleId);
const saved = !(opts && opts.fromStart) && (state.doc.progress || {})[articleId];
if (saved && saved.seconds) {
const audio = $('mag-playlist-audio');
if (audio) {
const resume = () => { audio.currentTime = saved.seconds; audio.removeEventListener('loadedmetadata', resume); };
audio.addEventListener('loadedmetadata', resume);
}
}
magPlaylistPlay(articleId, opts);
return;
}
const playlistAudio = $('mag-playlist-audio');
if (playlistAudio) playlistAudio.pause();
state.playlistCurrent = null;
state.article = a;
state.audioCache = new Map();
state.prepGen += 1;
@ -3228,27 +3299,189 @@ function renderPlaylistTracks() {
}
list.innerHTML = order.map((a, i) => {
const active = a.id === state.playlistCurrent;
return '<li class="mag-playlist-track' + (active ? ' is-playing' : '') + '" ' +
'onclick="magPlaylistPlay(\'' + a.id + '\')">' +
'<span class="mag-playlist-track-num">' + (active ? '<span class="mdi mdi-volume-high" aria-hidden="true"></span>' : i + 1) + '</span>' +
const progress = (state.doc.progress || {})[a.id] || {};
const completed = !!progress.completed;
const started = !completed && Number(progress.seconds) > 0;
const status = completed ? 'complete' : started ? 'in-progress' : 'unplayed';
const statusLabel = completed ? 'Played' : started ? 'Partly played' : 'Not played';
const duration = a.audio_seconds || progress.duration || a.estimated_seconds;
return '<li class="mag-playlist-track is-' + status + (active ? ' is-playing' : '') + '">' +
'<button type="button" class="mag-playlist-track-play" onclick="magPlaylistPlay(\'' + a.id + '\')" ' +
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi ' +
(active ? 'mdi-volume-high' : completed ? 'mdi-replay' : 'mdi-play') + '" aria-hidden="true"></span></button>' +
'<span class="mag-playlist-track-page">' + esc(fmtPages(a.pages)) + '</span>' +
'<span class="mag-playlist-track-main">' +
'<span class="mag-playlist-track-title">' + esc(a.title || 'Untitled') + '</span>' +
(a.author ? '<span class="mag-playlist-track-sub muted">by ' + esc(a.author) + '</span>' : '') +
'</span>' +
'<span class="muted">' + fmtPages(a.pages) + ' · ' + fmtDuration(a.estimated_seconds) + '</span>' +
'<span class="mag-playlist-track-duration muted">' + fmtDuration(duration) + '</span>' +
'<span class="mag-playlist-track-status" title="' + statusLabel + '"><span class="mdi ' +
(completed ? 'mdi-check-circle' : started ? 'mdi-progress-clock' : 'mdi-circle-outline') +
'" aria-hidden="true"></span><span class="sr-only">' + statusLabel + '</span></span>' +
'</li>';
}).join('');
}
function magPlaylistPlay(articleId) {
function playlistDuration(a) {
const audio = $('mag-playlist-audio');
if (audio && Number.isFinite(audio.duration) && audio.duration > 0) return audio.duration;
const progress = (state.doc?.progress || {})[a?.id] || {};
return Number(a?.audio_seconds || progress.duration || a?.estimated_seconds || 0);
}
function magFmtClock(seconds) {
const total = Math.max(0, Math.floor(Number(seconds) || 0));
const h = Math.floor(total / 3600), m = Math.floor((total % 3600) / 60), s = total % 60;
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(s).padStart(2, '0');
}
function savePlaylistProgress(completed, immediate) {
const a = state.articles.find((x) => x.id === state.playlistCurrent);
const audio = $('mag-playlist-audio');
if (!a || !audio || !state.doc) return;
const now = Date.now();
if (!immediate && now - state.playlistLastSaved < 5000) return;
state.playlistLastSaved = now;
const seconds = completed ? (Number.isFinite(audio.duration) ? audio.duration : audio.currentTime) : audio.currentTime;
const duration = Number.isFinite(audio.duration) ? audio.duration : playlistDuration(a);
const entry = Object.assign({}, (state.doc.progress || {})[a.id], {
seconds: Math.max(0, seconds || 0), duration: Math.max(0, duration || 0), completed: !!completed,
});
state.doc.progress = state.doc.progress || {};
state.doc.progress[a.id] = entry;
fetch('/api/magazine/' + state.doc.id + '/progress', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, keepalive: !!immediate,
body: JSON.stringify({ article: a.id, seconds: entry.seconds, duration: entry.duration, completed: entry.completed }),
}).catch(() => {});
renderPlaylistTracks();
}
function playlistTimeline(duration) {
const weights = state.playlistSentences.map((s) => Math.max(6, s.text.replace(/\s+/g, ' ').length));
const total = weights.reduce((sum, n) => sum + n, 0) || 1;
let cursor = 0;
return weights.map((weight) => {
const start = cursor / total * duration;
cursor += weight;
return { start, end: cursor / total * duration };
});
}
function magPlaylistSeekSentence(index) {
const audio = $('mag-playlist-audio');
if (!audio || !state.playlistSentences[index]) return;
const timeline = playlistTimeline(playlistDuration(state.article));
if (timeline[index]) audio.currentTime = timeline[index].start;
audio.play().catch(() => {});
}
function renderPlaylistCompanion() {
const companion = $('mag-playlist-companion');
const transcript = $('mag-playlist-transcript');
if (!companion || !transcript) return;
companion.hidden = !state.playlistSentences.length && !state.playlistImages.length;
state.playlistHighlightIndex = -1;
transcript.innerHTML = state.playlistSentences.map((s, i) => {
const tag = ['title', 'heading'].includes(s.type) ? 'h3' : 'p';
const words = s.words.map((w, j) => '<span class="mag-w" data-w="' + j + '">' + esc(w) + '</span>').join(' ');
return '<' + tag + ' class="mag-playlist-line" data-playlist-sentence="' + i + '" ' +
'onclick="magPlaylistSeekSentence(' + i + ')">' + words + '</' + tag + '>';
}).join('');
magPlaylistSync();
}
function renderPlaylistVisual(sentenceIndex) {
const host = $('mag-playlist-visual');
if (!host) return;
if (!state.playlistImages.length) { host.hidden = true; return; }
const order = state.playlistSentences[sentenceIndex]?.readingOrder || 0;
let imageIndex = 0;
state.playlistImages.forEach((img, i) => { if ((img.reading_order || 0) <= order) imageIndex = i; });
const img = state.playlistImages[imageIndex];
if (host.dataset.imageId === img.id) { host.hidden = false; return; }
host.dataset.imageId = img.id;
const label = img.type === 'table' ? 'Table' : img.type === 'chart' ? 'Graph' : 'Image';
host.innerHTML = '<img src="/api/magazine/' + state.doc.id + '/images/' + encodeURIComponent(img.filename) +
'" alt="' + label + ' from page ' + img.page + '">' +
'<figcaption><strong>' + label + ' · page ' + img.page + '</strong>' +
(img.description ? '<span>' + esc(img.description) + '</span>' : '') + '</figcaption>';
host.hidden = false;
}
function magPlaylistSync() {
const audio = $('mag-playlist-audio');
const a = state.articles.find((x) => x.id === state.playlistCurrent);
if (!audio || !a) return;
const duration = playlistDuration(a);
const timeline = playlistTimeline(duration);
let index = timeline.findIndex((t) => audio.currentTime < t.end);
if (index < 0) index = Math.max(0, timeline.length - 1);
const follow = $('mag-playlist-karaoke')?.checked !== false;
document.querySelectorAll('#mag-playlist-transcript [data-playlist-sentence]').forEach((el) => {
el.classList.toggle('is-current', follow && Number(el.dataset.playlistSentence) === index);
el.querySelectorAll('.mag-w').forEach((w) => w.classList.remove('is-current'));
});
const line = document.querySelector('#mag-playlist-transcript [data-playlist-sentence="' + index + '"]');
if (follow && line && timeline[index]) {
const local = Math.max(0, Math.min(0.999, (audio.currentTime - timeline[index].start) /
Math.max(0.1, timeline[index].end - timeline[index].start)));
const words = line.querySelectorAll('.mag-w');
if (words.length) words[Math.min(words.length - 1, Math.floor(local * words.length))].classList.add('is-current');
if (state.playlistHighlightIndex !== index) {
if (!prefersReducedMotion()) line.scrollIntoView({ block: 'center', behavior: 'smooth' });
else line.scrollIntoView({ block: 'center' });
state.playlistHighlightIndex = index;
}
}
renderPlaylistVisual(index);
const pct = duration ? Math.min(100, audio.currentTime / duration * 100) : 0;
const drivingBar = $('mag-driving-progress-bar');
const drivingTime = $('mag-driving-time');
if (drivingBar) drivingBar.style.width = pct + '%';
if (drivingTime) drivingTime.textContent = magFmtClock(audio.currentTime) + ' / ' + magFmtClock(duration);
}
async function loadPlaylistCompanion(a) {
const token = ++state.playlistLoadToken;
state.playlistSentences = [];
state.playlistImages = [];
renderPlaylistCompanion();
try {
await ensureLayout();
const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=1&descriptions=0');
if (token !== state.playlistLoadToken || state.playlistCurrent !== a.id) return;
state.playlistSentences = splitSentences(data.blocks || []);
state.playlistImages = articleImages(a);
renderPlaylistCompanion();
} catch (_) {
if (token === state.playlistLoadToken) renderPlaylistCompanion();
}
}
function magPlaylistPlay(articleId, options) {
if (!articleId) return;
const a = state.articles.find((x) => x.id === articleId);
if (!a) return;
if (!options?.skipCurrentSave && state.playlistCurrent && state.playlistCurrent !== articleId) {
savePlaylistProgress(false, true);
}
state.playlistCurrent = articleId;
state.article = a;
state.playlistLastSaved = 0;
const audio = $('mag-playlist-audio');
if (audio) {
state.playlistSwitching = true;
audio.dataset.article = articleId;
audio.src = '/api/magazine/' + state.doc.id + '/articles/' + articleId + '/audio';
audio.play().catch(() => {});
queueMicrotask(() => { state.playlistSwitching = false; });
const saved = (state.doc.progress || {})[articleId] || {};
const resumeAt = options?.fromStart || saved.completed ? 0 : Number(saved.seconds || 0);
const resume = () => {
if (resumeAt > 0 && resumeAt < audio.duration - 1) audio.currentTime = resumeAt;
audio.play().catch(() => {});
};
if (audio.readyState >= 1) resume();
else audio.addEventListener('loadedmetadata', resume, { once: true });
}
const titleEl = $('mag-playlist-title');
const subEl = $('mag-playlist-sub');
@ -3261,12 +3494,23 @@ function magPlaylistPlay(articleId) {
else cover.hidden = true;
}
renderPlaylistTracks();
loadPlaylistCompanion(a);
if (state.driving) renderDriving();
}
function _magPlaylistNext() {
function _magPlaylistNext(eventOrCompleted) {
const completed = eventOrCompleted !== false;
savePlaylistProgress(completed, true);
const order = _magPlaylistOrder();
const i = order.findIndex((a) => a.id === state.playlistCurrent);
if (i >= 0 && i < order.length - 1) magPlaylistPlay(order[i + 1].id);
if (i >= 0 && i < order.length - 1) magPlaylistPlay(order[i + 1].id, { skipCurrentSave: true });
else syncDrivingPlayButton();
}
function magPlaylistPrevious() {
const order = _magPlaylistOrder();
const i = order.findIndex((a) => a.id === state.playlistCurrent);
if (i > 0) magPlaylistPlay(order[i - 1].id);
}
function magStopQueue() {
@ -3340,7 +3584,7 @@ async function magLoadVoices() {
if (root.VoicePicker && el.dataset.vpUpgraded) root.VoicePicker.setValue(id, value);
else el.value = value;
};
const voiceLabels = { 'mag-voice': 'Narrator voice', 'mag-host-a': 'Host A voice', 'mag-host-b': 'Host B voice', 'mag-host-c': 'Host C voice', 'mag-audio-voice': 'Narrator for exported MP3s' };
const voiceLabels = { 'mag-voice': 'Narrator voice', 'mag-host-a': 'Host A voice', 'mag-host-b': 'Host B voice', 'mag-host-c': 'Host C voice', 'mag-audio-voice': 'Narrator for exported MP3s', 'mag-audio-interview-voice': 'Interview voice 2 (optional)' };
Object.keys(voiceLabels).forEach((id) => {
const el = $(id);
if (!el) return;
@ -3503,6 +3747,17 @@ function magToggleDriving() {
if (state.driving) renderDriving();
}
function magOpenDriving() {
const order = _magPlaylistOrder();
if (!order.length) return say('Generate the magazine audio first', 'err');
magTogglePlaylist(true);
if (!state.playlistCurrent) {
const next = order.find((a) => !((state.doc.progress || {})[a.id] || {}).completed) || order[0];
magPlaylistPlay(next.id);
}
if (!state.driving) magToggleDriving();
}
function renderDriving() {
const a = state.article;
if (!a) return;
@ -3512,16 +3767,68 @@ function renderDriving() {
if (byEl) byEl.textContent = a.author ? 'By ' + a.author : '';
syncDrivingPlayButton();
const qEl = $('mag-driving-queue');
if (qEl) qEl.hidden = !state.queue.length;
if (qEl) {
qEl.hidden = !state.queue.length && !state.playlistCurrent;
if (state.playlistCurrent) {
const order = _magPlaylistOrder();
const i = order.findIndex((item) => item.id === state.playlistCurrent);
const progress = $('mag-queue-progress-2');
if (progress) progress.textContent = 'Article ' + (i + 1) + ' / ' + order.length;
}
}
magPlaylistSync();
}
function syncDrivingPlayButton() {
const btn = $('mag-driving-play');
if (!btn) return;
btn.innerHTML = state.playing
const playlistAudio = $('mag-playlist-audio');
const isPlaying = state.playlistCurrent && playlistAudio ? !playlistAudio.paused : state.playing;
btn.innerHTML = isPlaying
? '<span class="mdi mdi-pause"></span>'
: '<span class="mdi mdi-play"></span>';
btn.setAttribute('aria-label', state.playing ? 'Pause' : 'Play');
btn.setAttribute('aria-label', isPlaying ? 'Pause' : 'Play');
}
function magDrivingTogglePlayback() {
const audio = $('mag-playlist-audio');
if (state.playlistCurrent && audio) {
if (audio.paused) audio.play().catch(() => {}); else audio.pause();
return;
}
magPlay();
}
function magDrivingNext() {
if (state.playlistCurrent) return _magPlaylistNext(false);
magQueueNext();
}
function magDrivingPrevious() {
if (state.playlistCurrent) return magPlaylistPrevious();
if (state.queue.length && state.queueIndex > 0) {
state.queueIndex -= 2;
magQueueNext();
}
}
function wirePlaylistAudio() {
const audio = $('mag-playlist-audio');
if (!audio || audio.dataset.wired) return;
audio.dataset.wired = '1';
audio.addEventListener('ended', _magPlaylistNext);
audio.addEventListener('timeupdate', () => { magPlaylistSync(); savePlaylistProgress(false, false); });
audio.addEventListener('loadedmetadata', () => {
const a = state.articles.find((x) => x.id === state.playlistCurrent);
if (a && Number.isFinite(audio.duration)) a.audio_seconds = audio.duration;
renderPlaylistTracks();
magPlaylistSync();
});
audio.addEventListener('play', syncDrivingPlayButton);
audio.addEventListener('pause', () => {
if (!state.playlistSwitching) savePlaylistProgress(false, true);
syncDrivingPlayButton();
});
}
// ── Boot ─────────────────────────────────────────────────────────────────────
@ -3529,11 +3836,7 @@ function syncDrivingPlayButton() {
async function magOnShow() {
setupMagDropzone();
setupMagIngestPreviewResize();
const playlistAudio = $('mag-playlist-audio');
if (playlistAudio && !playlistAudio.dataset.wired) {
playlistAudio.dataset.wired = '1';
playlistAudio.addEventListener('ended', _magPlaylistNext);
}
wirePlaylistAudio();
if ($('mag-library-list')) {
magLoadLibrary();
magLoadVoices();
@ -3543,11 +3846,7 @@ async function magOnShow() {
async function magInit() {
setupMagDropzone();
setupMagIngestPreviewResize();
const playlistAudio = $('mag-playlist-audio');
if (playlistAudio && !playlistAudio.dataset.wired) {
playlistAudio.dataset.wired = '1';
playlistAudio.addEventListener('ended', _magPlaylistNext);
}
wirePlaylistAudio();
try {
const savedView = localStorage.getItem(MAG_CONTENTS_VIEW_KEY);
if (MAG_CONTENTS_VIEW_CYCLE.includes(savedView)) state.contentsView = savedView;
@ -3588,8 +3887,11 @@ root.magContentsCommitTitle = magContentsCommitTitle;
root.magGenerateAllAudio = magGenerateAllAudio;
root.magTogglePlaylist = magTogglePlaylist;
root.magReviewPage = magReviewPage;
root.magCommitDocField = magCommitDocField;
root.magSetPagesCols = magSetPagesCols;
root.magPlaylistPlay = magPlaylistPlay;
root.magPlaylistSeekSentence = magPlaylistSeekSentence;
root.magPlaylistSync = magPlaylistSync;
root.magContentsToggleArticle = magContentsToggleArticle;
root.magContentsSelectAllVisible = magContentsSelectAllVisible;
root.magContentsClearSelection = magContentsClearSelection;
@ -3601,6 +3903,7 @@ root.magReviewSetActivePage = magReviewSetActivePage;
root.magReviewFocusBlock = magReviewFocusBlock;
root.magReviewHighlightBox = magReviewHighlightBox;
root.magReviewToggleBox = magReviewToggleBox;
root.magReviewToggleAllPage = magReviewToggleAllPage;
root.magReviewClearCurrentPage = magReviewClearCurrentPage;
root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes;
root.magReocrPage = magReocrPage;
@ -3652,6 +3955,10 @@ root.magPlayAll = magPlayAll;
root.magQueueNext = magQueueNext;
root.magStopQueue = magStopQueue;
root.magToggleDriving = magToggleDriving;
root.magOpenDriving = magOpenDriving;
root.magDrivingTogglePlayback = magDrivingTogglePlayback;
root.magDrivingNext = magDrivingNext;
root.magDrivingPrevious = magDrivingPrevious;
root.magInit = magInit;
MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages };

View File

@ -375,7 +375,9 @@ function libraryFilterMatch(v) {
if (wanted > 1 && r < wanted) return false;
}
if (f.text) {
const hay = [v.id, v.transcript, v.note, v.file_type, v.flag, v.gender, v.group, v.origin].map(x => String(x || '').toLowerCase()).join(' ');
const tags = Array.isArray(v.tags) ? v.tags.join(' ') : (v.tags || v.tag || '');
const hay = [v.id, v.name, v.transcript, v.note, v.file_type, v.flag, v.gender, v.group, v.origin, tags]
.map(x => String(x || '').toLowerCase()).join(' ');
if (!hay.includes(f.text)) return false;
}
return true;

View File

@ -41,6 +41,12 @@
return meta ? `${id} ${meta}` : id;
}
function _voiceSearchText(id, label) {
const v = _voiceData(id) || {};
const tags = Array.isArray(v.tags) ? v.tags.join(' ') : (v.tags || v.tag || '');
return [id, label, v.name, tags].map(x => String(x || '').toLowerCase()).join(' ');
}
// Shared gender/type avatar icons (male/female/neutral/robot/animal). Returns null
// when the voice has no avatar key, so callers fall back to photo/flag/letter.
const VOICE_AVATAR_ICONS = {
@ -142,7 +148,7 @@
function _renderList(filter) {
const opts = Array.from(sel.options).filter(o => o.value);
const q = (filter || '').toLowerCase();
const filtered = q ? opts.filter(o => o.value.toLowerCase().includes(q) || o.textContent.toLowerCase().includes(q)) : opts;
const filtered = q ? opts.filter(o => _voiceSearchText(o.value, o.textContent).includes(q)) : opts;
if (!filtered.length) { list.innerHTML = '<div class="vp-empty">No voices found</div>'; return; }
list.innerHTML = filtered.map(o => _buildItem(o.value, o.textContent)).join('');
// highlight current
@ -295,7 +301,7 @@
function _renderList(filter) {
const q = (filter || '').toLowerCase();
const filtered = q ? _voices().filter(v => v.id.toLowerCase().includes(q)) : _voices();
const filtered = q ? _voices().filter(v => _voiceSearchText(v.id, v.name || v.id).includes(q)) : _voices();
if (!filtered.length) { list.innerHTML = '<div class="vp-empty">No voices found</div>'; return; }
list.innerHTML = filtered.map(v => _buildItem(v.id, v.id)).join('');
list.querySelectorAll('.vp-item').forEach(el => el.classList.toggle('selected', el.dataset.value === input.value));

View File

@ -56,7 +56,13 @@
<button type="button" class="btn-sm" onclick="magBackToLibrary()">
<span class="mdi mdi-arrow-left"></span> All magazines
</button>
<h3 id="mag-doc-title">Magazine</h3>
<h3 id="mag-doc-title" contenteditable="true" spellcheck="false" title="Click to edit the magazine title"
onblur="magCommitDocField(this, 'title')"
onkeydown="if(event.key==='Enter'){event.preventDefault();this.blur();}">Magazine</h3>
<input id="mag-doc-issue" type="text" class="mag-doc-issue-input" placeholder="Issue, e.g. September 2026"
title="Issue date -- used for the year/month in exported MP3 filenames"
onblur="magCommitDocField(this, 'issue')"
onkeydown="if(event.key==='Enter'){this.blur();}">
<div class="mag-bar-actions">
<button type="button" class="btn-sm" id="mag-contents-view-toggle" onclick="magToggleContentsView()" title="Switch between an article list and a page-by-page thumbnail grid">
<span class="mdi mdi-view-grid-outline"></span> Thumbnails
@ -73,6 +79,10 @@
title="Show every article with a generated MP3, in reading order, with a player">
<span class="mdi mdi-playlist-music-outline"></span> Playlist
</button>
<button type="button" class="btn-sm" id="mag-driving-open" onclick="magOpenDriving()"
title="Open the magazine playlist with large, distraction-free controls">
<span class="mdi mdi-car-outline"></span> Driving mode
</button>
<button type="button" class="btn-sm primary" onclick="magPlayAll()">
<span class="mdi mdi-playlist-play"></span> Play all articles
</button>
@ -88,10 +98,18 @@
<div class="mag-audio-export">
<label for="mag-audio-voice">Narrator for exported MP3s</label>
<select id="mag-audio-voice"></select>
<label for="mag-audio-interview-voice">Interview voice 2 (optional)</label>
<select id="mag-audio-interview-voice" title="When an article contains two recurring speaker labels, the second speaker uses this voice. Normal articles are unaffected.">
<option value="">— use narrator only —</option>
</select>
<button type="button" class="btn-sm" id="mag-audio-export-btn" onclick="magGenerateAllAudio()"
title="Render every approved article that doesn't already have an MP3 -- title, author, cover image and full text embedded as tags -- for a phone's own music/podcast player or a car head unit. Safe to click again after an aborted run: already-generated articles are skipped.">
<span class="mdi mdi-file-music-outline"></span> Generate all audio (MP3)
</button>
<button type="button" class="btn-sm" id="mag-audio-rebuild-btn" onclick="magGenerateAllAudio(true)"
title="Rebuild every approved MP3 using the latest OCR cleanup, pronunciation and interview-voice settings. Existing files are replaced.">
<span class="mdi mdi-refresh"></span> Regenerate all MP3s
</button>
<a class="btn-sm" id="mag-audio-zip-link" href="#" hidden download>
<span class="mdi mdi-folder-zip-outline"></span> Download all (ZIP)
</a>
@ -102,6 +120,16 @@
<span id="mag-audio-export-status" class="muted" role="status" aria-live="polite"></span>
</div>
<div id="mag-playlist" class="mag-playlist" hidden>
<div class="mag-playlist-heading">
<div>
<strong>Magazine playlist</strong>
<span class="muted">Page · article · duration</span>
</div>
<label class="mag-karaoke-toggle" title="Highlight the sentence and word approximately as the MP3 plays">
<input type="checkbox" id="mag-playlist-karaoke" checked onchange="magPlaylistSync()">
Follow spoken text
</label>
</div>
<div class="mag-playlist-player">
<img id="mag-playlist-cover" class="mag-playlist-cover" alt="" hidden>
<div class="mag-playlist-nowplaying">
@ -114,6 +142,13 @@
</button>
</div>
<ol id="mag-playlist-tracks" class="mag-playlist-tracks"></ol>
<div id="mag-playlist-companion" class="mag-playlist-companion" hidden>
<figure id="mag-playlist-visual" class="mag-playlist-visual" hidden></figure>
<div>
<div id="mag-playlist-sync-note" class="mag-playlist-sync-note muted">Text timing is estimated from the MP3 position.</div>
<article id="mag-playlist-transcript" class="mag-playlist-transcript" aria-label="Article transcript"></article>
</div>
</div>
</div>
<div id="mag-ingest-status" class="mag-status" hidden></div>
<!-- Decorative "watch it work" visualisation -- the essential
@ -242,12 +277,17 @@
<span id="mag-queue-progress-2" class="muted"></span>
</div>
<div class="mag-driving-controls">
<button type="button" class="mag-driving-btn" onclick="magQueueNext()" aria-label="Next article" title="Next article (only in Play all)">
<span class="mdi mdi-skip-next"></span>
<button type="button" class="mag-driving-btn" onclick="magDrivingPrevious()" aria-label="Previous article" title="Previous article">
<span class="mdi mdi-skip-previous"></span>
</button>
<button type="button" class="mag-driving-btn mag-driving-play" id="mag-driving-play" onclick="magPlay()" aria-label="Play">
<button type="button" class="mag-driving-btn mag-driving-play" id="mag-driving-play" onclick="magDrivingTogglePlayback()" aria-label="Play">
<span class="mdi mdi-play"></span>
</button>
<button type="button" class="mag-driving-btn" onclick="magDrivingNext()" aria-label="Next article" title="Next article">
<span class="mdi mdi-skip-next"></span>
</button>
</div>
<div class="mag-driving-time" id="mag-driving-time" aria-live="polite">0:00 / 0:00</div>
<div class="mag-driving-progress" aria-hidden="true"><span id="mag-driving-progress-bar"></span></div>
</div>
</div>

View File

@ -4940,6 +4940,12 @@ input[list="lang-codes"] {
real scanned page and much better than a guess when it's slightly off,
since object-fit: contain still letterboxes any mismatch cleanly. */
.mag-pages-grid .mag-card-thumb { height: auto; aspect-ratio: 0.707 / 1; }
.mag-page-articles { display: flex; flex-direction: column; gap: 8px; margin-top: 2px; }
.mag-page-article { display: flex; flex-direction: column; gap: 5px; padding-top: 8px;
border-top: 1px solid var(--border); }
.mag-page-article:first-child { padding-top: 0; border-top: 0; }
.mag-page-article-title { color: var(--subtext); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
.mag-page-article-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.mag-cols-control { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--subtext); }
.mag-cols-control input[type="range"] { width: 90px; accent-color: var(--accent); }
#mag-cols-value { min-width: 16px; text-align: right; }
@ -4972,6 +4978,9 @@ input[list="lang-codes"] {
after another. Sticky player bar + scrollable track list underneath. */
.mag-playlist { border: 1px solid var(--border); border-radius: var(--radius);
background: var(--panel); margin-bottom: 12px; overflow: hidden; }
.mag-playlist-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px;
padding: 10px 14px; border-bottom: 1px solid var(--border); }
.mag-playlist-heading > div { display: flex; flex-direction: column; gap: 2px; }
.mag-playlist-player { display: flex; align-items: center; gap: 12px; padding: 10px 14px;
background: var(--surface); border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 2; }
@ -4979,19 +4988,41 @@ input[list="lang-codes"] {
.mag-playlist-nowplaying { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.mag-playlist-nowplaying strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mag-playlist-tracks { list-style: none; margin: 0; padding: 4px; max-height: 420px; overflow-y: auto; }
.mag-playlist-track { display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: 6px; cursor: pointer; }
.mag-playlist-track { display: grid; grid-template-columns: 36px minmax(70px, auto) minmax(180px, 1fr) auto 28px;
align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; }
.mag-playlist-track:hover { background: var(--surface); }
.mag-playlist-track.is-playing { background: var(--surface); border-left: 3px solid var(--accent); }
.mag-playlist-track-num { width: 22px; text-align: center; color: var(--subtext); flex-shrink: 0; }
.mag-playlist-track-num .mdi { color: var(--accent); }
.mag-playlist-track-play { width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--border); border-radius: 50%; background: var(--surface); color: var(--accent); cursor: pointer; }
.mag-playlist-track-play:hover { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.mag-playlist-track-page { color: var(--subtext); font-variant-numeric: tabular-nums; white-space: nowrap; }
.mag-playlist-track-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.mag-playlist-track-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mag-playlist-track-sub { font-size: 12px; }
.mag-playlist-track-duration { white-space: nowrap; font-variant-numeric: tabular-nums; }
.mag-playlist-track-status { font-size: 20px; text-align: center; color: var(--subtext); }
.mag-playlist-track.is-complete .mag-playlist-track-status { color: var(--green); }
.mag-playlist-track.is-in-progress .mag-playlist-track-status { color: var(--yellow); }
.mag-playlist-companion { display: grid; grid-template-columns: minmax(260px, .8fr) minmax(320px, 1.2fr);
gap: 18px; padding: 16px; border-top: 1px solid var(--border); background: var(--surface); }
.mag-playlist-visual { margin: 0; position: sticky; top: 92px; align-self: start; }
.mag-playlist-visual img { width: 100%; max-height: 65vh; display: block; object-fit: contain;
border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); }
.mag-playlist-visual figcaption { display: flex; flex-direction: column; gap: 4px; padding: 8px 2px;
font-size: 13px; color: var(--subtext); }
.mag-playlist-sync-note { margin-bottom: 8px; font-size: 12px; }
.mag-playlist-transcript { max-height: 65vh; overflow-y: auto; padding: 4px 12px 16px 4px;
font-size: 16px; line-height: 1.65; }
.mag-playlist-line { margin: 0 0 8px; padding: 2px 5px; border-radius: 4px; cursor: pointer; }
.mag-playlist-line:hover { background: var(--panel); }
.mag-playlist-line.is-current { background: rgba(37,99,235,.16); box-shadow: -3px 0 0 var(--accent); }
@media (max-width: 640px) {
.mag-playlist-player { flex-wrap: wrap; }
.mag-playlist-nowplaying { order: 3; flex-basis: 100%; }
.mag-playlist-track .muted:last-child { display: none; }
.mag-playlist-track { grid-template-columns: 36px 1fr auto 26px; }
.mag-playlist-track-page { display: none; }
.mag-playlist-companion { grid-template-columns: 1fr; }
.mag-playlist-visual { position: static; }
}
.mag-audio-export select { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text); }
@ -5141,6 +5172,13 @@ input[list="lang-codes"] {
.mag-block { display: grid; grid-template-columns: auto auto max-content 1fr; gap: 10px; align-items: start;
padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); margin-bottom: 4px; cursor: pointer; }
.mag-blocks-head { display: grid; grid-template-columns: auto auto max-content 1fr; gap: 10px; align-items: center;
position: sticky; top: 0; z-index: 3; padding: 8px 10px; margin-bottom: 6px;
border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel);
color: var(--subtext); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
.mag-blocks-check-all { display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
color: var(--text); text-transform: none; letter-spacing: 0; white-space: nowrap; }
.mag-blocks-check-all input { width: 17px; height: 17px; accent-color: var(--accent); }
.mag-block.is-aux { opacity: .68; }
.mag-block:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.14); }
.mag-block-num { font-size: 11px; font-weight: 700; color: var(--accent); min-width: 1.4em; }
@ -5255,6 +5293,10 @@ input[list="lang-codes"] {
display: flex; align-items: center; justify-content: center; }
.mag-driving-btn.mag-driving-play { width: 140px; height: 140px; font-size: 56px;
background: var(--accent); color: var(--on-accent); }
.mag-driving-time { margin-top: 26px; font-size: 22px; font-variant-numeric: tabular-nums; }
.mag-driving-progress { width: min(680px, 80vw); height: 10px; margin: 12px auto 0;
overflow: hidden; border-radius: 999px; background: var(--border); }
.mag-driving-progress span { display: block; width: 0; height: 100%; background: var(--accent); transition: width .2s linear; }
.mag-driving-btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
.mag-driving-btn:active { transform: scale(0.96); }

View File

@ -45,17 +45,21 @@ async function navTo(page, section) {
await page.evaluate((s) => window.navTo(s), section);
}
async function mockBackend(page, { articleStatus = 'detected' } = {}) {
const article = articleStatus === 'approved' ? ARTICLE_APPROVED : ARTICLE_DETECTED;
async function mockBackend(page, { articleStatus = 'detected', hasAudio = false, progress = {} } = {}) {
// Each test starts from the article list. A previous test may deliberately
// switch to page thumbnails, and that preference is persisted by the app.
await page.addInitScript(() => localStorage.removeItem('ttsvc_mag_contents_view'));
const base = articleStatus === 'approved' ? ARTICLE_APPROVED : ARTICLE_DETECTED;
const article = { ...base, has_audio: hasAudio, audio_seconds: hasAudio ? 2 : 0 };
await page.route('**/api/reader/docs', route => route.fulfill({
json: { docs: [{ id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', ingest: { status: 'done', articles: 1 } }] },
json: { docs: [{ id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', pageCount: 8, ingest: { status: 'done', articles: 1 } }] },
}));
await page.route(`**/api/reader/docs/${DOC_ID}`, route => route.fulfill({
json: { id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', ingest: { status: 'done', articles: 1 } },
json: { id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', pageCount: 8, ingest: { status: 'done', articles: 1 } },
}));
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({
json: { articles: [article], progress: {}, default_voice: '', podcast: { hosts: [] } },
json: { articles: [article], progress, default_voice: '', podcast: { hosts: [] } },
}));
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: LAYOUT }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => {
@ -68,17 +72,63 @@ async function mockBackend(page, { articleStatus = 'detected' } = {}) {
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({
json: { article, blocks: LAYOUT.blocks },
}));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => route.fulfill({
body: silentWav(2000), contentType: 'audio/wav',
}));
await page.route(`**/api/magazine/${DOC_ID}/progress`, route => route.fulfill({ json: { ok: true } }));
await page.route(`**/api/magazine/${DOC_ID}/pages/*`, route => route.fulfill({
// 1x1 PNG — only geometry/wiring is under test, not real page rendering.
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'),
contentType: 'image/png',
}));
await page.route('**/api/voices*', route => route.fulfill({
json: [{ id: 'smoke_voice', name: 'Smoke voice', enabled: true }],
json: [{ id: 'smoke_voice', name: 'Smoke voice', enabled: true, tag: 'Magazine' }],
}));
}
test.describe('Magazine reader', () => {
test('review region header selects and clears every region on the page', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: 'Review', exact: true }).click();
const all = page.locator('[data-mag-check-all]').first();
await expect(all).toBeChecked();
await all.uncheck();
await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(0);
await page.locator('[data-mag-check-all]').first().check();
await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(3);
});
test('generated MP3 playlist shows status and synchronized article text', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true, progress: { 'article-001': { seconds: 0.5 } } });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Playlist' }).click();
await expect(page.locator('.mag-playlist-track')).toContainText('p. 3');
await expect(page.locator('.mag-playlist-track')).toContainText('The Future of Batteries');
await expect(page.locator('.mag-playlist-track')).toHaveClass(/is-in-progress/);
await page.locator('.mag-playlist-track-play').click();
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(4);
await expect(page.locator('#mag-playlist-companion')).toBeVisible();
await page.locator('#mag-driving-open').click();
await expect(page.locator('#mag-driving')).toBeVisible();
await expect(page.locator('#mag-driving-title')).toHaveText('The Future of Batteries');
await page.locator('#mag-driving .mag-driving-exit').click();
const exportPicker = page.locator('.vp-root[data-vp-for="mag-audio-voice"]');
await exportPicker.locator('.vp-trigger').click();
await exportPicker.locator('.vp-search').fill('magazine');
await expect(exportPicker.locator('.vp-item[data-value="smoke_voice"]')).toBeVisible();
});
test('library lists a magazine and opens its contents tree', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
@ -107,8 +157,8 @@ test.describe('Magazine reader', () => {
await page.getByRole('button', { name: 'Review' }).click();
await expect(page.locator('#mag-view-review')).toBeVisible();
await expect(page.getByRole('group', { name: 'Review pages' })).toContainText('Page 3');
await expect(page.locator('.mag-review-pagenav').getByRole('button', { name: 'Draw image', exact: true })).toBeVisible();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3');
await expect(page.locator('.mag-review-pagenav').getByRole('button', { name: 'Draw region', exact: true })).toBeVisible();
// The article's own blocks (b1-b3) start ticked; nothing else does.
await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(3);
@ -173,6 +223,8 @@ test.describe('Magazine reader', () => {
await picker.locator('.vp-trigger').click();
await picker.locator('.vp-search').fill('zzz-no-such-voice');
await expect(picker.locator('.vp-empty')).toHaveText('No voices found');
await picker.locator('.vp-search').fill('magazine');
await expect(picker.locator('.vp-item[data-value="smoke_voice"]')).toBeVisible();
await picker.locator('.vp-search').fill('smoke');
await picker.locator('.vp-item[data-value="smoke_voice"]').click();
await expect(page.locator('#mag-voice')).toHaveValue('smoke_voice');
@ -268,7 +320,7 @@ test('drawing a chart links the crop and excludes enclosed narration on approval
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Review', exact: true }).click();
await page.getByRole('button', { name: 'Draw image', exact: true }).click();
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
await page.locator('#mag-review-page-wrap').evaluate(el => {
el.style.width = '400px';
el.scrollIntoView();
@ -278,7 +330,9 @@ test('drawing a chart links the crop and excludes enclosed narration on approval
await page.mouse.down();
await page.mouse.move(wrap.x + wrap.width * 260 / 595, wrap.y + wrap.height * 460 / 842, { steps: 5 });
await page.mouse.up();
await expect(page.locator('input[name="mag-draw-kind"][value="image"]')).toBeChecked();
await page.locator('#mag-draw-kind-search').fill('Image');
await page.locator('#mag-draw-kind-list').getByRole('option', { name: 'Image', exact: true }).click();
await expect(page.locator('#mag-draw-kind')).toHaveValue('image');
await expect(page.locator('#mag-draw-text')).toBeHidden();
await page.getByRole('button', { name: 'Add region', exact: true }).click();
await expect(page.locator('[data-block-row="manual-chart"]')).toContainText('Shown during playback');
@ -286,7 +340,7 @@ test('drawing a chart links the crop and excludes enclosed narration on approval
await expect(page.locator('#mag-review input[data-block="b2"]')).not.toBeChecked();
await expect(page.locator('#mag-review input[data-block="b3"]')).not.toBeChecked();
const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
await page.getByRole('button', { name: 'Approve', exact: true }).click();
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
expect((await request).postDataJSON()).toMatchObject({ images: ['manual-chart'], blocks: ['b1'] });
});
@ -357,7 +411,7 @@ test('every type the server knows is offered, image included', async ({ page })
const values = await page.locator('[data-block-row="b3"] select.mag-block-type-select option').evaluateAll(
options => options.map(o => o.value));
expect(values).toEqual(['body', 'title', 'heading', 'deck', 'byline', 'caption', 'pullquote', 'sidebar',
'page_number', 'source', 'ad', 'in_picture', 'ocr_noise', 'duplicate', 'image']);
'page_number', 'source', 'ad', 'in_picture', 'ocr_noise', 'duplicate', 'table', 'image']);
});
test('each review row has its own delete button, which deletes at once and offers undo', async ({ page }) => {
@ -566,7 +620,7 @@ test('approving after turning a ticked text box into an image sends the new imag
await page.locator('[data-block-row="b3"] select.mag-block-type-select').selectOption('image');
await expect(page.locator('[data-block-row="p0003-manual-000"]')).toBeVisible();
const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
await page.getByRole('button', { name: 'Approve', exact: true }).click();
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
const body = (await request).postDataJSON();
expect(body.blocks).not.toContain('b3');
expect(body.images).toContain('p0003-manual-000');
@ -582,7 +636,7 @@ test('approving after deleting a ticked box does not send its id', async ({ page
await page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ }).click();
await expect(page.locator('[data-block-row="b3"]')).toHaveCount(0);
const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
await page.getByRole('button', { name: 'Approve', exact: true }).click();
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
expect((await request).postDataJSON().blocks).not.toContain('b3');
});

View File

@ -66,12 +66,14 @@ from core.magazine import (
parse_contents,
parse_unlimited_ocr_regions,
parse_vision_ocr_regions,
prefer_drop_cap_repair,
score_contents_page,
vision_ocr_page,
vision_ocr_page_chain,
describe_visual_region,
describe_visual_region_chain,
BLOCK_FIGURE_DESCRIPTION,
interview_tts_segments,
)
PAGE_W, PAGE_H = 595.0, 842.0 # A4 in points
@ -82,6 +84,22 @@ def block(bid, page, x, y, w, h, text, size=10.0, btype=BLOCK_BODY):
text=text, font_size=size)
class DropCapRepairTests(unittest.TestCase):
def test_restores_omitted_first_line_and_joins_detached_initial(self):
old = ("tert, dass es fast ein Garant für einen sicheren, gut bezahlten Bürojob ist, "
"wenn man programmieren kann. Wer vor vier Jahren angefangen hat, Informatik zu studieren.")
local = ("J ahrelang wurde jungen Menschen eingetrichtert, dass es fast ein Garant für "
"einen sicheren, gut bezahlten Bürojob ist, wenn man programmieren kann. Wer vor "
"vier Jahren angefangen hat, Informatik zu studieren.")
repaired = prefer_drop_cap_repair(old, local)
self.assertTrue(repaired.startswith("Jahrelang wurde jungen Menschen eingetrichtert,"))
def test_does_not_replace_a_lowercase_continuation_with_unrelated_ocr(self):
old = ("und damit setzt sich der Gedanke auf der nächsten Seite fort. " * 4).strip()
unrelated = ("Andere Spalte mit vollständig anderem Inhalt und ganz anderen Worten. " * 4).strip()
self.assertEqual(prefer_drop_cap_repair(old, unrelated), old)
class ColumnGeometryTests(unittest.TestCase):
def test_two_columns_detected_from_the_gutter(self):
blocks = [
@ -656,6 +674,39 @@ class ArticleGroupingTests(unittest.TestCase):
arts = group_articles(blocks, [], page_count=40, skip_pages={2})
self.assertTrue(all(a.pages[0] != 2 for a in arts))
def test_internal_heading_on_continuation_page_does_not_split_article(self):
blocks = self._issue_blocks()[:3]
continuation = block("b0019-000", 19, 50, 60, 200, 100,
"Continued body copy. " * 20)
subheading = block("b0019-001", 19, 50, 180, 300, 35,
"A Large Internal Heading", size=22, btype=BLOCK_TITLE)
more = block("b0019-002", 19, 50, 230, 200, 200,
"More body copy. " * 20)
continuation.reading_order, subheading.reading_order, more.reading_order = 0, 1, 2
blocks.extend([continuation, subheading, more])
arts = group_articles(blocks, [], page_count=19)
self.assertEqual(len(arts), 1)
self.assertEqual(arts[0].pages, [18, 19])
class InterviewVoiceTests(unittest.TestCase):
def test_detects_exactly_two_recurring_speaker_labels(self):
blocks = [
{"text": "Anna Meyer: Wie verändert KI unsere Arbeit?"},
{"text": "Tom Schulz: Sie automatisiert Routineaufgaben."},
{"text": "Anna Meyer: Wo liegen die Grenzen?"},
{"text": "Tom Schulz: Verantwortung bleibt beim Menschen."},
]
segments = interview_tts_segments(blocks)
self.assertEqual([s["speaker"] for s in segments],
["Anna Meyer", "Tom Schulz", "Anna Meyer", "Tom Schulz"])
self.assertEqual(segments[0]["text"], "Wie verändert KI unsere Arbeit?")
def test_a_colon_in_normal_prose_does_not_trigger_interview_mode(self):
blocks = [{"text": "Das Ergebnis: Die Leistung steigt."},
{"text": "Ein zweiter gewöhnlicher Absatz."}]
self.assertTrue(all(s["speaker"] is None for s in interview_tts_segments(blocks)))
class OcrQualityScoreTests(unittest.TestCase):
"""Grounded in real output from both OCR paths on a scanned c't magazine.
@ -805,6 +856,52 @@ class VisionBlockBuildingTests(unittest.TestCase):
self.assertTrue(groups[0]['continues_from_previous_page'])
self.assertEqual(groups[1]['type'], 'body')
def test_semantic_paragraph_continuing_across_columns_keeps_two_boxes(self):
"""A logical paragraph can end in the left column and continue in
the right. Its bbox must not become the union covering both columns
and every unrelated paragraph between those two pieces (real pages
111 and 114 of c't KI Wissen 2026)."""
from core.magazine import apply_semantic_regions
blocks = [
Block('left', 1, [30, 500, 200, 100], 'body',
'The paragraph starts in the left column and ends with').to_json(),
Block('right', 1, [250, 80, 200, 80], 'body',
'the text at the top of the right column.').to_json(),
Block('left-other', 1, [30, 80, 200, 380], 'body',
'Unrelated paragraphs in the left column.').to_json(),
Block('right-other', 1, [250, 200, 200, 300], 'body',
'Unrelated paragraphs in the right column.').to_json(),
]
grouped = apply_semantic_regions(blocks, [
{'type': 'paragraph', 'block_ids': ['left', 'right']},
{'type': 'paragraph', 'block_ids': ['left-other']},
{'type': 'paragraph', 'block_ids': ['right-other']},
], {'page': 1, 'width': 480.0, 'height': 700.0})
continued = [b for b in grouped if b['id'] in {'left', 'right'}]
self.assertEqual([b['id'] for b in continued], ['left', 'right'])
self.assertEqual([b['bbox'] for b in continued],
[[30, 500, 200, 100], [250, 80, 200, 80]])
self.assertEqual([b['text'] for b in continued],
['The paragraph starts in the left column and ends with',
'the text at the top of the right column.'])
def test_semantic_fragments_still_merge_within_each_physical_column(self):
from core.magazine import apply_semantic_regions
blocks = [
Block('l1', 1, [30, 100, 200, 40], 'body', 'Left one').to_json(),
Block('l2', 1, [30, 145, 200, 40], 'body', 'left two').to_json(),
Block('r1', 1, [250, 100, 200, 40], 'body', 'Right one').to_json(),
Block('r2', 1, [250, 145, 200, 40], 'body', 'right two').to_json(),
]
grouped = apply_semantic_regions(blocks, [
{'type': 'paragraph', 'block_ids': ['l1', 'l2', 'r1', 'r2']},
], {'page': 1, 'width': 480.0, 'height': 700.0})
self.assertEqual(len(grouped), 2)
self.assertEqual([b['bbox'] for b in grouped],
[[30, 100, 200, 85], [250, 100, 200, 85]])
self.assertEqual([b['text'] for b in grouped],
['Left one left two', 'Right one right two'])
def test_semantic_group_cannot_absorb_editorial_into_advert(self):
from core.magazine import apply_semantic_regions
blocks = [Block('a', 1, [0,0,100,20], 'body', 'Editorial').to_json(),
@ -1637,7 +1734,7 @@ class ManualRegionTests(unittest.TestCase):
def test_bad_kind_raises(self):
with self.assertRaises(ValueError):
add_manual_region(self.doc_dir, self.layout, 3, [10, 10, 50, 50], "table")
add_manual_region(self.doc_dir, self.layout, 3, [10, 10, 50, 50], "unsupported")
def test_manual_text_with_typed_content_is_used_verbatim(self):
out = add_manual_region(self.doc_dir, self.layout, 3, [50, 200, 200, 40],

View File

@ -120,6 +120,26 @@ class ReviewImageTests(unittest.IsolatedAsyncioTestCase):
save.assert_not_called()
class MagazinePlaybackProgressTests(unittest.IsolatedAsyncioTestCase):
async def test_completed_audio_progress_keeps_duration_and_resume_position(self):
from unittest.mock import AsyncMock, patch
from routes.magazine import magazine_progress
meta = {"kind": "magazine", "progress": {}}
request = AsyncMock()
request.json.return_value = {
"article": "article-001", "seconds": 73.5,
"duration": 73.5, "completed": True,
}
with patch('routes.magazine._require_magazine', return_value=(Path('/tmp/doc'), meta)), \
patch('routes.magazine._write_meta') as write:
result = await magazine_progress('a' * 32, request)
self.assertEqual(result, {"ok": True})
self.assertEqual(meta["progress"]["article-001"]["seconds"], 73.5)
self.assertEqual(meta["progress"]["article-001"]["duration"], 73.5)
self.assertTrue(meta["progress"]["article-001"]["completed"])
write.assert_called_once()
class AdvertisementReviewTests(unittest.TestCase):
def test_llm_marks_only_known_confident_advertising_regions(self):
from unittest.mock import Mock, patch

View File

@ -0,0 +1,40 @@
import unittest
from core.pronunciations import (
expand_abbreviations,
normalize_ocr_text,
prepare_text_for_tts,
remove_linebreak_hyphens,
)
class PronunciationTests(unittest.TestCase):
def test_fuses_printed_line_break_but_keeps_real_compound(self):
self.assertEqual(remove_linebreak_hyphens("program-\nmieren mit einem KI-Modell"),
"programmieren mit einem KI-Modell")
self.assertEqual(remove_linebreak_hyphens("Hardware- und Software"),
"Hardware- und Software")
self.assertEqual(remove_linebreak_hyphens("einge\u00adtrichtert"), "eingetrichtert")
def test_existing_flattened_ocr_layouts_are_repaired_too(self):
self.assertEqual(remove_linebreak_hyphens("eingetrich- tert"), "eingetrichtert")
def test_german_umlaut_and_acronyms_are_prepared_for_speech(self):
spoken = prepare_text_for_tts(
"KI wird jahrlich wichtiger und APIs sind naturlich verfügbar.", "de")
self.assertIn("Künstliche Intelligenz", spoken)
self.assertIn("jährlich", spoken)
self.assertIn("Programmierschnittstellen", spoken)
self.assertIn("natürlich", spoken)
def test_english_acronyms_use_english_expansions(self):
self.assertEqual(expand_abbreviations("AI uses an API.", "en"),
"artificial intelligence uses an application programming interface.")
def test_unknown_technical_words_and_names_stay_unchanged(self):
text = "OpenAI und PyMuPDF bleiben unverändert."
self.assertEqual(normalize_ocr_text(text, "de"), text)
if __name__ == "__main__":
unittest.main()