| # 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, all_quadratic=True): |
| """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, all_quadratic) |
| 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 |
| |
| if all_quadratic or n == 3: |
| return [("qcurve", p) for p in new_points] |
| else: |
| return [("curve", p) for p in new_points] |
| |
| |
| def _glyphs_to_quadratic(glyphs, max_err, reverse_direction, stats, all_quadratic=True): |
| """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": |
| new_segments = _segments_to_quadratic( |
| segments, max_err, stats, all_quadratic |
| ) |
| if all_quadratic or new_segments != segments: |
| glyphs_modified = True |
| segments = new_segments |
| 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, all_quadratic=True |
| ): |
| """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, all_quadratic |
| ) |
| |
| |
| def fonts_to_quadratic( |
| fonts, |
| max_err_em=None, |
| max_err=None, |
| reverse_direction=False, |
| stats=None, |
| dump_stats=False, |
| remember_curve_type=True, |
| all_quadratic=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 in ("quadratic", "mixed"): |
| 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, all_quadratic |
| ) |
| 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") |
| new_curve_type = "quadratic" if all_quadratic else "mixed" |
| if curve_type != new_curve_type: |
| font.lib[CURVE_TYPE_LIB_KEY] = new_curve_type |
| 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) |