Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets
at 1.4.1 14 kB view raw
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 Ellipse(DrawingBasicElement): 239 ''' An ellipse 240 241 Additional keyword arguments are output as additional properties to the 242 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 243 TAG_NAME = 'ellipse' 244 def __init__(self, cx, cy, rx, ry, **kwargs): 245 super().__init__(cx=cx, cy=-cy, rx=rx, ry=ry, **kwargs) 246 247class ArcLine(Circle): 248 ''' An arc 249 250 In most cases, use Arc instead of ArcLine. ArcLine uses the 251 stroke-dasharray SVG property to make the edge of a circle look like 252 an arc. 253 254 Additional keyword arguments are output as additional arguments to the 255 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 256 def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs): 257 if endDeg - startDeg == 360: 258 super().__init__(cx, cy, r, **kwargs) 259 return 260 startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360 261 arcDeg = (endDeg - startDeg) % 360 262 def arcLen(deg): return math.radians(deg) * r 263 wholeLen = 2 * math.pi * r 264 if endDeg == startDeg: 265 offset = 1 266 dashes = "0 {}".format(wholeLen+2) 267 #elif endDeg >= startDeg: 268 elif True: 269 startLen = arcLen(startDeg) 270 arcLen = arcLen(arcDeg) 271 offLen = wholeLen - arcLen 272 offset = -startLen 273 dashes = "{} {}".format(arcLen, offLen) 274 #else: 275 # firstLen = arcLen(endDeg) 276 # secondLen = arcLen(360-startDeg) 277 # gapLen = wholeLen - firstLen - secondLen 278 # offset = 0 279 # dashes = "{} {} {}".format(firstLen, gapLen, secondLen) 280 super().__init__(cx, cy, r, stroke_dasharray=dashes, 281 stroke_dashoffset=offset, **kwargs) 282 283class Path(DrawingBasicElement): 284 ''' An arbitrary path 285 286 Path Supports building an SVG path by calling instance methods 287 corresponding to path commands. 288 289 Additional keyword arguments are output as additional properties to the 290 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 291 TAG_NAME = 'path' 292 def __init__(self, d='', **kwargs): 293 super().__init__(d=d, **kwargs) 294 def append(self, commandStr, *args): 295 if len(self.args['d']) > 0: 296 commandStr = ' ' + commandStr 297 if len(args) > 0: 298 commandStr = commandStr + ','.join(map(str, args)) 299 self.args['d'] += commandStr 300 def M(self, x, y): self.append('M', x, -y) 301 def m(self, dx, dy): self.append('m', dx, -dy) 302 def L(self, x, y): self.append('L', x, -y) 303 def l(self, dx, dy): self.append('l', dx, -dy) 304 def H(self, x): self.append('H', x) 305 def h(self, dx): self.append('h', dx) 306 def V(self, y): self.append('V', -y) 307 def v(self, dy): self.append('v', -dy) 308 def Z(self): self.append('Z') 309 def C(self, cx1, cy1, cx2, cy2, ex, ey): 310 self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey) 311 def c(self, cx1, cy1, cx2, cy2, ex, ey): 312 self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey) 313 def S(self, cx2, cy2, ex, ey): self.append('S', cx2, -cy2, ex, -ey) 314 def s(self, cx2, cy2, ex, ey): self.append('s', cx2, -cy2, ex, -ey) 315 def Q(self, cx, cy, ex, ey): self.append('Q', cx, -cy, ex, -ey) 316 def q(self, cx, cy, ex, ey): self.append('q', cx, -cy, ex, -ey) 317 def T(self, ex, ey): self.append('T', ex, -ey) 318 def t(self, ex, ey): self.append('t', ex, -ey) 319 def A(self, rx, ry, rot, largeArc, sweep, ex, ey): 320 self.append('A', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey) 321 def a(self, rx, ry, rot, largeArc, sweep, ex, ey): 322 self.append('a', rx, ry, rot, int(bool(largeArc)), int(bool(sweep)), ex, -ey) 323 def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True, includeL=False): 324 ''' Uses A() to draw a circular arc ''' 325 largeArc = (endDeg - startDeg) % 360 > 180 326 startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180 327 sx, sy = r*math.cos(startRad), r*math.sin(startRad) 328 ex, ey = r*math.cos(endRad), r*math.sin(endRad) 329 if includeL: 330 self.L(cx+sx, cy+sy) 331 elif includeM: 332 self.M(cx+sx, cy+sy) 333 self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey) 334 335class Lines(Path): 336 ''' A sequence of connected lines (or a polygon) 337 338 Additional keyword arguments are output as additional properties to the 339 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 340 def __init__(self, sx, sy, *points, close=False, **kwargs): 341 super().__init__(d='', **kwargs) 342 self.M(sx, sy) 343 assert len(points) % 2 == 0 344 for i in range(len(points) // 2): 345 self.L(points[2*i], points[2*i+1]) 346 if close: 347 self.Z() 348 349class Line(Lines): 350 ''' A line 351 352 Additional keyword arguments are output as additional properties to the 353 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 354 def __init__(self, sx, sy, ex, ey, **kwargs): 355 super().__init__(sx, sy, ex, ey, close=False, **kwargs) 356 357class Arc(Path): 358 ''' An arc 359 360 Additional keyword arguments are output as additional properties to the 361 SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. ''' 362 def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs): 363 super().__init__(d='', **kwargs) 364 self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True) 365