From 48c546fbc8574064612439a51e81fcdac9e978fa Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 26 Sep 2026 23:07:19 +0200 Subject: [PATCH] fix(magazine): region-type picker and page-quality buttons work; stronger quality audit - 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 --- CHANGELOG.md | 8 ++- Manual.md | 7 +- architecture/magazine-ingestion.md | 2 +- core/magazine.py | 111 +++++++++++++++++++++++------ routes/magazine.py | 12 +++- static/js/magazine.js | 82 ++++++++++++--------- static/sections/s-magazine.html | 6 +- static/style.css | 10 ++- tests/magazine-handlers.test.mjs | 18 +++++ tests/test_magazine.py | 68 ++++++++++++++++++ 10 files changed, 256 insertions(+), 68 deletions(-) create mode 100644 tests/magazine-handlers.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 81aa5e7..ffc4e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. --- diff --git a/Manual.md b/Manual.md index 4748e23..93788b6 100644 --- a/Manual.md +++ b/Manual.md @@ -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. diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md index a9639ed..1fc5d6a 100644 --- a/architecture/magazine-ingestion.md +++ b/architecture/magazine-ingestion.md @@ -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. diff --git a/core/magazine.py b/core/magazine.py index f60bb47..1b7b8aa 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -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 = [ diff --git a/routes/magazine.py b/routes/magazine.py index be7ac4e..4ae3878 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -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") diff --git a/static/js/magazine.js b/static/js/magazine.js index c56fea7..ddfafb8 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -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 = '

' + legend.map(([cls, label]) => '' + label + '').join('') + @@ -933,9 +926,6 @@ function renderPagesView(host) { '').join('') + '' : 'no article' + '

') + - '' + ''; } html += ''; @@ -1651,11 +1641,6 @@ function renderReview() { '' + - '' + '' + ''; @@ -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 ''; @@ -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) => + '

' + + (sent.words || [sent.text]).map((w) => '' + esc(w) + '').join(' ') + '

').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; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index f903281..5e1e82f 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -286,11 +286,7 @@ - +
diff --git a/static/style.css b/static/style.css index 409c6a4..16165b8 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/tests/magazine-handlers.test.mjs b/tests/magazine-handlers.test.mjs new file mode 100644 index 0000000..fc35125 --- /dev/null +++ b/tests/magazine-handlers.test.mjs @@ -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, []); +}); diff --git a/tests/test_magazine.py b/tests/test_magazine.py index 412a3aa..d15328b 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -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"], [])