diff --git a/Lib/fontTools/cu2qu/benchmark.py b/Lib/fontTools/cu2qu/benchmark.py index 63e7433e9..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(): @@ -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__": diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py index fe96deac1..73176a64f 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"]) @@ -26,10 +33,13 @@ __all__ = [ "splitCubic", "splitQuadraticAtT", "splitCubicAtT", + "splitCubicAtTC", + "splitCubicIntoTwoAtTC", "solveQuadratic", "solveCubic", "quadraticPointAtT", "cubicPointAtT", + "cubicPointAtTC", "linePointAtT", "segmentPointAtT", "lineLineIntersections", @@ -38,6 +48,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. @@ -67,6 +84,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) @@ -79,6 +104,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. @@ -97,10 +133,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 @@ -142,6 +186,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. @@ -191,6 +254,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. @@ -288,6 +362,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. @@ -549,6 +637,70 @@ 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. + + Yields: + Curve segments (each curve segment being four complex numbers). + """ + a, b, c, d = calcCubicParametersC(pt1, pt2, pt3, pt4) + yield from _splitCubicAtTC(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 = [] @@ -611,6 +763,44 @@ 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) + 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) + yield (pt1, pt2, pt3, pt4) + + # # Equation solvers. # @@ -773,6 +963,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 @@ -802,6 +1008,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 # @@ -845,21 +1068,47 @@ def cubicPointAtT(pt1, pt2, pt3, pt4, t): Returns: A 2D tuple with the coordinates of the point. """ + t2 = t * t + _1_t = 1 - t + _1_t_2 = _1_t * _1_t 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] + _1_t_2 * _1_t * pt1[0] + + 3 * (_1_t_2 * t * pt2[0] + _1_t * 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] + _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) +@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, _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. + + 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 + _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): if len(seg) == 2: return linePointAtT(*seg, t) diff --git a/Lib/fontTools/pens/cu2quPen.py b/Lib/fontTools/pens/cu2quPen.py index 6c55b3511..c21389ba2 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. @@ -31,13 +32,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,63 +40,12 @@ class Cu2QuPen(AbstractPen): max_err, reverse_direction=False, stats=None, - ignore_single_points=False, ): 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 - 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): - 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 _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() - - def lineTo(self, pt): - self._check_contour_is_open() - self._add_moveTo() - 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._add_moveTo() - 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) @@ -113,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 @@ -121,29 +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() - 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 - - def endPath(self): - self._check_contour_is_open() - if self.start_pt is None: - self.pen.endPath() - self.current_pt = self.start_pt = None - - def addComponent(self, glyphName, transformation): - self._check_contour_is_closed() - self.pen.addComponent(glyphName, transformation) + self.qCurveTo(*points) class Cu2QuPointPen(BasePointToSegmentPen): @@ -288,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 = [ 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): diff --git a/Lib/fontTools/pens/qu2cuPen.py b/Lib/fontTools/pens/qu2cuPen.py new file mode 100644 index 000000000..55dfeeff6 --- /dev/null +++ b/Lib/fontTools/pens/qu2cuPen.py @@ -0,0 +1,101 @@ +# 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_curves +from fontTools.pens.filterPen import ContourFilterPen +from fontTools.pens.reverseContourPen import ReverseContourPen + + +class Qu2CuPen(ContourFilterPen): + """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, + 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 = 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 + for curve in curves: + if len(curve) == 4: + yield ("curveTo", curve[1:]) + else: + yield ("qCurveTo", curve[1:]) + + def filterContour(self, contour): + quadratics = [] + 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 args[-1] is None: + raise NotImplementedError( + "oncurve-less contours with all_cubic not implemented" + ) + 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)) + + # Add back implicit oncurve points + contour = newContour + newContour = [] + for op, args in contour: + 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] + ): + newArgs = newContour[-1][1][:-1] + args + newContour[-1] = (op, newArgs) + continue + + newContour.append((op, args)) + + return newContour 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/__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/__main__.py b/Lib/fontTools/qu2cu/__main__.py new file mode 100644 index 000000000..27728cc7a --- /dev/null +++ b/Lib/fontTools/qu2cu/__main__.py @@ -0,0 +1,7 @@ +import sys + +from .cli import main + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Lib/fontTools/qu2cu/benchmark.py b/Lib/fontTools/qu2cu/benchmark.py new file mode 100644 index 000000000..0839ab439 --- /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=10, number=20): + 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() diff --git a/Lib/fontTools/qu2cu/cli.py b/Lib/fontTools/qu2cu/cli.py new file mode 100644 index 000000000..9a8f1df28 --- /dev/null +++ b/Lib/fontTools/qu2cu/cli.py @@ -0,0 +1,109 @@ +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, + } + + 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(preserveTopology=False) + + 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)", + ) + + 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, + ) + + for input_path, output_path in zip(options.infiles, output_paths): + _font_to_cubic(input_path, output_path, **kwargs) diff --git a/Lib/fontTools/qu2cu/qu2cu.py b/Lib/fontTools/qu2cu/qu2cu.py new file mode 100644 index 000000000..20bfaa93f --- /dev/null +++ b/Lib/fontTools/qu2cu/qu2cu.py @@ -0,0 +1,363 @@ +# 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 splitCubicAtTC +from collections import namedtuple +from typing import ( + List, + Tuple, + Union, +) + + +__all__ = ["quadratic_to_curves"] + + +if cython.compiled: + # Yep, I'm compiled. + COMPILED = True +else: + # Just a lowly interpreted script. + 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, + 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.""" + + # 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.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 + 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 + 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] + + # 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 + + +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] + + +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]] + cost = 0 + costs = [0] + for p in quads: + assert q[-1] == p[0] + 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, 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"]) + + +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 + elevated_quadratics = [ + 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 = [Solution(0, 0, 0, False)] + for i in range(1, len(elevated_quadratics) + 1): + best_sol = Solution(len(q) + 2, 0, 1, False) + for j in range(0, 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] - 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]) + 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) + + # 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])[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() diff --git a/Tests/pens/cu2quPen_test.py b/Tests/pens/cu2quPen_test.py index 682e9e1f2..2790ce028 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))", ], ) @@ -258,59 +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))", - ], - ) - - 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): diff --git a/Tests/pens/qu2cuPen_test.py b/Tests/pens/qu2cuPen_test.py new file mode 100644 index 000000000..f3f8a3f7c --- /dev/null +++ b/Tests/pens/qu2cuPen_test.py @@ -0,0 +1,224 @@ +# 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, all_cubic=True, **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_no_conversion(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.qCurveTo((0, 3), (1, 3), (1, 0))", + "pen.closePath()", + ], + ) + + 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) + 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() 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) diff --git a/Tests/qu2cu/qu2cu_test.py b/Tests/qu2cu/qu2cu_test.py new file mode 100644 index 000000000..fae240cc9 --- /dev/null +++ b/Tests/qu2cu/qu2cu_test.py @@ -0,0 +1,106 @@ +# 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 +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", + [ + ( + [ + [(0, 0), (0, 1), (2, 1), (2, 0)], + ], + [ + ((0, 0), (0, 4 / 3), (2, 4 / 3), (2, 0)), + ], + 0.1, + True, + ), + ( + [ + [(0, 0), (0, 1), (2, 1), (2, 2)], + ], + [ + ((0, 0), (0, 4 / 3), (2, 2 / 3), (2, 2)), + ], + 0.2, + True, + ), + ( + [ + [(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 + ] + + c = quadratic_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] + + def test_main(self): + # Just for coverage + qu2cu_main() + benchmark_main() diff --git a/setup.py b/setup.py index f9d7fd36e..6db1cceb4 100755 --- a/setup.py +++ b/setup.py @@ -73,6 +73,12 @@ 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"]), + ) ext_modules.append( Extension("fontTools.pens.momentsPen", ["Lib/fontTools/pens/momentsPen.py"]), )