pdf2md/pdf2md_v3.py
Dieter Schlüter f2965c5225 Initial commit: pdf2md v1–v4 und Recherche
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:46:54 +02:00

249 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b",
re.IGNORECASE,
)
def ends_sentence(text: str) -> 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())