From 360d6b224c53701c245190d407861492905f3a3d Mon Sep 17 00:00:00 2001 From: "Michael X. Grey" Date: Wed, 22 Aug 2018 08:00:29 +0700 Subject: [PATCH] Flattening SVG groups and handling transforms (#55) * Some progress (and added CONTRIBUTING.md) * fixed documentation line-width to be PEP 8 compliant * fixed documentation line-width to be PEP 8 compliant * style changes * made some design changes * Make the Document class available when importing the library * Add a method to parse transform strings * Iterate on the implementation of the Document class * Tweaks to transform parsing implementation * Implementing a depth-first flattening of groups * Finish implementation of flatten_paths * Beginning to write tests for groups * Refactoring flatten_paths() into flatten_all_paths() * Clean up implementation of document classes * Debugging xml namespace behavior -- needs improvement * Improve the way the svg namespace is handled * Print out some paths to see that they're sane * Fix multiplication of numpy matrices -- need to use .dot() instead of operator* * Create a unit test for parsing SVG groups * Return a reference to an element instead of a copied dictionary of attributes * Add a test for elements that contain a 'transform' attribute * minor docstring improvements * got rid of svg2path changes (reverted to master) * updated to match master * Remove accidental paranthesis * Remove unnecessary import * Use a default width and height of 0, as dictated by SVG specs, in case width or height is missing * Expose the CONVERSIONS and CONVERT_ONLY_PATHS constants * Fix the use of some numpy operations * Remove untested functions * Fix add_group() and write tests for adding groups and paths * Update documentation of document module * Add tests for parsing transforms * Update the module name for svg_to_paths * Improve Python3 compatibility * Try to improve compatibility * More tweaks for compatibility --- .travis.yml | 2 +- setup.py | 4 +- svgpathtools/__init__.py | 1 + svgpathtools/document.py | 327 +++++++++++++++++++++++++++++++++++ svgpathtools/parser.py | 110 +++++++++++- svgpathtools/path.py | 29 ++++ svgpathtools/svg_to_paths.py | 6 +- test/groups.svg | 161 +++++++++++++++++ test/test_groups.py | 192 ++++++++++++++++++++ test/test_parsing.py | 78 +++++++++ 10 files changed, 902 insertions(+), 8 deletions(-) create mode 100644 svgpathtools/document.py create mode 100644 test/groups.svg create mode 100644 test/test_groups.py 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)""") + ))