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

Add support for using SVG defs and referencing them from drawing elements, add RadialGradient def

+10
__init__.py
···
```
'''
from .raster import Raster
from .drawing import Drawing
from .elements import *
···
```
'''
+
from .defs import *
from .raster import Raster
from .drawing import Drawing
from .elements import *
+
# Make all elements available in the elements module
+
from . import defs
+
from . import elements
+
elementsDir = dir(elements)
+
for k in dir(defs):
+
if k.startswith('_'): continue
+
if k in elementsDir: continue
+
setattr(elements, k, getattr(defs, k))
+
+46
defs.py
···
···
+
+
from .elements import DrawingElement, DrawingParentElement
+
+
+
class DrawingDef(DrawingParentElement):
+
''' Parent class of SVG nodes that must be direct children of <defs> '''
+
@property
+
def id(self):
+
return self.args.get('id', None)
+
@id.setter
+
def id(self, newId):
+
self.args['id'] = newId
+
def getSvgDefs(self):
+
return (self,)
+
def writeSvgDefs(self, idGen, isDuplicate, outputFile):
+
DrawingElement.writeSvgDefs(idGen, isDuplicate, outputFile)
+
+
class DrawingDefSub(DrawingParentElement):
+
''' Parent class of SVG nodes that are meant to be descendants of a Def '''
+
pass
+
+
class RadialGradient(DrawingDef):
+
''' A radial gradient to use as a fill or other color
+
+
Has <stop> nodes as children. '''
+
TAG_NAME = 'radialGradient'
+
def __init__(self, cx, cy, r, gradientUnits='userSpaceOnUse', fy=None, **kwargs):
+
yShift = 0
+
if gradientUnits != 'userSpaceOnUse':
+
yShift = 1
+
try: cy = yShift - cy
+
except TypeError: pass
+
try: fy = yShift - fy
+
except TypeError: pass
+
super().__init__(cx=cx, cy=cy, r=r, gradientUnits=gradientUnits,
+
fy=fy, **kwargs)
+
def addStop(self, offset, color, opacity=None, **kwargs):
+
stop = GradientStop(offset=offset, stop_color=color,
+
stop_opacity=opacity, **kwargs)
+
self.append(stop)
+
+
class GradientStop(DrawingDefSub):
+
''' A control point for a radial or linear gradient '''
+
TAG_NAME = 'stop'
+
hasContent = False
+
+21 -1
drawing.py
···
imgWidth, imgHeight, *self.viewBox)
endStr = '</svg>'
outputFile.write(startStr)
-
outputFile.write('\n')
for element in self.elements:
try:
element.writeSvgElement(outputFile)
···
imgWidth, imgHeight, *self.viewBox)
endStr = '</svg>'
outputFile.write(startStr)
+
outputFile.write('\n<defs>\n')
+
# Write definition elements
+
idIndex = 0
+
def idGen(base='d'):
+
nonlocal idIndex
+
idStr = base + str(idIndex)
+
idIndex += 1
+
return idStr
+
prevSet = set()
+
def isDuplicate(obj):
+
nonlocal prevSet
+
dup = id(obj) in prevSet
+
prevSet.add(id(obj))
+
return dup
+
for element in self.elements:
+
try:
+
element.writeSvgDefs(idGen, isDuplicate, outputFile)
+
except AttributeError:
+
pass
+
outputFile.write('</defs>\n')
+
# Write normal elements
for element in self.elements:
try:
element.writeSvgElement(outputFile)
+55 -3
elements.py
···
import math
import os.path
import base64
import warnings
import xml.sax.saxutils as xml
# TODO: Support drawing ellipses without manually using Path
···
Subclasses must implement writeSvgElement '''
def writeSvgElement(self, outputFile):
raise NotImplementedError('Abstract base class')
def __eq__(self, other):
return self is other
···
def writeSvgElement(self, outputFile):
outputFile.write('<')
outputFile.write(self.TAG_NAME)
-
outputFile.write(' ')
for k, v in self.args.items():
k = k.replace('__', ':')
k = k.replace('_', '-')
-
outputFile.write('{}="{}" '.format(k,v))
if not self.hasContent:
-
outputFile.write('/>')
else:
outputFile.write('>')
self.writeContent(outputFile)
···
''' Override in a subclass to add data between the start and end
tags. This will not be called if hasContent is False. '''
raise RuntimeError('This element has no content')
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 '''
···
+
import sys
import math
import os.path
import base64
import warnings
import xml.sax.saxutils as xml
+
from . import defs
+
+
elementsModule = sys.modules[__name__]
+
# TODO: Support drawing ellipses without manually using Path
···
Subclasses must implement writeSvgElement '''
def writeSvgElement(self, outputFile):
raise NotImplementedError('Abstract base class')
+
def getSvgDefs(self):
+
return ()
+
def writeSvgDefs(self, idGen, isDuplicate, outputFile):
+
for defn in self.getSvgDefs():
+
if isDuplicate(defn): continue
+
defn.id = idGen()
+
defn.writeSvgElement(outputFile)
+
outputFile.write('\n')
def __eq__(self, other):
return self is other
···
def writeSvgElement(self, outputFile):
outputFile.write('<')
outputFile.write(self.TAG_NAME)
for k, v in self.args.items():
+
if v is None: continue
k = k.replace('__', ':')
k = k.replace('_', '-')
+
if isinstance(v, defs.DrawingDef):
+
v = 'url(#{})'.format(v.id)
+
outputFile.write(' {}="{}"'.format(k,v))
if not self.hasContent:
+
outputFile.write(' />')
else:
outputFile.write('>')
self.writeContent(outputFile)
···
''' Override in a subclass to add data between the start and end
tags. This will not be called if hasContent is False. '''
raise RuntimeError('This element has no content')
+
def getSvgDefs(self):
+
return [v for v in self.args.values() if isinstance(v, defs.DrawingDef)]
def __eq__(self, other):
if isinstance(other, type(self)):
return (self.tagName == other.tagName and
self.args == other.args)
return False
+
+
class DrawingParentElement(DrawingBasicElement):
+
''' Base class for SVG elements that can have child nodes '''
+
hasContent = True
+
def __init__(self, children=(), **args):
+
super().__init__(**args)
+
self.children = list(children)
+
if len(self.children) > 0:
+
self.checkChildrenAllowed()
+
def checkChildrenAllowed(self):
+
if not self.hasContent:
+
raise RuntimeError('{} does not support children'.format(type(self)))
+
def draw(self, obj, **kwargs):
+
if not hasattr(obj, 'writeSvgElement'):
+
elements = obj.toDrawables(elements=elementsModule, **kwargs)
+
self.extend(elements)
+
else:
+
assert len(kwargs) == 0
+
self.append(obj)
+
def append(self, element):
+
self.checkChildrenAllowed()
+
self.children.append(element)
+
def extend(self, iterable):
+
self.checkChildrenAllowed()
+
self.children.extend(iterable)
+
def writeContent(self, outputFile):
+
outputFile.write('\n')
+
for child in self.children:
+
child.writeSvgElement(outputFile)
+
outputFile.write('\n')
+
def writeSvgDefs(self, idGen, isDuplicate, outputFile):
+
super().writeSvgDefs(idGen, isDuplicate, outputFile)
+
for child in self.children:
+
child.writeSvgDefs(idGen, isDuplicate, outputFile)
+
outputFile.write('\n')
class NoElement(DrawingElement):
''' A drawing element that has no effect '''