Skip to content

Commit 7bb2d17

Browse files
attack68meeseeksmachine
authored andcommitted
Backport PR pandas-dev#45138: ENH: add cline to Styler.to_latex
1 parent dd9ff44 commit 7bb2d17

File tree

6 files changed

+189
-12
lines changed

6 files changed

+189
-12
lines changed

doc/source/whatsnew/v1.4.0.rst

+1
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ There are also some LaTeX specific enhancements:
105105

106106
- :meth:`.Styler.to_latex` introduces keyword argument ``environment``, which also allows a specific "longtable" entry through a separate jinja2 template (:issue:`41866`).
107107
- Naive sparsification is now possible for LaTeX without the necessity of including the multirow package (:issue:`43369`)
108+
- *cline* support has been added for MultiIndex row sparsification through a keyword argument (:issue:`45138`)
108109

109110
.. _whatsnew_140.enhancements.pyarrow_csv_engine:
110111

pandas/io/formats/style.py

+18
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,7 @@ def to_latex(
494494
position: str | None = None,
495495
position_float: str | None = None,
496496
hrules: bool | None = None,
497+
clines: str | None = None,
497498
label: str | None = None,
498499
caption: str | tuple | None = None,
499500
sparse_index: bool | None = None,
@@ -542,6 +543,22 @@ def to_latex(
542543
Defaults to ``pandas.options.styler.latex.hrules``, which is `False`.
543544
544545
.. versionchanged:: 1.4.0
546+
clines : str, optional
547+
Use to control adding \\cline commands for the index labels separation.
548+
Possible values are:
549+
550+
- `None`: no cline commands are added (default).
551+
- `"all;data"`: a cline is added for every index value extending the
552+
width of the table, including data entries.
553+
- `"all;index"`: as above with lines extending only the width of the
554+
index entries.
555+
- `"skip-last;data"`: a cline is added for each index value except the
556+
last level (which is never sparsified), extending the widtn of the
557+
table.
558+
- `"skip-last;index"`: as above with lines extending only the width of the
559+
index entries.
560+
561+
.. versionadded:: 1.4.0
545562
label : str, optional
546563
The LaTeX label included as: \\label{<label>}.
547564
This is used with \\ref{<label>} in the main .tex file.
@@ -911,6 +928,7 @@ def to_latex(
911928
environment=environment,
912929
convert_css=convert_css,
913930
siunitx=siunitx,
931+
clines=clines,
914932
)
915933

916934
encoding = encoding or get_option("styler.render.encoding")

pandas/io/formats/style_render.py

+48-12
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,16 @@ def _render_html(
164164
html_style_tpl=self.template_html_style,
165165
)
166166

167-
def _render_latex(self, sparse_index: bool, sparse_columns: bool, **kwargs) -> str:
167+
def _render_latex(
168+
self, sparse_index: bool, sparse_columns: bool, clines: str | None, **kwargs
169+
) -> str:
168170
"""
169171
Render a Styler in latex format
170172
"""
171173
self._compute()
172174

173175
d = self._translate(sparse_index, sparse_columns, blank="")
174-
self._translate_latex(d)
176+
self._translate_latex(d, clines=clines)
175177

176178
self.template_latex.globals["parse_wrap"] = _parse_latex_table_wrapping
177179
self.template_latex.globals["parse_table"] = _parse_latex_table_styles
@@ -257,13 +259,19 @@ def _translate(
257259
head = self._translate_header(sparse_cols, max_cols)
258260
d.update({"head": head})
259261

262+
# for sparsifying a MultiIndex and for use with latex clines
263+
idx_lengths = _get_level_lengths(
264+
self.index, sparse_index, max_rows, self.hidden_rows
265+
)
266+
d.update({"index_lengths": idx_lengths})
267+
260268
self.cellstyle_map: DefaultDict[tuple[CSSPair, ...], list[str]] = defaultdict(
261269
list
262270
)
263271
self.cellstyle_map_index: DefaultDict[
264272
tuple[CSSPair, ...], list[str]
265273
] = defaultdict(list)
266-
body = self._translate_body(sparse_index, max_rows, max_cols)
274+
body = self._translate_body(idx_lengths, max_rows, max_cols)
267275
d.update({"body": body})
268276

269277
ctx_maps = {
@@ -515,7 +523,7 @@ def _generate_index_names_row(self, iter: tuple, max_cols: int, col_lengths: dic
515523

516524
return index_names + column_blanks
517525

518-
def _translate_body(self, sparsify_index: bool, max_rows: int, max_cols: int):
526+
def _translate_body(self, idx_lengths: dict, max_rows: int, max_cols: int):
519527
"""
520528
Build each <tr> within table <body> as a list
521529
@@ -537,11 +545,6 @@ def _translate_body(self, sparsify_index: bool, max_rows: int, max_cols: int):
537545
body : list
538546
The associated HTML elements needed for template rendering.
539547
"""
540-
# for sparsifying a MultiIndex
541-
idx_lengths = _get_level_lengths(
542-
self.index, sparsify_index, max_rows, self.hidden_rows
543-
)
544-
545548
rlabels = self.data.index.tolist()
546549
if not isinstance(self.data.index, MultiIndex):
547550
rlabels = [[x] for x in rlabels]
@@ -738,7 +741,7 @@ def _generate_body_row(
738741

739742
return index_headers + data
740743

741-
def _translate_latex(self, d: dict) -> None:
744+
def _translate_latex(self, d: dict, clines: str | None) -> None:
742745
r"""
743746
Post-process the default render dict for the LaTeX template format.
744747
@@ -749,10 +752,10 @@ def _translate_latex(self, d: dict) -> None:
749752
or multirow sparsification (so that \multirow and \multicol work correctly).
750753
"""
751754
index_levels = self.index.nlevels
752-
visible_index_levels = index_levels - sum(self.hide_index_)
755+
visible_index_level_n = index_levels - sum(self.hide_index_)
753756
d["head"] = [
754757
[
755-
{**col, "cellstyle": self.ctx_columns[r, c - visible_index_levels]}
758+
{**col, "cellstyle": self.ctx_columns[r, c - visible_index_level_n]}
756759
for c, col in enumerate(row)
757760
if col["is_visible"]
758761
]
@@ -790,6 +793,39 @@ def _translate_latex(self, d: dict) -> None:
790793
body.append(row_body_headers + row_body_cells)
791794
d["body"] = body
792795

796+
# clines are determined from info on index_lengths and hidden_rows and input
797+
# to a dict defining which row clines should be added in the template.
798+
if clines not in [
799+
None,
800+
"all;data",
801+
"all;index",
802+
"skip-last;data",
803+
"skip-last;index",
804+
]:
805+
raise ValueError(
806+
f"`clines` value of {clines} is invalid. Should either be None or one "
807+
f"of 'all;data', 'all;index', 'skip-last;data', 'skip-last;index'."
808+
)
809+
elif clines is not None:
810+
data_len = len(row_body_cells) if "data" in clines else 0
811+
812+
d["clines"] = defaultdict(list)
813+
visible_row_indexes: list[int] = [
814+
r for r in range(len(self.data.index)) if r not in self.hidden_rows
815+
]
816+
visible_index_levels: list[int] = [
817+
i for i in range(index_levels) if not self.hide_index_[i]
818+
]
819+
for rn, r in enumerate(visible_row_indexes):
820+
for lvln, lvl in enumerate(visible_index_levels):
821+
if lvl == index_levels - 1 and "skip-last" in clines:
822+
continue
823+
idx_len = d["index_lengths"].get((lvl, r), None)
824+
if idx_len is not None: # i.e. not a sparsified entry
825+
d["clines"][rn + idx_len].append(
826+
f"\\cline{{{lvln+1}-{len(visible_index_levels)+data_len}}}"
827+
)
828+
793829
def format(
794830
self,
795831
formatter: ExtFormatter | None = None,

pandas/io/formats/templates/latex_longtable.tpl

+4
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@
7373
{% for c in row %}{% if not loop.first %} & {% endif %}
7474
{%- if c.type == 'th' %}{{parse_header(c, multirow_align, multicol_align)}}{% else %}{{parse_cell(c.cellstyle, c.display_value, convert_css)}}{% endif %}
7575
{%- endfor %} \\
76+
{% if clines and clines[loop.index] | length > 0 %}
77+
{%- for cline in clines[loop.index] %}{% if not loop.first %} {% endif %}{{ cline }}{% endfor %}
78+
79+
{% endif %}
7680
{% endfor %}
7781
\end{longtable}
7882
{% raw %}{% endraw %}

pandas/io/formats/templates/latex_table.tpl

+4
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
{% for c in row %}{% if not loop.first %} & {% endif %}
4242
{%- if c.type == 'th' %}{{parse_header(c, multirow_align, multicol_align, False, convert_css)}}{% else %}{{parse_cell(c.cellstyle, c.display_value, convert_css)}}{% endif %}
4343
{%- endfor %} \\
44+
{% if clines and clines[loop.index] | length > 0 %}
45+
{%- for cline in clines[loop.index] %}{% if not loop.first %} {% endif %}{{ cline }}{% endfor %}
46+
47+
{% endif %}
4448
{% endfor %}
4549
{% set bottomrule = parse_table(table_styles, 'bottomrule') %}
4650
{% if bottomrule is not none %}

pandas/tests/io/formats/style/test_to_latex.py

+114
Original file line numberDiff line numberDiff line change
@@ -876,3 +876,117 @@ def test_apply_index_hidden_levels():
876876
"""
877877
)
878878
assert result == expected
879+
880+
881+
@pytest.mark.parametrize("clines", ["bad", "index", "skip-last", "all", "data"])
882+
def test_clines_validation(clines, styler):
883+
msg = f"`clines` value of {clines} is invalid."
884+
with pytest.raises(ValueError, match=msg):
885+
styler.to_latex(clines=clines)
886+
887+
888+
@pytest.mark.parametrize(
889+
"clines, exp",
890+
[
891+
("all;index", "\n\\cline{1-1}"),
892+
("all;data", "\n\\cline{1-2}"),
893+
("skip-last;index", ""),
894+
("skip-last;data", ""),
895+
(None, ""),
896+
],
897+
)
898+
@pytest.mark.parametrize("env", ["table", "longtable"])
899+
def test_clines_index(clines, exp, env):
900+
df = DataFrame([[1], [2], [3], [4]])
901+
result = df.style.to_latex(clines=clines, environment=env)
902+
expected = f"""\
903+
0 & 1 \\\\{exp}
904+
1 & 2 \\\\{exp}
905+
2 & 3 \\\\{exp}
906+
3 & 4 \\\\{exp}
907+
"""
908+
assert expected in result
909+
910+
911+
@pytest.mark.parametrize(
912+
"clines, expected",
913+
[
914+
(
915+
None,
916+
dedent(
917+
"""\
918+
\\multirow[c]{2}{*}{A} & X & 1 \\\\
919+
& Y & 2 \\\\
920+
\\multirow[c]{2}{*}{B} & X & 3 \\\\
921+
& Y & 4 \\\\
922+
"""
923+
),
924+
),
925+
(
926+
"skip-last;index",
927+
dedent(
928+
"""\
929+
\\multirow[c]{2}{*}{A} & X & 1 \\\\
930+
& Y & 2 \\\\
931+
\\cline{1-2}
932+
\\multirow[c]{2}{*}{B} & X & 3 \\\\
933+
& Y & 4 \\\\
934+
\\cline{1-2}
935+
"""
936+
),
937+
),
938+
(
939+
"skip-last;data",
940+
dedent(
941+
"""\
942+
\\multirow[c]{2}{*}{A} & X & 1 \\\\
943+
& Y & 2 \\\\
944+
\\cline{1-3}
945+
\\multirow[c]{2}{*}{B} & X & 3 \\\\
946+
& Y & 4 \\\\
947+
\\cline{1-3}
948+
"""
949+
),
950+
),
951+
(
952+
"all;index",
953+
dedent(
954+
"""\
955+
\\multirow[c]{2}{*}{A} & X & 1 \\\\
956+
\\cline{2-2}
957+
& Y & 2 \\\\
958+
\\cline{1-2} \\cline{2-2}
959+
\\multirow[c]{2}{*}{B} & X & 3 \\\\
960+
\\cline{2-2}
961+
& Y & 4 \\\\
962+
\\cline{1-2} \\cline{2-2}
963+
"""
964+
),
965+
),
966+
(
967+
"all;data",
968+
dedent(
969+
"""\
970+
\\multirow[c]{2}{*}{A} & X & 1 \\\\
971+
\\cline{2-3}
972+
& Y & 2 \\\\
973+
\\cline{1-3} \\cline{2-3}
974+
\\multirow[c]{2}{*}{B} & X & 3 \\\\
975+
\\cline{2-3}
976+
& Y & 4 \\\\
977+
\\cline{1-3} \\cline{2-3}
978+
"""
979+
),
980+
),
981+
],
982+
)
983+
@pytest.mark.parametrize("env", ["table"])
984+
def test_clines_multiindex(clines, expected, env):
985+
# also tests simultaneously with hidden rows and a hidden multiindex level
986+
midx = MultiIndex.from_product([["A", "-", "B"], [0], ["X", "Y"]])
987+
df = DataFrame([[1], [2], [99], [99], [3], [4]], index=midx)
988+
styler = df.style
989+
styler.hide([("-", 0, "X"), ("-", 0, "Y")])
990+
styler.hide(level=1)
991+
result = styler.to_latex(clines=clines, environment=env)
992+
assert expected in result

0 commit comments

Comments
 (0)