--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:
parent
5710e3c5b5
commit
3ac32f4d34
1 changed files with 311 additions and 2 deletions
313
pdf2md.py
313
pdf2md.py
|
|
@ -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\n\n" if isolated else m.group(0).replace(token, f"")
|
||||
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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue