xref: /aosp_15_r20/external/fonttools/Lib/fontTools/misc/transform.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""Affine 2D transformation matrix class.
2
3The Transform class implements various transformation matrix operations,
4both on the matrix itself, as well as on 2D coordinates.
5
6Transform instances are effectively immutable: all methods that operate on the
7transformation itself always return a new instance. This has as the
8interesting side effect that Transform instances are hashable, ie. they can be
9used as dictionary keys.
10
11This module exports the following symbols:
12
13Transform
14	this is the main class
15Identity
16	Transform instance set to the identity transformation
17Offset
18	Convenience function that returns a translating transformation
19Scale
20	Convenience function that returns a scaling transformation
21
22The DecomposedTransform class implements a transformation with separate
23translate, rotation, scale, skew, and transformation-center components.
24
25:Example:
26
27	>>> t = Transform(2, 0, 0, 3, 0, 0)
28	>>> t.transformPoint((100, 100))
29	(200, 300)
30	>>> t = Scale(2, 3)
31	>>> t.transformPoint((100, 100))
32	(200, 300)
33	>>> t.transformPoint((0, 0))
34	(0, 0)
35	>>> t = Offset(2, 3)
36	>>> t.transformPoint((100, 100))
37	(102, 103)
38	>>> t.transformPoint((0, 0))
39	(2, 3)
40	>>> t2 = t.scale(0.5)
41	>>> t2.transformPoint((100, 100))
42	(52.0, 53.0)
43	>>> import math
44	>>> t3 = t2.rotate(math.pi / 2)
45	>>> t3.transformPoint((0, 0))
46	(2.0, 3.0)
47	>>> t3.transformPoint((100, 100))
48	(-48.0, 53.0)
49	>>> t = Identity.scale(0.5).translate(100, 200).skew(0.1, 0.2)
50	>>> t.transformPoints([(0, 0), (1, 1), (100, 100)])
51	[(50.0, 100.0), (50.550167336042726, 100.60135501775433), (105.01673360427253, 160.13550177543362)]
52	>>>
53"""
54
55import math
56from typing import NamedTuple
57from dataclasses import dataclass
58
59
60__all__ = ["Transform", "Identity", "Offset", "Scale", "DecomposedTransform"]
61
62
63_EPSILON = 1e-15
64_ONE_EPSILON = 1 - _EPSILON
65_MINUS_ONE_EPSILON = -1 + _EPSILON
66
67
68def _normSinCos(v):
69    if abs(v) < _EPSILON:
70        v = 0
71    elif v > _ONE_EPSILON:
72        v = 1
73    elif v < _MINUS_ONE_EPSILON:
74        v = -1
75    return v
76
77
78class Transform(NamedTuple):
79    """2x2 transformation matrix plus offset, a.k.a. Affine transform.
80    Transform instances are immutable: all transforming methods, eg.
81    rotate(), return a new Transform instance.
82
83    :Example:
84
85            >>> t = Transform()
86            >>> t
87            <Transform [1 0 0 1 0 0]>
88            >>> t.scale(2)
89            <Transform [2 0 0 2 0 0]>
90            >>> t.scale(2.5, 5.5)
91            <Transform [2.5 0 0 5.5 0 0]>
92            >>>
93            >>> t.scale(2, 3).transformPoint((100, 100))
94            (200, 300)
95
96    Transform's constructor takes six arguments, all of which are
97    optional, and can be used as keyword arguments::
98
99            >>> Transform(12)
100            <Transform [12 0 0 1 0 0]>
101            >>> Transform(dx=12)
102            <Transform [1 0 0 1 12 0]>
103            >>> Transform(yx=12)
104            <Transform [1 0 12 1 0 0]>
105
106    Transform instances also behave like sequences of length 6::
107
108            >>> len(Identity)
109            6
110            >>> list(Identity)
111            [1, 0, 0, 1, 0, 0]
112            >>> tuple(Identity)
113            (1, 0, 0, 1, 0, 0)
114
115    Transform instances are comparable::
116
117            >>> t1 = Identity.scale(2, 3).translate(4, 6)
118            >>> t2 = Identity.translate(8, 18).scale(2, 3)
119            >>> t1 == t2
120            1
121
122    But beware of floating point rounding errors::
123
124            >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6)
125            >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3)
126            >>> t1
127            <Transform [0.2 0 0 0.3 0.08 0.18]>
128            >>> t2
129            <Transform [0.2 0 0 0.3 0.08 0.18]>
130            >>> t1 == t2
131            0
132
133    Transform instances are hashable, meaning you can use them as
134    keys in dictionaries::
135
136            >>> d = {Scale(12, 13): None}
137            >>> d
138            {<Transform [12 0 0 13 0 0]>: None}
139
140    But again, beware of floating point rounding errors::
141
142            >>> t1 = Identity.scale(0.2, 0.3).translate(0.4, 0.6)
143            >>> t2 = Identity.translate(0.08, 0.18).scale(0.2, 0.3)
144            >>> t1
145            <Transform [0.2 0 0 0.3 0.08 0.18]>
146            >>> t2
147            <Transform [0.2 0 0 0.3 0.08 0.18]>
148            >>> d = {t1: None}
149            >>> d
150            {<Transform [0.2 0 0 0.3 0.08 0.18]>: None}
151            >>> d[t2]
152            Traceback (most recent call last):
153              File "<stdin>", line 1, in ?
154            KeyError: <Transform [0.2 0 0 0.3 0.08 0.18]>
155    """
156
157    xx: float = 1
158    xy: float = 0
159    yx: float = 0
160    yy: float = 1
161    dx: float = 0
162    dy: float = 0
163
164    def transformPoint(self, p):
165        """Transform a point.
166
167        :Example:
168
169                >>> t = Transform()
170                >>> t = t.scale(2.5, 5.5)
171                >>> t.transformPoint((100, 100))
172                (250.0, 550.0)
173        """
174        (x, y) = p
175        xx, xy, yx, yy, dx, dy = self
176        return (xx * x + yx * y + dx, xy * x + yy * y + dy)
177
178    def transformPoints(self, points):
179        """Transform a list of points.
180
181        :Example:
182
183                >>> t = Scale(2, 3)
184                >>> t.transformPoints([(0, 0), (0, 100), (100, 100), (100, 0)])
185                [(0, 0), (0, 300), (200, 300), (200, 0)]
186                >>>
187        """
188        xx, xy, yx, yy, dx, dy = self
189        return [(xx * x + yx * y + dx, xy * x + yy * y + dy) for x, y in points]
190
191    def transformVector(self, v):
192        """Transform an (dx, dy) vector, treating translation as zero.
193
194        :Example:
195
196                >>> t = Transform(2, 0, 0, 2, 10, 20)
197                >>> t.transformVector((3, -4))
198                (6, -8)
199                >>>
200        """
201        (dx, dy) = v
202        xx, xy, yx, yy = self[:4]
203        return (xx * dx + yx * dy, xy * dx + yy * dy)
204
205    def transformVectors(self, vectors):
206        """Transform a list of (dx, dy) vector, treating translation as zero.
207
208        :Example:
209                >>> t = Transform(2, 0, 0, 2, 10, 20)
210                >>> t.transformVectors([(3, -4), (5, -6)])
211                [(6, -8), (10, -12)]
212                >>>
213        """
214        xx, xy, yx, yy = self[:4]
215        return [(xx * dx + yx * dy, xy * dx + yy * dy) for dx, dy in vectors]
216
217    def translate(self, x=0, y=0):
218        """Return a new transformation, translated (offset) by x, y.
219
220        :Example:
221                >>> t = Transform()
222                >>> t.translate(20, 30)
223                <Transform [1 0 0 1 20 30]>
224                >>>
225        """
226        return self.transform((1, 0, 0, 1, x, y))
227
228    def scale(self, x=1, y=None):
229        """Return a new transformation, scaled by x, y. The 'y' argument
230        may be None, which implies to use the x value for y as well.
231
232        :Example:
233                >>> t = Transform()
234                >>> t.scale(5)
235                <Transform [5 0 0 5 0 0]>
236                >>> t.scale(5, 6)
237                <Transform [5 0 0 6 0 0]>
238                >>>
239        """
240        if y is None:
241            y = x
242        return self.transform((x, 0, 0, y, 0, 0))
243
244    def rotate(self, angle):
245        """Return a new transformation, rotated by 'angle' (radians).
246
247        :Example:
248                >>> import math
249                >>> t = Transform()
250                >>> t.rotate(math.pi / 2)
251                <Transform [0 1 -1 0 0 0]>
252                >>>
253        """
254        import math
255
256        c = _normSinCos(math.cos(angle))
257        s = _normSinCos(math.sin(angle))
258        return self.transform((c, s, -s, c, 0, 0))
259
260    def skew(self, x=0, y=0):
261        """Return a new transformation, skewed by x and y.
262
263        :Example:
264                >>> import math
265                >>> t = Transform()
266                >>> t.skew(math.pi / 4)
267                <Transform [1 0 1 1 0 0]>
268                >>>
269        """
270        import math
271
272        return self.transform((1, math.tan(y), math.tan(x), 1, 0, 0))
273
274    def transform(self, other):
275        """Return a new transformation, transformed by another
276        transformation.
277
278        :Example:
279                >>> t = Transform(2, 0, 0, 3, 1, 6)
280                >>> t.transform((4, 3, 2, 1, 5, 6))
281                <Transform [8 9 4 3 11 24]>
282                >>>
283        """
284        xx1, xy1, yx1, yy1, dx1, dy1 = other
285        xx2, xy2, yx2, yy2, dx2, dy2 = self
286        return self.__class__(
287            xx1 * xx2 + xy1 * yx2,
288            xx1 * xy2 + xy1 * yy2,
289            yx1 * xx2 + yy1 * yx2,
290            yx1 * xy2 + yy1 * yy2,
291            xx2 * dx1 + yx2 * dy1 + dx2,
292            xy2 * dx1 + yy2 * dy1 + dy2,
293        )
294
295    def reverseTransform(self, other):
296        """Return a new transformation, which is the other transformation
297        transformed by self. self.reverseTransform(other) is equivalent to
298        other.transform(self).
299
300        :Example:
301                >>> t = Transform(2, 0, 0, 3, 1, 6)
302                >>> t.reverseTransform((4, 3, 2, 1, 5, 6))
303                <Transform [8 6 6 3 21 15]>
304                >>> Transform(4, 3, 2, 1, 5, 6).transform((2, 0, 0, 3, 1, 6))
305                <Transform [8 6 6 3 21 15]>
306                >>>
307        """
308        xx1, xy1, yx1, yy1, dx1, dy1 = self
309        xx2, xy2, yx2, yy2, dx2, dy2 = other
310        return self.__class__(
311            xx1 * xx2 + xy1 * yx2,
312            xx1 * xy2 + xy1 * yy2,
313            yx1 * xx2 + yy1 * yx2,
314            yx1 * xy2 + yy1 * yy2,
315            xx2 * dx1 + yx2 * dy1 + dx2,
316            xy2 * dx1 + yy2 * dy1 + dy2,
317        )
318
319    def inverse(self):
320        """Return the inverse transformation.
321
322        :Example:
323                >>> t = Identity.translate(2, 3).scale(4, 5)
324                >>> t.transformPoint((10, 20))
325                (42, 103)
326                >>> it = t.inverse()
327                >>> it.transformPoint((42, 103))
328                (10.0, 20.0)
329                >>>
330        """
331        if self == Identity:
332            return self
333        xx, xy, yx, yy, dx, dy = self
334        det = xx * yy - yx * xy
335        xx, xy, yx, yy = yy / det, -xy / det, -yx / det, xx / det
336        dx, dy = -xx * dx - yx * dy, -xy * dx - yy * dy
337        return self.__class__(xx, xy, yx, yy, dx, dy)
338
339    def toPS(self):
340        """Return a PostScript representation
341
342        :Example:
343
344                >>> t = Identity.scale(2, 3).translate(4, 5)
345                >>> t.toPS()
346                '[2 0 0 3 8 15]'
347                >>>
348        """
349        return "[%s %s %s %s %s %s]" % self
350
351    def toDecomposed(self) -> "DecomposedTransform":
352        """Decompose into a DecomposedTransform."""
353        return DecomposedTransform.fromTransform(self)
354
355    def __bool__(self):
356        """Returns True if transform is not identity, False otherwise.
357
358        :Example:
359
360                >>> bool(Identity)
361                False
362                >>> bool(Transform())
363                False
364                >>> bool(Scale(1.))
365                False
366                >>> bool(Scale(2))
367                True
368                >>> bool(Offset())
369                False
370                >>> bool(Offset(0))
371                False
372                >>> bool(Offset(2))
373                True
374        """
375        return self != Identity
376
377    def __repr__(self):
378        return "<%s [%g %g %g %g %g %g]>" % ((self.__class__.__name__,) + self)
379
380
381Identity = Transform()
382
383
384def Offset(x=0, y=0):
385    """Return the identity transformation offset by x, y.
386
387    :Example:
388            >>> Offset(2, 3)
389            <Transform [1 0 0 1 2 3]>
390            >>>
391    """
392    return Transform(1, 0, 0, 1, x, y)
393
394
395def Scale(x, y=None):
396    """Return the identity transformation scaled by x, y. The 'y' argument
397    may be None, which implies to use the x value for y as well.
398
399    :Example:
400            >>> Scale(2, 3)
401            <Transform [2 0 0 3 0 0]>
402            >>>
403    """
404    if y is None:
405        y = x
406    return Transform(x, 0, 0, y, 0, 0)
407
408
409@dataclass
410class DecomposedTransform:
411    """The DecomposedTransform class implements a transformation with separate
412    translate, rotation, scale, skew, and transformation-center components.
413    """
414
415    translateX: float = 0
416    translateY: float = 0
417    rotation: float = 0  # in degrees, counter-clockwise
418    scaleX: float = 1
419    scaleY: float = 1
420    skewX: float = 0  # in degrees, clockwise
421    skewY: float = 0  # in degrees, counter-clockwise
422    tCenterX: float = 0
423    tCenterY: float = 0
424
425    @classmethod
426    def fromTransform(self, transform):
427        # Adapted from an answer on
428        # https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix
429        a, b, c, d, x, y = transform
430
431        sx = math.copysign(1, a)
432        if sx < 0:
433            a *= sx
434            b *= sx
435
436        delta = a * d - b * c
437
438        rotation = 0
439        scaleX = scaleY = 0
440        skewX = skewY = 0
441
442        # Apply the QR-like decomposition.
443        if a != 0 or b != 0:
444            r = math.sqrt(a * a + b * b)
445            rotation = math.acos(a / r) if b >= 0 else -math.acos(a / r)
446            scaleX, scaleY = (r, delta / r)
447            skewX, skewY = (math.atan((a * c + b * d) / (r * r)), 0)
448        elif c != 0 or d != 0:
449            s = math.sqrt(c * c + d * d)
450            rotation = math.pi / 2 - (
451                math.acos(-c / s) if d >= 0 else -math.acos(c / s)
452            )
453            scaleX, scaleY = (delta / s, s)
454            skewX, skewY = (0, math.atan((a * c + b * d) / (s * s)))
455        else:
456            # a = b = c = d = 0
457            pass
458
459        return DecomposedTransform(
460            x,
461            y,
462            math.degrees(rotation),
463            scaleX * sx,
464            scaleY,
465            math.degrees(skewX) * sx,
466            math.degrees(skewY),
467            0,
468            0,
469        )
470
471    def toTransform(self):
472        """Return the Transform() equivalent of this transformation.
473
474        :Example:
475                >>> DecomposedTransform(scaleX=2, scaleY=2).toTransform()
476                <Transform [2 0 0 2 0 0]>
477                >>>
478        """
479        t = Transform()
480        t = t.translate(
481            self.translateX + self.tCenterX, self.translateY + self.tCenterY
482        )
483        t = t.rotate(math.radians(self.rotation))
484        t = t.scale(self.scaleX, self.scaleY)
485        t = t.skew(math.radians(self.skewX), math.radians(self.skewY))
486        t = t.translate(-self.tCenterX, -self.tCenterY)
487        return t
488
489
490if __name__ == "__main__":
491    import sys
492    import doctest
493
494    sys.exit(doctest.testmod().failed)
495