#-----------------------------------------------------------------------------
# Copyright 2012-2016 Claude Zervas
# email: claude@utlco.com
#-----------------------------------------------------------------------------
"""
A simple library for SVG output.
"""
# Python 3 compatibility boilerplate
from __future__ import (absolute_import, division,
print_function, unicode_literals)
from future_builtins import *
import sys
import math
import numbers
import re
import string
import random
import logging
from lxml import etree
from geom import transform2d
from . import css
# For debugging...
logger = logging.getLogger(__name__)
# : SVG Namespaces
SVG_NS = {
# None: u'http://www.w3.org/2000/svg',
u'svg': u'http://www.w3.org/2000/svg',
u'xlink': u'http://www.w3.org/1999/xlink',
u'xml': u'http://www.w3.org/XML/1998/namespace'
}
def _add_ns(tag, ns_map, ns):
return '{%s}%s' % (ns_map[ns], tag)
[docs]def svg_ns(tag):
"""Shortcut to prepend SVG namespace to `tag`."""
return _add_ns(tag, SVG_NS, 'svg')
[docs]def xml_ns(tag):
"""Shortcut to prepend XML namespace to `tag`."""
return _add_ns(tag, SVG_NS, 'xml')
[docs]def xlink_ns(tag):
"""Shortcut to prepend xlink namespace to `tag`."""
return _add_ns(tag, SVG_NS, 'xlink')
[docs]def strip_ns(tag):
"""Strip the namespace part from the tag if any."""
return tag.rpartition('}')[2]
[docs]class SVGContext(object):
"""SVG document context.
"""
# Default floating point output precision.
# Number of digits after the decimal point.
_DEFAULT_PRECISION = 5
# A dictionary of explicit unit to px, or user unit, conversion factors
# See http://www.w3.org/TR/SVG/coords.html#Units
_UU_CONV = {
# em and ex should be determined by CSS
'em': 12.5, 'ex': 12.5,
'px': 1.0, 'pt': 1.25, 'pc': 15.0,
'mm': 3.543307, 'cm': 35.43307, 'in': 90.0,
# These are non-standard
'm': 3543.307, 'ft': 1080, 'yd': 3240,
}
# Pre-compiled REs for parsing unit specifiers.
_UU_UNIT = re.compile('(%s)$' % '|'.join(_UU_CONV.keys()))
_UU_FLOAT = re.compile(r'(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)')
# Pre-compiled RE for parsing SVG transform attribute value.
_TRANSFORM_RE = re.compile(r'(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)\s*,?',
re.IGNORECASE)
# Float comparison tolerance
_epsilon = math.pow(10.0, -_DEFAULT_PRECISION)
[docs] @classmethod
def create_document(cls, width, height, doc_id=None, doc_units=None):
"""Create a minimal SVG document.
Returns:
An ElementTree
"""
def floatystr(fstr):
# Strip off trailing zeros from fixed point float string
return ('%f' % fstr).rstrip('0').rstrip('.')
if doc_units is None:
doc_units = 'px'
docroot = etree.Element(svg_ns('svg'), nsmap=SVG_NS)
width_str = floatystr(width)
height_str = floatystr(height)
docroot.set('width', '%s%s' % (width_str, doc_units))
docroot.set('height', '%s%s' % (height_str, doc_units))
docroot.set('viewbox', '0 0 %s %s' % (width_str, height_str))
if doc_id is not None:
docroot.set('id', doc_id)
return etree.ElementTree(docroot)
[docs] @classmethod
def parse(cls, filename=None, huge_tree=True):
"""Parse an SVG file (or stdin) and return an SVGContext.
Args:
filename: The SVG file to parse. If this is None
stdin will be read by default.
Returns:
An SVGContext
"""
parser = etree.XMLParser(huge_tree=huge_tree)
if filename is None:
document = etree.parse(sys.stdin, parser=parser)
else:
with open(filename, 'r') as stream:
document = etree.parse(stream, parser=parser)
return cls(document)
def __init__(self, document, font_size=None, x_height=None):
"""New SVG context.
Args:
document: An SVG ElementTree. The svg 'width' and 'height'
attributes MUST be specified.
font_height: CSS font-height in user units.
ex_height: CSS x-height in user units.
"""
self.document = document
if hasattr(document, 'getroot'):
# Assume ElementTree
self.docroot = document.getroot()
else:
# Assume Element
self.docroot = document
self.current_parent = self.docroot
self.set_precision(self._DEFAULT_PRECISION)
if font_size is not None:
self._UU_CONV['em'] = font_size
if x_height is not None:
self._UU_CONV['ex'] = x_height
else:
self._UU_CONV['ex'] = self._UU_CONV['em']
# For some background on SVG coordinate systems
# and how Inkscape deals with units:
# http://www.w3.org/TR/SVG/coords.html
# http://wiki.inkscape.org/wiki/index.php/Units_In_Inkscape
# Get viewport width and height in user units
viewport_width = self.unit_convert(self.docroot.get('width'))
viewport_height = self.unit_convert(self.docroot.get('height'))
# Get the viewBox to determine user units and root scale factor
viewboxattr = self.docroot.get('viewBox')
if viewboxattr is not None:
p = re.compile('[,\s\t]+')
viewbox = [float(value) for value in p.split(viewboxattr)]
else:
viewbox = [0, 0, viewport_width, viewport_height]
viewbox_width = viewbox[2] - viewbox[0]
viewbox_height = viewbox[3] - viewbox[1]
# The viewBox can have a different size than the viewport
# which causes the user agent to scale the SVG.
# http://www.w3.org/TR/SVG/coords.html#ViewBoxAttribute
# For this purpose we assume the aspect ratio is preserved
# and that it's a degenerate case if not since it would be
# difficult, if not impossible, to make a general scaling rule
# for GUI value to user unit conversion.
scale_width = viewbox_width / viewport_width
scale_height = viewbox_height / viewport_height
if not self.float_eq(scale_width, scale_height):
raise Exception('viewBox aspect ratio does not match viewport.')
self.view_width = viewbox_width
self.view_height = viewbox_height
self.view_scale = scale_width
self.viewbox = viewbox
[docs] def unit2uu(self, value, from_unit='px'):
"""Convert a string/float that specifies a value in some source unit
(ie '3mm' or '5in') to a float value in user units.
The result will be scaled using the viewBox/viewport ratio.
See http://www.w3.org/TR/SVG/coords.html#ViewBoxAttribute
Args:
value: A numeric string value with an optional
unit identifier suffix (ie '3mm', '10pt, '5in'), or
a float value. If the string does not have a unit suffix
then `src_unit` will be used.
from_unit: A string specifying the units for the conversion.
Default is 'px'.
Returns:
A float value or 0.0 if the string can't be parsed.
"""
if isinstance(value, numbers.Number):
return value * self._UU_CONV[from_unit] * self.view_scale
else:
# Assume a string...
uu = self.unit_convert(value, from_unit=from_unit)
return uu * self.view_scale
[docs] def uu2unit(self, value, to_unit='px'):
"""Convert a value in user units to a destination unit.
Args:
value: A float value in user units.
to_unit: Destination unit (i.e. 'in', 'mm', etc.)
Default is 'px'.
Returns:
The converted value.
"""
v = self.unit_convert(value, to_unit=to_unit)
return v / self.view_scale
[docs] def unit_convert(self, value, to_unit='px', from_unit='px'):
"""Convert a string that specifies a scalar value in some source unit
(ie '3mm' or '5in') to a float value in a destination unit
('px', or user units, by default).
SVG/Inkscape units and viewBoxes are confusing...
See http://www.w3.org/TR/SVG/coords.html#ViewBoxAttribute
and http://www.w3.org/TR/SVG/coords.html#Units
and http://wiki.inkscape.org/wiki/index.php/Units_In_Inkscape
Args:
value: A string scalar with an optional unit identifier suffix.
(ie '3mm', '10pt, '5.3in')
to_unit: An SVG unit id of the destination unit conversion.
Default is 'px' (user units)
from_unit: Optional default source unit. ('px' if unspecified)
Returns:
A float value or 0.0 if the string can't be parsed.
"""
retval = 0.0
# Extract the scalar float value
m = self._UU_FLOAT.match(value)
if m is not None:
retval = float(m.string[m.start():m.end()])
m = self._UU_UNIT.search(value)
# Source value to user-unit scale factor
if m is not None:
unit = m.string[m.start():m.end()]
src2uu = self._UU_CONV.get(unit, from_unit)
else:
src2uu = self._UU_CONV.get(from_unit, 'px')
# User-unit to destination value scale factor
uu2dst = self._UU_CONV.get(to_unit, 'px')
retval *= src2uu / uu2dst
return retval
[docs] def write(self, filename=None):
"""Write the SVG to a file or stdout."""
if filename is None:
self._write_document(sys.stdout)
else:
with open(filename, 'w') as stream:
self._write_document(stream)
def _write_document(self, stream):
self.document.write(stream, encoding='UTF-8',
pretty_print=True, standalone=False)
[docs] def set_precision(self, precision):
"""Set the output precision.
Args:
precision: The number of digits after the decimal point.
"""
self._epsilon = math.pow(10.0, -precision)
self._fmt_float = '%%.%df' % (precision,)
self._fmt_point = '%%.%df,%%.%df' % (precision, precision)
self._fmt_move = 'M %s' % self._fmt_point
self._fmt_line = 'M %s L %s' % (self._fmt_point, self._fmt_point)
self._fmt_arc = 'A %s %s %%d %%d %s' % (self._fmt_point,
self._fmt_float,
self._fmt_point)
self._fmt_curve = 'C %s %s %s' % (self._fmt_point, self._fmt_point,
self._fmt_point)
[docs] def float_eq(self, a, b):
"""Compare two floats for equality in the SVG context.
The two float are considered equal if the difference between them is
less than epsilon. This is based on the current SVG numeric precision.
For a discussion of floating point comparisons see:
http://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/
"""
return abs(a - b) < self._epsilon
[docs] def set_default_parent(self, parent):
"""Set the current default parent (or layer) to :parent:"""
self.current_parent = parent
[docs] def get_node_by_id(self, node_id):
"""Find a node in the current document by id attribute.
Args:
node_id: The node id attribute value.
Returns:
A node if found otherwise None.
"""
return get_node_by_id(self.document, node_id)
[docs] def get_element_transform(self, node, root=None):
"""Get the combined transform of the element and it's combined parent
transforms.
Args:
node: The element node.
root: The document root or where to stop searching.
Returns:
The combined transform matrix or the identity matrix
if none found.
"""
matrix = self.get_parent_transform(node, root)
transform_attr = node.get('transform')
if transform_attr is not None and transform_attr:
node_transform = self.parse_transform_attr(transform_attr)
matrix = transform2d.compose_transform(matrix, node_transform)
return matrix
[docs] def get_parent_transform(self, node, root=None):
"""Get the combined transform of the node's parents.
Args:
node: The child node.
root: The document root or where to stop searching.
Returns:
The parent transform matrix or the identity matrix
if none found.
"""
matrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
parent = node.getparent()
while parent is not root:
parent_transform_attr = parent.get('transform')
if parent_transform_attr is not None:
parent_matrix = self.parse_transform_attr(parent_transform_attr)
matrix = transform2d.compose_transform(parent_matrix, matrix)
parent = parent.getparent()
return matrix
[docs] def node_is_visible(self, node, check_parent=True, _recurs=False):
"""Return True if the node is visible.
CSS visibility trumps SVG visibility attribute.
The node is not considered visible if the `visibility` style
is `hidden` or `collapse` or if the `display` style is `none`.
If the `visibility` style is `inherit` or `check_parent` is True
then the visibility is determined by the parent node.
Args:
node: An etree.Element node
check_parent: Recursively check parent nodes for visibility
Returns:
True if the node is visible otherwise False.
"""
if node is None:
return _recurs
styles = css.inline_style_to_dict(node.get('style'))
if styles.get('display') == 'none':
return False
visibility = styles.get('visibility', node.get('visibility'))
if visibility is not None:
if visibility == 'inherit' and not check_parent:
# Recursively determine parent visibility
return self.node_is_visible(node.getparent(), _recurs=True)
if visibility == 'hidden' or visibility == 'collapse':
return False
if check_parent:
return self.node_is_visible(node.getparent(), _recurs=True)
return True
[docs] def parse_transform_attr(self, transform_attr):
"""Parse an SVG transform attribute.
Args:
transform_attr: A string containing the SVG transform list.
Returns:
A single affine transform matrix.
"""
if (transform_attr is None or not transform_attr or
transform_attr.isspace()):
return transform2d.IDENTITY_MATRIX
transform_attr = transform_attr.strip()
transforms = self._TRANSFORM_RE.findall(transform_attr)
matrices = []
for transform, args in transforms:
matrix = None
values = [float(n) for n in args.replace(',', ' ').split()]
num_values = len(values)
if transform == 'translate':
x = values[0]
y = values[1] if num_values > 1 else 0.0
matrix = transform2d.matrix_translate(x, y)
if transform == 'scale':
x = values[0]
y = values[1] if num_values > 1 else x
matrix = transform2d.matrix_scale(x, y)
if transform == 'rotate':
a = math.radians(values[0])
cx = values[1] if num_values > 1 else 0.0
cy = values[2] if num_values > 2 else 0.0
matrix = transform2d.matrix_rotate(a, (cx, cy))
if transform == 'skewX':
a = math.radians(values[0])
matrix = transform2d.matrix_skew_x(a)
if transform == 'skewY':
a = math.radians(values[0])
matrix = transform2d.matrix_skew_y(a)
if transform == 'matrix':
matrix = ((values[0], values[2], values[4]),
(values[1], values[3], values[5]))
if matrix is not None:
matrices.append(matrix)
# Compose all the tranforms into one matrix
result_matrix = transform2d.IDENTITY_MATRIX
for matrix in matrices:
result_matrix = transform2d.compose_transform(result_matrix, matrix)
return result_matrix
[docs] def scale_inline_style(self, inline_style):
"""For any inline style attribute name that ends with
'width', 'height', or 'size'
scale the numeric value with an optional unit id suffix
by converting it to user units with no unit id.
"""
style_attrs = css.inline_style_to_dict(inline_style)
for attr, value in style_attrs.viewitems():
if attr.endswith(('width', 'height', 'size')):
# Automatically convert unit values
style_attrs[attr] = self.unit2uu(value)
return css.dict_to_inline_style(style_attrs)
[docs] def styles_from_templates(self, style_templates, default_map,
template_map=None):
"""Populate a dictionary of styles given a dictionary of templates and
mappings.
If a template key string ends with 'width', 'height', or 'size'
it is assumed that the value is a numeric value with an optional
unit id suffix and it will be automatically converted to user units
with no unit id.
Args:
style_templates: A dictionary of style names to
inline style template strings.
default_map: A dictionary of template keys to
default values. This must contain all template identifiers.
template_map: A dictionary of template keys to
values that override the defaults. Default is None.
Returns:
A dictionary of inline styles.
"""
# Create a template mapping that fills in missing values
# from the default map.
mapping = {}
# for key in default_map.iterkeys():
for key in default_map:
value = None
if template_map is not None:
value = template_map.get(key)
if value is None:
value = default_map[key]
if value is not None:
# If the value is a numeric type then it is assumed
# to already be in user units...
if (key.endswith(('width', 'height', 'size'))
and not isinstance(value, numbers.Number)):
# Automatically convert unit values
mapping[key] = self.unit2uu(value)
else:
mapping[key] = value
styles = {}
for name, template_str in style_templates.iteritems():
template = string.Template(template_str)
styles[name] = template.substitute(mapping)
return styles
[docs] def node_is_group(self, node):
"""Return True if the node is an SVG group."""
return node.tag == svg_ns('g') or node.tag == 'g'
[docs] def create_rect(self, position, width, height, style=None, parent=None):
"""Create an SVG rect element."""
if parent is None:
parent = self.current_parent
attrs = {'x': str(self._scale(position[0])),
'y': str(self._scale(position[1])),
'width': str(self._scale(width)),
'height': str(self._scale(height))}
if style:
attrs['style'] = style
return etree.SubElement(parent, svg_ns('rect'), attrs)
[docs] def create_circle(self, center, radius, style=None, parent=None):
"""Create an SVG circle element."""
if parent is None:
parent = self.current_parent
attrs = {'r': str(self._scale(radius)),
'cx': str(self._scale(center[0])),
'cy': str(self._scale(center[1]))}
if style:
attrs['style'] = style
return etree.SubElement(parent, svg_ns('circle'), attrs)
[docs] def create_ellipse(self, center, rx, ry, angle, style=None, parent=None):
"""Create an SVG ellipse."""
# See http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
# for converting center parameterization to SVG arc
# Normalize angle first:
angle = angle - (2 * math.pi) * math.floor(angle / (2 * math.pi))
x1 = rx * math.cos(angle) + center.x
y1 = rx * math.sin(angle) + center.y
x2 = rx * math.cos(angle + math.pi) + center.x
y2 = rx * math.sin(angle + math.pi) + center.y
m = self._fmt_move % (self._scale(x1), self._scale(y1))
a1 = self._fmt_arc % (self._scale(rx), self._scale(ry),
math.degrees(angle), 0, 1,
self._scale(x2), self._scale(y2))
a2 = self._fmt_arc % (self._scale(rx), self._scale(ry),
math.degrees(angle), 0, 1,
self._scale(x1),
self._scale(y1))
attrs = {'d': m + ' ' + a1 + ' ' + a2, }
return self.create_path(attrs, style, parent)
[docs] def create_line(self, p1, p2, style=None, parent=None, attrs=None):
"""Create an SVG path consisting of one line segment."""
line_path = self._fmt_line % (self._scale(p1[0]), self._scale(p1[1]),
self._scale(p2[0]), self._scale(p2[1]))
if attrs is None:
attrs = {}
attrs['d'] = line_path
return self.create_path(attrs, style, parent)
[docs] def create_circular_arc(self, startp, endp, radius, sweep_flag,
style=None, parent=None, attrs=None):
"""Create an SVG circular arc."""
m = self._fmt_move % (self._scale(startp[0]),
self._scale(startp[1]))
a = self._fmt_arc % (self._scale(radius), self._scale(radius),
0, 0, sweep_flag,
self._scale(endp[0]), self._scale(endp[1]))
if attrs is None:
attrs = {}
attrs['d'] = m + ' ' + a
return self.create_path(attrs, style, parent)
[docs] def create_curve(self, startp, cp1=None, cp2=None, p2=None,
style=None, parent=None, attrs=None):
"""Create an SVG cubic bezier curve.
Args:
startp: The start point of the curve or an indexable
collection of four control points.
style: A CSS style string.
parent: The parent element (or Inkscape layer).
attrs: Dictionary of SVG element attributes.
Returns:
An SVG path Element node.
"""
d = []
if cp1 is None:
p1, cp1, cp2, p2 = startp
else:
p1 = startp
d.append(self._fmt_move % (self._scale(p1[0]),
self._scale(p1[1])))
d.append(self._format_curve(cp1, cp2, p2))
if attrs is None:
attrs = {}
attrs['d'] = ' '.join(d)
return self.create_path(attrs, style, parent)
def _format_curve(self, cp1=None, cp2=None, p2=None):
return self._fmt_curve % (self._scale(cp1[0]), self._scale(cp1[1]),
self._scale(cp2[0]), self._scale(cp2[1]),
self._scale(p2[0]), self._scale(p2[1]))
[docs] def create_polygon(self, vertices, close_polygon=True, close_path=False,
style=None, parent=None, attrs=None):
"""Create an SVG path describing a polygon.
Args:
vertices: An iterable collection of 2D polygon vertices.
A vertice being a tuple containing x,y coordicates.
close_polygon: Close the polygon if it isn't already.
Default is True.
close_path: Close and join the the path ends by
appending 'Z' to the end of the path ('d') attribute.
Default is False.
style: A CSS style string.
parent: The parent element (or Inkscape layer).
attrs: Dictionary of SVG element attributes.
Returns:
An SVG path Element node, or None if the list of vertices is empty.
"""
if not vertices:
return None
d = ['M', self._fmt_point % (self._scale(vertices[0][0]),
self._scale(vertices[0][1])), 'L']
for p in vertices[1:]:
d.append(self._fmt_point % (self._scale(p[0]),
self._scale(p[1])))
if close_polygon and vertices[0] != vertices[-1]:
d.append(self._fmt_point % (self._scale(vertices[0][0]),
self._scale(vertices[0][1])))
if close_path:
d.append('Z')
if attrs is None:
attrs = {}
attrs['d'] = ' '.join(d)
return self.create_path(attrs, style, parent)
[docs] def create_polypath(self, path, close_path=False, style=None,
parent=None, attrs=None):
"""Create an SVG path from a sequence of line and arc segments.
Args:
path: An iterable sequence of line, circular arc, or cubic
Bezier curve segments.
A line segment is a 2-tuple containing the endpoints.
An arc segment is a 5-tuple containing the start point,
end point, radius, angle, and center, respectively. The
arc center is ignored.
A cubic bezier segment is a 4-tuple containing the first
endpoint, the first control point, the second control point,
and the second endpoint.
close_path: Close and join the the path ends by
appending 'Z' to the end of the path ('d') attribute.
Default is False.
style: A CSS style string.
parent: The parent element (i.e. Inkscape layer).
attrs: Dictionary of SVG element attributes.
Returns:
An SVG path Element node, or None if the path is empty.
"""
if not path:
return None
p1 = path[0][0]
d = ['M', self._fmt_point % (self._scale(p1[0]), self._scale(p1[1]))]
for segment in path:
if len(segment) == 2:
# Assume this is a line segment with two endpoints:
# ((x1, y1), (x2, y2))
p2 = segment[1]
d.append('L')
d.append(self._fmt_point % (self._scale(p2[0]),
self._scale(p2[1])))
elif len(segment) == 4:
# Assume this is a cubic Bezier:
# ((x1, y1), (cx1, cx1), (cx2, cx2), (x2, y2))
cp1 = segment[1]
cp2 = segment[2]
p2 = segment[3]
d.append(self._format_curve(cp1, cp2, p2))
elif len(segment) == 5:
# Assume this is an arc segment:
# ((x1, y1), (x2, y2), radius, angle, center)
p2 = segment[1]
radius = segment[2]
angle = segment[3]
sweep_flag = 0 if angle < 0 else 1
arc = self._fmt_arc % (self._scale(radius), self._scale(radius),
0, 0, sweep_flag,
self._scale(p2[0]), self._scale(p2[1]))
d.append(arc)
if close_path:
d.append('Z')
if attrs is None:
attrs = {}
attrs['d'] = ' '.join(d)
return self.create_path(attrs, style, parent)
[docs] def create_simple_marker(self, marker_id, d, style, transform,
replace=False):
"""Create an SVG line end marker glyph.
The glyph Element is placed under the document root.
"""
defs = self.docroot.find(svg_ns('defs'))
if defs is None:
defs = etree.SubElement(self.docroot, svg_ns('defs'))
elif replace:
# If a marker with the same id already exists
# then remove it first.
node = defs.find('*[@id="%s"]' % marker_id)
if node is not None:
node.getparent().remove(node)
marker = etree.SubElement(defs, svg_ns('marker'),
{'id': marker_id, 'orient': 'auto', 'refX': '0.0',
'refY': '0.0', 'style': 'overflow:visible', })
etree.SubElement(marker, svg_ns('path'),
{ 'd': d, 'style': style, 'transform': transform, })
return marker
[docs] def create_path(self, attrs, style=None, parent=None):
"""Create an SVG path element."""
if parent is None:
parent = self.current_parent
if style is not None:
attrs['style'] = style
return etree.SubElement(parent, svg_ns('path'), attrs)
[docs] def create_text(self, text, x, y, line_height=None,
style=None, parent=None):
"""Create a text block.
"""
if parent is None:
parent = self.current_parent
attrs = {'x': str(self._scale(x)), 'y': str(self._scale(y)),
xml_ns('space'): 'preserve',
'style': style
}
text_elem = etree.SubElement(parent, svg_ns('text'), attrs)
if isinstance(text, basestring):
self._create_text_line(text, x, y, text_elem)
else:
for text_line in text:
self._create_text_line(text_line, x, y, text_elem)
y += line_height
return text_elem
[docs] def add_elem(self, node, parent=None):
"""Add the element to the parent or the current parent if parent is None.
"""
if parent is None:
parent = self.current_parent
parent.append(node)
def _create_text_line(self, text, x, y, parent):
attrs = {'x': str(self._scale(x)), 'y': str(self._scale(y)), }
tspan_elem = etree.SubElement(parent, svg_ns('tspan'), attrs)
tspan_elem.text = text
return tspan_elem
def _scale(self, n):
# TODO: apply viewport scaling
# noop for now
return n
# def _rotate_point(self, x, y, angle):
# """Rotate point by angle amount around origin point."""
# x = x * math.cos(angle) - y * math.sin(angle)
# y = y * math.cos(angle) + x * math.sin(angle)
# return (x, y)
[docs]def path_tokenizer(path_data):
"""Tokenize SVG path data.
A generator that yields tokens from path data.
This will yield a tuple containing a
command token or a numeric parameter token
followed by a boolean flag that is True if the token
is a command and False if the token is a numeric parameter.
Yields:
A 2-tuple with token and token type hint.
"""
#--------------------------------------------------------------------------
# Thanks to Peter Stangl for this.
# It is significantly faster than using regexp.
# https://codereview.stackexchange.com/users/71285/peter-stangl
#
# See:
# https://codereview.stackexchange.com/questions/28502/svg-path-parsing
# https://www.w3.org/TR/SVG/paths.html#PathDataBNF
#--------------------------------------------------------------------------
DIGIT_EXP = '0123456789eE'
COMMA_WSP = ', \t\n\r\f\v'
DRAWTO_COMMAND = 'MmZzLlHhVvCcSsQqTtAa'
SIGN = '+-'
EXPONENT = 'eE'
in_float = False
entity = ''
for char in path_data:
if char in DIGIT_EXP:
entity += char
elif char in COMMA_WSP and entity:
yield (entity, False) # Number parameter
in_float = False
entity = ''
elif char in DRAWTO_COMMAND:
if entity:
yield (entity, False) # Number parameter
in_float = False
entity = ''
yield (char, True) # Yield a command
elif char == '.':
if in_float:
yield (entity, False) # Number parameter
entity = '.'
else:
entity += '.'
in_float = True
elif char in SIGN:
if entity and entity[-1] not in EXPONENT:
yield (entity, False) # Number parameter
in_float = False
entity = char
else:
entity += char
if entity:
yield (entity, False) # Number parameter
"""
This parser metadata structure is shamelessly borrowed from
Aaron Spike's simplepath parser with minor modifications.
{path-command:
[
output-command, # Canonical command
num-params, # Expected number of parameters
[casts, ...], # float, int
[coord-axis, ...] # 0 == x, 1 == y, -1 == not a coordinate param
]}
"""
_PATHDEFS = {
'M': ['M', 2, [float, float], [0, 1]],
'L': ['L', 2, [float, float], [0, 1]],
'H': ['L', 1, [float], [0, ]],
'V': ['L', 1, [float], [1, ]],
'C': ['C', 6, [float, float, float, float, float, float],
[0, 1, 0, 1, 0, 1]],
'S': ['C', 4, [float, float, float, float], [0, 1, 0, 1]],
'Q': ['Q', 4, [float, float, float, float], [0, 1, 0, 1]],
'T': ['Q', 2, [float, float], [0, 1]],
'A': ['A', 7, [float, float, float, int, int, float, float],
[-1, -1, -1, -1, -1, 0, 1]],
'Z': ['L', 0, [], []]}
[docs]def parse_path(path_data):
"""Parse an SVG path definition string.
Converts relative values to absolute and
shorthand commands to canonical (ie. H to L, S to C, etc.)
Terminating Z (or z) converts to L.
If path syntax errors are encountered, parsing will simply stop.
No exceptions are raised. This is by design so that parsing
is relatively forgiving of input.
Implemented as a generator so that memory usage can be minimized
for very long paths.
Args:
path_def: The 'd' attribute value of a SVG path element.
Yields:
A path component 2-tuple of the form (cmd, params).
"""
# Current command context
current_cmd = None
# Current path command definition
pathdef = _PATHDEFS['M']
# Start of sub-path
moveto = (0.0, 0.0)
# Current drawing position
pen = (0.0, 0.0)
# Last control point for curves
last_control = pen
# True if the command is relative
cmd_is_relative = False
# True if the parser expects a command
expecting_command = True
# Current accumulated parameters
params = []
tokenizer = path_tokenizer(path_data)
pushed_token = None
while True:
if pushed_token is not None:
token, is_command = pushed_token
pushed_token = None
else:
try:
token, is_command = tokenizer.next()
except StopIteration:
break
if expecting_command:
if current_cmd == None and token.upper() != 'M':
break
if is_command:
cmd_is_relative = token.islower()
cmd = token.upper()
pathdef = _PATHDEFS[cmd]
current_cmd = cmd
if current_cmd == 'Z':
# Push back an empty token since this has no parameters
pushed_token = ('', False)
else:
# In implicit command
if current_cmd == 'M':
# Any subsequent parameters are for an implicit LineTo
current_cmd = 'L'
pathdef = _PATHDEFS[current_cmd]
# Push back token for parameter accumulation on next pass
pushed_token = (token, is_command)
expecting_command = False
else:
if is_command:
# Bail if number of parameters doesn't match command
break
# Accumulate parameters for the current command
param_index = len(params)
if param_index < pathdef[1]:
cast = pathdef[2][param_index]
value = cast(token)
if cmd_is_relative:
# Get the axis this shorthand is referring to
# 0 = X, 1 = Y, -1 = none
axis = pathdef[3][param_index]
if axis >= 0:
# Make relative value absolute
value += pen[axis]
params.append(value)
param_index += 1
if param_index == pathdef[1]:
# All parameters have been accumulated now process command
if current_cmd == 'M':
moveto = (params[0], params[1])
elif current_cmd == 'Z':
params.extend(moveto)
pushed_token = None
elif current_cmd == 'H':
params.append(pen[1])
elif current_cmd == 'V':
params.insert(0, pen[0])
elif current_cmd in ('S', 'T'):
params.insert(0, pen[1] + (pen[1] - last_control[1]))
params.insert(0, pen[0] + (pen[0] - last_control[0]))
if current_cmd in ('C', 'Q'):
last_control = (params[-4], params[-3])
else:
last_control = pen
output_cmd = pathdef[0]
yield (output_cmd, params)
# Update the drawing position to the last end point.
pen = (params[-2], params[-1])
params = []
expecting_command = True
[docs]def explode_path(path_data):
"""Break the path at node points into component segments.
Args:
path_def: The 'd' attribute value of a SVG path element.
Returns:
A list of path 'd' attribute values.
"""
dlist = []
p1 = None
for cmd, params in parse_path(path_data):
if cmd == 'M':
p1 = (params[-2], params[-1])
continue
p2 = (params[-2], params[-1])
if p1 is not None:
paramstr = ' '.join([str(param) for param in params])
d = 'M %f %f %s %s' % (p1[0], p1[1], cmd, paramstr)
dlist.append(d)
p1 = p2
return dlist
[docs]def create_svg_document(width, height, doc_units='px', doc_id=None):
"""Create a minimal SVG document tree.
The svg element `viewbox` attribute will maintain the size and
attribute ratio as specified by width and height.
Args:
width: The width of the document in user units.
height: The height of the document in user units.
doc_units: The user unit type (i.e. 'in', 'mm', 'pt', 'em', etc.)
By default this will be 'px'.
doc_id: The id attribute of the enclosing svg element.
If None (default) then a random id will be generated.
Returns:
An lxml.etree.ElementTree
"""
def floatystr(value):
# Strip off trailing zeros from fixed point float string
# This is similar to the 'g' format but wont display scientific
# notation for big numbers.
return ('%f' % float(value)).rstrip('0').rstrip('.')
docroot = etree.Element(svg_ns('svg'), nsmap=SVG_NS)
width_str = floatystr(width)
height_str = floatystr(height)
docroot.set('width', '%s%s' % (width_str, doc_units))
docroot.set('height', '%s%s' % (height_str, doc_units))
docroot.set('viewbox', '0 0 %s %s' % (width_str, height_str))
if doc_id is None:
doc_id = random_id()
docroot.set('id', doc_id)
return etree.ElementTree(docroot)
[docs]def random_id(prefix='_svg', rootnode=None):
"""Create a random XML id attribute value.
Args:
prefix: The prefix prepended to a random number.
Default is '_svg'.
rootnode: The root element to search for id name collisions.
Default is None in which case no search will be performed.
Returns:
A random id string that has a fairly low chance of collision
with previously generated ids.
"""
id_attr = '%s%d' % (prefix, random.randint(1, 2 ** 31))
if rootnode is not None:
while get_node_by_id(rootnode, id) is not None:
id_attr = '%s%d' % (prefix, random.randint(1, 2 ** 31))
return id_attr
[docs]def get_node_by_id(rootnode, node_id):
"""Find a node in the current document by id attribute.
Args:
rootnode: The root element to search.
node_id: The node id attribute value.
Returns:
A node if found otherwise None.
"""
return rootnode.find('//*[@id="%s"]' % node_id)