tts-voice-creator-clone-and.../core/magazine.py
mARTin-B78 55f147d7a7
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
fix(magazine): derive figure boundaries from the page render
A vision OCR engine grounds a figure far less precisely than it
transcribes one. Over all 138 figure regions of a 204-page issue the
transcribed contents were right while the boxes were routinely offset,
undersized, or sitting on the neighbouring text column -- page 23's card
grid was reported at x 0.24-0.54 where the graphic runs 0.41-0.95.

snap_visual_bboxes() reduces the page render to an ink-coverage grid,
seeds a connected-component search with the engine's box, and re-joins
tiles a gutter apart so a card grid comes back as one figure. Text
blocks are excluded from the mask, which is what stops a figure
swallowing the column beside it. A candidate must be solidly inked or
framed on nearly all of its rows and columns (admitting a white-interior
screenshot), may not shrink below 30% of the seed, and reverts when two
figures land on the same graphic; anything else keeps the box it had.

Runs before the figure crop in both OCR paths, so the images shown
during playback are framed correctly too. Replaces refine_picture_
bounds(), which only recognised coloured panels and had no text fence.

Also adds "Fix figure boundaries" in review and POST
/api/magazine/{id}/figures/refine behind it, so an issue ingested before
this pass can be corrected without re-running OCR. Verified live against
c't KI Wissen 2026: 78 of 138 boxes corrected, the rest declined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:17:00 +02:00

2166 lines
94 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Layout-aware magazine ingestion.
Magazines are not books: a single page routinely carries several unrelated
articles, advertisements, sidebars and pull quotes, and an article often
continues twenty pages later. Treating a page as the unit of narration
therefore produces audio that jumps mid-sentence into an advert. The unit
here is the *article*, reconstructed from classified layout blocks.
Pipeline (see architecture/magazine-ingestion.md):
PDF
-> extract_pages() native text blocks with bbox/font via PyMuPDF
-> ocr_page() tesseract TSV fallback for scanned pages
-> classify_blocks() title / deck / byline / body / caption / ...
-> assign_reading_order() column detection, then top-to-bottom per column
-> detect_contents() Stage A: locate and parse the table of contents
-> group_articles() Stage C: group blocks, link continuations
-> (human review) Stage D happens in the UI; nothing is narrated
until an article is approved.
PyMuPDF is imported lazily inside the I/O functions so that the pure geometry
and classification helpers -- which hold all the fiddly logic worth testing --
can be exercised without the native dependency or a sample PDF.
"""
from __future__ import annotations
import base64
import difflib
import json
import logging
import math
import re
import statistics
import subprocess
import tempfile
import unicodedata
import requests
from core.retry import request_with_retries
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any, Iterable, Sequence
from urllib.parse import urlsplit
# ── Block taxonomy ───────────────────────────────────────────────────────────
# Ordered roughly by how much they matter to narration. Only NARRATABLE types
# are ever sent to TTS; the rest are kept for display and provenance.
BLOCK_TITLE = "title"
BLOCK_HEADING = "heading"
BLOCK_DECK = "deck" # standfirst / subtitle under the headline
BLOCK_BYLINE = "byline"
BLOCK_BODY = "body"
BLOCK_CAPTION = "caption"
BLOCK_PULLQUOTE = "pullquote" # verbatim repeat of body text, blown up
BLOCK_SIDEBAR = "sidebar"
BLOCK_FURNITURE = "furniture" # running heads, folios, page numbers
BLOCK_AD = "ad"
BLOCK_FIGURE = "figure"
BLOCK_CHART = "chart"
BLOCK_TABLE = "table"
BLOCK_CODE = "code"
VISUAL_TYPES = (BLOCK_FIGURE, BLOCK_CHART, BLOCK_TABLE, BLOCK_CODE)
NARRATABLE = (BLOCK_TITLE, BLOCK_HEADING, BLOCK_DECK, BLOCK_BYLINE, BLOCK_BODY)
# Text that marks an article jumping to (or arriving from) another page.
_CONT_TO = re.compile(
r"\((?:continued|cont\.?)\s+on\s+page\s+(\d{1,4})\)"
r"|continued\s+on\s+page\s+(\d{1,4})"
r"|fortsetzung\s+auf\s+seite\s+(\d{1,4})"
r"|weiter\s+auf\s+seite\s+(\d{1,4})",
re.I,
)
_CONT_FROM = re.compile(
r"continued\s+from\s+page\s+(\d{1,4})"
r"|fortsetzung\s+von\s+seite\s+(\d{1,4})",
re.I,
)
_BYLINE = re.compile(
r"^\s*(?:by|von|text|words|story|interview)\s*[:  ]\s*(.{2,80})$",
re.I,
)
# Headings that mark a contents page, and the section headers inside one.
_TOC_MARKERS = (
"contents", "table of contents", "in this issue", "inside this issue",
"features", "departments", "columns", "regulars",
"inhalt", "inhaltsverzeichnis", "in dieser ausgabe", "rubriken", "themen",
)
# "The future of batteries ....... 18" / "The future of batteries 18"
_TOC_ENTRY = re.compile(r"^(.{3,120}?)[\s.…·•\-]{2,}(\d{1,4})\s*$")
_ADVERT_MARKERS = ("advertisement", "anzeige", "sponsored", "promotion")
@dataclass
class Block:
"""One laid-out region of a page, in PDF user-space coordinates.
bbox is [x, y, width, height] with the origin at the page's top-left, which
is what the reflowed view needs to draw a highlight over the rendered page.
"""
id: str
page: int
bbox: list[float]
type: str
text: str
reading_order: int = 0
confidence: float = 1.0
font_size: float = 0.0
font_name: str = ""
bold: bool = False
column: int = 0
region_label: str = ""
continues_from_previous_page: bool = False
source: str = "native" # "native" | "ocr" | "vision_ocr" | "vision_label"
def to_json(self) -> dict[str, Any]:
return asdict(self)
@dataclass
class Article:
id: str
title: str
author: str = ""
pages: list[int] = field(default_factory=list)
blocks: list[str] = field(default_factory=list)
images: list[str] = field(default_factory=list)
status: str = "detected" # detected | approved | rejected
confidence: float = 0.0
section: str = ""
continued_to: list[int] = field(default_factory=list)
audio_status: str = "none" # none | narration | podcast | both
def to_json(self) -> dict[str, Any]:
return asdict(self)
# ── Geometry helpers (pure) ──────────────────────────────────────────────────
def bbox_overlaps(a: Sequence[float], b: Sequence[float], tol: float = 0.0) -> bool:
"""True when two [x, y, w, h] boxes intersect, allowing a tolerance."""
ax, ay, aw, ah = a
bx, by, bw, bh = b
return not (
ax + aw + tol < bx or bx + bw + tol < ax
or ay + ah + tol < by or by + bh + tol < ay
)
def detect_columns(blocks: Sequence[Block], page_width: float,
min_gap_ratio: float = 0.015) -> list[tuple[float, float]]:
"""Infer the column grid from where blocks are *absent*.
Magazines vary the column count per page, so a fixed grid is wrong. The
reliable signal is vertical whitespace running the height of the page: we
project every block onto the x axis, then split wherever the uncovered gap
is wider than a few percent of the page. Returns (left, right) spans.
1.5%, not the rounder 3.5% this started at: measured on a real scanned
c't magazine page, the actual gutter between two genuine columns was
only ~2.1% of the page width -- narrower than the old threshold, which
made detect_columns see one merged column and, combined with a wide
caption bridging the same gap (see assign_reading_order), produced
every block on the page landing in column=0.
"""
spans = sorted(
((b.bbox[0], b.bbox[0] + b.bbox[2]) for b in blocks if b.bbox[2] > 1),
key=lambda s: s[0],
)
if not spans:
return [(0.0, page_width)]
merged: list[list[float]] = [list(spans[0])]
min_gap = page_width * min_gap_ratio
for left, right in spans[1:]:
if left - merged[-1][1] > min_gap:
merged.append([left, right])
else:
merged[-1][1] = max(merged[-1][1], right)
return [(m[0], m[1]) for m in merged]
def assign_reading_order(blocks: list[Block], page_width: float) -> list[Block]:
"""Order blocks the way a person reads the page: column by column.
Sorting purely by y interleaves parallel columns, which is precisely the
failure that splices one article's sentence into another's.
Column BOUNDARIES are detected from blocks close to the page's typical
width only. Confirmed live on a real scanned magazine: a caption running
under a photo, at 49.9% of the page width -- under the "spans the whole
page" threshold below, so not given special handling -- sat squarely in
the whitespace gap between two real text columns and bridged them into
one merged span, which meant EVERY block on the page got column=0 and
reading order fell back to pure top-to-bottom, splicing the two
columns' sentences together (the exact failure this function exists to
prevent). Every block, wide ones included, is still assigned to
whichever column its centre or overlap lands in once boundaries are
known -- only the boundary DETECTION excludes them.
"""
widths = sorted(b.bbox[2] for b in blocks if b.bbox[2] > 1)
median_w = widths[len(widths) // 2] if widths else 0
narrow = [b for b in blocks if median_w <= 0 or b.bbox[2] <= median_w * 1.4]
columns = detect_columns(narrow if len(narrow) >= 2 else blocks, page_width)
def column_of(b: Block) -> int:
centre = b.bbox[0] + b.bbox[2] / 2
for i, (left, right) in enumerate(columns):
if left <= centre <= right:
return i
# Spanning blocks (a headline across all columns) belong to the first
# column they touch, so they precede the body they introduce.
for i, (left, right) in enumerate(columns):
if bbox_overlaps(b.bbox, [left, b.bbox[1], right - left, b.bbox[3]]):
return i
return len(columns)
for b in blocks:
b.column = column_of(b)
# A full-width headline must not be dragged to the bottom of column 0, so
# blocks spanning most of the page sort by y before anything below them.
def key(b: Block) -> tuple[float, int, float]:
spans_page = b.bbox[2] > page_width * 0.6
return (0 if spans_page else 1, b.column, round(b.bbox[1], 1))
ordered = sorted(blocks, key=key)
for i, b in enumerate(ordered):
b.reading_order = i
return ordered
# ── Classification (pure) ────────────────────────────────────────────────────
def body_font_size(blocks: Iterable[Block]) -> float:
"""The document's body size: the char-weighted median of block sizes.
Weighting by length stops a page of huge display type from redefining what
"normal" means -- body copy always dominates by character count.
"""
sizes: list[float] = []
for b in blocks:
if b.font_size > 0 and b.text.strip():
sizes.extend([b.font_size] * max(1, len(b.text) // 20))
return statistics.median(sizes) if sizes else 10.0
def _normalise(text: str) -> str:
text = unicodedata.normalize("NFKC", text or "")
return re.sub(r"\s+", " ", text).strip()
def looks_like_byline(text: str) -> tuple[bool, str]:
"""Detect 'By Jane Smith' / 'Von Jane Smith', returning the name."""
m = _BYLINE.match(_normalise(text))
if not m:
return False, ""
name = m.group(1).strip(" .,;:")
# A byline is a name, not a sentence: reject anything long or punctuated.
if len(name) > 60 or name.count(" ") > 5 or name.endswith((".", "!", "?")):
return False, ""
return True, name
def find_continuation(text: str) -> tuple[int | None, int | None]:
"""Return (continues_on_page, continued_from_page) mentioned in the text."""
to_m = _CONT_TO.search(text or "")
from_m = _CONT_FROM.search(text or "")
to_page = next((int(g) for g in to_m.groups() if g), None) if to_m else None
from_page = next((int(g) for g in from_m.groups() if g), None) if from_m else None
return to_page, from_page
def strip_continuation_markers(text: str) -> str:
"""Remove '(continued on page N)' style cues before text is narrated.
These are a print layout convention -- meaningless, and slightly absurd,
spoken aloud ("open paren continued on page eight close paren"). The
layout store keeps the original text; only the narration copy is cleaned.
"""
cleaned = _CONT_TO.sub("", text or "")
cleaned = _CONT_FROM.sub("", cleaned)
return re.sub(r"\s+", " ", cleaned).strip()
def classify_blocks(blocks: list[Block], page_height: float, page_width: float,
repeated: set[str] | None = None,
image_boxes: Sequence[Sequence[float]] = (),
base_size: float | None = None) -> list[Block]:
"""Assign a semantic type to every block on one page.
Font size is judged relative to the document body size rather than in
absolute points, so the same rules hold for a pocket magazine and a
broadsheet.
base_size must be measured across the whole document, not this page: a page
carrying a full-bleed photograph and one caption has no body copy of its
own, so a per-page baseline would make that caption its own reference and
classify it as body text. It falls back to the page when not supplied.
"""
repeated = repeated or set()
base = base_size if base_size and base_size > 0 else body_font_size(blocks)
for b in blocks:
# A vision OCR engine that explicitly labeled this region (footer,
# title, page_number, caption -- see build_vision_blocks) is a more
# direct signal than any font-size heuristic can reconstruct after
# the fact; second-guessing it here would throw that away.
if b.source == "vision_label":
continue
text = _normalise(b.text)
if not text:
b.type = BLOCK_FURNITURE
b.confidence = 0.5
continue
ratio = (b.font_size / base) if base else 1.0
near_top = b.bbox[1] < page_height * 0.07
near_bottom = b.bbox[1] + b.bbox[3] > page_height * 0.93
short = len(text) <= 90
lower = text.lower()
# Running heads, folios and page numbers repeat at the same edge on
# many pages -- that repetition is what identifies them, not position
# alone, since a headline can also sit at the top.
if (near_top or near_bottom) and (text in repeated or text.isdigit()):
b.type, b.confidence = BLOCK_FURNITURE, 0.95
continue
if any(m in lower for m in _ADVERT_MARKERS) and short:
b.type, b.confidence = BLOCK_AD, 0.8
continue
is_byline, _ = looks_like_byline(text)
if is_byline:
b.type, b.confidence = BLOCK_BYLINE, 0.9
continue
# A caption sits directly beneath or beside a figure and is set smaller
# than body copy. Both conditions are needed: small text elsewhere is
# usually a footnote, and body-sized text under an image is often the
# article continuing past it.
if ratio < 0.92 and any(
bbox_overlaps(b.bbox, box, tol=max(page_height, page_width) * 0.02)
for box in image_boxes
):
b.type, b.confidence = BLOCK_CAPTION, 0.75
continue
if ratio >= 1.6 and short:
b.type, b.confidence = BLOCK_TITLE, 0.9 if ratio >= 2.0 else 0.7
continue
if 1.18 <= ratio < 1.6 and len(text) <= 300:
# Display-sized text mid-column is a pull quote; at the head of a
# page it is the deck introducing the article.
b.type = BLOCK_DECK if b.bbox[1] < page_height * 0.45 else BLOCK_PULLQUOTE
b.confidence = 0.6
continue
b.type, b.confidence = BLOCK_BODY, 0.85
return blocks
def mark_advert_pages(blocks: list[Block]) -> list[Block]:
"""Spread an explicit advertisement label across its whole page.
Publishers print "ADVERTISEMENT" once to disclaim the page, not each block
on it. Without this the ad's own display type reads as a headline and the
page gets claimed by whichever feature precedes it.
"""
# A small ad label beside substantial prose does not establish a full-page ad.
if any(b.type == BLOCK_BODY and len(b.text) > 160 for b in blocks):
return blocks
if not any(b.type == BLOCK_AD and b.source not in ("vision_label", "llm_ad", "llm_layout") for b in blocks):
return blocks
for b in blocks:
if b.type != BLOCK_FURNITURE:
b.type, b.confidence = BLOCK_AD, 0.8
return blocks
def mark_pullquotes(blocks: list[Block]) -> list[Block]:
"""Demote display text that merely repeats nearby body copy.
Narrating a pull quote reads the same sentence twice, a few seconds apart,
which sounds like a skipping record.
"""
body_text = _normalise(" ".join(
b.text for b in blocks if b.type == BLOCK_BODY
)).lower()
if not body_text:
return blocks
for b in blocks:
if b.type not in (BLOCK_DECK, BLOCK_PULLQUOTE):
continue
probe = _normalise(b.text).lower().strip(' "“”«»')
if len(probe) > 25 and probe in body_text:
b.type, b.confidence = BLOCK_PULLQUOTE, 0.9
return blocks
def mark_sidebars(blocks: list[Block], page_width: float,
narrow_ratio: float = 0.7) -> list[Block]:
"""Reclassify body text sitting in an anomalously narrow column as a
sidebar, so it is excluded from an article's narration by default rather
than requiring a manual exclusion in review every time.
Geometry-only, like the rest of this module's classification: a sidebar
or pull-out box is reliably narrower than the page's regular column grid
(a boxed insert set off from the main flow), which needs no image
analysis to detect. This is a heuristic, not a certainty -- a genuinely
narrow-column page design (or a caption sitting in its own slim column)
can trigger a false positive, which is exactly why review still shows
every sidebar block and lets it be included with one click rather than
silently discarding it.
Full-width elements (a headline spanning the whole page) are excluded
from the column analysis here, the same way assign_reading_order treats
them specially: left in, one would bridge two genuinely separate columns
into a single merged span (its bbox overlaps both), hiding a real narrow
sidebar column entirely. This computes its own column partition rather
than trusting `.column` from assign_reading_order, since that partition
was built from the FULL block list and its indices would not line up
with this narrower one.
"""
candidates = [b for b in blocks if b.bbox[2] <= page_width * 0.6]
columns = detect_columns(candidates, page_width)
if len(columns) < 2:
return blocks
widths = [right - left for left, right in columns]
widest = max(widths)
narrow_spans = [columns[i] for i, w in enumerate(widths) if w < widest * narrow_ratio]
if not narrow_spans:
return blocks
for b in blocks:
if b.type != BLOCK_BODY or b.bbox[2] > page_width * 0.6:
continue
centre = b.bbox[0] + b.bbox[2] / 2
if any(left <= centre <= right for left, right in narrow_spans):
b.type, b.confidence = BLOCK_SIDEBAR, 0.6
return blocks
def find_repeated_lines(pages_text: Sequence[Sequence[str]],
threshold: float = 0.25) -> set[str]:
"""Lines appearing near the same edge on many pages: page furniture."""
if not pages_text:
return set()
counts: dict[str, int] = {}
for lines in pages_text:
for line in {_normalise(t) for t in lines if _normalise(t)}:
counts[line] = counts.get(line, 0) + 1
minimum = max(3, int(len(pages_text) * threshold))
# Long lines that recur are more likely a repeated standfirst than a
# running head, so cap the length.
return {t for t, n in counts.items() if n >= minimum and len(t) <= 80}
def looks_like_real_title(text: str) -> bool:
"""Guards the headline-fallback article start against OCR noise.
A stray misread character or chart-axis fragment ("I", "@)X", ". |") can
classify as BLOCK_TITLE by size alone; without this it would spawn its
own article. Confirmed live on a scanned magazine: single-character OCR
artifacts at running-footer size otherwise start empty articles.
"""
letters = sum(1 for c in (text or "") if c.isalpha())
return letters >= 4
def _digit_stripped(text: str) -> str:
return re.sub(r"\d+", "", text or "")
def cluster_running_furniture(blocks: Sequence[Block], page_heights: dict[int, float],
similarity: float = 0.6, min_count: int = 3) -> None:
"""Reclassify near-edge blocks that recur across pages as furniture, even
when OCR reads the running header/footer differently each time.
find_repeated_lines() needs an EXACT match, which OCR defeats: a magazine
tag like "c't Wissen 2026" came back as "cE KI-WISSEN", "ce Wissen 2136",
"cA Wissen 2036" across different pages of the same scan -- confirmed
live, and the dominant cause of a 28-page scan producing 26 near-1-per-
page "articles" instead of grouping around real headlines. Only the page
number reliably varies page to page, so digit-stripped fuzzy similarity
catches what exact matching misses. Mutates blocks in place.
"""
candidates: list[tuple[Block, str]] = []
for b in blocks:
if b.type in (BLOCK_FURNITURE, BLOCK_AD):
continue
text = _normalise(b.text)
if not text or len(text) > 80:
continue
height = page_heights.get(b.page, 842.0)
near_top = b.bbox[1] < height * 0.07
near_bottom = b.bbox[1] + b.bbox[3] > height * 0.93
if not (near_top or near_bottom):
continue
key = _digit_stripped(text).lower().strip()
if len(key) < 3:
continue
candidates.append((b, key))
assigned = [False] * len(candidates)
for i, (_, key_i) in enumerate(candidates):
if assigned[i]:
continue
cluster = [i]
for j in range(i + 1, len(candidates)):
if assigned[j]:
continue
if difflib.SequenceMatcher(None, key_i, candidates[j][1]).ratio() >= similarity:
cluster.append(j)
if len(cluster) >= min_count:
for idx in cluster:
candidates[idx][0].type = BLOCK_FURNITURE
candidates[idx][0].confidence = 0.75
assigned[idx] = True
# ── Stage A: the table of contents ───────────────────────────────────────────
def score_contents_page(lines: Sequence[str]) -> float:
"""How strongly one page looks like a table of contents (0..1)."""
if not lines:
return 0.0
joined = " ".join(lines).lower()
marker_hits = sum(1 for m in _TOC_MARKERS if m in joined)
entry_hits = sum(1 for line in lines if _TOC_ENTRY.match(_normalise(line)))
# Either signal alone is weak: a feature can be titled "Features", and a
# dotted leader appears in ordinary tables too.
score = min(1.0, marker_hits * 0.25) * 0.4 + min(1.0, entry_hits / 6) * 0.6
return round(score, 3)
def parse_contents(lines: Sequence[str]) -> list[dict[str, Any]]:
"""Pull (title, page, section) entries out of a contents page."""
entries: list[dict[str, Any]] = []
section = ""
for raw in lines:
line = _normalise(raw)
if not line:
continue
low = line.lower().strip(": ")
if low in _TOC_MARKERS and low not in ("contents", "inhalt"):
section = line.strip(": ")
continue
m = _TOC_ENTRY.match(line)
if not m:
continue
title, page = m.group(1).strip(" .…·•-"), int(m.group(2))
if len(title) < 3 or page <= 0 or page > 2000:
continue
entries.append({"title": title, "page": page, "section": section})
return entries
# ── Stage C: grouping blocks into articles ───────────────────────────────────
def _title_key(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "", _normalise(text).lower())
def group_articles(blocks: Sequence[Block],
contents: Sequence[dict[str, Any]],
page_count: int,
skip_pages: set[int] | None = None) -> list[Article]:
"""Group ordered blocks into articles.
The contents listing, when present, is the strongest available signal: it
states both the title and the starting page, so it anchors boundaries that
layout alone would guess at. Pages not claimed by any listing still get
articles from their own headlines, because a contents page never lists
everything (and some issues have no contents page at all).
"""
by_page: dict[int, list[Block]] = {}
for b in blocks:
by_page.setdefault(b.page, []).append(b)
for page_blocks in by_page.values():
page_blocks.sort(key=lambda b: b.reading_order)
# Where does each article start? A contents entry pins a start page; a
# title block on a page with no entry starts one there.
starts: dict[int, dict[str, Any]] = {}
for entry in contents:
page = entry["page"]
if 1 <= page <= page_count:
starts.setdefault(page, {
"title": entry["title"],
"section": entry.get("section", ""),
"confidence": 0.9,
})
skip_pages = skip_pages or set()
for page in sorted(by_page):
if page in starts or page in skip_pages:
continue
# A page carrying nothing but advertising and page furniture has no
# article on it -- the cover and the ad pages otherwise each become a
# spurious entry the reviewer has to reject by hand on every issue.
if not any(b.type in NARRATABLE and b.type != BLOCK_TITLE
for b in by_page[page]):
continue
titles = [b for b in by_page[page] if b.type == BLOCK_TITLE
and looks_like_real_title(b.text)]
if titles:
starts[page] = {
"title": _normalise(titles[0].text),
"section": "",
"confidence": 0.55,
}
if not starts:
return []
ordered_starts = sorted(starts)
articles: list[Article] = []
for i, start_page in enumerate(ordered_starts):
end_page = (ordered_starts[i + 1] - 1) if i + 1 < len(ordered_starts) else page_count
info = starts[start_page]
art = Article(
id=f"article-{i + 1:03d}",
title=info["title"],
section=info.get("section", ""),
confidence=info["confidence"],
)
for page in range(start_page, end_page + 1):
page_blocks = by_page.get(page, [])
if not page_blocks:
continue
# Only claim a page the article actually occupies. A full-page
# advert sitting between two features starts no article of its
# own, so it would otherwise be swallowed by the preceding one and
# highlighted as part of it in the PDF view.
#
# A sidebar/pull-out box (mark_sidebars) is excluded the same way
# advert text is: automatically kept out of narration by default,
# rather than requiring a manual exclusion in review every time.
# It still shows in review (blocksForPages lists every block on
# the article's pages) and can be ticked back in with one click.
contributed = False
for b in page_blocks:
if b.type in (BLOCK_FURNITURE, BLOCK_AD, BLOCK_SIDEBAR, BLOCK_PULLQUOTE, *VISUAL_TYPES):
continue
contributed = True
art.blocks.append(b.id)
if b.type == BLOCK_BYLINE and not art.author:
_, name = looks_like_byline(b.text)
art.author = name
to_page, _ = find_continuation(b.text)
if to_page and to_page not in art.continued_to:
art.continued_to.append(to_page)
if contributed:
art.pages.append(page)
# An article the pipeline could not fill with body copy is almost
# certainly a false start (an advert headline, a section divider).
has_body = any(
b.type == BLOCK_BODY for b in blocks if b.id in set(art.blocks)
)
if not has_body:
art.confidence = round(art.confidence * 0.4, 3)
articles.append(art)
return _link_continuations(articles, page_count)
def _link_continuations(articles: list[Article], page_count: int) -> list[Article]:
"""Attach jump pages ('continued on page 88') to their originating article.
Without this, the tail of a feature parked at the back of the issue is
orphaned and gets narrated as a separate stub article that begins
mid-sentence.
"""
by_start = {a.pages[0]: a for a in articles if a.pages}
for art in articles:
for target in art.continued_to:
if not (1 <= target <= page_count):
continue
tail = by_start.get(target)
if tail is None or tail is art or tail.status == "merged":
continue
# Only absorb a genuine continuation: a real article listed in the
# contents keeps its own identity.
if tail.confidence >= 0.9:
continue
art.pages.extend(p for p in tail.pages if p not in art.pages)
art.blocks.extend(tail.blocks)
art.images.extend(tail.images)
tail.status = "merged"
return [a for a in articles if a.status != "merged"]
def estimate_listen_seconds(blocks: Sequence[Block], article: Article,
words_per_minute: float = 155.0) -> int:
"""Rough narration length, for the 'estimated listening time' column."""
ids = set(article.blocks)
words = sum(
len(b.text.split()) for b in blocks
if b.id in ids and b.type in NARRATABLE
)
return int(round(words / words_per_minute * 60)) if words else 0
# ── PDF and OCR I/O ──────────────────────────────────────────────────────────
def _require_fitz():
"""Import PyMuPDF under its current name, falling back to the old alias.
1.24 renamed the module to ``pymupdf``; ``fitz`` still resolves but emits a
deprecation warning and is slated for removal.
"""
try:
import pymupdf
return pymupdf
except ImportError:
pass
try:
import fitz
return fitz
except ImportError as exc: # pragma: no cover - environment guard
raise RuntimeError(
"PyMuPDF is required for magazine ingestion; install pymupdf (pip install pymupdf) "
"or rebuild your Docker container."
) from exc
# Labels a vision OCR engine may return for a region, mapped onto the same
# taxonomy tesseract/native blocks use. Anything else ("text", or a label the
# engine invents) is treated as ambiguous body copy and still goes through
# classify_blocks() for finer distinctions (byline, deck, ...).
_VISION_LABEL_TYPES = {
# Headlines / Titles / Headers
"title": BLOCK_TITLE,
"headline": BLOCK_TITLE,
"article_headline": BLOCK_TITLE,
"main_title": BLOCK_TITLE,
"heading": BLOCK_HEADING,
"header": BLOCK_HEADING,
"section_heading": BLOCK_HEADING,
"subheading": BLOCK_HEADING,
"subtitle": BLOCK_HEADING,
"deck": BLOCK_HEADING,
# Paragraphs / Text / Body
"paragraph": BLOCK_BODY,
"body": BLOCK_BODY,
"content": BLOCK_BODY,
"list": BLOCK_BODY,
"list_item": BLOCK_BODY,
"author": BLOCK_BYLINE,
"byline": BLOCK_BYLINE,
# Sidenotes / Sidebars / Quotes / Footnotes
"sidenote": BLOCK_SIDEBAR,
"sidebar": BLOCK_SIDEBAR,
"aside_text": BLOCK_SIDEBAR,
"aside": BLOCK_SIDEBAR,
"footnote": BLOCK_SIDEBAR,
"footnotes": BLOCK_SIDEBAR,
"note": BLOCK_SIDEBAR,
"commentary": BLOCK_SIDEBAR,
"quote": BLOCK_PULLQUOTE,
"pullquote": BLOCK_PULLQUOTE,
# Page numbers / Furniture / Folios / Sources
"page_number": BLOCK_FURNITURE,
"pagenumber": BLOCK_FURNITURE,
"page": BLOCK_FURNITURE,
"header_text": BLOCK_FURNITURE,
"footer": BLOCK_FURNITURE,
"running_header": BLOCK_FURNITURE,
"running_footer": BLOCK_FURNITURE,
"folio": BLOCK_FURNITURE,
"head": BLOCK_FURNITURE,
"source": BLOCK_FURNITURE,
"sources": BLOCK_FURNITURE,
"citation": BLOCK_FURNITURE,
"citations": BLOCK_FURNITURE,
# Images / Figures / Photos
"image": BLOCK_FIGURE,
"figure": BLOCK_FIGURE,
"picture": BLOCK_FIGURE,
"photo": BLOCK_FIGURE,
"illustration": BLOCK_FIGURE,
"non_text": BLOCK_FIGURE,
"nontext": BLOCK_FIGURE,
"drawing": BLOCK_FIGURE,
# Graphics / Charts / Diagrams / Maps / Treemaps
"chart": BLOCK_CHART,
"graphic": BLOCK_CHART,
"infographic": BLOCK_CHART,
"statistics": BLOCK_CHART,
"diagram": BLOCK_CHART,
"map": BLOCK_CHART,
"treemap": BLOCK_CHART,
"plot": BLOCK_CHART,
"flowchart": BLOCK_CHART,
# Tables
"table": BLOCK_TABLE,
"grid": BLOCK_TABLE,
"matrix": BLOCK_TABLE,
# Code / Formulas / Math
"code": BLOCK_CODE,
"code_block": BLOCK_CODE,
"formula": BLOCK_CODE,
"equation": BLOCK_CODE,
"math": BLOCK_CODE,
# Captions
"caption": BLOCK_CAPTION,
"image_caption": BLOCK_CAPTION,
"figure_caption": BLOCK_CAPTION,
"table_caption": BLOCK_CAPTION,
# Ads
"advertisement": BLOCK_AD,
"advert": BLOCK_AD,
"ad": BLOCK_AD,
}
# One label + its four-number bbox, anchoring a region; content runs until
# the next such marker. Tolerates DeepSeek-OCR / Unlimited-OCR / PaddleOCR shapes
# like "text [241, 90, 588, 249]", "<|ref|>title<|/ref|><|det|>[[x0, y0, x1, y1]]<|/det|>", etc.
_VISION_REGION = re.compile(
r"(?m)"
r"(?:<\|ref\|>\s*(?P<ref>[^<]+?)\s*<\|/ref\|>\s*)?"
r"(?:<\|det\|>\s*)?"
r"(?P<label>[a-zA-Z0-9_\- ]+?)\s*"
r"\[\[?\s*(?P<x0>-?\d+(?:\.\d+)?)\s*,\s*(?P<y0>-?\d+(?:\.\d+)?)\s*,\s*(?P<x1>-?\d+(?:\.\d+)?)\s*,\s*(?P<y1>-?\d+(?:\.\d+)?)\s*\]\]?"
r"(?:\s*<\|/det\|>)?"
r"\s*:?"
)
_UNLIMITED_OCR_REGION = re.compile(
r"(?m)"
r"(?:<\|ref\|>\s*(?P<ref>[^<]+?)\s*<\|/ref\|>\s*)?"
r"<\|det\|>\s*"
r"(?P<label>[a-zA-Z0-9_\- ]+?)\s*"
r"(?:\[\[?\s*(?P<x0>-?\d+(?:\.\d+)?)\s*,\s*(?P<y0>-?\d+(?:\.\d+)?)\s*,\s*(?P<x1>-?\d+(?:\.\d+)?)\s*,\s*(?P<y1>-?\d+(?:\.\d+)?)\s*\]\]?)?"
r"\s*<\|/det\|>"
)
def ocr_quality_score(text: str) -> float:
"""Rough plausibility that OCR/extracted text is real prose, 0..1."""
text = (text or "").strip()
if not text:
return 0.0
words = text.split()
if not words:
return 0.0
def _stripped(w: str) -> str:
return w.strip(".,;:!?()[]{}\"'|-–—")
garbage = sum(1 for w in words if not _stripped(w))
letters = sum(1 for c in text if c.isalpha())
total = len(text.replace(" ", "")) or 1
score = (letters / total) * (1.0 - garbage / len(words))
return round(max(0.0, min(1.0, score)), 3)
def estimate_font_size_from_area(width: float, height: float, text_length: int,
char_width_ratio: float = 0.5,
line_height_ratio: float = 1.2) -> float:
"""Estimate a representative font size for a paragraph-level box."""
if width <= 0 or height <= 0 or text_length <= 0:
return 0.0
denom = text_length * char_width_ratio * line_height_ratio
return (width * height / denom) ** 0.5 if denom > 0 else 0.0
def parse_vision_ocr_regions(raw: str) -> list[dict[str, Any]]:
"""Parse a vision OCR engine's labeled-region transcription."""
matches = list(_VISION_REGION.finditer(raw or ""))
regions = []
for i, m in enumerate(matches):
gd = m.groupdict()
raw_label = (gd.get("ref") or gd.get("label") or "text").strip().strip("[]")
label = raw_label.lower().replace("-", "_").replace(" ", "_")
try:
x0, y0, x1, y1 = (float(gd[k]) for k in ("x0", "y0", "x1", "y1"))
except (ValueError, TypeError):
continue
start = m.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(raw or "")
text = (raw or "")[start:end].strip()
if (not text and _VISION_LABEL_TYPES.get(label) not in VISUAL_TYPES) or x1 <= x0 or y1 <= y0:
continue
regions.append({
"label": label,
"bbox": [x0, y0, x1 - x0, y1 - y0],
"text": text,
})
return regions
def parse_unlimited_ocr_regions(raw: str) -> list[dict[str, Any]]:
"""Parse Unlimited-OCR's native ``<|det|>`` document-layout markers."""
matches = list(_UNLIMITED_OCR_REGION.finditer(raw or ""))
regions = []
for i, match in enumerate(matches):
gd = match.groupdict()
raw_label = (gd.get("ref") or gd.get("label") or "").strip().strip("[]")
label = raw_label.lower().replace("-", "_").replace(" ", "_")
try:
x0, y0, x1, y1 = (float(gd[k]) for k in ("x0", "y0", "x1", "y1"))
except (ValueError, TypeError):
continue
if x1 <= x0 or y1 <= y0:
continue
start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(raw or "")
text = (raw or "")[start:end].strip()
if label in {"non_text", "nontext", "photo", "drawing", "picture"}:
label = "image"
elif label in {"map", "graphic", "infographic", "diagram", "treemap", "statistics"}:
label = "chart"
if not text and _VISION_LABEL_TYPES.get(label) not in VISUAL_TYPES:
continue
regions.append({
"label": label,
"bbox": [x0, y0, x1 - x0, y1 - y0],
"text": text,
"native_normalized_bbox": True,
})
return regions
def _whole_page_region(content: str, png_path: Path) -> list[dict[str, Any]] | None:
"""Fallback for an engine that answers with plain text and no coordinate
markers at all (confirmed live: KDL Frontier Parser nano -- fast, but no
"label [x0,y0,x1,y1]" structure the way ocr-baidu-unlimited-ocr gives).
Treated as one ambiguous "text" region spanning the whole page: real
text with no layout information beats no text at all, but it does mean
column/reading-order structure is lost for that page -- classify_blocks
still runs on it (source="vision_ocr", not "vision_label"), same as any
other ambiguous region.
"""
text = (content or "").strip()
# A vision model can occasionally echo a formatting instruction instead
# of reading the page. The observed failure is a long sequence of literal
# ``\\n `equation` `` tokens (not real line breaks), often prefixed with
# "Do not use special symbols". It scores highly under the generic
# letter-ratio heuristic because "equation" is a normal word, so reject
# this distinct response shape before turning it into a page-sized text
# block. Returning None lets vision_ocr_page_chain try the configured
# fallback engine, then preserves the existing OCR if both fail.
escaped_newlines = len(re.findall(r"\\n", text))
equation_tokens = len(re.findall(r"`?\s*\\?equation\b", text, re.IGNORECASE))
prompt_echo = "do not use special symbols" in text.lower()
# Another observed loop starts with genuine page text and then repeats a
# fabricated LaTex fragment (``\\therefore m = \\frac{3}{11}``) until the
# response limit. It has no instruction echo or `equation` token, so it
# needs its own narrow signature. Requiring both uncommon controls many
# times avoids rejecting ordinary prose that merely mentions one formula.
therefore_tokens = len(re.findall(r"\\therefore\b", text, re.IGNORECASE))
fraction_tokens = len(re.findall(r"\\frac\s*\{", text, re.IGNORECASE))
latex_loop = therefore_tokens >= 4 and fraction_tokens >= 4
if (escaped_newlines >= 4 and equation_tokens >= 4) or (
prompt_echo and escaped_newlines >= 2 and equation_tokens >= 2
) or latex_loop:
logging.getLogger(__name__).warning("Vision OCR returned a repeated formatting artifact; discarding response")
return None
if len(text) < 20 or ocr_quality_score(text) < _VISION_QUALITY_THRESHOLD:
return None
try:
from PIL import Image
with Image.open(png_path) as im:
width, height = im.size
except Exception:
return None
return [{"label": "text", "bbox": [0.0, 0.0, float(width), float(height)], "text": text}]
def vision_ocr_page(png_path: Path, base_url: str, model: str,
timeout: float = 150.0) -> list[dict[str, Any]] | None:
"""OCR one rendered page with a connected vision-capable OCR engine.
Returns None on any failure (unreachable engine, timeout, unusable
output) so the caller can fall back to the next engine or tesseract --
this must never be the reason ingestion fails outright. Confirmed live
against ocr-baidu-unlimited-ocr: markedly more accurate German text than
tesseract on the same scanned page, and it labels running headers/
footers and page numbers directly, which is the dominant source of the
over-fragmentation cluster_running_furniture() otherwise has to recover
after the fact from noisy OCR variants of the same repeating line.
"""
try:
image_b64 = base64.b64encode(png_path.read_bytes()).decode("ascii")
except OSError:
return None
# Settings also accept a bare server address; OpenAI-compatible servers
# expose their API under /v1. Preserve explicit proxy/API prefixes.
base_url = base_url.strip().rstrip("/")
if not urlsplit(base_url).path:
base_url += "/v1"
unlimited_ocr = "unlimited-ocr" in model.lower()
prompt = "<|grounding|>Convert the document to markdown." if unlimited_ocr else (
"Recognise the page layout and transcribe visible text verbatim. "
"Return one region per headline, title, paragraph, sidenote, sidebar, "
"image, figure, chart, graphic, map, diagram, table, code box, "
"caption, header, footer, or page number. Use the format "
"label [x0, y0, x1, y1]transcription with pixel coordinates in the "
"supplied image, one region starting on each new line. Labels: "
"title, heading, paragraph, sidenote, sidebar, image, figure, chart, "
"graphic, map, table, code, caption, header, footer, page_number. "
"Enclose a whole chart, diagram, map, graphic, or table in one box, "
"not separate axis labels or cells. Preserve table rows and columns "
"as Markdown and code indentation and line breaks. Images, figures, and "
"charts may have empty transcription; do not invent descriptions. "
"Do not merge neighbouring articles or include commentary."
)
payload = {
"model": model,
"messages": [{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {
"url": f"data:image/png;base64,{image_b64}",
}},
]}],
"max_tokens": 4000,
"temperature": 0.0,
}
# Unlimited-OCR's official vLLM recipe requires its per-request
# NGramPerReqLogitsProcessor settings. The container already exposes the
# processor, but without these fields it does nothing and the model can
# run into a repeated chart/formula loop. Keep the vendor-specific fields
# off generic OpenAI-compatible vision engines.
if unlimited_ocr:
payload.update({
"skip_special_tokens": False,
"ngram_size": 35,
"window_size": 128,
"images_config": {"image_mode": "gundam"},
})
try:
resp = request_with_retries(
"POST", f"{base_url.rstrip('/')}/chat/completions",
json=payload, timeout=timeout, attempts=2,
)
resp.raise_for_status()
content = resp.json()["choices"][0]["message"]["content"]
except requests.exceptions.RequestException as exc:
status = getattr(getattr(exc, "response", None), "status_code", None)
logging.getLogger(__name__).warning("Vision OCR request failed (%s%s)",
type(exc).__name__, f", HTTP {status}" if status else "")
return None
except (ValueError, KeyError, IndexError, TypeError):
logging.getLogger(__name__).warning("Vision OCR returned an invalid completion response")
return None
if not isinstance(content, str):
return None
regions = parse_unlimited_ocr_regions(content) if unlimited_ocr else parse_vision_ocr_regions(content)
if regions and unlimited_ocr:
# Native markers use the model input's 0–999-ish coordinate space;
# blocks downstream expect coordinates of the rendered PNG.
try:
from PIL import Image
with Image.open(png_path) as im:
width, height = im.size
for region in regions:
if region.pop("native_normalized_bbox", False):
x, y, w, h = region["bbox"]
region["bbox"] = [x / 1000 * width, y / 1000 * height,
w / 1000 * width, h / 1000 * height]
except Exception:
return None
return regions or _whole_page_region(content, png_path)
def vision_ocr_page_chain(png_path: Path,
engines: Sequence[tuple[str, str]],
timeout: float = 150.0) -> list[dict[str, Any]] | None:
"""Try each configured vision OCR engine in order, first success wins.
A primary engine (labeled regions, best quality) with a faster or
differently-shaped secondary as a real fallback -- not just tesseract --
when the primary is unreachable or times out. Engines are (url, model)
pairs; empty/blank entries are skipped.
"""
for url, model in engines:
if not (url and model):
continue
regions = vision_ocr_page(png_path, url, model, timeout=timeout)
if regions:
return regions
return None
def build_vision_blocks(regions: list[dict[str, Any]], page_no: int,
scale_x: float, id_prefix: str) -> list[Block]:
"""Turn one page's vision OCR regions into Blocks.
A label the engine gives directly (footer/page_number/header/title/
caption) is trusted outright -- source="vision_label" makes
classify_blocks() skip it rather than second-guess it from font size,
which vision OCR does not reliably provide (see estimate_font_size_from_
area's docstring: it is a paragraph-area estimate, not a measurement).
An unrecognised or "text" label is left ambiguous (BLOCK_BODY,
source="vision_ocr") and still goes through the normal classification
pass for finer distinctions such as byline or deck.
"""
out = []
for i, region in enumerate(regions):
x, y, w, h = (v * scale_x for v in region["bbox"])
label = region["label"]
text = region["text"]
mapped = _VISION_LABEL_TYPES.get(label)
if mapped is not None:
block = Block(
id=f"{id_prefix}{page_no:04d}-v{i:03d}",
page=page_no, bbox=[x, y, w, h], type=mapped, text=text,
confidence=0.85, source="vision_label",
)
else:
block = Block(
id=f"{id_prefix}{page_no:04d}-v{i:03d}",
page=page_no, bbox=[x, y, w, h], type=BLOCK_BODY, text=text,
confidence=0.8,
font_size=estimate_font_size_from_area(w, h, len(text)),
source="vision_ocr",
)
block.region_label = label
out.append(block)
return out
# ── Figure bounds from the page render ───────────────────────────────────────
#
# A vision OCR engine grounds a figure far less precisely than it transcribes
# one: on c't KI Wissen 2026 the label and the read-out contents were right
# while the four numbers landed on the neighbouring text column at two thirds
# of the real size (page 23), or covered only the top third of a full-width
# illustration (page 20). The rendered page itself carries the answer -- a
# photo, a chart or a UI screenshot is a densely inked rectangle fenced off
# from the type area by white gutters -- so the model's box is used only to
# say WHICH graphic is meant and the render says where that graphic ends.
#
# Deliberately conservative: every rule below can decline, and declining
# leaves the model's box untouched. A wrong box the user can see and redraw
# is a nuisance; a box confidently snapped onto the wrong thing is worse.
_SNAP_CELL_PX = 10 # grid resolution -- ~2 mm at the 110 dpi page render
_SNAP_SOLID = 0.55 # inked fraction marking a cell as graphic, not type
_SNAP_GAP_CELLS = 3 # white gutter a tiled graphic is allowed to span
_SNAP_MIN_FILL = 0.45 # an accepted box is this solidly inked ...
_SNAP_MIN_FRAME = 0.9 # ... or framed on this share of its rows AND columns
_SNAP_MIN_SEED_AREA = 0.30 # ... and never shrinks below this much of the seed
_SNAP_MIN_OVERLAP = 0.02 # a graphic must touch the seed by at least this much
def page_ink_grid(png_path: Path, cell: int = _SNAP_CELL_PX):
"""Coarse map of the page render: inked fraction of each `cell`-px square.
"Inked" means visibly darker than the paper or visibly coloured, so a
grey scan background does not count as ink. Body type covers roughly a
quarter of the cells it runs through; a photo or a filled panel covers
nearly all of them, which is the separation snap_visual_bboxes() relies
on. Returns None when the render cannot be read.
"""
try:
import numpy as np
from PIL import Image
with Image.open(png_path) as im:
rgb = np.asarray(im.convert("RGB"), dtype=np.float32)
except Exception:
return None
lightness = rgb.max(axis=2)
saturation = lightness - rgb.min(axis=2)
paper = float(np.percentile(lightness, 90)) or 255.0
ink = (lightness < paper * 0.88) | (saturation > 40)
rows, cols = (n // cell for n in ink.shape)
if rows < 4 or cols < 4:
return None
return ink[:rows * cell, :cols * cell].reshape(
rows, cell, cols, cell).mean(axis=(1, 3))
def _snap_seed(coverage, seed: Sequence[int], blocked) -> list[int] | None:
"""Grow the graphic the seed cell-box points at, or None to keep the seed.
Cells already claimed by a text block are excluded up front: text boxes
come back from the same engine far more accurately than figure boxes
(verified against the page render for every block of the issue above),
so they are the one trustworthy fence against a figure swallowing the
column beside it.
"""
from scipy import ndimage
solid = (coverage >= _SNAP_SOLID) & ~blocked
labels, count = ndimage.label(solid)
if not count:
return None
slices = ndimage.find_objects(labels)
boxes = [[s[1].start, s[0].start, s[1].stop - 1, s[0].stop - 1]
for s in slices]
sizes = ndimage.sum_labels(solid, labels, range(1, count + 1))
sx0, sy0, sx1, sy1 = seed
seed_area = max(1, (sx1 - sx0 + 1) * (sy1 - sy0 + 1))
overlaps = [_cell_overlap(b, seed) for b in boxes]
best = max(range(count), key=overlaps.__getitem__)
if overlaps[best] < _SNAP_MIN_OVERLAP * seed_area:
return None
# A figure split by white gutters (a grid of cards, a chart beside its
# key) arrives as several components; re-join the ones that line up with
# what we have so far, then re-test -- joining one may bring the next
# within reach.
box = list(boxes[best])
joined = {best}
growing = True
while growing:
growing = False
for i, other in enumerate(boxes):
if i in joined or sizes[i] < 4 or not _cells_adjacent(box, other):
continue
box = [min(box[0], other[0]), min(box[1], other[1]),
max(box[2], other[2]), max(box[3], other[3])]
joined.add(i)
growing = True
area = (box[2] - box[0] + 1) * (box[3] - box[1] + 1)
if area < _SNAP_MIN_SEED_AREA * seed_area:
return None
inside = coverage[box[1]:box[3] + 1, box[0]:box[2] + 1]
if inside.mean() >= _SNAP_MIN_FILL:
return box
# A screenshot or a boxed insert is mostly white inside but inked right
# around its edge, so a solid frame counts as a graphic too.
edge = solid[box[1]:box[3] + 1, box[0]:box[2] + 1]
if (edge.any(axis=1).mean() >= _SNAP_MIN_FRAME
and edge.any(axis=0).mean() >= _SNAP_MIN_FRAME):
return box
return None
def _cell_overlap(a: Sequence[int], b: Sequence[int]) -> int:
"""Area, in grid cells, shared by two inclusive [x0, y0, x1, y1] boxes."""
return (max(0, min(a[2], b[2]) - max(a[0], b[0]) + 1)
* max(0, min(a[3], b[3]) - max(a[1], b[1]) + 1))
def _cells_adjacent(box: Sequence[int], other: Sequence[int]) -> bool:
"""True when `other` is a gutter away from `box` and lines up with it."""
gap = _SNAP_GAP_CELLS
if not (other[0] <= box[2] + gap and other[2] >= box[0] - gap
and other[1] <= box[3] + gap and other[3] >= box[1] - gap):
return False
# Side by side or stacked, not merely diagonal: a tile of the same figure
# shares most of a row or most of a column with it.
shared_rows = max(0, min(other[3], box[3]) - max(other[1], box[1]) + 1)
shared_cols = max(0, min(other[2], box[2]) - max(other[0], box[0]) + 1)
return (shared_rows >= 0.5 * min(other[3] - other[1] + 1, box[3] - box[1] + 1)
or shared_cols >= 0.5 * min(other[2] - other[0] + 1, box[2] - box[0] + 1))
def snap_visual_bboxes(png_path: Path, blocks: Sequence[Block],
page_meta: dict[str, Any]) -> int:
"""Correct every visual block's bbox against the page render, in place.
Returns how many boxes moved. Silent no-op when the render is missing or
the imaging stack is unavailable -- the layout stays exactly as the OCR
engine reported it, which is what happened before this pass existed.
"""
visuals = [b for b in blocks if b.type in VISUAL_TYPES]
if not visuals:
return 0
try:
import numpy as np
import scipy.ndimage # noqa: F401 -- required by _snap_seed
except ImportError:
return 0
coverage = page_ink_grid(png_path)
if coverage is None:
return 0
rows, cols = coverage.shape
page_w = float(page_meta.get("width") or 0) or 1.0
page_h = float(page_meta.get("height") or 0) or 1.0
cells_x, cells_y = cols / page_w, rows / page_h
def to_cells(bbox: Sequence[float]) -> list[int]:
x, y, w, h = bbox
return [min(cols - 1, max(0, int(x * cells_x))),
min(rows - 1, max(0, int(y * cells_y))),
min(cols - 1, max(0, int((x + w) * cells_x))),
min(rows - 1, max(0, int((y + h) * cells_y)))]
blocked = np.zeros(coverage.shape, dtype=bool)
for b in blocks:
if b.type in VISUAL_TYPES or not b.text.strip():
continue
x0, y0, x1, y1 = to_cells(b.bbox)
blocked[y0:y1 + 1, x0:x1 + 1] = True
snapped = [_snap_seed(coverage, to_cells(b.bbox), blocked) for b in visuals]
# Two figures pulled onto the same graphic means the page was read as
# one picture where the engine saw two (or the reverse); neither reading
# can be trusted over the other, so both keep their original box.
for i, a in enumerate(snapped):
for j in range(i + 1, len(snapped)):
c = snapped[j]
if not a or not c:
continue
smaller = min((a[2] - a[0] + 1) * (a[3] - a[1] + 1),
(c[2] - c[0] + 1) * (c[3] - c[1] + 1))
if _cell_overlap(a, c) > 0.5 * smaller:
snapped[i] = snapped[j] = None
a = None
moved = 0
for block, box in zip(visuals, snapped):
if box is None:
continue
bbox = [box[0] / cells_x, box[1] / cells_y,
(box[2] + 1 - box[0]) / cells_x, (box[3] + 1 - box[1]) / cells_y]
if any(abs(a - b) > 1.0 for a, b in zip(bbox, block.bbox)):
moved += 1
block.bbox = bbox
return moved
def ocr_page(png_path: Path, lang: str = "deu+eng",
min_confidence: float = 40.0) -> list[dict[str, Any]]:
"""OCR one rendered page, returning paragraph boxes from tesseract's TSV.
Grouped by (block, paragraph), not by line: PyMuPDF's native path already
aggregates a paragraph's spans into one Block with one representative font
size, and OCR must match that shape or the two paths behave inconsistently
downstream. Confirmed live on a scanned magazine -- one Block per LINE let
a single anomalous line (a chart axis number, a headline's wrapped second
line, scan noise) independently trigger BLOCK_TITLE classification, and a
28-page scan produced ~11 "title" blocks per page instead of grouping
around real headlines. font_size uses the MEDIAN of the paragraph's own
line heights rather than the paragraph's total bbox height (which would
scale with line count, not type size) -- a genuine multi-line headline
still measures large because each of its few lines is uniformly large;
one noisy line inside an otherwise normal paragraph no longer dominates.
TSV rather than plain text because the coordinates are the whole point:
they are what lets an OCR'd page take part in the same column and article
grouping as a native-text page.
"""
try:
proc = subprocess.run(
["tesseract", str(png_path), "stdout", "-l", lang, "tsv"],
capture_output=True, text=True, timeout=180, check=True,
)
except (subprocess.SubprocessError, FileNotFoundError, OSError):
return []
paragraphs: dict[tuple[int, int], dict[str, Any]] = {}
for row in proc.stdout.splitlines()[1:]:
parts = row.split("\t")
if len(parts) < 12:
continue
try:
block_num, par_num, line_num = int(parts[2]), int(parts[3]), int(parts[4])
left, top, width, height = (int(parts[6]), int(parts[7]),
int(parts[8]), int(parts[9]))
conf = float(parts[10])
except ValueError:
continue
word = parts[11].strip()
if not word or conf < min_confidence:
continue
para_key = (block_num, par_num)
para = paragraphs.setdefault(para_key, {"lines": {}})
line = para["lines"].setdefault(line_num, {
"text": [], "x0": left, "y0": top,
"x1": left + width, "y1": top + height, "conf": [],
})
line["text"].append(word)
line["x0"] = min(line["x0"], left)
line["y0"] = min(line["y0"], top)
line["x1"] = max(line["x1"], left + width)
line["y1"] = max(line["y1"], top + height)
line["conf"].append(conf)
out = []
for _, para in sorted(paragraphs.items()):
lines = [l for _, l in sorted(para["lines"].items()) if l["text"]]
if not lines:
continue
x0 = min(l["x0"] for l in lines)
y0 = min(l["y0"] for l in lines)
x1 = max(l["x1"] for l in lines)
y1 = max(l["y1"] for l in lines)
heights = [float(l["y1"] - l["y0"]) for l in lines]
confs = [c for l in lines for c in l["conf"]]
out.append({
"text": " ".join(" ".join(l["text"]) for l in lines),
"bbox": [float(x0), float(y0), float(x1 - x0), float(y1 - y0)],
"line_height": statistics.median(heights),
"confidence": round(sum(confs) / len(confs) / 100.0, 3),
})
return out
_VISION_QUALITY_THRESHOLD = 0.5 # below this, existing native/OCR text is escalated
def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
ocr_lang: str = "deu+eng",
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,
vision_ocr_timeout: float = 150.0,
force_vision_ocr: bool = False,
progress: Any = None) -> dict[str, Any]:
"""Run the full extraction for one PDF, writing page renders and images.
When vision_ocr_url/model are given, a page whose text needs OCR (or
whose already-extracted text scores as mostly garbage, native or
tesseract alike -- see ocr_quality_score) is escalated to that engine
first, with a second vision engine (vision_ocr_fallback_*) tried next if
given, and tesseract only as the last resort if every vision call fails
or every engine is unreachable. Confirmed live against
ocr-baidu-unlimited-ocr: markedly more accurate transcription of a
scanned German magazine than tesseract, and it labels running headers/
footers/page numbers directly (see build_vision_blocks), which is the
dominant source of the over-fragmentation cluster_running_furniture()
otherwise has to recover after the fact from noisy OCR variants of the
same repeating line. KDL Frontier Parser nano, tested as the fallback
engine, answers faster but returns plain text with no layout coordinates
at all -- handled by _whole_page_region as one ambiguous full-page block.
Returns the layout document: every block on every page, classified and in
reading order, plus the raw contents-page candidates.
"""
fitz = _require_fitz()
pages_dir = out_dir / "pages"
images_dir = out_dir / "images"
pages_dir.mkdir(parents=True, exist_ok=True)
images_dir.mkdir(parents=True, exist_ok=True)
vision_engines = [(vision_ocr_url, vision_ocr_model),
(vision_ocr_fallback_url, vision_ocr_fallback_model)]
vision_enabled = bool(vision_ocr_url and vision_ocr_model)
vision_page_count = 0
doc = fitz.open(str(pdf_path))
try:
raw_pages: list[dict[str, Any]] = []
for pno in range(doc.page_count):
page = doc.load_page(pno)
rect = page.rect
page_no = pno + 1
png = pages_dir / f"{page_no:04d}.png"
if not png.exists():
pix = page.get_pixmap(dpi=render_dpi)
pix.save(str(png))
image_boxes: list[list[float]] = []
page_images: list[dict[str, Any]] = []
for img_index, info in enumerate(page.get_images(full=True)):
rects: list[list[float]] = []
try:
for r in page.get_image_rects(info[0]):
rects.append([r.x0, r.y0, r.width, r.height])
except Exception:
continue
image_boxes.extend(rects)
name = f"p{page_no:04d}-{img_index:02d}"
if _extract_image(fitz, doc, info[0], images_dir, name) and rects:
# A few embedded images repeat (a logo stamped on every
# page); each placement still gets its own bbox so a
# caption or figure position can be matched precisely.
for ri, box in enumerate(rects):
page_images.append({
"id": name if ri == 0 else f"{name}-{ri}",
"page": page_no,
"bbox": box,
"filename": f"{name}.png",
})
blocks: list[Block] = []
data = page.get_text("dict")
for bi, blk in enumerate(data.get("blocks", [])):
if blk.get("type") != 0: # 0 = text, 1 = image
continue
texts, sizes, fonts, bolds = [], [], [], []
for line in blk.get("lines", []):
for span in line.get("spans", []):
if not span.get("text", "").strip():
continue
texts.append(span["text"])
sizes.append(float(span.get("size", 0)))
fonts.append(span.get("font", ""))
bolds.append(bool(span.get("flags", 0) & 2 ** 4))
text = " ".join(texts).strip()
if not text:
continue
x0, y0, x1, y1 = blk["bbox"]
blocks.append(Block(
id=f"b{page_no:04d}-{bi:03d}",
page=page_no,
bbox=[x0, y0, x1 - x0, y1 - y0],
type=BLOCK_BODY,
text=text,
font_size=statistics.median(sizes) if sizes else 0.0,
font_name=max(set(fonts), key=fonts.count) if fonts else "",
bold=sum(bolds) > len(bolds) / 2 if bolds else False,
))
# A page whose native text is negligible is a scan (or is drawn as
# outlines); OCR is the only way to reach its words. A page WITH
# native/embedded text is not necessarily good text -- a
# pre-OCR'd scan (e.g. from scanner-bundled OCR software) can
# carry a poor text layer that this same escalation should reach.
native_chars = sum(len(b.text) for b in blocks)
needs_ocr = native_chars < 120
native_quality = (
ocr_quality_score(" ".join(b.text for b in blocks))
if not needs_ocr else 0.0
)
needs_escalation = force_vision_ocr or needs_ocr or native_quality < _VISION_QUALITY_THRESHOLD
vision_blocks: list[Block] | None = None
if vision_enabled and needs_escalation:
regions = vision_ocr_page_chain(png, vision_engines,
timeout=vision_ocr_timeout)
if regions:
scale_x = rect.width / max(1.0, _png_width(png, fitz))
vision_blocks = build_vision_blocks(
regions, page_no, scale_x, id_prefix="b")
if vision_blocks is not None:
# Replaces whatever was already there -- including a poor
# native/pre-OCR'd text layer, rather than layering a second,
# conflicting reading on top of it.
vision_page_count += 1
blocks = vision_blocks
page_meta = {"width": rect.width, "height": rect.height}
# Before cropping: the crop is taken from the block's bbox,
# so a box the engine placed loosely would be saved as a
# mis-framed figure file as well as a mis-drawn review box.
snap_visual_bboxes(png, blocks, page_meta)
detected_images = crop_visual_regions(out_dir, page_no,
page_meta, blocks)
if detected_images:
page_images = detected_images
elif needs_ocr:
scale_x = rect.width / max(1.0, _png_width(png, fitz))
for li, para in enumerate(ocr_page(png, ocr_lang)):
bx, by, bw, bh = para["bbox"]
blocks.append(Block(
id=f"b{page_no:04d}-o{li:03d}",
page=page_no,
bbox=[bx * scale_x, by * scale_x, bw * scale_x, bh * scale_x],
type=BLOCK_BODY,
text=para["text"],
confidence=para["confidence"],
# The paragraph's own line height, not its full bbox
# height -- that would scale with line count instead
# of type size (see ocr_page's docstring).
font_size=para["line_height"] * scale_x,
source="ocr",
))
# else: native text present and scored well enough -- unchanged.
raw_pages.append({
"page": page_no,
"width": rect.width,
"height": rect.height,
"blocks": blocks,
"image_boxes": image_boxes,
"images": page_images,
})
if progress:
# Raw block text, pre-classification -- good enough for a
# live "here is what was just read" preview during ingestion
# (mirrors ocr-baidu-unlimited-ocr's own demo: source page on
# one side, recognised text on the other, filled in as each
# page finishes) without waiting for the document-wide passes
# below.
progress(page_no, doc.page_count, blocks)
# Furniture can only be identified across the whole document, so
# classification waits until every page has been read.
repeated = find_repeated_lines(
[[b.text for b in p["blocks"]] for p in raw_pages]
)
all_blocks: list[Block] = []
contents_candidates: list[dict[str, Any]] = []
# One baseline for the whole issue (see classify_blocks).
doc_base = body_font_size(
[b for p in raw_pages for b in p["blocks"]]
)
for p in raw_pages:
classify_blocks(p["blocks"], p["height"], p["width"],
repeated=repeated, image_boxes=p["image_boxes"],
base_size=doc_base)
mark_advert_pages(p["blocks"])
mark_pullquotes(p["blocks"])
assign_reading_order(p["blocks"], p["width"])
mark_sidebars(p["blocks"], p["width"])
all_blocks.extend(p["blocks"])
# Cross-page, so it runs once over every block rather than per page
# (see cluster_running_furniture's docstring for why exact-match
# find_repeated_lines above is not enough once OCR is involved).
page_heights = {p["page"]: p["height"] for p in raw_pages}
cluster_running_furniture(all_blocks, page_heights)
for p in raw_pages:
lines = [b.text for b in sorted(p["blocks"], key=lambda b: b.reading_order)]
score = score_contents_page(lines)
if score >= 0.45:
contents_candidates.append({
"page": p["page"],
"score": score,
"entries": parse_contents(lines),
})
all_images = [img for p in raw_pages for img in p["images"]]
for img in all_images:
img["reading_order"] = image_reading_position(img, all_blocks)
return {
"page_count": doc.page_count,
"pages": [{"page": p["page"], "width": p["width"], "height": p["height"]}
for p in raw_pages],
"blocks": [b.to_json() for b in all_blocks],
"images": all_images,
"contents_candidates": contents_candidates,
"vision_ocr_pages": vision_page_count,
}
finally:
doc.close()
def _png_width(png: Path, fitz) -> float:
try:
return float(fitz.Pixmap(str(png)).width)
except Exception:
return 1.0
def _extract_image(fitz, doc, xref: int, images_dir: Path, name: str) -> bool:
"""Save one embedded image, skipping the tiny rules and spacers.
Returns whether a file now exists at that name, so the caller can decide
whether to record it as a real figure -- without this, a page's rules and
spacers were silently skipped on disk but still implicitly "existed" as
far as anything downstream knew, since nothing checked.
"""
try:
pix = fitz.Pixmap(doc, xref)
if pix.width < 64 or pix.height < 64:
return False
if pix.n - pix.alpha >= 4: # CMYK needs converting first
pix = fitz.Pixmap(fitz.csRGB, pix)
target = images_dir / f"{name}.png"
if not target.exists():
pix.save(str(target))
return True
except Exception:
return False
# ── Images: linking a figure to the article it illustrates ──────────────────
def assign_images_to_articles(images: Sequence[dict[str, Any]],
blocks: Sequence[Block],
articles: Sequence[Article]) -> None:
"""Attach each extracted figure to the article whose column it falls in.
A figure belongs to whichever article occupies its page; when two
articles share a page (a feature ending, a new one starting lower down),
the figure goes to whichever article has the nearer block by vertical
position, so a photo is not shown under the wrong headline. Mutates
articles in place.
"""
by_page: dict[int, list[Article]] = {}
for art in articles:
for page in art.pages:
by_page.setdefault(page, []).append(art)
blocks_by_page: dict[int, list[Block]] = {}
for b in blocks:
blocks_by_page.setdefault(b.page, []).append(b)
for img in images:
if img.get("type") == BLOCK_AD:
continue
candidates = by_page.get(img["page"], [])
if not candidates:
continue
if len(candidates) == 1:
candidates[0].images.append(img["id"])
continue
# Several articles share this page: pick by nearest block vertically,
# since that is what a reader would associate the figure with.
img_y = img["bbox"][1]
best, best_dist = candidates[0], float("inf")
for art in candidates:
own_blocks = [b for b in blocks_by_page.get(img["page"], [])
if b.id in set(art.blocks)]
if not own_blocks:
continue
dist = min(abs(b.bbox[1] - img_y) for b in own_blocks)
if dist < best_dist:
best, best_dist = art, dist
best.images.append(img["id"])
def image_reading_position(image: dict[str, Any], blocks: Sequence[Block]) -> int:
"""Approximate reading_order for an image, for ordering it among sentences.
Uses the nearest block on the same page whose top sits at or above the
image (so a photo is shown once the surrounding text has been reached,
not before), falling back to the closest block on the page if the image
sits above all of them.
"""
same_page = [b for b in blocks if b.page == image["page"]]
if not same_page:
return 0
above = [b for b in same_page if b.bbox[1] <= image["bbox"][1]]
pool = above or same_page
nearest = min(pool, key=lambda b: abs(b.bbox[1] - image["bbox"][1]))
return nearest.reading_order
# ── Persistence ──────────────────────────────────────────────────────────────
def build_articles(layout: dict[str, Any]) -> list[dict[str, Any]]:
"""Turn a stored layout document into detected articles with estimates."""
blocks = [Block(**b) for b in layout.get("blocks", [])]
candidates = layout.get("contents_candidates") or []
best = max(candidates, key=lambda c: c["score"], default=None)
entries = best["entries"] if best else []
# The contents page lists articles; it is not one.
skip = {c["page"] for c in candidates if c.get("score", 0) >= 0.45}
articles = group_articles(blocks, entries, layout.get("page_count", 0),
skip_pages=skip)
assign_images_to_articles(layout.get("images") or [], blocks, articles)
out = []
for art in articles:
rec = art.to_json()
rec["estimated_seconds"] = estimate_listen_seconds(blocks, art)
out.append(rec)
return out
def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int,
vision_ocr_url: str, vision_ocr_model: str,
vision_ocr_fallback_url: str | None = None,
vision_ocr_fallback_model: str | None = None,
timeout: float = 150.0) -> dict[str, Any] | None:
"""Force one page through the vision OCR chain again with the engine the
user picked, replacing only that page's blocks in an already-ingested
layout.
Unlike extract_pages()'s automatic escalation, this always calls the
engine regardless of the page's existing text quality -- a user pressing
"OCR again" has already judged the result unusable and is not asking for
another quality gate. Deliberately does NOT call build_articles(): an
article the user already reviewed and approved must not be silently
re-grouped and lose that approval just because one of its pages was
re-read. The caller decides whether to re-run detection separately.
Returns the updated layout, or None if the page has no rendered PNG or
the engine returned nothing usable (network failure, empty response) --
the caller keeps the existing blocks in that case, never discards good
data for a failed retry.
"""
fitz = _require_fitz()
png = doc_dir / "pages" / f"{page_no:04d}.png"
if not png.is_file():
return None
page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
if not page_meta:
return None
engines = [(vision_ocr_url, vision_ocr_model),
(vision_ocr_fallback_url, vision_ocr_fallback_model)]
regions = vision_ocr_page_chain(png, engines, timeout=timeout)
if not regions:
return None
scale_x = page_meta["width"] / max(1.0, _png_width(png, fitz))
new_blocks = build_vision_blocks(regions, page_no, scale_x, id_prefix="r")
snap_visual_bboxes(png, new_blocks, page_meta)
all_blocks = [Block(**b) for b in layout.get("blocks", [])]
kept = [b for b in all_blocks if b.page != page_no]
# The baseline must come from the rest of the document, not this page
# alone -- see classify_blocks' docstring on why a single page (which may
# have no body copy of its own) cannot be its own reference.
doc_base = body_font_size(kept) or body_font_size(all_blocks)
classify_blocks(new_blocks, page_meta["height"], page_meta["width"], base_size=doc_base)
mark_advert_pages(new_blocks)
mark_pullquotes(new_blocks)
assign_reading_order(new_blocks, page_meta["width"])
mark_sidebars(new_blocks, page_meta["width"])
merged = kept + new_blocks
page_heights = {p["page"]: p["height"] for p in layout.get("pages", [])}
cluster_running_furniture(merged, page_heights)
layout = dict(layout)
layout["blocks"] = [b.to_json() for b in merged]
detected_images = crop_visual_regions(doc_dir, page_no, page_meta, new_blocks)
if detected_images:
layout["images"] = [i for i in layout.get("images", []) if i["page"] != page_no or "-manual-" in i["id"]] + detected_images
layout["vision_ocr_pages"] = int(layout.get("vision_ocr_pages") or 0) + 1
return layout
def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: dict | None = None) -> list[dict]:
"""Group existing OCR fragments without allowing invented IDs or text."""
known = {b["id"]: b for b in blocks}
used = set()
out = []
allowed = set(NARRATABLE) | set(VISUAL_TYPES) | {BLOCK_AD, BLOCK_SIDEBAR, BLOCK_CAPTION, BLOCK_FURNITURE, BLOCK_PULLQUOTE}
for region in regions:
if not isinstance(region, dict):
raise ValueError("Invalid semantic region")
ids = region.get("block_ids")
label = region.get("type")
kind = _VISION_LABEL_TYPES.get(label, label)
visual_box = None
if "bbox" in region and kind in (*VISUAL_TYPES, BLOCK_AD):
bbox = region["bbox"]
if not page_meta or not isinstance(bbox, list) or len(bbox) != 4 or not all(
isinstance(v, (int, float)) and math.isfinite(v) and 0 <= v <= 1000 for v in bbox):
raise ValueError("Invalid visual bounding box")
x0, y0, x1, y1 = bbox
if x1 <= x0 or y1 <= y0:
raise ValueError("Empty visual region")
visual_box = [x0 / 1000 * page_meta["width"], y0 / 1000 * page_meta["height"],
(x1 - x0) / 1000 * page_meta["width"], (y1 - y0) / 1000 * page_meta["height"]]
if not ids and kind in VISUAL_TYPES and visual_box:
out.append(Block(id=f"p{page_meta['page']:04d}-visual-{len(out)}", page=page_meta['page'],
bbox=visual_box, type=kind, text="", reading_order=len(out),
source="llm_layout", region_label=label).to_json())
continue
if not isinstance(ids, list) or not ids or not all(isinstance(i, str) for i in ids):
raise ValueError("Invalid region IDs")
if kind not in allowed or len(set(ids)) != len(ids) or any(i not in known or i in used for i in ids):
raise ValueError("Unknown or repeated semantic region")
members = [known[i] for i in ids]
if len({b["page"] for b in members}) != 1:
raise ValueError("A region cannot span pages")
if any(b["type"] == BLOCK_AD for b in members):
if not all(b["type"] == BLOCK_AD for b in members):
raise ValueError("Cannot merge advertising and editorial text")
kind = BLOCK_AD
used.update(ids)
x = min(b["bbox"][0] for b in members)
y = min(b["bbox"][1] for b in members)
right = max(b["bbox"][0] + b["bbox"][2] for b in members)
bottom = max(b["bbox"][1] + b["bbox"][3] for b in members)
separator = "\n" if kind in (BLOCK_CODE, BLOCK_TABLE) else " "
text = separator.join(b["text"] for b in members)
if kind in NARRATABLE:
text = re.sub(r"(?<=\w)-\s+(?=[a-zäöüß])", "", text)
out.append(dict(members[0], bbox=visual_box or [x, y, right - x, bottom - y], text=text,
type=kind, region_label=label, source="llm_layout",
reading_order=len(out),
continues_from_previous_page=region.get("continues_from_previous_page") is True))
# Incomplete model output must not silently remove document content.
return out + [b for b in blocks if b["id"] not in used]
def remap_reviewed_page(articles: list[dict], old_layout: dict, layout: dict, page: int) -> None:
"""Reconnect reviewed articles when OCR replaces the page's region IDs."""
old = {b["id"]: b for b in old_layout.get("blocks", []) if b["page"] == page}
owners = {a["id"]: [old[i] for i in a.get("blocks", []) if i in old] for a in articles}
affected = [a for a in articles if page in a.get("pages", []) or owners[a["id"]]]
for a in affected:
a["blocks"] = [i for i in a.get("blocks", []) if i not in old]
known_images = {i["id"] for i in layout.get("images", [])}
a["images"] = [i for i in a.get("images", []) if i in known_images]
if a.get("status") == "approved":
a["status"] = "detected"
for block in layout.get("blocks", []):
if block["page"] != page or block["type"] not in NARRATABLE or not affected:
continue
x, y, w, h = block["bbox"]
def overlap(a):
return sum(max(0, min(x + w, b["bbox"][0] + b["bbox"][2]) - max(x, b["bbox"][0])) *
max(0, min(y + h, b["bbox"][1] + b["bbox"][3]) - max(y, b["bbox"][1]))
for b in owners[a["id"]])
best = max(affected, key=overlap)
if overlap(best) >= w * h * 0.35:
best["blocks"].append(block["id"])
# Newly recognised visual regions follow the closest article on the page.
objects = [Article(**{k: v for k, v in a.items() if k in Article.__dataclass_fields__}) for a in affected]
new_images = [i for i in layout.get("images", []) if i["page"] == page and i.get("source_block")]
for a in objects:
a.images = [i for i in a.images if i not in {im["id"] for im in new_images}]
assign_images_to_articles(new_images, [Block(**b) for b in layout.get("blocks", [])], objects)
for a, obj in zip(affected, objects):
a["images"] = obj.images
def _crop_page_png(doc_dir: Path, page_no: int, page_meta: dict[str, Any],
bbox: Sequence[float], dest: Path) -> None:
"""Crop one region (PDF-point bbox) out of a page's rendered PNG."""
from PIL import Image
png = doc_dir / "pages" / f"{page_no:04d}.png"
with Image.open(png) as im:
sx, sy = im.width / page_meta["width"], im.height / page_meta["height"]
box_px = (
max(0, int(bbox[0] * sx)), max(0, int(bbox[1] * sy)),
min(im.width, int((bbox[0] + bbox[2]) * sx)),
min(im.height, int((bbox[1] + bbox[3]) * sy)),
)
im.crop(box_px).save(dest)
def refresh_visual_regions(doc_dir: Path, layout: dict, page: int | None = None) -> None:
"""Re-snap figure bounds, regenerate the crops taken from them, and avoid
treating a whole scanned page as one figure."""
for meta in layout.get("pages", []):
page_no = meta["page"]
if page is not None and page_no != page:
continue
blocks = [Block(**b) for b in layout.get("blocks", []) if b["page"] == page_no]
# Re-snapping here, not only at OCR time, is what lets an issue
# ingested before this pass existed pick up corrected figure boxes
# without paying for a second OCR run of the whole document. It is
# idempotent: an already-correct box seeds the same graphic again.
if snap_visual_bboxes(doc_dir / "pages" / f"{page_no:04d}.png", blocks, meta):
snapped = {b.id: b.bbox for b in blocks if b.type in VISUAL_TYPES}
for saved in layout["blocks"]:
if saved["id"] in snapped:
saved["bbox"] = snapped[saved["id"]]
crops = crop_visual_regions(doc_dir, page_no, meta, blocks)
kept = []
for img in layout.get("images", []):
if img["page"] != page_no or "-manual-" in img["id"]:
kept.append(img)
continue
if img.get("source_block"):
continue
x, y, w, h = img["bbox"]
if w * h >= meta["width"] * meta["height"] * .85 and sum(len(b.text) for b in blocks) > 120:
continue
if any(abs(x - c["bbox"][0]) < 5 and abs(y - c["bbox"][1]) < 5 and
abs(w - c["bbox"][2]) < 10 and abs(h - c["bbox"][3]) < 10 for c in crops):
continue
# Embedded graphics inside an identified advert are advertising too.
if any(b.type == BLOCK_AD and x >= b.bbox[0] and y >= b.bbox[1] and
x + w <= b.bbox[0] + b.bbox[2] and y + h <= b.bbox[1] + b.bbox[3] for b in blocks):
img = dict(img, type=BLOCK_AD)
kept.append(img)
for crop in crops:
crop["reading_order"] = image_reading_position(crop, blocks)
layout["images"] = kept + crops
def crop_visual_regions(doc_dir: Path, page_no: int, page_meta: dict,
blocks: Sequence[Block]) -> list[dict[str, Any]]:
"""Keep charts, tables and code boxes intact as page crops for playback."""
images = []
for b in blocks:
if b.type not in VISUAL_TYPES:
continue
name = f"p{page_no:04d}-region-{b.id}"
(doc_dir / "images").mkdir(parents=True, exist_ok=True)
_crop_page_png(doc_dir, page_no, page_meta, b.bbox, doc_dir / "images" / f"{name}.png")
images.append({"id": name, "page": page_no, "bbox": b.bbox,
"filename": f"{name}.png", "type": b.type,
"source_block": b.id, "text": b.text,
"reading_order": b.reading_order})
return images
def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int,
bbox: Sequence[float], kind: str, text: str = "",
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,
timeout: float = 150.0) -> dict[str, Any]:
"""Add a region the user drew by hand -- for text or a figure OCR/layout
detection missed entirely, not for correcting one already found (use
review's tick/untick or reocr_page for that).
kind "image" crops the region into its own figure file and adds it to
layout["images"]. kind "text" adds a new Block: if `text` is supplied it
is used verbatim (the user typed it themselves), otherwise the crop is
sent through the vision OCR chain if one is configured -- best-effort,
the block is still added with a placeholder if that returns nothing,
since the user drawing this region at all means they judged the text
important enough to include even without a clean read.
Deliberately does not re-run classify_blocks on the new region: the user
is drawing it precisely because automatic detection already failed on
it, so re-applying the same heuristics would not be trustworthy. Text is
trusted as stated (BLOCK_BODY, narratable); explicit review labels map to
their matching semantic block types and only then receive a fresh
reading-order position on its page.
"""
page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
if not page_meta:
raise ValueError("Unknown page")
if len(bbox) != 4 or bbox[2] <= 0 or bbox[3] <= 0:
raise ValueError("bbox must be [x, y, width, height] with a positive size")
layout = dict(layout)
if kind == "image":
images_dir = doc_dir / "images"
images_dir.mkdir(parents=True, exist_ok=True)
existing = list(images_dir.glob(f"p{page_no:04d}-manual-*.png"))
name = f"p{page_no:04d}-manual-{len(existing):03d}"
_crop_page_png(doc_dir, page_no, page_meta, bbox, images_dir / f"{name}.png")
images = list(layout.get("images", []))
# Placed after every auto-detected image on the page by reading
# order -- a reasonable default for a figure the pipeline missed;
# the user can still see and move past it like any other figure.
page_images = [i for i in images if i["page"] == page_no]
next_ro = max([i.get("reading_order", 0) for i in page_images], default=0) + 1
images.append({"id": name, "page": page_no, "bbox": list(bbox),
"filename": f"{name}.png", "reading_order": next_ro})
layout["images"] = images
return layout
manual_type, region_label = {
"text": (BLOCK_BODY, None),
"heading": (BLOCK_HEADING, None),
"page_number": (BLOCK_FURNITURE, "page_number"),
"source": (BLOCK_FURNITURE, "source"),
"ad": (BLOCK_AD, None),
}.get(kind, (None, None))
if manual_type is None:
raise ValueError("Unsupported manual region kind")
final_text = text.strip()
if not final_text and vision_ocr_url and vision_ocr_model:
crop_path = doc_dir / "pages" / f".manual-crop-{page_no}.png"
try:
_crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path)
engines = [(vision_ocr_url, vision_ocr_model),
(vision_ocr_fallback_url, vision_ocr_fallback_model)]
regions = vision_ocr_page_chain(crop_path, engines, timeout=timeout)
if regions:
final_text = " ".join(r["text"] for r in regions).strip()
finally:
crop_path.unlink(missing_ok=True)
if not final_text:
final_text = "(no text recognised -- click to edit)"
blocks = [Block(**b) for b in layout.get("blocks", [])]
existing_ids = {b.id for b in blocks}
i = 0
while f"m{page_no:04d}-{i:03d}" in existing_ids:
i += 1
new_block = Block(id=f"m{page_no:04d}-{i:03d}", page=page_no, bbox=list(bbox),
type=manual_type, text=final_text, confidence=0.5, source="manual",
region_label=region_label)
page_blocks = [b for b in blocks if b.page == page_no] + [new_block]
assign_reading_order(page_blocks, page_meta["width"])
others = [b for b in blocks if b.page != page_no]
layout["blocks"] = [b.to_json() for b in others + page_blocks]
return layout
def extract_region_text(doc_dir: Path, layout: dict[str, Any], page_no: int,
bbox: Sequence[float], 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,
timeout: float = 150.0) -> str:
"""OCR a user-selected rectangle without changing the saved layout.
Used for metadata fields such as article title/author: unlike a manual
region, the selection is a one-off extraction and must not become prose
that is later narrated.
"""
page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
if not page_meta or len(bbox) != 4 or bbox[2] <= 0 or bbox[3] <= 0:
raise ValueError("Invalid page selection")
with tempfile.NamedTemporaryFile(prefix=f".extract-{page_no}-", suffix=".png",
dir=doc_dir / "pages", delete=False) as tmp:
crop_path = Path(tmp.name)
try:
_crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path)
regions = None
if vision_ocr_url and vision_ocr_model:
regions = vision_ocr_page_chain(crop_path, [
(vision_ocr_url, vision_ocr_model),
(vision_ocr_fallback_url, vision_ocr_fallback_model),
], timeout=timeout)
if regions:
return " ".join(r.get("text", "") for r in regions if r.get("text")).strip()
return " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip()
finally:
crop_path.unlink(missing_ok=True)
def save_layout(doc_dir: Path, layout: dict[str, Any]) -> None:
(doc_dir / "layout.json").write_text(
json.dumps(layout, ensure_ascii=False), "utf-8")
def load_layout(doc_dir: Path) -> dict[str, Any]:
path = doc_dir / "layout.json"
if not path.is_file():
return {}
try:
return json.loads(path.read_text("utf-8"))
except (ValueError, OSError):
return {}
def save_articles(doc_dir: Path, articles: list[dict[str, Any]]) -> None:
(doc_dir / "articles.json").write_text(
json.dumps({"articles": articles}, ensure_ascii=False), "utf-8")
def load_articles(doc_dir: Path) -> list[dict[str, Any]]:
path = doc_dir / "articles.json"
if not path.is_file():
return []
try:
return json.loads(path.read_text("utf-8")).get("articles", [])
except (ValueError, OSError):
return []
def article_text(layout: dict[str, Any], article: dict[str, Any],
include_captions: bool = False) -> list[dict[str, Any]]:
"""The narratable text of one article, in reading order.
Returned per block so the player can highlight the source region and jump
to the originating PDF page.
"""
wanted = set(article.get("blocks") or [])
types = set(NARRATABLE) | ({BLOCK_CAPTION} if include_captions else set())
blocks = [
dict(b, text=strip_continuation_markers(b["text"]))
for b in layout.get("blocks", [])
if b["id"] in wanted and b["type"] in types
]
blocks.sort(key=lambda b: (b["page"], b["reading_order"]))
return blocks