Files
ansible/lib/python3.10/site-packages/svg/path/path.py
2023-02-08 12:13:28 +01:00

691 lines
21 KiB
Python

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)