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] --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)