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