Skip to content

BUG/ENH: Translate CSS border properties for Styler.to_excel #45312

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 30 commits into from
Feb 11, 2022
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9543045
BUG/ENH: Improve border CSS parsing (GH42276)
tehunter Jan 9, 2022
d071893
Fix issue where width 'pt' is not recognized and fix border shorthand…
tehunter Jan 10, 2022
fb31af3
BUG: Xlsxwriter crashes on to_excel with only 'border-color' (GH30008…
tehunter Jan 10, 2022
9827ad9
DOC: Update documentation to resolve GH42267
tehunter Jan 10, 2022
67b9fff
Merge remote-tracking branch 'upstream/master' into tmp
tehunter Jan 11, 2022
aaac09a
CLN: Comply with PEP8 checks
tehunter Jan 11, 2022
f6af38a
CLN: Remove whitespace
tehunter Jan 11, 2022
9af2869
Fixes from pre-commit [automated commit]
tehunter Jan 11, 2022
13c3fa6
BUG: Fixed issue with invalid colors generating borders
tehunter Jan 11, 2022
3d067d0
Merged origin/style-border-css
tehunter Jan 11, 2022
18a6a41
Fixes from pre-commit [automated commit]
tehunter Jan 11, 2022
a5cd011
Merge remote-tracking branch 'upstream/master' into style-border-css
tehunter Jan 11, 2022
f3b1d13
Merge branch 'style-border-css' of https://github.com/tehunter/pandas…
tehunter Jan 11, 2022
905a6ca
Cleaned code and improved test (GH45312)
tehunter Jan 12, 2022
cce6457
Merge remote-tracking branch 'upstream/master' into style-border-css
tehunter Jan 12, 2022
7e6f5f1
Fixes from pre-commit [automated commit]
tehunter Jan 12, 2022
337b2f4
Clean border_expander
tehunter Jan 14, 2022
cd28198
Eliminate redundant test expansions
tehunter Jan 14, 2022
ed5d5ca
Merge branch 'style-border-css' of https://github.com/tehunter/pandas…
tehunter Jan 14, 2022
7caca16
Fixes from pre-commit [automated commit]
tehunter Jan 14, 2022
50f93a1
Updated docstrings and what's new entry
tehunter Jan 16, 2022
88590dc
Merge branch 'main' into style-border-css
jreback Jan 17, 2022
4785dc4
Update doc/source/whatsnew/v1.5.0.rst
tehunter Jan 17, 2022
93cfb92
Update pandas/tests/io/formats/test_css.py
tehunter Jan 17, 2022
dec6920
Resolved doc-build check
tehunter Jan 17, 2022
17dc457
Fixes from pre-commit [automated commit]
tehunter Jan 17, 2022
21bb3f6
Merge branch 'main' into style-border-css
attack68 Feb 8, 2022
15e8a5d
Merge remote-tracking branch 'upstream/main' into style-border-css
tehunter Feb 8, 2022
d9f2ed3
Merge branch 'style-border-css' of https://github.com/tehunter/pandas…
tehunter Feb 8, 2022
fa15e0a
Pre-commit cleanup
tehunter Feb 10, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
7 changes: 5 additions & 2 deletions doc/source/whatsnew/v1.5.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Styler
^^^^^^

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

.. _whatsnew_150.enhancements.enhancement2:

Expand All @@ -43,8 +44,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`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just put this in the I/O section


.. _whatsnew_150.notable_bug_fixes.notable_bug_fix2:

Expand Down
110 changes: 109 additions & 1 deletion pandas/io/formats/css.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import re
from typing import Callable
import warnings


Expand All @@ -13,8 +14,36 @@ class CSSWarning(UserWarning):
"""


def _side_expander(prop_fmt: 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

Notes
-----
Description of [shorthand](https://developer.mozilla.org/en-US/docs/Web/CSS/Shorthand_properties) syntax
"""
def expand(self, prop, value: str):
"""
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) -> tuple[str, str]:
"""
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 @@ -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 @@ -235,13 +235,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 @@ -256,14 +257,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 @@ -68,6 +68,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 @@ -93,7 +131,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 @@ -134,8 +172,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_shorthands(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
7 changes: 6 additions & 1 deletion pandas/tests/io/formats/test_to_excel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}),
Expand Down Expand Up @@ -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):
Expand Down