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