feat(magazine): text flow indicator (arrows, chips, reading-view seams); Auto-fix all pages with backup

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 10:46:55 +02:00
parent b85f40e263
commit d0fba6a16f
9 changed files with 271 additions and 8 deletions

View File

@ -21,6 +21,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Review shows what is wrong with each page, on the page.** A *What is wrong on this page* panel (measured live from the page render, not from the last run) explains each problem in plain language with its own fix button, and marks it on the boxes: red dashed frame = text outside any box, orange edge = a box edge cutting through text, purple dashed = two boxes over the same text, yellow dotted = words that look mis-read, red wavy underline = unknown words. **Fix this page automatically** fits boxes, adds inset pull quotes, cuts paragraphs that wrap around them, finds missed text, re-reads suspicious words and (with a language model) fixes provable misspellings, with one Undo. - **Review shows what is wrong with each page, on the page.** A *What is wrong on this page* panel (measured live from the page render, not from the last run) explains each problem in plain language with its own fix button, and marks it on the boxes: red dashed frame = text outside any box, orange edge = a box edge cutting through text, purple dashed = two boxes over the same text, yellow dotted = words that look mis-read, red wavy underline = unknown words. **Fix this page automatically** fits boxes, adds inset pull quotes, cuts paragraphs that wrap around them, finds missed text, re-reads suspicious words and (with a language model) fixes provable misspellings, with one Undo.
- **The whole-magazine run now fixes as well as checks:** *Quality check / run* also fits boxes, finds inset pull quotes and cuts wrapped paragraphs on every page before the missed-text, re-read and spelling steps, and reports how many boxes it fixed. - **The whole-magazine run now fixes as well as checks:** *Quality check / run* also fits boxes, finds inset pull quotes and cuts wrapped paragraphs on every page before the missed-text, re-read and spelling steps, and reports how many boxes it fixed.
- **Text flow indicator.** Review draws a green arrow where an article's text carries on to the next box below or the next column, and chips where it continues on another page (*continues on page 111 ›*, *‹ from page 110*) plus *article starts / ends*; a *Show text flow* switch hides them. The reading text shows small markers (`↓`, `col ›`, `p.111 ›`) at the exact word where a paragraph crosses a box, column or page. Built from the same fusion the narration uses (`GET .../articles/{id}/flow`), so the arrows show what is read as one paragraph. On the measured issue: 97 column, 29 page and 21 below continuations.
- **The whole-magazine button is now named *Auto-fix all pages*** (it fixes boxes, adds pull quotes, finds missed text, re-reads words and fixes provable misspellings on every page) and keeps a layout backup first.
### Fixed ### Fixed
- **The review list shows each region's whole text**, not the first 220 characters, so every misspelling gets its red wavy underline (like a word processor). The engine's `[Non-Text]` marker and hairline boxes (under 3 pt tall) are now treated as OCR noise by *Clean up boxes* and after a page re-OCR, so they no longer show up as near-invisible regions. Fitted boxes keep a little air on grown sides. - **The review list shows each region's whole text**, not the first 220 characters, so every misspelling gets its red wavy underline (like a word processor). The engine's `[Non-Text]` marker and hairline boxes (under 3 pt tall) are now treated as OCR noise by *Clean up boxes* and after a page re-OCR, so they no longer show up as near-invisible regions. Fitted boxes keep a little air on grown sides.
- **The quality check no longer flags correct text as "suspect OCR".** The word check guessed the language per fragment (`iPhones statt Token` read as English) and flagged valid German; it now uses the page's language, the same lenient German rules as the spellchecker, and treats words that recur on the page (names, brands) as vocabulary. Page 61 of the measured issue went from 11 suspect regions to 3 (one a genuinely truncated word), and the issue from 758 to 610 before the language fix's full effect. - **The quality check no longer flags correct text as "suspect OCR".** The word check guessed the language per fragment (`iPhones statt Token` read as English) and flagged valid German; it now uses the page's language, the same lenient German rules as the spellchecker, and treats words that recur on the page (names, brands) as vocabulary. Page 61 of the measured issue went from 11 suspect regions to 3 (one a genuinely truncated word), and the issue from 758 to 610 before the language fix's full effect.

View File

@ -287,7 +287,8 @@ unreviewed boundaries may include a neighbouring article or an advert.
**Purpose:** see at a glance what is wrong with a page and fix it without hunting for the right button. **Purpose:** see at a glance what is wrong with a page and fix it without hunting for the right button.
**Steps:** open a page in review. The **What is wrong on this page** panel lists each problem in plain language and marks it on the page: **red dashed frame** = printed text outside any box (never read); **orange edge** = a box edge cutting through text; **purple dashed outline** = two boxes over the same text; **yellow dotted outline** = words that look mis-read; **red wavy underline** (in the list) = words no dictionary knows. Each row has its own button (*Find missed text*, *Fit boxes to the text*, *Verify OCR text*, *Fix with language model*). **Fix this page automatically** does all of them in one go: fits boxes, adds an inset pull quote, cuts a paragraph that wraps around it, finds missed text, re-reads suspicious words and, when a language model is configured, fixes provable misspellings. **Steps:** open a page in review. The **What is wrong on this page** panel lists each problem in plain language and marks it on the page: **red dashed frame** = printed text outside any box (never read); **orange edge** = a box edge cutting through text; **purple dashed outline** = two boxes over the same text; **yellow dotted outline** = words that look mis-read; **red wavy underline** (in the list) = words no dictionary knows. Each row has its own button (*Find missed text*, *Fit boxes to the text*, *Verify OCR text*, *Fix with language model*). **Fix this page automatically** does all of them in one go: fits boxes, adds an inset pull quote, cuts a paragraph that wraps around it, finds missed text, re-reads suspicious words and, when a language model is configured, fixes provable misspellings.
**Result:** the page reloads with what was fixed; **Undo** (above the list) restores the page as it was. Overlapping boxes have no automatic fix: delete or merge one. **Result:** the page reloads with what was fixed; **Undo** (above the list) restores the page as it was. Overlapping boxes have no automatic fix: delete or merge one.
**Whole magazine:** **Quality check / run** in the contents view now applies the same fixes to every page and then lists the pages that still need a look (purple badges). **Text flow:** green arrows and chips on the page show where the article carries on: an arrow to the next box below, a curved *next column* arrow, and a chip *continues on page N ›* / *‹ from page N*, plus *article starts / ends*. Tick or untick **Show text flow** to hide them. In the reading view a small marker (`↓`, `col ›`, `p.N ›`) sits at the word where a paragraph crosses a box, column or page. The markers are labelled for screen readers.
**Whole magazine:** **Auto-fix all pages** (contents view, formerly *Quality check / run*) applies the same fixes to every page and then lists the pages that still need a look (purple badges).
**Errors:** a failed fix shows the reason and changes nothing. **Offline / privacy:** the box fixes and the audit are local; the OCR re-read and spelling steps use only the engines you configured. **Errors:** a failed fix shows the reason and changes nothing. **Offline / privacy:** the box fixes and the audit are local; the OCR re-read and spelling steps use only the engines you configured.
### Clean up boxes: fitting boxes to the text ### Clean up boxes: fitting boxes to the text

View File

@ -4621,6 +4621,34 @@ def fuse_paragraph_blocks(blocks: list[dict[str, Any]], language: str = "auto",
return out return out
def article_flow(layout: dict[str, Any], article: dict[str, Any]) -> dict[str, Any]:
"""Where an article's text carries on from one region to the next.
Built from the same fusion the narration uses, so what is drawn is exactly what is read as one
paragraph. Each link says how the reader's eye has to travel: `below` (the next region under
it, same column), `column` (top of another column on the same page) or `page` (another page).
Also returns the article's first and last regions.
"""
fused = article_text(layout, article, fuse=True)
links: list[dict[str, Any]] = []
for block in fused:
parts = block.get("parts") or []
for a, b in zip(parts, parts[1:]):
if a["page"] != b["page"]:
relation = "page"
else:
ax, aw = a["bbox"][0], a["bbox"][2]
bx, bw = b["bbox"][0], b["bbox"][2]
same_column = abs((ax + aw / 2) - (bx + bw / 2)) <= 0.5 * min(aw, bw)
relation = "below" if same_column and b["bbox"][1] >= a["bbox"][1] else "column"
links.append({"from": a["id"], "to": b["id"], "relation": relation,
"from_page": a["page"], "to_page": b["page"]})
narrated = [b for b in fused if b.get("type") in NARRATABLE]
return {"links": links,
"first": (narrated[0].get("parts") or [{"id": narrated[0]["id"]}])[0]["id"] if narrated else None,
"last": (narrated[-1].get("parts") or [{"id": narrated[-1]["id"]}])[-1]["id"] if narrated else None}
# ── Standalone audio (Stage E, optional) ───────────────────────────────────── # ── Standalone audio (Stage E, optional) ─────────────────────────────────────
# The in-browser reader synthesises sentence-by-sentence into memory and never # The in-browser reader synthesises sentence-by-sentence into memory and never
# writes a file -- fine for reading inside this app, useless for a car head # writes a file -- fine for reading inside this app, useless for a car head

View File

@ -623,6 +623,17 @@ async def magazine_article_text(doc_id: str, article_id: str,
return {"article": target, "blocks": blocks} return {"article": target, "blocks": blocks}
@router.get("/api/magazine/{doc_id}/articles/{article_id}/flow")
async def magazine_article_flow(doc_id: str, article_id: str):
"""How the article's text continues between regions (below / next column / next page)."""
d, _ = _require_magazine(doc_id)
articles = mag.load_articles(d)
target = next((a for a in articles if a.get("id") == _article_id(article_id)), None)
if target is None:
raise HTTPException(404, "Article not found")
return mag.article_flow(mag.load_layout(d), target)
@router.patch("/api/magazine/{doc_id}/progress") @router.patch("/api/magazine/{doc_id}/progress")
async def magazine_progress(doc_id: str, request: Request): async def magazine_progress(doc_id: str, request: Request):
"""Per-article resume position, so each article remembers its own place.""" """Per-article resume position, so each article remembers its own place."""
@ -1031,6 +1042,9 @@ def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> Non
layout = mag.load_layout(d) layout = mag.load_layout(d)
if not layout: if not layout:
raise FileNotFoundError("No layout yet -- run ingestion first") raise FileNotFoundError("No layout yet -- run ingestion first")
# The run edits boxes and text on every page: keep the layout as it was first.
(d / f"layout.backup-{time.strftime('%Y%m%dT%H%M%S')}.json").write_text(
json.dumps(layout, ensure_ascii=False), "utf-8")
url = str(settings.get("vision_ocr_url") or "").strip() or None url = str(settings.get("vision_ocr_url") or "").strip() or None
model = str(settings.get("vision_ocr_model") 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_url = str(settings.get("vision_ocr_fallback_url") or "").strip() or None

View File

@ -44,6 +44,8 @@ const state = {
spellByBlock: new Map(), spellByBlock: new Map(),
spellOpen: false, spellOpen: false,
audit: null, // live audit of the reviewed page ({page, data}) audit: null, // live audit of the reviewed page ({page, data})
flow: null, // how the open article's text continues between regions
flowOn: true, // show the flow arrows/chips in review
acronyms: null, // the open article's glossary state acronyms: null, // the open article's glossary state
queue: [], // article ids for "play all" (empty = not queued) queue: [], // article ids for "play all" (empty = not queued)
queueIndex: -1, queueIndex: -1,
@ -1729,6 +1731,7 @@ function renderReview() {
magReviewAutofillTitleAuthor(); magReviewAutofillTitleAuthor();
magSpellLoadPage(); magSpellLoadPage();
magAuditLoad(); magAuditLoad();
magFlowLoad();
} }
// A Headline or Author region belonging to this article is exactly what its // A Headline or Author region belonging to this article is exactly what its
@ -3089,7 +3092,7 @@ async function magQualityRun() {
jobId = (await api('/api/magazine/' + state.doc.id + '/quality/run', { method: 'POST' })).job_id; jobId = (await api('/api/magazine/' + state.doc.id + '/quality/run', { method: 'POST' })).job_id;
} catch (e) { } catch (e) {
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
if (label) label.textContent = 'Quality check / run'; if (label) label.textContent = 'Auto-fix all pages';
if (box) box.hidden = true; if (box) box.hidden = true;
return say('Could not start the quality check: ' + e.message, 'err'); return say('Could not start the quality check: ' + e.message, 'err');
} }
@ -3108,7 +3111,7 @@ async function magQualityRun() {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
} }
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
if (label) label.textContent = 'Quality check / run'; if (label) label.textContent = 'Auto-fix all pages';
magMarkLive(null); magMarkLive(null);
if (failed) { if (box) box.textContent = 'Quality check failed — see the message above.'; return; } if (failed) { if (box) box.textContent = 'Quality check failed — see the message above.'; return; }
state.layout = null; state.layout = null;
@ -3183,6 +3186,90 @@ async function magSetStatus(status) {
} }
} }
// ── Text flow: where the article carries on ───────────────────────────────────
// Server side (core.magazine.article_flow) uses the same fusion as narration, so the arrows show
// exactly what is read as one paragraph: below (same column), column (top of another column) or
// page (another page).
const MAG_FLOW_LABEL = { below: 'continues below', column: 'continues in the next column', page: 'continues on page' };
async function magFlowLoad() {
const a = state.article;
if (!a || !state.doc) return;
try {
state.flow = { article: a.id, ...(await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/flow')) };
} catch (_) { state.flow = null; }
magFlowPaint();
}
function magFlowToggle(on) {
state.flowOn = !!on;
magFlowPaint();
}
function magFlowPaint() {
const wrap = $('mag-review-page-wrap');
document.querySelectorAll('#mag-review .mag-flow-chip, #mag-review .mag-flow-svg').forEach((el) => el.remove());
if (!wrap || !state.flow || !state.flowOn || state.view !== 'review') return;
const page = state.reviewPage;
const boxOf = (id) => wrap.querySelector('.mag-review-box[data-block="' + CSS.escape(id) + '"]');
const chip = (id, where, text, cls) => {
const el = boxOf(id);
if (!el) return;
const c = document.createElement('span');
c.className = 'mag-flow-chip is-' + where + ' ' + cls;
c.textContent = text;
el.appendChild(c);
};
if (state.flow.first) chip(state.flow.first, 'top', 'article starts', 'is-edge');
if (state.flow.last) chip(state.flow.last, 'bottom', 'article ends', 'is-edge');
const arrows = [];
(state.flow.links || []).forEach((l) => {
if (l.relation === 'page') {
if (l.from_page === page) chip(l.from, 'bottom', 'continues on page ' + l.to_page + ' ›', 'is-page');
if (l.to_page === page) chip(l.to, 'top', '‹ from page ' + l.from_page, 'is-page');
} else if (l.from_page === page) {
arrows.push(l);
}
});
if (!arrows.length) return;
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('class', 'mag-flow-svg');
svg.setAttribute('aria-hidden', 'true');
const W = wrap.clientWidth, H = wrap.clientHeight;
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
svg.innerHTML = '<defs><marker id="mag-flow-head" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">' +
'<path d="M0,0 L10,5 L0,10 z" fill="#16a34a"/></marker></defs>';
const wr = wrap.getBoundingClientRect();
arrows.forEach((l) => {
const a = boxOf(l.from), b = boxOf(l.to);
if (!a || !b) return;
const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
const ax = ra.left - wr.left + ra.width / 2, ay = ra.bottom - wr.top - 6;
const bx = rb.left - wr.left + rb.width / 2, by = rb.top - wr.top + 6;
const path = document.createElementNS(svgNS, 'path');
const bend = l.relation === 'column' ? Math.max(30, Math.abs(by - ay) * 0.35) : 0;
path.setAttribute('d', l.relation === 'column'
? 'M' + ax + ',' + ay + ' C' + ax + ',' + (ay + bend) + ' ' + bx + ',' + (by - bend) + ' ' + bx + ',' + by
: 'M' + ax + ',' + ay + ' L' + bx + ',' + by);
path.setAttribute('class', 'mag-flow-path');
path.setAttribute('marker-end', 'url(#mag-flow-head)');
svg.appendChild(path);
const label = document.createElementNS(svgNS, 'text');
label.setAttribute('class', 'mag-flow-label');
label.setAttribute('x', (ax + bx) / 2 + 6);
label.setAttribute('y', (ay + by) / 2);
label.textContent = l.relation === 'column' ? 'next column' : '↓';
svg.appendChild(label);
});
wrap.appendChild(svg);
if (!wrap._magFlowObserver && typeof ResizeObserver === 'function') {
wrap._magFlowObserver = new ResizeObserver(() => { wrap.classList.toggle('has-room', wrap.clientWidth >= 640); if (state.flowOn) magFlowPaint(); });
wrap._magFlowObserver.observe(wrap);
}
}
// ── What is wrong on this page: markers on the boxes + one-click fixes ────────── // ── What is wrong on this page: markers on the boxes + one-click fixes ──────────
// The audit is measured live from the page render (GET .../pages/N/audit), not read from the last // The audit is measured live from the page render (GET .../pages/N/audit), not read from the last
// whole-issue run, so it always matches what is on screen. Each kind of problem has its own // whole-issue run, so it always matches what is on screen. Each kind of problem has its own
@ -3239,6 +3326,9 @@ function magAuditPaint() {
el.removeAttribute('data-issue'); el.removeAttribute('data-issue');
}); });
const panel = $('mag-issues-panel'); const panel = $('mag-issues-panel');
// Labels inside boxes only fit on a large page image; a small one keeps just the outlines (and
// the label on hover), and the panel explains the colours.
if (wrap) wrap.classList.toggle('has-room', wrap.clientWidth >= 640);
if (!audit || !panel) return; if (!audit || !panel) return;
const boxOf = (id) => document.querySelector('#mag-review-page-wrap .mag-review-box[data-block="' + CSS.escape(id) + '"]'); const boxOf = (id) => document.querySelector('#mag-review-page-wrap .mag-review-box[data-block="' + CSS.escape(id) + '"]');
const tag = (el, cls, text) => { const tag = (el, cls, text) => {
@ -3270,7 +3360,9 @@ function magAuditPaint() {
'<button type="button" class="btn-sm primary" id="mag-autofix-btn" onclick="magAutofixPage(\'all\')" ' + '<button type="button" class="btn-sm primary" id="mag-autofix-btn" onclick="magAutofixPage(\'all\')" ' +
'title="Fits boxes to their text, adds inset pull quotes, cuts paragraphs that wrap around them, finds missed text, re-reads suspicious words and (with a language model) fixes provable misspellings. One Undo brings the page back.">' + 'title="Fits boxes to their text, adds inset pull quotes, cuts paragraphs that wrap around them, finds missed text, re-reads suspicious words and (with a language model) fixes provable misspellings. One Undo brings the page back.">' +
'<span class="mdi mdi-auto-fix" aria-hidden="true"></span> Fix this page automatically</button>' + '<span class="mdi mdi-auto-fix" aria-hidden="true"></span> Fix this page automatically</button>' +
'<span id="mag-issues-status" class="muted" role="status"></span></div>'; '<span id="mag-issues-status" class="muted" role="status"></span>' +
'<label class="mag-flow-toggle" title="Green arrows show where the article\'s text carries on: below, in the next column or on another page">' +
'<input type="checkbox" id="mag-flow-toggle" ' + (state.flowOn ? 'checked' : '') + ' onchange="magFlowToggle(this.checked)"> Show text flow</label></div>';
if (total) { if (total) {
html += '<ul class="mag-issues-list">' + found.map((k) => { html += '<ul class="mag-issues-list">' + found.map((k) => {
const n = magIssueCount(audit, k.key); const n = magIssueCount(audit, k.key);
@ -3581,7 +3673,24 @@ function splitSentences(blocks) {
const src = b.parts.find((p) => at >= p.start && at < p.end) || b.parts[0]; const src = b.parts.find((p) => at >= p.start && at < p.end) || b.parts[0];
origin = { id: src.id, page: src.page, bbox: src.bbox }; origin = { id: src.id, page: src.page, bbox: src.bbox };
} }
// Where a fused paragraph crosses from one region to the next, mark the word after which
// the print continues (below / in another column / on another page).
let seams = null;
if (Array.isArray(b.parts) && b.parts.length > 1) {
const start = String(b.text || '').indexOf(text.trim(), Math.max(0, cursor - text.trim().length));
b.parts.slice(1).forEach((part, k) => {
const prev = b.parts[k];
if (start >= 0 && part.start > start && part.start < start + text.trim().length) {
const before = text.trim().slice(0, part.start - start).split(/\s+/).filter(Boolean).length;
const relation = prev.page !== part.page ? 'page'
: (Math.abs((prev.bbox[0] + prev.bbox[2] / 2) - (part.bbox[0] + part.bbox[2] / 2)) <= 0.5 * Math.min(prev.bbox[2], part.bbox[2])
&& part.bbox[1] >= prev.bbox[1]) ? 'below' : 'column';
(seams = seams || []).push({ afterWord: before - 1, relation, page: part.page });
}
});
}
out.push({ out.push({
seams,
text: text.trim(), blockId: origin.id, page: origin.page, bbox: origin.bbox, type: b.type, text: text.trim(), blockId: origin.id, page: origin.page, bbox: origin.bbox, type: b.type,
spokenText: spokenParts[partIndex] || text.trim(), spokenText: spokenParts[partIndex] || text.trim(),
// Used to pick "the image reached so far" during playback (see // Used to pick "the image reached so far" during playback (see
@ -3692,8 +3801,15 @@ function renderArticle() {
// Each word gets its own span so playback can highlight it individually // Each word gets its own span so playback can highlight it individually
// (see estimateWordTimings) -- sentence-level highlighting alone doesn't // (see estimateWordTimings) -- sentence-level highlighting alone doesn't
// need this, but word-level does and this markup serves both. // need this, but word-level does and this markup serves both.
const wordsHtml = s.words.map((w, j) => const wordsHtml = s.words.map((w, j) => {
'<span class="mag-w" data-w="' + j + '">' + esc(w) + '</span>').join(' '); const seam = (s.seams || []).find((m) => m.afterWord === j);
const marker = seam
? '<span class="mag-seam is-' + seam.relation + '" role="note" title="' + esc(seam.relation === 'page' ? 'Continues on page ' + seam.page
: seam.relation === 'column' ? 'Continues in the next column' : 'Continues in the next box below') + '">' +
(seam.relation === 'page' ? 'p.' + Number(seam.page) + ' ›' : seam.relation === 'column' ? 'col ›' : '↓') + '</span>'
: '';
return '<span class="mag-w" data-w="' + j + '">' + esc(w) + '</span>' + marker;
}).join(' ');
return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' + return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' +
'onclick="magSeek(' + i + ')">' + wordsHtml + '</' + open + '> '; 'onclick="magSeek(' + i + ')">' + wordsHtml + '</' + open + '> ';
}).join(''); }).join('');
@ -4829,6 +4945,7 @@ root.magDrivingCycleSpeed = magDrivingCycleSpeed;
root.magDrivingToggleText = magDrivingToggleText; root.magDrivingToggleText = magDrivingToggleText;
root.magDrivingToggleImages = magDrivingToggleImages; root.magDrivingToggleImages = magDrivingToggleImages;
root.magDrivingPrevious = magDrivingPrevious; root.magDrivingPrevious = magDrivingPrevious;
root.magFlowToggle = magFlowToggle;
root.magAutofixPage = magAutofixPage; root.magAutofixPage = magAutofixPage;
root.magSpellOpenAndFix = magSpellOpenAndFix; root.magSpellOpenAndFix = magSpellOpenAndFix;
root.magSpellToggle = magSpellToggle; root.magSpellToggle = magSpellToggle;

View File

@ -66,7 +66,7 @@
<div class="mag-bar-actions"> <div class="mag-bar-actions">
<button type="button" class="btn-sm" id="mag-quality-run-btn" onclick="magQualityRun()" <button type="button" class="btn-sm" id="mag-quality-run-btn" onclick="magQualityRun()"
title="Fix every page automatically (boxes, inset pull quotes, missed text, misread words, spelling) and list the pages that still need a look"> title="Fix every page automatically (boxes, inset pull quotes, missed text, misread words, spelling) and list the pages that still need a look">
<span class="mdi mdi-clipboard-check-outline"></span> <span class="mag-qrun-label">Quality check / run</span> <span class="mdi mdi-clipboard-check-outline"></span> <span class="mag-qrun-label">Auto-fix all pages</span>
</button> </button>
<button type="button" class="btn-sm" id="mag-contents-view-toggle" onclick="magToggleContentsView()" title="Switch between an article list and a page-by-page thumbnail grid"> <button type="button" class="btn-sm" id="mag-contents-view-toggle" onclick="magToggleContentsView()" title="Switch between an article list and a page-by-page thumbnail grid">
<span class="mdi mdi-view-grid-outline"></span> Thumbnails <span class="mdi mdi-view-grid-outline"></span> Thumbnails

View File

@ -5487,7 +5487,7 @@ input[list="lang-codes"] {
.mag-issue-swatch.is-spell { border: 0; background: repeating-linear-gradient(135deg, transparent 0 3px, #d93025 3px 5px); height: 4px; margin-top: 8px; } .mag-issue-swatch.is-spell { border: 0; background: repeating-linear-gradient(135deg, transparent 0 3px, #d93025 3px 5px); height: 4px; margin-top: 8px; }
.mag-issue-box { position: absolute; pointer-events: none; z-index: 3; box-sizing: border-box; } .mag-issue-box { position: absolute; pointer-events: none; z-index: 3; box-sizing: border-box; }
.mag-issue-box.is-uncovered { border: 3px dashed #dc2626; background: rgba(220,38,38,.10); } .mag-issue-box.is-uncovered { border: 3px dashed #dc2626; background: rgba(220,38,38,.10); }
.mag-issue-box::after, .mag-review-box[data-issue]::after { content: attr(data-issue); position: absolute; left: -2px; bottom: 100%; max-width: 240px; .mag-issue-box::after, .mag-review-box[data-issue]::after { display: none; content: attr(data-issue); position: absolute; left: -2px; bottom: 100%; max-width: 240px;
padding: 1px 5px; font-size: 11px; line-height: 1.3; color: #fff; background: #b91c1c; border-radius: 3px 3px 0 0; white-space: nowrap; pointer-events: none; } padding: 1px 5px; font-size: 11px; line-height: 1.3; color: #fff; background: #b91c1c; border-radius: 3px 3px 0 0; white-space: nowrap; pointer-events: none; }
.mag-review-box.is-issue-clipped[data-issue]::after { background: #c2410c; } .mag-review-box.is-issue-clipped[data-issue]::after { background: #c2410c; }
.mag-review-box.is-issue-overlap[data-issue]::after { background: #7e22ce; } .mag-review-box.is-issue-overlap[data-issue]::after { background: #7e22ce; }
@ -5498,3 +5498,21 @@ input[list="lang-codes"] {
.mag-review-box.is-issue-clipped.side-left { box-shadow: -5px 0 0 0 #f97316; } .mag-review-box.is-issue-clipped.side-left { box-shadow: -5px 0 0 0 #f97316; }
.mag-review-box.is-issue-clipped.side-top { box-shadow: 0 -5px 0 0 #f97316; } .mag-review-box.is-issue-clipped.side-top { box-shadow: 0 -5px 0 0 #f97316; }
.mag-review-box.is-issue-clipped.side-bottom { box-shadow: 0 5px 0 0 #f97316; } .mag-review-box.is-issue-clipped.side-bottom { box-shadow: 0 5px 0 0 #f97316; }
/* Magazine: text flow arrows, chips and reading-view seam markers */
.mag-flow-toggle { margin-left: auto; font-size: 13px; display: inline-flex; align-items: center; gap: 4px; }
.mag-flow-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4; overflow: visible; }
.mag-flow-path { fill: none; stroke: #16a34a; stroke-width: 3; stroke-linecap: round; }
.mag-flow-label { font-size: 11px; font-weight: 600; fill: #fff; paint-order: stroke; stroke: #15803d; stroke-width: 4px; stroke-linejoin: round; }
.mag-flow-chip { position: absolute; right: 2px; z-index: 5; padding: 0 5px; font-size: 11px; line-height: 1.5; border-radius: 3px;
color: #fff; background: #15803d; white-space: nowrap; pointer-events: none; }
.mag-flow-chip.is-top { top: 2px; } .mag-flow-chip.is-bottom { bottom: 2px; }
.mag-flow-chip.is-edge { background: #475569; }
.mag-seam { display: inline-block; margin: 0 2px; padding: 0 5px; font-size: .72em; line-height: 1.6; vertical-align: middle;
border-radius: 999px; color: #14532d; background: #dcfce7; border: 1px solid #86efac; user-select: none; }
.mag-seam.is-page { background: #bbf7d0; font-weight: 600; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .mag-seam { color: #dcfce7; background: #14532d; border-color: #166534; } }
:root[data-theme="dark"] .mag-seam { color: #dcfce7; background: #14532d; border-color: #166534; }
.mag-review-page-wrap.has-room .mag-issue-box::after, .mag-review-page-wrap.has-room .mag-review-box[data-issue]::after,
.mag-review-box[data-issue]:hover::after { display: block; }

View File

@ -90,6 +90,8 @@ async function mockBackend(page, { articleStatus = 'detected', hasAudio = false,
json: { page: 3, uncovered: [], uncovered_ratio: 0, suspect_blocks: [], overlaps: [], clipped: [], measured: true, spelling: 0, advertisement: false }, json: { page: 3, uncovered: [], uncovered_ratio: 0, suspect_blocks: [], overlaps: [], clipped: [], measured: true, spelling: 0, advertisement: false },
})); }));
await page.route(`**/api/magazine/${DOC_ID}/spelling**`, route => route.fulfill({ json: { issues: [], dictionary: {} } })); await page.route(`**/api/magazine/${DOC_ID}/spelling**`, route => route.fulfill({ json: { issues: [], dictionary: {} } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/flow`, route => route.fulfill({
json: { links: [], first: null, last: null } }));
await page.route(`**/api/magazine/${DOC_ID}/pages/*`, route => route.fulfill({ await page.route(`**/api/magazine/${DOC_ID}/pages/*`, route => route.fulfill({
// 1x1 PNG — only geometry/wiring is under test, not real page rendering. // 1x1 PNG — only geometry/wiring is under test, not real page rendering.
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'),
@ -515,6 +517,57 @@ test.describe('Magazine: what is wrong on this page', () => {
}); });
}); });
test.describe('Magazine: text flow indicator', () => {
async function openReview(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 3' })
.getByRole('button', { name: 'Review', exact: true }).click();
}
test('review draws an arrow for a continuation on the page and chips for the other pages and the ends', async ({ page }) => {
await mockBackend(page);
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/flow`, route => route.fulfill({ json: {
links: [
{ from: 'b1', to: 'b3', relation: 'column', from_page: 3, to_page: 3 },
{ from: 'b3', to: 'x9', relation: 'page', from_page: 3, to_page: 8 },
{ from: 'y1', to: 'b2', relation: 'page', from_page: 2, to_page: 3 }],
first: 'b1', last: 'x9' } }));
await openReview(page);
await expect(page.locator('#mag-review-page-wrap .mag-flow-path')).toHaveCount(1);
await expect(page.locator('#mag-review-page-wrap .mag-flow-label')).toHaveText('next column');
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'continues on page 8' })).toHaveCount(1);
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'from page 2' })).toHaveCount(1);
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'article starts' })).toHaveCount(1);
await page.getByLabel('Show text flow').uncheck();
await expect(page.locator('#mag-review-page-wrap .mag-flow-path, #mag-review-page-wrap .mag-flow-chip')).toHaveCount(0);
});
test('the reading text marks where a paragraph crosses a column or a page', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
const text = 'Der Satz beginnt hier und geht auf der nächsten Seite weiter, bis er endet.';
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: {
article: ARTICLE_APPROVED, blocks: [{
id: 'b3', page: 3, bbox: [50, 150, 200, 300], type: 'body', text, spoken_text: text, reading_order: 2,
parts: [{ id: 'b3', page: 3, bbox: [50, 150, 200, 300], start: 0, end: 30 },
{ id: 'z1', page: 4, bbox: [50, 60, 200, 300], start: 31, end: text.length }] }] } }));
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.waitForSelector('.mag-toc-row.is-approved');
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
const seam = page.locator('#mag-article .mag-seam');
await expect(seam).toHaveCount(1);
await expect(seam).toHaveText('p.4 ›');
await expect(seam).toHaveAttribute('title', 'Continues on page 4');
});
});
test('drawing a chart links the crop and excludes enclosed narration on approval', async ({ page }) => { test('drawing a chart links the crop and excludes enclosed narration on approval', async ({ page }) => {
await mockBackend(page); await mockBackend(page);
let cropped = false; let cropped = false;

View File

@ -2918,3 +2918,32 @@ class SplitAroundPullquoteTests(unittest.TestCase):
from core.magazine import split_around_pullquotes from core.magazine import split_around_pullquotes
layout = dict(self.layout, blocks=[self.layout["blocks"][0], dict(self.layout["blocks"][1], bbox=[400, 500, 70, 60])]) layout = dict(self.layout, blocks=[self.layout["blocks"][0], dict(self.layout["blocks"][1], bbox=[400, 500, 70, 60])])
self.assertEqual(split_around_pullquotes(self.dir, layout)[1], []) self.assertEqual(split_around_pullquotes(self.dir, layout)[1], [])
class ArticleFlowTests(unittest.TestCase):
def flow(self, rows):
from core.magazine import article_flow
blocks = [{"id": i, "page": pg, "type": t, "text": txt, "reading_order": n, "bbox": bb}
for n, (i, pg, t, txt, bb) in enumerate(rows)]
return article_flow({"pages": [], "blocks": blocks, "images": []}, {"blocks": [r[0] for r in rows]})
def test_a_sentence_running_on_says_how_the_eye_travels(self):
flow = self.flow([
("t", 1, "title", "Titel", [10, 10, 200, 20]),
("a", 1, "body", "Der Satz beginnt in der ersten Spalte und", [10, 40, 90, 100]),
("b", 1, "body", "geht in der zweiten Spalte weiter, bis er", [120, 40, 90, 100]),
("c", 2, "body", "auf der nächsten Seite endet. Danach ein neuer Absatz.", [10, 40, 90, 100])])
self.assertEqual([(l["from"], l["to"], l["relation"]) for l in flow["links"]],
[("a", "b", "column"), ("b", "c", "page")])
self.assertEqual((flow["first"], flow["last"]), ("t", "c"))
def test_a_continuation_under_the_same_column_is_below(self):
flow = self.flow([("a", 1, "body", "Der Satz beginnt hier und", [10, 40, 90, 50]),
("b", 1, "body", "geht direkt darunter weiter.", [10, 100, 90, 50])])
self.assertEqual(flow["links"][0]["relation"], "below")
def test_finished_paragraphs_and_headings_have_no_link(self):
flow = self.flow([("a", 1, "body", "Ein fertiger Absatz.", [10, 40, 90, 50]),
("h", 1, "heading", "Zwischentitel", [10, 100, 90, 10]),
("b", 1, "body", "Ein weiterer Absatz.", [10, 120, 90, 50])])
self.assertEqual(flow["links"], [])