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