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.TAG_NAME == other.TAG_NAME 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
113class NoElement(DrawingElement):
114 ''' A drawing element that has no effect '''
115 def __init__(self): pass
116 def writeSvgElement(self, outputFile):
117 pass
118 def __eq__(self, other):
119 if isinstance(other, type(self)):
120 return True
121 return False
122
123class Group(DrawingParentElement):
124 ''' A group of drawing elements
125
126 Any transform will apply to its children and other attributes will be
127 inherited by its children. '''
128 TAG_NAME = 'g'
129
130class Use(DrawingBasicElement):
131 ''' A copy of another element
132
133 Specify the other element by its id: href='#otherElemId'. '''
134 TAG_NAME = 'use'
135 def __init__(self, otherElem, x, y, **kwargs):
136 y = -y
137 if isinstance(otherElem, str):
138 otherElemId = otherElem
139 else:
140 if otherElem.id is None:
141 raise ValueError('otherElem must have an id')
142 otherElemId = otherElem.id
143 href = '#{}'.format(otherElemId)
144 super().__init__(xlink__href=href, x=x, y=y, **kwargs)
145
146class Image(DrawingBasicElement):
147 ''' A linked or embedded raster image '''
148 TAG_NAME = 'image'
149 MIME_MAP = {
150 '.bm': 'image/bmp',
151 '.bmp': 'image/bmp',
152 '.gif': 'image/gif',
153 '.jpeg':'image/jpeg',
154 '.jpg': 'image/jpeg',
155 '.png': 'image/png',
156 '.tif': 'image/tiff',
157 '.tiff':'image/tiff',
158 '.pdf': 'application/pdf',
159 '.txt': 'text/plain',
160 }
161 MIME_DEFAULT = 'image/png'
162 def __init__(self, x, y, width, height, path=None, data=None, embed=False,
163 mimeType=None, **kwargs):
164 ''' Specify either the path or data argument. If path is used and
165 embed is True, the image file is embedded in a data URI. '''
166 if path is None and data is None:
167 raise ValueError('Either path or data arguments must be given')
168 if mimeType is None and path is not None:
169 ext = os.path.splitext(path)[1].lower()
170 if ext in self.MIME_MAP:
171 mimeType = self.MIME_MAP[ext]
172 else:
173 mimeType = self.MIME_DEFAULT
174 warnings.warn('Unknown image file type "{}"'.format(ext), Warning)
175 if mimeType is None:
176 mimeType = self.MIME_DEFAULT
177 warnings.warn('Unspecified image type; assuming png'.format(ext), Warning)
178 if data is not None:
179 embed = True
180 if embed and data is None:
181 with open(path, 'rb') as f:
182 data = f.read()
183 if not embed:
184 uri = path
185 else:
186 encData = base64.b64encode(data).decode()
187 uri = 'data:{};base64,{}'.format(mimeType, encData)
188 super().__init__(x=x, y=-y-height, width=width, height=height,
189 xlink__href=uri, **kwargs)
190
191class Text(DrawingBasicElement):
192 ''' Text
193
194 Additional keyword arguments are output as additional arguments to the
195 SVG node e.g. fill="red", font_size=20, text_anchor="middle". '''
196 TAG_NAME = 'text'
197 hasContent = True
198 def __init__(self, text, fontSize, x, y, center=False, **kwargs):
199 if center:
200 if 'text_anchor' not in kwargs:
201 kwargs['text_anchor'] = 'middle'
202 try:
203 fontSize = float(fontSize)
204 translate = 'translate(0,{})'.format(fontSize*0.5*center)
205 if 'transform' in kwargs:
206 kwargs['transform'] = translate + ' ' + kwargs['transform']
207 else:
208 kwargs['transform'] = translate
209 except TypeError:
210 pass
211 super().__init__(x=x, y=-y, font_size=fontSize, **kwargs)
212 self.escapedText = xml.escape(text)
213 def writeContent(self, outputFile):
214 outputFile.write(self.escapedText)
215
216class Rectangle(DrawingBasicElement):
217 ''' A rectangle
218
219 Additional keyword arguments are output as additional arguments to the
220 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
221 TAG_NAME = 'rect'
222 def __init__(self, x, y, width, height, **kwargs):
223 super().__init__(x=x, y=-y-height, width=width, height=height,
224 **kwargs)
225
226class Circle(DrawingBasicElement):
227 ''' A circle
228
229 Additional keyword arguments are output as additional properties to the
230 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
231 TAG_NAME = 'circle'
232 def __init__(self, cx, cy, r, **kwargs):
233 super().__init__(cx=cx, cy=-cy, r=r, **kwargs)
234
235class ArcLine(Circle):
236 ''' An arc
237
238 In most cases, use Arc instead of ArcLine. ArcLine uses the
239 stroke-dasharray SVG property to make the edge of a circle look like
240 an arc.
241
242 Additional keyword arguments are output as additional arguments to the
243 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
244 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs):
245 if endDeg - startDeg == 360:
246 super().__init__(cx, cy, r, **kwargs)
247 return
248 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360
249 arcDeg = (endDeg - startDeg) % 360
250 def arcLen(deg): return math.radians(deg) * r
251 wholeLen = 2 * math.pi * r
252 if endDeg == startDeg:
253 offset = 1
254 dashes = "0 {}".format(wholeLen+2)
255 #elif endDeg >= startDeg:
256 elif True:
257 startLen = arcLen(startDeg)
258 arcLen = arcLen(arcDeg)
259 offLen = wholeLen - arcLen
260 offset = -startLen
261 dashes = "{} {}".format(arcLen, offLen)
262 #else:
263 # firstLen = arcLen(endDeg)
264 # secondLen = arcLen(360-startDeg)
265 # gapLen = wholeLen - firstLen - secondLen
266 # offset = 0
267 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen)
268 super().__init__(cx, cy, r, stroke_dasharray=dashes,
269 stroke_dashoffset=offset, **kwargs)
270
271class Path(DrawingBasicElement):
272 ''' An arbitrary path
273
274 Path Supports building an SVG path by calling instance methods
275 corresponding to path commands.
276
277 Additional keyword arguments are output as additional properties to the
278 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
279 TAG_NAME = 'path'
280 def __init__(self, d='', **kwargs):
281 super().__init__(d=d, **kwargs)
282 def append(self, commandStr, *args):
283 if len(self.args['d']) > 0:
284 commandStr = ' ' + commandStr
285 if len(args) > 0:
286 commandStr = commandStr + ','.join(map(str, args))
287 self.args['d'] += commandStr
288 def M(self, x, y): self.append('M', x, -y)
289 def m(self, dx, dy): self.append('m', dx, -dy)
290 def L(self, x, y): self.append('L', x, -y)
291 def l(self, dx, dy): self.append('l', dx, -dy)
292 def H(self, x, y): self.append('H', x)
293 def h(self, dx): self.append('h', dx)
294 def V(self, y): self.append('V', -y)
295 def v(self, dy): self.append('v', -dy)
296 def Z(self): self.append('Z')
297 def C(self, cx1, cy1, cx2, cy2, ex, ey):
298 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey)
299 def c(self, cx1, cy1, cx2, cy2, ex, ey):
300 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey)
301 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey)
302 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey)
303 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey)
304 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey)
305 def T(self, ex, ey): self.append('T', ex, -ey)
306 def t(self, ex, ey): self.append('t', ex, -ey)
307 def A(self, rx, ry, rot, largeArc, sweep, ex, ey):
308 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), 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 arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, includeL=False):
312 ''' Uses A() to draw a circular arc '''
313 largeArc = (endDeg - startDeg) % 360 > 180
314 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180
315 sx, sy = r*math.cos(startRad), r*math.sin(startRad)
316 ex, ey = r*math.cos(endRad), r*math.sin(endRad)
317 if includeL:
318 self.L(cx+sx, cy+sy)
319 elif includeM:
320 self.M(cx+sx, cy+sy)
321 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey)
322
323class Lines(Path):
324 ''' A sequence of connected lines (or a polygon)
325
326 Additional keyword arguments are output as additional properties to the
327 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
328 def __init__(self, sx, sy, *points, close=False, **kwargs):
329 super().__init__(d='', **kwargs)
330 self.M(sx, sy)
331 assert len(points) % 2 == 0
332 for i in range(len(points) // 2):
333 self.L(points[2*i], points[2*i+1])
334 if close:
335 self.Z()
336
337class Line(Lines):
338 ''' A line
339
340 Additional keyword arguments are output as additional properties to the
341 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
342 def __init__(self, sx, sy, ex, ey, **kwargs):
343 super().__init__(sx, sy, ex, ey, close=False, **kwargs)
344
345class Arc(Path):
346 ''' An arc
347
348 Additional keyword arguments are output as additional properties to the
349 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
350 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs):
351 super().__init__(d='', **kwargs)
352 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True)
353