parser.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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. .. code-block::
  84. pen.arcTo(rx, ry, rotation, arc_large, arc_sweep, (x, y))
  85. Otherwise, the arcs are approximated by series of cubic Bezier segments
  86. ("curveTo"), one every 90 degrees.
  87. """
  88. # In the SVG specs, initial movetos are absolute, even if
  89. # specified as 'm'. This is the default behavior here as well.
  90. # But if you pass in a current_pos variable, the initial moveto
  91. # will be relative to that current_pos. This is useful.
  92. current_pos = complex(*current_pos)
  93. elements = list(_tokenize_path(pathdef))
  94. # Reverse for easy use of .pop()
  95. elements.reverse()
  96. start_pos = None
  97. command = None
  98. last_control = None
  99. have_arcTo = hasattr(pen, "arcTo")
  100. while elements:
  101. if elements[-1] in COMMANDS:
  102. # New command.
  103. last_command = command # Used by S and T
  104. command = elements.pop()
  105. absolute = command in UPPERCASE
  106. command = command.upper()
  107. else:
  108. # If this element starts with numbers, it is an implicit command
  109. # and we don't change the command. Check that it's allowed:
  110. if command is None:
  111. raise ValueError(
  112. "Unallowed implicit command in %s, position %s"
  113. % (pathdef, len(pathdef.split()) - len(elements))
  114. )
  115. last_command = command # Used by S and T
  116. if command == "M":
  117. # Moveto command.
  118. x = elements.pop()
  119. y = elements.pop()
  120. pos = float(x) + float(y) * 1j
  121. if absolute:
  122. current_pos = pos
  123. else:
  124. current_pos += pos
  125. # M is not preceded by Z; it's an open subpath
  126. if start_pos is not None:
  127. pen.endPath()
  128. pen.moveTo((current_pos.real, current_pos.imag))
  129. # when M is called, reset start_pos
  130. # This behavior of Z is defined in svg spec:
  131. # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand
  132. start_pos = current_pos
  133. # Implicit moveto commands are treated as lineto commands.
  134. # So we set command to lineto here, in case there are
  135. # further implicit commands after this moveto.
  136. command = "L"
  137. elif command == "Z":
  138. # Close path
  139. if current_pos != start_pos:
  140. pen.lineTo((start_pos.real, start_pos.imag))
  141. pen.closePath()
  142. current_pos = start_pos
  143. start_pos = None
  144. command = None # You can't have implicit commands after closing.
  145. elif command == "L":
  146. x = elements.pop()
  147. y = elements.pop()
  148. pos = float(x) + float(y) * 1j
  149. if not absolute:
  150. pos += current_pos
  151. pen.lineTo((pos.real, pos.imag))
  152. current_pos = pos
  153. elif command == "H":
  154. x = elements.pop()
  155. pos = float(x) + current_pos.imag * 1j
  156. if not absolute:
  157. pos += current_pos.real
  158. pen.lineTo((pos.real, pos.imag))
  159. current_pos = pos
  160. elif command == "V":
  161. y = elements.pop()
  162. pos = current_pos.real + float(y) * 1j
  163. if not absolute:
  164. pos += current_pos.imag * 1j
  165. pen.lineTo((pos.real, pos.imag))
  166. current_pos = pos
  167. elif command == "C":
  168. control1 = float(elements.pop()) + float(elements.pop()) * 1j
  169. control2 = float(elements.pop()) + float(elements.pop()) * 1j
  170. end = float(elements.pop()) + float(elements.pop()) * 1j
  171. if not absolute:
  172. control1 += current_pos
  173. control2 += current_pos
  174. end += current_pos
  175. pen.curveTo(
  176. (control1.real, control1.imag),
  177. (control2.real, control2.imag),
  178. (end.real, end.imag),
  179. )
  180. current_pos = end
  181. last_control = control2
  182. elif command == "S":
  183. # Smooth curve. First control point is the "reflection" of
  184. # the second control point in the previous path.
  185. if last_command not in "CS":
  186. # If there is no previous command or if the previous command
  187. # was not an C, c, S or s, assume the first control point is
  188. # coincident with the current point.
  189. control1 = current_pos
  190. else:
  191. # The first control point is assumed to be the reflection of
  192. # the second control point on the previous command relative
  193. # to the current point.
  194. control1 = current_pos + current_pos - last_control
  195. control2 = float(elements.pop()) + float(elements.pop()) * 1j
  196. end = float(elements.pop()) + float(elements.pop()) * 1j
  197. if not absolute:
  198. control2 += current_pos
  199. end += current_pos
  200. pen.curveTo(
  201. (control1.real, control1.imag),
  202. (control2.real, control2.imag),
  203. (end.real, end.imag),
  204. )
  205. current_pos = end
  206. last_control = control2
  207. elif command == "Q":
  208. control = float(elements.pop()) + float(elements.pop()) * 1j
  209. end = float(elements.pop()) + float(elements.pop()) * 1j
  210. if not absolute:
  211. control += current_pos
  212. end += current_pos
  213. pen.qCurveTo((control.real, control.imag), (end.real, end.imag))
  214. current_pos = end
  215. last_control = control
  216. elif command == "T":
  217. # Smooth curve. Control point is the "reflection" of
  218. # the second control point in the previous path.
  219. if last_command not in "QT":
  220. # If there is no previous command or if the previous command
  221. # was not an Q, q, T or t, assume the first control point is
  222. # coincident with the current point.
  223. control = current_pos
  224. else:
  225. # The control point is assumed to be the reflection of
  226. # the control point on the previous command relative
  227. # to the current point.
  228. control = current_pos + current_pos - last_control
  229. end = float(elements.pop()) + float(elements.pop()) * 1j
  230. if not absolute:
  231. end += current_pos
  232. pen.qCurveTo((control.real, control.imag), (end.real, end.imag))
  233. current_pos = end
  234. last_control = control
  235. elif command == "A":
  236. rx = abs(float(elements.pop()))
  237. ry = abs(float(elements.pop()))
  238. rotation = float(elements.pop())
  239. arc_large = bool(int(elements.pop()))
  240. arc_sweep = bool(int(elements.pop()))
  241. end = float(elements.pop()) + float(elements.pop()) * 1j
  242. if not absolute:
  243. end += current_pos
  244. # if the pen supports arcs, pass the values unchanged, otherwise
  245. # approximate the arc with a series of cubic bezier curves
  246. if have_arcTo:
  247. pen.arcTo(
  248. rx,
  249. ry,
  250. rotation,
  251. arc_large,
  252. arc_sweep,
  253. (end.real, end.imag),
  254. )
  255. else:
  256. arc = arc_class(
  257. current_pos, rx, ry, rotation, arc_large, arc_sweep, end
  258. )
  259. arc.draw(pen)
  260. current_pos = end
  261. # no final Z command, it's an open path
  262. if start_pos is not None:
  263. pen.endPath()