Spaces:
Running
Running
# 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 the set of modified glyph names if any, else return an empty set. | |
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 = set() | |
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: | |
if _glyphs_to_quadratic( | |
glyphs, cur_max_errors, reverse_direction, stats, all_quadratic | |
): | |
modified.add(name) | |
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 | |
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 the set of modified glyph names if any, else return empty set. | |
""" | |
return fonts_to_quadratic([font], **kwargs) | |