diff --git a/pdf2md.py b/pdf2md.py index cc7cdc1..239a8a4 100644 --- a/pdf2md.py +++ b/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 /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 /formulas-.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 "/" 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)