"""Backend that outputs to SVG tags.
SVG is generated using the 'xml.etree' ElementTree std. lib module. A write
parameter has been added to all geometry creation methods, and all methods
return any ELementTree.Elements created.
.. Note::
If the svg.CommandFile isn't used as a context manager you will have to
provide a root ElementTree.Element using CommandFile.set_root method.
Also you will need to call CommandFile.teardown manually to add the style
block to the root, and write the tree to the provided file object.
.. Warning::
The point, points, and reset methods have no meaningful representation in
SVG. Any script using these methods cannot be run against the SVG backend.
There are several special module attributes:
:attr WIDTH: SVG doc width.
:attr HEIGHT: SVG doc height.
:attr FACTOR: The initial scale factor of the drawing.
:attr NAVIGATE: Toggles inclusion of
`SVGPan.js <https://code.google.com/p/svgpan/>`_ in teardown.
WIDTH and HEIGHT are written in the <svg> tag as their respective attributes.
The viewbox is set to '0 0 WIDTH HEIGHT' to match display and drawing units.
To better match other CAD packages a group is added inside the SVG tag wrapping
all other elements that mirrors everything across the y-axis so positive y is
up, and everything is translated down by HEIGHT so that the origin is in the
bottom left corner. This is also where initial scale FACTOR is applied.
"""
import os
from xml.etree import ElementTree as ET
from contextlib import contextmanager
import commandfile
EXT = '.svg'
WIDTH = '800px'
HEIGHT = '600px'
FACTOR = 12
NAVIGATE = True
mininav = os.path.abspath(os.path.join(os.path.dirname(__file__), 'SVGPan.js'))
with open(mininav, 'r') as infile:
mininav = infile.read()
# Patch ElementTree for CDATA support
orig_serialize_xml = ET._serialize_xml
def _serialize_xml(write, elem, encoding, qnames, namespaces):
"""Replace ET serializer with one that handles CDATA blocks.
Lifted from `this answer <http://stackoverflow.com/a/10440166/770443>`_.
"""
if elem.tag == 'CDATA':
write("\n<![CDATA[%s]]>\n" %elem.text)
else:
orig_serialize_xml(write, elem, encoding, qnames, namespaces)
ET._serialize_xml = _serialize_xml
[docs]class Layer(commandfile.Layer):
"""Group objects with identical styles.
.. Note::
All layers have a fill style of none.
:param name: This is the class name for the layer style.
A tuple of layer properties, or a Layer object may be passed as the
first argument (name) in which case the new layer is initialized with
provided values.
:type name: string OR Layer-like
:param co: The color of the layer [default: black].
:param lw: The line-weight of the layer [default: 0.1].
:param lc: The line-class (type) of the layer. The line-class can be None
for continuous lines, one of the names in the Layer.linecls
class attribute, or an iterable of ints.
"""
#: Named line classes
linecls = { # TODO add indexed (MicroStation like) version
'continuous':(0,),
'hidden':(0.25, 0.2),
'center':(1, 0.2, 0.2, 0.2),
'phantom':(0.75, 0.2, 0.2, 0.2, 0.2, 0.2),
}
#: Indexed colors
colors = dict((i+1, co) for i, co in enumerate((
'red',
'yellow',
'green',
'cyan',
'blue',
'magenta',
'black',
'gray',
'lightgray',
)))
[docs] def __str__(self):
"""Render this layer as a CSS style."""
style = dict()
style['name'] = self.name
# Check for indexed colors
if type(self.co) == int:
style['co'] = self.colors[self.co]
else:
style['co'] = self.co if self.co else 'black'
style['lw'] = self.lw if self.lw else 0.1
lc = self.lc
if not lc:
lc = self.linecls['continuous']
elif lc in self.linecls:
lc = self.linecls[self.lc]
style['lc'] = "{}".format(",".join(str(i) for i in lc))
le = '\n\t\t'
template = '\t.{name} {{' + le + 'stroke: {co};' + le
template += 'stroke-width: {lw};' + le + 'stroke-dasharray: {lc};'
template += '\n\t}}\n'
return template.format(**style)
[docs]class CommandFile(commandfile.CommandFile):
"""Wrapper for SVG generation.
Convenience methods for building an SVG tree and writing it to a filelike.
:param filelike: An object with a write method.
:type filelike: filelike
:param setup: Commands to include at the beginning of the script.
:type setup: callable OR valid CommandFile.cmd argument
:param teardown: Commands to include at the end of the script.
:type teardown: callable OR valid CommandFile.cmd argument
"""
Layer = Layer #: A local binding to the SVG Layer class
def __init__(self, filelike, setup=None, teardown=None):
commandfile.CommandFile.__init__(self, filelike, setup, teardown)
self._root = None
self._container = None
self._layers = dict()
[docs] def set_root(self, el, transform=False):
"""Sets the ElementTree Element to which tags will be added.
Also adds a wrapper group that modifies the SVG coordinate system.
:param el: The element to set as root.
:type el: ElementTree.Element
:raises: TypeError if el doesn't pass ElementTree.iselement test.
"""
if ET.iselement(el):
self._root = el
if transform:
# Add coordinate system modifying group
tfrm = "translate(0,{0}) scale({1},-{1})"
tfrm = tfrm.format(HEIGHT[:-2], FACTOR)
self._container = ET.Element('g', id="viewport", transform=tfrm)
self._root.append(self._container)
else:
# Set root as container
self._container = self._root
else:
raise TypeError("Expected Element, got {}".format(type(el)))
[docs] def setup(self):
"""Add default configuration info and run user provided setup func.
Called automatically by CommandFile context manager. Writes default
setup code, then adds any user provided setup. If the user setup is
callable the return value is written to the script, otherwise the user
setup is written as a string.
.. Note::
User setup is handled differently in SVG, if the user setup is not
a function it is passed to the cmd method to be added as a tag.
"""
svg = ET.Element('svg', width=WIDTH, height=HEIGHT, id="svgroot",
viewbox="0 0 {} {}".format(WIDTH[:-2], HEIGHT[:-2]))
self.set_root(svg, transform=True)
# Run user setup
if self.user_setup:
try:
# Assume user_setup is callable
self.user_setup(self)
except TypeError:
# Fall back to treating it as a command
self.cmd(self.user_setup)
[docs] def teardown(self):
"""Add final tags, run user provided teardown func, and write filelike.
Called automatically by CommandFile context manager. Writes any user
provided teardown, then writes default teardown code. If the user
teardown is callable the return value is written to the script,
otherwise the user teardown is written as a string.
.. Note::
User teardown is handled differently in SVG, if the user teardown is
not a function it is passed to the cmd method to be added as a tag.
"""
# Run user teardown
if self.user_teardown:
try:
# Assume user_teardown is callable
self.user_teardown(self)
except TypeError:
# Fall back to treating it as a command
self.cmd(self.user_teardown)
# Write style tag implementing layers used.
self._add_style()
if NAVIGATE:
self._add_nav()
# Write output to file
self.file.write(ET.tostring(self._root))
def _add_style(self):
"""Write an inline style sheet for default and layer properties."""
style = ET.Element('style')
data = ET.SubElement(style, 'CDATA')
# Add default styles
data.text = """
line, polyline, circle {
stroke: black;
fill: none;
stroke-width: 0.1px;
}"""
data.text += '\n'
# Add a style for each layer
try:
for layer in self._layers.values():
data.text += str(layer)
except AttributeError:
pass
self._container.append(style)
def _add_nav(self):
"""Adds mouse navigation."""
script = ET.Element('script', type="text/javascript")
data = ET.SubElement(script, 'CDATA')
data.text = mininav
self._container.append(script)
[docs] def set_layer(self, layer, *args, **kwargs):
r"""Activates the provided layer object or description.
:param layer: A layer object, or a valid Layer name argument.
:param \*args: Valid Layer arguments.
:param \*\*kwargs: Valid Layer arguments.
"""
if self._layer:
self.layer_stack.append(self._layer)
# Force creation of new layer
self._layer = self.Layer(layer, *args, **kwargs)
# Save every layer in a map of name:layer
self._layers[self._layer.name] = self._layer
[docs] def pop_layer(self):
"""Restores active layer prior to last set_layer call."""
if self.layer_stack:
self._layer = self.layer_stack.pop()
[docs] def cmd(self, command, *args, **kwargs):
r"""Add arbitrary elements to script root.
The command argument is expected to be a tag name, or an
ElementTree.Element, or an iterable of ElementTree Elements. If command
is a string then \*args and \*\*kwargs are passed to the Element init.
if command is already an element then the extra args are ignored.
:param command: The element(s) to be added to the document.
:type command: string OR ElementTree.Element OR Element iterable
:param \*args: The attrib argument of ElementTree.Element.
:type \*args: dict
:param \*\*kwargs: Extra attributes for the new ElementTree.Element.
:param write: Whether the element should be added to root (default=True).
:type write: boolean
:returns: Element
"""
write = kwargs.pop('write', True)
if hasattr(command, '__iter__'): # Is iterable
# Handle lists of elements to add
for el in command:
self.cmd(el, write=write)
return command
elif ET.iselement(command):
# Handle single element
el = command
else:
# Handle string
el = ET.Element(command, *args, **kwargs)
if self._layer:
el.set('class', self._layer.name)
if write:
self._container.append(el)
return el
[docs] def line(self, ps, reset=False, mode=None, write=True):
"""Add a series of line segment tags to root.
Since the SVG line tag only supports a single line segment multiple
tags may be added to a single set of points.
:param ps: The end points of the lines.
:type command: iterable
:param reset: Unused in SVG backend.
:type reset: boolean
:param mode: Unimplemented, relative coordinates could be implemented
for individual method invocations.
:type mode: string
:param write: Whether the line elements should be added to root.
:type write: boolean
:returns: Element list
"""
# Process the points by connecting the last seen point to the current
els = list()
last = None
for pt in ps:
pt = [str(i) for i in pt]
if not last:
last = pt
continue
line = ET.Element('line', x1=last[0], y1=last[1],
x2=pt[0], y2=pt[1])
if self._layer:
line.set('class', self._layer.name)
els.append(line)
last = pt
if write:
self._container.extend(els)
return els
[docs] def polyline(self, ps, close=True, reset=False, mode=None, write=True):
"""Add a polyline tag to root.
Points are written as "x,y" pairs seperated by spaces, so splitting
produces point strings, and splitting those strings on ',' produces the
point components.
:param ps: The ordered points forming the line.
:type command: iterable
:param close: Flag indicating whether the line should end where it started.
:type close: boolean
:param reset: Unused in SVG backend.
:type reset: boolean
:param mode: Unimplemented, relative coordinates could be implemented
for individual method invocations.
:type mode: string
:param write: Whether the element should be added to root.
:type write: boolean
:returns: Element
"""
if close:
ps = list(ps)
ps.append(ps[0])
pnts = ''
for pt in ps:
pnts += '{},{} '.format(pt[0], pt[1])
pline = ET.Element('polyline', points=pnts)
if self._layer:
pline.set('class', self._layer.name)
if write:
self._container.append(pline)
return pline
[docs] def circle(self, center, radius, write=True):
"""Add a circle tag to root.
:param center: The center of the circle.
:type center: iterable
:param radius: The radius of the circle.
:type radius: number
:param write: Whether the element should be added to root.
:type write: boolean
:returns: Element
"""
center = [str(i) for i in center]
circle = ET.Element('circle', cx=center[0], cy=center[1], r=str(radius))
if self._layer:
circle.set('class', self._layer.name)
if write:
self._container.append(circle)
return circle
[docs] def text(self, text, p, height=1.0, ang=0, just='tl', write=True):
"""Add a text tag to root.
The text is inspected for newline chars, and <tspan> tags are added
with (hopefully) appropriate dx/dy values to create multiline text.
:param text: The text to place.
:type text: string
:param p: The insertion point in a format passable to CommandFile.point.
:type p: iterable
:param height: How tall the text lines are.
:type height: number
:param ang: The angle of the text in degrees.
:type ang: number
:param just: The justification of the text, a pair of [t, m, b][l, c, r].
:type just: string
:param write: Whether the element should be added to root.
:type write: boolean
:returns: Element
"""
p = [str(i) for i in p]
if 'l' in just:
just = 'start'
elif 'c' in just:
just = 'middle'
else:
just = 'end'
tel = ET.Element('text', x=p[0], y=p[1],
transform="{} {},{}".format(ang, p[0], p[1]),
style="text-size: {}; text-anchor: {};".format(height, just))
if '\n' in text:
lines = text.split('\n')
last = None
for line in lines:
if not last:
last = line
tel.text = line
continue
tspan = ET.Element('tspan',
dx="-{}ex".format(len(last)),
dy="1em")
tspan.text = line
tel.append(tspan)
else:
tel.text = text
if self._layer:
tel.set('class', self._layer.name)
if write:
self._container.append(tel)
return tel
[docs] def store(self, name, *els):
r"""Store provided elements to be manipulated later.
This method is provided as a uniform interface for grouping objects
regardless of backend. In the SVG backend this is a noop.
:param name: The id used to store the selection.
:type name: Unused
:param \*els: The elements to store.
:type \*els: SVG elements or element lists.
:raises: ValueError if no elements are provided.
:returns: An element group passable to move/copy/rotate/scale methods.
"""
if not els:
raise ValueError('No elements provided to store!')
group = list()
for el in els:
if isinstance(el, ET.Element):
group.append(el)
else:
group.extend(el)
return group
[docs] def move(self, els, base=(0, 0), dest=(0, 0)):
"""Transform the provided elements from base to dest.
:param els: The elements to move.
:type els: iterable of Elements
:param base: The base point to transform from.
:type base: iterable
:param dest: The dest point to transform to.
:type dest: iterable
:returns: Element list
"""
if not hasattr(els, '__iter__'): # Is *not* iterable
els = [els]
vec = (dest[0] - base[0], dest[1] - base[1])
trans = "translate({} {})".format(*vec)
for el in els:
if 'transform' in el.attrib:
el.attrib['transform'] += ', ' + trans
else:
el.attrib['transform'] = trans
return els
[docs] def copy(self, els, base=(0, 0), dest=(0, 0), write=True):
"""Transform duplicates of the provided elements from base to dest.
:param els: The elements to copy.
:type els: iterable of Elements
:param base: The base point to transform from.
:type base: iterable
:param dest: The dest point to transform to.
:type dest: iterable
:param write: Whether the elements should be added to root.
:type write: boolean
:returns: Element list
"""
if not hasattr(els, '__iter__'): # Is *not* iterable
els = [els]
copies = [el.copy() for el in els]
if write:
self._container.extend(copies)
return self.move(copies, base, dest)
[docs] def rotate(self, els, base=(0, 0), ang=0):
"""Rotate the provided elements from base by angle.
:param els: The elements to rotate.
:type els: iterable of Elements
:param base: The base point to transform from.
:type base: iterable
:param ang: The angle to rotate in degrees (counter-clockwise).
:type ang: number
:returns: Element list
"""
if not hasattr(els, '__iter__'): # Is *not* iterable
els = [els]
trans = "rotate({} {},{})".format(ang, *base[:2])
for el in els:
if 'transform' in el.attrib:
el.attrib['transform'] += ', ' + trans
else:
el.attrib['transform'] = trans
return els
[docs] def scale(self, els, base=(0, 0), scale=(1, 1)):
"""Scale the provided elements from base by scale.
Remember that scaling an axis by -1 is equivalent to mirroring.
:param els: The elements to scale.
:type els: iterable of Elements
:param base: The base point to transform from.
:type base: iterable
:param scale: The x and y scale factor to apply.
:type scale: iterable
:returns: Element list
"""
if not hasattr(els, '__iter__'): # Is *not* iterable
els = [els]
trans = "translate({} {})".format(*base)
trans += "scale({} {})".format(*scale)
trans += "translate({} {})".format(-base[0], -base[1])
for el in els:
if 'transform' in el.attrib:
el.attrib['transform'] += ', ' + trans
else:
el.attrib['transform'] = trans
return els
[docs] def erase(self, els):
"""Remove the indicated elements from root.
:param els: The elements to remove.
:type els: iterable of Elements
:returns: Element list
"""
if not hasattr(els, '__iter__'): # Is *not* iterable
els = [els]
for el in els:
if el in self._container:
self._container.remove(el)
return els
if __name__ == "__main__": # pragma: no cover
# When run as a script generate a square
from cStringIO import StringIO
from contextlib import closing
with closing(StringIO()) as outfile:
# Build custom layer
mylayer = Layer('custom')
# Create script file
with CommandFile(outfile) as script:
# Draw a shape
with script.layer('first'):
script.line(((1, 2), (2, 2)))
with script.layer(mylayer):
script.line(((2, 2), (2, 1)))
with script.layer('third'):
script.line(((2, 1), (1, 1)))
with script.layer('fourth'):
script.line(((1, 1), (1, 2)))
print outfile.getvalue()