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>
This commit is contained in:
Dieter Schlüter 2026-10-06 05:00:25 +02:00
commit 4992672ca6
6 changed files with 218 additions and 4 deletions

View file

@ -209,6 +209,25 @@ python3 epub2md.py bildband.epub output/bildband --chapter-level 3
- Bilder, die im E-Book zwar enthalten, aber nirgends eingebunden sind, werden trotzdem gespeichert.
Im Bericht sind sie als `unreferenced` gekennzeichnet.
### Infokästen (Tipps, Hinweise, Warnungen)
Kästen aus dem Buch, etwa „Tipp“, „Achtung“, „Exkurs“ oder „Die MATLAB-Minute“, werden als hervorgehobene
Blöcke übernommen:
```markdown
> [!TIP]
> **Erwartungen an dieses Buch**
>
> Text des Kastens …
```
GitHub und Obsidian zeigen das als farbigen Kasten mit Symbol; andere Programme als eingerückten Zitatblock.
Andere Darstellungen wählst du mit `--boxes`:
- `quote`: Zitatblock ohne `[!TIP]`
- `html`: HTML-`<div>`
- `plain`: wie normaler Text
### Buch mit vielen Fußnoten
Standardmäßig stehen die Fußnoten am Ende des jeweiligen Kapitels. Alternativen:
@ -441,6 +460,7 @@ python3 epub2md_merge.py --help
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten |
| `--boxes callout/quote/html/plain` | Darstellung von Infokästen |
| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches |
| `--math auto/tex/mathml/image` | Formeldarstellung |
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |

View file

@ -97,6 +97,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
| `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt |
| `--code-language SPRACHE` | – | Sprachlabel für Codeblöcke ohne erkannte Sprache (z. B. `python`, `c`; Aliase wie `py` erlaubt). Erkannte Labels haben Vorrang; im Bericht `detected_by: "option"` |
| `--keep-wrap-markers` | aus | Druck-Umbruchzeichen `↪`/`➥` in Codeblöcken stehen lassen. Standard: Fortsetzungszeilen werden wieder zur ursprünglichen Codezeile zusammengefügt (Anzahl im Bericht: `wrap_lines_joined`) |
| `--boxes {callout,quote,html,plain}` | `callout` | Infokästen (Tipp, Hinweis, Warnung, Exkurs, Merke …): `callout` = `> [!TIP]` mit fettem Titel (GitHub-Alerts, Obsidian-Callouts; anderswo ein Zitatblock), `quote` = Zitatblock mit fettem Titel, `html` = `<div class="box box-tip">`, `plain` = normaler Text wie bisher |
| `--css-emphasis` / `--no-css-emphasis` | an | Fett, kursiv, unterstrichen, durchgestrichen, hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS-Klasse setzt (z. B. `span.italic`). Einzelwörter in Monospace-Schrift werden zu Inline-Code; per CSS ausgeblendete Elemente (`display:none`) entfallen. Statistik im Bericht unter `css` |
| `--keep-invisible-chars` | aus | Nullbreiten-Zeichen und weiche Trennstriche im Fließtext behalten. Standard: entfernen, weil sie Suche und RAG stören (Anzahl im Bericht: `text_cleanup`). Code bleibt immer unverändert |
| `--keep-html` | aus | Unbekannte/nicht sicher konvertierbare Elemente als bereinigtes HTML erhalten (sonst: Text übernommen + Eintrag in `diagnostics/unsupported-elements.md`) |
@ -243,6 +244,14 @@ weil CommonMark keine `[^…]`-Syntax kennt.
CSS-Dateien des Buches aus: Klassen-, Element- und ID-Selektoren, Nachfahren- und Kind-Kombinatoren,
Spezifität und `style`-Attribute. `@media`-Blöcke und Selektoren mit Pseudoklassen werden ignoriert.
Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`).
- **Infokästen:**
- **Erkennung:** `aside`/`epub:type` (sidebar, tip, warning …), Klassennamen (box, kasten, tipp, hinweis,
warnung, achtung, wichtig, merke, exkurs …) oder ein CSS-Rahmen bzw. -Hintergrund um kurzen Fließtext.
Folgen von Tipp-Absätzen ohne Rahmen werden zu einem Kasten zusammengefasst.
- **Titel und Symbole:** Kastenüberschriften werden zum fetten Titel und tauchen damit nicht mehr in der
Gliederung auf. Dekorative Symbolbilder entfallen.
- **Typ:** Er ergibt sich aus Klasse, Titel oder erstem Wort: TIP, IMPORTANT, WARNING, CAUTION, sonst NOTE.
- **Ausnahmen:** Umrahmte Codelistings und Bildrahmen gelten nicht als Kasten.
- **Szenenwechsel und Verse:**
- Leere Absätze zwischen zwei Textabsätzen werden zu `* * *`; mehrere hintereinander ergeben einen
Wechsel. Ebenso werden Ornament-Absätze wie `***` oder `⁂` zu `* * *`.

View file

@ -85,6 +85,10 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--keep-wrap-markers", action="store_true",
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
"Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt")
p.add_argument("--boxes", choices=["callout", "quote", "html", "plain"], default="callout",
help="Infokästen (Tipp, Hinweis, Warnung, Exkurs …): callout = '> [!TIP]' (GitHub/Obsidian), "
"quote = Zitatblock mit fettem Titel, html = <div class=\"box …\">, plain = normaler Text "
"(Standard: callout)")
p.add_argument("--css-emphasis", action=argparse.BooleanOptionalAction, default=True,
help="Fett/kursiv/unterstrichen/hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS "
"setzt; per CSS ausgeblendete Elemente weglassen (Standard: an)")
@ -127,6 +131,7 @@ def main(argv: list[str] | None = None) -> int:
keep_wrap_markers=args.keep_wrap_markers,
keep_invisible_chars=args.keep_invisible_chars,
css_emphasis=args.css_emphasis,
boxes=args.boxes,
strict=args.strict,
verbose=args.verbose,
dry_run=args.dry_run,

View file

@ -49,6 +49,7 @@ from .utils import (
rel_link,
remove_invisible,
resolve_href,
roles,
text_content,
url_path,
)
@ -97,6 +98,7 @@ class RenderOptions:
code_language: str = "" # --code-language: label for blocks without a detected language
keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines
keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens
boxes: str = "callout" # --boxes {callout,quote,html,plain}
css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses
@ -195,6 +197,30 @@ def _collapse_blank_lines(text: str) -> str:
return "\n".join(out)
CONSUMED = "data-e2m-consumed"
BOX_TAGS = {"div", "section", "aside", "p"}
BOX_CLASS = re.compile(r"(?:^|[-_])(box[a-z]?|kasten|infobox|infokasten|tipps?|tip|hint|hinweis|notiz|warn|warning|"
r"warnung|achtung|caution|vorsicht|important|wichtig|merke|merksatz|exkurs|sidebar|callout|"
r"alert)(?:[-_\d]|$)")
CALLOUT = {"note": "NOTE", "tip": "TIP", "important": "IMPORTANT", "warning": "WARNING", "caution": "CAUTION"}
TYPE_WORDS = {"tipp", "tip", "tipps", "hinweis", "note", "notiz", "info", "achtung", "warnung", "warning",
"wichtig", "important", "vorsicht", "caution", "gefahr", "merke"}
def _box_type(hint: str) -> str:
"""Callout type from class names, epub:type, a title or the first words of the box."""
h = hint.lower()
if re.search(r"vorsicht|caution|gefahr|danger", h):
return "caution"
if re.search(r"warn|achtung|attention", h):
return "warning"
if re.search(r"wichtig|important|merke", h):
return "important"
if re.search(r"tipp|\btip|hint", h):
return "tip"
return ""
ORNAMENT = re.compile(r"(?:[*∗⁂❖✻✱✳◆◇•·~][ \t]*){1,9}")
POEM_CLASS = re.compile(r"(?:^|[-_])(poem|poetry|verse|vers|lyrics|stanza|gedicht|lied|song)(?:$|[-_\d])")
ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"}
@ -267,6 +293,8 @@ class Renderer:
self.css = stylesheet
self.scene_breaks = 0
self.poems = 0
self.boxes = 0
self.in_box = False
self.css_applied: Counter[str] = Counter()
self.hidden_elements = 0
self.invisible_removed = 0
@ -337,8 +365,8 @@ class Renderer:
anchor = self.resolver.anchor_for(self.doc, item.element)
if anchor:
buf.append(("r", self._anchor(anchor)))
elif isinstance(item, Marker) or not is_element(item):
continue
elif isinstance(item, Marker) or not is_element(item) or item.get(CONSUMED):
continue # markers, comments, siblings already rendered as part of a box
elif self.is_block(item):
flush()
blocks.extend(self.block(item))
@ -409,6 +437,10 @@ class Renderer:
return ([anchor] if anchor else []) + ["* * *"]
if name in ("div", "section", "blockquote") and self._is_poem(el):
return self.poem(el)
if self.opt.boxes != "plain" and not self.in_box and not self.in_table and name in BOX_TAGS:
kind = self._box_kind(el)
if kind is not None:
return self.box(el, kind)
ch = self.ch
if ch is not None and el is ch.title_el and not heading_level(el):
return self.heading(el, forced_level=1)
@ -522,6 +554,112 @@ class Renderer:
text = "\n>\n".join("\\\n".join(f"> {line}" for line in stanza) for stanza in stanzas)
return ([anchor] if anchor else []) + [text]
# ------------------------------------------------------------------ info boxes (P2)
def _box_kind(self, el: Any) -> str | None:
"""'' for a box without type hint, a type keyword, or None if ``el`` is no box."""
name = local(el.tag)
types = set(epub_types(el)) | {r.removeprefix("doc-") for r in roles(el)}
hint = " ".join(sorted(types)) + " " + " ".join(classes(el))
semantic = types & {"sidebar", "tip", "notice", "warning", "caution", "important", "help", "example"}
by_class = any(BOX_CLASS.search(c) for c in classes(el))
if name == "p":
return _box_type(hint) if by_class and not self._is_empty(el) else None
if not (semantic or by_class or self._css_framed(el)):
return None
if not text_content(el).strip() or any(local(e.tag) == "pre" for e in iter_elements(el)) \
and not any(local(e.tag) == "p" and text_content(e).strip() for e in iter_elements(el)):
return None # bordered code listings or image frames are no info boxes
if self.ch is not None and any(e is self.ch.title_el for e in iter_elements(el)):
return None # never swallow the chapter heading
return _box_type(hint)
def _css_framed(self, el: Any) -> bool:
"""Block with a visible border or background in the book's CSS, holding short prose."""
if self.css is None or local(el.tag) not in ("div", "section", "aside"):
return False
style = self.css.computed(el)
border = any(k.startswith("border") and not re.search(r"\bnone\b|^0(px)?\b|hidden", v)
for k, v in style.items() if k in ("border", "border-top", "border-left", "border-style",
"border-width", "border-bottom", "border-right"))
bg = style.get("background-color") or style.get("background") or ""
background = bool(bg) and not re.search(r"transparent|none|inherit|initial|^#?fff(fff)?$|white", bg.lower())
if not (border or background):
return False
text = norm_ws(text_content(el))
return 0 < len(text) <= 3000 and any(local(e.tag) == "p" for e in iter_elements(el)) \
and not any(heading_level(e) in (1, 2) for e in iter_elements(el))
def box(self, el: Any, kind: str) -> list[str]:
anchor = self.anchors_of(el)
members = [el]
if local(el.tag) == "p": # paragraph-style boxes: group following siblings of the same kind
for sib in self._element_siblings(el, forward=True):
if local(sib.tag) == "p" and any(BOX_CLASS.search(c) for c in classes(sib)) \
and _box_type(" ".join(classes(sib))) == kind:
members.append(sib)
else:
break
title_el = self._box_title(members)
title = ""
if title_el is not None:
title = join_inline(self.inline_children(title_el), hard_break=" ").strip()
kind = kind or _box_type(norm_ws(text_content(title_el)))
saved = self.in_box
self.in_box = True
try:
blocks: list[str] = []
for member in members:
if member is title_el:
continue
if local(member.tag) == "p":
blocks.extend(self.flow(member))
else:
blocks.extend(self.render_items(
[(None, x) for x in self._items_without(member, title_el) if not self._box_icon(x)]))
finally:
self.in_box = saved
for sib in members[1:]:
sib.set(CONSUMED, "1")
if not kind and blocks:
kind = _box_type(norm_ws(re.sub(r"[*_\\]", "", blocks[0]))[:30])
kind = kind or "note"
if title and norm_ws(re.sub(r"[*_\\:]", "", title)).lower() in TYPE_WORDS:
title = "" # 'Tipp' as title of a TIP box adds nothing
self.boxes += 1
out = [anchor] if anchor else []
head = [f"**{title}**"] if title else []
if self.opt.boxes == "html":
out.append(f'<div class="box box-{kind}">\n\n' + "\n\n".join(head + blocks) + "\n\n</div>")
return out
body = "\n\n".join(head + blocks)
if self.opt.boxes == "callout":
body = f"[!{CALLOUT[kind]}]\n" + body
out.append("\n".join("> " + line if line.strip() else ">" for line in body.split("\n")))
return out
def _box_title(self, members: list[Any]) -> Any:
first = members[0]
if local(first.tag) == "p":
text = norm_ws(text_content(first))
return first if len(members) > 1 and 0 < len(text) <= 40 and not text.endswith((".", "!", "?")) else None
for child in first:
if not is_element(child) or self._box_icon(child):
continue
if heading_level(child) or any(re.search(r"heading|title|head$|ueberschrift", c) for c in classes(child)):
return child
return None
return None
@staticmethod
def _box_icon(node: Any) -> bool:
"""Decorative icon container inside a box (e.g. <div class="Box_img"><img alt="Image"></div>)."""
if not is_element(node) or text_content(node).strip():
return False
imgs = [e for e in iter_elements(node) if local(e.tag) == "img"]
return bool(imgs) and (any(re.search(r"icon|box_?img|symbol", c) for c in classes(node))
or all((i.get("alt") or "").strip().lower() in ("", "image", "icon", "bild") for i in imgs))
def _flow_with_anchor(self, el: Any) -> list[str]:
anchor = self.anchors_of(el)
if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types):

View file

@ -50,6 +50,7 @@ class Options:
keep_wrap_markers: bool = False
keep_invisible_chars: bool = False
css_emphasis: bool = True
boxes: str = "callout"
strict: bool = False
verbose: bool = False
dry_run: bool = False
@ -254,7 +255,8 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver:
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers,
keep_invisible_chars=opts.keep_invisible_chars, css_emphasis=opts.css_emphasis),
keep_invisible_chars=opts.keep_invisible_chars, css_emphasis=opts.css_emphasis,
boxes=opts.boxes),
resolver, media, notes, diag, set(reader.names), reader.entry_problem, stylesheet,
)
width = max(3, len(str(len(chapters) + 1)))
@ -517,7 +519,7 @@ def _report(input_path: Path, sha: str, generated_at: str, package: Any, opts: O
"text_cleanup": {"invisible_chars_removed": renderer.invisible_removed},
"css": {"rules": len(renderer.css.rules) if renderer.css else 0, "applied": dict(renderer.css_applied),
"hidden_elements": renderer.hidden_elements},
"layout": {"scene_breaks": renderer.scene_breaks, "poems": renderer.poems},
"layout": {"scene_breaks": renderer.scene_breaks, "poems": renderer.poems, "boxes": renderer.boxes},
"warnings": warnings,
"errors": errors,
"infos": [d.as_dict() for d in diag.items if d.level == "info"],

View file

@ -87,3 +87,43 @@ def test_p4_poems_keep_lines_and_stanzas(convert):
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
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")
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
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]