Skip to content

Commit ed9f60c

Browse files
authored
ENH: Styler.to_latex(): conditional styling with native latex format (#40422)
1 parent 4e2c588 commit ed9f60c

File tree

9 files changed

+1039
-2
lines changed

9 files changed

+1039
-2
lines changed

doc/source/_static/style/latex_1.png

11.4 KB
Loading

doc/source/_static/style/latex_2.png

14.5 KB
Loading

doc/source/reference/style.rst

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Styler properties
2424

2525
Styler.env
2626
Styler.template_html
27+
Styler.template_latex
2728
Styler.loader
2829

2930
Style application
@@ -66,3 +67,4 @@ Style export and import
6667
Styler.export
6768
Styler.use
6869
Styler.to_excel
70+
Styler.to_latex

doc/source/whatsnew/v1.3.0.rst

+3
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ properly format HTML and eliminate some inconsistencies (:issue:`39942` :issue:`
141141
:class:`.Styler` has also been compatible with non-unique index or columns, at least for as many features as are fully compatible, others made only partially compatible (:issue:`41269`).
142142
One also has greater control of the display through separate sparsification of the index or columns, using the new 'styler' options context (:issue:`41142`).
143143

144+
We have added an extension to allow LaTeX styling as an alternative to CSS styling and a method :meth:`.Styler.to_latex`
145+
which renders the necessary LaTeX format including built-up styles.
146+
144147
Documentation has also seen major revisions in light of new features (:issue:`39720` :issue:`39317` :issue:`40493`)
145148

146149
.. _whatsnew_130.dataframe_honors_copy_with_dict:

pandas/io/formats/style.py

+336
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from pandas._typing import (
2323
Axis,
24+
FilePathOrBuffer,
2425
FrameOrSeries,
2526
FrameOrSeriesUnion,
2627
IndexLabel,
@@ -30,6 +31,7 @@
3031
from pandas.util._decorators import doc
3132

3233
import pandas as pd
34+
from pandas import RangeIndex
3335
from pandas.api.types import is_list_like
3436
from pandas.core import generic
3537
import pandas.core.common as com
@@ -39,6 +41,8 @@
3941
)
4042
from pandas.core.generic import NDFrame
4143

44+
from pandas.io.formats.format import save_to_buffer
45+
4246
jinja2 = import_optional_dependency("jinja2", extra="DataFrame.style requires jinja2.")
4347

4448
from pandas.io.formats.style_render import (
@@ -403,6 +407,338 @@ def to_excel(
403407
engine=engine,
404408
)
405409

410+
def to_latex(
411+
self,
412+
buf: FilePathOrBuffer[str] | None = None,
413+
*,
414+
column_format: str | None = None,
415+
position: str | None = None,
416+
position_float: str | None = None,
417+
hrules: bool = False,
418+
label: str | None = None,
419+
caption: str | None = None,
420+
sparse_index: bool | None = None,
421+
sparse_columns: bool | None = None,
422+
multirow_align: str = "c",
423+
multicol_align: str = "r",
424+
siunitx: bool = False,
425+
encoding: str | None = None,
426+
):
427+
r"""
428+
Write Styler to a file, buffer or string in LaTeX format.
429+
430+
.. versionadded:: 1.3.0
431+
432+
Parameters
433+
----------
434+
buf : str, Path, or StringIO-like, optional, default None
435+
Buffer to write to. If ``None``, the output is returned as a string.
436+
column_format : str, optional
437+
The LaTeX column specification placed in location:
438+
439+
\\begin{tabular}{<column_format>}
440+
441+
Defaults to 'l' for index and
442+
non-numeric data columns, and, for numeric data columns,
443+
to 'r' by default, or 'S' if ``siunitx`` is ``True``.
444+
position : str, optional
445+
The LaTeX positional argument (e.g. 'h!') for tables, placed in location:
446+
447+
\\begin{table}[<position>]
448+
position_float : {"centering", "raggedleft", "raggedright"}, optional
449+
The LaTeX float command placed in location:
450+
451+
\\begin{table}[<position>]
452+
453+
\\<position_float>
454+
hrules : bool, default False
455+
Set to `True` to add \\toprule, \\midrule and \\bottomrule from the
456+
{booktabs} LaTeX package.
457+
label : str, optional
458+
The LaTeX label included as: \\label{<label>}.
459+
This is used with \\ref{<label>} in the main .tex file.
460+
caption : str, optional
461+
The LaTeX table caption included as: \\caption{<caption>}.
462+
sparse_index : bool, optional
463+
Whether to sparsify the display of a hierarchical index. Setting to False
464+
will display each explicit level element in a hierarchical key for each row.
465+
Defaults to ``pandas.options.styler.sparse.index`` value.
466+
sparse_columns : bool, optional
467+
Whether to sparsify the display of a hierarchical index. Setting to False
468+
will display each explicit level element in a hierarchical key for each row.
469+
Defaults to ``pandas.options.styler.sparse.columns`` value.
470+
multirow_align : {"c", "t", "b"}
471+
If sparsifying hierarchical MultiIndexes whether to align text centrally,
472+
at the top or bottom.
473+
multicol_align : {"r", "c", "l"}
474+
If sparsifying hierarchical MultiIndex columns whether to align text at
475+
the left, centrally, or at the right.
476+
siunitx : bool, default False
477+
Set to ``True`` to structure LaTeX compatible with the {siunitx} package.
478+
encoding : str, default "utf-8"
479+
Character encoding setting.
480+
481+
Returns
482+
-------
483+
str or None
484+
If `buf` is None, returns the result as a string. Otherwise returns `None`.
485+
486+
See Also
487+
--------
488+
Styler.format: Format the text display value of cells.
489+
490+
Notes
491+
-----
492+
**Latex Packages**
493+
494+
For the following features we recommend the following LaTeX inclusions:
495+
496+
===================== ==========================================================
497+
Feature Inclusion
498+
===================== ==========================================================
499+
sparse columns none: included within default {tabular} environment
500+
sparse rows \\usepackage{multirow}
501+
hrules \\usepackage{booktabs}
502+
colors \\usepackage[table]{xcolor}
503+
siunitx \\usepackage{siunitx}
504+
bold (with siunitx) | \\usepackage{etoolbox}
505+
| \\robustify\\bfseries
506+
| \\sisetup{detect-all = true} *(within {document})*
507+
italic (with siunitx) | \\usepackage{etoolbox}
508+
| \\robustify\\itshape
509+
| \\sisetup{detect-all = true} *(within {document})*
510+
===================== ==========================================================
511+
512+
**Cell Styles**
513+
514+
LaTeX styling can only be rendered if the accompanying styling functions have
515+
been constructed with appropriate LaTeX commands. All styling
516+
functionality is built around the concept of a CSS ``(<attribute>, <value>)``
517+
pair (see `Table Visualization <../../user_guide/style.ipynb>`_), and this
518+
should be replaced by a LaTeX
519+
``(<command>, <options>)`` approach. Each cell will be styled individually
520+
using nested LaTeX commands with their accompanied options.
521+
522+
For example the following code will highlight and bold a cell in HTML-CSS:
523+
524+
>>> df = pd.DataFrame([[1,2], [3,4]])
525+
>>> s = df.style.highlight_max(axis=None,
526+
... props='background-color:red; font-weight:bold;')
527+
>>> s.render()
528+
529+
The equivalent using LaTeX only commands is the following:
530+
531+
>>> s = df.style.highlight_max(axis=None,
532+
... props='cellcolor:{red}; bfseries: ;')
533+
>>> s.to_latex()
534+
535+
Internally these structured LaTeX ``(<command>, <options>)`` pairs
536+
are translated to the
537+
``display_value`` with the default structure:
538+
``\<command><options> <display_value>``.
539+
Where there are multiple commands the latter is nested recursively, so that
540+
the above example highlighed cell is rendered as
541+
``\cellcolor{red} \bfseries 4``.
542+
543+
Occasionally this format does not suit the applied command, or
544+
combination of LaTeX packages that is in use, so additional flags can be
545+
added to the ``<options>``, within the tuple, to result in different
546+
positions of required braces (the **default** being the same as ``--nowrap``):
547+
548+
=================================== ============================================
549+
Tuple Format Output Structure
550+
=================================== ============================================
551+
(<command>,<options>) \\<command><options> <display_value>
552+
(<command>,<options> ``--nowrap``) \\<command><options> <display_value>
553+
(<command>,<options> ``--rwrap``) \\<command><options>{<display_value>}
554+
(<command>,<options> ``--wrap``) {\\<command><options> <display_value>}
555+
(<command>,<options> ``--lwrap``) {\\<command><options>} <display_value>
556+
(<command>,<options> ``--dwrap``) {\\<command><options>}{<display_value>}
557+
=================================== ============================================
558+
559+
For example the `textbf` command for font-weight
560+
should always be used with `--rwrap` so ``('textbf', '--rwrap')`` will render a
561+
working cell, wrapped with braces, as ``\textbf{<display_value>}``.
562+
563+
A more comprehensive example is as follows:
564+
565+
>>> df = pd.DataFrame([[1, 2.2, "dogs"], [3, 4.4, "cats"], [2, 6.6, "cows"]],
566+
... index=["ix1", "ix2", "ix3"],
567+
... columns=["Integers", "Floats", "Strings"])
568+
>>> s = df.style.highlight_max(
569+
... props='cellcolor:[HTML]{FFFF00}; color:{red};'
570+
... 'textit:--rwrap; textbf:--rwrap;'
571+
... )
572+
>>> s.to_latex()
573+
574+
.. figure:: ../../_static/style/latex_1.png
575+
576+
**Table Styles**
577+
578+
Internally Styler uses its ``table_styles`` object to parse the
579+
``column_format``, ``position``, ``position_float``, and ``label``
580+
input arguments. These arguments are added to table styles in the format:
581+
582+
.. code-block:: python
583+
584+
set_table_styles([
585+
{"selector": "column_format", "props": f":{column_format};"},
586+
{"selector": "position", "props": f":{position};"},
587+
{"selector": "position_float", "props": f":{position_float};"},
588+
{"selector": "label", "props": f":{{{label.replace(':','§')}}};"}
589+
], overwrite=False)
590+
591+
Exception is made for the ``hrules`` argument which, in fact, controls all three
592+
commands: ``toprule``, ``bottomrule`` and ``midrule`` simultaneously. Instead of
593+
setting ``hrules`` to ``True``, it is also possible to set each
594+
individual rule definition, by manually setting the ``table_styles``,
595+
for example below we set a regular ``toprule``, set an ``hline`` for
596+
``bottomrule`` and exclude the ``midrule``:
597+
598+
.. code-block:: python
599+
600+
set_table_styles([
601+
{'selector': 'toprule', 'props': ':toprule;'},
602+
{'selector': 'bottomrule', 'props': ':hline;'},
603+
], overwrite=False)
604+
605+
If other ``commands`` are added to table styles they will be detected, and
606+
positioned immediately above the '\\begin{tabular}' command. For example to
607+
add odd and even row coloring, from the {colortbl} package, in format
608+
``\rowcolors{1}{pink}{red}``, use:
609+
610+
.. code-block:: python
611+
612+
set_table_styles([
613+
{'selector': 'rowcolors', 'props': ':{1}{pink}{red};'}
614+
], overwrite=False)
615+
616+
A more comprehensive example using these arguments is as follows:
617+
618+
>>> df.columns = pd.MultiIndex.from_tuples([
619+
... ("Numeric", "Integers"),
620+
... ("Numeric", "Floats"),
621+
... ("Non-Numeric", "Strings")
622+
... ])
623+
>>> df.index = pd.MultiIndex.from_tuples([
624+
... ("L0", "ix1"), ("L0", "ix2"), ("L1", "ix3")
625+
... ])
626+
>>> s = df.style.highlight_max(
627+
... props='cellcolor:[HTML]{FFFF00}; color:{red}; itshape:; bfseries:;'
628+
... )
629+
>>> s.to_latex(
630+
... column_format="rrrrr", position="h", position_float="centering",
631+
... hrules=True, label="table:5", caption="Styled LaTeX Table",
632+
... multirow_align="t", multicol_align="r"
633+
... )
634+
635+
.. figure:: ../../_static/style/latex_2.png
636+
637+
**Formatting**
638+
639+
To format values :meth:`Styler.format` should be used prior to calling
640+
`Styler.to_latex`, as well as other methods such as :meth:`Styler.hide_index`
641+
or :meth:`Styler.hide_columns`, for example:
642+
643+
>>> s.clear()
644+
>>> s.table_styles = []
645+
>>> s.caption = None
646+
>>> s.format({
647+
... ("Numeric", "Integers"): '\${}',
648+
... ("Numeric", "Floats"): '{:.3f}',
649+
... ("Non-Numeric", "Strings"): str.upper
650+
... })
651+
>>> s.to_latex()
652+
\begin{tabular}{llrrl}
653+
{} & {} & \multicolumn{2}{r}{Numeric} & {Non-Numeric} \\
654+
{} & {} & {Integers} & {Floats} & {Strings} \\
655+
\multirow[c]{2}{*}{L0} & ix1 & \\$1 & 2.200 & DOGS \\
656+
& ix2 & \$3 & 4.400 & CATS \\
657+
L1 & ix3 & \$2 & 6.600 & COWS \\
658+
\end{tabular}
659+
"""
660+
table_selectors = (
661+
[style["selector"] for style in self.table_styles]
662+
if self.table_styles is not None
663+
else []
664+
)
665+
666+
if column_format is not None:
667+
# add more recent setting to table_styles
668+
self.set_table_styles(
669+
[{"selector": "column_format", "props": f":{column_format}"}],
670+
overwrite=False,
671+
)
672+
elif "column_format" in table_selectors:
673+
pass # adopt what has been previously set in table_styles
674+
else:
675+
# create a default: set float, complex, int cols to 'r' ('S'), index to 'l'
676+
_original_columns = self.data.columns
677+
self.data.columns = RangeIndex(stop=len(self.data.columns))
678+
numeric_cols = self.data._get_numeric_data().columns.to_list()
679+
self.data.columns = _original_columns
680+
column_format = "" if self.hidden_index else "l" * self.data.index.nlevels
681+
for ci, _ in enumerate(self.data.columns):
682+
if ci not in self.hidden_columns:
683+
column_format += (
684+
("r" if not siunitx else "S") if ci in numeric_cols else "l"
685+
)
686+
self.set_table_styles(
687+
[{"selector": "column_format", "props": f":{column_format}"}],
688+
overwrite=False,
689+
)
690+
691+
if position:
692+
self.set_table_styles(
693+
[{"selector": "position", "props": f":{position}"}],
694+
overwrite=False,
695+
)
696+
697+
if position_float:
698+
if position_float not in ["raggedright", "raggedleft", "centering"]:
699+
raise ValueError(
700+
f"`position_float` should be one of "
701+
f"'raggedright', 'raggedleft', 'centering', "
702+
f"got: '{position_float}'"
703+
)
704+
self.set_table_styles(
705+
[{"selector": "position_float", "props": f":{position_float}"}],
706+
overwrite=False,
707+
)
708+
709+
if hrules:
710+
self.set_table_styles(
711+
[
712+
{"selector": "toprule", "props": ":toprule"},
713+
{"selector": "midrule", "props": ":midrule"},
714+
{"selector": "bottomrule", "props": ":bottomrule"},
715+
],
716+
overwrite=False,
717+
)
718+
719+
if label:
720+
self.set_table_styles(
721+
[{"selector": "label", "props": f":{{{label.replace(':', '§')}}}"}],
722+
overwrite=False,
723+
)
724+
725+
if caption:
726+
self.set_caption(caption)
727+
728+
if sparse_index is None:
729+
sparse_index = get_option("styler.sparse.index")
730+
if sparse_columns is None:
731+
sparse_columns = get_option("styler.sparse.columns")
732+
733+
latex = self._render_latex(
734+
sparse_index=sparse_index,
735+
sparse_columns=sparse_columns,
736+
multirow_align=multirow_align,
737+
multicol_align=multicol_align,
738+
)
739+
740+
return save_to_buffer(latex, buf=buf, encoding=encoding)
741+
406742
def set_td_classes(self, classes: DataFrame) -> Styler:
407743
"""
408744
Set the DataFrame of strings added to the ``class`` attribute of ``<td>``

0 commit comments

Comments
 (0)