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