diff --git a/svgpathtools/path.py b/svgpathtools/path.py index 9aebf33..37e0cfa 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 cf33b1f..6c9e238 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