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