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:
parent
9b0f060af2
commit
4992672ca6
6 changed files with 218 additions and 4 deletions
|
|
@ -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.
|
- Bilder, die im E-Book zwar enthalten, aber nirgends eingebunden sind, werden trotzdem gespeichert.
|
||||||
Im Bericht sind sie als `unreferenced` gekennzeichnet.
|
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
|
### Buch mit vielen Fußnoten
|
||||||
|
|
||||||
Standardmäßig stehen die Fußnoten am Ende des jeweiligen Kapitels. Alternativen:
|
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 |
|
| `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache |
|
||||||
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
|
| `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen |
|
||||||
| `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten |
|
| `--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 |
|
| `--no-css-emphasis` | Hervorhebungen nur aus HTML-Tags übernehmen, nicht aus dem Stylesheet des Buches |
|
||||||
| `--math auto/tex/mathml/image` | Formeldarstellung |
|
| `--math auto/tex/mathml/image` | Formeldarstellung |
|
||||||
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |
|
| `--footnotes end-of-chapter/end-of-book/inline/keep-links` | Fußnoten-Platzierung |
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| `--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"` |
|
| `--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`) |
|
| `--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` |
|
| `--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-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`) |
|
| `--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,
|
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.
|
Spezifität und `style`-Attribute. `@media`-Blöcke und Selektoren mit Pseudoklassen werden ignoriert.
|
||||||
Direkt aneinanderstoßende gleiche Hervorhebungen werden zusammengefasst (`*x**y*` → `*xy*`).
|
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:**
|
- **Szenenwechsel und Verse:**
|
||||||
- Leere Absätze zwischen zwei Textabsätzen werden zu `* * *`; mehrere hintereinander ergeben einen
|
- Leere Absätze zwischen zwei Textabsätzen werden zu `* * *`; mehrere hintereinander ergeben einen
|
||||||
Wechsel. Ebenso werden Ornament-Absätze wie `***` oder `⁂` zu `* * *`.
|
Wechsel. Ebenso werden Ornament-Absätze wie `***` oder `⁂` zu `* * *`.
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
p.add_argument("--keep-wrap-markers", action="store_true",
|
p.add_argument("--keep-wrap-markers", action="store_true",
|
||||||
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
|
help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte "
|
||||||
"Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt")
|
"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,
|
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 "
|
help="Fett/kursiv/unterstrichen/hoch-/tiefgestellt auch übernehmen, wenn das Buch sie nur per CSS "
|
||||||
"setzt; per CSS ausgeblendete Elemente weglassen (Standard: an)")
|
"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_wrap_markers=args.keep_wrap_markers,
|
||||||
keep_invisible_chars=args.keep_invisible_chars,
|
keep_invisible_chars=args.keep_invisible_chars,
|
||||||
css_emphasis=args.css_emphasis,
|
css_emphasis=args.css_emphasis,
|
||||||
|
boxes=args.boxes,
|
||||||
strict=args.strict,
|
strict=args.strict,
|
||||||
verbose=args.verbose,
|
verbose=args.verbose,
|
||||||
dry_run=args.dry_run,
|
dry_run=args.dry_run,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ from .utils import (
|
||||||
rel_link,
|
rel_link,
|
||||||
remove_invisible,
|
remove_invisible,
|
||||||
resolve_href,
|
resolve_href,
|
||||||
|
roles,
|
||||||
text_content,
|
text_content,
|
||||||
url_path,
|
url_path,
|
||||||
)
|
)
|
||||||
|
|
@ -97,6 +98,7 @@ class RenderOptions:
|
||||||
code_language: str = "" # --code-language: label for blocks without a detected language
|
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_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
|
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
|
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)
|
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}")
|
ORNAMENT = re.compile(r"(?:[*∗⁂❖✻✱✳◆◇•·~][ \t]*){1,9}")
|
||||||
POEM_CLASS = re.compile(r"(?:^|[-_])(poem|poetry|verse|vers|lyrics|stanza|gedicht|lied|song)(?:$|[-_\d])")
|
POEM_CLASS = re.compile(r"(?:^|[-_])(poem|poetry|verse|vers|lyrics|stanza|gedicht|lied|song)(?:$|[-_\d])")
|
||||||
ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"}
|
ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"}
|
||||||
|
|
@ -267,6 +293,8 @@ class Renderer:
|
||||||
self.css = stylesheet
|
self.css = stylesheet
|
||||||
self.scene_breaks = 0
|
self.scene_breaks = 0
|
||||||
self.poems = 0
|
self.poems = 0
|
||||||
|
self.boxes = 0
|
||||||
|
self.in_box = False
|
||||||
self.css_applied: Counter[str] = Counter()
|
self.css_applied: Counter[str] = Counter()
|
||||||
self.hidden_elements = 0
|
self.hidden_elements = 0
|
||||||
self.invisible_removed = 0
|
self.invisible_removed = 0
|
||||||
|
|
@ -337,8 +365,8 @@ class Renderer:
|
||||||
anchor = self.resolver.anchor_for(self.doc, item.element)
|
anchor = self.resolver.anchor_for(self.doc, item.element)
|
||||||
if anchor:
|
if anchor:
|
||||||
buf.append(("r", self._anchor(anchor)))
|
buf.append(("r", self._anchor(anchor)))
|
||||||
elif isinstance(item, Marker) or not is_element(item):
|
elif isinstance(item, Marker) or not is_element(item) or item.get(CONSUMED):
|
||||||
continue
|
continue # markers, comments, siblings already rendered as part of a box
|
||||||
elif self.is_block(item):
|
elif self.is_block(item):
|
||||||
flush()
|
flush()
|
||||||
blocks.extend(self.block(item))
|
blocks.extend(self.block(item))
|
||||||
|
|
@ -409,6 +437,10 @@ class Renderer:
|
||||||
return ([anchor] if anchor else []) + ["* * *"]
|
return ([anchor] if anchor else []) + ["* * *"]
|
||||||
if name in ("div", "section", "blockquote") and self._is_poem(el):
|
if name in ("div", "section", "blockquote") and self._is_poem(el):
|
||||||
return self.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
|
ch = self.ch
|
||||||
if ch is not None and el is ch.title_el and not heading_level(el):
|
if ch is not None and el is ch.title_el and not heading_level(el):
|
||||||
return self.heading(el, forced_level=1)
|
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)
|
text = "\n>\n".join("\\\n".join(f"> {line}" for line in stanza) for stanza in stanzas)
|
||||||
return ([anchor] if anchor else []) + [text]
|
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]:
|
def _flow_with_anchor(self, el: Any) -> list[str]:
|
||||||
anchor = self.anchors_of(el)
|
anchor = self.anchors_of(el)
|
||||||
if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types):
|
if self.ch is not None and {"index", "index-persons", "glossary"} & set(self.ch.types):
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ class Options:
|
||||||
keep_wrap_markers: bool = False
|
keep_wrap_markers: bool = False
|
||||||
keep_invisible_chars: bool = False
|
keep_invisible_chars: bool = False
|
||||||
css_emphasis: bool = True
|
css_emphasis: bool = True
|
||||||
|
boxes: str = "callout"
|
||||||
strict: bool = False
|
strict: bool = False
|
||||||
verbose: bool = False
|
verbose: bool = False
|
||||||
dry_run: 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,
|
RenderOptions(format=opts.format, footnotes=opts.footnotes, math=opts.math,
|
||||||
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
|
extract_media=opts.extract_media, keep_html=opts.keep_html, language=language,
|
||||||
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers,
|
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,
|
resolver, media, notes, diag, set(reader.names), reader.entry_problem, stylesheet,
|
||||||
)
|
)
|
||||||
width = max(3, len(str(len(chapters) + 1)))
|
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},
|
"text_cleanup": {"invisible_chars_removed": renderer.invisible_removed},
|
||||||
"css": {"rules": len(renderer.css.rules) if renderer.css else 0, "applied": dict(renderer.css_applied),
|
"css": {"rules": len(renderer.css.rules) if renderer.css else 0, "applied": dict(renderer.css_applied),
|
||||||
"hidden_elements": renderer.hidden_elements},
|
"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,
|
"warnings": warnings,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"infos": [d.as_dict() for d in diag.items if d.level == "info"],
|
"infos": [d.as_dict() for d in diag.items if d.level == "info"],
|
||||||
|
|
|
||||||
|
|
@ -87,3 +87,43 @@ def test_p4_poems_keep_lines_and_stanzas(convert):
|
||||||
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
|
text = convert(Book(docs=[Doc("c.xhtml", body)])).chapter("001")
|
||||||
assert "> »Erste Zeile\\\n> zweite Zeile\n>\n> Zweite Strophe«" in text
|
assert "> »Erste Zeile\\\n> zweite Zeile\n>\n> Zweite Strophe«" in text
|
||||||
assert "* * *" not 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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue