From 32a23a237aa04545c8d065e5e440171365845b31 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 28 Sep 2026 17:34:06 +0200 Subject: [PATCH] fix(magazine): dotted leaders and thin rules are not reported as missed text Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + core/magazine.py | 17 +++++++++++++++++ tests/test_magazine.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 372acf7..03e5df8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ยท versioned wi - **Articles nobody has reviewed are blue.** In the article list an unapproved, unrejected article has a blue background and edge and a *not reviewed* pill (approved keeps its green edge), so what still needs a person stands out. - **Overlapping boxes are fixed automatically.** Where a lower-ranked box runs over a higher-ranked one (headline > deck/byline > body > caption/sidebar/pull quote), the lower one is cut back to the largest part that no longer overlaps, unless that would leave under 40% of it (then both stay for a human). Hand-placed boxes and boxes of equal rank are never touched. - **Fewer false alarms and better fits:** a strip of solid ink beside a headline (a photo or filled panel behind it) is no longer counted as letters cut off; headline and deck boxes may grow up to 70 px sideways (body boxes 16 px); an inset pull-quote box that repeats text a box already holds becomes a duplicate. +- **Rules and dotted leaders are not missed text.** The dots between a table-of-contents entry and its page number (and thin printed rules) are ink but no letters; the audit now ignores ink that is only a few pixel rows tall. - **Unboxed ink in or beside a picture, chart or advert is no longer reported as missed text** (labels of a chart, text set into a photo, an advert's own wording). ### Fixed diff --git a/core/magazine.py b/core/magazine.py index 6fde51e..f3fb0a0 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -2878,6 +2878,21 @@ _AUDIT_GRAPHIC_MARGIN = 10.0 # points: unboxed ink this close to a picture/ch _AUDIT_CLIP_STRIP_PX = 14 # how far outside a box edge to look for ink that belongs to it (~1-2 letters) +_RULE_MAX_INK_ROWS = 6 # pixel rows with ink: a rule or dotted leader is a few, a text line is 8+ + + +def _is_rule_only(pixels, blob: Sequence[float], page_meta: dict[str, Any]) -> bool: + """True when the ink inside `blob` is only a thin rule or a dotted leader (the dots between a + table-of-contents entry and its page number), which has no letters and nothing to narrate.""" + height, width = pixels.shape + sx, sy = width / max(1.0, float(page_meta["width"])), height / max(1.0, float(page_meta["height"])) + x, y, w, h = blob + region = pixels[max(0, int(y * sy)):int((y + h) * sy) + 1, max(0, int(x * sx)):int((x + w) * sx) + 1] + if region.size == 0: + return False + return int((region.sum(axis=1) >= 3).sum()) <= _RULE_MAX_INK_ROWS + + def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str, Any]], boxes: list[Sequence[float]]) -> list[dict[str, Any]]: """Text boxes whose edge cuts through text: ink in a thin strip just outside a side, ignoring @@ -3534,6 +3549,8 @@ def audit_page_coverage(doc_dir: Path, layout: dict[str, Any], page_no: int) -> # graphic, not narratable text a box forgot: a blob in or beside one is not reported. if any(bbox_overlaps(blob, g, tol=_AUDIT_GRAPHIC_MARGIN) for g in graphic_boxes): continue + if pixels is not None and _is_rule_only(pixels, blob, page_meta): + continue # a printed rule or dotted leader, not letters kept_cells += len(rows) report["uncovered"].append(blob) report["uncovered_ratio"] = round(kept_cells / total, 4) diff --git a/tests/test_magazine.py b/tests/test_magazine.py index 5e61a8e..3624987 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -3269,3 +3269,33 @@ class ClippedEdgeIgnoresSolidBackgroundsTests(unittest.TestCase): def test_text_running_past_the_edge_is_clipping_but_a_solid_panel_is_not(self): self.assertEqual([c["side"] for c in self.audit(False)], ["right"]) self.assertEqual(self.audit(True), []) + + +class LeaderLineIsNotMissedTextTests(unittest.TestCase): + def audit(self, draw_fn): + from pathlib import Path + from PIL import Image, ImageDraw + from core.magazine import audit_page_coverage + d = Path(tempfile.mkdtemp(prefix="ttvc-rule-")) + self.addCleanup(shutil.rmtree, d, True) + (d / "pages").mkdir() + im = Image.new("RGB", (600, 800), (248, 242, 244)) + draw_fn(ImageDraw.Draw(im)) + im.save(d / "pages" / "0001.png") + layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": []} + return audit_page_coverage(d, layout, 1)["uncovered"] + + def test_a_dotted_leader_or_rule_is_not_reported_but_text_lines_are(self): + def leader(draw): + for x in range(200, 400, 6): # dots between a heading and its page number + draw.rectangle([x, 300, x + 1, 302], fill=(93, 91, 92)) + self.assertEqual(self.audit(leader), []) + + def rule(draw): + draw.rectangle([100, 500, 400, 502], fill=(93, 91, 92)) + self.assertEqual(self.audit(rule), []) + + def text(draw): + for y in range(300, 360, 12): + draw.rectangle([100, y, 300, y + 8], fill=(93, 91, 92)) + self.assertEqual(len(self.audit(text)), 1)