Skip to content

Commit 65ecb90

Browse files
authored
BUG/ENH: Translate CSS border properties for Styler.to_excel (#45312)
1 parent 1b5338e commit 65ecb90

File tree

7 files changed

+239
-15
lines changed

7 files changed

+239
-15
lines changed

doc/source/user_guide/style.ipynb

+4-1
Original file line numberDiff line numberDiff line change
@@ -1577,6 +1577,9 @@
15771577
"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",
15781578
"\n",
15791579
"- `background-color`\n",
1580+
"- `border-style` properties\n",
1581+
"- `border-width` properties\n",
1582+
"- `border-color` properties\n",
15801583
"- `color`\n",
15811584
"- `font-family`\n",
15821585
"- `font-style`\n",
@@ -1587,7 +1590,7 @@
15871590
"- `white-space: nowrap`\n",
15881591
"\n",
15891592
"\n",
1590-
"- Currently broken: `border-style`, `border-width`, `border-color` and their {`top`, `right`, `bottom`, `left` variants}\n",
1593+
"- 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",
15911594
"\n",
15921595
"\n",
15931596
"- Only CSS2 named colors and hex colors of the form `#rgb` or `#rrggbb` are currently supported.\n",

doc/source/whatsnew/v1.5.0.rst

+5-3
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Styler
2020
^^^^^^
2121

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

2525
.. _whatsnew_150.enhancements.enhancement2:
2626

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

5353
.. _whatsnew_150.notable_bug_fixes.notable_bug_fix1:
5454

55-
notable_bug_fix1
56-
^^^^^^^^^^^^^^^^
55+
Styler
56+
^^^^^^
57+
58+
- Fixed bug in :class:`CSSToExcelConverter` leading to ``TypeError`` when border color provided without border style for ``xlsxwriter`` engine (:issue:`42276`)
5759

5860
.. _whatsnew_150.notable_bug_fixes.notable_bug_fix2:
5961

pandas/io/formats/css.py

+111-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
from __future__ import annotations
55

66
import re
7+
from typing import (
8+
Callable,
9+
Generator,
10+
)
711
import warnings
812

913

@@ -13,8 +17,33 @@ class CSSWarning(UserWarning):
1317
"""
1418

1519

16-
def _side_expander(prop_fmt: str):
17-
def expand(self, prop, value: str):
20+
def _side_expander(prop_fmt: str) -> Callable:
21+
"""
22+
Wrapper to expand shorthand property into top, right, bottom, left properties
23+
24+
Parameters
25+
----------
26+
side : str
27+
The border side to expand into properties
28+
29+
Returns
30+
-------
31+
function: Return to call when a 'border(-{side}): {value}' string is encountered
32+
"""
33+
34+
def expand(self, prop, value: str) -> Generator[tuple[str, str], None, None]:
35+
"""
36+
Expand shorthand property into side-specific property (top, right, bottom, left)
37+
38+
Parameters
39+
----------
40+
prop (str): CSS property name
41+
value (str): String token for property
42+
43+
Yields
44+
------
45+
Tuple (str, str): Expanded property, value
46+
"""
1847
tokens = value.split()
1948
try:
2049
mapping = self.SIDE_SHORTHANDS[len(tokens)]
@@ -27,12 +56,72 @@ def expand(self, prop, value: str):
2756
return expand
2857

2958

59+
def _border_expander(side: str = "") -> Callable:
60+
"""
61+
Wrapper to expand 'border' property into border color, style, and width properties
62+
63+
Parameters
64+
----------
65+
side : str
66+
The border side to expand into properties
67+
68+
Returns
69+
-------
70+
function: Return to call when a 'border(-{side}): {value}' string is encountered
71+
"""
72+
if side != "":
73+
side = f"-{side}"
74+
75+
def expand(self, prop, value: str) -> Generator[tuple[str, str], None, None]:
76+
"""
77+
Expand border into color, style, and width tuples
78+
79+
Parameters
80+
----------
81+
prop : str
82+
CSS property name passed to styler
83+
value : str
84+
Value passed to styler for property
85+
86+
Yields
87+
------
88+
Tuple (str, str): Expanded property, value
89+
"""
90+
tokens = value.split()
91+
if len(tokens) == 0 or len(tokens) > 3:
92+
warnings.warn(
93+
f'Too many tokens provided to "{prop}" (expected 1-3)', CSSWarning
94+
)
95+
96+
# TODO: Can we use current color as initial value to comply with CSS standards?
97+
border_declarations = {
98+
f"border{side}-color": "black",
99+
f"border{side}-style": "none",
100+
f"border{side}-width": "medium",
101+
}
102+
for token in tokens:
103+
if token in self.BORDER_STYLES:
104+
border_declarations[f"border{side}-style"] = token
105+
elif any([ratio in token for ratio in self.BORDER_WIDTH_RATIOS]):
106+
border_declarations[f"border{side}-width"] = token
107+
else:
108+
border_declarations[f"border{side}-color"] = token
109+
# TODO: Warn user if item entered more than once (e.g. "border: red green")
110+
111+
# Per CSS, "border" will reset previous "border-*" definitions
112+
yield from self.atomize(border_declarations.items())
113+
114+
return expand
115+
116+
30117
class CSSResolver:
31118
"""
32119
A callable for parsing and resolving CSS to atomic properties.
33120
"""
34121

35122
UNIT_RATIOS = {
123+
"pt": ("pt", 1),
124+
"em": ("em", 1),
36125
"rem": ("pt", 12),
37126
"ex": ("em", 0.5),
38127
# 'ch':
@@ -76,6 +165,19 @@ class CSSResolver:
76165
}
77166
)
78167

168+
BORDER_STYLES = [
169+
"none",
170+
"hidden",
171+
"dotted",
172+
"dashed",
173+
"solid",
174+
"double",
175+
"groove",
176+
"ridge",
177+
"inset",
178+
"outset",
179+
]
180+
79181
SIDE_SHORTHANDS = {
80182
1: [0, 0, 0, 0],
81183
2: [0, 1, 0, 1],
@@ -244,7 +346,7 @@ def _error():
244346
size_fmt = f"{val:f}pt"
245347
return size_fmt
246348

247-
def atomize(self, declarations):
349+
def atomize(self, declarations) -> Generator[tuple[str, str], None, None]:
248350
for prop, value in declarations:
249351
attr = "expand_" + prop.replace("-", "_")
250352
try:
@@ -255,6 +357,12 @@ def atomize(self, declarations):
255357
for prop, value in expand(prop, value):
256358
yield prop, value
257359

360+
expand_border = _border_expander()
361+
expand_border_top = _border_expander("top")
362+
expand_border_right = _border_expander("right")
363+
expand_border_bottom = _border_expander("bottom")
364+
expand_border_left = _border_expander("left")
365+
258366
expand_border_color = _side_expander("border-{:s}-color")
259367
expand_border_style = _side_expander("border-{:s}-style")
260368
expand_border_width = _side_expander("border-{:s}-width")

pandas/io/formats/excel.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -237,13 +237,14 @@ def build_border(
237237
"style": self._border_style(
238238
props.get(f"border-{side}-style"),
239239
props.get(f"border-{side}-width"),
240+
self.color_to_excel(props.get(f"border-{side}-color")),
240241
),
241242
"color": self.color_to_excel(props.get(f"border-{side}-color")),
242243
}
243244
for side in ["top", "right", "bottom", "left"]
244245
}
245246

246-
def _border_style(self, style: str | None, width: str | None):
247+
def _border_style(self, style: str | None, width: str | None, color: str | None):
247248
# convert styles and widths to openxml, one of:
248249
# 'dashDot'
249250
# 'dashDotDot'
@@ -258,14 +259,20 @@ def _border_style(self, style: str | None, width: str | None):
258259
# 'slantDashDot'
259260
# 'thick'
260261
# 'thin'
261-
if width is None and style is None:
262+
if width is None and style is None and color is None:
263+
# Return None will remove "border" from style dictionary
262264
return None
265+
266+
if width is None and style is None:
267+
# Return "none" will keep "border" in style dictionary
268+
return "none"
269+
263270
if style == "none" or style == "hidden":
264-
return None
271+
return "none"
265272

266273
width_name = self._get_width_name(width)
267274
if width_name is None:
268-
return None
275+
return "none"
269276

270277
if style in (None, "groove", "ridge", "inset", "outset", "solid"):
271278
# not handled

pandas/tests/io/excel/test_style.py

+41-3
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,44 @@ def test_styler_to_excel_unstyled(engine):
7070
["alignment", "vertical"],
7171
{"xlsxwriter": None, "openpyxl": "bottom"}, # xlsxwriter Fails
7272
),
73+
# Border widths
74+
("border-left: 2pt solid red", ["border", "left", "style"], "medium"),
75+
("border-left: 1pt dotted red", ["border", "left", "style"], "dotted"),
76+
("border-left: 2pt dotted red", ["border", "left", "style"], "mediumDashDotDot"),
77+
("border-left: 1pt dashed red", ["border", "left", "style"], "dashed"),
78+
("border-left: 2pt dashed red", ["border", "left", "style"], "mediumDashed"),
79+
("border-left: 1pt solid red", ["border", "left", "style"], "thin"),
80+
("border-left: 3pt solid red", ["border", "left", "style"], "thick"),
81+
# Border expansion
82+
(
83+
"border-left: 2pt solid #111222",
84+
["border", "left", "color", "rgb"],
85+
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
86+
),
87+
("border: 1pt solid red", ["border", "top", "style"], "thin"),
88+
(
89+
"border: 1pt solid #111222",
90+
["border", "top", "color", "rgb"],
91+
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
92+
),
93+
("border: 1pt solid red", ["border", "right", "style"], "thin"),
94+
(
95+
"border: 1pt solid #111222",
96+
["border", "right", "color", "rgb"],
97+
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
98+
),
99+
("border: 1pt solid red", ["border", "bottom", "style"], "thin"),
100+
(
101+
"border: 1pt solid #111222",
102+
["border", "bottom", "color", "rgb"],
103+
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
104+
),
105+
("border: 1pt solid red", ["border", "left", "style"], "thin"),
106+
(
107+
"border: 1pt solid #111222",
108+
["border", "left", "color", "rgb"],
109+
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
110+
),
73111
]
74112

75113

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

100138
if isinstance(expected, dict):
101139
assert u_cell is None or u_cell != expected[engine]
@@ -136,8 +174,8 @@ def test_styler_to_excel_basic_indexes(engine, css, attrs, expected):
136174
ui_cell, si_cell = wb["null_styled"].cell(2, 1), wb["styled"].cell(2, 1)
137175
uc_cell, sc_cell = wb["null_styled"].cell(1, 2), wb["styled"].cell(1, 2)
138176
for attr in attrs:
139-
ui_cell, si_cell = getattr(ui_cell, attr), getattr(si_cell, attr)
140-
uc_cell, sc_cell = getattr(uc_cell, attr), getattr(sc_cell, attr)
177+
ui_cell, si_cell = getattr(ui_cell, attr, None), getattr(si_cell, attr)
178+
uc_cell, sc_cell = getattr(uc_cell, attr, None), getattr(sc_cell, attr)
141179

142180
if isinstance(expected, dict):
143181
assert ui_cell is None or ui_cell != expected[engine]

pandas/tests/io/formats/test_css.py

+61
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def test_css_parse_normalisation(name, norm, abnorm):
5757
("font-size: 1unknownunit", "font-size: 1em"),
5858
("font-size: 10", "font-size: 1em"),
5959
("font-size: 10 pt", "font-size: 1em"),
60+
# Too many args
61+
("border-top: 1pt solid red green", "border-top: 1pt solid green"),
6062
],
6163
)
6264
def test_css_parse_invalid(invalid_css, remainder):
@@ -123,6 +125,65 @@ def test_css_side_shorthands(shorthand, expansions):
123125
assert_resolves(f"{shorthand}: 1pt 1pt 1pt 1pt 1pt", {})
124126

125127

128+
@pytest.mark.parametrize(
129+
"shorthand,sides",
130+
[
131+
("border-top", ["top"]),
132+
("border-right", ["right"]),
133+
("border-bottom", ["bottom"]),
134+
("border-left", ["left"]),
135+
("border", ["top", "right", "bottom", "left"]),
136+
],
137+
)
138+
def test_css_border_shorthand_sides(shorthand, sides):
139+
def create_border_dict(sides, color=None, style=None, width=None):
140+
resolved = {}
141+
for side in sides:
142+
if color:
143+
resolved[f"border-{side}-color"] = color
144+
if style:
145+
resolved[f"border-{side}-style"] = style
146+
if width:
147+
resolved[f"border-{side}-width"] = width
148+
return resolved
149+
150+
assert_resolves(
151+
f"{shorthand}: 1pt red solid", create_border_dict(sides, "red", "solid", "1pt")
152+
)
153+
154+
155+
@pytest.mark.parametrize(
156+
"prop, expected",
157+
[
158+
("1pt red solid", ("red", "solid", "1pt")),
159+
("red 1pt solid", ("red", "solid", "1pt")),
160+
("red solid 1pt", ("red", "solid", "1pt")),
161+
("solid 1pt red", ("red", "solid", "1pt")),
162+
("red solid", ("red", "solid", "1.500000pt")),
163+
# Note: color=black is not CSS conforming
164+
# (See https://drafts.csswg.org/css-backgrounds/#border-shorthands)
165+
("1pt solid", ("black", "solid", "1pt")),
166+
("1pt red", ("red", "none", "1pt")),
167+
("red", ("red", "none", "1.500000pt")),
168+
("1pt", ("black", "none", "1pt")),
169+
("solid", ("black", "solid", "1.500000pt")),
170+
# Sizes
171+
("1em", ("black", "none", "12pt")),
172+
],
173+
)
174+
def test_css_border_shorthands(prop, expected):
175+
color, style, width = expected
176+
177+
assert_resolves(
178+
f"border-left: {prop}",
179+
{
180+
"border-left-color": color,
181+
"border-left-style": style,
182+
"border-left-width": width,
183+
},
184+
)
185+
186+
126187
@pytest.mark.parametrize(
127188
"style,inherited,equiv",
128189
[

0 commit comments

Comments
 (0)