diff --git a/doc/source/user_guide/io.rst b/doc/source/user_guide/io.rst index ec0e7d0636b07..bcb03eade8fcf 100644 --- a/doc/source/user_guide/io.rst +++ b/doc/source/user_guide/io.rst @@ -3880,6 +3880,19 @@ The look and feel of Excel worksheets created from pandas can be modified using * ``float_format`` : Format string for floating point numbers (default ``None``). * ``freeze_panes`` : A tuple of two integers representing the bottommost row and rightmost column to freeze. Each of these parameters is one-based, so (1, 1) will freeze the first row and first column (default ``None``). +The styling and formatting of a worksheet's header and body can be easily customized using CSS by passing a dictionary object to +``pd.io.formats.excel.ExcelFormatter.header_style`` and ``pd.io.formats.excel.ExcelFormatter.body_style``. + +.. code-block:: python + + # Set Excel worksheet header and body style + >>> df = pd.DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}]) + >>> pd.io.formats.excel.ExcelFormatter.header_style = {"font": {"bold": True},} + >>> pd.io.formats.excel.ExcelFormatter.body_style = { + ... "font": {"bold": True},"alignment": {"horizontal": "center", "vertical": "top"}, + ... } + >>> df.to_excel("path_to_file.xlsx", "Sheet1", index=False) + Using the `Xlsxwriter`_ engine provides many options for controlling the format of an Excel worksheet created with the ``to_excel`` method. Excellent examples can be found in the `Xlsxwriter`_ documentation here: https://xlsxwriter.readthedocs.io/working_with_pandas.html diff --git a/doc/source/whatsnew/v2.1.0.rst b/doc/source/whatsnew/v2.1.0.rst index 0f669beaa036f..e58354a26a44f 100644 --- a/doc/source/whatsnew/v2.1.0.rst +++ b/doc/source/whatsnew/v2.1.0.rst @@ -124,6 +124,7 @@ Other enhancements - Added a new parameter ``by_row`` to :meth:`Series.apply` and :meth:`DataFrame.apply`. When set to ``False`` the supplied callables will always operate on the whole Series or DataFrame (:issue:`53400`, :issue:`53601`). - Groupby aggregations (such as :meth:`DataFrameGroupby.sum`) now can preserve the dtype of the input instead of casting to ``float64`` (:issue:`44952`) - Improved error message when :meth:`DataFrameGroupBy.agg` failed (:issue:`52930`) +- Made :attr:`ExcelFormatter.header_style` a class attribute instead of a property. Default styles for :meth:`DataFrame.to_excel` are set to None. (:issue:`52369`) - Many read/to_* functions, such as :meth:`DataFrame.to_pickle` and :func:`read_csv`, support forwarding compression arguments to lzma.LZMAFile (:issue:`52979`) - Performance improvement in :func:`concat` with homogeneous ``np.float64`` or ``np.float32`` dtypes (:issue:`52685`) - Performance improvement in :meth:`DataFrame.filter` when ``items`` is given (:issue:`52941`) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 0dbb6529cd384..f74c42260531f 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -577,19 +577,26 @@ def __init__( self.header = header self.merge_cells = merge_cells self.inf_rep = inf_rep + self._header_style: dict[str, Any] | None = None + self._body_style: dict[str, Any] | None = None @property - def header_style(self) -> dict[str, dict[str, str | bool]]: - return { - "font": {"bold": True}, - "borders": { - "top": "thin", - "right": "thin", - "bottom": "thin", - "left": "thin", - }, - "alignment": {"horizontal": "center", "vertical": "top"}, - } + def header_style(self): + return self._header_style + + @header_style.setter + def header_style(self, val) -> None: + if isinstance(val, dict): + self._header_style = val + + @property + def body_style(self): + return self._body_style + + @body_style.setter + def body_style(self, val) -> None: + if isinstance(val, dict): + self._body_style = val def _format_value(self, val): if is_scalar(val) and missing.isna(val): @@ -876,7 +883,7 @@ def _generate_body(self, coloffset: int) -> Iterable[ExcelCell]: row=self.rowcounter + i, col=colidx + coloffset, val=val, - style=None, + style=self.body_style, css_styles=getattr(self.styler, "ctx", None), css_row=i, css_col=colidx, diff --git a/pandas/tests/io/excel/test_style.py b/pandas/tests/io/excel/test_style.py index 710f1f272cd7f..ab6b265b7334c 100644 --- a/pandas/tests/io/excel/test_style.py +++ b/pandas/tests/io/excel/test_style.py @@ -6,6 +6,7 @@ import pandas.util._test_decorators as td +import pandas as pd from pandas import ( DataFrame, read_excel, @@ -15,7 +16,7 @@ from pandas.io.excel import ExcelWriter from pandas.io.formats.excel import ExcelFormatter -pytest.importorskip("jinja2") +# pytest.importorskip("jinja2") # jinja2 is currently required for Styler.__init__(). Technically Styler.to_excel # could compute styles and render to excel without jinja2, since there is no # 'template' file, but this needs the import error to delayed until render time. @@ -35,7 +36,7 @@ def assert_equal_cell_styles(cell1, cell2): "engine", ["xlsxwriter", "openpyxl"], ) -def test_styler_to_excel_unstyled(engine): +def test_styler_to_excel_nostyle(engine): # compare DataFrame.to_excel and Styler.to_excel when no styles applied pytest.importorskip(engine) df = DataFrame(np.random.randn(2, 2)) @@ -254,6 +255,125 @@ def test_styler_to_excel_border_style(engine, border_style): assert s_cell == expected +def test_styler_update_values(): + # GH 53973 + openpyxl = pytest.importorskip("openpyxl") + df = DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}]) + style = { + "font": {"bold": True}, + "borders": { + "top": "thin", + "right": "thin", + "bottom": "thin", + "left": "thin", + }, + "alignment": {"horizontal": "center", "vertical": "top"}, + } + + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine="openpyxl") as writer: + # write to sheet 'custom' + pd.io.formats.excel.ExcelFormatter.header_style = style + pd.io.formats.excel.ExcelFormatter.body_style = style + df.to_excel(writer, sheet_name="custom") + + # write to sheet 'default' + pd.io.formats.excel.ExcelFormatter.header_style = None + pd.io.formats.excel.ExcelFormatter.body_style = None + df.to_excel(writer, sheet_name="default") + + with contextlib.closing(openpyxl.load_workbook(path)) as wb: + # Check font, spacing, indentation + assert wb["custom"].cell(1, 2).font.bold is True + assert wb["custom"].cell(1, 2).alignment.horizontal == "center" + assert wb["custom"].cell(1, 2).alignment.vertical == "top" + assert wb["default"].cell(1, 2).font.bold is False + assert wb["default"].cell(1, 2).alignment.horizontal is None + assert wb["default"].cell(1, 2).alignment.vertical is None + + # Check border + assert wb["custom"].cell(1, 2).border.bottom.border_style == "thin" + assert wb["custom"].cell(1, 2).border.top.border_style == "thin" + assert wb["custom"].cell(1, 2).border.left.border_style == "thin" + assert wb["custom"].cell(1, 2).border.right.border_style == "thin" + assert wb["default"].cell(1, 2).border.bottom.border_style is None + assert wb["default"].cell(1, 2).border.top.border_style is None + assert wb["default"].cell(1, 2).border.left.border_style is None + assert wb["default"].cell(1, 2).border.right.border_style is None + + +def test_styler_custom_values(): + # GH 53973 + openpyxl = pytest.importorskip("openpyxl") + pd.io.formats.excel.ExcelFormatter.header_style = { + "font": {"bold": True}, + "borders": { + "top": "thin", + "right": "thin", + "bottom": "thin", + "left": "thin", + }, + "alignment": {"horizontal": "center", "vertical": "top"}, + } + + pd.io.formats.excel.ExcelFormatter.body_style = { + "font": {"bold": False}, + "borders": { + "top": "thin", + "right": "thin", + "bottom": "thin", + "left": "thin", + }, + "alignment": {"horizontal": "right", "vertical": "top"}, + } + + df = DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}]) + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine="openpyxl") as writer: + df.to_excel(writer, sheet_name="custom") + + with contextlib.closing(openpyxl.load_workbook(path)) as wb: + # Check font, spacing, indentation + assert wb["custom"].cell(1, 2).font.bold is True + assert wb["custom"].cell(2, 2).font.bold is False + assert wb["custom"].cell(1, 2).alignment.horizontal == "center" + assert wb["custom"].cell(1, 2).alignment.vertical == "top" + assert wb["custom"].cell(2, 2).alignment.horizontal == "right" + assert wb["custom"].cell(2, 2).alignment.vertical == "top" + + # Check border + assert wb["custom"].cell(1, 2).border.bottom.border_style == "thin" + assert wb["custom"].cell(1, 2).border.top.border_style == "thin" + assert wb["custom"].cell(1, 2).border.left.border_style == "thin" + assert wb["custom"].cell(1, 2).border.right.border_style == "thin" + assert wb["custom"].cell(2, 2).border.bottom.border_style == "thin" + assert wb["custom"].cell(2, 2).border.top.border_style == "thin" + assert wb["custom"].cell(2, 2).border.left.border_style == "thin" + assert wb["custom"].cell(2, 2).border.right.border_style == "thin" + + +def test_styler_default_values(): + # GH 53973 + openpyxl = pytest.importorskip("openpyxl") + + df = DataFrame([{"A": 1, "B": 2, "C": 3}, {"A": 1, "B": 2, "C": 3}]) + with tm.ensure_clean(".xlsx") as path: + with ExcelWriter(path, engine="openpyxl") as writer: + df.to_excel(writer, sheet_name="custom") + + with contextlib.closing(openpyxl.load_workbook(path)) as wb: + # Check font, spacing, indentation + assert wb["custom"].cell(1, 1).font.bold is False + assert wb["custom"].cell(1, 1).alignment.horizontal is None + assert wb["custom"].cell(1, 1).alignment.vertical is None + + # Check border + assert wb["custom"].cell(1, 1).border.bottom.color is None + assert wb["custom"].cell(1, 1).border.top.color is None + assert wb["custom"].cell(1, 1).border.left.color is None + assert wb["custom"].cell(1, 1).border.right.color is None + + def test_styler_custom_converter(): openpyxl = pytest.importorskip("openpyxl")