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
9 changed files with 7726 additions and 0 deletions
163
tests/conftest.py
Normal file
163
tests/conftest.py
Normal 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
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
|
||||
88
tests/test_codeblocks.py
Normal file
88
tests/test_codeblocks.py
Normal 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") # <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}
|
||||
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()
|
||||
93
tests/test_footnotes.py
Normal file
93
tests/test_footnotes.py
Normal 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
126
tests/test_integration.py
Normal 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 "<" not in block and "&" 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
68
tests/test_links.py
Normal 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 '' in text
|
||||
assert "*Abbildung 1: Ein Foto.*" in text
|
||||
assert "" in text
|
||||
assert "" 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 "" in flat.chapter("001")
|
||||
orig = convert(book, "--images", "original-path")
|
||||
assert "" 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) '
|
||||
" [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
99
tests/test_math.py
Normal 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; 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 "]
|
||||
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"])
|
||||
65
tests/test_tables_lists.py
Normal file
65
tests/test_tables_lists.py
Normal 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) <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")
|
||||
Loading…
Reference in a new issue