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