--formulas: Formeln seitenweise erkennen, auch im Fließtext und in Kästen

Die Formelerkennung von pix2text (MFD) findet jede Formel auf der Seite, eingebettet
oder abgesetzt. Sie wird zeichengenau aus dem Text geschnitten (gemischte Textstücke
werden geteilt, Summen mit Grenzen an der richtigen Zeile verankert), als 300-dpi-Bild
gespeichert und in <bilder>/formulas-*.json mit Seite, Rechteck, PDF-Text und
"flach" vermerkt. Ergänzung über Mathematikschriften für übersehene Symbole.
"Formel"-Boxen mit Text werden zu Text, solche mit Kurven zu Grafiken. Wörter neben
hohen Formeln verlieren die falsche Hochstellung, Fußnotenzeichen bleiben.
Für formula_img2latex, das die Formeln in LaTeX übersetzt. pix2text ist optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-10 03:31:58 +02:00
commit 3ac32f4d34

313
pdf2md.py
View file

@ -1569,6 +1569,298 @@ def patch_pymupdf4llm_paragraphs() -> None:
document_layout.create_list_item_levels = create_list_item_levels
# ===========================================================================
# Formeln seitenweise erkennen (--formulas)
# ===========================================================================
# pymupdf4llm schneidet nur Blöcke der Layout-Klasse "formula" als Bild aus. Darunter
# sind auch Merkkästen mit Text und Grafiken, und Formeln im Fließtext bleiben (oft
# verstümmelter) Text ("x4n" statt x^{4n}). Mit --formulas sucht die Formelerkennung
# von pix2text (MFD) jede Formel auf der Seite, eingebettet oder abgesetzt, auch in
# Kästen. Jede wird als Bild ausgeschnitten und in <bilder>/formulas-*.json mit Seite
# und Rechteck vermerkt; formula_img2latex übersetzt sie dann in LaTeX.
FORMULA_DPI = 150 # Seitenbild für die Formelerkennung
FORMULA_CROP_DPI = 300 # gespeicherter Ausschnitt (bleibt im Buch, solange keine Übersetzung bestätigt ist)
FORMULA_MIN_SCORE = 0.3 # schwächere Treffer bleiben Text
FORMULA_BOX_WORDS = 3 # so viele Wörter in Textschrift außerhalb der Treffer: "Formel"-Box ist ein Kasten
FORMULA_FIGURE_PATHS = 3 # so viele gekrümmte/schräge Linien: "Formel"-Box ist eine Grafik
FORMULA_FIGURE_WORDS = 40 # ... sofern sie nicht vor allem Text enthält
# Mathematikschriften (TeX und Office); ihre Buchstaben ("lim", "sin") sind keine Wörter
MATH_FONT = re.compile(r"CMMI|CMSY|CMEX|CMBSY|MSBM|MSAM|EUFM|EURM|EUSM|RSFS|Math|math|Symbol|SY\d|rsfs|"
r"txsy|txex|pxsy|pxex|txmi|pxmi|NewTXMI|NewPXMI|STIX.*Math|MTMI|MTSY|MTEX|LMMath")
FOOTNOTE_MARK = re.compile(r"[\d*\u2020\u2021\u00a7]+") # Fußnotenzeichen: Ziffern, *, Kreuze, Paragraph
_formulas: dict = {"on": False, "detector": None, "docs": {}, "entries": {}, "tokens": {}}
TEXT_LIKE_BOXES = ("text", "list-item", "footnote", "title", "section-header", "caption")
def _formula_detector():
if _formulas["detector"] is None:
from pix2text import MathFormulaDetector # optional und schwer: nur mit --formulas laden
_formulas["detector"] = MathFormulaDetector()
return _formulas["detector"]
def detect_formulas(page: pymupdf.Page) -> list[dict]:
"""Formeln auf der Seite: [{"rect": Rect in pt, "type": "embedding"|"isolated", "score": float}]."""
from PIL import Image
pix = page.get_pixmap(dpi=FORMULA_DPI)
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
f = 72.0 / FORMULA_DPI
hits = []
for hit in _formula_detector().detect(img):
if hit["score"] < FORMULA_MIN_SCORE:
continue
xs = [p[0] for p in hit["box"]]
ys = [p[1] for p in hit["box"]]
hits.append({"rect": pymupdf.Rect(min(xs) * f, min(ys) * f, max(xs) * f, max(ys) * f),
"type": hit["type"], "score": float(hit["score"])})
return hits
def _hit_for(bbox, hits: list[dict]) -> int | None:
"""Index des Treffers, in dem die Mitte von bbox liegt."""
x, y = (bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2
for k, hit in enumerate(hits):
r = hit["rect"]
if r.x0 - 1 <= x <= r.x1 + 1 and r.y0 - 1 <= y <= r.y1 + 1:
return k
return None
def _text_words(textlines: list[dict], hits: list[dict]) -> int:
"""Wörter in Textschrift außerhalb der Formeltreffer."""
return sum(len(REAL_WORD.findall(span["text"]))
for line in textlines for span in line["spans"]
if not MATH_FONT.search(span.get("font", "")) and _hit_for(span["bbox"], hits) is None)
def _figure_paths(page: pymupdf.Page, rect: pymupdf.Rect) -> int:
"""Gekrümmte oder schräge Linien in rect: Kurven, Achsen mit Pfeilen. Bruch- und Wurzelstriche
sind waagerecht, Rahmen und Hinterlegungen Rechtecke."""
n = 0
for path in page.get_drawings():
if not rect.contains(path["rect"]):
continue
for item in path["items"]:
if item[0] == "c" or (item[0] == "l" and abs(item[1].y - item[2].y) > 0.5 and abs(item[1].x - item[2].x) > 0.5):
n += 1
break
return n
def _formula_token(page: pymupdf.Page, hits: list[dict], k: int, image_path: str, filename: str,
chars: list | None = None) -> str:
"""Platzhalter für Treffer k; schneidet ihn beim ersten Mal aus und vermerkt ihn. Nur Buchstaben und
Ziffern: übersteht jede Textausgabe und Maskierung, finish_formula_links macht das Bild daraus."""
token = f"FORMELBILD{page.number + 1:04d}N{k:03d}"
if token not in _formulas["tokens"]:
hit = hits[k]
rect = hit["rect"] + (-1, -1, 1, 1)
name = f"{filename}-{page.number + 1:04d}-f{k:03d}.png"
md_name, save_name = document_layout.utils.md_path(image_path, name)
page.get_pixmap(dpi=FORMULA_CROP_DPI, clip=rect).save(save_name)
text, flat = _formula_text(hit["rect"], chars or [])
_formulas["entries"][Path(save_name).name] = {"page": page.number + 1, "rect": [round(v, 2) for v in rect],
"type": hit["type"], "score": round(hit["score"], 3), "text": text, "flat": flat}
_formulas["tokens"][token] = (md_name, hit["type"] == "isolated")
return token
def _page_chars(page: pymupdf.Page) -> list[tuple[pymupdf.Rect, str, float, float]]:
"""Alle Zeichen der Seite mit Position, Grundlinie und Größe (die Textstücke der PDF mischen Text und
Formel: "if 0")."""
chars = []
for block in page.get_text("rawdict")["blocks"]:
for line in block.get("lines", []):
for span in line["spans"]:
chars.extend((pymupdf.Rect(c["bbox"]), c["c"], c["origin"][1], span["size"]) for c in span["chars"])
return chars
def _formula_text(rect: pymupdf.Rect, chars: list) -> tuple[str, bool]:
"""Text der PDF-Textebene im Formelrechteck und ob er "flach" ist: alle Zeichen auf einer Grundlinie in
gleicher Größe, also ohne Hoch-/Tiefstellung, Bruch oder Grenzen, die die Textebene verlieren würde."""
inside = [ch for ch in chars if rect.contains(pymupdf.Point((ch[0].x0 + ch[0].x1) / 2, (ch[0].y0 + ch[0].y1) / 2))]
inside.sort(key=lambda ch: (round(ch[2]), ch[0].x0))
text = "".join(ch[1] for ch in inside).strip()
flat = bool(inside) and max(ch[2] for ch in inside) - min(ch[2] for ch in inside) <= 0.6 \
and max(ch[3] for ch in inside) - min(ch[3] for ch in inside) <= 0.2
return text, flat
def _span_chars(span: dict, chars: list[tuple[pymupdf.Rect, str]]) -> list[tuple[pymupdf.Rect, str]]:
"""Die Zeichen eines Textstücks mit Position; sonst gleichmäßig über seine Breite verteilt."""
box = pymupdf.Rect(span["bbox"]) + (-0.5, -0.5, 0.5, 0.5)
inside = [(ch[0], ch[1]) for ch in chars if box.contains(pymupdf.Point((ch[0].x0 + ch[0].x1) / 2,
(ch[0].y0 + ch[0].y1) / 2))]
inside.sort(key=lambda rc: rc[0].x0)
if "".join(c for _, c in inside) == span["text"]:
return inside
x0, y0, x1, y1 = span["bbox"]
n = max(1, len(span["text"]))
w = (x1 - x0) / n
return [(pymupdf.Rect(x0 + i * w, y0, x0 + (i + 1) * w, y1), c) for i, c in enumerate(span["text"])]
def _replace_formula_spans(page, textlines: list[dict], hits: list[dict], chars, image_path: str, filename: str
) -> list[dict]:
"""Zeichen innerhalb eines Treffers durch einen Platzhalter ersetzen (einen je Treffer und Seite);
gemischte Textstücke werden dabei geteilt. Eine Formel über mehrere Textzeilen (Summe mit Grenzen)
steht in der Zeile, deren Mitte ihrer Mitte am nächsten liegt."""
split: list[list[tuple[dict, list]]] = [] # je Zeile: (Textstück, [[treffer oder None, zeichen]])
anchor: dict[int, tuple[float, int]] = {} # treffer -> (abstand, zeile)
for n, line in enumerate(textlines):
parts = []
for span in line["spans"]:
if not any(pymupdf.Rect(span["bbox"]).intersects(h["rect"]) for h in hits):
parts.append((span, [[None, None]]))
continue
groups: list[list] = []
for rect, c in _span_chars(span, chars):
k = _hit_for(tuple(rect), hits)
if groups and groups[-1][0] == k:
groups[-1][1].append((rect, c))
else:
groups.append([k, [(rect, c)]])
for k, _ in groups:
if k is not None:
mid = (line["bbox"][1] + line["bbox"][3]) / 2
dist = abs(mid - (hits[k]["rect"].y0 + hits[k]["rect"].y1) / 2)
if k not in anchor or dist < anchor[k][0]:
anchor[k] = (dist, n)
parts.append((span, groups))
split.append(parts)
out = []
for n, (line, parts) in enumerate(zip(textlines, split)):
spans = []
has_formula = any(k is not None for _, groups in parts for k, _ in groups)
for span, groups in parts:
if groups == [[None, None]]:
if has_formula and span["flags"] & pymupdf.TEXT_FONT_SUPERSCRIPT and not FOOTNOTE_MARK.fullmatch(span["text"].strip()):
span = dict(span, flags=span["flags"] & ~pymupdf.TEXT_FONT_SUPERSCRIPT) # Wort neben hoher Formel
spans.append(span)
continue
for k, part in groups:
if k is None:
text = "".join(c for _, c in part)
flags = span["flags"]
if not FOOTNOTE_MARK.fullmatch(text.strip()): # neben hoher Formel nicht hochgestellt (Fußnote: "22")
flags &= ~pymupdf.TEXT_FONT_SUPERSCRIPT
spans.append(dict(span, text=text, flags=flags, bbox=tuple(pymupdf.Rect(part[0][0]) | part[-1][0])))
elif anchor[k][1] == n and k not in _formulas["page_done"]:
_formulas["page_done"].add(k)
spans.append(dict(span, text=_formula_token(page, hits, k, image_path, filename, chars), flags=0,
char_flags=0, font="Formula", bbox=tuple(hits[k]["rect"])))
if any(sp["text"].strip() for sp in spans):
out.append(dict(line, spans=spans))
return out
def _font_hits(textlines: list[dict], hits: list[dict]) -> list[dict]:
"""Formeln in Mathematikschrift, die die Formelerkennung übersehen hat (einzelnes θ, π, "n ≥ m"):
Textstücke in Mathematikschrift samt angrenzender kurzer kursiver Buchstaben und Ziffern."""
found = []
for line in textlines:
spans = line["spans"]
math = [bool(MATH_FONT.search(sp.get("font", ""))) and sp["text"].strip() != ""
and _hit_for(sp["bbox"], hits) is None for sp in spans]
i = 0
while i < len(spans):
if not math[i]:
i += 1
continue
a = b = i
while a > 0 and _short_math_neighbor(spans[a - 1], hits):
a -= 1
while b + 1 < len(spans) and (math[b + 1] or _short_math_neighbor(spans[b + 1], hits)):
b += 1
rect = pymupdf.Rect(spans[a]["bbox"])
for sp in spans[a:b + 1]:
rect |= pymupdf.Rect(sp["bbox"])
found.append({"rect": rect, "type": "embedding", "score": 0.0})
i = b + 1
return found
def _short_math_neighbor(span: dict, hits: list[dict]) -> bool:
"""Kursiver Einzelbuchstabe, Ziffern oder Operator neben Mathematikschrift ("n" in "n ≥ m")."""
text = span["text"].strip()
return (_hit_for(span["bbox"], hits) is None and 0 < len(text) <= 3 and not REAL_WORD.search(text)
and (bool(span["flags"] & pymupdf.TEXT_FONT_ITALIC) or bool(re.fullmatch(r"[\d.,+\-=<>()]+", text))))
def _prepare_formula_page(page: pymupdf.Page, layout, image_path: str, filename: str) -> None:
"""Formeln der Seite finden; Boxen der Klasse "formula" neu einordnen (Kasten mit Text -> Text,
Grafik -> Bild, sonst abgesetzte Formel) und in allen Textboxen die Formeln durch Platzhalter ersetzen."""
hits = detect_formulas(page)
_formulas["page_done"] = set()
chars = _page_chars(page) if hits else []
for box in layout.boxes:
if box.boxclass == "formula":
rect = pymupdf.Rect(box.x0, box.y0, box.x1, box.y1)
lines = [{"bbox": ln[0], "spans": ln[1]}
for ln in document_layout.get_raw_lines(textpage=None, blocks=layout.fulltext, clip=rect,
ignore_invisible=False)]
words = _text_words(lines, hits)
if _figure_paths(page, rect) >= FORMULA_FIGURE_PATHS and words < FORMULA_FIGURE_WORDS:
box.boxclass = "picture"
elif words >= FORMULA_BOX_WORDS:
if isinstance(box.image, str): # pymupdf4llm hat den ganzen Kasten schon als Bild gespeichert
Path(box.image).unlink(missing_ok=True)
box.boxclass, box.image, box.textlines = "text", None, lines
if box.boxclass in TEXT_LIKE_BOXES and box.textlines:
extra = _font_hits(box.textlines, hits)
if extra and not chars:
chars = _page_chars(page)
hits.extend(extra)
if hits:
box.textlines = _replace_formula_spans(page, box.textlines, hits, chars, image_path, filename)
def patch_pymupdf4llm_formulas() -> None:
parse = document_layout.parse_document
if getattr(parse, "pdf2md_formulas", False):
return
def parse_document(doc, *args, **kwargs):
parsed = parse(doc, *args, **kwargs)
if _formulas["on"]:
if not isinstance(doc, pymupdf.Document):
doc = _formulas["docs"].setdefault(str(doc), pymupdf.open(doc))
for layout in parsed.pages:
_prepare_formula_page(doc[layout.page_number - 1], layout, parsed.image_path, parsed.filename)
return parsed
parse_document.pdf2md_formulas = True
document_layout.parse_document = parse_document
FORMULA_TOKEN = re.compile(r"[ \t]*FORMELBILD\d{4}N\d{3}[ \t]*")
def finish_formula_links(markdown: str) -> str:
"""Platzhalter -> Bildverweis; abgesetzte Formeln aus Textblöcken (Merkkästen) in einen eigenen Absatz."""
def link(m: re.Match) -> str:
token = m.group(0).strip()
md_name, isolated = _formulas["tokens"].get(token, (None, False))
if md_name is None:
return m.group(0)
return f"\n\n![]({md_name})\n\n" if isolated else m.group(0).replace(token, f"![]({md_name})")
return FORMULA_TOKEN.sub(link, markdown)
def write_formula_list(image_path: str, first_page: int) -> None:
"""Die Formeln dieses Arbeitsprozesses als <bilder>/formulas-<seite>.json."""
if _formulas["entries"]:
out = Path(image_path) / f"formulas-{first_page + 1:04d}.json"
out.write_text(json.dumps(_formulas["entries"], indent=1), encoding="utf-8")
_formulas["entries"] = {}
class _DiscardMessages(io.StringIO):
"""Ersatz für pymupdf4llms Meldungspuffer ("OCR on page ...")."""
@ -1592,6 +1884,7 @@ pymupdf.Pixmap.save = _save_pixmap
patch_pymupdf4llm_hyphens()
patch_pymupdf4llm_paragraphs()
patch_pymupdf4llm_formulas()
document_layout.INFO_MESSAGES = _DiscardMessages()
@ -1809,6 +2102,7 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> lis
"""Markdown je Seite (in der Reihenfolge von pages)."""
_jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker
_keep_lines[0] = options.get("keep_lines", False)
_formulas["on"] = bool(options.get("formulas"))
kwargs = dict(
pages=pages,
header=options["keep_headers"],
@ -1833,6 +2127,14 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> lis
else:
kwargs.update(use_ocr=False)
if _formulas["on"]:
texts = _convert_pages(source, run, pages, options, kwargs)
write_formula_list(options["image_path"], pages[0])
return [finish_formula_links(t) for t in texts]
return _convert_pages(source, run, pages, options, kwargs)
def _convert_pages(source: str, run: str, pages: list[int], options: dict, kwargs: dict) -> list[str]:
# pymupdf4llm schreibt Statusmeldungen auf stdout; wir melden selbst.
with contextlib.redirect_stdout(io.StringIO()):
if run != RUN_OCR:
@ -2662,6 +2964,12 @@ def main() -> int:
action="store_true",
help="Grafiken speichern und in Markdown verlinken",
)
parser.add_argument(
"--formulas",
action="store_true",
help="Formeln seitenweise erkennen (pix2text), auch im Fließtext und in Kästen, und als "
"Bilder ausschneiden - für formula_img2latex (schaltet --images ein)",
)
parser.add_argument(
"--image-format",
choices=["jpg", "png"],
@ -2780,8 +3088,8 @@ def main() -> int:
epub_path = None
if args.epub or args.epub_file:
epub_path = (args.epub_file or target.with_suffix(".epub")).expanduser()
if epub_path or args.epub_per_file:
args.images = True # EPUB ohne Bilder wäre unvollständig
if epub_path or args.epub_per_file or args.formulas:
args.images = True # EPUB ohne Bilder wäre unvollständig; Formeln sind Bilder
# --split: Ordner "<name>/" mit Einzeldateien und "images/" statt einer Datei
folder = target.with_suffix("") if args.split else None
@ -2842,6 +3150,7 @@ def main() -> int:
jpeg_quality=args.jpeg_quality,
keep_lines=args.keep_lines,
titles=titles,
formulas=args.formulas,
)
markdown, page_texts = extract_markdown(source, plan, options, args.jobs)