diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md
index 63aa171..580ef3b 100644
--- a/BEDIENUNGSANLEITUNG.md
+++ b/BEDIENUNGSANLEITUNG.md
@@ -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-`
`
+- `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 |
diff --git a/README.md b/README.md
index 71be0ab..7817d19 100644
--- a/README.md
+++ b/README.md
@@ -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` = `
`, `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 `* * *`.
diff --git a/epub2md/cli.py b/epub2md/cli.py
index 0555128..09a8177 100644
--- a/epub2md/cli.py
+++ b/epub2md/cli.py
@@ -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 =
, 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,
diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py
index d397e10..ea72b80 100644
--- a/epub2md/html_converter.py
+++ b/epub2md/html_converter.py
@@ -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'
\n\n' + "\n\n".join(head + blocks) + "\n\n
")
+ 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.
)."""
+ 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):
diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py
index df4770e..f520fb0 100644
--- a/epub2md/pipeline.py
+++ b/epub2md/pipeline.py
@@ -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"],
diff --git a/tests/test_styles.py b/tests/test_styles.py
index b80f217..e41ac9d 100644
--- a/tests/test_styles.py
+++ b/tests/test_styles.py
@@ -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 = ('
K
Vorher.
'
+ '
[»] Erwartungen
'
+ '
Kein Kompendium.
Zweiter Absatz.
'
+ '
'
+ '
Wichtig: Funktionen sind Objekte.
'
+ '
Tipp
Gelb mit Blau mischen.
Mehr Weiß.
'
+ ''
+ '
Achtung, heiß!
'
+ '
code()
Nachher.
')
+ 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 = '
K
'
+ 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 '
\n\n**Hinweis zum Pinsel**\n\nSauber halten.\n\n
' 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]