diff --git a/MANIFEST.in b/MANIFEST.in index b7d0cbf..6ec45c1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include *.svg LICENSE* +include svgpathtools/py.typed recursive-include test *.svg \ No newline at end of file diff --git a/setup.py b/setup.py index 07b678f..f739263 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ def read(relative_path): setup(name='svgpathtools', packages=['svgpathtools'], + package_data={'svgpathtools': ['py.typed']}, version=VERSION, description=('A collection of tools for manipulating and analyzing SVG ' 'Path objects and Bezier curves.'), diff --git a/svgpathtools/bezier.py b/svgpathtools/bezier.py index 32ca71f..2f81670 100644 --- a/svgpathtools/bezier.py +++ b/svgpathtools/bezier.py @@ -4,29 +4,71 @@ points given by their standard representation.""" # External dependencies: -from __future__ import division, absolute_import, print_function +from __future__ import division, absolute_import, print_function, annotations +from typing import (TYPE_CHECKING, Any, Literal, Protocol, Sequence, Tuple, + Union, overload) from math import factorial as fac, ceil, log, sqrt from numpy import poly1d # Internal dependencies -from .polytools import real, imag, polyroots, polyroots01 +from .polytools import real, imag, polyroots, polyroots01, Coefficients from .constants import FLOAT_EPSILON +if TYPE_CHECKING: + # Imported for type annotations only; importing `path` at runtime would + # be circular (`path` imports this submodule). + from .path import Arc, Line + + +class Bezier(Protocol): + """A Bezier curve in the standard representation used throughout this + submodule: its control points. + + Structural, so that it covers both a plain sequence of control points + and the `Line`, `QuadraticBezier` and `CubicBezier` objects from + `path`, which expose their control points via __getitem__/__len__ + without subclassing Sequence. + """ + + def __len__(self) -> int: ... + + @overload + def __getitem__(self, i: int, /) -> complex: ... + + @overload + def __getitem__(self, i: slice, /) -> Sequence[complex]: ... + + +# Several functions below also accept an `Arc` (see the "arc support" +# blocks), which is not expressible as a sequence of control points. +BezierOrArc = Union[Bezier, "Arc"] + +# An upright bounding box, (xmin, xmax, ymin, ymax). +BoundingBox = Tuple[float, float, float, float] + # Evaluation ################################################################## -def n_choose_k(n, k): +def n_choose_k(n: int, k: int) -> int: return fac(n)//fac(k)//fac(n-k) -def bernstein(n, t): +def bernstein(n: int, t: float) -> list[float]: """returns a list of the Bernstein basis polynomials b_{i, n} evaluated at t, for i =0...n""" t1 = 1-t return [n_choose_k(n, k) * t1**(n-k) * t**k for k in range(n+1)] -def bezier_point(p, t): +@overload +def bezier_point(p: Sequence[float], t: float) -> float: ... + + +@overload +def bezier_point(p: BezierOrArc, t: float) -> complex: ... + + +def bezier_point(p: Any, t: float) -> Any: """Evaluates the Bezier curve given by it's control points, p, at t. Note: Uses Horner's rule for cubic and lower order Bezier curves. Warning: Be concerned about numerical stability when using this function @@ -61,13 +103,33 @@ def bezier_point(p, t): # Conversion ################################################################## -def bezier2polynomial(p, numpy_ordering=True, return_poly1d=False): +@overload +def bezier2polynomial(p: Bezier, numpy_ordering: bool = ..., + return_poly1d: Literal[False] = ... + ) -> Sequence[complex]: ... + + +@overload +def bezier2polynomial(p: Bezier, numpy_ordering: bool = ..., + return_poly1d: Literal[True] = ...) -> poly1d: ... + + +@overload +def bezier2polynomial(p: Bezier, numpy_ordering: bool = ..., + return_poly1d: bool = ... + ) -> Union[Sequence[complex], poly1d]: ... + + +def bezier2polynomial(p: Bezier, numpy_ordering: bool = True, + return_poly1d: bool = False + ) -> Union[Sequence[complex], poly1d]: """Converts a tuple of Bezier control points to a tuple of coefficients of the expanded polynomial. return_poly1d : returns a numpy.poly1d object. This makes computations of derivatives/anti-derivatives and many other operations quite quick. numpy_ordering : By default (to accommodate numpy) the coefficients will be output in reverse standard order.""" + coeffs: Sequence[complex] if len(p) == 4: coeffs = (-p[0] + 3*(p[1] - p[2]) + p[3], 3*(p[0] - 2*p[1] + p[2]), @@ -81,7 +143,7 @@ def bezier2polynomial(p, numpy_ordering=True, return_poly1d=False): coeffs = (p[1]-p[0], p[0]) elif len(p) == 1: - coeffs = p + coeffs = p # type: ignore[assignment] else: # https://en.wikipedia.org/wiki/Bezier_curve#Polynomial_form n = len(p) - 1 @@ -96,14 +158,16 @@ def bezier2polynomial(p, numpy_ordering=True, return_poly1d=False): return coeffs -def polynomial2bezier(poly): +def polynomial2bezier(poly: Coefficients) -> tuple[complex, ...]: """Converts a cubic or lower order Polynomial object (or a sequence of coefficients) to a CubicBezier, QuadraticBezier, or Line object as appropriate.""" + c: Coefficients if isinstance(poly, poly1d): c = poly.coeffs else: c = poly + bpoints: tuple[complex, ...] order = len(c)-1 if order == 3: bpoints = (c[3], c[2]/3 + c[3], (c[1] + 2*c[2])/3 + c[3], @@ -120,15 +184,19 @@ def polynomial2bezier(poly): # Curve Splitting ############################################################# -def split_bezier(bpoints, t): +def split_bezier(bpoints: Bezier, + t: float) -> tuple[list[complex], list[complex]]: """Uses deCasteljau's recursion to split the Bezier curve at t into two Bezier curves of the same order.""" - def split_bezier_recursion(bpoints_left_, bpoints_right_, bpoints_, t_): + def split_bezier_recursion( + bpoints_left_: list[complex], bpoints_right_: list[complex], + bpoints_: Bezier, t_: float + ) -> tuple[list[complex], list[complex]]: if len(bpoints_) == 1: bpoints_left_.append(bpoints_[0]) bpoints_right_.append(bpoints_[0]) else: - new_points = [None]*(len(bpoints_) - 1) + new_points: list[complex] = [None]*(len(bpoints_) - 1) # type: ignore[list-item] bpoints_left_.append(bpoints_[0]) bpoints_right_.append(bpoints_[-1]) for i in range(len(bpoints_) - 1): @@ -137,15 +205,23 @@ def split_bezier_recursion(bpoints_left_, bpoints_right_, bpoints_, t_): bpoints_left_, bpoints_right_, new_points, t_) return bpoints_left_, bpoints_right_ - bpoints_left = [] - bpoints_right = [] + bpoints_left: list[complex] = [] + bpoints_right: list[complex] = [] bpoints_left, bpoints_right = \ split_bezier_recursion(bpoints_left, bpoints_right, bpoints, t) bpoints_right.reverse() return bpoints_left, bpoints_right -def halve_bezier(p): +@overload +def halve_bezier(p: Bezier) -> tuple[list[complex], list[complex]]: ... + + +@overload +def halve_bezier(p: Arc) -> tuple[Arc, Arc]: ... + + +def halve_bezier(p: Any) -> Any: # begin arc support block ######################## try: @@ -166,9 +242,9 @@ def halve_bezier(p): # Bounding Boxes ############################################################## -def bezier_real_minmax(p): +def bezier_real_minmax(p: Sequence[float]) -> tuple[float, float]: """returns the minimum and maximum for any real cubic bezier""" - local_extremizers = [0, 1] + local_extremizers: list[float] = [0, 1] if len(p) == 4: # cubic case a = [p.real for p in p] denom = a[0] - 3*a[1] + 3*a[2] - a[3] @@ -195,38 +271,40 @@ def bezier_real_minmax(p): return min(local_extrema), max(local_extrema) -def bezier_bounding_box(bez): +def bezier_bounding_box(bez: BezierOrArc) -> BoundingBox: """returns the bounding box for the segment in the form (xmin, xmax, ymin, ymax). Warning: For the non-cubic case this is not particularly efficient.""" # begin arc support block ######################## try: - bla = bez.large_arc - return bez.bbox() # added to support Arc objects + bez.large_arc # type: ignore[union-attr] + return bez.bbox() # type: ignore[union-attr] # added to support Arc objects except: pass # end arc support block ########################## - if len(bez) == 4: - xmin, xmax = bezier_real_minmax([p.real for p in bez]) - ymin, ymax = bezier_real_minmax([p.imag for p in bez]) + if len(bez) == 4: # type: ignore[arg-type] + # (mypy does not model iteration via __getitem__) + xmin, xmax = bezier_real_minmax([p.real for p in bez]) # type: ignore[union-attr] + ymin, ymax = bezier_real_minmax([p.imag for p in bez]) # type: ignore[union-attr] return xmin, xmax, ymin, ymax - poly = bezier2polynomial(bez, return_poly1d=True) + poly: poly1d + poly = bezier2polynomial(bez, return_poly1d=True) # type: ignore[arg-type, assignment] x = real(poly) y = imag(poly) dx = x.deriv() dy = y.deriv() - x_extremizers = [0, 1] + polyroots(dx, realroots=True, + x_extremizers: list[float] = [0, 1] + polyroots(dx, realroots=True, condition=lambda r: 0 < r < 1) - y_extremizers = [0, 1] + polyroots(dy, realroots=True, + y_extremizers: list[float] = [0, 1] + polyroots(dy, realroots=True, condition=lambda r: 0 < r < 1) x_extrema = [x(t) for t in x_extremizers] y_extrema = [y(t) for t in y_extremizers] return min(x_extrema), max(x_extrema), min(y_extrema), max(y_extrema) -def box_area(xmin, xmax, ymin, ymax): +def box_area(xmin: float, xmax: float, ymin: float, ymax: float) -> float: """ INPUT: 2-tuple of cubics (given by control points) OUTPUT: boolean @@ -234,13 +312,14 @@ def box_area(xmin, xmax, ymin, ymax): return (xmax - xmin)*(ymax - ymin) -def interval_intersection_width(a, b, c, d): +def interval_intersection_width(a: float, b: float, + c: float, d: float) -> float: """returns the width of the intersection of intervals [a,b] and [c,d] (thinking of these as intervals on the real number line)""" return max(0, min(b, d) - max(a, c)) -def boxes_intersect(box1, box2): +def boxes_intersect(box1: BoundingBox, box2: BoundingBox) -> bool: """Determines if two rectangles, each input as a tuple (xmin, xmax, ymin, ymax), intersect.""" xmin1, xmax1, ymin1, ymax1 = box1 @@ -257,29 +336,32 @@ def boxes_intersect(box1, box2): class ApproxSolutionSet(list): """A class that behaves like a set but treats two elements , x and y, as equivalent if abs(x-y) < self.tol""" - def __init__(self, tol): + def __init__(self, tol: float) -> None: self.tol = tol - def __contains__(self, x): + def __contains__(self, x: Any) -> bool: for y in self: if abs(x - y) < self.tol: return True return False - def appadd(self, pt): + def appadd(self, pt: complex) -> None: if pt not in self: self.append(pt) class BPair(object): - def __init__(self, bez1, bez2, t1, t2): + def __init__(self, bez1: BezierOrArc, bez2: BezierOrArc, + t1: float, t2: float) -> None: self.bez1 = bez1 self.bez2 = bez2 self.t1 = t1 # t value to get the mid point of this curve from cub1 self.t2 = t2 # t value to get the mid point of this curve from cub2 -def bezier_intersections(bez1, bez2, longer_length, tol=1e-8, tol_deC=1e-8): +def bezier_intersections(bez1: BezierOrArc, bez2: BezierOrArc, + longer_length: float, tol: float = 1e-8, + tol_deC: float = 1e-8) -> list[tuple[float, float]]: """INPUT: bez1, bez2 = [P0,P1,P2,...PN], [Q0,Q1,Q2,...,PN] defining the two Bezier curves to check for intersections between. @@ -293,7 +375,7 @@ def bezier_intersections(bez1, bez2, longer_length, tol=1e-8, tol_deC=1e-8): (assuming tol_deC is small enough).""" maxits = int(ceil(1-log(tol_deC/longer_length)/log(2))) pair_list = [BPair(bez1, bez2, 0.5, 0.5)] - intersection_list = [] + intersection_list: list[tuple[float, float]] = [] k = 0 approx_point_set = ApproxSolutionSet(tol) while pair_list and k < maxits: @@ -336,7 +418,8 @@ def bezier_intersections(bez1, bez2, longer_length, tol=1e-8, tol_deC=1e-8): return intersection_list -def bezier_by_line_intersections(bezier, line): +def bezier_by_line_intersections(bezier: Bezier, + line: Line) -> list[tuple[float, float]]: """Returns tuples (t1,t2) such that bezier.point(t1) ~= line.point(t2).""" # The method here is to translate (shift) then rotate the complex plane so # that line starts at the origin and proceeds along the positive real axis. @@ -345,13 +428,13 @@ def bezier_by_line_intersections(bezier, line): # between 0 and abs(line[1]-line[0])]. assert len(line[:]) == 2 assert line[0] != line[1] - if not any(p != bezier[0] for p in bezier): + if not any(p != bezier[0] for p in bezier): # type: ignore[attr-defined] raise ValueError("bezier is nodal, use " "bezier_by_line_intersection(bezier[0], line) " "instead for a bool to be returned.") # First let's shift the complex plane so that line starts at the origin - shifted_bezier = [z - line[0] for z in bezier] + shifted_bezier = [z - line[0] for z in bezier] # type: ignore[attr-defined] shifted_line_end = line[1] - line[0] line_length = abs(shifted_line_end) @@ -366,7 +449,7 @@ def bezier_by_line_intersections(bezier, line): roots_y = list(polyroots01(coeffs_y)) # returns real roots 0 <= r <= 1 transformed_bezier_real = [p.real for p in transformed_bezier] - intersection_list = [] + intersection_list: list[tuple[float, float]] = [] for bez_t in set(roots_y): xval = bezier_point(transformed_bezier_real, bez_t) if 0 <= xval <= line_length: diff --git a/svgpathtools/constants.py b/svgpathtools/constants.py index 4a623e3..ec7e96e 100644 --- a/svgpathtools/constants.py +++ b/svgpathtools/constants.py @@ -1,3 +1,3 @@ """This submodule contains constants used throughout the project.""" -FLOAT_EPSILON = 1e-12 +FLOAT_EPSILON: float = 1e-12 diff --git a/svgpathtools/document.py b/svgpathtools/document.py index 6ac8e93..b6dab1d 100644 --- a/svgpathtools/document.py +++ b/svgpathtools/document.py @@ -34,7 +34,9 @@ """ # External dependencies -from __future__ import division, absolute_import, print_function +from __future__ import division, absolute_import, print_function, annotations +from typing import Any, Callable, List, Mapping, Optional, Union +from types import MappingProxyType import os import collections import xml.etree.ElementTree as etree @@ -50,42 +52,51 @@ from .parser import parse_path from .parser import parse_transform from .svg_to_paths import (path2pathd, ellipse2pathd, line2pathd, - polyline2pathd, polygon2pathd, rect2pathd) + polyline2pathd, polygon2pathd, rect2pathd, + SVGSource) from .misctools import open_in_browser -from .path import transform, Path, is_path_segment - -# To maintain forward/backward compatibility -try: - string = basestring -except NameError: - string = str -try: - from os import PathLike -except ImportError: - PathLike = string +from .path import transform, Path, is_path_segment, Segment + +# Converts one SVG element into a path d-string. +PathConverter = Callable[[Element], str] + +# A group named by the chain of nested group names leading to it, or the +# element itself. The name list must be mutable: `get_group` and +# `get_or_add_group` consume it in place with .pop(0)/.insert(0, ...). +GroupRef = Union[List[str], Element] # Let xml.etree.ElementTree know about the SVG namespace SVG_NAMESPACE = {'svg': 'http://www.w3.org/2000/svg'} register_namespace('svg', 'http://www.w3.org/2000/svg') # THESE MUST BE WRAPPED TO OUTPUT ElementTree.element objects -CONVERSIONS = {'path': path2pathd, - 'circle': ellipse2pathd, - 'ellipse': ellipse2pathd, - 'line': line2pathd, - 'polyline': polyline2pathd, - 'polygon': polygon2pathd, - 'rect': rect2pathd} - -CONVERT_ONLY_PATHS = {'path': path2pathd} +CONVERSIONS: dict[str, PathConverter] = { + 'path': path2pathd, + 'circle': ellipse2pathd, + 'ellipse': ellipse2pathd, + 'line': line2pathd, + 'polyline': polyline2pathd, + 'polygon': polygon2pathd, + 'rect': rect2pathd, +} + +CONVERT_ONLY_PATHS: dict[str, PathConverter] = {'path': path2pathd} + +# A read-only view of CONVERSIONS, used as the default for the +# `path_conversions` parameters below. It tracks CONVERSIONS (so registering +# a converter there still takes effect) but cannot itself be mutated, so a +# caller holding the default cannot corrupt the shared dict. +_DEFAULT_CONVERSIONS: Mapping[str, PathConverter] = MappingProxyType(CONVERSIONS) SVG_GROUP_TAG = 'svg:g' -def flattened_paths(group, group_filter=lambda x: True, - path_filter=lambda x: True, path_conversions=CONVERSIONS, - group_search_xpath=SVG_GROUP_TAG, - strict_transform_parsing=False): +def flattened_paths(group: Element, + group_filter: Callable[[Element], bool] = lambda x: True, + path_filter: Callable[[Element], bool] = lambda x: True, + path_conversions: Mapping[str, PathConverter] = _DEFAULT_CONVERSIONS, + group_search_xpath: str = SVG_GROUP_TAG, + strict_transform_parsing: bool = False) -> list[Path]: """Returns the paths inside a group (recursively), expressing the paths in the base coordinates. @@ -126,12 +137,13 @@ def flattened_paths(group, group_filter=lambda x: True, StackElement = collections.namedtuple('StackElement', ['group', 'transform']) - def new_stack_element(element, last_tf): + def new_stack_element(element: Element, last_tf: np.ndarray) -> Any: return StackElement(element, last_tf.dot( parse_transform(element.get('transform'), strict=strict_transform_parsing))) - def get_relevant_children(parent, last_tf): + def get_relevant_children(parent: Element, + last_tf: np.ndarray) -> list[Any]: children = [] for elem in filter(group_filter, parent.iterfind(group_search_xpath, SVG_NAMESPACE)): @@ -140,7 +152,7 @@ def get_relevant_children(parent, last_tf): stack = [new_stack_element(group, np.identity(3))] - paths = [] + paths: list[Path] = [] while stack: top = stack.pop() @@ -163,12 +175,13 @@ def get_relevant_children(parent, last_tf): return paths -def flattened_paths_from_group(group_to_flatten, root, recursive=True, - group_filter=lambda x: True, - path_filter=lambda x: True, - path_conversions=CONVERSIONS, - group_search_xpath=SVG_GROUP_TAG, - strict_transform_parsing=False): +def flattened_paths_from_group( + group_to_flatten: Element, root: Element, recursive: bool = True, + group_filter: Callable[[Element], bool] = lambda x: True, + path_filter: Callable[[Element], bool] = lambda x: True, + path_conversions: Mapping[str, PathConverter] = _DEFAULT_CONVERSIONS, + group_search_xpath: str = SVG_GROUP_TAG, + strict_transform_parsing: bool = False) -> list[Path]: """Flatten all the paths in a specific group. The paths will be flattened into the 'root' frame. Note that root @@ -189,18 +202,18 @@ def flattened_paths_from_group(group_to_flatten, root, recursive=True, # We create a set of the unique IDs of each element that we wish to # flatten, if those elements are groups. Any groups outside of this # set will be skipped while we flatten the paths. - desired_groups = set() + desired_groups: set[int] = set() if recursive: for group in group_to_flatten.iter(): desired_groups.add(id(group)) else: desired_groups.add(id(group_to_flatten)) - ignore_paths = set() + ignore_paths: set[int] = set() # Use breadth-first search to find the path to the group that we care about if root is not group_to_flatten: - search = [[root]] - route = None + search: list[list[Element]] = [[root]] + route: Optional[list[Element]] = None while search: top = search.pop(0) frontier = top[-1] @@ -231,10 +244,10 @@ def flattened_paths_from_group(group_to_flatten, root, recursive=True, if route is None: raise ValueError('The group_to_flatten is not a descendant of the root!') - def desired_group_filter(x): + def desired_group_filter(x: Element) -> bool: return (id(x) in desired_groups) and group_filter(x) - def desired_path_filter(x): + def desired_path_filter(x: Element) -> bool: return (id(x) not in ignore_paths) and path_filter(x) return flattened_paths(root, desired_group_filter, desired_path_filter, @@ -243,7 +256,8 @@ def desired_path_filter(x): class Document: - def __init__(self, filepath=None, strict_transform_parsing=False): + def __init__(self, filepath: Optional[SVGSource] = None, + strict_transform_parsing: bool = False) -> None: """ A container for a DOM-style SVG document. @@ -268,9 +282,14 @@ def __init__(self, filepath=None, strict_transform_parsing=False): # strings are interpreted as file location everything else is treated as # file-like object and passed to the xml parser directly - from_filepath = isinstance(filepath, string) or isinstance(filepath, PathLike) - self.original_filepath = os.path.abspath(filepath) if from_filepath else None - + from_filepath = isinstance(filepath, (str, os.PathLike)) + # `from_filepath` is a plain bool, so it cannot narrow the union + # here, though it selects exactly the path-like cases. + self.original_filepath: Optional[str] = ( + os.path.abspath(filepath) if from_filepath else None) # type: ignore[arg-type] + + # Both branches below set a root, so it is not Optional here. + self.tree: etree.ElementTree[Element] if filepath is None: self.tree = etree.ElementTree(Element('svg')) else: @@ -280,7 +299,8 @@ def __init__(self, filepath=None, strict_transform_parsing=False): self.root = self.tree.getroot() @classmethod - def from_svg_string(cls, svg_string, strict_transform_parsing=False): + def from_svg_string(cls, svg_string: str, + strict_transform_parsing: bool = False) -> Document: """Constructor for creating a Document object from a string.""" # wrap string into StringIO object svg_file_obj = StringIO(svg_string) @@ -288,8 +308,11 @@ def from_svg_string(cls, svg_string, strict_transform_parsing=False): return Document(svg_file_obj, strict_transform_parsing=strict_transform_parsing) - def paths(self, group_filter=lambda x: True, - path_filter=lambda x: True, path_conversions=CONVERSIONS): + def paths(self, + group_filter: Callable[[Element], bool] = lambda x: True, + path_filter: Callable[[Element], bool] = lambda x: True, + path_conversions: Mapping[str, PathConverter] = _DEFAULT_CONVERSIONS + ) -> list[Path]: """Returns a list of all paths in the document. Note that any transform attributes are applied before returning @@ -299,12 +322,19 @@ def paths(self, group_filter=lambda x: True, self.tree.getroot(), group_filter, path_filter, path_conversions, strict_transform_parsing=self.strict_transform_parsing) - def paths_from_group(self, group, recursive=True, group_filter=lambda x: True, - path_filter=lambda x: True, path_conversions=CONVERSIONS): - if all(isinstance(s, string) for s in group): + def paths_from_group( + self, group: GroupRef, recursive: bool = True, + group_filter: Callable[[Element], bool] = lambda x: True, + path_filter: Callable[[Element], bool] = lambda x: True, + path_conversions: Mapping[str, PathConverter] = _DEFAULT_CONVERSIONS + ) -> list[Path]: + # The isinstance check matters: iterating an *empty* Element yields + # nothing, so all(...) would be vacuously true and the element would + # be treated as an empty name list, resolving to the document root. + if isinstance(group, list) and all(isinstance(s, str) for s in group): # If we're given a list of strings, assume it represents a # nested sequence - group = self.get_group(group) + group = self.get_group(group) # type: ignore[assignment] elif not isinstance(group, Element): raise TypeError( 'Must provide a list of strings that represent a nested ' @@ -316,11 +346,14 @@ def paths_from_group(self, group, recursive=True, group_filter=lambda x: True, return [] return flattened_paths_from_group( - group, self.tree.getroot(), recursive, group_filter, path_filter, + group, self.tree.getroot(), recursive, # type: ignore[arg-type] + group_filter, path_filter, path_conversions, strict_transform_parsing=self.strict_transform_parsing) - def add_path(self, path, attribs=None, group=None): + def add_path(self, path: Union[Path, Segment, str], + attribs: Optional[dict[str, str]] = None, + group: Optional[GroupRef] = None) -> Element: """Add a new path to the SVG.""" # If not given a parent, assume that the path does not have a group @@ -330,7 +363,7 @@ def add_path(self, path, attribs=None, group=None): # If given a list of strings (one or more), assume it represents # a sequence of nested group names elif len(group) > 0 and all(isinstance(elem, str) for elem in group): - group = self.get_or_add_group(group) + group = self.get_or_add_group(group) # type: ignore[arg-type] elif not isinstance(group, Element): raise TypeError( @@ -348,7 +381,7 @@ def add_path(self, path, attribs=None, group=None): path_svg = path.d() elif is_path_segment(path): path_svg = Path(path).d() - elif isinstance(path, string): + elif isinstance(path, str): # Assume this is a valid d-string. # TODO: Should we sanity check the input string? path_svg = path @@ -366,10 +399,11 @@ def add_path(self, path, attribs=None, group=None): return SubElement(group, 'path', attribs) - def contains_group(self, group): + def contains_group(self, group: Element) -> bool: return any(group is owned for owned in self.tree.iter()) - def get_group(self, nested_names, name_attr='id'): + def get_group(self, nested_names: list[str], + name_attr: str = 'id') -> Optional[Element]: """Get a group from the tree, or None if the requested group does not exist. Use get_or_add_group(~) if you want a new group to be created if it did not already exist. @@ -400,7 +434,8 @@ def get_group(self, nested_names, name_attr='id'): return group - def get_or_add_group(self, nested_names, name_attr='id'): + def get_or_add_group(self, nested_names: list[str], + name_attr: str = 'id') -> Element: """Get a group from the tree, or add a new one with the given name structure. @@ -439,7 +474,8 @@ def get_or_add_group(self, nested_names, name_attr='id'): # while-loop will end return group - def add_group(self, group_attribs=None, parent=None): + def add_group(self, group_attribs: Optional[dict[str, str]] = None, + parent: Optional[Element] = None) -> Element: """Add an empty group element to the SVG.""" if parent is None: parent = self.tree.getroot() @@ -455,20 +491,21 @@ def add_group(self, group_attribs=None, parent=None): return SubElement(parent, '{{{0}}}g'.format( SVG_NAMESPACE['svg']), group_attribs) - def __repr__(self): + def __repr__(self) -> str: return etree.tostring(self.tree.getroot()).decode() - def pretty(self, **kwargs): + def pretty(self, **kwargs: Any) -> str: return parseString(repr(self)).toprettyxml(**kwargs) - def save(self, filepath, prettify=False, **kwargs): + def save(self, filepath: str, prettify: bool = False, + **kwargs: Any) -> None: with open(filepath, 'w+') as output_svg: if prettify: output_svg.write(self.pretty(**kwargs)) else: output_svg.write(repr(self)) - def display(self, filepath=None): + def display(self, filepath: Optional[str] = None) -> None: """Displays/opens the doc using the OS's default application.""" if filepath is None: diff --git a/svgpathtools/misctools.py b/svgpathtools/misctools.py index 362d649..493c4e8 100644 --- a/svgpathtools/misctools.py +++ b/svgpathtools/misctools.py @@ -2,14 +2,14 @@ aren't specific to SVGs or related mathematical objects.""" # External dependencies: -from __future__ import division, absolute_import, print_function +from __future__ import division, absolute_import, print_function, annotations import os import sys import webbrowser # stackoverflow.com/questions/214359/converting-hex-color-to-rgb-and-vice-versa -def hex2rgb(value): +def hex2rgb(value: str) -> tuple[int, ...]: """Converts a hexadeximal color string to an RGB 3-tuple EXAMPLE @@ -23,7 +23,7 @@ def hex2rgb(value): # stackoverflow.com/questions/214359/converting-hex-color-to-rgb-and-vice-versa -def rgb2hex(rgb): +def rgb2hex(rgb: tuple[int, int, int]) -> str: """Converts an RGB 3-tuple to a hexadeximal color string. EXAMPLE @@ -34,12 +34,13 @@ def rgb2hex(rgb): return ('#%02x%02x%02x' % tuple(rgb)).upper() -def isclose(a, b, rtol=1e-5, atol=1e-8): +def isclose(a: complex, b: complex, + rtol: float = 1e-5, atol: float = 1e-8) -> bool: """This is essentially np.isclose, but slightly faster.""" return abs(a - b) < (atol + rtol * abs(b)) -def open_in_browser(file_location): +def open_in_browser(file_location: str) -> None: """Attempt to open file located at file_location in the default web browser.""" diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py index 12f6b63..a734190 100644 --- a/svgpathtools/parser.py +++ b/svgpathtools/parser.py @@ -99,6 +99,7 @@ def _parse_transform_substr(transform_substr: str) -> np.ndarray: _check_num_parsed_values(values, [1, 3], transform_substr) angle = values[0] * np.pi / 180.0 + offset: Sequence[float] if len(values) == 3: offset = values[1:3] else: diff --git a/svgpathtools/path.py b/svgpathtools/path.py index 746cefc..bc2b003 100644 --- a/svgpathtools/path.py +++ b/svgpathtools/path.py @@ -4,11 +4,10 @@ # External dependencies from __future__ import annotations +from typing import (TYPE_CHECKING, Any, Callable, Iterable, Iterator, Literal, + Optional, Sequence, Tuple, TypeVar, Union, overload) import re -try: - from collections.abc import MutableSequence # noqa -except ImportError: - from collections import MutableSequence # noqa +from collections.abc import MutableSequence from warnings import warn from operator import itemgetter import numpy as np @@ -30,15 +29,26 @@ # Internal dependencies from .bezier import (bezier_intersections, bezier_bounding_box, split_bezier, bezier_by_line_intersections, polynomial2bezier, - bezier2polynomial) + bezier2polynomial, BoundingBox) from .misctools import BugException from .polytools import rational_limit, polyroots, polyroots01, imag, real -# To maintain forward/backward compatibility -try: - str = basestring -except NameError: - pass +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + from typing_extensions import TypeGuard, TypeIs + + +# `z` in the `Arc` isometries below is a point, an array of points, or a +# polynomial with complex coefficients; each maps to its own kind. +PointLike = TypeVar("PointLike", complex, np.ndarray, np.poly1d) + +# ((min distance, t), (max distance, t)) -- see `Line.radialrange`. +SegmentRadialRange = Tuple[Tuple[float, float], Tuple[float, float]] + +# As above, plus the index of the segment realizing each extremum. The `t` +# and the index are None only for an empty `Path`. +PathExtremum = Tuple[float, Optional[float], Optional[int]] +PathRadialRange = Tuple[PathExtremum, PathExtremum] COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') @@ -58,7 +68,7 @@ WSP_COMMA_RE = re.compile(r"[\s,]*") -def _tokenize_arc_args(arg_chunk): +def _tokenize_arc_args(arg_chunk: str) -> Iterator[str]: """Yield the tokens of one or more seven-field elliptical-arc groups.""" pos = 0 field = 0 @@ -112,7 +122,7 @@ def _tokenize_arc_args(arg_chunk): # Miscellaneous ############################################################### -def bezier_segment(*bpoints): +def bezier_segment(*bpoints: complex) -> BezierSegment: # type: ignore[return] if len(bpoints) == 2: start, end = bpoints return Line(start, end) @@ -126,29 +136,29 @@ def bezier_segment(*bpoints): assert len(bpoints) in (2, 3, 4) -def is_bezier_segment(seg): +def is_bezier_segment(seg: object) -> TypeIs[BezierSegment]: return (isinstance(seg, Line) or isinstance(seg, QuadraticBezier) or isinstance(seg, CubicBezier)) -def is_path_segment(seg): +def is_path_segment(seg: object) -> TypeIs[Segment]: return is_bezier_segment(seg) or isinstance(seg, Arc) -def is_bezier_path(path): +def is_bezier_path(path: object) -> TypeGuard[Path]: """Checks that all segments in path are a Line, QuadraticBezier, or CubicBezier object.""" return isinstance(path, Path) and all(map(is_bezier_segment, path)) -def concatpaths(list_of_paths): +def concatpaths(list_of_paths: Iterable[Path]) -> Path: """Takes in a sequence of paths and returns their concatenations into a single path (following the order of the input sequence).""" return Path(*[seg for path in list_of_paths for seg in path]) -def bbox2path(xmin, xmax, ymin, ymax): +def bbox2path(xmin: float, xmax: float, ymin: float, ymax: float) -> Path: """Converts a bounding box 4-tuple to a Path object.""" b = Line(xmin + 1j*ymin, xmax + 1j*ymin) t = Line(xmin + 1j*ymax, xmax + 1j*ymax) @@ -157,14 +167,14 @@ def bbox2path(xmin, xmax, ymin, ymax): return Path(b, r, t.reversed(), l.reversed()) -def polyline(*points): +def polyline(*points: complex) -> Path: """Converts a list of points to a Path composed of lines connecting those points (i.e. a linear spline or polyline). See also `polygon()`.""" return Path(*[Line(points[i], points[i+1]) for i in range(len(points) - 1)]) -def polygon(*points): +def polygon(*points: complex) -> Path: """Converts a list of points to a Path composed of lines connecting those points, then closes the path by connecting the last point to the first. See also `polyline()`.""" @@ -174,7 +184,7 @@ def polygon(*points): # Conversion################################################################### -def bpoints2bezier(bpoints): +def bpoints2bezier(bpoints: Sequence[complex]) -> BezierSegment: # type: ignore[return] """Converts a list of length 2, 3, or 4 to a CubicBezier, QuadraticBezier, or Line object, respectively. See also: poly2bez.""" @@ -189,7 +199,19 @@ def bpoints2bezier(bpoints): assert len(bpoints) in {2, 3, 4} -def poly2bez(poly, return_bpoints=False): +@overload +def poly2bez(poly: Union[np.poly1d, Sequence[complex]], + return_bpoints: Literal[False] = ...) -> BezierSegment: ... + + +@overload +def poly2bez(poly: Union[np.poly1d, Sequence[complex]], + return_bpoints: Literal[True]) -> tuple[complex, ...]: ... + + +def poly2bez(poly: Union[np.poly1d, Sequence[complex]], + return_bpoints: bool = False + ) -> Union[BezierSegment, tuple[complex, ...]]: """Converts a cubic or lower order Polynomial object (or a sequence of coefficients) to a CubicBezier, QuadraticBezier, or Line object as appropriate. If return_bpoints=True then this will instead only return @@ -203,7 +225,27 @@ def poly2bez(poly, return_bpoints=False): return bpoints2bezier(bpoints) -def bez2poly(bez, numpy_ordering=True, return_poly1d=False): +@overload +def bez2poly(bez: Union[BezierSegment, Sequence[complex]], + numpy_ordering: bool = ..., + return_poly1d: Literal[False] = ...) -> Sequence[complex]: ... + + +@overload +def bez2poly(bez: Union[BezierSegment, Sequence[complex]], + numpy_ordering: bool = ..., + return_poly1d: Literal[True] = ...) -> np.poly1d: ... + + +@overload +def bez2poly(bez: Union[BezierSegment, Sequence[complex]], + numpy_ordering: bool = ..., return_poly1d: bool = ... + ) -> Union[Sequence[complex], np.poly1d]: ... + + +def bez2poly(bez: Union[BezierSegment, Sequence[complex]], + numpy_ordering: bool = True, return_poly1d: bool = False + ) -> Union[Sequence[complex], np.poly1d]: """Converts a Bezier object or tuple of Bezier control points to a tuple of coefficients of the expanded polynomial. return_poly1d : returns a numpy.poly1d object. This makes computations @@ -220,7 +262,8 @@ def bez2poly(bez, numpy_ordering=True, return_poly1d=False): # Geometric#################################################################### -def transform_segments_together(path, transformation): +def transform_segments_together( + path: Path, transformation: Callable[[Segment], Segment]) -> Path: """Makes sure that, if joints were continuous, they're kept that way.""" transformed_segs = [transformation(seg) for seg in path] @@ -230,13 +273,15 @@ def transform_segments_together(path, transformation): return Path(*transformed_segs) -def rotate(curve, degs, origin=None): +def rotate(curve: CurveT, degs: float, + origin: Optional[complex] = None) -> CurveT: """Returns curve rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default origin is either `curve.point(0.5)`, or in the case that curve is an Arc object, `origin` defaults to `curve.center`. """ - def rotate_point(z): - return exp(1j*radians(degs))*(z - origin) + origin + def rotate_point(z: complex) -> complex: + # `origin` is defaulted below, before this is ever called. + return exp(1j*radians(degs))*(z - origin) + origin # type: ignore[operator] if origin is None: if isinstance(curve, Arc): @@ -246,39 +291,42 @@ def rotate_point(z): if isinstance(curve, Path): transformation = lambda seg: rotate(seg, degs, origin=origin) - return transform_segments_together(curve, transformation) + return transform_segments_together(curve, transformation) # type: ignore[return-value] elif is_bezier_segment(curve): - return bpoints2bezier([rotate_point(bpt) for bpt in curve.bpoints()]) + return bpoints2bezier( # type: ignore[return-value] + [rotate_point(bpt) for bpt in curve.bpoints()]) elif isinstance(curve, Arc): new_start = rotate_point(curve.start) new_end = rotate_point(curve.end) new_rotation = curve.rotation + degs - return Arc(new_start, radius=curve.radius, rotation=new_rotation, + return Arc(new_start, radius=curve.radius, rotation=new_rotation, # type: ignore[return-value] large_arc=curve.large_arc, sweep=curve.sweep, end=new_end) else: raise TypeError("Input `curve` should be a Path, Line, " "QuadraticBezier, CubicBezier, or Arc object.") -def translate(curve, z0): +def translate(curve: CurveT, z0: complex) -> CurveT: """Shifts the curve by the complex quantity z such that translate(curve, z0).point(t) = curve.point(t) + z0""" if isinstance(curve, Path): transformation = lambda seg: translate(seg, z0) - return transform_segments_together(curve, transformation) + return transform_segments_together(curve, transformation) # type: ignore[return-value] elif is_bezier_segment(curve): - return bpoints2bezier([bpt + z0 for bpt in curve.bpoints()]) + return bpoints2bezier( # type: ignore[return-value] + [bpt + z0 for bpt in curve.bpoints()]) elif isinstance(curve, Arc): new_start = curve.start + z0 new_end = curve.end + z0 - return Arc(new_start, radius=curve.radius, rotation=curve.rotation, + return Arc(new_start, radius=curve.radius, rotation=curve.rotation, # type: ignore[return-value] large_arc=curve.large_arc, sweep=curve.sweep, end=new_end) else: raise TypeError("Input `curve` should be a Path, Line, " "QuadraticBezier, CubicBezier, or Arc object.") -def scale(curve, sx, sy=None, origin=0j): +def scale(curve: CurveT, sx: float, sy: Optional[float] = None, + origin: complex = 0j) -> CurveT: """Scales `curve`, about `origin`, by diagonal matrix `[[sx,0],[0,sy]]`. Notes: @@ -295,24 +343,24 @@ def scale(curve, sx, sy=None, origin=0j): else: isy = 1j*sy - def _scale(z): + def _scale(z: complex) -> complex: if sy is None: return sx*z return sx*z.real + isy*z.imag - def scale_bezier(bez): + def scale_bezier(bez: BezierSegment) -> BezierSegment: p = [_scale(c) for c in bez2poly(bez)] p[-1] += origin - _scale(origin) return poly2bez(p) if isinstance(curve, Path): transformation = lambda seg: scale(seg, sx, sy, origin) - return transform_segments_together(curve, transformation) + return transform_segments_together(curve, transformation) # type: ignore[return-value] elif is_bezier_segment(curve): - return scale_bezier(curve) + return scale_bezier(curve) # type: ignore[return-value] elif isinstance(curve, Arc): if sy is None or sy == sx: - return Arc(start=sx*(curve.start - origin) + origin, + return Arc(start=sx*(curve.start - origin) + origin, # type: ignore[return-value] radius=sx*curve.radius, rotation=curve.rotation, large_arc=curve.large_arc, @@ -326,23 +374,43 @@ def scale_bezier(bez): "QuadraticBezier, CubicBezier, or Arc object.") -def transform(curve, tf): - """Transforms the curve by the homogeneous transformation matrix tf""" +@overload +def transform(curve: Path, tf: np.ndarray) -> Path: ... + + +@overload +def transform(curve: BezierSegmentT, tf: np.ndarray) -> BezierSegmentT: ... + + +@overload +def transform(curve: Arc, tf: np.ndarray) -> Union[Arc, Line]: ... + + +def transform(curve: Curve, tf: np.ndarray) -> Any: + """Transforms the curve by the homogeneous transformation matrix tf + + Note: unlike `rotate`/`translate`/`scale`, this is not type-preserving + for an `Arc` -- one whose radius the transform collapses degenerates to + a `Line`. + """ if all((tf == np.eye(3)).ravel()): return curve # tf is identity, return curve as is - def to_point(p): + def to_point(p: complex) -> np.ndarray: return np.array([[p.real], [p.imag], [1.0]]) - def to_vector(z): + def to_vector(z: complex) -> np.ndarray: return np.array([[z.real], [z.imag], [0.0]]) - def to_complex(v): + def to_complex(v: np.ndarray) -> complex: return v.item(0) + 1j * v.item(1) if isinstance(curve, Path): - transformation = lambda seg: transform(seg, tf) + # Annotated so the lambda is checked against the element type; + # assigning it to a bare name would lose that context. + transformation: Callable[[Segment], Segment] = \ + lambda seg: transform(seg, tf) return transform_segments_together(curve, transformation) elif is_bezier_segment(curve): @@ -386,7 +454,7 @@ def to_complex(v): "QuadraticBezier, CubicBezier, or Arc object.") -def bezier_unit_tangent(seg, t): +def bezier_unit_tangent(seg: BezierSegment, t: float) -> complex: """Returns the unit tangent of the segment at t. Notes @@ -424,7 +492,8 @@ def bezier_unit_tangent(seg, t): return unit_tangent -def segment_curvature(self, t, use_inf=False): +def segment_curvature(self: Segment, t: float, + use_inf: bool = False) -> float: """returns the curvature of the segment at t. Notes @@ -435,6 +504,13 @@ def segment_curvature(self, t, use_inf=False): >>> np.seterr(**old) """ + # These hold floats on the fast path and np.poly1d objects in the + # removable-singularity branch below. + dx: Any + dy: Any + ddx: Any + ddy: Any + dz = self.derivative(t) ddz = self.derivative(t, n=2) dx, dy = dz.real, dz.imag @@ -444,7 +520,8 @@ def segment_curvature(self, t, use_inf=False): kappa = abs(dx*ddy - dy*ddx)/sqrt(dx*dx + dy*dy)**3 except (ZeroDivisionError, FloatingPointError): # tangent vector is zero at t, use polytools to find limit - p = self.poly() + # (Arc has no .poly(); a degenerate Arc reaching here would raise.) + p = self.poly() # type: ignore[union-attr] dp = p.deriv() ddp = dp.deriv() dx, dy = real(dp), imag(dp) @@ -452,7 +529,8 @@ def segment_curvature(self, t, use_inf=False): f2 = (dx*ddy - dy*ddx)**2 g2 = (dx*dx + dy*dy)**3 lim2 = rational_limit(f2, g2, t) - if lim2 < 0: # impossible, must be numerical error + # Real, since f2 and g2 have real coefficients. + if lim2 < 0: # type: ignore[operator] # impossible, must be numerical error return 0 kappa = sqrt(lim2) finally: @@ -460,14 +538,16 @@ def segment_curvature(self, t, use_inf=False): return kappa -def bezier_radialrange(seg, origin, return_all_global_extrema=False): +def bezier_radialrange(seg: BezierSegment, origin: complex, + return_all_global_extrema: bool = False + ) -> SegmentRadialRange: """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize and maximize, respectively, the distance d = |self.point(t)-origin|. return_all_global_extrema: Multiple such t_min or t_max values can exist. By default, this will only return one. Set return_all_global_extrema=True to return all such global extrema.""" - def _radius(tau): + def _radius(tau: float) -> float: return abs(seg.point(tau) - origin) shifted_seg_poly = seg.poly() - origin @@ -484,7 +564,7 @@ def _radius(tau): return seg_global_min, seg_global_max -def closest_point_in_path(pt, path): +def closest_point_in_path(pt: complex, path: Path) -> PathExtremum: """returns (|path.seg.point(t)-pt|, t, seg_idx) where t and seg_idx minimize the distance between pt and curve path[idx].point(t) for 0<=t<=1 and any seg_idx. @@ -493,7 +573,7 @@ def closest_point_in_path(pt, path): return path.radialrange(pt)[0] -def farthest_point_in_path(pt, path): +def farthest_point_in_path(pt: complex, path: Path) -> PathExtremum: """returns (|path.seg.point(t)-pt|, t, seg_idx) where t and seg_idx maximize the distance between pt and curve path[idx].point(t) for 0<=t<=1 and any seg_idx. @@ -505,7 +585,7 @@ def farthest_point_in_path(pt, path): return path.radialrange(pt)[1] -def path_encloses_pt(pt, opt, path): +def path_encloses_pt(pt: complex, opt: complex, path: Path) -> bool: """returns true if pt is a point enclosed by path (which must be a Path object satisfying path.isclosed==True). opt is a point you know is NOT enclosed by path.""" @@ -517,8 +597,10 @@ def path_encloses_pt(pt, opt, path): return False -def segment_length(curve, start, end, start_point, end_point, - error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH, depth=0): +def segment_length(curve: Segment, start: float, end: float, + start_point: complex, end_point: complex, + error: float = LENGTH_ERROR, + min_depth: int = LENGTH_MIN_DEPTH, depth: int = 0) -> float: """Recursively approximates the length by straight lines""" mid = (start + end)/2 mid_point = curve.point(mid) @@ -538,8 +620,9 @@ def segment_length(curve, start, end, start_point, end_point, return length2 -def inv_arclength(curve, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, - error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): +def inv_arclength(curve: Curve, s: float, s_tol: float = ILENGTH_S_TOL, + maxits: int = ILENGTH_MAXITS, error: float = ILENGTH_ERROR, + min_depth: int = ILENGTH_MIN_DEPTH) -> float: """INPUT: curve should be a CubicBezier, Line, of Path of CubicBezier and/or Line objects. OUTPUT: Returns a float, t, such that the arc length of curve from 0 to @@ -565,7 +648,7 @@ def inv_arclength(curve, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, if isinstance(curve, Path): seg_lengths = [seg.length(error=error, min_depth=min_depth) for seg in curve] - lsum = 0 + lsum: float = 0 # Find which segment the point we search for is located on for k, len_k in enumerate(seg_lengths): if lsum <= s <= lsum + len_k: @@ -582,8 +665,8 @@ def inv_arclength(curve, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, elif (isinstance(curve, QuadraticBezier) or isinstance(curve, CubicBezier) or isinstance(curve, Arc)): - t_upper = 1 - t_lower = 0 + t_upper: float = 1 + t_lower: float = 0 iteration = 0 while iteration < maxits: iteration += 1 @@ -608,7 +691,7 @@ def inv_arclength(curve, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, # Operations################################################################### -def crop_bezier(seg, t0, t1): +def crop_bezier(seg: BezierSegment, t0: float, t1: float) -> BezierSegment: """Crop a copy of this `self` from `self.point(t0)` to `self.point(t1)`.""" assert t0 < t1 if t0 == 0: @@ -632,70 +715,88 @@ def crop_bezier(seg, t0, t1): class Line(object): - def __init__(self, start, end): + def __init__(self, start: complex, end: complex) -> None: self.start = start self.end = end def __hash__(self) -> int: return hash((self.start, self.end)) - def __repr__(self): + def __repr__(self) -> str: return 'Line(start=%s, end=%s)' % (self.start, self.end) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, Line): return False return self.start == other.start and self.end == other.end - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, Line): return NotImplemented return not self == other - def __getitem__(self, item): + @overload + def __getitem__(self, item: int) -> complex: ... + + @overload + def __getitem__(self, item: slice) -> tuple[complex, ...]: ... + + def __getitem__(self, item: Union[int, slice]) -> Any: return self.bpoints()[item] - def __len__(self): + def __len__(self) -> int: return 2 - def joins_smoothly_with(self, previous, wrt_parameterization=False): + def joins_smoothly_with(self, previous: Segment, + wrt_parameterization: bool = False) -> bool: """Checks if this segment joins smoothly with previous segment. By default, this only checks that this segment starts moving (at t=0) in the same direction (and from the same positive) as previous stopped moving (at t=1). To check if the tangent magnitudes also match, set wrt_parameterization=True.""" if wrt_parameterization: - return self.start == previous.end and np.isclose( + # np.isclose returns np.bool_, which is a bool in all but name. + return self.start == previous.end and np.isclose( # type: ignore[return-value] self.derivative(0), previous.derivative(1)) else: - return self.start == previous.end and np.isclose( + return self.start == previous.end and np.isclose( # type: ignore[return-value] self.unit_tangent(0), previous.unit_tangent(1)) - def point(self, t): + def point(self, t: float) -> complex: """returns the coordinates of the Bezier curve evaluated at t.""" distance = self.end - self.start return self.start + distance*t - def points(self, ts): + def points(self, ts: Union[Sequence[float], np.ndarray]) -> np.ndarray: """Faster than running Path.point many times.""" return self.poly()(ts) - def length(self, t0=0, t1=1, error=None, min_depth=None): + def length(self, t0: float = 0, t1: float = 1, + error: Optional[float] = None, + min_depth: Optional[int] = None) -> float: """returns the length of the line segment between t0 and t1.""" return abs(self.end - self.start)*(t1-t0) - def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, - error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + def ilength(self, s: float, s_tol: float = ILENGTH_S_TOL, + maxits: int = ILENGTH_MAXITS, error: float = ILENGTH_ERROR, + min_depth: int = ILENGTH_MIN_DEPTH) -> float: """Returns a float, t, such that self.length(0, t) is approximately s. See the inv_arclength() docstring for more details.""" return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) - def bpoints(self): + def bpoints(self) -> tuple[complex, complex]: """returns the Bezier control points of the segment.""" return self.start, self.end - def poly(self, return_coeffs=False): + @overload + def poly(self, return_coeffs: Literal[False] = ...) -> np.poly1d: ... + + @overload + def poly(self, return_coeffs: Literal[True]) -> Sequence[complex]: ... + + def poly(self, return_coeffs: bool = False + ) -> Union[np.poly1d, Sequence[complex]]: """returns the line as a Polynomial object.""" p = self.bpoints() coeffs = ([p[1] - p[0], p[0]]) @@ -704,7 +805,7 @@ def poly(self, return_coeffs=False): else: return np.poly1d(coeffs) - def derivative(self, t=None, n=1): + def derivative(self, t: Optional[float] = None, n: int = 1) -> complex: """returns the nth derivative of the segment at t.""" assert self.end != self.start if n == 1: @@ -714,17 +815,17 @@ def derivative(self, t=None, n=1): else: raise ValueError("n should be a positive integer.") - def unit_tangent(self, t=None): + def unit_tangent(self, t: Optional[float] = None) -> complex: """returns the unit tangent of the segment at t.""" assert self.end != self.start dseg = self.end - self.start return dseg/abs(dseg) - def normal(self, t=None): + def normal(self, t: Optional[float] = None) -> complex: """returns the (right hand rule) unit normal vector to self at t.""" return -1j*self.unit_tangent(t) - def curvature(self, t): + def curvature(self, t: float) -> float: """returns the curvature of the line, which is always zero.""" return 0 @@ -738,11 +839,12 @@ def curvature(self, t): # "this is true at every point on the line.") # return [] - def reversed(self): + def reversed(self) -> Line: """returns a copy of the Line object with its orientation reversed.""" return Line(self.end, self.start) - def intersect(self, other_seg, tol=None): + def intersect(self, other_seg: Segment, + tol: Optional[float] = None) -> list[tuple[float, float]]: """Finds the intersections of two segments. returns a list of tuples (t1, t2) such that self.point(t1) == other_seg.point(t2). @@ -762,6 +864,9 @@ def intersect(self, other_seg, tol=None): return [] if max(ob) < min(sb): return [] + # Arc.intersect returns plain lists in one of its branches, so this + # is typed as a sequence of pairs rather than a list of tuples. + t2t1s: Sequence[Sequence[float]] if isinstance(other_seg, Line): assert other_seg.end != other_seg.start and self.end != self.start assert self != other_seg @@ -800,7 +905,7 @@ def intersect(self, other_seg, tol=None): else: raise TypeError("other_seg must be a path segment.") - def bbox(self): + def bbox(self) -> BoundingBox: """returns the bounding box for the segment in the form (xmin, xmax, ymin, ymax).""" xmin = min(self.start.real, self.end.real) @@ -809,7 +914,7 @@ def bbox(self): ymax = max(self.start.imag, self.end.imag) return xmin, xmax, ymin, ymax - def point_to_t(self, point): + def point_to_t(self, point: complex) -> Optional[float]: """If the point lies on the Line, returns its `t` parameter. If the point does not lie on the Line, returns None.""" @@ -832,18 +937,19 @@ def point_to_t(self, point): return t.real return None - def cropped(self, t0, t1): + def cropped(self, t0: float, t1: float) -> Line: """returns a cropped copy of this segment which starts at self.point(t0) and ends at self.point(t1).""" return Line(self.point(t0), self.point(t1)) - def split(self, t): + def split(self, t: float) -> tuple[Line, Line]: """returns two segments, whose union is this segment and which join at self.point(t).""" pt = self.point(t) return Line(self.start, pt), Line(pt, self.end) - def radialrange(self, origin, **kwargs): + def radialrange(self, origin: complex, + **kwargs: Any) -> SegmentRadialRange: """compute points in self that are min and max distance to origin. Args: @@ -879,60 +985,70 @@ def radialrange(self, origin, **kwargs): return (d0, 0), (d1, 1) return (d1, 1), (d0, 0) - def rotated(self, degs, origin=None): + def rotated(self, degs: float, + origin: Optional[complex] = None) -> Line: """Returns a copy of self rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default `origin` is either `self.point(0.5)`, or in the case that self is an Arc object, `origin` defaults to `self.center`.""" return rotate(self, degs, origin=origin) - def translated(self, z0): + def translated(self, z0: complex) -> Line: """Returns a copy of self shifted by the complex quantity `z0` such that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" return translate(self, z0) - def scaled(self, sx, sy=None, origin=0j): + def scaled(self, sx: float, sy: Optional[float] = None, + origin: complex = 0j) -> Line: """Scale transform. See `scale` function for further explanation.""" return scale(self, sx=sx, sy=sy, origin=origin) class QuadraticBezier(object): # For compatibility with old pickle files. - _length_info = {'length': None, 'bpoints': None} + _length_info: dict[str, Any] = {'length': None, 'bpoints': None} - def __init__(self, start, control, end): + def __init__(self, start: complex, control: complex, + end: complex) -> None: self.start = start self.end = end self.control = control # used to know if self._length needs to be updated - self._length_info = {'length': None, 'bpoints': None} + self._length_info: dict[str, Any] = {'length': None, 'bpoints': None} def __hash__(self) -> int: return hash((self.start, self.control, self.end)) - def __repr__(self): + def __repr__(self) -> str: return 'QuadraticBezier(start=%s, control=%s, end=%s)' % ( self.start, self.control, self.end) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, QuadraticBezier): return False return self.start == other.start and self.end == other.end \ and self.control == other.control - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, QuadraticBezier): return NotImplemented return not self == other - def __getitem__(self, item): + @overload + def __getitem__(self, item: int) -> complex: ... + + @overload + def __getitem__(self, item: slice) -> tuple[complex, ...]: ... + + def __getitem__(self, item: Union[int, slice]) -> Any: return self.bpoints()[item] - def __len__(self): + def __len__(self) -> int: return 3 - def is_smooth_from(self, previous, warning_on=True): + def is_smooth_from(self, previous: Optional[Segment], + warning_on: bool = True) -> bool: """[Warning: The name of this method is somewhat misleading (yet kept for compatibility with scripts created using svg.path 2.0). This method is meant only for d string creation and should not be used to @@ -947,8 +1063,9 @@ def is_smooth_from(self, previous, warning_on=True): else: return self.control == self.start - def joins_smoothly_with(self, previous, wrt_parameterization=False, - error=0): + def joins_smoothly_with(self, previous: Segment, + wrt_parameterization: bool = False, + error: float = 0) -> bool: """Checks if this segment joins smoothly with previous segment. By default, this only checks that this segment starts moving (at t=0) in the same direction (and from the same positive) as previous stopped @@ -961,16 +1078,18 @@ def joins_smoothly_with(self, previous, wrt_parameterization=False, return self.start == previous.end and abs( self.unit_tangent(0) - previous.unit_tangent(1)) <= error - def point(self, t): + def point(self, t: float) -> complex: """returns the coordinates of the Bezier curve evaluated at t.""" tc = 1 - t return tc*tc*self.start + 2*tc*t*self.control + t*t*self.end - def points(self, ts): + def points(self, ts: Union[Sequence[float], np.ndarray]) -> np.ndarray: """Faster than running Path.point many times.""" return self.poly()(ts) - def length(self, t0=0, t1=1, error=None, min_depth=None): + def length(self, t0: float = 0, t1: float = 1, + error: Optional[float] = None, + min_depth: Optional[int] = None) -> float: if t0 == 1 and t1 == 0: if self._length_info['bpoints'] == self.bpoints(): return self._length_info['length'] @@ -1013,18 +1132,26 @@ def length(self, t0=0, t1=1, error=None, min_depth=None): else: return s - def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, - error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + def ilength(self, s: float, s_tol: float = ILENGTH_S_TOL, + maxits: int = ILENGTH_MAXITS, error: float = ILENGTH_ERROR, + min_depth: int = ILENGTH_MIN_DEPTH) -> float: """Returns a float, t, such that self.length(0, t) is approximately s. See the inv_arclength() docstring for more details.""" return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) - def bpoints(self): + def bpoints(self) -> tuple[complex, complex, complex]: """returns the Bezier control points of the segment.""" return self.start, self.control, self.end - def poly(self, return_coeffs=False): + @overload + def poly(self, return_coeffs: Literal[False] = ...) -> np.poly1d: ... + + @overload + def poly(self, return_coeffs: Literal[True]) -> Sequence[complex]: ... + + def poly(self, return_coeffs: bool = False + ) -> Union[np.poly1d, Sequence[complex]]: """returns the quadratic as a Polynomial object.""" p = self.bpoints() coeffs = (p[0] - 2*p[1] + p[2], 2*(p[1] - p[0]), p[0]) @@ -1033,7 +1160,7 @@ def poly(self, return_coeffs=False): else: return np.poly1d(coeffs) - def derivative(self, t, n=1): + def derivative(self, t: float, n: int = 1) -> complex: """returns the nth derivative of the segment at t. Note: Bezier curves can have points where their derivative vanishes. If you are interested in the tangent direction, use the unit_tangent() @@ -1048,18 +1175,18 @@ def derivative(self, t, n=1): else: raise ValueError("n should be a positive integer.") - def unit_tangent(self, t): + def unit_tangent(self, t: float) -> complex: """returns the unit tangent vector of the segment at t (centered at the origin and expressed as a complex number). If the tangent vector's magnitude is zero, this method will find the limit of self.derivative(tau)/abs(self.derivative(tau)) as tau approaches t.""" return bezier_unit_tangent(self, t) - def normal(self, t): + def normal(self, t: float) -> complex: """returns the (right hand rule) unit normal vector to self at t.""" return -1j*self.unit_tangent(t) - def curvature(self, t): + def curvature(self, t: float) -> float: """returns the curvature of the segment at t.""" return segment_curvature(self, t) @@ -1074,7 +1201,7 @@ def curvature(self, t): # p = kappa**2*(dx**2 + dy**2)**3 - (dx*ddy - ddx*dy)**2 # return polyroots01(p) - def reversed(self): + def reversed(self) -> QuadraticBezier: """returns a copy of the QuadraticBezier object with its orientation reversed.""" new_quad = QuadraticBezier(self.end, self.control, self.start) @@ -1084,7 +1211,8 @@ def reversed(self): self.end, self.control, self.start) return new_quad - def intersect(self, other_seg, tol=1e-12): + def intersect(self, other_seg: Segment, + tol: float = 1e-12) -> list[tuple[float, float]]: """Finds the intersections of two segments. returns a list of tuples (t1, t2) such that self.point(t1) == other_seg.point(t2). @@ -1126,86 +1254,98 @@ def intersect(self, other_seg, tol=1e-12): else: raise TypeError("other_seg must be a path segment.") - def bbox(self): + def bbox(self) -> BoundingBox: """returns the bounding box for the segment in the form (xmin, xmax, ymin, ymax).""" return bezier_bounding_box(self) - def split(self, t): + def split(self, t: float) -> tuple[QuadraticBezier, QuadraticBezier]: """returns two segments, whose union is this segment and which join at self.point(t).""" bpoints1, bpoints2 = split_bezier(self.bpoints(), t) return QuadraticBezier(*bpoints1), QuadraticBezier(*bpoints2) - def cropped(self, t0, t1): + def cropped(self, t0: float, t1: float) -> QuadraticBezier: """returns a cropped copy of this segment which starts at self.point(t0) and ends at self.point(t1).""" - return QuadraticBezier(*crop_bezier(self, t0, t1)) + return QuadraticBezier(*crop_bezier(self, t0, t1)) # type: ignore[misc] - def radialrange(self, origin, return_all_global_extrema=False): + def radialrange(self, origin: complex, + return_all_global_extrema: bool = False + ) -> SegmentRadialRange: """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize and maximize, respectively, the distance d = |self.point(t)-origin|.""" return bezier_radialrange(self, origin, return_all_global_extrema=return_all_global_extrema) - def rotated(self, degs, origin=None): + def rotated(self, degs: float, + origin: Optional[complex] = None) -> QuadraticBezier: """Returns a copy of self rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default `origin` is either `self.point(0.5)`, or in the case that self is an Arc object, `origin` defaults to `self.center`.""" return rotate(self, degs, origin=origin) - def translated(self, z0): + def translated(self, z0: complex) -> QuadraticBezier: """Returns a copy of self shifted by the complex quantity `z0` such that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" return translate(self, z0) - def scaled(self, sx, sy=None, origin=0j): + def scaled(self, sx: float, sy: Optional[float] = None, + origin: complex = 0j) -> QuadraticBezier: """Scale transform. See `scale` function for further explanation.""" return scale(self, sx=sx, sy=sy, origin=origin) class CubicBezier(object): # For compatibility with old pickle files. - _length_info = {'length': None, 'bpoints': None, 'error': None, - 'min_depth': None} + _length_info: dict[str, Any] = {'length': None, 'bpoints': None, + 'error': None, 'min_depth': None} - def __init__(self, start, control1, control2, end): + def __init__(self, start: complex, control1: complex, control2: complex, + end: complex) -> None: self.start = start self.control1 = control1 self.control2 = control2 self.end = end # used to know if self._length needs to be updated - self._length_info = {'length': None, 'bpoints': None, 'error': None, - 'min_depth': None} + self._length_info: dict[str, Any] = { + 'length': None, 'bpoints': None, 'error': None, 'min_depth': None} def __hash__(self) -> int: return hash((self.start, self.control1, self.control2, self.end)) - def __repr__(self): + def __repr__(self) -> str: return 'CubicBezier(start=%s, control1=%s, control2=%s, end=%s)' % ( self.start, self.control1, self.control2, self.end) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, CubicBezier): return False return self.start == other.start and self.end == other.end \ and self.control1 == other.control1 \ and self.control2 == other.control2 - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, CubicBezier): return NotImplemented return not self == other - def __getitem__(self, item): + @overload + def __getitem__(self, item: int) -> complex: ... + + @overload + def __getitem__(self, item: slice) -> tuple[complex, ...]: ... + + def __getitem__(self, item: Union[int, slice]) -> Any: return self.bpoints()[item] - def __len__(self): + def __len__(self) -> int: return 4 - def is_smooth_from(self, previous, warning_on=True): + def is_smooth_from(self, previous: Optional[Segment], + warning_on: bool = True) -> bool: """[Warning: The name of this method is somewhat misleading (yet kept for compatibility with scripts created using svg.path 2.0). This method is meant only for d string creation and should not be used to @@ -1220,20 +1360,22 @@ def is_smooth_from(self, previous, warning_on=True): else: return self.control1 == self.start - def joins_smoothly_with(self, previous, wrt_parameterization=False): + def joins_smoothly_with(self, previous: Segment, + wrt_parameterization: bool = False) -> bool: """Checks if this segment joins smoothly with previous segment. By default, this only checks that this segment starts moving (at t=0) in the same direction (and from the same positive) as previous stopped moving (at t=1). To check if the tangent magnitudes also match, set wrt_parameterization=True.""" if wrt_parameterization: - return self.start == previous.end and np.isclose( + # np.isclose returns np.bool_, which is a bool in all but name. + return self.start == previous.end and np.isclose( # type: ignore[return-value] self.derivative(0), previous.derivative(1)) else: - return self.start == previous.end and np.isclose( + return self.start == previous.end and np.isclose( # type: ignore[return-value] self.unit_tangent(0), previous.unit_tangent(1)) - def point(self, t): + def point(self, t: float) -> complex: """Evaluate the cubic Bezier curve at t using Horner's rule.""" # algebraically equivalent to # P0*(1-t)**3 + 3*P1*t*(1-t)**2 + 3*P2*(1-t)*t**2 + P3*t**3 @@ -1244,11 +1386,13 @@ def point(self, t): -self.start + 3*(self.control1 - self.control2) + self.end ))) - def points(self, ts): + def points(self, ts: Union[Sequence[float], np.ndarray]) -> np.ndarray: """Faster than running Path.point many times.""" return self.poly()(ts) - def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): + def length(self, t0: float = 0, t1: float = 1, + error: float = LENGTH_ERROR, + min_depth: int = LENGTH_MIN_DEPTH) -> float: """Calculate the length of the path up to a certain position""" if t0 == 0 and t1 == 1: if self._length_info['bpoints'] == self.bpoints() \ @@ -1273,18 +1417,26 @@ def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): else: return s - def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, - error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + def ilength(self, s: float, s_tol: float = ILENGTH_S_TOL, + maxits: int = ILENGTH_MAXITS, error: float = ILENGTH_ERROR, + min_depth: int = ILENGTH_MIN_DEPTH) -> float: """Returns a float, t, such that self.length(0, t) is approximately s. See the inv_arclength() docstring for more details.""" return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) - def bpoints(self): + def bpoints(self) -> tuple[complex, complex, complex, complex]: """returns the Bezier control points of the segment.""" return self.start, self.control1, self.control2, self.end - def poly(self, return_coeffs=False): + @overload + def poly(self, return_coeffs: Literal[False] = ...) -> np.poly1d: ... + + @overload + def poly(self, return_coeffs: Literal[True]) -> Sequence[complex]: ... + + def poly(self, return_coeffs: bool = False + ) -> Union[np.poly1d, Sequence[complex]]: """Returns a the cubic as a Polynomial object.""" p = self.bpoints() coeffs = (-p[0] + 3*(p[1] - p[2]) + p[3], @@ -1296,7 +1448,7 @@ def poly(self, return_coeffs=False): else: return np.poly1d(coeffs) - def derivative(self, t, n=1): + def derivative(self, t: float, n: int = 1) -> complex: """returns the nth derivative of the segment at t. Note: Bezier curves can have points where their derivative vanishes. If you are interested in the tangent direction, use the unit_tangent() @@ -1315,18 +1467,18 @@ def derivative(self, t, n=1): else: raise ValueError("n should be a positive integer.") - def unit_tangent(self, t): + def unit_tangent(self, t: float) -> complex: """returns the unit tangent vector of the segment at t (centered at the origin and expressed as a complex number). If the tangent vector's magnitude is zero, this method will find the limit of self.derivative(tau)/abs(self.derivative(tau)) as tau approaches t.""" return bezier_unit_tangent(self, t) - def normal(self, t): + def normal(self, t: float) -> complex: """returns the (right hand rule) unit normal vector to self at t.""" return -1j * self.unit_tangent(t) - def curvature(self, t): + def curvature(self, t: float) -> float: """returns the curvature of the segment at t.""" return segment_curvature(self, t) @@ -1341,7 +1493,7 @@ def curvature(self, t): # p = kappa**2*(dx**2 + dy**2)**3 - (dx*ddy - ddx*dy)**2 # return polyroots01(p) - def reversed(self): + def reversed(self) -> CubicBezier: """returns a copy of the CubicBezier object with its orientation reversed.""" new_cub = CubicBezier(self.end, self.control2, self.control1, @@ -1352,7 +1504,8 @@ def reversed(self): self.end, self.control2, self.control1, self.start) return new_cub - def intersect(self, other_seg, tol=1e-12): + def intersect(self, other_seg: Segment, + tol: float = 1e-12) -> list[tuple[float, float]]: """Finds the intersections of two segments. Returns: @@ -1393,46 +1546,59 @@ def intersect(self, other_seg, tol=1e-12): else: raise TypeError("`other_seg` must be a path segment.") - def bbox(self): + def bbox(self) -> BoundingBox: """returns bounding box in format (xmin, xmax, ymin, ymax).""" return bezier_bounding_box(self) - def split(self, t): + def split(self, t: float) -> tuple[CubicBezier, CubicBezier]: """Splits a copy of `self` at t and returns the two subsegments.""" bpoints1, bpoints2 = split_bezier(self.bpoints(), t) return CubicBezier(*bpoints1), CubicBezier(*bpoints2) - def cropped(self, t0, t1): + def cropped(self, t0: float, t1: float) -> CubicBezier: """returns a cropped copy of this segment which starts at self.point(t0) and ends at self.point(t1).""" - return CubicBezier(*crop_bezier(self, t0, t1)) + return CubicBezier(*crop_bezier(self, t0, t1)) # type: ignore[misc] - def radialrange(self, origin, return_all_global_extrema=False): + def radialrange(self, origin: complex, + return_all_global_extrema: bool = False + ) -> SegmentRadialRange: """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize and maximize, respectively, the distance d = |self.point(t)-origin|.""" return bezier_radialrange( self, origin, return_all_global_extrema=return_all_global_extrema) - def rotated(self, degs, origin=None): + def rotated(self, degs: float, + origin: Optional[complex] = None) -> CubicBezier: """Returns a copy of self rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default `origin` is either `self.point(0.5)`, or in the case that self is an Arc object, `origin` defaults to `self.center`.""" return rotate(self, degs, origin=origin) - def translated(self, z0): + def translated(self, z0: complex) -> CubicBezier: """Returns a copy of self shifted by the complex quantity `z0` such that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" return translate(self, z0) - def scaled(self, sx, sy=None, origin=0j): + def scaled(self, sx: float, sy: Optional[float] = None, + origin: complex = 0j) -> CubicBezier: """Scale transform. See `scale` function for further explanation.""" return scale(self, sx=sx, sy=sy, origin=origin) class Arc(object): - def __init__(self, start, radius, rotation, large_arc, sweep, end, - autoscale_radius=True): + # Derived parameters, computed by `_parameterize()`; see the + # "Derived Parameters/Attributes" section of __init__'s docstring. + center: complex + theta: float + delta: float + phi: float + rot_matrix: complex + + def __init__(self, start: complex, radius: complex, rotation: float, + large_arc: Union[bool, int], sweep: Union[bool, int], + end: complex, autoscale_radius: bool = True) -> None: r""" This should be thought of as a part of an ellipse connecting two points on that ellipse, start and end. @@ -1518,8 +1684,8 @@ def __init__(self, start, radius, rotation, large_arc, sweep, end, self.end = end self.autoscale_radius = autoscale_radius - self.segment_length_hash = None - self.segment_length = None + self.segment_length_hash: Optional[int] = None + self.segment_length: Optional[float] = None # Convenience parameters self.phi = radians(self.rotation) @@ -1535,13 +1701,13 @@ def apoints(self) -> tuple[complex, complex, float, bool, bool, complex]: def __hash__(self) -> int: return hash(self.apoints()) - def __repr__(self): + def __repr__(self) -> str: params = (self.start, self.radius, self.rotation, self.large_arc, self.sweep, self.end) return ("Arc(start={}, radius={}, rotation={}, " "large_arc={}, sweep={}, end={})".format(*params)) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, Arc): return False return self.start == other.start and self.end == other.end \ @@ -1549,12 +1715,12 @@ def __eq__(self, other): and self.rotation == other.rotation \ and self.large_arc == other.large_arc and self.sweep == other.sweep - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, Arc): return NotImplemented return not self == other - def _parameterize(self): + def _parameterize(self) -> None: # See http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes # my notation roughly follows theirs rx = self.radius.real @@ -1658,7 +1824,7 @@ def _parameterize(self): elif self.large_arc and self.delta <= 0: self.delta += 360 - def point(self, t): + def point(self, t: float) -> complex: angle = (self.theta + t*self.delta)*pi/180 cosphi = self.rot_matrix.real @@ -1670,12 +1836,12 @@ def point(self, t): y = rx*sinphi*cos(angle) + ry*cosphi*sin(angle) + self.center.imag return x + y*1j - def point_to_t(self, point): + def point_to_t(self, point: complex) -> Optional[float]: """If the point lies on the Arc, returns its `t` parameter. If the point does not lie on the Arc, returns None. This function only works on Arcs with rotation == 0.0""" - def in_range(min, max, val): + def in_range(min: float, max: float, val: float) -> bool: return (min <= val) and (max >= val) # Single-precision floats have only 7 significant figures of @@ -1792,7 +1958,7 @@ def in_range(min, max, val): return None - def centeriso(self, z): + def centeriso(self, z: PointLike) -> PointLike: """Isometry to a centered aligned ellipse. This is an isometry that shifts and rotates `self`'s underlying @@ -1810,24 +1976,26 @@ def centeriso(self, z): """ return (1/self.rot_matrix)*(z - self.center) - def icenteriso(self, zeta): + def icenteriso(self, zeta: PointLike) -> PointLike: """The inverse of the `centeriso()` method.""" return self.rot_matrix*zeta + self.center - def u1transform(self, z): + def u1transform(self, z: PointLike) -> PointLike: """Similar to the `centeriso()` method, but maps to the unit circle.""" zeta = self.centeriso(z) x, y = real(zeta), imag(zeta) return x/self.radius.real + 1j*y/self.radius.imag - def iu1transform(self, zeta): + def iu1transform(self, zeta: PointLike) -> PointLike: """The inverse of the `u1transform()` method.""" x = real(zeta) y = imag(zeta) z = x*self.radius.real + y*self.radius.imag return self.rot_matrix*z + self.center - def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): + def length(self, t0: float = 0, t1: float = 1, + error: float = LENGTH_ERROR, + min_depth: int = LENGTH_MIN_DEPTH) -> float: """Computes the length of the Arc segment, `self`, from t0 to t1. Notes: @@ -1847,7 +2015,8 @@ def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): else: self.segment_length = segment_length(self, t0, t1, self.point(t0), self.point(t1), error, min_depth, 0) - return self.segment_length + # Just assigned above if it was None. + return self.segment_length # type: ignore[return-value] if _quad_available: return quad(lambda tau: abs(self.derivative(tau)), t0, t1, @@ -1856,8 +2025,9 @@ def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): return segment_length(self, t0, t1, self.point(t0), self.point(t1), error, min_depth, 0) - def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, - error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + def ilength(self, s: float, s_tol: float = ILENGTH_S_TOL, + maxits: int = ILENGTH_MAXITS, error: float = ILENGTH_ERROR, + min_depth: int = ILENGTH_MIN_DEPTH) -> float: """Approximates the unique `t` such that self.length(0, t) = s. Args: @@ -1872,8 +2042,9 @@ def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) - def joins_smoothly_with(self, previous, wrt_parameterization=False, - error=0): + def joins_smoothly_with(self, previous: Segment, + wrt_parameterization: bool = False, + error: float = 0) -> bool: """Checks if this segment joins smoothly with previous segment. By default, this only checks that this segment starts moving (at t=0) in the same direction (and from the same positive) as previous stopped @@ -1886,7 +2057,7 @@ def joins_smoothly_with(self, previous, wrt_parameterization=False, return self.start == previous.end and abs( self.unit_tangent(0) - previous.unit_tangent(1)) <= error - def derivative(self, t, n=1): + def derivative(self, t: float, n: int = 1) -> complex: """returns the nth derivative of the segment at t.""" angle = radians(self.theta + t*self.delta) phi = radians(self.rotation) @@ -1909,17 +2080,17 @@ def derivative(self, t, n=1): else: raise ValueError("n should be a positive integer.") - def unit_tangent(self, t): + def unit_tangent(self, t: float) -> complex: """returns the unit tangent vector of the segment at t (centered at the origin and expressed as a complex number).""" dseg = self.derivative(t) return dseg/abs(dseg) - def normal(self, t): + def normal(self, t: float) -> complex: """returns the (right hand rule) unit normal vector to self at t.""" return -1j*self.unit_tangent(t) - def curvature(self, t): + def curvature(self, t: float) -> float: """returns the curvature of the segment at t.""" return segment_curvature(self, t) @@ -1960,12 +2131,12 @@ def curvature(self, t): # return [t for t in ts if 0<=t<=1] - def reversed(self): + def reversed(self) -> Arc: """returns a copy of the Arc object with its orientation reversed.""" return Arc(self.end, self.radius, self.rotation, self.large_arc, not self.sweep, self.start) - def phase2t(self, psi): + def phase2t(self, psi: float) -> float: """Converts phase to t-value. I.e. given phase, psi, such that -np.pi < psi <= np.pi, approximates @@ -1979,7 +2150,7 @@ def phase2t(self, psi): (float): the corresponding t-value. """ - def _deg(rads, domain_lower_limit): + def _deg(rads: float, domain_lower_limit: float) -> float: # Convert rads to degrees in [0, 360) domain degs = degrees(rads % (2*pi)) @@ -1996,7 +2167,8 @@ def _deg(rads, domain_lower_limit): degs = _deg(psi, domain_lower_limit=self.theta) return (degs - self.theta)/self.delta - def intersect(self, other_seg, tol=1e-12): + def intersect(self, other_seg: Segment, + tol: float = 1e-12) -> Sequence[Sequence[float]]: """NOT FULLY IMPLEMENTED. Finds the intersections of two segments. returns a list of tuples (t1, t2) such that self.point(t1) == other_seg.point(t2). @@ -2008,6 +2180,10 @@ def intersect(self, other_seg, tol=1e-12): to let me know if you're interested in such a feature -- or even better please submit an implementation if you want to code one.""" + # This local is reused for a polynomial, a point, and a pair of + # t-values in the branches below. + p: Any + # This special case can be easily solved algebraically. if (self.rotation == 0) and isinstance(other_seg, Line): a = self.radius.real @@ -2111,7 +2287,7 @@ def intersect(self, other_seg, tol=1e-12): if y1 != y2: y_values.append(y2) - intersections = [] + intersections: list[Sequence[float]] = [] for x in x_values: for y in y_values: p = complex(x, y) + self.center @@ -2151,12 +2327,13 @@ def intersect(self, other_seg, tol=1e-12): # compute that and see if any of those # intersection points are on the arcs. if (self.rotation == 0) and (self.radius.real == self.radius.imag) and (other_seg.rotation == 0) and (other_seg.radius.real == other_seg.radius.imag): - r0 = self.radius.real - r1 = other_seg.radius.real + r0: float = self.radius.real + r1: float = other_seg.radius.real p0 = self.center p1 = other_seg.center d = abs(p0 - p1) - possible_inters = [] + possible_inters: list[tuple[Optional[float], + Optional[float]]] = [] if d > (r0 + r1): # The circles are farther apart than the sum of @@ -2172,7 +2349,8 @@ def intersect(self, other_seg, tol=1e-12): # The Arcs lie on the same circle: they have the # same center and are of equal radius. - def point_in_seg_interior(point, seg): + def point_in_seg_interior(point: complex, + seg: Arc) -> bool: t = seg.point_to_t(point) if (not t or np.isclose(t, 0.0, rtol=0.0, atol=1e-6) or @@ -2248,7 +2426,7 @@ def point_in_seg_interior(point, seg): possible_inters.append((self.point_to_t(p30), other_seg.point_to_t(p30))) possible_inters.append((self.point_to_t(p31), other_seg.point_to_t(p31))) - inters = [] + inters: list[tuple[float, float]] = [] for p in possible_inters: self_t = p[0] if (self_t is None) or (self_t < 0.0) or (self_t > 1.0): continue @@ -2268,7 +2446,7 @@ def point_in_seg_interior(point, seg): # ad hoc fix for redundant solutions if len(inters) > 2: - def keyfcn(tpair): + def keyfcn(tpair: tuple[float, float]) -> float: t1, t2 = tpair return abs(self.point(t1) - other_seg.point(t2)) inters.sort(key=keyfcn) @@ -2284,7 +2462,7 @@ def keyfcn(tpair): raise TypeError("other_seg should be a Arc, Line, " "QuadraticBezier, or CubicBezier object.") - def bbox(self): + def bbox(self) -> BoundingBox: """returns a bounding box for the segment in the form (xmin, xmax, ymin, ymax).""" # a(t) = radians(self.theta + self.delta*t) @@ -2305,6 +2483,8 @@ def bbox(self): # for all k s.t. 0 < t < 1 from math import atan, tan + atan_x: float + atan_y: float if cos(self.phi) == 0: atan_x = pi/2 atan_y = 0 @@ -2316,11 +2496,11 @@ def bbox(self): atan_x = atan(-(ry/rx)*tan(self.phi)) atan_y = atan((ry/rx)/tan(self.phi)) - def angle_inv(ang, k): # inverse of angle from Arc.derivative() + def angle_inv(ang: float, k: int) -> float: # inverse of Arc.derivative()'s angle return ((ang + pi*k)*(360/(2*pi)) - self.theta)/self.delta - xtrema = [self.start.real, self.end.real] - ytrema = [self.start.imag, self.end.imag] + xtrema: list[float] = [self.start.real, self.end.real] + ytrema: list[float] = [self.start.imag, self.end.imag] for k in range(-4, 5): tx = angle_inv(atan_x, k) @@ -2332,12 +2512,12 @@ def angle_inv(ang, k): # inverse of angle from Arc.derivative() xmin = max(xtrema) return min(xtrema), max(xtrema), min(ytrema), max(ytrema) - def split(self, t): + def split(self, t: float) -> tuple[Arc, Arc]: """returns two segments, whose union is this segment and which join at self.point(t).""" return self.cropped(0, t), self.cropped(t, 1) - def cropped(self, t0, t1): + def cropped(self, t0: float, t1: float) -> Arc: """returns a cropped copy of this segment which starts at self.point(t0) and ends at self.point(t1).""" if abs(self.delta*(t1 - t0)) <= 180: @@ -2348,7 +2528,9 @@ def cropped(self, t0, t1): large_arc=new_large_arc, sweep=self.sweep, end=self.point(t1), autoscale_radius=self.autoscale_radius) - def radialrange(self, origin, return_all_global_extrema=False): + def radialrange(self, origin: complex, + return_all_global_extrema: bool = False + ) -> SegmentRadialRange: """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize and maximize, respectively, the distance, d = |self.point(t)-origin|.""" @@ -2396,23 +2578,25 @@ def radialrange(self, origin, return_all_global_extrema=False): raise _NotImplemented4ArcException - def rotated(self, degs, origin=None): + def rotated(self, degs: float, + origin: Optional[complex] = None) -> Arc: """Returns a copy of self rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default `origin` is either `self.point(0.5)`, or in the case that self is an Arc object, `origin` defaults to `self.center`.""" return rotate(self, degs, origin=origin) - def translated(self, z0): + def translated(self, z0: complex) -> Arc: """Returns a copy of self shifted by the complex quantity `z0` such that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" return translate(self, z0) - def scaled(self, sx, sy=None, origin=0j): + def scaled(self, sx: float, sy: Optional[float] = None, + origin: complex = 0j) -> Arc: """Scale transform. See `scale` function for further explanation.""" return scale(self, sx=sx, sy=sy, origin=origin) - def as_cubic_curves(self, curves=1): + def as_cubic_curves(self, curves: int = 1) -> Iterator[CubicBezier]: """Generates cubic curves to approximate this arc""" slice_t = radians(self.delta) / float(curves) @@ -2457,7 +2641,7 @@ def as_cubic_curves(self, curves=1): p_start = p_end current_t = next_t - def as_quad_curves(self, curves=1): + def as_quad_curves(self, curves: int = 1) -> Iterator[QuadraticBezier]: """Generates quadratic curves to approximate this arc""" slice_t = radians(self.delta) / float(curves) @@ -2493,20 +2677,37 @@ def as_quad_curves(self, curves=1): current_t = next_t +# A path segment of any kind. `Segment` is the type most functions here +# accept and return; the narrower `BezierSegment` excludes `Arc`, for +# which several algorithms above are not implemented. +BezierSegment = Union[Line, QuadraticBezier, CubicBezier] +Segment = Union[BezierSegment, Arc] + +# `transform` is type-preserving for everything except an `Arc`, so it is +# spelled out per kind rather than with a single TypeVar. +BezierSegmentT = TypeVar("BezierSegmentT", bound=BezierSegment) + +# One entry of `Path.intersect()`'s result: ((T1, seg1, t1), (T2, seg2, t2)). +Intersection = Tuple[Tuple[float, Segment, float], + Tuple[float, Segment, float]] + + class Path(MutableSequence): """A Path is a sequence of path segments""" # Put it here, so there is a default if unpickled. - _closed = False - _start = None - _end = None - element = None - transform = None - meta = None # meant as container for storage of arbitrary meta data - - def __init__(self, *segments, **kw): - self._length = None - self._lengths = None + _closed: bool = False + _start: Optional[complex] = None + _end: Optional[complex] = None + # Set by `document.flattened_paths()` on the paths it returns. + element: Optional[Element] = None + transform: Optional[np.ndarray] = None + meta: Any = None # meant as container for storage of arbitrary meta data + + def __init__(self, *segments: Any, **kw: Any) -> None: + # Lazily filled in by `_calc_lengths()`; None means "not computed". + self._length: Optional[float] = None + self._lengths: Optional[list[float]] = None if 'closed' in kw: self.closed = kw['closed'] # DEPRECATED if len(segments) >= 1: @@ -2517,7 +2718,7 @@ def __init__(self, *segments, **kw): current_pos = kw['current_pos'] else: current_pos = 0j - self._segments = list() + self._segments: list[Segment] = list() self._parse_path(segments[0], current_pos) else: self._segments = list(segments) @@ -2531,26 +2732,39 @@ def __init__(self, *segments, **kw): self._end = None if 'tree_element' in kw: - self._tree_element = kw['tree_element'] + self._tree_element: Optional[Element] = kw['tree_element'] def __hash__(self) -> int: - def _pointify(segment): + def _pointify(segment: Segment) -> tuple[Any, ...]: return segment.apoints() if isinstance(segment, Arc) else segment.bpoints() pts = tuple(x for segment in self._segments for x in _pointify(segment)) return hash(pts + (self._closed,)) - def __getitem__(self, index): + @overload + def __getitem__(self, index: int) -> Segment: ... + + @overload + def __getitem__(self, index: slice) -> list[Segment]: ... + + def __getitem__(self, index: Union[int, slice]) -> Any: return self._segments[index] - def __setitem__(self, index, value): + @overload + def __setitem__(self, index: int, value: Segment) -> None: ... + + @overload + def __setitem__(self, index: slice, + value: Iterable[Segment]) -> None: ... + + def __setitem__(self, index: Union[int, slice], value: Any) -> None: self._segments[index] = value self._length = None self._start = self._segments[0].start self._end = self._segments[-1].end - def __delitem__(self, index): + def __delitem__(self, index: Union[int, slice]) -> None: del self._segments[index] self._length = None if len(self._segments) > 0: @@ -2560,32 +2774,32 @@ def __delitem__(self, index): self._start = None self._end = None - def __iter__(self): + def __iter__(self) -> Iterator[Segment]: return self._segments.__iter__() - def __contains__(self, x): + def __contains__(self, x: object) -> bool: return self._segments.__contains__(x) - def insert(self, index, value): + def insert(self, index: int, value: Segment) -> None: self._segments.insert(index, value) self._length = None self._start = self._segments[0].start self._end = self._segments[-1].end - def reversed(self): + def reversed(self) -> Path: """returns a copy of the Path object with its orientation reversed.""" newpath = [seg.reversed() for seg in self] newpath.reverse() return Path(*newpath) - def __len__(self): + def __len__(self) -> int: return len(self._segments) - def __repr__(self): + def __repr__(self) -> str: return "Path({})".format( ",\n ".join(repr(x) for x in self._segments)) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, Path): return False if len(self) != len(other): @@ -2595,12 +2809,13 @@ def __eq__(self, other): return False return True - def __ne__(self, other): + def __ne__(self, other: object) -> bool: if not isinstance(other, Path): return NotImplemented return not self == other - def _calc_lengths(self, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): + def _calc_lengths(self, error: float = LENGTH_ERROR, + min_depth: int = LENGTH_MIN_DEPTH) -> None: if self._length is not None: return @@ -2612,7 +2827,7 @@ def _calc_lengths(self, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): else: self._lengths = [each / self._length for each in lengths] - def point(self, pos): + def point(self, pos: float) -> complex: # Shortcuts if len(self._segments) == 0: @@ -2624,9 +2839,10 @@ def point(self, pos): self._calc_lengths() # Find which segment the point we search for is located on: - segment_start = 0 + segment_start: float = 0 for index, segment in enumerate(self._segments): - segment_end = segment_start + self._lengths[index] + # _calc_lengths() above populated _lengths. + segment_end = segment_start + self._lengths[index] # type: ignore[index] if segment_end >= pos: # This is the segment! How far in on the segment is the point? segment_pos = (pos - segment_start)/( @@ -2635,10 +2851,12 @@ def point(self, pos): segment_start = segment_end raise RuntimeError("Something has gone wrong. Could not compute Path.point({}) for path {}".format(pos, self)) - def length(self, T0=0, T1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): + def length(self, T0: float = 0, T1: float = 1, + error: float = LENGTH_ERROR, + min_depth: int = LENGTH_MIN_DEPTH) -> float: self._calc_lengths(error=error, min_depth=min_depth) if T0 == 0 and T1 == 1: - return self._length + return self._length # type: ignore[return-value] else: if len(self) == 1: return self[0].length(t0=T0, t1=T1) @@ -2650,19 +2868,20 @@ def length(self, T0=0, T1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): sum(self[idx].length() for idx in range(idx0 + 1, idx1)) + self[idx1].length(t1=t1)) - def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, - error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + def ilength(self, s: float, s_tol: float = ILENGTH_S_TOL, + maxits: int = ILENGTH_MAXITS, error: float = ILENGTH_ERROR, + min_depth: int = ILENGTH_MIN_DEPTH) -> float: """Returns a float, t, such that self.length(0, t) is approximately s. See the inv_arclength() docstring for more details.""" return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) - def iscontinuous(self): + def iscontinuous(self) -> bool: """Checks if a path is continuous with respect to its parameterization.""" return all(self[i].end == self[i+1].start for i in range(len(self) - 1)) - def continuous_subpaths(self): + def continuous_subpaths(self) -> list[Path]: """Breaks self into its continuous components, returning a list of continuous subpaths. I.e. @@ -2670,7 +2889,7 @@ def continuous_subpaths(self): and self == concatpaths(self.continuous_subpaths())) ) """ - subpaths = [] + subpaths: list[Path] = [] subpath_start = 0 for i in range(len(self) - 1): if self[i].end != self[(i+1) % len(self)].start: @@ -2679,17 +2898,17 @@ def continuous_subpaths(self): subpaths.append(Path(*self[subpath_start: len(self)])) return subpaths - def isclosed(self): + def isclosed(self) -> bool: """This function determines if a connected path is closed.""" assert len(self) != 0 assert self.iscontinuous() return self.start == self.end - def isclosedac(self): + def isclosedac(self) -> bool: assert len(self) != 0 return self.start == self.end - def _is_closable(self): + def _is_closable(self) -> bool: try: end = self[-1].end except IndexError: @@ -2700,7 +2919,7 @@ def _is_closable(self): return False @property - def closed(self, warning_on=CLOSED_WARNING_ON): + def closed(self, warning_on: bool = CLOSED_WARNING_ON) -> bool: """The closed attribute is deprecated, please use the isclosed() method instead. See _closed_warning for more information.""" mes = ("This attribute is deprecated, consider using isclosed() " @@ -2713,37 +2932,38 @@ def closed(self, warning_on=CLOSED_WARNING_ON): return self._closed and self._is_closable() @closed.setter - def closed(self, value): + def closed(self, value: Any) -> None: value = bool(value) if value and not self._is_closable(): raise ValueError("End does not coincide with a segment start.") self._closed = value @property - def start(self): + def start(self) -> Optional[complex]: if not self._start and len(self._segments)>0: self._start = self._segments[0].start return self._start @start.setter - def start(self, pt): + def start(self, pt: complex) -> None: self._start = pt if len(self._segments)>0: self._segments[0].start = pt @property - def end(self): + def end(self) -> Optional[complex]: if not self._end and len(self._segments)>0: self._end = self._segments[-1].end return self._end @end.setter - def end(self, pt): + def end(self, pt: complex) -> None: self._end = pt if len(self._segments)>0: self._segments[-1].end = pt - def d(self, useSandT=False, use_closed_attrib=False, rel=False): + def d(self, useSandT: bool = False, use_closed_attrib: bool = False, + rel: bool = False) -> str: """Returns a path d-string for the path object. For an explanation of useSandT and use_closed_attrib, see the compatibility notes in the README.""" @@ -2759,10 +2979,13 @@ def d(self, useSandT=False, use_closed_attrib=False, rel=False): self_closed = False segments = self[:] - current_pos = None - parts = [] - previous_segment = None + current_pos: Optional[complex] = None + parts: list[str] = [] + previous_segment: Optional[Segment] = None end = self[-1].end + # Reused for each command's format arguments, which differ in both + # length and element type. + args: tuple[Any, ...] for segment in segments: seg_start = segment.start @@ -2846,7 +3069,8 @@ def d(self, useSandT=False, use_closed_attrib=False, rel=False): s = ' '.join(parts) return s if not rel else s.lower() - def joins_smoothly_with(self, previous, wrt_parameterization=False): + def joins_smoothly_with(self, previous: Curve, + wrt_parameterization: bool = False) -> bool: """Checks if this Path object joins smoothly with previous path/segment. By default, this only checks that this Path starts moving (at t=0) in the same direction (and from the same positive) as @@ -2859,7 +3083,7 @@ def joins_smoothly_with(self, previous, wrt_parameterization=False): return self[0].start == previous.end and self.unit_tangent( 0) == previous.unit_tangent(1) - def T2t(self, T): + def T2t(self, T: float) -> tuple[int, float]: """returns the segment index, `seg_idx`, and segment parameter, `t`, corresponding to the path parameter `T`. In other words, this is the inverse of the `Path.t2T()` method.""" @@ -2869,8 +3093,9 @@ def T2t(self, T): return 0, 0 self._calc_lengths() # Find which segment self.point(T) falls on: - T0 = 0 # the T-value the current segment starts on - for seg_idx, seg_length in enumerate(self._lengths): + T0: float = 0 # the T-value the current segment starts on + # _calc_lengths() above populated _lengths. + for seg_idx, seg_length in enumerate(self._lengths): # type: ignore[arg-type] T1 = T0 + seg_length # the T-value the current segment ends on if T1 >= T: # This is the segment! @@ -2881,7 +3106,7 @@ def T2t(self, T): assert 0 <= T <= 1 raise BugException - def t2T(self, seg, t): + def t2T(self, seg: Union[int, Segment], t: float) -> float: """returns the path parameter T which corresponds to the segment parameter t. In other words, for any Path object, path, and any segment in path, seg, T(t) = path.t2T(seg, t) is the unique @@ -2900,12 +3125,13 @@ def t2T(self, seg, t): assert is_path_segment(seg) or isinstance(seg, int) raise - segment_start = sum(self._lengths[:seg_idx]) - segment_end = segment_start + self._lengths[seg_idx] + # _calc_lengths() above populated _lengths. + segment_start = sum(self._lengths[:seg_idx]) # type: ignore[index] + segment_end = segment_start + self._lengths[seg_idx] # type: ignore[index] T = (segment_end - segment_start)*t + segment_start return T - def derivative(self, T, n=1): + def derivative(self, T: float, n: int = 1) -> complex: """returns the tangent vector of the Path at T (centered at the origin and expressed as a complex number). Note: Bezier curves can have points where their derivative vanishes. @@ -2915,7 +3141,7 @@ def derivative(self, T, n=1): seg = self._segments[seg_idx] return seg.derivative(t, n=n)/seg.length()**n - def unit_tangent(self, T): + def unit_tangent(self, T: float) -> complex: """returns the unit tangent vector of the Path at T (centered at the origin and expressed as a complex number). If the tangent vector's magnitude is zero, this method will find the limit of @@ -2923,11 +3149,11 @@ def unit_tangent(self, T): seg_idx, t = self.T2t(T) return self._segments[seg_idx].unit_tangent(t) - def normal(self, t): + def normal(self, t: float) -> complex: """returns the (right hand rule) unit normal vector to self at t.""" return -1j*self.unit_tangent(t) - def curvature(self, T): + def curvature(self, T: float) -> float: """returns the curvature of this Path object at T and outputs float('inf') if not differentiable at T.""" seg_idx, t = self.T2t(T) @@ -2959,7 +3185,7 @@ def curvature(self, T): # Ts += [self.t2T(i, t) for t in seg.icurvature(kappa)] # return Ts - def area(self, chord_length=1e-4): + def area(self, chord_length: float = 1e-4) -> float: """Find area enclosed by path. Approximates any Arc segments in the Path with lines @@ -2982,17 +3208,18 @@ def area(self, chord_length=1e-4): desired accuracy). """ - def area_without_arcs(path): + def area_without_arcs(path: Path) -> float: area_enclosed = 0 for seg in path: - x = real(seg.poly()) - dy = imag(seg.poly()).deriv() + # `path` here is arc-free, so every segment has .poly(). + x = real(seg.poly()) # type: ignore[union-attr] + dy = imag(seg.poly()).deriv() # type: ignore[union-attr] integrand = x*dy integral = integrand.integ() area_enclosed += integral(1) - integral(0) return area_enclosed - def seg2lines(seg_): + def seg2lines(seg_: Segment) -> list[Line]: """Find piecewise-linear approximation of `seg`.""" num_lines = int(ceil(seg_.length() / chord_length)) pts = [seg_.point(t) for t in np.linspace(0, 1, num_lines+1)] @@ -3000,7 +3227,7 @@ def seg2lines(seg_): assert self.isclosed() - bezier_path_approximation = [] + bezier_path_approximation: list[Segment] = [] for seg in self: if isinstance(seg, Arc): bezier_path_approximation += seg2lines(seg) @@ -3008,7 +3235,19 @@ def seg2lines(seg_): bezier_path_approximation.append(seg) return area_without_arcs(Path(*bezier_path_approximation)) - def intersect(self, other_curve, justonemode=False, tol=1e-12): + @overload + def intersect(self, other_curve: Curve, + justonemode: Literal[False] = ..., + tol: float = ...) -> list[Intersection]: ... + + @overload + def intersect(self, other_curve: Curve, justonemode: Literal[True], + tol: float = ... + ) -> Union[Intersection, list[Intersection]]: ... + + def intersect(self, other_curve: Curve, justonemode: bool = False, + tol: float = 1e-12 + ) -> Union[Intersection, list[Intersection]]: """Finds intersections of `self` with `other_curve` Args: @@ -3032,7 +3271,7 @@ def intersect(self, other_curve, justonemode=False, tol=1e-12): path2 = other_curve if isinstance(other_curve, Path) else Path(other_curve) assert path1 != path2 - intersection_list = [] + intersection_list: list[Intersection] = [] for seg1 in path1: for seg2 in path2: if justonemode and intersection_list: @@ -3051,7 +3290,7 @@ def intersect(self, other_curve, justonemode=False, tol=1e-12): # redundancies. if intersection_list: pts = [_seg1.point(_t1) for _T1, _seg1, _t1 in list(zip(*intersection_list))[0]] - indices2remove = [] + indices2remove: list[int] = [] for ind1 in range(len(pts)): for ind2 in range(ind1 + 1, len(pts)): if abs(pts[ind1] - pts[ind2]) < tol: @@ -3062,7 +3301,7 @@ def intersect(self, other_curve, justonemode=False, tol=1e-12): ind not in indices2remove] return intersection_list - def bbox(self): + def bbox(self) -> BoundingBox: """returns bounding box in the form (xmin, xmax, ymin, ymax).""" bbs = [seg.bbox() for seg in self._segments] xmins, xmaxs, ymins, ymaxs = list(zip(*bbs)) @@ -3072,7 +3311,7 @@ def bbox(self): ymax = max(ymaxs) return xmin, xmax, ymin, ymax - def cropped(self, T0, T1): + def cropped(self, T0: float, T1: float) -> Path: """returns a cropped copy of the path.""" assert 0 <= T0 <= 1 and 0 <= T1<= 1 assert T0 != T1 @@ -3081,6 +3320,8 @@ def cropped(self, T0, T1): if T0 == 1 and 0 < T1 < 1 and self.isclosed(): return self.cropped(0, T1) + t_seg0: float + t_seg1: float if T1 == 1: seg1 = self[-1] t_seg1 = 1 @@ -3133,15 +3374,17 @@ def cropped(self, T0, T1): new_path.append(seg1.cropped(0, t_seg1)) return new_path - def radialrange(self, origin, return_all_global_extrema=False): + def radialrange(self, origin: complex, + return_all_global_extrema: bool = False + ) -> PathRadialRange: """returns the tuples (d_min, t_min, idx_min), (d_max, t_max, idx_max) which minimize and maximize, respectively, the distance d = |self[idx].point(t)-origin|.""" if return_all_global_extrema: raise NotImplementedError else: - global_min = (np.inf, None, None) - global_max = (0, None, None) + global_min: PathExtremum = (np.inf, None, None) + global_max: PathExtremum = (0, None, None) for seg_idx, seg in enumerate(self): seg_global_min, seg_global_max = seg.radialrange(origin) if seg_global_min[0] < global_min[0]: @@ -3150,23 +3393,25 @@ def radialrange(self, origin, return_all_global_extrema=False): global_max = seg_global_max + (seg_idx,) return global_min, global_max - def rotated(self, degs, origin=None): + def rotated(self, degs: float, + origin: Optional[complex] = None) -> Path: """Returns a copy of self rotated by `degs` degrees (CCW) around the point `origin` (a complex number). By default `origin` is either `self.point(0.5)`, or in the case that self is an Arc object, `origin` defaults to `self.center`.""" return rotate(self, degs, origin=origin) - def translated(self, z0): + def translated(self, z0: complex) -> Path: """Returns a copy of self shifted by the complex quantity `z0` such that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" return translate(self, z0) - def scaled(self, sx, sy=None, origin=0j): + def scaled(self, sx: float, sy: Optional[float] = None, + origin: complex = 0j) -> Path: """Scale transform. See `scale` function for further explanation.""" return scale(self, sx=sx, sy=sy, origin=origin) - def is_contained_by(self, other): + def is_contained_by(self, other: Path) -> bool: """Returns true if the path is fully contained in other closed path""" assert isinstance(other, Path) assert other.isclosed() @@ -3185,7 +3430,7 @@ def is_contained_by(self, other): opt = complex(xmin-1, ymin-1) return path_encloses_pt(pt, opt, other) - def approximate_arcs_with_cubics(self, error=0.1): + def approximate_arcs_with_cubics(self, error: float = 0.1) -> None: """ Iterates through this path and replaces any Arcs with cubic bezier curves. """ @@ -3198,7 +3443,7 @@ def approximate_arcs_with_cubics(self, error=0.1): arc_required = int(ceil(abs(segment.delta) / sweep_limit)) self[s:s+1] = list(segment.as_cubic_curves(arc_required)) - def approximate_arcs_with_quads(self, error=0.1): + def approximate_arcs_with_quads(self, error: float = 0.1) -> None: """ Iterates through this path and replaces any Arcs with quadratic bezier curves. """ @@ -3211,7 +3456,7 @@ def approximate_arcs_with_quads(self, error=0.1): arc_required = int(ceil(abs(segment.delta) / sweep_limit)) self[s:s+1] = list(segment.as_quad_curves(arc_required)) - def joints(self): + def joints(self) -> Iterator[tuple[Segment, Segment]]: """returns generator of segment joints I.e. Path(s0, s1, s2, ..., sn).joints() returns generator @@ -3223,8 +3468,8 @@ def joints(self): next(b, None) return zip(a, b) - def _tokenize_path(self, pathdef): - command = None + def _tokenize_path(self, pathdef: str) -> Iterator[str]: + command: Optional[str] = None for x in COMMAND_RE.split(pathdef): if x in COMMANDS: command = x @@ -3239,7 +3484,9 @@ def _tokenize_path(self, pathdef): for token in FLOAT_RE.findall(x): yield token - def _parse_path(self, pathdef, current_pos=0j, tree_element=None): + def _parse_path(self, pathdef: str, current_pos: complex = 0j, + tree_element: Optional[Element] = None + ) -> list[Segment]: # In the SVG specs, initial movetos are absolute, even if # specified as 'm'. This is the default behavior here as well. # But if you pass in a current_pos variable, the initial moveto @@ -3250,8 +3497,8 @@ def _parse_path(self, pathdef, current_pos=0j, tree_element=None): segments = self._segments - start_pos = None - command = None + start_pos: Optional[complex] = None + command: Optional[str] = None while elements: @@ -3301,9 +3548,11 @@ def _parse_path(self, pathdef, current_pos=0j, tree_element=None): elif command == 'Z': # Close path if not (current_pos == start_pos): - segments.append(Line(current_pos, start_pos)) + # start_pos is None only for a d-string that opens with + # a Z command, which is not valid path data. + segments.append(Line(current_pos, start_pos)) # type: ignore[arg-type] self._closed = True - current_pos = start_pos + current_pos = start_pos # type: ignore[assignment] command = None elif command == 'L': @@ -3357,7 +3606,8 @@ def _parse_path(self, pathdef, current_pos=0j, tree_element=None): # 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 + control1 = (current_pos + current_pos + - segments[-1].control2) # type: ignore[union-attr] control2 = float(elements.pop()) + float(elements.pop()) * 1j end = float(elements.pop()) + float(elements.pop()) * 1j @@ -3393,7 +3643,8 @@ def _parse_path(self, pathdef, current_pos=0j, tree_element=None): # 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 + control = (current_pos + current_pos + - segments[-1].control) # type: ignore[union-attr] end = float(elements.pop()) + float(elements.pop()) * 1j @@ -3426,8 +3677,19 @@ def _parse_path(self, pathdef, current_pos=0j, tree_element=None): ''.format(current_pos, end)) segments.append(Line(current_pos, end)) else: + # The flags are parsed with float(); Arc coerces them. segments.append( - Arc(current_pos, radius, rotation, arc, sweep, end)) + Arc(current_pos, radius, rotation, arc, sweep, end)) # type: ignore[arg-type] current_pos = end return segments + + +# Anything that can be evaluated/transformed as a curve, i.e. a single +# segment or a whole path. +Curve = Union[Segment, Path] + +# `rotate`, `translate` and `scale` return the same kind of curve they are +# given; `transform` does not (it degenerates an `Arc` to a `Line` when the +# transform collapses a radius), so it is not typed with this. +CurveT = TypeVar("CurveT", bound=Curve) diff --git a/svgpathtools/paths2svg.py b/svgpathtools/paths2svg.py index 6e9c10e..5396455 100644 --- a/svgpathtools/paths2svg.py +++ b/svgpathtools/paths2svg.py @@ -4,7 +4,8 @@ """ # External dependencies: -from __future__ import division, absolute_import, print_function +from __future__ import division, absolute_import, print_function, annotations +from typing import Any, Mapping, Optional, Sequence, Tuple, Union from math import ceil from os import path as os_path, makedirs from tempfile import gettempdir @@ -15,9 +16,18 @@ import re # Internal dependencies -from .path import Path, Line, is_path_segment +from .path import Path, Line, is_path_segment, Segment +from .bezier import BoundingBox from .misctools import open_in_browser +# Something `disvg` can draw: a Path, a single path segment, or a path +# d-string. +Drawable = Union[Path, Segment, str] + +# A stroke/fill color: anything the SVG viewer understands, or an RGB +# 3-tuple. +Color = Union[str, Tuple[int, int, int]] + # color shorthand for inputting color list as string of chars. color_dict = {'a': 'aqua', 'b': 'blue', @@ -47,7 +57,7 @@ 'z': 'azure'} -def str2colorlist(s, default_color=None): +def str2colorlist(s: str, default_color: Optional[str] = None) -> list[str]: color_list = [color_dict[ch] for ch in s] if default_color: for idx, c in enumerate(color_list): @@ -56,11 +66,12 @@ def str2colorlist(s, default_color=None): return color_list -def is3tuple(c): +def is3tuple(c: object) -> bool: return isinstance(c, tuple) and len(c) == 3 -def big_bounding_box(paths_n_stuff): +def big_bounding_box( + paths_n_stuff: Sequence[Union[Drawable, complex]]) -> BoundingBox: """returns minimal upright bounding box. Args: @@ -71,7 +82,7 @@ def big_bounding_box(paths_n_stuff): extrema of bounding box, (xmin, xmax, ymin, ymax) """ - bbs = [] + bbs: list[BoundingBox] = [] for thing in paths_n_stuff: if is_path_segment(thing) or isinstance(thing, Path): bbs.append(thing.bbox()) @@ -94,13 +105,27 @@ def big_bounding_box(paths_n_stuff): return xmin, xmax, ymin, ymax -def disvg(paths=None, colors=None, filename=None, stroke_widths=None, - nodes=None, node_colors=None, node_radii=None, - openinbrowser=True, timestamp=None, margin_size=0.1, - mindim=600, dimensions=None, viewbox=None, text=None, - text_path=None, font_size=None, attributes=None, - svg_attributes=None, svgwrite_debug=False, - paths2Drawing=False, baseunit='px'): +def disvg(paths: Union[Path, Segment, Sequence[Drawable], None] = None, # type: ignore[return] + colors: Union[str, Sequence[Color], None] = None, + filename: Optional[str] = None, + stroke_widths: Optional[Sequence[float]] = None, + nodes: Optional[Sequence[complex]] = None, + node_colors: Union[str, Sequence[Color], None] = None, + node_radii: Optional[Sequence[float]] = None, + openinbrowser: bool = True, + timestamp: Optional[bool] = None, + margin_size: float = 0.1, + mindim: Optional[int] = 600, + dimensions: Optional[Sequence[Union[str, float]]] = None, + viewbox: Union[str, Sequence[float], None] = None, + text: Union[str, Sequence[str], None] = None, + text_path: Optional[Sequence[Drawable]] = None, + font_size: Union[float, Sequence[float], None] = None, + attributes: Optional[Sequence[Mapping[str, Any]]] = None, + svg_attributes: Optional[Mapping[str, Any]] = None, + svgwrite_debug: bool = False, + paths2Drawing: bool = False, + baseunit: str = 'px') -> Optional[Drawing]: """Creates (and optionally displays) an SVG file. REQUIRED INPUTS: @@ -258,7 +283,7 @@ def disvg(paths=None, colors=None, filename=None, stroke_widths=None, # set up the viewBox and display dimensions of the output SVG # along the way, set stroke_widths and node_radii if not provided assert paths or nodes - stuff2bound = [] + stuff2bound: list[Union[Drawable, complex]] = [] if viewbox: if not isinstance(viewbox, str): viewbox = '%s %s %s %s' % viewbox @@ -266,8 +291,9 @@ def disvg(paths=None, colors=None, filename=None, stroke_widths=None, dimensions = viewbox.split(' ')[2:4] elif dimensions: dimensions = tuple(map(str, dimensions)) - def strip_units(s): - return re.search(r'\d*\.?\d*', s.strip()).group() + def strip_units(s: str) -> str: + # The pattern can match empty, so search never returns None. + return re.search(r'\d*\.?\d*', s.strip()).group() # type: ignore[union-attr] viewbox = '0 0 %s %s' % tuple(map(strip_units, dimensions)) else: if paths: @@ -362,14 +388,21 @@ def strip_units(s): dwg.add(dwg.path(**good_attribs)) else: - dwg.add(dwg.path(ps, stroke=colors[i], - stroke_width=str(stroke_widths[i]), + # `colors` is defaulted above under `if paths:`; but + # `stroke_widths` is only defaulted in the branch that + # computes the viewbox, so it is genuinely None when the + # caller passed `viewbox` or `dimensions` (raises TypeError). + dwg.add(dwg.path(ps, stroke=colors[i], # type: ignore[index] + stroke_width=str(stroke_widths[i]), # type: ignore[index] fill='none')) # add nodes (filled in circles) if nodes: for i_pt, pt in enumerate([(z.real, z.imag) for z in nodes]): - dwg.add(dwg.circle(pt, node_radii[i_pt], fill=node_colors[i_pt])) + # As above: node_colors is defaulted, node_radii only in the + # viewbox branch. + dwg.add(dwg.circle(pt, node_radii[i_pt], # type: ignore[index] + fill=node_colors[i_pt])) # type: ignore[index] # add texts if text: @@ -382,17 +415,21 @@ def strip_units(s): font_size = [_default_font_size] if not text_path: pos = complex(xmin + margin_size*dx, ymin + margin_size*dy) - text_path = [Line(pos, pos + 1).d()] + # NOTE: Line has no .d() method (only Path does), so this + # raises AttributeError; it is reached by passing `text` + # without `text_path`. xmin/dx are also only bound in the + # viewbox-computing branch. + text_path = [Line(pos, pos + 1).d()] # type: ignore[attr-defined] else: if font_size: if isinstance(font_size, list): assert len(font_size) == len(text) else: - font_size = [font_size] * len(text) + font_size = [font_size] * len(text) # type: ignore[list-item] else: font_size = [_default_font_size] * len(text) for idx, s in enumerate(text): - p = text_path[idx] + p = text_path[idx] # type: ignore[index] if isinstance(p, Path): ps = p.d() elif is_path_segment(p): @@ -404,7 +441,7 @@ def strip_units(s): # paragraph.add(dwg.textPath(ps, s)) pathid = 'tp' + str(idx) dwg.defs.add(dwg.path(d=ps, id=pathid)) - txter = dwg.add(dwg.text('', font_size=font_size[idx])) + txter = dwg.add(dwg.text('', font_size=font_size[idx])) # type: ignore[index] txter.add(txt.TextPath('#'+pathid, s)) if paths2Drawing: @@ -426,13 +463,27 @@ def strip_units(s): print(filename) -def wsvg(paths=None, colors=None, filename=None, stroke_widths=None, - nodes=None, node_colors=None, node_radii=None, - openinbrowser=False, timestamp=False, margin_size=0.1, - mindim=600, dimensions=None, viewbox=None, text=None, - text_path=None, font_size=None, attributes=None, - svg_attributes=None, svgwrite_debug=False, - paths2Drawing=False, baseunit='px'): +def wsvg(paths: Union[Path, Segment, Sequence[Drawable], None] = None, + colors: Union[str, Sequence[Color], None] = None, + filename: Optional[str] = None, + stroke_widths: Optional[Sequence[float]] = None, + nodes: Optional[Sequence[complex]] = None, + node_colors: Union[str, Sequence[Color], None] = None, + node_radii: Optional[Sequence[float]] = None, + openinbrowser: bool = False, + timestamp: Optional[bool] = False, + margin_size: float = 0.1, + mindim: Optional[int] = 600, + dimensions: Optional[Sequence[Union[str, float]]] = None, + viewbox: Union[str, Sequence[float], None] = None, + text: Union[str, Sequence[str], None] = None, + text_path: Optional[Sequence[Drawable]] = None, + font_size: Union[float, Sequence[float], None] = None, + attributes: Optional[Sequence[Mapping[str, Any]]] = None, + svg_attributes: Optional[Mapping[str, Any]] = None, + svgwrite_debug: bool = False, + paths2Drawing: bool = False, + baseunit: str = 'px') -> Optional[Drawing]: """Create SVG and write to disk. Note: This is identical to `disvg()` except that `openinbrowser` @@ -454,13 +505,27 @@ def wsvg(paths=None, colors=None, filename=None, stroke_widths=None, paths2Drawing=paths2Drawing, baseunit=baseunit) -def paths2Drawing(paths=None, colors=None, filename=None, - stroke_widths=None, nodes=None, node_colors=None, - node_radii=None, openinbrowser=False, timestamp=False, - margin_size=0.1, mindim=600, dimensions=None, - viewbox=None, text=None, text_path=None, - font_size=None, attributes=None, svg_attributes=None, - svgwrite_debug=False, paths2Drawing=True, baseunit='px'): +def paths2Drawing(paths: Union[Path, Segment, Sequence[Drawable], None] = None, + colors: Union[str, Sequence[Color], None] = None, + filename: Optional[str] = None, + stroke_widths: Optional[Sequence[float]] = None, + nodes: Optional[Sequence[complex]] = None, + node_colors: Union[str, Sequence[Color], None] = None, + node_radii: Optional[Sequence[float]] = None, + openinbrowser: bool = False, + timestamp: Optional[bool] = False, + margin_size: float = 0.1, + mindim: Optional[int] = 600, + dimensions: Optional[Sequence[Union[str, float]]] = None, + viewbox: Union[str, Sequence[float], None] = None, + text: Union[str, Sequence[str], None] = None, + text_path: Optional[Sequence[Drawable]] = None, + font_size: Union[float, Sequence[float], None] = None, + attributes: Optional[Sequence[Mapping[str, Any]]] = None, + svg_attributes: Optional[Mapping[str, Any]] = None, + svgwrite_debug: bool = False, + paths2Drawing: bool = True, + baseunit: str = 'px') -> Optional[Drawing]: """Create and return `svg.Drawing` object. Note: This is identical to `disvg()` except that `paths2Drawing` diff --git a/svgpathtools/polytools.py b/svgpathtools/polytools.py index 3fbdc22..cfe112a 100644 --- a/svgpathtools/polytools.py +++ b/svgpathtools/polytools.py @@ -1,15 +1,37 @@ """This submodule contains tools for working with numpy.poly1d objects.""" # External Dependencies -from __future__ import division, absolute_import +from __future__ import division, absolute_import, annotations +from typing import (Any, Callable, Iterable, Literal, Sequence, Union, + overload) from itertools import combinations import numpy as np # Internal Dependencies from .misctools import isclose +# Polynomial coefficients, highest-degree first -- anything np.roots accepts. +Coefficients = Union[np.poly1d, np.ndarray, Sequence[complex]] -def polyroots(p, realroots=False, condition=lambda r: True): + +@overload +def polyroots(p: Coefficients, realroots: Literal[False] = ..., + condition: Callable[[complex], bool] = ...) -> list[complex]: ... + + +@overload +def polyroots(p: Coefficients, realroots: Literal[True], + condition: Callable[[float], bool] = ...) -> list[float]: ... + + +@overload +def polyroots(p: Coefficients, realroots: bool, + condition: Callable[[float], bool] = ...) -> list[Any]: ... + + +def polyroots(p: Coefficients, realroots: bool = False, + condition: Callable[[float], bool] = lambda r: True + ) -> list[Any]: """ Returns the roots of a polynomial with coefficients given in p. p[0] * x**n + p[1] * x**(n-1) + ... + p[n-1]*x + p[n] @@ -23,7 +45,7 @@ def polyroots(p, realroots=False, condition=lambda r: True): OUTPUT: A list containing the roots of the polynomial. NOTE: This uses np.isclose and np.roots""" - roots = np.roots(p) + roots: Iterable[Any] = np.roots(p) if realroots: roots = [r.real for r in roots if isclose(r.imag, 0)] roots = [r for r in roots if condition(r)] @@ -35,7 +57,7 @@ def polyroots(p, realroots=False, condition=lambda r: True): return [r for idx, r in enumerate(roots) if idx not in duplicates] -def polyroots01(p): +def polyroots01(p: Coefficients) -> list[float]: """Returns the real roots between 0 and 1 of the polynomial with coefficients given in p, p[0] * x**n + p[1] * x**(n-1) + ... + p[n-1]*x + p[n] @@ -43,9 +65,12 @@ def polyroots01(p): return polyroots(p, realroots=True, condition=lambda tval: 0 <= tval <= 1) -def rational_limit(f, g, t0): +def rational_limit(f: np.poly1d, g: np.poly1d, t0: float) -> complex: """Computes the limit of the rational function (f/g)(t) - as t approaches t0.""" + as t approaches t0. + + Note: the limit is real whenever f and g have real coefficients; + np.poly1d cannot express that in its type, hence the complex return.""" assert isinstance(f, np.poly1d) and isinstance(g, np.poly1d) assert g != np.poly1d([0]) if g(t0) != 0: @@ -56,25 +81,51 @@ def rational_limit(f, g, t0): raise ValueError("Limit does not exist.") -def real(z): +@overload +def real(z: np.poly1d) -> np.poly1d: ... + + +@overload +def real(z: np.ndarray) -> np.ndarray: ... + + +@overload +def real(z: complex) -> float: ... + + +def real(z: Any) -> Any: + """Returns the real part of a poly1d, array, or number.""" try: return np.poly1d(z.coeffs.real) except AttributeError: return z.real -def imag(z): +@overload +def imag(z: np.poly1d) -> np.poly1d: ... + + +@overload +def imag(z: np.ndarray) -> np.ndarray: ... + + +@overload +def imag(z: complex) -> float: ... + + +def imag(z: Any) -> Any: + """Returns the imaginary part of a poly1d, array, or number.""" try: return np.poly1d(z.coeffs.imag) except AttributeError: return z.imag -def poly_real_part(poly): +def poly_real_part(poly: np.poly1d) -> np.poly1d: """Deprecated.""" return np.poly1d(poly.coeffs.real) -def poly_imag_part(poly): +def poly_imag_part(poly: np.poly1d) -> np.poly1d: """Deprecated.""" return np.poly1d(poly.coeffs.imag) diff --git a/svgpathtools/py.typed b/svgpathtools/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/svgpathtools/smoothing.py b/svgpathtools/smoothing.py index 3bd7015..df486b5 100644 --- a/svgpathtools/smoothing.py +++ b/svgpathtools/smoothing.py @@ -2,15 +2,16 @@ curves.""" # External Dependencies -from __future__ import division, absolute_import, print_function +from __future__ import division, absolute_import, print_function, annotations +from typing import Any, NoReturn # Internal Dependencies -from .path import Path, CubicBezier, Line +from .path import Path, CubicBezier, Line, Segment from .misctools import isclose from .paths2svg import disvg -def is_differentiable(path, tol=1e-8): +def is_differentiable(path: Path, tol: float = 1e-8) -> bool: for idx in range(len(path)): u = path[(idx-1) % len(path)].unit_tangent(1) v = path[idx].unit_tangent(0) @@ -20,9 +21,9 @@ def is_differentiable(path, tol=1e-8): return True -def kinks(path, tol=1e-8): +def kinks(path: Path, tol: float = 1e-8) -> list[int]: """returns indices of segments that start on a non-differentiable joint.""" - kink_list = [] + kink_list: list[int] = [] for idx in range(len(path)): if idx == 0 and not path.isclosed(): continue @@ -39,7 +40,7 @@ def kinks(path, tol=1e-8): return kink_list -def _report_unfixable_kinks(_path, _kink_list): +def _report_unfixable_kinks(_path: Path, _kink_list: list[int]) -> NoReturn: mes = ("\n%s kinks have been detected at that cannot be smoothed.\n" "To ignore these kinks and fix all others, run this function " "again with the second argument 'ignore_unfixable_kinks=True' " @@ -49,7 +50,9 @@ def _report_unfixable_kinks(_path, _kink_list): raise Exception(mes) -def smoothed_joint(seg0, seg1, maxjointsize=3, tightness=1.99): +def smoothed_joint(seg0: Segment, seg1: Segment, maxjointsize: float = 3, + tightness: float = 1.99 + ) -> tuple[Segment, list[Segment], Segment]: """ See Andy's notes on Smoothing Bezier Paths for an explanation of the method. Input: two segments seg0, seg1 such that seg0.end==seg1.start, and @@ -59,6 +62,13 @@ def smoothed_joint(seg0, seg1, maxjointsize=3, tightness=1.99): object that smoothly connects seg0_trimmed and seg1_trimmed. """ + # `elbow` holds a single CubicBezier in the branches that wrap it in a + # list below, and an already-assembled list of segments in the last one. + elbow: Any + seg0_trimmed: Segment + seg1_trimmed: Segment + args: tuple[Segment, Segment, float, float] + assert seg0.end == seg1.start assert 0 < maxjointsize assert 0 < tightness < 2 @@ -148,15 +158,17 @@ def smoothed_joint(seg0, seg1, maxjointsize=3, tightness=1.99): return seg0_trimmed, elbow, seg1_trimmed -def smoothed_path(path, maxjointsize=3, tightness=1.99, ignore_unfixable_kinks=False): +def smoothed_path(path: Path, maxjointsize: float = 3, + tightness: float = 1.99, + ignore_unfixable_kinks: bool = False) -> Path: """returns a path with no non-differentiable joints.""" if len(path) == 1: return path assert path.iscontinuous() - sharp_kinks = [] - new_path = [path[0]] + sharp_kinks: list[int] = [] + new_path: list[Segment] = [path[0]] for idx in range(len(path)): if idx == len(path)-1: if not path.isclosed(): diff --git a/svgpathtools/svg_io_sax.py b/svgpathtools/svg_io_sax.py index 4992a09..b0bdaa1 100644 --- a/svgpathtools/svg_io_sax.py +++ b/svgpathtools/svg_io_sax.py @@ -3,7 +3,8 @@ """ # External dependencies -from __future__ import division, absolute_import, print_function +from __future__ import division, absolute_import, print_function, annotations +from typing import Any, Dict, Optional import os from xml.etree.ElementTree import iterparse, Element, ElementTree, SubElement import numpy as np @@ -14,13 +15,11 @@ from .svg_to_paths import (path2pathd, ellipse2pathd, line2pathd, polyline2pathd, polygon2pathd, rect2pathd) from .misctools import open_in_browser -from .path import transform +from .path import transform, Path -# To maintain forward/backward compatibility -try: - string = basestring -except NameError: - string = str +# The attributes accumulated for one element of the light tree, including +# the 'd', 'name' and 'matrix' entries added by `SaxDocument.sax_parse`. +ElementValues = Dict[str, Any] NAME_SVG = "svg" ATTR_VERSION = "version" @@ -44,7 +43,8 @@ class SaxDocument: - def __init__(self, filename, strict_transform_parsing=False): + def __init__(self, filename: Optional[str], + strict_transform_parsing: bool = False) -> None: """ A container for a SAX SVG light tree objects document. @@ -58,8 +58,9 @@ def __init__(self, filename, strict_transform_parsing=False): skipped with an SVGSyntaxWarning. """ self.strict_transform_parsing = strict_transform_parsing - self.root_values = {} - self.tree = [] + self.root_values: ElementValues = {} + self.tree: list[ElementValues] = [] + self.original_filename: Optional[str] # remember location of original svg file if filename is not None and os.path.dirname(filename) == '': self.original_filename = os.path.join(os.getcwd(), filename) @@ -69,12 +70,12 @@ def __init__(self, filename, strict_transform_parsing=False): if filename is not None: self.sax_parse(filename) - def sax_parse(self, filename): + def sax_parse(self, filename: str) -> None: self.root_values = {} self.tree = [] - stack = [] - values = {} - matrix = None + stack: list[tuple[ElementValues, Optional[np.ndarray]]] = [] + values: ElementValues = {} + matrix: Optional[np.ndarray] = None # Open the file ourselves (rather than letting iterparse do it) # so the handle is closed even if parsing raises; otherwise the # file stays locked on Windows until garbage collection. @@ -133,8 +134,8 @@ def sax_parse(self, filename): values = v[0] matrix = v[1] - def flatten_all_paths(self): - flat = [] + def flatten_all_paths(self) -> list[Path]: + flat: list[Path] = [] for values in self.tree: pathd = values['d'] matrix = values['matrix'] @@ -144,15 +145,15 @@ def flatten_all_paths(self): flat.append(parsed_path) return flat - def get_pathd_and_matrix(self): - flat = [] + def get_pathd_and_matrix(self) -> list[tuple[str, Optional[np.ndarray]]]: + flat: list[tuple[str, Optional[np.ndarray]]] = [] for values in self.tree: pathd = values['d'] matrix = values['matrix'] flat.append((pathd, matrix)) return flat - def generate_dom(self): + def generate_dom(self) -> ElementTree: root = Element(NAME_SVG) root.set(ATTR_VERSION, VALUE_SVG_VERSION) root.set(ATTR_XMLNS, VALUE_XMLNS) @@ -177,17 +178,17 @@ def generate_dom(self): if matrix is not None and not np.all(np.equal(matrix, identity)): matrix_string = "matrix(" matrix_string += " " - matrix_string += string(matrix[0][0]) + matrix_string += str(matrix[0][0]) matrix_string += " " - matrix_string += string(matrix[1][0]) + matrix_string += str(matrix[1][0]) matrix_string += " " - matrix_string += string(matrix[0][1]) + matrix_string += str(matrix[0][1]) matrix_string += " " - matrix_string += string(matrix[1][1]) + matrix_string += str(matrix[1][1]) matrix_string += " " - matrix_string += string(matrix[0][2]) + matrix_string += str(matrix[0][2]) matrix_string += " " - matrix_string += string(matrix[1][2]) + matrix_string += str(matrix[1][2]) matrix_string += ")" path.set(ATTR_TRANSFORM, matrix_string) if ATTR_DATA in values: @@ -198,12 +199,12 @@ def generate_dom(self): path.set(ATTR_STROKE, values[ATTR_STROKE]) return ElementTree(root) - def save(self, filename): + def save(self, filename: str) -> None: with open(filename, 'wb') as output_svg: dom_tree = self.generate_dom() dom_tree.write(output_svg) - def display(self, filename=None): + def display(self, filename: Optional[str] = None) -> None: """Displays/opens the doc using the OS's default application.""" if filename is None: filename = 'display_temp.svg' diff --git a/svgpathtools/svg_to_paths.py b/svgpathtools/svg_to_paths.py index aa55869..c72c32e 100644 --- a/svgpathtools/svg_to_paths.py +++ b/svgpathtools/svg_to_paths.py @@ -2,18 +2,36 @@ The main tool being the svg2paths() function.""" # External dependencies -from __future__ import division, absolute_import, print_function +from __future__ import division, absolute_import, print_function, annotations +from typing import (IO, TYPE_CHECKING, Any, Dict, List, Literal, Mapping, + Tuple, Union, overload) from xml.dom.minidom import parse import os from io import StringIO import re -try: - from os import PathLike as FilePathLike -except ImportError: - FilePathLike = str +from os import PathLike as FilePathLike # Internal dependencies from .parser import parse_path +from .path import Path + +if TYPE_CHECKING: + from xml.etree.ElementTree import Element + +# The SVG element to convert: either an ElementTree Element or a plain +# mapping of its attributes (which is what `svg2paths` and `SaxDocument` +# build and pass in). +SVGElement = Union["Element", Mapping[str, str]] + +# An SVG element's attributes, as returned alongside the paths. +Attributes = Dict[str, str] + +# What `svg2paths` returns, with and without the element's attributes. +PathsAndAttributes = Tuple[List[Path], List[Attributes]] +PathsAttributesAndSVG = Tuple[List[Path], List[Attributes], Attributes] + +# An SVG to read: a path to one, or an already-open file-like object. +SVGSource = Union[str, "os.PathLike[str]", IO[str], IO[bytes]] COORD_PAIR_TMPLT = re.compile( @@ -23,28 +41,31 @@ ) -def path2pathd(path): +def path2pathd(path: SVGElement) -> str: return path.get('d', '') -def ellipse2pathd(ellipse, use_cubics=False): +def ellipse2pathd(ellipse: SVGElement, use_cubics: bool = False) -> str: """converts the parameters from an ellipse or a circle to a string for a Path object d-attribute""" - cx = ellipse.get('cx', 0) - cy = ellipse.get('cy', 0) - rx = ellipse.get('rx', None) - ry = ellipse.get('ry', None) + cx_attr = ellipse.get('cx', 0) + cy_attr = ellipse.get('cy', 0) + rx_attr = ellipse.get('rx', None) + ry_attr = ellipse.get('ry', None) r = ellipse.get('r', None) + rx: float + ry: float if r is not None: rx = ry = float(r) else: - rx = float(rx) - ry = float(ry) + # An ellipse without rx/ry is malformed; float(None) raises. + rx = float(rx_attr) # type: ignore[arg-type] + ry = float(ry_attr) # type: ignore[arg-type] - cx = float(cx) - cy = float(cy) + cx = float(cx_attr) + cy = float(cy_attr) if use_cubics: # Modified by NXP 2024, 2025 @@ -76,10 +97,14 @@ def ellipse2pathd(ellipse, use_cubics=False): return d + 'z' -def polyline2pathd(polyline, is_polygon=False): +def polyline2pathd(polyline: SVGElement, is_polygon: bool = False) -> str: """converts the string from a polyline points-attribute to a string for a Path object d-attribute""" + points: list[tuple[str, str]] if isinstance(polyline, str): + # NOTE: this branch cannot work -- the code below treats `points` as + # a list of (x, y) pairs, so a bare points-string raises IndexError. + # Left as-is, but no longer advertised in the signature. points = polyline else: points = COORD_PAIR_TMPLT.findall(polyline.get('points', '')) @@ -102,7 +127,7 @@ def polyline2pathd(polyline, is_polygon=False): return d -def polygon2pathd(polyline, is_polygon=True): +def polygon2pathd(polyline: SVGElement, is_polygon: bool = True) -> str: """converts the string from a polygon points-attribute to a string for a Path object d-attribute. Note: For a polygon made from n points, the resulting path will be @@ -111,7 +136,7 @@ def polygon2pathd(polyline, is_polygon=True): return polyline2pathd(polyline, is_polygon) -def rect2pathd(rect): +def rect2pathd(rect: SVGElement) -> str: """Converts an SVG-rect element to a Path d-string. The rectangle will start at the (x,y) coordinate specified by the @@ -123,13 +148,13 @@ def rect2pathd(rect): # if only one, rx or ry, is present, use that value for both # https://developer.mozilla.org/en-US/docs/Web/SVG/Element/rect - rx = rect.get('rx', None) - ry = rect.get('ry', None) - if rx is None: - rx = ry or 0. - if ry is None: - ry = rx or 0. - rx, ry = float(rx), float(ry) + rx_attr: Union[str, float, None] = rect.get('rx', None) + ry_attr: Union[str, float, None] = rect.get('ry', None) + if rx_attr is None: + rx_attr = ry_attr or 0. + if ry_attr is None: + ry_attr = rx_attr or 0. + rx, ry = float(rx_attr), float(ry_attr) d = "M {} {} ".format(x + rx, y) # right of p0 d += "L {} {} ".format(x + w - rx, y) # go to p1 @@ -153,21 +178,58 @@ def rect2pathd(rect): return d -def line2pathd(l): +def line2pathd(l: SVGElement) -> str: return ( - 'M' + l.attrib.get('x1', '0') + ' ' + l.attrib.get('y1', '0') - + 'L' + l.attrib.get('x2', '0') + ' ' + l.attrib.get('y2', '0') + 'M' + l.get('x1', '0') + ' ' + l.get('y1', '0') + + 'L' + l.get('x2', '0') + ' ' + l.get('y2', '0') ) -def svg2paths(svg_file_location, - return_svg_attributes=False, - convert_circles_to_paths=True, - convert_ellipses_to_paths=True, - convert_lines_to_paths=True, - convert_polylines_to_paths=True, - convert_polygons_to_paths=True, - convert_rectangles_to_paths=True): +@overload +def svg2paths(svg_file_location: SVGSource, + return_svg_attributes: Literal[False] = ..., + convert_circles_to_paths: bool = ..., + convert_ellipses_to_paths: bool = ..., + convert_lines_to_paths: bool = ..., + convert_polylines_to_paths: bool = ..., + convert_polygons_to_paths: bool = ..., + convert_rectangles_to_paths: bool = ... + ) -> PathsAndAttributes: ... + + +@overload +def svg2paths(svg_file_location: SVGSource, + return_svg_attributes: Literal[True], + convert_circles_to_paths: bool = ..., + convert_ellipses_to_paths: bool = ..., + convert_lines_to_paths: bool = ..., + convert_polylines_to_paths: bool = ..., + convert_polygons_to_paths: bool = ..., + convert_rectangles_to_paths: bool = ... + ) -> PathsAttributesAndSVG: ... + + +@overload +def svg2paths(svg_file_location: SVGSource, + return_svg_attributes: bool, + convert_circles_to_paths: bool = ..., + convert_ellipses_to_paths: bool = ..., + convert_lines_to_paths: bool = ..., + convert_polylines_to_paths: bool = ..., + convert_polygons_to_paths: bool = ..., + convert_rectangles_to_paths: bool = ... + ) -> Union[PathsAndAttributes, PathsAttributesAndSVG]: ... + + +def svg2paths(svg_file_location: SVGSource, + return_svg_attributes: bool = False, + convert_circles_to_paths: bool = True, + convert_ellipses_to_paths: bool = True, + convert_lines_to_paths: bool = True, + convert_polylines_to_paths: bool = True, + convert_polygons_to_paths: bool = True, + convert_rectangles_to_paths: bool = True + ) -> Union[PathsAndAttributes, PathsAttributesAndSVG]: """Converts an SVG into a list of Path objects and attribute dictionaries. Converts an SVG file into a list of Path objects and a list of @@ -204,11 +266,12 @@ def svg2paths(svg_file_location, # strings are interpreted as file location everything else is treated as # file-like object and passed to the xml parser directly from_filepath = isinstance(svg_file_location, str) or isinstance(svg_file_location, FilePathLike) - svg_file_location = os.path.abspath(svg_file_location) if from_filepath else svg_file_location + svg_file_location = (os.path.abspath(svg_file_location) # type: ignore[arg-type] + if from_filepath else svg_file_location) - doc = parse(svg_file_location) + doc = parse(svg_file_location) # type: ignore[arg-type] - def dom2dict(element): + def dom2dict(element: Any) -> Attributes: """Converts DOM elements to dictionaries of attributes.""" keys = list(element.attributes.keys()) values = [val.value for val in list(element.attributes.values())] @@ -265,14 +328,51 @@ def dom2dict(element): return path_list, attribute_dictionary_list -def svg2paths2(svg_file_location, - return_svg_attributes=True, - convert_circles_to_paths=True, - convert_ellipses_to_paths=True, - convert_lines_to_paths=True, - convert_polylines_to_paths=True, - convert_polygons_to_paths=True, - convert_rectangles_to_paths=True): +@overload +def svg2paths2(svg_file_location: SVGSource, + return_svg_attributes: Literal[True] = ..., + convert_circles_to_paths: bool = ..., + convert_ellipses_to_paths: bool = ..., + convert_lines_to_paths: bool = ..., + convert_polylines_to_paths: bool = ..., + convert_polygons_to_paths: bool = ..., + convert_rectangles_to_paths: bool = ... + ) -> PathsAttributesAndSVG: ... + + +@overload +def svg2paths2(svg_file_location: SVGSource, + return_svg_attributes: Literal[False], + convert_circles_to_paths: bool = ..., + convert_ellipses_to_paths: bool = ..., + convert_lines_to_paths: bool = ..., + convert_polylines_to_paths: bool = ..., + convert_polygons_to_paths: bool = ..., + convert_rectangles_to_paths: bool = ... + ) -> PathsAndAttributes: ... + + +@overload +def svg2paths2(svg_file_location: SVGSource, + return_svg_attributes: bool, + convert_circles_to_paths: bool = ..., + convert_ellipses_to_paths: bool = ..., + convert_lines_to_paths: bool = ..., + convert_polylines_to_paths: bool = ..., + convert_polygons_to_paths: bool = ..., + convert_rectangles_to_paths: bool = ... + ) -> Union[PathsAndAttributes, PathsAttributesAndSVG]: ... + + +def svg2paths2(svg_file_location: SVGSource, + return_svg_attributes: bool = True, + convert_circles_to_paths: bool = True, + convert_ellipses_to_paths: bool = True, + convert_lines_to_paths: bool = True, + convert_polylines_to_paths: bool = True, + convert_polygons_to_paths: bool = True, + convert_rectangles_to_paths: bool = True + ) -> Union[PathsAndAttributes, PathsAttributesAndSVG]: """Convenience function; identical to svg2paths() except that return_svg_attributes=True by default. See svg2paths() docstring for more info.""" @@ -286,14 +386,51 @@ def svg2paths2(svg_file_location, convert_rectangles_to_paths=convert_rectangles_to_paths) -def svgstr2paths(svg_string, - return_svg_attributes=False, - convert_circles_to_paths=True, - convert_ellipses_to_paths=True, - convert_lines_to_paths=True, - convert_polylines_to_paths=True, - convert_polygons_to_paths=True, - convert_rectangles_to_paths=True): +@overload +def svgstr2paths(svg_string: str, + return_svg_attributes: Literal[False] = ..., + convert_circles_to_paths: bool = ..., + convert_ellipses_to_paths: bool = ..., + convert_lines_to_paths: bool = ..., + convert_polylines_to_paths: bool = ..., + convert_polygons_to_paths: bool = ..., + convert_rectangles_to_paths: bool = ... + ) -> PathsAndAttributes: ... + + +@overload +def svgstr2paths(svg_string: str, + return_svg_attributes: Literal[True], + convert_circles_to_paths: bool = ..., + convert_ellipses_to_paths: bool = ..., + convert_lines_to_paths: bool = ..., + convert_polylines_to_paths: bool = ..., + convert_polygons_to_paths: bool = ..., + convert_rectangles_to_paths: bool = ... + ) -> PathsAttributesAndSVG: ... + + +@overload +def svgstr2paths(svg_string: str, + return_svg_attributes: bool, + convert_circles_to_paths: bool = ..., + convert_ellipses_to_paths: bool = ..., + convert_lines_to_paths: bool = ..., + convert_polylines_to_paths: bool = ..., + convert_polygons_to_paths: bool = ..., + convert_rectangles_to_paths: bool = ... + ) -> Union[PathsAndAttributes, PathsAttributesAndSVG]: ... + + +def svgstr2paths(svg_string: str, + return_svg_attributes: bool = False, + convert_circles_to_paths: bool = True, + convert_ellipses_to_paths: bool = True, + convert_lines_to_paths: bool = True, + convert_polylines_to_paths: bool = True, + convert_polygons_to_paths: bool = True, + convert_rectangles_to_paths: bool = True + ) -> Union[PathsAndAttributes, PathsAttributesAndSVG]: """Convenience function; identical to svg2paths() except that it takes the svg object as string. See svg2paths() docstring for more info.""" diff --git a/test/line.svg b/test/line.svg new file mode 100644 index 0000000..2ee3185 --- /dev/null +++ b/test/line.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/test_document.py b/test/test_document.py index a9cd74a..8b0306f 100644 --- a/test/test_document.py +++ b/test/test_document.py @@ -42,6 +42,20 @@ def test_from_stringio(self): self.assertEqual(len(doc.paths()), 2) + def test_paths_from_empty_group(self): + """An empty group has no paths, and must not fall back to the root.""" + svg = ('' + '' + '' + '' + '') + doc = Document.from_svg_string(svg) + + self.assertEqual(doc.paths_from_group(doc.get_group(['empty'])), []) + # a group that does have paths, and the nested-name form, still work + self.assertEqual(len(doc.paths_from_group(doc.get_group(['filled']))), 1) + self.assertEqual(len(doc.paths_from_group(['filled'])), 1) + def test_from_string(self): """Test reading svg object contained in a string""" with open(join(dirname(__file__), 'polygons.svg'), diff --git a/test/test_sax_groups.py b/test/test_sax_groups.py index 5e17aa3..b6f1263 100644 --- a/test/test_sax_groups.py +++ b/test/test_sax_groups.py @@ -1,6 +1,6 @@ from __future__ import division, absolute_import, print_function import unittest -from svgpathtools import SaxDocument +from svgpathtools import SaxDocument, Path, Line, svg2paths from os.path import join, dirname @@ -27,3 +27,19 @@ def test_parse_display(self): self.assertEqual(matrix, None) if i == 9: self.assertEqual(values['fill'], 'lime') + + def test_parse_line(self): + # Regression test: elements used to crash SaxDocument with + # AttributeError because line2pathd read `.attrib` from the plain + # attribute dict that sax_parse passes to it. + doc = SaxDocument(join(dirname(__file__), 'line.svg')) + self.assertEqual(len(doc.tree), 1) + self.assertEqual(doc.tree[0]['name'], 'line') + self.assertEqual(doc.tree[0]['d'], 'M0 0L10 10') + + expected = Path(Line(0+0j, 10+10j)) + self.assertEqual(doc.flatten_all_paths(), [expected]) + + # SaxDocument and svg2paths should agree on the same . + paths, _ = svg2paths(join(dirname(__file__), 'line.svg')) + self.assertEqual(paths, [expected])