Skip to content

Commit 05cfa0d

Browse files
Backport PR #40731: ENH: Styler.to_latex conversion from CSS (#42040)
Co-authored-by: attack68 <[email protected]>
1 parent ddb76a1 commit 05cfa0d

File tree

5 files changed

+175
-3
lines changed

5 files changed

+175
-3
lines changed

doc/source/whatsnew/v1.3.0.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ which has been revised and improved (:issue:`39720`, :issue:`39317`, :issue:`404
136136
- Many features of the :class:`.Styler` class are now either partially or fully usable on a DataFrame with a non-unique indexes or columns (:issue:`41143`)
137137
- One has greater control of the display through separate sparsification of the index or columns using the :ref:`new styler options <options.available>`, which are also usable via :func:`option_context` (:issue:`41142`)
138138
- Added the option ``styler.render.max_elements`` to avoid browser overload when styling large DataFrames (:issue:`40712`)
139-
- Added the method :meth:`.Styler.to_latex` (:issue:`21673`)
139+
- Added the method :meth:`.Styler.to_latex` (:issue:`21673`), which also allows some limited CSS conversion (:issue:`40731`)
140140
- Added the method :meth:`.Styler.to_html` (:issue:`13379`)
141141

142142
.. _whatsnew_130.enhancements.dataframe_honors_copy_with_dict:

pandas/io/formats/style.py

+45
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ def to_latex(
426426
multicol_align: str = "r",
427427
siunitx: bool = False,
428428
encoding: str | None = None,
429+
convert_css: bool = False,
429430
):
430431
r"""
431432
Write Styler to a file, buffer or string in LaTeX format.
@@ -482,6 +483,10 @@ def to_latex(
482483
Set to ``True`` to structure LaTeX compatible with the {siunitx} package.
483484
encoding : str, default "utf-8"
484485
Character encoding setting.
486+
convert_css : bool, default False
487+
Convert simple cell-styles from CSS to LaTeX format. Any CSS not found in
488+
conversion table is dropped. A style can be forced by adding option
489+
`--latex`. See notes.
485490
486491
Returns
487492
-------
@@ -661,6 +666,45 @@ def to_latex(
661666
& ix2 & \$3 & 4.400 & CATS \\
662667
L1 & ix3 & \$2 & 6.600 & COWS \\
663668
\end{tabular}
669+
670+
**CSS Conversion**
671+
672+
This method can convert a Styler constructured with HTML-CSS to LaTeX using
673+
the following limited conversions.
674+
675+
================== ==================== ============= ==========================
676+
CSS Attribute CSS value LaTeX Command LaTeX Options
677+
================== ==================== ============= ==========================
678+
font-weight | bold | bfseries
679+
| bolder | bfseries
680+
font-style | italic | itshape
681+
| oblique | slshape
682+
background-color | red cellcolor | {red}--lwrap
683+
| #fe01ea | [HTML]{FE01EA}--lwrap
684+
| #f0e | [HTML]{FF00EE}--lwrap
685+
| rgb(128,255,0) | [rgb]{0.5,1,0}--lwrap
686+
| rgba(128,0,0,0.5) | [rgb]{0.5,0,0}--lwrap
687+
| rgb(25%,255,50%) | [rgb]{0.25,1,0.5}--lwrap
688+
color | red color | {red}
689+
| #fe01ea | [HTML]{FE01EA}
690+
| #f0e | [HTML]{FF00EE}
691+
| rgb(128,255,0) | [rgb]{0.5,1,0}
692+
| rgba(128,0,0,0.5) | [rgb]{0.5,0,0}
693+
| rgb(25%,255,50%) | [rgb]{0.25,1,0.5}
694+
================== ==================== ============= ==========================
695+
696+
It is also possible to add user-defined LaTeX only styles to a HTML-CSS Styler
697+
using the ``--latex`` flag, and to add LaTeX parsing options that the
698+
converter will detect within a CSS-comment.
699+
700+
>>> df = pd.DataFrame([[1]])
701+
>>> df.style.set_properties(
702+
... **{"font-weight": "bold /* --dwrap */", "Huge": "--latex--rwrap"}
703+
... ).to_latex(css_convert=True)
704+
\begin{tabular}{lr}
705+
{} & {0} \\
706+
0 & {\bfseries}{\Huge{1}} \\
707+
\end{tabular}
664708
"""
665709
table_selectors = (
666710
[style["selector"] for style in self.table_styles]
@@ -740,6 +784,7 @@ def to_latex(
740784
sparse_columns=sparse_columns,
741785
multirow_align=multirow_align,
742786
multicol_align=multicol_align,
787+
convert_css=convert_css,
743788
)
744789

745790
return save_to_buffer(latex, buf=buf, encoding=encoding)

pandas/io/formats/style_render.py

+82-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from collections import defaultdict
44
from functools import partial
5+
import re
56
from typing import (
67
Any,
78
Callable,
@@ -1253,7 +1254,9 @@ def _parse_latex_table_styles(table_styles: CSSStyles, selector: str) -> str | N
12531254
return None
12541255

12551256

1256-
def _parse_latex_cell_styles(latex_styles: CSSList, display_value: str) -> str:
1257+
def _parse_latex_cell_styles(
1258+
latex_styles: CSSList, display_value: str, convert_css: bool = False
1259+
) -> str:
12571260
r"""
12581261
Mutate the ``display_value`` string including LaTeX commands from ``latex_styles``.
12591262
@@ -1279,6 +1282,8 @@ def _parse_latex_cell_styles(latex_styles: CSSList, display_value: str) -> str:
12791282
For example for styles:
12801283
`[('c1', 'o1--wrap'), ('c2', 'o2')]` this returns: `{\c1o1 \c2o2{display_value}}
12811284
"""
1285+
if convert_css:
1286+
latex_styles = _parse_latex_css_conversion(latex_styles)
12821287
for (command, options) in latex_styles[::-1]: # in reverse for most recent style
12831288
formatter = {
12841289
"--wrap": f"{{\\{command}--to_parse {display_value}}}",
@@ -1351,6 +1356,82 @@ def _parse_latex_options_strip(value: str | int | float, arg: str) -> str:
13511356
return str(value).replace(arg, "").replace("/*", "").replace("*/", "").strip()
13521357

13531358

1359+
def _parse_latex_css_conversion(styles: CSSList) -> CSSList:
1360+
"""
1361+
Convert CSS (attribute,value) pairs to equivalent LaTeX (command,options) pairs.
1362+
1363+
Ignore conversion if tagged with `--latex` option, skipped if no conversion found.
1364+
"""
1365+
1366+
def font_weight(value, arg):
1367+
if value == "bold" or value == "bolder":
1368+
return "bfseries", f"{arg}"
1369+
return None
1370+
1371+
def font_style(value, arg):
1372+
if value == "italic":
1373+
return "itshape", f"{arg}"
1374+
elif value == "oblique":
1375+
return "slshape", f"{arg}"
1376+
return None
1377+
1378+
def color(value, user_arg, command, comm_arg):
1379+
"""
1380+
CSS colors have 5 formats to process:
1381+
1382+
- 6 digit hex code: "#ff23ee" --> [HTML]{FF23EE}
1383+
- 3 digit hex code: "#f0e" --> [HTML]{FF00EE}
1384+
- rgba: rgba(128, 255, 0, 0.5) --> [rgb]{0.502, 1.000, 0.000}
1385+
- rgb: rgb(128, 255, 0,) --> [rbg]{0.502, 1.000, 0.000}
1386+
- string: red --> {red}
1387+
1388+
Additionally rgb or rgba can be expressed in % which is also parsed.
1389+
"""
1390+
arg = user_arg if user_arg != "" else comm_arg
1391+
1392+
if value[0] == "#" and len(value) == 7: # color is hex code
1393+
return command, f"[HTML]{{{value[1:].upper()}}}{arg}"
1394+
if value[0] == "#" and len(value) == 4: # color is short hex code
1395+
val = f"{value[1].upper()*2}{value[2].upper()*2}{value[3].upper()*2}"
1396+
return command, f"[HTML]{{{val}}}{arg}"
1397+
elif value[:3] == "rgb": # color is rgb or rgba
1398+
r = re.findall("(?<=\\()[0-9\\s%]+(?=,)", value)[0].strip()
1399+
r = float(r[:-1]) / 100 if "%" in r else int(r) / 255
1400+
g = re.findall("(?<=,)[0-9\\s%]+(?=,)", value)[0].strip()
1401+
g = float(g[:-1]) / 100 if "%" in g else int(g) / 255
1402+
if value[3] == "a": # color is rgba
1403+
b = re.findall("(?<=,)[0-9\\s%]+(?=,)", value)[1].strip()
1404+
else: # color is rgb
1405+
b = re.findall("(?<=,)[0-9\\s%]+(?=\\))", value)[0].strip()
1406+
b = float(b[:-1]) / 100 if "%" in b else int(b) / 255
1407+
return command, f"[rgb]{{{r:.3f}, {g:.3f}, {b:.3f}}}{arg}"
1408+
else:
1409+
return command, f"{{{value}}}{arg}" # color is likely string-named
1410+
1411+
CONVERTED_ATTRIBUTES: dict[str, Callable] = {
1412+
"font-weight": font_weight,
1413+
"background-color": partial(color, command="cellcolor", comm_arg="--lwrap"),
1414+
"color": partial(color, command="color", comm_arg=""),
1415+
"font-style": font_style,
1416+
}
1417+
1418+
latex_styles: CSSList = []
1419+
for (attribute, value) in styles:
1420+
if isinstance(value, str) and "--latex" in value:
1421+
# return the style without conversion but drop '--latex'
1422+
latex_styles.append((attribute, value.replace("--latex", "")))
1423+
if attribute in CONVERTED_ATTRIBUTES.keys():
1424+
arg = ""
1425+
for x in ["--wrap", "--nowrap", "--lwrap", "--dwrap", "--rwrap"]:
1426+
if x in str(value):
1427+
arg, value = x, _parse_latex_options_strip(value, x)
1428+
break
1429+
latex_style = CONVERTED_ATTRIBUTES[attribute](value, arg)
1430+
if latex_style is not None:
1431+
latex_styles.extend([latex_style])
1432+
return latex_styles
1433+
1434+
13541435
def _escape_latex(s):
13551436
r"""
13561437
Replace the characters ``&``, ``%``, ``$``, ``#``, ``_``, ``{``, ``}``,

pandas/io/formats/templates/latex.tpl

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
{% endif %}
4040
{% for row in body %}
4141
{% for c in row %}{% if not loop.first %} & {% endif %}
42-
{%- if c.type == 'th' %}{{parse_header(c, multirow_align, multicol_align)}}{% else %}{{parse_cell(c.cellstyle, c.display_value)}}{% endif %}
42+
{%- if c.type == 'th' %}{{parse_header(c, multirow_align, multicol_align)}}{% else %}{{parse_cell(c.cellstyle, c.display_value, convert_css)}}{% endif %}
4343
{%- endfor %} \\
4444
{% endfor %}
4545
{% set bottomrule = parse_table(table_styles, 'bottomrule') %}

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

+46
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pandas.io.formats.style import Styler
1313
from pandas.io.formats.style_render import (
1414
_parse_latex_cell_styles,
15+
_parse_latex_css_conversion,
1516
_parse_latex_header_span,
1617
_parse_latex_table_styles,
1718
_parse_latex_table_wrapping,
@@ -443,3 +444,48 @@ def test_parse_latex_table_wrapping(styler):
443444
def test_short_caption(styler):
444445
result = styler.to_latex(caption=("full cap", "short cap"))
445446
assert "\\caption[short cap]{full cap}" in result
447+
448+
449+
@pytest.mark.parametrize(
450+
"css, expected",
451+
[
452+
([("color", "red")], [("color", "{red}")]), # test color and input format types
453+
(
454+
[("color", "rgb(128, 128, 128 )")],
455+
[("color", "[rgb]{0.502, 0.502, 0.502}")],
456+
),
457+
(
458+
[("color", "rgb(128, 50%, 25% )")],
459+
[("color", "[rgb]{0.502, 0.500, 0.250}")],
460+
),
461+
(
462+
[("color", "rgba(128,128,128,1)")],
463+
[("color", "[rgb]{0.502, 0.502, 0.502}")],
464+
),
465+
([("color", "#FF00FF")], [("color", "[HTML]{FF00FF}")]),
466+
([("color", "#F0F")], [("color", "[HTML]{FF00FF}")]),
467+
([("font-weight", "bold")], [("bfseries", "")]), # test font-weight and types
468+
([("font-weight", "bolder")], [("bfseries", "")]),
469+
([("font-weight", "normal")], []),
470+
([("background-color", "red")], [("cellcolor", "{red}--lwrap")]),
471+
(
472+
[("background-color", "#FF00FF")], # test background-color command and wrap
473+
[("cellcolor", "[HTML]{FF00FF}--lwrap")],
474+
),
475+
([("font-style", "italic")], [("itshape", "")]), # test font-style and types
476+
([("font-style", "oblique")], [("slshape", "")]),
477+
([("font-style", "normal")], []),
478+
([("color", "red /*--dwrap*/")], [("color", "{red}--dwrap")]), # css comments
479+
([("background-color", "red /* --dwrap */")], [("cellcolor", "{red}--dwrap")]),
480+
],
481+
)
482+
def test_parse_latex_css_conversion(css, expected):
483+
result = _parse_latex_css_conversion(css)
484+
assert result == expected
485+
486+
487+
def test_parse_latex_css_conversion_option():
488+
css = [("command", "option--latex--wrap")]
489+
expected = [("command", "option--wrap")]
490+
result = _parse_latex_css_conversion(css)
491+
assert result == expected

0 commit comments

Comments
 (0)