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