Spaces:
Running
Running
# SVG Path specification parser. | |
# This is an adaptation from 'svg.path' by Lennart Regebro (@regebro), | |
# modified so that the parser takes a FontTools Pen object instead of | |
# returning a list of svg.path Path objects. | |
# The original code can be found at: | |
# https://github.com/regebro/svg.path/blob/4f9b6e3/src/svg/path/parser.py | |
# Copyright (c) 2013-2014 Lennart Regebro | |
# License: MIT | |
from .arc import EllipticalArc | |
import re | |
COMMANDS = set("MmZzLlHhVvCcSsQqTtAa") | |
ARC_COMMANDS = set("Aa") | |
UPPERCASE = set("MZLHVCSQTA") | |
COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])") | |
# https://www.w3.org/TR/css-syntax-3/#number-token-diagram | |
# but -6.e-5 will be tokenized as "-6" then "-5" and confuse parsing | |
FLOAT_RE = re.compile( | |
r"[-+]?" # optional sign | |
r"(?:" | |
r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?" # int/float | |
r"|" | |
r"(?:\.[0-9]+(?:[eE][-+]?[0-9]+)?)" # float with leading dot (e.g. '.42') | |
r")" | |
) | |
BOOL_RE = re.compile("^[01]") | |
SEPARATOR_RE = re.compile(f"[, \t]") | |
def _tokenize_path(pathdef): | |
arc_cmd = None | |
for x in COMMAND_RE.split(pathdef): | |
if x in COMMANDS: | |
arc_cmd = x if x in ARC_COMMANDS else None | |
yield x | |
continue | |
if arc_cmd: | |
try: | |
yield from _tokenize_arc_arguments(x) | |
except ValueError as e: | |
raise ValueError(f"Invalid arc command: '{arc_cmd}{x}'") from e | |
else: | |
for token in FLOAT_RE.findall(x): | |
yield token | |
ARC_ARGUMENT_TYPES = ( | |
("rx", FLOAT_RE), | |
("ry", FLOAT_RE), | |
("x-axis-rotation", FLOAT_RE), | |
("large-arc-flag", BOOL_RE), | |
("sweep-flag", BOOL_RE), | |
("x", FLOAT_RE), | |
("y", FLOAT_RE), | |
) | |
def _tokenize_arc_arguments(arcdef): | |
raw_args = [s for s in SEPARATOR_RE.split(arcdef) if s] | |
if not raw_args: | |
raise ValueError(f"Not enough arguments: '{arcdef}'") | |
raw_args.reverse() | |
i = 0 | |
while raw_args: | |
arg = raw_args.pop() | |
name, pattern = ARC_ARGUMENT_TYPES[i] | |
match = pattern.search(arg) | |
if not match: | |
raise ValueError(f"Invalid argument for '{name}' parameter: {arg!r}") | |
j, k = match.span() | |
yield arg[j:k] | |
arg = arg[k:] | |
if arg: | |
raw_args.append(arg) | |
# wrap around every 7 consecutive arguments | |
if i == 6: | |
i = 0 | |
else: | |
i += 1 | |
if i != 0: | |
raise ValueError(f"Not enough arguments: '{arcdef}'") | |
def parse_path(pathdef, pen, current_pos=(0, 0), arc_class=EllipticalArc): | |
"""Parse SVG path definition (i.e. "d" attribute of <path> elements) | |
and call a 'pen' object's moveTo, lineTo, curveTo, qCurveTo and closePath | |
methods. | |
If 'current_pos' (2-float tuple) is provided, the initial moveTo will | |
be relative to that instead being absolute. | |
If the pen has an "arcTo" method, it is called with the original values | |
of the elliptical arc curve commands: | |
pen.arcTo(rx, ry, rotation, arc_large, arc_sweep, (x, y)) | |
Otherwise, the arcs are approximated by series of cubic Bezier segments | |
("curveTo"), one every 90 degrees. | |
""" | |
# In the SVG specs, initial movetos are absolute, even if | |
# specified as 'm'. This is the default behavior here as well. | |
# But if you pass in a current_pos variable, the initial moveto | |
# will be relative to that current_pos. This is useful. | |
current_pos = complex(*current_pos) | |
elements = list(_tokenize_path(pathdef)) | |
# Reverse for easy use of .pop() | |
elements.reverse() | |
start_pos = None | |
command = None | |
last_control = None | |
have_arcTo = hasattr(pen, "arcTo") | |
while elements: | |
if elements[-1] in COMMANDS: | |
# New command. | |
last_command = command # Used by S and T | |
command = elements.pop() | |
absolute = command in UPPERCASE | |
command = command.upper() | |
else: | |
# If this element starts with numbers, it is an implicit command | |
# and we don't change the command. Check that it's allowed: | |
if command is None: | |
raise ValueError( | |
"Unallowed implicit command in %s, position %s" | |
% (pathdef, len(pathdef.split()) - len(elements)) | |
) | |
last_command = command # Used by S and T | |
if command == "M": | |
# Moveto command. | |
x = elements.pop() | |
y = elements.pop() | |
pos = float(x) + float(y) * 1j | |
if absolute: | |
current_pos = pos | |
else: | |
current_pos += pos | |
# M is not preceded by Z; it's an open subpath | |
if start_pos is not None: | |
pen.endPath() | |
pen.moveTo((current_pos.real, current_pos.imag)) | |
# when M is called, reset start_pos | |
# This behavior of Z is defined in svg spec: | |
# http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand | |
start_pos = current_pos | |
# Implicit moveto commands are treated as lineto commands. | |
# So we set command to lineto here, in case there are | |
# further implicit commands after this moveto. | |
command = "L" | |
elif command == "Z": | |
# Close path | |
if current_pos != start_pos: | |
pen.lineTo((start_pos.real, start_pos.imag)) | |
pen.closePath() | |
current_pos = start_pos | |
start_pos = None | |
command = None # You can't have implicit commands after closing. | |
elif command == "L": | |
x = elements.pop() | |
y = elements.pop() | |
pos = float(x) + float(y) * 1j | |
if not absolute: | |
pos += current_pos | |
pen.lineTo((pos.real, pos.imag)) | |
current_pos = pos | |
elif command == "H": | |
x = elements.pop() | |
pos = float(x) + current_pos.imag * 1j | |
if not absolute: | |
pos += current_pos.real | |
pen.lineTo((pos.real, pos.imag)) | |
current_pos = pos | |
elif command == "V": | |
y = elements.pop() | |
pos = current_pos.real + float(y) * 1j | |
if not absolute: | |
pos += current_pos.imag * 1j | |
pen.lineTo((pos.real, pos.imag)) | |
current_pos = pos | |
elif command == "C": | |
control1 = float(elements.pop()) + float(elements.pop()) * 1j | |
control2 = float(elements.pop()) + float(elements.pop()) * 1j | |
end = float(elements.pop()) + float(elements.pop()) * 1j | |
if not absolute: | |
control1 += current_pos | |
control2 += current_pos | |
end += current_pos | |
pen.curveTo( | |
(control1.real, control1.imag), | |
(control2.real, control2.imag), | |
(end.real, end.imag), | |
) | |
current_pos = end | |
last_control = control2 | |
elif command == "S": | |
# Smooth curve. First control point is the "reflection" of | |
# the second control point in the previous path. | |
if last_command not in "CS": | |
# 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 | |
else: | |
# 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 - last_control | |
control2 = float(elements.pop()) + float(elements.pop()) * 1j | |
end = float(elements.pop()) + float(elements.pop()) * 1j | |
if not absolute: | |
control2 += current_pos | |
end += current_pos | |
pen.curveTo( | |
(control1.real, control1.imag), | |
(control2.real, control2.imag), | |
(end.real, end.imag), | |
) | |
current_pos = end | |
last_control = control2 | |
elif command == "Q": | |
control = float(elements.pop()) + float(elements.pop()) * 1j | |
end = float(elements.pop()) + float(elements.pop()) * 1j | |
if not absolute: | |
control += current_pos | |
end += current_pos | |
pen.qCurveTo((control.real, control.imag), (end.real, end.imag)) | |
current_pos = end | |
last_control = control | |
elif command == "T": | |
# Smooth curve. Control point is the "reflection" of | |
# the second control point in the previous path. | |
if last_command not in "QT": | |
# 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 | |
else: | |
# 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 - last_control | |
end = float(elements.pop()) + float(elements.pop()) * 1j | |
if not absolute: | |
end += current_pos | |
pen.qCurveTo((control.real, control.imag), (end.real, end.imag)) | |
current_pos = end | |
last_control = control | |
elif command == "A": | |
rx = abs(float(elements.pop())) | |
ry = abs(float(elements.pop())) | |
rotation = float(elements.pop()) | |
arc_large = bool(int(elements.pop())) | |
arc_sweep = bool(int(elements.pop())) | |
end = float(elements.pop()) + float(elements.pop()) * 1j | |
if not absolute: | |
end += current_pos | |
# if the pen supports arcs, pass the values unchanged, otherwise | |
# approximate the arc with a series of cubic bezier curves | |
if have_arcTo: | |
pen.arcTo( | |
rx, | |
ry, | |
rotation, | |
arc_large, | |
arc_sweep, | |
(end.real, end.imag), | |
) | |
else: | |
arc = arc_class( | |
current_pos, rx, ry, rotation, arc_large, arc_sweep, end | |
) | |
arc.draw(pen) | |
current_pos = end | |
# no final Z command, it's an open path | |
if start_pos is not None: | |
pen.endPath() | |