300 lines
8.9 KiB
Python
300 lines
8.9 KiB
Python
# 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
|