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/__init__.py +15 -0
- picosvgx/_version.py +34 -0
- picosvgx/arc_to_cubic.py +210 -0
- picosvgx/geometric_types.py +170 -0
- picosvgx/picosvgx.py +85 -0
- picosvgx/py.typed +0 -0
- picosvgx/svg.py +1697 -0
- picosvgx/svg_meta.py +307 -0
- picosvgx/svg_path_iter.py +110 -0
- picosvgx/svg_pathops.py +194 -0
- picosvgx/svg_reuse.py +384 -0
- picosvgx/svg_transform.py +373 -0
- picosvgx/svg_types.py +1031 -0
- picosvgx-0.1.0.dist-info/METADATA +114 -0
- picosvgx-0.1.0.dist-info/RECORD +19 -0
- picosvgx-0.1.0.dist-info/WHEEL +5 -0
- picosvgx-0.1.0.dist-info/entry_points.txt +2 -0
- picosvgx-0.1.0.dist-info/licenses/LICENSE +202 -0
- picosvgx-0.1.0.dist-info/top_level.txt +1 -0
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 > 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}")
|