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:
Dieter Schlüter 2026-10-05 19:33:42 +02:00
commit 02449734a2
9 changed files with 7726 additions and 0 deletions

163
tests/conftest.py Normal file
View file

@ -0,0 +1,163 @@
"""Builders for small synthetic EPUB files used throughout the tests."""
from __future__ import annotations
import json
import sys
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from epub2md.cli import main # noqa: E402
# 1x1 transparent PNG
PNG = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d4944415478da63f8ffff3f0005fe02fea7d6a5b60000000049454e44ae426082"
)
SVG = b'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>'
XHTML = """<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="de">
<head><title>{title}</title></head>
<body>{body}</body>
</html>"""
@dataclass
class Doc:
name: str # file name inside OEBPS/
body: str
title: str = ""
linear: bool = True
@dataclass
class Book:
docs: list[Doc]
version: str = "3.0"
title: str = "Testbuch"
authors: list[str] = field(default_factory=lambda: ["Erika Musterfrau"])
language: str = "de"
identifier: str = "urn:isbn:9783161484100"
media: dict[str, bytes] = field(default_factory=dict) # OEBPS-relative path -> bytes
nav: list[tuple[str, str]] | None = None # (label, href) – default: one entry per doc
ncx: bool = False
extra_entries: dict[str, bytes] = field(default_factory=dict) # raw ZIP entries (any name)
container: str | None = None
opf_override: str | None = None
spine_extra: str = ""
cover: str | None = None
MEDIA_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".gif": "image/gif",
".mp3": "audio/mpeg", ".css": "text/css", ".otf": "font/otf"}
def build_epub(path: Path, book: Book) -> Path:
manifest = []
spine = []
for i, d in enumerate(book.docs):
manifest.append(f'<item id="d{i}" href="{d.name}" media-type="application/xhtml+xml"/>')
spine.append(f'<itemref idref="d{i}"' + ("" if d.linear else ' linear="no"') + "/>")
for i, (name, _) in enumerate(book.media.items()):
ext = "." + name.rsplit(".", 1)[-1].lower()
props = ' properties="cover-image"' if book.cover == name else ""
manifest.append(f'<item id="m{i}" href="{name}" media-type="{MEDIA_TYPES.get(ext, "application/octet-stream")}"{props}/>')
nav_entries = book.nav if book.nav is not None else [(d.title or d.name, d.name) for d in book.docs]
epub3 = book.version.startswith("3")
if epub3:
manifest.append('<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>')
if book.ncx:
manifest.append('<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>')
creators = "".join(f"<dc:creator>{a}</dc:creator>" for a in book.authors)
opf = book.opf_override or f"""<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="{book.version}" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>{book.title}</dc:title>{creators}
<dc:language>{book.language}</dc:language>
<dc:identifier id="uid">{book.identifier}</dc:identifier>
<dc:publisher>Testverlag</dc:publisher>
<dc:date>2024-01-01</dc:date>
</metadata>
<manifest>{''.join(manifest)}</manifest>
<spine{' toc="ncx"' if book.ncx else ''}>{''.join(spine)}{book.spine_extra}</spine>
</package>"""
nav_xhtml = XHTML.format(
title="Inhalt",
body='<nav epub:type="toc"><ol>' + "".join(f'<li><a href="{h}">{lbl}</a></li>' for lbl, h in nav_entries) + "</ol></nav>",
)
ncx = (
'<?xml version="1.0" encoding="utf-8"?><ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"><navMap>'
+ "".join(
f'<navPoint id="n{i}" playOrder="{i + 1}"><navLabel><text>{lbl}</text></navLabel><content src="{h}"/></navPoint>'
for i, (lbl, h) in enumerate(nav_entries)
)
+ "</navMap></ncx>"
)
container = book.container or (
'<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
'<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>'
)
with zipfile.ZipFile(path, "w") as z:
z.writestr("mimetype", "application/epub+zip", compress_type=zipfile.ZIP_STORED)
z.writestr("META-INF/container.xml", container)
z.writestr("OEBPS/content.opf", opf)
if epub3:
z.writestr("OEBPS/nav.xhtml", nav_xhtml)
if book.ncx:
z.writestr("OEBPS/toc.ncx", ncx)
for d in book.docs:
z.writestr(f"OEBPS/{d.name}", XHTML.format(title=d.title, body=d.body))
for name, data in book.media.items():
z.writestr(f"OEBPS/{name}", data)
for name, data in book.extra_entries.items():
z.writestr(zipfile.ZipInfo(name), data)
return path
@dataclass
class Run:
rc: int
out: Path
stdout: str
stderr: str
def chapter(self, prefix: str) -> str:
files = sorted((self.out / "chapters").glob(f"{prefix}*.md"))
assert files, f"no chapter {prefix} in {sorted(p.name for p in (self.out / 'chapters').glob('*.md'))}"
return files[0].read_text(encoding="utf-8")
def chapters(self) -> list[Path]:
return sorted((self.out / "chapters").glob("*.md"))
def all_md(self) -> str:
return "\n".join(p.read_text(encoding="utf-8") for p in self.chapters())
@property
def report(self) -> dict:
return json.loads((self.out / "conversion-report.json").read_text(encoding="utf-8"))
@pytest.fixture
def convert(tmp_path, capsys):
counter = {"n": 0}
def run(book_or_path, *args: str, out: Path | None = None) -> Run:
counter["n"] += 1
if isinstance(book_or_path, Book):
epub = build_epub(tmp_path / f"book{counter['n']}.epub", book_or_path)
else:
epub = Path(book_or_path)
target = out or tmp_path / f"out{counter['n']}"
rc = main([str(epub), str(target), *args])
captured = capsys.readouterr()
return Run(rc, target, captured.out, captured.err)
return run

116
tests/test_chapters.py Normal file
View file

@ -0,0 +1,116 @@
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

88
tests/test_codeblocks.py Normal file
View file

@ -0,0 +1,88 @@
import re
from conftest import Book, Doc
PY = """def read_book(path):
if path:
return path.read_text(encoding="utf-8") # &lt;utf-8&gt;
\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("&lt;", "<").replace("&gt;", ">")
assert "&lt;" not in code
def test_shell_code_with_pipes_backslashes_and_dollar(convert):
shell = "$ cat datei.txt | grep -v '^#' \\\n | sort &amp;&amp; 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> &lt;stdio.h&gt;<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}

139
tests/test_epub_reader.py Normal file
View 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()

93
tests/test_footnotes.py Normal file
View file

@ -0,0 +1,93 @@
import re
from conftest import Book, Doc
CH1 = (
"<h1>Kapitel</h1>"
'<p>Erste Aussage<a epub:type="noteref" href="#fn1" id="r1">1</a> und nochmal<a epub:type="noteref" href="#fn1" id="r1b">1</a>.</p>'
'<p>Zweite<sup><a role="doc-noteref" href="notes.xhtml#en1" id="r2">2</a></sup>.</p>'
'<aside epub:type="footnote" id="fn1"><p><a href="#r1">1</a> Erster Absatz mit <em>Betonung</em> und '
'<a href="https://example.org">Link</a>.</p><p>Zweiter Absatz mit <code>code()</code>.</p></aside>'
)
NOTES = (
"<h1>Anmerkungen</h1>"
'<aside epub:type="endnote" id="en1"><p><a href="ch.xhtml#r2">2</a> Endnote mit Formel '
'<math xmlns="http://www.w3.org/1998/Math/MathML"><msup><mi>e</mi><mi>x</mi></msup></math>.</p></aside>'
)
def book() -> Book:
return Book(docs=[Doc("ch.xhtml", CH1), Doc("notes.xhtml", NOTES)])
def test_end_of_chapter(convert):
run = convert(book())
text = run.chapter("001")
body, _, defs = text.partition("[^fn-001]:")
assert "Erste Aussage[^fn-001] und nochmal[^fn-001]." in body
assert "Zweite[^fn-002]." in body
assert "Erster Absatz" not in body # not duplicated as running text
assert "Erster Absatz mit *Betonung* und [Link](https://example.org).\n\n Zweiter Absatz mit `code()`." in defs
assert "[^fn-002]: Endnote mit Formel $e^{x}$." in text
assert text.count("[^fn-001]:") == 1
assert run.report["footnotes"]["rendered"] == 2
def test_end_of_book(convert):
run = convert(book(), "--footnotes", "end-of-book")
text = run.chapter("001")
assert '<a id="fnref-001"></a><sup>[1](999-anmerkungen.md#fn-001)</sup>' in text
assert '<a id="fnref-001-2"></a>' in text
notes = run.chapter("999")
assert '<a id="fn-001"></a>**1.** Erster Absatz' in notes
assert "[↩](001-kapitel.md#fnref-001)" in notes and "[↩](001-kapitel.md#fnref-001-2)" in notes
assert run.report["output_link_check"]["broken_count"] == 0
def test_inline(convert):
text = convert(book(), "--footnotes", "inline").chapter("001")
assert "Zweite (Anmerkung: Endnote mit Formel $e^{x}$.)." in text
assert "Erste Aussage (Anmerkung: Erster Absatz mit *Betonung* und [Link](https://example.org). Zweiter Absatz" in text
assert "[^fn" not in text
def test_inline_falls_back_for_block_content(convert):
body = ('<h1>K</h1><p>Text<a epub:type="noteref" href="#n">1</a>.</p>'
'<aside epub:type="footnote" id="n"><pre>code\n eingerückt</pre></aside>')
text = convert(Book(docs=[Doc("c.xhtml", body)]), "--footnotes", "inline").chapter("001")
assert "Text[^fn-001]." in text
assert "[^fn-001]: ```" in text
def test_keep_links(convert):
run = convert(book(), "--footnotes", "keep-links")
text = run.chapter("001")
assert "[1](#fn1)" in text
assert '<a id="fn1"></a>' in text
assert "Erster Absatz" in text
assert run.report["output_link_check"]["broken_count"] == 0
def test_class_based_footnotes_with_backlinks(convert):
body = ('<h1>K</h1><p>Text<sup><a href="c.xhtml#n1"><span id="r1">1</span></a></sup>.</p><hr/>'
'<p class="Footnote"><a href="c.xhtml#r1"><span id="n1">1</span></a> Die Fußnote.</p>')
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
assert "Text[^fn-001]." in text
assert re.search(r"^\[\^fn-001\]: Die Fußnote\.$", text, re.M)
def test_unresolvable_noteref_is_warned(convert):
body = '<h1>K</h1><p>Text<a epub:type="noteref" href="#fehlt">9</a></p>'
run = convert(Book(docs=[Doc("c.xhtml", body)]))
codes = [w["code"] for w in run.report["warnings"]]
assert "noteref-unresolved" in codes
def test_commonmark_footnotes_use_anchor_links(convert):
run = convert(book(), "--format", "commonmark")
text = run.chapter("001")
assert "[^fn" not in text
assert '<sup><a href="#fn-001">1</a></sup>' in text
assert '<a id="fn-001"></a>**1:** Erster Absatz' in text
assert "[↩](#fnref-001)" in text
assert run.report["output_link_check"]["broken_count"] == 0

126
tests/test_integration.py Normal file
View 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 "&lt;" not in block and "&amp;" 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

68
tests/test_links.py Normal file
View file

@ -0,0 +1,68 @@
from conftest import PNG, SVG, Book, Doc
from epub2md.linkcheck import check_links
def link_book() -> Book:
a = ('<h1 id="start">A</h1><p>Siehe <a href="b.xhtml">Kapitel B</a>, <a href="b.xhtml#ziel">Ziel in B</a>, '
'<a href="#unten">unten</a> und <a href="gibtsnicht.xhtml#x">kaputt</a>.</p>'
'<h1>Zweites H1</h1><p id="unten">Unten.</p>')
b = '<h1>B</h1><p>Text</p><p id="ziel">Ziel</p><p><a href="a.xhtml#start">zurück</a></p>'
return Book(docs=[Doc("a.xhtml", a), Doc("b.xhtml", b)])
def test_internal_links_are_rewritten(convert):
run = convert(link_book())
names = [p.name for p in run.chapters()]
assert names == ["001-a.md", "002-zweites-h1.md", "003-b.md"]
a = run.chapter("001")
assert "[Kapitel B](003-b.md)" in a
assert "[Ziel in B](003-b.md#ziel)" in a
assert "[unten](002-zweites-h1.md#unten)" in a # anchor ended up in the split-off chapter
assert "kaputt" in a and "gibtsnicht" not in a # unresolved: text kept, no broken link
assert '<a id="ziel"></a>Ziel' in run.chapter("003")
assert "[zurück](001-a.md#start)" in run.chapter("003")
assert run.report["unresolved_links"][0]["href"] == "gibtsnicht.xhtml#x"
assert any(w["code"] == "unresolved-link" for w in run.report["warnings"])
assert run.report["output_link_check"]["broken_count"] == 0
readme = (run.out / "README.md").read_text(encoding="utf-8")
assert "[A](chapters/001-a.md)" in readme
def test_images_are_extracted_once_with_relative_links(convert):
body = ('<h1>Bilder</h1><figure id="abb1"><img src="img/foto%20eins.png" alt="Foto" title="Titel"/>'
'<figcaption>Abbildung 1: Ein Foto.</figcaption></figure>'
'<p><img src="img/foto%20eins.png" alt="Nochmal"/> <img src="img/grafik.svg" alt=""/></p>'
'<figure><img src="img/grafik.svg"/><figcaption>Nur Unterschrift</figcaption></figure>')
book = Book(docs=[Doc("c.xhtml", body)], media={"img/foto eins.png": PNG, "img/grafik.svg": SVG})
run = convert(book)
text = run.chapter("001")
assert '![Foto](../media/images/0001-foto.png "Titel")' in text
assert "*Abbildung 1: Ein Foto.*" in text
assert "![Nochmal](../media/images/0001-foto.png)" in text
assert "![Nur Unterschrift](../media/images/0002-grafik.svg)" in text
images = sorted(p.name for p in (run.out / "media" / "images").iterdir())
assert images == ["0001-foto.png", "0002-grafik.svg"]
assert (run.out / "media/images/0002-grafik.svg").read_bytes() == SVG
assert run.report["output_link_check"]["broken_count"] == 0
def test_image_strategies(convert):
book = Book(docs=[Doc("c.xhtml", '<h1>B</h1><p><img src="img/a.png" alt="a"/></p>')], media={"img/a.png": PNG})
flat = convert(book, "--images", "flat")
assert "![a](../media/a.png)" in flat.chapter("001")
orig = convert(book, "--images", "original-path")
assert "![a](../media/img/a.png)" in orig.chapter("001")
assert (orig.out / "media/img/a.png").exists()
none = convert(book, "--no-extract-media")
assert not (none.out / "media").exists()
assert "Bild: a" in none.chapter("001")
def test_link_checker_detects_problems():
files = {
"chapters/001-a.md": '# A\n\n<a id="x"></a>Text [ok](002-b.md#y) [kaputt](002-b.md#nein) [fehlt](003.md) '
"![bild](../media/images/a.png) [ext](https://example.org)\n\n```\n[nicht](geprüft.md)\n```\n",
"chapters/002-b.md": '# B\n\n<a id="y"></a>Text [rück](001-a.md#x) [heading](#b)\n',
}
broken = check_links(files, {"media/images/a.png"})
assert {b["link"] for b in broken} == {"002-b.md#nein", "003.md"}

99
tests/test_math.py Normal file
View file

@ -0,0 +1,99 @@
import pytest
from lxml import etree
from conftest import PNG, SVG, Book, Doc
from epub2md.mathml import Unsupported, mathml_to_tex
M = 'xmlns="http://www.w3.org/1998/Math/MathML"'
def tex(xml: str) -> str:
return mathml_to_tex(etree.fromstring(f"<math {M}>{xml}</math>"))
@pytest.mark.parametrize(
("xml", "expected"),
[
("<mfrac><mi>a</mi><mi>b</mi></mfrac>", r"\frac{a}{b}"),
("<msup><mi>x</mi><mn>2</mn></msup>", "x^{2}"),
("<msub><mi>a</mi><mi>i</mi></msub>", "a_{i}"),
("<msubsup><mi>x</mi><mi>i</mi><mn>2</mn></msubsup>", "x_{i}^{2}"),
("<msqrt><mi>x</mi></msqrt>", r"\sqrt{x}"),
("<mroot><mi>x</mi><mn>3</mn></mroot>", r"\sqrt[3]{x}"),
("<munderover><mo>∑</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></munderover><msub><mi>a</mi><mi>i</mi></msub>",
r"\sum_{i = 1}^{n} a_{i}"),
("<mi>α</mi><mo>≤</mo><mi>β</mi>", r"\alpha \leq \beta"),
("<mover><mi>x</mi><mo>^</mo></mover>", r"\hat{x}"),
("<mi mathvariant='double-struck'>R</mi>", r"\mathbb{R}"),
("<mtext>für alle</mtext>", r"\text{für alle}"),
("<mrow><mo>(</mo><mtable><mtr><mtd><mn>1</mn></mtd><mtd><mn>0</mn></mtd></mtr>"
"<mtr><mtd><mn>0</mn></mtd><mtd><mn>1</mn></mtd></mtr></mtable><mo>)</mo></mrow>",
r"\left( \begin{matrix} 1 & 0 \\ 0 & 1 \end{matrix} \right)"),
],
)
def test_mathml_to_tex(xml, expected):
assert tex(xml) == expected
def test_tex_annotation_is_preferred():
xml = ('<semantics><mi>x</mi><annotation encoding="application/x-tex">\\mathbf{x}</annotation></semantics>')
assert tex(xml) == r"\mathbf{x}"
@pytest.mark.parametrize("xml", ["<menclose><mi>x</mi></menclose>", "<mmultiscripts><mi>x</mi></mmultiscripts>",
"<mtable><mlabeledtr><mtd>1</mtd></mlabeledtr></mtable>"])
def test_unsupported_mathml_raises(xml):
with pytest.raises(Unsupported):
tex(xml)
BODY = (
"<h1>Formeln</h1>"
f'<p>Inline <math {M}><mfrac><mi>a</mi><mi>b</mi></mfrac></math> im Satz.</p>'
f'<div class="equation"><math {M} display="block"><msup><mi>x</mi><mn>2</mn></msup></math> <span>(3.7)</span></div>'
f'<p>Unbekannt: <math {M}><menclose notation="box"><mi>z</mi></menclose></math></p>'
'<p>SVG: <span class="math"><img src="f.svg" alt="Formel 1"/></span></p>'
'<p>Bild ohne TeX: <span class="equation"><img src="eq.png" alt="Summe"/></span></p>'
'<p>Bild mit TeX: <span class="InlineEquation"><img src="eq2.png" alt="$$a &amp;amp; b \\leq c$$"/></span></p>'
)
def math_book() -> Book:
return Book(docs=[Doc("m.xhtml", BODY)], media={"f.svg": SVG, "eq.png": PNG, "eq2.png": PNG})
def test_math_auto(convert):
run = convert(math_book())
text = run.chapter("001")
assert r"Inline $\frac{a}{b}$ im Satz." in text
assert "$$\nx^{2}\n$$" in text
assert "(3.7)" in text # equation number visible
assert "<math" in text and "menclose" in text # unsupported MathML kept, not lost
assert "![Formel 1](../media/images/" in text # SVG formula stays SVG
assert "![Summe](../media/images/" in text
assert r"$a & b \leq c$" in text # TeX from alt, double escaping repaired
codes = [w["code"] for w in run.report["warnings"]]
assert "mathml-kept" in codes and "formula-image" in codes
svgs = [m for m in run.report["media"]["files"] if m["file"].endswith(".svg")]
assert svgs and svgs[0]["media_type"] == "image/svg+xml"
def test_math_tex_mode_uses_backslash_delimiters(convert):
text = convert(math_book(), "--math", "tex").chapter("001")
assert r"\(\frac{a}{b}\)" in text
assert "\\[\nx^{2}\n\\]" in text
assert "$" not in text.replace("\\$", "")
def test_math_mathml_mode_keeps_mathml(convert):
text = convert(math_book(), "--math", "mathml").chapter("001")
assert '<math xmlns="http://www.w3.org/1998/Math/MathML"><mfrac><mi>a</mi><mi>b</mi></mfrac></math>' in text
assert "frac{" not in text
def test_math_image_mode(convert):
run = convert(math_book(), "--math", "image")
text = run.chapter("001")
assert text.count("![") == 3 # svg + both formula images
assert "<math" in text # pure MathML without image is not lost
assert any(w["code"] == "mathml-no-image" for w in run.report["warnings"])

View file

@ -0,0 +1,65 @@
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) &lt;tag&gt; $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")