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