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