Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets
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