epub2md/tests/test_styles.py
dschlueter e8fd6c2fb9 fix: Kastentyp aus Verlagsklasse, kein Feld-Trenner vor ausgeblendeten Spans
- _box_type erkennt hinweis/note/notiz/anmerkung/info als NOTE; damit
  gewinnt die Klasse "box hinweis" gegen ein Typwort im Titel ("Wichtige …")
- _separate_field_spans setzt kein Leerzeichen, wenn einer der Spans per CSS
  display:none ist ("<u>x</u> ." → "<u>x</u>.")
- gefunden mit der neuen QA-Fixture 14-typography (QA-Matrix jetzt 85 Fälle,
  inkl. epub2md_merge.py und Pandoc-Filter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:36:25 +02:00

203 lines
12 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Typography and layout: invisible characters, CSS emphasis, scene breaks, boxes, styles, page markers."""
from conftest import Book, Doc
ZW, SHY = "​", "­"
def test_p6_invisible_characters_removed_outside_code(convert):
body = (f"<h1>Kapitel 1.{ZW}1</h1><p>Lese{SHY}rinnen und 1.{ZW}1 sowie{ZW}Wort.</p>"
f"<pre>a{ZW}b</pre><p><code>x{SHY}y</code></p>")
run = convert(Book(docs=[Doc("c.xhtml", body)]))
text = run.chapter("001")
assert "Leserinnen und 1.1 sowieWort." in text
assert "# Kapitel 1.1" in text and 'chapter_title: "Kapitel 1.1"' in text
assert f"a{ZW}b" in text and f"x{SHY}y" in text # code stays verbatim
assert run.report["text_cleanup"]["invisible_chars_removed"] == 4
kept = convert(Book(docs=[Doc("c.xhtml", body)]), "--keep-invisible-chars").chapter("001")
assert f"Lese{SHY}rinnen" in kept
CSS = b""".i { font-style: italic } .b { font-weight: bold } span.u { text-decoration: underline }
.s { text-decoration: line-through } .hoch { vertical-align: super } .weg { display: none }
li p.first > span.b { font-weight: normal; font-family: monospace } .schrift-c { font-family: "Courier New", monospace }
p.zitat { font-style: italic } h2.t { font-weight: bold; font-style: italic } .url { text-decoration: underline }
@media amzn-kf8 { .i { font-style: normal } }"""
def styled(body: str, *args: str, convert=None):
return convert(Book(docs=[Doc("c.xhtml", body)], media={"style.css": CSS}), *args)
def test_p1_css_classes_become_markdown_emphasis(convert):
body = ('<h1>K</h1><p>Ein <span class="i">kursives</span> und <span class="b">fettes</span> Wort, '
'<span class="u">unterstrichen</span>, <span class="s">gestrichen</span>, x<span class="hoch">2</span>, '
'<span class="weg">[»] </span>sichtbar, <span class="schrift-c">printf</span> und '
'<span class="schrift-c">zwei Wörter</span>, <span style="font-weight:700">inline</span>, '
'<span class="url"><a href="https://example.org">example.org</a></span>.</p>'
'<ul><li><p class="first"><span class="b">nicht fett im Listenkopf</span></p></li></ul>'
'<p class="zitat">Ein ganzer kursiver Absatz mit <em>Betonung</em>.</p>'
'<h2 class="t">Überschrift bleibt</h2><p><em class="i">nur einmal</em></p><pre><span class="b">code</span></pre>')
run = styled(body, convert=convert)
text = run.chapter("001")
assert "Ein *kursives* und **fettes** Wort, <u>unterstrichen</u>, ~~gestrichen~~, x<sup>2</sup>, sichtbar," in text
assert "`printf` und zwei Wörter, **inline**, [example.org](https://example.org)." in text
assert "[»]" not in text and "\\[»\\]" not in text
assert "- nicht fett im Listenkopf" in text
assert "*Ein ganzer kursiver Absatz mit Betonung.*" in text
assert "## Überschrift bleibt" in text and "*nur einmal*" in text and "**nur einmal**" not in text
assert "```\ncode\n```" in text
assert run.report["css"]["hidden_elements"] == 1
assert run.report["css"]["applied"]["italic"] >= 2
plain = styled(body, "--no-css-emphasis", convert=convert).chapter("001")
assert "Ein kursives und fettes Wort" in plain
def test_p1_css_emphasis_inside_html_tables(convert):
body = ('<h1>T</h1><table><tr><td colspan="2"><span class="i">kursiv</span> und <span class="b">fett</span></td></tr>'
'<tr><td>a</td><td>b</td></tr></table>')
text = styled(body, convert=convert).chapter("001")
assert "<em>kursiv</em> und <strong>fett</strong>" in text
def test_adjacent_emphasis_is_merged(convert):
body = ('<h1>K</h1><p>Fläche (A=r<sup>2</sup><span class="i">×</span><span class="i">π</span>) und '
'<em>x</em><em>y</em> sowie <strong>a</strong><strong>b</strong> und <em>c</em> <em>d</em>.</p>')
text = styled(body, convert=convert).chapter("001")
assert "<sup>2</sup>*×π*" in text
assert "*xy*" in text and "**ab**" in text and "*c* *d*" in text
def test_p4_scene_breaks(convert):
body = ('<h1>K</h1><p class="leerzeile">&#160;</p><p>Erster Absatz.</p><p class="leerzeile">&#160;</p>'
'<p class="leerzeile">&#160;</p><p>Neue Szene.</p><p>Weiter.</p><p class="centertext">***</p>'
'<p>Nach Ornament.</p><p>&#160;</p><h2>Abschnitt</h2><p>Text</p><p>⁂</p><p>Ende.</p>')
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
body_md = text.split("# K", 1)[1]
assert "Erster Absatz.\n\n* * *\n\nNeue Szene." in body_md
assert body_md.count("* * *") == 3 # two empties collapse into one; ***, ⁂ are ornaments; none before the heading
assert "Weiter.\n\n* * *\n\nNach Ornament." in body_md
assert "\\*" not in body_md
assert body_md.strip().startswith("Erster Absatz.") # no break at chapter start
def test_p4_poems_keep_lines_and_stanzas(convert):
body = ('<h1>G</h1><p>Davor.</p><div class="poem"><p>»Erste Zeile</p><p>zweite Zeile</p>'
'<p class="leerzeile">&#160;</p><p>Zweite Strophe«</p></div><p>Danach.</p>')
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
assert "> »Erste Zeile\\\n> zweite Zeile\n>\n> Zweite Strophe«" in text
assert "* * *" not in text
BOX_CSS = b""".rahmen { border: 1px solid #333; padding: 4px } .listing { border: 1px solid #ccc }"""
def boxed(body: str, *args: str, convert=None):
return convert(Book(docs=[Doc("c.xhtml", body)], media={"box.css": BOX_CSS}), *args)
def test_p2_boxes_become_callouts(convert):
body = ('<h1>K</h1><p>Vorher.</p>'
'<div class="box box_standard"><h3 class="boxheading"><span class="box_icon">[»] </span>Erwartungen</h3>'
'<p>Kein Kompendium.</p><p>Zweiter Absatz.</p></div>'
'<div class="Boxg"><div class="Box_img"><img alt="Image" src="tick.png"/></div><div class="Box_body">'
'<p><strong>Wichtig:</strong> Funktionen sind Objekte.</p></div></div>'
'<p class="p_TippU">Tipp</p><p class="p_Tipp_FT">Gelb mit Blau mischen.</p><p class="p_Tipp_FT">Mehr Weiß.</p>'
'<aside epub:type="sidebar"><h2>Exkurs</h2><p>Am Rande.</p></aside>'
'<div class="rahmen"><p>Achtung, heiß!</p></div>'
'<div class="listing"><pre>code()</pre></div><p>Nachher.</p>')
from conftest import PNG
run = convert(Book(docs=[Doc("c.xhtml", body)], media={"box.css": BOX_CSS, "tick.png": PNG}))
text = run.chapter("001")
assert "> [!NOTE]\n> **Erwartungen**\n>\n> Kein Kompendium.\n>\n> Zweiter Absatz." in text
assert "> [!IMPORTANT]\n> **Wichtig:** Funktionen sind Objekte." in text and "tick" not in text
assert "> [!TIP]\n> Gelb mit Blau mischen.\n>\n> Mehr Weiß." in text # label 'Tipp' = type word, no title
assert "> [!NOTE]\n> **Exkurs**\n>\n> Am Rande." in text
assert "> [!WARNING]\n> Achtung, heiß!" in text # box recognized via CSS border, type from text
assert "```\ncode()\n```" in text and "> ```" not in text # bordered code listing is no box
assert "### " not in text # box heading no longer pollutes the outline
assert run.report["layout"]["boxes"] == 5
def test_p2_box_modes(convert):
body = '<h1>K</h1><aside epub:type="tip"><h2>Hinweis zum Pinsel</h2><p>Sauber halten.</p></aside>'
quote = boxed(body, "--boxes", "quote", convert=convert).chapter("001")
assert "> **Hinweis zum Pinsel**\n>\n> Sauber halten." in quote and "[!" not in quote
htm = boxed(body, "--boxes", "html", convert=convert).chapter("001")
assert '<div class="box box-tip">\n\n**Hinweis zum Pinsel**\n\nSauber halten.\n\n</div>' in htm
plain = boxed(body, "--boxes", "plain", convert=convert).chapter("001")
assert "## Hinweis zum Pinsel" in plain and "Sauber halten." in plain and ">" not in plain.split("---")[-1]
STYLE_CSS = b"""p { font-family: "Aldus", serif; font-size: 0.9em; color: #000 }
.rot { color: #942f6a } .gelb { background-color: #ffff00 } .gross { font-size: 1.5em }
.kap { font-variant: small-caps } p.mitte { text-align: center } .script { font-family: "Mulled Wine", cursive }"""
def styles_book(body: str, *args: str, convert=None):
return convert(Book(docs=[Doc("c.xhtml", body)], media={"s.css": STYLE_CSS}), *args)
BODY_STYLES = ('<h1>S</h1><p>Normal <span class="rot">rot</span>, <span class="gelb">markiert</span>, '
'<span class="gross">groß</span>, <span class="kap">Kapitälchen</span>, <span class="script">Zierschrift</span>, '
'<span lang="en">English words</span> und <span lang="de">deutsch</span>.</p>'
'<p>Zweiter Absatz.</p><p>Dritter Absatz.</p><p class="mitte">Widmung</p>')
def test_p3_semantic_styles_default(convert):
text = styles_book(BODY_STYLES, convert=convert).chapter("001")
assert "Normal rot, <mark>markiert</mark>, groß, Kapitälchen, Zierschrift, English words und deutsch." in text
assert "style=" not in text and "lang=" not in text
def test_p3_p7_html_styles(convert):
run = styles_book(BODY_STYLES, "--styles", "html", convert=convert)
text = run.chapter("001")
assert '<span style="color: #942f6a">rot</span>' in text
assert '<span style="background-color: #ffff00">markiert</span>' in text
assert '<span style="font-size: 1.5em">groß</span>' in text
assert '<span style="font-variant: small-caps">Kapitälchen</span>' in text
assert "<span style=\"font-family: 'Mulled Wine', cursive\">Zierschrift</span>" in text
assert '<span lang="en">English words</span> und deutsch.' in text
assert '<div style="text-align: center">\n\nWidmung\n\n</div>' in text
assert "Zweiter Absatz." in text and "Aldus" not in text # base paragraph style is not repeated
assert run.report["css"]["applied"]["style"] >= 5
PAGES = ('<h1>K</h1><p>Ende Seite 89.</p><div aria-label="90" epub:type="pagebreak" id="page90" role="doc-pagebreak"/>'
'<p>Text<a id="page_12"/> weiter, <span id="page_13">Wort</span> und<a id="p14"/> fort.</p>'
'<p id="p15">Absatz mit Absatz-ID.</p><h2><a id="page_16"/>Kapitelanfang</h2><p>x</p><pre><a id="page_17"/>code</pre>'
'<table class="Head2"><tr><td class="sec_num"><a id="page_18"/>1.2</td><td>Tabellentitel</td></tr></table>')
def test_p5_page_markers(convert):
none = convert(Book(docs=[Doc("c.xhtml", PAGES)])).chapter("001")
assert "S. 90" not in none and "S. 12" not in none
vis = convert(Book(docs=[Doc("c.xhtml", PAGES)]), "--page-markers", "visible")
text = vis.chapter("001")
assert "Ende Seite 89.\n\n\\[S. 90\\]" in text
assert "Text\\[S. 12\\] weiter, \\[S. 13\\]Wort und\\[S. 14\\] fort." in text
assert "S. 15" not in text # paragraph id, not a page
assert "\\[S. 16\\]\n\n## Kapitelanfang" in text # marker before the heading, not inside it
assert "```\ncode\n```" in text # nothing inside code
assert "\\[S. 18\\]\n\n## 1.2 Tabellentitel" in text # layout-table heading keeps the page
assert vis.report["layout"]["page_markers"] == 6
com = convert(Book(docs=[Doc("c.xhtml", PAGES)]), "--page-markers", "comment").chapter("001")
assert "<!-- S. 90 -->" in com and "Text<!-- S. 12 --> weiter" in com
def test_book_readme_suggests_callout_filter(convert):
body = '<h1>K</h1><div class="box"><h3 class="boxheading">Merke</h3><p>Wichtiger Satz.</p></div>'
readme = (convert(Book(docs=[Doc("c.xhtml", body)])).out / "README.md").read_text(encoding="utf-8")
assert "--lua-filter" in readme and "pandoc/callouts.lua" in readme and "1 Infokasten " in readme
plain = convert(Book(docs=[Doc("c.xhtml", body)]), "--boxes", "plain").out / "README.md"
assert "--lua-filter" not in plain.read_text(encoding="utf-8")
def test_hidden_span_gets_no_field_separator_and_class_type_wins(convert):
css = b".u { text-decoration: underline; } .weg { display: none; }"
body = ('<h1>K</h1><p><span class="u">unterstrichen</span><span class="weg">VERSTECKT</span>.</p>'
'<div class="box hinweis"><h3 class="boxheading">Wichtige Regel</h3><p>Immer testen.</p></div>')
text = convert(Book(docs=[Doc("c.xhtml", body)], media={"s.css": css})).chapter("001")
assert "<u>unterstrichen</u>." in text and "VERSTECKT" not in text
assert "> [!NOTE]\n> **Wichtige Regel**" in text # class 'hinweis' beats the word in the title