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