[MERGE] Remove the embedded pychart library, and use the online version
[odoo/odoo.git] /
1 # -*- coding: utf-8 -*-
2 #
3 # Copyright (C) 2000-2005 by Yasushi Saito (yasushi.saito@gmail.com)
4
5 # Jockey is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by the
7 # Free Software Foundation; either version 2, or (at your option) any
8 # later version.
9 #
10 # Jockey is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
13 # for more details.
14 #
15 import sys,string,re,math
16 from xml.dom.minidom import Document,Comment
17 import theme
18 import basecanvas
19 import version
20 from scaling import *
21
22 # Note we flip all y-coords and negate all angles because SVG's coord
23 # system is inverted wrt postscript/PDF - note it's not enough to
24 # scale(1,-1) since that turns text into mirror writing with wrong origin
25
26 _comment_p = 0                           # whether comment() writes output
27
28 # Convert a PyChart color object to an SVG rgb() value
29 def _svgcolor(color):                   # see color.py
30     return 'rgb(%d,%d,%d)' % tuple(map(lambda x:int(255*x),
31                                        [color.r,color.g,color.b]))
32
33 # Take an SVG 'style' attribute string like 'stroke:none;fill:black'
34 # and parse it into a dictionary like {'stroke' : 'none', 'fill' : 'black'}
35 def _parseStyleStr(s):
36     styledict = {}
37     if s :
38         # parses L -> R so later keys overwrite earlier ones
39         for keyval in s.split(';'):
40             l = keyval.strip().split(':')
41             if l and len(l) == 2: styledict[l[0].strip()] = l[1].strip()
42     return styledict
43
44 # Make an SVG style string from the dictionary described above
45 def _makeStyleStr(styledict):
46     s = ''
47     for key in styledict.keys():
48         s += "%s:%s;"%(key,styledict[key])
49     return s
50
51 def _protectCurrentChildren(elt):
52     # If elt is a group, check to see whether there are any non-comment
53     # children, and if so, create a new group to hold attributes
54     # to avoid affecting previous children.  Return either the current
55     # elt or the newly generated group.
56     if (elt.nodeName == 'g') :
57         for kid in elt.childNodes :
58             if kid.nodeType != Comment.nodeType:
59                 g = elt.ownerDocument.createElement('g')
60                 g.setAttribute('auto','')
61                 if _comment_p:
62                     g.appendChild(g.ownerDocument.createComment
63                                   ('auto-generated group'))
64                 elt.appendChild(g)
65                 elt = g
66                 break
67     return elt
68             
69 class T(basecanvas.T):
70     def __init__(self, fname):
71         basecanvas.T.__init__(self)
72         self.__out_fname = fname
73         self.__xmin, self.__xmax, self.__ymin, self.__ymax = 0,0,0,0
74         self.__doc = Document()
75         self.__doc.appendChild(self.__doc.createComment
76              ('Created by PyChart ' + version.version + ' ' + version.copyright))
77         self.__svg = self.__doc.createElement('svg') # the svg doc
78         self.__doc.appendChild(self.__svg)
79         self.__defs = self.__doc.createElement('defs') # for clip paths
80         self.__svg.appendChild(self.__defs)
81         self.__currElt = self.__svg
82         self.gsave()       # create top-level group for dflt styles
83         self._updateStyle(font_family = theme.default_font_family,
84                           font_size = theme.default_font_size,
85                           font_style = 'normal',
86                           font_weight = 'normal',
87                           font_stretch = 'normal',
88                           fill = 'none',
89                           stroke = 'rgb(0,0,0)', #SVG dflt none, PS dflt blk
90                           stroke_width = theme.default_line_width,
91                           stroke_linejoin = 'miter',
92                           stroke_linecap = 'butt',
93                           stroke_dasharray = 'none')
94         
95     def _updateStyle(self, **addstyledict): 
96         elt = _protectCurrentChildren(self.__currElt)
97
98         # fetch the current styles for this node
99         mystyledict = _parseStyleStr(elt.getAttribute('style'))
100
101         # concat all parent style strings to get dflt styles for this node
102         parent,s = elt.parentNode,''
103         while parent.nodeType != Document.nodeType :
104             # prepend parent str so later keys will override earlier ones
105             s = parent.getAttribute('style') + s
106             parent = parent.parentNode
107         dfltstyledict = _parseStyleStr(s)
108
109         # Do some pre-processing on the caller-supplied add'l styles
110         # Convert '_' to '-' so caller can specify style tags as python
111         # variable names, eg. stroke_width => stroke-width.
112         # Also convert all RHS values to strs 
113         for key in addstyledict.keys():
114             k = re.sub('_','-',key)
115             addstyledict[k] = str(addstyledict[key]) # all vals => strs
116             if (k != key) : del addstyledict[key]
117
118         for k in addstyledict.keys() :
119             if (mystyledict.has_key(k) or # need to overwrite it
120                 (not dfltstyledict.has_key(k)) or # need to set it
121                 dfltstyledict[k] != addstyledict[k]) : # need to override it
122                 mystyledict[k] = addstyledict[k]
123         
124         s = _makeStyleStr(mystyledict)
125         if s : elt.setAttribute('style',s)
126
127         self.__currElt = elt
128
129     ####################################################################
130     # methods below define the pychart backend device API
131
132     # First are a set of methods to start, construct and finalize a path
133     
134     def newpath(self):                  # Start a new path
135         if (self.__currElt.nodeName != 'g') :
136             raise OverflowError, "No containing group for newpath"
137         # Just insert a new 'path' element into the document
138         p = self.__doc.createElement('path')
139         self.__currElt.appendChild(p)
140         self.__currElt = p
141
142     # This set of methods add data to an existing path element,
143     # simply add to the 'd' (data) attribute of the path elt
144     
145     def moveto(self, x, y):             # 
146         if (self.__currElt.nodeName != 'path') :
147             raise OverflowError, "No path for moveto"
148         d = ' '.join([self.__currElt.getAttribute('d'),'M',`x`,`-y`]).strip()
149         self.__currElt.setAttribute('d', d)
150     def lineto(self, x, y):
151         if (self.__currElt.nodeName != 'path') :
152             raise OverflowError, "No path for lineto"
153         d = ' '.join([self.__currElt.getAttribute('d'),'L',`x`,`-y`]).strip()
154         self.__currElt.setAttribute('d', d)
155     def path_arc(self, x, y, radius, ratio, start_angle, end_angle):
156         # mimic PS 'arc' given radius, yr/xr (=eccentricity), start and
157         # end angles.  PS arc draws from CP (if exists) to arc start,
158         # then draws arc in counterclockwise dir from start to end
159         # SVG provides an arc command that draws a segment of an
160         # ellipse (but not a full circle) given these args:
161         # A xr yr rotate majorArcFlag counterclockwiseFlag xe ye
162         # We don't use rotate(=0) and flipped axes => all arcs are clockwise
163
164         if (self.__currElt.nodeName != 'path') :
165             raise OverflowError, "No path for path_arc"
166
167         self.comment('x=%g, y=%g, r=%g, :=%g, %g-%g' 
168                      % (x,y,radius,ratio,start_angle,end_angle))
169
170         xs = x+radius*math.cos(2*math.pi/360.*start_angle)
171         ys = y+ratio*radius*math.sin(2*math.pi/360.*start_angle)
172         xe = x+radius*math.cos(2*math.pi/360.*end_angle)
173         ye = y+ratio*radius*math.sin(2*math.pi/360.*end_angle)
174         if (end_angle < start_angle) :  # make end bigger than start
175             while end_angle <= start_angle: # '<=' so 360->0 becomes 360->720
176                 end_angle += 360
177         full_circ = (end_angle - start_angle >= 360) # draw a full circle?
178             
179         d = self.__currElt.getAttribute('d')
180         d += ' %s %g %g' % (d and 'L' or 'M',xs,-ys) # draw from CP, if exists
181         if (radius > 0) : # skip, eg. 0-radius 'rounded' corners which blowup
182             if (full_circ) :
183                 # If we're drawing a full circle, move to the end coord
184                 # and draw half a circle to the reflected xe,ye
185                 d += ' M %g %g A %g %g 0 1 0 %g %g'%(xe,-ye,
186                                                      radius,radius*ratio,
187                                                      2*x-xe,-(2*y-ye))
188             # Draw arc from the CP (either reflected xe,ye for full circle else
189             # xs,ys) to the end coord - note with full_circ the
190             # 'bigArcFlag' value is moot, with exactly 180deg left to draw
191             d += ' A %g %g 0 %d 0 %g %g' % (radius,radius*ratio,
192                                             end_angle-start_angle>180,
193                                             xe,-ye)
194         self.__currElt.setAttribute('d',d.strip())
195     def curveto(self, x1,y1,x2,y2,x3,y3):
196         # Equivalent of PostScript's x1 y1 x2 y2 x3 y3 curveto which
197         # draws a cubic bezier curve from curr pt to x3,y3 with ctrl points
198         # x1,y1, and x2,y2
199         # In SVG this is just d='[M x0 y0] C x1 y1 x2 y2 x3 y3'
200         #! I can't find an example of this being used to test it
201         if (self.__currElt.nodeNode != 'path') :
202             raise OverflowError, "No path for curveto"
203         d = ' '.join([self.__currElt.getAttribute('d'),'C',
204                       `x1`,`-y1`,`x2`,`-y2`,`x3`,`-y3`,]).strip()
205         self.__currElt.setAttribute('d', d)
206     def closepath(self):                # close back to start of path
207         if (self.__currElt.nodeName != 'path') :
208             raise OverflowError, "No path for closepath"
209         d = ' '.join([self.__currElt.getAttribute('d'),'Z']).strip()
210         self.__currElt.setAttribute('d', d)
211
212     # Next we have three methods for finalizing a path element,
213     # either fill it, clip to it, or draw it (stroke)
214     # canvas.polygon() can generate fill/clip cmds with
215     # no corresponding path so just ignore them
216     def stroke(self):
217         if (self.__currElt.nodeName != 'path') :
218             self.comment('No path - ignoring stroke')
219             return
220         self._updateStyle(fill='none')
221         self.__currElt = self.__currElt.parentNode
222     def fill(self):
223         if (self.__currElt.nodeName != 'path') :
224             self.comment('No path - ignoring fill')
225             return
226         self._updateStyle(stroke='none')
227         self.__currElt = self.__currElt.parentNode
228     def clip_sub(self):
229         if (self.__currElt.nodeName != 'path') :
230             self.comment('No path - ignoring clip')
231             return
232
233         # remove the current path from the tree ...
234         p = self.__currElt
235         self.__currElt=p.parentNode
236         self.__currElt.removeChild(p)
237
238         # ... add it to a clipPath elt in the defs section
239         clip = self.__doc.createElement('clipPath')
240         clipid = 'clip'+`len(self.__defs.childNodes)`
241         clip.setAttribute('id',clipid)
242         clip.appendChild(p)
243         self.__defs.appendChild(clip)
244
245         # ... update the local style to point to it
246         self._updateStyle(clip_path = 'url(#%s)'%clipid)
247
248     # The text_xxx routines specify the start/end and contents of text
249     def text_begin(self):
250         if (self.__currElt.nodeName != 'g') :
251             raise ValueError, "No group for text block"
252         t = self.__doc.createElement('text')
253         self.__currElt.appendChild(t)
254         self.__currElt = t
255     def text_moveto(self, x, y, angle):
256         if (self.__currElt.nodeName != 'text') :
257             raise ValueError, "No text for moveto"
258         self.__currElt.setAttribute('x',`x`)
259         self.__currElt.setAttribute('y',`-y`)
260         if (angle) :
261             self.__currElt.setAttribute('transform',
262                                         'rotate(%g,%g,%g)' % (-angle,x,-y))
263     def text_show(self, font_name, size, color, str):
264         if (self.__currElt.nodeName != 'text') :
265             raise ValueError, "No text for show"
266
267         # PyChart constructs a postscript font name, for example:
268         #
269         # Helvetica Helvetica-Bold Helvetica-Oblique Helvetica-BoldOblique
270         # Helvetica-Narrow Times-Roman Times-Italic
271         # Symbol Palatino-Roman Bookman-Demi Courier AvantGarde-Book
272         #
273         # We need to deconstruct this to get the font-family (the
274         # piece before the '-'), and other characteristics.
275         # Note that 'Courier' seems to correspond to SVGs 'CourierNew'
276         # and that the SVG Symbol font is Unicode where the ascii text
277         # 'Symbol' doesn't create greek characters like 'Sigma ...' -
278         # should really pass a unicode string, or provide translation
279         #
280         # SVG defines:
281         # font-style = normal (aka roman) | italic | oblique
282         # font-weight = normal | bold (aka demi?)
283         # font-stretch = normal | wider | narrower | ultra-condensed |
284         #       extra-condensed | condensed | semi-condensed |
285         #       semi-expanded | expanded | extra-expanded | ultra-expanded
286         # ('narrow' seems to correspond to 'condensed')
287
288         m = re.match(r'([^-]*)(-.*)?',font_name)
289         font_name,modifiers = m.groups()
290         if font_name == 'Courier' : font_name = 'CourierNew'
291         font_style = font_weight = font_stretch = 'normal'
292         if modifiers :
293             if re.search('Italic',modifiers) : font_style = 'italic'
294             elif re.search('Oblique',modifiers) : font_style = 'oblique'
295             if re.search('Bold|Demi',modifiers) : font_weight = 'bold'
296             if re.search('Narrow',modifiers) : font_stretch = 'condensed'
297         #! translate ascii symbol font chars -> unicode (see www.unicode.org)
298         #! http://www.unicode.org/Public/MAPPINGS/VENDORS/ADOBE/symbol.txt
299         #! but xml Text element writes unicode chars as '?' to XML file...
300         str = re.sub(r'\\([()])',r'\1',str) # unescape brackets
301         self._updateStyle(fill=_svgcolor(color),
302                           stroke='none',
303                           font_family=font_name,
304                           font_size=size,
305                           font_style=font_style,
306                           font_weight=font_weight,
307                           font_stretch=font_stretch)
308         self.__currElt.appendChild(self.__doc.createTextNode(str))
309     def text_end(self):
310         if (self.__currElt.nodeName != 'text') :
311             raise ValueError, "No text for close"
312         self.__currElt = self.__currElt.parentNode
313
314
315     # Three methods that change the local style of elements
316     # If applied to a group, they persist until the next grestore,
317     # If applied within a path element, they only affect that path -
318     # although this may not in general correspond to (say) PostScript
319     # behavior, it appears to correspond to reflect mode of use of this API
320     def set_fill_color(self, color):
321         self._updateStyle(fill=_svgcolor(color))
322     def set_stroke_color(self, color):
323         self._updateStyle(stroke=_svgcolor(color))
324     def set_line_style(self, style):  # see line_style.py
325         linecap = {0:'butt', 1:'round', 2:'square'}
326         linejoin = {0:'miter', 1:'round', 2:'bevel'}
327         if style.dash: dash = ','.join(map(str,style.dash))
328         else : dash = 'none'
329         self._updateStyle(stroke_width = style.width,
330                           stroke = _svgcolor(style.color),
331                           stroke_linecap = linecap[style.cap_style],
332                           stroke_linejoin = linejoin[style.join_style],
333                           stroke_dasharray = dash)
334
335     # gsave & grestore respectively push & pop a new context to hold
336     # new style and transform parameters.  push/pop transformation are
337     # similar but explicitly specify a coordinate transform at the
338     # same time
339     def gsave(self):
340         if (self.__currElt.nodeName not in ['g','svg']) :
341             raise ValueError, "No group for gsave"
342         g = self.__doc.createElement('g')
343         self.__currElt.appendChild(g)
344         self.__currElt = g
345     def grestore(self):
346         if (self.__currElt.nodeName != 'g'):
347             raise ValueError, "No group for grestore"
348         # first pop off any auto-generated groups (see protectCurrentChildren)
349         while (self.__currElt.hasAttribute('auto')) :
350             self.__currElt.removeAttribute('auto')
351             self.__currElt = self.__currElt.parentNode
352         # then pop off the original caller-generated group
353         self.__currElt = self.__currElt.parentNode
354
355     def push_transformation(self, baseloc, scale, angle, in_text=0):
356         #? in_text arg appears to always be ignored
357
358         # In some cases this gets called after newpath, with
359         # corresonding pop_transformation called after the path is
360         # finalized so we check specifically for that, and generate
361         # an enclosing group to hold the incomplete path element
362         # We could add the transform directly to the path element
363         # (like we do with line-style etc) but that makes it harder
364         # to handle the closing 'pop' and might lead to inconsitency
365         # with PostScript if the closing pop doesn't come right after
366         # the path element
367
368         elt = self.__currElt
369         if elt.nodeName == 'g':
370             elt = None
371         elif (elt.nodeName == 'path' and not elt.hasAttribute('d')) :
372             g = elt.parentNode
373             g.removeChild(elt)
374             self.__currElt = g
375         else:
376             raise ValueError, "Illegal placement of push_transformation"
377             
378         t = ''
379         if baseloc :
380             t += 'translate(%g,%g) '%(baseloc[0],-baseloc[1])
381         if angle :
382             t += 'rotate(%g) '%-angle
383         if scale :
384             t += 'scale(%g,%g) '%tuple(scale)
385             
386         self.gsave()
387         self.__currElt.setAttribute('transform',t.strip())
388         if elt:                         # elt has incomplete 'path' or None
389             self.__currElt.appendChild(elt)
390             self.__currElt = elt
391
392     def pop_transformation(self, in_text=0): #? in_text unused?
393         self.grestore()
394
395     # If verbose, add comments to the output stream (helps debugging)
396     def comment(self, str):
397         if _comment_p : 
398             self.__currElt.appendChild(self.__doc.createComment(str))
399
400     # The verbatim method is currently not supported - presumably with
401     # the SVG backend the user would require access to the DOM since
402     # we're not directly outputting plain text here
403     def verbatim(self, str):
404         self.__currElt.appendChild(self.__doc.createComment('verbatim not implemented: ' + str))
405
406     # The close() method finalizes the SVG document and flattens the
407     # DOM document to XML text to the specified file (or stdout)
408     def close(self):
409         basecanvas.T.close(self)
410         self.grestore()           # matching the gsave in __init__
411         if (self.__currElt.nodeName != 'svg') :
412             raise ValueError, "Incomplete document at close!"
413
414         # Don't bother to output an empty document - this can happen
415         # when we get close()d immediately by theme reinit
416         if (len(self.__svg.childNodes[-1].childNodes) == 0) :
417             return
418             
419         fp, need_close = self.open_output(self.__out_fname)
420         bbox = theme.adjust_bounding_box([self.__xmin, self.__ymin,
421                                           self.__xmax, self.__ymax])
422         self.__svg.setAttribute('viewBox','%g %g %g %g'
423                                 % (xscale(bbox[0]),
424                                    -yscale(bbox[3]),
425                                    xscale(bbox[2])-xscale(bbox[0]),
426                                    yscale(bbox[3])-yscale(bbox[1])))
427         self.__doc.writexml(fp,'','  ','\n')
428         if need_close:
429             fp.close()