diff --git a/Lib/fontTools/misc/transform.py b/Lib/fontTools/misc/transform.py index 9ceee0a20..fc83da331 100644 --- a/Lib/fontTools/misc/transform.py +++ b/Lib/fontTools/misc/transform.py @@ -407,6 +407,10 @@ def Scale(x, y=None): return Transform(x, 0, 0, y, 0, 0) +def _sign(v): + return +1 if v >= 0 else -1 + + @dataclass class DecomposedTransform: """The DecomposedTransform class implements a transformation with separate @@ -428,6 +432,12 @@ class DecomposedTransform: # Adapted from an answer on # https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix a, b, c, d, x, y = transform + + sx = _sign(a) + if sx < 0: + a *= sx + b *= sx + delta = a * d - b * c rotation = 0 @@ -437,12 +447,14 @@ class DecomposedTransform: # Apply the QR-like decomposition. if a != 0 or b != 0: r = math.sqrt(a * a + b * b) - rotation = math.acos(a / r) if b > 0 else -math.acos(a / r) + rotation = math.acos(a / r) if b >= 0 else -math.acos(a / r) scaleX, scaleY = (r, delta / r) skewX, skewY = (math.atan((a * c + b * d) / (r * r)), 0) elif c != 0 or d != 0: s = math.sqrt(c * c + d * d) - rotation = math.pi / 2 - (math.acos(-c / s) if d > 0 else -math.acos(c / s)) + rotation = math.pi / 2 - ( + math.acos(-c / s) if d >= 0 else -math.acos(c / s) + ) scaleX, scaleY = (delta / s, s) skewX, skewY = (0, math.atan((a * c + b * d) / (s * s))) else: @@ -453,9 +465,9 @@ class DecomposedTransform: x, y, math.degrees(rotation), - scaleX, + scaleX * sx, scaleY, - math.degrees(skewX), + math.degrees(skewX) * sx, math.degrees(skewY), 0, 0, diff --git a/Tests/misc/transform_test.py b/Tests/misc/transform_test.py index 3c9b57cfd..eaa166784 100644 --- a/Tests/misc/transform_test.py +++ b/Tests/misc/transform_test.py @@ -123,6 +123,19 @@ class TransformTest(object): assert d.translateX == 5 assert d.translateY == 7 + def test_decompose(self): + t = Transform(-1, 0, 0, 1, 0, 0) + d = t.toDecomposed() + assert d.scaleX == -1 + assert d.scaleY == 1 + assert d.rotation == 0 + + t = Transform(1, 0, 0, -1, 0, 0) + d = t.toDecomposed() + assert d.scaleX == 1 + assert d.scaleY == -1 + assert d.rotation == 0 + class DecomposedTransformTest(object): def test_identity(self): @@ -141,3 +154,45 @@ class DecomposedTransformTest(object): def test_toTransform(self): t = DecomposedTransform(scaleX=2, scaleY=3) assert t.toTransform() == (2, 0, 0, 3, 0, 0) + + @pytest.mark.parametrize( + "decomposed", + [ + DecomposedTransform(scaleX=1, scaleY=0), + DecomposedTransform(scaleX=0, scaleY=1), + DecomposedTransform(scaleX=1, scaleY=0, rotation=30), + DecomposedTransform(scaleX=0, scaleY=1, rotation=30), + DecomposedTransform(scaleX=1, scaleY=1), + DecomposedTransform(scaleX=-1, scaleY=1), + DecomposedTransform(scaleX=1, scaleY=-1), + DecomposedTransform(scaleX=-1, scaleY=-1), + DecomposedTransform(rotation=90), + DecomposedTransform(rotation=-90), + DecomposedTransform(skewX=45), + DecomposedTransform(skewY=45), + DecomposedTransform(scaleX=-1, skewX=45), + DecomposedTransform(scaleX=-1, skewY=45), + DecomposedTransform(scaleY=-1, skewX=45), + DecomposedTransform(scaleY=-1, skewY=45), + DecomposedTransform(scaleX=-1, skewX=45, rotation=30), + DecomposedTransform(scaleX=-1, skewY=45, rotation=30), + DecomposedTransform(scaleY=-1, skewX=45, rotation=30), + DecomposedTransform(scaleY=-1, skewY=45, rotation=30), + DecomposedTransform(scaleX=-1, skewX=45, rotation=-30), + DecomposedTransform(scaleX=-1, skewY=45, rotation=-30), + DecomposedTransform(scaleY=-1, skewX=45, rotation=-30), + DecomposedTransform(scaleY=-1, skewY=45, rotation=-30), + DecomposedTransform(scaleX=-2, skewX=45, rotation=30), + DecomposedTransform(scaleX=-2, skewY=45, rotation=30), + DecomposedTransform(scaleY=-2, skewX=45, rotation=30), + DecomposedTransform(scaleY=-2, skewY=45, rotation=30), + DecomposedTransform(scaleX=-2, skewX=45, rotation=-30), + DecomposedTransform(scaleX=-2, skewY=45, rotation=-30), + DecomposedTransform(scaleY=-2, skewX=45, rotation=-30), + DecomposedTransform(scaleY=-2, skewY=45, rotation=-30), + ], + ) + def test_roundtrip(lst, decomposed): + assert decomposed.toTransform().toDecomposed().toTransform() == pytest.approx( + tuple(decomposed.toTransform()) + ), decomposed