xref: /aosp_15_r20/external/fonttools/Lib/fontTools/feaLib/builder.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1from fontTools.misc import sstruct
2from fontTools.misc.textTools import Tag, tostr, binary2num, safeEval
3from fontTools.feaLib.error import FeatureLibError
4from fontTools.feaLib.lookupDebugInfo import (
5    LookupDebugInfo,
6    LOOKUP_DEBUG_INFO_KEY,
7    LOOKUP_DEBUG_ENV_VAR,
8)
9from fontTools.feaLib.parser import Parser
10from fontTools.feaLib.ast import FeatureFile
11from fontTools.feaLib.variableScalar import VariableScalar
12from fontTools.otlLib import builder as otl
13from fontTools.otlLib.maxContextCalc import maxCtxFont
14from fontTools.ttLib import newTable, getTableModule
15from fontTools.ttLib.tables import otBase, otTables
16from fontTools.otlLib.builder import (
17    AlternateSubstBuilder,
18    ChainContextPosBuilder,
19    ChainContextSubstBuilder,
20    LigatureSubstBuilder,
21    MultipleSubstBuilder,
22    CursivePosBuilder,
23    MarkBasePosBuilder,
24    MarkLigPosBuilder,
25    MarkMarkPosBuilder,
26    ReverseChainSingleSubstBuilder,
27    SingleSubstBuilder,
28    ClassPairPosSubtableBuilder,
29    PairPosBuilder,
30    SinglePosBuilder,
31    ChainContextualRule,
32)
33from fontTools.otlLib.error import OpenTypeLibError
34from fontTools.varLib.varStore import OnlineVarStoreBuilder
35from fontTools.varLib.builder import buildVarDevTable
36from fontTools.varLib.featureVars import addFeatureVariationsRaw
37from fontTools.varLib.models import normalizeValue, piecewiseLinearMap
38from collections import defaultdict
39import copy
40import itertools
41from io import StringIO
42import logging
43import warnings
44import os
45
46
47log = logging.getLogger(__name__)
48
49
50def addOpenTypeFeatures(font, featurefile, tables=None, debug=False):
51    """Add features from a file to a font. Note that this replaces any features
52    currently present.
53
54    Args:
55        font (feaLib.ttLib.TTFont): The font object.
56        featurefile: Either a path or file object (in which case we
57            parse it into an AST), or a pre-parsed AST instance.
58        tables: If passed, restrict the set of affected tables to those in the
59            list.
60        debug: Whether to add source debugging information to the font in the
61            ``Debg`` table
62
63    """
64    builder = Builder(font, featurefile)
65    builder.build(tables=tables, debug=debug)
66
67
68def addOpenTypeFeaturesFromString(
69    font, features, filename=None, tables=None, debug=False
70):
71    """Add features from a string to a font. Note that this replaces any
72    features currently present.
73
74    Args:
75        font (feaLib.ttLib.TTFont): The font object.
76        features: A string containing feature code.
77        filename: The directory containing ``filename`` is used as the root of
78            relative ``include()`` paths; if ``None`` is provided, the current
79            directory is assumed.
80        tables: If passed, restrict the set of affected tables to those in the
81            list.
82        debug: Whether to add source debugging information to the font in the
83            ``Debg`` table
84
85    """
86
87    featurefile = StringIO(tostr(features))
88    if filename:
89        featurefile.name = filename
90    addOpenTypeFeatures(font, featurefile, tables=tables, debug=debug)
91
92
93class Builder(object):
94    supportedTables = frozenset(
95        Tag(tag)
96        for tag in [
97            "BASE",
98            "GDEF",
99            "GPOS",
100            "GSUB",
101            "OS/2",
102            "head",
103            "hhea",
104            "name",
105            "vhea",
106            "STAT",
107        ]
108    )
109
110    def __init__(self, font, featurefile):
111        self.font = font
112        # 'featurefile' can be either a path or file object (in which case we
113        # parse it into an AST), or a pre-parsed AST instance
114        if isinstance(featurefile, FeatureFile):
115            self.parseTree, self.file = featurefile, None
116        else:
117            self.parseTree, self.file = None, featurefile
118        self.glyphMap = font.getReverseGlyphMap()
119        self.varstorebuilder = None
120        if "fvar" in font:
121            self.axes = font["fvar"].axes
122            self.varstorebuilder = OnlineVarStoreBuilder(
123                [ax.axisTag for ax in self.axes]
124            )
125        self.default_language_systems_ = set()
126        self.script_ = None
127        self.lookupflag_ = 0
128        self.lookupflag_markFilterSet_ = None
129        self.language_systems = set()
130        self.seen_non_DFLT_script_ = False
131        self.named_lookups_ = {}
132        self.cur_lookup_ = None
133        self.cur_lookup_name_ = None
134        self.cur_feature_name_ = None
135        self.lookups_ = []
136        self.lookup_locations = {"GSUB": {}, "GPOS": {}}
137        self.features_ = {}  # ('latn', 'DEU ', 'smcp') --> [LookupBuilder*]
138        self.required_features_ = {}  # ('latn', 'DEU ') --> 'scmp'
139        self.feature_variations_ = {}
140        # for feature 'aalt'
141        self.aalt_features_ = []  # [(location, featureName)*], for 'aalt'
142        self.aalt_location_ = None
143        self.aalt_alternates_ = {}
144        # for 'featureNames'
145        self.featureNames_ = set()
146        self.featureNames_ids_ = {}
147        # for 'cvParameters'
148        self.cv_parameters_ = set()
149        self.cv_parameters_ids_ = {}
150        self.cv_num_named_params_ = {}
151        self.cv_characters_ = defaultdict(list)
152        # for feature 'size'
153        self.size_parameters_ = None
154        # for table 'head'
155        self.fontRevision_ = None  # 2.71
156        # for table 'name'
157        self.names_ = []
158        # for table 'BASE'
159        self.base_horiz_axis_ = None
160        self.base_vert_axis_ = None
161        # for table 'GDEF'
162        self.attachPoints_ = {}  # "a" --> {3, 7}
163        self.ligCaretCoords_ = {}  # "f_f_i" --> {300, 600}
164        self.ligCaretPoints_ = {}  # "f_f_i" --> {3, 7}
165        self.glyphClassDefs_ = {}  # "fi" --> (2, (file, line, column))
166        self.markAttach_ = {}  # "acute" --> (4, (file, line, column))
167        self.markAttachClassID_ = {}  # frozenset({"acute", "grave"}) --> 4
168        self.markFilterSets_ = {}  # frozenset({"acute", "grave"}) --> 4
169        # for table 'OS/2'
170        self.os2_ = {}
171        # for table 'hhea'
172        self.hhea_ = {}
173        # for table 'vhea'
174        self.vhea_ = {}
175        # for table 'STAT'
176        self.stat_ = {}
177        # for conditionsets
178        self.conditionsets_ = {}
179        # We will often use exactly the same locations (i.e. the font's masters)
180        # for a large number of variable scalars. Instead of creating a model
181        # for each, let's share the models.
182        self.model_cache = {}
183
184    def build(self, tables=None, debug=False):
185        if self.parseTree is None:
186            self.parseTree = Parser(self.file, self.glyphMap).parse()
187        self.parseTree.build(self)
188        # by default, build all the supported tables
189        if tables is None:
190            tables = self.supportedTables
191        else:
192            tables = frozenset(tables)
193            unsupported = tables - self.supportedTables
194            if unsupported:
195                unsupported_string = ", ".join(sorted(unsupported))
196                raise NotImplementedError(
197                    "The following tables were requested but are unsupported: "
198                    f"{unsupported_string}."
199                )
200        if "GSUB" in tables:
201            self.build_feature_aalt_()
202        if "head" in tables:
203            self.build_head()
204        if "hhea" in tables:
205            self.build_hhea()
206        if "vhea" in tables:
207            self.build_vhea()
208        if "name" in tables:
209            self.build_name()
210        if "OS/2" in tables:
211            self.build_OS_2()
212        if "STAT" in tables:
213            self.build_STAT()
214        for tag in ("GPOS", "GSUB"):
215            if tag not in tables:
216                continue
217            table = self.makeTable(tag)
218            if self.feature_variations_:
219                self.makeFeatureVariations(table, tag)
220            if (
221                table.ScriptList.ScriptCount > 0
222                or table.FeatureList.FeatureCount > 0
223                or table.LookupList.LookupCount > 0
224            ):
225                fontTable = self.font[tag] = newTable(tag)
226                fontTable.table = table
227            elif tag in self.font:
228                del self.font[tag]
229        if any(tag in self.font for tag in ("GPOS", "GSUB")) and "OS/2" in self.font:
230            self.font["OS/2"].usMaxContext = maxCtxFont(self.font)
231        if "GDEF" in tables:
232            gdef = self.buildGDEF()
233            if gdef:
234                self.font["GDEF"] = gdef
235            elif "GDEF" in self.font:
236                del self.font["GDEF"]
237        if "BASE" in tables:
238            base = self.buildBASE()
239            if base:
240                self.font["BASE"] = base
241            elif "BASE" in self.font:
242                del self.font["BASE"]
243        if debug or os.environ.get(LOOKUP_DEBUG_ENV_VAR):
244            self.buildDebg()
245
246    def get_chained_lookup_(self, location, builder_class):
247        result = builder_class(self.font, location)
248        result.lookupflag = self.lookupflag_
249        result.markFilterSet = self.lookupflag_markFilterSet_
250        self.lookups_.append(result)
251        return result
252
253    def add_lookup_to_feature_(self, lookup, feature_name):
254        for script, lang in self.language_systems:
255            key = (script, lang, feature_name)
256            self.features_.setdefault(key, []).append(lookup)
257
258    def get_lookup_(self, location, builder_class):
259        if (
260            self.cur_lookup_
261            and type(self.cur_lookup_) == builder_class
262            and self.cur_lookup_.lookupflag == self.lookupflag_
263            and self.cur_lookup_.markFilterSet == self.lookupflag_markFilterSet_
264        ):
265            return self.cur_lookup_
266        if self.cur_lookup_name_ and self.cur_lookup_:
267            raise FeatureLibError(
268                "Within a named lookup block, all rules must be of "
269                "the same lookup type and flag",
270                location,
271            )
272        self.cur_lookup_ = builder_class(self.font, location)
273        self.cur_lookup_.lookupflag = self.lookupflag_
274        self.cur_lookup_.markFilterSet = self.lookupflag_markFilterSet_
275        self.lookups_.append(self.cur_lookup_)
276        if self.cur_lookup_name_:
277            # We are starting a lookup rule inside a named lookup block.
278            self.named_lookups_[self.cur_lookup_name_] = self.cur_lookup_
279        if self.cur_feature_name_:
280            # We are starting a lookup rule inside a feature. This includes
281            # lookup rules inside named lookups inside features.
282            self.add_lookup_to_feature_(self.cur_lookup_, self.cur_feature_name_)
283        return self.cur_lookup_
284
285    def build_feature_aalt_(self):
286        if not self.aalt_features_ and not self.aalt_alternates_:
287            return
288        # > alternate glyphs will be sorted in the order that the source features
289        # > are named in the aalt definition, not the order of the feature definitions
290        # > in the file. Alternates defined explicitly ... will precede all others.
291        # https://github.com/fonttools/fonttools/issues/836
292        alternates = {g: list(a) for g, a in self.aalt_alternates_.items()}
293        for location, name in self.aalt_features_ + [(None, "aalt")]:
294            feature = [
295                (script, lang, feature, lookups)
296                for (script, lang, feature), lookups in self.features_.items()
297                if feature == name
298            ]
299            # "aalt" does not have to specify its own lookups, but it might.
300            if not feature and name != "aalt":
301                warnings.warn("%s: Feature %s has not been defined" % (location, name))
302                continue
303            for script, lang, feature, lookups in feature:
304                for lookuplist in lookups:
305                    if not isinstance(lookuplist, list):
306                        lookuplist = [lookuplist]
307                    for lookup in lookuplist:
308                        for glyph, alts in lookup.getAlternateGlyphs().items():
309                            alts_for_glyph = alternates.setdefault(glyph, [])
310                            alts_for_glyph.extend(
311                                g for g in alts if g not in alts_for_glyph
312                            )
313        single = {
314            glyph: repl[0] for glyph, repl in alternates.items() if len(repl) == 1
315        }
316        multi = {glyph: repl for glyph, repl in alternates.items() if len(repl) > 1}
317        if not single and not multi:
318            return
319        self.features_ = {
320            (script, lang, feature): lookups
321            for (script, lang, feature), lookups in self.features_.items()
322            if feature != "aalt"
323        }
324        old_lookups = self.lookups_
325        self.lookups_ = []
326        self.start_feature(self.aalt_location_, "aalt")
327        if single:
328            single_lookup = self.get_lookup_(location, SingleSubstBuilder)
329            single_lookup.mapping = single
330        if multi:
331            multi_lookup = self.get_lookup_(location, AlternateSubstBuilder)
332            multi_lookup.alternates = multi
333        self.end_feature()
334        self.lookups_.extend(old_lookups)
335
336    def build_head(self):
337        if not self.fontRevision_:
338            return
339        table = self.font.get("head")
340        if not table:  # this only happens for unit tests
341            table = self.font["head"] = newTable("head")
342            table.decompile(b"\0" * 54, self.font)
343            table.tableVersion = 1.0
344            table.created = table.modified = 3406620153  # 2011-12-13 11:22:33
345        table.fontRevision = self.fontRevision_
346
347    def build_hhea(self):
348        if not self.hhea_:
349            return
350        table = self.font.get("hhea")
351        if not table:  # this only happens for unit tests
352            table = self.font["hhea"] = newTable("hhea")
353            table.decompile(b"\0" * 36, self.font)
354            table.tableVersion = 0x00010000
355        if "caretoffset" in self.hhea_:
356            table.caretOffset = self.hhea_["caretoffset"]
357        if "ascender" in self.hhea_:
358            table.ascent = self.hhea_["ascender"]
359        if "descender" in self.hhea_:
360            table.descent = self.hhea_["descender"]
361        if "linegap" in self.hhea_:
362            table.lineGap = self.hhea_["linegap"]
363
364    def build_vhea(self):
365        if not self.vhea_:
366            return
367        table = self.font.get("vhea")
368        if not table:  # this only happens for unit tests
369            table = self.font["vhea"] = newTable("vhea")
370            table.decompile(b"\0" * 36, self.font)
371            table.tableVersion = 0x00011000
372        if "verttypoascender" in self.vhea_:
373            table.ascent = self.vhea_["verttypoascender"]
374        if "verttypodescender" in self.vhea_:
375            table.descent = self.vhea_["verttypodescender"]
376        if "verttypolinegap" in self.vhea_:
377            table.lineGap = self.vhea_["verttypolinegap"]
378
379    def get_user_name_id(self, table):
380        # Try to find first unused font-specific name id
381        nameIDs = [name.nameID for name in table.names]
382        for user_name_id in range(256, 32767):
383            if user_name_id not in nameIDs:
384                return user_name_id
385
386    def buildFeatureParams(self, tag):
387        params = None
388        if tag == "size":
389            params = otTables.FeatureParamsSize()
390            (
391                params.DesignSize,
392                params.SubfamilyID,
393                params.RangeStart,
394                params.RangeEnd,
395            ) = self.size_parameters_
396            if tag in self.featureNames_ids_:
397                params.SubfamilyNameID = self.featureNames_ids_[tag]
398            else:
399                params.SubfamilyNameID = 0
400        elif tag in self.featureNames_:
401            if not self.featureNames_ids_:
402                # name table wasn't selected among the tables to build; skip
403                pass
404            else:
405                assert tag in self.featureNames_ids_
406                params = otTables.FeatureParamsStylisticSet()
407                params.Version = 0
408                params.UINameID = self.featureNames_ids_[tag]
409        elif tag in self.cv_parameters_:
410            params = otTables.FeatureParamsCharacterVariants()
411            params.Format = 0
412            params.FeatUILabelNameID = self.cv_parameters_ids_.get(
413                (tag, "FeatUILabelNameID"), 0
414            )
415            params.FeatUITooltipTextNameID = self.cv_parameters_ids_.get(
416                (tag, "FeatUITooltipTextNameID"), 0
417            )
418            params.SampleTextNameID = self.cv_parameters_ids_.get(
419                (tag, "SampleTextNameID"), 0
420            )
421            params.NumNamedParameters = self.cv_num_named_params_.get(tag, 0)
422            params.FirstParamUILabelNameID = self.cv_parameters_ids_.get(
423                (tag, "ParamUILabelNameID_0"), 0
424            )
425            params.CharCount = len(self.cv_characters_[tag])
426            params.Character = self.cv_characters_[tag]
427        return params
428
429    def build_name(self):
430        if not self.names_:
431            return
432        table = self.font.get("name")
433        if not table:  # this only happens for unit tests
434            table = self.font["name"] = newTable("name")
435            table.names = []
436        for name in self.names_:
437            nameID, platformID, platEncID, langID, string = name
438            # For featureNames block, nameID is 'feature tag'
439            # For cvParameters blocks, nameID is ('feature tag', 'block name')
440            if not isinstance(nameID, int):
441                tag = nameID
442                if tag in self.featureNames_:
443                    if tag not in self.featureNames_ids_:
444                        self.featureNames_ids_[tag] = self.get_user_name_id(table)
445                        assert self.featureNames_ids_[tag] is not None
446                    nameID = self.featureNames_ids_[tag]
447                elif tag[0] in self.cv_parameters_:
448                    if tag not in self.cv_parameters_ids_:
449                        self.cv_parameters_ids_[tag] = self.get_user_name_id(table)
450                        assert self.cv_parameters_ids_[tag] is not None
451                    nameID = self.cv_parameters_ids_[tag]
452            table.setName(string, nameID, platformID, platEncID, langID)
453        table.names.sort()
454
455    def build_OS_2(self):
456        if not self.os2_:
457            return
458        table = self.font.get("OS/2")
459        if not table:  # this only happens for unit tests
460            table = self.font["OS/2"] = newTable("OS/2")
461            data = b"\0" * sstruct.calcsize(getTableModule("OS/2").OS2_format_0)
462            table.decompile(data, self.font)
463        version = 0
464        if "fstype" in self.os2_:
465            table.fsType = self.os2_["fstype"]
466        if "panose" in self.os2_:
467            panose = getTableModule("OS/2").Panose()
468            (
469                panose.bFamilyType,
470                panose.bSerifStyle,
471                panose.bWeight,
472                panose.bProportion,
473                panose.bContrast,
474                panose.bStrokeVariation,
475                panose.bArmStyle,
476                panose.bLetterForm,
477                panose.bMidline,
478                panose.bXHeight,
479            ) = self.os2_["panose"]
480            table.panose = panose
481        if "typoascender" in self.os2_:
482            table.sTypoAscender = self.os2_["typoascender"]
483        if "typodescender" in self.os2_:
484            table.sTypoDescender = self.os2_["typodescender"]
485        if "typolinegap" in self.os2_:
486            table.sTypoLineGap = self.os2_["typolinegap"]
487        if "winascent" in self.os2_:
488            table.usWinAscent = self.os2_["winascent"]
489        if "windescent" in self.os2_:
490            table.usWinDescent = self.os2_["windescent"]
491        if "vendor" in self.os2_:
492            table.achVendID = safeEval("'''" + self.os2_["vendor"] + "'''")
493        if "weightclass" in self.os2_:
494            table.usWeightClass = self.os2_["weightclass"]
495        if "widthclass" in self.os2_:
496            table.usWidthClass = self.os2_["widthclass"]
497        if "unicoderange" in self.os2_:
498            table.setUnicodeRanges(self.os2_["unicoderange"])
499        if "codepagerange" in self.os2_:
500            pages = self.build_codepages_(self.os2_["codepagerange"])
501            table.ulCodePageRange1, table.ulCodePageRange2 = pages
502            version = 1
503        if "xheight" in self.os2_:
504            table.sxHeight = self.os2_["xheight"]
505            version = 2
506        if "capheight" in self.os2_:
507            table.sCapHeight = self.os2_["capheight"]
508            version = 2
509        if "loweropsize" in self.os2_:
510            table.usLowerOpticalPointSize = self.os2_["loweropsize"]
511            version = 5
512        if "upperopsize" in self.os2_:
513            table.usUpperOpticalPointSize = self.os2_["upperopsize"]
514            version = 5
515
516        def checkattr(table, attrs):
517            for attr in attrs:
518                if not hasattr(table, attr):
519                    setattr(table, attr, 0)
520
521        table.version = max(version, table.version)
522        # this only happens for unit tests
523        if version >= 1:
524            checkattr(table, ("ulCodePageRange1", "ulCodePageRange2"))
525        if version >= 2:
526            checkattr(
527                table,
528                (
529                    "sxHeight",
530                    "sCapHeight",
531                    "usDefaultChar",
532                    "usBreakChar",
533                    "usMaxContext",
534                ),
535            )
536        if version >= 5:
537            checkattr(table, ("usLowerOpticalPointSize", "usUpperOpticalPointSize"))
538
539    def setElidedFallbackName(self, value, location):
540        # ElidedFallbackName is a convenience method for setting
541        # ElidedFallbackNameID so only one can be allowed
542        for token in ("ElidedFallbackName", "ElidedFallbackNameID"):
543            if token in self.stat_:
544                raise FeatureLibError(
545                    f"{token} is already set.",
546                    location,
547                )
548        if isinstance(value, int):
549            self.stat_["ElidedFallbackNameID"] = value
550        elif isinstance(value, list):
551            self.stat_["ElidedFallbackName"] = value
552        else:
553            raise AssertionError(value)
554
555    def addDesignAxis(self, designAxis, location):
556        if "DesignAxes" not in self.stat_:
557            self.stat_["DesignAxes"] = []
558        if designAxis.tag in (r.tag for r in self.stat_["DesignAxes"]):
559            raise FeatureLibError(
560                f'DesignAxis already defined for tag "{designAxis.tag}".',
561                location,
562            )
563        if designAxis.axisOrder in (r.axisOrder for r in self.stat_["DesignAxes"]):
564            raise FeatureLibError(
565                f"DesignAxis already defined for axis number {designAxis.axisOrder}.",
566                location,
567            )
568        self.stat_["DesignAxes"].append(designAxis)
569
570    def addAxisValueRecord(self, axisValueRecord, location):
571        if "AxisValueRecords" not in self.stat_:
572            self.stat_["AxisValueRecords"] = []
573        # Check for duplicate AxisValueRecords
574        for record_ in self.stat_["AxisValueRecords"]:
575            if (
576                {n.asFea() for n in record_.names}
577                == {n.asFea() for n in axisValueRecord.names}
578                and {n.asFea() for n in record_.locations}
579                == {n.asFea() for n in axisValueRecord.locations}
580                and record_.flags == axisValueRecord.flags
581            ):
582                raise FeatureLibError(
583                    "An AxisValueRecord with these values is already defined.",
584                    location,
585                )
586        self.stat_["AxisValueRecords"].append(axisValueRecord)
587
588    def build_STAT(self):
589        if not self.stat_:
590            return
591
592        axes = self.stat_.get("DesignAxes")
593        if not axes:
594            raise FeatureLibError("DesignAxes not defined", None)
595        axisValueRecords = self.stat_.get("AxisValueRecords")
596        axisValues = {}
597        format4_locations = []
598        for tag in axes:
599            axisValues[tag.tag] = []
600        if axisValueRecords is not None:
601            for avr in axisValueRecords:
602                valuesDict = {}
603                if avr.flags > 0:
604                    valuesDict["flags"] = avr.flags
605                if len(avr.locations) == 1:
606                    location = avr.locations[0]
607                    values = location.values
608                    if len(values) == 1:  # format1
609                        valuesDict.update({"value": values[0], "name": avr.names})
610                    if len(values) == 2:  # format3
611                        valuesDict.update(
612                            {
613                                "value": values[0],
614                                "linkedValue": values[1],
615                                "name": avr.names,
616                            }
617                        )
618                    if len(values) == 3:  # format2
619                        nominal, minVal, maxVal = values
620                        valuesDict.update(
621                            {
622                                "nominalValue": nominal,
623                                "rangeMinValue": minVal,
624                                "rangeMaxValue": maxVal,
625                                "name": avr.names,
626                            }
627                        )
628                    axisValues[location.tag].append(valuesDict)
629                else:
630                    valuesDict.update(
631                        {
632                            "location": {i.tag: i.values[0] for i in avr.locations},
633                            "name": avr.names,
634                        }
635                    )
636                    format4_locations.append(valuesDict)
637
638        designAxes = [
639            {
640                "ordering": a.axisOrder,
641                "tag": a.tag,
642                "name": a.names,
643                "values": axisValues[a.tag],
644            }
645            for a in axes
646        ]
647
648        nameTable = self.font.get("name")
649        if not nameTable:  # this only happens for unit tests
650            nameTable = self.font["name"] = newTable("name")
651            nameTable.names = []
652
653        if "ElidedFallbackNameID" in self.stat_:
654            nameID = self.stat_["ElidedFallbackNameID"]
655            name = nameTable.getDebugName(nameID)
656            if not name:
657                raise FeatureLibError(
658                    f"ElidedFallbackNameID {nameID} points "
659                    "to a nameID that does not exist in the "
660                    '"name" table',
661                    None,
662                )
663        elif "ElidedFallbackName" in self.stat_:
664            nameID = self.stat_["ElidedFallbackName"]
665
666        otl.buildStatTable(
667            self.font,
668            designAxes,
669            locations=format4_locations,
670            elidedFallbackName=nameID,
671        )
672
673    def build_codepages_(self, pages):
674        pages2bits = {
675            1252: 0,
676            1250: 1,
677            1251: 2,
678            1253: 3,
679            1254: 4,
680            1255: 5,
681            1256: 6,
682            1257: 7,
683            1258: 8,
684            874: 16,
685            932: 17,
686            936: 18,
687            949: 19,
688            950: 20,
689            1361: 21,
690            869: 48,
691            866: 49,
692            865: 50,
693            864: 51,
694            863: 52,
695            862: 53,
696            861: 54,
697            860: 55,
698            857: 56,
699            855: 57,
700            852: 58,
701            775: 59,
702            737: 60,
703            708: 61,
704            850: 62,
705            437: 63,
706        }
707        bits = [pages2bits[p] for p in pages if p in pages2bits]
708        pages = []
709        for i in range(2):
710            pages.append("")
711            for j in range(i * 32, (i + 1) * 32):
712                if j in bits:
713                    pages[i] += "1"
714                else:
715                    pages[i] += "0"
716        return [binary2num(p[::-1]) for p in pages]
717
718    def buildBASE(self):
719        if not self.base_horiz_axis_ and not self.base_vert_axis_:
720            return None
721        base = otTables.BASE()
722        base.Version = 0x00010000
723        base.HorizAxis = self.buildBASEAxis(self.base_horiz_axis_)
724        base.VertAxis = self.buildBASEAxis(self.base_vert_axis_)
725
726        result = newTable("BASE")
727        result.table = base
728        return result
729
730    def buildBASEAxis(self, axis):
731        if not axis:
732            return
733        bases, scripts = axis
734        axis = otTables.Axis()
735        axis.BaseTagList = otTables.BaseTagList()
736        axis.BaseTagList.BaselineTag = bases
737        axis.BaseTagList.BaseTagCount = len(bases)
738        axis.BaseScriptList = otTables.BaseScriptList()
739        axis.BaseScriptList.BaseScriptRecord = []
740        axis.BaseScriptList.BaseScriptCount = len(scripts)
741        for script in sorted(scripts):
742            record = otTables.BaseScriptRecord()
743            record.BaseScriptTag = script[0]
744            record.BaseScript = otTables.BaseScript()
745            record.BaseScript.BaseLangSysCount = 0
746            record.BaseScript.BaseValues = otTables.BaseValues()
747            record.BaseScript.BaseValues.DefaultIndex = bases.index(script[1])
748            record.BaseScript.BaseValues.BaseCoord = []
749            record.BaseScript.BaseValues.BaseCoordCount = len(script[2])
750            for c in script[2]:
751                coord = otTables.BaseCoord()
752                coord.Format = 1
753                coord.Coordinate = c
754                record.BaseScript.BaseValues.BaseCoord.append(coord)
755            axis.BaseScriptList.BaseScriptRecord.append(record)
756        return axis
757
758    def buildGDEF(self):
759        gdef = otTables.GDEF()
760        gdef.GlyphClassDef = self.buildGDEFGlyphClassDef_()
761        gdef.AttachList = otl.buildAttachList(self.attachPoints_, self.glyphMap)
762        gdef.LigCaretList = otl.buildLigCaretList(
763            self.ligCaretCoords_, self.ligCaretPoints_, self.glyphMap
764        )
765        gdef.MarkAttachClassDef = self.buildGDEFMarkAttachClassDef_()
766        gdef.MarkGlyphSetsDef = self.buildGDEFMarkGlyphSetsDef_()
767        gdef.Version = 0x00010002 if gdef.MarkGlyphSetsDef else 0x00010000
768        if self.varstorebuilder:
769            store = self.varstorebuilder.finish()
770            if store:
771                gdef.Version = 0x00010003
772                gdef.VarStore = store
773                varidx_map = store.optimize()
774
775                gdef.remap_device_varidxes(varidx_map)
776                if "GPOS" in self.font:
777                    self.font["GPOS"].table.remap_device_varidxes(varidx_map)
778            self.model_cache.clear()
779        if any(
780            (
781                gdef.GlyphClassDef,
782                gdef.AttachList,
783                gdef.LigCaretList,
784                gdef.MarkAttachClassDef,
785                gdef.MarkGlyphSetsDef,
786            )
787        ) or hasattr(gdef, "VarStore"):
788            result = newTable("GDEF")
789            result.table = gdef
790            return result
791        else:
792            return None
793
794    def buildGDEFGlyphClassDef_(self):
795        if self.glyphClassDefs_:
796            classes = {g: c for (g, (c, _)) in self.glyphClassDefs_.items()}
797        else:
798            classes = {}
799            for lookup in self.lookups_:
800                classes.update(lookup.inferGlyphClasses())
801            for markClass in self.parseTree.markClasses.values():
802                for markClassDef in markClass.definitions:
803                    for glyph in markClassDef.glyphSet():
804                        classes[glyph] = 3
805        if classes:
806            result = otTables.GlyphClassDef()
807            result.classDefs = classes
808            return result
809        else:
810            return None
811
812    def buildGDEFMarkAttachClassDef_(self):
813        classDefs = {g: c for g, (c, _) in self.markAttach_.items()}
814        if not classDefs:
815            return None
816        result = otTables.MarkAttachClassDef()
817        result.classDefs = classDefs
818        return result
819
820    def buildGDEFMarkGlyphSetsDef_(self):
821        sets = []
822        for glyphs, id_ in sorted(
823            self.markFilterSets_.items(), key=lambda item: item[1]
824        ):
825            sets.append(glyphs)
826        return otl.buildMarkGlyphSetsDef(sets, self.glyphMap)
827
828    def buildDebg(self):
829        if "Debg" not in self.font:
830            self.font["Debg"] = newTable("Debg")
831            self.font["Debg"].data = {}
832        self.font["Debg"].data[LOOKUP_DEBUG_INFO_KEY] = self.lookup_locations
833
834    def buildLookups_(self, tag):
835        assert tag in ("GPOS", "GSUB"), tag
836        for lookup in self.lookups_:
837            lookup.lookup_index = None
838        lookups = []
839        for lookup in self.lookups_:
840            if lookup.table != tag:
841                continue
842            lookup.lookup_index = len(lookups)
843            self.lookup_locations[tag][str(lookup.lookup_index)] = LookupDebugInfo(
844                location=str(lookup.location),
845                name=self.get_lookup_name_(lookup),
846                feature=None,
847            )
848            lookups.append(lookup)
849        otLookups = []
850        for l in lookups:
851            try:
852                otLookups.append(l.build())
853            except OpenTypeLibError as e:
854                raise FeatureLibError(str(e), e.location) from e
855            except Exception as e:
856                location = self.lookup_locations[tag][str(l.lookup_index)].location
857                raise FeatureLibError(str(e), location) from e
858        return otLookups
859
860    def makeTable(self, tag):
861        table = getattr(otTables, tag, None)()
862        table.Version = 0x00010000
863        table.ScriptList = otTables.ScriptList()
864        table.ScriptList.ScriptRecord = []
865        table.FeatureList = otTables.FeatureList()
866        table.FeatureList.FeatureRecord = []
867        table.LookupList = otTables.LookupList()
868        table.LookupList.Lookup = self.buildLookups_(tag)
869
870        # Build a table for mapping (tag, lookup_indices) to feature_index.
871        # For example, ('liga', (2,3,7)) --> 23.
872        feature_indices = {}
873        required_feature_indices = {}  # ('latn', 'DEU') --> 23
874        scripts = {}  # 'latn' --> {'DEU': [23, 24]} for feature #23,24
875        # Sort the feature table by feature tag:
876        # https://github.com/fonttools/fonttools/issues/568
877        sortFeatureTag = lambda f: (f[0][2], f[0][1], f[0][0], f[1])
878        for key, lookups in sorted(self.features_.items(), key=sortFeatureTag):
879            script, lang, feature_tag = key
880            # l.lookup_index will be None when a lookup is not needed
881            # for the table under construction. For example, substitution
882            # rules will have no lookup_index while building GPOS tables.
883            lookup_indices = tuple(
884                [l.lookup_index for l in lookups if l.lookup_index is not None]
885            )
886
887            size_feature = tag == "GPOS" and feature_tag == "size"
888            force_feature = self.any_feature_variations(feature_tag, tag)
889            if len(lookup_indices) == 0 and not size_feature and not force_feature:
890                continue
891
892            for ix in lookup_indices:
893                try:
894                    self.lookup_locations[tag][str(ix)] = self.lookup_locations[tag][
895                        str(ix)
896                    ]._replace(feature=key)
897                except KeyError:
898                    warnings.warn(
899                        "feaLib.Builder subclass needs upgrading to "
900                        "stash debug information. See fonttools#2065."
901                    )
902
903            feature_key = (feature_tag, lookup_indices)
904            feature_index = feature_indices.get(feature_key)
905            if feature_index is None:
906                feature_index = len(table.FeatureList.FeatureRecord)
907                frec = otTables.FeatureRecord()
908                frec.FeatureTag = feature_tag
909                frec.Feature = otTables.Feature()
910                frec.Feature.FeatureParams = self.buildFeatureParams(feature_tag)
911                frec.Feature.LookupListIndex = list(lookup_indices)
912                frec.Feature.LookupCount = len(lookup_indices)
913                table.FeatureList.FeatureRecord.append(frec)
914                feature_indices[feature_key] = feature_index
915            scripts.setdefault(script, {}).setdefault(lang, []).append(feature_index)
916            if self.required_features_.get((script, lang)) == feature_tag:
917                required_feature_indices[(script, lang)] = feature_index
918
919        # Build ScriptList.
920        for script, lang_features in sorted(scripts.items()):
921            srec = otTables.ScriptRecord()
922            srec.ScriptTag = script
923            srec.Script = otTables.Script()
924            srec.Script.DefaultLangSys = None
925            srec.Script.LangSysRecord = []
926            for lang, feature_indices in sorted(lang_features.items()):
927                langrec = otTables.LangSysRecord()
928                langrec.LangSys = otTables.LangSys()
929                langrec.LangSys.LookupOrder = None
930
931                req_feature_index = required_feature_indices.get((script, lang))
932                if req_feature_index is None:
933                    langrec.LangSys.ReqFeatureIndex = 0xFFFF
934                else:
935                    langrec.LangSys.ReqFeatureIndex = req_feature_index
936
937                langrec.LangSys.FeatureIndex = [
938                    i for i in feature_indices if i != req_feature_index
939                ]
940                langrec.LangSys.FeatureCount = len(langrec.LangSys.FeatureIndex)
941
942                if lang == "dflt":
943                    srec.Script.DefaultLangSys = langrec.LangSys
944                else:
945                    langrec.LangSysTag = lang
946                    srec.Script.LangSysRecord.append(langrec)
947            srec.Script.LangSysCount = len(srec.Script.LangSysRecord)
948            table.ScriptList.ScriptRecord.append(srec)
949
950        table.ScriptList.ScriptCount = len(table.ScriptList.ScriptRecord)
951        table.FeatureList.FeatureCount = len(table.FeatureList.FeatureRecord)
952        table.LookupList.LookupCount = len(table.LookupList.Lookup)
953        return table
954
955    def makeFeatureVariations(self, table, table_tag):
956        feature_vars = {}
957        has_any_variations = False
958        # Sort out which lookups to build, gather their indices
959        for (_, _, feature_tag), variations in self.feature_variations_.items():
960            feature_vars[feature_tag] = []
961            for conditionset, builders in variations.items():
962                raw_conditionset = self.conditionsets_[conditionset]
963                indices = []
964                for b in builders:
965                    if b.table != table_tag:
966                        continue
967                    assert b.lookup_index is not None
968                    indices.append(b.lookup_index)
969                    has_any_variations = True
970                feature_vars[feature_tag].append((raw_conditionset, indices))
971
972        if has_any_variations:
973            for feature_tag, conditions_and_lookups in feature_vars.items():
974                addFeatureVariationsRaw(
975                    self.font, table, conditions_and_lookups, feature_tag
976                )
977
978    def any_feature_variations(self, feature_tag, table_tag):
979        for (_, _, feature), variations in self.feature_variations_.items():
980            if feature != feature_tag:
981                continue
982            for conditionset, builders in variations.items():
983                if any(b.table == table_tag for b in builders):
984                    return True
985        return False
986
987    def get_lookup_name_(self, lookup):
988        rev = {v: k for k, v in self.named_lookups_.items()}
989        if lookup in rev:
990            return rev[lookup]
991        return None
992
993    def add_language_system(self, location, script, language):
994        # OpenType Feature File Specification, section 4.b.i
995        if script == "DFLT" and language == "dflt" and self.default_language_systems_:
996            raise FeatureLibError(
997                'If "languagesystem DFLT dflt" is present, it must be '
998                "the first of the languagesystem statements",
999                location,
1000            )
1001        if script == "DFLT":
1002            if self.seen_non_DFLT_script_:
1003                raise FeatureLibError(
1004                    'languagesystems using the "DFLT" script tag must '
1005                    "precede all other languagesystems",
1006                    location,
1007                )
1008        else:
1009            self.seen_non_DFLT_script_ = True
1010        if (script, language) in self.default_language_systems_:
1011            raise FeatureLibError(
1012                '"languagesystem %s %s" has already been specified'
1013                % (script.strip(), language.strip()),
1014                location,
1015            )
1016        self.default_language_systems_.add((script, language))
1017
1018    def get_default_language_systems_(self):
1019        # OpenType Feature File specification, 4.b.i. languagesystem:
1020        # If no "languagesystem" statement is present, then the
1021        # implementation must behave exactly as though the following
1022        # statement were present at the beginning of the feature file:
1023        # languagesystem DFLT dflt;
1024        if self.default_language_systems_:
1025            return frozenset(self.default_language_systems_)
1026        else:
1027            return frozenset({("DFLT", "dflt")})
1028
1029    def start_feature(self, location, name):
1030        self.language_systems = self.get_default_language_systems_()
1031        self.script_ = "DFLT"
1032        self.cur_lookup_ = None
1033        self.cur_feature_name_ = name
1034        self.lookupflag_ = 0
1035        self.lookupflag_markFilterSet_ = None
1036        if name == "aalt":
1037            self.aalt_location_ = location
1038
1039    def end_feature(self):
1040        assert self.cur_feature_name_ is not None
1041        self.cur_feature_name_ = None
1042        self.language_systems = None
1043        self.cur_lookup_ = None
1044        self.lookupflag_ = 0
1045        self.lookupflag_markFilterSet_ = None
1046
1047    def start_lookup_block(self, location, name):
1048        if name in self.named_lookups_:
1049            raise FeatureLibError(
1050                'Lookup "%s" has already been defined' % name, location
1051            )
1052        if self.cur_feature_name_ == "aalt":
1053            raise FeatureLibError(
1054                "Lookup blocks cannot be placed inside 'aalt' features; "
1055                "move it out, and then refer to it with a lookup statement",
1056                location,
1057            )
1058        self.cur_lookup_name_ = name
1059        self.named_lookups_[name] = None
1060        self.cur_lookup_ = None
1061        if self.cur_feature_name_ is None:
1062            self.lookupflag_ = 0
1063            self.lookupflag_markFilterSet_ = None
1064
1065    def end_lookup_block(self):
1066        assert self.cur_lookup_name_ is not None
1067        self.cur_lookup_name_ = None
1068        self.cur_lookup_ = None
1069        if self.cur_feature_name_ is None:
1070            self.lookupflag_ = 0
1071            self.lookupflag_markFilterSet_ = None
1072
1073    def add_lookup_call(self, lookup_name):
1074        assert lookup_name in self.named_lookups_, lookup_name
1075        self.cur_lookup_ = None
1076        lookup = self.named_lookups_[lookup_name]
1077        if lookup is not None:  # skip empty named lookup
1078            self.add_lookup_to_feature_(lookup, self.cur_feature_name_)
1079
1080    def set_font_revision(self, location, revision):
1081        self.fontRevision_ = revision
1082
1083    def set_language(self, location, language, include_default, required):
1084        assert len(language) == 4
1085        if self.cur_feature_name_ in ("aalt", "size"):
1086            raise FeatureLibError(
1087                "Language statements are not allowed "
1088                'within "feature %s"' % self.cur_feature_name_,
1089                location,
1090            )
1091        if self.cur_feature_name_ is None:
1092            raise FeatureLibError(
1093                "Language statements are not allowed "
1094                "within standalone lookup blocks",
1095                location,
1096            )
1097        self.cur_lookup_ = None
1098
1099        key = (self.script_, language, self.cur_feature_name_)
1100        lookups = self.features_.get((key[0], "dflt", key[2]))
1101        if (language == "dflt" or include_default) and lookups:
1102            self.features_[key] = lookups[:]
1103        else:
1104            self.features_[key] = []
1105        self.language_systems = frozenset([(self.script_, language)])
1106
1107        if required:
1108            key = (self.script_, language)
1109            if key in self.required_features_:
1110                raise FeatureLibError(
1111                    "Language %s (script %s) has already "
1112                    "specified feature %s as its required feature"
1113                    % (
1114                        language.strip(),
1115                        self.script_.strip(),
1116                        self.required_features_[key].strip(),
1117                    ),
1118                    location,
1119                )
1120            self.required_features_[key] = self.cur_feature_name_
1121
1122    def getMarkAttachClass_(self, location, glyphs):
1123        glyphs = frozenset(glyphs)
1124        id_ = self.markAttachClassID_.get(glyphs)
1125        if id_ is not None:
1126            return id_
1127        id_ = len(self.markAttachClassID_) + 1
1128        self.markAttachClassID_[glyphs] = id_
1129        for glyph in glyphs:
1130            if glyph in self.markAttach_:
1131                _, loc = self.markAttach_[glyph]
1132                raise FeatureLibError(
1133                    "Glyph %s already has been assigned "
1134                    "a MarkAttachmentType at %s" % (glyph, loc),
1135                    location,
1136                )
1137            self.markAttach_[glyph] = (id_, location)
1138        return id_
1139
1140    def getMarkFilterSet_(self, location, glyphs):
1141        glyphs = frozenset(glyphs)
1142        id_ = self.markFilterSets_.get(glyphs)
1143        if id_ is not None:
1144            return id_
1145        id_ = len(self.markFilterSets_)
1146        self.markFilterSets_[glyphs] = id_
1147        return id_
1148
1149    def set_lookup_flag(self, location, value, markAttach, markFilter):
1150        value = value & 0xFF
1151        if markAttach:
1152            markAttachClass = self.getMarkAttachClass_(location, markAttach)
1153            value = value | (markAttachClass << 8)
1154        if markFilter:
1155            markFilterSet = self.getMarkFilterSet_(location, markFilter)
1156            value = value | 0x10
1157            self.lookupflag_markFilterSet_ = markFilterSet
1158        else:
1159            self.lookupflag_markFilterSet_ = None
1160        self.lookupflag_ = value
1161
1162    def set_script(self, location, script):
1163        if self.cur_feature_name_ in ("aalt", "size"):
1164            raise FeatureLibError(
1165                "Script statements are not allowed "
1166                'within "feature %s"' % self.cur_feature_name_,
1167                location,
1168            )
1169        if self.cur_feature_name_ is None:
1170            raise FeatureLibError(
1171                "Script statements are not allowed " "within standalone lookup blocks",
1172                location,
1173            )
1174        if self.language_systems == {(script, "dflt")}:
1175            # Nothing to do.
1176            return
1177        self.cur_lookup_ = None
1178        self.script_ = script
1179        self.lookupflag_ = 0
1180        self.lookupflag_markFilterSet_ = None
1181        self.set_language(location, "dflt", include_default=True, required=False)
1182
1183    def find_lookup_builders_(self, lookups):
1184        """Helper for building chain contextual substitutions
1185
1186        Given a list of lookup names, finds the LookupBuilder for each name.
1187        If an input name is None, it gets mapped to a None LookupBuilder.
1188        """
1189        lookup_builders = []
1190        for lookuplist in lookups:
1191            if lookuplist is not None:
1192                lookup_builders.append(
1193                    [self.named_lookups_.get(l.name) for l in lookuplist]
1194                )
1195            else:
1196                lookup_builders.append(None)
1197        return lookup_builders
1198
1199    def add_attach_points(self, location, glyphs, contourPoints):
1200        for glyph in glyphs:
1201            self.attachPoints_.setdefault(glyph, set()).update(contourPoints)
1202
1203    def add_feature_reference(self, location, featureName):
1204        if self.cur_feature_name_ != "aalt":
1205            raise FeatureLibError(
1206                'Feature references are only allowed inside "feature aalt"', location
1207            )
1208        self.aalt_features_.append((location, featureName))
1209
1210    def add_featureName(self, tag):
1211        self.featureNames_.add(tag)
1212
1213    def add_cv_parameter(self, tag):
1214        self.cv_parameters_.add(tag)
1215
1216    def add_to_cv_num_named_params(self, tag):
1217        """Adds new items to ``self.cv_num_named_params_``
1218        or increments the count of existing items."""
1219        if tag in self.cv_num_named_params_:
1220            self.cv_num_named_params_[tag] += 1
1221        else:
1222            self.cv_num_named_params_[tag] = 1
1223
1224    def add_cv_character(self, character, tag):
1225        self.cv_characters_[tag].append(character)
1226
1227    def set_base_axis(self, bases, scripts, vertical):
1228        if vertical:
1229            self.base_vert_axis_ = (bases, scripts)
1230        else:
1231            self.base_horiz_axis_ = (bases, scripts)
1232
1233    def set_size_parameters(
1234        self, location, DesignSize, SubfamilyID, RangeStart, RangeEnd
1235    ):
1236        if self.cur_feature_name_ != "size":
1237            raise FeatureLibError(
1238                "Parameters statements are not allowed "
1239                'within "feature %s"' % self.cur_feature_name_,
1240                location,
1241            )
1242        self.size_parameters_ = [DesignSize, SubfamilyID, RangeStart, RangeEnd]
1243        for script, lang in self.language_systems:
1244            key = (script, lang, self.cur_feature_name_)
1245            self.features_.setdefault(key, [])
1246
1247    # GSUB rules
1248
1249    # GSUB 1
1250    def add_single_subst(self, location, prefix, suffix, mapping, forceChain):
1251        if self.cur_feature_name_ == "aalt":
1252            for from_glyph, to_glyph in mapping.items():
1253                alts = self.aalt_alternates_.setdefault(from_glyph, [])
1254                if to_glyph not in alts:
1255                    alts.append(to_glyph)
1256            return
1257        if prefix or suffix or forceChain:
1258            self.add_single_subst_chained_(location, prefix, suffix, mapping)
1259            return
1260        lookup = self.get_lookup_(location, SingleSubstBuilder)
1261        for from_glyph, to_glyph in mapping.items():
1262            if from_glyph in lookup.mapping:
1263                if to_glyph == lookup.mapping[from_glyph]:
1264                    log.info(
1265                        "Removing duplicate single substitution from glyph"
1266                        ' "%s" to "%s" at %s',
1267                        from_glyph,
1268                        to_glyph,
1269                        location,
1270                    )
1271                else:
1272                    raise FeatureLibError(
1273                        'Already defined rule for replacing glyph "%s" by "%s"'
1274                        % (from_glyph, lookup.mapping[from_glyph]),
1275                        location,
1276                    )
1277            lookup.mapping[from_glyph] = to_glyph
1278
1279    # GSUB 2
1280    def add_multiple_subst(
1281        self, location, prefix, glyph, suffix, replacements, forceChain=False
1282    ):
1283        if prefix or suffix or forceChain:
1284            chain = self.get_lookup_(location, ChainContextSubstBuilder)
1285            sub = self.get_chained_lookup_(location, MultipleSubstBuilder)
1286            sub.mapping[glyph] = replacements
1287            chain.rules.append(ChainContextualRule(prefix, [{glyph}], suffix, [sub]))
1288            return
1289        lookup = self.get_lookup_(location, MultipleSubstBuilder)
1290        if glyph in lookup.mapping:
1291            if replacements == lookup.mapping[glyph]:
1292                log.info(
1293                    "Removing duplicate multiple substitution from glyph"
1294                    ' "%s" to %s%s',
1295                    glyph,
1296                    replacements,
1297                    f" at {location}" if location else "",
1298                )
1299            else:
1300                raise FeatureLibError(
1301                    'Already defined substitution for glyph "%s"' % glyph, location
1302                )
1303        lookup.mapping[glyph] = replacements
1304
1305    # GSUB 3
1306    def add_alternate_subst(self, location, prefix, glyph, suffix, replacement):
1307        if self.cur_feature_name_ == "aalt":
1308            alts = self.aalt_alternates_.setdefault(glyph, [])
1309            alts.extend(g for g in replacement if g not in alts)
1310            return
1311        if prefix or suffix:
1312            chain = self.get_lookup_(location, ChainContextSubstBuilder)
1313            lookup = self.get_chained_lookup_(location, AlternateSubstBuilder)
1314            chain.rules.append(ChainContextualRule(prefix, [{glyph}], suffix, [lookup]))
1315        else:
1316            lookup = self.get_lookup_(location, AlternateSubstBuilder)
1317        if glyph in lookup.alternates:
1318            raise FeatureLibError(
1319                'Already defined alternates for glyph "%s"' % glyph, location
1320            )
1321        # We allow empty replacement glyphs here.
1322        lookup.alternates[glyph] = replacement
1323
1324    # GSUB 4
1325    def add_ligature_subst(
1326        self, location, prefix, glyphs, suffix, replacement, forceChain
1327    ):
1328        if prefix or suffix or forceChain:
1329            chain = self.get_lookup_(location, ChainContextSubstBuilder)
1330            lookup = self.get_chained_lookup_(location, LigatureSubstBuilder)
1331            chain.rules.append(ChainContextualRule(prefix, glyphs, suffix, [lookup]))
1332        else:
1333            lookup = self.get_lookup_(location, LigatureSubstBuilder)
1334
1335        if not all(glyphs):
1336            raise FeatureLibError("Empty glyph class in substitution", location)
1337
1338        # OpenType feature file syntax, section 5.d, "Ligature substitution":
1339        # "Since the OpenType specification does not allow ligature
1340        # substitutions to be specified on target sequences that contain
1341        # glyph classes, the implementation software will enumerate
1342        # all specific glyph sequences if glyph classes are detected"
1343        for g in itertools.product(*glyphs):
1344            lookup.ligatures[g] = replacement
1345
1346    # GSUB 5/6
1347    def add_chain_context_subst(self, location, prefix, glyphs, suffix, lookups):
1348        if not all(glyphs) or not all(prefix) or not all(suffix):
1349            raise FeatureLibError(
1350                "Empty glyph class in contextual substitution", location
1351            )
1352        lookup = self.get_lookup_(location, ChainContextSubstBuilder)
1353        lookup.rules.append(
1354            ChainContextualRule(
1355                prefix, glyphs, suffix, self.find_lookup_builders_(lookups)
1356            )
1357        )
1358
1359    def add_single_subst_chained_(self, location, prefix, suffix, mapping):
1360        if not mapping or not all(prefix) or not all(suffix):
1361            raise FeatureLibError(
1362                "Empty glyph class in contextual substitution", location
1363            )
1364        # https://github.com/fonttools/fonttools/issues/512
1365        # https://github.com/fonttools/fonttools/issues/2150
1366        chain = self.get_lookup_(location, ChainContextSubstBuilder)
1367        sub = chain.find_chainable_single_subst(mapping)
1368        if sub is None:
1369            sub = self.get_chained_lookup_(location, SingleSubstBuilder)
1370        sub.mapping.update(mapping)
1371        chain.rules.append(
1372            ChainContextualRule(prefix, [list(mapping.keys())], suffix, [sub])
1373        )
1374
1375    # GSUB 8
1376    def add_reverse_chain_single_subst(self, location, old_prefix, old_suffix, mapping):
1377        if not mapping:
1378            raise FeatureLibError("Empty glyph class in substitution", location)
1379        lookup = self.get_lookup_(location, ReverseChainSingleSubstBuilder)
1380        lookup.rules.append((old_prefix, old_suffix, mapping))
1381
1382    # GPOS rules
1383
1384    # GPOS 1
1385    def add_single_pos(self, location, prefix, suffix, pos, forceChain):
1386        if prefix or suffix or forceChain:
1387            self.add_single_pos_chained_(location, prefix, suffix, pos)
1388        else:
1389            lookup = self.get_lookup_(location, SinglePosBuilder)
1390            for glyphs, value in pos:
1391                if not glyphs:
1392                    raise FeatureLibError(
1393                        "Empty glyph class in positioning rule", location
1394                    )
1395                otValueRecord = self.makeOpenTypeValueRecord(
1396                    location, value, pairPosContext=False
1397                )
1398                for glyph in glyphs:
1399                    try:
1400                        lookup.add_pos(location, glyph, otValueRecord)
1401                    except OpenTypeLibError as e:
1402                        raise FeatureLibError(str(e), e.location) from e
1403
1404    # GPOS 2
1405    def add_class_pair_pos(self, location, glyphclass1, value1, glyphclass2, value2):
1406        if not glyphclass1 or not glyphclass2:
1407            raise FeatureLibError("Empty glyph class in positioning rule", location)
1408        lookup = self.get_lookup_(location, PairPosBuilder)
1409        v1 = self.makeOpenTypeValueRecord(location, value1, pairPosContext=True)
1410        v2 = self.makeOpenTypeValueRecord(location, value2, pairPosContext=True)
1411        lookup.addClassPair(location, glyphclass1, v1, glyphclass2, v2)
1412
1413    def add_specific_pair_pos(self, location, glyph1, value1, glyph2, value2):
1414        if not glyph1 or not glyph2:
1415            raise FeatureLibError("Empty glyph class in positioning rule", location)
1416        lookup = self.get_lookup_(location, PairPosBuilder)
1417        v1 = self.makeOpenTypeValueRecord(location, value1, pairPosContext=True)
1418        v2 = self.makeOpenTypeValueRecord(location, value2, pairPosContext=True)
1419        lookup.addGlyphPair(location, glyph1, v1, glyph2, v2)
1420
1421    # GPOS 3
1422    def add_cursive_pos(self, location, glyphclass, entryAnchor, exitAnchor):
1423        if not glyphclass:
1424            raise FeatureLibError("Empty glyph class in positioning rule", location)
1425        lookup = self.get_lookup_(location, CursivePosBuilder)
1426        lookup.add_attachment(
1427            location,
1428            glyphclass,
1429            self.makeOpenTypeAnchor(location, entryAnchor),
1430            self.makeOpenTypeAnchor(location, exitAnchor),
1431        )
1432
1433    # GPOS 4
1434    def add_mark_base_pos(self, location, bases, marks):
1435        builder = self.get_lookup_(location, MarkBasePosBuilder)
1436        self.add_marks_(location, builder, marks)
1437        if not bases:
1438            raise FeatureLibError("Empty glyph class in positioning rule", location)
1439        for baseAnchor, markClass in marks:
1440            otBaseAnchor = self.makeOpenTypeAnchor(location, baseAnchor)
1441            for base in bases:
1442                builder.bases.setdefault(base, {})[markClass.name] = otBaseAnchor
1443
1444    # GPOS 5
1445    def add_mark_lig_pos(self, location, ligatures, components):
1446        builder = self.get_lookup_(location, MarkLigPosBuilder)
1447        componentAnchors = []
1448        if not ligatures:
1449            raise FeatureLibError("Empty glyph class in positioning rule", location)
1450        for marks in components:
1451            anchors = {}
1452            self.add_marks_(location, builder, marks)
1453            for ligAnchor, markClass in marks:
1454                anchors[markClass.name] = self.makeOpenTypeAnchor(location, ligAnchor)
1455            componentAnchors.append(anchors)
1456        for glyph in ligatures:
1457            builder.ligatures[glyph] = componentAnchors
1458
1459    # GPOS 6
1460    def add_mark_mark_pos(self, location, baseMarks, marks):
1461        builder = self.get_lookup_(location, MarkMarkPosBuilder)
1462        self.add_marks_(location, builder, marks)
1463        if not baseMarks:
1464            raise FeatureLibError("Empty glyph class in positioning rule", location)
1465        for baseAnchor, markClass in marks:
1466            otBaseAnchor = self.makeOpenTypeAnchor(location, baseAnchor)
1467            for baseMark in baseMarks:
1468                builder.baseMarks.setdefault(baseMark, {})[
1469                    markClass.name
1470                ] = otBaseAnchor
1471
1472    # GPOS 7/8
1473    def add_chain_context_pos(self, location, prefix, glyphs, suffix, lookups):
1474        if not all(glyphs) or not all(prefix) or not all(suffix):
1475            raise FeatureLibError(
1476                "Empty glyph class in contextual positioning rule", location
1477            )
1478        lookup = self.get_lookup_(location, ChainContextPosBuilder)
1479        lookup.rules.append(
1480            ChainContextualRule(
1481                prefix, glyphs, suffix, self.find_lookup_builders_(lookups)
1482            )
1483        )
1484
1485    def add_single_pos_chained_(self, location, prefix, suffix, pos):
1486        if not pos or not all(prefix) or not all(suffix):
1487            raise FeatureLibError(
1488                "Empty glyph class in contextual positioning rule", location
1489            )
1490        # https://github.com/fonttools/fonttools/issues/514
1491        chain = self.get_lookup_(location, ChainContextPosBuilder)
1492        targets = []
1493        for _, _, _, lookups in chain.rules:
1494            targets.extend(lookups)
1495        subs = []
1496        for glyphs, value in pos:
1497            if value is None:
1498                subs.append(None)
1499                continue
1500            otValue = self.makeOpenTypeValueRecord(
1501                location, value, pairPosContext=False
1502            )
1503            sub = chain.find_chainable_single_pos(targets, glyphs, otValue)
1504            if sub is None:
1505                sub = self.get_chained_lookup_(location, SinglePosBuilder)
1506                targets.append(sub)
1507            for glyph in glyphs:
1508                sub.add_pos(location, glyph, otValue)
1509            subs.append(sub)
1510        assert len(pos) == len(subs), (pos, subs)
1511        chain.rules.append(
1512            ChainContextualRule(prefix, [g for g, v in pos], suffix, subs)
1513        )
1514
1515    def add_marks_(self, location, lookupBuilder, marks):
1516        """Helper for add_mark_{base,liga,mark}_pos."""
1517        for _, markClass in marks:
1518            for markClassDef in markClass.definitions:
1519                for mark in markClassDef.glyphs.glyphSet():
1520                    if mark not in lookupBuilder.marks:
1521                        otMarkAnchor = self.makeOpenTypeAnchor(
1522                            location, copy.deepcopy(markClassDef.anchor)
1523                        )
1524                        lookupBuilder.marks[mark] = (markClass.name, otMarkAnchor)
1525                    else:
1526                        existingMarkClass = lookupBuilder.marks[mark][0]
1527                        if markClass.name != existingMarkClass:
1528                            raise FeatureLibError(
1529                                "Glyph %s cannot be in both @%s and @%s"
1530                                % (mark, existingMarkClass, markClass.name),
1531                                location,
1532                            )
1533
1534    def add_subtable_break(self, location):
1535        self.cur_lookup_.add_subtable_break(location)
1536
1537    def setGlyphClass_(self, location, glyph, glyphClass):
1538        oldClass, oldLocation = self.glyphClassDefs_.get(glyph, (None, None))
1539        if oldClass and oldClass != glyphClass:
1540            raise FeatureLibError(
1541                "Glyph %s was assigned to a different class at %s"
1542                % (glyph, oldLocation),
1543                location,
1544            )
1545        self.glyphClassDefs_[glyph] = (glyphClass, location)
1546
1547    def add_glyphClassDef(
1548        self, location, baseGlyphs, ligatureGlyphs, markGlyphs, componentGlyphs
1549    ):
1550        for glyph in baseGlyphs:
1551            self.setGlyphClass_(location, glyph, 1)
1552        for glyph in ligatureGlyphs:
1553            self.setGlyphClass_(location, glyph, 2)
1554        for glyph in markGlyphs:
1555            self.setGlyphClass_(location, glyph, 3)
1556        for glyph in componentGlyphs:
1557            self.setGlyphClass_(location, glyph, 4)
1558
1559    def add_ligatureCaretByIndex_(self, location, glyphs, carets):
1560        for glyph in glyphs:
1561            if glyph not in self.ligCaretPoints_:
1562                self.ligCaretPoints_[glyph] = carets
1563
1564    def makeLigCaret(self, location, caret):
1565        if not isinstance(caret, VariableScalar):
1566            return caret
1567        default, device = self.makeVariablePos(location, caret)
1568        if device is not None:
1569            return (default, device)
1570        return default
1571
1572    def add_ligatureCaretByPos_(self, location, glyphs, carets):
1573        carets = [self.makeLigCaret(location, caret) for caret in carets]
1574        for glyph in glyphs:
1575            if glyph not in self.ligCaretCoords_:
1576                self.ligCaretCoords_[glyph] = carets
1577
1578    def add_name_record(self, location, nameID, platformID, platEncID, langID, string):
1579        self.names_.append([nameID, platformID, platEncID, langID, string])
1580
1581    def add_os2_field(self, key, value):
1582        self.os2_[key] = value
1583
1584    def add_hhea_field(self, key, value):
1585        self.hhea_[key] = value
1586
1587    def add_vhea_field(self, key, value):
1588        self.vhea_[key] = value
1589
1590    def add_conditionset(self, location, key, value):
1591        if "fvar" not in self.font:
1592            raise FeatureLibError(
1593                "Cannot add feature variations to a font without an 'fvar' table",
1594                location,
1595            )
1596
1597        # Normalize
1598        axisMap = {
1599            axis.axisTag: (axis.minValue, axis.defaultValue, axis.maxValue)
1600            for axis in self.axes
1601        }
1602
1603        value = {
1604            tag: (
1605                normalizeValue(bottom, axisMap[tag]),
1606                normalizeValue(top, axisMap[tag]),
1607            )
1608            for tag, (bottom, top) in value.items()
1609        }
1610
1611        # NOTE: This might result in rounding errors (off-by-ones) compared to
1612        # rules in Designspace files, since we're working with what's in the
1613        # `avar` table rather than the original values.
1614        if "avar" in self.font:
1615            mapping = self.font["avar"].segments
1616            value = {
1617                axis: tuple(
1618                    piecewiseLinearMap(v, mapping[axis]) if axis in mapping else v
1619                    for v in condition_range
1620                )
1621                for axis, condition_range in value.items()
1622            }
1623
1624        self.conditionsets_[key] = value
1625
1626    def makeVariablePos(self, location, varscalar):
1627        if not self.varstorebuilder:
1628            raise FeatureLibError(
1629                "Can't define a variable scalar in a non-variable font", location
1630            )
1631
1632        varscalar.axes = self.axes
1633        if not varscalar.does_vary:
1634            return varscalar.default, None
1635
1636        default, index = varscalar.add_to_variation_store(
1637            self.varstorebuilder, self.model_cache, self.font.get("avar")
1638        )
1639
1640        device = None
1641        if index is not None and index != 0xFFFFFFFF:
1642            device = buildVarDevTable(index)
1643
1644        return default, device
1645
1646    def makeOpenTypeAnchor(self, location, anchor):
1647        """ast.Anchor --> otTables.Anchor"""
1648        if anchor is None:
1649            return None
1650        variable = False
1651        deviceX, deviceY = None, None
1652        if anchor.xDeviceTable is not None:
1653            deviceX = otl.buildDevice(dict(anchor.xDeviceTable))
1654        if anchor.yDeviceTable is not None:
1655            deviceY = otl.buildDevice(dict(anchor.yDeviceTable))
1656        for dim in ("x", "y"):
1657            varscalar = getattr(anchor, dim)
1658            if not isinstance(varscalar, VariableScalar):
1659                continue
1660            if getattr(anchor, dim + "DeviceTable") is not None:
1661                raise FeatureLibError(
1662                    "Can't define a device coordinate and variable scalar", location
1663                )
1664            default, device = self.makeVariablePos(location, varscalar)
1665            setattr(anchor, dim, default)
1666            if device is not None:
1667                if dim == "x":
1668                    deviceX = device
1669                else:
1670                    deviceY = device
1671                variable = True
1672
1673        otlanchor = otl.buildAnchor(
1674            anchor.x, anchor.y, anchor.contourpoint, deviceX, deviceY
1675        )
1676        if variable:
1677            otlanchor.Format = 3
1678        return otlanchor
1679
1680    _VALUEREC_ATTRS = {
1681        name[0].lower() + name[1:]: (name, isDevice)
1682        for _, name, isDevice, _ in otBase.valueRecordFormat
1683        if not name.startswith("Reserved")
1684    }
1685
1686    def makeOpenTypeValueRecord(self, location, v, pairPosContext):
1687        """ast.ValueRecord --> otBase.ValueRecord"""
1688        if not v:
1689            return None
1690
1691        vr = {}
1692        for astName, (otName, isDevice) in self._VALUEREC_ATTRS.items():
1693            val = getattr(v, astName, None)
1694            if not val:
1695                continue
1696            if isDevice:
1697                vr[otName] = otl.buildDevice(dict(val))
1698            elif isinstance(val, VariableScalar):
1699                otDeviceName = otName[0:4] + "Device"
1700                feaDeviceName = otDeviceName[0].lower() + otDeviceName[1:]
1701                if getattr(v, feaDeviceName):
1702                    raise FeatureLibError(
1703                        "Can't define a device coordinate and variable scalar", location
1704                    )
1705                vr[otName], device = self.makeVariablePos(location, val)
1706                if device is not None:
1707                    vr[otDeviceName] = device
1708            else:
1709                vr[otName] = val
1710
1711        if pairPosContext and not vr:
1712            vr = {"YAdvance": 0} if v.vertical else {"XAdvance": 0}
1713        valRec = otl.buildValue(vr)
1714        return valRec
1715