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