Files
2025-09-29 00:52:08 +02:00

567 lines
16 KiB
Python
Executable File

# 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"