diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index 964f4b83866c9..0632387bd19b7 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -72,8 +72,8 @@ Styler :class:`.Styler` has been further developed in 1.4.0. The following enhancements have been made: - - Styling and formatting of indexes has been added, with :meth:`.Styler.apply_index`, :meth:`.Styler.applymap_index` and :meth:`.Styler.format_index`. These mirror the signature of the methods already used to style and format data values, and work with both HTML and LaTeX format (:issue:`41893`, :issue:`43101`). - - :meth:`.Styler.bar` introduces additional arguments to control alignment, display and colors (:issue:`26070`, :issue:`36419`, :issue:`43662`), and it also validates the input arguments ``width`` and ``height`` (:issue:`42511`). + - Styling and formatting of indexes has been added, with :meth:`.Styler.apply_index`, :meth:`.Styler.applymap_index` and :meth:`.Styler.format_index`. These mirror the signature of the methods already used to style and format data values, and work with both HTML, LaTeX and Excel format (:issue:`41893`, :issue:`43101`, :issue:`41993`, :issue:`41995`). + - :meth:`.Styler.bar` introduces additional arguments to control alignment and display (:issue:`26070`, :issue:`36419`), and it also validates the input arguments ``width`` and ``height`` (:issue:`42511`). - :meth:`.Styler.to_latex` introduces keyword argument ``environment``, which also allows a specific "longtable" entry through a separate jinja2 template (:issue:`41866`). - :meth:`.Styler.to_html` introduces keyword arguments ``sparse_index``, ``sparse_columns``, ``bold_headers``, ``caption``, ``max_rows`` and ``max_columns`` (:issue:`41946`, :issue:`43149`, :issue:`42972`). - Keyword arguments ``level`` and ``names`` added to :meth:`.Styler.hide_index` and :meth:`.Styler.hide_columns` for additional control of visibility of MultiIndexes and index names (:issue:`25475`, :issue:`43404`, :issue:`43346`) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 0c625e8a68db0..7f2905d9a63b9 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -7,6 +7,7 @@ import itertools import re from typing import ( + Any, Callable, Hashable, Iterable, @@ -70,6 +71,26 @@ def __init__( self.mergeend = mergeend +class CssExcelCell(ExcelCell): + def __init__( + self, + row: int, + col: int, + val, + style: dict | None, + css_styles: dict[tuple[int, int], list[tuple[str, Any]]] | None, + css_row: int, + css_col: int, + css_converter: Callable | None, + **kwargs, + ): + if css_styles and css_converter: + css = ";".join(a + ":" + str(v) for (a, v) in css_styles[css_row, css_col]) + style = css_converter(css) + + return super().__init__(row=row, col=col, val=val, style=style, **kwargs) + + class CSSToExcelConverter: """ A callable for converting CSS declarations to ExcelWriter styles @@ -472,12 +493,14 @@ def __init__( self.na_rep = na_rep if not isinstance(df, DataFrame): self.styler = df + self.styler._compute() # calculate applied styles df = df.data if style_converter is None: style_converter = CSSToExcelConverter() - self.style_converter = style_converter + self.style_converter: Callable | None = style_converter else: self.styler = None + self.style_converter = None self.df = df if cols is not None: @@ -567,22 +590,35 @@ def _format_header_mi(self) -> Iterable[ExcelCell]: ): values = levels.take(level_codes) for i, span_val in spans.items(): - spans_multiple_cells = span_val > 1 - yield ExcelCell( + mergestart, mergeend = None, None + if span_val > 1: + mergestart, mergeend = lnum, coloffset + i + span_val + yield CssExcelCell( row=lnum, col=coloffset + i + 1, val=values[i], style=self.header_style, - mergestart=lnum if spans_multiple_cells else None, - mergeend=( - coloffset + i + span_val if spans_multiple_cells else None - ), + css_styles=getattr(self.styler, "ctx_columns", None), + css_row=lnum, + css_col=i, + css_converter=self.style_converter, + mergestart=mergestart, + mergeend=mergeend, ) else: # Format in legacy format with dots to indicate levels. for i, values in enumerate(zip(*level_strs)): v = ".".join(map(pprint_thing, values)) - yield ExcelCell(lnum, coloffset + i + 1, v, self.header_style) + yield CssExcelCell( + row=lnum, + col=coloffset + i + 1, + val=v, + style=self.header_style, + css_styles=getattr(self.styler, "ctx_columns", None), + css_row=lnum, + css_col=i, + css_converter=self.style_converter, + ) self.rowcounter = lnum @@ -607,8 +643,15 @@ def _format_header_regular(self) -> Iterable[ExcelCell]: colnames = self.header for colindex, colname in enumerate(colnames): - yield ExcelCell( - self.rowcounter, colindex + coloffset, colname, self.header_style + yield CssExcelCell( + row=self.rowcounter, + col=colindex + coloffset, + val=colname, + style=self.header_style, + css_styles=getattr(self.styler, "ctx_columns", None), + css_row=0, + css_col=colindex, + css_converter=self.style_converter, ) def _format_header(self) -> Iterable[ExcelCell]: @@ -668,8 +711,16 @@ def _format_regular_rows(self) -> Iterable[ExcelCell]: index_values = self.df.index.to_timestamp() for idx, idxval in enumerate(index_values): - yield ExcelCell(self.rowcounter + idx, 0, idxval, self.header_style) - + yield CssExcelCell( + row=self.rowcounter + idx, + col=0, + val=idxval, + style=self.header_style, + css_styles=getattr(self.styler, "ctx_index", None), + css_row=idx, + css_col=0, + css_converter=self.style_converter, + ) coloffset = 1 else: coloffset = 0 @@ -721,18 +772,21 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: ) for i, span_val in spans.items(): - spans_multiple_cells = span_val > 1 - yield ExcelCell( + mergestart, mergeend = None, None + if span_val > 1: + mergestart = self.rowcounter + i + span_val - 1 + mergeend = gcolidx + yield CssExcelCell( row=self.rowcounter + i, col=gcolidx, val=values[i], style=self.header_style, - mergestart=( - self.rowcounter + i + span_val - 1 - if spans_multiple_cells - else None - ), - mergeend=gcolidx if spans_multiple_cells else None, + css_styles=getattr(self.styler, "ctx_index", None), + css_row=i, + css_col=gcolidx, + css_converter=self.style_converter, + mergestart=mergestart, + mergeend=mergeend, ) gcolidx += 1 @@ -740,11 +794,15 @@ def _format_hierarchical_rows(self) -> Iterable[ExcelCell]: # Format hierarchical rows with non-merged values. for indexcolvals in zip(*self.df.index): for idx, indexcolval in enumerate(indexcolvals): - yield ExcelCell( + yield CssExcelCell( row=self.rowcounter + idx, col=gcolidx, val=indexcolval, style=self.header_style, + css_styles=getattr(self.styler, "ctx_index", None), + css_row=idx, + css_col=gcolidx, + css_converter=self.style_converter, ) gcolidx += 1 @@ -756,22 +814,20 @@ def _has_aliases(self) -> bool: return is_list_like(self.header) def _generate_body(self, coloffset: int) -> Iterable[ExcelCell]: - if self.styler is None: - styles = None - else: - styles = self.styler._compute().ctx - if not styles: - styles = None - xlstyle = None - # Write the body of the frame data series by series. for colidx in range(len(self.columns)): series = self.df.iloc[:, colidx] for i, val in enumerate(series): - if styles is not None: - css = ";".join([a + ":" + str(v) for (a, v) in styles[i, colidx]]) - xlstyle = self.style_converter(css) - yield ExcelCell(self.rowcounter + i, colidx + coloffset, val, xlstyle) + yield CssExcelCell( + row=self.rowcounter + i, + col=colidx + coloffset, + val=val, + style=None, + css_styles=getattr(self.styler, "ctx", None), + css_row=i, + css_col=colidx, + css_converter=self.style_converter, + ) def get_formatted_cells(self) -> Iterable[ExcelCell]: for cell in itertools.chain(self._format_header(), self._format_body()): diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index ed996d32cf2fb..8a142aebd719d 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -7,163 +7,161 @@ from pandas.io.excel import ExcelWriter from pandas.io.formats.excel import ExcelFormatter +pytest.importorskip("jinja2") +# jinja2 is currently required for Styler.__init__(). Technically Styler.to_excel +# could compute styles and render to excel without jinja2, since there is no +# 'template' file, but this needs the import error to delayed until render time. + + +def assert_equal_cell_styles(cell1, cell2): + # TODO: should find a better way to check equality + assert cell1.alignment.__dict__ == cell2.alignment.__dict__ + assert cell1.border.__dict__ == cell2.border.__dict__ + assert cell1.fill.__dict__ == cell2.fill.__dict__ + assert cell1.font.__dict__ == cell2.font.__dict__ + assert cell1.number_format == cell2.number_format + assert cell1.protection.__dict__ == cell2.protection.__dict__ + @pytest.mark.parametrize( "engine", - [ - pytest.param( - "xlwt", - marks=pytest.mark.xfail( - reason="xlwt does not support openpyxl-compatible style dicts" - ), - ), - "xlsxwriter", - "openpyxl", - ], + ["xlsxwriter", "openpyxl"], ) -def test_styler_to_excel(request, engine): - def style(df): - # TODO: RGB colors not supported in xlwt - return DataFrame( - [ - ["font-weight: bold", "", ""], - ["", "color: blue", ""], - ["", "", "text-decoration: underline"], - ["border-style: solid", "", ""], - ["", "font-style: italic", ""], - ["", "", "text-align: right"], - ["background-color: red", "", ""], - ["number-format: 0%", "", ""], - ["", "", ""], - ["", "", ""], - ["", "", ""], - ], - index=df.index, - columns=df.columns, - ) - - def assert_equal_style(cell1, cell2, engine): - if engine in ["xlsxwriter", "openpyxl"]: - request.node.add_marker( - pytest.mark.xfail( - reason=( - f"GH25351: failing on some attribute comparisons in {engine}" - ) - ) - ) - # TODO: should find a better way to check equality - assert cell1.alignment.__dict__ == cell2.alignment.__dict__ - assert cell1.border.__dict__ == cell2.border.__dict__ - assert cell1.fill.__dict__ == cell2.fill.__dict__ - assert cell1.font.__dict__ == cell2.font.__dict__ - assert cell1.number_format == cell2.number_format - assert cell1.protection.__dict__ == cell2.protection.__dict__ - - def custom_converter(css): - # use bold iff there is custom style attached to the cell - if css.strip(" \n;"): - return {"font": {"bold": True}} - return {} - - pytest.importorskip("jinja2") +def test_styler_to_excel_unstyled(engine): + # compare DataFrame.to_excel and Styler.to_excel when no styles applied pytest.importorskip(engine) - - # Prepare spreadsheets - - df = DataFrame(np.random.randn(11, 3)) - with tm.ensure_clean(".xlsx" if engine != "xlwt" else ".xls") as path: + df = DataFrame(np.random.randn(2, 2)) + with tm.ensure_clean(".xlsx") as path: with ExcelWriter(path, engine=engine) as writer: - df.to_excel(writer, sheet_name="frame") + df.to_excel(writer, sheet_name="dataframe") df.style.to_excel(writer, sheet_name="unstyled") - styled = df.style.apply(style, axis=None) - styled.to_excel(writer, sheet_name="styled") - ExcelFormatter(styled, style_converter=custom_converter).write( - writer, sheet_name="custom" - ) - if engine not in ("openpyxl", "xlsxwriter"): - # For other engines, we only smoke test - return - openpyxl = pytest.importorskip("openpyxl") + openpyxl = pytest.importorskip("openpyxl") # test loading only with openpyxl wb = openpyxl.load_workbook(path) - # (1) compare DataFrame.to_excel and Styler.to_excel when unstyled - n_cells = 0 - for col1, col2 in zip(wb["frame"].columns, wb["unstyled"].columns): + for col1, col2 in zip(wb["dataframe"].columns, wb["unstyled"].columns): assert len(col1) == len(col2) for cell1, cell2 in zip(col1, col2): assert cell1.value == cell2.value - assert_equal_style(cell1, cell2, engine) - n_cells += 1 + assert_equal_cell_styles(cell1, cell2) + + +shared_style_params = [ + ( + "background-color: #111222", + ["fill", "fgColor", "rgb"], + {"xlsxwriter": "FF111222", "openpyxl": "00111222"}, + ), + ( + "color: #111222", + ["font", "color", "value"], + {"xlsxwriter": "FF111222", "openpyxl": "00111222"}, + ), + ("font-family: Arial;", ["font", "name"], "arial"), + ("font-weight: bold;", ["font", "b"], True), + ("font-style: italic;", ["font", "i"], True), + ("text-decoration: underline;", ["font", "u"], "single"), + ("number-format: $??,???.00;", ["number_format"], "$??,???.00"), + ("text-align: left;", ["alignment", "horizontal"], "left"), + ( + "vertical-align: bottom;", + ["alignment", "vertical"], + {"xlsxwriter": None, "openpyxl": "bottom"}, # xlsxwriter Fails + ), +] - # ensure iteration actually happened: - assert n_cells == (11 + 1) * (3 + 1) - # (2) check styling with default converter +@pytest.mark.parametrize( + "engine", + ["xlsxwriter", "openpyxl"], +) +@pytest.mark.parametrize("css, attrs, expected", shared_style_params) +def test_styler_to_excel_basic(engine, css, attrs, expected): + pytest.importorskip(engine) + df = DataFrame(np.random.randn(1, 1)) + styler = df.style.applymap(lambda x: css) - # TODO: openpyxl (as at 2.4) prefixes colors with 00, xlsxwriter with FF - alpha = "00" if engine == "openpyxl" else "FF" + 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") - n_cells = 0 - for col1, col2 in zip(wb["frame"].columns, wb["styled"].columns): - assert len(col1) == len(col2) - for cell1, cell2 in zip(col1, col2): - ref = f"{cell2.column}{cell2.row:d}" - # TODO: this isn't as strong a test as ideal; we should - # confirm that differences are exclusive - if ref == "B2": - assert not cell1.font.bold - assert cell2.font.bold - elif ref == "C3": - assert cell1.font.color.rgb != cell2.font.color.rgb - assert cell2.font.color.rgb == alpha + "0000FF" - elif ref == "D4": - assert cell1.font.underline != cell2.font.underline - assert cell2.font.underline == "single" - elif ref == "B5": - assert not cell1.border.left.style - assert ( - cell2.border.top.style - == cell2.border.right.style - == cell2.border.bottom.style - == cell2.border.left.style - == "medium" - ) - elif ref == "C6": - assert not cell1.font.italic - assert cell2.font.italic - elif ref == "D7": - assert cell1.alignment.horizontal != cell2.alignment.horizontal - assert cell2.alignment.horizontal == "right" - elif ref == "B8": - assert cell1.fill.fgColor.rgb != cell2.fill.fgColor.rgb - assert cell1.fill.patternType != cell2.fill.patternType - assert cell2.fill.fgColor.rgb == alpha + "FF0000" - assert cell2.fill.patternType == "solid" - elif ref == "B9": - assert cell1.number_format == "General" - assert cell2.number_format == "0%" - else: - assert_equal_style(cell1, cell2, engine) + openpyxl = pytest.importorskip("openpyxl") # test loading only with openpyxl + wb = openpyxl.load_workbook(path) - assert cell1.value == cell2.value - n_cells += 1 + # 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), getattr(s_cell, attr) - assert n_cells == (11 + 1) * (3 + 1) + 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 - # (3) check styling with custom converter - n_cells = 0 - for col1, col2 in zip(wb["frame"].columns, wb["custom"].columns): - assert len(col1) == len(col2) - for cell1, cell2 in zip(col1, col2): - ref = f"{cell2.column}{cell2.row:d}" - if ref in ("B2", "C3", "D4", "B5", "C6", "D7", "B8", "B9"): - assert not cell1.font.bold - assert cell2.font.bold - else: - assert_equal_style(cell1, cell2, engine) - assert cell1.value == cell2.value - n_cells += 1 +@pytest.mark.parametrize( + "engine", + ["xlsxwriter", "openpyxl"], +) +@pytest.mark.parametrize("css, attrs, expected", shared_style_params) +def test_styler_to_excel_basic_indexes(engine, css, attrs, expected): + pytest.importorskip(engine) + df = DataFrame(np.random.randn(1, 1)) + + styler = df.style + styler.applymap_index(lambda x: css, axis=0) + styler.applymap_index(lambda x: css, axis=1) + + null_styler = df.style + null_styler.applymap(lambda x: "null: css;") + null_styler.applymap_index(lambda x: "null: css;", axis=0) + null_styler.applymap_index(lambda x: "null: css;", axis=1) + + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine=engine) as writer: + null_styler.to_excel(writer, sheet_name="null_styled") + styler.to_excel(writer, sheet_name="styled") + + openpyxl = pytest.importorskip("openpyxl") # test loading only with openpyxl + wb = openpyxl.load_workbook(path) + + # test null styled index cells does not have expected styles + # test styled cell has expected styles + 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) + + if isinstance(expected, dict): + assert ui_cell is None or ui_cell != expected[engine] + assert si_cell == expected[engine] + assert uc_cell is None or uc_cell != expected[engine] + assert sc_cell == expected[engine] + else: + assert ui_cell is None or ui_cell != expected + assert si_cell == expected + assert uc_cell is None or uc_cell != expected + assert sc_cell == expected + + +def test_styler_custom_converter(): + openpyxl = pytest.importorskip("openpyxl") + + def custom_converter(css): + return {"font": {"color": {"rgb": "111222"}}} - assert n_cells == (11 + 1) * (3 + 1) + df = DataFrame(np.random.randn(1, 1)) + styler = df.style.applymap(lambda x: "color: #888999") + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine="openpyxl") as writer: + ExcelFormatter(styler, style_converter=custom_converter).write( + writer, sheet_name="custom" + ) + + wb = openpyxl.load_workbook(path) + assert wb["custom"].cell(2, 2).font.color.value == "00111222"