"""Partially instantiate a variable font. The module exports an `instantiateVariableFont` function and CLI that allow to create full instances (i.e. static fonts) from variable fonts, as well as "partial" variable fonts that only contain a subset of the original variation space. For example, if you wish to pin the width axis to a given location while also restricting the weight axis to 400..700 range, you can do: .. code-block:: sh $ fonttools varLib.instancer ./NotoSans-VF.ttf wdth=85 wght=400:700 See `fonttools varLib.instancer --help` for more info on the CLI options. The module's entry point is the `instantiateVariableFont` function, which takes a TTFont object and a dict specifying either axis coodinates or (min, max) ranges, and returns a new TTFont representing either a partial VF, or full instance if all the VF axes were given an explicit coordinate. E.g. here's how to pin the wght axis at a given location in a wght+wdth variable font, keeping only the deltas associated with the wdth axis: .. code-block:: pycon >>> >> from fontTools import ttLib >> from fontTools.varLib import instancer >> varfont = ttLib.TTFont("path/to/MyVariableFont.ttf") >> [a.axisTag for a in varfont["fvar"].axes] # the varfont's current axes ['wght', 'wdth'] >> partial = instancer.instantiateVariableFont(varfont, {"wght": 300}) >> [a.axisTag for a in partial["fvar"].axes] # axes left after pinning 'wght' ['wdth'] If the input location specifies all the axes, the resulting instance is no longer 'variable' (same as using fontools varLib.mutator): .. code-block:: pycon >>> >> instance = instancer.instantiateVariableFont( ... varfont, {"wght": 700, "wdth": 67.5} ... ) >> "fvar" not in instance True If one just want to drop an axis at the default location, without knowing in advance what the default value for that axis is, one can pass a `None` value: .. code-block:: pycon >>> >> instance = instancer.instantiateVariableFont(varfont, {"wght": None}) >> len(varfont["fvar"].axes) 1 From the console script, this is equivalent to passing `wght=drop` as input. This module is similar to fontTools.varLib.mutator, which it's intended to supersede. Note that, unlike varLib.mutator, when an axis is not mentioned in the input location, by default the varLib.instancer will keep the axis and the corresponding deltas, whereas mutator implicitly drops the axis at its default coordinate. To obtain the same behavior as mutator, pass the `static=True` parameter or the `--static` CLI option. The module supports all the following "levels" of instancing, which can of course be combined: L1 dropping one or more axes while leaving the default tables unmodified; .. code-block:: pycon >>> >> font = instancer.instantiateVariableFont(varfont, {"wght": None}) L2 dropping one or more axes while pinning them at non-default locations; .. code-block:: pycon >>> >> font = instancer.instantiateVariableFont(varfont, {"wght": 700}) L3 restricting the range of variation of one or more axes, by setting either a new minimum or maximum, potentially -- though not necessarily -- dropping entire regions of variations that fall completely outside this new range. .. code-block:: pycon >>> >> font = instancer.instantiateVariableFont(varfont, {"wght": (100, 300)}) L4 moving the default location of an axis, by specifying (min,defalt,max) values: .. code-block:: pycon >>> >> font = instancer.instantiateVariableFont(varfont, {"wght": (100, 300, 700)}) Both TrueType-flavored (glyf+gvar) variable and CFF2 variable fonts are supported. """ from fontTools.misc.fixedTools import ( floatToFixedToFloat, strToFixedToFloat, otRound, ) from fontTools.varLib.models import normalizeValue, piecewiseLinearMap, VariationModel from fontTools.ttLib import TTFont, newTable from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.ttLib.tables import _g_l_y_f from fontTools import varLib # we import the `subset` module because we use the `prune_lookups` method on the GSUB # table class, and that method is only defined dynamically upon importing `subset` from fontTools import subset # noqa: F401 from fontTools.cffLib import privateDictOperators2 from fontTools.cffLib.specializer import ( programToCommands, commandsToProgram, specializeCommands, generalizeCommands, ) from fontTools.cffLib.CFF2ToCFF import convertCFF2ToCFF from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.merger import MutatorMerger from fontTools.varLib.instancer import names from fontTools.varLib.varStore import NO_VARIATION_INDEX from .featureVars import instantiateFeatureVariations from fontTools.misc.cliTools import makeOutputFileName from fontTools.varLib.instancer import solver from fontTools.ttLib.tables.otTables import VarComponentFlags import collections import dataclasses from contextlib import contextmanager from copy import deepcopy from enum import IntEnum import logging import os import re import io from typing import Dict, Iterable, Mapping, Optional, Sequence, Tuple, Union import warnings log = logging.getLogger("fontTools.varLib.instancer") def AxisRange(minimum, maximum): warnings.warn( "AxisRange is deprecated; use AxisTriple instead", DeprecationWarning, stacklevel=2, ) return AxisTriple(minimum, None, maximum) def NormalizedAxisRange(minimum, maximum): warnings.warn( "NormalizedAxisRange is deprecated; use AxisTriple instead", DeprecationWarning, stacklevel=2, ) return NormalizedAxisTriple(minimum, None, maximum) @dataclasses.dataclass(frozen=True, order=True, repr=False) class AxisTriple(Sequence): """A triple of (min, default, max) axis values. Any of the values can be None, in which case the limitRangeAndPopulateDefaults() method can be used to fill in the missing values based on the fvar axis values. """ minimum: Optional[float] default: Optional[float] maximum: Optional[float] def __post_init__(self): if self.default is None and self.minimum == self.maximum: object.__setattr__(self, "default", self.minimum) if ( ( self.minimum is not None and self.default is not None and self.minimum > self.default ) or ( self.default is not None and self.maximum is not None and self.default > self.maximum ) or ( self.minimum is not None and self.maximum is not None and self.minimum > self.maximum ) ): raise ValueError( f"{type(self).__name__} minimum ({self.minimum}), default ({self.default}), maximum ({self.maximum}) must be in sorted order" ) def __getitem__(self, i): fields = dataclasses.fields(self) return getattr(self, fields[i].name) def __len__(self): return len(dataclasses.fields(self)) def _replace(self, **kwargs): return dataclasses.replace(self, **kwargs) def __repr__(self): return ( f"({', '.join(format(v, 'g') if v is not None else 'None' for v in self)})" ) @classmethod def expand( cls, v: Union[ "AxisTriple", float, # pin axis at single value, same as min==default==max Tuple[float, float], # (min, max), restrict axis and keep default Tuple[float, float, float], # (min, default, max) ], ) -> "AxisTriple": """Convert a single value or a tuple into an AxisTriple. If the input is a single value, it is interpreted as a pin at that value. If the input is a tuple, it is interpreted as (min, max) or (min, default, max). """ if isinstance(v, cls): return v if isinstance(v, (int, float)): return cls(v, v, v) try: n = len(v) except TypeError as e: raise ValueError( f"expected float, 2- or 3-tuple of floats; got {type(v)}: {v!r}" ) from e default = None if n == 2: minimum, maximum = v elif n >= 3: return cls(*v) else: raise ValueError(f"expected sequence of 2 or 3; got {n}: {v!r}") return cls(minimum, default, maximum) def limitRangeAndPopulateDefaults(self, fvarTriple) -> "AxisTriple": """Return a new AxisTriple with the default value filled in. Set default to fvar axis default if the latter is within the min/max range, otherwise set default to the min or max value, whichever is closer to the fvar axis default. If the default value is already set, return self. """ minimum = self.minimum if minimum is None: minimum = fvarTriple[0] default = self.default if default is None: default = fvarTriple[1] maximum = self.maximum if maximum is None: maximum = fvarTriple[2] minimum = max(minimum, fvarTriple[0]) maximum = max(maximum, fvarTriple[0]) minimum = min(minimum, fvarTriple[2]) maximum = min(maximum, fvarTriple[2]) default = max(minimum, min(maximum, default)) return AxisTriple(minimum, default, maximum) @dataclasses.dataclass(frozen=True, order=True, repr=False) class NormalizedAxisTriple(AxisTriple): """A triple of (min, default, max) normalized axis values.""" minimum: float default: float maximum: float def __post_init__(self): if self.default is None: object.__setattr__(self, "default", max(self.minimum, min(self.maximum, 0))) if not (-1.0 <= self.minimum <= self.default <= self.maximum <= 1.0): raise ValueError( "Normalized axis values not in -1..+1 range; got " f"minimum={self.minimum:g}, default={self.default:g}, maximum={self.maximum:g})" ) @dataclasses.dataclass(frozen=True, order=True, repr=False) class NormalizedAxisTripleAndDistances(AxisTriple): """A triple of (min, default, max) normalized axis values, with distances between min and default, and default and max, in the *pre-normalized* space.""" minimum: float default: float maximum: float distanceNegative: Optional[float] = 1 distancePositive: Optional[float] = 1 def __post_init__(self): if self.default is None: object.__setattr__(self, "default", max(self.minimum, min(self.maximum, 0))) if not (-1.0 <= self.minimum <= self.default <= self.maximum <= 1.0): raise ValueError( "Normalized axis values not in -1..+1 range; got " f"minimum={self.minimum:g}, default={self.default:g}, maximum={self.maximum:g})" ) def reverse_negate(self): v = self return self.__class__(-v[2], -v[1], -v[0], v[4], v[3]) def renormalizeValue(self, v, extrapolate=True): """Renormalizes a normalized value v to the range of this axis, considering the pre-normalized distances as well as the new axis limits.""" lower, default, upper, distanceNegative, distancePositive = self assert lower <= default <= upper if not extrapolate: v = max(lower, min(upper, v)) if v == default: return 0 if default < 0: return -self.reverse_negate().renormalizeValue(-v, extrapolate=extrapolate) # default >= 0 and v != default if v > default: return (v - default) / (upper - default) # v < default if lower >= 0: return (v - default) / (default - lower) # lower < 0 and v < default totalDistance = distanceNegative * -lower + distancePositive * default if v >= 0: vDistance = (default - v) * distancePositive else: vDistance = -v * distanceNegative + distancePositive * default return -vDistance / totalDistance class _BaseAxisLimits(Mapping[str, AxisTriple]): def __getitem__(self, key: str) -> AxisTriple: return self._data[key] def __iter__(self) -> Iterable[str]: return iter(self._data) def __len__(self) -> int: return len(self._data) def __repr__(self) -> str: return f"{type(self).__name__}({self._data!r})" def __str__(self) -> str: return str(self._data) def defaultLocation(self) -> Dict[str, float]: """Return a dict of default axis values.""" return {k: v.default for k, v in self.items()} def pinnedLocation(self) -> Dict[str, float]: """Return a location dict with only the pinned axes.""" return {k: v.default for k, v in self.items() if v.minimum == v.maximum} class AxisLimits(_BaseAxisLimits): """Maps axis tags (str) to AxisTriple values.""" def __init__(self, *args, **kwargs): self._data = data = {} for k, v in dict(*args, **kwargs).items(): if v is None: # will be filled in by limitAxesAndPopulateDefaults data[k] = v else: try: triple = AxisTriple.expand(v) except ValueError as e: raise ValueError(f"Invalid axis limits for {k!r}: {v!r}") from e data[k] = triple def limitAxesAndPopulateDefaults(self, varfont) -> "AxisLimits": """Return a new AxisLimits with defaults filled in from fvar table. If all axis limits already have defaults, return self. """ fvar = varfont["fvar"] fvarTriples = { a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in fvar.axes } newLimits = {} for axisTag, triple in self.items(): fvarTriple = fvarTriples[axisTag] default = fvarTriple[1] if triple is None: newLimits[axisTag] = AxisTriple(default, default, default) else: newLimits[axisTag] = triple.limitRangeAndPopulateDefaults(fvarTriple) return type(self)(newLimits) def normalize(self, varfont, usingAvar=True) -> "NormalizedAxisLimits": """Return a new NormalizedAxisLimits with normalized -1..0..+1 values. If usingAvar is True, the avar table is used to warp the default normalization. """ fvar = varfont["fvar"] badLimits = set(self.keys()).difference(a.axisTag for a in fvar.axes) if badLimits: raise ValueError("Cannot limit: {} not present in fvar".format(badLimits)) axes = { a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in fvar.axes if a.axisTag in self } if usingAvar and "avar" in varfont: avar = varfont["avar"] if getattr(avar, "majorVersion", 1) >= 2 and set( self.pinnedLocation() ).issuperset(a.axisTag for a in fvar.axes): # Full instancing of avar2 font (all axes pinned). # Use avar table to normalize location and return. location = self.pinnedLocation() location = { tag: normalize(value, axes[tag], None) for tag, value in location.items() } return NormalizedAxisLimits( **avar.renormalizeLocation(location, varfont, dropZeroes=False) ) # else: partial instancing of an avar2 font falls through to the # standard v1 normalization below, which yields intermediate-space # limits (post-avar v1, pre-avar v2); the avar v2 offset # compensation in the instancer handles the rest. # Note: normalized values are intentionally NOT quantized here; the # F2Dot14 quantization happens once, after the avar v1 mapping (in # renormalizeAxisLimits), so pre-map rounding doesn't compound drift. normalizedLimits = {} for axis_tag, fvarTriple in axes.items(): minUser, defaultUser, maxUser = fvarTriple distanceNegative = defaultUser - minUser distancePositive = maxUser - defaultUser if self[axis_tag] is None: # Drop normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances( 0, 0, 0, distanceNegative, distancePositive ) continue minV, defaultV, maxV = self[axis_tag] if defaultV is None: defaultV = fvarTriple[1] normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances( *(normalizeValue(v, fvarTriple) for v in (minV, defaultV, maxV)), distanceNegative, distancePositive, ) if usingAvar and "avar" in varfont: normalizedLimits = varfont["avar"].renormalizeAxisLimits( normalizedLimits, varfont ) else: # Quantize to F2Dot14, to avoid surprise interpolations. normalizedLimits = { tag: tuple(floatToFixedToFloat(v, 14) for v in tuple(triple)[:3]) + tuple(triple)[3:] for tag, triple in normalizedLimits.items() } return NormalizedAxisLimits(normalizedLimits) class NormalizedAxisLimits(_BaseAxisLimits): """Maps axis tags (str) to NormalizedAxisTriple values.""" def __init__(self, *args, **kwargs): self._data = data = {} for k, v in dict(*args, **kwargs).items(): try: triple = NormalizedAxisTripleAndDistances.expand(v) except ValueError as e: raise ValueError(f"Invalid axis limits for {k!r}: {v!r}") from e data[k] = triple class OverlapMode(IntEnum): KEEP_AND_DONT_SET_FLAGS = 0 KEEP_AND_SET_FLAGS = 1 REMOVE = 2 REMOVE_AND_IGNORE_ERRORS = 3 def instantiateVARC(varfont, axisLimits): log.info("Instantiating VARC tables") # TODO(behdad) My confidence in this function is rather low; # It needs more testing. Specially with partial-instancing, # I don't think it currently works. varc = varfont["VARC"].table fvarAxes = varfont["fvar"].axes if "fvar" in varfont else [] location = axisLimits.pinnedLocation() axisMap = [i for i, axis in enumerate(fvarAxes) if axis.axisTag not in location] reverseAxisMap = {i: j for j, i in enumerate(axisMap)} if varc.AxisIndicesList: axisIndicesList = varc.AxisIndicesList.Item for i, axisIndices in enumerate(axisIndicesList): if any(fvarAxes[j].axisTag in axisLimits for j in axisIndices): raise NotImplementedError( "Instancing across VarComponent axes is not supported." ) axisIndicesList[i] = [reverseAxisMap[j] for j in axisIndices] store = varc.MultiVarStore if store: for region in store.SparseVarRegionList.Region: newRegionAxis = [] for regionRecord in region.SparseVarRegionAxis: tag = fvarAxes[regionRecord.AxisIndex].axisTag if tag in axisLimits: raise NotImplementedError( "Instancing across VarComponent axes is not supported." ) regionRecord.AxisIndex = reverseAxisMap[regionRecord.AxisIndex] def instantiateTupleVariationStore( variations, axisLimits, origCoords=None, endPts=None ): """Instantiate TupleVariation list at the given location, or limit axes' min/max. The 'variations' list of TupleVariation objects is modified in-place. The 'axisLimits' (dict) maps axis tags (str) to NormalizedAxisTriple namedtuples specifying (minimum, default, maximum) in the -1,0,+1 normalized space. Pinned axes have minimum == default == maximum. A 'full' instance (i.e. static font) is produced when all the axes are pinned to single coordinates; a 'partial' instance (i.e. a less variable font) is produced when some of the axes are omitted, or restricted with a new range. Tuples that do not participate are kept as they are. Those that have 0 influence at the given location are removed from the variation store. Those that are fully instantiated (i.e. all their axes are being pinned) are also removed from the variation store, their scaled deltas accummulated and returned, so that they can be added by the caller to the default instance's coordinates. Tuples that are only partially instantiated (i.e. not all the axes that they participate in are being pinned) are kept in the store, and their deltas multiplied by the scalar support of the axes to be pinned at the desired location. Args: variations: List[TupleVariation] from either 'gvar' or 'cvar'. axisLimits: NormalizedAxisLimits: map from axis tags to (min, default, max) normalized coordinates for the full or partial instance. origCoords: GlyphCoordinates: default instance's coordinates for computing 'gvar' inferred points (cf. table__g_l_y_f._getCoordinatesAndControls). endPts: List[int]: indices of contour end points, for inferring 'gvar' deltas. Returns: List[float]: the overall delta adjustment after applicable deltas were summed. """ newVariations = changeTupleVariationsAxisLimits(variations, axisLimits) mergedVariations = collections.OrderedDict() for var in newVariations: # compute inferred deltas only for gvar ('origCoords' is None for cvar) if origCoords is not None: var.calcInferredDeltas(origCoords, endPts) # merge TupleVariations with overlapping "tents" axes = frozenset(var.axes.items()) if axes in mergedVariations: mergedVariations[axes] += var else: mergedVariations[axes] = var # drop TupleVariation if all axes have been pinned (var.axes.items() is empty); # its deltas will be added to the default instance's coordinates defaultVar = mergedVariations.pop(frozenset(), None) for var in mergedVariations.values(): var.roundDeltas() variations[:] = list(mergedVariations.values()) return defaultVar.coordinates if defaultVar is not None else [] def changeTupleVariationsAxisLimits(variations, axisLimits): for axisTag, axisLimit in sorted(axisLimits.items()): newVariations = [] for var in variations: newVariations.extend(changeTupleVariationAxisLimit(var, axisTag, axisLimit)) variations = newVariations return variations def changeTupleVariationAxisLimit(var, axisTag, axisLimit): assert isinstance(axisLimit, NormalizedAxisTripleAndDistances) # Skip when current axis is missing or peaks at 0 (i.e. doesn't participate) lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1)) if peak == 0: # explicitly defined, no-op axes can be omitted # https://github.com/fonttools/fonttools/issues/3453 if axisTag in var.axes: del var.axes[axisTag] return [var] # Drop if the var 'tent' isn't well-formed if not (lower <= peak <= upper) or (lower < 0 and upper > 0): return [] if axisTag not in var.axes: return [var] tent = var.axes[axisTag] solutions = solver.rebaseTent(tent, axisLimit) out = [] for scalar, tent in solutions: newVar = ( TupleVariation(var.axes, var.coordinates) if len(solutions) > 1 else var ) if tent is None: newVar.axes.pop(axisTag) else: assert tent[1] != 0, tent newVar.axes[axisTag] = tent newVar *= scalar out.append(newVar) return out def instantiateCFF2( varfont, axisLimits, *, round=round, specialize=True, generalize=False, downgrade=False, reachableRanges=None, ): # The algorithm here is rather simple: # # Take all blend operations and store their deltas in the (otherwise empty) # CFF2 VarStore. Then, instantiate the VarStore with the given axis limits, # and read back the new deltas. This is done for both the CharStrings and # the Private dicts. # # Then prune unused things and possibly drop the VarStore if it's empty. # # If the downgrade parameter is True, no actual downgrading is done, but # the function returns True if the VarStore was empty after instantiation, # and hence a downgrade to CFF is possible. In all other cases it returns # False. log.info("Instantiating CFF2 table") fvarAxes = varfont["fvar"].axes cff = varfont["CFF2"].cff topDict = cff.topDictIndex[0] varStore = getattr(topDict, "VarStore", None) if varStore is not None: varStore = varStore.otVarStore if not varStore: if downgrade: from fontTools.cffLib.CFF2ToCFF import convertCFF2ToCFF convertCFF2ToCFF(varfont) return cff.desubroutinize() def getNumRegions(vsindex): return varStore.VarData[vsindex if vsindex is not None else 0].VarRegionCount charStrings = topDict.CharStrings.values() # Gather all unique private dicts uniquePrivateDicts = set() privateDicts = [] for fd in topDict.FDArray: if fd.Private not in uniquePrivateDicts: uniquePrivateDicts.add(fd.Private) privateDicts.append(fd.Private) allCommands = [] allCommandPrivates = [] for cs in charStrings: assert cs.private.vstore.otVarStore is varStore # Or in many places!! commands = programToCommands(cs.program, getNumRegions=getNumRegions) if generalize: commands = generalizeCommands(commands) if specialize: commands = specializeCommands(commands, generalizeFirst=not generalize) allCommands.append(commands) allCommandPrivates.append(cs.private) def storeBlendsToVarStore(arg): if not isinstance(arg, list): return if any(isinstance(subarg, list) for subarg in arg[:-1]): raise NotImplementedError("Nested blend lists not supported (yet)") count = arg[-1] assert (len(arg) - 1) % count == 0 nRegions = (len(arg) - 1) // count - 1 assert nRegions == getNumRegions(vsindex) for i in range(count, len(arg) - 1, nRegions): deltas = arg[i : i + nRegions] assert len(deltas) == nRegions varData = varStore.VarData[vsindex] varData.Item.append(deltas) varData.ItemCount += 1 def fetchBlendsFromVarStore(arg): if not isinstance(arg, list): return [arg] if any(isinstance(subarg, list) for subarg in arg[:-1]): raise NotImplementedError("Nested blend lists not supported (yet)") count = arg[-1] assert (len(arg) - 1) % count == 0 numRegions = getNumRegions(vsindex) newDefaults = [] newDeltas = [] for i in range(count): defaultValue = arg[i] major = vsindex minor = varDataCursor[major] varDataCursor[major] += 1 defaultValue += round(defaultDeltas[major][minor]) newDefaults.append(defaultValue) varData = varStore.VarData[major] deltas = varData.Item[minor] assert len(deltas) == numRegions newDeltas.extend(deltas) if not numRegions: return newDefaults # No deltas, just return the defaults return [newDefaults + newDeltas + [count]] # Check VarData's are empty for varData in varStore.VarData: assert varData.Item == [] assert varData.ItemCount == 0 # Add charstring blend lists to VarStore so we can instantiate them for commands, private in zip(allCommands, allCommandPrivates): vsindex = getattr(private, "vsindex", 0) for command in commands: if command[0] == "vsindex": vsindex = command[1][0] continue for arg in command[1]: storeBlendsToVarStore(arg) # Add private blend lists to VarStore so we can instantiate values for private in privateDicts: vsindex = getattr(private, "vsindex", 0) for opcode, name, arg_type, default, converter in privateDictOperators2: if arg_type not in ("number", "delta", "array") or name == "vsindex": continue if not hasattr(private, name): continue values = getattr(private, name) if arg_type == "number": values = [values] for value in values: if not isinstance(value, list): continue assert len(value) % (getNumRegions(vsindex) + 1) == 0 count = len(value) // (getNumRegions(vsindex) + 1) storeBlendsToVarStore(value + [count]) # Instantiate VarStore defaultDeltas = instantiateItemVariationStore( varStore, fvarAxes, axisLimits, hierarchical=True ) # Optionally cull dead regions (for avar2 partial instancing) if reachableRanges: _cullItemVariationStore(varStore, fvarAxes, reachableRanges) # Read back new charstring blends from the instantiated VarStore varDataCursor = [0] * len(varStore.VarData) for commands, private in zip(allCommands, allCommandPrivates): vsindex = getattr(private, "vsindex", 0) for command in commands: if command[0] == "vsindex": vsindex = command[1][0] continue newArgs = [] for arg in command[1]: newArgs.extend(fetchBlendsFromVarStore(arg)) command[1][:] = newArgs # Read back new private blends from the instantiated VarStore for private in privateDicts: vsindex = getattr(private, "vsindex", 0) for opcode, name, arg_type, default, converter in privateDictOperators2: if arg_type not in ("number", "delta", "array") or name == "vsindex": continue if not hasattr(private, name): continue values = getattr(private, name) if arg_type == "number": values = [values] newValues = [] for value in values: if not isinstance(value, list): newValues.append(value) continue value.append(1) value = fetchBlendsFromVarStore(value) newValues.extend(v[:-1] if isinstance(v, list) else v for v in value) if arg_type == "number": newValues = newValues[0] setattr(private, name, newValues) # Empty out the VarStore for i, varData in enumerate(varStore.VarData): assert varDataCursor[i] == varData.ItemCount, ( varDataCursor[i], varData.ItemCount, ) varData.Item = [] varData.ItemCount = 0 # Collect surviving vsindexes usedVsindex = set( i for i in range(len(varStore.VarData)) if varStore.VarData[i].VarRegionCount ) # Remove vsindex commands that are no longer needed for commands, private in zip(allCommands, allCommandPrivates): if not any(isinstance(arg, list) for command in commands for arg in command[1]): commands[:] = [command for command in commands if command[0] != "vsindex"] # Remove unused VarData and update vsindex values vsindexMapping = {v: i for i, v in enumerate(sorted(usedVsindex))} varStore.VarData = [ varData for i, varData in enumerate(varStore.VarData) if i in usedVsindex ] varStore.VarDataCount = len(varStore.VarData) for commands in allCommands: for command in commands: if command[0] == "vsindex": command[1][0] = vsindexMapping[command[1][0]] for private in privateDicts: if hasattr(private, "vsindex"): if private.vsindex in vsindexMapping: private.vsindex = vsindexMapping[private.vsindex] else: # The referenced VarData lost all its regions; the dict's # blends have dissolved into plain values. del private.vsindex # Remove initial vsindex commands that are implied for commands, private in zip(allCommands, allCommandPrivates): vsindex = getattr(private, "vsindex", 0) if commands and commands[0] == ("vsindex", [vsindex]): commands.pop(0) # Ship the charstrings! for cs, commands in zip(charStrings, allCommands): cs.program = commandsToProgram(commands) # Remove empty VarStore if not varStore.VarData: if "VarStore" in topDict.rawDict: del topDict.rawDict["VarStore"] del topDict.VarStore del topDict.CharStrings.varStore for private in privateDicts: del private.vstore if downgrade: return True return False def _instantiateGvarGlyph( glyphname, glyf, gvar, hMetrics, vMetrics, axisLimits, optimize=True ): coordinates, ctrl = glyf._getCoordinatesAndControls(glyphname, hMetrics, vMetrics) endPts = ctrl.endPts # Not every glyph may have variations tupleVarStore = gvar.variations.get(glyphname) if tupleVarStore: defaultDeltas = instantiateTupleVariationStore( tupleVarStore, axisLimits, coordinates, endPts ) if defaultDeltas: coordinates += _g_l_y_f.GlyphCoordinates(defaultDeltas) # _setCoordinates also sets the hmtx/vmtx advance widths and sidebearings from # the four phantom points and glyph bounding boxes. # We call it unconditionally even if a glyph has no variations or no deltas are # applied at this location, in case the glyph's xMin and in turn its sidebearing # have changed. E.g. a composite glyph has no deltas for the component's (x, y) # offset nor for the 4 phantom points (e.g. it's monospaced). Thus its entry in # gvar table is empty; however, the composite's base glyph may have deltas # applied, hence the composite's bbox and left/top sidebearings may need updating # in the instanced font. glyf._setCoordinates(glyphname, coordinates, hMetrics, vMetrics) if not tupleVarStore: if glyphname in gvar.variations: del gvar.variations[glyphname] return if optimize: # IUP semantics depend on point equality, and so round prior to # optimization to ensure that comparisons that happen now will be the # same as those that happen at render time. This is especially needed # when floating point deltas have been applied to the default position. # See https://github.com/fonttools/fonttools/issues/3634 # Rounding must happen only after calculating glyf metrics above, to # preserve backwards compatibility. # See 0010a3cd9aa25f84a3a6250dafb119743d32aa40 coordinates.toInt() for var in tupleVarStore: var.optimize(coordinates, endPts) def instantiateGvarGlyph(varfont, glyphname, axisLimits, optimize=True): """Remove? https://github.com/fonttools/fonttools/pull/2266""" gvar = varfont["gvar"] glyf = varfont["glyf"] hMetrics = varfont["hmtx"].metrics vMetrics = getattr(varfont.get("vmtx"), "metrics", None) _instantiateGvarGlyph( glyphname, glyf, gvar, hMetrics, vMetrics, axisLimits, optimize=optimize ) def instantiateGvar(varfont, axisLimits, optimize=True): log.info("Instantiating glyf/gvar tables") gvar = varfont["gvar"] glyf = varfont["glyf"] hMetrics = varfont["hmtx"].metrics vMetrics = getattr(varfont.get("vmtx"), "metrics", None) # Get list of glyph names sorted by component depth. # If a composite glyph is processed before its base glyph, the bounds may # be calculated incorrectly because deltas haven't been applied to the # base glyph yet. glyphnames = sorted( glyf.glyphOrder, key=lambda name: ( ( glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth if glyf[name].isComposite() else 0 ), name, ), ) for glyphname in glyphnames: _instantiateGvarGlyph( glyphname, glyf, gvar, hMetrics, vMetrics, axisLimits, optimize=optimize ) if not gvar.variations: del varfont["gvar"] def setCvarDeltas(cvt, deltas): for i, delta in enumerate(deltas): if delta: cvt[i] += otRound(delta) def instantiateCvar(varfont, axisLimits): log.info("Instantiating cvt/cvar tables") cvar = varfont["cvar"] defaultDeltas = instantiateTupleVariationStore(cvar.variations, axisLimits) if defaultDeltas: setCvarDeltas(varfont["cvt "], defaultDeltas) if not cvar.variations: del varfont["cvar"] def setMvarDeltas(varfont, deltas): mvar = varfont["MVAR"].table records = mvar.ValueRecord for rec in records: mvarTag = rec.ValueTag if mvarTag not in MVAR_ENTRIES: continue tableTag, itemName = MVAR_ENTRIES[mvarTag] delta = deltas[rec.VarIdx] if delta != 0: setattr( varfont[tableTag], itemName, getattr(varfont[tableTag], itemName) + otRound(delta), ) @contextmanager def verticalMetricsKeptInSync(varfont): """Ensure hhea vertical metrics stay in sync with OS/2 ones after instancing. When applying MVAR deltas to the OS/2 table, if the ascender, descender and line gap change but they were the same as the respective hhea metrics in the original font, this context manager ensures that hhea metrcs also get updated accordingly. The MVAR spec only has tags for the OS/2 metrics, but it is common in fonts to have the hhea metrics be equal to those for compat reasons. https://learn.microsoft.com/en-us/typography/opentype/spec/mvar https://googlefonts.github.io/gf-guide/metrics.html#7-hhea-and-typo-metrics-should-be-equal https://github.com/fonttools/fonttools/issues/3297 """ current_os2_vmetrics = [ getattr(varfont["OS/2"], attr) for attr in ("sTypoAscender", "sTypoDescender", "sTypoLineGap") ] metrics_are_synced = current_os2_vmetrics == [ getattr(varfont["hhea"], attr) for attr in ("ascender", "descender", "lineGap") ] yield metrics_are_synced if metrics_are_synced: new_os2_vmetrics = [ getattr(varfont["OS/2"], attr) for attr in ("sTypoAscender", "sTypoDescender", "sTypoLineGap") ] if current_os2_vmetrics != new_os2_vmetrics: for attr, value in zip( ("ascender", "descender", "lineGap"), new_os2_vmetrics ): setattr(varfont["hhea"], attr, value) def instantiateMVAR(varfont, axisLimits): log.info("Instantiating MVAR table") mvar = varfont["MVAR"].table fvarAxes = varfont["fvar"].axes varStore = mvar.VarStore defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits) with verticalMetricsKeptInSync(varfont): setMvarDeltas(varfont, defaultDeltas) if varStore.VarRegionList.Region: varIndexMapping = varStore.optimize() for rec in mvar.ValueRecord: rec.VarIdx = varIndexMapping[rec.VarIdx] else: del varfont["MVAR"] def _remapVarIdxMap(table, attrName, varIndexMapping, glyphOrder): oldMapping = getattr(table, attrName).mapping newMapping = [varIndexMapping[oldMapping[glyphName]] for glyphName in glyphOrder] setattr(table, attrName, builder.buildVarIdxMap(newMapping, glyphOrder)) # TODO(anthrotype) Add support for HVAR/VVAR in CFF2 def _instantiateVHVAR(varfont, axisLimits, tableFields, *, round=round): location = axisLimits.pinnedLocation() tableTag = tableFields.tableTag fvarAxes = varfont["fvar"].axes log.info("Instantiating %s table", tableTag) vhvar = varfont[tableTag].table varStore = vhvar.VarStore if "glyf" in varfont: # Deltas from gvar table have already been applied to the hmtx/vmtx. For full # instances (i.e. all axes pinned), we can simply drop HVAR/VVAR and return if set(location).issuperset(axis.axisTag for axis in fvarAxes): log.info("Dropping %s table", tableTag) del varfont[tableTag] return defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits) if "glyf" not in varfont: # CFF2 fonts need hmtx/vmtx updated here. For glyf fonts, the instantiateGvar # function already updated the hmtx/vmtx from phantom points. Maybe remove # that and do it here for both CFF2 and glyf fonts? # # Specially, if a font has glyf but not gvar, the hmtx/vmtx will not have been # updated by instantiateGvar. Though one can call that a faulty font. metricsTag = "vmtx" if tableTag == "VVAR" else "hmtx" if metricsTag in varfont: advMapping = getattr(vhvar, tableFields.advMapping) metricsTable = varfont[metricsTag] metrics = metricsTable.metrics for glyphName, (advanceWidth, sb) in metrics.items(): if advMapping: varIdx = advMapping.mapping[glyphName] else: varIdx = varfont.getGlyphID(glyphName) delta = round(defaultDeltas[varIdx]) metrics[glyphName] = (max(0, advanceWidth + delta), sb) if ( tableTag == "VVAR" and getattr(vhvar, tableFields.vOrigMapping) is not None ): log.warning( "VORG table not yet updated to reflect changes in VVAR table" ) # For full instances (i.e. all axes pinned), we can simply drop HVAR/VVAR and return if set(location).issuperset(axis.axisTag for axis in fvarAxes): log.info("Dropping %s table", tableTag) del varfont[tableTag] return if varStore.VarRegionList.Region: # Only re-optimize VarStore if the HVAR/VVAR already uses indirect AdvWidthMap # or AdvHeightMap. If a direct, implicit glyphID->VariationIndex mapping is # used for advances, skip re-optimizing and maintain original VariationIndex. if getattr(vhvar, tableFields.advMapping): varIndexMapping = varStore.optimize(use_NO_VARIATION_INDEX=False) glyphOrder = varfont.getGlyphOrder() _remapVarIdxMap(vhvar, tableFields.advMapping, varIndexMapping, glyphOrder) if getattr(vhvar, tableFields.sb1): # left or top sidebearings _remapVarIdxMap(vhvar, tableFields.sb1, varIndexMapping, glyphOrder) if getattr(vhvar, tableFields.sb2): # right or bottom sidebearings _remapVarIdxMap(vhvar, tableFields.sb2, varIndexMapping, glyphOrder) if tableTag == "VVAR" and getattr(vhvar, tableFields.vOrigMapping): _remapVarIdxMap( vhvar, tableFields.vOrigMapping, varIndexMapping, glyphOrder ) def instantiateHVAR(varfont, axisLimits): return _instantiateVHVAR(varfont, axisLimits, varLib.HVAR_FIELDS) def instantiateVVAR(varfont, axisLimits): return _instantiateVHVAR(varfont, axisLimits, varLib.VVAR_FIELDS) class _TupleVarStoreAdapter(object): def __init__(self, regions, axisOrder, tupleVarData, itemCounts): self.regions = regions self.axisOrder = axisOrder self.tupleVarData = tupleVarData self.itemCounts = itemCounts @classmethod def fromItemVarStore(cls, itemVarStore, fvarAxes): axisOrder = [axis.axisTag for axis in fvarAxes] regions = [ region.get_support(fvarAxes) for region in itemVarStore.VarRegionList.Region ] tupleVarData = [] itemCounts = [] for varData in itemVarStore.VarData: variations = [] varDataRegions = (regions[i] for i in varData.VarRegionIndex) for axes, coordinates in zip(varDataRegions, zip(*varData.Item)): variations.append(TupleVariation(axes, list(coordinates))) tupleVarData.append(variations) itemCounts.append(varData.ItemCount) return cls(regions, axisOrder, tupleVarData, itemCounts) def rebuildRegions(self): # Collect the set of all unique region axes from the current TupleVariations. # We use an OrderedDict to de-duplicate regions while keeping the order. uniqueRegions = collections.OrderedDict.fromkeys( ( frozenset(var.axes.items()) for variations in self.tupleVarData for var in variations ) ) # Maintain the original order for the regions that pre-existed, appending # the new regions at the end of the region list. newRegions = [] for region in self.regions: regionAxes = frozenset(region.items()) if regionAxes in uniqueRegions: newRegions.append(region) del uniqueRegions[regionAxes] if uniqueRegions: newRegions.extend(dict(region) for region in uniqueRegions) self.regions = newRegions def instantiate(self, axisLimits): defaultDeltaArray = [] for variations, itemCount in zip(self.tupleVarData, self.itemCounts): defaultDeltas = instantiateTupleVariationStore(variations, axisLimits) if not defaultDeltas: defaultDeltas = [0] * itemCount defaultDeltaArray.append(defaultDeltas) # rebuild regions whose axes were dropped or limited self.rebuildRegions() pinnedAxes = set(axisLimits.pinnedLocation()) self.axisOrder = [ axisTag for axisTag in self.axisOrder if axisTag not in pinnedAxes ] return defaultDeltaArray def asItemVarStore(self): regionOrder = [frozenset(axes.items()) for axes in self.regions] varDatas = [] for variations, itemCount in zip(self.tupleVarData, self.itemCounts): if variations: assert len(variations[0].coordinates) == itemCount varRegionIndices = [ regionOrder.index(frozenset(var.axes.items())) for var in variations ] varDataItems = list(zip(*(var.coordinates for var in variations))) varDatas.append( builder.buildVarData(varRegionIndices, varDataItems, optimize=False) ) else: varDatas.append( builder.buildVarData([], [[] for _ in range(itemCount)]) ) regionList = builder.buildVarRegionList(self.regions, self.axisOrder) itemVarStore = builder.buildVarStore(regionList, varDatas) # remove unused regions from VarRegionList itemVarStore.prune_regions() return itemVarStore def instantiateItemVariationStore( itemVarStore, fvarAxes, axisLimits, hierarchical=False ): """Compute deltas at partial location, and update varStore in-place. Remove regions in which all axes were instanced, or fall outside the new axis limits. Scale the deltas of the remaining regions where only some of the axes were instanced. The number of VarData subtables, and the number of items within each, are not modified, in order to keep the existing VariationIndex valid. One may call VarStore.optimize() method after this to further optimize those. Args: varStore: An otTables.VarStore object (Item Variation Store) fvarAxes: list of fvar's Axis objects axisLimits: NormalizedAxisLimits: mapping axis tags to normalized min/default/max axis coordinates. May not specify coordinates/ranges for all the fvar axes. Returns: defaultDeltas: to be added to the default instance, of type dict of floats keyed by VariationIndex compound values: i.e. (outer << 16) + inner. """ tupleVarStore = _TupleVarStoreAdapter.fromItemVarStore(itemVarStore, fvarAxes) defaultDeltaArray = tupleVarStore.instantiate(axisLimits) newItemVarStore = tupleVarStore.asItemVarStore() itemVarStore.VarRegionList = newItemVarStore.VarRegionList if not hasattr(itemVarStore, "VarDataCount"): # Happens fromXML itemVarStore.VarDataCount = len(newItemVarStore.VarData) assert itemVarStore.VarDataCount == newItemVarStore.VarDataCount itemVarStore.VarData = newItemVarStore.VarData if not hierarchical: defaultDeltas = { ((major << 16) + minor): delta for major, deltas in enumerate(defaultDeltaArray) for minor, delta in enumerate(deltas) } defaultDeltas[itemVarStore.NO_VARIATION_INDEX] = 0 else: defaultDeltas = {0xFFFF: {0xFFFF: 0}} # NO_VARIATION_INDEX for major, deltas in enumerate(defaultDeltaArray): defaultDeltasForMajor = defaultDeltas.setdefault(major, {}) for minor, delta in enumerate(deltas): defaultDeltasForMajor[minor] = delta return defaultDeltas def _instantiateBASE(varfont, axisLimits): if "BASE" not in varfont: return base = varfont["BASE"].table varStore = getattr(base, "VarStore", None) if varStore is None: return log.info("Instantiating BASE table") fvarAxes = varfont["fvar"].axes defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits) merger = MutatorMerger( varfont, defaultDeltas, deleteVariations=(not varStore.VarRegionList.Region) ) merger.mergeTables(varfont, [varfont], ["BASE"]) if varStore.VarRegionList.Region: base.remap_device_varidxes(varStore.optimize()) else: # Downgrade BASE, it no longer references an ItemVariationStore. del base.VarStore base.Version = 0x00010000 def instantiateOTL(varfont, axisLimits): # TODO(anthrotype) Support partial instancing of JSTF table _instantiateBASE(varfont, axisLimits) if ( "GDEF" not in varfont or varfont["GDEF"].table.Version < 0x00010003 or not varfont["GDEF"].table.VarStore ): return if "GPOS" in varfont: msg = "Instantiating GDEF and GPOS tables" else: msg = "Instantiating GDEF table" log.info(msg) gdef = varfont["GDEF"].table varStore = gdef.VarStore fvarAxes = varfont["fvar"].axes defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits) # When VF are built, big lookups may overflow and be broken into multiple # subtables. MutatorMerger (which inherits from AligningMerger) reattaches # them upon instancing, in case they can now fit a single subtable (if not, # they will be split again upon compilation). # This 'merger' also works as a 'visitor' that traverses the OTL tables and # calls specific methods when instances of a given type are found. # Specifically, it adds default deltas to GPOS Anchors/ValueRecords and GDEF # LigatureCarets, and optionally deletes all VariationIndex tables if the # VarStore is fully instanced. merger = MutatorMerger( varfont, defaultDeltas, deleteVariations=(not varStore.VarRegionList.Region) ) merger.mergeTables(varfont, [varfont], ["GDEF", "GPOS"]) if varStore.VarRegionList.Region: varIndexMapping = varStore.optimize() gdef.remap_device_varidxes(varIndexMapping) if "GPOS" in varfont: varfont["GPOS"].table.remap_device_varidxes(varIndexMapping) else: # Downgrade GDEF. del gdef.VarStore gdef.Version = 0x00010002 if gdef.MarkGlyphSetsDef is None: del gdef.MarkGlyphSetsDef gdef.Version = 0x00010000 if not ( gdef.LigCaretList or gdef.MarkAttachClassDef or gdef.GlyphClassDef or gdef.AttachList or (gdef.Version >= 0x00010002 and gdef.MarkGlyphSetsDef) ): del varfont["GDEF"] def _isValidAvarSegmentMap(axisTag, segmentMap): if not segmentMap: return True if not {(-1.0, -1.0), (0, 0), (1.0, 1.0)}.issubset(segmentMap.items()): log.warning( f"Invalid avar SegmentMap record for axis '{axisTag}': does not " "include all required value maps {-1.0: -1.0, 0: 0, 1.0: 1.0}" ) return False previousValue = None for fromCoord, toCoord in sorted(segmentMap.items()): if previousValue is not None and previousValue > toCoord: log.warning( f"Invalid avar AxisValueMap({fromCoord}, {toCoord}) record " f"for axis '{axisTag}': the toCoordinate value must be >= to " f"the toCoordinate value of the preceding record ({previousValue})." ) return False previousValue = toCoord return True def downgradeCFF2ToCFF(varfont): # Save these properties recalcTimestamp = varfont.recalcTimestamp recalcBBoxes = varfont.recalcBBoxes # Disable them varfont.recalcTimestamp = False varfont.recalcBBoxes = False # Save to memory, reload, downgrade and save again, reload. # We do this dance because the convertCFF2ToCFF changes glyph # names, so following save would fail if any other table was # loaded and referencing glyph names. # # The second save+load is unfortunate but also necessary. stream = io.BytesIO() log.info("Saving CFF2 font to memory for downgrade") varfont.save(stream) stream.seek(0) varfont = TTFont(stream, recalcTimestamp=False, recalcBBoxes=False) convertCFF2ToCFF(varfont) stream = io.BytesIO() log.info("Saving downgraded CFF font to memory") varfont.save(stream) stream.seek(0) varfont = TTFont(stream, recalcTimestamp=False, recalcBBoxes=False) # Uncomment, to see test all tables can be loaded. This fails without # the extra save+load above. """ for tag in varfont.keys(): print("Loading", tag) varfont[tag] """ # Restore them varfont.recalcTimestamp = recalcTimestamp varfont.recalcBBoxes = recalcBBoxes return varfont def instantiateAvar(varfont, axisLimits, normalizedLimits=None, oldIntermediates=None): # 'axisLimits' dict must contain user-space (non-normalized) coordinates. if normalizedLimits is None: normalizedLimits = axisLimits.normalize(varfont) avar = varfont["avar"] segments = avar.segments # Capture the OLD avar v1 segment maps before the renormalization below # overwrites avar.segments; _instantiateAvarV2 needs them to collect the # interior breakpoints used for exact offset compensation. oldSegments = {tag: dict(mapping) for tag, mapping in segments.items()} # An avar version 2 font is never downgraded to version 1: even with a # NULL/empty VarStore, partial instancing takes the avar2 path (offset # compensation creates delta rows on demand), which keeps the other # variation tables in old coordinate space and the instance bit-exact. version = getattr(avar, "majorVersion", 1) isAvar2 = version >= 2 # Drop the table if we instantiate all the axes. An avar2 VarStore may # drive axes that have no v1 segment map (segments can even be empty), # so for those fonts require ALL fvar axes to be pinned, not merely all # segment-mapped ones — otherwise we'd delete live avar2 variation data. pinnedAxes = set(axisLimits.pinnedLocation()) if pinnedAxes.issuperset(axis.axisTag for axis in varfont["fvar"].axes) or ( not isAvar2 and pinnedAxes.issuperset(segments) ): log.info("Dropping avar table") del varfont["avar"] return # For avar2: need old intermediate values BEFORE modifying avar v1. if isAvar2 and oldIntermediates is None: oldIntermediates = _computeOldIntermediates(varfont, axisLimits) log.info("Instantiating avar table") if isAvar2: # For avar2 partial instancing, keep identity segments for pinned axes # that will be hidden in fvar. compile() needs segment entries for all # fvar axes. Self-contained axes' segments will be removed later when # they're removed from fvar. for axis in pinnedAxes: if axis in segments: segments[axis] = {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0} else: for axis in pinnedAxes: if axis in segments: del segments[axis] # Standard avar v1 instancing (unchanged). normalizedRanges = axisLimits.normalize(varfont, usingAvar=False) newSegments = {} for axisTag, mapping in segments.items(): if not _isValidAvarSegmentMap(axisTag, mapping): continue if mapping and axisTag in normalizedRanges: axisRange = normalizedRanges[axisTag] mappedMin = floatToFixedToFloat( piecewiseLinearMap(axisRange.minimum, mapping), 14 ) mappedDef = floatToFixedToFloat( piecewiseLinearMap(axisRange.default, mapping), 14 ) mappedMax = floatToFixedToFloat( piecewiseLinearMap(axisRange.maximum, mapping), 14 ) mappedAxisLimit = NormalizedAxisTripleAndDistances( mappedMin, mappedDef, mappedMax, axisRange.distanceNegative, axisRange.distancePositive, ) newMapping = {} for fromCoord, toCoord in mapping.items(): if fromCoord < axisRange.minimum or fromCoord > axisRange.maximum: continue fromCoord = axisRange.renormalizeValue(fromCoord) assert mappedMin <= toCoord <= mappedMax toCoord = mappedAxisLimit.renormalizeValue(toCoord) fromCoord = floatToFixedToFloat(fromCoord, 14) toCoord = floatToFixedToFloat(toCoord, 14) newMapping[fromCoord] = toCoord newMapping.update({-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}) newSegments[axisTag] = newMapping else: newSegments[axisTag] = mapping avar.segments = newSegments if version == 1: return {} assert version == 2 return _instantiateAvarV2( varfont, axisLimits, normalizedLimits, oldIntermediates, oldSegments ) def _computeOldIntermediates(varfont, axisLimits): """Compute old intermediate axis values before avar v1 modification. For each axis in axisLimits, computes the old intermediate coordinate values (post-fvar normalization + avar v1 mapping) at the new min, default, and max user-space values. Returns: dict mapping axis tags to (a_i, d_i, b_i) tuples where: - a_i = old intermediate value at the new minimum - d_i = old intermediate value at the new default - b_i = old intermediate value at the new maximum All values are in normalized coordinate space [-1, +1]. """ fvar = varfont["fvar"] avar = varfont["avar"] avarSegments = avar.segments result = {} for axis in fvar.axes: tag = axis.axisTag if tag not in axisLimits: continue triple = axisLimits[tag] minV = triple.minimum defV = triple.default if triple.default is not None else axis.defaultValue maxV = triple.maximum # Normalize using old fvar, map through old avar v1 segment map, # quantize to F2Dot14 — all via the module's normalize() helper so # the rounding behavior lives in a single place. oldFvarTriple = (axis.minValue, axis.defaultValue, axis.maxValue) avarMapping = avarSegments.get(tag, None) result[tag] = tuple( normalize(v, oldFvarTriple, avarMapping) for v in (minV, defV, maxV) ) return result # Threshold, in F2Dot14 units, above which a restricted avar2 axis's residual # offset-compensation error triggers a warning. The quantization floor is ~2 and # small residuals (a few units) are common and imperceptible for real avar v1 # maps; only a clearly larger residual — a steep retained segment that cannot be # reproduced bit-exactly — is worth flagging (see _estimateAvar2OffsetError). _AVAR2_OFFSET_WARN_THRESHOLD = 8 def _estimateAvar2OffsetError(oldSeg, newSeg, oldFvarTriple, newTriple, offsetByZ): """Estimate the residual offset-compensation error for one restricted axis. Returns the max |old-avar1-final - (new-avar1 + offset)| over the retained user range, in F2Dot14 units. offset(z) is the piecewise-linear function through the offsetByZ knots (what the 1-D VariationModel reproduces). The residual is dominated by F2Dot14 requantization of a steep retained avar v1 segment (e.g. a moved default compressing part of the axis into a narrow z band); offset compensation cannot remove it. Used only to decide which encoding ships and whether to warn. Sampled on a uniform grid augmented with the user-space preimages of every kink of the residual (old/new avar v1 breakpoints and offset(z) knots), so the worst kink cannot fall between uniform samples. The pointwise F2Dot14 rounding makes this an estimate rather than an exact bound, but every piecewise-linear extremum is visited. """ lo, _, hi = newTriple samples = 257 us = {lo + (hi - lo) * i / (samples - 1) for i in range(samples)} denormOld = {-1.0: oldFvarTriple[0], 0.0: oldFvarTriple[1], 1.0: oldFvarTriple[2]} denormNew = {-1.0: newTriple[0], 0.0: newTriple[1], 1.0: newTriple[2]} if oldSeg: us.update(piecewiseLinearMap(n, denormOld) for n in oldSeg) zToNorm = None if newSeg: us.update(piecewiseLinearMap(n, denormNew) for n in newSeg) # Inverse of newSeg, to pull offset(z) knots back to normalized space. # (For a non-strictly-monotone map this picks one preimage, which is # fine: the uniform grid still covers the rest.) zToNorm = {v: k for k, v in newSeg.items()} for z in offsetByZ: n = piecewiseLinearMap(z, zToNorm) if zToNorm else z us.add(piecewiseLinearMap(n, denormNew)) maxErr = 0 for u in us: if not (lo <= u <= hi): continue nOld = normalizeValue(u, oldFvarTriple) oldFinal = piecewiseLinearMap(nOld, oldSeg) if oldSeg else nOld nNew = normalizeValue(u, newTriple) z = piecewiseLinearMap(nNew, newSeg) if newSeg else nNew newFinal = z + piecewiseLinearMap(z, offsetByZ) maxErr = max(maxErr, abs(otRound(oldFinal * 16384) - otRound(newFinal * 16384))) return maxErr def _effectiveAvar2VarIdx(varStore, varIdxMap, axisIdx): """The varIdx an axis actually resolves to in the avar2 VarStore. Entries (or the implicit identity mapping) that point outside the store behave as "no variation" at runtime, so report them as NO_VARIATION_INDEX; this also covers a NULL VarStore. """ varIdx = varIdxMap[axisIdx] if varIdxMap is not None else axisIdx if varIdx == NO_VARIATION_INDEX or varStore is None: return NO_VARIATION_INDEX outer, inner = varIdx >> 16, varIdx & 0xFFFF if outer >= len(varStore.VarData) or inner >= varStore.VarData[outer].ItemCount: return NO_VARIATION_INDEX return varIdx def _instantiateAvarV2( varfont, axisLimits, normalizedLimits, oldIntermediates, oldSegments=None ): """Instance avar v2 IVS: rebase regions + add offset compensation. This implements the offset compensation approach from the design doc: 1. Rebase IVS regions to new intermediate coordinate space (via rebaseTent) 2. Detect self-contained pinned axes (whose final coord is constant) 3. Add offset compensation entries (empty-region bias + tent deltas) for non-self-contained axes so final coordinates remain in old space 4. Self-contained axes are skipped — their contributions will be folded into gvar/HVAR/etc. by the caller Returns: dict: {axisTag: finalCoord} for self-contained pinned axes that can be removed from fvar. finalCoord is in old-space normalized coordinates. """ avar = varfont["avar"] fvarAxes = varfont["fvar"].axes varStore = avar.table.VarStore varIdxMap = getattr(avar.table, "VarIdxMap", None) oldSegments = oldSegments or {} # A compiled DeltaSetIndexMap may be stored truncated (trailing duplicate # entries are trimmed; reads clamp to the last entry). We write into the # mapping by axis index below, so pad it back to full length first, with # the same value clamping would have produced for each index. if varIdxMap is not None and len(varIdxMap.mapping) < len(fvarAxes): mapping = varIdxMap.mapping fill = mapping[-1] if mapping else NO_VARIATION_INDEX mapping.extend([fill] * (len(fvarAxes) - len(mapping))) # Normalize mapping entries that don't resolve to a real store row (out # of range, or a NULL VarStore) to NO_VARIATION_INDEX, mirroring runtime # behavior. A version 2 font is never downgraded: with a NULL VarStore we # start from an empty store and offset compensation creates delta rows on # demand. numAxes = len(fvarAxes) rawMapping = [varIdxMap[i] if varIdxMap is not None else i for i in range(numAxes)] effectiveMapping = [ _effectiveAvar2VarIdx(varStore, varIdxMap, i) for i in range(numAxes) ] if effectiveMapping != rawMapping: if varIdxMap is None: varIdxMap = builder.buildDeltaSetIndexMap(effectiveMapping) avar.table.VarIdxMap = varIdxMap else: varIdxMap.mapping = effectiveMapping if varStore is None: varStore = builder.buildVarStore( builder.buildVarRegionList([], [a.axisTag for a in fvarAxes]), [] ) avar.table.VarStore = varStore # Step 1: Convert IVS to TupleVariation representation and rebase tupleVarStore = _TupleVarStoreAdapter.fromItemVarStore(varStore, fvarAxes) # Save original axis order (instantiate() will remove pinned axes) originalAxisOrder = list(tupleVarStore.axisOrder) # Rebase regions for restricted/pinned axes defaultDeltaArray = tupleVarStore.instantiate(normalizedLimits) # Restore full axis order — for avar2 we keep all fvar axes tupleVarStore.axisOrder = originalAxisOrder # Step 1.5: Detect self-contained pinned axes. # A pinned axis is self-contained if its final coordinate doesn't vary # with any remaining axes. After IVS rebasing, check if any remaining # TupleVariation has a non-zero delta at the axis's inner position. selfContainedAxes = {} pinnedLocation = axisLimits.pinnedLocation() for axisIdx, axis in enumerate(fvarAxes): tag = axis.axisTag if tag not in pinnedLocation: continue # Get this axis's varIdx if varIdxMap is not None: varIdx = varIdxMap[axisIdx] else: varIdx = axisIdx if varIdx == NO_VARIATION_INDEX: # No IVS entry → final coord = intermediate coord (self-contained) a_i, d_i, b_i = oldIntermediates[tag] finalInt = otRound(d_i * 16384) finalInt = min(max(finalInt, -(1 << 14)), 1 << 14) selfContainedAxes[tag] = finalInt / 16384.0 continue outer = varIdx >> 16 inner = varIdx & 0xFFFF if outer >= len(tupleVarStore.tupleVarData): continue # Check if any remaining TupleVariation has a non-zero delta hasDelta = any( tv.coordinates[inner] != 0 for tv in tupleVarStore.tupleVarData[outer] ) if not hasDelta: # Self-contained: final coord = intermediate + defaultDelta a_i, d_i, b_i = oldIntermediates[tag] d_int = otRound(d_i * 16384) defaultDelta = defaultDeltaArray[outer][inner] finalInt = d_int + otRound(defaultDelta) finalInt = min(max(finalInt, -(1 << 14)), 1 << 14) selfContainedAxes[tag] = finalInt / 16384.0 if selfContainedAxes: log.info( "Self-contained pinned axes (removable): %s", {tag: round(v, 6) for tag, v in selfContainedAxes.items()}, ) # Step 1.75: Privatize shared varIdx delta rows before adding offset # compensation. avar2's VarIdxMap may map several fvar axes to the SAME # IVS delta row (as the parametric test font does: e.g. GRAD and XOPQ can # share one varIdx). Writing one axis's offset-compensation deltas into a # shared row would corrupt every OTHER axis that reads that row. So give # each offset-receiving axis whose row is shared its own private copy of # the row (identical contents, preserving the rebased delta), then repoint # its VarIdxMap entry. Sharers keep the clean row; offsets land privately. def _axisVarIdx(i): return varIdxMap[i] if varIdxMap is not None else i varIdxRefCount = collections.Counter(_axisVarIdx(i) for i in range(len(fvarAxes))) for axisIdx, axis in enumerate(fvarAxes): tag = axis.axisTag # Only axes that will receive offset compensation (restricted or # non-self-contained pinned) can contaminate a shared row. if tag not in axisLimits or tag in selfContainedAxes: continue varIdx = _axisVarIdx(axisIdx) if varIdx == NO_VARIATION_INDEX: continue # gets a fresh, private VarData in the offset loop below if varIdxRefCount[varIdx] <= 1: continue # sole owner: safe to write offsets in place # Shared: duplicate this axis's item (row) within its VarData so it has # a private inner index. Copy the value from every TupleVariation so the # rebased delta is preserved; extend defaultDeltaArray in tandem. outer = varIdx >> 16 inner = varIdx & 0xFFFF newInner = tupleVarStore.itemCounts[outer] for tv in tupleVarStore.tupleVarData[outer]: tv.coordinates.append(tv.coordinates[inner]) tupleVarStore.itemCounts[outer] += 1 defaultDeltaArray[outer].append(defaultDeltaArray[outer][inner]) newVarIdx = (outer << 16) | newInner # A shared varIdx implies an explicit VarIdxMap (implicit identity maps # every axis to a distinct varIdx, so we could not be here otherwise). assert varIdxMap is not None varIdxMap.mapping[axisIdx] = newVarIdx varIdxRefCount[varIdx] -= 1 varIdxRefCount[newVarIdx] += 1 # Step 2: Add offset compensation TupleVariations processedVarIdxes = set() approxWarn = {} # {axisTag: residual F2Dot14 error} for axes we can't make exact for axisIdx, axis in enumerate(fvarAxes): tag = axis.axisTag # Determine this axis's varIdx if varIdxMap is not None: varIdx = varIdxMap[axisIdx] else: varIdx = axisIdx if tag in axisLimits: if tag in selfContainedAxes: # Self-contained pinned axis: skip offset compensation. # Its contribution will be folded into gvar/HVAR/etc. instead. continue # This axis is being restricted or pinned (non-self-contained) a_i, d_i, b_i = oldIntermediates[tag] isPinned = axisLimits[tag].minimum == axisLimits[tag].maximum if varIdx == NO_VARIATION_INDEX: # This axis has no existing avar2 mapping. Create a new # VarData with one item to hold the offset compensation. outer = len(tupleVarStore.tupleVarData) inner = 0 tupleVarStore.tupleVarData.append([]) tupleVarStore.itemCounts.append(1) defaultDeltaArray.append([0]) varIdx = (outer << 16) | inner # Update VarIdxMap if varIdxMap is None: # Identity mapping for all axes, except this one mapping = list(range(len(fvarAxes))) mapping[axisIdx] = varIdx varIdxMap = builder.buildDeltaSetIndexMap(mapping) avar.table.VarIdxMap = varIdxMap else: varIdxMap.mapping[axisIdx] = varIdx outer = varIdx >> 16 inner = varIdx & 0xFFFF itemCount = tupleVarStore.itemCounts[outer] defaultDelta = defaultDeltaArray[outer][inner] # Offset compensation encodes, as avar2 deltas on axis `tag`, the # piecewise-linear function offset(z) = inv_renorm(z) - z, where # inv_renorm maps a new intermediate coordinate z back to the old # intermediate coordinate. It is known at these breakpoints in the # new intermediate space: # z = -1 -> a_i + 1 (new minimum) # z = 0 -> d_i (new default) # z = +1 -> b_i - 1 (new maximum) # If the axis default MOVED, inv_renorm also kinks where the OLD # default lands in the new space (old intermediate crosses 0), at # z = z_old -> -z_old # Omitting that breakpoint (as a plain two-tent encoding does) makes # interior coordinates wrong. Feed all breakpoints to a VariationModel # to synthesize the correct tents; this reduces to the two classic # tents when the default is unchanged (z_old == 0). offsetByZ = {-1.0: a_i + 1.0, 0.0: d_i, 1.0: b_i - 1.0} if not isPinned: newSeg = avar.segments.get(tag) newLimit = axisLimits[tag] # default may be unpopulated (2-tuple limits): means unchanged. newDefault = ( newLimit.default if newLimit.default is not None else axis.defaultValue ) newTriple = (newLimit.minimum, newDefault, newLimit.maximum) # Moved-default kink: where the OLD default lands in new space. zOldNorm = normalizeValue(axis.defaultValue, newTriple) zOld = piecewiseLinearMap(zOldNorm, newSeg) if newSeg else zOldNorm zOld = floatToFixedToFloat(zOld, 14) if -1.0 < zOld < 1.0 and zOld not in offsetByZ: offsetByZ[zOld] = -zOld # Interior avar v1 breakpoints inside the retained range each put # a kink in offset(z). Sampling only {-1, 0, +1, z_old} would # linearly interpolate across those kinks (approximate, worst # under a moved default on an asymmetric axis). The retained # (renormalized) avar v1 map keeps exactly the in-range old # breakpoints, and z(u) kinks at each retained breakpoint's # output coordinate; add that z with its old intermediate value # so the model reproduces offset(z) at every kink. Iterating the # retained map lands each sample on the F2Dot14-quantized kink. oldSeg = oldSegments.get(tag) oldFvarTriple = (axis.minValue, axis.defaultValue, axis.maxValue) withBreakpoints = dict(offsetByZ) if oldSeg and newSeg: denormNew = { -1.0: newTriple[0], 0.0: newTriple[1], 1.0: newTriple[2], } for fromCoordNew, z in newSeg.items(): if fromCoordNew in (-1.0, 0.0, 1.0): continue # anchors already seeded z = floatToFixedToFloat(z, 14) if not (-1.0 < z < 1.0) or z in withBreakpoints: continue user = piecewiseLinearMap(fromCoordNew, denormNew) nOld = normalizeValue(user, oldFvarTriple) xOld = piecewiseLinearMap(nOld, oldSeg) withBreakpoints[z] = floatToFixedToFloat(xOld, 14) - z # Extra tents cost F2Dot14 rounding, so for a steep segment they # can add more quantization noise than the structural error they # remove. Keep the interior breakpoints only when they do not # increase the estimated residual; this makes the collection a # strict (never-worse) improvement over the {-1,0,+1,z_old} # anchors. Warn when even the better choice is not bit-exact # (a steep retained segment we cannot reproduce in F2Dot14). errAnchors = _estimateAvar2OffsetError( oldSeg, newSeg, oldFvarTriple, newTriple, offsetByZ ) if len(withBreakpoints) == len(offsetByZ): # Collection added no breakpoints (the common case): # the two encodings are identical, skip the second pass. approxErr = errAnchors else: errWith = _estimateAvar2OffsetError( oldSeg, newSeg, oldFvarTriple, newTriple, withBreakpoints ) if errWith <= errAnchors: offsetByZ = withBreakpoints approxErr = errWith else: approxErr = errAnchors if approxErr > _AVAR2_OFFSET_WARN_THRESHOLD: approxWarn[tag] = approxErr if isPinned: # Pinned axis has zero range: only the constant bias applies. bias = otRound(d_i * 16384) + otRound(defaultDelta) if bias != 0: coords = [0] * itemCount coords[inner] = bias tupleVarStore.tupleVarData[outer].append(TupleVariation({}, coords)) else: zs = sorted(offsetByZ) model = VariationModel([{tag: z} for z in zs], axisOrder=[tag]) deltas = model.getDeltas([offsetByZ[z] for z in zs]) for delta, support in zip(deltas, model.supports): di = otRound(delta * 16384) if not support: # Base (empty) region: fold in the rebased default delta. di += otRound(defaultDelta) if di != 0: coords = [0] * itemCount coords[inner] = di tupleVarStore.tupleVarData[outer].append( TupleVariation(dict(support), coords) ) else: # Free or private axis — not being restricted. # If it has a non-zero default delta (from rebasing restricted axes' # contributions), add it back as a bias. The standard instancing # subtracts default deltas, but for avar2 we must preserve them. # Skip if this varIdx was already processed (multiple axes can # share the same varIdx entry). if varIdx == NO_VARIATION_INDEX: continue if varIdx in processedVarIdxes: continue processedVarIdxes.add(varIdx) outer = varIdx >> 16 inner = varIdx & 0xFFFF if outer >= len(defaultDeltaArray): continue defaultDelta = defaultDeltaArray[outer][inner] if otRound(defaultDelta) != 0: itemCount = tupleVarStore.itemCounts[outer] coords = [0] * itemCount coords[inner] = otRound(defaultDelta) tupleVarStore.tupleVarData[outer].append(TupleVariation({}, coords)) if approxWarn: log.warning( "avar2 partial instancing is approximate (not bit-exact) for " "axes %s: the avar v1 segment map has interior breakpoint(s) in the " "retained range that, after a moved/edge default, require a steep " "F2Dot14 segment the offset compensation cannot fully reproduce. " "Max residual: %s F2Dot14 units.", ", ".join(sorted(approxWarn)), {tag: approxWarn[tag] for tag in sorted(approxWarn)}, ) # Step 3: Remove self-contained axes from axis order. # The VarRegionList must match the post-removal fvar axis count. # After instancing, no TupleVariation references self-contained axes. if selfContainedAxes: tupleVarStore.axisOrder = [ tag for tag in tupleVarStore.axisOrder if tag not in selfContainedAxes ] # Step 4: Rebuild regions and convert back to IVS tupleVarStore.rebuildRegions() newVarStore = tupleVarStore.asItemVarStore() # Step 5: Optimize the VarStore (merge regions, remove zero deltas) varIdxMapping = newVarStore.optimize(use_NO_VARIATION_INDEX=False) if varIdxMap is not None: varIdxMap.mapping = [ varIdxMapping.get(varIdx, varIdx) for varIdx in varIdxMap.mapping ] elif varIdxMapping: # optimize() remapped indices but we had no VarIdxMap (implicit # identity). Create one if the mapping is no longer identity. varIdxMap = builder.buildDeltaSetIndexMap( varIdxMapping.get(i, i) for i in range(len(fvarAxes)) ) if varIdxMap is not None: avar.table.VarIdxMap = varIdxMap avar.table.VarStore = newVarStore return selfContainedAxes def _isTupleVariationDead(tv, reachableRanges): """Check if a TupleVariation is dead (unreachable) given reachable axis ranges.""" for tag, (lo, hi) in reachableRanges.items(): if tag not in tv.axes: continue start, peak, end = tv.axes[tag] # Region is dead if entirely outside reachable range. Strict # comparisons: supportScalar returns 1 (not 0) at v == peak, so a # tent whose peak sits exactly on the reachable boundary (peak == # start == hi, or peak == end == lo) is still live there. if hi < start or lo > end: return True return False def _cullItemVariationStore(varStore, fvarAxes, reachableRanges): """Remove dead regions from an ItemVariationStore via TupleVariation culling. Converts to TupleVariations, removes dead ones, converts back. Returns the number of removed regions. """ tupleVarStore = _TupleVarStoreAdapter.fromItemVarStore(varStore, fvarAxes) totalCulled = 0 for tvList in tupleVarStore.tupleVarData: before = len(tvList) tvList[:] = [ tv for tv in tvList if not _isTupleVariationDead(tv, reachableRanges) ] totalCulled += before - len(tvList) if totalCulled: tupleVarStore.rebuildRegions() newVarStore = tupleVarStore.asItemVarStore() varStore.VarRegionList = newVarStore.VarRegionList varStore.VarData = newVarStore.VarData return totalCulled def _cullVariationsForAvar2(varfont, reachableRanges): """Cull dead variations outside reachable old-space final-coord ranges. After avar2 partial instancing with offset compensation, variation tables remain in old-space final coordinates. Some regions may be unreachable because the restricted axis range maps to a narrower final-coord range. For gvar/cvar: removes TupleVariations whose axis regions lie entirely outside the reachable range. For IVS-based tables (HVAR, VVAR, MVAR, GDEF): removes dead region columns from the ItemVariationStore. Args: varfont: The font being instanced. reachableRanges: dict mapping axis tags to (lo, hi) tuples representing the reachable range of old-space final coordinates for that axis. """ fvarAxes = varfont["fvar"].axes if "gvar" in varfont: gvar = varfont["gvar"] totalCulled = 0 totalTotal = 0 for glyphName in gvar.variations: variations = gvar.variations[glyphName] newVariations = [] for tv in variations: totalTotal += 1 if _isTupleVariationDead(tv, reachableRanges): totalCulled += 1 else: newVariations.append(tv) gvar.variations[glyphName] = newVariations if totalCulled: log.info( "avar2 gvar culling: removed %d / %d TupleVariations (%.1f%%)", totalCulled, totalTotal, 100 * totalCulled / totalTotal if totalTotal else 0, ) if "cvar" in varfont: cvar = varfont["cvar"] before = len(cvar.variations) cvar.variations = [ tv for tv in cvar.variations if not _isTupleVariationDead(tv, reachableRanges) ] culled = before - len(cvar.variations) if culled: log.info( "avar2 cvar culling: removed %d / %d TupleVariations (%.1f%%)", culled, before, 100 * culled / before if before else 0, ) # Cull all IVS-based tables. A fixed tag list (like the rest of the # instancer uses) rather than probing every table in the font, which # would force-decompile unrelated tables for nothing. # TODO: VARC's MultiVarStore is not culled here (different structure). for tag in ("HVAR", "VVAR", "MVAR", "GDEF", "BASE", "COLR"): if tag not in varfont: continue table = varfont[tag] table = getattr(table, "table", table) vs = getattr(table, "VarStore", None) if vs is None: continue removed = _cullItemVariationStore(vs, fvarAxes, reachableRanges) if removed: log.info( "avar2 %s culling: removed %d dead region references", tag, removed, ) # CFF2: cull via populate/cull/depopulate cycle (blend operators encode # region counts, so the VarStore can't be culled without updating them). if "CFF2" in varfont: cff = varfont["CFF2"].cff topDict = cff.topDictIndex[0] if getattr(topDict, "VarStore", None) and topDict.VarStore.otVarStore: instantiateCFF2( varfont, NormalizedAxisLimits({}), reachableRanges=reachableRanges, ) def _computeReachableRangesForAvar2(varfont, axisLimits, reachableRanges): """Compute reachable old-space final-coord ranges using getExtremes. For axes not already in reachableRanges (i.e., axes with IVS entries rather than NO_VARIATION_INDEX), compute conservative bounds on the reachable old-space final coordinate using getExtremes on the instanced avar v2 VarStore. This is critical for parametric fonts where gvar references private axes driven by public axes through avar2. Restricting wght narrows the reachable range of XOPQ, YOPQ, etc. Must be called AFTER _instantiateFvarForAvar2 has updated fvar and VarIdxMap so that axes and VarStore match. """ avar = varfont["avar"] fvarAxes = varfont["fvar"].axes varStore = getattr(avar.table, "VarStore", None) if varStore is None: return varIdxMap = getattr(avar.table, "VarIdxMap", None) # Private axes (originally hidden, not user-restricted) always have # intermediate coordinate = 0. Any VarStore region referencing them # as input gets scalar = 0 at intermediate = 0. Tell getExtremes # by pinning them at (0, 0, 0) so it correctly zeros out those regions. privateAxisLimits = {} for axis in fvarAxes: if axis.flags & 0x1 and axis.axisTag not in axisLimits: privateAxisLimits[axis.axisTag] = (0, 0, 0) for axisIdx, axis in enumerate(fvarAxes): tag = axis.axisTag if tag in reachableRanges: continue # Already computed (NO_VARIATION_INDEX, exact) if varIdxMap is not None: varIdx = varIdxMap[axisIdx] else: varIdx = axisIdx if varIdx == NO_VARIATION_INDEX: continue # Private axes have no identity term (intermediate always 0, # final = delta only). Public axes have identity + delta. private = axis.flags & 0x1 identityAxisIndex = None if private else axisIdx # Pass privateAxisLimits so getExtremes correctly zeros out # regions referencing private axes (whose intermediate is always 0). minV, maxV = varStore.getExtremes( varIdx, fvarAxes, privateAxisLimits, identityAxisIndex ) lo = min(+1, max(-1, minV / 16384)) hi = min(+1, max(-1, maxV / 16384)) assert lo <= hi reachableRanges[tag] = (lo, hi) log.info( "avar2 getExtremes range for %s: [%.4f, %.4f]", tag, lo, hi, ) def _instantiateFvarForAvar2(varfont, axisLimits, selfContainedAxes=None): """Update fvar for avar2 partial instancing. Self-contained pinned axes (in selfContainedAxes dict) are removed from fvar and their contributions folded into variation tables. Non-self-contained pinned axes are kept as hidden axes. Restricted axes have their ranges updated. """ fvar = varfont["fvar"] pinnedLocation = axisLimits.pinnedLocation() if selfContainedAxes is None: selfContainedAxes = {} log.info("Instantiating fvar table (avar2 mode)") # Track removed axis indices for VarIdxMap update removedAxisIndices = set() originalAxisCount = len(fvar.axes) axes = [] for axisIdx, axis in enumerate(fvar.axes): tag = axis.axisTag if tag in selfContainedAxes: # Self-contained pinned axis: remove from fvar removedAxisIndices.add(axisIdx) continue if tag in axisLimits: triple = axisLimits[tag] defV = triple.default if triple.default is not None else axis.defaultValue axis.minValue = triple.minimum axis.defaultValue = defV axis.maxValue = triple.maximum if tag in pinnedLocation: # Non-self-contained pinned: keep as hidden axis.flags |= 0x0001 # HIDDEN_AXIS axes.append(axis) fvar.axes = axes # Update avar for removed axes if removedAxisIndices and "avar" in varfont: avar = varfont["avar"] # Remove segment maps for removed axes for tag in selfContainedAxes: avar.segments.pop(tag, None) # Update VarIdxMap to reflect new axis positions if getattr(avar, "majorVersion", 1) >= 2: varIdxMap = getattr(avar.table, "VarIdxMap", None) if varIdxMap is not None: varIdxMap.mapping = [ varIdxMap.mapping[i] for i in range(len(varIdxMap.mapping)) if i not in removedAxisIndices ] else: # Implicit identity mapping: axis i → varIdx i. # After removing axes, need explicit mapping if non-identity. varIdxMap = builder.buildDeltaSetIndexMap( i for i in range(originalAxisCount) if i not in removedAxisIndices ) if varIdxMap is not None: avar.table.VarIdxMap = varIdxMap # Filter named instances: keep those within the new ranges instances = [] for instance in fvar.instances: # Check that pinned axis values match if any( instance.coordinates.get(tag) != value for tag, value in pinnedLocation.items() ): continue if not isInstanceWithinAxisRanges(instance.coordinates, axisLimits): continue # Remove coordinates for removed axes for tag in selfContainedAxes: instance.coordinates.pop(tag, None) instances.append(instance) fvar.instances = instances def isInstanceWithinAxisRanges(location, axisRanges): for axisTag, coord in location.items(): if axisTag in axisRanges: axisRange = axisRanges[axisTag] if coord < axisRange.minimum or coord > axisRange.maximum: return False return True def instantiateFvar(varfont, axisLimits): # 'axisLimits' dict must contain user-space (non-normalized) coordinates location = axisLimits.pinnedLocation() fvar = varfont["fvar"] # drop table if we instantiate all the axes if set(location).issuperset(axis.axisTag for axis in fvar.axes): log.info("Dropping fvar table") del varfont["fvar"] return log.info("Instantiating fvar table") axes = [] for axis in fvar.axes: axisTag = axis.axisTag if axisTag in location: continue if axisTag in axisLimits: triple = axisLimits[axisTag] if triple.default is None: triple = (triple.minimum, axis.defaultValue, triple.maximum) axis.minValue, axis.defaultValue, axis.maxValue = triple axes.append(axis) fvar.axes = axes # only keep NamedInstances whose coordinates == pinned axis location instances = [] for instance in fvar.instances: if any(instance.coordinates[axis] != value for axis, value in location.items()): continue for axisTag in location: del instance.coordinates[axisTag] if not isInstanceWithinAxisRanges(instance.coordinates, axisLimits): continue instances.append(instance) fvar.instances = instances def instantiateSTAT(varfont, axisLimits): # 'axisLimits' dict must contain user-space (non-normalized) coordinates stat = varfont["STAT"].table if not stat.DesignAxisRecord or not ( stat.AxisValueArray and stat.AxisValueArray.AxisValue ): return # STAT table empty, nothing to do log.info("Instantiating STAT table") newAxisValueTables = axisValuesFromAxisLimits(stat, axisLimits) stat.AxisValueCount = len(newAxisValueTables) if stat.AxisValueCount: stat.AxisValueArray.AxisValue = newAxisValueTables else: stat.AxisValueArray = None def axisValuesFromAxisLimits(stat, axisLimits): def isAxisValueOutsideLimits(axisTag, axisValue): if axisTag in axisLimits: triple = axisLimits[axisTag] if axisValue < triple.minimum or axisValue > triple.maximum: return True return False # only keep AxisValues whose axis is not pinned nor restricted, or is pinned at the # exact (nominal) value, or is restricted but the value is within the new range designAxes = stat.DesignAxisRecord.Axis newAxisValueTables = [] for axisValueTable in stat.AxisValueArray.AxisValue: axisValueFormat = axisValueTable.Format if axisValueFormat in (1, 2, 3): axisTag = designAxes[axisValueTable.AxisIndex].AxisTag if axisValueFormat == 2: axisValue = axisValueTable.NominalValue else: axisValue = axisValueTable.Value if isAxisValueOutsideLimits(axisTag, axisValue): continue elif axisValueFormat == 4: # drop 'non-analytic' AxisValue if _any_ AxisValueRecord doesn't match # the pinned location or is outside range dropAxisValueTable = False for rec in axisValueTable.AxisValueRecord: axisTag = designAxes[rec.AxisIndex].AxisTag axisValue = rec.Value if isAxisValueOutsideLimits(axisTag, axisValue): dropAxisValueTable = True break if dropAxisValueTable: continue else: log.warning("Unknown AxisValue table format (%s); ignored", axisValueFormat) newAxisValueTables.append(axisValueTable) return newAxisValueTables def setMacOverlapFlags(glyfTable): flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND flagOverlapSimple = _g_l_y_f.flagOverlapSimple for glyphName in glyfTable.keys(): glyph = glyfTable[glyphName] # Set OVERLAP_COMPOUND bit for compound glyphs if glyph.isComposite(): glyph.components[0].flags |= flagOverlapCompound # Set OVERLAP_SIMPLE bit for simple glyphs elif glyph.numberOfContours > 0: glyph.flags[0] |= flagOverlapSimple def normalize(value, triple, avarMapping=None): value = normalizeValue(value, triple) if avarMapping: value = piecewiseLinearMap(value, avarMapping) # Quantize to F2Dot14, to avoid surprise interpolations. return floatToFixedToFloat(value, 14) def _instantiateVariationTables(varfont, limits, optimize, downgradeCFF2): """Instantiate all variation-carrying tables at the given limits. Returns the new value of the downgradeCFF2 flag (instantiateCFF2 reports whether a downgrade to CFF is actually possible, i.e. nothing varies). """ if "VARC" in varfont: instantiateVARC(varfont, limits) if "CFF2" in varfont: downgradeCFF2 = instantiateCFF2(varfont, limits, downgrade=downgradeCFF2) if "gvar" in varfont: instantiateGvar(varfont, limits, optimize=optimize) if "cvar" in varfont: instantiateCvar(varfont, limits) if "MVAR" in varfont: instantiateMVAR(varfont, limits) if "HVAR" in varfont: instantiateHVAR(varfont, limits) if "VVAR" in varfont: instantiateVVAR(varfont, limits) instantiateOTL(varfont, limits) instantiateFeatureVariations(varfont, limits) return downgradeCFF2 def sanityCheckVariableTables(varfont): if "fvar" not in varfont: raise ValueError("Missing required table fvar") if "gvar" in varfont: if "glyf" not in varfont: raise ValueError("Can't have gvar without glyf") def instantiateVariableFont( varfont, axisLimits, inplace=False, optimize=True, overlap=OverlapMode.KEEP_AND_SET_FLAGS, updateFontNames=False, *, downgradeCFF2=False, static=False, ): """Instantiate variable font, either fully or partially. Depending on whether the `axisLimits` dictionary references all or some of the input varfont's axes, the output font will either be a full instance (static font) or a variable font with possibly less variation data. Args: varfont: a TTFont instance, which must contain at least an 'fvar' table. axisLimits: a dict keyed by axis tags (str) containing the coordinates (float) along one or more axes where the desired instance will be located. If the value is `None`, the default coordinate as per 'fvar' table for that axis is used. The limit values can also be (min, max) tuples for restricting an axis's variation range. The default axis value must be included in the new range. inplace (bool): whether to modify input TTFont object in-place instead of returning a distinct object. optimize (bool): if False, do not perform IUP-delta optimization on the remaining 'gvar' table's deltas. Possibly faster, and might work around rendering issues in some buggy environments, at the cost of a slightly larger file size. overlap (OverlapMode): variable fonts usually contain overlapping contours, and some font rendering engines on Apple platforms require that the `OVERLAP_SIMPLE` and `OVERLAP_COMPOUND` flags in the 'glyf' table be set to force rendering using a non-zero fill rule. Thus we always set these flags on all glyphs to maximise cross-compatibility of the generated instance. You can disable this by passing OverlapMode.KEEP_AND_DONT_SET_FLAGS. If you want to remove the overlaps altogether and merge overlapping contours and components, you can pass OverlapMode.REMOVE (or REMOVE_AND_IGNORE_ERRORS to not hard-fail on tricky glyphs). Note that this requires the skia-pathops package (available to pip install). The overlap parameter only has effect when generating full static instances. updateFontNames (bool): if True, update the instantiated font's name table using the Axis Value Tables from the STAT table. The name table and the style bits in the head and OS/2 table will be updated so they conform to the R/I/B/BI model. If the STAT table is missing or an Axis Value table is missing for a given axis coordinate, a ValueError will be raised. downgradeCFF2 (bool): if True, downgrade the CFF2 table to CFF table when possible ie. full instancing of all axes. This is useful for compatibility with older software that does not support CFF2. Defaults to False. Note that this operation also removes overlaps within glyph shapes, as CFF does not support overlaps but CFF2 does. static (bool): if True, generate a full instance (static font) instead of a partial instance (variable font). """ # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool overlap = OverlapMode(int(overlap)) sanityCheckVariableTables(varfont) if static: unspecified = [] for axis in varfont["fvar"].axes: if axis.axisTag not in axisLimits: axisLimits[axis.axisTag] = None unspecified.append(axis.axisTag) if unspecified: log.info("Pinning unspecified axes to default: %s", unspecified) axisLimits = AxisLimits(axisLimits).limitAxesAndPopulateDefaults(varfont) log.info("Restricted limits: %s", axisLimits) normalizedLimits = axisLimits.normalize(varfont) log.info("Normalized limits: %s", normalizedLimits) if not inplace: varfont = deepcopy(varfont) if "DSIG" in varfont: del varfont["DSIG"] if updateFontNames: log.info("Updating name table") names.updateNameTable(varfont, axisLimits) # Detect avar2 partial instancing: avar version 2, and not all fvar axes # are pinned. In this mode, variation tables (gvar, HVAR, etc.) are kept # in the old final-coordinate space, and the avar v2 offset compensation # ensures the correct old-space final coordinates are produced. A version # 2 font is never downgraded to v1 — even with a NULL/empty VarStore, the # avar2 path applies (offset compensation creates rows on demand). _isAvar2PartialInstancing = ( "avar" in varfont and getattr(varfont["avar"], "majorVersion", 1) >= 2 and not set(axisLimits.pinnedLocation()).issuperset( a.axisTag for a in varfont["fvar"].axes ) ) if _isAvar2PartialInstancing: log.info( "avar2 partial instancing: keeping variation tables in old " "coordinate space" ) if downgradeCFF2: # The result of avar2 partial instancing is still variable, so a # downgrade to CFF is never possible. log.warning("downgradeCFF2 is ignored for avar2 partial instancing") downgradeCFF2 = False else: downgradeCFF2 = _instantiateVariationTables( varfont, normalizedLimits, optimize, downgradeCFF2 ) # For avar2 partial instancing, compute old intermediate values BEFORE # avar instancing modifies the segments. Used for both NO_VARIATION_INDEX # reachable ranges and offset compensation in _instantiateAvarV2. reachableRanges = {} oldIntermediates = None if _isAvar2PartialInstancing: oldIntermediates = _computeOldIntermediates(varfont, axisLimits) # For NO_VARIATION_INDEX axes (no IVS delta), the final coord equals # the intermediate coord, so the reachable range is [a_i, b_i]. avar = varfont["avar"] varStore = getattr(avar.table, "VarStore", None) varIdxMap = getattr(avar.table, "VarIdxMap", None) fvarAxes = varfont["fvar"].axes for axisIdx, axis in enumerate(fvarAxes): tag = axis.axisTag if tag not in axisLimits: continue triple = axisLimits[tag] if triple.minimum == triple.maximum: continue # Pinned axis, not restricted if tag not in oldIntermediates: continue if ( _effectiveAvar2VarIdx(varStore, varIdxMap, axisIdx) == NO_VARIATION_INDEX ): a_i, d_i, b_i = oldIntermediates[tag] reachableRanges[tag] = (min(a_i, b_i), max(a_i, b_i)) selfContainedAxes = {} if "avar" in varfont: selfContainedAxes = ( instantiateAvar(varfont, axisLimits, normalizedLimits, oldIntermediates) or {} ) # For avar2 partial instancing, run variation instancing for # self-contained pinned axes (those that can be removed from fvar). # These axes have a constant old-space final coordinate, so we can # fold their contributions into gvar/HVAR/etc. at that coordinate. if _isAvar2PartialInstancing and selfContainedAxes: scLimits = NormalizedAxisLimits( { tag: NormalizedAxisTripleAndDistances(v, v, v) for tag, v in selfContainedAxes.items() } ) log.info( "Instancing variation tables for self-contained axes: %s", list(selfContainedAxes), ) downgradeCFF2 = _instantiateVariationTables( varfont, scLimits, optimize, downgradeCFF2 ) with names.pruningUnusedNames(varfont): if "STAT" in varfont: instantiateSTAT(varfont, axisLimits) if _isAvar2PartialInstancing: _instantiateFvarForAvar2(varfont, axisLimits, selfContainedAxes) else: instantiateFvar(varfont, axisLimits) # Cull dead gvar/cvar regions outside reachable old-space final-coord # ranges. Must be after _instantiateFvarForAvar2 so fvar and VarStore # axis orders match for getExtremes. if _isAvar2PartialInstancing: # Extend reachable ranges for axes with IVS entries (conservative # bounds via getExtremes on the instanced avar v2 VarStore). _computeReachableRangesForAvar2(varfont, axisLimits, reachableRanges) if reachableRanges: _cullVariationsForAvar2(varfont, reachableRanges) if "OS/2" in varfont: varfont["OS/2"].recalcAvgCharWidth(varfont) varLib.set_default_weight_width_slant( varfont, location=axisLimits.defaultLocation() ) if updateFontNames: # Set Regular/Italic/Bold/Bold Italic bits as appropriate, after the # name table has been updated. setRibbiBits(varfont) if downgradeCFF2: origVarfont = varfont varfont = downgradeCFF2ToCFF(varfont) if inplace: origVarfont.__dict__ = varfont.__dict__.copy() if "fvar" not in varfont: if overlap == OverlapMode.KEEP_AND_SET_FLAGS: if "glyf" in varfont: setMacOverlapFlags(varfont["glyf"]) elif overlap in (OverlapMode.REMOVE, OverlapMode.REMOVE_AND_IGNORE_ERRORS): from fontTools.ttLib.removeOverlaps import removeOverlaps log.info("Removing glyph outlines overlaps") removeOverlaps( varfont, ignoreErrors=(overlap == OverlapMode.REMOVE_AND_IGNORE_ERRORS), ) return varfont def setRibbiBits(font): """Set the `head.macStyle` and `OS/2.fsSelection` style bits appropriately.""" english_ribbi_style = font["name"].getName(names.NameID.SUBFAMILY_NAME, 3, 1, 0x409) if english_ribbi_style is None: return styleMapStyleName = english_ribbi_style.toStr().lower() if styleMapStyleName not in {"regular", "bold", "italic", "bold italic"}: return if styleMapStyleName == "bold": font["head"].macStyle = 0b01 elif styleMapStyleName == "bold italic": font["head"].macStyle = 0b11 elif styleMapStyleName == "italic": font["head"].macStyle = 0b10 selection = font["OS/2"].fsSelection # First clear... selection &= ~(1 << 0) selection &= ~(1 << 5) selection &= ~(1 << 6) # ...then re-set the bits. if styleMapStyleName == "regular": selection |= 1 << 6 elif styleMapStyleName == "bold": selection |= 1 << 5 elif styleMapStyleName == "italic": selection |= 1 << 0 elif styleMapStyleName == "bold italic": selection |= 1 << 0 selection |= 1 << 5 font["OS/2"].fsSelection = selection def parseLimits(limits: Iterable[str]) -> Dict[str, Optional[AxisTriple]]: result = {} for limitString in limits: match = re.match( r"^(\w{1,4})=(?:(drop)|(?:([^:]*)(?:[:]([^:]*))?(?:[:]([^:]*))?))$", limitString, ) if not match: raise ValueError("invalid location format: %r" % limitString) tag = match.group(1).ljust(4) if match.group(2): # 'drop' result[tag] = None continue triple = match.group(3, 4, 5) if triple[1] is None: # "value" syntax triple = (triple[0], triple[0], triple[0]) elif triple[2] is None: # "min:max" syntax triple = (triple[0], None, triple[1]) triple = tuple(float(v) if v else None for v in triple) result[tag] = AxisTriple(*triple) return result def parseArgs(args): """Parse argv. Returns: 3-tuple (infile, axisLimits, options) axisLimits is either a Dict[str, Optional[float]], for pinning variation axes to specific coordinates along those axes (with `None` as a placeholder for an axis' default value); or a Dict[str, Tuple(float, float)], meaning limit this axis to min/max range. Axes locations are in user-space coordinates, as defined in the "fvar" table. """ from fontTools import configLogger import argparse parser = argparse.ArgumentParser( "fonttools varLib.instancer", description="Partially instantiate a variable font", ) parser.add_argument("input", metavar="INPUT.ttf", help="Input variable TTF file.") parser.add_argument( "locargs", metavar="AXIS=LOC", nargs="*", help="List of space separated locations. A location consists of " "the tag of a variation axis, followed by '=' and the literal, " "string 'drop', or colon-separated list of one to three values, " "each of which is the empty string, or a number. " "E.g.: wdth=100 or wght=75.0:125.0 or wght=100:400:700 or wght=:500: " "or wght=drop", ) parser.add_argument( "-o", "--output", metavar="OUTPUT.ttf", default=None, help="Output instance TTF file (default: INPUT-instance.ttf).", ) parser.add_argument( "--static", dest="static", action="store_true", help="Make a static font: pin unspecified axes to their default location.", ) parser.add_argument( "--no-optimize", dest="optimize", action="store_false", help="Don't perform IUP optimization on the remaining gvar TupleVariations", ) parser.add_argument( "--no-overlap-flag", dest="overlap", action="store_false", help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags (only applicable " "when generating a full instance)", ) parser.add_argument( "--remove-overlaps", dest="remove_overlaps", action="store_true", help="Merge overlapping contours and components (only applicable " "when generating a full instance). Requires skia-pathops", ) parser.add_argument( "--ignore-overlap-errors", dest="ignore_overlap_errors", action="store_true", help="Don't crash if the remove-overlaps operation fails for some glyphs.", ) parser.add_argument( "--update-name-table", action="store_true", help="Update the instantiated font's `name` table. Input font must have " "a STAT table with Axis Value Tables", ) parser.add_argument( "--downgrade-cff2", action="store_true", help="If all axes are pinned, downgrade CFF2 to CFF table format", ) parser.add_argument( "--no-recalc-timestamp", dest="recalc_timestamp", action="store_false", help="Don't set the output font's timestamp to the current time.", ) parser.add_argument( "--no-recalc-bounds", dest="recalc_bounds", action="store_false", help="Don't recalculate font bounding boxes", ) loggingGroup = parser.add_mutually_exclusive_group(required=False) loggingGroup.add_argument( "-v", "--verbose", action="store_true", help="Run more verbosely." ) loggingGroup.add_argument( "-q", "--quiet", action="store_true", help="Turn verbosity off." ) options = parser.parse_args(args) if options.remove_overlaps: if options.ignore_overlap_errors: options.overlap = OverlapMode.REMOVE_AND_IGNORE_ERRORS else: options.overlap = OverlapMode.REMOVE else: options.overlap = OverlapMode(int(options.overlap)) infile = options.input if not os.path.isfile(infile): parser.error("No such file '{}'".format(infile)) configLogger( level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO") ) try: axisLimits = parseLimits(options.locargs) except ValueError as e: parser.error(str(e)) if len(axisLimits) != len(options.locargs): parser.error("Specified multiple limits for the same axis") return (infile, axisLimits, options) def main(args=None): """Partially instantiate a variable font""" infile, axisLimits, options = parseArgs(args) log.info("Restricting axes: %s", axisLimits) log.info("Loading variable font") varfont = TTFont( infile, recalcTimestamp=options.recalc_timestamp, recalcBBoxes=options.recalc_bounds, ) isFullInstance = options.static or { axisTag for axisTag, limit in axisLimits.items() if limit is None or limit[0] == limit[2] }.issuperset(axis.axisTag for axis in varfont["fvar"].axes) varfont = instantiateVariableFont( varfont, axisLimits, inplace=True, optimize=options.optimize, overlap=options.overlap, updateFontNames=options.update_name_table, downgradeCFF2=options.downgrade_cff2, static=options.static, ) suffix = "-instance" if isFullInstance else "-partial" outfile = ( makeOutputFileName(infile, overWrite=True, suffix=suffix) if not options.output else options.output ) log.info( "Saving %s font %s", "instance" if isFullInstance else "partial variable", outfile, ) varfont.save(outfile)