Programmatically generate SVG (vector) images, animations, and interactive Jupyter widgets

Major entangled drawsvg 2.0 changes

- All camelCase methods and attributes are now snake_case (except when the SVG
attribute name is camelCase. The package name is now lowercase.
- Remove all y-axis inversion. The y-axis now increases towards the bottom of
the screen.
- Various small API-breaking changes and cleanup.

+1 -1
LICENSE.txt
···
-
Copyright 2017 Casey Duckering
+
Copyright 2023 Casey Duckering
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+113 -107
README.md
···
-
# drawSvg
+
# drawsvg
A Python 3 library for programmatically generating SVG images (vector drawings) and rendering them or displaying them in a Jupyter notebook.
Most common SVG tags are supported and others can easily be added by writing a small subclass of `DrawableBasicElement` or `DrawableParentElement`.
-
An interactive [Jupyter notebook](https://jupyter.org) widget, `drawSvg.widgets.DrawingWidget`, is included that can update drawings based on mouse events.
+
An interactive [Jupyter notebook](https://jupyter.org) widget, `drawsvg.widgets.DrawingWidget`, is included that can update drawings based on mouse events.
# Install
-
drawSvg is available on PyPI:
+
drawsvg is available on PyPI:
-
```
-
$ pip3 install drawSvg
+
```bash
+
$ pip3 install "drawsvg[all]"
```
## Prerequisites
-
Cairo needs to be installed separately. When Cairo is installed, drawSvg can output PNG or other image formats in addition to SVG. See platform-specific [instructions for Linux, Windows, and macOS from Cairo](https://www.cairographics.org/download/). Below are some examples for installing Cairo on Linux distributions and macOS.
+
Cairo needs to be installed separately. When Cairo is installed, drawsvg can output PNG or other image formats in addition to SVG. See platform-specific [instructions for Linux, Windows, and macOS from Cairo](https://www.cairographics.org/download/). Below are some examples for installing Cairo on Linux distributions and macOS.
**Ubuntu**
-
```
+
```bash
$ sudo apt-get install libcairo2
```
···
Using [homebrew](https://brew.sh/):
-
```
+
```bash
$ brew install cairo
```
···
### Basic drawing elements
```python
-
import drawSvg as draw
+
import drawsvg as draw
-
d = draw.Drawing(200, 100, origin='center', displayInline=False)
+
d = draw.Drawing(200, 100, origin='center')
# Draw an irregular polygon
-
d.append(draw.Lines(-80, -45,
-
70, -49,
-
95, 49,
-
-90, 40,
+
d.append(draw.Lines(-80, 45,
+
70, 49,
+
95, -49,
+
-90, -40,
close=False,
fill='#eeee00',
stroke='black'))
# Draw a rectangle
-
r = draw.Rectangle(-80,0,40,50, fill='#1248ff')
-
r.appendTitle("Our first rectangle") # Add a tooltip
+
r = draw.Rectangle(-80, -50, 40, 50, fill='#1248ff')
+
r.append_title("Our first rectangle") # Add a tooltip
d.append(r)
# Draw a circle
-
d.append(draw.Circle(-40, -10, 30,
-
fill='red', stroke_width=2, stroke='black'))
+
d.append(draw.Circle(-40, 10, 30,
+
fill='red', stroke_width=2, stroke='black'))
# Draw an arbitrary path (a triangle in this case)
-
p = draw.Path(stroke_width=2, stroke='lime',
-
fill='black', fill_opacity=0.2)
-
p.M(-10, 20) # Start path at point (-10, 20)
-
p.C(30, -10, 30, 50, 70, 20) # Draw a curve to (70, 20)
+
p = draw.Path(stroke_width=2, stroke='lime', fill='black', fill_opacity=0.2)
+
p.M(-10, -20) # Start path at point (-10, -20)
+
p.C(30, 10, 30, -50, 70, -20) # Draw a curve to (70, -20)
d.append(p)
# Draw text
-
d.append(draw.Text('Basic text', 8, -10, 35, fill='blue')) # Text with font size 8
-
d.append(draw.Text('Path text', 8, path=p, text_anchor='start', valign='middle'))
-
d.append(draw.Text(['Multi-line', 'text'], 8, path=p, text_anchor='end'))
+
d.append(draw.Text('Basic text', 8, -10, -35, fill='blue')) # 8pt text at (-10, -35)
+
d.append(draw.Text('Path text', 8, path=p, text_anchor='start', line_height=1))
+
d.append(draw.Text(['Multi-line', 'text'], 8, path=p, text_anchor='end', center=True))
# Draw multiple circular arcs
-
d.append(draw.ArcLine(60,-20,20,60,270,
-
stroke='red', stroke_width=5, fill='red', fill_opacity=0.2))
-
d.append(draw.Arc(60,-20,20,60,270,cw=False,
-
stroke='green', stroke_width=3, fill='none'))
-
d.append(draw.Arc(60,-20,20,270,60,cw=True,
-
stroke='blue', stroke_width=1, fill='black', fill_opacity=0.3))
+
d.append(draw.ArcLine(60, 20, 20, 60, 270,
+
stroke='red', stroke_width=5, fill='red', fill_opacity=0.2))
+
d.append(draw.Arc(60, 20, 20, 60, 270, cw=False,
+
stroke='green', stroke_width=3, fill='none'))
+
d.append(draw.Arc(60, 20, 20, 270, 60, cw=True,
+
stroke='blue', stroke_width=1, fill='black', fill_opacity=0.3))
# Draw arrows
-
arrow = draw.Marker(-0.1, -0.5, 0.9, 0.5, scale=4, orient='auto')
-
arrow.append(draw.Lines(-0.1, -0.5, -0.1, 0.5, 0.9, 0, fill='red', close=True))
+
arrow = draw.Marker(-0.1, -0.51, 0.9, 0.5, scale=4, orient='auto')
+
arrow.append(draw.Lines(-0.1, 0.5, -0.1, -0.5, 0.9, 0, fill='red', close=True))
p = draw.Path(stroke='red', stroke_width=2, fill='none',
-
marker_end=arrow) # Add an arrow to the end of a path
-
p.M(20, -40).L(20, -27).L(0, -20) # Chain multiple path operations
+
marker_end=arrow) # Add an arrow to the end of a path
+
p.M(20, 40).L(20, 27).L(0, 20) # Chain multiple path commands
d.append(p)
-
d.append(draw.Line(30, -20, 0, -10,
-
stroke='red', stroke_width=2, fill='none',
-
marker_end=arrow)) # Add an arrow to the end of a line
+
d.append(draw.Line(30, 20, 0, 10,
+
stroke='red', stroke_width=2, fill='none',
+
marker_end=arrow)) # Add an arrow to the end of a line
-
d.setPixelScale(2) # Set number of pixels per geometry unit
-
#d.setRenderSize(400,200) # Alternative to setPixelScale
-
d.saveSvg('example.svg')
-
d.savePng('example.png')
+
d.set_pixel_scale(2) # Set number of pixels per geometry unit
+
#d.set_render_size(400, 200) # Alternative to set_pixel_scale
+
d.save_svg('example.svg')
+
d.save_png('example.png')
# Display in Jupyter notebook
d.rasterize() # Display as PNG
d # Display as SVG
```
-
[![Example output image](https://raw.githubusercontent.com/cduck/drawSvg/master/examples/example1.png)](https://github.com/cduck/drawSvg/blob/master/examples/example1.svg)
+
[![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example1.png)](https://github.com/cduck/drawsvg/blob/master/examples/example1.svg)
### Gradients
```python
-
import drawSvg as draw
+
import drawsvg as draw
d = draw.Drawing(1.5, 0.8, origin='center')
-
d.draw(draw.Rectangle(-0.75,-0.5,1.5,1, fill='#ddd'))
+
d.draw(draw.Rectangle(-0.75, -0.5, 1.5, 1, fill='#ddd'))
# Create gradient
-
gradient = draw.RadialGradient(0,-0.35,0.7*10)
-
gradient.addStop(0.5/0.7/10, 'green', 1)
-
gradient.addStop(1/10, 'red', 0)
+
gradient = draw.RadialGradient(0, 0.35, 0.7*10)
+
gradient.add_stop(0.5/0.7/10, 'green', 1)
+
gradient.add_stop(1/10, 'red', 0)
# Draw a shape to fill with the gradient
p = draw.Path(fill=gradient, stroke='black', stroke_width=0.002)
-
p.arc(0,-0.35,0.7,30,120)
-
p.arc(0,-0.35,0.5,120,30,cw=True, includeL=True)
+
p.arc(0, 0.35, 0.7, 30, 120)
+
p.arc(0, 0.35, 0.5, 120, 30, cw=True, include_l=True)
p.Z()
d.append(p)
# Draw another shape to fill with the same gradient
p = draw.Path(fill=gradient, stroke='red', stroke_width=0.002)
-
p.arc(0,-0.35,0.75,130,160)
-
p.arc(0,-0.35,0,160,130,cw=True, includeL=True)
+
p.arc(0, 0.35, 0.75, 130, 160)
+
p.arc(0, 0.35, 0, 160, 130, cw=True, include_l=True)
p.Z()
d.append(p)
# Another gradient
-
gradient2 = draw.LinearGradient(0.1,-0.35,0.1+0.6,-0.35+0.2)
-
gradient2.addStop(0, 'green', 1)
-
gradient2.addStop(1, 'red', 0)
-
d.append(draw.Rectangle(0.1,-0.35,0.6,0.2,
+
gradient2 = draw.LinearGradient(0.1, 0.35, 0.1+0.6, 0.35+0.2)
+
gradient2.add_stop(0, 'green', 1)
+
gradient2.add_stop(1, 'red', 0)
+
d.append(draw.Rectangle(0.1, 0.15, 0.6, 0.2,
stroke='black', stroke_width=0.002,
fill=gradient2))
# Display
-
d.setRenderSize(w=600)
+
d.set_render_size(w=600)
d
```
-
[![Example output image](https://raw.githubusercontent.com/cduck/drawSvg/master/examples/example2.png)](https://github.com/cduck/drawSvg/blob/master/examples/example2.svg)
+
[![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example2.png)](https://github.com/cduck/drawsvg/blob/master/examples/example2.svg)
### Duplicate geometry and clip paths
```python
-
import drawSvg as draw
+
import drawsvg as draw
d = draw.Drawing(1.4, 1.4, origin='center')
# Define clip path
clip = draw.ClipPath()
-
clip.append(draw.Rectangle(-.25,.25-1,1,1))
+
clip.append(draw.Rectangle(-.25, -.25, 1, 1))
# Draw a cropped circle
-
c = draw.Circle(0,0,0.5, stroke_width='0.01', stroke='black',
-
fill_opacity=0.3, clip_path=clip,
-
id='circle')
-
d.append(c)
+
circle = draw.Circle(0, 0, 0.5,
+
stroke_width='0.01', stroke='black',
+
fill_opacity=0.3, clip_path=clip)
+
d.append(circle)
# Make a transparent copy, cropped again
g = draw.Group(opacity=0.5, clip_path=clip)
-
g.append(draw.Use('circle', 0.25,0.1))
+
g.append(draw.Use(circle, 0.25, -0.1))
d.append(g)
# Display
-
d.setRenderSize(400)
+
d.set_render_size(400)
d.rasterize()
```
-
[![Example output image](https://raw.githubusercontent.com/cduck/drawSvg/master/examples/example3.png)](https://github.com/cduck/drawSvg/blob/master/examples/example3.svg)
+
[![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example3.png)](https://github.com/cduck/drawsvg/blob/master/examples/example3.svg)
### Implementing other SVG tags
```python
-
import drawSvg as draw
+
import drawsvg as draw
# Subclass DrawingBasicElement if it cannot have child nodes
# Subclass DrawingParentElement otherwise
···
def __init__(self, href, target=None, **kwargs):
# Other init logic...
# Keyword arguments to super().__init__() correspond to SVG node
-
# arguments: stroke_width=5 -> stroke-width="5"
+
# arguments: stroke_width=5 -> <a stroke-width="5" ...>...</a>
super().__init__(href=href, target=target, **kwargs)
d = draw.Drawing(1, 1.2, origin='center')
···
hlink = Hyperlink('https://www.python.org', target='_blank',
transform='skewY(-30)')
# Add child elements
-
hlink.append(draw.Circle(0,0,0.5, fill='green'))
-
hlink.append(draw.Text('Hyperlink',0.2, 0,0, center=0.6, fill='white'))
+
hlink.append(draw.Circle(0, 0, 0.5, fill='green'))
+
hlink.append(draw.Text('Hyperlink', 0.2, 0, 0, center=0.6, fill='white'))
# Draw and display
d.append(hlink)
-
d.setRenderSize(200)
+
d.set_render_size(200)
d
```
-
[![Example output image](https://raw.githubusercontent.com/cduck/drawSvg/master/examples/example4.png)](https://github.com/cduck/drawSvg/blob/master/examples/example4.svg)
+
[![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example4.png)](https://github.com/cduck/drawsvg/blob/master/examples/example4.svg)
### Animation with the SVG Animate Tag
```python
-
import drawSvg as draw
+
import drawsvg as draw
d = draw.Drawing(200, 200, origin='center')
···
c = draw.Circle(0, 0, 20, fill='red')
# See for supported attributes:
# https://developer.mozilla.org/en-US/docs/Web/SVG/Element/animate
-
c.appendAnim(draw.Animate('cy', '6s', '-80;80;-80',
-
repeatCount='indefinite'))
-
c.appendAnim(draw.Animate('cx', '6s', '0;80;0;-80;0',
-
repeatCount='indefinite'))
-
c.appendAnim(draw.Animate('fill', '6s', 'red;green;blue;yellow',
-
calcMode='discrete',
-
repeatCount='indefinite'))
+
c.append_anim(draw.Animate('cy', '6s', '-80;80;-80',
+
repeatCount='indefinite'))
+
c.append_anim(draw.Animate('cx', '6s', '0;80;0;-80;0',
+
repeatCount='indefinite'))
+
c.append_anim(draw.Animate('fill', '6s', 'red;green;blue;yellow',
+
calc_mode='discrete',
+
repeatCount='indefinite'))
d.append(c)
# Animate a black circle around an ellipse
···
ellipse.Z()
c2 = draw.Circle(0, 0, 10)
# See for supported attributes:
-
# https://developer.mozilla.org/en-US/docs/Web/SVG/Element/animateMotion
-
c2.appendAnim(draw.AnimateMotion(ellipse, '3s',
-
repeatCount='indefinite'))
+
# https://developer.mozilla.org/en-US/docs/Web/SVG/Element/animate_motion
+
c2.append_anim(draw.AnimateMotion(ellipse, '3s',
+
repeatCount='indefinite'))
# See for supported attributes:
-
# https://developer.mozilla.org/en-US/docs/Web/SVG/Element/animateTransform
-
c2.appendAnim(draw.AnimateTransform('scale', '3s', '1,2;2,1;1,2;2,1;1,2',
-
repeatCount='indefinite'))
+
# https://developer.mozilla.org/en-US/docs/Web/SVG/Element/animate_transform
+
c2.append_anim(draw.AnimateTransform('scale', '3s', '1,2;2,1;1,2;2,1;1,2',
+
repeatCount='indefinite'))
d.append(c2)
-
d.saveSvg('animated.svg') # Save to file
+
d.save_svg('animated.svg') # Save to file
d # Display in Jupyter notebook
```
-
[![Example output image](https://raw.githubusercontent.com/cduck/drawSvg/master/examples/animated-fix-github.svg?sanitize=true)](https://github.com/cduck/drawSvg/blob/master/examples/animated.svg)
+
[![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/animated-fix-github.svg?sanitize=true)](https://github.com/cduck/drawsvg/blob/master/examples/animated.svg)
### Interactive Widget
```python
-
import drawSvg as draw
-
from drawSvg.widgets import DrawingWidget
+
import drawsvg as draw
+
from drawsvg.widgets import DrawingWidget
import hyperbolic.poincare.shapes as hyper # pip3 install hyperbolic
+
from hyperbolic import euclid
+
+
# Patch the hyperbolic package for drawsvg version 2
+
patch = lambda m: lambda self, **kw: m(self, draw, **kw)
+
hyper.Circle.to_drawables = patch(hyper.Circle.toDrawables)
+
hyper.Line.to_drawables = patch(hyper.Line.toDrawables)
+
euclid.Arc.Arc.drawToPath = lambda self, path, includeM=True, includeL=False: path.arc(self.cx, -self.cy, self.r, self.startDeg, self.endDeg, cw=self.cw, include_m=includeM, include_l=includeL)
# Create drawing
d = draw.Drawing(2, 2, origin='center')
-
d.setRenderSize(500)
+
d.set_render_size(500)
d.append(draw.Circle(0, 0, 1, fill='orange'))
group = draw.Group()
d.append(group)
···
line = hyper.Line.fromPoints(*p1, *p2, segment=True)
group.draw(line, hwidth=0.2, fill='white')
for x, y in points:
-
p = hyper.Point.fromEuclid(x, y)
+
p = hyper.Point.fromEuclid(x, -y)
group.draw(hyper.Circle.fromCenterRadius(p, 0.1),
fill='green')
redraw(click_list)
···
widget
```
-
![Example output image](https://raw.githubusercontent.com/cduck/drawSvg/master/examples/example5.gif)
+
![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example5.gif)
Note: The above example currently only works in `jupyter notebook`, not `jupyter lab`.
### Animation with Python
```python
-
import drawSvg as draw
+
import drawsvg as draw
# Draw a frame of the animation
def draw_frame(t):
-
d = draw.Drawing(2, 6.05, origin=(-1,-1.05))
-
d.setRenderSize(h=300)
-
d.append(draw.Rectangle(-2, -2, 4, 8, fill='white'))
-
d.append(draw.Rectangle(-1, -1.05, 2, 0.05, fill='brown'))
+
d = draw.Drawing(2, 6.05, origin=(-1, -5))
+
d.set_render_size(h=300)
+
d.append(draw.Rectangle(-2, -6, 4, 8, fill='white'))
+
d.append(draw.Rectangle(-1, 1, 2, 0.05, fill='brown'))
t = (t + 1) % 2 - 1
-
y = 4 - t**2 * 4
+
y = t**2 * 4 - 4
d.append(draw.Circle(0, y, 1, fill='lime'))
return d
-
with draw.animate_jupyter(draw_frame, delay=0.05) as anim:
+
with draw.frame_animate_jupyter(draw_frame, delay=0.05) as anim:
# Or:
#with draw.animate_video('example6.gif', draw_frame, duration=0.05
# ) as anim:
···
anim.draw_frame(i/10)
```
-
![Example output image](https://raw.githubusercontent.com/cduck/drawSvg/master/examples/example6.gif)
+
![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example6.gif)
### Asynchronous Animation in Jupyter
```python
# Jupyter cell 1:
-
import drawSvg as draw
-
from drawSvg.widgets import AsyncAnimation
+
import drawsvg as draw
+
from drawsvg.widgets import AsyncAnimation
widget = AsyncAnimation(fps=10)
widget
# [Animation is displayed here (click to pause)]
···
def draw_frame(secs=0):
# Draw something...
d = draw.Drawing(100, 40)
-
d.append(draw.Text(global_variable, 20, 0, 10))
-
d.append(draw.Text('{:0.1f}'.format(secs), 20, 30, 10))
+
d.append(draw.Text(global_variable, 20, 0, 30))
+
d.append(draw.Text('{:0.1f}'.format(secs), 20, 30, 30))
return d
# Jupyter cell 3:
global_variable = 'b' # Animation above now displays 'b'
```
-
![Example output image](https://raw.githubusercontent.com/cduck/drawSvg/master/examples/example7.gif)
+
![Example output image](https://raw.githubusercontent.com/cduck/drawsvg/master/examples/example7.gif)
Note: The above example currently only works in `jupyter notebook`, not `jupyter lab`.
+52 -42
drawSvg/__init__.py
···
'''
A library for creating SVG files or just drawings that can be displayed in
-
iPython notebooks
+
Jupyter notebooks
Example:
```
+
import drawsvg as draw
+
d = draw.Drawing(200, 100, origin='center')
-
d.append(draw.Lines(-80, -45,
-
70, -49,
-
95, 49,
-
-90, 40,
+
# Draw an irregular polygon
+
d.append(draw.Lines(-80, 45,
+
70, 49,
+
95, -49,
+
-90, -40,
close=False,
fill='#eeee00',
stroke='black'))
-
d.append(draw.Rectangle(0,0,40,50, fill='#1248ff'))
-
d.append(draw.Circle(-40, -10, 30,
-
fill='red', stroke_width=2, stroke='black'))
+
# Draw a rectangle
+
r = draw.Rectangle(-80, -50, 40, 50, fill='#1248ff')
+
r.append_title("Our first rectangle") # Add a tooltip
+
d.append(r)
+
+
# Draw a circle
+
d.append(draw.Circle(-40, 10, 30,
+
fill='red', stroke_width=2, stroke='black'))
-
p = draw.Path(stroke_width=2, stroke='green',
-
fill='black', fill_opacity=0.5)
-
p.M(-30,5) # Start path at point (-30, 5)
-
p.l(60,30) # Draw line to (60, 30)
-
p.h(-70) # Draw horizontal line to x=-70
-
p.Z() # Draw line to start
+
# Draw an arbitrary path (a triangle in this case)
+
p = draw.Path(stroke_width=2, stroke='lime', fill='black', fill_opacity=0.2)
+
p.M(-10, -20) # Start path at point (-10, -20)
+
p.C(30, 10, 30, -50, 70, -20) # Draw a curve to (70, -20)
d.append(p)
-
d.append(draw.ArcLine(60,-20,20,60,270,
-
stroke='red', stroke_width=5, fill='red', fill_opacity=0.2))
-
d.append(draw.Arc(60,-20,20,60,270,cw=False,
-
stroke='green', stroke_width=3, fill='none'))
-
d.append(draw.Arc(60,-20,20,270,60,cw=True,
-
stroke='blue', stroke_width=1, fill='black', fill_opacity=0.3))
+
# Draw text
+
d.append(draw.Text('Basic text', 8, -10, -35, fill='blue')) # 8pt text at (-10, -35)
+
d.append(draw.Text('Path text', 8, path=p, text_anchor='start', line_height=1))
+
d.append(draw.Text(['Multi-line', 'text'], 8, path=p, text_anchor='end', center=True))
-
d.setPixelScale(2) # Set number of pixels per geometry unit
-
#d.setRenderSize(400,200) # Alternative to setPixelScale
-
d.saveSvg('example.svg')
-
d.savePng('example.png')
+
# Draw multiple circular arcs
+
d.append(draw.ArcLine(60, 20, 20, 60, 270,
+
stroke='red', stroke_width=5, fill='red', fill_opacity=0.2))
+
d.append(draw.Arc(60, 20, 20, 60, 270, cw=False,
+
stroke='green', stroke_width=3, fill='none'))
+
d.append(draw.Arc(60, 20, 20, 270, 60, cw=True,
+
stroke='blue', stroke_width=1, fill='black', fill_opacity=0.3))
+
+
# Draw arrows
+
arrow = draw.Marker(-0.1, -0.51, 0.9, 0.5, scale=4, orient='auto')
+
arrow.append(draw.Lines(-0.1, 0.5, -0.1, -0.5, 0.9, 0, fill='red', close=True))
+
p = draw.Path(stroke='red', stroke_width=2, fill='none',
+
marker_end=arrow) # Add an arrow to the end of a path
+
p.M(20, 40).L(20, 27).L(0, 20) # Chain multiple path commands
+
d.append(p)
+
d.append(draw.Line(30, 20, 0, 10,
+
stroke='red', stroke_width=2, fill='none',
+
marker_end=arrow)) # Add an arrow to the end of a line
+
+
d.set_pixel_scale(2) # Set number of pixels per geometry unit
+
#d.set_render_size(400, 200) # Alternative to set_pixel_scale
+
d.save_svg('example.svg')
+
d.save_png('example.png')
-
# Display in iPython notebook
+
# Display in Jupyter notebook
d.rasterize() # Display as PNG
d # Display as SVG
```
···
render_svg_frames,
save_video,
)
-
from .animation import (
-
Animation,
-
animate_video,
-
animate_jupyter,
+
from .frame_animation import (
+
FrameAnimation,
+
frame_animate_video,
+
frame_animate_jupyter,
)
-
-
-
# Make all elements available in the elements module
-
from . import defs
-
from . import elements
-
def registerElement(name, elem):
-
setattr(elements, name, elem)
-
elementsDir = dir(elements)
-
for k in dir(defs):
-
if k.startswith('_'): continue
-
if k in elementsDir: continue
-
registerElement(k, getattr(defs, k))
-
+9 -9
drawSvg/animation.py
···
from . import video
-
class Animation:
+
class FrameAnimation:
def __init__(self, draw_func=None, callback=None):
self.frames = []
if draw_func is None:
···
video.save_video(self.frames, file, **kwargs)
-
class AnimationContext:
+
class FrameAnimationContext:
def __init__(self, draw_func=None, out_file=None,
jupyter=False, pause=False, clear=True, delay=0, disable=False,
video_args=None, _patch_delay=0.05):
···
callback = self.draw_jupyter_frame
else:
callback = None
-
self.anim = Animation(draw_func, callback=callback)
+
self.anim = FrameAnimation(draw_func, callback=callback)
self.out_file = out_file
self.pause = pause
self.clear = clear
···
self.anim.save_video(self.out_file, **self.video_args)
-
def animate_video(out_file, draw_func=None, jupyter=False, **video_args):
+
def frame_animate_video(out_file, draw_func=None, jupyter=False, **video_args):
'''
Returns a context manager that stores frames and saves a video when the
context exits.
···
anim.draw_frame(...)
```
'''
-
return AnimationContext(draw_func=draw_func, out_file=out_file,
+
return FrameAnimationContext(draw_func=draw_func, out_file=out_file,
jupyter=jupyter, video_args=video_args)
-
def animate_jupyter(draw_func=None, pause=False, clear=True, delay=0.1,
-
**kwargs):
+
def frame_animate_jupyter(draw_func=None, pause=False, clear=True, delay=0.1,
+
**kwargs):
'''
Returns a context manager that displays frames in a Jupyter notebook.
···
anim.draw_frame(...)
```
'''
-
return AnimationContext(draw_func=draw_func, jupyter=True, pause=pause,
-
clear=clear, delay=delay, **kwargs)
+
return FrameAnimationContext(draw_func=draw_func, jupyter=True, pause=pause,
+
clear=clear, delay=delay, **kwargs)
+46 -43
drawSvg/color.py
···
-
import math
-
import numpy as np
-
import pwkit.colormaps # pip3 install pwkit
+
+
try:
+
import numpy as np
+
import pwkit.colormaps
+
except ImportError as e:
+
raise ImportError(
+
'Optional dependencies not installed. '
+
'Install with `python3 -m pip install "drawsvg[color]"'
+
) from e
# Most calculations from http://www.chilliant.com/rgb2hsv.html
···
if wts is None: wts = self.LUMA_WEIGHTS
rw, gw, bw = wts
return rw*self.r + gw*self.g + bw*self.b
-
def toSrgb(self):
+
def to_srgb(self):
return self
@staticmethod
-
def fromHue(h):
+
def from_hue(h):
h = h % 1
r = abs(h * 6 - 3) - 1
g = 2 - abs(h * 6 - 2)
···
def __repr__(self):
return 'HSL({}, {}, {})'.format(self.h, self.s, self.l)
def __str__(self):
-
r, g, b = self.toSrgb()
-
return 'rgb({}%,{}%,{}%)'.format(round(r*100), round(g*100), round(b*100))
-
def toSrgb(self):
-
hs = Srgb.fromHue(self.h)
+
r, g, b = self.to_srgb()
+
return 'rgb({}%,{}%,{}%)'.format(
+
round(r*100, 2), round(g*100, 2), round(b*100, 2))
+
def to_srgb(self):
+
hs = Srgb.from_hue(self.h)
c = (1 - abs(2 * self.l - 1)) * self.s
return Srgb(
(hs.r - 0.5) * c + self.l,
···
def __repr__(self):
return 'HSV({}, {}, {})'.format(self.h, self.s, self.v)
def __str__(self):
-
r, g, b = self.toSrgb()
-
return 'rgb({}%,{}%,{}%)'.format(round(r*100), round(g*100), round(b*100))
-
def toSrgb(self):
-
hs = Srgb.fromHue(self.h)
+
r, g, b = self.to_srgb()
+
return 'rgb({}%,{}%,{}%)'.format(
+
round(r*100, 2), round(g*100, 2), round(b*100, 2))
+
def to_srgb(self):
+
hs = Srgb.from_hue(self.h)
c = self.v * self.s
hp = self.h * 6
x = c * (1 - abs(hp % 2 - 1))
···
def __repr__(self):
return 'Sin({}, {}, {})'.format(self.h, self.s, self.l)
def __str__(self):
-
r, g, b = self.toSrgb()
-
return 'rgb({}%,{}%,{}%)'.format(round(r*100), round(g*100), round(b*100))
-
def toSrgb(self):
+
r, g, b = self.to_srgb()
+
return 'rgb({}%,{}%,{}%)'.format(
+
round(r*100, 2), round(g*100, 2), round(b*100, 2))
+
def to_srgb(self):
h = self.h
scale = self.s / 2
shift = self.l #* (1-2*scale)
···
def __repr__(self):
return 'HCY({}, {}, {})'.format(self.h, self.c, self.y)
def __str__(self):
-
r, g, b = self.toSrgb()
+
r, g, b = self.to_srgb()
return 'rgb({}%,{}%,{}%)'.format(r*100, g*100, b*100)
-
def toSrgb(self):
-
hs = Srgb.fromHue(self.h)
+
def to_srgb(self):
+
hs = Srgb.from_hue(self.h)
y = hs.luma(wts=self.HCY_WEIGHTS)
c = self.c
if self.y < y:
···
(hs.b - y) * c + self.y,
)
@staticmethod
-
def _rgbToHcv(srgb):
+
def _rgb_to_hcv(srgb):
if srgb.g < srgb.b:
p = (srgb.b, srgb.g, -1., 2./3.)
else:
···
h = abs((q[3] - q[1]) / (6*c + 1e-10) + q[2])
return (h, c, q[0])
@classmethod
-
def fromSrgb(cls, srgb):
-
hcv = list(cls._rgbToHcv(srgb))
+
def from_srgb(cls, srgb):
+
hcv = list(cls._rgb_to_hcv(srgb))
rw, gw, bw = cls.HCY_WEIGHTS
y = rw*srgb.r + gw*srgb.g + bw*srgb.b
-
hs = Srgb.fromHue(hcv[0])
+
hs = Srgb.from_hue(hcv[0])
z = rw*hs.r + gw*hs.g + bw*hs.b
if y < z:
hcv[1] *= z / (y + 1e-10)
···
def __repr__(self):
return 'CIELAB({}, {}, {})'.format(self.l, self.a, self.b)
def __str__(self):
-
r, g, b = self.toSrgb()
-
return 'rgb({}%,{}%,{}%)'.format(round(r*100), round(g*100), round(b*100))
-
def toSrgb(self):
-
inArr = np.array((*self.l,), dtype=float)
-
xyz = pwkit.colormaps.cielab_to_xyz(inArr, self.REF_WHITE)
-
linSrgb = pwkit.colormaps.xyz_to_linsrgb(xyz)
-
r, g, b = pwkit.colormaps.linsrgb_to_srgb(linSrgb)
+
r, g, b = self.to_srgb()
+
return 'rgb({}%,{}%,{}%)'.format(
+
round(r*100, 2), round(g*100, 2), round(b*100, 2))
+
def to_srgb(self):
+
in_arr = np.array((self.l, self.a, self.b))
+
xyz = pwkit.colormaps.cielab_to_xyz(in_arr, self.REF_WHITE)
+
lin_srgb = pwkit.colormaps.xyz_to_linsrgb(xyz)
+
r, g, b = pwkit.colormaps.linsrgb_to_srgb(lin_srgb)
return Srgb(r, g, b)
@classmethod
-
def fromSrgb(cls, srgb, refWhite=None):
-
if refWhite is None: refWhite = cls.REF_WHITE
-
inArr = np.array((*srgb,), dtype=float)
-
linSrgb = pwkit.colormaps.srgb_to_linsrgb(inArr)
-
xyz = pwkit.colormaps.linsrgb_to_xyz(linSrgb)
-
l, a, b = pwkit.colormaps.xyz_to_cielab(xyz, refWhite)
+
def from_srgb(cls, srgb, ref_white=None):
+
if ref_white is None: ref_white = cls.REF_WHITE
+
in_arr = np.array((*srgb,), dtype=float)
+
lin_srgb = pwkit.colormaps.srgb_to_linsrgb(in_arr)
+
xyz = pwkit.colormaps.linsrgb_to_xyz(lin_srgb)
+
l, a, b = pwkit.colormaps.xyz_to_cielab(xyz, ref_white)
return Cielab(l, a, b)
-
def toSrgb(self):
-
inArr = np.array((self.l, self.a, self.b))
-
xyz = pwkit.colormaps.cielab_to_xyz(inArr, self.REF_WHITE)
-
linSrgb = pwkit.colormaps.xyz_to_linsrgb(xyz)
-
r, g, b = pwkit.colormaps.linsrgb_to_srgb(linSrgb)
-
return Srgb(r, g, b)
-
+42 -45
drawSvg/defs.py
···
-
from .elements import DrawingElement, DrawingParentElement
class DrawingDef(DrawingParentElement):
-
''' Parent class of SVG nodes that must be direct children of <defs> '''
-
def getSvgDefs(self):
+
'''Parent class of SVG nodes that must be direct children of <defs>.'''
+
def get_svg_defs(self):
return (self,)
class DrawingDefSub(DrawingParentElement):
-
''' Parent class of SVG nodes that are meant to be descendants of a Def '''
+
'''Parent class of SVG nodes that are meant to be descendants of a Def.'''
pass
class LinearGradient(DrawingDef):
-
''' A linear gradient to use as a fill or other color
+
'''
+
A linear gradient to use as a fill or other color.
-
Has <stop> nodes as children. '''
+
Has <stop> nodes as children, added with `.add_stop()`.
+
'''
TAG_NAME = 'linearGradient'
-
def __init__(self, x1, y1, x2, y2, gradientUnits='userSpaceOnUse', **kwargs):
-
yShift = 0
-
if gradientUnits != 'userSpaceOnUse':
-
yShift = 1
-
try: y1 = yShift - y1
-
except TypeError: pass
-
try: y2 = yShift - y2
-
except TypeError: pass
-
super().__init__(x1=x1, y1=y1, x2=x2, y2=y2, gradientUnits=gradientUnits,
-
**kwargs)
-
def addStop(self, offset, color, opacity=None, **kwargs):
+
def __init__(self, x1, y1, x2, y2, gradientUnits='userSpaceOnUse',
+
**kwargs):
+
super().__init__(x1=x1, y1=y1, x2=x2, y2=y2,
+
gradientUnits=gradientUnits, **kwargs)
+
def add_stop(self, offset, color, opacity=None, **kwargs):
stop = GradientStop(offset=offset, stop_color=color,
stop_opacity=opacity, **kwargs)
self.append(stop)
class RadialGradient(DrawingDef):
-
''' A radial gradient to use as a fill or other color
+
'''
+
A radial gradient to use as a fill or other color
-
Has <stop> nodes as children. '''
+
Has <stop> nodes as children, added with `.add_stop()`.
+
'''
TAG_NAME = 'radialGradient'
-
def __init__(self, cx, cy, r, gradientUnits='userSpaceOnUse', fy=None, **kwargs):
-
yShift = 0
-
if gradientUnits != 'userSpaceOnUse':
-
yShift = 1
-
try: cy = yShift - cy
-
except TypeError: pass
-
try: fy = yShift - fy
-
except TypeError: pass
+
def __init__(self, cx, cy, r, gradientUnits='userSpaceOnUse', fy=None,
+
**kwargs):
super().__init__(cx=cx, cy=cy, r=r, gradientUnits=gradientUnits,
fy=fy, **kwargs)
-
def addStop(self, offset, color, opacity=None, **kwargs):
+
def add_stop(self, offset, color, opacity=None, **kwargs):
stop = GradientStop(offset=offset, stop_color=color,
stop_opacity=opacity, **kwargs)
self.append(stop)
class GradientStop(DrawingDefSub):
-
''' A control point for a radial or linear gradient '''
+
'''A control point for a radial or linear gradient.'''
TAG_NAME = 'stop'
-
hasContent = False
+
has_content = False
class ClipPath(DrawingDef):
-
''' A shape used to crop another element by not drawing outside of this
-
shape
+
'''
+
A shape used to crop another element by not drawing outside of this shape.
-
Has regular drawing elements as children. '''
+
Has regular drawing elements as children.
+
'''
TAG_NAME = 'clipPath'
class Mask(DrawingDef):
-
''' A drawing where the gray value and transparency are used to control the
-
transparency of another shape.
+
'''
+
A drawing where the gray value and transparency are used to control the
+
transparency of another shape.
-
Has regular drawing elements as children. '''
+
Has regular drawing elements as children.
+
'''
TAG_NAME = 'mask'
class Filter(DrawingDef):
-
''' A filter to apply to geometry
+
'''
+
A filter to apply to geometry.
-
For example a blur filter. '''
+
For example a blur filter.
+
'''
TAG_NAME = 'filter'
class FilterItem(DrawingDefSub):
-
''' A child of Filter with any tag name'''
-
hasContent = False
+
'''A child of Filter with any tag name.'''
+
has_content = False
def __init__(self, tag_name, **args):
super().__init__(**args)
self.TAG_NAME = tag_name
class Marker(DrawingDef):
-
''' A small drawing that can be placed at the ends of (or along) a path.
-
-
This can be used for arrow heads or points on a graph for example.
+
'''
+
A small drawing that can be placed at the ends of (or along) a path.
-
By default, units are multiples of stroke width.'''
+
This can be used for arrow heads or points on a graph for example.
+
By default, units are multiples of stroke width.
+
'''
TAG_NAME = 'marker'
def __init__(self, minx, miny, maxx, maxy, scale=1, orient='auto',
**kwargs):
···
kwargs = {
'markerWidth': width if scale == 1 else float(width) * scale,
'markerHeight': height if scale == 1 else float(height) * scale,
-
'viewBox': '{} {} {} {}'.format(minx, -maxy, width, height),
+
'viewBox': '{} {} {} {}'.format(minx, miny, width, height),
'orient': orient,
**kwargs,
}
+183 -161
drawSvg/drawing.py
···
-
from io import StringIO
-
import base64
-
import urllib.parse
-
import re
from collections import defaultdict
+
import random
+
import string
from . import Raster
-
from . import elements as elementsModule
+
from . import elements as elements_module
+
from . import jupyter
-
STRIP_CHARS = ('\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11'
-
'\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f')
+
+
SVG_START_FMT = '''<?xml version="1.0" encoding="UTF-8"?>
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
+
width="{}" height="{}" viewBox="{} {} {} {}"'''
+
SVG_END = '</svg>'
+
SVG_CSS_FMT = '<style><![CDATA[{}]]></style>'
+
SVG_JS_FMT = '<script><![CDATA[{}]]></script>'
class Drawing:
-
''' A canvas to draw on
+
'''
+
A vector drawing.
+
+
Append shapes and other elements with `.append()`. The default coordinate
+
system origin is at the top-left corner with x-values increasing to the
+
right and y-values increasing downward.
-
Supports iPython: If a Drawing is the last line of a cell, it will be
-
displayed as an SVG below. '''
-
def __init__(self, width, height, origin=(0,0), idPrefix='d',
-
displayInline=True, **svgArgs):
+
Supports iPython: If a Drawing is the last line of a cell, it will be
+
displayed as an SVG below.
+
'''
+
def __init__(self, width, height, origin=(0,0), id_prefix='d', **svg_args):
assert float(width) == width
assert float(height) == height
self.width = width
self.height = height
-
if origin == 'center':
-
self.viewBox = (-width/2, -height/2, width, height)
+
if isinstance(origin, str):
+
self.view_box = {
+
'center': (-width/2, -height/2, width, height),
+
'top-left': (0, 0, width, height),
+
'top-right': (-width, 0, width, height),
+
'bottom-left': (0, -height, width, height),
+
'bottom-right': (-width, -height, width, height),
+
}[origin]
else:
origin = tuple(origin)
assert len(origin) == 2
-
self.viewBox = origin + (width, height)
-
self.viewBox = (self.viewBox[0], -self.viewBox[1]-self.viewBox[3],
-
self.viewBox[2], self.viewBox[3])
+
self.view_box = origin + (width, height)
self.elements = []
-
self.orderedElements = defaultdict(list)
-
self.otherDefs = []
-
self.pixelScale = 1
-
self.renderWidth = None
-
self.renderHeight = None
-
self.idPrefix = str(idPrefix)
-
self.displayInline = displayInline
-
self.svgArgs = {}
-
for k, v in svgArgs.items():
+
self.ordered_elements = defaultdict(list)
+
self.other_defs = []
+
self.css_list = []
+
self.js_list = []
+
self.pixel_scale = 1
+
self.render_width = None
+
self.render_height = None
+
self.id_prefix = str(id_prefix)
+
self.svg_args = {}
+
for k, v in svg_args.items():
k = k.replace('__', ':')
k = k.replace('_', '-')
if k[-1] == '-':
k = k[:-1]
-
self.svgArgs[k] = v
-
self.idIndex = 0
-
def setRenderSize(self, w=None, h=None):
-
self.renderWidth = w
-
self.renderHeight = h
+
self.svg_args[k] = v
+
def set_render_size(self, w=None, h=None):
+
self.render_width = w
+
self.render_height = h
return self
-
def setPixelScale(self, s=1):
-
self.renderWidth = None
-
self.renderHeight = None
-
self.pixelScale = s
+
def set_pixel_scale(self, s=1):
+
self.render_width = None
+
self.render_height = None
+
self.pixel_scale = s
return self
-
def calcRenderSize(self):
-
if self.renderWidth is None and self.renderHeight is None:
-
return (self.width * self.pixelScale,
-
self.height * self.pixelScale)
-
elif self.renderWidth is None:
-
s = self.renderHeight / self.height
-
return self.width * s, self.renderHeight
-
elif self.renderHeight is None:
-
s = self.renderWidth / self.width
-
return self.renderWidth, self.height * s
+
def calc_render_size(self):
+
if self.render_width is None and self.render_height is None:
+
return (self.width * self.pixel_scale,
+
self.height * self.pixel_scale)
+
elif self.render_width is None:
+
s = self.render_height / self.height
+
return self.width * s, self.render_height
+
elif self.render_height is None:
+
s = self.render_width / self.width
+
return self.render_width, self.height * s
else:
-
return self.renderWidth, self.renderHeight
+
return self.render_width, self.render_height
def draw(self, obj, *, z=None, **kwargs):
+
'''Add any object that knows how to draw itself to the drawing.
+
+
This object must implement the `to_drawables(**kwargs)` method
+
that returns a `DrawingElement` or list of elements.
+
'''
if obj is None:
return
-
if not hasattr(obj, 'writeSvgElement'):
-
elements = obj.toDrawables(elements=elementsModule, **kwargs)
+
if not hasattr(obj, 'write_svg_element'):
+
elements = obj.to_drawables(**kwargs)
else:
assert len(kwargs) == 0
-
elements = (obj,)
-
self.extend(elements, z=z)
+
elements = obj
+
if hasattr(elements, 'write_svg_element'):
+
self.append(elements, z=z)
+
else:
+
self.extend(elements, z=z)
def append(self, element, *, z=None):
+
'''Add any `DrawingElement` to the drawing.
+
+
Do not append a `DrawingDef` referenced by other elements. These are
+
included automatically. Use `.append_def()` for an unreferenced
+
`DrawingDef`.
+
'''
if z is not None:
-
self.orderedElements[z].append(element)
+
self.ordered_elements[z].append(element)
else:
self.elements.append(element)
def extend(self, iterable, *, z=None):
if z is not None:
-
self.orderedElements[z].extend(iterable)
+
self.ordered_elements[z].extend(iterable)
else:
self.elements.extend(iterable)
def insert(self, i, element):
···
return self.elements.count(element)
def reverse(self):
self.elements.reverse()
-
def drawDef(self, obj, **kwargs):
-
if not hasattr(obj, 'writeSvgElement'):
-
elements = obj.toDrawables(elements=elementsModule, **kwargs)
+
def draw_def(self, obj, **kwargs):
+
if not hasattr(obj, 'write_svg_element'):
+
elements = obj.to_drawables(**kwargs)
else:
assert len(kwargs) == 0
-
elements = (obj,)
-
self.otherDefs.extend(elements)
-
def appendDef(self, element):
-
self.otherDefs.append(element)
-
def allElements(self):
-
''' Returns self.elements and self.orderedElements as a single list. '''
+
elements = obj
+
if hasattr(elements, 'write_svg_element'):
+
self.append_def(elements)
+
else:
+
self.other_defs.extend(elements)
+
def append_def(self, element):
+
self.other_defs.append(element)
+
def append_title(self, text, **kwargs):
+
self.append(elements.Title(text, **kwargs))
+
def append_css(self, css_text):
+
self.css_list.append(css_text)
+
def append_javascriipt(self, js_text, onload=None):
+
if onload:
+
if self.svg_args.get('onload'):
+
self.svg_args['onload'] = f'{self.svg_args["onload"]};{onload}'
+
else:
+
self.svg_args['onload'] = onload
+
self.js_list.append(js_text)
+
def all_elements(self):
+
'''Return self.elements and self.ordered_elements as a single list.'''
output = list(self.elements)
-
for z in sorted(self.orderedElements):
-
output.extend(self.orderedElements[z])
+
for z in sorted(self.ordered_elements):
+
output.extend(self.ordered_elements[z])
return output
-
def asSvg(self, outputFile=None):
-
returnString = outputFile is None
-
if returnString:
-
outputFile = StringIO()
-
imgWidth, imgHeight = self.calcRenderSize()
-
startStr = '''<?xml version="1.0" encoding="UTF-8"?>
-
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
-
width="{}" height="{}" viewBox="{} {} {} {}"'''.format(
-
imgWidth, imgHeight, *self.viewBox)
-
endStr = '</svg>'
-
outputFile.write(startStr)
-
elementsModule.writeXmlNodeArgs(self.svgArgs, outputFile)
-
outputFile.write('>\n<defs>\n')
+
def as_svg(self, output_file=None, randomize_ids=False):
+
if output_file is None:
+
with StringIO() as f:
+
self.as_svg(f, randomize_ids=randomize_ids)
+
return f.getvalue()
+
img_width, img_height = self.calc_render_size()
+
start_str = SVG_START_FMT.format(img_width, img_height, *self.view_box)
+
output_file.write(start_str)
+
elements_module.write_xml_node_args(self.svg_args, output_file)
+
output_file.write('>\n')
+
if self.css_list:
+
output_file.write(SVG_CSS_FMT.format('\n'.join(self.css_list)))
+
output_file.write('\n')
+
if self.js_list:
+
output_file.write(SVG_JS_FMT.format('\n'.join(self.js_list)))
+
output_file.write('\n')
+
output_file.write('<defs>\n')
# Write definition elements
-
def idGen(base=''):
-
idStr = self.idPrefix + base + str(self.idIndex)
-
self.idIndex += 1
-
return idStr
-
prevSet = set((id(defn) for defn in self.otherDefs))
-
def isDuplicate(obj):
-
nonlocal prevSet
-
dup = id(obj) in prevSet
-
prevSet.add(id(obj))
+
id_prefix = self.id_prefix
+
id_prefix = self._random_id() if randomize_ids else self.id_prefix
+
id_index = 0
+
def id_gen(base=''):
+
nonlocal id_index
+
id_str = f'{id_prefix}{base}{id_index}'
+
id_index += 1
+
return id_str
+
id_map = defaultdict(id_gen)
+
prev_set = set((id(defn) for defn in self.other_defs))
+
def is_duplicate(obj):
+
nonlocal prev_set
+
dup = id(obj) in prev_set
+
prev_set.add(id(obj))
return dup
-
for element in self.otherDefs:
-
try:
-
element.writeSvgElement(idGen, isDuplicate, outputFile, False)
-
outputFile.write('\n')
-
except AttributeError:
-
pass
-
allElements = self.allElements()
-
for element in allElements:
-
try:
-
element.writeSvgDefs(idGen, isDuplicate, outputFile, False)
-
except AttributeError:
-
pass
-
outputFile.write('</defs>\n')
+
for element in self.other_defs:
+
if hasattr(element, 'write_svg_element'):
+
element.write_svg_element(
+
id_map, is_duplicate, output_file, False)
+
output_file.write('\n')
+
all_elements = self.all_elements()
+
for element in all_elements:
+
if hasattr(element, 'write_svg_defs'):
+
element.write_svg_defs(
+
id_map, is_duplicate, output_file, False)
+
output_file.write('</defs>\n')
# Generate ids for normal elements
-
prevDefSet = set(prevSet)
-
for element in allElements:
-
try:
-
element.writeSvgElement(idGen, isDuplicate, outputFile, True)
-
except AttributeError:
-
pass
-
prevSet = prevDefSet
+
prev_def_set = set(prev_set)
+
for element in all_elements:
+
if hasattr(element, 'write_svg_element'):
+
element.write_svg_element(
+
id_map, is_duplicate, output_file, True)
+
prev_set = prev_def_set
# Write normal elements
-
for element in allElements:
-
try:
-
element.writeSvgElement(idGen, isDuplicate, outputFile, False)
-
outputFile.write('\n')
-
except AttributeError:
-
pass
-
outputFile.write(endStr)
-
if returnString:
-
return outputFile.getvalue()
-
def saveSvg(self, fname, encoding='utf-8'):
+
for element in all_elements:
+
if hasattr(element, 'write_svg_element'):
+
element.write_svg_element(
+
id_map, is_duplicate, output_file, False)
+
output_file.write('\n')
+
output_file.write(SVG_END)
+
@staticmethod
+
def _random_id(length=8):
+
return (random.choice(string.ascii_letters)
+
+ ''.join(random.choices(
+
string.ascii_letters+string.digits, k=length-1)))
+
def save_svg(self, fname, encoding='utf-8'):
with open(fname, 'w', encoding=encoding) as f:
-
self.asSvg(outputFile=f)
-
def savePng(self, fname):
-
self.rasterize(toFile=fname)
-
def rasterize(self, toFile=None):
-
if toFile:
-
return Raster.fromSvgToFile(self.asSvg(), toFile)
+
self.as_svg(output_file=f)
+
def save_png(self, fname):
+
self.rasterize(to_file=fname)
+
def rasterize(self, to_file=None):
+
if to_file:
+
return Raster.from_svg_to_file(self.as_svg(), to_file)
else:
-
return Raster.fromSvg(self.asSvg())
+
return Raster.from_svg(self.as_svg())
def _repr_svg_(self):
-
''' Display in Jupyter notebook '''
-
if not self.displayInline:
-
return None
-
return self.asSvg()
-
def _repr_html_(self):
-
''' Display in Jupyter notebook '''
-
if self.displayInline:
-
return None
-
prefix = b'data:image/svg+xml;base64,'
-
data = base64.b64encode(self.asSvg().encode(encoding='utf-8'))
-
src = (prefix+data).decode(encoding='ascii')
-
return '<img src="{}">'.format(src)
-
def asDataUri(self, strip_chars=STRIP_CHARS):
-
''' Returns a data URI with base64 encoding. '''
-
data = self.asSvg()
-
search = re.compile('|'.join(strip_chars))
-
data_safe = search.sub(lambda m: '', data)
-
b64 = base64.b64encode(data_safe.encode())
-
return 'data:image/svg+xml;base64,' + b64.decode(encoding='ascii')
-
def asUtf8DataUri(self, unsafe_chars='"', strip_chars=STRIP_CHARS):
-
''' Returns a data URI without base64 encoding.
-
-
The characters '#&%' are always escaped. '#' and '&' break parsing
-
of the data URI. If '%' is not escaped, plain text like '%50' will
-
be incorrectly decoded to 'P'. The characters in `strip_chars`
-
cause the SVG not to render even if they are escaped. '''
-
data = self.asSvg()
-
unsafe_chars = (unsafe_chars or '') + '#&%'
-
replacements = {
-
char: urllib.parse.quote(char, safe='')
-
for char in unsafe_chars
-
}
-
replacements.update({
-
char: ''
-
for char in strip_chars
-
})
-
search = re.compile('|'.join(map(re.escape, replacements.keys())))
-
data_safe = search.sub(lambda m: replacements[m.group(0)], data)
-
return 'data:image/svg+xml;utf8,' + data_safe
+
'''Display in Jupyter notebook.'''
+
return self.as_svg(randomize_ids=True)
+
def display_inline(self):
+
'''Display inline in the Jupyter web page.'''
+
return jupyter.JupyterSvgInline(self.as_svg(randomize_ids=True))
+
def display_iframe(self):
+
'''Display within an iframe the Jupyter web page.'''
+
w, h = self.calc_render_size()
+
return jupyter.JupyterSvgFrame(self.as_svg(), w, h)
+
def display_image(self):
+
'''Display within an img in the Jupyter web page.'''
+
return jupyter.JupyterSvgImage(self.as_svg())
+437 -380
drawSvg/elements.py
···
-
-
import sys
import math
import os.path
import base64
···
import xml.sax.saxutils as xml
from collections import defaultdict
-
from . import defs
+
from . import defs, url_encode
-
elementsModule = sys.modules[__name__]
-
# TODO: Support drawing ellipses without manually using Path
-
-
def writeXmlNodeArgs(args, outputFile):
+
def write_xml_node_args(args, output_file, id_map=None):
for k, v in args.items():
if v is None: continue
if isinstance(v, DrawingElement):
-
if v.id is None:
+
mapped_id = v.id
+
if id_map and id(v) in id_map:
+
mapped_id = id_map[id(v)]
+
if mapped_id is None:
continue
if k == 'xlink:href':
-
v = '#{}'.format(v.id)
+
v = '#{}'.format(mapped_id)
else:
-
v = 'url(#{})'.format(v.id)
-
outputFile.write(' {}="{}"'.format(k,v))
+
v = 'url(#{})'.format(mapped_id)
+
output_file.write(' {}="{}"'.format(k,v))
class DrawingElement:
-
''' Base class for drawing elements
+
'''Base class for drawing elements.
-
Subclasses must implement writeSvgElement '''
-
def writeSvgElement(self, idGen, isDuplicate, outputFile, dryRun,
-
forceDup=False):
+
Subclasses must implement write_svg_element.
+
'''
+
def write_svg_element(self, id_map, is_duplicate, output_file, dry_run,
+
force_dup=False):
raise NotImplementedError('Abstract base class')
-
def getSvgDefs(self):
+
def get_svg_defs(self):
return ()
-
def getLinkedElems(self):
+
def get_linked_elems(self):
return ()
-
def writeSvgDefs(self, idGen, isDuplicate, outputFile, dryRun):
-
for defn in self.getSvgDefs():
-
if isDuplicate(defn): continue
-
defn.writeSvgDefs(idGen, isDuplicate, outputFile, dryRun)
+
def write_svg_defs(self, id_map, is_duplicate, output_file, dry_run):
+
for defn in self.get_svg_defs():
+
if is_duplicate(defn):
+
continue
+
defn.write_svg_defs(id_map, is_duplicate, output_file, dry_run)
if defn.id is None:
-
defn.id = idGen()
-
defn.writeSvgElement(idGen, isDuplicate, outputFile, dryRun,
-
forceDup=True)
-
if not dryRun:
-
outputFile.write('\n')
+
id_map[id(defn)]
+
defn.write_svg_element(
+
id_map, is_duplicate, output_file, dry_run, force_dup=True)
+
if not dry_run:
+
output_file.write('\n')
def __eq__(self, other):
return self is other
class DrawingBasicElement(DrawingElement):
-
''' Base class for SVG drawing elements that are a single node with no
-
child nodes '''
+
'''Base class for SVG drawing elements that are a single node with no child
+
nodes.
+
'''
TAG_NAME = '_'
-
hasContent = False
+
has_content = False
def __init__(self, **args):
self.args = {}
for k, v in args.items():
···
k = k[:-1]
self.args[k] = v
self.children = []
-
self.orderedChildren = defaultdict(list)
-
def checkChildrenAllowed(self):
-
if not self.hasContent:
+
self.ordered_children = defaultdict(list)
+
def check_children_allowed(self):
+
if not self.has_content:
raise RuntimeError(
-
'{} does not support children'.format(type(self)))
-
def allChildren(self):
-
''' Returns self.children and self.orderedChildren as a single list. '''
+
'{} does not support children'.format(type(self)))
+
def all_children(self):
+
'''Return self.children and self.ordered_children as a single list.'''
output = list(self.children)
-
for z in sorted(self.orderedChildren):
-
output.extend(self.orderedChildren[z])
+
for z in sorted(self.ordered_children):
+
output.extend(self.ordered_children[z])
return output
@property
def id(self):
return self.args.get('id', None)
-
@id.setter
-
def id(self, newId):
-
self.args['id'] = newId
-
def writeSvgElement(self, idGen, isDuplicate, outputFile, dryRun,
-
forceDup=False):
-
children = self.allChildren()
-
if dryRun:
-
if isDuplicate(self) and self.id is None:
-
self.id = idGen()
-
for elem in self.getLinkedElems():
+
def write_svg_element(self, id_map, is_duplicate, output_file, dry_run,
+
force_dup=False):
+
children = self.all_children()
+
if dry_run:
+
if is_duplicate(self) and self.id is None:
+
id_map[id(self)]
+
for elem in self.get_linked_elems():
if elem.id is None:
-
elem.id = idGen()
-
if self.hasContent:
-
self.writeContent(idGen, isDuplicate, outputFile, dryRun)
+
id_map[id(elem.id)]
+
if self.has_content:
+
self.write_content(id_map, is_duplicate, output_file, dry_run)
if children:
-
self.writeChildrenContent(idGen, isDuplicate, outputFile,
-
dryRun)
+
self.write_children_content(
+
id_map, is_duplicate, output_file, dry_run)
return
-
if isDuplicate(self) and not forceDup:
-
outputFile.write('<use xlink:href="#{}" />'.format(self.id))
+
if is_duplicate(self) and not force_dup:
+
mapped_id = self.id
+
if id_map and id(self) in id_map:
+
mapped_id = id_map[id(self)]
+
output_file.write('<use xlink:href="#{}" />'.format(mapped_id))
return
-
outputFile.write('<')
-
outputFile.write(self.TAG_NAME)
-
writeXmlNodeArgs(self.args, outputFile)
-
if not self.hasContent and not children:
-
outputFile.write(' />')
+
output_file.write('<')
+
output_file.write(self.TAG_NAME)
+
override_args = self.args
+
if id(self) in id_map:
+
override_args = dict(override_args)
+
override_args['id'] = id_map[id(self)]
+
write_xml_node_args(override_args, output_file, id_map)
+
if not self.has_content and not children:
+
output_file.write(' />')
else:
-
outputFile.write('>')
-
if self.hasContent:
-
self.writeContent(idGen, isDuplicate, outputFile, dryRun)
+
output_file.write('>')
+
if self.has_content:
+
self.write_content(id_map, is_duplicate, output_file, dry_run)
if children:
-
self.writeChildrenContent(idGen, isDuplicate, outputFile,
-
dryRun)
-
outputFile.write('</')
-
outputFile.write(self.TAG_NAME)
-
outputFile.write('>')
-
def writeContent(self, idGen, isDuplicate, outputFile, dryRun):
-
''' Override in a subclass to add data between the start and end
-
tags. This will not be called if hasContent is False. '''
+
self.write_children_content(
+
id_map, is_duplicate, output_file, dry_run)
+
output_file.write('</')
+
output_file.write(self.TAG_NAME)
+
output_file.write('>')
+
def write_content(self, id_map, is_duplicate, output_file, dry_run):
+
'''Override in a subclass to add data between the start and end tags.
+
+
This will not be called if has_content is False.
+
'''
raise RuntimeError('This element has no content')
-
def writeChildrenContent(self, idGen, isDuplicate, outputFile, dryRun):
-
''' Override in a subclass to add data between the start and end
-
tags. This will not be called if hasContent is False. '''
-
children = self.allChildren()
-
if dryRun:
+
def write_children_content(self, id_map, is_duplicate, output_file,
+
dry_run):
+
'''Override in a subclass to add data between the start and end tags.
+
+
This will not be called if has_content is False.
+
'''
+
children = self.all_children()
+
if dry_run:
for child in children:
-
child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun)
+
child.write_svg_element(
+
id_map, is_duplicate, output_file, dry_run)
return
-
outputFile.write('\n')
+
output_file.write('\n')
for child in children:
-
child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun)
-
outputFile.write('\n')
-
def getSvgDefs(self):
+
child.write_svg_element(id_map, is_duplicate, output_file, dry_run)
+
output_file.write('\n')
+
def get_svg_defs(self):
return [v for v in self.args.values()
if isinstance(v, DrawingElement)]
-
def writeSvgDefs(self, idGen, isDuplicate, outputFile, dryRun):
-
super().writeSvgDefs(idGen, isDuplicate, outputFile, dryRun)
-
for child in self.allChildren():
-
child.writeSvgDefs(idGen, isDuplicate, outputFile, dryRun)
+
def write_svg_defs(self, id_map, is_duplicate, output_file, dry_run):
+
super().write_svg_defs(id_map, is_duplicate, output_file, dry_run)
+
for child in self.all_children():
+
child.write_svg_defs(id_map, is_duplicate, output_file, dry_run)
def __eq__(self, other):
if isinstance(other, type(self)):
return (self.TAG_NAME == other.TAG_NAME and
self.args == other.args and
self.children == other.children and
-
self.orderedChildren == other.orderedChildren)
+
self.ordered_children == other.ordered_children)
return False
-
def appendAnim(self, animateElement):
-
self.children.append(animateElement)
-
def extendAnim(self, animateIterable):
-
self.children.extend(animateIterable)
-
def appendTitle(self, text, **kwargs):
+
def append_anim(self, animate_element):
+
self.children.append(animate_element)
+
def extend_anim(self, animate_iterable):
+
self.children.extend(animate_iterable)
+
def append_title(self, text, **kwargs):
self.children.append(Title(text, **kwargs))
class DrawingParentElement(DrawingBasicElement):
-
''' Base class for SVG elements that can have child nodes '''
-
hasContent = True
-
def __init__(self, children=(), orderedChildren=None, **args):
+
'''Base class for SVG elements that can have child nodes.'''
+
has_content = True
+
def __init__(self, children=(), ordered_children=None, **args):
super().__init__(**args)
self.children = list(children)
-
if orderedChildren:
-
self.orderedChildren.update(
-
(z, list(v)) for z, v in orderedChildren.items())
-
if self.children or self.orderedChildren:
-
self.checkChildrenAllowed()
+
if ordered_children:
+
self.ordered_children.update(
+
(z, list(v)) for z, v in ordered_children.items())
+
if self.children or self.ordered_children:
+
self.check_children_allowed()
def draw(self, obj, *, z=None, **kwargs):
if obj is None:
return
-
if not hasattr(obj, 'writeSvgElement'):
-
elements = obj.toDrawables(elements=elementsModule, **kwargs)
+
if not hasattr(obj, 'write_svg_element'):
+
elements = obj.to_drawables(**kwargs)
else:
assert len(kwargs) == 0
-
elements = (obj,)
-
self.extend(elements, z=z)
+
elements = obj
+
if hasattr(elements, 'write_svg_element'):
+
self.append(elements, z=z)
+
else:
+
self.extend(elements, z=z)
def append(self, element, *, z=None):
-
self.checkChildrenAllowed()
+
self.check_children_allowed()
if z is not None:
-
self.orderedChildren[z].append(element)
+
self.ordered_children[z].append(element)
else:
self.children.append(element)
def extend(self, iterable, *, z=None):
-
self.checkChildrenAllowed()
+
self.check_children_allowed()
if z is not None:
-
self.orderedChildren[z].extend(iterable)
+
self.ordered_children[z].extend(iterable)
else:
self.children.extend(iterable)
-
def writeContent(self, idGen, isDuplicate, outputFile, dryRun):
+
def write_content(self, id_map, is_duplicate, output_file, dry_run):
pass
class NoElement(DrawingElement):
''' A drawing element that has no effect '''
-
def __init__(self): pass
-
def writeSvgElement(self, idGen, isDuplicate, outputFile, dryRun,
-
forceDup=False):
+
def __init__(self):
+
pass
+
def write_svg_element(self, id_map, is_duplicate, output_file, dry_run,
+
force_dup=False):
pass
def __eq__(self, other):
if isinstance(other, type(self)):
···
return False
class Group(DrawingParentElement):
-
''' A group of drawing elements
+
'''A group of drawing elements.
-
Any transform will apply to its children and other attributes will be
-
inherited by its children. '''
+
Any transform will apply to its children and other attributes will be
+
inherited by its children.
+
'''
TAG_NAME = 'g'
-
class Raw(Group):
-
''' Any any SVG code to insert into the output. '''
-
def __init__(self, content, defs=(), **kwargs):
-
super().__init__(**kwargs)
+
class Raw(DrawingBasicElement):
+
'''Raw unescaped text to include in the SVG output.
+
+
Special XML characters like '<' and '&' in the content may have unexpected
+
effects or completely break the resulting SVG.
+
'''
+
has_content = True
+
def __init__(self, content, defs=()):
+
super().__init__()
self.content = content
self.defs = defs
-
def writeContent(self, idGen, isDuplicate, outputFile, dryRun):
-
if dryRun:
+
def write_content(self, id_map, is_duplicate, output_file, dry_run):
+
if dry_run:
return
-
outputFile.write(self.content)
-
def getSvgDefs(self):
+
output_file.write(self.content)
+
def get_svg_defs(self):
return self.defs
+
def check_children_allowed(self):
+
raise RuntimeError('{} does not support children'.format(type(self)))
class Use(DrawingBasicElement):
-
''' A copy of another element
+
'''A copy of another element, drawn at a given position
-
The other element becomes an SVG def shared between all Use elements
-
that reference it. '''
+
The referenced element becomes an SVG def shared between all Use elements
+
that reference it. Useful for drawings with many copies of similar shapes.
+
Additional arguments like `fill='red'` will be used as the default for this
+
copy of the shapes.
+
'''
TAG_NAME = 'use'
-
def __init__(self, otherElem, x, y, **kwargs):
-
y = -y
-
if isinstance(otherElem, str) and not otherElem.startswith('#'):
-
otherElem = '#' + otherElem
-
super().__init__(xlink__href=otherElem, x=x, y=y, **kwargs)
+
def __init__(self, other_elem, x, y, **kwargs):
+
if isinstance(other_elem, str) and not other_elem.startswith('#'):
+
other_elem = '#' + other_elem
+
super().__init__(xlink__href=other_elem, x=x, y=y, **kwargs)
class Animate(DrawingBasicElement):
-
''' Animation for a specific property of another element
+
'''Animation for a specific property of another element.
+
+
This should be added as a child of the element to animate. Otherwise the
+
referenced other element and this element must both be added to the drawing.
-
This should be added as a child of the element to animate. Otherwise
-
the other element and this element must both be added to the drawing.
+
Useful SVG attributes:
+
- repeatCount: 0, 1, ..., 'indefinite'
'''
TAG_NAME = 'animate'
def __init__(self, attributeName, dur, from_or_values=None, to=None,
-
begin=None, otherElem=None, **kwargs):
+
begin=None, other_elem=None, **kwargs):
if to is None:
values = from_or_values
from_ = None
else:
values = None
from_ = from_or_values
-
if isinstance(otherElem, str) and not otherElem.startswith('#'):
-
otherElem = '#' + otherElem
+
if isinstance(other_elem, str) and not other_elem.startswith('#'):
+
other_elem = '#' + other_elem
kwargs.update(attributeName=attributeName, to=to, dur=dur, begin=begin)
kwargs.setdefault('values', values)
kwargs.setdefault('from_', from_)
-
super().__init__(xlink__href=otherElem, **kwargs)
+
super().__init__(xlink__href=other_elem, **kwargs)
-
def getSvgDefs(self):
+
def get_svg_defs(self):
return [v for k, v in self.args.items()
if isinstance(v, DrawingElement)
if k != 'xlink:href']
-
def getLinkedElems(self):
-
return (self.args['xlink:href'],)
+
def get_linked_elems(self):
+
elem = self.args['xlink:href']
+
return (elem,) if elem is not None else ()
class _Mpath(DrawingBasicElement):
-
''' Used by AnimateMotion '''
+
'''Used by AnimateMotion.'''
TAG_NAME = 'mpath'
-
def __init__(self, otherPath, **kwargs):
-
super().__init__(xlink__href=otherPath, **kwargs)
+
def __init__(self, other_path, **kwargs):
+
super().__init__(xlink__href=other_path, **kwargs)
class AnimateMotion(Animate):
-
''' Animation for the motion another element along a path
+
'''Animation for the motion of another element along a path.
-
This should be added as a child of the element to animate. Otherwise
-
the other element and this element must both be added to the drawing.
+
This should be added as a child of the element to animate. Otherwise the
+
referenced other element and this element must both be added to the drawing.
'''
TAG_NAME = 'animateMotion'
def __init__(self, path, dur, from_or_values=None, to=None, begin=None,
-
otherElem=None, **kwargs):
-
useMpath = False
+
other_elem=None, **kwargs):
+
use_mpath = False
if isinstance(path, DrawingElement):
-
useMpath = True
-
pathElem = path
+
use_mpath = True
+
path_elem = path
path = None
kwargs.setdefault('attributeName', None)
super().__init__(dur=dur, from_or_values=from_or_values, to=to,
-
begin=begin, path=path, otherElem=otherElem, **kwargs)
-
if useMpath:
-
self.children.append(_Mpath(pathElem))
+
begin=begin, path=path, other_elem=other_elem,
+
**kwargs)
+
if use_mpath:
+
self.children.append(_Mpath(path_elem))
class AnimateTransform(Animate):
-
''' Animation for the transform property of another element
+
'''Animation for the transform property of another element.
-
This should be added as a child of the element to animate. Otherwise
-
the other element and this element must both be added to the drawing.
+
This should be added as a child of the element to animate. Otherwise the
+
referenced other element and this element must both be added to the drawing.
'''
TAG_NAME = 'animateTransform'
def __init__(self, type, dur, from_or_values, to=None, begin=None,
-
attributeName='transform', otherElem=None, **kwargs):
+
attributeName='transform', other_elem=None, **kwargs):
super().__init__(attributeName, dur=dur, from_or_values=from_or_values,
-
to=to, begin=begin, type=type, otherElem=otherElem,
+
to=to, begin=begin, type=type, other_elem=other_elem,
**kwargs)
class Set(Animate):
-
''' Animation for a specific property of another element that sets the new
-
value without a transition.
+
'''Animation for a specific property of another element that sets the new
+
value without a transition.
-
This should be added as a child of the element to animate. Otherwise
-
the other element and this element must both be added to the drawing.
+
This should be added as a child of the element to animate. Otherwise the
+
referenced other element and this element must both be added to the drawing.
'''
TAG_NAME = 'set'
def __init__(self, attributeName, dur, to=None, begin=None,
-
otherElem=None, **kwargs):
+
other_elem=None, **kwargs):
super().__init__(attributeName, dur=dur, from_or_values=None,
-
to=to, begin=begin, otherElem=otherElem, **kwargs)
+
to=to, begin=begin, other_elem=other_elem, **kwargs)
class Discard(Animate):
-
''' Animation configuration specifying when it is safe to discard another
-
element. E.g. when it will no longer be visible after an animation.
+
'''Animation configuration specifying when it is safe to discard another
+
element.
-
This should be added as a child of the element to animate. Otherwise
-
the other element and this element must both be added to the drawing.
+
Use this when an element will no longer be visible after an animation.
+
This should be added as a child of the element to animate. Otherwise the
+
referenced other element and this element must both be added to the drawing.
'''
TAG_NAME = 'discard'
def __init__(self, attributeName, begin=None, **kwargs):
kwargs.setdefault('attributeName', None)
kwargs.setdefault('to', None)
kwargs.setdefault('dur', None)
-
super().__init__(from_or_values=None, begin=begin, otherElem=None,
+
super().__init__(from_or_values=None, begin=begin, other_elem=None,
**kwargs)
class Image(DrawingBasicElement):
-
''' A linked or embedded raster image '''
+
'''A linked or embedded image.'''
TAG_NAME = 'image'
MIME_MAP = {
'.bm': 'image/bmp',
···
}
MIME_DEFAULT = 'image/png'
def __init__(self, x, y, width, height, path=None, data=None, embed=False,
-
mimeType=None, **kwargs):
-
''' Specify either the path or data argument. If path is used and
-
embed is True, the image file is embedded in a data URI. '''
+
mime_type=None, **kwargs):
+
'''
+
Specify either the path or data argument. If path is used and embed is
+
True, the image file is embedded in a data URI.
+
'''
if path is None and data is None:
raise ValueError('Either path or data arguments must be given')
if embed:
-
if mimeType is None and path is not None:
+
if mime_type is None and path is not None:
ext = os.path.splitext(path)[1].lower()
if ext in self.MIME_MAP:
-
mimeType = self.MIME_MAP[ext]
+
mime_type = self.MIME_MAP[ext]
else:
-
mimeType = self.MIME_DEFAULT
+
mime_type = self.MIME_DEFAULT
warnings.warn('Unknown image file type "{}"'.format(ext),
Warning)
-
if mimeType is None:
-
mimeType = self.MIME_DEFAULT
+
if mime_type is None:
+
mime_type = self.MIME_DEFAULT
warnings.warn('Unspecified image type; assuming png', Warning)
if data is not None:
embed = True
···
if not embed:
uri = path
else:
-
encData = base64.b64encode(data).decode(encoding='ascii')
-
uri = 'data:{};base64,{}'.format(mimeType, encData)
-
super().__init__(x=x, y=-y-height, width=width, height=height,
-
xlink__href=uri, **kwargs)
+
uri = url_encode.bytes_as_data_uri(data, mime=mime_type)
+
super().__init__(x=x, y=y, width=width, height=height, xlink__href=uri,
+
**kwargs)
class Text(DrawingParentElement):
-
''' Text
+
'''A line or multiple lines of text, optionally placed along a path.
+
+
Additional keyword arguments are output as additional arguments to the SVG
+
node e.g. fill='red', font_size=20, letter_spacing=1.5.
-
Additional keyword arguments are output as additional arguments to the
-
SVG node e.g. fill='red', font_size=20, text_anchor='middle',
-
letter_spacing=1.5.
+
Useful SVG attributes:
+
- text_anchor: start, middle, end
+
- dominant_baseline: auto, central, middle, hanging, text-top, ...
+
See https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text
-
CairoSVG bug with letter spacing text on a path: The first two letters
-
are always spaced as if letter_spacing=1. '''
+
CairoSVG bug with letter spacing text on a path: The first two letters are
+
always spaced as if letter_spacing=1.
+
'''
TAG_NAME = 'text'
-
hasContent = True
-
def __new__(cls, text, *args, path=None, id=None, _skipCheck=False,
+
has_content = True
+
def __new__(cls, text, *args, path=None, id=None, _skip_check=False,
**kwargs):
# Check for the special case of multi-line text on a path
# This is inconsistently implemented by renderers so we return a group
# of single-line text on paths instead.
-
if path is not None and not _skipCheck:
-
text, _ = cls._handleTextArgument(text, True)
+
if path is not None and not _skip_check:
+
text, _ = cls._handle_text_argument(text, True)
if len(text) > 1:
# Special case
g = Group(id=id)
for i, line in enumerate(text):
subtext = [None] * len(text)
subtext[i] = line
-
g.append(Text(subtext, *args, path=path, _skipCheck=True,
+
g.append(Text(subtext, *args, path=path, _skip_check=True,
**kwargs))
return g
return super().__new__(cls)
-
def __init__(self, text, fontSize, x=None, y=None, *, center=False,
-
valign=None, lineHeight=1, lineOffset=0, path=None,
-
startOffset=None, pathArgs=None, tspanArgs=None,
-
cairoFix=True, _skipCheck=False, **kwargs):
+
def __init__(self, text, font_size, x=None, y=None, *, center=False,
+
line_height=1, line_offset=0, path=None, start_offset=None,
+
path_args=None, tspan_args=None, cairo_fix=True,
+
_skip_check=False, **kwargs):
# Check argument requirements
if path is None:
if x is None or y is None:
raise TypeError(
"__init__() missing required arguments: 'x' and 'y' "
"are required unless 'path' is specified")
-
try:
-
y = -y
-
except TypeError:
-
pass
else:
if x is not None or y is not None:
raise TypeError(
"__init__() conflicting arguments: 'x' and 'y' "
"should not be used when 'path' is specified")
-
if pathArgs is None:
-
pathArgs = {}
-
if startOffset is not None:
-
pathArgs.setdefault('startOffset', startOffset)
-
if tspanArgs is None:
-
tspanArgs = {}
-
onPath = path is not None
+
if path_args is None:
+
path_args = {}
+
if start_offset is not None:
+
path_args.setdefault('startOffset', start_offset)
+
if tspan_args is None:
+
tspan_args = {}
+
on_path = path is not None
-
text, singleLine = self._handleTextArgument(text, forceMulti=onPath)
-
numLines = len(text)
+
text, single_line = self._handle_text_argument(
+
text, force_multi=on_path)
+
num_lines = len(text)
# Text alignment
-
centerCompat = False
-
if center and valign is None:
-
valign = 'middle'
-
centerCompat = singleLine and not onPath
-
if center and kwargs.get('text_anchor') is None:
-
kwargs['text_anchor'] = 'middle'
-
if valign == 'middle':
-
if centerCompat: # Backwards compatible centering
-
lineOffset += 0.5 * center
+
if center:
+
kwargs.setdefault('text_anchor', 'middle')
+
if path is None and single_line:
+
kwargs.setdefault('dominant_baseline', 'central')
else:
-
lineOffset += 0.4 - lineHeight * (numLines - 1) / 2
-
elif valign == 'top':
-
lineOffset += 1
-
elif valign == 'bottom':
-
lineOffset += -lineHeight * (numLines - 1)
-
if singleLine:
-
dy = '{}em'.format(lineOffset)
-
kwargs.setdefault('dy', dy)
+
line_offset += 0.5
+
line_offset -= line_height * (num_lines - 1) / 2
# Text alignment on a path
-
if onPath:
+
if on_path:
if kwargs.get('text_anchor') == 'start':
-
pathArgs.setdefault('startOffset', '0')
+
path_args.setdefault('startOffset', '0')
elif kwargs.get('text_anchor') == 'middle':
-
pathArgs.setdefault('startOffset', '50%')
+
path_args.setdefault('startOffset', '50%')
elif kwargs.get('text_anchor') == 'end':
-
if cairoFix and 'startOffset' not in pathArgs:
+
if cairo_fix and 'startOffset' not in path_args:
# Fix CairoSVG not drawing the last character with aligned
# right
-
tspanArgs.setdefault('dx', -1)
-
pathArgs.setdefault('startOffset', '100%')
+
tspan_args.setdefault('dx', -1)
+
path_args.setdefault('startOffset', '100%')
-
super().__init__(x=x, y=y, font_size=fontSize, **kwargs)
-
self._textPath = None
-
if singleLine:
-
self.escapedText = xml.escape(text[0])
+
super().__init__(x=x, y=y, font_size=font_size, **kwargs)
+
self._text_path = None
+
if single_line:
+
self.escaped_text = xml.escape(text[0])
else:
# Add elements for each line of text
-
self.escapedText = ''
+
self.escaped_text = ''
if path is None:
# Text is an iterable
for i, line in enumerate(text):
-
dy = '{}em'.format(lineOffset if i == 0 else lineHeight)
-
self.appendLine(line, x=x, dy=dy, **tspanArgs)
+
dy = '{}em'.format(line_offset if i == 0 else line_height)
+
self.append_line(line, x=x, dy=dy, **tspan_args)
else:
-
self._textPath = _TextPath(path, **pathArgs)
+
self._text_path = _TextPath(path, **path_args)
assert sum(bool(line) for line in text) <= 1, (
'Logic error, __new__ should handle multi-line paths')
for i, line in enumerate(text):
-
if not line: continue
-
dy = '{}em'.format(lineOffset + i*lineHeight)
-
tspan = TSpan(line, dy=dy, **tspanArgs)
-
self._textPath.append(tspan)
-
self.append(self._textPath)
+
if not line:
+
continue
+
dy = '{}em'.format(line_offset + i*line_height)
+
tspan = TSpan(line, dy=dy, **tspan_args)
+
self._text_path.append(tspan)
+
self.append(self._text_path)
@staticmethod
-
def _handleTextArgument(text, forceMulti=False):
+
def _handle_text_argument(text, force_multi=False):
# Handle multi-line text (contains '\n' or is a list of strings)
-
singleLine = isinstance(text, str)
if isinstance(text, str):
-
singleLine = '\n' not in text and not forceMulti
-
if singleLine:
+
single_line = '\n' not in text and not force_multi
+
if single_line:
text = (text,)
else:
text = tuple(text.splitlines())
-
singleLine = False
else:
-
singleLine = False
+
single_line = False
text = tuple(text)
-
return text, singleLine
-
def writeContent(self, idGen, isDuplicate, outputFile, dryRun):
-
if dryRun:
+
return text, single_line
+
def write_content(self, id_map, is_duplicate, output_file, dry_run):
+
if dry_run:
return
-
outputFile.write(self.escapedText)
-
def writeChildrenContent(self, idGen, isDuplicate, outputFile, dryRun):
-
''' Override in a subclass to add data between the start and end
-
tags. This will not be called if hasContent is False. '''
-
children = self.allChildren()
-
if dryRun:
-
for child in children:
-
child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun)
-
return
+
output_file.write(self.escaped_text)
+
def write_children_content(self, id_map, is_duplicate, output_file,
+
dry_run):
+
children = self.all_children()
for child in children:
-
child.writeSvgElement(idGen, isDuplicate, outputFile, dryRun)
-
def appendLine(self, line, **kwargs):
-
if self._textPath is not None:
+
child.write_svg_element(id_map, is_duplicate, output_file, dry_run)
+
def append_line(self, line, **kwargs):
+
if self._text_path is not None:
raise ValueError('appendLine is not supported for text on a path')
self.append(TSpan(line, **kwargs))
class _TextPath(DrawingParentElement):
TAG_NAME = 'textPath'
-
hasContent = True
+
has_content = True
def __init__(self, path, **kwargs):
super().__init__(xlink__href=path, **kwargs)
class _TextContainingElement(DrawingBasicElement):
''' A private parent class used for elements that only have plain text
content. '''
-
hasContent = True
+
has_content = True
def __init__(self, text, **kwargs):
super().__init__(**kwargs)
-
self.escapedText = xml.escape(text)
-
def writeContent(self, idGen, isDuplicate, outputFile, dryRun):
-
if dryRun:
+
self.escaped_text = xml.escape(text)
+
def write_content(self, id_map, is_duplicate, output_file, dry_run):
+
if dry_run:
return
-
outputFile.write(self.escapedText)
+
output_file.write(self.escaped_text)
class TSpan(_TextContainingElement):
''' A line of text within the Text element. '''
TAG_NAME = 'tspan'
class Title(_TextContainingElement):
-
''' A title element.
+
'''A title element.
-
This element can be appended with shape.appendTitle("Your title!"),
-
which can be useful for adding a tooltip or on-hover text display
-
to an element.
+
This element can be appended with shape.append_title("Your title!"), which
+
can be useful for adding a tooltip or on-hover text display to an element.
'''
TAG_NAME = 'title'
class Rectangle(DrawingBasicElement):
-
''' A rectangle
+
'''A rectangle.
-
Additional keyword arguments are output as additional arguments to the
-
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
Additional keyword arguments are output as additional arguments to the SVG
+
node e.g. fill="red", stroke="#ff4477", stroke_width=2.
+
'''
TAG_NAME = 'rect'
def __init__(self, x, y, width, height, **kwargs):
-
try:
-
y = -y-height
-
except TypeError:
-
pass
-
super().__init__(x=x, y=y, width=width, height=height,
-
**kwargs)
+
super().__init__(x=x, y=y, width=width, height=height, **kwargs)
class Circle(DrawingBasicElement):
-
''' A circle
+
'''A circle.
-
Additional keyword arguments are output as additional properties to the
-
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
Additional keyword arguments are output as additional arguments to the SVG
+
node e.g. fill="red", stroke="#ff4477", stroke_width=2.
+
'''
TAG_NAME = 'circle'
def __init__(self, cx, cy, r, **kwargs):
-
try:
-
cy = -cy
-
except TypeError:
-
pass
super().__init__(cx=cx, cy=cy, r=r, **kwargs)
class Ellipse(DrawingBasicElement):
-
''' An ellipse
+
'''An ellipse.
-
Additional keyword arguments are output as additional properties to the
-
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
Additional keyword arguments are output as additional arguments to the SVG
+
node e.g. fill="red", stroke="#ff4477", stroke_width=2.
+
'''
TAG_NAME = 'ellipse'
def __init__(self, cx, cy, rx, ry, **kwargs):
-
try:
-
cy = -cy
-
except TypeError:
-
pass
super().__init__(cx=cx, cy=cy, rx=rx, ry=ry, **kwargs)
class ArcLine(Circle):
-
''' An arc
+
'''An arc.
-
In most cases, use Arc instead of ArcLine. ArcLine uses the
-
stroke-dasharray SVG property to make the edge of a circle look like
-
an arc.
+
In most cases, use Arc instead of ArcLine. ArcLine uses the
+
stroke-dasharray SVG property to make the edge of a circle look like an arc.
-
Additional keyword arguments are output as additional arguments to the
-
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
-
def __init__(self, cx, cy, r, startDeg, endDeg, **kwargs):
-
if endDeg - startDeg == 360:
+
Additional keyword arguments are output as additional arguments to the SVG
+
node e.g. fill="red", stroke="#ff4477", stroke_width=2.
+
'''
+
def __init__(self, cx, cy, r, start_deg, end_deg, **kwargs):
+
if end_deg - start_deg == 360:
super().__init__(cx, cy, r, **kwargs)
return
-
startDeg, endDeg = (-endDeg) % 360, (-startDeg) % 360
-
arcDeg = (endDeg - startDeg) % 360
-
def arcLen(deg): return math.radians(deg) * r
-
wholeLen = 2 * math.pi * r
-
if endDeg == startDeg:
+
start_deg, end_deg = (-end_deg) % 360, (-start_deg) % 360
+
arc_deg = (end_deg - start_deg) % 360
+
def arc_len(deg):
+
return math.radians(deg) * r
+
whole_len = 2 * math.pi * r
+
if end_deg == start_deg:
offset = 1
-
dashes = "0 {}".format(wholeLen+2)
-
#elif endDeg >= startDeg:
-
elif True:
-
startLen = arcLen(startDeg)
-
arcLen = arcLen(arcDeg)
-
offLen = wholeLen - arcLen
-
offset = -startLen
-
dashes = "{} {}".format(arcLen, offLen)
-
#else:
-
# firstLen = arcLen(endDeg)
-
# secondLen = arcLen(360-startDeg)
-
# gapLen = wholeLen - firstLen - secondLen
-
# offset = 0
-
# dashes = "{} {} {}".format(firstLen, gapLen, secondLen)
+
dashes = "0 {}".format(whole_len+2)
+
else:
+
start_len = arc_len(start_deg)
+
arc_len = arc_len(arc_deg)
+
off_len = whole_len - arc_len
+
offset = -start_len
+
dashes = "{} {}".format(arc_len, off_len)
super().__init__(cx, cy, r, stroke_dasharray=dashes,
stroke_dashoffset=offset, **kwargs)
class Path(DrawingBasicElement):
-
''' An arbitrary path
+
'''An arbitrary path.
-
Path Supports building an SVG path by calling instance methods
-
corresponding to path commands.
+
Path Supports building an SVG path by calling instance methods corresponding
+
to path commands.
-
Additional keyword arguments are output as additional properties to the
-
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
Complete descriptions of path commands:
+
https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#path_commands
+
+
Additional keyword arguments are output as additional arguments to the SVG
+
node e.g. fill="red", stroke="#ff4477", stroke_width=2.
+
'''
TAG_NAME = 'path'
def __init__(self, d='', **kwargs):
super().__init__(d=d, **kwargs)
-
def append(self, commandStr, *args):
+
def append(self, command_str, *args):
if len(self.args['d']) > 0:
-
commandStr = ' ' + commandStr
+
command_str = ' ' + command_str
if len(args) > 0:
-
commandStr = commandStr + ','.join(map(str, args))
-
self.args['d'] += commandStr
+
command_str = command_str + ','.join(map(str, args))
+
self.args['d'] += command_str
return self
-
def M(self, x, y): return self.append('M', x, -y)
-
def m(self, dx, dy): return self.append('m', dx, -dy)
-
def L(self, x, y): return self.append('L', x, -y)
-
def l(self, dx, dy): return self.append('l', dx, -dy)
-
def H(self, x): return self.append('H', x)
-
def h(self, dx): return self.append('h', dx)
-
def V(self, y): return self.append('V', -y)
-
def v(self, dy): return self.append('v', -dy)
-
def Z(self): return self.append('Z')
+
def M(self, x, y):
+
'''Start a new curve section from this point.'''
+
return self.append('M', x, y)
+
def m(self, dx, dy):
+
'''Start a new curve section from this point (relative coordinates).'''
+
return self.append('m', dx, dy)
+
def L(self, x, y):
+
'''Draw a line to this point.'''
+
return self.append('L', x, y)
+
def l(self, dx, dy):
+
'''Draw a line to this point (relative coordinates).'''
+
return self.append('l', dx, dy)
+
def H(self, x):
+
'''Draw a horizontal line to this x coordinate.'''
+
return self.append('H', x)
+
def h(self, dx):
+
'''Draw a horizontal line to this relative x coordinate.'''
+
return self.append('h', dx)
+
def V(self, y):
+
'''Draw a horizontal line to this y coordinate.'''
+
return self.append('V', y)
+
def v(self, dy):
+
'''Draw a horizontal line to this relative y coordinate.'''
+
return self.append('v', dy)
+
def Z(self):
+
'''Draw a line back to the previous m or M point.'''
+
return self.append('Z')
def C(self, cx1, cy1, cx2, cy2, ex, ey):
-
return self.append('C', cx1, -cy1, cx2, -cy2, ex, -ey)
+
'''Draw a cubic Bezier curve.'''
+
return self.append('C', cx1, cy1, cx2, cy2, ex, ey)
def c(self, cx1, cy1, cx2, cy2, ex, ey):
-
return self.append('c', cx1, -cy1, cx2, -cy2, ex, -ey)
-
def S(self, cx2, cy2, ex, ey): return self.append('S', cx2, -cy2, ex, -ey)
-
def s(self, cx2, cy2, ex, ey): return self.append('s', cx2, -cy2, ex, -ey)
-
def Q(self, cx, cy, ex, ey): return self.append('Q', cx, -cy, ex, -ey)
-
def q(self, cx, cy, ex, ey): return self.append('q', cx, -cy, ex, -ey)
-
def T(self, ex, ey): return self.append('T', ex, -ey)
-
def t(self, ex, ey): return self.append('t', ex, -ey)
-
def A(self, rx, ry, rot, largeArc, sweep, ex, ey):
-
return self.append('A', rx, ry, rot, int(bool(largeArc)),
-
int(bool(sweep)), ex, -ey)
-
def a(self, rx, ry, rot, largeArc, sweep, ex, ey):
-
return self.append('a', rx, ry, rot, int(bool(largeArc)),
-
int(bool(sweep)), ex, -ey)
-
def arc(self, cx, cy, r, startDeg, endDeg, cw=False, includeM=True,
-
includeL=False):
-
''' Uses A() to draw a circular arc '''
-
largeArc = (endDeg - startDeg) % 360 > 180
-
startRad, endRad = startDeg*math.pi/180, endDeg*math.pi/180
-
sx, sy = r*math.cos(startRad), r*math.sin(startRad)
-
ex, ey = r*math.cos(endRad), r*math.sin(endRad)
-
if includeL:
+
'''Draw a cubic Bezier curve (relative coordinates).'''
+
return self.append('c', cx1, cy1, cx2, cy2, ex, ey)
+
def S(self, cx2, cy2, ex, ey):
+
'''Draw a cubic Bezier curve, transitioning smoothly from the previous.
+
'''
+
return self.append('S', cx2, cy2, ex, ey)
+
def s(self, cx2, cy2, ex, ey):
+
'''Draw a cubic Bezier curve, transitioning smoothly from the previous
+
(relative coordinates).
+
'''
+
return self.append('s', cx2, cy2, ex, ey)
+
def Q(self, cx, cy, ex, ey):
+
'''Draw a quadratic Bezier curve.'''
+
return self.append('Q', cx, cy, ex, ey)
+
def q(self, cx, cy, ex, ey):
+
'''Draw a quadratic Bezier curve (relative coordinates).'''
+
return self.append('q', cx, cy, ex, ey)
+
def T(self, ex, ey):
+
'''Draw a quadratic Bezier curve, transitioning soothly from the
+
previous.
+
'''
+
return self.append('T', ex, ey)
+
def t(self, ex, ey):
+
'''Draw a quadratic Bezier curve, transitioning soothly from the
+
previous (relative coordinates).
+
'''
+
return self.append('t', ex, ey)
+
def A(self, rx, ry, rot, large_arc, sweep, ex, ey):
+
'''Draw a circular or elliptical arc.
+
+
See
+
https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#elliptical_arc_curve
+
'''
+
return self.append('A', rx, ry, rot, int(bool(large_arc)),
+
int(bool(sweep)), ex, ey)
+
def a(self, rx, ry, rot, large_arc, sweep, ex, ey):
+
'''Draw a circular or elliptical arc (relative coordinates).
+
+
See
+
https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#elliptical_arc_curve
+
'''
+
return self.append('a', rx, ry, rot, int(bool(large_arc)),
+
int(bool(sweep)), ex, ey)
+
def arc(self, cx, cy, r, start_deg, end_deg, cw=False, include_m=True,
+
include_l=False):
+
'''Draw a circular arc, controlled by center, radius, and start/end
+
degrees.
+
'''
+
large_arc = (end_deg - start_deg) % 360 > 180
+
start_rad, end_rad = start_deg*math.pi/180, end_deg*math.pi/180
+
sx, sy = r*math.cos(start_rad), -r*math.sin(start_rad)
+
ex, ey = r*math.cos(end_rad), -r*math.sin(end_rad)
+
if include_l:
self.L(cx+sx, cy+sy)
-
elif includeM:
+
elif include_m:
self.M(cx+sx, cy+sy)
-
return self.A(r, r, 0, largeArc ^ cw, cw, cx+ex, cy+ey)
+
return self.A(r, r, 0, large_arc ^ cw, cw, cx+ex, cy+ey)
class Lines(Path):
-
''' A sequence of connected lines (or a polygon)
+
'''A sequence of connected lines (or a polygon).
-
Additional keyword arguments are output as additional properties to the
-
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
Additional keyword arguments are output as additional arguments to the SVG
+
node e.g. fill="red", stroke="#ff4477", stroke_width=2.
+
'''
def __init__(self, sx, sy, *points, close=False, **kwargs):
super().__init__(d='', **kwargs)
self.M(sx, sy)
···
self.Z()
class Line(Lines):
-
''' A line
+
'''A simple line.
-
Additional keyword arguments are output as additional properties to the
-
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
+
Additional keyword arguments are output as additional arguments to the SVG
+
node e.g. fill="red", stroke="#ff4477", stroke_width=2.
+
'''
def __init__(self, sx, sy, ex, ey, **kwargs):
super().__init__(sx, sy, ex, ey, close=False, **kwargs)
class Arc(Path):
-
''' An arc
+
'''A circular arc.
-
Additional keyword arguments are output as additional properties to the
-
SVG node e.g. fill="red", stroke="#ff4477", stroke_width=2. '''
-
def __init__(self, cx, cy, r, startDeg, endDeg, cw=False, **kwargs):
+
Additional keyword arguments are output as additional arguments to the SVG
+
node e.g. fill="red", stroke="#ff4477", stroke_width=2.
+
'''
+
def __init__(self, cx, cy, r, start_deg, end_deg, cw=False, **kwargs):
super().__init__(d='', **kwargs)
-
self.arc(cx, cy, r, startDeg, endDeg, cw=cw, includeM=True)
-
+
self.arc(cx, cy, r, start_deg, end_deg, cw=cw, include_m=True)
+32
drawSvg/jupyter.py
···
+
import dataclasses
+
+
from . import url_encode
+
+
+
@dataclasses.dataclass
+
class JupyterSvgInline:
+
'''Jupyter-displayable SVG displayed inline on the Jupyter web page.'''
+
svg: str
+
def _repr_html_(self):
+
return self.svg
+
+
@dataclasses.dataclass
+
class JupyterSvgImage:
+
'''Jupyter-displayable SVG displayed within an img tag on the Jupyter web
+
page.
+
'''
+
svg: str
+
def _repr_html_(self):
+
uri = url_encode.svg_as_utf8_data_uri(self.svg)
+
return '<img src="{}">'.format(uri)
+
+
@dataclasses.dataclass
+
class JupyterSvgFrame:
+
'''Jupyter-displayable SVG displayed within an HTML iframe.'''
+
svg: str
+
width: float
+
height: float
+
def _repr_html_(self):
+
uri = url_encode.svg_as_utf8_data_uri(self.svg)
+
return (f'<iframe src="{uri}" width="{self.width}" '
+
f'height="{self.height}" style="border:0" />')
-7
drawSvg/missing.py
···
-
-
class MissingModule:
-
def __init__(self, errorMsg):
-
self.errorMsg = errorMsg
-
def __getattr__(self, name):
-
raise RuntimeError(self.errorMsg)
-
+33 -40
drawSvg/raster.py
···
-
import base64
import io
import warnings
-
from .missing import MissingModule
+
from .url_encode import bytes_as_data_uri
try:
import cairosvg
except OSError as e:
-
msg = (
-
'Failed to import CairoSVG. '
+
raise ImportError(
+
'Failed to load CairoSVG. '
'drawSvg will be unable to output PNG or other raster image formats. '
-
'See https://github.com/cduck/drawSvg#prerequisites for more details.\n'
-
'Original OSError: {}'.format(e)
-
)
-
cairosvg = MissingModule(msg)
-
warnings.warn(msg, RuntimeWarning)
+
'See https://github.com/cduck/drawsvg#prerequisites for more details.'
+
) from e
except ImportError as e:
-
msg = (
-
'CairoSVG will need to be installed to rasterize images: Install with `pip3 install cairosvg`\n'
-
'Original ImportError: {}'.format(e)
-
)
-
cairosvg = MissingModule(msg)
-
warnings.warn(msg, RuntimeWarning)
+
raise ImportError(
+
'CairoSVG will need to be installed to rasterize images. '
+
'Install with `pip3 install cairosvg`.'
+
) from e
class Raster:
-
def __init__(self, pngData=None, pngFile=None):
-
self.pngData = pngData
-
self.pngFile = pngFile
-
def savePng(self, fname):
+
def __init__(self, png_data=None, png_file=None):
+
self.png_data = png_data
+
self.png_file = png_file
+
def save_png(self, fname):
with open(fname, 'wb') as f:
-
f.write(self.pngData)
+
f.write(self.png_data)
@staticmethod
-
def fromSvg(svgData):
-
pngData = cairosvg.svg2png(bytestring=svgData)
-
return Raster(pngData)
+
def from_svg(svg_data):
+
png_data = cairosvg.svg2png(bytestring=svg_data)
+
return Raster(png_data)
@staticmethod
-
def fromSvgToFile(svgData, outFile):
-
cairosvg.svg2png(bytestring=svgData, write_to=outFile)
-
return Raster(None, pngFile=outFile)
+
def from_svg_to_file(svg_data, out_file):
+
cairosvg.svg2png(bytestring=svg_data, write_to=out_file)
+
return Raster(None, png_file=out_file)
def _repr_png_(self):
-
if self.pngData:
-
return self.pngData
-
elif self.pngFile:
+
if self.png_data:
+
return self.png_data
+
elif self.png_file:
try:
-
with open(self.pngFile, 'rb') as f:
+
with open(self.png_file, 'rb') as f:
return f.read()
except TypeError:
pass
try:
-
self.pngFile.seek(0)
-
return self.pngFile.read()
+
self.png_file.seek(0)
+
return self.png_file.read()
except io.UnsupportedOperation:
pass
-
def asDataUri(self):
-
if self.pngData:
-
data = self.pngData
+
def as_data_uri(self):
+
if self.png_data:
+
data = self.png_data
else:
try:
-
with open(self.pngFile, 'rb') as f:
+
with open(self.png_file, 'rb') as f:
data = f.read()
except TypeError:
-
self.pngFile.seek(0)
-
data = self.pngFile.read()
-
b64 = base64.b64encode(data)
-
return 'data:image/png;base64,' + b64.decode(encoding='ascii')
+
self.png_file.seek(0)
+
data = self.png_file.read()
+
return bytes_as_data_uri(data, mime='image/png')
+41
drawSvg/url_encode.py
···
+
import base64
+
import urllib.parse
+
import re
+
+
+
STRIP_CHARS = ('\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11'
+
'\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f')
+
+
def bytes_as_data_uri(data, strip_chars=STRIP_CHARS, mime='image/svg+xml'):
+
'''Return a data URI with base64 encoding.'''
+
b64 = base64.b64encode(data)
+
return f'data:{mime};base64,{b64.decode(encoding="ascii")}'
+
+
def svg_as_data_uri(txt, strip_chars=STRIP_CHARS, mime='image/svg+xml'):
+
'''Return a data URI with base64 encoding, stripping unsafe chars for SVG.
+
'''
+
search = re.compile('|'.join(strip_chars))
+
data_safe = search.sub(lambda m: '', txt)
+
return bytes_as_data_uri(data_safe.encode(encoding='utf-8'), mime=mime)
+
+
def svg_as_utf8_data_uri(txt, unsafe_chars='"', strip_chars=STRIP_CHARS,
+
mime='image/svg+xml'):
+
'''Returns a data URI without base64 encoding.
+
+
The characters '#&%' are always escaped. '#' and '&' break parsing of
+
the data URI. If '%' is not escaped, plain text like '%50' will be
+
incorrectly decoded to 'P'. The characters in `strip_chars` cause the
+
SVG not to render even if they are escaped.
+
'''
+
unsafe_chars = (unsafe_chars or '') + '#&%'
+
replacements = {
+
char: urllib.parse.quote(char, safe='')
+
for char in unsafe_chars
+
}
+
replacements.update({
+
char: ''
+
for char in strip_chars
+
})
+
search = re.compile('|'.join(map(re.escape, replacements.keys())))
+
data_safe = search.sub(lambda m: replacements[m.group(0)], txt)
+
return f'data:{mime};utf8,{data_safe}'
+8 -2
drawSvg/video.py
···
-
import numpy as np
-
import imageio
+
try:
+
import numpy as np
+
import imageio
+
except ImportError as e:
+
raise ImportError(
+
'Optional dependencies not installed. '
+
'Install with `python3 -m pip install "drawsvg[raster]"'
+
) from e
from .drawing import Drawing
+1 -2
drawSvg/widgets/async_animation.py
···
class AsyncAnimation(DrawingWidget):
-
'''AsyncAnimation is a Jupyter notebook widget for asynchronously displaying
-
an animation.
+
'''A Jupyter notebook widget for asynchronously displaying an animation.
Example:
# Jupyter cell 1:
+7 -6
drawSvg/widgets/drawing_widget.py
···
frame_delay = Int(-1).tag(sync=True)
def __init__(self, drawing, throttle=True, disable=False, frame_delay=-1):
-
'''
-
DrawingWidget is an interactive Jupyter notebook widget. It works
-
similarly to displaying a Drawing as a cell output but DrawingWidget
-
can register callbacks for user mouse events. Within a callback modify
-
the drawing then call .refresh() to update the output in real time.
+
'''An interactive Jupyter notebook widget.
+
+
This works similarly to displaying a Drawing as a cell output but
+
DrawingWidget can register callbacks for user mouse events. Within a
+
callback modify the drawing then call .refresh() to update the output in
+
real time.
Arguments:
drawing: The initial Drawing to display. Call .refresh() after
···
'''
Redraw the displayed output with the current value of self.drawing.
'''
-
self._image = self.drawing.asSvg()
+
self._image = self.drawing.as_svg()
def _receive_msg(self, _, content, buffers):
if not isinstance(content, dict):
+23 -9
setup.py
···
import logging
logger = logging.getLogger(__name__)
-
version = '1.9.0'
+
version = '2.0.0'
try:
with open('README.md', 'r') as f:
···
long_desc = None
setup(
-
name = 'drawSvg',
+
name = 'drawsvg',
packages = find_packages(),
version = version,
-
description = 'A Python 3 library for programmatically generating SVG images (vector drawings) and rendering them or displaying them in a Jupyter notebook',
+
description = 'A Python 3 library for programmatically generating SVG (vector) images and animations. Drawsvg can also render to PNG, MP4, and display your drawings in Jupyter notebook and Jupyter lab.',
long_description = long_desc,
long_description_content_type = 'text/markdown',
author = 'Casey Duckering',
#author_email = '',
-
url = 'https://github.com/cduck/drawSvg',
-
download_url = 'https://github.com/cduck/drawSvg/archive/{}.tar.gz'.format(version),
-
keywords = ['SVG', 'draw', 'graphics', 'iPython', 'Jupyter', 'widget'],
+
url = 'https://github.com/cduck/drawsvg',
+
download_url = 'https://github.com/cduck/drawsvg/archive/{}.tar.gz'.format(version),
+
keywords = ['SVG', 'draw', 'graphics', 'iPython', 'Jupyter', 'widget', 'animation'],
classifiers = [
'License :: OSI Approved :: MIT License',
'Programming Language :: Python :: 3',
···
'Framework :: Jupyter',
],
install_requires = [
-
'cairoSVG',
-
'numpy',
-
'imageio',
],
+
extras_require = {
+
'render': [
+
'cairoSVG~=2.3',
+
'numpy~=1.16',
+
'imageio~=2.5',
+
],
+
'color': [
+
'pwkit~=1.0',
+
'numpy~=1.16',
+
],
+
'all': [
+
'cairoSVG~=2.3',
+
'numpy~=1.16',
+
'imageio~=2.5',
+
'pwkit~=1.0',
+
],
+
},
)