feat(magazine): fit text boxes to their ink in Clean up boxes

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 08:12:12 +02:00
parent 91caa6cf90
commit f4892dbde4
7 changed files with 199 additions and 3 deletions

View File

@ -12,6 +12,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Pronunciation & spelling panel (per article).** Acronyms are spoken from the article's language (decided once per article, with per-block override for quotations) using an extended built-in list, letter-by-letter spelling for `USB`/`HDMI`-style acronyms, and an editable per-article glossary. Unfamiliar acronyms are listed with their sentence, and the language model can decide expansion / letters / word from context. - **Pronunciation & spelling panel (per article).** Acronyms are spoken from the article's language (decided once per article, with per-block override for quotations) using an extended built-in list, letter-by-letter spelling for `USB`/`HDMI`-style acronyms, and an editable per-article glossary. Unfamiliar acronyms are listed with their sentence, and the language model can decide expansion / letters / word from context.
- **German and English spellchecker with fix-or-mark.** Unknown words are underlined in the review list and listed with suggestions per page and per article; click a suggestion (undoable), or *Keep* to add the word to the issue's dictionary. *Fix with language model* corrects only what passes strict guards (a dictionary suggestion, or a known word within two edits) and keeps names/terms; the whole-magazine quality check now runs the same pass and reports pages with words still marked. - **German and English spellchecker with fix-or-mark.** Unknown words are underlined in the review list and listed with suggestions per page and per article; click a suggestion (undoable), or *Keep* to add the word to the issue's dictionary. *Fix with language model* corrects only what passes strict guards (a dictionary suggestion, or a known word within two edits) and keeps names/terms; the whole-magazine quality check now runs the same pass and reports pages with words still marked.
- **Clean up boxes now also fits text boxes to their text.** A side that cuts through letters grows (at most a letter or two, never into a neighbouring box) and empty margins are trimmed. Boxes you drew or resized by hand are never touched.
### Fixed
- Suspended compounds (`Programmier- und Hausaufgaben`) no longer lose their hyphen when regions are merged or layouts are grouped; already-ingested `Programmierund` shows in the spelling list with a one-click repair.
### Changed ### Changed
- `prepare_text_for_tts` accepts the article language and glossary; narration no longer guesses the language per fragment. - `prepare_text_for_tts` accepts the article language and glossary; narration no longer guesses the language per fragment.

View File

@ -282,6 +282,10 @@ the original for anything that matters.
Podcast generation is refused for an article you have not approved, because Podcast generation is refused for an article you have not approved, because
unreviewed boundaries may include a neighbouring article or an advert. unreviewed boundaries may include a neighbouring article or an advert.
### Clean up boxes: fitting boxes to the text
**Clean up boxes** (review toolbar) also fits each text box to the ink of its text: a side that cuts through letters grows a little, empty margin is trimmed. Boxes you drew or resized are never changed; a backup of the layout is kept and the message reports how many boxes were fitted.
### One paragraph, one block — even across columns and pages ### One paragraph, one block — even across columns and pages
**Purpose:** a paragraph that continues into the next column or onto a later page is separate regions in the layout. Reading and the MP3 join them before splitting into sentences, so narration never stops mid-sentence at a seam. **Purpose:** a paragraph that continues into the next column or onto a later page is separate regions in the layout. Reading and the MP3 join them before splitting into sentences, so narration never stops mid-sentence at a seam.

View File

@ -131,7 +131,7 @@ marked on the page for a human. Order of work:
read, or type a number into each one. The order drives playback and the OCR-results list. 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 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. 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 2026-09-21: type picker and delete per row; Clean up boxes — text in pictures, OCR noise, duplicates, run-on author lines. Done too: keyboard navigation, merge with next, split at cursor, per-row re-OCR and text editing. Still open: snapping text boxes to the ink of their text; the 333 overlapping boxes with different text on the measured issue need a human.) - [ ] **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. Done too: keyboard navigation, merge with next, split at cursor, per-row re-OCR and text editing. Done 2026-09-28: *Clean up boxes* now fits text boxes to their ink (grows over clipped edges, trims empty margins; hand-placed boxes untouched; on the measured issue only 5 of 658 boxes on pages 1-59 needed it). Still open: the 333 overlapping boxes with different text on the measured issue need a human.)
The boxes the layout analysis produces need a pass of their own, on top of the ordering work 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 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 Gieselmann Ein guter Butler kennt die Vorlieben seines"), so author and paragraph both come

View File

@ -2892,6 +2892,120 @@ def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str
return found return found
_SNAP_TEXT_STEP_PX = 2 # grow/shrink granularity
_SNAP_TEXT_MAX_GROW_PX = 16 # never grow a side further than about a letter or two
_SNAP_TEXT_PAD_PX = 2 # air left between the text and its snapped edge
_SNAP_TEXT_MIN_PX = 8 # a snapped box keeps at least this size
_SNAP_TEXT_MIN_MOVE_PX = 3 # smaller corrections are not worth changing a box for
_SNAP_TEXT_LINE_INK = 0.004 # a pixel line with less ink than this share is empty margin
def snap_text_boxes(doc_dir: Path, layout: dict[str, Any],
pages: Iterable[int] | None = None) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Fit each text box to the ink of its own text, using the page render.
Two corrections, both bounded: a side that cuts through letters (ink continues just outside
it, where no other box is) grows until the text ends, by at most a letter or two; empty
margin inside a side is trimmed back to the first line with ink, leaving a little air. A
box the reviewer drew or resized by hand is never touched, and neither is one that no page
render exists for. Geometry only -- the text is not changed. Returns the layout and one
report row per box that moved.
"""
try:
import numpy as np
except Exception:
return layout, []
wanted = set(pages) if pages is not None else None
snappable = set(NARRATABLE) | {BLOCK_CAPTION, BLOCK_SIDEBAR, BLOCK_PULLQUOTE}
layout = dict(layout)
blocks = [dict(b) for b in layout.get("blocks", [])]
moved: list[dict[str, Any]] = []
for meta in layout.get("pages", []):
page_no = meta["page"]
if wanted is not None and page_no not in wanted:
continue
png = doc_dir / "pages" / f"{page_no:04d}.png"
if not png.is_file():
continue
pixels = page_ink_mask(png)
if pixels is None:
continue
height, width = pixels.shape
sx, sy = width / max(1.0, float(meta["width"])), height / max(1.0, float(meta["height"]))
on_page = [b for b in blocks if b["page"] == page_no]
images = [i for i in layout.get("images", []) if i["page"] == page_no]
def to_px(bbox: Sequence[float]) -> list[int]:
x, y, w, h = bbox
return [max(0, int(round(x * sx))), max(0, int(round(y * sy))),
min(width, int(round((x + w) * sx))), min(height, int(round((y + h) * sy)))]
for block in on_page:
if (block["type"] not in snappable or block.get("source") == "manual"
or block.get("box_manual") or block.get("region_label")):
continue
left, top, right, bottom = to_px(block["bbox"])
if right - left < _SNAP_TEXT_MIN_PX or bottom - top < _SNAP_TEXT_MIN_PX:
continue
others = np.zeros_like(pixels)
for other in on_page:
if other is not block:
ol, ot, orr, ob = to_px(other["bbox"])
others[ot:ob, ol:orr] = True
for image in images:
il, it, ir, ib = to_px(image["bbox"])
others[it:ib, il:ir] = True
original = (left, top, right, bottom)
def strip_has_text(r0: int, r1: int, c0: int, c1: int) -> bool:
r0, c0, r1, c1 = max(0, r0), max(0, c0), min(height, r1), min(width, c1)
if r1 <= r0 or c1 <= c0:
return False
free = ~others[r0:r1, c0:c1]
return bool(free.sum() >= 12 and (pixels[r0:r1, c0:c1] & free).sum() / free.sum() >= _AUDIT_CLIP_INK)
step = _SNAP_TEXT_STEP_PX
for _ in range(_SNAP_TEXT_MAX_GROW_PX // step):
if strip_has_text(top, bottom, left - step, left) and left - step >= 0:
left -= step
elif strip_has_text(top, bottom, right, right + step) and right + step <= width:
right += step
elif strip_has_text(top - step, top, left, right) and top - step >= 0:
top -= step
elif strip_has_text(bottom, bottom + step, left, right) and bottom + step <= height:
bottom += step
else:
break
if (left, top, right, bottom) == original:
# Not clipped: trim the empty margins instead. A line is empty margin when it
# holds (almost) no ink of its own.
def line_empty(r0: int, r1: int, c0: int, c1: int) -> bool:
area = max(1, (r1 - r0) * (c1 - c0))
return (pixels[r0:r1, c0:c1].sum() / area) <= _SNAP_TEXT_LINE_INK
while right - left - step >= _SNAP_TEXT_MIN_PX and line_empty(top, bottom, left, left + step):
left += step
while right - left - step >= _SNAP_TEXT_MIN_PX and line_empty(top, bottom, right - step, right):
right -= step
while bottom - top - step >= _SNAP_TEXT_MIN_PX and line_empty(top, top + step, left, right):
top += step
while bottom - top - step >= _SNAP_TEXT_MIN_PX and line_empty(bottom - step, bottom, left, right):
bottom -= step
if (left, top, right, bottom) != original:
left, top = max(0, left - _SNAP_TEXT_PAD_PX), max(0, top - _SNAP_TEXT_PAD_PX)
right, bottom = min(width, right + _SNAP_TEXT_PAD_PX), min(height, bottom + _SNAP_TEXT_PAD_PX)
moves = [abs(a - b) for a, b in zip((left, top, right, bottom), original)]
if max(moves) < _SNAP_TEXT_MIN_MOVE_PX:
continue
block["bbox"] = [round(left / sx, 1), round(top / sy, 1),
round((right - left) / sx, 1), round((bottom - top) / sy, 1)]
block["box_snapped"] = True
moved.append({"page": page_no, "id": block["id"],
"grew": (left, top) < original[:2] or (right, bottom) > original[2:]})
layout["blocks"] = blocks
return layout, moved
def duplicate_article_groups(articles: Sequence[dict[str, Any]]) -> list[list[str]]: def duplicate_article_groups(articles: Sequence[dict[str, Any]]) -> list[list[str]]:
"""Groups of articles that share most of their text regions -- the same words would be """Groups of articles that share most of their text regions -- the same words would be
narrated once per article. Two articles are grouped when the smaller one shares at least narrated once per article. Two articles are grouped when the smaller one shares at least
@ -3492,6 +3606,7 @@ def update_region(doc_dir: Path, layout: dict[str, Any], page_no: int, block_id:
old_text = str(block.get("text") or "") old_text = str(block.get("text") or "")
if bbox is not None: if bbox is not None:
block["bbox"] = list(bbox) block["bbox"] = list(bbox)
block["box_manual"] = True # placed by hand: snap_text_boxes leaves it alone
new_text = None new_text = None
if reocr: if reocr:
new_text = extract_region_text( new_text = extract_region_text(

View File

@ -1327,6 +1327,9 @@ async def magazine_clean_up_boxes(doc_id: str):
backup = d / f"layout.backup-{time.strftime('%Y%m%dT%H%M%S')}.json" 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") backup.write_text(json.dumps(layout, ensure_ascii=False), "utf-8")
layout, report = await asyncio.to_thread(mag.clean_up_regions, layout) layout, report = await asyncio.to_thread(mag.clean_up_regions, layout)
# Last, so the boxes are fitted after the rules above have settled which ones remain.
layout, snapped = await asyncio.to_thread(mag.snap_text_boxes, d, layout)
report["snapped"] = snapped
mag.save_layout(d, layout) mag.save_layout(d, layout)
articles = mag.load_articles(d) articles = mag.load_articles(d)
if _add_split_paragraphs_to_articles(articles, report): if _add_split_paragraphs_to_articles(articles, report):

View File

@ -2398,7 +2398,7 @@ async function magReviewSubmitEdit(reocr) {
async function magCleanUpBoxes() { async function magCleanUpBoxes() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
const ok = await confirmDialog('Clean up the boxes of this whole issue: text inside pictures, OCR noise (boxes of one or two characters) ' + 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. ' + 'and boxes that repeat a bigger box\'s text stop being read aloud, author lines that run into the first sentence are split, and text boxes are fitted to the ink of their text (boxes you drew or resized yourself are left alone). ' +
'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.', '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' }); { title: 'Clean up boxes?', okLabel: 'Clean up' });
if (!ok) return; if (!ok) return;
@ -2412,7 +2412,7 @@ async function magCleanUpBoxes() {
return say('Could not clean up the boxes: ' + e.message, 'err'); return say('Could not clean up the boxes: ' + e.message, 'err');
} }
const c = r.counts || {}; 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']] const parts = [[c.in_picture, 'text in pictures'], [c.ocr_noise, 'OCR noise'], [c.duplicate, 'duplicates'], [c.byline_split, 'author lines split'], [c.snapped, 'boxes fitted to their text']]
.filter(([n]) => n).map(([n, what]) => n + ' ' + what); .filter(([n]) => n).map(([n, what]) => n + ' ' + what);
say(parts.length ? 'Cleaned up: ' + parts.join(', ') + '. Backup: ' + r.backup : 'Nothing to clean up.', 'ok'); say(parts.length ? 'Cleaned up: ' + parts.join(', ') + '. Backup: ' + r.backup : 'Nothing to clean up.', 'ok');
state.layout = null; state.layout = null;

View File

@ -2674,3 +2674,72 @@ class SuspendedCompoundSurvivesRegionEditsTests(unittest.TestCase):
{"id": "b", "page": 1, "type": "body", "bbox": [0, 10, 10, 10], "text": "tert", "reading_order": 1}]} {"id": "b", "page": 1, "type": "body", "bbox": [0, 10, 10, 10], "text": "tert", "reading_order": 1}]}
out, _ = merge_with_next(layout, 1, "a") out, _ = merge_with_next(layout, 1, "a")
self.assertEqual(out["blocks"][0]["text"], "eingetrichtert") self.assertEqual(out["blocks"][0]["text"], "eingetrichtert")
class SnapTextBoxesTests(unittest.TestCase):
"""A 600x800 white page (1 px = 1 point) with solid 'text' blocks of known extent."""
def setUp(self):
from pathlib import Path
from PIL import Image, ImageDraw
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-snap-"))
(self.dir / "pages").mkdir()
im = Image.new("RGB", (600, 800), "white")
draw = ImageDraw.Draw(im)
for y in range(100, 200, 10): # ten 'lines' of ink: x 100..300, y 100..200
draw.rectangle([100, y, 300, y + 5], fill="black")
for y in range(100, 200, 10): # a neighbouring column: x 320..500
draw.rectangle([320, y, 500, y + 5], fill="black")
im.save(self.dir / "pages" / "0001.png")
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def layout(self, boxes):
return {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [],
"blocks": [{"id": i, "page": 1, "type": "body", "text": "t", "bbox": bb, "reading_order": n, **extra}
for n, (i, bb, extra) in enumerate(boxes)]}
def snap(self, boxes):
from core.magazine import snap_text_boxes
layout, moved = snap_text_boxes(self.dir, self.layout(boxes))
return {b["id"]: b["bbox"] for b in layout["blocks"]}, moved, layout
def test_empty_margin_is_trimmed_to_the_text_with_a_little_air(self):
got, moved, _ = self.snap([("a", [60, 60, 250, 200], {})]) # box 60..310 x 60..260 around the text
x, y, w, h = got["a"]
self.assertTrue(96 <= x <= 100 and 96 <= y <= 100, got)
self.assertTrue(300 <= x + w <= 306 and 196 <= y + h <= 206, got)
self.assertEqual(len(moved), 1)
def test_a_side_that_cuts_through_the_text_grows_until_the_text_ends(self):
got, moved, _ = self.snap([("a", [100, 100, 190, 100], {})]) # right edge at 290, text runs to 300
x, y, w, h = got["a"]
self.assertGreaterEqual(x + w, 298)
self.assertLessEqual(x + w, 306)
self.assertTrue(moved[0]["grew"])
def test_growth_never_enters_a_neighbouring_box(self):
got, _, _ = self.snap([("a", [100, 100, 190, 100], {}), ("b", [296, 90, 220, 120], {})])
self.assertLessEqual(got["a"][0] + got["a"][2], 300)
def test_boxes_placed_by_hand_and_labelled_regions_are_left_alone(self):
boxes = [("m", [60, 60, 250, 200], {"source": "manual"}), ("e", [60, 60, 250, 200], {"box_manual": True}),
("l", [60, 60, 250, 200], {"region_label": "in_picture"})]
got, moved, _ = self.snap(boxes)
self.assertEqual(moved, [])
self.assertEqual(got["m"], [60, 60, 250, 200])
def test_a_box_already_fitted_does_not_move_again_and_text_is_untouched(self):
first, _, layout = self.snap([("a", [60, 60, 250, 200], {})])
from core.magazine import snap_text_boxes
again, moved = snap_text_boxes(self.dir, layout)
self.assertEqual(moved, [])
self.assertEqual([b["text"] for b in again["blocks"]], ["t"])
def test_a_page_without_a_render_or_a_picture_type_is_skipped(self):
from core.magazine import snap_text_boxes
layout = self.layout([("f", [60, 60, 250, 200], {"type": "figure"})])
self.assertEqual(snap_text_boxes(self.dir, layout)[1], [])
(self.dir / "pages" / "0001.png").unlink()
self.assertEqual(snap_text_boxes(self.dir, self.layout([("a", [60, 60, 250, 200], {})]))[1], [])