Initial commit: pdf2md v1–v4 und Recherche

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-01 23:46:54 +02:00
commit f2965c5225
9 changed files with 2370 additions and 0 deletions

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# Große Binärdateien bleiben lokal
*.pdf
*.epub
*.torrent
*.sqlite
*.jpg
ljutsch_files.xml
Fertig/
*_images/
# Python
__pycache__/
.pytest_cache/
# Leere Datei durch System-Bug
/3

8
README.md Normal file
View file

@ -0,0 +1,8 @@
# ljutsch – PDF → Markdown → EPUB
Werkzeug, um PDF-Bücher (hier: *Operation Ljutsch*, Band 1 und 2) in sauberes Markdown
und anschließend in ein EPUB für den PocketBook-Reader umzuwandeln – mit Überschriften,
Bildern und echten Absätzen.
- `pdf2md_v1.py` … `pdf2md_v4.py` – bisherige Entwicklungsstände (siehe `Info/`)
- `Info/` – Recherche und Entstehungsgeschichte der Skripte

27
ljutsch_meta.xml Normal file
View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<identifier>ljutsch</identifier>
<mediatype>texts</mediatype>
<collection>opensource</collection>
<collection>community</collection>
<description>Reinhard Otto Kranz&lt;br /&gt;</description>
<scanner>Internet Archive HTML5 Uploader 1.6.4</scanner>
<subject>ljutsch</subject>
<title>Ljutsch</title>
<uploader>schamhaar.friseur@gmail.com</uploader>
<publicdate>2022-05-30 15:09:50</publicdate>
<addeddate>2022-05-30 15:09:50</addeddate>
<identifier-access>http://archive.org/details/ljutsch</identifier-access>
<identifier-ark>ark:/13960/s2jf47qc96n</identifier-ark>
<ppi>300</ppi>
<curation>[curator]validator@archive.org[/curator][date]20220530154119[/date][comment]checked for malware[/comment]</curation>
<ocr>tesseract 5.0.0-1-g862e</ocr>
<ocr_parameters>-l deu+Latin</ocr_parameters>
<ocr_module_version>0.0.15</ocr_module_version>
<ocr_autonomous>true</ocr_autonomous>
<ocr_detected_script>Latin</ocr_detected_script>
<ocr_detected_script_conf>1.0000</ocr_detected_script_conf>
<ocr_detected_lang>de</ocr_detected_lang>
<ocr_detected_lang_conf>1.0000</ocr_detected_lang_conf>
<language>deu</language>
</metadata>

146
pdf2md_v1.py Normal file
View file

@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""PDF nach Markdown konvertieren und weiche Zeilenumbrüche entfernen."""
import argparse
import re
import sys
from pathlib import Path
import pymupdf4llm
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*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b", re.I)
def is_structural(line: str) -> bool:
"""Zeilen, die nicht mit dem vorherigen Fließtext verbunden werden."""
return bool(
HEADING.match(line)
or 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 = []
paragraph = []
in_fence = False
fence_char = ""
fence_length = 0
def flush_paragraph():
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()
result.append(line)
in_fence = True
fence_char = marker.group(1)[0]
fence_length = len(marker.group(1))
continue
if not line.strip():
flush_paragraph()
if result and result[-1] != "":
result.append("")
continue
if is_structural(line):
flush_paragraph()
result.append(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; Zeilen innerhalb von Absätzen verbinden."
)
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 erforderlicher OCR (Standard: deu)",
)
parser.add_argument(
"--no-ocr",
action="store_true",
help="OCR abschalten, wenn die PDF sicher durchsuchbaren Text enthält",
)
parser.add_argument(
"--keep-headers",
action="store_true",
help="Wiederkehrende Seitenköpfe in die Ausgabe übernehmen",
)
parser.add_argument(
"--keep-footers",
action="store_true",
help="Wiederkehrende Seitenfüße in die Ausgabe ü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,
)
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())

181
pdf2md_v2.py Normal file
View file

@ -0,0 +1,181 @@
#!/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*</?(?:table|tr|td|th|div|pre|p|h[1-6])\b",
re.I,
)
# Satzzeichen, optional gefolgt von schließenden Anführungszeichen/Klammern.
SENTENCE_END = re.compile(r"""[.!?…]+[”’"'»›)\]]*$""")
def ends_sentence(text: str) -> 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())

249
pdf2md_v3.py Normal file
View file

@ -0,0 +1,249 @@
#!/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())

269
pdf2md_v4.py Normal file
View file

@ -0,0 +1,269 @@
#!/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*</?(?: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():
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())

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
pymupdf4llm>=1.27.2
pymupdf-layout>=1.27.2
# Entwicklung / Tests
pytest>=8