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