From 4abb57b3bbeacdc7b97baf5a1f3663ec871851ed Mon Sep 17 00:00:00 2001 From: eeshsaxena Date: Fri, 14 Aug 2026 19:37:43 +0530 Subject: [PATCH] Raise ValueError for truncated path commands instead of IndexError A path string that ends mid-command (e.g. 'M 0 0 L', 'C 1 2', a bare 'H') popped past the end of the token list in _parse_path, surfacing a bare IndexError: pop from empty list. Add a per-command argument-count check so these raise a descriptive ValueError like other malformed paths. Also fix a leading smooth command (a path starting with S/s or T/t): the reflection check compared last_command (None) with 'in "CS"', raising TypeError. Compare against a tuple so a missing previous command is handled. --- svgpathtools/path.py | 17 +++++++++++++++-- test/test_parsing.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/svgpathtools/path.py b/svgpathtools/path.py index 9aebf33b..96d29dfb 100644 --- a/svgpathtools/path.py +++ b/svgpathtools/path.py @@ -44,6 +44,10 @@ COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') UPPERCASE = set('MZLHVCSQTA') +# Number of numeric parameters each command consumes per repetition. +COMMAND_NUM_ARGS = {'M': 2, 'L': 2, 'H': 1, 'V': 1, 'C': 6, + 'S': 4, 'Q': 4, 'T': 2, 'A': 7, 'Z': 0} + COMMAND_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])") FLOAT_RE = re.compile(r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?") @@ -3227,6 +3231,15 @@ def _parse_path(self, pathdef, current_pos=0j, tree_element=None): pathdef, len(pathdef.split()) - len(elements))) last_command = command # Used by S and T + # A command must be followed by its full set of coordinates; a + # truncated path (e.g. "M 0 0 L") would otherwise pop past the end + # of the token list and raise a bare IndexError. + if len(elements) < COMMAND_NUM_ARGS[command]: + raise ValueError("Invalid path string: command '%s' expects %d " + "values but only %d remain in %r" % ( + command, COMMAND_NUM_ARGS[command], + len(elements), pathdef)) + if command == 'M': # Moveto command. x = elements.pop() @@ -3297,7 +3310,7 @@ def _parse_path(self, pathdef, current_pos=0j, tree_element=None): # Smooth curve. First control point is the "reflection" of # the second control point in the previous path. - if last_command not in 'CS': + if last_command not in ('C', 'S'): # 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. @@ -3333,7 +3346,7 @@ def _parse_path(self, pathdef, current_pos=0j, tree_element=None): # Smooth curve. Control point is the "reflection" of # the second control point in the previous path. - if last_command not in 'QT': + if last_command not in ('Q', 'T'): # 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. diff --git a/test/test_parsing.py b/test/test_parsing.py index cf33b1f7..7eda86f0 100644 --- a/test/test_parsing.py +++ b/test/test_parsing.py @@ -182,6 +182,22 @@ def test_errors(self): self.assertRaises(ValueError, parse_path, 'M 100 100 L 200 200 Z 100 200') + def test_truncated_commands(self): + # A command missing some of its coordinates used to pop past the end of + # the token list and raise a bare IndexError instead of a ValueError. + for d in ('M', 'M 10', 'M 0 0 L', 'M 0 0 C 1 1 2 2', + 'H', 'V', 'Q 1 1', 'A 1 1 0 0 1'): + self.assertRaises(ValueError, parse_path, d) + + def test_leading_smooth_command(self): + # A path starting with a smooth command (S/T) has no previous control + # point to reflect; the first control point coincides with the current + # point. This used to raise a TypeError from ``None in 'CS'``. + self.assertEqual(parse_path('S 1 1 2 2'), + Path(CubicBezier(0j, 0j, 1 + 1j, 2 + 2j))) + self.assertEqual(parse_path('T 1 1'), + Path(QuadraticBezier(0j, 0j, 1 + 1j))) + def test_transform(self):