diff --git a/doc/source/whatsnew/v1.3.0.rst b/doc/source/whatsnew/v1.3.0.rst index 2f2e8aed6fdb8..8cc17da3d5647 100644 --- a/doc/source/whatsnew/v1.3.0.rst +++ b/doc/source/whatsnew/v1.3.0.rst @@ -142,7 +142,7 @@ properly format HTML and eliminate some inconsistencies (:issue:`39942` :issue:` One also has greater control of the display through separate sparsification of the index or columns, using the new 'styler' options context (:issue:`41142`). We have added an extension to allow LaTeX styling as an alternative to CSS styling and a method :meth:`.Styler.to_latex` -which renders the necessary LaTeX format including built-up styles. +which renders the necessary LaTeX format including built-up styles. An additional file io function :meth:`Styler.to_html` has been added for convenience (:issue:`40312`). Documentation has also seen major revisions in light of new features (:issue:`39720` :issue:`39317` :issue:`40493`) diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index 977a3a24f0844..2f4e51478aaf6 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -739,6 +739,74 @@ def to_latex( return save_to_buffer(latex, buf=buf, encoding=encoding) + def to_html( + self, + buf: FilePathOrBuffer[str] | None = None, + *, + table_uuid: str | None = None, + table_attributes: str | None = None, + encoding: str | None = None, + doctype_html: bool = False, + exclude_styles: bool = False, + ): + """ + Write Styler to a file, buffer or string in HTML-CSS format. + + .. versionadded:: 1.3.0 + + Parameters + ---------- + buf : str, Path, or StringIO-like, optional, default None + Buffer to write to. If ``None``, the output is returned as a string. + table_uuid: str, optional + Id attribute assigned to the HTML element in the format: + + ``
`` + + If not given uses Styler's initially assigned value. + table_attributes: str, optional + Attributes to assign within the `
` HTML element in the format: + + ``
>`` + + If not given defaults to Styler's preexisting value. + encoding : str, optional + Character encoding setting for file output, and HTML meta tags, + defaults to "utf-8" if None. + doctype_html : bool, default False + Whether to output a fully structured HTML file including all + HTML elements, or just the core `` -{% endblock style %} -{% block before_table %}{% endblock before_table %} -{% block table %} -
-{% block caption %} -{% if caption %} - +{# Update the template_structure.html documentation too #} +{% if doctype_html %} + + + + +{% if not exclude_styles %}{% include "html_style.tpl" %}{% endif %} + + +{% include "html_table.tpl" %} + + +{% elif not doctype_html %} +{% if not exclude_styles %}{% include "html_style.tpl" %}{% endif %} +{% include "html_table.tpl" %} {% endif %} -{% endblock caption %} -{% block thead %} - -{% block before_head_rows %}{% endblock %} -{% for r in head %} -{% block head_tr scoped %} - -{% for c in r %} -{% if c.is_visible != False %} - <{{c.type}} class="{{c.class}}" {{c.attributes}}>{{c.value}} -{% endif %} -{% endfor %} - -{% endblock head_tr %} -{% endfor %} -{% block after_head_rows %}{% endblock %} - -{% endblock thead %} -{% block tbody %} - -{% block before_rows %}{% endblock before_rows %} -{% for r in body %} -{% block tr scoped %} - -{% for c in r %} -{% if c.is_visible != False %} - <{{c.type}} {% if c.id is defined -%} id="T_{{uuid}}{{c.id}}" {%- endif %} class="{{c.class}}" {{c.attributes}}>{{c.display_value}} -{% endif %} -{% endfor %} - -{% endblock tr %} -{% endfor %} -{% block after_rows %}{% endblock after_rows %} - -{% endblock tbody %} -
{{caption}}
-{% endblock table %} -{% block after_table %}{% endblock after_table %} diff --git a/pandas/io/formats/templates/html_style.tpl b/pandas/io/formats/templates/html_style.tpl new file mode 100644 index 0000000000000..b34893076bedd --- /dev/null +++ b/pandas/io/formats/templates/html_style.tpl @@ -0,0 +1,24 @@ +{%- block before_style -%}{%- endblock before_style -%} +{% block style %} + +{% endblock style %} diff --git a/pandas/io/formats/templates/html_table.tpl b/pandas/io/formats/templates/html_table.tpl new file mode 100644 index 0000000000000..dadefa4bd8365 --- /dev/null +++ b/pandas/io/formats/templates/html_table.tpl @@ -0,0 +1,61 @@ +{% block before_table %}{% endblock before_table %} +{% block table %} +{% if exclude_styles %} + +{% else %} +
+{% endif %} +{% block caption %} +{% if caption %} + +{% endif %} +{% endblock caption %} +{% block thead %} + +{% block before_head_rows %}{% endblock %} +{% for r in head %} +{% block head_tr scoped %} + +{% if exclude_styles %} +{% for c in r %} +{% if c.is_visible != False %} + <{{c.type}} {{c.attributes}}>{{c.value}} +{% endif %} +{% endfor %} +{% else %} +{% for c in r %} +{% if c.is_visible != False %} + <{{c.type}} class="{{c.class}}" {{c.attributes}}>{{c.value}} +{% endif %} +{% endfor %} +{% endif %} + +{% endblock head_tr %} +{% endfor %} +{% block after_head_rows %}{% endblock %} + +{% endblock thead %} +{% block tbody %} + +{% block before_rows %}{% endblock before_rows %} +{% for r in body %} +{% block tr scoped %} + +{% if exclude_styles %} +{% for c in r %}{% if c.is_visible != False %} + <{{c.type}} {{c.attributes}}>{{c.display_value}} +{% endif %}{% endfor %} +{% else %} +{% for c in r %}{% if c.is_visible != False %} + <{{c.type}} {% if c.id is defined -%} id="T_{{uuid}}{{c.id}}" {%- endif %} class="{{c.class}}" {{c.attributes}}>{{c.display_value}} +{% endif %}{% endfor %} +{% endif %} + +{% endblock tr %} +{% endfor %} +{% block after_rows %}{% endblock after_rows %} + +{% endblock tbody %} +
{{caption}}
+{% endblock table %} +{% block after_table %}{% endblock after_table %} diff --git a/pandas/tests/io/formats/style/test_html.py b/pandas/tests/io/formats/style/test_html.py new file mode 100644 index 0000000000000..6c3abe04db926 --- /dev/null +++ b/pandas/tests/io/formats/style/test_html.py @@ -0,0 +1,233 @@ +from textwrap import dedent + +import pytest + +from pandas import DataFrame + +jinja2 = pytest.importorskip("jinja2") +from pandas.io.formats.style import Styler + +loader = jinja2.PackageLoader("pandas", "io/formats/templates") +env = jinja2.Environment(loader=loader, trim_blocks=True) + + +@pytest.fixture +def styler(): + return Styler(DataFrame([[2.61], [2.69]], index=["a", "b"], columns=["A"])) + + +@pytest.fixture +def tpl_style(): + return env.get_template("html_style.tpl") + + +@pytest.fixture +def tpl_table(): + return env.get_template("html_table.tpl") + + +def test_html_template_extends_options(): + # make sure if templates are edited tests are updated as are setup fixtures + # to understand the dependency + with open("pandas/io/formats/templates/html.tpl") as file: + result = file.read() + assert '{% include "html_style.tpl" %}' in result + assert '{% include "html_table.tpl" %}' in result + + +def test_exclude_styles(styler): + result = styler.to_html(exclude_styles=True, doctype_html=True) + expected = dedent( + """\ + + + + + + + + + + + + + + + + + + + + + + + +
 A
a2.610000
b2.690000
+ + + """ + ) + assert result == expected + + +def test_w3_html_format(styler): + styler.set_uuid("").set_table_styles( + [{"selector": "th", "props": "att2:v2;"}] + ).applymap(lambda x: "att1:v1;").set_table_attributes( + 'class="my-cls1" style="attr3:v3;"' + ).set_td_classes( + DataFrame(["my-cls2"], index=["a"], columns=["A"]) + ).format( + "{:.1f}" + ).set_caption( + "A comprehensive test" + ) + expected = dedent( + """\ + + + + + + + + + + + + + + + + + + + +
A comprehensive test
 A
a2.6
b2.7
+ """ + ) + assert expected == styler.render() + + +def test_colspan_w3(): + # GH 36223 + df = DataFrame(data=[[1, 2]], columns=[["l0", "l0"], ["l1a", "l1b"]]) + styler = Styler(df, uuid="_", cell_ids=False) + assert 'l0' in styler.render() + + +def test_rowspan_w3(): + # GH 38533 + df = DataFrame(data=[[1, 2]], index=[["l0", "l0"], ["l1a", "l1b"]]) + styler = Styler(df, uuid="_", cell_ids=False) + assert ( + 'l0' in styler.render() + ) + + +def test_styles(styler): + styler.set_uuid("abc_") + styler.set_table_styles([{"selector": "td", "props": "color: red;"}]) + result = styler.to_html(doctype_html=True) + expected = dedent( + """\ + + + + + + + + + + + + + + + + + + + + + + + + +
 A
a2.610000
b2.690000
+ + + """ + ) + assert result == expected + + +def test_doctype(styler): + result = styler.to_html(doctype_html=False) + assert "" not in result + assert "" not in result + assert "" not in result + assert "" not in result + + +def test_block_names(tpl_style, tpl_table): + # catch accidental removal of a block + expected_style = { + "before_style", + "style", + "table_styles", + "before_cellstyle", + "cellstyle", + } + expected_table = { + "before_table", + "table", + "caption", + "thead", + "tbody", + "after_table", + "before_head_rows", + "head_tr", + "after_head_rows", + "before_rows", + "tr", + "after_rows", + } + result1 = set(tpl_style.blocks) + assert result1 == expected_style + + result2 = set(tpl_table.blocks) + assert result2 == expected_table + + +def test_from_custom_template(tmpdir): + p = tmpdir.mkdir("templates").join("myhtml.tpl") + p.write( + dedent( + """\ + {% extends "html.tpl" %} + {% block table %} +

{{ table_title|default("My Table") }}

+ {{ super() }} + {% endblock table %}""" + ) + ) + result = Styler.from_custom_template(str(tmpdir.join("templates")), "myhtml.tpl") + assert issubclass(result, Styler) + assert result.env is not Styler.env + assert result.template_html is not Styler.template_html + styler = result(DataFrame({"A": [1, 2]})) + assert styler.render() diff --git a/pandas/tests/io/formats/style/test_style.py b/pandas/tests/io/formats/style/test_style.py index c556081b5f562..12b4a13ade271 100644 --- a/pandas/tests/io/formats/style/test_style.py +++ b/pandas/tests/io/formats/style/test_style.py @@ -1,6 +1,5 @@ import copy import re -import textwrap import numpy as np import pytest @@ -1293,21 +1292,6 @@ def test_column_and_row_styling(self): ) assert "#T__ .row0 {\n color: blue;\n}" in s.render() - def test_colspan_w3(self): - # GH 36223 - df = DataFrame(data=[[1, 2]], columns=[["l0", "l0"], ["l1a", "l1b"]]) - s = Styler(df, uuid="_", cell_ids=False) - assert 'l0' in s.render() - - def test_rowspan_w3(self): - # GH 38533 - df = DataFrame(data=[[1, 2]], index=[["l0", "l0"], ["l1a", "l1b"]]) - s = Styler(df, uuid="_", cell_ids=False) - assert ( - 'l0' in s.render() - ) - @pytest.mark.parametrize("len_", [1, 5, 32, 33, 100]) def test_uuid_len(self, len_): # GH 36345 @@ -1328,49 +1312,6 @@ def test_uuid_len_raises(self, len_): with pytest.raises(TypeError, match=msg): Styler(df, uuid_len=len_, cell_ids=False).render() - def test_w3_html_format(self): - s = ( - Styler( - DataFrame([[2.61], [2.69]], index=["a", "b"], columns=["A"]), - uuid_len=0, - ) - .set_table_styles([{"selector": "th", "props": "att2:v2;"}]) - .applymap(lambda x: "att1:v1;") - .set_table_attributes('class="my-cls1" style="attr3:v3;"') - .set_td_classes(DataFrame(["my-cls2"], index=["a"], columns=["A"])) - .format("{:.1f}") - .set_caption("A comprehensive test") - ) - expected = """ - - - - - - - - - - - - - - - - - - -
A comprehensive test
 A
a2.6
b2.7
-""" - assert expected == s.render() - @pytest.mark.parametrize( "slc", [ @@ -1457,48 +1398,3 @@ def test_non_reducing_multi_slice_on_multiindex(self, slice_): expected = df.loc[slice_] result = df.loc[non_reducing_slice(slice_)] tm.assert_frame_equal(result, expected) - - -def test_block_names(): - # catch accidental removal of a block - expected = { - "before_style", - "style", - "table_styles", - "before_cellstyle", - "cellstyle", - "before_table", - "table", - "caption", - "thead", - "tbody", - "after_table", - "before_head_rows", - "head_tr", - "after_head_rows", - "before_rows", - "tr", - "after_rows", - } - result = set(Styler.template_html.blocks) - assert result == expected - - -def test_from_custom_template(tmpdir): - p = tmpdir.mkdir("templates").join("myhtml.tpl") - p.write( - textwrap.dedent( - """\ - {% extends "html.tpl" %} - {% block table %} -

{{ table_title|default("My Table") }}

- {{ super() }} - {% endblock table %}""" - ) - ) - result = Styler.from_custom_template(str(tmpdir.join("templates")), "myhtml.tpl") - assert issubclass(result, Styler) - assert result.env is not Styler.env - assert result.template_html is not Styler.template_html - styler = result(DataFrame({"A": [1, 2]})) - assert styler.render()