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