Spaces:
Runtime error
Runtime error
| """ 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:: | |
| $ 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:: | |
| | >>> 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): | |
| | >>> 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: | |
| | >>> 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, the varLib.instancer will keep the axis and the corresponding deltas, | |
| whereas mutator implicitly drops the axis at its default coordinate. | |
| 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; | |
| | >>> font = instancer.instantiateVariableFont(varfont, {"wght": None}) | |
| L2 | |
| dropping one or more axes while pinning them at non-default locations; | |
| | >>> 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. | |
| | >>> font = instancer.instantiateVariableFont(varfont, {"wght": (100, 300)}) | |
| L4 | |
| moving the default location of an axis, by specifying (min,defalt,max) values: | |
| | >>> font = instancer.instantiateVariableFont(varfont, {"wght": (100, 300, 700)}) | |
| Currently only TrueType-flavored variable fonts (i.e. containing 'glyf' table) | |
| are supported, but support for CFF2 variable fonts will be added soon. | |
| The discussion and implementation of these features are tracked at | |
| https://github.com/fonttools/fonttools/issues/1537 | |
| """ | |
| from fontTools.misc.fixedTools import ( | |
| floatToFixedToFloat, | |
| strToFixedToFloat, | |
| otRound, | |
| ) | |
| from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLinearMap | |
| from fontTools.ttLib import TTFont | |
| 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.varLib import builder | |
| from fontTools.varLib.mvar import MVAR_ENTRIES | |
| from fontTools.varLib.merger import MutatorMerger | |
| from fontTools.varLib.instancer import names | |
| from .featureVars import instantiateFeatureVariations | |
| from fontTools.misc.cliTools import makeOutputFileName | |
| from fontTools.varLib.instancer import solver | |
| import collections | |
| import dataclasses | |
| from copy import deepcopy | |
| from enum import IntEnum | |
| import logging | |
| import os | |
| import re | |
| 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) | |
| class AxisTriple(Sequence): | |
| """A triple of (min, default, max) axis values. | |
| The default value can be None, in which case the limitRangeAndPopulateDefault() | |
| method can be used to fill in the missing default value based on the fvar axis | |
| default. | |
| """ | |
| minimum: float | |
| default: Optional[float] # if None, filled with by limitRangeAndPopulateDefault | |
| maximum: float | |
| def __post_init__(self): | |
| if self.default is None and self.minimum == self.maximum: | |
| object.__setattr__(self, "default", self.minimum) | |
| if not ( | |
| (self.minimum <= self.default <= self.maximum) | |
| if self.default is not None | |
| else (self.minimum <= self.maximum) | |
| ): | |
| raise ValueError( | |
| f"{type(self).__name__} minimum ({self.minimum}) must be <= default " | |
| f"({self.default}) which must be <= maximum ({self.maximum})" | |
| ) | |
| 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)})" | |
| ) | |
| 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 limitRangeAndPopulateDefault(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 | |
| maximum = self.maximum | |
| default = self.default | |
| if default is None: | |
| default = fvarTriple[1] | |
| minimum = max(self.minimum, fvarTriple[0]) | |
| maximum = max(self.maximum, fvarTriple[0]) | |
| minimum = min(minimum, fvarTriple[2]) | |
| maximum = min(maximum, fvarTriple[2]) | |
| default = max(minimum, min(maximum, default)) | |
| return AxisTriple(minimum, default, maximum) | |
| 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})" | |
| ) | |
| 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.limitRangeAndPopulateDefault(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 | |
| } | |
| avarSegments = {} | |
| if usingAvar and "avar" in varfont: | |
| avarSegments = varfont["avar"].segments | |
| normalizedLimits = {} | |
| for axis_tag, triple in axes.items(): | |
| distanceNegative = triple[1] - triple[0] | |
| distancePositive = triple[2] - triple[1] | |
| if self[axis_tag] is None: | |
| normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances( | |
| 0, 0, 0, distanceNegative, distancePositive | |
| ) | |
| continue | |
| minV, defaultV, maxV = self[axis_tag] | |
| if defaultV is None: | |
| defaultV = triple[1] | |
| avarMapping = avarSegments.get(axis_tag, None) | |
| normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances( | |
| *(normalize(v, triple, avarMapping) for v in (minV, defaultV, maxV)), | |
| distanceNegative, | |
| distancePositive, | |
| ) | |
| 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 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 (i.e. doesn't participate), | |
| lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1)) | |
| if peak == 0: | |
| 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 _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) | |
| glyph = glyf[glyphname] | |
| if glyph.isVarComposite(): | |
| for component in glyph.components: | |
| newLocation = {} | |
| for tag, loc in component.location.items(): | |
| if tag not in axisLimits: | |
| newLocation[tag] = loc | |
| continue | |
| if component.flags & _g_l_y_f.VarComponentFlags.AXES_HAVE_VARIATION: | |
| raise NotImplementedError( | |
| "Instancing accross VarComposite axes with variation is not supported." | |
| ) | |
| limits = axisLimits[tag] | |
| loc = limits.renormalizeValue(loc, extrapolate=False) | |
| newLocation[tag] = loc | |
| component.location = newLocation | |
| # _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: | |
| isComposite = glyf[glyphname].isComposite() | |
| for var in tupleVarStore: | |
| var.optimize(coordinates, endPts, isComposite) | |
| 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() or glyf[name].isVarComposite() | |
| 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), | |
| ) | |
| 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) | |
| 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): | |
| location = axisLimits.pinnedLocation() | |
| tableTag = tableFields.tableTag | |
| fvarAxes = varfont["fvar"].axes | |
| # 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 | |
| log.info("Instantiating %s table", tableTag) | |
| vhvar = varfont[tableTag].table | |
| varStore = vhvar.VarStore | |
| # since deltas were already applied, the return value here is ignored | |
| instantiateItemVariationStore(varStore, fvarAxes, axisLimits) | |
| 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 | |
| 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): | |
| """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 | |
| assert itemVarStore.VarDataCount == newItemVarStore.VarDataCount | |
| itemVarStore.VarData = newItemVarStore.VarData | |
| defaultDeltas = { | |
| ((major << 16) + minor): delta | |
| for major, deltas in enumerate(defaultDeltaArray) | |
| for minor, delta in enumerate(deltas) | |
| } | |
| defaultDeltas[itemVarStore.NO_VARIATION_INDEX] = 0 | |
| return defaultDeltas | |
| def instantiateOTL(varfont, axisLimits): | |
| # TODO(anthrotype) Support partial instancing of JSTF and BASE tables | |
| 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 instantiateAvar(varfont, axisLimits): | |
| # 'axisLimits' dict must contain user-space (non-normalized) coordinates. | |
| segments = varfont["avar"].segments | |
| # drop table if we instantiate all the axes | |
| pinnedAxes = set(axisLimits.pinnedLocation()) | |
| if pinnedAxes.issuperset(segments): | |
| log.info("Dropping avar table") | |
| del varfont["avar"] | |
| return | |
| log.info("Instantiating avar table") | |
| for axis in pinnedAxes: | |
| if axis in segments: | |
| del segments[axis] | |
| # First compute the default normalization for axisLimits coordinates: i.e. | |
| # min = -1.0, default = 0, max = +1.0, and in between values interpolated linearly, | |
| # without using the avar table's mappings. | |
| # Then, for each SegmentMap, if we are restricting its axis, compute the new | |
| # mappings by dividing the key/value pairs by the desired new min/max values, | |
| # dropping any mappings that fall outside the restricted range. | |
| # The keys ('fromCoord') are specified in default normalized coordinate space, | |
| # whereas the values ('toCoord') are "mapped forward" using the SegmentMap. | |
| 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 | |
| varfont["avar"].segments = newSegments | |
| 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): | |
| value = normalizeValue(value, triple) | |
| if avarMapping: | |
| value = piecewiseLinearMap(value, avarMapping) | |
| # Quantize to F2Dot14, to avoid surprise interpolations. | |
| return floatToFixedToFloat(value, 14) | |
| 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") | |
| # TODO(anthrotype) Remove once we do support partial instancing CFF2 | |
| if "CFF2" in varfont: | |
| raise NotImplementedError("Instancing CFF2 variable fonts is not supported yet") | |
| def instantiateVariableFont( | |
| varfont, | |
| axisLimits, | |
| inplace=False, | |
| optimize=True, | |
| overlap=OverlapMode.KEEP_AND_SET_FLAGS, | |
| updateFontNames=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. | |
| Note that variable fonts with 'CFF2' table are not supported yet. | |
| 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. | |
| """ | |
| # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool | |
| overlap = OverlapMode(int(overlap)) | |
| sanityCheckVariableTables(varfont) | |
| 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) | |
| if "gvar" in varfont: | |
| instantiateGvar(varfont, normalizedLimits, optimize=optimize) | |
| if "cvar" in varfont: | |
| instantiateCvar(varfont, normalizedLimits) | |
| if "MVAR" in varfont: | |
| instantiateMVAR(varfont, normalizedLimits) | |
| if "HVAR" in varfont: | |
| instantiateHVAR(varfont, normalizedLimits) | |
| if "VVAR" in varfont: | |
| instantiateVVAR(varfont, normalizedLimits) | |
| instantiateOTL(varfont, normalizedLimits) | |
| instantiateFeatureVariations(varfont, normalizedLimits) | |
| if "avar" in varfont: | |
| instantiateAvar(varfont, axisLimits) | |
| with names.pruningUnusedNames(varfont): | |
| if "STAT" in varfont: | |
| instantiateSTAT(varfont, axisLimits) | |
| instantiateFvar(varfont, axisLimits) | |
| if "fvar" not in varfont: | |
| if "glyf" in varfont: | |
| if overlap == OverlapMode.KEEP_AND_SET_FLAGS: | |
| setMacOverlapFlags(varfont["glyf"]) | |
| elif overlap in (OverlapMode.REMOVE, OverlapMode.REMOVE_AND_IGNORE_ERRORS): | |
| from fontTools.ttLib.removeOverlaps import removeOverlaps | |
| log.info("Removing overlaps from glyf table") | |
| removeOverlaps( | |
| varfont, | |
| ignoreErrors=(overlap == OverlapMode.REMOVE_AND_IGNORE_ERRORS), | |
| ) | |
| 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) | |
| 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' | |
| lbound = None | |
| else: | |
| lbound = strToFixedToFloat(match.group(3), precisionBits=16) | |
| ubound = default = lbound | |
| if match.group(4): | |
| ubound = default = strToFixedToFloat(match.group(4), precisionBits=16) | |
| default = None | |
| if match.group(5): | |
| default = ubound | |
| ubound = strToFixedToFloat(match.group(5), precisionBits=16) | |
| if all(v is None for v in (lbound, default, ubound)): | |
| result[tag] = None | |
| continue | |
| result[tag] = AxisTriple(lbound, default, ubound) | |
| 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 one of number, " | |
| "number:number or the literal string 'drop'. " | |
| "E.g.: wdth=100 or wght=75.0:125.0 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( | |
| "--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( | |
| "--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 = { | |
| axisTag for axisTag, limit in axisLimits.items() if not isinstance(limit, tuple) | |
| }.issuperset(axis.axisTag for axis in varfont["fvar"].axes) | |
| instantiateVariableFont( | |
| varfont, | |
| axisLimits, | |
| inplace=True, | |
| optimize=options.optimize, | |
| overlap=options.overlap, | |
| updateFontNames=options.update_name_table, | |
| ) | |
| 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) | |