| """ 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 partial["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 currently supports only the first three "levels" of partial instancing, |
| with the rest planned to be implemented in the future, namely: |
| L1) dropping one or more axes while leaving the default tables unmodified; |
| L2) dropping one or more axes while pinning them at non-default locations; |
| 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. |
| L4) moving the default location of an axis. |
| |
| 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, |
| MAX_F2DOT14, |
| ) |
| 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 contextlib import contextmanager |
| import collections |
| from copy import deepcopy |
| import logging |
| from itertools import islice |
| import os |
| import re |
| |
| |
| log = logging.getLogger("fontTools.varLib.instancer") |
| |
| |
| class AxisRange(collections.namedtuple("AxisRange", "minimum maximum")): |
| def __new__(cls, *args, **kwargs): |
| self = super().__new__(cls, *args, **kwargs) |
| if self.minimum > self.maximum: |
| raise ValueError( |
| f"Range minimum ({self.minimum:g}) must be <= maximum ({self.maximum:g})" |
| ) |
| return self |
| |
| def __repr__(self): |
| return f"{type(self).__name__}({self.minimum:g}, {self.maximum:g})" |
| |
| |
| class NormalizedAxisRange(AxisRange): |
| def __new__(cls, *args, **kwargs): |
| self = super().__new__(cls, *args, **kwargs) |
| if self.minimum < -1.0 or self.maximum > 1.0: |
| raise ValueError("Axis range values must be normalized to -1..+1 range") |
| if self.minimum > 0: |
| raise ValueError(f"Expected axis range minimum <= 0; got {self.minimum}") |
| if self.maximum < 0: |
| raise ValueError(f"Expected axis range maximum >= 0; got {self.maximum}") |
| return self |
| |
| |
| 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 either a single coordinate along the |
| axis (float), or to minimum/maximum coordinates (NormalizedAxisRange). |
| |
| 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: Dict[str, Union[float, NormalizedAxisRange]]: axes' coordinates for |
| the full or partial instance, or ranges for restricting an axis' min/max. |
| 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. |
| """ |
| pinnedLocation, axisRanges = splitAxisLocationAndRanges( |
| axisLimits, rangeType=NormalizedAxisRange |
| ) |
| |
| newVariations = variations |
| |
| if pinnedLocation: |
| newVariations = pinTupleVariationAxes(variations, pinnedLocation) |
| |
| if axisRanges: |
| newVariations = limitTupleVariationAxisRanges(newVariations, axisRanges) |
| |
| 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 pinTupleVariationAxes(variations, location): |
| newVariations = [] |
| for var in variations: |
| # Compute the scalar support of the axes to be pinned at the desired location, |
| # excluding any axes that we are not pinning. |
| # If a TupleVariation doesn't mention an axis, it implies that the axis peak |
| # is 0 (i.e. the axis does not participate). |
| support = {axis: var.axes.pop(axis, (-1, 0, +1)) for axis in location} |
| scalar = supportScalar(location, support) |
| if scalar == 0.0: |
| # no influence, drop the TupleVariation |
| continue |
| |
| var.scaleDeltas(scalar) |
| newVariations.append(var) |
| return newVariations |
| |
| |
| def limitTupleVariationAxisRanges(variations, axisRanges): |
| for axisTag, axisRange in sorted(axisRanges.items()): |
| newVariations = [] |
| for var in variations: |
| newVariations.extend(limitTupleVariationAxisRange(var, axisTag, axisRange)) |
| variations = newVariations |
| return variations |
| |
| |
| def _negate(*values): |
| yield from (-1 * v for v in values) |
| |
| |
| def limitTupleVariationAxisRange(var, axisTag, axisRange): |
| if not isinstance(axisRange, NormalizedAxisRange): |
| axisRange = NormalizedAxisRange(*axisRange) |
| |
| # skip when current axis is missing (i.e. doesn't participate), or when the |
| # 'tent' isn't fully on either the negative or positive side |
| lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1)) |
| if peak == 0 or lower > peak or peak > upper or (lower < 0 and upper > 0): |
| return [var] |
| |
| negative = lower < 0 |
| if negative: |
| if axisRange.minimum == -1.0: |
| return [var] |
| elif axisRange.minimum == 0.0: |
| return [] |
| else: |
| if axisRange.maximum == 1.0: |
| return [var] |
| elif axisRange.maximum == 0.0: |
| return [] |
| |
| limit = axisRange.minimum if negative else axisRange.maximum |
| |
| # Rebase axis bounds onto the new limit, which then becomes the new -1.0 or +1.0. |
| # The results are always positive, because both dividend and divisor are either |
| # all positive or all negative. |
| newLower = lower / limit |
| newPeak = peak / limit |
| newUpper = upper / limit |
| # for negative TupleVariation, swap lower and upper to simplify procedure |
| if negative: |
| newLower, newUpper = newUpper, newLower |
| |
| # special case when innermost bound == peak == limit |
| if newLower == newPeak == 1.0: |
| var.axes[axisTag] = (-1.0, -1.0, -1.0) if negative else (1.0, 1.0, 1.0) |
| return [var] |
| |
| # case 1: the whole deltaset falls outside the new limit; we can drop it |
| elif newLower >= 1.0: |
| return [] |
| |
| # case 2: only the peak and outermost bound fall outside the new limit; |
| # we keep the deltaset, update peak and outermost bound and and scale deltas |
| # by the scalar value for the restricted axis at the new limit. |
| elif newPeak >= 1.0: |
| scalar = supportScalar({axisTag: limit}, {axisTag: (lower, peak, upper)}) |
| var.scaleDeltas(scalar) |
| newPeak = 1.0 |
| newUpper = 1.0 |
| if negative: |
| newLower, newPeak, newUpper = _negate(newUpper, newPeak, newLower) |
| var.axes[axisTag] = (newLower, newPeak, newUpper) |
| return [var] |
| |
| # case 3: peak falls inside but outermost limit still fits within F2Dot14 bounds; |
| # we keep deltas as is and only scale the axes bounds. Deltas beyond -1.0 |
| # or +1.0 will never be applied as implementations must clamp to that range. |
| elif newUpper <= 2.0: |
| if negative: |
| newLower, newPeak, newUpper = _negate(newUpper, newPeak, newLower) |
| elif MAX_F2DOT14 < newUpper <= 2.0: |
| # we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience |
| newUpper = MAX_F2DOT14 |
| var.axes[axisTag] = (newLower, newPeak, newUpper) |
| return [var] |
| |
| # case 4: new limit doesn't fit; we need to chop the deltaset into two 'tents', |
| # because the shape of a triangle with part of one side cut off cannot be |
| # represented as a triangle itself. It can be represented as sum of two triangles. |
| # NOTE: This increases the file size! |
| else: |
| # duplicate the tent, then adjust lower/peak/upper so that the outermost limit |
| # of the original tent is +/-2.0, whereas the new tent's starts as the old |
| # one peaks and maxes out at +/-1.0. |
| newVar = TupleVariation(var.axes, var.coordinates) |
| if negative: |
| var.axes[axisTag] = (-2.0, -1 * newPeak, -1 * newLower) |
| newVar.axes[axisTag] = (-1.0, -1.0, -1 * newPeak) |
| else: |
| var.axes[axisTag] = (newLower, newPeak, MAX_F2DOT14) |
| newVar.axes[axisTag] = (newPeak, 1.0, 1.0) |
| # the new tent's deltas are scaled by the difference between the scalar value |
| # for the old tent at the desired limit... |
| scalar1 = supportScalar({axisTag: limit}, {axisTag: (lower, peak, upper)}) |
| # ... and the scalar value for the clamped tent (with outer limit +/-2.0), |
| # which can be simplified like this: |
| scalar2 = 1 / (2 - newPeak) |
| newVar.scaleDeltas(scalar1 - scalar2) |
| |
| return [var, newVar] |
| |
| |
| def instantiateGvarGlyph(varfont, glyphname, axisLimits, optimize=True): |
| glyf = varfont["glyf"] |
| coordinates, ctrl = glyf.getCoordinatesAndControls(glyphname, varfont) |
| endPts = ctrl.endPts |
| |
| gvar = varfont["gvar"] |
| # when exporting to TTX, a glyph with no variations is omitted; thus when loading |
| # a TTFont from TTX, a glyph that's present in glyf table may be missing from gvar. |
| 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, varfont) |
| |
| 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 instantiateGvar(varfont, axisLimits, optimize=True): |
| log.info("Instantiating glyf/gvar tables") |
| |
| gvar = varfont["gvar"] |
| glyf = varfont["glyf"] |
| # 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(varfont, glyphname, 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): |
| 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( |
| axisTag for axisTag, value in axisLimits.items() if not isinstance(value, tuple) |
| ).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() |
| 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 = { |
| axisTag |
| for axisTag, value in axisLimits.items() |
| if not isinstance(value, tuple) |
| } |
| 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: Dict[str, float] mapping axis tags to normalized axis coordinates |
| (float) or ranges for restricting an axis' min/max (NormalizedAxisRange). |
| 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) |
| } |
| 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 instantiateFeatureVariations(varfont, axisLimits): |
| for tableTag in ("GPOS", "GSUB"): |
| if tableTag not in varfont or not hasattr( |
| varfont[tableTag].table, "FeatureVariations" |
| ): |
| continue |
| log.info("Instantiating FeatureVariations of %s table", tableTag) |
| _instantiateFeatureVariations( |
| varfont[tableTag].table, varfont["fvar"].axes, axisLimits |
| ) |
| # remove unreferenced lookups |
| varfont[tableTag].prune_lookups() |
| |
| |
| def _featureVariationRecordIsUnique(rec, seen): |
| conditionSet = [] |
| for cond in rec.ConditionSet.ConditionTable: |
| if cond.Format != 1: |
| # can't tell whether this is duplicate, assume is unique |
| return True |
| conditionSet.append( |
| (cond.AxisIndex, cond.FilterRangeMinValue, cond.FilterRangeMaxValue) |
| ) |
| # besides the set of conditions, we also include the FeatureTableSubstitution |
| # version to identify unique FeatureVariationRecords, even though only one |
| # version is currently defined. It's theoretically possible that multiple |
| # records with same conditions but different substitution table version be |
| # present in the same font for backward compatibility. |
| recordKey = frozenset([rec.FeatureTableSubstitution.Version] + conditionSet) |
| if recordKey in seen: |
| return False |
| else: |
| seen.add(recordKey) # side effect |
| return True |
| |
| |
| def _limitFeatureVariationConditionRange(condition, axisRange): |
| minValue = condition.FilterRangeMinValue |
| maxValue = condition.FilterRangeMaxValue |
| |
| if ( |
| minValue > maxValue |
| or minValue > axisRange.maximum |
| or maxValue < axisRange.minimum |
| ): |
| # condition invalid or out of range |
| return |
| |
| values = [minValue, maxValue] |
| for i, value in enumerate(values): |
| if value < 0: |
| if axisRange.minimum == 0: |
| newValue = 0 |
| else: |
| newValue = value / abs(axisRange.minimum) |
| if newValue <= -1.0: |
| newValue = -1.0 |
| elif value > 0: |
| if axisRange.maximum == 0: |
| newValue = 0 |
| else: |
| newValue = value / axisRange.maximum |
| if newValue >= 1.0: |
| newValue = 1.0 |
| else: |
| newValue = 0 |
| values[i] = newValue |
| |
| return AxisRange(*values) |
| |
| |
| def _instantiateFeatureVariationRecord( |
| record, recIdx, location, fvarAxes, axisIndexMap |
| ): |
| applies = True |
| newConditions = [] |
| for i, condition in enumerate(record.ConditionSet.ConditionTable): |
| if condition.Format == 1: |
| axisIdx = condition.AxisIndex |
| axisTag = fvarAxes[axisIdx].axisTag |
| if axisTag in location: |
| minValue = condition.FilterRangeMinValue |
| maxValue = condition.FilterRangeMaxValue |
| v = location[axisTag] |
| if not (minValue <= v <= maxValue): |
| # condition not met so remove entire record |
| applies = False |
| newConditions = None |
| break |
| else: |
| # axis not pinned, keep condition with remapped axis index |
| applies = False |
| condition.AxisIndex = axisIndexMap[axisTag] |
| newConditions.append(condition) |
| else: |
| log.warning( |
| "Condition table {0} of FeatureVariationRecord {1} has " |
| "unsupported format ({2}); ignored".format(i, recIdx, condition.Format) |
| ) |
| applies = False |
| newConditions.append(condition) |
| |
| if newConditions: |
| record.ConditionSet.ConditionTable = newConditions |
| shouldKeep = True |
| else: |
| shouldKeep = False |
| |
| return applies, shouldKeep |
| |
| |
| def _limitFeatureVariationRecord(record, axisRanges, fvarAxes): |
| newConditions = [] |
| for i, condition in enumerate(record.ConditionSet.ConditionTable): |
| if condition.Format == 1: |
| axisIdx = condition.AxisIndex |
| axisTag = fvarAxes[axisIdx].axisTag |
| if axisTag in axisRanges: |
| axisRange = axisRanges[axisTag] |
| newRange = _limitFeatureVariationConditionRange(condition, axisRange) |
| if newRange: |
| # keep condition with updated limits and remapped axis index |
| condition.FilterRangeMinValue = newRange.minimum |
| condition.FilterRangeMaxValue = newRange.maximum |
| newConditions.append(condition) |
| else: |
| # condition out of range, remove entire record |
| newConditions = None |
| break |
| else: |
| newConditions.append(condition) |
| else: |
| newConditions.append(condition) |
| |
| if newConditions: |
| record.ConditionSet.ConditionTable = newConditions |
| shouldKeep = True |
| else: |
| shouldKeep = False |
| |
| return shouldKeep |
| |
| |
| def _instantiateFeatureVariations(table, fvarAxes, axisLimits): |
| location, axisRanges = splitAxisLocationAndRanges( |
| axisLimits, rangeType=NormalizedAxisRange |
| ) |
| pinnedAxes = set(location.keys()) |
| axisOrder = [axis.axisTag for axis in fvarAxes if axis.axisTag not in pinnedAxes] |
| axisIndexMap = {axisTag: axisOrder.index(axisTag) for axisTag in axisOrder} |
| |
| featureVariationApplied = False |
| uniqueRecords = set() |
| newRecords = [] |
| |
| for i, record in enumerate(table.FeatureVariations.FeatureVariationRecord): |
| applies, shouldKeep = _instantiateFeatureVariationRecord( |
| record, i, location, fvarAxes, axisIndexMap |
| ) |
| if shouldKeep: |
| shouldKeep = _limitFeatureVariationRecord(record, axisRanges, fvarAxes) |
| |
| if shouldKeep and _featureVariationRecordIsUnique(record, uniqueRecords): |
| newRecords.append(record) |
| |
| if applies and not featureVariationApplied: |
| assert record.FeatureTableSubstitution.Version == 0x00010000 |
| for rec in record.FeatureTableSubstitution.SubstitutionRecord: |
| table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = rec.Feature |
| # Set variations only once |
| featureVariationApplied = True |
| |
| if newRecords: |
| table.FeatureVariations.FeatureVariationRecord = newRecords |
| table.FeatureVariations.FeatureVariationCount = len(newRecords) |
| else: |
| del table.FeatureVariations |
| |
| |
| 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. |
| |
| location, axisRanges = splitAxisLocationAndRanges(axisLimits) |
| |
| segments = varfont["avar"].segments |
| |
| # drop table if we instantiate all the axes |
| pinnedAxes = set(location.keys()) |
| 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 axisRanges 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 = normalizeAxisLimits(varfont, axisRanges, 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 |
| ) |
| mappedMax = floatToFixedToFloat( |
| piecewiseLinearMap(axisRange.maximum, mapping), 14 |
| ) |
| newMapping = {} |
| for fromCoord, toCoord in mapping.items(): |
| if fromCoord < 0: |
| if axisRange.minimum == 0 or fromCoord < axisRange.minimum: |
| continue |
| else: |
| fromCoord /= abs(axisRange.minimum) |
| elif fromCoord > 0: |
| if axisRange.maximum == 0 or fromCoord > axisRange.maximum: |
| continue |
| else: |
| fromCoord /= axisRange.maximum |
| if toCoord < 0: |
| assert mappedMin != 0 |
| assert toCoord >= mappedMin |
| toCoord /= abs(mappedMin) |
| elif toCoord > 0: |
| assert mappedMax != 0 |
| assert toCoord <= mappedMax |
| toCoord /= mappedMax |
| fromCoord = floatToFixedToFloat(fromCoord, 14) |
| toCoord = floatToFixedToFloat(toCoord, 14) |
| newMapping[fromCoord] = toCoord |
| newMapping.update({-1.0: -1.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, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) |
| |
| 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 axisRanges: |
| axis.minValue, axis.maxValue = axisRanges[axisTag] |
| 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, axisRanges): |
| 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 |
| |
| location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) |
| |
| def isAxisValueOutsideLimits(axisTag, axisValue): |
| if axisTag in location and axisValue != location[axisTag]: |
| return True |
| elif axisTag in axisRanges: |
| axisRange = axisRanges[axisTag] |
| if axisValue < axisRange.minimum or axisValue > axisRange.maximum: |
| return True |
| return False |
| |
| log.info("Instantiating STAT table") |
| |
| # 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.warn("Unknown AxisValue table format (%s); ignored", axisValueFormat) |
| newAxisValueTables.append(axisValueTable) |
| |
| stat.AxisValueArray.AxisValue = newAxisValueTables |
| stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) |
| |
| |
| def getVariationNameIDs(varfont): |
| used = [] |
| if "fvar" in varfont: |
| fvar = varfont["fvar"] |
| for axis in fvar.axes: |
| used.append(axis.axisNameID) |
| for instance in fvar.instances: |
| used.append(instance.subfamilyNameID) |
| if instance.postscriptNameID != 0xFFFF: |
| used.append(instance.postscriptNameID) |
| if "STAT" in varfont: |
| stat = varfont["STAT"].table |
| for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else (): |
| used.append(axis.AxisNameID) |
| for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else (): |
| used.append(value.ValueNameID) |
| # nameIDs <= 255 are reserved by OT spec so we don't touch them |
| return {nameID for nameID in used if nameID > 255} |
| |
| |
| @contextmanager |
| def pruningUnusedNames(varfont): |
| origNameIDs = getVariationNameIDs(varfont) |
| |
| yield |
| |
| log.info("Pruning name table") |
| exclude = origNameIDs - getVariationNameIDs(varfont) |
| varfont["name"].names[:] = [ |
| record for record in varfont["name"].names if record.nameID not in exclude |
| ] |
| if "ltag" in varfont: |
| # Drop the whole 'ltag' table if all the language-dependent Unicode name |
| # records that reference it have been dropped. |
| # TODO: Only prune unused ltag tags, renumerating langIDs accordingly. |
| # Note ltag can also be used by feat or morx tables, so check those too. |
| if not any( |
| record |
| for record in varfont["name"].names |
| if record.platformID == 0 and record.langID != 0xFFFF |
| ): |
| del varfont["ltag"] |
| |
| |
| 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 normalizeAxisLimits(varfont, axisLimits, usingAvar=True): |
| fvar = varfont["fvar"] |
| badLimits = set(axisLimits.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 axisLimits |
| } |
| |
| avarSegments = {} |
| if usingAvar and "avar" in varfont: |
| avarSegments = varfont["avar"].segments |
| |
| for axis_tag, (_, default, _) in axes.items(): |
| value = axisLimits[axis_tag] |
| if isinstance(value, tuple): |
| minV, maxV = value |
| if minV > default or maxV < default: |
| raise NotImplementedError( |
| f"Unsupported range {axis_tag}={minV:g}:{maxV:g}; " |
| f"can't change default position ({axis_tag}={default:g})" |
| ) |
| |
| normalizedLimits = {} |
| for axis_tag, triple in axes.items(): |
| avarMapping = avarSegments.get(axis_tag, None) |
| value = axisLimits[axis_tag] |
| if isinstance(value, tuple): |
| normalizedLimits[axis_tag] = NormalizedAxisRange( |
| *(normalize(v, triple, avarMapping) for v in value) |
| ) |
| else: |
| normalizedLimits[axis_tag] = normalize(value, triple, avarMapping) |
| return normalizedLimits |
| |
| |
| 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 populateAxisDefaults(varfont, axisLimits): |
| if any(value is None for value in axisLimits.values()): |
| fvar = varfont["fvar"] |
| defaultValues = {a.axisTag: a.defaultValue for a in fvar.axes} |
| return { |
| axisTag: defaultValues[axisTag] if value is None else value |
| for axisTag, value in axisLimits.items() |
| } |
| return axisLimits |
| |
| |
| def instantiateVariableFont( |
| varfont, axisLimits, inplace=False, optimize=True, overlap=True |
| ): |
| """ 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, but this is not implemented yet. |
| 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 (bool): 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 setting `overalap` to False. |
| """ |
| sanityCheckVariableTables(varfont) |
| |
| axisLimits = populateAxisDefaults(varfont, axisLimits) |
| |
| normalizedLimits = normalizeAxisLimits(varfont, axisLimits) |
| |
| log.info("Normalized limits: %s", normalizedLimits) |
| |
| if not inplace: |
| varfont = deepcopy(varfont) |
| |
| 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 pruningUnusedNames(varfont): |
| if "STAT" in varfont: |
| instantiateSTAT(varfont, axisLimits) |
| |
| instantiateFvar(varfont, axisLimits) |
| |
| if "fvar" not in varfont: |
| if "glyf" in varfont and overlap: |
| setMacOverlapFlags(varfont["glyf"]) |
| |
| varLib.set_default_weight_width_slant( |
| varfont, |
| location={ |
| axisTag: limit |
| for axisTag, limit in axisLimits.items() |
| if not isinstance(limit, tuple) |
| }, |
| ) |
| |
| return varfont |
| |
| |
| def splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange): |
| location, axisRanges = {}, {} |
| for axisTag, value in axisLimits.items(): |
| if isinstance(value, rangeType): |
| axisRanges[axisTag] = value |
| elif isinstance(value, (int, float)): |
| location[axisTag] = value |
| elif isinstance(value, tuple): |
| axisRanges[axisTag] = rangeType(*value) |
| else: |
| raise TypeError( |
| f"Expected number or {rangeType.__name__}, " |
| f"got {type(value).__name__}: {value!r}" |
| ) |
| return location, axisRanges |
| |
| |
| def parseLimits(limits): |
| 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 = lbound |
| if match.group(4): |
| ubound = strToFixedToFloat(match.group(4), precisionBits=16) |
| if lbound != ubound: |
| result[tag] = AxisRange(lbound, ubound) |
| else: |
| result[tag] = lbound |
| 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 consist in " |
| "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)", |
| ) |
| 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) |
| |
| 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): |
| infile, axisLimits, options = parseArgs(args) |
| log.info("Restricting axes: %s", axisLimits) |
| |
| log.info("Loading variable font") |
| varfont = TTFont(infile) |
| |
| 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, |
| ) |
| |
| outfile = ( |
| os.path.splitext(infile)[0] |
| + "-{}.ttf".format("instance" if isFullInstance else "partial") |
| if not options.output |
| else options.output |
| ) |
| |
| log.info( |
| "Saving %s font %s", |
| "instance" if isFullInstance else "partial variable", |
| outfile, |
| ) |
| varfont.save(outfile) |
| |
| |
| if __name__ == "__main__": |
| import sys |
| |
| sys.exit(main()) |