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:
parent
baab49dbdf
commit
b7aa823455
@ -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
|
||||
|
||||
15
Manual.md
15
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-<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
|
||||
|
||||
2
TODO.md
2
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
|
||||
|
||||
@ -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
|
||||
|
||||
172
core/magazine.py
172
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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) ── -->
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user