from math import sqrt, cos, sin, acos, degrees, radians, log, pi from bisect import bisect from abc import ABC, abstractmethod try: from collections.abc import MutableSequence except ImportError: from collections import MutableSequence # This file contains classes for the different types of SVG path segments as # well as a Path object that contains a sequence of path segments. MIN_DEPTH = 5 ERROR = 1e-12 def segment_length(curve, start, end, start_point, end_point, error, min_depth, depth): """Recursively approximates the length by straight lines""" mid = (start + end) / 2 mid_point = curve.point(mid) length = abs(end_point - start_point) first_half = abs(mid_point - start_point) second_half = abs(end_point - mid_point) length2 = first_half + second_half if (length2 - length > error) or (depth < min_depth): # Calculate the length of each segment: depth += 1 return segment_length( curve, start, mid, start_point, mid_point, error, min_depth, depth ) + segment_length( curve, mid, end, mid_point, end_point, error, min_depth, depth ) # This is accurate enough. return length2 class PathSegment(ABC): @abstractmethod def point(self, pos): """Returns the coordinate point (as a complex number) of a point on the path, as expressed as a floating point number between 0 (start) and 1 (end). """ @abstractmethod def tangent(self, pos): """Returns a vector (as a complex number) representing the tangent of a point on the path as expressed as a floating point number between 0 (start) and 1 (end). """ @abstractmethod def length(self, error=ERROR, min_depth=MIN_DEPTH): """Returns the length of a path. The CubicBezier and Arc lengths are non-exact and iterative and you can select to either do the calculations until a maximum error has been achieved, or a minimum number of iterations. """ class NonLinear(PathSegment): """A line that is not straight The base of Arc, QuadraticBezier and CubicBezier """ class Linear(PathSegment): """A straight line The base for Line() and Close(). """ def __init__(self, start, end, relative=False): self.start = start self.end = end self.relative = relative def __ne__(self, other): if not isinstance(other, Line): return NotImplemented return not self == other def point(self, pos): distance = self.end - self.start return self.start + distance * pos def tangent(self, pos): return self.end - self.start def length(self, error=None, min_depth=None): distance = self.end - self.start return sqrt(distance.real**2 + distance.imag**2) class Line(Linear): def __init__(self, start, end, relative=False, vertical=False, horizontal=False): self.start = start self.end = end self.relative = relative self.vertical = vertical self.horizontal = horizontal def __repr__(self): return f"Line(start={self.start}, end={self.end})" def __eq__(self, other): if not isinstance(other, Line): return NotImplemented return self.start == other.start and self.end == other.end def _d(self, previous): x = self.end.real y = self.end.imag if self.relative: x -= previous.end.real y -= previous.end.imag if self.horizontal and self.is_horizontal_from(previous): cmd = "h" if self.relative else "H" return f"{cmd} {x:G},{y:G}" if self.vertical and self.is_vertical_from(previous): cmd = "v" if self.relative else "V" return f"{cmd} {y:G}" cmd = "l" if self.relative else "L" return f"{cmd} {x:G},{y:G}" def is_vertical_from(self, previous): return self.start == previous.end and self.start.real == self.end.real def is_horizontal_from(self, previous): return self.start == previous.end and self.start.imag == self.end.imag class CubicBezier(NonLinear): def __init__(self, start, control1, control2, end, relative=False, smooth=False): self.start = start self.control1 = control1 self.control2 = control2 self.end = end self.relative = relative self.smooth = smooth def __repr__(self): return ( f"CubicBezier(start={self.start}, control1={self.control1}, " f"control2={self.control2}, end={self.end}, smooth={self.smooth})" ) def __eq__(self, other): if not isinstance(other, CubicBezier): return NotImplemented return ( self.start == other.start and self.end == other.end and self.control1 == other.control1 and self.control2 == other.control2 ) def __ne__(self, other): if not isinstance(other, CubicBezier): return NotImplemented return not self == other def _d(self, previous): c1 = self.control1 c2 = self.control2 end = self.end if self.relative and previous: c1 -= previous.end c2 -= previous.end end -= previous.end if self.smooth and self.is_smooth_from(previous): cmd = "s" if self.relative else "S" return f"{cmd} {c2.real:G},{c2.imag:G} {end.real:G},{end.imag:G}" cmd = "c" if self.relative else "C" return f"{cmd} {c1.real:G},{c1.imag:G} {c2.real:G},{c2.imag:G} {end.real:G},{end.imag:G}" def is_smooth_from(self, previous): """Checks if this segment would be a smooth segment following the previous""" if isinstance(previous, CubicBezier): return self.start == previous.end and (self.control1 - self.start) == ( previous.end - previous.control2 ) else: return self.control1 == self.start def set_smooth_from(self, previous): assert isinstance(previous, CubicBezier) self.start = previous.end self.control1 = previous.end - previous.control2 + self.start self.smooth = True def point(self, pos): """Calculate the x,y position at a certain position of the path""" return ( ((1 - pos) ** 3 * self.start) + (3 * (1 - pos) ** 2 * pos * self.control1) + (3 * (1 - pos) * pos**2 * self.control2) + (pos**3 * self.end) ) def tangent(self, pos): return ( -3 * (1 - pos) ** 2 * self.start + 3 * (1 - pos) ** 2 * self.control1 - 6 * pos * (1 - pos) * self.control1 - 3 * pos**2 * self.control2 + 6 * pos * (1 - pos) * self.control2 + 3 * pos**2 * self.end ) def length(self, error=ERROR, min_depth=MIN_DEPTH): """Calculate the length of the path up to a certain position""" start_point = self.point(0) end_point = self.point(1) return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0) class QuadraticBezier(NonLinear): def __init__(self, start, control, end, relative=False, smooth=False): self.start = start self.end = end self.control = control self.relative = relative self.smooth = smooth def __repr__(self): return ( f"QuadraticBezier(start={self.start}, control={self.control}, " f"end={self.end}, smooth={self.smooth})" ) def __eq__(self, other): if not isinstance(other, QuadraticBezier): return NotImplemented return ( self.start == other.start and self.end == other.end and self.control == other.control ) def __ne__(self, other): if not isinstance(other, QuadraticBezier): return NotImplemented return not self == other def _d(self, previous): control = self.control end = self.end if self.relative and previous: control -= previous.end end -= previous.end if self.smooth and self.is_smooth_from(previous): cmd = "t" if self.relative else "T" return f"{cmd} {end.real:G},{end.imag:G}" cmd = "q" if self.relative else "Q" return f"{cmd} {control.real:G},{control.imag:G} {end.real:G},{end.imag:G}" def is_smooth_from(self, previous): """Checks if this segment would be a smooth segment following the previous""" if isinstance(previous, QuadraticBezier): return self.start == previous.end and (self.control - self.start) == ( previous.end - previous.control ) else: return self.control == self.start def set_smooth_from(self, previous): assert isinstance(previous, QuadraticBezier) self.start = previous.end self.control = previous.end - previous.control + self.start self.smooth = True def point(self, pos): return ( (1 - pos) ** 2 * self.start + 2 * (1 - pos) * pos * self.control + pos**2 * self.end ) def tangent(self, pos): return ( self.start * (2 * pos - 2) + (2 * self.end - 4 * self.control) * pos + 2 * self.control ) def length(self, error=None, min_depth=None): a = self.start - 2 * self.control + self.end b = 2 * (self.control - self.start) try: # For an explanation of this case, see # http://www.malczak.info/blog/quadratic-bezier-curve-length/ A = 4 * (a.real**2 + a.imag**2) B = 4 * (a.real * b.real + a.imag * b.imag) C = b.real**2 + b.imag**2 Sabc = 2 * sqrt(A + B + C) A2 = sqrt(A) A32 = 2 * A * A2 C2 = 2 * sqrt(C) BA = B / A2 s = ( A32 * Sabc + A2 * B * (Sabc - C2) + (4 * C * A - B**2) * log((2 * A2 + BA + Sabc) / (BA + C2)) ) / (4 * A32) except (ZeroDivisionError, ValueError): if abs(a) < 1e-10: s = abs(b) else: k = abs(b) / abs(a) if k >= 2: s = abs(b) - abs(a) else: s = abs(a) * (k**2 / 2 - k + 1) return s class Arc(NonLinear): def __init__(self, start, radius, rotation, arc, sweep, end, relative=False): """radius is complex, rotation is in degrees, large and sweep are 1 or 0 (True/False also work)""" self.start = start self.radius = radius self.rotation = rotation self.arc = bool(arc) self.sweep = bool(sweep) self.end = end self.relative = relative self._parameterize() def __repr__(self): return ( f"Arc(start={self.start}, radius={self.radius}, rotation={self.rotation}, " f"arc={self.arc}, sweep={self.sweep}, end={self.end})" ) def __eq__(self, other): if not isinstance(other, Arc): return NotImplemented return ( self.start == other.start and self.end == other.end and self.radius == other.radius and self.rotation == other.rotation and self.arc == other.arc and self.sweep == other.sweep ) def __ne__(self, other): if not isinstance(other, Arc): return NotImplemented return not self == other def _d(self, previous): end = self.end cmd = "a" if self.relative else "A" if self.relative: end -= previous.end return ( f"{cmd} {self.radius.real:G},{self.radius.imag:G} {self.rotation:G} " f"{int(self.arc):d},{int(self.sweep):d} {end.real:G},{end.imag:G}" ) def _parameterize(self): # Conversion from endpoint to center parameterization # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes if self.start == self.end: # This is equivalent of omitting the segment, so do nothing return if self.radius.real == 0 or self.radius.imag == 0: # This should be treated as a straight line return cosr = cos(radians(self.rotation)) sinr = sin(radians(self.rotation)) dx = (self.start.real - self.end.real) / 2 dy = (self.start.imag - self.end.imag) / 2 x1prim = cosr * dx + sinr * dy x1prim_sq = x1prim * x1prim y1prim = -sinr * dx + cosr * dy y1prim_sq = y1prim * y1prim rx = self.radius.real rx_sq = rx * rx ry = self.radius.imag ry_sq = ry * ry # Correct out of range radii radius_scale = (x1prim_sq / rx_sq) + (y1prim_sq / ry_sq) if radius_scale > 1: radius_scale = sqrt(radius_scale) rx *= radius_scale ry *= radius_scale rx_sq = rx * rx ry_sq = ry * ry self.radius_scale = radius_scale else: # SVG spec only scales UP self.radius_scale = 1 t1 = rx_sq * y1prim_sq t2 = ry_sq * x1prim_sq c = sqrt(abs((rx_sq * ry_sq - t1 - t2) / (t1 + t2))) if self.arc == self.sweep: c = -c cxprim = c * rx * y1prim / ry cyprim = -c * ry * x1prim / rx self.center = complex( (cosr * cxprim - sinr * cyprim) + ((self.start.real + self.end.real) / 2), (sinr * cxprim + cosr * cyprim) + ((self.start.imag + self.end.imag) / 2), ) ux = (x1prim - cxprim) / rx uy = (y1prim - cyprim) / ry vx = (-x1prim - cxprim) / rx vy = (-y1prim - cyprim) / ry n = sqrt(ux * ux + uy * uy) p = ux theta = degrees(acos(p / n)) if uy < 0: theta = -theta self.theta = theta % 360 n = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) p = ux * vx + uy * vy d = p / n # In certain cases the above calculation can through inaccuracies # become just slightly out of range, f ex -1.0000000000000002. if d > 1.0: d = 1.0 elif d < -1.0: d = -1.0 delta = degrees(acos(d)) if (ux * vy - uy * vx) < 0: delta = -delta self.delta = delta % 360 if not self.sweep: self.delta -= 360 def point(self, pos): if self.start == self.end: # This is equivalent of omitting the segment return self.start if self.radius.real == 0 or self.radius.imag == 0: # This should be treated as a straight line distance = self.end - self.start return self.start + distance * pos angle = radians(self.theta + (self.delta * pos)) cosr = cos(radians(self.rotation)) sinr = sin(radians(self.rotation)) radius = self.radius * self.radius_scale x = ( cosr * cos(angle) * radius.real - sinr * sin(angle) * radius.imag + self.center.real ) y = ( sinr * cos(angle) * radius.real + cosr * sin(angle) * radius.imag + self.center.imag ) return complex(x, y) def tangent(self, pos): angle = radians(self.theta + (self.delta * pos)) cosr = cos(radians(self.rotation)) sinr = sin(radians(self.rotation)) radius = self.radius * self.radius_scale x = cosr * cos(angle) * radius.real - sinr * sin(angle) * radius.imag y = sinr * cos(angle) * radius.real + cosr * sin(angle) * radius.imag return complex(x, y) * complex(0, 1) def length(self, error=ERROR, min_depth=MIN_DEPTH): """The length of an elliptical arc segment requires numerical integration, and in that case it's simpler to just do a geometric approximation, as for cubic bezier curves. """ if self.start == self.end: # This is equivalent of omitting the segment return 0 if self.radius.real == 0 or self.radius.imag == 0: # This should be treated as a straight line distance = self.end - self.start return sqrt(distance.real**2 + distance.imag**2) if self.radius.real == self.radius.imag: # It's a circle, which simplifies this a LOT. radius = self.radius.real * self.radius_scale return abs(radius * self.delta * pi / 180) start_point = self.point(0) end_point = self.point(1) return segment_length(self, 0, 1, start_point, end_point, error, min_depth, 0) class Move: """Represents move commands. Does nothing, but is there to handle paths that consist of only move commands, which is valid, but pointless. """ def __init__(self, to, relative=False): self.start = self.end = to self.relative = relative def __repr__(self): return "Move(to=%s)" % self.start def __eq__(self, other): if not isinstance(other, Move): return NotImplemented return self.start == other.start def __ne__(self, other): if not isinstance(other, Move): return NotImplemented return not self == other def _d(self, previous): cmd = "M" x = self.end.real y = self.end.imag if self.relative: cmd = "m" if previous: x -= previous.end.real y -= previous.end.imag return f"{cmd} {x:G},{y:G}" def point(self, pos): return self.start def tangent(self, pos): return 0 def length(self, error=ERROR, min_depth=MIN_DEPTH): return 0 class Close(Linear): """Represents the closepath command""" def __eq__(self, other): if not isinstance(other, Close): return NotImplemented return self.start == other.start and self.end == other.end def __repr__(self): return f"Close(start={self.start}, end={self.end})" def _d(self, previous): return "z" if self.relative else "Z" class Path(MutableSequence): """A Path is a sequence of path segments""" def __init__(self, *segments): self._segments = list(segments) self._length = None self._lengths = None # Fractional distance from starting point through the end of each segment. self._fractions = [] def __getitem__(self, index): return self._segments[index] def __setitem__(self, index, value): self._segments[index] = value self._length = None def __delitem__(self, index): del self._segments[index] self._length = None def insert(self, index, value): self._segments.insert(index, value) self._length = None def reverse(self): # Reversing the order of a path would require reversing each element # as well. That's not implemented. raise NotImplementedError def __len__(self): return len(self._segments) def __repr__(self): return "Path(%s)" % (", ".join(repr(x) for x in self._segments)) def __eq__(self, other): if not isinstance(other, Path): return NotImplemented if len(self) != len(other): return False for s, o in zip(self._segments, other._segments): if not s == o: return False return True def __ne__(self, other): if not isinstance(other, Path): return NotImplemented return not self == other def _calc_lengths(self, error=ERROR, min_depth=MIN_DEPTH): if self._length is not None: return lengths = [ each.length(error=error, min_depth=min_depth) for each in self._segments ] self._length = sum(lengths) if self._length == 0: self._lengths = lengths else: self._lengths = [each / self._length for each in lengths] # Calculate the fractional distance for each segment to use in point() fraction = 0 for each in self._lengths: fraction += each self._fractions.append(fraction) def _find_segment(self, pos, error=ERROR): # Shortcuts if pos == 0.0: return self._segments[0], pos if pos == 1.0: return self._segments[-1], pos self._calc_lengths(error=error) # Fix for paths of length 0 (i.e. points) if self._length == 0: return self._segments[0], 0.0 # Find which segment the point we search for is located on: i = bisect(self._fractions, pos) if i == 0: segment_pos = pos / self._fractions[0] else: segment_pos = (pos - self._fractions[i - 1]) / ( self._fractions[i] - self._fractions[i - 1] ) return self._segments[i], segment_pos def point(self, pos, error=ERROR): segment, pos = self._find_segment(pos, error) return segment.point(pos) def tangent(self, pos, error=ERROR): segment, pos = self._find_segment(pos, error) return segment.tangent(pos) def length(self, error=ERROR, min_depth=MIN_DEPTH): self._calc_lengths(error, min_depth) return self._length def d(self): parts = [] previous_segment = None for segment in self: parts.append(segment._d(previous_segment)) previous_segment = segment return " ".join(parts)