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