fix(magazine): region-type picker and page-quality buttons work; stronger quality audit
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

- Export the region-type picker handlers and magRunPageQuality to the page (they threw
  "not defined", so the picker never opened and Find missed text / Verify OCR did nothing)
- Add a unit test that fails when an inline mag* handler is not exported
- Quality audit: pixel-level clipping check on all four box edges, duplicate-article
  detection, detected full-page advertisements skipped and shown green (no manual button)
- Driving mode text is karaoke-style (whole article, current sentence and word highlighted)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-26 23:07:19 +02:00
parent 9904459913
commit 48c546fbc8
10 changed files with 256 additions and 68 deletions

View File

@ -7,9 +7,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Fixed
- **The region-type picker in review opens again** and **"Find missed text" / "Verify OCR text" work**: their click handlers were never exported to the page. A new unit test fails if any inline `mag*` handler is not exported.
### 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`).
- The quality check now measures box edges at pixel level on all four sides and reports articles that claim the same text regions (duplicates that would be narrated more than once).
- **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 article text as karaoke (whole article scrolls, current sentence and word highlighted) and the article's images.
- **Full-page advertisements are recognised automatically.** A page with detected advertisement regions and no narratable text turns green (dashed), is never read aloud and is skipped by the quality check — no button or manual approval. (`PUT/DELETE /api/magazine/{id}/pages/{page}/advertisement` still exist to force the state.)
- **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.
---

View File

@ -250,8 +250,7 @@ turned into a podcast discussion between two or three voices you pick.
(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
**Text** / **Images** toggles that show or hide the article text as karaoke (the whole article scrolls, the sentence being read is marked and the current word is highlighted; word timing is estimated from the MP3 position) and the article's current picture. Everything is a
large touch target and keyboard reachable; the speed also applies to the
playlist player.
@ -288,10 +287,10 @@ unreviewed boundaries may include a neighbouring article or an advert.
**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.
**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, boxes whose edge (left, right, top or bottom) cuts through text, and articles that claim the same regions (the same text would be read twice). 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.
**Advertisement pages:** a page where detection found advertisement regions and no article text turns green with a dashed border on its own — nothing on it is read aloud and the quality check skips it. No approval step is needed; a page that mixes an ad with article text is treated as an ordinary page.
**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.

View File

@ -703,7 +703,7 @@ 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
A page is an advertisement (`page_is_advertisement`) when detection labelled regions on it as ads and none is narratable, or when `layout.pages[n].advertisement = true` is set (via `PUT /pages/{page}/advertisement`, cleared with
`DELETE`) is a non-destructive approval: `article_text` drops every block and image description
on such a page, and the quality run records the page as `advertisement` without checking it.
Regions and articles are untouched, so the flag is fully reversible.

View File

@ -1750,15 +1750,9 @@ _SNAP_JOINT_ROUNDS = 6 # rounds of re-seeding every visual on a page from
# that does not truly converge
def page_ink_grid(png_path: Path, cell: int = _SNAP_CELL_PX):
"""Coarse map of the page render: inked fraction of each `cell`-px square.
"Inked" means visibly darker than the paper or visibly coloured, so a
grey scan background does not count as ink. Body type covers roughly a
quarter of the cells it runs through; a photo or a filled panel covers
nearly all of them, which is the separation snap_visual_bboxes() relies
on. Returns None when the render cannot be read.
"""
def page_ink_mask(png_path: Path):
"""Full-resolution boolean map of the page render: True where a pixel is visibly darker than
the paper or visibly coloured. None when the render cannot be read."""
try:
import numpy as np
from PIL import Image
@ -1769,7 +1763,21 @@ def page_ink_grid(png_path: Path, cell: int = _SNAP_CELL_PX):
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)
return (lightness < paper * 0.88) | (saturation > 40)
def page_ink_grid(png_path: Path, cell: int = _SNAP_CELL_PX):
"""Coarse map of the page render: inked fraction of each `cell`-px square.
"Inked" means visibly darker than the paper or visibly coloured, so a
grey scan background does not count as ink. Body type covers roughly a
quarter of the cells it runs through; a photo or a filled panel covers
nearly all of them, which is the separation snap_visual_bboxes() relies
on. Returns None when the render cannot be read.
"""
ink = page_ink_mask(png_path)
if ink is None:
return None
rows, cols = (n // cell for n in ink.shape)
if rows < 4 or cols < 4:
return None
@ -2844,11 +2852,66 @@ _AUDIT_MIN_CELL_INK = 0.08 # a text-bearing cell is inked well below a pho
# 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:
_AUDIT_CLIP_INK = 0.10 # 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
_AUDIT_CLIP_STRIP_PX = 14 # how far outside a box edge to look for ink that belongs to it (~1-2 letters)
def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str, Any]],
boxes: list[Sequence[float]]) -> list[dict[str, Any]]:
"""Text boxes whose edge cuts through text: ink in a thin strip just outside a side, ignoring
whatever another box already covers (a neighbouring column is not clipping)."""
import numpy as np
height, width = pixels.shape
sx, sy = width / max(1.0, float(page_meta["width"])), height / max(1.0, float(page_meta["height"]))
covered = np.zeros_like(pixels)
for x, y, w, h in boxes:
covered[max(0, int(y * sy)):int((y + h) * sy) + 1, max(0, int(x * sx)):int((x + w) * sx) + 1] = True
found = []
for block in text_blocks:
x, y, w, h = block["bbox"]
left, right = int(x * sx), int((x + w) * sx) + 1
top, bottom = int(y * sy), int((y + h) * sy) + 1
strips = {"left": (top, bottom, left - _AUDIT_CLIP_STRIP_PX, left),
"right": (top, bottom, right, right + _AUDIT_CLIP_STRIP_PX),
"top": (top - _AUDIT_CLIP_STRIP_PX, top, left, right),
"bottom": (bottom, bottom + _AUDIT_CLIP_STRIP_PX, left, right)}
for side, (r0, r1, c0, c1) in strips.items():
r0, c0, r1, c1 = max(0, r0), max(0, c0), min(height, r1), min(width, c1)
if r1 <= r0 or c1 <= c0:
continue
free = ~covered[r0:r1, c0:c1]
if free.sum() >= 40 and (pixels[r0:r1, c0:c1] & free).sum() / free.sum() >= _AUDIT_CLIP_INK:
found.append({"id": block["id"], "side": side})
return found
def duplicate_article_groups(articles: Sequence[dict[str, Any]]) -> list[list[str]]:
"""Groups of articles that share most of their text regions -- the same words would be
narrated once per article. Two articles are grouped when the smaller one shares at least
half its regions with the other."""
sets = [(a["id"], set(a.get("blocks") or [])) for a in articles if a.get("blocks")]
parent = {i: i for i, _ in sets}
def find(i: str) -> str:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
for n, (ia, sa) in enumerate(sets):
for ib, sb in sets[n + 1:]:
if len(sa & sb) >= 0.5 * min(len(sa), len(sb)):
parent[find(ia)] = find(ib)
groups: dict[str, list[str]] = {}
for i, _ in sets:
groups.setdefault(find(i), []).append(i)
return [g for g in groups.values() if len(g) > 1]
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.
@ -2889,14 +2952,9 @@ def audit_page_coverage(doc_dir: Path, layout: dict[str, Any], page_no: int) ->
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})
pixels = page_ink_mask(png)
if pixels is not None:
report["clipped"] = _clipped_edges(pixels, page_meta, text_blocks, boxes)
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
@ -3928,6 +3986,19 @@ def load_articles(doc_dir: Path) -> list[dict[str, Any]]:
BLOCK_FIGURE_DESCRIPTION = "figure_description"
def page_is_advertisement(layout: dict[str, Any], page_no: int) -> bool:
"""A page is an advertisement when it was flagged as one, or detection found advertisement
regions on it and nothing narratable -- a full-page ad needs no manual approval."""
meta = next((p for p in layout.get("pages", []) if p.get("page") == page_no), None)
if meta is None:
return False
if meta.get("advertisement"):
return True
on_page = [b for b in layout.get("blocks", []) if b.get("page") == page_no]
return (any(b.get("type") == BLOCK_AD for b in on_page)
and not any(b.get("type") in NARRATABLE for b in on_page))
def article_text(layout: dict[str, Any], article: dict[str, Any],
include_captions: bool = False,
include_descriptions: bool = False) -> list[dict[str, Any]]:
@ -3947,7 +4018,7 @@ def article_text(layout: dict[str, Any], article: dict[str, Any],
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")}
ad_pages = {p["page"] for p in layout.get("pages", []) if page_is_advertisement(layout, p["page"])}
wanted = set(article.get("blocks") or [])
types = set(NARRATABLE) | ({BLOCK_CAPTION} if include_captions else set())
blocks = [

View File

@ -999,7 +999,7 @@ def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> Non
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"):
if mag.page_is_advertisement(layout, page):
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})
@ -1023,12 +1023,22 @@ def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> Non
if i % 5 == 0:
mag.save_layout(d, layout)
mag.save_layout(d, layout)
articles = mag.load_articles(d)
duplicates = mag.duplicate_article_groups(articles)
by_id = {a["id"]: a for a in articles}
for row in rows:
row["duplicates"] = sorted({i for group in duplicates for i in group
if row["page"] in (by_id[i].get("pages") or [])
and any(row["page"] in (by_id[j].get("pages") or [])
for j in group if j != i)})
row["needs_review"] = bool(row["needs_review"] or row["duplicates"])
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"]],
"duplicate_article_groups": len(duplicates),
}
(d / _QUALITY_REPORT).write_text(
json.dumps({"summary": summary, "pages": rows}, ensure_ascii=False), encoding="utf-8")

View File

@ -800,23 +800,15 @@ const MAG_PAGES_COLS_KEY = 'ttsvc_mag_pages_cols';
// 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'.
// Mirrors page_is_advertisement() in core/magazine.py: flagged, or detection found advertisement
// regions and nothing narratable on the page.
const MAG_NARRATABLE_TYPES = ['title', 'heading', 'deck', 'byline', 'body', 'quote'];
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();
const layout = state.layout;
if (!layout) return false;
if ((layout.pages || []).find((m) => m.page === page)?.advertisement) return true;
const onPage = (layout.blocks || []).filter((b) => b.page === page);
return onPage.some((b) => b.type === 'ad') && !onPage.some((b) => MAG_NARRATABLE_TYPES.includes(b.type));
}
function magPageState(arts, q, page) {
@ -881,6 +873,7 @@ function magQualityProblems(q) {
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.duplicates || []).length) bits.push((q.duplicates.length) + ' articles claim this page (read more than once)');
if (q.error) bits.push('the check itself failed');
return bits;
}
@ -910,7 +903,7 @@ function renderPagesView(host) {
const legend = [
['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'],
['approved', 'reviewed'], ['ad', 'advertisement (detected, not read)'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'],
];
let html = '<p class="mag-pages-legend">' +
legend.map(([cls, label]) => '<span class="mag-pages-legend-item"><span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + label + '</span>').join('') +
@ -933,9 +926,6 @@ function renderPagesView(host) {
'</div>').join('') + '</div>'
: '<span class="mag-pill">no article</span>' +
'<div class="mag-toc-actions"><button type="button" class="btn-sm" onclick="magReviewPage(' + p + ')">Review this page</button></div>') +
'<button type="button" class="btn-sm mag-ad-btn" aria-pressed="' + String(magIsAdPage(p)) + '" onclick="magToggleAdPage(' + p + ')" ' +
'title="Approve this whole page as an advertisement: nothing on it is read aloud">' +
'<span class="mdi mdi-bullhorn-outline" aria-hidden="true"></span> ' + (magIsAdPage(p) ? 'Advertisement ✓ (undo)' : 'Advertisement') + '</button>' +
'</div></div>';
}
html += '</div>';
@ -1651,11 +1641,6 @@ function renderReview() {
'<button type="button" class="btn-sm" id="mag-verify-text-btn" onclick="magRunPageQuality(\'recognition\')" ' +
'title="Flag suspicious words by language, then re-read their page crops before changing anything">' +
'<span class="mdi mdi-spellcheck"></span> Verify OCR text</button>' +
'<button type="button" class="btn-sm" id="mag-ad-page-btn" onclick="magToggleAdPage(' + Number(page) + ')" ' +
'aria-pressed="' + String(magIsAdPage(page)) + '" ' +
'title="Approve this whole page as an advertisement: nothing on it is read aloud and the quality check skips it">' +
'<span class="mdi mdi-bullhorn-outline" aria-hidden="true"></span> ' +
(magIsAdPage(page) ? 'Advertisement page ✓ (undo)' : 'Approve page as advertisement') + '</button>' +
'<span id="mag-reocr-status" class="muted" role="status"></span>' +
'</div>';
@ -3058,6 +3043,7 @@ function magRenderQualityReport(report) {
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.duplicates || []).length) bits.push('duplicate articles');
if (p.error) bits.push('check failed');
return '<button type="button" class="btn-sm" onclick="magReviewPage(' + Number(p.page) + ')" title="' +
esc(bits.join(', ')) + '">Page ' + Number(p.page) + ' (' + esc(bits.join(', ')) + ')</button>';
@ -3778,7 +3764,10 @@ function magPlaylistSync() {
}
renderPlaylistVisual(index);
const pct = duration ? Math.min(100, audio.currentTime / duration * 100) : 0;
if (state.driving) magDrivingSync(audio, duration, pct, index);
const span = timeline[index];
const local = span ? Math.max(0, Math.min(0.999, (audio.currentTime - span.start) /
Math.max(0.1, span.end - span.start))) : 0;
if (state.driving) magDrivingSync(audio, duration, pct, index, local);
}
async function loadPlaylistCompanion(a) {
@ -4219,7 +4208,7 @@ function magDrivingApplyToggles() {
}
// Called from magPlaylistSync on every timeupdate while the overlay is open.
function magDrivingSync(audio, duration, pct, sentenceIndex) {
function magDrivingSync(audio, duration, pct, sentenceIndex, local) {
const prefs = magDrivingPrefs();
const seek = $('mag-driving-seek');
if (seek && document.activeElement !== seek) seek.value = String(Math.round(pct * 10));
@ -4229,12 +4218,32 @@ function magDrivingSync(audio, duration, pct, sentenceIndex) {
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);
if (!lyrics.hidden) {
// Whole article as karaoke text: built once per article, then only the current-sentence and
// current-word classes move. Word timing is estimated from the MP3 position, like the playlist.
const key = state.playlistCurrent + ':' + sentences.length;
if (lyrics.dataset.key !== key) {
lyrics.dataset.key = key;
lyrics.dataset.index = '';
lyrics.innerHTML = sentences.map((sent, i) =>
'<p class="mag-driving-line" data-i="' + i + '">' +
(sent.words || [sent.text]).map((w) => '<span class="mag-dw">' + esc(w) + '</span>').join(' ') + '</p>').join('');
}
if (lyrics.dataset.index !== String(sentenceIndex)) {
lyrics.dataset.index = String(sentenceIndex);
lyrics.querySelectorAll('.mag-driving-line.is-current').forEach((el) => el.classList.remove('is-current'));
const line = lyrics.querySelector('.mag-driving-line[data-i="' + sentenceIndex + '"]');
if (line) {
line.classList.add('is-current');
// Scroll the box itself (not the page) so the current sentence sits in the middle.
const target = line.offsetTop - (lyrics.clientHeight - line.offsetHeight) / 2;
lyrics.scrollTo({ top: Math.max(0, target), behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}
}
const line = lyrics.querySelector('.mag-driving-line.is-current');
lyrics.querySelectorAll('.mag-dw.is-current').forEach((w) => w.classList.remove('is-current'));
const words = line ? line.querySelectorAll('.mag-dw') : [];
if (words.length) words[Math.min(words.length - 1, Math.floor((local || 0) * words.length))].classList.add('is-current');
}
}
const visual = $('mag-driving-visual');
@ -4362,7 +4371,6 @@ root.magReocrPage = magReocrPage;
root.magRefineFigures = magRefineFigures;
root.magRefineAllFigures = magRefineAllFigures;
root.magQualityRun = magQualityRun;
root.magToggleAdPage = magToggleAdPage;
root.magReviewToggleImage = magReviewToggleImage;
root.magDescribeImage = magDescribeImage;
root.magSaveImageDescription = magSaveImageDescription;
@ -4374,6 +4382,12 @@ root.magReviewCancelEditPanel = magReviewCancelEditPanel;
root.magReviewSubmitEdit = magReviewSubmitEdit;
root.magReviewDeleteRegion = magReviewDeleteRegion;
root.magReviewChangeType = magReviewChangeType;
root.magRegionPickerFocus = magRegionPickerFocus;
root.magRegionPickerInput = magRegionPickerInput;
root.magRegionPickerBlur = magRegionPickerBlur;
root.magRegionPickerKeydown = magRegionPickerKeydown;
root.magRegionPickerChoose = magRegionPickerChoose;
root.magRunPageQuality = magRunPageQuality;
root.magFixReadingOrder = magFixReadingOrder;
root.magCleanUpBoxes = magCleanUpBoxes;
root.magRowEdit = magRowEdit;

View File

@ -286,11 +286,7 @@
<span id="mag-queue-progress-2" class="muted"></span>
</div>
<figure id="mag-driving-visual" class="mag-driving-visual" hidden></figure>
<div id="mag-driving-lyrics" class="mag-driving-lyrics" aria-live="off" hidden>
<p class="mag-driving-line is-prev" id="mag-driving-line-prev"></p>
<p class="mag-driving-line is-current" id="mag-driving-line-current"></p>
<p class="mag-driving-line is-next" id="mag-driving-line-next"></p>
</div>
<div id="mag-driving-lyrics" class="mag-driving-lyrics" tabindex="0" aria-label="Article text, following the audio" hidden></div>
<div class="mag-driving-seek">
<input type="range" id="mag-driving-seek" min="0" max="1000" value="0" step="1"
aria-label="Position in article" oninput="magDrivingSeek(this.value)">

View File

@ -5421,7 +5421,6 @@ input[list="lang-codes"] {
.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; }
@ -5443,3 +5442,12 @@ input[list="lang-codes"] {
.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; } }
/* Driving mode karaoke text: whole article, current sentence + word highlighted */
.mag-driving-lyrics { max-height: 34vh; overflow-y: auto; text-align: left; padding: 8px 14px; border-radius: 12px;
background: color-mix(in srgb, var(--surface) 70%, transparent); scroll-behavior: auto; }
.mag-driving-lyrics .mag-driving-line { margin: 0 0 12px; font-size: clamp(18px, 2.6vw, 26px); opacity: .55; color: var(--text); font-weight: 400; }
.mag-driving-lyrics .mag-driving-line.is-current { opacity: 1; font-weight: 600; font-size: clamp(20px, 3vw, 30px);
border-left: 4px solid var(--accent); padding-left: 10px; }
.mag-dw.is-current { background: var(--accent); color: var(--on-accent, #fff); border-radius: 4px; padding: 0 3px; }
.mag-driving-lyrics { position: relative; }

View File

@ -0,0 +1,18 @@
// Every function named in an inline on*="..." handler (in magazine.js markup or the section HTML)
// must be exported on `root` in magazine.js -- an unexported one fails silently in the browser
// (e.g. the region-type picker never opening).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const js = readFileSync(new URL('../static/js/magazine.js', import.meta.url), 'utf8');
const html = readFileSync(new URL('../static/sections/s-magazine.html', import.meta.url), 'utf8');
const exported = new Set([...js.matchAll(/^root\.(\w+)\s*=/gm)].map((m) => m[1]));
const handlerCalls = (text) => [...text.matchAll(/\son(?:click|change|input|focus|blur|keydown|keyup|mousedown|submit)=\\?["']([^"']*)/g)]
.flatMap((m) => [...m[1].matchAll(/\b(mag\w+)\s*\(/g)].map((c) => c[1]));
test('every mag* inline handler is exported on window', () => {
const used = new Set([...handlerCalls(js), ...handlerCalls(html)]);
const missing = [...used].filter((name) => !exported.has(name)).sort();
assert.deepEqual(missing, []);
});

View File

@ -2517,3 +2517,71 @@ class AdvertisementPageTests(unittest.TestCase):
"images": []}
out = article_text(layout, {"blocks": ["a", "b"], "pages": [1, 2]})
self.assertEqual([b["id"] for b in out], ["a"])
class DetectedAdvertisementPageTests(unittest.TestCase):
def _layout(self, types):
return {"pages": [{"page": 1}],
"blocks": [{"id": f"b{i}", "page": 1, "type": t, "text": "x", "reading_order": i}
for i, t in enumerate(types)],
"images": []}
def test_full_page_ad_is_detected_without_manual_approval(self):
from core.magazine import page_is_advertisement
self.assertTrue(page_is_advertisement(self._layout(["ad", "furniture"]), 1))
def test_page_with_article_text_is_not_an_ad(self):
from core.magazine import page_is_advertisement
self.assertFalse(page_is_advertisement(self._layout(["ad", "body"]), 1))
self.assertFalse(page_is_advertisement(self._layout(["furniture"]), 1))
def test_detected_ad_page_is_not_narrated(self):
from core.magazine import article_text
layout = self._layout(["ad", "body"])
layout["blocks"][1]["type"] = "ad" # only ads on the page
layout["blocks"].append({"id": "t", "page": 1, "type": "body", "text": "Real.", "reading_order": 9})
# a narratable block means the page is not a pure ad, so it is still read
self.assertEqual([b["id"] for b in article_text(layout, {"blocks": ["t"]})], ["t"])
class DuplicateArticleGroupTests(unittest.TestCase):
def test_articles_sharing_their_regions_are_grouped(self):
from core.magazine import duplicate_article_groups
arts = [{"id": "a", "blocks": ["1", "2", "3", "4"]},
{"id": "b", "blocks": ["1", "2", "3", "4"]}, # same page approved again
{"id": "c", "blocks": ["3", "9"]}, # shares 1 of 2 -> also a duplicate of a
{"id": "d", "blocks": ["7", "8"]},
{"id": "e", "blocks": []}]
self.assertEqual([sorted(g) for g in duplicate_article_groups(arts)], [["a", "b", "c"]])
def test_distinct_articles_are_not_grouped(self):
from core.magazine import duplicate_article_groups
self.assertEqual(duplicate_article_groups(
[{"id": "a", "blocks": ["1", "2"]}, {"id": "b", "blocks": ["3", "4"]}]), [])
class ClippedEdgeTests(unittest.TestCase):
def test_text_running_past_the_bottom_and_right_edges_is_reported(self):
import numpy as np
from core.magazine import _clipped_edges
pixels = np.zeros((200, 200), dtype=bool)
pixels[20:100:6, 20:100] = True # ruled "text" lines, 6px pitch
pixels[100:112:6, 20:100] = True # an extra line below the box
pixels[20:100, 100:110] = True # ink running past the right edge
meta = {"width": 200, "height": 200}
block = {"id": "b", "bbox": [20, 20, 80, 80]}
sides = {c["side"] for c in _clipped_edges(pixels, meta, [block], [block["bbox"]])}
self.assertIn("right", sides)
self.assertNotIn("left", sides)
def test_neighbouring_box_is_not_clipping(self):
import numpy as np
from core.magazine import _clipped_edges
pixels = np.zeros((200, 200), dtype=bool)
pixels[20:100, 20:100] = True
pixels[20:100, 100:190] = True
meta = {"width": 200, "height": 200}
left = {"id": "l", "bbox": [20, 20, 80, 80]}
right = {"id": "r", "bbox": [100, 20, 90, 80]}
found = _clipped_edges(pixels, meta, [left, right], [left["bbox"], right["bbox"]])
self.assertEqual([c for c in found if c["id"] == "l" and c["side"] == "right"], [])