feat: Farben, Schriften, Größen und Sprache per --styles (P3, P7)

--styles semantic (Standard): Hintergrundmarkierung wird zu <mark>, sonst
nur bedeutungstragende Gestaltung.
--styles html: Farbe, Hintergrund, Schriftgröße/-art, Kapitälchen und
Ausrichtung bleiben als <span style>/<div style> erhalten,
fremdsprachige Passagen als <span lang>. Übernommen wird nur, was vom
Grundstil abweicht (häufigste Werte aller Absätze, Schriftgröße ab ±15 %);
Formel- und Bildabsätze bekommen keine Ausrichtung; unsichere Werte werden
verworfen. Gilt im Fließtext, für Absätze und in HTML-Tabellen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-06 05:04:35 +02:00
commit 5cef9d7885
7 changed files with 227 additions and 8 deletions

View file

@ -228,6 +228,21 @@ Andere Darstellungen wählst du mit `--boxes`:
- `html`: HTML-`<div>` - `html`: HTML-`<div>`
- `plain`: wie normaler Text - `plain`: wie normaler Text
### Farben, Schriftarten und Schriftgrößen
Markdown kennt keine Farben, Schriftarten oder Schriftgrößen. Standardmäßig übernimmt das Programm daher
nur, was eine Bedeutung trägt:
- **fett** und *kursiv*, auch wenn das Buch sie nur über sein Stylesheet setzt
- unterstrichen, hoch- und tiefgestellt
- farbig hinterlegter Text als `<mark>`-Markierung
Möchtest du das Aussehen möglichst erhalten, etwa für eine HTML-, EPUB- oder PDF-Ausgabe mit Pandoc oder zum
Lesen in Obsidian, wandle mit `--styles html` um. Dann bleiben Farbe, Hintergrund, Schriftgröße,
Schriftart, Kapitälchen und zentrierte oder rechtsbündige Absätze als HTML erhalten. Das gilt nur dort, wo
sie vom normalen Text abweichen. Fremdsprachige Passagen werden als solche gekennzeichnet. GitHub zeigt
diese Angaben nicht an.
### 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:
@ -461,6 +476,7 @@ python3 epub2md_merge.py --help
| `--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 | | `--boxes callout/quote/html/plain` | Darstellung von Infokästen |
| `--styles semantic/html` | Farben, Schriften, Größen, Ausrichtung und Sprache als HTML übernehmen (`html`) |
| `--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 |

View file

@ -98,6 +98,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json
| `--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 | | `--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 |
| `--styles {semantic,html}` | `semantic` | `semantic`: nur bedeutungstragende Gestaltung (Hintergrundmarkierung → `<mark>`). `html`: zusätzlich Farbe, Hintergrund, Schriftgröße, Schriftart, Kapitälchen, Ausrichtung als `<span style>`/`<div style>` und fremdsprachige Passagen als `<span lang>`, jeweils nur, wo sie vom normalen Text des Buches abweichen. Obsidian, VS Code und Pandoc (HTML/PDF/EPUB) zeigen das an, GitHub nicht |
| `--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`) |
@ -244,6 +245,10 @@ 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*`).
- **Farben und Schriften:** Markdown kennt keine Farben, Schriftarten oder -größen. Mit `--styles html`
bleiben sie als HTML-Stil erhalten. Übernommen wird nur, was vom Grundstil abweicht (häufigste Werte aller
Absätze; Schriftgröße erst ab ±15 %). Abgesetzte Formeln und Bildabsätze bekommen keine Ausrichtung. Werte
mit `url(...)`, `expression` usw. werden verworfen.
- **Infokästen:** - **Infokästen:**
- **Erkennung:** `aside`/`epub:type` (sidebar, tip, warning …), Klassennamen (box, kasten, tipp, hinweis, - **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. warnung, achtung, wichtig, merke, exkurs …) oder ein CSS-Rahmen bzw. -Hintergrund um kurzen Fließtext.

View file

@ -89,6 +89,10 @@ def build_parser() -> argparse.ArgumentParser:
help="Infokästen (Tipp, Hinweis, Warnung, Exkurs …): callout = '> [!TIP]' (GitHub/Obsidian), " help="Infokästen (Tipp, Hinweis, Warnung, Exkurs …): callout = '> [!TIP]' (GitHub/Obsidian), "
"quote = Zitatblock mit fettem Titel, html = <div class=\"box …\">, plain = normaler Text " "quote = Zitatblock mit fettem Titel, html = <div class=\"box …\">, plain = normaler Text "
"(Standard: callout)") "(Standard: callout)")
p.add_argument("--styles", choices=["semantic", "html"], default="semantic",
help="semantic: nur Bedeutung übernehmen (Hintergrundmarkierung → <mark>); html: zusätzlich Farbe, "
"Hintergrund, Schriftgröße/-art, Kapitälchen, Ausrichtung und Fremdsprache als <span style>/"
"<span lang> (wo sie vom normalen Text abweichen; GitHub zeigt das nicht an) (Standard: semantic)")
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)")
@ -132,6 +136,7 @@ def main(argv: list[str] | None = None) -> int:
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, boxes=args.boxes,
styles=args.styles,
strict=args.strict, strict=args.strict,
verbose=args.verbose, verbose=args.verbose,
dry_run=args.dry_run, dry_run=args.dry_run,

View file

@ -192,3 +192,95 @@ def emphasis(style: dict[str, str]) -> dict[str, bool]:
"mono": bool(MONOSPACE.search(style.get("font-family", ""))), "mono": bool(MONOSPACE.search(style.get("font-family", ""))),
"hidden": style.get("display", "").lower() == "none", "hidden": style.get("display", "").lower() == "none",
} }
# ---------------------------------------------------------------------------- visual styles (--styles html)
STYLE_PROPS = ("color", "background-color", "font-size", "font-family", "font-variant", "text-align")
UNSAFE_VALUE = re.compile(r"url\(|expression|javascript|[;<>{}\\]", re.I)
TRIVIAL_BG = re.compile(r"^(transparent|none|inherit|initial|unset|white|#fff|#ffffff|rgba?\(\s*255\s*,\s*255\s*,\s*255.*)$")
COLOR_TOKEN = re.compile(r"(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|\b[a-z]+\b)", re.I)
KEYWORD_SIZES = {"xx-small": 0.6, "x-small": 0.75, "small": 0.89, "medium": 1.0, "large": 1.2, "x-large": 1.5,
"xx-large": 2.0, "smaller": 0.83, "larger": 1.2}
def _font_size_em(value: str) -> float | None:
v = value.strip().lower()
if v in KEYWORD_SIZES:
return KEYWORD_SIZES[v]
m = re.fullmatch(r"([\d.]+)\s*(em|rem|%|px|pt)?", v)
if not m:
return None
n = float(m.group(1))
return {"em": n, "rem": n, "%": n / 100, "px": n / 16, "pt": n / 12, None: None}.get(m.group(2), None)
def _background_color(style: dict[str, str]) -> str:
bg = style.get("background-color", "")
if not bg and style.get("background"):
for tok in COLOR_TOKEN.findall(style["background"]):
if tok.lower() not in ("none", "repeat", "no", "fixed", "scroll", "top", "left", "center", "right",
"bottom", "url"):
bg = tok
break
bg = bg.strip()
return "" if not bg or TRIVIAL_BG.match(bg.lower()) else bg
def _family(value: str) -> str:
parts = [p.strip().strip("'\"") for p in value.split(",") if p.strip()]
return ", ".join(f"'{p}'" if " " in p else p for p in parts)
def _is_black(value: str) -> bool:
return value.strip().lower() in ("black", "#000", "#000000", "rgb(0,0,0)", "rgb(0, 0, 0)", "windowtext")
def has_highlight(style: dict[str, str]) -> bool:
return bool(_background_color(style))
def base_style(stylesheet: StyleSheet, paragraphs: list[Any]) -> dict[str, str]:
"""Most frequent value per visual property over the book's paragraphs (the 'normal' text style)."""
counters: dict[str, dict[str, int]] = {p: {} for p in STYLE_PROPS}
for el in paragraphs:
style = stylesheet.computed(el)
for prop in STYLE_PROPS:
value = _background_color(style) if prop == "background-color" else style.get(prop, "")
counters[prop][value] = counters[prop].get(value, 0) + 1
return {p: max(c, key=lambda v: c[v]) for p, c in counters.items() if c}
def style_props(style: dict[str, str], base: dict[str, str], block: bool = False) -> list[tuple[str, str]]:
"""Visual properties of ``style`` that differ from the book's base text style (sanitized)."""
out: list[tuple[str, str]] = []
for prop in STYLE_PROPS:
if prop == "text-align" and not block:
continue
value = _background_color(style) if prop == "background-color" else style.get(prop, "").strip()
if not value or UNSAFE_VALUE.search(value) or value.lower() in ("inherit", "initial", "unset", "currentcolor"):
continue
reference = base.get(prop, "")
if prop == "color" and not reference:
reference = "black" # no colour declared = default text colour
if prop == "color" and _is_black(value) and _is_black(reference):
continue
if prop == "font-size":
size, ref = _font_size_em(value), _font_size_em(reference) if reference else 1.0
if size is None or ref is None or abs(size / (ref or 1.0) - 1) < 0.15:
continue
elif prop == "font-family":
if _family(value).lower() == _family(reference).lower():
continue
value = _family(value)
elif prop == "font-variant":
if "small-caps" not in value.lower() or "small-caps" in reference.lower():
continue
value = "small-caps"
elif prop == "text-align":
if value.lower() not in ("center", "right", "end") or value.lower() == reference.lower():
continue
elif value.lower() == reference.lower():
continue
out.append((prop, value))
return out

View file

@ -18,7 +18,7 @@ from urllib.parse import unquote
from lxml import etree from lxml import etree
from .chapters import IdCarrier, Marker, heading_level from .chapters import IdCarrier, Marker, heading_level
from .css import StyleSheet, emphasis, inline_style from .css import StyleSheet, emphasis, has_highlight, inline_style, style_props
from .codeblocks import ( from .codeblocks import (
code_text, code_text,
detect_language, detect_language,
@ -99,6 +99,7 @@ class RenderOptions:
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} boxes: str = "callout" # --boxes {callout,quote,html,plain}
styles: str = "semantic" # --styles {semantic,html}
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
@ -228,6 +229,10 @@ BOLD_TAGS = {"strong", "b"}
CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"} CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"}
def _style_attr(props: list[tuple[str, str]]) -> str:
return html.escape("; ".join(f"{k}: {v}" for k, v in props), quote=False).replace('"', "'")
def _wrap_text(text: str, marker: str) -> str: def _wrap_text(text: str, marker: str) -> str:
"""Wrap a finished paragraph in emphasis markers (whitespace stays outside).""" """Wrap a finished paragraph in emphasis markers (whitespace stays outside)."""
core = text.strip() core = text.strip()
@ -288,7 +293,8 @@ class Renderer:
def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry, def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry,
notes: NoteIndex, diag: Diagnostics, zip_names: set[str], notes: NoteIndex, diag: Diagnostics, zip_names: set[str],
entry_problem: Callable[[str], str | None] | None = None, entry_problem: Callable[[str], str | None] | None = None,
stylesheet: StyleSheet | None = None) -> None: stylesheet: StyleSheet | None = None, base_style: dict[str, str] | None = None) -> None:
self.base_style = base_style or {}
self.entry_problem = entry_problem or (lambda path: None) self.entry_problem = entry_problem or (lambda path: None)
self.css = stylesheet self.css = stylesheet
self.scene_breaks = 0 self.scene_breaks = 0
@ -684,8 +690,9 @@ class Renderer:
if flag: if flag:
blocks[0] = _wrap_text(blocks[0], marker) blocks[0] = _wrap_text(blocks[0], marker)
self.css_applied[key] += 1 self.css_applied[key] += 1
blocks = self._block_styles(el, blocks)
if anchor: if anchor:
if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "> ", "- ", "$$", "\\[", "* * *")) \ if blocks and not blocks[0].startswith(("#", "`", "|", "<table", "<div", "> ", "- ", "$$", "\\[", "* * *")) \
and not re.match(r"\d+\. ", blocks[0]): and not re.match(r"\d+\. ", blocks[0]):
blocks[0] = anchor + blocks[0] blocks[0] = anchor + blocks[0]
else: else:
@ -1057,14 +1064,61 @@ class Renderer:
return True return True
def inline(self, el: Any) -> list[Piece]: def inline(self, el: Any) -> list[Piece]:
"""Inline rendering plus formatting that only the stylesheet expresses (P1).""" """Inline rendering plus formatting that only the stylesheet expresses (P1, P3, P7)."""
name = local(el.tag) name = local(el.tag)
if not self.opt.css_emphasis or name in CSS_SKIP_TAGS: if name in CSS_SKIP_TAGS:
return self._inline_core(el) return self._inline_core(el)
if self._hidden(el): if self._hidden(el):
return [] return []
if self.in_heading or is_inline_code(el) or not text_content(el).strip(): if self.in_heading or is_inline_code(el) or not text_content(el).strip():
return self._inline_core(el) return self._inline_core(el)
pieces = self._inline_emphasis(el) if self.opt.css_emphasis else self._inline_core(el)
return self._inline_styles(el, pieces)
def _inline_styles(self, el: Any, pieces: list[Piece]) -> list[Piece]:
"""--styles semantic: background highlight → <mark>; --styles html: deviating visual properties and
foreign-language spans as <span style>/<span lang>."""
if self.css is None or (pieces and pieces[0][0] == "r" and pieces[0][1].startswith("`")):
return pieces # no stylesheet, or rendered as inline code
style = self.css.computed(el)
if self.opt.styles == "html":
props = style_props(style, self.base_style)
if props:
pieces = self._wrap(pieces, f'<span style="{_style_attr(props)}">', "</span>")
self.css_applied["style"] += 1
lang = self._foreign_lang(el)
if lang:
pieces = self._wrap(pieces, f'<span lang="{html.escape(lang)}">', "</span>")
self.css_applied["lang"] += 1
elif has_highlight(style):
pieces = self._wrap(pieces, "<mark>", "</mark>")
self.css_applied["mark"] += 1
return pieces
def _foreign_lang(self, el: Any) -> str:
lang = (el.get("lang") or el.get("{http://www.w3.org/XML/1998/namespace}lang") or "").strip()
if lang and re.fullmatch(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*", lang) \
and lang.split("-")[0].lower() != self.opt.language.split("-")[0].lower():
return lang
return ""
def _block_styles(self, el: Any, blocks: list[str]) -> list[str]:
"""--styles html: paragraph alignment, colour, size, font and language as a wrapping <div>."""
if self.opt.styles != "html" or self.css is None or self.in_table or not blocks or local(el.tag) not in ("p", "div") \
or any(local(d.tag) in BLOCK_TAGS for d in iter_elements(el) if d is not el):
return blocks
if all(b.startswith(("$$", "\\[", "![", "<img", "<math", "<a id")) for b in blocks):
return blocks # display formulas and image paragraphs are set apart anyway
props = style_props(self.css.computed(el), self.base_style, block=True)
lang = self._foreign_lang(el)
if not props and not lang:
return blocks
attrs = (f' style="{_style_attr(props)}"' if props else "") + (f' lang="{html.escape(lang)}"' if lang else "")
self.css_applied["style" if props else "lang"] += 1
return [f"<div{attrs}>"] + blocks + ["</div>"]
def _inline_emphasis(self, el: Any) -> list[Piece]:
name = local(el.tag)
e = self._emphasis(el) e = self._emphasis(el)
if e["mono"] and name != "a" and re.fullmatch(r"\S+", norm_ws(text_content(el))) \ if e["mono"] and name != "a" and re.fullmatch(r"\S+", norm_ws(text_content(el))) \
and not any(local(d.tag) in ("img", "svg", "math", "a") for d in iter_elements(el) if d is not el): and not any(local(d.tag) in ("img", "svg", "math", "a") for d in iter_elements(el) if d is not el):
@ -1577,6 +1631,15 @@ class Renderer:
if flag: if flag:
content = f"<{tag}>{content}</{tag}>" content = f"<{tag}>{content}</{tag}>"
self.css_applied[key] += 1 self.css_applied[key] += 1
if self.css is not None and content.strip():
style = self.css.computed(el)
props = style_props(style, self.base_style) if self.opt.styles == "html" else []
if props:
content = f'<span style="{_style_attr(props)}">{content}</span>'
self.css_applied["style"] += 1
elif self.opt.styles != "html" and has_highlight(style):
content = f"<mark>{content}</mark>"
self.css_applied["mark"] += 1
return anchor + content return anchor + content
attrs = [] attrs = []
for k, v in el.attrib.items(): for k, v in el.attrib.items():

View file

@ -16,7 +16,7 @@ from typing import Any
from . import TOOL_NAME, __version__ from . import TOOL_NAME, __version__
from .chapters import ChapterBuilder from .chapters import ChapterBuilder
from .diagnostics import Diagnostics from .diagnostics import Diagnostics
from .css import StyleSheet from .css import StyleSheet, base_style
from .epub_reader import EpubReader, decode_markup, parse_xml from .epub_reader import EpubReader, decode_markup, parse_xml
from .errors import ContainerError, InputError, StructureError from .errors import ContainerError, InputError, StructureError
from .footnotes import collect_notes from .footnotes import collect_notes
@ -26,7 +26,7 @@ from .media import MediaRegistry
from .models import Chapter from .models import Chapter
from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail
from .references import LinkResolver from .references import LinkResolver
from .utils import is_inside, iter_elements, local, sha256_file, slugify, text_content from .utils import is_element, is_inside, iter_elements, local, sha256_file, slugify, text_content
from . import writer from . import writer
MARKER_FILES = ("conversion-report.json", "manifest.json") MARKER_FILES = ("conversion-report.json", "manifest.json")
@ -51,6 +51,7 @@ class Options:
keep_invisible_chars: bool = False keep_invisible_chars: bool = False
css_emphasis: bool = True css_emphasis: bool = True
boxes: str = "callout" boxes: str = "callout"
styles: str = "semantic"
strict: bool = False strict: bool = False
verbose: bool = False verbose: bool = False
dry_run: bool = False dry_run: bool = False
@ -256,8 +257,10 @@ def _render(reader: EpubReader, package: Any, chapters: list[Chapter], resolver:
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), boxes=opts.boxes, styles=opts.styles),
resolver, media, notes, diag, set(reader.names), reader.entry_problem, stylesheet, resolver, media, notes, diag, set(reader.names), reader.entry_problem, stylesheet,
base_style(stylesheet, [p for c in chapters for _, it in c.items if is_element(it)
for p in iter_elements(it) if local(p.tag) == "p"][:20000]) if stylesheet else None,
) )
width = max(3, len(str(len(chapters) + 1))) width = max(3, len(str(len(chapters) + 1)))
notes_title = "Anmerkungen" if language.startswith("de") else "Notes" notes_title = "Anmerkungen" if language.startswith("de") else "Notes"

View file

@ -127,3 +127,38 @@ def test_p2_box_modes(convert):
assert '<div class="box box-tip">\n\n**Hinweis zum Pinsel**\n\nSauber halten.\n\n</div>' in htm 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") 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] assert "## Hinweis zum Pinsel" in plain and "Sauber halten." in plain and ">" not in plain.split("---")[-1]
STYLE_CSS = b"""p { font-family: "Aldus", serif; font-size: 0.9em; color: #000 }
.rot { color: #942f6a } .gelb { background-color: #ffff00 } .gross { font-size: 1.5em }
.kap { font-variant: small-caps } p.mitte { text-align: center } .script { font-family: "Mulled Wine", cursive }"""
def styles_book(body: str, *args: str, convert=None):
return convert(Book(docs=[Doc("c.xhtml", body)], media={"s.css": STYLE_CSS}), *args)
BODY_STYLES = ('<h1>S</h1><p>Normal <span class="rot">rot</span>, <span class="gelb">markiert</span>, '
'<span class="gross">groß</span>, <span class="kap">Kapitälchen</span>, <span class="script">Zierschrift</span>, '
'<span lang="en">English words</span> und <span lang="de">deutsch</span>.</p>'
'<p>Zweiter Absatz.</p><p>Dritter Absatz.</p><p class="mitte">Widmung</p>')
def test_p3_semantic_styles_default(convert):
text = styles_book(BODY_STYLES, convert=convert).chapter("001")
assert "Normal rot, <mark>markiert</mark>, groß, Kapitälchen, Zierschrift, English words und deutsch." in text
assert "style=" not in text and "lang=" not in text
def test_p3_p7_html_styles(convert):
run = styles_book(BODY_STYLES, "--styles", "html", convert=convert)
text = run.chapter("001")
assert '<span style="color: #942f6a">rot</span>' in text
assert '<span style="background-color: #ffff00">markiert</span>' in text
assert '<span style="font-size: 1.5em">groß</span>' in text
assert '<span style="font-variant: small-caps">Kapitälchen</span>' in text
assert "<span style=\"font-family: 'Mulled Wine', cursive\">Zierschrift</span>" in text
assert '<span lang="en">English words</span> und deutsch.' in text
assert '<div style="text-align: center">\n\nWidmung\n\n</div>' in text
assert "Zweiter Absatz." in text and "Aldus" not in text # base paragraph style is not repeated
assert run.report["css"]["applied"]["style"] >= 5