2026-10-06 02:15:35 +02:00
|
|
|
|
"""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
|
2026-10-06 04:52:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-06 04:57:03 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_p4_scene_breaks(convert):
|
|
|
|
|
|
body = ('<h1>K</h1><p class="leerzeile"> </p><p>Erster Absatz.</p><p class="leerzeile"> </p>'
|
|
|
|
|
|
'<p class="leerzeile"> </p><p>Neue Szene.</p><p>Weiter.</p><p class="centertext">***</p>'
|
|
|
|
|
|
'<p>Nach Ornament.</p><p> </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"> </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
|
feat: Infokästen als Callouts (P2)
Kästen werden erkannt über aside/epub:type, Klassennamen (box, kasten, tipp,
hinweis, warnung, wichtig, exkurs …) oder einen CSS-Rahmen/-Hintergrund um
kurzen Fließtext. Folgen von Tipp-Absätzen werden gruppiert. Ausgabe:
'> [!TIP]' mit fettem Titel (GitHub-Alert/Obsidian-Callout). Der Typ kommt
aus Klasse, Titel oder erstem Wort; dekorative Symbolbilder entfallen;
umrahmte Codelistings bleiben Code. Option --boxes callout|quote|html|plain.
Beispielbücher: Python-Buch 72, C-Buch 83, Bildband 32 Tipps, Mathebuch 11
MATLAB-Minuten; Code-Treue und Links unverändert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:00:25 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
2026-10-06 05:32:27 +02:00
|
|
|
|
assert "> [!NOTE]\n> **Erwartungen**\n>\n> Kein Kompendium.\n>\n> Zweiter Absatz." in text
|
feat: Infokästen als Callouts (P2)
Kästen werden erkannt über aside/epub:type, Klassennamen (box, kasten, tipp,
hinweis, warnung, wichtig, exkurs …) oder einen CSS-Rahmen/-Hintergrund um
kurzen Fließtext. Folgen von Tipp-Absätzen werden gruppiert. Ausgabe:
'> [!TIP]' mit fettem Titel (GitHub-Alert/Obsidian-Callout). Der Typ kommt
aus Klasse, Titel oder erstem Wort; dekorative Symbolbilder entfallen;
umrahmte Codelistings bleiben Code. Option --boxes callout|quote|html|plain.
Beispielbücher: Python-Buch 72, C-Buch 83, Bildband 32 Tipps, Mathebuch 11
MATLAB-Minuten; Code-Treue und Links unverändert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:00:25 +02:00
|
|
|
|
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]
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-06 05:10:38 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-06 05:32:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-06 06:03:55 +02:00
|
|
|
|
assert "pandoc/print.css --pdf-engine=weasyprint" in readme
|
2026-10-06 05:32:27 +02:00
|
|
|
|
plain = convert(Book(docs=[Doc("c.xhtml", body)]), "--boxes", "plain").out / "README.md"
|
|
|
|
|
|
assert "--lua-filter" not in plain.read_text(encoding="utf-8")
|
2026-10-06 05:36:25 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-06 05:41:55 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_page_marker_in_box_title_goes_before_the_box(convert):
|
|
|
|
|
|
body = ('<h1>K</h1><div class="box"><h3 class="boxheading"><a id="page_62"/>Wozu Deklarationen?</h3>'
|
|
|
|
|
|
'<p>Weil es <a id="page_63"/>hilft.</p></div>')
|
|
|
|
|
|
text = convert(Book(docs=[Doc("c.xhtml", body)]), "--page-markers", "visible").chapter("001")
|
|
|
|
|
|
assert "\\[S. 62\\]\n\n> [!NOTE]\n> **Wozu Deklarationen?**" in text
|
|
|
|
|
|
assert "> Weil es \\[S. 63\\]hilft." in text # markers in the box body stay in place
|