Init: mediaserver

This commit is contained in:
2023-02-08 12:13:28 +01:00
parent 848bc9739c
commit f7c23d4ba9
31914 changed files with 6175775 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
from .path import Path, Move, Line, Arc, Close # noqa: 401
from .path import CubicBezier, QuadraticBezier # noqa: 401
from .path import PathSegment, Linear, NonLinear # noqa: 401
from .parser import parse_path # noqa: 401

View File

@@ -0,0 +1,299 @@
# SVG Path specification parser
import re
from svg.path import path
COMMANDS = set("MmZzLlHhVvCcSsQqTtAa")
UPPERCASE = set("MZLHVCSQTA")
COMMAND_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])")
FLOAT_RE = re.compile(rb"^[-+]?\d*\.?\d*(?:[eE][-+]?\d+)?")
class InvalidPathError(ValueError):
pass
# The argument sequences from the grammar, made sane.
# u: Non-negative number
# s: Signed number or coordinate
# c: coordinate-pair, which is two coordinates/numbers, separated by whitespace
# f: A one character flag, doesn't need whitespace, 1 or 0
ARGUMENT_SEQUENCE = {
"M": "c",
"Z": "",
"L": "c",
"H": "s",
"V": "s",
"C": "ccc",
"S": "cc",
"Q": "cc",
"T": "c",
"A": "uusffc",
}
def strip_array(arg_array):
"""Strips whitespace and commas"""
# EBNF wsp:(#x20 | #x9 | #xD | #xA) + comma: 0x2C
while arg_array and arg_array[0] in (0x20, 0x9, 0xD, 0xA, 0x2C):
arg_array[0:1] = b""
def pop_number(arg_array):
res = FLOAT_RE.search(arg_array)
if not res or not res.group():
raise InvalidPathError(f"Expected a number, got '{arg_array}'.")
number = float(res.group())
start = res.start()
end = res.end()
arg_array[start:end] = b""
strip_array(arg_array)
return number
def pop_unsigned_number(arg_array):
number = pop_number(arg_array)
if number < 0:
raise InvalidPathError(f"Expected a non-negative number, got '{number}'.")
return number
def pop_coordinate_pair(arg_array):
x = pop_number(arg_array)
y = pop_number(arg_array)
return complex(x, y)
def pop_flag(arg_array):
flag = arg_array[0]
arg_array[0:1] = b""
strip_array(arg_array)
if flag == 48: # ASCII 0
return False
if flag == 49: # ASCII 1
return True
FIELD_POPPERS = {
"u": pop_unsigned_number,
"s": pop_number,
"c": pop_coordinate_pair,
"f": pop_flag,
}
def _commandify_path(pathdef):
"""Splits path into commands and arguments"""
token = None
for x in COMMAND_RE.split(pathdef):
x = x.strip()
if x in COMMANDS:
if token is not None:
yield token
if x in ("z", "Z"):
# The end command takes no arguments, so add a blank one
token = (x, "")
else:
token = (x,)
elif x:
if token is None:
raise InvalidPathError(f"Path does not start with a command: {pathdef}")
token += (x,)
yield token
def _tokenize_path(pathdef):
for command, args in _commandify_path(pathdef):
# Shortcut this for the close command, that doesn't have arguments:
if command in ("z", "Z"):
yield (command,)
continue
# For the rest of the commands, we parse the arguments and
# yield one command per full set of arguments
arg_sequence = ARGUMENT_SEQUENCE[command.upper()]
arguments = bytearray(args, "ascii")
implicit = False
while arguments:
command_arguments = []
for i, arg in enumerate(arg_sequence):
try:
command_arguments.append(FIELD_POPPERS[arg](arguments))
except InvalidPathError as e:
if i == 0 and implicit:
return # Invalid character in path, treat like a comment
raise InvalidPathError(
f"Invalid path element {command} {args}"
) from e
yield (command,) + tuple(command_arguments)
implicit = True
# Implicit Moveto commands should be treated as Lineto commands.
if command == "m":
command = "l"
elif command == "M":
command = "L"
def parse_path(pathdef):
segments = path.Path()
start_pos = None
last_command = None
current_pos = 0
for token in _tokenize_path(pathdef):
command = token[0]
relative = command.islower()
command = command.upper()
if command == "M":
pos = token[1]
if relative:
current_pos += pos
else:
current_pos = pos
segments.append(path.Move(current_pos, relative=relative))
start_pos = current_pos
elif command == "Z":
# For Close commands the "relative" argument just preserves case,
# it has no different in behavior.
segments.append(path.Close(current_pos, start_pos, relative=relative))
current_pos = start_pos
elif command == "L":
pos = token[1]
if relative:
pos += current_pos
segments.append(path.Line(current_pos, pos, relative=relative))
current_pos = pos
elif command == "H":
hpos = token[1]
if relative:
hpos += current_pos.real
pos = complex(hpos, current_pos.imag)
segments.append(
path.Line(current_pos, pos, relative=relative, horizontal=True)
)
current_pos = pos
elif command == "V":
vpos = token[1]
if relative:
vpos += current_pos.imag
pos = complex(current_pos.real, vpos)
segments.append(
path.Line(current_pos, pos, relative=relative, vertical=True)
)
current_pos = pos
elif command == "C":
control1 = token[1]
control2 = token[2]
end = token[3]
if relative:
control1 += current_pos
control2 += current_pos
end += current_pos
segments.append(
path.CubicBezier(
current_pos, control1, control2, end, relative=relative
)
)
current_pos = end
elif command == "S":
# Smooth curve. First control point is the "reflection" of
# the second control point in the previous path.
control2 = token[1]
end = token[2]
if relative:
control2 += current_pos
end += current_pos
if last_command in "CS":
# The first control point is assumed to be the reflection of
# the second control point on the previous command relative
# to the current point.
control1 = current_pos + current_pos - segments[-1].control2
else:
# If there is no previous command or if the previous command
# was not an C, c, S or s, assume the first control point is
# coincident with the current point.
control1 = current_pos
segments.append(
path.CubicBezier(
current_pos, control1, control2, end, relative=relative, smooth=True
)
)
current_pos = end
elif command == "Q":
control = token[1]
end = token[2]
if relative:
control += current_pos
end += current_pos
segments.append(
path.QuadraticBezier(current_pos, control, end, relative=relative)
)
current_pos = end
elif command == "T":
# Smooth curve. Control point is the "reflection" of
# the second control point in the previous path.
end = token[1]
if relative:
end += current_pos
if last_command in "QT":
# The control point is assumed to be the reflection of
# the control point on the previous command relative
# to the current point.
control = current_pos + current_pos - segments[-1].control
else:
# If there is no previous command or if the previous command
# was not an Q, q, T or t, assume the first control point is
# coincident with the current point.
control = current_pos
segments.append(
path.QuadraticBezier(
current_pos, control, end, smooth=True, relative=relative
)
)
current_pos = end
elif command == "A":
# For some reason I implemented the Arc with a complex radius.
# That doesn't really make much sense, but... *shrugs*
radius = complex(token[1], token[2])
rotation = token[3]
arc = token[4]
sweep = token[5]
end = token[6]
if relative:
end += current_pos
segments.append(
path.Arc(
current_pos, radius, rotation, arc, sweep, end, relative=relative
)
)
current_pos = end
# Finish up the loop in preparation for next command
last_command = command
return segments

View File

@@ -0,0 +1,690 @@
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)

View File

@@ -0,0 +1,5 @@
import doctest
def test_readme():
doctest.testfile("../../../../README.rst")

View File

@@ -0,0 +1,27 @@
import unittest
from ..parser import parse_path
class TestGeneration(unittest.TestCase):
def test_svg_examples(self):
"""Examples from the SVG spec"""
paths = [
# "M 100,100 L 300,100 L 200,300 Z",
# "M 0,0 L 50,20 M 100,100 L 300,100 L 200,300 Z",
# "M 100,100 L 200,200",
# "M 100,200 L 200,100 L -100,-200",
# "M 100,200 C 100,100 250,100 250,200 S 400,300 400,200",
# "M 100,200 C 100,100 400,100 400,200",
# "M 100,500 C 25,400 475,400 400,500",
# "M 100,800 C 175,700 325,700 400,800",
# "M 600,200 C 675,100 975,100 900,200",
# "M 600,500 C 600,350 900,650 900,500",
# "M 600,800 C 625,700 725,700 750,800 S 875,900 900,800",
# "M 200,300 Q 400,50 600,300 T 1000,300",
# "M -3.4E+38,3.4E+38 L -3.4E-38,3.4E-38",
# "M 0,0 L 50,20 M 50,20 L 200,100 Z",
"M 600,350 L 650,325 A 25,25 -30 0,1 700,300 L 750,275"
]
for path in paths:
self.assertEqual(parse_path(path).d(), path)

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,132 @@
import unittest
import os
from PIL import Image, ImageDraw, ImageColor, ImageChops
from math import sqrt
from ..path import CubicBezier, QuadraticBezier, Line, Arc
RED = ImageColor.getcolor("red", mode="RGB")
GREEN = ImageColor.getcolor("limegreen", mode="RGB")
BLUE = ImageColor.getcolor("cornflowerblue", mode="RGB")
YELLOW = ImageColor.getcolor("yellow", mode="RGB")
CYAN = ImageColor.getcolor("cyan", mode="RGB")
WHITE = ImageColor.getcolor("white", mode="RGB")
BLACK = ImageColor.getcolor("black", mode="RGB")
DOT = 4 + 4j # x+y radius of dot
def c2t(c):
"""Make a complex number into a tuple"""
return c.real, c.imag
def magnitude(c):
return sqrt(c.real**2 + c.imag**2)
class ImageTest(unittest.TestCase):
"""Creates a PNG image and compares with a correct PNG"""
def setUp(self):
self.image = Image.new(mode="RGB", size=(500, 1200))
self.draw = ImageDraw.Draw(self.image)
def draw_path(self, path):
lines = [c2t(path.point(x * 0.01)) for x in range(1, 101)]
self.draw.line(lines, fill=WHITE, width=2)
p = path.point(0)
self.draw.ellipse([c2t(p - DOT), c2t(p + DOT)], fill=BLUE)
p = path.point(1)
self.draw.ellipse([c2t(p - DOT), c2t(p + DOT)], fill=GREEN)
def draw_tangents(self, path, count):
count += 1
for i in range(1, count):
p = path.point(i / count)
t = path.tangent(i / count)
self.draw.line([c2t(p), c2t(p + t)], fill=RED, width=1)
# And a nice 90 angle
tt = complex(t.imag, -t.real)
# scale it to always be 20px
tt *= 20 / magnitude(tt)
self.draw.line([c2t(p), c2t(tt + p)], fill=YELLOW, width=1)
def test_image(self):
self.draw.text((10, 10), "This is an SVG line:")
self.draw.text(
(10, 100),
"The red line is a tangent, and the yellow is 90 degrees from that.",
)
line1 = Line(40 + 60j, 200 + 80j)
self.draw_path(line1)
self.draw_tangents(line1, 1)
self.draw.text((10, 140), "This is an Arc segment, almost a whole circle:")
arc1 = Arc(260 + 320j, 100 + 100j, 0, 1, 1, 260 + 319j)
self.draw_path(arc1)
self.draw_tangents(arc1, 5)
self.draw.text((10, 460), "With five tangents.")
self.draw.text(
(10, 500),
"Next we have a quadratic bezier curve, with one tangent:",
)
start = 30 + 600j
control = 400 + 540j
end = 260 + 650j
qbez1 = QuadraticBezier(start, control, end)
self.draw_path(qbez1)
self.draw.ellipse([c2t(control - DOT), c2t(control + DOT)], fill=WHITE)
self.draw.line([c2t(start), c2t(control), c2t(end)], fill=CYAN)
self.draw_tangents(qbez1, 1)
self.draw.text(
(10, 670),
"The white dot is the control point, and the cyan lines are ",
)
self.draw.text((10, 690), "illustrating the how the control point works.")
self.draw.text(
(10, 730),
"Lastly is a cubic bezier, with 2 tangents, and 2 control points:",
)
start = 30 + 800j
control1 = 400 + 780j
control2 = 50 + 900j
end = 300 + 980j
cbez1 = CubicBezier(start, control1, control2, end)
self.draw_path(cbez1)
self.draw.ellipse([c2t(control1 - DOT), c2t(control1 + DOT)], fill=WHITE)
self.draw.ellipse([c2t(control2 - DOT), c2t(control2 + DOT)], fill=WHITE)
self.draw.line(
[
c2t(start),
c2t(control1),
],
fill=CYAN,
)
self.draw.line([c2t(control2), c2t(end)], fill=CYAN)
self.draw_tangents(cbez1, 2)
# self.image.show() # Useful when debugging
filename = os.path.join(os.path.split(__file__)[0], "test_image.png")
# If you have made intentional changes to the test_image.png, save it
# by uncommenting these lines. Don't forget to comment them out again,
# or the test will always pass
# with open(filename, "wb") as fp:
# self.image.save(fp, format="PNG")
with open(filename, "rb") as fp:
test_image = Image.open(fp, mode="r")
diff = ImageChops.difference(test_image, self.image)
self.assertFalse(
diff.getbbox(), "The resulting image is different from test_image.png"
)

View File

@@ -0,0 +1,620 @@
import unittest
from ..path import CubicBezier, QuadraticBezier, Line, Arc, Path, Move, Close
from ..parser import parse_path
class TestParser(unittest.TestCase):
maxDiff = None
def test_svg_examples(self):
"""Examples from the SVG spec"""
path1 = parse_path("M 100 100 L 300 100 L 200 300 z")
self.assertEqual(
path1,
Path(
Move(100 + 100j),
Line(100 + 100j, 300 + 100j),
Line(300 + 100j, 200 + 300j),
Close(200 + 300j, 100 + 100j),
),
)
# for Z command behavior when there is multiple subpaths
path1 = parse_path("M 0 0 L 50 20 M 100 100 L 300 100 L 200 300 z")
self.assertEqual(
path1,
Path(
Move(0j),
Line(0 + 0j, 50 + 20j),
Move(100 + 100j),
Line(100 + 100j, 300 + 100j),
Line(300 + 100j, 200 + 300j),
Close(200 + 300j, 100 + 100j),
),
)
path1 = parse_path("M 100 100 L 200 200")
path2 = parse_path("M100 100L200 200")
self.assertEqual(path1, path2)
path1 = parse_path("M 100 200 L 200 100 L -100 -200")
path2 = parse_path("M 100 200 L 200 100 -100 -200")
self.assertEqual(path1, path2)
path1 = parse_path(
"""M100,200 C100,100 250,100 250,200
S400,300 400,200"""
)
self.assertEqual(
path1,
Path(
Move(100 + 200j),
CubicBezier(100 + 200j, 100 + 100j, 250 + 100j, 250 + 200j),
CubicBezier(250 + 200j, 250 + 300j, 400 + 300j, 400 + 200j),
),
)
path1 = parse_path("M100,200 C100,100 400,100 400,200")
self.assertEqual(
path1,
Path(
Move(100 + 200j),
CubicBezier(100 + 200j, 100 + 100j, 400 + 100j, 400 + 200j),
),
)
path1 = parse_path("M100,500 C25,400 475,400 400,500")
self.assertEqual(
path1,
Path(
Move(100 + 500j),
CubicBezier(100 + 500j, 25 + 400j, 475 + 400j, 400 + 500j),
),
)
path1 = parse_path("M100,800 C175,700 325,700 400,800")
self.assertEqual(
path1,
Path(
Move(100 + 800j),
CubicBezier(100 + 800j, 175 + 700j, 325 + 700j, 400 + 800j),
),
)
path1 = parse_path("M600,200 C675,100 975,100 900,200")
self.assertEqual(
path1,
Path(
Move(600 + 200j),
CubicBezier(600 + 200j, 675 + 100j, 975 + 100j, 900 + 200j),
),
)
path1 = parse_path("M600,500 C600,350 900,650 900,500")
self.assertEqual(
path1,
Path(
Move(600 + 500j),
CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j),
),
)
path1 = parse_path(
"""M600,800 C625,700 725,700 750,800
S875,900 900,800"""
)
self.assertEqual(
path1,
Path(
Move(600 + 800j),
CubicBezier(600 + 800j, 625 + 700j, 725 + 700j, 750 + 800j),
CubicBezier(750 + 800j, 775 + 900j, 875 + 900j, 900 + 800j),
),
)
path1 = parse_path("M200,300 Q400,50 600,300 T1000,300")
self.assertEqual(
path1,
Path(
Move(200 + 300j),
QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j),
QuadraticBezier(600 + 300j, 800 + 550j, 1000 + 300j),
),
)
path1 = parse_path("M300,200 h-150 a150,150 0 1,0 150,-150 z")
self.assertEqual(
path1,
Path(
Move(300 + 200j),
Line(300 + 200j, 150 + 200j),
Arc(150 + 200j, 150 + 150j, 0, 1, 0, 300 + 50j),
Close(300 + 50j, 300 + 200j),
),
)
path1 = parse_path("M275,175 v-150 a150,150 0 0,0 -150,150 z")
self.assertEqual(
path1,
Path(
Move(275 + 175j),
Line(275 + 175j, 275 + 25j),
Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j),
Close(125 + 175j, 275 + 175j),
),
)
path1 = parse_path("M275,175 v-150 a150,150 0 0,0 -150,150 L 275,175 z")
self.assertEqual(
path1,
Path(
Move(275 + 175j),
Line(275 + 175j, 275 + 25j),
Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j),
Line(125 + 175j, 275 + 175j),
Close(275 + 175j, 275 + 175j),
),
)
path1 = parse_path(
"""M600,350 l 50,-25
a25,25 -30 0,1 50,-25 l 50,-25
a25,50 -30 0,1 50,-25 l 50,-25
a25,75 -30 0,1 50,-25 l 50,-25
a25,100 -30 0,1 50,-25 l 50,-25"""
)
self.assertEqual(
path1,
Path(
Move(600 + 350j),
Line(600 + 350j, 650 + 325j),
Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j),
Line(700 + 300j, 750 + 275j),
Arc(750 + 275j, 25 + 50j, -30, 0, 1, 800 + 250j),
Line(800 + 250j, 850 + 225j),
Arc(850 + 225j, 25 + 75j, -30, 0, 1, 900 + 200j),
Line(900 + 200j, 950 + 175j),
Arc(950 + 175j, 25 + 100j, -30, 0, 1, 1000 + 150j),
Line(1000 + 150j, 1050 + 125j),
),
)
def test_wc3_examples12(self):
"""
W3C_SVG_11_TestSuite Paths
Test using multiple coord sets to build a polybeizer, and implicit values for initial S.
"""
path12 = parse_path(
"M 100 100 C 100 20 200 20 200 100 S 300 180 300 100"
)
self.assertEqual(
path12,
Path(
Move(to=(100 + 100j)),
CubicBezier(
start=(100 + 100j),
control1=(100 + 20j),
control2=(200 + 20j),
end=(200 + 100j),
),
CubicBezier(
start=(200 + 100j),
control1=(200 + 180j),
control2=(300 + 180j),
end=(300 + 100j),
),
),
)
path12 = parse_path("M 100 250 S 200 200 200 250 300 300 300 250")
self.assertEqual(
path12,
Path(
Move(to=(100 + 250j)),
CubicBezier(
start=(100 + 250j),
control1=(100 + 250j),
control2=(200 + 200j),
end=(200 + 250j),
),
CubicBezier(
start=(200 + 250j),
control1=(200 + 300j),
control2=(300 + 300j),
end=(300 + 250j),
),
),
)
def test_wc3_examples13(self):
"""
W3C_SVG_11_TestSuite Paths
Test multiple coordinates for V and H.
"""
#
path13 = parse_path(
" M 240.00000 56.00000 H 270.00000 300.00000 320.00000 400.00000 "
)
self.assertEqual(
path13,
Path(
Move(to=(240 + 56j)),
Line(start=(240 + 56j), end=(270 + 56j)),
Line(start=(270 + 56j), end=(300 + 56j)),
Line(start=(300 + 56j), end=(320 + 56j)),
Line(start=(320 + 56j), end=(400 + 56j)),
),
)
path13 = parse_path(
" M 240.00000 156.00000 V 180.00000 200.00000 260.00000 300.00000 "
)
self.assertEqual(
path13,
Path(
Move(to=(240 + 156j)),
Line(start=(240 + 156j), end=(240 + 180j)),
Line(start=(240 + 180j), end=(240 + 200j)),
Line(start=(240 + 200j), end=(240 + 260j)),
Line(start=(240 + 260j), end=(240 + 300j)),
),
)
def test_wc3_examples14(self):
"""
W3C_SVG_11_TestSuite Paths
Test implicit values for moveto. If the first command is 'm' it should be taken as an absolute moveto,
plus implicit lineto.
"""
path14 = parse_path(
" m 62.00000 56.00000 51.96152 90.00000 -103.92304 0.00000 51.96152 "
"-90.00000 z m 0.00000 15.00000 38.97114 67.50000 -77.91228 0.00000 "
"38.97114 -67.50000 z "
)
self.assertEqual(
path14,
Path(
Move(to=(62 + 56j)),
Line(start=(62 + 56j), end=(113.96152000000001 + 146j)),
Line(
start=(113.96152000000001 + 146j), end=(10.038480000000007 + 146j)
),
Line(start=(10.038480000000007 + 146j), end=(62.00000000000001 + 56j)),
Close(start=(62.00000000000001 + 56j), end=(62 + 56j)),
Move(to=(62 + 71j)),
Line(start=(62 + 71j), end=(100.97113999999999 + 138.5j)),
Line(
start=(100.97113999999999 + 138.5j),
end=(23.058859999999996 + 138.5j),
),
Line(
start=(23.058859999999996 + 138.5j), end=(62.029999999999994 + 71j)
),
Close(start=(62.029999999999994 + 71j), end=(62 + 71j)),
),
)
path14 = parse_path(
"M 177.00000 56.00000 228.96152 146.00000 125.03848 146.00000 177.00000 "
"56.00000 Z M 177.00000 71.00000 215.97114 138.50000 138.02886 138.50000 "
"177.00000 71.00000 Z "
)
self.assertEqual(
path14,
Path(
Move(to=(177 + 56j)),
Line(start=(177 + 56j), end=(228.96152 + 146j)),
Line(start=(228.96152 + 146j), end=(125.03848 + 146j)),
Line(start=(125.03848 + 146j), end=(177 + 56j)),
Close(start=(177 + 56j), end=(177 + 56j)),
Move(to=(177 + 71j)),
Line(start=(177 + 71j), end=(215.97114 + 138.5j)),
Line(start=(215.97114 + 138.5j), end=(138.02886 + 138.5j)),
Line(start=(138.02886 + 138.5j), end=(177 + 71j)),
Close(start=(177 + 71j), end=(177 + 71j)),
),
)
def test_wc3_examples15(self):
"""
W3C_SVG_11_TestSuite Paths
'M' or 'm' command with more than one pair of coordinates are absolute
if the moveto was specified with 'M' and relative if the moveto was
specified with 'm'.
"""
path15 = parse_path("M100,120 L160,220 L40,220 z")
self.assertEqual(
path15,
Path(
Move(to=(100 + 120j)),
Line(start=(100 + 120j), end=(160 + 220j)),
Line(start=(160 + 220j), end=(40 + 220j)),
Close(start=(40 + 220j), end=(100 + 120j)),
),
)
path15 = parse_path("M350,120 L410,220 L290,220 z")
self.assertEqual(
path15,
Path(
Move(to=(350 + 120j)),
Line(start=(350 + 120j), end=(410 + 220j)),
Line(start=(410 + 220j), end=(290 + 220j)),
Close(start=(290 + 220j), end=(350 + 120j)),
),
)
path15 = parse_path("M100,120 160,220 40,220 z")
self.assertEqual(
path15,
Path(
Move(to=(100 + 120j)),
Line(start=(100 + 120j), end=(160 + 220j)),
Line(start=(160 + 220j), end=(40 + 220j)),
Close(start=(40 + 220j), end=(100 + 120j)),
),
)
path15 = parse_path("m350,120 60,100 -120,0 z")
self.assertEqual(
path15,
Path(
Move(to=(350 + 120j)),
Line(start=(350 + 120j), end=(410 + 220j)),
Line(start=(410 + 220j), end=(290 + 220j)),
Close(start=(290 + 220j), end=(350 + 120j)),
),
)
def test_wc3_examples17(self):
"""
W3C_SVG_11_TestSuite Paths
Test that the 'z' and 'Z' command have the same effect.
"""
path17a = parse_path("M 50 50 L 50 150 L 150 150 L 150 50 z")
path17b = parse_path("M 50 50 L 50 150 L 150 150 L 150 50 Z")
self.assertEqual(path17a, path17b)
path17a = parse_path("M 250 50 L 250 150 L 350 150 L 350 50 Z")
path17b = parse_path("M 250 50 L 250 150 L 350 150 L 350 50 z")
self.assertEqual(path17a, path17b)
def test_wc3_examples18(self):
"""
W3C_SVG_11_TestSuite Paths
The 'path' element's 'd' attribute ignores additional whitespace, newline characters, and commas,
and BNF processing consumes as much content as possible, stopping as soon as a character that doesn't
satisfy the production is encountered.
"""
path18a = parse_path("M 20 40 H 40")
path18b = parse_path(
"""M 20 40
H 40"""
)
self.assertEqual(path18a, path18b)
path18a = parse_path("M 20 60 H 40")
path18b = parse_path(
"""
M
20
60
H
40
"""
)
self.assertEqual(path18a, path18b)
path18a = parse_path("M 20 80 H40")
path18b = parse_path("M 20,80 H 40")
self.assertEqual(path18a, path18b)
path18a = parse_path("M 20 100 H 40#90")
path18b = parse_path("M 20 100 H 40")
self.assertEqual(path18a, path18b)
path18a = parse_path("M 20 120 H 40.5 0.6")
path18b = parse_path("M 20 120 H 40.5.6")
self.assertEqual(path18a, path18b)
path18a = parse_path("M 20 140 h 10 -20")
path18b = parse_path("M 20 140 h 10-20")
self.assertEqual(path18a, path18b)
path18a = parse_path("M 20 160 H 40")
path18b = parse_path("M 20 160 H 40#90")
self.assertEqual(path18a, path18b)
def test_wc3_examples19(self):
"""
W3C_SVG_11_TestSuite Paths
Test that additional parameters to pathdata commands are treated as additional calls to the most recent command.
"""
path19a = parse_path("M20 20 H40 H60")
path19b = parse_path("M20 20 H40 60")
self.assertEqual(path19a, path19b)
path19a = parse_path("M20 40 h20 h20")
path19b = parse_path("M20 40 h20 20")
self.assertEqual(path19a, path19b)
path19a = parse_path("M120 20 V40 V60")
path19b = parse_path("M120 20 V40 60")
self.assertEqual(path19a, path19b)
path19a = parse_path("M140 20 v20 v20")
path19b = parse_path("M140 20 v20 20")
self.assertEqual(path19a, path19b)
path19a = parse_path("M220 20 L 240 20 L260 20")
path19b = parse_path("M220 20 L 240 20 260 20 ")
self.assertEqual(path19a, path19b)
path19a = parse_path("M220 40 l 20 0 l 20 0")
path19b = parse_path("M220 40 l 20 0 20 0")
self.assertEqual(path19a, path19b)
path19a = parse_path("M50 150 C50 50 200 50 200 150 C200 50 350 50 350 150")
path19b = parse_path("M50 150 C50 50 200 50 200 150 200 50 350 50 350 150")
self.assertEqual(path19a, path19b)
path19a = parse_path("M50, 200 c0,-100 150,-100 150,0 c0,-100 150,-100 150,0")
path19b = parse_path("M50, 200 c0,-100 150,-100 150,0 0,-100 150,-100 150,0")
self.assertEqual(path19a, path19b)
path19a = parse_path("M50 250 S125 200 200 250 S275, 200 350 250")
path19b = parse_path("M50 250 S125 200 200 250 275, 200 350 250")
self.assertEqual(path19a, path19b)
path19a = parse_path("M50 275 s75 -50 150 0 s75, -50 150 0")
path19b = parse_path("M50 275 s75 -50 150 0 75, -50 150 0")
self.assertEqual(path19a, path19b)
path19a = parse_path("M50 300 Q 125 275 200 300 Q 275 325 350 300")
path19b = parse_path("M50 300 Q 125 275 200 300 275 325 350 300")
self.assertEqual(path19a, path19b)
path19a = parse_path("M50 325 q 75 -25 150 0 q 75 25 150 0")
path19b = parse_path("M50 325 q 75 -25 150 0 75 25 150 0")
self.assertEqual(path19a, path19b)
path19a = parse_path("M425 25 T 425 75 T 425 125")
path19b = parse_path("M425 25 T 425 75 425 125")
self.assertEqual(path19a, path19b)
path19a = parse_path("M450 25 t 0 50 t 0 50")
path19b = parse_path("M450 25 t 0 50 0 50")
self.assertEqual(path19a, path19b)
path19a = parse_path("M400,200 A25 25 0 0 0 425 150 A25 25 0 0 0 400 200")
path19b = parse_path("M400,200 A25 25 0 0 0 425 150 25 25 0 0 0 400 200")
self.assertEqual(path19a, path19b)
path19a = parse_path("M400,300 a25 25 0 0 0 25 -50 a25 25 0 0 0 -25 50")
path19b = parse_path("M400,300 a25 25 0 0 0 25 -50 25 25 0 0 0 -25 50")
self.assertEqual(path19a, path19b)
def test_wc3_examples20(self):
"""
W3C_SVG_11_TestSuite Paths
Tests parsing of the elliptical arc path syntax.
"""
path20a = parse_path("M120,120 h25 a25,25 0 1,0 -25,25 z")
path20b = parse_path("M120,120 h25 a25,25 0 10 -25,25z")
self.assertEqual(path20a, path20b)
path20a = parse_path("M200,120 h-25 a25,25 0 1,1 25,25 z")
path20b = parse_path("M200,120 h-25 a25,25 0 1125,25 z")
self.assertEqual(path20a, path20b)
path20a = parse_path("M280,120 h25 a25,25 0 1,0 -25,25 z")
self.assertRaises(Exception, 'parse_path("M280,120 h25 a25,25 0 6 0 -25,25 z")')
path20a = parse_path("M360,120 h-25 a25,25 0 1,1 25,25 z")
self.assertRaises(
Exception, 'parse_path("M360,120 h-25 a25,25 0 1 -1 25,25 z")'
)
path20a = parse_path("M120,200 h25 a25,25 0 1,1 -25,-25 z")
path20b = parse_path("M120,200 h25 a25,25 0 1 1-25,-25 z")
self.assertEqual(path20a, path20b)
path20a = parse_path("M200,200 h-25 a25,25 0 1,0 25,-25 z")
self.assertRaises(Exception, 'parse_path("M200,200 h-25 a25,2501 025,-25 z")')
path20a = parse_path("M280,200 h25 a25,25 0 1,1 -25,-25 z")
self.assertRaises(
Exception, 'parse_path("M280,200 h25 a25 25 0 1 7 -25 -25 z")'
)
path20a = parse_path("M360,200 h-25 a25,25 0 1,0 25,-25 z")
self.assertRaises(
Exception, 'parse_path("M360,200 h-25 a25,25 0 -1 0 25,-25 z")'
)
def test_others(self):
# Other paths that need testing:
# Relative moveto:
path1 = parse_path("M 0 0 L 50 20 m 50 80 L 300 100 L 200 300 z")
self.assertEqual(
path1,
Path(
Move(0j),
Line(0 + 0j, 50 + 20j),
Move(100 + 100j),
Line(100 + 100j, 300 + 100j),
Line(300 + 100j, 200 + 300j),
Close(200 + 300j, 100 + 100j),
),
)
# Initial smooth and relative CubicBezier
path1 = parse_path("M100,200 s 150,-100 150,0")
self.assertEqual(
path1,
Path(
Move(100 + 200j),
CubicBezier(100 + 200j, 100 + 200j, 250 + 100j, 250 + 200j),
),
)
# Initial smooth and relative QuadraticBezier
path1 = parse_path("M100,200 t 150,0")
self.assertEqual(
path1,
Path(Move(100 + 200j), QuadraticBezier(100 + 200j, 100 + 200j, 250 + 200j)),
)
# Relative QuadraticBezier
path1 = parse_path("M100,200 q 0,0 150,0")
self.assertEqual(
path1,
Path(Move(100 + 200j), QuadraticBezier(100 + 200j, 100 + 200j, 250 + 200j)),
)
def test_negative(self):
"""You don't need spaces before a minus-sign"""
path1 = parse_path("M100,200c10-5,20-10,30-20")
path2 = parse_path("M 100 200 c 10 -5 20 -10 30 -20")
self.assertEqual(path1, path2)
def test_numbers(self):
"""Exponents and other number format cases"""
# It can be e or E, the plus is optional, and a minimum of +/-3.4e38 must be supported.
path1 = parse_path("M-3.4e38 3.4E+38L-3.4E-38,3.4e-38")
path2 = Path(
Move(-3.4e38 + 3.4e38j), Line(-3.4e38 + 3.4e38j, -3.4e-38 + 3.4e-38j)
)
self.assertEqual(path1, path2)
def test_errors(self):
self.assertRaises(ValueError, parse_path, "M 100 100 L 200 200 Z 100 200")
def test_non_path(self):
# It's possible in SVG to create paths that has zero length,
# we need to handle that.
path = parse_path("M10.236,100.184")
self.assertEqual(path.d(), "M 10.236,100.184")
def test_issue_45(self):
# A missing Z in certain cases
path = parse_path(
"m 1672.2372,-54.8161 "
"a 14.5445,14.5445 0 0 0 -11.3152,23.6652 "
"l 27.2573,27.2572 27.2572,-27.2572 "
"a 14.5445,14.5445 0 0 0 -11.3012,-23.634 "
"a 14.5445,14.5445 0 0 0 -11.414,5.4625 "
"l -4.542,4.5420 "
"l -4.5437,-4.5420 "
"a 14.5445,14.5445 0 0 0 -11.3984,-5.4937 "
"z"
)
self.assertEqual(
"m 1672.24,-54.8161 "
"a 14.5445,14.5445 0 0,0 -11.3152,23.6652 "
"l 27.2573,27.2572 l 27.2572,-27.2572 "
"a 14.5445,14.5445 0 0,0 -11.3012,-23.634 "
"a 14.5445,14.5445 0 0,0 -11.414,5.4625 "
"l -4.542,4.542 "
"l -4.5437,-4.542 "
"a 14.5445,14.5445 0 0,0 -11.3984,-5.4937 "
"z",
path.d(),
)
def test_arc_flag(self):
"""Issue #69"""
path = parse_path(
"M 5 1 v 7.344 A 3.574 3.574 0 003.5 8 3.515 3.515 0 000 11.5 C 0 13.421 1.579 15 3.5 15 "
"A 3.517 3.517 0 007 11.531 v -7.53 h 6 v 4.343 A 3.574 3.574 0 0011.5 8 3.515 3.515 0 008 11.5 "
"c 0 1.921 1.579 3.5 3.5 3.5 1.9 0 3.465 -1.546 3.5 -3.437 V 1 z"
)
# Check that all elemets is there:
self.assertEqual(len(path), 15)
# It ends on a vertical line to Y 1:
self.assertEqual(path[-1].end.imag, 1)
def test_incomplete_numbers(self):
path = parse_path("M 0. .1")
self.assertEqual(path.d(), "M 0,0.1")
path = parse_path("M 0..1")
self.assertEqual(path.d(), "M 0,0.1")

View File

@@ -0,0 +1,765 @@
import unittest
from math import sqrt, pi
from ..path import CubicBezier, QuadraticBezier, Line, Arc, Move, Close, Path
from ..parser import parse_path
# Most of these test points are not calculated separately, as that would
# take too long and be too error prone. Instead the curves have been verified
# to be correct visually, by drawing them with the turtle module, with code
# like this:
#
# import turtle
# t = turtle.Turtle()
# t.penup()
#
# for arc in (path1, path2):
# p = arc.point(0)
# t.goto(p.real - 500, -p.imag + 300)
# t.dot(3, 'black')
# t.pendown()
# for x in range(1, 101):
# p = arc.point(x * 0.01)
# t.goto(p.real - 500, -p.imag + 300)
# t.penup()
# t.dot(3, 'black')
#
# raw_input()
#
# After the paths have been verified to be correct this way, the testing of
# points along the paths has been added as regression tests, to make sure
# nobody changes the way curves are drawn by mistake. Therefore, do not take
# these points religiously. They might be subtly wrong, unless otherwise
# noted.
class LineTest(unittest.TestCase):
def test_lines(self):
# These points are calculated, and not just regression tests.
line1 = Line(0j, 400 + 0j)
self.assertAlmostEqual(line1.point(0), (0j))
self.assertAlmostEqual(line1.point(0.3), (120 + 0j))
self.assertAlmostEqual(line1.point(0.5), (200 + 0j))
self.assertAlmostEqual(line1.point(0.9), (360 + 0j))
self.assertAlmostEqual(line1.point(1), (400 + 0j))
self.assertAlmostEqual(line1.length(), 400)
line2 = Line(400 + 0j, 400 + 300j)
self.assertAlmostEqual(line2.point(0), (400 + 0j))
self.assertAlmostEqual(line2.point(0.3), (400 + 90j))
self.assertAlmostEqual(line2.point(0.5), (400 + 150j))
self.assertAlmostEqual(line2.point(0.9), (400 + 270j))
self.assertAlmostEqual(line2.point(1), (400 + 300j))
self.assertAlmostEqual(line2.length(), 300)
line3 = Line(400 + 300j, 0j)
self.assertAlmostEqual(line3.point(0), (400 + 300j))
self.assertAlmostEqual(line3.point(0.3), (280 + 210j))
self.assertAlmostEqual(line3.point(0.5), (200 + 150j))
self.assertAlmostEqual(line3.point(0.9), (40 + 30j))
self.assertAlmostEqual(line3.point(1), (0j))
self.assertAlmostEqual(line3.length(), 500)
def test_equality(self):
# This is to test the __eq__ and __ne__ methods, so we can't use
# assertEqual and assertNotEqual
line = Line(0j, 400 + 0j)
self.assertTrue(line == Line(0, 400))
self.assertTrue(line != Line(100, 400))
self.assertFalse(line == str(line))
self.assertTrue(line != str(line))
self.assertFalse(
CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j) == line
)
class CubicBezierTest(unittest.TestCase):
def test_approx_circle(self):
"""This is a approximate circle drawn in Inkscape"""
arc1 = CubicBezier(
complex(0, 0),
complex(0, 109.66797),
complex(-88.90345, 198.57142),
complex(-198.57142, 198.57142),
)
self.assertAlmostEqual(arc1.point(0), (0j))
self.assertAlmostEqual(arc1.point(0.1), (-2.59896457 + 32.20931647j))
self.assertAlmostEqual(arc1.point(0.2), (-10.12330256 + 62.76392816j))
self.assertAlmostEqual(arc1.point(0.3), (-22.16418039 + 91.25500149j))
self.assertAlmostEqual(arc1.point(0.4), (-38.31276448 + 117.27370288j))
self.assertAlmostEqual(arc1.point(0.5), (-58.16022125 + 140.41119875j))
self.assertAlmostEqual(arc1.point(0.6), (-81.29771712 + 160.25865552j))
self.assertAlmostEqual(arc1.point(0.7), (-107.31641851 + 176.40723961j))
self.assertAlmostEqual(arc1.point(0.8), (-135.80749184 + 188.44811744j))
self.assertAlmostEqual(arc1.point(0.9), (-166.36210353 + 195.97245543j))
self.assertAlmostEqual(arc1.point(1), (-198.57142 + 198.57142j))
arc2 = CubicBezier(
complex(-198.57142, 198.57142),
complex(-109.66797 - 198.57142, 0 + 198.57142),
complex(-198.57143 - 198.57142, -88.90345 + 198.57142),
complex(-198.57143 - 198.57142, 0),
)
self.assertAlmostEqual(arc2.point(0), (-198.57142 + 198.57142j))
self.assertAlmostEqual(arc2.point(0.1), (-230.78073675 + 195.97245543j))
self.assertAlmostEqual(arc2.point(0.2), (-261.3353492 + 188.44811744j))
self.assertAlmostEqual(arc2.point(0.3), (-289.82642365 + 176.40723961j))
self.assertAlmostEqual(arc2.point(0.4), (-315.8451264 + 160.25865552j))
self.assertAlmostEqual(arc2.point(0.5), (-338.98262375 + 140.41119875j))
self.assertAlmostEqual(arc2.point(0.6), (-358.830082 + 117.27370288j))
self.assertAlmostEqual(arc2.point(0.7), (-374.97866745 + 91.25500149j))
self.assertAlmostEqual(arc2.point(0.8), (-387.0195464 + 62.76392816j))
self.assertAlmostEqual(arc2.point(0.9), (-394.54388515 + 32.20931647j))
self.assertAlmostEqual(arc2.point(1), (-397.14285 + 0j))
arc3 = CubicBezier(
complex(-198.57143 - 198.57142, 0),
complex(0 - 198.57143 - 198.57142, -109.66797),
complex(88.90346 - 198.57143 - 198.57142, -198.57143),
complex(-198.57142, -198.57143),
)
self.assertAlmostEqual(arc3.point(0), (-397.14285 + 0j))
self.assertAlmostEqual(arc3.point(0.1), (-394.54388515 - 32.20931675j))
self.assertAlmostEqual(arc3.point(0.2), (-387.0195464 - 62.7639292j))
self.assertAlmostEqual(arc3.point(0.3), (-374.97866745 - 91.25500365j))
self.assertAlmostEqual(arc3.point(0.4), (-358.830082 - 117.2737064j))
self.assertAlmostEqual(arc3.point(0.5), (-338.98262375 - 140.41120375j))
self.assertAlmostEqual(arc3.point(0.6), (-315.8451264 - 160.258662j))
self.assertAlmostEqual(arc3.point(0.7), (-289.82642365 - 176.40724745j))
self.assertAlmostEqual(arc3.point(0.8), (-261.3353492 - 188.4481264j))
self.assertAlmostEqual(arc3.point(0.9), (-230.78073675 - 195.97246515j))
self.assertAlmostEqual(arc3.point(1), (-198.57142 - 198.57143j))
arc4 = CubicBezier(
complex(-198.57142, -198.57143),
complex(109.66797 - 198.57142, 0 - 198.57143),
complex(0, 88.90346 - 198.57143),
complex(0, 0),
)
self.assertAlmostEqual(arc4.point(0), (-198.57142 - 198.57143j))
self.assertAlmostEqual(arc4.point(0.1), (-166.36210353 - 195.97246515j))
self.assertAlmostEqual(arc4.point(0.2), (-135.80749184 - 188.4481264j))
self.assertAlmostEqual(arc4.point(0.3), (-107.31641851 - 176.40724745j))
self.assertAlmostEqual(arc4.point(0.4), (-81.29771712 - 160.258662j))
self.assertAlmostEqual(arc4.point(0.5), (-58.16022125 - 140.41120375j))
self.assertAlmostEqual(arc4.point(0.6), (-38.31276448 - 117.2737064j))
self.assertAlmostEqual(arc4.point(0.7), (-22.16418039 - 91.25500365j))
self.assertAlmostEqual(arc4.point(0.8), (-10.12330256 - 62.7639292j))
self.assertAlmostEqual(arc4.point(0.9), (-2.59896457 - 32.20931675j))
self.assertAlmostEqual(arc4.point(1), (0j))
def test_svg_examples(self):
# M100,200 C100,100 250,100 250,200
path1 = CubicBezier(100 + 200j, 100 + 100j, 250 + 100j, 250 + 200j)
self.assertAlmostEqual(path1.point(0), (100 + 200j))
self.assertAlmostEqual(path1.point(0.3), (132.4 + 137j))
self.assertAlmostEqual(path1.point(0.5), (175 + 125j))
self.assertAlmostEqual(path1.point(0.9), (245.8 + 173j))
self.assertAlmostEqual(path1.point(1), (250 + 200j))
# S400,300 400,200
path2 = CubicBezier(250 + 200j, 250 + 300j, 400 + 300j, 400 + 200j)
self.assertAlmostEqual(path2.point(0), (250 + 200j))
self.assertAlmostEqual(path2.point(0.3), (282.4 + 263j))
self.assertAlmostEqual(path2.point(0.5), (325 + 275j))
self.assertAlmostEqual(path2.point(0.9), (395.8 + 227j))
self.assertAlmostEqual(path2.point(1), (400 + 200j))
# M100,200 C100,100 400,100 400,200
path3 = CubicBezier(100 + 200j, 100 + 100j, 400 + 100j, 400 + 200j)
self.assertAlmostEqual(path3.point(0), (100 + 200j))
self.assertAlmostEqual(path3.point(0.3), (164.8 + 137j))
self.assertAlmostEqual(path3.point(0.5), (250 + 125j))
self.assertAlmostEqual(path3.point(0.9), (391.6 + 173j))
self.assertAlmostEqual(path3.point(1), (400 + 200j))
# M100,500 C25,400 475,400 400,500
path4 = CubicBezier(100 + 500j, 25 + 400j, 475 + 400j, 400 + 500j)
self.assertAlmostEqual(path4.point(0), (100 + 500j))
self.assertAlmostEqual(path4.point(0.3), (145.9 + 437j))
self.assertAlmostEqual(path4.point(0.5), (250 + 425j))
self.assertAlmostEqual(path4.point(0.9), (407.8 + 473j))
self.assertAlmostEqual(path4.point(1), (400 + 500j))
# M100,800 C175,700 325,700 400,800
path5 = CubicBezier(100 + 800j, 175 + 700j, 325 + 700j, 400 + 800j)
self.assertAlmostEqual(path5.point(0), (100 + 800j))
self.assertAlmostEqual(path5.point(0.3), (183.7 + 737j))
self.assertAlmostEqual(path5.point(0.5), (250 + 725j))
self.assertAlmostEqual(path5.point(0.9), (375.4 + 773j))
self.assertAlmostEqual(path5.point(1), (400 + 800j))
# M600,200 C675,100 975,100 900,200
path6 = CubicBezier(600 + 200j, 675 + 100j, 975 + 100j, 900 + 200j)
self.assertAlmostEqual(path6.point(0), (600 + 200j))
self.assertAlmostEqual(path6.point(0.3), (712.05 + 137j))
self.assertAlmostEqual(path6.point(0.5), (806.25 + 125j))
self.assertAlmostEqual(path6.point(0.9), (911.85 + 173j))
self.assertAlmostEqual(path6.point(1), (900 + 200j))
# M600,500 C600,350 900,650 900,500
path7 = CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j)
self.assertAlmostEqual(path7.point(0), (600 + 500j))
self.assertAlmostEqual(path7.point(0.3), (664.8 + 462.2j))
self.assertAlmostEqual(path7.point(0.5), (750 + 500j))
self.assertAlmostEqual(path7.point(0.9), (891.6 + 532.4j))
self.assertAlmostEqual(path7.point(1), (900 + 500j))
# M600,800 C625,700 725,700 750,800
path8 = CubicBezier(600 + 800j, 625 + 700j, 725 + 700j, 750 + 800j)
self.assertAlmostEqual(path8.point(0), (600 + 800j))
self.assertAlmostEqual(path8.point(0.3), (638.7 + 737j))
self.assertAlmostEqual(path8.point(0.5), (675 + 725j))
self.assertAlmostEqual(path8.point(0.9), (740.4 + 773j))
self.assertAlmostEqual(path8.point(1), (750 + 800j))
# S875,900 900,800
inversion = (750 + 800j) + (750 + 800j) - (725 + 700j)
path9 = CubicBezier(750 + 800j, inversion, 875 + 900j, 900 + 800j)
self.assertAlmostEqual(path9.point(0), (750 + 800j))
self.assertAlmostEqual(path9.point(0.3), (788.7 + 863j))
self.assertAlmostEqual(path9.point(0.5), (825 + 875j))
self.assertAlmostEqual(path9.point(0.9), (890.4 + 827j))
self.assertAlmostEqual(path9.point(1), (900 + 800j))
def test_length(self):
# A straight line:
arc = CubicBezier(
complex(0, 0), complex(0, 0), complex(0, 100), complex(0, 100)
)
self.assertAlmostEqual(arc.length(), 100)
# A diagonal line:
arc = CubicBezier(
complex(0, 0), complex(0, 0), complex(100, 100), complex(100, 100)
)
self.assertAlmostEqual(arc.length(), sqrt(2 * 100 * 100))
# A quarter circle arc with radius 100:
kappa = (
4 * (sqrt(2) - 1) / 3
) # http://www.whizkidtech.redprince.net/bezier/circle/
arc = CubicBezier(
complex(0, 0),
complex(0, kappa * 100),
complex(100 - kappa * 100, 100),
complex(100, 100),
)
# We can't compare with pi*50 here, because this is just an
# approximation of a circle arc. pi*50 is 157.079632679
# So this is just yet another "warn if this changes" test.
# This value is not verified to be correct.
self.assertAlmostEqual(arc.length(), 157.1016698)
# A recursive solution has also been suggested, but for CubicBezier
# curves it could get a false solution on curves where the midpoint is on a
# straight line between the start and end. For example, the following
# curve would get solved as a straight line and get the length 300.
# Make sure this is not the case.
arc = CubicBezier(
complex(600, 500), complex(600, 350), complex(900, 650), complex(900, 500)
)
self.assertTrue(arc.length() > 300.0)
def test_equality(self):
# This is to test the __eq__ and __ne__ methods, so we can't use
# assertEqual and assertNotEqual
segment = CubicBezier(
complex(600, 500), complex(600, 350), complex(900, 650), complex(900, 500)
)
self.assertTrue(
segment == CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j)
)
self.assertTrue(
segment != CubicBezier(600 + 501j, 600 + 350j, 900 + 650j, 900 + 500j)
)
self.assertTrue(segment != Line(0, 400))
def test_smooth(self):
cb1 = CubicBezier(0, 0, 100 + 100j, 100 + 100j)
cb2 = CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j)
self.assertFalse(cb2.is_smooth_from(cb1))
cb2.set_smooth_from(cb1)
self.assertTrue(cb2.is_smooth_from(cb1))
class QuadraticBezierTest(unittest.TestCase):
def test_svg_examples(self):
"""These is the path in the SVG specs"""
# M200,300 Q400,50 600,300 T1000,300
path1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j)
self.assertAlmostEqual(path1.point(0), (200 + 300j))
self.assertAlmostEqual(path1.point(0.3), (320 + 195j))
self.assertAlmostEqual(path1.point(0.5), (400 + 175j))
self.assertAlmostEqual(path1.point(0.9), (560 + 255j))
self.assertAlmostEqual(path1.point(1), (600 + 300j))
# T1000, 300
inversion = (600 + 300j) + (600 + 300j) - (400 + 50j)
path2 = QuadraticBezier(600 + 300j, inversion, 1000 + 300j)
self.assertAlmostEqual(path2.point(0), (600 + 300j))
self.assertAlmostEqual(path2.point(0.3), (720 + 405j))
self.assertAlmostEqual(path2.point(0.5), (800 + 425j))
self.assertAlmostEqual(path2.point(0.9), (960 + 345j))
self.assertAlmostEqual(path2.point(1), (1000 + 300j))
def test_length(self):
# expected results calculated with
# svg.path.segment_length(q, 0, 1, q.start, q.end, 1e-14, 20, 0)
q1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j)
q2 = QuadraticBezier(200 + 300j, 400 + 50j, 500 + 200j)
closedq = QuadraticBezier(6 + 2j, 5 - 1j, 6 + 2j)
linq1 = QuadraticBezier(1, 2, 3)
linq2 = QuadraticBezier(1 + 3j, 2 + 5j, -9 - 17j)
nodalq = QuadraticBezier(1, 1, 1)
tests = [
(q1, 487.77109389525975),
(q2, 379.90458193489155),
(closedq, 3.1622776601683795),
(linq1, 2),
(linq2, 22.73335777124786),
(nodalq, 0),
]
for q, exp_res in tests:
self.assertAlmostEqual(q.length(), exp_res)
def test_equality(self):
# This is to test the __eq__ and __ne__ methods, so we can't use
# assertEqual and assertNotEqual
segment = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j)
self.assertTrue(segment == QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j))
self.assertTrue(segment != QuadraticBezier(200 + 301j, 400 + 50j, 600 + 300j))
self.assertFalse(segment == Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j))
self.assertTrue(Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) != segment)
def test_linear_arcs_issue_61(self):
p = parse_path("M 206.5,525 Q 162.5,583 162.5,583")
self.assertAlmostEqual(p.length(), 72.80109889280519)
p = parse_path("M 425.781 446.289 Q 410.40000000000003 373.047 410.4 373.047")
self.assertAlmostEqual(p.length(), 74.83959997888816)
p = parse_path("M 639.648 568.115 Q 606.6890000000001 507.568 606.689 507.568")
self.assertAlmostEqual(p.length(), 68.93645544992873)
p = parse_path("M 288.818 616.699 Q 301.025 547.3629999999999 301.025 547.363")
self.assertAlmostEqual(p.length(), 70.40235610403947)
p = parse_path("M 339.927 706.25 Q 243.92700000000002 806.25 243.927 806.25")
self.assertAlmostEqual(p.length(), 138.6217876093077)
p = parse_path(
"M 539.795 702.637 Q 548.0959999999999 803.4669999999999 548.096 803.467"
)
self.assertAlmostEqual(p.length(), 101.17111989594662)
p = parse_path(
"M 537.815 555.042 Q 570.1680000000001 499.1600000000001 570.168 499.16"
)
self.assertAlmostEqual(p.length(), 64.57177814649368)
p = parse_path("M 615.297 470.503 Q 538.797 694.5029999999999 538.797 694.503")
self.assertAlmostEqual(p.length(), 236.70287281737836)
def test_smooth(self):
cb1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j)
cb2 = QuadraticBezier(600 + 300j, 400 + 50j, 1000 + 300j)
self.assertFalse(cb2.is_smooth_from(cb1))
cb2.set_smooth_from(cb1)
self.assertTrue(cb2.is_smooth_from(cb1))
class ArcTest(unittest.TestCase):
def test_points(self):
arc1 = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)
self.assertAlmostEqual(arc1.center, 100 + 0j)
self.assertAlmostEqual(arc1.theta, 180.0)
self.assertAlmostEqual(arc1.delta, -90.0)
self.assertAlmostEqual(arc1.point(0.0), (0j))
self.assertAlmostEqual(arc1.point(0.1), (1.23116594049 + 7.82172325201j))
self.assertAlmostEqual(arc1.point(0.2), (4.89434837048 + 15.4508497187j))
self.assertAlmostEqual(arc1.point(0.3), (10.8993475812 + 22.699524987j))
self.assertAlmostEqual(arc1.point(0.4), (19.0983005625 + 29.3892626146j))
self.assertAlmostEqual(arc1.point(0.5), (29.2893218813 + 35.3553390593j))
self.assertAlmostEqual(arc1.point(0.6), (41.2214747708 + 40.4508497187j))
self.assertAlmostEqual(arc1.point(0.7), (54.6009500260 + 44.5503262094j))
self.assertAlmostEqual(arc1.point(0.8), (69.0983005625 + 47.5528258148j))
self.assertAlmostEqual(arc1.point(0.9), (84.3565534960 + 49.3844170298j))
self.assertAlmostEqual(arc1.point(1.0), (100 + 50j))
arc2 = Arc(0j, 100 + 50j, 0, 1, 0, 100 + 50j)
self.assertAlmostEqual(arc2.center, 50j)
self.assertAlmostEqual(arc2.theta, 270.0)
self.assertAlmostEqual(arc2.delta, -270.0)
self.assertAlmostEqual(arc2.point(0.0), (0j))
self.assertAlmostEqual(arc2.point(0.1), (-45.399049974 + 5.44967379058j))
self.assertAlmostEqual(arc2.point(0.2), (-80.9016994375 + 20.6107373854j))
self.assertAlmostEqual(arc2.point(0.3), (-98.7688340595 + 42.178276748j))
self.assertAlmostEqual(arc2.point(0.4), (-95.1056516295 + 65.4508497187j))
self.assertAlmostEqual(arc2.point(0.5), (-70.7106781187 + 85.3553390593j))
self.assertAlmostEqual(arc2.point(0.6), (-30.9016994375 + 97.5528258148j))
self.assertAlmostEqual(arc2.point(0.7), (15.643446504 + 99.3844170298j))
self.assertAlmostEqual(arc2.point(0.8), (58.7785252292 + 90.4508497187j))
self.assertAlmostEqual(arc2.point(0.9), (89.1006524188 + 72.699524987j))
self.assertAlmostEqual(arc2.point(1.0), (100 + 50j))
arc3 = Arc(0j, 100 + 50j, 0, 0, 1, 100 + 50j)
self.assertAlmostEqual(arc3.center, 50j)
self.assertAlmostEqual(arc3.theta, 270.0)
self.assertAlmostEqual(arc3.delta, 90.0)
self.assertAlmostEqual(arc3.point(0.0), (0j))
self.assertAlmostEqual(arc3.point(0.1), (15.643446504 + 0.615582970243j))
self.assertAlmostEqual(arc3.point(0.2), (30.9016994375 + 2.44717418524j))
self.assertAlmostEqual(arc3.point(0.3), (45.399049974 + 5.44967379058j))
self.assertAlmostEqual(arc3.point(0.4), (58.7785252292 + 9.54915028125j))
self.assertAlmostEqual(arc3.point(0.5), (70.7106781187 + 14.6446609407j))
self.assertAlmostEqual(arc3.point(0.6), (80.9016994375 + 20.6107373854j))
self.assertAlmostEqual(arc3.point(0.7), (89.1006524188 + 27.300475013j))
self.assertAlmostEqual(arc3.point(0.8), (95.1056516295 + 34.5491502813j))
self.assertAlmostEqual(arc3.point(0.9), (98.7688340595 + 42.178276748j))
self.assertAlmostEqual(arc3.point(1.0), (100 + 50j))
arc4 = Arc(0j, 100 + 50j, 0, 1, 1, 100 + 50j)
self.assertAlmostEqual(arc4.center, 100 + 0j)
self.assertAlmostEqual(arc4.theta, 180.0)
self.assertAlmostEqual(arc4.delta, 270.0)
self.assertAlmostEqual(arc4.point(0.0), (0j))
self.assertAlmostEqual(arc4.point(0.1), (10.8993475812 - 22.699524987j))
self.assertAlmostEqual(arc4.point(0.2), (41.2214747708 - 40.4508497187j))
self.assertAlmostEqual(arc4.point(0.3), (84.3565534960 - 49.3844170298j))
self.assertAlmostEqual(arc4.point(0.4), (130.901699437 - 47.5528258148j))
self.assertAlmostEqual(arc4.point(0.5), (170.710678119 - 35.3553390593j))
self.assertAlmostEqual(arc4.point(0.6), (195.105651630 - 15.4508497187j))
self.assertAlmostEqual(arc4.point(0.7), (198.768834060 + 7.82172325201j))
self.assertAlmostEqual(arc4.point(0.8), (180.901699437 + 29.3892626146j))
self.assertAlmostEqual(arc4.point(0.9), (145.399049974 + 44.5503262094j))
self.assertAlmostEqual(arc4.point(1.0), (100 + 50j))
def test_length(self):
# I'll test the length calculations by making a circle, in two parts.
arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j)
arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j)
self.assertAlmostEqual(arc1.length(), pi * 100)
self.assertAlmostEqual(arc2.length(), pi * 100)
def test_length_out_of_range(self):
# See F.6.2 Out-of-range parameters
# If the endpoints (x1, y1) and (x2, y2) are identical, then this is
# equivalent to omitting the elliptical arc segment entirely.
arc = Arc(0j, 100 + 100j, 0, 0, 0, 0j)
self.assertAlmostEqual(arc.length(), 0)
# If rx = 0 or ry = 0 then this arc is treated as a straight
# line segment (a "lineto") joining the endpoints.
arc = Arc(0j, 0j, 0, 0, 0, 200 + 0j)
self.assertAlmostEqual(arc.length(), 200)
# If rx or ry have negative signs, these are dropped;
# the absolute value is used instead.
arc = Arc(200 + 0j, -100 - 100j, 0, 0, 0, 0j)
self.assertAlmostEqual(arc.length(), pi * 100)
# If rx, ry and φ are such that there is no solution (basically,
# the ellipse is not big enough to reach from (x1, y1) to (x2, y2))
# then the ellipse is scaled up uniformly until there is exactly
# one solution (until the ellipse is just big enough).
arc = Arc(0j, 1 + 1j, 0, 0, 0, 200 + 0j)
self.assertAlmostEqual(arc.length(), pi * 100)
# φ is taken mod 360 degrees.
arc = Arc(200 + 0j, -100 - 100j, 720, 0, 0, 0j)
self.assertAlmostEqual(arc.length(), pi * 100)
def test_equality(self):
# This is to test the __eq__ and __ne__ methods, so we can't use
# assertEqual and assertNotEqual
segment = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)
self.assertTrue(segment == Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j))
self.assertTrue(segment != Arc(0j, 100 + 50j, 0, 1, 0, 100 + 50j))
def test_issue25(self):
# This raised a math domain error
Arc(
(725.307482225571 - 915.5548199281527j),
(202.79421639137703 + 148.77294617167183j),
225.6910319606926,
1,
1,
(-624.6375539637027 + 896.5483089399895j),
)
class TestPath(unittest.TestCase):
def test_circle(self):
arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j)
arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j)
path = Path(arc1, arc2)
self.assertAlmostEqual(path.point(0.0), (0j))
self.assertAlmostEqual(path.point(0.25), (100 + 100j))
self.assertAlmostEqual(path.point(0.5), (200 + 0j))
self.assertAlmostEqual(path.point(0.75), (100 - 100j))
self.assertAlmostEqual(path.point(1.0), (0j))
self.assertAlmostEqual(path.length(), pi * 200)
def test_svg_specs(self):
"""The paths that are in the SVG specs"""
# Big pie: M300,200 h-150 a150,150 0 1,0 150,-150 z
path = Path(
Line(300 + 200j, 150 + 200j),
Arc(150 + 200j, 150 + 150j, 0, 1, 0, 300 + 50j),
Line(300 + 50j, 300 + 200j),
)
# The points and length for this path are calculated and not regression tests.
self.assertAlmostEqual(path.point(0.0), (300 + 200j))
self.assertAlmostEqual(path.point(0.14897825542), (150 + 200j))
self.assertAlmostEqual(path.point(0.5), (406.066017177 + 306.066017177j))
self.assertAlmostEqual(path.point(1 - 0.14897825542), (300 + 50j))
self.assertAlmostEqual(path.point(1.0), (300 + 200j))
# The errors seem to accumulate. Still 6 decimal places is more than good enough.
self.assertAlmostEqual(path.length(), pi * 225 + 300, places=6)
# Little pie: M275,175 v-150 a150,150 0 0,0 -150,150 z
path = Path(
Line(275 + 175j, 275 + 25j),
Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j),
Line(125 + 175j, 275 + 175j),
)
# The points and length for this path are calculated and not regression tests.
self.assertAlmostEqual(path.point(0.0), (275 + 175j))
self.assertAlmostEqual(path.point(0.2800495767557787), (275 + 25j))
self.assertAlmostEqual(
path.point(0.5), (168.93398282201787 + 68.93398282201787j)
)
self.assertAlmostEqual(path.point(1 - 0.2800495767557787), (125 + 175j))
self.assertAlmostEqual(path.point(1.0), (275 + 175j))
# The errors seem to accumulate. Still 6 decimal places is more than good enough.
self.assertAlmostEqual(path.length(), pi * 75 + 300, places=6)
# Bumpy path: M600,350 l 50,-25
# a25,25 -30 0,1 50,-25 l 50,-25
# a25,50 -30 0,1 50,-25 l 50,-25
# a25,75 -30 0,1 50,-25 l 50,-25
# a25,100 -30 0,1 50,-25 l 50,-25
path = Path(
Line(600 + 350j, 650 + 325j),
Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j),
Line(700 + 300j, 750 + 275j),
Arc(750 + 275j, 25 + 50j, -30, 0, 1, 800 + 250j),
Line(800 + 250j, 850 + 225j),
Arc(850 + 225j, 25 + 75j, -30, 0, 1, 900 + 200j),
Line(900 + 200j, 950 + 175j),
Arc(950 + 175j, 25 + 100j, -30, 0, 1, 1000 + 150j),
Line(1000 + 150j, 1050 + 125j),
)
# These are *not* calculated, but just regression tests. Be skeptical.
self.assertAlmostEqual(path.point(0.0), (600 + 350j))
self.assertAlmostEqual(path.point(0.3), (755.23979927 + 212.1820209585j))
self.assertAlmostEqual(path.point(0.5), (827.73074926 + 147.8241574162j))
self.assertAlmostEqual(path.point(0.9), (971.28435780 + 106.3023526073j))
self.assertAlmostEqual(path.point(1.0), (1050 + 125j))
self.assertAlmostEqual(path.length(), 928.388639381)
def test_repr(self):
path = Path(
Line(start=600 + 350j, end=650 + 325j),
Arc(
start=650 + 325j,
radius=25 + 25j,
rotation=-30,
arc=0,
sweep=1,
end=700 + 300j,
),
CubicBezier(
start=700 + 300j,
control1=800 + 400j,
control2=750 + 200j,
end=600 + 100j,
),
QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j),
)
self.assertEqual(eval(repr(path)), path)
def test_reverse(self):
# Currently you can't reverse paths.
self.assertRaises(NotImplementedError, Path().reverse)
def test_equality(self):
# This is to test the __eq__ and __ne__ methods, so we can't use
# assertEqual and assertNotEqual
path1 = Path(
Line(start=600 + 350j, end=650 + 325j),
Arc(
start=650 + 325j,
radius=25 + 25j,
rotation=-30,
arc=0,
sweep=1,
end=700 + 300j,
),
CubicBezier(
start=700 + 300j,
control1=800 + 400j,
control2=750 + 200j,
end=600 + 100j,
),
QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j),
)
path2 = Path(
Line(start=600 + 350j, end=650 + 325j),
Arc(
start=650 + 325j,
radius=25 + 25j,
rotation=-30,
arc=0,
sweep=1,
end=700 + 300j,
),
CubicBezier(
start=700 + 300j,
control1=800 + 400j,
control2=750 + 200j,
end=600 + 100j,
),
QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j),
)
self.assertTrue(path1 == path2)
# Modify path2:
path2[0].start = 601 + 350j
self.assertTrue(path1 != path2)
# Modify back:
path2[0].start = 600 + 350j
self.assertFalse(path1 != path2)
# Get rid of the last segment:
del path2[-1]
self.assertFalse(path1 == path2)
# It's not equal to a list of it's segments
self.assertTrue(path1 != path1[:])
self.assertFalse(path1 == path1[:])
def test_non_arc(self):
# And arc with the same start and end is a noop.
segment = Arc(0j + 70j, 35 + 35j, 0, 1, 0, 0 + 70j)
self.assertEqual(segment.length(), 0)
self.assertEqual(segment.point(0.5), segment.start)
def test_zero_paths(self):
move_only = Path(Move(0))
self.assertEqual(move_only.point(0), 0 + 0j)
self.assertEqual(move_only.point(0.5), 0 + 0j)
self.assertEqual(move_only.point(1), 0 + 0j)
self.assertEqual(move_only.length(), 0)
move_onlyz = Path(Move(0), Close(0, 0))
self.assertEqual(move_onlyz.point(0), 0 + 0j)
self.assertEqual(move_onlyz.point(0.5), 0 + 0j)
self.assertEqual(move_onlyz.point(1), 0 + 0j)
self.assertEqual(move_onlyz.length(), 0)
zero_line = Path(Move(0), Line(0, 0))
self.assertEqual(zero_line.point(0), 0 + 0j)
self.assertEqual(zero_line.point(0.5), 0 + 0j)
self.assertEqual(zero_line.point(1), 0 + 0j)
self.assertEqual(zero_line.length(), 0)
only_line = Path(Line(1 + 1j, 1 + 1j))
self.assertEqual(only_line.point(0), 1 + 1j)
self.assertEqual(only_line.point(0.5), 1 + 1j)
self.assertEqual(only_line.point(1), 1 + 1j)
self.assertEqual(only_line.length(), 0)
def test_tangent(self):
path = Path(
Line(start=600 + 350j, end=650 + 325j),
Arc(
start=650 + 325j,
radius=25 + 25j,
rotation=-30,
arc=0,
sweep=1,
end=700 + 300j,
),
CubicBezier(
start=700 + 300j,
control1=800 + 400j,
control2=750 + 200j,
end=600 + 100j,
),
QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j),
)
self.assertEqual(path.tangent(0), 50 - 25j)
# These are *not* calculated, but just regression tests. Be skeptical.
self.assertAlmostEqual(
path.tangent(0.25), 197.17077123205894 + 106.56022001841387j
)
self.assertAlmostEqual(
path.tangent(0.5), -226.30788045372367 - 364.5433357646594j
)
self.assertAlmostEqual(path.tangent(0.75), 13.630819414210208j)
self.assertAlmostEqual(path.tangent(1), 600j)
def test_tangent_magnitude(self):
line1 = Line(start=6 + 3.5j, end=6.5 + 3.25j)
line2 = Line(start=6 + 3.5j, end=7 + 3j)
# line2 is twice as long as line1, the tangent should have twice the magnitude:
self.assertAlmostEqual(line2.tangent(0.5) / line1.tangent(0.5), 2)
arc1 = Arc(
start=0 - 2.5j, radius=2.5 + 2.5j, rotation=0, arc=0, sweep=1, end=0 + 2.5j
)
arc2 = Arc(start=0 - 5j, radius=5 + 5j, rotation=0, arc=0, sweep=1, end=0 + 5j)
# The radius is twice as large, so the magnitude is twice as large
self.assertAlmostEqual(arc2.tangent(0.5) / arc1.tangent(0.5), 2)
bez1 = CubicBezier(start=0, control1=1 + 1j, control2=2 - 1j, end=3)
bez2 = CubicBezier(start=0, control1=2 + 2j, control2=4 - 2j, end=6)
# Length should be double, tangent is double.
self.assertAlmostEqual(bez2.tangent(0.5) / bez1.tangent(0.5), 2)
qb1 = QuadraticBezier(start=0, control=1 + 1j, end=2)
qb2 = QuadraticBezier(start=0, control=2 + 2j, end=4)
# Length should be double, tangent is double.
self.assertAlmostEqual(qb2.tangent(0.5) / qb1.tangent(0.5), 2)
# Code for visually verifying these tangents. I should make a test of this.
# import turtle
# t = turtle.Turtle()
# t.penup()
# for arc in (line1, line2, arc1, arc2, bez1, bez2):
# p = arc.point(0)
# t.goto(p.real*20, -p.imag*20)
# t.dot(3, 'black')
# t.pendown()
# for x in range(1, 101):
# p = arc.point(x * 0.01)
# t.goto(p.real*20,-p.imag*20)
# t.penup()
# t.dot(3, 'black')
# p = arc.point(0.5)
# t.goto(p.real*20,-p.imag*20)
# t.dot(3, 'red')
# t.pendown()
# p += arc.tangent(0.5)
# t.goto(p.real*20,-p.imag*20)
# t.penup()

View File

@@ -0,0 +1,72 @@
import pytest
from svg.path import parser
PATHS = [
(
"M 100 100 L 300 100 L 200 300 z",
[("M", "100 100"), ("L", "300 100"), ("L", "200 300"), ("z", "")],
[("M", 100 + 100j), ("L", 300 + 100j), ("L", 200 + 300j), ("z",)],
),
(
"M 5 1 v 7.344 A 3.574 3.574 0 003.5 8 3.515 3.515 0 000 11.5 C 0 13.421 1.579 15 3.5 15 "
"A 3.517 3.517 0 007 11.531 v -7.53 h 6 v 4.343 A 3.574 3.574 0 0011.5 8 3.515 3.515 0 008 11.5 "
"c 0 1.921 1.579 3.5 3.5 3.5 1.9 0 3.465 -1.546 3.5 -3.437 V 1 z",
[
("M", "5 1"),
("v", "7.344"),
("A", "3.574 3.574 0 003.5 8 3.515 3.515 0 000 11.5"),
("C", "0 13.421 1.579 15 3.5 15"),
("A", "3.517 3.517 0 007 11.531"),
("v", "-7.53"),
("h", "6"),
("v", "4.343"),
("A", "3.574 3.574 0 0011.5 8 3.515 3.515 0 008 11.5"),
("c", "0 1.921 1.579 3.5 3.5 3.5 1.9 0 3.465 -1.546 3.5 -3.437"),
("V", "1"),
("z", ""),
],
[
("M", 5 + 1j),
("v", 7.344),
("A", 3.574, 3.574, 0, False, False, 3.5 + 8j),
("A", 3.515, 3.515, 0, False, False, 0 + 11.5j),
("C", 0 + 13.421j, 1.579 + 15j, 3.5 + 15j),
("A", 3.517, 3.517, 0, False, False, 7 + 11.531j),
("v", -7.53),
("h", 6),
("v", 4.343),
("A", 3.574, 3.574, 0, False, False, 11.5 + 8j),
("A", 3.515, 3.515, 0, False, False, 8 + 11.5j),
("c", 0 + 1.921j, 1.579 + 3.5j, 3.5 + 3.5j),
("c", 1.9 + 0j, 3.465 - 1.546j, 3.5 - 3.437j),
("V", 1),
("z",),
],
),
(
"M 600,350 L 650,325 A 25,25 -30 0,1 700,300 L 750,275",
[
("M", "600,350"),
("L", "650,325"),
("A", "25,25 -30 0,1 700,300"),
("L", "750,275"),
],
[
("M", 600 + 350j),
("L", 650 + 325j),
("A", 25, 25, -30, False, True, 700 + 300j),
("L", 750 + 275j),
],
),
]
@pytest.mark.parametrize("path, commands, tokens", PATHS)
def test_commandifier(path, commands, tokens):
assert list(parser._commandify_path(path)) == commands
assert list(parser._tokenize_path(path)) == tokens
@pytest.mark.parametrize("path, commands, tokens", PATHS)
def test_parser(path, commands, tokens):
path = parser.parse_path(path)