"""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 class CSSResolver(object): """A callable for parsing and resolving CSS to atomic properties """ INITIAL_STYLE = { } 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 ------- props : 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 = self.INITIAL_STYLE.get(prop) 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 = 'border-{side}-width'.format(side=side) if prop in props: props[prop] = self.size_to_pt( props[prop], em_pt=font_size, conversions=self.BORDER_WIDTH_RATIOS) for prop in ['margin-{side}'.format(side=side), 'padding-{side}'.format(side=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', .5), # 'ch': 'px': ('pt', .75), 'pc': ('pt', 12), 'in': ('pt', 72), 'cm': ('in', 1 / 2.54), 'mm': ('in', 1 / 25.4), 'q': ('mm', .25), '!!default': ('em', 0), } FONT_SIZE_RATIOS = UNIT_RATIOS.copy() FONT_SIZE_RATIOS.update({ '%': ('em', .01), 'xx-small': ('rem', .5), 'x-small': ('rem', .625), 'small': ('rem', .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('Unhandled size: {val!r}'.format(val=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 = '{fmt:d}pt'.format(fmt=int(val)) else: size_fmt = '{fmt:f}pt'.format(fmt=val) 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') def _side_expander(prop_fmt): def expand(self, prop, value): tokens = value.split() try: mapping = self.SIDE_SHORTHANDS[len(tokens)] except KeyError: warnings.warn('Could not expand "{prop}: {val}"' .format(prop=prop, val=value), CSSWarning) return for key, idx in zip(self.SIDES, mapping): yield prop_fmt.format(key), tokens[idx] return expand 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): """Generates (prop, value) pairs from declarations In a future version may generate parsed tokens from tinycss/tinycss2 """ 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('Ill-formatted attribute: expected a colon ' 'in {decl!r}'.format(decl=decl), CSSWarning)