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