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 <path> 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 compatibilitypull/63/head
parent
ccc9ee6ae1
commit
360d6b224c
|
@ -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
|
||||
|
|
4
setup.py
4
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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,161 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg
|
||||
baseProfile="full"
|
||||
version="1.1"
|
||||
viewBox="0 0 365 365"
|
||||
height="100%"
|
||||
width="100%"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:test="some://testuri">
|
||||
<defs/>
|
||||
<g
|
||||
id="matrix group"
|
||||
transform="matrix(1.5 0.0 0.0 0.5 -40.0 20.0)">
|
||||
|
||||
<path
|
||||
d="M 183,183 l 0,-50"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path00"/>
|
||||
|
||||
<g
|
||||
id="scale group"
|
||||
transform="scale(1.25)">
|
||||
|
||||
<path
|
||||
d="M 122,320 l -50,0"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path01"/>
|
||||
|
||||
<g
|
||||
id="nested group - empty transform"
|
||||
transform="">
|
||||
|
||||
<path
|
||||
d="M 150,200 l -50,25"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path02"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
id="nested group - no transform">
|
||||
|
||||
<path
|
||||
d="M 150,200 l -50,25"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path03"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
id="nested group - translate"
|
||||
transform="translate(20)">
|
||||
|
||||
<path
|
||||
d="M 150,200 l -50,25"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path04"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
id="nested group - translate xy"
|
||||
transform="translate(20, 30)">
|
||||
|
||||
<path
|
||||
d="M 150,200 l -50,25"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path05"/>
|
||||
|
||||
</g>
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
id="scale xy group"
|
||||
transform="scale(0.5 1.5)">
|
||||
|
||||
<path
|
||||
d="M 122,320 l -50,0"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path06"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
id="rotate group"
|
||||
transform="rotate(20)">
|
||||
|
||||
<path
|
||||
d="M 183,183 l 0,30"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path07"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
id="rotate xy group"
|
||||
transform="rotate(45 183 183)">
|
||||
|
||||
<path
|
||||
d="M 183,183 l 0,30"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path08"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
id="skew x group"
|
||||
transform="skewX(5)">
|
||||
|
||||
<path
|
||||
d="M 183,183 l 40,40"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path09"/>
|
||||
|
||||
</g>
|
||||
|
||||
<g
|
||||
id="skew y group"
|
||||
transform="skewY(5)">
|
||||
|
||||
<path
|
||||
d="M 183,183 l 40,40"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="3"
|
||||
test:name="path10"/>
|
||||
|
||||
<path
|
||||
d="M 180,20 l -70,80"
|
||||
fill="black"
|
||||
stroke="black"
|
||||
stroke-width="4"
|
||||
transform="rotate(-40, 100, 100)"
|
||||
test:name="path11"/>
|
||||
|
||||
</g>
|
||||
|
||||
</g>
|
||||
|
||||
</svg>
|
After Width: | Height: | Size: 3.0 KiB |
|
@ -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 <path> 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'))
|
|
@ -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)""")
|
||||
))
|
||||
|
|
Loading…
Reference in New Issue