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
|
@@ -0,0 +1,373 @@
|
|
|
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
|
+
"""Helpers for https://www.w3.org/TR/SVG11/coords.html#TransformAttribute.
|
|
16
|
+
|
|
17
|
+
Focuses on converting to a sequence of affine matrices.
|
|
18
|
+
"""
|
|
19
|
+
import collections
|
|
20
|
+
from functools import reduce
|
|
21
|
+
from math import cos, sin, radians, tan, hypot
|
|
22
|
+
import operator
|
|
23
|
+
import re
|
|
24
|
+
from typing import NamedTuple, Sequence, Tuple
|
|
25
|
+
from sys import float_info
|
|
26
|
+
from picosvgx.geometric_types import (
|
|
27
|
+
Point,
|
|
28
|
+
Rect,
|
|
29
|
+
Vector,
|
|
30
|
+
DEFAULT_ALMOST_EQUAL_TOLERANCE,
|
|
31
|
+
almost_equal,
|
|
32
|
+
)
|
|
33
|
+
from picosvgx.svg_meta import ntos
|
|
34
|
+
|
|
35
|
+
DECOMPOSITION_ALMOST_EQUAL_TOLERANCE = 1e-4
|
|
36
|
+
|
|
37
|
+
_SVG_ARG_FIXUPS = collections.defaultdict(
|
|
38
|
+
lambda: lambda _: None,
|
|
39
|
+
{
|
|
40
|
+
"rotate": lambda args: _fix_rotate(args),
|
|
41
|
+
"skewx": lambda args: _fix_rotate(args),
|
|
42
|
+
"skewy": lambda args: _fix_rotate(args),
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# 2D affine transform.
|
|
48
|
+
#
|
|
49
|
+
# View as vector of 6 values or matrix:
|
|
50
|
+
#
|
|
51
|
+
# a c e
|
|
52
|
+
# b d f
|
|
53
|
+
class Affine2D(NamedTuple):
|
|
54
|
+
a: float
|
|
55
|
+
b: float
|
|
56
|
+
c: float
|
|
57
|
+
d: float
|
|
58
|
+
e: float
|
|
59
|
+
f: float
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def flip_y():
|
|
63
|
+
return Affine2D._flip_y
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def identity():
|
|
67
|
+
return Affine2D._identity
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def degenerate():
|
|
71
|
+
return Affine2D._degnerate
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def fromstring(raw_transform):
|
|
75
|
+
return parse_svg_transform(raw_transform)
|
|
76
|
+
|
|
77
|
+
def tostring(self):
|
|
78
|
+
if self == Affine2D.identity().translate(*self.gettranslate()):
|
|
79
|
+
return f'translate({", ".join(ntos(v) for v in self.gettranslate())})'
|
|
80
|
+
return f'matrix({" ".join(ntos(v) for v in self)})'
|
|
81
|
+
|
|
82
|
+
def __matmul__(self, other: "Affine2D") -> "Affine2D":
|
|
83
|
+
"""Returns the product of self × other. Order matters.
|
|
84
|
+
|
|
85
|
+
The combined affine matrix can be thought of mapping by other before applying self.
|
|
86
|
+
|
|
87
|
+
https://en.wikipedia.org/wiki/Matrix_multiplication
|
|
88
|
+
|
|
89
|
+
| a₁ c₁ e₁ |
|
|
90
|
+
first = | b₁ d₁ f₁ |
|
|
91
|
+
| 0 0 1 |
|
|
92
|
+
|
|
93
|
+
| a₂ c₂ e₂ |
|
|
94
|
+
second = | b₂ d₂ f₂ |
|
|
95
|
+
| 0 0 1 |
|
|
96
|
+
|
|
97
|
+
| a₁·a₂ + c₁·b₂ + e₁·0 a₁·c₂ + c₁·d₂ + e₁·0 a₁·e₂ + c₁·f₂ + e₁·1 |
|
|
98
|
+
first × second = | b₁·a₂ + d₁·b₂ + f₁·0 b₁·c₂ + d₁·d₂ + f₁·0 b₁·e₂ + d₁·f₂ + f₁·1 |
|
|
99
|
+
| 0·a₂ + 0·b₂ + 1·0 0·c₂ + 0·d₂ + 1·0 0·e₂ + 0·f₂ + 1·1 |
|
|
100
|
+
"""
|
|
101
|
+
if not isinstance(other, Affine2D):
|
|
102
|
+
return NotImplemented
|
|
103
|
+
return Affine2D(
|
|
104
|
+
a=self.a * other.a + self.c * other.b, # + self.e * 0
|
|
105
|
+
b=self.b * other.a + self.d * other.b, # + self.f * 0
|
|
106
|
+
c=self.a * other.c + self.c * other.d, # + self.e * 0
|
|
107
|
+
d=self.b * other.c + self.d * other.d, # + self.f * 0
|
|
108
|
+
e=self.a * other.e + self.c * other.f + self.e, # * 1
|
|
109
|
+
f=self.b * other.e + self.d * other.f + self.f, # * 1
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
__imatmul__ = __matmul__
|
|
113
|
+
|
|
114
|
+
def matrix(self, a, b, c, d, e, f):
|
|
115
|
+
return self @ Affine2D(a, b, c, d, e, f)
|
|
116
|
+
|
|
117
|
+
# https://www.w3.org/TR/SVG11/coords.html#TranslationDefined
|
|
118
|
+
def translate(self, tx, ty=0):
|
|
119
|
+
if (0, 0) == (tx, ty):
|
|
120
|
+
return self
|
|
121
|
+
return self.matrix(1, 0, 0, 1, tx, ty)
|
|
122
|
+
|
|
123
|
+
def gettranslate(self) -> Tuple[float, float]:
|
|
124
|
+
return (self.e, self.f)
|
|
125
|
+
|
|
126
|
+
def getscale(self) -> Tuple[float, float]:
|
|
127
|
+
return (self.a, self.d)
|
|
128
|
+
|
|
129
|
+
# https://www.w3.org/TR/SVG11/coords.html#ScalingDefined
|
|
130
|
+
def scale(self, sx, sy=None):
|
|
131
|
+
if sy is None:
|
|
132
|
+
sy = sx
|
|
133
|
+
return self.matrix(sx, 0, 0, sy, 0, 0)
|
|
134
|
+
|
|
135
|
+
# https://www.w3.org/TR/SVG11/coords.html#RotationDefined
|
|
136
|
+
# Note that rotation here is in radians
|
|
137
|
+
def rotate(self, a, cx=0.0, cy=0.0):
|
|
138
|
+
return (
|
|
139
|
+
self.translate(cx, cy)
|
|
140
|
+
.matrix(cos(a), sin(a), -sin(a), cos(a), 0, 0)
|
|
141
|
+
.translate(-cx, -cy)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Note that andl here is in radians
|
|
145
|
+
def skewx(self, a):
|
|
146
|
+
return self.matrix(1, 0, tan(a), 1, 0, 0)
|
|
147
|
+
|
|
148
|
+
# Note that angle here is in radians
|
|
149
|
+
def skewy(self, a):
|
|
150
|
+
return self.matrix(1, tan(a), 0, 1, 0, 0)
|
|
151
|
+
|
|
152
|
+
# Note that angle here is in radians
|
|
153
|
+
def skew(self, xAngle, yAngle):
|
|
154
|
+
return self.matrix(1, tan(yAngle), tan(xAngle), 1, 0, 0)
|
|
155
|
+
|
|
156
|
+
def determinant(self) -> float:
|
|
157
|
+
return self.a * self.d - self.b * self.c
|
|
158
|
+
|
|
159
|
+
def is_degenerate(self) -> bool:
|
|
160
|
+
"""Return True if [a b c d] matrix is degenerate (determinant is 0)."""
|
|
161
|
+
return abs(self.determinant()) <= float_info.epsilon
|
|
162
|
+
|
|
163
|
+
def inverse(self):
|
|
164
|
+
"""Return the inverse Affine2D transformation.
|
|
165
|
+
|
|
166
|
+
The inverse of a degenerate Affine2D is itself degenerate."""
|
|
167
|
+
if self == self.identity():
|
|
168
|
+
return self
|
|
169
|
+
elif self.is_degenerate():
|
|
170
|
+
return Affine2D.degenerate()
|
|
171
|
+
a, b, c, d, e, f = self
|
|
172
|
+
det = self.determinant()
|
|
173
|
+
a, b, c, d = d / det, -b / det, -c / det, a / det
|
|
174
|
+
e, f = -a * e - c * f, -b * e - d * f
|
|
175
|
+
return self.__class__(a, b, c, d, e, f)
|
|
176
|
+
|
|
177
|
+
def map_point(self, pt: Tuple[float, float]) -> Point:
|
|
178
|
+
"""Return Point (x, y) multiplied by Affine2D."""
|
|
179
|
+
x, y = pt
|
|
180
|
+
return Point(self.a * x + self.c * y + self.e, self.b * x + self.d * y + self.f)
|
|
181
|
+
|
|
182
|
+
def map_vector(self, vec: Tuple[float, float]) -> Vector:
|
|
183
|
+
"""Return Vector (x, y) multiplied by Affine2D, treating translation as zero."""
|
|
184
|
+
x, y = vec
|
|
185
|
+
return Vector(self.a * x + self.c * y, self.b * x + self.d * y)
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def compose_ltr(cls, affines: Sequence["Affine2D"]) -> "Affine2D":
|
|
189
|
+
"""Creates merged transform equivalent to applying transforms left-to-right order.
|
|
190
|
+
|
|
191
|
+
Affines apply like functions - f(g(x)) - so we merge them in reverse order.
|
|
192
|
+
"""
|
|
193
|
+
return reduce(operator.matmul, reversed(affines), cls.identity())
|
|
194
|
+
|
|
195
|
+
def round(self, digits: int) -> "Affine2D":
|
|
196
|
+
return Affine2D(*(round(v, digits) for v in self))
|
|
197
|
+
|
|
198
|
+
# all lowercase so we ignore case
|
|
199
|
+
_ALIGN_VALUES = frozenset(
|
|
200
|
+
(
|
|
201
|
+
"none",
|
|
202
|
+
"xminymin",
|
|
203
|
+
"xminymid",
|
|
204
|
+
"xminymax",
|
|
205
|
+
"xmidymin",
|
|
206
|
+
"xmidymid",
|
|
207
|
+
"xmidymax",
|
|
208
|
+
"xmaxymin",
|
|
209
|
+
"xmaxymid",
|
|
210
|
+
"xmaxymax",
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
_MEET_OR_SLICE = frozenset(("meet", "slice"))
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def rect_to_rect(
|
|
217
|
+
cls,
|
|
218
|
+
src: Rect,
|
|
219
|
+
dst: Rect,
|
|
220
|
+
preserveAspectRatio: str = "none",
|
|
221
|
+
) -> "Affine2D":
|
|
222
|
+
"""Return Affine2D set to scale and translate src Rect to dst Rect.
|
|
223
|
+
By default the mapping completely fills dst, it does not preserve aspect ratio,
|
|
224
|
+
unless the 'preserveAspectRatio' argument is used.
|
|
225
|
+
See https://www.w3.org/TR/SVG/coords.html#PreserveAspectRatioAttribute for
|
|
226
|
+
the list of values supported.
|
|
227
|
+
"""
|
|
228
|
+
if src.empty():
|
|
229
|
+
return cls.identity()
|
|
230
|
+
if dst.empty():
|
|
231
|
+
return cls(0, 0, 0, 0, 0, 0)
|
|
232
|
+
|
|
233
|
+
# We follow the same process described in the SVG spec for computing the
|
|
234
|
+
# equivalent scale + translation which maps from viewBox (src) to viewport (dst)
|
|
235
|
+
# coordinates given the value of preserveAspectRatio.
|
|
236
|
+
# https://www.w3.org/TR/SVG/coords.html#ComputingAViewportsTransform
|
|
237
|
+
sx = dst.w / src.w
|
|
238
|
+
sy = dst.h / src.h
|
|
239
|
+
|
|
240
|
+
align, _, meetOrSlice = preserveAspectRatio.lower().strip().partition(" ")
|
|
241
|
+
if (
|
|
242
|
+
align not in cls._ALIGN_VALUES
|
|
243
|
+
or meetOrSlice
|
|
244
|
+
and meetOrSlice not in cls._MEET_OR_SLICE
|
|
245
|
+
):
|
|
246
|
+
raise ValueError(f"Invalid preserveAspectRatio: {preserveAspectRatio!r}")
|
|
247
|
+
|
|
248
|
+
if align != "none":
|
|
249
|
+
sx = sy = max(sx, sy) if "slice" in meetOrSlice else min(sx, sy)
|
|
250
|
+
|
|
251
|
+
tx = dst.x - src.x * sx
|
|
252
|
+
ty = dst.y - src.y * sy
|
|
253
|
+
|
|
254
|
+
if "xmid" in align:
|
|
255
|
+
tx += (dst.w - src.w * sx) / 2
|
|
256
|
+
elif "xmax" in align:
|
|
257
|
+
tx += dst.w - src.w * sx
|
|
258
|
+
if "ymid" in align:
|
|
259
|
+
ty += (dst.h - src.h * sy) / 2
|
|
260
|
+
elif "ymax" in align:
|
|
261
|
+
ty += dst.h - src.h * sy
|
|
262
|
+
|
|
263
|
+
return cls(sx, 0, 0, sy, tx, ty)
|
|
264
|
+
|
|
265
|
+
def almost_equals(
|
|
266
|
+
self, other: "Affine2D", tolerance=DEFAULT_ALMOST_EQUAL_TOLERANCE
|
|
267
|
+
):
|
|
268
|
+
return all(almost_equal(v1, v2, tolerance) for v1, v2 in zip(self, other))
|
|
269
|
+
|
|
270
|
+
def decompose_scale(self) -> Tuple["Affine2D", "Affine2D"]:
|
|
271
|
+
"""Split affine into a scale component and whatever remains.
|
|
272
|
+
|
|
273
|
+
Return the affine components in LTR order, such that mapping a point
|
|
274
|
+
consecutively by each gives the same result as mapping the same by the
|
|
275
|
+
original combined affine.
|
|
276
|
+
|
|
277
|
+
For reference, see SkMatrix::decomposeScale
|
|
278
|
+
https://github.com/google/skia/blob/e0707b7/src/core/SkMatrix.cpp#L1577-L1597
|
|
279
|
+
"""
|
|
280
|
+
sx = hypot(self.a, self.b)
|
|
281
|
+
sy = hypot(self.c, self.d)
|
|
282
|
+
scale = Affine2D(sx, 0, 0, sy, 0, 0)
|
|
283
|
+
remaining = Affine2D.compose_ltr((scale.inverse(), self))
|
|
284
|
+
test_compose = Affine2D.compose_ltr((scale, remaining))
|
|
285
|
+
assert self.almost_equals(
|
|
286
|
+
test_compose, DECOMPOSITION_ALMOST_EQUAL_TOLERANCE
|
|
287
|
+
), f"Failed to extract scale from {self}, parts compose back to {test_compose}"
|
|
288
|
+
return scale, remaining
|
|
289
|
+
|
|
290
|
+
def decompose_translation(self) -> Tuple["Affine2D", "Affine2D"]:
|
|
291
|
+
"""Split affine into a translation and a 2x2 component.
|
|
292
|
+
|
|
293
|
+
Return the affine components in LTR order, such that mapping a point
|
|
294
|
+
consecutively by each gives the same result as mapping the same by the
|
|
295
|
+
original combined affine.
|
|
296
|
+
"""
|
|
297
|
+
affine_prime = self._replace(e=0, f=0)
|
|
298
|
+
# no translate? nop!
|
|
299
|
+
if self.almost_equals(affine_prime):
|
|
300
|
+
return Affine2D.identity(), affine_prime
|
|
301
|
+
|
|
302
|
+
a, b, c, d, e, f = self
|
|
303
|
+
|
|
304
|
+
# Handle degenerate matrix (all zeros in 2x2 portion)
|
|
305
|
+
if almost_equal(a, 0) and almost_equal(b, 0) and almost_equal(c, 0) and almost_equal(d, 0):
|
|
306
|
+
# Degenerate matrix collapses all points; translation is the only meaningful part
|
|
307
|
+
return Affine2D.identity().translate(e, f), affine_prime
|
|
308
|
+
# We need x`, y` such that matrix a b c d 0 0 yields same
|
|
309
|
+
# result as x, y with a b c d e f
|
|
310
|
+
# That is:
|
|
311
|
+
# 1) ax` + cy` + 0 = ax + cy + e
|
|
312
|
+
# 2) bx` + dy` + 0 = bx + dy + f
|
|
313
|
+
# ^ rhs is a known scalar; we'll call r1, r2
|
|
314
|
+
# multiply 1) by b/a so when subtracted from 2) we eliminate x`
|
|
315
|
+
# 1) bx` + (b/a)cy` = (b/a) * r1
|
|
316
|
+
# 2) - 1) bx` - bx` + dy` - (b/a)cy` = r2 - (b/a) * r1
|
|
317
|
+
# y` = (r2 - (b/a) * r1) / (d - (b/a)c)
|
|
318
|
+
|
|
319
|
+
# for the special case of origin (0,0) the math below could be simplified
|
|
320
|
+
# futher but I keep the expanded version for clarity sake
|
|
321
|
+
x, y = (0, 0)
|
|
322
|
+
r1, r2 = self.map_point((x, y))
|
|
323
|
+
if not almost_equal(a, 0):
|
|
324
|
+
y_prime = (r2 - r1 * b / a) / (d - b * c / a)
|
|
325
|
+
|
|
326
|
+
# Sub y` into 1)
|
|
327
|
+
# 1) x` = (r1 - cy`) / a
|
|
328
|
+
x_prime = (r1 - c * y_prime) / a
|
|
329
|
+
else:
|
|
330
|
+
# if a == 0 then above gives div / 0. Take a simpler path.
|
|
331
|
+
# 1) 0x` + cy` + 0 = 0x + cy + e
|
|
332
|
+
# y` = y + e/c
|
|
333
|
+
y_prime = y + e / c
|
|
334
|
+
# Sub y` into 2)
|
|
335
|
+
# 2) bx` + dy` + 0 = bx + dy + f
|
|
336
|
+
# x` = x + dy/b + f/b - dy`/b
|
|
337
|
+
x_prime = x + (d * y / b) + (f / b) - (d * y_prime / b)
|
|
338
|
+
|
|
339
|
+
# basically this says "by how much do I need to pre-translate things so
|
|
340
|
+
# that when I subsequently apply the 2x2 portion of the original affine
|
|
341
|
+
# (with the translation zeroed) it'll land me in the same place?"
|
|
342
|
+
translation = Affine2D.identity().translate(x_prime, y_prime)
|
|
343
|
+
# sanity check that combining the two affines gives back self
|
|
344
|
+
test_compose = Affine2D.compose_ltr((translation, affine_prime))
|
|
345
|
+
assert self.almost_equals(
|
|
346
|
+
test_compose, DECOMPOSITION_ALMOST_EQUAL_TOLERANCE
|
|
347
|
+
), f"Failed to extract translation from {self}, parts compose back to {test_compose}"
|
|
348
|
+
return translation, affine_prime
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
Affine2D._identity = Affine2D(1, 0, 0, 1, 0, 0)
|
|
352
|
+
Affine2D._degnerate = Affine2D(0, 0, 0, 0, 0, 0)
|
|
353
|
+
Affine2D._flip_y = Affine2D(1, 0, 0, -1, 0, 0)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _fix_rotate(args):
|
|
357
|
+
args[0] = radians(args[0])
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def parse_svg_transform(raw_transform: str):
|
|
361
|
+
# much simpler to read if we do stages rather than a single regex
|
|
362
|
+
# one day it might be worth writing a real parser
|
|
363
|
+
transform = Affine2D.identity()
|
|
364
|
+
|
|
365
|
+
for match in re.finditer(
|
|
366
|
+
r"(?i)(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)", raw_transform
|
|
367
|
+
):
|
|
368
|
+
op = match.group(1).lower()
|
|
369
|
+
args = [float(p) for p in re.split(r"\s*[,\s]\s*", match.group(2).strip())]
|
|
370
|
+
_SVG_ARG_FIXUPS[op](args)
|
|
371
|
+
transform = getattr(transform, op)(*args)
|
|
372
|
+
|
|
373
|
+
return transform
|