diff --git a/CHANGELOG.md b/CHANGELOG.md index c316d7e..1f69f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/core/magazine.py b/core/magazine.py index 24f0d90..ea1bae1 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -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, diff --git a/routes/magazine.py b/routes/magazine.py index f76bcb1..ac2966e 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -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 diff --git a/static/js/magazine.js b/static/js/magazine.js index 5957fe0..0c99508 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -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; diff --git a/tests/test_magazine.py b/tests/test_magazine.py index 048063c..ebd4fb4 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -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], [])