css.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. """Utilities for interpreting CSS from Stylers for formatting non-HTML outputs
  2. """
  3. import re
  4. import warnings
  5. class CSSWarning(UserWarning):
  6. """This CSS syntax cannot currently be parsed"""
  7. pass
  8. class CSSResolver(object):
  9. """A callable for parsing and resolving CSS to atomic properties
  10. """
  11. INITIAL_STYLE = {
  12. }
  13. def __call__(self, declarations_str, inherited=None):
  14. """ the given declarations to atomic properties
  15. Parameters
  16. ----------
  17. declarations_str : str
  18. A list of CSS declarations
  19. inherited : dict, optional
  20. Atomic properties indicating the inherited style context in which
  21. declarations_str is to be resolved. ``inherited`` should already
  22. be resolved, i.e. valid output of this method.
  23. Returns
  24. -------
  25. props : dict
  26. Atomic CSS 2.2 properties
  27. Examples
  28. --------
  29. >>> resolve = CSSResolver()
  30. >>> inherited = {'font-family': 'serif', 'font-weight': 'bold'}
  31. >>> out = resolve('''
  32. ... border-color: BLUE RED;
  33. ... font-size: 1em;
  34. ... font-size: 2em;
  35. ... font-weight: normal;
  36. ... font-weight: inherit;
  37. ... ''', inherited)
  38. >>> sorted(out.items()) # doctest: +NORMALIZE_WHITESPACE
  39. [('border-bottom-color', 'blue'),
  40. ('border-left-color', 'red'),
  41. ('border-right-color', 'red'),
  42. ('border-top-color', 'blue'),
  43. ('font-family', 'serif'),
  44. ('font-size', '24pt'),
  45. ('font-weight', 'bold')]
  46. """
  47. props = dict(self.atomize(self.parse(declarations_str)))
  48. if inherited is None:
  49. inherited = {}
  50. # 1. resolve inherited, initial
  51. for prop, val in inherited.items():
  52. if prop not in props:
  53. props[prop] = val
  54. for prop, val in list(props.items()):
  55. if val == 'inherit':
  56. val = inherited.get(prop, 'initial')
  57. if val == 'initial':
  58. val = self.INITIAL_STYLE.get(prop)
  59. if val is None:
  60. # we do not define a complete initial stylesheet
  61. del props[prop]
  62. else:
  63. props[prop] = val
  64. # 2. resolve relative font size
  65. if props.get('font-size'):
  66. if 'font-size' in inherited:
  67. em_pt = inherited['font-size']
  68. assert em_pt[-2:] == 'pt'
  69. em_pt = float(em_pt[:-2])
  70. else:
  71. em_pt = None
  72. props['font-size'] = self.size_to_pt(
  73. props['font-size'], em_pt, conversions=self.FONT_SIZE_RATIOS)
  74. font_size = float(props['font-size'][:-2])
  75. else:
  76. font_size = None
  77. # 3. TODO: resolve other font-relative units
  78. for side in self.SIDES:
  79. prop = 'border-{side}-width'.format(side=side)
  80. if prop in props:
  81. props[prop] = self.size_to_pt(
  82. props[prop], em_pt=font_size,
  83. conversions=self.BORDER_WIDTH_RATIOS)
  84. for prop in ['margin-{side}'.format(side=side),
  85. 'padding-{side}'.format(side=side)]:
  86. if prop in props:
  87. # TODO: support %
  88. props[prop] = self.size_to_pt(
  89. props[prop], em_pt=font_size,
  90. conversions=self.MARGIN_RATIOS)
  91. return props
  92. UNIT_RATIOS = {
  93. 'rem': ('pt', 12),
  94. 'ex': ('em', .5),
  95. # 'ch':
  96. 'px': ('pt', .75),
  97. 'pc': ('pt', 12),
  98. 'in': ('pt', 72),
  99. 'cm': ('in', 1 / 2.54),
  100. 'mm': ('in', 1 / 25.4),
  101. 'q': ('mm', .25),
  102. '!!default': ('em', 0),
  103. }
  104. FONT_SIZE_RATIOS = UNIT_RATIOS.copy()
  105. FONT_SIZE_RATIOS.update({
  106. '%': ('em', .01),
  107. 'xx-small': ('rem', .5),
  108. 'x-small': ('rem', .625),
  109. 'small': ('rem', .8),
  110. 'medium': ('rem', 1),
  111. 'large': ('rem', 1.125),
  112. 'x-large': ('rem', 1.5),
  113. 'xx-large': ('rem', 2),
  114. 'smaller': ('em', 1 / 1.2),
  115. 'larger': ('em', 1.2),
  116. '!!default': ('em', 1),
  117. })
  118. MARGIN_RATIOS = UNIT_RATIOS.copy()
  119. MARGIN_RATIOS.update({
  120. 'none': ('pt', 0),
  121. })
  122. BORDER_WIDTH_RATIOS = UNIT_RATIOS.copy()
  123. BORDER_WIDTH_RATIOS.update({
  124. 'none': ('pt', 0),
  125. 'thick': ('px', 4),
  126. 'medium': ('px', 2),
  127. 'thin': ('px', 1),
  128. # Default: medium only if solid
  129. })
  130. def size_to_pt(self, in_val, em_pt=None, conversions=UNIT_RATIOS):
  131. def _error():
  132. warnings.warn('Unhandled size: {val!r}'.format(val=in_val),
  133. CSSWarning)
  134. return self.size_to_pt('1!!default', conversions=conversions)
  135. try:
  136. val, unit = re.match(r'^(\S*?)([a-zA-Z%!].*)', in_val).groups()
  137. except AttributeError:
  138. return _error()
  139. if val == '':
  140. # hack for 'large' etc.
  141. val = 1
  142. else:
  143. try:
  144. val = float(val)
  145. except ValueError:
  146. return _error()
  147. while unit != 'pt':
  148. if unit == 'em':
  149. if em_pt is None:
  150. unit = 'rem'
  151. else:
  152. val *= em_pt
  153. unit = 'pt'
  154. continue
  155. try:
  156. unit, mul = conversions[unit]
  157. except KeyError:
  158. return _error()
  159. val *= mul
  160. val = round(val, 5)
  161. if int(val) == val:
  162. size_fmt = '{fmt:d}pt'.format(fmt=int(val))
  163. else:
  164. size_fmt = '{fmt:f}pt'.format(fmt=val)
  165. return size_fmt
  166. def atomize(self, declarations):
  167. for prop, value in declarations:
  168. attr = 'expand_' + prop.replace('-', '_')
  169. try:
  170. expand = getattr(self, attr)
  171. except AttributeError:
  172. yield prop, value
  173. else:
  174. for prop, value in expand(prop, value):
  175. yield prop, value
  176. SIDE_SHORTHANDS = {
  177. 1: [0, 0, 0, 0],
  178. 2: [0, 1, 0, 1],
  179. 3: [0, 1, 2, 1],
  180. 4: [0, 1, 2, 3],
  181. }
  182. SIDES = ('top', 'right', 'bottom', 'left')
  183. def _side_expander(prop_fmt):
  184. def expand(self, prop, value):
  185. tokens = value.split()
  186. try:
  187. mapping = self.SIDE_SHORTHANDS[len(tokens)]
  188. except KeyError:
  189. warnings.warn('Could not expand "{prop}: {val}"'
  190. .format(prop=prop, val=value), CSSWarning)
  191. return
  192. for key, idx in zip(self.SIDES, mapping):
  193. yield prop_fmt.format(key), tokens[idx]
  194. return expand
  195. expand_border_color = _side_expander('border-{:s}-color')
  196. expand_border_style = _side_expander('border-{:s}-style')
  197. expand_border_width = _side_expander('border-{:s}-width')
  198. expand_margin = _side_expander('margin-{:s}')
  199. expand_padding = _side_expander('padding-{:s}')
  200. def parse(self, declarations_str):
  201. """Generates (prop, value) pairs from declarations
  202. In a future version may generate parsed tokens from tinycss/tinycss2
  203. """
  204. for decl in declarations_str.split(';'):
  205. if not decl.strip():
  206. continue
  207. prop, sep, val = decl.partition(':')
  208. prop = prop.strip().lower()
  209. # TODO: don't lowercase case sensitive parts of values (strings)
  210. val = val.strip().lower()
  211. if sep:
  212. yield prop, val
  213. else:
  214. warnings.warn('Ill-formatted attribute: expected a colon '
  215. 'in {decl!r}'.format(decl=decl), CSSWarning)