323 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			323 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| # 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:
 | |
| 
 | |
|     .. code-block::
 | |
| 
 | |
|         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()
 |