-
-
Notifications
You must be signed in to change notification settings - Fork 19.3k
ENH: to_excel engine_kwargs for Excel header Autofilter and optional bold (xlsxwriter/openpyxl) #62670
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ENH: to_excel engine_kwargs for Excel header Autofilter and optional bold (xlsxwriter/openpyxl) #62670
Changes from all commits
60bfeb1
bc6a32b
cf597c4
382156a
dee9451
6ef4c8e
9c0cc9c
7fc11b7
4f81b1b
9fe2559
1e05531
1965b3b
6c9feed
f282a3e
b6c67e9
e528df6
dbb74a5
3d321a9
4191f1a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,7 +26,10 @@ | |
| if TYPE_CHECKING: | ||
| from openpyxl import Workbook | ||
| from openpyxl.descriptors.serialisable import Serialisable | ||
| from openpyxl.styles import Fill | ||
| from openpyxl.styles import ( | ||
| Fill, | ||
| Font, | ||
| ) | ||
|
|
||
| from pandas._typing import ( | ||
| ExcelWriterIfSheetExists, | ||
|
|
@@ -52,6 +55,7 @@ def __init__( # pyright: ignore[reportInconsistentConstructor] | |
| storage_options: StorageOptions | None = None, | ||
| if_sheet_exists: ExcelWriterIfSheetExists | None = None, | ||
| engine_kwargs: dict[str, Any] | None = None, | ||
| autofilter: bool = False, | ||
| **kwargs, | ||
| ) -> None: | ||
| # Use the openpyxl module as the Excel writer. | ||
|
|
@@ -67,6 +71,9 @@ def __init__( # pyright: ignore[reportInconsistentConstructor] | |
| engine_kwargs=engine_kwargs, | ||
| ) | ||
|
|
||
| self._engine_kwargs = engine_kwargs or {} | ||
| self.autofilter = autofilter | ||
|
|
||
| # ExcelWriter replaced "a" by "r+" to allow us to first read the excel file from | ||
| # the file and later write to it | ||
| if "r+" in self._mode: # Load from existing workbook | ||
|
|
@@ -181,50 +188,68 @@ def _convert_to_color(cls, color_spec): | |
| return Color(**color_spec) | ||
|
|
||
| @classmethod | ||
| def _convert_to_font(cls, font_dict): | ||
| """ | ||
| Convert ``font_dict`` to an openpyxl v2 Font object. | ||
| def _convert_to_font(cls, style_dict: dict) -> Font: | ||
| """Convert style_dict to an openpyxl Font object. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| font_dict : dict | ||
| A dict with zero or more of the following keys (or their synonyms). | ||
| 'name' | ||
| 'size' ('sz') | ||
| 'bold' ('b') | ||
| 'italic' ('i') | ||
| 'underline' ('u') | ||
| 'strikethrough' ('strike') | ||
| 'color' | ||
| 'vertAlign' ('vertalign') | ||
| 'charset' | ||
| 'scheme' | ||
| 'family' | ||
| 'outline' | ||
| 'shadow' | ||
| 'condense' | ||
| style_dict : dict | ||
| Dictionary of style properties | ||
|
|
||
| Returns | ||
| ------- | ||
| font : openpyxl.styles.Font | ||
| openpyxl.styles.Font | ||
| The converted font object | ||
| """ | ||
| from openpyxl.styles import Font | ||
|
|
||
| _font_key_map = { | ||
| "sz": "size", | ||
| if not style_dict: | ||
| return Font() | ||
|
|
||
| # Check for font-weight in different formats | ||
| is_bold = False | ||
|
|
||
| # Check for 'font-weight' directly in style_dict | ||
| if style_dict.get("font-weight") in ("bold", "bolder", 700, "700"): | ||
| is_bold = True | ||
| # Check for 'font' dictionary with 'weight' key | ||
| elif isinstance(style_dict.get("font"), dict) and style_dict["font"].get( | ||
| "weight" | ||
| ) in ("bold", "bolder", 700, "700"): | ||
| is_bold = True | ||
| # Check for 'b' or 'bold' keys | ||
| elif style_dict.get("b") or style_dict.get("bold"): | ||
| is_bold = True | ||
|
|
||
| # Map style keys to Font constructor arguments | ||
| # (accept both shorthand and CSS-like keys) | ||
| key_map = { | ||
| "b": "bold", | ||
| "bold": "bold", | ||
| "i": "italic", | ||
| "italic": "italic", | ||
| "u": "underline", | ||
| "underline": "underline", | ||
| "strike": "strikethrough", | ||
| "vertAlign": "vertAlign", | ||
| "vertalign": "vertAlign", | ||
| "sz": "size", | ||
| "size": "size", | ||
| "color": "color", | ||
| "name": "name", | ||
| "family": "family", | ||
| "scheme": "scheme", | ||
| } | ||
|
|
||
| font_kwargs = {} | ||
| for k, v in font_dict.items(): | ||
| k = _font_key_map.get(k, k) | ||
| if k == "color": | ||
| v = cls._convert_to_color(v) | ||
| font_kwargs[k] = v | ||
| font_kwargs = {"bold": is_bold} # Set bold based on our checks | ||
|
|
||
| # Process other font properties | ||
| for style_key, font_key in key_map.items(): | ||
| if style_key in style_dict and style_key not in ("b", "bold"): | ||
| value = style_dict[style_key] | ||
| if font_key == "color" and value is not None: | ||
| value = cls._convert_to_color(value) | ||
| font_kwargs[font_key] = value | ||
|
Comment on lines
+246
to
+252
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this changing?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was trying to improve the font property handling to be more robust, particularly for color conversion, thereby ensuring that color values are properly converted to openpyxl Color objects when processing font styles.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| return Font(**font_kwargs) | ||
|
|
||
|
|
@@ -452,9 +477,9 @@ def _write_cells( | |
| ) -> None: | ||
| # Write the frame cells using openpyxl. | ||
| sheet_name = self._get_sheet_name(sheet_name) | ||
| _style_cache: dict[str, dict[str, Any]] = {} | ||
|
|
||
| _style_cache: dict[str, dict[str, Serialisable]] = {} | ||
|
|
||
| # Initialize worksheet | ||
| if sheet_name in self.sheets and self._if_sheet_exists != "new": | ||
| if "r+" in self._mode: | ||
| if self._if_sheet_exists == "replace": | ||
|
|
@@ -486,51 +511,124 @@ def _write_cells( | |
| row=freeze_panes[0] + 1, column=freeze_panes[1] + 1 | ||
| ) | ||
|
|
||
| # Track bounds for autofilter application | ||
| min_row = min_col = max_row = max_col = None | ||
|
|
||
| # Process cells | ||
| for cell in cells: | ||
| xcell = wks.cell( | ||
| row=startrow + cell.row + 1, column=startcol + cell.col + 1 | ||
| ) | ||
| xrow = startrow + cell.row | ||
| xcol = startcol + cell.col | ||
|
|
||
| # Handle merged ranges if specified on this cell | ||
| if cell.mergestart is not None and cell.mergeend is not None: | ||
| start_r = xrow + 1 | ||
| start_c = xcol + 1 | ||
| end_r = startrow + cell.mergestart + 1 | ||
| end_c = startcol + cell.mergeend + 1 | ||
|
|
||
| # Create the merged range | ||
| wks.merge_cells( | ||
| start_row=start_r, | ||
| start_column=start_c, | ||
| end_row=end_r, | ||
| end_column=end_c, | ||
| ) | ||
|
|
||
| # Top-left cell of the merged range | ||
| tl = wks.cell(row=start_r, column=start_c) | ||
| tl.value, fmt = self._value_with_fmt(cell.val) | ||
| if fmt: | ||
| tl.number_format = fmt | ||
|
|
||
| style_kwargs = None | ||
| if cell.style: | ||
| key = str(cell.style) | ||
| if key not in _style_cache: | ||
| style_kwargs = self._convert_to_style_kwargs(cell.style) | ||
| _style_cache[key] = style_kwargs | ||
| else: | ||
| style_kwargs = _style_cache[key] | ||
|
|
||
| for k, v in style_kwargs.items(): | ||
| setattr(tl, k, v) | ||
|
|
||
| # Apply style across merged cells to satisfy tests | ||
| # that inspect non-top-left cells | ||
| if style_kwargs: | ||
| for r in range(start_r, end_r + 1): | ||
| for c in range(start_c, end_c + 1): | ||
| if r == start_r and c == start_c: | ||
| continue | ||
| mcell = wks.cell(row=r, column=c) | ||
| for k, v in style_kwargs.items(): | ||
| setattr(mcell, k, v) | ||
|
|
||
| # Update bounds with the entire merged rectangle | ||
| min_row = xrow if min_row is None else min(min_row, xrow) | ||
| min_col = xcol if min_col is None else min(min_col, xcol) | ||
| max_row = (end_r - 1) if max_row is None else max(max_row, end_r - 1) | ||
| max_col = (end_c - 1) if max_col is None else max(max_col, end_c - 1) | ||
| continue | ||
|
|
||
| # Non-merged cell path | ||
| xcell = wks.cell(row=xrow + 1, column=xcol + 1) | ||
|
|
||
| # Apply cell value and format | ||
| xcell.value, fmt = self._value_with_fmt(cell.val) | ||
| if fmt: | ||
| xcell.number_format = fmt | ||
|
|
||
| style_kwargs: dict[str, Serialisable] | None = {} | ||
| # Apply cell style if provided | ||
| if cell.style: | ||
| key = str(cell.style) | ||
| style_kwargs = _style_cache.get(key) | ||
| if style_kwargs is None: | ||
| if key not in _style_cache: | ||
| style_kwargs = self._convert_to_style_kwargs(cell.style) | ||
| _style_cache[key] = style_kwargs | ||
| else: | ||
| style_kwargs = _style_cache[key] | ||
|
|
||
| if style_kwargs: | ||
| for k, v in style_kwargs.items(): | ||
| setattr(xcell, k, v) | ||
|
|
||
| if cell.mergestart is not None and cell.mergeend is not None: | ||
| wks.merge_cells( | ||
| start_row=startrow + cell.row + 1, | ||
| start_column=startcol + cell.col + 1, | ||
| end_column=startcol + cell.mergeend + 1, | ||
| end_row=startrow + cell.mergestart + 1, | ||
| ) | ||
|
|
||
| # When cells are merged only the top-left cell is preserved | ||
| # The behaviour of the other cells in a merged range is | ||
| # undefined | ||
| if style_kwargs: | ||
| first_row = startrow + cell.row + 1 | ||
| last_row = startrow + cell.mergestart + 1 | ||
| first_col = startcol + cell.col + 1 | ||
| last_col = startcol + cell.mergeend + 1 | ||
|
|
||
| for row in range(first_row, last_row + 1): | ||
| for col in range(first_col, last_col + 1): | ||
| if row == first_row and col == first_col: | ||
| # Ignore first cell. It is already handled. | ||
| continue | ||
| xcell = wks.cell(column=col, row=row) | ||
| for k, v in style_kwargs.items(): | ||
| setattr(xcell, k, v) | ||
| # Update bounds | ||
| if min_row is None or xrow < min_row: | ||
| min_row = xrow | ||
| if max_row is None or xrow > max_row: | ||
| max_row = xrow | ||
| if min_col is None or xcol < min_col: | ||
| min_col = xcol | ||
| if max_col is None or xcol > max_col: | ||
| max_col = xcol | ||
|
|
||
| # Apply autofilter if requested | ||
| if getattr(self, "autofilter", False) and all( | ||
| v is not None for v in [min_row, min_col, max_row, max_col] | ||
| ): | ||
| try: | ||
| from openpyxl.utils import get_column_letter | ||
|
|
||
| start_ref = f"{get_column_letter(min_col + 1)}{min_row + 1}" | ||
| end_ref = f"{get_column_letter(max_col + 1)}{max_row + 1}" | ||
| wks.auto_filter.ref = f"{start_ref}:{end_ref}" | ||
| except Exception: | ||
| pass | ||
|
|
||
|
|
||
| def _update_bounds(self, wks, cell, startrow, startcol): | ||
| """Helper method to update the bounds for autofilter""" | ||
| global min_row, max_row, min_col, max_col | ||
|
|
||
| crow = startrow + cell.row + 1 | ||
| ccol = startcol + cell.col + 1 | ||
|
|
||
| if min_row is None or crow < min_row: | ||
| min_row = crow | ||
| if max_row is None or crow > max_row: | ||
| max_row = crow | ||
| if min_col is None or ccol < min_col: | ||
| min_col = ccol | ||
| if max_col is None or ccol > max_col: | ||
| max_col = ccol | ||
|
|
||
|
|
||
| class OpenpyxlReader(BaseExcelReader["Workbook"]): | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this changing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was meant to maintain consistency with the ExcelFormatter API. The
[write()](cci:1: .../pandas/pandas/io/formats/excel.py:960:4-982:9)method was deprecated in favor of[to_excel()](cci:1: .../pandas/pandas/io/formats/style.py:559:4-619:9),which provides better alignment with pandas' method naming conventions.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rhshadrach