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