#!/usr/bin/env python3 """PDF nach Markdown konvertieren, Absätze gliedern und optional Bilder speichern.""" 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+\S") # Satzzeichen, optional gefolgt von schließenden Anführungszeichen/Klammern. SENTENCE_END = re.compile(r"""[.!?…]+[”’"'»›)\]]*$""") FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})") LIST_ITEM = re.compile(r"^\s{0,3}(?:[-+*]|\d+[.)])\s+") BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?") RULE = re.compile(r"^\s{0,3}(?:-\s*){3,}$|^\s{0,3}(?:\*\s*){3,}$") IMAGE = re.compile(r"^\s*!\[.*?\]\(.*\)\s*$") # Beispiel: **==> picture [426 x 480] intentionally omitted <==** OMITTED_IMAGE = re.compile( r"^\s*(?:\*\*|__)?\s*==>\s*" r"(?:picture|image|bild)\b.*?" r"(?:intentionally omitted|omitted|ausgelassen)\s*" r"<==\s*(?:\*\*|__)?\s*$", re.IGNORECASE, ) CONTENTS_TITLE = re.compile( r"^\s*(?:#{1,6}\s+)?" r"(?:inhaltsverzeichnis|inhalt|contents|table of contents)" r"\s*$", re.IGNORECASE, ) TOC_WITH_PAGE = re.compile( r"^\s*(?:[-*]\s+)?\S.+?" r"(?:\.{2,}|\s{2,})" r"(?:\d+|[ivxlcdm]+)\s*$", re.IGNORECASE, ) TOC_NUMBERED = re.compile( r"^\s*(?:[-*]\s+)?" r"(?:\d+(?:\.\d+)*\.?\s+|Kapitel\s+\d+\b|Chapter\s+\d+\b)" r".+", re.IGNORECASE, ) HTML_BLOCK = re.compile( r"^\s* bool: return bool(SENTENCE_END.search(text.rstrip())) def is_other_structure(line: str) -> bool: """Markdown-Strukturen, die nicht zu Fließtext verbunden werden.""" 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 in_contents = False def blank_line() -> None: if result and result[-1] != "": result.append("") def flush_paragraph() -> None: if paragraph: result.append(" ".join(paragraph)) paragraph.clear() def standalone(line: str) -> None: flush_paragraph() blank_line() result.append(line) result.append("") lines = markdown.replace("\r\n", "\n").replace("\r", "\n").split("\n") for original in lines: 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() blank_line() result.append(line) in_fence = True fence_char = marker.group(1)[0] fence_length = len(marker.group(1)) continue if not line.strip(): if paragraph and ends_sentence(paragraph[-1]): flush_paragraph() blank_line() elif not paragraph: blank_line() continue if CONTENTS_TITLE.match(line): standalone(line) in_contents = True continue if HEADING.match(line): standalone(line) in_contents = False continue if OMITTED_IMAGE.match(line): standalone(line) continue if TOC_WITH_PAGE.match(line): standalone(line) continue if in_contents and TOC_NUMBERED.match(line): standalone(line) continue if in_contents and ends_sentence(line): in_contents = False if is_other_structure(line): flush_paragraph() result.append(line) continue paragraph.append(line.strip()) flush_paragraph() return "\n".join(result).strip() + "\n" def make_image_links_relative(markdown: str, image_dir: Path) -> str: """Absolute Bildpfade durch Pfade relativ zur Markdown-Datei ersetzen.""" absolute_dir = image_dir.resolve().as_posix() relative_dir = image_dir.name return markdown.replace( absolute_dir + "/", relative_dir + "/", ) def main() -> int: parser = argparse.ArgumentParser( description=( "PDF in Markdown umwandeln; Fließtext nach Satzenden gliedern " "und optional Grafiken speichern." ) ) 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( "--images", action="store_true", help="Grafiken speichern und in Markdown verlinken", ) 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.") image_dir = target.parent / f"{target.stem}_images" try: target.parent.mkdir(parents=True, exist_ok=True) if args.images: image_dir.mkdir(parents=True, exist_ok=True) 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 ), write_images=args.images, image_path=str(image_dir.resolve()) if args.images else "", image_format="png", ) if args.images: markdown = make_image_links_relative(markdown, image_dir) 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}") if args.images: print(f"Bilder gespeichert in: {image_dir}") return 0 if __name__ == "__main__": raise SystemExit(main())