SVG backend for matplotlib

Hi, I wanted to be able to use Inkscape to fiddle with the

    > details of my plot before exporting it to postscript, so I
    > started writing an SVG backend. What I have so far is
    > attached. Note that it's based on matplotlib-0.53.1, so it
    > won't work with 0.54 yet. So far it just draws lines,
    > rectangles, polygons, and text (only in helvetica).

    > Before I go further, I wanted to make sure you or someone
    > else is not already working on this. If not, I'll port it
    > to use font-caching, the new transform stuff and figure out
    > how to draw arcs and polygon collections, etc.

This is absolutely great news! Glad to hear you made it so far on
your own. I CCd the devel list, which you may want to join, in case
anyone else has any advice for you. As far as I know, noone else is
working on this.

Here are a few thoughts for you when porting to 0.54.

All the text layout has been moved to the text.Text front end. So you
no longer need to worry about horizontal alignment and the like,
compute_offsets is no longer needed, etc.. You also do not need to
provide the window extent of text anymore. Just provide

   get_text_width_height(self, s, prop, ismath)

where s is a string and prop is a FontProperties instance. This
returns the width and height of the *unrotated* string. In the front
end I use this to compute the width and height of the rotated string,
compute the alignment offsets, and pass the x,y location to you to
draw the rotated string in draw_text. Instead of working with Text
instances, as in 0.53, you now work with strings and font properties.

The other new Renderer method is

  get_canvas_width_height

As for polygon collections, you can implement this if you want, but
the base class provides an implementation that is reasonably fast
(approx 5x faster than the prior method of using separate polygon
instances). This may be good enough, since my guess is for most cases
you only demand the highest performance for interactive use, eg with
one of the GUI backends. So the *Agg backends implement this in
extension code.

As for draw_arc, currently this is only used for drawing circles, so
if you implemented draw_circle there is would suffice.

    > The fact that I, a python novice, could get so far in a day
    > attests to the awesomeness of matplotlib. Thanks!

A day... Amazing. Maybe we'll have you write the PDF backend next :slight_smile:

JDH

from __future__ import division
from cStringIO import StringIO

from matplotlib.afm import AFM
from matplotlib.backend_bases import RendererBase, GraphicsContextBase,\
     FigureManagerBase, FigureCanvasBase

from matplotlib.cbook import iterable, is_string_like, flatten, enumerate,\
     get_recursive_filelist, True, False

from matplotlib.figure import Figure
from matplotlib.font_manager import fontManager
from matplotlib.ft2font import FT2Font
from matplotlib._matlab_helpers import Gcf
from matplotlib.text import Text
from matplotlib.transforms import Bound1D, Bound2D, Transform
from matplotlib import rcParams

from matplotlib.numerix import fromstring, UInt8, Float32
import binascii

import sys,os

def error_msg_svg(msg, *args):
    print >>sys.stderr, 'Error:', msg
    sys.exit()

def _nums_to_str(seq, fmt='%1.3f'):
    return ' '.join([_int_or_float(val, fmt) for val in seq])

def draw_if_interactive():
    pass

def show():
    """
    Show all the figures and enter the gtk mainloop

    This should be the last line of your script
    """
    for manager in Gcf.get_all_fig_managers():
        manager.figure.realize()

def new_figure_manager(num, *args):
    thisFig = Figure(*args)
    canvas = FigureCanvasSVG(thisFig)
    manager = FigureManagerSVG(canvas, num)
    return manager

def _rgb_to_hex(rgb):
    rgbhex='#'
    for c in rgb:
        h=hex(int(c*255))[2:]
        if len(h) < 2:
            h='0'+ h
        rgbhex += h
    return rgbhex

class RendererSVG(RendererBase):
    def __init__(self, svgwriter,width,height):
        self._svgwriter = svgwriter
        self.width=width
        self.height=height

    def flipy(self):
        'return true if y small numbers are top for renderer'
        return False

    def draw_rawsvg(self, svg):
        self._svgwriter.write(svg)

    def compute_text_offsets(self, t):
        """
        Return the (x,y) offsets to adjust for the alignment
        specifications
        """

        prop = t.get_font_properties()
        font = AFM(file(fontManager.findfont(prop, fontext='afm')))

        text = t.get_text()
        l,b,w,h = font.get_str_bbox(text)

        fontsize = prop.get_size_in_points()
        w *= 0.001*fontsize
        h *= 0.001*fontsize

        halign = t.get_horizontalalignment()
        valign = t.get_verticalalignment()
        if t.get_rotation()=='vertical':
            w, h = h, w

            if halign=='center': offsetx = w/2
            elif halign=='right': offsetx = 0
            else: offsetx = w
            
            if valign=='center': offsety = h/2
            elif valign=='top': offsety = h
            else: offsety = 0
        else:
            if halign=='center': offsetx = -w/2
            elif halign=='right': offsetx = -w
            else: offsetx = 0

            if valign=='center': offsety = h/2
            elif valign=='top': offsety = h
            else: offsety = 0
        
        return (offsetx, offsety)

    def draw_arc(self, gc, rgbFace, x, y, width, height, angle1, angle2):
        pass

    def draw_line(self, gc, x1, y1, x2, y2):
        """
        Draw a single line from x1,y1 to x2,y2
        """
        type = '<path '
        details = ' d=\"M %f,%f L %f,%f\" ' % (x1,self.height-y1,x2,self.height-y2)
        self._draw_svg(type, details, gc, None)

    def draw_lines(self, gc, x, y):
        """
        x and y are equal length arrays, draw lines connecting each
        point in x, y
        """
        if len(x)==0: return
        if len(x)!=len(y): error_msg_svg('x and y must be the same length')
        type = '<path '
        details =' d=\"M %f,%f ' % (x[0], self.height-y[0])
        for tup in zip(x[1:], self.height-y[1:]):
            details += 'L %f,%f ' % tup
        details += '\" '
        self._draw_svg(type, details, gc, None)

    def draw_rectangle(self, gc, rgbFace, x, y, width, height):
        rgbhex='fill:#'
        for c in rgbFace:
            rgbhex += hex(int(c*255))[2:]
        type = '<rect '
        details = """
        width=\"%f\"
        height=\"%f\"
        x=\"%f\"
        y=\"%f\" """ % (width, height, x, self.height-y-height)
        self._draw_svg(type, details, gc, rgbFace)

    def draw_polygon(self, gc, rgbFace, points):
        type = '<polygon '
        details = 'points =\"'
        for point in points:
            details += '%f,%f ' % (point[0],self.height-point[1])
        details += '\"'
        self._draw_svg(type, details, gc, rgbFace)

    def draw_text(self, gc, x, y, t):
        """
        draw a Text instance
        """
        prop = t.get_font_properties()
        font = AFM(file(fontManager.findfont(prop, fontext='afm')))
        
        text = t.get_text()
        l,b,w,h = font.get_str_bbox(text)

        if text=='': return

        ox, oy = self.compute_text_offsets(t)

        if t.get_rotation()=='vertical':
            x = x+ox
            y = self.height - y+oy-0.001*h
        else:
            x = x+ox
            y = self.height - y+oy

        thetext = '%s' % text
        fontname = font.get_fontname()
        fontsize = prop.get_size_in_points()
        if t.get_rotation()=='vertical':
            rotate = '90 rotate'
        else:
            rotate = ''

        svg = '<text '

        svg += """\
        x=\"%f\"
        y=\"%f\"
        style=\"font-size:%f;stroke-width:1.0000000pt;font-family:helvetica;\" >
        """ % (x,y,float(fontsize))
        svg += thetext+' </text>'
        self.draw_rawsvg(svg)

    def get_svg(self):
        return self._svgwriter.getvalue()

    def finish(self):
        self._svgwriter.write('</svg>')

    def new_gc(self):
        return GraphicsContextSVG()

    def get_text_extent(self, t):
        x, y = t.get_xy_display()

        prop = t.get_font_properties()
        font = AFM(file(fontManager.findfont(prop, fontext='afm')))
        
        text = t.get_text()
        l,b,w,h = font.get_str_bbox(text)

        fontsize = prop.get_size_in_points()
        l *= 0.001*fontsize
        b *= 0.001*fontsize
        w *= 0.001*fontsize
        h *= 0.001*fontsize
        ox, oy = self.compute_text_offsets(t)
        left = x+ox+l
        bottom = y-oy+b
        if t.get_rotation()=='vertical':
            w,h = h,w
        return Bound2D(left, bottom, w, h)

    def _draw_svg(self, type, details, gc, rgbFace):
        svg=type
        if rgbFace is not None:
            rgbhex='fill:#'
            for c in rgbFace:
                rgbhex += hex(int(c*255))[2:]
            rgbhex += ';'
            
        else:
            rgbhex='fill:none;'
        style = self._get_gc_props_svg(gc)
        svg+=style+rgbhex+ ' \"\n'
        svg += details
        svg += ' />\n'
        self._svgwriter.write(svg)

    def _get_gc_props_svg(self, gc):
        color='stroke:'+_rgb_to_hex(gc.get_rgb())+';'
        linewidth = 'stroke-width:'+repr(gc.get_linewidth())+'pt;'
        join = 'stroke-linejoin:'+gc.get_joinstyle()+';'
        cap = 'stroke-linecap:'+gc.get_capstyle()+';'
        offset, seq = gc.get_dashes()
        if seq is not None:
            dashes = 'stroke-dasharray:'
            for s in seq:
                dashes += '%d ' % s
            dashes += ';'
            dashes += 'stroke-dashoffset:%f;' % offset
        else:
            dashes = ''
        style = 'style=\"'+color+linewidth+join+cap+dashes
        return style

class GraphicsContextSVG(GraphicsContextBase):

    def set_linestyle(self, style):
        GraphicsContextBase.set_linestyle(self, style)
        offset, dashes = self._dashd[style]
        self.set_dashes(offset, dashes)

class FigureCanvasSVG(FigureCanvasBase):

    def draw(self):
        pass

    def print_figure(self, filename, dpi=100,
                     facecolor='w', edgecolor='w',
                     orientation='portrait'):
        basename, ext = os.path.splitext(filename)
        if not len(ext): filename += '.svg'

        self._svgwriter = StringIO()

        renderer = RendererSVG(self._svgwriter,self.figure.figsize[0]*dpi,self.figure.figsize[1]*dpi)
        print dpi
        print (self.figure.figsize[0]*dpi,
                              self.figure.figsize[1]*dpi)

        self._svgwriter.write(_svgProlog % (renderer.width,renderer.height))

        self.figure.draw(renderer)
        renderer.finish()

        try: fh = file(filename, 'w')
        except IOError:
            error_msg_svg('Could not open %s for writing' % filename)
            return
        print >>fh, renderer.get_svg()

class FigureManagerSVG(FigureManagerBase):
    pass

FigureManager = FigureManagerSVG
error_msg = error_msg_svg

_svgProlog = """<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>
<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.0//EN\"
\"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd\\&quot;&gt;
<!-- Created with matplotlib (http://matplotlib.sourceforge.net/) -->
<svg
   xmlns=\"http://www.w3.org/2000/svg\\&quot;
   xmlns:xlink=\"http://www.w3.org/1999/xlink\\&quot;
   version=\"1.0\"
   x=\"0.0000000\"
   y=\"0.0000000\"
   width=\"%f\"
   height=\"%f\"
   id=\"svg1\">
"""