1from __future__ import print_function, division, absolute_import 2from fontTools.misc.py23 import * 3from fontTools.ttLib import TTFont, newTable 4from fontTools.varLib import build 5from fontTools.varLib import main as varLib_main, load_masters 6from fontTools.designspaceLib import ( 7 DesignSpaceDocumentError, DesignSpaceDocument, SourceDescriptor, 8) 9import difflib 10import os 11import shutil 12import sys 13import tempfile 14import unittest 15import pytest 16 17 18def reload_font(font): 19 """(De)serialize to get final binary layout.""" 20 buf = BytesIO() 21 font.save(buf) 22 buf.seek(0) 23 return TTFont(buf) 24 25 26class BuildTest(unittest.TestCase): 27 def __init__(self, methodName): 28 unittest.TestCase.__init__(self, methodName) 29 # Python 3 renamed assertRaisesRegexp to assertRaisesRegex, 30 # and fires deprecation warnings if a program uses the old name. 31 if not hasattr(self, "assertRaisesRegex"): 32 self.assertRaisesRegex = self.assertRaisesRegexp 33 34 def setUp(self): 35 self.tempdir = None 36 self.num_tempfiles = 0 37 38 def tearDown(self): 39 if self.tempdir: 40 shutil.rmtree(self.tempdir) 41 42 @staticmethod 43 def get_test_input(test_file_or_folder): 44 path, _ = os.path.split(__file__) 45 return os.path.join(path, "data", test_file_or_folder) 46 47 @staticmethod 48 def get_test_output(test_file_or_folder): 49 path, _ = os.path.split(__file__) 50 return os.path.join(path, "data", "test_results", test_file_or_folder) 51 52 @staticmethod 53 def get_file_list(folder, suffix, prefix=''): 54 all_files = os.listdir(folder) 55 file_list = [] 56 for p in all_files: 57 if p.startswith(prefix) and p.endswith(suffix): 58 file_list.append(os.path.abspath(os.path.join(folder, p))) 59 return file_list 60 61 def temp_path(self, suffix): 62 self.temp_dir() 63 self.num_tempfiles += 1 64 return os.path.join(self.tempdir, 65 "tmp%d%s" % (self.num_tempfiles, suffix)) 66 67 def temp_dir(self): 68 if not self.tempdir: 69 self.tempdir = tempfile.mkdtemp() 70 71 def read_ttx(self, path): 72 lines = [] 73 with open(path, "r", encoding="utf-8") as ttx: 74 for line in ttx.readlines(): 75 # Elide ttFont attributes because ttLibVersion may change, 76 # and use os-native line separators so we can run difflib. 77 if line.startswith("<ttFont "): 78 lines.append("<ttFont>" + os.linesep) 79 else: 80 lines.append(line.rstrip() + os.linesep) 81 return lines 82 83 def expect_ttx(self, font, expected_ttx, tables): 84 path = self.temp_path(suffix=".ttx") 85 font.saveXML(path, tables=tables) 86 actual = self.read_ttx(path) 87 expected = self.read_ttx(expected_ttx) 88 if actual != expected: 89 for line in difflib.unified_diff( 90 expected, actual, fromfile=expected_ttx, tofile=path): 91 sys.stdout.write(line) 92 self.fail("TTX output is different from expected") 93 94 def check_ttx_dump(self, font, expected_ttx, tables, suffix): 95 """Ensure the TTX dump is the same after saving and reloading the font.""" 96 path = self.temp_path(suffix=suffix) 97 font.save(path) 98 self.expect_ttx(TTFont(path), expected_ttx, tables) 99 100 def compile_font(self, path, suffix, temp_dir): 101 ttx_filename = os.path.basename(path) 102 savepath = os.path.join(temp_dir, ttx_filename.replace('.ttx', suffix)) 103 font = TTFont(recalcBBoxes=False, recalcTimestamp=False) 104 font.importXML(path) 105 font.save(savepath, reorderTables=None) 106 return font, savepath 107 108 def _run_varlib_build_test(self, designspace_name, font_name, tables, 109 expected_ttx_name, save_before_dump=False): 110 suffix = '.ttf' 111 ds_path = self.get_test_input(designspace_name + '.designspace') 112 ufo_dir = self.get_test_input('master_ufo') 113 ttx_dir = self.get_test_input('master_ttx_interpolatable_ttf') 114 115 self.temp_dir() 116 ttx_paths = self.get_file_list(ttx_dir, '.ttx', font_name + '-') 117 for path in ttx_paths: 118 self.compile_font(path, suffix, self.tempdir) 119 120 finder = lambda s: s.replace(ufo_dir, self.tempdir).replace('.ufo', suffix) 121 varfont, model, _ = build(ds_path, finder) 122 123 if save_before_dump: 124 # some data (e.g. counts printed in TTX inline comments) is only 125 # calculated at compile time, so before we can compare the TTX 126 # dumps we need to save to a temporary stream, and realod the font 127 varfont = reload_font(varfont) 128 129 expected_ttx_path = self.get_test_output(expected_ttx_name + '.ttx') 130 self.expect_ttx(varfont, expected_ttx_path, tables) 131 self.check_ttx_dump(varfont, expected_ttx_path, tables, suffix) 132# ----- 133# Tests 134# ----- 135 136 def test_varlib_build_ttf(self): 137 """Designspace file contains <axes> element.""" 138 self._run_varlib_build_test( 139 designspace_name='Build', 140 font_name='TestFamily', 141 tables=['GDEF', 'HVAR', 'MVAR', 'fvar', 'gvar'], 142 expected_ttx_name='Build' 143 ) 144 145 def test_varlib_build_no_axes_ttf(self): 146 """Designspace file does not contain an <axes> element.""" 147 ds_path = self.get_test_input('InterpolateLayout3.designspace') 148 with self.assertRaisesRegex(DesignSpaceDocumentError, "No axes defined"): 149 build(ds_path) 150 151 def test_varlib_avar_single_axis(self): 152 """Designspace file contains a 'weight' axis with <map> elements 153 modifying the normalization mapping. An 'avar' table is generated. 154 """ 155 test_name = 'BuildAvarSingleAxis' 156 self._run_varlib_build_test( 157 designspace_name=test_name, 158 font_name='TestFamily3', 159 tables=['avar'], 160 expected_ttx_name=test_name 161 ) 162 163 def test_varlib_avar_with_identity_maps(self): 164 """Designspace file contains two 'weight' and 'width' axes both with 165 <map> elements. 166 167 The 'width' axis only contains identity mappings, however the resulting 168 avar segment will not be empty but will contain the default axis value 169 maps: {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}. 170 171 This is to work around an issue with some rasterizers: 172 https://github.com/googlei18n/fontmake/issues/295 173 https://github.com/fonttools/fonttools/issues/1011 174 """ 175 test_name = 'BuildAvarIdentityMaps' 176 self._run_varlib_build_test( 177 designspace_name=test_name, 178 font_name='TestFamily3', 179 tables=['avar'], 180 expected_ttx_name=test_name 181 ) 182 183 def test_varlib_avar_empty_axis(self): 184 """Designspace file contains two 'weight' and 'width' axes, but 185 only one axis ('weight') has some <map> elements. 186 187 Even if no <map> elements are defined for the 'width' axis, the 188 resulting avar segment still contains the default axis value maps: 189 {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}. 190 191 This is again to work around an issue with some rasterizers: 192 https://github.com/googlei18n/fontmake/issues/295 193 https://github.com/fonttools/fonttools/issues/1011 194 """ 195 test_name = 'BuildAvarEmptyAxis' 196 self._run_varlib_build_test( 197 designspace_name=test_name, 198 font_name='TestFamily3', 199 tables=['avar'], 200 expected_ttx_name=test_name 201 ) 202 203 def test_varlib_build_feature_variations(self): 204 """Designspace file contains <rules> element, used to build 205 GSUB FeatureVariations table. 206 """ 207 self._run_varlib_build_test( 208 designspace_name="FeatureVars", 209 font_name="TestFamily", 210 tables=["fvar", "GSUB"], 211 expected_ttx_name="FeatureVars", 212 save_before_dump=True, 213 ) 214 215 def test_varlib_gvar_explicit_delta(self): 216 """The variable font contains a composite glyph odieresis which does not 217 need a gvar entry, because all its deltas are 0, but it must be added 218 anyway to work around an issue with macOS 10.14. 219 220 https://github.com/fonttools/fonttools/issues/1381 221 """ 222 test_name = 'BuildGvarCompositeExplicitDelta' 223 self._run_varlib_build_test( 224 designspace_name=test_name, 225 font_name='TestFamily4', 226 tables=['gvar'], 227 expected_ttx_name=test_name 228 ) 229 230 def test_varlib_build_CFF2(self): 231 ds_path = self.get_test_input('TestCFF2.designspace') 232 suffix = '.otf' 233 expected_ttx_name = 'BuildTestCFF2' 234 tables = ["fvar", "CFF2"] 235 236 finder = lambda s: s.replace('.ufo', suffix) 237 varfont, model, _ = build(ds_path, finder) 238 # some data (e.g. counts printed in TTX inline comments) is only 239 # calculated at compile time, so before we can compare the TTX 240 # dumps we need to save to a temporary stream, and realod the font 241 varfont = reload_font(varfont) 242 243 expected_ttx_path = self.get_test_output(expected_ttx_name + '.ttx') 244 self.expect_ttx(varfont, expected_ttx_path, tables) 245 self.check_ttx_dump(varfont, expected_ttx_path, tables, suffix) 246 247 def test_varlib_main_ttf(self): 248 """Mostly for testing varLib.main() 249 """ 250 suffix = '.ttf' 251 ds_path = self.get_test_input('Build.designspace') 252 ttx_dir = self.get_test_input('master_ttx_interpolatable_ttf') 253 254 self.temp_dir() 255 ttf_dir = os.path.join(self.tempdir, 'master_ttf_interpolatable') 256 os.makedirs(ttf_dir) 257 ttx_paths = self.get_file_list(ttx_dir, '.ttx', 'TestFamily-') 258 for path in ttx_paths: 259 self.compile_font(path, suffix, ttf_dir) 260 261 ds_copy = os.path.join(self.tempdir, 'BuildMain.designspace') 262 shutil.copy2(ds_path, ds_copy) 263 264 # by default, varLib.main finds master TTFs inside a 265 # 'master_ttf_interpolatable' subfolder in current working dir 266 cwd = os.getcwd() 267 os.chdir(self.tempdir) 268 try: 269 varLib_main([ds_copy]) 270 finally: 271 os.chdir(cwd) 272 273 varfont_path = os.path.splitext(ds_copy)[0] + '-VF' + suffix 274 self.assertTrue(os.path.exists(varfont_path)) 275 276 # try again passing an explicit --master-finder 277 os.remove(varfont_path) 278 finder = "%s/master_ttf_interpolatable/{stem}.ttf" % self.tempdir 279 varLib_main([ds_copy, "--master-finder", finder]) 280 self.assertTrue(os.path.exists(varfont_path)) 281 282 # and also with explicit -o output option 283 os.remove(varfont_path) 284 varfont_path = os.path.splitext(varfont_path)[0] + "-o" + suffix 285 varLib_main([ds_copy, "-o", varfont_path, "--master-finder", finder]) 286 self.assertTrue(os.path.exists(varfont_path)) 287 288 varfont = TTFont(varfont_path) 289 tables = [table_tag for table_tag in varfont.keys() if table_tag != 'head'] 290 expected_ttx_path = self.get_test_output('BuildMain.ttx') 291 self.expect_ttx(varfont, expected_ttx_path, tables) 292 293 def test_varlib_build_from_ds_object_in_memory_ttfonts(self): 294 ds_path = self.get_test_input("Build.designspace") 295 ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf") 296 expected_ttx_path = self.get_test_output("BuildMain.ttx") 297 298 self.temp_dir() 299 for path in self.get_file_list(ttx_dir, '.ttx', 'TestFamily-'): 300 self.compile_font(path, ".ttf", self.tempdir) 301 302 ds = DesignSpaceDocument.fromfile(ds_path) 303 for source in ds.sources: 304 filename = os.path.join( 305 self.tempdir, os.path.basename(source.filename).replace(".ufo", ".ttf") 306 ) 307 source.font = TTFont( 308 filename, recalcBBoxes=False, recalcTimestamp=False, lazy=True 309 ) 310 source.filename = None # Make sure no file path gets into build() 311 312 varfont, _, _ = build(ds) 313 varfont = reload_font(varfont) 314 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 315 self.expect_ttx(varfont, expected_ttx_path, tables) 316 317 def test_varlib_build_from_ttf_paths(self): 318 ds_path = self.get_test_input("Build.designspace") 319 ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf") 320 expected_ttx_path = self.get_test_output("BuildMain.ttx") 321 322 self.temp_dir() 323 for path in self.get_file_list(ttx_dir, '.ttx', 'TestFamily-'): 324 self.compile_font(path, ".ttf", self.tempdir) 325 326 ds = DesignSpaceDocument.fromfile(ds_path) 327 for source in ds.sources: 328 source.path = os.path.join( 329 self.tempdir, os.path.basename(source.filename).replace(".ufo", ".ttf") 330 ) 331 ds.updatePaths() 332 333 varfont, _, _ = build(ds) 334 varfont = reload_font(varfont) 335 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 336 self.expect_ttx(varfont, expected_ttx_path, tables) 337 338 def test_varlib_build_from_ttx_paths(self): 339 ds_path = self.get_test_input("Build.designspace") 340 ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf") 341 expected_ttx_path = self.get_test_output("BuildMain.ttx") 342 343 ds = DesignSpaceDocument.fromfile(ds_path) 344 for source in ds.sources: 345 source.path = os.path.join( 346 ttx_dir, os.path.basename(source.filename).replace(".ufo", ".ttx") 347 ) 348 ds.updatePaths() 349 350 varfont, _, _ = build(ds) 351 varfont = reload_font(varfont) 352 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 353 self.expect_ttx(varfont, expected_ttx_path, tables) 354 355 def test_varlib_build_sparse_masters(self): 356 ds_path = self.get_test_input("SparseMasters.designspace") 357 expected_ttx_path = self.get_test_output("SparseMasters.ttx") 358 359 varfont, _, _ = build(ds_path) 360 varfont = reload_font(varfont) 361 tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"] 362 self.expect_ttx(varfont, expected_ttx_path, tables) 363 364 def test_varlib_build_sparse_masters_MVAR(self): 365 import fontTools.varLib.mvar 366 367 ds_path = self.get_test_input("SparseMasters.designspace") 368 ds = DesignSpaceDocument.fromfile(ds_path) 369 load_masters(ds) 370 371 # Trigger MVAR generation so varLib is forced to create deltas with a 372 # sparse master inbetween. 373 font_0_os2 = ds.sources[0].font["OS/2"] 374 font_0_os2.sTypoAscender = 1 375 font_0_os2.sTypoDescender = 1 376 font_0_os2.sTypoLineGap = 1 377 font_0_os2.usWinAscent = 1 378 font_0_os2.usWinDescent = 1 379 font_0_os2.sxHeight = 1 380 font_0_os2.sCapHeight = 1 381 font_0_os2.ySubscriptXSize = 1 382 font_0_os2.ySubscriptYSize = 1 383 font_0_os2.ySubscriptXOffset = 1 384 font_0_os2.ySubscriptYOffset = 1 385 font_0_os2.ySuperscriptXSize = 1 386 font_0_os2.ySuperscriptYSize = 1 387 font_0_os2.ySuperscriptXOffset = 1 388 font_0_os2.ySuperscriptYOffset = 1 389 font_0_os2.yStrikeoutSize = 1 390 font_0_os2.yStrikeoutPosition = 1 391 font_0_vhea = newTable("vhea") 392 font_0_vhea.ascent = 1 393 font_0_vhea.descent = 1 394 font_0_vhea.lineGap = 1 395 font_0_vhea.caretSlopeRise = 1 396 font_0_vhea.caretSlopeRun = 1 397 font_0_vhea.caretOffset = 1 398 ds.sources[0].font["vhea"] = font_0_vhea 399 font_0_hhea = ds.sources[0].font["hhea"] 400 font_0_hhea.caretSlopeRise = 1 401 font_0_hhea.caretSlopeRun = 1 402 font_0_hhea.caretOffset = 1 403 font_0_post = ds.sources[0].font["post"] 404 font_0_post.underlineThickness = 1 405 font_0_post.underlinePosition = 1 406 407 font_2_os2 = ds.sources[2].font["OS/2"] 408 font_2_os2.sTypoAscender = 800 409 font_2_os2.sTypoDescender = 800 410 font_2_os2.sTypoLineGap = 800 411 font_2_os2.usWinAscent = 800 412 font_2_os2.usWinDescent = 800 413 font_2_os2.sxHeight = 800 414 font_2_os2.sCapHeight = 800 415 font_2_os2.ySubscriptXSize = 800 416 font_2_os2.ySubscriptYSize = 800 417 font_2_os2.ySubscriptXOffset = 800 418 font_2_os2.ySubscriptYOffset = 800 419 font_2_os2.ySuperscriptXSize = 800 420 font_2_os2.ySuperscriptYSize = 800 421 font_2_os2.ySuperscriptXOffset = 800 422 font_2_os2.ySuperscriptYOffset = 800 423 font_2_os2.yStrikeoutSize = 800 424 font_2_os2.yStrikeoutPosition = 800 425 font_2_vhea = newTable("vhea") 426 font_2_vhea.ascent = 800 427 font_2_vhea.descent = 800 428 font_2_vhea.lineGap = 800 429 font_2_vhea.caretSlopeRise = 800 430 font_2_vhea.caretSlopeRun = 800 431 font_2_vhea.caretOffset = 800 432 ds.sources[2].font["vhea"] = font_2_vhea 433 font_2_hhea = ds.sources[2].font["hhea"] 434 font_2_hhea.caretSlopeRise = 800 435 font_2_hhea.caretSlopeRun = 800 436 font_2_hhea.caretOffset = 800 437 font_2_post = ds.sources[2].font["post"] 438 font_2_post.underlineThickness = 800 439 font_2_post.underlinePosition = 800 440 441 varfont, _, _ = build(ds) 442 mvar_tags = [vr.ValueTag for vr in varfont["MVAR"].table.ValueRecord] 443 assert all(tag in mvar_tags for tag in fontTools.varLib.mvar.MVAR_ENTRIES) 444 445 446def test_load_masters_layerName_without_required_font(): 447 ds = DesignSpaceDocument() 448 s = SourceDescriptor() 449 s.font = None 450 s.layerName = "Medium" 451 ds.addSource(s) 452 453 with pytest.raises( 454 AttributeError, 455 match="specified a layer name but lacks the required TTFont object", 456 ): 457 load_masters(ds) 458 459 460if __name__ == "__main__": 461 sys.exit(unittest.main()) 462