diff --git a/pandas/formats/format.py b/pandas/formats/format.py index c16f1ce4f49ac..604225fa44d1c 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -1838,18 +1838,29 @@ def __call__(self, declarations_str, inherited=None): em_pt = float(em_pt[:-2]) else: em_pt = None - font_size = self.font_size_to_pt(props['font-size'], em_pt) - if font_size == int(font_size): - size_fmt = '%d' - else: - size_fmt = '%f' - props['font-size'] = (size_fmt + 'pt') % font_size + 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 - # 4. TODO: resolve other relative styles (e.g. ?) + 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_CONVERSIONS = { + UNIT_RATIOS = { 'rem': ('pt', 12), 'ex': ('em', .5), # 'ch': @@ -1859,11 +1870,12 @@ def __call__(self, declarations_str, inherited=None): 'cm': ('in', 1 / 2.54), 'mm': ('in', 1 / 25.4), 'q': ('mm', .25), + '!!default': ('em', 0), } - FONT_SIZE_CONVERSIONS = UNIT_CONVERSIONS.copy() - FONT_SIZE_CONVERSIONS.update({ - '%': ('em', 1), + FONT_SIZE_RATIOS = UNIT_RATIOS.copy() + FONT_SIZE_RATIOS.update({ + '%': ('em', .01), 'xx-small': ('rem', .5), 'x-small': ('rem', .625), 'small': ('rem', .8), @@ -1873,11 +1885,26 @@ def __call__(self, declarations_str, inherited=None): '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), }) - def font_size_to_pt(self, val, em_pt=None): + 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, val, em_pt=None, conversions=UNIT_RATIOS): try: - val, unit = re.match('(.*?)([a-zA-Z%].*)', val).groups() + val, unit = re.match('(.*?)([a-zA-Z%!].*)', val).groups() except AttributeError: warnings.warn('Unhandled font size: %r' % val, CSSWarning) return @@ -1900,9 +1927,19 @@ def font_size_to_pt(self, val, em_pt=None): unit = 'pt' continue - unit, mul = self.FONT_SIZE_CONVERSIONS[unit] + try: + unit, mul = conversions[unit] + except KeyError: + warnings.warn('Unknown size unit: %r' % unit, CSSWarning) + return self.size_to_pt('1!!default', conversions=conversions) val *= mul - return val + + 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: @@ -1915,33 +1952,33 @@ def atomize(self, declarations): for prop, value in expand(prop, value): yield prop, value - DIRECTION_SHORTHANDS = { + SIDE_SHORTHANDS = { 1: [0, 0, 0, 0], 2: [0, 1, 0, 1], 3: [0, 1, 2, 1], 4: [0, 1, 2, 3], } - DIRECTIONS = ('top', 'right', 'bottom', 'left') + SIDES = ('top', 'right', 'bottom', 'left') - def _direction_expander(prop_fmt): + def _side_expander(prop_fmt): def expand(self, prop, value): tokens = value.split() try: - mapping = self.DIRECTION_SHORTHANDS[len(tokens)] + 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.DIRECTIONS, mapping): + for key, idx in zip(self.SIDES, mapping): yield prop_fmt % key, tokens[idx] return expand - expand_border_color = _direction_expander('border-%s-color') - expand_border_style = _direction_expander('border-%s-style') - expand_border_width = _direction_expander('border-%s-width') - expand_margin = _direction_expander('margin-%s') - expand_padding = _direction_expander('padding-%s') + 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 @@ -1999,6 +2036,12 @@ def __call__(self, declarations_str): declarations_str : str List of CSS declarations. e.g. "font-weight: bold; background: blue" + + Returns + ------- + xlstyle : dict + A style as interpreted by ExcelWriter when found in + ExcelCell.style. """ # TODO: memoize? properties = self.compute_css(declarations_str, self.inherited) @@ -2045,28 +2088,65 @@ def build_alignment(self, props): } def build_border(self, props): + print(props) return {side: { - # TODO: convert styles and widths to openxml, one of: - # 'dashDot' - # 'dashDotDot' - # 'dashed' - # 'dotted' - # 'double' - # 'hair' - # 'medium' - # 'mediumDashDot' - # 'mediumDashDotDot' - # 'mediumDashed' - # 'slantDashDot' - # 'thick' - # 'thin' - 'style': ('medium' - if props.get('border-%s-style' % side) == 'solid' - else None), + 'style': self._border_style(props.get('border-%s-style' % side), + props.get('border-%s-width' % side)), 'color': self.color_to_excel( props.get('border-%s-color' % side)), } for side in ['top', 'right', 'bottom', 'left']} + def _border_style(self, style, width): + # TODO: convert styles and widths to openxml, one of: + # 'dashDot' + # 'dashDotDot' + # 'dashed' + # 'dotted' + # 'double' + # 'hair' + # 'medium' + # 'mediumDashDot' + # 'mediumDashDotDot' + # 'mediumDashed' + # 'slantDashDot' + # 'thick' + # 'thin' + if width is None and style is None: + return None + if style == 'none' or style == 'hidden': + return None + + if width is None: + width = '2pt' + width = float(width[:-2]) + if width < 1e-5: + return None + if width < 1: + width_name = 'hair' + elif width < 2: + width_name = 'thin' + elif width < 3.5: + width_name = 'medium' + else: + width_name = 'thick' + + if style in (None, 'groove', 'ridge', 'inset', 'outset'): + # not handled + style = 'solid' + + if style == 'double': + return 'double' + if style == 'solid': + return width_name + if style == 'dotted': + if width_name in ('hair', 'thin'): + return 'dotted' + return 'mediumDashDotDot' + if style == 'dashed': + if width_name in ('hair', 'thin'): + return 'dashed' + return 'mediumDashed' + def build_fill(self, props): # TODO: perhaps allow for special properties # -excel-pattern-bgcolor and -excel-pattern-type @@ -2087,9 +2167,11 @@ def build_font(self, props): size = props.get('font-size') if size is not None: assert size.endswith('pt') - size = int(round(size[:-2])) + size = float(size[:-2]) - font_names = props.get('font-family', '').split() + font_names = [name.strip() + for name in props.get('font-family', '').split(',') + if name.strip()] family = None for name in font_names: if name == 'serif': @@ -2155,9 +2237,9 @@ def color_to_excel(self, val): if val is None: return None if val.startswith('#') and len(val) == 7: - return val[1:] + return val[1:].upper() if val.startswith('#') and len(val) == 4: - return val[1] * 2 + val[2] * 2 + val[3] * 2 + return (val[1] * 2 + val[2] * 2 + val[3] * 2).upper() try: return self.NAMED_COLORS[val] except KeyError: diff --git a/pandas/tests/formats/test_to_excel.py b/pandas/tests/formats/test_to_excel.py index 99a4ed6259ddd..e3e0386d48189 100644 --- a/pandas/tests/formats/test_to_excel.py +++ b/pandas/tests/formats/test_to_excel.py @@ -8,7 +8,7 @@ from pandas.formats.format import CSSResolver, CSSWarning, CSSToExcelConverter -# Test parsing and normalising of CSS +# Tests for CSSResolver def assert_resolves(css, props, inherited=None): @@ -17,29 +17,41 @@ def assert_resolves(css, props, inherited=None): assert props == actual -def test_css_parse_whitespace(): - pass # TODO - - -def test_css_parse_case(): - pass # TODO - - -def test_css_parse_empty(): - pass # TODO - - -def test_css_parse_invalid(): - pass # TODO +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.xfail def test_css_parse_comments(): - pass # TODO + assert_same_resolution('hello: world', + 'hello/* foo */:/* bar \n */ world /*;not:here*/') @pytest.mark.xfail def test_css_parse_strings(): + # semicolons in strings + assert_resolves('background-image: url(\'http://blah.com/foo?a;b=c\')', + {'background-image': 'url(\'http://blah.com/foo?a;b=c\')'}) + assert_resolves('background-image: url("http://blah.com/foo?a;b=c")', + {'background-image': 'url("http://blah.com/foo?a;b=c")'}) + + +def test_css_parse_invalid(): pass # TODO @@ -56,27 +68,27 @@ def test_css_parse_strings(): ('border-style', ['border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style']), ]) -def test_css_direction_shorthands(shorthand, expansions): +def test_css_side_shorthands(shorthand, expansions): top, right, bottom, left = expansions - assert_resolves('%s: thin' % shorthand, - {top: 'thin', right: 'thin', - bottom: 'thin', left: 'thin'}) + assert_resolves('%s: 1pt' % shorthand, + {top: '1pt', right: '1pt', + bottom: '1pt', left: '1pt'}) - assert_resolves('%s: thin thick' % shorthand, - {top: 'thin', right: 'thick', - bottom: 'thin', left: 'thick'}) + assert_resolves('%s: 1pt 4pt' % shorthand, + {top: '1pt', right: '4pt', + bottom: '1pt', left: '4pt'}) - assert_resolves('%s: thin thick medium' % shorthand, - {top: 'thin', right: 'thick', - bottom: 'medium', left: 'thick'}) + assert_resolves('%s: 1pt 4pt 2pt' % shorthand, + {top: '1pt', right: '4pt', + bottom: '2pt', left: '4pt'}) - assert_resolves('%s: thin thick medium none' % shorthand, - {top: 'thin', right: 'thick', - bottom: 'medium', left: 'none'}) + assert_resolves('%s: 1pt 4pt 2pt 0pt' % shorthand, + {top: '1pt', right: '4pt', + bottom: '2pt', left: '0pt'}) with pytest.warns(CSSWarning): - assert_resolves('%s: thin thick medium none medium' % shorthand, + assert_resolves('%s: 1pt 1pt 1pt 1pt 1pt' % shorthand, {}) @@ -98,42 +110,152 @@ def test_css_font_shorthand(css, props): @pytest.mark.xfail -def test_css_background_shorthand(): - pass # TODO - - -def test_css_override(): - pass # TODO - - -def test_css_override_inherited(): - pass # TODO - - -def test_css_default_inherited(): - pass # TODO +@pytest.mark.parametrize('css,props', [ + ('background: blue', {'background-color': 'blue'}), + ('background: fixed blue', + {'background-color': 'blue', 'background-attachment': 'fixed'}), +]) +def test_css_background_shorthand(css, props): + assert_resolves(css, props) -def test_css_none_absent(): - pass # TODO +@pytest.mark.xfail +@pytest.mark.parametrize('style,equiv', [ + ('border: 1px solid red', + 'border-width: 1px; border-style: solid; border-color: red'), + ('border: solid red 1px', + 'border-width: 1px; border-style: solid; border-color: red'), + ('border: red solid', + 'border-style: solid; border-color: red'), +]) +def test_css_border_shorthand(style, equiv): + assert_same_resolution(style, equiv) + + +@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 -def test_css_font_size(): - pass # TODO +@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'), + # TODO: smaller, larger + + ('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) def test_css_font_size_invalid(): pass # TODO -# Test translation of CSS to ExcelCell.style values +# Tests for CSSToExcelConverter @pytest.mark.parametrize('css,expected', [ # FONT # - name + ('font-family: foo,bar', {'font': {'name': 'foo'}}), + pytest.mark.xfail(('font-family: "foo bar",baz', + {'font': {'name': 'foo bar'}})), + ('font-family: foo,\nbar', {'font': {'name': 'foo'}}), + ('font-family: foo, bar, baz', {'font': {'name': 'foo'}}), + ('font-family: bar, foo', {'font': {'name': 'bar'}}), # - family + ('font-family: serif', {'font': {'name': 'serif', 'family': 1}}), + ('font-family: roman, serif', {'font': {'name': 'roman', 'family': 1}}), + ('font-family: roman, sans-serif', {'font': {'name': 'roman', + 'family': 2}}), + ('font-family: roman, sans serif', {'font': {'name': 'roman'}}), + ('font-family: roman, sansserif', {'font': {'name': 'roman'}}), + ('font-family: roman, cursive', {'font': {'name': 'roman', 'family': 4}}), + ('font-family: roman, fantasy', {'font': {'name': 'roman', 'family': 5}}), # - size + ('font-size: 1em', {'font': {'size': 12}}), # - bold ('font-weight: 100', {'font': {'bold': False}}), ('font-weight: 200', {'font': {'bold': False}}), @@ -162,9 +284,32 @@ def test_css_font_size_invalid(): ('text-decoration: underline; text-decoration: line-through', {'font': {'strike': True, 'underline': False}}), # - color + ('font-color: red', {'font': {'color': 'FF0000'}}), + ('font-color: #ff0000', {'font': {'color': 'FF0000'}}), + ('font-color: #f0a', {'font': {'color': 'FF00AA'}}), # - shadow + ('text-shadow: none', {'font': {'shadow': False}}), + ('text-shadow: 0px -0em 0px #CCC', {'font': {'shadow': False}}), + ('text-shadow: 0px -0em 0px #999', {'font': {'shadow': False}}), + ('text-shadow: 0px -0em 0px', {'font': {'shadow': False}}), + ('text-shadow: 2px -0em 0px #CCC', {'font': {'shadow': True}}), + ('text-shadow: 0px -2em 0px #CCC', {'font': {'shadow': True}}), + ('text-shadow: 0px -0em 2px #CCC', {'font': {'shadow': True}}), + ('text-shadow: 0px -0em 2px', {'font': {'shadow': True}}), + ('text-shadow: 0px -2em', {'font': {'shadow': True}}), + # text-shadow with color first not yet implemented + pytest.mark.xfail(('text-shadow: #CCC 3px 3px 3px', + {'font': {'shadow': True}})), + pytest.mark.xfail(('text-shadow: #999 0px 0px 0px', + {'font': {'shadow': False}})), # FILL # - color, fillType + ('background-color: red', {'fill': {'fgColor': 'FF0000', + 'patternType': 'solid'}}), + ('background-color: #ff0000', {'fill': {'fgColor': 'FF0000', + 'patternType': 'solid'}}), + ('background-color: #f0a', {'fill': {'fgColor': 'FF00AA', + 'patternType': 'solid'}}), # BORDER # - style # - color @@ -178,6 +323,24 @@ def test_css_to_excel(css, expected): assert expected == convert(css) +def test_css_to_excel_multiple(): + convert = CSSToExcelConverter() + actual = convert(''' + font-weight: bold; + border-width: thin; + text-align: center; + vertical-align: top; + unused: something; + ''') + assert {"font": {"bold": True}, + "border": {"top": {"style": "hair"}, + "right": {"style": "hair"}, + "bottom": {"style": "hair"}, + "left": {"style": "hair"}}, + "alignment": {"horizontal": "center", + "vertical": "top"}} == actual + + @pytest.mark.parametrize('css,inherited,expected', [ ('font-weight: bold', '', {'font': {'bold': True}}),