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_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
+ )