ParseSVG.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  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. from lxml import etree as ET
  23. import re
  24. import itertools
  25. from svg.path import Path, Line, Arc, CubicBezier, QuadraticBezier, parse_path
  26. from svg.path.path import Move
  27. from shapely.geometry import LinearRing, LineString, Point, Polygon
  28. from shapely.affinity import translate, rotate, scale, skew, affine_transform
  29. import numpy as np
  30. import logging
  31. from ParseFont import *
  32. log = logging.getLogger('base2')
  33. def svgparselength(lengthstr):
  34. """
  35. Parse an SVG length string into a float and a units
  36. string, if any.
  37. :param lengthstr: SVG length string.
  38. :return: Number and units pair.
  39. :rtype: tuple(float, str|None)
  40. """
  41. integer_re_str = r'[+-]?[0-9]+'
  42. number_re_str = r'(?:[+-]?[0-9]*\.[0-9]+(?:[Ee]' + integer_re_str + ')?' + r')|' + \
  43. r'(?:' + integer_re_str + r'(?:[Ee]' + integer_re_str + r')?)'
  44. length_re_str = r'(' + number_re_str + r')(em|ex|px|in|cm|mm|pt|pc|%)?'
  45. match = re.search(length_re_str, lengthstr)
  46. if match:
  47. return float(match.group(1)), match.group(2)
  48. return
  49. def path2shapely(path, object_type, res=1.0):
  50. """
  51. Converts an svg.path.Path into a Shapely
  52. LinearRing or LinearString.
  53. :rtype : LinearRing
  54. :rtype : LineString
  55. :param path: svg.path.Path instance
  56. :param res: Resolution (minimum step along path)
  57. :return: Shapely geometry object
  58. """
  59. points = []
  60. geometry = []
  61. geo_element = None
  62. for component in path:
  63. # Line
  64. if isinstance(component, Line):
  65. start = component.start
  66. x, y = start.real, start.imag
  67. if len(points) == 0 or points[-1] != (x, y):
  68. points.append((x, y))
  69. end = component.end
  70. points.append((end.real, end.imag))
  71. continue
  72. # Arc, CubicBezier or QuadraticBezier
  73. if isinstance(component, Arc) or \
  74. isinstance(component, CubicBezier) or \
  75. isinstance(component, QuadraticBezier):
  76. # How many points to use in the discrete representation.
  77. length = component.length(res / 10.0)
  78. steps = int(length / res + 0.5)
  79. # solve error when step is below 1,
  80. # it may cause other problems, but LineString needs at least two points
  81. if steps == 0:
  82. steps = 1
  83. frac = 1.0 / steps
  84. # print length, steps, frac
  85. for i in range(steps):
  86. point = component.point(i * frac)
  87. x, y = point.real, point.imag
  88. if len(points) == 0 or points[-1] != (x, y):
  89. points.append((x, y))
  90. end = component.point(1.0)
  91. points.append((end.real, end.imag))
  92. continue
  93. # Move
  94. if isinstance(component, Move):
  95. if object_type == 'geometry':
  96. geo_element = LineString(points)
  97. elif object_type == 'gerber':
  98. # Didn't worked out using Polygon because if there is a large outline it will envelope everything
  99. # and create issues with intersections. I will let the parameter obj_type present though
  100. # geo_element = Polygon(points)
  101. geo_element = LineString(points)
  102. else:
  103. log.error("[error]: Not a valid target object.")
  104. if not points:
  105. continue
  106. else:
  107. geometry.append(geo_element)
  108. points = []
  109. continue
  110. log.warning("I don't know what this is: %s" % str(component))
  111. continue
  112. # if there are still points in points then add them as a LineString
  113. if points:
  114. geo_element = LineString(points)
  115. geometry.append(geo_element)
  116. points = []
  117. # if path.closed:
  118. # return Polygon(points).buffer(0)
  119. # # return LinearRing(points)
  120. # else:
  121. # return LineString(points)
  122. return geometry
  123. def svgrect2shapely(rect, n_points=32):
  124. """
  125. Converts an SVG rect into Shapely geometry.
  126. :param rect: Rect Element
  127. :type rect: xml.etree.ElementTree.Element
  128. :return: shapely.geometry.polygon.LinearRing
  129. """
  130. w = svgparselength(rect.get('width'))[0]
  131. h = svgparselength(rect.get('height'))[0]
  132. x_obj = rect.get('x')
  133. if x_obj is not None:
  134. x = svgparselength(x_obj)[0]
  135. else:
  136. x = 0
  137. y_obj = rect.get('y')
  138. if y_obj is not None:
  139. y = svgparselength(y_obj)[0]
  140. else:
  141. y = 0
  142. rxstr = rect.get('rx')
  143. rystr = rect.get('ry')
  144. if rxstr is None and rystr is None: # Sharp corners
  145. pts = [
  146. (x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)
  147. ]
  148. else: # Rounded corners
  149. rx = 0.0 if rxstr is None else svgparselength(rxstr)[0]
  150. ry = 0.0 if rystr is None else svgparselength(rystr)[0]
  151. n_points = int(n_points / 4 + 0.5)
  152. t = np.arange(n_points, dtype=float) / n_points / 4
  153. x_ = (x + w - rx) + rx * np.cos(2 * np.pi * (t + 0.75))
  154. y_ = (y + ry) + ry * np.sin(2 * np.pi * (t + 0.75))
  155. lower_right = [(x_[i], y_[i]) for i in range(n_points)]
  156. x_ = (x + w - rx) + rx * np.cos(2 * np.pi * t)
  157. y_ = (y + h - ry) + ry * np.sin(2 * np.pi * t)
  158. upper_right = [(x_[i], y_[i]) for i in range(n_points)]
  159. x_ = (x + rx) + rx * np.cos(2 * np.pi * (t + 0.25))
  160. y_ = (y + h - ry) + ry * np.sin(2 * np.pi * (t + 0.25))
  161. upper_left = [(x_[i], y_[i]) for i in range(n_points)]
  162. x_ = (x + rx) + rx * np.cos(2 * np.pi * (t + 0.5))
  163. y_ = (y + ry) + ry * np.sin(2 * np.pi * (t + 0.5))
  164. lower_left = [(x_[i], y_[i]) for i in range(n_points)]
  165. pts = [(x + rx, y), (x - rx + w, y)] + \
  166. lower_right + \
  167. [(x + w, y + ry), (x + w, y + h - ry)] + \
  168. upper_right + \
  169. [(x + w - rx, y + h), (x + rx, y + h)] + \
  170. upper_left + \
  171. [(x, y + h - ry), (x, y + ry)] + \
  172. lower_left
  173. return Polygon(pts).buffer(0)
  174. # return LinearRing(pts)
  175. def svgcircle2shapely(circle):
  176. """
  177. Converts an SVG circle into Shapely geometry.
  178. :param circle: Circle Element
  179. :type circle: xml.etree.ElementTree.Element
  180. :return: Shapely representation of the circle.
  181. :rtype: shapely.geometry.polygon.LinearRing
  182. """
  183. # cx = float(circle.get('cx'))
  184. # cy = float(circle.get('cy'))
  185. # r = float(circle.get('r'))
  186. cx = svgparselength(circle.get('cx'))[0] # TODO: No units support yet
  187. cy = svgparselength(circle.get('cy'))[0] # TODO: No units support yet
  188. r = svgparselength(circle.get('r'))[0] # TODO: No units support yet
  189. # TODO: No resolution specified.
  190. return Point(cx, cy).buffer(r)
  191. def svgellipse2shapely(ellipse, n_points=64):
  192. """
  193. Converts an SVG ellipse into Shapely geometry
  194. :param ellipse: Ellipse Element
  195. :type ellipse: xml.etree.ElementTree.Element
  196. :param n_points: Number of discrete points in output.
  197. :return: Shapely representation of the ellipse.
  198. :rtype: shapely.geometry.polygon.LinearRing
  199. """
  200. cx = svgparselength(ellipse.get('cx'))[0] # TODO: No units support yet
  201. cy = svgparselength(ellipse.get('cy'))[0] # TODO: No units support yet
  202. rx = svgparselength(ellipse.get('rx'))[0] # TODO: No units support yet
  203. ry = svgparselength(ellipse.get('ry'))[0] # TODO: No units support yet
  204. t = np.arange(n_points, dtype=float) / n_points
  205. x = cx + rx * np.cos(2 * np.pi * t)
  206. y = cy + ry * np.sin(2 * np.pi * t)
  207. pts = [(x[i], y[i]) for i in range(n_points)]
  208. return Polygon(pts).buffer(0)
  209. # return LinearRing(pts)
  210. def svgline2shapely(line):
  211. """
  212. :param line: Line element
  213. :type line: xml.etree.ElementTree.Element
  214. :return: Shapely representation on the line.
  215. :rtype: shapely.geometry.polygon.LinearRing
  216. """
  217. x1 = svgparselength(line.get('x1'))[0]
  218. y1 = svgparselength(line.get('y1'))[0]
  219. x2 = svgparselength(line.get('x2'))[0]
  220. y2 = svgparselength(line.get('y2'))[0]
  221. return LineString([(x1, y1), (x2, y2)])
  222. def svgpolyline2shapely(polyline):
  223. ptliststr = polyline.get('points')
  224. points = parse_svg_point_list(ptliststr)
  225. return LineString(points)
  226. def svgpolygon2shapely(polygon):
  227. ptliststr = polygon.get('points')
  228. points = parse_svg_point_list(ptliststr)
  229. return Polygon(points).buffer(0)
  230. # return LinearRing(points)
  231. def getsvggeo(node, object_type):
  232. """
  233. Extracts and flattens all geometry from an SVG node
  234. into a list of Shapely geometry.
  235. :param node: xml.etree.ElementTree.Element
  236. :return: List of Shapely geometry
  237. :rtype: list
  238. """
  239. kind = re.search('(?:\{.*\})?(.*)$', node.tag).group(1)
  240. geo = []
  241. # Recurse
  242. if len(node) > 0:
  243. for child in node:
  244. subgeo = getsvggeo(child, object_type)
  245. if subgeo is not None:
  246. geo += subgeo
  247. # Parse
  248. elif kind == 'path':
  249. log.debug("***PATH***")
  250. P = parse_path(node.get('d'))
  251. P = path2shapely(P, object_type)
  252. # for path, the resulting geometry is already a list so no need to create a new one
  253. geo = P
  254. elif kind == 'rect':
  255. log.debug("***RECT***")
  256. R = svgrect2shapely(node)
  257. geo = [R]
  258. elif kind == 'circle':
  259. log.debug("***CIRCLE***")
  260. C = svgcircle2shapely(node)
  261. geo = [C]
  262. elif kind == 'ellipse':
  263. log.debug("***ELLIPSE***")
  264. E = svgellipse2shapely(node)
  265. geo = [E]
  266. elif kind == 'polygon':
  267. log.debug("***POLYGON***")
  268. poly = svgpolygon2shapely(node)
  269. geo = [poly]
  270. elif kind == 'line':
  271. log.debug("***LINE***")
  272. line = svgline2shapely(node)
  273. geo = [line]
  274. elif kind == 'polyline':
  275. log.debug("***POLYLINE***")
  276. pline = svgpolyline2shapely(node)
  277. geo = [pline]
  278. else:
  279. log.warning("Unknown kind: " + kind)
  280. geo = None
  281. # ignore transformation for unknown kind
  282. if geo is not None:
  283. # Transformations
  284. if 'transform' in node.attrib:
  285. trstr = node.get('transform')
  286. trlist = parse_svg_transform(trstr)
  287. # log.debug(trlist)
  288. # Transformations are applied in reverse order
  289. for tr in trlist[::-1]:
  290. if tr[0] == 'translate':
  291. geo = [translate(geoi, tr[1], tr[2]) for geoi in geo]
  292. elif tr[0] == 'scale':
  293. geo = [scale(geoi, tr[0], tr[1], origin=(0, 0))
  294. for geoi in geo]
  295. elif tr[0] == 'rotate':
  296. geo = [rotate(geoi, tr[1], origin=(tr[2], tr[3]))
  297. for geoi in geo]
  298. elif tr[0] == 'skew':
  299. geo = [skew(geoi, tr[1], tr[2], origin=(0, 0))
  300. for geoi in geo]
  301. elif tr[0] == 'matrix':
  302. geo = [affine_transform(geoi, tr[1:]) for geoi in geo]
  303. else:
  304. raise Exception('Unknown transformation: %s', tr)
  305. return geo
  306. def getsvgtext(node, object_type, units='MM'):
  307. """
  308. Extracts and flattens all geometry from an SVG node
  309. into a list of Shapely geometry.
  310. :param node: xml.etree.ElementTree.Element
  311. :return: List of Shapely geometry
  312. :rtype: list
  313. """
  314. kind = re.search('(?:\{.*\})?(.*)$', node.tag).group(1)
  315. geo = []
  316. # Recurse
  317. if len(node) > 0:
  318. for child in node:
  319. subgeo = getsvgtext(child, object_type, units=units)
  320. if subgeo is not None:
  321. geo += subgeo
  322. # Parse
  323. elif kind == 'tspan':
  324. current_attrib = node.attrib
  325. txt = node.text
  326. style_dict = {}
  327. parrent_attrib = node.getparent().attrib
  328. style = parrent_attrib['style']
  329. try:
  330. style_list = style.split(';')
  331. for css in style_list:
  332. style_dict[css.rpartition(':')[0]] = css.rpartition(':')[-1]
  333. pos_x = float(current_attrib['x'])
  334. pos_y = float(current_attrib['y'])
  335. # should have used the instance from FlatCAMApp.App but how? without reworking everything ...
  336. pf = ParseFont()
  337. pf.get_fonts_by_types()
  338. font_name = style_dict['font-family'].replace("'", '')
  339. if style_dict['font-style'] == 'italic' and style_dict['font-weight'] == 'bold':
  340. font_type = 'bi'
  341. elif style_dict['font-weight'] == 'bold':
  342. font_type = 'bold'
  343. elif style_dict['font-style'] == 'italic':
  344. font_type = 'italic'
  345. else:
  346. font_type = 'regular'
  347. # value of 2.2 should have been 2.83 (conversion value from pixels to points)
  348. # but the dimensions from Inkscape did not corelate with the ones after importing in FlatCAM
  349. # so I adjusted this
  350. font_size = svgparselength(style_dict['font-size'])[0] * 2.2
  351. geo = [pf.font_to_geometry(txt,
  352. font_name=font_name,
  353. font_size=font_size,
  354. font_type=font_type,
  355. units=units,
  356. coordx=pos_x,
  357. coordy=pos_y)
  358. ]
  359. geo = [(scale(g, 1.0, -1.0)) for g in geo]
  360. except Exception as e:
  361. log.debug(str(e))
  362. else:
  363. geo = None
  364. # ignore transformation for unknown kind
  365. if geo is not None:
  366. # Transformations
  367. if 'transform' in node.attrib:
  368. trstr = node.get('transform')
  369. trlist = parse_svg_transform(trstr)
  370. # log.debug(trlist)
  371. # Transformations are applied in reverse order
  372. for tr in trlist[::-1]:
  373. if tr[0] == 'translate':
  374. geo = [translate(geoi, tr[1], tr[2]) for geoi in geo]
  375. elif tr[0] == 'scale':
  376. geo = [scale(geoi, tr[0], tr[1], origin=(0, 0))
  377. for geoi in geo]
  378. elif tr[0] == 'rotate':
  379. geo = [rotate(geoi, tr[1], origin=(tr[2], tr[3]))
  380. for geoi in geo]
  381. elif tr[0] == 'skew':
  382. geo = [skew(geoi, tr[1], tr[2], origin=(0, 0))
  383. for geoi in geo]
  384. elif tr[0] == 'matrix':
  385. geo = [affine_transform(geoi, tr[1:]) for geoi in geo]
  386. else:
  387. raise Exception('Unknown transformation: %s', tr)
  388. return geo
  389. def parse_svg_point_list(ptliststr):
  390. """
  391. Returns a list of coordinate pairs extracted from the "points"
  392. attribute in SVG polygons and polyline's.
  393. :param ptliststr: "points" attribute string in polygon or polyline.
  394. :return: List of tuples with coordinates.
  395. """
  396. pairs = []
  397. last = None
  398. pos = 0
  399. i = 0
  400. for match in re.finditer(r'(\s*,\s*)|(\s+)', ptliststr.strip(' ')):
  401. val = float(ptliststr[pos:match.start()])
  402. if i % 2 == 1:
  403. pairs.append((last, val))
  404. else:
  405. last = val
  406. pos = match.end()
  407. i += 1
  408. # Check for last element
  409. val = float(ptliststr[pos:])
  410. if i % 2 == 1:
  411. pairs.append((last, val))
  412. else:
  413. log.warning("Incomplete coordinates.")
  414. return pairs
  415. def parse_svg_transform(trstr):
  416. """
  417. Parses an SVG transform string into a list
  418. of transform names and their parameters.
  419. Possible transformations are:
  420. * Translate: translate(<tx> [<ty>]), which specifies
  421. a translation by tx and ty. If <ty> is not provided,
  422. it is assumed to be zero. Result is
  423. ['translate', tx, ty]
  424. * Scale: scale(<sx> [<sy>]), which specifies a scale operation
  425. by sx and sy. If <sy> is not provided, it is assumed to be
  426. equal to <sx>. Result is: ['scale', sx, sy]
  427. * Rotate: rotate(<rotate-angle> [<cx> <cy>]), which specifies
  428. a rotation by <rotate-angle> degrees about a given point.
  429. If optional parameters <cx> and <cy> are not supplied,
  430. the rotate is about the origin of the current user coordinate
  431. system. Result is: ['rotate', rotate-angle, cx, cy]
  432. * Skew: skewX(<skew-angle>), which specifies a skew
  433. transformation along the x-axis. skewY(<skew-angle>), which
  434. specifies a skew transformation along the y-axis.
  435. Result is ['skew', angle-x, angle-y]
  436. * Matrix: matrix(<a> <b> <c> <d> <e> <f>), which specifies a
  437. transformation in the form of a transformation matrix of six
  438. values. matrix(a,b,c,d,e,f) is equivalent to applying the
  439. transformation matrix [a b c d e f]. Result is
  440. ['matrix', a, b, c, d, e, f]
  441. Note: All parameters to the transformations are "numbers",
  442. i.e. no units present.
  443. :param trstr: SVG transform string.
  444. :type trstr: str
  445. :return: List of transforms.
  446. :rtype: list
  447. """
  448. trlist = []
  449. assert isinstance(trstr, str)
  450. trstr = trstr.strip(' ')
  451. integer_re_str = r'[+-]?[0-9]+'
  452. number_re_str = r'(?:[+-]?[0-9]*\.[0-9]+(?:[Ee]' + integer_re_str + ')?' + r')|' + \
  453. r'(?:' + integer_re_str + r'(?:[Ee]' + integer_re_str + r')?)'
  454. # num_re_str = r'[\+\-]?[0-9\.e]+' # TODO: Negative exponents missing
  455. comma_or_space_re_str = r'(?:(?:\s+)|(?:\s*,\s*))'
  456. translate_re_str = r'translate\s*\(\s*(' + \
  457. number_re_str + r')(?:' + \
  458. comma_or_space_re_str + \
  459. r'(' + number_re_str + r'))?\s*\)'
  460. scale_re_str = r'scale\s*\(\s*(' + \
  461. number_re_str + r')' + \
  462. r'(?:' + comma_or_space_re_str + \
  463. r'(' + number_re_str + r'))?\s*\)'
  464. skew_re_str = r'skew([XY])\s*\(\s*(' + \
  465. number_re_str + r')\s*\)'
  466. rotate_re_str = r'rotate\s*\(\s*(' + \
  467. number_re_str + r')' + \
  468. r'(?:' + comma_or_space_re_str + \
  469. r'(' + number_re_str + r')' + \
  470. comma_or_space_re_str + \
  471. r'(' + number_re_str + r'))?\s*\)'
  472. matrix_re_str = r'matrix\s*\(\s*' + \
  473. r'(' + number_re_str + r')' + comma_or_space_re_str + \
  474. r'(' + number_re_str + r')' + comma_or_space_re_str + \
  475. r'(' + number_re_str + r')' + comma_or_space_re_str + \
  476. r'(' + number_re_str + r')' + comma_or_space_re_str + \
  477. r'(' + number_re_str + r')' + comma_or_space_re_str + \
  478. r'(' + number_re_str + r')\s*\)'
  479. while len(trstr) > 0:
  480. match = re.search(r'^' + translate_re_str, trstr)
  481. if match:
  482. trlist.append([
  483. 'translate',
  484. float(match.group(1)),
  485. float(match.group(2)) if match.group else 0.0
  486. ])
  487. trstr = trstr[len(match.group(0)):].strip(' ')
  488. continue
  489. match = re.search(r'^' + scale_re_str, trstr)
  490. if match:
  491. trlist.append([
  492. 'translate',
  493. float(match.group(1)),
  494. float(match.group(2)) if not None else float(match.group(1))
  495. ])
  496. trstr = trstr[len(match.group(0)):].strip(' ')
  497. continue
  498. match = re.search(r'^' + skew_re_str, trstr)
  499. if match:
  500. trlist.append([
  501. 'skew',
  502. float(match.group(2)) if match.group(1) == 'X' else 0.0,
  503. float(match.group(2)) if match.group(1) == 'Y' else 0.0
  504. ])
  505. trstr = trstr[len(match.group(0)):].strip(' ')
  506. continue
  507. match = re.search(r'^' + rotate_re_str, trstr)
  508. if match:
  509. trlist.append([
  510. 'rotate',
  511. float(match.group(1)),
  512. float(match.group(2)) if match.group(2) else 0.0,
  513. float(match.group(3)) if match.group(3) else 0.0
  514. ])
  515. trstr = trstr[len(match.group(0)):].strip(' ')
  516. continue
  517. match = re.search(r'^' + matrix_re_str, trstr)
  518. if match:
  519. trlist.append(['matrix'] + [float(x) for x in match.groups()])
  520. trstr = trstr[len(match.group(0)):].strip(' ')
  521. continue
  522. # raise Exception("Don't know how to parse: %s" % trstr)
  523. log.error("[error] Don't know how to parse: %s" % trstr)
  524. return trlist
  525. # if __name__ == "__main__":
  526. # tree = ET.parse('tests/svg/drawing.svg')
  527. # root = tree.getroot()
  528. # ns = re.search(r'\{(.*)\}', root.tag).group(1)
  529. # print(ns)
  530. # for geo in getsvggeo(root):
  531. # print(geo)