- pdf2md_v5.py → pdf2md.py; Verweise in Doku und Tests angepasst - pdf2md_v1.py … pdf2md_v4.py nach History/ verschoben - ljutsch_meta.xml entfernt (nicht mehr verwendet) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
6.8 KiB
Python
249 lines
6.8 KiB
Python
#!/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())
|
||
|