Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets

Add drawing canvas and basic SVG primitives

+2
.gitignore
···
+
*.pyc
+
+46
__init__.py
···
+
'''
+
A library for creating SVG files or just drawings that can be displayed in
+
iPython notebooks
+
+
Example:
+
```
+
d = draw.Drawing(200, 100, origin='center')
+
+
d.append(draw.Lines(-80, -45,
+
70, -49,
+
95, 49,
+
-90, 40,
+
close=False,
+
fill='#eeee00',
+
stroke='black'))
+
+
d.append(draw.Rectangle(0,0,40,50, fill='#1248ff'))
+
d.append(draw.Circle(-40, -10, 30,
+
fill='red', stroke_width=2, stroke='black'))
+
+
p = draw.Path(stroke_width=2, stroke='green',
+
fill='black', fill_opacity=0.5)
+
p.M(-30,5)
+
p.l(60,30)
+
p.h(-70)
+
p.Z()
+
d.append(p)
+
+
d.append(draw.ArcLine(60,-20,20,60,270,
+
stroke='red', stroke_width=5, fill='red', fill_opacity=0.2))
+
d.append(draw.Arc(60,-20,20,60,270,cw=False,
+
stroke='green', stroke_width=3, fill='none'))
+
d.append(draw.Arc(60,-20,20,270,60,cw=True,
+
stroke='blue', stroke_width=1, fill='black', fill_opacity=0.3))
+
+
d.setPixelScale(2) # Set number of pixels per geometry unit
+
#d.setRenderSize(400,200) # Alternative to setPixelScale
+
d.saveSvg('example.svg')
+
+
d # Display in iPython notebook
+
```
+
'''
+
+
from .drawing import Drawing
+
from .elements import *
+
+100
drawing.py
···
+
+
from io import StringIO
+
+
from . import elements as elementsModule
+
+
+
class Drawing:
+
''' A canvas to draw on
+
+
Supports iPython: If a Drawing is the last line of a cell, it will be
+
displayed as an SVG below. '''
+
def __init__(self, width, height, origin=(0,0)):
+
assert float(width) == width
+
assert float(height) == height
+
self.width = width
+
self.height = height
+
if origin == 'center':
+
self.viewBox = (width/2, height/2, width, height)
+
else:
+
origin = tuple(origin)
+
assert len(origin) == 2
+
self.viewBox = origin + (width, height)
+
self.viewBox = (-self.viewBox[0], self.viewBox[1]-self.viewBox[3],
+
self.viewBox[2], self.viewBox[3])
+
self.elements = []
+
self.pixelScale = 1
+
self.renderWidth = None
+
self.renderHeight = None
+
def setRenderSize(self, w=None, h=None):
+
self.renderWidth = w
+
self.renderHeight = h
+
return self
+
def setPixelScale(self, s=1):
+
self.renderWidth = None
+
self.renderHeight = None
+
self.pixelScale = s
+
return self
+
def calcRenderSize(self):
+
if self.renderWidth is None and self.renderHeight is None:
+
return (self.width * self.pixelScale,
+
self.height * self.pixelScale)
+
elif self.renderWidth is None:
+
s = self.renderHeight / self.height
+
return self.width * s, self.renderHeight
+
elif self.renderWidth is None:
+
s = self.renderWidth / self.width
+
return self.renderWidth, self.height * s
+
else:
+
return self.renderWidth, self.renderHeight
+
def draw(self, obj, **kwargs):
+
if not hasattr(obj, 'writeSvgElement'):
+
elements = obj.toDrawables(elements=elementsModule, **kwargs)
+
else:
+
assert len(kwargs) == 0
+
elements = (obj,)
+
self.extend(elements)
+
def append(self, element):
+
self.elements.append(element)
+
def extend(self, iterable):
+
self.elements.extend(iterable)
+
def insert(self, i, element):
+
self.elements.insert(i, element)
+
def remove(self, element):
+
self.elements.remove(element)
+
def clear(self):
+
self.elements.clear()
+
def index(self, *args, **kwargs):
+
self.elements.index(*args, **kwargs)
+
def count(self, element):
+
self.elements.count(element)
+
def reverse(self):
+
self.elements.reverse()
+
def asSvg(self, outputFile=None):
+
returnString = outputFile is None
+
if returnString:
+
outputFile = StringIO()
+
imgWidth, imgHeight = self.calcRenderSize()
+
startStr = '''<?xml version="1.0" encoding="UTF-8"?>
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+
width="{}" height="{}" viewBox="{} {} {} {}">'''.format(
+
imgWidth, imgHeight, *self.viewBox)
+
endStr = '</svg>'
+
outputFile.write(startStr)
+
outputFile.write('\n')
+
for element in self.elements:
+
try:
+
element.writeSvgElement(outputFile)
+
outputFile.write('\n')
+
except AttributeError:
+
pass
+
outputFile.write(endStr)
+
if returnString:
+
return outputFile.getvalue()
+
def saveSvg(self, fname):
+
with open(fname, 'w') as f:
+
self.asSvg(outputFile=f)
+
def _repr_svg_(self):
+
''' Display in Jupyter notebook '''
+
return self.asSvg()
+
+183
elements.py
···
+
+
import math
+
+
# TODO: Support drawing ellipses without manually using Path
+
+
+
class DrawingElement:
+
''' Base class for drawing elements
+
+
Subclasses must implement writeSvgElement '''
+
def writeSvgElement(self, outputFile):
+
raise NotImplementedError('Abstract base class')
+
def __eq__(self, other):
+
return self is other
+
+
class DrawingBasicElement(DrawingElement):
+
''' Base class for SVG drawing elements that are a single node with no
+
child nodes '''
+
TAG_NAME = '_'
+
def __init__(self, **args):
+
self.args = args
+
def writeSvgElement(self, outputFile):
+
outputFile.write('<')
+
outputFile.write(self.TAG_NAME)
+
outputFile.write(' ')
+
for k, v in self.args.items():
+
k = k.replace('_', '-')
+
outputFile.write('{}="{}" '.format(k,v))
+
outputFile.write('/>')
+
def __eq__(self, other):
+
if isinstance(other, type(self)):
+
return (self.tagName == other.tagName and
+
self.args == other.args)
+
return False
+
+
class NoElement(DrawingElement):
+
''' A drawing element that has no effect '''
+
def __init__(self): pass
+
def writeSvgElement(self, outputFile):
+
pass
+
def __eq__(self, other):
+
if isinstance(other, type(self)):
+
return True
+
return False
+
+
class Rectangle(DrawingBasicElement):
+
''' A rectangle
+
+
Additional keyword arguments are ouput as additional arguments to the
+
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
TAG_NAME = 'rect'
+
def __init__(self, x, y, width, height, **kwargs):
+
super().__init__(x=x, y=-y-height, width=width, height=height,
+
**kwargs)
+
+
class Circle(DrawingBasicElement):
+
''' A circle
+
+
Additional keyword arguments are ouput as additional properties to the
+
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
TAG_NAME = 'circle'
+
def __init__(self, cx, cy, r, **kwargs):
+
super().__init__(cx=cx, cy=-cy, r=r, **kwargs)
+
+
class ArcLine(Circle):
+
''' An arc
+
+
In most cases, use Arc instead of ArcLine. ArcLine uses the
+
stroke-dasharray SVG property to make the edge of a circle look like
+
an arc.
+
+
Additional keyword arguments are ouput as additional arguments to the
+
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs):
+
if endDeg - startDeg == 360:
+
super().__init__(cx, cy, r, **kwargs)
+
return
+
startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360
+
arcDeg = (endDeg - startDeg) % 360
+
def arcLen(deg): return math.radians(deg) * r
+
wholeLen = 2 * math.pi * r
+
if endDeg == startDeg:
+
offset = 1
+
dashes = "0 {}".format(wholeLen+2)
+
#elif endDeg >= startDeg:
+
elif True:
+
startLen = arcLen(startDeg)
+
arcLen = arcLen(arcDeg)
+
offLen = wholeLen - arcLen
+
offset = -startLen
+
dashes = "{} {}".format(arcLen, offLen)
+
#else:
+
# firstLen = arcLen(endDeg)
+
# secondLen = arcLen(360-startDeg)
+
# gapLen = wholeLen - firstLen - secondLen
+
# offset = 0
+
# dashes = "{} {} {}".format(firstLen, gapLen, secondLen)
+
super().__init__(cx, cy, r, stroke_dasharray=dashes,
+
stroke_dashoffset=offset, **kwargs)
+
+
class Path(DrawingBasicElement):
+
''' An arbitrary path
+
+
Path Supports building an SVG path by calling instance methods
+
corresponding to path commands.
+
+
Additional keyword arguments are ouput as additional properties to the
+
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
TAG_NAME = 'path'
+
def __init__(self, d='', **kwargs):
+
super().__init__(d=d, **kwargs)
+
def append(self, commandStr, *args):
+
if len(self.args['d']) > 0:
+
commandStr = ' ' + commandStr
+
if len(args) > 0:
+
commandStr = commandStr + ','.join(map(str, args))
+
self.args['d'] += commandStr
+
def M(self, x, y): self.append('M', x, -y)
+
def m(self, dx, dy): self.append('m', dx, -dy)
+
def L(self, x, y): self.append('L', x, -y)
+
def l(self, dx, dy): self.append('l', dx, -dy)
+
def H(self, x, y): self.append('H', x)
+
def h(self, dx): self.append('h', dx)
+
def V(self, y): self.append('V', -y)
+
def v(self, dy): self.append('v', -dy)
+
def Z(self): self.append('Z')
+
def C(self, cx1, cy1, cx2, cy2, ex, ey):
+
self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey)
+
def c(self, cx1, cy1, cx2, cy2, ex, ey):
+
self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey)
+
def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey)
+
def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey)
+
def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey)
+
def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey)
+
def T(self, ex, ey): self.append('T', ex, -ey)
+
def t(self, ex, ey): self.append('t', ex, -ey)
+
def A(self, rx, ry, rot, largeArc, sweep, ex, ey):
+
self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey)
+
def a(self, rx, ry, rot, largeArc, sweep, ex, ey):
+
self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey)
+
def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, includeL=False):
+
''' Uses A() to draw a circular arc '''
+
largeArc = (endDeg - startDeg) % 360 > 180
+
startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180
+
sx, sy = r*math.cos(startRad), r*math.sin(startRad)
+
ex, ey = r*math.cos(endRad), r*math.sin(endRad)
+
if includeL:
+
self.L(cx+sx, cy+sy)
+
elif includeM:
+
self.M(cx+sx, cy+sy)
+
self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey)
+
+
class Lines(Path):
+
''' A sequence of connected lines (or a polygon)
+
+
Additional keyword arguments are ouput as additional properties to the
+
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
def __init__(self, sx, sy, *points, close=False, **kwargs):
+
super().__init__(d='', **kwargs)
+
self.M(sx, sy)
+
assert len(points) % 2 == 0
+
for i in range(len(points) // 2):
+
self.L(points[2*i], points[2*i+1])
+
if close:
+
self.Z()
+
+
class Line(Lines):
+
''' A line
+
+
Additional keyword arguments are ouput as additional properties to the
+
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
def __init__(self, sx, sy, ex, ey, **kwargs):
+
super().__init__(sx, sy, ex, ey, close=False, **kwargs)
+
+
class Arc(Path):
+
''' An arc
+
+
Additional keyword arguments are ouput as additional properties to the
+
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs):
+
super().__init__(d='', **kwargs)
+
self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True)
+