#!/usr/bin/env python3 """PDF nach Markdown konvertieren und Absätze anhand von Satzenden verbinden.""" import argparse import re import sys from pathlib import Path import pymupdf4llm from pymupdf4llm.ocr import tesseract_api HEADING = re.compile(r"^\s{0,3}#{1,6}\s+") LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+") BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?") FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})") RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$") IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$") HTML_BLOCK = re.compile( r"^\s* bool: return bool(SENTENCE_END.search(text.rstrip())) def is_structural(line: str) -> bool: """Markdown-Zeilen, die nicht in Fließtext aufgehen sollen.""" return bool( LIST_ITEM.match(line) or BLOCKQUOTE.match(line) or RULE.match(line) or IMAGE.match(line) or HTML_BLOCK.match(line) or line.lstrip().startswith("|") or line.startswith((" ", "\t")) ) def normalize_markdown(markdown: str) -> str: result: list[str] = [] paragraph: list[str] = [] in_fence = False fence_char = "" fence_length = 0 def add_blank_line() -> None: if result and result[-1] != "": result.append("") def flush_paragraph() -> None: if paragraph: result.append(" ".join(paragraph)) paragraph.clear() for original in markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n"): line = original.rstrip() marker = FENCE.match(line) if in_fence: result.append(line) if marker: token = marker.group(1) if token[0] == fence_char and len(token) >= fence_length: in_fence = False continue if marker: flush_paragraph() add_blank_line() result.append(line) in_fence = True fence_char = marker.group(1)[0] fence_length = len(marker.group(1)) continue if HEADING.match(line): flush_paragraph() add_blank_line() result.append(line) result.append("") continue if is_structural(line): flush_paragraph() result.append(line) continue if not line.strip(): # Eine Leerzeile ist nur nach einem Satzende eine Absatzgrenze. # Andernfalls bleibt paragraph bestehen und die nächste # Textzeile wird mit einem Leerzeichen angehängt. if paragraph and ends_sentence(paragraph[-1]): flush_paragraph() add_blank_line() elif not paragraph: add_blank_line() continue paragraph.append(line.strip()) flush_paragraph() return "\n".join(result).strip() + "\n" def main() -> int: parser = argparse.ArgumentParser( description=( "PDF in Markdown umwandeln; Absätze nur nach Satzende trennen." ) ) parser.add_argument("pdf", type=Path, help="Eingabe-PDF") parser.add_argument( "markdown", type=Path, nargs="?", help="Ausgabe-Datei; Standard: gleicher Name mit .md", ) parser.add_argument( "--ocr-language", default="deu", help="Tesseract-Sprachcode bei OCR (Standard: deu)", ) parser.add_argument( "--no-ocr", action="store_true", help="OCR deaktivieren", ) parser.add_argument( "--keep-headers", action="store_true", help="Wiederkehrende Seitenköpfe übernehmen", ) parser.add_argument( "--keep-footers", action="store_true", help="Wiederkehrende Seitenfüße übernehmen", ) args = parser.parse_args() source = args.pdf.expanduser() target = (args.markdown or source.with_suffix(".md")).expanduser() if not source.is_file(): parser.error(f"Eingabedatei nicht gefunden: {source}") if source.resolve() == target.resolve(): parser.error("Eingabe- und Ausgabedatei müssen verschieden sein.") try: markdown = pymupdf4llm.to_markdown( str(source), header=args.keep_headers, footer=args.keep_footers, use_ocr=not args.no_ocr, ocr_language=args.ocr_language, ocr_function=( tesseract_api.exec_ocr if not args.no_ocr else None ), ) target.parent.mkdir(parents=True, exist_ok=True) target.write_text(normalize_markdown(markdown), encoding="utf-8") except Exception as exc: print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr) return 1 print(f"Markdown geschrieben: {target}") return 0 if __name__ == "__main__": raise SystemExit(main())