1
0
Fork 0
inkscape/share/extensions/nicechart.py
Daniel Baumann 02d935e272
Adding upstream version 1.4.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-22 23:40:13 +02:00

653 lines
22 KiB
Python
Executable file

#!/usr/bin/env python3
# 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.
#
# pylint: disable=attribute-defined-outside-init
# 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)
# 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.
# See tests/data/nicechart_01.csv for example data
import re
import csv
import math
from argparse import ArgumentTypeError
import inkex
from inkex.utils import filename_arg
from inkex import Filter, TextElement, Circle, Rectangle
from inkex.paths import Move, line
from inkex.localization import inkex_gettext as _
# 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",
],
}
class NiceChart(inkex.GenerateExtension):
"""
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
"""
container_layer = True
@property
def container_label(self):
"""Layer title/label"""
return _("Chart-Layer: {}").format(self.options.what)
def add_arguments(self, pars):
pars.add_argument("--tab")
pars.add_argument("--encoding", default="utf-8")
pars.add_argument(
"-w",
"--what",
default="apples:3,bananas:5,oranges:10,pears:4",
help="Chart Values",
)
pars.add_argument(
"-t",
"--type",
type=self.arg_method("render"),
default=self.render_bar,
help="Chart Type",
)
pars.add_argument(
"-b", "--blur", type=inkex.Boolean, default=False, help="Blur Type"
)
pars.add_argument("-f", "--filename", type=filename_arg, help="Name of File")
pars.add_argument("-i", "--input_type", default="file", help="Chart Type")
pars.add_argument("-d", "--delimiter", default=";", help="delimiter")
pars.add_argument("-c", "--colors", default="default", help="color-scheme")
pars.add_argument("--colors_override", help="color-scheme-override")
pars.add_argument(
"--reverse_colors",
type=inkex.Boolean,
default=False,
help="reverse color-scheme",
)
pars.add_argument(
"-k", "--col_key", type=int, default=0, help="column that contains the keys"
)
pars.add_argument(
"-v",
"--col_val",
type=int,
default=1,
help="column that contains the values",
)
pars.add_argument(
"--headings",
type=inkex.Boolean,
default=False,
help="first line of the CSV file consists of headings for the columns",
)
pars.add_argument(
"-r",
"--rotate",
type=inkex.Boolean,
default=False,
help="Draw barchart horizontally",
)
pars.add_argument(
"-W", "--bar-width", type=int, default=10, help="width of bars"
)
pars.add_argument(
"-p", "--pie-radius", type=int, default=100, help="radius of pie-charts"
)
pars.add_argument(
"-H", "--bar-height", type=int, default=100, help="height of bars"
)
pars.add_argument(
"-O", "--bar-offset", type=int, default=5, help="distance between bars"
)
pars.add_argument("--stroke-width", type=float, default=1.0)
pars.add_argument(
"-o",
"--text-offset",
type=int,
default=5,
help="distance between bar and descriptions",
)
pars.add_argument(
"--heading-offset",
type=int,
default=50,
help="distance between chart and chart title",
)
pars.add_argument(
"--segment-overlap",
type=inkex.Boolean,
default=False,
help="Remove aliasing effects by letting pie chart segments overlap",
)
pars.add_argument(
"-F", "--font", default="sans-serif", help="font of description"
)
pars.add_argument(
"-S", "--font-size", type=int, default=10, help="font size of description"
)
pars.add_argument(
"-C", "--font-color", default="#000000", help="font color of description"
)
pars.add_argument(
"-V",
"--show_values",
type=inkex.Boolean,
default=False,
help="Show values in chart",
)
def get_data(self):
"""Process the data"""
col_key = self.options.col_key
col_val = self.options.col_val
def process_value(val):
"""Confirm the values from files or direct"""
val = float(val)
if val < 0:
raise inkex.AbortExtension(
_("Negative values are currently not supported!")
)
return val
if self.options.input_type == "file":
if self.options.filename is None:
raise inkex.AbortExtension(_("Filename not specified!"))
# Future: use encoding when opening the file here (if ever needed)
with open(self.options.filename, "r") as fhl:
reader = csv.reader(fhl, delimiter=self.options.delimiter)
title = col_val
if self.options.headings:
header = next(reader)
title = header[col_val]
values = [
(line[col_key], process_value(line[col_val])) for line in reader
]
return (title,) + tuple(zip(*values))
elif self.options.input_type == "direct_input":
(keys, values) = zip(
*[l.split(":", 1) for l in self.options.what.split(",")]
)
return ("Direct Input", keys, [process_value(val) for val in values])
raise inkex.AbortExtension(_("Unknown input type"))
def get_blur(self):
"""Add blur to the svg and return if needed"""
if self.options.blur:
defs = self.svg.defs
# Create new Filter
filt = defs.add(Filter(height="3", width="3", x="-0.5", y="-0.5"))
# Append Gaussian Blur to that Filter
filt.add_primitive("feGaussianBlur", stdDeviation="1.1")
return inkex.Style(filter=filt.get_id(as_url=2))
return inkex.Style()
def get_color(self):
"""Get the next available color"""
if not hasattr(self, "_colors"):
# Generate list of available colours
if self.options.colors_override:
colors = self.options.colors_override.strip()
else:
colors = self.options.colors
if colors[0].isalpha():
colors = COLOUR_TABLE.get(colors.lower(), COLOUR_TABLE["red"])
else:
colors = re.findall("(#[0-9a-fA-F]{6})", colors)
# to be sure we create a fallback:
if not colors:
colors = COLOUR_TABLE["red"]
if self.options.reverse_colors:
colors.reverse()
# Cache the list of colours for later use
self._colors = colors
self._color_index = 0
color = self._colors[self._color_index]
# Increase index to the next available color
self._color_index = (self._color_index + 1) % len(self._colors)
return color
def generate(self):
"""Generates a nice looking chart into SVG document."""
# Process the data from a file or text box
(self.title, keys, values) = self.get_data()
if not values:
raise inkex.AbortExtension(_("No data to render into a chart."))
# Get the page attributes:
self.width = self.svg.unittouu(self.svg.get("width"))
self.height = self.svg.unittouu(self.svg.attrib["height"])
self.fontoff = float(self.options.font_size) / 3
# Check if a drop shadow should be drawn:
self.blur = self.get_blur()
# Draw the right type of chart
for elem in self.options.type(keys, values):
yield elem
def draw_header(self, heading_x):
"""Draw an optional header text"""
if self.options.headings and self.title:
headingtext = self.draw_text(self.title, 4, anchor="end")
headingtext.set("y", str(self.height / 2 + self.options.heading_offset))
headingtext.set("x", str(heading_x))
return headingtext
return None
def render_bar(self, keys, values):
"""Draw bar chart"""
bar_height = self.options.bar_height
bar_width = self.options.bar_width
bar_offset = self.options.bar_offset
# Normalize the bars to the largest value
value_max = max(list(values) + [0.0])
# Draw Single bars with their shadows
for cnt, value in enumerate(values):
# Draw each bar a set amount offset
offset = cnt * (bar_width + bar_offset)
bar_value = (value / value_max) * bar_height
# Calculate the location of the bar
x = self.width / 2 + offset
y = self.height / 2 - int(bar_value)
width = bar_width
height = int(bar_value)
if self.options.rotate:
# Rotate the bar and align to the left
x, y, width, height = y, x, height, width
x += width
for elem in self.draw_rectangle(x, y, width, height):
yield elem
# If keys are given, create text elements
if keys:
text = self.draw_text(keys[cnt], anchor="end")
if not self.options.rotate: # =vertical
text.set("transform", "rotate(-90)")
# y after rotation:
text.set("x", "-" + str(self.height / 2 + self.options.text_offset))
# x after rotation:
text.set(
"y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)
)
else: # =horizontal
text.set(
"y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)
)
text.set("x", str(self.height / 2 - self.options.text_offset))
yield text
if self.options.show_values:
vtext = self.draw_text(int(value))
if not self.options.rotate: # =vertical
vtext.set("transform", "rotate(-90)")
# y after rotation:
vtext.set(
"x",
"-" + str(self.height / 2 + value - self.options.text_offset),
)
# x after rotation:
vtext.set(
"y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)
)
else: # =horizontal
vtext.set(
"y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)
)
vtext.set(
"x", str(self.height / 2 + value + self.options.text_offset)
)
yield vtext
yield self.draw_header(self.width / 2)
def draw_rectangle(self, x, y, width, height):
"""Draw a rectangle bar with optional shadow"""
if self.blur:
shadow = Rectangle(
x=str(x + 1), y=str(y + 1), width=str(width), height=str(height)
)
shadow.style = self.blur
yield shadow
rect = Rectangle(x=str(x), y=str(y), width=str(width), height=str(height))
rect.set("style", "fill:" + self.get_color())
yield rect
def draw_text(self, text, add_size=0, anchor="start", **kwargs):
"""Draw a textual label"""
vtext = TextElement(**kwargs)
vtext.style = {
"fill": self.options.font_color,
"font-family": self.options.font,
"font-size": str(self.options.font_size + add_size) + "px",
"font-style": "normal",
"font-variant": "normal",
"font-weight": "normal",
"font-stretch": "normal",
"-inkscape-font-specification": "Bitstream Charter",
"text-align": anchor,
"text-anchor": anchor,
}
vtext.text = str(text)
return vtext
def render_pie_abs(self, keys, values):
"""Draw a pie chart, with absolute values"""
# pie_abs = True
for elem in self.render_pie(keys, values, True):
yield elem
def render_pie(self, keys, values, pie_abs=False):
"""Draw pie chart"""
pie_radius = self.options.pie_radius
# Iterate all values to draw the different slices
color = 0
x = float(self.width) / 2
y = float(self.height) / 2
# Create the shadow first (if it should be created):
if self.blur:
shadow = Circle(cx=str(x), cy=str(y))
shadow.set("r", str(pie_radius))
shadow.style = self.blur + inkex.Style(fill="#000000")
yield shadow
# Add a grey background circle with a light stroke
background = Circle(cx=str(x), cy=str(y))
background.set("r", str(pie_radius))
background.set("style", "stroke:#ececec;fill:#f9f9f9")
yield background
# create value sum in order to divide the slices
try:
valuesum = sum(values)
except ValueError:
valuesum = 0
if pie_abs:
valuesum = 100
# Set an offsetangle
offset = 0
# Draw single slices
for cnt, value in enumerate(values):
# Calculate the PI-angles for start and end
angle = (2 * 3.141592) / valuesum * float(value)
start = offset
end = offset + angle
# proper overlapping
if self.options.segment_overlap:
if cnt != len(values) - 1:
end += 0.09 # add a 5° overlap
if cnt == 0:
start -= (
0.09 # let the first element overlap into the other direction
)
# then add the slice
pieslice = inkex.PathElement.arc(
[x, y], pie_radius, pie_radius, start=start, end=end
)
pieslice.set(
"style", "fill:" + self.get_color() + ";stroke:none;fill-opacity:1"
)
ang = angle / 2 + offset
# If text is given, draw short paths and add the text
if keys:
elem = inkex.PathElement()
elem.path = [
Move(
(self.width / 2) + pie_radius * math.cos(ang),
(self.height / 2) + pie_radius * math.sin(ang),
),
line(
(self.options.text_offset - 2) * math.cos(ang),
(self.options.text_offset - 2) * math.sin(ang),
),
]
elem.style = {
"fill": "none",
"stroke": self.options.font_color,
"stroke-width": self.options.stroke_width,
"stroke-linecap": "butt",
}
yield elem
label = keys[cnt]
if self.options.show_values:
label += " ({}{})".format(str(value), ("", "%")[pie_abs])
# check if it is right or left of the Pie
anchor = "start" if math.cos(ang) > 0 else "end"
text = self.draw_text(label, anchor=anchor)
off = pie_radius + self.options.text_offset
text.set("x", (self.width / 2) + off * math.cos(ang))
text.set("y", (self.height / 2) + off * math.sin(ang) + self.fontoff)
yield text
# increase the rotation-offset and the colorcycle-position
offset = offset + angle
color = (color + 1) % 8
# append the objects to the extension-layer
yield pieslice
yield self.draw_header(self.width / 2 - pie_radius)
def render_stbar(self, keys, values):
"""Draw stacked bar chart"""
# 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
# Init offset
offset = 0
width = self.options.bar_width
height = self.options.bar_height
x = self.width / 2
y = self.height / 2
if self.blur:
if self.options.rotate:
width, height = height, width
shy = y
else:
shy = str(y - self.options.bar_height)
# Create rectangle element
shadow = Rectangle(
x=str(x),
y=str(shy),
width=str(width),
height=str(height),
)
# Set shadow blur (connect to filter object in xml path)
shadow.style = self.blur
yield shadow
# Draw Single bars
for cnt, value in enumerate(values):
# Calculate the individual heights normalized on 100units
normedvalue = (self.options.bar_height / valuesum) * float(value)
# Create rectangle element
rect = Rectangle()
# Set chart position to center of document.
if not self.options.rotate:
rect.set("x", str(self.width / 2))
rect.set("y", str(self.height / 2 - offset - normedvalue))
rect.set("width", str(self.options.bar_width))
rect.set("height", str(normedvalue))
else:
rect.set("x", str(self.width / 2 + offset))
rect.set("y", str(self.height / 2))
rect.set("height", str(self.options.bar_width))
rect.set("width", str(normedvalue))
rect.set("style", "fill:" + self.get_color())
# If text is given, draw short paths and add the text
# TODO: apply overlap workaround for visible gaps in between
if keys:
if not self.options.rotate:
x1 = (self.width + self.options.bar_width) / 2
y1 = y - offset - (normedvalue / 2)
x2 = self.options.bar_width / 2 + self.options.text_offset
y2 = 0
txt = (
self.width / 2
+ self.options.bar_width
+ self.options.text_offset
+ 1
)
tyt = y - offset + self.fontoff - (normedvalue / 2)
else:
x1 = x + offset + normedvalue / 2
y1 = y + self.options.bar_width / 2
x2 = 0
y2 = (
self.options.bar_width / 2
+ (self.options.font_size * cnt)
+ self.options.text_offset
)
txt = x + offset + normedvalue / 2 - self.fontoff
tyt = (
(y)
+ self.options.bar_width
+ (self.options.font_size * (cnt + 1))
+ self.options.text_offset
)
elem = inkex.PathElement()
elem.path = [Move(x1, y1), line(x2, y2)]
elem.style = {
"fill": "none",
"stroke": self.options.font_color,
"stroke-width": self.options.stroke_width,
"stroke-linecap": "butt",
}
yield elem
yield self.draw_text(keys[cnt], x=str(txt), y=str(tyt))
# Increase Offset and Color
offset = offset + normedvalue
color = (color + 1) % 8
# Draw rectangle
yield rect
yield self.draw_header(self.width / 2 + offset + normedvalue)
if __name__ == "__main__":
NiceChart().run()