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

Page count unknown yet.

'; return; } const byPage = new Map(); @@ -812,8 +908,9 @@ function renderPagesView(host) { byPage.get(p).push(a); })); const legend = [ - ['blank', 'not reviewed yet'], ['detected', 'has content, not reviewed'], - ['approved', 'reviewed'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'], + ['blank', 'not reviewed yet'], ['detected', 'has content, not quality-checked'], + ['checked', 'quality check clean'], ['flagged', 'quality check found problems'], + ['approved', 'reviewed'], ['ad', 'approved as advertisement (not read)'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'], ]; let html = '

' + legend.map(([cls, label]) => '' + label + '').join('') + @@ -821,21 +918,24 @@ function renderPagesView(host) { html += '

'; for (let p = 1; p <= pageCount; p++) { const arts = byPage.get(p) || []; - html += '
' + + html += '
' + 'Page ' + p + '' + - 'Page ' + p + '' + + 'Page ' + p + '' + magQualityBadge(quality.get(p), p) + (arts.length ? '
' + arts.map((a) => '
' + '' + esc(a.title || 'Untitled') + '' + '' + - '' + + '' + (a.has_audio ? '' : '') + '' + '
').join('') + '
' : 'no article' + '
') + + '' + '
'; } html += '
'; @@ -1017,6 +1117,7 @@ async function magGenerateAllAudio(force = false) { return say('Could not check audio export: ' + e.message, 'err'); } if (status) status.textContent = job.detail || 'Working…'; + magMarkLive(job.done ? null : job, false); // The job's own detail text always leads with "N/Total" -- either // "Article N/Total…" between articles, or "(N/Total) Title: sentence // X/Y" while one is actively rendering. "Article 6 of 44" means little @@ -1073,6 +1174,12 @@ async function magReview(articleId) { renderReview(); } +async function magReviewAtPage(articleId, page) { + await magReview(articleId); + if (state.article?.id !== articleId) return; + magReviewGoMagazinePage(Number(page)); +} + async function ensureLayout() { if (state.layout) return; try { @@ -1106,9 +1213,15 @@ function magReviewGoPage(page) { renderReview(); } -function magReviewGoMagazinePage(page) { +async function magReviewGoMagazinePage(page) { const lastPage = Number(state.layout?.page_count || 0); if (!Number.isInteger(page) || page < 1 || page > lastPage) return; + const currentCoversPage = (state.article?.pages || []).includes(page); + if (!currentCoversPage) { + const owner = state.articles.find((article) => (article.pages || []).includes(page)); + if (owner) return magReviewAtPage(owner.id, page); + return magReviewPage(page); + } state.reviewPage = page; renderReview(); } @@ -1123,12 +1236,13 @@ function magReviewGoMagazinePage(page) { // (see magReviewSubmitDraw); everything else (page number, source, ad, // table, duplicate, OCR noise, caption, pull quote, sidenote) is furniture // or reference material excluded from narration on purpose and stays unticked. -const MAG_NARRATABLE_KINDS = new Set(['title', 'heading', 'deck', 'byline', 'body']); +const MAG_NARRATABLE_KINDS = new Set(['title', 'heading', 'deck', 'byline', 'body', 'quote']); const MAG_REGION_TYPES = [ - ['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], - ['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'], - ['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'], - ['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['table', 'Table'], ['image', 'Image'], + ['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Intro / Standfirst'], + ['byline', 'Author'], ['image', 'Image'], ['caption', 'Caption'], ['in_picture', 'Text in Picture'], + ['table', 'Table'], ['quote', 'Quote'], ['pullquote', 'Pullquote'], ['sidebar', 'Sidenote'], + ['ad', 'Advertisement'], ['page_number', 'Page Number'], ['source', 'Footer'], + ['duplicate', 'Duplicate'], ['ocr_noise', 'OCR Noise'], ]; // A compact subset of MAG_REGION_TYPES for the review page's colour legend // -- the full 16-entry list would be noisy here, and several of those kinds @@ -1136,8 +1250,8 @@ const MAG_REGION_TYPES = [ // "furniture" on the page itself, so listing them separately would promise // a colour distinction the boxes don't actually make. const MAG_LEGEND_TYPES = [ - ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], ['byline', 'Author'], - ['body', 'Paragraph'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'], + ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Intro / Standfirst'], ['byline', 'Author'], + ['body', 'Paragraph'], ['quote', 'Quote'], ['caption', 'Caption'], ['pullquote', 'Pullquote'], ['sidebar', 'Sidenote'], ['ad', 'Advertisement'], ['furniture', 'Page number / footer'], ['image', 'Image'], ['chart', 'Chart'], ['table', 'Table'], ['code', 'Code'], ]; @@ -1152,6 +1266,7 @@ function magReviewLegendHtml() { const MAG_REGION_TYPE_ICONS = { body: 'mdi-text-box-outline', title: 'mdi-format-title', heading: 'mdi-format-header-2', deck: 'mdi-text-short', byline: 'mdi-account-edit-outline', caption: 'mdi-image-text', + quote: 'mdi-format-quote-open', pullquote: 'mdi-format-quote-close', sidebar: 'mdi-note-text-outline', page_number: 'mdi-numeric', source: 'mdi-book-open-page-variant-outline', ad: 'mdi-advertisements-outline', in_picture: 'mdi-text-recognition', ocr_noise: 'mdi-alert-circle-outline', @@ -1173,16 +1288,93 @@ function magRegionTypeValue(region) { function magRegionControls(region, number) { const current = magRegionTypeValue(region); const known = MAG_REGION_TYPES.some(([v]) => v === current); - const options = (known ? [] : [[current, current]]).concat(MAG_REGION_TYPES) - .map(([v, label]) => '').join(''); - return '' + + const options = (known ? MAG_REGION_TYPES : [[current, current]].concat(MAG_REGION_TYPES)); + const label = (options.find(([value]) => value === current) || [current, current])[1]; + return '
' + + '' + + '' + + '' + + '
' + ''; } +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]) => + '
  • ' + + ' ' + + esc(label) + '
  • ').join('') + : '
  • No matching region type
  • '; + list.hidden = false; + input.setAttribute('aria-expanded', 'true'); +} + +// The selected label is useful while scanning rows, but it is not a search +// query. Clear it as soon as the field receives focus so typing can start +// immediately without first deleting “Paragraph”. +function magRegionPickerFocus(input) { + input.value = ''; + _magRenderRegionPicker(input); +} + +function magRegionPickerInput(input) { _magRenderRegionPicker(input); } + +function magRegionPickerBlur(input) { + setTimeout(() => { + const picker = input.closest('.mag-region-type-picker'); + if (!picker) return; + const list = picker.querySelector('.mag-type-picker-list'); + if (list) list.hidden = true; + input.value = _magRegionPickerLabel(picker.dataset.current); + input.setAttribute('aria-expanded', 'false'); + }, 0); +} + +function magRegionPickerKeydown(event, input) { + const picker = input.closest('.mag-region-type-picker'); + const list = picker && picker.querySelector('.mag-type-picker-list'); + if (event.key === 'Enter' && list) { + const first = list.querySelector('.mag-type-picker-option'); + if (first) { event.preventDefault(); magRegionPickerChoose(first, event); } + } else if (event.key === 'Escape') { + if (list) list.hidden = true; + input.value = _magRegionPickerLabel(picker.dataset.current); + input.setAttribute('aria-expanded', 'false'); + input.blur(); + } +} + +function magRegionPickerChoose(option, event) { + event.preventDefault(); + event.stopPropagation(); + const picker = option.closest('.mag-region-type-picker'); + const input = picker.querySelector('.mag-block-type-search'); + const list = picker.querySelector('.mag-type-picker-list'); + const value = option.dataset.value; + input.value = _magRegionPickerLabel(value); + list.hidden = true; + input.setAttribute('aria-expanded', 'false'); + if (value !== picker.dataset.current) magReviewChangeType(picker.dataset.regionId, value, input); +} + async function magReviewChangeType(regionId, type, select) { const toImage = type === 'image'; const fromImage = !!(state.layout?.images || []).some((img) => img.id === regionId); @@ -1206,6 +1398,7 @@ async function magReviewChangeType(regionId, type, select) { function magBlockIcon(type) { if (type === 'image' || type === 'figure') return 'mdi-image-outline'; + if (type === 'quote') return 'mdi-format-quote-open'; if (type === 'pullquote') return 'mdi-format-quote-close'; if (type === 'chart') return 'mdi-chart-bar'; if (type === 'table') return 'mdi-table'; @@ -1342,7 +1535,7 @@ function magReviewPageColumn(page, wrapId, interactive) { return; } const b = r.data; - const narratable = ['title', 'heading', 'deck', 'byline', 'body'].includes(b.type); + const narratable = MAG_NARRATABLE_KINDS.has(b.type); const on = included.has(b.id); listHtml += '
    '; @@ -1512,7 +1716,7 @@ function renderReview() { '' + '' + '' + '
    '; host.innerHTML = html; @@ -2558,7 +2762,7 @@ function _magSetupDrawKindPicker(options) { // Start on the default (Paragraph) with its label showing, matching the // hidden input's own default value set in the HTML above. pick(hidden.value, (options.find(([v]) => v === hidden.value) || [])[1] || ''); - search.addEventListener('focus', open); + search.addEventListener('focus', () => { search.value = ''; open(); }); search.addEventListener('input', () => { list.hidden = false; search.setAttribute('aria-expanded', 'true'); renderList(search.value); }); search.addEventListener('keydown', (e) => { if (e.key === 'Escape') { list.hidden = true; search.setAttribute('aria-expanded', 'false'); search.blur(); } @@ -2748,18 +2952,62 @@ async function magReocrPage() { renderReview(); } -// Re-derives every figure box in the issue from the page renders. Separate -// from "OCR again" because no OCR engine is involved: it only corrects where -// the figures are, never what the page says, so it is safe to run on an issue -// that is half reviewed already. -async function magRefineFigures() { - const btn = $('mag-refine-figures-btn'); +async function magRunPageQuality(mode) { + magOfferUndo(null); + const page = state.reviewPage; + if (page == null) return; + const isMissing = mode === 'missing'; + const btn = $(isMissing ? 'mag-find-missing-btn' : 'mag-verify-text-btn'); const statusEl = $('mag-reocr-status'); if (btn) btn.disabled = true; - if (statusEl) statusEl.textContent = 'Checking figure boundaries…'; + if (statusEl) statusEl.textContent = isMissing + ? 'Checking page ' + page + ' for missed text…' + : 'Verifying suspicious words on page ' + page + '…'; let jobId; try { - const r = await api('/api/magazine/' + state.doc.id + '/figures/refine', { method: 'POST' }); + const result = await api('/api/magazine/' + state.doc.id + '/pages/' + page + + '/quality/' + mode, { method: 'POST' }); + jobId = result.job_id; + } catch (e) { + if (btn) btn.disabled = false; + if (statusEl) statusEl.textContent = ''; + return say('Could not start quality check: ' + e.message, 'err'); + } + let failed = false; + for (;;) { + let job; + try { job = await api('/api/jobs/' + jobId); } catch (_) { break; } + if (job.done) { + if (job.error) { failed = true; say('Quality check failed: ' + job.error, 'err'); } + else say(job.detail || 'Page quality checked', 'ok'); + break; + } + if (statusEl && job.detail) statusEl.textContent = job.detail; + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + if (btn) btn.disabled = false; + if (statusEl) statusEl.textContent = ''; + if (failed) return; + state.layout = null; + await ensureLayout(); + _magReviewChecked = null; + renderReview(); +} + +// Re-derives figure boxes from the page renders. Review uses the active page; +// the contents toolbar exposes the intentionally separate whole-issue batch. +// No OCR engine is involved: this only corrects where figures are. +async function magRunFigureRefine(page) { + const wholeIssue = page == null; + const btn = $(wholeIssue ? 'mag-refine-all-figures-btn' : 'mag-refine-figures-btn'); + const statusEl = $('mag-reocr-status'); + if (btn) btn.disabled = true; + if (statusEl) statusEl.textContent = wholeIssue + ? 'Checking every page…' : 'Checking figure boundaries on page ' + page + '…'; + let jobId; + try { + const endpoint = wholeIssue ? '/figures/refine' : '/pages/' + page + '/figures/refine'; + const r = await api('/api/magazine/' + state.doc.id + endpoint, { method: 'POST' }); jobId = r.job_id; } catch (e) { if (btn) btn.disabled = false; @@ -2775,16 +3023,113 @@ async function magRefineFigures() { else say(job.detail || 'Figure boundaries checked', 'ok'); break; } + magMarkLive(job, wholeIssue); if (statusEl && job.detail) statusEl.textContent = job.detail; await new Promise((r) => setTimeout(r, 2000)); } + magMarkLive(null); if (btn) btn.disabled = false; if (statusEl) statusEl.textContent = ''; if (failed) return; state.layout = null; await ensureLayout(); _magReviewChecked = null; - renderReview(); + if (state.view === 'review') renderReview(); + else renderContents(); +} + +// Whole-issue completeness run (server: _quality_run_sync). Adds text no box covers, +// re-reads suspicious OCR, then lists the pages whose render still has uncovered ink or +// suspicious words so the reviewer can jump straight to them. +function magRenderQualityReport(report) { + const box = $('mag-quality-report'); + if (!box) return; + const flagged = (report.pages || []).filter((p) => p.needs_review); + if (!flagged.length) { + box.hidden = false; + box.textContent = 'Quality check: every page is fully covered and no suspicious words remain.'; + return; + } + box.hidden = false; + box.innerHTML = '' + flagged.length + ' page' + (flagged.length === 1 ? '' : 's') + + ' still need a look: ' + 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 ''; + }).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 = '
    ' + esc(text) + '
    ' + + (pct == null ? '' + : ''); + } + if (label) label.textContent = pct == null ? 'Checking…' : 'Checking… ' + pct + '%'; + }; + if (btn) btn.disabled = true; + showProgress('Starting quality check…', null); + let jobId; + try { + jobId = (await api('/api/magazine/' + state.doc.id + '/quality/run', { method: 'POST' })).job_id; + } catch (e) { + if (btn) btn.disabled = false; + if (label) label.textContent = 'Quality check / run'; + if (box) box.hidden = true; + return say('Could not start the quality check: ' + e.message, 'err'); + } + let failed = false; + for (;;) { + let job; + try { job = await api('/api/jobs/' + jobId); } catch (_) { break; } + if (job.done) { + if (job.error) { failed = true; say('Quality check failed: ' + job.error, 'err'); } + else say(job.detail || 'Quality check finished', 'ok'); + break; + } + const prog = magParsePageProgress(job.detail); + showProgress(job.detail || 'Working…', prog ? prog.pct : null); + magMarkLive(job, true); + await new Promise((r) => setTimeout(r, 1500)); + } + if (btn) btn.disabled = false; + if (label) label.textContent = 'Quality check / run'; + magMarkLive(null); + if (failed) { if (box) box.textContent = 'Quality check failed — see the message above.'; return; } + state.layout = null; + await ensureLayout(); + if (state.view !== 'review') renderContents(); + try { magRenderQualityReport(await api('/api/magazine/' + state.doc.id + '/quality/report')); } catch (_) { /* no report */ } + await magLoadQuality(true); +} + +async function magRefineFigures() { + if (!state.reviewPage) return; + return magRunFigureRefine(state.reviewPage); +} + +async function magRefineAllFigures() { + const ok = await confirmDialog( + 'Check and re-crop figure boundaries on every page in this magazine?', + { title: 'Fix all figure boundaries?', okLabel: 'Fix all pages' }); + if (!ok) return; + return magRunFigureRefine(null); } async function magApprove() { @@ -2807,21 +3152,15 @@ async function magApprove() { }); Object.assign(a, updated); say('Approved — ready to listen', 'ok'); - // Approving one article at a time is a queue, not a one-off — staying on - // the list meant re-finding and re-opening whichever article came next - // after every single approval. Go straight to it instead, and only fall - // back to the list once nothing is left to review. - // Next PAGE needing attention, not next article -- an article can span - // many pages or share one with others, and jumping by article order - // alone is exactly what left pages uncovered by any article invisible - // to this whole workflow (see magUncoveredPageRanges). Walking forward - // page by page and reusing magReviewPage's "create a stub if nothing - // covers it" logic means a gap gets exactly the same one-click - // attention as an already-detected-but-unreviewed article. - const lastPage = Math.max(0, ...(a.pages || [])); + // Review is a physical page-by-page workflow. An article may span many + // pages, so advancing from its last claimed page skips pages the user has + // not inspected yet. Always advance from the page currently on screen; + // magReviewPage creates a page-scoped placeholder if nothing covers it. + const nextPage = Number(state.reviewPage || 0) + 1; await magRefreshArticles(); - const nextPage = magNextUnresolvedPage(lastPage); - if (nextPage) magReviewPage(nextPage); + if (nextPage <= Number(state.doc?.pageCount || state.layout?.page_count || 0)) { + await magReviewPage(nextPage); + } else magShow('contents'); } catch (e) { say('Could not approve: ' + e.message, 'err'); @@ -2855,10 +3194,14 @@ function splitSentences(blocks) { const parts = String(b.text || '') .split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/) .map((s) => s.trim()).filter(Boolean); - (parts.length ? parts : [b.text]).forEach((text) => { + const spokenParts = String(b.spoken_text || b.text || '') + .split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/) + .map((s) => s.trim()).filter(Boolean); + (parts.length ? parts : [b.text]).forEach((text, partIndex) => { if (text && text.trim()) { out.push({ text: text.trim(), blockId: b.id, page: b.page, bbox: b.bbox, type: b.type, + spokenText: spokenParts[partIndex] || text.trim(), // Used to pick "the image reached so far" during playback (see // currentImageIndex) -- the backend already computes this per // block relative to the whole page, not just this article. @@ -3136,7 +3479,7 @@ async function magPrepareAudio() { const worker = async () => { while (!failure && !state.prepCancel && gen === state.prepGen && next < order.length) { const i = order[next++]; - try { await magSynth(voice, state.sentences[i].text); } + try { await magSynth(voice, state.sentences[i].spokenText || state.sentences[i].text); } catch (e) { failure = { i, message: e.message }; return; } done += 1; if (gen === state.prepGen) syncPrepButton(done, total); @@ -3171,13 +3514,13 @@ async function magPlay() { const sentence = state.sentences[state.idx]; let blob; try { - const pending = magSynth(voice, sentence.text); + const pending = magSynth(voice, sentence.spokenText || sentence.text); // Without a full "Prepare audio" run, still stay a few sentences ahead // so the wait is only ever for the first one. if (!state.preparing) { for (let k = 1; k <= MAG_LOOKAHEAD; k++) { const upcoming = state.sentences[state.idx + k]; - if (upcoming) magSynth(voice, upcoming.text).catch(() => {}); + if (upcoming) magSynth(voice, upcoming.spokenText || upcoming.text).catch(() => {}); } } blob = await pending; @@ -3435,10 +3778,7 @@ function magPlaylistSync() { } renderPlaylistVisual(index); const pct = duration ? Math.min(100, audio.currentTime / duration * 100) : 0; - const drivingBar = $('mag-driving-progress-bar'); - const drivingTime = $('mag-driving-time'); - if (drivingBar) drivingBar.style.width = pct + '%'; - if (drivingTime) drivingTime.textContent = magFmtClock(audio.currentTime) + ' / ' + magFmtClock(duration); + if (state.driving) magDrivingSync(audio, duration, pct, index); } async function loadPlaylistCompanion(a) { @@ -3495,7 +3835,7 @@ function magPlaylistPlay(articleId, options) { } renderPlaylistTracks(); loadPlaylistCompanion(a); - if (state.driving) renderDriving(); + if (state.driving) { renderDriving(); magDrivingApplyToggles(); magApplyPlaybackSpeed(); } } function _magPlaylistNext(eventOrCompleted) { @@ -3799,6 +4139,116 @@ function magDrivingTogglePlayback() { magPlay(); } +// ── Driving mode extras: +/-15 s, speed, text and image toggles, seek ──────── +const MAG_DRIVING_SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2]; +const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs'; + +function magDrivingPrefs() { + if (!state.drivingPrefs) { + let saved = {}; + try { saved = JSON.parse(localStorage.getItem(MAG_DRIVING_PREFS_KEY) || '{}') || {}; } catch (_) {} + state.drivingPrefs = { + speed: MAG_DRIVING_SPEEDS.includes(saved.speed) ? saved.speed : 1, + text: saved.text !== false, images: saved.images !== false, + }; + } + return state.drivingPrefs; +} + +function magDrivingSavePrefs() { + try { localStorage.setItem(MAG_DRIVING_PREFS_KEY, JSON.stringify(magDrivingPrefs())); } catch (_) {} +} + +// Speed lives on the audio element; a new article's src resets playbackRate to the element's +// default, so both are set and re-applied whenever a track loads. +function magApplyPlaybackSpeed() { + const audio = $('mag-playlist-audio'); + const speed = magDrivingPrefs().speed; + if (audio) { audio.defaultPlaybackRate = speed; audio.playbackRate = speed; } + const btn = $('mag-driving-speed'); + if (btn) { + btn.textContent = speed + '×'; + btn.setAttribute('aria-label', 'Playback speed ' + speed + '×. Press to change'); + } +} + +function magDrivingCycleSpeed() { + const prefs = magDrivingPrefs(); + prefs.speed = MAG_DRIVING_SPEEDS[(MAG_DRIVING_SPEEDS.indexOf(prefs.speed) + 1) % MAG_DRIVING_SPEEDS.length]; + magDrivingSavePrefs(); + magApplyPlaybackSpeed(); +} + +function magDrivingSkip(seconds) { + const audio = $('mag-playlist-audio'); + if (!audio || !state.playlistCurrent) return; + const end = Number.isFinite(audio.duration) ? audio.duration : Infinity; + audio.currentTime = Math.max(0, Math.min(end, audio.currentTime + seconds)); +} + +function magDrivingSeek(value) { + const audio = $('mag-playlist-audio'); + if (!audio || !Number.isFinite(audio.duration)) return; + audio.currentTime = audio.duration * (Number(value) / 1000); +} + +function magDrivingToggleText() { + const prefs = magDrivingPrefs(); + prefs.text = !prefs.text; + magDrivingSavePrefs(); + magDrivingApplyToggles(); + magPlaylistSync(); +} + +function magDrivingToggleImages() { + const prefs = magDrivingPrefs(); + prefs.images = !prefs.images; + magDrivingSavePrefs(); + magDrivingApplyToggles(); + magPlaylistSync(); +} + +function magDrivingApplyToggles() { + const prefs = magDrivingPrefs(); + const text = $('mag-driving-text-toggle'), images = $('mag-driving-image-toggle'); + if (text) text.setAttribute('aria-pressed', String(prefs.text)); + if (images) images.setAttribute('aria-pressed', String(prefs.images)); + const lyrics = $('mag-driving-lyrics'), visual = $('mag-driving-visual'); + if (lyrics && !prefs.text) lyrics.hidden = true; + if (visual && !prefs.images) visual.hidden = true; +} + +// Called from magPlaylistSync on every timeupdate while the overlay is open. +function magDrivingSync(audio, duration, pct, sentenceIndex) { + const prefs = magDrivingPrefs(); + const seek = $('mag-driving-seek'); + if (seek && document.activeElement !== seek) seek.value = String(Math.round(pct * 10)); + const time = $('mag-driving-time'); + if (time) time.textContent = magFmtClock(audio.currentTime) + ' / ' + magFmtClock(duration); + const sentences = state.playlistSentences || []; + const lyrics = $('mag-driving-lyrics'); + if (lyrics) { + lyrics.hidden = !prefs.text || !sentences.length; + if (!lyrics.hidden && lyrics.dataset.index !== String(sentenceIndex)) { + lyrics.dataset.index = String(sentenceIndex); + const set = (id, i) => { const el = $(id); if (el) el.textContent = sentences[i]?.text || ''; }; + set('mag-driving-line-prev', sentenceIndex - 1); + set('mag-driving-line-current', sentenceIndex); + set('mag-driving-line-next', sentenceIndex + 1); + } + } + const visual = $('mag-driving-visual'); + const source = $('mag-playlist-visual'); + if (visual) { + const show = prefs.images && source && !source.hidden && source.dataset.imageId; + visual.hidden = !show; + if (show && visual.dataset.imageId !== source.dataset.imageId) { + visual.dataset.imageId = source.dataset.imageId; + visual.innerHTML = source.innerHTML; + } + } +} + function magDrivingNext() { if (state.playlistCurrent) return _magPlaylistNext(false); magQueueNext(); @@ -3824,6 +4274,7 @@ function wirePlaylistAudio() { renderPlaylistTracks(); magPlaylistSync(); }); + audio.addEventListener('loadedmetadata', magApplyPlaybackSpeed); audio.addEventListener('play', syncDrivingPlayButton); audio.addEventListener('pause', () => { if (!state.playlistSwitching) savePlaylistProgress(false, true); @@ -3898,6 +4349,7 @@ root.magContentsClearSelection = magContentsClearSelection; root.magContentsBulkStatus = magContentsBulkStatus; root.magReviewGoPage = magReviewGoPage; root.magReviewGoMagazinePage = magReviewGoMagazinePage; +root.magReviewAtPage = magReviewAtPage; root.magReviewToggleSpread = magReviewToggleSpread; root.magReviewSetActivePage = magReviewSetActivePage; root.magReviewFocusBlock = magReviewFocusBlock; @@ -3908,6 +4360,9 @@ root.magReviewClearCurrentPage = magReviewClearCurrentPage; root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes; root.magReocrPage = magReocrPage; root.magRefineFigures = magRefineFigures; +root.magRefineAllFigures = magRefineAllFigures; +root.magQualityRun = magQualityRun; +root.magToggleAdPage = magToggleAdPage; root.magReviewToggleImage = magReviewToggleImage; root.magDescribeImage = magDescribeImage; root.magSaveImageDescription = magSaveImageDescription; @@ -3958,6 +4413,11 @@ root.magToggleDriving = magToggleDriving; root.magOpenDriving = magOpenDriving; root.magDrivingTogglePlayback = magDrivingTogglePlayback; root.magDrivingNext = magDrivingNext; +root.magDrivingSkip = magDrivingSkip; +root.magDrivingSeek = magDrivingSeek; +root.magDrivingCycleSpeed = magDrivingCycleSpeed; +root.magDrivingToggleText = magDrivingToggleText; +root.magDrivingToggleImages = magDrivingToggleImages; root.magDrivingPrevious = magDrivingPrevious; root.magInit = magInit; MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages }; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index 1a4c000..f903281 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -64,6 +64,10 @@ onblur="magCommitDocField(this, 'issue')" onkeydown="if(event.key==='Enter'){this.blur();}">
    + @@ -92,8 +96,13 @@ +
    +
    @@ -276,18 +285,41 @@ + + +
    + +
    0:00 / 0:00
    +
    - + - +
    -
    0:00 / 0:00
    - +
    + + + +
    diff --git a/static/style.css b/static/style.css index 9b65c1a..409c6a4 100644 --- a/static/style.css +++ b/static/style.css @@ -4960,6 +4960,12 @@ input[list="lang-codes"] { .mag-card.is-page-approved { border-color: var(--yellow); background: color-mix(in srgb, var(--yellow) 8%, var(--surface)); } .mag-card.is-page-audio { border-color: var(--green); background: color-mix(in srgb, var(--green) 8%, var(--surface)); } .mag-card.is-page-empty { border-color: var(--subtext); background: color-mix(in srgb, var(--subtext) 8%, var(--surface)); } +.mag-card.is-page-checked { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); } +.mag-card.is-page-flagged { border-color: #9333EA; background: color-mix(in srgb, #9333EA 9%, var(--surface)); } +.mag-pill-flagged { color: #7E22CE; border-color: #9333EA; font-size: 12px; } +:root[data-theme="dark"] .mag-pill-flagged { color: #C084FC; } +.mag-pages-legend-swatch.is-page-checked { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 20%, var(--surface)); } +.mag-pages-legend-swatch.is-page-flagged { border-color: #9333EA; background: color-mix(in srgb, #9333EA 20%, var(--surface)); } .mag-pages-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--subtext); margin: 0 0 12px; } .mag-pages-legend-item { display: inline-flex; align-items: center; gap: 5px; } .mag-pages-legend-swatch { width: 12px; height: 12px; border-radius: 3px; border: 2px solid var(--border); } @@ -5148,7 +5154,7 @@ input[list="lang-codes"] { than added/removed) so toggling edit mode doesn't cost a re-render. */ .mag-review-page-wrap.is-editing { cursor: default; } .mag-review-page-wrap.is-editing .mag-review-box { cursor: move; } -.mag-review-box-delete { display: none; position: absolute; top: -9px; right: -9px; width: 18px; height: 18px; +.mag-review-box-delete { display: none; position: absolute; top: -27px; right: -9px; width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border); background: var(--red); color: #fff; font-size: 11px; line-height: 1; align-items: center; justify-content: center; padding: 0; cursor: pointer; z-index: 3; } .mag-review-page-wrap.is-editing .mag-review-box-delete { display: flex; } @@ -5185,8 +5191,10 @@ input[list="lang-codes"] { .mag-block-type { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext); display: flex; align-items: center; gap: 3px; } .mag-block-type .mdi { font-size: 13px; } -.mag-block-type-select { font-size: 11px; text-transform: none; letter-spacing: 0; padding: 2px 4px; - max-width: 9.5em; min-height: 28px; } +.mag-region-type-picker { flex: 0 1 10.5em; width: 10.5em; text-transform: none; letter-spacing: 0; } +.mag-region-type-picker .mag-type-picker-input { width: 100%; min-height: 28px; font-size: 11px; padding: 2px 5px; } +.mag-region-type-picker .mag-type-picker-list { right: auto; min-width: 210px; z-index: 8; text-transform: none; letter-spacing: 0; } +.mag-block-type-select { display: none; } .mag-block-delete { padding: 2px 6px; min-width: 28px; min-height: 28px; color: var(--subtext); } .mag-block-delete:hover, .mag-block-delete:focus-visible { color: var(--danger, #dc2626); } .mag-block-owner { display: inline-block; padding: 1px 6px; margin-right: 6px; border-radius: 999px; @@ -5380,7 +5388,7 @@ input[list="lang-codes"] { .mag-preview-swatch { width: 10px; height: 10px; border-radius: 2px; background: var(--c); } .mag-preview-legend-count { margin-left: auto; color: #8a8f98; } .mag-review-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 12px; color: var(--subtext); margin: 6px 0 10px; } -.mpt-title { --c: #a855f7; } .mpt-heading { --c: #d8b4fe; } .mpt-deck { --c: #06b6d4; } .mpt-byline { --c: #10b981; } .mpt-body { --c: #3b82f6; } +.mpt-title { --c: #a855f7; } .mpt-heading { --c: #d8b4fe; } .mpt-deck { --c: #06b6d4; } .mpt-byline { --c: #10b981; } .mpt-body { --c: #3b82f6; } .mpt-quote { --c: #8b5cf6; } .mpt-caption { --c: #14b8a6; } .mpt-pullquote { --c: #ec4899; } .mpt-sidebar { --c: #f59e0b; } .mpt-ad { --c: #ef4444; } .mpt-furniture, .mpt-page_number { --c: #9ca3af; } /* Visual regions: their own colours and a stronger fill, so a picture reads as an area, not a text line. */ @@ -5388,3 +5396,50 @@ input[list="lang-codes"] { .mag-preview-box.mpt-image, .mag-preview-box.mpt-figure, .mag-preview-box.mpt-chart, .mag-preview-box.mpt-table, .mag-preview-box.mpt-code { border-width: 3px; border-style: dashed; background: color-mix(in srgb, var(--c) 24%, transparent); } .mag-preview-legend-hint { color: #8a8f98; font-style: italic; } + +.mag-quality-report { margin: 8px 0; padding: 10px 14px; border: 1px solid var(--border, #c9c9c9); + border-radius: 8px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.mag-quality-report[hidden] { display: none; } +.mag-quality-report progress { flex: 1 1 100%; height: 10px; } + +/* Live quality-run indicator on the page grid */ +.mag-card .mag-live-tag { position: absolute; top: 8px; right: 8px; z-index: 2; padding: 2px 8px; + border-radius: 999px; font-size: 12px; font-weight: 600; background: var(--surface); + border: 1px solid var(--accent); color: var(--accent); } +.mag-card.is-page-working { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent); + animation: mag-working-pulse 1.2s ease-in-out infinite; } +.mag-card.is-page-live-done .mag-live-tag { border-color: var(--green); color: var(--green); } +@keyframes mag-working-pulse { 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 15%, transparent); } } +@media (prefers-reduced-motion: reduce) { .mag-card.is-page-working { animation: none; } } + +.mag-card .mag-thumb-flag { position: absolute; top: 8px; left: 8px; z-index: 2; display: inline-flex; gap: 4px; + align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; cursor: pointer; + background: #9333EA; color: #fff; border: 2px solid #fff; } +.mag-card .mag-thumb-flag:hover, .mag-card .mag-thumb-flag:focus-visible { background: #7E22CE; outline: 2px solid #9333EA; outline-offset: 2px; } +.mag-card.is-page-flagged { border-width: 2px; } + +.mag-card.is-page-ad { border-color: var(--green); border-style: dashed; border-width: 2px; + background: color-mix(in srgb, var(--green) 12%, var(--surface)); } +.mag-pages-legend-swatch.is-page-ad { border-color: var(--green); border-style: dashed; background: color-mix(in srgb, var(--green) 20%, var(--surface)); } +.mag-card .mag-ad-btn { margin-top: 4px; } + +/* Driving mode: Spotify-style transport, text + image toggles, speed */ +.mag-driving { overflow-y: auto; } +.mag-driving-body { width: min(760px, 94vw); padding: 24px 0; } +.mag-driving-visual { margin: 0 auto 16px; max-height: 32vh; display: flex; flex-direction: column; align-items: center; gap: 6px; } +.mag-driving-visual img { max-width: 100%; max-height: 28vh; object-fit: contain; border-radius: 12px; } +.mag-driving-visual figcaption { font-size: 14px; color: var(--subtext); } +.mag-driving-lyrics { margin: 8px auto 18px; max-width: 640px; } +.mag-driving-line { margin: 6px 0; font-size: clamp(16px, 2.4vw, 22px); line-height: 1.4; color: var(--subtext); opacity: .6; } +.mag-driving-line.is-current { font-size: clamp(22px, 3.6vw, 34px); font-weight: 700; color: var(--text); opacity: 1; } +.mag-driving-seek { margin-top: 12px; } +.mag-driving-seek input[type="range"] { width: min(680px, 88vw); height: 28px; accent-color: var(--accent); } +.mag-driving-seek .mag-driving-time { margin-top: 2px; } +.mag-driving-controls { gap: 20px; margin-top: 18px; } +.mag-driving-btn.is-small { width: 64px; height: 64px; font-size: 26px; } +.mag-driving-options { display: flex; justify-content: center; gap: 12px; margin-top: 22px; flex-wrap: wrap; } +.mag-driving-opt { min-width: 76px; min-height: 52px; padding: 0 18px; border-radius: 26px; font-size: 18px; font-weight: 700; + border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; } +.mag-driving-opt[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } +.mag-driving-opt:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } +@media (max-width: 860px) { .mag-driving-btn.is-small { width: 52px; height: 52px; font-size: 22px; } .mag-driving-controls { gap: 12px; } } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index 60643d8..8454524 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -58,9 +58,18 @@ async function mockBackend(page, { articleStatus = 'detected', hasAudio = false, await page.route(`**/api/reader/docs/${DOC_ID}`, route => route.fulfill({ json: { id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', pageCount: 8, ingest: { status: 'done', articles: 1 } }, })); - await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ - json: { articles: [article], progress, default_voice: '', podcast: { hosts: [] } }, - })); + await page.route(`**/api/magazine/${DOC_ID}/articles`, route => { + if (route.request().method() === 'POST') { + const requestedPage = route.request().postDataJSON().page; + return route.fulfill({ json: { article: { + id: 'article-page-' + requestedPage, title: 'Page ' + requestedPage, author: '', + pages: [requestedPage], blocks: [], images: [], status: 'detected', confidence: 0, section: '', + } } }); + } + return route.fulfill({ + json: { articles: [article], progress, default_voice: '', podcast: { hosts: [] } }, + }); + }); await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: LAYOUT })); await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => { if (route.request().method() === 'PATCH') { @@ -105,6 +114,85 @@ test.describe('Magazine reader', () => { await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(3); }); + test('thumbnail review opens the clicked page for multi-page articles', async ({ page }) => { + await mockBackend(page); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + await page.locator('#mag-library-list .mag-card').first().click(); + await page.locator('#mag-contents-view-toggle').click(); + + await page.locator('.mag-card-wrap').filter({ hasText: 'Page 8' }) + .getByRole('button', { name: 'Review', exact: true }).click(); + + await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 8'); + }); + + test('Approve & Next advances one physical page inside a multi-page article', async ({ page }) => { + await mockBackend(page); + const pageFour = { + id: 'article-page-4', title: 'Page 4', author: '', pages: [4], blocks: [], + images: [], status: 'detected', confidence: 0, section: '', + }; + await page.route(`**/api/magazine/${DOC_ID}/articles`, async route => { + if (route.request().method() === 'POST') return route.fulfill({ json: { article: pageFour } }); + return route.fallback(); + }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + await page.locator('#mag-library-list .mag-card').first().click(); + await page.getByRole('button', { name: 'Review', exact: true }).click(); + await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3'); + await page.getByRole('button', { name: 'Approve & Next', exact: true }).click(); + await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4'); + }); + + test('figure boundary repair in review only sends the active page', async ({ page }) => { + await mockBackend(page); + let requested = ''; + await page.route(`**/api/magazine/${DOC_ID}/pages/3/figures/refine`, route => { + requested = new URL(route.request().url()).pathname; + return route.fulfill({ json: { job_id: 'page-figures' } }); + }); + await page.route('**/api/jobs/page-figures', route => route.fulfill({ + json: { done: true, detail: 'Figure boundaries checked on page 3', result: { moved: 1 } }, + })); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + await page.locator('#mag-library-list .mag-card').first().click(); + await page.locator('#mag-contents-view-toggle').click(); + await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }) + .getByRole('button', { name: 'Review', exact: true }).click(); + await page.getByRole('button', { name: 'Fix figure boundaries (this page)', exact: true }).click(); + await expect.poll(() => requested).toContain('/pages/3/figures/refine'); + expect(requested).not.toBe(`/api/magazine/${DOC_ID}/figures/refine`); + }); + + test('contents view keeps whole-magazine figure repair as an explicit batch action', async ({ page }) => { + await mockBackend(page); + let called = false; + await page.route(`**/api/magazine/${DOC_ID}/figures/refine`, route => { + called = true; + return route.fulfill({ json: { job_id: 'all-figures' } }); + }); + await page.route('**/api/jobs/all-figures', route => route.fulfill({ + json: { done: true, detail: 'All figure boundaries checked', result: { moved: 2 } }, + })); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + await page.locator('#mag-library-list .mag-card').first().click(); + // The Material Design icon font can become part of the computed accessible + // name in Chromium, so use the action's stable control id here. + await page.locator('#mag-refine-all-figures-btn').click(); + const dialog = page.getByRole('alertdialog', { name: 'Fix all figure boundaries?' }); + expect(called).toBe(false); + await dialog.getByRole('button', { name: 'Fix all pages', exact: true }).click(); + await expect.poll(() => called).toBe(true); + }); + test('generated MP3 playlist shows status and synchronized article text', async ({ page }) => { await mockBackend(page, { articleStatus: 'approved', hasAudio: true, progress: { 'article-001': { seconds: 0.5 } } }); await page.goto('/', { waitUntil: 'domcontentloaded' }); @@ -165,7 +253,7 @@ test.describe('Magazine reader', () => { await expect(page.locator('#mag-rev-title')).toHaveValue('The Future of Batteries'); await page.getByRole('button', { name: 'Approve' }).click(); - await expect(page.locator('#mag-view-contents')).toBeVisible(); + await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4'); }); test('approved article reads, highlights sentences, and links to the PDF page', async ({ page }) => { @@ -397,21 +485,24 @@ test('a region type can be changed from the review list without toggling its che const checkbox = page.locator('#mag-review input[data-block="b2"]'); const before = await checkbox.isChecked(); const picker = row.getByRole('combobox', { name: /Type of region/ }); - await expect(picker).toHaveValue('byline'); + await expect(picker).toHaveValue('Author'); await picker.click(); + await expect(picker).toHaveValue(''); await expect(checkbox).toBeChecked({ checked: before }); // opening the picker did not toggle the row - await picker.selectOption('page_number'); + await row.getByRole('option', { name: 'Page Number', exact: true }).click(); await expect.poll(() => changedTo).toBe('page_number'); }); -test('every type the server knows is offered, image included', async ({ page }) => { +test('the icon picker offers every server type in editorial order', async ({ page }) => { await mockBackend(page); await openReview(page); - await expect(page.locator('[data-block-row="b3"] select.mag-block-type-select')).toBeVisible(); - const values = await page.locator('[data-block-row="b3"] select.mag-block-type-select option').evaluateAll( - options => options.map(o => o.value)); - expect(values).toEqual(['body', 'title', 'heading', 'deck', 'byline', 'caption', 'pullquote', 'sidebar', - 'page_number', 'source', 'ad', 'in_picture', 'ocr_noise', 'duplicate', 'table', 'image']); + const row = page.locator('[data-block-row="b3"]'); + await row.getByRole('combobox', { name: /Type of region/ }).click(); + const values = await row.locator('.mag-type-picker-option').evaluateAll( + options => options.map(o => o.dataset.value)); + expect(values).toEqual(['body', 'title', 'heading', 'deck', 'byline', 'image', 'caption', 'in_picture', + 'table', 'quote', 'pullquote', 'sidebar', 'ad', 'page_number', 'source', 'duplicate', 'ocr_noise']); + await expect(row.locator('.mag-type-picker-option .mdi')).toHaveCount(values.length); }); test('each review row has its own delete button, which deletes at once and offers undo', async ({ page }) => { @@ -533,7 +624,7 @@ test('a box marked as text in a picture shows that type in its picker', async ({ await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: { ...LAYOUT, blocks: LAYOUT.blocks.map(b => b.id === 'b3' ? { ...b, type: 'furniture', region_label: 'in_picture' } : b) } })); await openReview(page); - await expect(page.locator('[data-block-row="b3"] select.mag-block-type-select')).toHaveValue('in_picture'); + await expect(page.locator('[data-block-row="b3"] .mag-block-type-select')).toHaveValue('in_picture'); }); test('a region text can be corrected and split at the cursor from the list', async ({ page }) => { @@ -617,7 +708,9 @@ test('approving after turning a ticked text box into an image sends the new imag }); await openReview(page); await expect(page.locator('#mag-review input[data-block="b3"]')).toBeChecked(); - await page.locator('[data-block-row="b3"] select.mag-block-type-select').selectOption('image'); + const picker = page.locator('[data-block-row="b3"]').getByRole('combobox', { name: /Type of region/ }); + await picker.click(); + await page.locator('[data-block-row="b3"]').getByRole('option', { name: 'Image', exact: true }).click(); await expect(page.locator('[data-block-row="p0003-manual-000"]')).toBeVisible(); const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001')); await page.getByRole('button', { name: 'Approve & Next', exact: true }).click(); diff --git a/tests/test_conversation.py b/tests/test_conversation.py index 8178e24..6e2b539 100644 --- a/tests/test_conversation.py +++ b/tests/test_conversation.py @@ -47,6 +47,17 @@ class AttributionParserTests(unittest.TestCase): self.assertIn(anchor, portrait) self.assertIn(anchor, reference) + def test_visual_prompts_forbid_generated_writing_including_chinese_characters(self): + sheet = {"gender": "male", "physical": "short dark hair"} + portrait = _build_character_portrait_prompt("Marcian", "The Trial", sheet, {}) + reference = _build_character_sheet_prompt("Marcian", "The Trial", sheet, {}) + for prompt in (portrait, reference): + self.assertIn("Chinese characters", prompt) + self.assertIn("text of any kind", prompt) + + def test_visual_prompt_version_changes_when_render_constraints_change(self): + self.assertEqual(_CHARACTER_VISUAL_PROMPT_VERSION, "v4") + def test_visual_pair_metadata_persists_shared_prompt_version_and_identity(self): sheet = {"name": "Mara Venn", "gender": "female", "physical": "copper hair and green eyes"} profile = "data:image/png;base64,AA==" diff --git a/tests/test_magazine.py b/tests/test_magazine.py index b0c9923..412a3aa 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -74,6 +74,8 @@ from core.magazine import ( describe_visual_region_chain, BLOCK_FIGURE_DESCRIPTION, interview_tts_segments, + merge_missing_text_candidates, + verify_ocr_recognition, ) PAGE_W, PAGE_H = 595.0, 842.0 # A4 in points @@ -85,6 +87,36 @@ def block(bid, page, x, y, w, h, text, size=10.0, btype=BLOCK_BODY): class DropCapRepairTests(unittest.TestCase): + def test_repairs_split_ki_and_confirmed_short_drop_cap_words(self): + from core.magazine import repair_drop_cap_omissions + regions = [ + {"label": "paragraph", "bbox": [0, 0, 100, 100], + "text": "K l soll Arbeit abnehmen und Kosten sparen."}, + {"label": "body", "bbox": [0, 0, 100, 100], + "text": "A m 3. Januar erschienen neue Bilder."}, + {"label": "text", "bbox": [0, 0, 100, 100], + "text": "B i s heute blieb das Problem bestehen."}, + ] + repaired = repair_drop_cap_omissions(pathlib.Path("missing-page.png"), regions) + self.assertEqual(repaired, 3) + self.assertEqual(regions[0]["text"], "KI soll Arbeit abnehmen und Kosten sparen.") + self.assertEqual(regions[1]["text"], "Am 3. Januar erschienen neue Bilder.") + self.assertEqual(regions[2]["text"], "Bis heute blieb das Problem bestehen.") + + def test_joins_a_detached_initial_already_found_by_primary_ocr(self): + from core.magazine import repair_drop_cap_omissions + regions = [{ + "label": "paragraph", + "bbox": [10, 10, 200, 300], + "text": "S prechende Katzen, halbnackte Prominente und unglaubliche Sensationsmeldungen.", + }] + repaired = repair_drop_cap_omissions(pathlib.Path("missing-page.png"), regions) + self.assertEqual(repaired, 1) + self.assertEqual( + regions[0]["text"], + "Sprechende Katzen, halbnackte Prominente und unglaubliche Sensationsmeldungen.", + ) + def test_restores_omitted_first_line_and_joins_detached_initial(self): old = ("tert, dass es fast ein Garant für einen sicheren, gut bezahlten Bürojob ist, " "wenn man programmieren kann. Wer vor vier Jahren angefangen hat, Informatik zu studieren.") @@ -99,6 +131,19 @@ class DropCapRepairTests(unittest.TestCase): unrelated = ("Andere Spalte mit vollständig anderem Inhalt und ganz anderen Worten. " * 4).strip() self.assertEqual(prefer_drop_cap_repair(old, unrelated), old) + def test_restores_a_whole_first_line_before_capitalised_remainder(self): + old = ("Herrn, verwaltet Termine, öffnet Türen, sortiert die Post und weiß, wo wichtige " + "Unterlagen liegen. Genau diese Rolle wollen digitale Assistenten übernehmen.") + local = ("Ein guter Butler kennt die Vorlieben seines Herrn, verwaltet Termine, öffnet Türen, " + "sortiert die Post und weiß, wo wichtige Unterlagen liegen. Genau diese Rolle wollen " + "digitale Assistenten übernehmen.") + repaired = prefer_drop_cap_repair(old, local) + self.assertEqual(repaired, local) + + def test_capitalised_remainder_rejects_a_tiny_prefix(self): + old = "Herrn, verwaltet Termine und öffnet Türen für die Bewohner des Hauses." + self.assertEqual(prefer_drop_cap_repair(old, "Der Herrn, verwaltet Termine und öffnet Türen für die Bewohner des Hauses."), old) + class ColumnGeometryTests(unittest.TestCase): def test_two_columns_detected_from_the_gutter(self): @@ -843,6 +888,42 @@ class VisionBlockBuildingTests(unittest.TestCase): blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") self.assertEqual(blocks[0].type, BLOCK_CAPTION) + def test_visual_box_over_text_is_dropped_but_standalone_figure_survives(self): + regions = [ + {"label": "paragraph", "bbox": [100, 100, 300, 200], "text": "Article text."}, + {"label": "image", "bbox": [80, 80, 340, 240], "text": ""}, + {"label": "figure", "bbox": [500, 100, 200, 200], "text": ""}, + ] + blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") + self.assertEqual([block.type for block in blocks], [BLOCK_BODY, "figure"]) + + def test_broad_visual_box_is_dropped_when_tighter_figure_is_nested(self): + regions = [ + {"label": "image", "bbox": [40, 40, 360, 700], "text": ""}, + {"label": "figure", "bbox": [60, 60, 300, 260], "text": ""}, + ] + blocks = build_vision_blocks(regions, page_no=136, scale_x=1.0, id_prefix="b") + self.assertEqual(len(blocks), 1) + self.assertEqual(blocks[0].type, BLOCK_FIGURE) + + def test_tall_visual_box_stops_before_overlapping_caption(self): + regions = [ + {"label": "image", "bbox": [40, 40, 300, 700], "text": ""}, + {"label": "caption", "bbox": [55, 500, 270, 80], + "text": "Caption below the photograph."}, + ] + blocks = build_vision_blocks(regions, page_no=136, scale_x=1.0, id_prefix="b") + self.assertEqual(blocks[0].bbox, [40.0, 40.0, 300.0, 460.0]) + + def test_text_inside_screenshot_is_not_a_separate_article_paragraph(self): + regions = [ + {"label": "image", "bbox": [100, 100, 400, 500], "text": ""}, + {"label": "paragraph", "bbox": [130, 180, 330, 50], + "text": "Text printed inside screenshot."}, + ] + blocks = build_vision_blocks(regions, page_no=139, scale_x=1.0, id_prefix="b") + self.assertEqual([b.type for b in blocks], [BLOCK_FIGURE]) + def test_semantic_groups_join_fragments_and_keep_continuation(self): from core.magazine import apply_semantic_regions blocks = [Block('a', 1, [10,10,100,20], 'body', 'A para-').to_json(), @@ -856,6 +937,22 @@ class VisionBlockBuildingTests(unittest.TestCase): self.assertTrue(groups[0]['continues_from_previous_page']) self.assertEqual(groups[1]['type'], 'body') + def test_semantic_group_uses_saved_reading_order_not_model_id_order(self): + from core.magazine import apply_semantic_regions + first = Block('first', 1, [20, 100, 200, 30], 'body', + 'Ein guter Butler kennt die Vorlieben seines') + second = Block('second', 1, [20, 135, 200, 80], 'body', + 'Herrn, verwaltet Termine und öffnet Türen.') + first.reading_order, second.reading_order = 3, 4 + grouped = apply_semantic_regions( + [first.to_json(), second.to_json()], + [{'type': 'paragraph', 'block_ids': ['second', 'first']}], + ) + self.assertEqual( + grouped[0]['text'], + 'Ein guter Butler kennt die Vorlieben seines Herrn, verwaltet Termine und öffnet Türen.', + ) + def test_semantic_paragraph_continuing_across_columns_keeps_two_boxes(self): """A logical paragraph can end in the left column and continue in the right. Its bbox must not become the union covering both columns @@ -902,6 +999,33 @@ class VisionBlockBuildingTests(unittest.TestCase): self.assertEqual([b['text'] for b in grouped], ['Left one left two', 'Right one right two']) + def test_body_paragraph_is_not_a_caption_without_a_nearby_figure(self): + """A bottom-of-column interview answer remains body copy (page 47).""" + from core.magazine import apply_semantic_regions + paragraph = Block( + 'answer', 47, [140, 530, 205, 145], 'body', + 'Haverkamp: Teilweise ja. In den Handlungsempfehlungen der KMK heißt es, ' + 'dass Prüfungsformate weiterentwickelt werden sollen.', + ).to_json() + grouped = apply_semantic_regions( + [paragraph], [{'type': 'caption', 'block_ids': ['answer']}], + {'page': 47, 'width': 595.0, 'height': 842.0}, + ) + self.assertEqual(grouped[0]['type'], BLOCK_BODY) + + def test_body_near_a_real_figure_may_be_promoted_to_caption(self): + from core.magazine import apply_semantic_regions + figure = Block('photo', 1, [40, 40, 200, 200], BLOCK_FIGURE, '').to_json() + copy = Block('copy', 1, [40, 245, 200, 25], BLOCK_BODY, + 'Portrait of the software developer.').to_json() + grouped = apply_semantic_regions( + [figure, copy], + [{'type': 'figure', 'block_ids': ['photo']}, + {'type': 'caption', 'block_ids': ['copy']}], + {'page': 1, 'width': 595.0, 'height': 842.0}, + ) + self.assertEqual(grouped[1]['type'], BLOCK_CAPTION) + def test_semantic_group_cannot_absorb_editorial_into_advert(self): from core.magazine import apply_semantic_regions blocks = [Block('a', 1, [0,0,100,20], 'body', 'Editorial').to_json(), @@ -1217,6 +1341,17 @@ class ContinuationMarkerStrippingTests(unittest.TestCase): class ArticleTextTests(unittest.TestCase): + def test_keeps_printed_abbreviation_but_provides_spoken_expansion(self): + layout = { + "blocks": [{"id": "b1", "page": 1, "type": BLOCK_BODY, + "reading_order": 0, "bbox": [0, 0, 1, 1], + "text": "KI hilft bei der Arbeit."}], + } + got = article_text(layout, {"blocks": ["b1"]}) + self.assertEqual(got[0]["text"], "KI hilft bei der Arbeit.") + self.assertEqual(got[0]["spoken_text"], + "Künstliche Intelligenz hilft bei der Arbeit.") + def test_continuation_marker_is_stripped_from_narrated_output(self): """article_text() feeds TTS directly, so the print cue must not survive into what gets spoken -- even though it stays in layout.json @@ -1458,6 +1593,57 @@ class ImageReadingPositionTests(unittest.TestCase): self.assertEqual(image_reading_position({"page": 9, "bbox": [0, 0, 1, 1]}, []), 0) +class OcrQualityPassTests(unittest.TestCase): + def test_missing_text_adds_only_non_overlapping_non_visual_candidate(self): + layout = { + 'pages': [{'page': 1, 'width': 600, 'height': 800}], + 'blocks': [block('saved', 1, 40, 50, 200, 100, + 'Already recognised paragraph text.').to_json()], + 'images': [{'id': 'photo', 'page': 1, 'bbox': [300, 40, 200, 180]}], + } + candidates = [ + block('', 1, 42, 55, 195, 90, 'Already recognised paragraph text.'), + block('', 1, 320, 70, 120, 80, 'Words printed inside the screenshot.'), + block('', 1, 40, 300, 200, 100, + 'This genuinely missed paragraph should be offered for review.'), + ] + updated, added = merge_missing_text_candidates(layout, 1, candidates) + self.assertEqual(added, 1) + new = [b for b in updated['blocks'] if b['id'].startswith('q0001-')] + self.assertEqual(len(new), 1) + self.assertIn('genuinely missed', new[0]['text']) + self.assertEqual(new[0]['source'], 'quality_missing_text') + + def test_verification_accepts_only_a_similar_reading_with_fewer_suspects(self): + old = 'Die Softwarentwicklng hilft Unternehmen bei dieser Aufgabe.' + fixed = 'Die Softwareentwicklung hilft Unternehmen bei dieser Aufgabe.' + layout = { + 'pages': [{'page': 1, 'width': 600, 'height': 800}], + 'blocks': [block('copy', 1, 40, 50, 200, 100, old).to_json()], + } + with mock.patch('core.magazine.suspect_ocr_tokens', + side_effect=lambda text: ['Softwarentwicklng'] if text == old else []), \ + mock.patch('core.magazine.extract_region_text', return_value=fixed): + updated, checked, corrected = verify_ocr_recognition( + pathlib.Path('/tmp/doc'), layout, 1) + self.assertEqual((checked, corrected), (1, 1)) + self.assertEqual(updated['blocks'][0]['text'], fixed) + + def test_verification_rejects_an_unrelated_second_reading(self): + old = 'Die Softwarentwicklng hilft Unternehmen bei dieser Aufgabe.' + layout = { + 'pages': [{'page': 1, 'width': 600, 'height': 800}], + 'blocks': [block('copy', 1, 40, 50, 200, 100, old).to_json()], + } + with mock.patch('core.magazine.suspect_ocr_tokens', return_value=['bad']), \ + mock.patch('core.magazine.extract_region_text', + return_value='Completely different words from another column entirely.'): + updated, checked, corrected = verify_ocr_recognition( + pathlib.Path('/tmp/doc'), layout, 1) + self.assertEqual((checked, corrected), (1, 0)) + self.assertEqual(updated['blocks'][0]['text'], old) + + class WholePageVisionFallbackTests(unittest.TestCase): """KDL Frontier Parser nano, confirmed live, answers with plain text and no coordinate markers at all -- unlike ocr-baidu-unlimited-ocr's labeled @@ -1882,6 +2068,32 @@ class UpdateRegionTests(unittest.TestCase): self.assertEqual(moved["text"], "Existing title") self.assertEqual(text, "") + def test_reocr_keeps_drop_cap_first_line_when_new_read_starts_later(self): + old = ("Mails versenden, Kalender bearbeiten, Skripte schreiben und ausführen: " + "Wenn man sie ließe, könnten LLMs viele Aufgaben ziemlich selbstständig erledigen.") + reread = ("schreiben und ausführen: Wenn man sie ließe, könnten LLMs viele Aufgaben " + "ziemlich selbstständig erledigen.") + self.layout["blocks"][0]["text"] = old + self.layout["blocks"][0]["type"] = BLOCK_BODY + with mock.patch("core.magazine.extract_region_text", return_value=reread): + out, text = update_region(self.doc_dir, self.layout, 3, "b0003-000", + [50, 60, 320, 80], reocr=True) + moved = next(b for b in out["blocks"] if b["id"] == "b0003-000") + self.assertEqual(moved["text"], old) + self.assertEqual(text, old) + + def test_reocr_allows_intentionally_moving_top_below_old_first_line(self): + old = ("Mails versenden, Kalender bearbeiten, Skripte schreiben und ausführen: " + "Wenn man sie ließe, könnten LLMs viele Aufgaben ziemlich selbstständig erledigen.") + reread = "schreiben und ausführen: Wenn man sie ließe, könnten LLMs viele Aufgaben erledigen." + self.layout["blocks"][0]["text"] = old + with mock.patch("core.magazine.extract_region_text", return_value=reread): + out, text = update_region(self.doc_dir, self.layout, 3, "b0003-000", + [50, 75, 300, 25], reocr=True) + moved = next(b for b in out["blocks"] if b["id"] == "b0003-000") + self.assertEqual(moved["text"], reread) + self.assertEqual(text, reread) + def test_moving_an_image_recrops_its_file(self): from PIL import Image out, text = update_region(self.doc_dir, self.layout, 3, "img0003-000", [0, 0, 60, 40]) @@ -2065,13 +2277,55 @@ class SnapVisualBboxesTests(unittest.TestCase): self.assertEqual(moved, 1) want = self._pt(self.FIGURE_PX) for got, expected in zip(block.bbox, want): - self.assertAlmostEqual(got, expected, delta=12.0) + self.assertAlmostEqual(got, expected, delta=2.0) + + def test_dense_photo_edges_do_not_keep_asymmetric_grid_margin(self): + block = self._figure_block((200, 60, 600, 250)) + snap_visual_bboxes(self.png, [block, self._text_block()], self.meta) + got = block.bbox + want = self._pt(self.FIGURE_PX) + for actual, expected in zip(got, want): + self.assertAlmostEqual(actual, expected, delta=2.0) def test_snap_does_not_swallow_the_text_column(self): block = self._figure_block((200, 60, 600, 250)) snap_visual_bboxes(self.png, [block, self._text_block()], self.meta) self.assertGreater(block.bbox[0], self._pt(self.TEXT_PX)[0] + 20) + def test_displaced_advert_box_does_not_block_figure_snapping(self): + block = self._figure_block((200, 60, 600, 250)) + advert = Block(id="ad", page=1, bbox=list(block.bbox), type=BLOCK_AD, + text="Advertisement copy " * 20) + moved = snap_visual_bboxes(self.png, [block, advert, self._text_block()], self.meta) + self.assertEqual(moved, 1) + want = self._pt(self.FIGURE_PX) + for got, expected in zip(block.bbox, want): + self.assertAlmostEqual(got, expected, delta=12.0) + + def test_refresh_moves_advert_outline_with_its_visual_parts(self): + from core.magazine import refresh_visual_regions + first = Block(id="f1", page=1, bbox=[10, 10, 100, 80], + type=BLOCK_FIGURE, text="") + second = Block(id="f2", page=1, bbox=[120, 10, 100, 80], + type=BLOCK_FIGURE, text="") + advert = Block(id="ad", page=1, bbox=[0, 0, 240, 100], + type=BLOCK_AD, text="Buy now") + layout = {"pages": [self.meta], + "blocks": [first.to_json(), second.to_json(), advert.to_json()], + "images": []} + + def move(_png, blocks, _meta): + by_id = {b.id: b for b in blocks} + by_id["f1"].bbox = [20, 400, 100, 80] + by_id["f2"].bbox = [130, 400, 100, 80] + return 2 + + with mock.patch("core.magazine.snap_visual_bboxes", side_effect=move), \ + mock.patch("core.magazine.crop_visual_regions", return_value=[]): + refresh_visual_regions(self.dir, layout, page=1) + saved = {b["id"]: b for b in layout["blocks"]} + self.assertEqual(saved["ad"]["bbox"], [20, 400, 210, 80]) + def test_snapping_is_idempotent(self): block = self._figure_block((200, 60, 600, 250)) blocks = [block, self._text_block()] @@ -2200,3 +2454,66 @@ class SnapVisualBboxesStabilityTests(unittest.TestCase): self.assertAlmostEqual(a, b, delta=8.0) # And never merge into one box spanning both panels. self.assertLess(left.bbox[0] + left.bbox[2], right.bbox[0] + 1.0) + + +class AuditPageCoverageTests(unittest.TestCase): + """audit_page_coverage: uncovered ink, overlapping boxes, clipped box edges.""" + + def _doc(self, boxes): + from PIL import Image, ImageDraw + d = pathlib.Path(tempfile.mkdtemp(prefix="ttvc-audit-")) + self.addCleanup(shutil.rmtree, d, True) + (d / "pages").mkdir() + im = Image.new("RGB", (400, 400), "white") + draw = ImageDraw.Draw(im) + for y in range(60, 340, 12): # a text column at x 100..250 + draw.rectangle([100, y, 250, y + 6], fill="black") + im.save(d / "pages" / "0001.png") + blocks = [{"id": f"b{i}", "page": 1, "bbox": list(b), "type": "body", "text": "Hallo Welt"} + for i, b in enumerate(boxes)] + layout = {"pages": [{"page": 1, "width": 400, "height": 400}], + "blocks": blocks, "images": []} + return d, layout + + def test_fully_covered_page_is_clean(self): + from core.magazine import audit_page_coverage + d, layout = self._doc([(90, 50, 170, 300)]) + r = audit_page_coverage(d, layout, 1) + self.assertTrue(r["measured"]) + self.assertEqual((r["uncovered"], r["overlaps"], r["clipped"]), ([], [], [])) + + def test_text_without_a_box_is_reported(self): + from core.magazine import audit_page_coverage + d, layout = self._doc([(90, 50, 170, 100)]) # lower two thirds uncovered + r = audit_page_coverage(d, layout, 1) + self.assertTrue(r["uncovered"]) + self.assertGreater(r["uncovered_ratio"], 0.3) + + def test_overlapping_text_boxes_are_reported(self): + from core.magazine import audit_page_coverage + d, layout = self._doc([(90, 50, 170, 160), (90, 120, 170, 180)]) + self.assertEqual(audit_page_coverage(d, layout, 1)["overlaps"], [["b0", "b1"]]) + + def test_box_cutting_into_text_is_clipped(self): + from core.magazine import audit_page_coverage + d, layout = self._doc([(160, 50, 60, 290)]) # starts mid-line, text continues left + clipped = audit_page_coverage(d, layout, 1)["clipped"] + self.assertIn({"id": "b0", "side": "left"}, clipped) + + def test_missing_render_is_not_measured(self): + from core.magazine import audit_page_coverage + d, layout = self._doc([(90, 50, 170, 300)]) + (d / "pages" / "0001.png").unlink() + self.assertFalse(audit_page_coverage(d, layout, 1)["measured"]) + + +class AdvertisementPageTests(unittest.TestCase): + def test_article_text_skips_pages_approved_as_advertisement(self): + from core.magazine import article_text + layout = {"pages": [{"page": 1}, {"page": 2, "advertisement": True}], + "blocks": [ + {"id": "a", "page": 1, "type": "body", "text": "Real text.", "reading_order": 0}, + {"id": "b", "page": 2, "type": "body", "text": "Buy now!", "reading_order": 0}], + "images": []} + out = article_text(layout, {"blocks": ["a", "b"], "pages": [1, 2]}) + self.assertEqual([b["id"] for b in out], ["a"]) diff --git a/tests/test_magazine_routes.py b/tests/test_magazine_routes.py index 80415b8..04db33c 100644 --- a/tests/test_magazine_routes.py +++ b/tests/test_magazine_routes.py @@ -235,6 +235,21 @@ class RefineFiguresJobTests(unittest.TestCase): self.assertEqual(done[-1]['result'], {'moved': 0}) self.assertNotIn('error', done[-1]) + def test_page_job_only_refreshes_the_requested_page(self): + from unittest.mock import patch + from routes.magazine import _refine_figures_sync + layout = self._layout() + with patch('routes.magazine.mag.load_layout', return_value=layout), \ + patch('routes.magazine.mag.refresh_visual_regions') as refresh, \ + patch('routes.magazine.mag.save_layout'), \ + patch('routes.magazine.background_job_update') as update: + _refine_figures_sync('a' * 32, 'job', page=2) + self.assertEqual(refresh.call_count, 1) + self.assertEqual(refresh.call_args.args[2], 2) + progress = [c.kwargs.get('detail', '') for c in update.call_args_list] + self.assertIn('Checking figure boundaries on page 2', progress) + self.assertFalse(any('of 2' in detail for detail in progress)) + def test_a_document_with_no_layout_reports_failure(self): from unittest.mock import patch from routes.magazine import _refine_figures_sync @@ -248,6 +263,63 @@ class RefineFiguresJobTests(unittest.TestCase): self.assertIn('No layout', done[-1]['error']) +class PageQualityJobTests(unittest.TestCase): + def test_missing_text_quality_job_saves_additive_result(self): + from unittest.mock import patch + from routes.magazine import _quality_page_sync + layout = {'pages': [{'page': 2}], 'blocks': []} + updated = {'pages': [{'page': 2}], 'blocks': [{'id': 'new'}]} + with patch('routes.magazine.mag.load_layout', return_value=layout), \ + patch('routes.magazine.mag.recover_missing_text', + return_value=(updated, 1)) as recover, \ + patch('routes.magazine.mag.save_layout') as save, \ + patch('routes.magazine.background_job_update') as update: + _quality_page_sync('a' * 32, 'job', 2, 'missing') + recover.assert_called_once() + save.assert_called_once() + self.assertEqual(save.call_args.args[1], updated) + done = [call.kwargs for call in update.call_args_list if call.kwargs.get('done')] + self.assertEqual(done[-1]['result'], {'page': 2, 'added': 1}) + + def test_recognition_quality_job_reports_checked_and_corrected(self): + from unittest.mock import patch + from routes.magazine import _quality_page_sync + layout = {'pages': [{'page': 3}], 'blocks': []} + with patch('routes.magazine.mag.load_layout', return_value=layout), \ + patch('routes.magazine.mag.verify_ocr_recognition', + return_value=(layout, 4, 2)), \ + patch('routes.magazine.mag.save_layout'), \ + patch('routes.magazine.background_job_update') as update: + _quality_page_sync('a' * 32, 'job', 3, 'recognition') + done = [call.kwargs for call in update.call_args_list if call.kwargs.get('done')] + self.assertEqual(done[-1]['result'], {'page': 3, 'checked': 4, 'corrected': 2}) + + +class PageQualityRouteTests(unittest.IsolatedAsyncioTestCase): + async def test_missing_text_route_starts_page_scoped_job(self): + from unittest.mock import patch + from routes.magazine import magazine_find_missing_text + layout = {'pages': [{'page': 7}], 'blocks': []} + with patch('routes.magazine._require_magazine', return_value=(Path('/tmp/doc'), {})), \ + patch('routes.magazine.mag.load_layout', return_value=layout), \ + patch('routes.magazine.background_job_create') as create, \ + patch('routes.magazine.asyncio.to_thread', new=lambda *a, **k: None), \ + patch('asyncio.create_task') as create_task: + result = await magazine_find_missing_text('a' * 32, 7) + self.assertIn('job_id', result) + create.assert_called_once() + create_task.assert_called_once() + + async def test_quality_route_rejects_unknown_page(self): + from unittest.mock import patch + from routes.magazine import magazine_find_missing_text + with patch('routes.magazine._require_magazine', return_value=(Path('/tmp/doc'), {})), \ + patch('routes.magazine.mag.load_layout', return_value={'pages': []}): + with self.assertRaises(HTTPException) as error: + await magazine_find_missing_text('a' * 32, 99) + self.assertEqual(error.exception.status_code, 404) + + class IngestPreviewHtmlTests(unittest.TestCase): """The live ingest preview: a fast, best-effort HTML render of one page's just-read blocks, well before classify_blocks or article @@ -1376,3 +1448,63 @@ class InterruptedIngestTests(unittest.TestCase): out = asyncio.run(magazine_ingest_status('a' * 32)) self.assertEqual(out["ingest"]["status"], "running") write.assert_not_called() + + +class QualityRunJobTests(unittest.TestCase): + def _run(self, settings, audit_row): + from unittest.mock import patch + from routes.magazine import _quality_run_sync + layout = {'pages': [{'page': 1}, {'page': 2}], 'blocks': []} + lib = Path(tempfile.mkdtemp(prefix='ttvc-qrun-')) + self.addCleanup(shutil.rmtree, lib, True) + (lib / ('a' * 32)).mkdir() + with patch('routes.magazine.mag.load_layout', return_value=layout), \ + patch('routes.magazine.mag.recover_missing_text', return_value=(layout, 1)), \ + patch('routes.magazine.mag.verify_ocr_recognition', return_value=(layout, 3, 2)) as verify, \ + patch('routes.magazine.mag.audit_page_coverage', side_effect=audit_row), \ + patch('routes.magazine.mag.save_layout'), \ + patch('routes.magazine._LIB', lib), \ + patch('routes.magazine.background_job_update') as update: + _quality_run_sync('a' * 32, 'job', settings) + done = [c.kwargs for c in update.call_args_list if c.kwargs.get('done')] + return done[-1], verify + + @staticmethod + def _row(page, **kw): + row = {'page': page, 'uncovered': [], 'uncovered_ratio': 0.0, 'suspect_blocks': [], + 'overlaps': [], 'clipped': [], 'measured': True} + row.update(kw) + return row + + def test_flags_pages_and_summarises(self): + rows = iter([self._row(1), self._row(2, overlaps=[['a', 'b']])]) + done, verify = self._run({'vision_ocr_url': 'http://x', 'vision_ocr_model': 'm'}, + lambda *a, **k: next(rows)) + self.assertEqual(done['result']['pages_needing_review'], [2]) + self.assertEqual(done['result']['regions_added'], 2) + self.assertEqual(done['result']['regions_corrected'], 4) + self.assertEqual(verify.call_count, 2) + + def test_skips_verification_without_vision_engine(self): + rows = iter([self._row(1), self._row(2)]) + done, verify = self._run({}, lambda *a, **k: next(rows)) + verify.assert_not_called() + self.assertFalse(done['result']['verified_with_vision']) + self.assertIn('skipped', done['detail']) + + +class PageAdvertisementRouteTests(unittest.IsolatedAsyncioTestCase): + async def test_mark_and_unmark_round_trip_and_404(self): + from unittest.mock import patch + from routes.magazine import (magazine_mark_page_advertisement, + magazine_unmark_page_advertisement) + layout = {'pages': [{'page': 1}, {'page': 2}], 'blocks': []} + with patch('routes.magazine._require_magazine', return_value=(Path('/nowhere'), {})), \ + patch('routes.magazine.mag.load_layout', return_value=layout), \ + patch('routes.magazine.mag.save_layout') as save: + await magazine_mark_page_advertisement('a' * 32, 2) + self.assertTrue(save.call_args.args[1]['pages'][1]['advertisement']) + await magazine_unmark_page_advertisement('a' * 32, 2) + self.assertNotIn('advertisement', save.call_args.args[1]['pages'][1]) + with self.assertRaises(HTTPException): + await magazine_mark_page_advertisement('a' * 32, 9) diff --git a/tests/test_pronunciations.py b/tests/test_pronunciations.py index fd7d3a8..13d8c01 100644 --- a/tests/test_pronunciations.py +++ b/tests/test_pronunciations.py @@ -5,6 +5,7 @@ from core.pronunciations import ( normalize_ocr_text, prepare_text_for_tts, remove_linebreak_hyphens, + suspect_ocr_tokens, ) @@ -27,6 +28,12 @@ class PronunciationTests(unittest.TestCase): self.assertIn("Programmierschnittstellen", spoken) self.assertIn("natürlich", spoken) + def test_ki_expansion_is_token_bounded(self): + self.assertEqual(prepare_text_for_tts("KI hilft der KI-Branche.", "de"), + "Künstliche Intelligenz hilft der Künstliche Intelligenz-Branche.") + self.assertEqual(prepare_text_for_tts("Kiel bleibt Kiel.", "de"), + "Kiel bleibt Kiel.") + def test_english_acronyms_use_english_expansions(self): self.assertEqual(expand_abbreviations("AI uses an API.", "en"), "artificial intelligence uses an application programming interface.") @@ -35,6 +42,18 @@ class PronunciationTests(unittest.TestCase): text = "OpenAI und PyMuPDF bleiben unverändert." self.assertEqual(normalize_ocr_text(text, "de"), text) + def test_spellcheck_only_flags_suspects_and_never_rewrites_text(self): + class Checker: + def __contains__(self, word): + return word != 'softwarentwicklng' + + from unittest import mock + with mock.patch('core.pronunciations._german_spellchecker', return_value=Checker()): + self.assertEqual( + suspect_ocr_tokens('Die Softwarentwicklng hilft OpenAI.', 'de'), + ['Softwarentwicklng'], + ) + if __name__ == "__main__": unittest.main()