Compare commits

...

97 Commits

Author SHA1 Message Date
Andrew Port fcb648b9bb add back pypi action new tag condition 2023-05-20 14:38:22 -04:00
Andrew Port ec546a71d4 Merge branch 'PyPI' 2023-05-20 14:37:02 -04:00
Andrew Port bc930005c2 test fixed pypi action 2023-05-20 14:34:32 -04:00
Andrew Port ae9b79e77a revert temporary fix for pushing 1.6.1 to pypi 2023-05-20 14:26:44 -04:00
Andrew Port 289ee6ecb4 temporary fix for pushing 1.6.1 to pypi 2023-05-20 14:25:19 -04:00
Andrew Port 592fe3a525 fix pypi actions 2023-05-20 14:18:42 -04:00
Andrew Port 81870e1f85 update version 2023-05-20 14:13:15 -04:00
Andrew Port 6015a97090
Merge pull request #207 from mathandy/fix-issue-205
return error if Path.point() cannot be computed
2023-05-20 14:11:07 -04:00
Andrew Port 788b2b43a2
Merge pull request #203 from kasbah/fix-escape-sequences
Fix invalid escape sequences in strings
2023-05-20 14:10:19 -04:00
Andrew Port b282094b53 return error if Path.point() cannot be computed 2023-05-20 13:20:05 -04:00
Andrew Port 229773ff9d
Merge pull request #201 from SebKuzminsky/fix-arc-sweep-with-negative-scale
Fix arc sweep with negative scale
2023-05-20 12:50:17 -04:00
Kaspar Emanuel a16a060c27
Fix invalid escape sequences in strings 2023-05-06 19:33:31 +01:00
Sebastian Kuzminsky e94483510e path.transform: Arc sweep is reversed by negative scale
When transforming an Arc, negative scale reverses the sweep.
2023-05-05 00:38:55 -06:00
Sebastian Kuzminsky 6abda09d1c add a test loading arcs with negative scale
This test currently fails, fix in the following commit.

The test loads an svg with a group with a transform with "scale(1,-1)".
This situation can mess up arc sweeps, resulting in corrupted paths.
2023-05-05 00:38:49 -06:00
Andrew Port 5c73056420
Merge pull request #199 from mathandy/issue-198
Issue 198
2023-04-01 15:39:39 -04:00
Andrew Port 4f5d8f3bf2 fix issue-198; circles parsing to non-closed paths 2023-04-01 15:30:04 -04:00
Andrew Port c4d98afc68 add test for issue 198 2023-04-01 15:26:22 -04:00
Andrew Port 9c69e45d6e add python==3.11 to setup.py 2023-02-13 18:15:36 -05:00
Andrew Port dc2f6e90cc remove broken build shield 2023-02-13 18:13:32 -05:00
Andrew Port 96676b7697 increment version number 2023-02-13 17:46:17 -05:00
Andrew Port 2a1cb735e9
Merge pull request #191 from tatarize/fastfail-intersection
Fastfail Intersection
2023-02-03 18:06:56 -08:00
Andrew Port 3eb21161cf add report of intersection count 2023-02-03 21:00:57 -05:00
Andrew Port 31b6f3dd90 Merge branch 'master' into fastfail-intersection 2023-02-03 20:49:49 -05:00
Andrew Port d9515ea399 remove all wildcard imports 2023-02-03 19:46:23 -05:00
Andrew Port 944ccf5e89 create new yaml for legacy system tests 2023-02-03 18:45:50 -05:00
Tatarize b6e5a623ea Add random intersections test 2022-12-04 00:59:40 -08:00
Tatarize 4c6abc5820 Add quick fails to paths 2022-12-04 00:59:15 -08:00
Andrew Port b8dfb6770a correct descriptive action name 2022-07-10 22:38:14 -03:00
Andrew Port 0e17702d04 update version to 1.5.1 2022-07-10 22:19:50 -03:00
Andrew Port 8df19f1c12 fixed issue 171 2022-07-09 19:49:12 -03:00
Andrew Port 9ac7f62515 cleanup long comment 2022-07-09 19:38:28 -03:00
Andrew Port d8a6e5e509 delete unused line 2022-07-09 19:30:40 -03:00
Andrew Port 73c887a8a3 rename as name already used by outside function 2022-07-09 19:28:42 -03:00
Andrew Port f7e074339d update version to 1.5.0 (now that string/file-like objects are handled) 2022-06-05 22:27:22 -07:00
Andrew Port d9f5a2a781 Merge branch 'master' into PyPI 2022-06-05 22:22:18 -07:00
Andrew Port 740e2bf991 Merge branch 'master' into PyPI 2022-06-05 22:21:36 -07:00
Andrew Port 8cbe6f0f81 minor docstring change 2022-06-05 22:19:26 -07:00
Andrew Port b82530aaac minor fix to docstring formatting 2022-06-05 21:49:21 -07:00
Andrew Port e8792f4d2d add support for os.PathLike arguments 2022-06-05 21:47:37 -07:00
Andrew Port d3a66f0bbd skip pathlib support for python < 3.6 2022-06-05 21:17:28 -07:00
Andrew Port 356d86df78 restore support for PosixPath inputs for Document 2022-06-05 21:00:42 -07:00
Andrew Port a989c9831d
Merge pull request #176 from FlyingSamson/read-svg-from-file-like-obj
Support reading of SVGs from strings and file-like objects
2022-06-05 20:12:19 -07:00
FlyingSamson 07f46d41f8 Rename svg_string2paths to svgstr2paths 2022-05-25 19:24:50 +02:00
FlyingSamson 2fc016d48f Make factory method a classmethod 2022-05-25 17:58:14 +02:00
FlyingSamson aacd5fa96d Remove second version of function returning the svg_attributes by default 2022-05-25 17:57:22 +02:00
FlyingSamson db5200f460 Switch back to previous function parameter names 2022-05-25 17:39:10 +02:00
FlyingSamson a473ee3f4c Remove unnecessary seek commands 2022-05-24 18:15:52 +02:00
FlyingSamson 02a223c220 Fix fileio for test compatibility with python2.7 2022-05-22 17:02:51 +02:00
FlyingSamson 68e0d1f30d Fix tests for old python versions not supporting type hints 2022-05-22 15:51:03 +02:00
FlyingSamson a743e0293c Add tests for creating from file location, file, StringIO, and string 2022-05-22 15:46:56 +02:00
FlyingSamson 1771fbfb06 Add factory method for creating from string holding svg object 2022-05-22 15:30:48 +02:00
FlyingSamson 33f4639bbf Add tests for functions taking svg objects as string 2022-05-22 14:51:16 +02:00
FlyingSamson 50b335f3da Add convenience functions for converting svgs contained in a string to paths 2022-05-22 14:37:27 +02:00
FlyingSamson ccdd10212c Add unit tests for reading from different sources in svg2paths 2022-05-22 13:32:09 +02:00
FlyingSamson ce43c75cd8 Allow file-like object as input to Documents ctor and svg2paths function 2022-05-22 13:09:13 +02:00
chanicpanic 19df25b99b
Fix Document.add_path for empty groups (#170) 2022-02-27 18:48:50 -08:00
Andrew Port c84c897bf2 aesthetic cleanup 2022-02-03 18:11:55 -08:00
Andrew Port ac138b8e5d aesthetic cleanup 2022-02-03 18:10:00 -08:00
Andrew Port 2dc06df20f rounded rect now parsed properly if only rx or only ry is included 2022-02-03 18:09:09 -08:00
Andrew Port 72fa3dcf17 Merge branch 'master' of github.com:mathandy/svgpathtools 2022-01-25 17:45:24 -08:00
Catherine Holloway 6c655ad220
add support for rounded rectangles (#161) 2022-01-25 17:23:04 -08:00
Andrew Port 5037fac574 fix issue with filenames with no directory causing error 2021-11-26 18:34:58 -08:00
Andrew Port abd99f0846 fix issue with filenames with no directory causing error 2021-11-26 18:31:38 -08:00
Andrew Port d1421d6286 fix issue with filenames with no directory causing error 2021-11-26 18:23:07 -08:00
Andrew Port 8ad7458b31 fix github action syntax for publishing to PyPI 2021-11-09 20:45:13 -08:00
Andrew Port 1b8caeec71 fix github action syntax 2021-11-09 20:35:58 -08:00
Andrew Port 002e691686 Publish now on any v* tag pushed to PyPI branch 2021-11-09 20:28:00 -08:00
Andrew Port 3576591e08 Publish now on any v* tag pushed to PyPI branch 2021-11-09 20:26:36 -08:00
Andrew Port ca094feea9 Add 3.10 CI check and disable test_hash unittest for Windows Python 2 environments 2021-11-09 19:48:41 -08:00
Andrew Port 3a6711a5e7 add scipy to requirements file 2021-11-09 18:47:17 -08:00
Andrew Port bbf75d0b5a Remove deprecated 'requires' setuptools parameter 2021-11-09 18:45:31 -08:00
Andrew Port 5b9ee30544 now scipy is installed by default 2021-11-09 18:30:30 -08:00
Andrew Port b35488efb0 fix path hash unit test 2021-11-01 18:05:39 -07:00
Andrew Port d97519ffa3 change codeql action to run on all branches 2021-10-18 22:40:22 -07:00
Andrew Port 5a3bb8fca8 update official python version support 2021-10-18 22:20:24 -07:00
Andrew Port d998587a32
Update README.md 2021-10-18 19:00:49 -07:00
Andrew Port b8579b2c12
Make donate badge test lowercase 2021-10-18 18:50:12 -07:00
Andrew Port 05a2d271b7
remove old donate button 2021-10-18 18:42:37 -07:00
Andrew Port a69898f83b
add some badges 2021-10-18 18:41:40 -07:00
Andrew Port 09ce497a4f add example how to use svgpathtools in JS with Pyodide 2021-10-04 21:24:04 -07:00
Julian Rüth 39d3ba713f
Fix implementation of points() (#155)
fix incorrect implementation of `points`  method
2021-09-26 18:22:09 -07:00
Andrew Port e4c7b53f62 fix text name 2021-09-26 18:17:58 -07:00
Andrew Port 8f4b1fee00 update setup.py download_url to point to wheel 2021-09-23 04:37:37 -07:00
Andrew Port a78ecf4290 update version 2021-09-23 04:13:12 -07:00
Andrew Port 73e0ae2b21 temporarily disable problemsome hash test 2021-09-23 04:02:51 -07:00
Andrew Port da5286f79e prevent pushing existing dists to testpypi 2021-09-23 04:00:36 -07:00
Andrew Port 8b8ac6c9fe fix issue with test failing due to hash builtin changing in python 3.2 then again in 3.8 2021-09-23 03:52:16 -07:00
Andrew Port 44d08b6737 fix issue with test failing due to hash builtin changing in python 3.8 2021-09-23 03:15:26 -07:00
Andrew Port 60984969a7 update testpypi secret 2021-09-23 03:08:33 -07:00
Andrew Port 3b33445c25 add download_url to setup.py 2021-09-23 03:03:30 -07:00
Andrew Port c4b77697f2 remove old distribution files 2021-09-23 02:58:06 -07:00
Andrew Port 56bbba0bd1 add workflow to publish to PyPI on tag 2021-09-23 02:51:01 -07:00
Andrew Port 4f685e732a remove junk comment from workflow tutorial 2021-09-23 02:30:16 -07:00
Andrew Port b8f4e71f5b remove accidentally committed debugging code 2021-09-23 00:18:03 -07:00
Andrew Port e993ff95c5 fix issue 156 caused by arc.intersect returning values of t outside [0, 1] 2021-09-23 00:17:19 -07:00
Andrew Port 1b503a7b2f fix test that failed in python2 due to changes to hash builtin 2021-09-23 00:15:37 -07:00
Andrew Port e0f212a334 make paths and path semgents hashable 2021-09-22 21:19:13 -07:00
49 changed files with 834 additions and 184 deletions

View File

@ -13,10 +13,7 @@ name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '30 2 * * 3'
@ -33,9 +30,6 @@ jobs:
fail-fast: false
matrix:
language: [ 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository

34
.github/workflows/github-ci-legacy.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Github CI Unit Testing for Legacy Environments
on:
push:
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ${{ matrix.os }}
continue-on-error: true
strategy:
matrix:
os: [ubuntu-18.04, macos-10.15, windows-2019]
python-version: [2.7, 3.5, 3.6]
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
# configure python
- uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
# install deps
- name: Install dependencies for ${{ matrix.os }} Python ${{ matrix.python-version }}
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install scipy
# find and run all unit tests
- name: Run unit tests
run: python -m unittest discover test

View File

@ -1,20 +1,18 @@
# This is a basic workflow to help you get started with Actions
name: Github CI Unit Testing
on:
push:
branches: [ master ]
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ${{ matrix.os }}
continue-on-error: true
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: [2.7, 3.6, 3.7, 3.8, 3.9]
python-version: [3.7, 3.8, 3.9, "3.10", "3.11"]
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2

View File

@ -0,0 +1,37 @@
name: Publish to TestPyPI
on:
push:
branches:
- master
jobs:
build-n-publish:
name: Build and publish to TestPyPI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Set up Python 3
uses: actions/setup-python@v1
with:
python-version: 3
- name: Install pypa/build
run: >-
python -m
pip install
build
--user
- name: Build a binary wheel and a source tarball
run: >-
python -m
build
--sdist
--wheel
--outdir dist/
.
- name: Publish to Test PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
skip_existing: true
password: ${{ secrets.TESTPYPI_API_TOKEN }}
repository_url: https://test.pypi.org/legacy/

42
.github/workflows/publish-on-pypi.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: Publish to PyPI if new version
on:
push:
tags:
- 'v*'
jobs:
build-n-publish:
name: Build and publish to TestPyPI and PyPI
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Set up Python 3
uses: actions/setup-python@v1
with:
python-version: 3
- name: Install pypa/build
run: >-
python -m
pip install
build
--user
- name: Build a binary wheel and a source tarball
run: >-
python -m
build
--sdist
--wheel
--outdir dist/
.
- name: Publish to Test PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
skip_existing: true
password: ${{ secrets.TESTPYPI_API_TOKEN }}
repository_url: https://test.pypi.org/legacy/
- name: Publish to PyPI
if: startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}

View File

@ -4,6 +4,10 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"[![Donate](https://img.shields.io/badge/donate-paypal-brightgreen)](https://www.paypal.com/donate?business=4SKJ27AM4EYYA&amp;no_recurring=0&amp;item_name=Support+the+creator+of+svgpathtools?++He%27s+a+student+and+would+appreciate+it.&amp;currency_code=USD)\n",
"![Python](https://img.shields.io/pypi/pyversions/svgpathtools.svg)\n",
"[![PyPI](https://img.shields.io/pypi/v/svgpathtools)](https://pypi.org/project/svgpathtools/)\n",
"[![PyPI - Downloads](https://img.shields.io/pypi/dm/svgpathtools?color=yellow)](https://pypistats.org/packages/svgpathtools)\n",
"# svgpathtools\n",
"\n",
"svgpathtools is a collection of tools for manipulating and analyzing SVG Path objects and Bézier curves.\n",
@ -36,20 +40,10 @@
"## Prerequisites\n",
"- **numpy**\n",
"- **svgwrite**\n",
"- **scipy** (optional but recommended for performance)\n",
"\n",
"## Setup\n",
"\n",
"If not already installed, you can **install the prerequisites** using pip.\n",
"\n",
"```bash\n",
"$ pip install numpy\n",
"```\n",
"\n",
"```bash\n",
"$ pip install svgwrite\n",
"```\n",
"\n",
"Then **install svgpathtools**:\n",
"```bash\n",
"$ pip install svgpathtools\n",
"``` \n",

View File

@ -1,4 +1,8 @@
# svgpathtools [![donate-button.svg](donate-button.svg)](https://www.paypal.com/donate?business=4SKJ27AM4EYYA&amp;no_recurring=0&amp;item_name=Support+the+creator+of+svgpathtools?++He%27s+a+student+and+would+appreciate+it.&amp;currency_code=USD)
[![Donate](https://img.shields.io/badge/donate-paypal-brightgreen)](https://www.paypal.com/donate?business=4SKJ27AM4EYYA&amp;no_recurring=0&amp;item_name=Support+the+creator+of+svgpathtools?++He%27s+a+student+and+would+appreciate+it.&amp;currency_code=USD)
![Python](https://img.shields.io/pypi/pyversions/svgpathtools.svg)
[![PyPI](https://img.shields.io/pypi/v/svgpathtools)](https://pypi.org/project/svgpathtools/)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/svgpathtools?color=yellow)](https://pypistats.org/packages/svgpathtools)
# svgpathtools
svgpathtools is a collection of tools for manipulating and analyzing SVG Path objects and Bézier curves.
@ -31,20 +35,10 @@ Some included tools:
## Prerequisites
- **numpy**
- **svgwrite**
- **scipy** (optional, but recommended for performance)
## Setup
If not already installed, you can **install the prerequisites** using pip.
```bash
$ pip install numpy
```
```bash
$ pip install svgwrite
```
Then **install svgpathtools**:
```bash
$ pip install svgpathtools
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,7 +8,7 @@ Note: The relevant matrix transformation for quadratics can be found in the
svgpathtools.bezier module."""
from __future__ import print_function
import numpy as np
from svgpathtools import *
from svgpathtools import bezier_point, Path, bpoints2bezier, polynomial2bezier
class HigherOrderBezier:

View File

@ -7,7 +7,8 @@ Path.continuous_subpaths() method to split a paths into a list of its
continuous subpaths.
"""
from svgpathtools import *
from svgpathtools import Path, Line
def path1_is_contained_in_path2(path1, path2):
assert path2.isclosed() # This question isn't well-defined otherwise
@ -16,11 +17,11 @@ def path1_is_contained_in_path2(path1, path2):
# find a point that's definitely outside path2
xmin, xmax, ymin, ymax = path2.bbox()
B = (xmin + 1) + 1j*(ymax + 1)
b = (xmin + 1) + 1j*(ymax + 1)
A = path1.start # pick an arbitrary point in path1
AB_line = Path(Line(A, B))
number_of_intersections = len(AB_line.intersect(path2))
a = path1.start # pick an arbitrary point in path1
ab_line = Path(Line(a, b))
number_of_intersections = len(ab_line.intersect(path2))
if number_of_intersections % 2: # if number of intersections is odd
return True
else:

View File

@ -1,13 +1,16 @@
from svgpathtools import *
from svgpathtools import disvg, Line, CubicBezier
from scipy.optimize import fminbound
# create some example paths
path1 = CubicBezier(1,2+3j,3-5j,4+1j)
path2 = path1.rotated(60).translated(3)
# find minimizer
from scipy.optimize import fminbound
def dist(t):
return path1.radialrange(path2.point(t))[0][0]
# find minimizer
T2 = fminbound(dist, 0, 1)
# Let's do a visual check

View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.18.1/full/pyodide.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<meta charset="utf-8" />
<title>svgpathtools in JS!</title>
</head>
<body>
<button id="go_button" onclick="tick()" hidden>Click Me!</button>
<br />
<br />
<div>Output:</div>
<label for="output"></label>
<textarea id="output" style="width: 100%;" rows="6" disabled></textarea>
<svg height="100" width="100">
<circle cx="50" cy="50" r="40" stroke-width="2" stroke="black" fill="blue"/>
<path id="ticker" d="M 50 50 L 50 15" stroke-width="2" stroke="black"/>
<circle cx="50" cy="50" r="3" stroke-width="2" stroke="black" fill="green"/>
Sorry, your browser does not support inline SVG.
</svg>
<script>
// init Pyodide environment and install svgpathtools
async function main() {
let pyodide = await loadPyodide({
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.18.1/full/",
});
await pyodide.loadPackage("micropip");
pyodide.runPythonAsync(`
import micropip
await micropip.install('svgpathtools')
`);
output.value += "svgpathtools is ready!\n";
return pyodide;
}
async function tick() {
let clock_hand = document.getElementById("ticker");
let pyodide = await pyodideReadyPromise;
try {
let result = pyodide.runPython(`
from svgpathtools import parse_path
parse_path('${clock_hand.getAttribute('d')}').rotated(45, origin=50+50j).d()
`);
clock_hand.setAttribute('d', result);
} catch (err) {
output.value += err;
}
}
let pyodideReadyPromise = main();
$(document).ready(function(){
const output = document.getElementById("output");
output.value = "Initializing...\n";
document.getElementById("go_button").removeAttribute('hidden');
});
</script>
</body>
</html>

View File

@ -1,2 +1,3 @@
numpy
svgwrite
scipy

View File

@ -3,18 +3,17 @@ import codecs
import os
VERSION = '1.4.1'
VERSION = '1.6.1'
AUTHOR_NAME = 'Andy Port'
AUTHOR_EMAIL = 'AndyAPort@gmail.com'
GITHUB = 'https://github.com/mathandy/svgpathtools'
_here = os.path.abspath(os.path.dirname(__file__))
def read(*parts):
"""
Build an absolute path from *parts* and and return the contents of the
resulting file. Assume UTF-8 encoding.
"""
HERE = os.path.abspath(os.path.dirname(__file__))
with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f:
def read(relative_path):
"""Reads file at relative path, returning contents as string."""
with codecs.open(os.path.join(_here, relative_path), "rb", "utf-8") as f:
return f.read()
@ -27,12 +26,12 @@ setup(name='svgpathtools',
long_description_content_type='text/markdown',
author=AUTHOR_NAME,
author_email=AUTHOR_EMAIL,
url='https://github.com/mathandy/svgpathtools',
# download_url = 'http://github.com/mathandy/svgpathtools/tarball/'+VERSION,
url=GITHUB,
download_url='{}/releases/download/{}/svgpathtools-{}-py2.py3-none-any.whl'
''.format(GITHUB, VERSION, VERSION),
license='MIT',
install_requires=['numpy', 'svgwrite'],
install_requires=['numpy', 'svgwrite', 'scipy'],
platforms="OS Independent",
requires=['numpy', 'svgwrite'],
keywords=['svg', 'svg path', 'svg.path', 'bezier', 'parse svg path', 'display svg'],
classifiers=[
"Development Status :: 4 - Beta",
@ -41,6 +40,14 @@ setup(name='svgpathtools',
"Operating System :: OS Independent",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Multimedia :: Graphics :: Editors :: Vector-Based",
"Topic :: Scientific/Engineering",
"Topic :: Scientific/Engineering :: Image Recognition",

View File

@ -17,6 +17,6 @@ from .document import (Document, CONVERSIONS, CONVERT_ONLY_PATHS,
from .svg_io_sax import SaxDocument
try:
from .svg_to_paths import svg2paths, svg2paths2
from .svg_to_paths import svg2paths, svg2paths2, svgstr2paths
except ImportError:
pass

View File

@ -13,7 +13,7 @@ An Historic Note:
Example:
Typical usage looks something like the following.
>> from svgpathtools import *
>> from svgpathtools import Document
>> doc = Document('my_file.html')
>> for path in doc.paths():
>> # Do something with the transformed Path object.
@ -41,8 +41,10 @@ import xml.etree.ElementTree as etree
from xml.etree.ElementTree import Element, SubElement, register_namespace
from xml.dom.minidom import parseString
import warnings
from io import StringIO
from tempfile import gettempdir
from time import time
import numpy as np
# Internal dependencies
from .parser import parse_path
@ -50,13 +52,17 @@ from .parser import parse_transform
from .svg_to_paths import (path2pathd, ellipse2pathd, line2pathd,
polyline2pathd, polygon2pathd, rect2pathd)
from .misctools import open_in_browser
from .path import *
from .path import transform, Path, is_path_segment
# To maintain forward/backward compatibility
try:
str = basestring
string = basestring
except NameError:
pass
string = str
try:
from os import PathLike
except ImportError:
PathLike = string
# Let xml.etree.ElementTree know about the SVG namespace
SVG_NAMESPACE = {'svg': 'http://www.w3.org/2000/svg'}
@ -235,13 +241,14 @@ class Document:
The output Path objects will be transformed based on their parent groups.
Args:
filepath (str): The filepath of the DOM-style object.
filepath (str or file-like): The filepath of the
DOM-style object or a file-like object containing it.
"""
# remember location of original svg file
self.original_filepath = filepath
if filepath is not None and os.path.dirname(filepath) == '':
self.original_filepath = os.path.join(os.getcwd(), filepath)
# strings are interpreted as file location everything else is treated as
# file-like object and passed to the xml parser directly
from_filepath = isinstance(filepath, string) or isinstance(filepath, PathLike)
self.original_filepath = os.path.abspath(filepath) if from_filepath else None
if filepath is None:
self.tree = etree.ElementTree(Element('svg'))
@ -251,6 +258,14 @@ class Document:
self.root = self.tree.getroot()
@classmethod
def from_svg_string(cls, svg_string):
"""Constructor for creating a Document object from a string."""
# wrap string into StringIO object
svg_file_obj = StringIO(svg_string)
# create document from file object
return Document(svg_file_obj)
def paths(self, group_filter=lambda x: True,
path_filter=lambda x: True, path_conversions=CONVERSIONS):
"""Returns a list of all paths in the document.
@ -263,7 +278,7 @@ class Document:
def paths_from_group(self, group, recursive=True, group_filter=lambda x: True,
path_filter=lambda x: True, path_conversions=CONVERSIONS):
if all(isinstance(s, str) for s in group):
if all(isinstance(s, string) for s in group):
# If we're given a list of strings, assume it represents a
# nested sequence
group = self.get_group(group)
@ -289,7 +304,7 @@ class Document:
# If given a list of strings (one or more), assume it represents
# a sequence of nested group names
elif all(isinstance(elem, str) for elem in group):
elif len(group) > 0 and all(isinstance(elem, str) for elem in group):
group = self.get_or_add_group(group)
elif not isinstance(group, Element):
@ -308,7 +323,7 @@ class Document:
path_svg = path.d()
elif is_path_segment(path):
path_svg = Path(path).d()
elif isinstance(path, str):
elif isinstance(path, string):
# Assume this is a valid d-string.
# TODO: Should we sanity check the input string?
path_svg = path

View File

@ -43,8 +43,8 @@ except NameError:
COMMANDS = set('MmZzLlHhVvCcSsQqTtAa')
UPPERCASE = set('MZLHVCSQTA')
COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])")
FLOAT_RE = re.compile("[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?")
COMMAND_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])")
FLOAT_RE = re.compile(r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?")
# Default Parameters ##########################################################
@ -189,7 +189,6 @@ def bez2poly(bez, numpy_ordering=True, return_poly1d=False):
def transform_segments_together(path, transformation):
"""Makes sure that, if joints were continuous, they're kept that way."""
transformed_segs = [transformation(seg) for seg in path]
joint_was_continuous = [sa.end == sb.start for sa, sb in path.joints()]
for i, (sa, sb) in enumerate(path.joints()):
if sa.end == sb.start:
@ -202,7 +201,7 @@ def rotate(curve, degs, origin=None):
(a complex number). By default origin is either `curve.point(0.5)`, or in
the case that curve is an Arc object, `origin` defaults to `curve.center`.
"""
def transform(z):
def rotate_point(z):
return exp(1j*radians(degs))*(z - origin) + origin
if origin is None:
@ -215,10 +214,10 @@ def rotate(curve, degs, origin=None):
transformation = lambda seg: rotate(seg, degs, origin=origin)
return transform_segments_together(curve, transformation)
elif is_bezier_segment(curve):
return bpoints2bezier([transform(bpt) for bpt in curve.bpoints()])
return bpoints2bezier([rotate_point(bpt) for bpt in curve.bpoints()])
elif isinstance(curve, Arc):
new_start = transform(curve.start)
new_end = transform(curve.end)
new_start = rotate_point(curve.start)
new_end = rotate_point(curve.end)
new_rotation = curve.rotation + degs
return Arc(new_start, radius=curve.radius, rotation=new_rotation,
large_arc=curve.large_arc, sweep=curve.sweep, end=new_end)
@ -292,8 +291,13 @@ def scale(curve, sx, sy=None, origin=0j):
raise TypeError("Input `curve` should be a Path, Line, "
"QuadraticBezier, CubicBezier, or Arc object.")
def transform(curve, tf):
"""Transforms the curve by the homogeneous transformation matrix tf"""
if all((tf == np.eye(3)).ravel()):
return curve # tf is identity, return curve as is
def to_point(p):
return np.array([[p.real], [p.imag], [1.0]])
@ -314,7 +318,7 @@ def transform(curve, tf):
new_start = to_complex(tf.dot(to_point(curve.start)))
new_end = to_complex(tf.dot(to_point(curve.end)))
# Based on https://math.stackexchange.com/questions/2349726/compute-the-major-and-minor-axis-of-an-ellipse-after-linearly-transforming-it
# Based on https://math.stackexchange.com/questions/2349726/
rx2 = curve.radius.real ** 2
ry2 = curve.radius.imag ** 2
@ -335,9 +339,13 @@ def transform(curve, tf):
if new_radius.real == 0 or new_radius.imag == 0 :
return Line(new_start, new_end)
else:
if tf[0][0] * tf[1][1] >= 0.0:
new_sweep = curve.sweep
else:
new_sweep = not curve.sweep
return Arc(new_start, radius=new_radius, rotation=curve.rotation + rot,
large_arc=curve.large_arc, sweep=curve.sweep, end=new_end,
autoscale_radius=False)
large_arc=curve.large_arc, sweep=new_sweep, end=new_end,
autoscale_radius=True)
else:
raise TypeError("Input `curve` should be a Path, Line, "
@ -567,8 +575,7 @@ def inv_arclength(curve, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS,
def crop_bezier(seg, t0, t1):
"""returns a cropped copy of this segment which starts at self.point(t0)
and ends at self.point(t1)."""
"""Crop a copy of this `self` from `self.point(t0)` to `self.point(t1)`."""
assert t0 < t1
if t0 == 0:
cropped_seg = seg.split(t1)[0]
@ -595,6 +602,9 @@ class Line(object):
self.start = start
self.end = end
def __hash__(self):
return hash((self.start, self.end))
def __repr__(self):
return 'Line(start=%s, end=%s)' % (self.start, self.end)
@ -634,7 +644,7 @@ class Line(object):
def points(self, ts):
"""Faster than running Path.point many times."""
return self.poly(ts)
return self.poly()(ts)
def length(self, t0=0, t1=1, error=None, min_depth=None):
"""returns the length of the line segment between t0 and t1."""
@ -705,6 +715,19 @@ class Line(object):
Note: This will fail if the two segments coincide for more than a
finite collection of points.
tol is not used."""
if isinstance(other_seg, (Line, QuadraticBezier, CubicBezier)):
ob = [e.real for e in other_seg.bpoints()]
sb = [e.real for e in self.bpoints()]
if min(ob) > max(sb):
return []
if max(ob) < min(sb):
return []
ob = [e.imag for e in other_seg.bpoints()]
sb = [e.imag for e in self.bpoints()]
if min(ob) > max(sb):
return []
if max(ob) < min(sb):
return []
if isinstance(other_seg, Line):
assert other_seg.end != other_seg.start and self.end != self.start
assert self != other_seg
@ -851,6 +874,9 @@ class QuadraticBezier(object):
# used to know if self._length needs to be updated
self._length_info = {'length': None, 'bpoints': None}
def __hash__(self):
return hash((self.start, self.control, self.end))
def __repr__(self):
return 'QuadraticBezier(start=%s, control=%s, end=%s)' % (
self.start, self.control, self.end)
@ -908,7 +934,7 @@ class QuadraticBezier(object):
def points(self, ts):
"""Faster than running Path.point many times."""
return self.poly(ts)
return self.poly()(ts)
def length(self, t0=0, t1=1, error=None, min_depth=None):
if t0 == 1 and t1 == 0:
@ -1029,6 +1055,19 @@ class QuadraticBezier(object):
self.point(t1) == other_seg.point(t2).
Note: This will fail if the two segments coincide for more than a
finite collection of points."""
if isinstance(other_seg, (Line, QuadraticBezier, CubicBezier)):
ob = [e.real for e in other_seg.bpoints()]
sb = [e.real for e in self.bpoints()]
if min(ob) > max(sb):
return []
if max(ob) < min(sb):
return []
ob = [e.imag for e in other_seg.bpoints()]
sb = [e.imag for e in self.bpoints()]
if min(ob) > max(sb):
return []
if max(ob) < min(sb):
return []
if isinstance(other_seg, Line):
return bezier_by_line_intersections(self, other_seg)
elif isinstance(other_seg, QuadraticBezier):
@ -1106,6 +1145,9 @@ class CubicBezier(object):
self._length_info = {'length': None, 'bpoints': None, 'error': None,
'min_depth': None}
def __hash__(self):
return hash((self.start, self.control1, self.control2, self.end))
def __repr__(self):
return 'CubicBezier(start=%s, control1=%s, control2=%s, end=%s)' % (
self.start, self.control1, self.control2, self.end)
@ -1169,7 +1211,7 @@ class CubicBezier(object):
def points(self, ts):
"""Faster than running Path.point many times."""
return self.poly(ts)
return self.poly()(ts)
def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH):
"""Calculate the length of the path up to a certain position"""
@ -1277,37 +1319,51 @@ class CubicBezier(object):
def intersect(self, other_seg, tol=1e-12):
"""Finds the intersections of two segments.
returns a list of tuples (t1, t2) such that
Returns:
(list[tuple[float]]) a list of tuples (t1, t2) such that
self.point(t1) == other_seg.point(t2).
Note: This will fail if the two segments coincide for more than a
finite collection of points."""
Scope:
This will fail if the two segments coincide for more than a
finite collection of points.
"""
if isinstance(other_seg, (Line, QuadraticBezier, CubicBezier)):
ob = [e.real for e in other_seg.bpoints()]
sb = [e.real for e in self.bpoints()]
if min(ob) > max(sb):
return []
if max(ob) < min(sb):
return []
ob = [e.imag for e in other_seg.bpoints()]
sb = [e.imag for e in self.bpoints()]
if min(ob) > max(sb):
return []
if max(ob) < min(sb):
return []
if isinstance(other_seg, Line):
return bezier_by_line_intersections(self, other_seg)
elif (isinstance(other_seg, QuadraticBezier) or
isinstance(other_seg, CubicBezier)):
assert self != other_seg
longer_length = max(self.length(), other_seg.length())
return bezier_intersections(self, other_seg,
longer_length=longer_length,
tol=tol, tol_deC=tol)
return bezier_intersections(
self, other_seg, longer_length=longer_length, tol=tol, tol_deC=tol
)
elif isinstance(other_seg, Arc):
t2t1s = other_seg.intersect(self)
return [(t1, t2) for t2, t1 in t2t1s]
return [(t1, t2) for t2, t1 in other_seg.intersect(self)]
elif isinstance(other_seg, Path):
raise TypeError(
"other_seg must be a path segment, not a Path object, use "
"Path.intersect().")
raise TypeError("`other_seg` must be a path segment, not a "
"`Path` object, use `Path.intersect()`.")
else:
raise TypeError("other_seg must be a path segment.")
raise TypeError("`other_seg` must be a path segment.")
def bbox(self):
"""returns the bounding box for the segment in the form
(xmin, xmax, ymin, ymax)."""
"""returns bounding box in format (xmin, xmax, ymin, ymax)."""
return bezier_bounding_box(self)
def split(self, t):
"""returns two segments, whose union is this segment and which join at
self.point(t)."""
"""Splits a copy of `self` at t and returns the two subsegments."""
bpoints1, bpoints2 = split_bezier(self.bpoints(), t)
return CubicBezier(*bpoints1), CubicBezier(*bpoints2)
@ -1319,8 +1375,8 @@ class CubicBezier(object):
def radialrange(self, origin, return_all_global_extrema=False):
"""returns the tuples (d_min, t_min) and (d_max, t_max) which minimize
and maximize, respectively, the distance d = |self.point(t)-origin|."""
return bezier_radialrange(self, origin,
return_all_global_extrema=return_all_global_extrema)
return bezier_radialrange(
self, origin, return_all_global_extrema=return_all_global_extrema)
def rotated(self, degs, origin=None):
"""Returns a copy of self rotated by `degs` degrees (CCW) around the
@ -1342,7 +1398,7 @@ class CubicBezier(object):
class Arc(object):
def __init__(self, start, radius, rotation, large_arc, sweep, end,
autoscale_radius=True):
"""
r"""
This should be thought of as a part of an ellipse connecting two
points on that ellipse, start and end.
Parameters
@ -1698,33 +1754,48 @@ class Arc(object):
return None
def centeriso(self, z):
"""This is an isometry that translates and rotates self so that it
is centered on the origin and has its axes aligned with the xy axes."""
"""Isometry to a centered aligned ellipse.
This is an isometry that shifts and rotates `self`'s underlying
ellipse so that it's centered on the origin and has its axes
aligned with the xy-axes.
Args:
z (:obj:`complex` or :obj:`numpy.ndarray[complex]`): a point
to send through the above-described isometry.
Returns:
(:obj:`complex` or :obj:`numpy.ndarray[complex]`) The point(s) f(z),
where f is the above described isometry of the xy-plane (i.e.
the one-dimensional complex plane).
"""
return (1/self.rot_matrix)*(z - self.center)
def icenteriso(self, zeta):
"""This is an isometry, the inverse of standardiso()."""
"""The inverse of the `centeriso()` method."""
return self.rot_matrix*zeta + self.center
def u1transform(self, z):
"""This is an affine transformation (same as used in
self._parameterize()) that sends self to the unit circle."""
zeta = (1/self.rot_matrix)*(z - self.center) # same as centeriso(z)
"""Similar to the `centeriso()` method, but maps to the unit circle."""
zeta = self.centeriso(z)
x, y = real(zeta), imag(zeta)
return x/self.radius.real + 1j*y/self.radius.imag
def iu1transform(self, zeta):
"""This is an affine transformation, the inverse of
self.u1transform()."""
"""The inverse of the `u1transform()` method."""
x = real(zeta)
y = imag(zeta)
z = x*self.radius.real + y*self.radius.imag
return self.rot_matrix*z + self.center
def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH):
"""The length of an elliptical large_arc segment requires numerical
"""Computes the length of the Arc segment, `self`, from t0 to t1.
Notes:
* The length of an elliptical large_arc segment requires numerical
integration, and in that case it's simpler to just do a geometric
approximation, as for cubic bezier curves."""
approximation, as for cubic bezier curves.
"""
assert 0 <= t0 <= 1 and 0 <= t1 <= 1
if t0 == 0 and t1 == 1:
@ -1748,8 +1819,17 @@ class Arc(object):
def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS,
error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH):
"""Returns a float, t, such that self.length(0, t) is approximately s.
See the inv_arclength() docstring for more details."""
"""Approximates the unique `t` such that self.length(0, t) = s.
Args:
s (float): A length between 0 and `self.length()`.
Returns:
(float) The t, such that self.length(0, t) is approximately s.
For more info:
See the inv_arclength() docstring.
"""
return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error,
min_depth=min_depth)
@ -1847,9 +1927,18 @@ class Arc(object):
not self.sweep, self.start)
def phase2t(self, psi):
"""Given phase -pi < psi <= pi,
returns the t value such that
exp(1j*psi) = self.u1transform(self.point(t)).
"""Converts phase to t-value.
I.e. given phase, psi, such that -np.pi < psi <= np.pi, approximates
the unique t-value such that `self.u1transform(self.point(t))` equals
`np.exp(1j*psi)`.
Args:
psi (float): The phase in radians.
Returns:
(float): the corresponding t-value.
"""
def _deg(rads, domain_lower_limit):
# Convert rads to degrees in [0, 360) domain
@ -1868,7 +1957,6 @@ class Arc(object):
degs = _deg(psi, domain_lower_limit=self.theta)
return (degs - self.theta)/self.delta
def intersect(self, other_seg, tol=1e-12):
"""NOT FULLY IMPLEMENTED. Finds the intersections of two segments.
returns a list of tuples (t1, t2) such that
@ -1998,11 +2086,19 @@ class Arc(object):
return intersections
elif is_bezier_segment(other_seg):
u1poly = self.u1transform(other_seg.poly())
# if self and other_seg intersect, they will itersect at the
# same points after being passed through the `u1transform`
# isometry. Since this isometry maps self to the unit circle,
# the intersections will be easy to find (just look for any
# points where other_seg is a distance of one from the origin.
# Moreoever, the t-values that the intersection happen at will
# be unchanged by the isometry.
u1poly = np.poly1d(self.u1transform(other_seg.poly()))
u1poly_mag2 = real(u1poly)**2 + imag(u1poly)**2
t2s = polyroots01(u1poly_mag2 - 1)
t2s = [t for t in polyroots01(u1poly_mag2 - 1) if 0 <= t <= 1]
t1s = [self.phase2t(phase(u1poly(t2))) for t2 in t2s]
return list(zip(t1s, t2s))
return [(t1, t2) for t1, t2 in zip(t1s, t2s) if 0 <= t1 <= 1]
elif isinstance(other_seg, Arc):
assert other_seg != self
@ -2039,19 +2135,23 @@ class Arc(object):
def point_in_seg_interior(point, seg):
t = seg.point_to_t(point)
if t is None: return False
if np.isclose(t, 0.0, rtol=0.0, atol=1e-6): return False
if np.isclose(t, 1.0, rtol=0.0, atol=1e-6): return False
if (not t or
np.isclose(t, 0.0, rtol=0.0, atol=1e-6) or
np.isclose(t, 1.0, rtol=0.0, atol=1e-6)):
return False
return True
# If either end of either segment is in the interior
# of the other segment, then the Arcs overlap
# in an infinite number of points, and we return
# "no intersections".
if point_in_seg_interior(self.start, other_seg): return []
if point_in_seg_interior(self.end, other_seg): return []
if point_in_seg_interior(other_seg.start, self): return []
if point_in_seg_interior(other_seg.end, self): return []
if (
point_in_seg_interior(self.start, other_seg) or
point_in_seg_interior(self.end, other_seg) or
point_in_seg_interior(other_seg.start, self) or
point_in_seg_interior(other_seg.end, self)
):
return []
# If they touch at their endpoint(s) and don't go
# in "overlapping directions", then we accept that
@ -2394,6 +2494,9 @@ class Path(MutableSequence):
if 'tree_element' in kw:
self._tree_element = kw['tree_element']
def __hash__(self):
return hash((tuple(self._segments), self._closed))
def __getitem__(self, index):
return self._segments[index]
@ -2469,7 +2572,7 @@ class Path(MutableSequence):
# Shortcuts
if len(self._segments) == 0:
return None
raise ValueError("This path contains no segments!")
if pos == 0.0:
return self._segments[0].point(pos)
if pos == 1.0:
@ -2486,6 +2589,7 @@ class Path(MutableSequence):
segment_end - segment_start)
return segment.point(segment_pos)
segment_start = segment_end
raise RuntimeError("Something has gone wrong. Could not compute Path.point({}) for path {}".format(pos, self))
def length(self, T0=0, T1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH):
self._calc_lengths(error=error, min_depth=min_depth)
@ -2844,10 +2948,10 @@ class Path(MutableSequence):
area_enclosed += integral(1) - integral(0)
return area_enclosed
def seg2lines(seg):
def seg2lines(seg_):
"""Find piecewise-linear approximation of `seg`."""
num_lines = int(ceil(seg.length() / chord_length))
pts = [seg.point(t) for t in np.linspace(0, 1, num_lines+1)]
num_lines = int(ceil(seg_.length() / chord_length))
pts = [seg_.point(t) for t in np.linspace(0, 1, num_lines+1)]
return [Line(pts[i], pts[i+1]) for i in range(num_lines)]
assert self.isclosed()
@ -2861,20 +2965,29 @@ class Path(MutableSequence):
return area_without_arcs(Path(*bezier_path_approximation))
def intersect(self, other_curve, justonemode=False, tol=1e-12):
"""returns list of pairs of pairs ((T1, seg1, t1), (T2, seg2, t2))
giving the intersection points.
If justonemode==True, then returns just the first
"""Finds intersections of `self` with `other_curve`
Args:
other_curve: the path or path segment to check for intersections
with `self`
justonemode (bool): if true, returns only the first
intersection found.
tol is used to check for redundant intersections (see comment above
the code block where tol is used).
Note: If the two path objects coincide for more than a finite set of
points, this code will fail."""
tol (float): A tolerance used to check for redundant intersections
(see comment above the code block where tol is used).
Returns:
(list[tuple[float, Curve, float]]): list of intersections, each
in the format ((T1, seg1, t1), (T2, seg2, t2)), where
self.point(T1) == seg1.point(t1) == seg2.point(t2) == other_curve.point(T2)
Scope:
If the two path objects coincide for more than a finite set of
points, this code will iterate to max depth and/or raise an error.
"""
path1 = self
if isinstance(other_curve, Path):
path2 = other_curve
else:
path2 = Path(other_curve)
path2 = other_curve if isinstance(other_curve, Path) else Path(other_curve)
assert path1 != path2
intersection_list = []
for seg1 in path1:
for seg2 in path2:
@ -2884,6 +2997,7 @@ class Path(MutableSequence):
T1 = path1.t2T(seg1, t1)
T2 = path2.t2T(seg2, t2)
intersection_list.append(((T1, seg1, t1), (T2, seg2, t2)))
if justonemode and intersection_list:
return intersection_list[0]
@ -2905,8 +3019,7 @@ class Path(MutableSequence):
return intersection_list
def bbox(self):
"""returns a bounding box for the input Path object in the form
(xmin, xmax, ymin, ymax)."""
"""returns bounding box in the form (xmin, xmax, ymin, ymax)."""
bbs = [seg.bbox() for seg in self._segments]
xmins, xmaxs, ymins, ymaxs = list(zip(*bbs))
xmin = min(xmins)

View File

@ -214,10 +214,13 @@ def disvg(paths=None, colors=None, filename=None, stroke_widths=None,
timestamp = True if timestamp is None else timestamp
filename = os_path.join(gettempdir(), 'disvg_output.svg')
dirname = os_path.abspath(os_path.dirname(filename))
if not os_path.exists(dirname):
makedirs(dirname)
# append time stamp to filename
if timestamp:
fbname, fext = os_path.splitext(filename)
dirname = os_path.dirname(filename)
tstamp = str(time()).replace('.', '')
stfilename = os_path.split(fbname)[1] + '_' + tstamp + fext
filename = os_path.join(dirname, stfilename)
@ -407,9 +410,6 @@ def disvg(paths=None, colors=None, filename=None, stroke_widths=None,
if paths2Drawing:
return dwg
# save svg
if not os_path.exists(os_path.dirname(filename)):
makedirs(os_path.dirname(filename))
dwg.save()
# re-open the svg, make the xml pretty, and save it again

View File

@ -6,6 +6,7 @@
from __future__ import division, absolute_import, print_function
import os
from xml.etree.ElementTree import iterparse, Element, ElementTree, SubElement
import numpy as np
# Internal dependencies
from .parser import parse_path
@ -13,13 +14,13 @@ from .parser import parse_transform
from .svg_to_paths import (path2pathd, ellipse2pathd, line2pathd,
polyline2pathd, polygon2pathd, rect2pathd)
from .misctools import open_in_browser
from .path import *
from .path import transform
# To maintain forward/backward compatibility
try:
str = basestring
string = basestring
except NameError:
pass
string = str
NAME_SVG = "svg"
ATTR_VERSION = "version"
@ -164,17 +165,17 @@ class SaxDocument:
if matrix is not None and not np.all(np.equal(matrix, identity)):
matrix_string = "matrix("
matrix_string += " "
matrix_string += str(matrix[0][0])
matrix_string += string(matrix[0][0])
matrix_string += " "
matrix_string += str(matrix[1][0])
matrix_string += string(matrix[1][0])
matrix_string += " "
matrix_string += str(matrix[0][1])
matrix_string += string(matrix[0][1])
matrix_string += " "
matrix_string += str(matrix[1][1])
matrix_string += string(matrix[1][1])
matrix_string += " "
matrix_string += str(matrix[0][2])
matrix_string += string(matrix[0][2])
matrix_string += " "
matrix_string += str(matrix[1][2])
matrix_string += string(matrix[1][2])
matrix_string += ")"
path.set(ATTR_TRANSFORM, matrix_string)
if ATTR_DATA in values:

View File

@ -4,8 +4,13 @@ The main tool being the svg2paths() function."""
# External dependencies
from __future__ import division, absolute_import, print_function
from xml.dom.minidom import parse
from os import path as os_path, getcwd
import os
from io import StringIO
import re
try:
from os import PathLike as FilePathLike
except ImportError:
FilePathLike = str
# Internal dependencies
from .parser import parse_path
@ -17,9 +22,11 @@ COORD_PAIR_TMPLT = re.compile(
r'([\+-]?\d*[\.\d]\d*[eE][\+-]?\d+|[\+-]?\d*[\.\d]\d*)'
)
def path2pathd(path):
return path.get('d', '')
def ellipse2pathd(ellipse):
"""converts the parameters from an ellipse or a circle to a string for a
Path object d-attribute"""
@ -44,7 +51,7 @@ def ellipse2pathd(ellipse):
d += 'a' + str(rx) + ',' + str(ry) + ' 0 1,0 ' + str(2 * rx) + ',0'
d += 'a' + str(rx) + ',' + str(ry) + ' 0 1,0 ' + str(-2 * rx) + ',0'
return d
return d + 'z'
def polyline2pathd(polyline, is_polygon=False):
@ -84,14 +91,39 @@ def rect2pathd(rect):
The rectangle will start at the (x,y) coordinate specified by the
rectangle object and proceed counter-clockwise."""
x0, y0 = float(rect.get('x', 0)), float(rect.get('y', 0))
x, y = float(rect.get('x', 0)), float(rect.get('y', 0))
w, h = float(rect.get('width', 0)), float(rect.get('height', 0))
x1, y1 = x0 + w, y0
x2, y2 = x0 + w, y0 + h
x3, y3 = x0, y0 + h
if 'rx' in rect or 'ry' in rect:
# if only one, rx or ry, is present, use that value for both
# https://developer.mozilla.org/en-US/docs/Web/SVG/Element/rect
rx = rect.get('rx', None)
ry = rect.get('ry', None)
if rx is None:
rx = ry or 0.
if ry is None:
ry = rx or 0.
rx, ry = float(rx), float(ry)
d = "M {} {} ".format(x + rx, y) # right of p0
d += "L {} {} ".format(x + w - rx, y) # go to p1
d += "A {} {} 0 0 1 {} {} ".format(rx, ry, x+w, y+ry) # arc for p1
d += "L {} {} ".format(x+w, y+h-ry) # above p2
d += "A {} {} 0 0 1 {} {} ".format(rx, ry, x+w-rx, y+h) # arc for p2
d += "L {} {} ".format(x+rx, y+h) # right of p3
d += "A {} {} 0 0 1 {} {} ".format(rx, ry, x, y+h-ry) # arc for p3
d += "L {} {} ".format(x, y+ry) # below p0
d += "A {} {} 0 0 1 {} {} z".format(rx, ry, x+rx, y) # arc for p0
return d
x0, y0 = x, y
x1, y1 = x + w, y
x2, y2 = x + w, y + h
x3, y3 = x, y + h
d = ("M{} {} L {} {} L {} {} L {} {} z"
"".format(x0, y0, x1, y1, x2, y2, x3, y3))
return d
@ -117,7 +149,9 @@ def svg2paths(svg_file_location,
SVG Path, Line, Polyline, Polygon, Circle, and Ellipse elements.
Args:
svg_file_location (string): the location of the svg file
svg_file_location (string or file-like object): the location of the
svg file on disk or a file-like object containing the content of a
svg file
return_svg_attributes (bool): Set to True and a dictionary of
svg-attributes will be extracted and returned. See also the
`svg2paths2()` function.
@ -141,8 +175,10 @@ def svg2paths(svg_file_location,
list: The list of corresponding path attribute dictionaries.
dict (optional): A dictionary of svg-attributes (see `svg2paths2()`).
"""
if os_path.dirname(svg_file_location) == '':
svg_file_location = os_path.join(getcwd(), svg_file_location)
# strings are interpreted as file location everything else is treated as
# file-like object and passed to the xml parser directly
from_filepath = isinstance(svg_file_location, str) or isinstance(svg_file_location, FilePathLike)
svg_file_location = os.path.abspath(svg_file_location) if from_filepath else svg_file_location
doc = parse(svg_file_location)
@ -222,3 +258,26 @@ def svg2paths2(svg_file_location,
convert_polylines_to_paths=convert_polylines_to_paths,
convert_polygons_to_paths=convert_polygons_to_paths,
convert_rectangles_to_paths=convert_rectangles_to_paths)
def svgstr2paths(svg_string,
return_svg_attributes=False,
convert_circles_to_paths=True,
convert_ellipses_to_paths=True,
convert_lines_to_paths=True,
convert_polylines_to_paths=True,
convert_polygons_to_paths=True,
convert_rectangles_to_paths=True):
"""Convenience function; identical to svg2paths() except that it takes the
svg object as string. See svg2paths() docstring for more
info."""
# wrap string into StringIO object
svg_file_obj = StringIO(svg_string)
return svg2paths(svg_file_location=svg_file_obj,
return_svg_attributes=return_svg_attributes,
convert_circles_to_paths=convert_circles_to_paths,
convert_ellipses_to_paths=convert_ellipses_to_paths,
convert_lines_to_paths=convert_lines_to_paths,
convert_polylines_to_paths=convert_polylines_to_paths,
convert_polygons_to_paths=convert_polygons_to_paths,
convert_rectangles_to_paths=convert_rectangles_to_paths)

19
test/negative-scale.svg Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100mm" height="100mm" viewBox="-100 -200 500 500" xmlns="http://www.w3.org/2000/svg" version="1.1">
<g id="Sketch" transform="scale(1,-1)">
<path id="slot" d="
M 0 10
L 0 80
A 30 30 0 1 0 0 140
A 10 10 0 0 1 0 100
L 100 100
A 10 10 0 1 1 100 140
A 30 30 0 0 0 100 80
L 100 10
A 10 10 0 0 0 90 0
L 10 0
A 10 10 0 0 0 0 10
" stroke="#ff0000" stroke-width="0.35 px"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@ -1,7 +1,7 @@
from __future__ import division, absolute_import, print_function
import numpy as np
import unittest
from svgpathtools.bezier import *
from svgpathtools.bezier import bezier_point, bezier2polynomial, polynomial2bezier
from svgpathtools.path import bpoints2bezier

54
test/test_document.py Normal file
View File

@ -0,0 +1,54 @@
from __future__ import division, absolute_import, print_function
import unittest
from svgpathtools import Document
from io import StringIO
from io import open # overrides build-in open for compatibility with python2
from os.path import join, dirname
from sys import version_info
class TestDocument(unittest.TestCase):
def test_from_file_path_string(self):
"""Test reading svg from file provided as path"""
doc = Document(join(dirname(__file__), 'polygons.svg'))
self.assertEqual(len(doc.paths()), 2)
def test_from_file_path(self):
"""Test reading svg from file provided as path"""
if version_info >= (3, 6):
import pathlib
doc = Document(pathlib.Path(__file__).parent / 'polygons.svg')
self.assertEqual(len(doc.paths()), 2)
def test_from_file_object(self):
"""Test reading svg from file object that has already been opened"""
with open(join(dirname(__file__), 'polygons.svg'), 'r') as file:
doc = Document(file)
self.assertEqual(len(doc.paths()), 2)
def test_from_stringio(self):
"""Test reading svg object contained in a StringIO object"""
with open(join(dirname(__file__), 'polygons.svg'),
'r', encoding='utf-8') as file:
# read entire file into string
file_content = file.read()
# prepare stringio object
file_as_stringio = StringIO(file_content)
doc = Document(file_as_stringio)
self.assertEqual(len(doc.paths()), 2)
def test_from_string(self):
"""Test reading svg object contained in a string"""
with open(join(dirname(__file__), 'polygons.svg'),
'r', encoding='utf-8') as file:
# read entire file into string
file_content = file.read()
doc = Document.from_svg_string(file_content)
self.assertEqual(len(doc.paths()), 2)

View File

@ -2,7 +2,7 @@
#------------------------------------------------------------------------------
from __future__ import division, absolute_import, print_function
import unittest
from svgpathtools import *
from svgpathtools import parse_path
class TestGeneration(unittest.TestCase):

View File

@ -5,11 +5,15 @@ $ python -m unittest test.test_groups.TestGroups.test_group_flatten
"""
from __future__ import division, absolute_import, print_function
import unittest
from svgpathtools import *
from svgpathtools import Document, SVG_NAMESPACE, parse_path, Line, Arc
from os.path import join, dirname
import numpy as np
# When an assert fails, show the full error message, don't truncate it.
unittest.util._MAX_LENGTH = 999999999
def get_desired_path(name, paths):
return next(p for p in paths
if p.element.get('{some://testuri}name') == name)
@ -42,6 +46,22 @@ class TestGroups(unittest.TestCase):
self.check_values(tf.dot(v_s), actual.start)
self.check_values(tf.dot(v_e), actual.end)
def test_group_transform(self):
# The input svg has a group transform of "scale(1,-1)", which
# can mess with Arc sweeps.
doc = Document(join(dirname(__file__), 'negative-scale.svg'))
path = doc.paths()[0]
self.assertEqual(path[0], Line(start=-10j, end=-80j))
self.assertEqual(path[1], Arc(start=-80j, radius=(30+30j), rotation=0.0, large_arc=True, sweep=True, end=-140j))
self.assertEqual(path[2], Arc(start=-140j, radius=(20+20j), rotation=0.0, large_arc=False, sweep=False, end=-100j))
self.assertEqual(path[3], Line(start=-100j, end=(100-100j)))
self.assertEqual(path[4], Arc(start=(100-100j), radius=(20+20j), rotation=0.0, large_arc=True, sweep=False, end=(100-140j)))
self.assertEqual(path[5], Arc(start=(100-140j), radius=(30+30j), rotation=0.0, large_arc=False, sweep=True, end=(100-80j)))
self.assertEqual(path[6], Line(start=(100-80j), end=(100-10j)))
self.assertEqual(path[7], Arc(start=(100-10j), radius=(10+10j), rotation=0.0, large_arc=False, sweep=True, end=(90+0j)))
self.assertEqual(path[8], Line(start=(90+0j), end=(10+0j)))
self.assertEqual(path[9], Arc(start=(10+0j), radius=(10+10j), rotation=0.0, large_arc=False, sweep=True, end=-10j))
def test_group_flatten(self):
# Test the Document.paths() function against the
# groups.svg test file.
@ -236,3 +256,10 @@ class TestGroups(unittest.TestCase):
path = parse_path(path_d)
svg_path = doc.add_path(path, group=new_leaf)
self.assertEqual(path_d, svg_path.get('d'))
# Test that paths are added to the correct group
new_sibling = doc.get_or_add_group(
['base_group', 'new_parent', 'new_sibling'])
doc.add_path(path, group=new_sibling)
self.assertEqual(len(new_sibling), 1)
self.assertEqual(path_d, new_sibling[0].get('d'))

View File

@ -1,8 +1,9 @@
# Note: This file was taken mostly as is from the svg.path module (v 2.0)
from __future__ import division, absolute_import, print_function
import unittest
from svgpathtools import *
from svgpathtools import Path, Line, QuadraticBezier, CubicBezier, Arc, parse_path
import svgpathtools
import numpy as np

View File

@ -1,5 +1,8 @@
# External dependencies
from __future__ import division, absolute_import, print_function
import os
import time
from sys import version_info
import unittest
from math import sqrt, pi
from operator import itemgetter
@ -8,8 +11,12 @@ import random
import warnings
# Internal dependencies
from svgpathtools import *
from svgpathtools.path import _NotImplemented4ArcException, bezier_radialrange
from svgpathtools import (
Line, QuadraticBezier, CubicBezier, Arc, Path, poly2bez, path_encloses_pt,
bpoints2bezier, closest_point_in_path, farthest_point_in_path,
is_bezier_segment, is_bezier_path, parse_path
)
from svgpathtools.path import bezier_radialrange
# An important note for those doing any debugging:
# ------------------------------------------------
@ -515,7 +522,7 @@ class ArcTest(unittest.TestCase):
except ValueError:
self.fail("Arc() raised ValueError unexpectedly!")
def test_points(self):
def test_point(self):
arc1 = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)
self.assertAlmostEqual(arc1.center, 100 + 0j, delta=TOL)
self.assertAlmostEqual(arc1.theta, 180.0, delta=TOL)
@ -717,6 +724,65 @@ class ArcTest(unittest.TestCase):
class TestPath(unittest.TestCase):
def test_hash(self):
line1 = Line(600.5 + 350.5j, 650.5 + 325.5j)
arc1 = Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j)
arc2 = Arc(650 + 325j, 30 + 25j, -30, 0, 0, 700 + 300j)
cub1 = CubicBezier(650 + 325j, 25 + 25j, -30, 700 + 300j)
cub2 = CubicBezier(700 + 300j, 800 + 400j, 750 + 200j, 600 + 100j)
quad3 = QuadraticBezier(600 + 100j, 600, 600 + 300j)
linez = Line(600 + 300j, 600 + 350j)
bezpath = Path(line1, cub1, cub2, quad3)
bezpathz = Path(line1, cub1, cub2, quad3, linez)
path = Path(line1, arc1, cub2, quad3)
pathz = Path(line1, arc1, cub2, quad3, linez)
lpath = Path(linez)
qpath = Path(quad3)
cpath = Path(cub1)
apath = Path(arc1, arc2)
test_curves = [bezpath, bezpathz, path, pathz, lpath, qpath, cpath,
apath, line1, arc1, arc2, cub1, cub2, quad3, linez]
# this is necessary due to changes to the builtin `hash` function
user_hash_seed = os.environ.get("PYTHONHASHSEED", "")
os.environ["PYTHONHASHSEED"] = "314"
if version_info >= (3, 8):
expected_hashes = [
-6073024107272494569, -2519772625496438197, 8726412907710383506,
2132930052750006195, 3112548573593977871, 991446120749438306,
-5589397644574569777, -4438808571483114580, -3125333407400456536,
-4418099728831808951, 702646573139378041, -6331016786776229094,
5053050772929443013, 6102272282813527681, -5385294438006156225
]
elif (3, 2) <= version_info < (3, 8):
expected_hashes = [
-5662973462929734898, 5166874115671195563, 5223434942701471389,
-7224979960884350294, -5178990533869800243, -4003140762934044601,
8575549467429100514, -6692132994808317852, 1594848578230132678,
-6374833902132909499, 4188352014604112779, -5090374009174854814,
-7093907105533857815, 2036243740727202243, -8108488067585685407
]
else:
expected_hashes = [
-5762846476463470127, -138736730317965290, -2005041722222729058,
8448700906794235291, -5178990533869800243, -4003140762934044601,
8575549467429100514, 5166859065265868968, 1373103287265872323,
-1022491904150314631, 4188352014604112779, -5090374009174854814,
-7093907105533857815, 2036243740727202243, -8108488067585685407
]
if version_info.major == 2 and os.name == 'nt':
# the expected hash values for 2.7 apparently differed on Windows
# if you work in Windows and want to fix this test, please do
return
for c, h in zip(test_curves, expected_hashes):
self.assertTrue(hash(c) == h, msg="hash {} was expected for curve = {}".format(h, c))
os.environ["PYTHONHASHSEED"] = user_hash_seed # restore user's hash seed
def test_circle(self):
arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j)
arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j)
@ -1430,6 +1496,50 @@ class Test_intersect(unittest.TestCase):
self.assertTrue(len(yix) == 1)
###################################################################
def test_random_intersections(self):
from random import Random
r = Random()
distance = 100
distribution = 10000
count = 500
def random_complex(offset_x=0.0, offset_y=0.0):
return complex(r.random() * distance + offset_x, r.random() * distance + offset_y)
def random_line():
offset_x = r.random() * distribution
offset_y = r.random() * distribution
return Line(random_complex(offset_x, offset_y), random_complex(offset_x, offset_y))
def random_quad():
offset_x = r.random() * distribution
offset_y = r.random() * distribution
return QuadraticBezier(random_complex(offset_x, offset_y), random_complex(offset_x, offset_y), random_complex(offset_x, offset_y))
def random_cubic():
offset_x = r.random() * distribution
offset_y = r.random() * distribution
return CubicBezier(random_complex(offset_x, offset_y), random_complex(offset_x, offset_y), random_complex(offset_x, offset_y), random_complex(offset_x, offset_y))
def random_path():
path = Path()
for i in range(count):
type_segment = random.randint(0, 3)
if type_segment == 0:
path.append(random_line())
if type_segment == 1:
path.append(random_quad())
if type_segment == 2:
path.append(random_cubic())
return path
path1 = random_path()
path2 = random_path()
t = time.time()
intersections = path1.intersect(path2)
print("\nFound {} intersections in {} seconds.\n"
"".format(len(intersections), time.time() - t))
def test_line_line_0(self):
l0 = Line(start=(25.389999999999997+99.989999999999995j),
end=(25.389999999999997+90.484999999999999j))

View File

@ -4,7 +4,7 @@ import unittest
import numpy as np
# Internal dependencies
from svgpathtools import *
from svgpathtools import rational_limit
class Test_polytools(unittest.TestCase):

View File

@ -1,6 +1,6 @@
from __future__ import division, absolute_import, print_function
import unittest
from svgpathtools import *
from svgpathtools import SaxDocument
from os.path import join, dirname

View File

@ -1,7 +1,16 @@
from __future__ import division, absolute_import, print_function
import unittest
from svgpathtools import *
from svgpathtools import Path, Line, Arc, svg2paths, svgstr2paths
from io import StringIO
from io import open # overrides build-in open for compatibility with python2
import os
from os.path import join, dirname
from sys import version_info
import tempfile
import shutil
from svgpathtools.svg_to_paths import rect2pathd
class TestSVG2Paths(unittest.TestCase):
def test_svg2paths_polygons(self):
@ -50,3 +59,78 @@ class TestSVG2Paths(unittest.TestCase):
self.assertTrue(len(path_circle)==2)
self.assertTrue(path_circle==path_circle_correct)
self.assertTrue(path_circle.isclosed())
# test for issue #198 (circles not being closed)
svg = u"""<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" width="40mm" height="40mm"
viewBox="0 0 40 40" version="1.1">
<g id="layer">
<circle id="c1" cx="20.000" cy="20.000" r="11.000" />
<circle id="c2" cx="20.000" cy="20.000" r="5.15" />
</g>
</svg>"""
tmpdir = tempfile.mkdtemp()
svgfile = os.path.join(tmpdir, 'test.svg')
with open(svgfile, 'w') as f:
f.write(svg)
paths, _ = svg2paths(svgfile)
self.assertEqual(len(paths), 2)
self.assertTrue(paths[0].isclosed())
self.assertTrue(paths[1].isclosed())
shutil.rmtree(tmpdir)
def test_rect2pathd(self):
non_rounded = {"x":"10", "y":"10", "width":"100","height":"100"}
self.assertEqual(rect2pathd(non_rounded), 'M10.0 10.0 L 110.0 10.0 L 110.0 110.0 L 10.0 110.0 z')
rounded = {"x":"10", "y":"10", "width":"100","height":"100", "rx":"15", "ry": "12"}
self.assertEqual(rect2pathd(rounded), "M 25.0 10.0 L 95.0 10.0 A 15.0 12.0 0 0 1 110.0 22.0 L 110.0 98.0 A 15.0 12.0 0 0 1 95.0 110.0 L 25.0 110.0 A 15.0 12.0 0 0 1 10.0 98.0 L 10.0 22.0 A 15.0 12.0 0 0 1 25.0 10.0 z")
def test_from_file_path_string(self):
"""Test reading svg from file provided as path"""
paths, _ = svg2paths(join(dirname(__file__), 'polygons.svg'))
self.assertEqual(len(paths), 2)
def test_from_file_path(self):
"""Test reading svg from file provided as pathlib POSIXPath"""
if version_info >= (3, 6):
import pathlib
paths, _ = svg2paths(pathlib.Path(__file__).parent / 'polygons.svg')
self.assertEqual(len(paths), 2)
def test_from_file_object(self):
"""Test reading svg from file object that has already been opened"""
with open(join(dirname(__file__), 'polygons.svg'), 'r') as file:
paths, _ = svg2paths(file)
self.assertEqual(len(paths), 2)
def test_from_stringio(self):
"""Test reading svg object contained in a StringIO object"""
with open(join(dirname(__file__), 'polygons.svg'),
'r', encoding='utf-8') as file:
# read entire file into string
file_content = file.read()
# prepare stringio object
file_as_stringio = StringIO(file_content)
paths, _ = svg2paths(file_as_stringio)
self.assertEqual(len(paths), 2)
def test_from_string(self):
"""Test reading svg object contained in a string"""
with open(join(dirname(__file__), 'polygons.svg'),
'r', encoding='utf-8') as file:
# read entire file into string
file_content = file.read()
paths, _ = svgstr2paths(file_content)
self.assertEqual(len(paths), 2)
if __name__ == '__main__':
unittest.main()