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}" 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