aboutsummaryrefslogtreecommitdiff
path: root/Lib/fontTools/qu2cu/qu2cu.py
diff options
context:
space:
mode:
Diffstat (limited to 'Lib/fontTools/qu2cu/qu2cu.py')
-rw-r--r--Lib/fontTools/qu2cu/qu2cu.py408
1 files changed, 408 insertions, 0 deletions
diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py
new file mode 100644
index 00000000..97a665f6
--- /dev/null
+++ b/Lib/fontTools/qu2cu/qu2cu.py
@@ -0,0 +1,408 @@
+# 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()