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>
This commit is contained in:
commit
02449734a2
1 changed files with 7726 additions and 0 deletions
126
tests/test_integration.py
Normal file
126
tests/test_integration.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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
|
||||
Loading…
Reference in a new issue