diff --git a/.travis.yml b/.travis.yml
index 3fa9427..7ea4dcf 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,6 +3,6 @@ python:
- "2.7"
- "3.6"
install:
- - pip install numpy svgwrite
+ - pip install numpy svgwrite future
script:
- python -m unittest discover test
diff --git a/setup.py b/setup.py
index acd036f..7dfb136 100644
--- a/setup.py
+++ b/setup.py
@@ -31,10 +31,10 @@ setup(name='svgpathtools',
# download_url = 'http://github.com/mathandy/svgpathtools/tarball/'+VERSION,
license='MIT',
- install_requires=['numpy', 'svgwrite'],
+ install_requires=['numpy', 'svgwrite', 'future'],
platforms="OS Independent",
# test_suite='tests',
- requires=['numpy', 'svgwrite'],
+ requires=['numpy', 'svgwrite', 'future'],
keywords=['svg', 'svg path', 'svg.path', 'bezier', 'parse svg path', 'display svg'],
classifiers = [
"Development Status :: 4 - Beta",
diff --git a/svgpathtools/__init__.py b/svgpathtools/__init__.py
index 47ad117..a9e2caa 100644
--- a/svgpathtools/__init__.py
+++ b/svgpathtools/__init__.py
@@ -12,6 +12,7 @@ from .paths2svg import disvg, wsvg
from .polytools import polyroots, polyroots01, rational_limit, real, imag
from .misctools import hex2rgb, rgb2hex
from .smoothing import smoothed_path, smoothed_joint, is_differentiable, kinks
+from .document import Document, CONVERSIONS, CONVERT_ONLY_PATHS, SVG_GROUP_TAG, SVG_NAMESPACE
try:
from .svg_to_paths import svg2paths, svg2paths2
diff --git a/svgpathtools/document.py b/svgpathtools/document.py
new file mode 100644
index 0000000..7f96928
--- /dev/null
+++ b/svgpathtools/document.py
@@ -0,0 +1,327 @@
+"""(Experimental) replacement for import/export functionality.
+
+This module contains the `Document` class, a container for a DOM-style
+document (e.g. svg, html, xml, etc.) designed to replace and improve
+upon the IO functionality of svgpathtools (i.e. the svg2paths and
+disvg/wsvg functions).
+
+An Historic Note:
+ The functionality in this module is meant to replace and improve
+ upon the IO functionality previously provided by the the
+ `svg2paths` and `disvg`/`wsvg` functions.
+
+Example:
+ Typical usage looks something like the following.
+
+ >> from svgpathtools import *
+ >> doc = Document('my_file.html')
+ >> results = doc.flatten_all_paths()
+ >> for result in results:
+ >> path = result.path
+ >> # Do something with the transformed Path object.
+ >> element = result.element
+ >> # Inspect the raw SVG element. This gives access to the path's attributes
+ >> transform = result.transform
+ >> # Use the transform that was applied to the path.
+ >> foo(doc.tree) # do stuff using ElementTree's functionality
+ >> doc.display() # display doc in OS's default application
+ >> doc.save('my_new_file.html')
+
+A Big Problem:
+ Derivatives and other functions may be messed up by
+ transforms unless transforms are flattened (and not included in
+ css)
+"""
+
+# External dependencies
+from __future__ import division, absolute_import, print_function
+import os
+import collections
+import xml.etree.ElementTree as etree
+from xml.etree.ElementTree import Element, SubElement, register_namespace, _namespace_map
+import warnings
+
+# Internal dependencies
+from .parser import parse_path
+from .parser import parse_transform
+from .svg_to_paths import (path2pathd, ellipse2pathd, line2pathd, polyline2pathd,
+ polygon2pathd, rect2pathd)
+from .misctools import open_in_browser
+from .path import *
+
+# To maintain forward/backward compatibility
+from past.builtins import basestring
+from future.utils import iteritems
+
+# Let xml.etree.ElementTree know about the SVG namespace
+SVG_NAMESPACE = {'svg': 'http://www.w3.org/2000/svg'}
+register_namespace('svg', 'http://www.w3.org/2000/svg')
+
+# THESE MUST BE WRAPPED TO OUTPUT ElementTree.element objects
+CONVERSIONS = {'path': path2pathd,
+ 'circle': ellipse2pathd,
+ 'ellipse': ellipse2pathd,
+ 'line': line2pathd,
+ 'polyline': polyline2pathd,
+ 'polygon': polygon2pathd,
+ 'rect': rect2pathd}
+
+CONVERT_ONLY_PATHS = {'path': path2pathd}
+
+SVG_GROUP_TAG = 'svg:g'
+
+
+def flatten_all_paths(
+ group,
+ group_filter=lambda x: True,
+ path_filter=lambda x: True,
+ path_conversions=CONVERSIONS,
+ group_search_xpath=SVG_GROUP_TAG):
+ """Returns the paths inside a group (recursively), expressing the paths in the base coordinates.
+
+ Note that if the group being passed in is nested inside some parent group(s), we cannot take the parent group(s)
+ into account, because xml.etree.Element has no pointer to its parent. You should use Document.flatten_group(group)
+ to flatten a specific nested group into the root coordinates.
+
+ Args:
+ group is an Element
+ path_conversions (dict): A dictionary to convert from an SVG element to a path data string. Any element tags
+ that are not included in this dictionary will be ignored (including the `path` tag).
+ To only convert explicit path elements, pass in path_conversions=CONVERT_ONLY_PATHS.
+ """
+ if not isinstance(group, Element):
+ raise TypeError('Must provide an xml.etree.Element object. Instead you provided {0}'.format(type(group)))
+
+ # Stop right away if the group_selector rejects this group
+ if not group_filter(group):
+ return []
+
+ # To handle the transforms efficiently, we'll traverse the tree of groups depth-first using a stack of tuples.
+ # The first entry in the tuple is a group element and the second entry is its transform. As we pop each entry in
+ # the stack, we will add all its child group elements to the stack.
+ StackElement = collections.namedtuple('StackElement', ['group', 'transform'])
+
+ def new_stack_element(element, last_tf):
+ return StackElement(element, last_tf.dot(parse_transform(element.get('transform'))))
+
+ def get_relevant_children(parent, last_tf):
+ children = []
+ for elem in filter(group_filter, parent.iterfind(group_search_xpath, SVG_NAMESPACE)):
+ children.append(new_stack_element(elem, last_tf))
+ return children
+
+ stack = [new_stack_element(group, np.identity(3))]
+
+ FlattenedPath = collections.namedtuple('FlattenedPath', ['path', 'element', 'transform'])
+ paths = []
+
+ while stack:
+ top = stack.pop()
+
+ # For each element type that we know how to convert into path data, parse the element after confirming that
+ # the path_filter accepts it.
+ for key, converter in iteritems(path_conversions):
+ for path_elem in filter(path_filter, top.group.iterfind('svg:'+key, SVG_NAMESPACE)):
+ path_tf = top.transform.dot(parse_transform(path_elem.get('transform')))
+ path = transform(parse_path(converter(path_elem)), path_tf)
+ paths.append(FlattenedPath(path, path_elem, path_tf))
+
+ stack.extend(get_relevant_children(top.group, top.transform))
+
+ return paths
+
+
+def flatten_group(
+ group_to_flatten,
+ root,
+ recursive=True,
+ group_filter=lambda x: True,
+ path_filter=lambda x: True,
+ path_conversions=CONVERSIONS,
+ group_search_xpath=SVG_GROUP_TAG):
+ """Flatten all the paths in a specific group.
+
+ The paths will be flattened into the 'root' frame. Note that root needs to be
+ an ancestor of the group that is being flattened. Otherwise, no paths will be returned."""
+
+ if not any(group_to_flatten is descendant for descendant in root.iter()):
+ warnings.warn('The requested group_to_flatten is not a descendant of root')
+ # We will shortcut here, because it is impossible for any paths to be returned anyhow.
+ return []
+
+ # We create a set of the unique IDs of each element that we wish to flatten, if those elements are groups.
+ # Any groups outside of this set will be skipped while we flatten the paths.
+ desired_groups = set()
+ if recursive:
+ for group in group_to_flatten.iter():
+ desired_groups.add(id(group))
+ else:
+ desired_groups.add(id(group_to_flatten))
+
+ def desired_group_filter(x):
+ return (id(x) in desired_groups) and group_filter(x)
+
+ return flatten_all_paths(root, desired_group_filter, path_filter, path_conversions, group_search_xpath)
+
+
+class Document:
+ def __init__(self, filename):
+ """A container for a DOM-style SVG document.
+
+ The `Document` class provides a simple interface to modify and analyze
+ the path elements in a DOM-style document. The DOM-style document is
+ parsed into an ElementTree object (stored in the `tree` attribute).
+
+ This class provides functions for extracting SVG data into Path objects.
+ The Path output objects will be transformed based on their parent groups.
+
+ Args:
+ filename (str): The filename of the DOM-style object.
+ """
+
+ # remember location of original svg file
+ if filename is not None and os.path.dirname(filename) == '':
+ self.original_filename = os.path.join(os.getcwd(), filename)
+ else:
+ self.original_filename = filename
+
+ if filename is not None:
+ # parse svg to ElementTree object
+ self.tree = etree.parse(filename)
+ else:
+ self.tree = etree.ElementTree(Element('svg'))
+
+ self.root = self.tree.getroot()
+
+ def flatten_all_paths(self,
+ group_filter=lambda x: True,
+ path_filter=lambda x: True,
+ path_conversions=CONVERSIONS):
+ """Forward the tree of this document into the more general flatten_all_paths function and return the result."""
+ return flatten_all_paths(self.tree.getroot(), group_filter, path_filter, path_conversions)
+
+ def flatten_group(self,
+ group,
+ recursive=True,
+ group_filter=lambda x: True,
+ path_filter=lambda x: True,
+ path_conversions=CONVERSIONS):
+ if all(isinstance(s, basestring) for s in group):
+ # If we're given a list of strings, assume it represents a nested sequence
+ group = self.get_or_add_group(group)
+ elif not isinstance(group, Element):
+ raise TypeError('Must provide a list of strings that represent a nested group name, '
+ 'or provide an xml.etree.Element object. Instead you provided {0}'.format(group))
+
+ return flatten_group(group, self.tree.getroot(), recursive, group_filter, path_filter, path_conversions)
+
+ def add_path(self, path, attribs=None, group=None):
+ """Add a new path to the SVG."""
+
+ # If we are not given a parent, assume that the path does not have a group
+ if group is None:
+ group = self.tree.getroot()
+
+ # If we are given a list of strings (one or more), assume it represents a sequence of nested group names
+ elif all(isinstance(elem, basestring) for elem in group):
+ group = self.get_or_add_group(group)
+
+ elif not isinstance(group, Element):
+ raise TypeError('Must provide a list of strings or an xml.etree.Element object. '
+ 'Instead you provided {0}'.format(group))
+
+ else:
+ # Make sure that the group belongs to this Document object
+ if not self.contains_group(group):
+ warnings.warn('The requested group does not belong to this Document')
+
+ # TODO: It might be better to use duck-typing here with a try-except
+ if isinstance(path, Path):
+ path_svg = path.d()
+ elif is_path_segment(path):
+ path_svg = Path(path).d()
+ elif isinstance(path, basestring):
+ # Assume this is a valid d-string. TODO: Should we sanity check the input string?
+ path_svg = path
+ else:
+ raise TypeError('Must provide a Path, a path segment type, or a valid SVG path d-string. '
+ 'Instead you provided {0}'.format(path))
+
+ if attribs is None:
+ attribs = {}
+ else:
+ attribs = attribs.copy()
+
+ attribs['d'] = path_svg
+
+ return SubElement(group, 'path', attribs)
+
+ def contains_group(self, group):
+ return any(group is owned for owned in self.tree.iter())
+
+ def get_or_add_group(self, nested_names, name_attr='id'):
+ """Get a group from the tree, or add a new one with the given name structure.
+
+ *nested_names* is a list of strings which represent group names. Each group name will be nested inside of the
+ previous group name.
+
+ *name_attr* is the group attribute that is being used to represent the group's name. Default is 'id', but some
+ SVGs may contain custom name labels, like 'inkscape:label'.
+
+ Returns the requested group. If the requested group did not exist, this function will create it, as well as all
+ parent groups that it requires. All created groups will be left with blank attributes.
+
+ """
+ group = self.tree.getroot()
+ # Drill down through the names until we find the desired group
+ while len(nested_names):
+ prev_group = group
+ next_name = nested_names.pop(0)
+ for elem in group.iterfind(SVG_GROUP_TAG, SVG_NAMESPACE):
+ if elem.get(name_attr) == next_name:
+ group = elem
+ break
+
+ if prev_group is group:
+ # The group we're looking for does not exist, so let's create the group structure
+ nested_names.insert(0, next_name)
+
+ while nested_names:
+ next_name = nested_names.pop(0)
+ group = self.add_group({'id': next_name}, group)
+ # Now nested_names will be empty, so the topmost while-loop will end
+
+ return group
+
+ def add_group(self, group_attribs=None, parent=None):
+ """Add an empty group element to the SVG."""
+ if parent is None:
+ parent = self.tree.getroot()
+ elif not self.contains_group(parent):
+ warnings.warn('The requested group {0} does not belong to this Document'.format(parent))
+
+ if group_attribs is None:
+ group_attribs = {}
+ else:
+ group_attribs = group_attribs.copy()
+
+ return SubElement(parent, '{{{0}}}g'.format(SVG_NAMESPACE['svg']), group_attribs)
+
+ def save(self, filename=None):
+ if filename is None:
+ filename = self.original_filename
+
+ with open(filename, 'w') as output_svg:
+ output_svg.write(etree.tostring(self.tree.getroot()))
+
+ def display(self, filename=None):
+ """Displays/opens the doc using the OS's default application."""
+
+ if filename is None:
+ filename = self.original_filename
+
+ # write to a (by default temporary) file
+ with open(filename, 'w') as output_svg:
+ output_svg.write(etree.tostring(self.tree.getroot()))
+
+ open_in_browser(filename)
diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py
index 2548448..2afe687 100644
--- a/svgpathtools/parser.py
+++ b/svgpathtools/parser.py
@@ -1,15 +1,18 @@
"""This submodule contains the path_parse() function used to convert SVG path
element d-strings into svgpathtools Path objects.
-Note: This file was taken (nearly) as is from the svg.path module
-(v 2.0)."""
+Note: This file was taken (nearly) as is from the svg.path module (v 2.0)."""
# External dependencies
from __future__ import division, absolute_import, print_function
import re
+import numpy as np
+import warnings
# Internal dependencies
from .path import Path, Line, QuadraticBezier, CubicBezier, Arc
+# To maintain forward/backward compatibility
+from past.builtins import basestring
COMMANDS = set('MmZzLlHhVvCcSsQqTtAa')
UPPERCASE = set('MZLHVCSQTA')
@@ -26,7 +29,7 @@ def _tokenize_path(pathdef):
yield token
-def parse_path(pathdef, current_pos=0j):
+def parse_path(pathdef, current_pos=0j, tree_element=None):
# In the SVG specs, initial movetos are absolute, even if
# specified as 'm'. This is the default behavior here as well.
# But if you pass in a current_pos variable, the initial moveto
@@ -35,7 +38,11 @@ def parse_path(pathdef, current_pos=0j):
# Reverse for easy use of .pop()
elements.reverse()
- segments = Path()
+ if tree_element is None:
+ segments = Path()
+ else:
+ segments = Path(tree_element=tree_element)
+
start_pos = None
command = None
@@ -193,3 +200,98 @@ def parse_path(pathdef, current_pos=0j):
current_pos = end
return segments
+
+
+def _check_num_parsed_values(values, allowed):
+ if not any(num == len(values) for num in allowed):
+ if len(allowed) > 1:
+ warnings.warn('Expected one of the following number of values {0}, but found {1} values instead: {2}'
+ .format(allowed, len(values), values))
+ elif allowed[0] != 1:
+ warnings.warn('Expected {0} values, found {1}: {2}'.format(allowed[0], len(values), values))
+ else:
+ warnings.warn('Expected 1 value, found {0}: {1}'.format(len(values), values))
+ return False
+ return True
+
+
+def _parse_transform_substr(transform_substr):
+
+ type_str, value_str = transform_substr.split('(')
+ value_str = value_str.replace(',', ' ')
+ values = list(map(float, filter(None, value_str.split(' '))))
+
+ transform = np.identity(3)
+ if 'matrix' in type_str:
+ if not _check_num_parsed_values(values, [6]):
+ return transform
+
+ transform[0:2, 0:3] = np.matrix([values[0:6:2], values[1:6:2]])
+
+ elif 'translate' in transform_substr:
+ if not _check_num_parsed_values(values, [1, 2]):
+ return transform
+
+ transform[0, 2] = values[0]
+ if len(values) > 1:
+ transform[1, 2] = values[1]
+
+ elif 'scale' in transform_substr:
+ if not _check_num_parsed_values(values, [1, 2]):
+ return transform
+
+ x_scale = values[0]
+ y_scale = values[1] if (len(values) > 1) else x_scale
+ transform[0, 0] = x_scale
+ transform[1, 1] = y_scale
+
+ elif 'rotate' in transform_substr:
+ if not _check_num_parsed_values(values, [1, 3]):
+ return transform
+
+ angle = values[0] * np.pi / 180.0
+ if len(values) == 3:
+ offset = values[1:3]
+ else:
+ offset = (0, 0)
+ tf_offset = np.identity(3)
+ tf_offset[0:2, 2:3] = np.matrix([[offset[0]], [offset[1]]])
+ tf_rotate = np.identity(3)
+ tf_rotate[0:2, 0:2] = np.matrix([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]])
+ tf_offset_neg = np.identity(3)
+ tf_offset_neg[0:2, 2:3] = np.matrix([[-offset[0]], [-offset[1]]])
+
+ transform = tf_offset.dot(tf_rotate).dot(tf_offset_neg)
+
+ elif 'skewX' in transform_substr:
+ if not _check_num_parsed_values(values, [1]):
+ return transform
+
+ transform[0, 1] = np.tan(values[0] * np.pi / 180.0)
+
+ elif 'skewY' in transform_substr:
+ if not _check_num_parsed_values(values, [1]):
+ return transform
+
+ transform[1, 0] = np.tan(values[0] * np.pi / 180.0)
+ else:
+ # Return an identity matrix if the type of transform is unknown, and warn the user
+ warnings.warn('Unknown SVG transform type: {0}'.format(type_str))
+
+ return transform
+
+
+def parse_transform(transform_str):
+ """Converts a valid SVG transformation string into a 3x3 matrix.
+ If the string is empty or null, this returns a 3x3 identity matrix"""
+ if not transform_str:
+ return np.identity(3)
+ elif not isinstance(transform_str, basestring):
+ raise TypeError('Must provide a string to parse')
+
+ total_transform = np.identity(3)
+ transform_substrs = transform_str.split(')')[:-1] # Skip the last element, because it should be empty
+ for substr in transform_substrs:
+ total_transform = total_transform.dot(_parse_transform_substr(substr))
+
+ return total_transform
diff --git a/svgpathtools/path.py b/svgpathtools/path.py
index 5f4453f..2a86e5b 100644
--- a/svgpathtools/path.py
+++ b/svgpathtools/path.py
@@ -255,6 +255,32 @@ def scale(curve, sx, sy=None, origin=0j):
"QuadraticBezier, CubicBezier, or Arc object.")
+def transform(curve, tf):
+ """Transforms the curve by the homogeneous transformation matrix tf"""
+ def to_point(p):
+ return np.matrix([[p.real], [p.imag], [1.0]])
+
+ def to_vector(z):
+ return np.matrix([[z.real], [z.imag], [0.0]])
+
+ def to_complex(v):
+ return v.item(0) + 1j * v.item(1)
+
+ if isinstance(curve, Path):
+ return Path(*[transform(segment, tf) for segment in curve])
+ elif is_bezier_segment(curve):
+ return bpoints2bezier([to_complex(tf.dot(to_point(p))) for p in curve.bpoints()])
+ elif isinstance(curve, Arc):
+ new_start = to_complex(tf.dot(to_point(curve.start)))
+ new_end = to_complex(tf.dot(to_point(curve.end)))
+ new_radius = to_complex(tf.dot(to_vector(curve.radius)))
+ return Arc(new_start, radius=new_radius, rotation=curve.rotation,
+ large_arc=curve.large_arc, sweep=curve.sweep, end=new_end)
+ else:
+ raise TypeError("Input `curve` should be a Path, Line, "
+ "QuadraticBezier, CubicBezier, or Arc object.")
+
+
def bezier_unit_tangent(seg, t):
"""Returns the unit tangent of the segment at t.
@@ -1783,6 +1809,9 @@ class Path(MutableSequence):
self._start = None
self._end = None
+ if 'tree_element' in kw:
+ self._tree_element = kw['tree_element']
+
def __getitem__(self, index):
return self._segments[index]
diff --git a/svgpathtools/svg_to_paths.py b/svgpathtools/svg_to_paths.py
index 05258e8..e922aaf 100644
--- a/svgpathtools/svg_to_paths.py
+++ b/svgpathtools/svg_to_paths.py
@@ -17,6 +17,8 @@ COORD_PAIR_TMPLT = re.compile(
r'([\+-]?\d*[\.\d]\d*[eE][\+-]?\d+|[\+-]?\d*[\.\d]\d*)'
)
+def path2pathd(path):
+ return path.get('d', '')
def ellipse2pathd(ellipse):
"""converts the parameters from an ellipse or a circle to a string for a
@@ -79,7 +81,7 @@ def rect2pathd(rect):
The rectangle will start at the (x,y) coordinate specified by the
rectangle object and proceed counter-clockwise."""
x0, y0 = float(rect.get('x', 0)), float(rect.get('y', 0))
- w, h = float(rect["width"]), float(rect["height"])
+ w, h = float(rect.get('width', 0)), float(rect.get('height', 0))
x1, y1 = x0 + w, y0
x2, y2 = x0 + w, y0 + h
x3, y3 = x0, y0 + h
@@ -88,6 +90,8 @@ def rect2pathd(rect):
"".format(x0, y0, x1, y1, x2, y2, x3, y3))
return d
+def line2pathd(l):
+ return 'M' + l['x1'] + ' ' + l['y1'] + 'L' + l['x2'] + ' ' + l['y2']
def svg2paths(svg_file_location,
return_svg_attributes=False,
diff --git a/test/groups.svg b/test/groups.svg
new file mode 100644
index 0000000..1787617
--- /dev/null
+++ b/test/groups.svg
@@ -0,0 +1,161 @@
+
+
\ No newline at end of file
diff --git a/test/test_groups.py b/test/test_groups.py
new file mode 100644
index 0000000..620a5ab
--- /dev/null
+++ b/test/test_groups.py
@@ -0,0 +1,192 @@
+from __future__ import division, absolute_import, print_function
+import unittest
+from svgpathtools import *
+from os.path import join, dirname
+import numpy as np
+
+
+def get_desired_path(name, paths):
+ return next(p for p in paths if p.element.get('{some://testuri}name') == name)
+
+
+def column_vector(values):
+ input = []
+ for value in values:
+ input.append([value])
+ return np.matrix(input)
+
+
+class TestGroups(unittest.TestCase):
+
+ def check_values(self, v, z):
+ # Check that the components of 2D vector v match the components of complex number z
+ self.assertAlmostEqual(v[0], z.real)
+ self.assertAlmostEqual(v[1], z.imag)
+
+ def check_line(self, tf, v_s_vals, v_e_relative_vals, name, paths):
+ # Check that the endpoints of the line have been correctly transformed.
+ # * tf is the transform that should have been applied.
+ # * v_s_vals is a 2D list of the values of the line's start point
+ # * v_e_relative_vals is a 2D list of the values of the line's end point relative to the start point
+ # * name is the path name (value of the test:name attribute in the SVG document)
+ # * paths is the output of doc.flatten_all_paths()
+ v_s_vals.append(1.0)
+ v_e_relative_vals.append(0.0)
+ v_s = column_vector(v_s_vals)
+ v_e = v_s + column_vector(v_e_relative_vals)
+
+ actual = get_desired_path(name, paths)
+
+ self.check_values(tf.dot(v_s), actual.path.start)
+ self.check_values(tf.dot(v_e), actual.path.end)
+
+ def test_group_flatten(self):
+ # Test the Document.flatten_all_paths() function against the groups.svg test file.
+ # There are 12 paths in that file, with various levels of being nested inside of group transforms.
+ # The check_line function is used to reduce the boilerplate, since all the tests are very similar.
+ # This test covers each of the different types of transforms that are specified by the SVG standard.
+ doc = Document(join(dirname(__file__), 'groups.svg'))
+
+ result = doc.flatten_all_paths()
+ self.assertEqual(12, len(result))
+
+ tf_matrix_group = np.matrix([[1.5, 0.0, -40.0], [0.0, 0.5, 20.0], [0.0, 0.0, 1.0]])
+
+ self.check_line(tf_matrix_group,
+ [183, 183], [0.0, -50],
+ 'path00', result)
+
+ tf_scale_group = np.matrix([[1.25, 0.0, 0.0], [0.0, 1.25, 0.0], [0.0, 0.0, 1.0]])
+
+ self.check_line(tf_matrix_group.dot(tf_scale_group),
+ [122, 320], [-50.0, 0.0],
+ 'path01', result)
+
+ self.check_line(tf_matrix_group.dot(tf_scale_group),
+ [150, 200], [-50, 25],
+ 'path02', result)
+
+ self.check_line(tf_matrix_group.dot(tf_scale_group),
+ [150, 200], [-50, 25],
+ 'path03', result)
+
+ tf_nested_translate_group = np.matrix([[1, 0, 20], [0, 1, 0], [0, 0, 1]])
+
+ self.check_line(tf_matrix_group.dot(tf_scale_group).dot(tf_nested_translate_group),
+ [150, 200], [-50, 25],
+ 'path04', result)
+
+ tf_nested_translate_xy_group = np.matrix([[1, 0, 20], [0, 1, 30], [0, 0, 1]])
+
+ self.check_line(tf_matrix_group.dot(tf_scale_group).dot(tf_nested_translate_xy_group),
+ [150, 200], [-50, 25],
+ 'path05', result)
+
+ tf_scale_xy_group = np.matrix([[0.5, 0, 0], [0, 1.5, 0.0], [0, 0, 1]])
+
+ self.check_line(tf_matrix_group.dot(tf_scale_xy_group),
+ [122, 320], [-50, 0],
+ 'path06', result)
+
+ a_07 = 20.0*np.pi/180.0
+ tf_rotate_group = np.matrix([[np.cos(a_07), -np.sin(a_07), 0],
+ [np.sin(a_07), np.cos(a_07), 0],
+ [0, 0, 1]])
+
+ self.check_line(tf_matrix_group.dot(tf_rotate_group),
+ [183, 183], [0, 30],
+ 'path07', result)
+
+ a_08 = 45.0*np.pi/180.0
+ tf_rotate_xy_group_R = np.matrix([[np.cos(a_08), -np.sin(a_08), 0],
+ [np.sin(a_08), np.cos(a_08), 0],
+ [0, 0, 1]])
+ tf_rotate_xy_group_T = np.matrix([[1, 0, 183], [0, 1, 183], [0, 0, 1]])
+ tf_rotate_xy_group = tf_rotate_xy_group_T.dot(tf_rotate_xy_group_R).dot(np.linalg.inv(tf_rotate_xy_group_T))
+
+ self.check_line(tf_matrix_group.dot(tf_rotate_xy_group),
+ [183, 183], [0, 30],
+ 'path08', result)
+
+ a_09 = 5.0*np.pi/180.0
+ tf_skew_x_group = np.matrix([[1, np.tan(a_09), 0], [0, 1, 0], [0, 0, 1]])
+
+ self.check_line(tf_matrix_group.dot(tf_skew_x_group),
+ [183, 183], [40, 40],
+ 'path09', result)
+
+ a_10 = 5.0*np.pi/180.0
+ tf_skew_y_group = np.matrix([[1, 0, 0], [np.tan(a_10), 1, 0], [0, 0, 1]])
+
+ self.check_line(tf_matrix_group.dot(tf_skew_y_group),
+ [183, 183], [40, 40],
+ 'path10', result)
+
+ # This last test is for handling transforms that are defined as attributes of a element.
+ a_11 = -40*np.pi/180.0
+ tf_path11_R = np.matrix([[np.cos(a_11), -np.sin(a_11), 0],
+ [np.sin(a_11), np.cos(a_11), 0],
+ [0, 0, 1]])
+ tf_path11_T = np.matrix([[1, 0, 100], [0, 1, 100], [0, 0, 1]])
+ tf_path11 = tf_path11_T.dot(tf_path11_R).dot(np.linalg.inv(tf_path11_T))
+
+ self.check_line(tf_matrix_group.dot(tf_skew_y_group).dot(tf_path11),
+ [180, 20], [-70, 80],
+ 'path11', result)
+
+ def check_group_count(self, doc, expected_count):
+ count = 0
+ for group in doc.tree.getroot().iter('{{{0}}}g'.format(SVG_NAMESPACE['svg'])):
+ count += 1
+
+ self.assertEqual(expected_count, count)
+
+ def test_add_group(self):
+ # Test the Document.add_group() function and related Document functions.
+ doc = Document(None)
+ self.check_group_count(doc, 0)
+
+ base_group = doc.add_group()
+ base_group.set('id', 'base_group')
+ self.assertTrue(doc.contains_group(base_group))
+ self.check_group_count(doc, 1)
+
+ child_group = doc.add_group(parent=base_group)
+ child_group.set('id', 'child_group')
+ self.assertTrue(doc.contains_group(child_group))
+ self.check_group_count(doc, 2)
+
+ grandchild_group = doc.add_group(parent=child_group)
+ grandchild_group.set('id', 'grandchild_group')
+ self.assertTrue(doc.contains_group(grandchild_group))
+ self.check_group_count(doc, 3)
+
+ sibling_group = doc.add_group(parent=base_group)
+ sibling_group.set('id', 'sibling_group')
+ self.assertTrue(doc.contains_group(sibling_group))
+ self.check_group_count(doc, 4)
+
+ # Test that we can retrieve each new group from the document
+ self.assertEqual(base_group, doc.get_or_add_group(['base_group']))
+ self.assertEqual(child_group, doc.get_or_add_group(['base_group', 'child_group']))
+ self.assertEqual(grandchild_group, doc.get_or_add_group(['base_group', 'child_group', 'grandchild_group']))
+ self.assertEqual(sibling_group, doc.get_or_add_group(['base_group', 'sibling_group']))
+
+ # Create a new nested group
+ new_child = doc.get_or_add_group(['base_group', 'new_parent', 'new_child'])
+ self.check_group_count(doc, 6)
+ self.assertEqual(new_child, doc.get_or_add_group(['base_group', 'new_parent', 'new_child']))
+
+ new_leaf = doc.get_or_add_group(['base_group', 'new_parent', 'new_child', 'new_leaf'])
+ self.assertEqual(new_leaf, doc.get_or_add_group(['base_group', 'new_parent', 'new_child', 'new_leaf']))
+ self.check_group_count(doc, 7)
+
+ path_d = 'M 206.07112,858.41289 L 206.07112,-2.02031 C -50.738,-81.14814 -20.36402,-105.87055 ' \
+ '52.52793,-101.01525 L 103.03556,0.0 L 0.0,111.11678'
+
+ svg_path = doc.add_path(path_d, group=new_leaf)
+ self.assertEqual(path_d, svg_path.get('d'))
+
+ path = parse_path(path_d)
+ svg_path = doc.add_path(path, group=new_leaf)
+ self.assertEqual(path_d, svg_path.get('d'))
\ No newline at end of file
diff --git a/test/test_parsing.py b/test/test_parsing.py
index c052ad8..90ef608 100644
--- a/test/test_parsing.py
+++ b/test/test_parsing.py
@@ -3,6 +3,20 @@
from __future__ import division, absolute_import, print_function
import unittest
from svgpathtools import *
+import svgpathtools
+import numpy as np
+
+
+def construct_rotation_tf(a, x, y):
+ a = a * np.pi / 180.0
+ tf_offset = np.identity(3)
+ tf_offset[0:2, 2:3] = np.matrix([[x], [y]])
+ tf_rotate = np.identity(3)
+ tf_rotate[0:2, 0:2] = np.matrix([[np.cos(a), -np.sin(a)], [np.sin(a), np.cos(a)]])
+ tf_offset_neg = np.identity(3)
+ tf_offset_neg[0:2, 2:3] = np.matrix([[-x], [-y]])
+
+ return tf_offset.dot(tf_rotate).dot(tf_offset_neg)
class TestParser(unittest.TestCase):
@@ -137,3 +151,67 @@ class TestParser(unittest.TestCase):
def test_errors(self):
self.assertRaises(ValueError, parse_path, 'M 100 100 L 200 200 Z 100 200')
+
+
+ def test_transform(self):
+
+ tf_matrix = svgpathtools.parser.parse_transform('matrix(1.0 2.0 3.0 4.0 5.0 6.0)')
+ expected_tf_matrix = np.identity(3)
+ expected_tf_matrix[0:2, 0:3] = np.matrix([[1.0, 3.0, 5.0], [2.0, 4.0, 6.0]])
+ self.assertTrue(np.array_equal(expected_tf_matrix, tf_matrix))
+
+ # Try a test with no y specified
+ expected_tf_translate = np.identity(3)
+ expected_tf_translate[0, 2] = -36
+ self.assertTrue(np.array_equal(
+ expected_tf_translate,
+ svgpathtools.parser.parse_transform('translate(-36)')
+ ))
+
+ # Now specify y
+ expected_tf_translate[1, 2] = 45.5
+ tf_translate = svgpathtools.parser.parse_transform('translate(-36 45.5)')
+ self.assertTrue(np.array_equal(expected_tf_translate, tf_translate))
+
+ # Try a test with no y specified
+ expected_tf_scale = np.identity(3)
+ expected_tf_scale[0, 0] = 10
+ expected_tf_scale[1, 1] = 10
+ self.assertTrue(np.array_equal(
+ expected_tf_scale,
+ svgpathtools.parser.parse_transform('scale(10)')
+ ))
+
+ # Now specify y
+ expected_tf_scale[1, 1] = 0.5
+ tf_scale = svgpathtools.parser.parse_transform('scale(10 0.5)')
+ self.assertTrue(np.array_equal(expected_tf_scale, tf_scale))
+
+ tf_rotation = svgpathtools.parser.parse_transform('rotate(-10 50 100)')
+ expected_tf_rotation = construct_rotation_tf(-10, 50, 100)
+ self.assertTrue(np.array_equal(expected_tf_rotation, tf_rotation))
+
+ # Try a test with no offset specified
+ self.assertTrue(np.array_equal(
+ construct_rotation_tf(50, 0, 0),
+ svgpathtools.parser.parse_transform('rotate(50)')
+ ))
+
+ expected_tf_skewx = np.identity(3)
+ expected_tf_skewx[0, 1] = np.tan(40.0 * np.pi/180.0)
+ tf_skewx = svgpathtools.parser.parse_transform('skewX(40)')
+ self.assertTrue(np.array_equal(expected_tf_skewx, tf_skewx))
+
+ expected_tf_skewy = np.identity(3)
+ expected_tf_skewy[1, 0] = np.tan(30.0 * np.pi / 180.0)
+ tf_skewy = svgpathtools.parser.parse_transform('skewY(30)')
+ self.assertTrue(np.array_equal(expected_tf_skewy, tf_skewy))
+
+ self.assertTrue(np.array_equal(
+ tf_rotation.dot(tf_translate).dot(tf_skewx).dot(tf_scale),
+ svgpathtools.parser.parse_transform(
+ """rotate(-10 50 100)
+ translate(-36 45.5)
+ skewX(40)
+ scale(10 0.5)""")
+ ))