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