From 8c9dd9c2af8fc30f1e9f61627da1a3daf9b93e82 Mon Sep 17 00:00:00 2001 From: Thomas H Date: Tue, 29 Nov 2022 03:56:56 +0100 Subject: [PATCH] Fix Excel-specific border styles (#48660) Backport --- doc/source/user_guide/style.ipynb | 3 +- doc/source/whatsnew/v1.5.3.rst | 2 +- pandas/io/formats/css.py | 11 ++++- pandas/io/formats/excel.py | 26 ++++++++++++ pandas/tests/io/excel/test_style.py | 62 +++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 4 deletions(-) diff --git a/doc/source/user_guide/style.ipynb b/doc/source/user_guide/style.ipynb index 43021fcbc13fb..620e3806a33b5 100644 --- a/doc/source/user_guide/style.ipynb +++ b/doc/source/user_guide/style.ipynb @@ -1594,8 +1594,9 @@ "\n", "\n", "- Only CSS2 named colors and hex colors of the form `#rgb` or `#rrggbb` are currently supported.\n", - "- The following pseudo CSS properties are also available to set excel specific style properties:\n", + "- The following pseudo CSS properties are also available to set Excel specific style properties:\n", " - `number-format`\n", + " - `border-style` (for Excel-specific styles: \"hair\", \"mediumDashDot\", \"dashDotDot\", \"mediumDashDotDot\", \"dashDot\", \"slantDashDot\", or \"mediumDashed\")\n", "\n", "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." ] diff --git a/doc/source/whatsnew/v1.5.3.rst b/doc/source/whatsnew/v1.5.3.rst index 808b609700193..c739c2f3656c5 100644 --- a/doc/source/whatsnew/v1.5.3.rst +++ b/doc/source/whatsnew/v1.5.3.rst @@ -26,7 +26,7 @@ Fixed regressions Bug fixes ~~~~~~~~~ -- +- Bug in :meth:`.Styler.to_excel` leading to error when unrecognized ``border-style`` (e.g. ``"hair"``) provided to Excel writers (:issue:`48649`) - .. --------------------------------------------------------------------------- diff --git a/pandas/io/formats/css.py b/pandas/io/formats/css.py index cfc95bc9d9569..34626a0bdfdb7 100644 --- a/pandas/io/formats/css.py +++ b/pandas/io/formats/css.py @@ -105,9 +105,9 @@ def expand(self, prop, value: str) -> Generator[tuple[str, str], None, None]: f"border{side}-width": "medium", } for token in tokens: - if token in self.BORDER_STYLES: + if token.lower() in self.BORDER_STYLES: border_declarations[f"border{side}-style"] = token - elif any([ratio in token for ratio in self.BORDER_WIDTH_RATIOS]): + 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 @@ -181,6 +181,13 @@ class CSSResolver: "ridge", "inset", "outset", + "mediumdashdot", + "dashdotdot", + "hair", + "mediumdashdotdot", + "dashdot", + "slantdashdot", + "mediumdashed", ] SIDE_SHORTHANDS = { diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 19b766fd70675..b6e0f271f417b 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -159,6 +159,22 @@ class CSSToExcelConverter: "fantasy": 5, # decorative } + BORDER_STYLE_MAP = { + style.lower(): style + for style in [ + "dashed", + "mediumDashDot", + "dashDotDot", + "hair", + "dotted", + "mediumDashDotDot", + "double", + "dashDot", + "slantDashDot", + "mediumDashed", + ] + } + # NB: Most of the methods here could be classmethods, as only __init__ # and __call__ make use of instance attributes. We leave them as # 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) if width_name in ("hair", "thin"): return "dashed" return "mediumDashed" + elif style in self.BORDER_STYLE_MAP: + # Excel-specific styles + return self.BORDER_STYLE_MAP[style] + else: + warnings.warn( + f"Unhandled border style format: {repr(style)}", + CSSWarning, + stacklevel=find_stack_level(), + ) + return "none" def _get_width_name(self, width_input: str | None) -> str | None: width = self._width_to_float(width_input) diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 00f6ccb96a905..f26df440d263b 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -115,6 +115,12 @@ def test_styler_to_excel_unstyled(engine): ["border", "left", "color", "rgb"], {"xlsxwriter": "FF111222", "openpyxl": "00111222"}, ), + # Border styles + ( + "border-left-style: hair; border-left-color: black", + ["border", "left", "style"], + "hair", + ), ] @@ -196,6 +202,62 @@ def test_styler_to_excel_basic_indexes(engine, css, attrs, expected): assert sc_cell == expected +# From https://openpyxl.readthedocs.io/en/stable/api/openpyxl.styles.borders.html +# Note: Leaving behavior of "width"-type styles undefined; user should use border-width +# instead +excel_border_styles = [ + # "thin", + "dashed", + "mediumDashDot", + "dashDotDot", + "hair", + "dotted", + "mediumDashDotDot", + # "medium", + "double", + "dashDot", + "slantDashDot", + # "thick", + "mediumDashed", +] + + +@pytest.mark.parametrize( + "engine", + ["xlsxwriter", "openpyxl"], +) +@pytest.mark.parametrize("border_style", excel_border_styles) +def test_styler_to_excel_border_style(engine, border_style): + css = f"border-left: {border_style} black thin" + attrs = ["border", "left", "style"] + expected = border_style + + pytest.importorskip(engine) + df = DataFrame(np.random.randn(1, 1)) + styler = df.style.applymap(lambda x: css) + + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine=engine) as writer: + df.to_excel(writer, sheet_name="dataframe") + styler.to_excel(writer, sheet_name="styled") + + openpyxl = pytest.importorskip("openpyxl") # test loading only with openpyxl + with contextlib.closing(openpyxl.load_workbook(path)) as wb: + + # test unstyled data cell does not have expected styles + # 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, None), getattr(s_cell, attr) + + if isinstance(expected, dict): + assert u_cell is None or u_cell != expected[engine] + assert s_cell == expected[engine] + else: + assert u_cell is None or u_cell != expected + assert s_cell == expected + + def test_styler_custom_converter(): openpyxl = pytest.importorskip("openpyxl")