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