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 if isinstance(v, DrawingElement): 19 if v.id is None: 20 continue 21 if k == 'xlink:href': 22 v = '#{}'.format(v.id) 23 else: 24 v = 'url(#{})'.format(v.id) 25 outputFile.write(' {}="{}"'.format(k,v)) 26 27 28class DrawingElement: 29 ''' Base class for drawing elements 30 31 Subclasses must implement writeSvgElement ''' 32 def writeSvgElement(self, idGen, isDuplicate, outputFile, dryRun, 33 forceDup=False): 34 raise NotImplementedError('Abstract base class') 35 def getSvgDefs(self): 36 return () 37 def getLinkedElems(self): 38 return () 39 def writeSvgDefs(self, idGen, isDuplicate, outputFile, dryRun): 40 for defn in self.getSvgDefs(): 41 if isDuplicate(defn): continue 42 defn.writeSvgDefs(idGen, isDuplicate, outputFile, dryRun) 43 if defn.id is None: 44 defn.id = idGen() 45 defn.writeSvgElement(idGen, isDuplicate, outputFile, dryRun, 46 forceDup=True) 47 if not dryRun: 48 outputFile.write('\n') 49 def __eq__(self, other): 50 return self is other 51 52class DrawingBasicElement(DrawingElement): 53 ''' Base class for SVG drawing elements that are a single node with no 54 child nodes ''' 55 TAG_NAME = '_' 56 hasContent = False 57 def __init__(self, **args): 58 self.args = {} 59 for k, v in args.items(): 60 k = k.replace('__', ':') 61 k = k.replace('_', '-') 62 if k[-1] == '-': 63 k = k[:-1] 64 self.args[k] = v 65 self.children = [] 66 def checkChildrenAllowed(self): 67 if not self.hasContent: 68 raise RuntimeError( 69 '{} does not support children'.format(type(self))) 70 @property 71 def id(self): 72 return self.args.get('id', None) 73 @id.setter 74 def id(self, newId): 75 self.args['id'] = newId 76 def writeSvgElement(self, idGen, isDuplicate, outputFile, dryRun, 77 forceDup=False): 78 if dryRun: 79 if isDuplicate(self) and self.id is None: 80 self.id = idGen() 81 for elem in self.getLinkedElems(): 82 if elem.id is None: 83 elem.id = idGen() 84 if self.hasContent: 85 self.writeContent(idGen, isDuplicate, outputFile, dryRun) 86 if self.children: 87 self.writeChildrenContent(idGen, isDuplicate, outputFile, 88 dryRun) 89 return 90 if isDuplicate(self) and not forceDup: 91 outputFile.write('<use xlink:href="#{}" />'.format(self.id)) 92 return 93 outputFile.write('<') 94 outputFile.write(self.TAG_NAME) 95 writeXmlNodeArgs(self.args, outputFile) 96 if not self.hasContent and not self.children: 97 outputFile.write(' />') 98 else: 99 outputFile.write('>') 100 if self.hasContent: 101 self.writeContent(idGen, isDuplicate, outputFile, dryRun) 102 if self.children: 103 self.writeChildrenContent(idGen, isDuplicate, outputFile, 104 dryRun) 105 outputFile.write('</') 106 outputFile.write(self.TAG_NAME) 107 outputFile.write('>') 108 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 109 ''' Override in a subclass to add data between the start and end 110 tags. This will not be called if hasContent is False. ''' 111 raise RuntimeError('This element has no content') 112 def writeChildrenContent(self, idGen, isDuplicate, outputFile, dryRun): 113 ''' Override in a subclass to add data between the start and end 114 tags. This will not be called if hasContent is False. ''' 115 if dryRun: 116 for child in self.children: 117 child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun) 118 return 119 outputFile.write('\n') 120 for child in self.children: 121 child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun) 122 outputFile.write('\n') 123 def getSvgDefs(self): 124 return [v for v in self.args.values() 125 if isinstance(v, DrawingElement)] 126 def writeSvgDefs(self, idGen, isDuplicate, outputFile, dryRun): 127 super().writeSvgDefs(idGen, isDuplicate, outputFile, dryRun) 128 for child in self.children: 129 child.writeSvgDefs(idGen, isDuplicate, outputFile, dryRun) 130 def __eq__(self, other): 131 if isinstance(other, type(self)): 132 return (self.TAG_NAME == other.TAG_NAME and 133 self.args == other.args and 134 self.children == other.children) 135 return False 136 def appendAnim(self, animateElement): 137 self.children.append(animateElement) 138 def extendAnim(self, animateIterable): 139 self.children.extend(animateIterable) 140 141class DrawingParentElement(DrawingBasicElement): 142 ''' Base class for SVG elements that can have child nodes ''' 143 hasContent = True 144 def __init__(self, children=(), **args): 145 super().__init__(**args) 146 self.children = list(children) 147 if len(self.children) > 0: 148 self.checkChildrenAllowed() 149 def draw(self, obj, **kwargs): 150 if not hasattr(obj, 'writeSvgElement'): 151 elements = obj.toDrawables(elements=elementsModule, **kwargs) 152 self.extend(elements) 153 else: 154 assert len(kwargs) == 0 155 self.append(obj) 156 def append(self, element): 157 self.checkChildrenAllowed() 158 self.children.append(element) 159 def extend(self, iterable): 160 self.checkChildrenAllowed() 161 self.children.extend(iterable) 162 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 163 pass 164 165class NoElement(DrawingElement): 166 ''' A drawing element that has no effect ''' 167 def __init__(self): pass 168 def writeSvgElement(self, idGen, isDuplicate, outputFile, dryRun, 169 forceDup=False): 170 pass 171 def __eq__(self, other): 172 if isinstance(other, type(self)): 173 return True 174 return False 175 176class Group(DrawingParentElement): 177 ''' A group of drawing elements 178 179 Any transform will apply to its children and other attributes will be 180 inherited by its children. ''' 181 TAG_NAME = 'g' 182 183class Use(DrawingBasicElement): 184 ''' A copy of another element 185 186 The other element becomes an SVG def shared between all Use elements 187 that reference it. ''' 188 TAG_NAME = 'use' 189 def __init__(self, otherElem, x, y, **kwargs): 190 y = -y 191 if isinstance(otherElem, str) and not otherElem.startswith('#'): 192 otherElem = '#' + otherElem 193 super().__init__(xlink__href=otherElem, x=x, y=y, **kwargs) 194 195class Animate(DrawingBasicElement): 196 ''' Animation for a specific property of another element 197 198 This should be added as a child of the element to animate. Otherwise 199 the other element and this element must both be added to the drawing. 200 ''' 201 TAG_NAME = 'animate' 202 def __init__(self, attributeName, dur, from_or_values=None, to=None, 203 begin=None, otherElem=None, **kwargs): 204 if to is None: 205 values = from_or_values 206 from_ = None 207 else: 208 values = None 209 from_ = from_or_values 210 if isinstance(otherElem, str) and not otherElem.startswith('#'): 211 otherElem = '#' + otherElem 212 kwargs.update(attributeName=attributeName, to=to, dur=dur, begin=begin) 213 kwargs.setdefault('values', values) 214 kwargs.setdefault('from_', from_) 215 super().__init__(xlink__href=otherElem, **kwargs) 216 217 def getSvgDefs(self): 218 return [v for k, v in self.args.items() 219 if isinstance(v, DrawingElement) 220 if k != 'xlink:href'] 221 222 def getLinkedElems(self): 223 return (self.args['xlink:href'],) 224 225class _Mpath(DrawingBasicElement): 226 ''' Used by AnimateMotion ''' 227 TAG_NAME = 'mpath' 228 def __init__(self, otherPath, **kwargs): 229 super().__init__(xlink__href=otherPath, **kwargs) 230 231class AnimateMotion(Animate): 232 ''' Animation for the motion another element along a path 233 234 This should be added as a child of the element to animate. Otherwise 235 the other element and this element must both be added to the drawing. 236 ''' 237 TAG_NAME = 'animateMotion' 238 def __init__(self, path, dur, from_or_values=None, to=None, begin=None, 239 otherElem=None, **kwargs): 240 useMpath = False 241 if isinstance(path, DrawingElement): 242 useMpath = True 243 pathElem = path 244 path = None 245 kwargs.setdefault('attributeName', None) 246 super().__init__(dur=dur, from_or_values=from_or_values, to=to, 247 begin=begin, path=path, otherElem=otherElem, **kwargs) 248 if useMpath: 249 self.children.append(_Mpath(pathElem)) 250 251class AnimateTransform(Animate): 252 ''' Animation for the transform property of another element 253 254 This should be added as a child of the element to animate. Otherwise 255 the other element and this element must both be added to the drawing. 256 ''' 257 TAG_NAME = 'animateTransform' 258 def __init__(self, type, dur, from_or_values, to=None, begin=None, 259 attributeName='transform', otherElem=None, **kwargs): 260 super().__init__(attributeName, dur=dur, from_or_values=from_or_values, 261 to=to, begin=begin, type=type, otherElem=otherElem, 262 **kwargs) 263 264class Set(Animate): 265 ''' Animation for a specific property of another element that sets the new 266 value without a transition. 267 268 This should be added as a child of the element to animate. Otherwise 269 the other element and this element must both be added to the drawing. 270 ''' 271 TAG_NAME = 'set' 272 def __init__(self, attributeName, dur, to=None, begin=None, 273 otherElem=None, **kwargs): 274 super().__init__(attributeName, dur=dur, from_or_values=None, 275 to=to, begin=begin, otherElem=otherElem, **kwargs) 276 277class Discard(Animate): 278 ''' Animation configuration specifying when it is safe to discard another 279 element. E.g. when it will no longer be visible after an animation. 280 281 This should be added as a child of the element to animate. Otherwise 282 the other element and this element must both be added to the drawing. 283 ''' 284 TAG_NAME = 'discard' 285 def __init__(self, attributeName, begin=None, **kwargs): 286 kwargs.setdefault('attributeName', None) 287 kwargs.setdefault('to', None) 288 kwargs.setdefault('dur', None) 289 super().__init__(from_or_values=None, begin=begin, otherElem=None, 290 **kwargs) 291 292class Image(DrawingBasicElement): 293 ''' A linked or embedded raster image ''' 294 TAG_NAME = 'image' 295 MIME_MAP = { 296 '.bm': 'image/bmp', 297 '.bmp': 'image/bmp', 298 '.gif': 'image/gif', 299 '.jpeg':'image/jpeg', 300 '.jpg': 'image/jpeg', 301 '.png': 'image/png', 302 '.tif': 'image/tiff', 303 '.tiff':'image/tiff', 304 '.pdf': 'application/pdf', 305 '.txt': 'text/plain', 306 } 307 MIME_DEFAULT = 'image/png' 308 def __init__(self, x, y, width, height, path=None, data=None, embed=False, 309 mimeType=None, **kwargs): 310 ''' Specify either the path or data argument. If path is used and 311 embed is True, the image file is embedded in a data URI. ''' 312 if path is None and data is None: 313 raise ValueError('Either path or data arguments must be given') 314 if mimeType is None and path is not None: 315 ext = os.path.splitext(path)[1].lower() 316 if ext in self.MIME_MAP: 317 mimeType = self.MIME_MAP[ext] 318 else: 319 mimeType = self.MIME_DEFAULT 320 warnings.warn('Unknown image file type "{}"'.format(ext), 321 Warning) 322 if mimeType is None: 323 mimeType = self.MIME_DEFAULT 324 warnings.warn('Unspecified image type; assuming png'.format(ext), 325 Warning) 326 if data is not None: 327 embed = True 328 if embed and data is None: 329 with open(path, 'rb') as f: 330 data = f.read() 331 if not embed: 332 uri = path 333 else: 334 encData = base64.b64encode(data).decode() 335 uri = 'data:{};base64,{}'.format(mimeType, encData) 336 super().__init__(x=x, y=-y-height, width=width, height=height, 337 xlink__href=uri, **kwargs) 338 339class Text(DrawingBasicElement): 340 ''' Text 341 342 Additional keyword arguments are output as additional arguments to the 343 SVG node e.g. fill="red", font_size=20, text_anchor="middle". ''' 344 TAG_NAME = 'text' 345 hasContent = True 346 def __init__(self, text, fontSize, x, y, center=False, **kwargs): 347 if center: 348 if 'text_anchor' not in kwargs: 349 kwargs['text_anchor'] = 'middle' 350 try: 351 fontSize = float(fontSize) 352 translate = 'translate(0,{})'.format(fontSize*0.5*center) 353 if 'transform' in kwargs: 354 kwargs['transform'] = translate + ' ' + kwargs['transform'] 355 else: 356 kwargs['transform'] = translate 357 except TypeError: 358 pass 359 super().__init__(x=x, y=-y, font_size=fontSize, **kwargs) 360 self.escapedText = xml.escape(text) 361 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 362 if dryRun: 363 return 364 outputFile.write(self.escapedText) 365 366class Rectangle(DrawingBasicElement): 367 ''' A rectangle 368 369 Additional keyword arguments are output as additional arguments to the 370 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 371 TAG_NAME = 'rect' 372 def __init__(self, x, y, width, height, **kwargs): 373 super().__init__(x=x, y=-y-height, width=width, height=height, 374 **kwargs) 375 376class Circle(DrawingBasicElement): 377 ''' A circle 378 379 Additional keyword arguments are output as additional properties to the 380 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 381 TAG_NAME = 'circle' 382 def __init__(self, cx, cy, r, **kwargs): 383 super().__init__(cx=cx, cy=-cy, r=r, **kwargs) 384 385class Ellipse(DrawingBasicElement): 386 ''' An ellipse 387 388 Additional keyword arguments are output as additional properties to the 389 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 390 TAG_NAME = 'ellipse' 391 def __init__(self, cx, cy, rx, ry, **kwargs): 392 super().__init__(cx=cx, cy=-cy, rx=rx, ry=ry, **kwargs) 393 394class ArcLine(Circle): 395 ''' An arc 396 397 In most cases, use Arc instead of ArcLine. ArcLine uses the 398 stroke-dasharray SVG property to make the edge of a circle look like 399 an arc. 400 401 Additional keyword arguments are output as additional arguments to the 402 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 403 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs): 404 if endDeg - startDeg == 360: 405 super().__init__(cx, cy, r, **kwargs) 406 return 407 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360 408 arcDeg = (endDeg - startDeg) % 360 409 def arcLen(deg): return math.radians(deg) * r 410 wholeLen = 2 * math.pi * r 411 if endDeg == startDeg: 412 offset = 1 413 dashes = "0 {}".format(wholeLen+2) 414 #elif endDeg >= startDeg: 415 elif True: 416 startLen = arcLen(startDeg) 417 arcLen = arcLen(arcDeg) 418 offLen = wholeLen - arcLen 419 offset = -startLen 420 dashes = "{} {}".format(arcLen, offLen) 421 #else: 422 # firstLen = arcLen(endDeg) 423 # secondLen = arcLen(360-startDeg) 424 # gapLen = wholeLen - firstLen - secondLen 425 # offset = 0 426 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen) 427 super().__init__(cx, cy, r, stroke_dasharray=dashes, 428 stroke_dashoffset=offset, **kwargs) 429 430class Path(DrawingBasicElement): 431 ''' An arbitrary path 432 433 Path Supports building an SVG path by calling instance methods 434 corresponding to path commands. 435 436 Additional keyword arguments are output as additional properties to the 437 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 438 TAG_NAME = 'path' 439 def __init__(self, d='', **kwargs): 440 super().__init__(d=d, **kwargs) 441 def append(self, commandStr, *args): 442 if len(self.args['d']) > 0: 443 commandStr = ' ' + commandStr 444 if len(args) > 0: 445 commandStr = commandStr + ','.join(map(str, args)) 446 self.args['d'] += commandStr 447 def M(self, x, y): self.append('M', x, -y) 448 def m(self, dx, dy): self.append('m', dx, -dy) 449 def L(self, x, y): self.append('L', x, -y) 450 def l(self, dx, dy): self.append('l', dx, -dy) 451 def H(self, x): self.append('H', x) 452 def h(self, dx): self.append('h', dx) 453 def V(self, y): self.append('V', -y) 454 def v(self, dy): self.append('v', -dy) 455 def Z(self): self.append('Z') 456 def C(self, cx1, cy1, cx2, cy2, ex, ey): 457 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey) 458 def c(self, cx1, cy1, cx2, cy2, ex, ey): 459 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey) 460 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey) 461 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey) 462 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey) 463 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey) 464 def T(self, ex, ey): self.append('T', ex, -ey) 465 def t(self, ex, ey): self.append('t', ex, -ey) 466 def A(self, rx, ry, rot, largeArc, sweep, ex, ey): 467 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, 468 -ey) 469 def a(self, rx, ry, rot, largeArc, sweep, ex, ey): 470 self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, 471 -ey) 472 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, 473 includeL=False): 474 ''' Uses A() to draw a circular arc ''' 475 largeArc = (endDeg - startDeg) % 360 > 180 476 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180 477 sx, sy = r*math.cos(startRad), r*math.sin(startRad) 478 ex, ey = r*math.cos(endRad), r*math.sin(endRad) 479 if includeL: 480 self.L(cx+sx, cy+sy) 481 elif includeM: 482 self.M(cx+sx, cy+sy) 483 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey) 484 485class Lines(Path): 486 ''' A sequence of connected lines (or a polygon) 487 488 Additional keyword arguments are output as additional properties to the 489 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 490 def __init__(self, sx, sy, *points, close=False, **kwargs): 491 super().__init__(d='', **kwargs) 492 self.M(sx, sy) 493 assert len(points) % 2 == 0 494 for i in range(len(points) // 2): 495 self.L(points[2*i], points[2*i+1]) 496 if close: 497 self.Z() 498 499class Line(Lines): 500 ''' A line 501 502 Additional keyword arguments are output as additional properties to the 503 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 504 def __init__(self, sx, sy, ex, ey, **kwargs): 505 super().__init__(sx, sy, ex, ey, close=False, **kwargs) 506 507class Arc(Path): 508 ''' An arc 509 510 Additional keyword arguments are output as additional properties to the 511 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 512 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs): 513 super().__init__(d='', **kwargs) 514 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True) 515