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