Skip to content

Commit 1ecd65a

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 "multicolumn" and "multirow" flags to to_latex which trigger the corresponding feature. Multirow adds clines to visually separate sections.
1 parent b97e007 commit 1ecd65a

File tree

5 files changed

+186
-11
lines changed

5 files changed

+186
-11
lines changed

doc/source/whatsnew/v0.20.0.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ Other enhancements
5151

5252
- ``pd.read_excel`` now preserves sheet order when using ``sheetname=None`` (:issue:`9930`)
5353

54+
- The ``.to_latex()`` method will now accept ``multicolumn`` and ``multirow`` arguments to use the accompanying LaTeX enhancements
55+
5456
- ``pd.cut`` and ``pd.qcut`` now support datetime64 and timedelta64 dtypes (issue:`14714`)
5557

5658
.. _whatsnew_0200.api_breaking:

pandas/core/config_init.py

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

252+
pc_latex_multicolumn = """
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_multicolumn_format = """
260+
: string
261+
This specifies the format for multicolumn headers.
262+
Can be surrounded with '|'.
263+
Valid values: 'l', 'c', 'r', 'p{<width>}'
264+
"""
265+
266+
pc_latex_multirow = """
267+
: bool
268+
This specifies if the to_latex method of a Dataframe uses multirows
269+
to pretty-print MultiIndex rows.
270+
method. Valid values: False,True
271+
"""
272+
252273
style_backup = dict()
253274

254275

@@ -338,6 +359,12 @@ def mpl_style_cb(key):
338359
validator=is_bool)
339360
cf.register_option('latex.longtable', False, pc_latex_longtable,
340361
validator=is_bool)
362+
cf.register_option('latex.multicolumn', True, pc_latex_multicolumn,
363+
validator=is_bool)
364+
cf.register_option('latex.multicolumn_format', 'l', pc_latex_multicolumn,
365+
validator=is_text)
366+
cf.register_option('latex.multirow', False, pc_latex_multirow,
367+
validator=is_bool)
341368

342369
cf.deprecate_option('display.line_width',
343370
msg=pc_line_width_deprecation_warning,

pandas/core/frame.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,10 +1615,11 @@ 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='.', multicolumn=None,
1619+
multicolumn_format=None, multirow=None):
1620+
r"""
16201621
Render a DataFrame to a tabular environment table. You can splice
1621-
this into a LaTeX document. Requires \\usepackage{booktabs}.
1622+
this into a LaTeX document. Requires \usepackage{booktabs}.
16221623
16231624
`to_latex`-specific options:
16241625
@@ -1631,7 +1632,7 @@ def to_latex(self, buf=None, columns=None, col_space=None, header=True,
16311632
longtable : boolean, default will be read from the pandas config module
16321633
default: False
16331634
Use a longtable environment instead of tabular. Requires adding
1634-
a \\usepackage{longtable} to your LaTeX preamble.
1635+
a \usepackage{longtable} to your LaTeX preamble.
16351636
escape : boolean, default will be read from the pandas config module
16361637
default: True
16371638
When set to False prevents from escaping latex special
@@ -1644,12 +1645,31 @@ def to_latex(self, buf=None, columns=None, col_space=None, header=True,
16441645
16451646
.. versionadded:: 0.18.0
16461647
1648+
multicolumn : boolean, default will be read from the config module
1649+
default: True
1650+
Use \multicolumn to enhance MultiIndex columns.
1651+
multicolumn_format : str, default will be read from the config module
1652+
default : 'l'
1653+
The alignment for multicolumns, similar to column_format
1654+
multirow : boolean, default will be read from the pandas config module
1655+
default : False
1656+
Use \multirow to enhance MultiIndex rows. Requires adding a
1657+
\usepackage{multirow} to your LaTeX preamble.
1658+
1659+
.. versionadded:: 0.20.0
1660+
16471661
"""
16481662
# Get defaults from the pandas config
16491663
if longtable is None:
16501664
longtable = get_option("display.latex.longtable")
16511665
if escape is None:
16521666
escape = get_option("display.latex.escape")
1667+
if multicolumn is None:
1668+
multicolumn = get_option("display.latex.multicolumn")
1669+
if multicolumn_format is None:
1670+
multicolumn_format = get_option("display.latex.multicolumn_format")
1671+
if multirow is None:
1672+
multirow = get_option("display.latex.multirow")
16531673

16541674
formatter = fmt.DataFrameFormatter(self, buf=buf, columns=columns,
16551675
col_space=col_space, na_rep=na_rep,
@@ -1661,7 +1681,9 @@ def to_latex(self, buf=None, columns=None, col_space=None, header=True,
16611681
index_names=index_names,
16621682
escape=escape, decimal=decimal)
16631683
formatter.to_latex(column_format=column_format, longtable=longtable,
1664-
encoding=encoding)
1684+
encoding=encoding, multicolumn=multicolumn,
1685+
multicolumn_format=multicolumn_format,
1686+
multirow=multirow)
16651687

16661688
if buf is None:
16671689
return formatter.buf.getvalue()

pandas/formats/format.py

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -646,13 +646,17 @@ 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+
multicolumn=False, multicolumn_format=None, 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,
657+
multicolumn=multicolumn,
658+
multicolumn_format=multicolumn_format,
659+
multirow=multirow)
656660

657661
if encoding is None:
658662
encoding = 'ascii' if compat.PY2 else 'utf-8'
@@ -820,11 +824,15 @@ class LatexFormatter(TableFormatter):
820824
HTMLFormatter
821825
"""
822826

823-
def __init__(self, formatter, column_format=None, longtable=False):
827+
def __init__(self, formatter, column_format=None, longtable=False,
828+
multicolumn=False, multicolumn_format=None, multirow=False):
824829
self.fmt = formatter
825830
self.frame = self.fmt.frame
826831
self.column_format = column_format
827832
self.longtable = longtable
833+
self.multicolumn = multicolumn
834+
self.multicolumn_format = multicolumn_format
835+
self.multirow = multirow
828836

829837
def write_result(self, buf):
830838
"""
@@ -850,10 +858,15 @@ def get_col_type(dtype):
850858
clevels = self.frame.columns.nlevels
851859
strcols.pop(0)
852860
name = any(self.frame.index.names)
861+
cname = any(self.frame.columns.names)
862+
lastcol = self.frame.index.nlevels - 1
853863
for i, lev in enumerate(self.frame.index.levels):
854864
lev2 = lev.format()
855865
blank = ' ' * len(lev2[0])
856-
lev3 = [blank] * clevels
866+
if cname and i == lastcol:
867+
lev3 = [x if x else '{}' for x in self.frame.columns.names]
868+
else:
869+
lev3 = [blank] * clevels
857870
if name:
858871
lev3.append(lev.name)
859872
for level_idx, group in itertools.groupby(
@@ -873,6 +886,9 @@ def get_col_type(dtype):
873886
compat.string_types): # pragma: no cover
874887
raise AssertionError('column_format must be str or unicode, not %s'
875888
% type(column_format))
889+
multicolumn_format = self.multicolumn_format
890+
if multicolumn_format is None:
891+
multicolumn_format = get_option("display.latex.multicolumn_format")
876892

877893
if not self.longtable:
878894
buf.write('\\begin{tabular}{%s}\n' % column_format)
@@ -881,10 +897,15 @@ def get_col_type(dtype):
881897
buf.write('\\begin{longtable}{%s}\n' % column_format)
882898
buf.write('\\toprule\n')
883899

884-
nlevels = self.frame.columns.nlevels
900+
ilevels = self.frame.index.nlevels
901+
clevels = self.frame.columns.nlevels
902+
nlevels = clevels
885903
if any(self.frame.index.names):
886904
nlevels += 1
887-
for i, row in enumerate(zip(*strcols)):
905+
strrows = list(zip(*strcols))
906+
clinebuf = []
907+
908+
for i, row in enumerate(strrows):
888909
if i == nlevels and self.fmt.header:
889910
buf.write('\\midrule\n') # End of header
890911
if self.longtable:
@@ -906,8 +927,51 @@ def get_col_type(dtype):
906927
if x else '{}') for x in row]
907928
else:
908929
crow = [x if x else '{}' for x in row]
930+
if i < clevels and self.fmt.header and self.multicolumn:
931+
row2 = list(crow[:ilevels])
932+
ncol = 1
933+
coltext = ''
934+
935+
def append_col():
936+
if ncol > 1:
937+
row2.append('\\multicolumn{{{0:d}}}{{{1:s}}}{{{2:s}}}'
938+
.format(ncol, multicolumn_format,
939+
coltext.strip()))
940+
else:
941+
row2.append(coltext)
942+
for c in crow[ilevels:]:
943+
if c.strip():
944+
if coltext:
945+
append_col()
946+
coltext = c
947+
ncol = 1
948+
else:
949+
ncol += 1
950+
if coltext:
951+
append_col()
952+
crow = row2
953+
if i >= nlevels and self.fmt.index and self.multirow:
954+
for j in range(ilevels):
955+
if crow[j].strip():
956+
nrow = 1
957+
for r in strrows[i + 1:]:
958+
if not r[j].strip():
959+
nrow += 1
960+
else:
961+
break
962+
if nrow > 1:
963+
crow[j] = '\\multirow{{{0:d}}}{{*}}{{{1:s}}}'\
964+
.format(nrow, crow[j].strip())
965+
clinebuf.append([nrow, j + 1])
909966
buf.write(' & '.join(crow))
910967
buf.write(' \\\\\n')
968+
if self.multirow and i < len(strrows) - 1:
969+
for cl in clinebuf:
970+
cl[0] -= 1
971+
if cl[0] == 0:
972+
buf.write('\cline{{{0:d}-{1:d}}}\n'.format(cl[1],
973+
len(strcols)))
974+
clinebuf = [x for x in clinebuf if x[0]]
911975

912976
if not self.longtable:
913977
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_multicolumnrow(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(multicolumn=True)
3016+
expected = r"""\begin{tabular}{lrrrrr}
3017+
\toprule
3018+
{} & \multicolumn{2}{l}{c1} & \multicolumn{2}{l}{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,multicolumn=True,multicolumn_format='c')
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)