feat: epub2md – EPUB nach kapitelweisem Markdown
Kommandozeilenwerkzeug, das DRM-freie EPUB-2/3-Dateien offline in
kapitelweise Markdown-Dateien umwandelt. Erzeugt werden außerdem Medien,
metadata.yaml, manifest.json, conversion-report.json und Diagnose-Dateien.
- Spine-basierte Kapitelbildung mit --chapter-level, epub:type-Abschnitten,
Fortsetzungsdateien und Erkennung von Layout-Überschriften
- Code mit exakter Einrückung, sicheren Fences und konservativer
Spracherkennung
- MathML->TeX (konservativ), TeX aus alt-Texten, SVG-/Bildformeln
bleiben Bilder
- Fußnoten-Modi end-of-chapter, end-of-book, inline und keep-links
- Interne Links/Anker werden umgeschrieben, Linkcheck über die Ausgabe
- Sicher: kein Entpacken, Schutz vor Path Traversal und XXE,
DRM-Erkennung, atomares Schreiben, --force nur für frühere Ausgaben
- 82 pytest-Tests mit synthetischen EPUBs und Invariantentests für Beispiele/
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:33:42 +02:00
|
|
|
|
import json
|
|
|
|
|
|
import re
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
|
|
|
from conftest import ROOT, Book, Doc, build_epub
|
|
|
|
|
|
|
|
|
|
|
|
BEISPIELE = sorted((ROOT / "Beispiele").glob("*.epub"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def simple_book() -> Book:
|
|
|
|
|
|
return Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>Text</p>"), Doc("b.xhtml", "<h1>B</h1><p>Mehr</p>")])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_output_structure_and_reports(convert):
|
|
|
|
|
|
run = convert(simple_book(), "--copy-original")
|
|
|
|
|
|
assert run.rc == 0
|
|
|
|
|
|
for rel in ("README.md", "metadata.yaml", "manifest.json", "conversion-report.json",
|
|
|
|
|
|
"diagnostics/warnings.md", "diagnostics/unsupported-elements.md"):
|
|
|
|
|
|
assert (run.out / rel).is_file(), rel
|
|
|
|
|
|
assert len(list((run.out / "source").glob("*.epub"))) == 1
|
|
|
|
|
|
manifest = json.loads((run.out / "manifest.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
assert [c["file"] for c in manifest["chapters"]] == ["chapters/001-a.md", "chapters/002-b.md"]
|
|
|
|
|
|
for c in manifest["chapters"]:
|
|
|
|
|
|
assert (run.out / c["file"]).is_file()
|
|
|
|
|
|
assert c["word_count"] > 0
|
|
|
|
|
|
assert run.report["status"] == "success"
|
|
|
|
|
|
assert run.report["input"]["sha256"]
|
|
|
|
|
|
fm = yaml.safe_load(run.chapter("001").split("---\n")[1])
|
|
|
|
|
|
assert fm["chapter_number"] == 1 and fm["source_href"] == "a.xhtml" and fm["book_title"] == "Testbuch"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_frontmatter_no_toc(convert):
|
|
|
|
|
|
run = convert(simple_book(), "--no-frontmatter", "--no-toc")
|
|
|
|
|
|
assert not run.chapter("001").startswith("---")
|
|
|
|
|
|
assert not (run.out / "README.md").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dry_run_writes_nothing(convert, tmp_path):
|
|
|
|
|
|
report = tmp_path / "r.json"
|
|
|
|
|
|
run = convert(simple_book(), "--dry-run", "--report", str(report))
|
|
|
|
|
|
assert run.rc == 0
|
|
|
|
|
|
assert not run.out.exists()
|
|
|
|
|
|
assert not report.exists()
|
|
|
|
|
|
assert "chapters/001-a.md" in run.stdout
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_existing_output_requires_force(convert, tmp_path):
|
|
|
|
|
|
out = tmp_path / "ziel"
|
|
|
|
|
|
out.mkdir()
|
|
|
|
|
|
(out / "wichtig.txt").write_text("nicht löschen")
|
|
|
|
|
|
run = convert(simple_book(), out=out)
|
|
|
|
|
|
assert run.rc == 1
|
|
|
|
|
|
assert (out / "wichtig.txt").exists()
|
|
|
|
|
|
# --force refuses directories that are not epub2md output
|
|
|
|
|
|
assert convert(simple_book(), "--force", out=out).rc == 1
|
|
|
|
|
|
assert (out / "wichtig.txt").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_force_replaces_previous_output(convert, tmp_path):
|
|
|
|
|
|
out = tmp_path / "ziel"
|
|
|
|
|
|
assert convert(simple_book(), out=out).rc == 0
|
|
|
|
|
|
(out / "alt.txt").write_text("alt")
|
|
|
|
|
|
run = convert(simple_book(), "--force", out=out)
|
|
|
|
|
|
assert run.rc == 0
|
|
|
|
|
|
assert not (out / "alt.txt").exists()
|
|
|
|
|
|
assert (out / "README.md").exists()
|
|
|
|
|
|
assert not list(tmp_path.glob(".epub2md-*")) # temp dir cleaned up
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_report_option_and_verbose(convert, tmp_path):
|
|
|
|
|
|
report = tmp_path / "rep" / "bericht.json"
|
|
|
|
|
|
run = convert(simple_book(), "--report", str(report), "--verbose")
|
|
|
|
|
|
assert json.loads(report.read_text(encoding="utf-8"))["tool"] == "epub2md"
|
|
|
|
|
|
assert "Rendere" in run.stderr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalid_arguments_exit_1(convert, tmp_path):
|
|
|
|
|
|
assert convert(simple_book(), "--math", "falsch").rc == 1
|
|
|
|
|
|
assert convert(simple_book(), "--chapter-level", "9").rc == 1
|
|
|
|
|
|
assert convert(tmp_path / "gibt-es-nicht.epub").rc == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deterministic_output(convert, tmp_path):
|
|
|
|
|
|
epub = build_epub(tmp_path / "same.epub", simple_book())
|
|
|
|
|
|
a = convert(epub, out=tmp_path / "x1")
|
|
|
|
|
|
b = convert(epub, out=tmp_path / "x2")
|
|
|
|
|
|
for p in a.chapters():
|
|
|
|
|
|
assert p.read_text() == (b.out / "chapters" / p.name).read_text()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_encoding_edge_cases(convert):
|
|
|
|
|
|
text_in = "Ärger über Öl – „Zitat“ — café naïve, Ωμέγα αβγ, ∀x∈ℝ: x² ≥ 0 … ‚einfach‘"
|
|
|
|
|
|
book = Book(docs=[Doc("Kapitel-é.xhtml", f'<h1 id="über">Grüße</h1><p>{text_in}</p>')])
|
|
|
|
|
|
run = convert(book)
|
|
|
|
|
|
text = run.chapter("001")
|
|
|
|
|
|
assert text_in in text
|
|
|
|
|
|
assert "<EFBFBD>" not in text
|
|
|
|
|
|
assert run.chapters()[0].name == "001-gruesse.md"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not BEISPIELE, reason="keine Beispiel-EPUBs vorhanden")
|
|
|
|
|
|
@pytest.mark.parametrize("epub", BEISPIELE, ids=[p.stem[:20] for p in BEISPIELE])
|
|
|
|
|
|
def test_example_books_structural_invariants(convert, epub: Path):
|
|
|
|
|
|
"""Checks structure only – no copyrighted text is asserted."""
|
|
|
|
|
|
run = convert(epub)
|
|
|
|
|
|
assert run.rc == 0, run.stderr
|
|
|
|
|
|
report = run.report
|
|
|
|
|
|
chapters = run.chapters()
|
|
|
|
|
|
assert len(chapters) >= 3
|
|
|
|
|
|
assert report["output_link_check"]["broken_count"] == 0, report["output_link_check"]["broken"][:5]
|
|
|
|
|
|
assert not report["errors"]
|
|
|
|
|
|
for path in chapters:
|
|
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
|
|
body = text.split("---\n", 2)[2] if text.startswith("---\n") else text
|
|
|
|
|
|
yaml.safe_load(text.split("---\n")[1])
|
|
|
|
|
|
assert body.strip(), f"leere Kapiteldatei {path.name}"
|
|
|
|
|
|
fences = re.findall(r"^(`{3,})", body, re.M)
|
|
|
|
|
|
assert len(fences) % 2 == 0, f"unbalancierte Code-Fences in {path.name}"
|
|
|
|
|
|
for block in re.findall(r"^```[\w+-]*\n(.*?)\n```$", body, re.M | re.S):
|
|
|
|
|
|
assert "<" not in block and "&" not in block
|
|
|
|
|
|
for m in report["media"]["files"]:
|
|
|
|
|
|
assert (run.out / m["file"]).is_file()
|
|
|
|
|
|
assert sum(c["stats"]["word_count"] for c in report["chapters"]) > 1000
|
2026-10-05 19:47:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unwritable_output_parent_gives_clear_error(convert, tmp_path):
|
|
|
|
|
|
"""QA-F1: a permission problem at the target must not be reported as internal error (exit 4)."""
|
|
|
|
|
|
import os
|
|
|
|
|
|
import stat
|
|
|
|
|
|
|
|
|
|
|
|
parent = tmp_path / "readonly"
|
|
|
|
|
|
parent.mkdir()
|
|
|
|
|
|
os.chmod(parent, stat.S_IRUSR | stat.S_IXUSR)
|
|
|
|
|
|
try:
|
|
|
|
|
|
run = convert(simple_book(), out=parent / "ziel")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
os.chmod(parent, stat.S_IRWXU)
|
|
|
|
|
|
assert run.rc == 1
|
|
|
|
|
|
assert "Interner Fehler" not in run.stderr
|
|
|
|
|
|
assert "nicht beschreibbar" in run.stderr or "Schreibfehler" in run.stderr
|