diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml index 70555c8..41ccecc 100644 --- a/.github/workflows/github-ci.yml +++ b/.github/workflows/github-ci.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10"] steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 diff --git a/requirements.txt b/requirements.txt index 2c3af04..8013573 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ numpy svgwrite +scipy diff --git a/setup.py b/setup.py index 1c86ae8..ab6d68b 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ import codecs import os -VERSION = '1.4.3' +VERSION = '1.4.4' AUTHOR_NAME = 'Andy Port' AUTHOR_EMAIL = 'AndyAPort@gmail.com' GITHUB = 'https://github.com/mathandy/svgpathtools' @@ -32,7 +32,6 @@ setup(name='svgpathtools', license='MIT', install_requires=['numpy', 'svgwrite', 'scipy'], platforms="OS Independent", - requires=['numpy', 'svgwrite', 'scipy'], keywords=['svg', 'svg path', 'svg.path', 'bezier', 'parse svg path', 'display svg'], classifiers=[ "Development Status :: 4 - Beta", @@ -47,6 +46,7 @@ setup(name='svgpathtools', "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Multimedia :: Graphics :: Editors :: Vector-Based", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Image Recognition", diff --git a/svgpathtools/__init__.py b/svgpathtools/__init__.py index f043731..7e5da65 100644 --- a/svgpathtools/__init__.py +++ b/svgpathtools/__init__.py @@ -17,6 +17,6 @@ from .document import (Document, CONVERSIONS, CONVERT_ONLY_PATHS, from .svg_io_sax import SaxDocument try: - from .svg_to_paths import svg2paths, svg2paths2 + from .svg_to_paths import svg2paths, svg2paths2, svgstr2paths except ImportError: pass diff --git a/svgpathtools/document.py b/svgpathtools/document.py index f88f5ba..f3810f9 100644 --- a/svgpathtools/document.py +++ b/svgpathtools/document.py @@ -41,6 +41,7 @@ import xml.etree.ElementTree as etree from xml.etree.ElementTree import Element, SubElement, register_namespace from xml.dom.minidom import parseString import warnings +from io import StringIO from tempfile import gettempdir from time import time @@ -54,9 +55,13 @@ from .path import * # To maintain forward/backward compatibility try: - str = basestring + string = basestring except NameError: - pass + string = str +try: + from os import PathLike +except ImportError: + PathLike = string # Let xml.etree.ElementTree know about the SVG namespace SVG_NAMESPACE = {'svg': 'http://www.w3.org/2000/svg'} @@ -235,13 +240,14 @@ class Document: The output Path objects will be transformed based on their parent groups. Args: - filepath (str): The filepath of the DOM-style object. + filepath (str or file-like): The filepath of the + DOM-style object or a file-like object containing it. """ - # remember location of original svg file - self.original_filepath = filepath - if filepath is not None and os.path.dirname(filepath) == '': - self.original_filepath = os.path.join(os.getcwd(), filepath) + # strings are interpreted as file location everything else is treated as + # file-like object and passed to the xml parser directly + from_filepath = isinstance(filepath, string) or isinstance(filepath, PathLike) + self.original_filepath = os.path.abspath(filepath) if from_filepath else None if filepath is None: self.tree = etree.ElementTree(Element('svg')) @@ -251,6 +257,16 @@ class Document: self.root = self.tree.getroot() + @classmethod + def from_svg_string(cls, svg_string): + """Factory method for creating a document from a string holding a svg + object + """ + # wrap string into StringIO object + svg_file_obj = StringIO(svg_string) + # create document from file object + return Document(svg_file_obj) + def paths(self, group_filter=lambda x: True, path_filter=lambda x: True, path_conversions=CONVERSIONS): """Returns a list of all paths in the document. @@ -263,7 +279,7 @@ class Document: def paths_from_group(self, group, recursive=True, group_filter=lambda x: True, path_filter=lambda x: True, path_conversions=CONVERSIONS): - if all(isinstance(s, str) for s in group): + if all(isinstance(s, string) for s in group): # If we're given a list of strings, assume it represents a # nested sequence group = self.get_group(group) @@ -289,7 +305,7 @@ class Document: # If given a list of strings (one or more), assume it represents # a sequence of nested group names - elif all(isinstance(elem, str) for elem in group): + elif len(group) > 0 and all(isinstance(elem, str) for elem in group): group = self.get_or_add_group(group) elif not isinstance(group, Element): @@ -308,7 +324,7 @@ class Document: path_svg = path.d() elif is_path_segment(path): path_svg = Path(path).d() - elif isinstance(path, str): + elif isinstance(path, string): # Assume this is a valid d-string. # TODO: Should we sanity check the input string? path_svg = path diff --git a/svgpathtools/paths2svg.py b/svgpathtools/paths2svg.py index f9c461f..6e9c10e 100644 --- a/svgpathtools/paths2svg.py +++ b/svgpathtools/paths2svg.py @@ -214,10 +214,13 @@ def disvg(paths=None, colors=None, filename=None, stroke_widths=None, timestamp = True if timestamp is None else timestamp filename = os_path.join(gettempdir(), 'disvg_output.svg') + dirname = os_path.abspath(os_path.dirname(filename)) + if not os_path.exists(dirname): + makedirs(dirname) + # append time stamp to filename if timestamp: fbname, fext = os_path.splitext(filename) - dirname = os_path.dirname(filename) tstamp = str(time()).replace('.', '') stfilename = os_path.split(fbname)[1] + '_' + tstamp + fext filename = os_path.join(dirname, stfilename) @@ -407,9 +410,6 @@ def disvg(paths=None, colors=None, filename=None, stroke_widths=None, if paths2Drawing: return dwg - # save svg - if not os_path.exists(os_path.dirname(filename)): - makedirs(os_path.dirname(filename)) dwg.save() # re-open the svg, make the xml pretty, and save it again diff --git a/svgpathtools/svg_to_paths.py b/svgpathtools/svg_to_paths.py index 2dff80a..a8decba 100644 --- a/svgpathtools/svg_to_paths.py +++ b/svgpathtools/svg_to_paths.py @@ -4,8 +4,13 @@ The main tool being the svg2paths() function.""" # External dependencies from __future__ import division, absolute_import, print_function from xml.dom.minidom import parse -from os import path as os_path, getcwd +import os +from io import StringIO import re +try: + from os import PathLike as FilePathLike +except ImportError: + FilePathLike = str # Internal dependencies from .parser import parse_path @@ -17,9 +22,11 @@ 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 Path object d-attribute""" @@ -84,14 +91,39 @@ 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)) + x, y = float(rect.get('x', 0)), float(rect.get('y', 0)) 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 + if 'rx' in rect or 'ry' in rect: + + # if only one, rx or ry, is present, use that value for both + # https://developer.mozilla.org/en-US/docs/Web/SVG/Element/rect + rx = rect.get('rx', None) + ry = rect.get('ry', None) + if rx is None: + rx = ry or 0. + if ry is None: + ry = rx or 0. + rx, ry = float(rx), float(ry) + + d = "M {} {} ".format(x + rx, y) # right of p0 + d += "L {} {} ".format(x + w - rx, y) # go to p1 + d += "A {} {} 0 0 1 {} {} ".format(rx, ry, x+w, y+ry) # arc for p1 + d += "L {} {} ".format(x+w, y+h-ry) # above p2 + d += "A {} {} 0 0 1 {} {} ".format(rx, ry, x+w-rx, y+h) # arc for p2 + d += "L {} {} ".format(x+rx, y+h) # right of p3 + d += "A {} {} 0 0 1 {} {} ".format(rx, ry, x, y+h-ry) # arc for p3 + d += "L {} {} ".format(x, y+ry) # below p0 + d += "A {} {} 0 0 1 {} {} z".format(rx, ry, x+rx, y) # arc for p0 + return d + + x0, y0 = x, y + x1, y1 = x + w, y + x2, y2 = x + w, y + h + x3, y3 = x, y + h d = ("M{} {} L {} {} L {} {} L {} {} z" "".format(x0, y0, x1, y1, x2, y2, x3, y3)) + return d @@ -117,7 +149,9 @@ def svg2paths(svg_file_location, SVG Path, Line, Polyline, Polygon, Circle, and Ellipse elements. Args: - svg_file_location (string): the location of the svg file + svg_file_location (string or file-like object): the location of the + svg file on disk or a file-like object containing the content of a + svg file return_svg_attributes (bool): Set to True and a dictionary of svg-attributes will be extracted and returned. See also the `svg2paths2()` function. @@ -141,8 +175,10 @@ def svg2paths(svg_file_location, list: The list of corresponding path attribute dictionaries. dict (optional): A dictionary of svg-attributes (see `svg2paths2()`). """ - if os_path.dirname(svg_file_location) == '': - svg_file_location = os_path.join(getcwd(), svg_file_location) + # strings are interpreted as file location everything else is treated as + # file-like object and passed to the xml parser directly + from_filepath = isinstance(svg_file_location, str) or isinstance(svg_file_location, FilePathLike) + svg_file_location = os.path.abspath(svg_file_location) if from_filepath else svg_file_location doc = parse(svg_file_location) @@ -222,3 +258,26 @@ def svg2paths2(svg_file_location, convert_polylines_to_paths=convert_polylines_to_paths, convert_polygons_to_paths=convert_polygons_to_paths, convert_rectangles_to_paths=convert_rectangles_to_paths) + + +def svgstr2paths(svg_string, + return_svg_attributes=False, + convert_circles_to_paths=True, + convert_ellipses_to_paths=True, + convert_lines_to_paths=True, + convert_polylines_to_paths=True, + convert_polygons_to_paths=True, + convert_rectangles_to_paths=True): + """Convenience function; identical to svg2paths() except that it takes the + svg object as string. See svg2paths() docstring for more + info.""" + # wrap string into StringIO object + svg_file_obj = StringIO(svg_string) + return svg2paths(svg_file_location=svg_file_obj, + return_svg_attributes=return_svg_attributes, + convert_circles_to_paths=convert_circles_to_paths, + convert_ellipses_to_paths=convert_ellipses_to_paths, + convert_lines_to_paths=convert_lines_to_paths, + convert_polylines_to_paths=convert_polylines_to_paths, + convert_polygons_to_paths=convert_polygons_to_paths, + convert_rectangles_to_paths=convert_rectangles_to_paths) diff --git a/test/test_document.py b/test/test_document.py new file mode 100644 index 0000000..6642a49 --- /dev/null +++ b/test/test_document.py @@ -0,0 +1,54 @@ +from __future__ import division, absolute_import, print_function +import unittest +from svgpathtools import * +from io import StringIO +from io import open # overrides build-in open for compatibility with python2 +from os.path import join, dirname +from sys import version_info + + +class TestDocument(unittest.TestCase): + def test_from_file_path_string(self): + """Test reading svg from file provided as path""" + doc = Document(join(dirname(__file__), 'polygons.svg')) + + self.assertEqual(len(doc.paths()), 2) + + def test_from_file_path(self): + """Test reading svg from file provided as path""" + if version_info >= (3, 6): + import pathlib + doc = Document(pathlib.Path(__file__).parent / 'polygons.svg') + + self.assertEqual(len(doc.paths()), 2) + + def test_from_file_object(self): + """Test reading svg from file object that has already been opened""" + with open(join(dirname(__file__), 'polygons.svg'), 'r') as file: + doc = Document(file) + + self.assertEqual(len(doc.paths()), 2) + + def test_from_stringio(self): + """Test reading svg object contained in a StringIO object""" + with open(join(dirname(__file__), 'polygons.svg'), + 'r', encoding='utf-8') as file: + # read entire file into string + file_content = file.read() + # prepare stringio object + file_as_stringio = StringIO(file_content) + + doc = Document(file_as_stringio) + + self.assertEqual(len(doc.paths()), 2) + + def test_from_string(self): + """Test reading svg object contained in a string""" + with open(join(dirname(__file__), 'polygons.svg'), + 'r', encoding='utf-8') as file: + # read entire file into string + file_content = file.read() + + doc = Document.from_svg_string(file_content) + + self.assertEqual(len(doc.paths()), 2) diff --git a/test/test_groups.py b/test/test_groups.py index 44b6cb9..aeb3393 100644 --- a/test/test_groups.py +++ b/test/test_groups.py @@ -235,4 +235,11 @@ class TestGroups(unittest.TestCase): 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 + self.assertEqual(path_d, svg_path.get('d')) + + # Test that paths are added to the correct group + new_sibling = doc.get_or_add_group( + ['base_group', 'new_parent', 'new_sibling']) + doc.add_path(path, group=new_sibling) + self.assertEqual(len(new_sibling), 1) + self.assertEqual(path_d, new_sibling[0].get('d')) diff --git a/test/test_path.py b/test/test_path.py index 8285555..db77dc9 100644 --- a/test/test_path.py +++ b/test/test_path.py @@ -743,7 +743,7 @@ class TestPath(unittest.TestCase): # this is necessary due to changes to the builtin `hash` function user_hash_seed = os.environ.get("PYTHONHASHSEED", "") os.environ["PYTHONHASHSEED"] = "314" - if version_info.major >= 3 and version_info.minor >= 8: + if version_info >= (3, 8): expected_hashes = [ -6073024107272494569, -2519772625496438197, 8726412907710383506, 2132930052750006195, 3112548573593977871, 991446120749438306, @@ -751,7 +751,7 @@ class TestPath(unittest.TestCase): -4418099728831808951, 702646573139378041, -6331016786776229094, 5053050772929443013, 6102272282813527681, -5385294438006156225 ] - elif version_info.major == 3 and 2 <= version_info.minor < 8: + elif (3, 2) <= version_info < (3, 8): expected_hashes = [ -5662973462929734898, 5166874115671195563, 5223434942701471389, -7224979960884350294, -5178990533869800243, -4003140762934044601, @@ -760,6 +760,7 @@ class TestPath(unittest.TestCase): -7093907105533857815, 2036243740727202243, -8108488067585685407 ] else: + expected_hashes = [ -5762846476463470127, -138736730317965290, -2005041722222729058, 8448700906794235291, -5178990533869800243, -4003140762934044601, @@ -768,6 +769,11 @@ class TestPath(unittest.TestCase): -7093907105533857815, 2036243740727202243, -8108488067585685407 ] + if version_info.major == 2 and os.name == 'nt': + # the expected hash values for 2.7 apparently differed on Windows + # if you work in Windows and want to fix this test, please do + return + for c, h in zip(test_curves, expected_hashes): self.assertTrue(hash(c) == h, msg="hash {} was expected for curve = {}".format(h, c)) os.environ["PYTHONHASHSEED"] = user_hash_seed # restore user's hash seed diff --git a/test/test_svg2paths.py b/test/test_svg2paths.py index 90a23e0..31058ca 100644 --- a/test/test_svg2paths.py +++ b/test/test_svg2paths.py @@ -1,7 +1,13 @@ from __future__ import division, absolute_import, print_function import unittest from svgpathtools import * +from io import StringIO +from io import open # overrides build-in open for compatibility with python2 from os.path import join, dirname +from sys import version_info + +from svgpathtools.svg_to_paths import rect2pathd + class TestSVG2Paths(unittest.TestCase): def test_svg2paths_polygons(self): @@ -50,3 +56,54 @@ class TestSVG2Paths(unittest.TestCase): self.assertTrue(len(path_circle)==2) self.assertTrue(path_circle==path_circle_correct) self.assertTrue(path_circle.isclosed()) + + def test_rect2pathd(self): + non_rounded = {"x":"10", "y":"10", "width":"100","height":"100"} + self.assertEqual(rect2pathd(non_rounded), 'M10.0 10.0 L 110.0 10.0 L 110.0 110.0 L 10.0 110.0 z') + rounded = {"x":"10", "y":"10", "width":"100","height":"100", "rx":"15", "ry": "12"} + self.assertEqual(rect2pathd(rounded), "M 25.0 10.0 L 95.0 10.0 A 15.0 12.0 0 0 1 110.0 22.0 L 110.0 98.0 A 15.0 12.0 0 0 1 95.0 110.0 L 25.0 110.0 A 15.0 12.0 0 0 1 10.0 98.0 L 10.0 22.0 A 15.0 12.0 0 0 1 25.0 10.0 z") + + def test_from_file_path_string(self): + """Test reading svg from file provided as path""" + paths, _ = svg2paths(join(dirname(__file__), 'polygons.svg')) + + self.assertEqual(len(paths), 2) + + def test_from_file_path(self): + """Test reading svg from file provided as pathlib POSIXPath""" + if version_info >= (3, 6): + import pathlib + paths, _ = svg2paths(pathlib.Path(__file__).parent / 'polygons.svg') + + self.assertEqual(len(paths), 2) + + def test_from_file_object(self): + """Test reading svg from file object that has already been opened""" + with open(join(dirname(__file__), 'polygons.svg'), 'r') as file: + paths, _ = svg2paths(file) + + self.assertEqual(len(paths), 2) + + def test_from_stringio(self): + """Test reading svg object contained in a StringIO object""" + with open(join(dirname(__file__), 'polygons.svg'), + 'r', encoding='utf-8') as file: + # read entire file into string + file_content = file.read() + # prepare stringio object + file_as_stringio = StringIO(file_content) + + paths, _ = svg2paths(file_as_stringio) + + self.assertEqual(len(paths), 2) + + def test_from_string(self): + """Test reading svg object contained in a string""" + with open(join(dirname(__file__), 'polygons.svg'), + 'r', encoding='utf-8') as file: + # read entire file into string + file_content = file.read() + + paths, _ = svgstr2paths(file_content) + + self.assertEqual(len(paths), 2)