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
116
tests/test_chapters.py
Normal file
116
tests/test_chapters.py
Normal 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
|
||||
Loading…
Reference in a new issue