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 re
|
|
|
|
|
|
|
|
|
|
from conftest import Book, Doc
|
|
|
|
|
|
|
|
|
|
PY = """def read_book(path):
|
|
|
|
|
if path:
|
|
|
|
|
return path.read_text(encoding="utf-8") # <utf-8>
|
|
|
|
|
\treturn None"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fenced_blocks(text: str) -> list[tuple[str, str]]:
|
|
|
|
|
return re.findall(r"^(`{3,})([\w+-]*)\n(.*?)\n\1$", text, re.M | re.S)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_python_indentation_and_special_chars(convert):
|
|
|
|
|
body = f'<h1>Code</h1><pre><code class="language-python">{PY}</code></pre>'
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
blocks = fenced_blocks(run.chapter("001"))
|
|
|
|
|
assert len(blocks) == 1
|
|
|
|
|
fence, lang, code = blocks[0]
|
|
|
|
|
assert lang == "python"
|
|
|
|
|
assert code == PY.replace("<", "<").replace(">", ">")
|
|
|
|
|
assert "<" not in code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_shell_code_with_pipes_backslashes_and_dollar(convert):
|
|
|
|
|
shell = "$ cat datei.txt | grep -v '^#' \\\n | sort && echo \"$HOME\" > out.txt"
|
|
|
|
|
body = f"<h1>Shell</h1><pre>{shell}</pre>"
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
(_, lang, code), = fenced_blocks(run.chapter("001"))
|
|
|
|
|
assert code == "$ cat datei.txt | grep -v '^#' \\\n | sort && echo \"$HOME\" > out.txt"
|
|
|
|
|
assert lang == "" # no reliable language signal -> no label
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_backticks_in_code_get_longer_fence(convert):
|
|
|
|
|
body = "<h1>T</h1><pre>echo ```nested``` and `x`</pre>"
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
(fence, _, code), = fenced_blocks(run.chapter("001"))
|
|
|
|
|
assert fence == "````"
|
|
|
|
|
assert code == "echo ```nested``` and `x`"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_br_lines_and_highlight_spans_in_pre(convert):
|
|
|
|
|
body = ('<h1>C</h1><div class="listing"><pre><span class="kw">#include</span> <stdio.h><br/><br/>'
|
|
|
|
|
'int main(void) {<br/> printf("Hallo\\n");<br/>} </pre></div>')
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
(_, lang, code), = fenced_blocks(run.chapter("001"))
|
|
|
|
|
assert lang == "c"
|
|
|
|
|
assert code == '#include <stdio.h>\n\nint main(void) {\n printf("Hallo\\n");\n} '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_listing_div_with_span_pre(convert):
|
|
|
|
|
body = ('<h1>K</h1><div class="divUnProgramlisting"><span class="pre"><strong>def</strong> f(x):\n'
|
|
|
|
|
' <strong>return</strong> 2 * x\n\n<strong>def</strong> g(x):\n <strong>return</strong> x</span></div>')
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
(_, lang, code), = fenced_blocks(run.chapter("001"))
|
|
|
|
|
assert lang == "python"
|
|
|
|
|
assert code == "def f(x):\n return 2 * x\n\ndef g(x):\n return x"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_line_numbers_are_removed(convert):
|
|
|
|
|
body = '<h1>L</h1><pre><span class="lineno">1</span>a = 1<br/><span class="lineno">2</span>b = 2</pre>'
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
(_, _, code), = fenced_blocks(run.chapter("001"))
|
|
|
|
|
assert code == "a = 1\nb = 2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inline_code_variants(convert):
|
|
|
|
|
body = ('<h1>I</h1><p>Nutze <code>a*b_c</code>, <span class="tt">x | y</span> und <code>`tick`</code> '
|
|
|
|
|
'sowie <kbd>Strg</kbd>.</p>')
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
text = run.chapter("001")
|
|
|
|
|
assert "`a*b_c`" in text
|
|
|
|
|
assert "`x | y`" in text
|
|
|
|
|
assert "`` `tick` ``" in text
|
|
|
|
|
assert "<kbd>Strg</kbd>" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_prose_is_not_forced_into_code(convert):
|
|
|
|
|
body = "<h1>P</h1><p>Ein normaler Absatz mit Leerzeichen.</p>"
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]))
|
|
|
|
|
assert "```" not in run.chapter("001")
|
|
|
|
|
assert "Ein normaler Absatz mit Leerzeichen." in run.chapter("001")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unlabeled_code_is_reported(convert):
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", "<h1>U</h1><pre>foo bar</pre>")]))
|
|
|
|
|
assert run.report["code_blocks"]["languages"] == {"(keine)": 1}
|
2026-10-05 20:01:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_code_language_option_fills_only_unlabeled_blocks(convert):
|
|
|
|
|
body = "<h1>C</h1><pre>x = compute()</pre><pre>#include <stdio.h>\nint main(void) { return 0; }</pre>"
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", body)]), "--code-language", "py")
|
|
|
|
|
langs = [lang for _, lang, _ in fenced_blocks(run.chapter("001"))]
|
|
|
|
|
assert langs == ["python", "c"]
|
|
|
|
|
by = {item["language"]: item["detected_by"] for item in run.report["code_blocks"]["items"]}
|
|
|
|
|
assert by == {"python": "option", "c": "content"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_code_language_option_rejects_unknown_values(convert):
|
|
|
|
|
run = convert(Book(docs=[Doc("c.xhtml", "<h1>C</h1><pre>x</pre>")]), "--code-language", "quatsch")
|
|
|
|
|
assert run.rc == 1
|
|
|
|
|
assert "quatsch" in run.stderr
|