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 try: 374 y = -y-height 375 except TypeError: 376 pass 377 super().__init__(x=x, y=y, width=width, height=height, 378 **kwargs) 379 380class Circle(DrawingBasicElement): 381 ''' A circle 382 383 Additional keyword arguments are output as additional properties to the 384 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 385 TAG_NAME = 'circle' 386 def __init__(self, cx, cy, r, **kwargs): 387 try: 388 cy = -cy 389 except TypeError: 390 pass 391 super().__init__(cx=cx, cy=cy, r=r, **kwargs) 392 393class Ellipse(DrawingBasicElement): 394 ''' An ellipse 395 396 Additional keyword arguments are output as additional properties to the 397 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 398 TAG_NAME = 'ellipse' 399 def __init__(self, cx, cy, rx, ry, **kwargs): 400 try: 401 cy = -cy 402 except TypeError: 403 pass 404 super().__init__(cx=cx, cy=cy, rx=rx, ry=ry, **kwargs) 405 406class ArcLine(Circle): 407 ''' An arc 408 409 In most cases, use Arc instead of ArcLine. ArcLine uses the 410 stroke-dasharray SVG property to make the edge of a circle look like 411 an arc. 412 413 Additional keyword arguments are output as additional arguments to the 414 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 415 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs): 416 if endDeg - startDeg == 360: 417 super().__init__(cx, cy, r, **kwargs) 418 return 419 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360 420 arcDeg = (endDeg - startDeg) % 360 421 def arcLen(deg): return math.radians(deg) * r 422 wholeLen = 2 * math.pi * r 423 if endDeg == startDeg: 424 offset = 1 425 dashes = "0 {}".format(wholeLen+2) 426 #elif endDeg >= startDeg: 427 elif True: 428 startLen = arcLen(startDeg) 429 arcLen = arcLen(arcDeg) 430 offLen = wholeLen - arcLen 431 offset = -startLen 432 dashes = "{} {}".format(arcLen, offLen) 433 #else: 434 # firstLen = arcLen(endDeg) 435 # secondLen = arcLen(360-startDeg) 436 # gapLen = wholeLen - firstLen - secondLen 437 # offset = 0 438 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen) 439 super().__init__(cx, cy, r, stroke_dasharray=dashes, 440 stroke_dashoffset=offset, **kwargs) 441 442class Path(DrawingBasicElement): 443 ''' An arbitrary path 444 445 Path Supports building an SVG path by calling instance methods 446 corresponding to path commands. 447 448 Additional keyword arguments are output as additional properties to the 449 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 450 TAG_NAME = 'path' 451 def __init__(self, d='', **kwargs): 452 super().__init__(d=d, **kwargs) 453 def append(self, commandStr, *args): 454 if len(self.args['d']) > 0: 455 commandStr = ' ' + commandStr 456 if len(args) > 0: 457 commandStr = commandStr + ','.join(map(str, args)) 458 self.args['d'] += commandStr 459 def M(self, x, y): self.append('M', x, -y) 460 def m(self, dx, dy): self.append('m', dx, -dy) 461 def L(self, x, y): self.append('L', x, -y) 462 def l(self, dx, dy): self.append('l', dx, -dy) 463 def H(self, x): self.append('H', x) 464 def h(self, dx): self.append('h', dx) 465 def V(self, y): self.append('V', -y) 466 def v(self, dy): self.append('v', -dy) 467 def Z(self): self.append('Z') 468 def C(self, cx1, cy1, cx2, cy2, ex, ey): 469 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey) 470 def c(self, cx1, cy1, cx2, cy2, ex, ey): 471 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey) 472 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey) 473 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey) 474 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey) 475 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey) 476 def T(self, ex, ey): self.append('T', ex, -ey) 477 def t(self, ex, ey): self.append('t', ex, -ey) 478 def A(self, rx, ry, rot, largeArc, sweep, ex, ey): 479 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, 480 -ey) 481 def a(self, rx, ry, rot, largeArc, sweep, ex, ey): 482 self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, 483 -ey) 484 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, 485 includeL=False): 486 ''' Uses A() to draw a circular arc ''' 487 largeArc = (endDeg - startDeg) % 360 > 180 488 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180 489 sx, sy = r*math.cos(startRad), r*math.sin(startRad) 490 ex, ey = r*math.cos(endRad), r*math.sin(endRad) 491 if includeL: 492 self.L(cx+sx, cy+sy) 493 elif includeM: 494 self.M(cx+sx, cy+sy) 495 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey) 496 497class Lines(Path): 498 ''' A sequence of connected lines (or a polygon) 499 500 Additional keyword arguments are output as additional properties to the 501 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 502 def __init__(self, sx, sy, *points, close=False, **kwargs): 503 super().__init__(d='', **kwargs) 504 self.M(sx, sy) 505 assert len(points) % 2 == 0 506 for i in range(len(points) // 2): 507 self.L(points[2*i], points[2*i+1]) 508 if close: 509 self.Z() 510 511class Line(Lines): 512 ''' A line 513 514 Additional keyword arguments are output as additional properties to the 515 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 516 def __init__(self, sx, sy, ex, ey, **kwargs): 517 super().__init__(sx, sy, ex, ey, close=False, **kwargs) 518 519class Arc(Path): 520 ''' An arc 521 522 Additional keyword arguments are output as additional properties to the 523 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 524 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs): 525 super().__init__(d='', **kwargs) 526 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True) 527