css.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. """
  2. Utilities for interpreting CSS from Stylers for formatting non-HTML outputs.
  3. """
  4. from __future__ import annotations
  5. import re
  6. from typing import (
  7. Callable,
  8. Generator,
  9. Iterable,
  10. Iterator,
  11. )
  12. import warnings
  13. from pandas.errors import CSSWarning
  14. from pandas.util._exceptions import find_stack_level
  15. def _side_expander(prop_fmt: str) -> Callable:
  16. """
  17. Wrapper to expand shorthand property into top, right, bottom, left properties
  18. Parameters
  19. ----------
  20. side : str
  21. The border side to expand into properties
  22. Returns
  23. -------
  24. function: Return to call when a 'border(-{side}): {value}' string is encountered
  25. """
  26. def expand(self, prop, value: str) -> Generator[tuple[str, str], None, None]:
  27. """
  28. Expand shorthand property into side-specific property (top, right, bottom, left)
  29. Parameters
  30. ----------
  31. prop (str): CSS property name
  32. value (str): String token for property
  33. Yields
  34. ------
  35. Tuple (str, str): Expanded property, value
  36. """
  37. tokens = value.split()
  38. try:
  39. mapping = self.SIDE_SHORTHANDS[len(tokens)]
  40. except KeyError:
  41. warnings.warn(
  42. f'Could not expand "{prop}: {value}"',
  43. CSSWarning,
  44. stacklevel=find_stack_level(),
  45. )
  46. return
  47. for key, idx in zip(self.SIDES, mapping):
  48. yield prop_fmt.format(key), tokens[idx]
  49. return expand
  50. def _border_expander(side: str = "") -> Callable:
  51. """
  52. Wrapper to expand 'border' property into border color, style, and width properties
  53. Parameters
  54. ----------
  55. side : str
  56. The border side to expand into properties
  57. Returns
  58. -------
  59. function: Return to call when a 'border(-{side}): {value}' string is encountered
  60. """
  61. if side != "":
  62. side = f"-{side}"
  63. def expand(self, prop, value: str) -> Generator[tuple[str, str], None, None]:
  64. """
  65. Expand border into color, style, and width tuples
  66. Parameters
  67. ----------
  68. prop : str
  69. CSS property name passed to styler
  70. value : str
  71. Value passed to styler for property
  72. Yields
  73. ------
  74. Tuple (str, str): Expanded property, value
  75. """
  76. tokens = value.split()
  77. if len(tokens) == 0 or len(tokens) > 3:
  78. warnings.warn(
  79. f'Too many tokens provided to "{prop}" (expected 1-3)',
  80. CSSWarning,
  81. stacklevel=find_stack_level(),
  82. )
  83. # TODO: Can we use current color as initial value to comply with CSS standards?
  84. border_declarations = {
  85. f"border{side}-color": "black",
  86. f"border{side}-style": "none",
  87. f"border{side}-width": "medium",
  88. }
  89. for token in tokens:
  90. if token.lower() in self.BORDER_STYLES:
  91. border_declarations[f"border{side}-style"] = token
  92. elif any(ratio in token.lower() for ratio in self.BORDER_WIDTH_RATIOS):
  93. border_declarations[f"border{side}-width"] = token
  94. else:
  95. border_declarations[f"border{side}-color"] = token
  96. # TODO: Warn user if item entered more than once (e.g. "border: red green")
  97. # Per CSS, "border" will reset previous "border-*" definitions
  98. yield from self.atomize(border_declarations.items())
  99. return expand
  100. class CSSResolver:
  101. """
  102. A callable for parsing and resolving CSS to atomic properties.
  103. """
  104. UNIT_RATIOS = {
  105. "pt": ("pt", 1),
  106. "em": ("em", 1),
  107. "rem": ("pt", 12),
  108. "ex": ("em", 0.5),
  109. # 'ch':
  110. "px": ("pt", 0.75),
  111. "pc": ("pt", 12),
  112. "in": ("pt", 72),
  113. "cm": ("in", 1 / 2.54),
  114. "mm": ("in", 1 / 25.4),
  115. "q": ("mm", 0.25),
  116. "!!default": ("em", 0),
  117. }
  118. FONT_SIZE_RATIOS = UNIT_RATIOS.copy()
  119. FONT_SIZE_RATIOS.update(
  120. {
  121. "%": ("em", 0.01),
  122. "xx-small": ("rem", 0.5),
  123. "x-small": ("rem", 0.625),
  124. "small": ("rem", 0.8),
  125. "medium": ("rem", 1),
  126. "large": ("rem", 1.125),
  127. "x-large": ("rem", 1.5),
  128. "xx-large": ("rem", 2),
  129. "smaller": ("em", 1 / 1.2),
  130. "larger": ("em", 1.2),
  131. "!!default": ("em", 1),
  132. }
  133. )
  134. MARGIN_RATIOS = UNIT_RATIOS.copy()
  135. MARGIN_RATIOS.update({"none": ("pt", 0)})
  136. BORDER_WIDTH_RATIOS = UNIT_RATIOS.copy()
  137. BORDER_WIDTH_RATIOS.update(
  138. {
  139. "none": ("pt", 0),
  140. "thick": ("px", 4),
  141. "medium": ("px", 2),
  142. "thin": ("px", 1),
  143. # Default: medium only if solid
  144. }
  145. )
  146. BORDER_STYLES = [
  147. "none",
  148. "hidden",
  149. "dotted",
  150. "dashed",
  151. "solid",
  152. "double",
  153. "groove",
  154. "ridge",
  155. "inset",
  156. "outset",
  157. "mediumdashdot",
  158. "dashdotdot",
  159. "hair",
  160. "mediumdashdotdot",
  161. "dashdot",
  162. "slantdashdot",
  163. "mediumdashed",
  164. ]
  165. SIDE_SHORTHANDS = {
  166. 1: [0, 0, 0, 0],
  167. 2: [0, 1, 0, 1],
  168. 3: [0, 1, 2, 1],
  169. 4: [0, 1, 2, 3],
  170. }
  171. SIDES = ("top", "right", "bottom", "left")
  172. CSS_EXPANSIONS = {
  173. **{
  174. (f"border-{prop}" if prop else "border"): _border_expander(prop)
  175. for prop in ["", "top", "right", "bottom", "left"]
  176. },
  177. **{
  178. f"border-{prop}": _side_expander(f"border-{{:s}}-{prop}")
  179. for prop in ["color", "style", "width"]
  180. },
  181. **{
  182. "margin": _side_expander("margin-{:s}"),
  183. "padding": _side_expander("padding-{:s}"),
  184. },
  185. }
  186. def __call__(
  187. self,
  188. declarations: str | Iterable[tuple[str, str]],
  189. inherited: dict[str, str] | None = None,
  190. ) -> dict[str, str]:
  191. """
  192. The given declarations to atomic properties.
  193. Parameters
  194. ----------
  195. declarations_str : str | Iterable[tuple[str, str]]
  196. A CSS string or set of CSS declaration tuples
  197. e.g. "font-weight: bold; background: blue" or
  198. {("font-weight", "bold"), ("background", "blue")}
  199. inherited : dict, optional
  200. Atomic properties indicating the inherited style context in which
  201. declarations_str is to be resolved. ``inherited`` should already
  202. be resolved, i.e. valid output of this method.
  203. Returns
  204. -------
  205. dict
  206. Atomic CSS 2.2 properties.
  207. Examples
  208. --------
  209. >>> resolve = CSSResolver()
  210. >>> inherited = {'font-family': 'serif', 'font-weight': 'bold'}
  211. >>> out = resolve('''
  212. ... border-color: BLUE RED;
  213. ... font-size: 1em;
  214. ... font-size: 2em;
  215. ... font-weight: normal;
  216. ... font-weight: inherit;
  217. ... ''', inherited)
  218. >>> sorted(out.items()) # doctest: +NORMALIZE_WHITESPACE
  219. [('border-bottom-color', 'blue'),
  220. ('border-left-color', 'red'),
  221. ('border-right-color', 'red'),
  222. ('border-top-color', 'blue'),
  223. ('font-family', 'serif'),
  224. ('font-size', '24pt'),
  225. ('font-weight', 'bold')]
  226. """
  227. if isinstance(declarations, str):
  228. declarations = self.parse(declarations)
  229. props = dict(self.atomize(declarations))
  230. if inherited is None:
  231. inherited = {}
  232. props = self._update_initial(props, inherited)
  233. props = self._update_font_size(props, inherited)
  234. return self._update_other_units(props)
  235. def _update_initial(
  236. self,
  237. props: dict[str, str],
  238. inherited: dict[str, str],
  239. ) -> dict[str, str]:
  240. # 1. resolve inherited, initial
  241. for prop, val in inherited.items():
  242. if prop not in props:
  243. props[prop] = val
  244. new_props = props.copy()
  245. for prop, val in props.items():
  246. if val == "inherit":
  247. val = inherited.get(prop, "initial")
  248. if val in ("initial", None):
  249. # we do not define a complete initial stylesheet
  250. del new_props[prop]
  251. else:
  252. new_props[prop] = val
  253. return new_props
  254. def _update_font_size(
  255. self,
  256. props: dict[str, str],
  257. inherited: dict[str, str],
  258. ) -> dict[str, str]:
  259. # 2. resolve relative font size
  260. if props.get("font-size"):
  261. props["font-size"] = self.size_to_pt(
  262. props["font-size"],
  263. self._get_font_size(inherited),
  264. conversions=self.FONT_SIZE_RATIOS,
  265. )
  266. return props
  267. def _get_font_size(self, props: dict[str, str]) -> float | None:
  268. if props.get("font-size"):
  269. font_size_string = props["font-size"]
  270. return self._get_float_font_size_from_pt(font_size_string)
  271. return None
  272. def _get_float_font_size_from_pt(self, font_size_string: str) -> float:
  273. assert font_size_string.endswith("pt")
  274. return float(font_size_string.rstrip("pt"))
  275. def _update_other_units(self, props: dict[str, str]) -> dict[str, str]:
  276. font_size = self._get_font_size(props)
  277. # 3. TODO: resolve other font-relative units
  278. for side in self.SIDES:
  279. prop = f"border-{side}-width"
  280. if prop in props:
  281. props[prop] = self.size_to_pt(
  282. props[prop],
  283. em_pt=font_size,
  284. conversions=self.BORDER_WIDTH_RATIOS,
  285. )
  286. for prop in [f"margin-{side}", f"padding-{side}"]:
  287. if prop in props:
  288. # TODO: support %
  289. props[prop] = self.size_to_pt(
  290. props[prop],
  291. em_pt=font_size,
  292. conversions=self.MARGIN_RATIOS,
  293. )
  294. return props
  295. def size_to_pt(self, in_val, em_pt=None, conversions=UNIT_RATIOS):
  296. def _error():
  297. warnings.warn(
  298. f"Unhandled size: {repr(in_val)}",
  299. CSSWarning,
  300. stacklevel=find_stack_level(),
  301. )
  302. return self.size_to_pt("1!!default", conversions=conversions)
  303. match = re.match(r"^(\S*?)([a-zA-Z%!].*)", in_val)
  304. if match is None:
  305. return _error()
  306. val, unit = match.groups()
  307. if val == "":
  308. # hack for 'large' etc.
  309. val = 1
  310. else:
  311. try:
  312. val = float(val)
  313. except ValueError:
  314. return _error()
  315. while unit != "pt":
  316. if unit == "em":
  317. if em_pt is None:
  318. unit = "rem"
  319. else:
  320. val *= em_pt
  321. unit = "pt"
  322. continue
  323. try:
  324. unit, mul = conversions[unit]
  325. except KeyError:
  326. return _error()
  327. val *= mul
  328. val = round(val, 5)
  329. if int(val) == val:
  330. size_fmt = f"{int(val):d}pt"
  331. else:
  332. size_fmt = f"{val:f}pt"
  333. return size_fmt
  334. def atomize(self, declarations: Iterable) -> Generator[tuple[str, str], None, None]:
  335. for prop, value in declarations:
  336. prop = prop.lower()
  337. value = value.lower()
  338. if prop in self.CSS_EXPANSIONS:
  339. expand = self.CSS_EXPANSIONS[prop]
  340. yield from expand(self, prop, value)
  341. else:
  342. yield prop, value
  343. def parse(self, declarations_str: str) -> Iterator[tuple[str, str]]:
  344. """
  345. Generates (prop, value) pairs from declarations.
  346. In a future version may generate parsed tokens from tinycss/tinycss2
  347. Parameters
  348. ----------
  349. declarations_str : str
  350. """
  351. for decl in declarations_str.split(";"):
  352. if not decl.strip():
  353. continue
  354. prop, sep, val = decl.partition(":")
  355. prop = prop.strip().lower()
  356. # TODO: don't lowercase case sensitive parts of values (strings)
  357. val = val.strip().lower()
  358. if sep:
  359. yield prop, val
  360. else:
  361. warnings.warn(
  362. f"Ill-formatted attribute: expected a colon in {repr(decl)}",
  363. CSSWarning,
  364. stacklevel=find_stack_level(),
  365. )