1# -*- coding: utf-8 -*- 2# This file is part of pygal 3# 4# A python svg graph plotting library 5# Copyright © 2012-2016 Kozea 6# 7# This library is free software: you can redistribute it and/or modify it under 8# the terms of the GNU Lesser General Public License as published by the Free 9# Software Foundation, either version 3 of the License, or (at your option) any 10# later version. 11# 12# This library is distributed in the hope that it will be useful, but WITHOUT 13# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 14# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more 15# details. 16# 17# You should have received a copy of the GNU Lesser General Public License 18# along with pygal. If not, see <http://www.gnu.org/licenses/>. 19"""Svg helper""" 20 21from __future__ import division 22 23import io 24import json 25import os 26from datetime import date, datetime 27from math import pi 28from numbers import Number 29 30from pygal import __version__ 31from pygal._compat import quote_plus, to_str, u 32from pygal.etree import etree 33from pygal.util import ( 34 coord_abs_project, coord_diff, coord_dual, coord_format, coord_project, 35 minify_css, template) 36 37nearly_2pi = 2 * pi - .00001 38 39 40class Svg(object): 41 42 """Svg related methods""" 43 44 ns = 'http://www.w3.org/2000/svg' 45 xlink_ns = 'http://www.w3.org/1999/xlink' 46 47 def __init__(self, graph): 48 """Create the svg helper with the chart instance""" 49 self.graph = graph 50 if not graph.no_prefix: 51 self.id = '#chart-%s ' % graph.uuid 52 else: 53 self.id = '' 54 self.processing_instructions = [] 55 if etree.lxml: 56 attrs = { 57 'nsmap': { 58 None: self.ns, 59 'xlink': self.xlink_ns 60 } 61 } 62 else: 63 attrs = { 64 'xmlns': self.ns 65 } 66 if hasattr(etree, 'register_namespace'): 67 etree.register_namespace('xlink', self.xlink_ns) 68 else: 69 etree._namespace_map[self.xlink_ns] = 'xlink' 70 71 self.root = etree.Element('svg', **attrs) 72 self.root.attrib['id'] = self.id.lstrip('#').rstrip() 73 if graph.classes: 74 self.root.attrib['class'] = ' '.join(graph.classes) 75 self.root.append( 76 etree.Comment(u( 77 'Generated with pygal %s (%s) ©Kozea 2012-2016 on %s' % ( 78 __version__, 79 'lxml' if etree.lxml else 'etree', 80 date.today().isoformat())))) 81 self.root.append(etree.Comment(u('http://pygal.org'))) 82 self.root.append(etree.Comment(u('http://github.com/Kozea/pygal'))) 83 self.defs = self.node(tag='defs') 84 self.title = self.node(tag='title') 85 self.title.text = graph.title or 'Pygal' 86 87 for def_ in self.graph.defs: 88 self.defs.append(etree.fromstring(def_)) 89 90 def add_styles(self): 91 """Add the css to the svg""" 92 colors = self.graph.style.get_colors(self.id, self.graph._order) 93 strokes = self.get_strokes() 94 all_css = [] 95 auto_css = ['file://base.css'] 96 97 if self.graph.style._google_fonts: 98 auto_css.append( 99 '//fonts.googleapis.com/css?family=%s' % quote_plus( 100 '|'.join(self.graph.style._google_fonts)) 101 ) 102 103 for css in auto_css + list(self.graph.css): 104 css_text = None 105 if css.startswith('inline:'): 106 css_text = css[len('inline:'):] 107 elif css.startswith('file://'): 108 css = css[len('file://'):] 109 110 if not os.path.exists(css): 111 css = os.path.join( 112 os.path.dirname(__file__), 'css', css) 113 114 with io.open(css, encoding='utf-8') as f: 115 css_text = template( 116 f.read(), 117 style=self.graph.style, 118 colors=colors, 119 strokes=strokes, 120 id=self.id) 121 122 if css_text is not None: 123 if not self.graph.pretty_print: 124 css_text = minify_css(css_text) 125 all_css.append(css_text) 126 else: 127 if css.startswith('//') and self.graph.force_uri_protocol: 128 css = '%s:%s' % (self.graph.force_uri_protocol, css) 129 self.processing_instructions.append( 130 etree.PI( 131 u('xml-stylesheet'), u('href="%s"' % css))) 132 self.node( 133 self.defs, 'style', type='text/css').text = '\n'.join(all_css) 134 135 def add_scripts(self): 136 """Add the js to the svg""" 137 common_script = self.node(self.defs, 'script', type='text/javascript') 138 139 def get_js_dict(): 140 return dict( 141 (k, getattr(self.graph.state, k)) 142 for k in dir(self.graph.config) 143 if not k.startswith('_') and hasattr(self.graph.state, k) and 144 not hasattr(getattr(self.graph.state, k), '__call__')) 145 146 def json_default(o): 147 if isinstance(o, (datetime, date)): 148 return o.isoformat() 149 if hasattr(o, 'to_dict'): 150 return o.to_dict() 151 return json.JSONEncoder().default(o) 152 153 dct = get_js_dict() 154 # Config adds 155 dct['legends'] = [ 156 l.get('title') if isinstance(l, dict) else l 157 for l in self.graph._legends + self.graph._secondary_legends] 158 159 common_js = 'window.pygal = window.pygal || {};' 160 common_js += 'window.pygal.config = window.pygal.config || {};' 161 if self.graph.no_prefix: 162 common_js += 'window.pygal.config = ' 163 else: 164 common_js += 'window.pygal.config[%r] = ' % self.graph.uuid 165 166 common_script.text = common_js + json.dumps(dct, default=json_default) 167 168 for js in self.graph.js: 169 if js.startswith('file://'): 170 script = self.node(self.defs, 'script', type='text/javascript') 171 with io.open(js[len('file://'):], encoding='utf-8') as f: 172 script.text = f.read() 173 else: 174 if js.startswith('//') and self.graph.force_uri_protocol: 175 js = '%s:%s' % (self.graph.force_uri_protocol, js) 176 self.node(self.defs, 'script', type='text/javascript', href=js) 177 178 def node(self, parent=None, tag='g', attrib=None, **extras): 179 """Make a new svg node""" 180 if parent is None: 181 parent = self.root 182 attrib = attrib or {} 183 attrib.update(extras) 184 185 def in_attrib_and_number(key): 186 return key in attrib and isinstance(attrib[key], Number) 187 188 for pos, dim in (('x', 'width'), ('y', 'height')): 189 if in_attrib_and_number(dim) and attrib[dim] < 0: 190 attrib[dim] = - attrib[dim] 191 if in_attrib_and_number(pos): 192 attrib[pos] = attrib[pos] - attrib[dim] 193 194 for key, value in dict(attrib).items(): 195 if value is None: 196 del attrib[key] 197 198 attrib[key] = to_str(value) 199 if key.endswith('_'): 200 attrib[key.rstrip('_')] = attrib[key] 201 del attrib[key] 202 elif key == 'href': 203 attrib[etree.QName( 204 'http://www.w3.org/1999/xlink', key)] = attrib[key] 205 del attrib[key] 206 return etree.SubElement(parent, tag, attrib) 207 208 def transposable_node(self, parent=None, tag='g', attrib=None, **extras): 209 """Make a new svg node which can be transposed if horizontal""" 210 if self.graph.horizontal: 211 for key1, key2 in (('x', 'y'), ('width', 'height'), ('cx', 'cy')): 212 attr1 = extras.get(key1, None) 213 attr2 = extras.get(key2, None) 214 if attr2: 215 extras[key1] = attr2 216 elif attr1: 217 del extras[key1] 218 if attr1: 219 extras[key2] = attr1 220 elif attr2: 221 del extras[key2] 222 return self.node(parent, tag, attrib, **extras) 223 224 def serie(self, serie): 225 """Make serie node""" 226 return dict( 227 plot=self.node( 228 self.graph.nodes['plot'], 229 class_='series serie-%d color-%d' % ( 230 serie.index, serie.index)), 231 overlay=self.node( 232 self.graph.nodes['overlay'], 233 class_='series serie-%d color-%d' % ( 234 serie.index, serie.index)), 235 text_overlay=self.node( 236 self.graph.nodes['text_overlay'], 237 class_='series serie-%d color-%d' % ( 238 serie.index, serie.index))) 239 240 def line(self, node, coords, close=False, **kwargs): 241 """Draw a svg line""" 242 line_len = len(coords) 243 if len([c for c in coords if c[1] is not None]) < 2: 244 return 245 root = 'M%s L%s Z' if close else 'M%s L%s' 246 origin_index = 0 247 while origin_index < line_len and None in coords[origin_index]: 248 origin_index += 1 249 if origin_index == line_len: 250 return 251 if self.graph.horizontal: 252 coord_format = lambda xy: '%f %f' % (xy[1], xy[0]) 253 else: 254 coord_format = lambda xy: '%f %f' % xy 255 256 origin = coord_format(coords[origin_index]) 257 line = ' '.join([coord_format(c) 258 for c in coords[origin_index + 1:] 259 if None not in c]) 260 return self.node( 261 node, 'path', d=root % (origin, line), **kwargs) 262 263 def slice( 264 self, serie_node, node, radius, small_radius, 265 angle, start_angle, center, val, i, metadata): 266 """Draw a pie slice""" 267 if angle == 2 * pi: 268 angle = nearly_2pi 269 270 if angle > 0: 271 to = [coord_abs_project(center, radius, start_angle), 272 coord_abs_project(center, radius, start_angle + angle), 273 coord_abs_project(center, small_radius, start_angle + angle), 274 coord_abs_project(center, small_radius, start_angle)] 275 rv = self.node( 276 node, 'path', 277 d='M%s A%s 0 %d 1 %s L%s A%s 0 %d 0 %s z' % ( 278 to[0], 279 coord_dual(radius), int(angle > pi), to[1], 280 to[2], 281 coord_dual(small_radius), int(angle > pi), to[3]), 282 class_='slice reactive tooltip-trigger') 283 else: 284 rv = None 285 x, y = coord_diff(center, coord_project( 286 (radius + small_radius) / 2, start_angle + angle / 2)) 287 288 self.graph._tooltip_data( 289 node, val, x, y, "centered", 290 self.graph._x_labels and self.graph._x_labels[i][0]) 291 if angle >= 0.3: # 0.3 radians is about 17 degrees 292 self.graph._static_value(serie_node, val, x, y, metadata) 293 return rv 294 295 def gauge_background( 296 self, serie_node, start_angle, center, radius, small_radius, 297 end_angle, half_pie, max_value): 298 299 if end_angle == 2 * pi: 300 end_angle = nearly_2pi 301 302 to_shade = [ 303 coord_abs_project(center, radius, start_angle), 304 coord_abs_project(center, radius, end_angle), 305 coord_abs_project(center, small_radius, end_angle), 306 coord_abs_project(center, small_radius, start_angle)] 307 308 self.node( 309 serie_node['plot'], 'path', 310 d='M%s A%s 0 1 1 %s L%s A%s 0 1 0 %s z' % ( 311 to_shade[0], 312 coord_dual(radius), 313 to_shade[1], 314 to_shade[2], 315 coord_dual(small_radius), 316 to_shade[3]), 317 class_='gauge-background reactive') 318 319 if half_pie: 320 begin_end = [ 321 coord_diff( 322 center, 323 coord_project( 324 radius - (radius - small_radius) / 2, start_angle)), 325 coord_diff( 326 center, 327 coord_project( 328 radius - (radius - small_radius) / 2, end_angle))] 329 pos = 0 330 for i in begin_end: 331 self.node( 332 serie_node['plot'], 'text', 333 class_='y-{} bound reactive'.format(pos), 334 x=i[0], 335 y=i[1] + 10, 336 attrib={'text-anchor': 'middle'} 337 ).text = '{}'.format(0 if pos == 0 else max_value) 338 pos += 1 339 else: 340 middle_radius = .5 * (radius + small_radius) 341 # Correct text vertical alignment 342 middle_radius -= .1 * (radius - small_radius) 343 to_labels = [ 344 coord_abs_project( 345 center, middle_radius, 0), 346 coord_abs_project( 347 center, middle_radius, nearly_2pi) 348 ] 349 self.node( 350 self.defs, 'path', id='valuePath-%s%s' % center, 351 d='M%s A%s 0 1 1 %s' % ( 352 to_labels[0], 353 coord_dual(middle_radius), 354 to_labels[1] 355 )) 356 text_ = self.node( 357 serie_node['text_overlay'], 'text') 358 self.node( 359 text_, 'textPath', class_='max-value reactive', 360 attrib={ 361 'href': '#valuePath-%s%s' % center, 362 'startOffset': '99%', 363 'text-anchor': 'end' 364 } 365 ).text = max_value 366 367 def solid_gauge( 368 self, serie_node, node, radius, small_radius, 369 angle, start_angle, center, val, i, metadata, half_pie, end_angle, 370 max_value): 371 """Draw a solid gauge slice and background slice""" 372 if angle == 2 * pi: 373 angle = nearly_2pi 374 375 if angle > 0: 376 to = [coord_abs_project(center, radius, start_angle), 377 coord_abs_project(center, radius, start_angle + angle), 378 coord_abs_project(center, small_radius, start_angle + angle), 379 coord_abs_project(center, small_radius, start_angle)] 380 381 self.node( 382 node, 'path', 383 d='M%s A%s 0 %d 1 %s L%s A%s 0 %d 0 %s z' % ( 384 to[0], 385 coord_dual(radius), 386 int(angle > pi), 387 to[1], 388 to[2], 389 coord_dual(small_radius), 390 int(angle > pi), 391 to[3]), 392 class_='slice reactive tooltip-trigger') 393 else: 394 return 395 396 x, y = coord_diff(center, coord_project( 397 (radius + small_radius) / 2, start_angle + angle / 2)) 398 self.graph._static_value(serie_node, val, x, y, metadata, 'middle') 399 self.graph._tooltip_data( 400 node, val, x, y, "centered", 401 self.graph._x_labels and self.graph._x_labels[i][0]) 402 403 def confidence_interval(self, node, x, low, high, width=7): 404 if self.graph.horizontal: 405 fmt = lambda xy: '%f %f' % (xy[1], xy[0]) 406 else: 407 fmt = coord_format 408 409 shr = lambda xy: (xy[0] + width, xy[1]) 410 shl = lambda xy: (xy[0] - width, xy[1]) 411 412 top = (x, high) 413 bottom = (x, low) 414 415 ci = self.node(node, class_="ci") 416 417 self.node( 418 ci, 'path', d="M%s L%s M%s L%s M%s L%s L%s M%s L%s" % tuple( 419 map(fmt, ( 420 top, shr(top), top, shl(top), top, 421 bottom, shr(bottom), bottom, shl(bottom) 422 )) 423 ), class_='nofill reactive' 424 ) 425 426 def pre_render(self): 427 """Last things to do before rendering""" 428 self.add_styles() 429 self.add_scripts() 430 self.root.set( 431 'viewBox', '0 0 %d %d' % (self.graph.width, self.graph.height)) 432 if self.graph.explicit_size: 433 self.root.set('width', str(self.graph.width)) 434 self.root.set('height', str(self.graph.height)) 435 436 def draw_no_data(self): 437 """Write the no data text to the svg""" 438 no_data = self.node(self.graph.nodes['text_overlay'], 'text', 439 x=self.graph.view.width / 2, 440 y=self.graph.view.height / 2, 441 class_='no_data') 442 no_data.text = self.graph.no_data_text 443 444 def render(self, is_unicode=False, pretty_print=False): 445 """Last thing to do before rendering""" 446 for f in self.graph.xml_filters: 447 self.root = f(self.root) 448 args = { 449 'encoding': 'utf-8' 450 } 451 452 svg = b'' 453 if etree.lxml: 454 args['pretty_print'] = pretty_print 455 456 if not self.graph.disable_xml_declaration: 457 svg = b"<?xml version='1.0' encoding='utf-8'?>\n" 458 459 if not self.graph.disable_xml_declaration: 460 svg += b'\n'.join( 461 [etree.tostring( 462 pi, **args) 463 for pi in self.processing_instructions] 464 ) 465 466 svg += etree.tostring( 467 self.root, **args) 468 469 if self.graph.disable_xml_declaration or is_unicode: 470 svg = svg.decode('utf-8') 471 return svg 472 473 def get_strokes(self): 474 """Return a css snippet containing all stroke style options""" 475 def stroke_dict_to_css(stroke, i=None): 476 """Return a css style for the given option""" 477 css = ['%s.series%s {\n' % ( 478 self.id, '.serie-%d' % i if i is not None else '')] 479 for key in ( 480 'width', 'linejoin', 'linecap', 481 'dasharray', 'dashoffset'): 482 if stroke.get(key): 483 css.append(' stroke-%s: %s;\n' % ( 484 key, stroke[key])) 485 css.append('}') 486 return '\n'.join(css) 487 488 css = [] 489 if self.graph.stroke_style is not None: 490 css.append(stroke_dict_to_css(self.graph.stroke_style)) 491 for serie in self.graph.series: 492 if serie.stroke_style is not None: 493 css.append(stroke_dict_to_css(serie.stroke_style, serie.index)) 494 495 for secondary_serie in self.graph.secondary_series: 496 if secondary_serie.stroke_style is not None: 497 css.append(stroke_dict_to_css(secondary_serie.stroke_style, secondary_serie.index)) 498 return '\n'.join(css) 499