xref: /aosp_15_r20/external/fonttools/Lib/fontTools/cu2qu/ufo.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1# Copyright 2015 Google Inc. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15
16"""Converts cubic bezier curves to quadratic splines.
17
18Conversion is performed such that the quadratic splines keep the same end-curve
19tangents as the original cubics. The approach is iterative, increasing the
20number of segments for a spline until the error gets below a bound.
21
22Respective curves from multiple fonts will be converted at once to ensure that
23the resulting splines are interpolation-compatible.
24"""
25
26import logging
27from fontTools.pens.basePen import AbstractPen
28from fontTools.pens.pointPen import PointToSegmentPen
29from fontTools.pens.reverseContourPen import ReverseContourPen
30
31from . import curves_to_quadratic
32from .errors import (
33    UnequalZipLengthsError,
34    IncompatibleSegmentNumberError,
35    IncompatibleSegmentTypesError,
36    IncompatibleGlyphsError,
37    IncompatibleFontsError,
38)
39
40
41__all__ = ["fonts_to_quadratic", "font_to_quadratic"]
42
43# The default approximation error below is a relative value (1/1000 of the EM square).
44# Later on, we convert it to absolute font units by multiplying it by a font's UPEM
45# (see fonts_to_quadratic).
46DEFAULT_MAX_ERR = 0.001
47CURVE_TYPE_LIB_KEY = "com.github.googlei18n.cu2qu.curve_type"
48
49logger = logging.getLogger(__name__)
50
51
52_zip = zip
53
54
55def zip(*args):
56    """Ensure each argument to zip has the same length. Also make sure a list is
57    returned for python 2/3 compatibility.
58    """
59
60    if len(set(len(a) for a in args)) != 1:
61        raise UnequalZipLengthsError(*args)
62    return list(_zip(*args))
63
64
65class GetSegmentsPen(AbstractPen):
66    """Pen to collect segments into lists of points for conversion.
67
68    Curves always include their initial on-curve point, so some points are
69    duplicated between segments.
70    """
71
72    def __init__(self):
73        self._last_pt = None
74        self.segments = []
75
76    def _add_segment(self, tag, *args):
77        if tag in ["move", "line", "qcurve", "curve"]:
78            self._last_pt = args[-1]
79        self.segments.append((tag, args))
80
81    def moveTo(self, pt):
82        self._add_segment("move", pt)
83
84    def lineTo(self, pt):
85        self._add_segment("line", pt)
86
87    def qCurveTo(self, *points):
88        self._add_segment("qcurve", self._last_pt, *points)
89
90    def curveTo(self, *points):
91        self._add_segment("curve", self._last_pt, *points)
92
93    def closePath(self):
94        self._add_segment("close")
95
96    def endPath(self):
97        self._add_segment("end")
98
99    def addComponent(self, glyphName, transformation):
100        pass
101
102
103def _get_segments(glyph):
104    """Get a glyph's segments as extracted by GetSegmentsPen."""
105
106    pen = GetSegmentsPen()
107    # glyph.draw(pen)
108    # We can't simply draw the glyph with the pen, but we must initialize the
109    # PointToSegmentPen explicitly with outputImpliedClosingLine=True.
110    # By default PointToSegmentPen does not outputImpliedClosingLine -- unless
111    # last and first point on closed contour are duplicated. Because we are
112    # converting multiple glyphs at the same time, we want to make sure
113    # this function returns the same number of segments, whether or not
114    # the last and first point overlap.
115    # https://github.com/googlefonts/fontmake/issues/572
116    # https://github.com/fonttools/fonttools/pull/1720
117    pointPen = PointToSegmentPen(pen, outputImpliedClosingLine=True)
118    glyph.drawPoints(pointPen)
119    return pen.segments
120
121
122def _set_segments(glyph, segments, reverse_direction):
123    """Draw segments as extracted by GetSegmentsPen back to a glyph."""
124
125    glyph.clearContours()
126    pen = glyph.getPen()
127    if reverse_direction:
128        pen = ReverseContourPen(pen)
129    for tag, args in segments:
130        if tag == "move":
131            pen.moveTo(*args)
132        elif tag == "line":
133            pen.lineTo(*args)
134        elif tag == "curve":
135            pen.curveTo(*args[1:])
136        elif tag == "qcurve":
137            pen.qCurveTo(*args[1:])
138        elif tag == "close":
139            pen.closePath()
140        elif tag == "end":
141            pen.endPath()
142        else:
143            raise AssertionError('Unhandled segment type "%s"' % tag)
144
145
146def _segments_to_quadratic(segments, max_err, stats, all_quadratic=True):
147    """Return quadratic approximations of cubic segments."""
148
149    assert all(s[0] == "curve" for s in segments), "Non-cubic given to convert"
150
151    new_points = curves_to_quadratic([s[1] for s in segments], max_err, all_quadratic)
152    n = len(new_points[0])
153    assert all(len(s) == n for s in new_points[1:]), "Converted incompatibly"
154
155    spline_length = str(n - 2)
156    stats[spline_length] = stats.get(spline_length, 0) + 1
157
158    if all_quadratic or n == 3:
159        return [("qcurve", p) for p in new_points]
160    else:
161        return [("curve", p) for p in new_points]
162
163
164def _glyphs_to_quadratic(glyphs, max_err, reverse_direction, stats, all_quadratic=True):
165    """Do the actual conversion of a set of compatible glyphs, after arguments
166    have been set up.
167
168    Return True if the glyphs were modified, else return False.
169    """
170
171    try:
172        segments_by_location = zip(*[_get_segments(g) for g in glyphs])
173    except UnequalZipLengthsError:
174        raise IncompatibleSegmentNumberError(glyphs)
175    if not any(segments_by_location):
176        return False
177
178    # always modify input glyphs if reverse_direction is True
179    glyphs_modified = reverse_direction
180
181    new_segments_by_location = []
182    incompatible = {}
183    for i, segments in enumerate(segments_by_location):
184        tag = segments[0][0]
185        if not all(s[0] == tag for s in segments[1:]):
186            incompatible[i] = [s[0] for s in segments]
187        elif tag == "curve":
188            new_segments = _segments_to_quadratic(
189                segments, max_err, stats, all_quadratic
190            )
191            if all_quadratic or new_segments != segments:
192                glyphs_modified = True
193            segments = new_segments
194        new_segments_by_location.append(segments)
195
196    if glyphs_modified:
197        new_segments_by_glyph = zip(*new_segments_by_location)
198        for glyph, new_segments in zip(glyphs, new_segments_by_glyph):
199            _set_segments(glyph, new_segments, reverse_direction)
200
201    if incompatible:
202        raise IncompatibleSegmentTypesError(glyphs, segments=incompatible)
203    return glyphs_modified
204
205
206def glyphs_to_quadratic(
207    glyphs, max_err=None, reverse_direction=False, stats=None, all_quadratic=True
208):
209    """Convert the curves of a set of compatible of glyphs to quadratic.
210
211    All curves will be converted to quadratic at once, ensuring interpolation
212    compatibility. If this is not required, calling glyphs_to_quadratic with one
213    glyph at a time may yield slightly more optimized results.
214
215    Return True if glyphs were modified, else return False.
216
217    Raises IncompatibleGlyphsError if glyphs have non-interpolatable outlines.
218    """
219    if stats is None:
220        stats = {}
221
222    if not max_err:
223        # assume 1000 is the default UPEM
224        max_err = DEFAULT_MAX_ERR * 1000
225
226    if isinstance(max_err, (list, tuple)):
227        max_errors = max_err
228    else:
229        max_errors = [max_err] * len(glyphs)
230    assert len(max_errors) == len(glyphs)
231
232    return _glyphs_to_quadratic(
233        glyphs, max_errors, reverse_direction, stats, all_quadratic
234    )
235
236
237def fonts_to_quadratic(
238    fonts,
239    max_err_em=None,
240    max_err=None,
241    reverse_direction=False,
242    stats=None,
243    dump_stats=False,
244    remember_curve_type=True,
245    all_quadratic=True,
246):
247    """Convert the curves of a collection of fonts to quadratic.
248
249    All curves will be converted to quadratic at once, ensuring interpolation
250    compatibility. If this is not required, calling fonts_to_quadratic with one
251    font at a time may yield slightly more optimized results.
252
253    Return True if fonts were modified, else return False.
254
255    By default, cu2qu stores the curve type in the fonts' lib, under a private
256    key "com.github.googlei18n.cu2qu.curve_type", and will not try to convert
257    them again if the curve type is already set to "quadratic".
258    Setting 'remember_curve_type' to False disables this optimization.
259
260    Raises IncompatibleFontsError if same-named glyphs from different fonts
261    have non-interpolatable outlines.
262    """
263
264    if remember_curve_type:
265        curve_types = {f.lib.get(CURVE_TYPE_LIB_KEY, "cubic") for f in fonts}
266        if len(curve_types) == 1:
267            curve_type = next(iter(curve_types))
268            if curve_type in ("quadratic", "mixed"):
269                logger.info("Curves already converted to quadratic")
270                return False
271            elif curve_type == "cubic":
272                pass  # keep converting
273            else:
274                raise NotImplementedError(curve_type)
275        elif len(curve_types) > 1:
276            # going to crash later if they do differ
277            logger.warning("fonts may contain different curve types")
278
279    if stats is None:
280        stats = {}
281
282    if max_err_em and max_err:
283        raise TypeError("Only one of max_err and max_err_em can be specified.")
284    if not (max_err_em or max_err):
285        max_err_em = DEFAULT_MAX_ERR
286
287    if isinstance(max_err, (list, tuple)):
288        assert len(max_err) == len(fonts)
289        max_errors = max_err
290    elif max_err:
291        max_errors = [max_err] * len(fonts)
292
293    if isinstance(max_err_em, (list, tuple)):
294        assert len(fonts) == len(max_err_em)
295        max_errors = [f.info.unitsPerEm * e for f, e in zip(fonts, max_err_em)]
296    elif max_err_em:
297        max_errors = [f.info.unitsPerEm * max_err_em for f in fonts]
298
299    modified = False
300    glyph_errors = {}
301    for name in set().union(*(f.keys() for f in fonts)):
302        glyphs = []
303        cur_max_errors = []
304        for font, error in zip(fonts, max_errors):
305            if name in font:
306                glyphs.append(font[name])
307                cur_max_errors.append(error)
308        try:
309            modified |= _glyphs_to_quadratic(
310                glyphs, cur_max_errors, reverse_direction, stats, all_quadratic
311            )
312        except IncompatibleGlyphsError as exc:
313            logger.error(exc)
314            glyph_errors[name] = exc
315
316    if glyph_errors:
317        raise IncompatibleFontsError(glyph_errors)
318
319    if modified and dump_stats:
320        spline_lengths = sorted(stats.keys())
321        logger.info(
322            "New spline lengths: %s"
323            % (", ".join("%s: %d" % (l, stats[l]) for l in spline_lengths))
324        )
325
326    if remember_curve_type:
327        for font in fonts:
328            curve_type = font.lib.get(CURVE_TYPE_LIB_KEY, "cubic")
329            new_curve_type = "quadratic" if all_quadratic else "mixed"
330            if curve_type != new_curve_type:
331                font.lib[CURVE_TYPE_LIB_KEY] = new_curve_type
332                modified = True
333    return modified
334
335
336def glyph_to_quadratic(glyph, **kwargs):
337    """Convenience wrapper around glyphs_to_quadratic, for just one glyph.
338    Return True if the glyph was modified, else return False.
339    """
340
341    return glyphs_to_quadratic([glyph], **kwargs)
342
343
344def font_to_quadratic(font, **kwargs):
345    """Convenience wrapper around fonts_to_quadratic, for just one font.
346    Return True if the font was modified, else return False.
347    """
348
349    return fonts_to_quadratic([font], **kwargs)
350