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