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