xref: /aosp_15_r20/external/fonttools/Tests/feaLib/builder_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1from fontTools.misc.loggingTools import CapturingLogHandler
2from fontTools.feaLib.builder import (
3    Builder,
4    addOpenTypeFeatures,
5    addOpenTypeFeaturesFromString,
6)
7from fontTools.feaLib.error import FeatureLibError
8from fontTools.ttLib import TTFont, newTable
9from fontTools.feaLib.parser import Parser
10from fontTools.feaLib import ast
11from fontTools.feaLib.lexer import Lexer
12from fontTools.fontBuilder import addFvar
13import difflib
14from io import StringIO
15import os
16import re
17import shutil
18import sys
19import tempfile
20import logging
21import unittest
22import warnings
23
24
25def makeTTFont():
26    glyphs = """
27        .notdef space slash fraction semicolon period comma ampersand
28        quotedblleft quotedblright quoteleft quoteright
29        zero one two three four five six seven eight nine
30        zero.oldstyle one.oldstyle two.oldstyle three.oldstyle
31        four.oldstyle five.oldstyle six.oldstyle seven.oldstyle
32        eight.oldstyle nine.oldstyle onequarter onehalf threequarters
33        onesuperior twosuperior threesuperior ordfeminine ordmasculine
34        A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
35        a b c d e f g h i j k l m n o p q r s t u v w x y z
36        A.sc B.sc C.sc D.sc E.sc F.sc G.sc H.sc I.sc J.sc K.sc L.sc M.sc
37        N.sc O.sc P.sc Q.sc R.sc S.sc T.sc U.sc V.sc W.sc X.sc Y.sc Z.sc
38        A.alt1 A.alt2 A.alt3 B.alt1 B.alt2 B.alt3 C.alt1 C.alt2 C.alt3
39        a.alt1 a.alt2 a.alt3 a.end b.alt c.mid d.alt d.mid
40        e.begin e.mid e.end m.begin n.end s.end z.end
41        Eng Eng.alt1 Eng.alt2 Eng.alt3
42        A.swash B.swash C.swash D.swash E.swash F.swash G.swash H.swash
43        I.swash J.swash K.swash L.swash M.swash N.swash O.swash P.swash
44        Q.swash R.swash S.swash T.swash U.swash V.swash W.swash X.swash
45        Y.swash Z.swash
46        f_l c_h c_k c_s c_t f_f f_f_i f_f_l f_i o_f_f_i s_t f_i.begin
47        a_n_d T_h T_h.swash germandbls ydieresis yacute breve
48        grave acute dieresis macron circumflex cedilla umlaut ogonek caron
49        damma hamza sukun kasratan lam_meem_jeem noon.final noon.initial
50        by feature lookup sub table uni0327 uni0328 e.fina
51    """.split()
52    glyphs.extend("cid{:05d}".format(cid) for cid in range(800, 1001 + 1))
53    font = TTFont()
54    font.setGlyphOrder(glyphs)
55    return font
56
57
58class BuilderTest(unittest.TestCase):
59    # Feature files in data/*.fea; output gets compared to data/*.ttx.
60    TEST_FEATURE_FILES = """
61        Attach cid_range enum markClass language_required
62        GlyphClassDef LigatureCaretByIndex LigatureCaretByPos
63        lookup lookupflag feature_aalt ignore_pos
64        GPOS_1 GPOS_1_zero GPOS_2 GPOS_2b GPOS_3 GPOS_4 GPOS_5 GPOS_6 GPOS_8
65        GSUB_2 GSUB_3 GSUB_6 GSUB_8
66        spec4h1 spec4h2 spec5d1 spec5d2 spec5fi1 spec5fi2 spec5fi3 spec5fi4
67        spec5f_ii_1 spec5f_ii_2 spec5f_ii_3 spec5f_ii_4
68        spec5h1 spec6b_ii spec6d2 spec6e spec6f
69        spec6h_ii spec6h_iii_1 spec6h_iii_3d spec8a spec8b spec8c spec8d
70        spec9a spec9b spec9c1 spec9c2 spec9c3 spec9d spec9e spec9f spec9g
71        spec10
72        bug453 bug457 bug463 bug501 bug502 bug504 bug505 bug506 bug509
73        bug512 bug514 bug568 bug633 bug1307 bug1459 bug2276 variable_bug2772
74        name size size2 multiple_feature_blocks omitted_GlyphClassDef
75        ZeroValue_SinglePos_horizontal ZeroValue_SinglePos_vertical
76        ZeroValue_PairPos_horizontal ZeroValue_PairPos_vertical
77        ZeroValue_ChainSinglePos_horizontal ZeroValue_ChainSinglePos_vertical
78        PairPosSubtable ChainSubstSubtable SubstSubtable ChainPosSubtable
79        LigatureSubtable AlternateSubtable MultipleSubstSubtable
80        SingleSubstSubtable aalt_chain_contextual_subst AlternateChained
81        MultipleLookupsPerGlyph MultipleLookupsPerGlyph2 GSUB_6_formats
82        GSUB_5_formats delete_glyph STAT_test STAT_test_elidedFallbackNameID
83        variable_scalar_valuerecord variable_scalar_anchor variable_conditionset
84        variable_mark_anchor
85    """.split()
86
87    VARFONT_AXES = [
88        ("wght", 200, 200, 1000, "Weight"),
89        ("wdth", 100, 100, 200, "Width"),
90    ]
91
92    def __init__(self, methodName):
93        unittest.TestCase.__init__(self, methodName)
94        # Python 3 renamed assertRaisesRegexp to assertRaisesRegex,
95        # and fires deprecation warnings if a program uses the old name.
96        if not hasattr(self, "assertRaisesRegex"):
97            self.assertRaisesRegex = self.assertRaisesRegexp
98
99    def setUp(self):
100        self.tempdir = None
101        self.num_tempfiles = 0
102
103    def tearDown(self):
104        if self.tempdir:
105            shutil.rmtree(self.tempdir)
106
107    @staticmethod
108    def getpath(testfile):
109        path, _ = os.path.split(__file__)
110        return os.path.join(path, "data", testfile)
111
112    def temp_path(self, suffix):
113        if not self.tempdir:
114            self.tempdir = tempfile.mkdtemp()
115        self.num_tempfiles += 1
116        return os.path.join(self.tempdir, "tmp%d%s" % (self.num_tempfiles, suffix))
117
118    def read_ttx(self, path):
119        lines = []
120        with open(path, "r", encoding="utf-8") as ttx:
121            for line in ttx.readlines():
122                # Elide ttFont attributes because ttLibVersion may change.
123                if line.startswith("<ttFont "):
124                    lines.append("<ttFont>\n")
125                else:
126                    lines.append(line.rstrip() + "\n")
127        return lines
128
129    def expect_ttx(self, font, expected_ttx, replace=None):
130        path = self.temp_path(suffix=".ttx")
131        font.saveXML(
132            path,
133            tables=[
134                "head",
135                "name",
136                "BASE",
137                "GDEF",
138                "GSUB",
139                "GPOS",
140                "OS/2",
141                "STAT",
142                "hhea",
143                "vhea",
144            ],
145        )
146        actual = self.read_ttx(path)
147        expected = self.read_ttx(expected_ttx)
148        if replace:
149            for i in range(len(expected)):
150                for k, v in replace.items():
151                    expected[i] = expected[i].replace(k, v)
152        if actual != expected:
153            for line in difflib.unified_diff(
154                expected, actual, fromfile=expected_ttx, tofile=path
155            ):
156                sys.stderr.write(line)
157            self.fail("TTX output is different from expected")
158
159    def build(self, featureFile, tables=None):
160        font = makeTTFont()
161        addOpenTypeFeaturesFromString(font, featureFile, tables=tables)
162        return font
163
164    def check_feature_file(self, name):
165        font = makeTTFont()
166        if name.startswith("variable_"):
167            font["name"] = newTable("name")
168            addFvar(font, self.VARFONT_AXES, [])
169            del font["name"]
170        feapath = self.getpath("%s.fea" % name)
171        addOpenTypeFeatures(font, feapath)
172        self.expect_ttx(font, self.getpath("%s.ttx" % name))
173        # Check that:
174        # 1) tables do compile (only G* tables as long as we have a mock font)
175        # 2) dumping after save-reload yields the same TTX dump as before
176        for tag in ("GDEF", "GSUB", "GPOS"):
177            if tag in font:
178                data = font[tag].compile(font)
179                font[tag].decompile(data, font)
180        self.expect_ttx(font, self.getpath("%s.ttx" % name))
181        # Optionally check a debug dump.
182        debugttx = self.getpath("%s-debug.ttx" % name)
183        if os.path.exists(debugttx):
184            addOpenTypeFeatures(font, feapath, debug=True)
185            self.expect_ttx(font, debugttx, replace={"__PATH__": feapath})
186
187    def check_fea2fea_file(self, name, base=None, parser=Parser):
188        font = makeTTFont()
189        fname = (name + ".fea") if "." not in name else name
190        p = parser(self.getpath(fname), glyphNames=font.getGlyphOrder())
191        doc = p.parse()
192        actual = self.normal_fea(doc.asFea().split("\n"))
193        with open(self.getpath(base or fname), "r", encoding="utf-8") as ofile:
194            expected = self.normal_fea(ofile.readlines())
195
196        if expected != actual:
197            fname = name.rsplit(".", 1)[0] + ".fea"
198            for line in difflib.unified_diff(
199                expected,
200                actual,
201                fromfile=fname + " (expected)",
202                tofile=fname + " (actual)",
203            ):
204                sys.stderr.write(line + "\n")
205            self.fail(
206                "Fea2Fea output is different from expected. "
207                "Generated:\n{}\n".format("\n".join(actual))
208            )
209
210    def normal_fea(self, lines):
211        output = []
212        skip = 0
213        for l in lines:
214            l = l.strip()
215            if l.startswith("#test-fea2fea:"):
216                if len(l) > 15:
217                    output.append(l[15:].strip())
218                skip = 1
219            x = l.find("#")
220            if x >= 0:
221                l = l[:x].strip()
222            if not len(l):
223                continue
224            if skip > 0:
225                skip = skip - 1
226                continue
227            output.append(l)
228        return output
229
230    def make_mock_vf(self):
231        font = makeTTFont()
232        font["name"] = newTable("name")
233        addFvar(font, self.VARFONT_AXES, [])
234        del font["name"]
235        return font
236
237    @staticmethod
238    def get_region(var_region_axis):
239        return (
240            var_region_axis.StartCoord,
241            var_region_axis.PeakCoord,
242            var_region_axis.EndCoord,
243        )
244
245    def test_alternateSubst_multipleSubstitutionsForSameGlyph(self):
246        self.assertRaisesRegex(
247            FeatureLibError,
248            'Already defined alternates for glyph "A"',
249            self.build,
250            "feature test {"
251            "    sub A from [A.alt1 A.alt2];"
252            "    sub B from [B.alt1 B.alt2 B.alt3];"
253            "    sub A from [A.alt1 A.alt2];"
254            "} test;",
255        )
256
257    def test_singleSubst_multipleIdenticalSubstitutionsForSameGlyph_info(self):
258        logger = logging.getLogger("fontTools.feaLib.builder")
259        with CapturingLogHandler(logger, "INFO") as captor:
260            self.build(
261                "feature test {"
262                "    sub A by A.sc;"
263                "    sub B by B.sc;"
264                "    sub A by A.sc;"
265                "} test;"
266            )
267        captor.assertRegex(
268            'Removing duplicate single substitution from glyph "A" to "A.sc"'
269        )
270
271    def test_multipleSubst_multipleSubstitutionsForSameGlyph(self):
272        self.assertRaisesRegex(
273            FeatureLibError,
274            'Already defined substitution for glyph "f_f_i"',
275            self.build,
276            "feature test {"
277            "    sub f_f_i by f f i;"
278            "    sub c_t by c t;"
279            "    sub f_f_i by f_f i;"
280            "} test;",
281        )
282
283    def test_multipleSubst_multipleIdenticalSubstitutionsForSameGlyph_info(self):
284        logger = logging.getLogger("fontTools.feaLib.builder")
285        with CapturingLogHandler(logger, "INFO") as captor:
286            self.build(
287                "feature test {"
288                "    sub f_f_i by f f i;"
289                "    sub c_t by c t;"
290                "    sub f_f_i by f f i;"
291                "} test;"
292            )
293        captor.assertRegex(
294            r"Removing duplicate multiple substitution from glyph \"f_f_i\" to \('f', 'f', 'i'\)"
295        )
296
297    def test_pairPos_redefinition_warning(self):
298        # https://github.com/fonttools/fonttools/issues/1147
299        logger = logging.getLogger("fontTools.otlLib.builder")
300        with CapturingLogHandler(logger, "DEBUG") as captor:
301            # the pair "yacute semicolon" is redefined in the enum pos
302            font = self.build(
303                "@Y_LC = [y yacute ydieresis];"
304                "@SMALL_PUNC = [comma semicolon period];"
305                "feature kern {"
306                "  pos yacute semicolon -70;"
307                "  enum pos @Y_LC semicolon -80;"
308                "  pos @Y_LC @SMALL_PUNC -100;"
309                "} kern;"
310            )
311
312        captor.assertRegex("Already defined position for pair yacute semicolon")
313
314        # the first definition prevails: yacute semicolon -70
315        st = font["GPOS"].table.LookupList.Lookup[0].SubTable[0]
316        self.assertEqual(st.Coverage.glyphs[2], "yacute")
317        self.assertEqual(st.PairSet[2].PairValueRecord[0].SecondGlyph, "semicolon")
318        self.assertEqual(
319            vars(st.PairSet[2].PairValueRecord[0].Value1), {"XAdvance": -70}
320        )
321
322    def test_singleSubst_multipleSubstitutionsForSameGlyph(self):
323        self.assertRaisesRegex(
324            FeatureLibError,
325            'Already defined rule for replacing glyph "e" by "E.sc"',
326            self.build,
327            "feature test {"
328            "    sub [a-z] by [A.sc-Z.sc];"
329            "    sub e by e.fina;"
330            "} test;",
331        )
332
333    def test_singlePos_redefinition(self):
334        self.assertRaisesRegex(
335            FeatureLibError,
336            'Already defined different position for glyph "A"',
337            self.build,
338            "feature test { pos A 123; pos A 456; } test;",
339        )
340
341    def test_feature_outside_aalt(self):
342        self.assertRaisesRegex(
343            FeatureLibError,
344            'Feature references are only allowed inside "feature aalt"',
345            self.build,
346            "feature test { feature test; } test;",
347        )
348
349    def test_feature_undefinedReference(self):
350        with warnings.catch_warnings(record=True) as w:
351            self.build("feature aalt { feature none; } aalt;")
352            assert len(w) == 1
353            assert "Feature none has not been defined" in str(w[0].message)
354
355    def test_GlyphClassDef_conflictingClasses(self):
356        self.assertRaisesRegex(
357            FeatureLibError,
358            "Glyph X was assigned to a different class",
359            self.build,
360            "table GDEF {"
361            "    GlyphClassDef [a b], [X], , ;"
362            "    GlyphClassDef [a b X], , , ;"
363            "} GDEF;",
364        )
365
366    def test_languagesystem(self):
367        builder = Builder(makeTTFont(), (None, None))
368        builder.add_language_system(None, "latn", "FRA")
369        builder.add_language_system(None, "cyrl", "RUS")
370        builder.start_feature(location=None, name="test")
371        self.assertEqual(builder.language_systems, {("latn", "FRA"), ("cyrl", "RUS")})
372
373    def test_languagesystem_duplicate(self):
374        self.assertRaisesRegex(
375            FeatureLibError,
376            '"languagesystem cyrl RUS" has already been specified',
377            self.build,
378            "languagesystem cyrl RUS; languagesystem cyrl RUS;",
379        )
380
381    def test_languagesystem_none_specified(self):
382        builder = Builder(makeTTFont(), (None, None))
383        builder.start_feature(location=None, name="test")
384        self.assertEqual(builder.language_systems, {("DFLT", "dflt")})
385
386    def test_languagesystem_DFLT_dflt_not_first(self):
387        self.assertRaisesRegex(
388            FeatureLibError,
389            'If "languagesystem DFLT dflt" is present, '
390            "it must be the first of the languagesystem statements",
391            self.build,
392            "languagesystem latn TRK; languagesystem DFLT dflt;",
393        )
394
395    def test_languagesystem_DFLT_not_preceding(self):
396        self.assertRaisesRegex(
397            FeatureLibError,
398            'languagesystems using the "DFLT" script tag must '
399            "precede all other languagesystems",
400            self.build,
401            "languagesystem DFLT dflt; "
402            "languagesystem latn dflt; "
403            "languagesystem DFLT fooo; ",
404        )
405
406    def test_script(self):
407        builder = Builder(makeTTFont(), (None, None))
408        builder.start_feature(location=None, name="test")
409        builder.set_script(location=None, script="cyrl")
410        self.assertEqual(builder.language_systems, {("cyrl", "dflt")})
411
412    def test_script_in_aalt_feature(self):
413        self.assertRaisesRegex(
414            FeatureLibError,
415            'Script statements are not allowed within "feature aalt"',
416            self.build,
417            "feature aalt { script latn; } aalt;",
418        )
419
420    def test_script_in_size_feature(self):
421        self.assertRaisesRegex(
422            FeatureLibError,
423            'Script statements are not allowed within "feature size"',
424            self.build,
425            "feature size { script latn; } size;",
426        )
427
428    def test_script_in_standalone_lookup(self):
429        self.assertRaisesRegex(
430            FeatureLibError,
431            "Script statements are not allowed within standalone lookup blocks",
432            self.build,
433            "lookup test { script latn; } test;",
434        )
435
436    def test_language(self):
437        builder = Builder(makeTTFont(), (None, None))
438        builder.add_language_system(None, "latn", "FRA ")
439        builder.start_feature(location=None, name="test")
440        builder.set_script(location=None, script="cyrl")
441        builder.set_language(
442            location=None, language="RUS ", include_default=False, required=False
443        )
444        self.assertEqual(builder.language_systems, {("cyrl", "RUS ")})
445        builder.set_language(
446            location=None, language="BGR ", include_default=True, required=False
447        )
448        self.assertEqual(builder.language_systems, {("cyrl", "BGR ")})
449        builder.start_feature(location=None, name="test2")
450        self.assertEqual(builder.language_systems, {("latn", "FRA ")})
451
452    def test_language_in_aalt_feature(self):
453        self.assertRaisesRegex(
454            FeatureLibError,
455            'Language statements are not allowed within "feature aalt"',
456            self.build,
457            "feature aalt { language FRA; } aalt;",
458        )
459
460    def test_language_in_size_feature(self):
461        self.assertRaisesRegex(
462            FeatureLibError,
463            'Language statements are not allowed within "feature size"',
464            self.build,
465            "feature size { language FRA; } size;",
466        )
467
468    def test_language_in_standalone_lookup(self):
469        self.assertRaisesRegex(
470            FeatureLibError,
471            "Language statements are not allowed within standalone lookup blocks",
472            self.build,
473            "lookup test { language FRA; } test;",
474        )
475
476    def test_language_required_duplicate(self):
477        self.assertRaisesRegex(
478            FeatureLibError,
479            r"Language FRA \(script latn\) has already specified "
480            "feature scmp as its required feature",
481            self.build,
482            "feature scmp {"
483            "    script latn;"
484            "    language FRA required;"
485            "    language DEU required;"
486            "    substitute [a-z] by [A.sc-Z.sc];"
487            "} scmp;"
488            "feature test {"
489            "    script latn;"
490            "    language FRA required;"
491            "    substitute [a-z] by [A.sc-Z.sc];"
492            "} test;",
493        )
494
495    def test_lookup_already_defined(self):
496        self.assertRaisesRegex(
497            FeatureLibError,
498            'Lookup "foo" has already been defined',
499            self.build,
500            "lookup foo {} foo; lookup foo {} foo;",
501        )
502
503    def test_lookup_multiple_flags(self):
504        self.assertRaisesRegex(
505            FeatureLibError,
506            "Within a named lookup block, all rules must be "
507            "of the same lookup type and flag",
508            self.build,
509            "lookup foo {"
510            "    lookupflag 1;"
511            "    sub f i by f_i;"
512            "    lookupflag 2;"
513            "    sub f f i by f_f_i;"
514            "} foo;",
515        )
516
517    def test_lookup_multiple_types(self):
518        self.assertRaisesRegex(
519            FeatureLibError,
520            "Within a named lookup block, all rules must be "
521            "of the same lookup type and flag",
522            self.build,
523            "lookup foo {"
524            "    sub f f i by f_f_i;"
525            "    sub A from [A.alt1 A.alt2];"
526            "} foo;",
527        )
528
529    def test_lookup_inside_feature_aalt(self):
530        self.assertRaisesRegex(
531            FeatureLibError,
532            "Lookup blocks cannot be placed inside 'aalt' features",
533            self.build,
534            "feature aalt {lookup L {} L;} aalt;",
535        )
536
537    def test_chain_subst_refrences_GPOS_looup(self):
538        self.assertRaisesRegex(
539            FeatureLibError,
540            "Missing index of the specified lookup, might be a positioning lookup",
541            self.build,
542            "lookup dummy { pos a 50; } dummy;"
543            "feature test {"
544            "    sub a' lookup dummy b;"
545            "} test;",
546        )
547
548    def test_chain_pos_refrences_GSUB_looup(self):
549        self.assertRaisesRegex(
550            FeatureLibError,
551            "Missing index of the specified lookup, might be a substitution lookup",
552            self.build,
553            "lookup dummy { sub a by A; } dummy;"
554            "feature test {"
555            "    pos a' lookup dummy b;"
556            "} test;",
557        )
558
559    def test_STAT_elidedfallbackname_already_defined(self):
560        self.assertRaisesRegex(
561            FeatureLibError,
562            "ElidedFallbackName is already set.",
563            self.build,
564            "table name {"
565            '   nameid 256 "Roman"; '
566            "} name;"
567            "table STAT {"
568            '    ElidedFallbackName { name "Roman"; };'
569            "    ElidedFallbackNameID 256;"
570            "} STAT;",
571        )
572
573    def test_STAT_elidedfallbackname_set_twice(self):
574        self.assertRaisesRegex(
575            FeatureLibError,
576            "ElidedFallbackName is already set.",
577            self.build,
578            "table name {"
579            '   nameid 256 "Roman"; '
580            "} name;"
581            "table STAT {"
582            '    ElidedFallbackName { name "Roman"; };'
583            '    ElidedFallbackName { name "Italic"; };'
584            "} STAT;",
585        )
586
587    def test_STAT_elidedfallbacknameID_already_defined(self):
588        self.assertRaisesRegex(
589            FeatureLibError,
590            "ElidedFallbackNameID is already set.",
591            self.build,
592            "table name {"
593            '   nameid 256 "Roman"; '
594            "} name;"
595            "table STAT {"
596            "    ElidedFallbackNameID 256;"
597            '    ElidedFallbackName { name "Roman"; };'
598            "} STAT;",
599        )
600
601    def test_STAT_elidedfallbacknameID_not_in_name_table(self):
602        self.assertRaisesRegex(
603            FeatureLibError,
604            "ElidedFallbackNameID 256 points to a nameID that does not "
605            'exist in the "name" table',
606            self.build,
607            "table name {"
608            '   nameid 257 "Roman"; '
609            "} name;"
610            "table STAT {"
611            "    ElidedFallbackNameID 256;"
612            '    DesignAxis opsz 1 { name "Optical Size"; };'
613            "} STAT;",
614        )
615
616    def test_STAT_design_axis_name(self):
617        self.assertRaisesRegex(
618            FeatureLibError,
619            'Expected "name"',
620            self.build,
621            "table name {"
622            '   nameid 256 "Roman"; '
623            "} name;"
624            "table STAT {"
625            '    ElidedFallbackName { name "Roman"; };'
626            '    DesignAxis opsz 0 { badtag "Optical Size"; };'
627            "} STAT;",
628        )
629
630    def test_STAT_duplicate_design_axis_name(self):
631        self.assertRaisesRegex(
632            FeatureLibError,
633            'DesignAxis already defined for tag "opsz".',
634            self.build,
635            "table name {"
636            '   nameid 256 "Roman"; '
637            "} name;"
638            "table STAT {"
639            '    ElidedFallbackName { name "Roman"; };'
640            '    DesignAxis opsz 0 { name "Optical Size"; };'
641            '    DesignAxis opsz 1 { name "Optical Size"; };'
642            "} STAT;",
643        )
644
645    def test_STAT_design_axis_duplicate_order(self):
646        self.assertRaisesRegex(
647            FeatureLibError,
648            "DesignAxis already defined for axis number 0.",
649            self.build,
650            "table name {"
651            '   nameid 256 "Roman"; '
652            "} name;"
653            "table STAT {"
654            '    ElidedFallbackName { name "Roman"; };'
655            '    DesignAxis opsz 0 { name "Optical Size"; };'
656            '    DesignAxis wdth 0 { name "Width"; };'
657            "    AxisValue {"
658            "         location opsz 8;"
659            "         location wdth 400;"
660            '         name "Caption";'
661            "     };"
662            "} STAT;",
663        )
664
665    def test_STAT_undefined_tag(self):
666        self.assertRaisesRegex(
667            FeatureLibError,
668            "DesignAxis not defined for wdth.",
669            self.build,
670            "table name {"
671            '   nameid 256 "Roman"; '
672            "} name;"
673            "table STAT {"
674            '    ElidedFallbackName { name "Roman"; };'
675            '    DesignAxis opsz 0 { name "Optical Size"; };'
676            "    AxisValue { "
677            "        location wdth 125; "
678            '        name "Wide"; '
679            "    };"
680            "} STAT;",
681        )
682
683    def test_STAT_axis_value_format4(self):
684        self.assertRaisesRegex(
685            FeatureLibError,
686            "Axis tag wdth already defined.",
687            self.build,
688            "table name {"
689            '   nameid 256 "Roman"; '
690            "} name;"
691            "table STAT {"
692            '    ElidedFallbackName { name "Roman"; };'
693            '    DesignAxis opsz 0 { name "Optical Size"; };'
694            '    DesignAxis wdth 1 { name "Width"; };'
695            '    DesignAxis wght 2 { name "Weight"; };'
696            "    AxisValue { "
697            "        location opsz 8; "
698            "        location wdth 125; "
699            "        location wdth 125; "
700            "        location wght 500; "
701            '        name "Caption Medium Wide"; '
702            "    };"
703            "} STAT;",
704        )
705
706    def test_STAT_duplicate_axis_value_record(self):
707        # Test for Duplicate AxisValueRecords even when the definition order
708        # is different.
709        self.assertRaisesRegex(
710            FeatureLibError,
711            "An AxisValueRecord with these values is already defined.",
712            self.build,
713            "table name {"
714            '   nameid 256 "Roman"; '
715            "} name;"
716            "table STAT {"
717            '    ElidedFallbackName { name "Roman"; };'
718            '    DesignAxis opsz 0 { name "Optical Size"; };'
719            '    DesignAxis wdth 1 { name "Width"; };'
720            "    AxisValue {"
721            "         location opsz 8;"
722            "         location wdth 400;"
723            '         name "Caption";'
724            "     };"
725            "    AxisValue {"
726            "         location wdth 400;"
727            "         location opsz 8;"
728            '         name "Caption";'
729            "     };"
730            "} STAT;",
731        )
732
733    def test_STAT_axis_value_missing_location(self):
734        self.assertRaisesRegex(
735            FeatureLibError,
736            'Expected "Axis location"',
737            self.build,
738            "table name {"
739            '   nameid 256 "Roman"; '
740            "} name;"
741            "table STAT {"
742            '    ElidedFallbackName {   name "Roman"; '
743            "};"
744            '    DesignAxis opsz 0 { name "Optical Size"; };'
745            "    AxisValue { "
746            '        name "Wide"; '
747            "    };"
748            "} STAT;",
749        )
750
751    def test_STAT_invalid_location_tag(self):
752        self.assertRaisesRegex(
753            FeatureLibError,
754            "Tags cannot be longer than 4 characters",
755            self.build,
756            "table name {"
757            '   nameid 256 "Roman"; '
758            "} name;"
759            "table STAT {"
760            '    ElidedFallbackName { name "Roman"; '
761            '                         name 3 1 0x0411 "ローマン"; }; '
762            '    DesignAxis width 0 { name "Width"; };'
763            "} STAT;",
764        )
765
766    def test_extensions(self):
767        class ast_BaseClass(ast.MarkClass):
768            def asFea(self, indent=""):
769                return ""
770
771        class ast_BaseClassDefinition(ast.MarkClassDefinition):
772            def asFea(self, indent=""):
773                return ""
774
775        class ast_MarkBasePosStatement(ast.MarkBasePosStatement):
776            def asFea(self, indent=""):
777                if isinstance(self.base, ast.MarkClassName):
778                    res = ""
779                    for bcd in self.base.markClass.definitions:
780                        if res != "":
781                            res += "\n{}".format(indent)
782                        res += "pos base {} {}".format(
783                            bcd.glyphs.asFea(), bcd.anchor.asFea()
784                        )
785                        for m in self.marks:
786                            res += " mark @{}".format(m.name)
787                        res += ";"
788                else:
789                    res = "pos base {}".format(self.base.asFea())
790                    for a, m in self.marks:
791                        res += " {} mark @{}".format(a.asFea(), m.name)
792                    res += ";"
793                return res
794
795        class testAst(object):
796            MarkBasePosStatement = ast_MarkBasePosStatement
797
798            def __getattr__(self, name):
799                return getattr(ast, name)
800
801        class testParser(Parser):
802            def parse_position_base_(self, enumerated, vertical):
803                location = self.cur_token_location_
804                self.expect_keyword_("base")
805                if enumerated:
806                    raise FeatureLibError(
807                        '"enumerate" is not allowed with '
808                        "mark-to-base attachment positioning",
809                        location,
810                    )
811                base = self.parse_glyphclass_(accept_glyphname=True)
812                if self.next_token_ == "<":
813                    marks = self.parse_anchor_marks_()
814                else:
815                    marks = []
816                    while self.next_token_ == "mark":
817                        self.expect_keyword_("mark")
818                        m = self.expect_markClass_reference_()
819                        marks.append(m)
820                self.expect_symbol_(";")
821                return self.ast.MarkBasePosStatement(base, marks, location=location)
822
823            def parseBaseClass(self):
824                if not hasattr(self.doc_, "baseClasses"):
825                    self.doc_.baseClasses = {}
826                location = self.cur_token_location_
827                glyphs = self.parse_glyphclass_(accept_glyphname=True)
828                anchor = self.parse_anchor_()
829                name = self.expect_class_name_()
830                self.expect_symbol_(";")
831                baseClass = self.doc_.baseClasses.get(name)
832                if baseClass is None:
833                    baseClass = ast_BaseClass(name)
834                    self.doc_.baseClasses[name] = baseClass
835                    self.glyphclasses_.define(name, baseClass)
836                bcdef = ast_BaseClassDefinition(
837                    baseClass, anchor, glyphs, location=location
838                )
839                baseClass.addDefinition(bcdef)
840                return bcdef
841
842            extensions = {"baseClass": lambda s: s.parseBaseClass()}
843            ast = testAst()
844
845        self.check_fea2fea_file(
846            "baseClass.feax", base="baseClass.fea", parser=testParser
847        )
848
849    def test_markClass_same_glyph_redefined(self):
850        self.assertRaisesRegex(
851            FeatureLibError,
852            "Glyph acute already defined",
853            self.build,
854            "markClass [acute] <anchor 350 0> @TOP_MARKS;" * 2,
855        )
856
857    def test_markClass_same_glyph_multiple_classes(self):
858        self.assertRaisesRegex(
859            FeatureLibError,
860            "Glyph uni0327 cannot be in both @ogonek and @cedilla",
861            self.build,
862            "feature mark {"
863            "    markClass [uni0327 uni0328] <anchor 0 0> @ogonek;"
864            "    pos base [a] <anchor 399 0> mark @ogonek;"
865            "    markClass [uni0327] <anchor 0 0> @cedilla;"
866            "    pos base [a] <anchor 244 0> mark @cedilla;"
867            "} mark;",
868        )
869
870    def test_build_specific_tables(self):
871        features = "feature liga {sub f i by f_i;} liga;"
872        font = self.build(features)
873        assert "GSUB" in font
874
875        font2 = self.build(features, tables=set())
876        assert "GSUB" not in font2
877
878    def test_build_unsupported_tables(self):
879        self.assertRaises(NotImplementedError, self.build, "", tables={"FOO"})
880
881    def test_build_pre_parsed_ast_featurefile(self):
882        f = StringIO("feature liga {sub f i by f_i;} liga;")
883        tree = Parser(f).parse()
884        font = makeTTFont()
885        addOpenTypeFeatures(font, tree)
886        assert "GSUB" in font
887
888    def test_unsupported_subtable_break(self):
889        logger = logging.getLogger("fontTools.otlLib.builder")
890        with CapturingLogHandler(logger, level="WARNING") as captor:
891            self.build(
892                "feature test {"
893                "    pos a 10;"
894                "    subtable;"
895                "    pos b 10;"
896                "} test;"
897            )
898
899        captor.assertRegex(
900            '<features>:1:32: unsupported "subtable" statement for lookup type'
901        )
902
903    def test_skip_featureNames_if_no_name_table(self):
904        features = (
905            "feature ss01 {"
906            "    featureNames {"
907            '        name "ignored as we request to skip name table";'
908            "    };"
909            "    sub A by A.alt1;"
910            "} ss01;"
911        )
912        font = self.build(features, tables=["GSUB"])
913        self.assertIn("GSUB", font)
914        self.assertNotIn("name", font)
915
916    def test_singlePos_multiplePositionsForSameGlyph(self):
917        self.assertRaisesRegex(
918            FeatureLibError,
919            "Already defined different position for glyph",
920            self.build,
921            "lookup foo {" "    pos A -45; " "    pos A 45; " "} foo;",
922        )
923
924    def test_pairPos_enumRuleOverridenBySinglePair_DEBUG(self):
925        logger = logging.getLogger("fontTools.otlLib.builder")
926        with CapturingLogHandler(logger, "DEBUG") as captor:
927            self.build(
928                "feature test {"
929                "    enum pos A [V Y] -80;"
930                "    pos A V -75;"
931                "} test;"
932            )
933        captor.assertRegex("Already defined position for pair A V at")
934
935    def test_ignore_empty_lookup_block(self):
936        # https://github.com/fonttools/fonttools/pull/2277
937        font = self.build(
938            "lookup EMPTY { ; } EMPTY;" "feature ss01 { lookup EMPTY; } ss01;"
939        )
940        assert "GPOS" not in font
941        assert "GSUB" not in font
942
943    def test_disable_empty_classes(self):
944        for test in [
945            "sub a by c []",
946            "sub f f [] by f",
947            "ignore sub a []'",
948            "ignore sub [] a'",
949            "sub a []' by b",
950            "sub [] a' by b",
951            "rsub [] by a",
952            "pos [] 120",
953            "pos a [] 120",
954            "enum pos a [] 120",
955            "pos cursive [] <anchor NULL> <anchor NULL>",
956            "pos base [] <anchor NULL> mark @TOPMARKS",
957            "pos ligature [] <anchor NULL> mark @TOPMARKS",
958            "pos mark [] <anchor NULL> mark @TOPMARKS",
959            "ignore pos a []'",
960            "ignore pos [] a'",
961        ]:
962            self.assertRaisesRegex(
963                FeatureLibError,
964                "Empty ",
965                self.build,
966                f"markClass a <anchor 150 -10> @TOPMARKS; lookup foo {{ {test}; }} foo;",
967            )
968        self.assertRaisesRegex(
969            FeatureLibError,
970            "Empty glyph class in mark class definition",
971            self.build,
972            "markClass [] <anchor 150 -10> @TOPMARKS;",
973        )
974        self.assertRaisesRegex(
975            FeatureLibError,
976            'Expected a glyph class with 1 elements after "by", but found a glyph class with 0 elements',
977            self.build,
978            "feature test { sub a by []; test};",
979        )
980
981    def test_unmarked_ignore_statement(self):
982        name = "bug2949"
983        logger = logging.getLogger("fontTools.feaLib.parser")
984        with CapturingLogHandler(logger, level="WARNING") as captor:
985            self.check_feature_file(name)
986        self.check_fea2fea_file(name)
987
988        for line, sub in {(3, "sub"), (8, "pos"), (13, "sub")}:
989            captor.assertRegex(
990                f'{name}.fea:{line}:12: Ambiguous "ignore {sub}", there should be least one marked glyph'
991            )
992
993    def test_conditionset_multiple_features(self):
994        """Test that using the same `conditionset` for multiple features reuses the
995        `FeatureVariationRecord`."""
996
997        features = """
998            languagesystem DFLT dflt;
999
1000            conditionset test {
1001                wght 600 1000;
1002                wdth 150 200;
1003            } test;
1004
1005            variation ccmp test {
1006                sub e by a;
1007            } ccmp;
1008
1009            variation rlig test {
1010                sub b by c;
1011            } rlig;
1012        """
1013
1014        def make_mock_vf():
1015            font = makeTTFont()
1016            font["name"] = newTable("name")
1017            addFvar(
1018                font,
1019                [("wght", 0, 0, 1000, "Weight"), ("wdth", 100, 100, 200, "Width")],
1020                [],
1021            )
1022            del font["name"]
1023            return font
1024
1025        font = make_mock_vf()
1026        addOpenTypeFeaturesFromString(font, features)
1027
1028        table = font["GSUB"].table
1029        assert table.FeatureVariations.FeatureVariationCount == 1
1030
1031        fvr = table.FeatureVariations.FeatureVariationRecord[0]
1032        assert fvr.FeatureTableSubstitution.SubstitutionCount == 2
1033
1034    def test_condition_set_avar(self):
1035        """Test that the `avar` table is consulted when normalizing user-space
1036        values."""
1037
1038        features = """
1039            languagesystem DFLT dflt;
1040
1041            lookup conditional_sub {
1042                sub e by a;
1043            } conditional_sub;
1044
1045            conditionset test {
1046                wght 600 1000;
1047                wdth 150 200;
1048            } test;
1049
1050            variation rlig test {
1051                lookup conditional_sub;
1052            } rlig;
1053        """
1054
1055        def make_mock_vf():
1056            font = makeTTFont()
1057            font["name"] = newTable("name")
1058            addFvar(
1059                font,
1060                [("wght", 0, 0, 1000, "Weight"), ("wdth", 100, 100, 200, "Width")],
1061                [],
1062            )
1063            del font["name"]
1064            return font
1065
1066        # Without `avar`:
1067        font = make_mock_vf()
1068        addOpenTypeFeaturesFromString(font, features)
1069        condition_table = (
1070            font.tables["GSUB"]
1071            .table.FeatureVariations.FeatureVariationRecord[0]
1072            .ConditionSet.ConditionTable
1073        )
1074        # user-space wdth=150 and wght=600:
1075        assert condition_table[0].FilterRangeMinValue == 0.5
1076        assert condition_table[1].FilterRangeMinValue == 0.6
1077
1078        # With `avar`, shifting the wght axis' positive midpoint 0.5 a bit to
1079        # the right, but leaving the wdth axis alone:
1080        font = make_mock_vf()
1081        font["avar"] = newTable("avar")
1082        font["avar"].segments = {"wght": {-1.0: -1.0, 0.0: 0.0, 0.5: 0.625, 1.0: 1.0}}
1083        addOpenTypeFeaturesFromString(font, features)
1084        condition_table = (
1085            font.tables["GSUB"]
1086            .table.FeatureVariations.FeatureVariationRecord[0]
1087            .ConditionSet.ConditionTable
1088        )
1089        # user-space wdth=150 as before and wght=600 shifted to the right:
1090        assert condition_table[0].FilterRangeMinValue == 0.5
1091        assert condition_table[1].FilterRangeMinValue == 0.7
1092
1093    def test_variable_scalar_avar(self):
1094        """Test that the `avar` table is consulted when normalizing user-space
1095        values."""
1096
1097        features = """
1098            languagesystem DFLT dflt;
1099
1100            feature kern {
1101                pos cursive one <anchor 0 (wght=200:12 wght=900:22 wdth=150,wght=900:42)> <anchor NULL>;
1102                pos two <0 (wght=200:12 wght=900:22 wdth=150,wght=900:42) 0 0>;
1103            } kern;
1104        """
1105
1106        # Without `avar` (wght=200, wdth=100 is the default location):
1107        font = self.make_mock_vf()
1108        addOpenTypeFeaturesFromString(font, features)
1109
1110        var_region_list = font.tables["GDEF"].table.VarStore.VarRegionList
1111        var_region_axis_wght = var_region_list.Region[0].VarRegionAxis[0]
1112        var_region_axis_wdth = var_region_list.Region[0].VarRegionAxis[1]
1113        assert self.get_region(var_region_axis_wght) == (0.0, 0.875, 0.875)
1114        assert self.get_region(var_region_axis_wdth) == (0.0, 0.0, 0.0)
1115        var_region_axis_wght = var_region_list.Region[1].VarRegionAxis[0]
1116        var_region_axis_wdth = var_region_list.Region[1].VarRegionAxis[1]
1117        assert self.get_region(var_region_axis_wght) == (0.0, 0.875, 0.875)
1118        assert self.get_region(var_region_axis_wdth) == (0.0, 0.5, 0.5)
1119
1120        # With `avar`, shifting the wght axis' positive midpoint 0.5 a bit to
1121        # the right, but leaving the wdth axis alone:
1122        font = self.make_mock_vf()
1123        font["avar"] = newTable("avar")
1124        font["avar"].segments = {"wght": {-1.0: -1.0, 0.0: 0.0, 0.5: 0.625, 1.0: 1.0}}
1125        addOpenTypeFeaturesFromString(font, features)
1126
1127        var_region_list = font.tables["GDEF"].table.VarStore.VarRegionList
1128        var_region_axis_wght = var_region_list.Region[0].VarRegionAxis[0]
1129        var_region_axis_wdth = var_region_list.Region[0].VarRegionAxis[1]
1130        assert self.get_region(var_region_axis_wght) == (0.0, 0.90625, 0.90625)
1131        assert self.get_region(var_region_axis_wdth) == (0.0, 0.0, 0.0)
1132        var_region_axis_wght = var_region_list.Region[1].VarRegionAxis[0]
1133        var_region_axis_wdth = var_region_list.Region[1].VarRegionAxis[1]
1134        assert self.get_region(var_region_axis_wght) == (0.0, 0.90625, 0.90625)
1135        assert self.get_region(var_region_axis_wdth) == (0.0, 0.5, 0.5)
1136
1137    def test_ligatureCaretByPos_variable_scalar(self):
1138        """Test that the `avar` table is consulted when normalizing user-space
1139        values."""
1140
1141        features = """
1142            table GDEF {
1143                LigatureCaretByPos f_i (wght=200:400 wght=900:1000) 380;
1144            } GDEF;
1145        """
1146
1147        font = self.make_mock_vf()
1148        addOpenTypeFeaturesFromString(font, features)
1149
1150        table = font["GDEF"].table
1151        lig_glyph = table.LigCaretList.LigGlyph[0]
1152        assert lig_glyph.CaretValue[0].Format == 1
1153        assert lig_glyph.CaretValue[0].Coordinate == 380
1154        assert lig_glyph.CaretValue[1].Format == 3
1155        assert lig_glyph.CaretValue[1].Coordinate == 400
1156
1157        var_region_list = table.VarStore.VarRegionList
1158        var_region_axis = var_region_list.Region[0].VarRegionAxis[0]
1159        assert self.get_region(var_region_axis) == (0.0, 0.875, 0.875)
1160
1161
1162def generate_feature_file_test(name):
1163    return lambda self: self.check_feature_file(name)
1164
1165
1166for name in BuilderTest.TEST_FEATURE_FILES:
1167    setattr(BuilderTest, "test_FeatureFile_%s" % name, generate_feature_file_test(name))
1168
1169
1170def generate_fea2fea_file_test(name):
1171    return lambda self: self.check_fea2fea_file(name)
1172
1173
1174for name in BuilderTest.TEST_FEATURE_FILES:
1175    setattr(
1176        BuilderTest,
1177        "test_Fea2feaFile_{}".format(name),
1178        generate_fea2fea_file_test(name),
1179    )
1180
1181
1182if __name__ == "__main__":
1183    sys.exit(unittest.main())
1184