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 super().__init__(x=x, y=-y-height, width=width, height=height,
374 **kwargs)
375
376class Circle(DrawingBasicElement):
377 ''' A circle
378
379 Additional keyword arguments are output as additional properties to the
380 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
381 TAG_NAME = 'circle'
382 def __init__(self, cx, cy, r, **kwargs):
383 super().__init__(cx=cx, cy=-cy, r=r, **kwargs)
384
385class Ellipse(DrawingBasicElement):
386 ''' An ellipse
387
388 Additional keyword arguments are output as additional properties to the
389 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
390 TAG_NAME = 'ellipse'
391 def __init__(self, cx, cy, rx, ry, **kwargs):
392 super().__init__(cx=cx, cy=-cy, rx=rx, ry=ry, **kwargs)
393
394class ArcLine(Circle):
395 ''' An arc
396
397 In most cases, use Arc instead of ArcLine. ArcLine uses the
398 stroke-dasharray SVG property to make the edge of a circle look like
399 an arc.
400
401 Additional keyword arguments are output as additional arguments to the
402 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
403 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs):
404 if endDeg - startDeg == 360:
405 super().__init__(cx, cy, r, **kwargs)
406 return
407 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360
408 arcDeg = (endDeg - startDeg) % 360
409 def arcLen(deg): return math.radians(deg) * r
410 wholeLen = 2 * math.pi * r
411 if endDeg == startDeg:
412 offset = 1
413 dashes = "0 {}".format(wholeLen+2)
414 #elif endDeg >= startDeg:
415 elif True:
416 startLen = arcLen(startDeg)
417 arcLen = arcLen(arcDeg)
418 offLen = wholeLen - arcLen
419 offset = -startLen
420 dashes = "{} {}".format(arcLen, offLen)
421 #else:
422 # firstLen = arcLen(endDeg)
423 # secondLen = arcLen(360-startDeg)
424 # gapLen = wholeLen - firstLen - secondLen
425 # offset = 0
426 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen)
427 super().__init__(cx, cy, r, stroke_dasharray=dashes,
428 stroke_dashoffset=offset, **kwargs)
429
430class Path(DrawingBasicElement):
431 ''' An arbitrary path
432
433 Path Supports building an SVG path by calling instance methods
434 corresponding to path commands.
435
436 Additional keyword arguments are output as additional properties to the
437 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
438 TAG_NAME = 'path'
439 def __init__(self, d='', **kwargs):
440 super().__init__(d=d, **kwargs)
441 def append(self, commandStr, *args):
442 if len(self.args['d']) > 0:
443 commandStr = ' ' + commandStr
444 if len(args) > 0:
445 commandStr = commandStr + ','.join(map(str, args))
446 self.args['d'] += commandStr
447 def M(self, x, y): self.append('M', x, -y)
448 def m(self, dx, dy): self.append('m', dx, -dy)
449 def L(self, x, y): self.append('L', x, -y)
450 def l(self, dx, dy): self.append('l', dx, -dy)
451 def H(self, x): self.append('H', x)
452 def h(self, dx): self.append('h', dx)
453 def V(self, y): self.append('V', -y)
454 def v(self, dy): self.append('v', -dy)
455 def Z(self): self.append('Z')
456 def C(self, cx1, cy1, cx2, cy2, ex, ey):
457 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey)
458 def c(self, cx1, cy1, cx2, cy2, ex, ey):
459 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey)
460 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey)
461 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey)
462 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey)
463 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey)
464 def T(self, ex, ey): self.append('T', ex, -ey)
465 def t(self, ex, ey): self.append('t', ex, -ey)
466 def A(self, rx, ry, rot, largeArc, sweep, ex, ey):
467 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex,
468 -ey)
469 def a(self, rx, ry, rot, largeArc, sweep, ex, ey):
470 self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex,
471 -ey)
472 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True,
473 includeL=False):
474 ''' Uses A() to draw a circular arc '''
475 largeArc = (endDeg - startDeg) % 360 > 180
476 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180
477 sx, sy = r*math.cos(startRad), r*math.sin(startRad)
478 ex, ey = r*math.cos(endRad), r*math.sin(endRad)
479 if includeL:
480 self.L(cx+sx, cy+sy)
481 elif includeM:
482 self.M(cx+sx, cy+sy)
483 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey)
484
485class Lines(Path):
486 ''' A sequence of connected lines (or a polygon)
487
488 Additional keyword arguments are output as additional properties to the
489 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
490 def __init__(self, sx, sy, *points, close=False, **kwargs):
491 super().__init__(d='', **kwargs)
492 self.M(sx, sy)
493 assert len(points) % 2 == 0
494 for i in range(len(points) // 2):
495 self.L(points[2*i], points[2*i+1])
496 if close:
497 self.Z()
498
499class Line(Lines):
500 ''' A line
501
502 Additional keyword arguments are output as additional properties to the
503 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
504 def __init__(self, sx, sy, ex, ey, **kwargs):
505 super().__init__(sx, sy, ex, ey, close=False, **kwargs)
506
507class Arc(Path):
508 ''' An arc
509
510 Additional keyword arguments are output as additional properties to the
511 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
512 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs):
513 super().__init__(d='', **kwargs)
514 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True)
515