pdf2md.py als fester Name, v1–v4 nach History/ verschoben

- 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>
This commit is contained in:
Dieter Schlüter 2026-10-02 13:12:36 +02:00
commit b4b911d37f
13 changed files with 22 additions and 49 deletions

View file

@ -1,6 +1,6 @@
# Bedienungsanleitung: pdf2md_v5.py – PDF → Markdown → EPUB # Bedienungsanleitung: pdf2md.py – PDF → Markdown → EPUB
`pdf2md_v5.py` wandelt PDF-Bücher in sauberes Markdown um: echte Absätze, Überschriften mit `pdf2md.py` wandelt PDF-Bücher in sauberes Markdown um: echte Absätze, Überschriften mit
richtiger Ebene, Bilder und ohne Seitenzahlen. Daraus entsteht auf Wunsch direkt ein EPUB für richtiger Ebene, Bilder und ohne Seitenzahlen. Daraus entsteht auf Wunsch direkt ein EPUB für
den PocketBook-Reader. Das funktioniert mit digitalen PDFs (Text lässt sich markieren) ebenso wie den PocketBook-Reader. Das funktioniert mit digitalen PDFs (Text lässt sich markieren) ebenso wie
mit gescannten PDFs (nur Seitenbilder, der Text wird per OCR erkannt). mit gescannten PDFs (nur Seitenbilder, der Text wird per OCR erkannt).
@ -31,7 +31,7 @@ Prüfen, ob alles da ist:
```bash ```bash
tesseract --list-langs # muss "deu" enthalten tesseract --list-langs # muss "deu" enthalten
pandoc --version # Version 3.x pandoc --version # Version 3.x
python3 pdf2md_v5.py --help # zeigt alle Optionen python3 pdf2md.py --help # zeigt alle Optionen
``` ```
--- ---
@ -40,7 +40,7 @@ python3 pdf2md_v5.py --help # zeigt alle Optionen
```bash ```bash
cd ~/Downloads/ljutsch cd ~/Downloads/ljutsch
python3 pdf2md_v5.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md python3 pdf2md.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md
``` ```
Das Programm meldet zuerst, was es in der PDF gefunden hat: Das Programm meldet zuerst, was es in der PDF gefunden hat:
@ -80,7 +80,7 @@ EPUB zum Vergleichen:
```bash ```bash
cd ~/Downloads/ljutsch cd ~/Downloads/ljutsch
python3 pdf2md_v5.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md python3 pdf2md.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md
``` ```
Wenn du kein Cover und kein Probe-EPUB brauchst, reicht `--images` statt `--epub`. Wenn du kein Cover und kein Probe-EPUB brauchst, reicht `--images` statt `--epub`.
@ -144,7 +144,7 @@ pandoc band1.md \
--epub-cover-image band1_images/cover.jpg --epub-cover-image band1_images/cover.jpg
``` ```
Das sind dieselben Einstellungen, die `pdf2md_v5.py --epub` verwendet. Titel und Autor trägst du Das sind dieselben Einstellungen, die `pdf2md.py --epub` verwendet. Titel und Autor trägst du
selbst ein. Ohne Cover lässt du die letzte Zeile weg und entfernst den `\` am Ende der Zeile davor. selbst ein. Ohne Cover lässt du die letzte Zeile weg und entfernst den `\` am Ende der Zeile davor.
Schritt 2 und 3 kannst du beliebig oft wiederholen. **Achtung:** Schritt 1 erneut auszuführen Schritt 2 und 3 kannst du beliebig oft wiederholen. **Achtung:** Schritt 1 erneut auszuführen
@ -158,7 +158,7 @@ Dateinamen.
Bei Scans liest das Programm den Text per OCR (Tesseract). Der Aufruf ist derselbe: Bei Scans liest das Programm den Text per OCR (Tesseract). Der Aufruf ist derselbe:
```bash ```bash
python3 pdf2md_v5.py --epub scan.pdf Output/scan.md python3 pdf2md.py --epub scan.pdf Output/scan.md
``` ```
| Situation | Option | | Situation | Option |

View file

@ -1,19 +1,19 @@
# CLAUDE.md # CLAUDE.md
PDF → Markdown → EPUB für den PocketBook-Reader (digitale und gescannte PDFs). PDF → Markdown → EPUB für den PocketBook-Reader (digitale und gescannte PDFs).
Aktuelles Programm: `pdf2md_v5.py` (Einzeldatei). v1–v4 sind Historie, nicht ändern. Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnummer im Dateinamen). v1–v4 liegen in `History/` (Historie, nicht ändern).
## Befehle ## Befehle
- Tests: `python3 -m pytest` (Fixtures werden synthetisch erzeugt; OCR braucht `tesseract-ocr-deu`) - Tests: `python3 -m pytest` (Fixtures werden synthetisch erzeugt; OCR braucht `tesseract-ocr-deu`)
- Lint: `python3 -m ruff check pdf2md_v5.py tests/` - Lint: `python3 -m ruff check pdf2md.py tests/`
- Echttest: `python3 pdf2md_v5.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf <scratch>/band1.md` - Echttest: `python3 pdf2md.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf <scratch>/band1.md`
## Konventionen ## Konventionen
- Hilfetexte, Meldungen, Kommentare und Docstrings auf Deutsch (wie v4); Bezeichner Englisch. - Hilfetexte, Meldungen, Kommentare und Docstrings auf Deutsch (wie v4); Bezeichner Englisch.
- Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers). - Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers).
- PDFs/EPUBs/Bilder sind per `.gitignore` ausgeschlossen – keine Binärdateien committen. - PDFs/EPUBs/Bilder sind per `.gitignore` ausgeschlossen – keine Binärdateien committen.
- `pdf2md_v5.py` patcht zwei Funktionen in `pymupdf4llm.helpers.document_layout` - `pdf2md.py` patcht zwei Funktionen in `pymupdf4llm.helpers.document_layout`
(`get_styled_text`, `text_to_md`). Nach einem pymupdf4llm-Update prüfen, ob (`get_styled_text`, `text_to_md`). Nach einem pymupdf4llm-Update prüfen, ob
`tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` noch grün ist. `tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` noch grün ist.

146
History/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
History/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
History/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
History/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())

View file

@ -22,13 +22,13 @@ dann EPUB“: **[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)**.
```bash ```bash
# digitales oder gescanntes PDF -> Markdown (OCR wird automatisch entschieden) # digitales oder gescanntes PDF -> Markdown (OCR wird automatisch entschieden)
python3 pdf2md_v5.py buch.pdf buch.md python3 pdf2md.py buch.pdf buch.md
# zusätzlich Bilder und EPUB (Titel/Autor aus den PDF-Metadaten, Seite 1 als Cover) # zusätzlich Bilder und EPUB (Titel/Autor aus den PDF-Metadaten, Seite 1 als Cover)
python3 pdf2md_v5.py --epub buch.pdf buch.md python3 pdf2md.py --epub buch.pdf buch.md
# Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse # Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse
python3 pdf2md_v5.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md python3 pdf2md.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md
``` ```
| Option | Bedeutung | | Option | Bedeutung |
@ -67,7 +67,7 @@ synthetisch erzeugt.
## Grenzen ## Grenzen
- Die Ergebnisqualität hängt von pymupdf4llm/pymupdf-layout ab. Zwei Schwächen der - Die Ergebnisqualität hängt von pymupdf4llm/pymupdf-layout ab. Zwei Schwächen der
Bibliothek gleicht `pdf2md_v5.py` per Patch aus (Bindestriche am Zeilenende, Absätze in Bibliothek gleicht `pdf2md.py` per Patch aus (Bindestriche am Zeilenende, Absätze in
Scan-Blöcken). Eine weitere ist bekannt: Bei Schriften mit Ligaturen (ff, fi) mit Scan-Blöcken). Eine weitere ist bekannt: Bei Schriften mit Ligaturen (ff, fi) mit
übergroßer Glyphenbox kann pymupdf4llm einzelne Zeilen verlieren. Bei den Ljutsch-Bänden übergroßer Glyphenbox kann pymupdf4llm einzelne Zeilen verlieren. Bei den Ljutsch-Bänden
tritt das nicht auf (geprüft per Wortabgleich PDF ↔ Markdown). tritt das nicht auf (geprüft per Wortabgleich PDF ↔ Markdown).
@ -76,7 +76,7 @@ synthetisch erzeugt.
## Dateien ## Dateien
- `pdf2md_v5.py` – aktuelle Version - `pdf2md.py` – aktuelle Version
- `pdf2md_v1.py` … `pdf2md_v4.py` – frühere Entwicklungsstände (siehe `Info/`) - `History/pdf2md_v1.py` … `History/pdf2md_v4.py` – frühere Entwicklungsstände (siehe `Info/`)
- `Info/` – Recherche und Entstehungsgeschichte der Skripte - `Info/` – Recherche und Entstehungsgeschichte der Skripte
- `tests/` – pytest-Suite - `tests/` – pytest-Suite

View file

@ -38,7 +38,7 @@ speichern. Damit lassen sich:
- EPUB aus den Einzeldateien: pandoc nimmt mehrere Eingabedateien in der Reihenfolge von - EPUB aus den Einzeldateien: pandoc nimmt mehrere Eingabedateien in der Reihenfolge von
`index.md`; Umsortieren = Reihenfolge im Index ändern. `index.md`; Umsortieren = Reihenfolge im Index ändern.
**Ansatzpunkte im Code (`pdf2md_v5.py`):** **Ansatzpunkte im Code (`pdf2md.py`):**
- Schnitt **nach** `normalize_markdown()` an den Überschriften `##` (Teil) und `###` (Kapitel); - Schnitt **nach** `normalize_markdown()` an den Überschriften `##` (Teil) und `###` (Kapitel);
die Ebenen kommen aus `load_outline()`. die Ebenen kommen aus `load_outline()`.

View file

@ -1,27 +0,0 @@
<?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>

1127
pdf2md.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
import pymupdf import pymupdf
import pytest import pytest
import pdf2md_v5 as p import pdf2md as p
from conftest import needs_tesseract from conftest import needs_tesseract

View file

@ -10,7 +10,7 @@ import pytest
from conftest import ROOT, TITLE, needs_pandoc, needs_tesseract from conftest import ROOT, TITLE, needs_pandoc, needs_tesseract
SCRIPT = ROOT / "pdf2md_v5.py" SCRIPT = ROOT / "pdf2md.py"
def run(*args, cwd=None) -> subprocess.CompletedProcess: def run(*args, cwd=None) -> subprocess.CompletedProcess:
@ -132,7 +132,7 @@ def test_png_on_request(pdfs, tmp_path):
def test_image_links_relative_to_markdown_with_relative_output(pdfs, tmp_path): def test_image_links_relative_to_markdown_with_relative_output(pdfs, tmp_path):
# Aufruf wie "python3 pdf2md_v5.py --images buch.pdf Output/buch.md" aus dem Projektordner # Aufruf wie "python3 pdf2md.py --images buch.pdf Output/buch.md" aus dem Projektordner
run(pdfs["digital"], "Output/buch.md", "--images", cwd=tmp_path) run(pdfs["digital"], "Output/buch.md", "--images", cwd=tmp_path)
md = (tmp_path / "Output" / "buch.md").read_text(encoding="utf-8") md = (tmp_path / "Output" / "buch.md").read_text(encoding="utf-8")
links = re.findall(r"!\[\]\(([^)]+)\)", md) links = re.findall(r"!\[\]\(([^)]+)\)", md)

View file

@ -5,7 +5,7 @@ from pathlib import Path
import pymupdf import pymupdf
import pytest import pytest
import pdf2md_v5 as p import pdf2md as p
def norm(md: str, outline=None, **kwargs) -> str: def norm(md: str, outline=None, **kwargs) -> str: