From ada51015ebf42b39396503b679948831ab4a32fb Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Tue, 28 Feb 2017 22:03:13 +1100 Subject: [PATCH 01/48] ENH: support Styler in ExcelFormatter --- pandas/formats/format.py | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 4c081770e0125..83fc66a0b8f22 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -1655,7 +1655,7 @@ class ExcelFormatter(object): Parameters ---------- - df : dataframe + df : dataframe or Styler na_rep: na representation float_format : string, default None Format string for floating point numbers @@ -1675,13 +1675,22 @@ class ExcelFormatter(object): inf_rep : string, default `'inf'` representation for np.inf values (which aren't representable in Excel) A `'-'` sign will be added in front of -inf. + style_converter : callable, optional + This translates Styler styles (CSS) into ExcelWriter styles. + It should have signature css_list -> dict or None. + This is only called for body cells. """ def __init__(self, df, na_rep='', float_format=None, cols=None, header=True, index=True, index_label=None, merge_cells=False, - inf_rep='inf'): + inf_rep='inf', style_converter=None): self.rowcounter = 0 self.na_rep = na_rep + if hasattr(df, 'render'): + self.styler = df + df = df.data + else: + self.styler = None self.df = df if cols is not None: self.df = df.loc[:, cols] @@ -1692,6 +1701,7 @@ def __init__(self, df, na_rep='', float_format=None, cols=None, self.header = header self.merge_cells = merge_cells self.inf_rep = inf_rep + self.style_converter = style_converter def _format_value(self, val): if lib.checknull(val): @@ -1802,7 +1812,6 @@ def _format_regular_rows(self): if has_aliases or self.header: self.rowcounter += 1 - coloffset = 0 # output index and index_label? if self.index: # chek aliases @@ -1829,15 +1838,11 @@ def _format_regular_rows(self): if isinstance(self.df.index, PeriodIndex): index_values = self.df.index.to_timestamp() - coloffset = 1 for idx, idxval in enumerate(index_values): yield ExcelCell(self.rowcounter + idx, 0, idxval, header_style) - # Write the body of the frame data series by series. - for colidx in range(len(self.columns)): - series = self.df.iloc[:, colidx] - for i, val in enumerate(series): - yield ExcelCell(self.rowcounter + i, colidx + coloffset, val) + for cell in self._generate_body(coloffset=1): + yield cell def _format_hierarchical_rows(self): has_aliases = isinstance(self.header, (tuple, list, np.ndarray, Index)) @@ -1902,11 +1907,26 @@ def _format_hierarchical_rows(self): indexcolval, header_style) gcolidx += 1 + for cell in self._generate_body(coloffset=gcolidx): + yield cell + + def _generate_body(self, coloffset): + if self.style_converter is None or self.styler is None: + styles = None + else: + styles = self.styler._compute().ctx + if not styles: + styles = None + xlstyle = None + # Write the body of the frame data series by series. for colidx in range(len(self.columns)): series = self.df.iloc[:, colidx] for i, val in enumerate(series): - yield ExcelCell(self.rowcounter + i, gcolidx + colidx, val) + if styles is not None: + xlstyle = self.style_converter(styles[i, colidx]) + yield ExcelCell(self.rowcounter + i, colidx + coloffset, val, + xlstyle) def get_formatted_cells(self): for cell in itertools.chain(self._format_header(), From f1cde08afa6105b500e8fd62757c38d77d26acb7 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Tue, 28 Feb 2017 23:41:09 +1100 Subject: [PATCH 02/48] FIX column offset incorrect in refactor --- pandas/formats/format.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 83fc66a0b8f22..4b51c71a2c042 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -1841,7 +1841,11 @@ def _format_regular_rows(self): for idx, idxval in enumerate(index_values): yield ExcelCell(self.rowcounter + idx, 0, idxval, header_style) - for cell in self._generate_body(coloffset=1): + coloffset = 1 + else: + coloffset = 0 + + for cell in self._generate_body(coloffset): yield cell def _format_hierarchical_rows(self): @@ -1907,7 +1911,7 @@ def _format_hierarchical_rows(self): indexcolval, header_style) gcolidx += 1 - for cell in self._generate_body(coloffset=gcolidx): + for cell in self._generate_body(gcolidx): yield cell def _generate_body(self, coloffset): From 96680f946e81aefcf2abf455d1280e00b4d63f32 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 5 Apr 2017 12:06:42 +1000 Subject: [PATCH 03/48] Largely complete CSSToExcelConverter and Styler.to_excel() --- pandas/core/frame.py | 80 ++++----- pandas/formats/format.py | 347 ++++++++++++++++++++++++++++++++++++++- pandas/formats/style.py | 25 +++ 3 files changed, 408 insertions(+), 44 deletions(-) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index c47490bfbede4..32f5a0309b864 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -199,6 +199,43 @@ """ + +def _to_excel(self, excel_writer, sheet_name='Sheet1', na_rep='', + float_format=None, columns=None, header=True, index=True, + index_label=None, startrow=0, startcol=0, engine=None, + merge_cells=True, encoding=None, inf_rep='inf', verbose=True, + freeze_panes=None): + # This implementation is shared by Styler.to_excel + from pandas.io.excel import ExcelWriter + need_save = False + if encoding is None: + encoding = 'ascii' + + if isinstance(excel_writer, compat.string_types): + excel_writer = ExcelWriter(excel_writer, engine=engine) + need_save = True + + formatter = fmt.ExcelFormatter(self, na_rep=na_rep, cols=columns, + header=header, + float_format=float_format, index=index, + index_label=index_label, + merge_cells=merge_cells, + inf_rep=inf_rep) + + formatted_cells = formatter.get_formatted_cells() + + if freeze_panes is not None: + if len(freeze_panes) != 2 or any(not isinstance(item, int) + for item in freeze_panes): + raise ValueError("freeze_panes must be of form (row, column)" + " where row and column are integers") + + excel_writer.write_cells(formatted_cells, sheet_name, + startrow=startrow, startcol=startcol, + freeze_panes=freeze_panes) + if need_save: + excel_writer.save() + # ----------------------------------------------------------------------- # DataFrame class @@ -1391,46 +1428,9 @@ def to_csv(self, path_or_buf=None, sep=",", na_rep='', float_format=None, if path_or_buf is None: return formatter.path_or_buf.getvalue() - @Appender(_shared_docs['to_excel'] % _shared_doc_kwargs) - def to_excel(self, excel_writer, sheet_name='Sheet1', na_rep='', - float_format=None, columns=None, header=True, index=True, - index_label=None, startrow=0, startcol=0, engine=None, - merge_cells=True, encoding=None, inf_rep='inf', verbose=True, - freeze_panes=None): - from pandas.io.excel import ExcelWriter - need_save = False - if encoding is None: - encoding = 'ascii' - - if isinstance(excel_writer, compat.string_types): - excel_writer = ExcelWriter(excel_writer, engine=engine) - need_save = True - - formatter = fmt.ExcelFormatter(self, na_rep=na_rep, cols=columns, - header=header, - float_format=float_format, index=index, - index_label=index_label, - merge_cells=merge_cells, - inf_rep=inf_rep) - - formatted_cells = formatter.get_formatted_cells() - freeze_panes = self._validate_freeze_panes(freeze_panes) - excel_writer.write_cells(formatted_cells, sheet_name, - startrow=startrow, startcol=startcol, - freeze_panes=freeze_panes) - if need_save: - excel_writer.save() - - def _validate_freeze_panes(self, freeze_panes): - if freeze_panes is not None: - if ( - len(freeze_panes) == 2 and - all(isinstance(item, int) for item in freeze_panes) - ): - return freeze_panes - - raise ValueError("freeze_panes must be of form (row, column)" - " where row and column are integers") + to_excel = Appender(_shared_docs['to_excel'] + % _shared_doc_kwargs)(_to_excel) + def to_stata(self, fname, convert_dates=None, write_index=True, encoding="latin-1", byteorder=None, time_stamp=None, diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 4b51c71a2c042..7eb5872dcc7d9 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -9,6 +9,8 @@ # pylint: disable=W0141 import sys +import re +import warnings from pandas.types.missing import isnull, notnull from pandas.types.common import (is_categorical_dtype, @@ -1649,13 +1651,347 @@ def __init__(self, row, col, val, style=None, mergestart=None, "vertical": "top"}} +class CSSParseWarning(Warning): + """This CSS syntax cannot currently be parsed""" + pass + + +class CSSToExcelConverter(object): + """Converts CSS declarations to ExcelWriter styles + + Supports parts of CSS2, with minimal CSS3 support (e.g. text-shadow), + focusing on font styling, backgrounds, borders and alignment. + + Operates by first computing CSS styles in a fairly generic + way (see :meth:`compute_css`) then determining Excel style + properties from CSS properties (see :meth:`build_xlstyle`). + + Parameters + ---------- + inherited : str, optional + CSS declarations understood to be the containing scope for the + CSS processed by :meth:`__call__`. + """ + + def __init__(self, inherited=None): + if inherited is not None: + inherited = self.compute_css(inherited, INITIAL_STYLE) + + self.inherited = inherited + + INITIAL_STYLE = { + 'font-size': '12pt' + } + + def __call__(self, declarations_str): + """Convert CSS declarations to ExcelWriter style + + Parameters + ---------- + declarations_str : str + List of CSS declarations. + e.g. "font-weight: bold; background: blue" + """ + # TODO: memoize? + properties = self.compute_css(declarations_str) + return self.build_xlstyle(properties) + + def build_xlstyle(self, props): + out = { + 'alignment': self.build_alignment(props), + 'borders': self.build_borders(props), + 'fill': self.build_fill(props), + 'font': self.build_font(props), + } + # TODO: handle cell width and height: needs support in pandas.io.excel + + def remove_none(d): + """Remove key where value is None, through nested dicts""" + for k, v in list(d.items()): + if v is None: + del d[k] + elif isinstance(v, dict): + remove_none(v) + if not v: + del d[k] + + remove_none(out) + return out + + VERTICAL_MAP = { + 'top': 'top', + 'text-top': 'top', + 'middle': 'center', + 'baseline': 'bottom', + 'bottom': 'bottom', + 'text-bottom': 'bottom', + # OpenXML also has 'justify', 'distributed' + } + + def build_alignment(self, props): + # TODO: text-indent -> alignment.indent + return {'horizontal': props.get('text-align'), + 'vertical': self.VERTICAL_MAP.get(props.get('vertical-align')), + 'wrapText': (props['white-space'] not in (None, 'nowrap') + if 'white-space' in props else None), + } + + def build_borders(self, 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), + 'color': self.color_to_excel( + props.get('border-%s-color' % side)), + } for side in ['top', 'right', 'bottom', 'left']} + + def build_fill(self, props): + # TODO: perhaps allow for special properties + # -excel-pattern-bgcolor and -excel-pattern-type + fill_color = props.get('background-color') + if fill_color not in (None, 'transparent', 'none'): + return { + 'fgColor': self.color_to_excel(fill_color), + 'patternType': 'solid', + } + + BOLD_MAP = {k: True for k in + ['bold', 'bolder', '600', '700', '800', '900']} + ITALIC_MAP = {'italic': True, 'oblique': True} + UNDERLINE_MAP = {'underline': True} + STRIKE_MAP = {'line-through': True} + + def build_font(self, props): + size = props.get('font-size') + if size is not None: + assert size.endswith('pt') + size = int(round(size[:-2])) + + font_names = props.get('font-family', '').split() + family = None + for name in font_names: + if name == 'serif': + family = 1 # roman + break + elif name == 'sans-serif': + family = 2 # swiss + break + elif name == 'cursive': + family = 4 # script + break + elif name == 'fantasy': + family = 5 # decorative + break + + return { + 'name': font_names[0] if font_names else None, + 'family': family, + 'size': size, + 'bold': self.BOLD_MAP.get(props.get('font-weight')), + 'italic': self.ITALIC_MAP.get(props.get('font-style')), + 'underline': self.UNDERLINE_MAP.get(props.get('text-decoration')), + 'strike': self.STRIKE_MAP.get(props.get('text-decoration')), + 'color': self.color_to_excel(props.get('font-color')), + # shadow if nonzero digit before shadow colour + 'shadow': (bool(re.search('^[^#(]*[1-9]', + props['text-shadow'])) + if 'text-shadow' in props else None), + # 'vertAlign':, + # 'charset': , + # 'scheme': , + # 'outline': , + # 'condense': , + } + + NAMED_COLORS = { + 'maroon': '800000', + 'red': 'FF0000', + 'orange': 'FFA500', + 'yellow': 'FFFF00', + 'olive': '808000', + 'green': '008000', + 'purple': '800080', + 'fuchsia': 'FF00FF', + 'lime': '00FF00', + 'teal': '008080', + 'aqua': '00FFFF', + 'blue': '0000FF', + 'navy': '000080', + 'black': '000000', + 'gray': '808080', + 'silver': 'C0C0C0', + 'white': 'FFFFFF', + } + + def color_to_excel(self, val): + if val is None: + return None + if val.startswith('#') and len(val) == 7: + return val[1:] + if val.startswith('#') and len(val) == 4: + return val[1] * 2 + val[2] * 2 + val[3] * 2 + try: + return self.NAMED_COLORS[val] + except KeyError: + warnings.warn('Unhandled colour format: %r' % val, CSSParseWarning) + + unit_conversions = { + '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), + } + + font_size_conversions = unit_conversions.copy() + font_size_conversions.update({ + '%': ('em', 1), + '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), + }) + + def font_size_to_pt(self, val, em_pt=None): + val, unit = re.split('(?=[a-zA-Z%])', val, 1).groups() + if val == '': + # hack for 'large' etc. + val = 1 + + while unit != 'pt': + if unit == 'em': + if em_pt is None: + unit = 'rem' + else: + val *= em_pt + unit = 'pt' + continue + + unit, mul = font_size_conversions[unit] + val *= mul + return val + + @classmethod + def compute_css(cls, declarations_str, inherited=None): + props = dict(cls.atomize(cls.parse(declarations_str))) + if inherited is None: + inherited = {} + + # 1. resolve inherited, initial + for prop, val in list(props.items()): + if val == 'inherited': + 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[val] + else: + props[prop] = val + + # 2. resolve relative font size + if props.get('font-size'): + em_pt = self.INITIAL_STYLE['font-size'] + assert em_pt[-2:] == 'pt' + em_pt = float(em_pt[:-2]) + font_size = self.font_size_to_pt(props['font-size'], em_pt) + props['font-size'] = '%fpt' % font_size + + # 3. TODO: resolve other font-relative units + # 4. TODO: resolve other relative styles (e.g. ?) + return props + + @classmethod + def atomize(cls, declarations): + for prop, value in declarations: + attr = 'expand_' + prop.replace('-', '_') + try: + expand = getattr(cls, attr) + except AttributeError: + yield prop, value + else: + for prop, value in expand(prop, value): + yield prop, value + + DIRECTION_SHORTHANDS = { + 1: [0, 0, 0, 0], + 2: [0, 1, 0, 1], + 3: [0, 1, 2, 1], + 3: [0, 1, 2, 3], + } + DIRECTIONS = ('top', 'right', 'bottom', 'left') + + def _direction_expander(prop_fmt): + @classmethod + def expand(cls, prop, value): + tokens = value.split() + try: + mapping = cls.DIRECTION_SHORTHANDS[len(tokens)] + except KeyError: + warnings.warn('Could not expand "%s: %s"' % (prop, value), + CSSParseWarning) + return + for key, idx in zip(cls.DIRECTIONS, 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') + + @classmethod + def parse(cls, declarations_str): + """Generates (prop, value) pairs from declarations + + In a future version may generate parsed tokens from tinycss/tinycss2 + """ + for decl in sum((l.split(';') for l in declarations_str), []): + 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 not sep: + raise ValueError('Ill-formatted attribute: expected a colon ' + 'in %r' % decl) + yield prop, val + + class ExcelFormatter(object): """ Class for formatting a DataFrame to a list of ExcelCells, Parameters ---------- - df : dataframe or Styler + df : DataFrame or Styler na_rep: na representation float_format : string, default None Format string for floating point numbers @@ -1677,7 +2013,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. - It should have signature css_list -> dict or None. + Defaults to ``CSSToExcelConverter()``. + It should have signature css_declarations string -> excel style. This is only called for body cells. """ @@ -1689,6 +2026,9 @@ def __init__(self, df, na_rep='', float_format=None, cols=None, if hasattr(df, 'render'): self.styler = df df = df.data + if style_converter is None: + style_converter = CSSToExcelConverter() + self.style_converter = style_converter else: self.styler = None self.df = df @@ -1701,7 +2041,6 @@ def __init__(self, df, na_rep='', float_format=None, cols=None, self.header = header self.merge_cells = merge_cells self.inf_rep = inf_rep - self.style_converter = style_converter def _format_value(self, val): if lib.checknull(val): @@ -1915,7 +2254,7 @@ def _format_hierarchical_rows(self): yield cell def _generate_body(self, coloffset): - if self.style_converter is None or self.styler is None: + if self.styler is None: styles = None else: styles = self.styler._compute().ctx diff --git a/pandas/formats/style.py b/pandas/formats/style.py index 89712910a22e1..6893d6d2cfabd 100644 --- a/pandas/formats/style.py +++ b/pandas/formats/style.py @@ -23,8 +23,10 @@ import pandas as pd from pandas.compat import range from pandas.core.config import get_option +from pandas.core.generic import _shared_docs import pandas.core.common as com from pandas.core.indexing import _maybe_numeric_slice, _non_reducing_slice +from pandas.util.decorators import Appender try: import matplotlib.pyplot as plt from matplotlib import colors @@ -191,6 +193,29 @@ def _repr_html_(self): """Hooks into Jupyter notebook rich display system.""" return self.render() + @Appender(_shared_docs['to_excel'] % dict( + axes='index, columns', klass='Styler', + axes_single_arg="{0 or 'index', 1 or 'columns'}", + optional_by=""" + by : str or list of str + Name or list of names which refer to the axis items.""", + versionadded_to_excel='\n .. versionadded:: 0.20')) + def to_excel(self, excel_writer, sheet_name='Sheet1', na_rep='', + float_format=None, columns=None, header=True, index=True, + index_label=None, startrow=0, startcol=0, engine=None, + merge_cells=True, encoding=None, inf_rep='inf', verbose=True, + freeze_panes=None): + + from pandas.core.frame import _to_excel + return _to_excel(self, excel_writer, sheet_name=sheet_name, + na_rep=na_rep, float_format=float_format, + columns=columns, header=header, index=index, + index_label=index_label, startrow=startrow, + startcol=startcol, engine=engine, + merge_cells=merge_cells, encoding=encoding, + inf_rep=inf_rep, verbose=verbose, + freeze_panes=freeze_panes) + def _translate(self): """ Convert the DataFrame in `self.data` and the attrs from `_build_styles` From 0ce72f925cc9aabc689419583cf4abe3981f6784 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 5 Apr 2017 12:32:43 +1000 Subject: [PATCH 04/48] Use inherited font size for em_pt --- pandas/formats/format.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 692847cdebbce..61f83c17cdf00 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -1787,7 +1787,6 @@ def __init__(self, inherited=None): self.inherited = inherited INITIAL_STYLE = { - 'font-size': '12pt' } def __call__(self, declarations_str): @@ -2022,9 +2021,10 @@ def compute_css(cls, declarations_str, inherited=None): # 2. resolve relative font size if props.get('font-size'): - em_pt = self.INITIAL_STYLE['font-size'] - assert em_pt[-2:] == 'pt' - em_pt = float(em_pt[:-2]) + if 'font-size' in inherited: + em_pt = inherited['font-size'] + assert em_pt[-2:] == 'pt' + em_pt = float(em_pt[:-2]) font_size = self.font_size_to_pt(props['font-size'], em_pt) props['font-size'] = '%fpt' % font_size From cb5cf020500b40ece180e57cc48393ea573e4ff7 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 5 Apr 2017 13:00:04 +1000 Subject: [PATCH 05/48] Fix bug where inherited not being passed; avoid classmethods --- pandas/formats/format.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 61f83c17cdf00..0387177899113 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -1779,6 +1779,10 @@ class CSSToExcelConverter(object): CSS declarations understood to be the containing scope for the CSS processed by :meth:`__call__`. """ + # NB: Most of the methods here could be classmethods, as only __init__ + # and __call__ make use of instance attributes. We leave them as + # instancemethods so that users can easily experiment with extensions + # without monkey-patching. def __init__(self, inherited=None): if inherited is not None: @@ -1799,7 +1803,7 @@ def __call__(self, declarations_str): e.g. "font-weight: bold; background: blue" """ # TODO: memoize? - properties = self.compute_css(declarations_str) + properties = self.compute_css(declarations_str, self.inherited) return self.build_xlstyle(properties) def build_xlstyle(self, props): @@ -2000,9 +2004,8 @@ def font_size_to_pt(self, val, em_pt=None): val *= mul return val - @classmethod - def compute_css(cls, declarations_str, inherited=None): - props = dict(cls.atomize(cls.parse(declarations_str))) + def compute_css(self, declarations_str, inherited=None): + props = dict(self.atomize(self.parse(declarations_str))) if inherited is None: inherited = {} @@ -2032,12 +2035,11 @@ def compute_css(cls, declarations_str, inherited=None): # 4. TODO: resolve other relative styles (e.g. ?) return props - @classmethod - def atomize(cls, declarations): + def atomize(self, declarations): for prop, value in declarations: attr = 'expand_' + prop.replace('-', '_') try: - expand = getattr(cls, attr) + expand = getattr(self, attr) except AttributeError: yield prop, value else: @@ -2053,16 +2055,15 @@ def atomize(cls, declarations): DIRECTIONS = ('top', 'right', 'bottom', 'left') def _direction_expander(prop_fmt): - @classmethod - def expand(cls, prop, value): + def expand(self, prop, value): tokens = value.split() try: - mapping = cls.DIRECTION_SHORTHANDS[len(tokens)] + mapping = self.DIRECTION_SHORTHANDS[len(tokens)] except KeyError: warnings.warn('Could not expand "%s: %s"' % (prop, value), CSSParseWarning) return - for key, idx in zip(cls.DIRECTIONS, mapping): + for key, idx in zip(self.DIRECTIONS, mapping): yield prop_fmt % key, tokens[idx] return expand @@ -2073,8 +2074,7 @@ def expand(cls, prop, value): expand_margin = _direction_expander('margin-%s') expand_padding = _direction_expander('padding-%s') - @classmethod - def parse(cls, declarations_str): + def parse(self, declarations_str): """Generates (prop, value) pairs from declarations In a future version may generate parsed tokens from tinycss/tinycss2 From c589c356222a94b08066bf2e1634a5defa35d1cc Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 5 Apr 2017 13:04:08 +1000 Subject: [PATCH 06/48] Fix some lint errors (yes, the code needs testing) --- pandas/formats/format.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 0387177899113..536bd57d5f143 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -1786,7 +1786,7 @@ class CSSToExcelConverter(object): def __init__(self, inherited=None): if inherited is not None: - inherited = self.compute_css(inherited, INITIAL_STYLE) + inherited = self.compute_css(inherited, sefl.INITIAL_STYLE) self.inherited = inherited @@ -1959,7 +1959,7 @@ def color_to_excel(self, val): except KeyError: warnings.warn('Unhandled colour format: %r' % val, CSSParseWarning) - unit_conversions = { + UNIT_CONVERSIONS = { 'rem': ('pt', 12), 'ex': ('em', .5), # 'ch': @@ -1971,8 +1971,8 @@ def color_to_excel(self, val): 'q': ('mm', .25), } - font_size_conversions = unit_conversions.copy() - font_size_conversions.update({ + FONT_SIZE_CONVERSIONS = unit_conversions.copy() + FONT_SIZE_CONVERSIONS.update({ '%': ('em', 1), 'xx-small': ('rem', .5), 'x-small': ('rem', .625), @@ -2000,7 +2000,7 @@ def font_size_to_pt(self, val, em_pt=None): unit = 'pt' continue - unit, mul = font_size_conversions[unit] + unit, mul = self.FONT_SIZE_CONVERSIONS[unit] val *= mul return val @@ -2050,7 +2050,7 @@ def atomize(self, declarations): 1: [0, 0, 0, 0], 2: [0, 1, 0, 1], 3: [0, 1, 2, 1], - 3: [0, 1, 2, 3], + 4: [0, 1, 2, 3], } DIRECTIONS = ('top', 'right', 'bottom', 'left') From 176e51ca8e60279fced19cb0d08f6c4b5476a29f Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 5 Apr 2017 13:19:18 +1000 Subject: [PATCH 07/48] Fix NameError --- pandas/formats/format.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 536bd57d5f143..16158663fb94a 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -1839,7 +1839,7 @@ def remove_none(d): } def build_alignment(self, props): - # TODO: text-indent -> alignment.indent + # TODO: text-indent, margin-left -> alignment.indent return {'horizontal': props.get('text-align'), 'vertical': self.VERTICAL_MAP.get(props.get('vertical-align')), 'wrapText': (props['white-space'] not in (None, 'nowrap') @@ -1971,7 +1971,7 @@ def color_to_excel(self, val): 'q': ('mm', .25), } - FONT_SIZE_CONVERSIONS = unit_conversions.copy() + FONT_SIZE_CONVERSIONS = UNIT_CONVERSIONS.copy() FONT_SIZE_CONVERSIONS.update({ '%': ('em', 1), 'xx-small': ('rem', .5), From d103f61ccd70101476a642c5d204c461896fab3a Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 5 Apr 2017 17:13:42 +1000 Subject: [PATCH 08/48] Refactoring and initial tests for CSS to Excel --- pandas/formats/format.py | 373 +++++++++++++++----------- pandas/tests/formats/test_to_excel.py | 178 ++++++++++++ pandas/tests/io/test_excel.py | 4 + 3 files changed, 404 insertions(+), 151 deletions(-) create mode 100644 pandas/tests/formats/test_to_excel.py diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 16158663fb94a..34e59b2977319 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -1758,15 +1758,213 @@ def __init__(self, row, col, val, style=None, mergestart=None, "vertical": "top"}} -class CSSParseWarning(Warning): +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[val] + 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 + 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 + + # 3. TODO: resolve other font-relative units + # 4. TODO: resolve other relative styles (e.g. ?) + return props + + UNIT_CONVERSIONS = { + '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), + } + + FONT_SIZE_CONVERSIONS = UNIT_CONVERSIONS.copy() + FONT_SIZE_CONVERSIONS.update({ + '%': ('em', 1), + '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), + }) + + def font_size_to_pt(self, val, em_pt=None): + try: + val, unit = re.match('(.*?)([a-zA-Z%].*)', val).groups() + except AttributeError: + warnings.warn('Unhandled font size: %r' % val, CSSWarning) + return + if val == '': + # hack for 'large' etc. + val = 1 + else: + try: + val = float(val) + except ValueError: + warnings.warn('Unhandled font size: %r' % val + unit, + CSSWarning) + + while unit != 'pt': + if unit == 'em': + if em_pt is None: + unit = 'rem' + else: + val *= em_pt + unit = 'pt' + continue + + unit, mul = self.FONT_SIZE_CONVERSIONS[unit] + val *= mul + return 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 + + DIRECTION_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') + + def _direction_expander(prop_fmt): + def expand(self, prop, value): + tokens = value.split() + try: + mapping = self.DIRECTION_SHORTHANDS[len(tokens)] + except KeyError: + warnings.warn('Could not expand "%s: %s"' % (prop, value), + CSSWarning) + return + for key, idx in zip(self.DIRECTIONS, 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') + + 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 not sep: + warnings.warn('Ill-formatted attribute: expected a colon ' + 'in %r' % decl, CSSWarning) + yield prop, val + + class CSSToExcelConverter(object): - """Converts CSS declarations to ExcelWriter styles + """A callable for converting CSS declarations to ExcelWriter styles - Supports parts of CSS2, with minimal CSS3 support (e.g. text-shadow), + Supports parts of CSS 2.2, with minimal CSS 3.0 support (e.g. text-shadow), focusing on font styling, backgrounds, borders and alignment. Operates by first computing CSS styles in a fairly generic @@ -1790,8 +1988,7 @@ def __init__(self, inherited=None): self.inherited = inherited - INITIAL_STYLE = { - } + compute_css = CSSResolver() def __call__(self, declarations_str): """Convert CSS declarations to ExcelWriter style @@ -1809,7 +2006,7 @@ def __call__(self, declarations_str): def build_xlstyle(self, props): out = { 'alignment': self.build_alignment(props), - 'borders': self.build_borders(props), + 'border': self.build_border(props), 'fill': self.build_fill(props), 'font': self.build_font(props), } @@ -1839,14 +2036,14 @@ def remove_none(d): } def build_alignment(self, props): - # TODO: text-indent, margin-left -> alignment.indent + # TODO: text-indent, padding-left -> alignment.indent return {'horizontal': props.get('text-align'), 'vertical': self.VERTICAL_MAP.get(props.get('vertical-align')), - 'wrapText': (props['white-space'] not in (None, 'nowrap') - if 'white-space' in props else None), + 'wrap_text': (props['white-space'] not in (None, 'nowrap') + if 'white-space' in props else None), } - def build_borders(self, props): + def build_border(self, props): return {side: { # TODO: convert styles and widths to openxml, one of: # 'dashDot' @@ -1879,11 +2076,11 @@ def build_fill(self, props): 'patternType': 'solid', } - BOLD_MAP = {k: True for k in - ['bold', 'bolder', '600', '700', '800', '900']} - ITALIC_MAP = {'italic': True, 'oblique': True} - UNDERLINE_MAP = {'underline': True} - STRIKE_MAP = {'line-through': True} + BOLD_MAP = {'bold': True, 'bolder': True, '600': True, '700': True, + '800': True, '900': True, + 'normal': False, 'lighter': False, '100': False, '200': False, + '300': False, '400': False, '500': False} + ITALIC_MAP = {'normal': False, 'italic': True, 'oblique': True} def build_font(self, props): size = props.get('font-size') @@ -1907,14 +2104,20 @@ def build_font(self, props): family = 5 # decorative break + decoration = props.get('text-decoration') + if decoration is not None: + decoration = decoration.split() + return { 'name': font_names[0] if font_names else None, 'family': family, 'size': size, 'bold': self.BOLD_MAP.get(props.get('font-weight')), 'italic': self.ITALIC_MAP.get(props.get('font-style')), - 'underline': self.UNDERLINE_MAP.get(props.get('text-decoration')), - 'strike': self.STRIKE_MAP.get(props.get('text-decoration')), + 'underline': (None if decoration is None + else 'underline' in decoration), + 'strike': (None if decoration is None + else 'line-through' in decoration), 'color': self.color_to_excel(props.get('font-color')), # shadow if nonzero digit before shadow colour 'shadow': (bool(re.search('^[^#(]*[1-9]', @@ -1957,139 +2160,7 @@ def color_to_excel(self, val): try: return self.NAMED_COLORS[val] except KeyError: - warnings.warn('Unhandled colour format: %r' % val, CSSParseWarning) - - UNIT_CONVERSIONS = { - '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), - } - - FONT_SIZE_CONVERSIONS = UNIT_CONVERSIONS.copy() - FONT_SIZE_CONVERSIONS.update({ - '%': ('em', 1), - '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), - }) - - def font_size_to_pt(self, val, em_pt=None): - val, unit = re.split('(?=[a-zA-Z%])', val, 1).groups() - if val == '': - # hack for 'large' etc. - val = 1 - - while unit != 'pt': - if unit == 'em': - if em_pt is None: - unit = 'rem' - else: - val *= em_pt - unit = 'pt' - continue - - unit, mul = self.FONT_SIZE_CONVERSIONS[unit] - val *= mul - return val - - def compute_css(self, declarations_str, inherited=None): - props = dict(self.atomize(self.parse(declarations_str))) - if inherited is None: - inherited = {} - - # 1. resolve inherited, initial - for prop, val in list(props.items()): - if val == 'inherited': - 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[val] - 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]) - font_size = self.font_size_to_pt(props['font-size'], em_pt) - props['font-size'] = '%fpt' % font_size - - # 3. TODO: resolve other font-relative units - # 4. TODO: resolve other relative styles (e.g. ?) - return props - - 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 - - DIRECTION_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') - - def _direction_expander(prop_fmt): - def expand(self, prop, value): - tokens = value.split() - try: - mapping = self.DIRECTION_SHORTHANDS[len(tokens)] - except KeyError: - warnings.warn('Could not expand "%s: %s"' % (prop, value), - CSSParseWarning) - return - for key, idx in zip(self.DIRECTIONS, 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') - - 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 sum((l.split(';') for l in declarations_str), []): - 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 not sep: - raise ValueError('Ill-formatted attribute: expected a colon ' - 'in %r' % decl) - yield prop, val + warnings.warn('Unhandled colour format: %r' % val, CSSWarning) class ExcelFormatter(object): @@ -2374,7 +2445,7 @@ def _generate_body(self, coloffset): series = self.df.iloc[:, colidx] for i, val in enumerate(series): if styles is not None: - xlstyle = self.style_converter(styles[i, colidx]) + xlstyle = self.style_converter(';'.join(styles[i, colidx])) yield ExcelCell(self.rowcounter + i, colidx + coloffset, val, xlstyle) diff --git a/pandas/tests/formats/test_to_excel.py b/pandas/tests/formats/test_to_excel.py new file mode 100644 index 0000000000000..de24e62f2d2a1 --- /dev/null +++ b/pandas/tests/formats/test_to_excel.py @@ -0,0 +1,178 @@ +"""Tests formatting as writer-agnostic ExcelCells + +Most of the conversion to Excel is tested in pandas/tests/io/test_excel.py +""" + +import pytest + +from pandas.formats.format import CSSResolver, CSSWarning, CSSToExcelConverter + + +# Test parsing and normalising of CSS + + +def assert_resolves(css, props, inherited=None): + resolve = CSSResolver() + actual = resolve(css, inherited=inherited) + 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 + + +@pytest.mark.xfail +def test_css_parse_comments(): + pass # TODO + + +@pytest.mark.xfail +def test_css_parse_strings(): + pass # TODO + + +@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_direction_shorthands(shorthand, expansions): + top, right, bottom, left = expansions + + assert_resolves('%s: thin' % shorthand, + {top: 'thin', right: 'thin', + bottom: 'thin', left: 'thin'}) + + assert_resolves('%s: thin thick' % shorthand, + {top: 'thin', right: 'thick', + bottom: 'thin', left: 'thick'}) + + assert_resolves('%s: thin thick medium' % shorthand, + {top: 'thin', right: 'thick', + bottom: 'medium', left: 'thick'}) + + assert_resolves('%s: thin thick medium none' % shorthand, + {top: 'thin', right: 'thick', + bottom: 'medium', left: 'none'}) + + with pytest.warns(CSSWarning): + assert_resolves('%s: thin thick medium none medium' % shorthand, + {}) + + +@pytest.mark.xfail +@pytest.mark.parametrize('css,props', [ + ('font: italic bold 12pt helvetica,sans-serif', + {'font-family': 'helvetica,sans-serif', + 'font-style': 'italic', + 'font-weight': 'bold', + 'font-size': '12pt'}), + ('font: bold italic 12pt helvetica,sans-serif', + {'font-family': 'helvetica,sans-serif', + 'font-style': 'italic', + 'font-weight': 'bold', + 'font-size': '12pt'}), +]) +def test_css_font_shorthand(css, props): + assert_resolves(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 + + +def test_css_none_absent(): + pass # TODO + + +def test_css_font_size(): + pass # TODO + + +def test_css_font_size_invalid(): + pass # TODO + + +# Test translation of CSS to ExcelCell.style values + + +@pytest.mark.parametrize('css,expected', [ + # FONT + # - name + # - family + # - size + # - bold + ('font-weight: 100', {'font': {'bold': False}}), + ('font-weight: 200', {'font': {'bold': False}}), + ('font-weight: 300', {'font': {'bold': False}}), + ('font-weight: 400', {'font': {'bold': False}}), + ('font-weight: normal', {'font': {'bold': False}}), + ('font-weight: lighter', {'font': {'bold': False}}), + ('font-weight: bold', {'font': {'bold': True}}), + ('font-weight: bolder', {'font': {'bold': True}}), + ('font-weight: 700', {'font': {'bold': True}}), + ('font-weight: 800', {'font': {'bold': True}}), + ('font-weight: 900', {'font': {'bold': True}}), + # - italic + # - underline + ('text-decoration: underline', + {'font': {'underline': True, 'strike': False}}), + ('text-decoration: overline', + {'font': {'underline': False, 'strike': False}}), + ('text-decoration: none', + {'font': {'underline': False, 'strike': False}}), + # - strike + ('text-decoration: line-through', + {'font': {'strike': True, 'underline': False}}), + ('text-decoration: underline line-through', + {'font': {'strike': True, 'underline': True}}), + ('text-decoration: underline; text-decoration: line-through', + {'font': {'strike': True, 'underline': False}}), + # - color + # - shadow + # FILL + # - color, fillType + # BORDER + # - style + # - color + # ALIGNMENT + # - horizontal + # - vertical + # - wrap_text +]) +def test_css_to_excel(css, expected): + convert = CSSToExcelConverter() + assert expected == convert(css) diff --git a/pandas/tests/io/test_excel.py b/pandas/tests/io/test_excel.py index 256a37e922177..6735d69b47fac 100644 --- a/pandas/tests/io/test_excel.py +++ b/pandas/tests/io/test_excel.py @@ -27,6 +27,10 @@ import pandas.util.testing as tm +# FIXME: run all/some tests with plain Styler instead of DataFrame +# FIXME: run some tests with styled Styler + + def _skip_if_no_xlrd(): try: import xlrd From 7db59c03919f0ad61e76c04f412c8af3e593b9d5 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Wed, 5 Apr 2017 17:27:18 +1000 Subject: [PATCH 09/48] Test inherited styles in converter --- pandas/formats/format.py | 5 +++-- pandas/tests/formats/test_to_excel.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 34e59b2977319..c16f1ce4f49ac 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -1826,7 +1826,7 @@ def __call__(self, declarations_str, inherited=None): if val is None: # we do not define a complete initial stylesheet - del props[val] + del props[prop] else: props[prop] = val @@ -1984,7 +1984,8 @@ class CSSToExcelConverter(object): def __init__(self, inherited=None): if inherited is not None: - inherited = self.compute_css(inherited, sefl.INITIAL_STYLE) + inherited = self.compute_css(inherited, + self.compute_css.INITIAL_STYLE) self.inherited = inherited diff --git a/pandas/tests/formats/test_to_excel.py b/pandas/tests/formats/test_to_excel.py index de24e62f2d2a1..99a4ed6259ddd 100644 --- a/pandas/tests/formats/test_to_excel.py +++ b/pandas/tests/formats/test_to_excel.py @@ -176,3 +176,21 @@ def test_css_font_size_invalid(): def test_css_to_excel(css, expected): convert = CSSToExcelConverter() assert expected == convert(css) + + +@pytest.mark.parametrize('css,inherited,expected', [ + ('font-weight: bold', '', + {'font': {'bold': True}}), + ('', 'font-weight: bold', + {'font': {'bold': True}}), + ('font-weight: bold', 'font-style: italic', + {'font': {'bold': True, 'italic': True}}), + ('font-style: normal', 'font-style: italic', + {'font': {'italic': False}}), + ('font-style: inherit', '', {}), + ('font-style: normal; font-style: inherit', 'font-style: italic', + {'font': {'italic': True}}), +]) +def test_css_to_excel_inherited(css, inherited, expected): + convert = CSSToExcelConverter(inherited) + assert expected == convert(css) From dc953d4c11919a0e41d51bdcd6008202609884e6 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Thu, 6 Apr 2017 07:06:21 +1000 Subject: [PATCH 10/48] Font size and border width --- pandas/formats/format.py | 174 ++++++++++++----- pandas/tests/formats/test_to_excel.py | 261 +++++++++++++++++++++----- 2 files changed, 340 insertions(+), 95 deletions(-) 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}}), From f62f02d800e45da38a3cc3363723a5b445269e25 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Thu, 6 Apr 2017 08:20:05 +1000 Subject: [PATCH 11/48] File restructure --- pandas/formats/common.py | 40 ++ pandas/formats/css.py | 246 ++++++++ pandas/formats/excel.py | 585 ++++++++++++++++++ pandas/formats/format.py | 853 +------------------------- pandas/tests/formats/test_css.py | 233 +++++++ pandas/tests/formats/test_to_excel.py | 232 +------ 6 files changed, 1109 insertions(+), 1080 deletions(-) create mode 100644 pandas/formats/common.py create mode 100644 pandas/formats/css.py create mode 100644 pandas/formats/excel.py create mode 100644 pandas/tests/formats/test_css.py diff --git a/pandas/formats/common.py b/pandas/formats/common.py new file mode 100644 index 0000000000000..0218890fd4311 --- /dev/null +++ b/pandas/formats/common.py @@ -0,0 +1,40 @@ +def _get_level_lengths(levels, sentinel=''): + """For each index in each level the function returns lengths of indexes. + + Parameters + ---------- + levels : list of lists + List of values on for level. + sentinel : string, optional + Value which states that no new index starts on there. + + Returns + ---------- + Returns list of maps. For each level returns map of indexes (key is index + in row and value is length of index). + """ + if len(levels) == 0: + return [] + + control = [True for x in levels[0]] + + result = [] + for level in levels: + last_index = 0 + + lengths = {} + for i, key in enumerate(level): + if control[i] and key == sentinel: + pass + else: + control[i] = False + lengths[last_index] = i - last_index + last_index = i + + lengths[last_index] = len(level) - last_index + + result.append(lengths) + + return result + + diff --git a/pandas/formats/css.py b/pandas/formats/css.py new file mode 100644 index 0000000000000..40bc0156a1261 --- /dev/null +++ b/pandas/formats/css.py @@ -0,0 +1,246 @@ +"""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, val, em_pt=None, conversions=UNIT_RATIOS): + try: + val, unit = re.match('(.*?)([a-zA-Z%!].*)', val).groups() + except AttributeError: + warnings.warn('Unhandled font size: %r' % val, CSSWarning) + return + if val == '': + # hack for 'large' etc. + val = 1 + else: + try: + val = float(val) + except ValueError: + warnings.warn('Unhandled font size: %r' % val + unit, + CSSWarning) + + 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: + warnings.warn('Unknown size unit: %r' % unit, CSSWarning) + return self.size_to_pt('1!!default', conversions=conversions) + 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 not sep: + warnings.warn('Ill-formatted attribute: expected a colon ' + 'in %r' % decl, CSSWarning) + yield prop, val diff --git a/pandas/formats/excel.py b/pandas/formats/excel.py new file mode 100644 index 0000000000000..aaf392a6af2fd --- /dev/null +++ b/pandas/formats/excel.py @@ -0,0 +1,585 @@ +"""Utilities for conversion to writer-agnostic Excel representation +""" + +import re +import warnings +import itertools + +import numpy as np + +from pandas.compat import reduce +from pandas.formats.css import CSSResolver, CSSWarning +from pandas.formats.printing import pprint_thing +from pandas.types.common import (is_float) +import pandas._libs.lib as lib +from pandas.core.index import Index, MultiIndex +from pandas.tseries.period import PeriodIndex +from pandas.formats.common import _get_level_lengths + + +# from collections import namedtuple +# ExcelCell = namedtuple("ExcelCell", +# 'row, col, val, style, mergestart, mergeend') + + +class ExcelCell(object): + __fields__ = ('row', 'col', 'val', 'style', 'mergestart', 'mergeend') + __slots__ = __fields__ + + def __init__(self, row, col, val, style=None, mergestart=None, + mergeend=None): + self.row = row + self.col = col + self.val = val + self.style = style + self.mergestart = mergestart + self.mergeend = mergeend + +header_style = {"font": {"bold": True}, + "borders": {"top": "thin", + "right": "thin", + "bottom": "thin", + "left": "thin"}, + "alignment": {"horizontal": "center", + "vertical": "top"}} + + +class CSSToExcelConverter(object): + """A callable for converting CSS declarations to ExcelWriter styles + + Supports parts of CSS 2.2, with minimal CSS 3.0 support (e.g. text-shadow), + focusing on font styling, backgrounds, borders and alignment. + + Operates by first computing CSS styles in a fairly generic + way (see :meth:`compute_css`) then determining Excel style + properties from CSS properties (see :meth:`build_xlstyle`). + + Parameters + ---------- + inherited : str, optional + CSS declarations understood to be the containing scope for the + CSS processed by :meth:`__call__`. + """ + # NB: Most of the methods here could be classmethods, as only __init__ + # and __call__ make use of instance attributes. We leave them as + # instancemethods so that users can easily experiment with extensions + # without monkey-patching. + + def __init__(self, inherited=None): + if inherited is not None: + inherited = self.compute_css(inherited, + self.compute_css.INITIAL_STYLE) + + self.inherited = inherited + + compute_css = CSSResolver() + + def __call__(self, declarations_str): + """Convert CSS declarations to ExcelWriter style + + Parameters + ---------- + 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) + return self.build_xlstyle(properties) + + def build_xlstyle(self, props): + out = { + 'alignment': self.build_alignment(props), + 'border': self.build_border(props), + 'fill': self.build_fill(props), + 'font': self.build_font(props), + } + # TODO: handle cell width and height: needs support in pandas.io.excel + + def remove_none(d): + """Remove key where value is None, through nested dicts""" + for k, v in list(d.items()): + if v is None: + del d[k] + elif isinstance(v, dict): + remove_none(v) + if not v: + del d[k] + + remove_none(out) + return out + + VERTICAL_MAP = { + 'top': 'top', + 'text-top': 'top', + 'middle': 'center', + 'baseline': 'bottom', + 'bottom': 'bottom', + 'text-bottom': 'bottom', + # OpenXML also has 'justify', 'distributed' + } + + def build_alignment(self, props): + # TODO: text-indent, padding-left -> alignment.indent + return {'horizontal': props.get('text-align'), + 'vertical': self.VERTICAL_MAP.get(props.get('vertical-align')), + 'wrap_text': (props['white-space'] not in (None, 'nowrap') + if 'white-space' in props else None), + } + + def build_border(self, props): + print(props) + return {side: { + '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 + fill_color = props.get('background-color') + if fill_color not in (None, 'transparent', 'none'): + return { + 'fgColor': self.color_to_excel(fill_color), + 'patternType': 'solid', + } + + BOLD_MAP = {'bold': True, 'bolder': True, '600': True, '700': True, + '800': True, '900': True, + 'normal': False, 'lighter': False, '100': False, '200': False, + '300': False, '400': False, '500': False} + ITALIC_MAP = {'normal': False, 'italic': True, 'oblique': True} + + def build_font(self, props): + size = props.get('font-size') + if size is not None: + assert size.endswith('pt') + size = float(size[:-2]) + + 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': + family = 1 # roman + break + elif name == 'sans-serif': + family = 2 # swiss + break + elif name == 'cursive': + family = 4 # script + break + elif name == 'fantasy': + family = 5 # decorative + break + + decoration = props.get('text-decoration') + if decoration is not None: + decoration = decoration.split() + + return { + 'name': font_names[0] if font_names else None, + 'family': family, + 'size': size, + 'bold': self.BOLD_MAP.get(props.get('font-weight')), + 'italic': self.ITALIC_MAP.get(props.get('font-style')), + 'underline': (None if decoration is None + else 'underline' in decoration), + 'strike': (None if decoration is None + else 'line-through' in decoration), + 'color': self.color_to_excel(props.get('font-color')), + # shadow if nonzero digit before shadow colour + 'shadow': (bool(re.search('^[^#(]*[1-9]', + props['text-shadow'])) + if 'text-shadow' in props else None), + # 'vertAlign':, + # 'charset': , + # 'scheme': , + # 'outline': , + # 'condense': , + } + + NAMED_COLORS = { + 'maroon': '800000', + 'red': 'FF0000', + 'orange': 'FFA500', + 'yellow': 'FFFF00', + 'olive': '808000', + 'green': '008000', + 'purple': '800080', + 'fuchsia': 'FF00FF', + 'lime': '00FF00', + 'teal': '008080', + 'aqua': '00FFFF', + 'blue': '0000FF', + 'navy': '000080', + 'black': '000000', + 'gray': '808080', + 'silver': 'C0C0C0', + 'white': 'FFFFFF', + } + + def color_to_excel(self, val): + if val is None: + return None + if val.startswith('#') and len(val) == 7: + return val[1:].upper() + if val.startswith('#') and len(val) == 4: + return (val[1] * 2 + val[2] * 2 + val[3] * 2).upper() + try: + return self.NAMED_COLORS[val] + except KeyError: + warnings.warn('Unhandled colour format: %r' % val, CSSWarning) + + +class ExcelFormatter(object): + """ + Class for formatting a DataFrame to a list of ExcelCells, + + Parameters + ---------- + df : DataFrame or Styler + na_rep: na representation + float_format : string, default None + Format string for floating point numbers + cols : sequence, optional + Columns to write + header : boolean or list of string, default True + Write out column names. If a list of string is given it is + assumed to be aliases for the column names + index : boolean, default True + output row names (index) + index_label : string or sequence, default None + Column label for index column(s) if desired. If None is given, and + `header` and `index` are True, then the index names are used. A + sequence should be given if the DataFrame uses MultiIndex. + merge_cells : boolean, default False + Format MultiIndex and Hierarchical Rows as merged cells. + inf_rep : string, default `'inf'` + representation for np.inf values (which aren't representable in Excel) + A `'-'` sign will be added in front of -inf. + style_converter : callable, optional + This translates Styler styles (CSS) into ExcelWriter styles. + Defaults to ``CSSToExcelConverter()``. + It should have signature css_declarations string -> excel style. + This is only called for body cells. + """ + + def __init__(self, df, na_rep='', float_format=None, cols=None, + header=True, index=True, index_label=None, merge_cells=False, + inf_rep='inf', style_converter=None): + self.rowcounter = 0 + self.na_rep = na_rep + if hasattr(df, 'render'): + self.styler = df + df = df.data + if style_converter is None: + style_converter = CSSToExcelConverter() + self.style_converter = style_converter + else: + self.styler = None + self.df = df + if cols is not None: + self.df = df.loc[:, cols] + self.columns = self.df.columns + self.float_format = float_format + self.index = index + self.index_label = index_label + self.header = header + self.merge_cells = merge_cells + self.inf_rep = inf_rep + + def _format_value(self, val): + if lib.checknull(val): + val = self.na_rep + elif is_float(val): + if lib.isposinf_scalar(val): + val = self.inf_rep + elif lib.isneginf_scalar(val): + val = '-%s' % self.inf_rep + elif self.float_format is not None: + val = float(self.float_format % val) + return val + + def _format_header_mi(self): + if self.columns.nlevels > 1: + if not self.index: + raise NotImplementedError("Writing to Excel with MultiIndex" + " columns and no index " + "('index'=False) is not yet " + "implemented.") + + has_aliases = isinstance(self.header, (tuple, list, np.ndarray, Index)) + if not (has_aliases or self.header): + return + + columns = self.columns + level_strs = columns.format(sparsify=self.merge_cells, adjoin=False, + names=False) + level_lengths = _get_level_lengths(level_strs) + coloffset = 0 + lnum = 0 + + if self.index and isinstance(self.df.index, MultiIndex): + coloffset = len(self.df.index[0]) - 1 + + if self.merge_cells: + # Format multi-index as a merged cells. + for lnum in range(len(level_lengths)): + name = columns.names[lnum] + yield ExcelCell(lnum, coloffset, name, header_style) + + for lnum, (spans, levels, labels) in enumerate(zip( + level_lengths, columns.levels, columns.labels)): + values = levels.take(labels) + for i in spans: + if spans[i] > 1: + yield ExcelCell(lnum, coloffset + i + 1, values[i], + header_style, lnum, + coloffset + i + spans[i]) + else: + yield ExcelCell(lnum, coloffset + i + 1, values[i], + header_style) + else: + # Format in legacy format with dots to indicate levels. + for i, values in enumerate(zip(*level_strs)): + v = ".".join(map(pprint_thing, values)) + yield ExcelCell(lnum, coloffset + i + 1, v, header_style) + + self.rowcounter = lnum + + def _format_header_regular(self): + has_aliases = isinstance(self.header, (tuple, list, np.ndarray, Index)) + if has_aliases or self.header: + coloffset = 0 + + if self.index: + coloffset = 1 + if isinstance(self.df.index, MultiIndex): + coloffset = len(self.df.index[0]) + + colnames = self.columns + if has_aliases: + if len(self.header) != len(self.columns): + raise ValueError('Writing %d cols but got %d aliases' % + (len(self.columns), len(self.header))) + else: + colnames = self.header + + for colindex, colname in enumerate(colnames): + yield ExcelCell(self.rowcounter, colindex + coloffset, colname, + header_style) + + def _format_header(self): + if isinstance(self.columns, MultiIndex): + gen = self._format_header_mi() + else: + gen = self._format_header_regular() + + gen2 = () + if self.df.index.names: + row = [x if x is not None else '' + for x in self.df.index.names] + [''] * len(self.columns) + if reduce(lambda x, y: x and y, map(lambda x: x != '', row)): + gen2 = (ExcelCell(self.rowcounter, colindex, val, header_style) + for colindex, val in enumerate(row)) + self.rowcounter += 1 + return itertools.chain(gen, gen2) + + def _format_body(self): + + if isinstance(self.df.index, MultiIndex): + return self._format_hierarchical_rows() + else: + return self._format_regular_rows() + + def _format_regular_rows(self): + has_aliases = isinstance(self.header, (tuple, list, np.ndarray, Index)) + if has_aliases or self.header: + self.rowcounter += 1 + + # output index and index_label? + if self.index: + # chek aliases + # if list only take first as this is not a MultiIndex + if (self.index_label and + isinstance(self.index_label, (list, tuple, np.ndarray, + Index))): + index_label = self.index_label[0] + # if string good to go + elif self.index_label and isinstance(self.index_label, str): + index_label = self.index_label + else: + index_label = self.df.index.names[0] + + if isinstance(self.columns, MultiIndex): + self.rowcounter += 1 + + if index_label and self.header is not False: + yield ExcelCell(self.rowcounter - 1, 0, index_label, + header_style) + + # write index_values + index_values = self.df.index + if isinstance(self.df.index, PeriodIndex): + index_values = self.df.index.to_timestamp() + + for idx, idxval in enumerate(index_values): + yield ExcelCell(self.rowcounter + idx, 0, idxval, header_style) + + coloffset = 1 + else: + coloffset = 0 + + for cell in self._generate_body(coloffset): + yield cell + + def _format_hierarchical_rows(self): + has_aliases = isinstance(self.header, (tuple, list, np.ndarray, Index)) + if has_aliases or self.header: + self.rowcounter += 1 + + gcolidx = 0 + + if self.index: + index_labels = self.df.index.names + # check for aliases + if (self.index_label and + isinstance(self.index_label, (list, tuple, np.ndarray, + Index))): + index_labels = self.index_label + + # MultiIndex columns require an extra row + # with index names (blank if None) for + # unambigous round-trip, unless not merging, + # in which case the names all go on one row Issue #11328 + if isinstance(self.columns, MultiIndex) and self.merge_cells: + self.rowcounter += 1 + + # if index labels are not empty go ahead and dump + if (any(x is not None for x in index_labels) and + self.header is not False): + + for cidx, name in enumerate(index_labels): + yield ExcelCell(self.rowcounter - 1, cidx, name, + header_style) + + if self.merge_cells: + # Format hierarchical rows as merged cells. + level_strs = self.df.index.format(sparsify=True, adjoin=False, + names=False) + level_lengths = _get_level_lengths(level_strs) + + for spans, levels, labels in zip(level_lengths, + self.df.index.levels, + self.df.index.labels): + + values = levels.take(labels, + allow_fill=levels._can_hold_na, + fill_value=True) + + for i in spans: + if spans[i] > 1: + yield ExcelCell(self.rowcounter + i, gcolidx, + values[i], header_style, + self.rowcounter + i + spans[i] - 1, + gcolidx) + else: + yield ExcelCell(self.rowcounter + i, gcolidx, + values[i], header_style) + gcolidx += 1 + + else: + # Format hierarchical rows with non-merged values. + for indexcolvals in zip(*self.df.index): + for idx, indexcolval in enumerate(indexcolvals): + yield ExcelCell(self.rowcounter + idx, gcolidx, + indexcolval, header_style) + gcolidx += 1 + + for cell in self._generate_body(gcolidx): + yield cell + + def _generate_body(self, coloffset): + if self.styler is None: + styles = None + else: + styles = self.styler._compute().ctx + if not styles: + styles = None + xlstyle = None + + # Write the body of the frame data series by series. + for colidx in range(len(self.columns)): + series = self.df.iloc[:, colidx] + for i, val in enumerate(series): + if styles is not None: + xlstyle = self.style_converter(';'.join(styles[i, colidx])) + yield ExcelCell(self.rowcounter + i, colidx + coloffset, val, + xlstyle) + + def get_formatted_cells(self): + for cell in itertools.chain(self._format_header(), + self._format_body()): + cell.val = self._format_value(cell.val) + yield cell diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 604225fa44d1c..4fcac45468434 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -9,8 +9,6 @@ # pylint: disable=W0141 import sys -import re -import warnings from pandas.types.missing import isnull, notnull from pandas.types.common import (is_categorical_dtype, @@ -28,12 +26,14 @@ from pandas.core.base import PandasObject from pandas.core.index import Index, MultiIndex, _ensure_index from pandas import compat -from pandas.compat import (StringIO, lzip, range, map, zip, reduce, u, +from pandas.compat import (StringIO, lzip, range, map, zip, u, OrderedDict, unichr) from pandas.util.terminal import get_terminal_size from pandas.core.config import get_option, set_option from pandas.io.common import _get_handle, UnicodeWriter, _expand_user from pandas.formats.printing import adjoin, justify, pprint_thing +from pandas.formats.excel import ExcelFormatter # for downstream # NOQA +from pandas.formats.common import _get_level_lengths import pandas.core.common as com import pandas._libs.lib as lib from pandas._libs.tslib import (iNaT, Timestamp, Timedelta, @@ -1438,46 +1438,6 @@ def _write_hierarchical_rows(self, fmt_values, indent): nindex_levels=frame.index.nlevels) -def _get_level_lengths(levels, sentinel=''): - """For each index in each level the function returns lengths of indexes. - - Parameters - ---------- - levels : list of lists - List of values on for level. - sentinel : string, optional - Value which states that no new index starts on there. - - Returns - ---------- - Returns list of maps. For each level returns map of indexes (key is index - in row and value is length of index). - """ - if len(levels) == 0: - return [] - - control = [True for x in levels[0]] - - result = [] - for level in levels: - last_index = 0 - - lengths = {} - for i, key in enumerate(level): - if control[i] and key == sentinel: - pass - else: - control[i] = False - lengths[last_index] = i - last_index - last_index = i - - lengths[last_index] = len(level) - last_index - - result.append(lengths) - - return result - - class CSVFormatter(object): def __init__(self, obj, path_or_buf=None, sep=",", na_rep='', @@ -1730,813 +1690,6 @@ def _save_chunk(self, start_i, end_i): lib.write_csv_rows(self.data, ix, self.nlevels, self.cols, self.writer) -# from collections import namedtuple -# ExcelCell = namedtuple("ExcelCell", -# 'row, col, val, style, mergestart, mergeend') - - -class ExcelCell(object): - __fields__ = ('row', 'col', 'val', 'style', 'mergestart', 'mergeend') - __slots__ = __fields__ - - def __init__(self, row, col, val, style=None, mergestart=None, - mergeend=None): - self.row = row - self.col = col - self.val = val - self.style = style - self.mergestart = mergestart - self.mergeend = mergeend - - -header_style = {"font": {"bold": True}, - "borders": {"top": "thin", - "right": "thin", - "bottom": "thin", - "left": "thin"}, - "alignment": {"horizontal": "center", - "vertical": "top"}} - - -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, val, em_pt=None, conversions=UNIT_RATIOS): - try: - val, unit = re.match('(.*?)([a-zA-Z%!].*)', val).groups() - except AttributeError: - warnings.warn('Unhandled font size: %r' % val, CSSWarning) - return - if val == '': - # hack for 'large' etc. - val = 1 - else: - try: - val = float(val) - except ValueError: - warnings.warn('Unhandled font size: %r' % val + unit, - CSSWarning) - - 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: - warnings.warn('Unknown size unit: %r' % unit, CSSWarning) - return self.size_to_pt('1!!default', conversions=conversions) - 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 not sep: - warnings.warn('Ill-formatted attribute: expected a colon ' - 'in %r' % decl, CSSWarning) - yield prop, val - - -class CSSToExcelConverter(object): - """A callable for converting CSS declarations to ExcelWriter styles - - Supports parts of CSS 2.2, with minimal CSS 3.0 support (e.g. text-shadow), - focusing on font styling, backgrounds, borders and alignment. - - Operates by first computing CSS styles in a fairly generic - way (see :meth:`compute_css`) then determining Excel style - properties from CSS properties (see :meth:`build_xlstyle`). - - Parameters - ---------- - inherited : str, optional - CSS declarations understood to be the containing scope for the - CSS processed by :meth:`__call__`. - """ - # NB: Most of the methods here could be classmethods, as only __init__ - # and __call__ make use of instance attributes. We leave them as - # instancemethods so that users can easily experiment with extensions - # without monkey-patching. - - def __init__(self, inherited=None): - if inherited is not None: - inherited = self.compute_css(inherited, - self.compute_css.INITIAL_STYLE) - - self.inherited = inherited - - compute_css = CSSResolver() - - def __call__(self, declarations_str): - """Convert CSS declarations to ExcelWriter style - - Parameters - ---------- - 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) - return self.build_xlstyle(properties) - - def build_xlstyle(self, props): - out = { - 'alignment': self.build_alignment(props), - 'border': self.build_border(props), - 'fill': self.build_fill(props), - 'font': self.build_font(props), - } - # TODO: handle cell width and height: needs support in pandas.io.excel - - def remove_none(d): - """Remove key where value is None, through nested dicts""" - for k, v in list(d.items()): - if v is None: - del d[k] - elif isinstance(v, dict): - remove_none(v) - if not v: - del d[k] - - remove_none(out) - return out - - VERTICAL_MAP = { - 'top': 'top', - 'text-top': 'top', - 'middle': 'center', - 'baseline': 'bottom', - 'bottom': 'bottom', - 'text-bottom': 'bottom', - # OpenXML also has 'justify', 'distributed' - } - - def build_alignment(self, props): - # TODO: text-indent, padding-left -> alignment.indent - return {'horizontal': props.get('text-align'), - 'vertical': self.VERTICAL_MAP.get(props.get('vertical-align')), - 'wrap_text': (props['white-space'] not in (None, 'nowrap') - if 'white-space' in props else None), - } - - def build_border(self, props): - print(props) - return {side: { - '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 - fill_color = props.get('background-color') - if fill_color not in (None, 'transparent', 'none'): - return { - 'fgColor': self.color_to_excel(fill_color), - 'patternType': 'solid', - } - - BOLD_MAP = {'bold': True, 'bolder': True, '600': True, '700': True, - '800': True, '900': True, - 'normal': False, 'lighter': False, '100': False, '200': False, - '300': False, '400': False, '500': False} - ITALIC_MAP = {'normal': False, 'italic': True, 'oblique': True} - - def build_font(self, props): - size = props.get('font-size') - if size is not None: - assert size.endswith('pt') - size = float(size[:-2]) - - 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': - family = 1 # roman - break - elif name == 'sans-serif': - family = 2 # swiss - break - elif name == 'cursive': - family = 4 # script - break - elif name == 'fantasy': - family = 5 # decorative - break - - decoration = props.get('text-decoration') - if decoration is not None: - decoration = decoration.split() - - return { - 'name': font_names[0] if font_names else None, - 'family': family, - 'size': size, - 'bold': self.BOLD_MAP.get(props.get('font-weight')), - 'italic': self.ITALIC_MAP.get(props.get('font-style')), - 'underline': (None if decoration is None - else 'underline' in decoration), - 'strike': (None if decoration is None - else 'line-through' in decoration), - 'color': self.color_to_excel(props.get('font-color')), - # shadow if nonzero digit before shadow colour - 'shadow': (bool(re.search('^[^#(]*[1-9]', - props['text-shadow'])) - if 'text-shadow' in props else None), - # 'vertAlign':, - # 'charset': , - # 'scheme': , - # 'outline': , - # 'condense': , - } - - NAMED_COLORS = { - 'maroon': '800000', - 'red': 'FF0000', - 'orange': 'FFA500', - 'yellow': 'FFFF00', - 'olive': '808000', - 'green': '008000', - 'purple': '800080', - 'fuchsia': 'FF00FF', - 'lime': '00FF00', - 'teal': '008080', - 'aqua': '00FFFF', - 'blue': '0000FF', - 'navy': '000080', - 'black': '000000', - 'gray': '808080', - 'silver': 'C0C0C0', - 'white': 'FFFFFF', - } - - def color_to_excel(self, val): - if val is None: - return None - if val.startswith('#') and len(val) == 7: - return val[1:].upper() - if val.startswith('#') and len(val) == 4: - return (val[1] * 2 + val[2] * 2 + val[3] * 2).upper() - try: - return self.NAMED_COLORS[val] - except KeyError: - warnings.warn('Unhandled colour format: %r' % val, CSSWarning) - - -class ExcelFormatter(object): - """ - Class for formatting a DataFrame to a list of ExcelCells, - - Parameters - ---------- - df : DataFrame or Styler - na_rep: na representation - float_format : string, default None - Format string for floating point numbers - cols : sequence, optional - Columns to write - header : boolean or list of string, default True - Write out column names. If a list of string is given it is - assumed to be aliases for the column names - index : boolean, default True - output row names (index) - index_label : string or sequence, default None - Column label for index column(s) if desired. If None is given, and - `header` and `index` are True, then the index names are used. A - sequence should be given if the DataFrame uses MultiIndex. - merge_cells : boolean, default False - Format MultiIndex and Hierarchical Rows as merged cells. - inf_rep : string, default `'inf'` - representation for np.inf values (which aren't representable in Excel) - A `'-'` sign will be added in front of -inf. - style_converter : callable, optional - This translates Styler styles (CSS) into ExcelWriter styles. - Defaults to ``CSSToExcelConverter()``. - It should have signature css_declarations string -> excel style. - This is only called for body cells. - """ - - def __init__(self, df, na_rep='', float_format=None, cols=None, - header=True, index=True, index_label=None, merge_cells=False, - inf_rep='inf', style_converter=None): - self.rowcounter = 0 - self.na_rep = na_rep - if hasattr(df, 'render'): - self.styler = df - df = df.data - if style_converter is None: - style_converter = CSSToExcelConverter() - self.style_converter = style_converter - else: - self.styler = None - self.df = df - if cols is not None: - self.df = df.loc[:, cols] - self.columns = self.df.columns - self.float_format = float_format - self.index = index - self.index_label = index_label - self.header = header - self.merge_cells = merge_cells - self.inf_rep = inf_rep - - def _format_value(self, val): - if lib.checknull(val): - val = self.na_rep - elif is_float(val): - if lib.isposinf_scalar(val): - val = self.inf_rep - elif lib.isneginf_scalar(val): - val = '-%s' % self.inf_rep - elif self.float_format is not None: - val = float(self.float_format % val) - return val - - def _format_header_mi(self): - if self.columns.nlevels > 1: - if not self.index: - raise NotImplementedError("Writing to Excel with MultiIndex" - " columns and no index " - "('index'=False) is not yet " - "implemented.") - - has_aliases = isinstance(self.header, (tuple, list, np.ndarray, Index)) - if not (has_aliases or self.header): - return - - columns = self.columns - level_strs = columns.format(sparsify=self.merge_cells, adjoin=False, - names=False) - level_lengths = _get_level_lengths(level_strs) - coloffset = 0 - lnum = 0 - - if self.index and isinstance(self.df.index, MultiIndex): - coloffset = len(self.df.index[0]) - 1 - - if self.merge_cells: - # Format multi-index as a merged cells. - for lnum in range(len(level_lengths)): - name = columns.names[lnum] - yield ExcelCell(lnum, coloffset, name, header_style) - - for lnum, (spans, levels, labels) in enumerate(zip( - level_lengths, columns.levels, columns.labels)): - values = levels.take(labels) - for i in spans: - if spans[i] > 1: - yield ExcelCell(lnum, coloffset + i + 1, values[i], - header_style, lnum, - coloffset + i + spans[i]) - else: - yield ExcelCell(lnum, coloffset + i + 1, values[i], - header_style) - else: - # Format in legacy format with dots to indicate levels. - for i, values in enumerate(zip(*level_strs)): - v = ".".join(map(pprint_thing, values)) - yield ExcelCell(lnum, coloffset + i + 1, v, header_style) - - self.rowcounter = lnum - - def _format_header_regular(self): - has_aliases = isinstance(self.header, (tuple, list, np.ndarray, Index)) - if has_aliases or self.header: - coloffset = 0 - - if self.index: - coloffset = 1 - if isinstance(self.df.index, MultiIndex): - coloffset = len(self.df.index[0]) - - colnames = self.columns - if has_aliases: - if len(self.header) != len(self.columns): - raise ValueError('Writing %d cols but got %d aliases' % - (len(self.columns), len(self.header))) - else: - colnames = self.header - - for colindex, colname in enumerate(colnames): - yield ExcelCell(self.rowcounter, colindex + coloffset, colname, - header_style) - - def _format_header(self): - if isinstance(self.columns, MultiIndex): - gen = self._format_header_mi() - else: - gen = self._format_header_regular() - - gen2 = () - if self.df.index.names: - row = [x if x is not None else '' - for x in self.df.index.names] + [''] * len(self.columns) - if reduce(lambda x, y: x and y, map(lambda x: x != '', row)): - gen2 = (ExcelCell(self.rowcounter, colindex, val, header_style) - for colindex, val in enumerate(row)) - self.rowcounter += 1 - return itertools.chain(gen, gen2) - - def _format_body(self): - - if isinstance(self.df.index, MultiIndex): - return self._format_hierarchical_rows() - else: - return self._format_regular_rows() - - def _format_regular_rows(self): - has_aliases = isinstance(self.header, (tuple, list, np.ndarray, Index)) - if has_aliases or self.header: - self.rowcounter += 1 - - # output index and index_label? - if self.index: - # chek aliases - # if list only take first as this is not a MultiIndex - if (self.index_label and - isinstance(self.index_label, (list, tuple, np.ndarray, - Index))): - index_label = self.index_label[0] - # if string good to go - elif self.index_label and isinstance(self.index_label, str): - index_label = self.index_label - else: - index_label = self.df.index.names[0] - - if isinstance(self.columns, MultiIndex): - self.rowcounter += 1 - - if index_label and self.header is not False: - yield ExcelCell(self.rowcounter - 1, 0, index_label, - header_style) - - # write index_values - index_values = self.df.index - if isinstance(self.df.index, PeriodIndex): - index_values = self.df.index.to_timestamp() - - for idx, idxval in enumerate(index_values): - yield ExcelCell(self.rowcounter + idx, 0, idxval, header_style) - - coloffset = 1 - else: - coloffset = 0 - - for cell in self._generate_body(coloffset): - yield cell - - def _format_hierarchical_rows(self): - has_aliases = isinstance(self.header, (tuple, list, np.ndarray, Index)) - if has_aliases or self.header: - self.rowcounter += 1 - - gcolidx = 0 - - if self.index: - index_labels = self.df.index.names - # check for aliases - if (self.index_label and - isinstance(self.index_label, (list, tuple, np.ndarray, - Index))): - index_labels = self.index_label - - # MultiIndex columns require an extra row - # with index names (blank if None) for - # unambigous round-trip, unless not merging, - # in which case the names all go on one row Issue #11328 - if isinstance(self.columns, MultiIndex) and self.merge_cells: - self.rowcounter += 1 - - # if index labels are not empty go ahead and dump - if (any(x is not None for x in index_labels) and - self.header is not False): - - for cidx, name in enumerate(index_labels): - yield ExcelCell(self.rowcounter - 1, cidx, name, - header_style) - - if self.merge_cells: - # Format hierarchical rows as merged cells. - level_strs = self.df.index.format(sparsify=True, adjoin=False, - names=False) - level_lengths = _get_level_lengths(level_strs) - - for spans, levels, labels in zip(level_lengths, - self.df.index.levels, - self.df.index.labels): - - values = levels.take(labels, - allow_fill=levels._can_hold_na, - fill_value=True) - - for i in spans: - if spans[i] > 1: - yield ExcelCell(self.rowcounter + i, gcolidx, - values[i], header_style, - self.rowcounter + i + spans[i] - 1, - gcolidx) - else: - yield ExcelCell(self.rowcounter + i, gcolidx, - values[i], header_style) - gcolidx += 1 - - else: - # Format hierarchical rows with non-merged values. - for indexcolvals in zip(*self.df.index): - for idx, indexcolval in enumerate(indexcolvals): - yield ExcelCell(self.rowcounter + idx, gcolidx, - indexcolval, header_style) - gcolidx += 1 - - for cell in self._generate_body(gcolidx): - yield cell - - def _generate_body(self, coloffset): - if self.styler is None: - styles = None - else: - styles = self.styler._compute().ctx - if not styles: - styles = None - xlstyle = None - - # Write the body of the frame data series by series. - for colidx in range(len(self.columns)): - series = self.df.iloc[:, colidx] - for i, val in enumerate(series): - if styles is not None: - xlstyle = self.style_converter(';'.join(styles[i, colidx])) - yield ExcelCell(self.rowcounter + i, colidx + coloffset, val, - xlstyle) - - def get_formatted_cells(self): - for cell in itertools.chain(self._format_header(), - self._format_body()): - cell.val = self._format_value(cell.val) - yield cell # ---------------------------------------------------------------------- # Array formatters diff --git a/pandas/tests/formats/test_css.py b/pandas/tests/formats/test_css.py new file mode 100644 index 0000000000000..4bb974089df88 --- /dev/null +++ b/pandas/tests/formats/test_css.py @@ -0,0 +1,233 @@ +import pytest + +from pandas.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.xfail +def test_css_parse_comments(): + assert_same_resolution('hello: world', + 'hello/* foo */:/* bar \n */ world /*;not:here*/') + + +@pytest.mark.xfail +def test_css_parse_specificity(): + # we don't need to handle specificity markers like !important, but we should ignore them + pass + + +@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 + + +@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 pytest.warns(CSSWarning): + assert_resolves('%s: 1pt 1pt 1pt 1pt 1pt' % shorthand, + {}) + + +@pytest.mark.xfail +@pytest.mark.parametrize('css,props', [ + ('font: italic bold 12pt helvetica,sans-serif', + {'font-family': 'helvetica,sans-serif', + 'font-style': 'italic', + 'font-weight': 'bold', + 'font-size': '12pt'}), + ('font: bold italic 12pt helvetica,sans-serif', + {'font-family': 'helvetica,sans-serif', + 'font-style': 'italic', + 'font-weight': 'bold', + 'font-size': '12pt'}), +]) +def test_css_font_shorthand(css, props): + assert_resolves(css, props) + + +@pytest.mark.xfail +@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) + + +@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 + + +@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 + + diff --git a/pandas/tests/formats/test_to_excel.py b/pandas/tests/formats/test_to_excel.py index e3e0386d48189..e01d3c427b0f6 100644 --- a/pandas/tests/formats/test_to_excel.py +++ b/pandas/tests/formats/test_to_excel.py @@ -1,239 +1,11 @@ """Tests formatting as writer-agnostic ExcelCells -Most of the conversion to Excel is tested in pandas/tests/io/test_excel.py +ExcelFormatter is tested implicitly in pandas/tests/io/test_excel.py """ import pytest -from pandas.formats.format import CSSResolver, CSSWarning, CSSToExcelConverter - - -# Tests for CSSResolver - - -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.xfail -def test_css_parse_comments(): - 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 - - -@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 pytest.warns(CSSWarning): - assert_resolves('%s: 1pt 1pt 1pt 1pt 1pt' % shorthand, - {}) - - -@pytest.mark.xfail -@pytest.mark.parametrize('css,props', [ - ('font: italic bold 12pt helvetica,sans-serif', - {'font-family': 'helvetica,sans-serif', - 'font-style': 'italic', - 'font-weight': 'bold', - 'font-size': '12pt'}), - ('font: bold italic 12pt helvetica,sans-serif', - {'font-family': 'helvetica,sans-serif', - 'font-style': 'italic', - 'font-weight': 'bold', - 'font-size': '12pt'}), -]) -def test_css_font_shorthand(css, props): - assert_resolves(css, props) - - -@pytest.mark.xfail -@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) - - -@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 - - -@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 - - -# Tests for CSSToExcelConverter +from pandas.formats.excel import CSSToExcelConverter @pytest.mark.parametrize('css,expected', [ From 3b26087b3022259c74063f99b075993166b1f5ee Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Thu, 6 Apr 2017 10:28:50 +1000 Subject: [PATCH 12/48] Make get_level_lengths non-private --- pandas/formats/common.py | 2 +- pandas/formats/excel.py | 4 ++-- pandas/formats/format.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pandas/formats/common.py b/pandas/formats/common.py index 0218890fd4311..5779baed277ce 100644 --- a/pandas/formats/common.py +++ b/pandas/formats/common.py @@ -1,4 +1,4 @@ -def _get_level_lengths(levels, sentinel=''): +def get_level_lengths(levels, sentinel=''): """For each index in each level the function returns lengths of indexes. Parameters diff --git a/pandas/formats/excel.py b/pandas/formats/excel.py index aaf392a6af2fd..a3f75b33ce588 100644 --- a/pandas/formats/excel.py +++ b/pandas/formats/excel.py @@ -14,7 +14,7 @@ import pandas._libs.lib as lib from pandas.core.index import Index, MultiIndex from pandas.tseries.period import PeriodIndex -from pandas.formats.common import _get_level_lengths +from pandas.formats.common import get_level_lengths # from collections import namedtuple @@ -376,7 +376,7 @@ def _format_header_mi(self): columns = self.columns level_strs = columns.format(sparsify=self.merge_cells, adjoin=False, names=False) - level_lengths = _get_level_lengths(level_strs) + level_lengths = get_level_lengths(level_strs) coloffset = 0 lnum = 0 diff --git a/pandas/formats/format.py b/pandas/formats/format.py index 4fcac45468434..ef8c6b50b9b4e 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -33,7 +33,7 @@ from pandas.io.common import _get_handle, UnicodeWriter, _expand_user from pandas.formats.printing import adjoin, justify, pprint_thing from pandas.formats.excel import ExcelFormatter # for downstream # NOQA -from pandas.formats.common import _get_level_lengths +from pandas.formats.common import get_level_lengths import pandas.core.common as com import pandas._libs.lib as lib from pandas._libs.tslib import (iNaT, Timestamp, Timedelta, @@ -1189,7 +1189,7 @@ def _column_header(): sentinel = None levels = self.columns.format(sparsify=sentinel, adjoin=False, names=False) - level_lengths = _get_level_lengths(levels, sentinel) + level_lengths = get_level_lengths(levels, sentinel) inner_lvl = len(level_lengths) - 1 for lnum, (records, values) in enumerate(zip(level_lengths, levels)): From eb02cc1c93619477e976bb787026974d55518b5d Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Thu, 6 Apr 2017 10:29:46 +1000 Subject: [PATCH 13/48] Fix testing ImportError --- pandas/tests/io/test_excel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/tests/io/test_excel.py b/pandas/tests/io/test_excel.py index 6735d69b47fac..7c07f881a29f5 100644 --- a/pandas/tests/io/test_excel.py +++ b/pandas/tests/io/test_excel.py @@ -2009,7 +2009,7 @@ def test_to_excel_styleconverter(self): self.assertEqual(kw['protection'], protection) def test_write_cells_merge_styled(self): - from pandas.formats.format import ExcelCell + from pandas.formats.excel import ExcelCell from openpyxl import styles sheet_name = 'merge_styled' From 1984cab2790afa5b75dd6683bfbab04b0c29e754 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Thu, 6 Apr 2017 10:52:38 +1000 Subject: [PATCH 14/48] Fix making get_level_lengths non-private --- pandas/formats/excel.py | 2 +- pandas/formats/format.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/formats/excel.py b/pandas/formats/excel.py index a3f75b33ce588..740165b336879 100644 --- a/pandas/formats/excel.py +++ b/pandas/formats/excel.py @@ -528,7 +528,7 @@ def _format_hierarchical_rows(self): # Format hierarchical rows as merged cells. level_strs = self.df.index.format(sparsify=True, adjoin=False, names=False) - level_lengths = _get_level_lengths(level_strs) + level_lengths = get_level_lengths(level_strs) for spans, levels, labels in zip(level_lengths, self.df.index.levels, diff --git a/pandas/formats/format.py b/pandas/formats/format.py index ef8c6b50b9b4e..b8d8675f8b386 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -1355,7 +1355,7 @@ def _write_hierarchical_rows(self, fmt_values, indent): levels = frame.index.format(sparsify=sentinel, adjoin=False, names=False) - level_lengths = _get_level_lengths(levels, sentinel) + level_lengths = get_level_lengths(levels, sentinel) inner_lvl = len(level_lengths) - 1 if truncate_v: # Insert ... row and adjust idx_values and From 9a5b791e3205caf490328a28471bff46ab26666a Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Thu, 6 Apr 2017 11:09:32 +1000 Subject: [PATCH 15/48] Fix testing ImportError --- pandas/tests/io/test_excel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/tests/io/test_excel.py b/pandas/tests/io/test_excel.py index 7c07f881a29f5..caa4289011d2a 100644 --- a/pandas/tests/io/test_excel.py +++ b/pandas/tests/io/test_excel.py @@ -2122,7 +2122,7 @@ def test_write_cells_merge_styled(self): if not openpyxl_compat.is_compat(major_ver=2): pytest.skip('incompatible openpyxl version') - from pandas.formats.format import ExcelCell + from pandas.formats.excel import ExcelCell sheet_name = 'merge_styled' From 1a8818f81db717b7c1ba3e8307ca890fc9e4b883 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Thu, 6 Apr 2017 11:30:03 +1000 Subject: [PATCH 16/48] Lint --- pandas/formats/common.py | 2 -- pandas/formats/excel.py | 1 + pandas/tests/formats/test_css.py | 5 ++--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pandas/formats/common.py b/pandas/formats/common.py index 5779baed277ce..82b64e5207212 100644 --- a/pandas/formats/common.py +++ b/pandas/formats/common.py @@ -36,5 +36,3 @@ def get_level_lengths(levels, sentinel=''): result.append(lengths) return result - - diff --git a/pandas/formats/excel.py b/pandas/formats/excel.py index 740165b336879..ca742c0add035 100644 --- a/pandas/formats/excel.py +++ b/pandas/formats/excel.py @@ -35,6 +35,7 @@ def __init__(self, row, col, val, style=None, mergestart=None, self.mergestart = mergestart self.mergeend = mergeend + header_style = {"font": {"bold": True}, "borders": {"top": "thin", "right": "thin", diff --git a/pandas/tests/formats/test_css.py b/pandas/tests/formats/test_css.py index 4bb974089df88..cbd5e5bd3c9fa 100644 --- a/pandas/tests/formats/test_css.py +++ b/pandas/tests/formats/test_css.py @@ -36,7 +36,8 @@ def test_css_parse_comments(): @pytest.mark.xfail def test_css_parse_specificity(): - # we don't need to handle specificity markers like !important, but we should ignore them + # we don't need to handle specificity markers like !important, + # but we should ignore them pass @@ -229,5 +230,3 @@ def test_css_relative_font_size(size, relative_to, resolved): def test_css_font_size_invalid(): pass # TODO - - From f17a0f4bf0f10ef42257f9116e0cafb44498b72a Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Thu, 6 Apr 2017 13:28:48 +1000 Subject: [PATCH 17/48] Some border style tests --- pandas/formats/excel.py | 2 +- pandas/tests/formats/test_to_excel.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pandas/formats/excel.py b/pandas/formats/excel.py index ca742c0add035..6cdf53fb72008 100644 --- a/pandas/formats/excel.py +++ b/pandas/formats/excel.py @@ -144,7 +144,7 @@ def build_border(self, props): } for side in ['top', 'right', 'bottom', 'left']} def _border_style(self, style, width): - # TODO: convert styles and widths to openxml, one of: + # convert styles and widths to openxml, one of: # 'dashDot' # 'dashDotDot' # 'dashed' diff --git a/pandas/tests/formats/test_to_excel.py b/pandas/tests/formats/test_to_excel.py index e01d3c427b0f6..8cb6b2aec5fe5 100644 --- a/pandas/tests/formats/test_to_excel.py +++ b/pandas/tests/formats/test_to_excel.py @@ -84,6 +84,18 @@ 'patternType': 'solid'}}), # BORDER # - style + # TODO: need to check this produces valid OpenXML without color + ('border-style: solid', + {'border': {'top': {'style': 'medium'}, + 'bottom': {'style': 'medium'}, + 'left': {'style': 'medium'}, + 'right': {'style': 'medium'}}}), + ('border-top-style: solid', + {'border': {'top': {'style': 'medium'}}}), + ('border-top-style: dotted', + {'border': {'top': {'style': 'mediumDashDotDot'}}}), + ('border-top-style: dashed', + {'border': {'top': {'style': 'mediumDashed'}}}), # - color # ALIGNMENT # - horizontal From efce9b6d3322b95aa82508d1e096ef5931346827 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Fri, 7 Apr 2017 12:00:12 +1000 Subject: [PATCH 18/48] More CSS to Excel testing; define ExcelFormatter.write --- pandas/core/frame.py | 48 +++++++++++--------------------- pandas/formats/excel.py | 39 ++++++++++++++++++++++---- pandas/formats/style.py | 20 +++++++------ pandas/io/excel.py | 1 + pandas/tests/formats/test_css.py | 6 ++-- 5 files changed, 65 insertions(+), 49 deletions(-) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index fbebd0e6b7ebc..c81cf4e4f4ac7 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -89,6 +89,7 @@ import pandas.core.nanops as nanops import pandas.core.ops as ops import pandas.formats.format as fmt +from pandas.formats.excel import ExcelFormatter from pandas.formats.printing import pprint_thing import pandas.tools.plotting as gfx @@ -203,35 +204,6 @@ """ -def _to_excel(self, excel_writer, sheet_name='Sheet1', na_rep='', - float_format=None, columns=None, header=True, index=True, - index_label=None, startrow=0, startcol=0, engine=None, - merge_cells=True, encoding=None, inf_rep='inf', verbose=True, - freeze_panes=None): - # This implementation is shared by Styler.to_excel - from pandas.io.excel import ExcelWriter - need_save = False - if encoding is None: - encoding = 'ascii' - - if isinstance(excel_writer, compat.string_types): - excel_writer = ExcelWriter(excel_writer, engine=engine) - need_save = True - - formatter = fmt.ExcelFormatter(self, na_rep=na_rep, cols=columns, - header=header, - float_format=float_format, index=index, - index_label=index_label, - merge_cells=merge_cells, - inf_rep=inf_rep) - - formatted_cells = formatter.get_formatted_cells() - excel_writer.write_cells(formatted_cells, sheet_name, - startrow=startrow, startcol=startcol, - freeze_panes=freeze_panes) - if need_save: - excel_writer.save() - # ----------------------------------------------------------------------- # DataFrame class @@ -1441,8 +1413,22 @@ def to_csv(self, path_or_buf=None, sep=",", na_rep='', float_format=None, if path_or_buf is None: return formatter.path_or_buf.getvalue() - to_excel = Appender(_shared_docs['to_excel'] - % _shared_doc_kwargs)(_to_excel) + @Appender(_shared_docs['to_excel'] % _shared_doc_kwargs) + def to_excel(self, excel_writer, sheet_name='Sheet1', na_rep='', + float_format=None, columns=None, header=True, index=True, + index_label=None, startrow=0, startcol=0, engine=None, + merge_cells=True, encoding=None, inf_rep='inf', verbose=True, + freeze_panes=None): + + formatter = ExcelFormatter(self, na_rep=na_rep, cols=columns, + header=header, + float_format=float_format, index=index, + index_label=index_label, + merge_cells=merge_cells, + inf_rep=inf_rep) + formatter.write(excel_writer, sheet_name=sheet_name, startrow=startrow, + startcol=startcol, freeze_panes=freeze_panes, + engine=engine) def to_stata(self, fname, convert_dates=None, write_index=True, encoding="latin-1", byteorder=None, time_stamp=None, diff --git a/pandas/formats/excel.py b/pandas/formats/excel.py index 6cdf53fb72008..e837d420c13e0 100644 --- a/pandas/formats/excel.py +++ b/pandas/formats/excel.py @@ -7,7 +7,7 @@ import numpy as np -from pandas.compat import reduce +from pandas.compat import reduce, string_types from pandas.formats.css import CSSResolver, CSSWarning from pandas.formats.printing import pprint_thing from pandas.types.common import (is_float) @@ -17,11 +17,6 @@ from pandas.formats.common import get_level_lengths -# from collections import namedtuple -# ExcelCell = namedtuple("ExcelCell", -# 'row, col, val, style, mergestart, mergeend') - - class ExcelCell(object): __fields__ = ('row', 'col', 'val', 'style', 'mergestart', 'mergeend') __slots__ = __fields__ @@ -584,3 +579,35 @@ def get_formatted_cells(self): self._format_body()): cell.val = self._format_value(cell.val) yield cell + + def write(self, writer, sheet_name='Sheet1', startrow=0, + startcol=0, freeze_panes=None, engine=None): + """ + writer : string or ExcelWriter object + File path or existing ExcelWriter + sheet_name : string, default 'Sheet1' + Name of sheet which will contain DataFrame + startrow : + upper left cell row to dump data frame + startcol : + upper left cell column to dump data frame + freeze_panes : tuple of integer (length 2), default None + Specifies the one-based bottommost row and rightmost column that + is to be frozen + engine : string, default None + write engine to use if writer is a path - you can also set this + via the options ``io.excel.xlsx.writer``, ``io.excel.xls.writer``, + and ``io.excel.xlsm.writer``. + """ + from pandas.io.excel import ExcelWriter + need_save = False + if isinstance(writer, string_types): + writer = ExcelWriter(writer, engine=engine) + need_save = True + + formatted_cells = self.get_formatted_cells() + writer.write_cells(formatted_cells, sheet_name, + startrow=startrow, startcol=startcol, + freeze_panes=freeze_panes) + if need_save: + writer.save() diff --git a/pandas/formats/style.py b/pandas/formats/style.py index 012123e00cca4..fb34ba90f9561 100644 --- a/pandas/formats/style.py +++ b/pandas/formats/style.py @@ -27,6 +27,7 @@ import pandas.core.common as com from pandas.core.indexing import _maybe_numeric_slice, _non_reducing_slice from pandas.util.decorators import Appender +from pandas.formats.excel import ExcelFormatter try: import matplotlib.pyplot as plt from matplotlib import colors @@ -206,15 +207,16 @@ def to_excel(self, excel_writer, sheet_name='Sheet1', na_rep='', merge_cells=True, encoding=None, inf_rep='inf', verbose=True, freeze_panes=None): - from pandas.core.frame import _to_excel - return _to_excel(self, excel_writer, sheet_name=sheet_name, - na_rep=na_rep, float_format=float_format, - columns=columns, header=header, index=index, - index_label=index_label, startrow=startrow, - startcol=startcol, engine=engine, - merge_cells=merge_cells, encoding=encoding, - inf_rep=inf_rep, verbose=verbose, - freeze_panes=freeze_panes) + formatter = ExcelFormatter(self, na_rep=na_rep, cols=columns, + header=header, + float_format=float_format, index=index, + index_label=index_label, + merge_cells=merge_cells, + inf_rep=inf_rep) + formatter.write(excel_writer, sheet_name=sheet_name, startrow=startrow, + startcol=startcol, freeze_panes=freeze_panes, + engine=engine) + def _translate(self): """ diff --git a/pandas/io/excel.py b/pandas/io/excel.py index 6d136869fc73f..9efedfa774589 100644 --- a/pandas/io/excel.py +++ b/pandas/io/excel.py @@ -24,6 +24,7 @@ string_types, OrderedDict) from pandas.core import config from pandas.formats.printing import pprint_thing +from pandas.formats.excel import ExcelFormatter import pandas.compat as compat import pandas.compat.openpyxl_compat as openpyxl_compat from warnings import warn diff --git a/pandas/tests/formats/test_css.py b/pandas/tests/formats/test_css.py index cbd5e5bd3c9fa..e51735e8b4577 100644 --- a/pandas/tests/formats/test_css.py +++ b/pandas/tests/formats/test_css.py @@ -34,10 +34,10 @@ def test_css_parse_comments(): 'hello/* foo */:/* bar \n */ world /*;not:here*/') -@pytest.mark.xfail +@pytest.mark.xfail(reason='''we don't need to handle specificity + markers like !important, but we should + ignore them in the future''') def test_css_parse_specificity(): - # we don't need to handle specificity markers like !important, - # but we should ignore them pass From 350eab5b2c1330e3456ef1f446d9157dc26b3541 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Fri, 7 Apr 2017 12:02:50 +1000 Subject: [PATCH 19/48] remove spurious blank line --- pandas/core/frame.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index c81cf4e4f4ac7..3a5ceb345cc47 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -203,7 +203,6 @@ """ - # ----------------------------------------------------------------------- # DataFrame class From 306eebee732c0f6fce36f5d1df1ccbdf9891ce92 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Sat, 8 Apr 2017 19:49:36 +1000 Subject: [PATCH 20/48] Module-level docstring --- pandas/formats/common.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pandas/formats/common.py b/pandas/formats/common.py index 82b64e5207212..4805d6e1ce4e7 100644 --- a/pandas/formats/common.py +++ b/pandas/formats/common.py @@ -1,3 +1,9 @@ +# -*- coding: utf-8 -*- +""" +Common helper methods used in different submodules of pandas.formats +""" + + def get_level_lengths(levels, sentinel=''): """For each index in each level the function returns lengths of indexes. From a43d6b7c43a330fb98b815b05084cece296768ba Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Sat, 8 Apr 2017 19:52:01 +1000 Subject: [PATCH 21/48] Cleaner imports --- pandas/core/frame.py | 2 +- pandas/formats/excel.py | 3 +-- pandas/formats/format.py | 1 - pandas/formats/style.py | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pandas/core/frame.py b/pandas/core/frame.py index 2afb357f9cdcd..10d1962026481 100644 --- a/pandas/core/frame.py +++ b/pandas/core/frame.py @@ -89,7 +89,6 @@ import pandas.core.nanops as nanops import pandas.core.ops as ops import pandas.formats.format as fmt -from pandas.formats.excel import ExcelFormatter from pandas.formats.printing import pprint_thing import pandas.tools.plotting as gfx @@ -1419,6 +1418,7 @@ def to_excel(self, excel_writer, sheet_name='Sheet1', na_rep='', merge_cells=True, encoding=None, inf_rep='inf', verbose=True, freeze_panes=None): + from pandas.formats.excel import ExcelFormatter formatter = ExcelFormatter(self, na_rep=na_rep, cols=columns, header=header, float_format=float_format, index=index, diff --git a/pandas/formats/excel.py b/pandas/formats/excel.py index e837d420c13e0..ac5145520ef2c 100644 --- a/pandas/formats/excel.py +++ b/pandas/formats/excel.py @@ -12,8 +12,7 @@ from pandas.formats.printing import pprint_thing from pandas.types.common import (is_float) import pandas._libs.lib as lib -from pandas.core.index import Index, MultiIndex -from pandas.tseries.period import PeriodIndex +from pandas import Index, MultiIndex, PeriodIndex from pandas.formats.common import get_level_lengths diff --git a/pandas/formats/format.py b/pandas/formats/format.py index b8d8675f8b386..7f2b987b16524 100644 --- a/pandas/formats/format.py +++ b/pandas/formats/format.py @@ -32,7 +32,6 @@ from pandas.core.config import get_option, set_option from pandas.io.common import _get_handle, UnicodeWriter, _expand_user from pandas.formats.printing import adjoin, justify, pprint_thing -from pandas.formats.excel import ExcelFormatter # for downstream # NOQA from pandas.formats.common import get_level_lengths import pandas.core.common as com import pandas._libs.lib as lib diff --git a/pandas/formats/style.py b/pandas/formats/style.py index fb34ba90f9561..b1aac615fff4f 100644 --- a/pandas/formats/style.py +++ b/pandas/formats/style.py @@ -27,7 +27,6 @@ import pandas.core.common as com from pandas.core.indexing import _maybe_numeric_slice, _non_reducing_slice from pandas.util.decorators import Appender -from pandas.formats.excel import ExcelFormatter try: import matplotlib.pyplot as plt from matplotlib import colors @@ -207,6 +206,7 @@ def to_excel(self, excel_writer, sheet_name='Sheet1', na_rep='', merge_cells=True, encoding=None, inf_rep='inf', verbose=True, freeze_panes=None): + from pandas.formats.excel import ExcelFormatter formatter = ExcelFormatter(self, na_rep=na_rep, cols=columns, header=header, float_format=float_format, index=index, From c1fc23251fb0ab117e73220703017810f74a6774 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Sat, 8 Apr 2017 20:39:59 +1000 Subject: [PATCH 22/48] Remove debugging print statements --- pandas/formats/excel.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pandas/formats/excel.py b/pandas/formats/excel.py index ac5145520ef2c..ff475205faddc 100644 --- a/pandas/formats/excel.py +++ b/pandas/formats/excel.py @@ -129,7 +129,6 @@ def build_alignment(self, props): } def build_border(self, props): - print(props) return {side: { 'style': self._border_style(props.get('border-%s-style' % side), props.get('border-%s-width' % side)), From 8e9a56731e97fe2b67f91541e1d45e547311b4d5 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Sat, 8 Apr 2017 21:15:07 +1000 Subject: [PATCH 23/48] Fixes from integration testing --- pandas/formats/excel.py | 8 +++++--- pandas/tests/formats/test_to_excel.py | 17 +++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/pandas/formats/excel.py b/pandas/formats/excel.py index ff475205faddc..256f494b4fe09 100644 --- a/pandas/formats/excel.py +++ b/pandas/formats/excel.py @@ -237,11 +237,13 @@ def build_font(self, props): 'size': size, 'bold': self.BOLD_MAP.get(props.get('font-weight')), 'italic': self.ITALIC_MAP.get(props.get('font-style')), - 'underline': (None if decoration is None - else 'underline' in decoration), + 'underline': ('single' + if decoration is not None + and 'underline' in decoration + else None), 'strike': (None if decoration is None else 'line-through' in decoration), - 'color': self.color_to_excel(props.get('font-color')), + 'color': self.color_to_excel(props.get('color')), # shadow if nonzero digit before shadow colour 'shadow': (bool(re.search('^[^#(]*[1-9]', props['text-shadow'])) diff --git a/pandas/tests/formats/test_to_excel.py b/pandas/tests/formats/test_to_excel.py index 8cb6b2aec5fe5..016d6dc792893 100644 --- a/pandas/tests/formats/test_to_excel.py +++ b/pandas/tests/formats/test_to_excel.py @@ -43,11 +43,11 @@ # - italic # - underline ('text-decoration: underline', - {'font': {'underline': True, 'strike': False}}), + {'font': {'underline': 'single', 'strike': False}}), ('text-decoration: overline', - {'font': {'underline': False, 'strike': False}}), + {'font': {'strike': False}}), ('text-decoration: none', - {'font': {'underline': False, 'strike': False}}), + {'font': {'strike': False}}), # - strike ('text-decoration: line-through', {'font': {'strike': True, 'underline': False}}), @@ -56,9 +56,9 @@ ('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'}}), + ('color: red', {'font': {'color': 'FF0000'}}), + ('color: #ff0000', {'font': {'color': 'FF0000'}}), + ('color: #f0a', {'font': {'color': 'FF00AA'}}), # - shadow ('text-shadow: none', {'font': {'shadow': False}}), ('text-shadow: 0px -0em 0px #CCC', {'font': {'shadow': False}}), @@ -141,3 +141,8 @@ def test_css_to_excel_multiple(): def test_css_to_excel_inherited(css, inherited, expected): convert = CSSToExcelConverter(inherited) assert expected == convert(css) + + +@pytest.mark.xfail +def test_css_to_excel_warns_when_not_supported(): + pass From 7c54a69520068a7aa6ee64924cc06c82482cc2fe Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Sat, 8 Apr 2017 21:39:57 +1000 Subject: [PATCH 24/48] Fix test failures; avoid hair border which renders strangely --- pandas/formats/excel.py | 2 -- pandas/tests/formats/test_to_excel.py | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pandas/formats/excel.py b/pandas/formats/excel.py index 256f494b4fe09..01834591b917d 100644 --- a/pandas/formats/excel.py +++ b/pandas/formats/excel.py @@ -161,8 +161,6 @@ def _border_style(self, style, width): 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: diff --git a/pandas/tests/formats/test_to_excel.py b/pandas/tests/formats/test_to_excel.py index 016d6dc792893..20246cb8e8864 100644 --- a/pandas/tests/formats/test_to_excel.py +++ b/pandas/tests/formats/test_to_excel.py @@ -50,11 +50,11 @@ {'font': {'strike': False}}), # - strike ('text-decoration: line-through', - {'font': {'strike': True, 'underline': False}}), + {'font': {'strike': True}}), ('text-decoration: underline line-through', - {'font': {'strike': True, 'underline': True}}), + {'font': {'strike': True, 'underline': 'single'}}), ('text-decoration: underline; text-decoration: line-through', - {'font': {'strike': True, 'underline': False}}), + {'font': {'strike': True}}), # - color ('color: red', {'font': {'color': 'FF0000'}}), ('color: #ff0000', {'font': {'color': 'FF0000'}}), From 9a62699fb923c2b0da285f74007a7511f9420fb5 Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Sun, 9 Apr 2017 00:46:53 +1000 Subject: [PATCH 25/48] Fix tests and add TODOs to tests --- pandas/tests/formats/test_css.py | 2 +- pandas/tests/formats/test_to_excel.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/pandas/tests/formats/test_css.py b/pandas/tests/formats/test_css.py index e51735e8b4577..41e0de0e7a772 100644 --- a/pandas/tests/formats/test_css.py +++ b/pandas/tests/formats/test_css.py @@ -38,7 +38,7 @@ def test_css_parse_comments(): markers like !important, but we should ignore them in the future''') def test_css_parse_specificity(): - pass + pass # TODO @pytest.mark.xfail diff --git a/pandas/tests/formats/test_to_excel.py b/pandas/tests/formats/test_to_excel.py index 20246cb8e8864..bca97dfea2a3d 100644 --- a/pandas/tests/formats/test_to_excel.py +++ b/pandas/tests/formats/test_to_excel.py @@ -84,7 +84,6 @@ 'patternType': 'solid'}}), # BORDER # - style - # TODO: need to check this produces valid OpenXML without color ('border-style: solid', {'border': {'top': {'style': 'medium'}, 'bottom': {'style': 'medium'}, @@ -96,11 +95,16 @@ {'border': {'top': {'style': 'mediumDashDotDot'}}}), ('border-top-style: dashed', {'border': {'top': {'style': 'mediumDashed'}}}), + # TODO: test other widths # - color + # TODO # ALIGNMENT # - horizontal + # TODO # - vertical + # TODO # - wrap_text + # TODO ]) def test_css_to_excel(css, expected): convert = CSSToExcelConverter() @@ -117,10 +121,10 @@ def test_css_to_excel_multiple(): unused: something; ''') assert {"font": {"bold": True}, - "border": {"top": {"style": "hair"}, - "right": {"style": "hair"}, - "bottom": {"style": "hair"}, - "left": {"style": "hair"}}, + "border": {"top": {"style": "thin"}, + "right": {"style": "thin"}, + "bottom": {"style": "thin"}, + "left": {"style": "thin"}}, "alignment": {"horizontal": "center", "vertical": "top"}} == actual @@ -145,4 +149,4 @@ def test_css_to_excel_inherited(css, inherited, expected): @pytest.mark.xfail def test_css_to_excel_warns_when_not_supported(): - pass + pass # TODO From 433be03ccb8b9e668a525d3d6e9525790275d7aa Mon Sep 17 00:00:00 2001 From: Joel Nothman Date: Sun, 9 Apr 2017 01:36:07 +1000 Subject: [PATCH 26/48] Documentation --- doc/source/_static/style-excel.png | Bin 0 -> 70880 bytes doc/source/html-styling.ipynb | 171 ++++++++++++----------------- doc/source/whatsnew/v0.20.0.txt | 26 +++++ 3 files changed, 99 insertions(+), 98 deletions(-) create mode 100644 doc/source/_static/style-excel.png diff --git a/doc/source/_static/style-excel.png b/doc/source/_static/style-excel.png new file mode 100644 index 0000000000000000000000000000000000000000..ee8a14a410d48166a9b6fb87839334c265338773 GIT binary patch literal 70880 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 z*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](https://github.com/pandas-dev/pandas/issues).*

\n", + "\n", + "Some support is available for exporting styled `DataFrames` to Excel worksheets using the `OpenPyXL` engine. CSS2.2 properties handled include:\n", + "\n", + "- `background-color`\n", + "- `border-style`, `border-width`, `border-color` and their {`top`, `right`, `bottom`, `left` variants}\n", + "- `color`\n", + "- `font-family`\n", + "- `font-style`\n", + "- `font-weight`\n", + "- `text-align`\n", + "- `text-decoration`\n", + "- `vertical-align`\n", + "- `white-space: nowrap`\n", + "\n", + "Only CSS2 named colors and hex colors of the form `#rgb` or `#rrggbb` are currently supported." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "df.style.\\\n", + " applymap(color_negative_red).\\\n", + " apply(highlight_max).\\\n", + " to_excel('styled.xlsx', engine='openpyxl')" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -849,9 +831,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": false - }, + "metadata": {}, "outputs": [], "source": [ "from IPython.html import widgets\n", @@ -866,9 +846,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": false - }, + "metadata": {}, "outputs": [], "source": [ "def magnify():\n", @@ -887,9 +865,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": false - }, + "metadata": {}, "outputs": [], "source": [ "np.random.seed(25)\n", @@ -960,8 +936,7 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.5.1" + "pygments_lexer": "ipython3" } }, "nbformat": 4, diff --git a/doc/source/whatsnew/v0.20.0.txt b/doc/source/whatsnew/v0.20.0.txt index d571c0f2d9620..b8845ddd2fd64 100644 --- a/doc/source/whatsnew/v0.20.0.txt +++ b/doc/source/whatsnew/v0.20.0.txt @@ -316,6 +316,32 @@ To convert a ``SparseDataFrame`` back to sparse SciPy matrix in COO format, you .. _whatsnew_0200.enhancements.other: +Stylers now support Excel output +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Experimental support has been added to export ``DataFrame.style`` to Excel using the ``openpyxl`` engine. (:issue:`15530`) + +For example, after running the following, ``styled.xlsx`` renders as below: + +.. ipython:: python + + import pandas as pd + import numpy as np + + np.random.seed(24) + df = pd.DataFrame({'A': np.linspace(1, 10, 10)}) + df = pd.concat([df, pd.DataFrame(np.random.randn(10, 4), columns=list('BCDE'))], + axis=1) + df.iloc[0, 2] = np.nan + df.style.\ + applymap(color_negative_red).\ + apply(highlight_max).\ + to_excel('styled.xlsx', engine='openpyxl') + +.. image:: _static/style-excel.png + +See the :ref:`Style documentation