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
139
tests/test_epub_reader.py
Normal file
139
tests/test_epub_reader.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import zipfile
|
||||
|
||||
import yaml
|
||||
|
||||
from conftest import PNG, Book, Doc, build_epub
|
||||
|
||||
|
||||
def test_minimal_epub2_with_ncx(convert):
|
||||
book = Book(version="2.0", ncx=True, docs=[Doc("ch1.xhtml", "<h1>Erstes Kapitel</h1><p>Hallo Welt.</p>", "Eins")])
|
||||
run = convert(book)
|
||||
assert run.rc == 0
|
||||
text = run.chapter("001")
|
||||
assert "# Erstes Kapitel" in text
|
||||
assert "Hallo Welt." in text
|
||||
assert run.report["epub"]["version"] == "2.0"
|
||||
assert run.report["epub"]["ncx"] == "OEBPS/toc.ncx"
|
||||
|
||||
|
||||
def test_minimal_epub3_with_nav_and_metadata(convert):
|
||||
book = Book(
|
||||
authors=["Anna A.", "Bert B."],
|
||||
docs=[Doc("a.xhtml", "<h1>A</h1><p>x</p>"), Doc("b.xhtml", "<h1>B</h1><p>y</p>")],
|
||||
)
|
||||
run = convert(book)
|
||||
assert run.rc == 0
|
||||
meta = yaml.safe_load((run.out / "metadata.yaml").read_text(encoding="utf-8"))
|
||||
assert meta["title"] == "Testbuch"
|
||||
assert meta["authors"] == ["Anna A.", "Bert B."]
|
||||
assert meta["language"] == "de"
|
||||
assert meta["identifier"] == "urn:isbn:9783161484100"
|
||||
assert meta["isbn"] == "9783161484100"
|
||||
assert meta["epub_version"] == "3.0"
|
||||
assert len(meta["source_sha256"]) == 64
|
||||
assert "subtitle" not in meta # nothing invented
|
||||
assert run.report["epub"]["nav"] == "OEBPS/nav.xhtml"
|
||||
|
||||
|
||||
def test_spine_order_is_respected_not_file_names(convert):
|
||||
docs = [Doc("z.xhtml", "<h1>Zuerst</h1><p>1</p>"), Doc("a.xhtml", "<h1>Danach</h1><p>2</p>")]
|
||||
run = convert(Book(docs=docs))
|
||||
names = [p.name for p in run.chapters()]
|
||||
assert names == ["001-zuerst.md", "002-danach.md"]
|
||||
|
||||
|
||||
def test_cover_image_is_extracted_first(convert):
|
||||
book = Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>x</p>")], media={"img/cover.png": PNG}, cover="img/cover.png")
|
||||
run = convert(book)
|
||||
meta = yaml.safe_load((run.out / "metadata.yaml").read_text(encoding="utf-8"))
|
||||
assert meta["cover_image"] == "media/images/0001-cover.png"
|
||||
assert (run.out / meta["cover_image"]).read_bytes() == PNG
|
||||
|
||||
|
||||
def test_missing_container_returns_exit_code_2(tmp_path, convert):
|
||||
path = tmp_path / "broken.epub"
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("mimetype", "application/epub+zip")
|
||||
z.writestr("OEBPS/x.xhtml", "<html/>")
|
||||
run = convert(path)
|
||||
assert run.rc == 2
|
||||
assert "container.xml" in run.stderr
|
||||
assert not run.out.exists()
|
||||
|
||||
|
||||
def test_not_a_zip_returns_exit_code_2(tmp_path, convert):
|
||||
path = tmp_path / "kaputt.epub"
|
||||
path.write_bytes(b"this is not a zip")
|
||||
run = convert(path)
|
||||
assert run.rc == 2
|
||||
|
||||
|
||||
def test_broken_opf_spine_reference(convert):
|
||||
book = Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>x</p>")], spine_extra='<itemref idref="does-not-exist"/>')
|
||||
run = convert(book)
|
||||
assert run.rc == 0
|
||||
assert any(e["code"] == "spine-idref-missing" for e in run.report["errors"])
|
||||
strict = convert(book, "--strict")
|
||||
assert strict.rc == 3
|
||||
assert not strict.out.exists()
|
||||
|
||||
|
||||
def test_drm_encrypted_epub_is_rejected(tmp_path, convert):
|
||||
book = Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>x</p>")],
|
||||
extra_entries={"META-INF/encryption.xml": b"""<encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container"
|
||||
xmlns:enc="http://www.w3.org/2001/04/xmlenc#"><enc:EncryptedData><enc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
|
||||
<enc:CipherData><enc:CipherReference URI="OEBPS/a.xhtml"/></enc:CipherData></enc:EncryptedData></encryption>"""})
|
||||
run = convert(book)
|
||||
assert run.rc == 2
|
||||
assert "DRM" in run.stderr
|
||||
|
||||
|
||||
def test_unsafe_zip_paths_are_never_written(tmp_path, convert):
|
||||
book = Book(
|
||||
docs=[Doc("a.xhtml", '<h1>A</h1><p>x <a href="../../outside.txt">raus</a></p>')],
|
||||
extra_entries={"../../outside.txt": b"evil", "/absolute/path.txt": b"evil", "OEBPS/../../escape.xhtml": b"evil"},
|
||||
)
|
||||
run = convert(book)
|
||||
assert run.rc == 0
|
||||
assert any(e["code"] == "unsafe-zip-path" for e in run.report["errors"])
|
||||
assert not (tmp_path / "outside.txt").exists()
|
||||
assert not (tmp_path.parent / "outside.txt").exists()
|
||||
for f in run.out.rglob("*"):
|
||||
assert run.out.resolve() in f.resolve().parents
|
||||
assert convert(book, "--strict").rc == 2
|
||||
|
||||
|
||||
def test_xml_external_entities_are_not_resolved(tmp_path, convert):
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("GEHEIM")
|
||||
body = "<h1>A</h1><p>&xxe;</p>"
|
||||
book = Book(docs=[Doc("a.xhtml", body)])
|
||||
path = build_epub(tmp_path / "xxe.epub", book)
|
||||
# inject a DOCTYPE with an external entity into the chapter
|
||||
with zipfile.ZipFile(path) as z:
|
||||
entries = {n: z.read(n) for n in z.namelist()}
|
||||
entries["OEBPS/a.xhtml"] = (
|
||||
f'<?xml version="1.0"?><!DOCTYPE html [<!ENTITY xxe SYSTEM "file://{secret}">]>'
|
||||
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>a</title></head><body><h1>A</h1><p>&xxe;</p></body></html>'
|
||||
).encode()
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
for n, d in entries.items():
|
||||
z.writestr(n, d)
|
||||
run = convert(path)
|
||||
assert run.rc == 0
|
||||
assert "GEHEIM" not in run.all_md()
|
||||
|
||||
|
||||
def test_invalid_utf8_is_reported(tmp_path, convert):
|
||||
book = Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>ok</p>")])
|
||||
path = build_epub(tmp_path / "enc.epub", book)
|
||||
with zipfile.ZipFile(path) as z:
|
||||
entries = {n: z.read(n) for n in z.namelist()}
|
||||
entries["OEBPS/a.xhtml"] = entries["OEBPS/a.xhtml"].replace(b"ok", b"kaputt \xff\xfe ende")
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
for n, d in entries.items():
|
||||
z.writestr(n, d)
|
||||
run = convert(path)
|
||||
assert run.rc == 0
|
||||
assert any(w["code"] == "decode-error" for w in run.report["warnings"])
|
||||
assert "ende" in run.all_md()
|
||||
Loading…
Reference in a new issue