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, valign=None, 383 lineHeight=1, **kwargs): 384 singleLine = isinstance(text, str) 385 if '\n' in text: 386 text = text.splitlines() 387 singleLine = False 388 if not singleLine: 389 text = tuple(text) 390 numLines = len(text) 391 else: 392 numLines = 1 393 centerOffset = 0 394 emOffset = 0 395 if center: 396 if 'text_anchor' not in kwargs: 397 kwargs['text_anchor'] = 'middle' 398 if valign is None: 399 if singleLine: 400 # Backwards compatible centering 401 centerOffset = fontSize*0.5*center 402 else: 403 emOffset = 0.4 - lineHeight * (numLines - 1) / 2 404 if valign == 'middle': 405 emOffset = 0.4 - lineHeight * (numLines - 1) / 2 406 elif valign == 'top': 407 emOffset = 1 408 elif valign == 'bottom': 409 emOffset = -lineHeight * (numLines - 1) 410 if centerOffset: 411 try: 412 fontSize = float(fontSize) 413 except TypeError: 414 pass 415 else: 416 translate = 'translate(0,{})'.format(centerOffset) 417 if 'transform' in kwargs: 418 kwargs['transform'] += ' ' + translate 419 else: 420 kwargs['transform'] = translate 421 super().__init__(x=x, y=-y, font_size=fontSize, **kwargs) 422 if singleLine: 423 self.escapedText = xml.escape(text) 424 else: 425 self.escapedText = '' 426 # Text is an iterable 427 for i, line in enumerate(text): 428 dy = '{}em'.format(emOffset if i == 0 else lineHeight) 429 self.appendLine(line, x=x, dy=dy) 430 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 431 if dryRun: 432 return 433 outputFile.write(self.escapedText) 434 def writeChildrenContent(self, idGen, isDuplicate, outputFile, dryRun): 435 ''' Override in a subclass to add data between the start and end 436 tags. This will not be called if hasContent is False. ''' 437 children = self.allChildren() 438 if dryRun: 439 for child in children: 440 child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun) 441 return 442 for child in children: 443 child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun) 444 def appendLine(self, line, **kwargs): 445 self.append(TSpan(line, **kwargs)) 446 447class TSpan(DrawingBasicElement): 448 ''' A line of text within the Text element. ''' 449 TAG_NAME = 'tspan' 450 hasContent = True 451 def __init__(self, text, **kwargs): 452 super().__init__(**kwargs) 453 self.escapedText = xml.escape(text) 454 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 455 if dryRun: 456 return 457 outputFile.write(self.escapedText) 458 459class Rectangle(DrawingBasicElement): 460 ''' A rectangle 461 462 Additional keyword arguments are output as additional arguments to the 463 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 464 TAG_NAME = 'rect' 465 def __init__(self, x, y, width, height, **kwargs): 466 try: 467 y = -y-height 468 except TypeError: 469 pass 470 super().__init__(x=x, y=y, width=width, height=height, 471 **kwargs) 472 473class Circle(DrawingBasicElement): 474 ''' A circle 475 476 Additional keyword arguments are output as additional properties to the 477 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 478 TAG_NAME = 'circle' 479 def __init__(self, cx, cy, r, **kwargs): 480 try: 481 cy = -cy 482 except TypeError: 483 pass 484 super().__init__(cx=cx, cy=cy, r=r, **kwargs) 485 486class Ellipse(DrawingBasicElement): 487 ''' An ellipse 488 489 Additional keyword arguments are output as additional properties to the 490 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 491 TAG_NAME = 'ellipse' 492 def __init__(self, cx, cy, rx, ry, **kwargs): 493 try: 494 cy = -cy 495 except TypeError: 496 pass 497 super().__init__(cx=cx, cy=cy, rx=rx, ry=ry, **kwargs) 498 499class ArcLine(Circle): 500 ''' An arc 501 502 In most cases, use Arc instead of ArcLine. ArcLine uses the 503 stroke-dasharray SVG property to make the edge of a circle look like 504 an arc. 505 506 Additional keyword arguments are output as additional arguments to the 507 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 508 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs): 509 if endDeg - startDeg == 360: 510 super().__init__(cx, cy, r, **kwargs) 511 return 512 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360 513 arcDeg = (endDeg - startDeg) % 360 514 def arcLen(deg): return math.radians(deg) * r 515 wholeLen = 2 * math.pi * r 516 if endDeg == startDeg: 517 offset = 1 518 dashes = "0 {}".format(wholeLen+2) 519 #elif endDeg >= startDeg: 520 elif True: 521 startLen = arcLen(startDeg) 522 arcLen = arcLen(arcDeg) 523 offLen = wholeLen - arcLen 524 offset = -startLen 525 dashes = "{} {}".format(arcLen, offLen) 526 #else: 527 # firstLen = arcLen(endDeg) 528 # secondLen = arcLen(360-startDeg) 529 # gapLen = wholeLen - firstLen - secondLen 530 # offset = 0 531 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen) 532 super().__init__(cx, cy, r, stroke_dasharray=dashes, 533 stroke_dashoffset=offset, **kwargs) 534 535class Path(DrawingBasicElement): 536 ''' An arbitrary path 537 538 Path Supports building an SVG path by calling instance methods 539 corresponding to path commands. 540 541 Additional keyword arguments are output as additional properties to the 542 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 543 TAG_NAME = 'path' 544 def __init__(self, d='', **kwargs): 545 super().__init__(d=d, **kwargs) 546 def append(self, commandStr, *args): 547 if len(self.args['d']) > 0: 548 commandStr = ' ' + commandStr 549 if len(args) > 0: 550 commandStr = commandStr + ','.join(map(str, args)) 551 self.args['d'] += commandStr 552 def M(self, x, y): self.append('M', x, -y) 553 def m(self, dx, dy): self.append('m', dx, -dy) 554 def L(self, x, y): self.append('L', x, -y) 555 def l(self, dx, dy): self.append('l', dx, -dy) 556 def H(self, x): self.append('H', x) 557 def h(self, dx): self.append('h', dx) 558 def V(self, y): self.append('V', -y) 559 def v(self, dy): self.append('v', -dy) 560 def Z(self): self.append('Z') 561 def C(self, cx1, cy1, cx2, cy2, ex, ey): 562 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey) 563 def c(self, cx1, cy1, cx2, cy2, ex, ey): 564 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey) 565 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey) 566 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey) 567 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey) 568 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey) 569 def T(self, ex, ey): self.append('T', ex, -ey) 570 def t(self, ex, ey): self.append('t', ex, -ey) 571 def A(self, rx, ry, rot, largeArc, sweep, ex, ey): 572 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, 573 -ey) 574 def a(self, rx, ry, rot, largeArc, sweep, ex, ey): 575 self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, 576 -ey) 577 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, 578 includeL=False): 579 ''' Uses A() to draw a circular arc ''' 580 largeArc = (endDeg - startDeg) % 360 > 180 581 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180 582 sx, sy = r*math.cos(startRad), r*math.sin(startRad) 583 ex, ey = r*math.cos(endRad), r*math.sin(endRad) 584 if includeL: 585 self.L(cx+sx, cy+sy) 586 elif includeM: 587 self.M(cx+sx, cy+sy) 588 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey) 589 590class Lines(Path): 591 ''' A sequence of connected lines (or a polygon) 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, *points, close=False, **kwargs): 596 super().__init__(d='', **kwargs) 597 self.M(sx, sy) 598 assert len(points) % 2 == 0 599 for i in range(len(points) // 2): 600 self.L(points[2*i], points[2*i+1]) 601 if close: 602 self.Z() 603 604class Line(Lines): 605 ''' A line 606 607 Additional keyword arguments are output as additional properties to the 608 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 609 def __init__(self, sx, sy, ex, ey, **kwargs): 610 super().__init__(sx, sy, ex, ey, close=False, **kwargs) 611 612class Arc(Path): 613 ''' An arc 614 615 Additional keyword arguments are output as additional properties to the 616 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 617 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs): 618 super().__init__(d='', **kwargs) 619 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True) 620