diff options
Diffstat (limited to 'Lib/fontTools/cu2qu/ufo.py')
-rw-r--r-- | Lib/fontTools/cu2qu/ufo.py | 324 |
1 files changed, 324 insertions, 0 deletions
diff --git a/Lib/fontTools/cu2qu/ufo.py b/Lib/fontTools/cu2qu/ufo.py new file mode 100644 index 00000000..447de7bb --- /dev/null +++ b/Lib/fontTools/cu2qu/ufo.py @@ -0,0 +1,324 @@ +# Copyright 2015 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. + + +"""Converts cubic bezier curves to quadratic splines. + +Conversion is performed such that the quadratic splines keep the same end-curve +tangents as the original cubics. The approach is iterative, increasing the +number of segments for a spline until the error gets below a bound. + +Respective curves from multiple fonts will be converted at once to ensure that +the resulting splines are interpolation-compatible. +""" + +import logging +from fontTools.pens.basePen import AbstractPen +from fontTools.pens.pointPen import PointToSegmentPen +from fontTools.pens.reverseContourPen import ReverseContourPen + +from . import curves_to_quadratic +from .errors import ( + UnequalZipLengthsError, IncompatibleSegmentNumberError, + IncompatibleSegmentTypesError, IncompatibleGlyphsError, + IncompatibleFontsError) + + +__all__ = ['fonts_to_quadratic', 'font_to_quadratic'] + +# The default approximation error below is a relative value (1/1000 of the EM square). +# Later on, we convert it to absolute font units by multiplying it by a font's UPEM +# (see fonts_to_quadratic). +DEFAULT_MAX_ERR = 0.001 +CURVE_TYPE_LIB_KEY = "com.github.googlei18n.cu2qu.curve_type" + +logger = logging.getLogger(__name__) + + +_zip = zip +def zip(*args): + """Ensure each argument to zip has the same length. Also make sure a list is + returned for python 2/3 compatibility. + """ + + if len(set(len(a) for a in args)) != 1: + raise UnequalZipLengthsError(*args) + return list(_zip(*args)) + + +class GetSegmentsPen(AbstractPen): + """Pen to collect segments into lists of points for conversion. + + Curves always include their initial on-curve point, so some points are + duplicated between segments. + """ + + def __init__(self): + self._last_pt = None + self.segments = [] + + def _add_segment(self, tag, *args): + if tag in ['move', 'line', 'qcurve', 'curve']: + self._last_pt = args[-1] + self.segments.append((tag, args)) + + def moveTo(self, pt): + self._add_segment('move', pt) + + def lineTo(self, pt): + self._add_segment('line', pt) + + def qCurveTo(self, *points): + self._add_segment('qcurve', self._last_pt, *points) + + def curveTo(self, *points): + self._add_segment('curve', self._last_pt, *points) + + def closePath(self): + self._add_segment('close') + + def endPath(self): + self._add_segment('end') + + def addComponent(self, glyphName, transformation): + pass + + +def _get_segments(glyph): + """Get a glyph's segments as extracted by GetSegmentsPen.""" + + pen = GetSegmentsPen() + # glyph.draw(pen) + # We can't simply draw the glyph with the pen, but we must initialize the + # PointToSegmentPen explicitly with outputImpliedClosingLine=True. + # By default PointToSegmentPen does not outputImpliedClosingLine -- unless + # last and first point on closed contour are duplicated. Because we are + # converting multiple glyphs at the same time, we want to make sure + # this function returns the same number of segments, whether or not + # the last and first point overlap. + # https://github.com/googlefonts/fontmake/issues/572 + # https://github.com/fonttools/fonttools/pull/1720 + pointPen = PointToSegmentPen(pen, outputImpliedClosingLine=True) + glyph.drawPoints(pointPen) + return pen.segments + + +def _set_segments(glyph, segments, reverse_direction): + """Draw segments as extracted by GetSegmentsPen back to a glyph.""" + + glyph.clearContours() + pen = glyph.getPen() + if reverse_direction: + pen = ReverseContourPen(pen) + for tag, args in segments: + if tag == 'move': + pen.moveTo(*args) + elif tag == 'line': + pen.lineTo(*args) + elif tag == 'curve': + pen.curveTo(*args[1:]) + elif tag == 'qcurve': + pen.qCurveTo(*args[1:]) + elif tag == 'close': + pen.closePath() + elif tag == 'end': + pen.endPath() + else: + raise AssertionError('Unhandled segment type "%s"' % tag) + + +def _segments_to_quadratic(segments, max_err, stats): + """Return quadratic approximations of cubic segments.""" + + assert all(s[0] == 'curve' for s in segments), 'Non-cubic given to convert' + + new_points = curves_to_quadratic([s[1] for s in segments], max_err) + n = len(new_points[0]) + assert all(len(s) == n for s in new_points[1:]), 'Converted incompatibly' + + spline_length = str(n - 2) + stats[spline_length] = stats.get(spline_length, 0) + 1 + + return [('qcurve', p) for p in new_points] + + +def _glyphs_to_quadratic(glyphs, max_err, reverse_direction, stats): + """Do the actual conversion of a set of compatible glyphs, after arguments + have been set up. + + Return True if the glyphs were modified, else return False. + """ + + try: + segments_by_location = zip(*[_get_segments(g) for g in glyphs]) + except UnequalZipLengthsError: + raise IncompatibleSegmentNumberError(glyphs) + if not any(segments_by_location): + return False + + # always modify input glyphs if reverse_direction is True + glyphs_modified = reverse_direction + + new_segments_by_location = [] + incompatible = {} + for i, segments in enumerate(segments_by_location): + tag = segments[0][0] + if not all(s[0] == tag for s in segments[1:]): + incompatible[i] = [s[0] for s in segments] + elif tag == 'curve': + segments = _segments_to_quadratic(segments, max_err, stats) + glyphs_modified = True + new_segments_by_location.append(segments) + + if glyphs_modified: + new_segments_by_glyph = zip(*new_segments_by_location) + for glyph, new_segments in zip(glyphs, new_segments_by_glyph): + _set_segments(glyph, new_segments, reverse_direction) + + if incompatible: + raise IncompatibleSegmentTypesError(glyphs, segments=incompatible) + return glyphs_modified + + +def glyphs_to_quadratic( + glyphs, max_err=None, reverse_direction=False, stats=None): + """Convert the curves of a set of compatible of glyphs to quadratic. + + All curves will be converted to quadratic at once, ensuring interpolation + compatibility. If this is not required, calling glyphs_to_quadratic with one + glyph at a time may yield slightly more optimized results. + + Return True if glyphs were modified, else return False. + + Raises IncompatibleGlyphsError if glyphs have non-interpolatable outlines. + """ + if stats is None: + stats = {} + + if not max_err: + # assume 1000 is the default UPEM + max_err = DEFAULT_MAX_ERR * 1000 + + if isinstance(max_err, (list, tuple)): + max_errors = max_err + else: + max_errors = [max_err] * len(glyphs) + assert len(max_errors) == len(glyphs) + + return _glyphs_to_quadratic(glyphs, max_errors, reverse_direction, stats) + + +def fonts_to_quadratic( + fonts, max_err_em=None, max_err=None, reverse_direction=False, + stats=None, dump_stats=False, remember_curve_type=True): + """Convert the curves of a collection of fonts to quadratic. + + All curves will be converted to quadratic at once, ensuring interpolation + compatibility. If this is not required, calling fonts_to_quadratic with one + font at a time may yield slightly more optimized results. + + Return True if fonts were modified, else return False. + + By default, cu2qu stores the curve type in the fonts' lib, under a private + key "com.github.googlei18n.cu2qu.curve_type", and will not try to convert + them again if the curve type is already set to "quadratic". + Setting 'remember_curve_type' to False disables this optimization. + + Raises IncompatibleFontsError if same-named glyphs from different fonts + have non-interpolatable outlines. + """ + + if remember_curve_type: + curve_types = {f.lib.get(CURVE_TYPE_LIB_KEY, "cubic") for f in fonts} + if len(curve_types) == 1: + curve_type = next(iter(curve_types)) + if curve_type == "quadratic": + logger.info("Curves already converted to quadratic") + return False + elif curve_type == "cubic": + pass # keep converting + else: + raise NotImplementedError(curve_type) + elif len(curve_types) > 1: + # going to crash later if they do differ + logger.warning("fonts may contain different curve types") + + if stats is None: + stats = {} + + if max_err_em and max_err: + raise TypeError('Only one of max_err and max_err_em can be specified.') + if not (max_err_em or max_err): + max_err_em = DEFAULT_MAX_ERR + + if isinstance(max_err, (list, tuple)): + assert len(max_err) == len(fonts) + max_errors = max_err + elif max_err: + max_errors = [max_err] * len(fonts) + + if isinstance(max_err_em, (list, tuple)): + assert len(fonts) == len(max_err_em) + max_errors = [f.info.unitsPerEm * e + for f, e in zip(fonts, max_err_em)] + elif max_err_em: + max_errors = [f.info.unitsPerEm * max_err_em for f in fonts] + + modified = False + glyph_errors = {} + for name in set().union(*(f.keys() for f in fonts)): + glyphs = [] + cur_max_errors = [] + for font, error in zip(fonts, max_errors): + if name in font: + glyphs.append(font[name]) + cur_max_errors.append(error) + try: + modified |= _glyphs_to_quadratic( + glyphs, cur_max_errors, reverse_direction, stats) + except IncompatibleGlyphsError as exc: + logger.error(exc) + glyph_errors[name] = exc + + if glyph_errors: + raise IncompatibleFontsError(glyph_errors) + + if modified and dump_stats: + spline_lengths = sorted(stats.keys()) + logger.info('New spline lengths: %s' % (', '.join( + '%s: %d' % (l, stats[l]) for l in spline_lengths))) + + if remember_curve_type: + for font in fonts: + curve_type = font.lib.get(CURVE_TYPE_LIB_KEY, "cubic") + if curve_type != "quadratic": + font.lib[CURVE_TYPE_LIB_KEY] = "quadratic" + modified = True + return modified + + +def glyph_to_quadratic(glyph, **kwargs): + """Convenience wrapper around glyphs_to_quadratic, for just one glyph. + Return True if the glyph was modified, else return False. + """ + + return glyphs_to_quadratic([glyph], **kwargs) + + +def font_to_quadratic(font, **kwargs): + """Convenience wrapper around fonts_to_quadratic, for just one font. + Return True if the font was modified, else return False. + """ + + return fonts_to_quadratic([font], **kwargs) |