feat: epub2md_merge.py – Gesamtdatei aus einer epub2md-Ausgabe

Fügt die Kapiteldateien in Lesereihenfolge (manifest.json) zu einer
Markdown-Datei mit dem Original-EPUB-Namen (.md) zusammen. Dabei werden:
- Links zwischen Kapiteln zu Sprunglinks in der Gesamtdatei,
- kollidierende Anker pro Kapitel umbenannt,
- Medienpfade relativ zum Ziel umgeschrieben,
- doppelte Fußnotendefinitionen entfernt,
- Code nie verändert.
Optionen -o, --toc, --no-frontmatter und --force; abschließender
Linkcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-05 20:24:47 +02:00
commit 88888e5587
4 changed files with 448 additions and 0 deletions

View file

@ -209,6 +209,38 @@ weil CommonMark keine `[^…]`-Syntax kennt.
- **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne - **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne
Registerlinks werden auf Kapiteldatei + Anker umgeschrieben. Seitenzahlen bleiben als Text sichtbar. Registerlinks werden auf Kapiteldatei + Anker umgeschrieben. Seitenzahlen bleiben als Text sichtbar.
## Gesamtdatei erzeugen: `epub2md_merge.py`
Fügt die Kapiteldateien einer epub2md-Ausgabe in Lesereihenfolge (aus `manifest.json`) zu **einer**
Markdown-Datei zusammen. Die Datei heißt wie das Original-EPUB, aber mit der Endung `.md`.
```bash
python3 epub2md_merge.py output/programmierhandbuch
# → output/programmierhandbuch/<Original-Name>.md
python3 epub2md_merge.py output/programmierhandbuch -o buecher/handbuch.md --toc --force
```
| Option | Wirkung |
| --- | --- |
| `-o`, `--output DATEI.md` | anderer Zielort; Medienpfade werden relativ zum Ziel umgeschrieben |
| `--frontmatter` / `--no-frontmatter` | ein YAML-Kopf mit Buchmetadaten (`title`, `author`, `lang`, …) für Pandoc (Standard: an) |
| `--toc` | Inhaltsverzeichnis mit Sprunglinks auf die Kapitel einfügen |
| `--force` | bestehende Zieldatei überschreiben (sonst Abbruch mit Exit 1) |
Beim Zusammenfügen passiert Folgendes:
- Die Frontmatter der einzelnen Kapitel wird entfernt.
- Jedes Kapitel erhält einen Startanker (`<a id="003-kapitelname"></a>`).
- Links zwischen Kapiteln (`003-x.md#anker`) werden zu Sprunglinks in der Gesamtdatei.
- Anker, die in mehreren Kapiteln vorkommen (z. B. `Sec1` in jedem Kapitel eines Springer-Buchs), werden
ab dem zweiten Vorkommen in `c012-Sec1` umbenannt, samt aller Links darauf.
- Medienpfade (`../media/…`) werden auf den Ort der Gesamtdatei umgeschrieben.
- Fußnotendefinitionen, die in mehreren Kapiteln stehen, erscheinen nur einmal.
- Codeblöcke und Inline-Code werden nie verändert.
Am Ende werden alle Links der Gesamtdatei geprüft.
## Weiterverarbeitung mit Pandoc ## Weiterverarbeitung mit Pandoc
```bash ```bash
@ -260,6 +292,7 @@ Verlustfreiheit behauptet (`lossless: false`).
```text ```text
epub2md.py Einstiegspunkt epub2md.py Einstiegspunkt
epub2md_merge.py Einstiegspunkt: Gesamtdatei aus einer Ausgabe erzeugen (epub2md/merge.py)
epub2md/ epub2md/
├── cli.py argparse, Exit-Codes ├── cli.py argparse, Exit-Codes
├── pipeline.py Ablaufsteuerung, Schreiben (atomar), Bericht/Manifest ├── pipeline.py Ablaufsteuerung, Schreiben (atomar), Bericht/Manifest

333
epub2md/merge.py Normal file
View file

@ -0,0 +1,333 @@
"""Merge an epub2md output directory into one complete Markdown file.
The chapter order comes from manifest.json; the file name is the original EPUB name with ".md".
Links between chapters become links to anchors inside the merged file, colliding anchors are
renamed per chapter, media paths are made relative to the merged file and duplicated footnote
definitions are dropped. Code (fenced blocks, inline code) is never modified.
"""
from __future__ import annotations
import argparse
import json
import os
import posixpath
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import NoReturn
from urllib.parse import quote, unquote
from . import __version__
from .errors import EXIT_INPUT, EXIT_INTERNAL
from .linkcheck import check_links
from .utils import to_yaml
FENCE_OPEN = re.compile(r"^(\s*)(`{3,}|~{3,})")
# order matters: inline code is matched first and returned unchanged
TOKEN = re.compile(
r"(?P<code>(?<![\\`])(?P<ticks>`+)(?!`)(?:[^`\n]|`(?!(?P=ticks)))+?(?P=ticks)(?!`))"
r"|(?P<mdlink>(?P<lpre>(?<!\\)!?\[(?:[^\[\]\\\n]|\\.|\[(?:[^\[\]\\\n]|\\.)*\]|\n(?![ \t]*\n))*\]\()(?P<ltarget><[^>\n]*>|[^)\s]+)(?P<lpost>(?:\s+\"[^\"]*\")?\)))"
r"|(?P<attr>\b(?P<aname>href|src|id)=\"(?P<avalue>[^\"]*)\")"
)
FN_DEF = re.compile(r"^\[\^([^\]]+)\]:")
class MergeError(Exception):
pass
@dataclass
class ChapterFile:
number: int
title: str
rel: str # e.g. chapters/001-x.md (relative to the output directory)
body: str = ""
start_anchor: str = ""
anchor_map: dict[str, str] = field(default_factory=dict)
@dataclass
class MergeResult:
target: Path
chapters: int
renamed_anchors: int
dropped_footnote_defs: int
unresolved: list[str]
broken: list[dict[str, str]]
def strip_frontmatter(text: str) -> str:
if text.startswith("---\n"):
end = text.find("\n---\n", 4)
if end >= 0:
return text[end + 5 :].lstrip("\n")
return text
def load_book(out_dir: Path) -> tuple[dict, list[ChapterFile]]:
manifest_path = out_dir / "manifest.json"
report_path = out_dir / "conversion-report.json"
if not out_dir.is_dir():
raise MergeError(f"Verzeichnis nicht gefunden: {out_dir}")
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
if manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
entries = [(c["number"], c["title"], c["file"]) for c in manifest.get("chapters", [])]
else:
files = sorted((out_dir / "chapters").glob("*.md"))
entries = [(i + 1, p.stem, f"chapters/{p.name}") for i, p in enumerate(files)]
chapters = [ChapterFile(n, t, rel) for n, t, rel in entries if (out_dir / rel).is_file()]
missing = [rel for _, _, rel in entries if not (out_dir / rel).is_file()]
if missing:
raise MergeError(f"Kapiteldateien fehlen: {', '.join(missing[:5])}")
if not chapters:
raise MergeError(f"Keine Kapiteldateien in {out_dir} – ist das eine epub2md-Ausgabe?")
for ch in chapters:
ch.body = strip_frontmatter((out_dir / ch.rel).read_text(encoding="utf-8"))
return report, chapters
def default_target(out_dir: Path, report: dict) -> Path:
name = (report.get("input") or {}).get("name") or (out_dir.name + ".epub")
stem = name[:-5] if name.lower().endswith(".epub") else name
return out_dir / f"{stem}.md"
def _non_code_segments(text: str) -> list[tuple[bool, str]]:
"""Split into (is_code, text) segments by fenced code blocks (also indented ones)."""
segments: list[tuple[bool, str]] = []
buf: list[str] = []
fence: str | None = None
for line in text.split("\n"):
if fence is None:
m = FENCE_OPEN.match(line)
if m:
if buf:
segments.append((False, "\n".join(buf)))
buf = [line]
fence = m.group(2)
continue
buf.append(line)
else:
buf.append(line)
stripped = line.strip()
if stripped and set(stripped) == {fence[0]} and len(stripped) >= len(fence):
segments.append((True, "\n".join(buf)))
buf = []
fence = None
if buf:
segments.append((fence is not None, "\n".join(buf)))
return segments
def _join(segments: list[tuple[bool, str]]) -> str:
return "\n".join(s for _, s in segments)
def assign_anchors(chapters: list[ChapterFile]) -> int:
"""Map every anchor id to a book-wide unique name; returns the number of renamed anchors."""
used: set[str] = set()
renamed = 0
stems = {}
for ch in chapters:
stem = posixpath.splitext(posixpath.basename(ch.rel))[0]
stems[ch.rel] = stem
for ch in chapters:
ids: list[str] = []
for is_code, seg in _non_code_segments(ch.body):
if is_code:
continue
for m in TOKEN.finditer(seg):
if m.group("attr") and m.group("aname") == "id" and m.group("avalue") not in ids:
ids.append(m.group("avalue"))
start = stems[ch.rel]
while start in used or start in ids:
start = "kapitel-" + start
ch.start_anchor = start
used.add(start)
for raw in ids:
name = raw
if name in used:
name = f"c{ch.number:03d}-{raw}"
n = 2
while name in used:
name = f"c{ch.number:03d}-{raw}-{n}"
n += 1
renamed += 1
used.add(name)
ch.anchor_map[raw] = name
return renamed
def merge(out_dir: Path, target: Path | None = None, *, frontmatter: bool = True, toc: bool = False,
force: bool = False) -> MergeResult:
out_dir = out_dir.resolve()
report, chapters = load_book(out_dir)
target = (target or default_target(out_dir, report)).resolve()
if target.exists() and not force:
raise MergeError(f"Zieldatei existiert bereits: {target} (mit --force überschreiben)")
if target.parent.exists() is False:
raise MergeError(f"Zielverzeichnis existiert nicht: {target.parent}")
renamed = assign_anchors(chapters)
by_rel = {ch.rel: ch for ch in chapters}
target_dir = target.parent
unresolved: list[str] = []
def rewrite_target(link: str, ch: ChapterFile) -> str:
bracket = link.startswith("<") and link.endswith(">")
raw = link[1:-1] if bracket else link
if not raw or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", raw):
return link # external URL, mailto:, …
path, _, frag = raw.partition("#")
frag = unquote(frag)
if path:
resolved = posixpath.normpath(posixpath.join(posixpath.dirname(ch.rel), unquote(path)))
else:
resolved = ch.rel
dest = by_rel.get(resolved)
if dest is not None:
if frag and frag in dest.anchor_map:
new = "#" + dest.anchor_map[frag]
else:
if frag:
unresolved.append(f"{ch.rel}: {raw}")
new = "#" + dest.start_anchor
else:
absolute = out_dir / resolved
rel = os.path.relpath(absolute, target_dir).replace(os.sep, "/")
new = quote(rel, safe="/-._~") + (("#" + quote(frag, safe="-._~:")) if frag else "")
return f"<{new}>" if bracket else new
def rewrite_segment(seg: str, ch: ChapterFile) -> str:
def repl(m: re.Match[str]) -> str:
if m.group("code"):
return m.group(0)
if m.group("mdlink"):
pre = m.group("lpre")
inner = TOKEN.sub(repl, pre[pre.index("[") + 1 : -2]) # images/links inside the link text
return pre[: pre.index("[") + 1] + inner + "](" + rewrite_target(m.group("ltarget"), ch) + m.group("lpost")
name, value = m.group("aname"), m.group("avalue")
if name == "id":
return f'id="{ch.anchor_map.get(value, value)}"'
return f'{name}="{rewrite_target(value, ch)}"'
return TOKEN.sub(repl, seg)
seen_defs: set[str] = set()
dropped = 0
parts: list[str] = []
if frontmatter:
md = report.get("metadata") or {}
fm: dict = {}
for key, src in (("title", "title"), ("subtitle", "subtitle"), ("author", "authors"), ("lang", "language"),
("publisher", "publisher"), ("date", "published"), ("identifier", "identifier"),
("rights", "rights"), ("description", "description")):
if md.get(src):
fm[key] = md[src]
if fm:
parts.append("---\n" + to_yaml(fm) + "---")
if toc:
lines = ["## Inhalt" if (report.get("metadata") or {}).get("language", "de").startswith("de") else "## Contents", ""]
lines += [f"{ch.number}. [{_escape_link_text(ch.title)}](#{ch.start_anchor})" for ch in chapters]
parts.append("\n".join(lines))
for ch in chapters:
segments = [(is_code, seg if is_code else rewrite_segment(seg, ch)) for is_code, seg in _non_code_segments(ch.body)]
body, n = _drop_duplicate_definitions(_join(segments), seen_defs)
dropped += n
parts.append(f'<a id="{ch.start_anchor}"></a>\n\n{body.strip()}')
text = "\n\n".join(parts).rstrip() + "\n"
existing = {p.resolve().as_posix() for p in out_dir.rglob("*") if p.is_file()}
broken = check_links({target.as_posix(): text}, existing)
target.write_text(text, encoding="utf-8")
return MergeResult(target, len(chapters), renamed, dropped, unresolved, broken)
def _escape_link_text(text: str) -> str:
return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
def _drop_duplicate_definitions(body: str, seen: set[str]) -> tuple[str, int]:
"""Remove footnote definitions whose label was already defined in an earlier chapter."""
out: list[str] = []
dropped = 0
skipping = False
for is_code, seg in _non_code_segments(body):
if is_code:
if not skipping:
out.append(seg)
continue
kept: list[str] = []
for line in seg.split("\n"):
m = FN_DEF.match(line)
if m:
label = m.group(1)
skipping = label in seen
if skipping:
dropped += 1
continue
seen.add(label)
elif skipping and (line.startswith(" ") or not line.strip()):
continue
else:
skipping = False
kept.append(line)
out.append("\n".join(kept))
return "\n".join(out), dropped
class _Parser(argparse.ArgumentParser):
def error(self, message: str) -> NoReturn:
self.print_usage(sys.stderr)
print(f"{self.prog}: Fehler: {message}", file=sys.stderr)
sys.exit(EXIT_INPUT)
def build_parser() -> argparse.ArgumentParser:
p = _Parser(
prog="epub2md_merge.py",
description="Fügt die Kapiteldateien einer epub2md-Ausgabe in Lesereihenfolge zu einer "
"vollständigen Markdown-Datei zusammen (Name = Original-EPUB mit Endung .md).",
epilog="Rückgabecodes: 0 Erfolg, 1 ungültige Eingabe/Zieldatei existiert, 4 interner Fehler.\n"
"Beispiel: python3 epub2md_merge.py output/programmierhandbuch",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("output_dir", metavar="AUSGABE_VERZEICHNIS", help="von epub2md.py erzeugtes Verzeichnis")
p.add_argument("-o", "--output", metavar="DATEI.md",
help="Zieldatei (Standard: AUSGABE_VERZEICHNIS/<Original-Name>.md; Medienpfade werden angepasst)")
p.add_argument("--frontmatter", action=argparse.BooleanOptionalAction, default=True,
help="YAML-Frontmatter mit Buchmetadaten an den Anfang setzen (Standard: an)")
p.add_argument("--toc", action="store_true", help="Inhaltsverzeichnis mit Links auf die Kapitel einfügen")
p.add_argument("--force", action="store_true", help="bestehende Zieldatei überschreiben")
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
return p
def main(argv: list[str] | None = None) -> int:
try:
args = build_parser().parse_args(argv)
except SystemExit as exc:
return exc.code if isinstance(exc.code, int) else EXIT_INPUT
try:
result = merge(Path(args.output_dir), Path(args.output) if args.output else None,
frontmatter=args.frontmatter, toc=args.toc, force=args.force)
except (MergeError, json.JSONDecodeError, OSError) as exc:
print(f"epub2md_merge: Fehler: {exc}", file=sys.stderr)
return EXIT_INPUT
except Exception as exc: # noqa: BLE001 – last line of defense
print(f"epub2md_merge: Interner Fehler: {type(exc).__name__}: {exc}", file=sys.stderr)
return EXIT_INTERNAL
for item in result.unresolved[:10]:
print(f"Warnung: Anker nicht gefunden, Link zeigt auf Kapitelanfang: {item}", file=sys.stderr)
for b in result.broken[:10]:
print(f"Warnung: ungültiger Link {b['link']} ({b['reason']})", file=sys.stderr)
print(f"epub2md_merge: {result.chapters} Kapitel zusammengefügt, {result.renamed_anchors} Anker umbenannt, "
f"{result.dropped_footnote_defs} doppelte Fußnotendefinitionen entfernt, {len(result.broken)} ungültige Links")
print(f"Ausgabe: {result.target}")
return 0
if __name__ == "__main__":
sys.exit(main())

9
epub2md_merge.py Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env python3
"""Entry point: python3 epub2md_merge.py AUSGABE_VERZEICHNIS [-o DATEI.md] [--toc] [--force]"""
import sys
from epub2md.merge import main
if __name__ == "__main__":
sys.exit(main())

73
tests/test_merge.py Normal file
View file

@ -0,0 +1,73 @@
import re
import yaml
from conftest import PNG, Book, Doc
from epub2md.linkcheck import anchors_in, check_links
from epub2md.merge import main as merge_main
A = ('<h1 id="sec">Kapitel A</h1><p>Siehe <a href="b.xhtml#sec">B</a>, <a href="b.xhtml">Kapitel B</a>, '
'<a href="#sec">hier</a> und Anmerkung<a epub:type="noteref" href="b.xhtml#n1">1</a>.</p>'
'<p><img src="img/a.png" alt="Bild"/></p>'
'<p><a href="b.xhtml"><img src="img/a.png" alt="Bildlink"/></a></p>'
'<pre>[kein](link.md) id="sec" `code`</pre>')
B = ('<h1 id="sec">Kapitel B</h1><p>Zurück zu <a href="a.xhtml#sec">A</a>, nochmal Anmerkung'
'<a epub:type="noteref" href="#n1">1</a>.</p>'
'<aside epub:type="footnote" id="n1"><p>Gemeinsame Anmerkung.</p></aside>')
def book() -> Book:
return Book(title="Merge-Test", docs=[Doc("a.xhtml", A), Doc("b.xhtml", B)], media={"img/a.png": PNG})
def test_merge_complete_book(convert, tmp_path):
run = convert(book())
assert merge_main([str(run.out)]) == 0
target = run.out / f"{run.report['input']['name'][:-5]}.md"
text = target.read_text(encoding="utf-8")
# one book-level frontmatter, no chapter frontmatter
fm = yaml.safe_load(text.split("---\n")[1])
assert fm["title"] == "Merge-Test" and fm["author"] == ["Erika Musterfrau"] and fm["lang"] == "de"
assert text.count("chapter_number") == 0
# reading order
assert text.index("# Kapitel A") < text.index("# Kapitel B")
# links between chapters point into the merged file; colliding id "sec" renamed in chapter B
assert '<a id="sec"></a>' in text and '<a id="c002-sec"></a>' in text
assert "[B](#c002-sec)" in text and "[A](#sec)" in text and "[hier](#sec)" in text
assert "[Kapitel B](#002-kapitel-b)" in text and '<a id="002-kapitel-b"></a>' in text
# media paths relative to the merged file, also inside image links
assert "![Bild](media/images/0001-bild.png)" in text
assert "[![Bildlink](media/images/0001-bild.png)](#002-kapitel-b)" in text
# code untouched
assert '[kein](link.md) id="sec" `code`' in text
# footnote defined in both chapters -> only once in the merged file
assert text.count("[^fn-001]:") == 1
assert check_links({target.as_posix(): text}, {p.as_posix() for p in run.out.rglob("*") if p.is_file()}) == []
prose = re.sub(r"^```.*?^```$", "", text, flags=re.M | re.S)
ids = re.findall(r'id="([^"]+)"', prose)
assert len(ids) == len(set(ids))
def test_merge_refuses_overwrite_without_force(convert):
run = convert(book())
assert merge_main([str(run.out)]) == 0
assert merge_main([str(run.out)]) == 1
assert merge_main([str(run.out), "--force"]) == 0
def test_merge_to_other_location_rewrites_media_paths(convert, tmp_path):
run = convert(book())
(tmp_path / "ziel").mkdir()
target = tmp_path / "ziel" / "buch.md"
assert merge_main([str(run.out), "-o", str(target), "--toc", "--no-frontmatter"]) == 0
text = target.read_text(encoding="utf-8")
assert not text.startswith("---")
assert f"![Bild](../{run.out.name}/media/images/0001-bild.png)" in text
assert "1. [Kapitel A](#001-kapitel-a)" in text
assert "001-kapitel-a" in anchors_in(text)
def test_merge_invalid_directory(tmp_path):
assert merge_main([str(tmp_path / "gibt-es-nicht")]) == 1
(tmp_path / "leer").mkdir()
assert merge_main([str(tmp_path / "leer")]) == 1