#!/usr/bin/env python """\ SVGgraph.py - Construct/display SVG scenes, with support for simple graphs. The following code is a lightweight wrapper around SVG files. The metaphor is to construct a scene, add objects to it, and then write it to a file to display it. This program uses ImageMagick to display the SVG files. ImageMagick also does a remarkable job of converting SVG files into other formats. """ __author__ = "Perry Kundert (perry@kundert.ca)" __copyright__ = "Copyright 2006, Perry Kundert" __contributors__ = [] __version__ = "1.0.1 $Rev:$" __license__ = "GPL" __history__ = """ """ import os import sys import cgi import cgitb import ImageColor cgitb.enable() display_prog = 'display' # Command to execute to display images. class Scene: def __init__(self,name="svg",height=400,width=400): self.name = name self.items = [] self.height = height self.width = width return def add(self,item): self.items.append(item) def strarray(self): var = ["\n", "\n" % (self.height,self.width), " \n"] for item in self.items: var += item.strarray() var += [" \n\n"] return var def write_svg(self,filename=None): if filename: self.svgname = filename else: self.svgname = self.name + ".svg" file = open(self.svgname,'w') file.writelines(self.strarray()) file.close() return def display(self,prog=display_prog): os.system("%s %s" % (prog,self.svgname)) return class Line: def __init__(self,start,end): self.start = start #xy tuple self.end = end #xy tuple return def strarray(self): return [" \n" %\ (self.start[0],self.start[1],self.end[0],self.end[1])] class Circle: def __init__(self,center,radius,color,stroke=None): self.center = center #xy tuple self.radius = radius #xy tuple self.color = color #rgb tuple in range(0,256) or None self.stroke = stroke #rbg tuple in range(0,256) or None return def strarray(self): try: strkcol = colorstr( self.stroke ) except: strkcol = "none" try: fillcol = colorstr( self.color ) except: fillcol = "none" return [ " \n" %\ ( strkcol, fillcol ) ] class Rectangle: def __init__(self,origin,height,width,color,stroke=None): self.origin = origin self.height = height self.width = width self.color = color self.stroke = stroke return def strarray(self): try: strkcol = colorstr( self.stroke ) except: strkcol = "none" try: fillcol = colorstr( self.color ) except: fillcol = "none" return [" \n" %\ ( self.width, strkcol, fillcol ) ] class Text: def __init__( self, origin, text, stroke=None, size="24pt" ): self.origin = origin self.text = text self.stroke = stroke self.size = size return def strarray(self): try: strkcol = colorstr( self.stroke ) except: strkcol = "#000000" return [" \n" %\ ( self.origin[0], self.origin[1], self.size, strkcol ), " %s\n" % self.text, " \n"] class Comment: def __init__( self, text ): self.text = text def strarray( self ): return [ "\n" % self.text ] def colorstr( rgb ): return "#%02x%02x%02x" % ( rgb[0], rgb[1], rgb[2] ) # # Graph # # Add points. Will auto-scale both axes to the given graph size. # class Graph: def __init__( self, origin, height, width, style = "smooth", line = 1 ): self.origin = origin # upper left corner of graph (in screen coordinates) self.height = height # total size of graph self.width = width self.style = style self.line = line self.xmin = (0,0) self.xmax = (0,0) self.ymin = (0,0) self.ymax = (0,0) self.extents = False self.points = {} # dictionary keyed on label, of lists of coordinate 2-tuples self.colors = {} # dictionary keyed on label, colors in hex form #RRGGBB return # Add data point(s), and (latest) axes min/max. Can handle either a list of 2-tuples, or a # single 2-tuple. def data( self, things, label = '', color = ( 0, 0, 0 )): if not self.points.has_key( label ): self.points[label] = [] self.colors[label] = color list = [] if type( things ) != type( [] ): list = [ things ] else: list = things for point in list: if self.extents: if point[0] <= self.xmin[0]: self.xmin = point if point[0] >= self.xmax[0]: self.xmax = point if point[1] <= self.ymin[1]: self.ymin = point if point[1] >= self.ymax[1]: self.ymax = point else: self.extents = True self.xmin = point self.xmax = point self.ymin = point self.ymax = point self.points[label].append( point ) # Returns the calculated axis scales for the graph. These are used to transform the data points # into screen coordinates relative to the graph's zero point def scale( self ): return ( float( self.width ) / ( 1, self.xmax[0] - self.xmin[0] )[self.xmax[0] != self.xmin[0]], float( self.height ) / ( 1, self.ymax[1] - self.ymin[1] )[self.ymax[1] != self.ymin[1]] ) # Returns the calculated zero point (in screen coordinates), relative to the origin. After each # data point is scaled, add this value to transform it into screen coordinates relative to the # origin. Screen y axis is inverted, remember, compared to graph y axis. def zero( self, scl ): return ( -int( self.xmin[0] * scl[0] ), -int( self.ymin[1] * scl[1] )) # Compute the graph coordinates of a point, relative to (0,0) in the LL corner def graph( self, pnt, scl, zro ): return ( pnt[0] * scl[0] + zro[0], pnt[1] * scl[1] + zro[1] ) # Compute the screen coordinates of a graph point def screen( self, gra ): return ( self.origin[0] + 0 + gra[0], self.origin[1] + self.height - gra[1] ) # Transform a given datum into the scale of the graph, and offset by the graph's zero point. # Remember, the SVG scene's origin is the upper left, the graph's origin is the lower left... def transform( self, pnt, scl = None, zro = None ): if not scl: scl = self.scale() if not zro: zro = self.zero( scl ) return self.screen( self.graph( pnt, scl, zro )) # Returns the transformed graph data points in the specified SVG form (line graph, by default) def strarray( self ): res = [] scl = self.scale() zro = self.zero( scl ) segs = self.ymax[1] - self.ymin[1] + 1 # segment count and size, used for "discrete" only seg = self.height / segs for label in self.points.keys(): # key is label if len( self.points[label] ) > 1: if self.style != "random": # implies "smooth", too self.points[label].sort() # nothing else necessary! tuples sort ok! ( cmp = lambda a, b: a[0] - b[0] ) lst = self.transform( self.points[label][0], scl, zro ) if self.style != "discrete": # smooth, step or random axi = " \n" % ( colorstr( self.colors[label] ), self.line ) res.append( axi ) else: # discrete res.append( " \n" % ( self.line )) for raw in self.points[label]: gra = self.graph( raw, scl, zro ) pct = gra[1] * 100 / self.height bot = ( gra[0], gra[1] - self.line - seg * pct / 100 ) top = ( gra[0], bot[1] + self.line + seg ) bot = self.screen( bot ) top = self.screen( top ) res.append( " \n" \ % ( bot[0], bot[1], top[0], top[1], colorstr( self.colors[label] ))) res.append( " \n" ) return res def test(): scene = Scene('test') scene.add(Rectangle((100,100),200,200,(0,255,255))) scene.add(Line((200,200),(200,300))) scene.add(Line((200,200),(300,200))) scene.add(Line((200,200),(100,200))) scene.add(Line((200,200),(200,100))) scene.add(Circle((200,200),30,(0,0,255))) scene.add(Circle((200,300),30,(0,255,0))) scene.add(Circle((300,200),30,(255,0,0))) scene.add(Circle((100,200),30,(255,255,0))) scene.add(Circle((200,100),30,(255,0,255))) scene.add(Text((50,50),"Testing SVG")) graph = Graph( (100,100), 100, 300 ) graph.data( (100,100) ) graph.data( (150,150) ) graph.data( (200,100) ) graph.data( (250,300) ) scene.add( graph ) scene.write_svg( "/tmp/test.svg" ) scene.display() return # Return the named query's value, or the default. If it is a list (query was supplied multiple # times), return the last one. def param( form, name, default ): if form.has_key( name ): if type( form[name] ) == type( [] ): return form[name][-1].value else: return form[name].value else: return default def content( type ): print "Content-type: " + type print "Status: 200 Ok" print "ETag: " + str(hash(os.environ['QUERY_STRING'] + __version__)) print "" def error( status = "Status: 400 Bad Request", errors = [] ): print "Content-type: text/plain" print status print "" print status print "" print "Specify one or more graph data sets, with optional color name:" print " Label=#,#..." print " Label=color:#,#..." print " Label=color:# #,# #,..." print "" print "Options:" print " height=# Graph height in pixels. Padded if max/min requested" print " width=# Graph width in pixels. Padded if legend requested" print " max=color Maxima dot, with value printed in same color (unless legend=none,... specified)" print " min=color Minima dot, with value printed in same color (unless legend=none,... specified)" print " legend=... none, bottom/top, min, max, all: Display selected legends." print " style=... smooth (the default), step, discrete, random: Graph style." print " format=... svg (the default), png, jpg, gif, ..." print "" if errors: sys.stdout.writelines( errors ) sys.stdout.writelines( [ k + ": " + os.environ[k] + "\n" for k in os.environ.keys() ] ) sys.exit() # If called directly, see if we are being invoked as CGI or not. if __name__ == '__main__': try: if not os.environ['REQUEST_METHOD'] in ['GET', 'HEAD']: error( "Status: 405 Method Not Allowed: %s" % os.environ['REQUEST_METHOD'] ) except: # Not CGI (no 'REQUEST_METHOD' found). Do a test... test() sys.exit() # It's CGI and an allowed request. Try to process, and return an error on exception form = cgi.FieldStorage() # Extract the known CGI query options (or assume defaults) querys = [ 'style', 'height', 'width', 'line', 'min', 'max', 'legend', 'format' ] styl = param( form, 'style', 'smooth' ) high = param( form, 'height', '18' ) wide = param( form, 'width', '100' ) line = param( form, 'line', '1' ) # line weight minc = param( form, 'min', '' ) maxc = param( form, 'max', '' ) lgnd = param( form, 'legend', '' ) # No legend by default fmat = param( form, 'format', 'svg' ) if styl not in [ 'smooth', 'step', 'discrete', 'random' ]: error( "Status: 400 Bad Request: Unrecognized style option: %s" % styl ) lgndmn = minc and 1 or 0 lgndmx = maxc and 1 or 0 lgndlb = None for l in lgnd.split(','): if l == "none": lgndmn = 0; lgndmx = 0; lgndlb = None elif l == "all": lgndmn = 1; lgndmx = 1; lgndlb = "bottom" elif l == "bottom" or l == "top": lgndlb = l elif l == "min": lgndmn = 1 elif l == "max": lgndmx = 1 elif l != '': error( "Status: 400 Bad Request: Unrecognized legend option: %s" % l ) # Count the maximal number of characters in the largest max/min value (if # any); determines if extra padding required due to line size and max./minima dots. lsiz = max( 1, int( line )) # Requested line size, minimum 1 lpad = 0 if minc or maxc: lpad = max( 2, lsiz ) # Max./minima dots are 2 pixels radius, or line width (if selected) graph = Graph( ( lpad, lpad ), int( high ), int( wide ), styl, lsiz ) # Get the data set(s). They may be tuples or values (deduce x axis). All the remaining # query options are assumed to be the named (and optionally the colored) data sets, if they # look like: # # name=[color:]#,#,... # or: name=[color:]# #,# #,... comment = "" for key in [ k for k in form.keys() if k not in querys ]: data = param( form, key, '' ) # Try to pick a color:#,#,... off the front of the data; default to "black". Convert color # to an RBG 3-tuple (#,#,#) label = key color = "black" try: color,data = data.split(':') except: pass strkcol = ImageColor.getrgb( color ) # If we fail while trying to parse the coordinates, or if we CAN parse it, and there are no # values, it's an error. list = [] try: for item in data.split(','): if item: coord = item.split() if len( coord ) == 2: # A full coordinate pair! Use it. Mixing 1 and 2+ coords not suggested, after any have been deduced... list.append( ( int( coord[0] ), int( coord[1] ))) elif len( coord ) == 1: # Not a coordinate pair. Deduce X from last 0, 1 or 2+ coords. if len( list ) == 0: list.append( ( 0, int( coord[0] ))) elif len( list ) == 1: list.append( ( list[-1][0] + 1, int( coord[0] ))) else: list.append( ( list[-1][0] + ( list[-1][0] - list[-2][0] ), int( coord[0] ))) except: error( "Status: 400 Bad Request: Unrecognized query: %s=%s" % ( key, param( form, key, '' ))) if not len( list ): error( "Status: 400 Bad Request: Label %s had no data" % label ) comment += "\nLabel: %s\n color: %s\n data: %s\n coords: %s\n"\ % ( label, color, data, ', '.join( [ "%d %d" % ( x, y ) for x,y in list ] )) graph.data( list, label, strkcol ) if not graph.colors.keys(): error( "Status: 400 Bad Request: No graph data set(s) supplied" ) # Create the scene. Legend across bottom (if multiple axes), maxima/minima stacked on right. # Font size in pixels, is minimum of 1/5 100dpi (5 pitch on 100dpi) or 1/2 the height, and 1/6 # inter-line spacing. flin = min( 100 / 5, ( int( high ) + 2 * lpad ) / 2 ) # Font line fsiz = flin * 5 / 6 # Font size fpad = flin - fsiz # Hence, the font inter-line pad # Determine total padding around graph, based on max./minima legend and/or label legend display mnum = 0 # Number of max./minima chars if lgndmn: mnum = max( mnum, len( str( graph.ymin[1] ))) if lgndmx: mnum = max( mnum, len( str( graph.ymax[1] ))) hpad = 0 if mnum: hpad += int( line ) + fsiz / 2 + mnum * fsiz * 2 / 3 # glyphs ~ 2/3 wide as high? 1/2 glyph spacing at start vpad = 0 if lgndlb: vpad += flin # Graph is padded on all sides by 'lpad', if any max./minima selected scene = Scene( 'graph', int( high ) + 2 * lpad + vpad, int( wide ) + 2 * lpad + hpad ) scene.add( Comment( comment )) scene.add( graph ) # Draw graph min/max points if desired (one maxima/minima for ALL axes!) if minc: scene.add( Circle( graph.transform( graph.ymin ), lpad, ImageColor.getrgb( minc ))) if lgndmn: scene.add( Text( (int( wide ) + int( line ) + fsiz / 2, int( high ) - 0 ), str( graph.ymin[1] ), ImageColor.getrgb( minc and minc or "black" ), str( fsiz ) + "px" )) if maxc: scene.add( Circle( graph.transform( graph.ymax ), lpad, ImageColor.getrgb( maxc ))) if lgndmx: scene.add( Text( (int( wide ) + int( line ) + fsiz / 2, int( high ) - flin ), str( graph.ymax[1] ), ImageColor.getrgb( maxc and maxc or "black" ), str( fsiz ) + "px" )) if lgndlb: # TODO: allow legend at "top" x = 0 for label in graph.colors.keys(): scene.add( Text( ( x * int( wide )/len( graph.colors.keys()), int( high ) + flin ), label, graph.colors[label], str( fsiz ) + "px" )) x += 1 # All OK. Output the (native) SVG content, or 'convert' to some other format! if fmat == "svg": content( "image/svg+xml" ) sys.stdout.writelines( scene.strarray()) else: # Some other graphics format. Convert to it. Assume they # know what they are talking about. to,fr,er = os.popen3( "convert svg:- " + fmat + ":-" ) to.writelines( scene.strarray() ) to.close() pid,exi = os.wait() if exi: # Didn't work; non-zero exit code. Probably a bad image format. errors = er.readlines() + fr.readlines() error( "Status: 400 Bad Request: Error converting to format 'image/" + fmat, errors ) content( "image/" + fmat ) sys.stdout.write( fr.read() ) fr.close() er.close()