1""" Partially instantiate a variable font. 2 3The module exports an `instantiateVariableFont` function and CLI that allow to 4create full instances (i.e. static fonts) from variable fonts, as well as "partial" 5variable fonts that only contain a subset of the original variation space. 6 7For example, if you wish to pin the width axis to a given location while also 8restricting the weight axis to 400..700 range, you can do: 9 10$ fonttools varLib.instancer ./NotoSans-VF.ttf wdth=85 wght=400:700 11 12See `fonttools varLib.instancer --help` for more info on the CLI options. 13 14The module's entry point is the `instantiateVariableFont` function, which takes 15a TTFont object and a dict specifying either axis coodinates or (min, max) ranges, 16and returns a new TTFont representing either a partial VF, or full instance if all 17the VF axes were given an explicit coordinate. 18 19E.g. here's how to pin the wght axis at a given location in a wght+wdth variable 20font, keeping only the deltas associated with the wdth axis: 21 22| >>> from fontTools import ttLib 23| >>> from fontTools.varLib import instancer 24| >>> varfont = ttLib.TTFont("path/to/MyVariableFont.ttf") 25| >>> [a.axisTag for a in varfont["fvar"].axes] # the varfont's current axes 26| ['wght', 'wdth'] 27| >>> partial = instancer.instantiateVariableFont(varfont, {"wght": 300}) 28| >>> [a.axisTag for a in partial["fvar"].axes] # axes left after pinning 'wght' 29| ['wdth'] 30 31If the input location specifies all the axes, the resulting instance is no longer 32'variable' (same as using fontools varLib.mutator): 33 34| >>> instance = instancer.instantiateVariableFont( 35| ... varfont, {"wght": 700, "wdth": 67.5} 36| ... ) 37| >>> "fvar" not in instance 38| True 39 40If one just want to drop an axis at the default location, without knowing in 41advance what the default value for that axis is, one can pass a `None` value: 42 43| >>> instance = instancer.instantiateVariableFont(varfont, {"wght": None}) 44| >>> len(varfont["fvar"].axes) 45| 1 46 47From the console script, this is equivalent to passing `wght=drop` as input. 48 49This module is similar to fontTools.varLib.mutator, which it's intended to supersede. 50Note that, unlike varLib.mutator, when an axis is not mentioned in the input 51location, the varLib.instancer will keep the axis and the corresponding deltas, 52whereas mutator implicitly drops the axis at its default coordinate. 53 54The module currently supports only the first three "levels" of partial instancing, 55with the rest planned to be implemented in the future, namely: 56L1) dropping one or more axes while leaving the default tables unmodified; 57L2) dropping one or more axes while pinning them at non-default locations; 58L3) restricting the range of variation of one or more axes, by setting either 59 a new minimum or maximum, potentially -- though not necessarily -- dropping 60 entire regions of variations that fall completely outside this new range. 61L4) moving the default location of an axis. 62 63Currently only TrueType-flavored variable fonts (i.e. containing 'glyf' table) 64are supported, but support for CFF2 variable fonts will be added soon. 65 66The discussion and implementation of these features are tracked at 67https://github.com/fonttools/fonttools/issues/1537 68""" 69from fontTools.misc.fixedTools import ( 70 floatToFixedToFloat, 71 strToFixedToFloat, 72 otRound, 73 MAX_F2DOT14, 74) 75from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLinearMap 76from fontTools.ttLib import TTFont 77from fontTools.ttLib.tables.TupleVariation import TupleVariation 78from fontTools.ttLib.tables import _g_l_y_f 79from fontTools import varLib 80 81# we import the `subset` module because we use the `prune_lookups` method on the GSUB 82# table class, and that method is only defined dynamically upon importing `subset` 83from fontTools import subset # noqa: F401 84from fontTools.varLib import builder 85from fontTools.varLib.mvar import MVAR_ENTRIES 86from fontTools.varLib.merger import MutatorMerger 87from fontTools.varLib.instancer import names 88from contextlib import contextmanager 89import collections 90from copy import deepcopy 91from enum import IntEnum 92import logging 93from itertools import islice 94import os 95import re 96 97 98log = logging.getLogger("fontTools.varLib.instancer") 99 100 101class AxisRange(collections.namedtuple("AxisRange", "minimum maximum")): 102 def __new__(cls, *args, **kwargs): 103 self = super().__new__(cls, *args, **kwargs) 104 if self.minimum > self.maximum: 105 raise ValueError( 106 f"Range minimum ({self.minimum:g}) must be <= maximum ({self.maximum:g})" 107 ) 108 return self 109 110 def __repr__(self): 111 return f"{type(self).__name__}({self.minimum:g}, {self.maximum:g})" 112 113 114class NormalizedAxisRange(AxisRange): 115 def __new__(cls, *args, **kwargs): 116 self = super().__new__(cls, *args, **kwargs) 117 if self.minimum < -1.0 or self.maximum > 1.0: 118 raise ValueError("Axis range values must be normalized to -1..+1 range") 119 if self.minimum > 0: 120 raise ValueError(f"Expected axis range minimum <= 0; got {self.minimum}") 121 if self.maximum < 0: 122 raise ValueError(f"Expected axis range maximum >= 0; got {self.maximum}") 123 return self 124 125 126class OverlapMode(IntEnum): 127 KEEP_AND_DONT_SET_FLAGS = 0 128 KEEP_AND_SET_FLAGS = 1 129 REMOVE = 2 130 REMOVE_AND_IGNORE_ERRORS = 3 131 132 133def instantiateTupleVariationStore( 134 variations, axisLimits, origCoords=None, endPts=None 135): 136 """Instantiate TupleVariation list at the given location, or limit axes' min/max. 137 138 The 'variations' list of TupleVariation objects is modified in-place. 139 The 'axisLimits' (dict) maps axis tags (str) to either a single coordinate along the 140 axis (float), or to minimum/maximum coordinates (NormalizedAxisRange). 141 142 A 'full' instance (i.e. static font) is produced when all the axes are pinned to 143 single coordinates; a 'partial' instance (i.e. a less variable font) is produced 144 when some of the axes are omitted, or restricted with a new range. 145 146 Tuples that do not participate are kept as they are. Those that have 0 influence 147 at the given location are removed from the variation store. 148 Those that are fully instantiated (i.e. all their axes are being pinned) are also 149 removed from the variation store, their scaled deltas accummulated and returned, so 150 that they can be added by the caller to the default instance's coordinates. 151 Tuples that are only partially instantiated (i.e. not all the axes that they 152 participate in are being pinned) are kept in the store, and their deltas multiplied 153 by the scalar support of the axes to be pinned at the desired location. 154 155 Args: 156 variations: List[TupleVariation] from either 'gvar' or 'cvar'. 157 axisLimits: Dict[str, Union[float, NormalizedAxisRange]]: axes' coordinates for 158 the full or partial instance, or ranges for restricting an axis' min/max. 159 origCoords: GlyphCoordinates: default instance's coordinates for computing 'gvar' 160 inferred points (cf. table__g_l_y_f._getCoordinatesAndControls). 161 endPts: List[int]: indices of contour end points, for inferring 'gvar' deltas. 162 163 Returns: 164 List[float]: the overall delta adjustment after applicable deltas were summed. 165 """ 166 pinnedLocation, axisRanges = splitAxisLocationAndRanges( 167 axisLimits, rangeType=NormalizedAxisRange 168 ) 169 170 newVariations = variations 171 172 if pinnedLocation: 173 newVariations = pinTupleVariationAxes(variations, pinnedLocation) 174 175 if axisRanges: 176 newVariations = limitTupleVariationAxisRanges(newVariations, axisRanges) 177 178 mergedVariations = collections.OrderedDict() 179 for var in newVariations: 180 # compute inferred deltas only for gvar ('origCoords' is None for cvar) 181 if origCoords is not None: 182 var.calcInferredDeltas(origCoords, endPts) 183 184 # merge TupleVariations with overlapping "tents" 185 axes = frozenset(var.axes.items()) 186 if axes in mergedVariations: 187 mergedVariations[axes] += var 188 else: 189 mergedVariations[axes] = var 190 191 # drop TupleVariation if all axes have been pinned (var.axes.items() is empty); 192 # its deltas will be added to the default instance's coordinates 193 defaultVar = mergedVariations.pop(frozenset(), None) 194 195 for var in mergedVariations.values(): 196 var.roundDeltas() 197 variations[:] = list(mergedVariations.values()) 198 199 return defaultVar.coordinates if defaultVar is not None else [] 200 201 202def pinTupleVariationAxes(variations, location): 203 newVariations = [] 204 for var in variations: 205 # Compute the scalar support of the axes to be pinned at the desired location, 206 # excluding any axes that we are not pinning. 207 # If a TupleVariation doesn't mention an axis, it implies that the axis peak 208 # is 0 (i.e. the axis does not participate). 209 support = {axis: var.axes.pop(axis, (-1, 0, +1)) for axis in location} 210 scalar = supportScalar(location, support) 211 if scalar == 0.0: 212 # no influence, drop the TupleVariation 213 continue 214 215 var.scaleDeltas(scalar) 216 newVariations.append(var) 217 return newVariations 218 219 220def limitTupleVariationAxisRanges(variations, axisRanges): 221 for axisTag, axisRange in sorted(axisRanges.items()): 222 newVariations = [] 223 for var in variations: 224 newVariations.extend(limitTupleVariationAxisRange(var, axisTag, axisRange)) 225 variations = newVariations 226 return variations 227 228 229def _negate(*values): 230 yield from (-1 * v for v in values) 231 232 233def limitTupleVariationAxisRange(var, axisTag, axisRange): 234 if not isinstance(axisRange, NormalizedAxisRange): 235 axisRange = NormalizedAxisRange(*axisRange) 236 237 # skip when current axis is missing (i.e. doesn't participate), or when the 238 # 'tent' isn't fully on either the negative or positive side 239 lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1)) 240 if peak == 0 or lower > peak or peak > upper or (lower < 0 and upper > 0): 241 return [var] 242 243 negative = lower < 0 244 if negative: 245 if axisRange.minimum == -1.0: 246 return [var] 247 elif axisRange.minimum == 0.0: 248 return [] 249 else: 250 if axisRange.maximum == 1.0: 251 return [var] 252 elif axisRange.maximum == 0.0: 253 return [] 254 255 limit = axisRange.minimum if negative else axisRange.maximum 256 257 # Rebase axis bounds onto the new limit, which then becomes the new -1.0 or +1.0. 258 # The results are always positive, because both dividend and divisor are either 259 # all positive or all negative. 260 newLower = lower / limit 261 newPeak = peak / limit 262 newUpper = upper / limit 263 # for negative TupleVariation, swap lower and upper to simplify procedure 264 if negative: 265 newLower, newUpper = newUpper, newLower 266 267 # special case when innermost bound == peak == limit 268 if newLower == newPeak == 1.0: 269 var.axes[axisTag] = (-1.0, -1.0, -1.0) if negative else (1.0, 1.0, 1.0) 270 return [var] 271 272 # case 1: the whole deltaset falls outside the new limit; we can drop it 273 elif newLower >= 1.0: 274 return [] 275 276 # case 2: only the peak and outermost bound fall outside the new limit; 277 # we keep the deltaset, update peak and outermost bound and and scale deltas 278 # by the scalar value for the restricted axis at the new limit. 279 elif newPeak >= 1.0: 280 scalar = supportScalar({axisTag: limit}, {axisTag: (lower, peak, upper)}) 281 var.scaleDeltas(scalar) 282 newPeak = 1.0 283 newUpper = 1.0 284 if negative: 285 newLower, newPeak, newUpper = _negate(newUpper, newPeak, newLower) 286 var.axes[axisTag] = (newLower, newPeak, newUpper) 287 return [var] 288 289 # case 3: peak falls inside but outermost limit still fits within F2Dot14 bounds; 290 # we keep deltas as is and only scale the axes bounds. Deltas beyond -1.0 291 # or +1.0 will never be applied as implementations must clamp to that range. 292 elif newUpper <= 2.0: 293 if negative: 294 newLower, newPeak, newUpper = _negate(newUpper, newPeak, newLower) 295 elif MAX_F2DOT14 < newUpper <= 2.0: 296 # we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience 297 newUpper = MAX_F2DOT14 298 var.axes[axisTag] = (newLower, newPeak, newUpper) 299 return [var] 300 301 # case 4: new limit doesn't fit; we need to chop the deltaset into two 'tents', 302 # because the shape of a triangle with part of one side cut off cannot be 303 # represented as a triangle itself. It can be represented as sum of two triangles. 304 # NOTE: This increases the file size! 305 else: 306 # duplicate the tent, then adjust lower/peak/upper so that the outermost limit 307 # of the original tent is +/-2.0, whereas the new tent's starts as the old 308 # one peaks and maxes out at +/-1.0. 309 newVar = TupleVariation(var.axes, var.coordinates) 310 if negative: 311 var.axes[axisTag] = (-2.0, -1 * newPeak, -1 * newLower) 312 newVar.axes[axisTag] = (-1.0, -1.0, -1 * newPeak) 313 else: 314 var.axes[axisTag] = (newLower, newPeak, MAX_F2DOT14) 315 newVar.axes[axisTag] = (newPeak, 1.0, 1.0) 316 # the new tent's deltas are scaled by the difference between the scalar value 317 # for the old tent at the desired limit... 318 scalar1 = supportScalar({axisTag: limit}, {axisTag: (lower, peak, upper)}) 319 # ... and the scalar value for the clamped tent (with outer limit +/-2.0), 320 # which can be simplified like this: 321 scalar2 = 1 / (2 - newPeak) 322 newVar.scaleDeltas(scalar1 - scalar2) 323 324 return [var, newVar] 325 326 327def _instantiateGvarGlyph(glyphname, glyf, gvar, hMetrics, vMetrics, axisLimits, optimize=True): 328 coordinates, ctrl = glyf._getCoordinatesAndControls(glyphname, hMetrics, vMetrics) 329 endPts = ctrl.endPts 330 331 # Not every glyph may have variations 332 tupleVarStore = gvar.variations.get(glyphname) 333 334 if tupleVarStore: 335 defaultDeltas = instantiateTupleVariationStore( 336 tupleVarStore, axisLimits, coordinates, endPts 337 ) 338 339 if defaultDeltas: 340 coordinates += _g_l_y_f.GlyphCoordinates(defaultDeltas) 341 342 # _setCoordinates also sets the hmtx/vmtx advance widths and sidebearings from 343 # the four phantom points and glyph bounding boxes. 344 # We call it unconditionally even if a glyph has no variations or no deltas are 345 # applied at this location, in case the glyph's xMin and in turn its sidebearing 346 # have changed. E.g. a composite glyph has no deltas for the component's (x, y) 347 # offset nor for the 4 phantom points (e.g. it's monospaced). Thus its entry in 348 # gvar table is empty; however, the composite's base glyph may have deltas 349 # applied, hence the composite's bbox and left/top sidebearings may need updating 350 # in the instanced font. 351 glyf._setCoordinates(glyphname, coordinates, hMetrics, vMetrics) 352 353 if not tupleVarStore: 354 if glyphname in gvar.variations: 355 del gvar.variations[glyphname] 356 return 357 358 if optimize: 359 isComposite = glyf[glyphname].isComposite() 360 for var in tupleVarStore: 361 var.optimize(coordinates, endPts, isComposite) 362 363def instantiateGvarGlyph(varfont, glyphname, axisLimits, optimize=True): 364 """Remove? 365 https://github.com/fonttools/fonttools/pull/2266""" 366 gvar = varfont["gvar"] 367 glyf = varfont["glyf"] 368 hMetrics = varfont['hmtx'].metrics 369 vMetrics = getattr(varfont.get('vmtx'), 'metrics', None) 370 _instantiateGvarGlyph(glyphname, glyf, gvar, hMetrics, vMetrics, axisLimits, optimize=optimize) 371 372def instantiateGvar(varfont, axisLimits, optimize=True): 373 log.info("Instantiating glyf/gvar tables") 374 375 gvar = varfont["gvar"] 376 glyf = varfont["glyf"] 377 hMetrics = varfont['hmtx'].metrics 378 vMetrics = getattr(varfont.get('vmtx'), 'metrics', None) 379 # Get list of glyph names sorted by component depth. 380 # If a composite glyph is processed before its base glyph, the bounds may 381 # be calculated incorrectly because deltas haven't been applied to the 382 # base glyph yet. 383 glyphnames = sorted( 384 glyf.glyphOrder, 385 key=lambda name: ( 386 glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth 387 if glyf[name].isComposite() 388 else 0, 389 name, 390 ), 391 ) 392 for glyphname in glyphnames: 393 _instantiateGvarGlyph(glyphname, glyf, gvar, hMetrics, vMetrics, axisLimits, optimize=optimize) 394 395 if not gvar.variations: 396 del varfont["gvar"] 397 398 399def setCvarDeltas(cvt, deltas): 400 for i, delta in enumerate(deltas): 401 if delta: 402 cvt[i] += otRound(delta) 403 404 405def instantiateCvar(varfont, axisLimits): 406 log.info("Instantiating cvt/cvar tables") 407 408 cvar = varfont["cvar"] 409 410 defaultDeltas = instantiateTupleVariationStore(cvar.variations, axisLimits) 411 412 if defaultDeltas: 413 setCvarDeltas(varfont["cvt "], defaultDeltas) 414 415 if not cvar.variations: 416 del varfont["cvar"] 417 418 419def setMvarDeltas(varfont, deltas): 420 mvar = varfont["MVAR"].table 421 records = mvar.ValueRecord 422 for rec in records: 423 mvarTag = rec.ValueTag 424 if mvarTag not in MVAR_ENTRIES: 425 continue 426 tableTag, itemName = MVAR_ENTRIES[mvarTag] 427 delta = deltas[rec.VarIdx] 428 if delta != 0: 429 setattr( 430 varfont[tableTag], 431 itemName, 432 getattr(varfont[tableTag], itemName) + otRound(delta), 433 ) 434 435 436def instantiateMVAR(varfont, axisLimits): 437 log.info("Instantiating MVAR table") 438 439 mvar = varfont["MVAR"].table 440 fvarAxes = varfont["fvar"].axes 441 varStore = mvar.VarStore 442 defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits) 443 setMvarDeltas(varfont, defaultDeltas) 444 445 if varStore.VarRegionList.Region: 446 varIndexMapping = varStore.optimize() 447 for rec in mvar.ValueRecord: 448 rec.VarIdx = varIndexMapping[rec.VarIdx] 449 else: 450 del varfont["MVAR"] 451 452 453def _remapVarIdxMap(table, attrName, varIndexMapping, glyphOrder): 454 oldMapping = getattr(table, attrName).mapping 455 newMapping = [varIndexMapping[oldMapping[glyphName]] for glyphName in glyphOrder] 456 setattr(table, attrName, builder.buildVarIdxMap(newMapping, glyphOrder)) 457 458 459# TODO(anthrotype) Add support for HVAR/VVAR in CFF2 460def _instantiateVHVAR(varfont, axisLimits, tableFields): 461 tableTag = tableFields.tableTag 462 fvarAxes = varfont["fvar"].axes 463 # Deltas from gvar table have already been applied to the hmtx/vmtx. For full 464 # instances (i.e. all axes pinned), we can simply drop HVAR/VVAR and return 465 if set( 466 axisTag for axisTag, value in axisLimits.items() if not isinstance(value, tuple) 467 ).issuperset(axis.axisTag for axis in fvarAxes): 468 log.info("Dropping %s table", tableTag) 469 del varfont[tableTag] 470 return 471 472 log.info("Instantiating %s table", tableTag) 473 vhvar = varfont[tableTag].table 474 varStore = vhvar.VarStore 475 # since deltas were already applied, the return value here is ignored 476 instantiateItemVariationStore(varStore, fvarAxes, axisLimits) 477 478 if varStore.VarRegionList.Region: 479 # Only re-optimize VarStore if the HVAR/VVAR already uses indirect AdvWidthMap 480 # or AdvHeightMap. If a direct, implicit glyphID->VariationIndex mapping is 481 # used for advances, skip re-optimizing and maintain original VariationIndex. 482 if getattr(vhvar, tableFields.advMapping): 483 varIndexMapping = varStore.optimize() 484 glyphOrder = varfont.getGlyphOrder() 485 _remapVarIdxMap(vhvar, tableFields.advMapping, varIndexMapping, glyphOrder) 486 if getattr(vhvar, tableFields.sb1): # left or top sidebearings 487 _remapVarIdxMap(vhvar, tableFields.sb1, varIndexMapping, glyphOrder) 488 if getattr(vhvar, tableFields.sb2): # right or bottom sidebearings 489 _remapVarIdxMap(vhvar, tableFields.sb2, varIndexMapping, glyphOrder) 490 if tableTag == "VVAR" and getattr(vhvar, tableFields.vOrigMapping): 491 _remapVarIdxMap( 492 vhvar, tableFields.vOrigMapping, varIndexMapping, glyphOrder 493 ) 494 495 496def instantiateHVAR(varfont, axisLimits): 497 return _instantiateVHVAR(varfont, axisLimits, varLib.HVAR_FIELDS) 498 499 500def instantiateVVAR(varfont, axisLimits): 501 return _instantiateVHVAR(varfont, axisLimits, varLib.VVAR_FIELDS) 502 503 504class _TupleVarStoreAdapter(object): 505 def __init__(self, regions, axisOrder, tupleVarData, itemCounts): 506 self.regions = regions 507 self.axisOrder = axisOrder 508 self.tupleVarData = tupleVarData 509 self.itemCounts = itemCounts 510 511 @classmethod 512 def fromItemVarStore(cls, itemVarStore, fvarAxes): 513 axisOrder = [axis.axisTag for axis in fvarAxes] 514 regions = [ 515 region.get_support(fvarAxes) for region in itemVarStore.VarRegionList.Region 516 ] 517 tupleVarData = [] 518 itemCounts = [] 519 for varData in itemVarStore.VarData: 520 variations = [] 521 varDataRegions = (regions[i] for i in varData.VarRegionIndex) 522 for axes, coordinates in zip(varDataRegions, zip(*varData.Item)): 523 variations.append(TupleVariation(axes, list(coordinates))) 524 tupleVarData.append(variations) 525 itemCounts.append(varData.ItemCount) 526 return cls(regions, axisOrder, tupleVarData, itemCounts) 527 528 def rebuildRegions(self): 529 # Collect the set of all unique region axes from the current TupleVariations. 530 # We use an OrderedDict to de-duplicate regions while keeping the order. 531 uniqueRegions = collections.OrderedDict.fromkeys( 532 ( 533 frozenset(var.axes.items()) 534 for variations in self.tupleVarData 535 for var in variations 536 ) 537 ) 538 # Maintain the original order for the regions that pre-existed, appending 539 # the new regions at the end of the region list. 540 newRegions = [] 541 for region in self.regions: 542 regionAxes = frozenset(region.items()) 543 if regionAxes in uniqueRegions: 544 newRegions.append(region) 545 del uniqueRegions[regionAxes] 546 if uniqueRegions: 547 newRegions.extend(dict(region) for region in uniqueRegions) 548 self.regions = newRegions 549 550 def instantiate(self, axisLimits): 551 defaultDeltaArray = [] 552 for variations, itemCount in zip(self.tupleVarData, self.itemCounts): 553 defaultDeltas = instantiateTupleVariationStore(variations, axisLimits) 554 if not defaultDeltas: 555 defaultDeltas = [0] * itemCount 556 defaultDeltaArray.append(defaultDeltas) 557 558 # rebuild regions whose axes were dropped or limited 559 self.rebuildRegions() 560 561 pinnedAxes = { 562 axisTag 563 for axisTag, value in axisLimits.items() 564 if not isinstance(value, tuple) 565 } 566 self.axisOrder = [ 567 axisTag for axisTag in self.axisOrder if axisTag not in pinnedAxes 568 ] 569 570 return defaultDeltaArray 571 572 def asItemVarStore(self): 573 regionOrder = [frozenset(axes.items()) for axes in self.regions] 574 varDatas = [] 575 for variations, itemCount in zip(self.tupleVarData, self.itemCounts): 576 if variations: 577 assert len(variations[0].coordinates) == itemCount 578 varRegionIndices = [ 579 regionOrder.index(frozenset(var.axes.items())) for var in variations 580 ] 581 varDataItems = list(zip(*(var.coordinates for var in variations))) 582 varDatas.append( 583 builder.buildVarData(varRegionIndices, varDataItems, optimize=False) 584 ) 585 else: 586 varDatas.append( 587 builder.buildVarData([], [[] for _ in range(itemCount)]) 588 ) 589 regionList = builder.buildVarRegionList(self.regions, self.axisOrder) 590 itemVarStore = builder.buildVarStore(regionList, varDatas) 591 # remove unused regions from VarRegionList 592 itemVarStore.prune_regions() 593 return itemVarStore 594 595 596def instantiateItemVariationStore(itemVarStore, fvarAxes, axisLimits): 597 """Compute deltas at partial location, and update varStore in-place. 598 599 Remove regions in which all axes were instanced, or fall outside the new axis 600 limits. Scale the deltas of the remaining regions where only some of the axes 601 were instanced. 602 603 The number of VarData subtables, and the number of items within each, are 604 not modified, in order to keep the existing VariationIndex valid. 605 One may call VarStore.optimize() method after this to further optimize those. 606 607 Args: 608 varStore: An otTables.VarStore object (Item Variation Store) 609 fvarAxes: list of fvar's Axis objects 610 axisLimits: Dict[str, float] mapping axis tags to normalized axis coordinates 611 (float) or ranges for restricting an axis' min/max (NormalizedAxisRange). 612 May not specify coordinates/ranges for all the fvar axes. 613 614 Returns: 615 defaultDeltas: to be added to the default instance, of type dict of floats 616 keyed by VariationIndex compound values: i.e. (outer << 16) + inner. 617 """ 618 tupleVarStore = _TupleVarStoreAdapter.fromItemVarStore(itemVarStore, fvarAxes) 619 defaultDeltaArray = tupleVarStore.instantiate(axisLimits) 620 newItemVarStore = tupleVarStore.asItemVarStore() 621 622 itemVarStore.VarRegionList = newItemVarStore.VarRegionList 623 assert itemVarStore.VarDataCount == newItemVarStore.VarDataCount 624 itemVarStore.VarData = newItemVarStore.VarData 625 626 defaultDeltas = { 627 ((major << 16) + minor): delta 628 for major, deltas in enumerate(defaultDeltaArray) 629 for minor, delta in enumerate(deltas) 630 } 631 return defaultDeltas 632 633 634def instantiateOTL(varfont, axisLimits): 635 # TODO(anthrotype) Support partial instancing of JSTF and BASE tables 636 637 if ( 638 "GDEF" not in varfont 639 or varfont["GDEF"].table.Version < 0x00010003 640 or not varfont["GDEF"].table.VarStore 641 ): 642 return 643 644 if "GPOS" in varfont: 645 msg = "Instantiating GDEF and GPOS tables" 646 else: 647 msg = "Instantiating GDEF table" 648 log.info(msg) 649 650 gdef = varfont["GDEF"].table 651 varStore = gdef.VarStore 652 fvarAxes = varfont["fvar"].axes 653 654 defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits) 655 656 # When VF are built, big lookups may overflow and be broken into multiple 657 # subtables. MutatorMerger (which inherits from AligningMerger) reattaches 658 # them upon instancing, in case they can now fit a single subtable (if not, 659 # they will be split again upon compilation). 660 # This 'merger' also works as a 'visitor' that traverses the OTL tables and 661 # calls specific methods when instances of a given type are found. 662 # Specifically, it adds default deltas to GPOS Anchors/ValueRecords and GDEF 663 # LigatureCarets, and optionally deletes all VariationIndex tables if the 664 # VarStore is fully instanced. 665 merger = MutatorMerger( 666 varfont, defaultDeltas, deleteVariations=(not varStore.VarRegionList.Region) 667 ) 668 merger.mergeTables(varfont, [varfont], ["GDEF", "GPOS"]) 669 670 if varStore.VarRegionList.Region: 671 varIndexMapping = varStore.optimize() 672 gdef.remap_device_varidxes(varIndexMapping) 673 if "GPOS" in varfont: 674 varfont["GPOS"].table.remap_device_varidxes(varIndexMapping) 675 else: 676 # Downgrade GDEF. 677 del gdef.VarStore 678 gdef.Version = 0x00010002 679 if gdef.MarkGlyphSetsDef is None: 680 del gdef.MarkGlyphSetsDef 681 gdef.Version = 0x00010000 682 683 if not ( 684 gdef.LigCaretList 685 or gdef.MarkAttachClassDef 686 or gdef.GlyphClassDef 687 or gdef.AttachList 688 or (gdef.Version >= 0x00010002 and gdef.MarkGlyphSetsDef) 689 ): 690 del varfont["GDEF"] 691 692 693def instantiateFeatureVariations(varfont, axisLimits): 694 for tableTag in ("GPOS", "GSUB"): 695 if tableTag not in varfont or not getattr( 696 varfont[tableTag].table, "FeatureVariations", None 697 ): 698 continue 699 log.info("Instantiating FeatureVariations of %s table", tableTag) 700 _instantiateFeatureVariations( 701 varfont[tableTag].table, varfont["fvar"].axes, axisLimits 702 ) 703 # remove unreferenced lookups 704 varfont[tableTag].prune_lookups() 705 706 707def _featureVariationRecordIsUnique(rec, seen): 708 conditionSet = [] 709 for cond in rec.ConditionSet.ConditionTable: 710 if cond.Format != 1: 711 # can't tell whether this is duplicate, assume is unique 712 return True 713 conditionSet.append( 714 (cond.AxisIndex, cond.FilterRangeMinValue, cond.FilterRangeMaxValue) 715 ) 716 # besides the set of conditions, we also include the FeatureTableSubstitution 717 # version to identify unique FeatureVariationRecords, even though only one 718 # version is currently defined. It's theoretically possible that multiple 719 # records with same conditions but different substitution table version be 720 # present in the same font for backward compatibility. 721 recordKey = frozenset([rec.FeatureTableSubstitution.Version] + conditionSet) 722 if recordKey in seen: 723 return False 724 else: 725 seen.add(recordKey) # side effect 726 return True 727 728 729def _limitFeatureVariationConditionRange(condition, axisRange): 730 minValue = condition.FilterRangeMinValue 731 maxValue = condition.FilterRangeMaxValue 732 733 if ( 734 minValue > maxValue 735 or minValue > axisRange.maximum 736 or maxValue < axisRange.minimum 737 ): 738 # condition invalid or out of range 739 return 740 741 values = [minValue, maxValue] 742 for i, value in enumerate(values): 743 if value < 0: 744 if axisRange.minimum == 0: 745 newValue = 0 746 else: 747 newValue = value / abs(axisRange.minimum) 748 if newValue <= -1.0: 749 newValue = -1.0 750 elif value > 0: 751 if axisRange.maximum == 0: 752 newValue = 0 753 else: 754 newValue = value / axisRange.maximum 755 if newValue >= 1.0: 756 newValue = 1.0 757 else: 758 newValue = 0 759 values[i] = newValue 760 761 return AxisRange(*values) 762 763 764def _instantiateFeatureVariationRecord( 765 record, recIdx, location, fvarAxes, axisIndexMap 766): 767 applies = True 768 newConditions = [] 769 for i, condition in enumerate(record.ConditionSet.ConditionTable): 770 if condition.Format == 1: 771 axisIdx = condition.AxisIndex 772 axisTag = fvarAxes[axisIdx].axisTag 773 if axisTag in location: 774 minValue = condition.FilterRangeMinValue 775 maxValue = condition.FilterRangeMaxValue 776 v = location[axisTag] 777 if not (minValue <= v <= maxValue): 778 # condition not met so remove entire record 779 applies = False 780 newConditions = None 781 break 782 else: 783 # axis not pinned, keep condition with remapped axis index 784 applies = False 785 condition.AxisIndex = axisIndexMap[axisTag] 786 newConditions.append(condition) 787 else: 788 log.warning( 789 "Condition table {0} of FeatureVariationRecord {1} has " 790 "unsupported format ({2}); ignored".format(i, recIdx, condition.Format) 791 ) 792 applies = False 793 newConditions.append(condition) 794 795 if newConditions: 796 record.ConditionSet.ConditionTable = newConditions 797 shouldKeep = True 798 else: 799 shouldKeep = False 800 801 return applies, shouldKeep 802 803 804def _limitFeatureVariationRecord(record, axisRanges, fvarAxes): 805 newConditions = [] 806 for i, condition in enumerate(record.ConditionSet.ConditionTable): 807 if condition.Format == 1: 808 axisIdx = condition.AxisIndex 809 axisTag = fvarAxes[axisIdx].axisTag 810 if axisTag in axisRanges: 811 axisRange = axisRanges[axisTag] 812 newRange = _limitFeatureVariationConditionRange(condition, axisRange) 813 if newRange: 814 # keep condition with updated limits and remapped axis index 815 condition.FilterRangeMinValue = newRange.minimum 816 condition.FilterRangeMaxValue = newRange.maximum 817 newConditions.append(condition) 818 else: 819 # condition out of range, remove entire record 820 newConditions = None 821 break 822 else: 823 newConditions.append(condition) 824 else: 825 newConditions.append(condition) 826 827 if newConditions: 828 record.ConditionSet.ConditionTable = newConditions 829 shouldKeep = True 830 else: 831 shouldKeep = False 832 833 return shouldKeep 834 835 836def _instantiateFeatureVariations(table, fvarAxes, axisLimits): 837 location, axisRanges = splitAxisLocationAndRanges( 838 axisLimits, rangeType=NormalizedAxisRange 839 ) 840 pinnedAxes = set(location.keys()) 841 axisOrder = [axis.axisTag for axis in fvarAxes if axis.axisTag not in pinnedAxes] 842 axisIndexMap = {axisTag: axisOrder.index(axisTag) for axisTag in axisOrder} 843 844 featureVariationApplied = False 845 uniqueRecords = set() 846 newRecords = [] 847 848 for i, record in enumerate(table.FeatureVariations.FeatureVariationRecord): 849 applies, shouldKeep = _instantiateFeatureVariationRecord( 850 record, i, location, fvarAxes, axisIndexMap 851 ) 852 if shouldKeep: 853 shouldKeep = _limitFeatureVariationRecord(record, axisRanges, fvarAxes) 854 855 if shouldKeep and _featureVariationRecordIsUnique(record, uniqueRecords): 856 newRecords.append(record) 857 858 if applies and not featureVariationApplied: 859 assert record.FeatureTableSubstitution.Version == 0x00010000 860 for rec in record.FeatureTableSubstitution.SubstitutionRecord: 861 table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = rec.Feature 862 # Set variations only once 863 featureVariationApplied = True 864 865 if newRecords: 866 table.FeatureVariations.FeatureVariationRecord = newRecords 867 table.FeatureVariations.FeatureVariationCount = len(newRecords) 868 else: 869 del table.FeatureVariations 870 871 872def _isValidAvarSegmentMap(axisTag, segmentMap): 873 if not segmentMap: 874 return True 875 if not {(-1.0, -1.0), (0, 0), (1.0, 1.0)}.issubset(segmentMap.items()): 876 log.warning( 877 f"Invalid avar SegmentMap record for axis '{axisTag}': does not " 878 "include all required value maps {-1.0: -1.0, 0: 0, 1.0: 1.0}" 879 ) 880 return False 881 previousValue = None 882 for fromCoord, toCoord in sorted(segmentMap.items()): 883 if previousValue is not None and previousValue > toCoord: 884 log.warning( 885 f"Invalid avar AxisValueMap({fromCoord}, {toCoord}) record " 886 f"for axis '{axisTag}': the toCoordinate value must be >= to " 887 f"the toCoordinate value of the preceding record ({previousValue})." 888 ) 889 return False 890 previousValue = toCoord 891 return True 892 893 894def instantiateAvar(varfont, axisLimits): 895 # 'axisLimits' dict must contain user-space (non-normalized) coordinates. 896 897 location, axisRanges = splitAxisLocationAndRanges(axisLimits) 898 899 segments = varfont["avar"].segments 900 901 # drop table if we instantiate all the axes 902 pinnedAxes = set(location.keys()) 903 if pinnedAxes.issuperset(segments): 904 log.info("Dropping avar table") 905 del varfont["avar"] 906 return 907 908 log.info("Instantiating avar table") 909 for axis in pinnedAxes: 910 if axis in segments: 911 del segments[axis] 912 913 # First compute the default normalization for axisRanges coordinates: i.e. 914 # min = -1.0, default = 0, max = +1.0, and in between values interpolated linearly, 915 # without using the avar table's mappings. 916 # Then, for each SegmentMap, if we are restricting its axis, compute the new 917 # mappings by dividing the key/value pairs by the desired new min/max values, 918 # dropping any mappings that fall outside the restricted range. 919 # The keys ('fromCoord') are specified in default normalized coordinate space, 920 # whereas the values ('toCoord') are "mapped forward" using the SegmentMap. 921 normalizedRanges = normalizeAxisLimits(varfont, axisRanges, usingAvar=False) 922 newSegments = {} 923 for axisTag, mapping in segments.items(): 924 if not _isValidAvarSegmentMap(axisTag, mapping): 925 continue 926 if mapping and axisTag in normalizedRanges: 927 axisRange = normalizedRanges[axisTag] 928 mappedMin = floatToFixedToFloat( 929 piecewiseLinearMap(axisRange.minimum, mapping), 14 930 ) 931 mappedMax = floatToFixedToFloat( 932 piecewiseLinearMap(axisRange.maximum, mapping), 14 933 ) 934 newMapping = {} 935 for fromCoord, toCoord in mapping.items(): 936 if fromCoord < 0: 937 if axisRange.minimum == 0 or fromCoord < axisRange.minimum: 938 continue 939 else: 940 fromCoord /= abs(axisRange.minimum) 941 elif fromCoord > 0: 942 if axisRange.maximum == 0 or fromCoord > axisRange.maximum: 943 continue 944 else: 945 fromCoord /= axisRange.maximum 946 if toCoord < 0: 947 assert mappedMin != 0 948 assert toCoord >= mappedMin 949 toCoord /= abs(mappedMin) 950 elif toCoord > 0: 951 assert mappedMax != 0 952 assert toCoord <= mappedMax 953 toCoord /= mappedMax 954 fromCoord = floatToFixedToFloat(fromCoord, 14) 955 toCoord = floatToFixedToFloat(toCoord, 14) 956 newMapping[fromCoord] = toCoord 957 newMapping.update({-1.0: -1.0, 1.0: 1.0}) 958 newSegments[axisTag] = newMapping 959 else: 960 newSegments[axisTag] = mapping 961 varfont["avar"].segments = newSegments 962 963 964def isInstanceWithinAxisRanges(location, axisRanges): 965 for axisTag, coord in location.items(): 966 if axisTag in axisRanges: 967 axisRange = axisRanges[axisTag] 968 if coord < axisRange.minimum or coord > axisRange.maximum: 969 return False 970 return True 971 972 973def instantiateFvar(varfont, axisLimits): 974 # 'axisLimits' dict must contain user-space (non-normalized) coordinates 975 976 location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) 977 978 fvar = varfont["fvar"] 979 980 # drop table if we instantiate all the axes 981 if set(location).issuperset(axis.axisTag for axis in fvar.axes): 982 log.info("Dropping fvar table") 983 del varfont["fvar"] 984 return 985 986 log.info("Instantiating fvar table") 987 988 axes = [] 989 for axis in fvar.axes: 990 axisTag = axis.axisTag 991 if axisTag in location: 992 continue 993 if axisTag in axisRanges: 994 axis.minValue, axis.maxValue = axisRanges[axisTag] 995 axes.append(axis) 996 fvar.axes = axes 997 998 # only keep NamedInstances whose coordinates == pinned axis location 999 instances = [] 1000 for instance in fvar.instances: 1001 if any(instance.coordinates[axis] != value for axis, value in location.items()): 1002 continue 1003 for axisTag in location: 1004 del instance.coordinates[axisTag] 1005 if not isInstanceWithinAxisRanges(instance.coordinates, axisRanges): 1006 continue 1007 instances.append(instance) 1008 fvar.instances = instances 1009 1010 1011def instantiateSTAT(varfont, axisLimits): 1012 # 'axisLimits' dict must contain user-space (non-normalized) coordinates 1013 1014 stat = varfont["STAT"].table 1015 if not stat.DesignAxisRecord or not ( 1016 stat.AxisValueArray and stat.AxisValueArray.AxisValue 1017 ): 1018 return # STAT table empty, nothing to do 1019 1020 log.info("Instantiating STAT table") 1021 newAxisValueTables = axisValuesFromAxisLimits(stat, axisLimits) 1022 stat.AxisValueArray.AxisValue = newAxisValueTables 1023 stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) 1024 1025 1026def axisValuesFromAxisLimits(stat, axisLimits): 1027 location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) 1028 1029 def isAxisValueOutsideLimits(axisTag, axisValue): 1030 if axisTag in location and axisValue != location[axisTag]: 1031 return True 1032 elif axisTag in axisRanges: 1033 axisRange = axisRanges[axisTag] 1034 if axisValue < axisRange.minimum or axisValue > axisRange.maximum: 1035 return True 1036 return False 1037 1038 # only keep AxisValues whose axis is not pinned nor restricted, or is pinned at the 1039 # exact (nominal) value, or is restricted but the value is within the new range 1040 designAxes = stat.DesignAxisRecord.Axis 1041 newAxisValueTables = [] 1042 for axisValueTable in stat.AxisValueArray.AxisValue: 1043 axisValueFormat = axisValueTable.Format 1044 if axisValueFormat in (1, 2, 3): 1045 axisTag = designAxes[axisValueTable.AxisIndex].AxisTag 1046 if axisValueFormat == 2: 1047 axisValue = axisValueTable.NominalValue 1048 else: 1049 axisValue = axisValueTable.Value 1050 if isAxisValueOutsideLimits(axisTag, axisValue): 1051 continue 1052 elif axisValueFormat == 4: 1053 # drop 'non-analytic' AxisValue if _any_ AxisValueRecord doesn't match 1054 # the pinned location or is outside range 1055 dropAxisValueTable = False 1056 for rec in axisValueTable.AxisValueRecord: 1057 axisTag = designAxes[rec.AxisIndex].AxisTag 1058 axisValue = rec.Value 1059 if isAxisValueOutsideLimits(axisTag, axisValue): 1060 dropAxisValueTable = True 1061 break 1062 if dropAxisValueTable: 1063 continue 1064 else: 1065 log.warning("Unknown AxisValue table format (%s); ignored", axisValueFormat) 1066 newAxisValueTables.append(axisValueTable) 1067 return newAxisValueTables 1068 1069 1070def setMacOverlapFlags(glyfTable): 1071 flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND 1072 flagOverlapSimple = _g_l_y_f.flagOverlapSimple 1073 for glyphName in glyfTable.keys(): 1074 glyph = glyfTable[glyphName] 1075 # Set OVERLAP_COMPOUND bit for compound glyphs 1076 if glyph.isComposite(): 1077 glyph.components[0].flags |= flagOverlapCompound 1078 # Set OVERLAP_SIMPLE bit for simple glyphs 1079 elif glyph.numberOfContours > 0: 1080 glyph.flags[0] |= flagOverlapSimple 1081 1082 1083def normalize(value, triple, avarMapping): 1084 value = normalizeValue(value, triple) 1085 if avarMapping: 1086 value = piecewiseLinearMap(value, avarMapping) 1087 # Quantize to F2Dot14, to avoid surprise interpolations. 1088 return floatToFixedToFloat(value, 14) 1089 1090 1091def normalizeAxisLimits(varfont, axisLimits, usingAvar=True): 1092 fvar = varfont["fvar"] 1093 badLimits = set(axisLimits.keys()).difference(a.axisTag for a in fvar.axes) 1094 if badLimits: 1095 raise ValueError("Cannot limit: {} not present in fvar".format(badLimits)) 1096 1097 axes = { 1098 a.axisTag: (a.minValue, a.defaultValue, a.maxValue) 1099 for a in fvar.axes 1100 if a.axisTag in axisLimits 1101 } 1102 1103 avarSegments = {} 1104 if usingAvar and "avar" in varfont: 1105 avarSegments = varfont["avar"].segments 1106 1107 for axis_tag, (_, default, _) in axes.items(): 1108 value = axisLimits[axis_tag] 1109 if isinstance(value, tuple): 1110 minV, maxV = value 1111 if minV > default or maxV < default: 1112 raise NotImplementedError( 1113 f"Unsupported range {axis_tag}={minV:g}:{maxV:g}; " 1114 f"can't change default position ({axis_tag}={default:g})" 1115 ) 1116 1117 normalizedLimits = {} 1118 for axis_tag, triple in axes.items(): 1119 avarMapping = avarSegments.get(axis_tag, None) 1120 value = axisLimits[axis_tag] 1121 if isinstance(value, tuple): 1122 normalizedLimits[axis_tag] = NormalizedAxisRange( 1123 *(normalize(v, triple, avarMapping) for v in value) 1124 ) 1125 else: 1126 normalizedLimits[axis_tag] = normalize(value, triple, avarMapping) 1127 return normalizedLimits 1128 1129 1130def sanityCheckVariableTables(varfont): 1131 if "fvar" not in varfont: 1132 raise ValueError("Missing required table fvar") 1133 if "gvar" in varfont: 1134 if "glyf" not in varfont: 1135 raise ValueError("Can't have gvar without glyf") 1136 # TODO(anthrotype) Remove once we do support partial instancing CFF2 1137 if "CFF2" in varfont: 1138 raise NotImplementedError("Instancing CFF2 variable fonts is not supported yet") 1139 1140 1141def populateAxisDefaults(varfont, axisLimits): 1142 if any(value is None for value in axisLimits.values()): 1143 fvar = varfont["fvar"] 1144 defaultValues = {a.axisTag: a.defaultValue for a in fvar.axes} 1145 return { 1146 axisTag: defaultValues[axisTag] if value is None else value 1147 for axisTag, value in axisLimits.items() 1148 } 1149 return axisLimits 1150 1151 1152def instantiateVariableFont( 1153 varfont, 1154 axisLimits, 1155 inplace=False, 1156 optimize=True, 1157 overlap=OverlapMode.KEEP_AND_SET_FLAGS, 1158 updateFontNames=False, 1159): 1160 """Instantiate variable font, either fully or partially. 1161 1162 Depending on whether the `axisLimits` dictionary references all or some of the 1163 input varfont's axes, the output font will either be a full instance (static 1164 font) or a variable font with possibly less variation data. 1165 1166 Args: 1167 varfont: a TTFont instance, which must contain at least an 'fvar' table. 1168 Note that variable fonts with 'CFF2' table are not supported yet. 1169 axisLimits: a dict keyed by axis tags (str) containing the coordinates (float) 1170 along one or more axes where the desired instance will be located. 1171 If the value is `None`, the default coordinate as per 'fvar' table for 1172 that axis is used. 1173 The limit values can also be (min, max) tuples for restricting an 1174 axis's variation range. The default axis value must be included in 1175 the new range. 1176 inplace (bool): whether to modify input TTFont object in-place instead of 1177 returning a distinct object. 1178 optimize (bool): if False, do not perform IUP-delta optimization on the 1179 remaining 'gvar' table's deltas. Possibly faster, and might work around 1180 rendering issues in some buggy environments, at the cost of a slightly 1181 larger file size. 1182 overlap (OverlapMode): variable fonts usually contain overlapping contours, and 1183 some font rendering engines on Apple platforms require that the 1184 `OVERLAP_SIMPLE` and `OVERLAP_COMPOUND` flags in the 'glyf' table be set to 1185 force rendering using a non-zero fill rule. Thus we always set these flags 1186 on all glyphs to maximise cross-compatibility of the generated instance. 1187 You can disable this by passing OverlapMode.KEEP_AND_DONT_SET_FLAGS. 1188 If you want to remove the overlaps altogether and merge overlapping 1189 contours and components, you can pass OverlapMode.REMOVE (or 1190 REMOVE_AND_IGNORE_ERRORS to not hard-fail on tricky glyphs). Note that this 1191 requires the skia-pathops package (available to pip install). 1192 The overlap parameter only has effect when generating full static instances. 1193 updateFontNames (bool): if True, update the instantiated font's name table using 1194 the Axis Value Tables from the STAT table. The name table will be updated so 1195 it conforms to the R/I/B/BI model. If the STAT table is missing or 1196 an Axis Value table is missing for a given axis coordinate, a ValueError will 1197 be raised. 1198 """ 1199 # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool 1200 overlap = OverlapMode(int(overlap)) 1201 1202 sanityCheckVariableTables(varfont) 1203 1204 axisLimits = populateAxisDefaults(varfont, axisLimits) 1205 1206 normalizedLimits = normalizeAxisLimits(varfont, axisLimits) 1207 1208 log.info("Normalized limits: %s", normalizedLimits) 1209 1210 if not inplace: 1211 varfont = deepcopy(varfont) 1212 1213 if updateFontNames: 1214 log.info("Updating name table") 1215 names.updateNameTable(varfont, axisLimits) 1216 1217 if "gvar" in varfont: 1218 instantiateGvar(varfont, normalizedLimits, optimize=optimize) 1219 1220 if "cvar" in varfont: 1221 instantiateCvar(varfont, normalizedLimits) 1222 1223 if "MVAR" in varfont: 1224 instantiateMVAR(varfont, normalizedLimits) 1225 1226 if "HVAR" in varfont: 1227 instantiateHVAR(varfont, normalizedLimits) 1228 1229 if "VVAR" in varfont: 1230 instantiateVVAR(varfont, normalizedLimits) 1231 1232 instantiateOTL(varfont, normalizedLimits) 1233 1234 instantiateFeatureVariations(varfont, normalizedLimits) 1235 1236 if "avar" in varfont: 1237 instantiateAvar(varfont, axisLimits) 1238 1239 with names.pruningUnusedNames(varfont): 1240 if "STAT" in varfont: 1241 instantiateSTAT(varfont, axisLimits) 1242 1243 instantiateFvar(varfont, axisLimits) 1244 1245 if "fvar" not in varfont: 1246 if "glyf" in varfont: 1247 if overlap == OverlapMode.KEEP_AND_SET_FLAGS: 1248 setMacOverlapFlags(varfont["glyf"]) 1249 elif overlap in (OverlapMode.REMOVE, OverlapMode.REMOVE_AND_IGNORE_ERRORS): 1250 from fontTools.ttLib.removeOverlaps import removeOverlaps 1251 1252 log.info("Removing overlaps from glyf table") 1253 removeOverlaps( 1254 varfont, 1255 ignoreErrors=(overlap == OverlapMode.REMOVE_AND_IGNORE_ERRORS), 1256 ) 1257 1258 varLib.set_default_weight_width_slant( 1259 varfont, 1260 location={ 1261 axisTag: limit 1262 for axisTag, limit in axisLimits.items() 1263 if not isinstance(limit, tuple) 1264 }, 1265 ) 1266 1267 return varfont 1268 1269 1270def splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange): 1271 location, axisRanges = {}, {} 1272 for axisTag, value in axisLimits.items(): 1273 if isinstance(value, rangeType): 1274 axisRanges[axisTag] = value 1275 elif isinstance(value, (int, float)): 1276 location[axisTag] = value 1277 elif isinstance(value, tuple): 1278 axisRanges[axisTag] = rangeType(*value) 1279 else: 1280 raise TypeError( 1281 f"Expected number or {rangeType.__name__}, " 1282 f"got {type(value).__name__}: {value!r}" 1283 ) 1284 return location, axisRanges 1285 1286 1287def parseLimits(limits): 1288 result = {} 1289 for limitString in limits: 1290 match = re.match(r"^(\w{1,4})=(?:(drop)|(?:([^:]+)(?:[:](.+))?))$", limitString) 1291 if not match: 1292 raise ValueError("invalid location format: %r" % limitString) 1293 tag = match.group(1).ljust(4) 1294 if match.group(2): # 'drop' 1295 lbound = None 1296 else: 1297 lbound = strToFixedToFloat(match.group(3), precisionBits=16) 1298 ubound = lbound 1299 if match.group(4): 1300 ubound = strToFixedToFloat(match.group(4), precisionBits=16) 1301 if lbound != ubound: 1302 result[tag] = AxisRange(lbound, ubound) 1303 else: 1304 result[tag] = lbound 1305 return result 1306 1307 1308def parseArgs(args): 1309 """Parse argv. 1310 1311 Returns: 1312 3-tuple (infile, axisLimits, options) 1313 axisLimits is either a Dict[str, Optional[float]], for pinning variation axes 1314 to specific coordinates along those axes (with `None` as a placeholder for an 1315 axis' default value); or a Dict[str, Tuple(float, float)], meaning limit this 1316 axis to min/max range. 1317 Axes locations are in user-space coordinates, as defined in the "fvar" table. 1318 """ 1319 from fontTools import configLogger 1320 import argparse 1321 1322 parser = argparse.ArgumentParser( 1323 "fonttools varLib.instancer", 1324 description="Partially instantiate a variable font", 1325 ) 1326 parser.add_argument("input", metavar="INPUT.ttf", help="Input variable TTF file.") 1327 parser.add_argument( 1328 "locargs", 1329 metavar="AXIS=LOC", 1330 nargs="*", 1331 help="List of space separated locations. A location consists of " 1332 "the tag of a variation axis, followed by '=' and one of number, " 1333 "number:number or the literal string 'drop'. " 1334 "E.g.: wdth=100 or wght=75.0:125.0 or wght=drop", 1335 ) 1336 parser.add_argument( 1337 "-o", 1338 "--output", 1339 metavar="OUTPUT.ttf", 1340 default=None, 1341 help="Output instance TTF file (default: INPUT-instance.ttf).", 1342 ) 1343 parser.add_argument( 1344 "--no-optimize", 1345 dest="optimize", 1346 action="store_false", 1347 help="Don't perform IUP optimization on the remaining gvar TupleVariations", 1348 ) 1349 parser.add_argument( 1350 "--no-overlap-flag", 1351 dest="overlap", 1352 action="store_false", 1353 help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags (only applicable " 1354 "when generating a full instance)", 1355 ) 1356 parser.add_argument( 1357 "--remove-overlaps", 1358 dest="remove_overlaps", 1359 action="store_true", 1360 help="Merge overlapping contours and components (only applicable " 1361 "when generating a full instance). Requires skia-pathops", 1362 ) 1363 parser.add_argument( 1364 "--ignore-overlap-errors", 1365 dest="ignore_overlap_errors", 1366 action="store_true", 1367 help="Don't crash if the remove-overlaps operation fails for some glyphs.", 1368 ) 1369 parser.add_argument( 1370 "--update-name-table", 1371 action="store_true", 1372 help="Update the instantiated font's `name` table. Input font must have " 1373 "a STAT table with Axis Value Tables", 1374 ) 1375 loggingGroup = parser.add_mutually_exclusive_group(required=False) 1376 loggingGroup.add_argument( 1377 "-v", "--verbose", action="store_true", help="Run more verbosely." 1378 ) 1379 loggingGroup.add_argument( 1380 "-q", "--quiet", action="store_true", help="Turn verbosity off." 1381 ) 1382 options = parser.parse_args(args) 1383 1384 if options.remove_overlaps: 1385 if options.ignore_overlap_errors: 1386 options.overlap = OverlapMode.REMOVE_AND_IGNORE_ERRORS 1387 else: 1388 options.overlap = OverlapMode.REMOVE 1389 else: 1390 options.overlap = OverlapMode(int(options.overlap)) 1391 1392 infile = options.input 1393 if not os.path.isfile(infile): 1394 parser.error("No such file '{}'".format(infile)) 1395 1396 configLogger( 1397 level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO") 1398 ) 1399 1400 try: 1401 axisLimits = parseLimits(options.locargs) 1402 except ValueError as e: 1403 parser.error(str(e)) 1404 1405 if len(axisLimits) != len(options.locargs): 1406 parser.error("Specified multiple limits for the same axis") 1407 1408 return (infile, axisLimits, options) 1409 1410 1411def main(args=None): 1412 """Partially instantiate a variable font.""" 1413 infile, axisLimits, options = parseArgs(args) 1414 log.info("Restricting axes: %s", axisLimits) 1415 1416 log.info("Loading variable font") 1417 varfont = TTFont(infile) 1418 1419 isFullInstance = { 1420 axisTag for axisTag, limit in axisLimits.items() if not isinstance(limit, tuple) 1421 }.issuperset(axis.axisTag for axis in varfont["fvar"].axes) 1422 1423 instantiateVariableFont( 1424 varfont, 1425 axisLimits, 1426 inplace=True, 1427 optimize=options.optimize, 1428 overlap=options.overlap, 1429 updateFontNames=options.update_name_table, 1430 ) 1431 1432 outfile = ( 1433 os.path.splitext(infile)[0] 1434 + "-{}.ttf".format("instance" if isFullInstance else "partial") 1435 if not options.output 1436 else options.output 1437 ) 1438 1439 log.info( 1440 "Saving %s font %s", 1441 "instance" if isFullInstance else "partial variable", 1442 outfile, 1443 ) 1444 varfont.save(outfile) 1445