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.TAG_NAME == other.TAG_NAME 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 113class NoElement(DrawingElement): 114 ''' A drawing element that has no effect ''' 115 def __init__(self): pass 116 def writeSvgElement(self, outputFile): 117 pass 118 def __eq__(self, other): 119 if isinstance(other, type(self)): 120 return True 121 return False 122 123class Group(DrawingParentElement): 124 ''' A group of drawing elements 125 126 Any transform will apply to its children and other attributes will be 127 inherited by its children. ''' 128 TAG_NAME = 'g' 129 130class Use(DrawingBasicElement): 131 ''' A copy of another element 132 133 Specify the other element by its id: href='#otherElemId'. ''' 134 TAG_NAME = 'use' 135 def __init__(self, otherElem, x, y, **kwargs): 136 y = -y 137 if isinstance(otherElem, str): 138 otherElemId = otherElem 139 else: 140 if otherElem.id is None: 141 raise ValueError('otherElem must have an id') 142 otherElemId = otherElem.id 143 href = '#{}'.format(otherElemId) 144 super().__init__(xlink__href=href, x=x, y=y, **kwargs) 145 146class Image(DrawingBasicElement): 147 ''' A linked or embedded raster image ''' 148 TAG_NAME = 'image' 149 MIME_MAP = { 150 '.bm': 'image/bmp', 151 '.bmp': 'image/bmp', 152 '.gif': 'image/gif', 153 '.jpeg':'image/jpeg', 154 '.jpg': 'image/jpeg', 155 '.png': 'image/png', 156 '.tif': 'image/tiff', 157 '.tiff':'image/tiff', 158 '.pdf': 'application/pdf', 159 '.txt': 'text/plain', 160 } 161 MIME_DEFAULT = 'image/png' 162 def __init__(self, x, y, width, height, path=None, data=None, embed=False, 163 mimeType=None, **kwargs): 164 ''' Specify either the path or data argument. If path is used and 165 embed is True, the image file is embedded in a data URI. ''' 166 if path is None and data is None: 167 raise ValueError('Either path or data arguments must be given') 168 if mimeType is None and path is not None: 169 ext = os.path.splitext(path)[1].lower() 170 if ext in self.MIME_MAP: 171 mimeType = self.MIME_MAP[ext] 172 else: 173 mimeType = self.MIME_DEFAULT 174 warnings.warn('Unknown image file type "{}"'.format(ext), Warning) 175 if mimeType is None: 176 mimeType = self.MIME_DEFAULT 177 warnings.warn('Unspecified image type; assuming png'.format(ext), Warning) 178 if data is not None: 179 embed = True 180 if embed and data is None: 181 with open(path, 'rb') as f: 182 data = f.read() 183 if not embed: 184 uri = path 185 else: 186 encData = base64.b64encode(data).decode() 187 uri = 'data:{};base64,{}'.format(mimeType, encData) 188 super().__init__(x=x, y=-y-height, width=width, height=height, 189 xlink__href=uri, **kwargs) 190 191class Text(DrawingBasicElement): 192 ''' Text 193 194 Additional keyword arguments are output as additional arguments to the 195 SVG node e.g. fill="red", font_size=20, text_anchor="middle". ''' 196 TAG_NAME = 'text' 197 hasContent = True 198 def __init__(self, text, fontSize, x, y, center=False, **kwargs): 199 if center: 200 if 'text_anchor' not in kwargs: 201 kwargs['text_anchor'] = 'middle' 202 try: 203 fontSize = float(fontSize) 204 translate = 'translate(0,{})'.format(fontSize*0.5*center) 205 if 'transform' in kwargs: 206 kwargs['transform'] = translate + ' ' + kwargs['transform'] 207 else: 208 kwargs['transform'] = translate 209 except TypeError: 210 pass 211 super().__init__(x=x, y=-y, font_size=fontSize, **kwargs) 212 self.escapedText = xml.escape(text) 213 def writeContent(self, outputFile): 214 outputFile.write(self.escapedText) 215 216class Rectangle(DrawingBasicElement): 217 ''' A rectangle 218 219 Additional keyword arguments are output as additional arguments to the 220 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 221 TAG_NAME = 'rect' 222 def __init__(self, x, y, width, height, **kwargs): 223 super().__init__(x=x, y=-y-height, width=width, height=height, 224 **kwargs) 225 226class Circle(DrawingBasicElement): 227 ''' A circle 228 229 Additional keyword arguments are output as additional properties to the 230 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 231 TAG_NAME = 'circle' 232 def __init__(self, cx, cy, r, **kwargs): 233 super().__init__(cx=cx, cy=-cy, r=r, **kwargs) 234 235class ArcLine(Circle): 236 ''' An arc 237 238 In most cases, use Arc instead of ArcLine. ArcLine uses the 239 stroke-dasharray SVG property to make the edge of a circle look like 240 an arc. 241 242 Additional keyword arguments are output as additional arguments to the 243 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 244 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs): 245 if endDeg - startDeg == 360: 246 super().__init__(cx, cy, r, **kwargs) 247 return 248 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360 249 arcDeg = (endDeg - startDeg) % 360 250 def arcLen(deg): return math.radians(deg) * r 251 wholeLen = 2 * math.pi * r 252 if endDeg == startDeg: 253 offset = 1 254 dashes = "0 {}".format(wholeLen+2) 255 #elif endDeg >= startDeg: 256 elif True: 257 startLen = arcLen(startDeg) 258 arcLen = arcLen(arcDeg) 259 offLen = wholeLen - arcLen 260 offset = -startLen 261 dashes = "{} {}".format(arcLen, offLen) 262 #else: 263 # firstLen = arcLen(endDeg) 264 # secondLen = arcLen(360-startDeg) 265 # gapLen = wholeLen - firstLen - secondLen 266 # offset = 0 267 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen) 268 super().__init__(cx, cy, r, stroke_dasharray=dashes, 269 stroke_dashoffset=offset, **kwargs) 270 271class Path(DrawingBasicElement): 272 ''' An arbitrary path 273 274 Path Supports building an SVG path by calling instance methods 275 corresponding to path commands. 276 277 Additional keyword arguments are output as additional properties to the 278 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 279 TAG_NAME = 'path' 280 def __init__(self, d='', **kwargs): 281 super().__init__(d=d, **kwargs) 282 def append(self, commandStr, *args): 283 if len(self.args['d']) > 0: 284 commandStr = ' ' + commandStr 285 if len(args) > 0: 286 commandStr = commandStr + ','.join(map(str, args)) 287 self.args['d'] += commandStr 288 def M(self, x, y): self.append('M', x, -y) 289 def m(self, dx, dy): self.append('m', dx, -dy) 290 def L(self, x, y): self.append('L', x, -y) 291 def l(self, dx, dy): self.append('l', dx, -dy) 292 def H(self, x, y): self.append('H', x) 293 def h(self, dx): self.append('h', dx) 294 def V(self, y): self.append('V', -y) 295 def v(self, dy): self.append('v', -dy) 296 def Z(self): self.append('Z') 297 def C(self, cx1, cy1, cx2, cy2, ex, ey): 298 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey) 299 def c(self, cx1, cy1, cx2, cy2, ex, ey): 300 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey) 301 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey) 302 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey) 303 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey) 304 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey) 305 def T(self, ex, ey): self.append('T', ex, -ey) 306 def t(self, ex, ey): self.append('t', ex, -ey) 307 def A(self, rx, ry, rot, largeArc, sweep, ex, ey): 308 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey) 309 def a(self, rx, ry, rot, largeArc, sweep, ex, ey): 310 self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey) 311 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, includeL=False): 312 ''' Uses A() to draw a circular arc ''' 313 largeArc = (endDeg - startDeg) % 360 > 180 314 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180 315 sx, sy = r*math.cos(startRad), r*math.sin(startRad) 316 ex, ey = r*math.cos(endRad), r*math.sin(endRad) 317 if includeL: 318 self.L(cx+sx, cy+sy) 319 elif includeM: 320 self.M(cx+sx, cy+sy) 321 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey) 322 323class Lines(Path): 324 ''' A sequence of connected lines (or a polygon) 325 326 Additional keyword arguments are output as additional properties to the 327 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 328 def __init__(self, sx, sy, *points, close=False, **kwargs): 329 super().__init__(d='', **kwargs) 330 self.M(sx, sy) 331 assert len(points) % 2 == 0 332 for i in range(len(points) // 2): 333 self.L(points[2*i], points[2*i+1]) 334 if close: 335 self.Z() 336 337class Line(Lines): 338 ''' A line 339 340 Additional keyword arguments are output as additional properties to the 341 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 342 def __init__(self, sx, sy, ex, ey, **kwargs): 343 super().__init__(sx, sy, ex, ey, close=False, **kwargs) 344 345class Arc(Path): 346 ''' An arc 347 348 Additional keyword arguments are output as additional properties to the 349 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 350 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs): 351 super().__init__(d='', **kwargs) 352 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True) 353