From b7aa823455a017f816cfa37b52aa74a642874944 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 21 Sep 2026 12:06:33 +0200 Subject: [PATCH] feat(magazine): clean up boxes - text in pictures, OCR noise, duplicates, run-on author lines Rules sized on a real 204-page issue before writing them; each re-types (not read aloud, reversible), none deletes. A run-on author line's sentence becomes its own paragraph instead of being glued to "the next paragraph", which was right in only 1 of 5 real cases. Runs on import and re-OCR, and on demand with a layout backup. v1.37.0. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 ++ Manual.md | 15 ++- TODO.md | 2 +- VERSION | 2 +- architecture/magazine-ingestion.md | 20 ++++ core/magazine.py | 172 ++++++++++++++++++++++++++++- routes/magazine.py | 39 +++++++ static/index.html | 12 +- static/js/magazine.js | 35 +++++- tests/magazine.spec.js | 27 ++++- tests/test_magazine_routes.py | 130 ++++++++++++++++++++++ 11 files changed, 446 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad8327..f222c36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] +## [1.37.0] — 2026-09-21 + +### Added +- **Magazine: "Clean up boxes"** — across a whole issue, short text inside pictures, one- or two-character OCR noise and boxes that only repeat a bigger box's text stop being read aloud, and author lines that run into the first sentence are split into the name and a paragraph of their own. Nothing is deleted (every change is a type you can undo in the review list) and the previous layout is kept as a backup. Runs automatically on import and *OCR again*. On a real 204-page issue: 71 texts in pictures, 25 noise boxes, 38 duplicates, 5 author lines. + +### Fixed +- Recomputing a page's reading order no longer drops box fields the layout model does not know about. + ## [1.36.0] — 2026-09-21 ### Added diff --git a/Manual.md b/Manual.md index 8f51c89..ef6a9a0 100644 --- a/Manual.md +++ b/Manual.md @@ -155,7 +155,20 @@ turned into a podcast discussion between two or three voices you pick. order — then *Save order*. A page ordered by hand says so above the list and is never reshuffled by *Fix reading order*, a type change or a newly drawn box (a new box is read last until you move it); **"Automatic - order"** hands the page back to the automatic column order. Use + order"** hands the page back to the automatic column order. + **"Clean up boxes"** fixes four problems across the whole issue at once: + short text inside a picture (a label on a screenshot, a word on a slide in + a photo) becomes *Text in picture*; boxes of one or two characters become + *OCR noise*; a box that only repeats a bigger box's text (typically a + subheading the paragraph box also read) becomes *Duplicate*, so it is not + read twice; and an author line that runs into the article's first + sentence is split into the name and a paragraph of its own. None of these + are read aloud, nothing is deleted, every change shows in the list and can + be undone with the type picker, and a backup of the page layout is kept + (`layout.backup-.json` in the issue's folder). New imports and + *OCR again* do this automatically. Longer text inside a picture is left + for you to judge: it can be real text caught in a picture frame that is + too big. Use **"Two-page spread"** to see this page next to its facing page (e.g. 22 and 23), each with its own OCR-results list directly beneath it — useful for lining up a figure or a jump that crosses the gutter. Only one page diff --git a/TODO.md b/TODO.md index 861dcdc..10c7a96 100644 --- a/TODO.md +++ b/TODO.md @@ -80,7 +80,7 @@ not part of this order. read, or type a number into each one. The order drives playback and the OCR-results list. Today the order is whatever the layout analysis produced, and a page with two text columns comes out interleaved, so a listener hears half a sentence from the wrong column. -- [ ] **L — Magazine: overhaul region boxes and how they are handled in OCR and approval.** (Done so far: type picker and delete button per row, 2026-09-21.) +- [ ] **L — Magazine: overhaul region boxes and how they are handled in OCR and approval.** (Done 2026-09-21: type picker and delete per row; Clean up boxes — text in pictures, OCR noise, duplicates, run-on author lines. Still open: keyboard navigation, merge/split, per-row re-OCR; the 333 overlapping boxes with different text need a human.) The boxes the layout analysis produces need a pass of their own, on top of the ordering work above. Seen on real pages: a byline box that runs on into the first body line ("Von Hartmut Gieselmann Ein guter Butler kennt die Vorlieben seines"), so author and paragraph both come diff --git a/VERSION b/VERSION index d70132e..e4dce85 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.36.0 \ No newline at end of file +1.37.0 \ No newline at end of file diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md index 66f52ce..d327e92 100644 --- a/architecture/magazine-ingestion.md +++ b/architecture/magazine-ingestion.md @@ -308,6 +308,26 @@ While locked: *Fix reading order* and type changes skip the page, a newly drawn and re-OCR of the page clears the lock (its boxes are all new). UI: an editable position number per row, and an *Order by clicking* mode (clicked boxes are numbered live; clicking does not tick/untick them). +## Box clean-up (`clean_up_regions`) + +Sized on a real 204-page issue (2 038 boxes) before any rule was written; every rule *re-types*, none +deletes (furniture + `region_label` `in_picture` / `ocr_noise` / `duplicate`: not narrated, visible, +reversible in the type picker). + +| rule | found | left alone on purpose | +|---|---|---| +| text ≤ 80 chars, ≥ 90% inside a picture → `in_picture` | 71 | longer text in a picture: included real running text caught in a loose frame | +| ≤ 2 alphanumeric characters → `ocr_noise` | 25 | longer short strings: a phone number, a price, a citation | +| box ≥ 80% inside a bigger text box whose text contains ≥ 90% of it → `duplicate` | 38 | the other 333 overlapping pairs hold different text | +| author line running into the first sentence → split | 5 (4 split, 1 already on page) | a two-author byline; a sentence starting with "Von" | + +The split sentence becomes its own paragraph (bbox: lower half of the author box), not text glued onto +"the next paragraph": on the five real cases the next paragraph in reading order was right once. The +new paragraph is inserted after the author line in every article that holds it; the author block keeps +`original_text`. Runs on import, on page re-OCR (that page), and via *Clean up boxes* (`POST +.../boxes/clean-up`, writes `layout.backup-.json` first). Idempotent. `_reorder_page` now writes +only position/column back, so fields the Block model does not carry (e.g. `original_text`) survive. + ## Region types from the review list Every region in the review list has a type picker and a delete button. `PATCH .../regions/{id}` takes diff --git a/core/magazine.py b/core/magazine.py index db5adb5..9756b9a 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -2730,6 +2730,163 @@ def update_region(doc_dir: Path, layout: dict[str, Any], page_no: int, block_id: return layout, None +# ── Box clean-up ──────────────────────────────────────────────────────────── +# Each rule was sized on a real 204-page scanned issue (2 038 boxes) before it was written, and each +# one only changes what a box IS, never deletes it: the types below are not read aloud, show in the +# review list, and can be changed back with the type picker. A split byline keeps its original text. + +_VISUAL_TYPES = (BLOCK_FIGURE, BLOCK_CHART, BLOCK_TABLE, BLOCK_CODE) +_BYLINE_RUNON = re.compile(r"^(Von|By|Text)\s+(.*)$", re.IGNORECASE) + + +def _area_inside(a: Sequence[float], b: Sequence[float]) -> float: + """Share of box a's area that lies inside box b.""" + w = max(0.0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0])) + h = max(0.0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1])) + return (w * h) / max(1e-6, a[2] * a[3]) + + +def _text_found_in(small: str, big: str) -> float: + """How much of `small` appears, in order, inside `big` (0..1), tolerant of OCR spacing.""" + import difflib + s = " ".join(small.split()).lower() + g = " ".join(big.split()).lower() + if not s: + return 0.0 + matcher = difflib.SequenceMatcher(None, s, g, autojunk=False) + return sum(m.size for m in matcher.get_matching_blocks()) / len(s) + + +def split_runon_byline(text: str) -> tuple[str, str] | None: + """Split "Von Andreas Weck Als Microsoft-CTO…" into ("Von Andreas Weck", "Als Microsoft-CTO…"). + + The OCR box of an author line often runs into the first line of the article. A name is two + words (a middle initial like "M." makes three), extended by "und/and/&" plus another name for + co-authors; whatever follows is the start of the article text. Returns None when there is + nothing to split -- including a plain two-author byline ("Von Sabrina Patsch und Morton + Swimmer") and a sentence that merely starts with "Von" ("Von Familienmitgliedern …"). + """ + m = _BYLINE_RUNON.match(" ".join((text or "").split())) + if not m: + return None + lead, words = m.group(1), m.group(2).split() + + def take_name(i: int) -> int: + """Index after one name starting at words[i], or -1 when words[i:] does not start with one.""" + if i + 1 >= len(words) or not all(w[:1].isupper() for w in words[i:i + 2]): + return -1 + j = i + 2 + if j < len(words) and re.fullmatch(r"[A-ZÄÖÜ]\.", words[i + 1]) and words[j][:1].isupper(): + j += 1 # "Anna M. Müller" + return j + + end = take_name(0) + if end < 0: + return None + while end + 1 < len(words) and words[end].lower() in ("und", "and", "&"): + nxt = take_name(end + 1) + if nxt < 0: + break + end = nxt + rest = " ".join(words[end:]) + if not rest: + return None + return f"{lead} {' '.join(words[:end])}", rest + + +def clean_up_regions(layout: dict[str, Any], pages: Iterable[int] | None = None) -> tuple[dict[str, Any], dict[str, list]]: + """Fix the box problems measured on real scanned issues. Returns the layout and a report. + + * Text inside a picture (a label on a screenshot, a word on a slide in a photo), at most 80 + characters and at least 90% inside the picture, becomes "in_picture". Longer text is left + alone: on the measured issue it included real running text caught inside a loose picture + frame ("nutzen, ohne dass du …" continuing the previous sentence). + * A box of two characters or fewer ("1", "V", "J 3", "#") becomes "ocr_noise". Longer short + strings are left alone: they included a phone number, a price and a citation. + * A box lying inside a bigger text box whose text already contains it (typically a subheading + the body box also read) becomes "duplicate", so it is not read twice. Overlapping boxes with + different text are left alone -- they were the large majority, and hiding one loses text. + * An author line that runs into the article's first sentence is split: the name stays, the + sentence becomes a paragraph box of its own, directly under the name in the same column, and + is added to the articles that hold the author line. It is not glued onto "the next paragraph": + measured on five real cases, the next paragraph in reading order was the right one only once + (it was in the other column, or a sentence further on). If a paragraph on the page already + starts with that sentence, only the name is kept. + Returns the layout and a report; report["byline_split"] entries carry the new paragraph's id. + """ + wanted = set(pages) if pages is not None else None + report: dict[str, list] = {"in_picture": [], "ocr_noise": [], "duplicate": [], "byline_split": []} + blocks = [dict(b) for b in layout.get("blocks", [])] + by_page: dict[int, list[dict]] = {} + for b in blocks: + if wanted is None or b["page"] in wanted: + by_page.setdefault(b["page"], []).append(b) + pictures: dict[int, list] = {} + for im in layout.get("images", []): + pictures.setdefault(im["page"], []).append(im["bbox"]) + for b in blocks: + if b["type"] in _VISUAL_TYPES: + pictures.setdefault(b["page"], []).append(b["bbox"]) + + def mark(b: dict, label: str) -> None: + b["type"], b["region_label"], b["confidence"] = BLOCK_FURNITURE, label, 0.9 + report[label].append({"page": b["page"], "id": b["id"], "text": " ".join((b.get("text") or "").split())[:80]}) + + for page_no, page_blocks in by_page.items(): + text_boxes = [b for b in page_blocks if b["type"] not in _VISUAL_TYPES and b["type"] != BLOCK_FURNITURE] + for b in text_boxes: + text = " ".join((b.get("text") or "").split()) + if text.startswith("(no text recognised"): + continue + if len(re.sub(r"[\W_]", "", text)) <= 2: + mark(b, "ocr_noise") + elif len(text) <= 80 and any(_area_inside(b["bbox"], p) >= 0.9 for p in pictures.get(page_no, [])): + mark(b, "in_picture") + for small in text_boxes: + if small["type"] == BLOCK_FURNITURE or len((small.get("text") or "").strip()) < 4: + continue + for big in text_boxes: + if big is small or big["type"] == BLOCK_FURNITURE: + continue + if (big["bbox"][2] * big["bbox"][3] > small["bbox"][2] * small["bbox"][3] + and _area_inside(small["bbox"], big["bbox"]) >= 0.8 + and _text_found_in(small.get("text") or "", big.get("text") or "") >= 0.9): + mark(small, "duplicate") + break + taken = {x["id"] for x in blocks} + for b in [x for x in page_blocks if x["type"] == BLOCK_BYLINE and not x.get("original_text")]: + split = split_runon_byline(b.get("text") or "") + if not split: + continue + name, rest = split + b["original_text"], b["text"] = b["text"], name + already = any(_text_found_in(rest[:60], (o.get("text") or "")[:len(rest) + 40]) >= 0.9 + for o in page_blocks if o is not b and o["type"] == BLOCK_BODY) + new_id = None + if not already: + n = 0 + while f"s{page_no:04d}-{n:03d}" in taken: + n += 1 + new_id = f"s{page_no:04d}-{n:03d}" + taken.add(new_id) + x, y, w, h = b["bbox"] + # The sentence sits on the lower part of the author line's box; placing it there keeps + # it directly after the name, in the same column, in reading order. + blocks.append({**{k: v for k, v in b.items() if k not in ("original_text",)}, + "id": new_id, "type": BLOCK_BODY, "text": rest, "region_label": "", + "bbox": [x, y + h * 0.5, w, max(1.0, h * 0.5)], "confidence": 0.7, + "reading_order": b.get("reading_order", 0)}) + page_blocks.append(blocks[-1]) + report["byline_split"].append({"page": page_no, "id": b["id"], "text": name, + "moved": rest[:60], "new_id": new_id}) + + layout = dict(layout) + layout["blocks"] = blocks + for page_no in {x["page"] for x in report["byline_split"] if x["new_id"]}: + layout = _reorder_page(layout, page_no) + return layout, report + + # What a region can be changed into from the review screen: text kinds map to a block type (and, # for furniture, which kind of furniture); "image" turns the region into a figure crop. REGION_TYPES: dict[str, tuple[str, str]] = { @@ -2744,6 +2901,9 @@ REGION_TYPES: dict[str, tuple[str, str]] = { "page_number": (BLOCK_FURNITURE, "page_number"), "source": (BLOCK_FURNITURE, "source"), "ad": (BLOCK_AD, ""), + "in_picture": (BLOCK_FURNITURE, "in_picture"), + "ocr_noise": (BLOCK_FURNITURE, "ocr_noise"), + "duplicate": (BLOCK_FURNITURE, "duplicate"), "image": ("", ""), } @@ -2867,11 +3027,15 @@ def _reorder_page(layout: dict[str, Any], page_no: int) -> dict[str, Any]: page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None) if not page_meta or page_meta.get("order_locked"): return layout - blocks = [Block(**b) for b in layout.get("blocks", [])] - page_blocks = [b for b in blocks if b.page == page_no] - assign_reading_order(page_blocks, page_meta["width"]) + # Only the position is written back, so fields the Block model does not carry survive. + fields = Block.__dataclass_fields__ + page_dicts = [b for b in layout.get("blocks", []) if b["page"] == page_no] + models = [Block(**{k: v for k, v in b.items() if k in fields}) for b in page_dicts] + assign_reading_order(models, page_meta["width"]) + placed = {m.id: (m.reading_order, m.column) for m in models} layout = dict(layout) - layout["blocks"] = [b.to_json() for b in [b for b in blocks if b.page != page_no] + page_blocks] + layout["blocks"] = [dict(b, reading_order=placed[b["id"]][0], column=placed[b["id"]][1]) + if b["page"] == page_no else b for b in layout.get("blocks", [])] return layout diff --git a/routes/magazine.py b/routes/magazine.py index 08197f1..8607827 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -219,6 +219,7 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int, _recognise_advertisements(layout, _load_settings()) _recognise_semantic_layout(layout, _load_settings(), doc_dir=d) mag.refresh_visual_regions(d, layout) + layout, _ = mag.clean_up_regions(layout) mag.save_layout(d, layout) background_job_update(job_id, stage="articles", detail="Detecting articles") @@ -654,8 +655,10 @@ def _reocr_page_sync(doc_id: str, job_id: str, page: int, _recognise_advertisements(updated, _load_settings(), page) _recognise_semantic_layout(updated, _load_settings(), page, doc_dir=d) mag.refresh_visual_regions(d, updated, page) + updated, cleanup = mag.clean_up_regions(updated, pages=[page]) articles = mag.load_articles(d) mag.remap_reviewed_page(articles, layout, updated, page) + _add_split_paragraphs_to_articles(articles, cleanup) mag.save_layout(d, updated) mag.save_articles(d, articles) background_job_update(job_id, stage="done", done=True, @@ -735,6 +738,42 @@ def _refine_figures_sync(doc_id: str, job_id: str) -> None: background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000]) +def _add_split_paragraphs_to_articles(articles: list[dict], report: dict) -> bool: + """A split author line's sentence became a paragraph of its own: read it wherever the author + line is read, right after it.""" + changed = False + for split in report.get("byline_split") or []: + if not split.get("new_id"): + continue + for article in articles: + blocks = list(article.get("blocks") or []) + if split["id"] in blocks and split["new_id"] not in blocks: + blocks.insert(blocks.index(split["id"]) + 1, split["new_id"]) + article["blocks"] = blocks + changed = True + return changed + + +@router.post("/api/magazine/{doc_id}/boxes/clean-up") +async def magazine_clean_up_boxes(doc_id: str): + """Fix measured box problems across the issue: text inside a picture, OCR noise, boxes that + repeat a bigger box's text, author lines running into the first sentence. Boxes are only + re-typed (not read aloud; reversible in the review list), never deleted, and the previous + layout is kept as a backup file first.""" + d, _ = _require_magazine(doc_id) + layout = mag.load_layout(d) + if not layout: + raise HTTPException(404, "No layout yet -- run ingestion first") + backup = d / f"layout.backup-{time.strftime('%Y%m%dT%H%M%S')}.json" + backup.write_text(json.dumps(layout, ensure_ascii=False), "utf-8") + layout, report = await asyncio.to_thread(mag.clean_up_regions, layout) + mag.save_layout(d, layout) + articles = mag.load_articles(d) + if _add_split_paragraphs_to_articles(articles, report): + mag.save_articles(d, articles) + return {"ok": True, "backup": backup.name, "counts": {k: len(v) for k, v in report.items()}, "report": report} + + @router.put("/api/magazine/{doc_id}/pages/{page}/reading-order") async def magazine_set_page_order(doc_id: str, page: int, request: Request): """Set one page's reading order by hand. Body: {order: [region id, ...]} -- text blocks and diff --git a/static/index.html b/static/index.html index 4df074c..d22f96a 100644 --- a/static/index.html +++ b/static/index.html @@ -27,12 +27,12 @@ - - - - - - + + + + + + diff --git a/static/js/magazine.js b/static/js/magazine.js index a4280d6..8a651cf 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -666,14 +666,17 @@ function magReviewGoMagazinePage(page) { const MAG_REGION_TYPES = [ ['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'], ['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'], - ['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'], ['image', 'Image'], + ['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'], + ['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['image', 'Image'], ]; +// Furniture kinds that the picker shows under their own name (everything else is a page number). +const MAG_FURNITURE_KINDS = ['source', 'in_picture', 'ocr_noise', 'duplicate']; // The picker value for a region as it is now. function magRegionTypeValue(region) { if (region.kind === 'image') return 'image'; const b = region.data; - if (b.type === 'furniture') return b.region_label === 'source' ? 'source' : 'page_number'; + if (b.type === 'furniture') return MAG_FURNITURE_KINDS.includes(b.region_label) ? b.region_label : 'page_number'; return MAG_REGION_TYPES.some(([v]) => v === b.type) ? b.type : 'body'; } @@ -936,6 +939,9 @@ function renderReview() { '' + + '' + '' + @@ -1459,6 +1465,30 @@ async function magReviewSubmitEdit(reocr) { renderReview(); } +async function magCleanUpBoxes() { + const ok = await confirmDialog('Clean up the boxes of this whole issue: text inside pictures, OCR noise (boxes of one or two characters) ' + + 'and boxes that repeat a bigger box\'s text stop being read aloud, and author lines that run into the first sentence are split. ' + + 'Nothing is deleted — every change shows in the review list and can be undone with the type picker — and a backup of the layout is kept.', + { title: 'Clean up boxes?', okLabel: 'Clean up' }); + if (!ok) return; + const btn = document.getElementById('mag-cleanup-btn'); + if (btn) btn.disabled = true; + let r; + try { + r = await api('/api/magazine/' + state.doc.id + '/boxes/clean-up', { method: 'POST' }); + } catch (e) { + if (btn) btn.disabled = false; + return say('Could not clean up the boxes: ' + e.message, 'err'); + } + const c = r.counts || {}; + const parts = [[c.in_picture, 'text in pictures'], [c.ocr_noise, 'OCR noise'], [c.duplicate, 'duplicates'], [c.byline_split, 'author lines split']] + .filter(([n]) => n).map(([n, what]) => n + ' ' + what); + say(parts.length ? 'Cleaned up: ' + parts.join(', ') + '. Backup: ' + r.backup : 'Nothing to clean up.', 'ok'); + state.layout = null; + await ensureLayout(); + renderReview(); +} + async function magFixReadingOrder() { const ok = await confirmDialog('Re-read the reading order of every page in this issue, column by column. ' + 'Text, boxes and approvals stay as they are, but the order in which approved articles are read aloud can change. ' + @@ -2547,6 +2577,7 @@ root.magReviewSubmitEdit = magReviewSubmitEdit; root.magReviewDeleteRegion = magReviewDeleteRegion; root.magReviewChangeType = magReviewChangeType; root.magFixReadingOrder = magFixReadingOrder; +root.magCleanUpBoxes = magCleanUpBoxes; root.magOrderToggle = magOrderToggle; root.magOrderSave = magOrderSave; root.magOrderUndo = magOrderUndo; diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index 742afc3..377488d 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -357,7 +357,7 @@ test('every type the server knows is offered, image included', async ({ page }) const values = await page.locator('[data-block-row="b3"] select.mag-block-type-select option').evaluateAll( options => options.map(o => o.value)); expect(values).toEqual(['body', 'title', 'heading', 'deck', 'byline', 'caption', 'pullquote', 'sidebar', - 'page_number', 'source', 'ad', 'image']); + 'page_number', 'source', 'ad', 'in_picture', 'ocr_noise', 'duplicate', 'image']); }); test('each review row has its own delete button that asks first', async ({ page }) => { @@ -451,3 +451,28 @@ test('a hand-ordered page says so and can go back to the automatic order', async await expect.poll(() => reset).toBe(true); await expect(page.locator('#mag-order-bar')).not.toContainText('set by hand'); }); + +test('clean up boxes asks first and reports what it changed', async ({ page }) => { + await mockBackend(page); + let called = false; + await page.route(`**/api/magazine/${DOC_ID}/boxes/clean-up`, route => { + called = true; + return route.fulfill({ json: { ok: true, backup: 'layout.backup-20260921T120000.json', + counts: { in_picture: 71, ocr_noise: 25, duplicate: 38, byline_split: 5 }, report: {} } }); + }); + await openReview(page); + await page.getByRole('button', { name: 'Clean up boxes', exact: true }).click(); + const dialog = page.getByRole('alertdialog', { name: 'Clean up boxes?' }); + await expect(dialog).toContainText('Nothing is deleted'); + expect(called).toBe(false); + await dialog.getByRole('button', { name: 'Clean up', exact: true }).click(); + await expect(page.getByText(/71 text in pictures, 25 OCR noise, 38 duplicates, 5 author lines split/)).toBeVisible(); +}); + +test('a box marked as text in a picture shows that type in its picker', async ({ page }) => { + await mockBackend(page); + await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: { ...LAYOUT, + blocks: LAYOUT.blocks.map(b => b.id === 'b3' ? { ...b, type: 'furniture', region_label: 'in_picture' } : b) } })); + await openReview(page); + await expect(page.locator('[data-block-row="b3"] select.mag-block-type-select')).toHaveValue('in_picture'); +}); diff --git a/tests/test_magazine_routes.py b/tests/test_magazine_routes.py index 21d1dab..11f83ee 100644 --- a/tests/test_magazine_routes.py +++ b/tests/test_magazine_routes.py @@ -790,3 +790,133 @@ class HandOrderTests(unittest.TestCase): with self.assertRaises(HTTPException) as err: asyncio.run(magazine_set_page_order('a' * 32, 1, request)) self.assertEqual(err.exception.status_code, 400) + + +class CleanUpBoxesTests(unittest.TestCase): + """Each rule, and what each rule must leave alone (sized on a real 204-page issue).""" + + def base(self, blocks, images=()): + return {"pages": [{"page": 1, "width": 595.0, "height": 842.0}], "blocks": blocks, "images": list(images)} + + def blk(self, bid, x, y, w, h, text, btype="body", ro=0): + return {"id": bid, "page": 1, "bbox": [x, y, w, h], "type": btype, "text": text, "reading_order": ro} + + def types(self, layout): + return {b["id"]: (b["type"], b.get("region_label", "")) for b in layout["blocks"]} + + def test_a_short_label_inside_a_picture_is_not_read_but_long_text_there_is(self): + from core.magazine import clean_up_regions + photo = {"id": "img", "page": 1, "bbox": [50, 50, 400, 300], "filename": "img.png"} + layout = self.base([ + self.blk("label", 100, 100, 120, 20, "Apple Intelligence", "title"), + self.blk("long", 60, 150, 380, 150, "nutzen, ohne dass du ein Prüfverfahren durchlaufen musst. " * 3), + self.blk("outside", 50, 400, 400, 100, "Running text under the photo."), + ], [photo]) + types = self.types(clean_up_regions(layout)[0]) + self.assertEqual(types["label"], ("furniture", "in_picture")) + self.assertEqual(types["long"][0], "body") # real text caught in a loose frame stays + self.assertEqual(types["outside"][0], "body") + + def test_one_or_two_characters_are_noise_but_a_phone_number_is_not(self): + from core.magazine import clean_up_regions + layout = self.base([ + self.blk("one", 10, 10, 10, 10, "1", "title"), + self.blk("j3", 30, 10, 20, 10, "J 3", "title"), + self.blk("hash", 60, 10, 10, 10, "#"), + self.blk("phone", 80, 10, 90, 10, "0511/592 99 077", "caption"), + self.blk("cite", 200, 10, 90, 10, "c't 17/2025, S. 136", "caption"), + ]) + types = self.types(clean_up_regions(layout)[0]) + for bid in ("one", "j3", "hash"): + self.assertEqual(types[bid], ("furniture", "ocr_noise"), bid) + self.assertEqual(types["phone"][0], "caption") + self.assertEqual(types["cite"][0], "caption") + + def test_a_subheading_the_body_box_also_read_is_a_duplicate(self): + from core.magazine import clean_up_regions + layout = self.base([ + self.blk("body", 50, 100, 300, 400, "Erster Absatz endet hier. Fazit Am Ende bleibt festzuhalten, dass alles gut ist."), + self.blk("sub", 50, 200, 60, 14, "Fazit", "heading"), + ]) + self.assertEqual(self.types(clean_up_regions(layout)[0])["sub"], ("furniture", "duplicate")) + + def test_an_overlapping_box_with_different_text_stays(self): + """The large majority of overlaps on the measured issue: hiding one would lose text.""" + from core.magazine import clean_up_regions + layout = self.base([ + self.blk("big", 50, 100, 300, 400, "One paragraph about something."), + self.blk("small", 50, 300, 300, 80, "A different paragraph entirely."), + ]) + self.assertEqual(self.types(clean_up_regions(layout)[0])["small"][0], "body") + + def test_a_runon_author_line_is_split_into_name_and_its_own_paragraph(self): + from core.magazine import clean_up_regions + layout = self.base([ + self.blk("by", 29, 223, 200, 30, "Von Andreas Weck Als Microsoft-CTO Kevin Scott vor einem Jahr", "byline", 0), + self.blk("left", 29, 260, 200, 200, "sagte, dass KI bis 2030 rund 95 Prozent des Codes schreiben wird.", "body", 1), + self.blk("right", 240, 223, 200, 200, "ihnen gebraucht werde, um Programmieraufgaben zu erledigen.", "body", 2), + ]) + out, report = clean_up_regions(layout) + by = next(b for b in out["blocks"] if b["id"] == "by") + self.assertEqual(by["text"], "Von Andreas Weck") + self.assertIn("Als Microsoft-CTO", by["original_text"]) + new_id = report["byline_split"][0]["new_id"] + order = [b["id"] for b in sorted(out["blocks"], key=lambda b: b["reading_order"])] + # The sentence is its own paragraph, right after the name and before the left column's next + # paragraph -- never glued onto the other column. + self.assertEqual(order[:3], ["by", new_id, "left"]) + self.assertTrue(next(b for b in out["blocks"] if b["id"] == "right")["text"].startswith("ihnen")) + + def test_only_the_name_is_kept_when_the_sentence_is_already_on_the_page(self): + from core.magazine import clean_up_regions + layout = self.base([ + self.blk("by", 29, 223, 200, 30, "Von Robin Ahrens Kurz vor dem Abendessen sind die Kinder", "byline", 0), + self.blk("body", 29, 260, 200, 200, "Kurz vor dem Abendessen sind die Kinder gereizt und müde.", "body", 1), + ]) + out, report = clean_up_regions(layout) + self.assertIsNone(report["byline_split"][0]["new_id"]) + self.assertEqual(len(out["blocks"]), 2) + + def test_a_plain_two_author_byline_and_a_sentence_starting_with_von_are_untouched(self): + from core.magazine import clean_up_regions + layout = self.base([ + self.blk("two", 29, 100, 200, 14, "Von Sabrina Patsch und Morton Swimmer", "byline"), + self.blk("sentence", 29, 200, 200, 80, "Von Familienmitgliedern vergangener Generationen bleibt oft nicht viel."), + ]) + out, report = clean_up_regions(layout) + self.assertEqual(report["byline_split"], []) + self.assertEqual({b["id"]: b["text"] for b in out["blocks"]}["two"], "Von Sabrina Patsch und Morton Swimmer") + + def test_running_it_twice_changes_nothing_the_second_time(self): + from core.magazine import clean_up_regions + photo = {"id": "img", "page": 1, "bbox": [50, 50, 400, 300], "filename": "img.png"} + layout = self.base([self.blk("label", 100, 100, 120, 20, "Label", "title"), self.blk("n", 10, 10, 5, 5, "V"), + self.blk("by", 29, 400, 200, 30, "Von Jo Bager KI gilt als Antwort", "byline")], [photo]) + once, _ = clean_up_regions(layout) + _, report = clean_up_regions(once) + self.assertEqual({k: len(v) for k, v in report.items()}, {"in_picture": 0, "ocr_noise": 0, "duplicate": 0, "byline_split": 0}) + + def test_route_keeps_a_backup_and_adds_the_new_paragraph_to_the_article(self): + import asyncio + from pathlib import Path + from unittest.mock import patch + from routes.magazine import magazine_clean_up_boxes + d = Path(tempfile.mkdtemp(prefix="ttvc-cleanup-")) + layout = self.base([self.blk("by", 29, 223, 200, 30, "Von Jo Bager KI gilt als Antwort", "byline", 0), + self.blk("body", 29, 260, 200, 200, "auf den Fachkräftemangel.", "body", 1)]) + articles = [{"id": "a1", "blocks": ["by", "body"], "images": []}] + saved = {} + try: + with patch('routes.magazine._require_magazine', return_value=(d, {})), \ + patch('routes.magazine.mag.load_layout', return_value=layout), \ + patch('routes.magazine.mag.save_layout'), \ + patch('routes.magazine.mag.load_articles', return_value=articles), \ + patch('routes.magazine.mag.save_articles', side_effect=lambda _d, a: saved.setdefault('a', a)), \ + patch('routes.magazine.asyncio.to_thread', new=_run_sync): + out = asyncio.run(magazine_clean_up_boxes('a' * 32)) + self.assertTrue((d / out["backup"]).is_file()) + self.assertEqual(out["counts"]["byline_split"], 1) + new_id = out["report"]["byline_split"][0]["new_id"] + self.assertEqual(saved["a"][0]["blocks"], ["by", new_id, "body"]) + finally: + shutil.rmtree(d, ignore_errors=True)