├── .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 | [](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.¤cy_code=USD)
2 | 
3 | [](https://pypi.org/project/svgpathtools/)
4 | [](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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
8 |
--------------------------------------------------------------------------------
/donate-button.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
24 |
25 |
61 |
62 |
--------------------------------------------------------------------------------
/examples/zero-radius-arcs.svg:
--------------------------------------------------------------------------------
1 |
22 |
23 |
--------------------------------------------------------------------------------
/output1.svg:
--------------------------------------------------------------------------------
1 |
2 |
9 |
--------------------------------------------------------------------------------
/output2.svg:
--------------------------------------------------------------------------------
1 |
2 |
22 |
--------------------------------------------------------------------------------
/output_intersections.svg:
--------------------------------------------------------------------------------
1 |
2 |
13 |
--------------------------------------------------------------------------------
/path.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
26 |
--------------------------------------------------------------------------------
/test/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mathandy/svgpathtools/9ec59bb946544b14806af57f785ad84ed8d05119/test/__init__.py
--------------------------------------------------------------------------------
/test/circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
5 |
--------------------------------------------------------------------------------
/test/display_temp.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/ellipse.svg:
--------------------------------------------------------------------------------
1 |
2 |
5 |
--------------------------------------------------------------------------------
/test/groups.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/test/negative-scale.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/test/polygons.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/test/polygons_no_points.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/test/polyline.svg:
--------------------------------------------------------------------------------
1 |
2 |
8 |
--------------------------------------------------------------------------------
/test/rects.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/test.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 | '",
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 | '",
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 | '",
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 | """
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 | """
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 | """
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 |
--------------------------------------------------------------------------------
/vectorframes.svg:
--------------------------------------------------------------------------------
1 |
2 |
22 |
--------------------------------------------------------------------------------