fix: restliche Befunde der unabhängigen QA (QA2-09 bis QA2-19)

- QA2-09: Formeln in HTML-Tabellen als MathML/Formelbild statt $…$; Inline-Noten
  dort als HTML-Verweis (Markdown wird in HTML-Blöcken nicht gerendert)
- QA2-10: PDF-Weg Markdown → HTML → PDF in README, Bedienungsanleitung und
  Buch-README. Beim direkten Weg Markdown → LaTeX verwirft Pandoc HTML-Tabellen
  und <a id>-Ziele. Geprüft: C-Buch 405 Seiten, 34 Tabellen, 728/755 Links.
- QA2-11: Hinweis code-language-hint bei einsprachigen Büchern
- QA2-12: --strict bricht bei nicht dekodierbarem XHTML ab (Exit 3);
  Hilfetext präzisiert
- QA2-13: verdächtig große Medien werden übersprungen (Fehler media-skipped,
  Platzhalter) statt die ganze Konvertierung abzubrechen
- QA2-14: Formelklasse am <img> selbst wird erkannt
- QA2-15: wiederholte Diagnosen werden gezählt (count, ×n); MathML in
  HTML-Tabellen zählt im Formelbericht mit
- QA2-16: Unicode-Buchstaben in Ankernamen bleiben erhalten
- QA2-17: keine leeren Links (#) auf den eigenen Kapitelanfang; keine
  namespaced Attribute (epub:type → type=) in HTML-Tabellen
- QA2-18: nicht definierte Entitäten werden gemeldet (undefined-entity)
- QA2-19: Bild-Platzhalter ohne Kursivschrift (benachbarte ergaben **)

Regressionstests: tests/test_qa2_open.py (11 Tests, vorher alle rot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-06 00:24:35 +02:00
commit 109612db28

121
tests/test_qa2_open.py Normal file
View file

@ -0,0 +1,121 @@
"""Regression tests for the remaining findings of the independent QA (QA2-09 … QA2-19)."""
import re
from conftest import PNG, SVG, Book, Doc
M = 'xmlns="http://www.w3.org/1998/Math/MathML"'
def html_table(text: str) -> str:
return text[text.index("<table"): text.index("</table>")]
def test_qa2_09_math_and_notes_in_html_tables_are_html(convert):
body = ('<h1>T</h1><table><tr><td colspan="2">Kopf</td></tr>'
f'<tr><td><math {M}><mfrac><mi>a</mi><mi>b</mi></mfrac></math></td>'
'<td><span class="InlineEquation"><img src="f.png" alt="$$x^2$$"/></span>'
'<a epub:type="noteref" href="#n1">1</a></td></tr></table>'
'<aside epub:type="footnote" id="n1"><p>Notiz <em>wichtig</em>.</p></aside>')
for mode in ("auto", "tex"):
run = convert(Book(docs=[Doc("c.xhtml", body)], media={"f.png": PNG}), "--math", mode, "--footnotes", "inline")
table = html_table(run.chapter("001"))
visible = re.sub(r'"[^"]*"', '""', table) # ignore attribute values (alt texts)
assert "<math" in table and "$" not in visible and "\\(" not in visible # renderable MathML
assert '<img src="../media/images/' in table and 'alt="$$x^2$$"' in table # formula image kept
assert "(Anmerkung:" not in table and '<sup><a href="#fn-001">1</a></sup>' in table
assert '<a id="fn-001"></a>' in run.chapter("001")
assert run.report["output_link_check"]["broken_count"] == 0
def test_qa2_10_book_readme_recommends_pdf_via_html(convert):
"""Pandoc drops raw HTML (tables, <a id>) when going Markdown -> LaTeX; the HTML route keeps both."""
run = convert(Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>x</p>")]))
readme = (run.out / "README.md").read_text(encoding="utf-8")
assert re.search(r"pandoc \S+\.md -s -o \S+\.html && pandoc \S+\.html --toc --pdf-engine=xelatex -o \S+\.pdf", readme)
def test_qa2_11_code_language_hint(convert):
py = "def f(x):\n return x\n\nclass A:\n pass"
body = "<h1>C</h1>" + "".join(f"<pre>{py}\n# {i}</pre><pre>print({i})</pre>" for i in range(10))
run = convert(Book(docs=[Doc("c.xhtml", body)]))
hints = [i for i in run.report["infos"] if i["code"] == "code-language-hint"]
assert hints and "--code-language python" in hints[0]["message"]
def test_qa2_12_strict_aborts_on_undecodable_xhtml(convert, tmp_path):
import zipfile
from conftest import build_epub
path = build_epub(tmp_path / "g.epub", Book(docs=[Doc("a.xhtml", "<h1>A</h1><p>x</p>")]))
with zipfile.ZipFile(path) as z:
entries = {n: z.read(n) for n in z.namelist()}
entries["OEBPS/a.xhtml"] = b'<?xml version="1.0" encoding="utf-8"?><html><body><p>\xff\xfe\x00\x81 kaputt</p></body></html>'
with zipfile.ZipFile(path, "w") as z:
for n, d in entries.items():
z.writestr(n, d)
assert convert(path).rc == 0
assert convert(path, "--strict").rc == 3
def test_qa2_13_suspicious_media_is_skipped_not_fatal(convert, tmp_path):
import zipfile
from conftest import build_epub
path = build_epub(tmp_path / "b.epub", Book(docs=[Doc("a.xhtml", '<h1>A</h1><p><img src="big.png" alt="Riesig"/> Text</p>')],
media={"big.png": PNG}))
with zipfile.ZipFile(path) as z:
entries = [(i, z.read(i)) for i in z.infolist()]
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as z:
for info, data in entries:
z.writestr(info.filename, b"\0" * 60_000_000 if info.filename == "OEBPS/big.png" else data)
run = convert(path)
assert run.rc == 0
assert "Riesig" in run.chapter("001") and "big.png" not in run.chapter("001")
assert any(e["code"] == "media-skipped" for e in run.report["errors"])
assert run.report["output_link_check"]["broken_count"] == 0
def test_qa2_14_formula_class_on_img_itself(convert):
body = '<h1>F</h1><p>Wert <img class="inlineequation" src="f.png" alt="Summe a plus b"/> ist.</p>'
run = convert(Book(docs=[Doc("c.xhtml", body)], media={"f.png": PNG}))
assert [f["origin"] for f in run.report["formulas"]["items"]] == ["image"]
assert any(w["code"] == "formula-image" for w in run.report["warnings"])
def test_qa2_15_diagnostics_count_repeats_and_html_table_formulas(convert):
img = '<span class="equation"><img src="f.png" alt="Summe"/></span>'
body = (f"<h1>F</h1><p>{img} und {img}</p>"
f'<table><tr><td colspan="2"><math {M}><mi>x</mi></math></td></tr><tr><td>a</td><td>b</td></tr></table>')
run = convert(Book(docs=[Doc("c.xhtml", body)], media={"f.png": PNG}))
w = [w for w in run.report["warnings"] if w["code"] == "formula-image"]
assert len(w) == 1 and w[0]["count"] == 2
assert run.report["formulas"]["count"] == 3
def test_qa2_16_non_ascii_ids_are_kept(convert):
body = '<h1 id="über-uns">Über</h1><p id="été">Sommer</p><p><a href="#été">S</a> <a href="#über-uns">Ü</a></p>'
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
assert '<a id="été"></a>' in text and '<a id="über-uns"></a>' in text
assert "[S](#été)" in text
def test_qa2_17_no_empty_fragment_links_and_no_epub_type_attr(convert):
body = ('<h1>K</h1><p>Text<a epub:type="noteref" href="#n">1</a></p>'
'<table><tr><td colspan="2"><a epub:type="noteref" href="#n">1</a></td></tr><tr><td>a</td><td>b</td></tr></table>'
'<aside epub:type="footnote" id="n"><p><a href="c.xhtml">↩</a> Note.</p></aside>')
text = convert(Book(docs=[Doc("c.xhtml", body)]), "--footnotes", "keep-links").chapter("001")
assert "](#)" not in text
assert ' type="noteref"' not in text
def test_qa2_18_undefined_entities_are_reported(convert):
body = "<h1>E</h1><p>A &unbekannt; B &auch;</p>"
run = convert(Book(docs=[Doc("c.xhtml", body)]))
w = [w for w in run.report["warnings"] if w["code"] == "undefined-entity"]
assert w and "unbekannt" in w[0]["message"] and "auch" in w[0]["message"]
def test_qa2_19_adjacent_placeholders_without_media(convert):
body = '<h1>B</h1><p><img src="a.png" alt="Eins"/><img src="b.svg" alt="Zwei"/></p>'
text = convert(Book(docs=[Doc("c.xhtml", body)], media={"a.png": PNG, "b.svg": SVG}), "--no-extract-media").chapter("001")
assert "**" not in text
assert re.search(r"Bild: Eins.*Bild: Zwei", text)