diff --git a/doc/source/user_guide/style.ipynb b/doc/source/user_guide/style.ipynb index c3ff0ec286968..96589b74fcf6b 100644 --- a/doc/source/user_guide/style.ipynb +++ b/doc/source/user_guide/style.ipynb @@ -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", @@ -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", diff --git a/doc/source/whatsnew/v1.5.0.rst b/doc/source/whatsnew/v1.5.0.rst index b559d8eb463a1..00adacfdbc776 100644 --- a/doc/source/whatsnew/v1.5.0.rst +++ b/doc/source/whatsnew/v1.5.0.rst @@ -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: @@ -50,8 +50,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: diff --git a/pandas/io/formats/css.py b/pandas/io/formats/css.py index 956951a6f2f3d..5335887785881 100644 --- a/pandas/io/formats/css.py +++ b/pandas/io/formats/css.py @@ -4,6 +4,10 @@ from __future__ import annotations import re +from typing import ( + Callable, + Generator, +) import warnings @@ -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)] @@ -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': @@ -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], @@ -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: @@ -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") diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 81076ef11e727..5371957365eae 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -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' @@ -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 diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 1a92cc9672bfa..c31e8ec022dcd 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -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"}, + ), ] @@ -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] @@ -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] diff --git a/pandas/tests/io/formats/test_css.py b/pandas/tests/io/formats/test_css.py index 8465d116805c7..c93694481ef53 100644 --- a/pandas/tests/io/formats/test_css.py +++ b/pandas/tests/io/formats/test_css.py @@ -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): @@ -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", [ diff --git a/pandas/tests/io/formats/test_to_excel.py b/pandas/tests/io/formats/test_to_excel.py index 968ad63eaceef..08ba05e7ab9ce 100644 --- a/pandas/tests/io/formats/test_to_excel.py +++ b/pandas/tests/io/formats/test_to_excel.py @@ -187,6 +187,10 @@ "border-top-style: solid; border-top-color: #06c", {"border": {"top": {"style": "medium", "color": "0066CC"}}}, ), + ( + "border-top-color: blue", + {"border": {"top": {"color": "0000FF", "style": "none"}}}, + ), # ALIGNMENT # - horizontal ("text-align: center", {"alignment": {"horizontal": "center"}}), @@ -288,7 +292,8 @@ def test_css_to_excel_good_colors(input_color, output_color): expected["font"] = {"color": output_color} expected["border"] = { - k: {"color": output_color} for k in ("top", "right", "bottom", "left") + k: {"color": output_color, "style": "none"} + for k in ("top", "right", "bottom", "left") } with tm.assert_produces_warning(None):