#!/usr/bin/env python # -*- coding: utf-8 -*- # nicechart.py # # Copyright 2011-2016 # # Christoph Sterz # Florian Weber # Maren Hachmann # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. # # TODO / Ideas: # allow negative values for bar charts # show values for stacked bar charts # don't create a new layer for each chart, but a normal group # correct bar height for stacked bars (it's only half as high as it should be, double) # adjust position of heading # use aliasing workaround for stacked bars (e.g. let the rectangles overlap) # Example CSV file contents: ''' Month;1978;1979;1980;1981 January;2;1,3;0.1;2.3 February;6.5;2.4;1.2;6.1 March;7.4;6.7;7.9;4.7 April;7.7;6.4;8.2;8.9 May;10.9;11.7;18.7;11.1 June;12.6;14.2;14.7;14.7 July;16.5;15.5;17.5;15.1 August;15.9;15.4;14.6;16.6 September;14;14.5;13.2;15.3 October;11.9;13.9;11.5;9.2 November;6.7;8.5;7;6.6 December;6.4;2.2;6.3;3.5 ''' # The extension creates one chart for a single value column in one go, # e.g. chart all temperatures for all months of the year 1978 into one chart. # (for this, select column 0 for labels and column 1 for values). # "1978" etc. can be used as heading (Need not be numeric. If not used delete the heading line.) # Month names can be used as labels # Values can be shown, in addition to labels (doesn't work with stacked bar charts) # Values can contain commas as decimal separator, as long as delimiter isn't comma # Negative values are not yet supported. import re import sys import math import inkex from simplestyle import * #www.sapdesignguild.org/goodies/diagram_guidelines/color_palettes.html#mss COLOUR_TABLE = { "red": ["#460101", "#980101", "#d40000", "#f44800", "#fb8b00", "#eec73e", "#d9bb7a", "#fdd99b"], "blue": ["#000442", "#0F1781", "#252FB7", "#3A45E1", "#656DDE", "#8A91EC"], "gray": ["#222222", "#444444", "#666666", "#888888", "#aaaaaa", "#cccccc", "#eeeeee"], "contrast": ["#0000FF", "#FF0000", "#00FF00", "#CF9100", "#FF00FF", "#00FFFF"], "sap": ["#f8d753", "#5c9746", "#3e75a7", "#7a653e", "#e1662a", "#74796f", "#c4384f", "#fff8a3", "#a9cc8f", "#b2c8d9", "#bea37a", "#f3aa79", "#b5b5a9", "#e6a5a5"] } def get_color_scheme(name="default"): return COLOUR_TABLE.get(name.lower(), COLOUR_TABLE['red']) class NiceChart(inkex.Effect): """ Inkscape extension that can draw pie charts and bar charts (stacked, single, horizontally or vertically) with optional drop shadow, from a csv file or from pasted text """ def __init__(self): """ Constructor. Defines the "--what" option of a script. """ # Call the base class constructor. inkex.Effect.__init__(self) # Define string option "--what" with "-w" shortcut and default chart values. self.OptionParser.add_option('-w', '--what', action='store', type='string', dest='what', default='22,11,67', help='Chart Values') # Define string option "--type" with "-t" shortcut. self.OptionParser.add_option("-t", "--type", action="store", type="string", dest="type", default='', help="Chart Type") # Define bool option "--blur" with "-b" shortcut. self.OptionParser.add_option("-b", "--blur", action="store", type="inkbool", dest="blur", default='True', help="Blur Type") # Define string option "--file" with "-f" shortcut. self.OptionParser.add_option("-f", "--filename", action="store", type="string", dest="filename", default='', help="Name of File") # Define string option "--input_type" with "-i" shortcut. self.OptionParser.add_option("-i", "--input_type", action="store", type="string", dest="input_type", default='file', help="Chart Type") # Define string option "--delimiter" with "-d" shortcut. self.OptionParser.add_option("-d", "--delimiter", action="store", type="string", dest="csv_delimiter", default=';', help="delimiter") # Define string option "--colors" with "-c" shortcut. self.OptionParser.add_option("-c", "--colors", action="store", type="string", dest="colors", default='default', help="color-scheme") # Define string option "--colors_override" self.OptionParser.add_option("", "--colors_override", action="store", type="string", dest="colors_override", default='', help="color-scheme-override") self.OptionParser.add_option("", "--reverse_colors", action="store", type="inkbool", dest="reverse_colors", default='False', help="reverse color-scheme") self.OptionParser.add_option("-k", "--col_key", action="store", type="int", dest="col_key", default='0', help="column that contains the keys") self.OptionParser.add_option("-v", "--col_val", action="store", type="int", dest="col_val", default='1', help="column that contains the values") self.OptionParser.add_option("", "--encoding", action="store", type="string", dest="encoding", default='utf-8', help="encoding of the CSV file, e.g. utf-8") self.OptionParser.add_option("", "--headings", action="store", type="inkbool", dest="headings", default='False', help="the first line of the CSV file consists of headings for the columns") self.OptionParser.add_option("-r", "--rotate", action="store", type="inkbool", dest="rotate", default='False', help="Draw barchart horizontally") self.OptionParser.add_option("-W", "--bar-width", action="store", type="int", dest="bar_width", default='10', help="width of bars") self.OptionParser.add_option("-p", "--pie-radius", action="store", type="int", dest="pie_radius", default='100', help="radius of pie-charts") self.OptionParser.add_option("-H", "--bar-height", action="store", type="int", dest="bar_height", default='100', help="height of bars") self.OptionParser.add_option("-O", "--bar-offset", action="store", type="int", dest="bar_offset", default='5', help="distance between bars") self.OptionParser.add_option("", "--stroke-width", action="store", type="float", dest="stroke_width", default='1') self.OptionParser.add_option("-o", "--text-offset", action="store", type="int", dest="text_offset", default='5', help="distance between bar and descriptions") self.OptionParser.add_option("", "--heading-offset", action="store", type="int", dest="heading_offset", default='50', help="distance between chart and chart title") self.OptionParser.add_option("", "--segment-overlap", action="store", type="inkbool", dest="segment_overlap", default='False', help="work around aliasing effects by letting pie chart segments overlap") self.OptionParser.add_option("-F", "--font", action="store", type="string", dest="font", default='sans-serif', help="font of description") self.OptionParser.add_option("-S", "--font-size", action="store", type="int", dest="font_size", default='10', help="font size of description") self.OptionParser.add_option("-C", "--font-color", action="store", type="string", dest="font_color", default='black', help="font color of description") #Dummy: self.OptionParser.add_option("","--input_sections") self.OptionParser.add_option("-V", "--show_values", action="store", type="inkbool", dest="show_values", default='False', help="Show values in chart") def effect(self): """ Effect behaviour. Overrides base class' method and inserts a nice looking chart into SVG document. """ # Get script's "--what" option value and process the data type --- i concess the if term is a little bit of magic what = self.options.what keys = [] values = [] orig_values = [] keys_present = True pie_abs = False cnt = 0 csv_file_name = self.options.filename csv_delimiter = self.options.csv_delimiter input_type = self.options.input_type col_key = self.options.col_key col_val = self.options.col_val show_values = self.options.show_values encoding = self.options.encoding.strip() or 'utf-8' headings = self.options.headings heading_offset = self.options.heading_offset if input_type == "\"file\"": csv_file = open(csv_file_name, "r") for linenum, line in enumerate(csv_file): value = line.decode(encoding).split(csv_delimiter) #make sure that there is at least one value (someone may want to use it as description) if len(value) >= 1: # allow to parse headings as strings if linenum == 0 and headings: heading = value[col_val] else: keys.append(value[col_key]) # replace comma decimal separator from file by colon, # to avoid file editing for people whose programs output # values with comma values.append(float(value[col_val].replace(",","."))) csv_file.close() elif input_type == "\"direct_input\"": what = re.findall("([A-Z|a-z|0-9]+:[0-9]+\.?[0-9]*)", what) for value in what: value = value.split(":") keys.append(value[0]) values.append(float(value[1])) # warn about negative values (not yet supported) for value in values: if value < 0: inkex.errormsg("Negative values are currently not supported!") return # Get script's "--type" option value. charttype = self.options.type if charttype == "pie_abs": pie_abs = True charttype = "pie" # Get access to main SVG document element and get its dimensions. svg = self.document.getroot() # Get the page attibutes: width = self.getUnittouu(svg.get('width')) height = self.getUnittouu(svg.attrib['height']) # Create a new layer. layer = inkex.etree.SubElement(svg, 'g') layer.set(inkex.addNS('label', 'inkscape'), 'Chart-Layer: %s' % (what)) layer.set(inkex.addNS('groupmode', 'inkscape'), 'layer') # Check if a drop shadow should be drawn: draw_blur = self.options.blur if draw_blur: # Get defs of Document defs = self.xpathSingle('/svg:svg//svg:defs') if defs == None: defs = inkex.etree.SubElement(self.document.getroot(), inkex.addNS('defs', 'svg')) # Create new Filter filt = inkex.etree.SubElement(defs,inkex.addNS('filter', 'svg')) filtId = self.uniqueId('filter') self.filtId = 'filter:url(#%s);' % filtId for k, v in [('id', filtId), ('height', "3"), ('width', "3"), ('x', '-0.5'), ('y', '-0.5')]: filt.set(k, v) # Append Gaussian Blur to that Filter fe = inkex.etree.SubElement(filt, inkex.addNS('feGaussianBlur', 'svg')) fe.set('stdDeviation', "1.1") # Set Default Colors self.options.colors_override.strip() if len(self.options.colors_override) > 0: colors = self.options.colors_override else: colors = self.options.colors if colors[0].isalpha(): colors = get_color_scheme(colors) else: colors = re.findall("(#[0-9a-fA-F]{6})", colors) #to be sure we create a fallback: if len(colors) == 0: colors = get_color_scheme() color_count = len(colors) if self.options.reverse_colors: colors.reverse() # Those values should be self-explanatory: bar_height = self.options.bar_height bar_width = self.options.bar_width bar_offset = self.options.bar_offset # offset of the description in stacked-bar-charts: # stacked_bar_text_offset=self.options.stacked_bar_text_offset text_offset = self.options.text_offset # prevents ugly aliasing effects between pie chart segments by overlapping segment_overlap = self.options.segment_overlap # get font font = self.options.font font_size = self.options.font_size font_color = self.options.font_color # get rotation rotate = self.options.rotate pie_radius = self.options.pie_radius stroke_width = self.options.stroke_width if charttype == "bar": ######### ###BAR### ######### # iterate all values, use offset to draw the bars in different places offset = 0 color = 0 # Normalize the bars to the largest value try: value_max = max(values) except ValueError: value_max = 0.0 for x in range(len(values)): orig_values.append(values[x]) values[x] = (values[x]/value_max) * bar_height # Draw Single bars with their shadows for value in values: # draw drop shadow, if necessary if draw_blur: # Create shadow element shadow = inkex.etree.Element(inkex.addNS("rect", "svg")) # Set chart position to center of document. Make it horizontal or vertical if not rotate: shadow.set('x', str(width/2 + offset + 1)) shadow.set('y', str(height/2 - int(value) + 1)) shadow.set("width", str(bar_width)) shadow.set("height", str(int(value))) else: shadow.set('y', str(width/2 + offset + 1)) shadow.set('x', str(height/2 + 1)) shadow.set("height", str(bar_width)) shadow.set("width", str(int(value))) # Set shadow blur (connect to filter object in xml path) shadow.set("style", "filter:url(#filter)") # Create rectangle element rect = inkex.etree.Element(inkex.addNS('rect', 'svg')) # Set chart position to center of document. if not rotate: rect.set('x', str(width/2 + offset)) rect.set('y', str(height/2 - int(value))) rect.set("width", str(bar_width)) rect.set("height", str(int(value))) else: rect.set('y', str(width/2 + offset)) rect.set('x', str(height/2)) rect.set("height", str(bar_width)) rect.set("width", str(int(value))) rect.set("style", "fill:" + colors[color % color_count]) # If keys are given, create text elements if keys_present: text = inkex.etree.Element(inkex.addNS('text', 'svg')) if not rotate: #=vertical text.set("transform", "matrix(0,-1,1,0,0,0)") #y after rotation: text.set("x", "-" + str(height/2 + text_offset)) #x after rotation: text.set("y", str(width/2 + offset + bar_width/2 + font_size/3)) else: #=horizontal text.set("y", str(width/2 + offset + bar_width/2 + font_size/3)) text.set("x", str(height/2 - text_offset)) text.set("style", "font-size:" + str(font_size)\ + "px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:"\ + font + ";-inkscape-font-specification:Bitstream Charter;text-align:end;text-anchor:end;fill:"\ + font_color) text.text = keys[cnt] # Increase Offset and Color #offset=offset+bar_width+bar_offset color = (color + 1) % 8 # Connect elements together. if draw_blur: layer.append(shadow) layer.append(rect) if keys_present: layer.append(text) if show_values: vtext = inkex.etree.Element(inkex.addNS('text', 'svg')) if not rotate: #=vertical vtext.set("transform", "matrix(0,-1,1,0,0,0)") #y after rotation: vtext.set("x", "-"+str(height/2+text_offset-value-text_offset-text_offset)) #x after rotation: vtext.set("y", str(width/2+offset+bar_width/2+font_size/3)) else: #=horizontal vtext.set("y", str(width/2+offset+bar_width/2+font_size/3)) vtext.set("x", str(height/2-text_offset+value+text_offset+text_offset)) vtext.set("style", "font-size:"+str(font_size)\ + "px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:"\ + font + ";-inkscape-font-specification:Bitstream Charter;text-align:start;text-anchor:start;fill:"\ + font_color) vtext.text = str(int(orig_values[cnt])) layer.append(vtext) cnt = cnt+1 offset = offset + bar_width + bar_offset # set x position for heading line if not rotate: heading_x = width/2 # TODO: adjust else: heading_x = width/2 # TODO: adjust elif charttype == "pie": ######### ###PIE### ######### # Iterate all values to draw the different slices color = 0 # Create the shadow first (if it should be created): if draw_blur: shadow = inkex.etree.Element(inkex.addNS("circle", "svg")) shadow.set('cx', str(width/2)) shadow.set('cy', str(height/2)) shadow.set('r', str(pie_radius)) shadow.set("style", "filter:url(#filter);fill:#000000") layer.append(shadow) # Add a grey background circle with a light stroke background = inkex.etree.Element(inkex.addNS("circle", "svg")) background.set("cx", str(width/2)) background.set("cy", str(height/2)) background.set("r", str(pie_radius)) background.set("style", "stroke:#ececec;fill:#f9f9f9") layer.append(background) #create value sum in order to divide the slices try: valuesum = sum(values) except ValueError: valuesum = 0 if pie_abs: valuesum = 100 num_values = len(values) # Set an offsetangle offset = 0 # Draw single slices for i in range(num_values): value = values[i] # Calculate the PI-angles for start and end angle = (2*3.141592) / valuesum * float(value) start = offset end = offset + angle # proper overlapping if segment_overlap: if i != num_values-1: end += 0.09 # add a 5° overlap if i == 0: start -= 0.09 # let the first element overlap into the other direction #then add the slice pieslice = inkex.etree.Element(inkex.addNS("path", "svg")) pieslice.set(inkex.addNS('type', 'sodipodi'), 'arc') pieslice.set(inkex.addNS('cx', 'sodipodi'), str(width/2)) pieslice.set(inkex.addNS('cy', 'sodipodi'), str(height/2)) pieslice.set(inkex.addNS('rx', 'sodipodi'), str(pie_radius)) pieslice.set(inkex.addNS('ry', 'sodipodi'), str(pie_radius)) pieslice.set(inkex.addNS('start', 'sodipodi'), str(start)) pieslice.set(inkex.addNS('end', 'sodipodi'), str(end)) pieslice.set("style", "fill:"+ colors[color % color_count] + ";stroke:none;fill-opacity:1") #If text is given, draw short paths and add the text if keys_present: path = inkex.etree.Element(inkex.addNS("path", "svg")) path.set("d", "m " + str((width/2) + pie_radius * math.cos(angle/2 + offset)) + "," + str((height/2) + pie_radius * math.sin(angle/2 + offset)) + " " + str((text_offset - 2) * math.cos(angle/2 + offset)) + "," + str((text_offset - 2) * math.sin(angle/2 + offset))) path.set("style", "fill:none;stroke:" + font_color + ";stroke-width:" + str(stroke_width) + "px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1") layer.append(path) text = inkex.etree.Element(inkex.addNS('text', 'svg')) text.set("x", str((width/2) + (pie_radius + text_offset) * math.cos(angle/2 + offset))) text.set("y", str((height/2) + (pie_radius + text_offset) * math.sin(angle/2 + offset) + font_size/3)) textstyle = "font-size:" + str(font_size) \ + "px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:" \ + font + ";-inkscape-font-specification:Bitstream Charter;fill:" + font_color # check if it is right or left of the Pie if math.cos(angle/2 + offset) > 0: text.set("style", textstyle) else: text.set("style", textstyle + ";text-align:end;text-anchor:end") text.text = keys[cnt] if show_values: text.text = text.text + "(" + str(values[cnt]) if pie_abs: text.text = text.text + " %" text.text = text.text + ")" cnt = cnt + 1 layer.append(text) # increase the rotation-offset and the colorcycle-position offset = offset + angle color = (color + 1) % 8 # append the objects to the extension-layer layer.append(pieslice) # set x position for heading line heading_x = width/2 - pie_radius # TODO: adjust elif charttype == "stbar": ################# ###STACKED BAR### ################# # Iterate over all values to draw the different slices color = 0 #create value sum in order to divide the bars try: valuesum = sum(values) except ValueError: valuesum = 0.0 for value in values: valuesum = valuesum + float(value) # Init offset offset = 0 if draw_blur: # Create rectangle element shadow = inkex.etree.Element(inkex.addNS("rect", "svg")) # Set chart position to center of document. if not rotate: shadow.set('x', str(width/2)) shadow.set('y', str(height/2 - bar_height/2)) else: shadow.set('x', str(width/2)) shadow.set('y', str(height/2)) # Set rectangle properties if not rotate: shadow.set("width", str(bar_width)) shadow.set("height", str(bar_height/2)) else: shadow.set("width",str(bar_height/2)) shadow.set("height", str(bar_width)) # Set shadow blur (connect to filter object in xml path) shadow.set("style", "filter:url(#filter)") layer.append(shadow) i = 0 # Draw Single bars for value in values: # Calculate the individual heights normalized on 100units normedvalue = (bar_height / valuesum) * float(value) # Create rectangle element rect = inkex.etree.Element(inkex.addNS('rect', 'svg')) # Set chart position to center of document. if not rotate: rect.set('x', str(width / 2 )) rect.set('y', str(height / 2 - offset - normedvalue)) else: rect.set('x', str(width / 2 + offset )) rect.set('y', str(height / 2 )) # Set rectangle properties if not rotate: rect.set("width", str(bar_width)) rect.set("height", str(normedvalue)) else: rect.set("height", str(bar_width)) rect.set("width", str(normedvalue)) rect.set("style", "fill:" + colors[color % color_count]) #If text is given, draw short paths and add the text # TODO: apply overlap workaround for visible gaps in between if keys_present: if not rotate: path = inkex.etree.Element(inkex.addNS("path", "svg")) path.set("d","m " + str((width + bar_width)/2) + "," + str(height/2 - offset - (normedvalue / 2)) + " " + str(bar_width/2 + text_offset) + ",0") path.set("style", "fill:none;stroke:" + font_color + ";stroke-width:" + str(stroke_width) + "px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1") layer.append(path) text = inkex.etree.Element(inkex.addNS('text', 'svg')) text.set("x", str(width/2 + bar_width + text_offset + 1)) text.set("y", str(height/ 2 - offset + font_size/3 - (normedvalue/2))) text.set("style", "font-size:" + str(font_size) + "px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:" + font + ";-inkscape-font-specification:Bitstream Charter;fill:" + font_color) text.text = keys[cnt] cnt = cnt + 1 layer.append(text) else: path = inkex.etree.Element(inkex.addNS("path", "svg")) path.set("d","m " + str((width)/2 + offset + normedvalue/2) + "," + str(height / 2 + bar_width/2) + " 0," + str(bar_width/2 + (font_size * i) + text_offset)) #line path.set("style", "fill:none;stroke:" + font_color + ";stroke-width:" + str(stroke_width) + "px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1") layer.append(path) text = inkex.etree.Element(inkex.addNS('text', 'svg')) text.set("x", str((width)/2 + offset + normedvalue/2 - font_size/3)) text.set("y", str((height/2) + bar_width + (font_size * (i + 1)) + text_offset)) text.set("style", "font-size:" + str(font_size) + "px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:" + font + ";-inkscape-font-specification:Bitstream Charter;fill:" + font_color) text.text = keys[color] layer.append(text) # Increase Offset and Color offset = offset + normedvalue color = (color + 1) % 8 # Draw rectangle layer.append(rect) i += 1 # set x position for heading line if not rotate: heading_x = width/2 + offset + normedvalue # TODO: adjust else: heading_x = width/2 + offset + normedvalue # TODO: adjust if headings and input_type == "\"file\"": headingtext = inkex.etree.Element(inkex.addNS('text', 'svg')) headingtext.set("y", str(height/2 + heading_offset)) headingtext.set("x", str(heading_x)) headingtext.set("style", "font-size:" + str(font_size + 4)\ + "px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-family:"\ + font + ";-inkscape-font-specification:Bitstream Charter;text-align:end;text-anchor:end;fill:"\ + font_color) headingtext.text = heading layer.append(headingtext) def getUnittouu(self, param): try: return inkex.unittouu(param) except AttributeError: return self.unittouu(param) if __name__ == '__main__': # Create effect instance and apply it. effect = NiceChart() effect.affect()