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