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