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_meta.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
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 re
|
|
16
|
+
from types import MappingProxyType
|
|
17
|
+
from lxml import etree # pytype: disable=import-error
|
|
18
|
+
from typing import (
|
|
19
|
+
Any,
|
|
20
|
+
Container,
|
|
21
|
+
Generator,
|
|
22
|
+
Iterable,
|
|
23
|
+
MutableMapping,
|
|
24
|
+
Optional,
|
|
25
|
+
Tuple,
|
|
26
|
+
)
|
|
27
|
+
from picosvgx.geometric_types import Rect
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SVGCommand = Tuple[str, Tuple[float, ...]]
|
|
31
|
+
SVGCommandSeq = Iterable[SVGCommand]
|
|
32
|
+
SVGCommandGen = Generator[SVGCommand, None, None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def svgns():
|
|
36
|
+
return "http://www.w3.org/2000/svg"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def xlinkns():
|
|
40
|
+
return "http://www.w3.org/1999/xlink"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def splitns(name):
|
|
44
|
+
qn = etree.QName(name)
|
|
45
|
+
return qn.namespace, qn.localname
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def strip_ns(tagname):
|
|
49
|
+
return splitns(tagname)[1]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# https://www.w3.org/TR/SVG11/paths.html#PathData
|
|
53
|
+
_CMD_ARGS = {
|
|
54
|
+
"m": 2,
|
|
55
|
+
"z": 0,
|
|
56
|
+
"l": 2,
|
|
57
|
+
"h": 1,
|
|
58
|
+
"v": 1,
|
|
59
|
+
"c": 6,
|
|
60
|
+
"s": 4,
|
|
61
|
+
"q": 4,
|
|
62
|
+
"t": 2,
|
|
63
|
+
"a": 7,
|
|
64
|
+
}
|
|
65
|
+
_CMD_ARGS.update({k.upper(): v for k, v in _CMD_ARGS.items()})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_cmd(cmd, args):
|
|
69
|
+
cmd_args = num_args(cmd)
|
|
70
|
+
if cmd_args == 0:
|
|
71
|
+
if args:
|
|
72
|
+
raise ValueError(f"{cmd} has no args, {len(args)} invalid")
|
|
73
|
+
elif len(args) % cmd_args != 0:
|
|
74
|
+
raise ValueError(f"{cmd} has sets of {cmd_args} args, {len(args)} invalid")
|
|
75
|
+
return cmd_args
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def num_args(cmd):
|
|
79
|
+
if not cmd in _CMD_ARGS:
|
|
80
|
+
raise ValueError(f'Invalid svg command "{cmd}"')
|
|
81
|
+
return _CMD_ARGS[cmd]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmds():
|
|
85
|
+
return _CMD_ARGS.keys()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# For each command iterable of x-coords and iterable of y-coords
|
|
89
|
+
# Helpful if you want to adjust them
|
|
90
|
+
_CMD_COORDS = {
|
|
91
|
+
"m": ((0,), (1,)),
|
|
92
|
+
"z": ((), ()),
|
|
93
|
+
"l": ((0,), (1,)),
|
|
94
|
+
"h": ((0,), ()),
|
|
95
|
+
"v": ((), (0,)),
|
|
96
|
+
"c": ((0, 2, 4), (1, 3, 5)),
|
|
97
|
+
"s": ((0, 2), (1, 3)),
|
|
98
|
+
"q": ((0, 2), (1, 3)),
|
|
99
|
+
"t": ((0,), (1,)),
|
|
100
|
+
"a": ((5,), (6,)),
|
|
101
|
+
}
|
|
102
|
+
_CMD_COORDS.update({k.upper(): v for k, v in _CMD_COORDS.items()})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_coords(cmd):
|
|
106
|
+
if not cmd in _CMD_ARGS:
|
|
107
|
+
raise ValueError(f'Invalid svg command "{cmd}"')
|
|
108
|
+
return _CMD_COORDS[cmd]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ntos(n: float) -> str:
|
|
112
|
+
# strip superflous .0 decimals
|
|
113
|
+
return str(int(n)) if isinstance(n, float) and n.is_integer() else str(n)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def number_or_percentage(s: str, scale=1) -> float:
|
|
117
|
+
return float(s[:-1]) / 100 * scale if s.endswith("%") else float(s)
|
|
118
|
+
|
|
119
|
+
def parse_css_length(s: str) -> float:
|
|
120
|
+
"""Parse CSS length values with absolute units and convert to pixels.
|
|
121
|
+
|
|
122
|
+
Converts absolute CSS units (px, pt, pc, mm, cm, in) to pixels using 96 DPI.
|
|
123
|
+
Percentages are returned as numeric values without the % sign (caller must handle scaling).
|
|
124
|
+
Relative units (em, rem, ex, ch, vw, vh) that require context are rejected.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
s: String value like '100px', '12pt', '50%'
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
float: Numeric value in pixels (or percentage value without % for percentages)
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
ValueError: If the value is empty, invalid, or uses unsupported relative units
|
|
134
|
+
"""
|
|
135
|
+
if not isinstance(s, str):
|
|
136
|
+
return float(s)
|
|
137
|
+
|
|
138
|
+
s = s.strip()
|
|
139
|
+
if not s:
|
|
140
|
+
raise ValueError("Empty CSS length value")
|
|
141
|
+
|
|
142
|
+
# Handle percentage values - return numeric value, caller handles scaling
|
|
143
|
+
if s.endswith('%'):
|
|
144
|
+
return float(s[:-1])
|
|
145
|
+
|
|
146
|
+
# Absolute unit conversions to pixels (96 DPI standard)
|
|
147
|
+
# Reference: https://www.w3.org/TR/css-values-3/#absolute-lengths
|
|
148
|
+
ABSOLUTE_UNITS = {
|
|
149
|
+
'px': 1.0, # pixels (base unit)
|
|
150
|
+
'pt': 96 / 72, # points: 1pt = 1/72 inch = 96/72 px
|
|
151
|
+
'pc': 96 / 6, # picas: 1pc = 1/6 inch = 16px
|
|
152
|
+
'in': 96, # inches: 1in = 96px (CSS standard)
|
|
153
|
+
'cm': 96 / 2.54, # centimeters: 1cm = 96/2.54 px
|
|
154
|
+
'mm': 96 / 25.4, # millimeters: 1mm = 96/25.4 px
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# Extract number and unit
|
|
158
|
+
match = re.match(r'^([+-]?(?:\d+\.?\d*|\.\d+))([a-z]*)$', s, re.IGNORECASE)
|
|
159
|
+
if not match:
|
|
160
|
+
raise ValueError(f"Invalid CSS length value: {s!r}")
|
|
161
|
+
|
|
162
|
+
number = float(match.group(1))
|
|
163
|
+
unit = match.group(2).lower()
|
|
164
|
+
|
|
165
|
+
if not unit:
|
|
166
|
+
# No unit specified, treat as pixels
|
|
167
|
+
return number
|
|
168
|
+
|
|
169
|
+
if unit in ABSOLUTE_UNITS:
|
|
170
|
+
return number * ABSOLUTE_UNITS[unit]
|
|
171
|
+
|
|
172
|
+
# Reject relative units that require context (em, rem, ex, ch, vw, vh, etc.)
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"Relative unit '{unit}' requires context and is not supported. "
|
|
175
|
+
f"Supported units: {', '.join(ABSOLUTE_UNITS.keys())}, %"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def path_segment(cmd, *args):
|
|
179
|
+
# put commas between coords, spaces otherwise, author readability pref
|
|
180
|
+
args_per_cmd = check_cmd(cmd, args)
|
|
181
|
+
args = [ntos(a) for a in args]
|
|
182
|
+
combined_args = []
|
|
183
|
+
xy_coords = set(zip(*_CMD_COORDS[cmd]))
|
|
184
|
+
if args_per_cmd:
|
|
185
|
+
for n in range(len(args) // args_per_cmd):
|
|
186
|
+
sub_args = args[n * args_per_cmd : (n + 1) * args_per_cmd]
|
|
187
|
+
i = 0
|
|
188
|
+
while i < len(sub_args):
|
|
189
|
+
if (i, i + 1) in xy_coords:
|
|
190
|
+
combined_args.append(f"{sub_args[i]},{sub_args[i+1]}")
|
|
191
|
+
i += 2
|
|
192
|
+
else:
|
|
193
|
+
combined_args.append(sub_args[i])
|
|
194
|
+
i += 1
|
|
195
|
+
return cmd + " ".join(combined_args)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_css_declarations(
|
|
199
|
+
style: str,
|
|
200
|
+
output: MutableMapping[str, Any],
|
|
201
|
+
property_names: Optional[Container[str]] = None,
|
|
202
|
+
) -> str:
|
|
203
|
+
"""Parse CSS declaration list into {property: value} XML element attributes.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
style: CSS declaration list without the enclosing braces,
|
|
207
|
+
as found in an SVG element's "style" attribute.
|
|
208
|
+
output: a dictionary or lxml.etree._Attrib where to store the parsed properties.
|
|
209
|
+
Note that lxml validates the attribute names and if a given CSS property name
|
|
210
|
+
is not a valid XML name (e.g. vendor specific keywords starting with a hyphen,
|
|
211
|
+
e.g. "-inkscape-font-specification"), it will be silently ignored.
|
|
212
|
+
property_names: optional set of property names to limit the declarations
|
|
213
|
+
to be parsed; if not provided, all will be parsed.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
A string containing the unparsed style declarations, if any.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ValueError if CSS declaration is invalid and can't be parsed.
|
|
220
|
+
|
|
221
|
+
References:
|
|
222
|
+
https://www.w3.org/TR/SVG/styling.html#ElementSpecificStyling
|
|
223
|
+
https://www.w3.org/TR/2013/REC-css-style-attr-20131107/#syntax
|
|
224
|
+
"""
|
|
225
|
+
unparsed = []
|
|
226
|
+
for declaration in style.split(";"):
|
|
227
|
+
declaration = declaration.strip()
|
|
228
|
+
if declaration.count(":") == 1:
|
|
229
|
+
property_name, value = declaration.split(":")
|
|
230
|
+
property_name, value = property_name.strip(), value.strip()
|
|
231
|
+
if property_names is None or property_name in property_names:
|
|
232
|
+
try:
|
|
233
|
+
output[property_name] = value
|
|
234
|
+
except ValueError:
|
|
235
|
+
# lxml raises if attrib name is invalid (e.g. starts with '-')
|
|
236
|
+
unparsed.append(declaration)
|
|
237
|
+
else:
|
|
238
|
+
unparsed.append(declaration)
|
|
239
|
+
elif declaration.strip():
|
|
240
|
+
raise ValueError(f"Invalid CSS declaration syntax: {declaration}")
|
|
241
|
+
return "; ".join(unparsed) + ";" if unparsed else ""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def parse_view_box(s: str) -> Rect:
|
|
245
|
+
box = tuple(float(v) for v in re.split(r",|\s+", s))
|
|
246
|
+
if len(box) != 4:
|
|
247
|
+
raise ValueError(f"Unable to parse viewBox: {s!r}")
|
|
248
|
+
return Rect(*box)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# SVG attributes that may contain CSS length values with units
|
|
252
|
+
# Define once using Python field naming convention (underscores)
|
|
253
|
+
_LENGTH_FIELDS = frozenset({
|
|
254
|
+
'width', 'height', 'x', 'y', 'cx', 'cy', 'r', 'rx', 'ry',
|
|
255
|
+
'x1', 'y1', 'x2', 'y2', 'stroke_width', 'stroke_dashoffset'
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
# Export both naming conventions
|
|
259
|
+
SVG_LENGTH_FIELDS = _LENGTH_FIELDS
|
|
260
|
+
SVG_LENGTH_ATTRS = frozenset(f.replace('_', '-') for f in _LENGTH_FIELDS)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# sentinel object to check if special linked fields such as fx/fy are explicitly set;
|
|
264
|
+
# using a float type instead of None to make the typechecker happy, and also so that one
|
|
265
|
+
# doesn't need to unwrap Optional type whenever accessing those fields
|
|
266
|
+
class _LinkedDefault(float):
|
|
267
|
+
def __new__(cls, attr_name):
|
|
268
|
+
self = float.__new__(cls, "NaN")
|
|
269
|
+
self.attr_name = attr_name
|
|
270
|
+
return self
|
|
271
|
+
|
|
272
|
+
def __call__(self, data_obj):
|
|
273
|
+
return getattr(data_obj, self.attr_name)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# makes dict read-only
|
|
277
|
+
ATTRIB_DEFAULTS = MappingProxyType(
|
|
278
|
+
{
|
|
279
|
+
"clip-path": "",
|
|
280
|
+
"clip-rule": "nonzero",
|
|
281
|
+
"fill": "black",
|
|
282
|
+
"fill-opacity": 1.0,
|
|
283
|
+
"fill-rule": "nonzero",
|
|
284
|
+
"stroke": "none",
|
|
285
|
+
"stroke-width": 1.0,
|
|
286
|
+
"stroke-linecap": "butt",
|
|
287
|
+
"stroke-linejoin": "miter",
|
|
288
|
+
"stroke-miterlimit": 4,
|
|
289
|
+
"stroke-dasharray": "none",
|
|
290
|
+
"stroke-dashoffset": 0.0,
|
|
291
|
+
"stroke-opacity": 1.0,
|
|
292
|
+
"opacity": 1.0,
|
|
293
|
+
"transform": "",
|
|
294
|
+
"style": "",
|
|
295
|
+
"display": "inline",
|
|
296
|
+
"d": "",
|
|
297
|
+
"id": "",
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def attrib_default(name: str, default: Any = ()) -> Any:
|
|
303
|
+
if name in ATTRIB_DEFAULTS:
|
|
304
|
+
return ATTRIB_DEFAULTS[name]
|
|
305
|
+
if default == ():
|
|
306
|
+
raise ValueError(f"No entry for '{name}' and no default given")
|
|
307
|
+
return default
|
|
@@ -0,0 +1,110 @@
|
|
|
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 re
|
|
16
|
+
from typing import Generator, Tuple
|
|
17
|
+
from picosvgx import svg_meta
|
|
18
|
+
|
|
19
|
+
_CMD_RE = re.compile(f'([{"".join(svg_meta.cmds())}])')
|
|
20
|
+
_SEPARATOR_RE = re.compile("[, ]+")
|
|
21
|
+
_FLOAT_RE = re.compile(
|
|
22
|
+
r"[-+]?" # optional sign
|
|
23
|
+
r"(?:"
|
|
24
|
+
r"(?:0|[1-9][0-9]*)(?:\.[0-9]+)?" # int or float
|
|
25
|
+
r"|"
|
|
26
|
+
r"(?:\.[0-9]+)" # float with leading dot (e.g. '.42')
|
|
27
|
+
r")"
|
|
28
|
+
r"(?:[eE][-+]?[0-9]+)?" # optional scientific notiation
|
|
29
|
+
)
|
|
30
|
+
_BOOL_RE = re.compile("^[01]")
|
|
31
|
+
_ARC_ARGUMENT_TYPES = (
|
|
32
|
+
(float, _FLOAT_RE), # rx
|
|
33
|
+
(float, _FLOAT_RE), # ry
|
|
34
|
+
(float, _FLOAT_RE), # x-axis-rotation
|
|
35
|
+
(int, _BOOL_RE), # large-arc-flag
|
|
36
|
+
(int, _BOOL_RE), # sweep-flag
|
|
37
|
+
(float, _FLOAT_RE), # x
|
|
38
|
+
(float, _FLOAT_RE), # y
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# https://www.w3.org/TR/SVG11/paths.html#PathDataMovetoCommands
|
|
42
|
+
# If a moveto is followed by multiple pairs of coordinates,
|
|
43
|
+
# the subsequent pairs are treated as implicit lineto commands
|
|
44
|
+
_IMPLICIT_REPEAT_CMD = {"m": "l", "M": "L"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_args(cmd: str, args: str) -> Generator[float, None, None]:
|
|
48
|
+
raw_args = [s for s in _SEPARATOR_RE.split(args) if s]
|
|
49
|
+
if not raw_args:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if cmd.upper() == "A":
|
|
53
|
+
arg_types = _ARC_ARGUMENT_TYPES
|
|
54
|
+
else:
|
|
55
|
+
arg_types = ((float, _FLOAT_RE),)
|
|
56
|
+
n = len(arg_types)
|
|
57
|
+
|
|
58
|
+
i = j = 0
|
|
59
|
+
while j < len(raw_args):
|
|
60
|
+
arg = raw_args[j]
|
|
61
|
+
# modulo to wrap around
|
|
62
|
+
converter, regex = arg_types[i % n]
|
|
63
|
+
m = regex.match(arg)
|
|
64
|
+
if not m:
|
|
65
|
+
raise ValueError(f"Invalid argument #{i} for '{cmd}': {arg!r}")
|
|
66
|
+
|
|
67
|
+
start, end = m.span()
|
|
68
|
+
yield converter(arg[start:end])
|
|
69
|
+
|
|
70
|
+
if end < len(arg):
|
|
71
|
+
raw_args[j] = arg[end:]
|
|
72
|
+
else:
|
|
73
|
+
j += 1
|
|
74
|
+
i += 1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _explode_cmd(args_per_cmd, cmd, args):
|
|
78
|
+
cmds = []
|
|
79
|
+
for i in range(len(args) // args_per_cmd):
|
|
80
|
+
if i > 0:
|
|
81
|
+
cmd = _IMPLICIT_REPEAT_CMD.get(cmd, cmd)
|
|
82
|
+
cmds.append((cmd, tuple(args[i * args_per_cmd : (i + 1) * args_per_cmd])))
|
|
83
|
+
return cmds
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_svg_path(
|
|
87
|
+
svg_path: str, exploded: bool = False
|
|
88
|
+
) -> Generator[Tuple[str, Tuple[float, ...]], None, None]:
|
|
89
|
+
"""Parses an svg path.
|
|
90
|
+
|
|
91
|
+
Exploded means when params repeat each the command is reported as
|
|
92
|
+
if multiplied. For example "M1,1 2,2 3,3" would report as three
|
|
93
|
+
separate steps when exploded.
|
|
94
|
+
|
|
95
|
+
Yields tuples of (cmd, (args))."""
|
|
96
|
+
command_tuples = []
|
|
97
|
+
parts = _CMD_RE.split(svg_path)[1:]
|
|
98
|
+
for i in range(0, len(parts), 2):
|
|
99
|
+
cmd = parts[i]
|
|
100
|
+
raw_args = parts[i + 1].strip()
|
|
101
|
+
|
|
102
|
+
args = tuple(_parse_args(cmd, raw_args))
|
|
103
|
+
|
|
104
|
+
args_per_cmd = svg_meta.check_cmd(cmd, args)
|
|
105
|
+
if args_per_cmd == 0 or not exploded:
|
|
106
|
+
command_tuples.append((cmd, args))
|
|
107
|
+
else:
|
|
108
|
+
command_tuples.extend(_explode_cmd(args_per_cmd, cmd, args))
|
|
109
|
+
for t in command_tuples:
|
|
110
|
+
yield t
|
picosvgx/svg_pathops.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
"""SVGPath <=> skia-pathops constructs to enable ops on paths."""
|
|
16
|
+
import functools
|
|
17
|
+
import pathops # pytype: disable=import-error
|
|
18
|
+
from typing import Sequence, Tuple
|
|
19
|
+
from picosvgx.svg_meta import SVGCommand, SVGCommandGen, SVGCommandSeq
|
|
20
|
+
from picosvgx.svg_transform import Affine2D
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Absolutes coords assumed
|
|
24
|
+
# A should never occur because we convert arcs to cubics
|
|
25
|
+
# S,T should never occur because we eliminate shorthand
|
|
26
|
+
_SVG_CMD_TO_SKIA_FN = {
|
|
27
|
+
"M": pathops.Path.moveTo,
|
|
28
|
+
"L": pathops.Path.lineTo,
|
|
29
|
+
"Q": pathops.Path.quadTo,
|
|
30
|
+
"Z": pathops.Path.close,
|
|
31
|
+
"C": pathops.Path.cubicTo,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_SVG_TO_SKIA_LINE_CAP = {
|
|
35
|
+
"butt": pathops.LineCap.BUTT_CAP,
|
|
36
|
+
"round": pathops.LineCap.ROUND_CAP,
|
|
37
|
+
"square": pathops.LineCap.SQUARE_CAP,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_SVG_TO_SKIA_LINE_JOIN = {
|
|
41
|
+
"miter": pathops.LineJoin.MITER_JOIN,
|
|
42
|
+
"round": pathops.LineJoin.ROUND_JOIN,
|
|
43
|
+
"bevel": pathops.LineJoin.BEVEL_JOIN,
|
|
44
|
+
# No arcs or miter-clip
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _skia_pts_to_svg(svg_cmd, points) -> SVGCommandGen:
|
|
49
|
+
# pathops.Path gives us sequences of points, flatten 'em
|
|
50
|
+
yield (svg_cmd, tuple(c for pt in points for c in pt))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_SKIA_CMD_TO_SVG_CMD = {
|
|
54
|
+
pathops.PathVerb.MOVE: functools.partial(_skia_pts_to_svg, "M"),
|
|
55
|
+
pathops.PathVerb.LINE: functools.partial(_skia_pts_to_svg, "L"),
|
|
56
|
+
pathops.PathVerb.QUAD: functools.partial(_skia_pts_to_svg, "Q"),
|
|
57
|
+
pathops.PathVerb.CUBIC: functools.partial(_skia_pts_to_svg, "C"),
|
|
58
|
+
pathops.PathVerb.CLOSE: functools.partial(_skia_pts_to_svg, "Z"),
|
|
59
|
+
# pathops.PathVerb.CONIC... convertConicsToQuads should have taken care of these
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_SVG_FILL_RULE_TO_SKIA_FILL_TYPE = {
|
|
63
|
+
"nonzero": pathops.FillType.WINDING,
|
|
64
|
+
"evenodd": pathops.FillType.EVEN_ODD,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def skia_path(svg_cmds: SVGCommandSeq, fill_rule: str) -> pathops.Path:
|
|
69
|
+
try:
|
|
70
|
+
fill_type = _SVG_FILL_RULE_TO_SKIA_FILL_TYPE[fill_rule]
|
|
71
|
+
except KeyError:
|
|
72
|
+
raise ValueError(f"Invalid fill rule: {fill_rule!r}")
|
|
73
|
+
sk_path = pathops.Path(fillType=fill_type)
|
|
74
|
+
for cmd, args in svg_cmds:
|
|
75
|
+
if cmd not in _SVG_CMD_TO_SKIA_FN:
|
|
76
|
+
raise ValueError(f'No mapping to Skia for "{cmd} {args}"')
|
|
77
|
+
_SVG_CMD_TO_SKIA_FN[cmd](sk_path, *args)
|
|
78
|
+
return sk_path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def svg_commands(skia_path: pathops.Path) -> SVGCommandGen:
|
|
82
|
+
for verb, points in skia_path:
|
|
83
|
+
if verb not in _SKIA_CMD_TO_SVG_CMD:
|
|
84
|
+
raise ValueError(f'No mapping to svg for "{verb} {points}"')
|
|
85
|
+
for svg_cmd, svg_args in _SKIA_CMD_TO_SVG_CMD[verb](points):
|
|
86
|
+
yield (svg_cmd, svg_args)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _do_pathop(
|
|
90
|
+
op: str, svg_cmd_seqs: Sequence[SVGCommandSeq], fill_rules: Sequence[str]
|
|
91
|
+
) -> SVGCommandGen:
|
|
92
|
+
if not svg_cmd_seqs:
|
|
93
|
+
return # pytype: disable=bad-return-type
|
|
94
|
+
assert len(svg_cmd_seqs) == len(fill_rules)
|
|
95
|
+
sk_path = skia_path(svg_cmd_seqs[0], fill_rules[0])
|
|
96
|
+
for svg_cmds, fill_rule in zip(svg_cmd_seqs[1:], fill_rules[1:]):
|
|
97
|
+
sk_path2 = skia_path(svg_cmds, fill_rule)
|
|
98
|
+
sk_path = pathops.op(sk_path, sk_path2, op, fix_winding=True)
|
|
99
|
+
else:
|
|
100
|
+
sk_path.simplify(fix_winding=True)
|
|
101
|
+
return svg_commands(sk_path)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def union(
|
|
105
|
+
svg_cmd_seqs: Sequence[SVGCommandSeq], fill_rules: Sequence[str]
|
|
106
|
+
) -> SVGCommandGen:
|
|
107
|
+
return _do_pathop(pathops.PathOp.UNION, svg_cmd_seqs, fill_rules)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def intersection(
|
|
111
|
+
svg_cmd_seqs: Sequence[SVGCommandSeq], fill_rules: Sequence[str]
|
|
112
|
+
) -> SVGCommandGen:
|
|
113
|
+
return _do_pathop(pathops.PathOp.INTERSECTION, svg_cmd_seqs, fill_rules)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def difference(
|
|
117
|
+
svg_cmd_seqs: Sequence[SVGCommandSeq], fill_rules: Sequence[str]
|
|
118
|
+
) -> SVGCommandGen:
|
|
119
|
+
return _do_pathop(pathops.PathOp.DIFFERENCE, svg_cmd_seqs, fill_rules)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def remove_overlaps(svg_cmds: SVGCommandSeq, fill_rule: str) -> SVGCommandGen:
|
|
123
|
+
"""Create a simplified path filled using the "nonzero" winding rule.
|
|
124
|
+
|
|
125
|
+
This uses skia-pathops simplify method to create a new path containing
|
|
126
|
+
non-overlapping contours that describe the same area as the original path.
|
|
127
|
+
The fill_rule ("evenodd" or "nonzero") of the original path determines
|
|
128
|
+
what is inside or outside the path.
|
|
129
|
+
After simplification, the winding order of the sub-paths is such that the
|
|
130
|
+
path looks the same no matter what fill rule is used to render it.
|
|
131
|
+
|
|
132
|
+
References:
|
|
133
|
+
https://oreillymedia.github.io/Using_SVG/extras/ch06-fill-rule.html
|
|
134
|
+
"""
|
|
135
|
+
sk_path = skia_path(svg_cmds, fill_rule=fill_rule)
|
|
136
|
+
sk_path.simplify(fix_winding=True)
|
|
137
|
+
assert sk_path.fillType == pathops.FillType.WINDING
|
|
138
|
+
return svg_commands(sk_path)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def transform(svg_cmds: SVGCommandSeq, affine: Affine2D) -> SVGCommandGen:
|
|
142
|
+
sk_path = skia_path(svg_cmds, fill_rule="nonzero").transform(*affine)
|
|
143
|
+
return svg_commands(sk_path)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def stroke(
|
|
147
|
+
svg_cmds: SVGCommandSeq,
|
|
148
|
+
svg_linecap: str,
|
|
149
|
+
svg_linejoin: str,
|
|
150
|
+
stroke_width: float,
|
|
151
|
+
stroke_miterlimit: float,
|
|
152
|
+
tolerance: float,
|
|
153
|
+
dash_array: Sequence[float] = (),
|
|
154
|
+
dash_offset: float = 0.0,
|
|
155
|
+
) -> SVGCommandGen:
|
|
156
|
+
"""Create a path that is a shape with its stroke applied.
|
|
157
|
+
|
|
158
|
+
The result may contain self-intersecting paths, thus it should be filled
|
|
159
|
+
using "nonzero" winding rule (otherwise with "evenodd" one may see gaps
|
|
160
|
+
where the sub-paths overlap).
|
|
161
|
+
"""
|
|
162
|
+
cap = _SVG_TO_SKIA_LINE_CAP.get(svg_linecap, None)
|
|
163
|
+
if cap is None:
|
|
164
|
+
raise ValueError(f"Unsupported cap {svg_linecap}")
|
|
165
|
+
join = _SVG_TO_SKIA_LINE_JOIN.get(svg_linejoin, None)
|
|
166
|
+
if join is None:
|
|
167
|
+
raise ValueError(f"Unsupported join {svg_linejoin}")
|
|
168
|
+
# the input path's fill_rule doesn't affect the stroked result so for
|
|
169
|
+
# simplicity here we assume 'nonzero'
|
|
170
|
+
sk_path = skia_path(svg_cmds, fill_rule="nonzero")
|
|
171
|
+
sk_path.stroke(stroke_width, cap, join, stroke_miterlimit, dash_array, dash_offset)
|
|
172
|
+
|
|
173
|
+
# nuke any conics that snuck in (e.g. with stroke-linecap="round")
|
|
174
|
+
sk_path.convertConicsToQuads(tolerance)
|
|
175
|
+
|
|
176
|
+
backup = pathops.Path(sk_path)
|
|
177
|
+
try:
|
|
178
|
+
sk_path.simplify(fix_winding=True)
|
|
179
|
+
except pathops.PathOpsError:
|
|
180
|
+
# skip tricky paths that trigger PathOpsError
|
|
181
|
+
# https://github.com/googlefonts/picosvg/issues/192
|
|
182
|
+
sk_path = backup
|
|
183
|
+
return svg_commands(sk_path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def bounding_box(svg_cmds: SVGCommandSeq):
|
|
187
|
+
return skia_path(svg_cmds, fill_rule="nonzero").bounds
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def path_area(svg_cmds: SVGCommandSeq, fill_rule: str) -> float:
|
|
191
|
+
"""Return the path's absolute area."""
|
|
192
|
+
sk_path = skia_path(svg_cmds, fill_rule=fill_rule)
|
|
193
|
+
sk_path.simplify(fix_winding=True)
|
|
194
|
+
return sk_path.area
|