epub2md/tests/test_styles.py
dschlueter 5cef9d7885 feat: Farben, Schriften, Größen und Sprache per --styles (P3, P7)
--styles semantic (Standard): Hintergrundmarkierung wird zu <mark>, sonst
nur bedeutungstragende Gestaltung.
--styles html: Farbe, Hintergrund, Schriftgröße/-art, Kapitälchen und
Ausrichtung bleiben als <span style>/<div style> erhalten,
fremdsprachige Passagen als <span lang>. Übernommen wird nur, was vom
Grundstil abweicht (häufigste Werte aller Absätze, Schriftgröße ab ±15 %);
Formel- und Bildabsätze bekommen keine Ausrichtung; unsichere Werte werden
verworfen. Gilt im Fließtext, für Absätze und in HTML-Tabellen.

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

164 lines
9.5 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