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_types.py
ADDED
|
@@ -0,0 +1,1031 @@
|
|
|
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
|
+
import copy
|
|
16
|
+
import dataclasses
|
|
17
|
+
from itertools import zip_longest
|
|
18
|
+
import math
|
|
19
|
+
import numbers
|
|
20
|
+
import re
|
|
21
|
+
from picosvgx.geometric_types import Point, Rect
|
|
22
|
+
from picosvgx.svg_meta import (
|
|
23
|
+
attrib_default,
|
|
24
|
+
check_cmd,
|
|
25
|
+
cmd_coords,
|
|
26
|
+
number_or_percentage,
|
|
27
|
+
parse_css_length,
|
|
28
|
+
ntos,
|
|
29
|
+
parse_css_declarations,
|
|
30
|
+
path_segment,
|
|
31
|
+
strip_ns,
|
|
32
|
+
SVGCommand,
|
|
33
|
+
SVGCommandSeq,
|
|
34
|
+
SVG_LENGTH_ATTRS,
|
|
35
|
+
_LinkedDefault,
|
|
36
|
+
)
|
|
37
|
+
from picosvgx import svg_pathops
|
|
38
|
+
from picosvgx.arc_to_cubic import arc_to_cubic
|
|
39
|
+
from picosvgx.svg_path_iter import parse_svg_path
|
|
40
|
+
from picosvgx.svg_transform import Affine2D
|
|
41
|
+
from typing import (
|
|
42
|
+
ClassVar,
|
|
43
|
+
Generator,
|
|
44
|
+
Iterable,
|
|
45
|
+
Mapping,
|
|
46
|
+
MutableMapping,
|
|
47
|
+
Optional,
|
|
48
|
+
Sequence,
|
|
49
|
+
Tuple,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _round_multiple(f: float, of: float) -> float:
|
|
54
|
+
return round(f / of) * of
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _explicit_lines_callback(subpath_start, curr_pos, cmd, args, *_):
|
|
58
|
+
del subpath_start
|
|
59
|
+
if cmd == "v":
|
|
60
|
+
args = (0, args[0])
|
|
61
|
+
elif cmd == "V":
|
|
62
|
+
args = (curr_pos.x, args[0])
|
|
63
|
+
elif cmd == "h":
|
|
64
|
+
args = (args[0], 0)
|
|
65
|
+
elif cmd == "H":
|
|
66
|
+
args = (args[0], curr_pos.y)
|
|
67
|
+
else:
|
|
68
|
+
return ((cmd, args),) # nothing changes
|
|
69
|
+
|
|
70
|
+
if cmd.islower():
|
|
71
|
+
cmd = "l"
|
|
72
|
+
else:
|
|
73
|
+
cmd = "L"
|
|
74
|
+
|
|
75
|
+
return ((cmd, args),)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _rewrite_coords(cmd_converter, coord_converter, curr_pos, cmd, args):
|
|
79
|
+
x_coord_idxs, y_coord_idxs = cmd_coords(cmd)
|
|
80
|
+
desired_cmd = cmd_converter(cmd)
|
|
81
|
+
if cmd != desired_cmd:
|
|
82
|
+
cmd = desired_cmd
|
|
83
|
+
# if x_coord_idxs or y_coord_idxs:
|
|
84
|
+
args = list(args) # we'd like to mutate 'em
|
|
85
|
+
for x_coord_idx in x_coord_idxs:
|
|
86
|
+
args[x_coord_idx] += coord_converter(curr_pos.x)
|
|
87
|
+
for y_coord_idx in y_coord_idxs:
|
|
88
|
+
args[y_coord_idx] += coord_converter(curr_pos.y)
|
|
89
|
+
|
|
90
|
+
return (cmd, tuple(args))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _relative_to_absolute(curr_pos, cmd, args):
|
|
94
|
+
return _rewrite_coords(
|
|
95
|
+
lambda cmd: cmd.upper(), lambda curr_scaler: curr_scaler, curr_pos, cmd, args
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _relative_to_absolute_moveto(curr_pos, cmd, args):
|
|
100
|
+
if cmd in ("M", "m"):
|
|
101
|
+
return _relative_to_absolute(curr_pos, cmd, args)
|
|
102
|
+
return (cmd, args)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _absolute_to_relative(curr_pos, cmd, args):
|
|
106
|
+
return _rewrite_coords(
|
|
107
|
+
lambda cmd: cmd.lower(), lambda curr_scaler: -curr_scaler, curr_pos, cmd, args
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _next_pos(curr_pos, cmd, cmd_args) -> Point:
|
|
112
|
+
# update current position
|
|
113
|
+
x_coord_idxs, y_coord_idxs = cmd_coords(cmd)
|
|
114
|
+
new_x, new_y = curr_pos
|
|
115
|
+
if cmd.isupper():
|
|
116
|
+
if x_coord_idxs:
|
|
117
|
+
new_x = 0
|
|
118
|
+
if y_coord_idxs:
|
|
119
|
+
new_y = 0
|
|
120
|
+
|
|
121
|
+
if x_coord_idxs:
|
|
122
|
+
new_x += cmd_args[x_coord_idxs[-1]]
|
|
123
|
+
if y_coord_idxs:
|
|
124
|
+
new_y += cmd_args[y_coord_idxs[-1]]
|
|
125
|
+
|
|
126
|
+
return Point(new_x, new_y)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _move_endpoint(curr_pos, cmd, cmd_args, new_endpoint):
|
|
130
|
+
# we need to be able to alter both axes
|
|
131
|
+
((cmd, cmd_args),) = _explicit_lines_callback(None, curr_pos, cmd, cmd_args)
|
|
132
|
+
|
|
133
|
+
x_coord_idxs, y_coord_idxs = cmd_coords(cmd)
|
|
134
|
+
if x_coord_idxs or y_coord_idxs:
|
|
135
|
+
cmd_args = list(cmd_args) # we'd like to mutate
|
|
136
|
+
new_x, new_y = new_endpoint
|
|
137
|
+
if cmd.islower():
|
|
138
|
+
new_x = new_x - curr_pos.x
|
|
139
|
+
new_y = new_y - curr_pos.y
|
|
140
|
+
|
|
141
|
+
cmd_args[x_coord_idxs[-1]] = new_x
|
|
142
|
+
cmd_args[y_coord_idxs[-1]] = new_y
|
|
143
|
+
|
|
144
|
+
return cmd, tuple(cmd_args)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# Subset of https://www.w3.org/TR/SVG11/painting.html
|
|
148
|
+
@dataclasses.dataclass
|
|
149
|
+
class SVGShape:
|
|
150
|
+
tag: ClassVar[str] = "unknown"
|
|
151
|
+
id: str = ""
|
|
152
|
+
clip_path: str = attrib_default("clip-path")
|
|
153
|
+
clip_rule: str = attrib_default("clip-rule")
|
|
154
|
+
fill: str = attrib_default("fill")
|
|
155
|
+
fill_opacity: float = attrib_default("fill-opacity")
|
|
156
|
+
fill_rule: str = attrib_default("fill-rule")
|
|
157
|
+
stroke: str = attrib_default("stroke")
|
|
158
|
+
stroke_width: float = attrib_default("stroke-width")
|
|
159
|
+
stroke_linecap: str = attrib_default("stroke-linecap")
|
|
160
|
+
stroke_linejoin: str = attrib_default("stroke-linejoin")
|
|
161
|
+
stroke_miterlimit: float = attrib_default("stroke-miterlimit")
|
|
162
|
+
stroke_dasharray: str = attrib_default("stroke-dasharray")
|
|
163
|
+
stroke_dashoffset: float = attrib_default("stroke-dashoffset")
|
|
164
|
+
stroke_opacity: float = attrib_default("stroke-opacity")
|
|
165
|
+
opacity: float = attrib_default("opacity")
|
|
166
|
+
transform: str = attrib_default("transform")
|
|
167
|
+
style: str = attrib_default("style")
|
|
168
|
+
display: str = attrib_default("display")
|
|
169
|
+
|
|
170
|
+
def _copy_common_fields(
|
|
171
|
+
self,
|
|
172
|
+
id,
|
|
173
|
+
clip_path,
|
|
174
|
+
clip_rule,
|
|
175
|
+
fill,
|
|
176
|
+
fill_opacity,
|
|
177
|
+
fill_rule,
|
|
178
|
+
stroke,
|
|
179
|
+
stroke_width,
|
|
180
|
+
stroke_linecap,
|
|
181
|
+
stroke_linejoin,
|
|
182
|
+
stroke_miterlimit,
|
|
183
|
+
stroke_dasharray,
|
|
184
|
+
stroke_dashoffset,
|
|
185
|
+
stroke_opacity,
|
|
186
|
+
opacity,
|
|
187
|
+
transform,
|
|
188
|
+
style,
|
|
189
|
+
display,
|
|
190
|
+
):
|
|
191
|
+
self.id = id
|
|
192
|
+
self.clip_path = clip_path
|
|
193
|
+
self.clip_rule = clip_rule
|
|
194
|
+
self.fill = fill
|
|
195
|
+
self.fill_opacity = fill_opacity
|
|
196
|
+
self.fill_rule = fill_rule
|
|
197
|
+
self.stroke = stroke
|
|
198
|
+
self.stroke_width = stroke_width
|
|
199
|
+
self.stroke_linecap = stroke_linecap
|
|
200
|
+
self.stroke_linejoin = stroke_linejoin
|
|
201
|
+
self.stroke_miterlimit = stroke_miterlimit
|
|
202
|
+
self.stroke_dasharray = stroke_dasharray
|
|
203
|
+
self.stroke_dashoffset = stroke_dashoffset
|
|
204
|
+
self.stroke_opacity = stroke_opacity
|
|
205
|
+
self.opacity = opacity
|
|
206
|
+
self.transform = transform
|
|
207
|
+
self.style = style
|
|
208
|
+
self.display = display
|
|
209
|
+
|
|
210
|
+
def __str__(self) -> str:
|
|
211
|
+
parts = ["<", self.tag]
|
|
212
|
+
for field in dataclasses.fields(self):
|
|
213
|
+
attr_name = field.name.replace("_", "-")
|
|
214
|
+
value = getattr(self, field.name)
|
|
215
|
+
default_value = attrib_default(attr_name)
|
|
216
|
+
if isinstance(default_value, float):
|
|
217
|
+
value = float(value)
|
|
218
|
+
if value == default_value:
|
|
219
|
+
continue
|
|
220
|
+
parts.append(" ")
|
|
221
|
+
parts.append(attr_name)
|
|
222
|
+
parts.append("=")
|
|
223
|
+
parts.append('"')
|
|
224
|
+
if isinstance(value, numbers.Number):
|
|
225
|
+
parts.append(ntos(value))
|
|
226
|
+
else:
|
|
227
|
+
parts.append(str(value))
|
|
228
|
+
parts.append('"')
|
|
229
|
+
parts.append("/>")
|
|
230
|
+
return "".join(parts)
|
|
231
|
+
|
|
232
|
+
def might_paint(self) -> bool:
|
|
233
|
+
"""False if we're sure this shape will not paint. True if it *might* paint."""
|
|
234
|
+
|
|
235
|
+
shape = self.apply_style_attribute()
|
|
236
|
+
|
|
237
|
+
if shape.display == "none":
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
def _visible(fill, opacity):
|
|
241
|
+
return fill != "none" and shape.opacity * opacity != 0
|
|
242
|
+
|
|
243
|
+
# if all you do is move the pen around you can't draw
|
|
244
|
+
if all(c[0].upper() == "M" for c in self.as_cmd_seq()):
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
# Does it look like the stroke is visible?
|
|
248
|
+
if _visible(shape.stroke, shape.stroke_opacity) and shape.stroke_width != 0:
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
# No stroke; if the shape is hidden we can't draw
|
|
252
|
+
if not _visible(shape.fill, shape.fill_opacity):
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
# Only shapes with area paint
|
|
256
|
+
try:
|
|
257
|
+
return (
|
|
258
|
+
svg_pathops.path_area(shape.as_cmd_seq(), fill_rule=shape.fill_rule) > 0
|
|
259
|
+
)
|
|
260
|
+
except svg_pathops.pathops.PathOpsError:
|
|
261
|
+
# some tricky paths with very densely packed segments sometimes can trigger a
|
|
262
|
+
# PathOpsError. We assume they do paint to stay on the safe side.
|
|
263
|
+
# https://github.com/googlefonts/picosvg/issues/192
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
def bounding_box(self) -> Rect:
|
|
267
|
+
x1, y1, x2, y2 = svg_pathops.bounding_box(self.as_cmd_seq())
|
|
268
|
+
return Rect(x1, y1, x2 - x1, y2 - y1)
|
|
269
|
+
|
|
270
|
+
def apply_transform(self, transform: Affine2D) -> "SVGPath":
|
|
271
|
+
target = self.as_path()
|
|
272
|
+
if target is self:
|
|
273
|
+
target = copy.deepcopy(target)
|
|
274
|
+
cmds = (("M", (0, 0)),)
|
|
275
|
+
if not transform.is_degenerate():
|
|
276
|
+
cmds = svg_pathops.transform(self.as_cmd_seq(), transform)
|
|
277
|
+
return target.update_path(cmds, inplace=True)
|
|
278
|
+
|
|
279
|
+
def as_path(self) -> "SVGPath":
|
|
280
|
+
raise NotImplementedError("You should implement as_path")
|
|
281
|
+
|
|
282
|
+
def as_cmd_seq(self) -> SVGCommandSeq:
|
|
283
|
+
return (
|
|
284
|
+
self.as_path()
|
|
285
|
+
.explicit_lines() # hHvV => lL
|
|
286
|
+
.expand_shorthand(inplace=True)
|
|
287
|
+
.absolute(inplace=True)
|
|
288
|
+
.arcs_to_cubics(inplace=True)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def absolute(self, inplace=False) -> "SVGShape":
|
|
292
|
+
"""Returns equivalent path with only absolute commands."""
|
|
293
|
+
# only meaningful for path, which overrides
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
def stroke_commands(self, tolerance) -> Generator[SVGCommand, None, None]:
|
|
297
|
+
dash_array = []
|
|
298
|
+
if self.stroke_dasharray != "none":
|
|
299
|
+
dash_array = [
|
|
300
|
+
float(v) for v in re.split(r"[, ]", self.stroke_dasharray) if v
|
|
301
|
+
]
|
|
302
|
+
# If an odd number of values is provided, then the list of values is repeated
|
|
303
|
+
# to yield an even number of values: e.g. 5,3,2 => 5,3,2,5,3,2.
|
|
304
|
+
# https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray
|
|
305
|
+
if len(dash_array) % 2 != 0:
|
|
306
|
+
dash_array.extend(dash_array)
|
|
307
|
+
|
|
308
|
+
return svg_pathops.stroke(
|
|
309
|
+
self.as_cmd_seq(),
|
|
310
|
+
self.stroke_linecap,
|
|
311
|
+
self.stroke_linejoin,
|
|
312
|
+
self.stroke_width,
|
|
313
|
+
self.stroke_miterlimit,
|
|
314
|
+
tolerance,
|
|
315
|
+
dash_array,
|
|
316
|
+
self.stroke_dashoffset,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def apply_style_attribute(self, inplace=False) -> "SVGShape":
|
|
320
|
+
"""Converts inlined CSS in "style" attribute to equivalent SVG attributes.
|
|
321
|
+
|
|
322
|
+
Unsupported attributes for which no corresponding field exists in SVGShape
|
|
323
|
+
dataclass are kept as text in the "style" attribute.
|
|
324
|
+
"""
|
|
325
|
+
target = self
|
|
326
|
+
if not inplace:
|
|
327
|
+
target = copy.deepcopy(self)
|
|
328
|
+
if target.style:
|
|
329
|
+
attr_types = {
|
|
330
|
+
f.name.replace("_", "-"): f.type for f in dataclasses.fields(self)
|
|
331
|
+
}
|
|
332
|
+
raw_attrs = {}
|
|
333
|
+
unparsed_style = parse_css_declarations(
|
|
334
|
+
target.style, raw_attrs, property_names=attr_types.keys()
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
for attr_name, attr_value in raw_attrs.items():
|
|
338
|
+
field_name = attr_name.replace("-", "_")
|
|
339
|
+
field_type = attr_types[attr_name]
|
|
340
|
+
|
|
341
|
+
if field_type == float and attr_name in SVG_LENGTH_ATTRS and isinstance(attr_value, str):
|
|
342
|
+
# Handle CSS length values with units
|
|
343
|
+
try:
|
|
344
|
+
if attr_value.endswith('%'):
|
|
345
|
+
# For percentage values, use number_or_percentage
|
|
346
|
+
field_value = number_or_percentage(attr_value, scale=100)
|
|
347
|
+
else:
|
|
348
|
+
# For other CSS units, use parse_css_length
|
|
349
|
+
field_value = parse_css_length(attr_value)
|
|
350
|
+
except (ValueError, TypeError):
|
|
351
|
+
# Fallback to original type conversion
|
|
352
|
+
field_value = field_type(attr_value)
|
|
353
|
+
else:
|
|
354
|
+
# Use original type conversion for non-length attributes
|
|
355
|
+
field_value = field_type(attr_value)
|
|
356
|
+
|
|
357
|
+
setattr(target, field_name, field_value)
|
|
358
|
+
target.style = unparsed_style
|
|
359
|
+
return target
|
|
360
|
+
|
|
361
|
+
def round_floats(self, ndigits: int, inplace=False) -> "SVGShape":
|
|
362
|
+
"""Round all floats in SVGShape to given decimal digits."""
|
|
363
|
+
target = self
|
|
364
|
+
if not inplace:
|
|
365
|
+
target = copy.deepcopy(self)
|
|
366
|
+
for field in dataclasses.fields(target):
|
|
367
|
+
field_value = getattr(self, field.name)
|
|
368
|
+
if isinstance(field_value, float):
|
|
369
|
+
setattr(target, field.name, round(field_value, ndigits))
|
|
370
|
+
return target
|
|
371
|
+
|
|
372
|
+
def round_multiple(self, multiple_of: float, inplace=False) -> "SVGShape":
|
|
373
|
+
"""Round all floats in SVGShape to nearest multiple of multiple_of."""
|
|
374
|
+
target = self
|
|
375
|
+
if not inplace:
|
|
376
|
+
target = copy.deepcopy(self)
|
|
377
|
+
for field in dataclasses.fields(target):
|
|
378
|
+
field_value = getattr(self, field.name)
|
|
379
|
+
if isinstance(field_value, float):
|
|
380
|
+
setattr(target, field.name, _round_multiple(field_value, multiple_of))
|
|
381
|
+
return target
|
|
382
|
+
|
|
383
|
+
def almost_equals(self, other: "SVGShape", tolerance: float) -> bool:
|
|
384
|
+
for (l_cmd, l_args), (r_cmd, r_args) in zip_longest(
|
|
385
|
+
self.as_path(), other.as_path(), fillvalue=(None, ())
|
|
386
|
+
):
|
|
387
|
+
if l_cmd != r_cmd or len(l_args) != len(r_args):
|
|
388
|
+
return False
|
|
389
|
+
if any(abs(lv - rv) > tolerance for lv, rv in zip(l_args, r_args)):
|
|
390
|
+
return False
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
def normalize_opacity(self, inplace=False):
|
|
394
|
+
"""Merge '{fill,stroke}_opacity' with generic 'opacity' when possible.
|
|
395
|
+
|
|
396
|
+
If stroke="none", multiply opacity by fill_opacity and reset the latter;
|
|
397
|
+
or if fill="none", multiply opacity by stroke_opacity and reset the latter.
|
|
398
|
+
If both == "none" or both != "none", return as is.
|
|
399
|
+
"""
|
|
400
|
+
target = self
|
|
401
|
+
if not inplace:
|
|
402
|
+
target = copy.deepcopy(self)
|
|
403
|
+
|
|
404
|
+
if target.fill == "none" and target.stroke == "none":
|
|
405
|
+
return target
|
|
406
|
+
|
|
407
|
+
default = 1.0
|
|
408
|
+
for fill_attr, opacity_attr in [
|
|
409
|
+
("fill", "stroke_opacity"),
|
|
410
|
+
("stroke", "fill_opacity"),
|
|
411
|
+
]:
|
|
412
|
+
if getattr(target, fill_attr) == "none":
|
|
413
|
+
target.opacity *= getattr(target, opacity_attr)
|
|
414
|
+
setattr(target, opacity_attr, default)
|
|
415
|
+
|
|
416
|
+
return target
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# https://www.w3.org/TR/SVG11/paths.html#PathElement
|
|
420
|
+
@dataclasses.dataclass
|
|
421
|
+
class SVGPath(SVGShape, SVGCommandSeq):
|
|
422
|
+
tag: ClassVar[str] = "path"
|
|
423
|
+
d: str = ""
|
|
424
|
+
|
|
425
|
+
def __init__(self, **kwargs):
|
|
426
|
+
for name, value in kwargs.items():
|
|
427
|
+
setattr(self, name, value)
|
|
428
|
+
|
|
429
|
+
def _add(self, path_snippet):
|
|
430
|
+
if self.d:
|
|
431
|
+
self.d += " "
|
|
432
|
+
self.d += path_snippet
|
|
433
|
+
|
|
434
|
+
def _add_cmd(self, cmd, *args):
|
|
435
|
+
self._add(path_segment(cmd, *args))
|
|
436
|
+
|
|
437
|
+
def M(self, *args):
|
|
438
|
+
self._add_cmd("M", *args)
|
|
439
|
+
|
|
440
|
+
def m(self, *args):
|
|
441
|
+
self._add_cmd("m", *args)
|
|
442
|
+
|
|
443
|
+
def _arc(self, c, rx, ry, x, y, large_arc):
|
|
444
|
+
self._add(path_segment(c, rx, ry, 0, large_arc, 1, x, y))
|
|
445
|
+
|
|
446
|
+
def A(self, rx, ry, x, y, large_arc=0):
|
|
447
|
+
self._arc("A", rx, ry, x, y, large_arc)
|
|
448
|
+
|
|
449
|
+
def a(self, rx, ry, x, y, large_arc=0):
|
|
450
|
+
self._arc("a", rx, ry, x, y, large_arc)
|
|
451
|
+
|
|
452
|
+
def H(self, *args):
|
|
453
|
+
self._add_cmd("H", *args)
|
|
454
|
+
|
|
455
|
+
def h(self, *args):
|
|
456
|
+
self._add_cmd("h", *args)
|
|
457
|
+
|
|
458
|
+
def V(self, *args):
|
|
459
|
+
self._add_cmd("V", *args)
|
|
460
|
+
|
|
461
|
+
def v(self, *args):
|
|
462
|
+
self._add_cmd("v", *args)
|
|
463
|
+
|
|
464
|
+
def L(self, *args):
|
|
465
|
+
self._add_cmd("L", *args)
|
|
466
|
+
|
|
467
|
+
def l(self, *args):
|
|
468
|
+
self._add_cmd("L", *args)
|
|
469
|
+
|
|
470
|
+
def C(self, *args):
|
|
471
|
+
self._add_cmd("C", *args)
|
|
472
|
+
|
|
473
|
+
def Q(self, *args):
|
|
474
|
+
self._add_cmd("Q", *args)
|
|
475
|
+
|
|
476
|
+
def end(self):
|
|
477
|
+
self._add("Z")
|
|
478
|
+
|
|
479
|
+
def as_path(self) -> "SVGPath":
|
|
480
|
+
return self
|
|
481
|
+
|
|
482
|
+
def remove_overlaps(self, inplace=False) -> "SVGPath":
|
|
483
|
+
cmds = svg_pathops.remove_overlaps(self.as_cmd_seq(), fill_rule=self.fill_rule)
|
|
484
|
+
target = self
|
|
485
|
+
if not inplace:
|
|
486
|
+
target = copy.deepcopy(self)
|
|
487
|
+
# simplified paths follow the 'nonzero' winding rule
|
|
488
|
+
target.fill_rule = target.clip_rule = "nonzero"
|
|
489
|
+
return target.update_path(cmds, inplace=True)
|
|
490
|
+
|
|
491
|
+
def __iter__(self):
|
|
492
|
+
return parse_svg_path(self.d, exploded=True)
|
|
493
|
+
|
|
494
|
+
def walk(self, callback) -> "SVGPath":
|
|
495
|
+
"""Walk path and call callback to build potentially new commands.
|
|
496
|
+
|
|
497
|
+
https://www.w3.org/TR/SVG11/paths.html
|
|
498
|
+
|
|
499
|
+
def callback(subpath_start, curr_xy, cmd, args, prev_xy, prev_cmd, prev_args)
|
|
500
|
+
prev_* None if there was no previous
|
|
501
|
+
returns sequence of (new_cmd, new_args) that replace cmd, args
|
|
502
|
+
"""
|
|
503
|
+
curr_pos = Point()
|
|
504
|
+
subpath_start_pos = curr_pos # where a z will take you
|
|
505
|
+
new_cmds = []
|
|
506
|
+
|
|
507
|
+
# iteration gives us exploded commands
|
|
508
|
+
for idx, (cmd, args) in enumerate(self):
|
|
509
|
+
check_cmd(cmd, args)
|
|
510
|
+
if idx == 0 and cmd == "m":
|
|
511
|
+
cmd = "M"
|
|
512
|
+
|
|
513
|
+
prev = (None, None, None)
|
|
514
|
+
if new_cmds:
|
|
515
|
+
prev = new_cmds[-1]
|
|
516
|
+
for new_cmd, new_cmd_args in callback(
|
|
517
|
+
subpath_start_pos, curr_pos, cmd, args, *prev
|
|
518
|
+
):
|
|
519
|
+
if new_cmd.lower() != "z":
|
|
520
|
+
next_pos = _next_pos(curr_pos, new_cmd, new_cmd_args)
|
|
521
|
+
else:
|
|
522
|
+
next_pos = subpath_start_pos
|
|
523
|
+
|
|
524
|
+
prev_pos, curr_pos = curr_pos, next_pos
|
|
525
|
+
if new_cmd.upper() == "M":
|
|
526
|
+
subpath_start_pos = curr_pos
|
|
527
|
+
new_cmds.append((prev_pos, new_cmd, new_cmd_args))
|
|
528
|
+
|
|
529
|
+
self.d = ""
|
|
530
|
+
for _, cmd, args in new_cmds:
|
|
531
|
+
self._add_cmd(cmd, *args)
|
|
532
|
+
return self
|
|
533
|
+
|
|
534
|
+
def subpaths(self) -> Tuple[str, ...]:
|
|
535
|
+
subpaths = [SVGPath()]
|
|
536
|
+
|
|
537
|
+
def subpaths_callback(subpath_start, curr_pos, cmd, args, *_unused):
|
|
538
|
+
if cmd.upper() == "M":
|
|
539
|
+
subpaths.append(SVGPath())
|
|
540
|
+
subpaths[-1]._add_cmd(cmd, *args)
|
|
541
|
+
if cmd.upper() == "Z":
|
|
542
|
+
subpaths.append(SVGPath())
|
|
543
|
+
return ((cmd, args),) # unmodified
|
|
544
|
+
|
|
545
|
+
# make all moveto absolute so each subpath is independent from the
|
|
546
|
+
# precending ones
|
|
547
|
+
self.absolute_moveto().walk(subpaths_callback)
|
|
548
|
+
|
|
549
|
+
return tuple(s.d for s in subpaths if s.d)
|
|
550
|
+
|
|
551
|
+
def remove_empty_subpaths(self, inplace=False) -> "SVGPath":
|
|
552
|
+
target = self
|
|
553
|
+
if not inplace:
|
|
554
|
+
target = copy.deepcopy(self)
|
|
555
|
+
|
|
556
|
+
target.d = " ".join(
|
|
557
|
+
subpath for subpath in self.subpaths() if SVGPath(d=subpath).might_paint()
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
return target
|
|
561
|
+
|
|
562
|
+
def move(self, dx, dy, inplace=False):
|
|
563
|
+
"""Returns a new path that is this one shifted."""
|
|
564
|
+
|
|
565
|
+
def move_callback(subpath_start, curr_pos, cmd, args, *_unused):
|
|
566
|
+
del subpath_start
|
|
567
|
+
del curr_pos
|
|
568
|
+
# Paths must start with an absolute moveto. Relative bits are ... relative.
|
|
569
|
+
# Shift the absolute parts and call it a day.
|
|
570
|
+
if cmd.islower():
|
|
571
|
+
return ((cmd, args),)
|
|
572
|
+
x_coord_idxs, y_coord_idxs = cmd_coords(cmd)
|
|
573
|
+
args = list(args) # we'd like to mutate 'em
|
|
574
|
+
for x_coord_idx in x_coord_idxs:
|
|
575
|
+
args[x_coord_idx] += dx
|
|
576
|
+
for y_coord_idx in y_coord_idxs:
|
|
577
|
+
args[y_coord_idx] += dy
|
|
578
|
+
return ((cmd, args),)
|
|
579
|
+
|
|
580
|
+
target = self
|
|
581
|
+
if not inplace:
|
|
582
|
+
target = copy.deepcopy(self)
|
|
583
|
+
target.walk(move_callback)
|
|
584
|
+
return target
|
|
585
|
+
|
|
586
|
+
def _rewrite_path(self, rewrite_fn, inplace) -> "SVGPath":
|
|
587
|
+
def rewrite_callback(subpath_start, curr_pos, cmd, args, *_):
|
|
588
|
+
new_cmd, new_cmd_args = rewrite_fn(curr_pos, cmd, args)
|
|
589
|
+
|
|
590
|
+
# if we modified cmd to pass *very* close to subpath start snap to it
|
|
591
|
+
# eliminates issues with not-quite-closed shapes due float imprecision
|
|
592
|
+
next_pos = _next_pos(curr_pos, new_cmd, new_cmd_args)
|
|
593
|
+
if next_pos != subpath_start and next_pos.almost_equals(subpath_start):
|
|
594
|
+
new_cmd, new_cmd_args = _move_endpoint(
|
|
595
|
+
curr_pos, new_cmd, new_cmd_args, subpath_start
|
|
596
|
+
)
|
|
597
|
+
return ((new_cmd, new_cmd_args),)
|
|
598
|
+
|
|
599
|
+
target = self
|
|
600
|
+
if not inplace:
|
|
601
|
+
target = copy.deepcopy(self)
|
|
602
|
+
target.walk(rewrite_callback)
|
|
603
|
+
return target
|
|
604
|
+
|
|
605
|
+
def absolute(self, inplace=False) -> "SVGPath":
|
|
606
|
+
"""Returns equivalent path with only absolute commands."""
|
|
607
|
+
return self._rewrite_path(_relative_to_absolute, inplace)
|
|
608
|
+
|
|
609
|
+
def absolute_moveto(self, inplace=False) -> "SVGPath":
|
|
610
|
+
"""Returns equivalent path with absolute moveto commands."""
|
|
611
|
+
return self._rewrite_path(_relative_to_absolute_moveto, inplace)
|
|
612
|
+
|
|
613
|
+
def relative(self, inplace=False) -> "SVGPath":
|
|
614
|
+
"""Returns equivalent path with only relative commands."""
|
|
615
|
+
result = self._rewrite_path(_absolute_to_relative, inplace)
|
|
616
|
+
# First move is always absolute
|
|
617
|
+
if result.d[0] == "m":
|
|
618
|
+
result.d = "M" + result.d[1:]
|
|
619
|
+
return result
|
|
620
|
+
|
|
621
|
+
def explicit_lines(self, inplace=False):
|
|
622
|
+
"""Replace all vertical/horizontal lines with line to (x,y)."""
|
|
623
|
+
target = self
|
|
624
|
+
if not inplace:
|
|
625
|
+
target = copy.deepcopy(self)
|
|
626
|
+
target.walk(_explicit_lines_callback)
|
|
627
|
+
return target
|
|
628
|
+
|
|
629
|
+
def expand_shorthand(self, inplace=False):
|
|
630
|
+
"""Rewrite commands that imply knowledge of prior commands arguments.
|
|
631
|
+
|
|
632
|
+
In particular, shorthand quadratic and bezier curves become explicit.
|
|
633
|
+
|
|
634
|
+
See https://www.w3.org/TR/SVG11/paths.html#PathDataCurveCommands.
|
|
635
|
+
"""
|
|
636
|
+
|
|
637
|
+
def expand_shorthand_callback(
|
|
638
|
+
_, curr_pos, cmd, args, prev_pos, prev_cmd, prev_args
|
|
639
|
+
):
|
|
640
|
+
short_to_long = {"S": "C", "T": "Q"}
|
|
641
|
+
if not cmd.upper() in short_to_long:
|
|
642
|
+
return ((cmd, args),)
|
|
643
|
+
|
|
644
|
+
if cmd.islower():
|
|
645
|
+
cmd, args = _relative_to_absolute(curr_pos, cmd, args)
|
|
646
|
+
|
|
647
|
+
# if there is no prev, or a bad prev, control point coincident current
|
|
648
|
+
new_cp = (curr_pos.x, curr_pos.y)
|
|
649
|
+
if prev_cmd:
|
|
650
|
+
if prev_cmd.islower():
|
|
651
|
+
prev_cmd, prev_args = _relative_to_absolute(
|
|
652
|
+
prev_pos, prev_cmd, prev_args
|
|
653
|
+
)
|
|
654
|
+
if prev_cmd in short_to_long.values():
|
|
655
|
+
# reflect 2nd-last x,y pair over curr_pos and make it our first arg
|
|
656
|
+
prev_cp = Point(prev_args[-4], prev_args[-3])
|
|
657
|
+
new_cp = (2 * curr_pos.x - prev_cp.x, 2 * curr_pos.y - prev_cp.y)
|
|
658
|
+
|
|
659
|
+
return ((short_to_long[cmd], new_cp + args),)
|
|
660
|
+
|
|
661
|
+
target = self
|
|
662
|
+
if not inplace:
|
|
663
|
+
target = copy.deepcopy(self)
|
|
664
|
+
target.walk(expand_shorthand_callback)
|
|
665
|
+
return target
|
|
666
|
+
|
|
667
|
+
def arcs_to_cubics(self, inplace=False):
|
|
668
|
+
"""Replace all arcs with similar cubics"""
|
|
669
|
+
|
|
670
|
+
def arc_to_cubic_callback(subpath_start, curr_pos, cmd, args, *_):
|
|
671
|
+
del subpath_start
|
|
672
|
+
if cmd not in {"a", "A"}:
|
|
673
|
+
# no work to do
|
|
674
|
+
return ((cmd, args),)
|
|
675
|
+
|
|
676
|
+
(rx, ry, x_rotation, large, sweep, end_x, end_y) = args
|
|
677
|
+
|
|
678
|
+
if cmd == "a":
|
|
679
|
+
end_x += curr_pos.x
|
|
680
|
+
end_y += curr_pos.y
|
|
681
|
+
end_pt = Point(end_x, end_y)
|
|
682
|
+
|
|
683
|
+
result = []
|
|
684
|
+
for p1, p2, target in arc_to_cubic(
|
|
685
|
+
curr_pos, rx, ry, x_rotation, large, sweep, end_pt
|
|
686
|
+
):
|
|
687
|
+
x, y = target
|
|
688
|
+
if p1 is not None:
|
|
689
|
+
assert p2 is not None
|
|
690
|
+
x1, y1 = p1
|
|
691
|
+
x2, y2 = p2
|
|
692
|
+
result.append(("C", (x1, y1, x2, y2, x, y)))
|
|
693
|
+
else:
|
|
694
|
+
result.append(("L", (x, y)))
|
|
695
|
+
|
|
696
|
+
return tuple(result)
|
|
697
|
+
|
|
698
|
+
target = self
|
|
699
|
+
if not inplace:
|
|
700
|
+
target = copy.deepcopy(self)
|
|
701
|
+
target.walk(arc_to_cubic_callback)
|
|
702
|
+
return target
|
|
703
|
+
|
|
704
|
+
@classmethod
|
|
705
|
+
def from_commands(cls, svg_cmds: Generator[SVGCommand, None, None]) -> "SVGPath":
|
|
706
|
+
return cls().update_path(svg_cmds, inplace=True)
|
|
707
|
+
|
|
708
|
+
def update_path(
|
|
709
|
+
self, svg_cmds: Generator[SVGCommand, None, None], inplace=False
|
|
710
|
+
) -> "SVGPath":
|
|
711
|
+
target = self
|
|
712
|
+
if not inplace:
|
|
713
|
+
target = copy.deepcopy(self)
|
|
714
|
+
target.d = ""
|
|
715
|
+
|
|
716
|
+
for cmd, args in svg_cmds:
|
|
717
|
+
target._add_cmd(cmd, *args)
|
|
718
|
+
return target
|
|
719
|
+
|
|
720
|
+
def round_floats(self, ndigits: int, inplace=False) -> "SVGPath":
|
|
721
|
+
"""Round all floats in SVGPath to given decimal digits.
|
|
722
|
+
|
|
723
|
+
Also reformat the SVGPath.d string floats with the same rounding.
|
|
724
|
+
"""
|
|
725
|
+
target: SVGPath = super().round_floats(ndigits, inplace=inplace).as_path()
|
|
726
|
+
|
|
727
|
+
d, target.d = target.d, ""
|
|
728
|
+
for cmd, args in parse_svg_path(d):
|
|
729
|
+
target._add_cmd(cmd, *(round(n, ndigits) for n in args))
|
|
730
|
+
|
|
731
|
+
return target
|
|
732
|
+
|
|
733
|
+
def round_multiple(self, multiple_of: float, inplace=False) -> "SVGPath":
|
|
734
|
+
"""Round all floats in SVGPath to given decimal digits.
|
|
735
|
+
|
|
736
|
+
Also reformat the SVGPath.d string floats with the same rounding.
|
|
737
|
+
"""
|
|
738
|
+
target: SVGPath = super().round_multiple(multiple_of, inplace=inplace).as_path()
|
|
739
|
+
|
|
740
|
+
d, target.d = target.d, ""
|
|
741
|
+
for cmd, args in parse_svg_path(d):
|
|
742
|
+
target._add_cmd(cmd, *(_round_multiple(n, multiple_of) for n in args))
|
|
743
|
+
|
|
744
|
+
return target
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# https://www.w3.org/TR/SVG11/shapes.html#CircleElement
|
|
748
|
+
@dataclasses.dataclass
|
|
749
|
+
class SVGCircle(SVGShape):
|
|
750
|
+
tag: ClassVar[str] = "circle"
|
|
751
|
+
r: float = 0
|
|
752
|
+
cx: float = 0
|
|
753
|
+
cy: float = 0
|
|
754
|
+
|
|
755
|
+
def as_path(self) -> SVGPath:
|
|
756
|
+
*shape_fields, r, cx, cy = dataclasses.astuple(self)
|
|
757
|
+
path = SVGEllipse(rx=r, ry=r, cx=cx, cy=cy).as_path()
|
|
758
|
+
path._copy_common_fields(*shape_fields)
|
|
759
|
+
return path
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
# https://www.w3.org/TR/SVG11/shapes.html#EllipseElement
|
|
763
|
+
@dataclasses.dataclass
|
|
764
|
+
class SVGEllipse(SVGShape):
|
|
765
|
+
tag: ClassVar[str] = "ellipse"
|
|
766
|
+
rx: float = 0
|
|
767
|
+
ry: float = 0
|
|
768
|
+
cx: float = 0
|
|
769
|
+
cy: float = 0
|
|
770
|
+
|
|
771
|
+
def as_path(self) -> SVGPath:
|
|
772
|
+
*shape_fields, rx, ry, cx, cy = dataclasses.astuple(self)
|
|
773
|
+
path = SVGPath()
|
|
774
|
+
# arc doesn't seem to like being a complete shape, draw two halves.
|
|
775
|
+
# We start at 3 o'clock and proceed in clockwise direction:
|
|
776
|
+
# https://www.w3.org/TR/SVG/shapes.html#CircleElement
|
|
777
|
+
path.M(cx + rx, cy)
|
|
778
|
+
path.A(rx, ry, cx - rx, cy, large_arc=1)
|
|
779
|
+
path.A(rx, ry, cx + rx, cy, large_arc=1)
|
|
780
|
+
path.end()
|
|
781
|
+
path._copy_common_fields(*shape_fields)
|
|
782
|
+
return path
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
# https://www.w3.org/TR/SVG11/shapes.html#LineElement
|
|
786
|
+
@dataclasses.dataclass
|
|
787
|
+
class SVGLine(SVGShape):
|
|
788
|
+
tag: ClassVar[str] = "line"
|
|
789
|
+
x1: float = 0
|
|
790
|
+
y1: float = 0
|
|
791
|
+
x2: float = 0
|
|
792
|
+
y2: float = 0
|
|
793
|
+
|
|
794
|
+
def as_path(self) -> SVGPath:
|
|
795
|
+
*shape_fields, x1, y1, x2, y2 = dataclasses.astuple(self)
|
|
796
|
+
path = SVGPath()
|
|
797
|
+
path.M(x1, y1)
|
|
798
|
+
path.L(x2, y2)
|
|
799
|
+
path._copy_common_fields(*shape_fields)
|
|
800
|
+
return path
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
# https://www.w3.org/TR/SVG11/shapes.html#PolygonElement
|
|
804
|
+
@dataclasses.dataclass
|
|
805
|
+
class SVGPolygon(SVGShape):
|
|
806
|
+
tag: ClassVar[str] = "polygon"
|
|
807
|
+
points: str = ""
|
|
808
|
+
|
|
809
|
+
def as_path(self) -> SVGPath:
|
|
810
|
+
*shape_fields, points = dataclasses.astuple(self)
|
|
811
|
+
if self.points:
|
|
812
|
+
path = SVGPath(d="M" + self.points + " Z")
|
|
813
|
+
else:
|
|
814
|
+
path = SVGPath()
|
|
815
|
+
path._copy_common_fields(*shape_fields)
|
|
816
|
+
return path
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
# https://www.w3.org/TR/SVG11/shapes.html#PolylineElement
|
|
820
|
+
@dataclasses.dataclass
|
|
821
|
+
class SVGPolyline(SVGShape):
|
|
822
|
+
tag: ClassVar[str] = "polyline"
|
|
823
|
+
points: str = ""
|
|
824
|
+
|
|
825
|
+
def as_path(self) -> SVGPath:
|
|
826
|
+
*shape_fields, points = dataclasses.astuple(self)
|
|
827
|
+
if points:
|
|
828
|
+
path = SVGPath(d="M" + self.points)
|
|
829
|
+
else:
|
|
830
|
+
path = SVGPath()
|
|
831
|
+
path._copy_common_fields(*shape_fields)
|
|
832
|
+
return path
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# https://www.w3.org/TR/SVG11/shapes.html#RectElement
|
|
836
|
+
@dataclasses.dataclass
|
|
837
|
+
class SVGRect(SVGShape):
|
|
838
|
+
tag: ClassVar[str] = "rect"
|
|
839
|
+
x: float = 0
|
|
840
|
+
y: float = 0
|
|
841
|
+
width: float = 0
|
|
842
|
+
height: float = 0
|
|
843
|
+
rx: float = 0
|
|
844
|
+
ry: float = 0
|
|
845
|
+
|
|
846
|
+
def __post_init__(self):
|
|
847
|
+
if not self.rx:
|
|
848
|
+
self.rx = self.ry
|
|
849
|
+
if not self.ry:
|
|
850
|
+
self.ry = self.rx
|
|
851
|
+
self.rx = min(self.rx, self.width / 2)
|
|
852
|
+
self.ry = min(self.ry, self.height / 2)
|
|
853
|
+
|
|
854
|
+
def as_path(self) -> SVGPath:
|
|
855
|
+
*shape_fields, x, y, w, h, rx, ry = dataclasses.astuple(self)
|
|
856
|
+
path = SVGPath()
|
|
857
|
+
path.M(x + rx, y)
|
|
858
|
+
path.H(x + w - rx)
|
|
859
|
+
if rx > 0:
|
|
860
|
+
path.A(rx, ry, x + w, y + ry)
|
|
861
|
+
path.V(y + h - ry)
|
|
862
|
+
if rx > 0:
|
|
863
|
+
path.A(rx, ry, x + w - rx, y + h)
|
|
864
|
+
path.H(x + rx)
|
|
865
|
+
if rx > 0:
|
|
866
|
+
path.A(rx, ry, x, y + h - ry)
|
|
867
|
+
path.V(y + ry)
|
|
868
|
+
if rx > 0:
|
|
869
|
+
path.A(rx, ry, x + rx, y)
|
|
870
|
+
path.end()
|
|
871
|
+
path._copy_common_fields(*shape_fields)
|
|
872
|
+
|
|
873
|
+
return path
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
_UNIT_RECT = Rect(0, 0, 1, 1)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
class _SVGGradient:
|
|
880
|
+
tag: ClassVar[str] = "some_gradient"
|
|
881
|
+
id: str
|
|
882
|
+
gradientTransform: Affine2D
|
|
883
|
+
gradientUnits: str
|
|
884
|
+
spreadMethod: str
|
|
885
|
+
|
|
886
|
+
@staticmethod
|
|
887
|
+
def _get_gradient_units_relative_scale(
|
|
888
|
+
attrib: Mapping[str, str], view_box: Optional[Rect]
|
|
889
|
+
) -> Rect:
|
|
890
|
+
gradient_units = attrib.get("gradientUnits", "objectBoundingBox")
|
|
891
|
+
if gradient_units == "userSpaceOnUse":
|
|
892
|
+
# For gradientUnits="userSpaceOnUse", percentages represent values relative to
|
|
893
|
+
# the current viewport.
|
|
894
|
+
# If view_box is None, fallback to unit rectangle
|
|
895
|
+
return view_box if view_box is not None else _UNIT_RECT
|
|
896
|
+
elif gradient_units == "objectBoundingBox":
|
|
897
|
+
# For gradientUnits="objectBoundingBox", percentages represent values relative
|
|
898
|
+
# to the object bounding box. The latter defines an abstract coordinate system
|
|
899
|
+
# with origin at (0,0) and a nominal width and height = 1.
|
|
900
|
+
return _UNIT_RECT
|
|
901
|
+
else:
|
|
902
|
+
raise ValueError(f'gradientUnits="{gradient_units}" not supported')
|
|
903
|
+
|
|
904
|
+
@staticmethod
|
|
905
|
+
def _parse_common_gradient_parts(attrib: MutableMapping[str, str]):
|
|
906
|
+
result = {}
|
|
907
|
+
for attr_name in ("id", "gradientUnits", "spreadMethod"):
|
|
908
|
+
if attr_name in attrib:
|
|
909
|
+
result[attr_name] = attrib.pop(attr_name)
|
|
910
|
+
if "gradientTransform" in attrib:
|
|
911
|
+
result["gradientTransform"] = Affine2D.fromstring(
|
|
912
|
+
attrib.pop("gradientTransform")
|
|
913
|
+
)
|
|
914
|
+
return result
|
|
915
|
+
|
|
916
|
+
def as_user_space_units(self, shape_bbox, inplace=False) -> "_SVGGradient":
|
|
917
|
+
# objectBoundingBox -> userSpaceOnUse
|
|
918
|
+
target = self
|
|
919
|
+
if not inplace:
|
|
920
|
+
target = copy.deepcopy(self)
|
|
921
|
+
if self.gradientUnits == "objectBoundingBox":
|
|
922
|
+
target.gradientTransform = Affine2D.compose_ltr(
|
|
923
|
+
(self.gradientTransform, Affine2D.rect_to_rect(_UNIT_RECT, shape_bbox))
|
|
924
|
+
)
|
|
925
|
+
target.gradientUnits = "userSpaceOnUse"
|
|
926
|
+
return target
|
|
927
|
+
|
|
928
|
+
@classmethod
|
|
929
|
+
def from_element(cls, el, view_box: Rect) -> "_SVGGradient":
|
|
930
|
+
raise NotImplementedError
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
# https://developer.mozilla.org/en-US/docs/Web/SVG/Element/linearGradient
|
|
934
|
+
# Should be parsed with from_element
|
|
935
|
+
@dataclasses.dataclass
|
|
936
|
+
class SVGLinearGradient(_SVGGradient):
|
|
937
|
+
tag: ClassVar[str] = "linearGradient"
|
|
938
|
+
id: str
|
|
939
|
+
x1: float
|
|
940
|
+
y1: float
|
|
941
|
+
x2: float
|
|
942
|
+
y2: float
|
|
943
|
+
gradientTransform: Affine2D = Affine2D.identity()
|
|
944
|
+
gradientUnits: str = "objectBoundingBox"
|
|
945
|
+
spreadMethod: str = "pad"
|
|
946
|
+
|
|
947
|
+
@classmethod
|
|
948
|
+
def from_element(cls, el, view_box) -> "SVGLinearGradient":
|
|
949
|
+
attrib = dict(el.attrib)
|
|
950
|
+
scale = cls._get_gradient_units_relative_scale(attrib, view_box)
|
|
951
|
+
self = cls(
|
|
952
|
+
x1=number_or_percentage(attrib.pop("x1", "0%"), scale.w),
|
|
953
|
+
y1=number_or_percentage(attrib.pop("y1", "0%"), scale.h),
|
|
954
|
+
x2=number_or_percentage(attrib.pop("x2", "100%"), scale.w),
|
|
955
|
+
y2=number_or_percentage(attrib.pop("y2", "0%"), scale.h),
|
|
956
|
+
**cls._parse_common_gradient_parts(attrib),
|
|
957
|
+
)
|
|
958
|
+
if attrib:
|
|
959
|
+
raise ValueError(f"unknown attributes in gradient {self.id!r}: {attrib!r}")
|
|
960
|
+
return self
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
# https://developer.mozilla.org/en-US/docs/Web/SVG/Element/radialGradient
|
|
964
|
+
# Should be parsed with from_element
|
|
965
|
+
@dataclasses.dataclass
|
|
966
|
+
class SVGRadialGradient(_SVGGradient):
|
|
967
|
+
tag: ClassVar[str] = "radialGradient"
|
|
968
|
+
id: str
|
|
969
|
+
cx: float
|
|
970
|
+
cy: float
|
|
971
|
+
r: float
|
|
972
|
+
fx: float = _LinkedDefault("cx")
|
|
973
|
+
fy: float = _LinkedDefault("cy")
|
|
974
|
+
fr: float = 0.0
|
|
975
|
+
gradientTransform: Affine2D = Affine2D.identity()
|
|
976
|
+
gradientUnits: str = "objectBoundingBox"
|
|
977
|
+
spreadMethod: str = "pad"
|
|
978
|
+
|
|
979
|
+
def __post_init__(self):
|
|
980
|
+
for field in dataclasses.fields(self):
|
|
981
|
+
value = getattr(self, field.name)
|
|
982
|
+
if isinstance(value, _LinkedDefault):
|
|
983
|
+
setattr(self, field.name, value(self))
|
|
984
|
+
|
|
985
|
+
@classmethod
|
|
986
|
+
def from_element(cls, el, view_box) -> "SVGRadialGradient":
|
|
987
|
+
attrib = dict(el.attrib)
|
|
988
|
+
scale = cls._get_gradient_units_relative_scale(attrib, view_box)
|
|
989
|
+
diagonal = scale.normalized_diagonal()
|
|
990
|
+
|
|
991
|
+
kwargs = dict(
|
|
992
|
+
cx=number_or_percentage(attrib.pop("cx", "50%"), scale.w),
|
|
993
|
+
cy=number_or_percentage(attrib.pop("cy", "50%"), scale.h),
|
|
994
|
+
r=number_or_percentage(attrib.pop("r", "50%"), diagonal),
|
|
995
|
+
fr=number_or_percentage(attrib.pop("fr", "0%"), diagonal),
|
|
996
|
+
)
|
|
997
|
+
if "fx" in attrib:
|
|
998
|
+
kwargs["fx"] = number_or_percentage(attrib.pop("fx"), scale.w)
|
|
999
|
+
if "fy" in attrib:
|
|
1000
|
+
kwargs["fy"] = number_or_percentage(attrib.pop("fy"), scale.h)
|
|
1001
|
+
kwargs.update(cls._parse_common_gradient_parts(attrib))
|
|
1002
|
+
self = cls(**kwargs)
|
|
1003
|
+
if attrib:
|
|
1004
|
+
raise ValueError(f"unknown attributes in gradient {self.id!r}: {attrib!r}")
|
|
1005
|
+
return self
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def union(shapes: Iterable[SVGShape]) -> Generator[SVGCommand, None, None]:
|
|
1009
|
+
return svg_pathops.union(
|
|
1010
|
+
[s.as_cmd_seq() for s in shapes], [s.clip_rule for s in shapes]
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def intersection(
|
|
1015
|
+
shapes: Iterable[SVGShape], fill_rules: Optional[Sequence[str]] = None
|
|
1016
|
+
) -> Generator[SVGCommand, None, None]:
|
|
1017
|
+
if fill_rules is None:
|
|
1018
|
+
# by default we assume the shapes to be intersected are all defined within
|
|
1019
|
+
# a clipPath element and as such we use their clip-rule as the fill type
|
|
1020
|
+
# when initialising a pathops.Path. If that's not the case (e.g. when
|
|
1021
|
+
# applying a clip path to the target shape) you should specify the correct
|
|
1022
|
+
# fill rules explicitly: i.e. fill-rule for target shapes and clip-rule
|
|
1023
|
+
# for children of clipPath.
|
|
1024
|
+
fill_rules = [s.clip_rule for s in shapes]
|
|
1025
|
+
return svg_pathops.intersection([s.as_cmd_seq() for s in shapes], fill_rules)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def difference(shapes: Iterable[SVGShape]) -> Generator[SVGCommand, None, None]:
|
|
1029
|
+
return svg_pathops.difference(
|
|
1030
|
+
[s.as_cmd_seq() for s in shapes], [s.clip_rule for s in shapes]
|
|
1031
|
+
)
|