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