This commit is contained in:
parent
ba3b374246
commit
b1c0197652
20
CHANGELOG.md
20
CHANGELOG.md
@ -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
21
TODO.md
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
270
core/magazine.py
270
core/magazine.py
@ -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
81
core/pronunciations.json
Normal 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
134
core/pronunciations.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
|
||||
|
||||
|
||||
6
static/dist/main.min.js
vendored
6
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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>
|
||||
|
||||
@ -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 & 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';
|
||||
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 };
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@ -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); }
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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
|
||||
|
||||
40
tests/test_pronunciations.py
Normal file
40
tests/test_pronunciations.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user