xref: /aosp_15_r20/external/fonttools/Lib/fontTools/varLib/__init__.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""
2Module for dealing with 'gvar'-style font variations, also known as run-time
3interpolation.
4
5The ideas here are very similar to MutatorMath.  There is even code to read
6MutatorMath .designspace files in the varLib.designspace module.
7
8For now, if you run this file on a designspace file, it tries to find
9ttf-interpolatable files for the masters and build a variable-font from
10them.  Such ttf-interpolatable and designspace files can be generated from
11a Glyphs source, eg., using noto-source as an example:
12
13	$ fontmake -o ttf-interpolatable -g NotoSansArabic-MM.glyphs
14
15Then you can make a variable-font this way:
16
17	$ fonttools varLib master_ufo/NotoSansArabic.designspace
18
19API *will* change in near future.
20"""
21
22from typing import List
23from fontTools.misc.vector import Vector
24from fontTools.misc.roundTools import noRound, otRound
25from fontTools.misc.fixedTools import floatToFixed as fl2fi
26from fontTools.misc.textTools import Tag, tostr
27from fontTools.ttLib import TTFont, newTable
28from fontTools.ttLib.tables._f_v_a_r import Axis, NamedInstance
29from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates, dropImpliedOnCurvePoints
30from fontTools.ttLib.tables.ttProgram import Program
31from fontTools.ttLib.tables.TupleVariation import TupleVariation
32from fontTools.ttLib.tables import otTables as ot
33from fontTools.ttLib.tables.otBase import OTTableWriter
34from fontTools.varLib import builder, models, varStore
35from fontTools.varLib.merger import VariationMerger, COLRVariationMerger
36from fontTools.varLib.mvar import MVAR_ENTRIES
37from fontTools.varLib.iup import iup_delta_optimize
38from fontTools.varLib.featureVars import addFeatureVariations
39from fontTools.designspaceLib import DesignSpaceDocument, InstanceDescriptor
40from fontTools.designspaceLib.split import splitInterpolable, splitVariableFonts
41from fontTools.varLib.stat import buildVFStatTable
42from fontTools.colorLib.builder import buildColrV1
43from fontTools.colorLib.unbuilder import unbuildColrV1
44from functools import partial
45from collections import OrderedDict, defaultdict, namedtuple
46import os.path
47import logging
48from copy import deepcopy
49from pprint import pformat
50from re import fullmatch
51from .errors import VarLibError, VarLibValidationError
52
53log = logging.getLogger("fontTools.varLib")
54
55# This is a lib key for the designspace document. The value should be
56# a comma-separated list of OpenType feature tag(s), to be used as the
57# FeatureVariations feature.
58# If present, the DesignSpace <rules processing="..."> flag is ignored.
59FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag"
60
61#
62# Creation routines
63#
64
65
66def _add_fvar(font, axes, instances: List[InstanceDescriptor]):
67    """
68    Add 'fvar' table to font.
69
70    axes is an ordered dictionary of DesignspaceAxis objects.
71
72    instances is list of dictionary objects with 'location', 'stylename',
73    and possibly 'postscriptfontname' entries.
74    """
75
76    assert axes
77    assert isinstance(axes, OrderedDict)
78
79    log.info("Generating fvar")
80
81    fvar = newTable("fvar")
82    nameTable = font["name"]
83
84    for a in axes.values():
85        axis = Axis()
86        axis.axisTag = Tag(a.tag)
87        # TODO Skip axes that have no variation.
88        axis.minValue, axis.defaultValue, axis.maxValue = (
89            a.minimum,
90            a.default,
91            a.maximum,
92        )
93        axis.axisNameID = nameTable.addMultilingualName(
94            a.labelNames, font, minNameID=256
95        )
96        axis.flags = int(a.hidden)
97        fvar.axes.append(axis)
98
99    for instance in instances:
100        # Filter out discrete axis locations
101        coordinates = {
102            name: value for name, value in instance.location.items() if name in axes
103        }
104
105        if "en" not in instance.localisedStyleName:
106            if not instance.styleName:
107                raise VarLibValidationError(
108                    f"Instance at location '{coordinates}' must have a default English "
109                    "style name ('stylename' attribute on the instance element or a "
110                    "stylename element with an 'xml:lang=\"en\"' attribute)."
111                )
112            localisedStyleName = dict(instance.localisedStyleName)
113            localisedStyleName["en"] = tostr(instance.styleName)
114        else:
115            localisedStyleName = instance.localisedStyleName
116
117        psname = instance.postScriptFontName
118
119        inst = NamedInstance()
120        inst.subfamilyNameID = nameTable.addMultilingualName(localisedStyleName)
121        if psname is not None:
122            psname = tostr(psname)
123            inst.postscriptNameID = nameTable.addName(psname)
124        inst.coordinates = {
125            axes[k].tag: axes[k].map_backward(v) for k, v in coordinates.items()
126        }
127        # inst.coordinates = {axes[k].tag:v for k,v in coordinates.items()}
128        fvar.instances.append(inst)
129
130    assert "fvar" not in font
131    font["fvar"] = fvar
132
133    return fvar
134
135
136def _add_avar(font, axes, mappings, axisTags):
137    """
138    Add 'avar' table to font.
139
140    axes is an ordered dictionary of AxisDescriptor objects.
141    """
142
143    assert axes
144    assert isinstance(axes, OrderedDict)
145
146    log.info("Generating avar")
147
148    avar = newTable("avar")
149
150    interesting = False
151    vals_triples = {}
152    for axis in axes.values():
153        # Currently, some rasterizers require that the default value maps
154        # (-1 to -1, 0 to 0, and 1 to 1) be present for all the segment
155        # maps, even when the default normalization mapping for the axis
156        # was not modified.
157        # https://github.com/googlei18n/fontmake/issues/295
158        # https://github.com/fonttools/fonttools/issues/1011
159        # TODO(anthrotype) revert this (and 19c4b37) when issue is fixed
160        curve = avar.segments[axis.tag] = {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}
161
162        keys_triple = (axis.minimum, axis.default, axis.maximum)
163        vals_triple = tuple(axis.map_forward(v) for v in keys_triple)
164        vals_triples[axis.tag] = vals_triple
165
166        if not axis.map:
167            continue
168
169        items = sorted(axis.map)
170        keys = [item[0] for item in items]
171        vals = [item[1] for item in items]
172
173        # Current avar requirements.  We don't have to enforce
174        # these on the designer and can deduce some ourselves,
175        # but for now just enforce them.
176        if axis.minimum != min(keys):
177            raise VarLibValidationError(
178                f"Axis '{axis.name}': there must be a mapping for the axis minimum "
179                f"value {axis.minimum} and it must be the lowest input mapping value."
180            )
181        if axis.maximum != max(keys):
182            raise VarLibValidationError(
183                f"Axis '{axis.name}': there must be a mapping for the axis maximum "
184                f"value {axis.maximum} and it must be the highest input mapping value."
185            )
186        if axis.default not in keys:
187            raise VarLibValidationError(
188                f"Axis '{axis.name}': there must be a mapping for the axis default "
189                f"value {axis.default}."
190            )
191        # No duplicate input values (output values can be >= their preceeding value).
192        if len(set(keys)) != len(keys):
193            raise VarLibValidationError(
194                f"Axis '{axis.name}': All axis mapping input='...' values must be "
195                "unique, but we found duplicates."
196            )
197        # Ascending values
198        if sorted(vals) != vals:
199            raise VarLibValidationError(
200                f"Axis '{axis.name}': mapping output values must be in ascending order."
201            )
202
203        keys = [models.normalizeValue(v, keys_triple) for v in keys]
204        vals = [models.normalizeValue(v, vals_triple) for v in vals]
205
206        if all(k == v for k, v in zip(keys, vals)):
207            continue
208        interesting = True
209
210        curve.update(zip(keys, vals))
211
212        assert 0.0 in curve and curve[0.0] == 0.0
213        assert -1.0 not in curve or curve[-1.0] == -1.0
214        assert +1.0 not in curve or curve[+1.0] == +1.0
215        # curve.update({-1.0: -1.0, 0.0: 0.0, 1.0: 1.0})
216
217    if mappings:
218        interesting = True
219
220        inputLocations = [
221            {
222                axes[name].tag: models.normalizeValue(v, vals_triples[axes[name].tag])
223                for name, v in mapping.inputLocation.items()
224            }
225            for mapping in mappings
226        ]
227        outputLocations = [
228            {
229                axes[name].tag: models.normalizeValue(v, vals_triples[axes[name].tag])
230                for name, v in mapping.outputLocation.items()
231            }
232            for mapping in mappings
233        ]
234        assert len(inputLocations) == len(outputLocations)
235
236        # If base-master is missing, insert it at zero location.
237        if not any(all(v == 0 for k, v in loc.items()) for loc in inputLocations):
238            inputLocations.insert(0, {})
239            outputLocations.insert(0, {})
240
241        model = models.VariationModel(inputLocations, axisTags)
242        storeBuilder = varStore.OnlineVarStoreBuilder(axisTags)
243        storeBuilder.setModel(model)
244        varIdxes = {}
245        for tag in axisTags:
246            masterValues = []
247            for vo, vi in zip(outputLocations, inputLocations):
248                if tag not in vo:
249                    masterValues.append(0)
250                    continue
251                v = vo[tag] - vi.get(tag, 0)
252                masterValues.append(fl2fi(v, 14))
253            varIdxes[tag] = storeBuilder.storeMasters(masterValues)[1]
254
255        store = storeBuilder.finish()
256        optimized = store.optimize()
257        varIdxes = {axis: optimized[value] for axis, value in varIdxes.items()}
258
259        varIdxMap = builder.buildDeltaSetIndexMap(varIdxes[t] for t in axisTags)
260
261        avar.majorVersion = 2
262        avar.table = ot.avar()
263        avar.table.VarIdxMap = varIdxMap
264        avar.table.VarStore = store
265
266    assert "avar" not in font
267    if not interesting:
268        log.info("No need for avar")
269        avar = None
270    else:
271        font["avar"] = avar
272
273    return avar
274
275
276def _add_stat(font):
277    # Note: this function only gets called by old code that calls `build()`
278    # directly. Newer code that wants to benefit from STAT data from the
279    # designspace should call `build_many()`
280
281    if "STAT" in font:
282        return
283
284    from ..otlLib.builder import buildStatTable
285
286    fvarTable = font["fvar"]
287    axes = [dict(tag=a.axisTag, name=a.axisNameID) for a in fvarTable.axes]
288    buildStatTable(font, axes)
289
290
291_MasterData = namedtuple("_MasterData", ["glyf", "hMetrics", "vMetrics"])
292
293
294def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True):
295    if tolerance < 0:
296        raise ValueError("`tolerance` must be a positive number.")
297
298    log.info("Generating gvar")
299    assert "gvar" not in font
300    gvar = font["gvar"] = newTable("gvar")
301    glyf = font["glyf"]
302    defaultMasterIndex = masterModel.reverseMapping[0]
303
304    master_datas = [
305        _MasterData(
306            m["glyf"], m["hmtx"].metrics, getattr(m.get("vmtx"), "metrics", None)
307        )
308        for m in master_ttfs
309    ]
310
311    for glyph in font.getGlyphOrder():
312        log.debug("building gvar for glyph '%s'", glyph)
313        isComposite = glyf[glyph].isComposite()
314
315        allData = [
316            m.glyf._getCoordinatesAndControls(glyph, m.hMetrics, m.vMetrics)
317            for m in master_datas
318        ]
319
320        if allData[defaultMasterIndex][1].numberOfContours != 0:
321            # If the default master is not empty, interpret empty non-default masters
322            # as missing glyphs from a sparse master
323            allData = [
324                d if d is not None and d[1].numberOfContours != 0 else None
325                for d in allData
326            ]
327
328        model, allData = masterModel.getSubModel(allData)
329
330        allCoords = [d[0] for d in allData]
331        allControls = [d[1] for d in allData]
332        control = allControls[0]
333        if not models.allEqual(allControls):
334            log.warning("glyph %s has incompatible masters; skipping" % glyph)
335            continue
336        del allControls
337
338        # Update gvar
339        gvar.variations[glyph] = []
340        deltas = model.getDeltas(
341            allCoords, round=partial(GlyphCoordinates.__round__, round=round)
342        )
343        supports = model.supports
344        assert len(deltas) == len(supports)
345
346        # Prepare for IUP optimization
347        origCoords = deltas[0]
348        endPts = control.endPts
349
350        for i, (delta, support) in enumerate(zip(deltas[1:], supports[1:])):
351            if all(v == 0 for v in delta.array) and not isComposite:
352                continue
353            var = TupleVariation(support, delta)
354            if optimize:
355                delta_opt = iup_delta_optimize(
356                    delta, origCoords, endPts, tolerance=tolerance
357                )
358
359                if None in delta_opt:
360                    """In composite glyphs, there should be one 0 entry
361                    to make sure the gvar entry is written to the font.
362
363                    This is to work around an issue with macOS 10.14 and can be
364                    removed once the behaviour of macOS is changed.
365
366                    https://github.com/fonttools/fonttools/issues/1381
367                    """
368                    if all(d is None for d in delta_opt):
369                        delta_opt = [(0, 0)] + [None] * (len(delta_opt) - 1)
370                    # Use "optimized" version only if smaller...
371                    var_opt = TupleVariation(support, delta_opt)
372
373                    axis_tags = sorted(
374                        support.keys()
375                    )  # Shouldn't matter that this is different from fvar...?
376                    tupleData, auxData = var.compile(axis_tags)
377                    unoptimized_len = len(tupleData) + len(auxData)
378                    tupleData, auxData = var_opt.compile(axis_tags)
379                    optimized_len = len(tupleData) + len(auxData)
380
381                    if optimized_len < unoptimized_len:
382                        var = var_opt
383
384            gvar.variations[glyph].append(var)
385
386
387def _remove_TTHinting(font):
388    for tag in ("cvar", "cvt ", "fpgm", "prep"):
389        if tag in font:
390            del font[tag]
391    maxp = font["maxp"]
392    for attr in (
393        "maxTwilightPoints",
394        "maxStorage",
395        "maxFunctionDefs",
396        "maxInstructionDefs",
397        "maxStackElements",
398        "maxSizeOfInstructions",
399    ):
400        setattr(maxp, attr, 0)
401    maxp.maxZones = 1
402    font["glyf"].removeHinting()
403    # TODO: Modify gasp table to deactivate gridfitting for all ranges?
404
405
406def _merge_TTHinting(font, masterModel, master_ttfs):
407    log.info("Merging TT hinting")
408    assert "cvar" not in font
409
410    # Check that the existing hinting is compatible
411
412    # fpgm and prep table
413
414    for tag in ("fpgm", "prep"):
415        all_pgms = [m[tag].program for m in master_ttfs if tag in m]
416        if not all_pgms:
417            continue
418        font_pgm = getattr(font.get(tag), "program", None)
419        if any(pgm != font_pgm for pgm in all_pgms):
420            log.warning(
421                "Masters have incompatible %s tables, hinting is discarded." % tag
422            )
423            _remove_TTHinting(font)
424            return
425
426    # glyf table
427
428    font_glyf = font["glyf"]
429    master_glyfs = [m["glyf"] for m in master_ttfs]
430    for name, glyph in font_glyf.glyphs.items():
431        all_pgms = [getattr(glyf.get(name), "program", None) for glyf in master_glyfs]
432        if not any(all_pgms):
433            continue
434        glyph.expand(font_glyf)
435        font_pgm = getattr(glyph, "program", None)
436        if any(pgm != font_pgm for pgm in all_pgms if pgm):
437            log.warning(
438                "Masters have incompatible glyph programs in glyph '%s', hinting is discarded."
439                % name
440            )
441            # TODO Only drop hinting from this glyph.
442            _remove_TTHinting(font)
443            return
444
445    # cvt table
446
447    all_cvs = [Vector(m["cvt "].values) if "cvt " in m else None for m in master_ttfs]
448
449    nonNone_cvs = models.nonNone(all_cvs)
450    if not nonNone_cvs:
451        # There is no cvt table to make a cvar table from, we're done here.
452        return
453
454    if not models.allEqual(len(c) for c in nonNone_cvs):
455        log.warning("Masters have incompatible cvt tables, hinting is discarded.")
456        _remove_TTHinting(font)
457        return
458
459    variations = []
460    deltas, supports = masterModel.getDeltasAndSupports(
461        all_cvs, round=round
462    )  # builtin round calls into Vector.__round__, which uses builtin round as we like
463    for i, (delta, support) in enumerate(zip(deltas[1:], supports[1:])):
464        if all(v == 0 for v in delta):
465            continue
466        var = TupleVariation(support, delta)
467        variations.append(var)
468
469    # We can build the cvar table now.
470    if variations:
471        cvar = font["cvar"] = newTable("cvar")
472        cvar.version = 1
473        cvar.variations = variations
474
475
476_MetricsFields = namedtuple(
477    "_MetricsFields",
478    ["tableTag", "metricsTag", "sb1", "sb2", "advMapping", "vOrigMapping"],
479)
480
481HVAR_FIELDS = _MetricsFields(
482    tableTag="HVAR",
483    metricsTag="hmtx",
484    sb1="LsbMap",
485    sb2="RsbMap",
486    advMapping="AdvWidthMap",
487    vOrigMapping=None,
488)
489
490VVAR_FIELDS = _MetricsFields(
491    tableTag="VVAR",
492    metricsTag="vmtx",
493    sb1="TsbMap",
494    sb2="BsbMap",
495    advMapping="AdvHeightMap",
496    vOrigMapping="VOrgMap",
497)
498
499
500def _add_HVAR(font, masterModel, master_ttfs, axisTags):
501    _add_VHVAR(font, masterModel, master_ttfs, axisTags, HVAR_FIELDS)
502
503
504def _add_VVAR(font, masterModel, master_ttfs, axisTags):
505    _add_VHVAR(font, masterModel, master_ttfs, axisTags, VVAR_FIELDS)
506
507
508def _add_VHVAR(font, masterModel, master_ttfs, axisTags, tableFields):
509    tableTag = tableFields.tableTag
510    assert tableTag not in font
511    log.info("Generating " + tableTag)
512    VHVAR = newTable(tableTag)
513    tableClass = getattr(ot, tableTag)
514    vhvar = VHVAR.table = tableClass()
515    vhvar.Version = 0x00010000
516
517    glyphOrder = font.getGlyphOrder()
518
519    # Build list of source font advance widths for each glyph
520    metricsTag = tableFields.metricsTag
521    advMetricses = [m[metricsTag].metrics for m in master_ttfs]
522
523    # Build list of source font vertical origin coords for each glyph
524    if tableTag == "VVAR" and "VORG" in master_ttfs[0]:
525        vOrigMetricses = [m["VORG"].VOriginRecords for m in master_ttfs]
526        defaultYOrigs = [m["VORG"].defaultVertOriginY for m in master_ttfs]
527        vOrigMetricses = list(zip(vOrigMetricses, defaultYOrigs))
528    else:
529        vOrigMetricses = None
530
531    metricsStore, advanceMapping, vOrigMapping = _get_advance_metrics(
532        font,
533        masterModel,
534        master_ttfs,
535        axisTags,
536        glyphOrder,
537        advMetricses,
538        vOrigMetricses,
539    )
540
541    vhvar.VarStore = metricsStore
542    if advanceMapping is None:
543        setattr(vhvar, tableFields.advMapping, None)
544    else:
545        setattr(vhvar, tableFields.advMapping, advanceMapping)
546    if vOrigMapping is not None:
547        setattr(vhvar, tableFields.vOrigMapping, vOrigMapping)
548    setattr(vhvar, tableFields.sb1, None)
549    setattr(vhvar, tableFields.sb2, None)
550
551    font[tableTag] = VHVAR
552    return
553
554
555def _get_advance_metrics(
556    font,
557    masterModel,
558    master_ttfs,
559    axisTags,
560    glyphOrder,
561    advMetricses,
562    vOrigMetricses=None,
563):
564    vhAdvanceDeltasAndSupports = {}
565    vOrigDeltasAndSupports = {}
566    # HACK: we treat width 65535 as a sentinel value to signal that a glyph
567    # from a non-default master should not participate in computing {H,V}VAR,
568    # as if it were missing. Allows to variate other glyph-related data independently
569    # from glyph metrics
570    sparse_advance = 0xFFFF
571    for glyph in glyphOrder:
572        vhAdvances = [
573            (
574                metrics[glyph][0]
575                if glyph in metrics and metrics[glyph][0] != sparse_advance
576                else None
577            )
578            for metrics in advMetricses
579        ]
580        vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(
581            vhAdvances, round=round
582        )
583
584    singleModel = models.allEqual(id(v[1]) for v in vhAdvanceDeltasAndSupports.values())
585
586    if vOrigMetricses:
587        singleModel = False
588        for glyph in glyphOrder:
589            # We need to supply a vOrigs tuple with non-None default values
590            # for each glyph. vOrigMetricses contains values only for those
591            # glyphs which have a non-default vOrig.
592            vOrigs = [
593                metrics[glyph] if glyph in metrics else defaultVOrig
594                for metrics, defaultVOrig in vOrigMetricses
595            ]
596            vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(
597                vOrigs, round=round
598            )
599
600    directStore = None
601    if singleModel:
602        # Build direct mapping
603        supports = next(iter(vhAdvanceDeltasAndSupports.values()))[1][1:]
604        varTupleList = builder.buildVarRegionList(supports, axisTags)
605        varTupleIndexes = list(range(len(supports)))
606        varData = builder.buildVarData(varTupleIndexes, [], optimize=False)
607        for glyphName in glyphOrder:
608            varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0], round=noRound)
609        varData.optimize()
610        directStore = builder.buildVarStore(varTupleList, [varData])
611
612    # Build optimized indirect mapping
613    storeBuilder = varStore.OnlineVarStoreBuilder(axisTags)
614    advMapping = {}
615    for glyphName in glyphOrder:
616        deltas, supports = vhAdvanceDeltasAndSupports[glyphName]
617        storeBuilder.setSupports(supports)
618        advMapping[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound)
619
620    if vOrigMetricses:
621        vOrigMap = {}
622        for glyphName in glyphOrder:
623            deltas, supports = vOrigDeltasAndSupports[glyphName]
624            storeBuilder.setSupports(supports)
625            vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound)
626
627    indirectStore = storeBuilder.finish()
628    mapping2 = indirectStore.optimize(use_NO_VARIATION_INDEX=False)
629    advMapping = [mapping2[advMapping[g]] for g in glyphOrder]
630    advanceMapping = builder.buildVarIdxMap(advMapping, glyphOrder)
631
632    if vOrigMetricses:
633        vOrigMap = [mapping2[vOrigMap[g]] for g in glyphOrder]
634
635    useDirect = False
636    vOrigMapping = None
637    if directStore:
638        # Compile both, see which is more compact
639
640        writer = OTTableWriter()
641        directStore.compile(writer, font)
642        directSize = len(writer.getAllData())
643
644        writer = OTTableWriter()
645        indirectStore.compile(writer, font)
646        advanceMapping.compile(writer, font)
647        indirectSize = len(writer.getAllData())
648
649        useDirect = directSize < indirectSize
650
651    if useDirect:
652        metricsStore = directStore
653        advanceMapping = None
654    else:
655        metricsStore = indirectStore
656        if vOrigMetricses:
657            vOrigMapping = builder.buildVarIdxMap(vOrigMap, glyphOrder)
658
659    return metricsStore, advanceMapping, vOrigMapping
660
661
662def _add_MVAR(font, masterModel, master_ttfs, axisTags):
663    log.info("Generating MVAR")
664
665    store_builder = varStore.OnlineVarStoreBuilder(axisTags)
666
667    records = []
668    lastTableTag = None
669    fontTable = None
670    tables = None
671    # HACK: we need to special-case post.underlineThickness and .underlinePosition
672    # and unilaterally/arbitrarily define a sentinel value to distinguish the case
673    # when a post table is present in a given master simply because that's where
674    # the glyph names in TrueType must be stored, but the underline values are not
675    # meant to be used for building MVAR's deltas. The value of -0x8000 (-36768)
676    # the minimum FWord (int16) value, was chosen for its unlikelyhood to appear
677    # in real-world underline position/thickness values.
678    specialTags = {"unds": -0x8000, "undo": -0x8000}
679
680    for tag, (tableTag, itemName) in sorted(MVAR_ENTRIES.items(), key=lambda kv: kv[1]):
681        # For each tag, fetch the associated table from all fonts (or not when we are
682        # still looking at a tag from the same tables) and set up the variation model
683        # for them.
684        if tableTag != lastTableTag:
685            tables = fontTable = None
686            if tableTag in font:
687                fontTable = font[tableTag]
688                tables = []
689                for master in master_ttfs:
690                    if tableTag not in master or (
691                        tag in specialTags
692                        and getattr(master[tableTag], itemName) == specialTags[tag]
693                    ):
694                        tables.append(None)
695                    else:
696                        tables.append(master[tableTag])
697                model, tables = masterModel.getSubModel(tables)
698                store_builder.setModel(model)
699            lastTableTag = tableTag
700
701        if tables is None:  # Tag not applicable to the master font.
702            continue
703
704        # TODO support gasp entries
705
706        master_values = [getattr(table, itemName) for table in tables]
707        if models.allEqual(master_values):
708            base, varIdx = master_values[0], None
709        else:
710            base, varIdx = store_builder.storeMasters(master_values)
711        setattr(fontTable, itemName, base)
712
713        if varIdx is None:
714            continue
715        log.info("	%s: %s.%s	%s", tag, tableTag, itemName, master_values)
716        rec = ot.MetricsValueRecord()
717        rec.ValueTag = tag
718        rec.VarIdx = varIdx
719        records.append(rec)
720
721    assert "MVAR" not in font
722    if records:
723        store = store_builder.finish()
724        # Optimize
725        mapping = store.optimize()
726        for rec in records:
727            rec.VarIdx = mapping[rec.VarIdx]
728
729        MVAR = font["MVAR"] = newTable("MVAR")
730        mvar = MVAR.table = ot.MVAR()
731        mvar.Version = 0x00010000
732        mvar.Reserved = 0
733        mvar.VarStore = store
734        # XXX these should not be hard-coded but computed automatically
735        mvar.ValueRecordSize = 8
736        mvar.ValueRecordCount = len(records)
737        mvar.ValueRecord = sorted(records, key=lambda r: r.ValueTag)
738
739
740def _add_BASE(font, masterModel, master_ttfs, axisTags):
741    log.info("Generating BASE")
742
743    merger = VariationMerger(masterModel, axisTags, font)
744    merger.mergeTables(font, master_ttfs, ["BASE"])
745    store = merger.store_builder.finish()
746
747    if not store:
748        return
749    base = font["BASE"].table
750    assert base.Version == 0x00010000
751    base.Version = 0x00010001
752    base.VarStore = store
753
754
755def _merge_OTL(font, model, master_fonts, axisTags):
756    otl_tags = ["GSUB", "GDEF", "GPOS"]
757    if not any(tag in font for tag in otl_tags):
758        return
759
760    log.info("Merging OpenType Layout tables")
761    merger = VariationMerger(model, axisTags, font)
762
763    merger.mergeTables(font, master_fonts, otl_tags)
764    store = merger.store_builder.finish()
765    if not store:
766        return
767    try:
768        GDEF = font["GDEF"].table
769        assert GDEF.Version <= 0x00010002
770    except KeyError:
771        font["GDEF"] = newTable("GDEF")
772        GDEFTable = font["GDEF"] = newTable("GDEF")
773        GDEF = GDEFTable.table = ot.GDEF()
774        GDEF.GlyphClassDef = None
775        GDEF.AttachList = None
776        GDEF.LigCaretList = None
777        GDEF.MarkAttachClassDef = None
778        GDEF.MarkGlyphSetsDef = None
779
780    GDEF.Version = 0x00010003
781    GDEF.VarStore = store
782
783    # Optimize
784    varidx_map = store.optimize()
785    GDEF.remap_device_varidxes(varidx_map)
786    if "GPOS" in font:
787        font["GPOS"].table.remap_device_varidxes(varidx_map)
788
789
790def _add_GSUB_feature_variations(
791    font, axes, internal_axis_supports, rules, featureTags
792):
793    def normalize(name, value):
794        return models.normalizeLocation({name: value}, internal_axis_supports)[name]
795
796    log.info("Generating GSUB FeatureVariations")
797
798    axis_tags = {name: axis.tag for name, axis in axes.items()}
799
800    conditional_subs = []
801    for rule in rules:
802        region = []
803        for conditions in rule.conditionSets:
804            space = {}
805            for condition in conditions:
806                axis_name = condition["name"]
807                if condition["minimum"] is not None:
808                    minimum = normalize(axis_name, condition["minimum"])
809                else:
810                    minimum = -1.0
811                if condition["maximum"] is not None:
812                    maximum = normalize(axis_name, condition["maximum"])
813                else:
814                    maximum = 1.0
815                tag = axis_tags[axis_name]
816                space[tag] = (minimum, maximum)
817            region.append(space)
818
819        subs = {k: v for k, v in rule.subs}
820
821        conditional_subs.append((region, subs))
822
823    addFeatureVariations(font, conditional_subs, featureTags)
824
825
826_DesignSpaceData = namedtuple(
827    "_DesignSpaceData",
828    [
829        "axes",
830        "axisMappings",
831        "internal_axis_supports",
832        "base_idx",
833        "normalized_master_locs",
834        "masters",
835        "instances",
836        "rules",
837        "rulesProcessingLast",
838        "lib",
839    ],
840)
841
842
843def _add_CFF2(varFont, model, master_fonts):
844    from .cff import merge_region_fonts
845
846    glyphOrder = varFont.getGlyphOrder()
847    if "CFF2" not in varFont:
848        from .cff import convertCFFtoCFF2
849
850        convertCFFtoCFF2(varFont)
851    ordered_fonts_list = model.reorderMasters(master_fonts, model.reverseMapping)
852    # re-ordering the master list simplifies building the CFF2 data item lists.
853    merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder)
854
855
856def _add_COLR(font, model, master_fonts, axisTags, colr_layer_reuse=True):
857    merger = COLRVariationMerger(
858        model, axisTags, font, allowLayerReuse=colr_layer_reuse
859    )
860    merger.mergeTables(font, master_fonts)
861    store = merger.store_builder.finish()
862
863    colr = font["COLR"].table
864    if store:
865        mapping = store.optimize()
866        colr.VarStore = store
867        varIdxes = [mapping[v] for v in merger.varIdxes]
868        colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes)
869
870
871def load_designspace(designspace, log_enabled=True):
872    # TODO: remove this and always assume 'designspace' is a DesignSpaceDocument,
873    # never a file path, as that's already handled by caller
874    if hasattr(designspace, "sources"):  # Assume a DesignspaceDocument
875        ds = designspace
876    else:  # Assume a file path
877        ds = DesignSpaceDocument.fromfile(designspace)
878
879    masters = ds.sources
880    if not masters:
881        raise VarLibValidationError("Designspace must have at least one source.")
882    instances = ds.instances
883
884    # TODO: Use fontTools.designspaceLib.tagForAxisName instead.
885    standard_axis_map = OrderedDict(
886        [
887            ("weight", ("wght", {"en": "Weight"})),
888            ("width", ("wdth", {"en": "Width"})),
889            ("slant", ("slnt", {"en": "Slant"})),
890            ("optical", ("opsz", {"en": "Optical Size"})),
891            ("italic", ("ital", {"en": "Italic"})),
892        ]
893    )
894
895    # Setup axes
896    if not ds.axes:
897        raise VarLibValidationError(f"Designspace must have at least one axis.")
898
899    axes = OrderedDict()
900    for axis_index, axis in enumerate(ds.axes):
901        axis_name = axis.name
902        if not axis_name:
903            if not axis.tag:
904                raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.")
905            axis_name = axis.name = axis.tag
906
907        if axis_name in standard_axis_map:
908            if axis.tag is None:
909                axis.tag = standard_axis_map[axis_name][0]
910            if not axis.labelNames:
911                axis.labelNames.update(standard_axis_map[axis_name][1])
912        else:
913            if not axis.tag:
914                raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.")
915            if not axis.labelNames:
916                axis.labelNames["en"] = tostr(axis_name)
917
918        axes[axis_name] = axis
919    if log_enabled:
920        log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()]))
921
922    axisMappings = ds.axisMappings
923    if axisMappings and log_enabled:
924        log.info("Mappings:\n%s", pformat(axisMappings))
925
926    # Check all master and instance locations are valid and fill in defaults
927    for obj in masters + instances:
928        obj_name = obj.name or obj.styleName or ""
929        loc = obj.getFullDesignLocation(ds)
930        obj.designLocation = loc
931        if loc is None:
932            raise VarLibValidationError(
933                f"Source or instance '{obj_name}' has no location."
934            )
935        for axis_name in loc.keys():
936            if axis_name not in axes:
937                raise VarLibValidationError(
938                    f"Location axis '{axis_name}' unknown for '{obj_name}'."
939                )
940        for axis_name, axis in axes.items():
941            v = axis.map_backward(loc[axis_name])
942            if not (axis.minimum <= v <= axis.maximum):
943                raise VarLibValidationError(
944                    f"Source or instance '{obj_name}' has out-of-range location "
945                    f"for axis '{axis_name}': is mapped to {v} but must be in "
946                    f"mapped range [{axis.minimum}..{axis.maximum}] (NOTE: all "
947                    "values are in user-space)."
948                )
949
950    # Normalize master locations
951
952    internal_master_locs = [o.getFullDesignLocation(ds) for o in masters]
953    if log_enabled:
954        log.info("Internal master locations:\n%s", pformat(internal_master_locs))
955
956    # TODO This mapping should ideally be moved closer to logic in _add_fvar/avar
957    internal_axis_supports = {}
958    for axis in axes.values():
959        triple = (axis.minimum, axis.default, axis.maximum)
960        internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple]
961    if log_enabled:
962        log.info("Internal axis supports:\n%s", pformat(internal_axis_supports))
963
964    normalized_master_locs = [
965        models.normalizeLocation(m, internal_axis_supports)
966        for m in internal_master_locs
967    ]
968    if log_enabled:
969        log.info("Normalized master locations:\n%s", pformat(normalized_master_locs))
970
971    # Find base master
972    base_idx = None
973    for i, m in enumerate(normalized_master_locs):
974        if all(v == 0 for v in m.values()):
975            if base_idx is not None:
976                raise VarLibValidationError(
977                    "More than one base master found in Designspace."
978                )
979            base_idx = i
980    if base_idx is None:
981        raise VarLibValidationError(
982            "Base master not found; no master at default location?"
983        )
984    if log_enabled:
985        log.info("Index of base master: %s", base_idx)
986
987    return _DesignSpaceData(
988        axes,
989        axisMappings,
990        internal_axis_supports,
991        base_idx,
992        normalized_master_locs,
993        masters,
994        instances,
995        ds.rules,
996        ds.rulesProcessingLast,
997        ds.lib,
998    )
999
1000
1001# https://docs.microsoft.com/en-us/typography/opentype/spec/os2#uswidthclass
1002WDTH_VALUE_TO_OS2_WIDTH_CLASS = {
1003    50: 1,
1004    62.5: 2,
1005    75: 3,
1006    87.5: 4,
1007    100: 5,
1008    112.5: 6,
1009    125: 7,
1010    150: 8,
1011    200: 9,
1012}
1013
1014
1015def set_default_weight_width_slant(font, location):
1016    if "OS/2" in font:
1017        if "wght" in location:
1018            weight_class = otRound(max(1, min(location["wght"], 1000)))
1019            if font["OS/2"].usWeightClass != weight_class:
1020                log.info("Setting OS/2.usWeightClass = %s", weight_class)
1021                font["OS/2"].usWeightClass = weight_class
1022
1023        if "wdth" in location:
1024            # map 'wdth' axis (50..200) to OS/2.usWidthClass (1..9), rounding to closest
1025            widthValue = min(max(location["wdth"], 50), 200)
1026            widthClass = otRound(
1027                models.piecewiseLinearMap(widthValue, WDTH_VALUE_TO_OS2_WIDTH_CLASS)
1028            )
1029            if font["OS/2"].usWidthClass != widthClass:
1030                log.info("Setting OS/2.usWidthClass = %s", widthClass)
1031                font["OS/2"].usWidthClass = widthClass
1032
1033    if "slnt" in location and "post" in font:
1034        italicAngle = max(-90, min(location["slnt"], 90))
1035        if font["post"].italicAngle != italicAngle:
1036            log.info("Setting post.italicAngle = %s", italicAngle)
1037            font["post"].italicAngle = italicAngle
1038
1039
1040def drop_implied_oncurve_points(*masters: TTFont) -> int:
1041    """Drop impliable on-curve points from all the simple glyphs in masters.
1042
1043    In TrueType glyf outlines, on-curve points can be implied when they are located
1044    exactly at the midpoint of the line connecting two consecutive off-curve points.
1045
1046    The input masters' glyf tables are assumed to contain same-named glyphs that are
1047    interpolatable. Oncurve points are only dropped if they can be implied for all
1048    the masters. The fonts are modified in-place.
1049
1050    Args:
1051        masters: The TTFont(s) to modify
1052
1053    Returns:
1054        The total number of points that were dropped if any.
1055
1056    Reference:
1057    https://developer.apple.com/fonts/TrueType-Reference-Manual/RM01/Chap1.html
1058    """
1059
1060    count = 0
1061    glyph_masters = defaultdict(list)
1062    # multiple DS source may point to the same TTFont object and we want to
1063    # avoid processing the same glyph twice as they are modified in-place
1064    for font in {id(m): m for m in masters}.values():
1065        glyf = font["glyf"]
1066        for glyphName in glyf.keys():
1067            glyph_masters[glyphName].append(glyf[glyphName])
1068    count = 0
1069    for glyphName, glyphs in glyph_masters.items():
1070        try:
1071            dropped = dropImpliedOnCurvePoints(*glyphs)
1072        except ValueError as e:
1073            # we don't fail for incompatible glyphs in _add_gvar so we shouldn't here
1074            log.warning("Failed to drop implied oncurves for %r: %s", glyphName, e)
1075        else:
1076            count += len(dropped)
1077    return count
1078
1079
1080def build_many(
1081    designspace: DesignSpaceDocument,
1082    master_finder=lambda s: s,
1083    exclude=[],
1084    optimize=True,
1085    skip_vf=lambda vf_name: False,
1086    colr_layer_reuse=True,
1087    drop_implied_oncurves=False,
1088):
1089    """
1090    Build variable fonts from a designspace file, version 5 which can define
1091    several VFs, or version 4 which has implicitly one VF covering the whole doc.
1092
1093    If master_finder is set, it should be a callable that takes master
1094    filename as found in designspace file and map it to master font
1095    binary as to be opened (eg. .ttf or .otf).
1096
1097    skip_vf can be used to skip building some of the variable fonts defined in
1098    the input designspace. It's a predicate that takes as argument the name
1099    of the variable font and returns `bool`.
1100
1101    Always returns a Dict[str, TTFont] keyed by VariableFontDescriptor.name
1102    """
1103    res = {}
1104    # varLib.build (used further below) by default only builds an incomplete 'STAT'
1105    # with an empty AxisValueArray--unless the VF inherited 'STAT' from its base master.
1106    # Designspace version 5 can also be used to define 'STAT' labels or customize
1107    # axes ordering, etc. To avoid overwriting a pre-existing 'STAT' or redoing the
1108    # same work twice, here we check if designspace contains any 'STAT' info before
1109    # proceeding to call buildVFStatTable for each VF.
1110    # https://github.com/fonttools/fonttools/pull/3024
1111    # https://github.com/fonttools/fonttools/issues/3045
1112    doBuildStatFromDSv5 = (
1113        "STAT" not in exclude
1114        and designspace.formatTuple >= (5, 0)
1115        and (
1116            any(a.axisLabels or a.axisOrdering is not None for a in designspace.axes)
1117            or designspace.locationLabels
1118        )
1119    )
1120    for _location, subDoc in splitInterpolable(designspace):
1121        for name, vfDoc in splitVariableFonts(subDoc):
1122            if skip_vf(name):
1123                log.debug(f"Skipping variable TTF font: {name}")
1124                continue
1125            vf = build(
1126                vfDoc,
1127                master_finder,
1128                exclude=exclude,
1129                optimize=optimize,
1130                colr_layer_reuse=colr_layer_reuse,
1131                drop_implied_oncurves=drop_implied_oncurves,
1132            )[0]
1133            if doBuildStatFromDSv5:
1134                buildVFStatTable(vf, designspace, name)
1135            res[name] = vf
1136    return res
1137
1138
1139def build(
1140    designspace,
1141    master_finder=lambda s: s,
1142    exclude=[],
1143    optimize=True,
1144    colr_layer_reuse=True,
1145    drop_implied_oncurves=False,
1146):
1147    """
1148    Build variation font from a designspace file.
1149
1150    If master_finder is set, it should be a callable that takes master
1151    filename as found in designspace file and map it to master font
1152    binary as to be opened (eg. .ttf or .otf).
1153    """
1154    if hasattr(designspace, "sources"):  # Assume a DesignspaceDocument
1155        pass
1156    else:  # Assume a file path
1157        designspace = DesignSpaceDocument.fromfile(designspace)
1158
1159    ds = load_designspace(designspace)
1160    log.info("Building variable font")
1161
1162    log.info("Loading master fonts")
1163    master_fonts = load_masters(designspace, master_finder)
1164
1165    # TODO: 'master_ttfs' is unused except for return value, remove later
1166    master_ttfs = []
1167    for master in master_fonts:
1168        try:
1169            master_ttfs.append(master.reader.file.name)
1170        except AttributeError:
1171            master_ttfs.append(None)  # in-memory fonts have no path
1172
1173    if drop_implied_oncurves and "glyf" in master_fonts[ds.base_idx]:
1174        drop_count = drop_implied_oncurve_points(*master_fonts)
1175        log.info(
1176            "Dropped %s on-curve points from simple glyphs in the 'glyf' table",
1177            drop_count,
1178        )
1179
1180    # Copy the base master to work from it
1181    vf = deepcopy(master_fonts[ds.base_idx])
1182
1183    if "DSIG" in vf:
1184        del vf["DSIG"]
1185
1186    # TODO append masters as named-instances as well; needs .designspace change.
1187    fvar = _add_fvar(vf, ds.axes, ds.instances)
1188    if "STAT" not in exclude:
1189        _add_stat(vf)
1190
1191    # Map from axis names to axis tags...
1192    normalized_master_locs = [
1193        {ds.axes[k].tag: v for k, v in loc.items()} for loc in ds.normalized_master_locs
1194    ]
1195    # From here on, we use fvar axes only
1196    axisTags = [axis.axisTag for axis in fvar.axes]
1197
1198    # Assume single-model for now.
1199    model = models.VariationModel(normalized_master_locs, axisOrder=axisTags)
1200    assert 0 == model.mapping[ds.base_idx]
1201
1202    log.info("Building variations tables")
1203    if "avar" not in exclude:
1204        _add_avar(vf, ds.axes, ds.axisMappings, axisTags)
1205    if "BASE" not in exclude and "BASE" in vf:
1206        _add_BASE(vf, model, master_fonts, axisTags)
1207    if "MVAR" not in exclude:
1208        _add_MVAR(vf, model, master_fonts, axisTags)
1209    if "HVAR" not in exclude:
1210        _add_HVAR(vf, model, master_fonts, axisTags)
1211    if "VVAR" not in exclude and "vmtx" in vf:
1212        _add_VVAR(vf, model, master_fonts, axisTags)
1213    if "GDEF" not in exclude or "GPOS" not in exclude:
1214        _merge_OTL(vf, model, master_fonts, axisTags)
1215    if "gvar" not in exclude and "glyf" in vf:
1216        _add_gvar(vf, model, master_fonts, optimize=optimize)
1217    if "cvar" not in exclude and "glyf" in vf:
1218        _merge_TTHinting(vf, model, master_fonts)
1219    if "GSUB" not in exclude and ds.rules:
1220        featureTags = _feature_variations_tags(ds)
1221        _add_GSUB_feature_variations(
1222            vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags
1223        )
1224    if "CFF2" not in exclude and ("CFF " in vf or "CFF2" in vf):
1225        _add_CFF2(vf, model, master_fonts)
1226        if "post" in vf:
1227            # set 'post' to format 2 to keep the glyph names dropped from CFF2
1228            post = vf["post"]
1229            if post.formatType != 2.0:
1230                post.formatType = 2.0
1231                post.extraNames = []
1232                post.mapping = {}
1233    if "COLR" not in exclude and "COLR" in vf and vf["COLR"].version > 0:
1234        _add_COLR(vf, model, master_fonts, axisTags, colr_layer_reuse)
1235
1236    set_default_weight_width_slant(
1237        vf, location={axis.axisTag: axis.defaultValue for axis in vf["fvar"].axes}
1238    )
1239
1240    for tag in exclude:
1241        if tag in vf:
1242            del vf[tag]
1243
1244    # TODO: Only return vf for 4.0+, the rest is unused.
1245    return vf, model, master_ttfs
1246
1247
1248def _open_font(path, master_finder=lambda s: s):
1249    # load TTFont masters from given 'path': this can be either a .TTX or an
1250    # OpenType binary font; or if neither of these, try use the 'master_finder'
1251    # callable to resolve the path to a valid .TTX or OpenType font binary.
1252    from fontTools.ttx import guessFileType
1253
1254    master_path = os.path.normpath(path)
1255    tp = guessFileType(master_path)
1256    if tp is None:
1257        # not an OpenType binary/ttx, fall back to the master finder.
1258        master_path = master_finder(master_path)
1259        tp = guessFileType(master_path)
1260    if tp in ("TTX", "OTX"):
1261        font = TTFont()
1262        font.importXML(master_path)
1263    elif tp in ("TTF", "OTF", "WOFF", "WOFF2"):
1264        font = TTFont(master_path)
1265    else:
1266        raise VarLibValidationError("Invalid master path: %r" % master_path)
1267    return font
1268
1269
1270def load_masters(designspace, master_finder=lambda s: s):
1271    """Ensure that all SourceDescriptor.font attributes have an appropriate TTFont
1272    object loaded, or else open TTFont objects from the SourceDescriptor.path
1273    attributes.
1274
1275    The paths can point to either an OpenType font, a TTX file, or a UFO. In the
1276    latter case, use the provided master_finder callable to map from UFO paths to
1277    the respective master font binaries (e.g. .ttf, .otf or .ttx).
1278
1279    Return list of master TTFont objects in the same order they are listed in the
1280    DesignSpaceDocument.
1281    """
1282    for master in designspace.sources:
1283        # If a SourceDescriptor has a layer name, demand that the compiled TTFont
1284        # be supplied by the caller. This spares us from modifying MasterFinder.
1285        if master.layerName and master.font is None:
1286            raise VarLibValidationError(
1287                f"Designspace source '{master.name or '<Unknown>'}' specified a "
1288                "layer name but lacks the required TTFont object in the 'font' "
1289                "attribute."
1290            )
1291
1292    return designspace.loadSourceFonts(_open_font, master_finder=master_finder)
1293
1294
1295class MasterFinder(object):
1296    def __init__(self, template):
1297        self.template = template
1298
1299    def __call__(self, src_path):
1300        fullname = os.path.abspath(src_path)
1301        dirname, basename = os.path.split(fullname)
1302        stem, ext = os.path.splitext(basename)
1303        path = self.template.format(
1304            fullname=fullname,
1305            dirname=dirname,
1306            basename=basename,
1307            stem=stem,
1308            ext=ext,
1309        )
1310        return os.path.normpath(path)
1311
1312
1313def _feature_variations_tags(ds):
1314    raw_tags = ds.lib.get(
1315        FEAVAR_FEATURETAG_LIB_KEY,
1316        "rclt" if ds.rulesProcessingLast else "rvrn",
1317    )
1318    return sorted({t.strip() for t in raw_tags.split(",")})
1319
1320
1321def addGSUBFeatureVariations(vf, designspace, featureTags=(), *, log_enabled=False):
1322    """Add GSUB FeatureVariations table to variable font, based on DesignSpace rules.
1323
1324    Args:
1325        vf: A TTFont object representing the variable font.
1326        designspace: A DesignSpaceDocument object.
1327        featureTags: Optional feature tag(s) to use for the FeatureVariations records.
1328            If unset, the key 'com.github.fonttools.varLib.featureVarsFeatureTag' is
1329            looked up in the DS <lib> and used; otherwise the default is 'rclt' if
1330            the <rules processing="last"> attribute is set, else 'rvrn'.
1331            See <https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#rules-element>
1332        log_enabled: If True, log info about DS axes and sources. Default is False, as
1333            the same info may have already been logged as part of varLib.build.
1334    """
1335    ds = load_designspace(designspace, log_enabled=log_enabled)
1336    if not ds.rules:
1337        return
1338    if not featureTags:
1339        featureTags = _feature_variations_tags(ds)
1340    _add_GSUB_feature_variations(
1341        vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags
1342    )
1343
1344
1345def main(args=None):
1346    """Build variable fonts from a designspace file and masters"""
1347    from argparse import ArgumentParser
1348    from fontTools import configLogger
1349
1350    parser = ArgumentParser(prog="varLib", description=main.__doc__)
1351    parser.add_argument("designspace")
1352    output_group = parser.add_mutually_exclusive_group()
1353    output_group.add_argument(
1354        "-o", metavar="OUTPUTFILE", dest="outfile", default=None, help="output file"
1355    )
1356    output_group.add_argument(
1357        "-d",
1358        "--output-dir",
1359        metavar="OUTPUTDIR",
1360        default=None,
1361        help="output dir (default: same as input designspace file)",
1362    )
1363    parser.add_argument(
1364        "-x",
1365        metavar="TAG",
1366        dest="exclude",
1367        action="append",
1368        default=[],
1369        help="exclude table",
1370    )
1371    parser.add_argument(
1372        "--disable-iup",
1373        dest="optimize",
1374        action="store_false",
1375        help="do not perform IUP optimization",
1376    )
1377    parser.add_argument(
1378        "--no-colr-layer-reuse",
1379        dest="colr_layer_reuse",
1380        action="store_false",
1381        help="do not rebuild variable COLR table to optimize COLR layer reuse",
1382    )
1383    parser.add_argument(
1384        "--drop-implied-oncurves",
1385        action="store_true",
1386        help=(
1387            "drop on-curve points that can be implied when exactly in the middle of "
1388            "two off-curve points (only applies to TrueType fonts)"
1389        ),
1390    )
1391    parser.add_argument(
1392        "--master-finder",
1393        default="master_ttf_interpolatable/{stem}.ttf",
1394        help=(
1395            "templated string used for finding binary font "
1396            "files given the source file names defined in the "
1397            "designspace document. The following special strings "
1398            "are defined: {fullname} is the absolute source file "
1399            "name; {basename} is the file name without its "
1400            "directory; {stem} is the basename without the file "
1401            "extension; {ext} is the source file extension; "
1402            "{dirname} is the directory of the absolute file "
1403            'name. The default value is "%(default)s".'
1404        ),
1405    )
1406    parser.add_argument(
1407        "--variable-fonts",
1408        default=".*",
1409        metavar="VF_NAME",
1410        help=(
1411            "Filter the list of variable fonts produced from the input "
1412            "Designspace v5 file. By default all listed variable fonts are "
1413            "generated. To generate a specific variable font (or variable fonts) "
1414            'that match a given "name" attribute, you can pass as argument '
1415            "the full name or a regular expression. E.g.: --variable-fonts "
1416            '"MyFontVF_WeightOnly"; or --variable-fonts "MyFontVFItalic_.*".'
1417        ),
1418    )
1419    logging_group = parser.add_mutually_exclusive_group(required=False)
1420    logging_group.add_argument(
1421        "-v", "--verbose", action="store_true", help="Run more verbosely."
1422    )
1423    logging_group.add_argument(
1424        "-q", "--quiet", action="store_true", help="Turn verbosity off."
1425    )
1426    options = parser.parse_args(args)
1427
1428    configLogger(
1429        level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO")
1430    )
1431
1432    designspace_filename = options.designspace
1433    designspace = DesignSpaceDocument.fromfile(designspace_filename)
1434
1435    vf_descriptors = designspace.getVariableFonts()
1436    if not vf_descriptors:
1437        parser.error(f"No variable fonts in given designspace {designspace.path!r}")
1438
1439    vfs_to_build = []
1440    for vf in vf_descriptors:
1441        # Skip variable fonts that do not match the user's inclusion regex if given.
1442        if not fullmatch(options.variable_fonts, vf.name):
1443            continue
1444        vfs_to_build.append(vf)
1445
1446    if not vfs_to_build:
1447        parser.error(f"No variable fonts matching {options.variable_fonts!r}")
1448
1449    if options.outfile is not None and len(vfs_to_build) > 1:
1450        parser.error(
1451            "can't specify -o because there are multiple VFs to build; "
1452            "use --output-dir, or select a single VF with --variable-fonts"
1453        )
1454
1455    output_dir = options.output_dir
1456    if output_dir is None:
1457        output_dir = os.path.dirname(designspace_filename)
1458
1459    vf_name_to_output_path = {}
1460    if len(vfs_to_build) == 1 and options.outfile is not None:
1461        vf_name_to_output_path[vfs_to_build[0].name] = options.outfile
1462    else:
1463        for vf in vfs_to_build:
1464            filename = vf.filename if vf.filename is not None else vf.name + ".{ext}"
1465            vf_name_to_output_path[vf.name] = os.path.join(output_dir, filename)
1466
1467    finder = MasterFinder(options.master_finder)
1468
1469    vfs = build_many(
1470        designspace,
1471        finder,
1472        exclude=options.exclude,
1473        optimize=options.optimize,
1474        colr_layer_reuse=options.colr_layer_reuse,
1475        drop_implied_oncurves=options.drop_implied_oncurves,
1476    )
1477
1478    for vf_name, vf in vfs.items():
1479        ext = "otf" if vf.sfntVersion == "OTTO" else "ttf"
1480        output_path = vf_name_to_output_path[vf_name].format(ext=ext)
1481        output_dir = os.path.dirname(output_path)
1482        if output_dir:
1483            os.makedirs(output_dir, exist_ok=True)
1484        log.info("Saving variation font %s", output_path)
1485        vf.save(output_path)
1486
1487
1488if __name__ == "__main__":
1489    import sys
1490
1491    if len(sys.argv) > 1:
1492        sys.exit(main())
1493    import doctest
1494
1495    sys.exit(doctest.testmod().failed)
1496