--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>
286 lines
11 KiB
Python
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
|