diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 2b68082..173f5b5 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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 diff --git a/README.md b/README.md index 57816ac..17374af 100644 --- a/README.md +++ b/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 ``-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 diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index 31fc82c..3e619cf 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -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. [»].""" + 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.
Image
).""" diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py index 940be21..c9021b4 100644 --- a/epub2md/pipeline.py +++ b/epub2md/pipeline.py @@ -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}") diff --git a/epub2md/writer.py b/epub2md/writer.py index 29ff3a3..3a25bc1 100644 --- a/epub2md/writer.py +++ b/epub2md/writer.py @@ -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") diff --git a/pandoc/callouts.lua b/pandoc/callouts.lua new file mode 100644 index 0000000..3bd34a5 --- /dev/null +++ b/pandoc/callouts.lua @@ -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:
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
coming back from HTML (md -> html -> pdf route) and styles +the
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 = [[ + +]] + +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 + +--
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 }, +} diff --git a/tests/test_styles.py b/tests/test_styles.py index dc3ca05..c3b42ea 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -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 "" in com and "Text weiter" in com + + +def test_book_readme_suggests_callout_filter(convert): + body = '

K

Merke

Wichtiger Satz.

' + 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")