diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index ef7ff2d24009e..89b7950c5faf4 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -38,7 +38,7 @@ Other enhancements - :meth:`Series.ewm`, :meth:`DataFrame.ewm`, now support a ``method`` argument with a ``'table'`` option that performs the windowing operation over an entire :class:`DataFrame`. See :ref:`Window Overview ` for performance and functional benefits (:issue:`42273`) - Added ``sparse_index`` and ``sparse_columns`` keyword arguments to :meth:`.Styler.to_html` (:issue:`41946`) - Added keyword argument ``environment`` to :meth:`.Styler.to_latex` also allowing a specific "longtable" entry with a separate jinja2 template (:issue:`41866`) -- :meth:`.Styler.apply_index` and :meth:`.Styler.applymap_index` added to allow conditional styling of index and column header values (:issue:`41893`) +- :meth:`.Styler.apply_index` and :meth:`.Styler.applymap_index` added to allow conditional styling of index and column header values for HTML and LaTeX (:issue:`41893`) - :meth:`.GroupBy.cummin` and :meth:`.GroupBy.cummax` now support the argument ``skipna`` (:issue:`34047`) - diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index c45519bf31ff2..aa58b3abbd06c 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -570,7 +570,14 @@ def _translate_latex(self, d: dict) -> None: - Remove hidden indexes or reinsert missing th elements if part of multiindex or multirow sparsification (so that \multirow and \multicol work correctly). """ - d["head"] = [[col for col in row if col["is_visible"]] for row in d["head"]] + d["head"] = [ + [ + {**col, "cellstyle": self.ctx_columns[r, c - self.index.nlevels]} + for c, col in enumerate(row) + if col["is_visible"] + ] + for r, row in enumerate(d["head"]) + ] body = [] for r, row in enumerate(d["body"]): if all(self.hide_index_): @@ -582,8 +589,9 @@ def _translate_latex(self, d: dict) -> None: "display_value": col["display_value"] if col["is_visible"] else "", + "cellstyle": self.ctx_index[r, c] if col["is_visible"] else [], } - for col in row + for c, col in enumerate(row) if col["type"] == "th" ] @@ -1378,26 +1386,21 @@ def _parse_latex_header_span( >>> _parse_latex_header_span(cell, 't', 'c') '\\multicolumn{3}{c}{text}' """ + display_val = _parse_latex_cell_styles(cell["cellstyle"], cell["display_value"]) if "attributes" in cell: attrs = cell["attributes"] if 'colspan="' in attrs: colspan = attrs[attrs.find('colspan="') + 9 :] # len('colspan="') = 9 colspan = int(colspan[: colspan.find('"')]) - return ( - f"\\multicolumn{{{colspan}}}{{{multicol_align}}}" - f"{{{cell['display_value']}}}" - ) + return f"\\multicolumn{{{colspan}}}{{{multicol_align}}}{{{display_val}}}" elif 'rowspan="' in attrs: rowspan = attrs[attrs.find('rowspan="') + 9 :] rowspan = int(rowspan[: rowspan.find('"')]) - return ( - f"\\multirow[{multirow_align}]{{{rowspan}}}{{*}}" - f"{{{cell['display_value']}}}" - ) + return f"\\multirow[{multirow_align}]{{{rowspan}}}{{*}}{{{display_val}}}" if wrap: - return f"{{{cell['display_value']}}}" + return f"{{{display_val}}}" else: - return cell["display_value"] + return display_val def _parse_latex_options_strip(value: str | int | float, arg: str) -> str: diff --git a/pandas/tests/io/formats/style/test_to_latex.py b/pandas/tests/io/formats/style/test_to_latex.py index 501d9b43ff106..ac164f2de9fb2 100644 --- a/pandas/tests/io/formats/style/test_to_latex.py +++ b/pandas/tests/io/formats/style/test_to_latex.py @@ -414,17 +414,20 @@ def test_parse_latex_cell_styles_braces(wrap_arg, expected): def test_parse_latex_header_span(): - cell = {"attributes": 'colspan="3"', "display_value": "text"} + cell = {"attributes": 'colspan="3"', "display_value": "text", "cellstyle": []} expected = "\\multicolumn{3}{Y}{text}" assert _parse_latex_header_span(cell, "X", "Y") == expected - cell = {"attributes": 'rowspan="5"', "display_value": "text"} + cell = {"attributes": 'rowspan="5"', "display_value": "text", "cellstyle": []} expected = "\\multirow[X]{5}{*}{text}" assert _parse_latex_header_span(cell, "X", "Y") == expected - cell = {"display_value": "text"} + cell = {"display_value": "text", "cellstyle": []} assert _parse_latex_header_span(cell, "X", "Y") == "text" + cell = {"display_value": "text", "cellstyle": [("bfseries", "--rwrap")]} + assert _parse_latex_header_span(cell, "X", "Y") == "\\bfseries{text}" + def test_parse_latex_table_wrapping(styler): styler.set_table_styles( @@ -635,3 +638,40 @@ def test_longtable_caption_label(styler, caption, cap_exp, label, lab_exp): assert expected in styler.to_latex( environment="longtable", caption=caption, label=label ) + + +@pytest.mark.parametrize("index", [True, False]) +@pytest.mark.parametrize("columns", [True, False]) +def test_apply_map_header_render_mi(df, index, columns): + cidx = MultiIndex.from_tuples([("Z", "a"), ("Z", "b"), ("Y", "c")]) + ridx = MultiIndex.from_tuples([("A", "a"), ("A", "b"), ("B", "c")]) + df.loc[2, :] = [2, -2.22, "de"] + df = df.astype({"A": int}) + df.index, df.columns = ridx, cidx + styler = df.style + + func = lambda v: "bfseries: --rwrap" if "A" in v or "Z" in v or "c" in v else None + + if index: + styler.applymap_index(func, axis="index") + if columns: + styler.applymap_index(func, axis="columns") + + result = styler.to_latex() + + expected_index = dedent( + """\ + \\multirow[c]{2}{*}{\\bfseries{A}} & a & 0 & -0.610000 & ab \\\\ + & b & 1 & -1.220000 & cd \\\\ + B & \\bfseries{c} & 2 & -2.220000 & de \\\\ + """ + ) + assert (expected_index in result) is index + + expected_columns = dedent( + """\ + {} & {} & \\multicolumn{2}{r}{\\bfseries{Z}} & {Y} \\\\ + {} & {} & {a} & {b} & {\\bfseries{c}} \\\\ + """ + ) + assert (expected_columns in result) is columns