--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:
parent
b05e2d9cf5
commit
5cd5e5d69b
6 changed files with 568 additions and 39 deletions
|
|
@ -10,8 +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.
|
||||
- **Weg C:** wie Weg B, aber jeder Teil, jedes Kapitel oder jeder Seitenbereich in einer
|
||||
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
|
||||
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
|
||||
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.
|
||||
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`
|
||||
|
|
@ -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) |
|
||||
| `--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) |
|
||||
| `--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) |
|
||||
| `--title`, `--author` | Titel/Autor im EPUB vorgeben |
|
||||
| `--lang` | Sprache im EPUB (Standard: aus `--ocr-language` abgeleitet, `deu`→`de`, `eng`→`en`; sonst `de`) |
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ Aktuelles Programm: `pdf2md.py` (Einzeldatei; Name bleibt fest, keine Versionsnu
|
|||
`tests/test_verbatim.py` noch grün sind.
|
||||
- Mit pymupdf4llm 1.28.2 getestet (Bildtext-Format `<!-- Start of picture text -->`, OCR-Text
|
||||
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
|
||||
Kopie entfernt (`drop_invisible_text`), sonst behält pymupdf4llm fremde OCR-Ebenen.
|
||||
Mit `--keep-lines` werden Scanseiten an pymupdf4llm vorbei direkt aus Tesseract gelesen
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -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
|
||||
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)
|
||||
python3 pdf2md.py --from-md buch.md
|
||||
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) |
|
||||
| `--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 |
|
||||
| `--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“ |
|
||||
| `--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) |
|
||||
| `--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 |
|
||||
|
|
@ -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).
|
||||
`--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“ …
|
||||
- **`--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
|
||||
|
||||
|
|
|
|||
3
TODO.md
3
TODO.md
|
|
@ -40,6 +40,7 @@
|
|||
|
||||
- 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
|
||||
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
|
||||
(Ljutsch Band 1/2, Zensur-Report, Markdown Guide, Register, Rust-Kochbuch, Kempis-Scan).
|
||||
|
|
|
|||
369
pdf2md.py
369
pdf2md.py
|
|
@ -13,6 +13,7 @@ Ablauf:
|
|||
"""
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import contextlib
|
||||
import difflib
|
||||
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
|
||||
_keep_lines[0] = options.get("keep_lines", False)
|
||||
kwargs = dict(
|
||||
|
|
@ -1814,6 +1816,8 @@ def convert_pages(source: str, run: str, pages: list[int], options: dict) -> str
|
|||
write_images=options["images"],
|
||||
image_path=options["image_path"],
|
||||
image_format=options["image_format"],
|
||||
# Text je Seite; aneinandergehängt identisch mit page_chunks=False
|
||||
page_chunks=True,
|
||||
)
|
||||
if run == RUN_OCR:
|
||||
# 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.
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
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"):
|
||||
with pymupdf.open(source) as doc:
|
||||
return "".join(
|
||||
return [
|
||||
ocr_lines_md(
|
||||
doc[pno], options["ocr_language"], options["ocr_dpi"],
|
||||
options.get("titles", {}).get(pno, []),
|
||||
)
|
||||
for pno in pages
|
||||
)
|
||||
]
|
||||
# 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.
|
||||
with pymupdf.open(source) as doc:
|
||||
for pno in pages:
|
||||
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.
|
||||
|
|
@ -1968,7 +1972,10 @@ def _init_worker() -> None:
|
|||
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)
|
||||
total = len(tasks)
|
||||
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 = []
|
||||
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
|
||||
# ohnehin alle Kerne.
|
||||
if jobs <= 1 or total == 1 or RUN_OCR not in plan:
|
||||
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)
|
||||
else:
|
||||
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 i, (future, (run, pages)) in enumerate(zip(futures, tasks), 1):
|
||||
parts.append(future.result())
|
||||
collect(pages, future.result())
|
||||
report(i, run, pages)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
return "\n\n".join(parts), page_texts
|
||||
|
||||
|
||||
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
|
||||
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]:
|
||||
"""Ohne verwertbare Überschriften: an Absatzgrenzen in etwa gleich lange Stücke."""
|
||||
def md_blocks(markdown: str) -> list[str]:
|
||||
"""Absätze samt folgendem Leerraum; Codeblöcke bleiben ganz.
|
||||
|
||||
Aneinandergehängt ergeben die Blöcke wieder genau den Text.
|
||||
"""
|
||||
blocks, fence = [], False
|
||||
for part in re.split(r"(\n\n+)", markdown):
|
||||
if blocks and fence:
|
||||
|
|
@ -2200,31 +2230,47 @@ def split_by_length(markdown: str, size: int = SPLIT_CHARS) -> list[dict]:
|
|||
else:
|
||||
blocks[-1] += part
|
||||
fence ^= sum(1 for line in part.split("\n") if FENCE.match(line)) % 2 == 1
|
||||
sections, current = [], ""
|
||||
for block in blocks:
|
||||
if current and len(current) + len(block) > size:
|
||||
return blocks
|
||||
|
||||
|
||||
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)
|
||||
current = ""
|
||||
current += block
|
||||
if current.strip():
|
||||
current = []
|
||||
current.append(block)
|
||||
if "".join(current).strip():
|
||||
sections.append(current)
|
||||
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".
|
||||
|
||||
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")
|
||||
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 split_by_length(markdown)
|
||||
return split_by_length(markdown, size)
|
||||
part_level, chapter_level = levels[0], levels[-1]
|
||||
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
|
||||
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 = []
|
||||
first = cuts[0][0] if cuts else len(lines)
|
||||
front = "\n".join(lines[:first]).strip()
|
||||
|
|
@ -2244,6 +2304,10 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
|
|||
part = ""
|
||||
for n, (index, level, text) in enumerate(cuts):
|
||||
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))
|
||||
if level == part_level:
|
||||
part = title
|
||||
|
|
@ -2252,11 +2316,135 @@ def split_markdown(markdown: str, mode: str) -> list[dict]:
|
|||
"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",
|
||||
"body": body,
|
||||
})
|
||||
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:
|
||||
return re.sub(r"[\W_]+", "", clean_inline(text).lower())
|
||||
|
||||
|
|
@ -2359,10 +2547,12 @@ def markdown_to_epub(
|
|||
title: str | None = None,
|
||||
author: str | None = None,
|
||||
language: str | None = None,
|
||||
cover: Path | 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
|
||||
if cover is None and meta.get("cover"):
|
||||
cover = base / meta["cover"]
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", encoding="utf-8", suffix=".md", dir=base, delete=False
|
||||
) as tmp:
|
||||
|
|
@ -2381,6 +2571,38 @@ def markdown_to_epub(
|
|||
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
|
||||
# ===========================================================================
|
||||
|
|
@ -2465,9 +2687,35 @@ def main() -> int:
|
|||
)
|
||||
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",
|
||||
choices=["teile", "kapitel", "seiten"],
|
||||
help="Teile, Kapitel oder Seitenbereiche der PDF als einzelne Markdown-Dateien "
|
||||
"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(
|
||||
"--from-md",
|
||||
|
|
@ -2502,6 +2750,16 @@ def main() -> int:
|
|||
args = parser.parse_args()
|
||||
if not 1 <= args.jpeg_quality <= 100:
|
||||
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:
|
||||
return epub_from_markdown(args, parser)
|
||||
|
|
@ -2520,8 +2778,9 @@ def main() -> int:
|
|||
|
||||
epub_path = None
|
||||
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()
|
||||
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
|
||||
folder = target.with_suffix("") if args.split else None
|
||||
|
|
@ -2583,7 +2842,7 @@ def main() -> int:
|
|||
keep_lines=args.keep_lines,
|
||||
titles=titles,
|
||||
)
|
||||
markdown = extract_markdown(source, plan, options, args.jobs)
|
||||
markdown, page_texts = extract_markdown(source, plan, options, args.jobs)
|
||||
|
||||
if args.images:
|
||||
markdown = make_image_links_relative(markdown, image_dir)
|
||||
|
|
@ -2605,8 +2864,17 @@ def main() -> int:
|
|||
"lang": args.lang,
|
||||
"cover": cover.relative_to(base).as_posix() if cover else None,
|
||||
}
|
||||
if folder:
|
||||
sections = split_markdown(markdown, args.split)
|
||||
if args.split == "seiten":
|
||||
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":
|
||||
print("Hinweis: keine Gliederung erkannt - nach Länge geteilt.")
|
||||
with pymupdf.open(source) as doc:
|
||||
|
|
@ -2617,6 +2885,8 @@ def main() -> int:
|
|||
|
||||
if 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:
|
||||
print(f"Fehler bei der Konvertierung: {exc}", file=sys.stderr)
|
||||
|
|
@ -2630,6 +2900,8 @@ def main() -> int:
|
|||
print(f"Bilder gespeichert in: {image_dir}")
|
||||
if 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
|
||||
|
||||
|
|
@ -2639,6 +2911,8 @@ def epub_from_markdown(args, parser) -> int:
|
|||
source = args.from_md.expanduser()
|
||||
if not source.exists():
|
||||
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("")
|
||||
epub_path = (args.epub_file or base.with_suffix(".epub")).expanduser()
|
||||
try:
|
||||
|
|
@ -2652,5 +2926,38 @@ def epub_from_markdown(args, parser) -> int:
|
|||
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__":
|
||||
raise SystemExit(main())
|
||||
|
|
|
|||
180
tests/test_split_pages.py
Normal file
180
tests/test_split_pages.py
Normal 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", "\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 = "  "
|
||||
out = p.rebase_image_links(md, tmp_path, tmp_path / "buch")
|
||||
assert out == "  "
|
||||
|
||||
|
||||
# --- 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
|
||||
Loading…
Reference in a new issue