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