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 k = k.replace('__', ':')
19 k = k.replace('_', '-')
20 if k[-1]=='-':
21 k = k[:-1]
22 if isinstance(v, defs.DrawingElement):
23 if k == 'xlink:href':
24 v = '#{}'.format(v.id)
25 else:
26 v = 'url(#{})'.format(v.id)
27 outputFile.write(' {}="{}"'.format(k,v))
28
29
30class DrawingElement:
31 ''' Base class for drawing elements
32
33 Subclasses must implement writeSvgElement '''
34 def writeSvgElement(self, outputFile):
35 raise NotImplementedError('Abstract base class')
36 def getSvgDefs(self):
37 return ()
38 def writeSvgDefs(self, idGen, isDuplicate, outputFile):
39 for defn in self.getSvgDefs():
40 if isDuplicate(defn): continue
41 defn.writeSvgDefs(idGen, isDuplicate, outputFile)
42 defn.id = idGen()
43 defn.writeSvgElement(outputFile)
44 outputFile.write('\n')
45 def __eq__(self, other):
46 return self is other
47
48class DrawingBasicElement(DrawingElement):
49 ''' Base class for SVG drawing elements that are a single node with no
50 child nodes '''
51 TAG_NAME = '_'
52 hasContent = False
53 def __init__(self, **args):
54 self.args = args
55 @property
56 def id(self):
57 return self.args.get('id', None)
58 @id.setter
59 def id(self, newId):
60 self.args['id'] = newId
61 def writeSvgElement(self, outputFile):
62 outputFile.write('<')
63 outputFile.write(self.TAG_NAME)
64 writeXmlNodeArgs(self.args, outputFile)
65 if not self.hasContent:
66 outputFile.write(' />')
67 else:
68 outputFile.write('>')
69 self.writeContent(outputFile)
70 outputFile.write('</')
71 outputFile.write(self.TAG_NAME)
72 outputFile.write('>')
73 def writeContent(self, outputFile):
74 ''' Override in a subclass to add data between the start and end
75 tags. This will not be called if hasContent is False. '''
76 raise RuntimeError('This element has no content')
77 def getSvgDefs(self):
78 return [v for v in self.args.values()
79 if isinstance(v, defs.DrawingElement)]
80 def __eq__(self, other):
81 if isinstance(other, type(self)):
82 return (self.TAG_NAME == other.TAG_NAME and
83 self.args == other.args)
84 return False
85
86class DrawingParentElement(DrawingBasicElement):
87 ''' Base class for SVG elements that can have child nodes '''
88 hasContent = True
89 def __init__(self, children=(), **args):
90 super().__init__(**args)
91 self.children = list(children)
92 if len(self.children) > 0:
93 self.checkChildrenAllowed()
94 def checkChildrenAllowed(self):
95 if not self.hasContent:
96 raise RuntimeError('{} does not support children'.format(type(self)))
97 def draw(self, obj, **kwargs):
98 if not hasattr(obj, 'writeSvgElement'):
99 elements = obj.toDrawables(elements=elementsModule, **kwargs)
100 self.extend(elements)
101 else:
102 assert len(kwargs) == 0
103 self.append(obj)
104 def append(self, element):
105 self.checkChildrenAllowed()
106 self.children.append(element)
107 def extend(self, iterable):
108 self.checkChildrenAllowed()
109 self.children.extend(iterable)
110 def writeContent(self, outputFile):
111 outputFile.write('\n')
112 for child in self.children:
113 child.writeSvgElement(outputFile)
114 outputFile.write('\n')
115 def writeSvgDefs(self, idGen, isDuplicate, outputFile):
116 super().writeSvgDefs(idGen, isDuplicate, outputFile)
117 for child in self.children:
118 child.writeSvgDefs(idGen, isDuplicate, outputFile)
119
120class NoElement(DrawingElement):
121 ''' A drawing element that has no effect '''
122 def __init__(self): pass
123 def writeSvgElement(self, outputFile):
124 pass
125 def __eq__(self, other):
126 if isinstance(other, type(self)):
127 return True
128 return False
129
130class Group(DrawingParentElement):
131 ''' A group of drawing elements
132
133 Any transform will apply to its children and other attributes will be
134 inherited by its children. '''
135 TAG_NAME = 'g'
136
137class Use(DrawingBasicElement):
138 ''' A copy of another element
139
140 The other element becomes an SVG def shared between all Use elements
141 that reference it. '''
142 TAG_NAME = 'use'
143 def __init__(self, otherElem, x, y, **kwargs):
144 y = -y
145 if isinstance(otherElem, str) and not otherElem.startswith('#'):
146 otherElem = '#' + otherElem
147 super().__init__(xlink__href=otherElem, x=x, y=y, **kwargs)
148
149class Image(DrawingBasicElement):
150 ''' A linked or embedded raster image '''
151 TAG_NAME = 'image'
152 MIME_MAP = {
153 '.bm': 'image/bmp',
154 '.bmp': 'image/bmp',
155 '.gif': 'image/gif',
156 '.jpeg':'image/jpeg',
157 '.jpg': 'image/jpeg',
158 '.png': 'image/png',
159 '.tif': 'image/tiff',
160 '.tiff':'image/tiff',
161 '.pdf': 'application/pdf',
162 '.txt': 'text/plain',
163 }
164 MIME_DEFAULT = 'image/png'
165 def __init__(self, x, y, width, height, path=None, data=None, embed=False,
166 mimeType=None, **kwargs):
167 ''' Specify either the path or data argument. If path is used and
168 embed is True, the image file is embedded in a data URI. '''
169 if path is None and data is None:
170 raise ValueError('Either path or data arguments must be given')
171 if mimeType is None and path is not None:
172 ext = os.path.splitext(path)[1].lower()
173 if ext in self.MIME_MAP:
174 mimeType = self.MIME_MAP[ext]
175 else:
176 mimeType = self.MIME_DEFAULT
177 warnings.warn('Unknown image file type "{}"'.format(ext), Warning)
178 if mimeType is None:
179 mimeType = self.MIME_DEFAULT
180 warnings.warn('Unspecified image type; assuming png'.format(ext), Warning)
181 if data is not None:
182 embed = True
183 if embed and data is None:
184 with open(path, 'rb') as f:
185 data = f.read()
186 if not embed:
187 uri = path
188 else:
189 encData = base64.b64encode(data).decode()
190 uri = 'data:{};base64,{}'.format(mimeType, encData)
191 super().__init__(x=x, y=-y-height, width=width, height=height,
192 xlink__href=uri, **kwargs)
193
194class Text(DrawingBasicElement):
195 ''' Text
196
197 Additional keyword arguments are output as additional arguments to the
198 SVG node e.g. fill="red", font_size=20, text_anchor="middle". '''
199 TAG_NAME = 'text'
200 hasContent = True
201 def __init__(self, text, fontSize, x, y, center=False, **kwargs):
202 if center:
203 if 'text_anchor' not in kwargs:
204 kwargs['text_anchor'] = 'middle'
205 try:
206 fontSize = float(fontSize)
207 translate = 'translate(0,{})'.format(fontSize*0.5*center)
208 if 'transform' in kwargs:
209 kwargs['transform'] = translate + ' ' + kwargs['transform']
210 else:
211 kwargs['transform'] = translate
212 except TypeError:
213 pass
214 super().__init__(x=x, y=-y, font_size=fontSize, **kwargs)
215 self.escapedText = xml.escape(text)
216 def writeContent(self, outputFile):
217 outputFile.write(self.escapedText)
218
219class Rectangle(DrawingBasicElement):
220 ''' A rectangle
221
222 Additional keyword arguments are output as additional arguments to the
223 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
224 TAG_NAME = 'rect'
225 def __init__(self, x, y, width, height, **kwargs):
226 super().__init__(x=x, y=-y-height, width=width, height=height,
227 **kwargs)
228
229class Circle(DrawingBasicElement):
230 ''' A circle
231
232 Additional keyword arguments are output as additional properties to the
233 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
234 TAG_NAME = 'circle'
235 def __init__(self, cx, cy, r, **kwargs):
236 super().__init__(cx=cx, cy=-cy, r=r, **kwargs)
237
238class Ellipse(DrawingBasicElement):
239 ''' An ellipse
240
241 Additional keyword arguments are output as additional properties to the
242 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
243 TAG_NAME = 'ellipse'
244 def __init__(self, cx, cy, rx, ry, **kwargs):
245 super().__init__(cx=cx, cy=-cy, rx=rx, ry=ry, **kwargs)
246
247class ArcLine(Circle):
248 ''' An arc
249
250 In most cases, use Arc instead of ArcLine. ArcLine uses the
251 stroke-dasharray SVG property to make the edge of a circle look like
252 an arc.
253
254 Additional keyword arguments are output as additional arguments to the
255 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
256 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs):
257 if endDeg - startDeg == 360:
258 super().__init__(cx, cy, r, **kwargs)
259 return
260 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360
261 arcDeg = (endDeg - startDeg) % 360
262 def arcLen(deg): return math.radians(deg) * r
263 wholeLen = 2 * math.pi * r
264 if endDeg == startDeg:
265 offset = 1
266 dashes = "0 {}".format(wholeLen+2)
267 #elif endDeg >= startDeg:
268 elif True:
269 startLen = arcLen(startDeg)
270 arcLen = arcLen(arcDeg)
271 offLen = wholeLen - arcLen
272 offset = -startLen
273 dashes = "{} {}".format(arcLen, offLen)
274 #else:
275 # firstLen = arcLen(endDeg)
276 # secondLen = arcLen(360-startDeg)
277 # gapLen = wholeLen - firstLen - secondLen
278 # offset = 0
279 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen)
280 super().__init__(cx, cy, r, stroke_dasharray=dashes,
281 stroke_dashoffset=offset, **kwargs)
282
283class Path(DrawingBasicElement):
284 ''' An arbitrary path
285
286 Path Supports building an SVG path by calling instance methods
287 corresponding to path commands.
288
289 Additional keyword arguments are output as additional properties to the
290 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
291 TAG_NAME = 'path'
292 def __init__(self, d='', **kwargs):
293 super().__init__(d=d, **kwargs)
294 def append(self, commandStr, *args):
295 if len(self.args['d']) > 0:
296 commandStr = ' ' + commandStr
297 if len(args) > 0:
298 commandStr = commandStr + ','.join(map(str, args))
299 self.args['d'] += commandStr
300 def M(self, x, y): self.append('M', x, -y)
301 def m(self, dx, dy): self.append('m', dx, -dy)
302 def L(self, x, y): self.append('L', x, -y)
303 def l(self, dx, dy): self.append('l', dx, -dy)
304 def H(self, x): self.append('H', x)
305 def h(self, dx): self.append('h', dx)
306 def V(self, y): self.append('V', -y)
307 def v(self, dy): self.append('v', -dy)
308 def Z(self): self.append('Z')
309 def C(self, cx1, cy1, cx2, cy2, ex, ey):
310 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey)
311 def c(self, cx1, cy1, cx2, cy2, ex, ey):
312 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey)
313 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey)
314 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey)
315 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey)
316 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey)
317 def T(self, ex, ey): self.append('T', ex, -ey)
318 def t(self, ex, ey): self.append('t', ex, -ey)
319 def A(self, rx, ry, rot, largeArc, sweep, ex, ey):
320 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey)
321 def a(self, rx, ry, rot, largeArc, sweep, ex, ey):
322 self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey)
323 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, includeL=False):
324 ''' Uses A() to draw a circular arc '''
325 largeArc = (endDeg - startDeg) % 360 > 180
326 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180
327 sx, sy = r*math.cos(startRad), r*math.sin(startRad)
328 ex, ey = r*math.cos(endRad), r*math.sin(endRad)
329 if includeL:
330 self.L(cx+sx, cy+sy)
331 elif includeM:
332 self.M(cx+sx, cy+sy)
333 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey)
334
335class Lines(Path):
336 ''' A sequence of connected lines (or a polygon)
337
338 Additional keyword arguments are output as additional properties to the
339 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
340 def __init__(self, sx, sy, *points, close=False, **kwargs):
341 super().__init__(d='', **kwargs)
342 self.M(sx, sy)
343 assert len(points) % 2 == 0
344 for i in range(len(points) // 2):
345 self.L(points[2*i], points[2*i+1])
346 if close:
347 self.Z()
348
349class Line(Lines):
350 ''' A line
351
352 Additional keyword arguments are output as additional properties to the
353 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
354 def __init__(self, sx, sy, ex, ey, **kwargs):
355 super().__init__(sx, sy, ex, ey, close=False, **kwargs)
356
357class Arc(Path):
358 ''' An arc
359
360 Additional keyword arguments are output as additional properties to the
361 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
362 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs):
363 super().__init__(d='', **kwargs)
364 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True)
365