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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-03 01:04:50 +02:00
commit 5cd5e5d69b

369
pdf2md.py
View file

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