Skip to content

Commit 69f05c4

Browse files
committed
ENH: Added multicolumn/multirow support for latex
- [X] closes pandas-dev#13508 - [X] tests added / passed - [X] passes `git diff upstream/master | flake8 --diff` - [X] whatsnew entry Print names of MultiIndex columns. Added "multicol" and "multirow" flags to to_latex which trigger the corresponding feature. Multirow adds clines to visually separate sections.
1 parent 1369e12 commit 69f05c4

File tree

5 files changed

+158
-12
lines changed

5 files changed

+158
-12
lines changed

doc/source/whatsnew/v0.20.0.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ New features
3030
Other enhancements
3131
^^^^^^^^^^^^^^^^^^
3232

33+
- The ``.to_latex()`` method will now accept ``multicol`` and ``multirow`` arguments to use the accompanying LaTeX enhancements
3334

3435

3536

pandas/core/config_init.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,20 @@
249249
method. Valid values: False,True
250250
"""
251251

252+
pc_latex_multicol = """
253+
: bool
254+
This specifies if the to_latex method of a Dataframe uses multicolumns
255+
to pretty-print MultiIndex columns.
256+
method. Valid values: False,True
257+
"""
258+
259+
pc_latex_multirow = """
260+
: bool
261+
This specifies if the to_latex method of a Dataframe uses multirows
262+
to pretty-print MultiIndex rows.
263+
method. Valid values: False,True
264+
"""
265+
252266
style_backup = dict()
253267

254268

@@ -338,6 +352,10 @@ def mpl_style_cb(key):
338352
validator=is_bool)
339353
cf.register_option('latex.longtable', False, pc_latex_longtable,
340354
validator=is_bool)
355+
cf.register_option('latex.multicol', True, pc_latex_multicol,
356+
validator=is_bool)
357+
cf.register_option('latex.multirow', False, pc_latex_multirow,
358+
validator=is_bool)
341359

342360
cf.deprecate_option('display.line_width',
343361
msg=pc_line_width_deprecation_warning,

pandas/core/frame.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,10 +1615,10 @@ def to_latex(self, buf=None, columns=None, col_space=None, header=True,
16151615
index=True, na_rep='NaN', formatters=None, float_format=None,
16161616
sparsify=None, index_names=True, bold_rows=True,
16171617
column_format=None, longtable=None, escape=None,
1618-
encoding=None, decimal='.'):
1619-
"""
1618+
encoding=None, decimal='.', multicol=None, multirow=None):
1619+
r"""
16201620
Render a DataFrame to a tabular environment table. You can splice
1621-
this into a LaTeX document. Requires \\usepackage{booktabs}.
1621+
this into a LaTeX document. Requires \usepackage{booktabs}.
16221622
16231623
`to_latex`-specific options:
16241624
@@ -1631,7 +1631,7 @@ def to_latex(self, buf=None, columns=None, col_space=None, header=True,
16311631
longtable : boolean, default will be read from the pandas config module
16321632
default: False
16331633
Use a longtable environment instead of tabular. Requires adding
1634-
a \\usepackage{longtable} to your LaTeX preamble.
1634+
a \usepackage{longtable} to your LaTeX preamble.
16351635
escape : boolean, default will be read from the pandas config module
16361636
default: True
16371637
When set to False prevents from escaping latex special
@@ -1641,15 +1641,24 @@ def to_latex(self, buf=None, columns=None, col_space=None, header=True,
16411641
defaults to 'ascii' on Python 2 and 'utf-8' on Python 3.
16421642
decimal : string, default '.'
16431643
Character recognized as decimal separator, e.g. ',' in Europe
1644+
multicol : boolean, default False
1645+
Use \multicolumn to enhance MultiIndex columns.
1646+
multirow : boolean, default False
1647+
Use \multirow to enhance MultiIndex rows. Requires adding a
1648+
\usepackage{multirow} to your LaTeX preamble.
16441649
1645-
.. versionadded:: 0.18.0
1650+
.. versionadded:: 0.19.0
16461651
16471652
"""
16481653
# Get defaults from the pandas config
16491654
if longtable is None:
16501655
longtable = get_option("display.latex.longtable")
16511656
if escape is None:
16521657
escape = get_option("display.latex.escape")
1658+
if multicol is None:
1659+
multicol = get_option("display.latex.multicol")
1660+
if multirow is None:
1661+
multirow = get_option("display.latex.multirow")
16531662

16541663
formatter = fmt.DataFrameFormatter(self, buf=buf, columns=columns,
16551664
col_space=col_space, na_rep=na_rep,
@@ -1661,7 +1670,8 @@ def to_latex(self, buf=None, columns=None, col_space=None, header=True,
16611670
index_names=index_names,
16621671
escape=escape, decimal=decimal)
16631672
formatter.to_latex(column_format=column_format, longtable=longtable,
1664-
encoding=encoding)
1673+
encoding=encoding, multicol=multicol,
1674+
multirow=multirow)
16651675

16661676
if buf is None:
16671677
return formatter.buf.getvalue()

pandas/formats/format.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -646,13 +646,15 @@ def _join_multiline(self, *strcols):
646646
st = ed
647647
return '\n\n'.join(str_lst)
648648

649-
def to_latex(self, column_format=None, longtable=False, encoding=None):
649+
def to_latex(self, column_format=None, longtable=False, encoding=None,
650+
multicol=False, multirow=False):
650651
"""
651652
Render a DataFrame to a LaTeX tabular/longtable environment output.
652653
"""
653654

654655
latex_renderer = LatexFormatter(self, column_format=column_format,
655-
longtable=longtable)
656+
longtable=longtable, multicol=multicol,
657+
multirow=multirow)
656658

657659
if encoding is None:
658660
encoding = 'ascii' if compat.PY2 else 'utf-8'
@@ -820,11 +822,14 @@ class LatexFormatter(TableFormatter):
820822
HTMLFormatter
821823
"""
822824

823-
def __init__(self, formatter, column_format=None, longtable=False):
825+
def __init__(self, formatter, column_format=None, longtable=False,
826+
multicol=False, multirow=False):
824827
self.fmt = formatter
825828
self.frame = self.fmt.frame
826829
self.column_format = column_format
827830
self.longtable = longtable
831+
self.multicol = multicol
832+
self.multirow = multirow
828833

829834
def write_result(self, buf):
830835
"""
@@ -850,10 +855,15 @@ def get_col_type(dtype):
850855
clevels = self.frame.columns.nlevels
851856
strcols.pop(0)
852857
name = any(self.frame.index.names)
858+
cname = any(self.frame.columns.names)
859+
lastcol = self.frame.index.nlevels - 1
853860
for i, lev in enumerate(self.frame.index.levels):
854861
lev2 = lev.format()
855862
blank = ' ' * len(lev2[0])
856-
lev3 = [blank] * clevels
863+
if cname and i == lastcol:
864+
lev3 = [x if x else '{}' for x in self.frame.columns.names]
865+
else:
866+
lev3 = [blank] * clevels
857867
if name:
858868
lev3.append(lev.name)
859869
for level_idx, group in itertools.groupby(
@@ -881,10 +891,15 @@ def get_col_type(dtype):
881891
buf.write('\\begin{longtable}{%s}\n' % column_format)
882892
buf.write('\\toprule\n')
883893

884-
nlevels = self.frame.columns.nlevels
894+
ilevels = self.frame.index.nlevels
895+
clevels = self.frame.columns.nlevels
896+
nlevels = clevels
885897
if any(self.frame.index.names):
886898
nlevels += 1
887-
for i, row in enumerate(zip(*strcols)):
899+
strrows = list(zip(*strcols))
900+
clinebuf = []
901+
902+
for i, row in enumerate(strrows):
888903
if i == nlevels and self.fmt.header:
889904
buf.write('\\midrule\n') # End of header
890905
if self.longtable:
@@ -906,8 +921,50 @@ def get_col_type(dtype):
906921
if x else '{}') for x in row]
907922
else:
908923
crow = [x if x else '{}' for x in row]
924+
if i < clevels and self.fmt.header and self.multicol:
925+
row2 = list(crow[:ilevels])
926+
ncol = 1
927+
coltext = ''
928+
929+
def appendCol():
930+
if ncol > 1:
931+
row2.append('\\multicolumn{{{0:d}}}{{c}}{{{1:s}}}'
932+
.format(ncol, coltext.strip()))
933+
else:
934+
row2.append(coltext)
935+
for c in crow[ilevels:]:
936+
if c.strip():
937+
if coltext:
938+
appendCol()
939+
coltext = c
940+
ncol = 1
941+
else:
942+
ncol += 1
943+
if coltext:
944+
appendCol()
945+
crow = row2
946+
if i >= nlevels and self.fmt.index and self.multirow:
947+
for j in range(ilevels):
948+
if crow[j].strip():
949+
nrow = 1
950+
for r in strrows[i + 1:]:
951+
if not r[j].strip():
952+
nrow += 1
953+
else:
954+
break
955+
if nrow > 1:
956+
crow[j] = '\\multirow{{{0:d}}}{{*}}{{{1:s}}}'\
957+
.format(nrow, crow[j].strip())
958+
clinebuf.append([nrow, j + 1])
909959
buf.write(' & '.join(crow))
910960
buf.write(' \\\\\n')
961+
if self.multirow and i < len(strrows) - 1:
962+
for cl in clinebuf:
963+
cl[0] -= 1
964+
if cl[0] == 0:
965+
buf.write('\cline{{{0:d}-{1:d}}}\n'.format(cl[1],
966+
len(strcols)))
967+
clinebuf = [x for x in clinebuf if x[0]]
911968

912969
if not self.longtable:
913970
buf.write('\\bottomrule\n')

pandas/tests/formats/test_format.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3004,6 +3004,66 @@ def test_to_latex_multiindex(self):
30043004

30053005
self.assertEqual(result, expected)
30063006

3007+
def test_to_latex_multicolrow(self):
3008+
df = pd.DataFrame({
3009+
('c1',0):dict((x,x) for x in range(5)),
3010+
('c1',1):dict((x,x+5) for x in range(5)),
3011+
('c2',0):dict((x,x) for x in range(5)),
3012+
('c2',1):dict((x,x+5) for x in range(5)),
3013+
('c3',0):dict((x,x) for x in range(5))
3014+
})
3015+
result = df.to_latex(multicol=True)
3016+
expected = r"""\begin{tabular}{lrrrrr}
3017+
\toprule
3018+
{} & \multicolumn{2}{c}{c1} & \multicolumn{2}{c}{c2} & c3 \\
3019+
{} & 0 & 1 & 0 & 1 & 0 \\
3020+
\midrule
3021+
0 & 0 & 5 & 0 & 5 & 0 \\
3022+
1 & 1 & 6 & 1 & 6 & 1 \\
3023+
2 & 2 & 7 & 2 & 7 & 2 \\
3024+
3 & 3 & 8 & 3 & 8 & 3 \\
3025+
4 & 4 & 9 & 4 & 9 & 4 \\
3026+
\bottomrule
3027+
\end{tabular}
3028+
"""
3029+
self.assertEqual(result, expected)
3030+
3031+
result = df.T.to_latex(multirow=True)
3032+
expected = r"""\begin{tabular}{llrrrrr}
3033+
\toprule
3034+
& & 0 & 1 & 2 & 3 & 4 \\
3035+
\midrule
3036+
\multirow{2}{*}{c1} & 0 & 0 & 1 & 2 & 3 & 4 \\
3037+
& 1 & 5 & 6 & 7 & 8 & 9 \\
3038+
\cline{1-7}
3039+
\multirow{2}{*}{c2} & 0 & 0 & 1 & 2 & 3 & 4 \\
3040+
& 1 & 5 & 6 & 7 & 8 & 9 \\
3041+
\cline{1-7}
3042+
c3 & 0 & 0 & 1 & 2 & 3 & 4 \\
3043+
\bottomrule
3044+
\end{tabular}
3045+
"""
3046+
self.assertEqual(result, expected)
3047+
3048+
df.index = df.T.index
3049+
result = df.T.to_latex(multirow=True,multicol=True)
3050+
expected = r"""\begin{tabular}{llrrrrr}
3051+
\toprule
3052+
& & \multicolumn{2}{c}{c1} & \multicolumn{2}{c}{c2} & c3 \\
3053+
& & 0 & 1 & 0 & 1 & 0 \\
3054+
\midrule
3055+
\multirow{2}{*}{c1} & 0 & 0 & 1 & 2 & 3 & 4 \\
3056+
& 1 & 5 & 6 & 7 & 8 & 9 \\
3057+
\cline{1-7}
3058+
\multirow{2}{*}{c2} & 0 & 0 & 1 & 2 & 3 & 4 \\
3059+
& 1 & 5 & 6 & 7 & 8 & 9 \\
3060+
\cline{1-7}
3061+
c3 & 0 & 0 & 1 & 2 & 3 & 4 \\
3062+
\bottomrule
3063+
\end{tabular}
3064+
"""
3065+
self.assertEqual(result, expected)
3066+
30073067
def test_to_latex_escape(self):
30083068
a = 'a'
30093069
b = 'b'

0 commit comments

Comments
 (0)