1from fontTools.misc.fixedTools import floatToFixedToFloat 2from fontTools.misc.textTools import Tag 3from fontTools import ttLib 4from fontTools import designspaceLib 5from fontTools.feaLib.builder import addOpenTypeFeaturesFromString 6from fontTools.ttLib.tables import _f_v_a_r, _g_l_y_f 7from fontTools.ttLib.tables import otTables 8from fontTools.ttLib.tables.TupleVariation import TupleVariation 9from fontTools import varLib 10from fontTools.varLib import instancer 11from fontTools.varLib.mvar import MVAR_ENTRIES 12from fontTools.varLib import builder 13from fontTools.varLib import featureVars 14from fontTools.varLib import models 15import collections 16from copy import deepcopy 17from io import BytesIO, StringIO 18import logging 19import os 20import re 21from types import SimpleNamespace 22import pytest 23 24 25# see Tests/varLib/instancer/conftest.py for "varfont" fixture definition 26 27TESTDATA = os.path.join(os.path.dirname(__file__), "data") 28 29 30@pytest.fixture(params=[True, False], ids=["optimize", "no-optimize"]) 31def optimize(request): 32 return request.param 33 34 35@pytest.fixture 36def fvarAxes(): 37 wght = _f_v_a_r.Axis() 38 wght.axisTag = Tag("wght") 39 wght.minValue = 100 40 wght.defaultValue = 400 41 wght.maxValue = 900 42 wdth = _f_v_a_r.Axis() 43 wdth.axisTag = Tag("wdth") 44 wdth.minValue = 70 45 wdth.defaultValue = 100 46 wdth.maxValue = 100 47 return [wght, wdth] 48 49 50def _get_coordinates(varfont, glyphname): 51 # converts GlyphCoordinates to a list of (x, y) tuples, so that pytest's 52 # assert will give us a nicer diff 53 return list(varfont["glyf"].getCoordinatesAndControls(glyphname, varfont)[0]) 54 55 56class InstantiateGvarTest(object): 57 @pytest.mark.parametrize("glyph_name", ["hyphen"]) 58 @pytest.mark.parametrize( 59 "location, expected", 60 [ 61 pytest.param( 62 {"wdth": -1.0}, 63 { 64 "hyphen": [ 65 (27, 229), 66 (27, 310), 67 (247, 310), 68 (247, 229), 69 (0, 0), 70 (274, 0), 71 (0, 536), 72 (0, 0), 73 ] 74 }, 75 id="wdth=-1.0", 76 ), 77 pytest.param( 78 {"wdth": -0.5}, 79 { 80 "hyphen": [ 81 (33.5, 229), 82 (33.5, 308.5), 83 (264.5, 308.5), 84 (264.5, 229), 85 (0, 0), 86 (298, 0), 87 (0, 536), 88 (0, 0), 89 ] 90 }, 91 id="wdth=-0.5", 92 ), 93 # an axis pinned at the default normalized location (0.0) means 94 # the default glyf outline stays the same 95 pytest.param( 96 {"wdth": 0.0}, 97 { 98 "hyphen": [ 99 (40, 229), 100 (40, 307), 101 (282, 307), 102 (282, 229), 103 (0, 0), 104 (322, 0), 105 (0, 536), 106 (0, 0), 107 ] 108 }, 109 id="wdth=0.0", 110 ), 111 ], 112 ) 113 def test_pin_and_drop_axis(self, varfont, glyph_name, location, expected, optimize): 114 instancer.instantiateGvar(varfont, location, optimize=optimize) 115 116 assert _get_coordinates(varfont, glyph_name) == expected[glyph_name] 117 118 # check that the pinned axis has been dropped from gvar 119 assert not any( 120 "wdth" in t.axes 121 for tuples in varfont["gvar"].variations.values() 122 for t in tuples 123 ) 124 125 def test_full_instance(self, varfont, optimize): 126 instancer.instantiateGvar( 127 varfont, {"wght": 0.0, "wdth": -0.5}, optimize=optimize 128 ) 129 130 assert _get_coordinates(varfont, "hyphen") == [ 131 (33.5, 229), 132 (33.5, 308.5), 133 (264.5, 308.5), 134 (264.5, 229), 135 (0, 0), 136 (298, 0), 137 (0, 536), 138 (0, 0), 139 ] 140 141 assert "gvar" not in varfont 142 143 def test_composite_glyph_not_in_gvar(self, varfont): 144 """The 'minus' glyph is a composite glyph, which references 'hyphen' as a 145 component, but has no tuple variations in gvar table, so the component offset 146 and the phantom points do not change; however the sidebearings and bounding box 147 do change as a result of the parent glyph 'hyphen' changing. 148 """ 149 hmtx = varfont["hmtx"] 150 vmtx = varfont["vmtx"] 151 152 hyphenCoords = _get_coordinates(varfont, "hyphen") 153 assert hyphenCoords == [ 154 (40, 229), 155 (40, 307), 156 (282, 307), 157 (282, 229), 158 (0, 0), 159 (322, 0), 160 (0, 536), 161 (0, 0), 162 ] 163 assert hmtx["hyphen"] == (322, 40) 164 assert vmtx["hyphen"] == (536, 229) 165 166 minusCoords = _get_coordinates(varfont, "minus") 167 assert minusCoords == [(0, 0), (0, 0), (422, 0), (0, 536), (0, 0)] 168 assert hmtx["minus"] == (422, 40) 169 assert vmtx["minus"] == (536, 229) 170 171 location = {"wght": -1.0, "wdth": -1.0} 172 173 instancer.instantiateGvar(varfont, location) 174 175 # check 'hyphen' coordinates changed 176 assert _get_coordinates(varfont, "hyphen") == [ 177 (26, 259), 178 (26, 286), 179 (237, 286), 180 (237, 259), 181 (0, 0), 182 (263, 0), 183 (0, 536), 184 (0, 0), 185 ] 186 # check 'minus' coordinates (i.e. component offset and phantom points) 187 # did _not_ change 188 assert _get_coordinates(varfont, "minus") == minusCoords 189 190 assert hmtx["hyphen"] == (263, 26) 191 assert vmtx["hyphen"] == (536, 250) 192 193 assert hmtx["minus"] == (422, 26) # 'minus' left sidebearing changed 194 assert vmtx["minus"] == (536, 250) # 'minus' top sidebearing too 195 196 197class InstantiateCvarTest(object): 198 @pytest.mark.parametrize( 199 "location, expected", 200 [ 201 pytest.param({"wght": -1.0}, [500, -400, 150, 250], id="wght=-1.0"), 202 pytest.param({"wdth": -1.0}, [500, -400, 180, 200], id="wdth=-1.0"), 203 pytest.param({"wght": -0.5}, [500, -400, 165, 250], id="wght=-0.5"), 204 pytest.param({"wdth": -0.3}, [500, -400, 180, 235], id="wdth=-0.3"), 205 ], 206 ) 207 def test_pin_and_drop_axis(self, varfont, location, expected): 208 instancer.instantiateCvar(varfont, location) 209 210 assert list(varfont["cvt "].values) == expected 211 212 # check that the pinned axis has been dropped from cvar 213 pinned_axes = location.keys() 214 assert not any( 215 axis in t.axes for t in varfont["cvar"].variations for axis in pinned_axes 216 ) 217 218 def test_full_instance(self, varfont): 219 instancer.instantiateCvar(varfont, {"wght": -0.5, "wdth": -0.5}) 220 221 assert list(varfont["cvt "].values) == [500, -400, 165, 225] 222 223 assert "cvar" not in varfont 224 225 226class InstantiateMVARTest(object): 227 @pytest.mark.parametrize( 228 "location, expected", 229 [ 230 pytest.param( 231 {"wght": 1.0}, 232 {"strs": 100, "undo": -200, "unds": 150, "xhgt": 530}, 233 id="wght=1.0", 234 ), 235 pytest.param( 236 {"wght": 0.5}, 237 {"strs": 75, "undo": -150, "unds": 100, "xhgt": 515}, 238 id="wght=0.5", 239 ), 240 pytest.param( 241 {"wght": 0.0}, 242 {"strs": 50, "undo": -100, "unds": 50, "xhgt": 500}, 243 id="wght=0.0", 244 ), 245 pytest.param( 246 {"wdth": -1.0}, 247 {"strs": 20, "undo": -100, "unds": 50, "xhgt": 500}, 248 id="wdth=-1.0", 249 ), 250 pytest.param( 251 {"wdth": -0.5}, 252 {"strs": 35, "undo": -100, "unds": 50, "xhgt": 500}, 253 id="wdth=-0.5", 254 ), 255 pytest.param( 256 {"wdth": 0.0}, 257 {"strs": 50, "undo": -100, "unds": 50, "xhgt": 500}, 258 id="wdth=0.0", 259 ), 260 ], 261 ) 262 def test_pin_and_drop_axis(self, varfont, location, expected): 263 mvar = varfont["MVAR"].table 264 # initially we have two VarData: the first contains deltas associated with 3 265 # regions: 1 with only wght, 1 with only wdth, and 1 with both wght and wdth 266 assert len(mvar.VarStore.VarData) == 2 267 assert mvar.VarStore.VarRegionList.RegionCount == 3 268 assert mvar.VarStore.VarData[0].VarRegionCount == 3 269 assert all(len(item) == 3 for item in mvar.VarStore.VarData[0].Item) 270 # The second VarData has deltas associated only with 1 region (wght only). 271 assert mvar.VarStore.VarData[1].VarRegionCount == 1 272 assert all(len(item) == 1 for item in mvar.VarStore.VarData[1].Item) 273 274 instancer.instantiateMVAR(varfont, location) 275 276 for mvar_tag, expected_value in expected.items(): 277 table_tag, item_name = MVAR_ENTRIES[mvar_tag] 278 assert getattr(varfont[table_tag], item_name) == expected_value 279 280 # check that regions and accompanying deltas have been dropped 281 num_regions_left = len(mvar.VarStore.VarRegionList.Region) 282 assert num_regions_left < 3 283 assert mvar.VarStore.VarRegionList.RegionCount == num_regions_left 284 assert mvar.VarStore.VarData[0].VarRegionCount == num_regions_left 285 # VarData subtables have been merged 286 assert len(mvar.VarStore.VarData) == 1 287 288 @pytest.mark.parametrize( 289 "location, expected", 290 [ 291 pytest.param( 292 {"wght": 1.0, "wdth": 0.0}, 293 {"strs": 100, "undo": -200, "unds": 150}, 294 id="wght=1.0,wdth=0.0", 295 ), 296 pytest.param( 297 {"wght": 0.0, "wdth": -1.0}, 298 {"strs": 20, "undo": -100, "unds": 50}, 299 id="wght=0.0,wdth=-1.0", 300 ), 301 pytest.param( 302 {"wght": 0.5, "wdth": -0.5}, 303 {"strs": 55, "undo": -145, "unds": 95}, 304 id="wght=0.5,wdth=-0.5", 305 ), 306 pytest.param( 307 {"wght": 1.0, "wdth": -1.0}, 308 {"strs": 50, "undo": -180, "unds": 130}, 309 id="wght=0.5,wdth=-0.5", 310 ), 311 ], 312 ) 313 def test_full_instance(self, varfont, location, expected): 314 instancer.instantiateMVAR(varfont, location) 315 316 for mvar_tag, expected_value in expected.items(): 317 table_tag, item_name = MVAR_ENTRIES[mvar_tag] 318 assert getattr(varfont[table_tag], item_name) == expected_value 319 320 assert "MVAR" not in varfont 321 322 323class InstantiateHVARTest(object): 324 # the 'expectedDeltas' below refer to the VarData item deltas for the "hyphen" 325 # glyph in the PartialInstancerTest-VF.ttx test font, that are left after 326 # partial instancing 327 @pytest.mark.parametrize( 328 "location, expectedRegions, expectedDeltas", 329 [ 330 ({"wght": -1.0}, [{"wdth": (-1.0, -1.0, 0)}], [-59]), 331 ({"wght": 0}, [{"wdth": (-1.0, -1.0, 0)}], [-48]), 332 ({"wght": 1.0}, [{"wdth": (-1.0, -1.0, 0)}], [7]), 333 ( 334 {"wdth": -1.0}, 335 [ 336 {"wght": (-1.0, -1.0, 0.0)}, 337 {"wght": (0.0, 0.6099854, 1.0)}, 338 {"wght": (0.6099854, 1.0, 1.0)}, 339 ], 340 [-11, 31, 51], 341 ), 342 ({"wdth": 0}, [{"wght": (0.6099854, 1.0, 1.0)}], [-4]), 343 ], 344 ) 345 def test_partial_instance(self, varfont, location, expectedRegions, expectedDeltas): 346 instancer.instantiateHVAR(varfont, location) 347 348 assert "HVAR" in varfont 349 hvar = varfont["HVAR"].table 350 varStore = hvar.VarStore 351 352 regions = varStore.VarRegionList.Region 353 fvarAxes = [a for a in varfont["fvar"].axes if a.axisTag not in location] 354 regionDicts = [reg.get_support(fvarAxes) for reg in regions] 355 assert len(regionDicts) == len(expectedRegions) 356 for region, expectedRegion in zip(regionDicts, expectedRegions): 357 assert region.keys() == expectedRegion.keys() 358 for axisTag, support in region.items(): 359 assert support == pytest.approx(expectedRegion[axisTag]) 360 361 assert len(varStore.VarData) == 1 362 assert varStore.VarData[0].ItemCount == 2 363 364 assert hvar.AdvWidthMap is not None 365 advWithMap = hvar.AdvWidthMap.mapping 366 367 assert advWithMap[".notdef"] == advWithMap["space"] 368 varIdx = advWithMap[".notdef"] 369 # these glyphs have no metrics variations in the test font 370 assert varStore.VarData[varIdx >> 16].Item[varIdx & 0xFFFF] == ( 371 [0] * varStore.VarData[0].VarRegionCount 372 ) 373 374 varIdx = advWithMap["hyphen"] 375 assert varStore.VarData[varIdx >> 16].Item[varIdx & 0xFFFF] == expectedDeltas 376 377 def test_full_instance(self, varfont): 378 instancer.instantiateHVAR(varfont, {"wght": 0, "wdth": 0}) 379 380 assert "HVAR" not in varfont 381 382 def test_partial_instance_keep_empty_table(self, varfont): 383 # Append an additional dummy axis to fvar, for which the current HVAR table 384 # in our test 'varfont' contains no variation data. 385 # Instancing the other two wght and wdth axes should leave HVAR table empty, 386 # to signal there are variations to the glyph's advance widths. 387 fvar = varfont["fvar"] 388 axis = _f_v_a_r.Axis() 389 axis.axisTag = "TEST" 390 fvar.axes.append(axis) 391 392 instancer.instantiateHVAR(varfont, {"wght": 0, "wdth": 0}) 393 394 assert "HVAR" in varfont 395 396 varStore = varfont["HVAR"].table.VarStore 397 398 assert varStore.VarRegionList.RegionCount == 0 399 assert not varStore.VarRegionList.Region 400 assert varStore.VarRegionList.RegionAxisCount == 1 401 402 403class InstantiateItemVariationStoreTest(object): 404 def test_VarRegion_get_support(self): 405 axisOrder = ["wght", "wdth", "opsz"] 406 regionAxes = {"wdth": (-1.0, -1.0, 0.0), "wght": (0.0, 1.0, 1.0)} 407 region = builder.buildVarRegion(regionAxes, axisOrder) 408 409 assert len(region.VarRegionAxis) == 3 410 assert region.VarRegionAxis[2].PeakCoord == 0 411 412 fvarAxes = [SimpleNamespace(axisTag=axisTag) for axisTag in axisOrder] 413 414 assert region.get_support(fvarAxes) == regionAxes 415 416 @pytest.fixture 417 def varStore(self): 418 return builder.buildVarStore( 419 builder.buildVarRegionList( 420 [ 421 {"wght": (-1.0, -1.0, 0)}, 422 {"wght": (0, 0.5, 1.0)}, 423 {"wght": (0.5, 1.0, 1.0)}, 424 {"wdth": (-1.0, -1.0, 0)}, 425 {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, 426 {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, 427 {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, 428 ], 429 ["wght", "wdth"], 430 ), 431 [ 432 builder.buildVarData([0, 1, 2], [[100, 100, 100], [100, 100, 100]]), 433 builder.buildVarData( 434 [3, 4, 5, 6], [[100, 100, 100, 100], [100, 100, 100, 100]] 435 ), 436 ], 437 ) 438 439 @pytest.mark.parametrize( 440 "location, expected_deltas, num_regions", 441 [ 442 ({"wght": 0}, [[0, 0], [0, 0]], 1), 443 ({"wght": 0.25}, [[50, 50], [0, 0]], 1), 444 ({"wdth": 0}, [[0, 0], [0, 0]], 3), 445 ({"wdth": -0.75}, [[0, 0], [75, 75]], 3), 446 ({"wght": 0, "wdth": 0}, [[0, 0], [0, 0]], 0), 447 ({"wght": 0.25, "wdth": 0}, [[50, 50], [0, 0]], 0), 448 ({"wght": 0, "wdth": -0.75}, [[0, 0], [75, 75]], 0), 449 ], 450 ) 451 def test_instantiate_default_deltas( 452 self, varStore, fvarAxes, location, expected_deltas, num_regions 453 ): 454 defaultDeltas = instancer.instantiateItemVariationStore( 455 varStore, fvarAxes, location 456 ) 457 458 defaultDeltaArray = [] 459 for varidx, delta in sorted(defaultDeltas.items()): 460 major, minor = varidx >> 16, varidx & 0xFFFF 461 if major == len(defaultDeltaArray): 462 defaultDeltaArray.append([]) 463 assert len(defaultDeltaArray[major]) == minor 464 defaultDeltaArray[major].append(delta) 465 466 assert defaultDeltaArray == expected_deltas 467 assert varStore.VarRegionList.RegionCount == num_regions 468 469 470class TupleVarStoreAdapterTest(object): 471 def test_instantiate(self): 472 regions = [ 473 {"wght": (-1.0, -1.0, 0)}, 474 {"wght": (0.0, 1.0, 1.0)}, 475 {"wdth": (-1.0, -1.0, 0)}, 476 {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, 477 {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, 478 ] 479 axisOrder = ["wght", "wdth"] 480 tupleVarData = [ 481 [ 482 TupleVariation({"wght": (-1.0, -1.0, 0)}, [10, 70]), 483 TupleVariation({"wght": (0.0, 1.0, 1.0)}, [30, 90]), 484 TupleVariation( 485 {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-40, -100] 486 ), 487 TupleVariation( 488 {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-60, -120] 489 ), 490 ], 491 [ 492 TupleVariation({"wdth": (-1.0, -1.0, 0)}, [5, 45]), 493 TupleVariation( 494 {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-15, -55] 495 ), 496 TupleVariation( 497 {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-35, -75] 498 ), 499 ], 500 ] 501 adapter = instancer._TupleVarStoreAdapter( 502 regions, axisOrder, tupleVarData, itemCounts=[2, 2] 503 ) 504 505 defaultDeltaArray = adapter.instantiate({"wght": 0.5}) 506 507 assert defaultDeltaArray == [[15, 45], [0, 0]] 508 assert adapter.regions == [{"wdth": (-1.0, -1.0, 0)}] 509 assert adapter.tupleVarData == [ 510 [TupleVariation({"wdth": (-1.0, -1.0, 0)}, [-30, -60])], 511 [TupleVariation({"wdth": (-1.0, -1.0, 0)}, [-12, 8])], 512 ] 513 514 def test_rebuildRegions(self): 515 regions = [ 516 {"wght": (-1.0, -1.0, 0)}, 517 {"wght": (0.0, 1.0, 1.0)}, 518 {"wdth": (-1.0, -1.0, 0)}, 519 {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, 520 {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, 521 ] 522 axisOrder = ["wght", "wdth"] 523 variations = [] 524 for region in regions: 525 variations.append(TupleVariation(region, [100])) 526 tupleVarData = [variations[:3], variations[3:]] 527 adapter = instancer._TupleVarStoreAdapter( 528 regions, axisOrder, tupleVarData, itemCounts=[1, 1] 529 ) 530 531 adapter.rebuildRegions() 532 533 assert adapter.regions == regions 534 535 del tupleVarData[0][2] 536 tupleVarData[1][0].axes = {"wght": (-1.0, -0.5, 0)} 537 tupleVarData[1][1].axes = {"wght": (0, 0.5, 1.0)} 538 539 adapter.rebuildRegions() 540 541 assert adapter.regions == [ 542 {"wght": (-1.0, -1.0, 0)}, 543 {"wght": (0.0, 1.0, 1.0)}, 544 {"wght": (-1.0, -0.5, 0)}, 545 {"wght": (0, 0.5, 1.0)}, 546 ] 547 548 def test_roundtrip(self, fvarAxes): 549 regions = [ 550 {"wght": (-1.0, -1.0, 0)}, 551 {"wght": (0, 0.5, 1.0)}, 552 {"wght": (0.5, 1.0, 1.0)}, 553 {"wdth": (-1.0, -1.0, 0)}, 554 {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, 555 {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, 556 {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, 557 ] 558 axisOrder = [axis.axisTag for axis in fvarAxes] 559 560 itemVarStore = builder.buildVarStore( 561 builder.buildVarRegionList(regions, axisOrder), 562 [ 563 builder.buildVarData( 564 [0, 1, 2, 4, 5, 6], 565 [[10, -20, 30, -40, 50, -60], [70, -80, 90, -100, 110, -120]], 566 ), 567 builder.buildVarData( 568 [3, 4, 5, 6], [[5, -15, 25, -35], [45, -55, 65, -75]] 569 ), 570 ], 571 ) 572 573 adapter = instancer._TupleVarStoreAdapter.fromItemVarStore( 574 itemVarStore, fvarAxes 575 ) 576 577 assert adapter.tupleVarData == [ 578 [ 579 TupleVariation({"wght": (-1.0, -1.0, 0)}, [10, 70]), 580 TupleVariation({"wght": (0, 0.5, 1.0)}, [-20, -80]), 581 TupleVariation({"wght": (0.5, 1.0, 1.0)}, [30, 90]), 582 TupleVariation( 583 {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-40, -100] 584 ), 585 TupleVariation( 586 {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, [50, 110] 587 ), 588 TupleVariation( 589 {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-60, -120] 590 ), 591 ], 592 [ 593 TupleVariation({"wdth": (-1.0, -1.0, 0)}, [5, 45]), 594 TupleVariation( 595 {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-15, -55] 596 ), 597 TupleVariation( 598 {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, [25, 65] 599 ), 600 TupleVariation( 601 {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-35, -75] 602 ), 603 ], 604 ] 605 assert adapter.itemCounts == [data.ItemCount for data in itemVarStore.VarData] 606 assert adapter.regions == regions 607 assert adapter.axisOrder == axisOrder 608 609 itemVarStore2 = adapter.asItemVarStore() 610 611 assert [ 612 reg.get_support(fvarAxes) for reg in itemVarStore2.VarRegionList.Region 613 ] == regions 614 615 assert itemVarStore2.VarDataCount == 2 616 assert itemVarStore2.VarData[0].VarRegionIndex == [0, 1, 2, 4, 5, 6] 617 assert itemVarStore2.VarData[0].Item == [ 618 [10, -20, 30, -40, 50, -60], 619 [70, -80, 90, -100, 110, -120], 620 ] 621 assert itemVarStore2.VarData[1].VarRegionIndex == [3, 4, 5, 6] 622 assert itemVarStore2.VarData[1].Item == [[5, -15, 25, -35], [45, -55, 65, -75]] 623 624 625def makeTTFont(glyphOrder, features): 626 font = ttLib.TTFont() 627 font.setGlyphOrder(glyphOrder) 628 addOpenTypeFeaturesFromString(font, features) 629 font["name"] = ttLib.newTable("name") 630 return font 631 632 633def _makeDSAxesDict(axes): 634 dsAxes = collections.OrderedDict() 635 for axisTag, axisValues in axes: 636 axis = designspaceLib.AxisDescriptor() 637 axis.name = axis.tag = axis.labelNames["en"] = axisTag 638 axis.minimum, axis.default, axis.maximum = axisValues 639 dsAxes[axis.tag] = axis 640 return dsAxes 641 642 643def makeVariableFont(masters, baseIndex, axes, masterLocations): 644 vf = deepcopy(masters[baseIndex]) 645 dsAxes = _makeDSAxesDict(axes) 646 fvar = varLib._add_fvar(vf, dsAxes, instances=()) 647 axisTags = [axis.axisTag for axis in fvar.axes] 648 normalizedLocs = [models.normalizeLocation(m, dict(axes)) for m in masterLocations] 649 model = models.VariationModel(normalizedLocs, axisOrder=axisTags) 650 varLib._merge_OTL(vf, model, masters, axisTags) 651 return vf 652 653 654def makeParametrizedVF(glyphOrder, features, values, increments): 655 # Create a test VF with given glyphs and parametrized OTL features. 656 # The VF is built from 9 masters (3 x 3 along wght and wdth), with 657 # locations hard-coded and base master at wght=400 and wdth=100. 658 # 'values' is a list of initial values that are interpolated in the 659 # 'features' string, and incremented for each subsequent master by the 660 # given 'increments' (list of 2-tuple) along the two axes. 661 assert values and len(values) == len(increments) 662 assert all(len(i) == 2 for i in increments) 663 masterLocations = [ 664 {"wght": 100, "wdth": 50}, 665 {"wght": 100, "wdth": 100}, 666 {"wght": 100, "wdth": 150}, 667 {"wght": 400, "wdth": 50}, 668 {"wght": 400, "wdth": 100}, # base master 669 {"wght": 400, "wdth": 150}, 670 {"wght": 700, "wdth": 50}, 671 {"wght": 700, "wdth": 100}, 672 {"wght": 700, "wdth": 150}, 673 ] 674 n = len(values) 675 values = list(values) 676 masters = [] 677 for _ in range(3): 678 for _ in range(3): 679 master = makeTTFont(glyphOrder, features=features % tuple(values)) 680 masters.append(master) 681 for i in range(n): 682 values[i] += increments[i][1] 683 for i in range(n): 684 values[i] += increments[i][0] 685 baseIndex = 4 686 axes = [("wght", (100, 400, 700)), ("wdth", (50, 100, 150))] 687 vf = makeVariableFont(masters, baseIndex, axes, masterLocations) 688 return vf 689 690 691@pytest.fixture 692def varfontGDEF(): 693 glyphOrder = [".notdef", "f", "i", "f_i"] 694 features = ( 695 "feature liga { sub f i by f_i;} liga;" 696 "table GDEF { LigatureCaretByPos f_i %d; } GDEF;" 697 ) 698 values = [100] 699 increments = [(+30, +10)] 700 return makeParametrizedVF(glyphOrder, features, values, increments) 701 702 703@pytest.fixture 704def varfontGPOS(): 705 glyphOrder = [".notdef", "V", "A"] 706 features = "feature kern { pos V A %d; } kern;" 707 values = [-80] 708 increments = [(-10, -5)] 709 return makeParametrizedVF(glyphOrder, features, values, increments) 710 711 712@pytest.fixture 713def varfontGPOS2(): 714 glyphOrder = [".notdef", "V", "A", "acutecomb"] 715 features = ( 716 "markClass [acutecomb] <anchor 150 -10> @TOP_MARKS;" 717 "feature mark {" 718 " pos base A <anchor %d 450> mark @TOP_MARKS;" 719 "} mark;" 720 "feature kern {" 721 " pos V A %d;" 722 "} kern;" 723 ) 724 values = [200, -80] 725 increments = [(+30, +10), (-10, -5)] 726 return makeParametrizedVF(glyphOrder, features, values, increments) 727 728 729class InstantiateOTLTest(object): 730 @pytest.mark.parametrize( 731 "location, expected", 732 [ 733 ({"wght": -1.0}, 110), # -60 734 ({"wght": 0}, 170), 735 ({"wght": 0.5}, 200), # +30 736 ({"wght": 1.0}, 230), # +60 737 ({"wdth": -1.0}, 160), # -10 738 ({"wdth": -0.3}, 167), # -3 739 ({"wdth": 0}, 170), 740 ({"wdth": 1.0}, 180), # +10 741 ], 742 ) 743 def test_pin_and_drop_axis_GDEF(self, varfontGDEF, location, expected): 744 vf = varfontGDEF 745 assert "GDEF" in vf 746 747 instancer.instantiateOTL(vf, location) 748 749 assert "GDEF" in vf 750 gdef = vf["GDEF"].table 751 assert gdef.Version == 0x00010003 752 assert gdef.VarStore 753 assert gdef.LigCaretList 754 caretValue = gdef.LigCaretList.LigGlyph[0].CaretValue[0] 755 assert caretValue.Format == 3 756 assert hasattr(caretValue, "DeviceTable") 757 assert caretValue.DeviceTable.DeltaFormat == 0x8000 758 assert caretValue.Coordinate == expected 759 760 @pytest.mark.parametrize( 761 "location, expected", 762 [ 763 ({"wght": -1.0, "wdth": -1.0}, 100), # -60 - 10 764 ({"wght": -1.0, "wdth": 0.0}, 110), # -60 765 ({"wght": -1.0, "wdth": 1.0}, 120), # -60 + 10 766 ({"wght": 0.0, "wdth": -1.0}, 160), # -10 767 ({"wght": 0.0, "wdth": 0.0}, 170), 768 ({"wght": 0.0, "wdth": 1.0}, 180), # +10 769 ({"wght": 1.0, "wdth": -1.0}, 220), # +60 - 10 770 ({"wght": 1.0, "wdth": 0.0}, 230), # +60 771 ({"wght": 1.0, "wdth": 1.0}, 240), # +60 + 10 772 ], 773 ) 774 def test_full_instance_GDEF(self, varfontGDEF, location, expected): 775 vf = varfontGDEF 776 assert "GDEF" in vf 777 778 instancer.instantiateOTL(vf, location) 779 780 assert "GDEF" in vf 781 gdef = vf["GDEF"].table 782 assert gdef.Version == 0x00010000 783 assert not hasattr(gdef, "VarStore") 784 assert gdef.LigCaretList 785 caretValue = gdef.LigCaretList.LigGlyph[0].CaretValue[0] 786 assert caretValue.Format == 1 787 assert not hasattr(caretValue, "DeviceTable") 788 assert caretValue.Coordinate == expected 789 790 @pytest.mark.parametrize( 791 "location, expected", 792 [ 793 ({"wght": -1.0}, -85), # +25 794 ({"wght": 0}, -110), 795 ({"wght": 1.0}, -135), # -25 796 ({"wdth": -1.0}, -105), # +5 797 ({"wdth": 0}, -110), 798 ({"wdth": 1.0}, -115), # -5 799 ], 800 ) 801 def test_pin_and_drop_axis_GPOS_kern(self, varfontGPOS, location, expected): 802 vf = varfontGPOS 803 assert "GDEF" in vf 804 assert "GPOS" in vf 805 806 instancer.instantiateOTL(vf, location) 807 808 gdef = vf["GDEF"].table 809 gpos = vf["GPOS"].table 810 assert gdef.Version == 0x00010003 811 assert gdef.VarStore 812 813 assert gpos.LookupList.Lookup[0].LookupType == 2 # PairPos 814 pairPos = gpos.LookupList.Lookup[0].SubTable[0] 815 valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 816 assert valueRec1.XAdvDevice 817 assert valueRec1.XAdvDevice.DeltaFormat == 0x8000 818 assert valueRec1.XAdvance == expected 819 820 @pytest.mark.parametrize( 821 "location, expected", 822 [ 823 ({"wght": -1.0, "wdth": -1.0}, -80), # +25 + 5 824 ({"wght": -1.0, "wdth": 0.0}, -85), # +25 825 ({"wght": -1.0, "wdth": 1.0}, -90), # +25 - 5 826 ({"wght": 0.0, "wdth": -1.0}, -105), # +5 827 ({"wght": 0.0, "wdth": 0.0}, -110), 828 ({"wght": 0.0, "wdth": 1.0}, -115), # -5 829 ({"wght": 1.0, "wdth": -1.0}, -130), # -25 + 5 830 ({"wght": 1.0, "wdth": 0.0}, -135), # -25 831 ({"wght": 1.0, "wdth": 1.0}, -140), # -25 - 5 832 ], 833 ) 834 def test_full_instance_GPOS_kern(self, varfontGPOS, location, expected): 835 vf = varfontGPOS 836 assert "GDEF" in vf 837 assert "GPOS" in vf 838 839 instancer.instantiateOTL(vf, location) 840 841 assert "GDEF" not in vf 842 gpos = vf["GPOS"].table 843 844 assert gpos.LookupList.Lookup[0].LookupType == 2 # PairPos 845 pairPos = gpos.LookupList.Lookup[0].SubTable[0] 846 valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 847 assert not hasattr(valueRec1, "XAdvDevice") 848 assert valueRec1.XAdvance == expected 849 850 @pytest.mark.parametrize( 851 "location, expected", 852 [ 853 ({"wght": -1.0}, (210, -85)), # -60, +25 854 ({"wght": 0}, (270, -110)), 855 ({"wght": 0.5}, (300, -122)), # +30, -12 856 ({"wght": 1.0}, (330, -135)), # +60, -25 857 ({"wdth": -1.0}, (260, -105)), # -10, +5 858 ({"wdth": -0.3}, (267, -108)), # -3, +2 859 ({"wdth": 0}, (270, -110)), 860 ({"wdth": 1.0}, (280, -115)), # +10, -5 861 ], 862 ) 863 def test_pin_and_drop_axis_GPOS_mark_and_kern( 864 self, varfontGPOS2, location, expected 865 ): 866 vf = varfontGPOS2 867 assert "GDEF" in vf 868 assert "GPOS" in vf 869 870 instancer.instantiateOTL(vf, location) 871 872 v1, v2 = expected 873 gdef = vf["GDEF"].table 874 gpos = vf["GPOS"].table 875 assert gdef.Version == 0x00010003 876 assert gdef.VarStore 877 assert gdef.GlyphClassDef 878 879 assert gpos.LookupList.Lookup[0].LookupType == 4 # MarkBasePos 880 markBasePos = gpos.LookupList.Lookup[0].SubTable[0] 881 baseAnchor = markBasePos.BaseArray.BaseRecord[0].BaseAnchor[0] 882 assert baseAnchor.Format == 3 883 assert baseAnchor.XDeviceTable 884 assert baseAnchor.XDeviceTable.DeltaFormat == 0x8000 885 assert not baseAnchor.YDeviceTable 886 assert baseAnchor.XCoordinate == v1 887 assert baseAnchor.YCoordinate == 450 888 889 assert gpos.LookupList.Lookup[1].LookupType == 2 # PairPos 890 pairPos = gpos.LookupList.Lookup[1].SubTable[0] 891 valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 892 assert valueRec1.XAdvDevice 893 assert valueRec1.XAdvDevice.DeltaFormat == 0x8000 894 assert valueRec1.XAdvance == v2 895 896 @pytest.mark.parametrize( 897 "location, expected", 898 [ 899 ({"wght": -1.0, "wdth": -1.0}, (200, -80)), # -60 - 10, +25 + 5 900 ({"wght": -1.0, "wdth": 0.0}, (210, -85)), # -60, +25 901 ({"wght": -1.0, "wdth": 1.0}, (220, -90)), # -60 + 10, +25 - 5 902 ({"wght": 0.0, "wdth": -1.0}, (260, -105)), # -10, +5 903 ({"wght": 0.0, "wdth": 0.0}, (270, -110)), 904 ({"wght": 0.0, "wdth": 1.0}, (280, -115)), # +10, -5 905 ({"wght": 1.0, "wdth": -1.0}, (320, -130)), # +60 - 10, -25 + 5 906 ({"wght": 1.0, "wdth": 0.0}, (330, -135)), # +60, -25 907 ({"wght": 1.0, "wdth": 1.0}, (340, -140)), # +60 + 10, -25 - 5 908 ], 909 ) 910 def test_full_instance_GPOS_mark_and_kern(self, varfontGPOS2, location, expected): 911 vf = varfontGPOS2 912 assert "GDEF" in vf 913 assert "GPOS" in vf 914 915 instancer.instantiateOTL(vf, location) 916 917 v1, v2 = expected 918 gdef = vf["GDEF"].table 919 gpos = vf["GPOS"].table 920 assert gdef.Version == 0x00010000 921 assert not hasattr(gdef, "VarStore") 922 assert gdef.GlyphClassDef 923 924 assert gpos.LookupList.Lookup[0].LookupType == 4 # MarkBasePos 925 markBasePos = gpos.LookupList.Lookup[0].SubTable[0] 926 baseAnchor = markBasePos.BaseArray.BaseRecord[0].BaseAnchor[0] 927 assert baseAnchor.Format == 1 928 assert not hasattr(baseAnchor, "XDeviceTable") 929 assert not hasattr(baseAnchor, "YDeviceTable") 930 assert baseAnchor.XCoordinate == v1 931 assert baseAnchor.YCoordinate == 450 932 933 assert gpos.LookupList.Lookup[1].LookupType == 2 # PairPos 934 pairPos = gpos.LookupList.Lookup[1].SubTable[0] 935 valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 936 assert not hasattr(valueRec1, "XAdvDevice") 937 assert valueRec1.XAdvance == v2 938 939 def test_GPOS_ValueRecord_XAdvDevice_wtihout_XAdvance(self): 940 # Test VF contains a PairPos adjustment in which the default instance 941 # has no XAdvance but there are deltas in XAdvDevice (VariationIndex). 942 vf = ttLib.TTFont() 943 vf.importXML(os.path.join(TESTDATA, "PartialInstancerTest4-VF.ttx")) 944 pairPos = vf["GPOS"].table.LookupList.Lookup[0].SubTable[0] 945 assert pairPos.ValueFormat1 == 0x40 946 valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 947 assert not hasattr(valueRec1, "XAdvance") 948 assert valueRec1.XAdvDevice.DeltaFormat == 0x8000 949 outer = valueRec1.XAdvDevice.StartSize 950 inner = valueRec1.XAdvDevice.EndSize 951 assert vf["GDEF"].table.VarStore.VarData[outer].Item[inner] == [-50] 952 953 # check that MutatorMerger for ValueRecord doesn't raise AttributeError 954 # when XAdvDevice is present but there's no corresponding XAdvance. 955 instancer.instantiateOTL(vf, {"wght": 0.5}) 956 957 pairPos = vf["GPOS"].table.LookupList.Lookup[0].SubTable[0] 958 assert pairPos.ValueFormat1 == 0x4 959 valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 960 assert not hasattr(valueRec1, "XAdvDevice") 961 assert valueRec1.XAdvance == -25 962 963 964class InstantiateAvarTest(object): 965 @pytest.mark.parametrize("location", [{"wght": 0.0}, {"wdth": 0.0}]) 966 def test_pin_and_drop_axis(self, varfont, location): 967 instancer.instantiateAvar(varfont, location) 968 969 assert set(varfont["avar"].segments).isdisjoint(location) 970 971 def test_full_instance(self, varfont): 972 instancer.instantiateAvar(varfont, {"wght": 0.0, "wdth": 0.0}) 973 974 assert "avar" not in varfont 975 976 @staticmethod 977 def quantizeF2Dot14Floats(mapping): 978 return { 979 floatToFixedToFloat(k, 14): floatToFixedToFloat(v, 14) 980 for k, v in mapping.items() 981 } 982 983 # the following values come from NotoSans-VF.ttf 984 DFLT_WGHT_MAPPING = { 985 -1.0: -1.0, 986 -0.6667: -0.7969, 987 -0.3333: -0.5, 988 0: 0, 989 0.2: 0.18, 990 0.4: 0.38, 991 0.6: 0.61, 992 0.8: 0.79, 993 1.0: 1.0, 994 } 995 996 DFLT_WDTH_MAPPING = {-1.0: -1.0, -0.6667: -0.7, -0.3333: -0.36664, 0: 0, 1.0: 1.0} 997 998 @pytest.fixture 999 def varfont(self): 1000 fvarAxes = ("wght", (100, 400, 900)), ("wdth", (62.5, 100, 100)) 1001 avarSegments = { 1002 "wght": self.quantizeF2Dot14Floats(self.DFLT_WGHT_MAPPING), 1003 "wdth": self.quantizeF2Dot14Floats(self.DFLT_WDTH_MAPPING), 1004 } 1005 varfont = ttLib.TTFont() 1006 varfont["name"] = ttLib.newTable("name") 1007 varLib._add_fvar(varfont, _makeDSAxesDict(fvarAxes), instances=()) 1008 avar = varfont["avar"] = ttLib.newTable("avar") 1009 avar.segments = avarSegments 1010 return varfont 1011 1012 @pytest.mark.parametrize( 1013 "axisLimits, expectedSegments", 1014 [ 1015 pytest.param( 1016 {"wght": (100, 900)}, 1017 {"wght": DFLT_WGHT_MAPPING, "wdth": DFLT_WDTH_MAPPING}, 1018 id="wght=100:900", 1019 ), 1020 pytest.param( 1021 {"wght": (400, 900)}, 1022 { 1023 "wght": { 1024 -1.0: -1.0, 1025 0: 0, 1026 0.2: 0.18, 1027 0.4: 0.38, 1028 0.6: 0.61, 1029 0.8: 0.79, 1030 1.0: 1.0, 1031 }, 1032 "wdth": DFLT_WDTH_MAPPING, 1033 }, 1034 id="wght=400:900", 1035 ), 1036 pytest.param( 1037 {"wght": (100, 400)}, 1038 { 1039 "wght": { 1040 -1.0: -1.0, 1041 -0.6667: -0.7969, 1042 -0.3333: -0.5, 1043 0: 0, 1044 1.0: 1.0, 1045 }, 1046 "wdth": DFLT_WDTH_MAPPING, 1047 }, 1048 id="wght=100:400", 1049 ), 1050 pytest.param( 1051 {"wght": (400, 800)}, 1052 { 1053 "wght": { 1054 -1.0: -1.0, 1055 0: 0, 1056 0.25: 0.22784, 1057 0.50006: 0.48103, 1058 0.75: 0.77214, 1059 1.0: 1.0, 1060 }, 1061 "wdth": DFLT_WDTH_MAPPING, 1062 }, 1063 id="wght=400:800", 1064 ), 1065 pytest.param( 1066 {"wght": (400, 700)}, 1067 { 1068 "wght": { 1069 -1.0: -1.0, 1070 0: 0, 1071 0.3334: 0.2951, 1072 0.66675: 0.623, 1073 1.0: 1.0, 1074 }, 1075 "wdth": DFLT_WDTH_MAPPING, 1076 }, 1077 id="wght=400:700", 1078 ), 1079 pytest.param( 1080 {"wght": (400, 600)}, 1081 { 1082 "wght": {-1.0: -1.0, 0: 0, 0.5: 0.47363, 1.0: 1.0}, 1083 "wdth": DFLT_WDTH_MAPPING, 1084 }, 1085 id="wght=400:600", 1086 ), 1087 pytest.param( 1088 {"wdth": (62.5, 100)}, 1089 { 1090 "wght": DFLT_WGHT_MAPPING, 1091 "wdth": { 1092 -1.0: -1.0, 1093 -0.6667: -0.7, 1094 -0.3333: -0.36664, 1095 0: 0, 1096 1.0: 1.0, 1097 }, 1098 }, 1099 id="wdth=62.5:100", 1100 ), 1101 pytest.param( 1102 {"wdth": (70, 100)}, 1103 { 1104 "wght": DFLT_WGHT_MAPPING, 1105 "wdth": { 1106 -1.0: -1.0, 1107 -0.8334: -0.85364, 1108 -0.4166: -0.44714, 1109 0: 0, 1110 1.0: 1.0, 1111 }, 1112 }, 1113 id="wdth=70:100", 1114 ), 1115 pytest.param( 1116 {"wdth": (75, 100)}, 1117 { 1118 "wght": DFLT_WGHT_MAPPING, 1119 "wdth": {-1.0: -1.0, -0.49994: -0.52374, 0: 0, 1.0: 1.0}, 1120 }, 1121 id="wdth=75:100", 1122 ), 1123 pytest.param( 1124 {"wdth": (77, 100)}, 1125 { 1126 "wght": DFLT_WGHT_MAPPING, 1127 "wdth": {-1.0: -1.0, -0.54346: -0.56696, 0: 0, 1.0: 1.0}, 1128 }, 1129 id="wdth=77:100", 1130 ), 1131 pytest.param( 1132 {"wdth": (87.5, 100)}, 1133 {"wght": DFLT_WGHT_MAPPING, "wdth": {-1.0: -1.0, 0: 0, 1.0: 1.0}}, 1134 id="wdth=87.5:100", 1135 ), 1136 ], 1137 ) 1138 def test_limit_axes(self, varfont, axisLimits, expectedSegments): 1139 instancer.instantiateAvar(varfont, axisLimits) 1140 1141 newSegments = varfont["avar"].segments 1142 expectedSegments = { 1143 axisTag: self.quantizeF2Dot14Floats(mapping) 1144 for axisTag, mapping in expectedSegments.items() 1145 } 1146 assert newSegments == expectedSegments 1147 1148 @pytest.mark.parametrize( 1149 "invalidSegmentMap", 1150 [ 1151 pytest.param({0.5: 0.5}, id="missing-required-maps-1"), 1152 pytest.param({-1.0: -1.0, 1.0: 1.0}, id="missing-required-maps-2"), 1153 pytest.param( 1154 {-1.0: -1.0, 0: 0, 0.5: 0.5, 0.6: 0.4, 1.0: 1.0}, 1155 id="retrograde-value-maps", 1156 ), 1157 ], 1158 ) 1159 def test_drop_invalid_segment_map(self, varfont, invalidSegmentMap, caplog): 1160 varfont["avar"].segments["wght"] = invalidSegmentMap 1161 1162 with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): 1163 instancer.instantiateAvar(varfont, {"wght": (100, 400)}) 1164 1165 assert "Invalid avar" in caplog.text 1166 assert "wght" not in varfont["avar"].segments 1167 1168 def test_isValidAvarSegmentMap(self): 1169 assert instancer._isValidAvarSegmentMap("FOOO", {}) 1170 assert instancer._isValidAvarSegmentMap("FOOO", {-1.0: -1.0, 0: 0, 1.0: 1.0}) 1171 assert instancer._isValidAvarSegmentMap( 1172 "FOOO", {-1.0: -1.0, 0: 0, 0.5: 0.5, 1.0: 1.0} 1173 ) 1174 assert instancer._isValidAvarSegmentMap( 1175 "FOOO", {-1.0: -1.0, 0: 0, 0.5: 0.5, 0.7: 0.5, 1.0: 1.0} 1176 ) 1177 1178 1179class InstantiateFvarTest(object): 1180 @pytest.mark.parametrize( 1181 "location, instancesLeft", 1182 [ 1183 ( 1184 {"wght": 400.0}, 1185 ["Regular", "SemiCondensed", "Condensed", "ExtraCondensed"], 1186 ), 1187 ( 1188 {"wght": 100.0}, 1189 ["Thin", "SemiCondensed Thin", "Condensed Thin", "ExtraCondensed Thin"], 1190 ), 1191 ( 1192 {"wdth": 100.0}, 1193 [ 1194 "Thin", 1195 "ExtraLight", 1196 "Light", 1197 "Regular", 1198 "Medium", 1199 "SemiBold", 1200 "Bold", 1201 "ExtraBold", 1202 "Black", 1203 ], 1204 ), 1205 # no named instance at pinned location 1206 ({"wdth": 90.0}, []), 1207 ], 1208 ) 1209 def test_pin_and_drop_axis(self, varfont, location, instancesLeft): 1210 instancer.instantiateFvar(varfont, location) 1211 1212 fvar = varfont["fvar"] 1213 assert {a.axisTag for a in fvar.axes}.isdisjoint(location) 1214 1215 for instance in fvar.instances: 1216 assert set(instance.coordinates).isdisjoint(location) 1217 1218 name = varfont["name"] 1219 assert [ 1220 name.getDebugName(instance.subfamilyNameID) for instance in fvar.instances 1221 ] == instancesLeft 1222 1223 def test_full_instance(self, varfont): 1224 instancer.instantiateFvar(varfont, {"wght": 0.0, "wdth": 0.0}) 1225 1226 assert "fvar" not in varfont 1227 1228 1229class InstantiateSTATTest(object): 1230 @pytest.mark.parametrize( 1231 "location, expected", 1232 [ 1233 ({"wght": 400}, ["Regular", "Condensed", "Upright", "Normal"]), 1234 ({"wdth": 100}, ["Thin", "Regular", "Black", "Upright", "Normal"]), 1235 ], 1236 ) 1237 def test_pin_and_drop_axis(self, varfont, location, expected): 1238 instancer.instantiateSTAT(varfont, location) 1239 1240 stat = varfont["STAT"].table 1241 designAxes = {a.AxisTag for a in stat.DesignAxisRecord.Axis} 1242 1243 assert designAxes == {"wght", "wdth", "ital"} 1244 1245 name = varfont["name"] 1246 valueNames = [] 1247 for axisValueTable in stat.AxisValueArray.AxisValue: 1248 valueName = name.getDebugName(axisValueTable.ValueNameID) 1249 valueNames.append(valueName) 1250 1251 assert valueNames == expected 1252 1253 def test_skip_table_no_axis_value_array(self, varfont): 1254 varfont["STAT"].table.AxisValueArray = None 1255 1256 instancer.instantiateSTAT(varfont, {"wght": 100}) 1257 1258 assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3 1259 assert varfont["STAT"].table.AxisValueArray is None 1260 1261 def test_skip_table_axis_value_array_empty(self, varfont): 1262 varfont["STAT"].table.AxisValueArray.AxisValue = [] 1263 1264 instancer.instantiateSTAT(varfont, {"wght": 100}) 1265 1266 assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3 1267 assert not varfont["STAT"].table.AxisValueArray.AxisValue 1268 1269 def test_skip_table_no_design_axes(self, varfont): 1270 stat = otTables.STAT() 1271 stat.Version = 0x00010001 1272 stat.populateDefaults() 1273 assert not stat.DesignAxisRecord 1274 assert not stat.AxisValueArray 1275 varfont["STAT"].table = stat 1276 1277 instancer.instantiateSTAT(varfont, {"wght": 100}) 1278 1279 assert not varfont["STAT"].table.DesignAxisRecord 1280 1281 @staticmethod 1282 def get_STAT_axis_values(stat): 1283 axes = stat.DesignAxisRecord.Axis 1284 result = [] 1285 for axisValue in stat.AxisValueArray.AxisValue: 1286 if axisValue.Format == 1: 1287 result.append((axes[axisValue.AxisIndex].AxisTag, axisValue.Value)) 1288 elif axisValue.Format == 3: 1289 result.append( 1290 ( 1291 axes[axisValue.AxisIndex].AxisTag, 1292 (axisValue.Value, axisValue.LinkedValue), 1293 ) 1294 ) 1295 elif axisValue.Format == 2: 1296 result.append( 1297 ( 1298 axes[axisValue.AxisIndex].AxisTag, 1299 ( 1300 axisValue.RangeMinValue, 1301 axisValue.NominalValue, 1302 axisValue.RangeMaxValue, 1303 ), 1304 ) 1305 ) 1306 elif axisValue.Format == 4: 1307 result.append( 1308 tuple( 1309 (axes[rec.AxisIndex].AxisTag, rec.Value) 1310 for rec in axisValue.AxisValueRecord 1311 ) 1312 ) 1313 else: 1314 raise AssertionError(axisValue.Format) 1315 return result 1316 1317 def test_limit_axes(self, varfont2): 1318 instancer.instantiateSTAT(varfont2, {"wght": (400, 500), "wdth": (75, 100)}) 1319 1320 assert len(varfont2["STAT"].table.AxisValueArray.AxisValue) == 5 1321 assert self.get_STAT_axis_values(varfont2["STAT"].table) == [ 1322 ("wght", (400.0, 700.0)), 1323 ("wght", 500.0), 1324 ("wdth", (93.75, 100.0, 100.0)), 1325 ("wdth", (81.25, 87.5, 93.75)), 1326 ("wdth", (68.75, 75.0, 81.25)), 1327 ] 1328 1329 def test_limit_axis_value_format_4(self, varfont2): 1330 stat = varfont2["STAT"].table 1331 1332 axisValue = otTables.AxisValue() 1333 axisValue.Format = 4 1334 axisValue.AxisValueRecord = [] 1335 for tag, value in (("wght", 575), ("wdth", 90)): 1336 rec = otTables.AxisValueRecord() 1337 rec.AxisIndex = next( 1338 i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag 1339 ) 1340 rec.Value = value 1341 axisValue.AxisValueRecord.append(rec) 1342 stat.AxisValueArray.AxisValue.append(axisValue) 1343 1344 instancer.instantiateSTAT(varfont2, {"wght": (100, 600)}) 1345 1346 assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue 1347 1348 instancer.instantiateSTAT(varfont2, {"wdth": (62.5, 87.5)}) 1349 1350 assert axisValue not in varfont2["STAT"].table.AxisValueArray.AxisValue 1351 1352 def test_unknown_axis_value_format(self, varfont2, caplog): 1353 stat = varfont2["STAT"].table 1354 axisValue = otTables.AxisValue() 1355 axisValue.Format = 5 1356 stat.AxisValueArray.AxisValue.append(axisValue) 1357 1358 with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): 1359 instancer.instantiateSTAT(varfont2, {"wght": 400}) 1360 1361 assert "Unknown AxisValue table format (5)" in caplog.text 1362 assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue 1363 1364 1365def test_setMacOverlapFlags(): 1366 flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND 1367 flagOverlapSimple = _g_l_y_f.flagOverlapSimple 1368 1369 glyf = ttLib.newTable("glyf") 1370 glyf.glyphOrder = ["a", "b", "c"] 1371 a = _g_l_y_f.Glyph() 1372 a.numberOfContours = 1 1373 a.flags = [0] 1374 b = _g_l_y_f.Glyph() 1375 b.numberOfContours = -1 1376 comp = _g_l_y_f.GlyphComponent() 1377 comp.flags = 0 1378 b.components = [comp] 1379 c = _g_l_y_f.Glyph() 1380 c.numberOfContours = 0 1381 glyf.glyphs = {"a": a, "b": b, "c": c} 1382 1383 instancer.setMacOverlapFlags(glyf) 1384 1385 assert a.flags[0] & flagOverlapSimple != 0 1386 assert b.components[0].flags & flagOverlapCompound != 0 1387 1388 1389def _strip_ttLibVersion(string): 1390 return re.sub(' ttLibVersion=".*"', "", string) 1391 1392 1393@pytest.fixture 1394def varfont2(): 1395 f = ttLib.TTFont(recalcTimestamp=False) 1396 f.importXML(os.path.join(TESTDATA, "PartialInstancerTest2-VF.ttx")) 1397 return f 1398 1399 1400@pytest.fixture 1401def varfont3(): 1402 f = ttLib.TTFont(recalcTimestamp=False) 1403 f.importXML(os.path.join(TESTDATA, "PartialInstancerTest3-VF.ttx")) 1404 return f 1405 1406 1407def _dump_ttx(ttFont): 1408 # compile to temporary bytes stream, reload and dump to XML 1409 tmp = BytesIO() 1410 ttFont.save(tmp) 1411 tmp.seek(0) 1412 ttFont2 = ttLib.TTFont(tmp, recalcBBoxes=False, recalcTimestamp=False) 1413 s = StringIO() 1414 ttFont2.saveXML(s) 1415 return _strip_ttLibVersion(s.getvalue()) 1416 1417 1418def _get_expected_instance_ttx( 1419 name, *locations, overlap=instancer.OverlapMode.KEEP_AND_SET_FLAGS 1420): 1421 filename = f"{name}-VF-instance-{','.join(str(loc) for loc in locations)}" 1422 if overlap == instancer.OverlapMode.KEEP_AND_DONT_SET_FLAGS: 1423 filename += "-no-overlap-flags" 1424 elif overlap == instancer.OverlapMode.REMOVE: 1425 filename += "-no-overlaps" 1426 with open( 1427 os.path.join(TESTDATA, "test_results", f"{filename}.ttx"), 1428 "r", 1429 encoding="utf-8", 1430 ) as fp: 1431 return _strip_ttLibVersion(fp.read()) 1432 1433 1434class InstantiateVariableFontTest(object): 1435 @pytest.mark.parametrize( 1436 "wght, wdth", 1437 [(100, 100), (400, 100), (900, 100), (100, 62.5), (400, 62.5), (900, 62.5)], 1438 ) 1439 def test_multiple_instancing(self, varfont2, wght, wdth): 1440 partial = instancer.instantiateVariableFont(varfont2, {"wght": wght}) 1441 instance = instancer.instantiateVariableFont(partial, {"wdth": wdth}) 1442 1443 expected = _get_expected_instance_ttx("PartialInstancerTest2", wght, wdth) 1444 1445 assert _dump_ttx(instance) == expected 1446 1447 def test_default_instance(self, varfont2): 1448 instance = instancer.instantiateVariableFont( 1449 varfont2, {"wght": None, "wdth": None} 1450 ) 1451 1452 expected = _get_expected_instance_ttx("PartialInstancerTest2", 400, 100) 1453 1454 assert _dump_ttx(instance) == expected 1455 1456 @pytest.mark.parametrize( 1457 "overlap, wght", 1458 [ 1459 (instancer.OverlapMode.KEEP_AND_DONT_SET_FLAGS, 400), 1460 (instancer.OverlapMode.REMOVE, 400), 1461 (instancer.OverlapMode.REMOVE, 700), 1462 ], 1463 ) 1464 def test_overlap(self, varfont3, wght, overlap): 1465 pytest.importorskip("pathops") 1466 1467 location = {"wght": wght} 1468 1469 instance = instancer.instantiateVariableFont( 1470 varfont3, location, overlap=overlap 1471 ) 1472 1473 expected = _get_expected_instance_ttx( 1474 "PartialInstancerTest3", wght, overlap=overlap 1475 ) 1476 1477 assert _dump_ttx(instance) == expected 1478 1479 def test_singlepos(self): 1480 varfont = ttLib.TTFont(recalcTimestamp=False) 1481 varfont.importXML(os.path.join(TESTDATA, "SinglePos.ttx")) 1482 1483 location = {"wght": 280, "opsz": 18} 1484 1485 instance = instancer.instantiateVariableFont( 1486 varfont, location, 1487 ) 1488 1489 expected = _get_expected_instance_ttx( 1490 "SinglePos", *location.values() 1491 ) 1492 1493 assert _dump_ttx(instance) == expected 1494 1495 1496 1497def _conditionSetAsDict(conditionSet, axisOrder): 1498 result = {} 1499 for cond in conditionSet.ConditionTable: 1500 assert cond.Format == 1 1501 axisTag = axisOrder[cond.AxisIndex] 1502 result[axisTag] = (cond.FilterRangeMinValue, cond.FilterRangeMaxValue) 1503 return result 1504 1505 1506def _getSubstitutions(gsub, lookupIndices): 1507 subs = {} 1508 for index, lookup in enumerate(gsub.LookupList.Lookup): 1509 if index in lookupIndices: 1510 for subtable in lookup.SubTable: 1511 subs.update(subtable.mapping) 1512 return subs 1513 1514 1515def makeFeatureVarsFont(conditionalSubstitutions): 1516 axes = set() 1517 glyphs = set() 1518 for region, substitutions in conditionalSubstitutions: 1519 for box in region: 1520 axes.update(box.keys()) 1521 glyphs.update(*substitutions.items()) 1522 1523 varfont = ttLib.TTFont() 1524 varfont.setGlyphOrder(sorted(glyphs)) 1525 1526 fvar = varfont["fvar"] = ttLib.newTable("fvar") 1527 fvar.axes = [] 1528 for axisTag in sorted(axes): 1529 axis = _f_v_a_r.Axis() 1530 axis.axisTag = Tag(axisTag) 1531 fvar.axes.append(axis) 1532 1533 featureVars.addFeatureVariations(varfont, conditionalSubstitutions) 1534 1535 return varfont 1536 1537 1538class InstantiateFeatureVariationsTest(object): 1539 @pytest.mark.parametrize( 1540 "location, appliedSubs, expectedRecords", 1541 [ 1542 ({"wght": 0}, {}, [({"cntr": (0.75, 1.0)}, {"uni0041": "uni0061"})]), 1543 ( 1544 {"wght": -1.0}, 1545 {}, 1546 [ 1547 ({"cntr": (0, 0.25)}, {"uni0061": "uni0041"}), 1548 ({"cntr": (0.75, 1.0)}, {"uni0041": "uni0061"}), 1549 ], 1550 ), 1551 ( 1552 {"wght": 1.0}, 1553 {"uni0024": "uni0024.nostroke"}, 1554 [ 1555 ( 1556 {"cntr": (0.75, 1.0)}, 1557 {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"}, 1558 ) 1559 ], 1560 ), 1561 ( 1562 {"cntr": 0}, 1563 {}, 1564 [ 1565 ({"wght": (-1.0, -0.45654)}, {"uni0061": "uni0041"}), 1566 ({"wght": (0.20886, 1.0)}, {"uni0024": "uni0024.nostroke"}), 1567 ], 1568 ), 1569 ( 1570 {"cntr": 1.0}, 1571 {"uni0041": "uni0061"}, 1572 [ 1573 ( 1574 {"wght": (0.20886, 1.0)}, 1575 {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"}, 1576 ) 1577 ], 1578 ), 1579 ], 1580 ) 1581 def test_partial_instance(self, location, appliedSubs, expectedRecords): 1582 font = makeFeatureVarsFont( 1583 [ 1584 ([{"wght": (0.20886, 1.0)}], {"uni0024": "uni0024.nostroke"}), 1585 ([{"cntr": (0.75, 1.0)}], {"uni0041": "uni0061"}), 1586 ( 1587 [{"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}], 1588 {"uni0061": "uni0041"}, 1589 ), 1590 ] 1591 ) 1592 1593 instancer.instantiateFeatureVariations(font, location) 1594 1595 gsub = font["GSUB"].table 1596 featureVariations = gsub.FeatureVariations 1597 1598 assert featureVariations.FeatureVariationCount == len(expectedRecords) 1599 1600 axisOrder = [a.axisTag for a in font["fvar"].axes if a.axisTag not in location] 1601 for i, (expectedConditionSet, expectedSubs) in enumerate(expectedRecords): 1602 rec = featureVariations.FeatureVariationRecord[i] 1603 conditionSet = _conditionSetAsDict(rec.ConditionSet, axisOrder) 1604 1605 assert conditionSet == expectedConditionSet 1606 1607 subsRecord = rec.FeatureTableSubstitution.SubstitutionRecord[0] 1608 lookupIndices = subsRecord.Feature.LookupListIndex 1609 substitutions = _getSubstitutions(gsub, lookupIndices) 1610 1611 assert substitutions == expectedSubs 1612 1613 appliedLookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex 1614 1615 assert _getSubstitutions(gsub, appliedLookupIndices) == appliedSubs 1616 1617 @pytest.mark.parametrize( 1618 "location, appliedSubs", 1619 [ 1620 ({"wght": 0, "cntr": 0}, None), 1621 ({"wght": -1.0, "cntr": 0}, {"uni0061": "uni0041"}), 1622 ({"wght": 1.0, "cntr": 0}, {"uni0024": "uni0024.nostroke"}), 1623 ({"wght": 0.0, "cntr": 1.0}, {"uni0041": "uni0061"}), 1624 ( 1625 {"wght": 1.0, "cntr": 1.0}, 1626 {"uni0041": "uni0061", "uni0024": "uni0024.nostroke"}, 1627 ), 1628 ({"wght": -1.0, "cntr": 0.3}, None), 1629 ], 1630 ) 1631 def test_full_instance(self, location, appliedSubs): 1632 font = makeFeatureVarsFont( 1633 [ 1634 ([{"wght": (0.20886, 1.0)}], {"uni0024": "uni0024.nostroke"}), 1635 ([{"cntr": (0.75, 1.0)}], {"uni0041": "uni0061"}), 1636 ( 1637 [{"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}], 1638 {"uni0061": "uni0041"}, 1639 ), 1640 ] 1641 ) 1642 1643 instancer.instantiateFeatureVariations(font, location) 1644 1645 gsub = font["GSUB"].table 1646 assert not hasattr(gsub, "FeatureVariations") 1647 1648 if appliedSubs: 1649 lookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex 1650 assert _getSubstitutions(gsub, lookupIndices) == appliedSubs 1651 else: 1652 assert not gsub.FeatureList.FeatureRecord 1653 1654 def test_unsupported_condition_format(self, caplog): 1655 font = makeFeatureVarsFont( 1656 [ 1657 ( 1658 [{"wdth": (-1.0, -0.5), "wght": (0.5, 1.0)}], 1659 {"dollar": "dollar.nostroke"}, 1660 ) 1661 ] 1662 ) 1663 featureVariations = font["GSUB"].table.FeatureVariations 1664 rec1 = featureVariations.FeatureVariationRecord[0] 1665 assert len(rec1.ConditionSet.ConditionTable) == 2 1666 rec1.ConditionSet.ConditionTable[0].Format = 2 1667 1668 with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): 1669 instancer.instantiateFeatureVariations(font, {"wdth": 0}) 1670 1671 assert ( 1672 "Condition table 0 of FeatureVariationRecord 0 " 1673 "has unsupported format (2); ignored" 1674 ) in caplog.text 1675 1676 # check that record with unsupported condition format (but whose other 1677 # conditions do not reference pinned axes) is kept as is 1678 featureVariations = font["GSUB"].table.FeatureVariations 1679 assert featureVariations.FeatureVariationRecord[0] is rec1 1680 assert len(rec1.ConditionSet.ConditionTable) == 2 1681 assert rec1.ConditionSet.ConditionTable[0].Format == 2 1682 1683 def test_GSUB_FeatureVariations_is_None(self, varfont2): 1684 varfont2["GSUB"].table.Version = 0x00010001 1685 varfont2["GSUB"].table.FeatureVariations = None 1686 tmp = BytesIO() 1687 varfont2.save(tmp) 1688 varfont = ttLib.TTFont(tmp) 1689 1690 # DO NOT raise an exception when the optional 'FeatureVariations' attribute is 1691 # present but is set to None (e.g. with GSUB 1.1); skip and do nothing. 1692 assert varfont["GSUB"].table.FeatureVariations is None 1693 instancer.instantiateFeatureVariations(varfont, {"wght": 400, "wdth": 100}) 1694 assert varfont["GSUB"].table.FeatureVariations is None 1695 1696 1697class LimitTupleVariationAxisRangesTest: 1698 def check_limit_single_var_axis_range(self, var, axisTag, axisRange, expected): 1699 result = instancer.limitTupleVariationAxisRange(var, axisTag, axisRange) 1700 print(result) 1701 1702 assert len(result) == len(expected) 1703 for v1, v2 in zip(result, expected): 1704 assert v1.coordinates == pytest.approx(v2.coordinates) 1705 assert v1.axes.keys() == v2.axes.keys() 1706 for k in v1.axes: 1707 p, q = v1.axes[k], v2.axes[k] 1708 assert p == pytest.approx(q) 1709 1710 @pytest.mark.parametrize( 1711 "var, axisTag, newMax, expected", 1712 [ 1713 ( 1714 TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), 1715 "wdth", 1716 0.5, 1717 [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])], 1718 ), 1719 ( 1720 TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), 1721 "wght", 1722 0.5, 1723 [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [50, 50])], 1724 ), 1725 ( 1726 TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), 1727 "wght", 1728 0.8, 1729 [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [80, 80])], 1730 ), 1731 ( 1732 TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), 1733 "wght", 1734 1.0, 1735 [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])], 1736 ), 1737 (TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), "wght", 0.0, []), 1738 (TupleVariation({"wght": (0.5, 1.0, 1.0)}, [100, 100]), "wght", 0.4, []), 1739 ( 1740 TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]), 1741 "wght", 1742 0.5, 1743 [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])], 1744 ), 1745 ( 1746 TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]), 1747 "wght", 1748 0.4, 1749 [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [80, 80])], 1750 ), 1751 ( 1752 TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]), 1753 "wght", 1754 0.6, 1755 [TupleVariation({"wght": (0.0, 0.833334, 1.666667)}, [100, 100])], 1756 ), 1757 ( 1758 TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]), 1759 "wght", 1760 0.4, 1761 [ 1762 TupleVariation({"wght": (0.0, 0.5, 1.99994)}, [100, 100]), 1763 TupleVariation({"wght": (0.5, 1.0, 1.0)}, [8.33333, 8.33333]), 1764 ], 1765 ), 1766 ( 1767 TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]), 1768 "wght", 1769 0.5, 1770 [TupleVariation({"wght": (0.0, 0.4, 1.99994)}, [100, 100])], 1771 ), 1772 ( 1773 TupleVariation({"wght": (0.5, 0.5, 1.0)}, [100, 100]), 1774 "wght", 1775 0.5, 1776 [TupleVariation({"wght": (1.0, 1.0, 1.0)}, [100, 100])], 1777 ), 1778 ], 1779 ) 1780 def test_positive_var(self, var, axisTag, newMax, expected): 1781 axisRange = instancer.NormalizedAxisRange(0, newMax) 1782 self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) 1783 1784 @pytest.mark.parametrize( 1785 "var, axisTag, newMin, expected", 1786 [ 1787 ( 1788 TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), 1789 "wdth", 1790 -0.5, 1791 [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])], 1792 ), 1793 ( 1794 TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), 1795 "wght", 1796 -0.5, 1797 [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [50, 50])], 1798 ), 1799 ( 1800 TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), 1801 "wght", 1802 -0.8, 1803 [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [80, 80])], 1804 ), 1805 ( 1806 TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), 1807 "wght", 1808 -1.0, 1809 [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])], 1810 ), 1811 (TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), "wght", 0.0, []), 1812 ( 1813 TupleVariation({"wght": (-1.0, -1.0, -0.5)}, [100, 100]), 1814 "wght", 1815 -0.4, 1816 [], 1817 ), 1818 ( 1819 TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]), 1820 "wght", 1821 -0.5, 1822 [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])], 1823 ), 1824 ( 1825 TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]), 1826 "wght", 1827 -0.4, 1828 [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [80, 80])], 1829 ), 1830 ( 1831 TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]), 1832 "wght", 1833 -0.6, 1834 [TupleVariation({"wght": (-1.666667, -0.833334, 0.0)}, [100, 100])], 1835 ), 1836 ( 1837 TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]), 1838 "wght", 1839 -0.4, 1840 [ 1841 TupleVariation({"wght": (-2.0, -0.5, -0.0)}, [100, 100]), 1842 TupleVariation({"wght": (-1.0, -1.0, -0.5)}, [8.33333, 8.33333]), 1843 ], 1844 ), 1845 ( 1846 TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]), 1847 "wght", 1848 -0.5, 1849 [TupleVariation({"wght": (-2.0, -0.4, 0.0)}, [100, 100])], 1850 ), 1851 ( 1852 TupleVariation({"wght": (-1.0, -0.5, -0.5)}, [100, 100]), 1853 "wght", 1854 -0.5, 1855 [TupleVariation({"wght": (-1.0, -1.0, -1.0)}, [100, 100])], 1856 ), 1857 ], 1858 ) 1859 def test_negative_var(self, var, axisTag, newMin, expected): 1860 axisRange = instancer.NormalizedAxisRange(newMin, 0) 1861 self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) 1862 1863 1864@pytest.mark.parametrize( 1865 "oldRange, newRange, expected", 1866 [ 1867 ((1.0, -1.0), (-1.0, 1.0), None), # invalid oldRange min > max 1868 ((0.6, 1.0), (0, 0.5), None), 1869 ((-1.0, -0.6), (-0.5, 0), None), 1870 ((0.4, 1.0), (0, 0.5), (0.8, 1.0)), 1871 ((-1.0, -0.4), (-0.5, 0), (-1.0, -0.8)), 1872 ((0.4, 1.0), (0, 0.4), (1.0, 1.0)), 1873 ((-1.0, -0.4), (-0.4, 0), (-1.0, -1.0)), 1874 ((-0.5, 0.5), (-0.4, 0.4), (-1.0, 1.0)), 1875 ((0, 1.0), (-1.0, 0), (0, 0)), # or None? 1876 ((-1.0, 0), (0, 1.0), (0, 0)), # or None? 1877 ], 1878) 1879def test_limitFeatureVariationConditionRange(oldRange, newRange, expected): 1880 condition = featureVars.buildConditionTable(0, *oldRange) 1881 1882 result = instancer._limitFeatureVariationConditionRange( 1883 condition, instancer.NormalizedAxisRange(*newRange) 1884 ) 1885 1886 assert result == expected 1887 1888 1889@pytest.mark.parametrize( 1890 "limits, expected", 1891 [ 1892 (["wght=400", "wdth=100"], {"wght": 400, "wdth": 100}), 1893 (["wght=400:900"], {"wght": (400, 900)}), 1894 (["slnt=11.4"], {"slnt": pytest.approx(11.399994)}), 1895 (["ABCD=drop"], {"ABCD": None}), 1896 ], 1897) 1898def test_parseLimits(limits, expected): 1899 assert instancer.parseLimits(limits) == expected 1900 1901 1902@pytest.mark.parametrize( 1903 "limits", [["abcde=123", "=0", "wght=:", "wght=1:", "wght=abcd", "wght=x:y"]] 1904) 1905def test_parseLimits_invalid(limits): 1906 with pytest.raises(ValueError, match="invalid location format"): 1907 instancer.parseLimits(limits) 1908 1909 1910def test_normalizeAxisLimits_tuple(varfont): 1911 normalized = instancer.normalizeAxisLimits(varfont, {"wght": (100, 400)}) 1912 assert normalized == {"wght": (-1.0, 0)} 1913 1914 1915def test_normalizeAxisLimits_unsupported_range(varfont): 1916 with pytest.raises(NotImplementedError, match="Unsupported range"): 1917 instancer.normalizeAxisLimits(varfont, {"wght": (401, 700)}) 1918 1919 1920def test_normalizeAxisLimits_no_avar(varfont): 1921 del varfont["avar"] 1922 1923 normalized = instancer.normalizeAxisLimits(varfont, {"wght": (400, 500)}) 1924 1925 assert normalized["wght"] == pytest.approx((0, 0.2), 1e-4) 1926 1927 1928def test_normalizeAxisLimits_missing_from_fvar(varfont): 1929 with pytest.raises(ValueError, match="not present in fvar"): 1930 instancer.normalizeAxisLimits(varfont, {"ZZZZ": 1000}) 1931 1932 1933def test_sanityCheckVariableTables(varfont): 1934 font = ttLib.TTFont() 1935 with pytest.raises(ValueError, match="Missing required table fvar"): 1936 instancer.sanityCheckVariableTables(font) 1937 1938 del varfont["glyf"] 1939 1940 with pytest.raises(ValueError, match="Can't have gvar without glyf"): 1941 instancer.sanityCheckVariableTables(varfont) 1942 1943 1944def test_main(varfont, tmpdir): 1945 fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf") 1946 varfont.save(fontfile) 1947 args = [fontfile, "wght=400"] 1948 1949 # exits without errors 1950 assert instancer.main(args) is None 1951 1952 1953def test_main_exit_nonexistent_file(capsys): 1954 with pytest.raises(SystemExit): 1955 instancer.main([""]) 1956 captured = capsys.readouterr() 1957 1958 assert "No such file ''" in captured.err 1959 1960 1961def test_main_exit_invalid_location(varfont, tmpdir, capsys): 1962 fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf") 1963 varfont.save(fontfile) 1964 1965 with pytest.raises(SystemExit): 1966 instancer.main([fontfile, "wght:100"]) 1967 captured = capsys.readouterr() 1968 1969 assert "invalid location format" in captured.err 1970 1971 1972def test_main_exit_multiple_limits(varfont, tmpdir, capsys): 1973 fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf") 1974 varfont.save(fontfile) 1975 1976 with pytest.raises(SystemExit): 1977 instancer.main([fontfile, "wght=400", "wght=90"]) 1978 captured = capsys.readouterr() 1979 1980 assert "Specified multiple limits for the same axis" in captured.err 1981