From d1c15b0e2bfa0aaab9ede00bcc05e7b8745835de Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 28 Sep 2026 08:35:35 +0200 Subject: [PATCH] fix(magazine): boxes grow over cut-off lines without entering neighbours; re-read grown boxes Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- core/magazine.py | 60 +++++++++++++++++++++++++++++++++++++----- routes/magazine.py | 5 +++- tests/test_magazine.py | 33 +++++++++++++++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8084630..7f63563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ 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. - **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. It also runs automatically after **OCR again (this page)**; on page 61 of the measured issue it widened the left-column paragraphs ~10 pt to cover their text, cutting clipped edges from 7 to 1. +- **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. It also runs automatically after **OCR again (this page)**; on page 61 of the measured issue it widened the left-column paragraphs ~10 pt to cover their text, cutting clipped edges from 7 to 0. A box never grows into a neighbouring box; when it gains whole lines, its text is re-read from the enlarged crop and replaced only by a longer reading that still starts with the old text. ### Fixed - **The quality check no longer flags correct text as "suspect OCR".** The word check guessed the language per fragment (`iPhones statt Token` read as English) and flagged valid German; it now uses the page's language, the same lenient German rules as the spellchecker, and treats words that recur on the page (names, brands) as vocabulary. Page 61 of the measured issue went from 11 suspect regions to 3 (one a genuinely truncated word), and the issue from 758 to 610 before the language fix's full effect. diff --git a/core/magazine.py b/core/magazine.py index 7a25bd6..adac510 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -2906,7 +2906,9 @@ def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str _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_MAX_GROW_PX = 16 # never grow a side sideways further than about a letter or two +_SNAP_TEXT_MAX_GROW_DOWN_PX = 90 # a paragraph cut short at the top/bottom can be a few lines +_SNAP_TEXT_LINE_GAP_PX = 14 # blank pixels between two text lines that still count as one block _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 @@ -2977,18 +2979,24 @@ def snap_text_boxes(doc_dir: Path, layout: dict[str, Any], free = ~others[r0:r1, c0:c1] return bool(free.sum() >= 12 and (pixels[r0:r1, c0:c1] & free).sum() / free.sum() >= _AUDIT_CLIP_INK) + def blocked(r0: int, r1: int, c0: int, c1: int) -> bool: + """The strip a side would grow into already belongs to another box.""" + r0, c0, r1, c1 = max(0, r0), max(0, c0), min(height, r1), min(width, c1) + return bool(r1 > r0 and c1 > c0 and others[r0:r1, c0:c1].any()) + step = _SNAP_TEXT_STEP_PX + gap = _SNAP_TEXT_LINE_GAP_PX budget = {"left": _SNAP_TEXT_MAX_GROW_PX, "right": _SNAP_TEXT_MAX_GROW_PX, - "top": _SNAP_TEXT_MAX_GROW_PX, "bottom": _SNAP_TEXT_MAX_GROW_PX} + "top": _SNAP_TEXT_MAX_GROW_DOWN_PX, "bottom": _SNAP_TEXT_MAX_GROW_DOWN_PX} while True: # each side has its own allowance; stop when none can grow grew = False - if budget["left"] > 0 and left - step >= 0 and strip_has_text(top, bottom, left - step, left): + if budget["left"] > 0 and left - step >= 0 and not blocked(top, bottom, left - step, left) and strip_has_text(top, bottom, left - step, left): left -= step; budget["left"] -= step; grew = True - if budget["right"] > 0 and right + step <= width and strip_has_text(top, bottom, right, right + step): + if budget["right"] > 0 and right + step <= width and not blocked(top, bottom, right, right + step) and strip_has_text(top, bottom, right, right + step): right += step; budget["right"] -= step; grew = True - if budget["top"] > 0 and top - step >= 0 and strip_has_text(top - step, top, left, right): + if budget["top"] > 0 and top - step >= 0 and not blocked(top - step, top, left, right) and strip_has_text(top - gap, top, left, right): top -= step; budget["top"] -= step; grew = True - if budget["bottom"] > 0 and bottom + step <= height and strip_has_text(bottom, bottom + step, left, right): + if budget["bottom"] > 0 and bottom + step <= height and not blocked(bottom, bottom + step, left, right) and strip_has_text(bottom, bottom + gap, left, right): bottom += step; budget["bottom"] -= step; grew = True if not grew: break @@ -3010,6 +3018,7 @@ def snap_text_boxes(doc_dir: Path, layout: dict[str, Any], 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) + grown_down = bottom - original[3] moves = [abs(a - b) for a, b in zip((left, top, right, bottom), original)] if max(moves) < _SNAP_TEXT_MIN_MOVE_PX: continue @@ -3017,11 +3026,48 @@ def snap_text_boxes(doc_dir: Path, layout: dict[str, Any], 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:]}) + "grew": (left, top) < original[:2] or (right, bottom) > original[2:], + # lines were added under/over the box: its text was probably cut short + "lines_added": grown_down > 8 or original[1] - top > 8}) layout["blocks"] = blocks return layout, moved +def reread_grown_regions(doc_dir: Path, layout: dict[str, Any], moved: Sequence[dict[str, Any]], + vision_ocr_url: str | None, vision_ocr_model: str | None, + vision_ocr_fallback_url: str | None = None, + vision_ocr_fallback_model: str | None = None, + timeout: float = 150.0) -> tuple[dict[str, Any], int]: + """Re-read boxes that snap_text_boxes extended by whole lines: their text was cut short too. + + The new reading replaces the old only when it is longer, still starts with the old text (so + a re-read that wandered into another paragraph is refused) and reads like prose. Returns the + layout and how many regions were extended. + """ + layout = dict(layout) + blocks = [dict(b) for b in layout.get("blocks", [])] + by_id = {b["id"]: b for b in blocks} + extended = 0 + for row in moved: + block = by_id.get(row["id"]) + if not row.get("lines_added") or block is None: + continue + old = " ".join((block.get("text") or "").split()) + try: + new = extract_region_text(doc_dir, layout, block["page"], block["bbox"], vision_ocr_url, + vision_ocr_model, vision_ocr_fallback_url, vision_ocr_fallback_model, timeout) + except Exception: + continue + new = " ".join((new or "").split()) + head = re.sub(r"\W+", "", old.casefold())[:24] + if (new and len(new) > len(old) + 8 and head and head in re.sub(r"\W+", "", new.casefold()) + and ocr_quality_score(new) >= 0.55): + block["text"] = new + extended += 1 + layout["blocks"] = blocks + return layout, extended + + 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 narrated once per article. Two articles are grouped when the smaller one shares at least diff --git a/routes/magazine.py b/routes/magazine.py index 0058ab2..3033a70 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -858,7 +858,10 @@ def _reocr_page_sync(doc_id: str, job_id: str, page: int, _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]) - updated, _ = mag.snap_text_boxes(d, updated, pages=[page]) # boxes cover their whole text + updated, snapped = mag.snap_text_boxes(d, updated, pages=[page]) # boxes cover their whole text + if any(m.get("lines_added") for m in snapped): + updated, _ = mag.reread_grown_regions(d, updated, snapped, vision_ocr_url, vision_ocr_model, + vision_ocr_fallback_url, vision_ocr_fallback_model) articles = mag.load_articles(d) mag.remap_reviewed_page(articles, layout, updated, page) _add_split_paragraphs_to_articles(articles, cleanup) diff --git a/tests/test_magazine.py b/tests/test_magazine.py index 11a88e0..f3197ed 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -2723,6 +2723,11 @@ class SnapTextBoxesTests(unittest.TestCase): got, _, _ = self.snap([("a", [100, 100, 190, 100], {}), ("b", [296, 90, 220, 120], {})]) self.assertLessEqual(got["a"][0] + got["a"][2], 300) + def test_a_box_never_grows_into_the_box_below_it(self): + # text continues under 'a' (y 100..200) but 'b' already owns y 150.. of the same column + got, _, _ = self.snap([("a", [100, 100, 200, 45], {}), ("b", [100, 150, 200, 60], {})]) + self.assertLessEqual(got["a"][1] + got["a"][3], 150) + 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], {"type": "furniture", "region_label": "in_picture"})] @@ -2767,3 +2772,31 @@ class BoxFlagsSurviveRebuildTests(unittest.TestCase): layout = {"pages": [{"page": 1, "width": 100, "height": 100}], "images": [], "blocks": [{"id": "a", "page": 1, "bbox": [0, 0, 10, 10], "type": "body", "text": "t", "box_snapped": True}]} self.assertEqual([Block(**b).id for b in layout["blocks"]], ["a"]) + + +class RereadGrownRegionsTests(unittest.TestCase): + def _layout(self, text): + return {"pages": [{"page": 1, "width": 100, "height": 100}], "images": [], + "blocks": [{"id": "a", "page": 1, "type": "body", "bbox": [0, 0, 50, 50], "text": text}]} + + def run_reread(self, old, new, moved=({"id": "a", "lines_added": True},)): + from core.magazine import reread_grown_regions + with mock.patch('core.magazine.extract_region_text', return_value=new): + layout, n = reread_grown_regions(pathlib.Path('/tmp/x'), self._layout(old), list(moved), 'u', 'm') + return layout["blocks"][0]["text"], n + + def test_a_longer_reading_that_starts_with_the_old_text_replaces_it(self): + old = "Allein hilft dieser Index allerdings wenig." + new = old + " Erst die sogenannten App Intents verraten dem System, was Apps mit den Inhalten anfangen." + self.assertEqual(self.run_reread(old, new), (new, 1)) + + def test_a_reading_that_wandered_elsewhere_or_is_not_longer_is_refused(self): + old = "Allein hilft dieser Index allerdings wenig." + self.assertEqual(self.run_reread(old, "Ganz anderer Absatz aus der anderen Spalte mit viel mehr Text drin.")[1], 0) + self.assertEqual(self.run_reread(old, old)[1], 0) + + def test_boxes_that_did_not_gain_lines_are_not_re_read(self): + with mock.patch('core.magazine.extract_region_text') as read: + from core.magazine import reread_grown_regions + reread_grown_regions(pathlib.Path('/tmp/x'), self._layout("t"), [{"id": "a", "lines_added": False}], 'u', 'm') + read.assert_not_called()