diff --git a/doc/source/whatsnew/v1.4.0.rst b/doc/source/whatsnew/v1.4.0.rst index 08f30f467dfa7..59d7d7b944e74 100644 --- a/doc/source/whatsnew/v1.4.0.rst +++ b/doc/source/whatsnew/v1.4.0.rst @@ -34,6 +34,7 @@ Other enhancements - Additional options added to :meth:`.Styler.bar` to control alignment and display, with keyword only arguments (:issue:`26070`, :issue:`36419`) - :meth:`Styler.bar` now validates the input argument ``width`` and ``height`` (:issue:`42511`) - :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 keyword argument ``environment`` to :meth:`.Styler.to_latex` also allowing a specific "longtable" entry with a separate jinja2 template (:issue:`41866`) - .. --------------------------------------------------------------------------- diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 91a301b665f7c..be16163eebcac 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -427,6 +427,7 @@ def to_latex( multirow_align: str = "c", multicol_align: str = "r", siunitx: bool = False, + environment: str | None = None, encoding: str | None = None, convert_css: bool = False, ): @@ -457,6 +458,8 @@ def to_latex( \\begin{table}[] \\ + + Cannot be used if ``environment`` is "longtable". hrules : bool, default False Set to `True` to add \\toprule, \\midrule and \\bottomrule from the {booktabs} LaTeX package. @@ -483,6 +486,12 @@ def to_latex( the left, centrally, or at the right. siunitx : bool, default False Set to ``True`` to structure LaTeX compatible with the {siunitx} package. + environment : str, optional + If given, the environment that will replace 'table' in ``\\begin{table}``. + If 'longtable' is specified then a more suitable template is + rendered. + + .. versionadded:: 1.4.0 encoding : str, default "utf-8" Character encoding setting. convert_css : bool, default False @@ -519,6 +528,8 @@ def to_latex( italic (with siunitx) | \\usepackage{etoolbox} | \\robustify\\itshape | \\sisetup{detect-all = true} *(within {document})* + environment \\usepackage{longtable} if arg is "longtable" + | or any other relevant environment package ===================== ========================================================== **Cell Styles** @@ -748,6 +759,10 @@ def to_latex( ) if position_float: + if environment == "longtable": + raise ValueError( + "`position_float` cannot be used in 'longtable' `environment`" + ) if position_float not in ["raggedright", "raggedleft", "centering"]: raise ValueError( f"`position_float` should be one of " @@ -788,6 +803,7 @@ def to_latex( sparse_columns=sparse_columns, multirow_align=multirow_align, multicol_align=multicol_align, + environment=environment, convert_css=convert_css, ) diff --git a/pandas/io/formats/templates/latex.tpl b/pandas/io/formats/templates/latex.tpl index fe081676d87af..ae341bbc29823 100644 --- a/pandas/io/formats/templates/latex.tpl +++ b/pandas/io/formats/templates/latex.tpl @@ -1,52 +1,5 @@ -{% if parse_wrap(table_styles, caption) %} -\begin{table} -{%- set position = parse_table(table_styles, 'position') %} -{%- if position is not none %} -[{{position}}] -{%- endif %} - -{% set position_float = parse_table(table_styles, 'position_float') %} -{% if position_float is not none%} -\{{position_float}} -{% endif %} -{% if caption and caption is string %} -\caption{% raw %}{{% endraw %}{{caption}}{% raw %}}{% endraw %} - -{% elif caption and caption is sequence %} -\caption[{{caption[1]}}]{% raw %}{{% endraw %}{{caption[0]}}{% raw %}}{% endraw %} - -{% endif %} -{% for style in table_styles %} -{% if style['selector'] not in ['position', 'position_float', 'caption', 'toprule', 'midrule', 'bottomrule', 'column_format'] %} -\{{style['selector']}}{{parse_table(table_styles, style['selector'])}} -{% endif %} -{% endfor %} -{% endif %} -\begin{tabular} -{%- set column_format = parse_table(table_styles, 'column_format') %} -{% raw %}{{% endraw %}{{column_format}}{% raw %}}{% endraw %} - -{% set toprule = parse_table(table_styles, 'toprule') %} -{% if toprule is not none %} -\{{toprule}} -{% endif %} -{% for row in head %} -{% for c in row %}{%- if not loop.first %} & {% endif %}{{parse_header(c, multirow_align, multicol_align, True)}}{% endfor %} \\ -{% endfor %} -{% set midrule = parse_table(table_styles, 'midrule') %} -{% if midrule is not none %} -\{{midrule}} -{% endif %} -{% for row in body %} -{% for c in row %}{% if not loop.first %} & {% endif %} - {%- if c.type == 'th' %}{{parse_header(c, multirow_align, multicol_align)}}{% else %}{{parse_cell(c.cellstyle, c.display_value, convert_css)}}{% endif %} -{%- endfor %} \\ -{% endfor %} -{% set bottomrule = parse_table(table_styles, 'bottomrule') %} -{% if bottomrule is not none %} -\{{bottomrule}} -{% endif %} -\end{tabular} -{% if parse_wrap(table_styles, caption) %} -\end{table} +{% if environment == "longtable" %} +{% include "latex_longtable.tpl" %} +{% else %} +{% include "latex_table.tpl" %} {% endif %} diff --git a/pandas/io/formats/templates/latex_longtable.tpl b/pandas/io/formats/templates/latex_longtable.tpl new file mode 100644 index 0000000000000..fdb45aea83c16 --- /dev/null +++ b/pandas/io/formats/templates/latex_longtable.tpl @@ -0,0 +1,73 @@ +\begin{longtable} +{%- set position = parse_table(table_styles, 'position') %} +{%- if position is not none %} +[{{position}}] +{%- endif %} +{%- set column_format = parse_table(table_styles, 'column_format') %} +{% raw %}{{% endraw %}{{column_format}}{% raw %}}{% endraw %} + +{% for style in table_styles %} +{% if style['selector'] not in ['position', 'position_float', 'caption', 'toprule', 'midrule', 'bottomrule', 'column_format', 'label'] %} +\{{style['selector']}}{{parse_table(table_styles, style['selector'])}} +{% endif %} +{% endfor %} +{% if caption and caption is string %} +\caption{% raw %}{{% endraw %}{{caption}}{% raw %}}{% endraw %} +{%- set label = parse_table(table_styles, 'label') %} +{%- if label is not none %} + \label{{label}} +{%- endif %} \\ +{% elif caption and caption is sequence %} +\caption[{{caption[1]}}]{% raw %}{{% endraw %}{{caption[0]}}{% raw %}}{% endraw %} +{%- set label = parse_table(table_styles, 'label') %} +{%- if label is not none %} + \label{{label}} +{%- endif %} \\ +{% endif %} +{% set toprule = parse_table(table_styles, 'toprule') %} +{% if toprule is not none %} +\{{toprule}} +{% endif %} +{% for row in head %} +{% for c in row %}{%- if not loop.first %} & {% endif %}{{parse_header(c, multirow_align, multicol_align, True)}}{% endfor %} \\ +{% endfor %} +{% set midrule = parse_table(table_styles, 'midrule') %} +{% if midrule is not none %} +\{{midrule}} +{% endif %} +\endfirsthead +{% if caption and caption is string %} +\caption[]{% raw %}{{% endraw %}{{caption}}{% raw %}}{% endraw %} \\ +{% elif caption and caption is sequence %} +\caption[]{% raw %}{{% endraw %}{{caption[0]}}{% raw %}}{% endraw %} \\ +{% endif %} +{% if toprule is not none %} +\{{toprule}} +{% endif %} +{% for row in head %} +{% for c in row %}{%- if not loop.first %} & {% endif %}{{parse_header(c, multirow_align, multicol_align, True)}}{% endfor %} \\ +{% endfor %} +{% if midrule is not none %} +\{{midrule}} +{% endif %} +\endhead +{% if midrule is not none %} +\{{midrule}} +{% endif %} +\multicolumn{% raw %}{{% endraw %}{{column_format|length}}{% raw %}}{% endraw %}{r}{Continued on next page} \\ +{% if midrule is not none %} +\{{midrule}} +{% endif %} +\endfoot +{% set bottomrule = parse_table(table_styles, 'bottomrule') %} +{% if bottomrule is not none %} +\{{bottomrule}} +{% endif %} +\endlastfoot +{% for row in body %} +{% for c in row %}{% if not loop.first %} & {% endif %} + {%- if c.type == 'th' %}{{parse_header(c, multirow_align, multicol_align)}}{% else %}{{parse_cell(c.cellstyle, c.display_value, convert_css)}}{% endif %} +{%- endfor %} \\ +{% endfor %} +\end{longtable} +{% raw %}{% endraw %} diff --git a/pandas/io/formats/templates/latex_table.tpl b/pandas/io/formats/templates/latex_table.tpl new file mode 100644 index 0000000000000..dfdd160351d02 --- /dev/null +++ b/pandas/io/formats/templates/latex_table.tpl @@ -0,0 +1,53 @@ +{% if environment or parse_wrap(table_styles, caption) %} +\begin{% raw %}{{% endraw %}{{environment if environment else "table"}}{% raw %}}{% endraw %} +{%- set position = parse_table(table_styles, 'position') %} +{%- if position is not none %} +[{{position}}] +{%- endif %} + +{% set position_float = parse_table(table_styles, 'position_float') %} +{% if position_float is not none%} +\{{position_float}} +{% endif %} +{% if caption and caption is string %} +\caption{% raw %}{{% endraw %}{{caption}}{% raw %}}{% endraw %} + +{% elif caption and caption is sequence %} +\caption[{{caption[1]}}]{% raw %}{{% endraw %}{{caption[0]}}{% raw %}}{% endraw %} + +{% endif %} +{% for style in table_styles %} +{% if style['selector'] not in ['position', 'position_float', 'caption', 'toprule', 'midrule', 'bottomrule', 'column_format'] %} +\{{style['selector']}}{{parse_table(table_styles, style['selector'])}} +{% endif %} +{% endfor %} +{% endif %} +\begin{tabular} +{%- set column_format = parse_table(table_styles, 'column_format') %} +{% raw %}{{% endraw %}{{column_format}}{% raw %}}{% endraw %} + +{% set toprule = parse_table(table_styles, 'toprule') %} +{% if toprule is not none %} +\{{toprule}} +{% endif %} +{% for row in head %} +{% for c in row %}{%- if not loop.first %} & {% endif %}{{parse_header(c, multirow_align, multicol_align, True)}}{% endfor %} \\ +{% endfor %} +{% set midrule = parse_table(table_styles, 'midrule') %} +{% if midrule is not none %} +\{{midrule}} +{% endif %} +{% for row in body %} +{% for c in row %}{% if not loop.first %} & {% endif %} + {%- if c.type == 'th' %}{{parse_header(c, multirow_align, multicol_align)}}{% else %}{{parse_cell(c.cellstyle, c.display_value, convert_css)}}{% endif %} +{%- endfor %} \\ +{% endfor %} +{% set bottomrule = parse_table(table_styles, 'bottomrule') %} +{% if bottomrule is not none %} +\{{bottomrule}} +{% endif %} +\end{tabular} +{% if environment or parse_wrap(table_styles, caption) %} +\end{% raw %}{{% endraw %}{{environment if environment else "table"}}{% raw %}}{% endraw %} + +{% endif %} diff --git a/pandas/tests/io/formats/style/test_to_latex.py b/pandas/tests/io/formats/style/test_to_latex.py index 55b17dc37adda..501d9b43ff106 100644 --- a/pandas/tests/io/formats/style/test_to_latex.py +++ b/pandas/tests/io/formats/style/test_to_latex.py @@ -120,6 +120,10 @@ def test_position_float_raises(styler): with pytest.raises(ValueError, match=msg): styler.to_latex(position_float="bad_string") + msg = "`position_float` cannot be used in 'longtable' `environment`" + with pytest.raises(ValueError, match=msg): + styler.to_latex(position_float="centering", environment="longtable") + @pytest.mark.parametrize("label", [(None, ""), ("text", "\\label{text}")]) @pytest.mark.parametrize("position", [(None, ""), ("h!", "{table}[h!]")]) @@ -322,7 +326,8 @@ def test_hidden_index(styler): assert styler.to_latex() == expected -def test_comprehensive(df): +@pytest.mark.parametrize("environment", ["table", "figure*", None]) +def test_comprehensive(df, 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")]) @@ -367,8 +372,8 @@ def test_comprehensive(df): \\end{tabular} \\end{table} """ - ) - assert s.format(precision=2).to_latex() == expected + ).replace("table", environment if environment else "table") + assert s.format(precision=2).to_latex(environment=environment) == expected def test_parse_latex_table_styles(styler): @@ -484,6 +489,31 @@ def test_parse_latex_css_conversion(css, expected): assert result == expected +@pytest.mark.parametrize( + "env, inner_env", + [ + (None, "tabular"), + ("table", "tabular"), + ("longtable", "longtable"), + ], +) +@pytest.mark.parametrize( + "convert, exp", [(True, "bfseries"), (False, "font-weightbold")] +) +def test_parse_latex_css_convert_minimal(styler, env, inner_env, convert, exp): + # parameters ensure longtable template is also tested + styler.highlight_max(props="font-weight:bold;") + result = styler.to_latex(convert_css=convert, environment=env) + expected = dedent( + f"""\ + 0 & 0 & \\{exp} -0.61 & ab \\\\ + 1 & \\{exp} 1 & -1.22 & \\{exp} cd \\\\ + \\end{{{inner_env}}} + """ + ) + assert expected in result + + def test_parse_latex_css_conversion_option(): css = [("command", "option--latex--wrap")] expected = [("command", "option--wrap")] @@ -505,3 +535,103 @@ def test_styler_object_after_render(styler): assert pre_render.table_styles == styler.table_styles assert pre_render.caption == styler.caption + + +def test_longtable_comprehensive(styler): + result = styler.to_latex( + environment="longtable", hrules=True, label="fig:A", caption=("full", "short") + ) + expected = dedent( + """\ + \\begin{longtable}{lrrl} + \\caption[short]{full} \\label{fig:A} \\\\ + \\toprule + {} & {A} & {B} & {C} \\\\ + \\midrule + \\endfirsthead + \\caption[]{full} \\\\ + \\toprule + {} & {A} & {B} & {C} \\\\ + \\midrule + \\endhead + \\midrule + \\multicolumn{4}{r}{Continued on next page} \\\\ + \\midrule + \\endfoot + \\bottomrule + \\endlastfoot + 0 & 0 & -0.61 & ab \\\\ + 1 & 1 & -1.22 & cd \\\\ + \\end{longtable} + """ + ) + assert result == expected + + +def test_longtable_minimal(styler): + result = styler.to_latex(environment="longtable") + expected = dedent( + """\ + \\begin{longtable}{lrrl} + {} & {A} & {B} & {C} \\\\ + \\endfirsthead + {} & {A} & {B} & {C} \\\\ + \\endhead + \\multicolumn{4}{r}{Continued on next page} \\\\ + \\endfoot + \\endlastfoot + 0 & 0 & -0.61 & ab \\\\ + 1 & 1 & -1.22 & cd \\\\ + \\end{longtable} + """ + ) + assert result == expected + + +@pytest.mark.parametrize( + "sparse, exp", + [ + (True, "{} & \\multicolumn{2}{r}{A} & {B}"), + (False, "{} & {A} & {A} & {B}"), + ], +) +def test_longtable_multiindex_columns(df, sparse, exp): + cidx = MultiIndex.from_tuples([("A", "a"), ("A", "b"), ("B", "c")]) + df.columns = cidx + expected = dedent( + f"""\ + \\begin{{longtable}}{{lrrl}} + {exp} \\\\ + {{}} & {{a}} & {{b}} & {{c}} \\\\ + \\endfirsthead + {exp} \\\\ + {{}} & {{a}} & {{b}} & {{c}} \\\\ + \\endhead + """ + ) + assert expected in df.style.to_latex(environment="longtable", sparse_columns=sparse) + + +@pytest.mark.parametrize( + "caption, cap_exp", + [ + ("full", ("{full}", "")), + (("full", "short"), ("{full}", "[short]")), + ], +) +@pytest.mark.parametrize("label, lab_exp", [(None, ""), ("tab:A", " \\label{tab:A}")]) +def test_longtable_caption_label(styler, caption, cap_exp, label, lab_exp): + cap_exp1 = f"\\caption{cap_exp[1]}{cap_exp[0]}" + cap_exp2 = f"\\caption[]{cap_exp[0]}" + + expected = dedent( + f"""\ + {cap_exp1}{lab_exp} \\\\ + {{}} & {{A}} & {{B}} & {{C}} \\\\ + \\endfirsthead + {cap_exp2} \\\\ + """ + ) + assert expected in styler.to_latex( + environment="longtable", caption=caption, label=label + )