fix(magazine): clipped-edge audit requires ink on the box's own text lines

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 17:54:12 +02:00
parent 9fa661fb6a
commit 1b9a4bd3bc
4 changed files with 77 additions and 5 deletions

View File

@ -40,6 +40,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **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.
- **Stacked boxes of a column now meet at the gap between two lines** (*seam mending*). Detection often cuts a column into slices that overlap or leave a line in the seam, so that line was in neither box and both reported a clipped edge. The shared boundary moves to the nearest blank gap (below the line for a paragraph that is still running, above it after a finished one, in the middle of an overlap); a line that had fallen into the seam is read on its own and attached to the box that now owns it, and the box takes the column's width. Page 32 of the measured issue: clipped edges 2 → 0 and the lost line "Entwicklungen auseinander. „KI ist bei uns in allen" is back in the text. 57 seams were mended across the issue.
- **"Edge cuts text" needs the box's own lines.** Ink beside a left/right edge only counts as cut-off letters when it sits on the rows where the box itself has text and is intermittent (letters and gaps); a solid vertical line (a screenshot frame, a chart border, a photo edge) or ink between the box's lines does not, and a thin horizontal rule above or below is not a cut line. On the measured issue clipped edges fell from 82 to 48 and pages needing a decision from 53 to 42.
- **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).
### Changed

View File

@ -84,8 +84,9 @@ marked on the page for a human. Order of work:
The current issue (c't KI-Wissen 2026) is the test bed. The point is to measure whether the newest
rules beat what was built up by hand, and to feed every difference back into the rules. Do these in order.
- [ ] **A — Finish verifying this magazine.** Work the remaining flags (about 53 pages need a decision:
~39 clipped edges, ~21 unboxed areas, ~10 overlaps), review and approve the 33 not-reviewed articles,
- [ ] **A — Finish verifying this magazine.** Work the remaining flags (42 pages need a decision as of
2026-09-28: 26 with a clipped edge — most sampled ones are cosmetic, the box encloses its text —, 21 with
unboxed ink, 10 with overlaps), review and approve the 33 not-reviewed articles,
and run the spelling/acronym steps once the language model answers again. Done when the report lists no
page needing a decision and every article is approved or rejected.
- [ ] **B — Make a backup copy.** Copy the whole magazine folder (`reader_library/<id>/`: layout, articles,

View File

@ -2922,11 +2922,44 @@ def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str
ink_share = (pixels[r0:r1, c0:c1] & free).sum() / free.sum()
# Text is ink between the lines; a strip that is (nearly) solid ink is a photo or a
# filled panel behind the headline, not letters cut off by the box.
if _AUDIT_CLIP_INK <= ink_share <= _AUDIT_CLIP_MAX_INK:
found.append({"id": block["id"], "side": side})
if not (_AUDIT_CLIP_INK <= ink_share <= _AUDIT_CLIP_MAX_INK):
continue
if not _strip_continues_the_text(pixels, side, (r0, r1, c0, c1), free, (top, bottom, left, right)):
continue
found.append({"id": block["id"], "side": side})
return found
def _strip_continues_the_text(pixels, side: str, strip: tuple[int, int, int, int], free,
box: tuple[int, int, int, int]) -> bool:
"""Whether the ink in a strip beside a box looks like the box's own text running on.
Letters cut off by a box edge sit on the same text lines as the box's own text, so beside a
left/right edge the strip's inked pixel rows fall on rows where the box itself has ink; and the
ink is intermittent (letters and gaps), not a line. A screenshot frame, a chart border or the
edge of a photo is a solid vertical line or does not follow the box's lines. Above/below, a
thin horizontal rule is not a cut line. A box with no ink of its own cannot be judged and is
kept as reported.
"""
r0, r1, c0, c1 = strip
top, bottom, left, right = box
ink = pixels[r0:r1, c0:c1] & free
if side in ("left", "right"):
inner = pixels[max(0, top):bottom, max(0, left):right]
strip_rows = ink.any(axis=1)
if inner.size == 0 or not inner.any() or not strip_rows.any():
return True
inner_rows = inner.any(axis=1)
rows = min(len(strip_rows), len(inner_rows))
strip_rows, inner_rows = strip_rows[:rows], inner_rows[:rows]
on_lines = float((strip_rows & inner_rows).sum()) / float(strip_rows.sum())
solid = float(strip_rows.sum()) / max(1, rows)
return on_lines >= 0.75 and solid <= 0.9
rows_inked = int(ink.any(axis=1).sum())
coverage = float(ink.any(axis=0).sum()) / max(1, ink.shape[1])
return not (rows_inked <= 3 and coverage >= 0.7)
_OVERLAP_RANK = {BLOCK_TITLE: 0, BLOCK_HEADING: 0, BLOCK_DECK: 1, BLOCK_BYLINE: 1, BLOCK_BODY: 2,
BLOCK_QUOTE: 2, BLOCK_PULLQUOTE: 3, BLOCK_CAPTION: 3, BLOCK_SIDEBAR: 3}
_OVERLAP_MIN_KEEP = 0.4 # never trim a box down to less than this share of its own area

View File

@ -2567,7 +2567,7 @@ class ClippedEdgeTests(unittest.TestCase):
pixels = np.zeros((200, 200), dtype=bool)
pixels[20:100:6, 20:100] = True # ruled "text" lines, 6px pitch
pixels[100:112:6, 20:100] = True # an extra line below the box
pixels[20:100, 100:110] = True # ink running past the right edge
pixels[20:100:6, 100:110] = True # the same lines running on past the right edge
meta = {"width": 200, "height": 200}
block = {"id": "b", "bbox": [20, 20, 80, 80]}
sides = {c["side"] for c in _clipped_edges(pixels, meta, [block], [block["bbox"]])}
@ -3389,3 +3389,40 @@ class MendColumnSeamsTests(unittest.TestCase):
with mock.patch('core.magazine.extract_region_text', return_value="|"):
out, n = read_seam_strips(self.dir, layout, [{"id": "a", "where": "end", "strip": [98, 137, 205, 22]}])
self.assertEqual((n, out["blocks"][0]["text"]), (0, "Der Absatz beginnt hier und"))
class ClippedEdgeNeedsTheBoxsOwnLinesTests(unittest.TestCase):
"""A box with two text lines (x 100..250); what stands beside its right edge decides."""
def audit(self, beside):
from pathlib import Path
from PIL import Image, ImageDraw
from core.magazine import audit_page_coverage
d = Path(tempfile.mkdtemp(prefix="ttvc-clip2-"))
self.addCleanup(shutil.rmtree, d, True)
(d / "pages").mkdir()
im = Image.new("RGB", (600, 800), (248, 242, 244))
draw = ImageDraw.Draw(im)
for y in range(100, 148, 16): # the box's own two text lines
draw.rectangle([100, y, 250, y + 6], fill=(93, 91, 92))
beside(draw)
im.save(d / "pages" / "0001.png")
layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [
{"id": "t", "page": 1, "type": "body", "text": "Zwei Zeilen Text", "bbox": [98, 98, 155, 56], "reading_order": 0}]}
return [c["side"] for c in audit_page_coverage(d, layout, 1)["clipped"]]
def test_letters_running_on_from_the_same_lines_are_clipping(self):
def words(draw):
for y in range(100, 148, 16):
draw.rectangle([256, y, 275, y + 6], fill=(93, 91, 92)) # the lines continue past the edge
self.assertEqual(self.audit(words), ["right"])
def test_a_frame_line_or_picture_edge_beside_the_box_is_not_clipping(self):
def frame(draw):
draw.rectangle([256, 90, 258, 170], fill=(93, 91, 92)) # a solid vertical frame line
self.assertEqual(self.audit(frame), [])
def off_lines(draw):
draw.rectangle([256, 116, 270, 118], fill=(93, 91, 92)) # ink between the box's own lines
draw.rectangle([256, 132, 270, 134], fill=(93, 91, 92))
self.assertEqual(self.audit(off_lines), [])