xref: /aosp_15_r20/external/fonttools/Tests/cu2qu/cu2qu_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1# Copyright 2016 Google Inc. All Rights Reserved.
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
15import collections
16import math
17import unittest
18import os
19import json
20
21from fontTools.cu2qu import curve_to_quadratic, curves_to_quadratic
22
23
24DATADIR = os.path.join(os.path.dirname(__file__), "data")
25
26MAX_ERR = 5
27
28
29class CurveToQuadraticTest(unittest.TestCase):
30    @classmethod
31    def setUpClass(cls):
32        """Do the curve conversion ahead of time, and run tests on results."""
33        with open(os.path.join(DATADIR, "curves.json"), "r") as fp:
34            curves = json.load(fp)
35
36        cls.single_splines = [curve_to_quadratic(c, MAX_ERR) for c in curves]
37        cls.single_errors = [
38            cls.curve_spline_dist(c, s) for c, s in zip(curves, cls.single_splines)
39        ]
40
41        curve_groups = [curves[i : i + 3] for i in range(0, 300, 3)]
42        cls.compat_splines = [
43            curves_to_quadratic(c, [MAX_ERR] * 3) for c in curve_groups
44        ]
45        cls.compat_errors = [
46            [cls.curve_spline_dist(c, s) for c, s in zip(curve_group, splines)]
47            for curve_group, splines in zip(curve_groups, cls.compat_splines)
48        ]
49
50        cls.results = []
51
52    @classmethod
53    def tearDownClass(cls):
54        """Print stats from conversion, as determined during tests."""
55
56        for tag, results in cls.results:
57            print(
58                "\n%s\n%s"
59                % (
60                    tag,
61                    "\n".join(
62                        "%s: %s (%d)" % (k, "#" * (v // 10 + 1), v)
63                        for k, v in sorted(results.items())
64                    ),
65                )
66            )
67
68    def test_results_unchanged(self):
69        """Tests that the results of conversion haven't changed since the time
70        of this test's writing. Useful as a quick check whenever one modifies
71        the conversion algorithm.
72        """
73
74        expected = {2: 6, 3: 26, 4: 82, 5: 232, 6: 360, 7: 266, 8: 28}
75
76        results = collections.defaultdict(int)
77        for spline in self.single_splines:
78            n = len(spline) - 2
79            results[n] += 1
80        self.assertEqual(results, expected)
81        self.results.append(("single spline lengths", results))
82
83    def test_results_unchanged_multiple(self):
84        """Test that conversion results are unchanged for multiple curves."""
85
86        expected = {5: 11, 6: 35, 7: 49, 8: 5}
87
88        results = collections.defaultdict(int)
89        for splines in self.compat_splines:
90            n = len(splines[0]) - 2
91            for spline in splines[1:]:
92                self.assertEqual(
93                    len(spline) - 2, n, "Got incompatible conversion results"
94                )
95            results[n] += 1
96        self.assertEqual(results, expected)
97        self.results.append(("compatible spline lengths", results))
98
99    def test_does_not_exceed_tolerance(self):
100        """Test that conversion results do not exceed given error tolerance."""
101
102        results = collections.defaultdict(int)
103        for error in self.single_errors:
104            results[round(error, 1)] += 1
105            self.assertLessEqual(error, MAX_ERR)
106        self.results.append(("single errors", results))
107
108    def test_does_not_exceed_tolerance_multiple(self):
109        """Test that error tolerance isn't exceeded for multiple curves."""
110
111        results = collections.defaultdict(int)
112        for errors in self.compat_errors:
113            for error in errors:
114                results[round(error, 1)] += 1
115                self.assertLessEqual(error, MAX_ERR)
116        self.results.append(("compatible errors", results))
117
118    @classmethod
119    def curve_spline_dist(cls, bezier, spline, total_steps=20):
120        """Max distance between a bezier and quadratic spline at sampled points."""
121
122        error = 0
123        n = len(spline) - 2
124        steps = total_steps // n
125        for i in range(0, n - 1):
126            p1 = spline[0] if i == 0 else p3
127            p2 = spline[i + 1]
128            if i < n - 1:
129                p3 = cls.lerp(spline[i + 1], spline[i + 2], 0.5)
130            else:
131                p3 = spline[n + 2]
132            segment = p1, p2, p3
133            for j in range(steps):
134                error = max(
135                    error,
136                    cls.dist(
137                        cls.cubic_bezier_at(bezier, (j / steps + i) / n),
138                        cls.quadratic_bezier_at(segment, j / steps),
139                    ),
140                )
141        return error
142
143    @classmethod
144    def lerp(cls, p1, p2, t):
145        (x1, y1), (x2, y2) = p1, p2
146        return x1 + (x2 - x1) * t, y1 + (y2 - y1) * t
147
148    @classmethod
149    def dist(cls, p1, p2):
150        (x1, y1), (x2, y2) = p1, p2
151        return math.hypot(x1 - x2, y1 - y2)
152
153    @classmethod
154    def quadratic_bezier_at(cls, b, t):
155        (x1, y1), (x2, y2), (x3, y3) = b
156        _t = 1 - t
157        t2 = t * t
158        _t2 = _t * _t
159        _2_t_t = 2 * t * _t
160        return (_t2 * x1 + _2_t_t * x2 + t2 * x3, _t2 * y1 + _2_t_t * y2 + t2 * y3)
161
162    @classmethod
163    def cubic_bezier_at(cls, b, t):
164        (x1, y1), (x2, y2), (x3, y3), (x4, y4) = b
165        _t = 1 - t
166        t2 = t * t
167        _t2 = _t * _t
168        t3 = t * t2
169        _t3 = _t * _t2
170        _3_t2_t = 3 * t2 * _t
171        _3_t_t2 = 3 * t * _t2
172        return (
173            _t3 * x1 + _3_t_t2 * x2 + _3_t2_t * x3 + t3 * x4,
174            _t3 * y1 + _3_t_t2 * y2 + _3_t2_t * y3 + t3 * y4,
175        )
176
177
178class AllQuadraticFalseTest(unittest.TestCase):
179    def test_cubic(self):
180        cubic = [(0, 0), (0, 1), (2, 1), (2, 0)]
181        result = curve_to_quadratic(cubic, 0.1, all_quadratic=False)
182        assert result == cubic
183
184    def test_quadratic(self):
185        cubic = [(0, 0), (2, 2), (4, 2), (6, 0)]
186        result = curve_to_quadratic(cubic, 0.1, all_quadratic=False)
187        quadratic = [(0, 0), (3, 3), (6, 0)]
188        assert result == quadratic
189
190
191if __name__ == "__main__":
192    unittest.main()
193