picosvgx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
picosvgx/svg_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
@@ -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