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 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 letter_spacing=1.5. 383 384 CairoSVG bug with letter spacing text on a path: The first two letters 385 are always spaced as if letter_spacing=1. ''' 386 TAG_NAME = 'text' 387 hasContent = True 388 def __new__(cls, text, *args, path=None, id=None, _skipCheck=False, 389 **kwargs): 390 # Check for the special case of multi-line text on a path 391 # This is inconsistently implemented by renderers so we return a group 392 # of single-line text on paths instead. 393 if path is not None and not _skipCheck: 394 text, _ = cls._handleTextArgument(text, True) 395 if len(text) > 1: 396 # Special case 397 g = Group(id=id) 398 for i, line in enumerate(text): 399 subtext = [None] * len(text) 400 subtext[i] = line 401 g.append(Text(subtext, *args, path=path, _skipCheck=True, 402 **kwargs)) 403 return g 404 return super().__new__(cls) 405 def __init__(self, text, fontSize, x=None, y=None, *, center=False, 406 valign=None, lineHeight=1, lineOffset=0, path=None, 407 startOffset=None, pathArgs=None, tspanArgs=None, 408 cairoFix=True, _skipCheck=False, **kwargs): 409 # Check argument requirements 410 if path is None: 411 if x is None or y is None: 412 raise TypeError( 413 "__init__() missing required arguments: 'x' and 'y' " 414 "are required unless 'path' is specified") 415 try: 416 y = -y 417 except TypeError: 418 pass 419 else: 420 if x is not None or y is not None: 421 raise TypeError( 422 "__init__() conflicting arguments: 'x' and 'y' " 423 "should not be used when 'path' is specified") 424 if pathArgs is None: 425 pathArgs = {} 426 if startOffset is not None: 427 pathArgs.setdefault('startOffset', startOffset) 428 if tspanArgs is None: 429 tspanArgs = {} 430 onPath = path is not None 431 432 text, singleLine = self._handleTextArgument(text, forceMulti=onPath) 433 numLines = len(text) 434 435 # Text alignment 436 centerCompat = False 437 if center and valign is None: 438 valign = 'middle' 439 centerCompat = singleLine and not onPath 440 if center and kwargs.get('text_anchor') is None: 441 kwargs['text_anchor'] = 'middle' 442 if valign == 'middle': 443 if centerCompat: # Backwards compatible centering 444 lineOffset += 0.5 * center 445 else: 446 lineOffset += 0.4 - lineHeight * (numLines - 1) / 2 447 elif valign == 'top': 448 lineOffset += 1 449 elif valign == 'bottom': 450 lineOffset += -lineHeight * (numLines - 1) 451 if singleLine: 452 dy = '{}em'.format(lineOffset) 453 kwargs.setdefault('dy', dy) 454 # Text alignment on a path 455 if onPath: 456 if kwargs.get('text_anchor') == 'start': 457 pathArgs.setdefault('startOffset', '0') 458 elif kwargs.get('text_anchor') == 'middle': 459 pathArgs.setdefault('startOffset', '50%') 460 elif kwargs.get('text_anchor') == 'end': 461 if cairoFix and 'startOffset' not in pathArgs: 462 # Fix CairoSVG not drawing the last character with aligned 463 # right 464 tspanArgs.setdefault('dx', -1) 465 pathArgs.setdefault('startOffset', '100%') 466 467 super().__init__(x=x, y=y, font_size=fontSize, **kwargs) 468 self._textPath = None 469 if singleLine: 470 self.escapedText = xml.escape(text[0]) 471 else: 472 # Add elements for each line of text 473 self.escapedText = '' 474 if path is None: 475 # Text is an iterable 476 for i, line in enumerate(text): 477 dy = '{}em'.format(lineOffset if i == 0 else lineHeight) 478 self.appendLine(line, x=x, dy=dy, **tspanArgs) 479 else: 480 self._textPath = _TextPath(path, **pathArgs) 481 assert sum(bool(line) for line in text) <= 1, ( 482 'Logic error, __new__ should handle multi-line paths') 483 for i, line in enumerate(text): 484 if not line: continue 485 dy = '{}em'.format(lineOffset + i*lineHeight) 486 tspan = TSpan(line, dy=dy, **tspanArgs) 487 self._textPath.append(tspan) 488 self.append(self._textPath) 489 @staticmethod 490 def _handleTextArgument(text, forceMulti=False): 491 # Handle multi-line text (contains '\n' or is a list of strings) 492 singleLine = isinstance(text, str) 493 if isinstance(text, str): 494 singleLine = '\n' not in text and not forceMulti 495 if singleLine: 496 text = (text,) 497 else: 498 text = tuple(text.splitlines()) 499 singleLine = False 500 else: 501 singleLine = False 502 text = tuple(text) 503 return text, singleLine 504 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 505 if dryRun: 506 return 507 outputFile.write(self.escapedText) 508 def writeChildrenContent(self, idGen, isDuplicate, outputFile, dryRun): 509 ''' Override in a subclass to add data between the start and end 510 tags. This will not be called if hasContent is False. ''' 511 children = self.allChildren() 512 if dryRun: 513 for child in children: 514 child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun) 515 return 516 for child in children: 517 child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun) 518 def appendLine(self, line, **kwargs): 519 if self._textPath is not None: 520 raise ValueError('appendLine is not supported for text on a path') 521 self.append(TSpan(line, **kwargs)) 522 523class _TextPath(DrawingParentElement): 524 TAG_NAME = 'textPath' 525 hasContent = True 526 def __init__(self, path, **kwargs): 527 super().__init__(xlink__href=path, **kwargs) 528 529class _TextContainingElement(DrawingBasicElement): 530 ''' A private parent class used for elements that only have plain text 531 content. ''' 532 hasContent = True 533 def __init__(self, text, **kwargs): 534 super().__init__(**kwargs) 535 self.escapedText = xml.escape(text) 536 def writeContent(self, idGen, isDuplicate, outputFile, dryRun): 537 if dryRun: 538 return 539 outputFile.write(self.escapedText) 540 541class TSpan(_TextContainingElement): 542 ''' A line of text within the Text element. ''' 543 TAG_NAME = 'tspan' 544 545class Title(_TextContainingElement): 546 ''' A title element. 547 548 This element can be appended with shape.appendTitle("Your title!"), 549 which can be useful for adding a tooltip or on-hover text display 550 to an element. 551 ''' 552 TAG_NAME = 'title' 553 554class Rectangle(DrawingBasicElement): 555 ''' A rectangle 556 557 Additional keyword arguments are output as additional arguments to the 558 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 559 TAG_NAME = 'rect' 560 def __init__(self, x, y, width, height, **kwargs): 561 try: 562 y = -y-height 563 except TypeError: 564 pass 565 super().__init__(x=x, y=y, width=width, height=height, 566 **kwargs) 567 568class Circle(DrawingBasicElement): 569 ''' A circle 570 571 Additional keyword arguments are output as additional properties to the 572 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 573 TAG_NAME = 'circle' 574 def __init__(self, cx, cy, r, **kwargs): 575 try: 576 cy = -cy 577 except TypeError: 578 pass 579 super().__init__(cx=cx, cy=cy, r=r, **kwargs) 580 581class Ellipse(DrawingBasicElement): 582 ''' An ellipse 583 584 Additional keyword arguments are output as additional properties to the 585 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 586 TAG_NAME = 'ellipse' 587 def __init__(self, cx, cy, rx, ry, **kwargs): 588 try: 589 cy = -cy 590 except TypeError: 591 pass 592 super().__init__(cx=cx, cy=cy, rx=rx, ry=ry, **kwargs) 593 594class ArcLine(Circle): 595 ''' An arc 596 597 In most cases, use Arc instead of ArcLine. ArcLine uses the 598 stroke-dasharray SVG property to make the edge of a circle look like 599 an arc. 600 601 Additional keyword arguments are output as additional arguments to the 602 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 603 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs): 604 if endDeg - startDeg == 360: 605 super().__init__(cx, cy, r, **kwargs) 606 return 607 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360 608 arcDeg = (endDeg - startDeg) % 360 609 def arcLen(deg): return math.radians(deg) * r 610 wholeLen = 2 * math.pi * r 611 if endDeg == startDeg: 612 offset = 1 613 dashes = "0 {}".format(wholeLen+2) 614 #elif endDeg >= startDeg: 615 elif True: 616 startLen = arcLen(startDeg) 617 arcLen = arcLen(arcDeg) 618 offLen = wholeLen - arcLen 619 offset = -startLen 620 dashes = "{} {}".format(arcLen, offLen) 621 #else: 622 # firstLen = arcLen(endDeg) 623 # secondLen = arcLen(360-startDeg) 624 # gapLen = wholeLen - firstLen - secondLen 625 # offset = 0 626 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen) 627 super().__init__(cx, cy, r, stroke_dasharray=dashes, 628 stroke_dashoffset=offset, **kwargs) 629 630class Path(DrawingBasicElement): 631 ''' An arbitrary path 632 633 Path Supports building an SVG path by calling instance methods 634 corresponding to path commands. 635 636 Additional keyword arguments are output as additional properties to the 637 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 638 TAG_NAME = 'path' 639 def __init__(self, d='', **kwargs): 640 super().__init__(d=d, **kwargs) 641 def append(self, commandStr, *args): 642 if len(self.args['d']) > 0: 643 commandStr = ' ' + commandStr 644 if len(args) > 0: 645 commandStr = commandStr + ','.join(map(str, args)) 646 self.args['d'] += commandStr 647 return self 648 def M(self, x, y): return self.append('M', x, -y) 649 def m(self, dx, dy): return self.append('m', dx, -dy) 650 def L(self, x, y): return self.append('L', x, -y) 651 def l(self, dx, dy): return self.append('l', dx, -dy) 652 def H(self, x): return self.append('H', x) 653 def h(self, dx): return self.append('h', dx) 654 def V(self, y): return self.append('V', -y) 655 def v(self, dy): return self.append('v', -dy) 656 def Z(self): return self.append('Z') 657 def C(self, cx1, cy1, cx2, cy2, ex, ey): 658 return self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey) 659 def c(self, cx1, cy1, cx2, cy2, ex, ey): 660 return self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey) 661 def S(self, cx2, cy2, ex, ey): return self.append('S', cx2, -cy2, ex, -ey) 662 def s(self, cx2, cy2, ex, ey): return self.append('s', cx2, -cy2, ex, -ey) 663 def Q(self, cx, cy, ex, ey): return self.append('Q', cx, -cy, ex, -ey) 664 def q(self, cx, cy, ex, ey): return self.append('q', cx, -cy, ex, -ey) 665 def T(self, ex, ey): return self.append('T', ex, -ey) 666 def t(self, ex, ey): return self.append('t', ex, -ey) 667 def A(self, rx, ry, rot, largeArc, sweep, ex, ey): 668 return self.append('A', rx, ry, rot, int(bool(largeArc)), 669 int(bool(sweep)), ex, -ey) 670 def a(self, rx, ry, rot, largeArc, sweep, ex, ey): 671 return self.append('a', rx, ry, rot, int(bool(largeArc)), 672 int(bool(sweep)), ex, -ey) 673 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, 674 includeL=False): 675 ''' Uses A() to draw a circular arc ''' 676 largeArc = (endDeg - startDeg) % 360 > 180 677 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180 678 sx, sy = r*math.cos(startRad), r*math.sin(startRad) 679 ex, ey = r*math.cos(endRad), r*math.sin(endRad) 680 if includeL: 681 self.L(cx+sx, cy+sy) 682 elif includeM: 683 self.M(cx+sx, cy+sy) 684 return self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey) 685 686class Lines(Path): 687 ''' A sequence of connected lines (or a polygon) 688 689 Additional keyword arguments are output as additional properties to the 690 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 691 def __init__(self, sx, sy, *points, close=False, **kwargs): 692 super().__init__(d='', **kwargs) 693 self.M(sx, sy) 694 assert len(points) % 2 == 0 695 for i in range(len(points) // 2): 696 self.L(points[2*i], points[2*i+1]) 697 if close: 698 self.Z() 699 700class Line(Lines): 701 ''' A line 702 703 Additional keyword arguments are output as additional properties to the 704 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 705 def __init__(self, sx, sy, ex, ey, **kwargs): 706 super().__init__(sx, sy, ex, ey, close=False, **kwargs) 707 708class Arc(Path): 709 ''' An arc 710 711 Additional keyword arguments are output as additional properties to the 712 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 713 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs): 714 super().__init__(d='', **kwargs) 715 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True) 716