• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# SVG Path specification parser.
2# This is an adaptation from 'svg.path' by Lennart Regebro (@regebro),
3# modified so that the parser takes a FontTools Pen object instead of
4# returning a list of svg.path Path objects.
5# The original code can be found at:
6# https://github.com/regebro/svg.path/blob/4f9b6e3/src/svg/path/parser.py
7# Copyright (c) 2013-2014 Lennart Regebro
8# License: MIT
9
10from .arc import EllipticalArc
11import re
12
13
14COMMANDS = set('MmZzLlHhVvCcSsQqTtAa')
15ARC_COMMANDS = set("Aa")
16UPPERCASE = set('MZLHVCSQTA')
17
18COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])")
19FLOAT_RE = re.compile(
20    r"[-+]?"  # optional sign
21    r"(?:"
22    r"(?:0|[1-9][0-9]*)(?:\.[0-9]+(?:[eE][-+]?[0-9]+)?)?"  # int/float
23    r"|"
24    r"(?:\.[0-9]+(?:[eE][-+]?[0-9]+)?)"  # float with leading dot (e.g. '.42')
25    r")"
26)
27BOOL_RE = re.compile("^[01]")
28SEPARATOR_RE = re.compile(f"[, \t]")
29
30
31def _tokenize_path(pathdef):
32    arc_cmd = None
33    for x in COMMAND_RE.split(pathdef):
34        if x in COMMANDS:
35            arc_cmd = x if x in ARC_COMMANDS else None
36            yield x
37            continue
38
39        if arc_cmd:
40            try:
41                yield from _tokenize_arc_arguments(x)
42            except ValueError as e:
43                raise ValueError(f"Invalid arc command: '{arc_cmd}{x}'") from e
44        else:
45            for token in FLOAT_RE.findall(x):
46                yield token
47
48
49ARC_ARGUMENT_TYPES = (
50    ("rx", FLOAT_RE),
51    ("ry", FLOAT_RE),
52    ("x-axis-rotation", FLOAT_RE),
53    ("large-arc-flag", BOOL_RE),
54    ("sweep-flag", BOOL_RE),
55    ("x", FLOAT_RE),
56    ("y", FLOAT_RE),
57)
58
59
60def _tokenize_arc_arguments(arcdef):
61    raw_args = [s for s in SEPARATOR_RE.split(arcdef) if s]
62    if not raw_args:
63        raise ValueError(f"Not enough arguments: '{arcdef}'")
64    raw_args.reverse()
65
66    i = 0
67    while raw_args:
68        arg = raw_args.pop()
69
70        name, pattern = ARC_ARGUMENT_TYPES[i]
71        match = pattern.search(arg)
72        if not match:
73            raise ValueError(f"Invalid argument for '{name}' parameter: {arg!r}")
74
75        j, k = match.span()
76        yield arg[j:k]
77        arg = arg[k:]
78
79        if arg:
80            raw_args.append(arg)
81
82        # wrap around every 7 consecutive arguments
83        if i == 6:
84            i = 0
85        else:
86            i += 1
87
88    if i != 0:
89        raise ValueError(f"Not enough arguments: '{arcdef}'")
90
91
92def parse_path(pathdef, pen, current_pos=(0, 0), arc_class=EllipticalArc):
93    """ Parse SVG path definition (i.e. "d" attribute of <path> elements)
94    and call a 'pen' object's moveTo, lineTo, curveTo, qCurveTo and closePath
95    methods.
96
97    If 'current_pos' (2-float tuple) is provided, the initial moveTo will
98    be relative to that instead being absolute.
99
100    If the pen has an "arcTo" method, it is called with the original values
101    of the elliptical arc curve commands:
102
103        pen.arcTo(rx, ry, rotation, arc_large, arc_sweep, (x, y))
104
105    Otherwise, the arcs are approximated by series of cubic Bezier segments
106    ("curveTo"), one every 90 degrees.
107    """
108    # In the SVG specs, initial movetos are absolute, even if
109    # specified as 'm'. This is the default behavior here as well.
110    # But if you pass in a current_pos variable, the initial moveto
111    # will be relative to that current_pos. This is useful.
112    current_pos = complex(*current_pos)
113
114    elements = list(_tokenize_path(pathdef))
115    # Reverse for easy use of .pop()
116    elements.reverse()
117
118    start_pos = None
119    command = None
120    last_control = None
121
122    have_arcTo = hasattr(pen, "arcTo")
123
124    while elements:
125
126        if elements[-1] in COMMANDS:
127            # New command.
128            last_command = command  # Used by S and T
129            command = elements.pop()
130            absolute = command in UPPERCASE
131            command = command.upper()
132        else:
133            # If this element starts with numbers, it is an implicit command
134            # and we don't change the command. Check that it's allowed:
135            if command is None:
136                raise ValueError("Unallowed implicit command in %s, position %s" % (
137                    pathdef, len(pathdef.split()) - len(elements)))
138            last_command = command  # Used by S and T
139
140        if command == 'M':
141            # Moveto command.
142            x = elements.pop()
143            y = elements.pop()
144            pos = float(x) + float(y) * 1j
145            if absolute:
146                current_pos = pos
147            else:
148                current_pos += pos
149
150            # M is not preceded by Z; it's an open subpath
151            if start_pos is not None:
152                pen.endPath()
153
154            pen.moveTo((current_pos.real, current_pos.imag))
155
156            # when M is called, reset start_pos
157            # This behavior of Z is defined in svg spec:
158            # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand
159            start_pos = current_pos
160
161            # Implicit moveto commands are treated as lineto commands.
162            # So we set command to lineto here, in case there are
163            # further implicit commands after this moveto.
164            command = 'L'
165
166        elif command == 'Z':
167            # Close path
168            if current_pos != start_pos:
169                pen.lineTo((start_pos.real, start_pos.imag))
170            pen.closePath()
171            current_pos = start_pos
172            start_pos = None
173            command = None  # You can't have implicit commands after closing.
174
175        elif command == 'L':
176            x = elements.pop()
177            y = elements.pop()
178            pos = float(x) + float(y) * 1j
179            if not absolute:
180                pos += current_pos
181            pen.lineTo((pos.real, pos.imag))
182            current_pos = pos
183
184        elif command == 'H':
185            x = elements.pop()
186            pos = float(x) + current_pos.imag * 1j
187            if not absolute:
188                pos += current_pos.real
189            pen.lineTo((pos.real, pos.imag))
190            current_pos = pos
191
192        elif command == 'V':
193            y = elements.pop()
194            pos = current_pos.real + float(y) * 1j
195            if not absolute:
196                pos += current_pos.imag * 1j
197            pen.lineTo((pos.real, pos.imag))
198            current_pos = pos
199
200        elif command == 'C':
201            control1 = float(elements.pop()) + float(elements.pop()) * 1j
202            control2 = float(elements.pop()) + float(elements.pop()) * 1j
203            end = float(elements.pop()) + float(elements.pop()) * 1j
204
205            if not absolute:
206                control1 += current_pos
207                control2 += current_pos
208                end += current_pos
209
210            pen.curveTo((control1.real, control1.imag),
211                        (control2.real, control2.imag),
212                        (end.real, end.imag))
213            current_pos = end
214            last_control = control2
215
216        elif command == 'S':
217            # Smooth curve. First control point is the "reflection" of
218            # the second control point in the previous path.
219
220            if last_command not in 'CS':
221                # If there is no previous command or if the previous command
222                # was not an C, c, S or s, assume the first control point is
223                # coincident with the current point.
224                control1 = current_pos
225            else:
226                # The first control point is assumed to be the reflection of
227                # the second control point on the previous command relative
228                # to the current point.
229                control1 = current_pos + current_pos - last_control
230
231            control2 = float(elements.pop()) + float(elements.pop()) * 1j
232            end = float(elements.pop()) + float(elements.pop()) * 1j
233
234            if not absolute:
235                control2 += current_pos
236                end += current_pos
237
238            pen.curveTo((control1.real, control1.imag),
239                        (control2.real, control2.imag),
240                        (end.real, end.imag))
241            current_pos = end
242            last_control = control2
243
244        elif command == 'Q':
245            control = float(elements.pop()) + float(elements.pop()) * 1j
246            end = float(elements.pop()) + float(elements.pop()) * 1j
247
248            if not absolute:
249                control += current_pos
250                end += current_pos
251
252            pen.qCurveTo((control.real, control.imag), (end.real, end.imag))
253            current_pos = end
254            last_control = control
255
256        elif command == 'T':
257            # Smooth curve. Control point is the "reflection" of
258            # the second control point in the previous path.
259
260            if last_command not in 'QT':
261                # If there is no previous command or if the previous command
262                # was not an Q, q, T or t, assume the first control point is
263                # coincident with the current point.
264                control = current_pos
265            else:
266                # The control point is assumed to be the reflection of
267                # the control point on the previous command relative
268                # to the current point.
269                control = current_pos + current_pos - last_control
270
271            end = float(elements.pop()) + float(elements.pop()) * 1j
272
273            if not absolute:
274                end += current_pos
275
276            pen.qCurveTo((control.real, control.imag), (end.real, end.imag))
277            current_pos = end
278            last_control = control
279
280        elif command == 'A':
281            rx = float(elements.pop())
282            ry = float(elements.pop())
283            rotation = float(elements.pop())
284            arc_large = bool(int(elements.pop()))
285            arc_sweep = bool(int(elements.pop()))
286            end = float(elements.pop()) + float(elements.pop()) * 1j
287
288            if not absolute:
289                end += current_pos
290
291            # if the pen supports arcs, pass the values unchanged, otherwise
292            # approximate the arc with a series of cubic bezier curves
293            if have_arcTo:
294                pen.arcTo(
295                    rx,
296                    ry,
297                    rotation,
298                    arc_large,
299                    arc_sweep,
300                    (end.real, end.imag),
301                )
302            else:
303                arc = arc_class(
304                    current_pos, rx, ry, rotation, arc_large, arc_sweep, end
305                )
306                arc.draw(pen)
307
308            current_pos = end
309
310    # no final Z command, it's an open path
311    if start_pos is not None:
312        pen.endPath()
313