251 lines
7.8 KiB
Python
251 lines
7.8 KiB
Python
|
"""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)
|