From b4b911d37fc4bb3078a80dfedf7d95a2ab219878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 13:12:36 +0200 Subject: [PATCH 01/10] =?UTF-8?q?pdf2md.py=20als=20fester=20Name,=20v1?= =?UTF-8?q?=E2=80=93v4=20nach=20History/=20verschoben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- BEDIENUNGSANLEITUNG.md | 14 +++++++------- CLAUDE.md | 8 ++++---- pdf2md_v1.py => History/pdf2md_v1.py | 0 pdf2md_v2.py => History/pdf2md_v2.py | 0 pdf2md_v3.py => History/pdf2md_v3.py | 0 pdf2md_v4.py => History/pdf2md_v4.py | 0 README.md | 12 ++++++------ TODO.md | 2 +- ljutsch_meta.xml | 27 --------------------------- pdf2md_v5.py => pdf2md.py | 0 tests/test_classify.py | 2 +- tests/test_e2e.py | 4 ++-- tests/test_normalize.py | 2 +- 13 files changed, 22 insertions(+), 49 deletions(-) rename pdf2md_v1.py => History/pdf2md_v1.py (100%) rename pdf2md_v2.py => History/pdf2md_v2.py (100%) rename pdf2md_v3.py => History/pdf2md_v3.py (100%) rename pdf2md_v4.py => History/pdf2md_v4.py (100%) delete mode 100644 ljutsch_meta.xml rename pdf2md_v5.py => pdf2md.py (100%) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index dbea778..f87b9a7 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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 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). @@ -31,7 +31,7 @@ Prüfen, ob alles da ist: ```bash tesseract --list-langs # muss "deu" enthalten 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 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: @@ -80,7 +80,7 @@ EPUB zum Vergleichen: ```bash 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`. @@ -144,7 +144,7 @@ pandoc band1.md \ --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. 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: ```bash -python3 pdf2md_v5.py --epub scan.pdf Output/scan.md +python3 pdf2md.py --epub scan.pdf Output/scan.md ``` | Situation | Option | diff --git a/CLAUDE.md b/CLAUDE.md index 6e8a555..353b813 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,19 +1,19 @@ # CLAUDE.md 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 - Tests: `python3 -m pytest` (Fixtures werden synthetisch erzeugt; OCR braucht `tesseract-ocr-deu`) -- Lint: `python3 -m ruff check pdf2md_v5.py tests/` -- Echttest: `python3 pdf2md_v5.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf /band1.md` +- Lint: `python3 -m ruff check pdf2md.py tests/` +- Echttest: `python3 pdf2md.py --epub Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf /band1.md` ## Konventionen - Hilfetexte, Meldungen, Kommentare und Docstrings auf Deutsch (wie v4); Bezeichner Englisch. - Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers). - 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 `tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` noch grün ist. diff --git a/pdf2md_v1.py b/History/pdf2md_v1.py similarity index 100% rename from pdf2md_v1.py rename to History/pdf2md_v1.py diff --git a/pdf2md_v2.py b/History/pdf2md_v2.py similarity index 100% rename from pdf2md_v2.py rename to History/pdf2md_v2.py diff --git a/pdf2md_v3.py b/History/pdf2md_v3.py similarity index 100% rename from pdf2md_v3.py rename to History/pdf2md_v3.py diff --git a/pdf2md_v4.py b/History/pdf2md_v4.py similarity index 100% rename from pdf2md_v4.py rename to History/pdf2md_v4.py diff --git a/README.md b/README.md index 115aa02..6cfa4c9 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,13 @@ dann EPUB“: **[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)**. ```bash # 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) -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 -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 | @@ -67,7 +67,7 @@ synthetisch erzeugt. ## Grenzen - 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 übergroßer Glyphenbox kann pymupdf4llm einzelne Zeilen verlieren. Bei den Ljutsch-Bänden tritt das nicht auf (geprüft per Wortabgleich PDF ↔ Markdown). @@ -76,7 +76,7 @@ synthetisch erzeugt. ## Dateien -- `pdf2md_v5.py` – aktuelle Version -- `pdf2md_v1.py` … `pdf2md_v4.py` – frühere Entwicklungsstände (siehe `Info/`) +- `pdf2md.py` – aktuelle Version +- `History/pdf2md_v1.py` … `History/pdf2md_v4.py` – frühere Entwicklungsstände (siehe `Info/`) - `Info/` – Recherche und Entstehungsgeschichte der Skripte - `tests/` – pytest-Suite diff --git a/TODO.md b/TODO.md index f3c396a..23588e6 100644 --- a/TODO.md +++ b/TODO.md @@ -38,7 +38,7 @@ speichern. Damit lassen sich: - EPUB aus den Einzeldateien: pandoc nimmt mehrere Eingabedateien in der Reihenfolge von `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); die Ebenen kommen aus `load_outline()`. diff --git a/ljutsch_meta.xml b/ljutsch_meta.xml deleted file mode 100644 index 670cce1..0000000 --- a/ljutsch_meta.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - ljutsch - texts - opensource - community - Reinhard Otto Kranz<br /> - Internet Archive HTML5 Uploader 1.6.4 - ljutsch - Ljutsch - schamhaar.friseur@gmail.com - 2022-05-30 15:09:50 - 2022-05-30 15:09:50 - http://archive.org/details/ljutsch - ark:/13960/s2jf47qc96n - 300 - [curator]validator@archive.org[/curator][date]20220530154119[/date][comment]checked for malware[/comment] - tesseract 5.0.0-1-g862e - -l deu+Latin - 0.0.15 - true - Latin - 1.0000 - de - 1.0000 - deu - diff --git a/pdf2md_v5.py b/pdf2md.py similarity index 100% rename from pdf2md_v5.py rename to pdf2md.py diff --git a/tests/test_classify.py b/tests/test_classify.py index adc8dbd..b6dbe5c 100644 --- a/tests/test_classify.py +++ b/tests/test_classify.py @@ -3,7 +3,7 @@ import pymupdf import pytest -import pdf2md_v5 as p +import pdf2md as p from conftest import needs_tesseract diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 9cbed2b..76ac589 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -10,7 +10,7 @@ import pytest 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: @@ -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): - # 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) md = (tmp_path / "Output" / "buch.md").read_text(encoding="utf-8") links = re.findall(r"!\[\]\(([^)]+)\)", md) diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 001e9a2..0098d45 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -5,7 +5,7 @@ from pathlib import Path import pymupdf import pytest -import pdf2md_v5 as p +import pdf2md as p def norm(md: str, outline=None, **kwargs) -> str: From a21d900e6b097f5cba9703271edad9c55ce4994c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 13:21:10 +0200 Subject: [PATCH 02/10] =?UTF-8?q?Aufr=C3=A4umen:=20alte=20Skriptst=C3=A4nd?= =?UTF-8?q?e=20und=20Info/=20entfernt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - History/pdf2md_v1.py … v4.py entfernt (bleiben in der Git-Historie) - Info/ entfernt - README.md und CLAUDE.md angepasst Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- History/pdf2md_v1.py | 146 -- History/pdf2md_v2.py | 181 -- History/pdf2md_v3.py | 249 --- History/pdf2md_v4.py | 269 --- ...lder_und_die_Ueberschriften_uebernehmen.md | 1470 ----------------- README.md | 2 - 7 files changed, 1 insertion(+), 2318 deletions(-) delete mode 100644 History/pdf2md_v1.py delete mode 100644 History/pdf2md_v2.py delete mode 100644 History/pdf2md_v3.py delete mode 100644 History/pdf2md_v4.py delete mode 100644 Info/Mit_welchem_Programmen_kann_ich_eine_PDF-Datei_am_elegantesten_in_eine_moderne_EPUB-Datei_fuer_meinen_Pocketbook-Reader_umwandeln_und_die_Bilder_und_die_Ueberschriften_uebernehmen.md diff --git a/CLAUDE.md b/CLAUDE.md index 353b813..53f8adc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md PDF → Markdown → EPUB für den PocketBook-Reader (digitale und gescannte PDFs). -Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnummer im Dateinamen). v1–v4 liegen in `History/` (Historie, nicht ändern). +Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnummer im Dateinamen). Frühere Stände (v1–v4) nur noch in der Git-Historie. ## Befehle diff --git a/History/pdf2md_v1.py b/History/pdf2md_v1.py deleted file mode 100644 index b462733..0000000 --- a/History/pdf2md_v1.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/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* 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()) - diff --git a/History/pdf2md_v2.py b/History/pdf2md_v2.py deleted file mode 100644 index ca5d53f..0000000 --- a/History/pdf2md_v2.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/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* 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()) - diff --git a/History/pdf2md_v3.py b/History/pdf2md_v3.py deleted file mode 100644 index 1bf2bd3..0000000 --- a/History/pdf2md_v3.py +++ /dev/null @@ -1,249 +0,0 @@ -#!/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()) - diff --git a/History/pdf2md_v4.py b/History/pdf2md_v4.py deleted file mode 100644 index 7a921ad..0000000 --- a/History/pdf2md_v4.py +++ /dev/null @@ -1,269 +0,0 @@ -#!/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* 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()) - diff --git a/Info/Mit_welchem_Programmen_kann_ich_eine_PDF-Datei_am_elegantesten_in_eine_moderne_EPUB-Datei_fuer_meinen_Pocketbook-Reader_umwandeln_und_die_Bilder_und_die_Ueberschriften_uebernehmen.md b/Info/Mit_welchem_Programmen_kann_ich_eine_PDF-Datei_am_elegantesten_in_eine_moderne_EPUB-Datei_fuer_meinen_Pocketbook-Reader_umwandeln_und_die_Bilder_und_die_Ueberschriften_uebernehmen.md deleted file mode 100644 index 94c7c65..0000000 --- a/Info/Mit_welchem_Programmen_kann_ich_eine_PDF-Datei_am_elegantesten_in_eine_moderne_EPUB-Datei_fuer_meinen_Pocketbook-Reader_umwandeln_und_die_Bilder_und_die_Ueberschriften_uebernehmen.md +++ /dev/null @@ -1,1470 +0,0 @@ - - -# Ubuntu 24.04: Mit welchem Programmen kann ich eine PDF-Datei am elegantesten in eine moderne EPUB-Datei für meinen Pocketbook-Reader umwandeln und die Bilder und die Überschriften übernehmen ? - -Für Ubuntu 24.04 würde ich mit **Calibre** beginnen und das Ergebnis anschließend in Sigil nachbearbeiten. So bekommst du ein EPUB mit anpassbarer Schriftgröße, eingebundenen Bildern und einem brauchbaren Inhaltsverzeichnis. Wie gut Überschriften und Bilder übernommen werden, hängt allerdings stark vom Aufbau der PDF ab: PDF speichert vor allem Seitenpositionen, nicht zuverlässig die logische Buchstruktur. Gerade mehrspaltige Seiten, Tabellen und vektorbasierte Grafiken bereiten Calibre Probleme.[^1_1][^1_2] - -## Welches Programm wofür? - -| Programm | Aufgabe | Empfehlung | -| :-- | :-- | :-- | -| Calibre | PDF in ein mitfließendes EPUB konvertieren; Bilder extrahieren und Kapitelüberschriften versuchsweise erkennen | Der einfachste erste Versuch. [^1_3][^1_1] | -| Sigil | Überschriften, Bilder und Inhaltsverzeichnis im fertigen EPUB kontrollieren und korrigieren | Für ein wirklich sauberes Ergebnis. Sigil unterstützt EPUB 3 und mehrstufige Inhaltsverzeichnisse. [^1_2] | -| OCRmyPDF | Gescannte, nicht durchsuchbare PDFs mit einer Textebene versehen | Nur als vorgeschalteter Schritt bei Scans; OCR stellt die ursprüngliche Überschriftenstruktur nicht automatisch wieder her. [^1_4][^1_1] | - -## Praktischer Ablauf - -1. Prüfe zuerst, ob du Text in der PDF markieren und kopieren kannst. Falls nicht, installiere die Texterkennung und erstelle eine durchsuchbare Arbeitskopie:[^1_4][^1_5] - -```bash -sudo apt install ocrmypdf tesseract-ocr-deu -ocrmypdf -l deu --deskew "buch.pdf" "buch-ocr.pdf" -``` - -2. Öffne die ursprüngliche PDF beziehungsweise bei einem Scan `buch-ocr.pdf` in Calibre. Wähle „Bücher konvertieren“ → Ausgabeformat „EPUB“. Prüfe insbesondere „Strukturerkennung“ und „Inhaltsverzeichnis“; Calibre kann unformatierte Kapitelüberschriften heuristisch als Überschriften markieren. Vergleiche das Resultat anschließend mit der PDF, statt die Erkennung ungeprüft zu übernehmen.[^1_6][^1_1] -3. Öffne das EPUB in Sigil. Korrigiere falsch erkannte Überschriften, fehlplatzierte Bilder und das Inhaltsverzeichnis. Das ist der Schritt, der aus einer bloßen Konvertierung ein angenehm lesbares E-Book macht.[^1_2] - -Für einen ersten Versuch auf der Kommandozeile reicht auch: - -```bash -sudo apt install calibre -ebook-convert "buch.pdf" "buch.epub" -``` - -Calibres `ebook-convert` nimmt Eingabe- und Ausgabedatei als erste beiden Argumente. Wenn Bilder wichtig sind, verwende nicht die Option `--no-images`: Sie unterbindet deren Extraktion.[^1_3] - -„Modern“ muss nicht zwingend EPUB 3 heißen: Calibre weist darauf hin, dass EPUB 2 breiter kompatibel ist. PocketBook unterstützt EPUB 3 bei dokumentierten Modellen ebenfalls; ohne genaue Modellangabe würde ich zunächst das erzeugte EPUB auf deinem Reader testen und EPUB 3 nur gezielt wählen, wenn du es brauchst.[^1_7][^1_8] - -**Wichtige Grenze:** Ist die PDF ein aufwendig gesetztes Magazin, ein Fachbuch mit vielen Tabellen oder überwiegend ein Seitenscan, gibt es meist keine elegante Ein-Klick-Umwandlung *mit zugleich* frei skalierbarem Text und originalgetreuem Layout. In solchen Fällen ist die EPUB-Originaldatei, falls erhältlich, die bessere Ausgangsbasis; andernfalls ist Nacharbeit unvermeidlich.[^1_1] - -[^1_10][^1_11][^1_12][^1_13][^1_14][^1_15][^1_16][^1_17][^1_18][^1_19][^1_20][^1_21][^1_22][^1_23][^1_24][^1_25][^1_26][^1_27][^1_28][^1_29][^1_30][^1_31][^1_32][^1_33][^1_34][^1_35][^1_36][^1_37][^1_38][^1_39][^1_40][^1_9] - -
⁂
- -[^1_1]: https://manual.calibre-ebook.com/conversion.html - -[^1_2]: https://sigil-ebook.com/sigil/ - -[^1_3]: https://manual.calibre-ebook.com/generated/en/ebook-convert.html - -[^1_4]: https://ocrmypdf.readthedocs.io/en/latest/introduction.html - -[^1_5]: https://ocrmypdf.readthedocs.io/en/latest/cookbook.html - -[^1_6]: https://manual.calibre-ebook.com/de/conversion.html - -[^1_7]: https://manual.calibre-ebook.com/de/generated/de/ebook-convert.html - -[^1_8]: https://support.pocketbook-int.com/fw/627/ww/6.5.2307/manual/User_Manual_Touch_Lux_4_EN.pdf - -[^1_9]: https://lehrerfortbildung-bw.de/st_digital/medienwerkstatt/office/text/ebook/erstellung/calibre_edit.html - -[^1_10]: https://play.google.com/store/apps/details?id=com.enzodev.convertirpdfaepub\&hl=de - -[^1_11]: https://tools.pdf24.org/de/pdf-in-epub - -[^1_12]: https://www.techspot.com/downloads/5411-calibre.html - -[^1_13]: https://www.chip.de/ratgeber/software/pdf-in-epub-umwandeln-so-geht-s_40a9a185-7250-4c01-a053-1bf50582d9ce.html - -[^1_14]: https://online2pdf.com/de/pdf-in-epub-umwandeln - -[^1_15]: https://mobisystems.com/de-de/mobipdf/features/pdf-to-epub - -[^1_16]: https://apps.apple.com/de/app/konvertieren-von-pdf-in-epub/id1477775676 - -[^1_17]: https://convertio.co/de/epub-pdf/ - -[^1_18]: https://www.reddit.com/r/Calibre/comments/1hc2utk/converting_pdf_to_epub/ - -[^1_19]: https://forum.ubuntuusers.de/topic/pdf-in-epub-umwandeln/ - -[^1_20]: https://tinkerpete.wordpress.com/2010/11/26/tipps-zum-konvertieren-von-pdf-zu-epub-mit-calibre/ - -[^1_21]: https://www.pdftodo.com/de/PDFToePub - -[^1_22]: https://www.tutonaut.de/anleitung-pdf-in-epub-konvertieren/ - -[^1_23]: https://ocrmypdf.readthedocs.io/en/latest/advanced.html - -[^1_24]: https://manual.calibre-ebook.com/calibre.pdf - -[^1_25]: https://manual.calibre-ebook.com/et/generated/et/ebook-convert.html - -[^1_26]: https://ocrmypdf.readthedocs.io/en/latest/batch.html - -[^1_27]: https://ocrmypdf.readthedocs.io/en/stable/apiref.html - -[^1_28]: https://ocrmypdf.readthedocs.io/\_/downloads/en/v9.7.2/pdf/ - -[^1_29]: https://manual.calibre-ebook.com/faq.html - -[^1_30]: https://ocrmypdf.readthedocs.io/\_/downloads/en/v11.1.1/pdf/ - -[^1_31]: https://ocrmypdf.readthedocs.io/en/latest/releasenotes/version03.html - -[^1_32]: https://ocrmypdf.readthedocs.io/\_/downloads/en/v15.2.0/pdf/ - -[^1_33]: https://manual.calibre-ebook.com/fr/generated/fr/ebook-convert.html - -[^1_34]: https://manual.calibre-ebook.com/sv/generated/sv/ebook-convert.html - -[^1_35]: https://support.pocketbook-int.com/fw/632_HZO/ww/6.0.926/rn/ww/RN_PB_Touch_HD_3_EN_6.0.pdf - -[^1_36]: https://support.pocketbook-int.com/fw/632_HZO/ww/6.11.1796/manual/ww/User_Manual_Touch_HD_3_EN.pdf - -[^1_37]: https://support.pocketbook-int.com/fw/629/u/6.8.2659/manual/User_Manual_Verse_EN.pdf - -[^1_38]: https://support.pocketbook-int.com/fw/700/u/6.7.584/manual/ww/User_Manual_Era_EN.pdf - -[^1_39]: https://support.pocketbook-int.com/fw/700/u/6.8.3713/manual/User_Manual_Era_EN.pdf - -[^1_40]: https://support.pocketbook-int.com/fw/740/u/6.1.1069/rn/ww/RN_PB_InkPad_3_EN.pdf - - ---- - -# Mit welche Programmen kann ich dann das EPUB wie editieren ? - -Für dein bereits konvertiertes EPUB ist der **integrierte Editor von Calibre** der bequemste Einstieg: Er zeigt die Textdateien, Bilder und CSS-Dateien des Buchs sowie eine Live-Vorschau. Wenn du vor allem Überschriften und das Inhaltsverzeichnis systematisch überarbeiten willst, ist Sigil eine gute Alternative. Beide bearbeiten das EPUB selbst – du musst nicht erneut von PDF konvertieren.[^2_1][^2_2] - -## So editierst du es in Calibre - -1. Lege zuerst eine Kopie der EPUB-Datei an. Füge das Buch dann in Calibre hinzu, klicke es mit der rechten Maustaste an und wähle „Buch bearbeiten“. -2. Öffne links eine der Textdateien. Der Buchtext liegt dort als HTML/XHTML; Änderungen siehst du rechts in der Vorschau. Die Formatierung steckt überwiegend in den CSS-Dateien.[^2_1] -``` -1. Korrigiere Überschriften im Text als echte Überschriften, etwa `

Kapitel 1

` für Kapitel und `

Unterkapitel

` für Unterabschnitte. Ein bloß fett formatierter Absatz ist für ein automatisch erzeugtes Inhaltsverzeichnis nicht dasselbe.[^2_3] -``` - -1. Prüfe die Bilddateien links im Dateibrowser und ihre Position im Text. Calibre lässt dich HTML-, CSS- und Bilddateien innerhalb des Buchs bearbeiten.[^2_1] -2. Öffne „Werkzeuge“ → „Inhaltsverzeichnis“ → „Inhaltsverzeichnis bearbeiten“. Dort kannst du Einträge umbenennen, verschieben und ihre Sprungziele korrigieren. Speichere anschließend das Buch und teste es auf dem PocketBook.[^2_1] - -Ein Beispiel für eine nach der PDF-Konvertierung falsch erkannte Überschrift: - -```html -

Kapitel 3 – Die Reise

-``` - -Ändere sie, sofern sie tatsächlich ein Kapiteltitel ist, zu: - -```html -

Kapitel 3 – Die Reise

-``` - -Damit ist die Struktur im EPUB ausdrücklich ausgezeichnet; danach kannst du das Inhaltsverzeichnis passend dazu aufbauen.[^2_3] - -## Wann Sigil sinnvoller ist - -Sigil ist ein eigenständiger EPUB-Editor für Linux. Er unterstützt EPUB 2 und EPUB 3, den Import von Bildern und Stylesheets sowie die Erzeugung eines mehrstufigen Inhaltsverzeichnisses aus Überschriften. Ich würde ihn nehmen, wenn du viele Kapitel systematisch bereinigen oder die EPUB-Struktur genauer kontrollieren möchtest.[^2_2][^2_4] - -Unter Ubuntu kannst du zunächst die Paketversionen verwenden: - -```bash -sudo apt install calibre sigil -``` - -Calibre ist in den Ubuntu-Paketquellen verfügbar; dessen Projekt empfiehlt für die jeweils aktuelle Version allerdings seine eigene Linux-Installation, weil Distributionspakete veraltet sein können.[^2_5][^2_6] - -Für dein konkretes Ziel würde ich mit Calibre anfangen und nur bei umfangreicher Strukturarbeit zu Sigil wechseln. Gerade bei einer aus PDF erzeugten Datei lohnt sich zuerst eine Stichprobe an drei Stellen: Anfang, Mitte und Ende – dort erkennst du schnell, ob Überschriften, Bildzuordnung und Lesereihenfolge grundsätzlich stimmen. - -[^2_10][^2_11][^2_12][^2_13][^2_14][^2_15][^2_16][^2_7][^2_8][^2_9] - -
⁂
- -[^2_1]: https://manual.calibre-ebook.com/edit.html - -[^2_2]: https://sigil-ebook.com/sigil/ - -[^2_3]: https://manual.calibre-ebook.com/de/polish.html - -[^2_4]: https://daisy.org/guidance/info-help/guidance-training/content-creation/using-sigil-to-create-epub-files/ - -[^2_5]: https://wiki.ubuntuusers.de/Calibre/ - -[^2_6]: https://calibre-ebook.com/download_linux - -[^2_7]: https://flathub.org/en/apps/com.sigil_ebook.Sigil - -[^2_8]: https://www.vpl.ca/guides/inspiration-lab-sigil/getting-started-sigil - -[^2_9]: https://lwn.net/Articles/1054751/ - -[^2_10]: https://github.com/Sigil-Ebook-Editor - -[^2_11]: https://davidgerard.co.uk/blockchain/2020/11/05/calibre-epub-and-epubcheck-the-curse-of-editing-xhtml/ - -[^2_12]: https://linuxcapable.com/how-to-install-calibre-on-ubuntu-linux/ - -[^2_13]: https://ossmalta.eu/how-to-install-sigil-2-3-1-in-ubuntu-24-04-linux-mint-22/ - -[^2_14]: https://pclosmag.com/html/Issues/201302/page09.html - -[^2_15]: https://ubuntu.dirkschmidtke.de/en/portfolio/calibre/ - -[^2_16]: https://www.reddit.com/r/Calibre/comments/cqa1s7/i_edit_an_epub_with_calibre_editerwhen_i_inport_a/ - - ---- - -# Ubuntu 24.04: Wie kann ich eine PDF-Datei in eine Markdown-Datei umwandeln und dabei alle Überschriften und Absätze so erhalten wie in der PDF-Datei, aber einfache Zeilenumbrüche sollen durch Leerzeichen ersetzt werden, doppelte Zeilenumbrüche (d. h. wahre Absätze) sollen erhalten bleiben. Schreibe ein Python-Commandline-Progamm, das die Konvertierung vornimmt. - -Für deinen Zweck würde ich die PDF zunächst mit PyMuPDF4LLM als strukturiertes Markdown auslesen und danach nur die Zeilenumbrüche innerhalb erkannter Textabsätze zusammenziehen. Das Programm unten lässt Leerzeilen als Absatzgrenzen stehen und schützt Markdown-Überschriften, Listen, Tabellen und Codeblöcke davor, versehentlich mit Fließtext verschmolzen zu werden. PyMuPDF4LLM unterstützt Layoutanalyse und kann Überschriften aus dem Dokument ableiten; eine perfekte Rekonstruktion ist bei PDFs aber nicht garantiert.[^3_1][^3_2][^3_3] - -## Installation unter Ubuntu 24.04 - -Installiere das Python-Paket in einer virtuellen Umgebung. Für gescannte PDFs kommen Tesseract und das deutsche Sprachpaket hinzu; bei PDFs mit auswählbarem Text brauchst du sie normalerweise nicht. PyMuPDF4LLM kann OCR bei Bedarf automatisch einsetzen.[^3_1] - -```bash -sudo apt install python3-venv tesseract-ocr tesseract-ocr-deu - -python3 -m venv ~/venvs/pdf2md -~/venvs/pdf2md/bin/pip install --upgrade pip -~/venvs/pdf2md/bin/pip install pymupdf4llm -``` - - -## Das Kommandozeilenprogramm - -Speichere Folgendes als `pdf2md.py`: - -```python -#!/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* 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[^3_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)[^3_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()) -``` - -PyMuPDF4LLM stellt die hier verwendeten Optionen für Markdown-Ausgabe, Seitenkopf/-fuß und OCR-Sprache bereit. Das Weglassen wiederkehrender Kopf- und Fußzeilen ist standardmäßig aktiviert, damit beispielsweise Seitenzahlen nicht zwischen zwei Textabsätzen landen.[^3_2][^3_1] - -## Aufruf und Ergebnis - -```bash -~/venvs/pdf2md/bin/python pdf2md.py "mein-buch.pdf" "mein-buch.md" -``` - -Für eine deutsch-englische gescannte PDF: - -```bash -~/venvs/pdf2md/bin/python pdf2md.py \ - --ocr-language deu+eng "mein-buch.pdf" "mein-buch.md" -``` - -Aus diesem erkannten Markdown-Abschnitt: - -```text -## Einleitung - -Dies ist die erste Zeile -desselben Absatzes. - -Dies ist ein neuer -Absatz. -``` - -macht das Programm: - -```text -## Einleitung - -Dies ist die erste Zeile desselben Absatzes. - -Dies ist ein neuer Absatz. -``` - -Die entscheidende Einschränkung: Das Programm erhält *erkannte* Absatzgrenzen. Wenn die PDF beziehungsweise die Extraktion zwei echte Absätze ohne Leerzeile ausgibt, kann die reine Zeilenumbruchregel sie nicht sicher unterscheiden. Ebenso können Überschriften bei uneinheitlicher Typografie falsch erkannt werden; mehrspaltige Seiten oder Tabellen solltest du stichprobenartig mit dem Original vergleichen.[^3_3][^3_2] - -[^3_10][^3_11][^3_12][^3_13][^3_14][^3_15][^3_16][^3_17][^3_18][^3_4][^3_5][^3_6][^3_7][^3_8][^3_9] - -
⁂
- -[^3_1]: https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/ - -[^3_2]: https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/api.html - -[^3_3]: https://pymupdf.readthedocs.io/en/latest/recipes-text.html - -[^3_4]: https://pymupdf.readthedocs.io/en/latest/app1.html - -[^3_5]: https://pymupdf.io/blog/pdf-native-vs-vision-models-gemini-3 - -[^3_6]: https://pymupdf.readthedocs.io/ - -[^3_7]: https://www.nutrient.io/blog/extract-text-from-pdf-pymupdf/ - -[^3_8]: https://github.com/pymupdf/PyMuPDF4LLM - -[^3_9]: https://deepwiki.com/pymupdf/pymupdf4llm/9-configuration-reference - -[^3_10]: https://github.com/pymupdf/pymupdf4llm/tree/main/src/helpers/chunking - -[^3_11]: https://deepwiki.com/pymupdf/pymupdf4llm/2.4-output-formats-and-page-chunking - -[^3_12]: https://github.com/pymupdf/PyMuPDF/discussions/1512 - -[^3_13]: https://github.com/pymupdf/PyMuPDF/discussions/2396 - -[^3_14]: https://deepwiki.com/pymupdf/PyMuPDF/2.2-text-extraction-and-processing - -[^3_15]: https://documentation.help/PyMuPDF/app2.html - -[^3_16]: tools.ebook_conversion - -[^3_17]: projects.textbook.operations_research_trading - -[^3_18]: https://www.perplexity.ai/search/2748e552-fbd6-492f-88ca-8fb325447e38 - - ---- - -# python pdf2md.py Operation_Ljutsch_Band_1-\_Reinhard_Otto_Kranz.pdf Operation_Ljutsch_Band_1-\_Reinhard_Otto_Kranz.md - -Fehler bei der Konvertierung: 'RapidOCR' object has no attribute 'text_detector' - -Der Fehler kommt aus der OCR-Komponente, nicht aus der Funktion, die Zeilenumbrüche zusammenzieht: PyMuPDF4LLM versucht offenbar, RapidOCR aufzurufen, dessen installiertes Objekt die erwartete Eigenschaft `text_detector` nicht hat. Die automatische OCR-Auswahl bevorzugt sogar eine Kombination aus RapidOCR und Tesseract, wenn beide verfügbar sind.[^4_1][^4_2] - -## Schnellster Versuch - -Wenn du in der PDF Text mit der Maus markieren und kopieren kannst, starte das vorhandene Skript zunächst ohne OCR: - -```bash -python pdf2md.py --no-ocr \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.pdf \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.md -``` - -In meinem Skript setzt `--no-ocr` den Parameter `use_ocr=False`. Das umgeht den fehlerhaften OCR-Pfad. Prüfe anschließend, ob die Markdown-Datei tatsächlich Text enthält.[^4_3] - -## Falls die PDF ein Scan ist - -Dann brauchst du OCR. Ändere in `pdf2md.py` den Importbereich von - -```python -import pymupdf4llm -``` - -zu - -```python -import pymupdf4llm -from pymupdf4llm.ocr import tesseract_api -``` - -und ergänze im Aufruf von `pymupdf4llm.to_markdown(...)` diese Zeile: - -```python -ocr_function=tesseract_api.exec_ocr if not args.no_ocr else None, -``` - -Der betreffende Abschnitt sieht danach so aus: - -```python -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, -) -``` - -Damit wählst du ausdrücklich das Tesseract-Plugin statt der automatischen Auswahl mit RapidOCR. PyMuPDF4LLM dokumentiert `ocr_function` genau für die Auswahl eines bestimmten OCR-Plugins.[^4_1] - -Stelle sicher, dass Tesseract und die deutschen Sprachdaten installiert sind, und starte denselben Befehl danach erneut: - -```bash -sudo apt install tesseract-ocr tesseract-ocr-deu - -python pdf2md.py \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.pdf \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.md -``` - -**Meine Empfehlung:** Probiere zuerst `--no-ocr`. Nur wenn dadurch Text fehlt, ändere das Skript auf das explizite Tesseract-Plugin. Das behebt gezielt den gemeldeten RapidOCR-Fehler, ohne an der Absatzbehandlung etwas zu verändern.[^4_2][^4_1] - -[^4_10][^4_11][^4_12][^4_13][^4_14][^4_15][^4_4][^4_5][^4_6][^4_7][^4_8][^4_9] - -
⁂
- -[^4_1]: https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/ocr-plugins.html - -[^4_2]: https://github.com/pymupdf/pymupdf4llm/issues/398 - -[^4_3]: https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/api.html - -[^4_4]: https://pymupdf.io/blog/hybrid-ocr-in-pymupdf4llm - -[^4_5]: https://artifex.com/blog/building-a-multimodal-llm-application-with-pymupdf4llm - -[^4_6]: https://pypi.org/project/pymupdf4llm/ - -[^4_7]: https://data.safetycli.com/packages/pypi/pymupdf/changelog - -[^4_8]: https://www.llamaindex.ai/glossary/tesseract-ocr-python - -[^4_9]: https://github.com/pymupdf/pymupdf4llm/blob/main/CHANGES.md - -[^4_10]: https://github.com/pymupdf/PyMuPDF/issues/4833 - -[^4_11]: https://buildmedia.readthedocs.org/media/pdf/pymupdf/latest/pymupdf.pdf - -[^4_12]: https://stackoverflow.com/questions/72175112/how-to-data-extract-from-unstructured-pdfs-using-pymupdf-in-python - -[^4_13]: https://dev.to/aairom/using-doclings-ocr-features-with-rapidocr-29hd - -[^4_14]: https://github.com/pymupdf/pymupdf4llm - -[^4_15]: https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/ - - ---- - -# Verbessere das Programm: Absätze müssen immer mit einem Satzzeichen enden, das das Ende eines Satzes markiert oder das Ende eines Satzes mit Anführungszeichen markiert ('z. B. .' , '?' , '!' oder diese Zeichen gefolgt von einem beendenden Anführungszeichen). Wenn am Ende eines vermeintlichen Absatzes kein solches Zeichen oder eine solche Zeichenfolge steht, handelt es sich NICHT um ein Absatzende. Dann sollen die Zeichen bis zur nächsten Zeile durch ein Leerzeichen ersetzt werden und der Absatz fortgesetzt werden. Nur Überschriften sind auch ohne Satzende-Zeichen(-folge) erlaubt. - -Ja. Die Absatzlogik lässt sich so ändern, dass eine Leerzeile nur dann einen neuen Absatz erzeugt, wenn der bisherige Text mit `.`, `?`, `!` oder `…` endet – gegebenenfalls gefolgt von schließenden Anführungszeichen. Andernfalls verbindet das Programm den Text über die Leerzeile hinweg mit einem Leerzeichen. **Überschriften** bleiben auch ohne Satzzeichen eigenständige Blöcke. - -Ersetze `pdf2md.py` vollständig durch diese Version. Sie enthält außerdem die Tesseract-Auswahl aus der vorherigen Fehlerbehebung: - -```python -#!/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* 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()) -``` - -Beispiel für die neue Regel: - -```text -Dies ist ein vermeintlicher Absatz - -der tatsächlich noch weitergeht. - -Hier endet ein Satz. - -Hier beginnt ein neuer Absatz. -``` - -wird zu: - -```text -Dies ist ein vermeintlicher Absatz der tatsächlich noch weitergeht. - -Hier endet ein Satz. - -Hier beginnt ein neuer Absatz. -``` - -Du kannst es wie bisher aufrufen: - -```bash -python pdf2md.py --no-ocr \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.pdf \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.md -``` - -Die Regel ist bewusst strikt: Eine echte Absatzgrenze nach einem Doppelpunkt oder eine abbrechende Dialogzeile ohne Schlusssatzzeichen wird ebenfalls zusammengezogen. Umgekehrt zählt auch ein Punkt in einer Abkürzung am Zeilenende als Satzende, *wenn dort zugleich eine Leerzeile erkannt wurde*. Listen, Tabellen, Bilder und Codeblöcke bleiben aus Sicherheitsgründen eigene Markdown-Strukturen und werden nicht nach dieser Satzzeichenregel zusammengezogen. - ---- - -# Verbessere das Programm: Absätze müssen immer mit einem Satzzeichen enden, das das Ende eines Satzes markiert oder das Ende eines Satzes mit Anführungszeichen markiert ('z. B. .' , '?' , '!' oder diese Zeichen gefolgt von einem beendenden Anführungszeichen). Wenn am Ende eines vermeintlichen Absatzes kein solches Zeichen oder eine solche Zeichenfolge steht, handelt es sich NICHT um ein Absatzende. Dann sollen die Zeichen bis zur nächsten Zeile durch ein Leerzeichen ersetzt werden und der Absatz fortgesetzt werden. Nur Überschriften, Zeilen des Inhaltsverzeichnisses oder Hinweise auf ausgelassene Bilder (z. B. '**==> picture [426 x 480] intentionally omitted \<==**' ) sind auch ohne Satzende-Zeichen(-folge) erlaubt. - -Die zusätzliche Regel ist: Eine Zeile des Inhaltsverzeichnisses und ein Platzhalter für ein ausgelassenes Bild dürfen ebenso wie eine Überschrift ohne Satzzeichen enden. Sie bleiben eigenständige Markdown-Blöcke; gewöhnlicher Fließtext wird dagegen über vermeintliche Absatzgrenzen hinweg verbunden, bis ein zulässiges Satzende erreicht ist. - -Ersetze `pdf2md.py` durch diese vollständige Fassung: - -```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* 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()) -``` - -Aufruf für eine PDF mit kopierbarem Text: - -```bash -python pdf2md.py --no-ocr \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.pdf \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.md -``` - -Beispielsweise bleibt diese Bildzeile jetzt allein stehen, obwohl sie keinen Satz abschließt: - -```markdown -**==> picture [426 x 480] intentionally omitted <==** -``` - -Auch `Kapitel 1 .......... 12` wird als eigenständige Inhaltsverzeichniszeile behandelt. Einträge *ohne* Seitenzahl erkennt das Programm nur im Abschnitt nach einer erkannten Überschrift „Inhaltsverzeichnis“, „Inhalt“, „Contents“ oder „Table of Contents“. - -Eine Grenze bleibt: Wenn die PDF-Extraktion eine Überschrift oder Inhaltsverzeichniszeile nicht als solche erkennbar ausgibt, lässt sich ihr Typ aus einem einfachen Zeilenumbruch nicht zuverlässig ableiten. Die neue Regel schützt die genannten Muster; bei ungewöhnlichem Buchsatz müssen wir deren Erkennung gegebenenfalls anhand eines tatsächlichen Auszugs deiner erzeugten Markdown-Datei ergänzen. - ---- - -# Ist es kompliziert, auch die Grafiken in die MD-Datei einzufügen ? - -Nein, für dein bestehendes Programm ist das eine überschaubare Änderung. PyMuPDF4LLM kann Grafiken als Bilddateien speichern und an ihrer Stelle Markdown-Verweise erzeugen. Das ist für deine Datei praktischer, als die Bilddaten direkt in die `.md`-Datei einzubetten: Du erhältst eine lesbare Markdown-Datei und einen zugehörigen Bilderordner.[^7_1] - -## Änderungen am Programm - -Ergänze bei den Argumenten in `main()`: - -```python -parser.add_argument( - "--images", - action="store_true", - help="Grafiken als Dateien speichern und in Markdown verlinken", -) -``` - -Füge unmittelbar *vor* dem Aufruf von `pymupdf4llm.to_markdown(...)` Folgendes ein: - -```python -image_dir = target.parent / f"{target.stem}_images" - -if args.images: - image_dir.mkdir(parents=True, exist_ok=True) -``` - -Ergänze im Aufruf von `to_markdown(...)` diese Parameter: - -```python -write_images=args.images, -image_path=str(image_dir) if args.images else "", -image_format="png", -``` - -Da `image_path` hier ein absoluter oder zum aktuellen Arbeitsverzeichnis relativer Dateipfad sein kann, sollten die erzeugten Verweise anschließend relativ zum Speicherort der Markdown-Datei sein. Füge deshalb direkt *nach* `to_markdown(...)`, aber *vor* `normalize_markdown(markdown)`, ein: - -```python -if args.images: - markdown = markdown.replace( - str(image_dir.resolve()) + "/", - image_dir.name + "/", - ).replace( - str(image_dir) + "/", - image_dir.name + "/", - ) -``` - -Dein bisheriger Schreibaufruf bleibt unverändert: - -```python -target.write_text(normalize_markdown(markdown), encoding="utf-8") -``` - -`normalize_markdown()` erkennt bereits Markdown-Bildzeilen wie `![...](...)` als eigenständige Struktur. Sie werden daher nicht mit dem nächsten Absatz verschmolzen.[^7_1] - -## Aufruf - -```bash -python pdf2md.py --no-ocr --images \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.pdf \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.md -``` - -Danach liegen die `.md`-Datei und ein Ordner namens `Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz_images` nebeneinander. Beim Verschieben solltest du beide zusammen mitnehmen. - -Eine wichtige Einschränkung: Die Bilder stehen nicht *als Bilddaten* in der Markdown-Datei, sondern als Verweise, etwa `![...](Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz_images/...)`. PyMuPDF4LLM kann auch Vektorgrafiken als Bildbereiche ausgeben; eingebetteter Text innerhalb solcher Bereiche kann je nach Erkennung Teil des Bildes werden. Vergleiche deshalb bei einigen Seiten die Position von Grafik und Bildunterschrift mit der PDF.[^7_1] - -[^7_2][^7_3] - -
⁂
- -[^7_1]: https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/api.html - -[^7_2]: https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/ - -[^7_3]: https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/ocr-plugins.html - - ---- - -# Zeige das geänderte Skript vollständig. - -Hier ist das vollständige Skript mit `--images`. Es speichert extrahierte Grafiken in einem Ordner neben der Markdown-Datei und setzt relative Bildverweise, sodass du Datei und Bilderordner gemeinsam verschieben kannst. PyMuPDF4LLM bietet dafür die Optionen `write_images`, `image_path` und `image_format`.[^8_1] - -```python -#!/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* 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[^8_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)[^8_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()) -``` - -Für deine PDF mit kopierbarem Text und Grafiken: - -```bash -python pdf2md.py --no-ocr --images \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.pdf \ - Operation_Ljutsch_Band_1-_Reinhard_Otto_Kranz.md -``` - -Falls die PDF stattdessen ein Scan ist, lasse `--no-ocr` weg. Die Grafiken stehen dann als Verweise in der `.md`-Datei; die eigentlichen Bilddateien liegen im gleichnamigen `_images`-Ordner. [^8_1] - -
⁂
- -[^8_1]: https://pymupdf.readthedocs.io/en/latest/pymupdf4llm/api.html - - ---- - -# Das Inhaltsverzeichnis in der MD-Datei sieht so aus: - -\#\#Inhaltsverzeichnis -Titel Vorwort: Berlin 2015 Teil I: Das Vermächtnis -1 Der Notar -2 Ein Brief – eine Liste – ein Vermächtnis -3 Die Grauen nehmen die Spur auf 4 Flucht aufs Land -5 Oie begegnet den Grauen -6 Ulms Elysium -7 Feindlich-freundliche Kooperation -8 Fischzug der Erinnerungen -9 Brand-Jagd Teil II: Verbündete -10 Samuel Nussbaum – Kryptologe -11 Nussbaums Talente -12 Dollar-Depot -13 Katharina -14 Reisevorbereitungen -15 Rudolfs CoCom-Geheimnis Teil III: Lettische Geschichten: Der Kreml-Flug – Operation Prelomlenie (Lichtbrechung) 16 Zweimal Riga 17 Hermannis Visvaldis 18 Auf dem Letten-Land 19 Kreml-Flug-Dirigent Oberst a.D. Valentin Kiparsky 20 Lettische Erzählungen 21 Nacht-Kampf mit den Grauen 22 Flucht zur Insel Ruhnu 23 Helsinki-Törn Teil IV: Russische Capricen 24 Operation Abendlicht – der Kreml-Flug Plan C 25 Helsinki-Positionen 26 Sankt Petersburg 27 Professor Kusnezow – Seismograph einer wirtschaftlichen Implosion 28 Unruhe 29 Sankt Petersburg – Moskau 30 Petrows Erzählungen – die politischen Hintergründe der Europäischen Perestroika 31 Die Dienste stochern im Nebel Der Autor Vorschau Band Zwei Impressum neobooks - -Woran liegt das ? - -Das liegt hauptsächlich an der **Absatzregel des Skripts**, nicht daran, dass die Einträge in der PDF zwingend so zusammenstehen. - -Die erste Zeile `##Inhaltsverzeichnis` wird von unserem Programm nicht als Überschrift erkannt: Die bisherige Erkennung erwartet ein Leerzeichen nach `##`, also `## Inhaltsverzeichnis`. Auch die spezielle Erkennung des Inhaltsverzeichnis-Titels erwartet dieses Leerzeichen. Damit wird `in_contents` nicht aktiviert. - -Danach greift genau die Regel, die wir für Fließtext eingebaut haben: Zeilen ohne Satzende werden über Zeilenumbrüche und Leerzeilen hinweg mit Leerzeichen verbunden. Inhaltsverzeichniseinträge wie `1 Der Notar` enden normalerweise nicht mit `.`, `?` oder `!`. Sie werden deshalb zu einem einzigen langen Absatz zusammengezogen. In deinem Ergebnis sieht man das besonders deutlich bei `3 Die Grauen nehmen die Spur auf 4 Flucht aufs Land`. - -Es gibt noch einen zweiten Punkt: Die bisherige Inhaltsverzeichniserkennung berücksichtigt vor allem nummerierte Einträge. Unnummerierte Zeilen wie `Titel`, `Vorwort`, `Teil I: Das Vermächtnis`, `Der Autor` und `Impressum` brauchen dort ebenfalls eine eigene Behandlung. - -Die unmittelbare Korrektur für die Überschrift wäre: - -```markdown -## Inhaltsverzeichnis -``` - -Das allein genügt für dein Beispiel aber noch nicht zuverlässig. Das Programm muss nach dieser Überschrift in einen eigenen Inhaltsverzeichnis-Modus wechseln und dort **jede erkannte Eintragszeile als eigene Zeile erhalten**, statt die Satzende-Regel für Fließtext anzuwenden. Falls PyMuPDF4LLM bereits mehrere Einträge *innerhalb derselben ausgegebenen Zeile* zusammengefügt hat, muss man zusätzlich anhand der PDF-Positionen beziehungsweise der Eintragsnummern trennen; aus dem fertigen Text ist die ursprüngliche Grenze nicht immer eindeutig rekonstruierbar. - diff --git a/README.md b/README.md index 6cfa4c9..1e5eb3d 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,4 @@ synthetisch erzeugt. ## Dateien - `pdf2md.py` – aktuelle Version -- `History/pdf2md_v1.py` … `History/pdf2md_v4.py` – frühere Entwicklungsstände (siehe `Info/`) -- `Info/` – Recherche und Entstehungsgeschichte der Skripte - `tests/` – pytest-Suite From 217d1ee09b72b6423b0cebbfec7bf78153bbaf06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 13:51:50 +0200 Subject: [PATCH 03/10] =?UTF-8?q?Weiche=20Trennstriche=20aufl=C3=B6sen,=20?= =?UTF-8?q?Courier-OCR-Ebenen=20nicht=20als=20Code=20setzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Zeilenende mit U+00AD oder "¬" wird wie Silbentrennung behandelt ("wach­ senden" -> "wachsenden") - Unsichtbarer Text (OCR-Ebene) verliert das Monospace-Flag, sonst setzt pymupdf4llm jedes Wort in Backticks - Tests für beide Fälle und für sichtbaren Monospace-Text Co-Authored-By: Claude Opus 5.5 --- pdf2md.py | 25 ++++++++++++++++++------- tests/test_normalize.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/pdf2md.py b/pdf2md.py index 2a1aef6..29bd0ee 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -472,28 +472,39 @@ def load_outline(doc: pymupdf.Document) -> dict[str, int]: # =========================================================================== -# Korrektur für pymupdf4llm: Bindestrich-Komposita am Zeilenende +# Korrektur für pymupdf4llm: Trennstriche am Zeilenende, OCR-Ebenen # =========================================================================== # pymupdf4llm entfernt jeden Bindestrich am Zeilenende ("Militär-" + Zeile # "Geheimdienst" -> "MilitärGeheimdienst"). Für Großbuchstaben und # Konjunktionen ("Arbeits- und") markieren wir den Strich vorher, sodass er # erhalten bleibt. Echte Silbentrennung (Kleinbuchstabe) bleibt unverändert. +# Weiche Trennstriche (U+00AD) und "¬" kennt pymupdf4llm nicht ("wach­ senden"); +# sie trennen immer innerhalb eines Wortes und werden zu "-", das dann entfällt. +# +# Unsichtbarer Text (OCR-Ebene eines Scans) ist oft in Courier gesetzt; +# pymupdf4llm hielte ihn sonst für Programmcode und setzte jedes Wort in `…`. KEEP_HYPHEN = "\ue000" # Strich behalten, ohne Leerzeichen anschließen KEEP_HYPHEN_SPACE = "\ue001" # Strich und Leerzeichen behalten +SOFT_HYPHENS = ("\u00ad", "¬") +MONOSPACE_FLAG = 8 + + def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]: marked = [] for i, span in enumerate(spans): nxt = spans[i + 1] if i + 1 < len(spans) else None + if not span.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT: + span = {**span, "flags": span.get("flags", 0) & ~MONOSPACE_FLAG} text = span["text"].rstrip() - if ( - nxt is not None - and text.endswith("-") - and (span.get("block"), span.get("line")) - != (nxt.get("block"), nxt.get("line")) - ): + line_end = nxt is not None and (span.get("block"), span.get("line")) != ( + nxt.get("block"), nxt.get("line") + ) + if line_end and text.endswith(SOFT_HYPHENS): + span = {**span, "text": text[:-1] + "-"} + elif line_end and text.endswith("-"): word = FIRST_WORD.match(nxt["text"].lstrip()) if word and word.group(0).lower() in CONJUNCTIONS: span = {**span, "text": text + KEEP_HYPHEN_SPACE} diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 0098d45..747bf12 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -74,6 +74,37 @@ def test_pymupdf4llm_patch_keeps_compound_hyphen(): assert output.strip() == "Der Militär-Geheimdienst wusste es. Arbeits- und Kriegergeschrei." +def test_pymupdf4llm_patch_resolves_soft_hyphens(): + # Weicher Trennstrich (U+00AD) bzw. "¬" am Zeilenende, auch vor Großbuchstaben + spans = [ + {"text": "einer wach­", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 0}, + {"text": "senden Demo¬", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 1}, + {"text": "kratie und Lebens­ ", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 2}, + {"text": "Läufe.", "flags": 0, "char_flags": 16, "font": "x", "block": 0, "line": 3}, + ] + output, _ = p.document_layout.get_styled_text(spans) + assert output.strip() == "einer wachsenden Demokratie und LebensLäufe." + + +def test_pymupdf4llm_patch_ignores_monospace_of_invisible_text(): + # OCR-Ebene in Courier: unsichtbar, aber mit Monospace-Flag + spans = [ + {"text": "Nature reioicej", "flags": 8, "char_flags": 0, "alpha": 0, "font": "Courier", + "block": 0, "line": 0}, + {"text": "of many frendes", "flags": 8, "char_flags": 0, "alpha": 0, "font": "Courier", + "block": 0, "line": 1}, + ] + output, _ = p.document_layout.get_styled_text(spans) + assert output.strip() == "Nature reioicej of many frendes" + + +def test_visible_monospace_stays_code(): + span = {"text": "print()", "flags": 8, "char_flags": int(p.VISIBLE_TEXT), "font": "Courier", + "block": 0, "line": 0} + output, _ = p.document_layout.get_styled_text([span]) + assert output.strip() == "`print()`" + + # --- Überschriften ----------------------------------------------------------------- def test_heading_without_space_and_bold_is_cleaned(): From f9dfa3a52e2b92e4a42e8dcf9680218aa6f84db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 14:24:58 +0200 Subject: [PATCH 04/10] =?UTF-8?q?Inhaltsverzeichnis=20als=20Tabelle=20erha?= =?UTF-8?q?lten,=20Doku=20nach=20Probel=C3=A4ufen=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalize_markdown: Tabellenzeilen im Inhaltsverzeichnis unverändert übernehmen; der Verzeichnis-Modus endet mit der Tabelle (sonst zerfiel die Tabelle und die folgende Überschrift "Vorwort" ging verloren) - Test für Verzeichnis als Tabelle - README/Bedienungsanleitung: weiche Trennstriche, Courier-OCR-Ebenen, englische Bücher, --ocr always für alte Scans, Grenzen aus den Probeläufen mit fremden PDFs - TODO.md: bekannte Schwächen (Zahlen als Überschrift, römische Seitenzahlen, gesperrte Überschriften) Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 14 +++++++++++-- README.md | 44 +++++++++++++++++++++++++++++------------ TODO.md | 11 +++++++++++ pdf2md.py | 18 ++++++++++++++++- tests/test_normalize.py | 16 +++++++++++++++ 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index f87b9a7..b4d25c5 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -167,6 +167,7 @@ python3 pdf2md.py --epub scan.pdf Output/scan.md | Deutsch und Englisch gemischt | `--ocr-language deu+eng` (Sprachpaket `tesseract-ocr-eng` nötig) | | schneller auf vielen Kernen | `--jobs 8` (Standard 4) | | OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` | +| englisches Buch | `--ocr-language eng --lang en` (Sprachpaket `tesseract-ocr-eng` nötig) | | keine OCR | `--ocr never` oder `--no-ocr` | So entscheidet das Programm von selbst (`--ocr auto`): @@ -175,7 +176,9 @@ So entscheidet das Programm von selbst (`--ocr auto`): also ca. 5 Minuten für 300 Seiten. - **Scans mit vorhandener OCR-Ebene** (z. B. von `ocrmypdf`): Eine gerade Ebene wird übernommen, das geht sehr schnell. Eine schiefe Ebene (Scan nicht entzerrt) wird neu erkannt, - weil ihre Lesereihenfolge oft falsch ist. + weil ihre Lesereihenfolge oft falsch ist. Alte OCR-Ebenen (z. B. aus Digitalisaten älterer + Bücher) sind oft schlecht: Kolumnentitel werden zu Überschriften, Buchstaben sind falsch + erkannt. Dann mit `--ocr always` neu erkennen lassen. - **Reine Bildseiten in einem sonst digitalen PDF** (Fotos, Cover) werden nur übernommen, wenn eine Probe-OCR echten Fließtext liefert. Sonst bleiben sie Bild, und das Programm meldet einen „Hinweis“. @@ -213,7 +216,9 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g grep -n "^#" Output/band1.md ``` Erwartet: `## Teil …` und darunter `### 1 …`, `### 2 …` usw. Ganze Sätze als Überschrift - (endend mit `.` oder `«`) sind Erkennungsfehler, die du in Weg B korrigierst. + (endend mit `.` oder `«`) sind Erkennungsfehler, die du in Weg B korrigierst. Die Ebenen + kommen aus der Gliederung (Lesezeichen) der PDF; ist sie flach, stehen alle Kapitel auf + einer Ebene. 2. **Stichproben** am Anfang, in der Mitte und am Ende des Markdowns mit der PDF vergleichen: Fehlen Absätze, sind Absätze zusammengeklebt, stehen Bilder an der richtigen Stelle? 3. **EPUB auf dem PocketBook testen:** Inhaltsverzeichnis, Kapitelsprünge, Bilder, Cover. @@ -231,6 +236,11 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g | Bilder fehlen im EPUB (Weg B) | pandoc im Ordner der `.md` starten (`cd Output`) bzw. `--resource-path` auf diesen Ordner setzen; der `_images`-Ordner muss daneben liegen | | Gescannter Text fehlt, Meldung „Hinweis: … Bildseite(n)“ | die Seite wurde für ein Foto gehalten → `--ocr always` | | Satz als Überschrift oder Kapitel ohne Überschrift | in Weg B von Hand korrigieren (Abschnitt 3) | +| Überschrift besteht nur aus einer Zahl (`## 90`, `## XX`) | meist Fußnotenzeichen oder Seitenzahl → in Weg B löschen | +| Buchstaben fehlen („Offzielle“, „treff ch“) | Ligatur (fi, fl) ohne Textzuordnung in der PDF; von Hand korrigieren | +| Überschrift gesperrt („M e i n k l e i nes Stück“) | Buchstaben einzeln gesetzt in der PDF; von Hand korrigieren | +| Formeln erscheinen als Bilder | bei Mathebüchern normal; dafür ist das Programm nicht gebaut | +| Alter Scan: falsche Überschriften, viele Lesefehler | `--ocr always` (vorhandene OCR-Ebene ignorieren) | | Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren | | EPUB zu groß | `--image-format jpg` (Standard) verwenden; PNG ist ca. 3× größer | | Meine Änderungen in der `.md` sind weg | Schritt 1 wurde erneut ausgeführt → vorher Kopie anlegen | diff --git a/README.md b/README.md index 1e5eb3d..175584a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # 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. Funktioniert für **digitale** PDFs (Textebene vorhanden) -ebenso wie für **gescannte** PDFs (nur Bilder, per OCR) und für Scans, die bereits eine -OCR-Ebene haben (z. B. von `ocrmypdf`). +Werkzeug, um PDF-Bücher in sauberes Markdown und anschließend in ein EPUB für den +PocketBook-Reader umzuwandeln – mit Überschriften, Bildern und echten Absätzen. Funktioniert +für **digitale** PDFs (Textebene vorhanden) ebenso wie für **gescannte** PDFs (nur Bilder, +per OCR) und für Scans, die bereits eine OCR-Ebene haben (z. B. von `ocrmypdf`). + +Entwickelt an *Operation Ljutsch* (Band 1 und 2), außerdem erprobt an einem deutschen +Sachbuch, einem englischen Handbuch, einem Mathematikbuch, einem Bericht mit Fußnoten und +einem englischen Scan von 1893 (Ergebnisse siehe [Grenzen](#grenzen)). ## Installation (Ubuntu 24.04) @@ -29,6 +32,9 @@ python3 pdf2md.py --epub buch.pdf buch.md # Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse python3 pdf2md.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md + +# englisches Buch; alter Scan mit schlechter OCR-Ebene neu erkennen +python3 pdf2md.py --epub --lang en --ocr-language eng --ocr always scan.pdf scan.md ``` | Option | Bedeutung | @@ -49,10 +55,13 @@ python3 pdf2md.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md (`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter). Auf Scans werden Absätze am Einzug erkannt. - **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit - Bindestrich, `Arbeits- und` bleibt. + Bindestrich, `Arbeits- und` bleibt. Weiche Trennstriche (U+00AD) und `¬` am Zeilenende + werden ebenfalls aufgelöst (`wach­|senden` → `wachsenden`). - **Überschriften:** Ebenen aus den PDF-Lesezeichen (Teile `##`, Kapitel `###`); Satzreste, die fälschlich als Überschrift erkannt wurden, kommen zurück in den Text. -- **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt. +- **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt; liegt das Verzeichnis + als Tabelle vor, bleibt die Tabelle erhalten. +- **OCR-Ebenen** in Courier werden nicht als Programmcode (`` `…` ``) gesetzt. - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. ## Tests @@ -66,12 +75,21 @@ synthetisch erzeugt. ## Grenzen -- Die Ergebnisqualität hängt von pymupdf4llm/pymupdf-layout ab. Zwei Schwächen der - 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 - übergroßer Glyphenbox kann pymupdf4llm einzelne Zeilen verlieren. Bei den Ljutsch-Bänden - tritt das nicht auf (geprüft per Wortabgleich PDF ↔ Markdown). -- OCR-Fehler (z. B. fehlende Leerzeichen) werden nicht korrigiert. +- Die Ergebnisqualität hängt von pymupdf4llm/pymupdf-layout ab. Einige Schwächen der + Bibliothek gleicht `pdf2md.py` per Patch aus (Bindestriche und weiche Trennstriche am + Zeilenende, Absätze in Scan-Blöcken, OCR-Ebenen in Courier). Eine weitere ist bekannt: + Bei Schriften mit Ligaturen (ff, fi) mit übergroßer Glyphenbox kann pymupdf4llm einzelne + Zeilen verlieren. Bei den Ljutsch-Bänden tritt das nicht auf (geprüft per Wortabgleich + PDF ↔ Markdown). +- OCR-Fehler (z. B. fehlende Leerzeichen) werden nicht korrigiert. Bei alten Scans mit + mitgelieferter OCR-Ebene liefert `--ocr always` meist deutlich besseren Text. +- **Mathematische Formeln** werden als Bilder übernommen; für Mathebücher ist ein anderes + Werkzeug besser geeignet (z. B. Marker, Nougat). +- **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“), + gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen + (alle Kapitel auf einer Ebene). +- Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen). + Weitere bekannte Schwächen: [TODO.md](TODO.md). - Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB. ## Dateien diff --git a/TODO.md b/TODO.md index 23588e6..81e8f2f 100644 --- a/TODO.md +++ b/TODO.md @@ -61,3 +61,14 @@ wieder zusammengesetzt = Gesamt-Markdown. ausführen; Titel, Autor und Cover automatisch übernehmen. - JPEG-Qualität einstellbar machen (z. B. `--jpeg-quality 85`), damit das EPUB noch kleiner wird. - Remote für das Git-Repo einrichten (z. B. GitHub), damit die Commits gesichert sind. + +## 3. Bekannte Schwächen (aus Probeläufen mit fremden PDFs) + +- Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften + („## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“ + echte Kapitelnummern. Möglicher Ansatz: Zahl-Überschrift nur behalten, wenn direkt eine + Überschrift folgt. +- Römische Seitenzahlen („## XX“, „## XXIV“) werden nicht als Seitenzahl erkannt; Vorsicht + wegen echter Kapitel „I.“, „II.“. +- Gesperrte Überschriften („M e i n k l e i nes Stück D D R“) bleiben zerrissen. +- Alte Scans mit eigener OCR-Ebene: `--ocr always` liefert meist besseren Text. diff --git a/pdf2md.py b/pdf2md.py index 29bd0ee..c60685a 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -303,6 +303,7 @@ def normalize_markdown( fence_length = 0 in_contents = False contents_seen: set[str] = set() # Titel, die im Inhaltsverzeichnis standen + contents_table = False # Inhaltsverzeichnis liegt als Markdown-Tabelle vor headings_seen: set[str] = set() def paragraph_open() -> bool: @@ -375,6 +376,7 @@ def normalize_markdown( if CONTENTS_TITLE.match(text): heading(len(match.group(1)) if match else 2, text) in_contents = True + contents_table = False continue if match: @@ -408,7 +410,21 @@ def normalize_markdown( continue if in_contents: - if ends_sentence(text) and len(text) > 80: + if stripped.startswith("|"): + # Verzeichnis als Tabelle: Zeilen unverändert übernehmen, + # sonst zerfällt die Tabelle in einzelne Absätze. + if not contents_table: + flush_paragraph() + blank_line() + contents_table = True + for cell in stripped.strip("|").split("|"): + contents_seen.add(norm_title(cell)) + result.append(line) + continue + if contents_table: + in_contents = False # Tabelle zu Ende: Fließtext folgt + blank_line() + elif ends_sentence(text) and len(text) > 80: in_contents = False # erster Fließtext nach dem Verzeichnis else: for entry in split_contents_line(stripped, titles): diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 747bf12..7097ad4 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -217,6 +217,22 @@ def test_contents_block_keeps_entries_and_headings_follow(): ) +def test_contents_table_stays_table_and_contents_end_after_it(): + outline = {"vorwort": 1, "der staat": 1} + md = ( + "## Inhaltsverzeichnis\n\n" + "|Vorwort|3|\n|---|---|\n|Der Staat|19|\n\n" + "Dieser Bericht steht unter CC BY-SA 4.0 (Lizenz)\n\n" + "Vorwort\n\nDer Text beginnt hier.\n" + ) + assert norm(md, outline) == ( + "## Inhaltsverzeichnis\n\n" + "|Vorwort|3|\n|---|---|\n|Der Staat|19|\n\n" + "Dieser Bericht steht unter CC BY-SA 4.0 (Lizenz)\n\n" + "# Vorwort\n\nDer Text beginnt hier.\n" + ) + + def test_heading_formatted_contents_entries_stay_plain(): outline = {"1 der notar": 3} md = "## Inhaltsverzeichnis\n\n## 1 Der Notar\n\n## 1 Der Notar\n\nText.\n" From 619421ef10f94cddf2bf00d68d5520e409485b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 15:07:47 +0200 Subject: [PATCH 05/10] URLs als anklickbare Links, zerbrochene URLs reparieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - load_link_uris: Linkziele aus den Link-Annotationen der PDF lesen - link_urls: URLs als Autolink schreiben; beim Zeilenumbruch zerbrochene URLs mit den Linkzielen abgleichen (tolerant gegenüber Bindestrichen, Leerraum, "/" am Ende, Abfrageteil, .html) und utm-Parameter entfernen; ohne Linkziel aus dem gedruckten Text zusammensetzen - Bindestrich am Zeilenende innerhalb einer URL bleibt erhalten - pandoc liest mit +autolink_bare_uris - Tests, README, Bedienungsanleitung und TODO ergänzt Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 4 +- README.md | 6 ++ TODO.md | 4 ++ pdf2md.py | 135 +++++++++++++++++++++++++++++++++++++++- tests/test_normalize.py | 75 ++++++++++++++++++++++ 5 files changed, 221 insertions(+), 3 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b4d25c5..088c461 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -97,6 +97,7 @@ Wenn du kein Cover und kein Probe-EPUB brauchst, reicht `--images` statt `--epub | Zeilenumbruch innerhalb eines Absatzes | einfacher Umbruch | wird zu einem Leerzeichen | | Bild | `![](band1_images/…jpg)` auf eigener Zeile | Bild an dieser Stelle | | kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett | +| Link | `` oder `[Text](https://beispiel.de/seite)` | anklickbarer Link | Wichtig: @@ -134,7 +135,7 @@ Im Ordner der Markdown-Datei starten (wichtig für die Bilder): ```bash cd ~/Downloads/ljutsch/Output pandoc band1.md \ - --from markdown-yaml_metadata_block --to epub3 \ + --from markdown-yaml_metadata_block+autolink_bare_uris --to epub3 \ --output band1.epub \ --resource-path . \ --split-level=3 --toc-depth=3 \ @@ -239,6 +240,7 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g | Überschrift besteht nur aus einer Zahl (`## 90`, `## XX`) | meist Fußnotenzeichen oder Seitenzahl → in Weg B löschen | | Buchstaben fehlen („Offzielle“, „treff ch“) | Ligatur (fi, fl) ohne Textzuordnung in der PDF; von Hand korrigieren | | Überschrift gesperrt („M e i n k l e i nes Stück“) | Buchstaben einzeln gesetzt in der PDF; von Hand korrigieren | +| Link führt ins Leere oder ist abgeschnitten | URL in der PDF nachsehen und in `<…>` korrigieren; manche PDFs enthalten selbst abgeschnittene Linkziele | | Formeln erscheinen als Bilder | bei Mathebüchern normal; dafür ist das Programm nicht gebaut | | Alter Scan: falsche Überschriften, viele Lesefehler | `--ocr always` (vorhandene OCR-Ebene ignorieren) | | Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren | diff --git a/README.md b/README.md index 175584a..6a1005c 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,11 @@ python3 pdf2md.py --epub --lang en --ocr-language eng --ocr always scan.pdf scan - **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt; liegt das Verzeichnis als Tabelle vor, bleibt die Tabelle erhalten. - **OCR-Ebenen** in Courier werden nicht als Programmcode (`` `…` ``) gesetzt. +- **Links:** URLs werden als `` geschrieben und sind damit in Markdown-Viewern + und im EPUB anklickbar. Beim Zeilenumbruch zerbrochene URLs (`deutsche-|sehen`, + `…/ youtube-…`) werden mit den Linkzielen der PDF abgeglichen und repariert; + Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird die URL aus dem gedruckten + Text zusammengesetzt. - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. ## Tests @@ -88,6 +93,7 @@ synthetisch erzeugt. - **Was am PDF liegt, bleibt:** fehlende Ligaturen ohne Textzuordnung („Offzielle“), gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen (alle Kapitel auf einer Ebene). +- Links mit anderem Text als der URL (z. B. „hier“) werden nicht übernommen. - Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen). Weitere bekannte Schwächen: [TODO.md](TODO.md). - Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB. diff --git a/TODO.md b/TODO.md index 81e8f2f..606bff7 100644 --- a/TODO.md +++ b/TODO.md @@ -72,3 +72,7 @@ wieder zusammengesetzt = Gesamt-Markdown. wegen echter Kapitel „I.“, „II.“. - Gesperrte Überschriften („M e i n k l e i nes Stück D D R“) bleiben zerrissen. - Alte Scans mit eigener OCR-Ebene: `--ocr always` liefert meist besseren Text. +- Links: Setzt sich eine URL in einem neuen Block fort (z. B. als Listenpunkt „- juuuport-…“), + bleibt sie abgeschnitten. Links mit eigenem Text („hier“) gehen verloren; Abhilfe wäre, + die Link-Rechtecke der PDF dem Text zuzuordnen (`[hier](…)`). Die Heuristik ohne Linkziel + hängt Wörter mit „/“ an („https://a.de/ und/oder“ → eine URL). diff --git a/pdf2md.py b/pdf2md.py index c60685a..a848de4 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -21,6 +21,7 @@ import re import shutil import subprocess import sys +import urllib.parse from collections import Counter from concurrent.futures import ProcessPoolExecutor from pathlib import Path @@ -487,6 +488,131 @@ def load_outline(doc: pymupdf.Document) -> dict[str, int]: return levels +# =========================================================================== +# Links +# =========================================================================== +# pymupdf4llm übernimmt keine Link-Annotationen, und gedruckte URLs zerbrechen +# beim Zeilenumbruch ("deutsche-|sehen" -> "deutschesehen", ".../ youtube-..."). +# Wir gleichen URLs im Text mit den Linkzielen der PDF ab, ersetzen sie durch +# das korrekte Ziel und schreiben sie als Autolink , den pandoc im +# EPUB als echten Link ausgibt. + +URL_START = re.compile( + r"(?-Links und [Text](…)-Zielen +URL_TRAILING = ".,;:!?)]}»«“”„\"'*_" +MAX_URL_PARTS = 4 # eine URL kann über so viele Zeilen gebrochen sein +# Ohne Linkziel: Folgewort gehört zur URL, wenn diese an einer typischen +# Umbruchstelle endet und das Wort wie ein URL-Teil aussieht. +URL_BREAK_AFTER = "/_-.=&?#" +URL_CONTINUATION = re.compile(r"^(?:\S*/\S*|(?:html?|php|pdf|aspx?)\W*)$", re.IGNORECASE) + + +def url_key(text: str) -> str: + """Vergleichsschlüssel: Bindestriche, Leerraum und "/" am Ende zählen nicht.""" + return re.sub(r"[-\s]", "", text).rstrip("/").lower() + + +def uri_keys(uri: str) -> set[str]: + """Schlüssel, unter denen ein Linkziel im gedruckten Text auftauchen kann. + + Gedruckt fehlen oft Abfrageteil ("?utm_source=…") oder Endung (".html"). + """ + base = uri.split("#")[0] + keys = set() + for variant in (uri, base, base.split("?")[0]): + keys.add(url_key(variant)) + keys.add(url_key(re.sub(r"\.(?:html?|php|aspx?)$", "", variant.rstrip("/")))) + return keys + + +def clean_uri(uri: str) -> str: + """Tracking-Parameter (utm_…) entfernen.""" + parts = urllib.parse.urlsplit(uri) + if "utm_" not in parts.query.lower(): + return uri + query = [ + (k, v) for k, v in urllib.parse.parse_qsl(parts.query, keep_blank_values=True) + if not k.lower().startswith("utm_") + ] + return urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query))) + + +def load_link_uris(doc: pymupdf.Document) -> list[str]: + """Web-Linkziele der PDF (Link-Annotationen), ohne Dubletten.""" + uris: dict[str, None] = {} + for page in doc: + for link in page.get_links(): + uri = (link.get("uri") or "").strip() + if re.match(r"https?://", uri, re.IGNORECASE): + uris.setdefault(uri.replace(" ", "%20")) + return list(uris) + + +def _autolink(url: str) -> str: + if url.lower().startswith("www."): + return f"[{url}](https://{url})" + return f"<{url}>" + + +def _match_known(tokens: list[str], known: dict[str, str]) -> tuple[str, int] | None: + """Längste Verbindung von Wörtern, die zu einem Linkziel der PDF passt.""" + for count in range(min(MAX_URL_PARTS, len(tokens)), 0, -1): + parts = [t.rstrip() for t in tokens[:count]] + joined = "".join(parts) + consumed = sum(map(len, tokens[: count - 1])) + len(parts[-1]) + for strip in range(len(joined)): + candidate = joined[: len(joined) - strip] + if uri := known.get(url_key(candidate)): + return uri, consumed - strip + if candidate[-1] not in URL_TRAILING: + break + return None + + +def _match_printed(tokens: list[str]) -> tuple[str, int]: + """URL ohne (passendes) Linkziel: aus dem gedruckten Text zusammensetzen.""" + parts = [tokens[0].rstrip()] + for token in tokens[1:MAX_URL_PARTS]: + if parts[-1][-1] in URL_BREAK_AFTER and URL_CONTINUATION.match(token.rstrip()): + parts.append(token.rstrip()) + else: + break + joined = "".join(parts) + url = joined.rstrip(URL_TRAILING) + consumed = sum(map(len, tokens[: len(parts) - 1])) + len(parts[-1]) + return url, consumed - (len(joined) - len(url)) + + +def _link_line(line: str, known: dict[str, str]) -> str: + out = [] + pos = 0 + while match := URL_START.search(line, pos): + out.append(line[pos:match.start()]) + tokens = re.findall(r"\S+\s*", line[match.start():]) + url, consumed = _match_known(tokens, known) or _match_printed(tokens) + out.append(_autolink(url) if "." in url else line[match.start():][:consumed]) + pos = match.start() + consumed + out.append(line[pos:]) + return "".join(out) + + +def link_urls(markdown: str, uris: list[str] | None = None) -> str: + """URLs im Fließtext anklickbar machen und mit PDF-Linkzielen reparieren.""" + known: dict[str, str] = {} + for uri in uris or []: + for key in uri_keys(uri): + known.setdefault(key, clean_uri(uri)) + lines = markdown.split("\n") + in_fence = False + for i, line in enumerate(lines): + if FENCE.match(line): + in_fence = not in_fence + elif not in_fence and not IMAGE.match(line): + lines[i] = _link_line(line, known) + return "\n".join(lines) + + # =========================================================================== # Korrektur für pymupdf4llm: Trennstriche am Zeilenende, OCR-Ebenen # =========================================================================== @@ -522,7 +648,10 @@ def _mark_line_end_hyphens(spans: list[dict]) -> list[dict]: span = {**span, "text": text[:-1] + "-"} elif line_end and text.endswith("-"): word = FIRST_WORD.match(nxt["text"].lstrip()) - if word and word.group(0).lower() in CONJUNCTIONS: + last = text.split()[-1] + if URL_START.search(last) or "/" in last: + span = {**span, "text": text + KEEP_HYPHEN} # URLs trennt niemand + elif word and word.group(0).lower() in CONJUNCTIONS: span = {**span, "text": text + KEEP_HYPHEN_SPACE} elif word and word.group(0)[0].isupper(): span = {**span, "text": text + KEEP_HYPHEN} @@ -945,7 +1074,7 @@ def write_epub( cmd = [ pandoc, str(markdown_path), - "--from", "markdown-yaml_metadata_block", + "--from", "markdown-yaml_metadata_block+autolink_bare_uris", "--to", "epub3", "--output", str(epub_path), "--resource-path", str(markdown_path.parent), @@ -1075,6 +1204,7 @@ def main() -> int: with pymupdf.open(source) as doc: kinds, plan = plan_pages(doc, args.ocr, args.ocr_language, args.ocr_dpi) outline = load_outline(doc) + uris = load_link_uris(doc) page_count = doc.page_count title = (doc.metadata or {}).get("title") or source.stem author = (doc.metadata or {}).get("author") or "" @@ -1124,6 +1254,7 @@ def main() -> int: drop_running_heads=not args.keep_headers, running_head_min=max(3, page_count // 10), ) + markdown = link_urls(markdown, uris) target.write_text(markdown, encoding="utf-8") if epub_path: diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 7097ad4..2f71234 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -326,3 +326,78 @@ def test_make_image_links_relative(tmp_path, monkeypatch): "![](buch_images/a.jpg)\n\n![](buch_images/b.jpg)\n\n" "![](https://example.org/c.jpg)\n" ) + + +# --- Links ------------------------------------------------------------------------- + +def test_link_urls_repairs_broken_urls_with_pdf_targets(): + uris = [ + "https://www.theeuropean.de/insa-umfrage-deutsche-sehen/", + "https://www.fsm.de/en/fsm/netzdg/", + ] + md = ( + "Quelle: https://www.theeuropean.de/insa-umfrage-deutschesehen/.\n" + "Siehe https://www.fsm.de/en/fsm/ netzdg/. Danach Text.\n" + ) + assert p.link_urls(md, uris) == ( + "Quelle: .\n" + "Siehe . Danach Text.\n" + ) + + +def test_link_urls_without_pdf_targets(): + md = "Mehr unter https://example.org/a_b, www.example.org und (https://x.de/y).\n" + assert p.link_urls(md) == ( + "Mehr unter , [www.example.org](https://www.example.org)" + " und ().\n" + ) + + +def test_link_urls_leaves_images_and_existing_links_alone(): + md = "![](bilder/x.jpg)\n\n und [b](https://b.de/)\n" + assert p.link_urls(md) == md + + +def test_pymupdf4llm_patch_keeps_hyphen_inside_url(): + spans = [ + {"text": "Siehe https://a.de/umfrage-deutsche-", "flags": 0, "char_flags": 16, + "font": "x", "block": 0, "line": 0}, + {"text": "sehen/ und mehr.", "flags": 0, "char_flags": 16, "font": "x", + "block": 0, "line": 1}, + ] + output, _ = p.document_layout.get_styled_text(spans) + assert output.strip() == "Siehe https://a.de/umfrage-deutsche-sehen/ und mehr." + + +def test_load_link_uris(): + doc = pymupdf.open() + page = doc.new_page() + for uri in ("https://a.de/x", "mailto:a@b.de", "https://a.de/x"): + page.insert_link({"kind": pymupdf.LINK_URI, "from": pymupdf.Rect(0, 0, 10, 10), + "uri": uri}) + doc = pymupdf.open("pdf", doc.tobytes()) + assert p.load_link_uris(doc) == ["https://a.de/x"] + + +def test_link_urls_tolerates_printed_variants(): + uris = [ + "https://a.de/artikel?utm_source=chatgpt.com", + "https://b.de/news/Gesetz-Kritik-3718401.html", + "https://c.de/seite", + ] + md = "Siehe https://a.de/artikel/, https://b.de/news/ Gesetz-Kritik-3718401 und https://c.de/seite/.\n" + assert p.link_urls(md, uris) == ( + "Siehe , und " + ".\n" + ) + + +def test_link_urls_joins_printed_continuation_without_pdf_target(): + md = ( + "Siehe https://a.org/digital_ dispatches/towards-x/. und https://b.de/ " + "Docs/artikel. html. Oder https://c.de/ und mehr.\n" + ) + assert p.link_urls(md) == ( + "Siehe . und " + ". Oder und mehr.\n" + ) From fa900505351df5548ca0a1683c00c39e98060673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 15:20:08 +0200 Subject: [PATCH 06/10] Bessere Standardwerte: EPUB-Sprache, Titel/Autor, Prozesszahl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --lang ohne Angabe aus der ersten OCR-Sprache ableiten (eng -> en) - Fehlen Titel/Autor in den PDF-Metadaten: aus dem Dateinamen nach dem Muster "Autor_-_Titel" übernehmen, sonst Dateiname mit Leerzeichen - --jobs: halbe Kernzahl, höchstens 8 (bisher 4) - Tests (tests/test_defaults.py), README und Bedienungsanleitung Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 15 ++++++++----- README.md | 9 ++++---- pdf2md.py | 48 ++++++++++++++++++++++++++++++++++----- tests/test_defaults.py | 51 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 tests/test_defaults.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 088c461..af9695b 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -65,10 +65,15 @@ Danach liegen in `Output/`: | :-- | :-- | | `band1.md` | das Buch als Markdown | | `band1_images/` | Bilder als JPEG, dazu `cover.jpg` (Seite 1 der PDF) | -| `band1.epub` | das fertige E-Book (Titel und Autor aus der PDF) | +| `band1.epub` | das fertige E-Book (Titel und Autor aus der PDF, siehe unten) | Ein Band mit ca. 700 Seiten dauert etwa anderthalb Minuten. +**Titel und Autor im EPUB** stammen aus den Metadaten der PDF. Fehlen sie, wertet das Programm +den Dateinamen nach dem Muster `Autor_-_Titel.pdf` aus: `Katja_Adler_-_Rolle_rückwärts.pdf` ergibt +Titel „Rolle rückwärts“ und Autor „Katja Adler“. Ohne ` - ` im Namen wird der ganze Dateiname +(mit Leerzeichen statt `_`) zum Titel. Es lohnt sich also, PDFs vorher passend umzubenennen. + --- ## 3. Weg B – Markdown von Hand bearbeiten, dann EPUB @@ -166,9 +171,9 @@ python3 pdf2md.py --epub scan.pdf Output/scan.md | :-- | :-- | | Buch in Deutsch (Standard) | – (`--ocr-language deu`) | | Deutsch und Englisch gemischt | `--ocr-language deu+eng` (Sprachpaket `tesseract-ocr-eng` nötig) | -| schneller auf vielen Kernen | `--jobs 8` (Standard 4) | +| mehr oder weniger parallele OCR-Prozesse | `--jobs N` (Standard: halbe Kernzahl, höchstens 8) | | OCR erzwingen, auch für vorhandene OCR-Ebenen | `--ocr always` | -| englisches Buch | `--ocr-language eng --lang en` (Sprachpaket `tesseract-ocr-eng` nötig) | +| englisches Buch | `--ocr-language eng` (Sprachpaket `tesseract-ocr-eng` nötig; EPUB-Sprache wird automatisch `en`) | | keine OCR | `--ocr never` oder `--no-ocr` | So entscheidet das Programm von selbst (`--ocr auto`): @@ -199,12 +204,12 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g | `--no-ocr` | Kurzform für `--ocr never` | | `--ocr-language` | Tesseract-Sprache(n), z. B. `deu+eng` (Standard `deu`) | | `--ocr-dpi` | Auflösung für die OCR (Standard 300) | -| `--jobs N` | parallele OCR-Prozesse (Standard 4) | +| `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8) | | `--images` | Bilder in `_images/` speichern und im Markdown verlinken | | `--image-format jpg\|png` | Bildformat (Standard `jpg`; `png` verlustfrei, aber ca. 3× größer) | | `--epub` | zusätzlich EPUB erzeugen (schaltet `--images` ein) | | `--epub-file PFAD` | anderer Pfad für das EPUB | -| `--lang` | Sprache im EPUB (Standard `de`) | +| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language` abgeleitet, `deu`→`de`, `eng`→`en`; sonst `de`) | | `--keep-headers` | Kolumnentitel/Seitenköpfe nicht entfernen | | `--keep-footers` | Seitenzahlen/Seitenfüße nicht entfernen | diff --git a/README.md b/README.md index 6a1005c..93e43e1 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,15 @@ dann EPUB“: **[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)**. # digitales oder gescanntes PDF -> Markdown (OCR wird automatisch entschieden) 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 bzw. dem Dateinamen +# "Autor_-_Titel.pdf", Seite 1 als Cover) python3 pdf2md.py --epub buch.pdf buch.md # Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse python3 pdf2md.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md # englisches Buch; alter Scan mit schlechter OCR-Ebene neu erkennen -python3 pdf2md.py --epub --lang en --ocr-language eng --ocr always scan.pdf scan.md +python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EPUB-Sprache: en ``` | Option | Bedeutung | @@ -42,11 +43,11 @@ python3 pdf2md.py --epub --lang en --ocr-language eng --ocr always scan.pdf scan | `--ocr auto` (Standard) | Scanseiten per OCR lesen; vorhandene, gerade OCR-Ebenen übernehmen, schiefe neu erkennen; reine Bildseiten in digitalen PDFs (Fotos, Cover) nur nach erfolgreicher Probe-OCR | | `--ocr always` / `--ocr never` (`--no-ocr`) | alle Seiten neu erkennen / keine OCR | | `--ocr-language`, `--ocr-dpi` | Tesseract-Sprache(n) (`deu`), Auflösung (300) | -| `--jobs N` | parallele OCR-Prozesse (Standard 4); reine Textdokumente laufen immer sequenziell | +| `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8); reine Textdokumente laufen immer sequenziell | | `--images` | Grafiken in `_images/` speichern und verlinken | | `--image-format jpg\|png` | Bildformat (Standard jpg; png verlustfrei, aber ca. 3× größer) | | `--epub`, `--epub-file PFAD` | EPUB 3 mit pandoc erzeugen (schaltet `--images` ein) | -| `--lang` | Sprache im EPUB (`de`) | +| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language`, `deu`→`de`, `eng`→`en`) | | `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen | ## Was die Nachbearbeitung macht diff --git a/pdf2md.py b/pdf2md.py index a848de4..7f81ff0 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -1057,6 +1057,39 @@ def heading_depth(markdown: str) -> int: return min(max(levels, default=1), 3) +# Tesseract-Sprachcode -> Sprachcode im EPUB (ISO 639-1) +EPUB_LANGUAGES = { + "deu": "de", "deu_latf": "de", "frk": "de", "eng": "en", "fra": "fr", + "spa": "es", "ita": "it", "nld": "nl", "por": "pt", "pol": "pl", + "ces": "cs", "dan": "da", "swe": "sv", "nor": "no", "fin": "fi", + "rus": "ru", "ukr": "uk", "lat": "la", "ell": "el", "tur": "tr", +} + + +def epub_language(ocr_language: str) -> str: + """EPUB-Sprache aus der (ersten) OCR-Sprache: "eng+deu" -> "en".""" + first = ocr_language.split("+")[0].strip().lower() + return EPUB_LANGUAGES.get(first, "de") + + +def default_jobs() -> int: + """Halbe Kernzahl, höchstens 8: Jeder Prozess lädt das Layout-Modell.""" + return max(1, min(8, (os.cpu_count() or 2) // 2)) + + +def title_author_from_filename(stem: str) -> tuple[str, str]: + """Titel und Autor aus einem Dateinamen nach dem Muster "Autor_-_Titel". + + "Katja_Adler_-_Rolle_rückwärts" -> ("Rolle rückwärts", "Katja Adler"); + ohne " - " ist der ganze Name der Titel. + """ + name = re.sub(r"\s+", " ", stem.replace("_", " ")).strip() + author, sep, title = name.partition(" - ") + if sep and author.strip() and title.strip(): + return title.strip(), author.strip() + return name, "" + + def write_epub( markdown_path: Path, epub_path: Path, @@ -1142,8 +1175,9 @@ def main() -> int: parser.add_argument( "--jobs", type=int, - default=min(4, os.cpu_count() or 1), - help="Anzahl paralleler Prozesse (Standard: %(default)s)", + default=default_jobs(), + help="Anzahl paralleler OCR-Prozesse (Standard: halbe Kernzahl, " + "höchstens 8; hier %(default)s)", ) parser.add_argument( "--images", @@ -1169,8 +1203,8 @@ def main() -> int: ) parser.add_argument( "--lang", - default="de", - help="Sprache im EPUB (Standard: de)", + help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, " + "deu -> de, eng -> en)", ) parser.add_argument( "--keep-headers", @@ -1183,6 +1217,7 @@ def main() -> int: help="Seitenfüße/Seitenzahlen übernehmen", ) args = parser.parse_args() + args.lang = args.lang or epub_language(args.ocr_language) source = args.pdf.expanduser() target = (args.markdown or source.with_suffix(".md")).expanduser() @@ -1206,8 +1241,9 @@ def main() -> int: outline = load_outline(doc) uris = load_link_uris(doc) page_count = doc.page_count - title = (doc.metadata or {}).get("title") or source.stem - author = (doc.metadata or {}).get("author") or "" + file_title, file_author = title_author_from_filename(source.stem) + title = ((doc.metadata or {}).get("title") or "").strip() or file_title + author = ((doc.metadata or {}).get("author") or "").strip() or file_author counts = Counter(kinds) print( diff --git a/tests/test_defaults.py b/tests/test_defaults.py new file mode 100644 index 0000000..848fd26 --- /dev/null +++ b/tests/test_defaults.py @@ -0,0 +1,51 @@ +"""Standardwerte: EPUB-Sprache, Prozesszahl, Titel/Autor aus dem Dateinamen.""" + +import zipfile + +import pymupdf +import pytest + +import pdf2md as p +from conftest import needs_pandoc +from test_e2e import run + + +@pytest.mark.parametrize( + "ocr_language, expected", + [("deu", "de"), ("eng", "en"), ("eng+deu", "en"), ("fra", "fr"), ("xyz", "de")], +) +def test_epub_language_follows_ocr_language(ocr_language, expected): + assert p.epub_language(ocr_language) == expected + + +@pytest.mark.parametrize("cores, expected", [(None, 1), (1, 1), (4, 2), (24, 8)]) +def test_default_jobs(monkeypatch, cores, expected): + monkeypatch.setattr(p.os, "cpu_count", lambda: cores) + assert p.default_jobs() == expected + + +@pytest.mark.parametrize( + "stem, expected", + [ + ("Katja_Adler_-_Rolle_rückwärts_DDR", ("Rolle rückwärts DDR", "Katja Adler")), + ("Zensur_Netzwerk_Deutschland_de-report-de", ("Zensur Netzwerk Deutschland de-report-de", "")), + ("A_-_B_-_C", ("B - C", "A")), + ], +) +def test_title_author_from_filename(stem, expected): + assert p.title_author_from_filename(stem) == expected + + +@needs_pandoc +def test_epub_without_metadata_uses_filename_and_ocr_language(pdfs, tmp_path): + doc = pymupdf.open(pdfs["digital"]) + doc.set_metadata({}) + source = tmp_path / "Max_Muster_-_Ein_Buch.pdf" + doc.save(source) + + run(source, tmp_path / "buch.md", "--epub", "--ocr-language", "eng") + with zipfile.ZipFile(tmp_path / "buch.epub") as z: + opf = z.read("EPUB/content.opf").decode() + assert ">Ein Buch<" in opf + assert ">Max Muster<" in opf + assert "en" in opf From d322ee3523f6acba06c45448c36fb5b5d427e5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 16:39:06 +0200 Subject: [PATCH 07/10] =?UTF-8?q?Zeilengetreue=20=C3=9Cbernahme=20von=20Co?= =?UTF-8?q?de,=20Registern,=20Versen=20und=20Adressen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Zeilenumbruch gilt als weich (vom Satzprogramm), wenn das erste Wort der nächsten Zeile nicht mehr gepasst hätte (Toleranz 5 % der Spalte); sonst als hart, und die Zeile bleibt samt Einrückung erhalten. - lines_to_md (Patch von text_to_md): harte/weiche Umbrüche aus der Geometrie; Blöcke mit überwiegend harten Umbrüchen zeilengetreu als Codeblock (auch Proportionalschrift, Einrückung rekonstruiert) oder als Zeilen mit "\" und geschützten Leerzeichen - Blockgrenzen: freie Zeichen der letzten Zeile werden mitgegeben, normalize_markdown prüft damit das erste Wort des nächsten Blocks - normalize_markdown: harte Umbrüche, Absatzenden ohne Satzzeichen, Codeblöcke über Seitengrenzen zusammenführen, wörtliches ``` maskieren - EPUB-CSS: lange Codezeilen umbrechen (white-space: pre-wrap) - Tests (tests/test_verbatim.py), README, Bedienungsanleitung, CLAUDE.md, TODO.md Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 5 + CLAUDE.md | 13 +- README.md | 15 +- TODO.md | 4 + pdf2md.py | 382 +++++++++++++++++++++++++++++++++++++++-- tests/test_verbatim.py | 143 +++++++++++++++ 6 files changed, 545 insertions(+), 17 deletions(-) create mode 100644 tests/test_verbatim.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index af9695b..690c3c6 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -100,6 +100,9 @@ Wenn du kein Cover und kein Probe-EPUB brauchst, reicht `--images` statt `--epub | Kapitel | `### 1 Der Notar` | eigenes Kapitel (eigene Datei im EPUB), unter dem Teil eingeordnet | | Absatz | durch **eine Leerzeile** vom nächsten getrennt | neuer Absatz | | Zeilenumbruch innerhalb eines Absatzes | einfacher Umbruch | wird zu einem Leerzeichen | +| Zeile erzwingen (Register, Vers, Adresse) | `\` am Zeilenende | neue Zeile im selben Absatz | +| Einrückung außerhalb von Code | geschützte Leerzeichen (U+00A0) am Zeilenanfang | bleibt erhalten (normale Leerzeichen verschwinden) | +| Code | zwischen zwei Zeilen ```` ``` ````, Einrückung mit Leerzeichen | Schreibmaschinenschrift, Einrückung bleibt, lange Zeilen werden umbrochen | | Bild | `![](band1_images/…jpg)` auf eigener Zeile | Bild an dieser Stelle | | kursiv / fett | `_Wort_` / `**Wort**` | kursiv / fett | | Link | `` oder `[Text](https://beispiel.de/seite)` | anklickbarer Link | @@ -246,6 +249,8 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g | Buchstaben fehlen („Offzielle“, „treff ch“) | Ligatur (fi, fl) ohne Textzuordnung in der PDF; von Hand korrigieren | | Überschrift gesperrt („M e i n k l e i nes Stück“) | Buchstaben einzeln gesetzt in der PDF; von Hand korrigieren | | Link führt ins Leere oder ist abgeschnitten | URL in der PDF nachsehen und in `<…>` korrigieren; manche PDFs enthalten selbst abgeschnittene Linkziele | +| Register, Verse oder Code zu einem Absatz verklebt | Zeilen in Weg B mit `\` am Ende trennen bzw. Code in ```` ``` ```` einschließen; das Programm erkennt nur Umbrüche, bei denen das nächste Wort noch in die Zeile gepasst hätte | +| Fließtext zerfällt in einzelne Zeilen mit `\` | in Weg B die `\` am Zeilenende entfernen | | Formeln erscheinen als Bilder | bei Mathebüchern normal; dafür ist das Programm nicht gebaut | | Alter Scan: falsche Überschriften, viele Lesefehler | `--ocr always` (vorhandene OCR-Ebene ignorieren) | | Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren | diff --git a/CLAUDE.md b/CLAUDE.md index 53f8adc..bd80d49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,13 @@ Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnu - Hilfetexte, Meldungen, Kommentare und Docstrings auf Deutsch (wie v4); Bezeichner Englisch. - Echttests nie nach `Fertig/` schreiben (dort liegen die Ergebnisse des Nutzers). - PDFs/EPUBs/Bilder sind per `.gitignore` ausgeschlossen – keine Binärdateien committen. -- `pdf2md.py` patcht zwei Funktionen in `pymupdf4llm.helpers.document_layout` - (`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. +- `pdf2md.py` patcht drei Funktionen in `pymupdf4llm.helpers.document_layout` + (`get_styled_text`, `text_to_md`, `create_list_item_levels` – letztere nur, um pro Seite + die Zeilengeometrie zu merken). Nach einem pymupdf4llm-Update prüfen, ob + `tests/test_normalize.py::test_pymupdf4llm_patch_keeps_compound_hyphen` und + `tests/test_verbatim.py` noch grün sind. +- Zwischen dem Patch (`lines_to_md`) und `normalize_markdown` laufen interne Markierungen + aus dem Private-Use-Bereich (`HARD_BREAK`, `BLOCK_END`, `PARAGRAPH_END`, `STANZA_BREAK`, + U+E002–U+E005); sie dürfen nie in der Ausgabe landen. +- Regressionsprüfung bei Änderungen am Umbruch: Band 1/2 vorher/nachher diffen; Fließtext + darf sich nur an echten Absatz- oder Zeilenwechseln der PDF ändern. diff --git a/README.md b/README.md index 93e43e1..5debf81 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,15 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP ## Was die Nachbearbeitung macht -- **Absätze:** Zeilen werden verbunden; eine Absatzgrenze gilt nur nach Satzende - (`.` `!` `?` `…`, auch mit `«` `“` `»` usw. dahinter). Auf Scans werden Absätze am - Einzug erkannt. +- **Fließtext oder zeilengetreu:** Ein Zeilenumbruch gilt als *weich* (vom Satzprogramm), + wenn das erste Wort der nächsten Zeile nicht mehr in die Zeile gepasst hätte – dann + werden die Zeilen zu Fließtext verbunden. Sonst hat ihn der Autor gesetzt (*hart*), und + die Zeile bleibt samt Einrückung erhalten. So bleiben **Code** (auch in Proportionalschrift, + als ```-Block mit Einrückung), **Register, Glossare, Verse, Adressen, Lebensläufe** zeilengetreu + (Markdown: `\` am Zeilenende, Einrückung als geschütztes Leerzeichen). Codeblöcke über + eine Seitengrenze werden zusammengeführt; lange Codezeilen bricht der Reader um. +- **Absätze:** eine Absatzgrenze gilt nach Satzende (`.` `!` `?` `…`, auch mit `«` `“` `»` + usw. dahinter) oder an einem harten Umbruch; auf Scans werden Absätze am Einzug erkannt. - **Silbentrennung:** `Geheim-|dienst` → `Geheimdienst`, `Militär-|Geheimdienst` bleibt mit Bindestrich, `Arbeits- und` bleibt. Weiche Trennstriche (U+00AD) und `¬` am Zeilenende werden ebenfalls aufgelöst (`wach­|senden` → `wachsenden`). @@ -95,6 +101,9 @@ synthetisch erzeugt. gesperrt gesetzte Überschriften („M e i n k l e i nes …“), flache PDF-Gliederungen (alle Kapitel auf einer Ebene). - Links mit anderem Text als der URL (z. B. „hier“) werden nicht übernommen. +- Zeilengetreue Erkennung: Verszeilen mit Satzzeichen am Ende werden zu eigenen Absätzen + statt zu Zeilen; einzelne Konfigurations- oder Ausgabezeilen neben Code (`[dependencies]`) + stehen als Zeilen statt im Codeblock. - Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen). Weitere bekannte Schwächen: [TODO.md](TODO.md). - Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB. diff --git a/TODO.md b/TODO.md index 606bff7..e397e31 100644 --- a/TODO.md +++ b/TODO.md @@ -72,6 +72,10 @@ wieder zusammengesetzt = Gesamt-Markdown. wegen echter Kapitel „I.“, „II.“. - Gesperrte Überschriften („M e i n k l e i nes Stück D D R“) bleiben zerrissen. - Alte Scans mit eigener OCR-Ebene: `--ocr always` liefert meist besseren Text. +- Zeilengetreu: Verszeilen mit Satzzeichen am Ende werden zu Absätzen (split_by_indent + wertet kurze Zeile + Satzende als Absatzende); Codefragmente ohne Code-Merkmale + (`[dependencies]`, Programmausgaben) landen als `\`-Zeilen neben dem Codeblock statt darin. +- Markdown-Sonderzeichen werden nicht maskiert: `__add__` im Register wird fett gesetzt. - Links: Setzt sich eine URL in einem neuen Block fort (z. B. als Listenpunkt „- juuuport-…“), bleibt sie abgeschnitten. Links mit eigenem Text („hier“) gehen verloren; Abhilfe wäre, die Link-Rechtecke der PDF dem Text zuzuordnen (`[hier](…)`). Die Heuristik ohne Linkziel diff --git a/pdf2md.py b/pdf2md.py index 7f81ff0..a9feea7 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -21,6 +21,7 @@ import re import shutil import subprocess import sys +import tempfile import urllib.parse from collections import Counter from concurrent.futures import ProcessPoolExecutor @@ -319,6 +320,8 @@ def normalize_markdown( text = paragraph[0] for part in paragraph[1:]: text = join_parts(text, part) + if result and result[-1].endswith(HARD_BREAK): + result.append("") # Fließtext nach zeilengetreuen Zeilen result.append(text) paragraph.clear() @@ -332,9 +335,13 @@ def normalize_markdown( headings_seen.add(norm_title(text)) standalone("#" * min(max(level, 1), 6) + " " + text) - for original in lines: + for index, original in enumerate(lines): line = original.rstrip() marker = FENCE.match(line) + if marker and not in_fence and MARKERS.search(line): + # Text aus einem Absatz, der mit ``` beginnt: kein Codezaun. + line = "\\" + line + marker = None if in_fence: result.append(line) @@ -346,14 +353,81 @@ def normalize_markdown( if marker: flush_paragraph() - blank_line() - result.append(line) in_fence = True fence_char = marker.group(1)[0] fence_length = len(marker.group(1)) + # Listing über eine Seitengrenze: an den vorigen Codeblock anhängen. + while result and result[-1] == "": + result.pop() + if result and result[-1] == line.strip() and line.strip() == fence_char * fence_length: + result.pop() + continue + blank_line() + result.append(line) continue - if not line.strip(): + if end_mark := BLOCK_END_MARK.search(line): + # Blockende ohne Satzende: Hätte das erste Wort des nächsten Blocks + # noch in die Zeile gepasst, war der Umbruch hart. + line = line[: end_mark.start()] + if not continues_block(lines[index + 1:], int(end_mark.group(1)), running_heads): + # Mehrzeiliger Absatz: Absatzende; einzelne Zeile: harter Umbruch + line += PARAGRAPH_END if end_mark.group(2) else HARD_BREAK + + if line.endswith(PARAGRAPH_END): + content = line.removesuffix(PARAGRAPH_END) + if not paragraph and result and result[-1].endswith(HARD_BREAK): + result.append(content) # letzte Zeile eines zeilengetreuen Blocks + else: + if not paragraph_open(): + flush_paragraph() + blank_line() + paragraph.append(content.strip()) + flush_paragraph() + blank_line() + continue + + if line.endswith(HARD_BREAK): + # Gliederungstitel oder "Inhalt" bleiben Überschriften. + check = clean_inline(line.removesuffix(HARD_BREAK)) + key = norm_title(check) + if CONTENTS_TITLE.match(check) or ( + key in outline and key not in headings_seen and not in_contents + ): + line = line.removesuffix(HARD_BREAK) + + if line.endswith(HARD_BREAK) or line == STANZA_BREAK: + # Zeilengetreue Zeile: Einrückung (geschützte Leerzeichen) behalten. + content = line.removesuffix(HARD_BREAK) + check = clean_inline(content) + if in_contents: + contents_seen.add(norm_title(check)) + if drop_page_numbers and PAGE_NUMBER.match(check): + continue + if running_heads and running_head_key(check) in running_heads: + continue + if paragraph_open() and line != STANZA_BREAK: + # Letzte Zeile eines Absatzes aus dem vorigen Block + paragraph.append(content.strip()) + flush_paragraph() + result[-1] += HARD_BREAK + continue + flush_paragraph() + while result and result[-1] == "" and len(result) > 1 and result[-2].endswith(HARD_BREAK): + result.pop() # Zeilen aus getrennten Blöcken zusammenhalten + if line == STANZA_BREAK: + if result and result[-1].endswith(HARD_BREAK): + result[-1] = result[-1].removesuffix(HARD_BREAK) + blank_line() + continue + if not result or result[-1] == "" or result[-1].endswith(HARD_BREAK): + result.append(line) + else: + blank_line() + result.append(line) + continue + + if not line.strip(" \t"): # Leerzeile nur dann als Absatzgrenze behandeln, # wenn der bisherige Fließtext mit einem Satzende endet. if paragraph and ends_sentence(paragraph[-1]): @@ -461,7 +535,44 @@ def normalize_markdown( paragraph.append(stripped) flush_paragraph() - return "\n".join(result).strip() + "\n" + return finish_hard_breaks("\n".join(result)).strip() + "\n" + + +def continues_block(following: list[str], free: int, running_heads: set[str]) -> bool: + """Geht der Satz im nächsten Block weiter? (erstes Wort passte nicht mehr)""" + for line in following: + text = line.strip(" \t") + if not text or PAGE_NUMBER.match(clean_inline(text)): + continue + if running_heads and running_head_key(text) in running_heads: + continue + if heading := HEADING.match(text): + text = heading.group(2) # oft falsch erkannt ("## trösten.") + elif text.startswith(("|", "!", ">", "```", "~~~", "**==>")): + return False + elif LIST_ITEM.match(text) and not ORDERED_ITEM.match(text): + return False # "14. Jahrhundert" ist dagegen oft keine Liste + word = clean_inline(text).replace(NBSP, " ").split() + # Breite aus der vorigen Zeile geschätzt: ein Zeichen Spielraum. + return bool(word) and len(word[0]) + 2 > free + return False + + +def finish_hard_breaks(markdown: str) -> str: + """Markierungen harter Umbrüche in Markdown umsetzen. + + Folgt eine weitere zeilengetreue Zeile, wird daraus "\" (harter Umbruch); + die letzte Zeile eines Abschnitts endet ohne. + """ + lines = markdown.split("\n") + for i, line in enumerate(lines): + if line.endswith(HARD_BREAK): + text = line.removesuffix(HARD_BREAK).rstrip() + following = lines[i + 1] if i + 1 < len(lines) else "" + if following.strip(" \t") and not FENCE.match(following): + text += "\\" + lines[i] = text + return MARKERS.sub("", "\n".join(lines)) # Sicherheitsnetz # =========================================================================== @@ -500,7 +611,7 @@ def load_outline(doc: pymupdf.Document) -> dict[str, int]: URL_START = re.compile( r"(?-Links und [Text](…)-Zielen -URL_TRAILING = ".,;:!?)]}»«“”„\"'*_" +URL_TRAILING = ".,;:!?)]}»«“”„\"'*_\\" # "\\": harter Zeilenumbruch MAX_URL_PARTS = 4 # eine URL kann über so viele Zeilen gebrochen sein # Ohne Linkziel: Folgewort gehört zur URL, wenn diese an einer typischen # Umbruchstelle endet und das Wort wie ein URL-Teil aussieht. @@ -716,21 +827,251 @@ def split_by_indent(textlines: list[dict]) -> list[list[dict]]: return groups +# --------------------------------------------------------------------------- +# Zeilengetreue Abschnitte: Code, Register, Verse, Adressen … +# --------------------------------------------------------------------------- +# Ein Zeilenumbruch stammt vom Satzprogramm (weich), wenn das erste Wort der +# nächsten Zeile nicht mehr in die Zeile gepasst hätte. Sonst hat ihn der Autor +# gesetzt (hart); dann bleibt die Zeile samt Einrückung erhalten. Überwiegen in +# einem Block die harten Umbrüche, wird er zeilengetreu ausgegeben: als +# Codeblock (```) oder als Zeilen mit hartem Umbruch ("\" am Zeilenende). + +HARD_BREAK = "\ue002" # Zeile endet mit hartem Umbruch (wird zu "\") +BLOCK_END = "\ue004" # Block endet ohne Satzende: "\ue004[P]\ue004" +BLOCK_END_MARK = re.compile("\ue004(\\d+)(P?)\ue004$") # P: mehrzeiliger Absatz +PARAGRAPH_END = "\ue005" # Absatz endet hier, auch ohne Satzende +MARKERS = re.compile("\ue004\\d*P?\ue004|[\ue002-\ue005]") # alle internen Markierungen +STANZA_BREAK = "\ue003" # Leerzeile innerhalb eines zeilengetreuen Blocks +NBSP = "\u00a0" +TEXT_BOXES = ("text", "list-item", "footnote") +# Spielraum beim "Hätte das Wort noch gepasst?": Satzprogramme brechen nicht +# exakt um (Band 1, Flattersatz: bis 3 % der Spalte), Register und Verse +# lassen fast immer weit mehr frei. +FIT_TOLERANCE = 0.05 # Anteil der Spaltenbreite +FIT_TOLERANCE_EM = 0.3 # mindestens +HARD_BLOCK_RATIO = 0.5 # ab diesem Anteil harter Umbrüche: zeilengetreu +STANZA_GAP = 1.6 # Zeilenabstand (× üblichem) für Leerzeile +INDENT_STEP_EM = 0.8 # Einrückungsstufen: Abstand mindestens so groß +CODE_INDENT = " " # Leerzeichen pro Einrückungsstufe im Code +TEXT_INDENT = NBSP * 4 # geschützte Leerzeichen pro Stufe sonst + +CODE_HINT = re.compile( + r"[{};]\s*$" # Zeile endet mit { } ; + r"|^\s*[}\])]" # beginnt mit } ] ) + r"|::|=>|->|==|!=|\+=|&&|\|\|" # Operatoren + r"|^\s*(?:def|class|fn|let|mut|use|import|from|return|pub|impl|struct|" + r"enum|match|const|var|function|elif|lambda|async|await|#include)\b" + r"|^\s*(?:if|for|while|else|try|except|with)\b.*:\s*$" # Python-Block + r"|^\s*(?:#\[|@\w+|//|/\*|\$ )" # Attribut, Dekorator, Kommentar + r"|\w\([^()]*\)\s*$" # endet mit Aufruf f(x) +) + +_page_lines: list[tuple[float, float]] = [] # (x0, x1) der Textzeilen der Seite + + +def _remember_page_lines(boxes) -> None: + _page_lines.clear() + for box in boxes: + if getattr(box, "boxclass", None) in TEXT_BOXES: + for line in getattr(box, "textlines", None) or []: + _page_lines.append((line["bbox"][0], line["bbox"][2])) + + +def _visual_lines(textlines: list[dict]) -> list[dict]: + """Textzeilen auf gleicher Höhe (z. B. Wechsel der Schrift) zusammenfassen.""" + merged: list[dict] = [] + for line in textlines: + x0, y0, x1, y1 = line["bbox"] + if merged: + last = merged[-1] + ly0, ly1 = last["bbox"][1], last["bbox"][3] + if (y0 + y1) / 2 < ly1 and (y0 + y1) / 2 > ly0 and x0 >= last["bbox"][0]: + bx0, by0, bx1, by1 = last["bbox"] + last["bbox"] = (bx0, min(by0, y0), max(bx1, x1), max(by1, y1)) + last["spans"] = last["spans"] + line["spans"] + continue + merged.append({"bbox": tuple(line["bbox"]), "spans": list(line["spans"])}) + return merged + + +def _raw_text(line: dict) -> str: + return "".join(span["text"] for span in line["spans"]) + + +def _char_width(line: dict, em: float) -> float: + chars = sum(len(s["text"]) for s in line["spans"]) + width = sum(s["bbox"][2] - s["bbox"][0] for s in line["spans"]) + return width / chars if chars and width > 0 else 0.5 * em + + +def _column_right(lines: list[dict], em: float) -> float: + """Rechter Rand der Textspalte, in der der Block steht.""" + bx0 = min(line["bbox"][0] for line in lines) + bx1 = max(line["bbox"][2] for line in lines) + edges = [ + x1 for x0, x1 in _page_lines + if x0 < bx1 and x1 > bx0 and x0 >= bx0 - 2 * em + ] + return max(edges + [bx1]) + + +def _fit_tolerance(lines: list[dict], right: float, em: float) -> float: + left = min(line["bbox"][0] for line in lines) + return max(FIT_TOLERANCE_EM * em, FIT_TOLERANCE * (right - left)) + + +def _is_soft_break(prev: dict, line: dict, right: float, em: float, tol: float) -> bool: + """Hätte das erste Wort der Zeile nicht mehr in die vorige gepasst?""" + prev_text = _raw_text(prev).rstrip() + text = _raw_text(line).strip() + if not text: + return True + if prev_text.endswith(("-",) + SOFT_HYPHENS) and text[0].islower(): + return True # Silbentrennung setzt nur das Satzprogramm + word = text.split()[0] + needed = (len(word) + 1) * _char_width(line, em) + return prev["bbox"][2] + needed > right - tol + + +def _indent_levels(lines: list[dict], em: float) -> list[int]: + """Einrückung jeder Zeile als Stufe 0, 1, 2 … (geometrisch).""" + lefts = sorted({round(line["bbox"][0], 1) for line in lines}) + stops: list[float] = [] + for x in lefts: + if not stops or x - stops[-1] >= INDENT_STEP_EM * em: + stops.append(x) + levels = [] + for line in lines: + x = line["bbox"][0] + levels.append(max(i for i, stop in enumerate(stops) if stop <= x + 0.5)) + return levels + + +def _is_code(lines: list[dict], ignore_code: bool) -> bool: + if ignore_code: + return False + texts = [_raw_text(line) for line in lines] + if all( + s["flags"] & 8 and s.get("char_flags", VISIBLE_TEXT) & VISIBLE_TEXT + for line in lines for s in line["spans"] if s["text"].strip() + ): + return True # Schreibmaschinenschrift + hints = sum(1 for t in texts if CODE_HINT.search(t.replace(NBSP, " "))) + indented = sum(1 for t in texts if t[:1] in (" ", NBSP, "\t")) + return hints >= max(1, (len(texts) + 1) // 2) or (hints and indented) + + +def _code_block(logical: list[list[dict]], levels: list[int]) -> str: + rows = [] + for parts, level in zip(logical, levels): + text = " ".join(_raw_text(line).replace(NBSP, " ").rstrip() for line in parts) + rows.append(CODE_INDENT * level + text) + fence = "~~~" if any("```" in row for row in rows) else "```" + return fence + "\n" + "\n".join(rows) + "\n" + fence + "\n\n" + + +def _verbatim_block( + logical: list[list[dict]], levels: list[int], gaps: list[bool], end: str +) -> str: + rows = [] + for i, (parts, level) in enumerate(zip(logical, levels)): + spans = [span for line in parts for span in line["spans"]] + text, _ = document_layout.get_styled_text(spans) + lead = len(_raw_text(parts[0])) - len(_raw_text(parts[0]).lstrip()) + row = TEXT_INDENT * level + NBSP * lead + text.strip() + last = i == len(logical) - 1 + row += (end or PARAGRAPH_END) if last else HARD_BREAK + rows.append(row) + if not last and gaps[i]: + rows.append(STANZA_BREAK) + return "\n".join(rows) + "\n\n" + + +def _block_end(lines: list[dict], right: float, em: float, tol: float) -> str: + """Markierung für ein Blockende ohne Satzende: freie Zeichen in der Zeile. + + Ob der Satz im nächsten Block weitergeht, entscheidet normalize_markdown + mit dem ersten Wort dieses Blocks ("Hätte es noch gepasst?"). + """ + last = lines[-1] + text = _raw_text(last).strip() + if not text or ends_sentence(text) or text.endswith(("-",) + SOFT_HYPHENS): + return "" + free = (right - tol - last["bbox"][2]) / _char_width(last, em) + paragraph = "P" if len(lines) > 1 else "" + return f"{BLOCK_END}{max(0, int(free))}{paragraph}{BLOCK_END}" + + +def lines_to_md(textlines: list[dict], original, ignore_code: bool = False) -> str: + """Textblock ausgeben: Fließtext wie bisher, sonst zeilengetreu.""" + lines = _visual_lines(textlines) + sizes = sorted(s["size"] for line in lines for s in line["spans"]) + em = sizes[len(sizes) // 2] if sizes else 10.0 + right = _column_right(lines, em) + tol = _fit_tolerance(lines, right, em) + end = _block_end(lines, right, em, tol) + + # Harte Umbrüche ohne Satzende; nach Satzende ist es ein Absatzende. + soft = [_is_soft_break(a, b, right, em, tol) for a, b in zip(lines, lines[1:])] + hard = sum( + 1 for is_soft, line in zip(soft, lines) + if not is_soft and not ends_sentence(_raw_text(line).strip()) + ) + + verbatim = bool(soft) and hard / len(soft) >= HARD_BLOCK_RATIO + if not verbatim: + if end and len(lines) == 1 and _is_code(lines, ignore_code): + return _code_block([lines], [0]) + # Fließtext: Absätze am Einzug trennen, wie bisher ausgeben. + groups = split_by_indent(textlines) + md = "".join(original(group, ignore_code) for group in groups) + return md.rstrip("\n").rstrip() + end + "\n\n" if end else md + + # Zeilengetreu: weich umbrochene Zeilen zu logischen Zeilen verbinden. + logical: list[list[dict]] = [[lines[0]]] + for is_soft, line in zip(soft, lines[1:]): + if is_soft: + logical[-1].append(line) + else: + logical.append([line]) + + firsts = [parts[0] for parts in logical] + levels = _indent_levels(firsts, em) + if _is_code(lines, ignore_code): + return _code_block(logical, levels) + + pitches = [b["bbox"][1] - a["bbox"][1] for a, b in zip(firsts, firsts[1:])] + usual = sorted(pitches)[len(pitches) // 2] if pitches else 0 + gaps = [usual > 0 and pitch > STANZA_GAP * usual for pitch in pitches] + return _verbatim_block(logical, levels, gaps, end) + + def patch_pymupdf4llm_paragraphs() -> None: original = getattr(document_layout, "text_to_md", None) if original is None or getattr(original, "pdf2md_patched", False): return def text_to_md(textlines, ignore_code: bool = False): + if not textlines or document_layout.is_superscripted(textlines[0]): + return original(textlines, ignore_code) try: - groups = split_by_indent(textlines) - except (KeyError, IndexError, TypeError): - groups = [textlines] # unerwartete Struktur: Originalverhalten - return "".join(original(group, ignore_code) for group in groups) + return lines_to_md(textlines, original, ignore_code) + except (KeyError, IndexError, TypeError, ValueError, ZeroDivisionError): + return original(textlines, ignore_code) # unerwartete Struktur text_to_md.pdf2md_patched = True document_layout.text_to_md = text_to_md + levels = getattr(document_layout, "create_list_item_levels", None) + if levels is not None and not getattr(levels, "pdf2md_patched", False): + + def create_list_item_levels(layout_info): + _remember_page_lines(layout_info) # einmal pro Seite aufgerufen + return levels(layout_info) + + create_list_item_levels.pdf2md_patched = True + document_layout.create_list_item_levels = create_list_item_levels + class _DiscardMessages(io.StringIO): """Ersatz für pymupdf4llms Meldungspuffer ("OCR on page …").""" @@ -1090,6 +1431,22 @@ def title_author_from_filename(stem: str) -> tuple[str, str]: return name, "" +# Ergänzung zum Standard-Stylesheet von pandoc: Code auf dem kleinen +# Bildschirm umbrechen statt abschneiden, Einrückung bleibt erhalten. +EPUB_CSS = """ +pre, pre code { white-space: pre-wrap; overflow-wrap: anywhere; } +pre { font-size: 0.85em; line-height: 1.3; } +""" + + +def epub_stylesheet(pandoc: str) -> str: + done = subprocess.run( + [pandoc, "--print-default-data-file", "epub.css"], + capture_output=True, text=True, + ) + return (done.stdout if done.returncode == 0 else "") + EPUB_CSS + + def write_epub( markdown_path: Path, epub_path: Path, @@ -1121,7 +1478,10 @@ def write_epub( if cover: cmd += ["--epub-cover-image", str(cover)] - done = subprocess.run(cmd, capture_output=True, text=True) + with tempfile.TemporaryDirectory() as tmp: + css = Path(tmp) / "pdf2md.css" + css.write_text(epub_stylesheet(pandoc), encoding="utf-8") + done = subprocess.run(cmd + ["--css", str(css)], capture_output=True, text=True) if done.returncode != 0: raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}") diff --git a/tests/test_verbatim.py b/tests/test_verbatim.py new file mode 100644 index 0000000..bc39827 --- /dev/null +++ b/tests/test_verbatim.py @@ -0,0 +1,143 @@ +"""Zeilengetreue Abschnitte: Register, Code, Verse – Fließtext bleibt Fließtext.""" + +import zipfile + +import pymupdf +import pytest + +import pdf2md as p +from conftest import needs_pandoc +from test_e2e import run + +NBSP = " " + +PROSE = ( + "

Dieser Absatz ist ganz normaler Fließtext im Flattersatz. Er wird vom " + "Satzprogramm umbrochen, weil die Zeilen voll sind, und muss deshalb in der " + "Ausgabe wieder zu einer einzigen Zeile zusammengefügt werden, ohne harte " + "Umbrüche und ohne Leerzeilen dazwischen.

" +) + + +def make_pdf(path, lines, html=PROSE): + """Fließtext oben (legt die Spaltenbreite fest), darunter Zeilen an festen Positionen.""" + doc = pymupdf.open() + page = doc.new_page(width=420, height=595) + page.insert_htmlbox(pymupdf.Rect(50, 40, 370, 200), html) + y = 240 + for x, text in lines: + page.insert_text((x, y), text, fontname="tiro", fontsize=11) + y += 14 + doc.save(path) + return path + + +def convert(tmp_path, lines, html=PROSE) -> str: + source = make_pdf(tmp_path / "probe.pdf", lines, html) + out = tmp_path / "probe.md" + run(source, out) + return out.read_text(encoding="utf-8") + + +def test_prose_stays_one_paragraph(tmp_path): + md = convert(tmp_path, []) + assert "Fließtext im Flattersatz. Er wird vom Satzprogramm umbrochen" in md + assert "\\" not in md + + +def test_register_keeps_lines_and_indentation(tmp_path): + md = convert(tmp_path, [ + (50, "Backslash"), + (62, "Maskierung, 79"), + (50, "BaseException, 227"), + (50, "Basisklasse, 206"), + (62, "abstrakte, 212"), + ]) + # "Backslash" hält das Layout-Modell hier für eine Überschrift. + assert ( + f"{NBSP * 4}Maskierung, 79\\\n" + "BaseException, 227\\\n" + "Basisklasse, 206\\\n" + f"{NBSP * 4}abstrakte, 212" + ) in md + + +def test_code_in_proportional_font_becomes_code_block(tmp_path): + md = convert(tmp_path, [ + (50, "def quadrat(x):"), + (62, "y = x * x"), + (62, "return y"), + (50, "print(quadrat(3))"), + ]) + assert "```\ndef quadrat(x):\n y = x * x\n return y\nprint(quadrat(3))\n```" in md + + +def test_poem_keeps_line_breaks(tmp_path): + html = PROSE + ( + "

Der Mond ist aufgegangen,
die goldnen Sternlein prangen
" + "am Himmel hell und klar.

" + ) + md = convert(tmp_path, [], html) + assert "Der Mond ist aufgegangen,\\\ndie goldnen Sternlein prangen\\\nam Himmel hell und klar." in md + + +# --- normalize_markdown mit Markierungen ------------------------------------------- + +H, P, B = p.HARD_BREAK, p.PARAGRAPH_END, p.BLOCK_END + + +def test_hard_breaks_become_backslashes_except_last(): + md = f"Erste{H}\n\nZweite{H}\n\nDritte{H}\n\nDanach Fließtext.\n" + assert p.normalize_markdown(md) == "Erste\\\nZweite\\\nDritte\n\nDanach Fließtext.\n" + + +def test_block_end_continues_when_word_did_not_fit(): + md = f"Er sprach mit dem{B}3{B}\n\nGeheimdienstchef über alles.\n" + assert p.normalize_markdown(md) == "Er sprach mit dem Geheimdienstchef über alles.\n" + + +def test_block_end_is_hard_when_word_would_have_fit(): + md = f"Moskau, im Mai{B}40{B}\n\nDer Zug fuhr ab.\n" + assert p.normalize_markdown(md) == "Moskau, im Mai\n\nDer Zug fuhr ab.\n" + + +def test_multiline_block_without_sentence_end_ends_paragraph(): + md = f"Ein Absatz mit Fußnote.[8 ]{B}30P{B}\n\nDer nächste Absatz.\n" + assert p.normalize_markdown(md) == "Ein Absatz mit Fußnote.[8 ]\n\nDer nächste Absatz.\n" + + +def test_paragraph_end_closes_verbatim_run_without_blank(): + md = f"Die Wahrheit erzählen,{H}\ndas ist der größte Witz.{P}\n\nWeiter.\n" + assert p.normalize_markdown(md) == ( + "Die Wahrheit erzählen,\\\ndas ist der größte Witz.\n\nWeiter.\n" + ) + + +def test_code_blocks_across_page_break_are_merged(): + md = "```\nfn main() {\n```\n\n17\n\n```\n}\n```\n" + assert p.normalize_markdown(md) == "```\nfn main() {\n}\n```\n" + + +@pytest.mark.parametrize("text", ["fn main() {", "use std::io;", "def f(x):", "print(x)"]) +def test_code_hints(text): + assert p.CODE_HINT.search(text) + + +@pytest.mark.parametrize("text", ["Maskierung, 79", "Der Mond ist aufgegangen,", "Kapitel (1)"]) +def test_no_code_hints(text): + assert not p.CODE_HINT.search(text) + + +@needs_pandoc +def test_epub_wraps_long_code_lines(pdfs, tmp_path): + run(pdfs["digital"], tmp_path / "buch.md", "--epub") + with zipfile.ZipFile(tmp_path / "buch.epub") as z: + css = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".css")) + assert "white-space: pre-wrap" in css + + +def test_literal_fence_text_is_escaped_and_markers_never_leak(): + md = f"```{B}66{B}\n\nText danach.\n\nRest{H}\n" + out = p.normalize_markdown(md) + assert out.startswith("\\```") + assert not p.MARKERS.search(out) From f0e0562451fa32af849f0edfcd4b0e7292b163b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 17:32:20 +0200 Subject: [PATCH 08/10] =?UTF-8?q?Inhaltsverzeichnis=20einheitlich:=20Eintr?= =?UTF-8?q?=C3=A4ge=20als=20Abs=C3=A4tze=20statt=20"\"-Zeilen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Im Inhaltsverzeichnis wird jeder Eintrag ein eigener Absatz, auch wenn er als zeilengetreu erkannt wurde (vorher gemischte Abstände im EPUB) - Zeilen, die wie Gliederungseinträge aussehen ("Teil V: …", "32 …"), werden auch ohne Überschrift "Inhalt" zu Absätzen (Vorschau Band Zwei) - Tests, README Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++-- pdf2md.py | 18 ++++++++++++++++++ tests/test_verbatim.py | 20 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5debf81..fbe7e08 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,10 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP werden ebenfalls aufgelöst (`wach­|senden` → `wachsenden`). - **Überschriften:** Ebenen aus den PDF-Lesezeichen (Teile `##`, Kapitel `###`); Satzreste, die fälschlich als Überschrift erkannt wurden, kommen zurück in den Text. -- **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt; liegt das Verzeichnis - als Tabelle vor, bleibt die Tabelle erhalten. +- **Inhaltsverzeichnis:** zusammengeklebte Einträge werden getrennt; jeder Eintrag wird ein + eigener Absatz (auch bei Verzeichnissen ohne Überschrift „Inhalt“, z. B. „Vorschau Band + Zwei“), nie eine Zeile mit `\`. Liegt das Verzeichnis als Tabelle vor, bleibt die Tabelle + erhalten. - **OCR-Ebenen** in Courier werden nicht als Programmcode (`` `…` ``) gesetzt. - **Links:** URLs werden als `` geschrieben und sind damit in Markdown-Viewern und im EPUB anklickbar. Beim Zeilenumbruch zerbrochene URLs (`deutsche-|sehen`, diff --git a/pdf2md.py b/pdf2md.py index a9feea7..bdb3deb 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -131,6 +131,11 @@ PART_TITLE = re.compile( r"^(?:teil|buch|band|part|book)\s+(?:[ivxlcdm]+|\d+)\b", re.IGNORECASE, ) +# Zeile, die wie ein Gliederungseintrag aussieht ("Teil V: …", "32 Oberst …"). +OUTLINE_ENTRY = re.compile( + r"^(?:\d{1,3}\.?\s+\S|(?:teil|buch|band|part|book|kapitel|chapter)\s+(?:[ivxlcdm]+|\d+)\b)", + re.IGNORECASE, +) NUMBERED_TITLE = re.compile( r"^(?:\d+\.?|kapitel\s+\d+|chapter\s+\d+)\s", re.IGNORECASE, @@ -374,6 +379,19 @@ def normalize_markdown( # Mehrzeiliger Absatz: Absatzende; einzelne Zeile: harter Umbruch line += PARAGRAPH_END if end_mark.group(2) else HARD_BREAK + if in_contents and line.endswith((HARD_BREAK, PARAGRAPH_END)): + # Inhaltsverzeichnis: jeder Eintrag ein eigener Absatz (einheitlich, + # auch wenn das Verzeichnis in der PDF zeilengetreu erscheint). + line = line.rstrip(HARD_BREAK + PARAGRAPH_END) + elif line.endswith((HARD_BREAK, PARAGRAPH_END)) and OUTLINE_ENTRY.match( + clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END)) + ): + # Verzeichnis ohne Überschrift "Inhalt" (z. B. "Vorschau Band Zwei"): + # Einträge als eigene Absätze statt als Zeilen mit "\". + flush_paragraph() + standalone(clean_inline(line.rstrip(HARD_BREAK + PARAGRAPH_END))) + continue + if line.endswith(PARAGRAPH_END): content = line.removesuffix(PARAGRAPH_END) if not paragraph and result and result[-1].endswith(HARD_BREAK): diff --git a/tests/test_verbatim.py b/tests/test_verbatim.py index bc39827..b8e7cd5 100644 --- a/tests/test_verbatim.py +++ b/tests/test_verbatim.py @@ -141,3 +141,23 @@ def test_literal_fence_text_is_escaped_and_markers_never_leak(): out = p.normalize_markdown(md) assert out.startswith("\\```") assert not p.MARKERS.search(out) + + +def test_contents_entries_are_uniform_paragraphs(): + outline = {"vorwort": 2, "1 der notar": 3, "2 ein brief": 3} + md = ( + f"## Inhaltsverzeichnis\n\nTitel{H}\nVorwort{H}\n1 Der Notar{P}\n\n" + f"2 Ein Brief{B}40{B}\n\n## Vorwort\n\nText.\n" + ) + assert p.normalize_markdown(md, outline) == ( + "## Inhaltsverzeichnis\n\nTitel\n\nVorwort\n\n1 Der Notar\n\n2 Ein Brief\n\n" + "## Vorwort\n\nText.\n" + ) + + +def test_outline_like_lines_outside_contents_become_paragraphs(): + md = f"## Vorschau Band Zwei\n\n32 Oberst a.D.{H}\n33 Wolga-Attacke{P}\n\nTeil VI: Polen{H}\nOperation Morgenlicht{P}\n" + assert p.normalize_markdown(md) == ( + "## Vorschau Band Zwei\n\n32 Oberst a.D.\n\n33 Wolga-Attacke\n\n" + "Teil VI: Polen\n\nOperation Morgenlicht\n" + ) From d52ac597fc0c956b731c3c6f637700d57d83e3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 18:04:57 +0200 Subject: [PATCH 09/10] --split, --from-md, --jpeg-quality, YAML-Kopf, --title/--author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --split teile|kapitel: Einzeldateien in einem Ordner mit index.md (Reihenfolge) und images/; Titelei und Inhaltsverzeichnis als Vorspann; YAML-Kopf je Datei (title, part, chapter, order, book, author) - --from-md DATEI|ORDNER: EPUB aus bearbeitetem Markdown bzw. Split-Ordner, Angaben aus dem YAML-Kopf; ersetzt den pandoc-Befehl aus Weg B - Markdown beginnt mit YAML-Kopf (title, author, lang, cover); Cover wird mit --images immer abgelegt - --jpeg-quality (Standard 85 statt fest 95): EPUB Band 1 3 MB statt 5 MB - --title/--author überschreiben PDF-Metadaten - Tests (tests/test_split.py), README, Bedienungsanleitung (Weg C), CLAUDE.md, TODO.md Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 97 +++++++++++--- CLAUDE.md | 3 + README.md | 18 ++- TODO.md | 66 +-------- pdf2md.py | 295 ++++++++++++++++++++++++++++++++++++++--- tests/test_e2e.py | 5 +- tests/test_split.py | 146 ++++++++++++++++++++ 7 files changed, 527 insertions(+), 103 deletions(-) create mode 100644 tests/test_split.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 690c3c6..298927b 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -10,6 +10,8 @@ Es gibt zwei Arbeitswege: - **Weg A:** PDF → EPUB in einem Schritt, wenn das Ergebnis ohne Nacharbeit passt. - **Weg B:** PDF → Markdown → von Hand bearbeiten → EPUB, wenn du Überschriften, Text oder Bilder noch korrigieren willst. +- **Weg C:** wie Weg B, aber jeder Teil bzw. jedes Kapitel in einer eigenen Datei – zum + Umsortieren, für RAG oder andere Formate. --- @@ -88,7 +90,21 @@ cd ~/Downloads/ljutsch 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 Probe-EPUB brauchst, reicht `--images` statt `--epub` (das Cover wird trotzdem +abgelegt). + +Am Anfang von `band1.md` steht ein **YAML-Kopf** mit den Angaben für das EPUB: + +```yaml +--- +title: "Operation Ljutsch" +author: "Reinhard Otto Kranz" +lang: "de" +cover: "band1_images/cover.jpg" +--- +``` + +Hier kannst du Titel, Autor, Sprache oder Cover ändern. Werte immer in `"…"` schreiben. ### Schritt 2: `band1.md` bearbeiten @@ -138,23 +154,14 @@ Am nächsten Morgen ... ### Schritt 3: EPUB aus dem bearbeiteten Markdown erzeugen -Im Ordner der Markdown-Datei starten (wichtig für die Bilder): - ```bash -cd ~/Downloads/ljutsch/Output -pandoc band1.md \ - --from markdown-yaml_metadata_block+autolink_bare_uris --to epub3 \ - --output band1.epub \ - --resource-path . \ - --split-level=3 --toc-depth=3 \ - --metadata title="Operation Ljutsch" \ - --metadata author="Reinhard Otto Kranz" \ - --metadata lang=de \ - --epub-cover-image band1_images/cover.jpg +cd ~/Downloads/ljutsch +python3 pdf2md.py --from-md Output/band1.md ``` -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. +Das erzeugt `Output/band1.epub` mit denselben Einstellungen wie `--epub`. Titel, Autor, Sprache +und Cover kommen aus dem YAML-Kopf; `--title`, `--author`, `--lang` oder `--epub-file PFAD` +überschreiben sie bei Bedarf. Schritt 2 und 3 kannst du beliebig oft wiederholen. **Achtung:** Schritt 1 erneut auszuführen überschreibt deine Änderungen in `band1.md`. Leg dir vorher eine Kopie an oder nimm einen anderen @@ -162,7 +169,48 @@ Dateinamen. --- -## 4. Gescannte PDFs +## 4. Weg C – Teile oder Kapitel als Einzeldateien + +```bash +cd ~/Downloads/ljutsch +python3 pdf2md.py --epub --split kapitel Operation_Ljutsch_Band_1_-_Reinhard_Otto_Kranz.pdf Output/band1.md +``` + +Statt `band1.md` entsteht der Ordner `Output/band1/`: + +``` +band1/ + index.md Reihenfolge der Dateien + YAML-Kopf des Buchs + 00_vorspann.md Titelei und Inhaltsverzeichnis + 01_vorwort-berlin-2015.md + 02_teil-i-das-vermaechtnis.md Teil-Überschrift (und ggf. Einleitung) + 03_1-der-notar.md + … + 40_impressum.md + images/ Bilder und cover.jpg +``` + +- `--split kapitel` schneidet an Teilen **und** Kapiteln, `--split teile` nur an Teilen (ein Teil + mit all seinen Kapiteln pro Datei). +- Jede Datei beginnt mit einem YAML-Kopf, z. B.: + ```yaml + --- + title: "3 Die Grauen nehmen die Spur auf" + part: "Teil I: Das Vermächtnis" + chapter: 3 + order: 5 + book: "Operation Ljutsch" + author: "Reinhard Otto Kranz" + --- + ``` +- **Umsortieren:** in `index.md` die Zeilen verschieben. **Weglassen:** Zeile löschen. + Dateien dürfen umbenannt werden, wenn der Link in `index.md` mitgeändert wird. +- **EPUB neu bauen:** `python3 pdf2md.py --from-md Output/band1/` → `Output/band1.epub` + (Kapitel in der Reihenfolge von `index.md`). + +--- + +## 5. Gescannte PDFs Bei Scans liest das Programm den Text per OCR (Tesseract). Der Aufruf ist derselbe: @@ -197,28 +245,32 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g --- -## 5. Alle Optionen +## 6. Alle Optionen | Option | Bedeutung | | :-- | :-- | -| `pdf` | Eingabe-PDF | +| `pdf` | Eingabe-PDF (entfällt bei `--from-md`) | | `markdown` | Ausgabe-Datei; ohne Angabe: gleicher Name wie die PDF mit `.md` | -| `--ocr auto\|always\|never` | OCR-Entscheidung (Standard `auto`, siehe Abschnitt 4) | +| `--ocr auto\|always\|never` | OCR-Entscheidung (Standard `auto`, siehe Abschnitt 5) | | `--no-ocr` | Kurzform für `--ocr never` | | `--ocr-language` | Tesseract-Sprache(n), z. B. `deu+eng` (Standard `deu`) | | `--ocr-dpi` | Auflösung für die OCR (Standard 300) | | `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8) | | `--images` | Bilder in `_images/` speichern und im Markdown verlinken | | `--image-format jpg\|png` | Bildformat (Standard `jpg`; `png` verlustfrei, aber ca. 3× größer) | +| `--jpeg-quality 1-100` | JPEG-Qualität (Standard 85; 95 fast verlustfrei, Bilder ca. 1,7× größer) | | `--epub` | zusätzlich EPUB erzeugen (schaltet `--images` ein) | | `--epub-file PFAD` | anderer Pfad für das EPUB | +| `--split teile\|kapitel` | Teile bzw. Kapitel als Einzeldateien (Abschnitt 4) | +| `--from-md PFAD` | nur EPUB aus `.md`-Datei oder `--split`-Ordner (Abschnitt 3, 4) | +| `--title`, `--author` | Titel/Autor im EPUB vorgeben | | `--lang` | Sprache im EPUB (Standard: aus `--ocr-language` abgeleitet, `deu`→`de`, `eng`→`en`; sonst `de`) | | `--keep-headers` | Kolumnentitel/Seitenköpfe nicht entfernen | | `--keep-footers` | Seitenzahlen/Seitenfüße nicht entfernen | --- -## 6. Ergebnis prüfen +## 7. Ergebnis prüfen 1. **Überschriften auflisten.** So sieht man schnell, ob Teile und Kapitel stimmen: ```bash @@ -235,7 +287,7 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g --- -## 7. Häufige Probleme +## 8. Häufige Probleme | Meldung / Problem | Lösung | | :-- | :-- | @@ -254,5 +306,6 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g | Formeln erscheinen als Bilder | bei Mathebüchern normal; dafür ist das Programm nicht gebaut | | Alter Scan: falsche Überschriften, viele Lesefehler | `--ocr always` (vorhandene OCR-Ebene ignorieren) | | Auf Scans fehlen Leerzeichen („deutetin“) | OCR-Fehler; höhere Auflösung versuchen (`--ocr-dpi 400`) oder von Hand korrigieren | -| EPUB zu groß | `--image-format jpg` (Standard) verwenden; PNG ist ca. 3× größer | +| EPUB zu groß | `--image-format jpg` (Standard) verwenden; PNG ist ca. 3× größer; `--jpeg-quality 70` spart weiter | +| `--from-md`: „In index.md genannt, aber nicht vorhanden“ | Datei umbenannt oder gelöscht → Link in `index.md` anpassen | | Meine Änderungen in der `.md` sind weg | Schritt 1 wurde erneut ausgeführt → vorher Kopie anlegen | diff --git a/CLAUDE.md b/CLAUDE.md index bd80d49..c968e60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,5 +22,8 @@ Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnu - Zwischen dem Patch (`lines_to_md`) und `normalize_markdown` laufen interne Markierungen aus dem Private-Use-Bereich (`HARD_BREAK`, `BLOCK_END`, `PARAGRAPH_END`, `STANZA_BREAK`, U+E002–U+E005); sie dürfen nie in der Ausgabe landen. +- Ausgabe-Markdown beginnt mit einem YAML-Kopf, den `make_frontmatter`/`split_frontmatter` + selbst schreiben und lesen; pandoc bekommt nur den Text (`markdown-yaml_metadata_block`), + weil `---`-Zeilen im Buchtext sonst als YAML gelesen würden. - Regressionsprüfung bei Änderungen am Umbruch: Band 1/2 vorher/nachher diffen; Fließtext darf sich nur an echten Absatz- oder Zeilenwechseln der PDF ändern. diff --git a/README.md b/README.md index fbe7e08..a47f034 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,13 @@ python3 pdf2md.py --epub buch.pdf buch.md # Scan in Deutsch und Englisch, 8 parallele OCR-Prozesse python3 pdf2md.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md +# Kapitel als Einzeldateien (Ordner buch/ mit index.md), dazu EPUB +python3 pdf2md.py --epub --split kapitel buch.pdf buch.md + +# EPUB aus bearbeitetem Markdown bzw. aus einem --split-Ordner (Reihenfolge: index.md) +python3 pdf2md.py --from-md buch.md +python3 pdf2md.py --from-md buch/ + # englisches Buch; alter Scan mit schlechter OCR-Ebene neu erkennen python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EPUB-Sprache: en ``` @@ -46,7 +53,11 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP | `--jobs N` | parallele OCR-Prozesse (Standard: halbe Kernzahl, höchstens 8); reine Textdokumente laufen immer sequenziell | | `--images` | Grafiken in `_images/` speichern und verlinken | | `--image-format jpg\|png` | Bildformat (Standard jpg; png verlustfrei, aber ca. 3× größer) | +| `--jpeg-quality 1-100` | JPEG-Qualität (Standard 85; 95 fast verlustfrei, Bilder ca. 1,7× größer) | | `--epub`, `--epub-file PFAD` | EPUB 3 mit pandoc erzeugen (schaltet `--images` ein) | +| `--split teile\|kapitel` | Teile bzw. Kapitel als Einzeldateien in einem Ordner `/` mit `index.md` (Reihenfolge) und `images/` | +| `--from-md PFAD` | nur EPUB erzeugen – aus einer (bearbeiteten) `.md` oder einem `--split`-Ordner | +| `--title`, `--author` | Titel/Autor im EPUB vorgeben (statt PDF-Metadaten bzw. YAML-Kopf) | | `--lang` | Sprache im EPUB (Standard: aus `--ocr-language`, `deu`→`de`, `eng`→`en`) | | `--keep-headers`, `--keep-footers` | Kolumnentitel bzw. Seitenzahlen nicht entfernen | @@ -77,11 +88,14 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP Tracking-Parameter (`utm_…`) entfallen. Ohne Linkziel wird die URL aus dem gedruckten Text zusammengesetzt. - **Seitenzahlen, Kolumnentitel, Text in Fotos** werden entfernt. +- **YAML-Kopf:** Jede Markdown-Datei beginnt mit `title`, `author`, `lang`, `cover` + (Einzeldateien von `--split` mit `title`, `part`, `chapter`, `order`, `book`, `author`). + `--from-md` liest daraus die EPUB-Angaben. ## Tests ```bash -python3 -m pytest # ca. 30 s; OCR-/EPUB-Tests werden ohne Tesseract/pandoc übersprungen +python3 -m pytest # ca. 45 s; OCR-/EPUB-Tests werden ohne Tesseract/pandoc übersprungen ``` Die Test-PDFs (digital, gescannt, mit OCR-Ebene, gemischt, Fotoseite) werden zur Laufzeit @@ -108,7 +122,7 @@ synthetisch erzeugt. stehen als Zeilen statt im Codeblock. - Reine Zahlen können als Überschrift erscheinen (Fußnotenzeichen, römische Seitenzahlen). Weitere bekannte Schwächen: [TODO.md](TODO.md). -- Bilder werden als JPEG (Qualität 95) gespeichert; EPUB Band 1 ca. 5 MB. +- Bilder werden als JPEG (Qualität 85) gespeichert; EPUB Band 1 ca. 3 MB. ## Dateien diff --git a/TODO.md b/TODO.md index e397e31..e930b0f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,68 +1,14 @@ # TODO -## 1. Teile und Kapitel als separate Markdown-Dateien (`--split`) +## 1. Ideen -**Ziel:** Beim Konvertieren PDF → Markdown jeden Teil bzw. jedes Kapitel als eigene MD-Datei -speichern. Damit lassen sich: - -- Teile/Kapitel einzeln ändern, -- Teile/Kapitel vertauschen oder umsortieren, -- die MD-Dateien für RAG verarbeiten (ein Kapitel = ein Dokument), -- daraus HTML-Dateien oder andere Formate erzeugen. - -**Skizze:** - -- Option `--split teile|kapitel`; Ausgabe als Ordner `/` statt einer einzelnen `.md`. -- Dateinamen mit Nummer und Titel, z. B. - ``` - band1/ - index.md # Reihenfolge der Dateien (bzw. book.yaml) - 00_vorspann.md # Titelei, Inhaltsverzeichnis, Vorwort - 01_teil-i_das-vermaechtnis.md - 02_kapitel-01_der-notar.md - … - 99_nachspann.md # Der Autor, Impressum - images/ - ``` -- YAML-Frontmatter pro Datei (für RAG und Umsortieren): - ```yaml - --- - title: "1 Der Notar" - part: "Teil I: Das Vermächtnis" - chapter: 1 - source_pages: [12, 25] - book: "Operation Ljutsch" - author: "Reinhard Otto Kranz" - --- - ``` -- EPUB aus den Einzeldateien: pandoc nimmt mehrere Eingabedateien in der Reihenfolge von - `index.md`; Umsortieren = Reihenfolge im Index ändern. - -**Ansatzpunkte im Code (`pdf2md.py`):** - -- Schnitt **nach** `normalize_markdown()` an den Überschriften `##` (Teil) und `###` (Kapitel); - die Ebenen kommen aus `load_outline()`. -- Bildpfade pro Datei anpassen (`make_image_links_relative()`). -- Vorspann (alles vor dem ersten Teil/Kapitel) und Nachspann als eigene Dateien. - -**Offene Fragen:** - -- Ein Ordner pro Teil oder alles flach in einem Ordner? -- Seitenzahlen ins Frontmatter? (pymupdf4llm liefert sie mit `page_chunks=True`.) -- Bücher ohne PDF-Gliederung: auf die erkannten Überschriften zurückfallen? -- Umkehrweg „Einzeldateien → ein MD/EPUB“ als eigene Option oder eigenes Skript? - -**Tests:** Aufteilung des synthetischen Test-PDFs (Teil I, Kapitel 1–2) prüfen; Einzeldateien -wieder zusammengesetzt = Gesamt-Markdown. - -## 2. Weitere Ideen - -- `--from-md buch.md`: nur den EPUB-Schritt aus einem (von Hand bearbeiteten) Markdown - ausführen; Titel, Autor und Cover automatisch übernehmen. -- JPEG-Qualität einstellbar machen (z. B. `--jpeg-quality 85`), damit das EPUB noch kleiner wird. +- `--split`: Seitenzahlen der PDF ins Frontmatter (`source_pages`); pymupdf4llm liefert sie + mit `page_chunks=True`, müsste aber durch die ganze Nachbearbeitung getragen werden. +- `--split`: Bücher ohne erkennbare Gliederung werden nicht geteilt (nur `00_vorspann.md`); + denkbar wäre ein Schnitt nach Seitenzahl oder Länge. - Remote für das Git-Repo einrichten (z. B. GitHub), damit die Commits gesichert sind. -## 3. Bekannte Schwächen (aus Probeläufen mit fremden PDFs) +## 2. Bekannte Schwächen (aus Probeläufen mit fremden PDFs) - Reine Zahlen als Überschrift: pymupdf4llm macht Fußnotenzeichen zu Überschriften („## 90“ im Zensur-Report). Nicht pauschal entfernen – im DDR-Buch sind „## 1“, „## 2“ diff --git a/pdf2md.py b/pdf2md.py index bdb3deb..46880bf 100644 --- a/pdf2md.py +++ b/pdf2md.py @@ -15,6 +15,7 @@ Ablauf: import argparse import contextlib import io +import json import multiprocessing import os import re @@ -22,6 +23,7 @@ import shutil import subprocess import sys import tempfile +import unicodedata import urllib.parse from collections import Counter from concurrent.futures import ProcessPoolExecutor @@ -1098,6 +1100,20 @@ class _DiscardMessages(io.StringIO): return len(text) +# JPEG-Qualität: pymupdf4llm speichert Bilder mit Pixmap.save() ohne Angabe +# der Qualität (Standard 95); wir setzen sie über --jpeg-quality. +DEFAULT_JPEG_QUALITY = 85 +_jpeg_quality = [DEFAULT_JPEG_QUALITY] +_pixmap_save = pymupdf.Pixmap.save + + +def _save_pixmap(self, filename, output=None, jpg_quality=None): + quality = _jpeg_quality[0] if jpg_quality is None else jpg_quality + return _pixmap_save(self, filename, output, quality) + + +pymupdf.Pixmap.save = _save_pixmap + patch_pymupdf4llm_hyphens() patch_pymupdf4llm_paragraphs() document_layout.INFO_MESSAGES = _DiscardMessages() @@ -1314,6 +1330,7 @@ def keep_existing_ocr(page, **kwargs) -> None: def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str: + _jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker kwargs = dict( pages=pages, header=options["keep_headers"], @@ -1504,6 +1521,187 @@ def write_epub( raise RuntimeError(f"pandoc fehlgeschlagen: {done.stderr.strip()}") +# =========================================================================== +# Metadaten-Kopf, Aufteilen in Einzeldateien, EPUB aus Markdown +# =========================================================================== +# Jede erzeugte Markdown-Datei beginnt mit einem YAML-Kopf (title, author, lang, +# cover …). Wir schreiben und lesen ihn selbst; pandoc bekommt nur den Text, +# weil "---"-Zeilen im Buchtext sonst als YAML missverstanden werden könnten. + +FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n+", re.DOTALL) +INDEX_LINK = re.compile(r"\]\(([^)\s]+\.md)\)") +UMLAUTS = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"}) + + +def make_frontmatter(meta: dict) -> str: + lines = ["---"] + for key, value in meta.items(): + if value not in (None, ""): + lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}") + return "\n".join(lines + ["---"]) + "\n\n" + + +def split_frontmatter(text: str) -> tuple[dict, str]: + """YAML-Kopf (nur "schlüssel: wert") abtrennen und auswerten.""" + match = FRONTMATTER.match(text) + if not match: + return {}, text + meta = {} + for line in match.group(1).splitlines(): + key, sep, value = line.partition(":") + if sep: + value = value.strip() + try: + meta[key.strip()] = json.loads(value) + except ValueError: + meta[key.strip()] = value.strip("'\"") + return meta, text[match.end():] + + +def slugify(text: str, limit: int = 50) -> str: + text = clean_inline(text).lower().translate(UMLAUTS) + text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() + slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-") + return slug[:limit].rstrip("-") or "abschnitt" + + +def iter_headings(lines: list[str]): + """(Zeilennummer, Ebene, Text) aller Überschriften außerhalb von Codeblöcken.""" + fence = None + for index, line in enumerate(lines): + marker = FENCE.match(line) + if fence: + if marker and marker.group(1)[0] == fence: + fence = None + continue + if marker: + fence = marker.group(1)[0] + elif match := re.match(r"^(#{1,6}) (.+)$", line): + yield index, len(match.group(1)), match.group(2).strip() + + +def split_markdown(markdown: str, mode: str) -> list[dict]: + """Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel". + + Titelei und Inhaltsverzeichnis bilden den Vorspann. + """ + lines = markdown.split("\n") + headings = list(iter_headings(lines)) + counts = Counter(level for _, level, _ in headings) + levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3) + if not levels: + return [{"title": "Vorspann", "body": markdown}] + part_level, chapter_level = levels[0], levels[-1] + cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level} + + # Bis einschließlich Inhaltsverzeichnis (falls am Anfang) alles Vorspann. + start = 0 + for index, _level, text in headings: + if CONTENTS_TITLE.match(clean_inline(text)) and index < len(lines) // 5: + start = index + 1 + break + cuts = [(i, lvl, t) for i, lvl, t in headings if lvl in cut_levels and i >= start] + + sections = [] + first = cuts[0][0] if cuts else len(lines) + front = "\n".join(lines[:first]).strip() + if front: + sections.append({"title": "Vorspann", "body": front + "\n"}) + part = "" + for n, (index, level, text) in enumerate(cuts): + end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines) + title = clean_inline(text) + if level == part_level: + part = title + number = re.match(r"(\d+)\b", title) if level == chapter_level != part_level else None + sections.append({ + "title": title, + "part": part if part != title else "", + "chapter": int(number.group(1)) if number else None, + "body": "\n".join(lines[index:end]).strip() + "\n", + }) + return sections + + +def write_split(folder: Path, sections: list[dict], meta: dict) -> list[Path]: + """Abschnitte als nummerierte Dateien plus index.md (Reihenfolge) schreiben.""" + folder.mkdir(parents=True, exist_ok=True) + width = max(2, len(str(len(sections)))) + files = [] + for order, section in enumerate(sections): + name = f"{order:0{width}d}_{slugify(section['title'])}.md" + head = { + "title": section["title"], + "part": section.get("part"), + "chapter": section.get("chapter"), + "order": order, + "book": meta.get("title"), + "author": meta.get("author"), + } + (folder / name).write_text(make_frontmatter(head) + section["body"], encoding="utf-8") + files.append(folder / name) + + entries = "\n".join( + f"- [{s['title']}]({f.name})" for s, f in zip(sections, files) + ) + index = ( + make_frontmatter(meta) + + "\n\n" + + entries + "\n" + ) + (folder / "index.md").write_text(index, encoding="utf-8") + return files + + +def read_markdown_source(path: Path) -> tuple[dict, str, Path]: + """Metadaten, Text und Basisordner aus einer .md-Datei oder einem Split-Ordner.""" + if path.is_dir(): + index = path / "index.md" + if not index.is_file(): + raise RuntimeError(f"index.md fehlt in {path}") + meta, listing = split_frontmatter(index.read_text(encoding="utf-8")) + parts = [] + for name in INDEX_LINK.findall(listing): + file = path / name + if not file.is_file(): + raise RuntimeError(f"In index.md genannt, aber nicht vorhanden: {file}") + parts.append(split_frontmatter(file.read_text(encoding="utf-8"))[1].strip()) + if not parts: + raise RuntimeError(f"index.md nennt keine Dateien: {index}") + return meta, "\n\n".join(parts) + "\n", path + meta, body = split_frontmatter(path.read_text(encoding="utf-8")) + return meta, body, path.parent + + +def markdown_to_epub( + source: Path, + epub_path: Path, + *, + title: str | None = None, + author: str | None = None, + language: str | None = None, +) -> None: + """EPUB aus Markdown-Datei oder Split-Ordner; Angaben aus dem YAML-Kopf.""" + meta, body, base = read_markdown_source(source) + cover = base / meta["cover"] if meta.get("cover") else None + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", suffix=".md", dir=base, delete=False + ) as tmp: + tmp.write(body) + try: + write_epub( + Path(tmp.name), + epub_path, + title=title or meta.get("title") or source.stem, + author=author or meta.get("author") or "", + language=language or meta.get("lang") or "de", + cover=cover if cover and cover.is_file() else None, + split_level=heading_depth(body), + ) + finally: + Path(tmp.name).unlink(missing_ok=True) + + # =========================================================================== # Kommandozeile # =========================================================================== @@ -1515,7 +1713,7 @@ def main() -> int: "Satzenden gliedern, optional Grafiken speichern und EPUB erzeugen." ) ) - parser.add_argument("pdf", type=Path, help="Eingabe-PDF") + parser.add_argument("pdf", type=Path, nargs="?", help="Eingabe-PDF") parser.add_argument( "markdown", type=Path, @@ -1579,6 +1777,28 @@ def main() -> int: metavar="PFAD", help="Pfad des EPUB; Standard: gleicher Name wie Markdown mit .epub", ) + parser.add_argument( + "--jpeg-quality", + type=int, + default=DEFAULT_JPEG_QUALITY, + metavar="1-100", + help="JPEG-Qualität der Bilder (Standard: %(default)s; 95 = fast verlustfrei, größer)", + ) + parser.add_argument( + "--split", + choices=["teile", "kapitel"], + help="Teile bzw. Kapitel als einzelne Markdown-Dateien in einem Ordner " + "(mit index.md für die Reihenfolge) statt einer Datei", + ) + parser.add_argument( + "--from-md", + type=Path, + metavar="PFAD", + help="nur EPUB erzeugen: aus einer (bearbeiteten) Markdown-Datei oder einem " + "--split-Ordner; Titel, Autor, Cover aus dem YAML-Kopf", + ) + parser.add_argument("--title", help="Titel im EPUB (statt PDF-Metadaten/Dateiname)") + parser.add_argument("--author", help="Autor im EPUB (statt PDF-Metadaten/Dateiname)") parser.add_argument( "--lang", help="Sprache im EPUB, z. B. en (Standard: aus --ocr-language, " @@ -1595,6 +1815,13 @@ def main() -> int: help="Seitenfüße/Seitenzahlen übernehmen", ) args = parser.parse_args() + if not 1 <= args.jpeg_quality <= 100: + parser.error("--jpeg-quality muss zwischen 1 und 100 liegen.") + + if args.from_md: + return epub_from_markdown(args, parser) + if args.pdf is None: + parser.error("Eingabe-PDF fehlt (oder --from-md PFAD angeben).") args.lang = args.lang or epub_language(args.ocr_language) source = args.pdf.expanduser() @@ -1611,7 +1838,9 @@ def main() -> int: args.images = True # EPUB ohne Bilder wäre unvollständig epub_path = (args.epub_file or target.with_suffix(".epub")).expanduser() - image_dir = target.parent / f"{target.stem}_images" + # --split: Ordner "/" mit Einzeldateien und "images/" statt einer Datei + folder = target.with_suffix("") if args.split else None + image_dir = folder / "images" if folder else target.parent / f"{target.stem}_images" try: with pymupdf.open(source) as doc: @@ -1620,8 +1849,12 @@ def main() -> int: uris = load_link_uris(doc) page_count = doc.page_count file_title, file_author = title_author_from_filename(source.stem) - title = ((doc.metadata or {}).get("title") or "").strip() or file_title - author = ((doc.metadata or {}).get("author") or "").strip() or file_author + title = ( + args.title or ((doc.metadata or {}).get("title") or "").strip() or file_title + ) + author = ( + args.author or ((doc.metadata or {}).get("author") or "").strip() or file_author + ) counts = Counter(kinds) print( @@ -1642,10 +1875,11 @@ def main() -> int: target.parent.mkdir(parents=True, exist_ok=True) cover = None if args.images: + # Cover immer mit ablegen, damit --from-md es später findet. image_dir.mkdir(parents=True, exist_ok=True) - if epub_path: - cover = image_dir / f"cover.{args.image_format}" - doc[0].get_pixmap(dpi=150).save(cover) + cover = image_dir / f"cover.{args.image_format}" + _jpeg_quality[0] = args.jpeg_quality + doc[0].get_pixmap(dpi=150).save(cover) options = dict( keep_headers=args.keep_headers, @@ -1655,6 +1889,7 @@ def main() -> int: image_format=args.image_format, ocr_language=args.ocr_language, ocr_dpi=args.ocr_dpi, + jpeg_quality=args.jpeg_quality, ) markdown = extract_markdown(source, plan, options, args.jobs) @@ -1669,24 +1904,30 @@ def main() -> int: running_head_min=max(3, page_count // 10), ) markdown = link_urls(markdown, uris) - target.write_text(markdown, encoding="utf-8") + + base = folder or target.parent + meta = { + "title": title, + "author": author, + "lang": args.lang, + "cover": cover.relative_to(base).as_posix() if cover else None, + } + if folder: + files = write_split(folder, split_markdown(markdown, args.split), meta) + else: + target.write_text(make_frontmatter(meta) + markdown, encoding="utf-8") if epub_path: - write_epub( - target, - epub_path, - title=title, - author=author, - language=args.lang, - cover=cover, - split_level=heading_depth(markdown), - ) + markdown_to_epub(folder or target, epub_path) except Exception as exc: print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr) return 1 - print(f"Markdown geschrieben: {target}") + if folder: + print(f"Markdown geschrieben: {len(files)} Dateien in {folder} (Reihenfolge: index.md)") + else: + print(f"Markdown geschrieben: {target}") if args.images: print(f"Bilder gespeichert in: {image_dir}") if epub_path: @@ -1695,5 +1936,23 @@ def main() -> int: return 0 +def epub_from_markdown(args, parser) -> int: + """--from-md: EPUB aus bearbeitetem Markdown bzw. Split-Ordner erzeugen.""" + source = args.from_md.expanduser() + if not source.exists(): + parser.error(f"Nicht gefunden: {source}") + base = source if source.is_dir() else source.with_suffix("") + epub_path = (args.epub_file or base.with_suffix(".epub")).expanduser() + try: + markdown_to_epub( + source, epub_path, title=args.title, author=args.author, language=args.lang + ) + except Exception as exc: + print(f"Fehler beim EPUB-Erzeugen: {exc}", file=sys.stderr) + return 1 + print(f"EPUB geschrieben: {epub_path}") + return 0 + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 76ac589..32038ca 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -9,6 +9,7 @@ import zipfile import pytest from conftest import ROOT, TITLE, needs_pandoc, needs_tesseract +from pdf2md import split_frontmatter SCRIPT = ROOT / "pdf2md.py" @@ -49,7 +50,9 @@ def test_digital_structure(digital_md): assert "Dort sprach er lange mit dem Geheimdienst über die Lage" in digital_md # keine Seitenzahlen, keine Kolumnentitel außer der Titelüberschrift assert not re.search(r"^\d+$", digital_md, re.MULTILINE) - assert digital_md.count(TITLE) == 1 + meta, body = split_frontmatter(digital_md) + assert meta["title"] == TITLE and meta["lang"] == "de" + assert body.count(TITLE) == 1 @needs_tesseract diff --git a/tests/test_split.py b/tests/test_split.py new file mode 100644 index 0000000..1eb298c --- /dev/null +++ b/tests/test_split.py @@ -0,0 +1,146 @@ +"""--split, --from-md, YAML-Kopf und --jpeg-quality.""" + +import zipfile + +import pytest + +import pdf2md as p +from conftest import needs_pandoc +from test_e2e import run + +BOOK = """# Das Buch + +## Inhaltsverzeichnis + +Teil I + +1 Anfang + +## Teil I: Der Anfang + +Einleitung zum Teil. + +### 1 Anfang + +Text eins. + +``` +# kein Kapitel, nur ein Kommentar +``` + +### 2 Weiter + +Text zwei. + +## Impressum + +Alle Rechte vorbehalten. +""" + + +def test_frontmatter_round_trip(): + meta = {"title": "Ein \"Buch\": Teil 1", "author": "Max Muster", "chapter": 3, "leer": ""} + text = p.make_frontmatter(meta) + "# Text\n" + parsed, body = p.split_frontmatter(text) + assert parsed == {"title": "Ein \"Buch\": Teil 1", "author": "Max Muster", "chapter": 3} + assert body == "# Text\n" + + +def test_text_without_frontmatter_is_unchanged(): + assert p.split_frontmatter("---\n\nText\n") == ({}, "---\n\nText\n") + + +def test_slugify(): + assert p.slugify("2 Ein Brief – eine Liste – ein Vermächtnis") == "2-ein-brief-eine-liste-ein-vermaechtnis" + assert p.slugify("Größe & Maß") == "groesse-mass" + assert p.slugify("!!!") == "abschnitt" + + +def test_split_by_chapters(): + sections = p.split_markdown(BOOK, "kapitel") + assert [s["title"] for s in sections] == [ + "Vorspann", "Teil I: Der Anfang", "1 Anfang", "2 Weiter", "Impressum", + ] + assert sections[0]["body"].startswith("# Das Buch") and "## Inhaltsverzeichnis" in sections[0]["body"] + assert sections[2]["part"] == "Teil I: Der Anfang" and sections[2]["chapter"] == 1 + assert "# kein Kapitel" in sections[2]["body"] # Codeblock wird nicht geschnitten + assert "".join(s["body"] for s in sections).replace("\n", "") == BOOK.replace("\n", "") + + +def test_split_by_parts(): + sections = p.split_markdown(BOOK, "teile") + assert [s["title"] for s in sections] == ["Vorspann", "Teil I: Der Anfang", "Impressum"] + assert "### 2 Weiter" in sections[1]["body"] + + +def test_write_split_and_read_back(tmp_path): + sections = p.split_markdown(BOOK, "kapitel") + files = p.write_split(tmp_path / "buch", sections, {"title": "Das Buch", "author": "Max"}) + assert [f.name for f in files][:3] == ["00_vorspann.md", "01_teil-i-der-anfang.md", "02_1-anfang.md"] + meta, body, base = p.read_markdown_source(tmp_path / "buch") + assert meta["title"] == "Das Buch" and base == tmp_path / "buch" + assert body.replace("\n", "") == BOOK.replace("\n", "") + head, _ = p.split_frontmatter(files[2].read_text()) + assert head == {"title": "1 Anfang", "part": "Teil I: Der Anfang", "chapter": 1, + "order": 2, "book": "Das Buch", "author": "Max"} + + +def test_missing_index_entry_is_reported(tmp_path): + folder = tmp_path / "buch" + p.write_split(folder, p.split_markdown(BOOK, "teile"), {"title": "X"}) + (folder / "01_teil-i-der-anfang.md").unlink() + with pytest.raises(RuntimeError, match="nicht vorhanden"): + p.read_markdown_source(folder) + + +# --- Ende zu Ende ------------------------------------------------------------------ + +def test_cli_split_writes_folder(pdfs, tmp_path): + run(pdfs["digital"], tmp_path / "buch.md", "--split", "kapitel", "--images") + folder = tmp_path / "buch" + names = sorted(f.name for f in folder.glob("*.md")) + assert names[0] == "00_vorspann.md" and "index.md" in names + assert any(n.endswith("_1-der-notar.md") for n in names) + assert (folder / "images" / "cover.jpg").is_file() + text = "".join(f.read_text() for f in folder.glob("*.md")) + assert "](images/" in text and "_images/" not in text + + +@needs_pandoc +def test_from_md_folder_follows_index_order(pdfs, tmp_path): + run(pdfs["digital"], tmp_path / "buch.md", "--split", "kapitel", "--epub") + index = tmp_path / "buch" / "index.md" + lines = index.read_text().split("\n") + i = next(n for n, line in enumerate(lines) if "1-der-notar" in line) + j = next(n for n, line in enumerate(lines) if "2-ein-brief" in line) + lines[i], lines[j] = lines[j], lines[i] + index.write_text("\n".join(lines)) + + run("--from-md", tmp_path / "buch", "--epub-file", tmp_path / "neu.epub") + with zipfile.ZipFile(tmp_path / "neu.epub") as z: + nav = z.read("EPUB/nav.xhtml").decode() + opf = z.read("EPUB/content.opf").decode() + assert nav.index("2 Ein Brief") < nav.index("1 Der Notar") + assert "Erika Muster" in opf and "cover" in opf + + +@needs_pandoc +def test_from_md_file_uses_frontmatter_and_overrides(pdfs, tmp_path): + run(pdfs["digital"], tmp_path / "buch.md", "--images") + md = tmp_path / "buch.md" + md.write_text(md.read_text().replace("Der Regen", "Der starke Regen")) + run("--from-md", md, "--title", "Neuer Titel") + with zipfile.ZipFile(tmp_path / "buch.epub") as z: + opf = z.read("EPUB/content.opf").decode() + text = "".join(z.read(n).decode() for n in z.namelist() if n.endswith(".xhtml")) + assert ">Neuer Titel<" in opf and "Erika Muster" in opf and "cover" in opf + assert "Der starke Regen" in text and "title:" not in text + + +def test_jpeg_quality_changes_image_size(pdfs, tmp_path): + def size(quality): + out = tmp_path / f"q{quality}" / "buch.md" + run(pdfs["digital"], out, "--images", "--jpeg-quality", str(quality)) + return sum(f.stat().st_size for f in (out.parent / "buch_images").glob("*.jpg")) + + assert size(30) < size(95) From add17eecacf1967f23f0b07f586678fecda6d0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Fri, 2 Oct 2026 18:30:02 +0200 Subject: [PATCH 10/10] =?UTF-8?q?.gitignore=20aufger=C3=A4umt:=20veraltete?= =?UTF-8?q?=20Eintr=C3=A4ge=20entfernt,=20History/,=20.ruff=5Fcache/,=20*.?= =?UTF-8?q?png,=20*.sdr/=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .gitignore | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 7a9b5da..eea74d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,16 @@ -# Große Binärdateien bleiben lokal +# Bücher, Ergebnisse und Bilder bleiben lokal *.pdf *.epub -*.torrent -*.sqlite *.jpg -ljutsch_files.xml -Fertig/ +*.png *_images/ +*.sdr/ +Output/ +Fertig/ +History/ # Python __pycache__/ .pytest_cache/ - -# Leere Datei durch System-Bug -/3 - -# Ergebnisse zum Ansehen -Output/ +.ruff_cache/ +.venv/