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.
Image
).""" + 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.

' + '
Image
' + '

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]