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