From 5cd5e5d69b6a42e33bb58cbd95b4e9753d8bdf76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 3 Oct 2026 01:04:50 +0200 Subject: [PATCH] --split seiten, --epub-per-file, Nachspann, --from-md --split, --split-chars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --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 /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 --- BEDIENUNGSANLEITUNG.md | 31 +++- CLAUDE.md | 3 + README.md | 21 ++- TODO.md | 3 +- pdf2md.py | 369 ++++++++++++++++++++++++++++++++++---- tests/test_split_pages.py | 180 +++++++++++++++++++ 6 files changed, 568 insertions(+), 39 deletions(-) create mode 100644 tests/test_split_pages.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index bb7d24b..e4026eb 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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`) | diff --git a/CLAUDE.md b/CLAUDE.md index 8e3646c..60d9a2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``, 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 diff --git a/README.md b/README.md index b43c926..d4ee350 100644 --- a/README.md +++ b/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 `/` 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 `/` 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 `/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 diff --git a/TODO.md b/TODO.md index 5b0fe61..f6c1349 100644 --- a/TODO.md +++ b/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). diff --git a/pdf2md.py b/pdf2md.py index 100793a..0ac317c 100644 --- a/pdf2md.py +++ b/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 "/" 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()) diff --git a/tests/test_split_pages.py b/tests/test_split_pages.py new file mode 100644 index 0000000..f23399a --- /dev/null +++ b/tests/test_split_pages.py @@ -0,0 +1,180 @@ +"""--split seiten, --page-ranges, Nachspann, --split-chars, --from-md --split, --epub-per-file.""" + +import zipfile + +import pytest + +import pdf2md as p +from conftest import needs_pandoc, needs_tesseract +from test_e2e import run +from test_split import BOOK + +# Rohtext je Seite (wie von pymupdf4llm) und das daraus normalisierte Markdown: +# Absatz B läuft von Seite 2 auf Seite 3 (mit Silbentrennung), auf Seite 3 +# steht vor Absatz D eine Überschrift. +PAGE_TEXTS = { + 0: "Absatz A steht ganz auf der ersten Seite.\n", + 1: "Absatz B beginnt auf der zweiten Seite und reicht mit einem Geheim-\n", + 2: "dienst bis auf die dritte Seite.\n\n## Neues Kapitel\n\nAbsatz D folgt danach.\n", + 3: "Absatz E auf der vierten Seite.\n", +} +MARKDOWN = ( + "Absatz A steht ganz auf der ersten Seite.\n\n" + "Absatz B beginnt auf der zweiten Seite und reicht mit einem Geheimdienst bis auf " + "die dritte Seite.\n\n" + "## Neues Kapitel\n\n" + "Absatz D folgt danach.\n\n" + "Absatz E auf der vierten Seite.\n" +) + + +def _flat(text: str) -> str: + return text.replace("\n", "") + + +def test_md_blocks_keep_text(): + assert "".join(p.md_blocks(BOOK)) == BOOK + assert "".join(p.md_blocks(MARKDOWN)) == MARKDOWN + + +def test_block_pages_start_and_end(): + spans = p.block_pages(p.md_blocks(MARKDOWN), PAGE_TEXTS) + assert spans == [(0, 0), (1, 2), (2, 2), (2, 2), (3, 3)] + + +def test_block_pages_inherit_unknown_blocks(): + blocks = ["Absatz A steht ganz auf der ersten Seite.\n\n", "![](images/x.jpg)\n\n", "Ja.\n\n", + "Absatz E auf der vierten Seite.\n"] + assert [s for s, _ in p.block_pages(blocks, PAGE_TEXTS)] == [0, 0, 0, 3] + + +def test_split_by_pages_keeps_paragraph_over_page_break(): + sections = p.split_by_pages(MARKDOWN, [0, 2], PAGE_TEXTS, align=False) + # Absatz B beginnt auf Seite 2 -> bleibt vorn; geschnitten wird vor der + # Überschrift, die auf Seite 3 beginnt. + assert sections[0]["body"].endswith("die dritte Seite.\n") + assert sections[0]["source_pages"] == [1, 3] + assert sections[1]["body"].startswith("## Neues Kapitel") + assert sections[1]["title"] == "Neues Kapitel" and sections[1]["source_pages"] == [3, 4] + assert _flat("".join(s["body"] for s in sections)) == _flat(MARKDOWN) + + +def test_split_by_pages_heading_moves_to_next_file(): + # Grenze vor Seite 3: der erste dort beginnende Block ist Absatz D; die + # Überschrift davor gehört zur neuen Datei. + texts = PAGE_TEXTS | {2: "dienst bis auf die dritte Seite.\n\n## Neues Kapitel\n", + 3: "Absatz D folgt danach.\n\nAbsatz E auf der vierten Seite.\n"} + sections = p.split_by_pages(MARKDOWN, [0, 3], texts, align=False) + assert sections[1]["body"].startswith("## Neues Kapitel\n\nAbsatz D") + assert not sections[0]["body"].rstrip().endswith("Kapitel") + + +def test_split_by_pages_aligns_to_nearby_heading(): + # Grenze vor Seite 4 (Absatz E); die Überschrift auf Seite 3 liegt eine Seite davor. + aligned = p.split_by_pages(MARKDOWN, [0, 3], PAGE_TEXTS, align=True) + strict = p.split_by_pages(MARKDOWN, [0, 3], PAGE_TEXTS, align=False) + assert aligned[1]["body"].startswith("## Neues Kapitel") + assert strict[1]["body"].startswith("Absatz E") and strict[1]["title"] == "Seite 4" + + +@pytest.mark.parametrize("starts", [[0, 1], [0, 1, 2, 3], [0, 2], [0]]) +def test_split_by_pages_loses_nothing(starts): + for align in (True, False): + sections = p.split_by_pages(MARKDOWN, starts, PAGE_TEXTS, align=align) + assert _flat("".join(s["body"] for s in sections)) == _flat(MARKDOWN) + + +def test_parse_page_ranges(): + assert p.parse_page_ranges("1-12,13-40,41-", 100) == [0, 12, 40] + assert p.parse_page_ranges("5-10, 20-", 30) == [0, 4, 10, 19] # Lücken = eigene Dateien + assert p.parse_page_ranges("3", 10) == [0, 2, 3] + for bad in ("1-12,10-20", "0-5", "5-200", "a-b", "8-3"): + with pytest.raises(ValueError): + p.parse_page_ranges(bad, 100) + + +def test_backmatter_becomes_own_file(): + book = BOOK.replace("## Impressum\n\nAlle Rechte vorbehalten.\n", + "### Der Autor\n\nLebt in Berlin.\n\n### Impressum\n\nRechte.\n") + sections = p.split_markdown(book, "teile") + assert [s["title"] for s in sections] == ["Vorspann", "Teil I: Der Anfang", "Nachspann"] + assert sections[-1]["body"].startswith("### Der Autor") and "Impressum" in sections[-1]["body"] + assert _flat("".join(s["body"] for s in sections)) == _flat(book) + + +def test_split_chars_and_heading_titles(): + paragraphs = [f"Absatz {n} " + " ".join(["Text"] * 40) for n in range(20)] + markdown = "## Erster Teil\n\n" + "\n\n".join(paragraphs) + "\n" + sections = p.split_markdown(markdown, "kapitel", size=1500) + assert sections[0]["title"] == "Erster Teil" and sections[1]["title"] == "Abschnitt 2" + assert len(p.split_markdown(markdown, "kapitel", size=100_000)) == 1 + + +def test_rebase_image_links(tmp_path): + md = "![](buch_images/a.jpg) ![x](https://e.de/b.png) ![](/abs/c.jpg)" + out = p.rebase_image_links(md, tmp_path, tmp_path / "buch") + assert out == "![](../buch_images/a.jpg) ![x](https://e.de/b.png) ![](/abs/c.jpg)" + + +# --- Ende zu Ende ------------------------------------------------------------------ + +def _heads(folder): + return [p.split_frontmatter(f.read_text())[0] for f in sorted(folder.glob("0*.md"))] + + +def test_cli_split_pages(pdfs, tmp_path): + run(pdfs["digital"], tmp_path / "buch.md", "--split", "seiten", "--pages-per-file", "1") + heads = _heads(tmp_path / "buch") + assert [h["source_pages"] for h in heads] == [[1, 1], [2, 3], [3, 3], [4, 4]] + assert [h["title"] for h in heads] == ["Das Testbuch", "Teil I: Der Anfang", "2 Ein Brief", "Seite 4"] + whole = tmp_path / "ganz.md" + run(pdfs["digital"], whole) + body = "\n\n".join(p.split_frontmatter(f.read_text())[1].strip() + for f in sorted((tmp_path / "buch").glob("0*.md"))) + assert _flat(body) == _flat(p.split_frontmatter(whole.read_text())[1].strip()) + + +def test_cli_page_ranges(pdfs, tmp_path): + run(pdfs["digital"], tmp_path / "buch.md", "--split", "seiten", "--page-ranges", "2-3") + assert [h["source_pages"][0] for h in _heads(tmp_path / "buch")] == [1, 2, 4] + + +@needs_tesseract +def test_cli_split_pages_scanned(pdfs, tmp_path): + # Seitenzuordnung über den OCR-Text (die Scanseiten haben selbst keinen Text) + run(pdfs["scanned"], tmp_path / "buch.md", "--split", "seiten", "--pages-per-file", "2", + "--jobs", "1") + heads = _heads(tmp_path / "buch") + assert len(heads) == 2 and heads[1]["source_pages"][0] == 3 + + +def test_cli_split_options_are_checked(pdfs, tmp_path): + import subprocess + import sys + for extra in (["--pages-per-file", "3"], ["--split", "kapitel", "--page-ranges", "1-2"], + ["--epub-per-file"]): + done = subprocess.run([sys.executable, str(p.__file__), str(pdfs["digital"]), + str(tmp_path / "x.md"), *extra], capture_output=True, text=True) + assert done.returncode == 2 and "nur mit --split" in done.stderr + + +@needs_pandoc +def test_from_md_split_and_epub_per_file(pdfs, tmp_path): + md = tmp_path / "buch.md" + run(pdfs["digital"], md, "--images") + run("--from-md", md, "--split", "kapitel", "--epub-per-file") + folder = tmp_path / "buch" + names = sorted(f.name for f in folder.glob("*.md")) + assert "index.md" in names and any(n.endswith("_1-der-notar.md") for n in names) + text = "".join(f.read_text() for f in folder.glob("0*.md")) + assert "](../buch_images/" in text + meta, _ = p.split_frontmatter((folder / "index.md").read_text()) + assert meta["cover"] == "../buch_images/cover.jpg" + epubs = sorted((folder / "epub").glob("*.epub")) + assert len(epubs) == len(names) - 1 + notar = next(e for e in epubs if "1-der-notar" in e.name) + with zipfile.ZipFile(notar) as z: + opf = z.read("EPUB/content.opf").decode() + images = [n for n in z.namelist() if n.endswith(".jpg")] + assert "Das Testbuch - 1 Der Notar" in opf and "cover" in opf and images + assert not (tmp_path / "buch.epub").exists() # Gesamt-EPUB nur mit --epub