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