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