# -*- coding: utf-8 -*- # # Copyright (C) 2000-2005 by Yasushi Saito (yasushi.saito@gmail.com) # # Jockey 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 2, or (at your option) any # later version. # # Jockey 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. # import sys,string,re,math from xml.dom.minidom import Document,Comment import theme import basecanvas import version from scaling import * # Note we flip all y-coords and negate all angles because SVG's coord # system is inverted wrt postscript/PDF - note it's not enough to # scale(1,-1) since that turns text into mirror writing with wrong origin _comment_p = 0 # whether comment() writes output # Convert a PyChart color object to an SVG rgb() value def _svgcolor(color): # see color.py return 'rgb(%d,%d,%d)' % tuple(map(lambda x:int(255*x), [color.r,color.g,color.b])) # Take an SVG 'style' attribute string like 'stroke:none;fill:black' # and parse it into a dictionary like {'stroke' : 'none', 'fill' : 'black'} def _parseStyleStr(s): styledict = {} if s : # parses L -> R so later keys overwrite earlier ones for keyval in s.split(';'): l = keyval.strip().split(':') if l and len(l) == 2: styledict[l[0].strip()] = l[1].strip() return styledict # Make an SVG style string from the dictionary described above def _makeStyleStr(styledict): s = '' for key in styledict.keys(): s += "%s:%s;"%(key,styledict[key]) return s def _protectCurrentChildren(elt): # If elt is a group, check to see whether there are any non-comment # children, and if so, create a new group to hold attributes # to avoid affecting previous children. Return either the current # elt or the newly generated group. if (elt.nodeName == 'g') : for kid in elt.childNodes : if kid.nodeType != Comment.nodeType: g = elt.ownerDocument.createElement('g') g.setAttribute('auto','') if _comment_p: g.appendChild(g.ownerDocument.createComment ('auto-generated group')) elt.appendChild(g) elt = g break return elt class T(basecanvas.T): def __init__(self, fname): basecanvas.T.__init__(self) self.__out_fname = fname self.__xmin, self.__xmax, self.__ymin, self.__ymax = 0,0,0,0 self.__doc = Document() self.__doc.appendChild(self.__doc.createComment ('Created by PyChart ' + version.version + ' ' + version.copyright)) self.__svg = self.__doc.createElement('svg') # the svg doc self.__doc.appendChild(self.__svg) self.__defs = self.__doc.createElement('defs') # for clip paths self.__svg.appendChild(self.__defs) self.__currElt = self.__svg self.gsave() # create top-level group for dflt styles self._updateStyle(font_family = theme.default_font_family, font_size = theme.default_font_size, font_style = 'normal', font_weight = 'normal', font_stretch = 'normal', fill = 'none', stroke = 'rgb(0,0,0)', #SVG dflt none, PS dflt blk stroke_width = theme.default_line_width, stroke_linejoin = 'miter', stroke_linecap = 'butt', stroke_dasharray = 'none') def _updateStyle(self, **addstyledict): elt = _protectCurrentChildren(self.__currElt) # fetch the current styles for this node mystyledict = _parseStyleStr(elt.getAttribute('style')) # concat all parent style strings to get dflt styles for this node parent,s = elt.parentNode,'' while parent.nodeType != Document.nodeType : # prepend parent str so later keys will override earlier ones s = parent.getAttribute('style') + s parent = parent.parentNode dfltstyledict = _parseStyleStr(s) # Do some pre-processing on the caller-supplied add'l styles # Convert '_' to '-' so caller can specify style tags as python # variable names, eg. stroke_width => stroke-width. # Also convert all RHS values to strs for key in addstyledict.keys(): k = re.sub('_','-',key) addstyledict[k] = str(addstyledict[key]) # all vals => strs if (k != key) : del addstyledict[key] for k in addstyledict.keys() : if (mystyledict.has_key(k) or # need to overwrite it (not dfltstyledict.has_key(k)) or # need to set it dfltstyledict[k] != addstyledict[k]) : # need to override it mystyledict[k] = addstyledict[k] s = _makeStyleStr(mystyledict) if s : elt.setAttribute('style',s) self.__currElt = elt #################################################################### # methods below define the pychart backend device API # First are a set of methods to start, construct and finalize a path def newpath(self): # Start a new path if (self.__currElt.nodeName != 'g') : raise OverflowError, "No containing group for newpath" # Just insert a new 'path' element into the document p = self.__doc.createElement('path') self.__currElt.appendChild(p) self.__currElt = p # This set of methods add data to an existing path element, # simply add to the 'd' (data) attribute of the path elt def moveto(self, x, y): # if (self.__currElt.nodeName != 'path') : raise OverflowError, "No path for moveto" d = ' '.join([self.__currElt.getAttribute('d'),'M',`x`,`-y`]).strip() self.__currElt.setAttribute('d', d) def lineto(self, x, y): if (self.__currElt.nodeName != 'path') : raise OverflowError, "No path for lineto" d = ' '.join([self.__currElt.getAttribute('d'),'L',`x`,`-y`]).strip() self.__currElt.setAttribute('d', d) def path_arc(self, x, y, radius, ratio, start_angle, end_angle): # mimic PS 'arc' given radius, yr/xr (=eccentricity), start and # end angles. PS arc draws from CP (if exists) to arc start, # then draws arc in counterclockwise dir from start to end # SVG provides an arc command that draws a segment of an # ellipse (but not a full circle) given these args: # A xr yr rotate majorArcFlag counterclockwiseFlag xe ye # We don't use rotate(=0) and flipped axes => all arcs are clockwise if (self.__currElt.nodeName != 'path') : raise OverflowError, "No path for path_arc" self.comment('x=%g, y=%g, r=%g, :=%g, %g-%g' % (x,y,radius,ratio,start_angle,end_angle)) xs = x+radius*math.cos(2*math.pi/360.*start_angle) ys = y+ratio*radius*math.sin(2*math.pi/360.*start_angle) xe = x+radius*math.cos(2*math.pi/360.*end_angle) ye = y+ratio*radius*math.sin(2*math.pi/360.*end_angle) if (end_angle < start_angle) : # make end bigger than start while end_angle <= start_angle: # '<=' so 360->0 becomes 360->720 end_angle += 360 full_circ = (end_angle - start_angle >= 360) # draw a full circle? d = self.__currElt.getAttribute('d') d += ' %s %g %g' % (d and 'L' or 'M',xs,-ys) # draw from CP, if exists if (radius > 0) : # skip, eg. 0-radius 'rounded' corners which blowup if (full_circ) : # If we're drawing a full circle, move to the end coord # and draw half a circle to the reflected xe,ye d += ' M %g %g A %g %g 0 1 0 %g %g'%(xe,-ye, radius,radius*ratio, 2*x-xe,-(2*y-ye)) # Draw arc from the CP (either reflected xe,ye for full circle else # xs,ys) to the end coord - note with full_circ the # 'bigArcFlag' value is moot, with exactly 180deg left to draw d += ' A %g %g 0 %d 0 %g %g' % (radius,radius*ratio, end_angle-start_angle>180, xe,-ye) self.__currElt.setAttribute('d',d.strip()) def curveto(self, x1,y1,x2,y2,x3,y3): # Equivalent of PostScript's x1 y1 x2 y2 x3 y3 curveto which # draws a cubic bezier curve from curr pt to x3,y3 with ctrl points # x1,y1, and x2,y2 # In SVG this is just d='[M x0 y0] C x1 y1 x2 y2 x3 y3' #! I can't find an example of this being used to test it if (self.__currElt.nodeNode != 'path') : raise OverflowError, "No path for curveto" d = ' '.join([self.__currElt.getAttribute('d'),'C', `x1`,`-y1`,`x2`,`-y2`,`x3`,`-y3`,]).strip() self.__currElt.setAttribute('d', d) def closepath(self): # close back to start of path if (self.__currElt.nodeName != 'path') : raise OverflowError, "No path for closepath" d = ' '.join([self.__currElt.getAttribute('d'),'Z']).strip() self.__currElt.setAttribute('d', d) # Next we have three methods for finalizing a path element, # either fill it, clip to it, or draw it (stroke) # canvas.polygon() can generate fill/clip cmds with # no corresponding path so just ignore them def stroke(self): if (self.__currElt.nodeName != 'path') : self.comment('No path - ignoring stroke') return self._updateStyle(fill='none') self.__currElt = self.__currElt.parentNode def fill(self): if (self.__currElt.nodeName != 'path') : self.comment('No path - ignoring fill') return self._updateStyle(stroke='none') self.__currElt = self.__currElt.parentNode def clip_sub(self): if (self.__currElt.nodeName != 'path') : self.comment('No path - ignoring clip') return # remove the current path from the tree ... p = self.__currElt self.__currElt=p.parentNode self.__currElt.removeChild(p) # ... add it to a clipPath elt in the defs section clip = self.__doc.createElement('clipPath') clipid = 'clip'+`len(self.__defs.childNodes)` clip.setAttribute('id',clipid) clip.appendChild(p) self.__defs.appendChild(clip) # ... update the local style to point to it self._updateStyle(clip_path = 'url(#%s)'%clipid) # The text_xxx routines specify the start/end and contents of text def text_begin(self): if (self.__currElt.nodeName != 'g') : raise ValueError, "No group for text block" t = self.__doc.createElement('text') self.__currElt.appendChild(t) self.__currElt = t def text_moveto(self, x, y, angle): if (self.__currElt.nodeName != 'text') : raise ValueError, "No text for moveto" self.__currElt.setAttribute('x',`x`) self.__currElt.setAttribute('y',`-y`) if (angle) : self.__currElt.setAttribute('transform', 'rotate(%g,%g,%g)' % (-angle,x,-y)) def text_show(self, font_name, size, color, str): if (self.__currElt.nodeName != 'text') : raise ValueError, "No text for show" # PyChart constructs a postscript font name, for example: # # Helvetica Helvetica-Bold Helvetica-Oblique Helvetica-BoldOblique # Helvetica-Narrow Times-Roman Times-Italic # Symbol Palatino-Roman Bookman-Demi Courier AvantGarde-Book # # We need to deconstruct this to get the font-family (the # piece before the '-'), and other characteristics. # Note that 'Courier' seems to correspond to SVGs 'CourierNew' # and that the SVG Symbol font is Unicode where the ascii text # 'Symbol' doesn't create greek characters like 'Sigma ...' - # should really pass a unicode string, or provide translation # # SVG defines: # font-style = normal (aka roman) | italic | oblique # font-weight = normal | bold (aka demi?) # font-stretch = normal | wider | narrower | ultra-condensed | # extra-condensed | condensed | semi-condensed | # semi-expanded | expanded | extra-expanded | ultra-expanded # ('narrow' seems to correspond to 'condensed') m = re.match(r'([^-]*)(-.*)?',font_name) font_name,modifiers = m.groups() if font_name == 'Courier' : font_name = 'CourierNew' font_style = font_weight = font_stretch = 'normal' if modifiers : if re.search('Italic',modifiers) : font_style = 'italic' elif re.search('Oblique',modifiers) : font_style = 'oblique' if re.search('Bold|Demi',modifiers) : font_weight = 'bold' if re.search('Narrow',modifiers) : font_stretch = 'condensed' #! translate ascii symbol font chars -> unicode (see www.unicode.org) #! http://www.unicode.org/Public/MAPPINGS/VENDORS/ADOBE/symbol.txt #! but xml Text element writes unicode chars as '?' to XML file... str = re.sub(r'\\([()])',r'\1',str) # unescape brackets self._updateStyle(fill=_svgcolor(color), stroke='none', font_family=font_name, font_size=size, font_style=font_style, font_weight=font_weight, font_stretch=font_stretch) self.__currElt.appendChild(self.__doc.createTextNode(str)) def text_end(self): if (self.__currElt.nodeName != 'text') : raise ValueError, "No text for close" self.__currElt = self.__currElt.parentNode # Three methods that change the local style of elements # If applied to a group, they persist until the next grestore, # If applied within a path element, they only affect that path - # although this may not in general correspond to (say) PostScript # behavior, it appears to correspond to reflect mode of use of this API def set_fill_color(self, color): self._updateStyle(fill=_svgcolor(color)) def set_stroke_color(self, color): self._updateStyle(stroke=_svgcolor(color)) def set_line_style(self, style): # see line_style.py linecap = {0:'butt', 1:'round', 2:'square'} linejoin = {0:'miter', 1:'round', 2:'bevel'} if style.dash: dash = ','.join(map(str,style.dash)) else : dash = 'none' self._updateStyle(stroke_width = style.width, stroke = _svgcolor(style.color), stroke_linecap = linecap[style.cap_style], stroke_linejoin = linejoin[style.join_style], stroke_dasharray = dash) # gsave & grestore respectively push & pop a new context to hold # new style and transform parameters. push/pop transformation are # similar but explicitly specify a coordinate transform at the # same time def gsave(self): if (self.__currElt.nodeName not in ['g','svg']) : raise ValueError, "No group for gsave" g = self.__doc.createElement('g') self.__currElt.appendChild(g) self.__currElt = g def grestore(self): if (self.__currElt.nodeName != 'g'): raise ValueError, "No group for grestore" # first pop off any auto-generated groups (see protectCurrentChildren) while (self.__currElt.hasAttribute('auto')) : self.__currElt.removeAttribute('auto') self.__currElt = self.__currElt.parentNode # then pop off the original caller-generated group self.__currElt = self.__currElt.parentNode def push_transformation(self, baseloc, scale, angle, in_text=0): #? in_text arg appears to always be ignored # In some cases this gets called after newpath, with # corresonding pop_transformation called after the path is # finalized so we check specifically for that, and generate # an enclosing group to hold the incomplete path element # We could add the transform directly to the path element # (like we do with line-style etc) but that makes it harder # to handle the closing 'pop' and might lead to inconsitency # with PostScript if the closing pop doesn't come right after # the path element elt = self.__currElt if elt.nodeName == 'g': elt = None elif (elt.nodeName == 'path' and not elt.hasAttribute('d')) : g = elt.parentNode g.removeChild(elt) self.__currElt = g else: raise ValueError, "Illegal placement of push_transformation" t = '' if baseloc : t += 'translate(%g,%g) '%(baseloc[0],-baseloc[1]) if angle : t += 'rotate(%g) '%-angle if scale : t += 'scale(%g,%g) '%tuple(scale) self.gsave() self.__currElt.setAttribute('transform',t.strip()) if elt: # elt has incomplete 'path' or None self.__currElt.appendChild(elt) self.__currElt = elt def pop_transformation(self, in_text=0): #? in_text unused? self.grestore() # If verbose, add comments to the output stream (helps debugging) def comment(self, str): if _comment_p : self.__currElt.appendChild(self.__doc.createComment(str)) # The verbatim method is currently not supported - presumably with # the SVG backend the user would require access to the DOM since # we're not directly outputting plain text here def verbatim(self, str): self.__currElt.appendChild(self.__doc.createComment('verbatim not implemented: ' + str)) # The close() method finalizes the SVG document and flattens the # DOM document to XML text to the specified file (or stdout) def close(self): basecanvas.T.close(self) self.grestore() # matching the gsave in __init__ if (self.__currElt.nodeName != 'svg') : raise ValueError, "Incomplete document at close!" # Don't bother to output an empty document - this can happen # when we get close()d immediately by theme reinit if (len(self.__svg.childNodes[-1].childNodes) == 0) : return fp, need_close = self.open_output(self.__out_fname) bbox = theme.adjust_bounding_box([self.__xmin, self.__ymin, self.__xmax, self.__ymax]) self.__svg.setAttribute('viewBox','%g %g %g %g' % (xscale(bbox[0]), -yscale(bbox[3]), xscale(bbox[2])-xscale(bbox[0]), yscale(bbox[3])-yscale(bbox[1]))) self.__doc.writexml(fp,'',' ','\n') if need_close: fp.close()