Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets
1import math 2import os.path 3import base64 4import warnings 5import xml.sax.saxutils as xml 6from collections import defaultdict 7 8from . import defs, url_encode 9 10 11def write_xml_node_args(args, output_file, id_map=None): 12 for k, v in args.items(): 13 if v is None: continue 14 if isinstance(v, DrawingElement): 15 mapped_id = v.id 16 if id_map and id(v) in id_map: 17 mapped_id = id_map[id(v)] 18 if mapped_id is None: 19 continue 20 if k == 'xlink:href': 21 v = '#{}'.format(mapped_id) 22 else: 23 v = 'url(#{})'.format(mapped_id) 24 output_file.write(' {}="{}"'.format(k,v)) 25 26 27class DrawingElement: 28 '''Base class for drawing elements. 29 30 Subclasses must implement write_svg_element. 31 ''' 32 def write_svg_element(self, id_map, is_duplicate, output_file, dry_run, 33 force_dup=False): 34 raise NotImplementedError('Abstract base class') 35 def get_svg_defs(self): 36 return () 37 def get_linked_elems(self): 38 return () 39 def write_svg_defs(self, id_map, is_duplicate, output_file, dry_run): 40 for defn in self.get_svg_defs(): 41 if is_duplicate(defn): 42 continue 43 defn.write_svg_defs(id_map, is_duplicate, output_file, dry_run) 44 if defn.id is None: 45 id_map[id(defn)] 46 defn.write_svg_element( 47 id_map, is_duplicate, output_file, dry_run, force_dup=True) 48 if not dry_run: 49 output_file.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 child 55 nodes. 56 ''' 57 TAG_NAME = '_' 58 has_content = False 59 def __init__(self, **args): 60 self.args = {} 61 for k, v in args.items(): 62 k = k.replace('__', ':') 63 k = k.replace('_', '-') 64 if k[-1] == '-': 65 k = k[:-1] 66 self.args[k] = v 67 self.children = [] 68 self.ordered_children = defaultdict(list) 69 def check_children_allowed(self): 70 if not self.has_content: 71 raise RuntimeError( 72 '{} does not support children'.format(type(self))) 73 def all_children(self): 74 '''Return self.children and self.ordered_children as a single list.''' 75 output = list(self.children) 76 for z in sorted(self.ordered_children): 77 output.extend(self.ordered_children[z]) 78 return output 79 @property 80 def id(self): 81 return self.args.get('id', None) 82 def write_svg_element(self, id_map, is_duplicate, output_file, dry_run, 83 force_dup=False): 84 children = self.all_children() 85 if dry_run: 86 if is_duplicate(self) and self.id is None: 87 id_map[id(self)] 88 for elem in self.get_linked_elems(): 89 if elem.id is None: 90 id_map[id(elem.id)] 91 if self.has_content: 92 self.write_content(id_map, is_duplicate, output_file, dry_run) 93 if children: 94 self.write_children_content( 95 id_map, is_duplicate, output_file, dry_run) 96 return 97 if is_duplicate(self) and not force_dup: 98 mapped_id = self.id 99 if id_map and id(self) in id_map: 100 mapped_id = id_map[id(self)] 101 output_file.write('<use xlink:href="#{}" />'.format(mapped_id)) 102 return 103 output_file.write('<') 104 output_file.write(self.TAG_NAME) 105 override_args = self.args 106 if id(self) in id_map: 107 override_args = dict(override_args) 108 override_args['id'] = id_map[id(self)] 109 write_xml_node_args(override_args, output_file, id_map) 110 if not self.has_content and not children: 111 output_file.write(' />') 112 else: 113 output_file.write('>') 114 if self.has_content: 115 self.write_content(id_map, is_duplicate, output_file, dry_run) 116 if children: 117 self.write_children_content( 118 id_map, is_duplicate, output_file, dry_run) 119 output_file.write('</') 120 output_file.write(self.TAG_NAME) 121 output_file.write('>') 122 def write_content(self, id_map, is_duplicate, output_file, dry_run): 123 '''Override in a subclass to add data between the start and end tags. 124 125 This will not be called if has_content is False. 126 ''' 127 raise RuntimeError('This element has no content') 128 def write_children_content(self, id_map, is_duplicate, output_file, 129 dry_run): 130 '''Override in a subclass to add data between the start and end tags. 131 132 This will not be called if has_content is False. 133 ''' 134 children = self.all_children() 135 if dry_run: 136 for child in children: 137 child.write_svg_element( 138 id_map, is_duplicate, output_file, dry_run) 139 return 140 output_file.write('\n') 141 for child in children: 142 child.write_svg_element(id_map, is_duplicate, output_file, dry_run) 143 output_file.write('\n') 144 def get_svg_defs(self): 145 return [v for v in self.args.values() 146 if isinstance(v, DrawingElement)] 147 def write_svg_defs(self, id_map, is_duplicate, output_file, dry_run): 148 super().write_svg_defs(id_map, is_duplicate, output_file, dry_run) 149 for child in self.all_children(): 150 child.write_svg_defs(id_map, is_duplicate, output_file, dry_run) 151 def __eq__(self, other): 152 if isinstance(other, type(self)): 153 return (self.TAG_NAME == other.TAG_NAME and 154 self.args == other.args and 155 self.children == other.children and 156 self.ordered_children == other.ordered_children) 157 return False 158 def append_anim(self, animate_element): 159 self.children.append(animate_element) 160 def extend_anim(self, animate_iterable): 161 self.children.extend(animate_iterable) 162 def append_title(self, text, **kwargs): 163 self.children.append(Title(text, **kwargs)) 164 165class DrawingParentElement(DrawingBasicElement): 166 '''Base class for SVG elements that can have child nodes.''' 167 has_content = True 168 def __init__(self, children=(), ordered_children=None, **args): 169 super().__init__(**args) 170 self.children = list(children) 171 if ordered_children: 172 self.ordered_children.update( 173 (z, list(v)) for z, v in ordered_children.items()) 174 if self.children or self.ordered_children: 175 self.check_children_allowed() 176 def draw(self, obj, *, z=None, **kwargs): 177 if obj is None: 178 return 179 if not hasattr(obj, 'write_svg_element'): 180 elements = obj.to_drawables(**kwargs) 181 else: 182 assert len(kwargs) == 0 183 elements = obj 184 if hasattr(elements, 'write_svg_element'): 185 self.append(elements, z=z) 186 else: 187 self.extend(elements, z=z) 188 def append(self, element, *, z=None): 189 self.check_children_allowed() 190 if z is not None: 191 self.ordered_children[z].append(element) 192 else: 193 self.children.append(element) 194 def extend(self, iterable, *, z=None): 195 self.check_children_allowed() 196 if z is not None: 197 self.ordered_children[z].extend(iterable) 198 else: 199 self.children.extend(iterable) 200 def write_content(self, id_map, is_duplicate, output_file, dry_run): 201 pass 202 203class NoElement(DrawingElement): 204 ''' A drawing element that has no effect ''' 205 def __init__(self): 206 pass 207 def write_svg_element(self, id_map, is_duplicate, output_file, dry_run, 208 force_dup=False): 209 pass 210 def __eq__(self, other): 211 if isinstance(other, type(self)): 212 return True 213 return False 214 215class Group(DrawingParentElement): 216 '''A group of drawing elements. 217 218 Any transform will apply to its children and other attributes will be 219 inherited by its children. 220 ''' 221 TAG_NAME = 'g' 222 223class Raw(DrawingBasicElement): 224 '''Raw unescaped text to include in the SVG output. 225 226 Special XML characters like '<' and '&' in the content may have unexpected 227 effects or completely break the resulting SVG. 228 ''' 229 has_content = True 230 def __init__(self, content, defs=()): 231 super().__init__() 232 self.content = content 233 self.defs = defs 234 def write_content(self, id_map, is_duplicate, output_file, dry_run): 235 if dry_run: 236 return 237 output_file.write(self.content) 238 def get_svg_defs(self): 239 return self.defs 240 def check_children_allowed(self): 241 raise RuntimeError('{} does not support children'.format(type(self))) 242 243class Use(DrawingBasicElement): 244 '''A copy of another element, drawn at a given position 245 246 The referenced element becomes an SVG def shared between all Use elements 247 that reference it. Useful for drawings with many copies of similar shapes. 248 Additional arguments like `fill='red'` will be used as the default for this 249 copy of the shapes. 250 ''' 251 TAG_NAME = 'use' 252 def __init__(self, other_elem, x, y, **kwargs): 253 if isinstance(other_elem, str) and not other_elem.startswith('#'): 254 other_elem = '#' + other_elem 255 super().__init__(xlink__href=other_elem, x=x, y=y, **kwargs) 256 257class Animate(DrawingBasicElement): 258 '''Animation for a specific property of another element. 259 260 This should be added as a child of the element to animate. Otherwise the 261 referenced other element and this element must both be added to the drawing. 262 263 Useful SVG attributes: 264 - repeatCount: 0, 1, ..., 'indefinite' 265 ''' 266 TAG_NAME = 'animate' 267 def __init__(self, attributeName, dur, from_or_values=None, to=None, 268 begin=None, other_elem=None, **kwargs): 269 if to is None: 270 values = from_or_values 271 from_ = None 272 else: 273 values = None 274 from_ = from_or_values 275 if isinstance(other_elem, str) and not other_elem.startswith('#'): 276 other_elem = '#' + other_elem 277 kwargs.update(attributeName=attributeName, to=to, dur=dur, begin=begin) 278 kwargs.setdefault('values', values) 279 kwargs.setdefault('from_', from_) 280 super().__init__(xlink__href=other_elem, **kwargs) 281 282 def get_svg_defs(self): 283 return [v for k, v in self.args.items() 284 if isinstance(v, DrawingElement) 285 if k != 'xlink:href'] 286 287 def get_linked_elems(self): 288 elem = self.args['xlink:href'] 289 return (elem,) if elem is not None else () 290 291class _Mpath(DrawingBasicElement): 292 '''Used by AnimateMotion.''' 293 TAG_NAME = 'mpath' 294 def __init__(self, other_path, **kwargs): 295 super().__init__(xlink__href=other_path, **kwargs) 296 297class AnimateMotion(Animate): 298 '''Animation for the motion of another element along a path. 299 300 This should be added as a child of the element to animate. Otherwise the 301 referenced other element and this element must both be added to the drawing. 302 ''' 303 TAG_NAME = 'animateMotion' 304 def __init__(self, path, dur, from_or_values=None, to=None, begin=None, 305 other_elem=None, **kwargs): 306 use_mpath = False 307 if isinstance(path, DrawingElement): 308 use_mpath = True 309 path_elem = path 310 path = None 311 kwargs.setdefault('attributeName', None) 312 super().__init__(dur=dur, from_or_values=from_or_values, to=to, 313 begin=begin, path=path, other_elem=other_elem, 314 **kwargs) 315 if use_mpath: 316 self.children.append(_Mpath(path_elem)) 317 318class AnimateTransform(Animate): 319 '''Animation for the transform property of another element. 320 321 This should be added as a child of the element to animate. Otherwise the 322 referenced other element and this element must both be added to the drawing. 323 ''' 324 TAG_NAME = 'animateTransform' 325 def __init__(self, type, dur, from_or_values, to=None, begin=None, 326 attributeName='transform', other_elem=None, **kwargs): 327 super().__init__(attributeName, dur=dur, from_or_values=from_or_values, 328 to=to, begin=begin, type=type, other_elem=other_elem, 329 **kwargs) 330 331class Set(Animate): 332 '''Animation for a specific property of another element that sets the new 333 value without a transition. 334 335 This should be added as a child of the element to animate. Otherwise the 336 referenced other element and this element must both be added to the drawing. 337 ''' 338 TAG_NAME = 'set' 339 def __init__(self, attributeName, dur, to=None, begin=None, 340 other_elem=None, **kwargs): 341 super().__init__(attributeName, dur=dur, from_or_values=None, 342 to=to, begin=begin, other_elem=other_elem, **kwargs) 343 344class Discard(Animate): 345 '''Animation configuration specifying when it is safe to discard another 346 element. 347 348 Use this when an element will no longer be visible after an animation. 349 This should be added as a child of the element to animate. Otherwise the 350 referenced other element and this element must both be added to the drawing. 351 ''' 352 TAG_NAME = 'discard' 353 def __init__(self, attributeName, begin=None, **kwargs): 354 kwargs.setdefault('attributeName', None) 355 kwargs.setdefault('to', None) 356 kwargs.setdefault('dur', None) 357 super().__init__(from_or_values=None, begin=begin, other_elem=None, 358 **kwargs) 359 360class Image(DrawingBasicElement): 361 '''A linked or embedded image.''' 362 TAG_NAME = 'image' 363 MIME_MAP = { 364 '.bm': 'image/bmp', 365 '.bmp': 'image/bmp', 366 '.gif': 'image/gif', 367 '.jpeg':'image/jpeg', 368 '.jpg': 'image/jpeg', 369 '.png': 'image/png', 370 '.svg': 'image/svg+xml', 371 '.tif': 'image/tiff', 372 '.tiff':'image/tiff', 373 '.pdf': 'application/pdf', 374 '.txt': 'text/plain', 375 } 376 MIME_DEFAULT = 'image/png' 377 def __init__(self, x, y, width, height, path=None, data=None, embed=False, 378 mime_type=None, **kwargs): 379 ''' 380 Specify either the path or data argument. If path is used and embed is 381 True, the image file is embedded in a data URI. 382 ''' 383 if path is None and data is None: 384 raise ValueError('Either path or data arguments must be given') 385 if embed: 386 if mime_type is None and path is not None: 387 ext = os.path.splitext(path)[1].lower() 388 if ext in self.MIME_MAP: 389 mime_type = self.MIME_MAP[ext] 390 else: 391 mime_type = self.MIME_DEFAULT 392 warnings.warn('Unknown image file type "{}"'.format(ext), 393 Warning) 394 if mime_type is None: 395 mime_type = self.MIME_DEFAULT 396 warnings.warn('Unspecified image type; assuming png', Warning) 397 if data is not None: 398 embed = True 399 if embed and data is None: 400 with open(path, 'rb') as f: 401 data = f.read() 402 if not embed: 403 uri = path 404 else: 405 uri = url_encode.bytes_as_data_uri(data, mime=mime_type) 406 super().__init__(x=x, y=y, width=width, height=height, xlink__href=uri, 407 **kwargs) 408 409class Text(DrawingParentElement): 410 '''A line or multiple lines of text, optionally placed along a path. 411 412 Additional keyword arguments are output as additional arguments to the SVG 413 node e.g. fill='red', font_size=20, letter_spacing=1.5. 414 415 Useful SVG attributes: 416 - text_anchor: start, middle, end 417 - dominant_baseline: auto, central, middle, hanging, text-top, ... 418 See https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text 419 420 CairoSVG bug with letter spacing text on a path: The first two letters are 421 always spaced as if letter_spacing=1. 422 ''' 423 TAG_NAME = 'text' 424 has_content = True 425 def __new__(cls, text, *args, path=None, id=None, _skip_check=False, 426 **kwargs): 427 # Check for the special case of multi-line text on a path 428 # This is inconsistently implemented by renderers so we return a group 429 # of single-line text on paths instead. 430 if path is not None and not _skip_check: 431 text, _ = cls._handle_text_argument(text, True) 432 if len(text) > 1: 433 # Special case 434 g = Group(id=id) 435 for i, line in enumerate(text): 436 subtext = [None] * len(text) 437 subtext[i] = line 438 g.append(Text(subtext, *args, path=path, _skip_check=True, 439 **kwargs)) 440 return g 441 return super().__new__(cls) 442 def __init__(self, text, font_size, x=None, y=None, *, center=False, 443 line_height=1, line_offset=0, path=None, start_offset=None, 444 path_args=None, tspan_args=None, cairo_fix=True, 445 _skip_check=False, **kwargs): 446 # Check argument requirements 447 if path is None: 448 if x is None or y is None: 449 raise TypeError( 450 "__init__() missing required arguments: 'x' and 'y' " 451 "are required unless 'path' is specified") 452 else: 453 if x is not None or y is not None: 454 raise TypeError( 455 "__init__() conflicting arguments: 'x' and 'y' " 456 "should not be used when 'path' is specified") 457 if path_args is None: 458 path_args = {} 459 if start_offset is not None: 460 path_args.setdefault('startOffset', start_offset) 461 if tspan_args is None: 462 tspan_args = {} 463 on_path = path is not None 464 465 text, single_line = self._handle_text_argument( 466 text, force_multi=on_path) 467 num_lines = len(text) 468 469 # Text alignment 470 if center: 471 kwargs.setdefault('text_anchor', 'middle') 472 if path is None and single_line: 473 kwargs.setdefault('dominant_baseline', 'central') 474 else: 475 line_offset += 0.5 476 line_offset -= line_height * (num_lines - 1) / 2 477 # Text alignment on a path 478 if on_path: 479 if kwargs.get('text_anchor') == 'start': 480 path_args.setdefault('startOffset', '0') 481 elif kwargs.get('text_anchor') == 'middle': 482 path_args.setdefault('startOffset', '50%') 483 elif kwargs.get('text_anchor') == 'end': 484 if cairo_fix and 'startOffset' not in path_args: 485 # Fix CairoSVG not drawing the last character with aligned 486 # right 487 tspan_args.setdefault('dx', -1) 488 path_args.setdefault('startOffset', '100%') 489 490 super().__init__(x=x, y=y, font_size=font_size, **kwargs) 491 self._text_path = None 492 if single_line: 493 self.escaped_text = xml.escape(text[0]) 494 else: 495 # Add elements for each line of text 496 self.escaped_text = '' 497 if path is None: 498 # Text is an iterable 499 for i, line in enumerate(text): 500 dy = '{}em'.format(line_offset if i == 0 else line_height) 501 self.append_line(line, x=x, dy=dy, **tspan_args) 502 else: 503 self._text_path = _TextPath(path, **path_args) 504 assert sum(bool(line) for line in text) <= 1, ( 505 'Logic error, __new__ should handle multi-line paths') 506 for i, line in enumerate(text): 507 if not line: 508 continue 509 dy = '{}em'.format(line_offset + i*line_height) 510 tspan = TSpan(line, dy=dy, **tspan_args) 511 self._text_path.append(tspan) 512 self.append(self._text_path) 513 @staticmethod 514 def _handle_text_argument(text, force_multi=False): 515 # Handle multi-line text (contains '\n' or is a list of strings) 516 if isinstance(text, str): 517 single_line = '\n' not in text and not force_multi 518 if single_line: 519 text = (text,) 520 else: 521 text = tuple(text.splitlines()) 522 else: 523 single_line = False 524 text = tuple(text) 525 return text, single_line 526 def write_content(self, id_map, is_duplicate, output_file, dry_run): 527 if dry_run: 528 return 529 output_file.write(self.escaped_text) 530 def write_children_content(self, id_map, is_duplicate, output_file, 531 dry_run): 532 children = self.all_children() 533 for child in children: 534 child.write_svg_element(id_map, is_duplicate, output_file, dry_run) 535 def append_line(self, line, **kwargs): 536 if self._text_path is not None: 537 raise ValueError('appendLine is not supported for text on a path') 538 self.append(TSpan(line, **kwargs)) 539 540class _TextPath(DrawingParentElement): 541 TAG_NAME = 'textPath' 542 has_content = True 543 def __init__(self, path, **kwargs): 544 super().__init__(xlink__href=path, **kwargs) 545 546class _TextContainingElement(DrawingBasicElement): 547 ''' A private parent class used for elements that only have plain text 548 content. ''' 549 has_content = True 550 def __init__(self, text, **kwargs): 551 super().__init__(**kwargs) 552 self.escaped_text = xml.escape(text) 553 def write_content(self, id_map, is_duplicate, output_file, dry_run): 554 if dry_run: 555 return 556 output_file.write(self.escaped_text) 557 558class TSpan(_TextContainingElement): 559 ''' A line of text within the Text element. ''' 560 TAG_NAME = 'tspan' 561 562class Title(_TextContainingElement): 563 '''A title element. 564 565 This element can be appended with shape.append_title("Your title!"), which 566 can be useful for adding a tooltip or on-hover text display to an element. 567 ''' 568 TAG_NAME = 'title' 569 570class Rectangle(DrawingBasicElement): 571 '''A rectangle. 572 573 Additional keyword arguments are output as additional arguments to the SVG 574 node e.g. fill="red", stroke="#ff4477", stroke_width=2. 575 ''' 576 TAG_NAME = 'rect' 577 def __init__(self, x, y, width, height, **kwargs): 578 super().__init__(x=x, y=y, width=width, height=height, **kwargs) 579 580class Circle(DrawingBasicElement): 581 '''A circle. 582 583 Additional keyword arguments are output as additional arguments to the SVG 584 node e.g. fill="red", stroke="#ff4477", stroke_width=2. 585 ''' 586 TAG_NAME = 'circle' 587 def __init__(self, cx, cy, r, **kwargs): 588 super().__init__(cx=cx, cy=cy, r=r, **kwargs) 589 590class Ellipse(DrawingBasicElement): 591 '''An ellipse. 592 593 Additional keyword arguments are output as additional arguments to the SVG 594 node e.g. fill="red", stroke="#ff4477", stroke_width=2. 595 ''' 596 TAG_NAME = 'ellipse' 597 def __init__(self, cx, cy, rx, ry, **kwargs): 598 super().__init__(cx=cx, cy=cy, rx=rx, ry=ry, **kwargs) 599 600class ArcLine(Circle): 601 '''An arc. 602 603 In most cases, use Arc instead of ArcLine. ArcLine uses the 604 stroke-dasharray SVG property to make the edge of a circle look like an arc. 605 606 Additional keyword arguments are output as additional arguments to the SVG 607 node e.g. fill="red", stroke="#ff4477", stroke_width=2. 608 ''' 609 def __init__(self, cx, cy, r, start_deg, end_deg, **kwargs): 610 if end_deg - start_deg == 360: 611 super().__init__(cx, cy, r, **kwargs) 612 return 613 start_deg, end_deg = (-end_deg) % 360, (-start_deg) % 360 614 arc_deg = (end_deg - start_deg) % 360 615 def arc_len(deg): 616 return math.radians(deg) * r 617 whole_len = 2 * math.pi * r 618 if end_deg == start_deg: 619 offset = 1 620 dashes = "0 {}".format(whole_len+2) 621 else: 622 start_len = arc_len(start_deg) 623 arc_len = arc_len(arc_deg) 624 off_len = whole_len - arc_len 625 offset = -start_len 626 dashes = "{} {}".format(arc_len, off_len) 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 corresponding 634 to path commands. 635 636 Complete descriptions of path commands: 637 https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#path_commands 638 639 Additional keyword arguments are output as additional arguments to the SVG 640 node e.g. fill="red", stroke="#ff4477", stroke_width=2. 641 ''' 642 TAG_NAME = 'path' 643 def __init__(self, d='', **kwargs): 644 super().__init__(d=d, **kwargs) 645 def append(self, command_str, *args): 646 if len(self.args['d']) > 0: 647 command_str = ' ' + command_str 648 if len(args) > 0: 649 command_str = command_str + ','.join(map(str, args)) 650 self.args['d'] += command_str 651 return self 652 def M(self, x, y): 653 '''Start a new curve section from this point.''' 654 return self.append('M', x, y) 655 def m(self, dx, dy): 656 '''Start a new curve section from this point (relative coordinates).''' 657 return self.append('m', dx, dy) 658 def L(self, x, y): 659 '''Draw a line to this point.''' 660 return self.append('L', x, y) 661 def l(self, dx, dy): 662 '''Draw a line to this point (relative coordinates).''' 663 return self.append('l', dx, dy) 664 def H(self, x): 665 '''Draw a horizontal line to this x coordinate.''' 666 return self.append('H', x) 667 def h(self, dx): 668 '''Draw a horizontal line to this relative x coordinate.''' 669 return self.append('h', dx) 670 def V(self, y): 671 '''Draw a horizontal line to this y coordinate.''' 672 return self.append('V', y) 673 def v(self, dy): 674 '''Draw a horizontal line to this relative y coordinate.''' 675 return self.append('v', dy) 676 def Z(self): 677 '''Draw a line back to the previous m or M point.''' 678 return self.append('Z') 679 def C(self, cx1, cy1, cx2, cy2, ex, ey): 680 '''Draw a cubic Bezier curve.''' 681 return self.append('C', cx1, cy1, cx2, cy2, ex, ey) 682 def c(self, cx1, cy1, cx2, cy2, ex, ey): 683 '''Draw a cubic Bezier curve (relative coordinates).''' 684 return self.append('c', cx1, cy1, cx2, cy2, ex, ey) 685 def S(self, cx2, cy2, ex, ey): 686 '''Draw a cubic Bezier curve, transitioning smoothly from the previous. 687 ''' 688 return self.append('S', cx2, cy2, ex, ey) 689 def s(self, cx2, cy2, ex, ey): 690 '''Draw a cubic Bezier curve, transitioning smoothly from the previous 691 (relative coordinates). 692 ''' 693 return self.append('s', cx2, cy2, ex, ey) 694 def Q(self, cx, cy, ex, ey): 695 '''Draw a quadratic Bezier curve.''' 696 return self.append('Q', cx, cy, ex, ey) 697 def q(self, cx, cy, ex, ey): 698 '''Draw a quadratic Bezier curve (relative coordinates).''' 699 return self.append('q', cx, cy, ex, ey) 700 def T(self, ex, ey): 701 '''Draw a quadratic Bezier curve, transitioning soothly from the 702 previous. 703 ''' 704 return self.append('T', ex, ey) 705 def t(self, ex, ey): 706 '''Draw a quadratic Bezier curve, transitioning soothly from the 707 previous (relative coordinates). 708 ''' 709 return self.append('t', ex, ey) 710 def A(self, rx, ry, rot, large_arc, sweep, ex, ey): 711 '''Draw a circular or elliptical arc. 712 713 See 714 https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#elliptical_arc_curve 715 ''' 716 return self.append('A', rx, ry, rot, int(bool(large_arc)), 717 int(bool(sweep)), ex, ey) 718 def a(self, rx, ry, rot, large_arc, sweep, ex, ey): 719 '''Draw a circular or elliptical arc (relative coordinates). 720 721 See 722 https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#elliptical_arc_curve 723 ''' 724 return self.append('a', rx, ry, rot, int(bool(large_arc)), 725 int(bool(sweep)), ex, ey) 726 def arc(self, cx, cy, r, start_deg, end_deg, cw=False, include_m=True, 727 include_l=False): 728 '''Draw a circular arc, controlled by center, radius, and start/end 729 degrees. 730 ''' 731 large_arc = (end_deg - start_deg) % 360 > 180 732 start_rad, end_rad = start_deg*math.pi/180, end_deg*math.pi/180 733 sx, sy = r*math.cos(start_rad), -r*math.sin(start_rad) 734 ex, ey = r*math.cos(end_rad), -r*math.sin(end_rad) 735 if include_l: 736 self.L(cx+sx, cy+sy) 737 elif include_m: 738 self.M(cx+sx, cy+sy) 739 return self.A(r, r, 0, large_arc ^ cw, cw, cx+ex, cy+ey) 740 741class Lines(Path): 742 '''A sequence of connected lines (or a polygon). 743 744 Additional keyword arguments are output as additional arguments to the SVG 745 node e.g. fill="red", stroke="#ff4477", stroke_width=2. 746 ''' 747 def __init__(self, sx, sy, *points, close=False, **kwargs): 748 super().__init__(d='', **kwargs) 749 self.M(sx, sy) 750 assert len(points) % 2 == 0 751 for i in range(len(points) // 2): 752 self.L(points[2*i], points[2*i+1]) 753 if close: 754 self.Z() 755 756class Line(Lines): 757 '''A simple line. 758 759 Additional keyword arguments are output as additional arguments to the SVG 760 node e.g. fill="red", stroke="#ff4477", stroke_width=2. 761 ''' 762 def __init__(self, sx, sy, ex, ey, **kwargs): 763 super().__init__(sx, sy, ex, ey, close=False, **kwargs) 764 765class Arc(Path): 766 '''A circular arc. 767 768 Additional keyword arguments are output as additional arguments to the SVG 769 node e.g. fill="red", stroke="#ff4477", stroke_width=2. 770 ''' 771 def __init__(self, cx, cy, r, start_deg, end_deg, cw=False, **kwargs): 772 super().__init__(d='', **kwargs) 773 self.arc(cx, cy, r, start_deg, end_deg, cw=cw, include_m=True)