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. 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`: Andere Darstellungen wählst du mit `--boxes`:
- `quote`: Zitatblock ohne `[!TIP]` - `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. 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 #### Direkt aus den Kapiteldateien
Ohne Gesamtdatei geht es auch. Dann im Ordner `chapters/` arbeiten und die Buchdaten aus Ohne Gesamtdatei geht es auch. Dann im Ordner `chapters/` arbeiten und die Buchdaten aus

View file

@ -329,8 +329,9 @@ konsistente Anker:
```bash ```bash
cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei cd output/programmierbuch # Medienpfade sind relativ zur Gesamtdatei
pandoc programmierbuch.md --toc -s -o programmierbuch.html F=~/Python_Programs/epub2md/pandoc/callouts.lua # Pfad zum epub2md-Ordner anpassen
pandoc programmierbuch.md --toc -o programmierbuch.epub pandoc programmierbuch.md --toc -s --lua-filter $F -o programmierbuch.html
pandoc programmierbuch.md --toc --lua-filter $F -o programmierbuch.epub
cd output/mathematikbuch cd output/mathematikbuch
pandoc mathematikbuch.md --mathml -s -o mathematikbuch.html 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 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. **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 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. 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 - 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). realen Buch geprüft (434 Bilder, 246 Figuren, ausführliche Alternativtexte).
- Rein per CSS erzeugte Inhalte (`::before`/`content:`, z. B. automatische Schrittnummern) werden nicht - 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 ## Architektur
@ -419,6 +426,7 @@ epub2md/
├── chapters.py Kapitelbildung (Spine + Überschriften + epub:type), Klassifikation ├── chapters.py Kapitelbildung (Spine + Überschriften + epub:type), Klassifikation
├── references.py Ziel-IDs → Kapiteldatei + Anker, Link-Auflösung ├── references.py Ziel-IDs → Kapiteldatei + Anker, Link-Auflösung
├── html_converter.py rekursiver XHTML→Markdown-Renderer (Blöcke, Inline, Tabellen, Listen, Bilder) ├── 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 ├── codeblocks.py Codeerkennung, exakte Textextraktion, Spracherkennung, Fences
├── mathml.py konservative MathML→TeX-Konvertierung, TeX-Erkennung ├── mathml.py konservative MathML→TeX-Konvertierung, TeX-Erkennung
├── media.py Medienregistry mit stabilen Namen ├── media.py Medienregistry mit stabilen Namen
@ -428,6 +436,8 @@ epub2md/
├── diagnostics.py zentrale Sammlung von Warnungen/Fehlern ├── diagnostics.py zentrale Sammlung von Warnungen/Fehlern
├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …) ├── models.py Dataclasses (Kapitel, Medien, Noten, Metadaten …)
└── utils.py Slugs, YAML-Emitter, Pfadhelfer └── 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 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_el = self._box_title(members)
title = "" title = ""
if title_el is not None: 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))) kind = kind or _box_type(norm_ws(text_content(title_el)))
saved = self.in_box saved = self.in_box
self.in_box = True self.in_box = True
@ -669,6 +675,13 @@ class Renderer:
return None return None
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 @staticmethod
def _box_icon(node: Any) -> bool: def _box_icon(node: Any) -> bool:
"""Decorative icon container inside a box (e.g. <div class="Box_img"><img alt="Image"></div>).""" """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) stats = _book_stats(all_chapters, media, renderer, diag)
if opts.toc: if opts.toc:
files["README.md"] = writer.readme(md, chapters, endnotes, package.toc, resolver, stats, rendered.cover_out, 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() existing = {mf.output for mf in media.files.values()} if opts.extract_media else set()
if opts.copy_original: if opts.copy_original:
existing.add(f"source/{input_path.name}") existing.add(f"source/{input_path.name}")

View file

@ -6,6 +6,7 @@ import json
import posixpath import posixpath
import shlex import shlex
from collections import Counter from collections import Counter
from pathlib import Path
from typing import Any from typing import Any
from . import TOOL_NAME, __version__ from . import TOOL_NAME, __version__
@ -15,6 +16,8 @@ from .models import BookMetadata, Chapter, NavEntry
from .references import LinkResolver from .references import LinkResolver
from .utils import rel_link, to_yaml, url_path from .utils import rel_link, to_yaml, url_path
CALLOUT_FILTER = Path(__file__).resolve().parent.parent / "pandoc" / "callouts.lua"
SPECIAL_LABELS = { SPECIAL_LABELS = {
"glossary": "Glossar", "glossary": "Glossar",
"bibliography": "Literatur / Quellen", "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], 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, 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)}"] out = [f"# {md_escape(md.title or source_name)}"]
if md.subtitle: if md.subtitle:
out.append(f"*{md_escape(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)") "[manifest.json](manifest.json), [Warnungen](diagnostics/warnings.md)")
stem = source_name[:-5] if source_name.lower().endswith(".epub") else source_name stem = source_name[:-5] if source_name.lower().endswith(".epub") else source_name
math = " --mathml" if stats.get("formulas") else "" 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 = [ commands = [
"# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)", "# Gesamtdatei erzeugen (im selben Arbeitsverzeichnis wie die Konvertierung)",
f"python3 epub2md_merge.py {shlex.quote(output_dir)}", f"python3 epub2md_merge.py {shlex.quote(output_dir)}",
"# danach in diesem Ordner:", "# 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 -s{math}{lua} -o {shlex.quote(stem + '.html')}",
f"pandoc {shlex.quote(stem + '.md')} --toc -o {shlex.quote(stem + '.epub')}", 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", "# 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 + '.md')} -s{math}{lua} -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 + '.html')} --toc{lua} --pdf-engine=xelatex -o {shlex.quote(stem + '.pdf')}",
] ]
out.append("## Weiterverarbeitung") out.append("## Weiterverarbeitung")
out.append("```bash\n" + "\n".join(commands) + "\n```") 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 " 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.") "Pandoc Matrizen und ähnliche Konstrukte als TeX-Text.")
out.append("## Reproduktion") 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 from conftest import PNG
run = convert(Book(docs=[Doc("c.xhtml", body)], media={"box.css": BOX_CSS, "tick.png": PNG})) run = convert(Book(docs=[Doc("c.xhtml", body)], media={"box.css": BOX_CSS, "tick.png": PNG}))
text = run.chapter("001") 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 "> [!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 "> [!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 "> [!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 assert vis.report["layout"]["page_markers"] == 6
com = convert(Book(docs=[Doc("c.xhtml", PAGES)]), "--page-markers", "comment").chapter("001") 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 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")