adjust parsing to raise nicer exceptions

pull/213/head
snoyer 2023-08-30 20:06:08 +04:00
parent fcb648b9bb
commit 21ca2fe6a8
2 changed files with 91 additions and 40 deletions

View File

@ -43,8 +43,15 @@ except NameError:
COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') COMMANDS = set('MmZzLlHhVvCcSsQqTtAa')
UPPERCASE = set('MZLHVCSQTA') UPPERCASE = set('MZLHVCSQTA')
COMMAND_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])") TOKEN_RE = re.compile(r"""
FLOAT_RE = re.compile(r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?") (
[MmZzLlHhVvCcSsQqTtAa] # command
|
(?:[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?) # float
)
""", re.VERBOSE
)
SEPARATORS = ', \t\r\n'
# Default Parameters ########################################################## # Default Parameters ##########################################################
@ -3179,19 +3186,28 @@ class Path(MutableSequence):
next(b, None) next(b, None)
return zip(a, b) return zip(a, b)
def _tokenize_path(self, pathdef): @staticmethod
for x in COMMAND_RE.split(pathdef): def _tokenize_path(*pathdef_lines):
if x in COMMANDS: # yield line number and offset in addition to token
yield x # so we can raise syntax errors
for token in FLOAT_RE.findall(x): for lineno, line in enumerate(pathdef_lines):
yield token start = 0
for token in TOKEN_RE.split(line):
if token.strip(SEPARATORS):
yield token, lineno, start
start += len(token)
def _parse_path(self, pathdef, current_pos=0j, tree_element=None): def _parse_path(self, pathdef, current_pos=0j, tree_element=None):
# In the SVG specs, initial movetos are absolute, even if # In the SVG specs, initial movetos are absolute, even if
# specified as 'm'. This is the default behavior here as well. # specified as 'm'. This is the default behavior here as well.
# But if you pass in a current_pos variable, the initial moveto # But if you pass in a current_pos variable, the initial moveto
# will be relative to that current_pos. This is useful. # will be relative to that current_pos. This is useful.
elements = list(self._tokenize_path(pathdef))
# we need to keep the pathdef split by lines so we can retrieve a specific line
# to throw syntax errors
pathdef_lines = pathdef.splitlines()
elements = list(self._tokenize_path(*pathdef_lines))
# Reverse for easy use of .pop() # Reverse for easy use of .pop()
elements.reverse() elements.reverse()
@ -3200,27 +3216,43 @@ class Path(MutableSequence):
start_pos = None start_pos = None
command = None command = None
while elements: def pop_float(elements):
try:
token, lineno, start = elements.pop()
try:
return float(token)
except ValueError:
line = pathdef_lines[lineno]
end = start + len(token)
raise self._syntax_error('invalid token %r' % token, lineno, line, start, end)
except IndexError:
lineno = len(pathdef_lines) - 1
line = pathdef_lines[lineno]
end = len(line) - 1
raise self._syntax_error('not enough arguments', lineno, line, end, end)
if elements[-1] in COMMANDS: while elements:
if elements[-1][0] in COMMANDS:
# New command. # New command.
last_command = command # Used by S and T last_command = command # Used by S and T
command = elements.pop() command = elements.pop()[0]
absolute = command in UPPERCASE absolute = command in UPPERCASE
command = command.upper() command = command.upper()
else: else:
# If this element starts with numbers, it is an implicit command # If this element starts with numbers, it is an implicit command
# and we don't change the command. Check that it's allowed: # and we don't change the command. Check that it's allowed:
if command is None: if command is None:
raise ValueError("Unallowed implicit command in %s, position %s" % ( token, lineno, start = elements[-1]
pathdef, len(pathdef.split()) - len(elements))) end = start + len(token)
line = pathdef_lines[lineno]
raise self._syntax_error("missing command", lineno, line, start, end)
last_command = command # Used by S and T last_command = command # Used by S and T
if command == 'M': if command == 'M':
# Moveto command. # Moveto command.
x = elements.pop() x = pop_float(elements)
y = elements.pop() y = pop_float(elements)
pos = float(x) + float(y) * 1j pos = x + y * 1j
if absolute: if absolute:
current_pos = pos current_pos = pos
else: else:
@ -3245,34 +3277,34 @@ class Path(MutableSequence):
command = None command = None
elif command == 'L': elif command == 'L':
x = elements.pop() x = pop_float(elements)
y = elements.pop() y = pop_float(elements)
pos = float(x) + float(y) * 1j pos = x + y * 1j
if not absolute: if not absolute:
pos += current_pos pos += current_pos
segments.append(Line(current_pos, pos)) segments.append(Line(current_pos, pos))
current_pos = pos current_pos = pos
elif command == 'H': elif command == 'H':
x = elements.pop() x = pop_float(elements)
pos = float(x) + current_pos.imag * 1j pos = x + current_pos.imag * 1j
if not absolute: if not absolute:
pos += current_pos.real pos += current_pos.real
segments.append(Line(current_pos, pos)) segments.append(Line(current_pos, pos))
current_pos = pos current_pos = pos
elif command == 'V': elif command == 'V':
y = elements.pop() y = pop_float(elements)
pos = current_pos.real + float(y) * 1j pos = current_pos.real + y * 1j
if not absolute: if not absolute:
pos += current_pos.imag * 1j pos += current_pos.imag * 1j
segments.append(Line(current_pos, pos)) segments.append(Line(current_pos, pos))
current_pos = pos current_pos = pos
elif command == 'C': elif command == 'C':
control1 = float(elements.pop()) + float(elements.pop()) * 1j control1 = pop_float(elements) + pop_float(elements) * 1j
control2 = float(elements.pop()) + float(elements.pop()) * 1j control2 = pop_float(elements) + pop_float(elements) * 1j
end = float(elements.pop()) + float(elements.pop()) * 1j end = pop_float(elements) + pop_float(elements) * 1j
if not absolute: if not absolute:
control1 += current_pos control1 += current_pos
@ -3297,8 +3329,8 @@ class Path(MutableSequence):
# to the current point. # to the current point.
control1 = current_pos + current_pos - segments[-1].control2 control1 = current_pos + current_pos - segments[-1].control2
control2 = float(elements.pop()) + float(elements.pop()) * 1j control2 = pop_float(elements) + pop_float(elements) * 1j
end = float(elements.pop()) + float(elements.pop()) * 1j end = pop_float(elements) + pop_float(elements) * 1j
if not absolute: if not absolute:
control2 += current_pos control2 += current_pos
@ -3308,8 +3340,8 @@ class Path(MutableSequence):
current_pos = end current_pos = end
elif command == 'Q': elif command == 'Q':
control = float(elements.pop()) + float(elements.pop()) * 1j control = pop_float(elements) + pop_float(elements) * 1j
end = float(elements.pop()) + float(elements.pop()) * 1j end = pop_float(elements) + pop_float(elements) * 1j
if not absolute: if not absolute:
control += current_pos control += current_pos
@ -3333,7 +3365,7 @@ class Path(MutableSequence):
# to the current point. # to the current point.
control = current_pos + current_pos - segments[-1].control control = current_pos + current_pos - segments[-1].control
end = float(elements.pop()) + float(elements.pop()) * 1j end = pop_float(elements) + pop_float(elements) * 1j
if not absolute: if not absolute:
end += current_pos end += current_pos
@ -3343,11 +3375,11 @@ class Path(MutableSequence):
elif command == 'A': elif command == 'A':
radius = float(elements.pop()) + float(elements.pop()) * 1j radius = pop_float(elements) + pop_float(elements) * 1j
rotation = float(elements.pop()) rotation = pop_float(elements)
arc = float(elements.pop()) arc = pop_float(elements)
sweep = float(elements.pop()) sweep = pop_float(elements)
end = float(elements.pop()) + float(elements.pop()) * 1j end = pop_float(elements) + pop_float(elements) * 1j
if not absolute: if not absolute:
end += current_pos end += current_pos
@ -3369,3 +3401,12 @@ class Path(MutableSequence):
current_pos = end current_pos = end
return segments return segments
@classmethod
def _syntax_error(cls, msg, lineno, line, offset,end_offset):
filename = '<svg-d-string>'
try:
return SyntaxError(msg, (filename, lineno+1, offset + 1, line, lineno, end_offset + 1))
except IndexError:
# on python < 3.10
return SyntaxError(msg, (filename, lineno+1, offset + 1, line))

View File

@ -178,10 +178,20 @@ class TestParser(unittest.TestCase):
path2 = Path(Line(-3.4e+38 + 3.4e+38j, -3.4e-38 + 3.4e-38j)) path2 = Path(Line(-3.4e+38 + 3.4e+38j, -3.4e-38 + 3.4e-38j))
self.assertEqual(path1, path2) self.assertEqual(path1, path2)
def test_errors(self): def test_error_missing_command(self):
self.assertRaises(ValueError, parse_path, with self.assertRaises(SyntaxError) as e:
'M 100 100 L 200 200 Z 100 200') parse_path('M 100 100 L 200 200 Z 100 200')
assert "missing command" in e.exception.msg
def test_error_invalid_token(self):
with self.assertRaises(SyntaxError) as e:
parse_path("M 0 \n1 N 2 3")
assert "invalid token" in e.exception.msg
def test_error_not_enough_arguments(self):
with self.assertRaises(SyntaxError) as e:
Path("M 0 1\n 2")
assert "not enough arguments" in e.exception.msg
def test_transform(self): def test_transform(self):