diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0dfa99b..81aa5e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,30 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
+### Added
+- **Driving mode is now a podcast-style player.** Back/forward 15 seconds, a seek bar, a playback-speed button (0.75×–2×, remembered), and toggles to show or hide the current text (with neighbouring lines) and the article's images.
+- **Pages can be approved as advertisements.** One click on the thumbnail or in review marks the page green, excludes everything on it from narration and from the quality check, and is reversible (`PUT/DELETE /api/magazine/{id}/pages/{page}/advertisement`).
+- **Whole-magazine quality check.** The "Quality check / run" button (first in the contents toolbar) runs, per page: missed-text recovery, guarded OCR re-reading, and a pixel audit that reports ink outside every box, overlapping text boxes, and boxes clipping the first/last letters of a column. Pages that still need attention are listed with links into review; the report is saved as `quality_report.json`. Thumbnail cards gain quality-stage colours (clean = blue, problems = purple overriding review colours, with a clickable warning badge on the thumbnail listing what was found) and a live per-page indicator while the run works. The same indicator now covers every batch step (OCR/ingest, paragraph and advertisement recognition, figure fixing, audio rendering); each background job reports `{page|pages, step}` in its result.
+
+---
+
+## [1.53.5] — 2026-09-25
+
+### Added
+- **Magazine review now has two conservative OCR quality checks.** “Find missed text” uses an independent local pass to add only genuinely absent, non-overlapping regions; “Verify OCR text” uses language-aware spell checking only to flag suspicious regions and changes text only after a guarded re-read of the real page crop.
+- **Whole-magazine figure-boundary repair is available as an explicit batch action** in the contents view, while the review-page action is now page-scoped.
+
+### Changed
+- **Magazine TTS expands known abbreviations without changing displayed OCR text.** In German, `KI` remains visible as printed but is spoken as “Künstliche Intelligenz.”
+- **Figure crops use pixel-level edge refinement for dense images.** Clear image boundaries are tight on all four sides instead of retaining coarse-grid paper margins on only some edges.
+
+### Fixed
+- **Approve & Next advances to the immediately following physical page.** Page navigation also switches to the article that owns the visible page (or creates a page-scoped placeholder), preventing multi-page articles from being skipped and preventing approval from mutating the wrong article.
+- **Drop caps no longer introduce spaces or lose an entire opening line.** Split initials such as `S prechende`, `K I`, `A m`, and `B i s` are joined, and guarded second reads restore article-opening text even when the surviving remainder begins with a valid capitalized word.
+- **Semantic layout no longer creates giant paragraph boxes across two columns**, reorders run-on byline fragments, or labels ordinary lower-page paragraphs as captions when no figure is nearby.
+- **Narrow pull quotes between columns remain separate regions**, and figure/advertisement cleanup is less likely to absorb surrounding editorial text.
+- **All eight resize handles now have the same appearance.** The delete control no longer covers the north-east resize handle.
+
---
## [1.53.4] — 2026-09-24
diff --git a/Manual.md b/Manual.md
index e0d2432..4748e23 100644
--- a/Manual.md
+++ b/Manual.md
@@ -247,8 +247,13 @@ turned into a podcast discussion between two or three voices you pick.
generated during review spoken aloud where they occur in the article;
figures without a generated description are silently skipped, exactly as
before this option existed. **Driving mode**
- (the button next to the article title) switches to two large controls and
- nothing else, for listening without looking at the screen.
+ (the button next to the article title) is a full-screen, podcast-style player
+ for the generated MP3s: previous/next article, **back 15 s / forward 15 s**,
+ large play/pause, a seek bar, a **speed** button (0.75×–2×, remembered), and
+ **Text** / **Images** toggles that show or hide the sentence being read (with
+ the lines before and after) and the article's current picture. Everything is a
+ large touch target and keyboard reachable; the speed also applies to the
+ playlist player.
**Expected result:** the article plays from its headline to its final paragraph.
Page furniture (running heads, page numbers), adverts, sidebars and pull quotes
@@ -278,6 +283,18 @@ the original for anything that matters.
Podcast generation is refused for an article you have not approved, because
unreviewed boundaries may include a neighbouring article or an advert.
+### Quality check / run (whole magazine)
+
+**Purpose:** find every page where text is not inside a box, boxes overlap or cut off letters, or OCR still looks wrong.
+**Prerequisites:** the magazine has been ingested. A vision OCR engine (Settings) is optional; without it the word re-reading step is skipped and the result says so.
+**Steps:** on the contents screen press **"Quality check / run"** (first button in the toolbar) and confirm. Progress shows "Checking page N of M".
+**Result:** the run (1) adds text regions that independent OCR found outside every box (unticked, so nothing new is read aloud until you approve it), (2) re-reads suspicious words from the page image and only accepts a clearly better reading, (3) measures the page image for ink outside every box, overlapping text boxes, and boxes whose edge cuts through text. Pages that still need a look are listed as buttons under the toolbar; each opens that page in review.
+**Errors:** one page failing does not stop the run; it is listed as "check failed". The report is kept in the magazine's folder (`quality_report.json`) and served at `GET /api/magazine/{id}/quality/report`.
+**Page colours and live indicator:** in the thumbnail grid, pages the run found clean turn blue and pages with problems turn purple, whatever their review state (only a page whose every article was rejected keeps its grey). A purple warning badge with the problem count sits on the thumbnail; hover or focus it to read what was found, click it to open the page in review. The colour reflects the last run — run the check again after fixing pages. While the run is going, the page being worked on pulses and names its step (finding missed text / re-reading suspicious words / measuring box coverage), and finished pages show "✓ checked". Pulsing is disabled under reduced-motion. The same live indicator works for every batch step while the thumbnail grid is showing: reading pages (OCR), finding advertisements / recognising paragraphs during detection, fixing all figure boundaries, and rendering audio (the pages of the article being rendered pulse with "rendering audio n/total").
+**Advertisement pages:** press **Advertisement** on a thumbnail (or **Approve page as advertisement** in review) to approve a whole page as an ad. The page turns green with a dashed border, nothing on it is read aloud (even if an article claims it), and the quality check skips it. Press the button again to undo; no boxes or articles are changed.
+**Mobile / accessibility:** the list is a labelled live region of ordinary buttons, keyboard reachable.
+**Offline / privacy:** the missed-text pass uses local OCR; the word re-read uses only the vision engine you configured. Nothing else leaves the machine.
+
### Errors
| What you see | What happened | What to do |
@@ -308,8 +325,8 @@ stack and sends nothing outward.
Sentence and word highlighting both respect `prefers-reduced-motion` (no
smooth-scroll animation when it is set; the highlight itself is a colour
-change, not motion). Driving mode gives two large, high-contrast controls
-(skip, play/pause) with nothing else to interact with, for use without
+change, not motion). Driving mode gives large, high-contrast controls
+(skip, ±15 s, play/pause, speed) — hide Text and Images for a bare screen — for use without
looking at the screen — it does not, and should not, replace watching the
road. Word-level highlighting can be switched off in the reading controls if
it is more distracting than helpful; sentence highlighting keeps working
diff --git a/TODO.md b/TODO.md
index 09d8b50..0b2cf7e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -687,3 +687,11 @@ shortcuts when appearance quality matters.
content that's no longer recognised as one). Fix: when the deleted id is a block with a
crop derived from it, also remove the `p{page}-region-{id}` image entry, delete its file,
and strip that derived id (not just the block id) from every article's `images` list.
+
+- [ ] **Audiobook character image runs must be isolated per character.** Before generating
+ a new character's profile image or character sheet, clear/reset every generation field,
+ reference image and transient conditioning value inherited from the preceding character.
+ Profile and sheet images for the *same* character should continue to share identity, but
+ no appearance traits (for example dwarf stature) may leak into the next character. Add a
+ regression test that generates two deliberately dissimilar characters consecutively and
+ verifies that the second request contains only its own identity/features/references.
diff --git a/VERSION b/VERSION
index d2a66da..288c3c0 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.53.4
+1.53.5
diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md
index f239c1b..a9639ed 100644
--- a/architecture/magazine-ingestion.md
+++ b/architecture/magazine-ingestion.md
@@ -689,3 +689,21 @@ Magazines, Settings, or any future section.
comparison above is one confirmed live test, not a systematic evaluation.
- Image descriptions, table/chart handling, and EPUB 3 Media Overlay export
remain P2 and are not started.
+
+## Whole-issue quality run
+
+`POST /api/magazine/{id}/quality/run` starts a background job (`_quality_run_sync`) that,
+per page, calls `recover_missing_text`, `verify_ocr_recognition` (only when a vision engine
+is configured) and `audit_page_coverage`. The audit is read-only: it reports `uncovered`
+ink blobs (page-point boxes outside every block/image box, edge margins and dust ignored),
+`overlaps` (text boxes sharing >=30% of the smaller box), `clipped` (ink in the cell column
+just outside a text box's left/right edge) and `suspect_blocks`. Layout is saved every five
+pages; the report goes to `quality_report.json` and `GET .../quality/report`. Added regions
+stay unticked. Thresholds are the `_AUDIT_*` constants in `core/magazine.py`.
+
+## Advertisement pages
+
+`layout.pages[n].advertisement = true` (set via `PUT /pages/{page}/advertisement`, cleared with
+`DELETE`) is a non-destructive approval: `article_text` drops every block and image description
+on such a page, and the quality run records the page as `advertisement` without checking it.
+Regions and articles are untouched, so the flag is fully reversible.
diff --git a/core/magazine.py b/core/magazine.py
index e9387d7..f60bb47 100644
--- a/core/magazine.py
+++ b/core/magazine.py
@@ -38,7 +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 core.pronunciations import normalize_ocr_text, prepare_text_for_tts, suspect_ocr_tokens
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any, Iterable, Sequence
@@ -52,6 +52,7 @@ BLOCK_HEADING = "heading"
BLOCK_DECK = "deck" # standfirst / subtitle under the headline
BLOCK_BYLINE = "byline"
BLOCK_BODY = "body"
+BLOCK_QUOTE = "quote" # quoted speech that belongs in narration
BLOCK_CAPTION = "caption"
BLOCK_PULLQUOTE = "pullquote" # verbatim repeat of body text, blown up
BLOCK_SIDEBAR = "sidebar"
@@ -63,7 +64,7 @@ BLOCK_TABLE = "table"
BLOCK_CODE = "code"
VISUAL_TYPES = (BLOCK_FIGURE, BLOCK_CHART, BLOCK_TABLE, BLOCK_CODE)
-NARRATABLE = (BLOCK_TITLE, BLOCK_HEADING, BLOCK_DECK, BLOCK_BYLINE, BLOCK_BODY)
+NARRATABLE = (BLOCK_TITLE, BLOCK_HEADING, BLOCK_DECK, BLOCK_BYLINE, BLOCK_BODY, BLOCK_QUOTE)
# Text that marks an article jumping to (or arriving from) another page.
_CONT_TO = re.compile(
@@ -905,7 +906,8 @@ _VISION_LABEL_TYPES = {
"footnotes": BLOCK_SIDEBAR,
"note": BLOCK_SIDEBAR,
"commentary": BLOCK_SIDEBAR,
- "quote": BLOCK_PULLQUOTE,
+ "quote": BLOCK_QUOTE,
+ "blockquote": BLOCK_QUOTE,
"pullquote": BLOCK_PULLQUOTE,
# Page numbers / Furniture / Folios / Sources
@@ -1279,18 +1281,37 @@ def vision_ocr_page_chain(png_path: Path,
_LOWERCASE_REGION_START = re.compile(r"^[a-zäöüß]")
_DETACHED_DROP_CAP = re.compile(r"^([A-ZÄÖÜ])\s+([a-zäöüß]{3,})\b")
+_SPLIT_KI_DROP_CAP = re.compile(r"^K\s+[Il1](?=\s|[.,;:!?])")
+_SHORT_DETACHED_DROP_CAPS = (
+ (re.compile(r"^A\s+m\b"), "Am"),
+ (re.compile(r"^B\s+i\s+s\b"), "Bis"),
+)
+
+
+def _join_detached_drop_cap(text: str) -> str:
+ """Join OCR fragments caused by an oversized paragraph initial.
+
+ ``KI`` needs its own rule: OCR commonly returns the ordinary-sized I as
+ ``I``, ``l`` or ``1`` after a separately detected drop-cap K. Treat that
+ narrow, word-bounded shape as the acronym, while leaving ordinary phrases
+ such as ``K ist`` alone. The short-word rules cover confirmed document
+ cases that the deliberately conservative 3-letter remainder rule cannot.
+ """
+ joined = _SPLIT_KI_DROP_CAP.sub("KI", text, count=1)
+ for pattern, replacement in _SHORT_DETACHED_DROP_CAPS:
+ joined = pattern.sub(replacement, joined, count=1)
+ return _DETACHED_DROP_CAP.sub(r"\1\2", joined, count=1)
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.
+ object, then begin the paragraph transcription later. The remainder may be
+ a lower-case word fragment (``tert, dass`` in ``eingetrichtert, dass``) or
+ a valid capitalised word after an entire omitted line (``Herrn, verwaltet``).
+ A second reading is accepted only when most saved text still appears in it;
+ capitalised remainders additionally require a substantial multi-word prefix.
"""
old = " ".join((original or "").split())
# Tesseract often emits the ordinary first-line letters *before* the
@@ -1302,8 +1323,9 @@ def prefer_drop_cap_repair(original: str, candidate: str) -> str:
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():
+ new = _join_detached_drop_cap(new)
+ old_starts_lower = bool(_LOWERCASE_REGION_START.match(old))
+ if not old or not new 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
@@ -1318,10 +1340,22 @@ def prefer_drop_cap_repair(original: str, candidate: str) -> str:
# 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()
+ # A valid capitalised remainder can hide a whole omitted first line
+ # ("Herrn, verwaltet ..." after a large initial E). For that shape,
+ # accept only a substantial multi-word prefix. The older lowercase case
+ # may legitimately restore only the tail of one split word.
+ if not old_starts_lower and (len(prefix) < 8 or len(prefix.split()) < 2):
+ return old
if prefix.endswith("-"):
prefix = prefix[:-1] # printed line-break hyphen: eingetrich- + tert
return prefix + old
- joins_word = prefix[-1:].isalnum() and old[:1].isalnum()
+ # Join only when the matched remainder begins inside a word in the
+ # candidate. If it begins after whitespace, the missing material was a
+ # complete word/line (``Skripte | schreiben``), not a split word
+ # (``eingetrich|tert``).
+ joins_word = (old_starts_lower and first_substantial.b > 0
+ and new[first_substantial.b - 1:first_substantial.b].isalnum()
+ and old[:1].isalnum())
return prefix + ("" if joins_word or old[:1] in ",.;:!?" else " ") + old
@@ -1333,18 +1367,46 @@ def repair_drop_cap_omissions(png_path: Path, regions: list[dict[str, Any]],
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
+ repaired = 0
+ for region in regions:
+ if region.get("label") not in {"text", "paragraph", "body"}:
+ continue
+ text = " ".join((region.get("text") or "").split())
+ # The primary OCR can see both pieces but preserve the visual gap
+ # after an oversized initial ("S prechende"). Join it here, before
+ # the lower-case omission check below; previously this rule only ran
+ # on the secondary Tesseract reading and missed that common result.
+ joined = _join_detached_drop_cap(text)
+ if joined != text:
+ region["text"] = joined
+ repaired += 1
+
+ def follows_article_header(index: int, region: dict[str, Any]) -> bool:
+ """Whether this body box begins just below a title/deck/byline."""
+ x, y, w, _ = (float(v) for v in region.get("bbox", [0, 0, 0, 0]))
+ for previous in regions[:index]:
+ if previous.get("label") not in {
+ "title", "headline", "author", "byline", "deck", "standfirst"}:
+ continue
+ px, py, pw, ph = (float(v) for v in previous.get("bbox", [0, 0, 0, 0]))
+ horizontal = max(0.0, min(x + w, px + pw) - max(x, px))
+ gap = y - (py + ph)
+ if horizontal >= 0.3 * max(1.0, min(w, pw)) and -10 <= gap <= 250:
+ return True
+ return False
+
+ suspicious = [r for i, r in enumerate(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())]
+ and (_LOWERCASE_REGION_START.match((r.get("text") or "").strip())
+ or follows_article_header(i, r))]
if not suspicious:
- return 0
+ return repaired
try:
from PIL import Image
image = Image.open(png_path)
except Exception:
- return 0
- repaired = 0
+ return repaired
try:
for region in suspicious:
x, y, w, h = (float(v) for v in region["bbox"])
@@ -1521,11 +1583,88 @@ def build_vision_blocks(regions: list[dict[str, Any]], page_no: int,
pass for finer distinctions such as byline or deck.
"""
out = []
+ # Vision OCR occasionally labels a whole text column (or an advert
+ # containing text) as an image as well as returning the actual text
+ # regions. Keeping both produces the large, overlapping boxes users see
+ # after re-OCR. Text detections are the more useful source of truth in
+ # that case; retain a visual region only when it has meaningful area not
+ # covered by a text region.
+ scaled = [
+ (region, tuple(v * scale_x for v in region["bbox"]))
+ for region in regions
+ ]
+ text_boxes = [
+ box for region, box in scaled
+ if _VISION_LABEL_TYPES.get(region.get("label")) not in VISUAL_TYPES
+ and region.get("text", "").strip()
+ ]
+
+ def covered_by_text(box: tuple[float, float, float, float]) -> bool:
+ x, y, w, h = box
+ area = max(0.0, w) * max(0.0, h)
+ if area <= 0:
+ return True
+ covered = 0.0
+ for tx, ty, tw, th in text_boxes:
+ covered += max(0.0, min(x + w, tx + tw) - max(x, tx)) * \
+ max(0.0, min(y + h, ty + th) - max(y, ty))
+ return covered / area >= 0.35
+
+ # Some vision responses emit a broad ``image`` box for a complete column
+ # and a tighter figure box for the actual photograph in that column. The
+ # broad box is not useful as an image crop and can hide the text beneath
+ # it in the review UI. Prefer the contained, tighter visual detection.
+ visual_boxes = [
+ (idx, box) for idx, (region, box) in enumerate(scaled)
+ if _VISION_LABEL_TYPES.get(region.get("label")) in VISUAL_TYPES
+ ]
+
+ def contains(outer: tuple[float, float, float, float],
+ inner: tuple[float, float, float, float]) -> bool:
+ ox, oy, ow, oh = outer
+ ix, iy, iw, ih = inner
+ return (ix >= ox and iy >= oy and ix + iw <= ox + ow
+ and iy + ih <= oy + oh)
+
+ shadowed_visuals = {
+ outer_idx for outer_idx, outer in visual_boxes
+ if any(outer_idx != inner_idx and contains(outer, inner)
+ and inner[2] * inner[3] <= outer[2] * outer[3] * 0.65
+ for inner_idx, inner in visual_boxes)
+ }
+ visual_regions = [box for idx, box in visual_boxes
+ if idx not in shadowed_visuals and not covered_by_text(box)]
+
+ # OCR often transcribes labels printed inside a screenshot as ordinary
+ # paragraphs. Keep the screenshot as one image region; those inner
+ # detections must not become separately narrated article text.
+ contained_text = {
+ idx for idx, (region, box) in enumerate(scaled)
+ if _VISION_LABEL_TYPES.get(region.get("label")) not in VISUAL_TYPES
+ and any(contains(visual, box) for visual in visual_regions)
+ }
+
for i, region in enumerate(regions):
x, y, w, h = (v * scale_x for v in region["bbox"])
label = region["label"]
text = region["text"]
mapped = _VISION_LABEL_TYPES.get(label)
+ if i in shadowed_visuals:
+ continue
+ if i in contained_text and mapped not in (BLOCK_CAPTION, BLOCK_FURNITURE):
+ continue
+ if mapped in VISUAL_TYPES and h > w * 1.25:
+ # A common page-136 response extends the photo box through its
+ # caption. When OCR also returned that caption, stop the image
+ # at the first strongly overlapping text box in its lower half.
+ for tx, ty, tw, th in text_boxes:
+ overlap = max(0.0, min(x + w, tx + tw) - max(x, tx))
+ if (overlap >= 0.55 * min(w, tw)
+ and ty > y + h * 0.35 and ty < y + h * 0.9):
+ h = max(1.0, ty - y)
+ break
+ if mapped in VISUAL_TYPES and covered_by_text((x, y, w, h)):
+ continue
if mapped is not None:
block = Block(
id=f"{id_prefix}{page_no:04d}-v{i:03d}",
@@ -1892,6 +2031,67 @@ def _cell_overlap(a: Sequence[int], b: Sequence[int]) -> int:
* max(0, min(a[3], b[3]) - max(a[1], b[1]) + 1))
+def _refine_dense_visual_edges(png_path: Path, box: Sequence[int],
+ page_w: float, page_h: float) -> list[float] | None:
+ """Resolve the coarse grid's sub-cell ambiguity for dense photographs.
+
+ The grid safely finds the right graphic, but a ten-pixel cell can leave
+ paper on whichever edges the true boundary crosses mid-cell. That creates
+ the visibly mixed crop (margin on left/bottom, none on top/right). Dense
+ figures have a strong per-pixel transition, so refine all four edges from
+ the render. Sparse charts deliberately stay on the safer coarse path.
+ """
+ try:
+ import numpy as np
+ from PIL import Image
+ with Image.open(png_path) as im:
+ rgb = np.asarray(im.convert("RGB"), dtype=np.float32)
+ except Exception:
+ return None
+ lightness = rgb.max(axis=2)
+ saturation = lightness - rgb.min(axis=2)
+ paper = float(np.percentile(lightness, 90)) or 255.0
+ ink = (lightness < paper * 0.88) | (saturation > 40)
+ height, width = ink.shape
+ x0 = max(0, int(box[0] * _SNAP_CELL_PX))
+ y0 = max(0, int(box[1] * _SNAP_CELL_PX))
+ x1 = min(width, int((box[2] + 1) * _SNAP_CELL_PX))
+ y1 = min(height, int((box[3] + 1) * _SNAP_CELL_PX))
+ if x1 <= x0 or y1 <= y0 or float(ink[y0:y1, x0:x1].mean()) < _SNAP_MIN_FILL:
+ return None
+
+ radius = _SNAP_CELL_PX * 2
+
+ def best_run(flags, original_start: int, original_end: int, offset: int):
+ runs = []
+ start = None
+ for index, on in enumerate(list(flags) + [False]):
+ if on and start is None:
+ start = index
+ elif not on and start is not None:
+ absolute = (offset + start, offset + index)
+ overlap = max(0, min(absolute[1], original_end) -
+ max(absolute[0], original_start))
+ runs.append((overlap, absolute[1] - absolute[0], absolute))
+ start = None
+ return max(runs, default=(0, 0, None))[2]
+
+ wx0, wx1 = max(0, x0 - radius), min(width, x1 + radius)
+ col_run = best_run(ink[y0:y1, wx0:wx1].mean(axis=0) >= 0.35,
+ x0, x1, wx0)
+ if not col_run:
+ return None
+ rx0, rx1 = col_run
+ wy0, wy1 = max(0, y0 - radius), min(height, y1 + radius)
+ row_run = best_run(ink[wy0:wy1, rx0:rx1].mean(axis=1) >= 0.35,
+ y0, y1, wy0)
+ if not row_run:
+ return None
+ ry0, ry1 = row_run
+ return [rx0 * page_w / width, ry0 * page_h / height,
+ (rx1 - rx0) * page_w / width, (ry1 - ry0) * page_h / height]
+
+
def snap_visual_bboxes(png_path: Path, blocks: Sequence[Block],
page_meta: dict[str, Any]) -> int:
"""Correct every visual block's bbox against the page render, in place.
@@ -1925,7 +2125,11 @@ def snap_visual_bboxes(png_path: Path, blocks: Sequence[Block],
text_blocked = np.zeros(coverage.shape, dtype=bool)
for b in blocks:
- if b.type in VISUAL_TYPES or not b.text.strip():
+ # Advertisement geometry comes from the same vision model whose loose
+ # figure boxes this pass is repairing. Treating that often-wrong ad
+ # rectangle as immutable text blocked the real graphic below it and
+ # left both the orange figure crops and red advert outline displaced.
+ if b.type in (*VISUAL_TYPES, BLOCK_AD) or not b.text.strip():
continue
x0, y0, x1, y1 = to_cells(b.bbox)
text_blocked[y0:y1 + 1, x0:x1 + 1] = True
@@ -1996,15 +2200,18 @@ def snap_visual_bboxes(png_path: Path, blocks: Sequence[Block],
current = nxt
reverted = ambiguous | colliding_pairs(current)
- final = [None if i in reverted or current[i] == visual_seeds[i] else current[i]
- for i in range(len(current))]
+ final = [None if i in reverted else current[i] for i in range(len(current))]
moved = 0
- for block, box in zip(visuals, final):
+ for index, (block, box) in enumerate(zip(visuals, final)):
if box is None:
continue
- bbox = [box[0] / cells_x, box[1] / cells_y,
- (box[2] + 1 - box[0]) / cells_x, (box[3] + 1 - box[1]) / cells_y]
+ refined = _refine_dense_visual_edges(png_path, box, page_w, page_h)
+ if box == visual_seeds[index] and refined is None:
+ continue
+ bbox = (refined or [box[0] / cells_x, box[1] / cells_y,
+ (box[2] + 1 - box[0]) / cells_x,
+ (box[3] + 1 - box[1]) / cells_y])
if any(abs(a - b) > 1.0 for a, b in zip(bbox, block.bbox)):
moved += 1
block.bbox = bbox
@@ -2516,6 +2723,205 @@ def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int,
return layout
+def _bbox_intersection_ratio(a: Sequence[float], b: Sequence[float]) -> float:
+ overlap = (max(0.0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
+ * max(0.0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])))
+ area = max(1.0, float(a[2]) * float(a[3]))
+ return overlap / area
+
+
+def merge_missing_text_candidates(layout: dict[str, Any], page_no: int,
+ candidates: Sequence[Block]) -> tuple[dict[str, Any], int]:
+ """Add independently OCR'd text regions that the saved layout omitted.
+
+ Candidates overlapping existing text or a known picture are rejected.
+ New regions deliberately remain outside article selections so a reviewer
+ sees them unchecked and decides whether they belong to the article.
+ """
+ existing = [Block(**b) for b in layout.get("blocks", []) if b["page"] == page_no]
+ visuals = [b.bbox for b in existing if b.type in VISUAL_TYPES]
+ visuals += [i["bbox"] for i in layout.get("images", []) if i["page"] == page_no]
+ text_blocks = [b for b in existing if b.type not in VISUAL_TYPES]
+ ids = {b["id"] for b in layout.get("blocks", [])}
+ added: list[Block] = []
+ for candidate in candidates:
+ text = " ".join((candidate.text or "").split())
+ if len(text) < 12 or ocr_quality_score(text) < 0.55:
+ continue
+ if any(_bbox_intersection_ratio(candidate.bbox, box) >= 0.6 for box in visuals):
+ continue
+ duplicate = False
+ for saved in text_blocks + added:
+ overlap = _bbox_intersection_ratio(candidate.bbox, saved.bbox)
+ similarity = difflib.SequenceMatcher(
+ None, text.casefold(), " ".join(saved.text.split()).casefold(), autojunk=False
+ ).ratio()
+ if overlap >= 0.25 or similarity >= 0.72:
+ duplicate = True
+ break
+ if duplicate:
+ continue
+ number = 0
+ while f"q{page_no:04d}-m{number:03d}" in ids:
+ number += 1
+ candidate.id = f"q{page_no:04d}-m{number:03d}"
+ candidate.text = text
+ candidate.source = "quality_missing_text"
+ ids.add(candidate.id)
+ added.append(candidate)
+ if not added:
+ return layout, 0
+ updated = dict(layout)
+ all_blocks = [Block(**b) for b in layout.get("blocks", [])] + added
+ page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
+ if page_meta and not page_meta.get("order_locked"):
+ assign_reading_order([b for b in all_blocks if b.page == page_no], page_meta["width"])
+ updated["blocks"] = [b.to_json() for b in all_blocks]
+ return updated, len(added)
+
+
+def recover_missing_text(doc_dir: Path, layout: dict[str, Any], page_no: int,
+ lang: str = "deu+eng") -> tuple[dict[str, Any], int]:
+ """Use local OCR as an independent completeness check without replacing boxes."""
+ fitz = _require_fitz()
+ png = doc_dir / "pages" / f"{page_no:04d}.png"
+ page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
+ if not png.is_file() or not page_meta:
+ return layout, 0
+ scale = page_meta["width"] / max(1.0, _png_width(png, fitz))
+ candidates = [Block(
+ id="", page=page_no, bbox=[float(v) * scale for v in region["bbox"]],
+ type=BLOCK_BODY, text=region.get("text", ""),
+ confidence=float(region.get("confidence") or 0.5),
+ font_size=float(region.get("line_height") or 0.0) * scale,
+ source="quality_missing_text",
+ ) for region in ocr_page(png, lang)]
+ base = body_font_size([Block(**b) for b in layout.get("blocks", [])])
+ classify_blocks(candidates, page_meta["height"], page_meta["width"], base_size=base)
+ return merge_missing_text_candidates(layout, page_no, candidates)
+
+
+def verify_ocr_recognition(doc_dir: Path, layout: dict[str, Any], page_no: int,
+ vision_ocr_url: str | None = None,
+ vision_ocr_model: str | None = None,
+ vision_ocr_fallback_url: str | None = None,
+ vision_ocr_fallback_model: str | None = None,
+ timeout: float = 150.0) -> tuple[dict[str, Any], int, int]:
+ """Re-read dictionary-suspicious regions and accept only a better image reading."""
+ updated = dict(layout)
+ blocks = [dict(b) for b in layout.get("blocks", [])]
+ checked = corrected = 0
+ eligible = set(NARRATABLE) | {BLOCK_CAPTION, BLOCK_SIDEBAR, BLOCK_PULLQUOTE}
+ for index, block in enumerate(blocks):
+ if block["page"] != page_no or block["type"] not in eligible:
+ continue
+ old = " ".join((block.get("text") or "").split())
+ old_suspects = suspect_ocr_tokens(old)
+ if not old_suspects:
+ continue
+ checked += 1
+ candidate = extract_region_text(
+ doc_dir, updated, page_no, block["bbox"],
+ vision_ocr_url, vision_ocr_model,
+ vision_ocr_fallback_url, vision_ocr_fallback_model, timeout,
+ )
+ candidate = _join_detached_drop_cap(" ".join((candidate or "").split()))
+ if not candidate or candidate == old:
+ continue
+ similarity = difflib.SequenceMatcher(None, old.casefold(), candidate.casefold(),
+ autojunk=False).ratio()
+ new_suspects = suspect_ocr_tokens(candidate)
+ length_ratio = len(candidate) / max(1, len(old))
+ if (similarity >= 0.55 and 0.6 <= length_ratio <= 1.6
+ and len(new_suspects) < len(old_suspects)):
+ blocks[index]["text"] = candidate
+ corrected += 1
+ updated["blocks"] = blocks
+ return updated, checked, corrected
+
+
+_AUDIT_MIN_CELL_INK = 0.08 # a text-bearing cell is inked well below a photo's; this still
+ # ignores paper grain and scanner noise
+_AUDIT_MIN_CELLS = 10 # ~one short word line at the 10 px grid; smaller specks are dust
+_AUDIT_OVERLAP_RATIO = 0.3 # two text boxes sharing this much of the smaller one's area
+_AUDIT_CLIP_INK = 0.12 # ink density in the cell column just outside a box's side edge:
+ # text running past the box means its first/last letters are cut
+_AUDIT_EDGE_FRACTION = 0.03 # scan borders / spine shadows live here and are never article text
+
+
+def audit_page_coverage(doc_dir: Path, layout: dict[str, Any], page_no: int) -> dict[str, Any]:
+ """Report inked areas of a page render that no region box covers.
+
+ Pure measurement: it never edits the layout. `uncovered` holds page-point boxes of ink
+ blobs outside every block/image box (any type, so furniture and pictures count as
+ covered); `uncovered_ratio` is that ink's share of all ink on the page. `suspect_blocks`
+ lists text regions whose words still look mis-read after any verification pass.
+ """
+ page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
+ png = doc_dir / "pages" / f"{page_no:04d}.png"
+ blocks = [b for b in layout.get("blocks", []) if b["page"] == page_no]
+ eligible = set(NARRATABLE) | {BLOCK_CAPTION, BLOCK_SIDEBAR, BLOCK_PULLQUOTE}
+ suspects = [b["id"] for b in blocks
+ if b["type"] in eligible and suspect_ocr_tokens(" ".join((b.get("text") or "").split()))]
+ text_blocks = [b for b in blocks if b["type"] not in VISUAL_TYPES]
+ overlaps = [[a["id"], b["id"]] for i, a in enumerate(text_blocks) for b in text_blocks[i + 1:]
+ if max(_bbox_intersection_ratio(a["bbox"], b["bbox"]),
+ _bbox_intersection_ratio(b["bbox"], a["bbox"])) >= _AUDIT_OVERLAP_RATIO]
+ report: dict[str, Any] = {"page": page_no, "uncovered": [], "uncovered_ratio": 0.0,
+ "suspect_blocks": suspects, "overlaps": overlaps, "clipped": [],
+ "measured": False}
+ if not page_meta or not png.is_file():
+ return report
+ try:
+ import numpy as np
+ from scipy import ndimage
+ except Exception:
+ return report
+ grid_info = page_ink_grid_points(png, page_meta)
+ if grid_info is None:
+ return report
+ grid, cell_w, cell_h = grid_info
+ ink = grid >= _AUDIT_MIN_CELL_INK
+ covered = np.zeros_like(ink)
+ boxes = [b["bbox"] for b in blocks] + [i["bbox"] for i in layout.get("images", [])
+ if i["page"] == page_no]
+ for x, y, w, h in boxes:
+ r0, r1 = max(0, int(y / cell_h) - 1), min(ink.shape[0], int(math.ceil((y + h) / cell_h)) + 1)
+ c0, c1 = max(0, int(x / cell_w) - 1), min(ink.shape[1], int(math.ceil((x + w) / cell_w)) + 1)
+ covered[r0:r1, c0:c1] = True
+ for b in text_blocks:
+ x, y, w, h = b["bbox"]
+ r0, r1 = max(0, int(y / cell_h)), min(ink.shape[0], int(math.ceil((y + h) / cell_h)))
+ if r1 - r0 < 3:
+ continue
+ for side, col in (("left", int(x / cell_w) - 1), ("right", int(math.ceil((x + w) / cell_w)))):
+ if 0 <= col < ink.shape[1] and float(grid[r0:r1, col].mean()) >= _AUDIT_CLIP_INK:
+ report["clipped"].append({"id": b["id"], "side": side})
+ er, ec = int(ink.shape[0] * _AUDIT_EDGE_FRACTION), int(ink.shape[1] * _AUDIT_EDGE_FRACTION)
+ edge = np.zeros_like(ink)
+ edge[:er, :] = edge[-er:, :] = True
+ edge[:, :ec] = edge[:, -ec:] = True
+ stray = ink & ~covered & ~edge
+ report["measured"] = True
+ total = int(ink.sum())
+ if not stray.any() or not total:
+ return report
+ # Bridge the gaps between letters and lines so one paragraph is one blob.
+ labels, count = ndimage.label(ndimage.binary_dilation(stray, iterations=2))
+ kept_cells = 0
+ for index in range(1, count + 1):
+ rows, cols = np.nonzero((labels == index) & stray)
+ if len(rows) < _AUDIT_MIN_CELLS:
+ continue
+ kept_cells += len(rows)
+ report["uncovered"].append([
+ round(float(cols.min() * cell_w), 1), round(float(rows.min() * cell_h), 1),
+ round(float((cols.max() + 1 - cols.min()) * cell_w), 1),
+ round(float((rows.max() + 1 - rows.min()) * cell_h), 1)])
+ report["uncovered_ratio"] = round(kept_cells / total, 4)
+ return report
+
+
_INVENTED_VISUAL_MIN_INK = 0.4 # see the guard below apply_semantic_regions; a real photo, chart
# or table measured 0.56-0.97 on confirmed real pages of the same
# issue, a text area the model mistook for one 0.10-0.14 -- wide
@@ -2554,6 +2960,7 @@ def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: d
the adjacent regions continuously.
"""
known = {b["id"]: b for b in blocks}
+ visual_boxes = [b["bbox"] for b in blocks if b.get("type") in VISUAL_TYPES]
used = set()
out = []
allowed = set(NARRATABLE) | set(VISUAL_TYPES) | {BLOCK_AD, BLOCK_SIDEBAR, BLOCK_CAPTION, BLOCK_FURNITURE, BLOCK_PULLQUOTE}
@@ -2607,6 +3014,11 @@ def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: d
def append_group(members: list[dict], kind: str, label: str,
visual_box: list[float] | None, continues: bool) -> None:
+ # The semantic model returns a set of block IDs, not a dependable
+ # transcription order. Joining in its arbitrary ID order moved the
+ # first line extracted from a run-on byline to the paragraph's end.
+ members = sorted(members, key=lambda b: (
+ b.get("reading_order", 10**9), b["bbox"][1], b["bbox"][0]))
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)
@@ -2656,6 +3068,21 @@ def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: d
members = [known[i] for i in ids]
if len({b["page"] for b in members}) != 1:
raise ValueError("A region cannot span pages")
+ # A semantic model can call ordinary lower-page body copy a caption.
+ # Preserve that label only when there is actually a nearby figure.
+ # Explicit captions from the primary layout engine remain untouched.
+ if (kind == BLOCK_CAPTION
+ and all(member.get("type") == BLOCK_BODY for member in members)):
+ x = min(member["bbox"][0] for member in members)
+ y = min(member["bbox"][1] for member in members)
+ right = max(member["bbox"][0] + member["bbox"][2] for member in members)
+ bottom = max(member["bbox"][1] + member["bbox"][3] for member in members)
+ member_box = [x, y, right - x, bottom - y]
+ tolerance = max(float(page_meta.get("width", 0)) if page_meta else 0,
+ float(page_meta.get("height", 0)) if page_meta else 0) * 0.02
+ if not any(bbox_overlaps(member_box, box, tol=tolerance)
+ for box in visual_boxes):
+ kind = BLOCK_BODY
if any(b["type"] == BLOCK_AD for b in members):
if not all(b["type"] == BLOCK_AD for b in members):
raise ValueError("Cannot merge advertising and editorial text")
@@ -2724,12 +3151,39 @@ def refresh_visual_regions(doc_dir: Path, layout: dict, page: int | None = None)
if page is not None and page_no != page:
continue
blocks = [Block(**b) for b in layout.get("blocks", []) if b["page"] == page_no]
+ visuals = [b for b in blocks if b.type in VISUAL_TYPES]
+
+ def intersection_area(a: Sequence[float], b: Sequence[float]) -> float:
+ return (max(0.0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
+ * max(0.0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])))
+
+ # Remember which visual seeds made up each advert before moving them.
+ # Once they land on the rendered artwork, their union is better advert
+ # geometry than the model's displaced red rectangle. The coverage
+ # floor avoids shrinking a mixed text advert around one incidental icon.
+ advert_visuals: dict[str, list[str]] = {}
+ for advert in (b for b in blocks if b.type == BLOCK_AD):
+ related = [v for v in visuals
+ if intersection_area(v.bbox, advert.bbox) >= 0.5 * v.bbox[2] * v.bbox[3]]
+ covered = sum(intersection_area(v.bbox, advert.bbox) for v in related)
+ if related and covered >= 0.25 * advert.bbox[2] * advert.bbox[3]:
+ advert_visuals[advert.id] = [v.id for v in related]
# Re-snapping here, not only at OCR time, is what lets an issue
# ingested before this pass existed pick up corrected figure boxes
# without paying for a second OCR run of the whole document. It is
# idempotent: an already-correct box seeds the same graphic again.
if snap_visual_bboxes(doc_dir / "pages" / f"{page_no:04d}.png", blocks, meta):
snapped = {b.id: b.bbox for b in blocks if b.type in VISUAL_TYPES}
+ for advert in (b for b in blocks if b.id in advert_visuals):
+ parts = [b for b in visuals if b.id in advert_visuals[advert.id]]
+ if not parts:
+ continue
+ x0 = min(b.bbox[0] for b in parts)
+ y0 = min(b.bbox[1] for b in parts)
+ x1 = max(b.bbox[0] + b.bbox[2] for b in parts)
+ y1 = max(b.bbox[1] + b.bbox[3] for b in parts)
+ advert.bbox = [x0, y0, x1 - x0, y1 - y0]
+ snapped[advert.id] = advert.bbox
for saved in layout["blocks"]:
if saved["id"] in snapped:
saved["bbox"] = snapped[saved["id"]]
@@ -2837,6 +3291,7 @@ def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int,
"heading": (BLOCK_HEADING, None),
"deck": (BLOCK_DECK, None),
"byline": (BLOCK_BYLINE, None),
+ "quote": (BLOCK_QUOTE, None),
"caption": (BLOCK_CAPTION, None),
"pullquote": (BLOCK_PULLQUOTE, None),
"sidebar": (BLOCK_SIDEBAR, None),
@@ -2932,8 +3387,11 @@ def extract_region_text(doc_dir: Path, layout: dict[str, Any], page_no: int,
logging.getLogger(__name__).warning(
"vision OCR unreachable for a re-read region; falling back to local OCR")
if regions:
- return " ".join(r.get("text", "") for r in regions if r.get("text")).strip()
- return " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip()
+ repair_drop_cap_omissions(crop_path, regions)
+ return _join_detached_drop_cap(
+ " ".join(r.get("text", "") for r in regions if r.get("text")).strip())
+ return _join_detached_drop_cap(
+ " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip())
finally:
crop_path.unlink(missing_ok=True)
@@ -2967,6 +3425,8 @@ def update_region(doc_dir: Path, layout: dict[str, Any], page_no: int, block_id:
block_idx = next((i for i, b in enumerate(blocks) if b["id"] == block_id), None)
if block_idx is not None:
block = dict(blocks[block_idx])
+ old_bbox = list(block.get("bbox") or [])
+ old_text = str(block.get("text") or "")
if bbox is not None:
block["bbox"] = list(bbox)
new_text = None
@@ -2976,6 +3436,17 @@ def update_region(doc_dir: Path, layout: dict[str, Any], page_no: int, block_id:
vision_ocr_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model, timeout)
if new_text:
+ # A vision re-read can omit the first printed line when a
+ # paragraph begins with a drop cap. If the corrected box
+ # still includes its old top edge, use the existing text as
+ # a guarded second reading to restore only that lost prefix.
+ # Moving the top edge down intentionally must still be able
+ # to remove text, hence the geometry check.
+ same_top = (bbox is None or not old_bbox
+ or float(block["bbox"][1]) <= float(old_bbox[1])
+ + max(3.0, float(old_bbox[3]) * 0.15))
+ if same_top:
+ new_text = prefer_drop_cap_repair(new_text, old_text)
block["text"] = new_text
blocks[block_idx] = block
layout["blocks"] = blocks
@@ -3184,6 +3655,7 @@ REGION_TYPES: dict[str, tuple[str, str]] = {
"heading": (BLOCK_HEADING, ""),
"deck": (BLOCK_DECK, ""),
"byline": (BLOCK_BYLINE, ""),
+ "quote": (BLOCK_QUOTE, ""),
"caption": (BLOCK_CAPTION, ""),
"pullquote": (BLOCK_PULLQUOTE, ""),
"sidebar": (BLOCK_SIDEBAR, ""),
@@ -3474,18 +3946,26 @@ def article_text(layout: dict[str, Any], article: dict[str, Any],
listening experience enough that it should be an explicit choice, not a
silent side effect of one being available.
"""
+ # A page the reviewer approved as an advertisement is never narrated, whatever article claims it.
+ ad_pages = {p["page"] for p in layout.get("pages", []) if p.get("advertisement")}
wanted = set(article.get("blocks") or [])
types = set(NARRATABLE) | ({BLOCK_CAPTION} if include_captions else set())
blocks = [
- dict(b, text=normalize_ocr_text(strip_continuation_markers(b["text"])))
+ dict(
+ b,
+ text=normalize_ocr_text(strip_continuation_markers(b["text"])),
+ # Keep faithful print text for reading/highlighting, but give live
+ # TTS the same pronunciation expansion as exported article audio.
+ spoken_text=prepare_text_for_tts(strip_continuation_markers(b["text"])),
+ )
for b in layout.get("blocks", [])
- if b["id"] in wanted and b["type"] in types
+ if b["id"] in wanted and b["type"] in types and b["page"] not in ad_pages
]
if include_descriptions:
wanted_images = set(article.get("images") or [])
for img in layout.get("images", []):
description = (img.get("description") or "").strip()
- if img["id"] not in wanted_images or not description:
+ if img["id"] not in wanted_images or not description or img["page"] in ad_pages:
continue
blocks.append({
"id": f"desc-{img['id']}", "page": img["page"], "bbox": img["bbox"],
diff --git a/core/pronunciations.py b/core/pronunciations.py
index 57c35b2..bc7b84f 100644
--- a/core/pronunciations.py
+++ b/core/pronunciations.py
@@ -18,6 +18,7 @@ _GERMAN_HINTS = {
"eine", "einer", "einen", "für", "hat", "ist", "mit", "nicht", "oder", "sich",
"sie", "und", "von", "werden", "wie", "zu",
}
+_SPELL_WORD_RE = re.compile(r"[A-Za-zÄÖÜäöüß]{3,}")
@lru_cache(maxsize=1)
@@ -61,6 +62,42 @@ def _german_spellchecker():
return None
+@lru_cache(maxsize=1)
+def _english_spellchecker():
+ try:
+ from spellchecker import SpellChecker
+ return SpellChecker(language="en", distance=1)
+ except Exception:
+ return None
+
+
+def suspect_ocr_tokens(text: str, language: str = "auto") -> list[str]:
+ """Return dictionary-unknown tokens worth checking against the page image.
+
+ This deliberately does not provide replacements. Magazine text contains
+ names, brands and technical compounds that a spellchecker cannot know;
+ dictionary output is only a trigger for a second OCR reading of the real
+ crop, never authority to rewrite printed text by itself.
+ """
+ lang = detect_text_language(text) if language == "auto" else language
+ checker = _german_spellchecker() if lang == "de" else _english_spellchecker()
+ if checker is None:
+ return []
+ tokens = _SPELL_WORD_RE.findall(text or "")
+ unknown = []
+ for index, token in enumerate(tokens):
+ # Acronyms are handled by the pronunciation map; title-cased words in
+ # the middle of a sentence are much more likely to be names/products.
+ # German capitalises every noun, so title case cannot distinguish a
+ # name from an OCR error there. False positives are harmless because
+ # this list only triggers a guarded image re-read.
+ if token.isupper() or (lang != "de" and index and token[:1].isupper()) or len(token) < 4:
+ continue
+ if token.casefold() not in checker:
+ unknown.append(token)
+ return list(dict.fromkeys(unknown))
+
+
def _match_case(source: str, replacement: str) -> str:
if source.isupper() and len(source) > 1:
return replacement.upper()
diff --git a/routes/conversation.py b/routes/conversation.py
index 6faff82..775ec1b 100644
--- a/routes/conversation.py
+++ b/routes/conversation.py
@@ -77,7 +77,7 @@ _CAST_JOBS: dict[str, dict] = {}
_CAST_JOB_IDEMPOTENCY: dict[str, tuple[str, float]] = {}
_CAST_JOB_MAX = 128
_CAST_JOB_TIMEOUT = 15 * 60
-_CHARACTER_VISUAL_PROMPT_VERSION = "v3"
+_CHARACTER_VISUAL_PROMPT_VERSION = "v4"
_CHARACTER_IDENTITY_FIELDS = (
"age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species",
"gender", "marks", "scars", "nationality_background", "clothing", "era", "profession",
@@ -2363,7 +2363,7 @@ def _build_character_portrait_prompt(name: str, book: str, sheet: dict, book_pro
f"Style: {style}. "
"Composition: one person, chest-up square at eye level, 70 mm, calm three-quarter gaze, warm-gray studio background, face unobstructed. "
"Lighting: fixed soft north-window key camera-right, restrained fill and rim, natural shadows, no color cast. "
- "Constraints: identity, gender, age, species, occupation, clothing, and marks must be unmistakable; no extra people, text, logos, watermark, modern styling, or real-person likeness. "
+ "Constraints: identity, gender, age, species, occupation, clothing, and marks must be unmistakable; no extra people, text of any kind (including Chinese characters, other non-Latin writing, symbols, labels, or pseudo-text), logos, watermark, modern styling, or real-person likeness. "
"Output: production-ready 1024x1024 portrait, safe for a circular crop."
)
@@ -2391,7 +2391,7 @@ def _build_character_sheet_prompt(name: str, book: str, sheet: dict, book_profil
"Composition: clean production board; three equal-scale, fully visible full-body orthographic views across the top: strict front, true left profile, strict back, same neutral stance. "
"Below, one source-grounded story moment showing the character acting, speaking, working, or fighting as they do in the book, plus zero to three verified portable items; each item is visually distinct, complete, shown once, assembled, isolated, and unlabeled. Empty prop slots remain empty. "
"Lighting: identical neutral museum-soft light, gentle contact shadows, no color cast. "
- "Constraints: identical face, age, species, proportions, silhouette, garment seams, palette, and wear in every view; inventory never implies that armor or clothing is worn; no invented or duplicate gear, no repeated weapon category, no disassembled/floating fragments, extra limbs, generated text, logos, or watermark. "
+ "Constraints: identical face, age, species, proportions, silhouette, garment seams, palette, and wear in every view; inventory never implies that armor or clothing is worn; no invented or duplicate gear, no repeated weapon category, no disassembled/floating fragments, extra limbs, generated text of any kind (especially Chinese characters, other non-Latin writing, symbols, labels, or pseudo-text), logos, or watermark. "
"Output: crisp production-ready square continuity sheet assembled from individually controlled panels."
)
diff --git a/routes/magazine.py b/routes/magazine.py
index acbefdf..be7ac4e 100644
--- a/routes/magazine.py
+++ b/routes/magazine.py
@@ -300,6 +300,7 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
background_job_update(
job_id, stage="extract",
detail=f"Analysing page {page} of {total}",
+ result={"page": page, "step": "reading text (OCR)"},
)
# Written to its own small file rather than the generic job
# result: that field strips any key resembling "text"/"content"
@@ -321,7 +322,8 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
def labelled(step: str, *, refresh: bool = False):
def on_page(page_no: int) -> None:
background_job_update(job_id, stage="advertisements",
- detail=f"{step} — page {page_no} of {total_pages}")
+ detail=f"{step} — page {page_no} of {total_pages}",
+ result={"page": page_no, "step": step[:1].lower() + step[1:]})
if refresh:
# Snap this page's picture/chart/table boxes to the ink and crop them to
# real files now, instead of waiting for the end-of-document pass below --
@@ -898,8 +900,171 @@ async def magazine_reocr_page(doc_id: str, page: int, request: Request):
return {"job_id": job_id}
-def _refine_figures_sync(doc_id: str, job_id: str) -> None:
- """Re-snap every figure box in a document against its page renders.
+def _quality_page_sync(doc_id: str, job_id: str, page: int, mode: str,
+ vision_ocr_url: str | None = None,
+ vision_ocr_model: str | None = None,
+ vision_ocr_fallback_url: str | None = None,
+ vision_ocr_fallback_model: str | None = None) -> None:
+ """Run a conservative, non-destructive page quality check."""
+ d = _LIB / doc_id
+ try:
+ layout = mag.load_layout(d)
+ if not layout:
+ raise FileNotFoundError("No layout yet -- run ingestion first")
+ if mode == "missing":
+ background_job_update(job_id, stage="missing-text",
+ detail=f"Checking page {page} for missed text")
+ updated, added = mag.recover_missing_text(d, layout, page)
+ result = {"page": page, "added": added}
+ detail = (f"Added {added} missed text region{'s' if added != 1 else ''}"
+ if added else "No missed text regions found")
+ elif mode == "recognition":
+ background_job_update(job_id, stage="verify-text",
+ detail=f"Verifying suspicious words on page {page}")
+ updated, checked, corrected = mag.verify_ocr_recognition(
+ d, layout, page, vision_ocr_url, vision_ocr_model,
+ vision_ocr_fallback_url, vision_ocr_fallback_model,
+ )
+ result = {"page": page, "checked": checked, "corrected": corrected}
+ detail = (f"Corrected {corrected} of {checked} suspicious region"
+ f"{'s' if checked != 1 else ''}")
+ else:
+ raise ValueError("Unknown quality check")
+ mag.save_layout(d, updated)
+ background_job_update(job_id, stage="done", done=True,
+ detail=detail, result=result)
+ except Exception as exc: # noqa: BLE001 - surfaced to UI
+ background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000])
+
+
+def _start_quality_page_job(doc_id: str, page: int, mode: str,
+ settings: dict[str, Any] | None = None) -> dict[str, str]:
+ d, _ = _require_magazine(doc_id)
+ layout = mag.load_layout(d)
+ if not layout or not any(meta.get("page") == page for meta in layout.get("pages", [])):
+ raise HTTPException(404, "Page not found")
+ settings = settings or {}
+ job_id = uuid.uuid4().hex
+ background_job_create(job_id, f"magazine-quality-{mode}",
+ fingerprint=f"{doc_id}:{page}")
+ asyncio.create_task(asyncio.to_thread(
+ _quality_page_sync, doc_id, job_id, page, mode,
+ str(settings.get("vision_ocr_url") or "").strip() or None,
+ str(settings.get("vision_ocr_model") or "").strip() or None,
+ str(settings.get("vision_ocr_fallback_url") or "").strip() or None,
+ str(settings.get("vision_ocr_fallback_model") or "").strip() or None,
+ ))
+ return {"job_id": job_id}
+
+
+@router.post("/api/magazine/{doc_id}/pages/{page}/quality/missing")
+async def magazine_find_missing_text(doc_id: str, page: int):
+ """Add only independently detected text regions absent from this page."""
+ return _start_quality_page_job(doc_id, page, "missing")
+
+
+@router.post("/api/magazine/{doc_id}/pages/{page}/quality/recognition")
+async def magazine_verify_ocr_text(doc_id: str, page: int):
+ """Re-read suspicious words from page crops; never spell-correct blindly."""
+ return _start_quality_page_job(doc_id, page, "recognition", _load_settings())
+
+
+_QUALITY_REPORT = "quality_report.json"
+
+
+def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> None:
+ """Whole-issue completeness run: find missed text, verify OCR, then measure coverage.
+
+ Per page: (1) independent local OCR adds absent regions, (2) suspicious regions are
+ re-read from the page crop when a vision engine is configured, (3) the render is
+ audited for ink no box covers. Layout is saved every few pages so an interrupted run
+ keeps its work; the per-page report lands in quality_report.json.
+ """
+ d = _LIB / doc_id
+ try:
+ layout = mag.load_layout(d)
+ if not layout:
+ raise FileNotFoundError("No layout yet -- run ingestion first")
+ url = str(settings.get("vision_ocr_url") or "").strip() or None
+ model = str(settings.get("vision_ocr_model") or "").strip() or None
+ fb_url = str(settings.get("vision_ocr_fallback_url") or "").strip() or None
+ fb_model = str(settings.get("vision_ocr_fallback_model") or "").strip() or None
+ verify = bool(url and model)
+ pages = [meta["page"] for meta in layout.get("pages", [])]
+ rows: list[dict[str, Any]] = []
+ for i, page in enumerate(pages, 1):
+ def step(text: str, page: int = page, i: int = i) -> None:
+ # "Checking page N of M: step" -- the UI parses N to mark the working page
+ background_job_update(job_id, stage="quality-run",
+ detail=f"Checking page {i} of {len(pages)}: {text}",
+ result={"page": page, "step": text})
+ added = checked = corrected = 0
+ if next((m for m in layout.get("pages", []) if m["page"] == page), {}).get("advertisement"):
+ rows.append({"page": page, "uncovered": [], "uncovered_ratio": 0.0, "suspect_blocks": [],
+ "overlaps": [], "clipped": [], "measured": False, "advertisement": True,
+ "added": 0, "checked": 0, "corrected": 0, "needs_review": False})
+ continue
+ try:
+ step("finding missed text")
+ layout, added = mag.recover_missing_text(d, layout, page)
+ if verify:
+ step("re-reading suspicious words")
+ layout, checked, corrected = mag.verify_ocr_recognition(
+ d, layout, page, url, model, fb_url, fb_model)
+ step("measuring box coverage")
+ row = mag.audit_page_coverage(d, layout, page)
+ except Exception as exc: # noqa: BLE001 - one bad page must not stop the run
+ row = {"page": page, "uncovered": [], "uncovered_ratio": 0.0,
+ "suspect_blocks": [], "overlaps": [], "clipped": [], "measured": False, "error": str(exc)[:200]}
+ row.update(added=added, checked=checked, corrected=corrected)
+ row["needs_review"] = bool(row["uncovered"] or row["suspect_blocks"] or row["overlaps"]
+ or row["clipped"] or row.get("error"))
+ rows.append(row)
+ if i % 5 == 0:
+ mag.save_layout(d, layout)
+ mag.save_layout(d, layout)
+ summary = {
+ "pages": len(rows),
+ "verified_with_vision": verify,
+ "regions_added": sum(r["added"] for r in rows),
+ "regions_corrected": sum(r["corrected"] for r in rows),
+ "pages_needing_review": [r["page"] for r in rows if r["needs_review"]],
+ }
+ (d / _QUALITY_REPORT).write_text(
+ json.dumps({"summary": summary, "pages": rows}, ensure_ascii=False), encoding="utf-8")
+ flagged = len(summary["pages_needing_review"])
+ background_job_update(
+ job_id, stage="done", done=True, result=summary,
+ detail=(f"{summary['regions_added']} missed regions added, "
+ f"{summary['regions_corrected']} words corrected; "
+ f"{flagged} page{'s' if flagged != 1 else ''} still need a look"
+ + ("" if verify else " (OCR verification skipped: no vision engine configured)")))
+ except Exception as exc: # noqa: BLE001 - surfaced to UI
+ background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000])
+
+
+@router.post("/api/magazine/{doc_id}/quality/run")
+async def magazine_quality_run(doc_id: str):
+ """Check every page for uncovered text and unverified OCR (whole issue)."""
+ _require_magazine(doc_id)
+ job_id = uuid.uuid4().hex
+ background_job_create(job_id, "magazine-quality-run", fingerprint=doc_id)
+ asyncio.create_task(asyncio.to_thread(_quality_run_sync, doc_id, job_id, _load_settings()))
+ return {"job_id": job_id}
+
+
+@router.get("/api/magazine/{doc_id}/quality/report")
+async def magazine_quality_report(doc_id: str):
+ """Result of the last whole-issue quality run (404 if none yet)."""
+ d, _ = _require_magazine(doc_id)
+ path = d / _QUALITY_REPORT
+ if not path.is_file():
+ raise HTTPException(404, "No quality run yet")
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def _refine_figures_sync(doc_id: str, job_id: str, page: int | None = None) -> None:
+ """Re-snap figure boxes against their page renders.
Restart-safe like the other layout jobs: a 200-page issue means 200 page
renders read and re-cropped, far too long for a request/response cycle.
@@ -913,11 +1078,17 @@ def _refine_figures_sync(doc_id: str, job_id: str) -> None:
layout = mag.load_layout(d)
if not layout:
raise FileNotFoundError("No layout yet -- run ingestion first")
- pages = layout.get("pages", [])
+ pages = [meta for meta in layout.get("pages", [])
+ if page is None or meta["page"] == page]
+ if page is not None and not pages:
+ raise ValueError("Unknown page")
before = {b["id"]: list(b["bbox"]) for b in layout.get("blocks", [])}
for i, meta in enumerate(pages, 1):
- background_job_update(job_id, stage="figures",
- detail=f"Checking figure boundaries on page {i} of {len(pages)}")
+ detail = (f"Checking figure boundaries on page {meta['page']}"
+ if page is not None else
+ f"Checking figure boundaries on page {i} of {len(pages)}")
+ background_job_update(job_id, stage="figures", detail=detail,
+ result={"page": meta["page"], "step": "fixing figure boundaries"})
mag.refresh_visual_regions(d, layout, meta["page"])
moved = sum(1 for b in layout.get("blocks", [])
if before.get(b["id"]) and b["bbox"] != before[b["id"]])
@@ -1131,6 +1302,33 @@ async def magazine_clean_up_boxes(doc_id: str):
return {"ok": True, "backup": backup.name, "counts": {k: len(v) for k, v in report.items()}, "report": report}
+def _set_page_advertisement(doc_id: str, page: int, value: bool) -> dict[str, Any]:
+ d, _ = _require_magazine(doc_id)
+ layout = mag.load_layout(d)
+ meta = next((p for p in (layout or {}).get("pages", []) if p.get("page") == page), None)
+ if meta is None:
+ raise HTTPException(404, "Page not found")
+ if value:
+ meta["advertisement"] = True
+ else:
+ meta.pop("advertisement", None)
+ mag.save_layout(d, layout)
+ return {"ok": True, "page": page, "advertisement": value}
+
+
+@router.put("/api/magazine/{doc_id}/pages/{page}/advertisement")
+async def magazine_mark_page_advertisement(doc_id: str, page: int):
+ """Approve a page as an advertisement: nothing on it is narrated and the quality check
+ leaves it alone. Non-destructive -- regions and articles are untouched."""
+ return _set_page_advertisement(doc_id, page, True)
+
+
+@router.delete("/api/magazine/{doc_id}/pages/{page}/advertisement")
+async def magazine_unmark_page_advertisement(doc_id: str, page: int):
+ """Take the advertisement approval off a page."""
+ return _set_page_advertisement(doc_id, page, False)
+
+
@router.put("/api/magazine/{doc_id}/pages/{page}/reading-order")
async def magazine_set_page_order(doc_id: str, page: int, request: Request):
"""Set one page's reading order by hand. Body: {order: [region id, ...]} -- text blocks and
@@ -1199,6 +1397,20 @@ async def magazine_refine_figures(doc_id: str):
return {"job_id": job_id}
+@router.post("/api/magazine/{doc_id}/pages/{page}/figures/refine")
+async def magazine_refine_page_figures(doc_id: str, page: int):
+ """Re-check figure boundaries on one review page only."""
+ d, _ = _require_magazine(doc_id)
+ layout = mag.load_layout(d)
+ if not layout or not any(meta.get("page") == page for meta in layout.get("pages", [])):
+ raise HTTPException(404, "Page not found")
+ job_id = uuid.uuid4().hex
+ background_job_create(job_id, "magazine-refine-page-figures",
+ fingerprint=f"{doc_id}:{page}")
+ asyncio.create_task(asyncio.to_thread(_refine_figures_sync, doc_id, job_id, page))
+ return {"job_id": job_id}
+
+
def _add_region_sync(doc_id: str, job_id: str, page: int, bbox: list[float], kind: str,
text: str, vision_ocr_url: str | None, vision_ocr_model: str | None,
vision_ocr_fallback_url: str | None, vision_ocr_fallback_model: str | None) -> None:
@@ -1237,7 +1449,7 @@ async def magazine_add_region(doc_id: str, page: int, request: Request):
and all(isinstance(v, (int, float)) for v in bbox)):
raise HTTPException(400, "bbox must be [x, y, width, height]")
kind = str(body.get("kind") or "text")
- if kind not in ("text", "body", "image", "title", "heading", "deck", "byline", "caption",
+ if kind not in ("text", "body", "image", "title", "heading", "deck", "byline", "quote", "caption",
"pullquote", "sidebar", "page_number", "source", "in_picture", "ocr_noise",
"duplicate", "table", "ad"):
raise HTTPException(400, "Unsupported region kind")
@@ -1738,7 +1950,8 @@ def _synthesize_article_audio_sync(doc_id: str, job_id: str, article_id: str, vo
def progress(done: int, total: int) -> None:
background_job_update(
- job_id, detail=f"{prefix}{article.get('title') or article_id}: sentence {done}/{total}")
+ job_id, detail=f"{prefix}{article.get('title') or article_id}: sentence {done}/{total}",
+ result={"pages": article.get("pages") or [], "step": f"rendering audio {done}/{total}"})
return mag.synthesize_article_audio(
d, article, layout, voice, settings,
@@ -1864,7 +2077,10 @@ def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str],
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}…")
+ pages = (next((a for a in mag.load_articles(_LIB / doc_id) if a.get("id") == article_id), {})
+ .get("pages") or [])
+ background_job_update(job_id, detail=f"Article {i}/{total}…",
+ result={"pages": pages, "step": "rendering audio"})
try:
_synthesize_article_audio_sync(
doc_id, job_id, article_id, voice, interview_voice,
diff --git a/static/js/library-characters.js b/static/js/library-characters.js
index 4a5aadb..ec30b7e 100644
--- a/static/js/library-characters.js
+++ b/static/js/library-characters.js
@@ -1296,7 +1296,7 @@ function _libDeleteForm(sheet, id) {
function _libFormPairState(sheet, form) {
if (!form || !form.image || !form.concept_art_image) return 'none';
const fingerprint = _libIdentityFingerprint(Object.assign({}, sheet, form));
- const stale = form.consistent_prompt_version !== 'v3' || !form.consistent_identity_fingerprint
+ const stale = form.consistent_prompt_version !== 'v4' || !form.consistent_identity_fingerprint
|| form.consistent_identity_fingerprint !== fingerprint || form.identity_consistent === false;
return stale ? 'review' : 'ok';
}
@@ -1448,7 +1448,7 @@ async function _charDetailPage(rec, allChars, opts) {
// header at thumbnail size (confirmed live: 110px was too small to read
// any actual detail in a multi-pose design sheet).
const visualPairNeedsReview = !!(avatarImage && sh.concept_art_image && (
- sh.consistent_prompt_version !== 'v3'
+ sh.consistent_prompt_version !== 'v4'
|| !sh.consistent_identity_fingerprint
|| sh.consistent_identity_fingerprint !== identityFingerprintShort
|| sh.identity_consistent === false
@@ -3822,7 +3822,7 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
panel.setJobId(job_id);
const d = await _cijPollJob(job_id, panel);
if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair');
- const visualFields = { image: d.profile, concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', profile_asset_digest: d.profile_asset_digest || '', reference_asset_digest: d.reference_asset_digest || '', identity_consistent: d.identity_consistent !== false, image_check: d.image_check || null };
+ const visualFields = { image: d.profile, concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v4', consistent_identity_fingerprint: d.identity_fingerprint || '', profile_asset_digest: d.profile_asset_digest || '', reference_asset_digest: d.reference_asset_digest || '', identity_consistent: d.identity_consistent !== false, image_check: d.image_check || null };
await _libSaveCharacterVisual(rec, visualFields);
document.querySelectorAll('.lib-char-avatar[data-char-id="' + CSS.escape(rec.id) + '"]').forEach(function (av) {
av.innerHTML = '';
diff --git a/static/js/magazine.js b/static/js/magazine.js
index a4a13b5..c56fea7 100644
--- a/static/js/magazine.js
+++ b/static/js/magazine.js
@@ -475,8 +475,8 @@ function magParsePageProgress(detail) {
// drawn over the page image as they arrive, coloured by type and numbered in reading order.
const MAG_PREVIEW_TYPES = {
- title: 'Headline', heading: 'Subheading', deck: 'Standfirst', byline: 'Author', body: 'Paragraph',
- caption: 'Caption', pullquote: 'Pull quote', sidebar: 'Sidenote', ad: 'Advertisement',
+ title: 'Headline', heading: 'Subheading', deck: 'Intro / Standfirst', byline: 'Author', body: 'Paragraph',
+ quote: 'Quote', caption: 'Caption', pullquote: 'Pullquote', sidebar: 'Sidenote', ad: 'Advertisement',
furniture: 'Running head / footer', page_number: 'Page number', image: 'Picture',
figure: 'Picture', chart: 'Chart', table: 'Table', code: 'Code',
};
@@ -545,6 +545,7 @@ async function pollIngest() {
} catch (_) { break; }
const ing = data.ingest || {};
const detail = (data.job || {}).detail || '';
+ magMarkLive(ing.status === 'running' ? data.job : null, true);
if (bar) {
if (ing.status === 'running') {
bar.className = 'mag-status is-running';
@@ -795,15 +796,110 @@ const MAG_PAGES_COLS_KEY = 'ttsvc_mag_pages_cols';
// that touches it -- a blank page or an ad, not a gap
// A page touched by several articles takes the most-attention-needed state
// among them (audio beats approved beats detected beats empty).
-function magPageState(arts) {
- if (!arts.length) return 'blank';
+// The last whole-issue quality run splits the two unreviewed states further:
+// checked -- quality run found nothing left to fix (blue)
+// flagged -- quality run found uncovered text / overlaps / clipping / suspect words (purple)
+// flagged wins over every review state except a fully rejected page; clean only replaces 'unreviewed'.
+function magIsAdPage(page) {
+ return !!(state.layout?.pages || []).find((m) => m.page === page)?.advertisement;
+}
+
+// Approve / un-approve a whole page as an advertisement (server flag; nothing is deleted).
+async function magToggleAdPage(page) {
+ if (!state.doc || !Number.isInteger(page)) return;
+ const on = !magIsAdPage(page);
+ try {
+ await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/advertisement',
+ { method: on ? 'PUT' : 'DELETE' });
+ } catch (e) { return say('Could not update the page: ' + e.message, 'err'); }
+ const meta = (state.layout?.pages || []).find((m) => m.page === page);
+ if (meta) { if (on) meta.advertisement = true; else delete meta.advertisement; }
+ say(on ? 'Page ' + page + ' approved as advertisement — it will not be read aloud'
+ : 'Page ' + page + ' is no longer marked as advertisement', 'ok');
+ if (state.view === 'review') renderReview(); else renderContents();
+}
+
+function magPageState(arts, q, page) {
+ // A page the quality run flagged stays purple whatever its review state (a reviewed or
+ // audio page can still hide clipped or uncovered text) -- except one whose every article was
+ // rejected, which is a settled "nothing to narrate".
+ const flagged = !!(q && q.needs_review);
+ if (page != null && magIsAdPage(page)) return 'ad'; // approved as advertisement: settled, green
+ const rejectedOnly = arts.length > 0 && arts.every((a) => a.status === 'rejected');
+ if (flagged && !rejectedOnly) return 'flagged';
+ const unreviewed = q ? 'checked' : null;
+ if (!arts.length) return unreviewed || 'blank';
if (arts.some((a) => a.has_audio)) return 'audio';
- if (arts.some((a) => a.status === 'detected')) return 'detected';
+ if (arts.some((a) => a.status === 'detected')) return unreviewed || 'detected';
if (arts.some((a) => a.status === 'approved')) return 'approved';
return 'empty';
}
+// Quality-run rows by page, loaded once per document (and refreshed after a run).
+async function magLoadQuality(force) {
+ if (!state.doc) return;
+ if (!force && state.qualityDocId === state.doc.id) return;
+ const docId = state.doc.id;
+ state.qualityDocId = docId;
+ state.quality = new Map();
+ try {
+ const report = await api('/api/magazine/' + docId + '/quality/report');
+ if (state.doc && state.doc.id === docId) {
+ state.quality = new Map((report.pages || []).map((row) => [row.page, row]));
+ }
+ } catch (_) { /* no quality run yet */ }
+ if (state.doc && state.doc.id === docId && state.contentsView === 'pages' && state.view !== 'review') renderContents();
+}
+
+// Live marks while any batch step runs (ingest/OCR, quality run, figure fixing, audio export):
+// each server job reports {page | pages, step} in its result. With `sequential`, pages before
+// the working page get a tick. Applied straight to the existing cards -- no re-render per poll.
+// Pass nothing to clear the marks.
+function magMarkLive(job, sequential) {
+ const r = job && job.result;
+ const working = new Set(r && Array.isArray(r.pages) ? r.pages : (r && r.page != null ? [r.page] : []));
+ const first = working.size ? Math.min(...working) : null;
+ const step = r && r.step;
+ document.querySelectorAll('.mag-pages-grid .mag-card-wrap').forEach((wrap) => {
+ const n = Number(wrap.dataset.page);
+ const card = wrap.querySelector('.mag-card');
+ const isWorking = working.has(n);
+ const isDone = !!sequential && first != null && n < first;
+ card.classList.toggle('is-page-working', isWorking);
+ card.classList.toggle('is-page-live-done', isDone);
+ let tag = card.querySelector('.mag-live-tag');
+ if (isWorking || isDone) {
+ if (!tag) { tag = document.createElement('span'); tag.className = 'mag-live-tag'; card.prepend(tag); }
+ tag.textContent = isWorking ? (step || 'working…') : '✓ done';
+ } else if (tag) tag.remove();
+ });
+}
+
+function magQualityProblems(q) {
+ const bits = [];
+ if (q.uncovered.length) bits.push(q.uncovered.length + ' text area' + (q.uncovered.length === 1 ? '' : 's') + ' outside any box');
+ if (q.overlaps.length) bits.push(q.overlaps.length + ' overlapping box pair' + (q.overlaps.length === 1 ? '' : 's'));
+ if (q.clipped.length) bits.push(q.clipped.length + ' box edge' + (q.clipped.length === 1 ? '' : 's') + ' cutting into text');
+ if (q.suspect_blocks.length) bits.push(q.suspect_blocks.length + ' region' + (q.suspect_blocks.length === 1 ? '' : 's') + ' with suspect OCR words');
+ if (q.error) bits.push('the check itself failed');
+ return bits;
+}
+
+// Overlay on the thumbnail itself: a warning mark with the problem count, hover/focus text
+// listing what was found, and a click that opens that page in review.
+function magQualityBadge(q, page) {
+ if (!q || !q.needs_review) return '';
+ const bits = magQualityProblems(q);
+ const text = 'Page ' + page + ': ' + bits.join('; ');
+ return '';
+}
+
function renderPagesView(host) {
+ if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); });
+ if (state.qualityDocId !== state.doc?.id) magLoadQuality(false);
+ const quality = state.quality || new Map();
const pageCount = state.doc?.pageCount || 0;
if (!pageCount) { host.innerHTML = '
Page count unknown yet.
'; return; } const byPage = new Map(); @@ -812,8 +908,9 @@ function renderPagesView(host) { byPage.get(p).push(a); })); const legend = [ - ['blank', 'not reviewed yet'], ['detected', 'has content, not reviewed'], - ['approved', 'reviewed'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'], + ['blank', 'not reviewed yet'], ['detected', 'has content, not quality-checked'], + ['checked', 'quality check clean'], ['flagged', 'quality check found problems'], + ['approved', 'reviewed'], ['ad', 'approved as advertisement (not read)'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'], ]; let html = '' + legend.map(([cls, label]) => '' + label + '').join('') + @@ -821,21 +918,24 @@ function renderPagesView(host) { html += '