Coverage for trimesh/path/exchange/svg_io.py: 89%

322 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1import base64 

2import json 

3from collections import defaultdict, deque 

4from copy import deepcopy 

5 

6import numpy as np 

7 

8from ... import exceptions, grouping, resources, util 

9from ...constants import log, tol 

10from ...transformations import planar_matrix, transform_points 

11from ...typed import Iterable, Mapping, NDArray, Number 

12from ...util import jsonify 

13from ..arc import arc_center, to_threepoint 

14from ..entities import Arc, Bezier, Line 

15 

16# store any additional properties using a trimesh namespace 

17_ns_name = "trimesh" 

18_ns_url = "https://github.com/mikedh/trimesh" 

19_ns = f"{{{_ns_url}}}" 

20 

21_IDENTITY = np.eye(3) 

22_IDENTITY.flags["WRITEABLE"] = False 

23 

24 

25def svg_to_path(file_obj=None, file_type=None, path_string=None): 

26 """ 

27 Load an SVG file into a Path2D object. 

28 

29 Parameters 

30 ----------- 

31 file_obj : open file object 

32 Contains SVG data 

33 file_type: None 

34 Not used 

35 path_string : None or str 

36 If passed, parse a single path string and ignore `file_obj`. 

37 

38 Returns 

39 ----------- 

40 loaded : dict 

41 With kwargs for Path2D constructor 

42 """ 

43 

44 force = None 

45 tree = None 

46 paths = [] 

47 shapes = [] 

48 if file_obj is not None: 

49 # first parse the XML 

50 tree = etree.fromstring( 

51 file_obj.read(), parser=etree.XMLParser(**XML_PARSER_OPTIONS) 

52 ) 

53 # store paths and transforms as 

54 # (path string, 3x3 matrix) 

55 for element in tree.iter("{*}path"): 

56 # store every path element attributes and transform 

57 paths.append((element.attrib, element_transform(element))) 

58 

59 # now try converting shapes 

60 for shape in tree.iter( 

61 ("{*}circle", "{*}rect", "{*}line", "{*}polyline", "{*}polygon") 

62 ): 

63 shapes.append( 

64 (shape.tag.rsplit("}", 1)[-1], shape.attrib, element_transform(shape)) 

65 ) 

66 

67 try: 

68 # see if the SVG should be reproduced as a scene 

69 force = tree.attrib[_ns + "class"] 

70 except BaseException: 

71 pass 

72 elif path_string is not None: 

73 # parse a single SVG path string 

74 paths.append(({"d": path_string}, _IDENTITY)) 

75 else: 

76 raise ValueError("`file_obj` or `pathstring` required") 

77 

78 result = _svg_path_convert(paths=paths, shapes=shapes, force=force) 

79 

80 try: 

81 if tree is not None: 

82 # get overall metadata from JSON string if it exists 

83 result["metadata"] = _decode(tree.attrib[_ns + "metadata"]) 

84 except KeyError: 

85 # not in the trimesh ns 

86 pass 

87 except BaseException: 

88 # no metadata stored with trimesh ns 

89 log.debug("failed metadata", exc_info=True) 

90 

91 # if the result is a scene try to get the metadata 

92 # for each subgeometry here 

93 if "geometry" in result: 

94 try: 

95 # get per-geometry metadata if available 

96 bag = _decode(tree.attrib[_ns + "metadata_geometry"]) 

97 for name, meta in bag.items(): 

98 if name in result["geometry"]: 

99 # assign this metadata to the geometry 

100 result["geometry"][name]["metadata"] = meta 

101 except KeyError: 

102 # no stored geometry metadata so ignore 

103 pass 

104 except BaseException: 

105 # failed to load existing metadata 

106 log.debug("failed metadata", exc_info=True) 

107 

108 return result 

109 

110 

111def _attrib_metadata(attrib: Mapping) -> dict: 

112 try: 

113 # try to retrieve any trimesh attributes as metadata 

114 return { 

115 k.lstrip(_ns): _decode(v) 

116 for k, v in attrib.items() 

117 if k[1:].startswith(_ns_url) 

118 } 

119 except BaseException: 

120 return {} 

121 

122 

123def element_transform(element, max_depth=10): 

124 """ 

125 Find a transformation matrix for an XML element. 

126 

127 Parameters 

128 -------------- 

129 e : lxml.etree.Element 

130 Element to search upwards from. 

131 max_depth : int 

132 Maximum depth to search for transforms. 

133 """ 

134 matrices = deque() 

135 # start at the passed element 

136 current = element 

137 for _ in range(max_depth): 

138 # get the transforms from a particular element 

139 if "transform" in current.attrib: 

140 matrices.extendleft(transform_to_matrices(current.attrib["transform"])[::-1]) 

141 current = current.getparent() 

142 if current is None: 

143 break 

144 if len(matrices) == 0: 

145 # no transforms is an identity matrix 

146 return _IDENTITY 

147 elif len(matrices) == 1: 

148 return matrices[0] 

149 else: 

150 # evaluate the transforms in the order they were passed 

151 # as this is what the SVG spec says you should do 

152 return util.multi_dot(matrices) 

153 

154 

155def transform_to_matrices(transform: str) -> NDArray[np.float64]: 

156 """ 

157 Convert an SVG transform string to an array of matrices. 

158 

159 i.e. "rotate(-10 50 100) 

160 translate(-36 45.5) 

161 skewX(40) 

162 scale(1 0.5)" 

163 

164 Parameters 

165 ----------- 

166 transform : str 

167 Contains transformation information in SVG form 

168 

169 Returns 

170 ----------- 

171 matrices : (n, 3, 3) float 

172 Multiple transformation matrices from input transform string 

173 """ 

174 # split the transform string in to components of: 

175 # (operation, args) i.e. (translate, '-1.0, 2.0') 

176 components = [ 

177 [j.strip() for j in i.strip().split("(") if len(j) > 0] 

178 for i in transform.lower().split(")") 

179 if len(i) > 0 

180 ] 

181 # store each matrix without dotting 

182 matrices = [] 

183 for line in components: 

184 if len(line) == 0: 

185 continue 

186 elif len(line) != 2: 

187 raise ValueError("should always have two components!") 

188 key, args = line 

189 # convert string args to array of floats 

190 # support either comma or space delimiter 

191 values = np.array([float(i) for i in args.replace(",", " ").split()]) 

192 if key == "translate": 

193 # convert translation to a (3, 3) homogeneous matrix 

194 matrices.append(_IDENTITY.copy()) 

195 matrices[-1][:2, 2] = values 

196 elif key == "matrix": 

197 # [a b c d e f] -> 

198 # [[a c e], 

199 # [b d f], 

200 # [0 0 1]] 

201 matrices.append(np.vstack((values.reshape((3, 2)).T, [0, 0, 1]))) 

202 elif key == "rotate": 

203 # SVG rotations are in degrees 

204 angle = np.degrees(values[0]) 

205 # if there are three values rotate around point 

206 if len(values) == 3: 

207 point = values[1:] 

208 else: 

209 point = None 

210 matrices.append(planar_matrix(theta=angle, point=point)) 

211 elif key == "scale": 

212 # supports (x_scale, y_scale) or (scale) 

213 mat = _IDENTITY.copy() 

214 mat[:2, :2] *= values 

215 matrices.append(mat) 

216 else: 

217 log.debug(f"unknown SVG transform: {key}") 

218 

219 return np.array(matrices, dtype=np.float64) 

220 

221 

222def _svg_path_convert(paths: Iterable, shapes: Iterable, force=None): 

223 """ 

224 Convert an SVG path string into a Path2D object 

225 

226 Parameters 

227 ------------- 

228 paths: list of tuples 

229 Containing (path string, (3, 3) matrix, metadata) 

230 

231 Returns 

232 ------------- 

233 drawing : dict 

234 Kwargs for Path2D constructor 

235 """ 

236 

237 def complex_to_float(values): 

238 return np.array([[i.real, i.imag] for i in values], dtype=np.float64) 

239 

240 def load_multi(multi): 

241 # load a previously parsed multiline 

242 # start the count where indicated 

243 start = counts[name] 

244 # end at the block of our new points 

245 end = start + len(multi.points) 

246 

247 return (Line(points=np.arange(start, end)), multi.points) 

248 

249 def load_arc(svg_arc): 

250 # load an SVG arc into a trimesh arc 

251 points = complex_to_float([svg_arc.start, svg_arc.point(0.5), svg_arc.end]) 

252 # create an arc from the now numpy points 

253 arc = Arc( 

254 points=np.arange(3) + counts[name], 

255 # we may have monkey-patched the entity to 

256 # indicate that it is a closed circle 

257 closed=getattr(svg_arc, "closed", False), 

258 ) 

259 return arc, points 

260 

261 def load_quadratic(svg_quadratic): 

262 # load a quadratic bezier spline 

263 points = complex_to_float( 

264 [svg_quadratic.start, svg_quadratic.control, svg_quadratic.end] 

265 ) 

266 return Bezier(points=np.arange(3) + counts[name]), points 

267 

268 def load_cubic(svg_cubic): 

269 # load a cubic bezier spline 

270 points = complex_to_float( 

271 [svg_cubic.start, svg_cubic.control1, svg_cubic.control2, svg_cubic.end] 

272 ) 

273 return Bezier(np.arange(4) + counts[name]), points 

274 

275 class MultiLine: 

276 # An object to hold one or multiple Line entities. 

277 def __init__(self, lines): 

278 if tol.strict: 

279 # in unit tests make sure we only have lines 

280 assert all(type(L).__name__ in ("Line", "Close") for L in lines) 

281 # get the starting point of every line 

282 points = [L.start for L in lines] 

283 # append the endpoint 

284 points.append(lines[-1].end) 

285 # convert to (n, 2) float points 

286 self.points = np.array([[i.real, i.imag] for i in points], dtype=np.float64) 

287 

288 # load functions for each entity 

289 loaders = { 

290 "Arc": load_arc, 

291 "MultiLine": load_multi, 

292 "CubicBezier": load_cubic, 

293 "QuadraticBezier": load_quadratic, 

294 } 

295 

296 entities = defaultdict(list) 

297 vertices = defaultdict(list) 

298 counts = defaultdict(lambda: 0) 

299 

300 for attrib, matrix in paths: 

301 # the path string is stored under `d` 

302 path_string = attrib.get("d", "") 

303 if len(path_string) == 0: 

304 log.debug("empty path string!") 

305 continue 

306 

307 # get the name of the geometry if trimesh specified it 

308 # note that the get will by default return `None` 

309 name = _decode(attrib.get(_ns + "name")) 

310 # get parsed entities from svg.path 

311 raw = np.array(list(parse_path(path_string))) 

312 

313 # if there is no path string exit 

314 if len(raw) == 0: 

315 continue 

316 

317 # create an integer code for entities we can combine 

318 kinds_lookup = {"Line": 1, "Close": 1, "Arc": 2} 

319 # get a code for each entity we parsed 

320 kinds = np.array([kinds_lookup.get(type(i).__name__, 0) for i in raw], dtype=int) 

321 

322 # find groups of consecutive entities so we can combine 

323 blocks = grouping.blocks(kinds, min_len=1, only_nonzero=False) 

324 

325 if tol.strict: 

326 # in unit tests make sure we didn't lose any entities 

327 assert util.allclose(np.hstack(blocks), np.arange(len(raw))) 

328 

329 # Combine consecutive entities that can be represented 

330 # more concisely as a single trimesh entity. 

331 parsed = [] 

332 for b in blocks: 

333 chunk = raw[b] 

334 current = type(raw[b[0]]).__name__ 

335 if current in ("Line", "Close"): 

336 # if entity consists of lines add a multiline 

337 parsed.append(MultiLine(chunk)) 

338 elif len(b) > 1 and current == "Arc": 

339 # if we have multiple arcs check to see if they 

340 # actually represent a single closed circle 

341 # get a single array with the relevant arc points 

342 verts = np.array( 

343 [ 

344 [ 

345 a.start.real, 

346 a.start.imag, 

347 a.end.real, 

348 a.end.imag, 

349 a.center.real, 

350 a.center.imag, 

351 a.radius.real, 

352 a.radius.imag, 

353 a.rotation, 

354 ] 

355 for a in chunk 

356 ], 

357 dtype=np.float64, 

358 ) 

359 # all arcs share the same center radius and rotation 

360 closed = False 

361 if np.ptp(verts[:, 4:], axis=0).mean() < 1e-3: 

362 start, end = verts[:, :2], verts[:, 2:4] 

363 # if every end point matches the start point of a new 

364 # arc that means this is really a closed circle made 

365 # up of multiple arc segments 

366 closed = util.allclose(start, np.roll(end, 1, axis=0)) 

367 if closed: 

368 # hot-patch a closed arc flag 

369 chunk[0].closed = True 

370 # all arcs in this block are now represented by one entity 

371 parsed.append(chunk[0]) 

372 else: 

373 # we don't have a closed circle so add each 

374 # arc entity individually without combining 

375 parsed.extend(chunk) 

376 else: 

377 # otherwise just add the entities 

378 parsed.extend(chunk) 

379 

380 entity_meta = _attrib_metadata(attrib=attrib) 

381 

382 # loop through parsed entity objects 

383 for svg_entity in parsed: 

384 # keyed by entity class name 

385 type_name = type(svg_entity).__name__ 

386 if type_name in loaders: 

387 # get new entities and vertices 

388 e, v = loaders[type_name](svg_entity) 

389 e.metadata.update(entity_meta) 

390 # append them to the result 

391 entities[name].append(e) 

392 # transform the vertices by the matrix and append 

393 vertices[name].append(transform_points(v, matrix)) 

394 counts[name] += len(v) 

395 

396 # load simple shape geometry 

397 for kind, attrib, matrix in shapes: 

398 # get the geometry name (defaults to None) 

399 name = _decode(attrib.get(_ns + "name")) 

400 

401 if kind == "circle": 

402 points = to_threepoint( 

403 [float(attrib["cx"]), float(attrib["cy"])], float(attrib["r"]) 

404 ) 

405 entity = Arc(points=np.arange(3) + counts[name], closed=True) 

406 

407 elif kind == "rect": 

408 # todo : support rounded rectangle 

409 origin = np.array([attrib["x"], attrib["y"]], dtype=np.float64) 

410 w, h = np.array([attrib["width"], attrib["height"]], dtype=np.float64) 

411 

412 points = np.array( 

413 [origin, origin + (w, 0), origin + (w, h), origin + (0, h), origin], 

414 dtype=np.float64, 

415 ) 

416 entity = Line(points=np.arange(len(points)) + counts[name]) 

417 

418 elif kind == "polyline": 

419 points = np.fromstring( 

420 attrib["points"].strip().replace(",", " "), sep=" ", dtype=np.float64 

421 ).reshape((-1, 2)) 

422 entity = Line(points=np.arange(len(points)) + counts[name]) 

423 

424 elif kind == "polygon": 

425 points = np.fromstring( 

426 attrib["points"].strip().replace(",", " "), sep=" ", dtype=np.float64 

427 ).reshape((-1, 2)) 

428 

429 # polygon implies forced-closed so check to see if it 

430 # is already closed and if not add the closing index 

431 if (points[0] == points[-1]).all(): 

432 index = np.arange(len(points)) + counts[name] 

433 else: 

434 index = np.arange(len(points) + 1) + counts[name] 

435 index[-1] = index[0] 

436 

437 entity = Line(points=index) 

438 

439 elif kind == "line": 

440 points = np.array( 

441 [attrib["x1"], attrib["y1"], attrib["x2"], attrib["y2"]], dtype=np.float64 

442 ).reshape((2, 2)) 

443 entity = Line(points=np.arange(len(points)) + counts[name]) 

444 else: 

445 log.debug(f"unsupported SVG shape: `{kind}`") 

446 continue 

447 

448 entities[name].append(entity) 

449 vertices[name].append(transform_points(points, matrix)) 

450 counts[name] += len(points) 

451 

452 if len(vertices) == 0: 

453 return {"vertices": [], "entities": []} 

454 

455 geoms = { 

456 name: {"vertices": np.vstack(v), "entities": entities[name]} 

457 for name, v in vertices.items() 

458 } 

459 if len(geoms) > 1 or force == "Scene": 

460 kwargs = {"geometry": geoms} 

461 else: 

462 # return a single Path2D 

463 kwargs = next(iter(geoms.values())) 

464 

465 return kwargs 

466 

467 

468def _entities_to_str(entities, vertices, name=None, digits=None, only_layers=None): 

469 """ 

470 Convert the entities of a path to path strings. 

471 

472 Parameters 

473 ------------ 

474 entities : (n,) list 

475 Entity objects 

476 vertices : (m, 2) float 

477 Vertices entities reference 

478 name : any 

479 Trimesh namespace name to assign to entity 

480 digits : int 

481 Number of digits to format exports into 

482 only_layers : set 

483 Only export these layers if passed 

484 """ 

485 if digits is None: 

486 digits = 13 

487 

488 points = vertices.copy() 

489 

490 # generate a format string with the requested digits 

491 temp_digits = f"0.{int(digits)}f" 

492 # generate a format string for circles as two arc segments 

493 temp_circle = ( 

494 "M {x:DI},{y:DI}a{r:DI},{r:DI},0,1,0,{d:DI}," + "0a{r:DI},{r:DI},0,1,0,-{d:DI},0Z" 

495 ).replace("DI", temp_digits) 

496 # generate a format string for an absolute move-to command 

497 temp_move = "M{:DI},{:DI}".replace("DI", temp_digits) 

498 # generate a format string for an absolute-line command 

499 temp_line = "L{:DI},{:DI}".replace("DI", temp_digits) 

500 # generate a format string for a single arc 

501 temp_arc = "M{SX:DI} {SY:DI}A{R},{R} 0 {L:d},{S:d} {EX:DI},{EY:DI}".replace( 

502 "DI", temp_digits 

503 ) 

504 

505 def _cross_2d(a: NDArray, b: NDArray) -> Number: 

506 """ 

507 Numpy 2.0 depreciated cross products of 2D arrays. 

508 """ 

509 return a[0] * b[1] - a[1] * b[0] 

510 

511 def svg_arc(arc): 

512 """ 

513 arc string: (rx ry x-axis-rotation large-arc-flag sweep-flag x y)+ 

514 large-arc-flag: greater than 180 degrees 

515 sweep flag: direction (cw/ccw) 

516 """ 

517 vertices = points[arc.points] 

518 info = arc_center(vertices, return_normal=False, return_angle=True) 

519 C, R, angle = info.center, info.radius, info.span 

520 if arc.closed: 

521 return temp_circle.format(x=C[0] - R, y=C[1], r=R, d=2.0 * R) 

522 

523 vertex_start, vertex_mid, vertex_end = vertices 

524 large_flag = int(angle > np.pi) 

525 sweep_flag = int( 

526 _cross_2d(vertex_mid - vertex_start, vertex_end - vertex_start) > 0.0 

527 ) 

528 return temp_arc.format( 

529 SX=vertex_start[0], 

530 SY=vertex_start[1], 

531 L=large_flag, 

532 S=sweep_flag, 

533 EX=vertex_end[0], 

534 EY=vertex_end[1], 

535 R=R, 

536 ) 

537 

538 def svg_discrete(entity): 

539 """ 

540 Use an entities discrete representation to export a 

541 curve as a polyline 

542 """ 

543 discrete = entity.discrete(points) 

544 # if entity contains no geometry return 

545 if len(discrete) == 0: 

546 return "" 

547 # the format string for the SVG path 

548 return (temp_move + (temp_line * (len(discrete) - 1))).format( 

549 *discrete.reshape(-1) 

550 ) 

551 

552 # tuples of (metadata, path string) 

553 pairs = [] 

554 

555 for entity in entities: 

556 if only_layers is not None and entity.layer not in only_layers: 

557 continue 

558 # check the class name of the entity 

559 if entity.__class__.__name__ == "Arc": 

560 # export the exact version of the entity 

561 path_string = svg_arc(entity) 

562 else: 

563 # just export the polyline version of the entity 

564 path_string = svg_discrete(entity) 

565 meta = deepcopy(entity.metadata) 

566 if name is not None: 

567 meta["name"] = name 

568 pairs.append((meta, path_string)) 

569 return pairs 

570 

571 

572def export_svg(drawing, return_path=False, only_layers=None, digits=None, **kwargs): 

573 """ 

574 Export a Path2D object into an SVG file. 

575 

576 Parameters 

577 ----------- 

578 drawing : Path2D 

579 Source geometry 

580 return_path : bool 

581 If True return only path string not wrapped in XML 

582 only_layers : None or set 

583 If passed only export the specified layers 

584 digits : None or int 

585 Number of digits for floating point values 

586 

587 Returns 

588 ----------- 

589 as_svg : str 

590 XML formatted SVG, or path string 

591 """ 

592 # collect custom attributes for the overall export 

593 attribs = {"class": type(drawing).__name__} 

594 

595 if util.is_instance_named(drawing, "Scene"): 

596 pairs = [] 

597 geom_meta = {} 

598 for name, geom in drawing.geometry.items(): 

599 if not util.is_instance_named(geom, "Path2D"): 

600 continue 

601 geom_meta[name] = geom.metadata 

602 # a pair of (metadata, path string) 

603 pairs.extend( 

604 _entities_to_str( 

605 entities=geom.entities, 

606 vertices=geom.vertices, 

607 name=name, 

608 digits=digits, 

609 only_layers=only_layers, 

610 ) 

611 ) 

612 if len(geom_meta) > 0: 

613 # encode the whole metadata bundle here to avoid 

614 # polluting the file with a ton of loose attribs 

615 attribs["metadata_geometry"] = _encode(geom_meta) 

616 elif util.is_instance_named(drawing, "Path2D"): 

617 pairs = _entities_to_str( 

618 entities=drawing.entities, 

619 vertices=drawing.vertices, 

620 digits=digits, 

621 only_layers=only_layers, 

622 ) 

623 

624 else: 

625 raise ValueError("drawing must be Scene or Path2D object!") 

626 

627 # return path string without XML wrapping 

628 if return_path: 

629 return " ".join(v[1] for v in pairs) 

630 

631 # fetch the export template for the base SVG file 

632 template_svg = resources.get_string("templates/base.svg") 

633 

634 elements = [] 

635 for meta, path_string in pairs: 

636 # create a simple path element 

637 elements.append(f'<path d="{path_string}" {_format_attrib(meta)}/>') 

638 

639 # format as XML 

640 if "stroke_width" in kwargs: 

641 stroke_width = float(kwargs["stroke_width"]) 

642 else: 

643 # set stroke to something OK looking 

644 stroke_width = drawing.extents.max() / 800.0 

645 try: 

646 # store metadata in XML as JSON -_- 

647 attribs["metadata"] = _encode(drawing.metadata) 

648 except BaseException: 

649 # log failed metadata encoding 

650 log.debug("failed to encode", exc_info=True) 

651 

652 subs = { 

653 "elements": "\n".join(elements), 

654 "min_x": drawing.bounds[0][0], 

655 "min_y": drawing.bounds[0][1], 

656 "width": drawing.extents[0], 

657 "height": drawing.extents[1], 

658 "stroke_width": stroke_width, 

659 "attribs": _format_attrib(attribs), 

660 } 

661 return template_svg.format(**subs) 

662 

663 

664def _format_attrib(attrib): 

665 """ 

666 Format attribs into the trimesh namespace. 

667 

668 Parameters 

669 ----------- 

670 attrib : dict 

671 Bag of keys and values. 

672 """ 

673 bag = {k: _encode(v) for k, v in attrib.items()} 

674 return "\n".join( 

675 f'{_ns_name}:{k}="{v}"' 

676 for k, v in bag.items() 

677 if len(k) > 0 and v is not None and len(v) > 0 

678 ) 

679 

680 

681def _encode(stuff): 

682 """ 

683 Wangle things into a string. 

684 

685 Parameters 

686 ----------- 

687 stuff : dict, str 

688 Thing to pack 

689 

690 Returns 

691 ------------ 

692 encoded : str 

693 Packaged into url-safe b64 string 

694 """ 

695 if isinstance(stuff, str) and '"' not in stuff: 

696 return stuff 

697 pack = base64.urlsafe_b64encode( 

698 jsonify( 

699 {k: v for k, v in stuff.items() if not k.startswith("_")}, 

700 separators=(",", ":"), 

701 ).encode("utf-8") 

702 ) 

703 result = "base64," + util.decode_text(pack) 

704 if tol.strict: 

705 # make sure we haven't broken the things 

706 _deep_same(stuff, _decode(result)) 

707 

708 return result 

709 

710 

711def _deep_same(original, other): 

712 """ 

713 Do a recursive comparison of two items to check 

714 our encoding scheme in unit tests. 

715 

716 Parameters 

717 ----------- 

718 original : str, bytes, list, dict 

719 Original item 

720 other : str, bytes, list, dict 

721 Item that should be identical 

722 

723 Raises 

724 ------------ 

725 AssertionError 

726 If items are not the same. 

727 """ 

728 # ndarrays will be converted to lists 

729 # but otherwise types should be identical 

730 if isinstance(original, np.ndarray): 

731 assert isinstance(other, (list, np.ndarray)) 

732 elif isinstance(original, str): 

733 assert isinstance(other, str) 

734 else: 

735 # otherwise they should be the same type 

736 assert isinstance(original, type(other)) 

737 

738 if isinstance(original, (str, bytes)): 

739 # string and bytes should just be identical 

740 assert original == other 

741 return 

742 elif isinstance(original, (float, int, np.ndarray)): 

743 # for Number classes use numpy magic comparison 

744 # which includes an epsilon for floating point 

745 assert np.allclose(original, other) 

746 return 

747 elif isinstance(original, list): 

748 # lengths should match 

749 assert len(original) == len(other) 

750 # every element should be identical 

751 for a, b in zip(original, other): 

752 _deep_same(a, b) 

753 return 

754 

755 # we should have special-cased everything else by here 

756 assert isinstance(original, dict) 

757 

758 # all keys should match 

759 assert set(original.keys()) == set(other.keys()) 

760 # do a recursive comparison of the values 

761 for k in original.keys(): 

762 _deep_same(original[k], other[k]) 

763 

764 

765def _decode(bag): 

766 """ 

767 Decode a base64 bag of stuff. 

768 

769 Parameters 

770 ------------ 

771 bag : str 

772 Starts with `base64,` 

773 

774 Returns 

775 ------------- 

776 loaded : dict 

777 Loaded bag of stuff 

778 """ 

779 if bag is None: 

780 return 

781 text = util.decode_text(bag) 

782 if text.startswith("base64,"): 

783 return json.loads( 

784 base64.urlsafe_b64decode(text[7:].encode("utf-8")).decode("utf-8") 

785 ) 

786 return text 

787 

788 

789_svg_loaders = {"svg": svg_to_path} 

790 

791try: 

792 # pip install svg.path 

793 from svg.path import parse_path 

794except BaseException as E: 

795 # will re-raise the import exception when 

796 # someone tries to call `parse_path` 

797 parse_path = exceptions.ExceptionWrapper(E) 

798 _svg_loaders["svg"] = parse_path 

799 

800try: 

801 from lxml import etree 

802 

803 from ...exchange.common import XML_PARSER_OPTIONS 

804except BaseException as E: 

805 # will re-raise the import exception when 

806 # someone actually tries to use the module 

807 etree = exceptions.ExceptionWrapper(E) 

808 _svg_loaders["svg"] = etree