diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index a85b84aad9f94..be38937741f93 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -78,6 +78,7 @@ Styler - :meth:`.Styler.to_html` introduces keyword arguments ``sparse_index``, ``sparse_columns``, ``bold_headers``, ``caption`` (:issue:`41946`, :issue:`43149`). - 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`) - Global options have been extended to configure default ``Styler`` properties including formatting and encoding and mathjax options and LaTeX (:issue:`41395`) + - Naive sparsification is now possible for LaTeX without the multirow package (:issue:`43369`) Formerly Styler relied on ``display.html.use_mathjax``, which has now been replaced by ``styler.html.mathjax``. diff --git a/pandas/core/config_init.py b/pandas/core/config_init.py index 83e5f890ff3c7..4e2c836a9db8d 100644 --- a/pandas/core/config_init.py +++ b/pandas/core/config_init.py @@ -889,7 +889,7 @@ def register_converter_cb(key): "latex.multirow_align", "c", styler_multirow_align, - validator=is_one_of_factory(["c", "t", "b"]), + validator=is_one_of_factory(["c", "t", "b", "naive"]), ) cf.register_option( diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 616e533bdc10f..92bd4bcc7ced1 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -520,10 +520,13 @@ def to_latex( Whether to sparsify the display of a hierarchical index. Setting to False will display each explicit level element in a hierarchical key for each column. Defaults to ``pandas.options.styler.sparse.columns`` value. - multirow_align : {"c", "t", "b"}, optional + multirow_align : {"c", "t", "b", "naive"}, optional If sparsifying hierarchical MultiIndexes whether to align text centrally, - at the top or bottom. If not given defaults to - ``pandas.options.styler.latex.multirow_align`` + at the top or bottom using the multirow package. If not given defaults to + ``pandas.options.styler.latex.multirow_align``. If "naive" is given renders + without multirow. + + .. versionchanged:: 1.4.0 multicol_align : {"r", "c", "l"}, optional If sparsifying hierarchical MultiIndex columns whether to align text at the left, centrally, or at the right. If not given defaults to diff --git a/pandas/io/formats/style_render.py b/pandas/io/formats/style_render.py index 264ea803db7f3..6be75262a64ca 100644 --- a/pandas/io/formats/style_render.py +++ b/pandas/io/formats/style_render.py @@ -1412,6 +1412,8 @@ def _parse_latex_header_span( colspan = int(colspan[: colspan.find('"')]) return f"\\multicolumn{{{colspan}}}{{{multicol_align}}}{{{display_val}}}" elif 'rowspan="' in attrs: + if multirow_align == "naive": + return display_val rowspan = attrs[attrs.find('rowspan="') + 9 :] rowspan = int(rowspan[: rowspan.find('"')]) return f"\\multirow[{multirow_align}]{{{rowspan}}}{{*}}{{{display_val}}}" diff --git a/pandas/tests/io/formats/style/test_to_latex.py b/pandas/tests/io/formats/style/test_to_latex.py index badb676ef2e97..3df94c4eb0a02 100644 --- a/pandas/tests/io/formats/style/test_to_latex.py +++ b/pandas/tests/io/formats/style/test_to_latex.py @@ -24,6 +24,13 @@ def df(): return DataFrame({"A": [0, 1], "B": [-0.61, -1.22], "C": ["ab", "cd"]}) +@pytest.fixture +def df_ext(): + return DataFrame( + {"A": [0, 1, 2], "B": [-0.61, -1.22, -2.22], "C": ["ab", "cd", "de"]} + ) + + @pytest.fixture def styler(df): return Styler(df, uuid_len=0, precision=2) @@ -210,11 +217,9 @@ def test_multiindex_columns(df): assert expected == s.to_latex(sparse_columns=False) -def test_multiindex_row(df): +def test_multiindex_row(df_ext): ridx = MultiIndex.from_tuples([("A", "a"), ("A", "b"), ("B", "c")]) - df.loc[2, :] = [2, -2.22, "de"] - df = df.astype({"A": int}) - df.index = ridx + df_ext.index = ridx expected = dedent( """\ \\begin{tabular}{llrrl} @@ -225,8 +230,9 @@ def test_multiindex_row(df): \\end{tabular} """ ) - s = df.style.format(precision=2) - assert expected == s.to_latex() + styler = df_ext.style.format(precision=2) + result = styler.to_latex() + assert expected == result # non-sparse expected = dedent( @@ -239,15 +245,32 @@ def test_multiindex_row(df): \\end{tabular} """ ) - assert expected == s.to_latex(sparse_index=False) + result = styler.to_latex(sparse_index=False) + assert expected == result + + +def test_multirow_naive(df_ext): + ridx = MultiIndex.from_tuples([("X", "x"), ("X", "y"), ("Y", "z")]) + df_ext.index = ridx + expected = dedent( + """\ + \\begin{tabular}{llrrl} + & & A & B & C \\\\ + X & x & 0 & -0.61 & ab \\\\ + & y & 1 & -1.22 & cd \\\\ + Y & z & 2 & -2.22 & de \\\\ + \\end{tabular} + """ + ) + styler = df_ext.style.format(precision=2) + result = styler.to_latex(multirow_align="naive") + assert expected == result -def test_multiindex_row_and_col(df): +def test_multiindex_row_and_col(df_ext): 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 + df_ext.index, df_ext.columns = ridx, cidx expected = dedent( """\ \\begin{tabular}{llrrl} @@ -259,8 +282,9 @@ def test_multiindex_row_and_col(df): \\end{tabular} """ ) - s = df.style.format(precision=2) - assert s.to_latex(multirow_align="b", multicol_align="l") == expected + styler = df_ext.style.format(precision=2) + result = styler.to_latex(multirow_align="b", multicol_align="l") + assert result == expected # non-sparse expected = dedent( @@ -274,16 +298,15 @@ def test_multiindex_row_and_col(df): \\end{tabular} """ ) - assert s.to_latex(sparse_index=False, sparse_columns=False) == expected + result = styler.to_latex(sparse_index=False, sparse_columns=False) + assert result == expected -def test_multi_options(df): +def test_multi_options(df_ext): 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.format(precision=2) + df_ext.index, df_ext.columns = ridx, cidx + styler = df_ext.style.format(precision=2) expected = dedent( """\ @@ -292,7 +315,8 @@ def test_multi_options(df): \\multirow[c]{2}{*}{A} & a & 0 & -0.61 & ab \\\\ """ ) - assert expected in styler.to_latex() + result = styler.to_latex() + assert expected in result with option_context("styler.latex.multicol_align", "l"): assert " & & \\multicolumn{2}{l}{Z} & Y \\\\" in styler.to_latex() @@ -311,30 +335,25 @@ def test_multiindex_columns_hidden(): assert "{tabular}{lrrr}" in s.to_latex() -def test_sparse_options(df): +@pytest.mark.parametrize( + "option, value", + [ + ("styler.sparse.index", True), + ("styler.sparse.index", False), + ("styler.sparse.columns", True), + ("styler.sparse.columns", False), + ], +) +def test_sparse_options(df_ext, option, value): 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.index, df.columns = ridx, cidx - s = df.style - - latex1 = s.to_latex() - - with option_context("styler.sparse.index", True): - latex2 = s.to_latex() - assert latex1 == latex2 - - with option_context("styler.sparse.index", False): - latex2 = s.to_latex() - assert latex1 != latex2 + df_ext.index, df_ext.columns = ridx, cidx + styler = df_ext.style - with option_context("styler.sparse.columns", True): - latex2 = s.to_latex() - assert latex1 == latex2 - - with option_context("styler.sparse.columns", False): - latex2 = s.to_latex() - assert latex1 != latex2 + latex1 = styler.to_latex() + with option_context(option, value): + latex2 = styler.to_latex() + assert (latex1 == latex2) is value def test_hidden_index(styler): @@ -352,16 +371,14 @@ def test_hidden_index(styler): @pytest.mark.parametrize("environment", ["table", "figure*", None]) -def test_comprehensive(df, environment): +def test_comprehensive(df_ext, environment): # test as many low level features simultaneously as possible 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 - s = df.style - s.set_caption("mycap") - s.set_table_styles( + df_ext.index, df_ext.columns = ridx, cidx + stlr = df_ext.style + stlr.set_caption("mycap") + stlr.set_table_styles( [ {"selector": "label", "props": ":{fig§item}"}, {"selector": "position", "props": ":h!"}, @@ -373,8 +390,8 @@ def test_comprehensive(df, environment): {"selector": "rowcolors", "props": ":{3}{pink}{}"}, # custom command ] ) - s.highlight_max(axis=0, props="textbf:--rwrap;cellcolor:[rgb]{1,1,0.6}--rwrap") - s.highlight_max(axis=None, props="Huge:--wrap;", subset=[("Z", "a"), ("Z", "b")]) + stlr.highlight_max(axis=0, props="textbf:--rwrap;cellcolor:[rgb]{1,1,0.6}--rwrap") + stlr.highlight_max(axis=None, props="Huge:--wrap;", subset=[("Z", "a"), ("Z", "b")]) expected = ( """\ @@ -398,7 +415,8 @@ def test_comprehensive(df, environment): \\end{table} """ ).replace("table", environment if environment else "table") - assert s.format(precision=2).to_latex(environment=environment) == expected + result = stlr.format(precision=2).to_latex(environment=environment) + assert result == expected def test_environment_option(styler): @@ -687,13 +705,11 @@ def test_longtable_caption_label(styler, caption, cap_exp, label, lab_exp): (False, False), ], ) -def test_apply_map_header_render_mi(df, index, columns, siunitx): +def test_apply_map_header_render_mi(df_ext, index, columns, siunitx): 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 + df_ext.index, df_ext.columns = ridx, cidx + styler = df_ext.style func = lambda v: "bfseries: --rwrap" if "A" in v or "Z" in v or "c" in v else None