| # Copyright 2016 Google Inc. All Rights Reserved. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| import collections |
| import math |
| import unittest |
| import os |
| import json |
| |
| from fontTools.cu2qu import curve_to_quadratic, curves_to_quadratic |
| |
| |
| DATADIR = os.path.join(os.path.dirname(__file__), "data") |
| |
| MAX_ERR = 5 |
| |
| |
| class CurveToQuadraticTest(unittest.TestCase): |
| @classmethod |
| def setUpClass(cls): |
| """Do the curve conversion ahead of time, and run tests on results.""" |
| with open(os.path.join(DATADIR, "curves.json"), "r") as fp: |
| curves = json.load(fp) |
| |
| cls.single_splines = [curve_to_quadratic(c, MAX_ERR) for c in curves] |
| cls.single_errors = [ |
| cls.curve_spline_dist(c, s) for c, s in zip(curves, cls.single_splines) |
| ] |
| |
| curve_groups = [curves[i : i + 3] for i in range(0, 300, 3)] |
| cls.compat_splines = [ |
| curves_to_quadratic(c, [MAX_ERR] * 3) for c in curve_groups |
| ] |
| cls.compat_errors = [ |
| [cls.curve_spline_dist(c, s) for c, s in zip(curve_group, splines)] |
| for curve_group, splines in zip(curve_groups, cls.compat_splines) |
| ] |
| |
| cls.results = [] |
| |
| @classmethod |
| def tearDownClass(cls): |
| """Print stats from conversion, as determined during tests.""" |
| |
| for tag, results in cls.results: |
| print( |
| "\n%s\n%s" |
| % ( |
| tag, |
| "\n".join( |
| "%s: %s (%d)" % (k, "#" * (v // 10 + 1), v) |
| for k, v in sorted(results.items()) |
| ), |
| ) |
| ) |
| |
| def test_results_unchanged(self): |
| """Tests that the results of conversion haven't changed since the time |
| of this test's writing. Useful as a quick check whenever one modifies |
| the conversion algorithm. |
| """ |
| |
| expected = {2: 6, 3: 26, 4: 82, 5: 232, 6: 360, 7: 266, 8: 28} |
| |
| results = collections.defaultdict(int) |
| for spline in self.single_splines: |
| n = len(spline) - 2 |
| results[n] += 1 |
| self.assertEqual(results, expected) |
| self.results.append(("single spline lengths", results)) |
| |
| def test_results_unchanged_multiple(self): |
| """Test that conversion results are unchanged for multiple curves.""" |
| |
| expected = {5: 11, 6: 35, 7: 49, 8: 5} |
| |
| results = collections.defaultdict(int) |
| for splines in self.compat_splines: |
| n = len(splines[0]) - 2 |
| for spline in splines[1:]: |
| self.assertEqual( |
| len(spline) - 2, n, "Got incompatible conversion results" |
| ) |
| results[n] += 1 |
| self.assertEqual(results, expected) |
| self.results.append(("compatible spline lengths", results)) |
| |
| def test_does_not_exceed_tolerance(self): |
| """Test that conversion results do not exceed given error tolerance.""" |
| |
| results = collections.defaultdict(int) |
| for error in self.single_errors: |
| results[round(error, 1)] += 1 |
| self.assertLessEqual(error, MAX_ERR) |
| self.results.append(("single errors", results)) |
| |
| def test_does_not_exceed_tolerance_multiple(self): |
| """Test that error tolerance isn't exceeded for multiple curves.""" |
| |
| results = collections.defaultdict(int) |
| for errors in self.compat_errors: |
| for error in errors: |
| results[round(error, 1)] += 1 |
| self.assertLessEqual(error, MAX_ERR) |
| self.results.append(("compatible errors", results)) |
| |
| @classmethod |
| def curve_spline_dist(cls, bezier, spline, total_steps=20): |
| """Max distance between a bezier and quadratic spline at sampled points.""" |
| |
| error = 0 |
| n = len(spline) - 2 |
| steps = total_steps // n |
| for i in range(0, n - 1): |
| p1 = spline[0] if i == 0 else p3 |
| p2 = spline[i + 1] |
| if i < n - 1: |
| p3 = cls.lerp(spline[i + 1], spline[i + 2], 0.5) |
| else: |
| p3 = spline[n + 2] |
| segment = p1, p2, p3 |
| for j in range(steps): |
| error = max( |
| error, |
| cls.dist( |
| cls.cubic_bezier_at(bezier, (j / steps + i) / n), |
| cls.quadratic_bezier_at(segment, j / steps), |
| ), |
| ) |
| return error |
| |
| @classmethod |
| def lerp(cls, p1, p2, t): |
| (x1, y1), (x2, y2) = p1, p2 |
| return x1 + (x2 - x1) * t, y1 + (y2 - y1) * t |
| |
| @classmethod |
| def dist(cls, p1, p2): |
| (x1, y1), (x2, y2) = p1, p2 |
| return math.hypot(x1 - x2, y1 - y2) |
| |
| @classmethod |
| def quadratic_bezier_at(cls, b, t): |
| (x1, y1), (x2, y2), (x3, y3) = b |
| _t = 1 - t |
| t2 = t * t |
| _t2 = _t * _t |
| _2_t_t = 2 * t * _t |
| return (_t2 * x1 + _2_t_t * x2 + t2 * x3, _t2 * y1 + _2_t_t * y2 + t2 * y3) |
| |
| @classmethod |
| def cubic_bezier_at(cls, b, t): |
| (x1, y1), (x2, y2), (x3, y3), (x4, y4) = b |
| _t = 1 - t |
| t2 = t * t |
| _t2 = _t * _t |
| t3 = t * t2 |
| _t3 = _t * _t2 |
| _3_t2_t = 3 * t2 * _t |
| _3_t_t2 = 3 * t * _t2 |
| return ( |
| _t3 * x1 + _3_t_t2 * x2 + _3_t2_t * x3 + t3 * x4, |
| _t3 * y1 + _3_t_t2 * y2 + _3_t2_t * y3 + t3 * y4, |
| ) |
| |
| |
| class AllQuadraticFalseTest(unittest.TestCase): |
| def test_cubic(self): |
| cubic = [(0, 0), (0, 1), (2, 1), (2, 0)] |
| result = curve_to_quadratic(cubic, 0.1, all_quadratic=False) |
| assert result == cubic |
| |
| def test_quadratic(self): |
| cubic = [(0, 0), (2, 2), (4, 2), (6, 0)] |
| result = curve_to_quadratic(cubic, 0.1, all_quadratic=False) |
| quadratic = [(0, 0), (3, 3), (6, 0)] |
| assert result == quadratic |
| |
| |
| if __name__ == "__main__": |
| unittest.main() |