From 66dccc28d43c5096748ff8b8c6e280a4b06f89fc Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 30 Jun 2026 12:26:17 +0200 Subject: [PATCH] Parse SVG arc paths with packed large-arc/sweep flags parse_path() raised IndexError on valid arc commands whose large-arc-flag and sweep-flag are written without a separator before the next number (e.g. 'A 5 5 0 0110 0', as emitted by SVGO and accepted by every browser). The tokenizer used FLOAT_RE.findall for every command, so it greedily read the packed flag pair '0110' as the single number 110, leaving the arc branch's seven pops to run the token list dry. Tokenize arc arguments flag-aware: read the two flag fields one '0'/'1' character at a time. The token stream for a packed arc is then identical to its spaced-out form, which the existing parser already handles. Fixes #129. --- svgpathtools/path.py | 42 ++++++++++++++++++++++++++++++++++++++++-- test/test_parsing.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/svgpathtools/path.py b/svgpathtools/path.py index 9aebf33b..37e0cfa0 100644 --- a/svgpathtools/path.py +++ b/svgpathtools/path.py @@ -46,6 +46,35 @@ COMMAND_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])") FLOAT_RE = re.compile(r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?") +# In an elliptical arc argument the large-arc-flag and sweep-flag are each a +# single "0"/"1" character that, unlike the other fields, may be written with +# no separator before the next number (e.g. "0110 0" == "0 1 10 0"); see the +# SVG path grammar: https://www.w3.org/TR/SVG/paths.html#PathDataBNF +ARC_FLAG_RE = re.compile(r"[01]") +WSP_COMMA_RE = re.compile(r"[\s,]*") + + +def _tokenize_arc_args(arg_chunk): + """Yield the tokens of one or more seven-field elliptical-arc groups.""" + pos = 0 + field = 0 + n = len(arg_chunk) + while True: + sep = WSP_COMMA_RE.match(arg_chunk, pos) + if sep: + pos = sep.end() + if pos >= n: + return + match = None + if field % 7 in (3, 4): + match = ARC_FLAG_RE.match(arg_chunk, pos) + if match is None: + match = FLOAT_RE.match(arg_chunk, pos) + if match is None: + return + yield match.group() + pos = match.end() + field += 1 # Default Parameters ########################################################## @@ -3191,11 +3220,20 @@ def joints(self): return zip(a, b) def _tokenize_path(self, pathdef): + command = None for x in COMMAND_RE.split(pathdef): if x in COMMANDS: + command = x yield x - for token in FLOAT_RE.findall(x): - yield token + continue + if command in ('A', 'a'): + # Arc arguments need flag-aware tokenizing; the other commands + # only carry plain numbers. + for token in _tokenize_arc_args(x): + yield token + else: + for token in FLOAT_RE.findall(x): + yield token def _parse_path(self, pathdef, current_pos=0j, tree_element=None): # In the SVG specs, initial movetos are absolute, even if diff --git a/test/test_parsing.py b/test/test_parsing.py index cf33b1f7..6c9e238f 100644 --- a/test/test_parsing.py +++ b/test/test_parsing.py @@ -170,6 +170,36 @@ def test_negative(self): path2 = parse_path('M 100 200 c 10 -5 20 -10 30 -20') self.assertEqual(path1, path2) + def test_arc_flag_packing(self): + """Arc flags may run straight into the next number (#129).""" + # Each packed form must parse identically to the spaced-out form. + equivalent = [ + ('M 0 0 A 5 5 0 0110 0', 'M 0 0 A 5 5 0 0 1 10 0'), + ('M 0 0 A 5 5 0 1110 0', 'M 0 0 A 5 5 0 1 1 10 0'), + ('M 0 0 A 5 5 0 0010 0', 'M 0 0 A 5 5 0 0 0 10 0'), + ('M 0 0 a 5 5 0 0010 0', 'M 0 0 a 5 5 0 0 0 10 0'), + ('M 0 0 A 5 5 0 01-10 0', 'M 0 0 A 5 5 0 0 1 -10 0'), + ('M 0 0 A 5 5 0 0 1 10 0 5 5 0 1120 0', + 'M 0 0 A 5 5 0 0 1 10 0 5 5 0 1 1 20 0'), + ] + for packed, spaced in equivalent: + self.assertEqual(parse_path(packed), parse_path(spaced)) + + # The flags carry through to the resulting Arc. + arc = parse_path('M 0 0 A 5 5 0 0110 0')[0] + self.assertFalse(arc.large_arc) + self.assertTrue(arc.sweep) + self.assertEqual(arc.end, 10 + 0j) + + # Real-world path from issue #129 (simple-icons "emlakjet") with packed + # "00" flags; this used to raise IndexError. + icon = 'M12 0a3.543 3.543 0 00-1.267 2.471A3.543 3.543 0 0012 0z' + path = parse_path(icon) + self.assertEqual(len(path), 2) + self.assertTrue(all(isinstance(seg, Arc) for seg in path)) + self.assertFalse(path[0].large_arc) + self.assertFalse(path[0].sweep) + def test_numbers(self): """Exponents and other number format cases""" # It can be e or E, the plus is optional, and a minimum of