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 yaml
|
|
|
|
|
|
|
|
|
|
from conftest import Book, Doc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def frontmatter(text: str) -> dict:
|
|
|
|
|
assert text.startswith("---\n")
|
|
|
|
|
return yaml.safe_load(text.split("---\n")[1])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_files_are_numbered_and_slugged(convert):
|
|
|
|
|
docs = [Doc("c1.xhtml", "<h1>Übersicht & Größen</h1><p>a</p>"), Doc("c2.xhtml", "<h1>Übersicht & Größen</h1><p>b</p>")]
|
|
|
|
|
run = convert(Book(docs=docs))
|
|
|
|
|
assert [p.name for p in run.chapters()] == ["001-uebersicht-groessen.md", "002-uebersicht-groessen.md"]
|
|
|
|
|
run_en = convert(Book(docs=docs), "--slug-language", "en")
|
|
|
|
|
assert run_en.chapters()[0].name == "001-ubersicht-grossen.md"
|
|
|
|
|
run_none = convert(Book(docs=docs), "--slug-language", "none")
|
|
|
|
|
assert run_none.chapters()[0].name == "001-übersicht-größen.md"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_h1_split_inside_one_file(convert):
|
|
|
|
|
body = "<h1>Eins</h1><p>a</p><h2>Eins.Eins</h2><p>b</p><h1>Zwei</h1><p>c</p>"
|
|
|
|
|
run = convert(Book(docs=[Doc("all.xhtml", body)]))
|
|
|
|
|
assert [p.name for p in run.chapters()] == ["001-eins.md", "002-zwei.md"]
|
|
|
|
|
assert "## Eins.Eins" in run.chapter("001")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_h2_split_with_chapter_level_2(convert):
|
|
|
|
|
body = "<h1>Teil I</h1><p>Intro</p><h2>Kapitel 1</h2><p>a</p><h2>Kapitel 2</h2><p>b</p>"
|
|
|
|
|
run = convert(Book(docs=[Doc("all.xhtml", body)]), "--chapter-level", "2")
|
|
|
|
|
names = [p.name for p in run.chapters()]
|
|
|
|
|
assert names == ["001-teil-i.md", "002-kapitel-1.md", "003-kapitel-2.md"]
|
|
|
|
|
assert run.chapter("002").count("\n# ") == 1 # exactly one H1 per file
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_section_files_without_h1_continue_previous_chapter(convert):
|
|
|
|
|
docs = [
|
|
|
|
|
Doc("01.xhtml", "<h1>Kapitel 1</h1><p>a</p>"),
|
|
|
|
|
Doc("01b.xhtml", '<div class="wrap"><h2>1.1 Abschnitt</h2><p>b</p></div>'),
|
|
|
|
|
Doc("02.xhtml", "<h1>Kapitel 2</h1><p>c</p>"),
|
|
|
|
|
]
|
|
|
|
|
run = convert(Book(docs=docs))
|
|
|
|
|
assert len(run.chapters()) == 2
|
|
|
|
|
first = run.chapter("001")
|
|
|
|
|
assert "## 1.1 Abschnitt" in first
|
|
|
|
|
assert frontmatter(first)["source_hrefs"] == ["01.xhtml", "01b.xhtml"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_headingless_documents_become_own_chapters_with_nav_title(convert):
|
|
|
|
|
docs = [Doc("titel.xhtml", "<p>Widmung für alle</p>"), Doc("c.xhtml", "<h1>Start</h1><p>x</p>")]
|
|
|
|
|
book = Book(docs=docs, nav=[("Widmung", "titel.xhtml"), ("Start", "c.xhtml")])
|
|
|
|
|
run = convert(book)
|
|
|
|
|
assert [p.name for p in run.chapters()] == ["001-widmung.md", "002-start.md"]
|
|
|
|
|
fm = frontmatter(run.chapter("001"))
|
|
|
|
|
assert fm["chapter_title"] == "Widmung"
|
|
|
|
|
assert "dedication" in fm["epub_type"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_semantic_sections_are_split_and_classified(convert):
|
|
|
|
|
body = (
|
|
|
|
|
'<section epub:type="chapter"><h1>Kapitel</h1><p>Text</p></section>'
|
|
|
|
|
'<section epub:type="glossary"><h2>Glossar</h2><dl><dt>API</dt><dd>Schnittstelle.</dd></dl></section>'
|
|
|
|
|
'<section epub:type="bibliography"><h2>Literatur</h2><ul><li>Knuth, D.: TAOCP. '
|
|
|
|
|
'<a href="https://doi.org/10.1000/xyz">doi:10.1000/xyz</a>, ISBN 978-0-201-89683-1</li></ul></section>'
|
|
|
|
|
'<section epub:type="index"><h2>Sachregister</h2><p>A</p></section>'
|
|
|
|
|
'<section epub:type="index"><h2>Personenregister</h2><p>Knuth</p></section>'
|
|
|
|
|
)
|
|
|
|
|
run = convert(Book(docs=[Doc("b.xhtml", body)]))
|
|
|
|
|
names = [p.name for p in run.chapters()]
|
|
|
|
|
assert names == ["001-kapitel.md", "002-glossar.md", "003-literatur.md", "004-sachregister.md", "005-personenregister.md"]
|
|
|
|
|
assert frontmatter(run.chapter("002"))["epub_type"] == ["glossary"]
|
|
|
|
|
assert "**API:** Schnittstelle." in run.chapter("002")
|
|
|
|
|
lit = run.chapter("003")
|
|
|
|
|
assert "[doi:10.1000/xyz](https://doi.org/10.1000/xyz)" in lit and "ISBN 978-0-201-89683-1" in lit
|
|
|
|
|
assert "index-persons" in frontmatter(run.chapter("005"))["epub_type"]
|
|
|
|
|
readme = (run.out / "README.md").read_text(encoding="utf-8")
|
|
|
|
|
assert "Glossar: [Glossar](chapters/002-glossar.md)" in readme
|
|
|
|
|
assert "Personenregister: [Personenregister](chapters/005-personenregister.md)" in readme
|
|
|
|
|
types = {s["type"] for s in run.report["special_sections"]}
|
|
|
|
|
assert {"glossary", "bibliography", "index", "index-persons"} <= types
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wrapper_preamble_belongs_to_heading_chapter(convert):
|
|
|
|
|
body = '<div epub:type="chapter"><div class="ctx">Kontextinfo</div><h1>Kapitel</h1><p>Text</p></div>'
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
assert len(run.chapters()) == 1
|
|
|
|
|
text = run.chapter("001")
|
|
|
|
|
assert "Kontextinfo" in text and text.count("# Kapitel") == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_html_toc_document_is_not_duplicated(convert):
|
|
|
|
|
toc = '<h1>Inhaltsverzeichnis</h1><p><a href="a.xhtml">Kapitel A</a></p><p><a href="b.xhtml">Kapitel B</a></p><p><a href="b.xhtml#x">B.1</a></p>'
|
|
|
|
|
docs = [Doc("toc.xhtml", toc), Doc("a.xhtml", "<h1>A</h1><p>a</p>"), Doc("b.xhtml", '<h1>B</h1><p id="x">b</p>')]
|
|
|
|
|
run = convert(Book(docs=docs, nav=[("A", "a.xhtml"), ("B", "b.xhtml")]))
|
|
|
|
|
assert [p.name for p in run.chapters()] == ["001-a.md", "002-b.md"]
|
|
|
|
|
assert run.report["skipped_documents"] == [{"href": "OEBPS/toc.xhtml", "reason": "html-toc"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_short_title_page_is_kept(convert):
|
|
|
|
|
docs = [Doc("t.xhtml", "<p>Für M.</p>", "Widmung"), Doc("c.xhtml", "<h1>K</h1><p>x</p>")]
|
|
|
|
|
run = convert(Book(docs=docs, nav=[]))
|
|
|
|
|
assert len(run.chapters()) == 2
|
|
|
|
|
assert "Für M." in run.chapter("001")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_layout_table_headings_are_recognized(convert):
|
|
|
|
|
body = (
|
|
|
|
|
'<table class="Document_number"><tr><td>3</td><td class="Title_document">Grundlagen</td></tr></table>'
|
|
|
|
|
"<p>Text</p>"
|
|
|
|
|
'<table class="Head1"><tr><td>3.1</td><td>Einführung</td></tr></table><p>mehr</p>'
|
|
|
|
|
)
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
text = run.chapter("001")
|
|
|
|
|
assert "# 3 Grundlagen" in text
|
|
|
|
|
assert "## 3.1 Einführung" in text
|
|
|
|
|
assert "<table" not in text
|
2026-10-05 19:47:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_glossary_anchor_is_placed_before_bold_term(convert):
|
|
|
|
|
"""QA-F2: the anchor of a <dt id> must not end up inside the bold markers."""
|
|
|
|
|
body = ('<h1>K</h1><p><a href="#g">API</a></p><section epub:type="glossary"><h2>Glossar</h2>'
|
|
|
|
|
'<dl><dt id="g">API</dt><dd>Schnittstelle.</dd></dl></section>')
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("002")
|
|
|
|
|
assert '<a id="g"></a>**API:** Schnittstelle.' in text
|
2026-10-05 20:00:02 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_large_chapter_with_subheadings_suggests_deeper_chapter_level(convert):
|
|
|
|
|
words = " ".join(["Wort"] * 11000)
|
|
|
|
|
body = "<h1>Teil 1</h1>" + "".join(f"<h2>Kapitel {i}</h2><p>{words}</p>" for i in range(3))
|
|
|
|
|
run = convert(Book(docs=[Doc("t.xhtml", body)]))
|
|
|
|
|
hints = [w for w in run.report["warnings"] if w["code"] == "chapter-large"]
|
|
|
|
|
assert len(hints) == 1
|
|
|
|
|
assert "--chapter-level 2" in hints[0]["message"] and "3 Unterabschnitte" in hints[0]["message"]
|
|
|
|
|
small = convert(Book(docs=[Doc("t.xhtml", "<h1>Teil</h1><h2>A</h2><p>x</p><h2>B</h2><p>y</p>")]))
|
|
|
|
|
assert not any(w["code"] == "chapter-large" for w in small.report["warnings"])
|
2026-10-05 20:46:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_titles_derived_from_content_when_no_other_source(convert):
|
|
|
|
|
docs = [
|
|
|
|
|
Doc("ccover.xhtml", '<div><img src="c.png" alt=""/></div>'),
|
|
|
|
|
Doc("cinner.xhtml", "<p>Erika Musterfrau</p><p>Testbuch</p><p>Roman</p>"),
|
|
|
|
|
Doc("cimp.xhtml", "<p>© 2024 Testverlag</p><p>Das Werk ist urheberrechtlich geschützt.</p>"),
|
|
|
|
|
Doc("cwid.xhtml", "<p>Für Anna, die immer an mich geglaubt hat, und für alle anderen.</p>"),
|
|
|
|
|
Doc("c1.xhtml", "<h1>Eins</h1><p>Text</p>"),
|
|
|
|
|
]
|
|
|
|
|
from conftest import PNG
|
|
|
|
|
run = convert(Book(docs=docs, nav=[("Eins", "c1.xhtml")], media={"c.png": PNG}))
|
|
|
|
|
names = [p.name for p in run.chapters()]
|
|
|
|
|
assert names == ["001-cover.md", "002-titelseite.md", "003-impressum.md",
|
|
|
|
|
"004-fuer-anna-die-immer-an-mich-geglaubt-hat.md", "005-eins.md"]
|
|
|
|
|
types = {c["file"]: c["types"] for c in run.report["chapters"]}
|
|
|
|
|
assert "cover" in types["chapters/001-cover.md"]
|
|
|
|
|
assert "titlepage" in types["chapters/002-titelseite.md"]
|
|
|
|
|
assert "colophon" in types["chapters/003-impressum.md"]
|
2026-10-05 20:47:40 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_adjacent_field_spans_get_a_separator(convert):
|
|
|
|
|
body = ('<h1>K</h1><div class="ContextInformation"><span class="Names">J. Liesen, V. Mehrmann</span>'
|
|
|
|
|
'<span class="BookTitles"><span class="BookTitle">Lineare Algebra</span></span>'
|
|
|
|
|
'<span class="Series">Springer Studium</span></div>'
|
|
|
|
|
'<p><span class="dropcap">D</span><span class="rest">as Wetter</span> war schlecht.</p>'
|
|
|
|
|
'<p><span class="a">Haupt</span><span class="b">bahnhof</span> und x<sub>i</sub>.</p>')
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
|
|
|
|
|
assert "J. Liesen, V. Mehrmann Lineare Algebra Springer Studium" in text
|
|
|
|
|
assert "Das Wetter war schlecht." in text
|
|
|
|
|
assert "Hauptbahnhof und x<sub>i</sub>." in text
|