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
|
|
|
from conftest import Book, Doc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_nested_lists_keep_structure(convert):
|
|
|
|
|
body = ("<h1>L</h1><ul><li>Eins<ol><li>Unterpunkt A</li><li>Unterpunkt B<ul><li>tief</li></ul></li></ol></li>"
|
|
|
|
|
"<li>Zwei</li></ul><ol start=\"4\"><li>vier</li><li>fünf</li></ol>")
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
|
|
|
|
|
assert "- Eins\n\n 1. Unterpunkt A\n\n 2. Unterpunkt B\n\n - tief\n\n- Zwei" in text
|
|
|
|
|
assert "4. vier\n5. fünf" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_simple_table_as_gfm(convert):
|
|
|
|
|
body = ("<h1>T</h1><table><caption>Tabelle 1: Werte</caption><thead><tr><th>Name</th><th>Wert</th></tr></thead>"
|
|
|
|
|
"<tbody><tr><td><em>a</em></td><td>1 | 2</td></tr><tr><td>b</td><td><code>x</code></td></tr></tbody></table>")
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
|
|
|
|
|
assert "*Tabelle 1: Werte*" in text
|
|
|
|
|
assert "| Name | Wert |\n| --- | --- |\n| *a* | 1 \\| 2 |\n| b | `x` |" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_complex_table_kept_as_html_with_warning(convert):
|
|
|
|
|
body = ("<h1>T</h1><table><tr><th colspan=\"2\">Kopf</th></tr><tr><td rowspan=\"2\">A</td><td><p>Zeile 1</p>"
|
|
|
|
|
"<p>Zeile 2</p></td></tr><tr><td><strong>B</strong></td></tr></table>")
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
text = run.chapter("001")
|
|
|
|
|
assert '<th colspan="2">Kopf</th>' in text
|
|
|
|
|
assert '<td rowspan="2">A</td>' in text
|
|
|
|
|
assert "Zeile 1" in text and "Zeile 2" in text and "<strong>B</strong>" in text
|
|
|
|
|
assert "\n\n" not in text.split("<table>")[1].split("</table>")[0] # HTML block must not be interrupted
|
|
|
|
|
assert any(w["code"] == "table-html" for w in run.report["warnings"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_commonmark_uses_html_tables(convert):
|
|
|
|
|
body = "<h1>T</h1><table><tr><th>a</th></tr><tr><td>b</td></tr></table><p><del>alt</del></p>"
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)]), "--format", "commonmark").chapter("001")
|
|
|
|
|
assert "<table>" in text and "| a |" not in text
|
|
|
|
|
assert "<del>alt</del>" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_definition_list_blockquote_and_inline_semantics(convert):
|
|
|
|
|
body = ("<h1>D</h1><dl><dt>Term</dt><dd>Erklärung.</dd></dl><blockquote><p>Zitat</p><p>weiter</p></blockquote>"
|
|
|
|
|
"<p>H<sub>2</sub>O, x<sup>2</sup>, <q>Zitat</q>, <s>weg</s>, <u>unter</u><br/>neue Zeile</p><hr/>")
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
|
|
|
|
|
assert "**Term:** Erklärung." in text
|
|
|
|
|
assert "> Zitat\n>\n> weiter" in text
|
|
|
|
|
assert "H<sub>2</sub>O, x<sup>2</sup>, „Zitat“, ~~weg~~, <u>unter</u>\\\nneue Zeile" in text
|
|
|
|
|
assert "* * *" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_markdown_special_characters_are_escaped(convert):
|
|
|
|
|
body = "<h1>E</h1><p>1. kein Listenpunkt</p><p># keine Überschrift</p><p>a*b*c [x](y) <tag> $5</p>"
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
|
|
|
|
|
assert "1\\. kein Listenpunkt" in text
|
|
|
|
|
assert "\\# keine Überschrift" in text
|
|
|
|
|
assert "a\\*b\\*c \\[x\\](y) \\<tag> \\$5" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_elements_are_reported_and_keep_html(convert):
|
|
|
|
|
body = ("<h1>U</h1><p>Vor <foo-widget data-x=\"1\" onclick=\"evil()\" style=\"x\">Inhalt bleibt</foo-widget>"
|
|
|
|
|
" nach.</p>")
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
assert "Vor Inhalt bleibt nach." in run.chapter("001")
|
|
|
|
|
assert run.report["unsupported_elements"] == {"foo-widget": 1}
|
|
|
|
|
kept = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-html")
|
|
|
|
|
assert '<foo-widget data-x="1">Inhalt bleibt</foo-widget>' in kept.chapter("001")
|
|
|
|
|
assert "evil" not in kept.chapter("001")
|
2026-10-05 20:24:47 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_complex_table_anchor_is_emitted_once(convert):
|
|
|
|
|
body = ('<h1>T</h1><p><a href="#t1">Tabelle 1</a></p><table id="t1"><tr><td colspan="2">a</td></tr>'
|
|
|
|
|
"<tr><td>b</td><td>c</td></tr></table>")
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
|
|
|
|
|
assert text.count('id="t1"') == 1
|