--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 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`) |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
# 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
|
||||||
|
|
||||||
|
|
|
||||||
3
TODO.md
3
TODO.md
|
|
@ -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
369
pdf2md.py
|
|
@ -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
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