Skip to content

Commit 60bfeb1

Browse files
committed
ENH: to_excel engine_kwargs for header autofilter and header bold (xlsxwriter/openpyxl); tests. Closes #62651
1 parent 1863adb commit 60bfeb1

File tree

5 files changed

+135
-3
lines changed

5 files changed

+135
-3
lines changed

pandas/io/excel/_openpyxl.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ def __init__( # pyright: ignore[reportInconsistentConstructor]
6767
engine_kwargs=engine_kwargs,
6868
)
6969

70+
# Persist engine kwargs for later feature toggles (e.g., autofilter/header bold)
71+
self._engine_kwargs = engine_kwargs
72+
7073
# ExcelWriter replaced "a" by "r+" to allow us to first read the excel file from
7174
# the file and later write to it
7275
if "r+" in self._mode: # Load from existing workbook
@@ -486,6 +489,15 @@ def _write_cells(
486489
row=freeze_panes[0] + 1, column=freeze_panes[1] + 1
487490
)
488491

492+
# Track bounds for autofilter application
493+
min_row = None
494+
min_col = None
495+
max_row = None
496+
max_col = None
497+
498+
# Prepare header bold setting
499+
header_bold = bool(self._engine_kwargs.get("header_bold", False)) if hasattr(self, "_engine_kwargs") else False
500+
489501
for cell in cells:
490502
xcell = wks.cell(
491503
row=startrow + cell.row + 1, column=startcol + cell.col + 1
@@ -506,6 +518,26 @@ def _write_cells(
506518
for k, v in style_kwargs.items():
507519
setattr(xcell, k, v)
508520

521+
# Update bounds
522+
crow = startrow + cell.row + 1
523+
ccol = startcol + cell.col + 1
524+
if min_row is None or crow < min_row:
525+
min_row = crow
526+
if min_col is None or ccol < min_col:
527+
min_col = ccol
528+
if max_row is None or crow > max_row:
529+
max_row = crow
530+
if max_col is None or ccol > max_col:
531+
max_col = ccol
532+
533+
# Apply bold to first header row cells if requested
534+
if header_bold and (cell.row == 0):
535+
try:
536+
from openpyxl.styles import Font
537+
xcell.font = Font(bold=True)
538+
except Exception:
539+
pass
540+
509541
if cell.mergestart is not None and cell.mergeend is not None:
510542
wks.merge_cells(
511543
start_row=startrow + cell.row + 1,
@@ -532,6 +564,17 @@ def _write_cells(
532564
for k, v in style_kwargs.items():
533565
setattr(xcell, k, v)
534566

567+
# Apply autofilter over the used range if requested
568+
if hasattr(self, "_engine_kwargs") and bool(self._engine_kwargs.get("autofilter_header", False)):
569+
if min_row is not None and min_col is not None and max_row is not None and max_col is not None:
570+
try:
571+
from openpyxl.utils import get_column_letter
572+
start_ref = f"{get_column_letter(min_col)}{min_row}"
573+
end_ref = f"{get_column_letter(max_col)}{max_row}"
574+
wks.auto_filter.ref = f"{start_ref}:{end_ref}"
575+
except Exception:
576+
pass
577+
535578

536579
class OpenpyxlReader(BaseExcelReader["Workbook"]):
537580
@doc(storage_options=_shared_docs["storage_options"])

pandas/io/excel/_xlsxwriter.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ def __init__( # pyright: ignore[reportInconsistentConstructor]
212212
engine_kwargs=engine_kwargs,
213213
)
214214

215+
self._engine_kwargs = engine_kwargs
216+
215217
try:
216218
self._book = Workbook(self._handles.handle, **engine_kwargs) # type: ignore[arg-type]
217219
except TypeError:
@@ -258,6 +260,14 @@ def _write_cells(
258260
if validate_freeze_panes(freeze_panes):
259261
wks.freeze_panes(*(freeze_panes))
260262

263+
min_row = None
264+
min_col = None
265+
max_row = None
266+
max_col = None
267+
268+
header_bold = bool(self._engine_kwargs.get("header_bold", False)) if hasattr(self, "_engine_kwargs") else False
269+
bold_format = self.book.add_format({"bold": True}) if header_bold else None
270+
261271
for cell in cells:
262272
val, fmt = self._value_with_fmt(cell.val)
263273

@@ -271,14 +281,35 @@ def _write_cells(
271281
style = self.book.add_format(_XlsxStyler.convert(cell.style, fmt))
272282
style_dict[stylekey] = style
273283

284+
crow = startrow + cell.row
285+
ccol = startcol + cell.col
286+
if min_row is None or crow < min_row:
287+
min_row = crow
288+
if min_col is None or ccol < min_col:
289+
min_col = ccol
290+
if max_row is None or crow > max_row:
291+
max_row = crow
292+
if max_col is None or ccol > max_col:
293+
max_col = ccol
294+
274295
if cell.mergestart is not None and cell.mergeend is not None:
275296
wks.merge_range(
276-
startrow + cell.row,
277-
startcol + cell.col,
297+
crow,
298+
ccol,
278299
startrow + cell.mergestart,
279300
startcol + cell.mergeend,
280301
val,
281302
style,
282303
)
283304
else:
284-
wks.write(startrow + cell.row, startcol + cell.col, val, style)
305+
if bold_format is not None and (startrow == crow):
306+
wks.write(crow, ccol, val, bold_format)
307+
else:
308+
wks.write(crow, ccol, val, style)
309+
310+
if hasattr(self, "_engine_kwargs") and bool(self._engine_kwargs.get("autofilter_header", False)):
311+
if min_row is not None and min_col is not None and max_row is not None and max_col is not None:
312+
try:
313+
wks.autofilter(min_row, min_col, max_row, max_col)
314+
except Exception:
315+
pass

pandas/io/formats/excel.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,15 @@ def write(
922922
formatted_cells = self.get_formatted_cells()
923923
if isinstance(writer, ExcelWriter):
924924
need_save = False
925+
# Propagate engine_kwargs to an existing writer instance if provided
926+
if engine_kwargs:
927+
try:
928+
current = getattr(writer, "_engine_kwargs", {}) or {}
929+
merged = {**current, **engine_kwargs}
930+
setattr(writer, "_engine_kwargs", merged)
931+
except Exception:
932+
# Best-effort propagation; ignore if engine does not support it
933+
pass
925934
else:
926935
writer = ExcelWriter(
927936
writer,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import io
2+
import pytest
3+
import pandas as pd
4+
5+
openpyxl = pytest.importorskip("openpyxl")
6+
7+
8+
def test_to_excel_openpyxl_autofilter_and_bold():
9+
df = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
10+
buf = io.BytesIO()
11+
with pd.ExcelWriter(buf, engine="openpyxl") as writer:
12+
df.to_excel(
13+
writer,
14+
index=False,
15+
engine_kwargs={"autofilter_header": True, "header_bold": True},
16+
)
17+
buf.seek(0)
18+
wb = openpyxl.load_workbook(buf)
19+
ws = wb.active
20+
# Autofilter should be set spanning header+data
21+
assert ws.auto_filter is not None
22+
assert ws.auto_filter.ref is not None and ws.auto_filter.ref != ""
23+
# Header row (row 1) should be bold
24+
assert all(ws.cell(row=1, column=c).font.bold for c in range(1, df.shape[1] + 1))
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import io
2+
import pytest
3+
import pandas as pd
4+
5+
pytest.importorskip("xlsxwriter")
6+
openpyxl = pytest.importorskip("openpyxl")
7+
8+
9+
def test_to_excel_xlsxwriter_autofilter_and_bold():
10+
df = pd.DataFrame({"A": [1, 2], "B": [3, 4]})
11+
buf = io.BytesIO()
12+
with pd.ExcelWriter(buf, engine="xlsxwriter") as writer:
13+
df.to_excel(
14+
writer,
15+
index=False,
16+
engine_kwargs={"autofilter_header": True, "header_bold": True},
17+
)
18+
buf.seek(0)
19+
wb = openpyxl.load_workbook(buf)
20+
ws = wb.active
21+
# Autofilter should be set spanning header+data
22+
assert ws.auto_filter is not None
23+
assert ws.auto_filter.ref is not None and ws.auto_filter.ref != ""
24+
# Header row (row 1) should be bold
25+
assert all(ws.cell(row=1, column=c).font.bold for c in range(1, df.shape[1] + 1))

0 commit comments

Comments
 (0)