|
""" |
|
Utilities for interpreting CSS from Stylers for formatting non-HTML outputs. |
|
""" |
|
from __future__ import annotations |
|
|
|
import re |
|
from typing import ( |
|
TYPE_CHECKING, |
|
Callable, |
|
) |
|
import warnings |
|
|
|
from pandas.errors import CSSWarning |
|
from pandas.util._exceptions import find_stack_level |
|
|
|
if TYPE_CHECKING: |
|
from collections.abc import ( |
|
Generator, |
|
Iterable, |
|
Iterator, |
|
) |
|
|
|
|
|
def _side_expander(prop_fmt: str) -> Callable: |
|
""" |
|
Wrapper to expand shorthand property into top, right, bottom, left properties |
|
|
|
Parameters |
|
---------- |
|
side : str |
|
The border side to expand into properties |
|
|
|
Returns |
|
------- |
|
function: Return to call when a 'border(-{side}): {value}' string is encountered |
|
""" |
|
|
|
def expand(self, prop, value: str) -> Generator[tuple[str, str], None, None]: |
|
""" |
|
Expand shorthand property into side-specific property (top, right, bottom, left) |
|
|
|
Parameters |
|
---------- |
|
prop (str): CSS property name |
|
value (str): String token for property |
|
|
|
Yields |
|
------ |
|
Tuple (str, str): Expanded property, value |
|
""" |
|
tokens = value.split() |
|
try: |
|
mapping = self.SIDE_SHORTHANDS[len(tokens)] |
|
except KeyError: |
|
warnings.warn( |
|
f'Could not expand "{prop}: {value}"', |
|
CSSWarning, |
|
stacklevel=find_stack_level(), |
|
) |
|
return |
|
for key, idx in zip(self.SIDES, mapping): |
|
yield prop_fmt.format(key), tokens[idx] |
|
|
|
return expand |
|
|
|
|
|
def _border_expander(side: str = "") -> Callable: |
|
""" |
|
Wrapper to expand 'border' property into border color, style, and width properties |
|
|
|
Parameters |
|
---------- |
|
side : str |
|
The border side to expand into properties |
|
|
|
Returns |
|
------- |
|
function: Return to call when a 'border(-{side}): {value}' string is encountered |
|
""" |
|
if side != "": |
|
side = f"-{side}" |
|
|
|
def expand(self, prop, value: str) -> Generator[tuple[str, str], None, None]: |
|
""" |
|
Expand border into color, style, and width tuples |
|
|
|
Parameters |
|
---------- |
|
prop : str |
|
CSS property name passed to styler |
|
value : str |
|
Value passed to styler for property |
|
|
|
Yields |
|
------ |
|
Tuple (str, str): Expanded property, value |
|
""" |
|
tokens = value.split() |
|
if len(tokens) == 0 or len(tokens) > 3: |
|
warnings.warn( |
|
f'Too many tokens provided to "{prop}" (expected 1-3)', |
|
CSSWarning, |
|
stacklevel=find_stack_level(), |
|
) |
|
|
|
|
|
border_declarations = { |
|
f"border{side}-color": "black", |
|
f"border{side}-style": "none", |
|
f"border{side}-width": "medium", |
|
} |
|
for token in tokens: |
|
if token.lower() in self.BORDER_STYLES: |
|
border_declarations[f"border{side}-style"] = token |
|
elif any(ratio in token.lower() for ratio in self.BORDER_WIDTH_RATIOS): |
|
border_declarations[f"border{side}-width"] = token |
|
else: |
|
border_declarations[f"border{side}-color"] = token |
|
|
|
|
|
|
|
yield from self.atomize(border_declarations.items()) |
|
|
|
return expand |
|
|
|
|
|
class CSSResolver: |
|
""" |
|
A callable for parsing and resolving CSS to atomic properties. |
|
""" |
|
|
|
UNIT_RATIOS = { |
|
"pt": ("pt", 1), |
|
"em": ("em", 1), |
|
"rem": ("pt", 12), |
|
"ex": ("em", 0.5), |
|
|
|
"px": ("pt", 0.75), |
|
"pc": ("pt", 12), |
|
"in": ("pt", 72), |
|
"cm": ("in", 1 / 2.54), |
|
"mm": ("in", 1 / 25.4), |
|
"q": ("mm", 0.25), |
|
"!!default": ("em", 0), |
|
} |
|
|
|
FONT_SIZE_RATIOS = UNIT_RATIOS.copy() |
|
FONT_SIZE_RATIOS.update( |
|
{ |
|
"%": ("em", 0.01), |
|
"xx-small": ("rem", 0.5), |
|
"x-small": ("rem", 0.625), |
|
"small": ("rem", 0.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), |
|
|
|
} |
|
) |
|
|
|
BORDER_STYLES = [ |
|
"none", |
|
"hidden", |
|
"dotted", |
|
"dashed", |
|
"solid", |
|
"double", |
|
"groove", |
|
"ridge", |
|
"inset", |
|
"outset", |
|
"mediumdashdot", |
|
"dashdotdot", |
|
"hair", |
|
"mediumdashdotdot", |
|
"dashdot", |
|
"slantdashdot", |
|
"mediumdashed", |
|
] |
|
|
|
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") |
|
|
|
CSS_EXPANSIONS = { |
|
**{ |
|
(f"border-{prop}" if prop else "border"): _border_expander(prop) |
|
for prop in ["", "top", "right", "bottom", "left"] |
|
}, |
|
**{ |
|
f"border-{prop}": _side_expander(f"border-{{:s}}-{prop}") |
|
for prop in ["color", "style", "width"] |
|
}, |
|
"margin": _side_expander("margin-{:s}"), |
|
"padding": _side_expander("padding-{:s}"), |
|
} |
|
|
|
def __call__( |
|
self, |
|
declarations: str | Iterable[tuple[str, str]], |
|
inherited: dict[str, str] | None = None, |
|
) -> dict[str, str]: |
|
""" |
|
The given declarations to atomic properties. |
|
|
|
Parameters |
|
---------- |
|
declarations_str : str | Iterable[tuple[str, str]] |
|
A CSS string or set of CSS declaration tuples |
|
e.g. "font-weight: bold; background: blue" or |
|
{("font-weight", "bold"), ("background", "blue")} |
|
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 |
|
------- |
|
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')] |
|
""" |
|
if isinstance(declarations, str): |
|
declarations = self.parse(declarations) |
|
props = dict(self.atomize(declarations)) |
|
if inherited is None: |
|
inherited = {} |
|
|
|
props = self._update_initial(props, inherited) |
|
props = self._update_font_size(props, inherited) |
|
return self._update_other_units(props) |
|
|
|
def _update_initial( |
|
self, |
|
props: dict[str, str], |
|
inherited: dict[str, str], |
|
) -> dict[str, str]: |
|
|
|
for prop, val in inherited.items(): |
|
if prop not in props: |
|
props[prop] = val |
|
|
|
new_props = props.copy() |
|
for prop, val in props.items(): |
|
if val == "inherit": |
|
val = inherited.get(prop, "initial") |
|
|
|
if val in ("initial", None): |
|
|
|
del new_props[prop] |
|
else: |
|
new_props[prop] = val |
|
return new_props |
|
|
|
def _update_font_size( |
|
self, |
|
props: dict[str, str], |
|
inherited: dict[str, str], |
|
) -> dict[str, str]: |
|
|
|
if props.get("font-size"): |
|
props["font-size"] = self.size_to_pt( |
|
props["font-size"], |
|
self._get_font_size(inherited), |
|
conversions=self.FONT_SIZE_RATIOS, |
|
) |
|
return props |
|
|
|
def _get_font_size(self, props: dict[str, str]) -> float | None: |
|
if props.get("font-size"): |
|
font_size_string = props["font-size"] |
|
return self._get_float_font_size_from_pt(font_size_string) |
|
return None |
|
|
|
def _get_float_font_size_from_pt(self, font_size_string: str) -> float: |
|
assert font_size_string.endswith("pt") |
|
return float(font_size_string.rstrip("pt")) |
|
|
|
def _update_other_units(self, props: dict[str, str]) -> dict[str, str]: |
|
font_size = self._get_font_size(props) |
|
|
|
for side in self.SIDES: |
|
prop = f"border-{side}-width" |
|
if prop in props: |
|
props[prop] = self.size_to_pt( |
|
props[prop], |
|
em_pt=font_size, |
|
conversions=self.BORDER_WIDTH_RATIOS, |
|
) |
|
|
|
for prop in [f"margin-{side}", f"padding-{side}"]: |
|
if prop in props: |
|
|
|
props[prop] = self.size_to_pt( |
|
props[prop], |
|
em_pt=font_size, |
|
conversions=self.MARGIN_RATIOS, |
|
) |
|
return props |
|
|
|
def size_to_pt(self, in_val, em_pt=None, conversions=UNIT_RATIOS) -> str: |
|
def _error(): |
|
warnings.warn( |
|
f"Unhandled size: {repr(in_val)}", |
|
CSSWarning, |
|
stacklevel=find_stack_level(), |
|
) |
|
return self.size_to_pt("1!!default", conversions=conversions) |
|
|
|
match = re.match(r"^(\S*?)([a-zA-Z%!].*)", in_val) |
|
if match is None: |
|
return _error() |
|
|
|
val, unit = match.groups() |
|
if val == "": |
|
|
|
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 = f"{int(val):d}pt" |
|
else: |
|
size_fmt = f"{val:f}pt" |
|
return size_fmt |
|
|
|
def atomize(self, declarations: Iterable) -> Generator[tuple[str, str], None, None]: |
|
for prop, value in declarations: |
|
prop = prop.lower() |
|
value = value.lower() |
|
if prop in self.CSS_EXPANSIONS: |
|
expand = self.CSS_EXPANSIONS[prop] |
|
yield from expand(self, prop, value) |
|
else: |
|
yield prop, value |
|
|
|
def parse(self, declarations_str: str) -> Iterator[tuple[str, str]]: |
|
""" |
|
Generates (prop, value) pairs from declarations. |
|
|
|
In a future version may generate parsed tokens from tinycss/tinycss2 |
|
|
|
Parameters |
|
---------- |
|
declarations_str : str |
|
""" |
|
for decl in declarations_str.split(";"): |
|
if not decl.strip(): |
|
continue |
|
prop, sep, val = decl.partition(":") |
|
prop = prop.strip().lower() |
|
|
|
val = val.strip().lower() |
|
if sep: |
|
yield prop, val |
|
else: |
|
warnings.warn( |
|
f"Ill-formatted attribute: expected a colon in {repr(decl)}", |
|
CSSWarning, |
|
stacklevel=find_stack_level(), |
|
) |
|
|