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