1from fontTools.colorLib.builder import buildCOLR 2from fontTools.ttLib import TTFont, newTable 3from fontTools.ttLib.tables import otTables as ot 4from fontTools.varLib import ( 5 build, 6 build_many, 7 load_designspace, 8 _add_COLR, 9 addGSUBFeatureVariations, 10) 11from fontTools.varLib.errors import VarLibValidationError 12import fontTools.varLib.errors as varLibErrors 13from fontTools.varLib.models import VariationModel 14from fontTools.varLib.mutator import instantiateVariableFont 15from fontTools.varLib import main as varLib_main, load_masters 16from fontTools.varLib import set_default_weight_width_slant 17from fontTools.designspaceLib import ( 18 DesignSpaceDocumentError, 19 DesignSpaceDocument, 20 SourceDescriptor, 21) 22from fontTools.feaLib.builder import addOpenTypeFeaturesFromString 23import difflib 24from copy import deepcopy 25from io import BytesIO 26import os 27import shutil 28import sys 29import tempfile 30import unittest 31import pytest 32 33 34def reload_font(font): 35 """(De)serialize to get final binary layout.""" 36 buf = BytesIO() 37 font.save(buf) 38 # Close the font to release filesystem resources so that on Windows the tearDown 39 # method can successfully remove the temporary directory created during setUp. 40 font.close() 41 buf.seek(0) 42 return TTFont(buf) 43 44 45class BuildTest(unittest.TestCase): 46 def __init__(self, methodName): 47 unittest.TestCase.__init__(self, methodName) 48 # Python 3 renamed assertRaisesRegexp to assertRaisesRegex, 49 # and fires deprecation warnings if a program uses the old name. 50 if not hasattr(self, "assertRaisesRegex"): 51 self.assertRaisesRegex = self.assertRaisesRegexp 52 53 def setUp(self): 54 self.tempdir = None 55 self.num_tempfiles = 0 56 57 def tearDown(self): 58 if self.tempdir: 59 shutil.rmtree(self.tempdir) 60 61 def get_test_input(self, test_file_or_folder, copy=False): 62 parent_dir = os.path.dirname(__file__) 63 path = os.path.join(parent_dir, "data", test_file_or_folder) 64 if copy: 65 copied_path = os.path.join(self.tempdir, test_file_or_folder) 66 shutil.copy2(path, copied_path) 67 return copied_path 68 else: 69 return path 70 71 @staticmethod 72 def get_test_output(test_file_or_folder): 73 path, _ = os.path.split(__file__) 74 return os.path.join(path, "data", "test_results", test_file_or_folder) 75 76 @staticmethod 77 def get_file_list(folder, suffix, prefix=""): 78 all_files = os.listdir(folder) 79 file_list = [] 80 for p in all_files: 81 if p.startswith(prefix) and p.endswith(suffix): 82 file_list.append(os.path.abspath(os.path.join(folder, p))) 83 return file_list 84 85 def temp_path(self, suffix): 86 self.temp_dir() 87 self.num_tempfiles += 1 88 return os.path.join(self.tempdir, "tmp%d%s" % (self.num_tempfiles, suffix)) 89 90 def temp_dir(self): 91 if not self.tempdir: 92 self.tempdir = tempfile.mkdtemp() 93 94 def read_ttx(self, path): 95 lines = [] 96 with open(path, "r", encoding="utf-8") as ttx: 97 for line in ttx.readlines(): 98 # Elide ttFont attributes because ttLibVersion may change. 99 if line.startswith("<ttFont "): 100 lines.append("<ttFont>\n") 101 else: 102 lines.append(line.rstrip() + "\n") 103 return lines 104 105 def expect_ttx(self, font, expected_ttx, tables): 106 path = self.temp_path(suffix=".ttx") 107 font.saveXML(path, tables=tables) 108 actual = self.read_ttx(path) 109 expected = self.read_ttx(expected_ttx) 110 if actual != expected: 111 for line in difflib.unified_diff( 112 expected, actual, fromfile=expected_ttx, tofile=path 113 ): 114 sys.stdout.write(line) 115 self.fail("TTX output is different from expected") 116 117 def check_ttx_dump(self, font, expected_ttx, tables, suffix): 118 """Ensure the TTX dump is the same after saving and reloading the font.""" 119 path = self.temp_path(suffix=suffix) 120 font.save(path) 121 self.expect_ttx(TTFont(path), expected_ttx, tables) 122 123 def compile_font(self, path, suffix, temp_dir): 124 ttx_filename = os.path.basename(path) 125 savepath = os.path.join(temp_dir, ttx_filename.replace(".ttx", suffix)) 126 font = TTFont(recalcBBoxes=False, recalcTimestamp=False) 127 font.importXML(path) 128 font.save(savepath, reorderTables=None) 129 return font, savepath 130 131 def _run_varlib_build_test( 132 self, 133 designspace_name, 134 font_name, 135 tables, 136 expected_ttx_name, 137 save_before_dump=False, 138 post_process_master=None, 139 ): 140 suffix = ".ttf" 141 ds_path = self.get_test_input(designspace_name + ".designspace") 142 ufo_dir = self.get_test_input("master_ufo") 143 ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf") 144 145 self.temp_dir() 146 ttx_paths = self.get_file_list(ttx_dir, ".ttx", font_name + "-") 147 for path in ttx_paths: 148 font, savepath = self.compile_font(path, suffix, self.tempdir) 149 if post_process_master is not None: 150 post_process_master(font, savepath) 151 152 finder = lambda s: s.replace(ufo_dir, self.tempdir).replace(".ufo", suffix) 153 varfont, model, _ = build(ds_path, finder) 154 155 if save_before_dump: 156 # some data (e.g. counts printed in TTX inline comments) is only 157 # calculated at compile time, so before we can compare the TTX 158 # dumps we need to save to a temporary stream, and realod the font 159 varfont = reload_font(varfont) 160 161 expected_ttx_path = self.get_test_output(expected_ttx_name + ".ttx") 162 self.expect_ttx(varfont, expected_ttx_path, tables) 163 self.check_ttx_dump(varfont, expected_ttx_path, tables, suffix) 164 165 # ----- 166 # Tests 167 # ----- 168 169 def test_varlib_build_ttf(self): 170 """Designspace file contains <axes> element.""" 171 self._run_varlib_build_test( 172 designspace_name="Build", 173 font_name="TestFamily", 174 tables=["GDEF", "HVAR", "MVAR", "fvar", "gvar"], 175 expected_ttx_name="Build", 176 ) 177 178 def test_varlib_build_no_axes_ttf(self): 179 """Designspace file does not contain an <axes> element.""" 180 ds_path = self.get_test_input("InterpolateLayout3.designspace") 181 with self.assertRaisesRegex(DesignSpaceDocumentError, "No axes defined"): 182 build(ds_path) 183 184 def test_varlib_avar_single_axis(self): 185 """Designspace file contains a 'weight' axis with <map> elements 186 modifying the normalization mapping. An 'avar' table is generated. 187 """ 188 test_name = "BuildAvarSingleAxis" 189 self._run_varlib_build_test( 190 designspace_name=test_name, 191 font_name="TestFamily3", 192 tables=["avar"], 193 expected_ttx_name=test_name, 194 ) 195 196 def test_varlib_avar_with_identity_maps(self): 197 """Designspace file contains two 'weight' and 'width' axes both with 198 <map> elements. 199 200 The 'width' axis only contains identity mappings, however the resulting 201 avar segment will not be empty but will contain the default axis value 202 maps: {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}. 203 204 This is to work around an issue with some rasterizers: 205 https://github.com/googlei18n/fontmake/issues/295 206 https://github.com/fonttools/fonttools/issues/1011 207 """ 208 test_name = "BuildAvarIdentityMaps" 209 self._run_varlib_build_test( 210 designspace_name=test_name, 211 font_name="TestFamily3", 212 tables=["avar"], 213 expected_ttx_name=test_name, 214 ) 215 216 def test_varlib_avar_empty_axis(self): 217 """Designspace file contains two 'weight' and 'width' axes, but 218 only one axis ('weight') has some <map> elements. 219 220 Even if no <map> elements are defined for the 'width' axis, the 221 resulting avar segment still contains the default axis value maps: 222 {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}. 223 224 This is again to work around an issue with some rasterizers: 225 https://github.com/googlei18n/fontmake/issues/295 226 https://github.com/fonttools/fonttools/issues/1011 227 """ 228 test_name = "BuildAvarEmptyAxis" 229 self._run_varlib_build_test( 230 designspace_name=test_name, 231 font_name="TestFamily3", 232 tables=["avar"], 233 expected_ttx_name=test_name, 234 ) 235 236 def test_varlib_avar2(self): 237 """Designspace file contains a 'weight' axis with <map> elements 238 modifying the normalization mapping as well as <mappings> element 239 modifying it post-normalization. An 'avar' table is generated. 240 """ 241 test_name = "BuildAvar2" 242 self._run_varlib_build_test( 243 designspace_name=test_name, 244 font_name="TestFamily3", 245 tables=["avar"], 246 expected_ttx_name=test_name, 247 ) 248 249 def test_varlib_build_feature_variations(self): 250 """Designspace file contains <rules> element, used to build 251 GSUB FeatureVariations table. 252 """ 253 self._run_varlib_build_test( 254 designspace_name="FeatureVars", 255 font_name="TestFamily", 256 tables=["fvar", "GSUB"], 257 expected_ttx_name="FeatureVars", 258 save_before_dump=True, 259 ) 260 261 def test_varlib_build_feature_variations_custom_tag(self): 262 """Designspace file contains <rules> element, used to build 263 GSUB FeatureVariations table. 264 """ 265 self._run_varlib_build_test( 266 designspace_name="FeatureVarsCustomTag", 267 font_name="TestFamily", 268 tables=["fvar", "GSUB"], 269 expected_ttx_name="FeatureVarsCustomTag", 270 save_before_dump=True, 271 ) 272 273 def test_varlib_build_feature_variations_whole_range(self): 274 """Designspace file contains <rules> element specifying the entire design 275 space, used to build GSUB FeatureVariations table. 276 """ 277 self._run_varlib_build_test( 278 designspace_name="FeatureVarsWholeRange", 279 font_name="TestFamily", 280 tables=["fvar", "GSUB"], 281 expected_ttx_name="FeatureVarsWholeRange", 282 save_before_dump=True, 283 ) 284 285 def test_varlib_build_feature_variations_whole_range_empty(self): 286 """Designspace file contains <rules> element without a condition, specifying 287 the entire design space, used to build GSUB FeatureVariations table. 288 """ 289 self._run_varlib_build_test( 290 designspace_name="FeatureVarsWholeRangeEmpty", 291 font_name="TestFamily", 292 tables=["fvar", "GSUB"], 293 expected_ttx_name="FeatureVarsWholeRange", 294 save_before_dump=True, 295 ) 296 297 def test_varlib_build_feature_variations_with_existing_rclt(self): 298 """Designspace file contains <rules> element, used to build GSUB 299 FeatureVariations table. <rules> is specified to do its OT processing 300 "last", so a 'rclt' feature will be used or created. This test covers 301 the case when a 'rclt' already exists in the masters. 302 303 We dynamically add a 'rclt' feature to an existing set of test 304 masters, to avoid adding more test data. 305 306 The multiple languages are done to verify whether multiple existing 307 'rclt' features are updated correctly. 308 """ 309 310 def add_rclt(font, savepath): 311 features = """ 312 languagesystem DFLT dflt; 313 languagesystem latn dflt; 314 languagesystem latn NLD; 315 316 feature rclt { 317 script latn; 318 language NLD; 319 lookup A { 320 sub uni0041 by uni0061; 321 } A; 322 language dflt; 323 lookup B { 324 sub uni0041 by uni0061; 325 } B; 326 } rclt; 327 """ 328 addOpenTypeFeaturesFromString(font, features) 329 font.save(savepath) 330 331 self._run_varlib_build_test( 332 designspace_name="FeatureVars", 333 font_name="TestFamily", 334 tables=["fvar", "GSUB"], 335 expected_ttx_name="FeatureVars_rclt", 336 save_before_dump=True, 337 post_process_master=add_rclt, 338 ) 339 340 def test_varlib_gvar_explicit_delta(self): 341 """The variable font contains a composite glyph odieresis which does not 342 need a gvar entry, because all its deltas are 0, but it must be added 343 anyway to work around an issue with macOS 10.14. 344 345 https://github.com/fonttools/fonttools/issues/1381 346 """ 347 test_name = "BuildGvarCompositeExplicitDelta" 348 self._run_varlib_build_test( 349 designspace_name=test_name, 350 font_name="TestFamily4", 351 tables=["gvar"], 352 expected_ttx_name=test_name, 353 ) 354 355 def test_varlib_nonmarking_CFF2(self): 356 self.temp_dir() 357 358 ds_path = self.get_test_input("TestNonMarkingCFF2.designspace", copy=True) 359 ttx_dir = self.get_test_input("master_non_marking_cff2") 360 expected_ttx_path = self.get_test_output("TestNonMarkingCFF2.ttx") 361 362 for path in self.get_file_list(ttx_dir, ".ttx", "TestNonMarkingCFF2_"): 363 self.compile_font(path, ".otf", self.tempdir) 364 365 ds = DesignSpaceDocument.fromfile(ds_path) 366 for source in ds.sources: 367 source.path = os.path.join( 368 self.tempdir, os.path.basename(source.filename).replace(".ufo", ".otf") 369 ) 370 ds.updatePaths() 371 372 varfont, _, _ = build(ds) 373 varfont = reload_font(varfont) 374 375 tables = ["CFF2"] 376 self.expect_ttx(varfont, expected_ttx_path, tables) 377 378 def test_varlib_build_CFF2(self): 379 self.temp_dir() 380 381 ds_path = self.get_test_input("TestCFF2.designspace", copy=True) 382 ttx_dir = self.get_test_input("master_cff2") 383 expected_ttx_path = self.get_test_output("BuildTestCFF2.ttx") 384 385 for path in self.get_file_list(ttx_dir, ".ttx", "TestCFF2_"): 386 self.compile_font(path, ".otf", self.tempdir) 387 388 ds = DesignSpaceDocument.fromfile(ds_path) 389 for source in ds.sources: 390 source.path = os.path.join( 391 self.tempdir, os.path.basename(source.filename).replace(".ufo", ".otf") 392 ) 393 ds.updatePaths() 394 395 varfont, _, _ = build(ds) 396 varfont = reload_font(varfont) 397 398 tables = ["fvar", "CFF2"] 399 self.expect_ttx(varfont, expected_ttx_path, tables) 400 401 def test_varlib_build_CFF2_from_CFF2(self): 402 self.temp_dir() 403 404 ds_path = self.get_test_input("TestCFF2Input.designspace", copy=True) 405 ttx_dir = self.get_test_input("master_cff2_input") 406 expected_ttx_path = self.get_test_output("BuildTestCFF2.ttx") 407 408 for path in self.get_file_list(ttx_dir, ".ttx", "TestCFF2_"): 409 self.compile_font(path, ".otf", self.tempdir) 410 411 ds = DesignSpaceDocument.fromfile(ds_path) 412 for source in ds.sources: 413 source.path = os.path.join( 414 self.tempdir, os.path.basename(source.filename).replace(".ufo", ".otf") 415 ) 416 ds.updatePaths() 417 418 varfont, _, _ = build(ds) 419 varfont = reload_font(varfont) 420 421 tables = ["fvar", "CFF2"] 422 self.expect_ttx(varfont, expected_ttx_path, tables) 423 424 def test_varlib_build_sparse_CFF2(self): 425 self.temp_dir() 426 427 ds_path = self.get_test_input("TestSparseCFF2VF.designspace", copy=True) 428 ttx_dir = self.get_test_input("master_sparse_cff2") 429 expected_ttx_path = self.get_test_output("TestSparseCFF2VF.ttx") 430 431 for path in self.get_file_list(ttx_dir, ".ttx", "MasterSet_Kanji-"): 432 self.compile_font(path, ".otf", self.tempdir) 433 434 ds = DesignSpaceDocument.fromfile(ds_path) 435 for source in ds.sources: 436 source.path = os.path.join( 437 self.tempdir, os.path.basename(source.filename).replace(".ufo", ".otf") 438 ) 439 ds.updatePaths() 440 441 varfont, _, _ = build(ds) 442 varfont = reload_font(varfont) 443 444 tables = ["fvar", "CFF2"] 445 self.expect_ttx(varfont, expected_ttx_path, tables) 446 447 def test_varlib_build_vpal(self): 448 self.temp_dir() 449 450 ds_path = self.get_test_input("test_vpal.designspace", copy=True) 451 ttx_dir = self.get_test_input("master_vpal_test") 452 expected_ttx_path = self.get_test_output("test_vpal.ttx") 453 454 for path in self.get_file_list(ttx_dir, ".ttx", "master_vpal_test_"): 455 self.compile_font(path, ".otf", self.tempdir) 456 457 ds = DesignSpaceDocument.fromfile(ds_path) 458 for source in ds.sources: 459 source.path = os.path.join( 460 self.tempdir, os.path.basename(source.filename).replace(".ufo", ".otf") 461 ) 462 ds.updatePaths() 463 464 varfont, _, _ = build(ds) 465 varfont = reload_font(varfont) 466 467 tables = ["GPOS"] 468 self.expect_ttx(varfont, expected_ttx_path, tables) 469 470 def test_varlib_main_ttf(self): 471 """Mostly for testing varLib.main()""" 472 suffix = ".ttf" 473 ds_path = self.get_test_input("Build.designspace") 474 ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf") 475 476 self.temp_dir() 477 ttf_dir = os.path.join(self.tempdir, "master_ttf_interpolatable") 478 os.makedirs(ttf_dir) 479 ttx_paths = self.get_file_list(ttx_dir, ".ttx", "TestFamily-") 480 for path in ttx_paths: 481 self.compile_font(path, suffix, ttf_dir) 482 483 ds_copy = os.path.join(self.tempdir, "BuildMain.designspace") 484 shutil.copy2(ds_path, ds_copy) 485 486 # by default, varLib.main finds master TTFs inside a 487 # 'master_ttf_interpolatable' subfolder in current working dir 488 cwd = os.getcwd() 489 os.chdir(self.tempdir) 490 try: 491 varLib_main([ds_copy]) 492 finally: 493 os.chdir(cwd) 494 495 varfont_path = os.path.splitext(ds_copy)[0] + "-VF" + suffix 496 self.assertTrue(os.path.exists(varfont_path)) 497 498 # try again passing an explicit --master-finder 499 os.remove(varfont_path) 500 finder = "%s/master_ttf_interpolatable/{stem}.ttf" % self.tempdir 501 varLib_main([ds_copy, "--master-finder", finder]) 502 self.assertTrue(os.path.exists(varfont_path)) 503 504 # and also with explicit -o output option 505 os.remove(varfont_path) 506 varfont_path = os.path.splitext(varfont_path)[0] + "-o" + suffix 507 varLib_main([ds_copy, "-o", varfont_path, "--master-finder", finder]) 508 self.assertTrue(os.path.exists(varfont_path)) 509 510 varfont = TTFont(varfont_path) 511 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 512 expected_ttx_path = self.get_test_output("BuildMain.ttx") 513 self.expect_ttx(varfont, expected_ttx_path, tables) 514 515 def test_varLib_main_output_dir(self): 516 self.temp_dir() 517 outdir = os.path.join(self.tempdir, "output_dir_test") 518 self.assertFalse(os.path.exists(outdir)) 519 520 ds_path = os.path.join(self.tempdir, "BuildMain.designspace") 521 shutil.copy2(self.get_test_input("Build.designspace"), ds_path) 522 523 shutil.copytree( 524 self.get_test_input("master_ttx_interpolatable_ttf"), 525 os.path.join(outdir, "master_ttx"), 526 ) 527 528 finder = "%s/output_dir_test/master_ttx/{stem}.ttx" % self.tempdir 529 530 varLib_main([ds_path, "--output-dir", outdir, "--master-finder", finder]) 531 532 self.assertTrue(os.path.isdir(outdir)) 533 self.assertTrue(os.path.exists(os.path.join(outdir, "BuildMain-VF.ttf"))) 534 535 def test_varLib_main_filter_variable_fonts(self): 536 self.temp_dir() 537 outdir = os.path.join(self.tempdir, "filter_variable_fonts_test") 538 self.assertFalse(os.path.exists(outdir)) 539 540 ds_path = os.path.join(self.tempdir, "BuildMain.designspace") 541 shutil.copy2(self.get_test_input("Build.designspace"), ds_path) 542 543 shutil.copytree( 544 self.get_test_input("master_ttx_interpolatable_ttf"), 545 os.path.join(outdir, "master_ttx"), 546 ) 547 548 finder = "%s/filter_variable_fonts_test/master_ttx/{stem}.ttx" % self.tempdir 549 550 cmd = [ds_path, "--output-dir", outdir, "--master-finder", finder] 551 552 with pytest.raises(SystemExit): 553 varLib_main(cmd + ["--variable-fonts", "FooBar"]) # no font matches 554 555 varLib_main(cmd + ["--variable-fonts", "Build.*"]) # this does match 556 557 self.assertTrue(os.path.isdir(outdir)) 558 self.assertTrue(os.path.exists(os.path.join(outdir, "BuildMain-VF.ttf"))) 559 560 def test_varLib_main_drop_implied_oncurves(self): 561 self.temp_dir() 562 outdir = os.path.join(self.tempdir, "drop_implied_oncurves_test") 563 self.assertFalse(os.path.exists(outdir)) 564 565 ttf_dir = os.path.join(outdir, "master_ttf_interpolatable") 566 os.makedirs(ttf_dir) 567 ttx_dir = self.get_test_input("master_ttx_drop_oncurves") 568 ttx_paths = self.get_file_list(ttx_dir, ".ttx", "TestFamily-") 569 for path in ttx_paths: 570 self.compile_font(path, ".ttf", ttf_dir) 571 572 ds_copy = os.path.join(outdir, "DropOnCurves.designspace") 573 ds_path = self.get_test_input("DropOnCurves.designspace") 574 shutil.copy2(ds_path, ds_copy) 575 576 finder = "%s/master_ttf_interpolatable/{stem}.ttf" % outdir 577 varLib_main([ds_copy, "--master-finder", finder, "--drop-implied-oncurves"]) 578 579 vf_path = os.path.join(outdir, "DropOnCurves-VF.ttf") 580 varfont = TTFont(vf_path) 581 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 582 expected_ttx_path = self.get_test_output("DropOnCurves.ttx") 583 self.expect_ttx(varfont, expected_ttx_path, tables) 584 585 def test_varLib_build_many_no_overwrite_STAT(self): 586 # Ensure that varLib.build_many doesn't overwrite a pre-existing STAT table, 587 # e.g. one built by feaLib from features.fea; the VF simply should inherit the 588 # STAT from the base master: https://github.com/googlefonts/fontmake/issues/985 589 base_master = TTFont() 590 base_master.importXML( 591 self.get_test_input("master_no_overwrite_stat/Test-CondensedThin.ttx") 592 ) 593 assert "STAT" in base_master 594 595 vf = next( 596 iter( 597 build_many( 598 DesignSpaceDocument.fromfile( 599 self.get_test_input("TestNoOverwriteSTAT.designspace") 600 ) 601 ).values() 602 ) 603 ) 604 assert "STAT" in vf 605 606 assert vf["STAT"].table == base_master["STAT"].table 607 608 def test_varlib_build_from_ds_object_in_memory_ttfonts(self): 609 ds_path = self.get_test_input("Build.designspace") 610 ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf") 611 expected_ttx_path = self.get_test_output("BuildMain.ttx") 612 613 self.temp_dir() 614 for path in self.get_file_list(ttx_dir, ".ttx", "TestFamily-"): 615 self.compile_font(path, ".ttf", self.tempdir) 616 617 ds = DesignSpaceDocument.fromfile(ds_path) 618 for source in ds.sources: 619 filename = os.path.join( 620 self.tempdir, os.path.basename(source.filename).replace(".ufo", ".ttf") 621 ) 622 source.font = TTFont( 623 filename, recalcBBoxes=False, recalcTimestamp=False, lazy=True 624 ) 625 source.filename = None # Make sure no file path gets into build() 626 627 varfont, _, _ = build(ds) 628 varfont = reload_font(varfont) 629 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 630 self.expect_ttx(varfont, expected_ttx_path, tables) 631 632 def test_varlib_build_from_ttf_paths(self): 633 self.temp_dir() 634 635 ds_path = self.get_test_input("Build.designspace", copy=True) 636 ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf") 637 expected_ttx_path = self.get_test_output("BuildMain.ttx") 638 639 for path in self.get_file_list(ttx_dir, ".ttx", "TestFamily-"): 640 self.compile_font(path, ".ttf", self.tempdir) 641 642 ds = DesignSpaceDocument.fromfile(ds_path) 643 for source in ds.sources: 644 source.path = os.path.join( 645 self.tempdir, os.path.basename(source.filename).replace(".ufo", ".ttf") 646 ) 647 ds.updatePaths() 648 649 varfont, _, _ = build(ds) 650 varfont = reload_font(varfont) 651 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 652 self.expect_ttx(varfont, expected_ttx_path, tables) 653 654 def test_varlib_build_from_ttx_paths(self): 655 ds_path = self.get_test_input("Build.designspace") 656 ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf") 657 expected_ttx_path = self.get_test_output("BuildMain.ttx") 658 659 ds = DesignSpaceDocument.fromfile(ds_path) 660 for source in ds.sources: 661 source.path = os.path.join( 662 ttx_dir, os.path.basename(source.filename).replace(".ufo", ".ttx") 663 ) 664 ds.updatePaths() 665 666 varfont, _, _ = build(ds) 667 varfont = reload_font(varfont) 668 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 669 self.expect_ttx(varfont, expected_ttx_path, tables) 670 671 def test_varlib_build_sparse_masters(self): 672 ds_path = self.get_test_input("SparseMasters.designspace") 673 expected_ttx_path = self.get_test_output("SparseMasters.ttx") 674 675 varfont, _, _ = build(ds_path) 676 varfont = reload_font(varfont) 677 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 678 self.expect_ttx(varfont, expected_ttx_path, tables) 679 680 def test_varlib_build_lazy_masters(self): 681 # See https://github.com/fonttools/fonttools/issues/1808 682 ds_path = self.get_test_input("SparseMasters.designspace") 683 expected_ttx_path = self.get_test_output("SparseMasters.ttx") 684 685 def _open_font(master_path, master_finder=lambda s: s): 686 font = TTFont() 687 font.importXML(master_path) 688 buf = BytesIO() 689 font.save(buf, reorderTables=False) 690 buf.seek(0) 691 font = TTFont(buf, lazy=True) # reopen in lazy mode, to reproduce #1808 692 return font 693 694 ds = DesignSpaceDocument.fromfile(ds_path) 695 ds.loadSourceFonts(_open_font) 696 varfont, _, _ = build(ds) 697 varfont = reload_font(varfont) 698 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 699 self.expect_ttx(varfont, expected_ttx_path, tables) 700 701 def test_varlib_build_sparse_masters_MVAR(self): 702 import fontTools.varLib.mvar 703 704 ds_path = self.get_test_input("SparseMasters.designspace") 705 ds = DesignSpaceDocument.fromfile(ds_path) 706 load_masters(ds) 707 708 # Trigger MVAR generation so varLib is forced to create deltas with a 709 # sparse master inbetween. 710 font_0_os2 = ds.sources[0].font["OS/2"] 711 font_0_os2.sTypoAscender = 1 712 font_0_os2.sTypoDescender = 1 713 font_0_os2.sTypoLineGap = 1 714 font_0_os2.usWinAscent = 1 715 font_0_os2.usWinDescent = 1 716 font_0_os2.sxHeight = 1 717 font_0_os2.sCapHeight = 1 718 font_0_os2.ySubscriptXSize = 1 719 font_0_os2.ySubscriptYSize = 1 720 font_0_os2.ySubscriptXOffset = 1 721 font_0_os2.ySubscriptYOffset = 1 722 font_0_os2.ySuperscriptXSize = 1 723 font_0_os2.ySuperscriptYSize = 1 724 font_0_os2.ySuperscriptXOffset = 1 725 font_0_os2.ySuperscriptYOffset = 1 726 font_0_os2.yStrikeoutSize = 1 727 font_0_os2.yStrikeoutPosition = 1 728 font_0_vhea = newTable("vhea") 729 font_0_vhea.ascent = 1 730 font_0_vhea.descent = 1 731 font_0_vhea.lineGap = 1 732 font_0_vhea.caretSlopeRise = 1 733 font_0_vhea.caretSlopeRun = 1 734 font_0_vhea.caretOffset = 1 735 ds.sources[0].font["vhea"] = font_0_vhea 736 font_0_hhea = ds.sources[0].font["hhea"] 737 font_0_hhea.caretSlopeRise = 1 738 font_0_hhea.caretSlopeRun = 1 739 font_0_hhea.caretOffset = 1 740 font_0_post = ds.sources[0].font["post"] 741 font_0_post.underlineThickness = 1 742 font_0_post.underlinePosition = 1 743 744 font_2_os2 = ds.sources[2].font["OS/2"] 745 font_2_os2.sTypoAscender = 800 746 font_2_os2.sTypoDescender = 800 747 font_2_os2.sTypoLineGap = 800 748 font_2_os2.usWinAscent = 800 749 font_2_os2.usWinDescent = 800 750 font_2_os2.sxHeight = 800 751 font_2_os2.sCapHeight = 800 752 font_2_os2.ySubscriptXSize = 800 753 font_2_os2.ySubscriptYSize = 800 754 font_2_os2.ySubscriptXOffset = 800 755 font_2_os2.ySubscriptYOffset = 800 756 font_2_os2.ySuperscriptXSize = 800 757 font_2_os2.ySuperscriptYSize = 800 758 font_2_os2.ySuperscriptXOffset = 800 759 font_2_os2.ySuperscriptYOffset = 800 760 font_2_os2.yStrikeoutSize = 800 761 font_2_os2.yStrikeoutPosition = 800 762 font_2_vhea = newTable("vhea") 763 font_2_vhea.ascent = 800 764 font_2_vhea.descent = 800 765 font_2_vhea.lineGap = 800 766 font_2_vhea.caretSlopeRise = 800 767 font_2_vhea.caretSlopeRun = 800 768 font_2_vhea.caretOffset = 800 769 ds.sources[2].font["vhea"] = font_2_vhea 770 font_2_hhea = ds.sources[2].font["hhea"] 771 font_2_hhea.caretSlopeRise = 800 772 font_2_hhea.caretSlopeRun = 800 773 font_2_hhea.caretOffset = 800 774 font_2_post = ds.sources[2].font["post"] 775 font_2_post.underlineThickness = 800 776 font_2_post.underlinePosition = 800 777 778 varfont, _, _ = build(ds) 779 mvar_tags = [vr.ValueTag for vr in varfont["MVAR"].table.ValueRecord] 780 assert all(tag in mvar_tags for tag in fontTools.varLib.mvar.MVAR_ENTRIES) 781 782 def test_varlib_build_VVAR_CFF2(self): 783 self.temp_dir() 784 785 ds_path = self.get_test_input("TestVVAR.designspace", copy=True) 786 ttx_dir = self.get_test_input("master_vvar_cff2") 787 expected_ttx_name = "TestVVAR" 788 suffix = ".otf" 789 790 for path in self.get_file_list(ttx_dir, ".ttx", "TestVVAR"): 791 font, savepath = self.compile_font(path, suffix, self.tempdir) 792 793 ds = DesignSpaceDocument.fromfile(ds_path) 794 for source in ds.sources: 795 source.path = os.path.join( 796 self.tempdir, os.path.basename(source.filename).replace(".ufo", suffix) 797 ) 798 ds.updatePaths() 799 800 varfont, _, _ = build(ds) 801 varfont = reload_font(varfont) 802 803 expected_ttx_path = self.get_test_output(expected_ttx_name + ".ttx") 804 tables = ["VVAR"] 805 self.expect_ttx(varfont, expected_ttx_path, tables) 806 self.check_ttx_dump(varfont, expected_ttx_path, tables, suffix) 807 808 def test_varlib_build_BASE(self): 809 self.temp_dir() 810 811 ds_path = self.get_test_input("TestBASE.designspace", copy=True) 812 ttx_dir = self.get_test_input("master_base_test") 813 expected_ttx_name = "TestBASE" 814 suffix = ".otf" 815 816 for path in self.get_file_list(ttx_dir, ".ttx", "TestBASE"): 817 font, savepath = self.compile_font(path, suffix, self.tempdir) 818 819 ds = DesignSpaceDocument.fromfile(ds_path) 820 for source in ds.sources: 821 source.path = os.path.join( 822 self.tempdir, os.path.basename(source.filename).replace(".ufo", suffix) 823 ) 824 ds.updatePaths() 825 826 varfont, _, _ = build(ds) 827 varfont = reload_font(varfont) 828 829 expected_ttx_path = self.get_test_output(expected_ttx_name + ".ttx") 830 tables = ["BASE"] 831 self.expect_ttx(varfont, expected_ttx_path, tables) 832 self.check_ttx_dump(varfont, expected_ttx_path, tables, suffix) 833 834 def test_varlib_build_single_master(self): 835 self._run_varlib_build_test( 836 designspace_name="SingleMaster", 837 font_name="TestFamily", 838 tables=["GDEF", "HVAR", "MVAR", "STAT", "fvar", "cvar", "gvar", "name"], 839 expected_ttx_name="SingleMaster", 840 save_before_dump=True, 841 ) 842 843 def test_kerning_merging(self): 844 """Test the correct merging of class-based pair kerning. 845 846 Problem description at https://github.com/fonttools/fonttools/pull/1638. 847 Test font and Designspace generated by 848 https://gist.github.com/madig/183d0440c9f7d05f04bd1280b9664bd1. 849 """ 850 ds_path = self.get_test_input("KerningMerging.designspace") 851 ttx_dir = self.get_test_input("master_kerning_merging") 852 853 ds = DesignSpaceDocument.fromfile(ds_path) 854 for source in ds.sources: 855 ttx_dump = TTFont() 856 ttx_dump.importXML( 857 os.path.join( 858 ttx_dir, os.path.basename(source.filename).replace(".ttf", ".ttx") 859 ) 860 ) 861 source.font = reload_font(ttx_dump) 862 863 varfont, _, _ = build(ds) 864 varfont = reload_font(varfont) 865 866 class_kerning_tables = [ 867 t 868 for l in varfont["GPOS"].table.LookupList.Lookup 869 for t in l.SubTable 870 if t.Format == 2 871 ] 872 assert len(class_kerning_tables) == 1 873 class_kerning_table = class_kerning_tables[0] 874 875 # Test that no class kerned against class zero (containing all glyphs not 876 # classed) has a `XAdvDevice` table attached, which in the variable font 877 # context is a "VariationIndex" table and points to kerning deltas in the GDEF 878 # table. Variation deltas of any kerning class against class zero should 879 # probably never exist. 880 for class1_record in class_kerning_table.Class1Record: 881 class2_zero = class1_record.Class2Record[0] 882 assert getattr(class2_zero.Value1, "XAdvDevice", None) is None 883 884 # Assert the variable font's kerning table (without deltas) is equal to the 885 # default font's kerning table. The bug fixed in 886 # https://github.com/fonttools/fonttools/pull/1638 caused rogue kerning 887 # values to be written to the variable font. 888 assert _extract_flat_kerning(varfont, class_kerning_table) == { 889 ("A", ".notdef"): 0, 890 ("A", "A"): 0, 891 ("A", "B"): -20, 892 ("A", "C"): 0, 893 ("A", "D"): -20, 894 ("B", ".notdef"): 0, 895 ("B", "A"): 0, 896 ("B", "B"): 0, 897 ("B", "C"): 0, 898 ("B", "D"): 0, 899 } 900 901 instance_thin = instantiateVariableFont(varfont, {"wght": 100}) 902 instance_thin_kerning_table = ( 903 instance_thin["GPOS"].table.LookupList.Lookup[0].SubTable[0] 904 ) 905 assert _extract_flat_kerning(instance_thin, instance_thin_kerning_table) == { 906 ("A", ".notdef"): 0, 907 ("A", "A"): 0, 908 ("A", "B"): 0, 909 ("A", "C"): 10, 910 ("A", "D"): 0, 911 ("B", ".notdef"): 0, 912 ("B", "A"): 0, 913 ("B", "B"): 0, 914 ("B", "C"): 10, 915 ("B", "D"): 0, 916 } 917 918 instance_black = instantiateVariableFont(varfont, {"wght": 900}) 919 instance_black_kerning_table = ( 920 instance_black["GPOS"].table.LookupList.Lookup[0].SubTable[0] 921 ) 922 assert _extract_flat_kerning(instance_black, instance_black_kerning_table) == { 923 ("A", ".notdef"): 0, 924 ("A", "A"): 0, 925 ("A", "B"): 0, 926 ("A", "C"): 0, 927 ("A", "D"): 40, 928 ("B", ".notdef"): 0, 929 ("B", "A"): 0, 930 ("B", "B"): 0, 931 ("B", "C"): 0, 932 ("B", "D"): 40, 933 } 934 935 def test_designspace_fill_in_location(self): 936 ds_path = self.get_test_input("VarLibLocationTest.designspace") 937 ds = DesignSpaceDocument.fromfile(ds_path) 938 ds_loaded = load_designspace(ds) 939 940 assert ds_loaded.instances[0].location == {"weight": 0, "width": 50} 941 942 def test_varlib_build_incompatible_features(self): 943 with pytest.raises( 944 varLibErrors.ShouldBeConstant, 945 match=""" 946 947Couldn't merge the fonts, because some values were different, but should have 948been the same. This happened while performing the following operation: 949GPOS.table.FeatureList.FeatureCount 950 951The problem is likely to be in Simple Two Axis Bold: 952Expected to see .FeatureCount==2, instead saw 1 953 954Incompatible features between masters. 955Expected: kern, mark. 956Got: kern. 957""", 958 ): 959 self._run_varlib_build_test( 960 designspace_name="IncompatibleFeatures", 961 font_name="IncompatibleFeatures", 962 tables=["GPOS"], 963 expected_ttx_name="IncompatibleFeatures", 964 save_before_dump=True, 965 ) 966 967 def test_varlib_build_incompatible_lookup_types(self): 968 with pytest.raises( 969 varLibErrors.MismatchedTypes, match=r"'MarkBasePos', instead saw 'PairPos'" 970 ): 971 self._run_varlib_build_test( 972 designspace_name="IncompatibleLookupTypes", 973 font_name="IncompatibleLookupTypes", 974 tables=["GPOS"], 975 expected_ttx_name="IncompatibleLookupTypes", 976 save_before_dump=True, 977 ) 978 979 def test_varlib_build_incompatible_arrays(self): 980 with pytest.raises( 981 varLibErrors.ShouldBeConstant, 982 match=""" 983 984Couldn't merge the fonts, because some values were different, but should have 985been the same. This happened while performing the following operation: 986GPOS.table.ScriptList.ScriptCount 987 988The problem is likely to be in Simple Two Axis Bold: 989Expected to see .ScriptCount==1, instead saw 0""", 990 ): 991 self._run_varlib_build_test( 992 designspace_name="IncompatibleArrays", 993 font_name="IncompatibleArrays", 994 tables=["GPOS"], 995 expected_ttx_name="IncompatibleArrays", 996 save_before_dump=True, 997 ) 998 999 def test_varlib_build_variable_colr(self): 1000 self._run_varlib_build_test( 1001 designspace_name="TestVariableCOLR", 1002 font_name="TestVariableCOLR", 1003 tables=["GlyphOrder", "fvar", "glyf", "COLR", "CPAL"], 1004 expected_ttx_name="TestVariableCOLR-VF", 1005 save_before_dump=True, 1006 ) 1007 1008 def test_varlib_build_variable_cff2_with_empty_sparse_glyph(self): 1009 # https://github.com/fonttools/fonttools/issues/3233 1010 self._run_varlib_build_test( 1011 designspace_name="SparseCFF2", 1012 font_name="SparseCFF2", 1013 tables=["GlyphOrder", "CFF2", "fvar", "hmtx", "HVAR"], 1014 expected_ttx_name="SparseCFF2-VF", 1015 save_before_dump=True, 1016 ) 1017 1018 def test_varlib_addGSUBFeatureVariations(self): 1019 ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf") 1020 1021 ds = DesignSpaceDocument.fromfile( 1022 self.get_test_input("FeatureVars.designspace") 1023 ) 1024 for source in ds.sources: 1025 ttx_dump = TTFont() 1026 ttx_dump.importXML( 1027 os.path.join( 1028 ttx_dir, os.path.basename(source.filename).replace(".ufo", ".ttx") 1029 ) 1030 ) 1031 source.font = ttx_dump 1032 1033 varfont, _, _ = build(ds, exclude=["GSUB"]) 1034 assert "GSUB" not in varfont 1035 1036 addGSUBFeatureVariations(varfont, ds) 1037 assert "GSUB" in varfont 1038 1039 tables = ["fvar", "GSUB"] 1040 expected_ttx_path = self.get_test_output("FeatureVars.ttx") 1041 self.expect_ttx(varfont, expected_ttx_path, tables) 1042 self.check_ttx_dump(varfont, expected_ttx_path, tables, ".ttf") 1043 1044 1045def test_load_masters_layerName_without_required_font(): 1046 ds = DesignSpaceDocument() 1047 s = SourceDescriptor() 1048 s.font = None 1049 s.layerName = "Medium" 1050 ds.addSource(s) 1051 1052 with pytest.raises( 1053 VarLibValidationError, 1054 match="specified a layer name but lacks the required TTFont object", 1055 ): 1056 load_masters(ds) 1057 1058 1059def _extract_flat_kerning(font, pairpos_table): 1060 extracted_kerning = {} 1061 for glyph_name_1 in pairpos_table.Coverage.glyphs: 1062 class_def_1 = pairpos_table.ClassDef1.classDefs.get(glyph_name_1, 0) 1063 for glyph_name_2 in font.getGlyphOrder(): 1064 class_def_2 = pairpos_table.ClassDef2.classDefs.get(glyph_name_2, 0) 1065 kern_value = ( 1066 pairpos_table.Class1Record[class_def_1] 1067 .Class2Record[class_def_2] 1068 .Value1.XAdvance 1069 ) 1070 extracted_kerning[(glyph_name_1, glyph_name_2)] = kern_value 1071 return extracted_kerning 1072 1073 1074@pytest.fixture 1075def ttFont(): 1076 f = TTFont() 1077 f["OS/2"] = newTable("OS/2") 1078 f["OS/2"].usWeightClass = 400 1079 f["OS/2"].usWidthClass = 100 1080 f["post"] = newTable("post") 1081 f["post"].italicAngle = 0 1082 return f 1083 1084 1085class SetDefaultWeightWidthSlantTest(object): 1086 @pytest.mark.parametrize( 1087 "location, expected", 1088 [ 1089 ({"wght": 0}, 1), 1090 ({"wght": 1}, 1), 1091 ({"wght": 100}, 100), 1092 ({"wght": 1000}, 1000), 1093 ({"wght": 1001}, 1000), 1094 ], 1095 ) 1096 def test_wght(self, ttFont, location, expected): 1097 set_default_weight_width_slant(ttFont, location) 1098 1099 assert ttFont["OS/2"].usWeightClass == expected 1100 1101 @pytest.mark.parametrize( 1102 "location, expected", 1103 [ 1104 ({"wdth": 0}, 1), 1105 ({"wdth": 56}, 1), 1106 ({"wdth": 57}, 2), 1107 ({"wdth": 62.5}, 2), 1108 ({"wdth": 75}, 3), 1109 ({"wdth": 87.5}, 4), 1110 ({"wdth": 100}, 5), 1111 ({"wdth": 112.5}, 6), 1112 ({"wdth": 125}, 7), 1113 ({"wdth": 150}, 8), 1114 ({"wdth": 200}, 9), 1115 ({"wdth": 201}, 9), 1116 ({"wdth": 1000}, 9), 1117 ], 1118 ) 1119 def test_wdth(self, ttFont, location, expected): 1120 set_default_weight_width_slant(ttFont, location) 1121 1122 assert ttFont["OS/2"].usWidthClass == expected 1123 1124 @pytest.mark.parametrize( 1125 "location, expected", 1126 [ 1127 ({"slnt": -91}, -90), 1128 ({"slnt": -90}, -90), 1129 ({"slnt": 0}, 0), 1130 ({"slnt": 11.5}, 11.5), 1131 ({"slnt": 90}, 90), 1132 ({"slnt": 91}, 90), 1133 ], 1134 ) 1135 def test_slnt(self, ttFont, location, expected): 1136 set_default_weight_width_slant(ttFont, location) 1137 1138 assert ttFont["post"].italicAngle == expected 1139 1140 def test_all(self, ttFont): 1141 set_default_weight_width_slant( 1142 ttFont, {"wght": 500, "wdth": 150, "slnt": -12.0} 1143 ) 1144 1145 assert ttFont["OS/2"].usWeightClass == 500 1146 assert ttFont["OS/2"].usWidthClass == 8 1147 assert ttFont["post"].italicAngle == -12.0 1148 1149 1150def test_variable_COLR_without_VarIndexMap(): 1151 # test we don't add a no-op VarIndexMap to variable COLR when not needed 1152 # https://github.com/fonttools/fonttools/issues/2800 1153 1154 font1 = TTFont() 1155 font1.setGlyphOrder([".notdef", "A"]) 1156 font1["COLR"] = buildCOLR({"A": (ot.PaintFormat.PaintSolid, 0, 1.0)}) 1157 # font2 == font1 except for PaintSolid.Alpha 1158 font2 = deepcopy(font1) 1159 font2["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord[0].Paint.Alpha = 0.0 1160 master_fonts = [font1, font2] 1161 1162 varfont = deepcopy(font1) 1163 axis_order = ["XXXX"] 1164 model = VariationModel([{}, {"XXXX": 1.0}], axis_order) 1165 1166 _add_COLR(varfont, model, master_fonts, axis_order) 1167 1168 colr = varfont["COLR"].table 1169 1170 assert len(colr.BaseGlyphList.BaseGlyphPaintRecord) == 1 1171 baserec = colr.BaseGlyphList.BaseGlyphPaintRecord[0] 1172 assert baserec.Paint.Format == ot.PaintFormat.PaintVarSolid 1173 assert baserec.Paint.VarIndexBase == 0 1174 1175 assert colr.VarStore is not None 1176 assert len(colr.VarStore.VarData) == 1 1177 assert len(colr.VarStore.VarData[0].Item) == 1 1178 assert colr.VarStore.VarData[0].Item[0] == [-16384] 1179 1180 assert colr.VarIndexMap is None 1181 1182 1183if __name__ == "__main__": 1184 sys.exit(unittest.main()) 1185