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