""" Utilities for interpreting CSS from Stylers for formatting non-HTML outputs. """ import re from typing import Dict, Optional import warnings class CSSWarning(UserWarning): """ This CSS syntax cannot currently be parsed. """ def _side_expander(prop_fmt: str): def expand(self, prop, value: str): tokens = value.split() try: mapping = self.SIDE_SHORTHANDS[len(tokens)] except KeyError: warnings.warn(f'Could not expand "{prop}: {value}"', CSSWarning) return for key, idx in zip(self.SIDES, mapping): yield prop_fmt.format(key), tokens[idx] return expand class CSSResolver: """ A callable for parsing and resolving CSS to atomic properties. """ UNIT_RATIOS = { "rem": ("pt", 12), "ex": ("em", 0.5), # 'ch': "px": ("pt", 0.75), "pc": ("pt", 12), "in": ("pt", 72), "cm": ("in", 1 / 2.54), "mm": ("in", 1 / 25.4), "q": ("mm", 0.25), "!!default": ("em", 0), } FONT_SIZE_RATIOS = UNIT_RATIOS.copy() FONT_SIZE_RATIOS.update( { "%": ("em", 0.01), "xx-small": ("rem", 0.5), "x-small": ("rem", 0.625), "small": ("rem", 0.8), "medium": ("rem", 1), "large": ("rem", 1.125), "x-large": ("rem", 1.5), "xx-large": ("rem", 2), "smaller": ("em", 1 / 1.2), "larger": ("em", 1.2), "!!default": ("em", 1), } ) MARGIN_RATIOS = UNIT_RATIOS.copy() MARGIN_RATIOS.update({"none": ("pt", 0)}) BORDER_WIDTH_RATIOS = UNIT_RATIOS.copy() BORDER_WIDTH_RATIOS.update( { "none": ("pt", 0), "thick": ("px", 4), "medium": ("px", 2), "thin": ("px", 1), # Default: medium only if solid } ) SIDE_SHORTHANDS = { 1: [0, 0, 0, 0], 2: [0, 1, 0, 1], 3: [0, 1, 2, 1], 4: [0, 1, 2, 3], } SIDES = ("top", "right", "bottom", "left") def __call__( self, declarations_str: str, inherited: Optional[Dict[str, str]] = None, ) -> Dict[str, str]: """ The given declarations to atomic properties. Parameters ---------- declarations_str : str A list of CSS declarations inherited : dict, optional Atomic properties indicating the inherited style context in which declarations_str is to be resolved. ``inherited`` should already be resolved, i.e. valid output of this method. Returns ------- dict Atomic CSS 2.2 properties. Examples -------- >>> resolve = CSSResolver() >>> inherited = {'font-family': 'serif', 'font-weight': 'bold'} >>> out = resolve(''' ... border-color: BLUE RED; ... font-size: 1em; ... font-size: 2em; ... font-weight: normal; ... font-weight: inherit; ... ''', inherited) >>> sorted(out.items()) # doctest: +NORMALIZE_WHITESPACE [('border-bottom-color', 'blue'), ('border-left-color', 'red'), ('border-right-color', 'red'), ('border-top-color', 'blue'), ('font-family', 'serif'), ('font-size', '24pt'), ('font-weight', 'bold')] """ props = dict(self.atomize(self.parse(declarations_str))) if inherited is None: inherited = {} props = self._update_initial(props, inherited) props = self._update_font_size(props, inherited) return self._update_other_units(props) def _update_initial( self, props: Dict[str, str], inherited: Dict[str, str], ) -> Dict[str, str]: # 1. resolve inherited, initial for prop, val in inherited.items(): if prop not in props: props[prop] = val new_props = props.copy() for prop, val in props.items(): if val == "inherit": val = inherited.get(prop, "initial") if val in ("initial", None): # we do not define a complete initial stylesheet del new_props[prop] else: new_props[prop] = val return new_props def _update_font_size( self, props: Dict[str, str], inherited: Dict[str, str], ) -> Dict[str, str]: # 2. resolve relative font size if props.get("font-size"): props["font-size"] = self.size_to_pt( props["font-size"], self._get_font_size(inherited), conversions=self.FONT_SIZE_RATIOS, ) return props def _get_font_size(self, props: Dict[str, str]) -> Optional[float]: if props.get("font-size"): font_size_string = props["font-size"] return self._get_float_font_size_from_pt(font_size_string) return None def _get_float_font_size_from_pt(self, font_size_string: str) -> float: assert font_size_string.endswith("pt") return float(font_size_string.rstrip("pt")) def _update_other_units(self, props: Dict[str, str]) -> Dict[str, str]: font_size = self._get_font_size(props) # 3. TODO: resolve other font-relative units for side in self.SIDES: prop = f"border-{side}-width" if prop in props: props[prop] = self.size_to_pt( props[prop], em_pt=font_size, conversions=self.BORDER_WIDTH_RATIOS, ) for prop in [f"margin-{side}", f"padding-{side}"]: if prop in props: # TODO: support % props[prop] = self.size_to_pt( props[prop], em_pt=font_size, conversions=self.MARGIN_RATIOS, ) return props def size_to_pt(self, in_val, em_pt=None, conversions=UNIT_RATIOS): def _error(): warnings.warn(f"Unhandled size: {repr(in_val)}", CSSWarning) return self.size_to_pt("1!!default", conversions=conversions) match = re.match(r"^(\S*?)([a-zA-Z%!].*)", in_val) if match is None: return _error() val, unit = match.groups() if val == "": # hack for 'large' etc. val = 1 else: try: val = float(val) except ValueError: return _error() while unit != "pt": if unit == "em": if em_pt is None: unit = "rem" else: val *= em_pt unit = "pt" continue try: unit, mul = conversions[unit] except KeyError: return _error() val *= mul val = round(val, 5) if int(val) == val: size_fmt = f"{int(val):d}pt" else: size_fmt = f"{val:f}pt" return size_fmt def atomize(self, declarations): for prop, value in declarations: attr = "expand_" + prop.replace("-", "_") try: expand = getattr(self, attr) except AttributeError: yield prop, value else: for prop, value in expand(prop, value): yield prop, value expand_border_color = _side_expander("border-{:s}-color") expand_border_style = _side_expander("border-{:s}-style") expand_border_width = _side_expander("border-{:s}-width") expand_margin = _side_expander("margin-{:s}") expand_padding = _side_expander("padding-{:s}") def parse(self, declarations_str: str): """ Generates (prop, value) pairs from declarations. In a future version may generate parsed tokens from tinycss/tinycss2 Parameters ---------- declarations_str : str """ for decl in declarations_str.split(";"): if not decl.strip(): continue prop, sep, val = decl.partition(":") prop = prop.strip().lower() # TODO: don't lowercase case sensitive parts of values (strings) val = val.strip().lower() if sep: yield prop, val else: warnings.warn( f"Ill-formatted attribute: expected a colon in {repr(decl)}", CSSWarning, )