feat(magazine): mend column seams; docs: TODO for verify/re-import/compare and Voice Studio evaluation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 17:44:28 +02:00
parent 32a23a237a
commit 25eea74096
5 changed files with 302 additions and 2 deletions

View File

@ -39,6 +39,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **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.
- **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.
- **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

46
TODO.md
View File

@ -79,6 +79,52 @@ marked on the page for a human. Order of work:
a vision re-read and an LLM adjudication fix what they can prove, everything left
is marked on the page with suggestions (accept / ignore / add to dictionary).
### Magazine: verify, re-import and compare — set 2026-09-28
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,
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,
approvals, dictionary, glossaries, audio, hand-set reading orders) to a dated read-only snapshot, and
record what it contains (page/article/approval counts) so the comparison in E has a fixed baseline.
- [ ] **C — Clone the PDF and re-import it.** Create a second magazine project from the same `source.pdf`
(no reuse of the reviewed layout), so the pipeline starts from nothing exactly as for a new issue.
- [ ] **D — Process the new import to the maximum.** Full ingest, then **Auto-fix all pages**, then every
automatic step (page linking, seam mending, overlap fixing, figures, pull quotes, spelling agent and
acronym glossary with the language model up, vision re-read with the OCR engine reachable). Record
wall-clock time per stage and the remaining manual queue.
- [ ] **E — Compare the new result with the backup.** Script the comparison (no manual eyeballing):
article boundaries and count, page-to-article assignment, block text (word-aligned, as in
`scripts/score_attribution.py`), reading order per page, boxes (IoU per region), figures/pull quotes,
approvals, spelling fixes, glossary entries. Output a machine-readable report plus per-page diffs.
- [ ] **F — Show the differences and decide what is better.** A review view listing every difference with
both versions side by side (page render, boxes, text) and a verdict per difference (new better / backup
better / equal), so the winner is a recorded human decision, not an impression.
- [ ] **G — Adjust the rules so the next magazine needs less work.** For each difference where the backup
won, find the rule that failed and fix it (with a regression test using that page); for each where the new
run won, keep the rule. Re-run D→E→F until the new import matches or beats the backup, then record the
final numbers (pages needing a decision, articles needing linking/approval) in `PROJECT_MAP.md`.
### Voice Studio (github.com/debpalash/voicestudio) — evaluation, container, comparison
- [ ] **1 — Study Voice Studio.** Read its repository (README, licence, architecture, model/engine list,
API, UI, deployment files) and record what it does. Check licence, cost, data flow and maintenance state
before running anything (project rule 8); no scraping, read only what the repository publishes.
- [ ] **2 — Run it as a Docker container.** Add it as its own service (own compose file or profile, not
inside this app's image), on the DGX Spark next to the TTS engines, with pinned versions, a non-root user
and no secrets in the image. Verify it starts, serves its UI and generates audio; document the exact
commands, ports, volumes and GPU needs, and how to stop or remove it without touching this app.
- [ ] **3 — Detailed comparison with this software.** A written comparison (`docs/` or `architecture/`)
covering: voice cloning and voice design, supported engines/models and languages, audiobook and
multi-voice/character casting, magazine and PDF ingestion, STT, API/automation (REST, MCP, jobs), UI,
accessibility and mobile, offline/self-hosting, licensing, performance and resource use, security, and
maintenance. Back every claim with something observed (a run, a measurement or a quoted source), list what
each does better, and end with what is worth adopting here.
### P0 — do first
- [x] **S — Stop provider API keys from reaching frontend JavaScript or logs.** Provider keys are masked in settings

View File

@ -3419,6 +3419,164 @@ def split_around_pullquotes(doc_dir: Path, layout: dict[str, Any],
return layout, rows
_SEAM_WINDOW_PX = 16 # how far around a shared boundary to look for the gap between two lines
_SEAM_MIN_SIDE_PX = 10 # each box keeps at least this much height after a seam moves
_SEAM_STRIP_MIN_PX = 6 # a box that gained less than this needs no text read for the new strip
def mend_column_seams(doc_dir: Path, layout: dict[str, Any],
pages: Iterable[int] | None = None) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Make vertically stacked boxes of one column meet at the white gap between two text lines.
Detection often cuts a column into slices that overlap or leave a line in the seam: the
boundary runs through a line of text, so that line is in neither box (both report a clipped
edge, and neither can grow because the other is in the way). For each pair of neighbouring
boxes whose shared boundary overlaps or cuts through ink, the boundary moves to the nearest
gap of at least two blank pixel rows -- down (to the box below's top) when the upper paragraph
is still running, up otherwise, and to the middle of an overlap. Text and x-extent are not
changed here. Returns the layout and one row per box that gained lines:
{id, strip: [x, y, w, h], where: "end" | "start"}, the newly covered strip whose text the
caller reads (read_seam_strips) and attaches after / before the box's text.
"""
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}
layout = dict(layout)
blocks = [dict(b) for b in layout.get("blocks", [])]
strips: 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"
ink = page_ink_mask(png) if png.is_file() else None
if ink is None:
continue
height, width = ink.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 and b["type"] in snappable
and not b.get("box_manual") and b.get("source") != "manual" and (b.get("text") or "").strip()]
for upper in sorted(on_page, key=lambda b: b["bbox"][1]):
ax, ay, aw, ah = upper["bbox"]
below = [b for b in on_page if b is not upper and b["bbox"][1] > ay + 0.3 * ah
and min(ax + aw, b["bbox"][0] + b["bbox"][2]) - max(ax, b["bbox"][0])
>= 0.6 * min(aw, b["bbox"][2])]
if not below:
continue
lower = min(below, key=lambda b: b["bbox"][1])
bx, by, bw, bh = lower["bbox"]
a_bot, b_top = int(round((ay + ah) * sy)), int(round(by * sy))
left = max(0, int(max(ax, bx) * sx))
right = min(width, int(min(ax + aw, bx + bw) * sx))
if right - left < 20:
continue
lo = max(0, min(a_bot, b_top) - _SEAM_WINDOW_PX)
hi = min(height, max(a_bot, b_top) + _SEAM_WINDOW_PX)
if hi - lo < 6:
continue
profile = ink[lo:hi, left:right].sum(axis=1).astype(float)
peak = float(profile.max())
if peak <= 0:
continue
def cuts_text(row: int) -> bool:
r = row - lo
return bool(profile[max(0, r - 1):r + 2].max() > 0.25 * peak)
overlap = a_bot - b_top
if not (overlap > 2 or cuts_text(a_bot) or cuts_text(b_top)):
continue
blank = profile <= 0.12 * peak
runs, i = [], 0
while i < len(blank):
if blank[i]:
j = i
while j < len(blank) and blank[j]:
j += 1
if j - i >= 2:
runs.append((i, j))
i = j
else:
i += 1
if not runs:
continue
running = not _SENTENCE_END_RE.search((upper.get("text") or "").rstrip())
centre = lambda r: (r[0] + r[1]) / 2
if overlap > 2: # both boxes claim it: split the difference
run = min(runs, key=lambda r: abs(centre(r) - ((a_bot + b_top) / 2 - lo)))
elif running:
# The upper paragraph is still going: a line sitting in the seam is its next line, so
# the seam goes after that line (the first gap below where the lower box starts).
below_start = [r for r in runs if centre(r) >= max(a_bot, b_top) - lo - 1]
run = min(below_start, key=centre) if below_start else min(runs, key=lambda r: abs(centre(r) - (b_top - lo)))
else:
# It ended: a line in the seam opens the paragraph below, so the seam goes before it.
above_end = [r for r in runs if centre(r) <= min(a_bot, b_top) - lo + 1]
run = max(above_end, key=centre) if above_end else min(runs, key=lambda r: abs(centre(r) - (a_bot - lo)))
seam = lo + (run[0] + run[1]) // 2
if seam - int(ay * sy) < _SEAM_MIN_SIDE_PX or int((by + bh) * sy) - seam < _SEAM_MIN_SIDE_PX:
continue
new_bottom, new_top = seam / sy, (seam + 1) / sy
old_bottom, old_top = ay + ah, by
# A line in the seam runs the width of the column, so a box that gains one takes the
# union width of the two stacked boxes (a short last-lines slice is narrower than that).
ux0, ux1 = min(ax, bx), max(ax + aw, bx + bw)
grew_down = (new_bottom - old_bottom) * sy > _SEAM_STRIP_MIN_PX
grew_up = (old_top - new_top) * sy > _SEAM_STRIP_MIN_PX
upper["bbox"] = ([ux0, ay, ux1 - ux0, round(new_bottom - ay, 1)] if grew_down
else [ax, ay, aw, round(new_bottom - ay, 1)])
lower["bbox"] = ([ux0, round(new_top, 1), ux1 - ux0, round(by + bh - new_top, 1)] if grew_up
else [bx, round(new_top, 1), bw, round(by + bh - new_top, 1)])
upper["box_snapped"] = lower["box_snapped"] = True
if grew_down:
strips.append({"id": upper["id"], "where": "end",
"strip": [ux0, round(old_bottom, 1), ux1 - ux0, round(new_bottom - old_bottom, 1)]})
if grew_up:
# The line the old top cut through belongs to the strip too: read down to its end.
line_end = next((lo + r[0] for r in runs if lo + r[0] >= int(old_top * sy)), int(old_top * sy))
strips.append({"id": lower["id"], "where": "start",
"strip": [ux0, round(new_top, 1), ux1 - ux0, round(max(old_top, line_end / sy) - new_top, 1)]})
layout["blocks"] = blocks
return layout, strips
def read_seam_strips(doc_dir: Path, layout: dict[str, Any], strips: Sequence[dict[str, Any]],
vision_ocr_url: str | None = None, vision_ocr_model: str | None = None,
vision_ocr_fallback_url: str | None = None,
vision_ocr_fallback_model: str | None = None) -> tuple[dict[str, Any], int]:
"""Read the lines a mended seam newly covers and attach them to the box that now owns them.
Only the strip is read, so nothing has to agree with an earlier reading of the whole box; the
words go after the text of the box above or before the text of the box below. Reading uses the
vision engine when it answers and local OCR otherwise. Returns the layout and how many strips
were attached; an unreadable or implausible strip is skipped (the box keeps its text).
"""
layout = dict(layout)
blocks = [dict(b) for b in layout.get("blocks", [])]
by_id = {b["id"]: b for b in blocks}
attached = 0
for row in strips:
block = by_id.get(row["id"])
if block is None:
continue
try:
text = " ".join((extract_region_text(doc_dir, layout, block["page"], row["strip"], vision_ocr_url,
vision_ocr_model, vision_ocr_fallback_url,
vision_ocr_fallback_model) or "").split())
except Exception:
continue
if len(text) < 3 or ocr_quality_score(text) < 0.4:
continue
old = block["text"].strip()
block["text"] = f"{old} {text}" if row["where"] == "end" else f"{text} {old}"
attached += 1
layout["blocks"] = blocks
return layout, attached
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,

View File

@ -1108,6 +1108,11 @@ def _fix_page_geometry(d: Path, layout: dict[str, Any], articles: list[dict[str,
layout, quotes = mag.detect_inset_pullquotes(d, layout, [page], url, model, fb_url, fb_model)
layout, wrap = mag.split_around_pullquotes(d, layout, [page])
cleanup["wrap_split"] = wrap
# Stacked boxes of one column meet at the gap between two lines; text lines that had fallen
# into a seam are read and attached to the box that now owns them.
layout, seams = mag.mend_column_seams(d, layout, [page])
if seams:
layout, _ = mag.read_seam_strips(d, layout, seams, url, model, fb_url, fb_model)
layout, trimmed = mag.resolve_box_overlaps(layout, [page])
layout, snapped = mag.snap_text_boxes(d, layout, pages=[page])
# The column-aware reading order is otherwise only applied by the manual "Fix reading order"
@ -1126,7 +1131,7 @@ def _fix_page_geometry(d: Path, layout: dict[str, Any], articles: list[dict[str,
owner["images"] = list(dict.fromkeys([*(owner.get("images") or []), *pictures]))
changed = True
counts = {"noise_retyped": noise, "pullquotes_found": len(quotes), "paragraphs_cut": len(wrap),
"pictures_added": len(pictures), "overlaps_fixed": len(trimmed),
"pictures_added": len(pictures), "overlaps_fixed": len(trimmed), "seams_mended": len(seams),
"boxes_fitted": len(snapped), "bylines_split": len(cleanup.get("byline_split", []))}
return layout, counts, changed
@ -1178,7 +1183,7 @@ def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> Non
layout, geometry, moved_articles = _fix_page_geometry(d, layout, articles, page, settings)
articles_changed = articles_changed or moved_articles
fixed_boxes += (geometry["boxes_fitted"] + geometry["paragraphs_cut"] + geometry["pullquotes_found"]
+ geometry["pictures_added"] + geometry["overlaps_fixed"])
+ geometry["pictures_added"] + geometry["overlaps_fixed"] + geometry["seams_mended"])
step("finding missed text")
layout, added = mag.recover_missing_text(d, layout, page)
if verify:

View File

@ -3299,3 +3299,93 @@ class LeaderLineIsNotMissedTextTests(unittest.TestCase):
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)
class MendColumnSeamsTests(unittest.TestCase):
"""600x800 page (1 px = 1 pt): text lines 6 px tall every 12 px in one column, x 100..300."""
def setUp(self):
from pathlib import Path
from PIL import Image, ImageDraw
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-seam-"))
(self.dir / "pages").mkdir()
im = Image.new("RGB", (600, 800), (248, 242, 244))
draw = ImageDraw.Draw(im)
for y in range(100, 260, 12): # 14 lines: y 100-106 ... 256-262
draw.rectangle([100, y, 300, y + 6], fill=(93, 91, 92))
im.save(self.dir / "pages" / "0001.png")
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def layout(self, a_bottom, b_top, a_text="Der Absatz beginnt hier und"):
blk = lambda i, bbox, t: {"id": i, "page": 1, "type": "body", "text": t, "bbox": bbox, "reading_order": 0}
return {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [
blk("a", [98, 98, 205, a_bottom - 98], a_text), blk("b", [98, b_top, 205, 264 - b_top], "geht weiter und endet.")]}
def lines_cut(self, out):
"""Text lines (as y start) that a box boundary runs through."""
cuts = []
for y in range(100, 260, 12):
for b in out["blocks"]:
top, bottom = b["bbox"][1], b["bbox"][1] + b["bbox"][3]
for edge in (top, bottom):
if y + 1 < edge < y + 5:
cuts.append(y)
return cuts
def test_overlapping_boxes_meet_at_a_gap_between_lines(self):
from core.magazine import mend_column_seams
layout = self.layout(a_bottom=180, b_top=166) # overlap of 14 px, through lines
out, _ = mend_column_seams(self.dir, layout)
a, b = out["blocks"]
self.assertLessEqual(a["bbox"][1] + a["bbox"][3], b["bbox"][1] + 0.5) # no overlap any more
self.assertEqual(self.lines_cut(out), [])
self.assertTrue(a["box_snapped"] and b["box_snapped"])
def test_a_line_left_in_the_seam_goes_to_the_running_paragraph_above_and_is_reported_for_reading(self):
from core.magazine import mend_column_seams
layout = self.layout(a_bottom=137, b_top=160) # the lines at y 136 and 148 lie between the boxes
out, strips = mend_column_seams(self.dir, layout)
a, b = out["blocks"]
self.assertGreaterEqual(a["bbox"][1] + a["bbox"][3], 155) # the upper box now covers them
self.assertLessEqual(a["bbox"][1] + a["bbox"][3], b["bbox"][1] + 0.5) # and they no longer overlap
self.assertEqual(self.lines_cut(out), [])
self.assertEqual([(s["id"], s["where"]) for s in strips], [("a", "end")])
self.assertGreaterEqual(strips[0]["strip"][3], 15)
def test_a_finished_paragraph_gives_the_seam_line_to_the_box_below(self):
from core.magazine import mend_column_seams
layout = self.layout(a_bottom=137, b_top=160, a_text="Der Absatz ist zu Ende.")
_, strips = mend_column_seams(self.dir, layout)
self.assertEqual([(s["id"], s["where"]) for s in strips], [("b", "start")])
def test_a_clean_seam_hand_placed_boxes_and_other_columns_are_left_alone(self):
from core.magazine import mend_column_seams
clean = self.layout(a_bottom=181, b_top=182) # already inside the gap after the line at 172-178
out, strips = mend_column_seams(self.dir, clean)
self.assertEqual((out["blocks"][0]["bbox"], strips), (clean["blocks"][0]["bbox"], []))
manual = self.layout(a_bottom=180, b_top=166)
manual["blocks"][0]["box_manual"] = True
self.assertEqual(mend_column_seams(self.dir, manual)[0]["blocks"][0]["bbox"], manual["blocks"][0]["bbox"])
beside = self.layout(a_bottom=180, b_top=166)
beside["blocks"][1]["bbox"][0] = 400 # a different column
self.assertEqual(mend_column_seams(self.dir, beside)[0]["blocks"][1]["bbox"], beside["blocks"][1]["bbox"])
def test_read_seam_strips_attaches_the_words_before_or_after_the_right_text(self):
from core.magazine import read_seam_strips
layout = self.layout(a_bottom=137, b_top=160)
strips = [{"id": "a", "where": "end", "strip": [98, 137, 205, 22]},
{"id": "b", "where": "start", "strip": [98, 160, 205, 10]}]
with mock.patch('core.magazine.extract_region_text', side_effect=["Zwischenzeile mit genug Wörtern", "Vorlauf des Absatzes davor"]):
out, n = read_seam_strips(self.dir, layout, strips)
self.assertEqual(n, 2)
self.assertEqual(out["blocks"][0]["text"], "Der Absatz beginnt hier und Zwischenzeile mit genug Wörtern")
self.assertEqual(out["blocks"][1]["text"], "Vorlauf des Absatzes davor geht weiter und endet.")
def test_an_unreadable_strip_leaves_the_text_as_it_was(self):
from core.magazine import read_seam_strips
layout = self.layout(a_bottom=137, b_top=160)
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"))