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, lineHeight=1,
383 **kwargs):
384 singleLine = isinstance(text, str)
385 if not singleLine:
386 text = tuple(text)
387 numLines = len(text)
388 centerOffset = 0
389 emOffset = 0
390 if center:
391 if 'text_anchor' not in kwargs:
392 kwargs['text_anchor'] = 'middle'
393 if singleLine:
394 centerOffset = fontSize*0.5*center
395 else:
396 emOffset = 0.4 - lineHeight * (numLines - 1) / 2
397 if centerOffset:
398 try:
399 fontSize = float(fontSize)
400 translate = 'translate(0,{})'.format(centerOffset)
401 if 'transform' in kwargs:
402 kwargs['transform'] = translate + ' ' + kwargs['transform']
403 else:
404 kwargs['transform'] = translate
405 except TypeError:
406 pass
407 super().__init__(x=x, y=-y, font_size=fontSize, **kwargs)
408 if singleLine:
409 self.escapedText = xml.escape(text)
410 else:
411 self.escapedText = ''
412 # Text is an iterable
413 for i, line in enumerate(text):
414 dy = '{}em'.format(emOffset if i == 0 else lineHeight)
415 self.appendLine(line, x=x, dy=dy)
416 def writeContent(self, idGen, isDuplicate, outputFile, dryRun):
417 if dryRun:
418 return
419 outputFile.write(self.escapedText)
420 def writeChildrenContent(self, idGen, isDuplicate, outputFile, dryRun):
421 ''' Override in a subclass to add data between the start and end
422 tags. This will not be called if hasContent is False. '''
423 children = self.allChildren()
424 if dryRun:
425 for child in children:
426 child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun)
427 return
428 for child in children:
429 child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun)
430 def appendLine(self, line, **kwargs):
431 self.append(TSpan(line, **kwargs))
432
433class TSpan(DrawingBasicElement):
434 ''' A line of text within the Text element. '''
435 TAG_NAME = 'tspan'
436 hasContent = True
437 def __init__(self, text, **kwargs):
438 super().__init__(**kwargs)
439 self.escapedText = xml.escape(text)
440 def writeContent(self, idGen, isDuplicate, outputFile, dryRun):
441 if dryRun:
442 return
443 outputFile.write(self.escapedText)
444
445class Rectangle(DrawingBasicElement):
446 ''' A rectangle
447
448 Additional keyword arguments are output as additional arguments to the
449 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
450 TAG_NAME = 'rect'
451 def __init__(self, x, y, width, height, **kwargs):
452 try:
453 y = -y-height
454 except TypeError:
455 pass
456 super().__init__(x=x, y=y, width=width, height=height,
457 **kwargs)
458
459class Circle(DrawingBasicElement):
460 ''' A circle
461
462 Additional keyword arguments are output as additional properties to the
463 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
464 TAG_NAME = 'circle'
465 def __init__(self, cx, cy, r, **kwargs):
466 try:
467 cy = -cy
468 except TypeError:
469 pass
470 super().__init__(cx=cx, cy=cy, r=r, **kwargs)
471
472class Ellipse(DrawingBasicElement):
473 ''' An ellipse
474
475 Additional keyword arguments are output as additional properties to the
476 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
477 TAG_NAME = 'ellipse'
478 def __init__(self, cx, cy, rx, ry, **kwargs):
479 try:
480 cy = -cy
481 except TypeError:
482 pass
483 super().__init__(cx=cx, cy=cy, rx=rx, ry=ry, **kwargs)
484
485class ArcLine(Circle):
486 ''' An arc
487
488 In most cases, use Arc instead of ArcLine. ArcLine uses the
489 stroke-dasharray SVG property to make the edge of a circle look like
490 an arc.
491
492 Additional keyword arguments are output as additional arguments to the
493 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
494 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs):
495 if endDeg - startDeg == 360:
496 super().__init__(cx, cy, r, **kwargs)
497 return
498 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360
499 arcDeg = (endDeg - startDeg) % 360
500 def arcLen(deg): return math.radians(deg) * r
501 wholeLen = 2 * math.pi * r
502 if endDeg == startDeg:
503 offset = 1
504 dashes = "0 {}".format(wholeLen+2)
505 #elif endDeg >= startDeg:
506 elif True:
507 startLen = arcLen(startDeg)
508 arcLen = arcLen(arcDeg)
509 offLen = wholeLen - arcLen
510 offset = -startLen
511 dashes = "{} {}".format(arcLen, offLen)
512 #else:
513 # firstLen = arcLen(endDeg)
514 # secondLen = arcLen(360-startDeg)
515 # gapLen = wholeLen - firstLen - secondLen
516 # offset = 0
517 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen)
518 super().__init__(cx, cy, r, stroke_dasharray=dashes,
519 stroke_dashoffset=offset, **kwargs)
520
521class Path(DrawingBasicElement):
522 ''' An arbitrary path
523
524 Path Supports building an SVG path by calling instance methods
525 corresponding to path commands.
526
527 Additional keyword arguments are output as additional properties to the
528 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
529 TAG_NAME = 'path'
530 def __init__(self, d='', **kwargs):
531 super().__init__(d=d, **kwargs)
532 def append(self, commandStr, *args):
533 if len(self.args['d']) > 0:
534 commandStr = ' ' + commandStr
535 if len(args) > 0:
536 commandStr = commandStr + ','.join(map(str, args))
537 self.args['d'] += commandStr
538 def M(self, x, y): self.append('M', x, -y)
539 def m(self, dx, dy): self.append('m', dx, -dy)
540 def L(self, x, y): self.append('L', x, -y)
541 def l(self, dx, dy): self.append('l', dx, -dy)
542 def H(self, x): self.append('H', x)
543 def h(self, dx): self.append('h', dx)
544 def V(self, y): self.append('V', -y)
545 def v(self, dy): self.append('v', -dy)
546 def Z(self): self.append('Z')
547 def C(self, cx1, cy1, cx2, cy2, ex, ey):
548 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey)
549 def c(self, cx1, cy1, cx2, cy2, ex, ey):
550 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey)
551 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey)
552 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey)
553 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey)
554 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey)
555 def T(self, ex, ey): self.append('T', ex, -ey)
556 def t(self, ex, ey): self.append('t', ex, -ey)
557 def A(self, rx, ry, rot, largeArc, sweep, ex, ey):
558 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex,
559 -ey)
560 def a(self, rx, ry, rot, largeArc, sweep, ex, ey):
561 self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex,
562 -ey)
563 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True,
564 includeL=False):
565 ''' Uses A() to draw a circular arc '''
566 largeArc = (endDeg - startDeg) % 360 > 180
567 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180
568 sx, sy = r*math.cos(startRad), r*math.sin(startRad)
569 ex, ey = r*math.cos(endRad), r*math.sin(endRad)
570 if includeL:
571 self.L(cx+sx, cy+sy)
572 elif includeM:
573 self.M(cx+sx, cy+sy)
574 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey)
575
576class Lines(Path):
577 ''' A sequence of connected lines (or a polygon)
578
579 Additional keyword arguments are output as additional properties to the
580 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
581 def __init__(self, sx, sy, *points, close=False, **kwargs):
582 super().__init__(d='', **kwargs)
583 self.M(sx, sy)
584 assert len(points) % 2 == 0
585 for i in range(len(points) // 2):
586 self.L(points[2*i], points[2*i+1])
587 if close:
588 self.Z()
589
590class Line(Lines):
591 ''' A line
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, ex, ey, **kwargs):
596 super().__init__(sx, sy, ex, ey, close=False, **kwargs)
597
598class Arc(Path):
599 ''' An arc
600
601 Additional keyword arguments are output as additional properties to the
602 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
603 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs):
604 super().__init__(d='', **kwargs)
605 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True)
606