feat: Pandoc-Filter callouts.lua für Infokästen

Pandoc 3.1.3 kennt '> [!TIP]' nicht und druckt die Marke als Text.
pandoc/callouts.lua macht daraus in HTML/EPUB gerahmte, farbige Kästen
(<div class="callout callout-tip"> + CSS) und in LaTeX/DOCX/ODT einen
Zitatblock mit fettem Titel ("Tipp: …", englisch bei lang: en); auch für
den PDF-Weg über HTML.

- Buch-README: Pandoc-Befehle enthalten --lua-filter, wenn Callouts da sind
- Kastentitel ohne dekorative Symbole (<span class="box_icon">[»]</span>)
- README: css.py/pandoc in der Architektur, veralteter CSS-Hinweis korrigiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-06 05:32:27 +02:00
commit f7cceeba25
7 changed files with 204 additions and 12 deletions

View file

@ -222,6 +222,7 @@ Blöcke übernommen:
```
GitHub und Obsidian zeigen das als farbigen Kasten mit Symbol; andere Programme als eingerückten Zitatblock.
Für Pandoc (HTML, EPUB, PDF) gibt es einen Filter, der echte Kästen zeichnet, siehe Kapitel 7.
Andere Darstellungen wählst du mit `--boxes`:
- `quote`: Zitatblock ohne `[!TIP]`
@ -331,6 +332,23 @@ Original-E-Books.
Bei Büchern mit Formeln für HTML zusätzlich `--mathml` angeben, sonst bleiben Matrizen als TeX-Text stehen.
#### Infokästen richtig darstellen
Pandoc in der Ubuntu-Version kennt die Kasten-Schreibweise `> [!TIP]` nicht und druckt „[!TIP]“ einfach mit.
Dafür liegt im epub2md-Ordner der Filter `pandoc/callouts.lua`. Hänge ihn an jeden Pandoc-Befehl an:
```bash
F=~/Python_Programs/epub2md/pandoc/callouts.lua # Pfad zu deinem epub2md-Ordner
pandoc meinbuch.md -s --toc --lua-filter $F -o meinbuch.html
pandoc meinbuch.md --toc --lua-filter $F -o meinbuch.epub
pandoc meinbuch.md -s --lua-filter $F -o meinbuch.html && pandoc meinbuch.html --toc --lua-filter $F --pdf-engine=xelatex -o meinbuch.pdf
```
In HTML, EPUB und im PDF über weasyprint werden daraus gerahmte, farbige Kästen mit Titel („Tipp: …“). Im
PDF über LaTeX und in Word/LibreOffice wird ein eingerückter Block mit fettem Titel daraus. Du musst nicht
selbst nachsehen, ob dein Buch Kästen hat: Die `README.md` im Ausgabeordner enthält in diesem Fall die
Befehle bereits mit Filter.
#### Direkt aus den Kapiteldateien
Ohne Gesamtdatei geht es auch. Dann im Ordner `chapters/` arbeiten und die Buchdaten aus

View file

@ -329,8 +329,9 @@ konsistente Anker:
```bash
cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei
pandoc programmierbuch.md --toc -s -o programmierbuch.html
pandoc programmierbuch.md --toc -o programmierbuch.epub
F=~/Python_Programs/epub2md/pandoc/callouts.lua # Pfad zum epub2md-Ordner anpassen
pandoc programmierbuch.md --toc -s --lua-filter $F -o programmierbuch.html
pandoc programmierbuch.md --toc --lua-filter $F -o programmierbuch.epub
cd output/mathematikbuch
pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html
@ -338,6 +339,12 @@ pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html
pandoc mathematikbuch.html --toc --pdf-engine=xelatex -o mathematikbuch.pdf
```
**Infokästen:** Pandoc kennt die Schreibweise `> [!TIP]` erst ab neueren Versionen (Erweiterung `alerts`);
3.1.3 (Ubuntu 24.04) zeigt „[!TIP]“ als Text. Der Filter `pandoc/callouts.lua` behebt das: In HTML und
EPUB entstehen gerahmte, farbige Kästen mit Titel („Tipp: …“, bei `lang: en` „Tip: …“), in LaTeX/PDF, Word
und ODT ein Zitatblock mit fettem Titel. Beim PDF-Weg über HTML den Filter in beiden Schritten angeben.
Die `README.md` eines konvertierten Buches mit Kästen enthält die Befehle bereits mit dem Filter.
**PDF immer über HTML erzeugen.** Beim direkten Weg Markdown → LaTeX verwirft Pandoc allen rohen HTML-Code.
Dabei gehen HTML-Tabellen samt Inhalt verloren, und `<a id>`-Sprungziele fehlen, sodass interne Links im PDF
ins Leere zeigen. Liest Pandoc dagegen die HTML-Fassung, macht es daraus echte Tabellen und Sprungziele.
@ -403,7 +410,7 @@ Verlustfreiheit behauptet (`lossless: false`).
- Audio/Video sind implementiert, aber nur mit synthetischen Daten geprüft. Bildbände sind mit einem
realen Buch geprüft (434 Bilder, 246 Figuren, ausführliche Alternativtexte).
- Rein per CSS erzeugte Inhalte (`::before`/`content:`, z. B. automatische Schrittnummern) werden nicht
übernommen, weil CSS nicht ausgewertet wird.
übernommen, weil Pseudo-Elemente nicht ausgewertet werden.
## Architektur
@ -419,6 +426,7 @@ epub2md/
├── chapters.py Kapitelbildung (Spine + Überschriften + epub:type), Klassifikation
├── references.py Ziel-IDs → Kapiteldatei + Anker, Link-Auflösung
├── html_converter.py rekursiver XHTML→Markdown-Renderer (Blöcke, Inline, Tabellen, Listen, Bilder)
├── css.py Mini-CSS-Auswertung (Selektoren, Spezifität) für Hervorhebungen und Stile
├── codeblocks.py Codeerkennung, exakte Textextraktion, Spracherkennung, Fences
├── mathml.py konservative MathML→TeX-Konvertierung, TeX-Erkennung
├── media.py Medienregistry mit stabilen Namen
@ -428,6 +436,8 @@ epub2md/
├── diagnostics.py zentrale Sammlung von Warnungen/Fehlern
├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …)
└── utils.py Slugs, YAML-Emitter, Pfadhelfer
pandoc/
└── callouts.lua Pandoc-Filter: '> [!TIP]'-Kästen → gerahmte Kästen (HTML/EPUB) bzw. Zitatblock mit Titel
```
Statt BeautifulSoup wird direkt lxml verwendet: Es ist für die XHTML-Bäume ausreichend, schneller und

View file

@ -621,7 +621,13 @@ class Renderer:
title_el = self._box_title(members)
title = ""
if title_el is not None:
title = join_inline(self.inline_children(title_el), hard_break=" ").strip()
pieces: list[Piece] = [("t", self._text(title_el.text))] if title_el.text else []
for child in title_el:
if is_element(child) and not self._title_symbol(child):
pieces.extend(self.inline(child))
if child.tail:
pieces.append(("t", self._text(child.tail)))
title = join_inline(pieces, hard_break=" ").strip()
kind = kind or _box_type(norm_ws(text_content(title_el)))
saved = self.in_box
self.in_box = True
@ -669,6 +675,13 @@ class Renderer:
return None
return None
@staticmethod
def _title_symbol(node: Any) -> bool:
"""Decorative symbol in a box title, e.g. <span class="box_icon">[»]</span>."""
text = norm_ws(text_content(node))
return any(re.search(r"icon|symbol", c) for c in classes(node)) and len(text) <= 5 \
and not re.search(r"\w", text)
@staticmethod
def _box_icon(node: Any) -> bool:
"""Decorative icon container inside a box (e.g. <div class="Box_img"><img alt="Image"></div>)."""

View file

@ -363,7 +363,8 @@ def _assemble(input_path: Path, output_dir: Path, opts: Options, diag: Diagnosti
stats = _book_stats(all_chapters, media, renderer, diag)
if opts.toc:
files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, rendered.cover_out,
package.version, input_path.name, opts.command, str(output_dir))
package.version, input_path.name, opts.command, str(output_dir),
renderer.boxes if opts.boxes == "callout" else 0)
existing = {mf.output for mf in media.files.values()} if opts.extract_media else set()
if opts.copy_original:
existing.add(f"source/{input_path.name}")

View file

@ -6,6 +6,7 @@ import json
import posixpath
import shlex
from collections import Counter
from pathlib import Path
from typing import Any
from . import TOOL_NAME, __version__
@ -15,6 +16,8 @@ from .models import BookMetadata, Chapter, NavEntry
from .references import LinkResolver
from .utils import rel_link, to_yaml, url_path
CALLOUT_FILTER = Path(__file__).resolve().parent.parent / "pandoc" / "callouts.lua"
SPECIAL_LABELS = {
"glossary": "Glossar",
"bibliography": "Literatur / Quellen",
@ -87,7 +90,7 @@ def _nav_lines(entries: list[NavEntry], resolver: LinkResolver, depth: int = 0)
def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None, nav: list[NavEntry],
resolver: LinkResolver, stats: dict[str, int], cover: str, epub_version: str, source_name: str,
command: list[str], output_dir: str = "OUTPUT_VERZEICHNIS") -> str:
command: list[str], output_dir: str = "OUTPUT_VERZEICHNIS", callouts: int = 0) -> str:
out = [f"# {md_escape(md.title or source_name)}"]
if md.subtitle:
out.append(f"*{md_escape(md.subtitle)}*")
@ -130,19 +133,26 @@ def readme(md: BookMetadata, chapters: list[Chapter], endnotes: Chapter | None,
"[manifest.json](manifest.json), [Warnungen](diagnostics/warnings.md)")
stem = source_name[:-5] if source_name.lower().endswith(".epub") else source_name
math = " --mathml" if stats.get("formulas") else ""
lua = ""
if callouts and CALLOUT_FILTER.is_file(): # '> [!TIP]' boxes: Pandoc needs the filter to draw real boxes
lua = f" --lua-filter {shlex.quote(str(CALLOUT_FILTER))}"
commands = [
"# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)",
f"python3 epub2md_merge.py {shlex.quote(output_dir)}",
"# danach in diesem Ordner:",
f"pandoc {shlex.quote(stem + '.md')} --toc -s{math} -o {shlex.quote(stem + '.html')}",
f"pandoc {shlex.quote(stem + '.md')} --toc -o {shlex.quote(stem + '.epub')}",
f"pandoc {shlex.quote(stem + '.md')} --toc -s{math}{lua} -o {shlex.quote(stem + '.html')}",
f"pandoc {shlex.quote(stem + '.md')} --toc{lua} -o {shlex.quote(stem + '.epub')}",
"# PDF über HTML: Pandoc übernimmt dann auch HTML-Tabellen und interne Sprungziele",
f"pandoc {shlex.quote(stem + '.md')} -s{math} -o {shlex.quote(stem + '.html')} && "
f"pandoc {shlex.quote(stem + '.html')} --toc --pdf-engine=xelatex -o {shlex.quote(stem + '.pdf')}",
f"pandoc {shlex.quote(stem + '.md')} -s{math}{lua} -o {shlex.quote(stem + '.html')} && "
f"pandoc {shlex.quote(stem + '.html')} --toc{lua} --pdf-engine=xelatex -o {shlex.quote(stem + '.pdf')}",
]
out.append("## Weiterverarbeitung")
out.append("```bash\n" + "\n".join(commands) + "\n```")
if math:
if callouts:
noun = "Infokasten" if callouts == 1 else "Infokästen"
out.append(f"Das Buch enthält {callouts} {noun} (`> [!TIP]` …). Der Filter `callouts.lua` macht daraus "
"in HTML, EPUB und PDF gerahmte Kästen mit Titel; ohne ihn zeigt Pandoc „[!TIP]“ als Text.")
if stats.get("formulas"):
out.append("Das Buch enthält Formeln: Für HTML `--mathml` (oder `--mathjax`) verwenden, sonst belässt "
"Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.")
out.append("## Reproduktion")

132
pandoc/callouts.lua Normal file
View file

@ -0,0 +1,132 @@
--[[
callouts.lua – Pandoc filter for the info boxes written by epub2md (--boxes callout).
epub2md writes boxes as GitHub alerts / Obsidian callouts:
> [!TIP]
> **Title**
>
> Text …
Pandoc < 3.1.10 does not know this syntax and prints "[!TIP]" literally. This filter turns such
block quotes into real boxes:
* HTML, EPUB: <div class="callout callout-tip"> with a bold label and a small stylesheet
* other formats (LaTeX/PDF, DOCX, ODT …): block quote with a bold label "Tipp: Title"
It also handles <div class="callout …"> coming back from HTML (md -> html -> pdf route) and styles
the <div class="box …"> blocks of --boxes html.
Usage:
pandoc buch.md -s --lua-filter /pfad/zu/epub2md/pandoc/callouts.lua -o buch.html
]]
local LABELS = {
de = { NOTE = "Hinweis", TIP = "Tipp", IMPORTANT = "Wichtig", WARNING = "Warnung", CAUTION = "Vorsicht" },
en = { NOTE = "Note", TIP = "Tip", IMPORTANT = "Important", WARNING = "Warning", CAUTION = "Caution" },
}
local CSS = [[
<style>
.callout, .box { border: 1px solid #c8ccd2; border-left: 0.35em solid #4a7bd0; border-radius: 0.3em;
background: #f5f8fc; padding: 0.4em 1em; margin: 1em 0; }
.callout > p:first-child { margin-top: 0.4em; }
.callout-tip { border-left-color: #2e9a4f; background: #f3faf5; }
.callout-important { border-left-color: #8250df; background: #f8f5fe; }
.callout-warning { border-left-color: #c58a00; background: #fffaf0; }
.callout-caution { border-left-color: #cf222e; background: #fff5f5; }
</style>
]]
local lang = "de"
local function is_html()
return FORMAT:match("html") ~= nil or FORMAT:match("epub") ~= nil
end
local function label_for(kind)
local labels = LABELS[lang] or LABELS.de
return labels[kind] or labels.NOTE
end
local function drop_leading_breaks(inlines)
while #inlines > 0 and (inlines[1].t == "SoftBreak" or inlines[1].t == "LineBreak" or inlines[1].t == "Space") do
inlines:remove(1)
end
return inlines
end
-- Build the box from its kind ("TIP" …), optional title inlines and body blocks.
local function make_box(kind, title, body)
local head = pandoc.Inlines({ pandoc.Str(label_for(kind)) })
if title and #title > 0 then
head:insert(pandoc.Str(":"))
head:insert(pandoc.Space())
head:extend(title)
end
local blocks = pandoc.Blocks({ pandoc.Para({ pandoc.Strong(head) }) })
blocks:extend(body)
if is_html() then
return pandoc.Div(blocks, pandoc.Attr("", { "callout", "callout-" .. kind:lower() }))
end
return pandoc.BlockQuote(blocks)
end
local function Meta(meta)
if meta.lang then
local l = pandoc.utils.stringify(meta.lang):lower():sub(1, 2)
if LABELS[l] then
lang = l
end
end
if is_html() then
local includes = meta["header-includes"]
if includes == nil then
includes = pandoc.MetaList({})
elseif includes.t ~= "MetaList" then
includes = pandoc.MetaList({ includes })
end
includes:insert(pandoc.MetaBlocks({ pandoc.RawBlock("html", CSS) }))
meta["header-includes"] = includes
end
return meta
end
local function BlockQuote(quote)
local first = quote.content[1]
if not first or first.t ~= "Para" or #first.content == 0 or first.content[1].t ~= "Str" then
return nil
end
local kind = first.content[1].text:match("^%[!(%a+)%]$")
if not kind then
return nil
end
kind = kind:upper()
local rest = drop_leading_breaks(first.content:clone())
rest:remove(1)
rest = drop_leading_breaks(rest)
local body = pandoc.Blocks({})
for i = 2, #quote.content do
body:insert(quote.content[i])
end
local title = nil
if #rest == 1 and rest[1].t == "Strong" then
title = rest[1].content -- "> **Title**" line
elseif #rest > 0 then
body:insert(1, pandoc.Para(rest)) -- text directly after the marker
end
return make_box(kind, title, body)
end
-- <div class="callout callout-tip"> read back from HTML: keep it as a box in non-HTML formats.
local function Div(div)
if not div.classes:includes("callout") or is_html() then
return nil
end
return pandoc.BlockQuote(div.content)
end
return {
{ Meta = Meta },
{ BlockQuote = BlockQuote, Div = Div },
}

View file

@ -109,7 +109,7 @@ def test_p2_boxes_become_callouts(convert):
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 "> [!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
@ -184,3 +184,11 @@ def test_p5_page_markers(convert):
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")