feat(magazine): cut a paragraph's box where its text wraps around an inset pull quote

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 09:50:26 +02:00
parent e00b215bc6
commit efaab0590e
5 changed files with 219 additions and 2 deletions

View File

@ -16,6 +16,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Inset pull quotes get a region of their own.** A display-coloured quote set into the text (detection wraps body text around it, so no box covered it) is found from the page image — a muted-colour blob of quote size on plain paper that no headline, figure or existing pull quote covers — and added as a *pull quote* region (never read aloud). Runs after **OCR again (this page)** and in **Clean up boxes**. On the measured 204-page issue it finds the two real quotes (pages 40 and 62) and one infographic (page 139, harmless and deletable).
- **A paragraph that wraps around an inset pull quote is cut into a full-width part and a narrow part**, so its box no longer sits over the quote (as the right-hand column already did). The text lines are measured from the page image; the text is cut in proportion to line width at a space, within a word or two of the printed break, and the two pieces are read in order and rejoined for narration. A paragraph whose lines are all narrow just gets a narrower box. Runs after **OCR again (this page)** and in **Clean up boxes**; new pieces join the same articles.
### Fixed
- **The review list shows each region's whole text**, not the first 220 characters, so every misspelling gets its red wavy underline (like a word processor). The engine's `[Non-Text]` marker and hairline boxes (under 3 pt tall) are now treated as OCR noise by *Clean up boxes* and after a page re-OCR, so they no longer show up as near-invisible regions. Fitted boxes keep a little air on grown sides.
- **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.

View File

@ -3144,6 +3144,160 @@ def detect_inset_pullquotes(doc_dir: Path, layout: dict[str, Any], pages: Iterab
return layout, added
def split_around_pullquotes(doc_dir: Path, layout: dict[str, Any],
pages: Iterable[int] | None = None) -> tuple[dict[str, Any], list[dict[str, str]]]:
"""Cut a paragraph's box where its text wraps around an inset pull quote.
One rectangle cannot follow text that runs full width above a quote and in a narrower
column beside it, so the box would sit over the quote. The paragraph is measured from the
page render: its text lines are found, lines that end left of the quote (or sit beside it)
are 'narrow', the rest 'full'. The box and the text are cut between the two runs, the text
in proportion to the ink on each side and only at a space. A paragraph whose every line is
narrow just gets its box narrowed. Returns the layout and one {id, new_id} row per new
piece, in reading order, for the caller to add to the articles.
"""
try:
import numpy as np
except Exception:
return layout, []
wanted = set(pages) if pages is not None else None
layout = dict(layout)
blocks = [dict(b) for b in layout.get("blocks", [])]
rows: list[dict[str, str]] = []
for meta in layout.get("pages", []):
page_no = meta["page"]
if wanted is not None and page_no not in wanted:
continue
quotes = [b for b in blocks if b["page"] == page_no and b["type"] == BLOCK_PULLQUOTE]
png = doc_dir / "pages" / f"{page_no:04d}.png"
if not quotes or not png.is_file():
continue
ink = page_ink_mask(png)
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"]))
for quote in quotes:
qx, qy, qw, qh = quote["bbox"]
q_left, q_top, q_bottom = int(qx * sx), int(qy * sy), int((qy + qh) * sy)
q_right = int((qx + qw) * sx)
for block in [b for b in blocks if b["page"] == page_no and b["type"] == BLOCK_BODY
and not b.get("box_manual") and (b.get("text") or "").strip()]:
if _area_inside(quote["bbox"], block["bbox"]) < 0.15:
continue
bx, by, bw, bh = block["bbox"]
left, top = max(0, int(bx * sx)), max(0, int(by * sy))
right, bottom = min(width, int((bx + bw) * sx)), min(height, int((by + bh) * sy))
if q_left <= left + 20 or right - left < 40:
continue
area = ink[top:bottom, left:right].copy()
area[max(0, q_top - top):max(0, q_bottom - top), max(0, q_left - left):max(0, q_right - left)] = False
profile = area.sum(axis=1)
# Relative to this paragraph's own ink density: set-tight lines and scan
# bleed-through leave no row truly empty, only clearly lighter.
threshold = max(2.0, 0.3 * float(np.percentile(profile, 90)))
bands, start, gap = [], None, 0
for y, count in enumerate(profile):
if count > threshold:
if start is None:
start = y
gap = 0
elif start is not None:
gap += 1
if gap > 1:
bands.append((start, y - gap))
start, gap = None, 0
if start is not None:
bands.append((start, len(profile) - 1 - gap))
bands = [(a, b) for a, b in bands if b - a >= 4]
if len(bands) < 3:
continue
# A line is narrow when it ends before the quote's left edge and sits at or just above
# the quote (text starts wrapping a line before the quote's own box does).
line_height = float(np.median([b - a + 1 for a, b in bands]))
labels = []
for a, b in bands:
cols = np.nonzero(area[a:b + 1].any(axis=0))[0]
extent = int(cols.max()) + left if len(cols) else left
beside = top + b >= q_top - 2.2 * line_height and top + a < q_bottom + 2
after = top + a >= q_bottom - 2
if extent <= q_left + 6 and (beside or after):
labels.append("narrow")
elif after and labels and labels[-1] == "narrow" and extent <= q_left + 6 + 0.5 * (right - q_left):
labels.append("narrow")
else:
labels.append("full")
if "narrow" not in labels:
continue
# Characters per line follow the line's printed width (its left-to-right extent),
# which is steadier than its ink, so the text is cut where the lines are.
weights = []
for a, b in bands:
cols = np.nonzero(area[a:b + 1].any(axis=0))[0]
weights.append(max(1, int(cols.max() - cols.min() + 1)) if len(cols) else 1)
runs: list[list[int]] = [] # [label index run start, end)
for i, label in enumerate(labels):
if runs and labels[runs[-1][0]] == label:
runs[-1][1] = i + 1
else:
runs.append([i, i + 1])
text = " ".join(block["text"].split())
total = float(sum(weights))
cuts, acc = [], 0.0
for run in runs[:-1]:
acc += sum(weights[run[0]:run[1]])
at = int(round(acc / total * len(text)))
space = min((i for i in range(len(text)) if text[i] == " "),
key=lambda i: abs(i - at), default=None)
if space is None:
cuts = []
break
cuts.append(space)
if runs and (len(cuts) != len(runs) - 1 or cuts != sorted(set(cuts)) or (cuts and (cuts[0] <= 0 or cuts[-1] >= len(text) - 1))):
continue
pieces = []
previous = 0
for k, run in enumerate(runs):
end = cuts[k] if k < len(cuts) else len(text)
y0 = top + bands[run[0]][0] - 2
y1 = top + bands[run[1] - 1][1] + 3
narrow = labels[run[0]] == "narrow"
x1 = min(right, q_left - 3) if narrow else right
pieces.append({"text": text[previous:end].strip(),
"bbox": [round(left / sx, 1), round(max(0, y0) / sy, 1),
round((x1 - left) / sx, 1), round((min(height, y1) - max(0, y0)) / sy, 1)]})
previous = end
if any(not piece["text"] for piece in pieces):
continue
taken = {b["id"] for b in blocks}
serial = 0
def new_id() -> str:
nonlocal serial
while f"t{page_no:04d}-{serial:03d}" in taken:
serial += 1
taken.add(f"t{page_no:04d}-{serial:03d}")
return f"t{page_no:04d}-{serial:03d}"
built = [dict(block, text=pieces[0]["text"], bbox=pieces[0]["bbox"], box_snapped=False)]
for piece in pieces[1:]:
extra = {**block, "id": new_id(), "text": piece["text"], "bbox": piece["bbox"],
"reading_order": block.get("reading_order", 0) + 0.5}
extra.pop("original_text", None)
built.append(extra)
for a_, b_ in zip(built, built[1:]):
rows.append({"id": a_["id"], "new_id": b_["id"]})
blocks = [x for b in blocks for x in (built if b["id"] == block["id"] else [b])]
if rows:
touched = {b["page"] for b in blocks if any(b["id"] == r["new_id"] for r in rows)}
for page_no in touched:
page = sorted((b for b in blocks if b["page"] == page_no), key=lambda b: b.get("reading_order", 0))
position = {b["id"]: i for i, b in enumerate(page)}
blocks = [dict(b, reading_order=position[b["id"]]) if b["page"] == page_no else b for b in blocks]
layout["blocks"] = blocks
return layout, rows
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

@ -860,6 +860,8 @@ def _reocr_page_sync(doc_id: str, job_id: str, page: int,
updated, cleanup = mag.clean_up_regions(updated, pages=[page])
updated, _ = mag.detect_inset_pullquotes(d, updated, [page], vision_ocr_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model)
updated, wrap = mag.split_around_pullquotes(d, updated, [page])
cleanup["wrap_split"] = wrap
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,
@ -1143,7 +1145,7 @@ def _add_split_paragraphs_to_articles(articles: list[dict], report: dict) -> boo
"""A split author line's sentence became a paragraph of its own: read it wherever the author
line is read, right after it."""
changed = False
for split in report.get("byline_split") or []:
for split in [*(report.get("byline_split") or []), *(report.get("wrap_split") or [])]:
if not split.get("new_id"):
continue
for article in articles:
@ -1339,6 +1341,8 @@ async def magazine_clean_up_boxes(doc_id: str):
str(settings.get("vision_ocr_url") or "").strip() or None, str(settings.get("vision_ocr_model") or "").strip() or None,
str(settings.get("vision_ocr_fallback_url") or "").strip() or None, str(settings.get("vision_ocr_fallback_model") or "").strip() or None)
report["pullquote_added"] = pullquotes
layout, wrap = await asyncio.to_thread(mag.split_around_pullquotes, d, layout)
report["wrap_split"] = wrap
# 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

View File

@ -2411,7 +2411,7 @@ async function magCleanUpBoxes() {
return say('Could not clean up the boxes: ' + e.message, 'err');
}
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'], [c.snapped, 'boxes fitted to their text'], [c.pullquote_added, 'inset pull quotes found']]
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'], [c.pullquote_added, 'inset pull quotes found'], [c.wrap_split, 'paragraphs cut around a pull quote']]
.filter(([n]) => n).map(([n, what]) => n + ' ' + what);
say(parts.length ? 'Cleaned up: ' + parts.join(', ') + '. Backup: ' + r.backup : 'Nothing to clean up.', 'ok');
state.layout = null;

View File

@ -2861,3 +2861,60 @@ class InsetPullquoteTests(unittest.TestCase):
with mock.patch('core.magazine.extract_region_text', return_value="Weil virtuelle Butler mächtig werden"):
out, _ = detect_inset_pullquotes(self.dir, self.layout(), None, "http://ocr", "m")
self.assertEqual(out["blocks"][0]["text"], "Weil virtuelle Butler mächtig werden")
class SplitAroundPullquoteTests(unittest.TestCase):
"""Page 600x800 (1 px = 1 pt): four full-width text lines, then five lines narrowed by an inset quote."""
def setUp(self):
from pathlib import Path
from PIL import Image, ImageDraw
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-wrap-"))
(self.dir / "pages").mkdir()
im = Image.new("RGB", (600, 800), (248, 242, 244))
draw = ImageDraw.Draw(im)
for i in range(4): # full width: x 50..250, y 100..
draw.rectangle([50, 100 + i * 14, 250, 106 + i * 14], fill=(93, 91, 92))
for i in range(5): # narrow: x 50..170, y 160..
draw.rectangle([50, 160 + i * 14, 170, 166 + i * 14], fill=(93, 91, 92))
im.save(self.dir / "pages" / "0001.png")
self.text = " ".join(f"word{i:02d}" for i in range(40))
self.layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [
{"id": "p", "page": 1, "type": "body", "bbox": [48, 98, 206, 136], "text": self.text, "reading_order": 0},
{"id": "q", "page": 1, "type": "pullquote", "bbox": [180, 158, 70, 60], "text": "quote", "reading_order": 1}]}
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def test_the_paragraph_is_cut_into_a_full_width_part_and_a_narrow_part(self):
from core.magazine import split_around_pullquotes
out, rows = split_around_pullquotes(self.dir, self.layout)
parts = [b for b in out["blocks"] if b["type"] == "body"]
self.assertEqual(len(parts), 2)
self.assertEqual(len(rows), 1)
top, low = parts
self.assertGreaterEqual(top["bbox"][2], 195) # full width
self.assertLessEqual(top["bbox"][1] + top["bbox"][3], 162) # ends before the narrow lines
self.assertLessEqual(low["bbox"][0] + low["bbox"][2], 178) # stays left of the quote (x 180)
self.assertEqual(" ".join([top["text"], low["text"]]), self.text) # no word lost or split
self.assertGreater(len(top["text"]), len(self.text) * 0.3) # roughly 4 of 9 lines of ink
self.assertLess(len(top["text"]), len(self.text) * 0.6)
self.assertEqual(rows[0], {"id": "p", "new_id": low["id"]})
def test_reading_order_keeps_the_new_part_directly_after_the_first(self):
from core.magazine import split_around_pullquotes
out, _ = split_around_pullquotes(self.dir, self.layout)
order = [b["type"] for b in sorted(out["blocks"], key=lambda b: b["reading_order"])]
self.assertEqual(order, ["body", "body", "pullquote"])
def test_running_twice_and_hand_placed_boxes_change_nothing(self):
from core.magazine import split_around_pullquotes
once, _ = split_around_pullquotes(self.dir, self.layout)
self.assertEqual(split_around_pullquotes(self.dir, once)[1], [])
manual = dict(self.layout, blocks=[dict(self.layout["blocks"][0], box_manual=True), self.layout["blocks"][1]])
self.assertEqual(split_around_pullquotes(self.dir, manual)[1], [])
def test_a_paragraph_the_quote_does_not_touch_is_left_alone(self):
from core.magazine import split_around_pullquotes
layout = dict(self.layout, blocks=[self.layout["blocks"][0], dict(self.layout["blocks"][1], bbox=[400, 500, 70, 60])])
self.assertEqual(split_around_pullquotes(self.dir, layout)[1], [])