--split, --from-md, --jpeg-quality, YAML-Kopf, --title/--author
- --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 <noreply@anthropic.com>
This commit is contained in:
parent
f0e0562451
commit
d52ac597fc
7 changed files with 527 additions and 103 deletions
|
|
@ -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 `<name>_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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
18
README.md
18
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 `<name>_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 `<name>/` 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
|
||||
|
||||
|
|
|
|||
66
TODO.md
66
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 `<name>/` 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“
|
||||
|
|
|
|||
289
pdf2md.py
289
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)
|
||||
+ "<!-- Reihenfolge der Dateien im Buch; zum Umsortieren Zeilen verschieben. -->\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 "<name>/" 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,9 +1875,10 @@ 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}"
|
||||
_jpeg_quality[0] = args.jpeg_quality
|
||||
doc[0].get_pixmap(dpi=150).save(cover)
|
||||
|
||||
options = dict(
|
||||
|
|
@ -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,23 +1904,29 @@ 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
|
||||
|
||||
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}")
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
146
tests/test_split.py
Normal file
146
tests/test_split.py
Normal file
|
|
@ -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)
|
||||
Loading…
Reference in a new issue