--split seiten, --epub-per-file, Nachspann, --from-md --split, --split-chars

- --split seiten mit --pages-per-file N (Standard 20, Ausrichtung an
  Überschriften bis eine Seite neben der Grenze) oder --page-ranges
  (streng; Seiten außerhalb werden eigene Dateien)
- Schnitt nur im fertigen Gesamt-Markdown (lückenlos): vor dem ersten
  Absatz, der auf der Grenzseite beginnt; Überschriften vor dem Schnitt
  wandern in die neue Datei; source_pages aus Anfang/Ende jedes Absatzes
- Seitenzuordnung über die Rohtexte je Seite (pymupdf4llm page_chunks=True,
  aneinandergehängt byte-gleich; Band 1/2 unverändert), auch für OCR-Seiten
- --epub-per-file: ein EPUB je Einzeldatei in <name>/epub/
- Nachspann: Anhang/Autor/Impressum nach dem letzten Kapitel als eigene Datei
- --from-md DATEI --split teile|kapitel: bearbeitetes Markdown nachträglich
  teilen, Bild- und Cover-Pfade angepasst
- --split-chars N; Längen-Abschnitte tragen die Überschrift am Anfang als Titel
- Tests (tests/test_split_pages.py), README, Bedienungsanleitung, CLAUDE.md, TODO

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-03 01:04:50 +02:00
commit 5cd5e5d69b
6 changed files with 568 additions and 39 deletions

View file

@ -10,8 +10,8 @@ Es gibt zwei Arbeitswege:
- **Weg A:** PDF → EPUB in einem Schritt, wenn das Ergebnis ohne Nacharbeit passt. - **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 - **Weg B:** PDF → Markdown → von Hand bearbeiten → EPUB, wenn du Überschriften, Text oder
Bilder noch korrigieren willst. Bilder noch korrigieren willst.
- **Weg C:** wie Weg B, aber jeder Teil bzw. jedes Kapitel in einer eigenen Datei – zum - **Weg C:** wie Weg B, aber jeder Teil, jedes Kapitel oder jeder Seitenbereich in einer
Umsortieren, für RAG oder andere Formate. eigenen Datei – zum Umsortieren, für RAG oder andere Formate.
--- ---
@ -171,7 +171,7 @@ Dateinamen.
--- ---
## 4. Weg C – Teile oder Kapitel als Einzeldateien ## 4. Weg C – Teile, Kapitel oder Seitenbereiche als Einzeldateien
```bash ```bash
cd pdf2md # Ordner des Repos cd pdf2md # Ordner des Repos
@ -207,7 +207,25 @@ band1/
--- ---
``` ```
- Erkennt das Programm keine Gliederung, teilt es nach Länge (ca. 30 000 Zeichen, an - Erkennt das Programm keine Gliederung, teilt es nach Länge (ca. 30 000 Zeichen, an
Absatzgrenzen) in `Abschnitt 1`, `Abschnitt 2` … und meldet das. Absatzgrenzen; ändern mit `--split-chars N`) in `Abschnitt 1`, `Abschnitt 2` … und meldet das.
- Anhang, „Der Autor“, Impressum usw. nach dem letzten Kapitel landen in `nn_nachspann.md`,
sofern sie keine eigene Kapitelüberschrift haben.
- **Nach Seiten der PDF** (für Bücher ohne Kapitel):
```bash
python3 pdf2md.py --split seiten --pages-per-file 25 buch.pdf Output/buch.md
python3 pdf2md.py --split seiten --page-ranges 1-12,13-40,41- buch.pdf Output/buch.md
```
Es geht kein Text verloren: Geschnitten wird vor dem ersten Absatz, der auf der Grenzseite
beginnt. Ein Absatz, der über die Seitengrenze läuft, bleibt ganz in der vorigen Datei;
eine Überschrift direkt vor dem Schnitt kommt in die neue Datei. Mit `--pages-per-file`
wird bevorzugt an einer Überschrift geschnitten, die höchstens eine Seite neben der Grenze
liegt; `--page-ranges` hält sich streng an die Seiten (Seiten außerhalb der Bereiche werden
eigene Dateien). `source_pages` kann sich deshalb um eine Seite überlappen.
- **Ein EPUB je Datei:** `--epub-per-file` legt in `Output/band1/epub/` für jede Datei ein
EPUB an („Buchtitel - Kapiteltitel“, mit Cover). Das Gesamt-EPUB entsteht nur mit `--epub`.
- **Bearbeitetes Markdown nachträglich teilen:**
`python3 pdf2md.py --from-md Output/band1.md --split kapitel` → Ordner `Output/band1/`
(Bildpfade werden angepasst; `--split seiten` geht hier nicht, dafür braucht es die PDF).
- **Umsortieren:** in `index.md` die Zeilen verschieben. **Weglassen:** Zeile löschen. - **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. 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` - **EPUB neu bauen:** `python3 pdf2md.py --from-md Output/band1/` → `Output/band1.epub`
@ -292,7 +310,10 @@ Kolumnentitel und Seitenzahlen gedruckter Bücher werden automatisch entfernt, g
| `--jpeg-quality 1-100` | JPEG-Qualität (Standard 85; 95 fast verlustfrei, Bilder ca. 1,7× 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` | zusätzlich EPUB erzeugen (schaltet `--images` ein) |
| `--epub-file PFAD` | anderer Pfad für das EPUB | | `--epub-file PFAD` | anderer Pfad für das EPUB |
| `--split teile\|kapitel` | Teile bzw. Kapitel als Einzeldateien (Abschnitt 4) | | `--split teile\|kapitel\|seiten` | Teile, Kapitel oder Seitenbereiche als Einzeldateien (Abschnitt 4) |
| `--pages-per-file N`, `--page-ranges BEREICHE` | Seiten je Datei bzw. feste Bereiche für `--split seiten` |
| `--split-chars N` | Länge der Abschnitte, wenn keine Gliederung erkannt wird (Standard 30 000) |
| `--epub-per-file` | mit `--split`: ein EPUB je Einzeldatei |
| `--from-md PFAD` | nur EPUB aus `.md`-Datei oder `--split`-Ordner (Abschnitt 3, 4) | | `--from-md PFAD` | nur EPUB aus `.md`-Datei oder `--split`-Ordner (Abschnitt 3, 4) |
| `--title`, `--author` | Titel/Autor im EPUB vorgeben | | `--title`, `--author` | Titel/Autor im EPUB vorgeben |
| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language` abgeleitet, `deu`→`de`, `eng`→`en`; sonst `de`) | | `--lang` | Sprache im EPUB (Standard: aus `--ocr-language` abgeleitet, `deu`→`de`, `eng`→`en`; sonst `de`) |

View file

@ -22,6 +22,9 @@ Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnu
`tests/test_verbatim.py` noch grün sind. `tests/test_verbatim.py` noch grün sind.
- Mit pymupdf4llm 1.28.2 getestet (Bildtext-Format `<!-- Start of picture text -->`, OCR-Text - Mit pymupdf4llm 1.28.2 getestet (Bildtext-Format `<!-- Start of picture text -->`, OCR-Text
wird über unsichtbaren Text erkannt, nicht über `full_ocred`). wird über unsichtbaren Text erkannt, nicht über `full_ocred`).
- `convert_pages` ruft pymupdf4llm mit `page_chunks=True` auf und hängt die Seitentexte
aneinander (in 1.28.2 byte-gleich mit `page_chunks=False`); die Rohtexte je Seite braucht
`--split seiten` (`block_pages`) zur Seitenzuordnung.
- OCR (`convert_pages`, Lauf `RUN_OCR`): vorhandene unsichtbare Textebenen werden vorher in einer - OCR (`convert_pages`, Lauf `RUN_OCR`): vorhandene unsichtbare Textebenen werden vorher in einer
Kopie entfernt (`drop_invisible_text`), sonst behält pymupdf4llm fremde OCR-Ebenen. Kopie entfernt (`drop_invisible_text`), sonst behält pymupdf4llm fremde OCR-Ebenen.
Mit `--keep-lines` werden Scanseiten an pymupdf4llm vorbei direkt aus Tesseract gelesen Mit `--keep-lines` werden Scanseiten an pymupdf4llm vorbei direkt aus Tesseract gelesen

View file

@ -41,6 +41,12 @@ python3 pdf2md.py --ocr-language deu+eng --jobs 8 scan.pdf scan.md
# Kapitel als Einzeldateien (Ordner buch/ mit index.md), dazu EPUB # Kapitel als Einzeldateien (Ordner buch/ mit index.md), dazu EPUB
python3 pdf2md.py --epub --split kapitel buch.pdf buch.md python3 pdf2md.py --epub --split kapitel buch.pdf buch.md
# Buch ohne Kapitel: je etwa 25 PDF-Seiten eine Datei und ein EPUB je Datei
python3 pdf2md.py --split seiten --pages-per-file 25 --epub-per-file buch.pdf buch.md
# bearbeitetes Gesamt-Markdown nachträglich in Kapitel teilen
python3 pdf2md.py --from-md buch.md --split kapitel
# EPUB aus bearbeitetem Markdown bzw. aus einem --split-Ordner (Reihenfolge: index.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.md
python3 pdf2md.py --from-md buch/ python3 pdf2md.py --from-md buch/
@ -59,8 +65,12 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
| `--image-format jpg\|png` | Bildformat (Standard jpg; png verlustfrei, aber ca. 3× größer) | | `--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) | | `--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) | | `--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/` | | `--split teile\|kapitel\|seiten` | Teile, Kapitel oder PDF-Seitenbereiche als Einzeldateien in einem Ordner `<name>/` mit `index.md` (Reihenfolge) und `images/`; Anhang/Autor/Impressum nach dem letzten Kapitel als „Nachspann“ |
| `--from-md PFAD` | nur EPUB erzeugen – aus einer (bearbeiteten) `.md` oder einem `--split`-Ordner | | `--pages-per-file N` | mit `--split seiten`: etwa N PDF-Seiten je Datei (Standard 20), Schnitt an Absatzgrenzen, bevorzugt an einer Überschrift bis zu einer Seite neben der Grenze |
| `--page-ranges 1-12,13-40,41-` | mit `--split seiten`: feste Seitenbereiche (streng, ohne Ausrichten); Seiten außerhalb werden eigene Dateien |
| `--split-chars N` | `--split teile\|kapitel` ohne Gliederung: Abschnitte von etwa N Zeichen (Standard 30 000) |
| `--epub-per-file` | mit `--split`: zusätzlich ein EPUB je Einzeldatei in `<name>/epub/` (Gesamt-EPUB nur mit `--epub`) |
| `--from-md PFAD` | nur EPUB erzeugen – aus einer (bearbeiteten) `.md` oder einem `--split`-Ordner; mit `--split teile\|kapitel` eine `.md` stattdessen in einen Ordner teilen |
| `--title`, `--author` | Titel/Autor im EPUB vorgeben (statt PDF-Metadaten bzw. YAML-Kopf) | | `--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`) | | `--lang` | Sprache im EPUB (Standard: aus `--ocr-language`, `deu`→`de`, `eng`→`en`) |
| `--keep-lines` | jede Zeile der PDF bleibt eine Zeile (Gedichtbände, Liedtexte): Strophen als Absätze, Einrückungen bleiben; nichts wird zusammengefügt, auch keine Silbentrennung. Scans werden direkt aus Tesseract gelesen, Titel aus der PDF-Gliederung übernommen | | `--keep-lines` | jede Zeile der PDF bleibt eine Zeile (Gedichtbände, Liedtexte): Strophen als Absätze, Einrückungen bleiben; nichts wird zusammengefügt, auch keine Silbentrennung. Scans werden direkt aus Tesseract gelesen, Titel aus der PDF-Gliederung übernommen |
@ -112,6 +122,13 @@ python3 pdf2md.py --epub --ocr-language eng --ocr always scan.pdf scan.md # EP
`book`, `author`; `source_pages` = Seiten der PDF-Datei, nicht die gedruckten). `book`, `author`; `source_pages` = Seiten der PDF-Datei, nicht die gedruckten).
`--from-md` liest daraus die EPUB-Angaben. Ohne verwertbare Gliederung teilt `--split` `--from-md` liest daraus die EPUB-Angaben. Ohne verwertbare Gliederung teilt `--split`
nach Länge (ca. 30 000 Zeichen, an Absatzgrenzen) in „Abschnitt 1“, „Abschnitt 2“ … nach Länge (ca. 30 000 Zeichen, an Absatzgrenzen) in „Abschnitt 1“, „Abschnitt 2“ …
- **`--split seiten`:** Das Gesamt-Markdown wird nur zerschnitten, nie gekürzt – alle Dateien
aneinandergehängt ergeben wieder den ganzen Text. Geschnitten wird vor dem ersten Absatz,
der auf der Grenzseite beginnt; ein Absatz über die Seitengrenze bleibt ganz in der Datei
davor, Überschriften direkt vor dem Schnitt wandern in die neue Datei. Die Seite jedes
Absatzes wird im Rohtext der Seiten gesucht (bei Scans im OCR-Text); `source_pages` darf
sich deshalb um eine Seite überlappen. Titel: die Überschrift am Dateianfang, sonst
„Seiten 21-40“.
## Tests ## Tests

View file

@ -40,6 +40,7 @@
- Nach einem Update von pymupdf4llm (`pip install -U pymupdf4llm`) zuerst `python3 -m pytest` - Nach einem Update von pymupdf4llm (`pip install -U pymupdf4llm`) zuerst `python3 -m pytest`
laufen lassen: `pdf2md.py` patcht drei Funktionen der Bibliothek. Version 1.28 hat z. B. das laufen lassen: `pdf2md.py` patcht drei Funktionen der Bibliothek. Version 1.28 hat z. B. das
Format für Bildtext und die OCR-Erkennung geändert. Format für Bildtext und die OCR-Erkennung geändert. Außerdem prüfen, ob `page_chunks=True`
aneinandergehängt noch dasselbe liefert wie ohne (Regressionsvergleich Band 1/2).
- Bei Änderungen am Umbruch: Regressionsvergleich gegen den letzten Commit über das Probekorpus - Bei Änderungen am Umbruch: Regressionsvergleich gegen den letzten Commit über das Probekorpus
(Ljutsch Band 1/2, Zensur-Report, Markdown Guide, Register, Rust-Kochbuch, Kempis-Scan). (Ljutsch Band 1/2, Zensur-Report, Markdown Guide, Register, Rust-Kochbuch, Kempis-Scan).

369
pdf2md.py
View file

@ -13,6 +13,7 @@ Ablauf:
""" """
import argparse import argparse
import bisect
import contextlib import contextlib
import difflib import difflib
import io import io
@ -1804,7 +1805,8 @@ def keep_existing_ocr(page, **kwargs) -> None:
""" """
def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str: def convert_pages(source: str, run: str, pages: list[int], options: dict) -> list[str]:
"""Markdown je Seite (in der Reihenfolge von pages)."""
_jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker _jpeg_quality[0] = options.get("jpeg_quality", DEFAULT_JPEG_QUALITY) # auch im Worker
_keep_lines[0] = options.get("keep_lines", False) _keep_lines[0] = options.get("keep_lines", False)
kwargs = dict( kwargs = dict(
@ -1814,6 +1816,8 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
write_images=options["images"], write_images=options["images"],
image_path=options["image_path"], image_path=options["image_path"],
image_format=options["image_format"], image_format=options["image_format"],
# Text je Seite; aneinandergehängt identisch mit page_chunks=False
page_chunks=True,
) )
if run == RUN_OCR: if run == RUN_OCR:
# FORCE_DROP_OLD: vorhandene (schlechte) OCR-Ebene verwerfen und neu # FORCE_DROP_OLD: vorhandene (schlechte) OCR-Ebene verwerfen und neu
@ -1832,22 +1836,22 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
# pymupdf4llm schreibt Statusmeldungen auf stdout; wir melden selbst. # pymupdf4llm schreibt Statusmeldungen auf stdout; wir melden selbst.
with contextlib.redirect_stdout(io.StringIO()): with contextlib.redirect_stdout(io.StringIO()):
if run != RUN_OCR: if run != RUN_OCR:
return pymupdf4llm.to_markdown(source, **kwargs) return [chunk["text"] for chunk in pymupdf4llm.to_markdown(source, **kwargs)]
if options.get("keep_lines"): if options.get("keep_lines"):
with pymupdf.open(source) as doc: with pymupdf.open(source) as doc:
return "".join( return [
ocr_lines_md( ocr_lines_md(
doc[pno], options["ocr_language"], options["ocr_dpi"], doc[pno], options["ocr_language"], options["ocr_dpi"],
options.get("titles", {}).get(pno, []), options.get("titles", {}).get(pno, []),
) )
for pno in pages for pno in pages
) ]
# Fremde OCR-Ebenen entfernt pymupdf4llm nicht (nur eigene); sonst stünde # Fremde OCR-Ebenen entfernt pymupdf4llm nicht (nur eigene); sonst stünde
# alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert. # alter und neuer Text doppelt da. Nur die geöffnete Kopie wird geändert.
with pymupdf.open(source) as doc: with pymupdf.open(source) as doc:
for pno in pages: for pno in pages:
drop_invisible_text(doc[pno]) drop_invisible_text(doc[pno])
return pymupdf4llm.to_markdown(doc, **kwargs) return [chunk["text"] for chunk in pymupdf4llm.to_markdown(doc, **kwargs)]
# --keep-lines auf Scanseiten: Zeilen direkt aus Tesseract, ohne Layout-Modell. # --keep-lines auf Scanseiten: Zeilen direkt aus Tesseract, ohne Layout-Modell.
@ -1968,7 +1972,10 @@ def _init_worker() -> None:
os.environ["OMP_THREAD_LIMIT"] = "1" os.environ["OMP_THREAD_LIMIT"] = "1"
def extract_markdown(source: Path, plan: list[str], options: dict, jobs: int) -> str: def extract_markdown(
source: Path, plan: list[str], options: dict, jobs: int
) -> tuple[str, dict[int, str]]:
"""Gesamt-Markdown und Rohtext je Seite (für --split seiten)."""
tasks = make_tasks(plan) tasks = make_tasks(plan)
total = len(tasks) total = len(tasks)
labels = {RUN_PLAIN: "Text", RUN_OCR: "OCR", RUN_REUSE: "OCR-Ebene"} labels = {RUN_PLAIN: "Text", RUN_OCR: "OCR", RUN_REUSE: "OCR-Ebene"}
@ -1981,11 +1988,17 @@ def extract_markdown(source: Path, plan: list[str], options: dict, jobs: int) ->
) )
parts = [] parts = []
page_texts: dict[int, str] = {}
def collect(pages: list[int], texts: list[str]) -> None:
page_texts.update(zip(pages, texts))
parts.append("".join(texts))
# Nur OCR profitiert von mehreren Prozessen; das Layout-Modell nutzt # Nur OCR profitiert von mehreren Prozessen; das Layout-Modell nutzt
# ohnehin alle Kerne. # ohnehin alle Kerne.
if jobs <= 1 or total == 1 or RUN_OCR not in plan: if jobs <= 1 or total == 1 or RUN_OCR not in plan:
for i, (run, pages) in enumerate(tasks, 1): for i, (run, pages) in enumerate(tasks, 1):
parts.append(convert_pages(str(source), run, pages, options)) collect(pages, convert_pages(str(source), run, pages, options))
report(i, run, pages) report(i, run, pages)
else: else:
context = multiprocessing.get_context("spawn") context = multiprocessing.get_context("spawn")
@ -1999,10 +2012,10 @@ def extract_markdown(source: Path, plan: list[str], options: dict, jobs: int) ->
for run, pages in tasks for run, pages in tasks
] ]
for i, (future, (run, pages)) in enumerate(zip(futures, tasks), 1): for i, (future, (run, pages)) in enumerate(zip(futures, tasks), 1):
parts.append(future.result()) collect(pages, future.result())
report(i, run, pages) report(i, run, pages)
return "\n\n".join(parts) return "\n\n".join(parts), page_texts
IMAGE_LINK = re.compile(r"(!\[[^\]]*\]\()([^)\s]+)\)") IMAGE_LINK = re.compile(r"(!\[[^\]]*\]\()([^)\s]+)\)")
@ -2187,10 +2200,27 @@ def iter_headings(lines: list[str]):
SPLIT_CHARS = 30_000 # ohne Gliederung: Abschnitte von etwa dieser Länge SPLIT_CHARS = 30_000 # ohne Gliederung: Abschnitte von etwa dieser Länge
PAGES_PER_FILE = 20 # --split seiten: Standard für --pages-per-file
HEADING_BLOCK = re.compile(r"^(#{1,6}) (.+)$")
# Nachspann: erste dieser Überschriften nach dem letzten Kapitel/Teil.
BACKMATTER_TITLE = re.compile(
r"^(?:nachwort|nachbemerkung|anhang|anhänge|der autor|die autorin|"
r"über den autor|über die autorin|zum autor|zur autorin|impressum|danksagung|dank|"
r"quellen|quellenverzeichnis|quellennachweis|literatur|literaturverzeichnis|"
r"bibliographie|bibliografie|register|personenregister|sachregister|"
r"stichwortverzeichnis|glossar|anmerkungen|endnoten|bildnachweis|"
r"about the author|acknowledgements|acknowledgments|appendix|bibliography|"
r"glossary|index|notes)\b",
re.IGNORECASE,
)
def split_by_length(markdown: str, size: int = SPLIT_CHARS) -> list[dict]: def md_blocks(markdown: str) -> list[str]:
"""Ohne verwertbare Überschriften: an Absatzgrenzen in etwa gleich lange Stücke.""" """Absätze samt folgendem Leerraum; Codeblöcke bleiben ganz.
Aneinandergehängt ergeben die Blöcke wieder genau den Text.
"""
blocks, fence = [], False blocks, fence = [], False
for part in re.split(r"(\n\n+)", markdown): for part in re.split(r"(\n\n+)", markdown):
if blocks and fence: if blocks and fence:
@ -2200,31 +2230,47 @@ def split_by_length(markdown: str, size: int = SPLIT_CHARS) -> list[dict]:
else: else:
blocks[-1] += part blocks[-1] += part
fence ^= sum(1 for line in part.split("\n") if FENCE.match(line)) % 2 == 1 fence ^= sum(1 for line in part.split("\n") if FENCE.match(line)) % 2 == 1
sections, current = [], "" return blocks
for block in blocks:
if current and len(current) + len(block) > size:
def block_heading(block: str) -> str | None:
"""Text der Überschrift, wenn der Block nur aus einer Überschrift besteht."""
lines = block.strip().split("\n")
match = HEADING_BLOCK.match(lines[0]) if len(lines) == 1 else None
return md_unescape(clean_inline(match.group(2))) if match else None
def split_by_length(markdown: str, size: int = SPLIT_CHARS) -> list[dict]:
"""Ohne verwertbare Überschriften: an Absatzgrenzen in etwa gleich lange Stücke."""
sections, current = [], []
for block in md_blocks(markdown):
if current and sum(map(len, current)) + len(block) > size:
sections.append(current) sections.append(current)
current = "" current = []
current += block current.append(block)
if current.strip(): if "".join(current).strip():
sections.append(current) sections.append(current)
return [ return [
{"title": f"Abschnitt {n}", "body": body.strip() + "\n"} {
for n, body in enumerate(sections, start=1) "title": block_heading(blocks[0]) or f"Abschnitt {n}",
"body": "".join(blocks).strip() + "\n",
}
for n, blocks in enumerate(sections, start=1)
] ]
def split_markdown(markdown: str, mode: str) -> list[dict]: def split_markdown(markdown: str, mode: str, size: int = SPLIT_CHARS) -> list[dict]:
"""Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel". """Buch in Abschnitte teilen: "teile" (oberste Ebene) oder "kapitel".
Titelei und Inhaltsverzeichnis bilden den Vorspann. Titelei und Inhaltsverzeichnis bilden den Vorspann, Anhang/Autor/Impressum
nach dem letzten Kapitel den Nachspann. Ohne Gliederung: nach Länge (size).
""" """
lines = markdown.split("\n") lines = markdown.split("\n")
headings = list(iter_headings(lines)) headings = list(iter_headings(lines))
counts = Counter(level for _, level, _ in headings) counts = Counter(level for _, level, _ in headings)
levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3) levels = sorted(level for level, n in counts.items() if n >= 2 and level <= 3)
if not levels: if not levels:
return split_by_length(markdown) return split_by_length(markdown, size)
part_level, chapter_level = levels[0], levels[-1] part_level, chapter_level = levels[0], levels[-1]
cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level} cut_levels = {part_level} if mode == "teile" else {part_level, chapter_level}
@ -2236,6 +2282,20 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
break break
cuts = [(i, lvl, t) for i, lvl, t in headings if lvl in cut_levels and i >= start] cuts = [(i, lvl, t) for i, lvl, t in headings if lvl in cut_levels and i >= start]
# Nachspann (Ebene 0): Anhang & Co. unterhalb der Schnittebenen, sonst hinge
# er am letzten Kapitel.
after = cuts[-1][0] if cuts else start - 1
back = next(
(
(i, 0, t) for i, lvl, t in headings
if i > after and lvl <= 3 and lvl not in cut_levels
and BACKMATTER_TITLE.match(md_unescape(clean_inline(t)))
),
None,
)
if back:
cuts.append(back)
sections = [] sections = []
first = cuts[0][0] if cuts else len(lines) first = cuts[0][0] if cuts else len(lines)
front = "\n".join(lines[:first]).strip() front = "\n".join(lines[:first]).strip()
@ -2244,6 +2304,10 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
part = "" part = ""
for n, (index, level, text) in enumerate(cuts): for n, (index, level, text) in enumerate(cuts):
end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines) end = cuts[n + 1][0] if n + 1 < len(cuts) else len(lines)
body = "\n".join(lines[index:end]).strip() + "\n"
if level == 0:
sections.append({"title": "Nachspann", "body": body})
continue
title = md_unescape(clean_inline(text)) title = md_unescape(clean_inline(text))
if level == part_level: if level == part_level:
part = title part = title
@ -2252,11 +2316,135 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
"title": title, "title": title,
"part": part if part != title else "", "part": part if part != title else "",
"chapter": int(number.group(1)) if number else None, "chapter": int(number.group(1)) if number else None,
"body": "\n".join(lines[index:end]).strip() + "\n", "body": body,
}) })
return sections return sections
def parse_page_ranges(spec: str, page_count: int) -> list[int]:
"""--page-ranges "1-12,13-40,41-" -> Startseiten (0-basiert), immer mit 0.
Seiten vor dem ersten, zwischen und nach den Bereichen werden eigene
Abschnitte; so geht kein Text verloren.
"""
ranges = []
for item in spec.replace(" ", "").split(","):
match = re.fullmatch(r"(\d+)(?:-(\d*))?", item)
if not match:
raise ValueError(f"Seitenbereich nicht lesbar: {item!r} (Beispiel: 1-12,13-40,41-)")
first = int(match.group(1))
last = page_count if match.group(2) == "" else int(match.group(2) or first)
if not 1 <= first <= last <= page_count:
raise ValueError(f"Seitenbereich {item!r} passt nicht zu {page_count} Seiten")
ranges.append((first - 1, last - 1))
ranges.sort()
for (_, last), (first, _) in zip(ranges, ranges[1:]):
if first <= last:
raise ValueError(f"Seitenbereiche überlappen sich: Seite {first + 1}")
starts = {0}
for first, last in ranges:
starts.add(first)
if last + 1 < page_count:
starts.add(last + 1)
return sorted(starts)
PAGE_KEY_MIN = 6 # kürzere Absätze ("Ja.") erben die Seite des Vorgängers
PAGE_SEARCH = 50_000 # so weit (Zeichen ohne Leerraum) wird vorausgesucht
def block_pages(blocks: list[str], page_texts: dict[int, str]) -> list[tuple[int, int]]:
"""Start- und Endseite (0-basiert) jedes Blocks.
Die Rohtexte aller Seiten bilden einen fortlaufenden Vergleichstext; Anfang und
Ende jedes Blocks werden darin nur vorwärts gesucht. Nicht gefundene Blöcke
(Bilder, stark veränderter Text) erben die Seite des Vorgängers.
"""
stream, offsets = [], []
length = 0
for pno in range(max(page_texts, default=-1) + 1):
offsets.append(length)
key = _page_key(page_texts.get(pno, ""))
stream.append(key)
length += len(key)
text = "".join(stream)
def page_of(position: int) -> int:
return bisect.bisect_right(offsets, position) - 1
result, pos, page = [], 0, 0
for block in blocks:
key = _page_key(block)
end = page
if len(key) >= PAGE_KEY_MIN:
found = text.find(key[:30], pos, pos + PAGE_SEARCH + 30)
if found >= 0:
pos, page = found, page_of(found)
tail = text.find(key[-30:], pos, pos + 2 * len(key) + 1000)
end = page_of(tail + len(key[-30:]) - 1) if tail >= 0 else page
result.append((page, max(page, end)))
return result
def split_by_pages(
markdown: str,
starts: list[int],
page_texts: dict[int, str],
align: bool = True,
) -> list[dict]:
"""Nach Seiten der PDF teilen; starts = Startseiten (0-basiert).
Geschnitten wird vor dem ersten Block, der auf der Startseite beginnt; ein
Absatz über die Seitengrenze bleibt ganz in der Datei davor. Überschriften
direkt vor dem Schnitt gehören zur neuen Datei. Mit align wird an einer
Überschrift höchstens eine Seite vor oder nach der Grenze geschnitten.
"""
blocks = md_blocks(markdown)
if not blocks:
return []
spans = block_pages(blocks, page_texts)
pages = [first for first, _ in spans]
heading = [block_heading(b) is not None for b in blocks]
cuts: list[int] = []
for start in starts:
if start == 0:
continue
cut = next((i for i, page in enumerate(pages) if page >= start), None)
if cut is None:
break
if align:
near = [
i for i in range(1, len(blocks))
if heading[i] and not heading[i - 1] and abs(pages[i] - start) <= 1
and (not cuts or i > cuts[-1])
]
if near:
cut = min(near, key=lambda i: (abs(pages[i] - start), abs(i - cut)))
while cut > 0 and heading[cut - 1]:
cut -= 1
if cut > (cuts[-1] if cuts else 0):
cuts.append(cut)
last_page = max(page_texts, default=0)
bounds = [0, *cuts, len(blocks)]
sections = []
for a, b in zip(bounds, bounds[1:]):
first = pages[a] if a else 0 # Titelei ab Seite 1, auch ohne Text (Cover)
# Ende: bis vor die nächste Startseite, mindestens bis zum Ende des letzten
# Absatzes (der über die Seitengrenze reichen kann).
end = max(
max(last for _, last in spans[a:b]),
pages[b] - 1 if b < len(blocks) else last_page,
)
name = f"Seite {first + 1}" if end == first else f"Seiten {first + 1}-{end + 1}"
sections.append({
"title": block_heading(blocks[a]) or name,
"body": "".join(blocks[a:b]).strip() + "\n",
"source_pages": [first + 1, end + 1],
})
return [s for s in sections if s["body"].strip()]
def _page_key(text: str) -> str: def _page_key(text: str) -> str:
return re.sub(r"[\W_]+", "", clean_inline(text).lower()) return re.sub(r"[\W_]+", "", clean_inline(text).lower())
@ -2359,10 +2547,12 @@ def markdown_to_epub(
title: str | None = None, title: str | None = None,
author: str | None = None, author: str | None = None,
language: str | None = None, language: str | None = None,
cover: Path | None = None,
) -> None: ) -> None:
"""EPUB aus Markdown-Datei oder Split-Ordner; Angaben aus dem YAML-Kopf.""" """EPUB aus Markdown-Datei oder Split-Ordner; Angaben aus dem YAML-Kopf."""
meta, body, base = read_markdown_source(source) meta, body, base = read_markdown_source(source)
cover = base / meta["cover"] if meta.get("cover") else None if cover is None and meta.get("cover"):
cover = base / meta["cover"]
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", suffix=".md", dir=base, delete=False "w", encoding="utf-8", suffix=".md", dir=base, delete=False
) as tmp: ) as tmp:
@ -2381,6 +2571,38 @@ def markdown_to_epub(
Path(tmp.name).unlink(missing_ok=True) Path(tmp.name).unlink(missing_ok=True)
def epubs_per_file(folder: Path, files: list[Path], meta: dict) -> Path:
"""--epub-per-file: ein EPUB je Einzeldatei im Unterordner "epub/"."""
target = folder / "epub"
target.mkdir(exist_ok=True)
cover = folder / meta["cover"] if meta.get("cover") else None
for file in files:
head, _ = split_frontmatter(file.read_text(encoding="utf-8"))
book = meta.get("title") or folder.name
markdown_to_epub(
file,
target / f"{file.stem}.epub",
title=f"{book} - {head.get('title') or file.stem}",
author=meta.get("author"),
language=meta.get("lang"),
cover=cover if cover and cover.is_file() else None,
)
return target
def rebase_image_links(markdown: str, old_base: Path, new_base: Path) -> str:
"""Relative Bildpfade von old_base auf new_base umrechnen."""
def fix(match: re.Match) -> str:
link = match.group(2)
if re.match(r"^[a-z][a-z0-9+.-]*:", link, re.IGNORECASE) or Path(link).is_absolute():
return match.group(0) # URL, data: oder absoluter Pfad
new = os.path.relpath(old_base / link, new_base)
return f"{match.group(1)}{Path(new).as_posix()})"
return IMAGE_LINK.sub(fix, markdown)
# =========================================================================== # ===========================================================================
# Kommandozeile # Kommandozeile
# =========================================================================== # ===========================================================================
@ -2465,9 +2687,35 @@ def main() -> int:
) )
parser.add_argument( parser.add_argument(
"--split", "--split",
choices=["teile", "kapitel"], choices=["teile", "kapitel", "seiten"],
help="Teile bzw. Kapitel als einzelne Markdown-Dateien in einem Ordner " help="Teile, Kapitel oder Seitenbereiche der PDF als einzelne Markdown-Dateien "
"(mit index.md für die Reihenfolge) statt einer Datei", "in einem Ordner (mit index.md für die Reihenfolge) statt einer Datei",
)
parser.add_argument(
"--pages-per-file",
type=int,
metavar="N",
help=f"mit --split seiten: etwa N PDF-Seiten je Datei, Schnitt an Absatzgrenzen, "
f"bevorzugt an einer Überschrift (Standard: {PAGES_PER_FILE})",
)
parser.add_argument(
"--page-ranges",
metavar="BEREICHE",
help="mit --split seiten: feste Bereiche der PDF-Seiten, z. B. 1-12,13-40,41- "
"(Seiten außerhalb werden eigene Dateien)",
)
parser.add_argument(
"--split-chars",
type=int,
default=SPLIT_CHARS,
metavar="N",
help="--split teile|kapitel ohne Gliederung: Abschnitte von etwa N Zeichen "
"(Standard: %(default)s)",
)
parser.add_argument(
"--epub-per-file",
action="store_true",
help="mit --split: zusätzlich ein EPUB je Einzeldatei im Unterordner epub/",
) )
parser.add_argument( parser.add_argument(
"--from-md", "--from-md",
@ -2502,6 +2750,16 @@ def main() -> int:
args = parser.parse_args() args = parser.parse_args()
if not 1 <= args.jpeg_quality <= 100: if not 1 <= args.jpeg_quality <= 100:
parser.error("--jpeg-quality muss zwischen 1 und 100 liegen.") parser.error("--jpeg-quality muss zwischen 1 und 100 liegen.")
if (args.pages_per_file is not None or args.page_ranges) and args.split != "seiten":
parser.error("--pages-per-file und --page-ranges nur mit --split seiten.")
if args.pages_per_file is not None and args.page_ranges:
parser.error("--pages-per-file und --page-ranges schließen sich aus.")
if args.pages_per_file is not None and args.pages_per_file < 1:
parser.error("--pages-per-file muss mindestens 1 sein.")
if args.split_chars < 1000:
parser.error("--split-chars muss mindestens 1000 sein.")
if args.epub_per_file and not args.split:
parser.error("--epub-per-file nur mit --split.")
if args.from_md: if args.from_md:
return epub_from_markdown(args, parser) return epub_from_markdown(args, parser)
@ -2520,8 +2778,9 @@ def main() -> int:
epub_path = None epub_path = None
if args.epub or args.epub_file: if args.epub or args.epub_file:
args.images = True # EPUB ohne Bilder wäre unvollständig
epub_path = (args.epub_file or target.with_suffix(".epub")).expanduser() epub_path = (args.epub_file or target.with_suffix(".epub")).expanduser()
if epub_path or args.epub_per_file:
args.images = True # EPUB ohne Bilder wäre unvollständig
# --split: Ordner "<name>/" mit Einzeldateien und "images/" statt einer Datei # --split: Ordner "<name>/" mit Einzeldateien und "images/" statt einer Datei
folder = target.with_suffix("") if args.split else None folder = target.with_suffix("") if args.split else None
@ -2583,7 +2842,7 @@ def main() -> int:
keep_lines=args.keep_lines, keep_lines=args.keep_lines,
titles=titles, titles=titles,
) )
markdown = extract_markdown(source, plan, options, args.jobs) markdown, page_texts = extract_markdown(source, plan, options, args.jobs)
if args.images: if args.images:
markdown = make_image_links_relative(markdown, image_dir) markdown = make_image_links_relative(markdown, image_dir)
@ -2605,8 +2864,17 @@ def main() -> int:
"lang": args.lang, "lang": args.lang,
"cover": cover.relative_to(base).as_posix() if cover else None, "cover": cover.relative_to(base).as_posix() if cover else None,
} }
if folder: if args.split == "seiten":
sections = split_markdown(markdown, args.split) if args.page_ranges:
starts = parse_page_ranges(args.page_ranges, page_count)
else:
starts = list(range(0, page_count, args.pages_per_file or PAGES_PER_FILE))
sections = split_by_pages(
markdown, starts, page_texts, align=not args.page_ranges
)
files = write_split(folder, sections, meta)
elif folder:
sections = split_markdown(markdown, args.split, args.split_chars)
if sections and sections[0]["title"] == "Abschnitt 1": if sections and sections[0]["title"] == "Abschnitt 1":
print("Hinweis: keine Gliederung erkannt - nach Länge geteilt.") print("Hinweis: keine Gliederung erkannt - nach Länge geteilt.")
with pymupdf.open(source) as doc: with pymupdf.open(source) as doc:
@ -2617,6 +2885,8 @@ def main() -> int:
if epub_path: if epub_path:
markdown_to_epub(folder or target, epub_path) markdown_to_epub(folder or target, epub_path)
if args.epub_per_file:
epub_dir = epubs_per_file(folder, files, meta)
except Exception as exc: except Exception as exc:
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr) print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
@ -2630,6 +2900,8 @@ def main() -> int:
print(f"Bilder gespeichert in: {image_dir}") print(f"Bilder gespeichert in: {image_dir}")
if epub_path: if epub_path:
print(f"EPUB geschrieben: {epub_path}") print(f"EPUB geschrieben: {epub_path}")
if args.epub_per_file:
print(f"Einzel-EPUBs geschrieben: {len(files)} in {epub_dir}")
return 0 return 0
@ -2639,6 +2911,8 @@ def epub_from_markdown(args, parser) -> int:
source = args.from_md.expanduser() source = args.from_md.expanduser()
if not source.exists(): if not source.exists():
parser.error(f"Nicht gefunden: {source}") parser.error(f"Nicht gefunden: {source}")
if args.split:
return split_from_markdown(args, parser, source)
base = source if source.is_dir() else source.with_suffix("") base = source if source.is_dir() else source.with_suffix("")
epub_path = (args.epub_file or base.with_suffix(".epub")).expanduser() epub_path = (args.epub_file or base.with_suffix(".epub")).expanduser()
try: try:
@ -2652,5 +2926,38 @@ def epub_from_markdown(args, parser) -> int:
return 0 return 0
def split_from_markdown(args, parser, source: Path) -> int:
"""--from-md DATEI --split teile|kapitel: Gesamt-Markdown in einen Ordner teilen."""
if args.split == "seiten":
parser.error("--split seiten braucht die PDF (Seitenzuordnung), nicht --from-md.")
if not source.is_file():
parser.error(f"--split mit --from-md braucht eine Markdown-Datei: {source}")
folder = source.with_suffix("")
try:
meta, body = split_frontmatter(source.read_text(encoding="utf-8"))
body = rebase_image_links(body, source.parent, folder)
if meta.get("cover"):
meta["cover"] = Path(os.path.relpath(source.parent / meta["cover"], folder)).as_posix()
meta["title"] = args.title or meta.get("title") or source.stem
meta["author"] = args.author or meta.get("author")
meta["lang"] = args.lang or meta.get("lang")
sections = split_markdown(body, args.split, args.split_chars)
if sections and sections[0]["title"] == "Abschnitt 1":
print("Hinweis: keine Gliederung erkannt - nach Länge geteilt.")
files = write_split(folder, sections, meta)
print(f"Markdown geschrieben: {len(files)} Dateien in {folder} (Reihenfolge: index.md)")
if args.epub or args.epub_file:
epub_path = (args.epub_file or folder.with_suffix(".epub")).expanduser()
markdown_to_epub(folder, epub_path)
print(f"EPUB geschrieben: {epub_path}")
if args.epub_per_file:
epub_dir = epubs_per_file(folder, files, meta)
print(f"Einzel-EPUBs geschrieben: {len(files)} in {epub_dir}")
except Exception as exc:
print(f"Fehler beim Aufteilen: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

180
tests/test_split_pages.py Normal file
View file

@ -0,0 +1,180 @@
"""--split seiten, --page-ranges, Nachspann, --split-chars, --from-md --split, --epub-per-file."""
import zipfile
import pytest
import pdf2md as p
from conftest import needs_pandoc, needs_tesseract
from test_e2e import run
from test_split import BOOK
# Rohtext je Seite (wie von pymupdf4llm) und das daraus normalisierte Markdown:
# Absatz B läuft von Seite 2 auf Seite 3 (mit Silbentrennung), auf Seite 3
# steht vor Absatz D eine Überschrift.
PAGE_TEXTS = {
0: "Absatz A steht ganz auf der ersten Seite.\n",
1: "Absatz B beginnt auf der zweiten Seite und reicht mit einem Geheim-\n",
2: "dienst bis auf die dritte Seite.\n\n## Neues Kapitel\n\nAbsatz D folgt danach.\n",
3: "Absatz E auf der vierten Seite.\n",
}
MARKDOWN = (
"Absatz A steht ganz auf der ersten Seite.\n\n"
"Absatz B beginnt auf der zweiten Seite und reicht mit einem Geheimdienst bis auf "
"die dritte Seite.\n\n"
"## Neues Kapitel\n\n"
"Absatz D folgt danach.\n\n"
"Absatz E auf der vierten Seite.\n"
)
def _flat(text: str) -> str:
return text.replace("\n", "")
def test_md_blocks_keep_text():
assert "".join(p.md_blocks(BOOK)) == BOOK
assert "".join(p.md_blocks(MARKDOWN)) == MARKDOWN
def test_block_pages_start_and_end():
spans = p.block_pages(p.md_blocks(MARKDOWN), PAGE_TEXTS)
assert spans == [(0, 0), (1, 2), (2, 2), (2, 2), (3, 3)]
def test_block_pages_inherit_unknown_blocks():
blocks = ["Absatz A steht ganz auf der ersten Seite.\n\n", "![](images/x.jpg)\n\n", "Ja.\n\n",
"Absatz E auf der vierten Seite.\n"]
assert [s for s, _ in p.block_pages(blocks, PAGE_TEXTS)] == [0, 0, 0, 3]
def test_split_by_pages_keeps_paragraph_over_page_break():
sections = p.split_by_pages(MARKDOWN, [0, 2], PAGE_TEXTS, align=False)
# Absatz B beginnt auf Seite 2 -> bleibt vorn; geschnitten wird vor der
# Überschrift, die auf Seite 3 beginnt.
assert sections[0]["body"].endswith("die dritte Seite.\n")
assert sections[0]["source_pages"] == [1, 3]
assert sections[1]["body"].startswith("## Neues Kapitel")
assert sections[1]["title"] == "Neues Kapitel" and sections[1]["source_pages"] == [3, 4]
assert _flat("".join(s["body"] for s in sections)) == _flat(MARKDOWN)
def test_split_by_pages_heading_moves_to_next_file():
# Grenze vor Seite 3: der erste dort beginnende Block ist Absatz D; die
# Überschrift davor gehört zur neuen Datei.
texts = PAGE_TEXTS | {2: "dienst bis auf die dritte Seite.\n\n## Neues Kapitel\n",
3: "Absatz D folgt danach.\n\nAbsatz E auf der vierten Seite.\n"}
sections = p.split_by_pages(MARKDOWN, [0, 3], texts, align=False)
assert sections[1]["body"].startswith("## Neues Kapitel\n\nAbsatz D")
assert not sections[0]["body"].rstrip().endswith("Kapitel")
def test_split_by_pages_aligns_to_nearby_heading():
# Grenze vor Seite 4 (Absatz E); die Überschrift auf Seite 3 liegt eine Seite davor.
aligned = p.split_by_pages(MARKDOWN, [0, 3], PAGE_TEXTS, align=True)
strict = p.split_by_pages(MARKDOWN, [0, 3], PAGE_TEXTS, align=False)
assert aligned[1]["body"].startswith("## Neues Kapitel")
assert strict[1]["body"].startswith("Absatz E") and strict[1]["title"] == "Seite 4"
@pytest.mark.parametrize("starts", [[0, 1], [0, 1, 2, 3], [0, 2], [0]])
def test_split_by_pages_loses_nothing(starts):
for align in (True, False):
sections = p.split_by_pages(MARKDOWN, starts, PAGE_TEXTS, align=align)
assert _flat("".join(s["body"] for s in sections)) == _flat(MARKDOWN)
def test_parse_page_ranges():
assert p.parse_page_ranges("1-12,13-40,41-", 100) == [0, 12, 40]
assert p.parse_page_ranges("5-10, 20-", 30) == [0, 4, 10, 19] # Lücken = eigene Dateien
assert p.parse_page_ranges("3", 10) == [0, 2, 3]
for bad in ("1-12,10-20", "0-5", "5-200", "a-b", "8-3"):
with pytest.raises(ValueError):
p.parse_page_ranges(bad, 100)
def test_backmatter_becomes_own_file():
book = BOOK.replace("## Impressum\n\nAlle Rechte vorbehalten.\n",
"### Der Autor\n\nLebt in Berlin.\n\n### Impressum\n\nRechte.\n")
sections = p.split_markdown(book, "teile")
assert [s["title"] for s in sections] == ["Vorspann", "Teil I: Der Anfang", "Nachspann"]
assert sections[-1]["body"].startswith("### Der Autor") and "Impressum" in sections[-1]["body"]
assert _flat("".join(s["body"] for s in sections)) == _flat(book)
def test_split_chars_and_heading_titles():
paragraphs = [f"Absatz {n} " + " ".join(["Text"] * 40) for n in range(20)]
markdown = "## Erster Teil\n\n" + "\n\n".join(paragraphs) + "\n"
sections = p.split_markdown(markdown, "kapitel", size=1500)
assert sections[0]["title"] == "Erster Teil" and sections[1]["title"] == "Abschnitt 2"
assert len(p.split_markdown(markdown, "kapitel", size=100_000)) == 1
def test_rebase_image_links(tmp_path):
md = "![](buch_images/a.jpg) ![x](https://e.de/b.png) ![](/abs/c.jpg)"
out = p.rebase_image_links(md, tmp_path, tmp_path / "buch")
assert out == "![](../buch_images/a.jpg) ![x](https://e.de/b.png) ![](/abs/c.jpg)"
# --- Ende zu Ende ------------------------------------------------------------------
def _heads(folder):
return [p.split_frontmatter(f.read_text())[0] for f in sorted(folder.glob("0*.md"))]
def test_cli_split_pages(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--split", "seiten", "--pages-per-file", "1")
heads = _heads(tmp_path / "buch")
assert [h["source_pages"] for h in heads] == [[1, 1], [2, 3], [3, 3], [4, 4]]
assert [h["title"] for h in heads] == ["Das Testbuch", "Teil I: Der Anfang", "2 Ein Brief", "Seite 4"]
whole = tmp_path / "ganz.md"
run(pdfs["digital"], whole)
body = "\n\n".join(p.split_frontmatter(f.read_text())[1].strip()
for f in sorted((tmp_path / "buch").glob("0*.md")))
assert _flat(body) == _flat(p.split_frontmatter(whole.read_text())[1].strip())
def test_cli_page_ranges(pdfs, tmp_path):
run(pdfs["digital"], tmp_path / "buch.md", "--split", "seiten", "--page-ranges", "2-3")
assert [h["source_pages"][0] for h in _heads(tmp_path / "buch")] == [1, 2, 4]
@needs_tesseract
def test_cli_split_pages_scanned(pdfs, tmp_path):
# Seitenzuordnung über den OCR-Text (die Scanseiten haben selbst keinen Text)
run(pdfs["scanned"], tmp_path / "buch.md", "--split", "seiten", "--pages-per-file", "2",
"--jobs", "1")
heads = _heads(tmp_path / "buch")
assert len(heads) == 2 and heads[1]["source_pages"][0] == 3
def test_cli_split_options_are_checked(pdfs, tmp_path):
import subprocess
import sys
for extra in (["--pages-per-file", "3"], ["--split", "kapitel", "--page-ranges", "1-2"],
["--epub-per-file"]):
done = subprocess.run([sys.executable, str(p.__file__), str(pdfs["digital"]),
str(tmp_path / "x.md"), *extra], capture_output=True, text=True)
assert done.returncode == 2 and "nur mit --split" in done.stderr
@needs_pandoc
def test_from_md_split_and_epub_per_file(pdfs, tmp_path):
md = tmp_path / "buch.md"
run(pdfs["digital"], md, "--images")
run("--from-md", md, "--split", "kapitel", "--epub-per-file")
folder = tmp_path / "buch"
names = sorted(f.name for f in folder.glob("*.md"))
assert "index.md" in names and any(n.endswith("_1-der-notar.md") for n in names)
text = "".join(f.read_text() for f in folder.glob("0*.md"))
assert "](../buch_images/" in text
meta, _ = p.split_frontmatter((folder / "index.md").read_text())
assert meta["cover"] == "../buch_images/cover.jpg"
epubs = sorted((folder / "epub").glob("*.epub"))
assert len(epubs) == len(names) - 1
notar = next(e for e in epubs if "1-der-notar" in e.name)
with zipfile.ZipFile(notar) as z:
opf = z.read("EPUB/content.opf").decode()
images = [n for n in z.namelist() if n.endswith(".jpg")]
assert "Das Testbuch - 1 Der Notar" in opf and "cover" in opf and images
assert not (tmp_path / "buch.epub").exists() # Gesamt-EPUB nur mit --epub