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:
parent
16e2d186a4
commit
f7cceeba25
7 changed files with 204 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>)."""
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
132
pandoc/callouts.lua
Normal 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 },
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue