css.py 12 KB

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