├── .github └── workflows │ ├── codacy.yml │ ├── codeql-analysis.yml │ ├── github-ci.yml │ └── publish-on-pypi.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.txt ├── LICENSE2.txt ├── MANIFEST.in ├── README.ipynb ├── README.md ├── SECURITY.md ├── decorated_ellipse.svg ├── donate-button.svg ├── examples ├── compute-many-points-quickly-using-numpy-arrays.py ├── determine-if-svg-path-is-contained-in-other-path-example.py ├── distance-between-two-svg-paths-example.py ├── wasm-via-pyodide-example.html └── zero-radius-arcs.svg ├── offset_curves.svg ├── output1.svg ├── output2.svg ├── output_intersections.svg ├── path.svg ├── requirements.txt ├── setup.cfg ├── setup.py ├── svgpathtools ├── __init__.py ├── bezier.py ├── constants.py ├── document.py ├── misctools.py ├── parser.py ├── path.py ├── paths2svg.py ├── polytools.py ├── smoothing.py ├── svg_io_sax.py └── svg_to_paths.py ├── test.svg ├── test ├── __init__.py ├── circle.svg ├── display_temp.svg ├── ellipse.svg ├── groups.svg ├── negative-scale.svg ├── polygons.svg ├── polygons_no_points.svg ├── polyline.svg ├── rects.svg ├── test.svg ├── test_bezier.py ├── test_document.py ├── test_generation.py ├── test_groups.py ├── test_parsing.py ├── test_path.py ├── test_polytools.py ├── test_sax_groups.py ├── test_svg2paths.py └── transforms.svg └── vectorframes.svg /.github/workflows/codacy.yml: -------------------------------------------------------------------------------- 1 | name: Codacy 2 | 3 | on: ["push"] 4 | 5 | jobs: 6 | codacy-analysis-cli: 7 | name: Codacy Analysis CLI 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@main 12 | 13 | - name: Run Codacy Analysis CLI 14 | uses: codacy/codacy-analysis-cli-action@master 15 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | pull_request: 17 | schedule: 18 | - cron: '30 2 * * 3' 19 | 20 | jobs: 21 | analyze: 22 | name: Analyze 23 | runs-on: ubuntu-latest 24 | permissions: 25 | actions: read 26 | contents: read 27 | security-events: write 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | language: [ 'python' ] 33 | 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v2 37 | 38 | # Initializes the CodeQL tools for scanning. 39 | - name: Initialize CodeQL 40 | uses: github/codeql-action/init@v1 41 | with: 42 | languages: ${{ matrix.language }} 43 | # If you wish to specify custom queries, you can do so here or in a config file. 44 | # By default, queries listed here will override any specified in a config file. 45 | # Prefix the list here with "+" to use these queries and those in the config file. 46 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 47 | 48 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 49 | # If this step fails, then you should remove it and run the build manually (see below) 50 | - name: Autobuild 51 | uses: github/codeql-action/autobuild@v1 52 | 53 | # ℹ️ Command-line programs to run using the OS shell. 54 | # 📚 https://git.io/JvXDl 55 | 56 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 57 | # and modify them (or add more) to build your code if your project 58 | # uses a compiled language 59 | 60 | #- run: | 61 | # make bootstrap 62 | # make release 63 | 64 | - name: Perform CodeQL Analysis 65 | uses: github/codeql-action/analyze@v1 66 | -------------------------------------------------------------------------------- /.github/workflows/github-ci.yml: -------------------------------------------------------------------------------- 1 | name: Github CI Unit Testing 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | continue-on-error: true 12 | strategy: 13 | matrix: 14 | os: [ubuntu-24.04, macos-15, windows-2025] 15 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] 16 | steps: 17 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 18 | - uses: actions/checkout@v2 19 | 20 | # configure python 21 | - uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | # install deps 26 | - name: Install dependencies for ${{ matrix.os }} Python ${{ matrix.python-version }} 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | pip install scipy 31 | 32 | # find and run all unit tests 33 | - name: Run unit tests 34 | run: python -m unittest discover test 35 | -------------------------------------------------------------------------------- /.github/workflows/publish-on-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI if new version 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish to TestPyPI and PyPI 11 | runs-on: ubuntu-24.04 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: '3.8' 19 | 20 | - name: Upgrade pip 21 | run: python -m pip install --upgrade pip 22 | 23 | - name: Install build tool 24 | run: python -m pip install build 25 | 26 | - name: Build a binary wheel and a source tarball 27 | run: python -m build --sdist --wheel --outdir dist/ 28 | 29 | - name: Publish to Test PyPI 30 | uses: pypa/gh-action-pypi-publish@release/v1 31 | with: 32 | skip_existing: true 33 | password: ${{ secrets.TESTPYPI_API_TOKEN }} 34 | repository_url: https://test.pypi.org/legacy/ 35 | 36 | - name: Publish to PyPI 37 | if: startsWith(github.ref, 'refs/tags') 38 | uses: pypa/gh-action-pypi-publish@release/v1 39 | with: 40 | skip_existing: true 41 | password: ${{ secrets.PYPI_API_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .* 3 | /svgpathtools/nonunittests 4 | build 5 | svgpathtools.egg-info 6 | !.travis.yml 7 | !/.gitignore 8 | !/.github 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to svgpathtools 2 | 3 | The following is a few and guidelines regarding the current philosophy, style, 4 | flaws, and the future directions of svgpathtools. These guidelines are meant 5 | to make it easy to contribute. 6 | 7 | ## Being a Hero 8 | We need better automated testing coverage. Please, submit unittests! See the 9 | Testing Style section below for info. 10 | 11 | Here's a list of things that need (more) unittests: 12 | * TBA (feel free to help) 13 | 14 | ## Submitting Bugs 15 | If you find a bug, please submit an issue along with an **easily reproducible 16 | example**. Feel free to make a pull-request too (see relevant section below). 17 | 18 | 19 | ## Submitting Pull-Requests 20 | 21 | #### New features come with unittests and docstrings. 22 | If you want to add a cool/useful feature to svgpathtools, that's great! Just 23 | make sure your pull-request includes both thorough unittests and well-written 24 | docstrings. See relevant sections below on "Testing Style" and 25 | "Docstring Style" below. 26 | 27 | 28 | #### Modifications to old code may require additional unittests. 29 | Certain submodules of svgpathtools are poorly covered by the current set of 30 | unittests. That said, most functionality in svgpathtools has been tested quite 31 | a bit through use. 32 | The point being, if you're working on functionality not currently covered by 33 | unittests (and your changes replace more than a few lines), then please include 34 | unittests designed to verify that any affected functionary still works. 35 | 36 | 37 | ## Style 38 | 39 | ### Coding Style 40 | * Follow the PEP8 guidelines unless you have good reason to violate them (e.g. 41 | you want your code's variable names to match some official documentation, or 42 | PEP8 guidelines contradict those present in this document). 43 | * Include docstrings and in-line comments where appropriate. See 44 | "Docstring Style" section below for more info. 45 | * Use explicit, uncontracted names (e.g. `parse_transform` instead of 46 | `parse_trafo`). Maybe the most important feature for a name is how easy it is 47 | for a user to guess (after having seen other names used in `svgpathtools`). 48 | * Use a capital 'T' denote a Path object's parameter, use a lower case 't' to 49 | denote a Path segment's parameter. See the methods `Path.t2T` and `Path.T2t` 50 | if you're unsure what I mean. In the ambiguous case, use either 't' or another 51 | appropriate option (e.g. "tau"). 52 | 53 | 54 | ### Testing Style 55 | You want to submit unittests?! Yes! Please see the svgpathtools/test folder 56 | for examples. 57 | 58 | 59 | ### Docstring Style 60 | All docstrings in svgpathtools should (roughly) adhere to the Google Python 61 | Style Guide. Currently, this is not the case... but for the sake of 62 | consistency, Google Style is the officially preferred docstring style of 63 | svgpathtools. 64 | [Some nice examples of Google Python Style docstrings]( 65 | https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) 66 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Andrew Allan Port 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /LICENSE2.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2014 Lennart Regebro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.svg LICENSE* 2 | recursive-include test *.svg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Donate](https://img.shields.io/badge/donate-paypal-brightgreen)](https://www.paypal.com/donate?business=4SKJ27AM4EYYA&no_recurring=0&item_name=Support+the+creator+of+svgpathtools?++He%27s+a+student+and+would+appreciate+it.&currency_code=USD) 2 | ![Python](https://img.shields.io/pypi/pyversions/svgpathtools.svg) 3 | [![PyPI](https://img.shields.io/pypi/v/svgpathtools)](https://pypi.org/project/svgpathtools/) 4 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/svgpathtools?color=yellow)](https://pypistats.org/packages/svgpathtools) 5 | # svgpathtools 6 | 7 | 8 | svgpathtools is a collection of tools for manipulating and analyzing SVG Path objects and Bézier curves. 9 | 10 | ## Features 11 | 12 | svgpathtools contains functions designed to **easily read, write and display SVG files** as well as *a large selection of geometrically\-oriented tools* to **transform and analyze path elements**. 13 | 14 | Additionally, the submodule *bezier.py* contains tools for for working with general **nth order Bezier curves stored as n-tuples**. 15 | 16 | Some included tools: 17 | 18 | - **read**, **write**, and **display** SVG files containing Path (and other) SVG elements 19 | - convert Bézier path segments to **numpy.poly1d** (polynomial) objects 20 | - convert polynomials (in standard form) to their Bézier form 21 | - compute **tangent vectors** and (right-hand rule) **normal vectors** 22 | - compute **curvature** 23 | - break discontinuous paths into their **continuous subpaths**. 24 | - efficiently compute **intersections** between paths and/or segments 25 | - find a **bounding box** for a path or segment 26 | - **reverse** segment/path orientation 27 | - **crop** and **split** paths and segments 28 | - **smooth** paths (i.e. smooth away kinks to make paths differentiable) 29 | - **transition maps** from path domain to segment domain and back (T2t and t2T) 30 | - compute **area** enclosed by a closed path 31 | - compute **arc length** 32 | - compute **inverse arc length** 33 | - convert RGB color tuples to hexadecimal color strings and back 34 | 35 | ## Prerequisites 36 | - **numpy** 37 | - **svgwrite** 38 | - **scipy** (optional, but recommended for performance) 39 | 40 | ## Setup 41 | 42 | ```bash 43 | $ pip install svgpathtools 44 | ``` 45 | 46 | ### Alternative Setup 47 | You can download the source from Github and install by using the command (from inside the folder containing setup.py): 48 | 49 | ```bash 50 | $ python setup.py install 51 | ``` 52 | 53 | ## Credit where credit's due 54 | Much of the core of this module was taken from [the svg.path (v2.0) module](https://github.com/regebro/svg.path). Interested svg.path users should see the compatibility notes at bottom of this readme. 55 | 56 | ## Basic Usage 57 | 58 | ### Classes 59 | The svgpathtools module is primarily structured around four path segment classes: ``Line``, ``QuadraticBezier``, ``CubicBezier``, and ``Arc``. There is also a fifth class, ``Path``, whose objects are sequences of (connected or disconnected[1](#f1)) path segment objects. 60 | 61 | * ``Line(start, end)`` 62 | 63 | * ``Arc(start, radius, rotation, large_arc, sweep, end)`` Note: See docstring for a detailed explanation of these parameters 64 | 65 | * ``QuadraticBezier(start, control, end)`` 66 | 67 | * ``CubicBezier(start, control1, control2, end)`` 68 | 69 | * ``Path(*segments)`` 70 | 71 | See the relevant docstrings in *path.py* or the [official SVG specifications]() for more information on what each parameter means. 72 | 73 | 1 Warning: Some of the functionality in this library has not been tested on discontinuous Path objects. A simple workaround is provided, however, by the ``Path.continuous_subpaths()`` method. [↩](#a1) 74 | 75 | 76 | ```python 77 | from __future__ import division, print_function 78 | ``` 79 | 80 | 81 | ```python 82 | # Coordinates are given as points in the complex plane 83 | from svgpathtools import Path, Line, QuadraticBezier, CubicBezier, Arc 84 | seg1 = CubicBezier(300+100j, 100+100j, 200+200j, 200+300j) # A cubic beginning at (300, 100) and ending at (200, 300) 85 | seg2 = Line(200+300j, 250+350j) # A line beginning at (200, 300) and ending at (250, 350) 86 | path = Path(seg1, seg2) # A path traversing the cubic and then the line 87 | 88 | # We could alternatively created this Path object using a d-string 89 | from svgpathtools import parse_path 90 | path_alt = parse_path('M 300 100 C 100 100 200 200 200 300 L 250 350') 91 | 92 | # Let's check that these two methods are equivalent 93 | print(path) 94 | print(path_alt) 95 | print(path == path_alt) 96 | 97 | # On a related note, the Path.d() method returns a Path object's d-string 98 | print(path.d()) 99 | print(parse_path(path.d()) == path) 100 | ``` 101 | 102 | Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)), 103 | Line(start=(200+300j), end=(250+350j))) 104 | Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)), 105 | Line(start=(200+300j), end=(250+350j))) 106 | True 107 | M 300.0,100.0 C 100.0,100.0 200.0,200.0 200.0,300.0 L 250.0,350.0 108 | True 109 | 110 | 111 | The ``Path`` class is a mutable sequence, so it behaves much like a list. 112 | So segments can **append**ed, **insert**ed, set by index, **del**eted, **enumerate**d, **slice**d out, etc. 113 | 114 | 115 | ```python 116 | # Let's append another to the end of it 117 | path.append(CubicBezier(250+350j, 275+350j, 250+225j, 200+100j)) 118 | print(path) 119 | 120 | # Let's replace the first segment with a Line object 121 | path[0] = Line(200+100j, 200+300j) 122 | print(path) 123 | 124 | # You may have noticed that this path is connected and now is also closed (i.e. path.start == path.end) 125 | print("path is continuous? ", path.iscontinuous()) 126 | print("path is closed? ", path.isclosed()) 127 | 128 | # The curve the path follows is not, however, smooth (differentiable) 129 | from svgpathtools import kinks, smoothed_path 130 | print("path contains non-differentiable points? ", len(kinks(path)) > 0) 131 | 132 | # If we want, we can smooth these out (Experimental and only for line/cubic paths) 133 | # Note: smoothing will always works (except on 180 degree turns), but you may want 134 | # to play with the maxjointsize and tightness parameters to get pleasing results 135 | # Note also: smoothing will increase the number of segments in a path 136 | spath = smoothed_path(path) 137 | print("spath contains non-differentiable points? ", len(kinks(spath)) > 0) 138 | print(spath) 139 | 140 | # Let's take a quick look at the path and its smoothed relative 141 | # The following commands will open two browser windows to display path and spaths 142 | from svgpathtools import disvg 143 | from time import sleep 144 | disvg(path) 145 | sleep(1) # needed when not giving the SVGs unique names (or not using timestamp) 146 | disvg(spath) 147 | print("Notice that path contains {} segments and spath contains {} segments." 148 | "".format(len(path), len(spath))) 149 | ``` 150 | 151 | Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)), 152 | Line(start=(200+300j), end=(250+350j)), 153 | CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j))) 154 | Path(Line(start=(200+100j), end=(200+300j)), 155 | Line(start=(200+300j), end=(250+350j)), 156 | CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j))) 157 | path is continuous? True 158 | path is closed? True 159 | path contains non-differentiable points? True 160 | spath contains non-differentiable points? False 161 | Path(Line(start=(200+101.5j), end=(200+298.5j)), 162 | CubicBezier(start=(200+298.5j), control1=(200+298.505j), control2=(201.057124638+301.057124638j), end=(201.060660172+301.060660172j)), 163 | Line(start=(201.060660172+301.060660172j), end=(248.939339828+348.939339828j)), 164 | CubicBezier(start=(248.939339828+348.939339828j), control1=(249.649982143+349.649982143j), control2=(248.995+350j), end=(250+350j)), 165 | CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j)), 166 | CubicBezier(start=(200+100j), control1=(199.62675237+99.0668809257j), control2=(200+100.495j), end=(200+101.5j))) 167 | Notice that path contains 3 segments and spath contains 6 segments. 168 | 169 | 170 | ### Reading SVGSs 171 | 172 | The **svg2paths()** function converts an svgfile to a list of Path objects and a separate list of dictionaries containing the attributes of each said path. 173 | Note: Line, Polyline, Polygon, and Path SVG elements can all be converted to Path objects using this function. 174 | 175 | 176 | ```python 177 | # Read SVG into a list of path objects and list of dictionaries of attributes 178 | from svgpathtools import svg2paths, wsvg 179 | paths, attributes = svg2paths('test.svg') 180 | 181 | # Update: You can now also extract the svg-attributes by setting 182 | # return_svg_attributes=True, or with the convenience function svg2paths2 183 | from svgpathtools import svg2paths2 184 | paths, attributes, svg_attributes = svg2paths2('test.svg') 185 | 186 | # Let's print out the first path object and the color it was in the SVG 187 | # We'll see it is composed of two CubicBezier objects and, in the SVG file it 188 | # came from, it was red 189 | redpath = paths[0] 190 | redpath_attribs = attributes[0] 191 | print(redpath) 192 | print(redpath_attribs['stroke']) 193 | ``` 194 | 195 | Path(CubicBezier(start=(10.5+80j), control1=(40+10j), control2=(65+10j), end=(95+80j)), 196 | CubicBezier(start=(95+80j), control1=(125+150j), control2=(150+150j), end=(180+80j))) 197 | red 198 | 199 | 200 | ### Writing SVGSs (and some geometric functions and methods) 201 | 202 | The **wsvg()** function creates an SVG file from a list of path. This function can do many things (see docstring in *paths2svg.py* for more information) and is meant to be quick and easy to use. 203 | Note: Use the convenience function **disvg()** (or set 'openinbrowser=True') to automatically attempt to open the created svg file in your default SVG viewer. 204 | 205 | 206 | ```python 207 | # Let's make a new SVG that's identical to the first 208 | wsvg(paths, attributes=attributes, svg_attributes=svg_attributes, filename='output1.svg') 209 | ``` 210 | 211 | ![output1.svg](output1.svg) 212 | 213 | There will be many more examples of writing and displaying path data below. 214 | 215 | ### The .point() method and transitioning between path and path segment parameterizations 216 | SVG Path elements and their segments have official parameterizations. 217 | These parameterizations can be accessed using the ``Path.point()``, ``Line.point()``, ``QuadraticBezier.point()``, ``CubicBezier.point()``, and ``Arc.point()`` methods. 218 | All these parameterizations are defined over the domain 0 <= t <= 1. 219 | 220 | **Note:** In this document and in inline documentation and doctrings, I use a capital ``T`` when referring to the parameterization of a Path object and a lower case ``t`` when referring speaking about path segment objects (i.e. Line, QaudraticBezier, CubicBezier, and Arc objects). 221 | Given a ``T`` value, the ``Path.T2t()`` method can be used to find the corresponding segment index, ``k``, and segment parameter, ``t``, such that ``path.point(T)=path[k].point(t)``. 222 | There is also a ``Path.t2T()`` method to solve the inverse problem. 223 | 224 | 225 | ```python 226 | # Example: 227 | 228 | # Let's check that the first segment of redpath starts 229 | # at the same point as redpath 230 | firstseg = redpath[0] 231 | print(redpath.point(0) == firstseg.point(0) == redpath.start == firstseg.start) 232 | 233 | # Let's check that the last segment of redpath ends on the same point as redpath 234 | lastseg = redpath[-1] 235 | print(redpath.point(1) == lastseg.point(1) == redpath.end == lastseg.end) 236 | 237 | # This next boolean should return False as redpath is composed multiple segments 238 | print(redpath.point(0.5) == firstseg.point(0.5)) 239 | 240 | # If we want to figure out which segment of redpoint the 241 | # point redpath.point(0.5) lands on, we can use the path.T2t() method 242 | k, t = redpath.T2t(0.5) 243 | print(redpath[k].point(t) == redpath.point(0.5)) 244 | ``` 245 | 246 | True 247 | True 248 | False 249 | True 250 | 251 | 252 | ### Bezier curves as NumPy polynomial objects 253 | Another great way to work with the parameterizations for `Line`, `QuadraticBezier`, and `CubicBezier` objects is to convert them to ``numpy.poly1d`` objects. This is done easily using the ``Line.poly()``, ``QuadraticBezier.poly()`` and ``CubicBezier.poly()`` methods. 254 | There's also a ``polynomial2bezier()`` function in the pathtools.py submodule to convert polynomials back to Bezier curves. 255 | 256 | **Note:** cubic Bezier curves are parameterized as $$\mathcal{B}(t) = P_0(1-t)^3 + 3P_1(1-t)^2t + 3P_2(1-t)t^2 + P_3t^3$$ 257 | where $P_0$, $P_1$, $P_2$, and $P_3$ are the control points ``start``, ``control1``, ``control2``, and ``end``, respectively, that svgpathtools uses to define a CubicBezier object. The ``CubicBezier.poly()`` method expands this polynomial to its standard form 258 | $$\mathcal{B}(t) = c_0t^3 + c_1t^2 +c_2t+c3$$ 259 | where 260 | $$\begin{bmatrix}c_0\\c_1\\c_2\\c_3\end{bmatrix} = 261 | \begin{bmatrix} 262 | -1 & 3 & -3 & 1\\ 263 | 3 & -6 & -3 & 0\\ 264 | -3 & 3 & 0 & 0\\ 265 | 1 & 0 & 0 & 0\\ 266 | \end{bmatrix} 267 | \begin{bmatrix}P_0\\P_1\\P_2\\P_3\end{bmatrix}$$ 268 | 269 | `QuadraticBezier.poly()` and `Line.poly()` are [defined similarly](https://en.wikipedia.org/wiki/B%C3%A9zier_curve#General_definition). 270 | 271 | 272 | ```python 273 | # Example: 274 | b = CubicBezier(300+100j, 100+100j, 200+200j, 200+300j) 275 | p = b.poly() 276 | 277 | # p(t) == b.point(t) 278 | print(p(0.235) == b.point(0.235)) 279 | 280 | # What is p(t)? It's just the cubic b written in standard form. 281 | bpretty = "{}*(1-t)^3 + 3*{}*(1-t)^2*t + 3*{}*(1-t)*t^2 + {}*t^3".format(*b.bpoints()) 282 | print("The CubicBezier, b.point(x) = \n\n" + 283 | bpretty + "\n\n" + 284 | "can be rewritten in standard form as \n\n" + 285 | str(p).replace('x','t')) 286 | ``` 287 | 288 | True 289 | The CubicBezier, b.point(x) = 290 | 291 | (300+100j)*(1-t)^3 + 3*(100+100j)*(1-t)^2*t + 3*(200+200j)*(1-t)*t^2 + (200+300j)*t^3 292 | 293 | can be rewritten in standard form as 294 | 295 | 3 2 296 | (-400 + -100j) t + (900 + 300j) t - 600 t + (300 + 100j) 297 | 298 | 299 | The ability to convert between Bezier objects to NumPy polynomial objects is very useful. For starters, we can take turn a list of Bézier segments into a NumPy array 300 | 301 | ### Numpy Array operations on Bézier path segments 302 | 303 | [Example available here](https://github.com/mathandy/svgpathtools/blob/master/examples/compute-many-points-quickly-using-numpy-arrays.py) 304 | 305 | To further illustrate the power of being able to convert our Bezier curve objects to numpy.poly1d objects and back, lets compute the unit tangent vector of the above CubicBezier object, b, at t=0.5 in four different ways. 306 | 307 | ### Tangent vectors (and more on NumPy polynomials) 308 | 309 | 310 | ```python 311 | t = 0.5 312 | ### Method 1: the easy way 313 | u1 = b.unit_tangent(t) 314 | 315 | ### Method 2: another easy way 316 | # Note: This way will fail if it encounters a removable singularity. 317 | u2 = b.derivative(t)/abs(b.derivative(t)) 318 | 319 | ### Method 2: a third easy way 320 | # Note: This way will also fail if it encounters a removable singularity. 321 | dp = p.deriv() 322 | u3 = dp(t)/abs(dp(t)) 323 | 324 | ### Method 4: the removable-singularity-proof numpy.poly1d way 325 | # Note: This is roughly how Method 1 works 326 | from svgpathtools import real, imag, rational_limit 327 | dx, dy = real(dp), imag(dp) # dp == dx + 1j*dy 328 | p_mag2 = dx**2 + dy**2 # p_mag2(t) = |p(t)|**2 329 | # Note: abs(dp) isn't a polynomial, but abs(dp)**2 is, and, 330 | # the limit_{t->t0}[f(t) / abs(f(t))] == 331 | # sqrt(limit_{t->t0}[f(t)**2 / abs(f(t))**2]) 332 | from cmath import sqrt 333 | u4 = sqrt(rational_limit(dp**2, p_mag2, t)) 334 | 335 | print("unit tangent check:", u1 == u2 == u3 == u4) 336 | 337 | # Let's do a visual check 338 | mag = b.length()/4 # so it's not hard to see the tangent line 339 | tangent_line = Line(b.point(t), b.point(t) + mag*u1) 340 | disvg([b, tangent_line], 'bg', nodes=[b.point(t)]) 341 | ``` 342 | 343 | unit tangent check: True 344 | 345 | 346 | ### Translations (shifts), reversing orientation, and normal vectors 347 | 348 | 349 | ```python 350 | # Speaking of tangents, let's add a normal vector to the picture 351 | n = b.normal(t) 352 | normal_line = Line(b.point(t), b.point(t) + mag*n) 353 | disvg([b, tangent_line, normal_line], 'bgp', nodes=[b.point(t)]) 354 | 355 | # and let's reverse the orientation of b! 356 | # the tangent and normal lines should be sent to their opposites 357 | br = b.reversed() 358 | 359 | # Let's also shift b_r over a bit to the right so we can view it next to b 360 | # The simplest way to do this is br = br.translated(3*mag), but let's use 361 | # the .bpoints() instead, which returns a Bezier's control points 362 | br.start, br.control1, br.control2, br.end = [3*mag + bpt for bpt in br.bpoints()] # 363 | 364 | tangent_line_r = Line(br.point(t), br.point(t) + mag*br.unit_tangent(t)) 365 | normal_line_r = Line(br.point(t), br.point(t) + mag*br.normal(t)) 366 | wsvg([b, tangent_line, normal_line, br, tangent_line_r, normal_line_r], 367 | 'bgpkgp', nodes=[b.point(t), br.point(t)], filename='vectorframes.svg', 368 | text=["b's tangent", "br's tangent"], text_path=[tangent_line, tangent_line_r]) 369 | ``` 370 | 371 | ![vectorframes.svg](vectorframes.svg) 372 | 373 | ### Rotations and Translations 374 | 375 | 376 | ```python 377 | # Let's take a Line and an Arc and make some pictures 378 | top_half = Arc(start=-1, radius=1+2j, rotation=0, large_arc=1, sweep=1, end=1) 379 | midline = Line(-1.5, 1.5) 380 | 381 | # First let's make our ellipse whole 382 | bottom_half = top_half.rotated(180) 383 | decorated_ellipse = Path(top_half, bottom_half) 384 | 385 | # Now let's add the decorations 386 | for k in range(12): 387 | decorated_ellipse.append(midline.rotated(30*k)) 388 | 389 | # Let's move it over so we can see the original Line and Arc object next 390 | # to the final product 391 | decorated_ellipse = decorated_ellipse.translated(4+0j) 392 | wsvg([top_half, midline, decorated_ellipse], filename='decorated_ellipse.svg') 393 | ``` 394 | 395 | ![decorated_ellipse.svg](decorated_ellipse.svg) 396 | 397 | ### arc length and inverse arc length 398 | 399 | Here we'll create an SVG that shows off the parametric and geometric midpoints of the paths from ``test.svg``. We'll need to compute use the ``Path.length()``, ``Line.length()``, ``QuadraticBezier.length()``, ``CubicBezier.length()``, and ``Arc.length()`` methods, as well as the related inverse arc length methods ``.ilength()`` function to do this. 400 | 401 | 402 | ```python 403 | # First we'll load the path data from the file test.svg 404 | paths, attributes = svg2paths('test.svg') 405 | 406 | # Let's mark the parametric midpoint of each segment 407 | # I say "parametric" midpoint because Bezier curves aren't 408 | # parameterized by arclength 409 | # If they're also the geometric midpoint, let's mark them 410 | # purple and otherwise we'll mark the geometric midpoint green 411 | min_depth = 5 412 | error = 1e-4 413 | dots = [] 414 | ncols = [] 415 | nradii = [] 416 | for path in paths: 417 | for seg in path: 418 | parametric_mid = seg.point(0.5) 419 | seg_length = seg.length() 420 | if seg.length(0.5)/seg.length() == 1/2: 421 | dots += [parametric_mid] 422 | ncols += ['purple'] 423 | nradii += [5] 424 | else: 425 | t_mid = seg.ilength(seg_length/2) 426 | geo_mid = seg.point(t_mid) 427 | dots += [parametric_mid, geo_mid] 428 | ncols += ['red', 'green'] 429 | nradii += [5] * 2 430 | 431 | # In 'output2.svg' the paths will retain their original attributes 432 | wsvg(paths, nodes=dots, node_colors=ncols, node_radii=nradii, 433 | attributes=attributes, filename='output2.svg') 434 | ``` 435 | 436 | ![output2.svg](output2.svg) 437 | 438 | ### Intersections between Bezier curves 439 | 440 | 441 | ```python 442 | # Let's find all intersections between redpath and the other 443 | redpath = paths[0] 444 | redpath_attribs = attributes[0] 445 | intersections = [] 446 | for path in paths[1:]: 447 | for (T1, seg1, t1), (T2, seg2, t2) in redpath.intersect(path): 448 | intersections.append(redpath.point(T1)) 449 | 450 | disvg(paths, filename='output_intersections.svg', attributes=attributes, 451 | nodes = intersections, node_radii = [5]*len(intersections)) 452 | ``` 453 | 454 | ![output_intersections.svg](output_intersections.svg) 455 | 456 | ### An Advanced Application: Offsetting Paths 457 | Here we'll find the [offset curve](https://en.wikipedia.org/wiki/Parallel_curve) for a few paths. 458 | 459 | 460 | ```python 461 | from svgpathtools import parse_path, Line, Path, wsvg 462 | def offset_curve(path, offset_distance, steps=1000): 463 | """Takes in a Path object, `path`, and a distance, 464 | `offset_distance`, and outputs an piecewise-linear approximation 465 | of the 'parallel' offset curve.""" 466 | nls = [] 467 | for seg in path: 468 | ct = 1 469 | for k in range(steps): 470 | t = k / steps 471 | offset_vector = offset_distance * seg.normal(t) 472 | nl = Line(seg.point(t), seg.point(t) + offset_vector) 473 | nls.append(nl) 474 | connect_the_dots = [Line(nls[k].end, nls[k+1].end) for k in range(len(nls)-1)] 475 | if path.isclosed(): 476 | connect_the_dots.append(Line(nls[-1].end, nls[0].end)) 477 | offset_path = Path(*connect_the_dots) 478 | return offset_path 479 | 480 | # Examples: 481 | path1 = parse_path("m 288,600 c -52,-28 -42,-61 0,-97 ") 482 | path2 = parse_path("M 151,395 C 407,485 726.17662,160 634,339").translated(300) 483 | path3 = parse_path("m 117,695 c 237,-7 -103,-146 457,0").translated(500+400j) 484 | paths = [path1, path2, path3] 485 | 486 | offset_distances = [10*k for k in range(1,51)] 487 | offset_paths = [] 488 | for path in paths: 489 | for distances in offset_distances: 490 | offset_paths.append(offset_curve(path, distances)) 491 | 492 | # Let's take a look 493 | wsvg(paths + offset_paths, 'g'*len(paths) + 'r'*len(offset_paths), filename='offset_curves.svg') 494 | ``` 495 | 496 | ![offset_curves.svg](offset_curves.svg) 497 | 498 | ## Compatibility Notes for users of svg.path (v2.0) 499 | 500 | - renamed Arc.arc attribute as Arc.large_arc 501 | 502 | - Path.d() : For behavior similar[2](#f2) to svg.path (v2.0), set both useSandT and use_closed_attrib to be True. 503 | 504 | 2 The behavior would be identical, but the string formatting used in this method has been changed to use default format (instead of the General format, {:G}), for inceased precision. [↩](#a2) 505 | 506 | 507 | Licence 508 | ------- 509 | 510 | This module is under a MIT License. 511 | 512 | 513 | ```python 514 | 515 | ``` 516 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | To report any security vulnerability, email andyaport@gmail.com 6 | -------------------------------------------------------------------------------- /decorated_ellipse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /donate-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Donate to the creator 11 | 12 | 13 | (He's a student.) 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/compute-many-points-quickly-using-numpy-arrays.py: -------------------------------------------------------------------------------- 1 | """The goal of this gist is to show how to compute many points on a path 2 | quickly using NumPy arrays. I.e. there's a much faster way than using, say 3 | [some_path.point(t) for t in many_tvals]. The example below assumes the 4 | `Path` object is composed entirely of `CubicBezier` objects, but this can 5 | easily be generalized to paths containing `Line` and `QuadraticBezier` objects 6 | also. 7 | Note: The relevant matrix transformation for quadratics can be found in the 8 | svgpathtools.bezier module.""" 9 | from __future__ import print_function 10 | import numpy as np 11 | from svgpathtools import bezier_point, Path, bpoints2bezier, polynomial2bezier 12 | 13 | 14 | class HigherOrderBezier: 15 | def __init__(self, bpoints): 16 | self.bpts = bpoints 17 | 18 | def bpoints(self): 19 | return self.bpts 20 | 21 | def point(self, t): 22 | return bezier_point(self.bpoints(), t) 23 | 24 | def __repr__(self): 25 | return str(self.bpts) 26 | 27 | 28 | def random_bezier(degree): 29 | if degree <= 3: 30 | return bpoints2bezier(polynomial2bezier(np.random.rand(degree + 1))) 31 | else: 32 | return HigherOrderBezier(np.random.rand(degree + 1)) 33 | 34 | 35 | def points_in_each_seg_slow(path, tvals): 36 | return [seg.poly()(tvals) for seg in path] 37 | 38 | 39 | def points_in_each_seg(path, tvals): 40 | """Compute seg.point(t) for each seg in path and each t in tvals.""" 41 | A = np.array([[-1, 3, -3, 1], # transforms cubic bez to standard poly 42 | [ 3, -6, 3, 0], 43 | [-3, 3, 0, 0], 44 | [ 1, 0, 0, 0]]) 45 | B = [seg.bpoints() for seg in path] 46 | return np.dot(B, np.dot(A, np.power(tvals, [[3],[2],[1],[0]]))) 47 | 48 | 49 | if __name__ == '__main__': 50 | num_segs = 1000 51 | testpath = Path(*[random_bezier(3) for dummy in range(num_segs)]) 52 | tvals = np.linspace(0, 1, 10) 53 | 54 | pts = points_in_each_seg(testpath, tvals) 55 | pts_check = points_in_each_seg_slow(testpath, tvals) 56 | print(np.max(pts - pts_check)) 57 | -------------------------------------------------------------------------------- /examples/determine-if-svg-path-is-contained-in-other-path-example.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of how to determine if an svg path is contained in another 3 | svg path in Python. 4 | 5 | Note: for discontinuous paths you can use the svgpathtools 6 | Path.continuous_subpaths() method to split a paths into a list of its 7 | continuous subpaths. 8 | """ 9 | 10 | from svgpathtools import Path, Line 11 | 12 | 13 | def path1_is_contained_in_path2(path1, path2): 14 | assert path2.isclosed() # This question isn't well-defined otherwise 15 | if path2.intersect(path1): 16 | return False 17 | 18 | # find a point that's definitely outside path2 19 | xmin, xmax, ymin, ymax = path2.bbox() 20 | b = (xmin + 1) + 1j*(ymax + 1) 21 | 22 | a = path1.start # pick an arbitrary point in path1 23 | ab_line = Path(Line(a, b)) 24 | number_of_intersections = len(ab_line.intersect(path2)) 25 | if number_of_intersections % 2: # if number of intersections is odd 26 | return True 27 | else: 28 | return False 29 | 30 | 31 | # Test examples 32 | closed_path = Path(Line(0,5), Line(5,5+5j), Line(5+5j, 0)) 33 | path_that_is_contained = Path(Line(1+1j, 2+2j)) 34 | print(path1_is_contained_in_path2(path_that_is_contained, closed_path)) 35 | 36 | path_thats_not_contained = Path(Line(10+10j, 20+20j)) 37 | print(path1_is_contained_in_path2(path_thats_not_contained, closed_path)) 38 | 39 | path_that_intersects = Path(Line(2+1j, 10+10j)) 40 | print(path1_is_contained_in_path2(path_that_intersects, closed_path)) 41 | -------------------------------------------------------------------------------- /examples/distance-between-two-svg-paths-example.py: -------------------------------------------------------------------------------- 1 | from svgpathtools import disvg, Line, CubicBezier 2 | from scipy.optimize import fminbound 3 | 4 | # create some example paths 5 | path1 = CubicBezier(1,2+3j,3-5j,4+1j) 6 | path2 = path1.rotated(60).translated(3) 7 | 8 | 9 | def dist(t): 10 | return path1.radialrange(path2.point(t))[0][0] 11 | 12 | 13 | # find minimizer 14 | T2 = fminbound(dist, 0, 1) 15 | 16 | # Let's do a visual check 17 | pt2 = path2.point(T2) 18 | T1 = path1.radialrange(pt2)[0][1] 19 | pt1 = path1.point(T1) 20 | disvg([path1, path2, Line(pt1, pt2)], 'grb', nodes=[pt1, pt2]) 21 | -------------------------------------------------------------------------------- /examples/wasm-via-pyodide-example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | svgpathtools in JS! 8 | 9 | 10 | 11 | 12 |
13 |
14 |
Output:
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Sorry, your browser does not support inline SVG. 23 | 24 | 25 | 61 | 62 | -------------------------------------------------------------------------------- /examples/zero-radius-arcs.svg: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /output1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /output2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /output_intersections.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /path.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | svgwrite 3 | scipy 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE.txt -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import codecs 3 | import os 4 | 5 | 6 | VERSION = '1.7.1' 7 | AUTHOR_NAME = 'Andy Port' 8 | AUTHOR_EMAIL = 'AndyAPort@gmail.com' 9 | GITHUB = 'https://github.com/mathandy/svgpathtools' 10 | 11 | _here = os.path.abspath(os.path.dirname(__file__)) 12 | 13 | 14 | def read(relative_path): 15 | """Reads file at relative path, returning contents as string.""" 16 | with codecs.open(os.path.join(_here, relative_path), "rb", "utf-8") as f: 17 | return f.read() 18 | 19 | 20 | setup(name='svgpathtools', 21 | packages=['svgpathtools'], 22 | version=VERSION, 23 | description=('A collection of tools for manipulating and analyzing SVG ' 24 | 'Path objects and Bezier curves.'), 25 | long_description=read("README.md"), 26 | long_description_content_type='text/markdown', 27 | author=AUTHOR_NAME, 28 | author_email=AUTHOR_EMAIL, 29 | url=GITHUB, 30 | download_url='{}/releases/download/{}/svgpathtools-{}-py3-none-any.whl' 31 | ''.format(GITHUB, VERSION, VERSION), 32 | license='MIT', 33 | install_requires=['numpy', 'svgwrite', 'scipy'], 34 | python_requires='>=3.8', 35 | platforms="OS Independent", 36 | keywords=['svg', 'svg path', 'svg.path', 'bezier', 'parse svg path', 'display svg'], 37 | classifiers=[ 38 | "Development Status :: 4 - Beta", 39 | "Intended Audience :: Developers", 40 | "License :: OSI Approved :: MIT License", 41 | "Operating System :: OS Independent", 42 | "Programming Language :: Python :: 3", 43 | "Programming Language :: Python :: 3.8", 44 | "Programming Language :: Python :: 3.9", 45 | "Programming Language :: Python :: 3.10", 46 | "Programming Language :: Python :: 3.11", 47 | "Programming Language :: Python :: 3.12", 48 | "Programming Language :: Python :: 3.13", 49 | "Topic :: Multimedia :: Graphics :: Editors :: Vector-Based", 50 | "Topic :: Scientific/Engineering", 51 | "Topic :: Scientific/Engineering :: Image Recognition", 52 | "Topic :: Scientific/Engineering :: Information Analysis", 53 | "Topic :: Scientific/Engineering :: Mathematics", 54 | "Topic :: Scientific/Engineering :: Visualization", 55 | "Topic :: Software Development :: Libraries :: Python Modules", 56 | ], 57 | ) 58 | -------------------------------------------------------------------------------- /svgpathtools/__init__.py: -------------------------------------------------------------------------------- 1 | from .bezier import (bezier_point, bezier2polynomial, 2 | polynomial2bezier, split_bezier, 3 | bezier_bounding_box, bezier_intersections, 4 | bezier_by_line_intersections) 5 | from .path import (Path, Line, QuadraticBezier, CubicBezier, Arc, 6 | bezier_segment, is_bezier_segment, is_path_segment, 7 | is_bezier_path, concatpaths, poly2bez, bpoints2bezier, 8 | closest_point_in_path, farthest_point_in_path, 9 | path_encloses_pt, bbox2path, polygon, polyline) 10 | from .parser import parse_path 11 | from .paths2svg import disvg, wsvg, paths2Drawing 12 | from .polytools import polyroots, polyroots01, rational_limit, real, imag 13 | from .misctools import hex2rgb, rgb2hex 14 | from .smoothing import smoothed_path, smoothed_joint, is_differentiable, kinks 15 | from .document import (Document, CONVERSIONS, CONVERT_ONLY_PATHS, 16 | SVG_GROUP_TAG, SVG_NAMESPACE) 17 | from .svg_io_sax import SaxDocument 18 | 19 | try: 20 | from .svg_to_paths import svg2paths, svg2paths2, svgstr2paths 21 | except ImportError: 22 | pass 23 | -------------------------------------------------------------------------------- /svgpathtools/bezier.py: -------------------------------------------------------------------------------- 1 | """This submodule contains tools that deal with generic, degree n, Bezier 2 | curves. 3 | Note: Bezier curves here are always represented by the tuple of their control 4 | points given by their standard representation.""" 5 | 6 | # External dependencies: 7 | from __future__ import division, absolute_import, print_function 8 | from math import factorial as fac, ceil, log, sqrt 9 | from numpy import poly1d 10 | 11 | # Internal dependencies 12 | from .polytools import real, imag, polyroots, polyroots01 13 | from .constants import FLOAT_EPSILON 14 | 15 | 16 | # Evaluation ################################################################## 17 | 18 | def n_choose_k(n, k): 19 | return fac(n)//fac(k)//fac(n-k) 20 | 21 | 22 | def bernstein(n, t): 23 | """returns a list of the Bernstein basis polynomials b_{i, n} evaluated at 24 | t, for i =0...n""" 25 | t1 = 1-t 26 | return [n_choose_k(n, k) * t1**(n-k) * t**k for k in range(n+1)] 27 | 28 | 29 | def bezier_point(p, t): 30 | """Evaluates the Bezier curve given by it's control points, p, at t. 31 | Note: Uses Horner's rule for cubic and lower order Bezier curves. 32 | Warning: Be concerned about numerical stability when using this function 33 | with high order curves.""" 34 | 35 | # begin arc support block ######################## 36 | try: 37 | p.large_arc 38 | return p.point(t) 39 | except: 40 | pass 41 | # end arc support block ########################## 42 | 43 | deg = len(p) - 1 44 | if deg == 3: 45 | return p[0] + t*( 46 | 3*(p[1] - p[0]) + t*( 47 | 3*(p[0] + p[2]) - 6*p[1] + t*( 48 | -p[0] + 3*(p[1] - p[2]) + p[3]))) 49 | elif deg == 2: 50 | return p[0] + t*( 51 | 2*(p[1] - p[0]) + t*( 52 | p[0] - 2*p[1] + p[2])) 53 | elif deg == 1: 54 | return p[0] + t*(p[1] - p[0]) 55 | elif deg == 0: 56 | return p[0] 57 | else: 58 | bern = bernstein(deg, t) 59 | return sum(bern[k]*p[k] for k in range(deg+1)) 60 | 61 | 62 | # Conversion ################################################################## 63 | 64 | def bezier2polynomial(p, numpy_ordering=True, return_poly1d=False): 65 | """Converts a tuple of Bezier control points to a tuple of coefficients 66 | of the expanded polynomial. 67 | return_poly1d : returns a numpy.poly1d object. This makes computations 68 | of derivatives/anti-derivatives and many other operations quite quick. 69 | numpy_ordering : By default (to accommodate numpy) the coefficients will 70 | be output in reverse standard order.""" 71 | if len(p) == 4: 72 | coeffs = (-p[0] + 3*(p[1] - p[2]) + p[3], 73 | 3*(p[0] - 2*p[1] + p[2]), 74 | 3*(p[1]-p[0]), 75 | p[0]) 76 | elif len(p) == 3: 77 | coeffs = (p[0] - 2*p[1] + p[2], 78 | 2*(p[1] - p[0]), 79 | p[0]) 80 | elif len(p) == 2: 81 | coeffs = (p[1]-p[0], 82 | p[0]) 83 | elif len(p) == 1: 84 | coeffs = p 85 | else: 86 | # https://en.wikipedia.org/wiki/Bezier_curve#Polynomial_form 87 | n = len(p) - 1 88 | coeffs = [fac(n)//fac(n-j) * sum( 89 | (-1)**(i+j) * p[i] / (fac(i) * fac(j-i)) for i in range(j+1)) 90 | for j in range(n+1)] 91 | coeffs.reverse() 92 | if not numpy_ordering: 93 | coeffs = coeffs[::-1] # can't use .reverse() as might be tuple 94 | if return_poly1d: 95 | return poly1d(coeffs) 96 | return coeffs 97 | 98 | 99 | def polynomial2bezier(poly): 100 | """Converts a cubic or lower order Polynomial object (or a sequence of 101 | coefficients) to a CubicBezier, QuadraticBezier, or Line object as 102 | appropriate.""" 103 | if isinstance(poly, poly1d): 104 | c = poly.coeffs 105 | else: 106 | c = poly 107 | order = len(c)-1 108 | if order == 3: 109 | bpoints = (c[3], c[2]/3 + c[3], (c[1] + 2*c[2])/3 + c[3], 110 | c[0] + c[1] + c[2] + c[3]) 111 | elif order == 2: 112 | bpoints = (c[2], c[1]/2 + c[2], c[0] + c[1] + c[2]) 113 | elif order == 1: 114 | bpoints = (c[1], c[0] + c[1]) 115 | else: 116 | raise AssertionError("This function is only implemented for linear, " 117 | "quadratic, and cubic polynomials.") 118 | return bpoints 119 | 120 | 121 | # Curve Splitting ############################################################# 122 | 123 | def split_bezier(bpoints, t): 124 | """Uses deCasteljau's recursion to split the Bezier curve at t into two 125 | Bezier curves of the same order.""" 126 | def split_bezier_recursion(bpoints_left_, bpoints_right_, bpoints_, t_): 127 | if len(bpoints_) == 1: 128 | bpoints_left_.append(bpoints_[0]) 129 | bpoints_right_.append(bpoints_[0]) 130 | else: 131 | new_points = [None]*(len(bpoints_) - 1) 132 | bpoints_left_.append(bpoints_[0]) 133 | bpoints_right_.append(bpoints_[-1]) 134 | for i in range(len(bpoints_) - 1): 135 | new_points[i] = (1 - t_)*bpoints_[i] + t_*bpoints_[i + 1] 136 | bpoints_left_, bpoints_right_ = split_bezier_recursion( 137 | bpoints_left_, bpoints_right_, new_points, t_) 138 | return bpoints_left_, bpoints_right_ 139 | 140 | bpoints_left = [] 141 | bpoints_right = [] 142 | bpoints_left, bpoints_right = \ 143 | split_bezier_recursion(bpoints_left, bpoints_right, bpoints, t) 144 | bpoints_right.reverse() 145 | return bpoints_left, bpoints_right 146 | 147 | 148 | def halve_bezier(p): 149 | 150 | # begin arc support block ######################## 151 | try: 152 | p.large_arc 153 | return p.split(0.5) 154 | except: 155 | pass 156 | # end arc support block ########################## 157 | 158 | if len(p) == 4: 159 | return ([p[0], (p[0] + p[1])/2, (p[0] + 2*p[1] + p[2])/4, 160 | (p[0] + 3*p[1] + 3*p[2] + p[3])/8], 161 | [(p[0] + 3*p[1] + 3*p[2] + p[3])/8, 162 | (p[1] + 2*p[2] + p[3])/4, (p[2] + p[3])/2, p[3]]) 163 | else: 164 | return split_bezier(p, 0.5) 165 | 166 | 167 | # Bounding Boxes ############################################################## 168 | 169 | def bezier_real_minmax(p): 170 | """returns the minimum and maximum for any real cubic bezier""" 171 | local_extremizers = [0, 1] 172 | if len(p) == 4: # cubic case 173 | a = [p.real for p in p] 174 | denom = a[0] - 3*a[1] + 3*a[2] - a[3] 175 | if abs(denom) > FLOAT_EPSILON: # check that denom != 0 accounting for floating point error 176 | delta = a[1]**2 - (a[0] + a[1])*a[2] + a[2]**2 + (a[0] - a[1])*a[3] 177 | if delta >= 0: # otherwise no local extrema 178 | sqdelta = sqrt(delta) 179 | tau = a[0] - 2*a[1] + a[2] 180 | r1 = (tau + sqdelta)/denom 181 | r2 = (tau - sqdelta)/denom 182 | if 0 < r1 < 1: 183 | local_extremizers.append(r1) 184 | if 0 < r2 < 1: 185 | local_extremizers.append(r2) 186 | local_extrema = [bezier_point(a, t) for t in local_extremizers] 187 | return min(local_extrema), max(local_extrema) 188 | 189 | # find reverse standard coefficients of the derivative 190 | dcoeffs = bezier2polynomial(a, return_poly1d=True).deriv().coeffs 191 | 192 | # find real roots, r, such that 0 <= r <= 1 193 | local_extremizers += polyroots01(dcoeffs) 194 | local_extrema = [bezier_point(a, t) for t in local_extremizers] 195 | return min(local_extrema), max(local_extrema) 196 | 197 | 198 | def bezier_bounding_box(bez): 199 | """returns the bounding box for the segment in the form 200 | (xmin, xmax, ymin, ymax). 201 | Warning: For the non-cubic case this is not particularly efficient.""" 202 | 203 | # begin arc support block ######################## 204 | try: 205 | bla = bez.large_arc 206 | return bez.bbox() # added to support Arc objects 207 | except: 208 | pass 209 | # end arc support block ########################## 210 | 211 | if len(bez) == 4: 212 | xmin, xmax = bezier_real_minmax([p.real for p in bez]) 213 | ymin, ymax = bezier_real_minmax([p.imag for p in bez]) 214 | return xmin, xmax, ymin, ymax 215 | poly = bezier2polynomial(bez, return_poly1d=True) 216 | x = real(poly) 217 | y = imag(poly) 218 | dx = x.deriv() 219 | dy = y.deriv() 220 | x_extremizers = [0, 1] + polyroots(dx, realroots=True, 221 | condition=lambda r: 0 < r < 1) 222 | y_extremizers = [0, 1] + polyroots(dy, realroots=True, 223 | condition=lambda r: 0 < r < 1) 224 | x_extrema = [x(t) for t in x_extremizers] 225 | y_extrema = [y(t) for t in y_extremizers] 226 | return min(x_extrema), max(x_extrema), min(y_extrema), max(y_extrema) 227 | 228 | 229 | def box_area(xmin, xmax, ymin, ymax): 230 | """ 231 | INPUT: 2-tuple of cubics (given by control points) 232 | OUTPUT: boolean 233 | """ 234 | return (xmax - xmin)*(ymax - ymin) 235 | 236 | 237 | def interval_intersection_width(a, b, c, d): 238 | """returns the width of the intersection of intervals [a,b] and [c,d] 239 | (thinking of these as intervals on the real number line)""" 240 | return max(0, min(b, d) - max(a, c)) 241 | 242 | 243 | def boxes_intersect(box1, box2): 244 | """Determines if two rectangles, each input as a tuple 245 | (xmin, xmax, ymin, ymax), intersect.""" 246 | xmin1, xmax1, ymin1, ymax1 = box1 247 | xmin2, xmax2, ymin2, ymax2 = box2 248 | if interval_intersection_width(xmin1, xmax1, xmin2, xmax2) and \ 249 | interval_intersection_width(ymin1, ymax1, ymin2, ymax2): 250 | return True 251 | else: 252 | return False 253 | 254 | 255 | # Intersections ############################################################### 256 | 257 | class ApproxSolutionSet(list): 258 | """A class that behaves like a set but treats two elements , x and y, as 259 | equivalent if abs(x-y) < self.tol""" 260 | def __init__(self, tol): 261 | self.tol = tol 262 | 263 | def __contains__(self, x): 264 | for y in self: 265 | if abs(x - y) < self.tol: 266 | return True 267 | return False 268 | 269 | def appadd(self, pt): 270 | if pt not in self: 271 | self.append(pt) 272 | 273 | 274 | class BPair(object): 275 | def __init__(self, bez1, bez2, t1, t2): 276 | self.bez1 = bez1 277 | self.bez2 = bez2 278 | self.t1 = t1 # t value to get the mid point of this curve from cub1 279 | self.t2 = t2 # t value to get the mid point of this curve from cub2 280 | 281 | 282 | def bezier_intersections(bez1, bez2, longer_length, tol=1e-8, tol_deC=1e-8): 283 | """INPUT: 284 | bez1, bez2 = [P0,P1,P2,...PN], [Q0,Q1,Q2,...,PN] defining the two 285 | Bezier curves to check for intersections between. 286 | longer_length - the length (or an upper bound) on the longer of the two 287 | Bezier curves. Determines the maximum iterations needed together with tol. 288 | tol - is the smallest distance that two solutions can differ by and still 289 | be considered distinct solutions. 290 | OUTPUT: a list of tuples (t,s) in [0,1]x[0,1] such that 291 | abs(bezier_point(bez1[0],t) - bezier_point(bez2[1],s)) < tol_deC 292 | Note: This will return exactly one such tuple for each intersection 293 | (assuming tol_deC is small enough).""" 294 | maxits = int(ceil(1-log(tol_deC/longer_length)/log(2))) 295 | pair_list = [BPair(bez1, bez2, 0.5, 0.5)] 296 | intersection_list = [] 297 | k = 0 298 | approx_point_set = ApproxSolutionSet(tol) 299 | while pair_list and k < maxits: 300 | new_pairs = [] 301 | delta = 0.5**(k + 2) 302 | for pair in pair_list: 303 | bbox1 = bezier_bounding_box(pair.bez1) 304 | bbox2 = bezier_bounding_box(pair.bez2) 305 | if boxes_intersect(bbox1, bbox2): 306 | if box_area(*bbox1) < tol_deC and box_area(*bbox2) < tol_deC: 307 | point = bezier_point(bez1, pair.t1) 308 | if point not in approx_point_set: 309 | approx_point_set.append(point) 310 | # this is the point in the middle of the pair 311 | intersection_list.append((pair.t1, pair.t2)) 312 | 313 | # this prevents the output of redundant intersection points 314 | for otherPair in pair_list: 315 | if pair.bez1 == otherPair.bez1 or \ 316 | pair.bez2 == otherPair.bez2 or \ 317 | pair.bez1 == otherPair.bez2 or \ 318 | pair.bez2 == otherPair.bez1: 319 | pair_list.remove(otherPair) 320 | else: 321 | (c11, c12) = halve_bezier(pair.bez1) 322 | (t11, t12) = (pair.t1 - delta, pair.t1 + delta) 323 | (c21, c22) = halve_bezier(pair.bez2) 324 | (t21, t22) = (pair.t2 - delta, pair.t2 + delta) 325 | new_pairs += [BPair(c11, c21, t11, t21), 326 | BPair(c11, c22, t11, t22), 327 | BPair(c12, c21, t12, t21), 328 | BPair(c12, c22, t12, t22)] 329 | pair_list = new_pairs 330 | k += 1 331 | if k >= maxits: 332 | raise Exception("bezier_intersections has reached maximum " 333 | "iterations without terminating... " 334 | "either there's a problem/bug or you can fix by " 335 | "raising the max iterations or lowering tol_deC") 336 | return intersection_list 337 | 338 | 339 | def bezier_by_line_intersections(bezier, line): 340 | """Returns tuples (t1,t2) such that bezier.point(t1) ~= line.point(t2).""" 341 | # The method here is to translate (shift) then rotate the complex plane so 342 | # that line starts at the origin and proceeds along the positive real axis. 343 | # After this transformation, the intersection points are the real roots of 344 | # the imaginary component of the bezier for which the real component is 345 | # between 0 and abs(line[1]-line[0])]. 346 | assert len(line[:]) == 2 347 | assert line[0] != line[1] 348 | if not any(p != bezier[0] for p in bezier): 349 | raise ValueError("bezier is nodal, use " 350 | "bezier_by_line_intersection(bezier[0], line) " 351 | "instead for a bool to be returned.") 352 | 353 | # First let's shift the complex plane so that line starts at the origin 354 | shifted_bezier = [z - line[0] for z in bezier] 355 | shifted_line_end = line[1] - line[0] 356 | line_length = abs(shifted_line_end) 357 | 358 | # Now let's rotate the complex plane so that line falls on the x-axis 359 | rotation_matrix = line_length/shifted_line_end 360 | transformed_bezier = [rotation_matrix*z for z in shifted_bezier] 361 | 362 | # Now all intersections should be roots of the imaginary component of 363 | # the transformed bezier 364 | transformed_bezier_imag = [p.imag for p in transformed_bezier] 365 | coeffs_y = bezier2polynomial(transformed_bezier_imag) 366 | roots_y = list(polyroots01(coeffs_y)) # returns real roots 0 <= r <= 1 367 | 368 | transformed_bezier_real = [p.real for p in transformed_bezier] 369 | intersection_list = [] 370 | for bez_t in set(roots_y): 371 | xval = bezier_point(transformed_bezier_real, bez_t) 372 | if 0 <= xval <= line_length: 373 | line_t = xval/line_length 374 | intersection_list.append((bez_t, line_t)) 375 | return intersection_list 376 | 377 | -------------------------------------------------------------------------------- /svgpathtools/constants.py: -------------------------------------------------------------------------------- 1 | """This submodule contains constants used throughout the project.""" 2 | 3 | FLOAT_EPSILON = 1e-12 4 | -------------------------------------------------------------------------------- /svgpathtools/document.py: -------------------------------------------------------------------------------- 1 | """(Experimental) replacement for import/export functionality. 2 | 3 | This module contains the `Document` class, a container for a DOM-style 4 | document (e.g. svg, html, xml, etc.) designed to replace and improve 5 | upon the IO functionality of svgpathtools (i.e. the svg2paths and 6 | disvg/wsvg functions). 7 | 8 | An Historic Note: 9 | The functionality in this module is meant to replace and improve 10 | upon the IO functionality previously provided by the the 11 | `svg2paths` and `disvg`/`wsvg` functions. 12 | 13 | Example: 14 | Typical usage looks something like the following. 15 | 16 | >> from svgpathtools import Document 17 | >> doc = Document('my_file.html') 18 | >> for path in doc.paths(): 19 | >> # Do something with the transformed Path object. 20 | >> foo(path) 21 | >> # Inspect the raw SVG element, e.g. change its attributes 22 | >> foo(path.element) 23 | >> transform = result.transform 24 | >> # Use the transform that was applied to the path. 25 | >> foo(path.transform) 26 | >> foo(doc.tree) # do stuff using ElementTree's functionality 27 | >> doc.display() # display doc in OS's default application 28 | >> doc.save('my_new_file.html') 29 | 30 | A Big Problem: 31 | Derivatives and other functions may be messed up by 32 | transforms unless transforms are flattened (and not included in 33 | css) 34 | """ 35 | 36 | # External dependencies 37 | from __future__ import division, absolute_import, print_function 38 | import os 39 | import collections 40 | import xml.etree.ElementTree as etree 41 | from xml.etree.ElementTree import Element, SubElement, register_namespace 42 | from xml.dom.minidom import parseString 43 | import warnings 44 | from io import StringIO 45 | from tempfile import gettempdir 46 | from time import time 47 | import numpy as np 48 | 49 | # Internal dependencies 50 | from .parser import parse_path 51 | from .parser import parse_transform 52 | from .svg_to_paths import (path2pathd, ellipse2pathd, line2pathd, 53 | polyline2pathd, polygon2pathd, rect2pathd) 54 | from .misctools import open_in_browser 55 | from .path import transform, Path, is_path_segment 56 | 57 | # To maintain forward/backward compatibility 58 | try: 59 | string = basestring 60 | except NameError: 61 | string = str 62 | try: 63 | from os import PathLike 64 | except ImportError: 65 | PathLike = string 66 | 67 | # Let xml.etree.ElementTree know about the SVG namespace 68 | SVG_NAMESPACE = {'svg': 'http://www.w3.org/2000/svg'} 69 | register_namespace('svg', 'http://www.w3.org/2000/svg') 70 | 71 | # THESE MUST BE WRAPPED TO OUTPUT ElementTree.element objects 72 | CONVERSIONS = {'path': path2pathd, 73 | 'circle': ellipse2pathd, 74 | 'ellipse': ellipse2pathd, 75 | 'line': line2pathd, 76 | 'polyline': polyline2pathd, 77 | 'polygon': polygon2pathd, 78 | 'rect': rect2pathd} 79 | 80 | CONVERT_ONLY_PATHS = {'path': path2pathd} 81 | 82 | SVG_GROUP_TAG = 'svg:g' 83 | 84 | 85 | def flattened_paths(group, group_filter=lambda x: True, 86 | path_filter=lambda x: True, path_conversions=CONVERSIONS, 87 | group_search_xpath=SVG_GROUP_TAG): 88 | """Returns the paths inside a group (recursively), expressing the 89 | paths in the base coordinates. 90 | 91 | Note that if the group being passed in is nested inside some parent 92 | group(s), we cannot take the parent group(s) into account, because 93 | xml.etree.Element has no pointer to its parent. You should use 94 | Document.flattened_paths_from_group(group) to flatten a specific nested group into 95 | the root coordinates. 96 | 97 | Args: 98 | group is an Element 99 | path_conversions (dict): 100 | A dictionary to convert from an SVG element to a path data 101 | string. Any element tags that are not included in this 102 | dictionary will be ignored (including the `path` tag). To 103 | only convert explicit path elements, pass in 104 | `path_conversions=CONVERT_ONLY_PATHS`. 105 | """ 106 | if not isinstance(group, Element): 107 | raise TypeError('Must provide an xml.etree.Element object. ' 108 | 'Instead you provided {0}'.format(type(group))) 109 | 110 | # Stop right away if the group_selector rejects this group 111 | if not group_filter(group): 112 | warnings.warn('The input group [{}] (id attribute: {}) was rejected by the group filter' 113 | .format(group, group.get('id'))) 114 | return [] 115 | 116 | # To handle the transforms efficiently, we'll traverse the tree of 117 | # groups depth-first using a stack of tuples. 118 | # The first entry in the tuple is a group element and the second 119 | # entry is its transform. As we pop each entry in the stack, we 120 | # will add all its child group elements to the stack. 121 | StackElement = collections.namedtuple('StackElement', 122 | ['group', 'transform']) 123 | 124 | def new_stack_element(element, last_tf): 125 | return StackElement(element, last_tf.dot( 126 | parse_transform(element.get('transform')))) 127 | 128 | def get_relevant_children(parent, last_tf): 129 | children = [] 130 | for elem in filter(group_filter, 131 | parent.iterfind(group_search_xpath, SVG_NAMESPACE)): 132 | children.append(new_stack_element(elem, last_tf)) 133 | return children 134 | 135 | stack = [new_stack_element(group, np.identity(3))] 136 | 137 | paths = [] 138 | while stack: 139 | top = stack.pop() 140 | 141 | # For each element type that we know how to convert into path 142 | # data, parse the element after confirming that the path_filter 143 | # accepts it. 144 | for key, converter in path_conversions.items(): 145 | for path_elem in filter(path_filter, top.group.iterfind( 146 | 'svg:'+key, SVG_NAMESPACE)): 147 | path_tf = top.transform.dot( 148 | parse_transform(path_elem.get('transform'))) 149 | path = transform(parse_path(converter(path_elem)), path_tf) 150 | path.element = path_elem 151 | path.transform = path_tf 152 | paths.append(path) 153 | 154 | stack.extend(get_relevant_children(top.group, top.transform)) 155 | 156 | return paths 157 | 158 | 159 | def flattened_paths_from_group(group_to_flatten, root, recursive=True, 160 | group_filter=lambda x: True, 161 | path_filter=lambda x: True, 162 | path_conversions=CONVERSIONS, 163 | group_search_xpath=SVG_GROUP_TAG): 164 | """Flatten all the paths in a specific group. 165 | 166 | The paths will be flattened into the 'root' frame. Note that root 167 | needs to be an ancestor of the group that is being flattened. 168 | Otherwise, no paths will be returned.""" 169 | 170 | if not any(group_to_flatten is descendant for descendant in root.iter()): 171 | warnings.warn('The requested group_to_flatten is not a ' 172 | 'descendant of root') 173 | # We will shortcut here, because it is impossible for any paths 174 | # to be returned anyhow. 175 | return [] 176 | 177 | # We create a set of the unique IDs of each element that we wish to 178 | # flatten, if those elements are groups. Any groups outside of this 179 | # set will be skipped while we flatten the paths. 180 | desired_groups = set() 181 | if recursive: 182 | for group in group_to_flatten.iter(): 183 | desired_groups.add(id(group)) 184 | else: 185 | desired_groups.add(id(group_to_flatten)) 186 | 187 | ignore_paths = set() 188 | # Use breadth-first search to find the path to the group that we care about 189 | if root is not group_to_flatten: 190 | search = [[root]] 191 | route = None 192 | while search: 193 | top = search.pop(0) 194 | frontier = top[-1] 195 | for child in frontier.iterfind(group_search_xpath, SVG_NAMESPACE): 196 | if child is group_to_flatten: 197 | route = top 198 | break 199 | future_top = list(top) 200 | future_top.append(child) 201 | search.append(future_top) 202 | 203 | if route is not None: 204 | for group in route: 205 | # Add each group from the root to the parent of the desired group 206 | # to the list of groups that we should traverse. This makes sure 207 | # that paths will not stop before reaching the desired 208 | # group. 209 | desired_groups.add(id(group)) 210 | for key in path_conversions.keys(): 211 | for path_elem in group.iterfind('svg:'+key, SVG_NAMESPACE): 212 | # Add each path in the parent groups to the list of paths 213 | # that should be ignored. The user has not requested to 214 | # flatten the paths of the parent groups, so we should not 215 | # include any of these in the result. 216 | ignore_paths.add(id(path_elem)) 217 | break 218 | 219 | if route is None: 220 | raise ValueError('The group_to_flatten is not a descendant of the root!') 221 | 222 | def desired_group_filter(x): 223 | return (id(x) in desired_groups) and group_filter(x) 224 | 225 | def desired_path_filter(x): 226 | return (id(x) not in ignore_paths) and path_filter(x) 227 | 228 | return flattened_paths(root, desired_group_filter, desired_path_filter, 229 | path_conversions, group_search_xpath) 230 | 231 | 232 | class Document: 233 | def __init__(self, filepath=None): 234 | """A container for a DOM-style SVG document. 235 | 236 | The `Document` class provides a simple interface to modify and analyze 237 | the path elements in a DOM-style document. The DOM-style document is 238 | parsed into an ElementTree object (stored in the `tree` attribute). 239 | 240 | This class provides functions for extracting SVG data into Path objects. 241 | The output Path objects will be transformed based on their parent groups. 242 | 243 | Args: 244 | filepath (str or file-like): The filepath of the 245 | DOM-style object or a file-like object containing it. 246 | """ 247 | 248 | # strings are interpreted as file location everything else is treated as 249 | # file-like object and passed to the xml parser directly 250 | from_filepath = isinstance(filepath, string) or isinstance(filepath, PathLike) 251 | self.original_filepath = os.path.abspath(filepath) if from_filepath else None 252 | 253 | if filepath is None: 254 | self.tree = etree.ElementTree(Element('svg')) 255 | else: 256 | # parse svg to ElementTree object 257 | self.tree = etree.parse(filepath) 258 | 259 | self.root = self.tree.getroot() 260 | 261 | @classmethod 262 | def from_svg_string(cls, svg_string): 263 | """Constructor for creating a Document object from a string.""" 264 | # wrap string into StringIO object 265 | svg_file_obj = StringIO(svg_string) 266 | # create document from file object 267 | return Document(svg_file_obj) 268 | 269 | def paths(self, group_filter=lambda x: True, 270 | path_filter=lambda x: True, path_conversions=CONVERSIONS): 271 | """Returns a list of all paths in the document. 272 | 273 | Note that any transform attributes are applied before returning 274 | the paths. 275 | """ 276 | return flattened_paths(self.tree.getroot(), group_filter, 277 | path_filter, path_conversions) 278 | 279 | def paths_from_group(self, group, recursive=True, group_filter=lambda x: True, 280 | path_filter=lambda x: True, path_conversions=CONVERSIONS): 281 | if all(isinstance(s, string) for s in group): 282 | # If we're given a list of strings, assume it represents a 283 | # nested sequence 284 | group = self.get_group(group) 285 | elif not isinstance(group, Element): 286 | raise TypeError( 287 | 'Must provide a list of strings that represent a nested ' 288 | 'group name, or provide an xml.etree.Element object. ' 289 | 'Instead you provided {0}'.format(group)) 290 | 291 | if group is None: 292 | warnings.warn("Could not find the requested group!") 293 | return [] 294 | 295 | return flattened_paths_from_group(group, self.tree.getroot(), recursive, 296 | group_filter, path_filter, path_conversions) 297 | 298 | def add_path(self, path, attribs=None, group=None): 299 | """Add a new path to the SVG.""" 300 | 301 | # If not given a parent, assume that the path does not have a group 302 | if group is None: 303 | group = self.tree.getroot() 304 | 305 | # If given a list of strings (one or more), assume it represents 306 | # a sequence of nested group names 307 | elif len(group) > 0 and all(isinstance(elem, str) for elem in group): 308 | group = self.get_or_add_group(group) 309 | 310 | elif not isinstance(group, Element): 311 | raise TypeError( 312 | 'Must provide a list of strings or an xml.etree.Element ' 313 | 'object. Instead you provided {0}'.format(group)) 314 | 315 | else: 316 | # Make sure that the group belongs to this Document object 317 | if not self.contains_group(group): 318 | warnings.warn('The requested group does not belong to ' 319 | 'this Document') 320 | 321 | # TODO: It might be better to use duck-typing here with a try-except 322 | if isinstance(path, Path): 323 | path_svg = path.d() 324 | elif is_path_segment(path): 325 | path_svg = Path(path).d() 326 | elif isinstance(path, string): 327 | # Assume this is a valid d-string. 328 | # TODO: Should we sanity check the input string? 329 | path_svg = path 330 | else: 331 | raise TypeError( 332 | 'Must provide a Path, a path segment type, or a valid ' 333 | 'SVG path d-string. Instead you provided {0}'.format(path)) 334 | 335 | if attribs is None: 336 | attribs = {} 337 | else: 338 | attribs = attribs.copy() 339 | 340 | attribs['d'] = path_svg 341 | 342 | return SubElement(group, 'path', attribs) 343 | 344 | def contains_group(self, group): 345 | return any(group is owned for owned in self.tree.iter()) 346 | 347 | def get_group(self, nested_names, name_attr='id'): 348 | """Get a group from the tree, or None if the requested group 349 | does not exist. Use get_or_add_group(~) if you want a new group 350 | to be created if it did not already exist. 351 | 352 | `nested_names` is a list of strings which represent group names. 353 | Each group name will be nested inside of the previous group name. 354 | 355 | `name_attr` is the group attribute that is being used to 356 | represent the group's name. Default is 'id', but some SVGs may 357 | contain custom name labels, like 'inkscape:label'. 358 | 359 | Returns the request group. If the requested group did not 360 | exist, this function will return a None value. 361 | """ 362 | group = self.tree.getroot() 363 | # Drill down through the names until we find the desired group 364 | while len(nested_names): 365 | prev_group = group 366 | next_name = nested_names.pop(0) 367 | for elem in group.iterfind(SVG_GROUP_TAG, SVG_NAMESPACE): 368 | if elem.get(name_attr) == next_name: 369 | group = elem 370 | break 371 | 372 | if prev_group is group: 373 | # The nested group could not be found, so we return None 374 | return None 375 | 376 | return group 377 | 378 | def get_or_add_group(self, nested_names, name_attr='id'): 379 | """Get a group from the tree, or add a new one with the given 380 | name structure. 381 | 382 | `nested_names` is a list of strings which represent group names. 383 | Each group name will be nested inside of the previous group name. 384 | 385 | `name_attr` is the group attribute that is being used to 386 | represent the group's name. Default is 'id', but some SVGs may 387 | contain custom name labels, like 'inkscape:label'. 388 | 389 | Returns the requested group. If the requested group did not 390 | exist, this function will create it, as well as all parent 391 | groups that it requires. All created groups will be left with 392 | blank attributes. 393 | 394 | """ 395 | group = self.tree.getroot() 396 | # Drill down through the names until we find the desired group 397 | while len(nested_names): 398 | prev_group = group 399 | next_name = nested_names.pop(0) 400 | for elem in group.iterfind(SVG_GROUP_TAG, SVG_NAMESPACE): 401 | if elem.get(name_attr) == next_name: 402 | group = elem 403 | break 404 | 405 | if prev_group is group: 406 | # The group we're looking for does not exist, so let's 407 | # create the group structure 408 | nested_names.insert(0, next_name) 409 | 410 | while nested_names: 411 | next_name = nested_names.pop(0) 412 | group = self.add_group({'id': next_name}, group) 413 | # Now nested_names will be empty, so the topmost 414 | # while-loop will end 415 | return group 416 | 417 | def add_group(self, group_attribs=None, parent=None): 418 | """Add an empty group element to the SVG.""" 419 | if parent is None: 420 | parent = self.tree.getroot() 421 | elif not self.contains_group(parent): 422 | warnings.warn('The requested group {0} does not belong to ' 423 | 'this Document'.format(parent)) 424 | 425 | if group_attribs is None: 426 | group_attribs = {} 427 | else: 428 | group_attribs = group_attribs.copy() 429 | 430 | return SubElement(parent, '{{{0}}}g'.format( 431 | SVG_NAMESPACE['svg']), group_attribs) 432 | 433 | def __repr__(self): 434 | return etree.tostring(self.tree.getroot()).decode() 435 | 436 | def pretty(self, **kwargs): 437 | return parseString(repr(self)).toprettyxml(**kwargs) 438 | 439 | def save(self, filepath, prettify=False, **kwargs): 440 | with open(filepath, 'w+') as output_svg: 441 | if prettify: 442 | output_svg.write(self.pretty(**kwargs)) 443 | else: 444 | output_svg.write(repr(self)) 445 | 446 | def display(self, filepath=None): 447 | """Displays/opens the doc using the OS's default application.""" 448 | 449 | if filepath is None: 450 | if self.original_filepath is None: # created from empty Document 451 | orig_name, ext = 'unnamed', '.svg' 452 | else: 453 | orig_name, ext = \ 454 | os.path.splitext(os.path.basename(self.original_filepath)) 455 | tmp_name = orig_name + '_' + str(time()).replace('.', '-') + ext 456 | filepath = os.path.join(gettempdir(), tmp_name) 457 | 458 | # write to a (by default temporary) file 459 | with open(filepath, 'w') as output_svg: 460 | output_svg.write(repr(self)) 461 | 462 | open_in_browser(filepath) 463 | -------------------------------------------------------------------------------- /svgpathtools/misctools.py: -------------------------------------------------------------------------------- 1 | """This submodule contains miscellaneous tools that are used internally, but 2 | aren't specific to SVGs or related mathematical objects.""" 3 | 4 | # External dependencies: 5 | from __future__ import division, absolute_import, print_function 6 | import os 7 | import sys 8 | import webbrowser 9 | 10 | 11 | # stackoverflow.com/questions/214359/converting-hex-color-to-rgb-and-vice-versa 12 | def hex2rgb(value): 13 | """Converts a hexadeximal color string to an RGB 3-tuple 14 | 15 | EXAMPLE 16 | ------- 17 | >>> hex2rgb('#0000FF') 18 | (0, 0, 255) 19 | """ 20 | value = value.lstrip('#') 21 | lv = len(value) 22 | return tuple(int(value[i:i+lv//3], 16) for i in range(0, lv, lv//3)) 23 | 24 | 25 | # stackoverflow.com/questions/214359/converting-hex-color-to-rgb-and-vice-versa 26 | def rgb2hex(rgb): 27 | """Converts an RGB 3-tuple to a hexadeximal color string. 28 | 29 | EXAMPLE 30 | ------- 31 | >>> rgb2hex((0,0,255)) 32 | '#0000FF' 33 | """ 34 | return ('#%02x%02x%02x' % tuple(rgb)).upper() 35 | 36 | 37 | def isclose(a, b, rtol=1e-5, atol=1e-8): 38 | """This is essentially np.isclose, but slightly faster.""" 39 | return abs(a - b) < (atol + rtol * abs(b)) 40 | 41 | 42 | def open_in_browser(file_location): 43 | """Attempt to open file located at file_location in the default web 44 | browser.""" 45 | 46 | # If just the name of the file was given, check if it's in the Current 47 | # Working Directory. 48 | if not os.path.isfile(file_location): 49 | file_location = os.path.join(os.getcwd(), file_location) 50 | if not os.path.isfile(file_location): 51 | raise IOError("\n\nFile not found.") 52 | 53 | # For some reason OSX requires this adjustment (tested on 10.10.4) 54 | if sys.platform == "darwin": 55 | file_location = "file:///"+file_location 56 | 57 | new = 2 # open in a new tab, if possible 58 | webbrowser.get().open(file_location, new=new) 59 | 60 | 61 | BugException = Exception("This code should never be reached. You've found a " 62 | "bug. Please submit an issue to \n" 63 | "https://github.com/mathandy/svgpathtools/issues" 64 | "\nwith an easily reproducible example.") 65 | -------------------------------------------------------------------------------- /svgpathtools/parser.py: -------------------------------------------------------------------------------- 1 | """This submodule contains the path_parse() function used to convert SVG path 2 | element d-strings into svgpathtools Path objects. 3 | Note: This file was taken (nearly) as is from the svg.path module (v 2.0).""" 4 | 5 | # External dependencies 6 | from __future__ import division, absolute_import, print_function 7 | import numpy as np 8 | import warnings 9 | 10 | # Internal dependencies 11 | from .path import Path 12 | 13 | 14 | def parse_path(pathdef, current_pos=0j, tree_element=None): 15 | return Path(pathdef, current_pos=current_pos, tree_element=tree_element) 16 | 17 | 18 | def _check_num_parsed_values(values, allowed): 19 | if not any(num == len(values) for num in allowed): 20 | if len(allowed) > 1: 21 | warnings.warn('Expected one of the following number of values {0}, but found {1} values instead: {2}' 22 | .format(allowed, len(values), values)) 23 | elif allowed[0] != 1: 24 | warnings.warn('Expected {0} values, found {1}: {2}'.format(allowed[0], len(values), values)) 25 | else: 26 | warnings.warn('Expected 1 value, found {0}: {1}'.format(len(values), values)) 27 | return False 28 | return True 29 | 30 | 31 | def _parse_transform_substr(transform_substr): 32 | 33 | type_str, value_str = transform_substr.split('(') 34 | value_str = value_str.replace(',', ' ') 35 | values = list(map(float, filter(None, value_str.split(' ')))) 36 | 37 | transform = np.identity(3) 38 | if 'matrix' in type_str: 39 | if not _check_num_parsed_values(values, [6]): 40 | return transform 41 | 42 | transform[0:2, 0:3] = np.array([values[0:6:2], values[1:6:2]]) 43 | 44 | elif 'translate' in transform_substr: 45 | if not _check_num_parsed_values(values, [1, 2]): 46 | return transform 47 | 48 | transform[0, 2] = values[0] 49 | if len(values) > 1: 50 | transform[1, 2] = values[1] 51 | 52 | elif 'scale' in transform_substr: 53 | if not _check_num_parsed_values(values, [1, 2]): 54 | return transform 55 | 56 | x_scale = values[0] 57 | y_scale = values[1] if (len(values) > 1) else x_scale 58 | transform[0, 0] = x_scale 59 | transform[1, 1] = y_scale 60 | 61 | elif 'rotate' in transform_substr: 62 | if not _check_num_parsed_values(values, [1, 3]): 63 | return transform 64 | 65 | angle = values[0] * np.pi / 180.0 66 | if len(values) == 3: 67 | offset = values[1:3] 68 | else: 69 | offset = (0, 0) 70 | tf_offset = np.identity(3) 71 | tf_offset[0:2, 2:3] = np.array([[offset[0]], [offset[1]]]) 72 | tf_rotate = np.identity(3) 73 | tf_rotate[0:2, 0:2] = np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]) 74 | tf_offset_neg = np.identity(3) 75 | tf_offset_neg[0:2, 2:3] = np.array([[-offset[0]], [-offset[1]]]) 76 | 77 | transform = tf_offset.dot(tf_rotate).dot(tf_offset_neg) 78 | 79 | elif 'skewX' in transform_substr: 80 | if not _check_num_parsed_values(values, [1]): 81 | return transform 82 | 83 | transform[0, 1] = np.tan(values[0] * np.pi / 180.0) 84 | 85 | elif 'skewY' in transform_substr: 86 | if not _check_num_parsed_values(values, [1]): 87 | return transform 88 | 89 | transform[1, 0] = np.tan(values[0] * np.pi / 180.0) 90 | else: 91 | # Return an identity matrix if the type of transform is unknown, and warn the user 92 | warnings.warn('Unknown SVG transform type: {0}'.format(type_str)) 93 | 94 | return transform 95 | 96 | 97 | def parse_transform(transform_str): 98 | """Converts a valid SVG transformation string into a 3x3 matrix. 99 | If the string is empty or null, this returns a 3x3 identity matrix""" 100 | if not transform_str: 101 | return np.identity(3) 102 | elif not isinstance(transform_str, str): 103 | raise TypeError('Must provide a string to parse') 104 | 105 | total_transform = np.identity(3) 106 | transform_substrs = transform_str.split(')')[:-1] # Skip the last element, because it should be empty 107 | for substr in transform_substrs: 108 | total_transform = total_transform.dot(_parse_transform_substr(substr)) 109 | 110 | return total_transform 111 | -------------------------------------------------------------------------------- /svgpathtools/paths2svg.py: -------------------------------------------------------------------------------- 1 | """This submodule: basic tools for creating svg files from path data. 2 | 3 | See also the document.py submodule. 4 | """ 5 | 6 | # External dependencies: 7 | from __future__ import division, absolute_import, print_function 8 | from math import ceil 9 | from os import path as os_path, makedirs 10 | from tempfile import gettempdir 11 | from xml.dom.minidom import parse as md_xml_parse 12 | from svgwrite import Drawing, text as txt 13 | from time import time 14 | from warnings import warn 15 | import re 16 | 17 | # Internal dependencies 18 | from .path import Path, Line, is_path_segment 19 | from .misctools import open_in_browser 20 | 21 | # color shorthand for inputting color list as string of chars. 22 | color_dict = {'a': 'aqua', 23 | 'b': 'blue', 24 | 'c': 'cyan', 25 | 'd': 'darkblue', 26 | 'e': '', 27 | 'f': '', 28 | 'g': 'green', 29 | 'h': '', 30 | 'i': '', 31 | 'j': '', 32 | 'k': 'black', 33 | 'l': 'lime', 34 | 'm': 'magenta', 35 | 'n': 'brown', 36 | 'o': 'orange', 37 | 'p': 'pink', 38 | 'q': 'turquoise', 39 | 'r': 'red', 40 | 's': 'salmon', 41 | 't': 'tan', 42 | 'u': 'purple', 43 | 'v': 'violet', 44 | 'w': 'white', 45 | 'x': '', 46 | 'y': 'yellow', 47 | 'z': 'azure'} 48 | 49 | 50 | def str2colorlist(s, default_color=None): 51 | color_list = [color_dict[ch] for ch in s] 52 | if default_color: 53 | for idx, c in enumerate(color_list): 54 | if not c: 55 | color_list[idx] = default_color 56 | return color_list 57 | 58 | 59 | def is3tuple(c): 60 | return isinstance(c, tuple) and len(c) == 3 61 | 62 | 63 | def big_bounding_box(paths_n_stuff): 64 | """returns minimal upright bounding box. 65 | 66 | Args: 67 | paths_n_stuff: iterable of Paths, Bezier path segments, and 68 | points (given as complex numbers). 69 | 70 | Returns: 71 | extrema of bounding box, (xmin, xmax, ymin, ymax) 72 | 73 | """ 74 | bbs = [] 75 | for thing in paths_n_stuff: 76 | if is_path_segment(thing) or isinstance(thing, Path): 77 | bbs.append(thing.bbox()) 78 | elif isinstance(thing, complex): 79 | bbs.append((thing.real, thing.real, thing.imag, thing.imag)) 80 | else: 81 | try: 82 | complexthing = complex(thing) 83 | bbs.append((complexthing.real, complexthing.real, 84 | complexthing.imag, complexthing.imag)) 85 | except ValueError: 86 | raise TypeError("paths_n_stuff can only contains Path, " 87 | "CubicBezier, QuadraticBezier, Line, " 88 | "and complex objects.") 89 | xmins, xmaxs, ymins, ymaxs = list(zip(*bbs)) 90 | xmin = min(xmins) 91 | xmax = max(xmaxs) 92 | ymin = min(ymins) 93 | ymax = max(ymaxs) 94 | return xmin, xmax, ymin, ymax 95 | 96 | 97 | def disvg(paths=None, colors=None, filename=None, stroke_widths=None, 98 | nodes=None, node_colors=None, node_radii=None, 99 | openinbrowser=True, timestamp=None, margin_size=0.1, 100 | mindim=600, dimensions=None, viewbox=None, text=None, 101 | text_path=None, font_size=None, attributes=None, 102 | svg_attributes=None, svgwrite_debug=False, 103 | paths2Drawing=False, baseunit='px'): 104 | """Creates (and optionally displays) an SVG file. 105 | 106 | REQUIRED INPUTS: 107 | :param paths - a list of paths 108 | 109 | OPTIONAL INPUT: 110 | :param colors - specifies the path stroke color. By default all paths 111 | will be black (#000000). This paramater can be input in a few ways 112 | 1) a list of strings that will be input into the path elements stroke 113 | attribute (so anything that is understood by the svg viewer). 114 | 2) a string of single character colors -- e.g. setting colors='rrr' is 115 | equivalent to setting colors=['red', 'red', 'red'] (see the 116 | 'color_dict' dictionary above for a list of possibilities). 117 | 3) a list of rgb 3-tuples -- e.g. colors = [(255, 0, 0), ...]. 118 | 119 | :param filename - the desired location/filename of the SVG file 120 | created (by default the SVG will be named 'disvg_output.svg' or 121 | 'disvg_output_.svg' and stored in the temporary 122 | directory returned by `tempfile.gettempdir()`. See `timestamp` 123 | for information on the timestamp. 124 | 125 | :param stroke_widths - a list of stroke_widths to use for paths 126 | (default is 0.5% of the SVG's width or length) 127 | 128 | :param nodes - a list of points to draw as filled-in circles 129 | 130 | :param node_colors - a list of colors to use for the nodes (by default 131 | nodes will be red) 132 | 133 | :param node_radii - a list of radii to use for the nodes (by default 134 | nodes will be radius will be 1 percent of the svg's width/length) 135 | 136 | :param text - string or list of strings to be displayed 137 | 138 | :param text_path - if text is a list, then this should be a list of 139 | path (or path segments of the same length. Note: the path must be 140 | long enough to display the text or the text will be cropped by the svg 141 | viewer. 142 | 143 | :param font_size - a single float of list of floats. 144 | 145 | :param openinbrowser - Set to True to automatically open the created 146 | SVG in the user's default web browser. 147 | 148 | :param timestamp - if true, then the a timestamp will be 149 | appended to the output SVG's filename. This is meant as a 150 | workaround for issues related to rapidly opening multiple 151 | SVGs in your browser using `disvg`. This defaults to true if 152 | `filename is None` and false otherwise. 153 | 154 | :param margin_size - The min margin (empty area framing the collection 155 | of paths) size used for creating the canvas and background of the SVG. 156 | 157 | :param mindim - The minimum dimension (height or width) of the output 158 | SVG (default is 600). 159 | 160 | :param dimensions - The (x,y) display dimensions of the output SVG. 161 | I.e. this specifies the `width` and `height` SVG attributes. Note that 162 | these also can be used to specify units other than pixels. Using this 163 | will override the `mindim` parameter. 164 | 165 | :param viewbox - This specifies the coordinated system used in the svg. 166 | The SVG `viewBox` attribute works together with the the `height` and 167 | `width` attrinutes. Using these three attributes allows for shifting 168 | and scaling of the SVG canvas without changing the any values other 169 | than those in `viewBox`, `height`, and `width`. `viewbox` should be 170 | input as a 4-tuple, (min_x, min_y, width, height), or a string 171 | "min_x min_y width height". Using this will override the `mindim` 172 | parameter. 173 | 174 | :param attributes - a list of dictionaries of attributes for the input 175 | paths. Note: This will override any other conflicting settings. 176 | 177 | :param svg_attributes - a dictionary of attributes for output svg. 178 | 179 | :param svgwrite_debug - This parameter turns on/off `svgwrite`'s 180 | debugging mode. By default svgwrite_debug=False. This increases 181 | speed and also prevents `svgwrite` from raising of an error when not 182 | all `svg_attributes` key-value pairs are understood. 183 | 184 | :param paths2Drawing - If true, an `svgwrite.Drawing` object is 185 | returned and no file is written. This `Drawing` can later be saved 186 | using the `svgwrite.Drawing.save()` method. 187 | 188 | NOTES: 189 | * The `svg_attributes` parameter will override any other conflicting 190 | settings. 191 | 192 | * Any `extra` parameters that `svgwrite.Drawing()` accepts can be 193 | controlled by passing them in through `svg_attributes`. 194 | 195 | * The unit of length here is assumed to be pixels in all variables. 196 | 197 | * If this function is used multiple times in quick succession to 198 | display multiple SVGs (all using the default filename), the 199 | svgviewer/browser will likely fail to load some of the SVGs in time. 200 | To fix this, use the timestamp attribute, or give the files unique 201 | names, or use a pause command (e.g. time.sleep(1)) between uses. 202 | 203 | SEE ALSO: 204 | * document.py 205 | """ 206 | 207 | _default_relative_node_radius = 5e-3 208 | _default_relative_stroke_width = 1e-3 209 | _default_path_color = '#000000' # black 210 | _default_node_color = '#ff0000' # red 211 | _default_font_size = 12 212 | 213 | if filename is None: 214 | timestamp = True if timestamp is None else timestamp 215 | filename = os_path.join(gettempdir(), 'disvg_output.svg') 216 | 217 | dirname = os_path.abspath(os_path.dirname(filename)) 218 | if not os_path.exists(dirname): 219 | makedirs(dirname) 220 | 221 | # append time stamp to filename 222 | if timestamp: 223 | fbname, fext = os_path.splitext(filename) 224 | tstamp = str(time()).replace('.', '') 225 | stfilename = os_path.split(fbname)[1] + '_' + tstamp + fext 226 | filename = os_path.join(dirname, stfilename) 227 | 228 | # check paths and colors are set 229 | if isinstance(paths, Path) or is_path_segment(paths): 230 | paths = [paths] 231 | if paths: 232 | if not colors: 233 | colors = [_default_path_color] * len(paths) 234 | else: 235 | assert len(colors) == len(paths) 236 | if isinstance(colors, str): 237 | colors = str2colorlist(colors, 238 | default_color=_default_path_color) 239 | elif isinstance(colors, list): 240 | for idx, c in enumerate(colors): 241 | if is3tuple(c): 242 | colors[idx] = "rgb" + str(c) 243 | 244 | # check nodes and nodes_colors are set (node_radii are set later) 245 | if nodes: 246 | if not node_colors: 247 | node_colors = [_default_node_color] * len(nodes) 248 | else: 249 | assert len(node_colors) == len(nodes) 250 | if isinstance(node_colors, str): 251 | node_colors = str2colorlist(node_colors, 252 | default_color=_default_node_color) 253 | elif isinstance(node_colors, list): 254 | for idx, c in enumerate(node_colors): 255 | if is3tuple(c): 256 | node_colors[idx] = "rgb" + str(c) 257 | 258 | # set up the viewBox and display dimensions of the output SVG 259 | # along the way, set stroke_widths and node_radii if not provided 260 | assert paths or nodes 261 | stuff2bound = [] 262 | if viewbox: 263 | if not isinstance(viewbox, str): 264 | viewbox = '%s %s %s %s' % viewbox 265 | if dimensions is None: 266 | dimensions = viewbox.split(' ')[2:4] 267 | elif dimensions: 268 | dimensions = tuple(map(str, dimensions)) 269 | def strip_units(s): 270 | return re.search(r'\d*\.?\d*', s.strip()).group() 271 | viewbox = '0 0 %s %s' % tuple(map(strip_units, dimensions)) 272 | else: 273 | if paths: 274 | stuff2bound += paths 275 | if nodes: 276 | stuff2bound += nodes 277 | if text_path: 278 | stuff2bound += text_path 279 | xmin, xmax, ymin, ymax = big_bounding_box(stuff2bound) 280 | dx = xmax - xmin 281 | dy = ymax - ymin 282 | 283 | if dx == 0: 284 | dx = 1 285 | if dy == 0: 286 | dy = 1 287 | 288 | # determine stroke_widths to use (if not provided) and max_stroke_width 289 | if paths: 290 | if not stroke_widths: 291 | sw = max(dx, dy) * _default_relative_stroke_width 292 | stroke_widths = [sw]*len(paths) 293 | max_stroke_width = sw 294 | else: 295 | assert len(paths) == len(stroke_widths) 296 | max_stroke_width = max(stroke_widths) 297 | else: 298 | max_stroke_width = 0 299 | 300 | # determine node_radii to use (if not provided) and max_node_diameter 301 | if nodes: 302 | if not node_radii: 303 | r = max(dx, dy) * _default_relative_node_radius 304 | node_radii = [r]*len(nodes) 305 | max_node_diameter = 2*r 306 | else: 307 | assert len(nodes) == len(node_radii) 308 | max_node_diameter = 2*max(node_radii) 309 | else: 310 | max_node_diameter = 0 311 | 312 | extra_space_for_style = max(max_stroke_width, max_node_diameter) 313 | xmin -= margin_size*dx + extra_space_for_style/2 314 | ymin -= margin_size*dy + extra_space_for_style/2 315 | dx += 2*margin_size*dx + extra_space_for_style 316 | dy += 2*margin_size*dy + extra_space_for_style 317 | viewbox = "%s %s %s %s" % (xmin, ymin, dx, dy) 318 | 319 | if mindim is None: 320 | szx = "{}{}".format(dx, baseunit) 321 | szy = "{}{}".format(dy, baseunit) 322 | else: 323 | if dx > dy: 324 | szx = str(mindim) + baseunit 325 | szy = str(int(ceil(mindim * dy / dx))) + baseunit 326 | else: 327 | szx = str(int(ceil(mindim * dx / dy))) + baseunit 328 | szy = str(mindim) + baseunit 329 | dimensions = szx, szy 330 | 331 | # Create an SVG file 332 | if svg_attributes is not None: 333 | dimensions = (svg_attributes.get("width", dimensions[0]), 334 | svg_attributes.get("height", dimensions[1])) 335 | debug = svg_attributes.get("debug", svgwrite_debug) 336 | dwg = Drawing(filename=filename, size=dimensions, debug=debug, 337 | **svg_attributes) 338 | else: 339 | dwg = Drawing(filename=filename, size=dimensions, debug=svgwrite_debug, 340 | viewBox=viewbox) 341 | 342 | # add paths 343 | if paths: 344 | for i, p in enumerate(paths): 345 | if isinstance(p, Path): 346 | ps = p.d() 347 | elif is_path_segment(p): 348 | ps = Path(p).d() 349 | else: # assume this path, p, was input as a Path d-string 350 | ps = p 351 | 352 | if attributes: 353 | good_attribs = {'d': ps} 354 | for key in attributes[i]: 355 | val = attributes[i][key] 356 | if key != 'd': 357 | try: 358 | dwg.path(ps, **{key: val}) 359 | good_attribs.update({key: val}) 360 | except Exception as e: 361 | warn(str(e)) 362 | 363 | dwg.add(dwg.path(**good_attribs)) 364 | else: 365 | dwg.add(dwg.path(ps, stroke=colors[i], 366 | stroke_width=str(stroke_widths[i]), 367 | fill='none')) 368 | 369 | # add nodes (filled in circles) 370 | if nodes: 371 | for i_pt, pt in enumerate([(z.real, z.imag) for z in nodes]): 372 | dwg.add(dwg.circle(pt, node_radii[i_pt], fill=node_colors[i_pt])) 373 | 374 | # add texts 375 | if text: 376 | assert isinstance(text, str) or (isinstance(text, list) and 377 | isinstance(text_path, list) and 378 | len(text_path) == len(text)) 379 | if isinstance(text, str): 380 | text = [text] 381 | if not font_size: 382 | font_size = [_default_font_size] 383 | if not text_path: 384 | pos = complex(xmin + margin_size*dx, ymin + margin_size*dy) 385 | text_path = [Line(pos, pos + 1).d()] 386 | else: 387 | if font_size: 388 | if isinstance(font_size, list): 389 | assert len(font_size) == len(text) 390 | else: 391 | font_size = [font_size] * len(text) 392 | else: 393 | font_size = [_default_font_size] * len(text) 394 | for idx, s in enumerate(text): 395 | p = text_path[idx] 396 | if isinstance(p, Path): 397 | ps = p.d() 398 | elif is_path_segment(p): 399 | ps = Path(p).d() 400 | else: # assume this path, p, was input as a Path d-string 401 | ps = p 402 | 403 | # paragraph = dwg.add(dwg.g(font_size=font_size[idx])) 404 | # paragraph.add(dwg.textPath(ps, s)) 405 | pathid = 'tp' + str(idx) 406 | dwg.defs.add(dwg.path(d=ps, id=pathid)) 407 | txter = dwg.add(dwg.text('', font_size=font_size[idx])) 408 | txter.add(txt.TextPath('#'+pathid, s)) 409 | 410 | if paths2Drawing: 411 | return dwg 412 | 413 | dwg.save() 414 | 415 | # re-open the svg, make the xml pretty, and save it again 416 | xmlstring = md_xml_parse(filename).toprettyxml() 417 | with open(filename, 'w') as f: 418 | f.write(xmlstring) 419 | 420 | # try to open in web browser 421 | if openinbrowser: 422 | try: 423 | open_in_browser(filename) 424 | except: 425 | print("Failed to open output SVG in browser. SVG saved to:") 426 | print(filename) 427 | 428 | 429 | def wsvg(paths=None, colors=None, filename=None, stroke_widths=None, 430 | nodes=None, node_colors=None, node_radii=None, 431 | openinbrowser=False, timestamp=False, margin_size=0.1, 432 | mindim=600, dimensions=None, viewbox=None, text=None, 433 | text_path=None, font_size=None, attributes=None, 434 | svg_attributes=None, svgwrite_debug=False, 435 | paths2Drawing=False, baseunit='px'): 436 | """Create SVG and write to disk. 437 | 438 | Note: This is identical to `disvg()` except that `openinbrowser` 439 | is false by default and an assertion error is raised if `filename 440 | is None`. 441 | 442 | See `disvg()` docstring for more info. 443 | """ 444 | assert filename is not None 445 | return disvg(paths, colors=colors, filename=filename, 446 | stroke_widths=stroke_widths, nodes=nodes, 447 | node_colors=node_colors, node_radii=node_radii, 448 | openinbrowser=openinbrowser, timestamp=timestamp, 449 | margin_size=margin_size, mindim=mindim, 450 | dimensions=dimensions, viewbox=viewbox, text=text, 451 | text_path=text_path, font_size=font_size, 452 | attributes=attributes, svg_attributes=svg_attributes, 453 | svgwrite_debug=svgwrite_debug, 454 | paths2Drawing=paths2Drawing, baseunit=baseunit) 455 | 456 | 457 | def paths2Drawing(paths=None, colors=None, filename=None, 458 | stroke_widths=None, nodes=None, node_colors=None, 459 | node_radii=None, openinbrowser=False, timestamp=False, 460 | margin_size=0.1, mindim=600, dimensions=None, 461 | viewbox=None, text=None, text_path=None, 462 | font_size=None, attributes=None, svg_attributes=None, 463 | svgwrite_debug=False, paths2Drawing=True, baseunit='px'): 464 | """Create and return `svg.Drawing` object. 465 | 466 | Note: This is identical to `disvg()` except that `paths2Drawing` 467 | is true by default and an assertion error is raised if `filename 468 | is None`. 469 | 470 | See `disvg()` docstring for more info. 471 | """ 472 | return disvg(paths, colors=colors, filename=filename, 473 | stroke_widths=stroke_widths, nodes=nodes, 474 | node_colors=node_colors, node_radii=node_radii, 475 | openinbrowser=openinbrowser, timestamp=timestamp, 476 | margin_size=margin_size, mindim=mindim, 477 | dimensions=dimensions, viewbox=viewbox, text=text, 478 | text_path=text_path, font_size=font_size, 479 | attributes=attributes, svg_attributes=svg_attributes, 480 | svgwrite_debug=svgwrite_debug, 481 | paths2Drawing=paths2Drawing, baseunit=baseunit) 482 | -------------------------------------------------------------------------------- /svgpathtools/polytools.py: -------------------------------------------------------------------------------- 1 | """This submodule contains tools for working with numpy.poly1d objects.""" 2 | 3 | # External Dependencies 4 | from __future__ import division, absolute_import 5 | from itertools import combinations 6 | import numpy as np 7 | 8 | # Internal Dependencies 9 | from .misctools import isclose 10 | 11 | 12 | def polyroots(p, realroots=False, condition=lambda r: True): 13 | """ 14 | Returns the roots of a polynomial with coefficients given in p. 15 | p[0] * x**n + p[1] * x**(n-1) + ... + p[n-1]*x + p[n] 16 | INPUT: 17 | p - Rank-1 array-like object of polynomial coefficients. 18 | realroots - a boolean. If true, only real roots will be returned and the 19 | condition function can be written assuming all roots are real. 20 | condition - a boolean-valued function. Only roots satisfying this will be 21 | returned. If realroots==True, these conditions should assume the roots 22 | are real. 23 | OUTPUT: 24 | A list containing the roots of the polynomial. 25 | NOTE: This uses np.isclose and np.roots""" 26 | roots = np.roots(p) 27 | if realroots: 28 | roots = [r.real for r in roots if isclose(r.imag, 0)] 29 | roots = [r for r in roots if condition(r)] 30 | 31 | duplicates = [] 32 | for idx, (r1, r2) in enumerate(combinations(roots, 2)): 33 | if isclose(r1, r2): 34 | duplicates.append(idx) 35 | return [r for idx, r in enumerate(roots) if idx not in duplicates] 36 | 37 | 38 | def polyroots01(p): 39 | """Returns the real roots between 0 and 1 of the polynomial with 40 | coefficients given in p, 41 | p[0] * x**n + p[1] * x**(n-1) + ... + p[n-1]*x + p[n] 42 | p can also be a np.poly1d object. See polyroots for more information.""" 43 | return polyroots(p, realroots=True, condition=lambda tval: 0 <= tval <= 1) 44 | 45 | 46 | def rational_limit(f, g, t0): 47 | """Computes the limit of the rational function (f/g)(t) 48 | as t approaches t0.""" 49 | assert isinstance(f, np.poly1d) and isinstance(g, np.poly1d) 50 | assert g != np.poly1d([0]) 51 | if g(t0) != 0: 52 | return f(t0)/g(t0) 53 | elif f(t0) == 0: 54 | return rational_limit(f.deriv(), g.deriv(), t0) 55 | else: 56 | raise ValueError("Limit does not exist.") 57 | 58 | 59 | def real(z): 60 | try: 61 | return np.poly1d(z.coeffs.real) 62 | except AttributeError: 63 | return z.real 64 | 65 | 66 | def imag(z): 67 | try: 68 | return np.poly1d(z.coeffs.imag) 69 | except AttributeError: 70 | return z.imag 71 | 72 | 73 | def poly_real_part(poly): 74 | """Deprecated.""" 75 | return np.poly1d(poly.coeffs.real) 76 | 77 | 78 | def poly_imag_part(poly): 79 | """Deprecated.""" 80 | return np.poly1d(poly.coeffs.imag) 81 | -------------------------------------------------------------------------------- /svgpathtools/smoothing.py: -------------------------------------------------------------------------------- 1 | """This submodule contains functions related to smoothing paths of Bezier 2 | curves.""" 3 | 4 | # External Dependencies 5 | from __future__ import division, absolute_import, print_function 6 | 7 | # Internal Dependencies 8 | from .path import Path, CubicBezier, Line 9 | from .misctools import isclose 10 | from .paths2svg import disvg 11 | 12 | 13 | def is_differentiable(path, tol=1e-8): 14 | for idx in range(len(path)): 15 | u = path[(idx-1) % len(path)].unit_tangent(1) 16 | v = path[idx].unit_tangent(0) 17 | u_dot_v = u.real*v.real + u.imag*v.imag 18 | if abs(u_dot_v - 1) > tol: 19 | return False 20 | return True 21 | 22 | 23 | def kinks(path, tol=1e-8): 24 | """returns indices of segments that start on a non-differentiable joint.""" 25 | kink_list = [] 26 | for idx in range(len(path)): 27 | if idx == 0 and not path.isclosed(): 28 | continue 29 | try: 30 | u = path[(idx - 1) % len(path)].unit_tangent(1) 31 | v = path[idx].unit_tangent(0) 32 | u_dot_v = u.real*v.real + u.imag*v.imag 33 | flag = False 34 | except ValueError: 35 | flag = True 36 | 37 | if flag or abs(u_dot_v - 1) > tol: 38 | kink_list.append(idx) 39 | return kink_list 40 | 41 | 42 | def _report_unfixable_kinks(_path, _kink_list): 43 | mes = ("\n%s kinks have been detected at that cannot be smoothed.\n" 44 | "To ignore these kinks and fix all others, run this function " 45 | "again with the second argument 'ignore_unfixable_kinks=True' " 46 | "The locations of the unfixable kinks are at the beginnings of " 47 | "segments: %s" % (len(_kink_list), _kink_list)) 48 | disvg(_path, nodes=[_path[idx].start for idx in _kink_list]) 49 | raise Exception(mes) 50 | 51 | 52 | def smoothed_joint(seg0, seg1, maxjointsize=3, tightness=1.99): 53 | """ See Andy's notes on 54 | Smoothing Bezier Paths for an explanation of the method. 55 | Input: two segments seg0, seg1 such that seg0.end==seg1.start, and 56 | jointsize, a positive number 57 | 58 | Output: seg0_trimmed, elbow, seg1_trimmed, where elbow is a cubic bezier 59 | object that smoothly connects seg0_trimmed and seg1_trimmed. 60 | 61 | """ 62 | assert seg0.end == seg1.start 63 | assert 0 < maxjointsize 64 | assert 0 < tightness < 2 65 | # sgn = lambda x:x/abs(x) 66 | q = seg0.end 67 | 68 | try: v = seg0.unit_tangent(1) 69 | except: v = seg0.unit_tangent(1 - 1e-4) 70 | try: w = seg1.unit_tangent(0) 71 | except: w = seg1.unit_tangent(1e-4) 72 | 73 | max_a = maxjointsize / 2 74 | a = min(max_a, min(seg1.length(), seg0.length()) / 20) 75 | if isinstance(seg0, Line) and isinstance(seg1, Line): 76 | ''' 77 | Note: Letting 78 | c(t) = elbow.point(t), v= the unit tangent of seg0 at 1, w = the 79 | unit tangent vector of seg1 at 0, 80 | Q = seg0.point(1) = seg1.point(0), and a,b>0 some constants. 81 | The elbow will be the unique CubicBezier, c, such that 82 | c(0)= Q-av, c(1)=Q+aw, c'(0) = bv, and c'(1) = bw 83 | where a and b are derived above/below from tightness and 84 | maxjointsize. 85 | ''' 86 | # det = v.imag*w.real-v.real*w.imag 87 | # Note: 88 | # If det is negative, the curvature of elbow is negative for all 89 | # real t if and only if b/a > 6 90 | # If det is positive, the curvature of elbow is negative for all 91 | # real t if and only if b/a < 2 92 | 93 | # if det < 0: 94 | # b = (6+tightness)*a 95 | # elif det > 0: 96 | # b = (2-tightness)*a 97 | # else: 98 | # raise Exception("seg0 and seg1 are parallel lines.") 99 | b = (2 - tightness)*a 100 | elbow = CubicBezier(q - a*v, q - (a - b/3)*v, q + (a - b/3)*w, q + a*w) 101 | seg0_trimmed = Line(seg0.start, elbow.start) 102 | seg1_trimmed = Line(elbow.end, seg1.end) 103 | return seg0_trimmed, [elbow], seg1_trimmed 104 | elif isinstance(seg0, Line): 105 | ''' 106 | Note: Letting 107 | c(t) = elbow.point(t), v= the unit tangent of seg0 at 1, 108 | w = the unit tangent vector of seg1 at 0, 109 | Q = seg0.point(1) = seg1.point(0), and a,b>0 some constants. 110 | The elbow will be the unique CubicBezier, c, such that 111 | c(0)= Q-av, c(1)=Q, c'(0) = bv, and c'(1) = bw 112 | where a and b are derived above/below from tightness and 113 | maxjointsize. 114 | ''' 115 | # det = v.imag*w.real-v.real*w.imag 116 | # Note: If g has the same sign as det, then the curvature of elbow is 117 | # negative for all real t if and only if b/a < 4 118 | b = (4 - tightness)*a 119 | # g = sgn(det)*b 120 | elbow = CubicBezier(q - a*v, q + (b/3 - a)*v, q - b/3*w, q) 121 | seg0_trimmed = Line(seg0.start, elbow.start) 122 | return seg0_trimmed, [elbow], seg1 123 | elif isinstance(seg1, Line): 124 | args = (seg1.reversed(), seg0.reversed(), maxjointsize, tightness) 125 | rseg1_trimmed, relbow, rseg0 = smoothed_joint(*args) 126 | elbow = relbow[0].reversed() 127 | return seg0, [elbow], rseg1_trimmed.reversed() 128 | else: 129 | # find a point on each seg that is about a/2 away from joint. Make 130 | # line between them. 131 | t0 = seg0.ilength(seg0.length() - a/2) 132 | t1 = seg1.ilength(a/2) 133 | seg0_trimmed = seg0.cropped(0, t0) 134 | seg1_trimmed = seg1.cropped(t1, 1) 135 | seg0_line = Line(seg0_trimmed.end, q) 136 | seg1_line = Line(q, seg1_trimmed.start) 137 | 138 | args = (seg0_trimmed, seg0_line, maxjointsize, tightness) 139 | dummy, elbow0, seg0_line_trimmed = smoothed_joint(*args) 140 | 141 | args = (seg1_line, seg1_trimmed, maxjointsize, tightness) 142 | seg1_line_trimmed, elbow1, dummy = smoothed_joint(*args) 143 | 144 | args = (seg0_line_trimmed, seg1_line_trimmed, maxjointsize, tightness) 145 | seg0_line_trimmed, elbowq, seg1_line_trimmed = smoothed_joint(*args) 146 | 147 | elbow = elbow0 + [seg0_line_trimmed] + elbowq + [seg1_line_trimmed] + elbow1 148 | return seg0_trimmed, elbow, seg1_trimmed 149 | 150 | 151 | def smoothed_path(path, maxjointsize=3, tightness=1.99, ignore_unfixable_kinks=False): 152 | """returns a path with no non-differentiable joints.""" 153 | if len(path) == 1: 154 | return path 155 | 156 | assert path.iscontinuous() 157 | 158 | sharp_kinks = [] 159 | new_path = [path[0]] 160 | for idx in range(len(path)): 161 | if idx == len(path)-1: 162 | if not path.isclosed(): 163 | continue 164 | else: 165 | seg1 = new_path[0] 166 | else: 167 | seg1 = path[idx + 1] 168 | seg0 = new_path[-1] 169 | 170 | try: 171 | unit_tangent0 = seg0.unit_tangent(1) 172 | unit_tangent1 = seg1.unit_tangent(0) 173 | flag = False 174 | except ValueError: 175 | flag = True # unit tangent not well-defined 176 | 177 | if not flag and isclose(unit_tangent0, unit_tangent1): # joint is already smooth 178 | if idx != len(path)-1: 179 | new_path.append(seg1) 180 | continue 181 | else: 182 | kink_idx = (idx + 1) % len(path) # kink at start of this seg 183 | if not flag and isclose(-unit_tangent0, unit_tangent1): 184 | # joint is sharp 180 deg (must be fixed manually) 185 | new_path.append(seg1) 186 | sharp_kinks.append(kink_idx) 187 | else: # joint is not smooth, let's smooth it. 188 | args = (seg0, seg1, maxjointsize, tightness) 189 | new_seg0, elbow_segs, new_seg1 = smoothed_joint(*args) 190 | new_path[-1] = new_seg0 191 | new_path += elbow_segs 192 | if idx == len(path) - 1: 193 | new_path[0] = new_seg1 194 | else: 195 | new_path.append(new_seg1) 196 | 197 | # If unfixable kinks were found, let the user know 198 | if sharp_kinks and not ignore_unfixable_kinks: 199 | _report_unfixable_kinks(path, sharp_kinks) 200 | 201 | return Path(*new_path) 202 | -------------------------------------------------------------------------------- /svgpathtools/svg_io_sax.py: -------------------------------------------------------------------------------- 1 | """(Experimental) replacement for import/export functionality SAX 2 | 3 | """ 4 | 5 | # External dependencies 6 | from __future__ import division, absolute_import, print_function 7 | import os 8 | from xml.etree.ElementTree import iterparse, Element, ElementTree, SubElement 9 | import numpy as np 10 | 11 | # Internal dependencies 12 | from .parser import parse_path 13 | from .parser import parse_transform 14 | from .svg_to_paths import (path2pathd, ellipse2pathd, line2pathd, 15 | polyline2pathd, polygon2pathd, rect2pathd) 16 | from .misctools import open_in_browser 17 | from .path import transform 18 | 19 | # To maintain forward/backward compatibility 20 | try: 21 | string = basestring 22 | except NameError: 23 | string = str 24 | 25 | NAME_SVG = "svg" 26 | ATTR_VERSION = "version" 27 | VALUE_SVG_VERSION = "1.1" 28 | ATTR_XMLNS = "xmlns" 29 | VALUE_XMLNS = "http://www.w3.org/2000/svg" 30 | ATTR_XMLNS_LINK = "xmlns:xlink" 31 | VALUE_XLINK = "http://www.w3.org/1999/xlink" 32 | ATTR_XMLNS_EV = "xmlns:ev" 33 | VALUE_XMLNS_EV = "http://www.w3.org/2001/xml-events" 34 | ATTR_WIDTH = "width" 35 | ATTR_HEIGHT = "height" 36 | ATTR_VIEWBOX = "viewBox" 37 | NAME_PATH = "path" 38 | ATTR_DATA = "d" 39 | ATTR_FILL = "fill" 40 | ATTR_STROKE = "stroke" 41 | ATTR_STROKE_WIDTH = "stroke-width" 42 | ATTR_TRANSFORM = "transform" 43 | VALUE_NONE = "none" 44 | 45 | 46 | class SaxDocument: 47 | def __init__(self, filename): 48 | """A container for a SAX SVG light tree objects document. 49 | 50 | This class provides functions for extracting SVG data into Path objects. 51 | 52 | Args: 53 | filename (str): The filename of the SVG file 54 | """ 55 | self.root_values = {} 56 | self.tree = [] 57 | # remember location of original svg file 58 | if filename is not None and os.path.dirname(filename) == '': 59 | self.original_filename = os.path.join(os.getcwd(), filename) 60 | else: 61 | self.original_filename = filename 62 | 63 | if filename is not None: 64 | self.sax_parse(filename) 65 | 66 | def sax_parse(self, filename): 67 | self.root_values = {} 68 | self.tree = [] 69 | stack = [] 70 | values = {} 71 | matrix = None 72 | for event, elem in iterparse(filename, events=('start', 'end')): 73 | if event == 'start': 74 | stack.append((values, matrix)) 75 | if matrix is not None: 76 | matrix = matrix.copy() # copy of matrix 77 | current_values = values 78 | values = {} 79 | values.update(current_values) # copy of dictionary 80 | attrs = elem.attrib 81 | values.update(attrs) 82 | name = elem.tag[28:] 83 | if "style" in attrs: 84 | for equate in attrs["style"].split(";"): 85 | equal_item = equate.split(":") 86 | values[equal_item[0]] = equal_item[1] 87 | if "transform" in attrs: 88 | transform_matrix = parse_transform(attrs["transform"]) 89 | if matrix is None: 90 | matrix = np.identity(3) 91 | matrix = transform_matrix.dot(matrix) 92 | if "svg" == name: 93 | current_values = values 94 | values = {} 95 | values.update(current_values) 96 | self.root_values = current_values 97 | continue 98 | elif "g" == name: 99 | continue 100 | elif 'path' == name: 101 | values['d'] = path2pathd(values) 102 | elif 'circle' == name: 103 | values["d"] = ellipse2pathd(values) 104 | elif 'ellipse' == name: 105 | values["d"] = ellipse2pathd(values) 106 | elif 'line' == name: 107 | values["d"] = line2pathd(values) 108 | elif 'polyline' == name: 109 | values["d"] = polyline2pathd(values) 110 | elif 'polygon' == name: 111 | values["d"] = polygon2pathd(values) 112 | elif 'rect' == name: 113 | values["d"] = rect2pathd(values) 114 | else: 115 | continue 116 | values["matrix"] = matrix 117 | values["name"] = name 118 | self.tree.append(values) 119 | else: 120 | v = stack.pop() 121 | values = v[0] 122 | matrix = v[1] 123 | 124 | def flatten_all_paths(self): 125 | flat = [] 126 | for values in self.tree: 127 | pathd = values['d'] 128 | matrix = values['matrix'] 129 | parsed_path = parse_path(pathd) 130 | if matrix is not None: 131 | transform(parsed_path, matrix) 132 | flat.append(parsed_path) 133 | return flat 134 | 135 | def get_pathd_and_matrix(self): 136 | flat = [] 137 | for values in self.tree: 138 | pathd = values['d'] 139 | matrix = values['matrix'] 140 | flat.append((pathd, matrix)) 141 | return flat 142 | 143 | def generate_dom(self): 144 | root = Element(NAME_SVG) 145 | root.set(ATTR_VERSION, VALUE_SVG_VERSION) 146 | root.set(ATTR_XMLNS, VALUE_XMLNS) 147 | root.set(ATTR_XMLNS_LINK, VALUE_XLINK) 148 | root.set(ATTR_XMLNS_EV, VALUE_XMLNS_EV) 149 | width = self.root_values.get(ATTR_WIDTH, None) 150 | height = self.root_values.get(ATTR_HEIGHT, None) 151 | if width is not None: 152 | root.set(ATTR_WIDTH, width) 153 | if height is not None: 154 | root.set(ATTR_HEIGHT, height) 155 | viewbox = self.root_values.get(ATTR_VIEWBOX, None) 156 | if viewbox is not None: 157 | root.set(ATTR_VIEWBOX, viewbox) 158 | identity = np.identity(3) 159 | for values in self.tree: 160 | pathd = values.get('d', '') 161 | matrix = values.get('matrix', None) 162 | # path_value = parse_path(pathd) 163 | 164 | path = SubElement(root, NAME_PATH) 165 | if matrix is not None and not np.all(np.equal(matrix, identity)): 166 | matrix_string = "matrix(" 167 | matrix_string += " " 168 | matrix_string += string(matrix[0][0]) 169 | matrix_string += " " 170 | matrix_string += string(matrix[1][0]) 171 | matrix_string += " " 172 | matrix_string += string(matrix[0][1]) 173 | matrix_string += " " 174 | matrix_string += string(matrix[1][1]) 175 | matrix_string += " " 176 | matrix_string += string(matrix[0][2]) 177 | matrix_string += " " 178 | matrix_string += string(matrix[1][2]) 179 | matrix_string += ")" 180 | path.set(ATTR_TRANSFORM, matrix_string) 181 | if ATTR_DATA in values: 182 | path.set(ATTR_DATA, values[ATTR_DATA]) 183 | if ATTR_FILL in values: 184 | path.set(ATTR_FILL, values[ATTR_FILL]) 185 | if ATTR_STROKE in values: 186 | path.set(ATTR_STROKE, values[ATTR_STROKE]) 187 | return ElementTree(root) 188 | 189 | def save(self, filename): 190 | with open(filename, 'wb') as output_svg: 191 | dom_tree = self.generate_dom() 192 | dom_tree.write(output_svg) 193 | 194 | def display(self, filename=None): 195 | """Displays/opens the doc using the OS's default application.""" 196 | if filename is None: 197 | filename = 'display_temp.svg' 198 | self.save(filename) 199 | open_in_browser(filename) 200 | -------------------------------------------------------------------------------- /svgpathtools/svg_to_paths.py: -------------------------------------------------------------------------------- 1 | """This submodule contains tools for creating path objects from SVG files. 2 | The main tool being the svg2paths() function.""" 3 | 4 | # External dependencies 5 | from __future__ import division, absolute_import, print_function 6 | from xml.dom.minidom import parse 7 | import os 8 | from io import StringIO 9 | import re 10 | try: 11 | from os import PathLike as FilePathLike 12 | except ImportError: 13 | FilePathLike = str 14 | 15 | # Internal dependencies 16 | from .parser import parse_path 17 | 18 | 19 | COORD_PAIR_TMPLT = re.compile( 20 | r'([\+-]?\d*[\.\d]\d*[eE][\+-]?\d+|[\+-]?\d*[\.\d]\d*)' + 21 | r'(?:\s*,\s*|\s+|(?=-))' + 22 | r'([\+-]?\d*[\.\d]\d*[eE][\+-]?\d+|[\+-]?\d*[\.\d]\d*)' 23 | ) 24 | 25 | 26 | def path2pathd(path): 27 | return path.get('d', '') 28 | 29 | 30 | def ellipse2pathd(ellipse, use_cubics=False): 31 | """converts the parameters from an ellipse or a circle to a string for a 32 | Path object d-attribute""" 33 | 34 | cx = ellipse.get('cx', 0) 35 | cy = ellipse.get('cy', 0) 36 | rx = ellipse.get('rx', None) 37 | ry = ellipse.get('ry', None) 38 | r = ellipse.get('r', None) 39 | 40 | if r is not None: 41 | rx = ry = float(r) 42 | else: 43 | rx = float(rx) 44 | ry = float(ry) 45 | 46 | cx = float(cx) 47 | cy = float(cy) 48 | 49 | if use_cubics: 50 | # Modified by NXP 2024, 2025 51 | PATH_KAPPA = 0.552284 52 | rxKappa = rx * PATH_KAPPA; 53 | ryKappa = ry * PATH_KAPPA; 54 | 55 | #According to the SVG specification (https://lists.w3.org/Archives/Public/www-archive/2005May/att-0005/SVGT12_Main.pdf), 56 | #Section 9.4, "The 'ellipse' element": "The arc of an 'ellipse' element begins at the "3 o'clock" point on 57 | #the radius and progresses towards the "9 o'clock". Therefore, the ellipse begins at the rightmost point 58 | #and progresses clockwise. 59 | d = '' 60 | # Move to the rightmost point 61 | d += 'M' + str(cx + rx) + ' ' + str(cy) 62 | # Draw bottom-right quadrant 63 | d += 'C' + str(cx + rx) + ' ' + str(cy + ryKappa) + ' ' + str(cx + rxKappa) + ' ' + str(cy + ry) + ' ' + str(cx) + ' ' + str(cy + ry) 64 | # Draw bottom-left quadrant 65 | d += 'C' + str(cx - rxKappa) + ' ' + str(cy + ry) + ' ' + str(cx - rx) + ' ' + str(cy + ryKappa) + ' ' + str(cx - rx) + ' ' + str(cy) 66 | # Draw top-left quadrant 67 | d += 'C' + str(cx - rx) + ' ' + str(cy - ryKappa) + ' ' + str(cx - rxKappa) + ' ' + str(cy - ry) + ' ' + str(cx) + ' ' + str(cy - ry) 68 | # Draw top-right quadrant 69 | d += 'C' + str(cx + rxKappa) + ' ' + str(cy - ry) + ' ' + str(cx + rx) + ' ' + str(cy - ryKappa) + ' ' + str(cx + rx) + ' ' + str(cy) 70 | else: 71 | d = '' 72 | d += 'M' + str(cx - rx) + ',' + str(cy) 73 | d += 'a' + str(rx) + ',' + str(ry) + ' 0 1,0 ' + str(2 * rx) + ',0' 74 | d += 'a' + str(rx) + ',' + str(ry) + ' 0 1,0 ' + str(-2 * rx) + ',0' 75 | 76 | return d + 'z' 77 | 78 | 79 | def polyline2pathd(polyline, is_polygon=False): 80 | """converts the string from a polyline points-attribute to a string for a 81 | Path object d-attribute""" 82 | if isinstance(polyline, str): 83 | points = polyline 84 | else: 85 | points = COORD_PAIR_TMPLT.findall(polyline.get('points', '')) 86 | 87 | if len(points) == 0: 88 | return '' 89 | 90 | closed = (float(points[0][0]) == float(points[-1][0]) and 91 | float(points[0][1]) == float(points[-1][1])) 92 | 93 | # The `parse_path` call ignores redundant 'z' (closure) commands 94 | # e.g. `parse_path('M0 0L100 100Z') == parse_path('M0 0L100 100L0 0Z')` 95 | # This check ensures that an n-point polygon is converted to an n-Line path. 96 | if is_polygon and closed: 97 | points.append(points[0]) 98 | 99 | d = 'M' + 'L'.join('{0} {1}'.format(x,y) for x,y in points) 100 | if is_polygon or closed: 101 | d += 'z' 102 | return d 103 | 104 | 105 | def polygon2pathd(polyline, is_polygon=True): 106 | """converts the string from a polygon points-attribute to a string 107 | for a Path object d-attribute. 108 | Note: For a polygon made from n points, the resulting path will be 109 | composed of n lines (even if some of these lines have length zero). 110 | """ 111 | return polyline2pathd(polyline, is_polygon) 112 | 113 | 114 | def rect2pathd(rect): 115 | """Converts an SVG-rect element to a Path d-string. 116 | 117 | The rectangle will start at the (x,y) coordinate specified by the 118 | rectangle object and proceed counter-clockwise.""" 119 | x, y = float(rect.get('x', 0)), float(rect.get('y', 0)) 120 | w, h = float(rect.get('width', 0)), float(rect.get('height', 0)) 121 | 122 | if 'rx' in rect.keys() or 'ry' in rect.keys(): 123 | 124 | # if only one, rx or ry, is present, use that value for both 125 | # https://developer.mozilla.org/en-US/docs/Web/SVG/Element/rect 126 | rx = rect.get('rx', None) 127 | ry = rect.get('ry', None) 128 | if rx is None: 129 | rx = ry or 0. 130 | if ry is None: 131 | ry = rx or 0. 132 | rx, ry = float(rx), float(ry) 133 | 134 | d = "M {} {} ".format(x + rx, y) # right of p0 135 | d += "L {} {} ".format(x + w - rx, y) # go to p1 136 | d += "A {} {} 0 0 1 {} {} ".format(rx, ry, x+w, y+ry) # arc for p1 137 | d += "L {} {} ".format(x+w, y+h-ry) # above p2 138 | d += "A {} {} 0 0 1 {} {} ".format(rx, ry, x+w-rx, y+h) # arc for p2 139 | d += "L {} {} ".format(x+rx, y+h) # right of p3 140 | d += "A {} {} 0 0 1 {} {} ".format(rx, ry, x, y+h-ry) # arc for p3 141 | d += "L {} {} ".format(x, y+ry) # below p0 142 | d += "A {} {} 0 0 1 {} {} z".format(rx, ry, x+rx, y) # arc for p0 143 | return d 144 | 145 | x0, y0 = x, y 146 | x1, y1 = x + w, y 147 | x2, y2 = x + w, y + h 148 | x3, y3 = x, y + h 149 | 150 | d = ("M{} {} L {} {} L {} {} L {} {} z" 151 | "".format(x0, y0, x1, y1, x2, y2, x3, y3)) 152 | 153 | return d 154 | 155 | 156 | def line2pathd(l): 157 | return ( 158 | 'M' + l.attrib.get('x1', '0') + ' ' + l.attrib.get('y1', '0') 159 | + 'L' + l.attrib.get('x2', '0') + ' ' + l.attrib.get('y2', '0') 160 | ) 161 | 162 | 163 | def svg2paths(svg_file_location, 164 | return_svg_attributes=False, 165 | convert_circles_to_paths=True, 166 | convert_ellipses_to_paths=True, 167 | convert_lines_to_paths=True, 168 | convert_polylines_to_paths=True, 169 | convert_polygons_to_paths=True, 170 | convert_rectangles_to_paths=True): 171 | """Converts an SVG into a list of Path objects and attribute dictionaries. 172 | 173 | Converts an SVG file into a list of Path objects and a list of 174 | dictionaries containing their attributes. This currently supports 175 | SVG Path, Line, Polyline, Polygon, Circle, and Ellipse elements. 176 | 177 | Args: 178 | svg_file_location (string or file-like object): the location of the 179 | svg file on disk or a file-like object containing the content of a 180 | svg file 181 | return_svg_attributes (bool): Set to True and a dictionary of 182 | svg-attributes will be extracted and returned. See also the 183 | `svg2paths2()` function. 184 | convert_circles_to_paths: Set to False to exclude SVG-Circle 185 | elements (converted to Paths). By default circles are included as 186 | paths of two `Arc` objects. 187 | convert_ellipses_to_paths (bool): Set to False to exclude SVG-Ellipse 188 | elements (converted to Paths). By default ellipses are included as 189 | paths of two `Arc` objects. 190 | convert_lines_to_paths (bool): Set to False to exclude SVG-Line elements 191 | (converted to Paths) 192 | convert_polylines_to_paths (bool): Set to False to exclude SVG-Polyline 193 | elements (converted to Paths) 194 | convert_polygons_to_paths (bool): Set to False to exclude SVG-Polygon 195 | elements (converted to Paths) 196 | convert_rectangles_to_paths (bool): Set to False to exclude SVG-Rect 197 | elements (converted to Paths). 198 | 199 | Returns: 200 | list: The list of Path objects. 201 | list: The list of corresponding path attribute dictionaries. 202 | dict (optional): A dictionary of svg-attributes (see `svg2paths2()`). 203 | """ 204 | # strings are interpreted as file location everything else is treated as 205 | # file-like object and passed to the xml parser directly 206 | from_filepath = isinstance(svg_file_location, str) or isinstance(svg_file_location, FilePathLike) 207 | svg_file_location = os.path.abspath(svg_file_location) if from_filepath else svg_file_location 208 | 209 | doc = parse(svg_file_location) 210 | 211 | def dom2dict(element): 212 | """Converts DOM elements to dictionaries of attributes.""" 213 | keys = list(element.attributes.keys()) 214 | values = [val.value for val in list(element.attributes.values())] 215 | return dict(list(zip(keys, values))) 216 | 217 | # Use minidom to extract path strings from input SVG 218 | paths = [dom2dict(el) for el in doc.getElementsByTagName('path')] 219 | d_strings = [el['d'] for el in paths] 220 | attribute_dictionary_list = paths 221 | 222 | # Use minidom to extract polyline strings from input SVG, convert to 223 | # path strings, add to list 224 | if convert_polylines_to_paths: 225 | plins = [dom2dict(el) for el in doc.getElementsByTagName('polyline')] 226 | d_strings += [polyline2pathd(pl) for pl in plins] 227 | attribute_dictionary_list += plins 228 | 229 | # Use minidom to extract polygon strings from input SVG, convert to 230 | # path strings, add to list 231 | if convert_polygons_to_paths: 232 | pgons = [dom2dict(el) for el in doc.getElementsByTagName('polygon')] 233 | d_strings += [polygon2pathd(pg, True) for pg in pgons] 234 | attribute_dictionary_list += pgons 235 | 236 | if convert_lines_to_paths: 237 | lines = [dom2dict(el) for el in doc.getElementsByTagName('line')] 238 | d_strings += [('M' + l['x1'] + ' ' + l['y1'] + 239 | 'L' + l['x2'] + ' ' + l['y2']) for l in lines] 240 | attribute_dictionary_list += lines 241 | 242 | if convert_ellipses_to_paths: 243 | ellipses = [dom2dict(el) for el in doc.getElementsByTagName('ellipse')] 244 | d_strings += [ellipse2pathd(e) for e in ellipses] 245 | attribute_dictionary_list += ellipses 246 | 247 | if convert_circles_to_paths: 248 | circles = [dom2dict(el) for el in doc.getElementsByTagName('circle')] 249 | d_strings += [ellipse2pathd(c) for c in circles] 250 | attribute_dictionary_list += circles 251 | 252 | if convert_rectangles_to_paths: 253 | rectangles = [dom2dict(el) for el in doc.getElementsByTagName('rect')] 254 | d_strings += [rect2pathd(r) for r in rectangles] 255 | attribute_dictionary_list += rectangles 256 | 257 | if return_svg_attributes: 258 | svg_attributes = dom2dict(doc.getElementsByTagName('svg')[0]) 259 | doc.unlink() 260 | path_list = [parse_path(d) for d in d_strings] 261 | return path_list, attribute_dictionary_list, svg_attributes 262 | else: 263 | doc.unlink() 264 | path_list = [parse_path(d) for d in d_strings] 265 | return path_list, attribute_dictionary_list 266 | 267 | 268 | def svg2paths2(svg_file_location, 269 | return_svg_attributes=True, 270 | convert_circles_to_paths=True, 271 | convert_ellipses_to_paths=True, 272 | convert_lines_to_paths=True, 273 | convert_polylines_to_paths=True, 274 | convert_polygons_to_paths=True, 275 | convert_rectangles_to_paths=True): 276 | """Convenience function; identical to svg2paths() except that 277 | return_svg_attributes=True by default. See svg2paths() docstring for more 278 | info.""" 279 | return svg2paths(svg_file_location=svg_file_location, 280 | return_svg_attributes=return_svg_attributes, 281 | convert_circles_to_paths=convert_circles_to_paths, 282 | convert_ellipses_to_paths=convert_ellipses_to_paths, 283 | convert_lines_to_paths=convert_lines_to_paths, 284 | convert_polylines_to_paths=convert_polylines_to_paths, 285 | convert_polygons_to_paths=convert_polygons_to_paths, 286 | convert_rectangles_to_paths=convert_rectangles_to_paths) 287 | 288 | 289 | def svgstr2paths(svg_string, 290 | return_svg_attributes=False, 291 | convert_circles_to_paths=True, 292 | convert_ellipses_to_paths=True, 293 | convert_lines_to_paths=True, 294 | convert_polylines_to_paths=True, 295 | convert_polygons_to_paths=True, 296 | convert_rectangles_to_paths=True): 297 | """Convenience function; identical to svg2paths() except that it takes the 298 | svg object as string. See svg2paths() docstring for more 299 | info.""" 300 | # wrap string into StringIO object 301 | svg_file_obj = StringIO(svg_string) 302 | return svg2paths(svg_file_location=svg_file_obj, 303 | return_svg_attributes=return_svg_attributes, 304 | convert_circles_to_paths=convert_circles_to_paths, 305 | convert_ellipses_to_paths=convert_ellipses_to_paths, 306 | convert_lines_to_paths=convert_lines_to_paths, 307 | convert_polylines_to_paths=convert_polylines_to_paths, 308 | convert_polygons_to_paths=convert_polygons_to_paths, 309 | convert_rectangles_to_paths=convert_rectangles_to_paths) 310 | -------------------------------------------------------------------------------- /test.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 11 | 12 | 16 | 25 | 26 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathandy/svgpathtools/9ec59bb946544b14806af57f785ad84ed8d05119/test/__init__.py -------------------------------------------------------------------------------- /test/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /test/display_temp.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/ellipse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /test/groups.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 14 | 15 | 21 | 22 | 25 | 26 | 32 | 33 | 36 | 37 | 43 | 44 | 45 | 46 | 48 | 49 | 55 | 56 | 57 | 58 | 61 | 62 | 68 | 69 | 70 | 71 | 75 | 76 | 82 | 83 | 84 | 85 | 86 | 87 | 90 | 91 | 97 | 98 | 99 | 100 | 103 | 104 | 110 | 111 | 112 | 113 | 116 | 117 | 123 | 124 | 125 | 126 | 129 | 130 | 136 | 137 | 138 | 139 | 142 | 143 | 149 | 150 | 157 | 158 | 159 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /test/negative-scale.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /test/polygons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/polygons_no_points.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/polyline.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/rects.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/test.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 38 | 40 | 41 | 43 | image/svg+xml 44 | 46 | 47 | 48 | 49 | 50 | 54 | 60 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /test/test_bezier.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import, print_function 2 | import numpy as np 3 | import unittest 4 | from svgpathtools.bezier import bezier_point, bezier2polynomial, polynomial2bezier, bezier_bounding_box, bezier_real_minmax 5 | from svgpathtools.path import bpoints2bezier, CubicBezier 6 | 7 | 8 | seed = 2718 9 | np.random.seed(seed) 10 | 11 | 12 | class HigherOrderBezier: 13 | def __init__(self, bpoints): 14 | self.bpts = bpoints 15 | 16 | def bpoints(self): 17 | return self.bpts 18 | 19 | def point(self, t): 20 | return bezier_point(self.bpoints(), t) 21 | 22 | def __repr__(self): 23 | return str(self.bpts) 24 | 25 | 26 | def random_polynomial(degree): 27 | return np.poly1d(np.random.rand(degree + 1)) 28 | 29 | 30 | def random_bezier(degree): 31 | if degree <= 3: 32 | return bpoints2bezier(polynomial2bezier(np.random.rand(degree + 1))) 33 | else: 34 | return HigherOrderBezier(np.random.rand(degree + 1)) 35 | 36 | 37 | class TestBezier2Polynomial(unittest.TestCase): 38 | def test_bezier2polynomial(self): 39 | tvals = np.linspace(0, 1, 10) 40 | for d in range(1, 10): 41 | b = random_bezier(d) 42 | p = np.poly1d(bezier2polynomial(b.bpoints())) 43 | for t in tvals: 44 | msg = ("degree {}\nt = {}\nb(t) = {}\n = {}\np(t) = \n{}\n = {}" 45 | "".format(d, t, b, b.point(t), p, p(t))) 46 | self.assertAlmostEqual(b.point(t), p(t), msg=msg) 47 | 48 | 49 | class TestPolynomial2Bezier(unittest.TestCase): 50 | def test_polynomial2bezier(self): 51 | tvals = np.linspace(0, 1, 10) 52 | for d in range(1, 3): 53 | p = random_polynomial(d) 54 | b = HigherOrderBezier(polynomial2bezier(p)) 55 | for t in tvals: 56 | msg = ("degree {}\nt = {}\nb(t) = {}\n = {}\np(t) = \n{}\n = {}" 57 | "".format(d, t, b, b.point(t), p, p(t))) 58 | self.assertAlmostEqual(b.point(t), p(t), msg=msg) 59 | 60 | 61 | class TestBezierBoundingBox(unittest.TestCase): 62 | def test_bezier_bounding_box(self): 63 | # This bezier curve has denominator == 0 but due to floating point arithmetic error it is not exactly 0 64 | zero_denominator_bezier_curve = CubicBezier(612.547 + 109.3261j, 579.967 - 19.4422j, 428.0344 - 19.4422j, 395.4374 + 109.3261j) 65 | zero_denom_xmin, zero_denom_xmax, zero_denom_ymin, zero_denom_ymax = bezier_bounding_box(zero_denominator_bezier_curve) 66 | self.assertAlmostEqual(zero_denom_xmin, 395.437400, 5) 67 | self.assertAlmostEqual(zero_denom_xmax, 612.547, 5) 68 | self.assertAlmostEqual(zero_denom_ymin, 12.7498749, 5) 69 | self.assertAlmostEqual(zero_denom_ymax, 109.3261, 5) 70 | 71 | # This bezier curve has global extrema at the start and end points 72 | start_end_bbox_bezier_curve = CubicBezier(886.8238 + 354.8439j, 884.4765 + 340.5983j, 877.6258 + 330.0518j, 868.2909 + 323.2453j) 73 | start_end_xmin, start_end_xmax, start_end_ymin, start_end_ymax = bezier_bounding_box(start_end_bbox_bezier_curve) 74 | self.assertAlmostEqual(start_end_xmin, 868.2909, 5) 75 | self.assertAlmostEqual(start_end_xmax, 886.8238, 5) 76 | self.assertAlmostEqual(start_end_ymin, 323.2453, 5) 77 | self.assertAlmostEqual(start_end_ymax, 354.8439, 5) 78 | 79 | # This bezier curve is to cover some random case where at least one of the global extrema is not the start or end point 80 | general_bezier_curve = CubicBezier(295.2282 + 402.0233j, 310.3734 + 355.5329j, 343.547 + 340.5983j, 390.122 + 355.7018j) 81 | general_xmin, general_xmax, general_ymin, general_ymax = bezier_bounding_box(general_bezier_curve) 82 | self.assertAlmostEqual(general_xmin, 295.2282, 5) 83 | self.assertAlmostEqual(general_xmax, 390.121999999, 5) 84 | self.assertAlmostEqual(general_ymin, 350.030030142, 5) 85 | self.assertAlmostEqual(general_ymax, 402.0233, 5) 86 | 87 | 88 | class TestBezierRealMinMax(unittest.TestCase): 89 | def test_bezier_real_minmax(self): 90 | # This bezier curve has denominator == 0 but due to floating point arithmetic error it is not exactly 0 91 | zero_denominator_bezier_curve = [109.3261, -19.4422, -19.4422, 109.3261] 92 | zero_denominator_minmax = bezier_real_minmax(zero_denominator_bezier_curve) 93 | self.assertAlmostEqual(zero_denominator_minmax[0], 12.7498749, 5) 94 | self.assertAlmostEqual(zero_denominator_minmax[1], 109.3261, 5) 95 | 96 | # This bezier curve has global extrema at the start and end points 97 | start_end_bbox_bezier_curve = [354.8439, 340.5983, 330.0518, 323.2453] 98 | start_end_bbox_minmax = bezier_real_minmax(start_end_bbox_bezier_curve) 99 | self.assertAlmostEqual(start_end_bbox_minmax[0], 323.2453, 5) 100 | self.assertAlmostEqual(start_end_bbox_minmax[1], 354.8439, 5) 101 | 102 | # This bezier curve is to cover some random case where at least one of the global extrema is not the start or end point 103 | general_bezier_curve = [402.0233, 355.5329, 340.5983, 355.7018] 104 | general_minmax = bezier_real_minmax(general_bezier_curve) 105 | self.assertAlmostEqual(general_minmax[0], 350.030030142, 5) 106 | self.assertAlmostEqual(general_minmax[1], 402.0233, 5) 107 | 108 | 109 | if __name__ == '__main__': 110 | unittest.main() 111 | -------------------------------------------------------------------------------- /test/test_document.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import, print_function 2 | import unittest 3 | from svgpathtools import Document 4 | from io import StringIO 5 | from io import open # overrides build-in open for compatibility with python2 6 | from os.path import join, dirname 7 | from sys import version_info 8 | 9 | 10 | class TestDocument(unittest.TestCase): 11 | def test_from_file_path_string(self): 12 | """Test reading svg from file provided as path""" 13 | doc = Document(join(dirname(__file__), 'polygons.svg')) 14 | 15 | self.assertEqual(len(doc.paths()), 2) 16 | 17 | def test_from_file_path(self): 18 | """Test reading svg from file provided as path""" 19 | if version_info >= (3, 6): 20 | import pathlib 21 | doc = Document(pathlib.Path(__file__).parent / 'polygons.svg') 22 | 23 | self.assertEqual(len(doc.paths()), 2) 24 | 25 | def test_from_file_object(self): 26 | """Test reading svg from file object that has already been opened""" 27 | with open(join(dirname(__file__), 'polygons.svg'), 'r') as file: 28 | doc = Document(file) 29 | 30 | self.assertEqual(len(doc.paths()), 2) 31 | 32 | def test_from_stringio(self): 33 | """Test reading svg object contained in a StringIO object""" 34 | with open(join(dirname(__file__), 'polygons.svg'), 35 | 'r', encoding='utf-8') as file: 36 | # read entire file into string 37 | file_content = file.read() 38 | # prepare stringio object 39 | file_as_stringio = StringIO(file_content) 40 | 41 | doc = Document(file_as_stringio) 42 | 43 | self.assertEqual(len(doc.paths()), 2) 44 | 45 | def test_from_string(self): 46 | """Test reading svg object contained in a string""" 47 | with open(join(dirname(__file__), 'polygons.svg'), 48 | 'r', encoding='utf-8') as file: 49 | # read entire file into string 50 | file_content = file.read() 51 | 52 | doc = Document.from_svg_string(file_content) 53 | 54 | self.assertEqual(len(doc.paths()), 2) 55 | -------------------------------------------------------------------------------- /test/test_generation.py: -------------------------------------------------------------------------------- 1 | # Note: This file was taken mostly as is from the svg.path module (v 2.0) 2 | #------------------------------------------------------------------------------ 3 | from __future__ import division, absolute_import, print_function 4 | import unittest 5 | import re 6 | from typing import Optional 7 | 8 | import numpy as np 9 | 10 | from svgpathtools import parse_path 11 | 12 | 13 | _space_or_comma_pattern = re.compile(r'[,\s]+') 14 | 15 | 16 | def _assert_d_strings_are_almost_equal(d1: str, d2: str, test_case=unittest.TestCase, msg: Optional[str] = None) -> bool: 17 | """Slight differences are expected on different platforms, check each part is approx. as expected.""" 18 | 19 | parts1 = _space_or_comma_pattern.split(d1) 20 | parts2 = _space_or_comma_pattern.split(d2) 21 | test_case.assertEqual(len(parts1), len(parts2), msg=msg) 22 | for p1, p2 in zip(parts1, parts2): 23 | if p1.isalpha(): 24 | test_case.assertEqual(p1, p2, msg=msg) 25 | else: 26 | test_case.assertTrue(np.isclose(float(p1), float(p2)), msg=msg) 27 | 28 | 29 | 30 | class TestGeneration(unittest.TestCase): 31 | 32 | def test_path_parsing(self): 33 | """Examples from the SVG spec""" 34 | paths = [ 35 | 'M 100,100 L 300,100 L 200,300 Z', 36 | 'M 0,0 L 50,20 M 100,100 L 300,100 L 200,300 Z', 37 | 'M 100,100 L 200,200', 38 | 'M 100,200 L 200,100 L -100,-200', 39 | 'M 100,200 C 100,100 250,100 250,200 S 400,300 400,200', 40 | 'M 100,200 C 100,100 400,100 400,200', 41 | 'M 100,500 C 25,400 475,400 400,500', 42 | 'M 100,800 C 175,700 325,700 400,800', 43 | 'M 600,200 C 675,100 975,100 900,200', 44 | 'M 600,500 C 600,350 900,650 900,500', 45 | 'M 600,800 C 625,700 725,700 750,800 S 875,900 900,800', 46 | 'M 200,300 Q 400,50 600,300 T 1000,300', 47 | 'M -3.4E+38,3.4E+38 L -3.4E-38,3.4E-38', 48 | 'M 0,0 L 50,20 M 50,20 L 200,100 Z', 49 | 'M 600,350 L 650,325 A 25,25 -30 0,1 700,300 L 750,275', 50 | ] 51 | float_paths = [ 52 | 'M 100.0,100.0 L 300.0,100.0 L 200.0,300.0 L 100.0,100.0', 53 | 'M 0.0,0.0 L 50.0,20.0 M 100.0,100.0 L 300.0,100.0 L 200.0,300.0 L 100.0,100.0', 54 | 'M 100.0,100.0 L 200.0,200.0', 55 | 'M 100.0,200.0 L 200.0,100.0 L -100.0,-200.0', 56 | 'M 100.0,200.0 C 100.0,100.0 250.0,100.0 250.0,200.0 C 250.0,300.0 400.0,300.0 400.0,200.0', 57 | 'M 100.0,200.0 C 100.0,100.0 400.0,100.0 400.0,200.0', 58 | 'M 100.0,500.0 C 25.0,400.0 475.0,400.0 400.0,500.0', 59 | 'M 100.0,800.0 C 175.0,700.0 325.0,700.0 400.0,800.0', 60 | 'M 600.0,200.0 C 675.0,100.0 975.0,100.0 900.0,200.0', 61 | 'M 600.0,500.0 C 600.0,350.0 900.0,650.0 900.0,500.0', 62 | 'M 600.0,800.0 C 625.0,700.0 725.0,700.0 750.0,800.0 C 775.0,900.0 875.0,900.0 900.0,800.0', 63 | 'M 200.0,300.0 Q 400.0,50.0 600.0,300.0 Q 800.0,550.0 1000.0,300.0', 64 | 'M -3.4e+38,3.4e+38 L -3.4e-38,3.4e-38', 65 | 'M 0.0,0.0 L 50.0,20.0 L 200.0,100.0 L 50.0,20.0', 66 | 'M 600.0,350.0 L 650.0,325.0 A 27.9508497187,27.9508497187 -30.0 0,1 700.0,300.0 L 750.0,275.0' 67 | ] 68 | 69 | for path, expected in zip(paths, float_paths): 70 | parsed_path = parse_path(path) 71 | res = parsed_path.d() 72 | msg = ('\npath =\n {}\nexpected =\n {}\nparse_path(path).d() =\n {}' 73 | ''.format(path, expected, res)) 74 | _assert_d_strings_are_almost_equal(res, expected, self, msg) 75 | 76 | 77 | def test_normalizing(self): 78 | # Relative paths will be made absolute, subpaths merged if they can, 79 | # and syntax will change. 80 | path = 'M0 0L3.4E2-10L100.0,100M100,100l100,-100' 81 | ps = 'M 0,0 L 340,-10 L 100,100 L 200,0' 82 | psf = 'M 0.0,0.0 L 340.0,-10.0 L 100.0,100.0 L 200.0,0.0' 83 | self.assertTrue(parse_path(path).d() in (ps, psf)) 84 | 85 | def test_floating_point_stability(self): 86 | # Check that reading and then outputting a d-string 87 | # does not introduce floating point error noise. 88 | path = "M 70.63,10.42 C 0.11,0.33 -0.89,2.09 -1.54,2.45 C -4.95,2.73 -17.52,7.24 -39.46,11.04" 89 | self.assertEqual(parse_path(path).d(), path) 90 | 91 | 92 | if __name__ == '__main__': 93 | unittest.main() 94 | -------------------------------------------------------------------------------- /test/test_groups.py: -------------------------------------------------------------------------------- 1 | """Tests related to SVG groups. 2 | 3 | To run these tests, you can use (from root svgpathtools directory): 4 | $ python -m unittest test.test_groups.TestGroups.test_group_flatten 5 | """ 6 | from __future__ import division, absolute_import, print_function 7 | import unittest 8 | from svgpathtools import Document, SVG_NAMESPACE, parse_path, Line, Arc 9 | from os.path import join, dirname 10 | import numpy as np 11 | 12 | 13 | # When an assert fails, show the full error message, don't truncate it. 14 | unittest.util._MAX_LENGTH = 999999999 15 | 16 | 17 | def get_desired_path(name, paths): 18 | return next(p for p in paths 19 | if p.element.get('{some://testuri}name') == name) 20 | 21 | 22 | class TestGroups(unittest.TestCase): 23 | 24 | def check_values(self, v, z): 25 | # Check that the components of 2D vector v match the components 26 | # of complex number z 27 | self.assertAlmostEqual(v[0], z.real) 28 | self.assertAlmostEqual(v[1], z.imag) 29 | 30 | def check_line(self, tf, v_s_vals, v_e_relative_vals, name, paths): 31 | # Check that the endpoints of the line have been correctly transformed. 32 | # * tf is the transform that should have been applied. 33 | # * v_s_vals is a 2D list of the values of the line's start point 34 | # * v_e_relative_vals is a 2D list of the values of the line's 35 | # end point relative to the start point 36 | # * name is the path name (value of the test:name attribute in 37 | # the SVG document) 38 | # * paths is the output of doc.paths() 39 | v_s_vals.append(1.0) 40 | v_e_relative_vals.append(0.0) 41 | v_s = np.array(v_s_vals) 42 | v_e = v_s + v_e_relative_vals 43 | 44 | actual = get_desired_path(name, paths) 45 | 46 | self.check_values(tf.dot(v_s), actual.start) 47 | self.check_values(tf.dot(v_e), actual.end) 48 | 49 | def test_nonrounded_rect(self): 50 | # Check that (nonrounded) rect is parsed properly 51 | 52 | x, y = 10, 10 53 | w, h = 100, 100 54 | 55 | doc = Document.from_svg_string( 56 | "\n".join( 57 | [ 58 | '', 60 | f' ', 61 | "", 62 | ] 63 | ) 64 | ) 65 | 66 | line_count, arc_count = 0, 0 67 | 68 | for p in doc.paths(): 69 | for s in p: 70 | if isinstance(s, Line): 71 | line_count += 1 72 | if isinstance(s, Arc): 73 | arc_count += 1 74 | 75 | self.assertEqual(line_count, 4) 76 | self.assertEqual(arc_count, 0) 77 | 78 | def test_rounded_rect(self): 79 | # Check that rounded rect is parsed properly 80 | 81 | x, y = 10, 10 82 | rx, ry = 15, 12 83 | w, h = 100, 100 84 | 85 | doc = Document.from_svg_string( 86 | "\n".join( 87 | [ 88 | '', 90 | f' ', 91 | "", 92 | ] 93 | ) 94 | ) 95 | 96 | line_count, arc_count = 0, 0 97 | 98 | for p in doc.paths(): 99 | for s in p: 100 | if isinstance(s, Line): 101 | line_count += 1 102 | if isinstance(s, Arc): 103 | arc_count += 1 104 | 105 | self.assertEqual(line_count, 4) 106 | self.assertEqual(arc_count, 4) 107 | 108 | def test_group_transform(self): 109 | # The input svg has a group transform of "scale(1,-1)", which 110 | # can mess with Arc sweeps. 111 | doc = Document(join(dirname(__file__), 'negative-scale.svg')) 112 | path = doc.paths()[0] 113 | self.assertEqual(path[0], Line(start=-10j, end=-80j)) 114 | self.assertEqual(path[1], Arc(start=-80j, radius=(30+30j), rotation=0.0, large_arc=True, sweep=True, end=-140j)) 115 | self.assertEqual(path[2], Arc(start=-140j, radius=(20+20j), rotation=0.0, large_arc=False, sweep=False, end=-100j)) 116 | self.assertEqual(path[3], Line(start=-100j, end=(100-100j))) 117 | self.assertEqual(path[4], Arc(start=(100-100j), radius=(20+20j), rotation=0.0, large_arc=True, sweep=False, end=(100-140j))) 118 | self.assertEqual(path[5], Arc(start=(100-140j), radius=(30+30j), rotation=0.0, large_arc=False, sweep=True, end=(100-80j))) 119 | self.assertEqual(path[6], Line(start=(100-80j), end=(100-10j))) 120 | self.assertEqual(path[7], Arc(start=(100-10j), radius=(10+10j), rotation=0.0, large_arc=False, sweep=True, end=(90+0j))) 121 | self.assertEqual(path[8], Line(start=(90+0j), end=(10+0j))) 122 | self.assertEqual(path[9], Arc(start=(10+0j), radius=(10+10j), rotation=0.0, large_arc=False, sweep=True, end=-10j)) 123 | 124 | def test_ellipse_transform(self): 125 | # Check that ellipse to path conversion respects rotation transforms 126 | 127 | cx, cy = 40, 80 128 | rx, ry = 15, 20 129 | 130 | def dist_to_ellipse(angle, pt): 131 | rot = np.exp(-1j * np.radians(angle)) 132 | transformed_pt = rot * complex(pt.real - cx, pt.imag - cy) 133 | return transformed_pt.real**2 / rx**2 + transformed_pt.imag**2 / ry**2 - 1 134 | 135 | for angle in np.linspace(-179, 180, num=123): 136 | svgstring = "\n".join( 137 | [ 138 | '', 140 | f' ', 141 | "", 142 | ] 143 | ) 144 | 145 | doc = Document.from_svg_string(svgstring) 146 | 147 | for p in doc.paths(): 148 | subtended_angle = 0.0 149 | for s in p: 150 | if isinstance(s, Arc): 151 | # check that several points lie on the original ellipse 152 | for t in [0.0, 1 / 3.0, 0.5, 2 / 3.0, 1.0]: 153 | dist = dist_to_ellipse(angle, s.point(t)) 154 | self.assertAlmostEqual(dist, 0) 155 | 156 | # and that the subtended angles sum to 2*pi 157 | subtended_angle = subtended_angle + s.delta 158 | self.assertAlmostEqual(np.abs(subtended_angle), 360) 159 | 160 | def test_group_flatten(self): 161 | # Test the Document.paths() function against the 162 | # groups.svg test file. 163 | # There are 12 paths in that file, with various levels of being 164 | # nested inside of group transforms. 165 | # The check_line function is used to reduce the boilerplate, 166 | # since all the tests are very similar. 167 | # This test covers each of the different types of transforms 168 | # that are specified by the SVG standard. 169 | doc = Document(join(dirname(__file__), 'groups.svg')) 170 | 171 | result = doc.paths() 172 | self.assertEqual(12, len(result)) 173 | 174 | tf_matrix_group = np.array([[1.5, 0.0, -40.0], 175 | [0.0, 0.5, 20.0], 176 | [0.0, 0.0, 1.0]]) 177 | 178 | self.check_line(tf_matrix_group, 179 | [183, 183], [0.0, -50], 180 | 'path00', result) 181 | 182 | tf_scale_group = np.array([[1.25, 0.0, 0.0], 183 | [0.0, 1.25, 0.0], 184 | [0.0, 0.0, 1.0]]) 185 | 186 | self.check_line(tf_matrix_group.dot(tf_scale_group), 187 | [122, 320], [-50.0, 0.0], 188 | 'path01', result) 189 | 190 | self.check_line(tf_matrix_group.dot(tf_scale_group), 191 | [150, 200], [-50, 25], 192 | 'path02', result) 193 | 194 | self.check_line(tf_matrix_group.dot(tf_scale_group), 195 | [150, 200], [-50, 25], 196 | 'path03', result) 197 | 198 | tf_nested_translate_group = np.array([[1, 0, 20], 199 | [0, 1, 0], 200 | [0, 0, 1]]) 201 | 202 | self.check_line(tf_matrix_group.dot(tf_scale_group 203 | ).dot(tf_nested_translate_group), 204 | [150, 200], [-50, 25], 205 | 'path04', result) 206 | 207 | tf_nested_translate_xy_group = np.array([[1, 0, 20], 208 | [0, 1, 30], 209 | [0, 0, 1]]) 210 | 211 | self.check_line(tf_matrix_group.dot(tf_scale_group 212 | ).dot(tf_nested_translate_xy_group), 213 | [150, 200], [-50, 25], 214 | 'path05', result) 215 | 216 | tf_scale_xy_group = np.array([[0.5, 0, 0], 217 | [0, 1.5, 0.0], 218 | [0, 0, 1]]) 219 | 220 | self.check_line(tf_matrix_group.dot(tf_scale_xy_group), 221 | [122, 320], [-50, 0], 222 | 'path06', result) 223 | 224 | a_07 = 20.0*np.pi/180.0 225 | tf_rotate_group = np.array([[np.cos(a_07), -np.sin(a_07), 0], 226 | [np.sin(a_07), np.cos(a_07), 0], 227 | [0, 0, 1]]) 228 | 229 | self.check_line(tf_matrix_group.dot(tf_rotate_group), 230 | [183, 183], [0, 30], 231 | 'path07', result) 232 | 233 | a_08 = 45.0*np.pi/180.0 234 | tf_rotate_xy_group_R = np.array([[np.cos(a_08), -np.sin(a_08), 0], 235 | [np.sin(a_08), np.cos(a_08), 0], 236 | [0, 0, 1]]) 237 | tf_rotate_xy_group_T = np.array([[1, 0, 183], 238 | [0, 1, 183], 239 | [0, 0, 1]]) 240 | tf_rotate_xy_group = tf_rotate_xy_group_T.dot( 241 | tf_rotate_xy_group_R).dot( 242 | np.linalg.inv(tf_rotate_xy_group_T)) 243 | 244 | self.check_line(tf_matrix_group.dot(tf_rotate_xy_group), 245 | [183, 183], [0, 30], 246 | 'path08', result) 247 | 248 | a_09 = 5.0*np.pi/180.0 249 | tf_skew_x_group = np.array([[1, np.tan(a_09), 0], 250 | [0, 1, 0], 251 | [0, 0, 1]]) 252 | 253 | self.check_line(tf_matrix_group.dot(tf_skew_x_group), 254 | [183, 183], [40, 40], 255 | 'path09', result) 256 | 257 | a_10 = 5.0*np.pi/180.0 258 | tf_skew_y_group = np.array([[1, 0, 0], 259 | [np.tan(a_10), 1, 0], 260 | [0, 0, 1]]) 261 | 262 | self.check_line(tf_matrix_group.dot(tf_skew_y_group), 263 | [183, 183], [40, 40], 264 | 'path10', result) 265 | 266 | # This last test is for handling transforms that are defined as 267 | # attributes of a element. 268 | a_11 = -40*np.pi/180.0 269 | tf_path11_R = np.array([[np.cos(a_11), -np.sin(a_11), 0], 270 | [np.sin(a_11), np.cos(a_11), 0], 271 | [0, 0, 1]]) 272 | tf_path11_T = np.array([[1, 0, 100], 273 | [0, 1, 100], 274 | [0, 0, 1]]) 275 | tf_path11 = tf_path11_T.dot(tf_path11_R).dot(np.linalg.inv(tf_path11_T)) 276 | 277 | self.check_line(tf_matrix_group.dot(tf_skew_y_group).dot(tf_path11), 278 | [180, 20], [-70, 80], 279 | 'path11', result) 280 | 281 | def check_group_count(self, doc, expected_count): 282 | count = 0 283 | for _ in doc.tree.getroot().iter('{{{0}}}g'.format(SVG_NAMESPACE['svg'])): 284 | count += 1 285 | 286 | self.assertEqual(expected_count, count) 287 | 288 | def test_nested_group(self): 289 | # A bug in the flattened_paths_from_group() implementation made it so that only top-level 290 | # groups could have their paths flattened. This is a regression test to make 291 | # sure that when a nested group is requested, its paths can also be flattened. 292 | doc = Document(join(dirname(__file__), 'groups.svg')) 293 | result = doc.paths_from_group(['matrix group', 'scale group']) 294 | self.assertEqual(len(result), 5) 295 | 296 | def test_add_group(self): 297 | # Test `Document.add_group()` function and related Document functions. 298 | doc = Document(None) 299 | self.check_group_count(doc, 0) 300 | 301 | base_group = doc.add_group() 302 | base_group.set('id', 'base_group') 303 | self.assertTrue(doc.contains_group(base_group)) 304 | self.check_group_count(doc, 1) 305 | 306 | child_group = doc.add_group(parent=base_group) 307 | child_group.set('id', 'child_group') 308 | self.assertTrue(doc.contains_group(child_group)) 309 | self.check_group_count(doc, 2) 310 | 311 | grandchild_group = doc.add_group(parent=child_group) 312 | grandchild_group.set('id', 'grandchild_group') 313 | self.assertTrue(doc.contains_group(grandchild_group)) 314 | self.check_group_count(doc, 3) 315 | 316 | sibling_group = doc.add_group(parent=base_group) 317 | sibling_group.set('id', 'sibling_group') 318 | self.assertTrue(doc.contains_group(sibling_group)) 319 | self.check_group_count(doc, 4) 320 | 321 | # Test that we can retrieve each new group from the document 322 | self.assertEqual(base_group, doc.get_or_add_group(['base_group'])) 323 | self.assertEqual(child_group, doc.get_or_add_group( 324 | ['base_group', 'child_group'])) 325 | self.assertEqual(grandchild_group, doc.get_or_add_group( 326 | ['base_group', 'child_group', 'grandchild_group'])) 327 | self.assertEqual(sibling_group, doc.get_or_add_group( 328 | ['base_group', 'sibling_group'])) 329 | 330 | # Create a new nested group 331 | new_child = doc.get_or_add_group( 332 | ['base_group', 'new_parent', 'new_child']) 333 | self.check_group_count(doc, 6) 334 | self.assertEqual(new_child, doc.get_or_add_group( 335 | ['base_group', 'new_parent', 'new_child'])) 336 | 337 | new_leaf = doc.get_or_add_group( 338 | ['base_group', 'new_parent', 'new_child', 'new_leaf']) 339 | self.assertEqual(new_leaf, doc.get_or_add_group([ 340 | 'base_group', 'new_parent', 'new_child', 'new_leaf'])) 341 | self.check_group_count(doc, 7) 342 | 343 | path_d = ('M 206.07112,858.41289 L 206.07112,-2.02031 ' 344 | 'C -50.738,-81.14814 -20.36402,-105.87055 52.52793,-101.01525 ' 345 | 'L 103.03556,0.0 ' 346 | 'L 0.0,111.11678') 347 | 348 | svg_path = doc.add_path(path_d, group=new_leaf) 349 | self.assertEqual(path_d, svg_path.get('d')) 350 | 351 | path = parse_path(path_d) 352 | svg_path = doc.add_path(path, group=new_leaf) 353 | self.assertEqual(path_d, svg_path.get('d')) 354 | 355 | # Test that paths are added to the correct group 356 | new_sibling = doc.get_or_add_group( 357 | ['base_group', 'new_parent', 'new_sibling']) 358 | doc.add_path(path, group=new_sibling) 359 | self.assertEqual(len(new_sibling), 1) 360 | self.assertEqual(path_d, new_sibling[0].get('d')) 361 | -------------------------------------------------------------------------------- /test/test_parsing.py: -------------------------------------------------------------------------------- 1 | # Note: This file was taken mostly as is from the svg.path module (v 2.0) 2 | from __future__ import division, absolute_import, print_function 3 | import unittest 4 | from svgpathtools import Path, Line, QuadraticBezier, CubicBezier, Arc, parse_path 5 | import svgpathtools 6 | 7 | import numpy as np 8 | 9 | 10 | def construct_rotation_tf(a, x, y): 11 | a = a * np.pi / 180.0 12 | tf_offset = np.identity(3) 13 | tf_offset[0:2, 2:3] = np.array([[x], [y]]) 14 | tf_rotate = np.identity(3) 15 | tf_rotate[0:2, 0:2] = np.array([[np.cos(a), -np.sin(a)], 16 | [np.sin(a), np.cos(a)]]) 17 | tf_offset_neg = np.identity(3) 18 | tf_offset_neg[0:2, 2:3] = np.array([[-x], [-y]]) 19 | 20 | return tf_offset.dot(tf_rotate).dot(tf_offset_neg) 21 | 22 | 23 | class TestParser(unittest.TestCase): 24 | 25 | def test_svg_examples(self): 26 | """Examples from the SVG spec""" 27 | path1 = parse_path('M 100 100 L 300 100 L 200 300 z') 28 | self.assertEqual(path1, Path(Line(100 + 100j, 300 + 100j), 29 | Line(300 + 100j, 200 + 300j), 30 | Line(200 + 300j, 100 + 100j))) 31 | self.assertTrue(path1.isclosed()) 32 | 33 | # for Z command behavior when there is multiple subpaths 34 | path1 = parse_path('M 0 0 L 50 20 M 100 100 L 300 100 L 200 300 z') 35 | self.assertEqual(path1, Path(Line(0 + 0j, 50 + 20j), 36 | Line(100 + 100j, 300 + 100j), 37 | Line(300 + 100j, 200 + 300j), 38 | Line(200 + 300j, 100 + 100j))) 39 | 40 | path1 = parse_path('M 100 100 L 200 200') 41 | path2 = parse_path('M100 100L200 200') 42 | self.assertEqual(path1, path2) 43 | 44 | path1 = parse_path('M 100 200 L 200 100 L -100 -200') 45 | path2 = parse_path('M 100 200 L 200 100 -100 -200') 46 | self.assertEqual(path1, path2) 47 | 48 | path1 = parse_path("""M100,200 C100,100 250,100 250,200 49 | S400,300 400,200""") 50 | self.assertEqual(path1, Path(CubicBezier(100 + 200j, 51 | 100 + 100j, 52 | 250 + 100j, 53 | 250 + 200j), 54 | CubicBezier(250 + 200j, 55 | 250 + 300j, 56 | 400 + 300j, 57 | 400 + 200j))) 58 | 59 | path1 = parse_path('M100,200 C100,100 400,100 400,200') 60 | self.assertEqual(path1, Path(CubicBezier(100 + 200j, 61 | 100 + 100j, 62 | 400 + 100j, 63 | 400 + 200j))) 64 | 65 | path1 = parse_path('M100,500 C25,400 475,400 400,500') 66 | self.assertEqual(path1, Path(CubicBezier(100 + 500j, 67 | 25 + 400j, 68 | 475 + 400j, 69 | 400 + 500j))) 70 | 71 | path1 = parse_path('M100,800 C175,700 325,700 400,800') 72 | self.assertEqual(path1, Path(CubicBezier(100 + 800j, 73 | 175 + 700j, 74 | 325 + 700j, 75 | 400 + 800j))) 76 | 77 | path1 = parse_path('M600,200 C675,100 975,100 900,200') 78 | self.assertEqual(path1, Path(CubicBezier(600 + 200j, 79 | 675 + 100j, 80 | 975 + 100j, 81 | 900 + 200j))) 82 | 83 | path1 = parse_path('M600,500 C600,350 900,650 900,500') 84 | self.assertEqual(path1, Path(CubicBezier(600 + 500j, 85 | 600 + 350j, 86 | 900 + 650j, 87 | 900 + 500j))) 88 | 89 | path1 = parse_path("""M600,800 C625,700 725,700 750,800 90 | S875,900 900,800""") 91 | self.assertEqual(path1, Path(CubicBezier(600 + 800j, 92 | 625 + 700j, 93 | 725 + 700j, 94 | 750 + 800j), 95 | CubicBezier(750 + 800j, 96 | 775 + 900j, 97 | 875 + 900j, 98 | 900 + 800j))) 99 | 100 | path1 = parse_path('M200,300 Q400,50 600,300 T1000,300') 101 | self.assertEqual(path1, Path(QuadraticBezier(200 + 300j, 102 | 400 + 50j, 103 | 600 + 300j), 104 | QuadraticBezier(600 + 300j, 105 | 800 + 550j, 106 | 1000 + 300j))) 107 | 108 | path1 = parse_path('M300,200 h-150 a150,150 0 1,0 150,-150 z') 109 | self.assertEqual(path1, Path(Line(300 + 200j, 150 + 200j), 110 | Arc(150 + 200j, 150 + 150j, 0, 1, 0, 300 + 50j), 111 | Line(300 + 50j, 300 + 200j))) 112 | 113 | path1 = parse_path('M275,175 v-150 a150,150 0 0,0 -150,150 z') 114 | self.assertEqual(path1, 115 | Path(Line(275 + 175j, 275 + 25j), 116 | Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j), 117 | Line(125 + 175j, 275 + 175j))) 118 | 119 | path1 = parse_path("""M600,350 l 50,-25 120 | a25,25 -30 0,1 50,-25 l 50,-25 121 | a25,50 -30 0,1 50,-25 l 50,-25 122 | a25,75 -30 0,1 50,-25 l 50,-25 123 | a25,100 -30 0,1 50,-25 l 50,-25""") 124 | self.assertEqual(path1, 125 | Path(Line(600 + 350j, 650 + 325j), 126 | Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j), 127 | Line(700 + 300j, 750 + 275j), 128 | Arc(750 + 275j, 25 + 50j, -30, 0, 1, 800 + 250j), 129 | Line(800 + 250j, 850 + 225j), 130 | Arc(850 + 225j, 25 + 75j, -30, 0, 1, 900 + 200j), 131 | Line(900 + 200j, 950 + 175j), 132 | Arc(950 + 175j, 25 + 100j, -30, 0, 1, 1000 + 150j), 133 | Line(1000 + 150j, 1050 + 125j))) 134 | 135 | def test_others(self): 136 | # Other paths that need testing: 137 | 138 | # Relative moveto: 139 | path1 = parse_path('M 0 0 L 50 20 m 50 80 L 300 100 L 200 300 z') 140 | self.assertEqual(path1, Path(Line(0 + 0j, 50 + 20j), 141 | Line(100 + 100j, 300 + 100j), 142 | Line(300 + 100j, 200 + 300j), 143 | Line(200 + 300j, 100 + 100j))) 144 | 145 | # Initial smooth and relative CubicBezier 146 | path1 = parse_path("""M100,200 s 150,-100 150,0""") 147 | self.assertEqual(path1, 148 | Path(CubicBezier(100 + 200j, 149 | 100 + 200j, 150 | 250 + 100j, 151 | 250 + 200j))) 152 | 153 | # Initial smooth and relative QuadraticBezier 154 | path1 = parse_path("""M100,200 t 150,0""") 155 | self.assertEqual(path1, 156 | Path(QuadraticBezier(100 + 200j, 157 | 100 + 200j, 158 | 250 + 200j))) 159 | 160 | # Relative QuadraticBezier 161 | path1 = parse_path("""M100,200 q 0,0 150,0""") 162 | self.assertEqual(path1, 163 | Path(QuadraticBezier(100 + 200j, 164 | 100 + 200j, 165 | 250 + 200j))) 166 | 167 | def test_negative(self): 168 | """You don't need spaces before a minus-sign""" 169 | path1 = parse_path('M100,200c10-5,20-10,30-20') 170 | path2 = parse_path('M 100 200 c 10 -5 20 -10 30 -20') 171 | self.assertEqual(path1, path2) 172 | 173 | def test_numbers(self): 174 | """Exponents and other number format cases""" 175 | # It can be e or E, the plus is optional, and a minimum of 176 | # +/-3.4e38 must be supported. 177 | path1 = parse_path('M-3.4e38 3.4E+38L-3.4E-38,3.4e-38') 178 | path2 = Path(Line(-3.4e+38 + 3.4e+38j, -3.4e-38 + 3.4e-38j)) 179 | self.assertEqual(path1, path2) 180 | 181 | def test_errors(self): 182 | self.assertRaises(ValueError, parse_path, 183 | 'M 100 100 L 200 200 Z 100 200') 184 | 185 | 186 | def test_transform(self): 187 | 188 | tf_matrix = svgpathtools.parser.parse_transform( 189 | 'matrix(1.0 2.0 3.0 4.0 5.0 6.0)') 190 | expected_tf_matrix = np.identity(3) 191 | expected_tf_matrix[0:2, 0:3] = np.array([[1.0, 3.0, 5.0], 192 | [2.0, 4.0, 6.0]]) 193 | self.assertTrue(np.array_equal(expected_tf_matrix, tf_matrix)) 194 | 195 | # Try a test with no y specified 196 | expected_tf_translate = np.identity(3) 197 | expected_tf_translate[0, 2] = -36 198 | self.assertTrue(np.array_equal( 199 | expected_tf_translate, 200 | svgpathtools.parser.parse_transform('translate(-36)') 201 | )) 202 | 203 | # Now specify y 204 | expected_tf_translate[1, 2] = 45.5 205 | tf_translate = svgpathtools.parser.parse_transform( 206 | 'translate(-36 45.5)') 207 | self.assertTrue(np.array_equal(expected_tf_translate, tf_translate)) 208 | 209 | # Try a test with no y specified 210 | expected_tf_scale = np.identity(3) 211 | expected_tf_scale[0, 0] = 10 212 | expected_tf_scale[1, 1] = 10 213 | self.assertTrue(np.array_equal( 214 | expected_tf_scale, 215 | svgpathtools.parser.parse_transform('scale(10)') 216 | )) 217 | 218 | # Now specify y 219 | expected_tf_scale[1, 1] = 0.5 220 | tf_scale = svgpathtools.parser.parse_transform('scale(10 0.5)') 221 | self.assertTrue(np.array_equal(expected_tf_scale, tf_scale)) 222 | 223 | tf_rotation = svgpathtools.parser.parse_transform('rotate(-10 50 100)') 224 | expected_tf_rotation = construct_rotation_tf(-10, 50, 100) 225 | self.assertTrue(np.array_equal(expected_tf_rotation, tf_rotation)) 226 | 227 | # Try a test with no offset specified 228 | self.assertTrue(np.array_equal( 229 | construct_rotation_tf(50, 0, 0), 230 | svgpathtools.parser.parse_transform('rotate(50)') 231 | )) 232 | 233 | expected_tf_skewx = np.identity(3) 234 | expected_tf_skewx[0, 1] = np.tan(40.0 * np.pi/180.0) 235 | tf_skewx = svgpathtools.parser.parse_transform('skewX(40)') 236 | self.assertTrue(np.array_equal(expected_tf_skewx, tf_skewx)) 237 | 238 | expected_tf_skewy = np.identity(3) 239 | expected_tf_skewy[1, 0] = np.tan(30.0 * np.pi / 180.0) 240 | tf_skewy = svgpathtools.parser.parse_transform('skewY(30)') 241 | self.assertTrue(np.array_equal(expected_tf_skewy, tf_skewy)) 242 | 243 | self.assertTrue(np.array_equal( 244 | tf_rotation.dot(tf_translate).dot(tf_skewx).dot(tf_scale), 245 | svgpathtools.parser.parse_transform( 246 | """rotate(-10 50 100) 247 | translate(-36 45.5) 248 | skewX(40) 249 | scale(10 0.5)""") 250 | )) 251 | 252 | def test_pathd_init(self): 253 | path0 = Path('') 254 | path1 = parse_path("M 100 100 L 300 100 L 200 300 z") 255 | path2 = Path("M 100 100 L 300 100 L 200 300 z") 256 | self.assertEqual(path1, path2) 257 | 258 | path1 = parse_path("m 100 100 L 300 100 L 200 300 z", current_pos=50+50j) 259 | path2 = Path("m 100 100 L 300 100 L 200 300 z") 260 | self.assertNotEqual(path1, path2) 261 | 262 | path1 = parse_path("m 100 100 L 300 100 L 200 300 z") 263 | path2 = Path("m 100 100 L 300 100 L 200 300 z", current_pos=50 + 50j) 264 | self.assertNotEqual(path1, path2) 265 | 266 | path1 = parse_path("m 100 100 L 300 100 L 200 300 z", current_pos=50 + 50j) 267 | path2 = Path("m 100 100 L 300 100 L 200 300 z", current_pos=50 + 50j) 268 | self.assertEqual(path1, path2) 269 | 270 | path1 = parse_path("m 100 100 L 300 100 L 200 300 z", 50+50j) 271 | path2 = Path("m 100 100 L 300 100 L 200 300 z") 272 | self.assertNotEqual(path1, path2) 273 | 274 | path1 = parse_path("m 100 100 L 300 100 L 200 300 z") 275 | path2 = Path("m 100 100 L 300 100 L 200 300 z", 50 + 50j) 276 | self.assertNotEqual(path1, path2) 277 | 278 | path1 = parse_path("m 100 100 L 300 100 L 200 300 z", 50 + 50j) 279 | path2 = Path("m 100 100 L 300 100 L 200 300 z", 50 + 50j) 280 | self.assertEqual(path1, path2) 281 | 282 | def test_issue_99(self): 283 | p = Path("M 100 250 S 200 200 200 250 300 300 300 250") 284 | self.assertEqual(p.d(useSandT=True), 'M 100.0,250.0 S 200.0,200.0 200.0,250.0 S 300.0,300.0 300.0,250.0') 285 | self.assertEqual(p.d(), 286 | 'M 100.0,250.0 C 100.0,250.0 200.0,200.0 200.0,250.0 C 200.0,300.0 300.0,300.0 300.0,250.0') 287 | self.assertNotEqual(p.d(), 288 | 'M 100.0,250.0 C 100.0,250.0 200.0,200.0 200.0,250.0 C 200.0,250.0 300.0,300.0 300.0,250.0') 289 | -------------------------------------------------------------------------------- /test/test_polytools.py: -------------------------------------------------------------------------------- 1 | # External dependencies 2 | from __future__ import division, absolute_import, print_function 3 | import unittest 4 | import numpy as np 5 | 6 | # Internal dependencies 7 | from svgpathtools import rational_limit 8 | 9 | 10 | class Test_polytools(unittest.TestCase): 11 | # def test_poly_roots(self): 12 | # self.fail() 13 | 14 | def test_rational_limit(self): 15 | 16 | # (3x^3 + x)/(4x^2 - 2x) -> -1/2 as x->0 17 | f = np.poly1d([3, 0, 1, 0]) 18 | g = np.poly1d([4, -2, 0]) 19 | lim = rational_limit(f, g, 0) 20 | self.assertAlmostEqual(lim, -0.5) 21 | 22 | # (3x^2)/(4x^2 - 2x) -> 0 as x->0 23 | f = np.poly1d([3, 0, 0]) 24 | g = np.poly1d([4, -2, 0]) 25 | lim = rational_limit(f, g, 0) 26 | self.assertAlmostEqual(lim, 0) 27 | 28 | 29 | if __name__ == '__main__': 30 | unittest.main() 31 | -------------------------------------------------------------------------------- /test/test_sax_groups.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import, print_function 2 | import unittest 3 | from svgpathtools import SaxDocument 4 | from os.path import join, dirname 5 | 6 | 7 | class TestSaxGroups(unittest.TestCase): 8 | 9 | def check_values(self, v, z): 10 | # Check that the components of 2D vector v match the components 11 | # of complex number z 12 | self.assertAlmostEqual(v[0], z.real) 13 | self.assertAlmostEqual(v[1], z.imag) 14 | 15 | def test_parse_display(self): 16 | doc = SaxDocument(join(dirname(__file__), 'transforms.svg')) 17 | # doc.display() 18 | for i, node in enumerate(doc.tree): 19 | values = node 20 | path_value = values['d'] 21 | matrix = values['matrix'] 22 | self.assertTrue(values is not None) 23 | self.assertTrue(path_value is not None) 24 | if i == 0: 25 | self.assertEqual(values['fill'], 'red') 26 | if i == 8 or i == 7: 27 | self.assertEqual(matrix, None) 28 | if i == 9: 29 | self.assertEqual(values['fill'], 'lime') 30 | -------------------------------------------------------------------------------- /test/test_svg2paths.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import, print_function 2 | import unittest 3 | from svgpathtools import Path, Line, Arc, svg2paths, svgstr2paths 4 | from io import StringIO 5 | from io import open # overrides build-in open for compatibility with python2 6 | import os 7 | from os.path import join, dirname 8 | from sys import version_info 9 | import tempfile 10 | import shutil 11 | 12 | from svgpathtools.svg_to_paths import rect2pathd 13 | 14 | 15 | class TestSVG2Paths(unittest.TestCase): 16 | def test_svg2paths_polygons(self): 17 | 18 | paths, _ = svg2paths(join(dirname(__file__), 'polygons.svg')) 19 | 20 | # triangular polygon test 21 | path = paths[0] 22 | path_correct = Path(Line(55.5+0j, 55.5+50j), 23 | Line(55.5+50j, 105.5+50j), 24 | Line(105.5+50j, 55.5+0j) 25 | ) 26 | self.assertTrue(path.isclosed()) 27 | self.assertTrue(len(path)==3) 28 | self.assertTrue(path==path_correct) 29 | 30 | # triangular quadrilateral (with a redundant 4th "closure" point) 31 | path = paths[1] 32 | path_correct = Path(Line(0+0j, 0-100j), 33 | Line(0-100j, 0.1-100j), 34 | Line(0.1-100j, 0+0j), 35 | Line(0+0j, 0+0j) # result of redundant point 36 | ) 37 | self.assertTrue(path.isclosed()) 38 | self.assertTrue(len(path)==4) 39 | self.assertTrue(path==path_correct) 40 | 41 | def test_svg2paths_ellipses(self): 42 | 43 | paths, _ = svg2paths(join(dirname(__file__), 'ellipse.svg')) 44 | 45 | # ellipse tests 46 | path_ellipse = paths[0] 47 | path_ellipse_correct = Path(Arc(50+100j, 50+50j, 0.0, True, False, 150+100j), 48 | Arc(150+100j, 50+50j, 0.0, True, False, 50+100j)) 49 | self.assertTrue(len(path_ellipse)==2) 50 | self.assertTrue(path_ellipse==path_ellipse_correct) 51 | self.assertTrue(path_ellipse.isclosed()) 52 | 53 | # circle tests 54 | paths, _ = svg2paths(join(dirname(__file__), 'circle.svg')) 55 | 56 | path_circle = paths[0] 57 | path_circle_correct = Path(Arc(50+100j, 50+50j, 0.0, True, False, 150+100j), 58 | Arc(150+100j, 50+50j, 0.0, True, False, 50+100j)) 59 | self.assertTrue(len(path_circle)==2) 60 | self.assertTrue(path_circle==path_circle_correct) 61 | self.assertTrue(path_circle.isclosed()) 62 | 63 | # test for issue #198 (circles not being closed) 64 | svg = u""" 65 | 67 | 68 | 69 | 70 | 71 | 72 | """ 73 | tmpdir = tempfile.mkdtemp() 74 | svgfile = os.path.join(tmpdir, 'test.svg') 75 | with open(svgfile, 'w') as f: 76 | f.write(svg) 77 | paths, _ = svg2paths(svgfile) 78 | self.assertEqual(len(paths), 2) 79 | self.assertTrue(paths[0].isclosed()) 80 | self.assertTrue(paths[1].isclosed()) 81 | shutil.rmtree(tmpdir) 82 | 83 | def test_rect2pathd(self): 84 | non_rounded_dict = {"x": "10", "y": "10", "width": "100", "height": "100"} 85 | self.assertEqual( 86 | rect2pathd(non_rounded_dict), 87 | "M10.0 10.0 L 110.0 10.0 L 110.0 110.0 L 10.0 110.0 z", 88 | ) 89 | 90 | non_rounded_svg = """ 91 | 92 | 93 | """ 94 | 95 | paths, _ = svg2paths(StringIO(non_rounded_svg)) 96 | self.assertEqual(len(paths), 1) 97 | self.assertTrue(paths[0].isclosed()) 98 | self.assertEqual( 99 | paths[0].d(use_closed_attrib=True), 100 | "M 10.0,10.0 L 110.0,10.0 L 110.0,110.0 L 10.0,110.0 Z", 101 | ) 102 | self.assertEqual( 103 | paths[0].d(use_closed_attrib=False), 104 | "M 10.0,10.0 L 110.0,10.0 L 110.0,110.0 L 10.0,110.0 L 10.0,10.0", 105 | ) 106 | 107 | rounded_dict = {"x": "10", "y": "10", "width": "100","height": "100", "rx": "15", "ry": "12"} 108 | self.assertEqual( 109 | rect2pathd(rounded_dict), 110 | "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", 111 | ) 112 | 113 | rounded_svg = """ 114 | 115 | 116 | """ 117 | 118 | paths, _ = svg2paths(StringIO(rounded_svg)) 119 | self.assertEqual(len(paths), 1) 120 | self.assertTrue(paths[0].isclosed()) 121 | self.assertEqual( 122 | paths[0].d(), 123 | "M 25.0,10.0 L 95.0,10.0 A 15.0,12.0 0.0 0,1 110.0,22.0 L 110.0,98.0 A 15.0,12.0 0.0 0,1 95.0,110.0 L 25.0,110.0 A 15.0,12.0 0.0 0,1 10.0,98.0 L 10.0,22.0 A 15.0,12.0 0.0 0,1 25.0,10.0", 124 | ) 125 | 126 | def test_from_file_path_string(self): 127 | """Test reading svg from file provided as path""" 128 | paths, _ = svg2paths(join(dirname(__file__), 'polygons.svg')) 129 | 130 | self.assertEqual(len(paths), 2) 131 | 132 | def test_from_file_path(self): 133 | """Test reading svg from file provided as pathlib POSIXPath""" 134 | if version_info >= (3, 6): 135 | import pathlib 136 | paths, _ = svg2paths(pathlib.Path(__file__).parent / 'polygons.svg') 137 | 138 | self.assertEqual(len(paths), 2) 139 | 140 | def test_from_file_object(self): 141 | """Test reading svg from file object that has already been opened""" 142 | with open(join(dirname(__file__), 'polygons.svg'), 'r') as file: 143 | paths, _ = svg2paths(file) 144 | 145 | self.assertEqual(len(paths), 2) 146 | 147 | def test_from_stringio(self): 148 | """Test reading svg object contained in a StringIO object""" 149 | with open(join(dirname(__file__), 'polygons.svg'), 150 | 'r', encoding='utf-8') as file: 151 | # read entire file into string 152 | file_content = file.read() 153 | # prepare stringio object 154 | file_as_stringio = StringIO(file_content) 155 | 156 | paths, _ = svg2paths(file_as_stringio) 157 | 158 | self.assertEqual(len(paths), 2) 159 | 160 | def test_from_string(self): 161 | """Test reading svg object contained in a string""" 162 | with open(join(dirname(__file__), 'polygons.svg'), 163 | 'r', encoding='utf-8') as file: 164 | # read entire file into string 165 | file_content = file.read() 166 | 167 | paths, _ = svgstr2paths(file_content) 168 | 169 | self.assertEqual(len(paths), 2) 170 | 171 | def test_svg2paths_polygon_no_points(self): 172 | 173 | paths, _ = svg2paths(join(dirname(__file__), 'polygons_no_points.svg')) 174 | 175 | path = paths[0] 176 | path_correct = Path() 177 | self.assertTrue(len(path)==0) 178 | self.assertTrue(path==path_correct) 179 | 180 | path = paths[1] 181 | self.assertTrue(len(path)==0) 182 | self.assertTrue(path==path_correct) 183 | 184 | def test_svg2paths_polyline_tests(self): 185 | 186 | paths, _ = svg2paths(join(dirname(__file__), 'polyline.svg')) 187 | 188 | path = paths[0] 189 | path_correct = Path(Line(59+185j, 98+203j), 190 | Line(98+203j, 108+245j), 191 | Line(108+245j, 82+279j), 192 | Line(82+279j, 39+280j), 193 | Line(39+280j, 11+247j), 194 | Line(11+247j, 19+205j)) 195 | self.assertFalse(path.isclosed()) 196 | self.assertTrue(len(path)==6) 197 | self.assertTrue(path==path_correct) 198 | 199 | path = paths[1] 200 | path_correct = Path(Line(220+50j, 267+84j), 201 | Line(267+84j, 249+140j), 202 | Line(249+140j, 190+140j), 203 | Line(190+140j, 172+84j), 204 | Line(172+84j, 220+50j)) 205 | self.assertTrue(path.isclosed()) 206 | self.assertTrue(len(path)==5) 207 | self.assertTrue(path==path_correct) 208 | 209 | 210 | if __name__ == '__main__': 211 | unittest.main() 212 | -------------------------------------------------------------------------------- /test/transforms.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 12 | 13 | 14 | 15 | 21 | 22 | 23 | 25 | 27 | 29 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 40 | A 41 | B 42 | C 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /vectorframes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | b's tangent 17 | 18 | 19 | br's tangent 20 | 21 | 22 | --------------------------------------------------------------------------------