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