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