pax_global_header00006660000000000000000000000064144232377560014527gustar00rootroot0000000000000052 comment=62a8a94d9c0f9be1b2bf18418b332e5b78140c9d svg.path-6.3/000077500000000000000000000000001442323775600131315ustar00rootroot00000000000000svg.path-6.3/.github/000077500000000000000000000000001442323775600144715ustar00rootroot00000000000000svg.path-6.3/.github/workflows/000077500000000000000000000000001442323775600165265ustar00rootroot00000000000000svg.path-6.3/.github/workflows/integration.yml000066400000000000000000000017421442323775600216000ustar00rootroot00000000000000# Runs the unit tests for the svg.path package # Based on https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Test package on: [pull_request, push] jobs: build: name: Run package tests runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ['3.7', '3.11'] steps: - name: Checkout uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Upgrade pip run: python -m pip install --upgrade pip - name: Install tools run: pip install flake8 black - name: Install package run: pip install -e ".[test]" - name: Run black run: black --quiet --check . - name: Run flake8 run: flake8 . - name: Run tests run: pytest svg.path-6.3/.gitignore000066400000000000000000000005311442323775600151200ustar00rootroot00000000000000*.py[cod] *$py.class # C extensions *.so # Packages *.egg *.egg-info dist build eggs parts bin var sdist develop-eggs .installed.cfg lib lib64 .venv # Installer logs pip-log.txt *.wpr *.wpu # Unit test / coverage reports .coverage .tox nosetests.xml # Translations *.mo # Mr Developer .mr.developer.cfg .project .pydevproject *.swp *.swo svg.path-6.3/CHANGES.txt000066400000000000000000000146501442323775600147500ustar00rootroot00000000000000Changelog ========= 6.3 (2023-04-29) ---------------- - Fixed serialization of 'H'/'h' command. - New boundingbox() methods allow you to get the boundingbox() for paths.[twjang] - Tests are now outside the source directory, which is common practice now. - Drops Python 3.6 and 3.7, adds support for 3.10 and 3.11. 6.2 (2022-06-17) ---------------- - Allow numbers with decimal point but no decimals, because other parsers do. - Re-enabled the README.rst doctest, which got lost when switching to pytest. 6.1 (2022-06-09) ---------------- - Not all path segments preserved the relative setting. [Lucas-C] 6.0 (2022-04-14) ---------------- - No functional changes from 6.0b1, only changes to tests. 6.0b1 (2022-04-02) ------------------ - Added new abstract base classes: PathSegment, and NonLinear. Also, Linear is now derived from PathSegment, and may become abstract in the future. - Added smooth support: - CubicBezier and QuadraticBezier now has a "smooth" flag, that will be set when parsing if the SVG path had a smooth segment. - A path element will now only be designated as a smooth segment if it has the smooth flag set. That means a path that *is* smooth but not parsed from smooth (S and T) segments will not be represented as smooth. The path segment must also be smooth, so if you parse a path with a smooth segment, and modify it so it isn't smooth, it will not be represented as smooth, regardless of the flag. - CubicBezier and QuadraticBezier now has a "set_smooth_from" flag, that will adjust the start point and first control point so that the curve is smooth. It also sets the smooth flag. - Added support to preserve vertical/horizontal commands. - Refactored the generation of SVG path texts, each segment now generates its own segment text, with a `_d(self, previous)` method. 5.1 (2022-03-23) ---------------- - Added SVG standard tests. [tatarize] - Allow random characters to end parsing. - #61: Fixed the length() calculations of nearly linear QuadradicBeziers. [tatarize, regebro] 5.0.1 (2022-03-21) ------------------ - Two new test files were omitted from the distributions. 5.0.0 (2022-03-21) ------------------ - Drop Python 2 support, also 3.4 to 3.6. New minimum Python version is 3.7. - New parser that solves the issue with Arc flags and whitespace. See Issues #53 and #69. - Fixed #60: Handle paths that are length 0 [Thanks to martinleopold and tatarize] - New method on path objects: `.tangent(point)`, which returns a vector that is the derivatative / tangent of the curve at that point. [vidstige] - New graphical test. That test requires Pillow, so I stopped testing on PyPy, it got too complicated to support. But it still works on PyPy. 4.1 (2021-02-16) ---------------- - Use collections.abc for ABC import to add Python 3.9 compatibility. 4.0.2 (2019-11-04) ------------------ - A solution for the setup.cfg [Alex Grönholm] 4.0.1 (2019-11-03) ------------------ else: raise - The pure setup.cfg config didn't work. All the tests pass fine, but when installing the package somewhere else, nothing gets installed. So I'm reverting that change for now. 4.0 (2019-11-02) ---------------- - Moved all the information from setup.py into setup.cfg. - Added a Close() command which is different from a Line() command in no way at all, to simplify the handling of closepath commands and subpaths. - Path()'s no longer have a `closed` attribute. - Now fully supports the SVG 1.1 "F.6.2 Out-of-range parameters" list. - Uses circular maths to calculate the length of circular arcs, more accurate and much faster. 3.1 (2019-10-25) ---------------- - The Move null command was not imported into ``__init__.py`` [blokhin] - #41: Switched from ``pkg_resource``-style namespace package for ``svg`` to a `pkgutil style `_ namespace package. - A faster ``point()`` implementation for paths. [ClayJarCom] - Dropped support for Python 2.6 and Python 3.3. - Added support for Python 3.7 and 3.8. 3.0 (2018-08-14) ---------------- - Dropped support for Python 3.1 and 3.2. It still works, but it may stop. Added support for Python 3.6. Dropped support for Jython, it's not supported by Travis, and hasn't seen a release in over a year. - #33: Move commands are now preserved when parsed. - Subpaths are no longer merged even if they are joined. - #30: Arcs where the endpoint is the same as the start point caused a crash. The SVG specs say that it instead should be the equavalent of skipping that section, which now is the case. 2.2 (2016-10-15) ---------------- - Don't add a line when closing a path if it's not needed. 2.1.1 (2016-02-28) ------------------ - #18: QuadraticBeziers could get a DivideByZero error under certain circumstances. [MTician] - Accept an error parameter to Path.point() to be able to control error vs performance setting. [saschwarz] - #25: Arc's could create a MathDomain error under certain circumstances. - #17: Set last_command always. 2.0.1 (2015-10-17) ------------------ - #20: The doctext for the closed() setter was incorrect. - #19: Fixed so tests didn't use relative paths. [danstender] 2.0 (2015-05-15) ---------------- - Nothing changed yet. 2.0b1 (2014-11-06) ------------------ - Added a Path.d() function to generate the Path's d attribute. - Added is_smooth_from() on QubicBezier and QuadradicBezier. - Path()'s now have a .closed property. - Fixed the representation so it's parseable. - The calculations for CubicBezier and Arc segments are now recursive, and will end when a specific accuracy has been achieved. This is somewhat faster for Arcs and somewhat slower for CubicBezier. However, you can now specify an accuracy, so if you want faster but looser calculations, you can have that. - 't' segments (smooth, relative QuadraticBeziers) whose previous segment was not a QuadraticBezier would get an incorrect control point. 1.2 (2014-11-01) ---------------- - New Quadradic Bezier implementation. [Justin Gruenberg] - Solved issue #6: Z close path behavior. [abcjjy] 1.1 (2013-10-19) ---------------- - Floats with negative exponents work again. - New tokenizer that is around 20 times faster. 1.0 (2013-05-28) ---------------- - Solved issue #2: Paths with negative values and no spaces didn't work. [regebro] 1.0b1 (2013-02-03) ------------------ - Original release. svg.path-6.3/CONTRIBUTORS.txt000066400000000000000000000016451442323775600156350ustar00rootroot00000000000000Contributors ============ Lennart Regebro , Original Author Justin Gruenberg implemented the Quadradic Bezier calculations and provided suggestions and feedback about the d() function. Michiel Schallig suggested calculating length by recursive straight-line approximations, which enables you to choose between accuracy or speed. Steve Schwarz added an error argument to make that choice an argument. ClayJarCom speeded up `point()` calculations for paths. Thanks also to bug fixers Martin R, abcjjy, Daniel Stender, MTician, blokhin, Karthikeyan, jaraco, martinleopold and twjang. Thanks to tatarize for help with investigating issues, and coming with much feedback and ideas. Samuel Carlsson [vidstige] provided the `tangent()` functions. Lucas Simon discovered and fixed that not all path segments preserved the relative setting when parsing. Taewoong Jang [twjang] implemented boundingbox functions. svg.path-6.3/LICENSE.txt000066400000000000000000000020771442323775600147620ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2013-2014 Lennart Regebro Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. svg.path-6.3/MANIFEST.in000066400000000000000000000001111442323775600146600ustar00rootroot00000000000000include *.rst include *.txt exclude Makefile recursive-exclude tests * svg.path-6.3/Makefile000066400000000000000000000015611442323775600145740ustar00rootroot00000000000000root_dir := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) ifdef NO_VENV bin_dir := python_exe := python3 endif ifndef NO_VENV bin_dir := $(root_dir)/ve/bin/ python_exe := $(bin_dir)python3 endif all: devenv check test # The fullrelease script is a part of zest.releaser, which is the last # package installed, so if it exists, the devenv is installed. devenv: $(bin_dir)fullrelease setup.cfg $(bin_dir): virtualenv ve --python python3.9 $(bin_dir)fullrelease: $(bin_dir) $(python_exe) -m pip install -e .[test] check: devenv $(bin_dir)black src tests $(bin_dir)flake8 src tests $(bin_dir)pyroma -d . $(bin_dir)check-manifest coverage: devenv $(bin_dir)coverage run $(bin_dir)pytest $(bin_dir)coverage html $(bin_dir)coverage report test: devenv $(bin_dir)pytest release: $(bin_dir)fullrelease clean: rm -rf ve .coverage htmlcov build .pytest_cache svg.path-6.3/README.rst000066400000000000000000000115121442323775600146200ustar00rootroot00000000000000svg.path ======== svg.path is a collection of objects that implement the different path commands in SVG, and a parser for SVG path definitions. Usage ----- There are four path segment objects, ``Line``, ``Arc``, ``CubicBezier`` and ``QuadraticBezier``.`There is also a ``Path`` object that acts as a collection of the path segment objects. All coordinate values for these classes are given as ``complex`` values, where the ``.real`` part represents the X coordinate, and the ``.imag`` part representes the Y coordinate:: >>> from svg.path import Path, Move, Line, Arc, CubicBezier, QuadraticBezier, Close All of these objects have a ``.point()`` function which will return the coordinates of a point on the path, where the point is given as a floating point value where ``0.0`` is the start of the path and ``1.0`` is the end. You can calculate the length of a Path or it's segments with the ``.length()`` function. For CubicBezier and Arc segments this is done by geometric approximation and for this reason **may be very slow**. You can make it faster by passing in an ``error`` option to the method. If you don't pass in error, it defaults to ``1e-12``:: >>> CubicBezier(300+100j, 100+100j, 200+200j, 200+300j).length(error=1e-5) 297.2208145656899 CubicBezier and Arc also has a ``min_depth`` option that specifies the minimum recursion depth. This is set to 5 by default, resulting in using a minimum of 32 segments for the calculation. Setting it to 0 is a bad idea for CubicBeziers, as they may become approximated to a straight line. ``Line.length()`` and ``QuadraticBezier.length()`` also takes these parameters, but they are ignored. CubicBezier and QuadraticBezier also has ``is_smooth_from(previous)`` methods, that check if the segment is a "smooth" segment compared to the given segment. There is also a ``parse_path()`` function that will take an SVG path definition and return a ``Path`` object:: >>> from svg.path import parse_path >>> parse_path('M 100 100 L 300 100') Path(Move(to=(100+100j)), Line(start=(100+100j), end=(300+100j))) Classes ....... These are the SVG path segment classes. See the `SVG specifications `_ for more information on what each parameter means. * ``Line(start, end)`` * ``Arc(start, radius, rotation, arc, sweep, end)`` * ``QuadraticBezier(start, control, end)`` * ``CubicBezier(start, control1, control2, end)`` In addition to that, there is the ``Path`` class, which is instantiated with a sequence of path segments: * ``Path(*segments)`` The ``Path`` class is a mutable sequence, so it behaves like a list. You can add to it and replace path segments etc:: >>> path = Path(Move(200+100j), Line(200+100j,100+200j), Line(100+200j,300+100j)) >>> path.append(QuadraticBezier(300+100j, 200+200j, 200+300j)) >>> path[0] = Move(200+100j) >>> del path[1] The path object also has a ``d()`` method that will return the SVG representation of the Path segments:: >>> path.d() 'M 200,100 L 300,100 Q 200,200 200,300' Note that there currently is no internal consistency checks when you manipulate lines this way. This path now has an internal representation that it's different from it's d() path. Notice how the `Line()` segment starts in a different location from where the `Move()` segments say. This **may** change in future releases, and the Path manipulation methods **may** be changed to ensure consistency. >>> path Path(Move(to=(200+100j)), Line(start=(100+200j), end=(300+100j)), QuadraticBezier(start=(300+100j), control=(200+200j), end=(200+300j), smooth=False)) Examples ........ This SVG path example draws a triangle:: >>> path1 = parse_path('M 100 100 L 300 100 L 200 300 z') You can format SVG paths in many different ways, all valid paths should be accepted:: >>> path2 = parse_path('M100,100L300,100L200,300z') And these paths should be equal:: >>> path1 == path2 True You can also build a path from objects:: >>> path3 = Path(Line(100+100j,300+100j), Line(300+100j, 200+300j), Line(200+300j, 100+100j)) And it should again be equal to the first path:: >>> path1 == path2 True Paths are mutable sequences, you can slice and append:: >>> path1.append(QuadraticBezier(300+100j, 200+200j, 200+300j)) >>> len(path1[2:]) == 3 True Note that there is no protection against you creating paths that are invalid. You can for example have a Close command that doesn't end at the path start:: >>> wrong = Path(Line(100+100j,200+100j), Close(200+300j, 0)) Future features --------------- * Reversing paths. They should then reasonably be drawn "backwards" meaning each path segment also needs to be reversed. * Mathematical transformations might make sense. * Verifying that paths are correct, or protection against creating incorrect paths. License ------- This module is under a MIT License. svg.path-6.3/setup.cfg000066400000000000000000000023611442323775600147540ustar00rootroot00000000000000[metadata] name = svg.path version = 6.3 description = SVG path objects and parser long_description = file: README.rst, CONTRIBUTORS.txt, CHANGES.txt classifiers = Development Status :: 5 - Production/Stable Intended Audience :: Developers License :: OSI Approved :: MIT License Operating System :: OS Independent Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3 :: Only Programming Language :: Python :: Implementation :: PyPy Topic :: Multimedia :: Graphics keywords = svg, path, maths author = Lennart Regebro author_email = regebro@gmail.com url = https://github.com/regebro/svg.path license = MIT [options] python_requires = >=3.8 zip_safe = True include_package_data = True packages = find: package_dir = = src [options.packages.find] where = src [options.extras_require] test = pytest pytest-cov Pillow black flake8 pyroma check-manifest zest.releaser[recommended] [flake8] max-line-length=120 [bdist_wheel] universal=1 [tool:pytest] testpaths = tests svg.path-6.3/setup.py000066400000000000000000000000461442323775600146430ustar00rootroot00000000000000from setuptools import setup setup() svg.path-6.3/src/000077500000000000000000000000001442323775600137205ustar00rootroot00000000000000svg.path-6.3/src/svg/000077500000000000000000000000001442323775600145175ustar00rootroot00000000000000svg.path-6.3/src/svg/__init__.py000066400000000000000000000001011442323775600166200ustar00rootroot00000000000000__path__ = __import__("pkgutil").extend_path(__path__, __name__) svg.path-6.3/src/svg/path/000077500000000000000000000000001442323775600154535ustar00rootroot00000000000000svg.path-6.3/src/svg/path/__init__.py000066400000000000000000000003421442323775600175630ustar00rootroot00000000000000from .path import Path, Move, Line, Arc, Close # noqa: 401 from .path import CubicBezier, QuadraticBezier # noqa: 401 from .path import PathSegment, Linear, NonLinear # noqa: 401 from .parser import parse_path # noqa: 401 svg.path-6.3/src/svg/path/parser.py000066400000000000000000000216731442323775600173320ustar00rootroot00000000000000# SVG Path specification parser import re from svg.path import path COMMANDS = set("MmZzLlHhVvCcSsQqTtAa") UPPERCASE = set("MZLHVCSQTA") COMMAND_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])") FLOAT_RE = re.compile(rb"^[-+]?\d*\.?\d*(?:[eE][-+]?\d+)?") class InvalidPathError(ValueError): pass # The argument sequences from the grammar, made sane. # u: Non-negative number # s: Signed number or coordinate # c: coordinate-pair, which is two coordinates/numbers, separated by whitespace # f: A one character flag, doesn't need whitespace, 1 or 0 ARGUMENT_SEQUENCE = { "M": "c", "Z": "", "L": "c", "H": "s", "V": "s", "C": "ccc", "S": "cc", "Q": "cc", "T": "c", "A": "uusffc", } def strip_array(arg_array): """Strips whitespace and commas""" # EBNF wsp:(#x20 | #x9 | #xD | #xA) + comma: 0x2C while arg_array and arg_array[0] in (0x20, 0x9, 0xD, 0xA, 0x2C): arg_array[0:1] = b"" def pop_number(arg_array): res = FLOAT_RE.search(arg_array) if not res or not res.group(): raise InvalidPathError(f"Expected a number, got '{arg_array}'.") number = float(res.group()) start = res.start() end = res.end() arg_array[start:end] = b"" strip_array(arg_array) return number def pop_unsigned_number(arg_array): number = pop_number(arg_array) if number < 0: raise InvalidPathError(f"Expected a non-negative number, got '{number}'.") return number def pop_coordinate_pair(arg_array): x = pop_number(arg_array) y = pop_number(arg_array) return complex(x, y) def pop_flag(arg_array): flag = arg_array[0] arg_array[0:1] = b"" strip_array(arg_array) if flag == 48: # ASCII 0 return False if flag == 49: # ASCII 1 return True FIELD_POPPERS = { "u": pop_unsigned_number, "s": pop_number, "c": pop_coordinate_pair, "f": pop_flag, } def _commandify_path(pathdef): """Splits path into commands and arguments""" token = None for x in COMMAND_RE.split(pathdef): x = x.strip() if x in COMMANDS: if token is not None: yield token if x in ("z", "Z"): # The end command takes no arguments, so add a blank one token = (x, "") else: token = (x,) elif x: if token is None: raise InvalidPathError(f"Path does not start with a command: {pathdef}") token += (x,) yield token def _tokenize_path(pathdef): for command, args in _commandify_path(pathdef): # Shortcut this for the close command, that doesn't have arguments: if command in ("z", "Z"): yield (command,) continue # For the rest of the commands, we parse the arguments and # yield one command per full set of arguments arg_sequence = ARGUMENT_SEQUENCE[command.upper()] arguments = bytearray(args, "ascii") implicit = False while arguments: command_arguments = [] for i, arg in enumerate(arg_sequence): try: command_arguments.append(FIELD_POPPERS[arg](arguments)) except InvalidPathError as e: if i == 0 and implicit: return # Invalid character in path, treat like a comment raise InvalidPathError( f"Invalid path element {command} {args}" ) from e yield (command,) + tuple(command_arguments) implicit = True # Implicit Moveto commands should be treated as Lineto commands. if command == "m": command = "l" elif command == "M": command = "L" def parse_path(pathdef): segments = path.Path() start_pos = None last_command = None current_pos = 0 for token in _tokenize_path(pathdef): command = token[0] relative = command.islower() command = command.upper() if command == "M": pos = token[1] if relative: current_pos += pos else: current_pos = pos segments.append(path.Move(current_pos, relative=relative)) start_pos = current_pos elif command == "Z": # For Close commands the "relative" argument just preserves case, # it has no different in behavior. segments.append(path.Close(current_pos, start_pos, relative=relative)) current_pos = start_pos elif command == "L": pos = token[1] if relative: pos += current_pos segments.append(path.Line(current_pos, pos, relative=relative)) current_pos = pos elif command == "H": hpos = token[1] if relative: hpos += current_pos.real pos = complex(hpos, current_pos.imag) segments.append( path.Line(current_pos, pos, relative=relative, horizontal=True) ) current_pos = pos elif command == "V": vpos = token[1] if relative: vpos += current_pos.imag pos = complex(current_pos.real, vpos) segments.append( path.Line(current_pos, pos, relative=relative, vertical=True) ) current_pos = pos elif command == "C": control1 = token[1] control2 = token[2] end = token[3] if relative: control1 += current_pos control2 += current_pos end += current_pos segments.append( path.CubicBezier( current_pos, control1, control2, end, relative=relative ) ) current_pos = end elif command == "S": # Smooth curve. First control point is the "reflection" of # the second control point in the previous path. control2 = token[1] end = token[2] if relative: control2 += current_pos end += current_pos if last_command in "CS": # The first control point is assumed to be the reflection of # the second control point on the previous command relative # to the current point. control1 = current_pos + current_pos - segments[-1].control2 else: # If there is no previous command or if the previous command # was not an C, c, S or s, assume the first control point is # coincident with the current point. control1 = current_pos segments.append( path.CubicBezier( current_pos, control1, control2, end, relative=relative, smooth=True ) ) current_pos = end elif command == "Q": control = token[1] end = token[2] if relative: control += current_pos end += current_pos segments.append( path.QuadraticBezier(current_pos, control, end, relative=relative) ) current_pos = end elif command == "T": # Smooth curve. Control point is the "reflection" of # the second control point in the previous path. end = token[1] if relative: end += current_pos if last_command in "QT": # The control point is assumed to be the reflection of # the control point on the previous command relative # to the current point. control = current_pos + current_pos - segments[-1].control else: # If there is no previous command or if the previous command # was not an Q, q, T or t, assume the first control point is # coincident with the current point. control = current_pos segments.append( path.QuadraticBezier( current_pos, control, end, smooth=True, relative=relative ) ) current_pos = end elif command == "A": # For some reason I implemented the Arc with a complex radius. # That doesn't really make much sense, but... *shrugs* radius = complex(token[1], token[2]) rotation = token[3] arc = token[4] sweep = token[5] end = token[6] if relative: end += current_pos segments.append( path.Arc( current_pos, radius, rotation, arc, sweep, end, relative=relative ) ) current_pos = end # Finish up the loop in preparation for next command last_command = command return segments svg.path-6.3/src/svg/path/path.py000066400000000000000000000706351442323775600167740ustar00rootroot00000000000000from math import sqrt, cos, sin, acos, degrees, radians, log, pi from bisect import bisect from abc import ABC, abstractmethod import math try: from collections.abc import MutableSequence except ImportError: from collections import MutableSequence # This file contains classes for the different types of SVG path segments as # well as a Path object that contains a sequence of path segments. MIN_DEPTH = 5 ERROR = 1e-12 def _find_solutions_for_bezier(c2, c1, c0): """Find solutions of c2 * t^2 + c1 * t + c0 = 0 where t in [0, 1]""" soln = [] if c2 == 0: if c1 != 0: soln.append(-c0 / c1) else: det = c1**2 - 4 * c2 * c0 if det >= 0: soln.append((-c1 + math.pow(det, 0.5)) / 2.0 / c2) soln.append((-c1 - math.pow(det, 0.5)) / 2.0 / c2) return [s for s in soln if 0.0 <= s and s <= 1.0] def _find_solutions_for_arc(a, b, c, d): """Find solution for a sin(x) + b cos(x) = 0 where x = c + d * t and t in [0, 1]""" if a == 0: # when n \in Z # pi / 2 + pi * n = c + d * t # --> n = d / pi * t - (1/2 - c/pi) # --> t = (pi / 2 - c + pi * n) / d n_ranges = [-0.5 + c / math.pi, d / math.pi - 0.5 + c / math.pi] n_range_start = math.floor(min(n_ranges)) n_range_end = math.ceil(max(n_ranges)) t_list = [ (math.pi / 2 - c + math.pi * n) / d for n in range(n_range_start, n_range_end + 1) ] elif b == 0: # when n \in Z # pi * n = c + d * t # --> n = d / pi * t + c / pi # --> t = (- c + pi * n) / d n_ranges = [c / math.pi, d / math.pi + c / math.pi] n_range_start = math.floor(min(n_ranges)) n_range_end = math.ceil(max(n_ranges)) t_list = [(-c + math.pi * n) / d for n in range(n_range_start, n_range_end + 1)] else: # when n \in Z # arct = tan^-1 (- b / a) and # arct + pi * n = c + d * t # --> n = (c - arct + d * t) / pi # --> t = (arct - c + pi * n) / d arct = math.atan(-b / a) n_ranges = [(c - arct) / math.pi, d / math.pi + (c - arct) / math.pi] n_range_start = math.floor(min(n_ranges)) n_range_end = math.ceil(max(n_ranges)) t_list = [ (arct - c + math.pi * n) / d for n in range(n_range_start, n_range_end + 1) ] t_list = [t for t in t_list if 0.0 <= t and t <= 1.0] return t_list def segment_length(curve, start, end, start_point, end_point, error, min_depth, depth): """Recursively approximates the length by straight lines""" mid = (start + end) / 2 mid_point = curve.point(mid) length = abs(end_point - start_point) first_half = abs(mid_point - start_point) second_half = abs(end_point - mid_point) length2 = first_half + second_half if (length2 - length > error) or (depth < min_depth): # Calculate the length of each segment: depth += 1 return segment_length( curve, start, mid, start_point, mid_point, error, min_depth, depth ) + segment_length( curve, mid, end, mid_point, end_point, error, min_depth, depth ) # This is accurate enough. return length2 class PathSegment(ABC): @abstractmethod def point(self, pos): """Returns the coordinate point (as a complex number) of a point on the path, as expressed as a floating point number between 0 (start) and 1 (end). """ @abstractmethod def tangent(self, pos): """Returns a vector (as a complex number) representing the tangent of a point on the path as expressed as a floating point number between 0 (start) and 1 (end). """ @abstractmethod def length(self, error=ERROR, min_depth=MIN_DEPTH): """Returns the length of a path. The CubicBezier and Arc lengths are non-exact and iterative and you can select to either do the calculations until a maximum error has been achieved, or a minimum number of iterations. """ @abstractmethod def boundingbox(self): """Returns the bounding box of a path in the format of [left, top, right, bottom]""" class NonLinear(PathSegment): """A line that is not straight The base of Arc, QuadraticBezier and CubicBezier """ class Linear(PathSegment): """A straight line The base for Line() and Close(). """ def __init__(self, start, end, relative=False): self.start = start self.end = end self.relative = relative def __ne__(self, other): if not isinstance(other, Line): return NotImplemented return not self == other def point(self, pos): distance = self.end - self.start return self.start + distance * pos def tangent(self, pos): return self.end - self.start def length(self, error=None, min_depth=None): distance = self.end - self.start return sqrt(distance.real**2 + distance.imag**2) class Line(Linear): def __init__(self, start, end, relative=False, vertical=False, horizontal=False): self.start = start self.end = end self.relative = relative self.vertical = vertical self.horizontal = horizontal def __repr__(self): return f"Line(start={self.start}, end={self.end})" def __eq__(self, other): if not isinstance(other, Line): return NotImplemented return self.start == other.start and self.end == other.end def _d(self, previous): x = self.end.real y = self.end.imag if self.relative: x -= previous.end.real y -= previous.end.imag if self.horizontal and self.is_horizontal_from(previous): cmd = "h" if self.relative else "H" return f"{cmd} {x:G}" if self.vertical and self.is_vertical_from(previous): cmd = "v" if self.relative else "V" return f"{cmd} {y:G}" cmd = "l" if self.relative else "L" return f"{cmd} {x:G},{y:G}" def is_vertical_from(self, previous): return self.start == previous.end and self.start.real == self.end.real def is_horizontal_from(self, previous): return self.start == previous.end and self.start.imag == self.end.imag def boundingbox(self): x_min = min(self.start.real, self.end.real) x_max = max(self.start.real, self.end.real) y_min = min(self.start.imag, self.end.imag) y_max = max(self.start.imag, self.end.imag) return [x_min, y_min, x_max, y_max] class CubicBezier(NonLinear): def __init__(self, start, control1, control2, end, relative=False, smooth=False): self.start = start self.control1 = control1 self.control2 = control2 self.end = end self.relative = relative self.smooth = smooth def __repr__(self): return ( f"CubicBezier(start={self.start}, control1={self.control1}, " f"control2={self.control2}, end={self.end}, smooth={self.smooth})" ) def __eq__(self, other): if not isinstance(other, CubicBezier): return NotImplemented return ( self.start == other.start and self.end == other.end and self.control1 == other.control1 and self.control2 == other.control2 ) def __ne__(self, other): if not isinstance(other, CubicBezier): return NotImplemented return not self == other def _d(self, previous): c1 = self.control1 c2 = self.control2 end = self.end if self.relative and previous: c1 -= previous.end c2 -= previous.end end -= previous.end if self.smooth and self.is_smooth_from(previous): cmd = "s" if self.relative else "S" return f"{cmd} {c2.real:G},{c2.imag:G} {end.real:G},{end.imag:G}" cmd = "c" if self.relative else "C" return f"{cmd} {c1.real:G},{c1.imag:G} {c2.real:G},{c2.imag:G} {end.real:G},{end.imag:G}" def is_smooth_from(self, previous): """Checks if this segment would be a smooth segment following the previous""" if isinstance(previous, CubicBezier): return self.start == previous.end and (self.control1 - self.start) == ( previous.end - previous.control2 ) else: return self.control1 == self.start def set_smooth_from(self, previous): assert isinstance(previous, CubicBezier) self.start = previous.end self.control1 = previous.end - previous.control2 + self.start self.smooth = True def point(self, pos): """Calculate the x,y position at a certain position of the path""" return ( ((1 - pos) ** 3 * self.start) + (3 * (1 - pos) ** 2 * pos * self.control1) + (3 * (1 - pos) * pos**2 * self.control2) + (pos**3 * self.end) ) def tangent(self, pos): return ( -3 * (1 - pos) ** 2 * self.start + 3 * (1 - pos) ** 2 * self.control1 - 6 * pos * (1 - pos) * self.control1 - 3 * pos**2 * self.control2 + 6 * pos * (1 - pos) * self.control2 + 3 * pos**2 * self.end ) def length(self, error=ERROR, min_depth=MIN_DEPTH): """Calculate the length of the path up to a certain position""" start_point = self.point(0) end_point = self.point(1) return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0) def boundingbox(self): """Calculate the bounding box of a cubic Bezier curve. A cubic Bezier curve and its derivative are given as follows. P(t) = (1-t)^3 P0 + 3 t (1-t)^2 P1 + 3 t^2 (1-t) P2 + t^3 P_3 P'(t) = 3(1-t)^2 (P1-P0) + 6(1-t)t (P2-P1) + 3 t^2 (P3 - P2) """ g0 = self.control1 - self.start g1 = self.control2 - self.control1 g2 = self.end - self.control2 c0 = 3 * g0 c1 = -6 * g0 + 6 * g1 c2 = 3 * g0 - 6 * g1 + 3 * g2 x_c0, x_c1, x_c2 = [c.real for c in [c0, c1, c2]] y_c0, y_c1, y_c2 = [c.imag for c in [c0, c1, c2]] x_cand = [0, 1] + _find_solutions_for_bezier(x_c2, x_c1, x_c0) y_cand = [0, 1] + _find_solutions_for_bezier(y_c2, y_c1, y_c0) x_coords = [] y_coords = [] for t in x_cand: p = self.point(t) x_coords.append(p.real) for t in y_cand: p = self.point(t) y_coords.append(p.imag) x_min, x_max = min(x_coords), max(x_coords) y_min, y_max = min(y_coords), max(y_coords) return [x_min, y_min, x_max, y_max] class QuadraticBezier(NonLinear): def __init__(self, start, control, end, relative=False, smooth=False): self.start = start self.end = end self.control = control self.relative = relative self.smooth = smooth def __repr__(self): return ( f"QuadraticBezier(start={self.start}, control={self.control}, " f"end={self.end}, smooth={self.smooth})" ) def __eq__(self, other): if not isinstance(other, QuadraticBezier): return NotImplemented return ( self.start == other.start and self.end == other.end and self.control == other.control ) def __ne__(self, other): if not isinstance(other, QuadraticBezier): return NotImplemented return not self == other def _d(self, previous): control = self.control end = self.end if self.relative and previous: control -= previous.end end -= previous.end if self.smooth and self.is_smooth_from(previous): cmd = "t" if self.relative else "T" return f"{cmd} {end.real:G},{end.imag:G}" cmd = "q" if self.relative else "Q" return f"{cmd} {control.real:G},{control.imag:G} {end.real:G},{end.imag:G}" def is_smooth_from(self, previous): """Checks if this segment would be a smooth segment following the previous""" if isinstance(previous, QuadraticBezier): return self.start == previous.end and (self.control - self.start) == ( previous.end - previous.control ) else: return self.control == self.start def set_smooth_from(self, previous): assert isinstance(previous, QuadraticBezier) self.start = previous.end self.control = previous.end - previous.control + self.start self.smooth = True def point(self, pos): return ( (1 - pos) ** 2 * self.start + 2 * (1 - pos) * pos * self.control + pos**2 * self.end ) def tangent(self, pos): return ( self.start * (2 * pos - 2) + (2 * self.end - 4 * self.control) * pos + 2 * self.control ) def length(self, error=None, min_depth=None): a = self.start - 2 * self.control + self.end b = 2 * (self.control - self.start) try: # For an explanation of this case, see # http://www.malczak.info/blog/quadratic-bezier-curve-length/ A = 4 * (a.real**2 + a.imag**2) B = 4 * (a.real * b.real + a.imag * b.imag) C = b.real**2 + b.imag**2 Sabc = 2 * sqrt(A + B + C) A2 = sqrt(A) A32 = 2 * A * A2 C2 = 2 * sqrt(C) BA = B / A2 s = ( A32 * Sabc + A2 * B * (Sabc - C2) + (4 * C * A - B**2) * log((2 * A2 + BA + Sabc) / (BA + C2)) ) / (4 * A32) except (ZeroDivisionError, ValueError): if abs(a) < 1e-10: s = abs(b) else: k = abs(b) / abs(a) if k >= 2: s = abs(b) - abs(a) else: s = abs(a) * (k**2 / 2 - k + 1) return s def boundingbox(self): """Calculate the bounding box of a quadratic Bezier curve. A quadratic Bezier curve and its derivative are given as follows. P(t) = (1-t)^2 P0 + 2 t (1-t)^2 P1 + t^2 P2 P'(t) = 2(1-t) (P1-P0) + 2t (P2-P1) """ g0 = self.control - self.start g1 = self.end - self.control c0 = 2 * g0 c1 = -2 * g0 + 2 * g1 x_c0, x_c1 = [c.real for c in [c0, c1]] y_c0, y_c1 = [c.imag for c in [c0, c1]] x_cand = [0, 1] + _find_solutions_for_bezier(0, x_c1, x_c0) y_cand = [0, 1] + _find_solutions_for_bezier(0, y_c1, y_c0) x_coords = [] y_coords = [] for t in x_cand: p = self.point(t) x_coords.append(p.real) for t in y_cand: p = self.point(t) y_coords.append(p.imag) x_min, x_max = min(x_coords), max(x_coords) y_min, y_max = min(y_coords), max(y_coords) return [x_min, y_min, x_max, y_max] class Arc(NonLinear): def __init__(self, start, radius, rotation, arc, sweep, end, relative=False): """radius is complex, rotation is in degrees, large and sweep are 1 or 0 (True/False also work)""" self.start = start self.radius = radius self.rotation = rotation self.arc = bool(arc) self.sweep = bool(sweep) self.end = end self.relative = relative self._parameterize() def __repr__(self): return ( f"Arc(start={self.start}, radius={self.radius}, rotation={self.rotation}, " f"arc={self.arc}, sweep={self.sweep}, end={self.end})" ) def __eq__(self, other): if not isinstance(other, Arc): return NotImplemented return ( self.start == other.start and self.end == other.end and self.radius == other.radius and self.rotation == other.rotation and self.arc == other.arc and self.sweep == other.sweep ) def __ne__(self, other): if not isinstance(other, Arc): return NotImplemented return not self == other def _d(self, previous): end = self.end cmd = "a" if self.relative else "A" if self.relative: end -= previous.end return ( f"{cmd} {self.radius.real:G},{self.radius.imag:G} {self.rotation:G} " f"{int(self.arc):d},{int(self.sweep):d} {end.real:G},{end.imag:G}" ) def _parameterize(self): # Conversion from endpoint to center parameterization # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes if self.start == self.end: # This is equivalent of omitting the segment, so do nothing return if self.radius.real == 0 or self.radius.imag == 0: # This should be treated as a straight line return cosr = cos(radians(self.rotation)) sinr = sin(radians(self.rotation)) dx = (self.start.real - self.end.real) / 2 dy = (self.start.imag - self.end.imag) / 2 x1prim = cosr * dx + sinr * dy x1prim_sq = x1prim * x1prim y1prim = -sinr * dx + cosr * dy y1prim_sq = y1prim * y1prim rx = self.radius.real rx_sq = rx * rx ry = self.radius.imag ry_sq = ry * ry # Correct out of range radii radius_scale = (x1prim_sq / rx_sq) + (y1prim_sq / ry_sq) if radius_scale > 1: radius_scale = sqrt(radius_scale) rx *= radius_scale ry *= radius_scale rx_sq = rx * rx ry_sq = ry * ry self.radius_scale = radius_scale else: # SVG spec only scales UP self.radius_scale = 1 t1 = rx_sq * y1prim_sq t2 = ry_sq * x1prim_sq c = sqrt(abs((rx_sq * ry_sq - t1 - t2) / (t1 + t2))) if self.arc == self.sweep: c = -c cxprim = c * rx * y1prim / ry cyprim = -c * ry * x1prim / rx self.center = complex( (cosr * cxprim - sinr * cyprim) + ((self.start.real + self.end.real) / 2), (sinr * cxprim + cosr * cyprim) + ((self.start.imag + self.end.imag) / 2), ) ux = (x1prim - cxprim) / rx uy = (y1prim - cyprim) / ry vx = (-x1prim - cxprim) / rx vy = (-y1prim - cyprim) / ry n = sqrt(ux * ux + uy * uy) p = ux theta = degrees(acos(p / n)) if uy < 0: theta = -theta self.theta = theta % 360 n = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) p = ux * vx + uy * vy d = p / n # In certain cases the above calculation can through inaccuracies # become just slightly out of range, f ex -1.0000000000000002. if d > 1.0: d = 1.0 elif d < -1.0: d = -1.0 delta = degrees(acos(d)) if (ux * vy - uy * vx) < 0: delta = -delta self.delta = delta % 360 if not self.sweep: self.delta -= 360 def point(self, pos): if self.start == self.end: # This is equivalent of omitting the segment return self.start if self.radius.real == 0 or self.radius.imag == 0: # This should be treated as a straight line distance = self.end - self.start return self.start + distance * pos angle = radians(self.theta + (self.delta * pos)) cosr = cos(radians(self.rotation)) sinr = sin(radians(self.rotation)) radius = self.radius * self.radius_scale x = ( cosr * cos(angle) * radius.real - sinr * sin(angle) * radius.imag + self.center.real ) y = ( sinr * cos(angle) * radius.real + cosr * sin(angle) * radius.imag + self.center.imag ) return complex(x, y) def tangent(self, pos): angle = radians(self.theta + (self.delta * pos)) cosr = cos(radians(self.rotation)) sinr = sin(radians(self.rotation)) radius = self.radius * self.radius_scale x = cosr * cos(angle) * radius.real - sinr * sin(angle) * radius.imag y = sinr * cos(angle) * radius.real + cosr * sin(angle) * radius.imag return complex(x, y) * complex(0, 1) def length(self, error=ERROR, min_depth=MIN_DEPTH): """The length of an elliptical arc segment requires numerical integration, and in that case it's simpler to just do a geometric approximation, as for cubic bezier curves. """ if self.start == self.end: # This is equivalent of omitting the segment return 0 if self.radius.real == 0 or self.radius.imag == 0: # This should be treated as a straight line distance = self.end - self.start return sqrt(distance.real**2 + distance.imag**2) if self.radius.real == self.radius.imag: # It's a circle, which simplifies this a LOT. radius = self.radius.real * self.radius_scale return abs(radius * self.delta * pi / 180) start_point = self.point(0) end_point = self.point(1) return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0) def boundingbox(self): """Calculate the bounding box of an arc To calculate the extremums of the arc coordinates, we solve x'(angle) = - cosr * radius.real * sin(angle) - sinr * radius.imag * cos(angle) = 0 y'(angle) = - sinr * radius.real * sin(angle) + cosr * radius.imag * cos(angle) = 0 and angle = radians(self.theta + (self.delta * pos)) where pos ranges from 0 to 1 """ # angle = radians(self.theta + (self.delta * pos)) cosr = cos(radians(self.rotation)) sinr = sin(radians(self.rotation)) radius = self.radius * self.radius_scale x_a = -cosr * radius.real x_b = -sinr * radius.imag x_c = radians(self.theta) x_d = radians(self.delta) y_a = -sinr * radius.real y_b = +cosr * radius.imag y_c = radians(self.theta) y_d = radians(self.delta) x_pos = [0, 1.0] + _find_solutions_for_arc(x_a, x_b, x_c, x_d) y_pos = [0, 1.0] + _find_solutions_for_arc(y_a, y_b, y_c, y_d) x_coords = [] y_coords = [] for pos in x_pos: p = self.point(pos) x_coords.append(p.real) for pos in y_pos: p = self.point(pos) y_coords.append(p.imag) x_min, x_max = min(x_coords), max(x_coords) y_min, y_max = min(y_coords), max(y_coords) return [x_min, y_min, x_max, y_max] class Move: """Represents move commands. Does nothing, but is there to handle paths that consist of only move commands, which is valid, but pointless. """ def __init__(self, to, relative=False): self.start = self.end = to self.relative = relative def __repr__(self): return "Move(to=%s)" % self.start def __eq__(self, other): if not isinstance(other, Move): return NotImplemented return self.start == other.start def __ne__(self, other): if not isinstance(other, Move): return NotImplemented return not self == other def _d(self, previous): cmd = "M" x = self.end.real y = self.end.imag if self.relative: cmd = "m" if previous: x -= previous.end.real y -= previous.end.imag return f"{cmd} {x:G},{y:G}" def point(self, pos): return self.start def tangent(self, pos): return 0 def length(self, error=ERROR, min_depth=MIN_DEPTH): return 0 def boundingbox(self): x_min = min(self.start.real, self.end.real) x_max = max(self.start.real, self.end.real) y_min = min(self.start.imag, self.end.imag) y_max = max(self.start.imag, self.end.imag) return [x_min, y_min, x_max, y_max] class Close(Linear): """Represents the closepath command""" def __eq__(self, other): if not isinstance(other, Close): return NotImplemented return self.start == other.start and self.end == other.end def __repr__(self): return f"Close(start={self.start}, end={self.end})" def _d(self, previous): return "z" if self.relative else "Z" def boundingbox(self): x_min = min(self.start.real, self.end.real) x_max = max(self.start.real, self.end.real) y_min = min(self.start.imag, self.end.imag) y_max = max(self.start.imag, self.end.imag) return [x_min, y_min, x_max, y_max] class Path(MutableSequence): """A Path is a sequence of path segments""" def __init__(self, *segments): self._segments = list(segments) self._length = None self._lengths = None # Fractional distance from starting point through the end of each segment. self._fractions = [] def __getitem__(self, index): return self._segments[index] def __setitem__(self, index, value): self._segments[index] = value self._length = None def __delitem__(self, index): del self._segments[index] self._length = None def insert(self, index, value): self._segments.insert(index, value) self._length = None def reverse(self): # Reversing the order of a path would require reversing each element # as well. That's not implemented. raise NotImplementedError def __len__(self): return len(self._segments) def __repr__(self): return "Path(%s)" % (", ".join(repr(x) for x in self._segments)) def __eq__(self, other): if not isinstance(other, Path): return NotImplemented if len(self) != len(other): return False for s, o in zip(self._segments, other._segments): if not s == o: return False return True def __ne__(self, other): if not isinstance(other, Path): return NotImplemented return not self == other def _calc_lengths(self, error=ERROR, min_depth=MIN_DEPTH): if self._length is not None: return lengths = [ each.length(error=error, min_depth=min_depth) for each in self._segments ] self._length = sum(lengths) if self._length == 0: self._lengths = lengths else: self._lengths = [each / self._length for each in lengths] # Calculate the fractional distance for each segment to use in point() fraction = 0 for each in self._lengths: fraction += each self._fractions.append(fraction) def _find_segment(self, pos, error=ERROR): # Shortcuts if pos == 0.0: return self._segments[0], pos if pos == 1.0: return self._segments[-1], pos self._calc_lengths(error=error) # Fix for paths of length 0 (i.e. points) if self._length == 0: return self._segments[0], 0.0 # Find which segment the point we search for is located on: i = bisect(self._fractions, pos) if i == 0: segment_pos = pos / self._fractions[0] else: segment_pos = (pos - self._fractions[i - 1]) / ( self._fractions[i] - self._fractions[i - 1] ) return self._segments[i], segment_pos def point(self, pos, error=ERROR): segment, pos = self._find_segment(pos, error) return segment.point(pos) def tangent(self, pos, error=ERROR): segment, pos = self._find_segment(pos, error) return segment.tangent(pos) def length(self, error=ERROR, min_depth=MIN_DEPTH): self._calc_lengths(error, min_depth) return self._length def d(self): parts = [] previous_segment = None for segment in self: parts.append(segment._d(previous_segment)) previous_segment = segment return " ".join(parts) def boundingbox(self): x_coords = [] y_coords = [] for e in self: x_min, y_min, x_max, y_max = e.boundingbox() x_coords.append(x_min) x_coords.append(x_max) y_coords.append(y_min) y_coords.append(y_max) x_min, x_max = min(x_coords), max(x_coords) y_min, y_max = min(y_coords), max(y_coords) return [x_min, y_min, x_max, y_max] svg.path-6.3/tests/000077500000000000000000000000001442323775600142735ustar00rootroot00000000000000svg.path-6.3/tests/__init__.py000066400000000000000000000000001442323775600163720ustar00rootroot00000000000000svg.path-6.3/tests/test_boundingbox_image.png000066400000000000000000000252331442323775600215250ustar00rootroot00000000000000PNG  IHDRE/*bIDATxˑƖ-PV45OIS I+&* PBphYqDddPv׭qvz?{e8: y4iڶ4&g榲*_n]+r'[{wF;9,n*|k|/78۶d8ؽ1="0qjϏ%0ɍdz@&r/{x5>bZ|r0Nac̟+z/퇛0s6J"o}ؼw'n6vcXɖ'<}eO_t>&zb9|ye02nK: @q_K.~m4LԎLZ^/#xA4Ev{:; ;zņꮅ#;p_#) BN1I$ 8Ő5~1+CCj O1$Ew]=QS %bxp/Djû|o!7wM$\Oٜzo#\EWG+CSoPw2먐;Y;@4HW}wkN] :Aj; ^Os IlVLl'ޤhټc8Y:| sx`)%y|/t"mŁd8 #S`Δx1Z&DC62]Oā}M{Vqq*xAu$}#.6k]#\r̽z; s#@#ieGO-!M.ցxytAlUog-4#b~/ަax=$E x7o`P. Xz_AW]m[7O )I~p7hQKS~++0n [ZT.\8dY^q%~sŤ|K [^D)҂VrW`>1yM.G҂5伮b}"Z>a,BnKi.-8&xf&Jpia䤮ds*} 6jZZp=NѶ0ok&܏N_BM ן;;0/-}}p-k.$ܣ(؟!*EÍQW]ϝC$[pp_%% L@a%ݍQgN;%Q"ӤO=ev?Hw'?쉓I7Ȋ|]C&?6=Sªwɞ5g =wt]
޲{w ;{Vn,?C7#kT}'ư]WuNod|*8??T%{W_,ä]7p"\7)9놦isfI7KTgnņF%45wW(J"ڔY()|W`JfbD==ƬjSZ52w5Nҙ`@@:3*p @(pד:3)p`p(p(P΅,'ʓk  $ $ $ $ $ $ $ $ @}c>_wGN~m=P[o^ w݆XTSIYsRUCk-nɏVHNվ~&15 /m'_P4?RSK1Wإ>۶olelTo4C z&E3sƐn|{߸Io6,.#u>Iϟ~U'J:*b?Fe/Mb>V?FCb1A/:}AyH+k۶[cVH ]j=d;`;\0*U@rIv/^HB1z"e ӔoxS.;~\+5˄)Ӥ@TЖSg%&du&[2w}R.GVB?LN"܁_k[&'wz27)Nz%Tz r܁4IDw's9ɞꁤHd=B ${jJdiq dݖE=ey{O۝dΓ ‚Ie=;p~L )TT[lHIc}&*vI$O()<"Iҩ!J A:SL xpOaZI7ē(0532ýf8OqNJ*s w &-q&p6ݼ#&3%$SW:[>Hp޹R4 *I-}k˷*=JjXx78q㙫3ҬA'9yɾpל&C,&ܛ&sVLD]oMeaz?hΤ/+-mכpI,+Ey/7|OSW)c}y{ərSge\f(%r_Od WLU'WwIKX4]=DEV-v=]`RD*r X|w(#}.<)j^`$[۶_])0e=J}uמp?N1~ԫ4U;$5ʅa%˵Z~ϟƿ.ZSd ;xAWY:VOÞ]2SIS/u.B~o<ճ줠dohk5 O$kHP@?Ԉƚlt~HPՅk&|r$+Kne$Ҕ$.=}}dY\:s5ͅOoV]Kj఼u\O+__[ƙD/܁j) w  ;41^Pd?|A7)uźB˫~O8ra@:1h{=_6@3C%܁ ̣$|OpH$0Mg$֢_;p(yvI""}p.+J bEzp.,ohonuMӴyfq`skbO'1{rK=,%] Ȩe(|۶:]Hช<^괱DžLu6 ]0Cos]&if| {+Lts_k7^.{s̽+m)m~'klc9ɀc<-neبeH݇%] ޺/^JmYޏwc?r`kqyck,~7WCxhM-==T&`{rEɂj@/Vpm3G@Xa='܁-큟6/=p$o?{xExռ2Y{A5L<(&gwQ́(o.羋9.r>G8`NɅAo_`-䛻맙Aೇ|w~u{7Ǵb.e&DjfʕlOHP(o_d9 RiɴBʈ L.$(3|B?Ã*1MeGo_YPyII?t~gh PJW(ؓ4PQɞg I8$O'N=IyQY=޾;ɞ0'(K<{)s qQ^ɞ 'Dy${>'rM8Ux r*$'d]l$=[_$MCnt۴&_;t*wo[Q~&˾}Ǜe"ًP٪ѷ"ksxD%whD$$[󓳚e`/pϞʻ& .^(,ew&oiJ2%Uy|2d/p(e_LE;-u2x2A(lr&.^>rak% &{5*wQ΃D^j}=8hL${e wQNeS}+r4lѴmse`UnROrZ&em>ʒuM5os| ]{s>jqAXv}#u_.#{R`BP4y-դ|m-yZ14Msyan$; wf6[!/#7n>4,ͭvK_Kv>8܁Gisz{{;wꗘ:[e 7Z1DN>8 pSߑ=;@N2<~'#9D[ޱ2 Q?zfd+9G[ې윦rv."Q.ֹ ZF! v2-$;L5mXN;4M|k_Pnw:C}2>*w{_ܽ;wn)bý'GRSNH[fԟQde_bS)Od|`\5c.s=;ŭFyԚivc۹j^8qI]ڹ쵽b0%8v?p6 kq˛YڽY|Dm?j&7=]A-pr#\;1my>=s`8'dg0f_o:>tw\a [f)oyqloko3Žۮj}Qޙߜ-DŽMggCcj=p_y'k}d\C|^R1>;χcfA$\lmVL'Gs3[8|r]rďp.dv־caO|k5~)"lyn6Rۏv+l]lgon&YObگk|;C;c՘=pv6>ҹ1LEx_e\҉ o?)]<mS@xO]O"L{]񶋇yΏ\]wLcC1X/:UMplk3ڳʺb!Ǝ?4џ7`?0| (nmxŸ<p]ggڇ??rsgO3F~z Ya-^=7+$=5YN3˩ŭ-k[3xMZ1kҴݵh>?᝷5?Mq-&x7׶_o;kv֎}滛s`_1^~Z<QEYs7'J޴ݮ뚦iv]v:3k&%cx 5rx_c)`a5CڼWx׆ud/ \̵_Q/+O9€i`$0f6w "mTss3>χ`>+dGۄSglk/b5zvc4`ѬgV^kWخ">?:|0Ykwm8 C'&f_{%%Á;=澓qv=vWsD\2#sqQćpWwH}ͫlg\2 g'/3B19-Qlw^?=<ᕙ~{\o_.ui[\oNn3A +|V[}|q~Zv@u q5Kg0 c` {Ҵ]7a5cF y)sď9r;gU,#SMk;Ew!7kv낯gj9U/o.qKLy?[9wl'܁~/HSmXP! .+ܡVLuIDd:+4Cdzo(L| 2K%YgM@nd:,ȄLg&9r$tαn %2X@լ$xL62i\>۶MjZy*wn7y;bwH qd/u^? mۤ4- ;cǷ7npC?IpƄ3svS1o|5%aܯZ?@^@O[&N -Cf7bwr!;kOϝ v1;S2$M1tIv8L(gwR$$Nr$;'IdKw"*THvp' %yd w^&7Ivp}.'yCCn%yd w&;$Σ$;22 s'X&!;X2"Tȑp'DC2%&!;U2/2 Țpgd w$;@LCք;x IC!ܙP2PNh@q;?Iv(pGC $k!E4di@{4d`½R&k$١x½^ &ܫETp TBHC{E4d½:vp(pEߓQC%{tۡ6½"vp/*$klw y IL%˧l b)ۡf_K{!suJ+'Ŧі)E\B#;+pSphwMӊd}F8*p_ԗM5:R,WuVL?Yü&I}`K ֥!}x½'W_P i۟FrٖХiFO)"{"Cm xýW_#^O+-3KTpxJe&4@JoLu,{"(]Mm x\{F7*24@q̄F]Qgxsr*w>SCT6ٻDϰWS9;+Fx%_|ʝY{(>4߾c/ܩ qڶkiT6@*v^GK5SZKvHp( G>wJ=n;EIENDB`svg.path-6.3/tests/test_boundingbox_image.py000066400000000000000000000110721442323775600213650ustar00rootroot00000000000000import unittest import os import pytest import sys from PIL import Image, ImageDraw, ImageColor, ImageChops from svg.path.path import CubicBezier, QuadraticBezier, Line, Arc RED = ImageColor.getcolor("red", mode="RGB") GREEN = ImageColor.getcolor("limegreen", mode="RGB") BLUE = ImageColor.getcolor("cornflowerblue", mode="RGB") YELLOW = ImageColor.getcolor("yellow", mode="RGB") CYAN = ImageColor.getcolor("cyan", mode="RGB") WHITE = ImageColor.getcolor("white", mode="RGB") BLACK = ImageColor.getcolor("black", mode="RGB") DOT = 4 + 4j # x+y radius of dot def c2t(c): """Make a complex number into a tuple""" return c.real, c.imag class BoundingBoxImageTest(unittest.TestCase): """Creates a PNG image and compares with a correct PNG to test boundingbox capability""" def setUp(self): self.image = Image.new(mode="RGB", size=(500, 1200)) self.draw = ImageDraw.Draw(self.image) def draw_path(self, path): lines = [c2t(path.point(x * 0.01)) for x in range(1, 101)] self.draw.line(lines, fill=WHITE, width=2) p = path.point(0) self.draw.ellipse([c2t(p - DOT), c2t(p + DOT)], fill=BLUE) p = path.point(1) self.draw.ellipse([c2t(p - DOT), c2t(p + DOT)], fill=GREEN) def draw_boundingbox(self, path): x1, y1, x2, y2 = path.boundingbox() self.draw.line( [ (x1, y1), (x2, y1), (x2, y2), (x1, y2), (x1, y1), ], fill=RED, width=2, ) @pytest.mark.skipif( sys.platform != "linux", reason="Different platforms have different fonts" ) def test_image(self): self.draw.text((10, 10), "This is an SVG line:") self.draw.text( (10, 100), "The red line is a bounding box.", ) line1 = Line(40 + 60j, 200 + 80j) self.draw_path(line1) self.draw_boundingbox(line1) self.draw.text((10, 140), "These are Arc segments:") arc1 = Arc(260 + 320j, 100 + 100j, 0, 1, 1, 260 + 319j) self.draw_path(arc1) self.draw_boundingbox(arc1) arc2 = Arc(450 + 320j, 40 + 80j, 50, 1, 1, 420 + 319j) self.draw_path(arc2) self.draw_boundingbox(arc2) arc3 = Arc(400 + 260j, 40 + 70j, 50, 0, 1, 340 + 260j) self.draw_path(arc3) self.draw_boundingbox(arc3) self.draw.text( (10, 500), "Next we have a quadratic bezier curve, with one tangent:", ) start = 30 + 600j control = 400 + 540j end = 260 + 650j qbez1 = QuadraticBezier(start, control, end) self.draw_path(qbez1) self.draw.ellipse([c2t(control - DOT), c2t(control + DOT)], fill=WHITE) self.draw.line([c2t(start), c2t(control), c2t(end)], fill=CYAN) self.draw_boundingbox(qbez1) self.draw.text( (10, 670), "The white dot is the control point, and the cyan lines are ", ) self.draw.text((10, 690), "illustrating the how the control point works.") self.draw.text( (10, 730), "Lastly is a cubic bezier, with 2 tangents, and 2 control points:", ) start = 200 + 800j control1 = 350 + 750j control2 = 50 + 900j end = 190 + 980j cbez1 = CubicBezier(start, control1, control2, end) self.draw_path(cbez1) self.draw.ellipse([c2t(control1 - DOT), c2t(control1 + DOT)], fill=WHITE) self.draw.ellipse([c2t(control2 - DOT), c2t(control2 + DOT)], fill=WHITE) self.draw.line( [ c2t(start), c2t(control1), ], fill=CYAN, ) self.draw.line([c2t(control2), c2t(end)], fill=CYAN) self.draw_boundingbox(cbez1) # self.image.show() # Useful when debugging filename = os.path.join( os.path.split(__file__)[0], "test_boundingbox_image.png" ) # If you have made intentional changes to the test_boundingbox_image.png, # save it by uncommenting these lines. Don't forget to comment them out again, # or the test will always pass # with open(filename, "wb") as fp: # self.image.save(fp, format="PNG") with open(filename, "rb") as fp: test_image = Image.open(fp, mode="r") diff = ImageChops.difference(test_image, self.image) self.assertFalse( diff.getbbox(), "The resulting image is different from test_boundingbox_image.png", ) svg.path-6.3/tests/test_doc.py000066400000000000000000000001121442323775600164430ustar00rootroot00000000000000import doctest def test_readme(): doctest.testfile("../README.rst") svg.path-6.3/tests/test_generation.py000066400000000000000000000021361442323775600200410ustar00rootroot00000000000000import unittest from svg.path.parser import parse_path class TestGeneration(unittest.TestCase): def test_svg_examples(self): """Examples from the SVG spec""" paths = [ # "M 100,100 L 300,100 L 200,300 Z", # "M 0,0 L 50,20 M 100,100 L 300,100 L 200,300 Z", # "M 100,100 L 200,200", # "M 100,200 L 200,100 L -100,-200", # "M 100,200 C 100,100 250,100 250,200 S 400,300 400,200", # "M 100,200 C 100,100 400,100 400,200", # "M 100,500 C 25,400 475,400 400,500", # "M 100,800 C 175,700 325,700 400,800", # "M 600,200 C 675,100 975,100 900,200", # "M 600,500 C 600,350 900,650 900,500", # "M 600,800 C 625,700 725,700 750,800 S 875,900 900,800", # "M 200,300 Q 400,50 600,300 T 1000,300", # "M -3.4E+38,3.4E+38 L -3.4E-38,3.4E-38", # "M 0,0 L 50,20 M 50,20 L 200,100 Z", "M 600,350 L 650,325 A 25,25 -30 0,1 700,300 L 750,275" ] for path in paths: self.assertEqual(parse_path(path).d(), path) svg.path-6.3/tests/test_image.png000066400000000000000000000276061442323775600171350ustar00rootroot00000000000000PNG  IHDRE//MIDATx͕8.P2WZpۓsyRn{ʓTz"@pA?A i]u6k~5=[]}g: gMӴm[i }8nGu?zvvàoM޽˸}&J\26J~0cmAfl.ζm5S{|,% <ٟWg2֊pׯX#,gjU?WP9$ܛ<ݹ//з_FX\ofyq=ƫޯ?v}A˃t=<pH$j/뇿K:pkkx}xƲ @@dٞmRw]MB_݁sZXv/ }/Wd&U>*};pasi6Wd$o8YP*;p&yMa_M $?B(D\LG7%=Be>\]??4%z#9GG+w\dɩ*_5fɵP*w gvK;\U.3WBy]C>0fsG3|DbOOh%datiQ;n߫RZ:[3anniU;.U϶qdwN.ڶn%CGr|ʞk2e};q^ mOi~A׿g7;ZD7\\)jr_9܁ӓϙ8э7p[`Ք3x*g{>,V}][;7(1 n`yjbV>υV}X՟D;s{_u.ƻ{v\};̈́}pLX{5o9}57V-oˊpO˜Ukd΃ږ#?\x샤ˑ3Tj{a$ZU8sƶ,oy=xþeŸ sOv)_9_{{Oz"ٲgw-Gq]硕Fj?J0b;M-^uεK/Sfy򱡝_ܲط:ҵ"q&$_ 1>'"in&=ju:3>3scjnPmOǍiu0ƄUϓLpQﰹ\Lifrjͮ:;}{fj-I<0?aAɟx>xoI\&4?k>\6k> a="鞳ն'Ș,]x@b{jͦ3xMf#9fW=  sJV{^*g=lY̭۟v $\r^62p[MնX*~ujxAlrj܍Vk;Z\E熨v`QCAUV! W9.;6ȯ kg#ʏ OVty^02 (/ȇ6U,\ϕǥݸUy^^7X c)* no;wʋ{Gd%e;D~~מojy*/śTMF2笭p*{L1>0xQKv8eC K\ۭ*7"Əpv8ʽAmp*/UWKJx"[?sa}ע>s_H}^֕xK&}?U<kpa5:)ᡬ*/]~w T:/N. w ~?|xd?NoTUgVbJxI"ٯ2.Up%ٯh" ix`Qu蚻XI|-0r iWdA7Vi I7F 3~ss.a@7d7"> w 2BRSO9E<<"%slF#E?Ğm!0⇻|<-<ř-x*<_$FSeʒJz (r7i .a4ݭP()`o* we;o=+ṹ.x{a; /d;n>x)Z+I8nzXnfv=Ip߯@yv.]g`dMuʝEKZJ%.X,"Ț -xaYN#N{ 1Y9Zp *} JpowX(Y .NIu;;eeتw c `N;Y!0pp=j*;Y!ZPnM"Rp,R5wrwrSH w w;@@ pH$]_?ymOBRS;D<,p&P vUۦ\VuϪۡoT~JnBlYnU??V_r?X+t].Ҽ M!w;@@ pH$܃?68`j]d:O@pHp>ÏR%BR$Ϧ,"*;@@ sdڶ#P^Sqx5 `pmppWvL;_,5«>]cՇ˓Ue*i# TC3\RB{M8;Wi\'S{JkU$<ĩzN7w<?CW19.lKxe}OsJ:c@*4.cK#DnI\*_D6.+s `3̄N߀=\6%a\9)"|p, s<ru|o$;*ZA eVeu8i*TLA\QKxٴV 8j/jN*W _np.i}i~uyu޺_Y*bs/#料C'QK핳*wmuGmeeUB.snyf>[krN;dnL m۾U,-, &{ZjCn~ ~Η,3 9$ƍ Zﺿgbw%{sk58 b[\u]4m[Wś=džO᪁9bG;Wϱ39 eҷ{MSz5IYm7sUq<^ia* &}&1`ī?6U^Vd<&{sVy6x96h?sk>\C#}l<]}\41='7?ߒȗt˫xz;ng|%F=,2b;doӣ ɳHn?7WW͖}}Us!>^tdV:rN[ͅN~!-<2omNh49δ>f̡߷m6Y5S\j0&[^4~mOVɛ}̉A,O'nݗX-R|y΂c[\+-O65w"+}52918-kSɽp M %;-==GRy? ! W,8(U0ګ;m+#ޱ'Ӿ]My~u_$z&gI V RՕ+]׌ i*wD9Q>D>0|¯M}c? JI ~/w0v%nFtr.K0lFy#<}|@^фnB8+Ǒ0PZJ4G2p{1|@~0 72$/\ Q> OaD>'2pz߾r5|d?Dy>K11Do`qOQ.KUMU~~5cx9,߃@P i6T`u$w*$oHߌpDYd%͈(Y:վ}E}17r,JH,ܞp<0{ ;%'Kyb(|^;=վ}c=nuQCwO;${{PgE8.kmO<,^9g.Q~rdO)S32B%K8)o_ِݑ"1l_Mx'+Yvm8 2 XY]YzATzQIpγGyf.ipڶ Nd=(dNEVFm\=/ Мڕ:]49I(_\R #>=7N-%8=(lXOlb"c<rN!?6}j*؟1gz>8\Yh I zE9wU$?<ͯ|Ͽ}EsOw˟lsvgJr9N]^~9Q_ǶwDuݱyToe))~z_:⿪/8qXg,+;k~6nY~y{()e{^+})g|FQÁ_n^|ڶ-](ǿ ?kw[-^Pg*udW/%]7J;!]?T@Q9c!}?esU0Ǽ.^dLQY&`ϡ/E=&ζnM>fgX^ ^F˪~tK]pԂҼ* nrs 8*w8r+OgeuT5^ԫp˱fڋz'xRL%* .X 8*w8삌={.^pZ$~5Lv$b˯%;=Gg Xgџ`lAI vV*RԛsPdۊz2PdmʚyP+9E"UPNj v{e@K풝{vun{2a.(\>]s3f$f)3)aK1T,P \w"٩9 ˾>ZN LSHQS)20KS/$;U3eaEvjgB4͏~߯TޱߒUKv*gh珿?~^#(ûeK?١vHv"<ڋ5j'*4MU5;@U}Su_)W,U-o@R#m6yl>{OY4w.4Bu5MӶm픚znjtEZʪٙ;bg̻dnp1 ff [l'}0dˋ8U3O&kpσedGùß\HωGu9O&`lߵjS!q7ջ=|jq_TH<ٷ<}V}ܸ&b׎hLNi}Α3sey(2+=mܡ:H&߇`-R1:smsbA6$Z,[vΏ{ZlsP->^|R{E6t,9Cd.!"+QkGqeGn \7~ WXsٿ%ӶϤܪN#ߓR)UbLcCY_F6=a[kǍ#Yd^U1ΐcd&>{Gi[Nsq4?`''_uv}x#3{88g~&eiNu* Sk .σ{|߹UΗ$=5ldk[~hOX'9sv݆R;T5Zw4 n_P?\+ԢR4xlqN<讚p'{.+dIbvܕX ʵp#`)fn=-'W-'eq#emIo?gn[断ggq63w쓇6`2/Bs ;2vD@Av]4M۶~]ASxwxUI9>y^?{^xP.ǝaq׫sUn%}Kenڶ|RlYbg{b_OuuJ; ٸ=V[Xp~8CN;ٟ {Rwiqu^&k{QOnWf^ag{41JzO3ң*4`>qnǕt}uzɩ#6ε9hr;9Gny\h3+ܾǕZ[WG'srnM :/={‰[d\UmqV=Ҽ:_[f\Á)833xsQi}&gwrÁC}ľ7fYiC;_.>yyʹgQ#gr=l'g|6{Չ*}w{b_w˙^s[9y''vׂ{⸚3~1w{&YeY =l-s|3s\ i>g\zb/1*xT3Gl'iy\٬K>\(7bE:^,]׽~h[w>PpnmlO;3/=p\LWo `x-T7PÓ(sbRJia@ ,3 >) $(4fs{JiXYp" )\o\!(O.;QJc(K&??@g;M1c}p~'8onmOW6 xV)e˰x?$(O6ہ_#ݺMaTozMɇʝ#->Qc^P}t z/RP"x;=eV?<2Gu;Fs!p pbm(ޡ8;'܂܅|;@@QC){PpHs;wOsGv wnJ{wK';lr%ܹ;3p@!Ojq w;5Q7wXrl| N ܩ{麮T.!ApVg AS1p GUFØʝx$FS=3)&k*rH!Msm

 (]4]7Mtrw.m#SM}%w\y'g^>(^񜣵8C -y a(gZ{_1Qý^W| Fmtdi>w{ GRS;ެxAKP|^wܳ<@douٙ=v};KfJ 7)O9 7) _c0L*F1I pp&2Yxx,p&{ 2Hɝ e~IWwEɣ2XP'zR-?^84MZ~B72ߣl­$V*,O@Qs' y3Oz˼Eq\Z5pfq /,t DzLU"~f8rL 3p30;hp/\ k~q]rMCYm&ߡ aN)7'Z||F=PUP)W+rR.wf|7Fv| oۚLf&V|ﰍyo/a]s3&a-ӌF)D~^{wN`Qί=cc5MJ"XC~'ae ;2(o"]s(RU4kUsS2ܧW]wb^Qi6p m})_*|&H)_cw3(dTmdj;eNwHNOJxNYRk?^SDa%TvWTwJ1(xJxN)&]Or^f%jN!53(vƨFƐ Bv`I{F}J?VA*M #L )zi$ª|=9Vw}e_xrӆѯd%g<9iCa3ϏC Dץ|Q& ="XyRBaʠkg ,- qб[Kh]A.(Lu4Mu0_뇟j<03z_Nar|\Hv|O4(`PYԯc[;Elm^jw3 -wwX}Dsk/E (BZkp? 7aY pH$ w;@@ pH$ w;@@ pH$ w;0 pH$ w;@@ pH$ w;@@ pH$ w;@@]OpH$ w;@@ pH$ w;@@ pH$ w;@@ L0rH$ w;@@ pH$ w;@@ pH$ w;@@ pF; w;@@ pH$ w;@@ pH$ w;@@ pH$O& w;@@ pH$ w;@@ pH$ w;@@ pH$sT w;@@ pH$ w;@@ p վIENDB`svg.path-6.3/tests/test_image.py000066400000000000000000000106221442323775600167670ustar00rootroot00000000000000import unittest import os from PIL import Image, ImageDraw, ImageColor, ImageChops from math import sqrt from svg.path.path import CubicBezier, QuadraticBezier, Line, Arc RED = ImageColor.getcolor("red", mode="RGB") GREEN = ImageColor.getcolor("limegreen", mode="RGB") BLUE = ImageColor.getcolor("cornflowerblue", mode="RGB") YELLOW = ImageColor.getcolor("yellow", mode="RGB") CYAN = ImageColor.getcolor("cyan", mode="RGB") WHITE = ImageColor.getcolor("white", mode="RGB") BLACK = ImageColor.getcolor("black", mode="RGB") DOT = 4 + 4j # x+y radius of dot def c2t(c): """Make a complex number into a tuple""" return c.real, c.imag def magnitude(c): return sqrt(c.real**2 + c.imag**2) class ImageTest(unittest.TestCase): """Creates a PNG image and compares with a correct PNG""" def setUp(self): self.image = Image.new(mode="RGB", size=(500, 1200)) self.draw = ImageDraw.Draw(self.image) def draw_path(self, path): lines = [c2t(path.point(x * 0.01)) for x in range(1, 101)] self.draw.line(lines, fill=WHITE, width=2) p = path.point(0) self.draw.ellipse([c2t(p - DOT), c2t(p + DOT)], fill=BLUE) p = path.point(1) self.draw.ellipse([c2t(p - DOT), c2t(p + DOT)], fill=GREEN) def draw_tangents(self, path, count): count += 1 for i in range(1, count): p = path.point(i / count) t = path.tangent(i / count) self.draw.line([c2t(p), c2t(p + t)], fill=RED, width=1) # And a nice 90 angle tt = complex(t.imag, -t.real) # scale it to always be 20px tt *= 20 / magnitude(tt) self.draw.line([c2t(p), c2t(tt + p)], fill=YELLOW, width=1) def test_image(self): self.draw.text((10, 10), "This is an SVG line:") self.draw.text( (10, 100), "The red line is a tangent, and the yellow is 90 degrees from that.", ) line1 = Line(40 + 60j, 200 + 80j) self.draw_path(line1) self.draw_tangents(line1, 1) self.draw.text((10, 140), "This is an Arc segment, almost a whole circle:") arc1 = Arc(260 + 320j, 100 + 100j, 0, 1, 1, 260 + 319j) self.draw_path(arc1) self.draw_tangents(arc1, 5) self.draw.text((10, 460), "With five tangents.") self.draw.text( (10, 500), "Next we have a quadratic bezier curve, with one tangent:", ) start = 30 + 600j control = 400 + 540j end = 260 + 650j qbez1 = QuadraticBezier(start, control, end) self.draw_path(qbez1) self.draw.ellipse([c2t(control - DOT), c2t(control + DOT)], fill=WHITE) self.draw.line([c2t(start), c2t(control), c2t(end)], fill=CYAN) self.draw_tangents(qbez1, 1) self.draw.text( (10, 670), "The white dot is the control point, and the cyan lines are ", ) self.draw.text((10, 690), "illustrating the how the control point works.") self.draw.text( (10, 730), "Lastly is a cubic bezier, with 2 tangents, and 2 control points:", ) start = 30 + 800j control1 = 400 + 780j control2 = 50 + 900j end = 300 + 980j cbez1 = CubicBezier(start, control1, control2, end) self.draw_path(cbez1) self.draw.ellipse([c2t(control1 - DOT), c2t(control1 + DOT)], fill=WHITE) self.draw.ellipse([c2t(control2 - DOT), c2t(control2 + DOT)], fill=WHITE) self.draw.line( [ c2t(start), c2t(control1), ], fill=CYAN, ) self.draw.line([c2t(control2), c2t(end)], fill=CYAN) self.draw_tangents(cbez1, 2) # self.image.show() # Useful when debugging filename = os.path.join(os.path.split(__file__)[0], "test_image.png") # If you have made intentional changes to the test_image.png, save it # by uncommenting these lines. Don't forget to comment them out again, # or the test will always pass # with open(filename, "wb") as fp: # self.image.save(fp, format="PNG") with open(filename, "rb") as fp: test_image = Image.open(fp, mode="r") diff = ImageChops.difference(test_image, self.image) self.assertFalse( diff.getbbox(), "The resulting image is different from test_image.png" ) svg.path-6.3/tests/test_parsing.py000066400000000000000000000553471442323775600173650ustar00rootroot00000000000000import unittest from svg.path.path import CubicBezier, QuadraticBezier, Line, Arc, Path, Move, Close from svg.path.parser import parse_path class TestParser(unittest.TestCase): maxDiff = None def test_svg_examples(self): """Examples from the SVG spec""" path1 = parse_path("M 100 100 L 300 100 L 200 300 z") self.assertEqual( path1, Path( Move(100 + 100j), Line(100 + 100j, 300 + 100j), Line(300 + 100j, 200 + 300j), Close(200 + 300j, 100 + 100j), ), ) # for Z command behavior when there is multiple subpaths path1 = parse_path("M 0 0 L 50 20 M 100 100 L 300 100 L 200 300 z") self.assertEqual( path1, Path( Move(0j), Line(0 + 0j, 50 + 20j), Move(100 + 100j), Line(100 + 100j, 300 + 100j), Line(300 + 100j, 200 + 300j), Close(200 + 300j, 100 + 100j), ), ) path1 = parse_path("M 100 100 L 200 200") path2 = parse_path("M100 100L200 200") self.assertEqual(path1, path2) path1 = parse_path("M 100 200 L 200 100 L -100 -200") path2 = parse_path("M 100 200 L 200 100 -100 -200") self.assertEqual(path1, path2) path1 = parse_path( """M100,200 C100,100 250,100 250,200 S400,300 400,200""" ) self.assertEqual( path1, Path( Move(100 + 200j), CubicBezier(100 + 200j, 100 + 100j, 250 + 100j, 250 + 200j), CubicBezier(250 + 200j, 250 + 300j, 400 + 300j, 400 + 200j), ), ) path1 = parse_path("M100,200 C100,100 400,100 400,200") self.assertEqual( path1, Path( Move(100 + 200j), CubicBezier(100 + 200j, 100 + 100j, 400 + 100j, 400 + 200j), ), ) path1 = parse_path("M100,500 C25,400 475,400 400,500") self.assertEqual( path1, Path( Move(100 + 500j), CubicBezier(100 + 500j, 25 + 400j, 475 + 400j, 400 + 500j), ), ) path1 = parse_path("M100,800 C175,700 325,700 400,800") self.assertEqual( path1, Path( Move(100 + 800j), CubicBezier(100 + 800j, 175 + 700j, 325 + 700j, 400 + 800j), ), ) path1 = parse_path("M600,200 C675,100 975,100 900,200") self.assertEqual( path1, Path( Move(600 + 200j), CubicBezier(600 + 200j, 675 + 100j, 975 + 100j, 900 + 200j), ), ) path1 = parse_path("M600,500 C600,350 900,650 900,500") self.assertEqual( path1, Path( Move(600 + 500j), CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j), ), ) path1 = parse_path( """M600,800 C625,700 725,700 750,800 S875,900 900,800""" ) self.assertEqual( path1, Path( Move(600 + 800j), CubicBezier(600 + 800j, 625 + 700j, 725 + 700j, 750 + 800j), CubicBezier(750 + 800j, 775 + 900j, 875 + 900j, 900 + 800j), ), ) path1 = parse_path("M200,300 Q400,50 600,300 T1000,300") self.assertEqual( path1, Path( Move(200 + 300j), QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j), QuadraticBezier(600 + 300j, 800 + 550j, 1000 + 300j), ), ) path1 = parse_path("M300,200 h-150 a150,150 0 1,0 150,-150 z") self.assertEqual( path1, Path( Move(300 + 200j), Line(300 + 200j, 150 + 200j), Arc(150 + 200j, 150 + 150j, 0, 1, 0, 300 + 50j), Close(300 + 50j, 300 + 200j), ), ) path1 = parse_path("M275,175 v-150 a150,150 0 0,0 -150,150 z") self.assertEqual( path1, Path( Move(275 + 175j), Line(275 + 175j, 275 + 25j), Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j), Close(125 + 175j, 275 + 175j), ), ) path1 = parse_path("M275,175 v-150 a150,150 0 0,0 -150,150 L 275,175 z") self.assertEqual( path1, Path( Move(275 + 175j), Line(275 + 175j, 275 + 25j), Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j), Line(125 + 175j, 275 + 175j), Close(275 + 175j, 275 + 175j), ), ) path1 = parse_path( """M600,350 l 50,-25 a25,25 -30 0,1 50,-25 l 50,-25 a25,50 -30 0,1 50,-25 l 50,-25 a25,75 -30 0,1 50,-25 l 50,-25 a25,100 -30 0,1 50,-25 l 50,-25""" ) self.assertEqual( path1, Path( Move(600 + 350j), Line(600 + 350j, 650 + 325j), Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j), Line(700 + 300j, 750 + 275j), Arc(750 + 275j, 25 + 50j, -30, 0, 1, 800 + 250j), Line(800 + 250j, 850 + 225j), Arc(850 + 225j, 25 + 75j, -30, 0, 1, 900 + 200j), Line(900 + 200j, 950 + 175j), Arc(950 + 175j, 25 + 100j, -30, 0, 1, 1000 + 150j), Line(1000 + 150j, 1050 + 125j), ), ) def test_wc3_examples12(self): """ W3C_SVG_11_TestSuite Paths Test using multiple coord sets to build a polybeizer, and implicit values for initial S. """ path12 = parse_path( "M 100 100 C 100 20 200 20 200 100 S 300 180 300 100" ) self.assertEqual( path12, Path( Move(to=(100 + 100j)), CubicBezier( start=(100 + 100j), control1=(100 + 20j), control2=(200 + 20j), end=(200 + 100j), ), CubicBezier( start=(200 + 100j), control1=(200 + 180j), control2=(300 + 180j), end=(300 + 100j), ), ), ) path12 = parse_path("M 100 250 S 200 200 200 250 300 300 300 250") self.assertEqual( path12, Path( Move(to=(100 + 250j)), CubicBezier( start=(100 + 250j), control1=(100 + 250j), control2=(200 + 200j), end=(200 + 250j), ), CubicBezier( start=(200 + 250j), control1=(200 + 300j), control2=(300 + 300j), end=(300 + 250j), ), ), ) def test_wc3_examples13(self): """ W3C_SVG_11_TestSuite Paths Test multiple coordinates for V and H. """ # path13 = parse_path( " M 240.00000 56.00000 H 270.00000 300.00000 320.00000 400.00000 " ) self.assertEqual( path13, Path( Move(to=(240 + 56j)), Line(start=(240 + 56j), end=(270 + 56j)), Line(start=(270 + 56j), end=(300 + 56j)), Line(start=(300 + 56j), end=(320 + 56j)), Line(start=(320 + 56j), end=(400 + 56j)), ), ) path13 = parse_path( " M 240.00000 156.00000 V 180.00000 200.00000 260.00000 300.00000 " ) self.assertEqual( path13, Path( Move(to=(240 + 156j)), Line(start=(240 + 156j), end=(240 + 180j)), Line(start=(240 + 180j), end=(240 + 200j)), Line(start=(240 + 200j), end=(240 + 260j)), Line(start=(240 + 260j), end=(240 + 300j)), ), ) def test_wc3_examples14(self): """ W3C_SVG_11_TestSuite Paths Test implicit values for moveto. If the first command is 'm' it should be taken as an absolute moveto, plus implicit lineto. """ path14 = parse_path( " m 62.00000 56.00000 51.96152 90.00000 -103.92304 0.00000 51.96152 " "-90.00000 z m 0.00000 15.00000 38.97114 67.50000 -77.91228 0.00000 " "38.97114 -67.50000 z " ) self.assertEqual( path14, Path( Move(to=(62 + 56j)), Line(start=(62 + 56j), end=(113.96152000000001 + 146j)), Line( start=(113.96152000000001 + 146j), end=(10.038480000000007 + 146j) ), Line(start=(10.038480000000007 + 146j), end=(62.00000000000001 + 56j)), Close(start=(62.00000000000001 + 56j), end=(62 + 56j)), Move(to=(62 + 71j)), Line(start=(62 + 71j), end=(100.97113999999999 + 138.5j)), Line( start=(100.97113999999999 + 138.5j), end=(23.058859999999996 + 138.5j), ), Line( start=(23.058859999999996 + 138.5j), end=(62.029999999999994 + 71j) ), Close(start=(62.029999999999994 + 71j), end=(62 + 71j)), ), ) path14 = parse_path( "M 177.00000 56.00000 228.96152 146.00000 125.03848 146.00000 177.00000 " "56.00000 Z M 177.00000 71.00000 215.97114 138.50000 138.02886 138.50000 " "177.00000 71.00000 Z " ) self.assertEqual( path14, Path( Move(to=(177 + 56j)), Line(start=(177 + 56j), end=(228.96152 + 146j)), Line(start=(228.96152 + 146j), end=(125.03848 + 146j)), Line(start=(125.03848 + 146j), end=(177 + 56j)), Close(start=(177 + 56j), end=(177 + 56j)), Move(to=(177 + 71j)), Line(start=(177 + 71j), end=(215.97114 + 138.5j)), Line(start=(215.97114 + 138.5j), end=(138.02886 + 138.5j)), Line(start=(138.02886 + 138.5j), end=(177 + 71j)), Close(start=(177 + 71j), end=(177 + 71j)), ), ) def test_wc3_examples15(self): """ W3C_SVG_11_TestSuite Paths 'M' or 'm' command with more than one pair of coordinates are absolute if the moveto was specified with 'M' and relative if the moveto was specified with 'm'. """ path15 = parse_path("M100,120 L160,220 L40,220 z") self.assertEqual( path15, Path( Move(to=(100 + 120j)), Line(start=(100 + 120j), end=(160 + 220j)), Line(start=(160 + 220j), end=(40 + 220j)), Close(start=(40 + 220j), end=(100 + 120j)), ), ) path15 = parse_path("M350,120 L410,220 L290,220 z") self.assertEqual( path15, Path( Move(to=(350 + 120j)), Line(start=(350 + 120j), end=(410 + 220j)), Line(start=(410 + 220j), end=(290 + 220j)), Close(start=(290 + 220j), end=(350 + 120j)), ), ) path15 = parse_path("M100,120 160,220 40,220 z") self.assertEqual( path15, Path( Move(to=(100 + 120j)), Line(start=(100 + 120j), end=(160 + 220j)), Line(start=(160 + 220j), end=(40 + 220j)), Close(start=(40 + 220j), end=(100 + 120j)), ), ) path15 = parse_path("m350,120 60,100 -120,0 z") self.assertEqual( path15, Path( Move(to=(350 + 120j)), Line(start=(350 + 120j), end=(410 + 220j)), Line(start=(410 + 220j), end=(290 + 220j)), Close(start=(290 + 220j), end=(350 + 120j)), ), ) def test_wc3_examples17(self): """ W3C_SVG_11_TestSuite Paths Test that the 'z' and 'Z' command have the same effect. """ path17a = parse_path("M 50 50 L 50 150 L 150 150 L 150 50 z") path17b = parse_path("M 50 50 L 50 150 L 150 150 L 150 50 Z") self.assertEqual(path17a, path17b) path17a = parse_path("M 250 50 L 250 150 L 350 150 L 350 50 Z") path17b = parse_path("M 250 50 L 250 150 L 350 150 L 350 50 z") self.assertEqual(path17a, path17b) def test_wc3_examples18(self): """ W3C_SVG_11_TestSuite Paths The 'path' element's 'd' attribute ignores additional whitespace, newline characters, and commas, and BNF processing consumes as much content as possible, stopping as soon as a character that doesn't satisfy the production is encountered. """ path18a = parse_path("M 20 40 H 40") path18b = parse_path( """M 20 40 H 40""" ) self.assertEqual(path18a, path18b) path18a = parse_path("M 20 60 H 40") path18b = parse_path( """ M 20 60 H 40 """ ) self.assertEqual(path18a, path18b) path18a = parse_path("M 20 80 H40") path18b = parse_path("M 20,80 H 40") self.assertEqual(path18a, path18b) path18a = parse_path("M 20 100 H 40#90") path18b = parse_path("M 20 100 H 40") self.assertEqual(path18a, path18b) path18a = parse_path("M 20 120 H 40.5 0.6") path18b = parse_path("M 20 120 H 40.5.6") self.assertEqual(path18a, path18b) path18a = parse_path("M 20 140 h 10 -20") path18b = parse_path("M 20 140 h 10-20") self.assertEqual(path18a, path18b) path18a = parse_path("M 20 160 H 40") path18b = parse_path("M 20 160 H 40#90") self.assertEqual(path18a, path18b) def test_wc3_examples19(self): """ W3C_SVG_11_TestSuite Paths Test that additional parameters to pathdata commands are treated as additional calls to the most recent command. """ path19a = parse_path("M20 20 H40 H60") path19b = parse_path("M20 20 H40 60") self.assertEqual(path19a, path19b) path19a = parse_path("M20 40 h20 h20") path19b = parse_path("M20 40 h20 20") self.assertEqual(path19a, path19b) path19a = parse_path("M120 20 V40 V60") path19b = parse_path("M120 20 V40 60") self.assertEqual(path19a, path19b) path19a = parse_path("M140 20 v20 v20") path19b = parse_path("M140 20 v20 20") self.assertEqual(path19a, path19b) path19a = parse_path("M220 20 L 240 20 L260 20") path19b = parse_path("M220 20 L 240 20 260 20 ") self.assertEqual(path19a, path19b) path19a = parse_path("M220 40 l 20 0 l 20 0") path19b = parse_path("M220 40 l 20 0 20 0") self.assertEqual(path19a, path19b) path19a = parse_path("M50 150 C50 50 200 50 200 150 C200 50 350 50 350 150") path19b = parse_path("M50 150 C50 50 200 50 200 150 200 50 350 50 350 150") self.assertEqual(path19a, path19b) path19a = parse_path("M50, 200 c0,-100 150,-100 150,0 c0,-100 150,-100 150,0") path19b = parse_path("M50, 200 c0,-100 150,-100 150,0 0,-100 150,-100 150,0") self.assertEqual(path19a, path19b) path19a = parse_path("M50 250 S125 200 200 250 S275, 200 350 250") path19b = parse_path("M50 250 S125 200 200 250 275, 200 350 250") self.assertEqual(path19a, path19b) path19a = parse_path("M50 275 s75 -50 150 0 s75, -50 150 0") path19b = parse_path("M50 275 s75 -50 150 0 75, -50 150 0") self.assertEqual(path19a, path19b) path19a = parse_path("M50 300 Q 125 275 200 300 Q 275 325 350 300") path19b = parse_path("M50 300 Q 125 275 200 300 275 325 350 300") self.assertEqual(path19a, path19b) path19a = parse_path("M50 325 q 75 -25 150 0 q 75 25 150 0") path19b = parse_path("M50 325 q 75 -25 150 0 75 25 150 0") self.assertEqual(path19a, path19b) path19a = parse_path("M425 25 T 425 75 T 425 125") path19b = parse_path("M425 25 T 425 75 425 125") self.assertEqual(path19a, path19b) path19a = parse_path("M450 25 t 0 50 t 0 50") path19b = parse_path("M450 25 t 0 50 0 50") self.assertEqual(path19a, path19b) path19a = parse_path("M400,200 A25 25 0 0 0 425 150 A25 25 0 0 0 400 200") path19b = parse_path("M400,200 A25 25 0 0 0 425 150 25 25 0 0 0 400 200") self.assertEqual(path19a, path19b) path19a = parse_path("M400,300 a25 25 0 0 0 25 -50 a25 25 0 0 0 -25 50") path19b = parse_path("M400,300 a25 25 0 0 0 25 -50 25 25 0 0 0 -25 50") self.assertEqual(path19a, path19b) def test_wc3_examples20(self): """ W3C_SVG_11_TestSuite Paths Tests parsing of the elliptical arc path syntax. """ path20a = parse_path("M120,120 h25 a25,25 0 1,0 -25,25 z") path20b = parse_path("M120,120 h25 a25,25 0 10 -25,25z") self.assertEqual(path20a, path20b) path20a = parse_path("M200,120 h-25 a25,25 0 1,1 25,25 z") path20b = parse_path("M200,120 h-25 a25,25 0 1125,25 z") self.assertEqual(path20a, path20b) path20a = parse_path("M280,120 h25 a25,25 0 1,0 -25,25 z") self.assertRaises(Exception, 'parse_path("M280,120 h25 a25,25 0 6 0 -25,25 z")') path20a = parse_path("M360,120 h-25 a25,25 0 1,1 25,25 z") self.assertRaises( Exception, 'parse_path("M360,120 h-25 a25,25 0 1 -1 25,25 z")' ) path20a = parse_path("M120,200 h25 a25,25 0 1,1 -25,-25 z") path20b = parse_path("M120,200 h25 a25,25 0 1 1-25,-25 z") self.assertEqual(path20a, path20b) path20a = parse_path("M200,200 h-25 a25,25 0 1,0 25,-25 z") self.assertRaises(Exception, 'parse_path("M200,200 h-25 a25,2501 025,-25 z")') path20a = parse_path("M280,200 h25 a25,25 0 1,1 -25,-25 z") self.assertRaises( Exception, 'parse_path("M280,200 h25 a25 25 0 1 7 -25 -25 z")' ) path20a = parse_path("M360,200 h-25 a25,25 0 1,0 25,-25 z") self.assertRaises( Exception, 'parse_path("M360,200 h-25 a25,25 0 -1 0 25,-25 z")' ) def test_others(self): # Other paths that need testing: # Relative moveto: path1 = parse_path("M 0 0 L 50 20 m 50 80 L 300 100 L 200 300 z") self.assertEqual( path1, Path( Move(0j), Line(0 + 0j, 50 + 20j), Move(100 + 100j), Line(100 + 100j, 300 + 100j), Line(300 + 100j, 200 + 300j), Close(200 + 300j, 100 + 100j), ), ) # Initial smooth and relative CubicBezier path1 = parse_path("M100,200 s 150,-100 150,0") self.assertEqual( path1, Path( Move(100 + 200j), CubicBezier(100 + 200j, 100 + 200j, 250 + 100j, 250 + 200j), ), ) # Initial smooth and relative QuadraticBezier path1 = parse_path("M100,200 t 150,0") self.assertEqual( path1, Path(Move(100 + 200j), QuadraticBezier(100 + 200j, 100 + 200j, 250 + 200j)), ) # Relative QuadraticBezier path1 = parse_path("M100,200 q 0,0 150,0") self.assertEqual( path1, Path(Move(100 + 200j), QuadraticBezier(100 + 200j, 100 + 200j, 250 + 200j)), ) def test_negative(self): """You don't need spaces before a minus-sign""" path1 = parse_path("M100,200c10-5,20-10,30-20") path2 = parse_path("M 100 200 c 10 -5 20 -10 30 -20") self.assertEqual(path1, path2) def test_numbers(self): """Exponents and other number format cases""" # It can be e or E, the plus is optional, and a minimum of +/-3.4e38 must be supported. path1 = parse_path("M-3.4e38 3.4E+38L-3.4E-38,3.4e-38") path2 = Path( Move(-3.4e38 + 3.4e38j), Line(-3.4e38 + 3.4e38j, -3.4e-38 + 3.4e-38j) ) self.assertEqual(path1, path2) def test_errors(self): self.assertRaises(ValueError, parse_path, "M 100 100 L 200 200 Z 100 200") def test_non_path(self): # It's possible in SVG to create paths that has zero length, # we need to handle that. path = parse_path("M10.236,100.184") self.assertEqual(path.d(), "M 10.236,100.184") def test_issue_45(self): # A missing Z in certain cases path = parse_path( "m 1672.2372,-54.8161 " "a 14.5445,14.5445 0 0 0 -11.3152,23.6652 " "l 27.2573,27.2572 27.2572,-27.2572 " "a 14.5445,14.5445 0 0 0 -11.3012,-23.634 " "a 14.5445,14.5445 0 0 0 -11.414,5.4625 " "l -4.542,4.5420 " "l -4.5437,-4.5420 " "a 14.5445,14.5445 0 0 0 -11.3984,-5.4937 " "z" ) self.assertEqual( "m 1672.24,-54.8161 " "a 14.5445,14.5445 0 0,0 -11.3152,23.6652 " "l 27.2573,27.2572 l 27.2572,-27.2572 " "a 14.5445,14.5445 0 0,0 -11.3012,-23.634 " "a 14.5445,14.5445 0 0,0 -11.414,5.4625 " "l -4.542,4.542 " "l -4.5437,-4.542 " "a 14.5445,14.5445 0 0,0 -11.3984,-5.4937 " "z", path.d(), ) def test_arc_flag(self): """Issue #69""" path = parse_path( "M 5 1 v 7.344 A 3.574 3.574 0 003.5 8 3.515 3.515 0 000 11.5 C 0 13.421 1.579 15 3.5 15 " "A 3.517 3.517 0 007 11.531 v -7.53 h 6 v 4.343 A 3.574 3.574 0 0011.5 8 3.515 3.515 0 008 11.5 " "c 0 1.921 1.579 3.5 3.5 3.5 1.9 0 3.465 -1.546 3.5 -3.437 V 1 z" ) # Check that all elemets is there: self.assertEqual(len(path), 15) # It ends on a vertical line to Y 1: self.assertEqual(path[-1].end.imag, 1) def test_incomplete_numbers(self): path = parse_path("M 0. .1") self.assertEqual(path.d(), "M 0,0.1") path = parse_path("M 0..1") self.assertEqual(path.d(), "M 0,0.1") svg.path-6.3/tests/test_paths.py000066400000000000000000001037041442323775600170300ustar00rootroot00000000000000import unittest from math import sqrt, pi from svg.path.path import CubicBezier, QuadraticBezier, Line, Arc, Move, Close, Path from svg.path.parser import parse_path # Most of these test points are not calculated separately, as that would # take too long and be too error prone. Instead the curves have been verified # to be correct visually, by drawing them with the turtle module, with code # like this: # # import turtle # t = turtle.Turtle() # t.penup() # # for arc in (path1, path2): # p = arc.point(0) # t.goto(p.real - 500, -p.imag + 300) # t.dot(3, 'black') # t.pendown() # for x in range(1, 101): # p = arc.point(x * 0.01) # t.goto(p.real - 500, -p.imag + 300) # t.penup() # t.dot(3, 'black') # # raw_input() # # After the paths have been verified to be correct this way, the testing of # points along the paths has been added as regression tests, to make sure # nobody changes the way curves are drawn by mistake. Therefore, do not take # these points religiously. They might be subtly wrong, unless otherwise # noted. class LineTest(unittest.TestCase): def test_lines(self): # These points are calculated, and not just regression tests. line1 = Line(0j, 400 + 0j) self.assertAlmostEqual(line1.point(0), (0j)) self.assertAlmostEqual(line1.point(0.3), (120 + 0j)) self.assertAlmostEqual(line1.point(0.5), (200 + 0j)) self.assertAlmostEqual(line1.point(0.9), (360 + 0j)) self.assertAlmostEqual(line1.point(1), (400 + 0j)) self.assertAlmostEqual(line1.length(), 400) line2 = Line(400 + 0j, 400 + 300j) self.assertAlmostEqual(line2.point(0), (400 + 0j)) self.assertAlmostEqual(line2.point(0.3), (400 + 90j)) self.assertAlmostEqual(line2.point(0.5), (400 + 150j)) self.assertAlmostEqual(line2.point(0.9), (400 + 270j)) self.assertAlmostEqual(line2.point(1), (400 + 300j)) self.assertAlmostEqual(line2.length(), 300) line3 = Line(400 + 300j, 0j) self.assertAlmostEqual(line3.point(0), (400 + 300j)) self.assertAlmostEqual(line3.point(0.3), (280 + 210j)) self.assertAlmostEqual(line3.point(0.5), (200 + 150j)) self.assertAlmostEqual(line3.point(0.9), (40 + 30j)) self.assertAlmostEqual(line3.point(1), (0j)) self.assertAlmostEqual(line3.length(), 500) def test_equality(self): # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual line = Line(0j, 400 + 0j) self.assertTrue(line == Line(0, 400)) self.assertTrue(line != Line(100, 400)) self.assertFalse(line == str(line)) self.assertTrue(line != str(line)) self.assertFalse( CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j) == line ) class CubicBezierTest(unittest.TestCase): def test_approx_circle(self): """This is a approximate circle drawn in Inkscape""" arc1 = CubicBezier( complex(0, 0), complex(0, 109.66797), complex(-88.90345, 198.57142), complex(-198.57142, 198.57142), ) self.assertAlmostEqual(arc1.point(0), (0j)) self.assertAlmostEqual(arc1.point(0.1), (-2.59896457 + 32.20931647j)) self.assertAlmostEqual(arc1.point(0.2), (-10.12330256 + 62.76392816j)) self.assertAlmostEqual(arc1.point(0.3), (-22.16418039 + 91.25500149j)) self.assertAlmostEqual(arc1.point(0.4), (-38.31276448 + 117.27370288j)) self.assertAlmostEqual(arc1.point(0.5), (-58.16022125 + 140.41119875j)) self.assertAlmostEqual(arc1.point(0.6), (-81.29771712 + 160.25865552j)) self.assertAlmostEqual(arc1.point(0.7), (-107.31641851 + 176.40723961j)) self.assertAlmostEqual(arc1.point(0.8), (-135.80749184 + 188.44811744j)) self.assertAlmostEqual(arc1.point(0.9), (-166.36210353 + 195.97245543j)) self.assertAlmostEqual(arc1.point(1), (-198.57142 + 198.57142j)) arc2 = CubicBezier( complex(-198.57142, 198.57142), complex(-109.66797 - 198.57142, 0 + 198.57142), complex(-198.57143 - 198.57142, -88.90345 + 198.57142), complex(-198.57143 - 198.57142, 0), ) self.assertAlmostEqual(arc2.point(0), (-198.57142 + 198.57142j)) self.assertAlmostEqual(arc2.point(0.1), (-230.78073675 + 195.97245543j)) self.assertAlmostEqual(arc2.point(0.2), (-261.3353492 + 188.44811744j)) self.assertAlmostEqual(arc2.point(0.3), (-289.82642365 + 176.40723961j)) self.assertAlmostEqual(arc2.point(0.4), (-315.8451264 + 160.25865552j)) self.assertAlmostEqual(arc2.point(0.5), (-338.98262375 + 140.41119875j)) self.assertAlmostEqual(arc2.point(0.6), (-358.830082 + 117.27370288j)) self.assertAlmostEqual(arc2.point(0.7), (-374.97866745 + 91.25500149j)) self.assertAlmostEqual(arc2.point(0.8), (-387.0195464 + 62.76392816j)) self.assertAlmostEqual(arc2.point(0.9), (-394.54388515 + 32.20931647j)) self.assertAlmostEqual(arc2.point(1), (-397.14285 + 0j)) arc3 = CubicBezier( complex(-198.57143 - 198.57142, 0), complex(0 - 198.57143 - 198.57142, -109.66797), complex(88.90346 - 198.57143 - 198.57142, -198.57143), complex(-198.57142, -198.57143), ) self.assertAlmostEqual(arc3.point(0), (-397.14285 + 0j)) self.assertAlmostEqual(arc3.point(0.1), (-394.54388515 - 32.20931675j)) self.assertAlmostEqual(arc3.point(0.2), (-387.0195464 - 62.7639292j)) self.assertAlmostEqual(arc3.point(0.3), (-374.97866745 - 91.25500365j)) self.assertAlmostEqual(arc3.point(0.4), (-358.830082 - 117.2737064j)) self.assertAlmostEqual(arc3.point(0.5), (-338.98262375 - 140.41120375j)) self.assertAlmostEqual(arc3.point(0.6), (-315.8451264 - 160.258662j)) self.assertAlmostEqual(arc3.point(0.7), (-289.82642365 - 176.40724745j)) self.assertAlmostEqual(arc3.point(0.8), (-261.3353492 - 188.4481264j)) self.assertAlmostEqual(arc3.point(0.9), (-230.78073675 - 195.97246515j)) self.assertAlmostEqual(arc3.point(1), (-198.57142 - 198.57143j)) arc4 = CubicBezier( complex(-198.57142, -198.57143), complex(109.66797 - 198.57142, 0 - 198.57143), complex(0, 88.90346 - 198.57143), complex(0, 0), ) self.assertAlmostEqual(arc4.point(0), (-198.57142 - 198.57143j)) self.assertAlmostEqual(arc4.point(0.1), (-166.36210353 - 195.97246515j)) self.assertAlmostEqual(arc4.point(0.2), (-135.80749184 - 188.4481264j)) self.assertAlmostEqual(arc4.point(0.3), (-107.31641851 - 176.40724745j)) self.assertAlmostEqual(arc4.point(0.4), (-81.29771712 - 160.258662j)) self.assertAlmostEqual(arc4.point(0.5), (-58.16022125 - 140.41120375j)) self.assertAlmostEqual(arc4.point(0.6), (-38.31276448 - 117.2737064j)) self.assertAlmostEqual(arc4.point(0.7), (-22.16418039 - 91.25500365j)) self.assertAlmostEqual(arc4.point(0.8), (-10.12330256 - 62.7639292j)) self.assertAlmostEqual(arc4.point(0.9), (-2.59896457 - 32.20931675j)) self.assertAlmostEqual(arc4.point(1), (0j)) def test_svg_examples(self): # M100,200 C100,100 250,100 250,200 path1 = CubicBezier(100 + 200j, 100 + 100j, 250 + 100j, 250 + 200j) self.assertAlmostEqual(path1.point(0), (100 + 200j)) self.assertAlmostEqual(path1.point(0.3), (132.4 + 137j)) self.assertAlmostEqual(path1.point(0.5), (175 + 125j)) self.assertAlmostEqual(path1.point(0.9), (245.8 + 173j)) self.assertAlmostEqual(path1.point(1), (250 + 200j)) # S400,300 400,200 path2 = CubicBezier(250 + 200j, 250 + 300j, 400 + 300j, 400 + 200j) self.assertAlmostEqual(path2.point(0), (250 + 200j)) self.assertAlmostEqual(path2.point(0.3), (282.4 + 263j)) self.assertAlmostEqual(path2.point(0.5), (325 + 275j)) self.assertAlmostEqual(path2.point(0.9), (395.8 + 227j)) self.assertAlmostEqual(path2.point(1), (400 + 200j)) # M100,200 C100,100 400,100 400,200 path3 = CubicBezier(100 + 200j, 100 + 100j, 400 + 100j, 400 + 200j) self.assertAlmostEqual(path3.point(0), (100 + 200j)) self.assertAlmostEqual(path3.point(0.3), (164.8 + 137j)) self.assertAlmostEqual(path3.point(0.5), (250 + 125j)) self.assertAlmostEqual(path3.point(0.9), (391.6 + 173j)) self.assertAlmostEqual(path3.point(1), (400 + 200j)) # M100,500 C25,400 475,400 400,500 path4 = CubicBezier(100 + 500j, 25 + 400j, 475 + 400j, 400 + 500j) self.assertAlmostEqual(path4.point(0), (100 + 500j)) self.assertAlmostEqual(path4.point(0.3), (145.9 + 437j)) self.assertAlmostEqual(path4.point(0.5), (250 + 425j)) self.assertAlmostEqual(path4.point(0.9), (407.8 + 473j)) self.assertAlmostEqual(path4.point(1), (400 + 500j)) # M100,800 C175,700 325,700 400,800 path5 = CubicBezier(100 + 800j, 175 + 700j, 325 + 700j, 400 + 800j) self.assertAlmostEqual(path5.point(0), (100 + 800j)) self.assertAlmostEqual(path5.point(0.3), (183.7 + 737j)) self.assertAlmostEqual(path5.point(0.5), (250 + 725j)) self.assertAlmostEqual(path5.point(0.9), (375.4 + 773j)) self.assertAlmostEqual(path5.point(1), (400 + 800j)) # M600,200 C675,100 975,100 900,200 path6 = CubicBezier(600 + 200j, 675 + 100j, 975 + 100j, 900 + 200j) self.assertAlmostEqual(path6.point(0), (600 + 200j)) self.assertAlmostEqual(path6.point(0.3), (712.05 + 137j)) self.assertAlmostEqual(path6.point(0.5), (806.25 + 125j)) self.assertAlmostEqual(path6.point(0.9), (911.85 + 173j)) self.assertAlmostEqual(path6.point(1), (900 + 200j)) # M600,500 C600,350 900,650 900,500 path7 = CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j) self.assertAlmostEqual(path7.point(0), (600 + 500j)) self.assertAlmostEqual(path7.point(0.3), (664.8 + 462.2j)) self.assertAlmostEqual(path7.point(0.5), (750 + 500j)) self.assertAlmostEqual(path7.point(0.9), (891.6 + 532.4j)) self.assertAlmostEqual(path7.point(1), (900 + 500j)) # M600,800 C625,700 725,700 750,800 path8 = CubicBezier(600 + 800j, 625 + 700j, 725 + 700j, 750 + 800j) self.assertAlmostEqual(path8.point(0), (600 + 800j)) self.assertAlmostEqual(path8.point(0.3), (638.7 + 737j)) self.assertAlmostEqual(path8.point(0.5), (675 + 725j)) self.assertAlmostEqual(path8.point(0.9), (740.4 + 773j)) self.assertAlmostEqual(path8.point(1), (750 + 800j)) # S875,900 900,800 inversion = (750 + 800j) + (750 + 800j) - (725 + 700j) path9 = CubicBezier(750 + 800j, inversion, 875 + 900j, 900 + 800j) self.assertAlmostEqual(path9.point(0), (750 + 800j)) self.assertAlmostEqual(path9.point(0.3), (788.7 + 863j)) self.assertAlmostEqual(path9.point(0.5), (825 + 875j)) self.assertAlmostEqual(path9.point(0.9), (890.4 + 827j)) self.assertAlmostEqual(path9.point(1), (900 + 800j)) def test_length(self): # A straight line: arc = CubicBezier( complex(0, 0), complex(0, 0), complex(0, 100), complex(0, 100) ) self.assertAlmostEqual(arc.length(), 100) # A diagonal line: arc = CubicBezier( complex(0, 0), complex(0, 0), complex(100, 100), complex(100, 100) ) self.assertAlmostEqual(arc.length(), sqrt(2 * 100 * 100)) # A quarter circle arc with radius 100: kappa = ( 4 * (sqrt(2) - 1) / 3 ) # http://www.whizkidtech.redprince.net/bezier/circle/ arc = CubicBezier( complex(0, 0), complex(0, kappa * 100), complex(100 - kappa * 100, 100), complex(100, 100), ) # We can't compare with pi*50 here, because this is just an # approximation of a circle arc. pi*50 is 157.079632679 # So this is just yet another "warn if this changes" test. # This value is not verified to be correct. self.assertAlmostEqual(arc.length(), 157.1016698) # A recursive solution has also been suggested, but for CubicBezier # curves it could get a false solution on curves where the midpoint is on a # straight line between the start and end. For example, the following # curve would get solved as a straight line and get the length 300. # Make sure this is not the case. arc = CubicBezier( complex(600, 500), complex(600, 350), complex(900, 650), complex(900, 500) ) self.assertTrue(arc.length() > 300.0) def test_equality(self): # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual segment = CubicBezier( complex(600, 500), complex(600, 350), complex(900, 650), complex(900, 500) ) self.assertTrue( segment == CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j) ) self.assertTrue( segment != CubicBezier(600 + 501j, 600 + 350j, 900 + 650j, 900 + 500j) ) self.assertTrue(segment != Line(0, 400)) def test_smooth(self): cb1 = CubicBezier(0, 0, 100 + 100j, 100 + 100j) cb2 = CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j) self.assertFalse(cb2.is_smooth_from(cb1)) cb2.set_smooth_from(cb1) self.assertTrue(cb2.is_smooth_from(cb1)) class QuadraticBezierTest(unittest.TestCase): def test_svg_examples(self): """These is the path in the SVG specs""" # M200,300 Q400,50 600,300 T1000,300 path1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) self.assertAlmostEqual(path1.point(0), (200 + 300j)) self.assertAlmostEqual(path1.point(0.3), (320 + 195j)) self.assertAlmostEqual(path1.point(0.5), (400 + 175j)) self.assertAlmostEqual(path1.point(0.9), (560 + 255j)) self.assertAlmostEqual(path1.point(1), (600 + 300j)) # T1000, 300 inversion = (600 + 300j) + (600 + 300j) - (400 + 50j) path2 = QuadraticBezier(600 + 300j, inversion, 1000 + 300j) self.assertAlmostEqual(path2.point(0), (600 + 300j)) self.assertAlmostEqual(path2.point(0.3), (720 + 405j)) self.assertAlmostEqual(path2.point(0.5), (800 + 425j)) self.assertAlmostEqual(path2.point(0.9), (960 + 345j)) self.assertAlmostEqual(path2.point(1), (1000 + 300j)) def test_length(self): # expected results calculated with # svg.path.segment_length(q, 0, 1, q.start, q.end, 1e-14, 20, 0) q1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) q2 = QuadraticBezier(200 + 300j, 400 + 50j, 500 + 200j) closedq = QuadraticBezier(6 + 2j, 5 - 1j, 6 + 2j) linq1 = QuadraticBezier(1, 2, 3) linq2 = QuadraticBezier(1 + 3j, 2 + 5j, -9 - 17j) nodalq = QuadraticBezier(1, 1, 1) tests = [ (q1, 487.77109389525975), (q2, 379.90458193489155), (closedq, 3.1622776601683795), (linq1, 2), (linq2, 22.73335777124786), (nodalq, 0), ] for q, exp_res in tests: self.assertAlmostEqual(q.length(), exp_res) def test_equality(self): # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual segment = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) self.assertTrue(segment == QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j)) self.assertTrue(segment != QuadraticBezier(200 + 301j, 400 + 50j, 600 + 300j)) self.assertFalse(segment == Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)) self.assertTrue(Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) != segment) def test_linear_arcs_issue_61(self): p = parse_path("M 206.5,525 Q 162.5,583 162.5,583") self.assertAlmostEqual(p.length(), 72.80109889280519) p = parse_path("M 425.781 446.289 Q 410.40000000000003 373.047 410.4 373.047") self.assertAlmostEqual(p.length(), 74.83959997888816) p = parse_path("M 639.648 568.115 Q 606.6890000000001 507.568 606.689 507.568") self.assertAlmostEqual(p.length(), 68.93645544992873) p = parse_path("M 288.818 616.699 Q 301.025 547.3629999999999 301.025 547.363") self.assertAlmostEqual(p.length(), 70.40235610403947) p = parse_path("M 339.927 706.25 Q 243.92700000000002 806.25 243.927 806.25") self.assertAlmostEqual(p.length(), 138.6217876093077) p = parse_path( "M 539.795 702.637 Q 548.0959999999999 803.4669999999999 548.096 803.467" ) self.assertAlmostEqual(p.length(), 101.17111989594662) p = parse_path( "M 537.815 555.042 Q 570.1680000000001 499.1600000000001 570.168 499.16" ) self.assertAlmostEqual(p.length(), 64.57177814649368) p = parse_path("M 615.297 470.503 Q 538.797 694.5029999999999 538.797 694.503") self.assertAlmostEqual(p.length(), 236.70287281737836) def test_smooth(self): cb1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) cb2 = QuadraticBezier(600 + 300j, 400 + 50j, 1000 + 300j) self.assertFalse(cb2.is_smooth_from(cb1)) cb2.set_smooth_from(cb1) self.assertTrue(cb2.is_smooth_from(cb1)) class ArcTest(unittest.TestCase): def test_points(self): arc1 = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) self.assertAlmostEqual(arc1.center, 100 + 0j) self.assertAlmostEqual(arc1.theta, 180.0) self.assertAlmostEqual(arc1.delta, -90.0) self.assertAlmostEqual(arc1.point(0.0), (0j)) self.assertAlmostEqual(arc1.point(0.1), (1.23116594049 + 7.82172325201j)) self.assertAlmostEqual(arc1.point(0.2), (4.89434837048 + 15.4508497187j)) self.assertAlmostEqual(arc1.point(0.3), (10.8993475812 + 22.699524987j)) self.assertAlmostEqual(arc1.point(0.4), (19.0983005625 + 29.3892626146j)) self.assertAlmostEqual(arc1.point(0.5), (29.2893218813 + 35.3553390593j)) self.assertAlmostEqual(arc1.point(0.6), (41.2214747708 + 40.4508497187j)) self.assertAlmostEqual(arc1.point(0.7), (54.6009500260 + 44.5503262094j)) self.assertAlmostEqual(arc1.point(0.8), (69.0983005625 + 47.5528258148j)) self.assertAlmostEqual(arc1.point(0.9), (84.3565534960 + 49.3844170298j)) self.assertAlmostEqual(arc1.point(1.0), (100 + 50j)) arc2 = Arc(0j, 100 + 50j, 0, 1, 0, 100 + 50j) self.assertAlmostEqual(arc2.center, 50j) self.assertAlmostEqual(arc2.theta, 270.0) self.assertAlmostEqual(arc2.delta, -270.0) self.assertAlmostEqual(arc2.point(0.0), (0j)) self.assertAlmostEqual(arc2.point(0.1), (-45.399049974 + 5.44967379058j)) self.assertAlmostEqual(arc2.point(0.2), (-80.9016994375 + 20.6107373854j)) self.assertAlmostEqual(arc2.point(0.3), (-98.7688340595 + 42.178276748j)) self.assertAlmostEqual(arc2.point(0.4), (-95.1056516295 + 65.4508497187j)) self.assertAlmostEqual(arc2.point(0.5), (-70.7106781187 + 85.3553390593j)) self.assertAlmostEqual(arc2.point(0.6), (-30.9016994375 + 97.5528258148j)) self.assertAlmostEqual(arc2.point(0.7), (15.643446504 + 99.3844170298j)) self.assertAlmostEqual(arc2.point(0.8), (58.7785252292 + 90.4508497187j)) self.assertAlmostEqual(arc2.point(0.9), (89.1006524188 + 72.699524987j)) self.assertAlmostEqual(arc2.point(1.0), (100 + 50j)) arc3 = Arc(0j, 100 + 50j, 0, 0, 1, 100 + 50j) self.assertAlmostEqual(arc3.center, 50j) self.assertAlmostEqual(arc3.theta, 270.0) self.assertAlmostEqual(arc3.delta, 90.0) self.assertAlmostEqual(arc3.point(0.0), (0j)) self.assertAlmostEqual(arc3.point(0.1), (15.643446504 + 0.615582970243j)) self.assertAlmostEqual(arc3.point(0.2), (30.9016994375 + 2.44717418524j)) self.assertAlmostEqual(arc3.point(0.3), (45.399049974 + 5.44967379058j)) self.assertAlmostEqual(arc3.point(0.4), (58.7785252292 + 9.54915028125j)) self.assertAlmostEqual(arc3.point(0.5), (70.7106781187 + 14.6446609407j)) self.assertAlmostEqual(arc3.point(0.6), (80.9016994375 + 20.6107373854j)) self.assertAlmostEqual(arc3.point(0.7), (89.1006524188 + 27.300475013j)) self.assertAlmostEqual(arc3.point(0.8), (95.1056516295 + 34.5491502813j)) self.assertAlmostEqual(arc3.point(0.9), (98.7688340595 + 42.178276748j)) self.assertAlmostEqual(arc3.point(1.0), (100 + 50j)) arc4 = Arc(0j, 100 + 50j, 0, 1, 1, 100 + 50j) self.assertAlmostEqual(arc4.center, 100 + 0j) self.assertAlmostEqual(arc4.theta, 180.0) self.assertAlmostEqual(arc4.delta, 270.0) self.assertAlmostEqual(arc4.point(0.0), (0j)) self.assertAlmostEqual(arc4.point(0.1), (10.8993475812 - 22.699524987j)) self.assertAlmostEqual(arc4.point(0.2), (41.2214747708 - 40.4508497187j)) self.assertAlmostEqual(arc4.point(0.3), (84.3565534960 - 49.3844170298j)) self.assertAlmostEqual(arc4.point(0.4), (130.901699437 - 47.5528258148j)) self.assertAlmostEqual(arc4.point(0.5), (170.710678119 - 35.3553390593j)) self.assertAlmostEqual(arc4.point(0.6), (195.105651630 - 15.4508497187j)) self.assertAlmostEqual(arc4.point(0.7), (198.768834060 + 7.82172325201j)) self.assertAlmostEqual(arc4.point(0.8), (180.901699437 + 29.3892626146j)) self.assertAlmostEqual(arc4.point(0.9), (145.399049974 + 44.5503262094j)) self.assertAlmostEqual(arc4.point(1.0), (100 + 50j)) def test_length(self): # I'll test the length calculations by making a circle, in two parts. arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j) arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j) self.assertAlmostEqual(arc1.length(), pi * 100) self.assertAlmostEqual(arc2.length(), pi * 100) def test_length_out_of_range(self): # See F.6.2 Out-of-range parameters # If the endpoints (x1, y1) and (x2, y2) are identical, then this is # equivalent to omitting the elliptical arc segment entirely. arc = Arc(0j, 100 + 100j, 0, 0, 0, 0j) self.assertAlmostEqual(arc.length(), 0) # If rx = 0 or ry = 0 then this arc is treated as a straight # line segment (a "lineto") joining the endpoints. arc = Arc(0j, 0j, 0, 0, 0, 200 + 0j) self.assertAlmostEqual(arc.length(), 200) # If rx or ry have negative signs, these are dropped; # the absolute value is used instead. arc = Arc(200 + 0j, -100 - 100j, 0, 0, 0, 0j) self.assertAlmostEqual(arc.length(), pi * 100) # If rx, ry and φ are such that there is no solution (basically, # the ellipse is not big enough to reach from (x1, y1) to (x2, y2)) # then the ellipse is scaled up uniformly until there is exactly # one solution (until the ellipse is just big enough). arc = Arc(0j, 1 + 1j, 0, 0, 0, 200 + 0j) self.assertAlmostEqual(arc.length(), pi * 100) # φ is taken mod 360 degrees. arc = Arc(200 + 0j, -100 - 100j, 720, 0, 0, 0j) self.assertAlmostEqual(arc.length(), pi * 100) def test_equality(self): # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual segment = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) self.assertTrue(segment == Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)) self.assertTrue(segment != Arc(0j, 100 + 50j, 0, 1, 0, 100 + 50j)) def test_issue25(self): # This raised a math domain error Arc( (725.307482225571 - 915.5548199281527j), (202.79421639137703 + 148.77294617167183j), 225.6910319606926, 1, 1, (-624.6375539637027 + 896.5483089399895j), ) class TestPath(unittest.TestCase): def test_circle(self): arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j) arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j) path = Path(arc1, arc2) self.assertAlmostEqual(path.point(0.0), (0j)) self.assertAlmostEqual(path.point(0.25), (100 + 100j)) self.assertAlmostEqual(path.point(0.5), (200 + 0j)) self.assertAlmostEqual(path.point(0.75), (100 - 100j)) self.assertAlmostEqual(path.point(1.0), (0j)) self.assertAlmostEqual(path.length(), pi * 200) def test_svg_specs(self): """The paths that are in the SVG specs""" # Big pie: M300,200 h-150 a150,150 0 1,0 150,-150 z path = Path( Line(300 + 200j, 150 + 200j), Arc(150 + 200j, 150 + 150j, 0, 1, 0, 300 + 50j), Line(300 + 50j, 300 + 200j), ) # The points and length for this path are calculated and not regression tests. self.assertAlmostEqual(path.point(0.0), (300 + 200j)) self.assertAlmostEqual(path.point(0.14897825542), (150 + 200j)) self.assertAlmostEqual(path.point(0.5), (406.066017177 + 306.066017177j)) self.assertAlmostEqual(path.point(1 - 0.14897825542), (300 + 50j)) self.assertAlmostEqual(path.point(1.0), (300 + 200j)) # The errors seem to accumulate. Still 6 decimal places is more than good enough. self.assertAlmostEqual(path.length(), pi * 225 + 300, places=6) # Little pie: M275,175 v-150 a150,150 0 0,0 -150,150 z path = Path( Line(275 + 175j, 275 + 25j), Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j), Line(125 + 175j, 275 + 175j), ) # The points and length for this path are calculated and not regression tests. self.assertAlmostEqual(path.point(0.0), (275 + 175j)) self.assertAlmostEqual(path.point(0.2800495767557787), (275 + 25j)) self.assertAlmostEqual( path.point(0.5), (168.93398282201787 + 68.93398282201787j) ) self.assertAlmostEqual(path.point(1 - 0.2800495767557787), (125 + 175j)) self.assertAlmostEqual(path.point(1.0), (275 + 175j)) # The errors seem to accumulate. Still 6 decimal places is more than good enough. self.assertAlmostEqual(path.length(), pi * 75 + 300, places=6) # Bumpy path: M600,350 l 50,-25 # a25,25 -30 0,1 50,-25 l 50,-25 # a25,50 -30 0,1 50,-25 l 50,-25 # a25,75 -30 0,1 50,-25 l 50,-25 # a25,100 -30 0,1 50,-25 l 50,-25 path = Path( Line(600 + 350j, 650 + 325j), Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j), Line(700 + 300j, 750 + 275j), Arc(750 + 275j, 25 + 50j, -30, 0, 1, 800 + 250j), Line(800 + 250j, 850 + 225j), Arc(850 + 225j, 25 + 75j, -30, 0, 1, 900 + 200j), Line(900 + 200j, 950 + 175j), Arc(950 + 175j, 25 + 100j, -30, 0, 1, 1000 + 150j), Line(1000 + 150j, 1050 + 125j), ) # These are *not* calculated, but just regression tests. Be skeptical. self.assertAlmostEqual(path.point(0.0), (600 + 350j)) self.assertAlmostEqual(path.point(0.3), (755.23979927 + 212.1820209585j)) self.assertAlmostEqual(path.point(0.5), (827.73074926 + 147.8241574162j)) self.assertAlmostEqual(path.point(0.9), (971.28435780 + 106.3023526073j)) self.assertAlmostEqual(path.point(1.0), (1050 + 125j)) self.assertAlmostEqual(path.length(), 928.388639381) def test_repr(self): path = Path( Line(start=600 + 350j, end=650 + 325j), Arc( start=650 + 325j, radius=25 + 25j, rotation=-30, arc=0, sweep=1, end=700 + 300j, ), CubicBezier( start=700 + 300j, control1=800 + 400j, control2=750 + 200j, end=600 + 100j, ), QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j), ) self.assertEqual(eval(repr(path)), path) def test_reverse(self): # Currently you can't reverse paths. self.assertRaises(NotImplementedError, Path().reverse) def test_equality(self): # This is to test the __eq__ and __ne__ methods, so we can't use # assertEqual and assertNotEqual path1 = Path( Line(start=600 + 350j, end=650 + 325j), Arc( start=650 + 325j, radius=25 + 25j, rotation=-30, arc=0, sweep=1, end=700 + 300j, ), CubicBezier( start=700 + 300j, control1=800 + 400j, control2=750 + 200j, end=600 + 100j, ), QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j), ) path2 = Path( Line(start=600 + 350j, end=650 + 325j), Arc( start=650 + 325j, radius=25 + 25j, rotation=-30, arc=0, sweep=1, end=700 + 300j, ), CubicBezier( start=700 + 300j, control1=800 + 400j, control2=750 + 200j, end=600 + 100j, ), QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j), ) self.assertTrue(path1 == path2) # Modify path2: path2[0].start = 601 + 350j self.assertTrue(path1 != path2) # Modify back: path2[0].start = 600 + 350j self.assertFalse(path1 != path2) # Get rid of the last segment: del path2[-1] self.assertFalse(path1 == path2) # It's not equal to a list of it's segments self.assertTrue(path1 != path1[:]) self.assertFalse(path1 == path1[:]) def test_non_arc(self): # And arc with the same start and end is a noop. segment = Arc(0j + 70j, 35 + 35j, 0, 1, 0, 0 + 70j) self.assertEqual(segment.length(), 0) self.assertEqual(segment.point(0.5), segment.start) def test_zero_paths(self): move_only = Path(Move(0)) self.assertEqual(move_only.point(0), 0 + 0j) self.assertEqual(move_only.point(0.5), 0 + 0j) self.assertEqual(move_only.point(1), 0 + 0j) self.assertEqual(move_only.length(), 0) move_onlyz = Path(Move(0), Close(0, 0)) self.assertEqual(move_onlyz.point(0), 0 + 0j) self.assertEqual(move_onlyz.point(0.5), 0 + 0j) self.assertEqual(move_onlyz.point(1), 0 + 0j) self.assertEqual(move_onlyz.length(), 0) zero_line = Path(Move(0), Line(0, 0)) self.assertEqual(zero_line.point(0), 0 + 0j) self.assertEqual(zero_line.point(0.5), 0 + 0j) self.assertEqual(zero_line.point(1), 0 + 0j) self.assertEqual(zero_line.length(), 0) only_line = Path(Line(1 + 1j, 1 + 1j)) self.assertEqual(only_line.point(0), 1 + 1j) self.assertEqual(only_line.point(0.5), 1 + 1j) self.assertEqual(only_line.point(1), 1 + 1j) self.assertEqual(only_line.length(), 0) def test_tangent(self): path = Path( Line(start=600 + 350j, end=650 + 325j), Arc( start=650 + 325j, radius=25 + 25j, rotation=-30, arc=0, sweep=1, end=700 + 300j, ), CubicBezier( start=700 + 300j, control1=800 + 400j, control2=750 + 200j, end=600 + 100j, ), QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j), ) self.assertEqual(path.tangent(0), 50 - 25j) # These are *not* calculated, but just regression tests. Be skeptical. self.assertAlmostEqual( path.tangent(0.25), 197.17077123205894 + 106.56022001841387j ) self.assertAlmostEqual( path.tangent(0.5), -226.30788045372367 - 364.5433357646594j ) self.assertAlmostEqual(path.tangent(0.75), 13.630819414210208j) self.assertAlmostEqual(path.tangent(1), 600j) def test_tangent_magnitude(self): line1 = Line(start=6 + 3.5j, end=6.5 + 3.25j) line2 = Line(start=6 + 3.5j, end=7 + 3j) # line2 is twice as long as line1, the tangent should have twice the magnitude: self.assertAlmostEqual(line2.tangent(0.5) / line1.tangent(0.5), 2) arc1 = Arc( start=0 - 2.5j, radius=2.5 + 2.5j, rotation=0, arc=0, sweep=1, end=0 + 2.5j ) arc2 = Arc(start=0 - 5j, radius=5 + 5j, rotation=0, arc=0, sweep=1, end=0 + 5j) # The radius is twice as large, so the magnitude is twice as large self.assertAlmostEqual(arc2.tangent(0.5) / arc1.tangent(0.5), 2) bez1 = CubicBezier(start=0, control1=1 + 1j, control2=2 - 1j, end=3) bez2 = CubicBezier(start=0, control1=2 + 2j, control2=4 - 2j, end=6) # Length should be double, tangent is double. self.assertAlmostEqual(bez2.tangent(0.5) / bez1.tangent(0.5), 2) qb1 = QuadraticBezier(start=0, control=1 + 1j, end=2) qb2 = QuadraticBezier(start=0, control=2 + 2j, end=4) # Length should be double, tangent is double. self.assertAlmostEqual(qb2.tangent(0.5) / qb1.tangent(0.5), 2) # Code for visually verifying these tangents. I should make a test of this. # import turtle # t = turtle.Turtle() # t.penup() # for arc in (line1, line2, arc1, arc2, bez1, bez2): # p = arc.point(0) # t.goto(p.real*20, -p.imag*20) # t.dot(3, 'black') # t.pendown() # for x in range(1, 101): # p = arc.point(x * 0.01) # t.goto(p.real*20,-p.imag*20) # t.penup() # t.dot(3, 'black') # p = arc.point(0.5) # t.goto(p.real*20,-p.imag*20) # t.dot(3, 'red') # t.pendown() # p += arc.tangent(0.5) # t.goto(p.real*20,-p.imag*20) # t.penup() svg.path-6.3/tests/test_tokenizer.py000066400000000000000000000046741442323775600177310ustar00rootroot00000000000000import pytest from svg.path import parser PATHS = [ ( "M 100 100 L 300 100 L 200 300 z", [("M", "100 100"), ("L", "300 100"), ("L", "200 300"), ("z", "")], [("M", 100 + 100j), ("L", 300 + 100j), ("L", 200 + 300j), ("z",)], ), ( "M 5 1 v 7.344 A 3.574 3.574 0 003.5 8 3.515 3.515 0 000 11.5 C 0 13.421 1.579 15 3.5 15 " "A 3.517 3.517 0 007 11.531 v -7.53 h 6 v 4.343 A 3.574 3.574 0 0011.5 8 3.515 3.515 0 008 11.5 " "c 0 1.921 1.579 3.5 3.5 3.5 1.9 0 3.465 -1.546 3.5 -3.437 V 1 z", [ ("M", "5 1"), ("v", "7.344"), ("A", "3.574 3.574 0 003.5 8 3.515 3.515 0 000 11.5"), ("C", "0 13.421 1.579 15 3.5 15"), ("A", "3.517 3.517 0 007 11.531"), ("v", "-7.53"), ("h", "6"), ("v", "4.343"), ("A", "3.574 3.574 0 0011.5 8 3.515 3.515 0 008 11.5"), ("c", "0 1.921 1.579 3.5 3.5 3.5 1.9 0 3.465 -1.546 3.5 -3.437"), ("V", "1"), ("z", ""), ], [ ("M", 5 + 1j), ("v", 7.344), ("A", 3.574, 3.574, 0, False, False, 3.5 + 8j), ("A", 3.515, 3.515, 0, False, False, 0 + 11.5j), ("C", 0 + 13.421j, 1.579 + 15j, 3.5 + 15j), ("A", 3.517, 3.517, 0, False, False, 7 + 11.531j), ("v", -7.53), ("h", 6), ("v", 4.343), ("A", 3.574, 3.574, 0, False, False, 11.5 + 8j), ("A", 3.515, 3.515, 0, False, False, 8 + 11.5j), ("c", 0 + 1.921j, 1.579 + 3.5j, 3.5 + 3.5j), ("c", 1.9 + 0j, 3.465 - 1.546j, 3.5 - 3.437j), ("V", 1), ("z",), ], ), ( "M 600,350 L 650,325 A 25,25 -30 0,1 700,300 L 750,275", [ ("M", "600,350"), ("L", "650,325"), ("A", "25,25 -30 0,1 700,300"), ("L", "750,275"), ], [ ("M", 600 + 350j), ("L", 650 + 325j), ("A", 25, 25, -30, False, True, 700 + 300j), ("L", 750 + 275j), ], ), ] @pytest.mark.parametrize("path, commands, tokens", PATHS) def test_commandifier(path, commands, tokens): assert list(parser._commandify_path(path)) == commands assert list(parser._tokenize_path(path)) == tokens @pytest.mark.parametrize("path, commands, tokens", PATHS) def test_parser(path, commands, tokens): path = parser.parse_path(path)