diff --git a/pptx/chart/chart.py b/pptx/chart/chart.py index 9070dd316..3094e615d 100644 --- a/pptx/chart/chart.py +++ b/pptx/chart/chart.py @@ -14,6 +14,7 @@ from .plot import PlotFactory, PlotTypeInspector from .series import SeriesCollection from ..util import lazyproperty +from .data import ChartDataMoreDetails class Chart(object): @@ -147,6 +148,28 @@ def _workbook(self): """ return self._chart_part.chart_workbook + def get_data_more_details(self): + """ + return |ChartDataMoreDetails| object contains categories and series + in the xml of this chart + """ + chart_data = ChartDataMoreDetails() + if len(self.series) > 0: + categories = self.series[0].categories_tuples + else: + categories = None + if not categories is None: + chart_data.categories = categories + chart_data.categories_len = self.series[0].categories_len + for item_series in self.series: + chart_data.add_series( + item_series.name, + item_series.values_tuple, + values_len=item_series.values_len, + format_code=item_series.format_code, + ) + return chart_data + class Legend(object): """ @@ -277,6 +300,7 @@ def _adjust_ser_count(cls, chartSpace, new_ser_count): increasing order of the c:ser/c:idx value, starting with 0 and with any gaps in numbering collapsed. """ + chartSpace.reindex_sers() ser_count_diff = new_ser_count - len(chartSpace.sers) if ser_count_diff > 0: cls._add_cloned_sers(chartSpace, ser_count_diff) @@ -294,7 +318,8 @@ def _rewrite_ser_data(cls, ser, series_data): ser._remove_cat() ser._remove_val() ser._insert_tx(series_data.tx) - ser._insert_cat(series_data.cat) + if not series_data.cat is None: + ser._insert_cat(series_data.cat) ser._insert_val(series_data.val) @classmethod diff --git a/pptx/chart/data.py b/pptx/chart/data.py index 95cf3e4cd..f2f52a3ee 100644 --- a/pptx/chart/data.py +++ b/pptx/chart/data.py @@ -6,10 +6,14 @@ from __future__ import absolute_import, print_function, unicode_literals +import warnings + from ..oxml import parse_xml from ..oxml.ns import nsdecls from .xlsx import WorkbookWriter from .xmlwriter import ChartXmlWriter +from xlsxwriter.utility import xl_rowcol_to_cell, xl_col_to_name +from xml.sax.saxutils import escape class ChartData(object): @@ -144,7 +148,7 @@ def tx(self): series name. """ xml = self._tx_tmpl.format( - wksht_ref=self._series_name_ref, series_name=self.name, + wksht_ref=self._series_name_ref, series_name=escape(self.name), nsdecls=' %s' % nsdecls('c') ) return parse_xml(xml) @@ -156,7 +160,7 @@ def tx_xml(self): element contains the series name. """ return self._tx_tmpl.format( - wksht_ref=self._series_name_ref, series_name=self.name, + wksht_ref=self._series_name_ref, series_name=escape(self.name), nsdecls='' ) @@ -209,7 +213,8 @@ def _cat_pt_xml(self): ' \n' ' %s\n' ' \n' - ) % (idx, name) + ) % (idx, (escape(name) if + isinstance(name,(str, unicode)) else name)) return xml @property @@ -308,3 +313,352 @@ def _values_ref(self): return "Sheet1!$%s$2:$%s$%d" % ( self._col_letter, self._col_letter, len(self._values)+1 ) + + +class ChartDataMoreDetails(ChartData): + """ + Subclass of ChartData, support categories and vals with more details: + categories with multiple levels, categories and vals with blanks. + + See also :class: `ChartData`. + """ + def __init__(self): + super(ChartDataMoreDetails, self).__init__() + self._categories_len = None + self._values_len = None + + @property + def categories_len(self): + """ + Read-write. The length of categories. Assigned value + will be applied to all sers + """ + return self._categories_len + + @categories_len.setter + def categories_len(self, value): + self._categories_len = value + #make sure all sers have this categories_len + for series in self._series_lst: + series.categories_len = value + + @property + def values_len(self): + """ + Read-write. The length of values.Assigned value + will be applied to all sers + """ + return self._values_len + + @values_len.setter + def values_len(self, value): + self._values_len = value + if self._values_len < self._categories_len: + warnings.warn( + '''Length of values is less than that of categories. +Over bound categories will not be displayed.''') + #make sure all sers have this values_len + for series in self._series_lst: + series.values_len = value + + + def add_series(self, name, values, values_len=None, format_code=None): + """ + Add a series to this data set entitled *name* and the data points + specified by *values*, an iterable of numeric values. + """ + series_idx = len(self._series_lst) + series = _SeriesDataMoreDetails( + series_idx, name, values, + self.categories, + values_len = values_len or self._values_len, + categories_len=self._categories_len, + format_code=format_code, + ) + self._series_lst.append(series) + + +class _SeriesDataMoreDetails(_SeriesData): + """ + Subclass of _SeriesData, support categories and vals with more details: + + * categories with multiple levels + * categories and vals with blanks + * column letters exceeding 'Z' + * formatCode + + Arguments : values & categories must be 2D sequence of (idx, value) + + See also : :class: `_SeriesData`. + """ + def __init__(self, series_idx, name, values, categories, + values_len=None, categories_len=None, format_code=None): + super(_SeriesDataMoreDetails, self).__init__(series_idx, name, + values, categories) + self._values_len = values_len or max(i[0] for i in values) + 1 + self._auto_values_len = True if values_len else False + if categories_len is None and (not self._categories is None) and ( + len(self._categories) != 0): + self._categories_len = max(max(j[0] for j in i) + for i in self._categories) + 1 + else: + self._categories_len = categories_len + if self._values_len != self._categories_len: + warnings.warn('''Categories and Values have different lengths. + Will break data range adjustment by dragging in MS PowerPoint.''') + self._format_code = format_code or 'General' + + @property + def categories_len(self): + """ + Read-write. The length of categories. + """ + return self._categories_len + + @categories_len.setter + def categories_len(self, value): + self._categories_len = value + + @property + def values_len(self): + """ + Read-write. The length of values. + """ + return self._values_len + + @values_len.setter + def values_len(self, value): + self._values_len = value + + @property + def format_code(self): + """ + format code string in ```` element + """ + return self._format_code + + @property + def is_cat_multilvl(self): + """ + whether ```` element has multiple levels + """ + if len(self._categories) > 1: + return True + else: + return False + + @property + def prefix(self): + """ + prefix for ```` and ```` element + """ + if self.is_cat_multilvl: + return 'multiLvlStr' + else: + return 'str' + + @property + def cat(self): + """ + The ```` element XML for this series, as an oxml element. + """ + if self._categories_len > 0: + xml = self._cat_tmpl.format( + prefix=self.prefix, + wksht_ref=self._categories_ref, cat_count=self._categories_len, + cat_pt_xml=self._cat_pt_xml, nsdecls=' %s' % nsdecls('c') + ) + return parse_xml(xml) + else: + return None + + + @property + def cat_xml(self): + """ + The unicode XML snippet for the ```` element for this series, + containing the category labels and spreadsheet reference. + """ + if self._categories_len > 0: + return self._cat_tmpl.format( + prefix=self.prefix, + wksht_ref=self._categories_ref, cat_count=self._categories_len, + cat_pt_xml=self._cat_pt_xml, nsdecls='' + ) + else: + return '' + + @property + def val(self): + """ + The ```` XML for this series, as an oxml element. + """ + xml = self._val_tmpl.format( + wksht_ref=self._values_ref, val_count=self._values_len, + format_code=self._format_code, + val_pt_xml=self._val_pt_xml, nsdecls=' %s' % nsdecls('c') + ) + return parse_xml(xml) + + @property + def val_xml(self): + """ + Return the unicode XML snippet for the ```` element describing + this series. + """ + return self._val_tmpl.format( + wksht_ref=self._values_ref, val_count=self._values_len, + format_code=self._format_code, + val_pt_xml=self._val_pt_xml, nsdecls='' + ) + + @property + def values(self): + """ + The values in this series as a tuple of a sequence of float. + """ + return self._values + + @property + def _categories_ref(self): + """ + The Excel worksheet reference to the categories for this series. + """ + end_col_number = len(self._categories) - 1 + end_row_number = self._categories_len + return "Sheet1!$A$2:%s" % xl_rowcol_to_cell( + end_row_number, end_col_number, + row_abs=True, col_abs=True) + + @property + def _cat_pt_xml(self): + """ + The unicode XML snippet for the ```` elements containing the + category names for this series. + """ + xml = '' + if self.is_cat_multilvl: + lvl_start_tag = ' \n' + lvl_end_tag = ' \n' + pt_indent_spaces = ' ' + else: + lvl_start_tag = '' + lvl_end_tag = '' + pt_indent_spaces = '' + pt_xml = pt_indent_spaces.join( + ('', + ' \n', + ' %s\n', + ' \n',)) + #ref lvl is in reverse sequence in xml + loop_range = range(len(self._categories)) + loop_range.reverse() + for ilvl in loop_range: + lvl = self._categories[ilvl] + xml += lvl_start_tag + for idx, name in lvl: + #ignore idx out bound + if idx < self.categories_len: + xml += pt_xml % (idx, (escape(name) if + isinstance(name,(str, unicode)) else name)) + xml += lvl_end_tag + return xml + + @property + def _cat_tmpl(self): + """ + The template for the ```` element for this series, containing + the category labels and spreadsheet reference. + """ + return ( + ' \n' + ' \n' + ' {wksht_ref}\n' + ' \n' + ' \n' + '{cat_pt_xml}' + ' \n' + ' \n' + ' \n' + ) + + @property + def _col_letter(self): + """ + The letter of the Excel worksheet column in which the data for this + series appears. + """ + return xl_col_to_name(max(1, len(self._categories)) + self._series_idx) + + @property + def _val_pt_xml(self): + """ + The unicode XML snippet containing the ```` elements for this + series. + """ + xml = '' + for idx, value in self._values: + if idx < self.values_len: + xml += ( + ' \n' + ' %s\n' + ' \n' + ) % (idx, value) + return xml + + @property + def _val_tmpl(self): + """ + The template for the ```` element for this series, containing + the series values and their spreadsheet range reference. + """ + return ( + ' \n' + ' \n' + ' {wksht_ref}\n' + ' \n' + ' {format_code}\n' + ' \n' + '{val_pt_xml}' + ' \n' + ' \n' + ' \n' + ) + + @property + def _values_ref(self): + """ + The Excel worksheet reference to the values for this series (not + including the series name). + """ + return "Sheet1!${col_letter}$2:${col_letter}${end_row_number}".format( + col_letter = self._col_letter, + end_row_number = self._values_len + 1, + ) + + @property + def name(self): + """ + The name of this series. + """ + return self._name + + @name.setter + def name(self, value): + #name setter + self._name = value + + @property + def values(self): + """ + The values in this series as a sequence of float. + """ + return self._values + + @values.setter + def values(self, _values): + #values setter + self._values = _values + #update values len if auto + if self._auto_values_len: + self._values_len = max(i[0] for i in _values) + 1 diff --git a/pptx/chart/plot.py b/pptx/chart/plot.py index 48906d26a..55a602e6d 100644 --- a/pptx/chart/plot.py +++ b/pptx/chart/plot.py @@ -37,6 +37,21 @@ def categories(self): category_pt_elms = xChart.cat_pts return tuple(pt.v.text for pt in category_pt_elms) + @property + def categories_tuples(self): + """ + Tuples containing the category indexes and strings for this plot. + If len > 1, the plot has multilvl categories. + """ + return self._element.iter_sers().next().cat.tuples_pts + + @property + def categories_len(self): + """ + Return length of categories, equal to ticks in categories axis + """ + return self._element.iter_sers().next().cat.val_ptCount + @property def chart(self): """ diff --git a/pptx/chart/series.py b/pptx/chart/series.py index 6f5f7f71c..1ce8ae4cd 100644 --- a/pptx/chart/series.py +++ b/pptx/chart/series.py @@ -51,6 +51,44 @@ def values(self): value_pt_elms = ser.val_pts return tuple(pt.value for pt in value_pt_elms) + @property + def categories_tuples(self): + """ + Tuples containing the category indexes and strings for this series. + If len > 1, the series has multilvl categories. + """ + if not self._element.cat is None: + return self._element.cat.tuples_pts + + @property + def categories_len(self): + """ + Return length of categories, equal to ticks in categories axis + """ + if not self._element.cat is None: + return self._element.cat.val_ptCount + + @property + def values_tuple(self): + """ + Tuple containing the value indexes and value for this series. + """ + return self._element.val.tuples_pts + + @property + def values_len(self): + """ + Return length of values, equal to ticks in categories axis + """ + return self._element.val.val_ptCount + + @property + def format_code(self): + """ + Return formatCode + """ + return self._element.val.ref.cache.text_fomatCode + class BarSeries(_BaseSeries): """ diff --git a/pptx/chart/xlsx.py b/pptx/chart/xlsx.py index 681aeb4a7..5365e501d 100644 --- a/pptx/chart/xlsx.py +++ b/pptx/chart/xlsx.py @@ -46,11 +46,23 @@ def _populate_worksheet(cls, worksheet, categories, series): """ Write *categories* and *series* to *worksheet* in the standard layout, categories in first column starting in second row, and series - as columns starting in second column, series title in first cell. - Make the whole range an Excel List. + as columns starting in column next to categories, series title in first + cell. Make the whole range an Excel List. """ - worksheet.write_column(1, 0, categories) - for series in series: - series_col = series.index + 1 - worksheet.write(0, series_col, series.name) - worksheet.write_column(1, series_col, series.values) + if len(categories) != 0 and isinstance(categories[0], (list, tuple)): + for ilvl in xrange(len(categories)): + for idx, token in categories[ilvl]: + worksheet.write(1+idx, ilvl, token) + value_start_col = len(categories) + else: + worksheet.write_column(1, 0, categories) + value_start_col = 1 + for item_series in series: + series_col = item_series.index + value_start_col + worksheet.write(0, series_col, item_series.name) + if len(item_series.values) != 0 and isinstance( + item_series.values[0], tuple): + for idx, token in item_series.values: + worksheet.write(1+idx, series_col, token) + else: + worksheet.write_column(1, series_col, item_series.values) diff --git a/pptx/oxml/__init__.py b/pptx/oxml/__init__.py index 6ec67cfdf..a60da615e 100644 --- a/pptx/oxml/__init__.py +++ b/pptx/oxml/__init__.py @@ -84,9 +84,24 @@ def register_element_cls(nsptagname, cls): register_element_cls('c:pieChart', CT_PieChart) -from .chart.series import CT_SeriesComposite, CT_StrVal_NumVal_Composite +from .chart.series import ( + CT_SeriesComposite, CT_StrVal_NumVal_Composite, + CT_Val, CT_Cat, + CT_MultiLvlStrRef, CT_StrRef, CT_NumRef, + CT_MultiLvlStrCache, CT_Lvl, CT_StrCache, CT_NumCache +) register_element_cls('c:pt', CT_StrVal_NumVal_Composite) register_element_cls('c:ser', CT_SeriesComposite) +register_element_cls('c:val', CT_Val) +register_element_cls('c:cat', CT_Cat) +register_element_cls('c:multiLvlStrRef', CT_MultiLvlStrRef) +register_element_cls('c:strRef', CT_StrRef) +register_element_cls('c:numRef', CT_NumRef) +register_element_cls('c:multiLvlStrCache', CT_MultiLvlStrCache) +register_element_cls('c:lvl', CT_Lvl) +register_element_cls('c:strCache', CT_StrCache) +register_element_cls('c:numCache', CT_NumCache) + from .chart.shared import ( @@ -108,7 +123,7 @@ def register_element_cls(nsptagname, cls): register_element_cls('c:varyColors', CT_Boolean) register_element_cls('c:x', CT_Double) register_element_cls('c:xMode', CT_LayoutMode) - +register_element_cls('c:ptCount', CT_UnsignedInt) from .dml.color import ( CT_HslColor, CT_Percentage, CT_PresetColor, CT_SchemeColor, diff --git a/pptx/oxml/chart/chart.py b/pptx/oxml/chart/chart.py index 2b4ffb5d6..824eb2db8 100644 --- a/pptx/oxml/chart/chart.py +++ b/pptx/oxml/chart/chart.py @@ -105,18 +105,29 @@ def last_doc_order_ser(self): def sers(self): """ An immutable sequence of the `c:ser` elements under this chartSpace - element, sorted in order of their `c:ser/c:idx/@val` value and with - any gaps in numbering collapsed. + element in order of `c:ser/c:idx/@val` (same as Official VBA API : + Chart.SeriesCollection(Index)) """ - def ser_idx(ser): - return ser.idx.val + return sorted(self.xpath('.//c:ser'), key=lambda ser: ser.idx.val) - sers = sorted(self.xpath('.//c:ser'), key=ser_idx) - for idx, ser in enumerate(sers): + def reindex_sers(self): + """ + Reindex `c:ser` elements in order of their `c:ser/c:idx/@val` value + and with any gaps in numbering collapsed. (method for backwards + compatible) Beware : Altering `c:ser/c:idx/@val` value frequently + change auto style of the series + """ + for idx, ser in enumerate(self.sers): if ser.idx.val != idx: ser.idx.val = idx - ser.order.val = idx - return sers + ser.order.val = idx + + def reset_order_sers(self): + """ + Duplicated `c:ser/c:order/@val` is not allowed in MS PowerPoint, reset + order starting from zero""" + for order, ser in enumerate(self.sers): + ser.order.val = order @property def valAx(self): diff --git a/pptx/oxml/chart/series.py b/pptx/oxml/chart/series.py index af77b55a9..710dd5152 100644 --- a/pptx/oxml/chart/series.py +++ b/pptx/oxml/chart/series.py @@ -8,7 +8,7 @@ from ..simpletypes import XsdUnsignedInt from ..xmlchemy import ( - BaseOxmlElement, OneAndOnlyOne, RequiredAttribute, ZeroOrOne + BaseOxmlElement, OneAndOnlyOne, RequiredAttribute, ZeroOrOne, OneOrMore ) @@ -85,3 +85,206 @@ def value(self): The float value of the text in the required ```` child. """ return float(self.v.text) + +class _Base_Seq(BaseOxmlElement): + """ + base class for sequence element + provides similar properties and methods for element with ref and cache + """ + #ref element must be implemented in subclass + + @property + def ref(self): + """ + ref element, must be implemented in subclass + """ + raise NotImplementedError( + 'property ref must be implemented in subclass') + + @property + def text_ref(self): + """ + reference text + """ + return self.ref.text_cf + + @property + def val_ptCount(self): + """ + val of ptCount + """ + return self.ref.cache.ptCount.val + + @property + def tuples_pts(self): + """ + tuples of pts + """ + return self.ref.cache.tuple_pts + + +class CT_Val(_Base_Seq): + """ + ```` element, contains values + """ + _ref = ZeroOrOne('c:numRef') + + @property + def ref(self): + """ + ref element of ```` child + """ + return self._ref + + +class CT_Cat(_Base_Seq): + """ + ```` element, contains categories + """ + _multilvlstrref = ZeroOrOne('c:multiLvlStrRef') + _strref = ZeroOrOne('c:strRef') + _numref = ZeroOrOne('c:numRef') + + @property + def is_multilvl(self): + """ + True if with ``c:multiLvlStrRef>`` child + """ + if self._multilvlstrref is None: + return False + else: + return True + + @property + def ref(self): + """ + ref element of ```` or ```` child + """ + if self.is_multilvl: + return self._multilvlstrref + elif not self._numref is None: + return self._numref + else: + return self._strref + + + @property + def tuples_pts(self): + """ + tuples of pts + """ + if self.is_multilvl: + return tuple( + (lvl.tuple_pts for lvl in self.ref.cache.lvl_lst) + ) + else: + return (self.ref.cache.tuple_pts,) + + +class _Base_Ref(BaseOxmlElement): + """ + base class for ````, ````, and ```` + element + """ + cf = ZeroOrOne('c:f', successors=('c:multiLvlStrCache', 'c:strCache', + 'c:numCache')) + + @property + def text_cf(self): + """ + reference text of ```` child. + """ + if not self.cf is None: + return self.cf.text + + +class CT_MultiLvlStrRef(_Base_Ref): + """ + ```` element + """ + cache = OneAndOnlyOne('c:multiLvlStrCache') + + +class CT_StrRef(_Base_Ref): + """ + ```` element + """ + cache = OneAndOnlyOne('c:strCache') + +class CT_NumRef(_Base_Ref): + """ + ```` element + """ + cache = OneAndOnlyOne('c:numCache') + + +class _Base_Cache(BaseOxmlElement): + """ + base class for ````, ````, and ```` element + """ + formatCode = ZeroOrOne('c:formatCode', successors=('c:ptCount',)) + ptCount = OneAndOnlyOne('c:ptCount') + + +class CT_MultiLvlStrCache(_Base_Cache): + """ + ```` element + """ + lvl = OneOrMore('c:lvl') + + +class _PtMixin(BaseOxmlElement): + """ + mixin class for ```` children, provides pt_tuples + """ + pt = OneOrMore('c:pt') + + @property + def tuple_pts(self): + """ + return pt tuples as : (idx, v.text) + """ + return tuple( + ((pt.idx, pt.v.text) for pt in self.pt_lst) + ) + + +class CT_Lvl(_PtMixin): + """ + ```` element + """ + pass + + +class CT_StrCache(_Base_Cache, _PtMixin): + """ + ```` element + """ + pass + + +class CT_NumCache(_Base_Cache, _PtMixin): + """ + ```` element + """ + @property + def text_fomatCode(self): + """ + return text of formatCode + """ + return self.formatCode.text + + @property + def tuple_pts(self): + """ + try return pt tuple as : (idx, float(v.text)) + and return (idx, v.text) if failed + """ + pt_tuple_lst = list() + for pt in self.pt_lst: + try: + value = float(pt.v.text) + except ValueError: + value = pt.v.text + pt_tuple_lst.append((pt.idx, value)) + return tuple(pt_tuple_lst)