picosvgx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
picosvgx/svg.py ADDED
@@ -0,0 +1,1697 @@
1
+ # Copyright 2020 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from collections import defaultdict, deque
16
+ import copy
17
+ import dataclasses
18
+ from functools import lru_cache, reduce
19
+ import itertools
20
+ from lxml import etree # pytype: disable=import-error
21
+ import re
22
+ from typing import (
23
+ Any,
24
+ Generator,
25
+ Iterable,
26
+ List,
27
+ Mapping,
28
+ MutableMapping,
29
+ NamedTuple,
30
+ Optional,
31
+ Sequence,
32
+ Tuple,
33
+ )
34
+ from picosvgx.svg_meta import (
35
+ ATTRIB_DEFAULTS,
36
+ attrib_default,
37
+ number_or_percentage,
38
+ parse_css_length,
39
+ ntos,
40
+ splitns,
41
+ strip_ns,
42
+ svgns,
43
+ xlinkns,
44
+ parse_css_declarations,
45
+ parse_view_box,
46
+ SVG_LENGTH_FIELDS,
47
+ _LinkedDefault,
48
+ )
49
+ from picosvgx.svg_types import *
50
+ from picosvgx.svg_transform import Affine2D
51
+ import numbers
52
+
53
+ _SHAPE_CLASSES = {
54
+ "circle": SVGCircle,
55
+ "ellipse": SVGEllipse,
56
+ "line": SVGLine,
57
+ "path": SVGPath,
58
+ "polygon": SVGPolygon,
59
+ "polyline": SVGPolyline,
60
+ "rect": SVGRect,
61
+ }
62
+ _GRADIENT_CLASSES = {
63
+ "linearGradient": SVGLinearGradient,
64
+ "radialGradient": SVGRadialGradient,
65
+ }
66
+ _CLASS_ELEMENTS = {
67
+ v: f"{{{svgns()}}}{k}" for k, v in {**_SHAPE_CLASSES, **_GRADIENT_CLASSES}.items()
68
+ }
69
+ _SHAPE_CLASSES.update({f"{{{svgns()}}}{k}": v for k, v in _SHAPE_CLASSES.items()})
70
+
71
+ _SHAPE_FIELDS = {
72
+ tag: tuple(f.name for f in dataclasses.fields(klass))
73
+ for tag, klass in _SHAPE_CLASSES.items()
74
+ }
75
+ _GRADIENT_FIELDS = {
76
+ tag: tuple(f.name for f in dataclasses.fields(klass))
77
+ for tag, klass in _GRADIENT_CLASSES.items()
78
+ }
79
+ # Manually add stop, we don't type it
80
+ _GRADIENT_FIELDS["stop"] = tuple({"offset", "stop_color", "stop_opacity"})
81
+
82
+ _GRADIENT_COORDS = {
83
+ "linearGradient": (("x1", "y1"), ("x2", "y2")),
84
+ "radialGradient": (("cx", "cy"), ("fx", "fy")),
85
+ }
86
+
87
+ _VALID_FIELDS = {}
88
+ _VALID_FIELDS.update(_SHAPE_FIELDS)
89
+ _VALID_FIELDS.update(_GRADIENT_FIELDS)
90
+
91
+ _XLINK_TEMP = "xlink_"
92
+
93
+
94
+ _ATTRIB_W_CUSTOM_INHERITANCE = frozenset({"clip-path", "opacity", "transform"})
95
+
96
+
97
+ # How much error, as pct of viewbox max(w,h), is allowed on lossy ops
98
+ # For example, for a Noto Emoji with a viewBox 0 0 128 128 permit error of 0.128
99
+ _MAX_PCT_ERROR = 0.1
100
+
101
+ # When you have no viewbox, use this. Absolute value in svg units.
102
+ _DEFAULT_DEFAULT_TOLERENCE = 0.1
103
+
104
+
105
+ # Rounding for rewritten gradient matrices
106
+ _GRADIENT_TRANSFORM_NDIGITS = 6
107
+
108
+ # Safe SVG elements allowed in defs (top-level)
109
+ _DEFS_ALLOWED_TAGS = frozenset({
110
+ "filter", "mask", "pattern", "clipPath", "marker", "symbol",
111
+ "linearGradient", "radialGradient", "stop",
112
+ "rect", "circle", "ellipse", "line", "polyline", "polygon",
113
+ "path", "g", "image", "use",
114
+ })
115
+
116
+ # Safe SVG elements allowed as children inside defs elements
117
+ _DEFS_CHILD_ALLOWED_TAGS = frozenset({
118
+ # filter primitives
119
+ "feGaussianBlur", "feOffset", "feMerge", "feMergeNode", "feBlend",
120
+ "feColorMatrix", "feComponentTransfer", "feComposite", "feConvolveMatrix",
121
+ "feDiffuseLighting", "feDisplacementMap", "feDropShadow", "feFlood",
122
+ "feFuncR", "feFuncG", "feFuncB", "feFuncA",
123
+ "feImage", "feMorphology", "feSpecularLighting", "feTile", "feTurbulence",
124
+ # lighting sources
125
+ "fePointLight", "feSpotLight", "feDistantLight",
126
+ # basic shapes & structural
127
+ "rect", "circle", "ellipse", "line", "polyline", "polygon",
128
+ "path", "text", "use", "g", "image",
129
+ # other
130
+ "stop", "animate", "animateTransform", "set",
131
+ })
132
+
133
+
134
+ def _clamp(value: float, minv: float = 0.0, maxv: float = 1.0) -> float:
135
+ return max(min(value, maxv), minv)
136
+
137
+
138
+ def _xlink_href_attr_name() -> str:
139
+ return f"{{{xlinkns()}}}href"
140
+
141
+
142
+ def _copy_new_nsmap(tree, nsm):
143
+ new_tree = etree.Element(tree.tag, nsmap=nsm)
144
+ new_tree.attrib.update(tree.attrib)
145
+ new_tree[:] = tree[:]
146
+ return new_tree
147
+
148
+
149
+ def _fix_xlink_ns(tree):
150
+ """Fix namespace problems for SVG and xlink.
151
+
152
+ Ensure SVG has proper default namespace.
153
+ If there are xlink temps, add namespace and fix temps.
154
+ If we declare xlink but don't use it then remove it.
155
+ """
156
+ # Ensure SVG has proper default namespace
157
+ nsm = copy.copy(tree.nsmap)
158
+ if nsm.get(None) != svgns():
159
+ nsm[None] = svgns()
160
+ tree = _copy_new_nsmap(tree, nsm)
161
+ xlink_nsmap = {"xlink": xlinkns()}
162
+ if "xlink" in tree.nsmap and not len(
163
+ tree.xpath("//*[@xlink:href]", namespaces=xlink_nsmap)
164
+ ):
165
+ # no reason to keep xlink
166
+ nsm = copy.copy(tree.nsmap)
167
+ del nsm["xlink"]
168
+ tree = _copy_new_nsmap(tree, nsm)
169
+
170
+ elif "xlink" not in tree.nsmap and len(tree.xpath(f"//*[@{_XLINK_TEMP}]")):
171
+ # declare xlink and fix temps
172
+ nsm = copy.copy(tree.nsmap)
173
+ nsm["xlink"] = xlinkns()
174
+ tree = _copy_new_nsmap(tree, nsm)
175
+ for el in tree.xpath(f"//*[@{_XLINK_TEMP}]"):
176
+ # try to retain attrib order, unexpected when they shuffle
177
+ attrs = [(k, v) for k, v in el.attrib.items()]
178
+ el.attrib.clear()
179
+ for name, value in attrs:
180
+ if name == _XLINK_TEMP:
181
+ name = _xlink_href_attr_name()
182
+ el.attrib[name] = value
183
+
184
+ return tree
185
+
186
+
187
+ def _del_attrs(el, *attr_names):
188
+ for name in attr_names:
189
+ if name in el.attrib:
190
+ del el.attrib[name]
191
+
192
+
193
+ def _attr_name(field_name: str) -> str:
194
+ return field_name.replace("_", "-")
195
+
196
+
197
+ def _field_name(attr_name: str) -> str:
198
+ return attr_name.replace("-", "_")
199
+
200
+
201
+ def _is_defs(tag):
202
+ return strip_ns(tag) == "defs"
203
+
204
+
205
+ def _is_shape(tag):
206
+ return strip_ns(tag) in _SHAPE_CLASSES
207
+
208
+
209
+ def _is_gradient(tag):
210
+ return strip_ns(tag) in _GRADIENT_CLASSES
211
+
212
+
213
+ def _is_group(tag):
214
+ return strip_ns(tag) == "g"
215
+
216
+
217
+ def _opacity(el: etree.Element) -> float:
218
+ return _clamp(float(el.attrib.get("opacity", 1.0)))
219
+
220
+
221
+ def _is_redundant(tag):
222
+ return tag is etree.Comment or tag is etree.ProcessingInstruction
223
+
224
+
225
+ def _is_removable_group(el):
226
+ """
227
+ Groups with:
228
+
229
+ 0 < opacity < 1
230
+ >1 child
231
+
232
+ must be retained.
233
+
234
+ This over-retains groups; no difference unless children overlap
235
+ """
236
+ if not _is_group(el):
237
+ return False
238
+ # no attributes makes a group meaningless
239
+ if len(el.attrib) == 0:
240
+ return True
241
+ num_children = sum(1 for e in el if not _is_redundant(e.tag))
242
+
243
+ return num_children <= 1 or _opacity(el) in {0.0, 1.0}
244
+
245
+
246
+ def _try_remove_group(group_el, push_opacity=True):
247
+ """
248
+ Transfer children of group to their parent if possible.
249
+
250
+ Only groups with 0 < opacity < 1 *and* multiple children must be retained.
251
+
252
+ This over-retains groups; no difference unless children overlap
253
+ """
254
+ assert _is_group(group_el)
255
+
256
+ remove = _is_removable_group(group_el)
257
+ opacity = _opacity(group_el)
258
+ if remove:
259
+ children = list(group_el)
260
+ if group_el.getparent() is not None:
261
+ _replace_el(group_el, list(group_el))
262
+ if push_opacity:
263
+ for child in children:
264
+ if _is_redundant(child.tag):
265
+ continue
266
+ _inherit_attrib({"opacity": opacity}, child)
267
+ else:
268
+ # We're keeping the group, but we promised groups only have opacity
269
+ group_el.attrib.clear()
270
+ group_el.attrib["opacity"] = ntos(opacity)
271
+ _drop_default_attrib(group_el.attrib)
272
+ return remove
273
+
274
+
275
+ def _element_transform(el, current_transform=Affine2D.identity()):
276
+ attr_name = "transform"
277
+ if _is_gradient(el.tag):
278
+ attr_name = "gradientTransform"
279
+
280
+ raw = el.attrib.get(attr_name, None)
281
+ if raw:
282
+ return Affine2D.compose_ltr((Affine2D.fromstring(raw), current_transform))
283
+ return current_transform
284
+
285
+
286
+ def from_element(el, **inherited_attrib):
287
+ if not _is_shape(el.tag):
288
+ raise ValueError(f"Bad tag <{el.tag}>")
289
+ data_type = _SHAPE_CLASSES[el.tag]
290
+ attrs = {**inherited_attrib, **el.attrib}
291
+
292
+ args = {}
293
+ for f in dataclasses.fields(data_type):
294
+ attr_name = _attr_name(f.name)
295
+ if attr_name not in attrs or not attrs[attr_name].strip():
296
+ continue
297
+
298
+ attr_value = attrs[attr_name]
299
+ if f.type == float and f.name in SVG_LENGTH_FIELDS and isinstance(attr_value, str):
300
+ # For CSS length values (including percentages and units), use parse_css_length
301
+ try:
302
+ if attr_value.endswith('%'):
303
+ # For percentage values, use number_or_percentage with appropriate scaling
304
+ args[f.name] = number_or_percentage(attr_value, scale=100)
305
+ else:
306
+ # For other CSS units (px, pt, em, etc.), use parse_css_length
307
+ args[f.name] = parse_css_length(attr_value)
308
+ except (ValueError, TypeError):
309
+ # If parsing fails, try the original type conversion as fallback
310
+ args[f.name] = f.type(attr_value)
311
+ else:
312
+ # Use the original type conversion
313
+ args[f.name] = f.type(attr_value)
314
+
315
+ return data_type(**args)
316
+
317
+ def to_element(data_obj, **inherited_attrib):
318
+ el = etree.Element(_CLASS_ELEMENTS[type(data_obj)])
319
+ for field in dataclasses.fields(data_obj):
320
+ attr_name = _attr_name(field.name)
321
+ field_value = getattr(data_obj, field.name)
322
+ # omit attributes whose value == the respective default,
323
+ # unless it's != from the attribute value inherited from context
324
+ if isinstance(field.default, _LinkedDefault):
325
+ default_value = field.default(data_obj)
326
+ else:
327
+ default_value = field.default
328
+ attrib_value = field_value
329
+ if isinstance(attrib_value, numbers.Number):
330
+ attrib_value = ntos(attrib_value)
331
+ elif isinstance(attrib_value, Affine2D):
332
+ attrib_value = attrib_value.tostring()
333
+ if attr_name in inherited_attrib:
334
+ if attrib_value == inherited_attrib[attr_name]:
335
+ continue
336
+ elif field_value == default_value:
337
+ continue
338
+ el.attrib[attr_name] = attrib_value
339
+ return el
340
+
341
+
342
+ def _reset_attrs(data_obj, field_pred):
343
+ for field in dataclasses.fields(data_obj):
344
+ if field_pred(field):
345
+ setattr(data_obj, field.name, field.default)
346
+
347
+
348
+ def _safe_remove(el: etree.Element):
349
+ parent = el.getparent()
350
+ if parent is not None:
351
+ parent.remove(el)
352
+
353
+
354
+ def _id_of_target(url):
355
+ match = re.match(r"^url[(]#([\w-]+)[)]$", url)
356
+ if not match:
357
+ raise ValueError(f'Unrecognized url "{url}"')
358
+ return match.group(1)
359
+
360
+
361
+ def _xpath_for_url(url, el_tag):
362
+ return f'//svg:{el_tag}[@id="{_id_of_target(url)}"]'
363
+
364
+
365
+ def _attrib_to_pass_on(current_attrib, el, skips=_ATTRIB_W_CUSTOM_INHERITANCE):
366
+ attr_catcher = etree.Element("dummy")
367
+ _inherit_attrib(el.attrib, attr_catcher, skips=skips, skip_unhandled=True)
368
+ _inherit_attrib(current_attrib, attr_catcher, skips=skips)
369
+ return dict(attr_catcher.attrib)
370
+
371
+
372
+ def _replace_el(el, replacements):
373
+ parent = el.getparent()
374
+ idx = parent.index(el)
375
+ parent.remove(el)
376
+ for child_idx, child in enumerate(replacements):
377
+ parent.insert(idx + child_idx, child)
378
+
379
+
380
+ class SVGTraverseContext(NamedTuple):
381
+ nth_of_type: int
382
+ element: etree.Element
383
+ path: str
384
+ transform: Affine2D
385
+ clips: Tuple[SVGPath, ...]
386
+ attrib: Mapping[str, Any] # except _ATTRIB_W_CUSTOM_INHERITANCE
387
+
388
+ def depth(self) -> int:
389
+ return self.path.count("/") - 1
390
+
391
+ def shape(self) -> SVGShape:
392
+ return from_element(self.element, **self.attrib)
393
+
394
+ def is_shape(self):
395
+ return _is_shape(self.element)
396
+
397
+ def is_group(self):
398
+ return _is_group(self.element)
399
+
400
+
401
+ class SVG:
402
+ svg_root: etree.Element
403
+ elements: List[Tuple[etree.Element, Tuple[SVGShape, ...]]]
404
+
405
+ def __init__(self, svg_root):
406
+ self.svg_root = svg_root
407
+ self.elements = []
408
+
409
+ def _clone(self) -> "SVG":
410
+ return SVG(svg_root=copy.deepcopy(self.svg_root))
411
+
412
+ def _elements(self) -> List[Tuple[etree.Element, Tuple[SVGShape, ...]]]:
413
+ if self.elements:
414
+ return self.elements
415
+ elements = []
416
+ for context in self.depth_first(resolve_clip_paths=False):
417
+ el = context.element
418
+ if el.tag not in _SHAPE_CLASSES:
419
+ continue
420
+ elements.append((el, (context.shape(),)))
421
+ self.elements = elements
422
+ return self.elements
423
+
424
+ def _set_element(self, idx: int, el: etree.Element, shapes: Tuple[SVGShape, ...]):
425
+ self.elements[idx] = (el, shapes)
426
+
427
+ def _fallback_view_box(self) -> Optional[Rect]:
428
+ """Derive viewBox from width/height when viewBox is missing or empty."""
429
+ w = self.svg_root.attrib.get("width", None)
430
+ h = self.svg_root.attrib.get("height", None)
431
+ if not w or not h:
432
+ return None
433
+ try:
434
+ return Rect(0, 0, parse_css_length(w), parse_css_length(h))
435
+ except (ValueError, TypeError):
436
+ try:
437
+ return Rect(0, 0, float(w), float(h))
438
+ except (ValueError, TypeError):
439
+ return None
440
+
441
+ def view_box(self) -> Optional[Rect]:
442
+ if "viewBox" not in self.svg_root.attrib:
443
+ return self._fallback_view_box()
444
+
445
+ viewbox_value = self.svg_root.attrib["viewBox"]
446
+ if not viewbox_value or not viewbox_value.strip():
447
+ return self._fallback_view_box()
448
+
449
+ return parse_view_box(viewbox_value)
450
+
451
+ def _default_tolerance(self):
452
+ vbox = self.view_box()
453
+ # Absence of viewBox is unusual
454
+ if vbox is None:
455
+ return _DEFAULT_DEFAULT_TOLERENCE
456
+ return min(vbox.w, vbox.h) * _MAX_PCT_ERROR / 100
457
+
458
+ @property
459
+ def tolerance(self):
460
+ return self._default_tolerance()
461
+
462
+ def shapes(self):
463
+ """Returns all shapes in order encountered.
464
+
465
+ Use to operate per-shape; if you want to iterate over graph use breadth_first.
466
+ """
467
+ return tuple(shape for (_, shapes) in self._elements() for shape in shapes)
468
+
469
+ def bounding_box(self) -> Optional[Rect]:
470
+ """Returns the bounding box of this SVG."""
471
+ shapes = self.shapes()
472
+ if not shapes:
473
+ return None
474
+ return reduce(
475
+ lambda a, b: a.union(b), (shape.bounding_box() for shape in shapes)
476
+ )
477
+
478
+ def absolute(self, inplace=False):
479
+ """Converts all basic shapes to their equivalent path."""
480
+ if not inplace:
481
+ svg = self._clone()
482
+ svg.absolute(inplace=True)
483
+ return svg
484
+
485
+ swaps = []
486
+ for idx, (el, (shape,)) in enumerate(self._elements()):
487
+ self.elements[idx] = (el, (shape.absolute(),))
488
+ return self
489
+
490
+ def shapes_to_paths(self, inplace=False):
491
+ """Converts all basic shapes to their equivalent path."""
492
+ if not inplace:
493
+ svg = self._clone()
494
+ svg.shapes_to_paths(inplace=True)
495
+ return svg
496
+
497
+ swaps = []
498
+ for idx, (el, (shape,)) in enumerate(self._elements()):
499
+ self.elements[idx] = (el, (shape.as_path(),))
500
+ return self
501
+
502
+ def expand_shorthand(self, inplace=False):
503
+ if not inplace:
504
+ svg = self._clone()
505
+ svg.expand_shorthand(inplace=True)
506
+ return svg
507
+
508
+ for idx, (el, (shape,)) in enumerate(self._elements()):
509
+ if isinstance(shape, SVGPath):
510
+ self.elements[idx] = (
511
+ el,
512
+ (shape.explicit_lines().expand_shorthand(inplace=True),),
513
+ )
514
+ return self
515
+
516
+ def _apply_styles(self, el: etree.Element):
517
+ parse_css_declarations(el.attrib.pop("style", ""), el.attrib)
518
+
519
+ def apply_style_attributes(self, inplace=False):
520
+ """Converts inlined CSS "style" attributes to equivalent SVG attributes."""
521
+ if not inplace:
522
+ svg = self._clone()
523
+ svg.apply_style_attributes(inplace=True)
524
+ return svg
525
+
526
+ if self.elements:
527
+ # if we already parsed the SVG shapes, apply style attrs and sync tree
528
+ for shape in self.shapes():
529
+ shape.apply_style_attribute(inplace=True)
530
+ self._update_etree()
531
+
532
+ # parse all remaining style attributes (e.g. in gradients or root svg element)
533
+ for el in itertools.chain((self.svg_root,), self.xpath("//svg:*[@style]")):
534
+ self._apply_styles(el)
535
+
536
+ return self
537
+
538
+ def xpath(self, xpath: str, el: etree.Element = None, expected_result_range=None):
539
+ if el is None:
540
+ el = self.svg_root
541
+ results = el.xpath(xpath, namespaces={"svg": svgns()})
542
+ if expected_result_range and len(results) not in expected_result_range:
543
+ raise ValueError(
544
+ f"Expected {xpath} matches in {expected_result_range}, {len(results)} results"
545
+ )
546
+ return results
547
+
548
+ def xpath_one(self, xpath):
549
+ return self.xpath(xpath, expected_result_range=range(1, 2))[0]
550
+
551
+ def resolve_url(self, url, el_tag):
552
+ return self.xpath_one(_xpath_for_url(url, el_tag))
553
+
554
+ def _resolve_use(self, scope_el):
555
+ attrib_not_copied = {
556
+ "x",
557
+ "y",
558
+ "width",
559
+ "height",
560
+ "transform",
561
+ _xlink_href_attr_name(),
562
+ }
563
+
564
+ # capture elements by id so even if we change it they remain stable
565
+ el_by_id = {el.attrib["id"]: el for el in self.xpath(".//svg:*[@id]")}
566
+
567
+ while True:
568
+ swaps = []
569
+ use_els = list(self.xpath(".//svg:use", el=scope_el))
570
+ if not use_els:
571
+ break
572
+ for use_el in use_els:
573
+ ref = use_el.attrib.get(_xlink_href_attr_name(), "")
574
+ if not ref.startswith("#"):
575
+ raise ValueError(f"Only use #fragment supported, reject {ref}")
576
+
577
+ target = el_by_id.get(ref[1:], None)
578
+ if target is None:
579
+ raise ValueError(f"No element has id '{ref[1:]}'")
580
+
581
+ new_el = copy.deepcopy(target)
582
+ # leaving id's on <use> instantiated content is a path to duplicate ids
583
+ for el in new_el.getiterator("*"):
584
+ if "id" in el.attrib:
585
+ del el.attrib["id"]
586
+
587
+ group = etree.Element(f"{{{svgns()}}}g", nsmap=self.svg_root.nsmap)
588
+ affine = Affine2D.identity().translate(
589
+ float(use_el.attrib.get("x", 0)), float(use_el.attrib.get("y", 0))
590
+ )
591
+
592
+ if "transform" in use_el.attrib:
593
+ affine = Affine2D.compose_ltr(
594
+ (
595
+ affine,
596
+ Affine2D.fromstring(use_el.attrib["transform"]),
597
+ )
598
+ )
599
+
600
+ if affine != Affine2D.identity():
601
+ group.attrib["transform"] = affine.tostring()
602
+
603
+ for attr_name in use_el.attrib:
604
+ if attr_name in attrib_not_copied:
605
+ continue
606
+ group.attrib[attr_name] = use_el.attrib[attr_name]
607
+
608
+ group.append(new_el)
609
+
610
+ if _try_remove_group(group, push_opacity=False):
611
+ _inherit_attrib(group.attrib, new_el)
612
+ swaps.append((use_el, new_el))
613
+ else:
614
+ swaps.append((use_el, group))
615
+
616
+ for old_el, new_el in swaps:
617
+ old_el.getparent().replace(old_el, new_el)
618
+
619
+ def resolve_use(self, inplace=False):
620
+ """Instantiate reused elements.
621
+
622
+ https://www.w3.org/TR/SVG11/struct.html#UseElement"""
623
+ if not inplace:
624
+ svg = self._clone()
625
+ svg.resolve_use(inplace=True)
626
+ return svg
627
+
628
+ self._update_etree()
629
+ self._resolve_use(self.svg_root)
630
+ return self
631
+
632
+ def _resolve_clip_path(
633
+ self, clip_path_url, transform=Affine2D.identity()
634
+ ) -> SVGPath:
635
+ clip_path_el = self.resolve_url(clip_path_url, "clipPath")
636
+ self._resolve_use(clip_path_el)
637
+
638
+ transform = _element_transform(clip_path_el, transform)
639
+ clip_paths = [
640
+ from_element(e).apply_transform(_element_transform(e, transform))
641
+ for e in clip_path_el
642
+ if _is_shape(e.tag)
643
+ ]
644
+
645
+ # Return empty path if no valid clip paths found
646
+ if not clip_paths:
647
+ return SVGPath()
648
+
649
+ clip = SVGPath.from_commands(union(clip_paths))
650
+
651
+ if "clip-path" in clip_path_el.attrib:
652
+ # TODO cycle detection
653
+ clip_clop = self._resolve_clip_path(
654
+ clip_path_el.attrib["clip-path"], transform
655
+ )
656
+ clip = SVGPath.from_commands(intersection([clip, clip_clop]))
657
+
658
+ return clip
659
+
660
+ def append_to(self, xpath, el):
661
+ self._update_etree()
662
+ self.xpath_one(xpath).append(el)
663
+ return el
664
+
665
+ def _new_id(self, template):
666
+ for i in range(1 << 16):
667
+ potential_id = template % i
668
+ existing = self.xpath(f'//svg:*[@id="{potential_id}"]')
669
+ if not existing:
670
+ return potential_id
671
+ raise ValueError(f"No free id for {template}")
672
+
673
+ def _traverse(self, next_fn, append_fn, resolve_clip_paths=True):
674
+ frontier = [
675
+ SVGTraverseContext(
676
+ 0,
677
+ self.svg_root,
678
+ "/svg[0]",
679
+ Affine2D.identity(),
680
+ (),
681
+ _attrib_to_pass_on(_INHERITABLE_ATTRIB_DEFAULTS, self.svg_root),
682
+ )
683
+ ]
684
+ while frontier:
685
+ context = next_fn(frontier)
686
+ yield context
687
+
688
+ child_idxs = defaultdict(int)
689
+ new_entries = []
690
+ for child in context.element:
691
+ if _is_redundant(child.tag):
692
+ continue
693
+ transform = _element_transform(child, context.transform)
694
+ clips = context.clips
695
+ if (
696
+ resolve_clip_paths
697
+ and child.attrib.get("clip-path") # neither None nor ""
698
+ and child.attrib["clip-path"] != "none"
699
+ ):
700
+ clips += (
701
+ self._resolve_clip_path(child.attrib["clip-path"], transform),
702
+ )
703
+
704
+ nth_of_type = child_idxs[strip_ns(child.tag)]
705
+ child_idxs[strip_ns(child.tag)] += 1
706
+ path = f"{context.path}/{strip_ns(child.tag)}[{nth_of_type}]"
707
+ child_context = SVGTraverseContext(
708
+ nth_of_type,
709
+ child,
710
+ path,
711
+ transform,
712
+ clips,
713
+ _attrib_to_pass_on(context.attrib, child),
714
+ )
715
+ new_entries.append(child_context)
716
+ append_fn(frontier, new_entries)
717
+
718
+ def depth_first(self, resolve_clip_paths=True):
719
+ # dfs will take from the back
720
+ # reverse so this still yields in order (first child, second child, etc)
721
+ # makes processing feel more intuitive
722
+ yield from self._traverse(
723
+ lambda f: f.pop(),
724
+ lambda f, e: f.extend(reversed(e)),
725
+ resolve_clip_paths=resolve_clip_paths,
726
+ )
727
+
728
+ def breadth_first(self, resolve_clip_paths=True):
729
+ yield from self._traverse(
730
+ lambda f: f.pop(0),
731
+ lambda f, e: f.extend(e),
732
+ resolve_clip_paths=resolve_clip_paths,
733
+ )
734
+
735
+ def _add_to_defs(self, defs, new_el):
736
+ if "id" not in new_el.attrib:
737
+ return # idless defs are useless
738
+ new_id = new_el.attrib["id"]
739
+ insert_at = 0
740
+ for i, el in enumerate(defs):
741
+ if new_id < el.attrib["id"]:
742
+ insert_at = i
743
+ break
744
+ defs.insert(insert_at, new_el)
745
+
746
+ def _transformed_gradient(self, defs, fill_el, transform, shape_bbox):
747
+ assert _is_gradient(fill_el), f"Not sure how to fill from {fill_el.tag}"
748
+
749
+ gradient = (
750
+ _GRADIENT_CLASSES[strip_ns(fill_el.tag)]
751
+ .from_element(fill_el, self.view_box())
752
+ .as_user_space_units(shape_bbox, inplace=True)
753
+ )
754
+ gradient.gradientTransform = Affine2D.compose_ltr(
755
+ (gradient.gradientTransform, transform)
756
+ ).round(_GRADIENT_TRANSFORM_NDIGITS)
757
+ gradient.id = self._new_id(gradient.id + "_%d")
758
+
759
+ new_fill = to_element(gradient)
760
+ # TODO normalize stop elements too
761
+ new_fill.extend(copy.deepcopy(stop) for stop in fill_el)
762
+
763
+ self._apply_gradient_translation(new_fill)
764
+
765
+ self._add_to_defs(defs, new_fill)
766
+ return new_fill
767
+
768
+ def _simplify(self, allow_all_defs=False):
769
+ """
770
+ Removes groups where possible, applies transforms, applies clip paths.
771
+ """
772
+ # Reversed: we want leaves first
773
+ to_process = reversed(tuple(c for c in self.breadth_first()))
774
+
775
+ defs = etree.Element(f"{{{svgns()}}}defs")
776
+ self.svg_root.insert(0, defs)
777
+
778
+ for context in to_process:
779
+ if "clipPath" in context.path:
780
+ _safe_remove(context.element)
781
+ continue
782
+
783
+ el = context.element
784
+
785
+ # When allow_all_defs is enabled, completely skip non-gradient
786
+ # elements inside defs (filter, mask, pattern, etc.) so that
787
+ # _del_attrs, _inherit_attrib, and shape processing leave them
788
+ # untouched — preserving transform and all other attributes.
789
+ if allow_all_defs and re.search(r"/defs\[\d+\]", context.path):
790
+ tag_name = strip_ns(el.tag)
791
+ if tag_name not in _GRADIENT_CLASSES and tag_name != "defs" and tag_name != "stop":
792
+ continue
793
+
794
+ _del_attrs(el, "clip-path", "transform") # handled separately
795
+ _inherit_attrib(context.attrib, el)
796
+
797
+ # Only some elements change
798
+ if _is_shape(el.tag):
799
+ assert len(el) == 0, "Shapes shouldn't have children"
800
+
801
+ # If we are transformed and we use a gradient we may need to
802
+ # emit the transformed gradient
803
+ if context.transform != Affine2D.identity() and "url" in el.attrib.get(
804
+ "fill", ""
805
+ ):
806
+ fill_el = self.resolve_url(el.attrib["fill"], "*")
807
+ # Only apply gradient transform if fill_el is actually a gradient
808
+ # (not pattern or other paint server)
809
+ if _is_gradient(fill_el.tag):
810
+ self._apply_gradient_template(fill_el)
811
+ fill_el = self._transformed_gradient(
812
+ defs,
813
+ fill_el,
814
+ context.transform,
815
+ from_element(el).bounding_box(),
816
+ )
817
+ fill_id = fill_el.attrib["id"]
818
+ el.attrib["fill"] = f"url(#{fill_id})"
819
+
820
+ paths = [from_element(el).as_path().absolute(inplace=True)]
821
+ initial_path = copy.deepcopy(paths[0])
822
+
823
+ # stroke may introduce multiple paths
824
+ assert len(paths) == 1 # oh ye of little faith
825
+ if paths[0].stroke != "none":
826
+ paths = list(self._stroke(paths[0]))
827
+
828
+ # Any remaining stroke attributes don't do anything
829
+ # For example, a stroke-width with no stroke set is removed
830
+ for path in paths:
831
+ _reset_attrs(path, lambda field: field.name.startswith("stroke"))
832
+
833
+ # Apply any transform
834
+ if context.transform != Affine2D.identity():
835
+ paths = [p.apply_transform(context.transform) for p in paths]
836
+
837
+ if context.clips:
838
+ for p in paths:
839
+ # When constructing pathops.Path objects for performing the
840
+ # intersection operation, we need to use the fill-rule attribute
841
+ # for the shape to be clipped, and clip-rule for the clipping
842
+ # path itself (clip-rule only applies within clipPath element).
843
+ p.update_path(
844
+ intersection(
845
+ (p, *context.clips),
846
+ fill_rules=(
847
+ p.fill_rule,
848
+ *(c.clip_rule for c in context.clips),
849
+ ),
850
+ ),
851
+ inplace=True,
852
+ )
853
+ # skia-pathops operations always return nonzero winding paths
854
+ p.fill_rule = "nonzero"
855
+
856
+ if len(paths) != 1 or paths[0] != initial_path:
857
+ _replace_el(el, [to_element(p) for p in paths])
858
+
859
+ elif _is_gradient(el.tag):
860
+ _safe_remove(el)
861
+ self._add_to_defs(defs, el)
862
+ self._apply_gradient_template(el)
863
+ self._apply_gradient_translation(el)
864
+
865
+ elif _is_defs(el.tag):
866
+ # any children were already processed
867
+ # now just moved to master defs
868
+ for child_el in el:
869
+ self._add_to_defs(defs, child_el)
870
+ _safe_remove(el)
871
+
872
+ elif _is_group(el.tag):
873
+ _try_remove_group(el)
874
+
875
+ # https://github.com/googlefonts/nanoemoji/issues/275
876
+ _del_attrs(self.svg_root, *_INHERITABLE_ATTRIB)
877
+
878
+ self._remove_orphaned_gradients()
879
+
880
+ # After simplification only gradient defs should be referenced
881
+ # It's illegal for picosvg to leave anything else in defs
882
+ # Unless allow_all_defs is True, in which case we keep all defs elements
883
+ if not allow_all_defs:
884
+ for unused_el in [el for el in defs if not _is_gradient(el)]:
885
+ defs.remove(unused_el)
886
+
887
+ self.elements = None # force elements to reload
888
+
889
+ def simplify(self, inplace=False, allow_all_defs=False):
890
+ if not inplace:
891
+ svg = self._clone()
892
+ svg.simplify(inplace=True, allow_all_defs=allow_all_defs)
893
+ return svg
894
+
895
+ self._update_etree()
896
+ self._simplify(allow_all_defs=allow_all_defs)
897
+ return self
898
+
899
+ def _stroke(self, shape):
900
+ """Convert stroke to path.
901
+
902
+ Returns sequence of shapes in draw order. That is, result[1] should be
903
+ drawn on top of result[0], etc."""
904
+
905
+ assert shape.stroke != "none"
906
+
907
+ # make a new path that is the stroke
908
+ stroke = shape.as_path().update_path(shape.stroke_commands(self.tolerance))
909
+
910
+ # skia stroker returns paths with 'nonzero' winding fill rule
911
+ stroke.fill_rule = stroke.clip_rule = "nonzero"
912
+
913
+ # a few attributes move in interesting ways
914
+ stroke.opacity *= stroke.stroke_opacity
915
+ stroke.fill = stroke.stroke
916
+ # the fill and stroke are now different (filled) paths, reset 'fill_opacity'
917
+ # to default and only use a combined 'opacity' in each one.
918
+ shape.opacity *= shape.fill_opacity
919
+ shape.fill_opacity = stroke.fill_opacity = 1.0
920
+
921
+ # remove all the stroke settings
922
+ for cleanmeup in (shape, stroke):
923
+ _reset_attrs(cleanmeup, lambda field: field.name.startswith("stroke"))
924
+
925
+ if not shape.might_paint():
926
+ return (stroke,)
927
+
928
+ # The original id doesn't correctly refer to either
929
+ # It would be for the best if any id-based operations happened first
930
+ shape.id = stroke.id = ""
931
+
932
+ return (shape, stroke)
933
+
934
+ def clip_to_viewbox(self, inplace=False):
935
+ if not inplace:
936
+ svg = self._clone()
937
+ svg.clip_to_viewbox(inplace=True)
938
+ return svg
939
+
940
+ self._update_etree()
941
+
942
+ view_box = self.view_box()
943
+
944
+ # phase 1: dump shapes that are completely out of bounds
945
+ for el, (shape,) in self._elements():
946
+ if view_box.intersection(shape.bounding_box()) is None:
947
+ _safe_remove(el)
948
+
949
+ self.elements = None # force elements to reload
950
+
951
+ # phase 2: clip things that are partially out of bounds
952
+ updates = []
953
+ for idx, (el, (shape,)) in enumerate(self._elements()):
954
+ bbox = shape.bounding_box()
955
+ isct = view_box.intersection(bbox)
956
+ assert isct is not None, f"We should have already dumped {shape}"
957
+ if bbox == isct:
958
+ continue
959
+ clip_path = (
960
+ SVGRect(x=isct.x, y=isct.y, width=isct.w, height=isct.h)
961
+ .as_path()
962
+ .absolute(inplace=True)
963
+ )
964
+ shape = shape.as_path().absolute(inplace=True)
965
+ shape.update_path(
966
+ intersection(
967
+ (shape, clip_path),
968
+ fill_rules=(shape.fill_rule, clip_path.clip_rule),
969
+ ),
970
+ inplace=True,
971
+ )
972
+ shape.fill_rule = "nonzero"
973
+ updates.append((idx, el, shape))
974
+
975
+ for idx, el, shape in updates:
976
+ self._set_element(idx, el, (shape,))
977
+
978
+ # Update the etree
979
+ self._update_etree()
980
+
981
+ # We may now have useless groups
982
+ for context in reversed(list(self.depth_first())):
983
+ if _is_group(context.element):
984
+ _try_remove_group(context.element)
985
+
986
+ return self
987
+
988
+ def evenodd_to_nonzero_winding(self, inplace=False):
989
+ if not inplace:
990
+ svg = self._clone()
991
+ svg.evenodd_to_nonzero_winding(inplace=True)
992
+ return svg
993
+
994
+ for idx, (el, (shape,)) in enumerate(self._elements()):
995
+ if shape.fill_rule == "evenodd":
996
+ path = shape.as_path().remove_overlaps(inplace=True)
997
+ self._set_element(idx, el, (path,))
998
+
999
+ return self
1000
+
1001
+ def round_floats(self, ndigits: int, inplace=False):
1002
+ if not inplace:
1003
+ svg = self._clone()
1004
+ svg.round_floats(ndigits, inplace=True)
1005
+ return svg
1006
+
1007
+ for shape in self.shapes():
1008
+ shape.round_floats(ndigits, inplace=True)
1009
+ return self
1010
+
1011
+ def remove_empty_subpaths(self, inplace=False):
1012
+ if not inplace:
1013
+ svg = self._clone()
1014
+ svg.remove_empty_subpaths(inplace=True)
1015
+ return svg
1016
+
1017
+ for shape in self.shapes():
1018
+ if isinstance(shape, SVGPath):
1019
+ shape.remove_empty_subpaths(inplace=True)
1020
+
1021
+ return self
1022
+
1023
+ def remove_unpainted_shapes(self, inplace=False):
1024
+ if not inplace:
1025
+ svg = self._clone()
1026
+ svg.remove_unpainted_shapes(inplace=True)
1027
+ return svg
1028
+
1029
+ self._update_etree()
1030
+
1031
+ remove = []
1032
+ for el, (shape,) in self._elements():
1033
+ if not shape.might_paint():
1034
+ remove.append(el)
1035
+
1036
+ for el in remove:
1037
+ el.getparent().remove(el)
1038
+
1039
+ self.elements = None
1040
+
1041
+ return self
1042
+
1043
+ def remove_nonsvg_content(self, inplace=False):
1044
+ if not inplace:
1045
+ svg = self._clone()
1046
+ svg.remove_nonsvg_content(inplace=True)
1047
+ return svg
1048
+
1049
+ self._update_etree()
1050
+
1051
+ good_ns = {svgns(), xlinkns()}
1052
+ # Some SVGs may have no default namespace key in nsmap; avoid KeyError
1053
+ default_ns = self.svg_root.nsmap.get(None)
1054
+ # Accept un-namespaced elements either when default ns is SVG or missing
1055
+ if default_ns == svgns() or default_ns is None:
1056
+ good_ns.add(None)
1057
+
1058
+ el_to_rm = []
1059
+ for el in self.svg_root.getiterator("*"):
1060
+ attr_to_rm = []
1061
+ ns, _ = splitns(el.tag)
1062
+ if ns not in good_ns:
1063
+ el_to_rm.append(el)
1064
+ continue
1065
+ for attr in el.attrib:
1066
+ ns, _ = splitns(attr)
1067
+ if ns not in good_ns:
1068
+ attr_to_rm.append(attr)
1069
+ for attr in attr_to_rm:
1070
+ del el.attrib[attr]
1071
+
1072
+ for el in el_to_rm:
1073
+ el.getparent().remove(el)
1074
+
1075
+ # Make svg default; destroy anything unexpected
1076
+ good_nsmap = {
1077
+ None: svgns(),
1078
+ "xlink": xlinkns(),
1079
+ }
1080
+ if any(good_nsmap.get(k, None) != v for k, v in self.svg_root.nsmap.items()):
1081
+ self.svg_root = _copy_new_nsmap(self.svg_root, good_nsmap)
1082
+
1083
+ self.elements = None
1084
+
1085
+ return self
1086
+
1087
+ def remove_processing_instructions(self, inplace=False):
1088
+ if not inplace:
1089
+ svg = SVG(copy.deepcopy(self.svg_root))
1090
+ svg.remove_processing_instructions(inplace=True)
1091
+ return svg
1092
+
1093
+ self._update_etree()
1094
+
1095
+ for el in self.xpath("//processing-instruction()"):
1096
+ el.getparent().remove(el)
1097
+
1098
+ return self
1099
+
1100
+ def remove_anonymous_symbols(self, inplace=False):
1101
+ # No id makes a symbol useless
1102
+ # https://github.com/googlefonts/picosvg/issues/46
1103
+ if not inplace:
1104
+ svg = self._clone()
1105
+ svg.remove_anonymous_symbols(inplace=True)
1106
+ return svg
1107
+
1108
+ self._update_etree()
1109
+
1110
+ for el in self.xpath("//svg:symbol[not(@id)]"):
1111
+ el.getparent().remove(el)
1112
+
1113
+ return self
1114
+
1115
+ def remove_title_meta_desc(self, inplace=False):
1116
+ if not inplace:
1117
+ svg = self._clone()
1118
+ svg.remove_title_meta_desc(inplace=True)
1119
+ return svg
1120
+
1121
+ self._update_etree()
1122
+
1123
+ for tag in ("title", "desc", "metadata", "comment"):
1124
+ for el in self.xpath(f"//svg:{tag}"):
1125
+ el.getparent().remove(el)
1126
+
1127
+ return self
1128
+
1129
+ def set_attributes(self, name_values, xpath="/svg:svg", inplace=False):
1130
+ if not inplace:
1131
+ svg = self._clone()
1132
+ svg.set_attributes(name_values, xpath=xpath, inplace=True)
1133
+ return svg
1134
+
1135
+ self._update_etree()
1136
+
1137
+ for el in self.xpath(xpath):
1138
+ for name, value in name_values:
1139
+ el.attrib[name] = value
1140
+
1141
+ return self
1142
+
1143
+ def remove_attributes(self, names, xpath="/svg:svg", inplace=False):
1144
+ """Drop things like viewBox, width, height that set size of overall svg"""
1145
+ if not inplace:
1146
+ svg = self._clone()
1147
+ svg.remove_attributes(names, xpath=xpath, inplace=True)
1148
+ return svg
1149
+
1150
+ self._update_etree()
1151
+
1152
+ for el in self.xpath(xpath):
1153
+ _del_attrs(el, *names)
1154
+
1155
+ return self
1156
+
1157
+ def normalize_opacity(self, inplace=False):
1158
+ """Merge '{fill,stroke}_opacity' with generic 'opacity' when possible."""
1159
+ if not inplace:
1160
+ svg = self._clone()
1161
+ svg.normalize_opacity(inplace=True)
1162
+ return svg
1163
+
1164
+ for shape in self.shapes():
1165
+ shape.normalize_opacity(inplace=True)
1166
+
1167
+ return self
1168
+
1169
+ def _iter_nested_svgs(
1170
+ self, root: etree.Element
1171
+ ) -> Generator[etree.Element, None, None]:
1172
+ # This is different from Element.iter("svg") in that we don't yield the root
1173
+ # svg element itself, only traverse its children and yield any immediate
1174
+ # nested SVGs without traversing the latter's children as well.
1175
+ frontier = deque(root)
1176
+ while frontier:
1177
+ el = frontier.popleft()
1178
+ if _is_redundant(el.tag):
1179
+ continue
1180
+ if strip_ns(el.tag) == "svg":
1181
+ yield el
1182
+ elif len(el) != 0:
1183
+ frontier.extend(el)
1184
+
1185
+ def _unnest_svg(
1186
+ self, svg: etree.Element, parent_width: float, parent_height: float
1187
+ ) -> Tuple[etree.Element, ...]:
1188
+ x = float(svg.attrib.get("x", 0))
1189
+ y = float(svg.attrib.get("y", 0))
1190
+ width = float(svg.attrib.get("width", parent_width))
1191
+ height = float(svg.attrib.get("height", parent_height))
1192
+
1193
+ viewport = viewbox = Rect(x, y, width, height)
1194
+ if "viewBox" in svg.attrib:
1195
+ viewbox = parse_view_box(svg.attrib["viewBox"])
1196
+
1197
+ # first recurse to un-nest any nested nested SVGs
1198
+ self._swap_elements(
1199
+ (el, self._unnest_svg(el, viewbox.w, viewbox.h))
1200
+ for el in self._iter_nested_svgs(svg)
1201
+ )
1202
+
1203
+ g = etree.Element(f"{{{svgns()}}}g")
1204
+ g.extend(svg)
1205
+
1206
+ if viewport != viewbox:
1207
+ preserve_aspect_ratio = svg.attrib.get("preserveAspectRatio", "xMidYMid")
1208
+ transform = Affine2D.rect_to_rect(viewbox, viewport, preserve_aspect_ratio)
1209
+ else:
1210
+ transform = Affine2D.identity().translate(x, y)
1211
+
1212
+ if "transform" in svg.attrib:
1213
+ transform = Affine2D.compose_ltr(
1214
+ (transform, Affine2D.fromstring(svg.attrib["transform"]))
1215
+ )
1216
+
1217
+ if transform != Affine2D.identity():
1218
+ g.attrib["transform"] = transform.tostring()
1219
+
1220
+ # non-root svg elements by default have overflow="hidden" which means a clip path
1221
+ # the size of the SVG viewport is applied; if overflow="visible" don't clip
1222
+ # https://www.w3.org/TR/SVG/render.html#OverflowAndClipProperties
1223
+ overflow = svg.attrib.get("overflow", "hidden")
1224
+ if overflow == "visible":
1225
+ return (g,)
1226
+
1227
+ if overflow != "hidden":
1228
+ raise NotImplementedError(f"overflow='{overflow}' is not supported")
1229
+
1230
+ clip_path = etree.Element(
1231
+ f"{{{svgns()}}}clipPath", {"id": self._new_id("nested-svg-viewport-%d")}
1232
+ )
1233
+ clip_path.append(to_element(SVGRect(x=x, y=y, width=width, height=height)))
1234
+ clipped_g = etree.Element(f"{{{svgns()}}}g")
1235
+ clipped_g.attrib["clip-path"] = f"url(#{clip_path.attrib['id']})"
1236
+ clipped_g.append(g)
1237
+
1238
+ return (clip_path, clipped_g)
1239
+
1240
+ def resolve_nested_svgs(self, inplace=False):
1241
+ """Replace nested <svg> elements with equivalent <g> with a transform.
1242
+
1243
+ NOTE: currently this is still missing two features:
1244
+ 1) resolving percentage units in reference to the nearest SVG viewport;
1245
+ 2) applying a clip to all children of the nested SVG with a rectangle the size
1246
+ of the new viewport (inner SVGs have default overflow property set to
1247
+ 'hidden'). Blocked on https://github.com/googlefonts/picosvg/issues/200
1248
+ No error is raised in these cases.
1249
+
1250
+ References:
1251
+ - https://www.w3.org/TR/SVG/coords.html
1252
+ - https://www.sarasoueidan.com/blog/nesting-svgs/
1253
+ """
1254
+ if not inplace:
1255
+ svg = self._clone()
1256
+ svg.resolve_nested_svgs(inplace=True)
1257
+ return svg
1258
+
1259
+ self._update_etree()
1260
+
1261
+ nested_svgs = list(self._iter_nested_svgs(self.svg_root))
1262
+ if len(nested_svgs) == 0:
1263
+ return
1264
+
1265
+ vb = self.view_box()
1266
+ if vb is None:
1267
+ raise ValueError(
1268
+ "Can't determine root SVG width/height, "
1269
+ "which is required for resolving nested SVGs"
1270
+ )
1271
+
1272
+ self._swap_elements(
1273
+ (el, self._unnest_svg(el, vb.w, vb.h)) for el in nested_svgs
1274
+ )
1275
+
1276
+ return self
1277
+
1278
+ def _select_gradients(self):
1279
+ return self.xpath(" | ".join(f"//svg:{tag}" for tag in _GRADIENT_CLASSES))
1280
+
1281
+ def _apply_gradient_translation(self, el: etree.Element):
1282
+ assert _is_gradient(el)
1283
+ gradient = _GRADIENT_CLASSES[strip_ns(el.tag)].from_element(el, self.view_box())
1284
+ affine = gradient.gradientTransform
1285
+
1286
+ # split translation from rest of the transform and apply to gradient coords
1287
+ translate, affine_prime = affine.decompose_translation()
1288
+ if translate.round(_GRADIENT_TRANSFORM_NDIGITS) != Affine2D.identity():
1289
+ for x_attr, y_attr in _GRADIENT_COORDS[strip_ns(el.tag)]:
1290
+ x = getattr(gradient, x_attr)
1291
+ y = getattr(gradient, y_attr)
1292
+ x_prime, y_prime = translate.map_point((x, y))
1293
+ setattr(gradient, x_attr, round(x_prime, _GRADIENT_TRANSFORM_NDIGITS))
1294
+ setattr(gradient, y_attr, round(y_prime, _GRADIENT_TRANSFORM_NDIGITS))
1295
+
1296
+ gradient.gradientTransform = affine_prime.round(_GRADIENT_TRANSFORM_NDIGITS)
1297
+
1298
+ el.attrib.clear()
1299
+ el.attrib.update(to_element(gradient).attrib)
1300
+
1301
+ def _apply_gradient_template(self, gradient: etree.Element):
1302
+ # Gradients can have an 'href' attribute that specifies another gradient as
1303
+ # a template, inheriting its attributes and/or stops when not already defined:
1304
+ # https://www.w3.org/TR/SVG/pservers.html#PaintServerTemplates
1305
+
1306
+ assert _is_gradient(gradient)
1307
+
1308
+ href_attr = _xlink_href_attr_name()
1309
+ if href_attr not in gradient.attrib:
1310
+ return # nop
1311
+
1312
+ ref = gradient.attrib[href_attr]
1313
+ if not ref.startswith("#"):
1314
+ raise ValueError(f"Only use #fragment supported, reject {ref}")
1315
+ ref = ref[1:].strip()
1316
+
1317
+ template = self.xpath_one(f'.//svg:*[@id="{ref}"]')
1318
+
1319
+ template_tag = strip_ns(template.tag)
1320
+ if template_tag not in _GRADIENT_CLASSES:
1321
+ raise ValueError(
1322
+ f"Referenced element with id='{ref}' has unexpected tag: "
1323
+ f"expected linear or radialGradient, found '{template_tag}'"
1324
+ )
1325
+
1326
+ # recurse if template references another template
1327
+ if template.attrib.get(href_attr):
1328
+ self._apply_gradient_template(template)
1329
+
1330
+ for attr_name in _GRADIENT_FIELDS[strip_ns(gradient.tag)]:
1331
+ if attr_name in template.attrib and attr_name not in gradient.attrib:
1332
+ gradient.attrib[attr_name] = template.attrib[attr_name]
1333
+
1334
+ # only copy stops if we don't have our own
1335
+ if len(gradient) == 0:
1336
+ for stop_el in template:
1337
+ new_stop_el = copy.deepcopy(stop_el)
1338
+ # strip stop id if present; useless and no longer unique
1339
+ _del_attrs(new_stop_el, "id")
1340
+ gradient.append(new_stop_el)
1341
+
1342
+ del gradient.attrib[href_attr]
1343
+
1344
+ def _remove_orphaned_gradients(self):
1345
+ # remove orphaned templates, only keep gradients directly referenced by shapes
1346
+ used_gradient_ids = set()
1347
+ for shape in self.shapes():
1348
+ if shape.fill.startswith("url("):
1349
+ try:
1350
+ el = self.resolve_url(shape.fill, "*")
1351
+ except ValueError: # skip not found
1352
+ continue
1353
+ if strip_ns(el.tag) not in _GRADIENT_CLASSES:
1354
+ # unlikely the url target isn't a gradient but I'm not the police
1355
+ continue
1356
+ used_gradient_ids.add(el.attrib["id"])
1357
+ for grad in self._select_gradients():
1358
+ if grad.attrib.get("id") not in used_gradient_ids:
1359
+ _safe_remove(grad)
1360
+
1361
+ def checkpicosvg(self, allow_text=False, allow_all_defs=False, drop_unsupported=False):
1362
+ """Check for nano violations, return xpaths to bad elements.
1363
+
1364
+ If result sequence empty then this is a valid picosvg.
1365
+ """
1366
+
1367
+ self._update_etree()
1368
+
1369
+ errors = []
1370
+ bad_paths = set()
1371
+
1372
+ path_allowlist = {
1373
+ r"^/svg\[0\]$",
1374
+ r"^/svg\[0\]/defs\[0\]$",
1375
+ r"^/svg\[0\]/defs\[0\]/(linear|radial)Gradient\[\d+\](/stop\[\d+\])?$",
1376
+ r"^/svg\[0\](/(path|g)\[\d+\])+$",
1377
+ }
1378
+ if allow_text:
1379
+ # Allow text elements directly under svg or nested within g elements
1380
+ path_allowlist.add(
1381
+ r"^/svg\[0\](/(path|g)\[\d+\])*(/(text|textPath)\[\d+\])+(/(text|tspan|textPath)\[\d+\])*$"
1382
+ )
1383
+ if allow_all_defs:
1384
+ _defs_re = "|".join(sorted(_DEFS_ALLOWED_TAGS))
1385
+ _child_re = "|".join(sorted(_DEFS_CHILD_ALLOWED_TAGS))
1386
+ # Allow whitelisted elements in defs with safe children
1387
+ path_allowlist.add(
1388
+ r"^/svg\[0\]/defs\[0\]/("
1389
+ + _defs_re
1390
+ + r")\[\d+\](/("
1391
+ + _child_re
1392
+ + r")\[\d+\])*$"
1393
+ )
1394
+ # Allow switch/symbol/use at root level with safe children
1395
+ # NOTE: foreignObject excluded — it can embed arbitrary HTML
1396
+ path_allowlist.add(
1397
+ r"^/svg\[0\](/(switch|symbol|use)\[\d+\])+(/(g|path|rect|circle|ellipse|text|image)\[\d+\])*$"
1398
+ )
1399
+ # Allow style/pattern/mask/clipPath at root level with safe children
1400
+ path_allowlist.add(
1401
+ r"^/svg\[0\]/(style|pattern|mask|clipPath)\[\d+\](/(rect|circle|ellipse|path|line|polyline|polygon|g|use|image|text)\[\d+\])*$"
1402
+ )
1403
+ paths_required = {
1404
+ "/svg[0]",
1405
+ "/svg[0]/defs[0]",
1406
+ }
1407
+
1408
+ # Make a list of xpaths with offsets (/svg/defs[0]/..., etc)
1409
+ ids = {}
1410
+ for context in self.breadth_first():
1411
+ if any(context.path.startswith(bp) for bp in bad_paths):
1412
+ continue # no sense reporting all the children as bad
1413
+
1414
+ if not any((re.match(pat, context.path) for pat in path_allowlist)):
1415
+ if drop_unsupported:
1416
+ _safe_remove(context.element)
1417
+ else:
1418
+ errors.append(f"BadElement: {context.path}")
1419
+ bad_paths.add(context.path)
1420
+ continue
1421
+
1422
+ paths_required.discard(context.path)
1423
+
1424
+ el_id = context.element.attrib.get("id", None)
1425
+ if el_id is not None:
1426
+ if el_id in ids:
1427
+ errors.append(
1428
+ f'BadElement: {context.path} reuses id="{el_id}", first seen at {ids[el_id]}'
1429
+ )
1430
+ ids[el_id] = context.path
1431
+
1432
+ for path in paths_required:
1433
+ errors.append(f"MissingElement: {path}")
1434
+
1435
+ # TODO paths, groups, & gradients should only have specific attributes
1436
+
1437
+ return tuple(errors)
1438
+
1439
+ def topicosvg(
1440
+ self, *, ndigits=3, inplace=False, allow_text=False, allow_all_defs=False, drop_unsupported=False
1441
+ ):
1442
+ if not inplace:
1443
+ svg = self._clone()
1444
+ svg.topicosvg(
1445
+ ndigits=ndigits,
1446
+ inplace=True,
1447
+ allow_text=allow_text,
1448
+ allow_all_defs=allow_all_defs,
1449
+ drop_unsupported=drop_unsupported,
1450
+ )
1451
+ return svg
1452
+
1453
+ self._update_etree()
1454
+
1455
+ # Discard useless content
1456
+ self.remove_nonsvg_content(inplace=True)
1457
+ self.remove_processing_instructions(inplace=True)
1458
+ self.remove_anonymous_symbols(inplace=True)
1459
+ self.remove_title_meta_desc(inplace=True)
1460
+
1461
+ # Simplify things that simplify in isolation
1462
+ self.apply_style_attributes(inplace=True)
1463
+ self.resolve_nested_svgs(inplace=True)
1464
+ self.shapes_to_paths(inplace=True)
1465
+ self.expand_shorthand(inplace=True)
1466
+ self.resolve_use(inplace=True)
1467
+
1468
+ # Simplify things that do not simplify in isolation
1469
+ self.simplify(inplace=True, allow_all_defs=allow_all_defs)
1470
+
1471
+ # Tidy up
1472
+ self.evenodd_to_nonzero_winding(inplace=True)
1473
+ self.normalize_opacity(inplace=True)
1474
+ self.absolute(inplace=True)
1475
+ self.round_floats(ndigits, inplace=True)
1476
+
1477
+ # https://github.com/googlefonts/picosvg/issues/269 remove empty subpaths *after* rounding
1478
+ self.remove_empty_subpaths(inplace=True)
1479
+ self.remove_unpainted_shapes(inplace=True)
1480
+
1481
+ violations = self.checkpicosvg(
1482
+ allow_text=allow_text, allow_all_defs=allow_all_defs, drop_unsupported=drop_unsupported
1483
+ )
1484
+ if violations:
1485
+ raise ValueError("Unable to convert to picosvg: " + ",".join(violations))
1486
+
1487
+ return self
1488
+
1489
+ @staticmethod
1490
+ def _swap_elements(swaps: Iterable[Tuple[etree.Element, Sequence[etree.Element]]]):
1491
+ for old_el, new_els in swaps:
1492
+ for new_el in reversed(new_els):
1493
+ old_el.addnext(new_el)
1494
+ parent = old_el.getparent()
1495
+ if parent is None:
1496
+ raise ValueError("Lost parent!")
1497
+ parent.remove(old_el)
1498
+
1499
+ @lru_cache(maxsize=None)
1500
+ def _inherited_attrib(self, el: etree.Element) -> Mapping[str, str]:
1501
+ parents = []
1502
+ while el.getparent() is not None:
1503
+ el = el.getparent()
1504
+ parents.append(el)
1505
+ return reduce(
1506
+ _attrib_to_pass_on, reversed(parents), _INHERITABLE_ATTRIB_DEFAULTS
1507
+ )
1508
+
1509
+ def _update_etree(self):
1510
+ if not self.elements:
1511
+ return
1512
+ self._inherited_attrib.cache_clear()
1513
+ self._swap_elements(
1514
+ (
1515
+ old_el,
1516
+ [to_element(s, **self._inherited_attrib(old_el)) for s in shapes],
1517
+ )
1518
+ for old_el, shapes in self.elements
1519
+ )
1520
+ self.elements = None
1521
+
1522
+ def toetree(self):
1523
+ self._update_etree()
1524
+ self.svg_root = _fix_xlink_ns(self.svg_root)
1525
+ return copy.deepcopy(self.svg_root)
1526
+
1527
+ def tostring(self, pretty_print=False):
1528
+ return etree.tostring(self.toetree(), pretty_print=pretty_print).decode("utf-8")
1529
+
1530
+ @classmethod
1531
+ def fromstring(cls, string):
1532
+ if isinstance(string, bytes):
1533
+ string = string.decode("utf-8")
1534
+
1535
+ # svgs are fond of not declaring xlink
1536
+ # based on https://mailman-mail5.webfaction.com/pipermail/lxml/20100323/021184.html
1537
+ if "xlink" in string and "xmlns:xlink" not in string:
1538
+ string = string.replace("xlink:href", _XLINK_TEMP)
1539
+
1540
+ # encode because fromstring dislikes xml encoding decl if input is str
1541
+ parser = etree.XMLParser(
1542
+ remove_comments=True,
1543
+ remove_blank_text=True,
1544
+ # external entities may load local files (e.g. /etc/passwd), so disable
1545
+ # safe entities like &gt; are still allowed
1546
+ resolve_entities=False,
1547
+ )
1548
+ tree = etree.fromstring(string.encode("utf-8"), parser)
1549
+ tree = _fix_xlink_ns(tree)
1550
+ return cls(tree)
1551
+
1552
+ @classmethod
1553
+ def parse(cls, file_or_path):
1554
+ if hasattr(file_or_path, "read"):
1555
+ raw_svg = file_or_path.read()
1556
+ else:
1557
+ with open(file_or_path) as f:
1558
+ raw_svg = f.read()
1559
+ return cls.fromstring(raw_svg)
1560
+
1561
+
1562
+ def _inherit_copy(attrib, child, attr_name):
1563
+ if attr_name in child.attrib:
1564
+ return
1565
+ if attr_name in attrib:
1566
+ child.attrib[attr_name] = attrib[attr_name]
1567
+
1568
+
1569
+ def _inherit_multiply(attrib, child, attr_name):
1570
+ if attr_name not in attrib and attr_name not in child.attrib:
1571
+ return
1572
+ value = float(attrib.get(attr_name, 1.0))
1573
+ value *= float(child.attrib.get(attr_name, 1.0))
1574
+ child.attrib[attr_name] = ntos(value)
1575
+
1576
+
1577
+ def _inherit_clip_path(attrib, child, attr_name):
1578
+ clips = sorted(
1579
+ child.attrib.get("clip-path", "").split(",") + [attrib.get("clip-path", "")]
1580
+ )
1581
+ child.attrib["clip-path"] = ",".join([c for c in clips if c])
1582
+
1583
+
1584
+ def _inherit_nondefault_overflow(attrib, child, attr_name):
1585
+ value = attrib.get(attr_name, "visible")
1586
+ if value != "visible":
1587
+ _inherit_copy(attrib, child, attr_name)
1588
+
1589
+
1590
+ # https://github.com/googlefonts/picosvg/issues/260
1591
+ def _inherit_nondefault_display(attrib, child, attr_name):
1592
+ value = attrib.get(attr_name, "")
1593
+ if value == "none":
1594
+ child.attrib[attr_name] = value
1595
+ else:
1596
+ _inherit_copy(attrib, child, attr_name)
1597
+
1598
+
1599
+ def _inherit_matrix_multiply(attrib, child, attr_name):
1600
+ transform = Affine2D.identity()
1601
+ if attr_name in attrib:
1602
+ transform = Affine2D.fromstring(attrib[attr_name])
1603
+ if attr_name in child.attrib:
1604
+ transform = Affine2D.compose_ltr(
1605
+ (Affine2D.fromstring(child.attrib[attr_name]), transform)
1606
+ )
1607
+ if transform != Affine2D.identity():
1608
+ child.attrib[attr_name] = transform.tostring()
1609
+ else:
1610
+ del child.attrib[attr_name]
1611
+
1612
+
1613
+ def _do_not_inherit(*_):
1614
+ return
1615
+
1616
+
1617
+ _INHERIT_ATTRIB_HANDLERS = {
1618
+ "clip-rule": _inherit_copy,
1619
+ "color": _inherit_copy,
1620
+ "display": _inherit_nondefault_display,
1621
+ "fill": _inherit_copy,
1622
+ "fill-rule": _inherit_copy,
1623
+ "style": _inherit_copy,
1624
+ "transform": _inherit_matrix_multiply,
1625
+ "stroke": _inherit_copy,
1626
+ "stroke-width": _inherit_copy,
1627
+ "stroke-linecap": _inherit_copy,
1628
+ "stroke-linejoin": _inherit_copy,
1629
+ "stroke-miterlimit": _inherit_copy,
1630
+ "stroke-dasharray": _inherit_copy,
1631
+ "stroke-dashoffset": _inherit_copy,
1632
+ "stroke-opacity": _inherit_copy,
1633
+ "fill-opacity": _inherit_copy,
1634
+ "opacity": _inherit_multiply,
1635
+ "clip-path": _inherit_clip_path,
1636
+ "id": _do_not_inherit,
1637
+ "data-name": _do_not_inherit,
1638
+ "enable-background": _do_not_inherit,
1639
+ "overflow": _inherit_nondefault_overflow,
1640
+ }
1641
+
1642
+
1643
+ _INHERITABLE_ATTRIB = frozenset(
1644
+ k for k, v in _INHERIT_ATTRIB_HANDLERS.items() if v is not _do_not_inherit
1645
+ )
1646
+
1647
+ _INHERITABLE_ATTRIB_DEFAULTS = {
1648
+ k: (
1649
+ ntos(ATTRIB_DEFAULTS[k])
1650
+ if isinstance(ATTRIB_DEFAULTS[k], numbers.Number)
1651
+ else str(ATTRIB_DEFAULTS[k])
1652
+ )
1653
+ for k, v in _INHERIT_ATTRIB_HANDLERS.items()
1654
+ if k in ATTRIB_DEFAULTS and v is _inherit_copy
1655
+ }
1656
+
1657
+
1658
+ def _attr_supported(el: etree.Element, attr_name: str) -> bool:
1659
+ tag = strip_ns(el.tag)
1660
+ field_name = _field_name(attr_name)
1661
+ if tag in _VALID_FIELDS:
1662
+ return field_name in _VALID_FIELDS[tag]
1663
+ return True # we don't know
1664
+
1665
+
1666
+ def _drop_default_attrib(attrib: MutableMapping[str, Any]):
1667
+ for attr_name in sorted(attrib.keys()):
1668
+ value = attrib[attr_name]
1669
+ default_value = attrib_default(attr_name, default=None)
1670
+ if default_value is None:
1671
+ continue
1672
+ if isinstance(default_value, float):
1673
+ value = float(value)
1674
+ if default_value == value:
1675
+ del attrib[attr_name]
1676
+
1677
+
1678
+ def _inherit_attrib(
1679
+ attrib: Mapping[str, Any],
1680
+ child: etree.Element,
1681
+ skip_unhandled: bool = False,
1682
+ skips=frozenset(),
1683
+ ):
1684
+ attrib: MutableMapping[str, Any] = copy.deepcopy(
1685
+ attrib
1686
+ ) # pytype: disable=annotation-type-mismatch
1687
+ for attr_name in sorted(attrib.keys()):
1688
+ if attr_name in skips or not _attr_supported(child, attr_name):
1689
+ del attrib[attr_name]
1690
+ continue
1691
+ if not attr_name in _INHERIT_ATTRIB_HANDLERS:
1692
+ continue
1693
+ _INHERIT_ATTRIB_HANDLERS[attr_name](attrib, child, attr_name)
1694
+ del attrib[attr_name]
1695
+
1696
+ if len(attrib) and not skip_unhandled:
1697
+ raise ValueError(f"Unable to process attrib {attrib}")