# 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 COMPILED = cython.compiled except (AttributeError, ImportError): # if cython not installed, use mock module with no-op decorators and types from fontTools.misc import cython COMPILED = False from fontTools.misc.bezierTools import splitCubicAtTC from collections import namedtuple import math from typing import ( List, Tuple, Union, ) __all__ = ["quadratic_to_curves"] # 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( p0=cython.complex, p1=cython.complex, p2=cython.complex, p1_2_3=cython.complex, ) def elevate_quadratic(p0, p1, p2): """Given a quadratic bezier curve, return its degree-elevated cubic.""" # https://pomax.github.io/bezierinfo/#reordering p1_2_3 = p1 * (2 / 3) return ( p0, (p0 * (1 / 3) + p1_2_3), (p2 * (1 / 3) + p1_2_3), p2, ) @cython.cfunc @cython.locals( start=cython.int, n=cython.int, k=cython.int, prod_ratio=cython.double, sum_ratio=cython.double, ratio=cython.double, t=cython.double, p0=cython.complex, p1=cython.complex, p2=cython.complex, p3=cython.complex, ) def merge_curves(curves, start, n): """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 prod_ratio = 1.0 sum_ratio = 1.0 ts = [1] for k in range(1, n): ck = curves[start + k] c_before = curves[start + 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[start][0] p1 = curves[start][1] p2 = curves[start + n - 1][2] p3 = curves[start + 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) curve = (p0, p1, p2, p3) return curve, ts @cython.locals( count=cython.int, num_offcurves=cython.int, i=cython.int, off1=cython.complex, off2=cython.complex, on=cython.complex, ) def add_implicit_on_curves(p): 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 return q Point = Union[Tuple[float, float], complex] @cython.locals( cost=cython.int, is_complex=cython.int, ) def quadratic_to_curves( quads: List[List[Point]], max_err: float = 0.5, all_cubic: bool = False, ) -> 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. 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 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 max_err: absolute error tolerance; defaults to 0.5 all_cubic: if True, only cubic curves are generated; defaults to False """ is_complex = type(quads[0][0]) is complex if not is_complex: quads = [[complex(x, y) for (x, y) in p] for p in quads] q = [quads[0][0]] costs = [1] cost = 1 for p in quads: assert q[-1] == p[0] for i in range(len(p) - 2): cost += 1 costs.append(cost) costs.append(cost) qq = add_implicit_on_curves(p)[1:] costs.pop() q.extend(qq) cost += 1 costs.append(cost) 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] return curves Solution = namedtuple("Solution", ["num_points", "error", "start_index", "is_cubic"]) @cython.locals( i=cython.int, j=cython.int, k=cython.int, start=cython.int, i_sol_count=cython.int, j_sol_count=cython.int, this_sol_count=cython.int, tolerance=cython.double, err=cython.double, error=cython.double, i_sol_error=cython.double, j_sol_error=cython.double, all_cubic=cython.int, is_cubic=cython.int, count=cython.int, p0=cython.complex, p1=cython.complex, p2=cython.complex, p3=cython.complex, v=cython.complex, u=cython.complex, ) 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(1, len(q)+1). """ assert len(q) >= 3, "quadratic spline requires at least 3 points" # Elevate quadratic segments to cubic elevated_quadratics = [ elevate_quadratic(*q[i : i + 3]) for i in range(0, len(q) - 2, 2) ] # Find sharp corners; they have to be oncurves for sure. forced = set() for i in range(1, len(elevated_quadratics)): p0 = elevated_quadratics[i - 1][2] p1 = elevated_quadratics[i][0] p2 = elevated_quadratics[i][1] if abs(p1 - p0) + abs(p2 - p1) > tolerance + abs(p2 - p0): forced.add(i) # Dynamic-Programming to find the solution with fewest number of # cubic curves, and within those the one with smallest error. sols = [Solution(0, 0, 0, False)] impossible = Solution(len(elevated_quadratics) * 3 + 1, 0, 1, False) start = 0 for i in range(1, len(elevated_quadratics) + 1): best_sol = impossible for j in range(start, i): j_sol_count, j_sol_error = sols[j].num_points, sols[j].error if not all_cubic: # Solution with quadratics between j:i this_count = costs[2 * i - 1] - costs[2 * j] + 1 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 in the path below continue # Fit elevated_quadratics[j:i] into one cubic try: curve, ts = merge_curves(elevated_quadratics, j, i - j) except ZeroDivisionError: continue # 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] err = abs(reconst[3] - orig[3]) error = max(error, err) if error > tolerance: break reconstructed.append(reconst) if error > tolerance: # Not feasible continue # 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 # Save best solution i_sol_count = j_sol_count + 3 i_sol_error = max(j_sol_error, error) i_sol = Solution(i_sol_count, i_sol_error, i - j, True) if i_sol < best_sol: best_sol = i_sol if i_sol_count == 3: # Can't get any better than this break sols.append(best_sol) if i in forced: start = i # Reconstruct solution splits = [] cubic = [] i = len(sols) - 1 while i: count, is_cubic = sols[i].start_index, sols[i].is_cubic splits.append(i) cubic.append(is_cubic) i -= count curves = [] j = 0 for i, is_cubic in reversed(list(zip(splits, cubic))): if is_cubic: curves.append(merge_curves(elevated_quadratics, j, i - j)[0]) else: for k in range(j, i): curves.append(q[k * 2 : k * 2 + 3]) j = i 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, tolerance) print( "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) print("Those quadratics turned back into %d cubics. " % len(curves)) print("Original curve:", curve) print("Reconstructed curve(s):", curves) if __name__ == "__main__": main()