1import numpy as np 2 3from string import Template 4 5 6from ..arc import arc_center 7from ..entities import Line, Arc, Bezier 8 9from ...constants import log, tol 10from ...constants import res_path as res 11 12from ... import util 13from ... import grouping 14from ... import resources 15from ... import exceptions 16 17from ... transformations import transform_points, planar_matrix 18 19try: 20 # pip install svg.path 21 from svg.path import parse_path 22except BaseException as E: 23 # will re-raise the import exception when 24 # someone tries to call `parse_path` 25 parse_path = exceptions.closure(E) 26 27try: 28 from lxml import etree 29except BaseException as E: 30 # will re-raise the import exception when 31 # someone actually tries to use the module 32 etree = exceptions.ExceptionModule(E) 33 34 35def svg_to_path(file_obj, file_type=None): 36 """ 37 Load an SVG file into a Path2D object. 38 39 Parameters 40 ----------- 41 file_obj : open file object 42 Contains SVG data 43 file_type: None 44 Not used 45 46 Returns 47 ----------- 48 loaded : dict 49 With kwargs for Path2D constructor 50 """ 51 52 def element_transform(e, max_depth=100): 53 """ 54 Find a transformation matrix for an XML element. 55 """ 56 matrices = [] 57 current = e 58 for i in range(max_depth): 59 if 'transform' in current.attrib: 60 matrices.extend(transform_to_matrices( 61 current.attrib['transform'])) 62 current = current.getparent() 63 if current is None: 64 break 65 66 if len(matrices) == 0: 67 return np.eye(3) 68 elif len(matrices) == 1: 69 return matrices[0] 70 else: 71 return util.multi_dot(matrices[::-1]) 72 73 # first parse the XML 74 xml = etree.fromstring(file_obj.read()) 75 76 # store paths and transforms as 77 # (path string, 3x3 matrix) 78 paths = [] 79 80 # store every path element 81 for element in xml.iter('{*}path'): 82 paths.append((element.attrib['d'], 83 element_transform(element))) 84 85 return _svg_path_convert(paths) 86 87 88def transform_to_matrices(transform): 89 """ 90 Convert an SVG transform string to an array of matrices. 91 92 i.e. "rotate(-10 50 100) 93 translate(-36 45.5) 94 skewX(40) 95 scale(1 0.5)" 96 97 Parameters 98 ----------- 99 transform : str 100 Contains transformation information in SVG form 101 102 Returns 103 ----------- 104 matrices : (n, 3, 3) float 105 Multiple transformation matrices from input transform string 106 """ 107 # split the transform string in to components of: 108 # (operation, args) i.e. (translate, '-1.0, 2.0') 109 components = [ 110 [j.strip() for j in i.strip().split('(') if len(j) > 0] 111 for i in transform.lower().split(')') if len(i) > 0] 112 # store each matrix without dotting 113 matrices = [] 114 for line in components: 115 if len(line) == 0: 116 continue 117 elif len(line) != 2: 118 raise ValueError('should always have two components!') 119 key, args = line 120 # convert string args to array of floats 121 # support either comma or space delimiter 122 values = np.array([float(i) for i in 123 args.replace(',', ' ').split()]) 124 if key == 'translate': 125 # convert translation to a (3, 3) homogeneous matrix 126 matrices.append(np.eye(3)) 127 matrices[-1][:2, 2] = values 128 elif key == 'matrix': 129 # [a b c d e f] -> 130 # [[a c e], 131 # [b d f], 132 # [0 0 1]] 133 matrices.append(np.vstack(( 134 values.reshape((3, 2)).T, [0, 0, 1]))) 135 elif key == 'rotate': 136 # SVG rotations are in degrees 137 angle = np.degrees(values[0]) 138 # if there are three values rotate around point 139 if len(values) == 3: 140 point = values[1:] 141 else: 142 point = None 143 matrices.append(planar_matrix(theta=angle, 144 point=point)) 145 elif key == 'scale': 146 # supports (x_scale, y_scale) or (scale) 147 mat = np.eye(3) 148 mat[:2, :2] *= values 149 matrices.append(mat) 150 else: 151 log.warning('unknown SVG transform: {}'.format(key)) 152 153 return matrices 154 155 156def _svg_path_convert(paths): 157 """ 158 Convert an SVG path string into a Path2D object 159 160 Parameters 161 ------------- 162 paths: list of tuples 163 Containing (path string, (3, 3) matrix) 164 165 Returns 166 ------------- 167 drawing : dict 168 Kwargs for Path2D constructor 169 """ 170 def complex_to_float(values): 171 return np.array([[i.real, i.imag] for i in values]) 172 173 def load_multi(multi): 174 # load a previously parsed multiline 175 return Line(np.arange(len(multi.points)) + count), multi.points 176 177 def load_arc(svg_arc): 178 # load an SVG arc into a trimesh arc 179 points = complex_to_float([svg_arc.start, 180 svg_arc.point(.5), 181 svg_arc.end]) 182 return Arc(np.arange(3) + count), points 183 184 def load_quadratic(svg_quadratic): 185 # load a quadratic bezier spline 186 points = complex_to_float([svg_quadratic.start, 187 svg_quadratic.control, 188 svg_quadratic.end]) 189 return Bezier(np.arange(3) + count), points 190 191 def load_cubic(svg_cubic): 192 # load a cubic bezier spline 193 points = complex_to_float([svg_cubic.start, 194 svg_cubic.control1, 195 svg_cubic.control2, 196 svg_cubic.end]) 197 return Bezier(np.arange(4) + count), points 198 199 # store loaded values here 200 entities = [] 201 vertices = [] 202 # how many vertices have we loaded 203 count = 0 204 # load functions for each entity 205 loaders = {'Arc': load_arc, 206 'MultiLine': load_multi, 207 'CubicBezier': load_cubic, 208 'QuadraticBezier': load_quadratic} 209 210 class MultiLine(object): 211 # An object to hold one or multiple Line entities. 212 def __init__(self, lines): 213 if tol.strict: 214 # in unit tests make sure we only have lines 215 assert all(type(L).__name__ == 'Line' 216 for L in lines) 217 # get the starting point of every line 218 points = [L.start for L in lines] 219 # append the endpoint 220 points.append(lines[-1].end) 221 # convert to (n, 2) float points 222 self.points = np.array([[i.real, i.imag] 223 for i in points], 224 dtype=np.float64) 225 226 for path_string, matrix in paths: 227 # get parsed entities from svg.path 228 raw = np.array(list(parse_path(path_string))) 229 # check to see if each entity is a Line 230 is_line = np.array([type(i).__name__ == 'Line' 231 for i in raw]) 232 # find groups of consecutive lines so we can combine them 233 blocks = grouping.blocks( 234 is_line, min_len=1, only_nonzero=False) 235 if tol.strict: 236 # in unit tests make sure we didn't lose any entities 237 assert np.allclose(np.hstack(blocks), 238 np.arange(len(raw))) 239 240 # Combine consecutive lines into a single MultiLine 241 parsed = [] 242 for b in blocks: 243 if type(raw[b[0]]).__name__ == 'Line': 244 # if entity consists of lines add a multiline 245 parsed.append(MultiLine(raw[b])) 246 else: 247 # otherwise just add the entities 248 parsed.extend(raw[b]) 249 # loop through parsed entity objects 250 for svg_entity in parsed: 251 # keyed by entity class name 252 type_name = type(svg_entity).__name__ 253 if type_name in loaders: 254 # get new entities and vertices 255 e, v = loaders[type_name](svg_entity) 256 # append them to the result 257 entities.append(e) 258 # create a sequence of vertex arrays 259 vertices.append(transform_points(v, matrix)) 260 count += len(vertices[-1]) 261 262 # store results as kwargs and stack vertices 263 loaded = {'entities': np.array(entities), 264 'vertices': np.vstack(vertices)} 265 return loaded 266 267 268def export_svg(drawing, 269 return_path=False, 270 layers=None, 271 **kwargs): 272 """ 273 Export a Path2D object into an SVG file. 274 275 Parameters 276 ----------- 277 drawing : Path2D 278 Source geometry 279 return_path : bool 280 If True return only path string not wrapped in XML 281 layers : None, or [str] 282 Only export specified layers 283 284 Returns 285 ----------- 286 as_svg : str 287 XML formatted SVG, or path string 288 """ 289 if not util.is_instance_named(drawing, 'Path2D'): 290 raise ValueError('drawing must be Path2D object!') 291 292 # copy the points and make sure they're not a TrackedArray 293 points = drawing.vertices.view(np.ndarray).copy() 294 295 # fetch the export template for SVG files 296 template_svg = Template(resources.get('svg.template.xml')) 297 298 def circle_to_svgpath(center, radius, reverse): 299 radius_str = format(radius, res.export) 300 path_str = ' M ' + format(center[0] - radius, res.export) + ',' 301 path_str += format(center[1], res.export) 302 path_str += ' a ' + radius_str + ',' + radius_str 303 path_str += ',0,1,' + str(int(reverse)) + ',' 304 path_str += format(2 * radius, res.export) + ',0' 305 path_str += ' a ' + radius_str + ',' + radius_str 306 path_str += ',0,1,' + str(int(reverse)) + ',' 307 path_str += format(-2 * radius, res.export) + ',0 Z' 308 return path_str 309 310 def svg_arc(arc, reverse): 311 """ 312 arc string: (rx ry x-axis-rotation large-arc-flag sweep-flag x y)+ 313 large-arc-flag: greater than 180 degrees 314 sweep flag: direction (cw/ccw) 315 """ 316 arc_idx = arc.points[::((reverse * -2) + 1)] 317 vertices = points[arc_idx] 318 vertex_start, vertex_mid, vertex_end = vertices 319 center_info = arc_center(vertices) 320 C, R, angle = (center_info['center'], 321 center_info['radius'], 322 center_info['span']) 323 if arc.closed: 324 return circle_to_svgpath(C, R, reverse) 325 326 large_flag = str(int(angle > np.pi)) 327 sweep_flag = str(int(np.cross(vertex_mid - vertex_start, 328 vertex_end - vertex_start) > 0.0)) 329 330 arc_str = move_to(arc_idx[0]) 331 arc_str += 'A {},{} 0 {}, {} {},{}'.format(R, 332 R, 333 large_flag, 334 sweep_flag, 335 vertex_end[0], 336 vertex_end[1]) 337 return arc_str 338 339 def move_to(vertex_id): 340 x_ex = format(points[vertex_id][0], res.export) 341 y_ex = format(points[vertex_id][1], res.export) 342 move_str = ' M ' + x_ex + ',' + y_ex 343 return move_str 344 345 def svg_discrete(entity, reverse): 346 """ 347 Use an entities discrete representation to export a 348 curve as a polyline 349 """ 350 discrete = entity.discrete(points) 351 # if entity contains no geometry return 352 if len(discrete) == 0: 353 return '' 354 # are we reversing the entity 355 if reverse: 356 discrete = discrete[::-1] 357 # the format string for the SVG path 358 template = ' M {},{} ' + (' L {},{}' * (len(discrete) - 1)) 359 # apply the data from the discrete curve 360 result = template.format(*discrete.reshape(-1)) 361 return result 362 363 def convert_entity(entity, reverse=False): 364 if layers is not None and entity.layer not in layers: 365 return '' 366 # the class name of the entity 367 etype = entity.__class__.__name__ 368 if etype == 'Arc': 369 # export the exact version of the entity 370 return svg_arc(entity, reverse=False) 371 else: 372 # just export the polyline version of the entity 373 return svg_discrete(entity, reverse=False) 374 375 # convert each entity to an SVG entity 376 converted = [convert_entity(e) for e in drawing.entities] 377 378 # append list of converted into a string 379 path_str = ''.join(converted).strip() 380 381 # return path string without XML wrapping 382 if return_path: 383 return path_str 384 385 # format as XML 386 if 'stroke_width' in kwargs: 387 stroke_width = float(kwargs['stroke_width']) 388 else: 389 stroke_width = drawing.extents.max() / 800.0 390 subs = {'PATH_STRING': path_str, 391 'MIN_X': points[:, 0].min(), 392 'MIN_Y': points[:, 1].min(), 393 'WIDTH': drawing.extents[0], 394 'HEIGHT': drawing.extents[1], 395 'STROKE': stroke_width} 396 result = template_svg.substitute(subs) 397 return result 398