| # SVG Path specification parser. |
| # This is an adaptation from 'svg.path' by Lennart Regebro (@regebro), |
| # modified so that the parser takes a FontTools Pen object instead of |
| # returning a list of svg.path Path objects. |
| # The original code can be found at: |
| # https://github.com/regebro/svg.path/blob/4f9b6e3/src/svg/path/parser.py |
| # Copyright (c) 2013-2014 Lennart Regebro |
| # License: MIT |
| |
| from .arc import EllipticalArc |
| import re |
| |
| |
| COMMANDS = set("MmZzLlHhVvCcSsQqTtAa") |
| ARC_COMMANDS = set("Aa") |
| UPPERCASE = set("MZLHVCSQTA") |
| |
| COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])") |
| |
| # https://www.w3.org/TR/css-syntax-3/#number-token-diagram |
| # but -6.e-5 will be tokenized as "-6" then "-5" and confuse parsing |
| FLOAT_RE = re.compile( |
| r"[-+]?" # optional sign |
| r"(?:" |
| r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?" # int/float |
| r"|" |
| r"(?:\.[0-9]+(?:[eE][-+]?[0-9]+)?)" # float with leading dot (e.g. '.42') |
| r")" |
| ) |
| BOOL_RE = re.compile("^[01]") |
| SEPARATOR_RE = re.compile(f"[, \t]") |
| |
| |
| def _tokenize_path(pathdef): |
| arc_cmd = None |
| for x in COMMAND_RE.split(pathdef): |
| if x in COMMANDS: |
| arc_cmd = x if x in ARC_COMMANDS else None |
| yield x |
| continue |
| |
| if arc_cmd: |
| try: |
| yield from _tokenize_arc_arguments(x) |
| except ValueError as e: |
| raise ValueError(f"Invalid arc command: '{arc_cmd}{x}'") from e |
| else: |
| for token in FLOAT_RE.findall(x): |
| yield token |
| |
| |
| ARC_ARGUMENT_TYPES = ( |
| ("rx", FLOAT_RE), |
| ("ry", FLOAT_RE), |
| ("x-axis-rotation", FLOAT_RE), |
| ("large-arc-flag", BOOL_RE), |
| ("sweep-flag", BOOL_RE), |
| ("x", FLOAT_RE), |
| ("y", FLOAT_RE), |
| ) |
| |
| |
| def _tokenize_arc_arguments(arcdef): |
| raw_args = [s for s in SEPARATOR_RE.split(arcdef) if s] |
| if not raw_args: |
| raise ValueError(f"Not enough arguments: '{arcdef}'") |
| raw_args.reverse() |
| |
| i = 0 |
| while raw_args: |
| arg = raw_args.pop() |
| |
| name, pattern = ARC_ARGUMENT_TYPES[i] |
| match = pattern.search(arg) |
| if not match: |
| raise ValueError(f"Invalid argument for '{name}' parameter: {arg!r}") |
| |
| j, k = match.span() |
| yield arg[j:k] |
| arg = arg[k:] |
| |
| if arg: |
| raw_args.append(arg) |
| |
| # wrap around every 7 consecutive arguments |
| if i == 6: |
| i = 0 |
| else: |
| i += 1 |
| |
| if i != 0: |
| raise ValueError(f"Not enough arguments: '{arcdef}'") |
| |
| |
| def parse_path(pathdef, pen, current_pos=(0, 0), arc_class=EllipticalArc): |
| """Parse SVG path definition (i.e. "d" attribute of <path> elements) |
| and call a 'pen' object's moveTo, lineTo, curveTo, qCurveTo and closePath |
| methods. |
| |
| If 'current_pos' (2-float tuple) is provided, the initial moveTo will |
| be relative to that instead being absolute. |
| |
| If the pen has an "arcTo" method, it is called with the original values |
| of the elliptical arc curve commands: |
| |
| pen.arcTo(rx, ry, rotation, arc_large, arc_sweep, (x, y)) |
| |
| Otherwise, the arcs are approximated by series of cubic Bezier segments |
| ("curveTo"), one every 90 degrees. |
| """ |
| # In the SVG specs, initial movetos are absolute, even if |
| # specified as 'm'. This is the default behavior here as well. |
| # But if you pass in a current_pos variable, the initial moveto |
| # will be relative to that current_pos. This is useful. |
| current_pos = complex(*current_pos) |
| |
| elements = list(_tokenize_path(pathdef)) |
| # Reverse for easy use of .pop() |
| elements.reverse() |
| |
| start_pos = None |
| command = None |
| last_control = None |
| |
| have_arcTo = hasattr(pen, "arcTo") |
| |
| while elements: |
| if elements[-1] in COMMANDS: |
| # New command. |
| last_command = command # Used by S and T |
| command = elements.pop() |
| absolute = command in UPPERCASE |
| command = command.upper() |
| else: |
| # If this element starts with numbers, it is an implicit command |
| # and we don't change the command. Check that it's allowed: |
| if command is None: |
| raise ValueError( |
| "Unallowed implicit command in %s, position %s" |
| % (pathdef, len(pathdef.split()) - len(elements)) |
| ) |
| last_command = command # Used by S and T |
| |
| if command == "M": |
| # Moveto command. |
| x = elements.pop() |
| y = elements.pop() |
| pos = float(x) + float(y) * 1j |
| if absolute: |
| current_pos = pos |
| else: |
| current_pos += pos |
| |
| # M is not preceded by Z; it's an open subpath |
| if start_pos is not None: |
| pen.endPath() |
| |
| pen.moveTo((current_pos.real, current_pos.imag)) |
| |
| # when M is called, reset start_pos |
| # This behavior of Z is defined in svg spec: |
| # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand |
| start_pos = current_pos |
| |
| # Implicit moveto commands are treated as lineto commands. |
| # So we set command to lineto here, in case there are |
| # further implicit commands after this moveto. |
| command = "L" |
| |
| elif command == "Z": |
| # Close path |
| if current_pos != start_pos: |
| pen.lineTo((start_pos.real, start_pos.imag)) |
| pen.closePath() |
| current_pos = start_pos |
| start_pos = None |
| command = None # You can't have implicit commands after closing. |
| |
| elif command == "L": |
| x = elements.pop() |
| y = elements.pop() |
| pos = float(x) + float(y) * 1j |
| if not absolute: |
| pos += current_pos |
| pen.lineTo((pos.real, pos.imag)) |
| current_pos = pos |
| |
| elif command == "H": |
| x = elements.pop() |
| pos = float(x) + current_pos.imag * 1j |
| if not absolute: |
| pos += current_pos.real |
| pen.lineTo((pos.real, pos.imag)) |
| current_pos = pos |
| |
| elif command == "V": |
| y = elements.pop() |
| pos = current_pos.real + float(y) * 1j |
| if not absolute: |
| pos += current_pos.imag * 1j |
| pen.lineTo((pos.real, pos.imag)) |
| current_pos = pos |
| |
| elif command == "C": |
| control1 = float(elements.pop()) + float(elements.pop()) * 1j |
| control2 = float(elements.pop()) + float(elements.pop()) * 1j |
| end = float(elements.pop()) + float(elements.pop()) * 1j |
| |
| if not absolute: |
| control1 += current_pos |
| control2 += current_pos |
| end += current_pos |
| |
| pen.curveTo( |
| (control1.real, control1.imag), |
| (control2.real, control2.imag), |
| (end.real, end.imag), |
| ) |
| current_pos = end |
| last_control = control2 |
| |
| elif command == "S": |
| # Smooth curve. First control point is the "reflection" of |
| # the second control point in the previous path. |
| |
| if last_command not in "CS": |
| # If there is no previous command or if the previous command |
| # was not an C, c, S or s, assume the first control point is |
| # coincident with the current point. |
| control1 = current_pos |
| else: |
| # The first control point is assumed to be the reflection of |
| # the second control point on the previous command relative |
| # to the current point. |
| control1 = current_pos + current_pos - last_control |
| |
| control2 = float(elements.pop()) + float(elements.pop()) * 1j |
| end = float(elements.pop()) + float(elements.pop()) * 1j |
| |
| if not absolute: |
| control2 += current_pos |
| end += current_pos |
| |
| pen.curveTo( |
| (control1.real, control1.imag), |
| (control2.real, control2.imag), |
| (end.real, end.imag), |
| ) |
| current_pos = end |
| last_control = control2 |
| |
| elif command == "Q": |
| control = float(elements.pop()) + float(elements.pop()) * 1j |
| end = float(elements.pop()) + float(elements.pop()) * 1j |
| |
| if not absolute: |
| control += current_pos |
| end += current_pos |
| |
| pen.qCurveTo((control.real, control.imag), (end.real, end.imag)) |
| current_pos = end |
| last_control = control |
| |
| elif command == "T": |
| # Smooth curve. Control point is the "reflection" of |
| # the second control point in the previous path. |
| |
| if last_command not in "QT": |
| # If there is no previous command or if the previous command |
| # was not an Q, q, T or t, assume the first control point is |
| # coincident with the current point. |
| control = current_pos |
| else: |
| # The control point is assumed to be the reflection of |
| # the control point on the previous command relative |
| # to the current point. |
| control = current_pos + current_pos - last_control |
| |
| end = float(elements.pop()) + float(elements.pop()) * 1j |
| |
| if not absolute: |
| end += current_pos |
| |
| pen.qCurveTo((control.real, control.imag), (end.real, end.imag)) |
| current_pos = end |
| last_control = control |
| |
| elif command == "A": |
| rx = abs(float(elements.pop())) |
| ry = abs(float(elements.pop())) |
| rotation = float(elements.pop()) |
| arc_large = bool(int(elements.pop())) |
| arc_sweep = bool(int(elements.pop())) |
| end = float(elements.pop()) + float(elements.pop()) * 1j |
| |
| if not absolute: |
| end += current_pos |
| |
| # if the pen supports arcs, pass the values unchanged, otherwise |
| # approximate the arc with a series of cubic bezier curves |
| if have_arcTo: |
| pen.arcTo( |
| rx, |
| ry, |
| rotation, |
| arc_large, |
| arc_sweep, |
| (end.real, end.imag), |
| ) |
| else: |
| arc = arc_class( |
| current_pos, rx, ry, rotation, arc_large, arc_sweep, end |
| ) |
| arc.draw(pen) |
| |
| current_pos = end |
| |
| # no final Z command, it's an open path |
| if start_pos is not None: |
| pen.endPath() |