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