svgparse.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. ############################################################
  2. # FlatCAM: 2D Post-processing for Manufacturing #
  3. # http://flatcam.org #
  4. # Author: Juan Pablo Caram (c) #
  5. # Date: 12/18/2015 #
  6. # MIT Licence #
  7. # #
  8. # SVG Features supported: #
  9. # * Groups #
  10. # * Rectangles (w/ rounded corners) #
  11. # * Circles #
  12. # * Ellipses #
  13. # * Polygons #
  14. # * Polylines #
  15. # * Lines #
  16. # * Paths #
  17. # * All transformations #
  18. # #
  19. # Reference: www.w3.org/TR/SVG/Overview.html #
  20. ############################################################
  21. import xml.etree.ElementTree as ET
  22. import re
  23. import itertools
  24. from svg.path import Path, Line, Arc, CubicBezier, QuadraticBezier, parse_path
  25. from shapely.geometry import LinearRing, LineString, Point, Polygon
  26. from shapely.affinity import translate, rotate, scale, skew, affine_transform
  27. import numpy as np
  28. import logging
  29. log = logging.getLogger('base2')
  30. def svgparselength(lengthstr):
  31. """
  32. Parse an SVG length string into a float and a units
  33. string, if any.
  34. :param lengthstr: SVG length string.
  35. :return: Number and units pair.
  36. :rtype: tuple(float, str|None)
  37. """
  38. integer_re_str = r'[+-]?[0-9]+'
  39. number_re_str = r'(?:[+-]?[0-9]*\.[0-9]+(?:[Ee]' + integer_re_str + ')?' + r')|' + \
  40. r'(?:' + integer_re_str + r'(?:[Ee]' + integer_re_str + r')?)'
  41. length_re_str = r'(' + number_re_str + r')(em|ex|px|in|cm|mm|pt|pc|%)?'
  42. match = re.search(length_re_str, lengthstr)
  43. if match:
  44. return float(match.group(1)), match.group(2)
  45. raise Exception('Cannot parse SVG length: %s' % lengthstr)
  46. def path2shapely(path, res=1.0):
  47. """
  48. Converts an svg.path.Path into a Shapely
  49. LinearRing or LinearString.
  50. :rtype : LinearRing
  51. :rtype : LineString
  52. :param path: svg.path.Path instance
  53. :param res: Resolution (minimum step along path)
  54. :return: Shapely geometry object
  55. """
  56. points = []
  57. for component in path:
  58. # Line
  59. if isinstance(component, Line):
  60. start = component.start
  61. x, y = start.real, start.imag
  62. if len(points) == 0 or points[-1] != (x, y):
  63. points.append((x, y))
  64. end = component.end
  65. points.append((end.real, end.imag))
  66. continue
  67. # Arc, CubicBezier or QuadraticBezier
  68. if isinstance(component, Arc) or \
  69. isinstance(component, CubicBezier) or \
  70. isinstance(component, QuadraticBezier):
  71. # How many points to use in the dicrete representation.
  72. length = component.length(res / 10.0)
  73. steps = int(length / res + 0.5)
  74. # solve error when step is below 1,
  75. # it may cause other problems, but LineString needs at least two points
  76. if steps == 0:
  77. steps = 1
  78. frac = 1.0 / steps
  79. # print length, steps, frac
  80. for i in range(steps):
  81. point = component.point(i * frac)
  82. x, y = point.real, point.imag
  83. if len(points) == 0 or points[-1] != (x, y):
  84. points.append((x, y))
  85. end = component.point(1.0)
  86. points.append((end.real, end.imag))
  87. continue
  88. log.warning("I don't know what this is:", component)
  89. continue
  90. if path.closed:
  91. return Polygon(points).buffer(0)
  92. # return LinearRing(points)
  93. else:
  94. return LineString(points)
  95. def svgrect2shapely(rect, n_points=32):
  96. """
  97. Converts an SVG rect into Shapely geometry.
  98. :param rect: Rect Element
  99. :type rect: xml.etree.ElementTree.Element
  100. :return: shapely.geometry.polygon.LinearRing
  101. """
  102. w = svgparselength(rect.get('width'))[0]
  103. h = svgparselength(rect.get('height'))[0]
  104. x_obj = rect.get('x')
  105. if x_obj is not None:
  106. x = svgparselength(x_obj)[0]
  107. else:
  108. x = 0
  109. y_obj = rect.get('y')
  110. if y_obj is not None:
  111. y = svgparselength(y_obj)[0]
  112. else:
  113. y = 0
  114. rxstr = rect.get('rx')
  115. rystr = rect.get('ry')
  116. if rxstr is None and rystr is None: # Sharp corners
  117. pts = [
  118. (x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)
  119. ]
  120. else: # Rounded corners
  121. rx = 0.0 if rxstr is None else svgparselength(rxstr)[0]
  122. ry = 0.0 if rystr is None else svgparselength(rystr)[0]
  123. n_points = int(n_points / 4 + 0.5)
  124. t = np.arange(n_points, dtype=float) / n_points / 4
  125. x_ = (x + w - rx) + rx * np.cos(2 * np.pi * (t + 0.75))
  126. y_ = (y + ry) + ry * np.sin(2 * np.pi * (t + 0.75))
  127. lower_right = [(x_[i], y_[i]) for i in range(n_points)]
  128. x_ = (x + w - rx) + rx * np.cos(2 * np.pi * t)
  129. y_ = (y + h - ry) + ry * np.sin(2 * np.pi * t)
  130. upper_right = [(x_[i], y_[i]) for i in range(n_points)]
  131. x_ = (x + rx) + rx * np.cos(2 * np.pi * (t + 0.25))
  132. y_ = (y + h - ry) + ry * np.sin(2 * np.pi * (t + 0.25))
  133. upper_left = [(x_[i], y_[i]) for i in range(n_points)]
  134. x_ = (x + rx) + rx * np.cos(2 * np.pi * (t + 0.5))
  135. y_ = (y + ry) + ry * np.sin(2 * np.pi * (t + 0.5))
  136. lower_left = [(x_[i], y_[i]) for i in range(n_points)]
  137. pts = [(x + rx, y), (x - rx + w, y)] + \
  138. lower_right + \
  139. [(x + w, y + ry), (x + w, y + h - ry)] + \
  140. upper_right + \
  141. [(x + w - rx, y + h), (x + rx, y + h)] + \
  142. upper_left + \
  143. [(x, y + h - ry), (x, y + ry)] + \
  144. lower_left
  145. return Polygon(pts).buffer(0)
  146. # return LinearRing(pts)
  147. def svgcircle2shapely(circle):
  148. """
  149. Converts an SVG circle into Shapely geometry.
  150. :param circle: Circle Element
  151. :type circle: xml.etree.ElementTree.Element
  152. :return: Shapely representation of the circle.
  153. :rtype: shapely.geometry.polygon.LinearRing
  154. """
  155. # cx = float(circle.get('cx'))
  156. # cy = float(circle.get('cy'))
  157. # r = float(circle.get('r'))
  158. cx = svgparselength(circle.get('cx'))[0] # TODO: No units support yet
  159. cy = svgparselength(circle.get('cy'))[0] # TODO: No units support yet
  160. r = svgparselength(circle.get('r'))[0] # TODO: No units support yet
  161. # TODO: No resolution specified.
  162. return Point(cx, cy).buffer(r)
  163. def svgellipse2shapely(ellipse, n_points=64):
  164. """
  165. Converts an SVG ellipse into Shapely geometry
  166. :param ellipse: Ellipse Element
  167. :type ellipse: xml.etree.ElementTree.Element
  168. :param n_points: Number of discrete points in output.
  169. :return: Shapely representation of the ellipse.
  170. :rtype: shapely.geometry.polygon.LinearRing
  171. """
  172. cx = svgparselength(ellipse.get('cx'))[0] # TODO: No units support yet
  173. cy = svgparselength(ellipse.get('cy'))[0] # TODO: No units support yet
  174. rx = svgparselength(ellipse.get('rx'))[0] # TODO: No units support yet
  175. ry = svgparselength(ellipse.get('ry'))[0] # TODO: No units support yet
  176. t = np.arange(n_points, dtype=float) / n_points
  177. x = cx + rx * np.cos(2 * np.pi * t)
  178. y = cy + ry * np.sin(2 * np.pi * t)
  179. pts = [(x[i], y[i]) for i in range(n_points)]
  180. return Polygon(pts).buffer(0)
  181. # return LinearRing(pts)
  182. def svgline2shapely(line):
  183. """
  184. :param line: Line element
  185. :type line: xml.etree.ElementTree.Element
  186. :return: Shapely representation on the line.
  187. :rtype: shapely.geometry.polygon.LinearRing
  188. """
  189. x1 = svgparselength(line.get('x1'))[0]
  190. y1 = svgparselength(line.get('y1'))[0]
  191. x2 = svgparselength(line.get('x2'))[0]
  192. y2 = svgparselength(line.get('y2'))[0]
  193. return LineString([(x1, y1), (x2, y2)])
  194. def svgpolyline2shapely(polyline):
  195. ptliststr = polyline.get('points')
  196. points = parse_svg_point_list(ptliststr)
  197. return LineString(points)
  198. def svgpolygon2shapely(polygon):
  199. ptliststr = polygon.get('points')
  200. points = parse_svg_point_list(ptliststr)
  201. return Polygon(points).buffer(0)
  202. # return LinearRing(points)
  203. def getsvggeo(node):
  204. """
  205. Extracts and flattens all geometry from an SVG node
  206. into a list of Shapely geometry.
  207. :param node: xml.etree.ElementTree.Element
  208. :return: List of Shapely geometry
  209. :rtype: list
  210. """
  211. kind = re.search('(?:\{.*\})?(.*)$', node.tag).group(1)
  212. geo = []
  213. # Recurse
  214. if len(node) > 0:
  215. for child in node:
  216. subgeo = getsvggeo(child)
  217. if subgeo is not None:
  218. geo += subgeo
  219. # Parse
  220. elif kind == 'path':
  221. log.debug("***PATH***")
  222. P = parse_path(node.get('d'))
  223. P = path2shapely(P)
  224. geo = [P]
  225. elif kind == 'rect':
  226. log.debug("***RECT***")
  227. R = svgrect2shapely(node)
  228. geo = [R]
  229. elif kind == 'circle':
  230. log.debug("***CIRCLE***")
  231. C = svgcircle2shapely(node)
  232. geo = [C]
  233. elif kind == 'ellipse':
  234. log.debug("***ELLIPSE***")
  235. E = svgellipse2shapely(node)
  236. geo = [E]
  237. elif kind == 'polygon':
  238. log.debug("***POLYGON***")
  239. poly = svgpolygon2shapely(node)
  240. geo = [poly]
  241. elif kind == 'line':
  242. log.debug("***LINE***")
  243. line = svgline2shapely(node)
  244. geo = [line]
  245. elif kind == 'polyline':
  246. log.debug("***POLYLINE***")
  247. pline = svgpolyline2shapely(node)
  248. geo = [pline]
  249. else:
  250. log.warning("Unknown kind: " + kind)
  251. geo = None
  252. # ignore transformation for unknown kind
  253. if geo is not None:
  254. # Transformations
  255. if 'transform' in node.attrib:
  256. trstr = node.get('transform')
  257. trlist = parse_svg_transform(trstr)
  258. #log.debug(trlist)
  259. # Transformations are applied in reverse order
  260. for tr in trlist[::-1]:
  261. if tr[0] == 'translate':
  262. geo = [translate(geoi, tr[1], tr[2]) for geoi in geo]
  263. elif tr[0] == 'scale':
  264. geo = [scale(geoi, tr[0], tr[1], origin=(0, 0))
  265. for geoi in geo]
  266. elif tr[0] == 'rotate':
  267. geo = [rotate(geoi, tr[1], origin=(tr[2], tr[3]))
  268. for geoi in geo]
  269. elif tr[0] == 'skew':
  270. geo = [skew(geoi, tr[1], tr[2], origin=(0, 0))
  271. for geoi in geo]
  272. elif tr[0] == 'matrix':
  273. geo = [affine_transform(geoi, tr[1:]) for geoi in geo]
  274. else:
  275. raise Exception('Unknown transformation: %s', tr)
  276. return geo
  277. def parse_svg_point_list(ptliststr):
  278. """
  279. Returns a list of coordinate pairs extracted from the "points"
  280. attribute in SVG polygons and polylines.
  281. :param ptliststr: "points" attribute string in polygon or polyline.
  282. :return: List of tuples with coordinates.
  283. """
  284. pairs = []
  285. last = None
  286. pos = 0
  287. i = 0
  288. for match in re.finditer(r'(\s*,\s*)|(\s+)', ptliststr.strip(' ')):
  289. val = float(ptliststr[pos:match.start()])
  290. if i % 2 == 1:
  291. pairs.append((last, val))
  292. else:
  293. last = val
  294. pos = match.end()
  295. i += 1
  296. # Check for last element
  297. val = float(ptliststr[pos:])
  298. if i % 2 == 1:
  299. pairs.append((last, val))
  300. else:
  301. log.warning("Incomplete coordinates.")
  302. return pairs
  303. def parse_svg_transform(trstr):
  304. """
  305. Parses an SVG transform string into a list
  306. of transform names and their parameters.
  307. Possible transformations are:
  308. * Translate: translate(<tx> [<ty>]), which specifies
  309. a translation by tx and ty. If <ty> is not provided,
  310. it is assumed to be zero. Result is
  311. ['translate', tx, ty]
  312. * Scale: scale(<sx> [<sy>]), which specifies a scale operation
  313. by sx and sy. If <sy> is not provided, it is assumed to be
  314. equal to <sx>. Result is: ['scale', sx, sy]
  315. * Rotate: rotate(<rotate-angle> [<cx> <cy>]), which specifies
  316. a rotation by <rotate-angle> degrees about a given point.
  317. If optional parameters <cx> and <cy> are not supplied,
  318. the rotate is about the origin of the current user coordinate
  319. system. Result is: ['rotate', rotate-angle, cx, cy]
  320. * Skew: skewX(<skew-angle>), which specifies a skew
  321. transformation along the x-axis. skewY(<skew-angle>), which
  322. specifies a skew transformation along the y-axis.
  323. Result is ['skew', angle-x, angle-y]
  324. * Matrix: matrix(<a> <b> <c> <d> <e> <f>), which specifies a
  325. transformation in the form of a transformation matrix of six
  326. values. matrix(a,b,c,d,e,f) is equivalent to applying the
  327. transformation matrix [a b c d e f]. Result is
  328. ['matrix', a, b, c, d, e, f]
  329. Note: All parameters to the transformations are "numbers",
  330. i.e. no units present.
  331. :param trstr: SVG transform string.
  332. :type trstr: str
  333. :return: List of transforms.
  334. :rtype: list
  335. """
  336. trlist = []
  337. assert isinstance(trstr, str)
  338. trstr = trstr.strip(' ')
  339. integer_re_str = r'[+-]?[0-9]+'
  340. number_re_str = r'(?:[+-]?[0-9]*\.[0-9]+(?:[Ee]' + integer_re_str + ')?' + r')|' + \
  341. r'(?:' + integer_re_str + r'(?:[Ee]' + integer_re_str + r')?)'
  342. # num_re_str = r'[\+\-]?[0-9\.e]+' # TODO: Negative exponents missing
  343. comma_or_space_re_str = r'(?:(?:\s+)|(?:\s*,\s*))'
  344. translate_re_str = r'translate\s*\(\s*(' + \
  345. number_re_str + r')(?:' + \
  346. comma_or_space_re_str + \
  347. r'(' + number_re_str + r'))?\s*\)'
  348. scale_re_str = r'scale\s*\(\s*(' + \
  349. number_re_str + r')' + \
  350. r'(?:' + comma_or_space_re_str + \
  351. r'(' + number_re_str + r'))?\s*\)'
  352. skew_re_str = r'skew([XY])\s*\(\s*(' + \
  353. number_re_str + r')\s*\)'
  354. rotate_re_str = r'rotate\s*\(\s*(' + \
  355. number_re_str + r')' + \
  356. r'(?:' + comma_or_space_re_str + \
  357. r'(' + number_re_str + r')' + \
  358. comma_or_space_re_str + \
  359. r'(' + number_re_str + r'))?\s*\)'
  360. matrix_re_str = r'matrix\s*\(\s*' + \
  361. r'(' + number_re_str + r')' + comma_or_space_re_str + \
  362. r'(' + number_re_str + r')' + comma_or_space_re_str + \
  363. r'(' + number_re_str + r')' + comma_or_space_re_str + \
  364. r'(' + number_re_str + r')' + comma_or_space_re_str + \
  365. r'(' + number_re_str + r')' + comma_or_space_re_str + \
  366. r'(' + number_re_str + r')\s*\)'
  367. while len(trstr) > 0:
  368. match = re.search(r'^' + translate_re_str, trstr)
  369. if match:
  370. trlist.append([
  371. 'translate',
  372. float(match.group(1)),
  373. float(match.group(2)) if match.group else 0.0
  374. ])
  375. trstr = trstr[len(match.group(0)):].strip(' ')
  376. continue
  377. match = re.search(r'^' + scale_re_str, trstr)
  378. if match:
  379. trlist.append([
  380. 'translate',
  381. float(match.group(1)),
  382. float(match.group(2)) if match.group else float(match.group(1))
  383. ])
  384. trstr = trstr[len(match.group(0)):].strip(' ')
  385. continue
  386. match = re.search(r'^' + skew_re_str, trstr)
  387. if match:
  388. trlist.append([
  389. 'skew',
  390. float(match.group(2)) if match.group(1) == 'X' else 0.0,
  391. float(match.group(2)) if match.group(1) == 'Y' else 0.0
  392. ])
  393. trstr = trstr[len(match.group(0)):].strip(' ')
  394. continue
  395. match = re.search(r'^' + rotate_re_str, trstr)
  396. if match:
  397. trlist.append([
  398. 'rotate',
  399. float(match.group(1)),
  400. float(match.group(2)) if match.group(2) else 0.0,
  401. float(match.group(3)) if match.group(3) else 0.0
  402. ])
  403. trstr = trstr[len(match.group(0)):].strip(' ')
  404. continue
  405. match = re.search(r'^' + matrix_re_str, trstr)
  406. if match:
  407. trlist.append(['matrix'] + [float(x) for x in match.groups()])
  408. trstr = trstr[len(match.group(0)):].strip(' ')
  409. continue
  410. raise Exception("Don't know how to parse: %s" % trstr)
  411. return trlist
  412. if __name__ == "__main__":
  413. tree = ET.parse('tests/svg/drawing.svg')
  414. root = tree.getroot()
  415. ns = re.search(r'\{(.*)\}', root.tag).group(1)
  416. print(ns)
  417. for geo in getsvggeo(root):
  418. print(geo)