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 ArcLine(Circle):
239 ''' An arc
240
241 In most cases, use Arc instead of ArcLine. ArcLine uses the
242 stroke-dasharray SVG property to make the edge of a circle look like
243 an arc.
244
245 Additional keyword arguments are output as additional arguments to the
246 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
247 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs):
248 if endDeg - startDeg == 360:
249 super().__init__(cx, cy, r, **kwargs)
250 return
251 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360
252 arcDeg = (endDeg - startDeg) % 360
253 def arcLen(deg): return math.radians(deg) * r
254 wholeLen = 2 * math.pi * r
255 if endDeg == startDeg:
256 offset = 1
257 dashes = "0 {}".format(wholeLen+2)
258 #elif endDeg >= startDeg:
259 elif True:
260 startLen = arcLen(startDeg)
261 arcLen = arcLen(arcDeg)
262 offLen = wholeLen - arcLen
263 offset = -startLen
264 dashes = "{} {}".format(arcLen, offLen)
265 #else:
266 # firstLen = arcLen(endDeg)
267 # secondLen = arcLen(360-startDeg)
268 # gapLen = wholeLen - firstLen - secondLen
269 # offset = 0
270 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen)
271 super().__init__(cx, cy, r, stroke_dasharray=dashes,
272 stroke_dashoffset=offset, **kwargs)
273
274class Path(DrawingBasicElement):
275 ''' An arbitrary path
276
277 Path Supports building an SVG path by calling instance methods
278 corresponding to path commands.
279
280 Additional keyword arguments are output as additional properties to the
281 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
282 TAG_NAME = 'path'
283 def __init__(self, d='', **kwargs):
284 super().__init__(d=d, **kwargs)
285 def append(self, commandStr, *args):
286 if len(self.args['d']) > 0:
287 commandStr = ' ' + commandStr
288 if len(args) > 0:
289 commandStr = commandStr + ','.join(map(str, args))
290 self.args['d'] += commandStr
291 def M(self, x, y): self.append('M', x, -y)
292 def m(self, dx, dy): self.append('m', dx, -dy)
293 def L(self, x, y): self.append('L', x, -y)
294 def l(self, dx, dy): self.append('l', dx, -dy)
295 def H(self, x): self.append('H', x)
296 def h(self, dx): self.append('h', dx)
297 def V(self, y): self.append('V', -y)
298 def v(self, dy): self.append('v', -dy)
299 def Z(self): self.append('Z')
300 def C(self, cx1, cy1, cx2, cy2, ex, ey):
301 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey)
302 def c(self, cx1, cy1, cx2, cy2, ex, ey):
303 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey)
304 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey)
305 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey)
306 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey)
307 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey)
308 def T(self, ex, ey): self.append('T', ex, -ey)
309 def t(self, ex, ey): self.append('t', ex, -ey)
310 def A(self, rx, ry, rot, largeArc, sweep, ex, ey):
311 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey)
312 def a(self, rx, ry, rot, largeArc, sweep, ex, ey):
313 self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey)
314 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, includeL=False):
315 ''' Uses A() to draw a circular arc '''
316 largeArc = (endDeg - startDeg) % 360 > 180
317 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180
318 sx, sy = r*math.cos(startRad), r*math.sin(startRad)
319 ex, ey = r*math.cos(endRad), r*math.sin(endRad)
320 if includeL:
321 self.L(cx+sx, cy+sy)
322 elif includeM:
323 self.M(cx+sx, cy+sy)
324 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey)
325
326class Lines(Path):
327 ''' A sequence of connected lines (or a polygon)
328
329 Additional keyword arguments are output as additional properties to the
330 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
331 def __init__(self, sx, sy, *points, close=False, **kwargs):
332 super().__init__(d='', **kwargs)
333 self.M(sx, sy)
334 assert len(points) % 2 == 0
335 for i in range(len(points) // 2):
336 self.L(points[2*i], points[2*i+1])
337 if close:
338 self.Z()
339
340class Line(Lines):
341 ''' A line
342
343 Additional keyword arguments are output as additional properties to the
344 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
345 def __init__(self, sx, sy, ex, ey, **kwargs):
346 super().__init__(sx, sy, ex, ey, close=False, **kwargs)
347
348class Arc(Path):
349 ''' An arc
350
351 Additional keyword arguments are output as additional properties to the
352 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
353 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs):
354 super().__init__(d='', **kwargs)
355 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True)
356