From 16ee5ca19505c03e8f1369a1e9d497ab1e0e1bf6 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 13 Feb 2023 14:19:28 -0700 Subject: [PATCH 01/67] [setup] Build misc.bezierTools with Cython --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index f9d7fd36e..aea320f93 100755 --- a/setup.py +++ b/setup.py @@ -73,6 +73,9 @@ if with_cython is True or (with_cython is None and has_cython): ext_modules.append( Extension("fontTools.cu2qu.cu2qu", ["Lib/fontTools/cu2qu/cu2qu.py"]), ) + ext_modules.append( + Extension("fontTools.misc.bezierTools", ["Lib/fontTools/misc/bezierTools.py"]), + ) ext_modules.append( Extension("fontTools.pens.momentsPen", ["Lib/fontTools/pens/momentsPen.py"]), ) From 8dde7fef902f7cd5d835fbc620264bb7988aeee9 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 13 Feb 2023 14:19:45 -0700 Subject: [PATCH 02/67] [bezier] Add cubicPointAtTC --- Lib/fontTools/misc/bezierTools.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index fe96deac1..457e58277 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -30,6 +30,7 @@ __all__ = [ "solveCubic", "quadraticPointAtT", "cubicPointAtT", + "cubicPointAtTC", "linePointAtT", "segmentPointAtT", "lineLineIntersections", @@ -860,6 +861,31 @@ def cubicPointAtT(pt1, pt2, pt3, pt4, t): return (x, y) +@cython.returns(cython.complex) +@cython.locals( + t=cython.double, + pt1=cython.complex, + pt2=cython.complex, + pt3=cython.complex, + pt4=cython.complex, +) +@cython.locals(t2=cython.double, t_1 = cython.double, t_1_2 = cython.double) +def cubicPointAtTC(pt1, pt2, pt3, pt4, t): + """Finds the point at time `t` on a cubic curve. + + Args: + pt1, pt2, pt3, pt4: Coordinates of the curve as complex numbers. + t: The time along the curve. + + Returns: + A complex number with the coordinates of the point. + """ + t2 = t * t + t_1 = 1 - t + t_1_2 = t_1 * t_1 + return t_1_2 * t_1 * pt1 + 3 * (t_1_2 * t * pt2 + t_1 * t2 * pt3) + t2 * t * pt4 + + def segmentPointAtT(seg, t): if len(seg) == 2: return linePointAtT(*seg, t) From 39b6f7a75218a578dc0d1a773ba47abde501a842 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 13 Feb 2023 14:24:33 -0700 Subject: [PATCH 03/67] [bezier] Speed up cubicPointAtT --- Lib/fontTools/misc/bezierTools.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 457e58277..fbd43d246 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -846,17 +846,18 @@ def cubicPointAtT(pt1, pt2, pt3, pt4, t): Returns: A 2D tuple with the coordinates of the point. """ + t2 = t * t + t_1 = 1 - t + t_1_2 = t_1 * t_1 x = ( - (1 - t) * (1 - t) * (1 - t) * pt1[0] - + 3 * (1 - t) * (1 - t) * t * pt2[0] - + 3 * (1 - t) * t * t * pt3[0] - + t * t * t * pt4[0] + t_1_2 * t_1 * pt1[0] + + 3 * (t_1_2 * t * pt2[0] + t_1 * t2 * pt3[0]) + + t2 * t * pt4[0] ) y = ( - (1 - t) * (1 - t) * (1 - t) * pt1[1] - + 3 * (1 - t) * (1 - t) * t * pt2[1] - + 3 * (1 - t) * t * t * pt3[1] - + t * t * t * pt4[1] + t_1_2 * t_1 * pt1[1] + + 3 * (t_1_2 * t * pt2[1] + t_1 * t2 * pt3[1]) + + t2 * t * pt4[1] ) return (x, y) From 86c67a17b2413cda296931db78011aaa313d24b5 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 13 Feb 2023 14:44:36 -0700 Subject: [PATCH 04/67] [bezier] Add Cython annotations --- Lib/fontTools/misc/bezierTools.py | 87 ++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index fbd43d246..b781db4e5 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -7,6 +7,13 @@ from fontTools.misc.transform import Identity import math from collections import namedtuple +try: + import cython +except ImportError: + # if cython not installed, use mock module with no-op decorators and types + from fontTools.misc import cython + + Intersection = namedtuple("Intersection", ["pt", "t1", "t2"]) @@ -39,6 +46,13 @@ __all__ = [ "segmentSegmentIntersections", ] +if cython.compiled: + # Yep, I'm compiled. + COMPILED = True +else: + # Just a lowly interpreted script. + COMPILED = False + def calcCubicArcLength(pt1, pt2, pt3, pt4, tolerance=0.005): """Calculates the arc length for a cubic Bezier segment. @@ -68,6 +82,14 @@ def _split_cubic_into_two(p0, p1, p2, p3): ) +@cython.returns(cython.double) +@cython.locals( + p0=cython.complex, + p1=cython.complex, + p2=cython.complex, + p3=cython.complex, +) +@cython.locals(mult=cython.double, arch=cython.double, box=cython.double) def _calcCubicArcLengthCRecurse(mult, p0, p1, p2, p3): arch = abs(p0 - p3) box = abs(p0 - p1) + abs(p1 - p2) + abs(p2 - p3) @@ -80,6 +102,17 @@ def _calcCubicArcLengthCRecurse(mult, p0, p1, p2, p3): ) +@cython.returns(cython.double) +@cython.locals( + pt1=cython.complex, + pt2=cython.complex, + pt3=cython.complex, + pt4=cython.complex, +) +@cython.locals( + tolerance=cython.double, + mult=cython.double, +) def calcCubicArcLengthC(pt1, pt2, pt3, pt4, tolerance=0.005): """Calculates the arc length for a cubic Bezier segment. @@ -98,10 +131,18 @@ epsilonDigits = 6 epsilon = 1e-10 +@cython.cfunc +@cython.inline +@cython.returns(cython.double) +@cython.locals(v1=cython.complex, v2=cython.complex) def _dot(v1, v2): return (v1 * v2.conjugate()).real +@cython.cfunc +@cython.inline +@cython.returns(cython.double) +@cython.locals(x=cython.complex) def _intSecAtan(x): # In : sympy.integrate(sp.sec(sp.atan(x))) # Out: x*sqrt(x**2 + 1)/2 + asinh(x)/2 @@ -143,6 +184,25 @@ def calcQuadraticArcLength(pt1, pt2, pt3): return calcQuadraticArcLengthC(complex(*pt1), complex(*pt2), complex(*pt3)) +@cython.returns(cython.double) +@cython.locals( + pt1=cython.complex, + pt2=cython.complex, + pt3=cython.complex, + d0=cython.complex, + d1=cython.complex, + d=cython.complex, + n=cython.complex, +) +@cython.locals( + scale=cython.double, + origDist=cython.double, + a=cython.double, + b=cython.double, + x0=cython.double, + x1=cython.double, + Len=cython.double, +) def calcQuadraticArcLengthC(pt1, pt2, pt3): """Calculates the arc length for a quadratic Bezier segment. @@ -192,6 +252,17 @@ def approximateQuadraticArcLength(pt1, pt2, pt3): return approximateQuadraticArcLengthC(complex(*pt1), complex(*pt2), complex(*pt3)) +@cython.returns(cython.double) +@cython.locals( + pt1=cython.complex, + pt2=cython.complex, + pt3=cython.complex, +) +@cython.locals( + v0=cython.double, + v1=cython.double, + v2=cython.double, +) def approximateQuadraticArcLengthC(pt1, pt2, pt3): """Calculates the arc length for a quadratic Bezier segment. @@ -289,6 +360,20 @@ def approximateCubicArcLength(pt1, pt2, pt3, pt4): ) +@cython.returns(cython.double) +@cython.locals( + pt1=cython.complex, + pt2=cython.complex, + pt3=cython.complex, + pt4=cython.complex, +) +@cython.locals( + v0=cython.double, + v1=cython.double, + v2=cython.double, + v3=cython.double, + v4=cython.double, +) def approximateCubicArcLengthC(pt1, pt2, pt3, pt4): """Approximates the arc length for a cubic Bezier segment. @@ -870,7 +955,7 @@ def cubicPointAtT(pt1, pt2, pt3, pt4, t): pt3=cython.complex, pt4=cython.complex, ) -@cython.locals(t2=cython.double, t_1 = cython.double, t_1_2 = cython.double) +@cython.locals(t2=cython.double, t_1=cython.double, t_1_2=cython.double) def cubicPointAtTC(pt1, pt2, pt3, pt4, t): """Finds the point at time `t` on a cubic curve. From cce99f00f73977c3462171c9df780b9fabc9e61e Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 13 Feb 2023 14:55:23 -0700 Subject: [PATCH 05/67] [bezier] Internal variable rename --- Lib/fontTools/misc/bezierTools.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index b781db4e5..9e3338dfa 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -932,16 +932,16 @@ def cubicPointAtT(pt1, pt2, pt3, pt4, t): A 2D tuple with the coordinates of the point. """ t2 = t * t - t_1 = 1 - t - t_1_2 = t_1 * t_1 + _1_t = 1 - t + _1_t_2 = _1_t * _1_t x = ( - t_1_2 * t_1 * pt1[0] - + 3 * (t_1_2 * t * pt2[0] + t_1 * t2 * pt3[0]) + _1_t_2 * _1_t * pt1[0] + + 3 * (_1_t_2 * t * pt2[0] + _1_t * t2 * pt3[0]) + t2 * t * pt4[0] ) y = ( - t_1_2 * t_1 * pt1[1] - + 3 * (t_1_2 * t * pt2[1] + t_1 * t2 * pt3[1]) + _1_t_2 * _1_t * pt1[1] + + 3 * (_1_t_2 * t * pt2[1] + _1_t * t2 * pt3[1]) + t2 * t * pt4[1] ) return (x, y) @@ -955,7 +955,7 @@ def cubicPointAtT(pt1, pt2, pt3, pt4, t): pt3=cython.complex, pt4=cython.complex, ) -@cython.locals(t2=cython.double, t_1=cython.double, t_1_2=cython.double) +@cython.locals(t2=cython.double, _1_t=cython.double, _1_t_2=cython.double) def cubicPointAtTC(pt1, pt2, pt3, pt4, t): """Finds the point at time `t` on a cubic curve. @@ -967,9 +967,9 @@ def cubicPointAtTC(pt1, pt2, pt3, pt4, t): A complex number with the coordinates of the point. """ t2 = t * t - t_1 = 1 - t - t_1_2 = t_1 * t_1 - return t_1_2 * t_1 * pt1 + 3 * (t_1_2 * t * pt2 + t_1 * t2 * pt3) + t2 * t * pt4 + _1_t = 1 - t + _1_t_2 = _1_t * _1_t + return _1_t_2 * _1_t * pt1 + 3 * (_1_t_2 * t * pt2 + _1_t * t2 * pt3) + t2 * t * pt4 def segmentPointAtT(seg, t): From fd46f25ffb59de5b9c061a6e927a43b7ca4b1788 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 13 Feb 2023 15:07:52 -0700 Subject: [PATCH 06/67] [bezier] Add splitCubicIntoTwoAtTC --- Lib/fontTools/misc/bezierTools.py | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 9e3338dfa..88fdcb686 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -33,6 +33,7 @@ __all__ = [ "splitCubic", "splitQuadraticAtT", "splitCubicAtT", + "splitCubicIntoTwoAtTC", "solveQuadratic", "solveCubic", "quadraticPointAtT", @@ -635,6 +636,46 @@ def splitCubicAtT(pt1, pt2, pt3, pt4, *ts): return _splitCubicAtT(a, b, c, d, *ts) +@cython.returns(cython.complex) +@cython.locals( + t=cython.double, + pt1=cython.complex, + pt2=cython.complex, + pt3=cython.complex, + pt4=cython.complex, + pointAtT=cython.complex, + off1=cython.complex, + off2=cython.complex, +) +@cython.locals( + t2=cython.double, _1_t=cython.double, _1_t_2=cython.double, _2_t_1_t=cython.double +) +def splitCubicIntoTwoAtTC(pt1, pt2, pt3, pt4, t): + """Split a cubic Bezier curve at t. + + Args: + pt1,pt2,pt3,pt4: Control points of the Bezier as complex numbers. + t: Position at which to split the curve. + + Returns: + A tuple of two curve segments (each curve segment being four complex numbers). + """ + t2 = t * t + _1_t = 1 - t + _1_t_2 = _1_t * _1_t + _2_t_1_t = 2 * t * _1_t + pointAtT = ( + _1_t_2 * _1_t * pt1 + 3 * (_1_t_2 * t * pt2 + _1_t * t2 * pt3) + t2 * t * pt4 + ) + off1 = _1_t_2 * pt1 + _2_t_1_t * pt2 + t2 * pt3 + off2 = _1_t_2 * pt2 + _2_t_1_t * pt3 + t2 * pt4 + + pt2 = pt1 + (pt2 - pt1) * t + pt3 = pt4 + (pt3 - pt4) * _1_t + + return ((pt1, pt2, off1, pointAtT), (pointAtT, off2, pt3, pt4)) + + def _splitQuadraticAtT(a, b, c, *ts): ts = list(ts) segments = [] From 83398db0616dba4147819a7e85945af2af4acbba Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 13 Feb 2023 15:23:13 -0700 Subject: [PATCH 07/67] [qu2cu] Add new module to convert quadratic to cubic Beziers --- Lib/fontTools/qu2cu/__init__.py | 15 ++++ Lib/fontTools/qu2cu/qu2cu.py | 148 ++++++++++++++++++++++++++++++++ setup.py | 3 + 3 files changed, 166 insertions(+) create mode 100644 Lib/fontTools/qu2cu/__init__.py create mode 100644 Lib/fontTools/qu2cu/qu2cu.py diff --git a/Lib/fontTools/qu2cu/__init__.py b/Lib/fontTools/qu2cu/__init__.py new file mode 100644 index 000000000..ce357417c --- /dev/null +++ b/Lib/fontTools/qu2cu/__init__.py @@ -0,0 +1,15 @@ +# 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. + +from .qu2cu import * diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py new file mode 100644 index 000000000..e7d3f5ae7 --- /dev/null +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -0,0 +1,148 @@ +# cython: language_level=3 +# distutils: define_macros=CYTHON_TRACE_NOGIL=1 + +# Copyright 2023 Google Inc. All Rights Reserved. +# Copyright 2023 Behdad Esfahbod. 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. + +try: + import cython +except ImportError: + # if cython not installed, use mock module with no-op decorators and types + from fontTools.misc import cython + +from fontTools.misc.bezierTools import splitCubicIntoTwoAtTC +from fontTools.cu2qu.cu2qu import cubic_farthest_fit_inside + + +__all__ = ["quadratic_to_curves"] + + +NAN = float("NaN") + + +if cython.compiled: + # Yep, I'm compiled. + COMPILED = True +else: + # Just a lowly interpreted script. + COMPILED = False + + +@cython.locals(_1_3=cython.double, _2_3=cython.double) +@cython.locals( + p0=cython.complex, + p1=cython.complex, + p2=cython.complex, + p1_2_3=cython.complex, +) +def elevate_quadratic(p0, p1, p2, _1_3=1 / 3, _2_3=2 / 3): + """Given a quadratic bezier curve, return its degree-elevated cubic.""" + + p1_2_3 = p1 * _2_3 + return ( + p0, + (p0 * _1_3 + p1_2_3), + (p2 * _1_3 + p1_2_3), + p2, + ) + + +@cython.locals(k=cython.double, k_1=cython.double, t=cython.double) +@cython.locals( + p1=cython.complex, + p2=cython.complex, + p3=cython.complex, + p4=cython.complex, + p5=cython.complex, + p6=cython.complex, + p7=cython.complex, + off1=cython.complex, + off2=cython.complex, +) +def merge_two_curves(p1, p2, p3, p4, p5, p6, p7): + """Return the initial cubic bezier curve subdivided in two segments. + Input must be a sequence of 7 points, i.e. two consecutive cubic curve + segments sharing the middle point. + Inspired by: + https://math.stackexchange.com/questions/877725/retrieve-the-initial-cubic-b%C3%A9zier-curve-subdivided-in-two-b%C3%A9zier-curves/879213#879213 + """ + k = abs(p5 - p4) / abs(p4 - p3) + k_1 = k + 1 + off1 = k_1 * p2 - k * p1 + off2 = (k_1 * p6 - p7) / k + t = 1 / k_1 + return (p1, off1, off2, p7), t + + +def quadratic_to_curves(p, tolerance=0.5): + assert len(p) >= 3, "quadratic spline requires at least 3 points" + p = [complex(x, y) for (x, y) in p] + + # if spline has more than one offcurve, insert interpolated oncurves + q = list(p) + count = 0 + num_offcurves = len(p) - 2 + for i in range(1, num_offcurves): + off1 = p[i] + off2 = p[i + 1] + on = off1 + (off2 - off1) * 0.5 + q.insert(i + 1 + count, on) + count += 1 + del p + + # elevate quadratic segments to cubic, and join them together + curves = [] + + curve = elevate_quadratic(*q[:3]) + err = 0 + for i in range(4, len(q), 2): + cubic_segment = elevate_quadratic(q[i - 2], q[i - 1], q[i]) + new_curve, t = merge_two_curves(*(curve + cubic_segment[1:])) + + seg1, seg2 = splitCubicIntoTwoAtTC(*new_curve, t) + t_point = seg2[0] + + t_err = abs(t_point - cubic_segment[0]) + if ( + t_err > tolerance + or not cubic_farthest_fit_inside( + *(v - u for v, u in zip(seg1, curve)), tolerance - err + ) + or not cubic_farthest_fit_inside( + *(v - u for v, u in zip(seg2, cubic_segment)), tolerance + ) + ): + # Error too high. Start a new segment. + curves.append(curve) + new_curve = cubic_segment + err = 0 + pass + + curve = new_curve + err += t_err + + curves.append(curve) + + return [tuple((c.real, c.imag) for c in curve) for curve in curves] + + +def main(): + from fontTools.cu2qu.benchmark import generate_curve + from fontTools.cu2qu import curve_to_quadratic + + curve = generate_curve() + quadratics = curve_to_quadratic(curve, 0.05) + print(len(quadratics)) + print(len(quadratic_to_curves(quadratics, 0.05 * 2))) diff --git a/setup.py b/setup.py index aea320f93..6db1cceb4 100755 --- a/setup.py +++ b/setup.py @@ -73,6 +73,9 @@ if with_cython is True or (with_cython is None and has_cython): ext_modules.append( Extension("fontTools.cu2qu.cu2qu", ["Lib/fontTools/cu2qu/cu2qu.py"]), ) + ext_modules.append( + Extension("fontTools.qu2cu.qu2cu", ["Lib/fontTools/qu2cu/qu2cu.py"]), + ) ext_modules.append( Extension("fontTools.misc.bezierTools", ["Lib/fontTools/misc/bezierTools.py"]), ) From c4e3322b20a43d174de362c8d34c32d5fad29b87 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Tue, 14 Feb 2023 00:54:54 -0700 Subject: [PATCH 08/67] [bezier] Add a few more complex versions of functions --- Lib/fontTools/misc/bezierTools.py | 98 +++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index 88fdcb686..ecd137904 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -33,6 +33,7 @@ __all__ = [ "splitCubic", "splitQuadraticAtT", "splitCubicAtT", + "splitCubicAtTC", "splitCubicIntoTwoAtTC", "solveQuadratic", "solveCubic", @@ -636,6 +637,30 @@ def splitCubicAtT(pt1, pt2, pt3, pt4, *ts): return _splitCubicAtT(a, b, c, d, *ts) +@cython.locals( + pt1=cython.complex, + pt2=cython.complex, + pt3=cython.complex, + pt4=cython.complex, + a=cython.complex, + b=cython.complex, + c=cython.complex, + d=cython.complex, +) +def splitCubicAtTC(pt1, pt2, pt3, pt4, *ts): + """Split a cubic Bezier curve at one or more values of t. + + Args: + pt1,pt2,pt3,pt4: Control points of the Bezier as complex numbers.. + *ts: Positions at which to split the curve. + + Returns: + A list of curve segments (each curve segment being four complex numbers). + """ + a, b, c, d = calcCubicParametersC(pt1, pt2, pt3, pt4) + return _splitCubicAtTC(a, b, c, d, *ts) + + @cython.returns(cython.complex) @cython.locals( t=cython.double, @@ -738,6 +763,46 @@ def _splitCubicAtT(a, b, c, d, *ts): return segments +@cython.locals( + a=cython.complex, + b=cython.complex, + c=cython.complex, + d=cython.complex, + t1=cython.double, + t2=cython.double, + delta=cython.double, + delta_2=cython.double, + delta_3=cython.double, + a1=cython.complex, + b1=cython.complex, + c1=cython.complex, + d1=cython.complex, +) +def _splitCubicAtTC(a, b, c, d, *ts): + ts = list(ts) + ts.insert(0, 0.0) + ts.append(1.0) + segments = [] + for i in range(len(ts) - 1): + t1 = ts[i] + t2 = ts[i + 1] + delta = t2 - t1 + + delta_2 = delta * delta + delta_3 = delta * delta_2 + t1_2 = t1 * t1 + t1_3 = t1 * t1_2 + + # calc new a, b, c and d + a1 = a * delta_3 + b1 = (3 * a * t1 + b) * delta_2 + c1 = (2 * b * t1 + c + 3 * a * t1_2) * delta + d1 = a * t1_3 + b * t1_2 + c * t1 + d + pt1, pt2, pt3, pt4 = calcCubicPointsC(a1, b1, c1, d1) + segments.append((pt1, pt2, pt3, pt4)) + return segments + + # # Equation solvers. # @@ -900,6 +965,22 @@ def calcCubicParameters(pt1, pt2, pt3, pt4): return (ax, ay), (bx, by), (cx, cy), (dx, dy) +@cython.locals( + pt1=cython.complex, + pt2=cython.complex, + pt3=cython.complex, + pt4=cython.complex, + a=cython.complex, + b=cython.complex, + c=cython.complex, +) +def calcCubicParametersC(pt1, pt2, pt3, pt4): + c = (pt2 - pt1) * 3.0 + b = (pt3 - pt2) * 3.0 - c + a = pt4 - pt1 - c - b + return (a, b, c, pt1) + + def calcQuadraticPoints(a, b, c): ax, ay = a bx, by = b @@ -929,6 +1010,23 @@ def calcCubicPoints(a, b, c, d): return (x1, y1), (x2, y2), (x3, y3), (x4, y4) +@cython.locals( + a=cython.complex, + b=cython.complex, + c=cython.complex, + d=cython.complex, + p2=cython.complex, + p3=cython.complex, + p4=cython.complex, + _1_3=cython.double, +) +def calcCubicPointsC(a, b, c, d, _1_3=1.0 / 3): + p2 = (c * _1_3) + d + p3 = (b + c) * _1_3 + p2 + p4 = a + b + c + d + return (d, p2, p3, p4) + + # # Point at time # From 710da53b8ecabd100494387c3de46b600ff5c2d3 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Tue, 14 Feb 2023 00:56:50 -0700 Subject: [PATCH 09/67] [qu2cu] Use a better algorithm Dynamic-programming. Produces fewer number of curves. --- Lib/fontTools/qu2cu/qu2cu.py | 162 +++++++++++++++++++++-------------- 1 file changed, 99 insertions(+), 63 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index e7d3f5ae7..dfb0c7107 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -22,16 +22,13 @@ except ImportError: # if cython not installed, use mock module with no-op decorators and types from fontTools.misc import cython -from fontTools.misc.bezierTools import splitCubicIntoTwoAtTC +from fontTools.misc.bezierTools import splitCubicAtTC from fontTools.cu2qu.cu2qu import cubic_farthest_fit_inside __all__ = ["quadratic_to_curves"] -NAN = float("NaN") - - if cython.compiled: # Yep, I'm compiled. COMPILED = True @@ -59,36 +56,51 @@ def elevate_quadratic(p0, p1, p2, _1_3=1 / 3, _2_3=2 / 3): ) -@cython.locals(k=cython.double, k_1=cython.double, t=cython.double) @cython.locals( - p1=cython.complex, - p2=cython.complex, - p3=cython.complex, - p4=cython.complex, - p5=cython.complex, - p6=cython.complex, - p7=cython.complex, - off1=cython.complex, - off2=cython.complex, + n=cython.int, + prod_ratio=cython.double, + sum_ratio=cython.double, + ratio=cython.double, ) -def merge_two_curves(p1, p2, p3, p4, p5, p6, p7): - """Return the initial cubic bezier curve subdivided in two segments. - Input must be a sequence of 7 points, i.e. two consecutive cubic curve - segments sharing the middle point. - Inspired by: - https://math.stackexchange.com/questions/877725/retrieve-the-initial-cubic-b%C3%A9zier-curve-subdivided-in-two-b%C3%A9zier-curves/879213#879213 - """ - k = abs(p5 - p4) / abs(p4 - p3) - k_1 = k + 1 - off1 = k_1 * p2 - k * p1 - off2 = (k_1 * p6 - p7) / k - t = 1 / k_1 - return (p1, off1, off2, p7), t +def merge_curves(curves): + n = len(curves) + prod_ratio = 1.0 + sum_ratio = 1.0 + ts = [1] + for k in range(1, n): + ck = curves[k] + c_before = curves[k - 1] + + # |t_(k+1) - t_k| / |t_k - t_(k - 1)| = ratio + assert ck[0] == c_before[3] + ratio = abs(ck[1] - ck[0]) / abs(c_before[3] - c_before[2]) + + prod_ratio *= ratio + sum_ratio += prod_ratio + ts.append(sum_ratio) + + # (t(n) - t(n - 1)) / (t_(1) - t(0)) = prod_ratio + + ts = [t / sum_ratio for t in ts[:-1]] + + p0 = curves[0][0] + p1 = curves[0][1] + p2 = curves[n - 1][2] + p3 = curves[n - 1][3] + + p1 = p0 + (p1 - p0) / (ts[0] if ts else 1) + p2 = p3 + (p2 - p3) / ((1 - ts[-1]) if ts else 1) + + curve = (p0, p1, p2, p3) + + return curve, ts def quadratic_to_curves(p, tolerance=0.5): assert len(p) >= 3, "quadratic spline requires at least 3 points" - p = [complex(x, y) for (x, y) in p] + is_complex = type(p[0]) is complex + if not is_complex: + p = [complex(x, y) for (x, y) in p] # if spline has more than one offcurve, insert interpolated oncurves q = list(p) @@ -102,47 +114,71 @@ def quadratic_to_curves(p, tolerance=0.5): count += 1 del p - # elevate quadratic segments to cubic, and join them together + # Elevate quadratic segments to cubic + elevated_quadratics = [ + elevate_quadratic(*q[i : i + 3]) for i in range(0, len(q) - 2, 2) + ] + + sols = [(0, 0, 0)] # (best_num_segments, best_error, start_index) + for i in range(1, len(elevated_quadratics) + 1): + best_sol = (len(q) + 1, 0, 1) + for j in range(i - 1, -1, -1): + + # Fit elevated_quadratics[j:i] into one cubic + curve, ts = merge_curves(elevated_quadratics[j:i]) + reconstructed = splitCubicAtTC(*curve, *ts) + error = max( + abs(reconst[3] - orig[3]) + for reconst, orig in zip(reconstructed, elevated_quadratics[j:i]) + ) + if error > tolerance or not all( + cubic_farthest_fit_inside( + *(v - u for v, u in zip(seg1, seg2)), tolerance + ) + for seg1, seg2 in zip(reconstructed, elevated_quadratics[j:i]) + ): + continue + + j_sol_count, j_sol_error, _ = sols[j] + i_sol_count = j_sol_count + 1 + i_sol_error = max(j_sol_error, error) + i_sol = (i_sol_count, i_sol_error, i - j) + if i_sol < best_sol: + best_sol = i_sol + + sols.append(best_sol) + + # Reconstruct solution + splits = [] + i = len(sols) - 1 + while i: + splits.append(i) + _, _, count = sols[i] + i -= count curves = [] + j = 0 + for i in reversed(splits): + curves.append(merge_curves(elevated_quadratics[j:i])[0]) + j = i - curve = elevate_quadratic(*q[:3]) - err = 0 - for i in range(4, len(q), 2): - cubic_segment = elevate_quadratic(q[i - 2], q[i - 1], q[i]) - new_curve, t = merge_two_curves(*(curve + cubic_segment[1:])) - - seg1, seg2 = splitCubicIntoTwoAtTC(*new_curve, t) - t_point = seg2[0] - - t_err = abs(t_point - cubic_segment[0]) - if ( - t_err > tolerance - or not cubic_farthest_fit_inside( - *(v - u for v, u in zip(seg1, curve)), tolerance - err - ) - or not cubic_farthest_fit_inside( - *(v - u for v, u in zip(seg2, cubic_segment)), tolerance - ) - ): - # Error too high. Start a new segment. - curves.append(curve) - new_curve = cubic_segment - err = 0 - pass - - curve = new_curve - err += t_err - - curves.append(curve) - - return [tuple((c.real, c.imag) for c in curve) for curve in curves] + if not is_complex: + curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] + return curves def main(): from fontTools.cu2qu.benchmark import generate_curve from fontTools.cu2qu import curve_to_quadratic + tolerance = 0.05 + reconstruct_tolerance = tolerance * 1 curve = generate_curve() - quadratics = curve_to_quadratic(curve, 0.05) - print(len(quadratics)) - print(len(quadratic_to_curves(quadratics, 0.05 * 2))) + quadratics = curve_to_quadratic(curve, tolerance) + print( + "cu2qu tolerance %g. qu2cu tolerance %g." % (tolerance, reconstruct_tolerance) + ) + print("One random cubic turned into %d quadratics." % len(quadratics)) + print( + "Those quadratics turned back into %d cubics. " + % len(quadratic_to_curves(quadratics, reconstruct_tolerance)) + ) From 3c294d17cfa9ddd6388139badef2d3129a7661be Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Tue, 14 Feb 2023 12:50:50 -0700 Subject: [PATCH 10/67] [qu2cu] Speed up --- Lib/fontTools/qu2cu/qu2cu.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index dfb0c7107..d150c7271 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -122,7 +122,7 @@ def quadratic_to_curves(p, tolerance=0.5): sols = [(0, 0, 0)] # (best_num_segments, best_error, start_index) for i in range(1, len(elevated_quadratics) + 1): best_sol = (len(q) + 1, 0, 1) - for j in range(i - 1, -1, -1): + for j in range(0, i): # Fit elevated_quadratics[j:i] into one cubic curve, ts = merge_curves(elevated_quadratics[j:i]) @@ -146,6 +146,9 @@ def quadratic_to_curves(p, tolerance=0.5): if i_sol < best_sol: best_sol = i_sol + if i_sol_count == 1: + break + sols.append(best_sol) # Reconstruct solution @@ -178,7 +181,7 @@ def main(): "cu2qu tolerance %g. qu2cu tolerance %g." % (tolerance, reconstruct_tolerance) ) print("One random cubic turned into %d quadratics." % len(quadratics)) - print( - "Those quadratics turned back into %d cubics. " - % len(quadratic_to_curves(quadratics, reconstruct_tolerance)) - ) + curves = quadratic_to_curves(quadratics, reconstruct_tolerance) + print("Those quadratics turned back into %d cubics. " % len(curves)) + print("Original curve:", curve) + print("Reconstructed curve(s):", curves) From 2a54dc5742331dfb7e6b7d61b227e1f3c2523c33 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Tue, 14 Feb 2023 13:08:47 -0700 Subject: [PATCH 11/67] [qu2cu] Comment --- Lib/fontTools/qu2cu/qu2cu.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index d150c7271..94c764cc5 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -47,6 +47,7 @@ else: def elevate_quadratic(p0, p1, p2, _1_3=1 / 3, _2_3=2 / 3): """Given a quadratic bezier curve, return its degree-elevated cubic.""" + # https://pomax.github.io/bezierinfo/#reordering p1_2_3 = p1 * _2_3 return ( p0, @@ -63,6 +64,11 @@ def elevate_quadratic(p0, p1, p2, _1_3=1 / 3, _2_3=2 / 3): ratio=cython.double, ) def merge_curves(curves): + """Give a cubic-Bezier spline, reconstruct one cubic-Bezier + that has the same endpoints and tangents and approxmates + the spline.""" + + # Reconstruct the t values of the cut segments n = len(curves) prod_ratio = 1.0 sum_ratio = 1.0 @@ -88,6 +94,7 @@ def merge_curves(curves): p2 = curves[n - 1][2] p3 = curves[n - 1][3] + # Build the curve by scaling the control-points. p1 = p0 + (p1 - p0) / (ts[0] if ts else 1) p2 = p3 + (p2 - p3) / ((1 - ts[-1]) if ts else 1) @@ -119,6 +126,8 @@ def quadratic_to_curves(p, tolerance=0.5): elevate_quadratic(*q[i : i + 3]) for i in range(0, len(q) - 2, 2) ] + # Dynamic- Programming to find the solution with fewest number of + # cubic curves, and within those the one with smallest error. sols = [(0, 0, 0)] # (best_num_segments, best_error, start_index) for i in range(1, len(elevated_quadratics) + 1): best_sol = (len(q) + 1, 0, 1) @@ -127,6 +136,7 @@ def quadratic_to_curves(p, tolerance=0.5): # Fit elevated_quadratics[j:i] into one cubic curve, ts = merge_curves(elevated_quadratics[j:i]) reconstructed = splitCubicAtTC(*curve, *ts) + # Knot errors error = max( abs(reconst[3] - orig[3]) for reconst, orig in zip(reconstructed, elevated_quadratics[j:i]) @@ -137,8 +147,10 @@ def quadratic_to_curves(p, tolerance=0.5): ) for seg1, seg2 in zip(reconstructed, elevated_quadratics[j:i]) ): + # Not feasible continue + # Save best solution j_sol_count, j_sol_error, _ = sols[j] i_sol_count = j_sol_count + 1 i_sol_error = max(j_sol_error, error) @@ -147,6 +159,7 @@ def quadratic_to_curves(p, tolerance=0.5): best_sol = i_sol if i_sol_count == 1: + # Can't get any better than this break sols.append(best_sol) From ff5d758b27af3122e3f0ae4782e7f195cb034761 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 16:58:15 -0700 Subject: [PATCH 12/67] [cu2qu.benchmark] Remove unused parameter --- Lib/fontTools/cu2qu/benchmark.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/cu2qu/benchmark.py b/Lib/fontTools/cu2qu/benchmark.py index 63e7433e9..4d3fe24bc 100644 --- a/Lib/fontTools/cu2qu/benchmark.py +++ b/Lib/fontTools/cu2qu/benchmark.py @@ -23,9 +23,7 @@ def setup_curves_to_quadratic(): return ([generate_curve() for curve in range(num_curves)], [MAX_ERR] * num_curves) -def run_benchmark( - benchmark_module, module, function, setup_suffix="", repeat=5, number=1000 -): +def run_benchmark(module, function, setup_suffix="", repeat=5, number=1000): setup_func = "setup_" + function if setup_suffix: print("%s with %s:" % (function, setup_suffix), end="") @@ -48,8 +46,8 @@ def run_benchmark( def main(): """Benchmark the cu2qu algorithm performance.""" - run_benchmark("cu2qu.benchmark", "cu2qu", "curve_to_quadratic") - run_benchmark("cu2qu.benchmark", "cu2qu", "curves_to_quadratic") + run_benchmark("cu2qu", "curve_to_quadratic") + run_benchmark("cu2qu", "curves_to_quadratic") if __name__ == "__main__": From f75391f461af74cda09e3281e08772eb131fd009 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 17:08:24 -0700 Subject: [PATCH 13/67] [qu2cu] Add .benchmark module --- Lib/fontTools/qu2cu/benchmark.py | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 Lib/fontTools/qu2cu/benchmark.py diff --git a/Lib/fontTools/qu2cu/benchmark.py b/Lib/fontTools/qu2cu/benchmark.py new file mode 100644 index 000000000..3c9a1c693 --- /dev/null +++ b/Lib/fontTools/qu2cu/benchmark.py @@ -0,0 +1,52 @@ +"""Benchmark the qu2cu algorithm performance.""" + +from .qu2cu import * +from fontTools.cu2qu import curve_to_quadratic +import random +import timeit + +MAX_ERR = 0.05 + + +def generate_curve(): + return [ + tuple(float(random.randint(0, 2048)) for coord in range(2)) + for point in range(4) + ] + + +def setup_quadratic_to_curves(): + curve = generate_curve() + quadratics = curve_to_quadratic(curve, MAX_ERR) + return quadratics, MAX_ERR + + +def run_benchmark(module, function, setup_suffix="", repeat=5, number=10): + setup_func = "setup_" + function + if setup_suffix: + print("%s with %s:" % (function, setup_suffix), end="") + setup_func += "_" + setup_suffix + else: + print("%s:" % function, end="") + + def wrapper(function, setup_func): + function = globals()[function] + setup_func = globals()[setup_func] + + def wrapped(): + return function(*setup_func()) + + return wrapped + + results = timeit.repeat(wrapper(function, setup_func), repeat=repeat, number=number) + print("\t%5.1fus" % (min(results) * 1000000.0 / number)) + + +def main(): + """Benchmark the qu2cu algorithm performance.""" + run_benchmark("qu2cu", "quadratic_to_curves") + + +if __name__ == "__main__": + random.seed(1) + main() From 0013740ab39c8ce5350ae9c5979a58e443243ac7 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 17:11:57 -0700 Subject: [PATCH 14/67] [qu2cu] Copy a function from cu2qu, to make Cython happy --- Lib/fontTools/qu2cu/qu2cu.py | 44 +++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 94c764cc5..5e4df3404 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -23,7 +23,6 @@ except ImportError: from fontTools.misc import cython from fontTools.misc.bezierTools import splitCubicAtTC -from fontTools.cu2qu.cu2qu import cubic_farthest_fit_inside __all__ = ["quadratic_to_curves"] @@ -37,6 +36,49 @@ else: COMPILED = False +# Copied from cu2qu +#@cython.cfunc +@cython.returns(cython.int) +@cython.locals( + tolerance=cython.double, + p0=cython.complex, + p1=cython.complex, + p2=cython.complex, + p3=cython.complex, +) +@cython.locals(mid=cython.complex, deriv3=cython.complex) +def cubic_farthest_fit_inside(p0, p1, p2, p3, tolerance): + """Check if a cubic Bezier lies within a given distance of the origin. + + "Origin" means *the* origin (0,0), not the start of the curve. Note that no + checks are made on the start and end positions of the curve; this function + only checks the inside of the curve. + + Args: + p0 (complex): Start point of curve. + p1 (complex): First handle of curve. + p2 (complex): Second handle of curve. + p3 (complex): End point of curve. + tolerance (double): Distance from origin. + + Returns: + bool: True if the cubic Bezier ``p`` entirely lies within a distance + ``tolerance`` of the origin, False otherwise. + """ + # First check p2 then p1, as p2 has higher error early on. + if abs(p2) <= tolerance and abs(p1) <= tolerance: + return True + + # Split. + mid = (p0 + 3 * (p1 + p2) + p3) * 0.125 + if abs(mid) > tolerance: + return False + deriv3 = (p3 + p2 - p1 - p0) * 0.125 + return cubic_farthest_fit_inside( + p0, (p0 + p1) * 0.5, mid - deriv3, mid, tolerance + ) and cubic_farthest_fit_inside(mid, mid + deriv3, (p2 + p3) * 0.5, p3, tolerance) + + @cython.locals(_1_3=cython.double, _2_3=cython.double) @cython.locals( p0=cython.complex, From fdc7714679fc06366770d91c100fcb7d9381b3bb Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 17:14:47 -0700 Subject: [PATCH 15/67] [cu2qu] Reduce benchmark conversion error To match qu2cu's. --- Lib/fontTools/cu2qu/benchmark.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/cu2qu/benchmark.py b/Lib/fontTools/cu2qu/benchmark.py index 4d3fe24bc..2ab1e966b 100644 --- a/Lib/fontTools/cu2qu/benchmark.py +++ b/Lib/fontTools/cu2qu/benchmark.py @@ -4,7 +4,7 @@ from .cu2qu import * import random import timeit -MAX_ERR = 5 +MAX_ERR = 0.05 def generate_curve(): From 822f7a01da4071c90ed4614f28761869990c2182 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 17:25:30 -0700 Subject: [PATCH 16/67] [bezier] Make splitCubicAtTC into a generator --- Lib/fontTools/misc/bezierTools.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index ecd137904..73176a64f 100644 --- a/Lib/fontTools/misc/bezierTools.py +++ b/Lib/fontTools/misc/bezierTools.py @@ -654,11 +654,11 @@ def splitCubicAtTC(pt1, pt2, pt3, pt4, *ts): pt1,pt2,pt3,pt4: Control points of the Bezier as complex numbers.. *ts: Positions at which to split the curve. - Returns: - A list of curve segments (each curve segment being four complex numbers). + Yields: + Curve segments (each curve segment being four complex numbers). """ a, b, c, d = calcCubicParametersC(pt1, pt2, pt3, pt4) - return _splitCubicAtTC(a, b, c, d, *ts) + yield from _splitCubicAtTC(a, b, c, d, *ts) @cython.returns(cython.complex) @@ -782,7 +782,6 @@ def _splitCubicAtTC(a, b, c, d, *ts): ts = list(ts) ts.insert(0, 0.0) ts.append(1.0) - segments = [] for i in range(len(ts) - 1): t1 = ts[i] t2 = ts[i + 1] @@ -799,8 +798,7 @@ def _splitCubicAtTC(a, b, c, d, *ts): c1 = (2 * b * t1 + c + 3 * a * t1_2) * delta d1 = a * t1_3 + b * t1_2 + c * t1 + d pt1, pt2, pt3, pt4 = calcCubicPointsC(a1, b1, c1, d1) - segments.append((pt1, pt2, pt3, pt4)) - return segments + yield (pt1, pt2, pt3, pt4) # From 085872d2bce155806487cf09f15772a41dee61ed Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 17:32:15 -0700 Subject: [PATCH 17/67] [qu2cu] Speed up using generator splitCubicAtTC --- Lib/fontTools/qu2cu/qu2cu.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 5e4df3404..9eb3713c4 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -177,12 +177,19 @@ def quadratic_to_curves(p, tolerance=0.5): # Fit elevated_quadratics[j:i] into one cubic curve, ts = merge_curves(elevated_quadratics[j:i]) - reconstructed = splitCubicAtTC(*curve, *ts) + # Knot errors - error = max( - abs(reconst[3] - orig[3]) - for reconst, orig in zip(reconstructed, elevated_quadratics[j:i]) - ) + reconstructed_iter = splitCubicAtTC(*curve, *ts) + reconstructed = [] + error = 0 + for k, reconst in enumerate(reconstructed_iter): + orig = elevated_quadratics[j + k] + err = abs(reconst[3] - orig[3]) + error = max(error, err) + if error > tolerance: + break + reconstructed.append(reconst) + if error > tolerance or not all( cubic_farthest_fit_inside( *(v - u for v, u in zip(seg1, seg2)), tolerance From 86e6c55c95ff20df622f6e6b56c90c2f44198207 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 17:33:19 -0700 Subject: [PATCH 18/67] black --- Lib/fontTools/qu2cu/qu2cu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 9eb3713c4..9e1781992 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -37,7 +37,7 @@ else: # Copied from cu2qu -#@cython.cfunc +# @cython.cfunc @cython.returns(cython.int) @cython.locals( tolerance=cython.double, From aa6f60942bdde02abd3605d2cf057937f6cb9924 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 17:36:01 -0700 Subject: [PATCH 19/67] [qu2cu] More Cython annotations --- Lib/fontTools/qu2cu/qu2cu.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 9e1781992..d371ad83a 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -101,9 +101,14 @@ def elevate_quadratic(p0, p1, p2, _1_3=1 / 3, _2_3=2 / 3): @cython.locals( n=cython.int, + k=cython.int, prod_ratio=cython.double, sum_ratio=cython.double, ratio=cython.double, + p0=cython.complex, + p1=cython.complex, + p2=cython.complex, + p3=cython.complex, ) def merge_curves(curves): """Give a cubic-Bezier spline, reconstruct one cubic-Bezier From 8c607b4efa307504337a0825d7025ae1957db71d Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 20:09:23 -0700 Subject: [PATCH 20/67] [qu2cu.benchmark] Increase benchmark repeat --- Lib/fontTools/qu2cu/benchmark.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/benchmark.py b/Lib/fontTools/qu2cu/benchmark.py index 3c9a1c693..f6edc8679 100644 --- a/Lib/fontTools/qu2cu/benchmark.py +++ b/Lib/fontTools/qu2cu/benchmark.py @@ -21,7 +21,7 @@ def setup_quadratic_to_curves(): return quadratics, MAX_ERR -def run_benchmark(module, function, setup_suffix="", repeat=5, number=10): +def run_benchmark(module, function, setup_suffix="", repeat=10, number=20): setup_func = "setup_" + function if setup_suffix: print("%s with %s:" % (function, setup_suffix), end="") From 51b1a47dff6c87fb39b389d9a1da3e58b7122a45 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 20:10:40 -0700 Subject: [PATCH 21/67] [qu2cu] Add __main__.py --- Lib/fontTools/qu2cu/__main__.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Lib/fontTools/qu2cu/__main__.py diff --git a/Lib/fontTools/qu2cu/__main__.py b/Lib/fontTools/qu2cu/__main__.py new file mode 100644 index 000000000..451476deb --- /dev/null +++ b/Lib/fontTools/qu2cu/__main__.py @@ -0,0 +1,8 @@ +import sys + +# from .cli import main +from .qu2cu import main + + +if __name__ == "__main__": + sys.exit(main()) From 9a7e042f01250968f6c5f36c82c89f2e5c9c598b Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 20:16:45 -0700 Subject: [PATCH 22/67] [qu2cu] Speed up cubic_farthest_fit_inside --- Lib/fontTools/qu2cu/qu2cu.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index d371ad83a..a7cbe4e62 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -37,7 +37,7 @@ else: # Copied from cu2qu -# @cython.cfunc +@cython.cfunc @cython.returns(cython.int) @cython.locals( tolerance=cython.double, @@ -194,13 +194,19 @@ def quadratic_to_curves(p, tolerance=0.5): if error > tolerance: break reconstructed.append(reconst) + if error > tolerance: + # Not feasible + continue - if error > tolerance or not all( - cubic_farthest_fit_inside( - *(v - u for v, u in zip(seg1, seg2)), tolerance - ) - for seg1, seg2 in zip(reconstructed, elevated_quadratics[j:i]) - ): + # Interior errors + for k, reconst in enumerate(reconstructed): + orig = elevated_quadratics[j + k] + p0, p1, p2, p3 = tuple(v - u for v, u in zip(reconst, orig)) + + if not cubic_farthest_fit_inside(p0, p1, p2, p3, tolerance): + error = tolerance + 1 + break + if error > tolerance: # Not feasible continue From 3a3b8af154855d9d85dc686dca04dfe418ed5d9c Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Thu, 16 Feb 2023 20:25:59 -0700 Subject: [PATCH 23/67] [qu2cu] Comment --- Lib/fontTools/qu2cu/qu2cu.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index a7cbe4e62..21b9ea8c1 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -156,7 +156,7 @@ def quadratic_to_curves(p, tolerance=0.5): if not is_complex: p = [complex(x, y) for (x, y) in p] - # if spline has more than one offcurve, insert interpolated oncurves + # If spline has more than one offcurve, insert interpolated oncurves q = list(p) count = 0 num_offcurves = len(p) - 2 @@ -173,7 +173,7 @@ def quadratic_to_curves(p, tolerance=0.5): elevate_quadratic(*q[i : i + 3]) for i in range(0, len(q) - 2, 2) ] - # Dynamic- Programming to find the solution with fewest number of + # Dynamic-Programming to find the solution with fewest number of # cubic curves, and within those the one with smallest error. sols = [(0, 0, 0)] # (best_num_segments, best_error, start_index) for i in range(1, len(elevated_quadratics) + 1): @@ -183,9 +183,11 @@ def quadratic_to_curves(p, tolerance=0.5): # Fit elevated_quadratics[j:i] into one cubic curve, ts = merge_curves(elevated_quadratics[j:i]) - # Knot errors + # Now reconstruct the segments from the fitted curve reconstructed_iter = splitCubicAtTC(*curve, *ts) reconstructed = [] + + # Knot errors error = 0 for k, reconst in enumerate(reconstructed_iter): orig = elevated_quadratics[j + k] From 64bce6fc9b668357455e1aba550cd3b979b8fab6 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 11:09:33 -0700 Subject: [PATCH 24/67] [cu2quPen] Remove deprecated ignore_single_points --- Lib/fontTools/pens/cu2quPen.py | 43 ++++++---------------------------- Tests/pens/cu2quPen_test.py | 40 ------------------------------- 2 files changed, 7 insertions(+), 76 deletions(-) diff --git a/Lib/fontTools/pens/cu2quPen.py b/Lib/fontTools/pens/cu2quPen.py index 6c55b3511..332c2585f 100644 --- a/Lib/fontTools/pens/cu2quPen.py +++ b/Lib/fontTools/pens/cu2quPen.py @@ -31,13 +31,6 @@ class Cu2QuPen(AbstractPen): value equal, or close to UPEM / 1000. reverse_direction: flip the contours' direction but keep starting point. stats: a dictionary counting the point numbers of quadratic segments. - ignore_single_points: don't emit contours containing only a single point - - NOTE: The "ignore_single_points" argument is deprecated since v1.3.0, - which dropped Robofab support. It's no longer needed to special-case - UFO2-style anchors (aka "named points") when using ufoLib >= 2.0, - as these are no longer drawn onto pens as single-point contours, - but are handled separately as anchors. """ def __init__( @@ -46,7 +39,6 @@ class Cu2QuPen(AbstractPen): max_err, reverse_direction=False, stats=None, - ignore_single_points=False, ): if reverse_direction: self.pen = ReverseContourPen(other_pen) @@ -54,17 +46,6 @@ class Cu2QuPen(AbstractPen): self.pen = other_pen self.max_err = max_err self.stats = stats - if ignore_single_points: - import warnings - - warnings.warn( - "ignore_single_points is deprecated and " - "will be removed in future versions", - UserWarning, - stacklevel=2, - ) - self.ignore_single_points = ignore_single_points - self.start_pt = None self.current_pt = None def _check_contour_is_open(self): @@ -75,20 +56,14 @@ class Cu2QuPen(AbstractPen): if self.current_pt is not None: raise AssertionError("closePath or endPath is required") - def _add_moveTo(self): - if self.start_pt is not None: - self.pen.moveTo(self.start_pt) - self.start_pt = None - def moveTo(self, pt): self._check_contour_is_closed() - self.start_pt = self.current_pt = pt - if not self.ignore_single_points: - self._add_moveTo() + self.current_pt = pt + self.pen.moveTo(pt) + self.current_pt = pt def lineTo(self, pt): self._check_contour_is_open() - self._add_moveTo() self.pen.lineTo(pt) self.current_pt = pt @@ -98,7 +73,6 @@ class Cu2QuPen(AbstractPen): if n == 1: self.lineTo(points[0]) elif n > 1: - self._add_moveTo() self.pen.qCurveTo(*points) self.current_pt = points[-1] else: @@ -130,16 +104,13 @@ class Cu2QuPen(AbstractPen): def closePath(self): self._check_contour_is_open() - if self.start_pt is None: - # if 'start_pt' is _not_ None, we are ignoring single-point paths - self.pen.closePath() - self.current_pt = self.start_pt = None + self.pen.closePath() + self.current_pt = None def endPath(self): self._check_contour_is_open() - if self.start_pt is None: - self.pen.endPath() - self.current_pt = self.start_pt = None + self.pen.endPath() + self.current_pt = None def addComponent(self, glyphName, transformation): self._check_contour_is_closed() diff --git a/Tests/pens/cu2quPen_test.py b/Tests/pens/cu2quPen_test.py index 682e9e1f2..f1b9c1188 100644 --- a/Tests/pens/cu2quPen_test.py +++ b/Tests/pens/cu2quPen_test.py @@ -271,46 +271,6 @@ class TestCu2QuPen(unittest.TestCase, _TestPenMixin): ], ) - def test_ignore_single_points(self): - pen = DummyPen() - try: - logging.captureWarnings(True) - with CapturingLogHandler("py.warnings", level="WARNING") as log: - quadpen = Cu2QuPen(pen, MAX_ERR, ignore_single_points=True) - finally: - logging.captureWarnings(False) - quadpen.moveTo((0, 0)) - quadpen.endPath() - quadpen.moveTo((1, 1)) - quadpen.closePath() - - self.assertGreaterEqual(len(log.records), 1) - if sys.version_info < (3, 11): - self.assertIn("ignore_single_points is deprecated", log.records[0].args[0]) - else: - self.assertIn("ignore_single_points is deprecated", log.records[0].msg) - - # single-point contours were ignored, so the pen commands are empty - self.assertFalse(pen.commands) - - # redraw without ignoring single points - quadpen.ignore_single_points = False - quadpen.moveTo((0, 0)) - quadpen.endPath() - quadpen.moveTo((1, 1)) - quadpen.closePath() - - self.assertTrue(pen.commands) - self.assertEqual( - str(pen).splitlines(), - [ - "pen.moveTo((0, 0))", - "pen.endPath()", - "pen.moveTo((1, 1))", - "pen.closePath()", - ], - ) - class TestCu2QuPointPen(unittest.TestCase, _TestPenMixin): def __init__(self, *args, **kwargs): From e18fca76eff07bd94210ff50711c669bcfe33649 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 11:36:05 -0700 Subject: [PATCH 25/67] [filterPen] Add current_pt --- Lib/fontTools/pens/filterPen.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Lib/fontTools/pens/filterPen.py b/Lib/fontTools/pens/filterPen.py index 5417ae56d..81423109a 100644 --- a/Lib/fontTools/pens/filterPen.py +++ b/Lib/fontTools/pens/filterPen.py @@ -56,24 +56,31 @@ class FilterPen(_PassThruComponentsMixin, AbstractPen): def __init__(self, outPen): self._outPen = outPen + self.current_pt = None def moveTo(self, pt): self._outPen.moveTo(pt) + self.current_pt = pt def lineTo(self, pt): self._outPen.lineTo(pt) + self.current_pt = pt def curveTo(self, *points): self._outPen.curveTo(*points) + self.current_pt = points[-1] def qCurveTo(self, *points): self._outPen.qCurveTo(*points) + self.current_pt = points[-1] def closePath(self): self._outPen.closePath() + self.current_pt = None def endPath(self): self._outPen.endPath() + self.current_pt = None class ContourFilterPen(_PassThruComponentsMixin, RecordingPen): From ac94ee9949f02184f32f7c8b33f97e0e5829332e Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 11:54:35 -0700 Subject: [PATCH 26/67] [Cu2QuPen] Use FilterPen --- Lib/fontTools/pens/cu2quPen.py | 62 ++++------------------------------ Tests/pens/cu2quPen_test.py | 59 ++------------------------------ 2 files changed, 8 insertions(+), 113 deletions(-) diff --git a/Lib/fontTools/pens/cu2quPen.py b/Lib/fontTools/pens/cu2quPen.py index 332c2585f..748b2dbb1 100644 --- a/Lib/fontTools/pens/cu2quPen.py +++ b/Lib/fontTools/pens/cu2quPen.py @@ -13,13 +13,14 @@ # limitations under the License. from fontTools.cu2qu import curve_to_quadratic, curves_to_quadratic -from fontTools.pens.basePen import AbstractPen, decomposeSuperBezierSegment +from fontTools.pens.basePen import decomposeSuperBezierSegment +from fontTools.pens.filterPen import FilterPen from fontTools.pens.reverseContourPen import ReverseContourPen from fontTools.pens.pointPen import BasePointToSegmentPen from fontTools.pens.pointPen import ReverseContourPointPen -class Cu2QuPen(AbstractPen): +class Cu2QuPen(FilterPen): """A filter pen to convert cubic bezier curves to quadratic b-splines using the FontTools SegmentPen protocol. @@ -41,42 +42,10 @@ class Cu2QuPen(AbstractPen): stats=None, ): if reverse_direction: - self.pen = ReverseContourPen(other_pen) - else: - self.pen = other_pen + other_pen = ReverseContourPen(other_pen) + super().__init__(other_pen) self.max_err = max_err self.stats = stats - self.current_pt = None - - def _check_contour_is_open(self): - if self.current_pt is None: - raise AssertionError("moveTo is required") - - def _check_contour_is_closed(self): - if self.current_pt is not None: - raise AssertionError("closePath or endPath is required") - - def moveTo(self, pt): - self._check_contour_is_closed() - self.current_pt = pt - self.pen.moveTo(pt) - self.current_pt = pt - - def lineTo(self, pt): - self._check_contour_is_open() - self.pen.lineTo(pt) - self.current_pt = pt - - def qCurveTo(self, *points): - self._check_contour_is_open() - n = len(points) - if n == 1: - self.lineTo(points[0]) - elif n > 1: - self.pen.qCurveTo(*points) - self.current_pt = points[-1] - else: - raise AssertionError("illegal qcurve segment point count: %d" % n) def _curve_to_quadratic(self, pt1, pt2, pt3): curve = (self.current_pt, pt1, pt2, pt3) @@ -87,7 +56,6 @@ class Cu2QuPen(AbstractPen): self.qCurveTo(*quadratic[1:]) def curveTo(self, *points): - self._check_contour_is_open() n = len(points) if n == 3: # this is the most common case, so we special-case it @@ -95,26 +63,8 @@ class Cu2QuPen(AbstractPen): elif n > 3: for segment in decomposeSuperBezierSegment(points): self._curve_to_quadratic(*segment) - elif n == 2: - self.qCurveTo(*points) - elif n == 1: - self.lineTo(points[0]) else: - raise AssertionError("illegal curve segment point count: %d" % n) - - def closePath(self): - self._check_contour_is_open() - self.pen.closePath() - self.current_pt = None - - def endPath(self): - self._check_contour_is_open() - self.pen.endPath() - self.current_pt = None - - def addComponent(self, glyphName, transformation): - self._check_contour_is_closed() - self.pen.addComponent(glyphName, transformation) + self.qCurveTo(*points) class Cu2QuPointPen(BasePointToSegmentPen): diff --git a/Tests/pens/cu2quPen_test.py b/Tests/pens/cu2quPen_test.py index f1b9c1188..b31b28c3d 100644 --- a/Tests/pens/cu2quPen_test.py +++ b/Tests/pens/cu2quPen_test.py @@ -117,52 +117,6 @@ class TestCu2QuPen(unittest.TestCase, _TestPenMixin): self.pen_getter_name = "getPen" self.draw_method_name = "draw" - def test__check_contour_is_open(self): - msg = "moveTo is required" - quadpen = Cu2QuPen(DummyPen(), MAX_ERR) - - with self.assertRaisesRegex(AssertionError, msg): - quadpen.lineTo((0, 0)) - with self.assertRaisesRegex(AssertionError, msg): - quadpen.qCurveTo((0, 0), (1, 1)) - with self.assertRaisesRegex(AssertionError, msg): - quadpen.curveTo((0, 0), (1, 1), (2, 2)) - with self.assertRaisesRegex(AssertionError, msg): - quadpen.closePath() - with self.assertRaisesRegex(AssertionError, msg): - quadpen.endPath() - - quadpen.moveTo((0, 0)) # now it works - quadpen.lineTo((1, 1)) - quadpen.qCurveTo((2, 2), (3, 3)) - quadpen.curveTo((4, 4), (5, 5), (6, 6)) - quadpen.closePath() - - def test__check_contour_closed(self): - msg = "closePath or endPath is required" - quadpen = Cu2QuPen(DummyPen(), MAX_ERR) - quadpen.moveTo((0, 0)) - - with self.assertRaisesRegex(AssertionError, msg): - quadpen.moveTo((1, 1)) - with self.assertRaisesRegex(AssertionError, msg): - quadpen.addComponent("a", (1, 0, 0, 1, 0, 0)) - - # it works if contour is closed - quadpen.closePath() - quadpen.moveTo((1, 1)) - quadpen.endPath() - quadpen.addComponent("a", (1, 0, 0, 1, 0, 0)) - - def test_qCurveTo_no_points(self): - quadpen = Cu2QuPen(DummyPen(), MAX_ERR) - quadpen.moveTo((0, 0)) - - with self.assertRaisesRegex( - AssertionError, "illegal qcurve segment point count: 0" - ): - quadpen.qCurveTo() - def test_qCurveTo_1_point(self): pen = DummyPen() quadpen = Cu2QuPen(pen, MAX_ERR) @@ -173,7 +127,7 @@ class TestCu2QuPen(unittest.TestCase, _TestPenMixin): str(pen).splitlines(), [ "pen.moveTo((0, 0))", - "pen.lineTo((1, 1))", + "pen.qCurveTo((1, 1))", ], ) @@ -191,15 +145,6 @@ class TestCu2QuPen(unittest.TestCase, _TestPenMixin): ], ) - def test_curveTo_no_points(self): - quadpen = Cu2QuPen(DummyPen(), MAX_ERR) - quadpen.moveTo((0, 0)) - - with self.assertRaisesRegex( - AssertionError, "illegal curve segment point count: 0" - ): - quadpen.curveTo() - def test_curveTo_1_point(self): pen = DummyPen() quadpen = Cu2QuPen(pen, MAX_ERR) @@ -210,7 +155,7 @@ class TestCu2QuPen(unittest.TestCase, _TestPenMixin): str(pen).splitlines(), [ "pen.moveTo((0, 0))", - "pen.lineTo((1, 1))", + "pen.qCurveTo((1, 1))", ], ) From 2b9be6eca1ab6e186d21c89d5a83c21c026d8c23 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 11:55:57 -0700 Subject: [PATCH 27/67] [Cu2QuMultiPen] Add TODO --- Lib/fontTools/pens/cu2quPen.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/fontTools/pens/cu2quPen.py b/Lib/fontTools/pens/cu2quPen.py index 748b2dbb1..c21389ba2 100644 --- a/Lib/fontTools/pens/cu2quPen.py +++ b/Lib/fontTools/pens/cu2quPen.py @@ -209,6 +209,9 @@ class Cu2QuMultiPen: each of the pens in other_pens. """ + # TODO Simplify like 3e8ebcdce592fe8a59ca4c3a294cc9724351e1ce + # Remove start_pts and _add_moveTO + def __init__(self, other_pens, max_err, reverse_direction=False): if reverse_direction: other_pens = [ From 92e34335892e33858b198815e3f399b9c9357d73 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 12:24:22 -0700 Subject: [PATCH 28/67] [pens] Add qu2cuPen.py --- Lib/fontTools/pens/qu2cuPen.py | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 Lib/fontTools/pens/qu2cuPen.py diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py new file mode 100644 index 000000000..9629632a7 --- /dev/null +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -0,0 +1,62 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# Copyright 2023 Behdad Esfahbod. 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. + +from fontTools.qu2cu import quadratic_to_curve +from fontTools.pens.filterPen import FilterPen +from fontTools.pens.reverseContourPen import ReverseContourPen + + +class Qu2CuPen(FilterPen): + """A filter pen to convert quadratic bezier splines to cubic curves + using the FontTools SegmentPen protocol. + + Args: + + other_pen: another SegmentPen used to draw the transformed outline. + max_err: maximum approximation error in font units. For optimal results, + if you know the UPEM of the font, we recommend setting this to a + value equal, or close to UPEM / 1000. + reverse_direction: flip the contours' direction but keep starting point. + stats: a dictionary counting the point numbers of cubic segments. + """ + + def __init__( + self, + other_pen, + max_err, + reverse_direction=False, + stats=None, + ): + if reverse_direction: + other_pen = ReverseContourPen(other_pen) + super().__init__(other_pen) + self.max_err = max_err + self.stats = stats + + def _quadratic_to_curve(self, points): + quadratics = (self.current_pt,) + points + curves = quadratic_to_curves(quadratics, self.max_err) + if self.stats is not None: + n = str(len(curves)) + self.stats[n] = self.stats.get(n, 0) + 1 + for curve in curves: + self.curveTo(*curve[1:]) + + def qCurveTo(self, *points): + n = len(points) + if n < 2: + self.lineTo(*points) + else: + self._quadratic_to_curve(points) From b221f867df15ee5f0a3020a09fb98db20b9a3bc0 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 12:53:06 -0700 Subject: [PATCH 29/67] [qu2cu] Add cli.py that converts TTFs to cubic glyf1 --- Lib/fontTools/pens/qu2cuPen.py | 2 +- Lib/fontTools/qu2cu/__main__.py | 3 +- Lib/fontTools/qu2cu/cli.py | 117 ++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 Lib/fontTools/qu2cu/cli.py diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index 9629632a7..b5950af35 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from fontTools.qu2cu import quadratic_to_curve +from fontTools.qu2cu import quadratic_to_curves from fontTools.pens.filterPen import FilterPen from fontTools.pens.reverseContourPen import ReverseContourPen diff --git a/Lib/fontTools/qu2cu/__main__.py b/Lib/fontTools/qu2cu/__main__.py index 451476deb..27728cc7a 100644 --- a/Lib/fontTools/qu2cu/__main__.py +++ b/Lib/fontTools/qu2cu/__main__.py @@ -1,7 +1,6 @@ import sys -# from .cli import main -from .qu2cu import main +from .cli import main if __name__ == "__main__": diff --git a/Lib/fontTools/qu2cu/cli.py b/Lib/fontTools/qu2cu/cli.py new file mode 100644 index 000000000..07f2ad3b7 --- /dev/null +++ b/Lib/fontTools/qu2cu/cli.py @@ -0,0 +1,117 @@ +import os +import argparse +import logging +from fontTools.misc.cliTools import makeOutputFileName +from fontTools.ttLib import TTFont +from fontTools.pens.qu2cuPen import Qu2CuPen +from fontTools.pens.ttGlyphPen import TTGlyphPen +import fontTools + + +logger = logging.getLogger("fontTools.qu2cu") + + +def _font_to_cubic(input_path, output_path=None, **kwargs): + font = TTFont(input_path) + logger.info("Converting curves for %s", input_path) + + qu2cu_kwargs = { + "stats": {} if kwargs["dump_stats"] else None, + "max_err": kwargs["max_err_em"] * font["head"].unitsPerEm, + "reverse_direction": kwargs["reverse_direction"], + } + + assert "gvar" not in font, "Cannot convert variable font" + glyphSet = font.getGlyphSet() + glyphOrder = font.getGlyphOrder() + glyf = font["glyf"] + for glyphName in glyphOrder: + glyph = glyphSet[glyphName] + ttpen = TTGlyphPen(glyphSet) + pen = Qu2CuPen(ttpen, **qu2cu_kwargs) + glyph.draw(pen) + glyf[glyphName] = ttpen.glyph() + + logger.info("Saving %s", output_path) + font.save(output_path) + + +def main(args=None): + parser = argparse.ArgumentParser(prog="qu2cu") + parser.add_argument("--version", action="version", version=fontTools.__version__) + parser.add_argument( + "infiles", + nargs="+", + metavar="INPUT", + help="one or more input TTF source file(s).", + ) + parser.add_argument("-v", "--verbose", action="count", default=0) + parser.add_argument( + "-e", + "--conversion-error", + type=float, + metavar="ERROR", + default=0.001, + help="maxiumum approximation error measured in EM (default: 0.001)", + ) + parser.add_argument( + "--keep-direction", + dest="reverse_direction", + action="store_false", + help="do not reverse the contour direction", + ) + + output_parser = parser.add_mutually_exclusive_group() + output_parser.add_argument( + "-o", + "--output-file", + default=None, + metavar="OUTPUT", + help=("output filename for the converted TTF."), + ) + output_parser.add_argument( + "-d", + "--output-dir", + default=None, + metavar="DIRECTORY", + help="output directory where to save converted TTFs", + ) + + options = parser.parse_args(args) + + if not options.verbose: + level = "WARNING" + elif options.verbose == 1: + level = "INFO" + else: + level = "DEBUG" + logging.basicConfig(level=level) + + if len(options.infiles) > 1 and options.output_file: + parser.error("-o/--output-file can't be used with multile inputs") + + if options.output_dir: + output_dir = options.output_dir + if not os.path.exists(output_dir): + os.mkdir(output_dir) + elif not os.path.isdir(output_dir): + parser.error("'%s' is not a directory" % output_dir) + output_paths = [ + os.path.join(output_dir, os.path.basename(p)) for p in options.infiles + ] + elif options.output_file: + output_paths = [options.output_file] + else: + output_paths = [ + makeOutputFileName(p, overWrite=True, suffix=".cubic") + for p in options.infiles + ] + + kwargs = dict( + dump_stats=options.verbose > 0, + max_err_em=options.conversion_error, + reverse_direction=options.reverse_direction, + ) + + for input_path, output_path in zip(options.infiles, output_paths): + _font_to_cubic(input_path, output_path, **kwargs) From aa468c1c8824b673134815987b5e69e4370e98de Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 13:36:07 -0700 Subject: [PATCH 30/67] [qu2cu] Add quadratics_to_curves() Untested. --- Lib/fontTools/qu2cu/qu2cu.py | 49 ++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 21b9ea8c1..0381e2ede 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -25,7 +25,7 @@ except ImportError: from fontTools.misc.bezierTools import splitCubicAtTC -__all__ = ["quadratic_to_curves"] +__all__ = ["quadratic_to_curves", "quadratics_to_curves"] if cython.compiled: @@ -150,13 +150,7 @@ def merge_curves(curves): return curve, ts -def quadratic_to_curves(p, tolerance=0.5): - assert len(p) >= 3, "quadratic spline requires at least 3 points" - is_complex = type(p[0]) is complex - if not is_complex: - p = [complex(x, y) for (x, y) in p] - - # If spline has more than one offcurve, insert interpolated oncurves +def add_implicit_on_curves(p): q = list(p) count = 0 num_offcurves = len(p) - 2 @@ -166,7 +160,42 @@ def quadratic_to_curves(p, tolerance=0.5): on = off1 + (off2 - off1) * 0.5 q.insert(i + 1 + count, on) count += 1 - del p + return q + + +def quadratics_to_curves(pp, tolerance=0.5): + is_complex = type(pp[0][0]) is complex + if not is_complex: + pp = [[complex(x, y) for (x, y) in p] for p in pp] + + q = [pp[0][0]] + for p in pp: + assert q[-1] == p[0] + q.extend(spline_to_curves(q)[1:]) + + q = add_implicit_on_curves(q) + + if not is_complex: + curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] + return curves + + +def quadratic_to_curves(q, tolerance=0.5): + is_complex = type(q[0]) is complex + if not is_complex: + q = [complex(x, y) for (x, y) in q] + + q = add_implicit_on_curves(q) + + curves = spline_to_curves(q, tolerance) + + if not is_complex: + curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] + return curves + + +def spline_to_curves(q, tolerance=0.5): + assert len(q) >= 3, "quadratic spline requires at least 3 points" # Elevate quadratic segments to cubic elevated_quadratics = [ @@ -239,8 +268,6 @@ def quadratic_to_curves(p, tolerance=0.5): curves.append(merge_curves(elevated_quadratics[j:i])[0]) j = i - if not is_complex: - curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] return curves From cac4be60b6ec54255a5f6861eeae9dbd72c56f43 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 14:15:03 -0700 Subject: [PATCH 31/67] [qu2cu.cli] Remove reverse_direction setting --- Lib/fontTools/qu2cu/cli.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Lib/fontTools/qu2cu/cli.py b/Lib/fontTools/qu2cu/cli.py index 07f2ad3b7..0e54ff628 100644 --- a/Lib/fontTools/qu2cu/cli.py +++ b/Lib/fontTools/qu2cu/cli.py @@ -18,7 +18,6 @@ def _font_to_cubic(input_path, output_path=None, **kwargs): qu2cu_kwargs = { "stats": {} if kwargs["dump_stats"] else None, "max_err": kwargs["max_err_em"] * font["head"].unitsPerEm, - "reverse_direction": kwargs["reverse_direction"], } assert "gvar" not in font, "Cannot convert variable font" @@ -54,12 +53,6 @@ def main(args=None): default=0.001, help="maxiumum approximation error measured in EM (default: 0.001)", ) - parser.add_argument( - "--keep-direction", - dest="reverse_direction", - action="store_false", - help="do not reverse the contour direction", - ) output_parser = parser.add_mutually_exclusive_group() output_parser.add_argument( @@ -110,7 +103,6 @@ def main(args=None): kwargs = dict( dump_stats=options.verbose > 0, max_err_em=options.conversion_error, - reverse_direction=options.reverse_direction, ) for input_path, output_path in zip(options.infiles, output_paths): From e3f7154a9db2abeca9a780a0614b658eb399d2ca Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 14:15:30 -0700 Subject: [PATCH 32/67] [qu2cuPen] Keep quadratics if more economical Perhaps the pen should have a setting for this. --- Lib/fontTools/pens/qu2cuPen.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index b5950af35..9cccc2790 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -51,12 +51,15 @@ class Qu2CuPen(FilterPen): if self.stats is not None: n = str(len(curves)) self.stats[n] = self.stats.get(n, 0) + 1 - for curve in curves: - self.curveTo(*curve[1:]) + if len(quadratics) <= len(curves) * 3: + super().qCurveTo(*quadratics) + else: + for curve in curves: + self.curveTo(*curve[1:]) def qCurveTo(self, *points): n = len(points) - if n < 2: - self.lineTo(*points) + if n <= 3: + super().qCurveTo(*points) else: self._quadratic_to_curve(points) From 8c88184413629232a24a5e292938193a8f87faf9 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 14:24:56 -0700 Subject: [PATCH 33/67] [qu2cuPen] Support quadratic splines with no on-curve --- Lib/fontTools/pens/qu2cuPen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index 9cccc2790..aa32c7661 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -59,7 +59,7 @@ class Qu2CuPen(FilterPen): def qCurveTo(self, *points): n = len(points) - if n <= 3: + if n <= 3 or points[-1] is None: super().qCurveTo(*points) else: self._quadratic_to_curve(points) From 1a10b05c99ee96b5285c1807c088f05459494902 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 15:02:06 -0700 Subject: [PATCH 34/67] [qu2cuPen] Process multiple qCurveTo's at a time --- Lib/fontTools/pens/qu2cuPen.py | 43 ++++++++++++++++++++-------------- Lib/fontTools/qu2cu/qu2cu.py | 14 +++++++---- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index aa32c7661..92f50dd29 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -13,12 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from fontTools.qu2cu import quadratic_to_curves -from fontTools.pens.filterPen import FilterPen +from fontTools.qu2cu import quadratics_to_curves +from fontTools.pens.filterPen import ContourFilterPen from fontTools.pens.reverseContourPen import ReverseContourPen -class Qu2CuPen(FilterPen): +class Qu2CuPen(ContourFilterPen): """A filter pen to convert quadratic bezier splines to cubic curves using the FontTools SegmentPen protocol. @@ -45,21 +45,30 @@ class Qu2CuPen(FilterPen): self.max_err = max_err self.stats = stats - def _quadratic_to_curve(self, points): - quadratics = (self.current_pt,) + points - curves = quadratic_to_curves(quadratics, self.max_err) + def _quadratics_to_curve(self, q): + curves = quadratics_to_curves(q, self.max_err) if self.stats is not None: n = str(len(curves)) self.stats[n] = self.stats.get(n, 0) + 1 - if len(quadratics) <= len(curves) * 3: - super().qCurveTo(*quadratics) - else: - for curve in curves: - self.curveTo(*curve[1:]) + for curve in curves: + if len(curve) == 4: + yield ("curveTo", curve[1:]) + else: + yield ("qCurveTo", curve[1:]) - def qCurveTo(self, *points): - n = len(points) - if n <= 3 or points[-1] is None: - super().qCurveTo(*points) - else: - self._quadratic_to_curve(points) + def filterContour(self, contour): + quadratics = [] + currentPt = None + newContour = [] + for op,args in contour: + if op == 'qCurveTo' and len(args) > 2 and args[-1] is not None: + quadratics.append((currentPt,) + args) + else: + if quadratics: + newContour.extend(self._quadratics_to_curve(quadratics)) + quadratics = [] + newContour.append((op, args)) + currentPt = args[-1] if args else None + if quadratics: + newContour.extend(self._quadratics_to_curve(quadratics)) + return newContour diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 0381e2ede..cacaf4e4b 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -171,9 +171,9 @@ def quadratics_to_curves(pp, tolerance=0.5): q = [pp[0][0]] for p in pp: assert q[-1] == p[0] - q.extend(spline_to_curves(q)[1:]) + q.extend(add_implicit_on_curves(p)[1:]) - q = add_implicit_on_curves(q) + curves = spline_to_curves(q, tolerance) if not is_complex: curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] @@ -210,7 +210,10 @@ def spline_to_curves(q, tolerance=0.5): for j in range(0, i): # Fit elevated_quadratics[j:i] into one cubic - curve, ts = merge_curves(elevated_quadratics[j:i]) + try: + curve, ts = merge_curves(elevated_quadratics[j:i]) + except ZeroDivisionError: + continue # Now reconstruct the segments from the fitted curve reconstructed_iter = splitCubicAtTC(*curve, *ts) @@ -265,7 +268,10 @@ def spline_to_curves(q, tolerance=0.5): curves = [] j = 0 for i in reversed(splits): - curves.append(merge_curves(elevated_quadratics[j:i])[0]) + if j + 1 == i: + curves.append(q[j:j+3]) + else: + curves.append(merge_curves(elevated_quadratics[j:i])[0]) j = i return curves From ceae682246d64e59a6b56cc94db4c3b3476afefe Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 15:05:29 -0700 Subject: [PATCH 35/67] [qu2cu] Add all_cubic parameter --- Lib/fontTools/pens/qu2cuPen.py | 4 +++- Lib/fontTools/qu2cu/qu2cu.py | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index 92f50dd29..547f3a1ae 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -36,17 +36,19 @@ class Qu2CuPen(ContourFilterPen): self, other_pen, max_err, + all_cubic=False, reverse_direction=False, stats=None, ): if reverse_direction: other_pen = ReverseContourPen(other_pen) super().__init__(other_pen) + self.all_cubic = all_cubic self.max_err = max_err self.stats = stats def _quadratics_to_curve(self, q): - curves = quadratics_to_curves(q, self.max_err) + curves = quadratics_to_curves(q, self.max_err, self.all_cubic) if self.stats is not None: n = str(len(curves)) self.stats[n] = self.stats.get(n, 0) + 1 diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index cacaf4e4b..b178d4876 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -163,7 +163,7 @@ def add_implicit_on_curves(p): return q -def quadratics_to_curves(pp, tolerance=0.5): +def quadratics_to_curves(pp, tolerance=0.5, all_cubic=False): is_complex = type(pp[0][0]) is complex if not is_complex: pp = [[complex(x, y) for (x, y) in p] for p in pp] @@ -173,28 +173,28 @@ def quadratics_to_curves(pp, tolerance=0.5): assert q[-1] == p[0] q.extend(add_implicit_on_curves(p)[1:]) - curves = spline_to_curves(q, tolerance) + curves = spline_to_curves(q, tolerance, all_cubic) if not is_complex: curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] return curves -def quadratic_to_curves(q, tolerance=0.5): +def quadratic_to_curves(q, tolerance=0.5, all_cubic=False): is_complex = type(q[0]) is complex if not is_complex: q = [complex(x, y) for (x, y) in q] q = add_implicit_on_curves(q) - curves = spline_to_curves(q, tolerance) + curves = spline_to_curves(q, tolerance, all_cubic) if not is_complex: curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] return curves -def spline_to_curves(q, tolerance=0.5): +def spline_to_curves(q, tolerance=0.5, all_cubic=False): assert len(q) >= 3, "quadratic spline requires at least 3 points" # Elevate quadratic segments to cubic @@ -268,7 +268,7 @@ def spline_to_curves(q, tolerance=0.5): curves = [] j = 0 for i in reversed(splits): - if j + 1 == i: + if not all_cubic and j + 1 == i: curves.append(q[j:j+3]) else: curves.append(merge_curves(elevated_quadratics[j:i])[0]) From ea8ae8f399a6b09c4cce0551ae6e02be512d342d Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 15:22:24 -0700 Subject: [PATCH 36/67] Black --- Lib/fontTools/pens/qu2cuPen.py | 4 ++-- Lib/fontTools/qu2cu/qu2cu.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index 547f3a1ae..e5218f49b 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -62,8 +62,8 @@ class Qu2CuPen(ContourFilterPen): quadratics = [] currentPt = None newContour = [] - for op,args in contour: - if op == 'qCurveTo' and len(args) > 2 and args[-1] is not None: + for op, args in contour: + if op == "qCurveTo" and len(args) > 2 and args[-1] is not None: quadratics.append((currentPt,) + args) else: if quadratics: diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index b178d4876..4c1a65c13 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -269,7 +269,7 @@ def spline_to_curves(q, tolerance=0.5, all_cubic=False): j = 0 for i in reversed(splits): if not all_cubic and j + 1 == i: - curves.append(q[j:j+3]) + curves.append(q[j : j + 3]) else: curves.append(merge_curves(elevated_quadratics[j:i])[0]) j = i From c11682ca85c656c7fa70f8eb7d9b4e6dfa61cd05 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 15:31:21 -0700 Subject: [PATCH 37/67] [qu2cuPen] Drop cubic implicit oncurves --- Lib/fontTools/pens/qu2cuPen.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index e5218f49b..0558eacd5 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -73,4 +73,21 @@ class Qu2CuPen(ContourFilterPen): currentPt = args[-1] if args else None if quadratics: newContour.extend(self._quadratics_to_curve(quadratics)) + + # Add cubic implicit oncurve points + contour = newContour + newContour = [] + for op, args in contour: + if (op == "curveTo" and newContour and newContour[-1][0] == "curveTo"): + pt0 = newContour[-1][1][-2] + pt1 = newContour[-1][1][-1] + pt2 = args[0] + if (pt2[0] - pt1[0] == pt1[0] - pt0[0] and + pt2[1] - pt1[1] == pt1[1] - pt0[1]): + newArgs = newContour[-1][1][:-1] + args + newContour[-1] = ("curveTo", newArgs) + continue + + newContour.append((op, args)) + return newContour From 74cab7ae1fecfcaaa2e7edb67b4e7996147287af Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 15:34:57 -0700 Subject: [PATCH 38/67] [qu2cu] Fix --- Lib/fontTools/qu2cu/qu2cu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 4c1a65c13..0ea059225 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -269,7 +269,7 @@ def spline_to_curves(q, tolerance=0.5, all_cubic=False): j = 0 for i in reversed(splits): if not all_cubic and j + 1 == i: - curves.append(q[j : j + 3]) + curves.append(q[j * 2 : j * 2 + 3]) else: curves.append(merge_curves(elevated_quadratics[j:i])[0]) j = i From 86aff322b9e53c3b3b2eb4702a6b73d76eeba7a9 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 15:41:57 -0700 Subject: [PATCH 39/67] [qu2cuPen] Drop quadratic implicit oncurves too --- Lib/fontTools/pens/qu2cuPen.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index 0558eacd5..5af237d5f 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -74,18 +74,18 @@ class Qu2CuPen(ContourFilterPen): if quadratics: newContour.extend(self._quadratics_to_curve(quadratics)) - # Add cubic implicit oncurve points + # Add back implicit oncurve points contour = newContour newContour = [] for op, args in contour: - if (op == "curveTo" and newContour and newContour[-1][0] == "curveTo"): + if (op in {"curveTo", "qCurveTo"} and newContour and newContour[-1][0] == op): pt0 = newContour[-1][1][-2] pt1 = newContour[-1][1][-1] pt2 = args[0] if (pt2[0] - pt1[0] == pt1[0] - pt0[0] and pt2[1] - pt1[1] == pt1[1] - pt0[1]): newArgs = newContour[-1][1][:-1] + args - newContour[-1] = ("curveTo", newArgs) + newContour[-1] = (op, newArgs) continue newContour.append((op, args)) From 84cd10d6662aaa43470d2883d6197cd34adcd850 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 15:47:46 -0700 Subject: [PATCH 40/67] [qu2cuPen] Don't add implicit points for cubics We can't since that would be interpretted as a superBezier. --- Lib/fontTools/pens/qu2cuPen.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index 5af237d5f..bdbc23e23 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -78,12 +78,14 @@ class Qu2CuPen(ContourFilterPen): contour = newContour newContour = [] for op, args in contour: - if (op in {"curveTo", "qCurveTo"} and newContour and newContour[-1][0] == op): + if op == "qCurveTo" and newContour and newContour[-1][0] == "qCurveTo": pt0 = newContour[-1][1][-2] pt1 = newContour[-1][1][-1] pt2 = args[0] - if (pt2[0] - pt1[0] == pt1[0] - pt0[0] and - pt2[1] - pt1[1] == pt1[1] - pt0[1]): + if ( + pt2[0] - pt1[0] == pt1[0] - pt0[0] + and pt2[1] - pt1[1] == pt1[1] - pt0[1] + ): newArgs = newContour[-1][1][:-1] + args newContour[-1] = (op, newArgs) continue From 6e1f53f101edf6c88471c1c777a67d3a93eb4e25 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Fri, 17 Feb 2023 16:50:57 -0700 Subject: [PATCH 41/67] [qu2cu.qu2cu] Call main --- Lib/fontTools/qu2cu/qu2cu.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 0ea059225..486072f7d 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -293,3 +293,7 @@ def main(): print("Those quadratics turned back into %d cubics. " % len(curves)) print("Original curve:", curve) print("Reconstructed curve(s):", curves) + + +if __name__ == "__main__": + main() From e76f962883ea446366bf8fb077ecca041e0d0e94 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 18 Feb 2023 08:09:18 -0700 Subject: [PATCH 42/67] [qu2cu] Add test --- Tests/qu2cu/qu2cu_test.py | 83 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 Tests/qu2cu/qu2cu_test.py diff --git a/Tests/qu2cu/qu2cu_test.py b/Tests/qu2cu/qu2cu_test.py new file mode 100644 index 000000000..ad06448b3 --- /dev/null +++ b/Tests/qu2cu/qu2cu_test.py @@ -0,0 +1,83 @@ +# Copyright 2023 Behdad Esfahbod. 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 unittest +import pytest + +from fontTools.qu2cu import quadratic_to_curves, quadratics_to_curves + + +class Qu2CuTest: + @pytest.mark.parametrize( + "quadratics, expected, tolerance, cubic_only", + [ + ( + [ + [(0, 0), (0, 1), (2, 1), (2, 0)], + ], + [ + ((0, 0), (0, 4 / 3), (2, 4 / 3), (2, 0)), + ], + 0.1, + False, + ), + ( + [ + [(0, 0), (0, 1), (2, 1), (2, 2)], + ], + [ + ((0, 0), (0, 4 / 3), (2, 2 / 3), (2, 2)), + ], + 0.2, + False, + ), + ( + [ + [(0, 0), (0, 1), (1, 1)], + [(1, 1), (3, 1), (3, 0)], + ], + [ + ((0, 0), (0, 1), (1, 1)), + ((1, 1), (3, 1), (3, 0)), + ], + 0.2, + False, + ), + ( + [ + [(0, 0), (0, 1), (1, 1)], + [(1, 1), (3, 1), (3, 0)], + ], + [ + ((0, 0), (0, 2 / 3), (1 / 3, 1), (1, 1)), + ((1, 1), (7 / 3, 1), (3, 2 / 3), (3, 0)), + ], + 0.2, + True, + ), + ], + ) + def test_simple(self, quadratics, expected, tolerance, cubic_only): + + expected = [ + tuple((pytest.approx(p[0]), pytest.approx(p[1])) for p in curve) + for curve in expected + ] + + if len(quadratics) == 1: + c = quadratic_to_curves(quadratics[0], tolerance, cubic_only) + assert c == expected + + c = quadratics_to_curves(quadratics, tolerance, cubic_only) + assert c == expected From 701a75c74a498c47fef577821ca2d167a658dda7 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 18 Feb 2023 08:54:00 -0700 Subject: [PATCH 43/67] [qu2cu] Add roundtrip test --- Tests/qu2cu/qu2cu_test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Tests/qu2cu/qu2cu_test.py b/Tests/qu2cu/qu2cu_test.py index ad06448b3..3701a3abd 100644 --- a/Tests/qu2cu/qu2cu_test.py +++ b/Tests/qu2cu/qu2cu_test.py @@ -17,6 +17,9 @@ import pytest from fontTools.qu2cu import quadratic_to_curves, quadratics_to_curves +import os +import json +from fontTools.cu2qu import curve_to_quadratic class Qu2CuTest: @pytest.mark.parametrize( @@ -81,3 +84,19 @@ class Qu2CuTest: c = quadratics_to_curves(quadratics, tolerance, cubic_only) assert c == expected + + def test_roundtrip(self): + + DATADIR = os.path.join(os.path.dirname(__file__), "..", "cu2qu", "data") + with open(os.path.join(DATADIR, "curves.json"), "r") as fp: + curves = json.load(fp) + + tolerance = 1 + + splines = [curve_to_quadratic(c, tolerance) for c in curves] + reconsts = [quadratic_to_curves(spline, tolerance) for spline in splines] + + for curve, reconst in zip(curves, reconsts): + assert len(reconst) == 1 + curve = tuple((pytest.approx(p[0]), pytest.approx(p[1])) for p in curve) + assert curve == reconst[0] From f726ac6bbb0d7342fc01038b7728f81b7841c7b1 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 18 Feb 2023 08:56:02 -0700 Subject: [PATCH 44/67] [qu2cu] Call main() from tests For coverage --- Tests/qu2cu/qu2cu_test.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Tests/qu2cu/qu2cu_test.py b/Tests/qu2cu/qu2cu_test.py index 3701a3abd..c59f1f189 100644 --- a/Tests/qu2cu/qu2cu_test.py +++ b/Tests/qu2cu/qu2cu_test.py @@ -16,11 +16,14 @@ import unittest import pytest from fontTools.qu2cu import quadratic_to_curves, quadratics_to_curves +from fontTools.qu2cu.qu2cu import main as qu2cu_main +from fontTools.qu2cu.benchmark import main as benchmark_main import os import json from fontTools.cu2qu import curve_to_quadratic + class Qu2CuTest: @pytest.mark.parametrize( "quadratics, expected, tolerance, cubic_only", @@ -100,3 +103,8 @@ class Qu2CuTest: assert len(reconst) == 1 curve = tuple((pytest.approx(p[0]), pytest.approx(p[1])) for p in curve) assert curve == reconst[0] + + def test_main(self): + # Just for coverage + qu2cu_main() + benchmark_main() From f32df5a418947c393d576d87bc25bbb1a40fa694 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 18 Feb 2023 09:10:32 -0700 Subject: [PATCH 45/67] [cu2quPen_test] Remove redundant test --- Tests/pens/cu2quPen_test.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/Tests/pens/cu2quPen_test.py b/Tests/pens/cu2quPen_test.py index b31b28c3d..2790ce028 100644 --- a/Tests/pens/cu2quPen_test.py +++ b/Tests/pens/cu2quPen_test.py @@ -203,19 +203,6 @@ class TestCu2QuPen(unittest.TestCase, _TestPenMixin): ], ) - def test_addComponent(self): - pen = DummyPen() - quadpen = Cu2QuPen(pen, MAX_ERR) - quadpen.addComponent("a", (1, 2, 3, 4, 5.0, 6.0)) - - # components are passed through without changes - self.assertEqual( - str(pen).splitlines(), - [ - "pen.addComponent('a', (1, 2, 3, 4, 5.0, 6.0))", - ], - ) - class TestCu2QuPointPen(unittest.TestCase, _TestPenMixin): def __init__(self, *args, **kwargs): From 3534b5963118dccd3ca10fd1d6b020f2de21cfa9 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 18 Feb 2023 09:28:46 -0700 Subject: [PATCH 46/67] [qu2cu] Add pen tests --- Tests/pens/qu2cuPen_test.py | 213 ++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 Tests/pens/qu2cuPen_test.py diff --git a/Tests/pens/qu2cuPen_test.py b/Tests/pens/qu2cuPen_test.py new file mode 100644 index 000000000..812d11fd2 --- /dev/null +++ b/Tests/pens/qu2cuPen_test.py @@ -0,0 +1,213 @@ +# 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 sys +import unittest + +from fontTools.pens.qu2cuPen import Qu2CuPen +from fontTools.pens.recordingPen import RecordingPen +from . import CUBIC_GLYPHS, QUAD_GLYPHS +from .utils import DummyGlyph +from .utils import DummyPen +from textwrap import dedent + + +MAX_ERR = 1.0 + + +class _TestPenMixin(object): + """Collection of tests that are shared by both the SegmentPen and the + PointPen test cases, plus some helper methods. + Note: We currently don't have a PointPen. + """ + + maxDiff = None + + def diff(self, expected, actual): + import difflib + + expected = str(self.Glyph(expected)).splitlines(True) + actual = str(self.Glyph(actual)).splitlines(True) + diff = difflib.unified_diff( + expected, actual, fromfile="expected", tofile="actual" + ) + return "".join(diff) + + def convert_glyph(self, glyph, **kwargs): + # draw source glyph onto a new glyph using a Cu2Qu pen and return it + converted = self.Glyph() + pen = getattr(converted, self.pen_getter_name)() + cubicpen = self.Qu2CuPen(pen, MAX_ERR, **kwargs) + getattr(glyph, self.draw_method_name)(cubicpen) + return converted + + def expect_glyph(self, source, expected): + converted = self.convert_glyph(source) + self.assertNotEqual(converted, source) + if not converted.approx(expected): + print(self.diff(expected, converted)) + self.fail("converted glyph is different from expected") + + def test_convert_simple_glyph(self): + self.expect_glyph(QUAD_GLYPHS["a"], CUBIC_GLYPHS["a"]) + self.expect_glyph(QUAD_GLYPHS["A"], CUBIC_GLYPHS["A"]) + + def test_convert_composite_glyph(self): + source = CUBIC_GLYPHS["Aacute"] + converted = self.convert_glyph(source) + # components don't change after quadratic conversion + self.assertEqual(converted, source) + + def test_reverse_direction(self): + for name in ("a", "A", "Eacute"): + source = QUAD_GLYPHS[name] + normal_glyph = self.convert_glyph(source) + reversed_glyph = self.convert_glyph(source, reverse_direction=True) + + # the number of commands is the same, just their order is iverted + self.assertTrue(len(normal_glyph.outline), len(reversed_glyph.outline)) + self.assertNotEqual(normal_glyph, reversed_glyph) + + def test_stats(self): + stats = {} + for name in QUAD_GLYPHS.keys(): + source = QUAD_GLYPHS[name] + self.convert_glyph(source, stats=stats) + + self.assertTrue(stats) + self.assertTrue("1" in stats) + self.assertEqual(type(stats["1"]), int) + + def test_addComponent(self): + pen = self.Pen() + cubicpen = self.Qu2CuPen(pen, MAX_ERR) + cubicpen.addComponent("a", (1, 2, 3, 4, 5.0, 6.0)) + + # components are passed through without changes + self.assertEqual( + str(pen).splitlines(), + [ + "pen.addComponent('a', (1, 2, 3, 4, 5.0, 6.0))", + ], + ) + + +class TestQu2CuPen(unittest.TestCase, _TestPenMixin): + def __init__(self, *args, **kwargs): + super(TestQu2CuPen, self).__init__(*args, **kwargs) + self.Glyph = DummyGlyph + self.Pen = DummyPen + self.Qu2CuPen = Qu2CuPen + self.pen_getter_name = "getPen" + self.draw_method_name = "draw" + + def test_qCurveTo_1_point(self): + pen = DummyPen() + cubicpen = Qu2CuPen(pen, MAX_ERR) + cubicpen.moveTo((0, 0)) + cubicpen.qCurveTo((1, 1)) + cubicpen.closePath() + + self.assertEqual( + str(pen).splitlines(), + [ + "pen.moveTo((0, 0))", + "pen.qCurveTo((1, 1))", + "pen.closePath()", + ], + ) + + def test_qCurveTo_2_points(self): + pen = DummyPen() + cubicpen = Qu2CuPen(pen, MAX_ERR) + cubicpen.moveTo((0, 0)) + cubicpen.qCurveTo((1, 1), (2, 2)) + cubicpen.closePath() + + self.assertEqual( + str(pen).splitlines(), + [ + "pen.moveTo((0, 0))", + "pen.qCurveTo((1, 1), (2, 2))", + "pen.closePath()", + ], + ) + + def test_qCurveTo_3_points(self): + pen = DummyPen() + cubicpen = Qu2CuPen(pen, MAX_ERR) + cubicpen.moveTo((0, 0)) + cubicpen.qCurveTo((0, 3), (1, 3), (1, 0)) + cubicpen.closePath() + + self.assertEqual( + str(pen).splitlines(), + [ + "pen.moveTo((0, 0))", + "pen.curveTo((0, 4.0), (1, 4.0), (1, 0))", + "pen.closePath()", + ], + ) + + def test_curveTo_1_point(self): + pen = DummyPen() + cubicpen = Qu2CuPen(pen, MAX_ERR) + cubicpen.moveTo((0, 0)) + cubicpen.curveTo((1, 1)) + cubicpen.closePath() + + self.assertEqual( + str(pen).splitlines(), + [ + "pen.moveTo((0, 0))", + "pen.curveTo((1, 1))", + "pen.closePath()", + ], + ) + + def test_curveTo_2_points(self): + pen = DummyPen() + cubicpen = Qu2CuPen(pen, MAX_ERR) + cubicpen.moveTo((0, 0)) + cubicpen.curveTo((1, 1), (2, 2)) + cubicpen.closePath() + + self.assertEqual( + str(pen).splitlines(), + [ + "pen.moveTo((0, 0))", + "pen.curveTo((1, 1), (2, 2))", + "pen.closePath()", + ], + ) + + def test_curveTo_3_points(self): + pen = DummyPen() + cubicpen = Qu2CuPen(pen, MAX_ERR) + cubicpen.moveTo((0, 0)) + cubicpen.curveTo((1, 1), (2, 2), (3, 3)) + cubicpen.closePath() + + self.assertEqual( + str(pen).splitlines(), + [ + "pen.moveTo((0, 0))", + "pen.curveTo((1, 1), (2, 2), (3, 3))", + "pen.closePath()", + ], + ) + + +if __name__ == "__main__": + unittest.main() From 8427e6dd18528611d3fd43686f5ec826fc5776e7 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 18 Feb 2023 10:50:15 -0700 Subject: [PATCH 47/67] [ttGlyphPen] Add preserveTopology=True If False, perform implicit-oncurve elimination. --- Lib/fontTools/pens/ttGlyphPen.py | 49 +++++++++++++++++++++++++++++++- Lib/fontTools/qu2cu/cli.py | 2 +- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/pens/ttGlyphPen.py b/Lib/fontTools/pens/ttGlyphPen.py index bd97ab03f..8f8e7d748 100644 --- a/Lib/fontTools/pens/ttGlyphPen.py +++ b/Lib/fontTools/pens/ttGlyphPen.py @@ -7,6 +7,7 @@ from fontTools.misc.roundTools import otRound from fontTools.pens.basePen import LoggingPen, PenError from fontTools.pens.transformPen import TransformPen, TransformPointPen from fontTools.ttLib.tables import ttProgram +from fontTools.ttLib.tables._g_l_y_f import flagOnCurve from fontTools.ttLib.tables._g_l_y_f import Glyph from fontTools.ttLib.tables._g_l_y_f import GlyphComponent from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates @@ -124,7 +125,7 @@ class _TTGlyphBasePen: components.append(component) return components - def glyph(self, componentFlags: int = 0x4) -> Glyph: + def glyph(self, componentFlags: int = 0x4, preserveTopology=True) -> Glyph: """ Returns a :py:class:`~._g_l_y_f.Glyph` object representing the glyph. """ @@ -149,6 +150,52 @@ class _TTGlyphBasePen: glyph.program = ttProgram.Program() glyph.program.fromBytecode(b"") + if not preserveTopology: + + # Drop implied on-curve points + + drop = set() + start = 0 + flags = glyph.flags + coords = glyph.coordinates + for last in glyph.endPtsOfContours: + for i in range(start, last + 1): + if not (flags[i] & flagOnCurve): + continue + prv = i - 1 if i > start else last + nxt = i + 1 if i < last else start + if (flags[prv] & flagOnCurve) or flags[prv] != flags[nxt]: + continue + p0 = coords[prv] + p1 = coords[i] + p2 = coords[nxt] + if p1[0] - p0[0] != p2[0] - p1[0] or p1[1] - p0[1] != p2[1] - p1[1]: + continue + + drop.add(i) + if drop: + # Do the actual dropping + glyph.coordinates = GlyphCoordinates( + coords[i] for i in range(len(coords)) if i not in drop + ) + glyph.flags = array( + "B", (flags[i] for i in range(len(flags)) if i not in drop) + ) + + endPts = glyph.endPtsOfContours + newEndPts = [] + i = 0 + delta = 0 + for d in sorted(drop): + while d > endPts[i]: + newEndPts.append(endPts[i] - delta) + i += 1 + delta += 1 + while i < len(endPts): + newEndPts.append(endPts[i] - delta) + i += 1 + glyph.endPtsOfContours = newEndPts + return glyph diff --git a/Lib/fontTools/qu2cu/cli.py b/Lib/fontTools/qu2cu/cli.py index 0e54ff628..9a8f1df28 100644 --- a/Lib/fontTools/qu2cu/cli.py +++ b/Lib/fontTools/qu2cu/cli.py @@ -29,7 +29,7 @@ def _font_to_cubic(input_path, output_path=None, **kwargs): ttpen = TTGlyphPen(glyphSet) pen = Qu2CuPen(ttpen, **qu2cu_kwargs) glyph.draw(pen) - glyf[glyphName] = ttpen.glyph() + glyf[glyphName] = ttpen.glyph(preserveTopology=False) logger.info("Saving %s", output_path) font.save(output_path) From f1086ddb650f7d9f55eb50de3c16c28ddf314805 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Sat, 18 Feb 2023 16:21:39 -0700 Subject: [PATCH 48/67] [qu2cu] Produce optimal mix of cubic/quadratic splines Yay. Finally! --- Lib/fontTools/qu2cu/qu2cu.py | 58 ++++++++++++++++++++++++++---------- Tests/pens/qu2cuPen_test.py | 6 ++-- Tests/qu2cu/qu2cu_test.py | 4 +-- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 486072f7d..065f29d9f 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -169,11 +169,21 @@ def quadratics_to_curves(pp, tolerance=0.5, all_cubic=False): pp = [[complex(x, y) for (x, y) in p] for p in pp] q = [pp[0][0]] + cost = 0 + costs = [0] for p in pp: assert q[-1] == p[0] - q.extend(add_implicit_on_curves(p)[1:]) + for i in range(len(p) - 2): + cost += 1 + costs.append(cost) + costs.append(cost + 1) + qq = add_implicit_on_curves(p)[1:] + q.extend(qq) + cost += 1 + costs.append(cost) + costs.append(cost + 1) - curves = spline_to_curves(q, tolerance, all_cubic) + curves = spline_to_curves(q, costs, tolerance, all_cubic) if not is_complex: curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] @@ -185,16 +195,22 @@ def quadratic_to_curves(q, tolerance=0.5, all_cubic=False): if not is_complex: q = [complex(x, y) for (x, y) in q] + costs = [0] + for i in range(len(q) - 2): + costs.append(i + 1) + costs.append(i + 2) + costs.append(len(q) - 1) + costs.append(len(q)) q = add_implicit_on_curves(q) - curves = spline_to_curves(q, tolerance, all_cubic) + curves = spline_to_curves(q, costs, tolerance, all_cubic) if not is_complex: curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] return curves -def spline_to_curves(q, tolerance=0.5, all_cubic=False): +def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): assert len(q) >= 3, "quadratic spline requires at least 3 points" # Elevate quadratic segments to cubic @@ -204,11 +220,21 @@ def spline_to_curves(q, tolerance=0.5, all_cubic=False): # Dynamic-Programming to find the solution with fewest number of # cubic curves, and within those the one with smallest error. - sols = [(0, 0, 0)] # (best_num_segments, best_error, start_index) + sols = [(0, 0, 0, False)] # (best_num_points, best_error, start_index, cubic) for i in range(1, len(elevated_quadratics) + 1): - best_sol = (len(q) + 1, 0, 1) + best_sol = (len(q) + 2, 0, 1, False) for j in range(0, i): + j_sol_count, j_sol_error, _, _ = sols[j] + + if not all_cubic: + # Solution with quadratics between j:i + i_sol_count = j_sol_count + costs[2 * i] - costs[2 * j] + i_sol_error = j_sol_error + i_sol = (i_sol_count, i_sol_error, i - j, False) + if i_sol < best_sol: + best_sol = i_sol + # Fit elevated_quadratics[j:i] into one cubic try: curve, ts = merge_curves(elevated_quadratics[j:i]) @@ -245,14 +271,13 @@ def spline_to_curves(q, tolerance=0.5, all_cubic=False): continue # Save best solution - j_sol_count, j_sol_error, _ = sols[j] - i_sol_count = j_sol_count + 1 + i_sol_count = j_sol_count + 3 i_sol_error = max(j_sol_error, error) - i_sol = (i_sol_count, i_sol_error, i - j) + i_sol = (i_sol_count, i_sol_error, i - j, True) if i_sol < best_sol: best_sol = i_sol - if i_sol_count == 1: + if i_sol_count == 4: # Can't get any better than this break @@ -260,18 +285,21 @@ def spline_to_curves(q, tolerance=0.5, all_cubic=False): # Reconstruct solution splits = [] + cubic = [] i = len(sols) - 1 while i: + _, _, count, is_cubic = sols[i] splits.append(i) - _, _, count = sols[i] + cubic.append(is_cubic) i -= count curves = [] j = 0 - for i in reversed(splits): - if not all_cubic and j + 1 == i: - curves.append(q[j * 2 : j * 2 + 3]) - else: + for i, is_cubic in reversed(list(zip(splits, cubic))): + if is_cubic: curves.append(merge_curves(elevated_quadratics[j:i])[0]) + else: + for k in range(j, i): + curves.append(q[k * 2 : k * 2 + 3]) j = i return curves diff --git a/Tests/pens/qu2cuPen_test.py b/Tests/pens/qu2cuPen_test.py index 812d11fd2..8f05ca7b8 100644 --- a/Tests/pens/qu2cuPen_test.py +++ b/Tests/pens/qu2cuPen_test.py @@ -48,7 +48,7 @@ class _TestPenMixin(object): # draw source glyph onto a new glyph using a Cu2Qu pen and return it converted = self.Glyph() pen = getattr(converted, self.pen_getter_name)() - cubicpen = self.Qu2CuPen(pen, MAX_ERR, **kwargs) + cubicpen = self.Qu2CuPen(pen, MAX_ERR, all_cubic=True, **kwargs) getattr(glyph, self.draw_method_name)(cubicpen) return converted @@ -144,7 +144,7 @@ class TestQu2CuPen(unittest.TestCase, _TestPenMixin): ], ) - def test_qCurveTo_3_points(self): + def test_qCurveTo_3_points_no_conversion(self): pen = DummyPen() cubicpen = Qu2CuPen(pen, MAX_ERR) cubicpen.moveTo((0, 0)) @@ -155,7 +155,7 @@ class TestQu2CuPen(unittest.TestCase, _TestPenMixin): str(pen).splitlines(), [ "pen.moveTo((0, 0))", - "pen.curveTo((0, 4.0), (1, 4.0), (1, 0))", + "pen.qCurveTo((0, 3), (1, 3), (1, 0))", "pen.closePath()", ], ) diff --git a/Tests/qu2cu/qu2cu_test.py b/Tests/qu2cu/qu2cu_test.py index c59f1f189..bb45435f1 100644 --- a/Tests/qu2cu/qu2cu_test.py +++ b/Tests/qu2cu/qu2cu_test.py @@ -36,7 +36,7 @@ class Qu2CuTest: ((0, 0), (0, 4 / 3), (2, 4 / 3), (2, 0)), ], 0.1, - False, + True, ), ( [ @@ -46,7 +46,7 @@ class Qu2CuTest: ((0, 0), (0, 4 / 3), (2, 2 / 3), (2, 2)), ], 0.2, - False, + True, ), ( [ From b3be1883c8fbf255eef73b1075f4e36c1754deb8 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 08:17:24 -0700 Subject: [PATCH 49/67] [qu2cu] Use NamedTuple for solution --- Lib/fontTools/qu2cu/qu2cu.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 065f29d9f..522fecc6c 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -23,6 +23,7 @@ except ImportError: from fontTools.misc import cython from fontTools.misc.bezierTools import splitCubicAtTC +from typing import NamedTuple __all__ = ["quadratic_to_curves", "quadratics_to_curves"] @@ -210,6 +211,13 @@ def quadratic_to_curves(q, tolerance=0.5, all_cubic=False): return curves +class Solution(NamedTuple): + num_points: int + error: float + start_index: int + is_cubic: bool + + def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): assert len(q) >= 3, "quadratic spline requires at least 3 points" @@ -220,18 +228,18 @@ def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): # Dynamic-Programming to find the solution with fewest number of # cubic curves, and within those the one with smallest error. - sols = [(0, 0, 0, False)] # (best_num_points, best_error, start_index, cubic) + sols = [Solution(0, 0, 0, False)] for i in range(1, len(elevated_quadratics) + 1): - best_sol = (len(q) + 2, 0, 1, False) + best_sol = Solution(len(q) + 2, 0, 1, False) for j in range(0, i): - j_sol_count, j_sol_error, _, _ = sols[j] + j_sol_count, j_sol_error = sols[j].num_points, sols[j].error if not all_cubic: # Solution with quadratics between j:i i_sol_count = j_sol_count + costs[2 * i] - costs[2 * j] i_sol_error = j_sol_error - i_sol = (i_sol_count, i_sol_error, i - j, False) + i_sol = Solution(i_sol_count, i_sol_error, i - j, False) if i_sol < best_sol: best_sol = i_sol @@ -273,7 +281,7 @@ def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): # Save best solution i_sol_count = j_sol_count + 3 i_sol_error = max(j_sol_error, error) - i_sol = (i_sol_count, i_sol_error, i - j, True) + i_sol = Solution(i_sol_count, i_sol_error, i - j, True) if i_sol < best_sol: best_sol = i_sol @@ -288,7 +296,7 @@ def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): cubic = [] i = len(sols) - 1 while i: - _, _, count, is_cubic = sols[i] + count, is_cubic = sols[i].start_index, sols[i].is_cubic splits.append(i) cubic.append(is_cubic) i -= count From 837448d42886607f9c75f4387494af0a5724637f Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 08:22:35 -0700 Subject: [PATCH 50/67] [qu2cu] Document what costs parameter is --- Lib/fontTools/qu2cu/qu2cu.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 522fecc6c..414a92594 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -219,6 +219,15 @@ class Solution(NamedTuple): def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): + """ + q: quadratic spline with alternating on-curve / off-curve points. + + costs: cumulative list of encoding cost of q in terms of number of + points that need to be encoded. Implied on-curve points do not + contribute to the cost. If all points need to be encoded, then + costs will be range(len(q)+1). + """ + assert len(q) >= 3, "quadratic spline requires at least 3 points" # Elevate quadratic segments to cubic From b73ff5f1717fbc3b65175951bede884f5f80fc29 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 08:45:25 -0700 Subject: [PATCH 51/67] [qu2cu] Use collections.namedtuple instead, to make cython bot happy --- Lib/fontTools/qu2cu/qu2cu.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 414a92594..1df78a240 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -23,7 +23,7 @@ except ImportError: from fontTools.misc import cython from fontTools.misc.bezierTools import splitCubicAtTC -from typing import NamedTuple +from collections import namedtuple __all__ = ["quadratic_to_curves", "quadratics_to_curves"] @@ -211,11 +211,7 @@ def quadratic_to_curves(q, tolerance=0.5, all_cubic=False): return curves -class Solution(NamedTuple): - num_points: int - error: float - start_index: int - is_cubic: bool +Solution = namedtuple("Solution", ["num_points", "error", "start_index", "is_cubic"]) def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): From 77d25b332e4dbd6f06ef6eee5584b17242b2f7a8 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 08:58:29 -0700 Subject: [PATCH 52/67] [qu2cu] Add test for oncurveless contour --- Tests/pens/qu2cuPen_test.py | 11 +++++++++++ Tests/pens/utils.py | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Tests/pens/qu2cuPen_test.py b/Tests/pens/qu2cuPen_test.py index 8f05ca7b8..f3f8a3f7c 100644 --- a/Tests/pens/qu2cuPen_test.py +++ b/Tests/pens/qu2cuPen_test.py @@ -160,6 +160,17 @@ class TestQu2CuPen(unittest.TestCase, _TestPenMixin): ], ) + def test_qCurveTo_no_oncurve_points(self): + pen = DummyPen() + cubicpen = Qu2CuPen(pen, MAX_ERR) + cubicpen.qCurveTo((0, 0), (1, 0), (1, 1), (0, 1), None) + cubicpen.closePath() + + self.assertEqual( + str(pen).splitlines(), + ["pen.qCurveTo((0, 0), (1, 0), (1, 1), (0, 1), None)", "pen.closePath()"], + ) + def test_curveTo_1_point(self): pen = DummyPen() cubicpen = Qu2CuPen(pen, MAX_ERR) diff --git a/Tests/pens/utils.py b/Tests/pens/utils.py index 00643161b..4cf71746b 100644 --- a/Tests/pens/utils.py +++ b/Tests/pens/utils.py @@ -226,7 +226,11 @@ def _repr_pen_commands(commands): # cast float to int if there're no digits after decimal point, # and round floats to 12 decimal digits (more than enough) args = [ - tuple((int(v) if int(v) == v else round(v, 12)) for v in pt) + ( + tuple((int(v) if int(v) == v else round(v, 12)) for v in pt) + if pt is not None + else None + ) for pt in args ] args = ", ".join(repr(a) for a in args) From 336cfc3e8f13aef60c409b0e9d0867beaaac703d Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 09:05:58 -0700 Subject: [PATCH 53/67] [qu2cu_pen] Respect all_cubic --- Lib/fontTools/pens/qu2cuPen.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index bdbc23e23..4d8f10401 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -63,7 +63,11 @@ class Qu2CuPen(ContourFilterPen): currentPt = None newContour = [] for op, args in contour: - if op == "qCurveTo" and len(args) > 2 and args[-1] is not None: + if ( + op == "qCurveTo" + and (self.all_cubic or len(args) > 2) + and args[-1] is not None + ): quadratics.append((currentPt,) + args) else: if quadratics: From efed2550be8370b40596b49e03ce64d669eb8d93 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 09:10:07 -0700 Subject: [PATCH 54/67] [qu2cu_pen] Respect all_cubic for oncurveless curves --- Lib/fontTools/pens/qu2cuPen.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index 4d8f10401..a04d1f61e 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -63,11 +63,13 @@ class Qu2CuPen(ContourFilterPen): currentPt = None newContour = [] for op, args in contour: - if ( - op == "qCurveTo" - and (self.all_cubic or len(args) > 2) - and args[-1] is not None + if op == "qCurveTo" and ( + self.all_cubic or (len(args) > 2 and args[-1] is not None) ): + if args[-1] is None: + raise NotImplementedError( + "oncurve-less contours with all_cubic not implemented" + ) quadratics.append((currentPt,) + args) else: if quadratics: From f58a17d6e9f5eb08acd88a345a55cedd9e7a385f Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 09:50:52 -0700 Subject: [PATCH 55/67] [qu2cu] Document new API --- Lib/fontTools/qu2cu/qu2cu.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 1df78a240..414dce584 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -165,6 +165,22 @@ def add_implicit_on_curves(p): def quadratics_to_curves(pp, tolerance=0.5, all_cubic=False): + """Convers a connecting list of quadratic splines to a list of quadratic + and cubic curves. + + A quadratic spline is specified as a list of points, each of which is + a 2-tuple of X,Y coordinates. The first and last points are on-curve points + and the rest are off-curve points, with an implied on-curve point in the + middle between every two consequtive off-curve points. + + The output is a list of tuples. Each tuple is either of length three, for + a quadratic curve, or four, for a cubic curve. Each curve's last point + is the same as the next curve's first point. + + q: quadratic splines + tolerance: absolute error tolerance; defaults to 0.5 + all_cubic: if True, only cubic curves are generated; defaults to False + """ is_complex = type(pp[0][0]) is complex if not is_complex: pp = [[complex(x, y) for (x, y) in p] for p in pp] @@ -192,6 +208,21 @@ def quadratics_to_curves(pp, tolerance=0.5, all_cubic=False): def quadratic_to_curves(q, tolerance=0.5, all_cubic=False): + """Convers a quadratic spline to a list of quadratic and cubic curves. + + The quadratic spline is specified as a list of points, each of which is + a 2-tuple of X,Y coordinates. The first and last points are on-curve points + and the rest are off-curve points, with an implied on-curve point in the + middle between every two consequtive off-curve points. + + The output is a list of tuples. Each tuple is either of length three, for + a quadratic curve, or four, for a cubic curve. Each curve's last point + is the same as the next curve's first point. + + q: quadratic spline + tolerance: absolute error tolerance; defaults to 0.5 + all_cubic: if True, only cubic curves are generated; defaults to False + """ is_complex = type(q[0]) is complex if not is_complex: q = [complex(x, y) for (x, y) in q] From d0896ac2969dc747b231f9d4e3722665de9c47f9 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:03:30 -0700 Subject: [PATCH 56/67] [qu2cu] Simplify API Drop the one that was special-case of the other. --- Lib/fontTools/pens/qu2cuPen.py | 4 ++-- Lib/fontTools/qu2cu/benchmark.py | 2 +- Lib/fontTools/qu2cu/qu2cu.py | 41 +++----------------------------- Tests/qu2cu/qu2cu_test.py | 10 +++----- 4 files changed, 9 insertions(+), 48 deletions(-) diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py index a04d1f61e..55dfeeff6 100644 --- a/Lib/fontTools/pens/qu2cuPen.py +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from fontTools.qu2cu import quadratics_to_curves +from fontTools.qu2cu import quadratic_to_curves from fontTools.pens.filterPen import ContourFilterPen from fontTools.pens.reverseContourPen import ReverseContourPen @@ -48,7 +48,7 @@ class Qu2CuPen(ContourFilterPen): self.stats = stats def _quadratics_to_curve(self, q): - curves = quadratics_to_curves(q, self.max_err, self.all_cubic) + curves = quadratic_to_curves(q, self.max_err, self.all_cubic) if self.stats is not None: n = str(len(curves)) self.stats[n] = self.stats.get(n, 0) + 1 diff --git a/Lib/fontTools/qu2cu/benchmark.py b/Lib/fontTools/qu2cu/benchmark.py index f6edc8679..0839ab439 100644 --- a/Lib/fontTools/qu2cu/benchmark.py +++ b/Lib/fontTools/qu2cu/benchmark.py @@ -18,7 +18,7 @@ def generate_curve(): def setup_quadratic_to_curves(): curve = generate_curve() quadratics = curve_to_quadratic(curve, MAX_ERR) - return quadratics, MAX_ERR + return [quadratics], MAX_ERR def run_benchmark(module, function, setup_suffix="", repeat=10, number=20): diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 414dce584..69ebb1281 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -26,7 +26,7 @@ from fontTools.misc.bezierTools import splitCubicAtTC from collections import namedtuple -__all__ = ["quadratic_to_curves", "quadratics_to_curves"] +__all__ = ["quadratic_to_curves"] if cython.compiled: @@ -164,7 +164,7 @@ def add_implicit_on_curves(p): return q -def quadratics_to_curves(pp, tolerance=0.5, all_cubic=False): +def quadratic_to_curves(pp, tolerance=0.5, all_cubic=False): """Convers a connecting list of quadratic splines to a list of quadratic and cubic curves. @@ -207,41 +207,6 @@ def quadratics_to_curves(pp, tolerance=0.5, all_cubic=False): return curves -def quadratic_to_curves(q, tolerance=0.5, all_cubic=False): - """Convers a quadratic spline to a list of quadratic and cubic curves. - - The quadratic spline is specified as a list of points, each of which is - a 2-tuple of X,Y coordinates. The first and last points are on-curve points - and the rest are off-curve points, with an implied on-curve point in the - middle between every two consequtive off-curve points. - - The output is a list of tuples. Each tuple is either of length three, for - a quadratic curve, or four, for a cubic curve. Each curve's last point - is the same as the next curve's first point. - - q: quadratic spline - tolerance: absolute error tolerance; defaults to 0.5 - all_cubic: if True, only cubic curves are generated; defaults to False - """ - is_complex = type(q[0]) is complex - if not is_complex: - q = [complex(x, y) for (x, y) in q] - - costs = [0] - for i in range(len(q) - 2): - costs.append(i + 1) - costs.append(i + 2) - costs.append(len(q) - 1) - costs.append(len(q)) - q = add_implicit_on_curves(q) - - curves = spline_to_curves(q, costs, tolerance, all_cubic) - - if not is_complex: - curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] - return curves - - Solution = namedtuple("Solution", ["num_points", "error", "start_index", "is_cubic"]) @@ -361,7 +326,7 @@ def main(): "cu2qu tolerance %g. qu2cu tolerance %g." % (tolerance, reconstruct_tolerance) ) print("One random cubic turned into %d quadratics." % len(quadratics)) - curves = quadratic_to_curves(quadratics, reconstruct_tolerance) + curves = quadratic_to_curves([quadratics], reconstruct_tolerance) print("Those quadratics turned back into %d cubics. " % len(curves)) print("Original curve:", curve) print("Reconstructed curve(s):", curves) diff --git a/Tests/qu2cu/qu2cu_test.py b/Tests/qu2cu/qu2cu_test.py index bb45435f1..fae240cc9 100644 --- a/Tests/qu2cu/qu2cu_test.py +++ b/Tests/qu2cu/qu2cu_test.py @@ -15,7 +15,7 @@ import unittest import pytest -from fontTools.qu2cu import quadratic_to_curves, quadratics_to_curves +from fontTools.qu2cu import quadratic_to_curves from fontTools.qu2cu.qu2cu import main as qu2cu_main from fontTools.qu2cu.benchmark import main as benchmark_main @@ -81,11 +81,7 @@ class Qu2CuTest: for curve in expected ] - if len(quadratics) == 1: - c = quadratic_to_curves(quadratics[0], tolerance, cubic_only) - assert c == expected - - c = quadratics_to_curves(quadratics, tolerance, cubic_only) + c = quadratic_to_curves(quadratics, tolerance, cubic_only) assert c == expected def test_roundtrip(self): @@ -97,7 +93,7 @@ class Qu2CuTest: tolerance = 1 splines = [curve_to_quadratic(c, tolerance) for c in curves] - reconsts = [quadratic_to_curves(spline, tolerance) for spline in splines] + reconsts = [quadratic_to_curves([spline], tolerance) for spline in splines] for curve, reconst in zip(curves, reconsts): assert len(reconst) == 1 From 34a3f90859dde1008a8268b884aab6b37950dc63 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:07:31 -0700 Subject: [PATCH 57/67] [qu2cu] Typo --- Lib/fontTools/qu2cu/qu2cu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 69ebb1281..d703f35fe 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -165,7 +165,7 @@ def add_implicit_on_curves(p): def quadratic_to_curves(pp, tolerance=0.5, all_cubic=False): - """Convers a connecting list of quadratic splines to a list of quadratic + """Converts a connecting list of quadratic splines to a list of quadratic and cubic curves. A quadratic spline is specified as a list of points, each of which is From 97caa108c871522058a159a46342cedbe61a4650 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:08:33 -0700 Subject: [PATCH 58/67] [qu2cu] Add an optimization --- Lib/fontTools/qu2cu/qu2cu.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index d703f35fe..122aeeaf6 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -238,11 +238,15 @@ def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): if not all_cubic: # Solution with quadratics between j:i - i_sol_count = j_sol_count + costs[2 * i] - costs[2 * j] + this_count = costs[2 * i] - costs[2 * j] + i_sol_count = j_sol_count + this_count i_sol_error = j_sol_error i_sol = Solution(i_sol_count, i_sol_error, i - j, False) if i_sol < best_sol: best_sol = i_sol + if this_count == 4: + # Can't get any better than this + break # Fit elevated_quadratics[j:i] into one cubic try: From dd080d473a2a7dfca95bcacf09bf8d9c5ffd37f0 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:10:47 -0700 Subject: [PATCH 59/67] [qu2cu] Improve docs --- Lib/fontTools/qu2cu/qu2cu.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 122aeeaf6..a934fc852 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -24,6 +24,10 @@ except ImportError: from fontTools.misc.bezierTools import splitCubicAtTC from collections import namedtuple +from typing import ( + List, + Tuple, +) __all__ = ["quadratic_to_curves"] @@ -164,7 +168,11 @@ def add_implicit_on_curves(p): return q -def quadratic_to_curves(pp, tolerance=0.5, all_cubic=False): +def quadratic_to_curves( + quads: List[Tuple[Tuple[float, float]]], + tolerance: float = 0.5, + all_cubic: bool = False, +): """Converts a connecting list of quadratic splines to a list of quadratic and cubic curves. @@ -173,22 +181,26 @@ def quadratic_to_curves(pp, tolerance=0.5, all_cubic=False): and the rest are off-curve points, with an implied on-curve point in the middle between every two consequtive off-curve points. - The output is a list of tuples. Each tuple is either of length three, for - a quadratic curve, or four, for a cubic curve. Each curve's last point - is the same as the next curve's first point. + Returns: + The output is a list of tuples. Each tuple is either of length three, for + a quadratic curve, or four, for a cubic curve. Each curve's last point + is the same as the next curve's first point. - q: quadratic splines - tolerance: absolute error tolerance; defaults to 0.5 - all_cubic: if True, only cubic curves are generated; defaults to False + Args: + quads: quadratic splines + + tolerance: absolute error tolerance; defaults to 0.5 + + all_cubic: if True, only cubic curves are generated; defaults to False """ - is_complex = type(pp[0][0]) is complex + is_complex = type(quads[0][0]) is complex if not is_complex: - pp = [[complex(x, y) for (x, y) in p] for p in pp] + quads = [[complex(x, y) for (x, y) in p] for p in quads] - q = [pp[0][0]] + q = [quads[0][0]] cost = 0 costs = [0] - for p in pp: + for p in quads: assert q[-1] == p[0] for i in range(len(p) - 2): cost += 1 From 6028fee260cae85109f9844a75b6e45bbc1fe055 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:16:34 -0700 Subject: [PATCH 60/67] [qu2cu] Rename tolerance to max_err To match cu2qu. --- Lib/fontTools/qu2cu/qu2cu.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index a934fc852..576c746d5 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -170,7 +170,7 @@ def add_implicit_on_curves(p): def quadratic_to_curves( quads: List[Tuple[Tuple[float, float]]], - tolerance: float = 0.5, + max_err: float = 0.5, all_cubic: bool = False, ): """Converts a connecting list of quadratic splines to a list of quadratic @@ -189,7 +189,7 @@ def quadratic_to_curves( Args: quads: quadratic splines - tolerance: absolute error tolerance; defaults to 0.5 + max_err: absolute error tolerance; defaults to 0.5 all_cubic: if True, only cubic curves are generated; defaults to False """ @@ -212,7 +212,7 @@ def quadratic_to_curves( costs.append(cost) costs.append(cost + 1) - curves = spline_to_curves(q, costs, tolerance, all_cubic) + curves = spline_to_curves(q, costs, max_err, all_cubic) if not is_complex: curves = [tuple((c.real, c.imag) for c in curve) for curve in curves] From 702265a760b168dbd57ec28e91ef4da3d125c7e6 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:18:51 -0700 Subject: [PATCH 61/67] [qu2cu] Fixup annotation --- Lib/fontTools/qu2cu/qu2cu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 576c746d5..6ba071a0e 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -169,7 +169,7 @@ def add_implicit_on_curves(p): def quadratic_to_curves( - quads: List[Tuple[Tuple[float, float]]], + quads: List[Tuple[Tuple[float, float], ...]], max_err: float = 0.5, all_cubic: bool = False, ): From eec3dca58a762cddb8f4315e2c8591ca4aecf7d4 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:19:50 -0700 Subject: [PATCH 62/67] Revert "[qu2cu] Add an optimization" This reverts commit 97caa108c871522058a159a46342cedbe61a4650. This was wrong, and made the test fail indeed. I'll think to see if I can come up with a proper one. --- Lib/fontTools/qu2cu/qu2cu.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 6ba071a0e..dc65f81b7 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -250,15 +250,11 @@ def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): if not all_cubic: # Solution with quadratics between j:i - this_count = costs[2 * i] - costs[2 * j] - i_sol_count = j_sol_count + this_count + i_sol_count = j_sol_count + costs[2 * i] - costs[2 * j] i_sol_error = j_sol_error i_sol = Solution(i_sol_count, i_sol_error, i - j, False) if i_sol < best_sol: best_sol = i_sol - if this_count == 4: - # Can't get any better than this - break # Fit elevated_quadratics[j:i] into one cubic try: From 95692c29df26143ab2067507976aff1049db2616 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:21:12 -0700 Subject: [PATCH 63/67] [qu2cu] Tweak annotation again --- Lib/fontTools/qu2cu/qu2cu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index dc65f81b7..f545626a2 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -169,7 +169,7 @@ def add_implicit_on_curves(p): def quadratic_to_curves( - quads: List[Tuple[Tuple[float, float], ...]], + quads: List[List[Tuple[float, float]]], max_err: float = 0.5, all_cubic: bool = False, ): From 51ed6c151f158911e10bfbe8d2473234bc8e915b Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:25:28 -0700 Subject: [PATCH 64/67] [qu2cu] More annotation --- Lib/fontTools/qu2cu/qu2cu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index f545626a2..b59daae4b 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -172,7 +172,7 @@ def quadratic_to_curves( quads: List[List[Tuple[float, float]]], max_err: float = 0.5, all_cubic: bool = False, -): +) -> List[Tuple[Tuple[float, float], ...]]: """Converts a connecting list of quadratic splines to a list of quadratic and cubic curves. From 68c735aa76ed8db7138dcee4829615b8c842f8da Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:29:48 -0700 Subject: [PATCH 65/67] [qu2cu] Fix optimization 4 wouldn't have caused problem really but 3 is correct. --- Lib/fontTools/qu2cu/qu2cu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index b59daae4b..6136c2096 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -298,7 +298,7 @@ def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): if i_sol < best_sol: best_sol = i_sol - if i_sol_count == 4: + if i_sol_count == 3: # Can't get any better than this break From 90a2a80524d55b933e5a4345a19bed9670d825c1 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:30:24 -0700 Subject: [PATCH 66/67] Revert "Revert "[qu2cu] Add an optimization"" This reverts commit eec3dca58a762cddb8f4315e2c8591ca4aecf7d4. The optimization is correct now. It brings down the Cythonized benchmark from 1400us to 1100us. --- Lib/fontTools/qu2cu/qu2cu.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 6136c2096..51de8e13d 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -250,12 +250,17 @@ def spline_to_curves(q, costs, tolerance=0.5, all_cubic=False): if not all_cubic: # Solution with quadratics between j:i - i_sol_count = j_sol_count + costs[2 * i] - costs[2 * j] + this_count = costs[2 * i] - costs[2 * j] + i_sol_count = j_sol_count + this_count i_sol_error = j_sol_error i_sol = Solution(i_sol_count, i_sol_error, i - j, False) if i_sol < best_sol: best_sol = i_sol + if this_count == 3: + # Can't get any better than this + break + # Fit elevated_quadratics[j:i] into one cubic try: curve, ts = merge_curves(elevated_quadratics[j:i]) From 789f45d4ee60cc23080e5e7a0ea80e45449f502c Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 20 Feb 2023 10:41:13 -0700 Subject: [PATCH 67/67] [qu2cu] Improve docs --- Lib/fontTools/qu2cu/qu2cu.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py index 51de8e13d..20bfaa93f 100644 --- a/Lib/fontTools/qu2cu/qu2cu.py +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -27,6 +27,7 @@ from collections import namedtuple from typing import ( List, Tuple, + Union, ) @@ -168,23 +169,32 @@ def add_implicit_on_curves(p): return q +Point = Union[Tuple[float, float], complex] + + def quadratic_to_curves( - quads: List[List[Tuple[float, float]]], + quads: List[List[Point]], max_err: float = 0.5, all_cubic: bool = False, -) -> List[Tuple[Tuple[float, float], ...]]: +) -> List[Tuple[Point, ...]]: """Converts a connecting list of quadratic splines to a list of quadratic and cubic curves. - A quadratic spline is specified as a list of points, each of which is - a 2-tuple of X,Y coordinates. The first and last points are on-curve points - and the rest are off-curve points, with an implied on-curve point in the - middle between every two consequtive off-curve points. + A quadratic spline is specified as a list of points. Either each point is + a 2-tuple of X,Y coordinates, or each point is a complex number with + real/imaginary components representing X,Y coordinates. + + The first and last points are on-curve points and the rest are off-curve + points, with an implied on-curve point in the middle between every two + consequtive off-curve points. Returns: - The output is a list of tuples. Each tuple is either of length three, for - a quadratic curve, or four, for a cubic curve. Each curve's last point - is the same as the next curve's first point. + The output is a list of tuples of points. Points are represented + in the same format as the input, either as 2-tuples or complex numbers. + + Each tuple is either of length three, for a quadratic curve, or four, + for a cubic curve. Each curve's last point is the same as the next + curve's first point. Args: quads: quadratic splines