parser.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  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. from .arc import EllipticalArc
  10. import re
  11. COMMANDS = set("MmZzLlHhVvCcSsQqTtAa")
  12. ARC_COMMANDS = set("Aa")
  13. UPPERCASE = set("MZLHVCSQTA")
  14. COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])")
  15. # https://www.w3.org/TR/css-syntax-3/#number-token-diagram
  16. # but -6.e-5 will be tokenized as "-6" then "-5" and confuse parsing
  17. FLOAT_RE = re.compile(
  18. r"[-+]?" # optional sign
  19. r"(?:"
  20. r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?" # int/float
  21. r"|"
  22. r"(?:\.[0-9]+(?:[eE][-+]?[0-9]+)?)" # float with leading dot (e.g. '.42')
  23. r")"
  24. )
  25. BOOL_RE = re.compile("^[01]")
  26. SEPARATOR_RE = re.compile(f"[, \t]")
  27. def _tokenize_path(pathdef):
  28. arc_cmd = None
  29. for x in COMMAND_RE.split(pathdef):
  30. if x in COMMANDS:
  31. arc_cmd = x if x in ARC_COMMANDS else None
  32. yield x
  33. continue
  34. if arc_cmd:
  35. try:
  36. yield from _tokenize_arc_arguments(x)
  37. except ValueError as e:
  38. raise ValueError(f"Invalid arc command: '{arc_cmd}{x}'") from e
  39. else:
  40. for token in FLOAT_RE.findall(x):
  41. yield token
  42. ARC_ARGUMENT_TYPES = (
  43. ("rx", FLOAT_RE),
  44. ("ry", FLOAT_RE),
  45. ("x-axis-rotation", FLOAT_RE),
  46. ("large-arc-flag", BOOL_RE),
  47. ("sweep-flag", BOOL_RE),
  48. ("x", FLOAT_RE),
  49. ("y", FLOAT_RE),
  50. )
  51. def _tokenize_arc_arguments(arcdef):
  52. raw_args = [s for s in SEPARATOR_RE.split(arcdef) if s]
  53. if not raw_args:
  54. raise ValueError(f"Not enough arguments: '{arcdef}'")
  55. raw_args.reverse()
  56. i = 0
  57. while raw_args:
  58. arg = raw_args.pop()
  59. name, pattern = ARC_ARGUMENT_TYPES[i]
  60. match = pattern.search(arg)
  61. if not match:
  62. raise ValueError(f"Invalid argument for '{name}' parameter: {arg!r}")
  63. j, k = match.span()
  64. yield arg[j:k]
  65. arg = arg[k:]
  66. if arg:
  67. raw_args.append(arg)
  68. # wrap around every 7 consecutive arguments
  69. if i == 6:
  70. i = 0
  71. else:
  72. i += 1
  73. if i != 0:
  74. raise ValueError(f"Not enough arguments: '{arcdef}'")
  75. def parse_path(pathdef, pen, current_pos=(0, 0), arc_class=EllipticalArc):
  76. """Parse SVG path definition (i.e. "d" attribute of <path> elements)
  77. and call a 'pen' object's moveTo, lineTo, curveTo, qCurveTo and closePath
  78. methods.
  79. If 'current_pos' (2-float tuple) is provided, the initial moveTo will
  80. be relative to that instead being absolute.
  81. If the pen has an "arcTo" method, it is called with the original values
  82. of the elliptical arc curve commands:
  83. pen.arcTo(rx, ry, rotation, arc_large, arc_sweep, (x, y))
  84. Otherwise, the arcs are approximated by series of cubic Bezier segments
  85. ("curveTo"), one every 90 degrees.
  86. """
  87. # In the SVG specs, initial movetos are absolute, even if
  88. # specified as 'm'. This is the default behavior here as well.
  89. # But if you pass in a current_pos variable, the initial moveto
  90. # will be relative to that current_pos. This is useful.
  91. current_pos = complex(*current_pos)
  92. elements = list(_tokenize_path(pathdef))
  93. # Reverse for easy use of .pop()
  94. elements.reverse()
  95. start_pos = None
  96. command = None
  97. last_control = None
  98. have_arcTo = hasattr(pen, "arcTo")
  99. while elements:
  100. if elements[-1] in COMMANDS:
  101. # New command.
  102. last_command = command # Used by S and T
  103. command = elements.pop()
  104. absolute = command in UPPERCASE
  105. command = command.upper()
  106. else:
  107. # If this element starts with numbers, it is an implicit command
  108. # and we don't change the command. Check that it's allowed:
  109. if command is None:
  110. raise ValueError(
  111. "Unallowed implicit command in %s, position %s"
  112. % (pathdef, len(pathdef.split()) - len(elements))
  113. )
  114. last_command = command # Used by S and T
  115. if command == "M":
  116. # Moveto command.
  117. x = elements.pop()
  118. y = elements.pop()
  119. pos = float(x) + float(y) * 1j
  120. if absolute:
  121. current_pos = pos
  122. else:
  123. current_pos += pos
  124. # M is not preceded by Z; it's an open subpath
  125. if start_pos is not None:
  126. pen.endPath()
  127. pen.moveTo((current_pos.real, current_pos.imag))
  128. # when M is called, reset start_pos
  129. # This behavior of Z is defined in svg spec:
  130. # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand
  131. start_pos = current_pos
  132. # Implicit moveto commands are treated as lineto commands.
  133. # So we set command to lineto here, in case there are
  134. # further implicit commands after this moveto.
  135. command = "L"
  136. elif command == "Z":
  137. # Close path
  138. if current_pos != start_pos:
  139. pen.lineTo((start_pos.real, start_pos.imag))
  140. pen.closePath()
  141. current_pos = start_pos
  142. start_pos = None
  143. command = None # You can't have implicit commands after closing.
  144. elif command == "L":
  145. x = elements.pop()
  146. y = elements.pop()
  147. pos = float(x) + float(y) * 1j
  148. if not absolute:
  149. pos += current_pos
  150. pen.lineTo((pos.real, pos.imag))
  151. current_pos = pos
  152. elif command == "H":
  153. x = elements.pop()
  154. pos = float(x) + current_pos.imag * 1j
  155. if not absolute:
  156. pos += current_pos.real
  157. pen.lineTo((pos.real, pos.imag))
  158. current_pos = pos
  159. elif command == "V":
  160. y = elements.pop()
  161. pos = current_pos.real + float(y) * 1j
  162. if not absolute:
  163. pos += current_pos.imag * 1j
  164. pen.lineTo((pos.real, pos.imag))
  165. current_pos = pos
  166. elif command == "C":
  167. control1 = float(elements.pop()) + float(elements.pop()) * 1j
  168. control2 = float(elements.pop()) + float(elements.pop()) * 1j
  169. end = float(elements.pop()) + float(elements.pop()) * 1j
  170. if not absolute:
  171. control1 += current_pos
  172. control2 += current_pos
  173. end += current_pos
  174. pen.curveTo(
  175. (control1.real, control1.imag),
  176. (control2.real, control2.imag),
  177. (end.real, end.imag),
  178. )
  179. current_pos = end
  180. last_control = control2
  181. elif command == "S":
  182. # Smooth curve. First control point is the "reflection" of
  183. # the second control point in the previous path.
  184. if last_command not in "CS":
  185. # If there is no previous command or if the previous command
  186. # was not an C, c, S or s, assume the first control point is
  187. # coincident with the current point.
  188. control1 = current_pos
  189. else:
  190. # The first control point is assumed to be the reflection of
  191. # the second control point on the previous command relative
  192. # to the current point.
  193. control1 = current_pos + current_pos - last_control
  194. control2 = float(elements.pop()) + float(elements.pop()) * 1j
  195. end = float(elements.pop()) + float(elements.pop()) * 1j
  196. if not absolute:
  197. control2 += current_pos
  198. end += current_pos
  199. pen.curveTo(
  200. (control1.real, control1.imag),
  201. (control2.real, control2.imag),
  202. (end.real, end.imag),
  203. )
  204. current_pos = end
  205. last_control = control2
  206. elif command == "Q":
  207. control = float(elements.pop()) + float(elements.pop()) * 1j
  208. end = float(elements.pop()) + float(elements.pop()) * 1j
  209. if not absolute:
  210. control += current_pos
  211. end += current_pos
  212. pen.qCurveTo((control.real, control.imag), (end.real, end.imag))
  213. current_pos = end
  214. last_control = control
  215. elif command == "T":
  216. # Smooth curve. Control point is the "reflection" of
  217. # the second control point in the previous path.
  218. if last_command not in "QT":
  219. # If there is no previous command or if the previous command
  220. # was not an Q, q, T or t, assume the first control point is
  221. # coincident with the current point.
  222. control = current_pos
  223. else:
  224. # The control point is assumed to be the reflection of
  225. # the control point on the previous command relative
  226. # to the current point.
  227. control = current_pos + current_pos - last_control
  228. end = float(elements.pop()) + float(elements.pop()) * 1j
  229. if not absolute:
  230. end += current_pos
  231. pen.qCurveTo((control.real, control.imag), (end.real, end.imag))
  232. current_pos = end
  233. last_control = control
  234. elif command == "A":
  235. rx = abs(float(elements.pop()))
  236. ry = abs(float(elements.pop()))
  237. rotation = float(elements.pop())
  238. arc_large = bool(int(elements.pop()))
  239. arc_sweep = bool(int(elements.pop()))
  240. end = float(elements.pop()) + float(elements.pop()) * 1j
  241. if not absolute:
  242. end += current_pos
  243. # if the pen supports arcs, pass the values unchanged, otherwise
  244. # approximate the arc with a series of cubic bezier curves
  245. if have_arcTo:
  246. pen.arcTo(
  247. rx,
  248. ry,
  249. rotation,
  250. arc_large,
  251. arc_sweep,
  252. (end.real, end.imag),
  253. )
  254. else:
  255. arc = arc_class(
  256. current_pos, rx, ry, rotation, arc_large, arc_sweep, end
  257. )
  258. arc.draw(pen)
  259. current_pos = end
  260. # no final Z command, it's an open path
  261. if start_pos is not None:
  262. pen.endPath()