Skip to content

Commit d52b6b3

Browse files
authored
Fix Excel-specific border styles (#48660)
* Add tests * Add support for "hair" style * Support excel-specific border styles * Use black border if styled but color unspecified * Added documentation and whatsnew * Black linter * Fixed tests for Excel border color fallback * Revert style.ipynb metadata * Fix typo * Revert border-color default value * Revert "Fixed tests for Excel border color fallback" This reverts commit d2680ef. * Revert border color default tests * Updated whatsnew entry * Add method link to whatsnew * Add tests and fix case sensitivity * Apply black linter * Update v1.5.2.rst * Fix documentation typo * Test that border shorthand works * Append Excel styles to shorthand parsing * Update to find_stack_level call * Update v1.5.3.rst
1 parent a0c3eef commit d52b6b3

File tree

5 files changed

+100
-4
lines changed

5 files changed

+100
-4
lines changed

doc/source/user_guide/style.ipynb

+2-1
Original file line numberDiff line numberDiff line change
@@ -1594,8 +1594,9 @@
15941594
"\n",
15951595
"\n",
15961596
"- Only CSS2 named colors and hex colors of the form `#rgb` or `#rrggbb` are currently supported.\n",
1597-
"- The following pseudo CSS properties are also available to set excel specific style properties:\n",
1597+
"- The following pseudo CSS properties are also available to set Excel specific style properties:\n",
15981598
" - `number-format`\n",
1599+
" - `border-style` (for Excel-specific styles: \"hair\", \"mediumDashDot\", \"dashDotDot\", \"mediumDashDotDot\", \"dashDot\", \"slantDashDot\", or \"mediumDashed\")\n",
15991600
"\n",
16001601
"Table level styles, and data cell CSS-classes are not included in the export to Excel: individual cells must have their properties mapped by the `Styler.apply` and/or `Styler.applymap` methods."
16011602
]

doc/source/whatsnew/v1.5.3.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Fixed regressions
2424

2525
Bug fixes
2626
~~~~~~~~~
27-
-
27+
- Bug in :meth:`.Styler.to_excel` leading to error when unrecognized ``border-style`` (e.g. ``"hair"``) provided to Excel writers (:issue:`48649`)
2828
-
2929

3030
.. ---------------------------------------------------------------------------

pandas/io/formats/css.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ def expand(self, prop, value: str) -> Generator[tuple[str, str], None, None]:
105105
f"border{side}-width": "medium",
106106
}
107107
for token in tokens:
108-
if token in self.BORDER_STYLES:
108+
if token.lower() in self.BORDER_STYLES:
109109
border_declarations[f"border{side}-style"] = token
110-
elif any(ratio in token for ratio in self.BORDER_WIDTH_RATIOS):
110+
elif any(ratio in token.lower() for ratio in self.BORDER_WIDTH_RATIOS):
111111
border_declarations[f"border{side}-width"] = token
112112
else:
113113
border_declarations[f"border{side}-color"] = token
@@ -181,6 +181,13 @@ class CSSResolver:
181181
"ridge",
182182
"inset",
183183
"outset",
184+
"mediumdashdot",
185+
"dashdotdot",
186+
"hair",
187+
"mediumdashdotdot",
188+
"dashdot",
189+
"slantdashdot",
190+
"mediumdashed",
184191
]
185192

186193
SIDE_SHORTHANDS = {

pandas/io/formats/excel.py

+26
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,22 @@ class CSSToExcelConverter:
159159
"fantasy": 5, # decorative
160160
}
161161

162+
BORDER_STYLE_MAP = {
163+
style.lower(): style
164+
for style in [
165+
"dashed",
166+
"mediumDashDot",
167+
"dashDotDot",
168+
"hair",
169+
"dotted",
170+
"mediumDashDotDot",
171+
"double",
172+
"dashDot",
173+
"slantDashDot",
174+
"mediumDashed",
175+
]
176+
}
177+
162178
# NB: Most of the methods here could be classmethods, as only __init__
163179
# and __call__ make use of instance attributes. We leave them as
164180
# instancemethods so that users can easily experiment with extensions
@@ -306,6 +322,16 @@ def _border_style(self, style: str | None, width: str | None, color: str | None)
306322
if width_name in ("hair", "thin"):
307323
return "dashed"
308324
return "mediumDashed"
325+
elif style in self.BORDER_STYLE_MAP:
326+
# Excel-specific styles
327+
return self.BORDER_STYLE_MAP[style]
328+
else:
329+
warnings.warn(
330+
f"Unhandled border style format: {repr(style)}",
331+
CSSWarning,
332+
stacklevel=find_stack_level(),
333+
)
334+
return "none"
309335

310336
def _get_width_name(self, width_input: str | None) -> str | None:
311337
width = self._width_to_float(width_input)

pandas/tests/io/excel/test_style.py

+62
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ def test_styler_to_excel_unstyled(engine):
115115
["border", "left", "color", "rgb"],
116116
{"xlsxwriter": "FF111222", "openpyxl": "00111222"},
117117
),
118+
# Border styles
119+
(
120+
"border-left-style: hair; border-left-color: black",
121+
["border", "left", "style"],
122+
"hair",
123+
),
118124
]
119125

120126

@@ -196,6 +202,62 @@ def test_styler_to_excel_basic_indexes(engine, css, attrs, expected):
196202
assert sc_cell == expected
197203

198204

205+
# From https://openpyxl.readthedocs.io/en/stable/api/openpyxl.styles.borders.html
206+
# Note: Leaving behavior of "width"-type styles undefined; user should use border-width
207+
# instead
208+
excel_border_styles = [
209+
# "thin",
210+
"dashed",
211+
"mediumDashDot",
212+
"dashDotDot",
213+
"hair",
214+
"dotted",
215+
"mediumDashDotDot",
216+
# "medium",
217+
"double",
218+
"dashDot",
219+
"slantDashDot",
220+
# "thick",
221+
"mediumDashed",
222+
]
223+
224+
225+
@pytest.mark.parametrize(
226+
"engine",
227+
["xlsxwriter", "openpyxl"],
228+
)
229+
@pytest.mark.parametrize("border_style", excel_border_styles)
230+
def test_styler_to_excel_border_style(engine, border_style):
231+
css = f"border-left: {border_style} black thin"
232+
attrs = ["border", "left", "style"]
233+
expected = border_style
234+
235+
pytest.importorskip(engine)
236+
df = DataFrame(np.random.randn(1, 1))
237+
styler = df.style.applymap(lambda x: css)
238+
239+
with tm.ensure_clean(".xlsx") as path:
240+
with ExcelWriter(path, engine=engine) as writer:
241+
df.to_excel(writer, sheet_name="dataframe")
242+
styler.to_excel(writer, sheet_name="styled")
243+
244+
openpyxl = pytest.importorskip("openpyxl") # test loading only with openpyxl
245+
with contextlib.closing(openpyxl.load_workbook(path)) as wb:
246+
247+
# test unstyled data cell does not have expected styles
248+
# test styled cell has expected styles
249+
u_cell, s_cell = wb["dataframe"].cell(2, 2), wb["styled"].cell(2, 2)
250+
for attr in attrs:
251+
u_cell, s_cell = getattr(u_cell, attr, None), getattr(s_cell, attr)
252+
253+
if isinstance(expected, dict):
254+
assert u_cell is None or u_cell != expected[engine]
255+
assert s_cell == expected[engine]
256+
else:
257+
assert u_cell is None or u_cell != expected
258+
assert s_cell == expected
259+
260+
199261
def test_styler_custom_converter():
200262
openpyxl = pytest.importorskip("openpyxl")
201263

0 commit comments

Comments
 (0)