diff --git a/doc/source/style.ipynb b/doc/source/style.ipynb index 1d6ce163cf977..a78595beabf1d 100644 --- a/doc/source/style.ipynb +++ b/doc/source/style.ipynb @@ -935,7 +935,7 @@ "\n", "*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.*\n", "\n", - "Some support is available for exporting styled `DataFrames` to Excel worksheets using the `OpenPyXL` engine. CSS2.2 properties handled include:\n", + "Some support is available for exporting styled `DataFrames` to Excel worksheets using the `OpenPyXL` or `XlsxWriter` engines. CSS2.2 properties handled include:\n", "\n", "- `background-color`\n", "- `border-style`, `border-width`, `border-color` and their {`top`, `right`, `bottom`, `left` variants}\n", diff --git a/doc/source/whatsnew/v0.22.0.txt b/doc/source/whatsnew/v0.22.0.txt index cbd094ec4ef49..a4c7fcb3d29e5 100644 --- a/doc/source/whatsnew/v0.22.0.txt +++ b/doc/source/whatsnew/v0.22.0.txt @@ -22,7 +22,7 @@ New features Other Enhancements ^^^^^^^^^^^^^^^^^^ -- +- Better support for ``Dataframe.style.to_excel()`` output with the ``xlsxwriter`` engine. (:issue:`16149`) - - diff --git a/pandas/io/excel.py b/pandas/io/excel.py index c8d0e42a022ba..fec916dc52d20 100644 --- a/pandas/io/excel.py +++ b/pandas/io/excel.py @@ -1578,6 +1578,149 @@ def _convert_to_style(cls, style_dict, num_format_str=None): register_writer(_XlwtWriter) +class _XlsxStyler(object): + # Map from openpyxl-oriented styles to flatter xlsxwriter representation + # Ordering necessary for both determinism and because some are keyed by + # prefixes of others. + STYLE_MAPPING = { + 'font': [ + (('name',), 'font_name'), + (('sz',), 'font_size'), + (('size',), 'font_size'), + (('color', 'rgb',), 'font_color'), + (('color',), 'font_color'), + (('b',), 'bold'), + (('bold',), 'bold'), + (('i',), 'italic'), + (('italic',), 'italic'), + (('u',), 'underline'), + (('underline',), 'underline'), + (('strike',), 'font_strikeout'), + (('vertAlign',), 'font_script'), + (('vertalign',), 'font_script'), + ], + 'number_format': [ + (('format_code',), 'num_format'), + ((), 'num_format',), + ], + 'protection': [ + (('locked',), 'locked'), + (('hidden',), 'hidden'), + ], + 'alignment': [ + (('horizontal',), 'align'), + (('vertical',), 'valign'), + (('text_rotation',), 'rotation'), + (('wrap_text',), 'text_wrap'), + (('indent',), 'indent'), + (('shrink_to_fit',), 'shrink'), + ], + 'fill': [ + (('patternType',), 'pattern'), + (('patterntype',), 'pattern'), + (('fill_type',), 'pattern'), + (('start_color', 'rgb',), 'fg_color'), + (('fgColor', 'rgb',), 'fg_color'), + (('fgcolor', 'rgb',), 'fg_color'), + (('start_color',), 'fg_color'), + (('fgColor',), 'fg_color'), + (('fgcolor',), 'fg_color'), + (('end_color', 'rgb',), 'bg_color'), + (('bgColor', 'rgb',), 'bg_color'), + (('bgcolor', 'rgb',), 'bg_color'), + (('end_color',), 'bg_color'), + (('bgColor',), 'bg_color'), + (('bgcolor',), 'bg_color'), + ], + 'border': [ + (('color', 'rgb',), 'border_color'), + (('color',), 'border_color'), + (('style',), 'border'), + (('top', 'color', 'rgb',), 'top_color'), + (('top', 'color',), 'top_color'), + (('top', 'style',), 'top'), + (('top',), 'top'), + (('right', 'color', 'rgb',), 'right_color'), + (('right', 'color',), 'right_color'), + (('right', 'style',), 'right'), + (('right',), 'right'), + (('bottom', 'color', 'rgb',), 'bottom_color'), + (('bottom', 'color',), 'bottom_color'), + (('bottom', 'style',), 'bottom'), + (('bottom',), 'bottom'), + (('left', 'color', 'rgb',), 'left_color'), + (('left', 'color',), 'left_color'), + (('left', 'style',), 'left'), + (('left',), 'left'), + ], + } + + @classmethod + def convert(cls, style_dict, num_format_str=None): + """ + converts a style_dict to an xlsxwriter format dict + + Parameters + ---------- + style_dict: style dictionary to convert + num_format_str: optional number format string + """ + + # Create a XlsxWriter format object. + props = {} + + if num_format_str is not None: + props['num_format'] = num_format_str + + if style_dict is None: + return props + + if 'borders' in style_dict: + style_dict = style_dict.copy() + style_dict['border'] = style_dict.pop('borders') + + for style_group_key, style_group in style_dict.items(): + for src, dst in cls.STYLE_MAPPING.get(style_group_key, []): + # src is a sequence of keys into a nested dict + # dst is a flat key + if dst in props: + continue + v = style_group + for k in src: + try: + v = v[k] + except (KeyError, TypeError): + break + else: + props[dst] = v + + if isinstance(props.get('pattern'), string_types): + # TODO: support other fill patterns + props['pattern'] = 0 if props['pattern'] == 'none' else 1 + + for k in ['border', 'top', 'right', 'bottom', 'left']: + if isinstance(props.get(k), string_types): + try: + props[k] = ['none', 'thin', 'medium', 'dashed', 'dotted', + 'thick', 'double', 'hair', 'mediumDashed', + 'dashDot', 'mediumDashDot', 'dashDotDot', + 'mediumDashDotDot', 'slantDashDot'].\ + index(props[k]) + except ValueError: + props[k] = 2 + + if isinstance(props.get('font_script'), string_types): + props['font_script'] = ['baseline', 'superscript', 'subscript'].\ + index(props['font_script']) + + if isinstance(props.get('underline'), string_types): + props['underline'] = {'none': 0, 'single': 1, 'double': 2, + 'singleAccounting': 33, + 'doubleAccounting': 34}[props['underline']] + + return props + + class _XlsxWriter(ExcelWriter): engine = 'xlsxwriter' supported_extensions = ('.xlsx',) @@ -1612,7 +1755,7 @@ def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0, wks = self.book.add_worksheet(sheet_name) self.sheets[sheet_name] = wks - style_dict = {} + style_dict = {'null': None} if _validate_freeze_panes(freeze_panes): wks.freeze_panes(*(freeze_panes)) @@ -1633,7 +1776,8 @@ def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0, if stylekey in style_dict: style = style_dict[stylekey] else: - style = self._convert_to_style(cell.style, num_format_str) + style = self.book.add_format( + _XlsxStyler.convert(cell.style, num_format_str)) style_dict[stylekey] = style if cell.mergestart is not None and cell.mergeend is not None: @@ -1647,49 +1791,5 @@ def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0, startcol + cell.col, val, style) - def _convert_to_style(self, style_dict, num_format_str=None): - """ - converts a style_dict to an xlsxwriter format object - Parameters - ---------- - style_dict: style dictionary to convert - num_format_str: optional number format string - """ - - # If there is no formatting we don't create a format object. - if num_format_str is None and style_dict is None: - return None - - # Create a XlsxWriter format object. - xl_format = self.book.add_format() - - if num_format_str is not None: - xl_format.set_num_format(num_format_str) - - if style_dict is None: - return xl_format - - # Map the cell font to XlsxWriter font properties. - if style_dict.get('font'): - font = style_dict['font'] - if font.get('bold'): - xl_format.set_bold() - - # Map the alignment to XlsxWriter alignment properties. - alignment = style_dict.get('alignment') - if alignment: - if (alignment.get('horizontal') and - alignment['horizontal'] == 'center'): - xl_format.set_align('center') - if (alignment.get('vertical') and - alignment['vertical'] == 'top'): - xl_format.set_align('top') - - # Map the cell borders to XlsxWriter border properties. - if style_dict.get('borders'): - xl_format.set_border() - - return xl_format - register_writer(_XlsxWriter) diff --git a/pandas/tests/io/test_excel.py b/pandas/tests/io/test_excel.py index 7af8bd12ca805..d33136a86faad 100644 --- a/pandas/tests/io/test_excel.py +++ b/pandas/tests/io/test_excel.py @@ -2476,88 +2476,103 @@ def custom_converter(css): styled.to_excel(writer, sheet_name='styled') ExcelFormatter(styled, style_converter=custom_converter).write( writer, sheet_name='custom') + writer.save() - # For engines other than openpyxl 2, we only smoke test - if engine != 'openpyxl': - return - if not openpyxl_compat.is_compat(major_ver=2): - pytest.skip('incompatible openpyxl version') - - # (1) compare DataFrame.to_excel and Styler.to_excel when unstyled - n_cells = 0 - for col1, col2 in zip(writer.sheets['frame'].columns, - writer.sheets['unstyled'].columns): - assert len(col1) == len(col2) - for cell1, cell2 in zip(col1, col2): - assert cell1.value == cell2.value - assert_equal_style(cell1, cell2) - n_cells += 1 - - # ensure iteration actually happened: - assert n_cells == (10 + 1) * (3 + 1) - - # (2) check styling with default converter - n_cells = 0 - for col1, col2 in zip(writer.sheets['frame'].columns, - writer.sheets['styled'].columns): - assert len(col1) == len(col2) - for cell1, cell2 in zip(col1, col2): - ref = '%s%d' % (cell2.column, cell2.row) - # XXX: this isn't as strong a test as ideal; we should - # differences are exclusive - if ref == 'B2': - assert not cell1.font.bold - assert cell2.font.bold - elif ref == 'C3': - assert cell1.font.color.rgb != cell2.font.color.rgb - assert cell2.font.color.rgb == '000000FF' - elif ref == 'D4': - assert cell1.font.underline != cell2.font.underline - assert cell2.font.underline == 'single' - elif ref == 'B5': - assert not cell1.border.left.style - assert (cell2.border.top.style == - cell2.border.right.style == - cell2.border.bottom.style == - cell2.border.left.style == - 'medium') - elif ref == 'C6': - assert not cell1.font.italic - assert cell2.font.italic - elif ref == 'D7': - assert (cell1.alignment.horizontal != - cell2.alignment.horizontal) - assert cell2.alignment.horizontal == 'right' - elif ref == 'B8': - assert cell1.fill.fgColor.rgb != cell2.fill.fgColor.rgb - assert cell1.fill.patternType != cell2.fill.patternType - assert cell2.fill.fgColor.rgb == '00FF0000' - assert cell2.fill.patternType == 'solid' - else: - assert_equal_style(cell1, cell2) - - assert cell1.value == cell2.value - n_cells += 1 - - assert n_cells == (10 + 1) * (3 + 1) - - # (3) check styling with custom converter - n_cells = 0 - for col1, col2 in zip(writer.sheets['frame'].columns, - writer.sheets['custom'].columns): - assert len(col1) == len(col2) - for cell1, cell2 in zip(col1, col2): - ref = '%s%d' % (cell2.column, cell2.row) - if ref in ('B2', 'C3', 'D4', 'B5', 'C6', 'D7', 'B8'): - assert not cell1.font.bold - assert cell2.font.bold - else: - assert_equal_style(cell1, cell2) + if engine not in ('openpyxl', 'xlsxwriter'): + # For other engines, we only smoke test + return + openpyxl = pytest.importorskip('openpyxl') + if not openpyxl_compat.is_compat(major_ver=2): + pytest.skip('incompatible openpyxl version') - assert cell1.value == cell2.value - n_cells += 1 + wb = openpyxl.load_workbook(path) - assert n_cells == (10 + 1) * (3 + 1) + # (1) compare DataFrame.to_excel and Styler.to_excel when unstyled + n_cells = 0 + for col1, col2 in zip(wb['frame'].columns, + wb['unstyled'].columns): + assert len(col1) == len(col2) + for cell1, cell2 in zip(col1, col2): + assert cell1.value == cell2.value + assert_equal_style(cell1, cell2) + n_cells += 1 + + # ensure iteration actually happened: + assert n_cells == (10 + 1) * (3 + 1) + + # (2) check styling with default converter + + # XXX: openpyxl (as at 2.4) prefixes colors with 00, xlsxwriter with FF + alpha = '00' if engine == 'openpyxl' else 'FF' + + n_cells = 0 + for col1, col2 in zip(wb['frame'].columns, + wb['styled'].columns): + assert len(col1) == len(col2) + for cell1, cell2 in zip(col1, col2): + ref = '%s%d' % (cell2.column, cell2.row) + # XXX: this isn't as strong a test as ideal; we should + # confirm that differences are exclusive + if ref == 'B2': + assert not cell1.font.bold + assert cell2.font.bold + elif ref == 'C3': + assert cell1.font.color.rgb != cell2.font.color.rgb + assert cell2.font.color.rgb == alpha + '0000FF' + elif ref == 'D4': + # This fails with engine=xlsxwriter due to + # https://bitbucket.org/openpyxl/openpyxl/issues/800 + if engine == 'xlsxwriter' \ + and (LooseVersion(openpyxl.__version__) < + LooseVersion('2.4.6')): + pass + else: + assert cell1.font.underline != cell2.font.underline + assert cell2.font.underline == 'single' + elif ref == 'B5': + assert not cell1.border.left.style + assert (cell2.border.top.style == + cell2.border.right.style == + cell2.border.bottom.style == + cell2.border.left.style == + 'medium') + elif ref == 'C6': + assert not cell1.font.italic + assert cell2.font.italic + elif ref == 'D7': + assert (cell1.alignment.horizontal != + cell2.alignment.horizontal) + assert cell2.alignment.horizontal == 'right' + elif ref == 'B8': + assert cell1.fill.fgColor.rgb != cell2.fill.fgColor.rgb + assert cell1.fill.patternType != cell2.fill.patternType + assert cell2.fill.fgColor.rgb == alpha + 'FF0000' + assert cell2.fill.patternType == 'solid' + else: + assert_equal_style(cell1, cell2) + + assert cell1.value == cell2.value + n_cells += 1 + + assert n_cells == (10 + 1) * (3 + 1) + + # (3) check styling with custom converter + n_cells = 0 + for col1, col2 in zip(wb['frame'].columns, + wb['custom'].columns): + assert len(col1) == len(col2) + for cell1, cell2 in zip(col1, col2): + ref = '%s%d' % (cell2.column, cell2.row) + if ref in ('B2', 'C3', 'D4', 'B5', 'C6', 'D7', 'B8'): + assert not cell1.font.bold + assert cell2.font.bold + else: + assert_equal_style(cell1, cell2) + + assert cell1.value == cell2.value + n_cells += 1 + + assert n_cells == (10 + 1) * (3 + 1) class TestFSPath(object):