Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets
at 1.7.0 25 kB view raw
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 def appendTitle(self, text, **kwargs): 152 self.children.append(Title(text, **kwargs)) 153 154class DrawingParentElement(DrawingBasicElement): 155 ''' Base class for SVG elements that can have child nodes ''' 156 hasContent = True 157 def __init__(self, children=(), orderedChildren=None, **args): 158 super().__init__(**args) 159 self.children = list(children) 160 if orderedChildren: 161 self.orderedChildren.update( 162 (z, list(v)) for z, v in orderedChildren.items()) 163 if self.children or self.orderedChildren: 164 self.checkChildrenAllowed() 165 def draw(self, obj, *, z=None, **kwargs): 166 if obj is None: 167 return 168 if not hasattr(obj, 'writeSvgElement'): 169 elements = obj.toDrawables(elements=elementsModule, **kwargs) 170 else: 171 assert len(kwargs) == 0 172 elements = (obj,) 173 self.extend(elements, z=z) 174 def append(self, element, *, z=None): 175 self.checkChildrenAllowed() 176 if z is not None: 177 self.orderedChildren[z].append(element) 178 else: 179 self.children.append(element) 180 def extend(self, iterable, *, z=None): 181 self.checkChildrenAllowed() 182 if z is not None: 183 self.orderedChildren[z].extend(iterable) 184 else: 185 self.children.extend(iterable) 186 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 187 pass 188 189class NoElement(DrawingElement): 190 ''' A drawing element that has no effect ''' 191 def __init__(self): pass 192 def writeSvgElement(self, idGen, isDuplicate, outputFile, dryRun, 193 forceDup=False): 194 pass 195 def __eq__(self, other): 196 if isinstance(other, type(self)): 197 return True 198 return False 199 200class Group(DrawingParentElement): 201 ''' A group of drawing elements 202 203 Any transform will apply to its children and other attributes will be 204 inherited by its children. ''' 205 TAG_NAME = 'g' 206 207class Raw(Group): 208 ''' Any any SVG code to insert into the output. ''' 209 def __init__(self, content, defs=(), **kwargs): 210 super().__init__(**kwargs) 211 self.content = content 212 self.defs = defs 213 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 214 if dryRun: 215 return 216 outputFile.write(self.content) 217 def getSvgDefs(self): 218 return self.defs 219 220class Use(DrawingBasicElement): 221 ''' A copy of another element 222 223 The other element becomes an SVG def shared between all Use elements 224 that reference it. ''' 225 TAG_NAME = 'use' 226 def __init__(self, otherElem, x, y, **kwargs): 227 y = -y 228 if isinstance(otherElem, str) and not otherElem.startswith('#'): 229 otherElem = '#' + otherElem 230 super().__init__(xlink__href=otherElem, x=x, y=y, **kwargs) 231 232class Animate(DrawingBasicElement): 233 ''' Animation for a specific property of another element 234 235 This should be added as a child of the element to animate. Otherwise 236 the other element and this element must both be added to the drawing. 237 ''' 238 TAG_NAME = 'animate' 239 def __init__(self, attributeName, dur, from_or_values=None, to=None, 240 begin=None, otherElem=None, **kwargs): 241 if to is None: 242 values = from_or_values 243 from_ = None 244 else: 245 values = None 246 from_ = from_or_values 247 if isinstance(otherElem, str) and not otherElem.startswith('#'): 248 otherElem = '#' + otherElem 249 kwargs.update(attributeName=attributeName, to=to, dur=dur, begin=begin) 250 kwargs.setdefault('values', values) 251 kwargs.setdefault('from_', from_) 252 super().__init__(xlink__href=otherElem, **kwargs) 253 254 def getSvgDefs(self): 255 return [v for k, v in self.args.items() 256 if isinstance(v, DrawingElement) 257 if k != 'xlink:href'] 258 259 def getLinkedElems(self): 260 return (self.args['xlink:href'],) 261 262class _Mpath(DrawingBasicElement): 263 ''' Used by AnimateMotion ''' 264 TAG_NAME = 'mpath' 265 def __init__(self, otherPath, **kwargs): 266 super().__init__(xlink__href=otherPath, **kwargs) 267 268class AnimateMotion(Animate): 269 ''' Animation for the motion another element along a path 270 271 This should be added as a child of the element to animate. Otherwise 272 the other element and this element must both be added to the drawing. 273 ''' 274 TAG_NAME = 'animateMotion' 275 def __init__(self, path, dur, from_or_values=None, to=None, begin=None, 276 otherElem=None, **kwargs): 277 useMpath = False 278 if isinstance(path, DrawingElement): 279 useMpath = True 280 pathElem = path 281 path = None 282 kwargs.setdefault('attributeName', None) 283 super().__init__(dur=dur, from_or_values=from_or_values, to=to, 284 begin=begin, path=path, otherElem=otherElem, **kwargs) 285 if useMpath: 286 self.children.append(_Mpath(pathElem)) 287 288class AnimateTransform(Animate): 289 ''' Animation for the transform property of another element 290 291 This should be added as a child of the element to animate. Otherwise 292 the other element and this element must both be added to the drawing. 293 ''' 294 TAG_NAME = 'animateTransform' 295 def __init__(self, type, dur, from_or_values, to=None, begin=None, 296 attributeName='transform', otherElem=None, **kwargs): 297 super().__init__(attributeName, dur=dur, from_or_values=from_or_values, 298 to=to, begin=begin, type=type, otherElem=otherElem, 299 **kwargs) 300 301class Set(Animate): 302 ''' Animation for a specific property of another element that sets the new 303 value without a transition. 304 305 This should be added as a child of the element to animate. Otherwise 306 the other element and this element must both be added to the drawing. 307 ''' 308 TAG_NAME = 'set' 309 def __init__(self, attributeName, dur, to=None, begin=None, 310 otherElem=None, **kwargs): 311 super().__init__(attributeName, dur=dur, from_or_values=None, 312 to=to, begin=begin, otherElem=otherElem, **kwargs) 313 314class Discard(Animate): 315 ''' Animation configuration specifying when it is safe to discard another 316 element. E.g. when it will no longer be visible after an animation. 317 318 This should be added as a child of the element to animate. Otherwise 319 the other element and this element must both be added to the drawing. 320 ''' 321 TAG_NAME = 'discard' 322 def __init__(self, attributeName, begin=None, **kwargs): 323 kwargs.setdefault('attributeName', None) 324 kwargs.setdefault('to', None) 325 kwargs.setdefault('dur', None) 326 super().__init__(from_or_values=None, begin=begin, otherElem=None, 327 **kwargs) 328 329class Image(DrawingBasicElement): 330 ''' A linked or embedded raster image ''' 331 TAG_NAME = 'image' 332 MIME_MAP = { 333 '.bm': 'image/bmp', 334 '.bmp': 'image/bmp', 335 '.gif': 'image/gif', 336 '.jpeg':'image/jpeg', 337 '.jpg': 'image/jpeg', 338 '.png': 'image/png', 339 '.svg': 'image/svg+xml', 340 '.tif': 'image/tiff', 341 '.tiff':'image/tiff', 342 '.pdf': 'application/pdf', 343 '.txt': 'text/plain', 344 } 345 MIME_DEFAULT = 'image/png' 346 def __init__(self, x, y, width, height, path=None, data=None, embed=False, 347 mimeType=None, **kwargs): 348 ''' Specify either the path or data argument. If path is used and 349 embed is True, the image file is embedded in a data URI. ''' 350 if path is None and data is None: 351 raise ValueError('Either path or data arguments must be given') 352 if embed: 353 if mimeType is None and path is not None: 354 ext = os.path.splitext(path)[1].lower() 355 if ext in self.MIME_MAP: 356 mimeType = self.MIME_MAP[ext] 357 else: 358 mimeType = self.MIME_DEFAULT 359 warnings.warn('Unknown image file type "{}"'.format(ext), 360 Warning) 361 if mimeType is None: 362 mimeType = self.MIME_DEFAULT 363 warnings.warn('Unspecified image type; assuming png', Warning) 364 if data is not None: 365 embed = True 366 if embed and data is None: 367 with open(path, 'rb') as f: 368 data = f.read() 369 if not embed: 370 uri = path 371 else: 372 encData = base64.b64encode(data).decode() 373 uri = 'data:{};base64,{}'.format(mimeType, encData) 374 super().__init__(x=x, y=-y-height, width=width, height=height, 375 xlink__href=uri, **kwargs) 376 377class Text(DrawingParentElement): 378 ''' Text 379 380 Additional keyword arguments are output as additional arguments to the 381 SVG node e.g. fill="red", font_size=20, text_anchor="middle". ''' 382 TAG_NAME = 'text' 383 hasContent = True 384 def __init__(self, text, fontSize, x, y, center=False, valign=None, 385 lineHeight=1, **kwargs): 386 singleLine = isinstance(text, str) 387 if '\n' in text: 388 text = text.splitlines() 389 singleLine = False 390 if not singleLine: 391 text = tuple(text) 392 numLines = len(text) 393 else: 394 numLines = 1 395 centerOffset = 0 396 emOffset = 0 397 if center: 398 if 'text_anchor' not in kwargs: 399 kwargs['text_anchor'] = 'middle' 400 if valign is None: 401 if singleLine: 402 # Backwards compatible centering 403 centerOffset = fontSize*0.5*center 404 else: 405 emOffset = 0.4 - lineHeight * (numLines - 1) / 2 406 if valign == 'middle': 407 emOffset = 0.4 - lineHeight * (numLines - 1) / 2 408 elif valign == 'top': 409 emOffset = 1 410 elif valign == 'bottom': 411 emOffset = -lineHeight * (numLines - 1) 412 if centerOffset: 413 try: 414 fontSize = float(fontSize) 415 except TypeError: 416 pass 417 else: 418 translate = 'translate(0,{})'.format(centerOffset) 419 if 'transform' in kwargs: 420 kwargs['transform'] += ' ' + translate 421 else: 422 kwargs['transform'] = translate 423 super().__init__(x=x, y=-y, font_size=fontSize, **kwargs) 424 if singleLine: 425 self.escapedText = xml.escape(text) 426 else: 427 self.escapedText = '' 428 # Text is an iterable 429 for i, line in enumerate(text): 430 dy = '{}em'.format(emOffset if i == 0 else lineHeight) 431 self.appendLine(line, x=x, dy=dy) 432 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 433 if dryRun: 434 return 435 outputFile.write(self.escapedText) 436 def writeChildrenContent(self, idGen, isDuplicate, outputFile, dryRun): 437 ''' Override in a subclass to add data between the start and end 438 tags. This will not be called if hasContent is False. ''' 439 children = self.allChildren() 440 if dryRun: 441 for child in children: 442 child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun) 443 return 444 for child in children: 445 child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun) 446 def appendLine(self, line, **kwargs): 447 self.append(TSpan(line, **kwargs)) 448 449class _TextContainingElement(DrawingBasicElement): 450 ''' A private parent class used for elements that only have plain text 451 content. ''' 452 hasContent = True 453 def __init__(self, text, **kwargs): 454 super().__init__(**kwargs) 455 self.escapedText = xml.escape(text) 456 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 457 if dryRun: 458 return 459 outputFile.write(self.escapedText) 460 461 462class TSpan(_TextContainingElement): 463 ''' A line of text within the Text element. ''' 464 TAG_NAME = 'tspan' 465 466class Title(_TextContainingElement): 467 ''' A title element. 468 469 This element can be appended with shape.appendTitle("Your title!"), 470 which can be useful for adding a tooltip or on-hover text display 471 to an element. 472 ''' 473 TAG_NAME = 'title' 474 475class Rectangle(DrawingBasicElement): 476 ''' A rectangle 477 478 Additional keyword arguments are output as additional arguments to the 479 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 480 TAG_NAME = 'rect' 481 def __init__(self, x, y, width, height, **kwargs): 482 try: 483 y = -y-height 484 except TypeError: 485 pass 486 super().__init__(x=x, y=y, width=width, height=height, 487 **kwargs) 488 489class Circle(DrawingBasicElement): 490 ''' A circle 491 492 Additional keyword arguments are output as additional properties to the 493 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 494 TAG_NAME = 'circle' 495 def __init__(self, cx, cy, r, **kwargs): 496 try: 497 cy = -cy 498 except TypeError: 499 pass 500 super().__init__(cx=cx, cy=cy, r=r, **kwargs) 501 502class Ellipse(DrawingBasicElement): 503 ''' An ellipse 504 505 Additional keyword arguments are output as additional properties to the 506 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 507 TAG_NAME = 'ellipse' 508 def __init__(self, cx, cy, rx, ry, **kwargs): 509 try: 510 cy = -cy 511 except TypeError: 512 pass 513 super().__init__(cx=cx, cy=cy, rx=rx, ry=ry, **kwargs) 514 515class ArcLine(Circle): 516 ''' An arc 517 518 In most cases, use Arc instead of ArcLine. ArcLine uses the 519 stroke-dasharray SVG property to make the edge of a circle look like 520 an arc. 521 522 Additional keyword arguments are output as additional arguments to the 523 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 524 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs): 525 if endDeg - startDeg == 360: 526 super().__init__(cx, cy, r, **kwargs) 527 return 528 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360 529 arcDeg = (endDeg - startDeg) % 360 530 def arcLen(deg): return math.radians(deg) * r 531 wholeLen = 2 * math.pi * r 532 if endDeg == startDeg: 533 offset = 1 534 dashes = "0 {}".format(wholeLen+2) 535 #elif endDeg >= startDeg: 536 elif True: 537 startLen = arcLen(startDeg) 538 arcLen = arcLen(arcDeg) 539 offLen = wholeLen - arcLen 540 offset = -startLen 541 dashes = "{} {}".format(arcLen, offLen) 542 #else: 543 # firstLen = arcLen(endDeg) 544 # secondLen = arcLen(360-startDeg) 545 # gapLen = wholeLen - firstLen - secondLen 546 # offset = 0 547 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen) 548 super().__init__(cx, cy, r, stroke_dasharray=dashes, 549 stroke_dashoffset=offset, **kwargs) 550 551class Path(DrawingBasicElement): 552 ''' An arbitrary path 553 554 Path Supports building an SVG path by calling instance methods 555 corresponding to path commands. 556 557 Additional keyword arguments are output as additional properties to the 558 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 559 TAG_NAME = 'path' 560 def __init__(self, d='', **kwargs): 561 super().__init__(d=d, **kwargs) 562 def append(self, commandStr, *args): 563 if len(self.args['d']) > 0: 564 commandStr = ' ' + commandStr 565 if len(args) > 0: 566 commandStr = commandStr + ','.join(map(str, args)) 567 self.args['d'] += commandStr 568 return self 569 def M(self, x, y): return self.append('M', x, -y) 570 def m(self, dx, dy): return self.append('m', dx, -dy) 571 def L(self, x, y): return self.append('L', x, -y) 572 def l(self, dx, dy): return self.append('l', dx, -dy) 573 def H(self, x): return self.append('H', x) 574 def h(self, dx): return self.append('h', dx) 575 def V(self, y): return self.append('V', -y) 576 def v(self, dy): return self.append('v', -dy) 577 def Z(self): return self.append('Z') 578 def C(self, cx1, cy1, cx2, cy2, ex, ey): 579 return self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey) 580 def c(self, cx1, cy1, cx2, cy2, ex, ey): 581 return self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey) 582 def S(self, cx2, cy2, ex, ey): return self.append('S', cx2, -cy2, ex, -ey) 583 def s(self, cx2, cy2, ex, ey): return self.append('s', cx2, -cy2, ex, -ey) 584 def Q(self, cx, cy, ex, ey): return self.append('Q', cx, -cy, ex, -ey) 585 def q(self, cx, cy, ex, ey): return self.append('q', cx, -cy, ex, -ey) 586 def T(self, ex, ey): return self.append('T', ex, -ey) 587 def t(self, ex, ey): return self.append('t', ex, -ey) 588 def A(self, rx, ry, rot, largeArc, sweep, ex, ey): 589 return self.append('A', rx, ry, rot, int(bool(largeArc)), 590 int(bool(sweep)), ex, -ey) 591 def a(self, rx, ry, rot, largeArc, sweep, ex, ey): 592 return self.append('a', rx, ry, rot, int(bool(largeArc)), 593 int(bool(sweep)), ex, -ey) 594 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, 595 includeL=False): 596 ''' Uses A() to draw a circular arc ''' 597 largeArc = (endDeg - startDeg) % 360 > 180 598 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180 599 sx, sy = r*math.cos(startRad), r*math.sin(startRad) 600 ex, ey = r*math.cos(endRad), r*math.sin(endRad) 601 if includeL: 602 self.L(cx+sx, cy+sy) 603 elif includeM: 604 self.M(cx+sx, cy+sy) 605 return self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey) 606 607class Lines(Path): 608 ''' A sequence of connected lines (or a polygon) 609 610 Additional keyword arguments are output as additional properties to the 611 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 612 def __init__(self, sx, sy, *points, close=False, **kwargs): 613 super().__init__(d='', **kwargs) 614 self.M(sx, sy) 615 assert len(points) % 2 == 0 616 for i in range(len(points) // 2): 617 self.L(points[2*i], points[2*i+1]) 618 if close: 619 self.Z() 620 621class Line(Lines): 622 ''' A line 623 624 Additional keyword arguments are output as additional properties to the 625 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 626 def __init__(self, sx, sy, ex, ey, **kwargs): 627 super().__init__(sx, sy, ex, ey, close=False, **kwargs) 628 629class Arc(Path): 630 ''' An arc 631 632 Additional keyword arguments are output as additional properties to the 633 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 634 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs): 635 super().__init__(d='', **kwargs) 636 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True) 637