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