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