feat(magazine): whole-magazine quality check, page stages, ad pages, driving-mode player
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

- Quality run: per-page missed-text recovery, guarded OCR re-read and a pixel audit
  (uncovered ink, overlapping boxes, clipped box edges); report saved as quality_report.json
- Thumbnail grid: quality-stage colours, flag badge, live per-page indicator for every
  batch step (OCR, detection, figure fixing, quality run, audio)
- Pages can be approved as advertisements (not narrated, skipped by quality run)
- Driving mode: +/-15 s, seek bar, playback speed, text and image toggles
- Plus the previously uncommitted 1.53.5 magazine/OCR work in the same tree

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-26 19:29:43 +02:00
parent 7e29751607
commit 9904459913
18 changed files with 2050 additions and 131 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -1 +1 @@
1.53.4
1.53.5

View File

@ -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.

View File

@ -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"],

View File

@ -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()

View File

@ -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."
)

View File

@ -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,

View File

@ -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 = '<img src="' + d.profile + '" alt="' + escHtml(rec.name) + '">';

View File

@ -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 '<button type="button" class="mag-thumb-flag" title="' + esc(text) + '" aria-label="' + esc(text + '. Open in review') +
'" onclick="magReviewPage(' + Number(page) + ')"><span class="mdi mdi-alert-circle-outline" aria-hidden="true"></span> ' +
bits.length + '</button>';
}
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 = '<p class="muted">Page count unknown yet.</p>'; 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 = '<p class="mag-pages-legend">' +
legend.map(([cls, label]) => '<span class="mag-pages-legend-item"><span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + label + '</span>').join('') +
@ -821,21 +918,24 @@ function renderPagesView(host) {
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
for (let p = 1; p <= pageCount; p++) {
const arts = byPage.get(p) || [];
html += '<div class="mag-card-wrap"><div class="mag-card is-page-' + magPageState(arts) + '">' +
html += '<div class="mag-card-wrap" data-page="' + p + '"><div class="mag-card is-page-' + magPageState(arts, quality.get(p), p) + '">' +
'<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>' +
'<span class="mag-card-title">Page ' + p + '</span>' + magQualityBadge(quality.get(p), p) +
(arts.length
? '<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>' +
'<button type="button" class="btn-sm" onclick="magReviewAtPage(\'' + a.id + '\', ' + p + ')">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>') +
'<button type="button" class="btn-sm mag-ad-btn" aria-pressed="' + String(magIsAdPage(p)) + '" onclick="magToggleAdPage(' + p + ')" ' +
'title="Approve this whole page as an advertisement: nothing on it is read aloud">' +
'<span class="mdi mdi-bullhorn-outline" aria-hidden="true"></span> ' + (magIsAdPage(p) ? 'Advertisement ✓ (undo)' : 'Advertisement') + '</button>' +
'</div></div>';
}
html += '</div>';
@ -1017,6 +1117,7 @@ async function magGenerateAllAudio(force = false) {
return say('Could not check audio export: ' + e.message, 'err');
}
if (status) status.textContent = job.detail || 'Working…';
magMarkLive(job.done ? null : job, false);
// The job's own detail text always leads with "N/Total" -- either
// "Article N/Total…" between articles, or "(N/Total) Title: sentence
// X/Y" while one is actively rendering. "Article 6 of 44" means little
@ -1073,6 +1174,12 @@ async function magReview(articleId) {
renderReview();
}
async function magReviewAtPage(articleId, page) {
await magReview(articleId);
if (state.article?.id !== articleId) return;
magReviewGoMagazinePage(Number(page));
}
async function ensureLayout() {
if (state.layout) return;
try {
@ -1106,9 +1213,15 @@ function magReviewGoPage(page) {
renderReview();
}
function magReviewGoMagazinePage(page) {
async function magReviewGoMagazinePage(page) {
const lastPage = Number(state.layout?.page_count || 0);
if (!Number.isInteger(page) || page < 1 || page > lastPage) return;
const currentCoversPage = (state.article?.pages || []).includes(page);
if (!currentCoversPage) {
const owner = state.articles.find((article) => (article.pages || []).includes(page));
if (owner) return magReviewAtPage(owner.id, page);
return magReviewPage(page);
}
state.reviewPage = page;
renderReview();
}
@ -1123,12 +1236,13 @@ function magReviewGoMagazinePage(page) {
// (see magReviewSubmitDraw); everything else (page number, source, ad,
// table, duplicate, OCR noise, caption, pull quote, sidenote) is furniture
// or reference material excluded from narration on purpose and stays unticked.
const MAG_NARRATABLE_KINDS = new Set(['title', 'heading', 'deck', 'byline', 'body']);
const MAG_NARRATABLE_KINDS = new Set(['title', 'heading', 'deck', 'byline', 'body', 'quote']);
const MAG_REGION_TYPES = [
['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'],
['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'],
['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'],
['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['table', 'Table'], ['image', 'Image'],
['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Intro / Standfirst'],
['byline', 'Author'], ['image', 'Image'], ['caption', 'Caption'], ['in_picture', 'Text in Picture'],
['table', 'Table'], ['quote', 'Quote'], ['pullquote', 'Pullquote'], ['sidebar', 'Sidenote'],
['ad', 'Advertisement'], ['page_number', 'Page Number'], ['source', 'Footer'],
['duplicate', 'Duplicate'], ['ocr_noise', 'OCR Noise'],
];
// A compact subset of MAG_REGION_TYPES for the review page's colour legend
// -- the full 16-entry list would be noisy here, and several of those kinds
@ -1136,8 +1250,8 @@ const MAG_REGION_TYPES = [
// "furniture" on the page itself, so listing them separately would promise
// a colour distinction the boxes don't actually make.
const MAG_LEGEND_TYPES = [
['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], ['byline', 'Author'],
['body', 'Paragraph'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'],
['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Intro / Standfirst'], ['byline', 'Author'],
['body', 'Paragraph'], ['quote', 'Quote'], ['caption', 'Caption'], ['pullquote', 'Pullquote'], ['sidebar', 'Sidenote'],
['ad', 'Advertisement'], ['furniture', 'Page number / footer'],
['image', 'Image'], ['chart', 'Chart'], ['table', 'Table'], ['code', 'Code'],
];
@ -1152,6 +1266,7 @@ function magReviewLegendHtml() {
const MAG_REGION_TYPE_ICONS = {
body: 'mdi-text-box-outline', title: 'mdi-format-title', heading: 'mdi-format-header-2',
deck: 'mdi-text-short', byline: 'mdi-account-edit-outline', caption: 'mdi-image-text',
quote: 'mdi-format-quote-open',
pullquote: 'mdi-format-quote-close', sidebar: 'mdi-note-text-outline', page_number: 'mdi-numeric',
source: 'mdi-book-open-page-variant-outline', ad: 'mdi-advertisements-outline',
in_picture: 'mdi-text-recognition', ocr_noise: 'mdi-alert-circle-outline',
@ -1173,16 +1288,93 @@ function magRegionTypeValue(region) {
function magRegionControls(region, number) {
const current = magRegionTypeValue(region);
const known = MAG_REGION_TYPES.some(([v]) => v === current);
const options = (known ? [] : [[current, current]]).concat(MAG_REGION_TYPES)
.map(([v, label]) => '<option value="' + v + '"' + (v === current ? ' selected' : '') + '>' + esc(label) + '</option>').join('');
return '<select class="mag-block-type-select" aria-label="Type of region ' + number + '" ' +
'onclick="event.stopPropagation()" onchange="magReviewChangeType(\'' + esc(region.id) + '\', this.value, this)">' +
options + '</select>' +
const options = (known ? MAG_REGION_TYPES : [[current, current]].concat(MAG_REGION_TYPES));
const label = (options.find(([value]) => value === current) || [current, current])[1];
return '<div class="mag-type-picker mag-region-type-picker" data-region-id="' + esc(region.id) + '" ' +
'data-current="' + esc(current) + '" onclick="event.preventDefault();event.stopPropagation()">' +
'<input type="text" class="mag-type-picker-input mag-block-type-search" value="' + esc(label) + '" ' +
'aria-label="Type of region ' + number + '" autocomplete="off" role="combobox" aria-autocomplete="list" ' +
'aria-expanded="false" onfocus="magRegionPickerFocus(this)" oninput="magRegionPickerInput(this)" ' +
'onkeydown="magRegionPickerKeydown(event,this)" onblur="magRegionPickerBlur(this)">' +
'<input type="hidden" class="mag-block-type-select" value="' + esc(current) + '">' +
'<ul class="mag-type-picker-list" role="listbox" hidden></ul>' +
'</div>' +
'<button type="button" class="btn-sm mag-block-delete" aria-label="Delete region ' + number + '" title="Delete this region" ' +
'onclick="event.preventDefault(); event.stopPropagation(); magReviewDeleteRegion(\'' + esc(region.id) + '\')">' +
'<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span></button>';
}
function _magRegionPickerLabel(value) {
return (MAG_REGION_TYPES.find(([candidate]) => candidate === value) || [value, value])[1];
}
function _magRenderRegionPicker(input) {
const picker = input.closest('.mag-region-type-picker');
const list = picker && picker.querySelector('.mag-type-picker-list');
if (!picker || !list) return;
const query = input.value.trim().toLowerCase();
const matches = query
? MAG_REGION_TYPES.filter(([, label]) => label.toLowerCase().includes(query))
: MAG_REGION_TYPES;
list.innerHTML = matches.length ? matches.map(([value, label]) =>
'<li role="option" data-value="' + value + '" class="mag-type-picker-option' +
(value === picker.dataset.current ? ' is-selected' : '') + '" ' +
'onmousedown="magRegionPickerChoose(this,event)">' +
'<span class="mdi ' + (MAG_REGION_TYPE_ICONS[value] || 'mdi-text-box-outline') + '" aria-hidden="true"></span> ' +
esc(label) + '</li>').join('')
: '<li class="mag-type-picker-empty muted">No matching region type</li>';
list.hidden = false;
input.setAttribute('aria-expanded', 'true');
}
// The selected label is useful while scanning rows, but it is not a search
// query. Clear it as soon as the field receives focus so typing can start
// immediately without first deleting “Paragraph”.
function magRegionPickerFocus(input) {
input.value = '';
_magRenderRegionPicker(input);
}
function magRegionPickerInput(input) { _magRenderRegionPicker(input); }
function magRegionPickerBlur(input) {
setTimeout(() => {
const picker = input.closest('.mag-region-type-picker');
if (!picker) return;
const list = picker.querySelector('.mag-type-picker-list');
if (list) list.hidden = true;
input.value = _magRegionPickerLabel(picker.dataset.current);
input.setAttribute('aria-expanded', 'false');
}, 0);
}
function magRegionPickerKeydown(event, input) {
const picker = input.closest('.mag-region-type-picker');
const list = picker && picker.querySelector('.mag-type-picker-list');
if (event.key === 'Enter' && list) {
const first = list.querySelector('.mag-type-picker-option');
if (first) { event.preventDefault(); magRegionPickerChoose(first, event); }
} else if (event.key === 'Escape') {
if (list) list.hidden = true;
input.value = _magRegionPickerLabel(picker.dataset.current);
input.setAttribute('aria-expanded', 'false');
input.blur();
}
}
function magRegionPickerChoose(option, event) {
event.preventDefault();
event.stopPropagation();
const picker = option.closest('.mag-region-type-picker');
const input = picker.querySelector('.mag-block-type-search');
const list = picker.querySelector('.mag-type-picker-list');
const value = option.dataset.value;
input.value = _magRegionPickerLabel(value);
list.hidden = true;
input.setAttribute('aria-expanded', 'false');
if (value !== picker.dataset.current) magReviewChangeType(picker.dataset.regionId, value, input);
}
async function magReviewChangeType(regionId, type, select) {
const toImage = type === 'image';
const fromImage = !!(state.layout?.images || []).some((img) => img.id === regionId);
@ -1206,6 +1398,7 @@ async function magReviewChangeType(regionId, type, select) {
function magBlockIcon(type) {
if (type === 'image' || type === 'figure') return 'mdi-image-outline';
if (type === 'quote') return 'mdi-format-quote-open';
if (type === 'pullquote') return 'mdi-format-quote-close';
if (type === 'chart') return 'mdi-chart-bar';
if (type === 'table') return 'mdi-table';
@ -1342,7 +1535,7 @@ function magReviewPageColumn(page, wrapId, interactive) {
return;
}
const b = r.data;
const narratable = ['title', 'heading', 'deck', 'byline', 'body'].includes(b.type);
const narratable = MAG_NARRATABLE_KINDS.has(b.type);
const on = included.has(b.id);
listHtml += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
@ -1448,10 +1641,21 @@ function renderReview() {
'title="Stop reading text inside pictures, OCR noise and duplicated boxes, and split author lines that run into the text. Nothing is deleted; a backup is kept.">' +
'<span class="mdi mdi-broom" aria-hidden="true"></span> Clean up boxes</button>' +
'<button type="button" class="btn-sm" id="mag-refine-figures-btn" onclick="magRefineFigures()" ' +
'title="Re-check every figure boundary in this issue against the scanned pages">' +
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries</button>' +
'title="Re-check figure boundaries on this page only">' +
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries (this page)</button>' +
'<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
'<button type="button" class="btn-sm" id="mag-find-missing-btn" onclick="magRunPageQuality(\'missing\')" ' +
'title="Use an independent OCR pass to find text regions that are absent, without replacing existing boxes">' +
'<span class="mdi mdi-text-box-search-outline"></span> Find missed text</button>' +
'<button type="button" class="btn-sm" id="mag-verify-text-btn" onclick="magRunPageQuality(\'recognition\')" ' +
'title="Flag suspicious words by language, then re-read their page crops before changing anything">' +
'<span class="mdi mdi-spellcheck"></span> Verify OCR text</button>' +
'<button type="button" class="btn-sm" id="mag-ad-page-btn" onclick="magToggleAdPage(' + Number(page) + ')" ' +
'aria-pressed="' + String(magIsAdPage(page)) + '" ' +
'title="Approve this whole page as an advertisement: nothing on it is read aloud and the quality check skips it">' +
'<span class="mdi mdi-bullhorn-outline" aria-hidden="true"></span> ' +
(magIsAdPage(page) ? 'Advertisement page ✓ (undo)' : 'Approve page as advertisement') + '</button>' +
'<span id="mag-reocr-status" class="muted" role="status"></span>' +
'</div>';
@ -1512,7 +1716,7 @@ function renderReview() {
'<button type="button" class="btn" onclick="magShow(\'contents\')">Back to contents</button>' +
'<button type="button" class="btn" onclick="magSetStatus(\'rejected\')">Not an article</button>' +
'<button type="button" class="btn primary" onclick="magApprove()" ' +
'title="Approve this article, then jump straight to the next page that still needs a decision -- an unreviewed article, or a page nothing covers yet">' +
'title="Approve this article, then open the immediately following physical page">' +
'Approve &amp; Next</button>' +
'</div>';
host.innerHTML = html;
@ -2558,7 +2762,7 @@ function _magSetupDrawKindPicker(options) {
// Start on the default (Paragraph) with its label showing, matching the
// hidden input's own default value set in the HTML above.
pick(hidden.value, (options.find(([v]) => v === hidden.value) || [])[1] || '');
search.addEventListener('focus', open);
search.addEventListener('focus', () => { search.value = ''; open(); });
search.addEventListener('input', () => { list.hidden = false; search.setAttribute('aria-expanded', 'true'); renderList(search.value); });
search.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { list.hidden = true; search.setAttribute('aria-expanded', 'false'); search.blur(); }
@ -2748,18 +2952,62 @@ async function magReocrPage() {
renderReview();
}
// Re-derives every figure box in the issue from the page renders. Separate
// from "OCR again" because no OCR engine is involved: it only corrects where
// the figures are, never what the page says, so it is safe to run on an issue
// that is half reviewed already.
async function magRefineFigures() {
const btn = $('mag-refine-figures-btn');
async function magRunPageQuality(mode) {
magOfferUndo(null);
const page = state.reviewPage;
if (page == null) return;
const isMissing = mode === 'missing';
const btn = $(isMissing ? 'mag-find-missing-btn' : 'mag-verify-text-btn');
const statusEl = $('mag-reocr-status');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Checking figure boundaries…';
if (statusEl) statusEl.textContent = isMissing
? 'Checking page ' + page + ' for missed text…'
: 'Verifying suspicious words on page ' + page + '…';
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/figures/refine', { method: 'POST' });
const result = await api('/api/magazine/' + state.doc.id + '/pages/' + page +
'/quality/' + mode, { method: 'POST' });
jobId = result.job_id;
} catch (e) {
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
return say('Could not start quality check: ' + e.message, 'err');
}
let failed = false;
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
if (job.done) {
if (job.error) { failed = true; say('Quality check failed: ' + job.error, 'err'); }
else say(job.detail || 'Page quality checked', 'ok');
break;
}
if (statusEl && job.detail) statusEl.textContent = job.detail;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
if (failed) return;
state.layout = null;
await ensureLayout();
_magReviewChecked = null;
renderReview();
}
// Re-derives figure boxes from the page renders. Review uses the active page;
// the contents toolbar exposes the intentionally separate whole-issue batch.
// No OCR engine is involved: this only corrects where figures are.
async function magRunFigureRefine(page) {
const wholeIssue = page == null;
const btn = $(wholeIssue ? 'mag-refine-all-figures-btn' : 'mag-refine-figures-btn');
const statusEl = $('mag-reocr-status');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = wholeIssue
? 'Checking every page…' : 'Checking figure boundaries on page ' + page + '…';
let jobId;
try {
const endpoint = wholeIssue ? '/figures/refine' : '/pages/' + page + '/figures/refine';
const r = await api('/api/magazine/' + state.doc.id + endpoint, { method: 'POST' });
jobId = r.job_id;
} catch (e) {
if (btn) btn.disabled = false;
@ -2775,16 +3023,113 @@ async function magRefineFigures() {
else say(job.detail || 'Figure boundaries checked', 'ok');
break;
}
magMarkLive(job, wholeIssue);
if (statusEl && job.detail) statusEl.textContent = job.detail;
await new Promise((r) => setTimeout(r, 2000));
}
magMarkLive(null);
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
if (failed) return;
state.layout = null;
await ensureLayout();
_magReviewChecked = null;
renderReview();
if (state.view === 'review') renderReview();
else renderContents();
}
// Whole-issue completeness run (server: _quality_run_sync). Adds text no box covers,
// re-reads suspicious OCR, then lists the pages whose render still has uncovered ink or
// suspicious words so the reviewer can jump straight to them.
function magRenderQualityReport(report) {
const box = $('mag-quality-report');
if (!box) return;
const flagged = (report.pages || []).filter((p) => p.needs_review);
if (!flagged.length) {
box.hidden = false;
box.textContent = 'Quality check: every page is fully covered and no suspicious words remain.';
return;
}
box.hidden = false;
box.innerHTML = '<strong>' + flagged.length + ' page' + (flagged.length === 1 ? '' : 's') +
' still need a look:</strong> ' + flagged.map((p) => {
const bits = [];
if (p.uncovered.length) bits.push(p.uncovered.length + ' uncovered area' + (p.uncovered.length === 1 ? '' : 's'));
if (p.suspect_blocks.length) bits.push(p.suspect_blocks.length + ' suspect text');
if (p.overlaps.length) bits.push(p.overlaps.length + ' overlapping');
if (p.clipped.length) bits.push(p.clipped.length + ' clipped');
if (p.error) bits.push('check failed');
return '<button type="button" class="btn-sm" onclick="magReviewPage(' + Number(p.page) + ')" title="' +
esc(bits.join(', ')) + '">Page ' + Number(p.page) + ' (' + esc(bits.join(', ')) + ')</button>';
}).join(' ');
}
async function magQualityRun() {
if (!state.doc) return;
const ok = await confirmDialog(
'Scan every page for text no box covers and re-read suspicious OCR? This can take a while on a long issue. ' +
'Added regions start unticked, so nothing new is read aloud until you approve it.',
{ title: 'Quality check the whole magazine?', okLabel: 'Run check' });
if (!ok) return;
const btn = $('mag-quality-run-btn');
const box = $('mag-quality-report');
const label = btn && btn.querySelector('.mag-qrun-label');
const showProgress = (text, pct) => {
if (box) {
box.hidden = false;
box.innerHTML = '<div>' + esc(text) + '</div>' +
(pct == null ? '<progress aria-label="Quality check progress"></progress>'
: '<progress max="100" value="' + pct + '" aria-label="Quality check progress"></progress>');
}
if (label) label.textContent = pct == null ? 'Checking…' : 'Checking… ' + pct + '%';
};
if (btn) btn.disabled = true;
showProgress('Starting quality check…', null);
let jobId;
try {
jobId = (await api('/api/magazine/' + state.doc.id + '/quality/run', { method: 'POST' })).job_id;
} catch (e) {
if (btn) btn.disabled = false;
if (label) label.textContent = 'Quality check / run';
if (box) box.hidden = true;
return say('Could not start the quality check: ' + e.message, 'err');
}
let failed = false;
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
if (job.done) {
if (job.error) { failed = true; say('Quality check failed: ' + job.error, 'err'); }
else say(job.detail || 'Quality check finished', 'ok');
break;
}
const prog = magParsePageProgress(job.detail);
showProgress(job.detail || 'Working…', prog ? prog.pct : null);
magMarkLive(job, true);
await new Promise((r) => setTimeout(r, 1500));
}
if (btn) btn.disabled = false;
if (label) label.textContent = 'Quality check / run';
magMarkLive(null);
if (failed) { if (box) box.textContent = 'Quality check failed — see the message above.'; return; }
state.layout = null;
await ensureLayout();
if (state.view !== 'review') renderContents();
try { magRenderQualityReport(await api('/api/magazine/' + state.doc.id + '/quality/report')); } catch (_) { /* no report */ }
await magLoadQuality(true);
}
async function magRefineFigures() {
if (!state.reviewPage) return;
return magRunFigureRefine(state.reviewPage);
}
async function magRefineAllFigures() {
const ok = await confirmDialog(
'Check and re-crop figure boundaries on every page in this magazine?',
{ title: 'Fix all figure boundaries?', okLabel: 'Fix all pages' });
if (!ok) return;
return magRunFigureRefine(null);
}
async function magApprove() {
@ -2807,21 +3152,15 @@ async function magApprove() {
});
Object.assign(a, updated);
say('Approved — ready to listen', 'ok');
// Approving one article at a time is a queue, not a one-off — staying on
// the list meant re-finding and re-opening whichever article came next
// after every single approval. Go straight to it instead, and only fall
// back to the list once nothing is left to review.
// Next PAGE needing attention, not next article -- an article can span
// many pages or share one with others, and jumping by article order
// alone is exactly what left pages uncovered by any article invisible
// to this whole workflow (see magUncoveredPageRanges). Walking forward
// page by page and reusing magReviewPage's "create a stub if nothing
// covers it" logic means a gap gets exactly the same one-click
// attention as an already-detected-but-unreviewed article.
const lastPage = Math.max(0, ...(a.pages || []));
// Review is a physical page-by-page workflow. An article may span many
// pages, so advancing from its last claimed page skips pages the user has
// not inspected yet. Always advance from the page currently on screen;
// magReviewPage creates a page-scoped placeholder if nothing covers it.
const nextPage = Number(state.reviewPage || 0) + 1;
await magRefreshArticles();
const nextPage = magNextUnresolvedPage(lastPage);
if (nextPage) magReviewPage(nextPage);
if (nextPage <= Number(state.doc?.pageCount || state.layout?.page_count || 0)) {
await magReviewPage(nextPage);
}
else magShow('contents');
} catch (e) {
say('Could not approve: ' + e.message, 'err');
@ -2855,10 +3194,14 @@ function splitSentences(blocks) {
const parts = String(b.text || '')
.split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/)
.map((s) => s.trim()).filter(Boolean);
(parts.length ? parts : [b.text]).forEach((text) => {
const spokenParts = String(b.spoken_text || b.text || '')
.split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/)
.map((s) => s.trim()).filter(Boolean);
(parts.length ? parts : [b.text]).forEach((text, partIndex) => {
if (text && text.trim()) {
out.push({
text: text.trim(), blockId: b.id, page: b.page, bbox: b.bbox, type: b.type,
spokenText: spokenParts[partIndex] || text.trim(),
// Used to pick "the image reached so far" during playback (see
// currentImageIndex) -- the backend already computes this per
// block relative to the whole page, not just this article.
@ -3136,7 +3479,7 @@ async function magPrepareAudio() {
const worker = async () => {
while (!failure && !state.prepCancel && gen === state.prepGen && next < order.length) {
const i = order[next++];
try { await magSynth(voice, state.sentences[i].text); }
try { await magSynth(voice, state.sentences[i].spokenText || state.sentences[i].text); }
catch (e) { failure = { i, message: e.message }; return; }
done += 1;
if (gen === state.prepGen) syncPrepButton(done, total);
@ -3171,13 +3514,13 @@ async function magPlay() {
const sentence = state.sentences[state.idx];
let blob;
try {
const pending = magSynth(voice, sentence.text);
const pending = magSynth(voice, sentence.spokenText || sentence.text);
// Without a full "Prepare audio" run, still stay a few sentences ahead
// so the wait is only ever for the first one.
if (!state.preparing) {
for (let k = 1; k <= MAG_LOOKAHEAD; k++) {
const upcoming = state.sentences[state.idx + k];
if (upcoming) magSynth(voice, upcoming.text).catch(() => {});
if (upcoming) magSynth(voice, upcoming.spokenText || upcoming.text).catch(() => {});
}
}
blob = await pending;
@ -3435,10 +3778,7 @@ function magPlaylistSync() {
}
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);
if (state.driving) magDrivingSync(audio, duration, pct, index);
}
async function loadPlaylistCompanion(a) {
@ -3495,7 +3835,7 @@ function magPlaylistPlay(articleId, options) {
}
renderPlaylistTracks();
loadPlaylistCompanion(a);
if (state.driving) renderDriving();
if (state.driving) { renderDriving(); magDrivingApplyToggles(); magApplyPlaybackSpeed(); }
}
function _magPlaylistNext(eventOrCompleted) {
@ -3799,6 +4139,116 @@ function magDrivingTogglePlayback() {
magPlay();
}
// ── Driving mode extras: +/-15 s, speed, text and image toggles, seek ────────
const MAG_DRIVING_SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2];
const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs';
function magDrivingPrefs() {
if (!state.drivingPrefs) {
let saved = {};
try { saved = JSON.parse(localStorage.getItem(MAG_DRIVING_PREFS_KEY) || '{}') || {}; } catch (_) {}
state.drivingPrefs = {
speed: MAG_DRIVING_SPEEDS.includes(saved.speed) ? saved.speed : 1,
text: saved.text !== false, images: saved.images !== false,
};
}
return state.drivingPrefs;
}
function magDrivingSavePrefs() {
try { localStorage.setItem(MAG_DRIVING_PREFS_KEY, JSON.stringify(magDrivingPrefs())); } catch (_) {}
}
// Speed lives on the audio element; a new article's src resets playbackRate to the element's
// default, so both are set and re-applied whenever a track loads.
function magApplyPlaybackSpeed() {
const audio = $('mag-playlist-audio');
const speed = magDrivingPrefs().speed;
if (audio) { audio.defaultPlaybackRate = speed; audio.playbackRate = speed; }
const btn = $('mag-driving-speed');
if (btn) {
btn.textContent = speed + '×';
btn.setAttribute('aria-label', 'Playback speed ' + speed + '×. Press to change');
}
}
function magDrivingCycleSpeed() {
const prefs = magDrivingPrefs();
prefs.speed = MAG_DRIVING_SPEEDS[(MAG_DRIVING_SPEEDS.indexOf(prefs.speed) + 1) % MAG_DRIVING_SPEEDS.length];
magDrivingSavePrefs();
magApplyPlaybackSpeed();
}
function magDrivingSkip(seconds) {
const audio = $('mag-playlist-audio');
if (!audio || !state.playlistCurrent) return;
const end = Number.isFinite(audio.duration) ? audio.duration : Infinity;
audio.currentTime = Math.max(0, Math.min(end, audio.currentTime + seconds));
}
function magDrivingSeek(value) {
const audio = $('mag-playlist-audio');
if (!audio || !Number.isFinite(audio.duration)) return;
audio.currentTime = audio.duration * (Number(value) / 1000);
}
function magDrivingToggleText() {
const prefs = magDrivingPrefs();
prefs.text = !prefs.text;
magDrivingSavePrefs();
magDrivingApplyToggles();
magPlaylistSync();
}
function magDrivingToggleImages() {
const prefs = magDrivingPrefs();
prefs.images = !prefs.images;
magDrivingSavePrefs();
magDrivingApplyToggles();
magPlaylistSync();
}
function magDrivingApplyToggles() {
const prefs = magDrivingPrefs();
const text = $('mag-driving-text-toggle'), images = $('mag-driving-image-toggle');
if (text) text.setAttribute('aria-pressed', String(prefs.text));
if (images) images.setAttribute('aria-pressed', String(prefs.images));
const lyrics = $('mag-driving-lyrics'), visual = $('mag-driving-visual');
if (lyrics && !prefs.text) lyrics.hidden = true;
if (visual && !prefs.images) visual.hidden = true;
}
// Called from magPlaylistSync on every timeupdate while the overlay is open.
function magDrivingSync(audio, duration, pct, sentenceIndex) {
const prefs = magDrivingPrefs();
const seek = $('mag-driving-seek');
if (seek && document.activeElement !== seek) seek.value = String(Math.round(pct * 10));
const time = $('mag-driving-time');
if (time) time.textContent = magFmtClock(audio.currentTime) + ' / ' + magFmtClock(duration);
const sentences = state.playlistSentences || [];
const lyrics = $('mag-driving-lyrics');
if (lyrics) {
lyrics.hidden = !prefs.text || !sentences.length;
if (!lyrics.hidden && lyrics.dataset.index !== String(sentenceIndex)) {
lyrics.dataset.index = String(sentenceIndex);
const set = (id, i) => { const el = $(id); if (el) el.textContent = sentences[i]?.text || ''; };
set('mag-driving-line-prev', sentenceIndex - 1);
set('mag-driving-line-current', sentenceIndex);
set('mag-driving-line-next', sentenceIndex + 1);
}
}
const visual = $('mag-driving-visual');
const source = $('mag-playlist-visual');
if (visual) {
const show = prefs.images && source && !source.hidden && source.dataset.imageId;
visual.hidden = !show;
if (show && visual.dataset.imageId !== source.dataset.imageId) {
visual.dataset.imageId = source.dataset.imageId;
visual.innerHTML = source.innerHTML;
}
}
}
function magDrivingNext() {
if (state.playlistCurrent) return _magPlaylistNext(false);
magQueueNext();
@ -3824,6 +4274,7 @@ function wirePlaylistAudio() {
renderPlaylistTracks();
magPlaylistSync();
});
audio.addEventListener('loadedmetadata', magApplyPlaybackSpeed);
audio.addEventListener('play', syncDrivingPlayButton);
audio.addEventListener('pause', () => {
if (!state.playlistSwitching) savePlaylistProgress(false, true);
@ -3898,6 +4349,7 @@ root.magContentsClearSelection = magContentsClearSelection;
root.magContentsBulkStatus = magContentsBulkStatus;
root.magReviewGoPage = magReviewGoPage;
root.magReviewGoMagazinePage = magReviewGoMagazinePage;
root.magReviewAtPage = magReviewAtPage;
root.magReviewToggleSpread = magReviewToggleSpread;
root.magReviewSetActivePage = magReviewSetActivePage;
root.magReviewFocusBlock = magReviewFocusBlock;
@ -3908,6 +4360,9 @@ root.magReviewClearCurrentPage = magReviewClearCurrentPage;
root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes;
root.magReocrPage = magReocrPage;
root.magRefineFigures = magRefineFigures;
root.magRefineAllFigures = magRefineAllFigures;
root.magQualityRun = magQualityRun;
root.magToggleAdPage = magToggleAdPage;
root.magReviewToggleImage = magReviewToggleImage;
root.magDescribeImage = magDescribeImage;
root.magSaveImageDescription = magSaveImageDescription;
@ -3958,6 +4413,11 @@ root.magToggleDriving = magToggleDriving;
root.magOpenDriving = magOpenDriving;
root.magDrivingTogglePlayback = magDrivingTogglePlayback;
root.magDrivingNext = magDrivingNext;
root.magDrivingSkip = magDrivingSkip;
root.magDrivingSeek = magDrivingSeek;
root.magDrivingCycleSpeed = magDrivingCycleSpeed;
root.magDrivingToggleText = magDrivingToggleText;
root.magDrivingToggleImages = magDrivingToggleImages;
root.magDrivingPrevious = magDrivingPrevious;
root.magInit = magInit;
MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages };

View File

@ -64,6 +64,10 @@
onblur="magCommitDocField(this, 'issue')"
onkeydown="if(event.key==='Enter'){this.blur();}">
<div class="mag-bar-actions">
<button type="button" class="btn-sm" id="mag-quality-run-btn" onclick="magQualityRun()"
title="Check every page: add text no box covers, re-read suspicious OCR, and list pages that still need a look">
<span class="mdi mdi-clipboard-check-outline"></span> <span class="mag-qrun-label">Quality check / run</span>
</button>
<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
</button>
@ -92,8 +96,13 @@
<button type="button" class="btn-sm" onclick="magReocrMagazine()" title="Send every page through the configured OCR engine again, then re-detect articles">
<span class="mdi mdi-text-recognition"></span> OCR again (whole magazine)
</button>
<button type="button" class="btn-sm" id="mag-refine-all-figures-btn" onclick="magRefineAllFigures()"
title="Batch-check figure boundaries on every page in this magazine">
<span class="mdi mdi-image-auto-adjust"></span> Fix all figure boundaries
</button>
</div>
</div>
<div id="mag-quality-report" class="mag-quality-report" role="region" aria-label="Quality check result" aria-live="polite" hidden></div>
<div id="mag-contents-bulkbar" class="mag-bulkbar" hidden></div>
<div class="mag-audio-export">
<label for="mag-audio-voice">Narrator for exported MP3s</label>
@ -276,18 +285,41 @@
<div id="mag-driving-queue" class="mag-driving-queue" hidden>
<span id="mag-queue-progress-2" class="muted"></span>
</div>
<figure id="mag-driving-visual" class="mag-driving-visual" hidden></figure>
<div id="mag-driving-lyrics" class="mag-driving-lyrics" aria-live="off" hidden>
<p class="mag-driving-line is-prev" id="mag-driving-line-prev"></p>
<p class="mag-driving-line is-current" id="mag-driving-line-current"></p>
<p class="mag-driving-line is-next" id="mag-driving-line-next"></p>
</div>
<div class="mag-driving-seek">
<input type="range" id="mag-driving-seek" min="0" max="1000" value="0" step="1"
aria-label="Position in article" oninput="magDrivingSeek(this.value)">
<div class="mag-driving-time" id="mag-driving-time" aria-live="off">0:00 / 0:00</div>
</div>
<div class="mag-driving-controls">
<button type="button" class="mag-driving-btn" onclick="magDrivingPrevious()" aria-label="Previous article" title="Previous article">
<button type="button" class="mag-driving-btn is-small" onclick="magDrivingPrevious()" aria-label="Previous article" title="Previous article">
<span class="mdi mdi-skip-previous"></span>
</button>
<button type="button" class="mag-driving-btn" onclick="magDrivingSkip(-15)" aria-label="Back 15 seconds" title="Back 15 seconds">
<span class="mdi mdi-rewind-15"></span>
</button>
<button type="button" class="mag-driving-btn mag-driving-play" id="mag-driving-play" onclick="magDrivingTogglePlayback()" aria-label="Play">
<span class="mdi mdi-play"></span>
</button>
<button type="button" class="mag-driving-btn" onclick="magDrivingNext()" aria-label="Next article" title="Next article">
<button type="button" class="mag-driving-btn" onclick="magDrivingSkip(15)" aria-label="Forward 15 seconds" title="Forward 15 seconds">
<span class="mdi mdi-fast-forward-15"></span>
</button>
<button type="button" class="mag-driving-btn is-small" 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 class="mag-driving-options" role="group" aria-label="Playback options">
<button type="button" class="mag-driving-opt" id="mag-driving-speed" onclick="magDrivingCycleSpeed()"
aria-label="Playback speed 1×" title="Playback speed">1×</button>
<button type="button" class="mag-driving-opt" id="mag-driving-text-toggle" aria-pressed="true" onclick="magDrivingToggleText()"
title="Show or hide the text being read"><span class="mdi mdi-text-long" aria-hidden="true"></span> Text</button>
<button type="button" class="mag-driving-opt" id="mag-driving-image-toggle" aria-pressed="true" onclick="magDrivingToggleImages()"
title="Show or hide the article's pictures"><span class="mdi mdi-image-outline" aria-hidden="true"></span> Images</button>
</div>
</div>
</div>

View File

@ -4960,6 +4960,12 @@ input[list="lang-codes"] {
.mag-card.is-page-approved { border-color: var(--yellow); background: color-mix(in srgb, var(--yellow) 8%, var(--surface)); }
.mag-card.is-page-audio { border-color: var(--green); background: color-mix(in srgb, var(--green) 8%, var(--surface)); }
.mag-card.is-page-empty { border-color: var(--subtext); background: color-mix(in srgb, var(--subtext) 8%, var(--surface)); }
.mag-card.is-page-checked { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); }
.mag-card.is-page-flagged { border-color: #9333EA; background: color-mix(in srgb, #9333EA 9%, var(--surface)); }
.mag-pill-flagged { color: #7E22CE; border-color: #9333EA; font-size: 12px; }
:root[data-theme="dark"] .mag-pill-flagged { color: #C084FC; }
.mag-pages-legend-swatch.is-page-checked { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 20%, var(--surface)); }
.mag-pages-legend-swatch.is-page-flagged { border-color: #9333EA; background: color-mix(in srgb, #9333EA 20%, var(--surface)); }
.mag-pages-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--subtext); margin: 0 0 12px; }
.mag-pages-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.mag-pages-legend-swatch { width: 12px; height: 12px; border-radius: 3px; border: 2px solid var(--border); }
@ -5148,7 +5154,7 @@ input[list="lang-codes"] {
than added/removed) so toggling edit mode doesn't cost a re-render. */
.mag-review-page-wrap.is-editing { cursor: default; }
.mag-review-page-wrap.is-editing .mag-review-box { cursor: move; }
.mag-review-box-delete { display: none; position: absolute; top: -9px; right: -9px; width: 18px; height: 18px;
.mag-review-box-delete { display: none; position: absolute; top: -27px; right: -9px; width: 18px; height: 18px;
border-radius: 50%; border: 1px solid var(--border); background: var(--red); color: #fff;
font-size: 11px; line-height: 1; align-items: center; justify-content: center; padding: 0; cursor: pointer; z-index: 3; }
.mag-review-page-wrap.is-editing .mag-review-box-delete { display: flex; }
@ -5185,8 +5191,10 @@ input[list="lang-codes"] {
.mag-block-type { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext);
display: flex; align-items: center; gap: 3px; }
.mag-block-type .mdi { font-size: 13px; }
.mag-block-type-select { font-size: 11px; text-transform: none; letter-spacing: 0; padding: 2px 4px;
max-width: 9.5em; min-height: 28px; }
.mag-region-type-picker { flex: 0 1 10.5em; width: 10.5em; text-transform: none; letter-spacing: 0; }
.mag-region-type-picker .mag-type-picker-input { width: 100%; min-height: 28px; font-size: 11px; padding: 2px 5px; }
.mag-region-type-picker .mag-type-picker-list { right: auto; min-width: 210px; z-index: 8; text-transform: none; letter-spacing: 0; }
.mag-block-type-select { display: none; }
.mag-block-delete { padding: 2px 6px; min-width: 28px; min-height: 28px; color: var(--subtext); }
.mag-block-delete:hover, .mag-block-delete:focus-visible { color: var(--danger, #dc2626); }
.mag-block-owner { display: inline-block; padding: 1px 6px; margin-right: 6px; border-radius: 999px;
@ -5380,7 +5388,7 @@ input[list="lang-codes"] {
.mag-preview-swatch { width: 10px; height: 10px; border-radius: 2px; background: var(--c); }
.mag-preview-legend-count { margin-left: auto; color: #8a8f98; }
.mag-review-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 12px; color: var(--subtext); margin: 6px 0 10px; }
.mpt-title { --c: #a855f7; } .mpt-heading { --c: #d8b4fe; } .mpt-deck { --c: #06b6d4; } .mpt-byline { --c: #10b981; } .mpt-body { --c: #3b82f6; }
.mpt-title { --c: #a855f7; } .mpt-heading { --c: #d8b4fe; } .mpt-deck { --c: #06b6d4; } .mpt-byline { --c: #10b981; } .mpt-body { --c: #3b82f6; } .mpt-quote { --c: #8b5cf6; }
.mpt-caption { --c: #14b8a6; } .mpt-pullquote { --c: #ec4899; } .mpt-sidebar { --c: #f59e0b; } .mpt-ad { --c: #ef4444; }
.mpt-furniture, .mpt-page_number { --c: #9ca3af; }
/* Visual regions: their own colours and a stronger fill, so a picture reads as an area, not a text line. */
@ -5388,3 +5396,50 @@ input[list="lang-codes"] {
.mag-preview-box.mpt-image, .mag-preview-box.mpt-figure, .mag-preview-box.mpt-chart, .mag-preview-box.mpt-table,
.mag-preview-box.mpt-code { border-width: 3px; border-style: dashed; background: color-mix(in srgb, var(--c) 24%, transparent); }
.mag-preview-legend-hint { color: #8a8f98; font-style: italic; }
.mag-quality-report { margin: 8px 0; padding: 10px 14px; border: 1px solid var(--border, #c9c9c9);
border-radius: 8px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.mag-quality-report[hidden] { display: none; }
.mag-quality-report progress { flex: 1 1 100%; height: 10px; }
/* Live quality-run indicator on the page grid */
.mag-card .mag-live-tag { position: absolute; top: 8px; right: 8px; z-index: 2; padding: 2px 8px;
border-radius: 999px; font-size: 12px; font-weight: 600; background: var(--surface);
border: 1px solid var(--accent); color: var(--accent); }
.mag-card.is-page-working { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent);
animation: mag-working-pulse 1.2s ease-in-out infinite; }
.mag-card.is-page-live-done .mag-live-tag { border-color: var(--green); color: var(--green); }
@keyframes mag-working-pulse { 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 15%, transparent); } }
@media (prefers-reduced-motion: reduce) { .mag-card.is-page-working { animation: none; } }
.mag-card .mag-thumb-flag { position: absolute; top: 8px; left: 8px; z-index: 2; display: inline-flex; gap: 4px;
align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; cursor: pointer;
background: #9333EA; color: #fff; border: 2px solid #fff; }
.mag-card .mag-thumb-flag:hover, .mag-card .mag-thumb-flag:focus-visible { background: #7E22CE; outline: 2px solid #9333EA; outline-offset: 2px; }
.mag-card.is-page-flagged { border-width: 2px; }
.mag-card.is-page-ad { border-color: var(--green); border-style: dashed; border-width: 2px;
background: color-mix(in srgb, var(--green) 12%, var(--surface)); }
.mag-pages-legend-swatch.is-page-ad { border-color: var(--green); border-style: dashed; background: color-mix(in srgb, var(--green) 20%, var(--surface)); }
.mag-card .mag-ad-btn { margin-top: 4px; }
/* Driving mode: Spotify-style transport, text + image toggles, speed */
.mag-driving { overflow-y: auto; }
.mag-driving-body { width: min(760px, 94vw); padding: 24px 0; }
.mag-driving-visual { margin: 0 auto 16px; max-height: 32vh; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.mag-driving-visual img { max-width: 100%; max-height: 28vh; object-fit: contain; border-radius: 12px; }
.mag-driving-visual figcaption { font-size: 14px; color: var(--subtext); }
.mag-driving-lyrics { margin: 8px auto 18px; max-width: 640px; }
.mag-driving-line { margin: 6px 0; font-size: clamp(16px, 2.4vw, 22px); line-height: 1.4; color: var(--subtext); opacity: .6; }
.mag-driving-line.is-current { font-size: clamp(22px, 3.6vw, 34px); font-weight: 700; color: var(--text); opacity: 1; }
.mag-driving-seek { margin-top: 12px; }
.mag-driving-seek input[type="range"] { width: min(680px, 88vw); height: 28px; accent-color: var(--accent); }
.mag-driving-seek .mag-driving-time { margin-top: 2px; }
.mag-driving-controls { gap: 20px; margin-top: 18px; }
.mag-driving-btn.is-small { width: 64px; height: 64px; font-size: 26px; }
.mag-driving-options { display: flex; justify-content: center; gap: 12px; margin-top: 22px; flex-wrap: wrap; }
.mag-driving-opt { min-width: 76px; min-height: 52px; padding: 0 18px; border-radius: 26px; font-size: 18px; font-weight: 700;
border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; }
.mag-driving-opt[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
.mag-driving-opt:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
@media (max-width: 860px) { .mag-driving-btn.is-small { width: 52px; height: 52px; font-size: 22px; } .mag-driving-controls { gap: 12px; } }

View File

@ -58,9 +58,18 @@ async function mockBackend(page, { articleStatus = 'detected', hasAudio = false,
await page.route(`**/api/reader/docs/${DOC_ID}`, route => route.fulfill({
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: [] } },
}));
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => {
if (route.request().method() === 'POST') {
const requestedPage = route.request().postDataJSON().page;
return route.fulfill({ json: { article: {
id: 'article-page-' + requestedPage, title: 'Page ' + requestedPage, author: '',
pages: [requestedPage], blocks: [], images: [], status: 'detected', confidence: 0, section: '',
} } });
}
return route.fulfill({
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 => {
if (route.request().method() === 'PATCH') {
@ -105,6 +114,85 @@ test.describe('Magazine reader', () => {
await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(3);
});
test('thumbnail review opens the clicked page for multi-page articles', 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 8' })
.getByRole('button', { name: 'Review', exact: true }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 8');
});
test('Approve & Next advances one physical page inside a multi-page article', async ({ page }) => {
await mockBackend(page);
const pageFour = {
id: 'article-page-4', title: 'Page 4', author: '', pages: [4], blocks: [],
images: [], status: 'detected', confidence: 0, section: '',
};
await page.route(`**/api/magazine/${DOC_ID}/articles`, async route => {
if (route.request().method() === 'POST') return route.fulfill({ json: { article: pageFour } });
return route.fallback();
});
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: 'Review', exact: true }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3');
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4');
});
test('figure boundary repair in review only sends the active page', async ({ page }) => {
await mockBackend(page);
let requested = '';
await page.route(`**/api/magazine/${DOC_ID}/pages/3/figures/refine`, route => {
requested = new URL(route.request().url()).pathname;
return route.fulfill({ json: { job_id: 'page-figures' } });
});
await page.route('**/api/jobs/page-figures', route => route.fulfill({
json: { done: true, detail: 'Figure boundaries checked on page 3', result: { moved: 1 } },
}));
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();
await page.getByRole('button', { name: 'Fix figure boundaries (this page)', exact: true }).click();
await expect.poll(() => requested).toContain('/pages/3/figures/refine');
expect(requested).not.toBe(`/api/magazine/${DOC_ID}/figures/refine`);
});
test('contents view keeps whole-magazine figure repair as an explicit batch action', async ({ page }) => {
await mockBackend(page);
let called = false;
await page.route(`**/api/magazine/${DOC_ID}/figures/refine`, route => {
called = true;
return route.fulfill({ json: { job_id: 'all-figures' } });
});
await page.route('**/api/jobs/all-figures', route => route.fulfill({
json: { done: true, detail: 'All figure boundaries checked', result: { moved: 2 } },
}));
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();
// The Material Design icon font can become part of the computed accessible
// name in Chromium, so use the action's stable control id here.
await page.locator('#mag-refine-all-figures-btn').click();
const dialog = page.getByRole('alertdialog', { name: 'Fix all figure boundaries?' });
expect(called).toBe(false);
await dialog.getByRole('button', { name: 'Fix all pages', exact: true }).click();
await expect.poll(() => called).toBe(true);
});
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' });
@ -165,7 +253,7 @@ test.describe('Magazine reader', () => {
await expect(page.locator('#mag-rev-title')).toHaveValue('The Future of Batteries');
await page.getByRole('button', { name: 'Approve' }).click();
await expect(page.locator('#mag-view-contents')).toBeVisible();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4');
});
test('approved article reads, highlights sentences, and links to the PDF page', async ({ page }) => {
@ -397,21 +485,24 @@ test('a region type can be changed from the review list without toggling its che
const checkbox = page.locator('#mag-review input[data-block="b2"]');
const before = await checkbox.isChecked();
const picker = row.getByRole('combobox', { name: /Type of region/ });
await expect(picker).toHaveValue('byline');
await expect(picker).toHaveValue('Author');
await picker.click();
await expect(picker).toHaveValue('');
await expect(checkbox).toBeChecked({ checked: before }); // opening the picker did not toggle the row
await picker.selectOption('page_number');
await row.getByRole('option', { name: 'Page Number', exact: true }).click();
await expect.poll(() => changedTo).toBe('page_number');
});
test('every type the server knows is offered, image included', async ({ page }) => {
test('the icon picker offers every server type in editorial order', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await expect(page.locator('[data-block-row="b3"] select.mag-block-type-select')).toBeVisible();
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', 'table', 'image']);
const row = page.locator('[data-block-row="b3"]');
await row.getByRole('combobox', { name: /Type of region/ }).click();
const values = await row.locator('.mag-type-picker-option').evaluateAll(
options => options.map(o => o.dataset.value));
expect(values).toEqual(['body', 'title', 'heading', 'deck', 'byline', 'image', 'caption', 'in_picture',
'table', 'quote', 'pullquote', 'sidebar', 'ad', 'page_number', 'source', 'duplicate', 'ocr_noise']);
await expect(row.locator('.mag-type-picker-option .mdi')).toHaveCount(values.length);
});
test('each review row has its own delete button, which deletes at once and offers undo', async ({ page }) => {
@ -533,7 +624,7 @@ test('a box marked as text in a picture shows that type in its picker', async ({
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: { ...LAYOUT,
blocks: LAYOUT.blocks.map(b => b.id === 'b3' ? { ...b, type: 'furniture', region_label: 'in_picture' } : b) } }));
await openReview(page);
await expect(page.locator('[data-block-row="b3"] select.mag-block-type-select')).toHaveValue('in_picture');
await expect(page.locator('[data-block-row="b3"] .mag-block-type-select')).toHaveValue('in_picture');
});
test('a region text can be corrected and split at the cursor from the list', async ({ page }) => {
@ -617,7 +708,9 @@ test('approving after turning a ticked text box into an image sends the new imag
});
await openReview(page);
await expect(page.locator('#mag-review input[data-block="b3"]')).toBeChecked();
await page.locator('[data-block-row="b3"] select.mag-block-type-select').selectOption('image');
const picker = page.locator('[data-block-row="b3"]').getByRole('combobox', { name: /Type of region/ });
await picker.click();
await page.locator('[data-block-row="b3"]').getByRole('option', { name: 'Image', exact: true }).click();
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 & Next', exact: true }).click();

View File

@ -47,6 +47,17 @@ class AttributionParserTests(unittest.TestCase):
self.assertIn(anchor, portrait)
self.assertIn(anchor, reference)
def test_visual_prompts_forbid_generated_writing_including_chinese_characters(self):
sheet = {"gender": "male", "physical": "short dark hair"}
portrait = _build_character_portrait_prompt("Marcian", "The Trial", sheet, {})
reference = _build_character_sheet_prompt("Marcian", "The Trial", sheet, {})
for prompt in (portrait, reference):
self.assertIn("Chinese characters", prompt)
self.assertIn("text of any kind", prompt)
def test_visual_prompt_version_changes_when_render_constraints_change(self):
self.assertEqual(_CHARACTER_VISUAL_PROMPT_VERSION, "v4")
def test_visual_pair_metadata_persists_shared_prompt_version_and_identity(self):
sheet = {"name": "Mara Venn", "gender": "female", "physical": "copper hair and green eyes"}
profile = "data:image/png;base64,AA=="

View File

@ -74,6 +74,8 @@ from core.magazine import (
describe_visual_region_chain,
BLOCK_FIGURE_DESCRIPTION,
interview_tts_segments,
merge_missing_text_candidates,
verify_ocr_recognition,
)
PAGE_W, PAGE_H = 595.0, 842.0 # A4 in points
@ -85,6 +87,36 @@ def block(bid, page, x, y, w, h, text, size=10.0, btype=BLOCK_BODY):
class DropCapRepairTests(unittest.TestCase):
def test_repairs_split_ki_and_confirmed_short_drop_cap_words(self):
from core.magazine import repair_drop_cap_omissions
regions = [
{"label": "paragraph", "bbox": [0, 0, 100, 100],
"text": "K l soll Arbeit abnehmen und Kosten sparen."},
{"label": "body", "bbox": [0, 0, 100, 100],
"text": "A m 3. Januar erschienen neue Bilder."},
{"label": "text", "bbox": [0, 0, 100, 100],
"text": "B i s heute blieb das Problem bestehen."},
]
repaired = repair_drop_cap_omissions(pathlib.Path("missing-page.png"), regions)
self.assertEqual(repaired, 3)
self.assertEqual(regions[0]["text"], "KI soll Arbeit abnehmen und Kosten sparen.")
self.assertEqual(regions[1]["text"], "Am 3. Januar erschienen neue Bilder.")
self.assertEqual(regions[2]["text"], "Bis heute blieb das Problem bestehen.")
def test_joins_a_detached_initial_already_found_by_primary_ocr(self):
from core.magazine import repair_drop_cap_omissions
regions = [{
"label": "paragraph",
"bbox": [10, 10, 200, 300],
"text": "S prechende Katzen, halbnackte Prominente und unglaubliche Sensationsmeldungen.",
}]
repaired = repair_drop_cap_omissions(pathlib.Path("missing-page.png"), regions)
self.assertEqual(repaired, 1)
self.assertEqual(
regions[0]["text"],
"Sprechende Katzen, halbnackte Prominente und unglaubliche Sensationsmeldungen.",
)
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.")
@ -99,6 +131,19 @@ class DropCapRepairTests(unittest.TestCase):
unrelated = ("Andere Spalte mit vollständig anderem Inhalt und ganz anderen Worten. " * 4).strip()
self.assertEqual(prefer_drop_cap_repair(old, unrelated), old)
def test_restores_a_whole_first_line_before_capitalised_remainder(self):
old = ("Herrn, verwaltet Termine, öffnet Türen, sortiert die Post und weiß, wo wichtige "
"Unterlagen liegen. Genau diese Rolle wollen digitale Assistenten übernehmen.")
local = ("Ein guter Butler kennt die Vorlieben seines Herrn, verwaltet Termine, öffnet Türen, "
"sortiert die Post und weiß, wo wichtige Unterlagen liegen. Genau diese Rolle wollen "
"digitale Assistenten übernehmen.")
repaired = prefer_drop_cap_repair(old, local)
self.assertEqual(repaired, local)
def test_capitalised_remainder_rejects_a_tiny_prefix(self):
old = "Herrn, verwaltet Termine und öffnet Türen für die Bewohner des Hauses."
self.assertEqual(prefer_drop_cap_repair(old, "Der Herrn, verwaltet Termine und öffnet Türen für die Bewohner des Hauses."), old)
class ColumnGeometryTests(unittest.TestCase):
def test_two_columns_detected_from_the_gutter(self):
@ -843,6 +888,42 @@ class VisionBlockBuildingTests(unittest.TestCase):
blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b")
self.assertEqual(blocks[0].type, BLOCK_CAPTION)
def test_visual_box_over_text_is_dropped_but_standalone_figure_survives(self):
regions = [
{"label": "paragraph", "bbox": [100, 100, 300, 200], "text": "Article text."},
{"label": "image", "bbox": [80, 80, 340, 240], "text": ""},
{"label": "figure", "bbox": [500, 100, 200, 200], "text": ""},
]
blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b")
self.assertEqual([block.type for block in blocks], [BLOCK_BODY, "figure"])
def test_broad_visual_box_is_dropped_when_tighter_figure_is_nested(self):
regions = [
{"label": "image", "bbox": [40, 40, 360, 700], "text": ""},
{"label": "figure", "bbox": [60, 60, 300, 260], "text": ""},
]
blocks = build_vision_blocks(regions, page_no=136, scale_x=1.0, id_prefix="b")
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0].type, BLOCK_FIGURE)
def test_tall_visual_box_stops_before_overlapping_caption(self):
regions = [
{"label": "image", "bbox": [40, 40, 300, 700], "text": ""},
{"label": "caption", "bbox": [55, 500, 270, 80],
"text": "Caption below the photograph."},
]
blocks = build_vision_blocks(regions, page_no=136, scale_x=1.0, id_prefix="b")
self.assertEqual(blocks[0].bbox, [40.0, 40.0, 300.0, 460.0])
def test_text_inside_screenshot_is_not_a_separate_article_paragraph(self):
regions = [
{"label": "image", "bbox": [100, 100, 400, 500], "text": ""},
{"label": "paragraph", "bbox": [130, 180, 330, 50],
"text": "Text printed inside screenshot."},
]
blocks = build_vision_blocks(regions, page_no=139, scale_x=1.0, id_prefix="b")
self.assertEqual([b.type for b in blocks], [BLOCK_FIGURE])
def test_semantic_groups_join_fragments_and_keep_continuation(self):
from core.magazine import apply_semantic_regions
blocks = [Block('a', 1, [10,10,100,20], 'body', 'A para-').to_json(),
@ -856,6 +937,22 @@ class VisionBlockBuildingTests(unittest.TestCase):
self.assertTrue(groups[0]['continues_from_previous_page'])
self.assertEqual(groups[1]['type'], 'body')
def test_semantic_group_uses_saved_reading_order_not_model_id_order(self):
from core.magazine import apply_semantic_regions
first = Block('first', 1, [20, 100, 200, 30], 'body',
'Ein guter Butler kennt die Vorlieben seines')
second = Block('second', 1, [20, 135, 200, 80], 'body',
'Herrn, verwaltet Termine und öffnet Türen.')
first.reading_order, second.reading_order = 3, 4
grouped = apply_semantic_regions(
[first.to_json(), second.to_json()],
[{'type': 'paragraph', 'block_ids': ['second', 'first']}],
)
self.assertEqual(
grouped[0]['text'],
'Ein guter Butler kennt die Vorlieben seines Herrn, verwaltet Termine und öffnet Türen.',
)
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
@ -902,6 +999,33 @@ class VisionBlockBuildingTests(unittest.TestCase):
self.assertEqual([b['text'] for b in grouped],
['Left one left two', 'Right one right two'])
def test_body_paragraph_is_not_a_caption_without_a_nearby_figure(self):
"""A bottom-of-column interview answer remains body copy (page 47)."""
from core.magazine import apply_semantic_regions
paragraph = Block(
'answer', 47, [140, 530, 205, 145], 'body',
'Haverkamp: Teilweise ja. In den Handlungsempfehlungen der KMK heißt es, '
'dass Prüfungsformate weiterentwickelt werden sollen.',
).to_json()
grouped = apply_semantic_regions(
[paragraph], [{'type': 'caption', 'block_ids': ['answer']}],
{'page': 47, 'width': 595.0, 'height': 842.0},
)
self.assertEqual(grouped[0]['type'], BLOCK_BODY)
def test_body_near_a_real_figure_may_be_promoted_to_caption(self):
from core.magazine import apply_semantic_regions
figure = Block('photo', 1, [40, 40, 200, 200], BLOCK_FIGURE, '').to_json()
copy = Block('copy', 1, [40, 245, 200, 25], BLOCK_BODY,
'Portrait of the software developer.').to_json()
grouped = apply_semantic_regions(
[figure, copy],
[{'type': 'figure', 'block_ids': ['photo']},
{'type': 'caption', 'block_ids': ['copy']}],
{'page': 1, 'width': 595.0, 'height': 842.0},
)
self.assertEqual(grouped[1]['type'], BLOCK_CAPTION)
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(),
@ -1217,6 +1341,17 @@ class ContinuationMarkerStrippingTests(unittest.TestCase):
class ArticleTextTests(unittest.TestCase):
def test_keeps_printed_abbreviation_but_provides_spoken_expansion(self):
layout = {
"blocks": [{"id": "b1", "page": 1, "type": BLOCK_BODY,
"reading_order": 0, "bbox": [0, 0, 1, 1],
"text": "KI hilft bei der Arbeit."}],
}
got = article_text(layout, {"blocks": ["b1"]})
self.assertEqual(got[0]["text"], "KI hilft bei der Arbeit.")
self.assertEqual(got[0]["spoken_text"],
"Künstliche Intelligenz hilft bei der Arbeit.")
def test_continuation_marker_is_stripped_from_narrated_output(self):
"""article_text() feeds TTS directly, so the print cue must not
survive into what gets spoken -- even though it stays in layout.json
@ -1458,6 +1593,57 @@ class ImageReadingPositionTests(unittest.TestCase):
self.assertEqual(image_reading_position({"page": 9, "bbox": [0, 0, 1, 1]}, []), 0)
class OcrQualityPassTests(unittest.TestCase):
def test_missing_text_adds_only_non_overlapping_non_visual_candidate(self):
layout = {
'pages': [{'page': 1, 'width': 600, 'height': 800}],
'blocks': [block('saved', 1, 40, 50, 200, 100,
'Already recognised paragraph text.').to_json()],
'images': [{'id': 'photo', 'page': 1, 'bbox': [300, 40, 200, 180]}],
}
candidates = [
block('', 1, 42, 55, 195, 90, 'Already recognised paragraph text.'),
block('', 1, 320, 70, 120, 80, 'Words printed inside the screenshot.'),
block('', 1, 40, 300, 200, 100,
'This genuinely missed paragraph should be offered for review.'),
]
updated, added = merge_missing_text_candidates(layout, 1, candidates)
self.assertEqual(added, 1)
new = [b for b in updated['blocks'] if b['id'].startswith('q0001-')]
self.assertEqual(len(new), 1)
self.assertIn('genuinely missed', new[0]['text'])
self.assertEqual(new[0]['source'], 'quality_missing_text')
def test_verification_accepts_only_a_similar_reading_with_fewer_suspects(self):
old = 'Die Softwarentwicklng hilft Unternehmen bei dieser Aufgabe.'
fixed = 'Die Softwareentwicklung hilft Unternehmen bei dieser Aufgabe.'
layout = {
'pages': [{'page': 1, 'width': 600, 'height': 800}],
'blocks': [block('copy', 1, 40, 50, 200, 100, old).to_json()],
}
with mock.patch('core.magazine.suspect_ocr_tokens',
side_effect=lambda text: ['Softwarentwicklng'] if text == old else []), \
mock.patch('core.magazine.extract_region_text', return_value=fixed):
updated, checked, corrected = verify_ocr_recognition(
pathlib.Path('/tmp/doc'), layout, 1)
self.assertEqual((checked, corrected), (1, 1))
self.assertEqual(updated['blocks'][0]['text'], fixed)
def test_verification_rejects_an_unrelated_second_reading(self):
old = 'Die Softwarentwicklng hilft Unternehmen bei dieser Aufgabe.'
layout = {
'pages': [{'page': 1, 'width': 600, 'height': 800}],
'blocks': [block('copy', 1, 40, 50, 200, 100, old).to_json()],
}
with mock.patch('core.magazine.suspect_ocr_tokens', return_value=['bad']), \
mock.patch('core.magazine.extract_region_text',
return_value='Completely different words from another column entirely.'):
updated, checked, corrected = verify_ocr_recognition(
pathlib.Path('/tmp/doc'), layout, 1)
self.assertEqual((checked, corrected), (1, 0))
self.assertEqual(updated['blocks'][0]['text'], old)
class WholePageVisionFallbackTests(unittest.TestCase):
"""KDL Frontier Parser nano, confirmed live, answers with plain text and
no coordinate markers at all -- unlike ocr-baidu-unlimited-ocr's labeled
@ -1882,6 +2068,32 @@ class UpdateRegionTests(unittest.TestCase):
self.assertEqual(moved["text"], "Existing title")
self.assertEqual(text, "")
def test_reocr_keeps_drop_cap_first_line_when_new_read_starts_later(self):
old = ("Mails versenden, Kalender bearbeiten, Skripte schreiben und ausführen: "
"Wenn man sie ließe, könnten LLMs viele Aufgaben ziemlich selbstständig erledigen.")
reread = ("schreiben und ausführen: Wenn man sie ließe, könnten LLMs viele Aufgaben "
"ziemlich selbstständig erledigen.")
self.layout["blocks"][0]["text"] = old
self.layout["blocks"][0]["type"] = BLOCK_BODY
with mock.patch("core.magazine.extract_region_text", return_value=reread):
out, text = update_region(self.doc_dir, self.layout, 3, "b0003-000",
[50, 60, 320, 80], reocr=True)
moved = next(b for b in out["blocks"] if b["id"] == "b0003-000")
self.assertEqual(moved["text"], old)
self.assertEqual(text, old)
def test_reocr_allows_intentionally_moving_top_below_old_first_line(self):
old = ("Mails versenden, Kalender bearbeiten, Skripte schreiben und ausführen: "
"Wenn man sie ließe, könnten LLMs viele Aufgaben ziemlich selbstständig erledigen.")
reread = "schreiben und ausführen: Wenn man sie ließe, könnten LLMs viele Aufgaben erledigen."
self.layout["blocks"][0]["text"] = old
with mock.patch("core.magazine.extract_region_text", return_value=reread):
out, text = update_region(self.doc_dir, self.layout, 3, "b0003-000",
[50, 75, 300, 25], reocr=True)
moved = next(b for b in out["blocks"] if b["id"] == "b0003-000")
self.assertEqual(moved["text"], reread)
self.assertEqual(text, reread)
def test_moving_an_image_recrops_its_file(self):
from PIL import Image
out, text = update_region(self.doc_dir, self.layout, 3, "img0003-000", [0, 0, 60, 40])
@ -2065,13 +2277,55 @@ class SnapVisualBboxesTests(unittest.TestCase):
self.assertEqual(moved, 1)
want = self._pt(self.FIGURE_PX)
for got, expected in zip(block.bbox, want):
self.assertAlmostEqual(got, expected, delta=12.0)
self.assertAlmostEqual(got, expected, delta=2.0)
def test_dense_photo_edges_do_not_keep_asymmetric_grid_margin(self):
block = self._figure_block((200, 60, 600, 250))
snap_visual_bboxes(self.png, [block, self._text_block()], self.meta)
got = block.bbox
want = self._pt(self.FIGURE_PX)
for actual, expected in zip(got, want):
self.assertAlmostEqual(actual, expected, delta=2.0)
def test_snap_does_not_swallow_the_text_column(self):
block = self._figure_block((200, 60, 600, 250))
snap_visual_bboxes(self.png, [block, self._text_block()], self.meta)
self.assertGreater(block.bbox[0], self._pt(self.TEXT_PX)[0] + 20)
def test_displaced_advert_box_does_not_block_figure_snapping(self):
block = self._figure_block((200, 60, 600, 250))
advert = Block(id="ad", page=1, bbox=list(block.bbox), type=BLOCK_AD,
text="Advertisement copy " * 20)
moved = snap_visual_bboxes(self.png, [block, advert, self._text_block()], self.meta)
self.assertEqual(moved, 1)
want = self._pt(self.FIGURE_PX)
for got, expected in zip(block.bbox, want):
self.assertAlmostEqual(got, expected, delta=12.0)
def test_refresh_moves_advert_outline_with_its_visual_parts(self):
from core.magazine import refresh_visual_regions
first = Block(id="f1", page=1, bbox=[10, 10, 100, 80],
type=BLOCK_FIGURE, text="")
second = Block(id="f2", page=1, bbox=[120, 10, 100, 80],
type=BLOCK_FIGURE, text="")
advert = Block(id="ad", page=1, bbox=[0, 0, 240, 100],
type=BLOCK_AD, text="Buy now")
layout = {"pages": [self.meta],
"blocks": [first.to_json(), second.to_json(), advert.to_json()],
"images": []}
def move(_png, blocks, _meta):
by_id = {b.id: b for b in blocks}
by_id["f1"].bbox = [20, 400, 100, 80]
by_id["f2"].bbox = [130, 400, 100, 80]
return 2
with mock.patch("core.magazine.snap_visual_bboxes", side_effect=move), \
mock.patch("core.magazine.crop_visual_regions", return_value=[]):
refresh_visual_regions(self.dir, layout, page=1)
saved = {b["id"]: b for b in layout["blocks"]}
self.assertEqual(saved["ad"]["bbox"], [20, 400, 210, 80])
def test_snapping_is_idempotent(self):
block = self._figure_block((200, 60, 600, 250))
blocks = [block, self._text_block()]
@ -2200,3 +2454,66 @@ class SnapVisualBboxesStabilityTests(unittest.TestCase):
self.assertAlmostEqual(a, b, delta=8.0)
# And never merge into one box spanning both panels.
self.assertLess(left.bbox[0] + left.bbox[2], right.bbox[0] + 1.0)
class AuditPageCoverageTests(unittest.TestCase):
"""audit_page_coverage: uncovered ink, overlapping boxes, clipped box edges."""
def _doc(self, boxes):
from PIL import Image, ImageDraw
d = pathlib.Path(tempfile.mkdtemp(prefix="ttvc-audit-"))
self.addCleanup(shutil.rmtree, d, True)
(d / "pages").mkdir()
im = Image.new("RGB", (400, 400), "white")
draw = ImageDraw.Draw(im)
for y in range(60, 340, 12): # a text column at x 100..250
draw.rectangle([100, y, 250, y + 6], fill="black")
im.save(d / "pages" / "0001.png")
blocks = [{"id": f"b{i}", "page": 1, "bbox": list(b), "type": "body", "text": "Hallo Welt"}
for i, b in enumerate(boxes)]
layout = {"pages": [{"page": 1, "width": 400, "height": 400}],
"blocks": blocks, "images": []}
return d, layout
def test_fully_covered_page_is_clean(self):
from core.magazine import audit_page_coverage
d, layout = self._doc([(90, 50, 170, 300)])
r = audit_page_coverage(d, layout, 1)
self.assertTrue(r["measured"])
self.assertEqual((r["uncovered"], r["overlaps"], r["clipped"]), ([], [], []))
def test_text_without_a_box_is_reported(self):
from core.magazine import audit_page_coverage
d, layout = self._doc([(90, 50, 170, 100)]) # lower two thirds uncovered
r = audit_page_coverage(d, layout, 1)
self.assertTrue(r["uncovered"])
self.assertGreater(r["uncovered_ratio"], 0.3)
def test_overlapping_text_boxes_are_reported(self):
from core.magazine import audit_page_coverage
d, layout = self._doc([(90, 50, 170, 160), (90, 120, 170, 180)])
self.assertEqual(audit_page_coverage(d, layout, 1)["overlaps"], [["b0", "b1"]])
def test_box_cutting_into_text_is_clipped(self):
from core.magazine import audit_page_coverage
d, layout = self._doc([(160, 50, 60, 290)]) # starts mid-line, text continues left
clipped = audit_page_coverage(d, layout, 1)["clipped"]
self.assertIn({"id": "b0", "side": "left"}, clipped)
def test_missing_render_is_not_measured(self):
from core.magazine import audit_page_coverage
d, layout = self._doc([(90, 50, 170, 300)])
(d / "pages" / "0001.png").unlink()
self.assertFalse(audit_page_coverage(d, layout, 1)["measured"])
class AdvertisementPageTests(unittest.TestCase):
def test_article_text_skips_pages_approved_as_advertisement(self):
from core.magazine import article_text
layout = {"pages": [{"page": 1}, {"page": 2, "advertisement": True}],
"blocks": [
{"id": "a", "page": 1, "type": "body", "text": "Real text.", "reading_order": 0},
{"id": "b", "page": 2, "type": "body", "text": "Buy now!", "reading_order": 0}],
"images": []}
out = article_text(layout, {"blocks": ["a", "b"], "pages": [1, 2]})
self.assertEqual([b["id"] for b in out], ["a"])

View File

@ -235,6 +235,21 @@ class RefineFiguresJobTests(unittest.TestCase):
self.assertEqual(done[-1]['result'], {'moved': 0})
self.assertNotIn('error', done[-1])
def test_page_job_only_refreshes_the_requested_page(self):
from unittest.mock import patch
from routes.magazine import _refine_figures_sync
layout = self._layout()
with patch('routes.magazine.mag.load_layout', return_value=layout), \
patch('routes.magazine.mag.refresh_visual_regions') as refresh, \
patch('routes.magazine.mag.save_layout'), \
patch('routes.magazine.background_job_update') as update:
_refine_figures_sync('a' * 32, 'job', page=2)
self.assertEqual(refresh.call_count, 1)
self.assertEqual(refresh.call_args.args[2], 2)
progress = [c.kwargs.get('detail', '') for c in update.call_args_list]
self.assertIn('Checking figure boundaries on page 2', progress)
self.assertFalse(any('of 2' in detail for detail in progress))
def test_a_document_with_no_layout_reports_failure(self):
from unittest.mock import patch
from routes.magazine import _refine_figures_sync
@ -248,6 +263,63 @@ class RefineFiguresJobTests(unittest.TestCase):
self.assertIn('No layout', done[-1]['error'])
class PageQualityJobTests(unittest.TestCase):
def test_missing_text_quality_job_saves_additive_result(self):
from unittest.mock import patch
from routes.magazine import _quality_page_sync
layout = {'pages': [{'page': 2}], 'blocks': []}
updated = {'pages': [{'page': 2}], 'blocks': [{'id': 'new'}]}
with patch('routes.magazine.mag.load_layout', return_value=layout), \
patch('routes.magazine.mag.recover_missing_text',
return_value=(updated, 1)) as recover, \
patch('routes.magazine.mag.save_layout') as save, \
patch('routes.magazine.background_job_update') as update:
_quality_page_sync('a' * 32, 'job', 2, 'missing')
recover.assert_called_once()
save.assert_called_once()
self.assertEqual(save.call_args.args[1], updated)
done = [call.kwargs for call in update.call_args_list if call.kwargs.get('done')]
self.assertEqual(done[-1]['result'], {'page': 2, 'added': 1})
def test_recognition_quality_job_reports_checked_and_corrected(self):
from unittest.mock import patch
from routes.magazine import _quality_page_sync
layout = {'pages': [{'page': 3}], 'blocks': []}
with patch('routes.magazine.mag.load_layout', return_value=layout), \
patch('routes.magazine.mag.verify_ocr_recognition',
return_value=(layout, 4, 2)), \
patch('routes.magazine.mag.save_layout'), \
patch('routes.magazine.background_job_update') as update:
_quality_page_sync('a' * 32, 'job', 3, 'recognition')
done = [call.kwargs for call in update.call_args_list if call.kwargs.get('done')]
self.assertEqual(done[-1]['result'], {'page': 3, 'checked': 4, 'corrected': 2})
class PageQualityRouteTests(unittest.IsolatedAsyncioTestCase):
async def test_missing_text_route_starts_page_scoped_job(self):
from unittest.mock import patch
from routes.magazine import magazine_find_missing_text
layout = {'pages': [{'page': 7}], 'blocks': []}
with patch('routes.magazine._require_magazine', return_value=(Path('/tmp/doc'), {})), \
patch('routes.magazine.mag.load_layout', return_value=layout), \
patch('routes.magazine.background_job_create') as create, \
patch('routes.magazine.asyncio.to_thread', new=lambda *a, **k: None), \
patch('asyncio.create_task') as create_task:
result = await magazine_find_missing_text('a' * 32, 7)
self.assertIn('job_id', result)
create.assert_called_once()
create_task.assert_called_once()
async def test_quality_route_rejects_unknown_page(self):
from unittest.mock import patch
from routes.magazine import magazine_find_missing_text
with patch('routes.magazine._require_magazine', return_value=(Path('/tmp/doc'), {})), \
patch('routes.magazine.mag.load_layout', return_value={'pages': []}):
with self.assertRaises(HTTPException) as error:
await magazine_find_missing_text('a' * 32, 99)
self.assertEqual(error.exception.status_code, 404)
class IngestPreviewHtmlTests(unittest.TestCase):
"""The live ingest preview: a fast, best-effort HTML render of one
page's just-read blocks, well before classify_blocks or article
@ -1376,3 +1448,63 @@ class InterruptedIngestTests(unittest.TestCase):
out = asyncio.run(magazine_ingest_status('a' * 32))
self.assertEqual(out["ingest"]["status"], "running")
write.assert_not_called()
class QualityRunJobTests(unittest.TestCase):
def _run(self, settings, audit_row):
from unittest.mock import patch
from routes.magazine import _quality_run_sync
layout = {'pages': [{'page': 1}, {'page': 2}], 'blocks': []}
lib = Path(tempfile.mkdtemp(prefix='ttvc-qrun-'))
self.addCleanup(shutil.rmtree, lib, True)
(lib / ('a' * 32)).mkdir()
with patch('routes.magazine.mag.load_layout', return_value=layout), \
patch('routes.magazine.mag.recover_missing_text', return_value=(layout, 1)), \
patch('routes.magazine.mag.verify_ocr_recognition', return_value=(layout, 3, 2)) as verify, \
patch('routes.magazine.mag.audit_page_coverage', side_effect=audit_row), \
patch('routes.magazine.mag.save_layout'), \
patch('routes.magazine._LIB', lib), \
patch('routes.magazine.background_job_update') as update:
_quality_run_sync('a' * 32, 'job', settings)
done = [c.kwargs for c in update.call_args_list if c.kwargs.get('done')]
return done[-1], verify
@staticmethod
def _row(page, **kw):
row = {'page': page, 'uncovered': [], 'uncovered_ratio': 0.0, 'suspect_blocks': [],
'overlaps': [], 'clipped': [], 'measured': True}
row.update(kw)
return row
def test_flags_pages_and_summarises(self):
rows = iter([self._row(1), self._row(2, overlaps=[['a', 'b']])])
done, verify = self._run({'vision_ocr_url': 'http://x', 'vision_ocr_model': 'm'},
lambda *a, **k: next(rows))
self.assertEqual(done['result']['pages_needing_review'], [2])
self.assertEqual(done['result']['regions_added'], 2)
self.assertEqual(done['result']['regions_corrected'], 4)
self.assertEqual(verify.call_count, 2)
def test_skips_verification_without_vision_engine(self):
rows = iter([self._row(1), self._row(2)])
done, verify = self._run({}, lambda *a, **k: next(rows))
verify.assert_not_called()
self.assertFalse(done['result']['verified_with_vision'])
self.assertIn('skipped', done['detail'])
class PageAdvertisementRouteTests(unittest.IsolatedAsyncioTestCase):
async def test_mark_and_unmark_round_trip_and_404(self):
from unittest.mock import patch
from routes.magazine import (magazine_mark_page_advertisement,
magazine_unmark_page_advertisement)
layout = {'pages': [{'page': 1}, {'page': 2}], 'blocks': []}
with patch('routes.magazine._require_magazine', return_value=(Path('/nowhere'), {})), \
patch('routes.magazine.mag.load_layout', return_value=layout), \
patch('routes.magazine.mag.save_layout') as save:
await magazine_mark_page_advertisement('a' * 32, 2)
self.assertTrue(save.call_args.args[1]['pages'][1]['advertisement'])
await magazine_unmark_page_advertisement('a' * 32, 2)
self.assertNotIn('advertisement', save.call_args.args[1]['pages'][1])
with self.assertRaises(HTTPException):
await magazine_mark_page_advertisement('a' * 32, 9)

View File

@ -5,6 +5,7 @@ from core.pronunciations import (
normalize_ocr_text,
prepare_text_for_tts,
remove_linebreak_hyphens,
suspect_ocr_tokens,
)
@ -27,6 +28,12 @@ class PronunciationTests(unittest.TestCase):
self.assertIn("Programmierschnittstellen", spoken)
self.assertIn("natürlich", spoken)
def test_ki_expansion_is_token_bounded(self):
self.assertEqual(prepare_text_for_tts("KI hilft der KI-Branche.", "de"),
"Künstliche Intelligenz hilft der Künstliche Intelligenz-Branche.")
self.assertEqual(prepare_text_for_tts("Kiel bleibt Kiel.", "de"),
"Kiel bleibt Kiel.")
def test_english_acronyms_use_english_expansions(self):
self.assertEqual(expand_abbreviations("AI uses an API.", "en"),
"artificial intelligence uses an application programming interface.")
@ -35,6 +42,18 @@ class PronunciationTests(unittest.TestCase):
text = "OpenAI und PyMuPDF bleiben unverändert."
self.assertEqual(normalize_ocr_text(text, "de"), text)
def test_spellcheck_only_flags_suspects_and_never_rewrites_text(self):
class Checker:
def __contains__(self, word):
return word != 'softwarentwicklng'
from unittest import mock
with mock.patch('core.pronunciations._german_spellchecker', return_value=Checker()):
self.assertEqual(
suspect_ocr_tokens('Die Softwarentwicklng hilft OpenAI.', 'de'),
['Softwarentwicklng'],
)
if __name__ == "__main__":
unittest.main()