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)