epub2md/epub2md/css.py
dschlueter 5cef9d7885 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>
2026-10-06 05:04:35 +02:00

286 lines
11 KiB
Python

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