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