""" Utilities for interpreting CSS from Stylers for formatting non-HTML outputs. """ import re import warnings class CSSWarning(UserWarning): """ This CSS syntax cannot currently be parsed. """ pass 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. """ def __call__(self, declarations_str, inherited=None): """ 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 = {} # 1. resolve inherited, initial for prop, val in inherited.items(): if prop not in props: props[prop] = val for prop, val in list(props.items()): if val == "inherit": val = inherited.get(prop, "initial") if val == "initial": val = None if val is None: # we do not define a complete initial stylesheet del props[prop] else: props[prop] = val # 2. resolve relative font size if props.get("font-size"): if "font-size" in inherited: em_pt = inherited["font-size"] assert em_pt[-2:] == "pt" em_pt = float(em_pt[:-2]) else: em_pt = None props["font-size"] = self.size_to_pt( props["font-size"], em_pt, conversions=self.FONT_SIZE_RATIOS ) font_size = float(props["font-size"][:-2]) else: font_size = None # 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 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 } ) 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) try: val, unit = re.match(r"^(\S*?)([a-zA-Z%!].*)", in_val).groups() except AttributeError: return _error() 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 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") 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, )