Skip to content

Commit 96ab259

Browse files
committed
Support more styles for xlsxwriter
1 parent b555c43 commit 96ab259

File tree

4 files changed

+205
-106
lines changed

4 files changed

+205
-106
lines changed

doc/source/style.ipynb

+1-1
Original file line numberDiff line numberDiff line change
@@ -918,7 +918,7 @@
918918
"\n",
919919
"<span style=\"color: red\">*Experimental: This is a new feature and still under development. We'll be adding features and possibly making breaking changes in future releases. We'd love to hear your feedback.*</span>\n",
920920
"\n",
921-
"Some support is available for exporting styled `DataFrames` to Excel worksheets using the `OpenPyXL` engine. CSS2.2 properties handled include:\n",
921+
"Some support is available for exporting styled `DataFrames` to Excel worksheets using the `OpenPyXL` or `XlsxWriter` engines. CSS2.2 properties handled include:\n",
922922
"\n",
923923
"- `background-color`\n",
924924
"- `border-style`, `border-width`, `border-color` and their {`top`, `right`, `bottom`, `left` variants}\n",

doc/source/whatsnew/v0.20.0.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ To convert a ``SparseDataFrame`` back to sparse SciPy matrix in COO format, you
368368
Excel output for styled DataFrames
369369
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
370370

371-
Experimental support has been added to export ``DataFrame.style`` formats to Excel using the ``openpyxl`` engine. (:issue:`15530`)
371+
Experimental support has been added to export ``DataFrame.style`` formats to Excel using the ``openpyxl`` or ``xlsxwriter`` engines. (:issue:`15530`, :issue:`16149`)
372372

373373
For example, after running the following, ``styled.xlsx`` renders as below:
374374

pandas/io/excel.py

+109-25
Original file line numberDiff line numberDiff line change
@@ -1596,6 +1596,68 @@ def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0,
15961596
startcol + cell.col,
15971597
val, style)
15981598

1599+
# Map from openpyxl-oriented styles to flatter xlsxwriter representation
1600+
STYLE_MAPPING = [
1601+
(('font', 'name'), 'font_name'),
1602+
(('font', 'sz'), 'font_size'),
1603+
(('font', 'size'), 'font_size'),
1604+
(('font', 'color', 'rgb'), 'font_color'),
1605+
(('font', 'color'), 'font_color'),
1606+
(('font', 'b'), 'bold'),
1607+
(('font', 'bold'), 'bold'),
1608+
(('font', 'i'), 'italic'),
1609+
(('font', 'italic'), 'italic'),
1610+
(('font', 'u'), 'underline'),
1611+
(('font', 'underline'), 'underline'),
1612+
(('font', 'strike'), 'font_strikeout'),
1613+
(('font', 'vertAlign'), 'font_script'),
1614+
(('font', 'vertalign'), 'font_script'),
1615+
(('number_format', 'format_code'), 'num_format'),
1616+
(('number_format',), 'num_format'),
1617+
(('protection', 'locked'), 'locked'),
1618+
(('protection', 'hidden'), 'hidden'),
1619+
(('alignment', 'horizontal'), 'align'),
1620+
(('alignment', 'vertical'), 'valign'),
1621+
(('alignment', 'text_rotation'), 'rotation'),
1622+
(('alignment', 'wrap_text'), 'text_wrap'),
1623+
(('alignment', 'indent'), 'indent'),
1624+
(('alignment', 'shrink_to_fit'), 'shrink'),
1625+
(('fill', 'patternType'), 'pattern'),
1626+
(('fill', 'patterntype'), 'pattern'),
1627+
(('fill', 'fill_type'), 'pattern'),
1628+
(('fill', 'start_color', 'rgb'), 'fg_color'),
1629+
(('fill', 'fgColor', 'rgb'), 'fg_color'),
1630+
(('fill', 'fgcolor', 'rgb'), 'fg_color'),
1631+
(('fill', 'start_color'), 'fg_color'),
1632+
(('fill', 'fgColor'), 'fg_color'),
1633+
(('fill', 'fgcolor'), 'fg_color'),
1634+
(('fill', 'end_color', 'rgb'), 'bg_color'),
1635+
(('fill', 'bgColor', 'rgb'), 'bg_color'),
1636+
(('fill', 'bgcolor', 'rgb'), 'bg_color'),
1637+
(('fill', 'end_color'), 'bg_color'),
1638+
(('fill', 'bgColor'), 'bg_color'),
1639+
(('fill', 'bgcolor'), 'bg_color'),
1640+
(('border', 'color', 'rgb'), 'border_color'),
1641+
(('border', 'color'), 'border_color'),
1642+
(('border', 'style'), 'border'),
1643+
(('border', 'top', 'color', 'rgb'), 'top_color'),
1644+
(('border', 'top', 'color'), 'top_color'),
1645+
(('border', 'top', 'style'), 'top'),
1646+
(('border', 'top'), 'top'),
1647+
(('border', 'right', 'color', 'rgb'), 'right_color'),
1648+
(('border', 'right', 'color'), 'right_color'),
1649+
(('border', 'right', 'style'), 'right'),
1650+
(('border', 'right'), 'right'),
1651+
(('border', 'bottom', 'color', 'rgb'), 'bottom_color'),
1652+
(('border', 'bottom', 'color'), 'bottom_color'),
1653+
(('border', 'bottom', 'style'), 'bottom'),
1654+
(('border', 'bottom'), 'bottom'),
1655+
(('border', 'left', 'color', 'rgb'), 'left_color'),
1656+
(('border', 'left', 'color'), 'left_color'),
1657+
(('border', 'left', 'style'), 'left'),
1658+
(('border', 'left'), 'left'),
1659+
]
1660+
15991661
def _convert_to_style(self, style_dict, num_format_str=None):
16001662
"""
16011663
converts a style_dict to an xlsxwriter format object
@@ -1610,35 +1672,57 @@ def _convert_to_style(self, style_dict, num_format_str=None):
16101672
return None
16111673

16121674
# Create a XlsxWriter format object.
1613-
xl_format = self.book.add_format()
1675+
props = {}
16141676

16151677
if num_format_str is not None:
1616-
xl_format.set_num_format(num_format_str)
1678+
props['num_format'] = num_format_str
16171679

16181680
if style_dict is None:
1619-
return xl_format
1620-
1621-
# Map the cell font to XlsxWriter font properties.
1622-
if style_dict.get('font'):
1623-
font = style_dict['font']
1624-
if font.get('bold'):
1625-
xl_format.set_bold()
1626-
1627-
# Map the alignment to XlsxWriter alignment properties.
1628-
alignment = style_dict.get('alignment')
1629-
if alignment:
1630-
if (alignment.get('horizontal') and
1631-
alignment['horizontal'] == 'center'):
1632-
xl_format.set_align('center')
1633-
if (alignment.get('vertical') and
1634-
alignment['vertical'] == 'top'):
1635-
xl_format.set_align('top')
1636-
1637-
# Map the cell borders to XlsxWriter border properties.
1638-
if style_dict.get('borders'):
1639-
xl_format.set_border()
1640-
1641-
return xl_format
1681+
return self.book.add_format(props)
1682+
1683+
if 'borders' in style_dict:
1684+
style_dict = style_dict.copy()
1685+
style_dict['border'] = style_dict.pop('borders')
1686+
1687+
for src, dst in self.STYLE_MAPPING:
1688+
# src is a sequence of keys into a nested dict
1689+
# dst is a flat key
1690+
if dst in props:
1691+
continue
1692+
v = style_dict
1693+
for k in src:
1694+
try:
1695+
v = v[k]
1696+
except (KeyError, TypeError):
1697+
break
1698+
else:
1699+
props[dst] = v
1700+
1701+
if isinstance(props.get('pattern'), string_types):
1702+
# TODO: support other fill patterns
1703+
props['pattern'] = 0 if props['pattern'] == 'none' else 1
1704+
1705+
for k in ['border', 'top', 'right', 'bottom', 'left']:
1706+
if isinstance(props.get(k), string_types):
1707+
try:
1708+
props[k] = ['none', 'thin', 'medium', 'dashed', 'dotted',
1709+
'thick', 'double', 'hair', 'mediumDashed',
1710+
'dashDot', 'mediumDashDot', 'dashDotDot',
1711+
'mediumDashDotDot', 'slantDashDot'].\
1712+
index(props[k])
1713+
except ValueError:
1714+
props[k] = 2
1715+
1716+
if isinstance(props.get('font_script'), string_types):
1717+
props['font_script'] = ['baseline', 'superscript', 'subscript'].\
1718+
index(props['font_script'])
1719+
1720+
if isinstance(props.get('underline'), string_types):
1721+
props['underline'] = {'none': 0, 'single': 1, 'double': 2,
1722+
'singleAccounting': 33,
1723+
'doubleAccounting': 34}[props['underline']]
1724+
1725+
return self.book.add_format(props)
16421726

16431727

16441728
register_writer(_XlsxWriter)

pandas/tests/io/test_excel.py

+94-79
Original file line numberDiff line numberDiff line change
@@ -2408,85 +2408,100 @@ def custom_converter(css):
24082408
styled.to_excel(writer, sheet_name='styled')
24092409
ExcelFormatter(styled, style_converter=custom_converter).write(
24102410
writer, sheet_name='custom')
2411+
writer.save()
24112412

2412-
# For engines other than openpyxl 2, we only smoke test
2413-
if engine != 'openpyxl':
2414-
return
2415-
if not openpyxl_compat.is_compat(major_ver=2):
2416-
pytest.skip('incompatible openpyxl version')
2417-
2418-
# (1) compare DataFrame.to_excel and Styler.to_excel when unstyled
2419-
n_cells = 0
2420-
for col1, col2 in zip(writer.sheets['frame'].columns,
2421-
writer.sheets['unstyled'].columns):
2422-
assert len(col1) == len(col2)
2423-
for cell1, cell2 in zip(col1, col2):
2424-
assert cell1.value == cell2.value
2425-
assert_equal_style(cell1, cell2)
2426-
n_cells += 1
2427-
2428-
# ensure iteration actually happened:
2429-
assert n_cells == (10 + 1) * (3 + 1)
2430-
2431-
# (2) check styling with default converter
2432-
n_cells = 0
2433-
for col1, col2 in zip(writer.sheets['frame'].columns,
2434-
writer.sheets['styled'].columns):
2435-
assert len(col1) == len(col2)
2436-
for cell1, cell2 in zip(col1, col2):
2437-
ref = '%s%d' % (cell2.column, cell2.row)
2438-
# XXX: this isn't as strong a test as ideal; we should
2439-
# differences are exclusive
2440-
if ref == 'B2':
2441-
assert not cell1.font.bold
2442-
assert cell2.font.bold
2443-
elif ref == 'C3':
2444-
assert cell1.font.color.rgb != cell2.font.color.rgb
2445-
assert cell2.font.color.rgb == '000000FF'
2446-
elif ref == 'D4':
2447-
assert cell1.font.underline != cell2.font.underline
2448-
assert cell2.font.underline == 'single'
2449-
elif ref == 'B5':
2450-
assert not cell1.border.left.style
2451-
assert (cell2.border.top.style ==
2452-
cell2.border.right.style ==
2453-
cell2.border.bottom.style ==
2454-
cell2.border.left.style ==
2455-
'medium')
2456-
elif ref == 'C6':
2457-
assert not cell1.font.italic
2458-
assert cell2.font.italic
2459-
elif ref == 'D7':
2460-
assert (cell1.alignment.horizontal !=
2461-
cell2.alignment.horizontal)
2462-
assert cell2.alignment.horizontal == 'right'
2463-
elif ref == 'B8':
2464-
assert cell1.fill.fgColor.rgb != cell2.fill.fgColor.rgb
2465-
assert cell1.fill.patternType != cell2.fill.patternType
2466-
assert cell2.fill.fgColor.rgb == '00FF0000'
2467-
assert cell2.fill.patternType == 'solid'
2468-
else:
2469-
assert_equal_style(cell1, cell2)
2470-
2471-
assert cell1.value == cell2.value
2472-
n_cells += 1
2473-
2474-
assert n_cells == (10 + 1) * (3 + 1)
2475-
2476-
# (3) check styling with custom converter
2477-
n_cells = 0
2478-
for col1, col2 in zip(writer.sheets['frame'].columns,
2479-
writer.sheets['custom'].columns):
2480-
assert len(col1) == len(col2)
2481-
for cell1, cell2 in zip(col1, col2):
2482-
ref = '%s%d' % (cell2.column, cell2.row)
2483-
if ref in ('B2', 'C3', 'D4', 'B5', 'C6', 'D7', 'B8'):
2484-
assert not cell1.font.bold
2485-
assert cell2.font.bold
2486-
else:
2487-
assert_equal_style(cell1, cell2)
2413+
if engine not in ('openpyxl', 'xlsxwriter'):
2414+
# For other engines, we only smoke test
2415+
return
2416+
openpyxl = pytest.importorskip('openpyxl')
2417+
if not openpyxl_compat.is_compat(major_ver=2):
2418+
pytest.skip('incompatible openpyxl version')
24882419

2489-
assert cell1.value == cell2.value
2490-
n_cells += 1
2420+
wb = openpyxl.load_workbook(path)
24912421

2492-
assert n_cells == (10 + 1) * (3 + 1)
2422+
# (1) compare DataFrame.to_excel and Styler.to_excel when unstyled
2423+
n_cells = 0
2424+
for col1, col2 in zip(wb['frame'].columns,
2425+
wb['unstyled'].columns):
2426+
assert len(col1) == len(col2)
2427+
for cell1, cell2 in zip(col1, col2):
2428+
assert cell1.value == cell2.value
2429+
assert_equal_style(cell1, cell2)
2430+
n_cells += 1
2431+
2432+
# ensure iteration actually happened:
2433+
assert n_cells == (10 + 1) * (3 + 1)
2434+
2435+
# (2) check styling with default converter
2436+
2437+
# XXX: openpyxl (as at 2.4) prefixes colors with 00, xlsxwriter with FF
2438+
alpha = '00' if engine == 'openpyxl' else 'FF'
2439+
2440+
n_cells = 0
2441+
for col1, col2 in zip(wb['frame'].columns,
2442+
wb['styled'].columns):
2443+
assert len(col1) == len(col2)
2444+
for cell1, cell2 in zip(col1, col2):
2445+
ref = '%s%d' % (cell2.column, cell2.row)
2446+
# XXX: this isn't as strong a test as ideal; we should
2447+
# confirm that differences are exclusive
2448+
if ref == 'B2':
2449+
assert not cell1.font.bold
2450+
assert cell2.font.bold
2451+
elif ref == 'C3':
2452+
assert cell1.font.color.rgb != cell2.font.color.rgb
2453+
assert cell2.font.color.rgb == alpha + '0000FF'
2454+
elif ref == 'D4':
2455+
# This fails with engine=xlsxwriter due to
2456+
# https://bitbucket.org/openpyxl/openpyxl/issues/800
2457+
if engine == 'xlsxwriter' \
2458+
and (LooseVersion(openpyxl.__version__) <
2459+
LooseVersion('2.4.6')):
2460+
pass
2461+
else:
2462+
assert cell1.font.underline != cell2.font.underline
2463+
assert cell2.font.underline == 'single'
2464+
elif ref == 'B5':
2465+
assert not cell1.border.left.style
2466+
assert (cell2.border.top.style ==
2467+
cell2.border.right.style ==
2468+
cell2.border.bottom.style ==
2469+
cell2.border.left.style ==
2470+
'medium')
2471+
elif ref == 'C6':
2472+
assert not cell1.font.italic
2473+
assert cell2.font.italic
2474+
elif ref == 'D7':
2475+
assert (cell1.alignment.horizontal !=
2476+
cell2.alignment.horizontal)
2477+
assert cell2.alignment.horizontal == 'right'
2478+
elif ref == 'B8':
2479+
assert cell1.fill.fgColor.rgb != cell2.fill.fgColor.rgb
2480+
assert cell1.fill.patternType != cell2.fill.patternType
2481+
assert cell2.fill.fgColor.rgb == alpha + 'FF0000'
2482+
assert cell2.fill.patternType == 'solid'
2483+
else:
2484+
assert_equal_style(cell1, cell2)
2485+
2486+
assert cell1.value == cell2.value
2487+
n_cells += 1
2488+
2489+
assert n_cells == (10 + 1) * (3 + 1)
2490+
2491+
# (3) check styling with custom converter
2492+
n_cells = 0
2493+
for col1, col2 in zip(wb['frame'].columns,
2494+
wb['custom'].columns):
2495+
assert len(col1) == len(col2)
2496+
for cell1, cell2 in zip(col1, col2):
2497+
ref = '%s%d' % (cell2.column, cell2.row)
2498+
if ref in ('B2', 'C3', 'D4', 'B5', 'C6', 'D7', 'B8'):
2499+
assert not cell1.font.bold
2500+
assert cell2.font.bold
2501+
else:
2502+
assert_equal_style(cell1, cell2)
2503+
2504+
assert cell1.value == cell2.value
2505+
n_cells += 1
2506+
2507+
assert n_cells == (10 + 1) * (3 + 1)

0 commit comments

Comments
 (0)