diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f69f37..c0415b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **A paragraph that wraps around an inset pull quote is cut into a full-width part and a narrow part**, so its box no longer sits over the quote (as the right-hand column already did). The text lines are measured from the page image; the text is cut in proportion to line width at a space, within a word or two of the printed break, and the two pieces are read in order and rejoined for narration. A paragraph whose lines are all narrow just gets a narrower box. Runs after **OCR again (this page)** and in **Clean up boxes**; new pieces join the same articles. +- **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. + ### 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 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. diff --git a/Manual.md b/Manual.md index fd23fe2..6bf7aff 100644 --- a/Manual.md +++ b/Manual.md @@ -282,6 +282,14 @@ the original for anything that matters. Podcast generation is refused for an article you have not approved, because unreviewed boundaries may include a neighbouring article or an advert. +### What is wrong on this page, and fixing it automatically + +**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. +**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). +**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** (review toolbar) also fits each text box to the ink of its text: a side that cuts through letters grows a little, empty margin is trimmed. Boxes you drew or resized are never changed; a backup of the layout is kept and the message reports how many boxes were fitted. diff --git a/routes/magazine.py b/routes/magazine.py index ac2966e..015c968 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -988,6 +988,36 @@ async def magazine_verify_ocr_text(doc_id: str, page: int): _QUALITY_REPORT = "quality_report.json" +def _vision_args(settings: dict[str, Any]) -> tuple[str | None, str | None, str | None, str | None]: + def pick(key: str) -> str | None: + return str(settings.get(key) or "").strip() or None + return (pick("vision_ocr_url"), pick("vision_ocr_model"), + pick("vision_ocr_fallback_url"), pick("vision_ocr_fallback_model")) + + +def _fix_page_geometry(d: Path, layout: dict[str, Any], articles: list[dict[str, Any]], page: int, + settings: dict[str, Any]) -> tuple[dict[str, Any], dict[str, int], bool]: + """The deterministic box fixes for one page, in the order that lets each rely on the last. + + Retype noise/duplicates, add an inset pull quote, cut a paragraph that wraps around it, then + fit every text box to its ink (re-reading the ones that gained lines). Returns the layout, + counts of what was done, and whether the articles changed (new pieces join their article). + """ + url, model, fb_url, fb_model = _vision_args(settings) + layout, cleanup = mag.clean_up_regions(layout, pages=[page]) + noise = len(cleanup.get("ocr_noise", [])) + len(cleanup.get("duplicate", [])) + len(cleanup.get("in_picture", [])) + layout, quotes = mag.detect_inset_pullquotes(d, layout, [page], url, model, fb_url, fb_model) + layout, wrap = mag.split_around_pullquotes(d, layout, [page]) + cleanup["wrap_split"] = wrap + layout, snapped = mag.snap_text_boxes(d, layout, pages=[page]) + if url and model and any(m.get("lines_added") for m in snapped): + layout, _ = mag.reread_grown_regions(d, layout, snapped, url, model, fb_url, fb_model) + changed = _add_split_paragraphs_to_articles(articles, cleanup) + counts = {"noise_retyped": noise, "pullquotes_found": len(quotes), "paragraphs_cut": len(wrap), + "boxes_fitted": len(snapped), "bylines_split": len(cleanup.get("byline_split", []))} + return layout, counts, changed + + def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> None: """Whole-issue completeness run: find missed text, verify OCR, then measure coverage. @@ -1009,6 +1039,9 @@ def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> Non agent = bool(str(settings.get("llm_model") or "").strip()) issue_language = _issue_language(d, layout) pages = [meta["page"] for meta in layout.get("pages", [])] + articles = mag.load_articles(d) + articles_changed = False + fixed_boxes = 0 rows: list[dict[str, Any]] = [] for i, page in enumerate(pages, 1): def step(text: str, page: int = page, i: int = i) -> None: @@ -1025,6 +1058,10 @@ def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> Non "needs_review": False}) continue try: + step("fixing boxes") + layout, geometry, moved_articles = _fix_page_geometry(d, layout, articles, page, settings) + articles_changed = articles_changed or moved_articles + fixed_boxes += geometry["boxes_fitted"] + geometry["paragraphs_cut"] + geometry["pullquotes_found"] step("finding missed text") layout, added = mag.recover_missing_text(d, layout, page) if verify: @@ -1049,7 +1086,8 @@ 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) + if articles_changed: + mag.save_articles(d, articles) duplicates = mag.duplicate_article_groups(articles) by_id = {a["id"]: a for a in articles} for row in rows: @@ -1061,6 +1099,7 @@ def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> Non summary = { "pages": len(rows), "verified_with_vision": verify, + "boxes_fixed": fixed_boxes, "regions_added": sum(r["added"] for r in rows), "regions_corrected": sum(r["corrected"] for r in rows), "spelling_fixed": sum(r.get("spelling_fixed", 0) for r in rows), @@ -1074,7 +1113,7 @@ def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> Non flagged = len(summary["pages_needing_review"]) background_job_update( job_id, stage="done", done=True, result=summary, - detail=(f"{summary['regions_added']} missed regions added, " + detail=(f"{summary['boxes_fixed']} boxes fixed, {summary['regions_added']} missed regions added, " f"{summary['regions_corrected']} words corrected, " f"{summary['spelling_fixed']} misspellings fixed, {summary['spelling_marked']} marked; " f"{flagged} page{'s' if flagged != 1 else ''} still need a look" @@ -1093,6 +1132,73 @@ async def magazine_quality_run(doc_id: str): return {"job_id": job_id} +@router.get("/api/magazine/{doc_id}/pages/{page}/audit") +async def magazine_page_audit(doc_id: str, page: int): + """What is wrong with one page right now, measured live from its render (not the last run).""" + d, _ = _require_magazine(doc_id) + layout = mag.load_layout(d) + meta = next((p for p in (layout or {}).get("pages", []) if p["page"] == page), None) + if meta is None: + raise HTTPException(404, "Page not found") + audit = await asyncio.to_thread(mag.audit_page_coverage, d, layout, page) + spell = await asyncio.to_thread(_spelling_findings, d, layout, None, page, _issue_language(d, layout)) + audit["spelling"] = len(spell) + audit["advertisement"] = mag.page_is_advertisement(layout, page) + return audit + + +def _autofix_page_sync(doc_id: str, job_id: str, page: int, mode: str, settings: dict[str, Any]) -> None: + """Fix one page: boxes always; missed text, OCR verification and spelling too unless mode is 'boxes'.""" + d = _LIB / doc_id + try: + layout = mag.load_layout(d) + if not layout: + raise FileNotFoundError("No layout yet -- run ingestion first") + articles = mag.load_articles(d) + token = _snapshot_for_undo(d, f"Auto-fixed page {page}") + background_job_update(job_id, stage="fixing", detail=f"Fixing the boxes on page {page}") + layout, done, moved = _fix_page_geometry(d, layout, articles, page, settings) + if mode != "boxes": + url, model, fb_url, fb_model = _vision_args(settings) + background_job_update(job_id, stage="fixing", detail="Looking for missed text") + layout, done["regions_added"] = mag.recover_missing_text(d, layout, page) + if url and model: + background_job_update(job_id, stage="fixing", detail="Re-reading suspicious words") + layout, _, done["words_corrected"] = mag.verify_ocr_recognition(d, layout, page, url, model, fb_url, fb_model) + if str(settings.get("llm_model") or "").strip(): + background_job_update(job_id, stage="fixing", detail="Checking spelling") + outcome = spelling_agent_pass(d, layout, settings, page=page, language=_issue_language(d, layout)) + layout, done["misspellings_fixed"] = outcome["layout"], outcome["fixed"] + mag.save_layout(d, layout) + if moved: + mag.save_articles(d, articles) + _seal_undo(d, token) + done["undo"] = token + fixed = sum(v for k, v in done.items() if k != "undo" and isinstance(v, int)) + background_job_update(job_id, stage="done", done=True, result=done, + detail=f"Page {page}: {fixed} fix{'es' if fixed != 1 else ''} applied") + except Exception as exc: # noqa: BLE001 - surfaced to UI + background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000]) + + +@router.post("/api/magazine/{doc_id}/pages/{page}/autofix") +async def magazine_autofix_page(doc_id: str, page: int, request: Request): + """Background job that fixes what can be fixed on one page. Body: {mode: "all" | "boxes"}.""" + d, _ = _require_magazine(doc_id) + layout = mag.load_layout(d) + if not any(p["page"] == page for p in (layout or {}).get("pages", [])): + raise HTTPException(404, "Page not found") + try: + body = await request.json() + except ValueError: + body = {} + mode = "boxes" if isinstance(body, dict) and body.get("mode") == "boxes" else "all" + job_id = uuid.uuid4().hex + background_job_create(job_id, "magazine-autofix", fingerprint=f"{doc_id}:{page}") + asyncio.create_task(asyncio.to_thread(_autofix_page_sync, doc_id, job_id, page, mode, _load_settings())) + return {"job_id": job_id} + + @router.get("/api/magazine/{doc_id}/quality/report") async def magazine_quality_report(doc_id: str): """Result of the last whole-issue quality run (404 if none yet).""" diff --git a/static/js/magazine.js b/static/js/magazine.js index 0c99508..3acb698 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -43,6 +43,7 @@ const state = { spell: null, // last spelling findings shown (page or article scope) spellByBlock: new Map(), spellOpen: false, + audit: null, // live audit of the reviewed page ({page, data}) acronyms: null, // the open article's glossary state queue: [], // article ids for "play all" (empty = not queued) queueIndex: -1, @@ -1650,7 +1651,8 @@ function renderReview() { ' Spelling ' + '' + '' + - '
'; + '' + + ''; html += 'Checking this page…
'; + let data; + try { data = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/audit'); } catch (_) { + if (panel) panel.innerHTML = ''; + return; + } + if (seq !== _magAuditSeq || state.view !== 'review') return; + state.audit = { page, data }; + magAuditPaint(); +} + +function magAuditPaint() { + const wrap = $('mag-review-page-wrap'); + const audit = state.audit && state.audit.page === state.reviewPage ? state.audit.data : null; + document.querySelectorAll('#mag-review .mag-issue-box').forEach((el) => el.remove()); + document.querySelectorAll('#mag-review .mag-review-box').forEach((el) => { + el.classList.remove('is-issue-clipped', 'is-issue-overlap', 'is-issue-suspect', + 'side-left', 'side-right', 'side-top', 'side-bottom'); + el.removeAttribute('data-issue'); + }); + const panel = $('mag-issues-panel'); + if (!audit || !panel) return; + const boxOf = (id) => document.querySelector('#mag-review-page-wrap .mag-review-box[data-block="' + CSS.escape(id) + '"]'); + const tag = (el, cls, text) => { + if (!el) return; + el.classList.add(cls); + el.setAttribute('data-issue', [el.getAttribute('data-issue'), text].filter(Boolean).join(' · ')); + }; + (audit.clipped || []).forEach((c) => { + const el = boxOf(c.id); + tag(el, 'is-issue-clipped', 'edge cuts text'); + if (el) el.classList.add('side-' + c.side); + }); + (audit.overlaps || []).forEach(([a, b]) => { tag(boxOf(a), 'is-issue-overlap', 'overlaps another box'); tag(boxOf(b), 'is-issue-overlap', 'overlaps another box'); }); + (audit.suspect_blocks || []).forEach((id) => tag(boxOf(id), 'is-issue-suspect', 'words look mis-read')); + const meta = (state.layout?.pages || []).find((p) => p.page === state.reviewPage); + if (wrap && meta) { + (audit.uncovered || []).forEach((b) => { + const el = document.createElement('div'); + el.className = 'mag-issue-box is-uncovered'; + el.setAttribute('data-issue', 'text outside any box'); + el.style.cssText = 'left:' + (b[0] / meta.width * 100).toFixed(2) + '%;top:' + (b[1] / meta.height * 100).toFixed(2) + + '%;width:' + (b[2] / meta.width * 100).toFixed(2) + '%;height:' + (b[3] / meta.height * 100).toFixed(2) + '%'; + wrap.appendChild(el); + }); + } + const found = MAG_ISSUE_KINDS.filter((k) => magIssueCount(audit, k.key) > 0); + const total = found.reduce((n, k) => n + magIssueCount(audit, k.key), 0); + let html = 'This page is an advertisement: nothing on it is read aloud.
'; + panel.innerHTML = html; +} + +async function magAutofixPage(mode) { + const page = state.reviewPage; + if (page == null) return; + const btn = $('mag-autofix-btn'); + const status = () => $('mag-issues-status'); + if (btn) btn.disabled = true; + if (status()) status().textContent = 'Starting…'; + let jobId; + try { + jobId = (await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/autofix', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }), + })).job_id; + } catch (e) { + if (btn) btn.disabled = false; + return say('Could not start the fix: ' + e.message, 'err'); + } + let job; + for (;;) { + try { job = await api('/api/jobs/' + jobId); } catch (_) { job = null; break; } + if (job.done) break; + if (status() && job.detail) status().textContent = job.detail; + await new Promise((r) => setTimeout(r, 1500)); + } + if (btn) btn.disabled = false; + if (!job || job.error) return say('Fixing the page failed: ' + (job && job.error || 'lost the job'), 'err'); + say(job.detail || 'Page fixed', 'ok'); + if (job.result && job.result.undo) state.undo = { token: job.result.undo, label: 'Auto-fixed page ' + page }; + state.layout = null; + await ensureLayout(); + const articleId = state.article.id; + await magRefreshArticles(); + state.article = state.articles.find((a) => a.id === articleId) || state.article; + _magReviewChecked = null; + renderReview(); +} + +async function magSpellOpenAndFix() { + if (!state.spellOpen) magSpellToggle(); + await magSpellAgent(); +} + // ── Spelling and pronunciation checks ──────────────────────────────────────── // Spelling: dictionary-unknown words are MARKED (review list) and listed with // suggestions; nothing is rewritten unless the reviewer picks a suggestion or the @@ -4684,6 +4829,8 @@ root.magDrivingCycleSpeed = magDrivingCycleSpeed; root.magDrivingToggleText = magDrivingToggleText; root.magDrivingToggleImages = magDrivingToggleImages; root.magDrivingPrevious = magDrivingPrevious; +root.magAutofixPage = magAutofixPage; +root.magSpellOpenAndFix = magSpellOpenAndFix; root.magSpellToggle = magSpellToggle; root.magSpellFix = magSpellFix; root.magSpellKeep = magSpellKeep; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index e2434f8..4c73f94 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -65,7 +65,7 @@ onkeydown="if(event.key==='Enter'){this.blur();}">