#!/usr/bin/env python3 """PDF nach Markdown konvertieren und Fließtext nach Satzenden gliedern.""" 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") # Punkt, Fragezeichen, Ausrufezeichen oder Auslassungspunkte; # danach dürfen schließende Anführungszeichen/Klammern folgen. 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, ) # Inhaltsverzeichnis mit Füllpunkten oder Abstand vor einer Seitenzahl: # Kapitel 1 .......... 12 # Kapitel 1 12 # Kapitel 1 xiv TOC_WITH_PAGE = re.compile( r"^\s*(?:[-*]\s+)?\S.+?" r"(?:\.{2,}|\s{2,})" r"(?:\d+|[ivxlcdm]+)\s*$", re.IGNORECASE, ) # Einträge ohne Seitenzahl, sofern bereits ein Inhaltsverzeichnis erkannt wurde: # 1 Einleitung # 1.2 Vorgeschichte # Kapitel 3 – Die Reise 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(): # Leerzeile nur dann als Absatzgrenze behandeln, # wenn der bisherige Fließtext mit einem Satzende endet. 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): # Erster normaler Satz nach einem erkannten Inhaltsverzeichnis. in_contents = False if is_other_structure(line): flush_paragraph() result.append(line) continue # Einfache Zeilenumbrüche und nicht bestätigte Absatzgrenzen # werden beim späteren Flush durch Leerzeichen ersetzt. paragraph.append(line.strip()) flush_paragraph() return "\n".join(result).strip() + "\n" def main() -> int: parser = argparse.ArgumentParser( description="PDF in Markdown umwandeln; Fließtext 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())