123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- # 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()
|