Skip to content

Commit

Permalink
BUG/ENH: Translate CSS border properties for Styler.to_excel (panda…
Browse files Browse the repository at this point in the history
  • Loading branch information
tehunter authored Feb 11, 2022
1 parent 1b5338e commit 65ecb90
Show file tree
Hide file tree
Showing 7 changed files with 239 additions and 15 deletions.
5 changes: 4 additions & 1 deletion doc/source/user_guide/style.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -1577,6 +1577,9 @@
"Some support (*since version 0.20.0*) is available for exporting styled `DataFrames` to Excel worksheets using the `OpenPyXL` or `XlsxWriter` engines. CSS2.2 properties handled include:\n",
"\n",
"- `background-color`\n",
"- `border-style` properties\n",
"- `border-width` properties\n",
"- `border-color` properties\n",
"- `color`\n",
"- `font-family`\n",
"- `font-style`\n",
Expand All @@ -1587,7 +1590,7 @@
"- `white-space: nowrap`\n",
"\n",
"\n",
"- Currently broken: `border-style`, `border-width`, `border-color` and their {`top`, `right`, `bottom`, `left` variants}\n",
"- Shorthand and side-specific border properties are supported (e.g. `border-style` and `border-left-style`) as well as the `border` shorthands for all sides (`border: 1px solid green`) or specified sides (`border-left: 1px solid green`). Using a `border` shorthand will override any border properties set before it (See [CSS Working Group](https://drafts.csswg.org/css-backgrounds/#border-shorthands) for more details)\n",
"\n",
"\n",
"- Only CSS2 named colors and hex colors of the form `#rgb` or `#rrggbb` are currently supported.\n",
Expand Down
8 changes: 5 additions & 3 deletions doc/source/whatsnew/v1.5.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Styler
^^^^^^

- New method :meth:`.Styler.to_string` for alternative customisable output methods (:issue:`44502`)
- Various bug fixes, see below.
- Added the ability to render ``border`` and ``border-{side}`` CSS properties in Excel (:issue:`42276`)

.. _whatsnew_150.enhancements.enhancement2:

Expand Down Expand Up @@ -52,8 +52,10 @@ These are bug fixes that might have notable behavior changes.

.. _whatsnew_150.notable_bug_fixes.notable_bug_fix1:

notable_bug_fix1
^^^^^^^^^^^^^^^^
Styler
^^^^^^

- Fixed bug in :class:`CSSToExcelConverter` leading to ``TypeError`` when border color provided without border style for ``xlsxwriter`` engine (:issue:`42276`)

.. _whatsnew_150.notable_bug_fixes.notable_bug_fix2:

Expand Down
114 changes: 111 additions & 3 deletions pandas/io/formats/css.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
from __future__ import annotations

import re
from typing import (
Callable,
Generator,
)
import warnings


Expand All @@ -13,8 +17,33 @@ class CSSWarning(UserWarning):
"""


def _side_expander(prop_fmt: str):
def expand(self, prop, value: str):
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)]
Expand All @@ -27,12 +56,72 @@ def expand(self, prop, value: str):
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
)

# 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 in self.BORDER_STYLES:
border_declarations[f"border{side}-style"] = token
elif any([ratio in token 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':
Expand Down Expand Up @@ -76,6 +165,19 @@ class CSSResolver:
}
)

BORDER_STYLES = [
"none",
"hidden",
"dotted",
"dashed",
"solid",
"double",
"groove",
"ridge",
"inset",
"outset",
]

SIDE_SHORTHANDS = {
1: [0, 0, 0, 0],
2: [0, 1, 0, 1],
Expand Down Expand Up @@ -244,7 +346,7 @@ def _error():
size_fmt = f"{val:f}pt"
return size_fmt

def atomize(self, declarations):
def atomize(self, declarations) -> Generator[tuple[str, str], None, None]:
for prop, value in declarations:
attr = "expand_" + prop.replace("-", "_")
try:
Expand All @@ -255,6 +357,12 @@ def atomize(self, declarations):
for prop, value in expand(prop, value):
yield prop, value

expand_border = _border_expander()
expand_border_top = _border_expander("top")
expand_border_right = _border_expander("right")
expand_border_bottom = _border_expander("bottom")
expand_border_left = _border_expander("left")

expand_border_color = _side_expander("border-{:s}-color")
expand_border_style = _side_expander("border-{:s}-style")
expand_border_width = _side_expander("border-{:s}-width")
Expand Down
15 changes: 11 additions & 4 deletions pandas/io/formats/excel.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,14 @@ def build_border(
"style": self._border_style(
props.get(f"border-{side}-style"),
props.get(f"border-{side}-width"),
self.color_to_excel(props.get(f"border-{side}-color")),
),
"color": self.color_to_excel(props.get(f"border-{side}-color")),
}
for side in ["top", "right", "bottom", "left"]
}

def _border_style(self, style: str | None, width: str | None):
def _border_style(self, style: str | None, width: str | None, color: str | None):
# convert styles and widths to openxml, one of:
# 'dashDot'
# 'dashDotDot'
Expand All @@ -258,14 +259,20 @@ def _border_style(self, style: str | None, width: str | None):
# 'slantDashDot'
# 'thick'
# 'thin'
if width is None and style is None:
if width is None and style is None and color is None:
# Return None will remove "border" from style dictionary
return None

if width is None and style is None:
# Return "none" will keep "border" in style dictionary
return "none"

if style == "none" or style == "hidden":
return None
return "none"

width_name = self._get_width_name(width)
if width_name is None:
return None
return "none"

if style in (None, "groove", "ridge", "inset", "outset", "solid"):
# not handled
Expand Down
44 changes: 41 additions & 3 deletions pandas/tests/io/excel/test_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,44 @@ def test_styler_to_excel_unstyled(engine):
["alignment", "vertical"],
{"xlsxwriter": None, "openpyxl": "bottom"}, # xlsxwriter Fails
),
# Border widths
("border-left: 2pt solid red", ["border", "left", "style"], "medium"),
("border-left: 1pt dotted red", ["border", "left", "style"], "dotted"),
("border-left: 2pt dotted red", ["border", "left", "style"], "mediumDashDotDot"),
("border-left: 1pt dashed red", ["border", "left", "style"], "dashed"),
("border-left: 2pt dashed red", ["border", "left", "style"], "mediumDashed"),
("border-left: 1pt solid red", ["border", "left", "style"], "thin"),
("border-left: 3pt solid red", ["border", "left", "style"], "thick"),
# Border expansion
(
"border-left: 2pt solid #111222",
["border", "left", "color", "rgb"],
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
),
("border: 1pt solid red", ["border", "top", "style"], "thin"),
(
"border: 1pt solid #111222",
["border", "top", "color", "rgb"],
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
),
("border: 1pt solid red", ["border", "right", "style"], "thin"),
(
"border: 1pt solid #111222",
["border", "right", "color", "rgb"],
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
),
("border: 1pt solid red", ["border", "bottom", "style"], "thin"),
(
"border: 1pt solid #111222",
["border", "bottom", "color", "rgb"],
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
),
("border: 1pt solid red", ["border", "left", "style"], "thin"),
(
"border: 1pt solid #111222",
["border", "left", "color", "rgb"],
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
),
]


Expand All @@ -95,7 +133,7 @@ def test_styler_to_excel_basic(engine, css, attrs, expected):
# test styled cell has expected styles
u_cell, s_cell = wb["dataframe"].cell(2, 2), wb["styled"].cell(2, 2)
for attr in attrs:
u_cell, s_cell = getattr(u_cell, attr), getattr(s_cell, attr)
u_cell, s_cell = getattr(u_cell, attr, None), getattr(s_cell, attr)

if isinstance(expected, dict):
assert u_cell is None or u_cell != expected[engine]
Expand Down Expand Up @@ -136,8 +174,8 @@ def test_styler_to_excel_basic_indexes(engine, css, attrs, expected):
ui_cell, si_cell = wb["null_styled"].cell(2, 1), wb["styled"].cell(2, 1)
uc_cell, sc_cell = wb["null_styled"].cell(1, 2), wb["styled"].cell(1, 2)
for attr in attrs:
ui_cell, si_cell = getattr(ui_cell, attr), getattr(si_cell, attr)
uc_cell, sc_cell = getattr(uc_cell, attr), getattr(sc_cell, attr)
ui_cell, si_cell = getattr(ui_cell, attr, None), getattr(si_cell, attr)
uc_cell, sc_cell = getattr(uc_cell, attr, None), getattr(sc_cell, attr)

if isinstance(expected, dict):
assert ui_cell is None or ui_cell != expected[engine]
Expand Down
61 changes: 61 additions & 0 deletions pandas/tests/io/formats/test_css.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ def test_css_parse_normalisation(name, norm, abnorm):
("font-size: 1unknownunit", "font-size: 1em"),
("font-size: 10", "font-size: 1em"),
("font-size: 10 pt", "font-size: 1em"),
# Too many args
("border-top: 1pt solid red green", "border-top: 1pt solid green"),
],
)
def test_css_parse_invalid(invalid_css, remainder):
Expand Down Expand Up @@ -123,6 +125,65 @@ def test_css_side_shorthands(shorthand, expansions):
assert_resolves(f"{shorthand}: 1pt 1pt 1pt 1pt 1pt", {})


@pytest.mark.parametrize(
"shorthand,sides",
[
("border-top", ["top"]),
("border-right", ["right"]),
("border-bottom", ["bottom"]),
("border-left", ["left"]),
("border", ["top", "right", "bottom", "left"]),
],
)
def test_css_border_shorthand_sides(shorthand, sides):
def create_border_dict(sides, color=None, style=None, width=None):
resolved = {}
for side in sides:
if color:
resolved[f"border-{side}-color"] = color
if style:
resolved[f"border-{side}-style"] = style
if width:
resolved[f"border-{side}-width"] = width
return resolved

assert_resolves(
f"{shorthand}: 1pt red solid", create_border_dict(sides, "red", "solid", "1pt")
)


@pytest.mark.parametrize(
"prop, expected",
[
("1pt red solid", ("red", "solid", "1pt")),
("red 1pt solid", ("red", "solid", "1pt")),
("red solid 1pt", ("red", "solid", "1pt")),
("solid 1pt red", ("red", "solid", "1pt")),
("red solid", ("red", "solid", "1.500000pt")),
# Note: color=black is not CSS conforming
# (See https://drafts.csswg.org/css-backgrounds/#border-shorthands)
("1pt solid", ("black", "solid", "1pt")),
("1pt red", ("red", "none", "1pt")),
("red", ("red", "none", "1.500000pt")),
("1pt", ("black", "none", "1pt")),
("solid", ("black", "solid", "1.500000pt")),
# Sizes
("1em", ("black", "none", "12pt")),
],
)
def test_css_border_shorthands(prop, expected):
color, style, width = expected

assert_resolves(
f"border-left: {prop}",
{
"border-left-color": color,
"border-left-style": style,
"border-left-width": width,
},
)


@pytest.mark.parametrize(
"style,inherited,equiv",
[
Expand Down
Loading

0 comments on commit 65ecb90

Please sign in to comment.