Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets
1 2import sys 3import math 4import os.path 5import base64 6import warnings 7import xml.sax.saxutils as xml 8 9from . import defs 10 11elementsModule = sys.modules[__name__] 12 13# TODO: Support drawing ellipses without manually using Path 14 15def writeXmlNodeArgs(args, outputFile): 16 for k, v in args.items(): 17 if v is None: continue 18 k = k.replace('__', ':') 19 k = k.replace('_', '-') 20 if isinstance(v, defs.DrawingDef): 21 v = 'url(#{})'.format(v.id) 22 outputFile.write(' {}="{}"'.format(k,v)) 23 24 25class DrawingElement: 26 ''' Base class for drawing elements 27 28 Subclasses must implement writeSvgElement ''' 29 def writeSvgElement(self, outputFile): 30 raise NotImplementedError('Abstract base class') 31 def getSvgDefs(self): 32 return () 33 def writeSvgDefs(self, idGen, isDuplicate, outputFile): 34 for defn in self.getSvgDefs(): 35 if isDuplicate(defn): continue 36 defn.id = idGen() 37 defn.writeSvgElement(outputFile) 38 outputFile.write('\n') 39 def __eq__(self, other): 40 return self is other 41 42class DrawingBasicElement(DrawingElement): 43 ''' Base class for SVG drawing elements that are a single node with no 44 child nodes ''' 45 TAG_NAME = '_' 46 hasContent = False 47 def __init__(self, **args): 48 self.args = args 49 def writeSvgElement(self, outputFile): 50 outputFile.write('<') 51 outputFile.write(self.TAG_NAME) 52 writeXmlNodeArgs(self.args, outputFile) 53 if not self.hasContent: 54 outputFile.write(' />') 55 else: 56 outputFile.write('>') 57 self.writeContent(outputFile) 58 outputFile.write('</') 59 outputFile.write(self.TAG_NAME) 60 outputFile.write('>') 61 def writeContent(self, outputFile): 62 ''' Override in a subclass to add data between the start and end 63 tags. This will not be called if hasContent is False. ''' 64 raise RuntimeError('This element has no content') 65 def getSvgDefs(self): 66 return [v for v in self.args.values() if isinstance(v, defs.DrawingDef)] 67 def __eq__(self, other): 68 if isinstance(other, type(self)): 69 return (self.tagName == other.tagName and 70 self.args == other.args) 71 return False 72 73class DrawingParentElement(DrawingBasicElement): 74 ''' Base class for SVG elements that can have child nodes ''' 75 hasContent = True 76 def __init__(self, children=(), **args): 77 super().__init__(**args) 78 self.children = list(children) 79 if len(self.children) > 0: 80 self.checkChildrenAllowed() 81 def checkChildrenAllowed(self): 82 if not self.hasContent: 83 raise RuntimeError('{} does not support children'.format(type(self))) 84 def draw(self, obj, **kwargs): 85 if not hasattr(obj, 'writeSvgElement'): 86 elements = obj.toDrawables(elements=elementsModule, **kwargs) 87 self.extend(elements) 88 else: 89 assert len(kwargs) == 0 90 self.append(obj) 91 def append(self, element): 92 self.checkChildrenAllowed() 93 self.children.append(element) 94 def extend(self, iterable): 95 self.checkChildrenAllowed() 96 self.children.extend(iterable) 97 def writeContent(self, outputFile): 98 outputFile.write('\n') 99 for child in self.children: 100 child.writeSvgElement(outputFile) 101 outputFile.write('\n') 102 def writeSvgDefs(self, idGen, isDuplicate, outputFile): 103 super().writeSvgDefs(idGen, isDuplicate, outputFile) 104 for child in self.children: 105 child.writeSvgDefs(idGen, isDuplicate, outputFile) 106 outputFile.write('\n') 107 108class NoElement(DrawingElement): 109 ''' A drawing element that has no effect ''' 110 def __init__(self): pass 111 def writeSvgElement(self, outputFile): 112 pass 113 def __eq__(self, other): 114 if isinstance(other, type(self)): 115 return True 116 return False 117 118class Image(DrawingBasicElement): 119 ''' A linked or embedded raster image ''' 120 TAG_NAME = 'image' 121 MIME_MAP = { 122 '.bm': 'image/bmp', 123 '.bmp': 'image/bmp', 124 '.gif': 'image/gif', 125 '.jpeg':'image/jpeg', 126 '.jpg': 'image/jpeg', 127 '.png': 'image/png', 128 '.tif': 'image/tiff', 129 '.tiff':'image/tiff', 130 '.pdf': 'application/pdf', 131 '.txt': 'text/plain', 132 } 133 MIME_DEFAULT = 'image/png' 134 def __init__(self, x, y, width, height, path=None, data=None, embed=False, 135 mimeType=None, **kwargs): 136 ''' Specify either the path or data argument. If path is used and 137 embed is True, the image file is embedded in a data URI. ''' 138 if path is None and data is None: 139 raise ValueError('Either path or data arguments must be given') 140 if mimeType is None and path is not None: 141 ext = os.path.splitext(path)[1].lower() 142 if ext in self.MIME_MAP: 143 mimeType = self.MIME_MAP[ext] 144 else: 145 mimeType = self.MIME_DEFAULT 146 warnings.warn('Unknown image file type "{}"'.format(ext), Warning) 147 if mimeType is None: 148 mimeType = self.MIME_DEFAULT 149 warnings.warn('Unspecified image type; assuming png'.format(ext), Warning) 150 if data is not None: 151 embed = True 152 if embed and data is None: 153 with open(path, 'rb') as f: 154 data = f.read() 155 if not embed: 156 uri = path 157 else: 158 encData = base64.b64encode(data).decode() 159 uri = 'data:{};base64,{}'.format(mimeType, encData) 160 super().__init__(x=x, y=-y-height, width=width, height=height, 161 xlink__href=uri, **kwargs) 162 163class Text(DrawingBasicElement): 164 ''' Text 165 166 Additional keyword arguments are output as additional arguments to the 167 SVG node e.g. fill="red", font_size=20, text_anchor="middle". ''' 168 TAG_NAME = 'text' 169 hasContent = True 170 def __init__(self, text, fontSize, x, y, center=False, **kwargs): 171 if center: 172 if 'text_anchor' not in kwargs: 173 kwargs['text_anchor'] = 'middle' 174 try: 175 fontSize = float(fontSize) 176 translate = 'translate(0,{})'.format(fontSize*0.5*center) 177 if 'transform' in kwargs: 178 kwargs['transform'] = translate + ' ' + kwargs['transform'] 179 else: 180 kwargs['transform'] = translate 181 except TypeError: 182 pass 183 super().__init__(x=x, y=-y, font_size=fontSize, **kwargs) 184 self.escapedText = xml.escape(text) 185 def writeContent(self, outputFile): 186 outputFile.write(self.escapedText) 187 188class Rectangle(DrawingBasicElement): 189 ''' A rectangle 190 191 Additional keyword arguments are output as additional arguments to the 192 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 193 TAG_NAME = 'rect' 194 def __init__(self, x, y, width, height, **kwargs): 195 super().__init__(x=x, y=-y-height, width=width, height=height, 196 **kwargs) 197 198class Circle(DrawingBasicElement): 199 ''' A circle 200 201 Additional keyword arguments are output as additional properties to the 202 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 203 TAG_NAME = 'circle' 204 def __init__(self, cx, cy, r, **kwargs): 205 super().__init__(cx=cx, cy=-cy, r=r, **kwargs) 206 207class ArcLine(Circle): 208 ''' An arc 209 210 In most cases, use Arc instead of ArcLine. ArcLine uses the 211 stroke-dasharray SVG property to make the edge of a circle look like 212 an arc. 213 214 Additional keyword arguments are output as additional arguments to the 215 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 216 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs): 217 if endDeg - startDeg == 360: 218 super().__init__(cx, cy, r, **kwargs) 219 return 220 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360 221 arcDeg = (endDeg - startDeg) % 360 222 def arcLen(deg): return math.radians(deg) * r 223 wholeLen = 2 * math.pi * r 224 if endDeg == startDeg: 225 offset = 1 226 dashes = "0 {}".format(wholeLen+2) 227 #elif endDeg >= startDeg: 228 elif True: 229 startLen = arcLen(startDeg) 230 arcLen = arcLen(arcDeg) 231 offLen = wholeLen - arcLen 232 offset = -startLen 233 dashes = "{} {}".format(arcLen, offLen) 234 #else: 235 # firstLen = arcLen(endDeg) 236 # secondLen = arcLen(360-startDeg) 237 # gapLen = wholeLen - firstLen - secondLen 238 # offset = 0 239 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen) 240 super().__init__(cx, cy, r, stroke_dasharray=dashes, 241 stroke_dashoffset=offset, **kwargs) 242 243class Path(DrawingBasicElement): 244 ''' An arbitrary path 245 246 Path Supports building an SVG path by calling instance methods 247 corresponding to path commands. 248 249 Additional keyword arguments are output as additional properties to the 250 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 251 TAG_NAME = 'path' 252 def __init__(self, d='', **kwargs): 253 super().__init__(d=d, **kwargs) 254 def append(self, commandStr, *args): 255 if len(self.args['d']) > 0: 256 commandStr = ' ' + commandStr 257 if len(args) > 0: 258 commandStr = commandStr + ','.join(map(str, args)) 259 self.args['d'] += commandStr 260 def M(self, x, y): self.append('M', x, -y) 261 def m(self, dx, dy): self.append('m', dx, -dy) 262 def L(self, x, y): self.append('L', x, -y) 263 def l(self, dx, dy): self.append('l', dx, -dy) 264 def H(self, x, y): self.append('H', x) 265 def h(self, dx): self.append('h', dx) 266 def V(self, y): self.append('V', -y) 267 def v(self, dy): self.append('v', -dy) 268 def Z(self): self.append('Z') 269 def C(self, cx1, cy1, cx2, cy2, ex, ey): 270 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey) 271 def c(self, cx1, cy1, cx2, cy2, ex, ey): 272 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey) 273 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey) 274 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey) 275 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey) 276 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey) 277 def T(self, ex, ey): self.append('T', ex, -ey) 278 def t(self, ex, ey): self.append('t', ex, -ey) 279 def A(self, rx, ry, rot, largeArc, sweep, ex, ey): 280 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey) 281 def a(self, rx, ry, rot, largeArc, sweep, ex, ey): 282 self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey) 283 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, includeL=False): 284 ''' Uses A() to draw a circular arc ''' 285 largeArc = (endDeg - startDeg) % 360 > 180 286 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180 287 sx, sy = r*math.cos(startRad), r*math.sin(startRad) 288 ex, ey = r*math.cos(endRad), r*math.sin(endRad) 289 if includeL: 290 self.L(cx+sx, cy+sy) 291 elif includeM: 292 self.M(cx+sx, cy+sy) 293 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey) 294 295class Lines(Path): 296 ''' A sequence of connected lines (or a polygon) 297 298 Additional keyword arguments are output as additional properties to the 299 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 300 def __init__(self, sx, sy, *points, close=False, **kwargs): 301 super().__init__(d='', **kwargs) 302 self.M(sx, sy) 303 assert len(points) % 2 == 0 304 for i in range(len(points) // 2): 305 self.L(points[2*i], points[2*i+1]) 306 if close: 307 self.Z() 308 309class Line(Lines): 310 ''' A line 311 312 Additional keyword arguments are output as additional properties to the 313 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 314 def __init__(self, sx, sy, ex, ey, **kwargs): 315 super().__init__(sx, sy, ex, ey, close=False, **kwargs) 316 317class Arc(Path): 318 ''' An arc 319 320 Additional keyword arguments are output as additional properties to the 321 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 322 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs): 323 super().__init__(d='', **kwargs) 324 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True) 325