diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md
index 580ef3b..6e6f5ae 100644
--- a/BEDIENUNGSANLEITUNG.md
+++ b/BEDIENUNGSANLEITUNG.md
@@ -228,6 +228,21 @@ Andere Darstellungen wählst du mit `--boxes`:
- `html`: HTML-`
`
- `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 `
`-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
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-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten |
| `--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 |
| `--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 7817d19..9f074ef 100644
--- a/README.md
+++ b/README.md
@@ -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"` |
| `--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 |
+| `--styles {semantic,html}` | `semantic` | `semantic`: nur bedeutungstragende Gestaltung (Hintergrundmarkierung → `
`). `html`: zusätzlich Farbe, Hintergrund, Schriftgröße, Schriftart, Kapitälchen, Ausrichtung als ``/`` und fremdsprachige Passagen als `
`, 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` |
| `--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`) |
@@ -244,6 +245,10 @@ 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*`).
+- **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:**
- **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.
diff --git a/epub2md/cli.py b/epub2md/cli.py
index 09a8177..d66c4b8 100644
--- a/epub2md/cli.py
+++ b/epub2md/cli.py
@@ -89,6 +89,10 @@ def build_parser() -> argparse.ArgumentParser:
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("--styles", choices=["semantic", "html"], default="semantic",
+ help="semantic: nur Bedeutung übernehmen (Hintergrundmarkierung →
); html: zusätzlich Farbe, "
+ "Hintergrund, Schriftgröße/-art, Kapitälchen, Ausrichtung und Fremdsprache als /"
+ " (wo sie vom normalen Text abweichen; GitHub zeigt das nicht an) (Standard: semantic)")
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)")
@@ -132,6 +136,7 @@ def main(argv: list[str] | None = None) -> int:
keep_invisible_chars=args.keep_invisible_chars,
css_emphasis=args.css_emphasis,
boxes=args.boxes,
+ styles=args.styles,
strict=args.strict,
verbose=args.verbose,
dry_run=args.dry_run,
diff --git a/epub2md/css.py b/epub2md/css.py
index 528afa4..d385d28 100644
--- a/epub2md/css.py
+++ b/epub2md/css.py
@@ -192,3 +192,95 @@ def emphasis(style: dict[str, str]) -> dict[str, bool]:
"mono": bool(MONOSPACE.search(style.get("font-family", ""))),
"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
diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py
index ea72b80..25782ec 100644
--- a/epub2md/html_converter.py
+++ b/epub2md/html_converter.py
@@ -18,7 +18,7 @@ from urllib.parse import unquote
from lxml import etree
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 (
code_text,
detect_language,
@@ -99,6 +99,7 @@ class RenderOptions:
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}
+ styles: str = "semantic" # --styles {semantic,html}
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"}
+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:
"""Wrap a finished paragraph in emphasis markers (whitespace stays outside)."""
core = text.strip()
@@ -288,7 +293,8 @@ class Renderer:
def __init__(self, options: RenderOptions, resolver: LinkResolver, media: MediaRegistry,
notes: NoteIndex, diag: Diagnostics, zip_names: set[str],
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.css = stylesheet
self.scene_breaks = 0
@@ -684,8 +690,9 @@ class Renderer:
if flag:
blocks[0] = _wrap_text(blocks[0], marker)
self.css_applied[key] += 1
+ blocks = self._block_styles(el, blocks)
if anchor:
- if blocks and not blocks[0].startswith(("#", "`", "|", " ", "- ", "$$", "\\[", "* * *")) \
+ if blocks and not blocks[0].startswith(("#", "`", "|", " ", "- ", "$$", "\\[", "* * *")) \
and not re.match(r"\d+\. ", blocks[0]):
blocks[0] = anchor + blocks[0]
else:
@@ -1057,14 +1064,61 @@ class Renderer:
return True
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)
- if not self.opt.css_emphasis or name in CSS_SKIP_TAGS:
+ if name in CSS_SKIP_TAGS:
return self._inline_core(el)
if self._hidden(el):
return []
if self.in_heading or is_inline_code(el) or not text_content(el).strip():
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 → ; --styles html: deviating visual properties and
+ foreign-language spans as /."""
+ 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'', "")
+ self.css_applied["style"] += 1
+ lang = self._foreign_lang(el)
+ if lang:
+ pieces = self._wrap(pieces, f'', "")
+ self.css_applied["lang"] += 1
+ elif has_highlight(style):
+ pieces = self._wrap(pieces, "", "")
+ 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 ."""
+ 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(("$$", "\\[", "![", "
![]()
"] + blocks + ["
"]
+
+ def _inline_emphasis(self, el: Any) -> list[Piece]:
+ name = local(el.tag)
e = self._emphasis(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):
@@ -1577,6 +1631,15 @@ class Renderer:
if flag:
content = f"<{tag}>{content}{tag}>"
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'{content}'
+ self.css_applied["style"] += 1
+ elif self.opt.styles != "html" and has_highlight(style):
+ content = f"{content}"
+ self.css_applied["mark"] += 1
return anchor + content
attrs = []
for k, v in el.attrib.items():
diff --git a/epub2md/pipeline.py b/epub2md/pipeline.py
index f520fb0..551a61e 100644
--- a/epub2md/pipeline.py
+++ b/epub2md/pipeline.py
@@ -16,7 +16,7 @@ from typing import Any
from . import TOOL_NAME, __version__
from .chapters import ChapterBuilder
from .diagnostics import Diagnostics
-from .css import StyleSheet
+from .css import StyleSheet, base_style
from .epub_reader import EpubReader, decode_markup, parse_xml
from .errors import ContainerError, InputError, StructureError
from .footnotes import collect_notes
@@ -26,7 +26,7 @@ from .media import MediaRegistry
from .models import Chapter
from .normalize import Doc, book_uses_title_tables, is_html_toc_doc, make_doc, normalize_doc, remove_keep_tail
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
MARKER_FILES = ("conversion-report.json", "manifest.json")
@@ -51,6 +51,7 @@ class Options:
keep_invisible_chars: bool = False
css_emphasis: bool = True
boxes: str = "callout"
+ styles: str = "semantic"
strict: bool = False
verbose: 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,
code_language=opts.code_language, keep_wrap_markers=opts.keep_wrap_markers,
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,
+ 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)))
notes_title = "Anmerkungen" if language.startswith("de") else "Notes"
diff --git a/tests/test_styles.py b/tests/test_styles.py
index e41ac9d..ff23373 100644
--- a/tests/test_styles.py
+++ b/tests/test_styles.py
@@ -127,3 +127,38 @@ def test_p2_box_modes(convert):
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]
+
+
+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 = ('S
Normal rot, markiert, '
+ 'groß, Kapitälchen, Zierschrift, '
+ 'English words und deutsch.
'
+ 'Zweiter Absatz.
Dritter Absatz.
Widmung
')
+
+
+def test_p3_semantic_styles_default(convert):
+ text = styles_book(BODY_STYLES, convert=convert).chapter("001")
+ assert "Normal rot, markiert, 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 'rot' in text
+ assert 'markiert' in text
+ assert 'groß' in text
+ assert 'Kapitälchen' in text
+ assert "Zierschrift" in text
+ assert 'English words und deutsch.' in text
+ assert '\n\nWidmung\n\n
' 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