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] ## [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 ## [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; generated during review spoken aloud where they occur in the article;
figures without a generated description are silently skipped, exactly as figures without a generated description are silently skipped, exactly as
before this option existed. **Driving mode** before this option existed. **Driving mode**
(the button next to the article title) switches to two large controls and (the button next to the article title) is a full-screen, podcast-style player
nothing else, for listening without looking at the screen. 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. **Expected result:** the article plays from its headline to its final paragraph.
Page furniture (running heads, page numbers), adverts, sidebars and pull quotes 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 Podcast generation is refused for an article you have not approved, because
unreviewed boundaries may include a neighbouring article or an advert. 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 ### Errors
| What you see | What happened | What to do | | 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 Sentence and word highlighting both respect `prefers-reduced-motion` (no
smooth-scroll animation when it is set; the highlight itself is a colour smooth-scroll animation when it is set; the highlight itself is a colour
change, not motion). Driving mode gives two large, high-contrast controls change, not motion). Driving mode gives large, high-contrast controls
(skip, play/pause) with nothing else to interact with, for use without (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 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 road. Word-level highlighting can be switched off in the reading controls if
it is more distracting than helpful; sentence highlighting keeps working 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 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, 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. 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. comparison above is one confirmed live test, not a systematic evaluation.
- Image descriptions, table/chart handling, and EPUB 3 Media Overlay export - Image descriptions, table/chart handling, and EPUB 3 Media Overlay export
remain P2 and are not started. 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 import requests
from core.retry import request_with_retries 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 dataclasses import dataclass, field, asdict
from pathlib import Path from pathlib import Path
from typing import Any, Iterable, Sequence from typing import Any, Iterable, Sequence
@ -52,6 +52,7 @@ BLOCK_HEADING = "heading"
BLOCK_DECK = "deck" # standfirst / subtitle under the headline BLOCK_DECK = "deck" # standfirst / subtitle under the headline
BLOCK_BYLINE = "byline" BLOCK_BYLINE = "byline"
BLOCK_BODY = "body" BLOCK_BODY = "body"
BLOCK_QUOTE = "quote" # quoted speech that belongs in narration
BLOCK_CAPTION = "caption" BLOCK_CAPTION = "caption"
BLOCK_PULLQUOTE = "pullquote" # verbatim repeat of body text, blown up BLOCK_PULLQUOTE = "pullquote" # verbatim repeat of body text, blown up
BLOCK_SIDEBAR = "sidebar" BLOCK_SIDEBAR = "sidebar"
@ -63,7 +64,7 @@ BLOCK_TABLE = "table"
BLOCK_CODE = "code" BLOCK_CODE = "code"
VISUAL_TYPES = (BLOCK_FIGURE, BLOCK_CHART, BLOCK_TABLE, BLOCK_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. # Text that marks an article jumping to (or arriving from) another page.
_CONT_TO = re.compile( _CONT_TO = re.compile(
@ -905,7 +906,8 @@ _VISION_LABEL_TYPES = {
"footnotes": BLOCK_SIDEBAR, "footnotes": BLOCK_SIDEBAR,
"note": BLOCK_SIDEBAR, "note": BLOCK_SIDEBAR,
"commentary": BLOCK_SIDEBAR, "commentary": BLOCK_SIDEBAR,
"quote": BLOCK_PULLQUOTE, "quote": BLOCK_QUOTE,
"blockquote": BLOCK_QUOTE,
"pullquote": BLOCK_PULLQUOTE, "pullquote": BLOCK_PULLQUOTE,
# Page numbers / Furniture / Folios / Sources # 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äöüß]") _LOWERCASE_REGION_START = re.compile(r"^[a-zäöüß]")
_DETACHED_DROP_CAP = re.compile(r"^([A-ZÄÖÜ])\s+([a-zäöüß]{3,})\b") _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: def prefer_drop_cap_repair(original: str, candidate: str) -> str:
"""Use a local OCR reading when it demonstrably restores a missing first line. """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 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 object, then begin the paragraph transcription later. The remainder may be
body region beginning with a lower-case word fragment (``tert, dass`` in a lower-case word fragment (``tert, dass`` in ``eingetrichtert, dass``) or
``eingetrichtert, dass``). A crop read by a second OCR engine is accepted a valid capitalised word after an entire omitted line (``Herrn, verwaltet``).
only when most of the existing transcription still appears in it and it A second reading is accepted only when most saved text still appears in it;
adds an upper-case beginning; this prevents an ordinary lower-case page capitalised remainders additionally require a substantial multi-word prefix.
continuation from being replaced by an unrelated, poorer reading.
""" """
old = " ".join((original or "").split()) old = " ".join((original or "").split())
# Tesseract often emits the ordinary first-line letters *before* the # 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: if cap_line:
lines = [cap_line.group(1) + lines[0], cap_line.group(2), *lines[2:]] lines = [cap_line.group(1) + lines[0], cap_line.group(2), *lines[2:]]
new = " ".join(lines).lstrip("|¦[]{}() ") new = " ".join(lines).lstrip("|¦[]{}() ")
new = _DETACHED_DROP_CAP.sub(r"\1\2", new, count=1) new = _join_detached_drop_cap(new)
if not old or not new or not _LOWERCASE_REGION_START.match(old) or not new[:1].isupper(): old_starts_lower = bool(_LOWERCASE_REGION_START.match(old))
if not old or not new or not new[:1].isupper():
return old return old
if len(new) < max(40, int(len(old) * 0.85)) or ocr_quality_score(new) < 0.55: if len(new) < max(40, int(len(old) * 0.85)) or ocr_quality_score(new) < 0.55:
return old 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 # this also prevents a crop touching the next paragraph by a few pixels
# from appending that paragraph a second time. # from appending that paragraph a second time.
prefix = new[:first_substantial.b].rstrip() 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("-"): if prefix.endswith("-"):
prefix = prefix[:-1] # printed line-break hyphen: eingetrich- + tert prefix = prefix[:-1] # printed line-break hyphen: eingetrich- + tert
return prefix + old 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 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 normal regions cost nothing, and a replacement is made only through
:func:`prefer_drop_cap_repair`'s overlap guard. :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"} if r.get("label") in {"text", "paragraph", "body"}
and len((r.get("text") or "").strip()) >= 80 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: if not suspicious:
return 0 return repaired
try: try:
from PIL import Image from PIL import Image
image = Image.open(png_path) image = Image.open(png_path)
except Exception: except Exception:
return 0 return repaired
repaired = 0
try: try:
for region in suspicious: for region in suspicious:
x, y, w, h = (float(v) for v in region["bbox"]) 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. pass for finer distinctions such as byline or deck.
""" """
out = [] 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): for i, region in enumerate(regions):
x, y, w, h = (v * scale_x for v in region["bbox"]) x, y, w, h = (v * scale_x for v in region["bbox"])
label = region["label"] label = region["label"]
text = region["text"] text = region["text"]
mapped = _VISION_LABEL_TYPES.get(label) 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: if mapped is not None:
block = Block( block = Block(
id=f"{id_prefix}{page_no:04d}-v{i:03d}", 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)) * 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], def snap_visual_bboxes(png_path: Path, blocks: Sequence[Block],
page_meta: dict[str, Any]) -> int: page_meta: dict[str, Any]) -> int:
"""Correct every visual block's bbox against the page render, in place. """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) text_blocked = np.zeros(coverage.shape, dtype=bool)
for b in blocks: 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 continue
x0, y0, x1, y1 = to_cells(b.bbox) x0, y0, x1, y1 = to_cells(b.bbox)
text_blocked[y0:y1 + 1, x0:x1 + 1] = True 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 current = nxt
reverted = ambiguous | colliding_pairs(current) reverted = ambiguous | colliding_pairs(current)
final = [None if i in reverted or current[i] == visual_seeds[i] else current[i] final = [None if i in reverted else current[i] for i in range(len(current))]
for i in range(len(current))]
moved = 0 moved = 0
for block, box in zip(visuals, final): for index, (block, box) in enumerate(zip(visuals, final)):
if box is None: if box is None:
continue continue
bbox = [box[0] / cells_x, box[1] / cells_y, refined = _refine_dense_visual_edges(png_path, box, page_w, page_h)
(box[2] + 1 - box[0]) / cells_x, (box[3] + 1 - box[1]) / cells_y] 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)): if any(abs(a - b) > 1.0 for a, b in zip(bbox, block.bbox)):
moved += 1 moved += 1
block.bbox = bbox block.bbox = bbox
@ -2516,6 +2723,205 @@ def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int,
return layout 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 _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 # 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 # 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. the adjacent regions continuously.
""" """
known = {b["id"]: b for b in blocks} 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() used = set()
out = [] out = []
allowed = set(NARRATABLE) | set(VISUAL_TYPES) | {BLOCK_AD, BLOCK_SIDEBAR, BLOCK_CAPTION, BLOCK_FURNITURE, BLOCK_PULLQUOTE} 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, def append_group(members: list[dict], kind: str, label: str,
visual_box: list[float] | None, continues: bool) -> None: 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) x = min(b["bbox"][0] for b in members)
y = min(b["bbox"][1] 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) 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] members = [known[i] for i in ids]
if len({b["page"] for b in members}) != 1: if len({b["page"] for b in members}) != 1:
raise ValueError("A region cannot span pages") 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 any(b["type"] == BLOCK_AD for b in members):
if not all(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") 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: if page is not None and page_no != page:
continue continue
blocks = [Block(**b) for b in layout.get("blocks", []) if b["page"] == page_no] 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 # Re-snapping here, not only at OCR time, is what lets an issue
# ingested before this pass existed pick up corrected figure boxes # ingested before this pass existed pick up corrected figure boxes
# without paying for a second OCR run of the whole document. It is # without paying for a second OCR run of the whole document. It is
# idempotent: an already-correct box seeds the same graphic again. # idempotent: an already-correct box seeds the same graphic again.
if snap_visual_bboxes(doc_dir / "pages" / f"{page_no:04d}.png", blocks, meta): 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} 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"]: for saved in layout["blocks"]:
if saved["id"] in snapped: if saved["id"] in snapped:
saved["bbox"] = snapped[saved["id"]] 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), "heading": (BLOCK_HEADING, None),
"deck": (BLOCK_DECK, None), "deck": (BLOCK_DECK, None),
"byline": (BLOCK_BYLINE, None), "byline": (BLOCK_BYLINE, None),
"quote": (BLOCK_QUOTE, None),
"caption": (BLOCK_CAPTION, None), "caption": (BLOCK_CAPTION, None),
"pullquote": (BLOCK_PULLQUOTE, None), "pullquote": (BLOCK_PULLQUOTE, None),
"sidebar": (BLOCK_SIDEBAR, 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( logging.getLogger(__name__).warning(
"vision OCR unreachable for a re-read region; falling back to local OCR") "vision OCR unreachable for a re-read region; falling back to local OCR")
if regions: if regions:
return " ".join(r.get("text", "") for r in regions if r.get("text")).strip() repair_drop_cap_omissions(crop_path, regions)
return " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip() 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: finally:
crop_path.unlink(missing_ok=True) 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) block_idx = next((i for i, b in enumerate(blocks) if b["id"] == block_id), None)
if block_idx is not None: if block_idx is not None:
block = dict(blocks[block_idx]) block = dict(blocks[block_idx])
old_bbox = list(block.get("bbox") or [])
old_text = str(block.get("text") or "")
if bbox is not None: if bbox is not None:
block["bbox"] = list(bbox) block["bbox"] = list(bbox)
new_text = None 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_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model, timeout) vision_ocr_fallback_url, vision_ocr_fallback_model, timeout)
if new_text: 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 block["text"] = new_text
blocks[block_idx] = block blocks[block_idx] = block
layout["blocks"] = blocks layout["blocks"] = blocks
@ -3184,6 +3655,7 @@ REGION_TYPES: dict[str, tuple[str, str]] = {
"heading": (BLOCK_HEADING, ""), "heading": (BLOCK_HEADING, ""),
"deck": (BLOCK_DECK, ""), "deck": (BLOCK_DECK, ""),
"byline": (BLOCK_BYLINE, ""), "byline": (BLOCK_BYLINE, ""),
"quote": (BLOCK_QUOTE, ""),
"caption": (BLOCK_CAPTION, ""), "caption": (BLOCK_CAPTION, ""),
"pullquote": (BLOCK_PULLQUOTE, ""), "pullquote": (BLOCK_PULLQUOTE, ""),
"sidebar": (BLOCK_SIDEBAR, ""), "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 listening experience enough that it should be an explicit choice, not a
silent side effect of one being available. 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 []) wanted = set(article.get("blocks") or [])
types = set(NARRATABLE) | ({BLOCK_CAPTION} if include_captions else set()) types = set(NARRATABLE) | ({BLOCK_CAPTION} if include_captions else set())
blocks = [ 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", []) 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: if include_descriptions:
wanted_images = set(article.get("images") or []) wanted_images = set(article.get("images") or [])
for img in layout.get("images", []): for img in layout.get("images", []):
description = (img.get("description") or "").strip() 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 continue
blocks.append({ blocks.append({
"id": f"desc-{img['id']}", "page": img["page"], "bbox": img["bbox"], "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", "eine", "einer", "einen", "für", "hat", "ist", "mit", "nicht", "oder", "sich",
"sie", "und", "von", "werden", "wie", "zu", "sie", "und", "von", "werden", "wie", "zu",
} }
_SPELL_WORD_RE = re.compile(r"[A-Za-zÄÖÜäöüß]{3,}")
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
@ -61,6 +62,42 @@ def _german_spellchecker():
return None 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: def _match_case(source: str, replacement: str) -> str:
if source.isupper() and len(source) > 1: if source.isupper() and len(source) > 1:
return replacement.upper() return replacement.upper()

View File

@ -77,7 +77,7 @@ _CAST_JOBS: dict[str, dict] = {}
_CAST_JOB_IDEMPOTENCY: dict[str, tuple[str, float]] = {} _CAST_JOB_IDEMPOTENCY: dict[str, tuple[str, float]] = {}
_CAST_JOB_MAX = 128 _CAST_JOB_MAX = 128
_CAST_JOB_TIMEOUT = 15 * 60 _CAST_JOB_TIMEOUT = 15 * 60
_CHARACTER_VISUAL_PROMPT_VERSION = "v3" _CHARACTER_VISUAL_PROMPT_VERSION = "v4"
_CHARACTER_IDENTITY_FIELDS = ( _CHARACTER_IDENTITY_FIELDS = (
"age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species", "age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species",
"gender", "marks", "scars", "nationality_background", "clothing", "era", "profession", "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}. " f"Style: {style}. "
"Composition: one person, chest-up square at eye level, 70 mm, calm three-quarter gaze, warm-gray studio background, face unobstructed. " "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. " "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." "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. " "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. " "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. " "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." "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( background_job_update(
job_id, stage="extract", job_id, stage="extract",
detail=f"Analysing page {page} of {total}", 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 # Written to its own small file rather than the generic job
# result: that field strips any key resembling "text"/"content" # 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 labelled(step: str, *, refresh: bool = False):
def on_page(page_no: int) -> None: def on_page(page_no: int) -> None:
background_job_update(job_id, stage="advertisements", 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: if refresh:
# Snap this page's picture/chart/table boxes to the ink and crop them to # 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 -- # 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} return {"job_id": job_id}
def _refine_figures_sync(doc_id: str, job_id: str) -> None: def _quality_page_sync(doc_id: str, job_id: str, page: int, mode: str,
"""Re-snap every figure box in a document against its page renders. 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 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. 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) layout = mag.load_layout(d)
if not layout: if not layout:
raise FileNotFoundError("No layout yet -- run ingestion first") 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", [])} before = {b["id"]: list(b["bbox"]) for b in layout.get("blocks", [])}
for i, meta in enumerate(pages, 1): for i, meta in enumerate(pages, 1):
background_job_update(job_id, stage="figures", detail = (f"Checking figure boundaries on page {meta['page']}"
detail=f"Checking figure boundaries on page {i} of {len(pages)}") 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"]) mag.refresh_visual_regions(d, layout, meta["page"])
moved = sum(1 for b in layout.get("blocks", []) moved = sum(1 for b in layout.get("blocks", [])
if before.get(b["id"]) and b["bbox"] != before[b["id"]]) 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} 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") @router.put("/api/magazine/{doc_id}/pages/{page}/reading-order")
async def magazine_set_page_order(doc_id: str, page: int, request: Request): 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 """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} 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, 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, 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: 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)): and all(isinstance(v, (int, float)) for v in bbox)):
raise HTTPException(400, "bbox must be [x, y, width, height]") raise HTTPException(400, "bbox must be [x, y, width, height]")
kind = str(body.get("kind") or "text") 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", "pullquote", "sidebar", "page_number", "source", "in_picture", "ocr_noise",
"duplicate", "table", "ad"): "duplicate", "table", "ad"):
raise HTTPException(400, "Unsupported region kind") 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: def progress(done: int, total: int) -> None:
background_job_update( 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( return mag.synthesize_article_audio(
d, article, layout, voice, settings, 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 = [], [] ok, failed = [], []
total = len(article_ids) total = len(article_ids)
for i, article_id in enumerate(article_ids, start=1): 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: try:
_synthesize_article_audio_sync( _synthesize_article_audio_sync(
doc_id, job_id, article_id, voice, interview_voice, doc_id, job_id, article_id, voice, interview_voice,

View File

@ -1296,7 +1296,7 @@ function _libDeleteForm(sheet, id) {
function _libFormPairState(sheet, form) { function _libFormPairState(sheet, form) {
if (!form || !form.image || !form.concept_art_image) return 'none'; if (!form || !form.image || !form.concept_art_image) return 'none';
const fingerprint = _libIdentityFingerprint(Object.assign({}, sheet, form)); 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; || form.consistent_identity_fingerprint !== fingerprint || form.identity_consistent === false;
return stale ? 'review' : 'ok'; 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 // header at thumbnail size (confirmed live: 110px was too small to read
// any actual detail in a multi-pose design sheet). // any actual detail in a multi-pose design sheet).
const visualPairNeedsReview = !!(avatarImage && sh.concept_art_image && ( 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
|| sh.consistent_identity_fingerprint !== identityFingerprintShort || sh.consistent_identity_fingerprint !== identityFingerprintShort
|| sh.identity_consistent === false || sh.identity_consistent === false
@ -3822,7 +3822,7 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
panel.setJobId(job_id); panel.setJobId(job_id);
const d = await _cijPollJob(job_id, panel); const d = await _cijPollJob(job_id, panel);
if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair'); 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); await _libSaveCharacterVisual(rec, visualFields);
document.querySelectorAll('.lib-char-avatar[data-char-id="' + CSS.escape(rec.id) + '"]').forEach(function (av) { 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) + '">'; 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. // drawn over the page image as they arrive, coloured by type and numbered in reading order.
const MAG_PREVIEW_TYPES = { const MAG_PREVIEW_TYPES = {
title: 'Headline', heading: 'Subheading', deck: 'Standfirst', byline: 'Author', body: 'Paragraph', title: 'Headline', heading: 'Subheading', deck: 'Intro / Standfirst', byline: 'Author', body: 'Paragraph',
caption: 'Caption', pullquote: 'Pull quote', sidebar: 'Sidenote', ad: 'Advertisement', quote: 'Quote', caption: 'Caption', pullquote: 'Pullquote', sidebar: 'Sidenote', ad: 'Advertisement',
furniture: 'Running head / footer', page_number: 'Page number', image: 'Picture', furniture: 'Running head / footer', page_number: 'Page number', image: 'Picture',
figure: 'Picture', chart: 'Chart', table: 'Table', code: 'Code', figure: 'Picture', chart: 'Chart', table: 'Table', code: 'Code',
}; };
@ -545,6 +545,7 @@ async function pollIngest() {
} catch (_) { break; } } catch (_) { break; }
const ing = data.ingest || {}; const ing = data.ingest || {};
const detail = (data.job || {}).detail || ''; const detail = (data.job || {}).detail || '';
magMarkLive(ing.status === 'running' ? data.job : null, true);
if (bar) { if (bar) {
if (ing.status === 'running') { if (ing.status === 'running') {
bar.className = 'mag-status is-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 // that touches it -- a blank page or an ad, not a gap
// A page touched by several articles takes the most-attention-needed state // A page touched by several articles takes the most-attention-needed state
// among them (audio beats approved beats detected beats empty). // among them (audio beats approved beats detected beats empty).
function magPageState(arts) { // The last whole-issue quality run splits the two unreviewed states further:
if (!arts.length) return 'blank'; // 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.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'; if (arts.some((a) => a.status === 'approved')) return 'approved';
return 'empty'; 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) { 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; const pageCount = state.doc?.pageCount || 0;
if (!pageCount) { host.innerHTML = '<p class="muted">Page count unknown yet.</p>'; return; } if (!pageCount) { host.innerHTML = '<p class="muted">Page count unknown yet.</p>'; return; }
const byPage = new Map(); const byPage = new Map();
@ -812,8 +908,9 @@ function renderPagesView(host) {
byPage.get(p).push(a); byPage.get(p).push(a);
})); }));
const legend = [ const legend = [
['blank', 'not reviewed yet'], ['detected', 'has content, not reviewed'], ['blank', 'not reviewed yet'], ['detected', 'has content, not quality-checked'],
['approved', 'reviewed'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'], ['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">' + 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('') + 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 + '">'; html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
for (let p = 1; p <= pageCount; p++) { for (let p = 1; p <= pageCount; p++) {
const arts = byPage.get(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 + '">' + '<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 (arts.length
? '<div class="mag-page-articles">' + arts.map((a) => ? '<div class="mag-page-articles">' + arts.map((a) =>
'<div class="mag-page-article">' + '<div class="mag-page-article">' +
'<span class="mag-page-article-title">' + esc(a.title || 'Untitled') + '</span>' + '<span class="mag-page-article-title">' + esc(a.title || 'Untitled') + '</span>' +
'<span class="mag-page-article-actions">' + '<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 + '\')" ' + (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>' : '') + 'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi mdi-play" aria-hidden="true"></span> Play</button>' : '') +
'</span>' + '</span>' +
'</div>').join('') + '</div>' '</div>').join('') + '</div>'
: '<span class="mag-pill">no article</span>' + : '<span class="mag-pill">no article</span>' +
'<div class="mag-toc-actions"><button type="button" class="btn-sm" onclick="magReviewPage(' + p + ')">Review this page</button></div>') + '<div 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>'; '</div></div>';
} }
html += '</div>'; html += '</div>';
@ -1017,6 +1117,7 @@ async function magGenerateAllAudio(force = false) {
return say('Could not check audio export: ' + e.message, 'err'); return say('Could not check audio export: ' + e.message, 'err');
} }
if (status) status.textContent = job.detail || 'Working…'; 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 // The job's own detail text always leads with "N/Total" -- either
// "Article N/Total…" between articles, or "(N/Total) Title: sentence // "Article N/Total…" between articles, or "(N/Total) Title: sentence
// X/Y" while one is actively rendering. "Article 6 of 44" means little // X/Y" while one is actively rendering. "Article 6 of 44" means little
@ -1073,6 +1174,12 @@ async function magReview(articleId) {
renderReview(); renderReview();
} }
async function magReviewAtPage(articleId, page) {
await magReview(articleId);
if (state.article?.id !== articleId) return;
magReviewGoMagazinePage(Number(page));
}
async function ensureLayout() { async function ensureLayout() {
if (state.layout) return; if (state.layout) return;
try { try {
@ -1106,9 +1213,15 @@ function magReviewGoPage(page) {
renderReview(); renderReview();
} }
function magReviewGoMagazinePage(page) { async function magReviewGoMagazinePage(page) {
const lastPage = Number(state.layout?.page_count || 0); const lastPage = Number(state.layout?.page_count || 0);
if (!Number.isInteger(page) || page < 1 || page > lastPage) return; 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; state.reviewPage = page;
renderReview(); renderReview();
} }
@ -1123,12 +1236,13 @@ function magReviewGoMagazinePage(page) {
// (see magReviewSubmitDraw); everything else (page number, source, ad, // (see magReviewSubmitDraw); everything else (page number, source, ad,
// table, duplicate, OCR noise, caption, pull quote, sidenote) is furniture // table, duplicate, OCR noise, caption, pull quote, sidenote) is furniture
// or reference material excluded from narration on purpose and stays unticked. // 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 = [ const MAG_REGION_TYPES = [
['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], ['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Intro / Standfirst'],
['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'], ['byline', 'Author'], ['image', 'Image'], ['caption', 'Caption'], ['in_picture', 'Text in Picture'],
['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'], ['table', 'Table'], ['quote', 'Quote'], ['pullquote', 'Pullquote'], ['sidebar', 'Sidenote'],
['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['table', 'Table'], ['image', 'Image'], ['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 // 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 // -- 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 // "furniture" on the page itself, so listing them separately would promise
// a colour distinction the boxes don't actually make. // a colour distinction the boxes don't actually make.
const MAG_LEGEND_TYPES = [ const MAG_LEGEND_TYPES = [
['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], ['byline', 'Author'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Intro / Standfirst'], ['byline', 'Author'],
['body', 'Paragraph'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'], ['body', 'Paragraph'], ['quote', 'Quote'], ['caption', 'Caption'], ['pullquote', 'Pullquote'], ['sidebar', 'Sidenote'],
['ad', 'Advertisement'], ['furniture', 'Page number / footer'], ['ad', 'Advertisement'], ['furniture', 'Page number / footer'],
['image', 'Image'], ['chart', 'Chart'], ['table', 'Table'], ['code', 'Code'], ['image', 'Image'], ['chart', 'Chart'], ['table', 'Table'], ['code', 'Code'],
]; ];
@ -1152,6 +1266,7 @@ function magReviewLegendHtml() {
const MAG_REGION_TYPE_ICONS = { const MAG_REGION_TYPE_ICONS = {
body: 'mdi-text-box-outline', title: 'mdi-format-title', heading: 'mdi-format-header-2', 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', 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', 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', source: 'mdi-book-open-page-variant-outline', ad: 'mdi-advertisements-outline',
in_picture: 'mdi-text-recognition', ocr_noise: 'mdi-alert-circle-outline', in_picture: 'mdi-text-recognition', ocr_noise: 'mdi-alert-circle-outline',
@ -1173,16 +1288,93 @@ function magRegionTypeValue(region) {
function magRegionControls(region, number) { function magRegionControls(region, number) {
const current = magRegionTypeValue(region); const current = magRegionTypeValue(region);
const known = MAG_REGION_TYPES.some(([v]) => v === current); const known = MAG_REGION_TYPES.some(([v]) => v === current);
const options = (known ? [] : [[current, current]]).concat(MAG_REGION_TYPES) const options = (known ? MAG_REGION_TYPES : [[current, current]].concat(MAG_REGION_TYPES));
.map(([v, label]) => '<option value="' + v + '"' + (v === current ? ' selected' : '') + '>' + esc(label) + '</option>').join(''); const label = (options.find(([value]) => value === current) || [current, current])[1];
return '<select class="mag-block-type-select" aria-label="Type of region ' + number + '" ' + return '<div class="mag-type-picker mag-region-type-picker" data-region-id="' + esc(region.id) + '" ' +
'onclick="event.stopPropagation()" onchange="magReviewChangeType(\'' + esc(region.id) + '\', this.value, this)">' + 'data-current="' + esc(current) + '" onclick="event.preventDefault();event.stopPropagation()">' +
options + '</select>' + '<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" ' + '<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) + '\')">' + 'onclick="event.preventDefault(); event.stopPropagation(); magReviewDeleteRegion(\'' + esc(region.id) + '\')">' +
'<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span></button>'; '<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) { async function magReviewChangeType(regionId, type, select) {
const toImage = type === 'image'; const toImage = type === 'image';
const fromImage = !!(state.layout?.images || []).some((img) => img.id === regionId); const fromImage = !!(state.layout?.images || []).some((img) => img.id === regionId);
@ -1206,6 +1398,7 @@ async function magReviewChangeType(regionId, type, select) {
function magBlockIcon(type) { function magBlockIcon(type) {
if (type === 'image' || type === 'figure') return 'mdi-image-outline'; 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 === 'pullquote') return 'mdi-format-quote-close';
if (type === 'chart') return 'mdi-chart-bar'; if (type === 'chart') return 'mdi-chart-bar';
if (type === 'table') return 'mdi-table'; if (type === 'table') return 'mdi-table';
@ -1342,7 +1535,7 @@ function magReviewPageColumn(page, wrapId, interactive) {
return; return;
} }
const b = r.data; 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); const on = included.has(b.id);
listHtml += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' + listHtml += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' + '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.">' + '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>' + '<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()" ' + '<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">' + 'title="Re-check figure boundaries on this page only">' +
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries</button>' + '<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()">' + '<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' + '<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>' + '<span id="mag-reocr-status" class="muted" role="status"></span>' +
'</div>'; '</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="magShow(\'contents\')">Back to contents</button>' +
'<button type="button" class="btn" onclick="magSetStatus(\'rejected\')">Not an article</button>' + '<button type="button" class="btn" onclick="magSetStatus(\'rejected\')">Not an article</button>' +
'<button type="button" class="btn primary" onclick="magApprove()" ' + '<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>' + 'Approve &amp; Next</button>' +
'</div>'; '</div>';
host.innerHTML = html; host.innerHTML = html;
@ -2558,7 +2762,7 @@ function _magSetupDrawKindPicker(options) {
// Start on the default (Paragraph) with its label showing, matching the // Start on the default (Paragraph) with its label showing, matching the
// hidden input's own default value set in the HTML above. // hidden input's own default value set in the HTML above.
pick(hidden.value, (options.find(([v]) => v === hidden.value) || [])[1] || ''); 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('input', () => { list.hidden = false; search.setAttribute('aria-expanded', 'true'); renderList(search.value); });
search.addEventListener('keydown', (e) => { search.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { list.hidden = true; search.setAttribute('aria-expanded', 'false'); search.blur(); } if (e.key === 'Escape') { list.hidden = true; search.setAttribute('aria-expanded', 'false'); search.blur(); }
@ -2748,18 +2952,62 @@ async function magReocrPage() {
renderReview(); renderReview();
} }
// Re-derives every figure box in the issue from the page renders. Separate async function magRunPageQuality(mode) {
// from "OCR again" because no OCR engine is involved: it only corrects where magOfferUndo(null);
// the figures are, never what the page says, so it is safe to run on an issue const page = state.reviewPage;
// that is half reviewed already. if (page == null) return;
async function magRefineFigures() { const isMissing = mode === 'missing';
const btn = $('mag-refine-figures-btn'); const btn = $(isMissing ? 'mag-find-missing-btn' : 'mag-verify-text-btn');
const statusEl = $('mag-reocr-status'); const statusEl = $('mag-reocr-status');
if (btn) btn.disabled = true; 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; let jobId;
try { 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; jobId = r.job_id;
} catch (e) { } catch (e) {
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
@ -2775,16 +3023,113 @@ async function magRefineFigures() {
else say(job.detail || 'Figure boundaries checked', 'ok'); else say(job.detail || 'Figure boundaries checked', 'ok');
break; break;
} }
magMarkLive(job, wholeIssue);
if (statusEl && job.detail) statusEl.textContent = job.detail; if (statusEl && job.detail) statusEl.textContent = job.detail;
await new Promise((r) => setTimeout(r, 2000)); await new Promise((r) => setTimeout(r, 2000));
} }
magMarkLive(null);
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = ''; if (statusEl) statusEl.textContent = '';
if (failed) return; if (failed) return;
state.layout = null; state.layout = null;
await ensureLayout(); await ensureLayout();
_magReviewChecked = null; _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() { async function magApprove() {
@ -2807,21 +3152,15 @@ async function magApprove() {
}); });
Object.assign(a, updated); Object.assign(a, updated);
say('Approved — ready to listen', 'ok'); say('Approved — ready to listen', 'ok');
// Approving one article at a time is a queue, not a one-off — staying on // Review is a physical page-by-page workflow. An article may span many
// the list meant re-finding and re-opening whichever article came next // pages, so advancing from its last claimed page skips pages the user has
// after every single approval. Go straight to it instead, and only fall // not inspected yet. Always advance from the page currently on screen;
// back to the list once nothing is left to review. // magReviewPage creates a page-scoped placeholder if nothing covers it.
// Next PAGE needing attention, not next article -- an article can span const nextPage = Number(state.reviewPage || 0) + 1;
// 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 || []));
await magRefreshArticles(); await magRefreshArticles();
const nextPage = magNextUnresolvedPage(lastPage); if (nextPage <= Number(state.doc?.pageCount || state.layout?.page_count || 0)) {
if (nextPage) magReviewPage(nextPage); await magReviewPage(nextPage);
}
else magShow('contents'); else magShow('contents');
} catch (e) { } catch (e) {
say('Could not approve: ' + e.message, 'err'); say('Could not approve: ' + e.message, 'err');
@ -2855,10 +3194,14 @@ function splitSentences(blocks) {
const parts = String(b.text || '') const parts = String(b.text || '')
.split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/) .split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/)
.map((s) => s.trim()).filter(Boolean); .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()) { if (text && text.trim()) {
out.push({ out.push({
text: text.trim(), blockId: b.id, page: b.page, bbox: b.bbox, type: b.type, 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 // Used to pick "the image reached so far" during playback (see
// currentImageIndex) -- the backend already computes this per // currentImageIndex) -- the backend already computes this per
// block relative to the whole page, not just this article. // block relative to the whole page, not just this article.
@ -3136,7 +3479,7 @@ async function magPrepareAudio() {
const worker = async () => { const worker = async () => {
while (!failure && !state.prepCancel && gen === state.prepGen && next < order.length) { while (!failure && !state.prepCancel && gen === state.prepGen && next < order.length) {
const i = order[next++]; 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; } catch (e) { failure = { i, message: e.message }; return; }
done += 1; done += 1;
if (gen === state.prepGen) syncPrepButton(done, total); if (gen === state.prepGen) syncPrepButton(done, total);
@ -3171,13 +3514,13 @@ async function magPlay() {
const sentence = state.sentences[state.idx]; const sentence = state.sentences[state.idx];
let blob; let blob;
try { 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 // Without a full "Prepare audio" run, still stay a few sentences ahead
// so the wait is only ever for the first one. // so the wait is only ever for the first one.
if (!state.preparing) { if (!state.preparing) {
for (let k = 1; k <= MAG_LOOKAHEAD; k++) { for (let k = 1; k <= MAG_LOOKAHEAD; k++) {
const upcoming = state.sentences[state.idx + 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; blob = await pending;
@ -3435,10 +3778,7 @@ function magPlaylistSync() {
} }
renderPlaylistVisual(index); renderPlaylistVisual(index);
const pct = duration ? Math.min(100, audio.currentTime / duration * 100) : 0; const pct = duration ? Math.min(100, audio.currentTime / duration * 100) : 0;
const drivingBar = $('mag-driving-progress-bar'); if (state.driving) magDrivingSync(audio, duration, pct, index);
const drivingTime = $('mag-driving-time');
if (drivingBar) drivingBar.style.width = pct + '%';
if (drivingTime) drivingTime.textContent = magFmtClock(audio.currentTime) + ' / ' + magFmtClock(duration);
} }
async function loadPlaylistCompanion(a) { async function loadPlaylistCompanion(a) {
@ -3495,7 +3835,7 @@ function magPlaylistPlay(articleId, options) {
} }
renderPlaylistTracks(); renderPlaylistTracks();
loadPlaylistCompanion(a); loadPlaylistCompanion(a);
if (state.driving) renderDriving(); if (state.driving) { renderDriving(); magDrivingApplyToggles(); magApplyPlaybackSpeed(); }
} }
function _magPlaylistNext(eventOrCompleted) { function _magPlaylistNext(eventOrCompleted) {
@ -3799,6 +4139,116 @@ function magDrivingTogglePlayback() {
magPlay(); 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() { function magDrivingNext() {
if (state.playlistCurrent) return _magPlaylistNext(false); if (state.playlistCurrent) return _magPlaylistNext(false);
magQueueNext(); magQueueNext();
@ -3824,6 +4274,7 @@ function wirePlaylistAudio() {
renderPlaylistTracks(); renderPlaylistTracks();
magPlaylistSync(); magPlaylistSync();
}); });
audio.addEventListener('loadedmetadata', magApplyPlaybackSpeed);
audio.addEventListener('play', syncDrivingPlayButton); audio.addEventListener('play', syncDrivingPlayButton);
audio.addEventListener('pause', () => { audio.addEventListener('pause', () => {
if (!state.playlistSwitching) savePlaylistProgress(false, true); if (!state.playlistSwitching) savePlaylistProgress(false, true);
@ -3898,6 +4349,7 @@ root.magContentsClearSelection = magContentsClearSelection;
root.magContentsBulkStatus = magContentsBulkStatus; root.magContentsBulkStatus = magContentsBulkStatus;
root.magReviewGoPage = magReviewGoPage; root.magReviewGoPage = magReviewGoPage;
root.magReviewGoMagazinePage = magReviewGoMagazinePage; root.magReviewGoMagazinePage = magReviewGoMagazinePage;
root.magReviewAtPage = magReviewAtPage;
root.magReviewToggleSpread = magReviewToggleSpread; root.magReviewToggleSpread = magReviewToggleSpread;
root.magReviewSetActivePage = magReviewSetActivePage; root.magReviewSetActivePage = magReviewSetActivePage;
root.magReviewFocusBlock = magReviewFocusBlock; root.magReviewFocusBlock = magReviewFocusBlock;
@ -3908,6 +4360,9 @@ root.magReviewClearCurrentPage = magReviewClearCurrentPage;
root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes; root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes;
root.magReocrPage = magReocrPage; root.magReocrPage = magReocrPage;
root.magRefineFigures = magRefineFigures; root.magRefineFigures = magRefineFigures;
root.magRefineAllFigures = magRefineAllFigures;
root.magQualityRun = magQualityRun;
root.magToggleAdPage = magToggleAdPage;
root.magReviewToggleImage = magReviewToggleImage; root.magReviewToggleImage = magReviewToggleImage;
root.magDescribeImage = magDescribeImage; root.magDescribeImage = magDescribeImage;
root.magSaveImageDescription = magSaveImageDescription; root.magSaveImageDescription = magSaveImageDescription;
@ -3958,6 +4413,11 @@ root.magToggleDriving = magToggleDriving;
root.magOpenDriving = magOpenDriving; root.magOpenDriving = magOpenDriving;
root.magDrivingTogglePlayback = magDrivingTogglePlayback; root.magDrivingTogglePlayback = magDrivingTogglePlayback;
root.magDrivingNext = magDrivingNext; root.magDrivingNext = magDrivingNext;
root.magDrivingSkip = magDrivingSkip;
root.magDrivingSeek = magDrivingSeek;
root.magDrivingCycleSpeed = magDrivingCycleSpeed;
root.magDrivingToggleText = magDrivingToggleText;
root.magDrivingToggleImages = magDrivingToggleImages;
root.magDrivingPrevious = magDrivingPrevious; root.magDrivingPrevious = magDrivingPrevious;
root.magInit = magInit; root.magInit = magInit;
MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages }; MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages };

View File

@ -64,6 +64,10 @@
onblur="magCommitDocField(this, 'issue')" onblur="magCommitDocField(this, 'issue')"
onkeydown="if(event.key==='Enter'){this.blur();}"> onkeydown="if(event.key==='Enter'){this.blur();}">
<div class="mag-bar-actions"> <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"> <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 <span class="mdi mdi-view-grid-outline"></span> Thumbnails
</button> </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"> <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) <span class="mdi mdi-text-recognition"></span> OCR again (whole magazine)
</button> </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> </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 id="mag-contents-bulkbar" class="mag-bulkbar" hidden></div>
<div class="mag-audio-export"> <div class="mag-audio-export">
<label for="mag-audio-voice">Narrator for exported MP3s</label> <label for="mag-audio-voice">Narrator for exported MP3s</label>
@ -276,18 +285,41 @@
<div id="mag-driving-queue" class="mag-driving-queue" hidden> <div id="mag-driving-queue" class="mag-driving-queue" hidden>
<span id="mag-queue-progress-2" class="muted"></span> <span id="mag-queue-progress-2" class="muted"></span>
</div> </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"> <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> <span class="mdi mdi-skip-previous"></span>
</button> </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"> <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> <span class="mdi mdi-play"></span>
</button> </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> <span class="mdi mdi-skip-next"></span>
</button> </button>
</div> </div>
<div class="mag-driving-time" id="mag-driving-time" aria-live="polite">0:00 / 0:00</div> <div class="mag-driving-options" role="group" aria-label="Playback options">
<div class="mag-driving-progress" aria-hidden="true"><span id="mag-driving-progress-bar"></span></div> <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>
</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-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-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-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 { 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-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); } .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. */ 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 { cursor: default; }
.mag-review-page-wrap.is-editing .mag-review-box { cursor: move; } .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; 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; } 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; } .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); .mag-block-type { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext);
display: flex; align-items: center; gap: 3px; } display: flex; align-items: center; gap: 3px; }
.mag-block-type .mdi { font-size: 13px; } .mag-block-type .mdi { font-size: 13px; }
.mag-block-type-select { font-size: 11px; text-transform: none; letter-spacing: 0; padding: 2px 4px; .mag-region-type-picker { flex: 0 1 10.5em; width: 10.5em; text-transform: none; letter-spacing: 0; }
max-width: 9.5em; min-height: 28px; } .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 { 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-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; .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-swatch { width: 10px; height: 10px; border-radius: 2px; background: var(--c); }
.mag-preview-legend-count { margin-left: auto; color: #8a8f98; } .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; } .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-caption { --c: #14b8a6; } .mpt-pullquote { --c: #ec4899; } .mpt-sidebar { --c: #f59e0b; } .mpt-ad { --c: #ef4444; }
.mpt-furniture, .mpt-page_number { --c: #9ca3af; } .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. */ /* 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-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-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-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({ 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 } }, 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({ await page.route(`**/api/magazine/${DOC_ID}/articles`, route => {
json: { articles: [article], progress, default_voice: '', podcast: { hosts: [] } }, 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}/layout`, route => route.fulfill({ json: LAYOUT }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => { await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => {
if (route.request().method() === 'PATCH') { 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); 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 }) => { 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 mockBackend(page, { articleStatus: 'approved', hasAudio: true, progress: { 'article-001': { seconds: 0.5 } } });
await page.goto('/', { waitUntil: 'domcontentloaded' }); 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 expect(page.locator('#mag-rev-title')).toHaveValue('The Future of Batteries');
await page.getByRole('button', { name: 'Approve' }).click(); 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 }) => { 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 checkbox = page.locator('#mag-review input[data-block="b2"]');
const before = await checkbox.isChecked(); const before = await checkbox.isChecked();
const picker = row.getByRole('combobox', { name: /Type of region/ }); const picker = row.getByRole('combobox', { name: /Type of region/ });
await expect(picker).toHaveValue('byline'); await expect(picker).toHaveValue('Author');
await picker.click(); await picker.click();
await expect(picker).toHaveValue('');
await expect(checkbox).toBeChecked({ checked: before }); // opening the picker did not toggle the row 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'); 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 mockBackend(page);
await openReview(page); await openReview(page);
await expect(page.locator('[data-block-row="b3"] select.mag-block-type-select')).toBeVisible(); const row = page.locator('[data-block-row="b3"]');
const values = await page.locator('[data-block-row="b3"] select.mag-block-type-select option').evaluateAll( await row.getByRole('combobox', { name: /Type of region/ }).click();
options => options.map(o => o.value)); const values = await row.locator('.mag-type-picker-option').evaluateAll(
expect(values).toEqual(['body', 'title', 'heading', 'deck', 'byline', 'caption', 'pullquote', 'sidebar', options => options.map(o => o.dataset.value));
'page_number', 'source', 'ad', 'in_picture', 'ocr_noise', 'duplicate', 'table', 'image']); 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 }) => { 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, 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) } })); blocks: LAYOUT.blocks.map(b => b.id === 'b3' ? { ...b, type: 'furniture', region_label: 'in_picture' } : b) } }));
await openReview(page); 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 }) => { 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 openReview(page);
await expect(page.locator('#mag-review input[data-block="b3"]')).toBeChecked(); 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(); 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')); const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click(); 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, portrait)
self.assertIn(anchor, reference) 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): def test_visual_pair_metadata_persists_shared_prompt_version_and_identity(self):
sheet = {"name": "Mara Venn", "gender": "female", "physical": "copper hair and green eyes"} sheet = {"name": "Mara Venn", "gender": "female", "physical": "copper hair and green eyes"}
profile = "data:image/png;base64,AA==" profile = "data:image/png;base64,AA=="

View File

@ -74,6 +74,8 @@ from core.magazine import (
describe_visual_region_chain, describe_visual_region_chain,
BLOCK_FIGURE_DESCRIPTION, BLOCK_FIGURE_DESCRIPTION,
interview_tts_segments, interview_tts_segments,
merge_missing_text_candidates,
verify_ocr_recognition,
) )
PAGE_W, PAGE_H = 595.0, 842.0 # A4 in points 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): 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): 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, " 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.") "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() unrelated = ("Andere Spalte mit vollständig anderem Inhalt und ganz anderen Worten. " * 4).strip()
self.assertEqual(prefer_drop_cap_repair(old, unrelated), old) 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): class ColumnGeometryTests(unittest.TestCase):
def test_two_columns_detected_from_the_gutter(self): 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") blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b")
self.assertEqual(blocks[0].type, BLOCK_CAPTION) 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): def test_semantic_groups_join_fragments_and_keep_continuation(self):
from core.magazine import apply_semantic_regions from core.magazine import apply_semantic_regions
blocks = [Block('a', 1, [10,10,100,20], 'body', 'A para-').to_json(), 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.assertTrue(groups[0]['continues_from_previous_page'])
self.assertEqual(groups[1]['type'], 'body') 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): def test_semantic_paragraph_continuing_across_columns_keeps_two_boxes(self):
"""A logical paragraph can end in the left column and continue in """A logical paragraph can end in the left column and continue in
the right. Its bbox must not become the union covering both columns 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], self.assertEqual([b['text'] for b in grouped],
['Left one left two', 'Right one right two']) ['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): def test_semantic_group_cannot_absorb_editorial_into_advert(self):
from core.magazine import apply_semantic_regions from core.magazine import apply_semantic_regions
blocks = [Block('a', 1, [0,0,100,20], 'body', 'Editorial').to_json(), blocks = [Block('a', 1, [0,0,100,20], 'body', 'Editorial').to_json(),
@ -1217,6 +1341,17 @@ class ContinuationMarkerStrippingTests(unittest.TestCase):
class ArticleTextTests(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): def test_continuation_marker_is_stripped_from_narrated_output(self):
"""article_text() feeds TTS directly, so the print cue must not """article_text() feeds TTS directly, so the print cue must not
survive into what gets spoken -- even though it stays in layout.json 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) 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): class WholePageVisionFallbackTests(unittest.TestCase):
"""KDL Frontier Parser nano, confirmed live, answers with plain text and """KDL Frontier Parser nano, confirmed live, answers with plain text and
no coordinate markers at all -- unlike ocr-baidu-unlimited-ocr's labeled 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(moved["text"], "Existing title")
self.assertEqual(text, "") 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): def test_moving_an_image_recrops_its_file(self):
from PIL import Image from PIL import Image
out, text = update_region(self.doc_dir, self.layout, 3, "img0003-000", [0, 0, 60, 40]) 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) self.assertEqual(moved, 1)
want = self._pt(self.FIGURE_PX) want = self._pt(self.FIGURE_PX)
for got, expected in zip(block.bbox, want): 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): def test_snap_does_not_swallow_the_text_column(self):
block = self._figure_block((200, 60, 600, 250)) block = self._figure_block((200, 60, 600, 250))
snap_visual_bboxes(self.png, [block, self._text_block()], self.meta) snap_visual_bboxes(self.png, [block, self._text_block()], self.meta)
self.assertGreater(block.bbox[0], self._pt(self.TEXT_PX)[0] + 20) 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): def test_snapping_is_idempotent(self):
block = self._figure_block((200, 60, 600, 250)) block = self._figure_block((200, 60, 600, 250))
blocks = [block, self._text_block()] blocks = [block, self._text_block()]
@ -2200,3 +2454,66 @@ class SnapVisualBboxesStabilityTests(unittest.TestCase):
self.assertAlmostEqual(a, b, delta=8.0) self.assertAlmostEqual(a, b, delta=8.0)
# And never merge into one box spanning both panels. # And never merge into one box spanning both panels.
self.assertLess(left.bbox[0] + left.bbox[2], right.bbox[0] + 1.0) 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.assertEqual(done[-1]['result'], {'moved': 0})
self.assertNotIn('error', done[-1]) 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): def test_a_document_with_no_layout_reports_failure(self):
from unittest.mock import patch from unittest.mock import patch
from routes.magazine import _refine_figures_sync from routes.magazine import _refine_figures_sync
@ -248,6 +263,63 @@ class RefineFiguresJobTests(unittest.TestCase):
self.assertIn('No layout', done[-1]['error']) 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): class IngestPreviewHtmlTests(unittest.TestCase):
"""The live ingest preview: a fast, best-effort HTML render of one """The live ingest preview: a fast, best-effort HTML render of one
page's just-read blocks, well before classify_blocks or article 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)) out = asyncio.run(magazine_ingest_status('a' * 32))
self.assertEqual(out["ingest"]["status"], "running") self.assertEqual(out["ingest"]["status"], "running")
write.assert_not_called() 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, normalize_ocr_text,
prepare_text_for_tts, prepare_text_for_tts,
remove_linebreak_hyphens, remove_linebreak_hyphens,
suspect_ocr_tokens,
) )
@ -27,6 +28,12 @@ class PronunciationTests(unittest.TestCase):
self.assertIn("Programmierschnittstellen", spoken) self.assertIn("Programmierschnittstellen", spoken)
self.assertIn("natürlich", 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): def test_english_acronyms_use_english_expansions(self):
self.assertEqual(expand_abbreviations("AI uses an API.", "en"), self.assertEqual(expand_abbreviations("AI uses an API.", "en"),
"artificial intelligence uses an application programming interface.") "artificial intelligence uses an application programming interface.")
@ -35,6 +42,18 @@ class PronunciationTests(unittest.TestCase):
text = "OpenAI und PyMuPDF bleiben unverändert." text = "OpenAI und PyMuPDF bleiben unverändert."
self.assertEqual(normalize_ocr_text(text, "de"), text) 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__": if __name__ == "__main__":
unittest.main() unittest.main()