diff --git a/doc/source/whatsnew/v1.2.0.rst b/doc/source/whatsnew/v1.2.0.rst index 9fc094330fb36..b6d5fc0f35bc3 100644 --- a/doc/source/whatsnew/v1.2.0.rst +++ b/doc/source/whatsnew/v1.2.0.rst @@ -96,6 +96,32 @@ For example: buffer = io.BytesIO() data.to_csv(buffer, mode="w+b", encoding="utf-8", compression="gzip") +Support for short caption and table position in ``to_latex`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:meth:`DataFrame.to_latex` now allows one to specify +a floating table position (:issue:`35281`) +and a short caption (:issue:`36267`). + +New keyword ``position`` is implemented to set the position. + +.. ipython:: python + + data = pd.DataFrame({'a': [1, 2], 'b': [3, 4]}) + table = data.to_latex(position='ht') + print(table) + +Usage of keyword ``caption`` is extended. +Besides taking a single string as an argument, +one can optionally provide a tuple of ``(full_caption, short_caption)`` +to add a short caption macro. + +.. ipython:: python + + data = pd.DataFrame({'a': [1, 2], 'b': [3, 4]}) + table = data.to_latex(caption=('the full long caption', 'short caption')) + print(table) + .. _whatsnew_120.read_csv_table_precision_default: Change in default floating precision for ``read_csv`` and ``read_table`` diff --git a/pandas/core/generic.py b/pandas/core/generic.py index 784e8877ef128..77be48ef29df8 100644 --- a/pandas/core/generic.py +++ b/pandas/core/generic.py @@ -3013,6 +3013,9 @@ def to_latex( .. versionchanged:: 1.0.0 Added caption and label arguments. + .. versionchanged:: 1.2.0 + Added position argument, changed meaning of caption argument. + Parameters ---------- buf : str, Path or StringIO-like, optional, default None @@ -3074,11 +3077,16 @@ def to_latex( centered labels (instead of top-aligned) across the contained rows, separating groups via clines. The default will be read from the pandas config module. - caption : str, optional - The LaTeX caption to be placed inside ``\caption{{}}`` in the output. + caption : str or tuple, optional + Tuple (full_caption, short_caption), + which results in ``\caption[short_caption]{{full_caption}}``; + if a single string is passed, no short caption will be set. .. versionadded:: 1.0.0 + .. versionchanged:: 1.2.0 + Optionally allow caption to be a tuple ``(full_caption, short_caption)``. + label : str, optional The LaTeX label to be placed inside ``\label{{}}`` in the output. This is used with ``\ref{{}}`` in the main ``.tex`` file. @@ -3087,6 +3095,8 @@ def to_latex( position : str, optional The LaTeX positional argument for tables, to be placed after ``\begin{{}}`` in the output. + + .. versionadded:: 1.2.0 {returns} See Also -------- @@ -3097,8 +3107,8 @@ def to_latex( Examples -------- >>> df = pd.DataFrame(dict(name=['Raphael', 'Donatello'], - ... mask=['red', 'purple'], - ... weapon=['sai', 'bo staff'])) + ... mask=['red', 'purple'], + ... weapon=['sai', 'bo staff'])) >>> print(df.to_latex(index=False)) # doctest: +NORMALIZE_WHITESPACE \begin{{tabular}}{{lll}} \toprule diff --git a/pandas/io/formats/format.py b/pandas/io/formats/format.py index dcd91b3a12294..7635cda56ba26 100644 --- a/pandas/io/formats/format.py +++ b/pandas/io/formats/format.py @@ -1021,7 +1021,7 @@ def to_latex( multicolumn: bool = False, multicolumn_format: Optional[str] = None, multirow: bool = False, - caption: Optional[str] = None, + caption: Optional[Union[str, Tuple[str, str]]] = None, label: Optional[str] = None, position: Optional[str] = None, ) -> Optional[str]: diff --git a/pandas/io/formats/latex.py b/pandas/io/formats/latex.py index 170df193bef00..2eee0ce73291f 100644 --- a/pandas/io/formats/latex.py +++ b/pandas/io/formats/latex.py @@ -2,7 +2,7 @@ Module for formatting output data in Latex. """ from abc import ABC, abstractmethod -from typing import IO, Iterator, List, Optional, Type +from typing import IO, Iterator, List, Optional, Tuple, Type, Union import numpy as np @@ -11,6 +11,39 @@ from pandas.io.formats.format import DataFrameFormatter, TableFormatter +def _split_into_full_short_caption( + caption: Optional[Union[str, Tuple[str, str]]] +) -> Tuple[str, str]: + """Extract full and short captions from caption string/tuple. + + Parameters + ---------- + caption : str or tuple, optional + Either table caption string or tuple (full_caption, short_caption). + If string is provided, then it is treated as table full caption, + while short_caption is considered an empty string. + + Returns + ------- + full_caption, short_caption : tuple + Tuple of full_caption, short_caption strings. + """ + if caption: + if isinstance(caption, str): + full_caption = caption + short_caption = "" + else: + try: + full_caption, short_caption = caption + except ValueError as err: + msg = "caption must be either a string or a tuple of two strings" + raise ValueError(msg) from err + else: + full_caption = "" + short_caption = "" + return full_caption, short_caption + + class RowStringConverter(ABC): r"""Converter for dataframe rows into LaTeX strings. @@ -275,6 +308,8 @@ class TableBuilderAbstract(ABC): Use multirow to enhance MultiIndex rows. caption: str, optional Table caption. + short_caption: str, optional + Table short caption. label: str, optional LaTeX label. position: str, optional @@ -289,6 +324,7 @@ def __init__( multicolumn_format: Optional[str] = None, multirow: bool = False, caption: Optional[str] = None, + short_caption: Optional[str] = None, label: Optional[str] = None, position: Optional[str] = None, ): @@ -298,6 +334,7 @@ def __init__( self.multicolumn_format = multicolumn_format self.multirow = multirow self.caption = caption + self.short_caption = short_caption self.label = label self.position = position @@ -384,8 +421,23 @@ def _position_macro(self) -> str: @property def _caption_macro(self) -> str: - r"""Caption macro, extracted from self.caption, like \caption{cap}.""" - return f"\\caption{{{self.caption}}}" if self.caption else "" + r"""Caption macro, extracted from self.caption. + + With short caption: + \caption[short_caption]{caption_string}. + + Without short caption: + \caption{caption_string}. + """ + if self.caption: + return "".join( + [ + r"\caption", + f"[{self.short_caption}]" if self.short_caption else "", + f"{{{self.caption}}}", + ] + ) + return "" @property def _label_macro(self) -> str: @@ -596,15 +648,32 @@ def env_end(self) -> str: class LatexFormatter(TableFormatter): - """ + r""" Used to render a DataFrame to a LaTeX tabular/longtable environment output. Parameters ---------- formatter : `DataFrameFormatter` + longtable : bool, default False + Use longtable environment. column_format : str, default None The columns format as specified in `LaTeX table format `__ e.g 'rcl' for 3 columns + multicolumn : bool, default False + Use \multicolumn to enhance MultiIndex columns. + multicolumn_format : str, default 'l' + The alignment for multicolumns, similar to `column_format` + multirow : bool, default False + Use \multirow to enhance MultiIndex rows. + caption : str or tuple, optional + Tuple (full_caption, short_caption), + which results in \caption[short_caption]{full_caption}; + if a single string is passed, no short caption will be set. + label : str, optional + The LaTeX label to be placed inside ``\label{}`` in the output. + position : str, optional + The LaTeX positional argument for tables, to be placed after + ``\begin{}`` in the output. See Also -------- @@ -619,18 +688,18 @@ def __init__( multicolumn: bool = False, multicolumn_format: Optional[str] = None, multirow: bool = False, - caption: Optional[str] = None, + caption: Optional[Union[str, Tuple[str, str]]] = None, label: Optional[str] = None, position: Optional[str] = None, ): self.fmt = formatter self.frame = self.fmt.frame self.longtable = longtable - self.column_format = column_format # type: ignore[assignment] + self.column_format = column_format self.multicolumn = multicolumn self.multicolumn_format = multicolumn_format self.multirow = multirow - self.caption = caption + self.caption, self.short_caption = _split_into_full_short_caption(caption) self.label = label self.position = position @@ -658,6 +727,7 @@ def builder(self) -> TableBuilderAbstract: multicolumn_format=self.multicolumn_format, multirow=self.multirow, caption=self.caption, + short_caption=self.short_caption, label=self.label, position=self.position, ) @@ -671,7 +741,7 @@ def _select_builder(self) -> Type[TableBuilderAbstract]: return TabularBuilder @property - def column_format(self) -> str: + def column_format(self) -> Optional[str]: """Column format.""" return self._column_format diff --git a/pandas/tests/io/formats/test_to_latex.py b/pandas/tests/io/formats/test_to_latex.py index d3d865158309c..908fdea2f73d0 100644 --- a/pandas/tests/io/formats/test_to_latex.py +++ b/pandas/tests/io/formats/test_to_latex.py @@ -414,6 +414,11 @@ def caption_table(self): """Caption for table/tabular LaTeX environment.""" return "a table in a \\texttt{table/tabular} environment" + @pytest.fixture + def short_caption(self): + """Short caption for testing \\caption[short_caption]{full_caption}.""" + return "a table" + @pytest.fixture def label_table(self): """Label for table/tabular LaTeX environment.""" @@ -493,6 +498,107 @@ def test_to_latex_caption_and_label(self, df_short, caption_table, label_table): ) assert result == expected + def test_to_latex_caption_and_shortcaption( + self, + df_short, + caption_table, + short_caption, + ): + result = df_short.to_latex(caption=(caption_table, short_caption)) + expected = _dedent( + r""" + \begin{table} + \centering + \caption[a table]{a table in a \texttt{table/tabular} environment} + \begin{tabular}{lrl} + \toprule + {} & a & b \\ + \midrule + 0 & 1 & b1 \\ + 1 & 2 & b2 \\ + \bottomrule + \end{tabular} + \end{table} + """ + ) + assert result == expected + + def test_to_latex_caption_and_shortcaption_list_is_ok(self, df_short): + caption = ("Long-long-caption", "Short") + result_tuple = df_short.to_latex(caption=caption) + result_list = df_short.to_latex(caption=list(caption)) + assert result_tuple == result_list + + def test_to_latex_caption_shortcaption_and_label( + self, + df_short, + caption_table, + short_caption, + label_table, + ): + # test when the short_caption is provided alongside caption and label + result = df_short.to_latex( + caption=(caption_table, short_caption), + label=label_table, + ) + expected = _dedent( + r""" + \begin{table} + \centering + \caption[a table]{a table in a \texttt{table/tabular} environment} + \label{tab:table_tabular} + \begin{tabular}{lrl} + \toprule + {} & a & b \\ + \midrule + 0 & 1 & b1 \\ + 1 & 2 & b2 \\ + \bottomrule + \end{tabular} + \end{table} + """ + ) + assert result == expected + + @pytest.mark.parametrize( + "bad_caption", + [ + ("full_caption", "short_caption", "extra_string"), + ("full_caption", "short_caption", 1), + ("full_caption", "short_caption", None), + ("full_caption",), + (None,), + ], + ) + def test_to_latex_bad_caption_raises(self, bad_caption): + # test that wrong number of params is raised + df = pd.DataFrame({"a": [1]}) + msg = "caption must be either a string or a tuple of two strings" + with pytest.raises(ValueError, match=msg): + df.to_latex(caption=bad_caption) + + def test_to_latex_two_chars_caption(self, df_short): + # test that two chars caption is handled correctly + # it must not be unpacked into long_caption, short_caption. + result = df_short.to_latex(caption="xy") + expected = _dedent( + r""" + \begin{table} + \centering + \caption{xy} + \begin{tabular}{lrl} + \toprule + {} & a & b \\ + \midrule + 0 & 1 & b1 \\ + 1 & 2 & b2 \\ + \bottomrule + \end{tabular} + \end{table} + """ + ) + assert result == expected + def test_to_latex_longtable_caption_only(self, df_short, caption_longtable): # GH 25436 # test when no caption and no label is provided @@ -595,6 +701,47 @@ def test_to_latex_longtable_caption_and_label( ) assert result == expected + def test_to_latex_longtable_caption_shortcaption_and_label( + self, + df_short, + caption_longtable, + short_caption, + label_longtable, + ): + # test when the caption, the short_caption and the label are provided + result = df_short.to_latex( + longtable=True, + caption=(caption_longtable, short_caption), + label=label_longtable, + ) + expected = _dedent( + r""" + \begin{longtable}{lrl} + \caption[a table]{a table in a \texttt{longtable} environment} + \label{tab:longtable}\\ + \toprule + {} & a & b \\ + \midrule + \endfirsthead + \caption[]{a table in a \texttt{longtable} environment} \\ + \toprule + {} & a & b \\ + \midrule + \endhead + \midrule + \multicolumn{3}{r}{{Continued on next page}} \\ + \midrule + \endfoot + + \bottomrule + \endlastfoot + 0 & 1 & b1 \\ + 1 & 2 & b2 \\ + \end{longtable} + """ + ) + assert result == expected + class TestToLatexEscape: @pytest.fixture