422 lines
12 KiB
Python
422 lines
12 KiB
Python
"""
|
|
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(),
|
|
)
|
|
|
|
# TODO: Can we use current color as initial value to comply with CSS standards?
|
|
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
|
|
# TODO: Warn user if item entered more than once (e.g. "border: red green")
|
|
|
|
# Per CSS, "border" will reset previous "border-*" definitions
|
|
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),
|
|
# 'ch':
|
|
"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),
|
|
# Default: medium only if solid
|
|
}
|
|
)
|
|
|
|
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]:
|
|
# 1. resolve inherited, initial
|
|
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):
|
|
# we do not define a complete initial stylesheet
|
|
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]:
|
|
# 2. resolve relative font size
|
|
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)
|
|
# 3. TODO: resolve other font-relative units
|
|
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:
|
|
# TODO: support %
|
|
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 == "":
|
|
# 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 = 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()
|
|
# TODO: don't lowercase case sensitive parts of values (strings)
|
|
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(),
|
|
)
|