diff --git a/ci/requirements-2.7.pip b/ci/requirements-2.7.pip index eb796368e7820..ec10ee76c01e4 100644 --- a/ci/requirements-2.7.pip +++ b/ci/requirements-2.7.pip @@ -6,3 +6,4 @@ py PyCrypto mock ipython +cssdecl diff --git a/ci/requirements-2.7_WIN.pip b/ci/requirements-2.7_WIN.pip new file mode 100644 index 0000000000000..b37e06c6219c9 --- /dev/null +++ b/ci/requirements-2.7_WIN.pip @@ -0,0 +1 @@ +cssdecl diff --git a/ci/requirements-3.5_DOC.pip b/ci/requirements-3.5_DOC.pip new file mode 100644 index 0000000000000..b37e06c6219c9 --- /dev/null +++ b/ci/requirements-3.5_DOC.pip @@ -0,0 +1 @@ +cssdecl diff --git a/ci/requirements-3.6.pip b/ci/requirements-3.6.pip index e69de29bb2d1d..b37e06c6219c9 100644 --- a/ci/requirements-3.6.pip +++ b/ci/requirements-3.6.pip @@ -0,0 +1 @@ +cssdecl diff --git a/doc/source/install.rst b/doc/source/install.rst index 48d51e1200447..5402ca9159977 100644 --- a/doc/source/install.rst +++ b/doc/source/install.rst @@ -249,6 +249,7 @@ Optional Dependencies * `openpyxl `__: openpyxl version 1.6.1 or higher (but lower than 2.0.0), or version 2.2 or higher, for writing .xlsx files (xlrd >= 0.9.0) * `XlsxWriter `__: Alternative Excel writer + * `cssdecl `__: along with one of openpyxl or XlsxWriter for exporting styled DataFrames * `Jinja2 `__: Template engine for conditional HTML formatting. * `s3fs `__: necessary for Amazon S3 access (s3fs >= 0.0.7). diff --git a/doc/source/style.ipynb b/doc/source/style.ipynb index 4eeda491426b1..d9837d51b5b03 100644 --- a/doc/source/style.ipynb +++ b/doc/source/style.ipynb @@ -948,7 +948,9 @@ "- `vertical-align`\n", "- `white-space: nowrap`\n", "\n", - "Only CSS2 named colors and hex colors of the form `#rgb` or `#rrggbb` are currently supported." + "Only CSS2 named colors and hex colors of the form `#rgb` or `#rrggbb` are currently supported.\n" + "\n", + "This feature requires the `cssdecl` package to be installed.", ] }, { diff --git a/doc/source/whatsnew/v0.21.0.txt b/doc/source/whatsnew/v0.21.0.txt index 3df0a21facb02..7a0dab2520404 100644 --- a/doc/source/whatsnew/v0.21.0.txt +++ b/doc/source/whatsnew/v0.21.0.txt @@ -35,6 +35,7 @@ Other Enhancements Backwards incompatible API changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- The ``DataFrame.style.to_excel()`` styled Excel export functionality now requires an external dependency, `cssdecl `_ (:issue:`16170`). .. _whatsnew_0210.api: diff --git a/pandas/io/formats/css.py b/pandas/io/formats/css.py deleted file mode 100644 index d12d2373e1190..0000000000000 --- a/pandas/io/formats/css.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Utilities for interpreting CSS from Stylers for formatting non-HTML outputs -""" - -import re -import warnings - - -class CSSWarning(UserWarning): - """This CSS syntax cannot currently be parsed""" - pass - - -class CSSResolver(object): - """A callable for parsing and resolving CSS to atomic properties - - """ - - INITIAL_STYLE = { - } - - def __call__(self, declarations_str, inherited=None): - """ the given declarations to atomic properties - - Parameters - ---------- - declarations_str : str - A list of CSS declarations - inherited : dict, optional - Atomic properties indicating the inherited style context in which - declarations_str is to be resolved. ``inherited`` should already - be resolved, i.e. valid output of this method. - - Returns - ------- - props : dict - Atomic CSS 2.2 properties - - Examples - -------- - >>> resolve = CSSResolver() - >>> inherited = {'font-family': 'serif', 'font-weight': 'bold'} - >>> out = resolve(''' - ... border-color: BLUE RED; - ... font-size: 1em; - ... font-size: 2em; - ... font-weight: normal; - ... font-weight: inherit; - ... ''', inherited) - >>> sorted(out.items()) # doctest: +NORMALIZE_WHITESPACE - [('border-bottom-color', 'blue'), - ('border-left-color', 'red'), - ('border-right-color', 'red'), - ('border-top-color', 'blue'), - ('font-family', 'serif'), - ('font-size', '24pt'), - ('font-weight', 'bold')] - """ - - props = dict(self.atomize(self.parse(declarations_str))) - if inherited is None: - inherited = {} - - # 1. resolve inherited, initial - for prop, val in inherited.items(): - if prop not in props: - props[prop] = val - - for prop, val in list(props.items()): - if val == 'inherit': - val = inherited.get(prop, 'initial') - if val == 'initial': - val = self.INITIAL_STYLE.get(prop) - - if val is None: - # we do not define a complete initial stylesheet - del props[prop] - else: - props[prop] = val - - # 2. resolve relative font size - if props.get('font-size'): - if 'font-size' in inherited: - em_pt = inherited['font-size'] - assert em_pt[-2:] == 'pt' - em_pt = float(em_pt[:-2]) - else: - em_pt = None - props['font-size'] = self.size_to_pt( - props['font-size'], em_pt, conversions=self.FONT_SIZE_RATIOS) - - font_size = float(props['font-size'][:-2]) - else: - font_size = None - - # 3. TODO: resolve other font-relative units - for side in self.SIDES: - prop = 'border-%s-width' % side - if prop in props: - props[prop] = self.size_to_pt( - props[prop], em_pt=font_size, - conversions=self.BORDER_WIDTH_RATIOS) - for prop in ['margin-%s' % side, 'padding-%s' % side]: - if prop in props: - # TODO: support % - props[prop] = self.size_to_pt( - props[prop], em_pt=font_size, - conversions=self.MARGIN_RATIOS) - - return props - - UNIT_RATIOS = { - 'rem': ('pt', 12), - 'ex': ('em', .5), - # 'ch': - 'px': ('pt', .75), - 'pc': ('pt', 12), - 'in': ('pt', 72), - 'cm': ('in', 1 / 2.54), - 'mm': ('in', 1 / 25.4), - 'q': ('mm', .25), - '!!default': ('em', 0), - } - - FONT_SIZE_RATIOS = UNIT_RATIOS.copy() - FONT_SIZE_RATIOS.update({ - '%': ('em', .01), - 'xx-small': ('rem', .5), - 'x-small': ('rem', .625), - 'small': ('rem', .8), - 'medium': ('rem', 1), - 'large': ('rem', 1.125), - 'x-large': ('rem', 1.5), - 'xx-large': ('rem', 2), - 'smaller': ('em', 1 / 1.2), - 'larger': ('em', 1.2), - '!!default': ('em', 1), - }) - - MARGIN_RATIOS = UNIT_RATIOS.copy() - MARGIN_RATIOS.update({ - 'none': ('pt', 0), - }) - - BORDER_WIDTH_RATIOS = UNIT_RATIOS.copy() - BORDER_WIDTH_RATIOS.update({ - 'none': ('pt', 0), - 'thick': ('px', 4), - 'medium': ('px', 2), - 'thin': ('px', 1), - # Default: medium only if solid - }) - - def size_to_pt(self, in_val, em_pt=None, conversions=UNIT_RATIOS): - def _error(): - warnings.warn('Unhandled size: %r' % in_val, CSSWarning) - return self.size_to_pt('1!!default', conversions=conversions) - - try: - val, unit = re.match(r'^(\S*?)([a-zA-Z%!].*)', in_val).groups() - except AttributeError: - return _error() - if val == '': - # hack for 'large' etc. - val = 1 - else: - try: - val = float(val) - except ValueError: - return _error() - - while unit != 'pt': - if unit == 'em': - if em_pt is None: - unit = 'rem' - else: - val *= em_pt - unit = 'pt' - continue - - try: - unit, mul = conversions[unit] - except KeyError: - return _error() - val *= mul - - val = round(val, 5) - if int(val) == val: - size_fmt = '%d' - else: - size_fmt = '%f' - return (size_fmt + 'pt') % val - - def atomize(self, declarations): - for prop, value in declarations: - attr = 'expand_' + prop.replace('-', '_') - try: - expand = getattr(self, attr) - except AttributeError: - yield prop, value - else: - for prop, value in expand(prop, value): - yield prop, value - - SIDE_SHORTHANDS = { - 1: [0, 0, 0, 0], - 2: [0, 1, 0, 1], - 3: [0, 1, 2, 1], - 4: [0, 1, 2, 3], - } - SIDES = ('top', 'right', 'bottom', 'left') - - def _side_expander(prop_fmt): - def expand(self, prop, value): - tokens = value.split() - try: - mapping = self.SIDE_SHORTHANDS[len(tokens)] - except KeyError: - warnings.warn('Could not expand "%s: %s"' % (prop, value), - CSSWarning) - return - for key, idx in zip(self.SIDES, mapping): - yield prop_fmt % key, tokens[idx] - - return expand - - expand_border_color = _side_expander('border-%s-color') - expand_border_style = _side_expander('border-%s-style') - expand_border_width = _side_expander('border-%s-width') - expand_margin = _side_expander('margin-%s') - expand_padding = _side_expander('padding-%s') - - def parse(self, declarations_str): - """Generates (prop, value) pairs from declarations - - In a future version may generate parsed tokens from tinycss/tinycss2 - """ - for decl in declarations_str.split(';'): - if not decl.strip(): - continue - prop, sep, val = decl.partition(':') - prop = prop.strip().lower() - # TODO: don't lowercase case sensitive parts of values (strings) - val = val.strip().lower() - if sep: - yield prop, val - else: - warnings.warn('Ill-formatted attribute: expected a colon ' - 'in %r' % decl, CSSWarning) diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 80587f9a752c7..b9f64f0b833cf 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -8,7 +8,6 @@ import numpy as np from pandas.compat import reduce, string_types -from pandas.io.formats.css import CSSResolver, CSSWarning from pandas.io.formats.printing import pprint_thing from pandas.core.dtypes.common import is_float import pandas._libs.lib as lib @@ -61,14 +60,17 @@ class CSSToExcelConverter(object): # without monkey-patching. def __init__(self, inherited=None): + try: + from cssdecl import CSS22Resolver + except ImportError: + raise ImportError("cssdecl is not installed and is requied to " + "convert styles to Excel") + self.compute_css = CSS22Resolver().resolve_string if inherited is not None: - inherited = self.compute_css(inherited, - self.compute_css.INITIAL_STYLE) + inherited = self.compute_css(inherited) self.inherited = inherited - compute_css = CSSResolver() - def __call__(self, declarations_str): """Convert CSS declarations to ExcelWriter style @@ -302,6 +304,7 @@ def color_to_excel(self, val): try: return self.NAMED_COLORS[val] except KeyError: + from cssdecl import CSSWarning warnings.warn('Unhandled colour format: %r' % val, CSSWarning) @@ -333,7 +336,8 @@ class ExcelFormatter(object): A `'-'` sign will be added in front of -inf. style_converter : callable, optional This translates Styler styles (CSS) into ExcelWriter styles. - Defaults to ``CSSToExcelConverter()``. + Defaults to ``CSSToExcelConverter()`` which requires the ``cssdecl`` + package installed. It should have signature css_declarations string -> excel style. This is only called for body cells. """ diff --git a/pandas/tests/io/formats/test_css.py b/pandas/tests/io/formats/test_css.py deleted file mode 100644 index c07856dc63602..0000000000000 --- a/pandas/tests/io/formats/test_css.py +++ /dev/null @@ -1,186 +0,0 @@ -import pytest - -from pandas.util import testing as tm -from pandas.io.formats.css import CSSResolver, CSSWarning - - -def assert_resolves(css, props, inherited=None): - resolve = CSSResolver() - actual = resolve(css, inherited=inherited) - assert props == actual - - -def assert_same_resolution(css1, css2, inherited=None): - resolve = CSSResolver() - resolved1 = resolve(css1, inherited=inherited) - resolved2 = resolve(css2, inherited=inherited) - assert resolved1 == resolved2 - - -@pytest.mark.parametrize('name,norm,abnorm', [ - ('whitespace', 'hello: world; foo: bar', - ' \t hello \t :\n world \n ; \n foo: \tbar\n\n'), - ('case', 'hello: world; foo: bar', 'Hello: WORLD; foO: bar'), - ('empty-decl', 'hello: world; foo: bar', - '; hello: world;; foo: bar;\n; ;'), - ('empty-list', '', ';'), -]) -def test_css_parse_normalisation(name, norm, abnorm): - assert_same_resolution(norm, abnorm) - - -@pytest.mark.parametrize( - 'invalid_css,remainder', [ - # No colon - ('hello-world', ''), - ('border-style: solid; hello-world', 'border-style: solid'), - ('border-style: solid; hello-world; font-weight: bold', - 'border-style: solid; font-weight: bold'), - # Unclosed string fail - # Invalid size - ('font-size: blah', 'font-size: 1em'), - ('font-size: 1a2b', 'font-size: 1em'), - ('font-size: 1e5pt', 'font-size: 1em'), - ('font-size: 1+6pt', 'font-size: 1em'), - ('font-size: 1unknownunit', 'font-size: 1em'), - ('font-size: 10', 'font-size: 1em'), - ('font-size: 10 pt', 'font-size: 1em'), - ]) -def test_css_parse_invalid(invalid_css, remainder): - with tm.assert_produces_warning(CSSWarning): - assert_same_resolution(invalid_css, remainder) - - # TODO: we should be checking that in other cases no warnings are raised - - -@pytest.mark.parametrize( - 'shorthand,expansions', - [('margin', ['margin-top', 'margin-right', - 'margin-bottom', 'margin-left']), - ('padding', ['padding-top', 'padding-right', - 'padding-bottom', 'padding-left']), - ('border-width', ['border-top-width', 'border-right-width', - 'border-bottom-width', 'border-left-width']), - ('border-color', ['border-top-color', 'border-right-color', - 'border-bottom-color', 'border-left-color']), - ('border-style', ['border-top-style', 'border-right-style', - 'border-bottom-style', 'border-left-style']), - ]) -def test_css_side_shorthands(shorthand, expansions): - top, right, bottom, left = expansions - - assert_resolves('%s: 1pt' % shorthand, - {top: '1pt', right: '1pt', - bottom: '1pt', left: '1pt'}) - - assert_resolves('%s: 1pt 4pt' % shorthand, - {top: '1pt', right: '4pt', - bottom: '1pt', left: '4pt'}) - - assert_resolves('%s: 1pt 4pt 2pt' % shorthand, - {top: '1pt', right: '4pt', - bottom: '2pt', left: '4pt'}) - - assert_resolves('%s: 1pt 4pt 2pt 0pt' % shorthand, - {top: '1pt', right: '4pt', - bottom: '2pt', left: '0pt'}) - - with tm.assert_produces_warning(CSSWarning): - assert_resolves('%s: 1pt 1pt 1pt 1pt 1pt' % shorthand, - {}) - - -@pytest.mark.parametrize('style,inherited,equiv', [ - ('margin: 1px; margin: 2px', '', - 'margin: 2px'), - ('margin: 1px', 'margin: 2px', - 'margin: 1px'), - ('margin: 1px; margin: inherit', 'margin: 2px', - 'margin: 2px'), - ('margin: 1px; margin-top: 2px', '', - 'margin-left: 1px; margin-right: 1px; ' + - 'margin-bottom: 1px; margin-top: 2px'), - ('margin-top: 2px', 'margin: 1px', - 'margin: 1px; margin-top: 2px'), - ('margin: 1px', 'margin-top: 2px', - 'margin: 1px'), - ('margin: 1px; margin-top: inherit', 'margin: 2px', - 'margin: 1px; margin-top: 2px'), -]) -def test_css_precedence(style, inherited, equiv): - resolve = CSSResolver() - inherited_props = resolve(inherited) - style_props = resolve(style, inherited=inherited_props) - equiv_props = resolve(equiv) - assert style_props == equiv_props - - -@pytest.mark.parametrize('style,equiv', [ - ('margin: 1px; margin-top: inherit', - 'margin-bottom: 1px; margin-right: 1px; margin-left: 1px'), - ('margin-top: inherit', ''), - ('margin-top: initial', ''), -]) -def test_css_none_absent(style, equiv): - assert_same_resolution(style, equiv) - - -@pytest.mark.parametrize('size,resolved', [ - ('xx-small', '6pt'), - ('x-small', '%fpt' % 7.5), - ('small', '%fpt' % 9.6), - ('medium', '12pt'), - ('large', '%fpt' % 13.5), - ('x-large', '18pt'), - ('xx-large', '24pt'), - - ('8px', '6pt'), - ('1.25pc', '15pt'), - ('.25in', '18pt'), - ('02.54cm', '72pt'), - ('25.4mm', '72pt'), - ('101.6q', '72pt'), - ('101.6q', '72pt'), -]) -@pytest.mark.parametrize('relative_to', # invariant to inherited size - [None, '16pt']) -def test_css_absolute_font_size(size, relative_to, resolved): - if relative_to is None: - inherited = None - else: - inherited = {'font-size': relative_to} - assert_resolves('font-size: %s' % size, {'font-size': resolved}, - inherited=inherited) - - -@pytest.mark.parametrize('size,relative_to,resolved', [ - ('1em', None, '12pt'), - ('1.0em', None, '12pt'), - ('1.25em', None, '15pt'), - ('1em', '16pt', '16pt'), - ('1.0em', '16pt', '16pt'), - ('1.25em', '16pt', '20pt'), - ('1rem', '16pt', '12pt'), - ('1.0rem', '16pt', '12pt'), - ('1.25rem', '16pt', '15pt'), - ('100%', None, '12pt'), - ('125%', None, '15pt'), - ('100%', '16pt', '16pt'), - ('125%', '16pt', '20pt'), - ('2ex', None, '12pt'), - ('2.0ex', None, '12pt'), - ('2.50ex', None, '15pt'), - ('inherit', '16pt', '16pt'), - - ('smaller', None, '10pt'), - ('smaller', '18pt', '15pt'), - ('larger', None, '%fpt' % 14.4), - ('larger', '15pt', '18pt'), -]) -def test_css_relative_font_size(size, relative_to, resolved): - if relative_to is None: - inherited = None - else: - inherited = {'font-size': relative_to} - assert_resolves('font-size: %s' % size, {'font-size': resolved}, - inherited=inherited) diff --git a/pandas/tests/io/formats/test_to_excel.py b/pandas/tests/io/formats/test_to_excel.py index 26a9bb018f30a..30e93fb6ab45f 100644 --- a/pandas/tests/io/formats/test_to_excel.py +++ b/pandas/tests/io/formats/test_to_excel.py @@ -5,7 +5,15 @@ import pytest +import numpy as np + +import pandas as pd +from pandas.compat import openpyxl_compat +from pandas.io.excel import ExcelWriter +from pandas import DataFrame from pandas.io.formats.excel import CSSToExcelConverter +from pandas.io.formats.excel import ExcelFormatter +from pandas.util.testing import ensure_clean @pytest.mark.parametrize('css,expected', [ @@ -172,11 +180,13 @@ {'alignment': {'wrap_text': True}}), ]) def test_css_to_excel(css, expected): + pytest.importorskip('cssdecl') convert = CSSToExcelConverter() assert expected == convert(css) def test_css_to_excel_multiple(): + pytest.importorskip('cssdecl') convert = CSSToExcelConverter() actual = convert(''' font-weight: bold; @@ -210,5 +220,222 @@ def test_css_to_excel_multiple(): {'font': {'italic': True}}), ]) def test_css_to_excel_inherited(css, inherited, expected): + pytest.importorskip('cssdecl') convert = CSSToExcelConverter(inherited) assert expected == convert(css) + + +@pytest.fixture +def styled_dataframe(): + def style(df): + # XXX: RGB colors not supported in xlwt + return DataFrame([['font-weight: bold', '', ''], + ['', 'color: blue', ''], + ['', '', 'text-decoration: underline'], + ['border-style: solid', '', ''], + ['', 'font-style: italic', ''], + ['', '', 'text-align: right'], + ['background-color: red', '', ''], + ['', '', ''], + ['', '', ''], + ['', '', '']], + index=df.index, columns=df.columns) + + pytest.importorskip('jinja2') + df = DataFrame(np.random.randn(10, 3)) + return df.style.apply(style, axis=None) + + +def assert_equal_style(cell1, cell2): + # XXX: should find a better way to check equality + # Neither OpenPyXl's Cell, nor its style objects have __eq__ defined + assert cell1.alignment.__dict__ == cell2.alignment.__dict__ + assert cell1.border.__dict__ == cell2.border.__dict__ + assert cell1.fill.__dict__ == cell2.fill.__dict__ + assert cell1.font.__dict__ == cell2.font.__dict__ + assert cell1.number_format == cell2.number_format + assert cell1.protection.__dict__ == cell2.protection.__dict__ + + +@pytest.mark.parametrize('engine', [ + pytest.mark.xfail('xlwt', reason='xlwt does not support ' + 'openpyxl-compatible style dicts'), + 'xlsxwriter', + 'openpyxl', +]) +def test_styler_to_excel(engine, styled_dataframe): + def custom_converter(css): + # use bold iff there is custom style attached to the cell + if css.strip(' \n;'): + return {'font': {'bold': True}} + return {} + + pytest.importorskip('cssdecl') + pytest.importorskip(engine) + + if engine == 'openpyxl' and openpyxl_compat.is_compat(major_ver=1): + pytest.xfail('openpyxl1 does not support some openpyxl2-compatible ' + 'style dicts') + + styled = styled_dataframe + df = styled.data + + # Prepare spreadsheets + + with ensure_clean('.xlsx' if engine != 'xlwt' else '.xls') as path: + writer = ExcelWriter(path, engine=engine) + df.to_excel(writer, sheet_name='frame') + df.style.to_excel(writer, sheet_name='unstyled') + styled.to_excel(writer, sheet_name='styled') + ExcelFormatter(styled, style_converter=custom_converter).write( + writer, sheet_name='custom') + + # 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) + + assert cell1.value == cell2.value + n_cells += 1 + + assert n_cells == (10 + 1) * (3 + 1) + + +@pytest.mark.parametrize('engine', [ + pytest.mark.xfail('xlwt', reason='xlwt does not support ' + 'openpyxl-compatible style dicts'), + 'xlsxwriter', + 'openpyxl', +]) +def test_styler_to_excel_no_cssdecl(engine, styled_dataframe): + def custom_converter(css): + # use bold iff there is custom style attached to the cell + if css.strip(' \n;'): + return {'font': {'bold': True}} + return {} + + try: + import cssdecl # noqa + except ImportError: + with pytest.raises(ImportError) as rec: + pd.DataFrame({"A": [1, 2]}).style.to_excel(engine) + assert rec.match("not installed") + else: + pytest.skip('Test only run if cssdecl not installed') + + pytest.importorskip(engine) + + styled = styled_dataframe + df = styled.data + + # Prepare spreadsheets + + with ensure_clean('.xlsx' if engine != 'xlwt' else '.xls') as path: + writer = ExcelWriter(path, engine=engine) + df.to_excel(writer, sheet_name='frame') + with pytest.raises(ImportError): + # default style_converter requires cssdecl + df.style.to_excel(writer, sheet_name='unstyled') + styled.to_excel(writer, sheet_name='styled') + + ExcelFormatter(styled, style_converter=custom_converter).write( + writer, sheet_name='custom') + + writer.save() + + openpyxl = pytest.importorskip('openpyxl') + if openpyxl_compat.is_compat(major_ver=1): + # smoke test only + return + + wb = openpyxl.load_workbook(path) + + # 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) diff --git a/pandas/tests/io/test_excel.py b/pandas/tests/io/test_excel.py index b4a5b24616728..1de016aadd358 100644 --- a/pandas/tests/io/test_excel.py +++ b/pandas/tests/io/test_excel.py @@ -17,7 +17,6 @@ import pandas as pd from pandas import DataFrame, Index, MultiIndex -from pandas.io.formats.excel import ExcelFormatter from pandas.io.parsers import read_csv from pandas.io.excel import ( ExcelFile, ExcelWriter, read_excel, _XlwtWriter, _Openpyxl1Writer, @@ -2361,141 +2360,3 @@ def check_called(func): check_called( lambda: df.to_excel( 'something.xls', engine='dummy')) - - -@pytest.mark.parametrize('engine', [ - pytest.mark.xfail('xlwt', reason='xlwt does not support ' - 'openpyxl-compatible style dicts'), - 'xlsxwriter', - 'openpyxl', -]) -def test_styler_to_excel(engine): - def style(df): - # XXX: RGB colors not supported in xlwt - return DataFrame([['font-weight: bold', '', ''], - ['', 'color: blue', ''], - ['', '', 'text-decoration: underline'], - ['border-style: solid', '', ''], - ['', 'font-style: italic', ''], - ['', '', 'text-align: right'], - ['background-color: red', '', ''], - ['', '', ''], - ['', '', ''], - ['', '', '']], - index=df.index, columns=df.columns) - - def assert_equal_style(cell1, cell2): - # XXX: should find a better way to check equality - assert cell1.alignment.__dict__ == cell2.alignment.__dict__ - assert cell1.border.__dict__ == cell2.border.__dict__ - assert cell1.fill.__dict__ == cell2.fill.__dict__ - assert cell1.font.__dict__ == cell2.font.__dict__ - assert cell1.number_format == cell2.number_format - assert cell1.protection.__dict__ == cell2.protection.__dict__ - - def custom_converter(css): - # use bold iff there is custom style attached to the cell - if css.strip(' \n;'): - return {'font': {'bold': True}} - return {} - - pytest.importorskip('jinja2') - pytest.importorskip(engine) - - if engine == 'openpyxl' and openpyxl_compat.is_compat(major_ver=1): - pytest.xfail('openpyxl1 does not support some openpyxl2-compatible ' - 'style dicts') - - # Prepare spreadsheets - - df = DataFrame(np.random.randn(10, 3)) - with ensure_clean('.xlsx' if engine != 'xlwt' else '.xls') as path: - writer = ExcelWriter(path, engine=engine) - df.to_excel(writer, sheet_name='frame') - df.style.to_excel(writer, sheet_name='unstyled') - styled = df.style.apply(style, axis=None) - styled.to_excel(writer, sheet_name='styled') - ExcelFormatter(styled, style_converter=custom_converter).write( - writer, sheet_name='custom') - - # 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) - - assert cell1.value == cell2.value - n_cells += 1 - - assert n_cells == (10 + 1) * (3 + 1)