# Copyright (c) 2010-2014 openpyxl # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # # @license: http://www.opensource.org/licenses/mit-license.php # @author: see AUTHORS file import math from numbers import Number from openpyxl.style import NumberFormat, is_date_format, is_builtin from openpyxl.drawing import Drawing, Shape from openpyxl.shared.units import pixels_to_EMU, short_color from openpyxl.cell import get_column_letter def less_than_one(value): """Recalculate the maximum for a series if it is less than one by scaling by powers of 10 until is greater than 1 """ value = abs(value) if value < 1: exp = int(math.log10(value)) return 10**((abs(exp)) + 1) class Axis(object): POSITION_BOTTOM = 'b' POSITION_LEFT = 'l' ORIENTATION_MIN_MAX = "minMax" position = None tick_label_position = None crosses = None auto = None label_align = None label_offset = None cross_between = None orientation = ORIENTATION_MIN_MAX number_format = NumberFormat() delete_axis = False def __init__(self, auto_axis=True): self.auto_axis = auto_axis self.min = 0 self.max = 0 self.unit = None self.title = '' def _max_min(self): """ Calculate minimum and maximum for the axis adding some padding. There are always a maximum of ten units for the length of the axis. """ value = length = self._max - self._min sign = value/value zoom = less_than_one(value) or 1 value = value * zoom ab = abs(value) value = math.ceil(ab * 1.1) * sign # calculate tick l = math.log10(abs(value)) exp = int(l) mant = l - exp unit = math.ceil(math.ceil(10**mant) * 10**(exp-1)) # recalculate max value = math.ceil(value / unit) * unit unit = unit / zoom if value / unit > 9: # no more that 10 ticks unit *= 2 self.unit = unit scale = value / length mini = math.floor(self._min * scale) / zoom maxi = math.ceil(self._max * scale) / zoom return mini, maxi @property def min(self): if self.auto_axis: return self._max_min()[0] return self._min @min.setter def min(self, value): self._min = value @property def max(self): if self.auto_axis: return self._max_min()[1] return self._max @max.setter def max(self, value): self._max = value @property def unit(self): if self.auto_axis: self._max_min() return self._unit @unit.setter def unit(self, value): self._unit = value class CategoryAxis(Axis): id = 60871424 cross = 60873344 position = Axis.POSITION_BOTTOM tick_label_position = 'nextTo' crosses = "autoZero" auto = True label_align = 'ctr' label_offset = 100 cross_between = "midCat" type = "catAx" class ValueAxis(Axis): id = 60873344 cross = 60871424 position = Axis.POSITION_LEFT major_gridlines = None tick_label_position = 'nextTo' crosses = 'autoZero' auto = False cross_between = 'between' type= "valAx" class Reference(object): """ a simple wrapper around a serie of reference data """ _data_type = None def __init__(self, sheet, pos1, pos2=None, data_type=None, number_format=None): self.sheet = sheet self.pos1 = pos1 self.pos2 = pos2 if data_type is not None: self.data_type = data_type self.number_format = number_format @property def data_type(self): return self._data_type @data_type.setter def data_type(self, value): if value not in ['n', 's']: raise ValueError("References must be either numeric or strings") self._data_type = value @property def number_format(self): return self._number_format @number_format.setter def number_format(self, value): if value is not None: if not is_builtin(value): raise ValueError("Invalid number format") self._number_format = value @property def values(self): """ read data in sheet - to be used at writing time """ if hasattr(self, "_values"): return self._values if self.pos2 is None: cell = self.sheet.cell(row=self.pos1[0], column=self.pos1[1]) self.data_type = cell.data_type self._values = [cell.internal_value] else: self._values = [] for row in range(int(self.pos1[0]), int(self.pos2[0] + 1)): for col in range(int(self.pos1[1]), int(self.pos2[1] + 1)): cell = self.sheet.cell(row=row, column=col) self._values.append(cell.internal_value) if cell.internal_value == '': continue if self.data_type is None and cell.data_type: self.data_type = cell.data_type return self._values def __str__(self): """ format excel reference notation """ if self.pos2: return "'%s'!$%s$%s:$%s$%s" % (self.sheet.title, get_column_letter(self.pos1[1] + 1), self.pos1[0] + 1, get_column_letter(self.pos2[1] + 1), self.pos2[0] + 1) else: return "'%s'!$%s$%s" % (self.sheet.title, get_column_letter(self.pos1[1] + 1), self.pos1[0] + 1) class Series(object): """ a serie of data and possibly associated labels """ MARKER_NONE = 'none' _title = None _legend = None def __init__(self, values, title=None, labels=None, color=None, xvalues=None, legend=None): self.marker = Series.MARKER_NONE self.values = values self.xvalues = xvalues self.labels = labels self.title = title self.error_bar = None if legend is not None: self.legend = legend @property def title(self): if self._title is not None: return self._title if self.legend is not None: return self.legend.values[0] @title.setter def title(self, value): self._title = value @property def legend(self): return self._legend @legend.setter def legend(self, value): from warnings import warn warn("Series titles can be set directly using series.title. Series legend will be removed in 2.0") value.data_type = 's' self._legend = value @property def color(self): return getattr(self, "_color", None) @color.setter def color(self, color): if color is None: raise ValueError("Colors must be strings of the format XXXXX") self._color = short_color(color) @property def values(self): """Return values from underlying reference""" return self._values @values.setter def values(self, reference): """Assign values from reference to serie""" if reference is not None: if not isinstance(reference, Reference): raise TypeError("Series values must be a Reference instance") self._values = reference.values else: self._values = None self.reference = reference @property def xvalues(self): """Return xvalues""" return self._xvalues @xvalues.setter def xvalues(self, reference): if reference is not None: if not isinstance(reference, Reference): raise TypeError("Series xvalues must be a Reference instance") self._xvalues = reference.values else: self._xvalues = None self.xreference = reference @property def labels(self): """Return values from reference set as label""" return self._labels @labels.setter def labels(self, reference): if reference is not None: if not isinstance(reference, Reference): raise TypeError("Series labels must be a Reference instance") reference.values self._labels = reference else: self._labels = None def max(self, attr='values'): """ Return the maximum value for numeric series. NB None has a value of u'' which is ignored """ values = getattr(self, attr) if self.error_bar: values = self._error_bar_values cleaned = [v for v in values if isinstance(v, Number)] if cleaned: return max(cleaned) def min(self, attr='values'): """ Return the minimum value for numeric series NB None has a value of u'' which is ignored """ values = getattr(self, attr) if self.error_bar: values = self._error_bar_values cleaned = [v for v in values if isinstance(v, Number)] if cleaned: return min(cleaned) @property def _error_bar_values(self): """Documentation required here""" err_cache = self.error_bar.values vals = [v + err_cache[i] \ for i, v in enumerate(self.values)] return vals def get_min_max(self): """Legacy method. Replaced by properties""" return self.min(), self.max() def __len__(self): return len(self.values) # backwards compatibility Serie = Series class Legend(object): def __init__(self): self.position = 'r' self.layout = None class ErrorBar(object): PLUS = 1 MINUS = 2 PLUS_MINUS = 3 def __init__(self, _type, values): self.type = _type self.values = values @property def values(self): """Return values from underlying reference""" return self._values @values.setter def values(self, reference): """Assign values from reference to serie""" if reference is not None: if not isinstance(reference, Reference): raise TypeError("Errorbar values must be a Reference instance") self._values = reference.values else: self._values = None class Chart(object): """ raw chart class """ GROUPING = 'standard' TYPE = None def mymax(self, values): return max([x for x in values if x is not None]) def mymin(self, values): return min([x for x in values if x is not None]) def __init__(self): self.series = [] self._series = self.series # backwards compatible # public api self.legend = Legend() self.show_legend = True self.lang = 'en-GB' self.title = '' self.print_margins = dict(b=.75, l=.7, r=.7, t=.75, header=0.3, footer=.3) # the containing drawing self.drawing = Drawing() self.drawing.left = 10 self.drawing.top = 400 self.drawing.height = 400 self.drawing.width = 800 # the offset for the plot part in percentage of the drawing size self.width = .6 self.height = .6 self._margin_top = 1 self._margin_top = self.margin_top self._margin_left = 0 # the user defined shapes self.shapes = [] self._shapes = self.shapes # backwards compatible def append(self, obj): """Add a series or a shape""" if isinstance(obj, Series): self.series.append(obj) elif isinstance(obj, Shape): self.shapes.append(obj) add_shape = add_serie = add_series = append def __iter__(self): return iter(self.series) def get_y_chars(self): """ estimate nb of chars for y axis """ _max = max([s.max() for s in self]) return len(str(int(_max))) @property def margin_top(self): """ get margin in percent """ return min(self._margin_top, self._get_max_margin_top()) @margin_top.setter def margin_top(self, value): """ set base top margin""" self._margin_top = value def _get_max_margin_top(self): mb = Shape.FONT_HEIGHT + Shape.MARGIN_BOTTOM plot_height = self.drawing.height * self.height return float(self.drawing.height - plot_height - mb) / self.drawing.height @property def margin_left(self): return max(self._get_min_margin_left(), self._margin_left) @margin_left.setter def margin_left(self, value): self._margin_left = value def _get_min_margin_left(self): ml = (self.get_y_chars() * Shape.FONT_WIDTH) + Shape.MARGIN_LEFT return float(ml) / self.drawing.width class PieChart(Chart): TYPE = "pieChart" class GraphChart(Chart): """Chart with axes""" x_axis = CategoryAxis y_axis = ValueAxis def __init__(self, auto_axis=True): super(GraphChart, self).__init__() self.auto_axis = auto_axis self.x_axis = getattr(self, "x_axis")(auto_axis) self.y_axis = getattr(self, "y_axis")(auto_axis) def compute_axes(self): """Calculate maximum value and units for axes""" mini, maxi = self._get_extremes() self.y_axis.min = mini self.y_axis.max = maxi self.y_axis._max_min() if not None in [s.xvalues for s in self]: mini, maxi = self._get_extremes('xvalues') self.x_axis.min = mini self.x_axis.max = maxi self.x_axis._max_min() def get_x_units(self): """ calculate one unit for x axis in EMU """ return max([len(s.values) for s in self]) def get_y_units(self): """ calculate one unit for y axis in EMU """ dh = pixels_to_EMU(self.drawing.height) return (dh * self.height) / self.y_axis.max def _get_extremes(self, attr='values'): """Calculate the maximum and minimum values of all series for an axis 'values' for columns 'xvalues for rows """ # calculate the maximum and minimum for all series series_max = [0] series_min = [0] for s in self: if s is not None: series_max.append(s.max(attr)) series_min.append(s.min(attr)) return min(series_min), max(series_max) class BarChart(GraphChart): TYPE = "barChart" GROUPING = "clustered" class LineChart(GraphChart): TYPE = "lineChart" class ScatterChart(GraphChart): TYPE = "scatterChart" def __init__(self): super(ScatterChart, self).__init__() self.x_axis.type = "valAx" self.x_axis.cross_between = "midCat" self.y_axis.cross_between = "midCat"