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