diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index b7a473a..6b942ec 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -435,6 +435,7 @@ python3 epub2md_merge.py --help | `--code-language SPRACHE` | Sprache für Codeblöcke ohne erkannte Sprache | | `--keep-wrap-markers` | Druck-Umbruchzeichen `↪` im Code stehen lassen | | `--keep-invisible-chars` | unsichtbare Zeichen (Nullbreiten-Leerzeichen, weiche Trennstriche) behalten | +| `--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 | | `--format gfm/commonmark` | Markdown-Variante; `commonmark` für sehr strikte Programme | diff --git a/README.md b/README.md index caf194f..92d572f 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ python3 epub2md.py buch.epub output/buch --force --report berichte/buch.json | `--slug-language {de,en,none}` | `de` | Dateinamen: `de` ä→ae, ß→ss; `en` ä→a, ß→ss; `none` Unicode bleibt | | `--code-language SPRACHE` | – | Sprachlabel für Codeblöcke ohne erkannte Sprache (z. B. `python`, `c`; Aliase wie `py` erlaubt). Erkannte Labels haben Vorrang; im Bericht `detected_by: "option"` | | `--keep-wrap-markers` | aus | Druck-Umbruchzeichen `↪`/`➥` in Codeblöcken stehen lassen. Standard: Fortsetzungszeilen werden wieder zur ursprünglichen Codezeile zusammengefügt (Anzahl im Bericht: `wrap_lines_joined`) | +| `--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`) | | `--strict` | aus | Abbruch bei kritischen Fehlern (fehlende/unlesbare Spine-Dokumente, unsichere ZIP-Pfade, ungültige Spine-Verweise) | @@ -238,6 +239,10 @@ weil CommonMark keine `[^…]`-Syntax kennt. (`#include`, `def …:`, Shebang, …). Ist sie unsicher, bleibt das Label leer. Bei einsprachigen Büchern kann `--code-language python` (o. Ä.) alle übrigen Blöcke kennzeichnen. Achtung: Das betrifft auch Programmausgaben und Shell-Befehle im Buch. +- **CSS-Formatierung:** Viele Verlage setzen Hervorhebungen nur über Stylesheets. epub2md wertet die + 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*`). - **Gescannte Texte:** nur Bilder, kein Text (keine OCR). - **DRM:** wird erkannt und abgelehnt. - **Register:** Buchstabengruppen werden zu `##`-Überschriften, Untereinträge erhalten `– `. Interne diff --git a/epub2md/cli.py b/epub2md/cli.py index 4f40c58..0555128 100644 --- a/epub2md/cli.py +++ b/epub2md/cli.py @@ -85,6 +85,9 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--keep-wrap-markers", action="store_true", help="Druck-Umbruchzeichen (↪/➥) in Codeblöcken nicht entfernen; Standard: fortgesetzte " "Zeilen werden wieder zur ursprünglichen Codezeile zusammengefügt") + p.add_argument("--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)") p.add_argument("--keep-invisible-chars", action="store_true", help="Nullbreiten-Zeichen und weiche Trennstriche (U+00AD) im Fließtext behalten; Standard: entfernen " "(stören Suche und Weiterverarbeitung). Code bleibt immer unverändert") @@ -123,6 +126,7 @@ def main(argv: list[str] | None = None) -> int: code_language=args.code_language or "", keep_wrap_markers=args.keep_wrap_markers, keep_invisible_chars=args.keep_invisible_chars, + css_emphasis=args.css_emphasis, strict=args.strict, verbose=args.verbose, dry_run=args.dry_run, diff --git a/epub2md/css.py b/epub2md/css.py new file mode 100644 index 0000000..528afa4 --- /dev/null +++ b/epub2md/css.py @@ -0,0 +1,194 @@ +"""Minimal CSS evaluation for formatting that publishers express only through stylesheets. + +Supports type/class/id selectors, descendant (` `) and child (`>`) combinators, specificity and source +order, and `style` attributes. Selectors with pseudo-classes, attribute selectors or sibling combinators +are ignored (conservative: an unknown rule never adds formatting). @-blocks (@media, @font-face, @page …) +are skipped. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from .utils import classes, local + +COMPOUND = re.compile(r"^(\*|[a-zA-Z][\w-]*)?((?:[.#][\w-]+)*)$") +MONOSPACE = re.compile(r"monospace|courier|consolas|menlo|monaco|lucida console|source code|dejavu sans mono|" + r"liberation mono|fira mono|fira code|inconsolata|andale mono|\bmono\b", re.I) + + +@dataclass +class _Compound: + tag: str | None + classes: frozenset[str] + ids: frozenset[str] + + def matches(self, el: Any) -> bool: + if self.tag and self.tag != "*" and local(el.tag) != self.tag: + return False + if self.ids and (el.get("id") or "").lower() not in self.ids: + return False + return not self.classes or self.classes <= set(classes(el)) + + +@dataclass +class _Rule: + parts: list[tuple[str, _Compound]] # (combinator to the previous part, compound); first combinator is "" + specificity: tuple[int, int, int] + order: int + decls: dict[str, str] + + def matches(self, el: Any) -> bool: + if not self.parts[-1][1].matches(el): + return False + node = el + for i in range(len(self.parts) - 1, 0, -1): + comb = self.parts[i][0] + compound = self.parts[i - 1][1] + node = node.getparent() + if comb == ">": + if node is None or not compound.matches(node): + return False + else: # descendant + while node is not None and not compound.matches(node): + node = node.getparent() + if node is None: + return False + return True + + +def _parse_declarations(text: str) -> dict[str, str]: + decls: dict[str, str] = {} + for part in text.split(";"): + if ":" not in part: + continue + prop, value = part.split(":", 1) + prop = prop.strip().lower() + value = re.sub(r"\s*!important\s*$", "", value.strip(), flags=re.I).strip() + if prop and value: + decls[prop] = value + return decls + + +def _parse_selector(sel: str) -> list[tuple[str, _Compound]] | None: + sel = sel.strip().lower() + if not sel or re.search(r"[:\[\]+~]", sel): + return None + tokens = re.split(r"\s*(>)\s*|\s+", sel) + parts: list[tuple[str, _Compound]] = [] + comb = "" + for tok in tokens: + if tok is None or tok == "": + continue + if tok == ">": + comb = ">" + continue + m = COMPOUND.match(tok) + if not m: + return None + rest = m.group(2) or "" + parts.append((comb if parts else "", _Compound( + tag=m.group(1), + classes=frozenset(re.findall(r"\.([\w-]+)", rest)), + ids=frozenset(re.findall(r"#([\w-]+)", rest)), + ))) + comb = " " + return parts or None + + +def _strip_at_blocks(css: str) -> str: + """Remove @-rules including nested blocks (@media, @font-face, @supports, @page …).""" + out: list[str] = [] + i = 0 + while i < len(css): + if css[i] == "@": + brace = css.find("{", i) + semi = css.find(";", i) + if semi != -1 and (brace == -1 or semi < brace): # @import/@charset …; + i = semi + 1 + continue + if brace == -1: + break + depth, j = 0, brace + while j < len(css): + if css[j] == "{": + depth += 1 + elif css[j] == "}": + depth -= 1 + if depth == 0: + break + j += 1 + i = j + 1 + continue + out.append(css[i]) + i += 1 + return "".join(out) + + +class StyleSheet: + def __init__(self, css_texts: list[str]) -> None: + self.rules: list[_Rule] = [] + self._index: dict[str, list[_Rule]] = {} + self._cache: dict[Any, dict[str, str]] = {} + order = 0 + for css in css_texts: + css = re.sub(r"/\*.*?\*/", "", css, flags=re.S) + css = _strip_at_blocks(css) + for selectors, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css): + decls = _parse_declarations(body) + if not decls: + continue + for sel in selectors.split(","): + parts = _parse_selector(sel) + if not parts: + continue + spec = (sum(len(c.ids) for _, c in parts), sum(len(c.classes) for _, c in parts), + sum(1 for _, c in parts if c.tag and c.tag != "*")) + rule = _Rule(parts, spec, order, decls) + order += 1 + self.rules.append(rule) + last = parts[-1][1] + keys = [f".{c}" for c in last.classes] or ([last.tag] if last.tag and last.tag != "*" else ["*"]) + for key in keys[:1]: # one index entry suffices: every key must match anyway + self._index.setdefault(key, []).append(rule) + + def computed(self, el: Any) -> dict[str, str]: + """Declared properties for ``el`` (no inheritance), including its ``style`` attribute.""" + cached = self._cache.get(el) + if cached is not None: + return cached + candidates: list[_Rule] = [] + for key in [f".{c}" for c in classes(el)] + [local(el.tag), "*"]: + candidates.extend(self._index.get(key, ())) + result: dict[str, str] = {} + for rule in sorted({id(r): r for r in candidates}.values(), key=lambda r: (r.specificity, r.order)): + if rule.matches(el): + result.update(rule.decls) + if el.get("style"): + result.update(_parse_declarations(el.get("style"))) + self._cache[el] = result + return result + + +def inline_style(el: Any) -> dict[str, str]: + """Only the ``style`` attribute (used when no stylesheet is available).""" + return _parse_declarations(el.get("style") or "") + + +def emphasis(style: dict[str, str]) -> dict[str, bool]: + """Semantic formatting expressed by a computed style.""" + weight = style.get("font-weight", "").lower() + deco = (style.get("text-decoration-line", "") + " " + style.get("text-decoration", "")).lower() + valign = style.get("vertical-align", "").lower() + return { + "bold": weight in ("bold", "bolder") or (weight.isdigit() and int(weight) >= 600), + "italic": style.get("font-style", "").lower() in ("italic", "oblique"), + "underline": "underline" in deco, + "strike": "line-through" in deco, + "sup": valign == "super", + "sub": valign == "sub", + "mono": bool(MONOSPACE.search(style.get("font-family", ""))), + "hidden": style.get("display", "").lower() == "none", + } diff --git a/epub2md/html_converter.py b/epub2md/html_converter.py index d842b90..a053fb8 100644 --- a/epub2md/html_converter.py +++ b/epub2md/html_converter.py @@ -9,6 +9,7 @@ from __future__ import annotations import html import re +from collections import Counter from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Any @@ -17,6 +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 .codeblocks import ( code_text, detect_language, @@ -95,6 +97,7 @@ class RenderOptions: code_language: str = "" # --code-language: label for blocks without a detected language keep_wrap_markers: bool = False # --keep-wrap-markers: do not join ↪-continued code lines keep_invisible_chars: bool = False # --keep-invisible-chars: keep zero-width chars and soft hyphens + css_emphasis: bool = True # --no-css-emphasis: ignore bold/italic/… that only the stylesheet expresses def md_escape(text: str, table: bool = False) -> str: @@ -126,9 +129,23 @@ def escape_line_starts(text: str) -> str: return "\n".join(out) +EMPH_SEP = "\x1f" # separates open marker, content and close marker inside an ("e", …) piece + + def join_inline(pieces: list[Piece], hard_break: str = "\\\n") -> str: out = "" + last_e: tuple[int, str, str, str, int] | None = None # (start, open, core, close, end) of the last emphasis for kind, s in pieces: + if kind == "e": + open_, core, close = s.split(EMPH_SEP) + if last_e and last_e[4] == len(out) and last_e[1] == open_ and last_e[3] == close: + # directly adjacent identical emphasis (xy): merge, "*x**y*" would break + out = out[: last_e[0]] + core = last_e[2] + core + start = len(out) + out += open_ + core + close + last_e = (start, open_, core, close, len(out)) + continue if kind == "t": s = WS.sub(" ", s) if not out or out.endswith((" ", "\n")): @@ -178,6 +195,17 @@ def _collapse_blank_lines(text: str) -> str: return "\n".join(out) +ITALIC_TAGS = {"em", "i", "cite", "dfn", "var"} +BOLD_TAGS = {"strong", "b"} +CSS_SKIP_TAGS = {"br", "wbr", "img", "svg", "image", "math", "code", "samp", "tt", "kbd", "pre"} + + +def _wrap_text(text: str, marker: str) -> str: + """Wrap a finished paragraph in emphasis markers (whitespace stays outside).""" + core = text.strip() + return f"{marker}{core}{marker}" if core else text + + _BLOCK_SYNTAX = ("```", "~~~", "|", "