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.
@@ -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