All computer source code presented on this page, unless it includes attribution to another author, is provided by Ed Halley under the Artistic License. Use such code freely and without any expectation of support. I would like to know if you make anything cool with the code, or need questions answered.


Produces a series of image files to animate the build-up of a SVG graphic.


This script takes a SVG (scaleable vector graphics) file, and uses the
Inkscape application to render each frame of a movie animation.  If
viewed in sequence, a virtual "camera" is animated along a tour of the
image as it is constructed, entity by entity, from nothing up to the
final construction.

The final movie rebuilds the graphic from the bottom-most layer up to the
top-most layer.  The script ignores hidden layers (i.e., whenever it
finds style="display:none").  The virtual "camera" pans and zooms to the
bounding area of each element smoothly, and then the new element is added
to the composition.

Depending on system performance and drawing complexity, expect a
rendering speed of one frame per second.  Be wary of any special effects
offered in newer Inkscape versions; in particular, zooming in close near
a highly blurred path can cause rendering time to skyrocket.


    %  svgbuild  --path --text  monalisa.svg
    Rendered movie/monalisa06789.png
    Time: 3h:08m


    %  svgbuild  --help
    %  svgbuild  -h

    Command line options offer control over many aspects of rendering
    the animation, such as camera movement, element drawing style, or
    where to put the rendered frames.

    Long form:      Short:    Default value:   Purpose:
    ----------      ------    --------------   --------------------------

      --path          -p        False/Off      build up paths visually
      --text          -t        False/Off      build up text visually
      --image         -i        False/Off      build up images visually

      --width         -w        640            pixel width of frames
      --height        -h        480            pixel height of frames
      --folder        -f        'movie'        location to put frames
      --name          -n        'movie'        prefix for each frame
      --temp          -T        'temp.svg'     name of temporary svg
      --background    -B        'white'        web color behind svg

      --zoom          -z        8.0            limit zoom to 1/z of whole
      --dally         -d        4              hold after drawing element
      --dolly         -D        50             time limit for panning
      --hold          -H        100            extra hold frames at end

      --from          -F        0              first frame to be written
      --until         -U        99999          last frame to be written
                      -X                       do not write any frames


    Ed Halley ( 6 April 2008


    This is a complete reworking of the script to be a bit more general,
    working with the lxml etree library to manipulate the elements of the
    XML in a recursive fashion.  No "xml pretty printer" is required to
    run now.  This will hopefully make it easier to get running on
    multiple platforms with less compatibility hassle.

    In addition, the frame name format and range has moved to five digits,
    and the writing of frames in a given numerical window has been added.
    If you notice something awry during rendering, you can stop, fix the
    SVG element in question, and restart at a frame just before the
    problem element was being built.  Running without rendering any frames
    will let you see the order that each element is found, and at what
    frame number it would be introduced in the animation.


    Certainly there are plenty of buried assumptions and things that
    will break when faced with the unexpected.


    The output is a series of PNG image files, in a subdirectory
    with serial numbered filenames that are five digits long.  For
    example, movie/movie00023.png is the 24th frame of animation.

    To join those as an animation, many third-party tools can take an
    image sequence like this.  QuickTime Pro has an "Open Image
    Sequence" menu choice.  The mplayer project's mencoder or ffmpeg
    tools also work.  Here's an ffmpeg command line the author uses:

    %  ffmpeg -r 24 -b 4000 -aspect 1 \
              -i 'movie/movie%05d.png' \
              -i 'soundtrack.mp3' \
              -f mpegvideo \
              -y 'movie/movie.mpeg'


    Also uses some python routines for vector math and interpolations,
    to be found at


# Arrange or adjust these if they're not on your PATH or platform.
import os

if == 'nt':
    # see to learn how to make inker.bat
    inkscape = r'd:\bin\Inkscape\inker.bat'
    temporary = r't:\TEMP\_svg'
    identify = r'd:\bin\ImageMagick\identify.exe'
    convert = r'd:\bin\ImageMagick\convert.exe'
    inkscape = r'"/Applications/Art Tools/"'
    temporary = r'/tmp/_svg'
    identify = '/opt/local/bin/identify'
    convert = '/opt/local/bin/convert'

import interpolations
import vectors ; from vectors import *

from lxml import etree
import subprocess
import shutil
import time
import sys
import re
import os

# conveniences

def _qx(cmd, verbose=False):
    # naive version of qx() is not portable to Windows
    if verbose: print cmd
    output = os.popen(cmd + ' 2>/dev/null').read()
    return output

def qx(cmd, verbose=False):
    '''Just like qx// or backticks operator from Perl, running the command
    and returning the STDOUT results as a string.  Optional echo of the
    command issued first.
    if verbose: print cmd
    run = subprocess.Popen(cmd, shell=True,
    (out, err) = run.communicate()
    if run.returncode != 0:
        return 'Return code: %d' % run.returncode
    return out

def system(cmd, verbose=False):
    '''Just like os.system() but with optional echo of the command.'''
    if verbose: print cmd
    return os.system(cmd)

def boolify(value):
    '''Take a friendly user input value, and turn it into True or False.'''
    if value in (True, 1, 'y', 'yes', 'on', 'enable'):
        return True
    if value in (None, False, 0, 'n', 'no', 'off', 'disable'):
        return False
    return True

def getopt(arg, tail, opt, default):
    '''Super-lightweight implementation of one --option=value parsing.
        -o       / --option        (returns True if default is a bool)
        -o=value / --option=value  (returns value in same type as default)
        -o value / --option value  (returns value in same type as default)
    Pops values from tail (usually remainder of argv list) only if required.
    value = None
    o = opt[0]
    opt = opt.lower()
    match = re.match(r"^(-%s|--%s)$" % (o, opt), arg)
    if match:
        if isinstance(default, (bool, type(None))):
            return True
        if not len(tail):
            raise ValueError, 'Option %s needs an argument.'
        value = tail.pop(0)
        match = re.match(r"^(-%s|--%s)=(.*)$" % (o, opt), arg)
        if match:
            value =
    if value is None:
        return default
    if isinstance(default, bool):
        return boolify(value.lower())
    if isinstance(default, int):
        return int(value)
    if isinstance(default, float):
        return float(value)
    return value

def usage(this, options):
    '''Super-lightweight implementation of command-line usage help.
    Does not have anything particularly wordy about the meanings of each
    option and inputfiles.
    print 'usage:', this, '<options>', '<inputfiles>'
    print 'options and (default) values:'
    for option in options:
        print '\t--%-15s\t(%s)' % (option, repr(options[option]))

def getopts(argv, options):
    '''Super-lightweight implementation of command-line argument parsing.
    Give it the sys.argv list (without the script name), and a dict of
    default values, like:
        options = { 'flag': False,   # -f,--flag,--flag=Yes,-f False
                    'number': 3,     # -n 2, -n=4, --number=6,-n 5
                    'Name': 'Sally', # -N Mary,--name John,--name=Bill
    Assumes initial letters are unique and --options are lowercase.
    (Especially note the -n/--number and -N/--name examples above.)
    Does no fancy unique-prefix magic to determine useful options.
    Does no list or increment handling for -n=3 -n=4 or +v +v +v.
    Does not indicate any ordering of options received; last value wins.
    Returns list of all non-option arguments in order found, including
    any lone - argument.  Anything after a -- are non-option arguments.
    others = [ ]
    this = argv.pop(0)
    while argv:
        arg = argv.pop(0)
        if arg == '--':
            argv[:] = []
        if arg in ('-h', '-?', '--help'):
            usage(this, options)
        elif len(arg) and arg[0] == '-' and arg != '-':
            for opt in options:
                options[opt] = getopt(arg, sys.argv, opt, options[opt])
    return others


class SVG:

    def __init__(self):
        '''Prepares the virtual svg drawing container.'''
        self.filename = None
        self.tree = None
        self.root = None
        self.ids = { }

    def survey(self):
        '''Scan through the XML entities to ensure proper id attributes.
        Inkscape files write a unique id for each element, and gives a
        general "flipped Y" coordinate space inside a page of known size.
        Non-Inkscape SVG files may not comply with these optional niceties.
        We check that these features are available so Inkscape can render
        and resolve rendering locations for every entity later on.
        # ensure at least a default page size (arbitrarily, us letter)
        if 'width' not in self.root.attrib:
            self.root.attrib['width'] = '744.09448819'
        if 'height' not in self.root.attrib:
            self.root.attrib['height'] = '1052.3622047'
        # scan all elements in tree
        elements = [ self.root ] + self.root.findall(".//*")
        self.ids = { }
        for element in elements:
            if 'id' in element.attrib:
                self.ids[element.attrib['id']] = element
        # if any have no id at all, give them a new unique id
        unique = 0
        for element in elements:
            if not 'id' in element.attrib:
                id = 'uniq%d' % unique
                while id in self.ids:
                    unique += 1
                    id = 'uniq%d' % unique
                element.attrib['id'] = id
                self.ids[id] = element
        print 'Surveyed %d elements.' % len(self.ids.keys())
        return len(self.ids.keys())

    def read(self, filename):
        '''Requests the XML data be read from a file.'''
        self.filename = filename
        self.tree = etree.parse(filename)
        self.root = self.tree.getroot()


class Camera:

    def __init__(self, options):
        '''Construct a virtual camera.'''
        self.locked = False
        self.time = 0
        self.area = [ 0., 0., 1., 1. ]
        self.temp = options['folder'] + '/' + options['Temp']
        self.width = float(options['width'])
        self.height = float(options['height'])
        self.dally = options['dally']
        self.dolly = options['Dolly']
        self.layout = { }

    def _write(self, svg):
        # Save a scratch prepared copy of the xml to be used by Inkscape
        file = open(self.temp, 'w')
        file.write(etree.tostring(svg.root, pretty_print=True))

    def survey(self, svg):
        '''Learn the locations of all elements.'''
        if self.layout: return
        # ask Inkscape for a survey of all ids
        settings = ' '.join( [ '-z',
                               ] )
        command = ' '.join( [ inkscape, settings, self.temp ] )
        result = qx(command)
        result = result.split('\n')
        layout = self.layout
        page = [ float(svg.root.attrib['width']),
                 float(svg.root.attrib['height']) ]
        for line in result:
            fields = line.split(',')
            if len(fields) != 5: continue
            area = [ float(x) for x in fields[1:] ]
            area[2] += area[0]
            area[3] += area[1]
            layout[fields[0]] = self._flip(area, page)
        self.limit = max(page) / options['zoom']
        print 'Surveyed %d element locations.' % len(layout.keys())
        return len(layout.keys())

    def cleanup(self):
        '''Remove any temporary files required for rendering.'''
        if os.path.exists(self.temp):

    def _flip(self, area, page):
        # Helper to turn --query-all rects into rendering area rects.
        flipped = list(area[:])
        high = abs(area[3]-area[1])
        flipped[1] = page[1] - min(area[1],area[3]) - high
        flipped[3] = flipped[1] + high
        if flipped[2] < flipped[0]:
            flipped[0],flipped[2] = flipped[2],flipped[0]
        return flipped

    def locate(self, target):
        '''Find a target (element id or area rect) and convert it
        as necessary to return the area rect.
        area = None
        if isinstance(target, list):
            area = target
        elif target in self.layout:
            area = self.layout[target]
        return area

    def move(self, target):
        '''Find a target (element id or area rect) and move camera
        to view it instantly.
        area = self.locate(target)
        if area:
            self.area = area
        return area

    def _extent(self, target, fill=False):
        # Adjusts a target area to match the camera's proper aspect ratio.
        area = self.locate(target)
        if area[3] == area[1]:
            area[3] += 1
        high = float(area[3]-area[1])
        wide = float(area[2]-area[0])
        ratio = wide / high
        shape = self.width / self.height
        if (ratio > shape) == fill:
            mid = float(area[2]+area[0])/2.
            wide = high * shape
            area[0] = mid - wide/2.
            area[2] = mid + wide/2.
            mid = float(area[3]+area[1])/2.
            high = wide / shape
            area[3] = mid + high/2.
            area[1] = mid - high/2.
        return area

    def fill(self, target):
        '''Adjust an area to ensure its center fills the camera's view.'''
        return self._extent(target, fill=True)

    def fit(self, target):
        '''Adjust an area to ensure it fits within the camera's view.'''
        return self._extent(target, fill=False)

    def speed(self, before, after):
        '''Given two rectangles, calculate how many frames
        of animation to spend on a nice swoop from one to the other.
        Number of frames is bounded.
        page = [ float(svg.root.attrib['width']),
                 float(svg.root.attrib['height']) ]
        before = V( (before[2]+before[0])/2.,
                    (before[3]+before[1])/2. )
        after = V( (after[2]+after[0])/2.,
                   (after[3]+after[1])/2. )
        dist = vectors.distance(before, after)
        ts = int(interpolations.linear( 0, max(page),
                                        self.dally, self.dolly ))
        ts = min(max(self.dally, ts), self.dolly)
        return ts

    def zoom(self, target, amount=1.0):
        '''Given a target area rect, ensure the area is not too small.'''
        area = self.locate(target)
        if area[3] == area[1]:
            area[3] += 1
        high = float(area[3]-area[1])
        wide = float(area[2]-area[0])
        ratio = wide / high
        if high < self.limit:
            high = self.limit
            wide = high * ratio
        mid = float(area[2]+area[0])/2.
        wide *= amount
        area[0] = mid - wide/2.
        area[2] = mid + wide/2.
        mid = float(area[3]+area[1])/2.
        high *= amount
        area[3] = mid + high/2.
        area[1] = mid - high/2.
        return area

    def shoot(self, svg, marker='>'):
        '''Render one image at the current camera position.'''
        # Includes two hacks (spill and convert -extent)
        # to fix imprecise image output sizes.
        # Also applies background color to avoid alpha movie problems.
        if options['From'] <= self.time <= options['Until']:
            output = "%s/%s%05d.png" % (options['folder'],
            spill = (self.area[3]-self.area[1]) / 20.
            area = "%d:%d:%d:%d" % (self.area[0],
                                    self.area[3] + spill)
            settings = ' '.join( [ '-z',
                                   '--export-png=%s' % output,
                                   '--export-area=%s' % area,
                                   '--export-width=%d' % options['width'],
                                   ] )
            command = ' '.join( [ inkscape, settings, self.temp ] )
            results = qx(command)
            conversion = ' '.join( [ '-background %s' % options['Background'],
                                     '-extent %dx%d+0+0!' % (options['width'],
                                     ] )
            command = ' '.join( [ convert, output, conversion, output,
                                  '&' ] )
            results = qx(command)
            print '  ' + marker, output
        self.time += 1

    def hold(self, ts=1):
        '''Make a number of duplicates of the most recent frame written.'''
        if ts <= 0: return
        before = "%s/%s%05d.png" % (options['folder'],
        for i in range(ts):
            after = "%s/%s%05d.png" % (options['folder'],
            if options['From'] <= self.time <= options['Until']:
                shutil.copyfile(before, after)
                print '  =', after
            self.time += 1

    def pan(self, svg, target, ts=0, margin=1.0):
        '''Shoot the intervening frames from the current camera area toward
        a target camera area.  The camera speed eases into the motion and
        eases to a stop, rather than lurching with a simple linear
        interpolation, but the motion is in a direct path.
        before = self.area
        if isinstance(target, list):
        elif target in self.layout:
            target = self.zoom(, margin)
        if not ts:
            ts = self.speed(before, target)
        a = V(before)
        b = V(before)
        c = V(target)
        d = V(target)
        for i in range(ts):
            tt = (i + 1) / float(ts)
            where = interpolations.bezier( tt, a, b, c, d ).list()
            self.shoot(svg, marker='-')


def build_image(svg, camera, entity, options):
    '''Special progressive drawing of an image element.
    The image will be included a few scanlines at a time until whole.'''
    href = '{}href'
    if not href in entity.attrib: return
    if not os.path.exists(identify):
        print 'ImageMagick "identify" tool not found; skipping.'
    if not os.path.exists(convert):
        print 'ImageMagick "convert" tool not found; skipping.'
    img = entity.attrib[href]
    if not os.path.exists(img):
        print 'Image file not found locally:', img
    # figure out original image's pixel size
    results = qx('%s %s' % (identify, img))
    m ='(\d+)x(\d+)', results)
    if not m:
        print 'ImageMagick could not identify size of image; skipping.'
    size = [ int(, int( ]
    # for a handful of frames, replace image with a truncated temporary image
    tmp = options['folder'] + '/temp.png'
    frames = int(options['dally']) * 4
    for frame in range(frames):
        height = interpolations.linear(0, frames, frame, 1, size[1])
        command = ' '.join( [ convert,
                              '-type TrueColorMatte',
                              '-channel alpha',
                              '-background "#00000000"',
                              '-crop %dx%d+0+0' % (size[0], height),
                              '-extent %dx%d' % (size[0], size[1]),
                              tmp ] )
        results = qx(command)
        if os.path.exists(tmp):
            entity.attrib[href] = tmp
    # replace the original image reference
    entity.attrib[href] = img

def build_path(svg, camera, entity, options):
    '''Special progressive drawing of a path element.
    The path will be included one bezier element at a time until whole.'''
    if not 'd' in entity.attrib: return
    # replace style with our own style
    style = ''
    if 'style' in entity.attrib:
        style = entity.attrib['style']
    width = (camera.area[3]-camera.area[1]) / float(camera.height)
    hairline = ''.join([ 'opacity:1;', 'overflow:visible;',
                         'fill:none;', 'fill-opacity:0.;',
                         'stroke-width:%f;' % width,
                         'stroke-linecap:round;', 'stroke-linejoin:round;',
                         'marker:none;', 'marker-start:none;',
                         'marker-mid:none;', 'marker-end:none;',
                         'stroke-miterlimit:4;', 'stroke-dasharray:none;',
                         'stroke-dashoffset:0;', 'stroke-opacity:1;',
                         'visibility:visible;', 'display:inline;',
                         'enable-background:accumulate' ])
    entity.attrib['style'] = hairline
    # scan the control points
    points = entity.attrib['d'].split(' ')
    built = [ ]
    # each control point is a letter, followed by some floating-point pairs
    while points:
        built.append( points.pop(0) )
        while points and not re.match(r'^[a-zA-Z]$', points[0]):
            built.append( points.pop(0) )
        # add the point to our path
        entity.attrib['d'] = ' '.join(built)
    # put the original style back
    entity.attrib['style'] = style

def build_text(svg, camera, entity, options):
    '''Special progressive drawing of a text or tspan contents.
    The text will appear one letter at a time until whole.'''
    text = entity.text
    entity.text = ''
    # if we have children, recurse to build their .text now
    if entity.getchildren():
        children = [ ]
        for child in entity.iterchildren():
            if child.text:
        for child in children:
        for child in children:
            build_text(svg, camera, child, options)
    # come back to build our own direct text
    if not text: return
    for l in range(1, len(text)):
        entity.text = text[:l]
    entity.text = text

def build(svg, camera, entity, options):
    '''Recursively build up the given entity, by removing all its children
    and adding them back in one at a time, and shooting the progress with
    the given camera.
    id = entity.attrib['id']
    name = id
    label = '}label'
    if label in entity.attrib:
        name = entity.attrib[label]
    print '%05d - Building up <%s id="%s"> (%s)...' % (camera.time, entity.tag, id, name)

    nobuild = set([ '{}defs',

    nochild = set([ '{}text',

    backable = set([ '{}g',

    ripped = [ ]
    for child in entity.iterchildren():
        if child.tag in nobuild: continue
        if not child.attrib['id'] in camera.layout: continue
        if 'style' in child.attrib:
            if 'display:none' in child.attrib['style']:
    for child in ripped:
    backward = True
    if not entity.tag in backable: backward = False
    if not 'back' in id and not 'back' in name: backward = False
    if backward:
        print ' (Building children of entity %s backwards.)' % id
    for child in ripped:
        print ' Adding child <%s id="%s">...' % (child.tag, child.attrib['id'])
        camera.pan(svg, child.attrib['id'], margin=1.2)
        if backward:
            entity.insert(0, child)
        if child.getchildren() and not child.tag in nochild:
            build(svg, camera, child, options)
        elif options['path'] and"\}path$", child.tag):
            build_path(svg, camera, child, options)
        elif options['image'] and"\}image$", child.tag):
            build_image(svg, camera, child, options)
        elif options['text'] and"\}text$", child.tag):
            build_text(svg, camera, child, options)
        camera.hold(options['dally'] - 1)

    camera.pan(svg, id)

# operate as a command-line driven tool

if __name__ == "__main__":

    # process command-line options
    options = { 'folder': 'movie',
                'name': 'movie',
                'Temp': 'temp.svg',
                'From': 0,
                'Until': 99999,
                'image': False,
                'path': False,
                'text': False,
                'width': 640,
                'height': 480,
                'dally': 4,
                'Dolly': 50,
                'Hold': 100,
                'Background': 'white',
                'zoom': 6.,
                'Xx': False,
    this = sys.argv[0]
    files = getopts(sys.argv, options)
    if not files:
        print 'No SVG files were specified.'
        usage(this, options)
    if options['width'] < 1 or options['height'] < 1:
        print 'Invalid output pixel --height or --width specified.'
        usage(this, options)
    if zero(options['zoom']) or options['zoom'] < 0:
        print 'Zoom limiting value is invalid; must be positive.'
        usage(this, options)
    if options['Xx']:
        options['From'] = options['Until'] = -1

    # overall preparations
    overall = time.time()
    if not os.path.exists(options['folder']):

    # build each file in turn with same options
    for file in files:
            start = time.time()
            print 'Starting buildup of %s...' % file

            svg = SVG()
            camera = Camera(options)
            if camera.survey(svg):
                build(svg, camera, svg.root, options)
                print 'Finishing...'

            finish = time.time()
            hrs = int((finish - start) / 60) / 60
            min = int((finish - start) / 60) % 60
            folder = options['folder']
            print 'Finished %s to %s in %dh:%02dm.' % (file, folder, hrs, min)
        except type(None), e: # except Exception, e:
            print str(e)

    # overall summary if multiple files given
    if len(files) > 1:
        finish = time.time()
        hrs = int((finish - start) / 60) / 60
        min = int((finish - start) / 60) % 60
        print 'Done in %dh:%02dm overall.' % (hrs, min)

Contact Ed Halley by email at
Text, code, layout and artwork are Copyright © 1996-2013 Ed Halley.
Copying in whole or in part, with author attribution, is expressly allowed.
Any references to trademarks are illustrative and are controlled by their respective owners.
Make donations with PayPal - it's fast, free and secure!