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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-21 12:06:33 +02:00
parent baab49dbdf
commit b7aa823455
11 changed files with 446 additions and 16 deletions

View File

@ -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

View File

@ -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-<date>.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

View File

@ -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

View File

@ -1 +1 @@
1.36.0
1.37.0

View File

@ -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-<ts>.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

View File

@ -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

View File

@ -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

View File

@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.36.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.36.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.36.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.36.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.36.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.36.0">
<link rel="stylesheet" href="/static/style.css?v=1.37.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.37.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.37.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.37.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.37.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.37.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -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() {
'<button type="button" class="btn-sm" id="mag-reading-order-btn" onclick="magFixReadingOrder()" ' +
'title="Re-read every page column by column, left to right. Text and approvals stay as they are; pages you ordered by hand are kept." >' +
'<span class="mdi mdi-sort-numeric-ascending" aria-hidden="true"></span> Fix reading order</button>' +
'<button type="button" class="btn-sm" id="mag-cleanup-btn" onclick="magCleanUpBoxes()" ' +
'title="Stop reading text inside pictures, OCR noise and duplicated boxes, and split author lines that run into the text. Nothing is deleted; a backup is kept.">' +
'<span class="mdi mdi-broom" aria-hidden="true"></span> Clean up boxes</button>' +
'<button type="button" class="btn-sm" id="mag-refine-figures-btn" onclick="magRefineFigures()" ' +
'title="Re-check every figure boundary in this issue against the scanned pages">' +
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries</button>' +
@ -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;

View File

@ -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');
});

View File

@ -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)