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