diff options
Diffstat (limited to 'Tests/varLib/instancer/instancer_test.py')
-rw-r--r-- | Tests/varLib/instancer/instancer_test.py | 1939 |
1 files changed, 1939 insertions, 0 deletions
diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py new file mode 100644 index 00000000..cb7e8547 --- /dev/null +++ b/Tests/varLib/instancer/instancer_test.py @@ -0,0 +1,1939 @@ +from fontTools.misc.py23 import Tag +from fontTools.misc.fixedTools import floatToFixedToFloat +from fontTools import ttLib +from fontTools import designspaceLib +from fontTools.feaLib.builder import addOpenTypeFeaturesFromString +from fontTools.ttLib.tables import _f_v_a_r, _g_l_y_f +from fontTools.ttLib.tables import otTables +from fontTools.ttLib.tables.TupleVariation import TupleVariation +from fontTools import varLib +from fontTools.varLib import instancer +from fontTools.varLib.mvar import MVAR_ENTRIES +from fontTools.varLib import builder +from fontTools.varLib import featureVars +from fontTools.varLib import models +import collections +from copy import deepcopy +from io import BytesIO, StringIO +import logging +import os +import re +from types import SimpleNamespace +import pytest + + +# see Tests/varLib/instancer/conftest.py for "varfont" fixture definition + +TESTDATA = os.path.join(os.path.dirname(__file__), "data") + + +@pytest.fixture(params=[True, False], ids=["optimize", "no-optimize"]) +def optimize(request): + return request.param + + +@pytest.fixture +def fvarAxes(): + wght = _f_v_a_r.Axis() + wght.axisTag = Tag("wght") + wght.minValue = 100 + wght.defaultValue = 400 + wght.maxValue = 900 + wdth = _f_v_a_r.Axis() + wdth.axisTag = Tag("wdth") + wdth.minValue = 70 + wdth.defaultValue = 100 + wdth.maxValue = 100 + return [wght, wdth] + + +def _get_coordinates(varfont, glyphname): + # converts GlyphCoordinates to a list of (x, y) tuples, so that pytest's + # assert will give us a nicer diff + return list(varfont["glyf"].getCoordinatesAndControls(glyphname, varfont)[0]) + + +class InstantiateGvarTest(object): + @pytest.mark.parametrize("glyph_name", ["hyphen"]) + @pytest.mark.parametrize( + "location, expected", + [ + pytest.param( + {"wdth": -1.0}, + { + "hyphen": [ + (27, 229), + (27, 310), + (247, 310), + (247, 229), + (0, 0), + (274, 0), + (0, 536), + (0, 0), + ] + }, + id="wdth=-1.0", + ), + pytest.param( + {"wdth": -0.5}, + { + "hyphen": [ + (33.5, 229), + (33.5, 308.5), + (264.5, 308.5), + (264.5, 229), + (0, 0), + (298, 0), + (0, 536), + (0, 0), + ] + }, + id="wdth=-0.5", + ), + # an axis pinned at the default normalized location (0.0) means + # the default glyf outline stays the same + pytest.param( + {"wdth": 0.0}, + { + "hyphen": [ + (40, 229), + (40, 307), + (282, 307), + (282, 229), + (0, 0), + (322, 0), + (0, 536), + (0, 0), + ] + }, + id="wdth=0.0", + ), + ], + ) + def test_pin_and_drop_axis(self, varfont, glyph_name, location, expected, optimize): + instancer.instantiateGvar(varfont, location, optimize=optimize) + + assert _get_coordinates(varfont, glyph_name) == expected[glyph_name] + + # check that the pinned axis has been dropped from gvar + assert not any( + "wdth" in t.axes + for tuples in varfont["gvar"].variations.values() + for t in tuples + ) + + def test_full_instance(self, varfont, optimize): + instancer.instantiateGvar( + varfont, {"wght": 0.0, "wdth": -0.5}, optimize=optimize + ) + + assert _get_coordinates(varfont, "hyphen") == [ + (33.5, 229), + (33.5, 308.5), + (264.5, 308.5), + (264.5, 229), + (0, 0), + (298, 0), + (0, 536), + (0, 0), + ] + + assert "gvar" not in varfont + + def test_composite_glyph_not_in_gvar(self, varfont): + """The 'minus' glyph is a composite glyph, which references 'hyphen' as a + component, but has no tuple variations in gvar table, so the component offset + and the phantom points do not change; however the sidebearings and bounding box + do change as a result of the parent glyph 'hyphen' changing. + """ + hmtx = varfont["hmtx"] + vmtx = varfont["vmtx"] + + hyphenCoords = _get_coordinates(varfont, "hyphen") + assert hyphenCoords == [ + (40, 229), + (40, 307), + (282, 307), + (282, 229), + (0, 0), + (322, 0), + (0, 536), + (0, 0), + ] + assert hmtx["hyphen"] == (322, 40) + assert vmtx["hyphen"] == (536, 229) + + minusCoords = _get_coordinates(varfont, "minus") + assert minusCoords == [(0, 0), (0, 0), (422, 0), (0, 536), (0, 0)] + assert hmtx["minus"] == (422, 40) + assert vmtx["minus"] == (536, 229) + + location = {"wght": -1.0, "wdth": -1.0} + + instancer.instantiateGvar(varfont, location) + + # check 'hyphen' coordinates changed + assert _get_coordinates(varfont, "hyphen") == [ + (26, 259), + (26, 286), + (237, 286), + (237, 259), + (0, 0), + (263, 0), + (0, 536), + (0, 0), + ] + # check 'minus' coordinates (i.e. component offset and phantom points) + # did _not_ change + assert _get_coordinates(varfont, "minus") == minusCoords + + assert hmtx["hyphen"] == (263, 26) + assert vmtx["hyphen"] == (536, 250) + + assert hmtx["minus"] == (422, 26) # 'minus' left sidebearing changed + assert vmtx["minus"] == (536, 250) # 'minus' top sidebearing too + + +class InstantiateCvarTest(object): + @pytest.mark.parametrize( + "location, expected", + [ + pytest.param({"wght": -1.0}, [500, -400, 150, 250], id="wght=-1.0"), + pytest.param({"wdth": -1.0}, [500, -400, 180, 200], id="wdth=-1.0"), + pytest.param({"wght": -0.5}, [500, -400, 165, 250], id="wght=-0.5"), + pytest.param({"wdth": -0.3}, [500, -400, 180, 235], id="wdth=-0.3"), + ], + ) + def test_pin_and_drop_axis(self, varfont, location, expected): + instancer.instantiateCvar(varfont, location) + + assert list(varfont["cvt "].values) == expected + + # check that the pinned axis has been dropped from cvar + pinned_axes = location.keys() + assert not any( + axis in t.axes for t in varfont["cvar"].variations for axis in pinned_axes + ) + + def test_full_instance(self, varfont): + instancer.instantiateCvar(varfont, {"wght": -0.5, "wdth": -0.5}) + + assert list(varfont["cvt "].values) == [500, -400, 165, 225] + + assert "cvar" not in varfont + + +class InstantiateMVARTest(object): + @pytest.mark.parametrize( + "location, expected", + [ + pytest.param( + {"wght": 1.0}, + {"strs": 100, "undo": -200, "unds": 150, "xhgt": 530}, + id="wght=1.0", + ), + pytest.param( + {"wght": 0.5}, + {"strs": 75, "undo": -150, "unds": 100, "xhgt": 515}, + id="wght=0.5", + ), + pytest.param( + {"wght": 0.0}, + {"strs": 50, "undo": -100, "unds": 50, "xhgt": 500}, + id="wght=0.0", + ), + pytest.param( + {"wdth": -1.0}, + {"strs": 20, "undo": -100, "unds": 50, "xhgt": 500}, + id="wdth=-1.0", + ), + pytest.param( + {"wdth": -0.5}, + {"strs": 35, "undo": -100, "unds": 50, "xhgt": 500}, + id="wdth=-0.5", + ), + pytest.param( + {"wdth": 0.0}, + {"strs": 50, "undo": -100, "unds": 50, "xhgt": 500}, + id="wdth=0.0", + ), + ], + ) + def test_pin_and_drop_axis(self, varfont, location, expected): + mvar = varfont["MVAR"].table + # initially we have two VarData: the first contains deltas associated with 3 + # regions: 1 with only wght, 1 with only wdth, and 1 with both wght and wdth + assert len(mvar.VarStore.VarData) == 2 + assert mvar.VarStore.VarRegionList.RegionCount == 3 + assert mvar.VarStore.VarData[0].VarRegionCount == 3 + assert all(len(item) == 3 for item in mvar.VarStore.VarData[0].Item) + # The second VarData has deltas associated only with 1 region (wght only). + assert mvar.VarStore.VarData[1].VarRegionCount == 1 + assert all(len(item) == 1 for item in mvar.VarStore.VarData[1].Item) + + instancer.instantiateMVAR(varfont, location) + + for mvar_tag, expected_value in expected.items(): + table_tag, item_name = MVAR_ENTRIES[mvar_tag] + assert getattr(varfont[table_tag], item_name) == expected_value + + # check that regions and accompanying deltas have been dropped + num_regions_left = len(mvar.VarStore.VarRegionList.Region) + assert num_regions_left < 3 + assert mvar.VarStore.VarRegionList.RegionCount == num_regions_left + assert mvar.VarStore.VarData[0].VarRegionCount == num_regions_left + # VarData subtables have been merged + assert len(mvar.VarStore.VarData) == 1 + + @pytest.mark.parametrize( + "location, expected", + [ + pytest.param( + {"wght": 1.0, "wdth": 0.0}, + {"strs": 100, "undo": -200, "unds": 150}, + id="wght=1.0,wdth=0.0", + ), + pytest.param( + {"wght": 0.0, "wdth": -1.0}, + {"strs": 20, "undo": -100, "unds": 50}, + id="wght=0.0,wdth=-1.0", + ), + pytest.param( + {"wght": 0.5, "wdth": -0.5}, + {"strs": 55, "undo": -145, "unds": 95}, + id="wght=0.5,wdth=-0.5", + ), + pytest.param( + {"wght": 1.0, "wdth": -1.0}, + {"strs": 50, "undo": -180, "unds": 130}, + id="wght=0.5,wdth=-0.5", + ), + ], + ) + def test_full_instance(self, varfont, location, expected): + instancer.instantiateMVAR(varfont, location) + + for mvar_tag, expected_value in expected.items(): + table_tag, item_name = MVAR_ENTRIES[mvar_tag] + assert getattr(varfont[table_tag], item_name) == expected_value + + assert "MVAR" not in varfont + + +class InstantiateHVARTest(object): + # the 'expectedDeltas' below refer to the VarData item deltas for the "hyphen" + # glyph in the PartialInstancerTest-VF.ttx test font, that are left after + # partial instancing + @pytest.mark.parametrize( + "location, expectedRegions, expectedDeltas", + [ + ({"wght": -1.0}, [{"wdth": (-1.0, -1.0, 0)}], [-59]), + ({"wght": 0}, [{"wdth": (-1.0, -1.0, 0)}], [-48]), + ({"wght": 1.0}, [{"wdth": (-1.0, -1.0, 0)}], [7]), + ( + {"wdth": -1.0}, + [ + {"wght": (-1.0, -1.0, 0.0)}, + {"wght": (0.0, 0.6099854, 1.0)}, + {"wght": (0.6099854, 1.0, 1.0)}, + ], + [-11, 31, 51], + ), + ({"wdth": 0}, [{"wght": (0.6099854, 1.0, 1.0)}], [-4]), + ], + ) + def test_partial_instance(self, varfont, location, expectedRegions, expectedDeltas): + instancer.instantiateHVAR(varfont, location) + + assert "HVAR" in varfont + hvar = varfont["HVAR"].table + varStore = hvar.VarStore + + regions = varStore.VarRegionList.Region + fvarAxes = [a for a in varfont["fvar"].axes if a.axisTag not in location] + regionDicts = [reg.get_support(fvarAxes) for reg in regions] + assert len(regionDicts) == len(expectedRegions) + for region, expectedRegion in zip(regionDicts, expectedRegions): + assert region.keys() == expectedRegion.keys() + for axisTag, support in region.items(): + assert support == pytest.approx(expectedRegion[axisTag]) + + assert len(varStore.VarData) == 1 + assert varStore.VarData[0].ItemCount == 2 + + assert hvar.AdvWidthMap is not None + advWithMap = hvar.AdvWidthMap.mapping + + assert advWithMap[".notdef"] == advWithMap["space"] + varIdx = advWithMap[".notdef"] + # these glyphs have no metrics variations in the test font + assert varStore.VarData[varIdx >> 16].Item[varIdx & 0xFFFF] == ( + [0] * varStore.VarData[0].VarRegionCount + ) + + varIdx = advWithMap["hyphen"] + assert varStore.VarData[varIdx >> 16].Item[varIdx & 0xFFFF] == expectedDeltas + + def test_full_instance(self, varfont): + instancer.instantiateHVAR(varfont, {"wght": 0, "wdth": 0}) + + assert "HVAR" not in varfont + + def test_partial_instance_keep_empty_table(self, varfont): + # Append an additional dummy axis to fvar, for which the current HVAR table + # in our test 'varfont' contains no variation data. + # Instancing the other two wght and wdth axes should leave HVAR table empty, + # to signal there are variations to the glyph's advance widths. + fvar = varfont["fvar"] + axis = _f_v_a_r.Axis() + axis.axisTag = "TEST" + fvar.axes.append(axis) + + instancer.instantiateHVAR(varfont, {"wght": 0, "wdth": 0}) + + assert "HVAR" in varfont + + varStore = varfont["HVAR"].table.VarStore + + assert varStore.VarRegionList.RegionCount == 0 + assert not varStore.VarRegionList.Region + assert varStore.VarRegionList.RegionAxisCount == 1 + + +class InstantiateItemVariationStoreTest(object): + def test_VarRegion_get_support(self): + axisOrder = ["wght", "wdth", "opsz"] + regionAxes = {"wdth": (-1.0, -1.0, 0.0), "wght": (0.0, 1.0, 1.0)} + region = builder.buildVarRegion(regionAxes, axisOrder) + + assert len(region.VarRegionAxis) == 3 + assert region.VarRegionAxis[2].PeakCoord == 0 + + fvarAxes = [SimpleNamespace(axisTag=axisTag) for axisTag in axisOrder] + + assert region.get_support(fvarAxes) == regionAxes + + @pytest.fixture + def varStore(self): + return builder.buildVarStore( + builder.buildVarRegionList( + [ + {"wght": (-1.0, -1.0, 0)}, + {"wght": (0, 0.5, 1.0)}, + {"wght": (0.5, 1.0, 1.0)}, + {"wdth": (-1.0, -1.0, 0)}, + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, + {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, + {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, + ], + ["wght", "wdth"], + ), + [ + builder.buildVarData([0, 1, 2], [[100, 100, 100], [100, 100, 100]]), + builder.buildVarData( + [3, 4, 5, 6], [[100, 100, 100, 100], [100, 100, 100, 100]] + ), + ], + ) + + @pytest.mark.parametrize( + "location, expected_deltas, num_regions", + [ + ({"wght": 0}, [[0, 0], [0, 0]], 1), + ({"wght": 0.25}, [[50, 50], [0, 0]], 1), + ({"wdth": 0}, [[0, 0], [0, 0]], 3), + ({"wdth": -0.75}, [[0, 0], [75, 75]], 3), + ({"wght": 0, "wdth": 0}, [[0, 0], [0, 0]], 0), + ({"wght": 0.25, "wdth": 0}, [[50, 50], [0, 0]], 0), + ({"wght": 0, "wdth": -0.75}, [[0, 0], [75, 75]], 0), + ], + ) + def test_instantiate_default_deltas( + self, varStore, fvarAxes, location, expected_deltas, num_regions + ): + defaultDeltas = instancer.instantiateItemVariationStore( + varStore, fvarAxes, location + ) + + defaultDeltaArray = [] + for varidx, delta in sorted(defaultDeltas.items()): + major, minor = varidx >> 16, varidx & 0xFFFF + if major == len(defaultDeltaArray): + defaultDeltaArray.append([]) + assert len(defaultDeltaArray[major]) == minor + defaultDeltaArray[major].append(delta) + + assert defaultDeltaArray == expected_deltas + assert varStore.VarRegionList.RegionCount == num_regions + + +class TupleVarStoreAdapterTest(object): + def test_instantiate(self): + regions = [ + {"wght": (-1.0, -1.0, 0)}, + {"wght": (0.0, 1.0, 1.0)}, + {"wdth": (-1.0, -1.0, 0)}, + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, + {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, + ] + axisOrder = ["wght", "wdth"] + tupleVarData = [ + [ + TupleVariation({"wght": (-1.0, -1.0, 0)}, [10, 70]), + TupleVariation({"wght": (0.0, 1.0, 1.0)}, [30, 90]), + TupleVariation( + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-40, -100] + ), + TupleVariation( + {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-60, -120] + ), + ], + [ + TupleVariation({"wdth": (-1.0, -1.0, 0)}, [5, 45]), + TupleVariation( + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-15, -55] + ), + TupleVariation( + {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-35, -75] + ), + ], + ] + adapter = instancer._TupleVarStoreAdapter( + regions, axisOrder, tupleVarData, itemCounts=[2, 2] + ) + + defaultDeltaArray = adapter.instantiate({"wght": 0.5}) + + assert defaultDeltaArray == [[15, 45], [0, 0]] + assert adapter.regions == [{"wdth": (-1.0, -1.0, 0)}] + assert adapter.tupleVarData == [ + [TupleVariation({"wdth": (-1.0, -1.0, 0)}, [-30, -60])], + [TupleVariation({"wdth": (-1.0, -1.0, 0)}, [-12, 8])], + ] + + def test_rebuildRegions(self): + regions = [ + {"wght": (-1.0, -1.0, 0)}, + {"wght": (0.0, 1.0, 1.0)}, + {"wdth": (-1.0, -1.0, 0)}, + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, + {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, + ] + axisOrder = ["wght", "wdth"] + variations = [] + for region in regions: + variations.append(TupleVariation(region, [100])) + tupleVarData = [variations[:3], variations[3:]] + adapter = instancer._TupleVarStoreAdapter( + regions, axisOrder, tupleVarData, itemCounts=[1, 1] + ) + + adapter.rebuildRegions() + + assert adapter.regions == regions + + del tupleVarData[0][2] + tupleVarData[1][0].axes = {"wght": (-1.0, -0.5, 0)} + tupleVarData[1][1].axes = {"wght": (0, 0.5, 1.0)} + + adapter.rebuildRegions() + + assert adapter.regions == [ + {"wght": (-1.0, -1.0, 0)}, + {"wght": (0.0, 1.0, 1.0)}, + {"wght": (-1.0, -0.5, 0)}, + {"wght": (0, 0.5, 1.0)}, + ] + + def test_roundtrip(self, fvarAxes): + regions = [ + {"wght": (-1.0, -1.0, 0)}, + {"wght": (0, 0.5, 1.0)}, + {"wght": (0.5, 1.0, 1.0)}, + {"wdth": (-1.0, -1.0, 0)}, + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, + {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, + {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, + ] + axisOrder = [axis.axisTag for axis in fvarAxes] + + itemVarStore = builder.buildVarStore( + builder.buildVarRegionList(regions, axisOrder), + [ + builder.buildVarData( + [0, 1, 2, 4, 5, 6], + [[10, -20, 30, -40, 50, -60], [70, -80, 90, -100, 110, -120]], + ), + builder.buildVarData( + [3, 4, 5, 6], [[5, -15, 25, -35], [45, -55, 65, -75]] + ), + ], + ) + + adapter = instancer._TupleVarStoreAdapter.fromItemVarStore( + itemVarStore, fvarAxes + ) + + assert adapter.tupleVarData == [ + [ + TupleVariation({"wght": (-1.0, -1.0, 0)}, [10, 70]), + TupleVariation({"wght": (0, 0.5, 1.0)}, [-20, -80]), + TupleVariation({"wght": (0.5, 1.0, 1.0)}, [30, 90]), + TupleVariation( + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-40, -100] + ), + TupleVariation( + {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, [50, 110] + ), + TupleVariation( + {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-60, -120] + ), + ], + [ + TupleVariation({"wdth": (-1.0, -1.0, 0)}, [5, 45]), + TupleVariation( + {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, [-15, -55] + ), + TupleVariation( + {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, [25, 65] + ), + TupleVariation( + {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, [-35, -75] + ), + ], + ] + assert adapter.itemCounts == [data.ItemCount for data in itemVarStore.VarData] + assert adapter.regions == regions + assert adapter.axisOrder == axisOrder + + itemVarStore2 = adapter.asItemVarStore() + + assert [ + reg.get_support(fvarAxes) for reg in itemVarStore2.VarRegionList.Region + ] == regions + + assert itemVarStore2.VarDataCount == 2 + assert itemVarStore2.VarData[0].VarRegionIndex == [0, 1, 2, 4, 5, 6] + assert itemVarStore2.VarData[0].Item == [ + [10, -20, 30, -40, 50, -60], + [70, -80, 90, -100, 110, -120], + ] + assert itemVarStore2.VarData[1].VarRegionIndex == [3, 4, 5, 6] + assert itemVarStore2.VarData[1].Item == [[5, -15, 25, -35], [45, -55, 65, -75]] + + +def makeTTFont(glyphOrder, features): + font = ttLib.TTFont() + font.setGlyphOrder(glyphOrder) + addOpenTypeFeaturesFromString(font, features) + font["name"] = ttLib.newTable("name") + return font + + +def _makeDSAxesDict(axes): + dsAxes = collections.OrderedDict() + for axisTag, axisValues in axes: + axis = designspaceLib.AxisDescriptor() + axis.name = axis.tag = axis.labelNames["en"] = axisTag + axis.minimum, axis.default, axis.maximum = axisValues + dsAxes[axis.tag] = axis + return dsAxes + + +def makeVariableFont(masters, baseIndex, axes, masterLocations): + vf = deepcopy(masters[baseIndex]) + dsAxes = _makeDSAxesDict(axes) + fvar = varLib._add_fvar(vf, dsAxes, instances=()) + axisTags = [axis.axisTag for axis in fvar.axes] + normalizedLocs = [models.normalizeLocation(m, dict(axes)) for m in masterLocations] + model = models.VariationModel(normalizedLocs, axisOrder=axisTags) + varLib._merge_OTL(vf, model, masters, axisTags) + return vf + + +def makeParametrizedVF(glyphOrder, features, values, increments): + # Create a test VF with given glyphs and parametrized OTL features. + # The VF is built from 9 masters (3 x 3 along wght and wdth), with + # locations hard-coded and base master at wght=400 and wdth=100. + # 'values' is a list of initial values that are interpolated in the + # 'features' string, and incremented for each subsequent master by the + # given 'increments' (list of 2-tuple) along the two axes. + assert values and len(values) == len(increments) + assert all(len(i) == 2 for i in increments) + masterLocations = [ + {"wght": 100, "wdth": 50}, + {"wght": 100, "wdth": 100}, + {"wght": 100, "wdth": 150}, + {"wght": 400, "wdth": 50}, + {"wght": 400, "wdth": 100}, # base master + {"wght": 400, "wdth": 150}, + {"wght": 700, "wdth": 50}, + {"wght": 700, "wdth": 100}, + {"wght": 700, "wdth": 150}, + ] + n = len(values) + values = list(values) + masters = [] + for _ in range(3): + for _ in range(3): + master = makeTTFont(glyphOrder, features=features % tuple(values)) + masters.append(master) + for i in range(n): + values[i] += increments[i][1] + for i in range(n): + values[i] += increments[i][0] + baseIndex = 4 + axes = [("wght", (100, 400, 700)), ("wdth", (50, 100, 150))] + vf = makeVariableFont(masters, baseIndex, axes, masterLocations) + return vf + + +@pytest.fixture +def varfontGDEF(): + glyphOrder = [".notdef", "f", "i", "f_i"] + features = ( + "feature liga { sub f i by f_i;} liga;" + "table GDEF { LigatureCaretByPos f_i %d; } GDEF;" + ) + values = [100] + increments = [(+30, +10)] + return makeParametrizedVF(glyphOrder, features, values, increments) + + +@pytest.fixture +def varfontGPOS(): + glyphOrder = [".notdef", "V", "A"] + features = "feature kern { pos V A %d; } kern;" + values = [-80] + increments = [(-10, -5)] + return makeParametrizedVF(glyphOrder, features, values, increments) + + +@pytest.fixture +def varfontGPOS2(): + glyphOrder = [".notdef", "V", "A", "acutecomb"] + features = ( + "markClass [acutecomb] <anchor 150 -10> @TOP_MARKS;" + "feature mark {" + " pos base A <anchor %d 450> mark @TOP_MARKS;" + "} mark;" + "feature kern {" + " pos V A %d;" + "} kern;" + ) + values = [200, -80] + increments = [(+30, +10), (-10, -5)] + return makeParametrizedVF(glyphOrder, features, values, increments) + + +class InstantiateOTLTest(object): + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0}, 110), # -60 + ({"wght": 0}, 170), + ({"wght": 0.5}, 200), # +30 + ({"wght": 1.0}, 230), # +60 + ({"wdth": -1.0}, 160), # -10 + ({"wdth": -0.3}, 167), # -3 + ({"wdth": 0}, 170), + ({"wdth": 1.0}, 180), # +10 + ], + ) + def test_pin_and_drop_axis_GDEF(self, varfontGDEF, location, expected): + vf = varfontGDEF + assert "GDEF" in vf + + instancer.instantiateOTL(vf, location) + + assert "GDEF" in vf + gdef = vf["GDEF"].table + assert gdef.Version == 0x00010003 + assert gdef.VarStore + assert gdef.LigCaretList + caretValue = gdef.LigCaretList.LigGlyph[0].CaretValue[0] + assert caretValue.Format == 3 + assert hasattr(caretValue, "DeviceTable") + assert caretValue.DeviceTable.DeltaFormat == 0x8000 + assert caretValue.Coordinate == expected + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0, "wdth": -1.0}, 100), # -60 - 10 + ({"wght": -1.0, "wdth": 0.0}, 110), # -60 + ({"wght": -1.0, "wdth": 1.0}, 120), # -60 + 10 + ({"wght": 0.0, "wdth": -1.0}, 160), # -10 + ({"wght": 0.0, "wdth": 0.0}, 170), + ({"wght": 0.0, "wdth": 1.0}, 180), # +10 + ({"wght": 1.0, "wdth": -1.0}, 220), # +60 - 10 + ({"wght": 1.0, "wdth": 0.0}, 230), # +60 + ({"wght": 1.0, "wdth": 1.0}, 240), # +60 + 10 + ], + ) + def test_full_instance_GDEF(self, varfontGDEF, location, expected): + vf = varfontGDEF + assert "GDEF" in vf + + instancer.instantiateOTL(vf, location) + + assert "GDEF" in vf + gdef = vf["GDEF"].table + assert gdef.Version == 0x00010000 + assert not hasattr(gdef, "VarStore") + assert gdef.LigCaretList + caretValue = gdef.LigCaretList.LigGlyph[0].CaretValue[0] + assert caretValue.Format == 1 + assert not hasattr(caretValue, "DeviceTable") + assert caretValue.Coordinate == expected + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0}, -85), # +25 + ({"wght": 0}, -110), + ({"wght": 1.0}, -135), # -25 + ({"wdth": -1.0}, -105), # +5 + ({"wdth": 0}, -110), + ({"wdth": 1.0}, -115), # -5 + ], + ) + def test_pin_and_drop_axis_GPOS_kern(self, varfontGPOS, location, expected): + vf = varfontGPOS + assert "GDEF" in vf + assert "GPOS" in vf + + instancer.instantiateOTL(vf, location) + + gdef = vf["GDEF"].table + gpos = vf["GPOS"].table + assert gdef.Version == 0x00010003 + assert gdef.VarStore + + assert gpos.LookupList.Lookup[0].LookupType == 2 # PairPos + pairPos = gpos.LookupList.Lookup[0].SubTable[0] + valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 + assert valueRec1.XAdvDevice + assert valueRec1.XAdvDevice.DeltaFormat == 0x8000 + assert valueRec1.XAdvance == expected + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0, "wdth": -1.0}, -80), # +25 + 5 + ({"wght": -1.0, "wdth": 0.0}, -85), # +25 + ({"wght": -1.0, "wdth": 1.0}, -90), # +25 - 5 + ({"wght": 0.0, "wdth": -1.0}, -105), # +5 + ({"wght": 0.0, "wdth": 0.0}, -110), + ({"wght": 0.0, "wdth": 1.0}, -115), # -5 + ({"wght": 1.0, "wdth": -1.0}, -130), # -25 + 5 + ({"wght": 1.0, "wdth": 0.0}, -135), # -25 + ({"wght": 1.0, "wdth": 1.0}, -140), # -25 - 5 + ], + ) + def test_full_instance_GPOS_kern(self, varfontGPOS, location, expected): + vf = varfontGPOS + assert "GDEF" in vf + assert "GPOS" in vf + + instancer.instantiateOTL(vf, location) + + assert "GDEF" not in vf + gpos = vf["GPOS"].table + + assert gpos.LookupList.Lookup[0].LookupType == 2 # PairPos + pairPos = gpos.LookupList.Lookup[0].SubTable[0] + valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 + assert not hasattr(valueRec1, "XAdvDevice") + assert valueRec1.XAdvance == expected + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0}, (210, -85)), # -60, +25 + ({"wght": 0}, (270, -110)), + ({"wght": 0.5}, (300, -122)), # +30, -12 + ({"wght": 1.0}, (330, -135)), # +60, -25 + ({"wdth": -1.0}, (260, -105)), # -10, +5 + ({"wdth": -0.3}, (267, -108)), # -3, +2 + ({"wdth": 0}, (270, -110)), + ({"wdth": 1.0}, (280, -115)), # +10, -5 + ], + ) + def test_pin_and_drop_axis_GPOS_mark_and_kern( + self, varfontGPOS2, location, expected + ): + vf = varfontGPOS2 + assert "GDEF" in vf + assert "GPOS" in vf + + instancer.instantiateOTL(vf, location) + + v1, v2 = expected + gdef = vf["GDEF"].table + gpos = vf["GPOS"].table + assert gdef.Version == 0x00010003 + assert gdef.VarStore + assert gdef.GlyphClassDef + + assert gpos.LookupList.Lookup[0].LookupType == 4 # MarkBasePos + markBasePos = gpos.LookupList.Lookup[0].SubTable[0] + baseAnchor = markBasePos.BaseArray.BaseRecord[0].BaseAnchor[0] + assert baseAnchor.Format == 3 + assert baseAnchor.XDeviceTable + assert baseAnchor.XDeviceTable.DeltaFormat == 0x8000 + assert not baseAnchor.YDeviceTable + assert baseAnchor.XCoordinate == v1 + assert baseAnchor.YCoordinate == 450 + + assert gpos.LookupList.Lookup[1].LookupType == 2 # PairPos + pairPos = gpos.LookupList.Lookup[1].SubTable[0] + valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 + assert valueRec1.XAdvDevice + assert valueRec1.XAdvDevice.DeltaFormat == 0x8000 + assert valueRec1.XAdvance == v2 + + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": -1.0, "wdth": -1.0}, (200, -80)), # -60 - 10, +25 + 5 + ({"wght": -1.0, "wdth": 0.0}, (210, -85)), # -60, +25 + ({"wght": -1.0, "wdth": 1.0}, (220, -90)), # -60 + 10, +25 - 5 + ({"wght": 0.0, "wdth": -1.0}, (260, -105)), # -10, +5 + ({"wght": 0.0, "wdth": 0.0}, (270, -110)), + ({"wght": 0.0, "wdth": 1.0}, (280, -115)), # +10, -5 + ({"wght": 1.0, "wdth": -1.0}, (320, -130)), # +60 - 10, -25 + 5 + ({"wght": 1.0, "wdth": 0.0}, (330, -135)), # +60, -25 + ({"wght": 1.0, "wdth": 1.0}, (340, -140)), # +60 + 10, -25 - 5 + ], + ) + def test_full_instance_GPOS_mark_and_kern(self, varfontGPOS2, location, expected): + vf = varfontGPOS2 + assert "GDEF" in vf + assert "GPOS" in vf + + instancer.instantiateOTL(vf, location) + + v1, v2 = expected + gdef = vf["GDEF"].table + gpos = vf["GPOS"].table + assert gdef.Version == 0x00010000 + assert not hasattr(gdef, "VarStore") + assert gdef.GlyphClassDef + + assert gpos.LookupList.Lookup[0].LookupType == 4 # MarkBasePos + markBasePos = gpos.LookupList.Lookup[0].SubTable[0] + baseAnchor = markBasePos.BaseArray.BaseRecord[0].BaseAnchor[0] + assert baseAnchor.Format == 1 + assert not hasattr(baseAnchor, "XDeviceTable") + assert not hasattr(baseAnchor, "YDeviceTable") + assert baseAnchor.XCoordinate == v1 + assert baseAnchor.YCoordinate == 450 + + assert gpos.LookupList.Lookup[1].LookupType == 2 # PairPos + pairPos = gpos.LookupList.Lookup[1].SubTable[0] + valueRec1 = pairPos.PairSet[0].PairValueRecord[0].Value1 + assert not hasattr(valueRec1, "XAdvDevice") + assert valueRec1.XAdvance == v2 + + +class InstantiateAvarTest(object): + @pytest.mark.parametrize("location", [{"wght": 0.0}, {"wdth": 0.0}]) + def test_pin_and_drop_axis(self, varfont, location): + instancer.instantiateAvar(varfont, location) + + assert set(varfont["avar"].segments).isdisjoint(location) + + def test_full_instance(self, varfont): + instancer.instantiateAvar(varfont, {"wght": 0.0, "wdth": 0.0}) + + assert "avar" not in varfont + + @staticmethod + def quantizeF2Dot14Floats(mapping): + return { + floatToFixedToFloat(k, 14): floatToFixedToFloat(v, 14) + for k, v in mapping.items() + } + + # the following values come from NotoSans-VF.ttf + DFLT_WGHT_MAPPING = { + -1.0: -1.0, + -0.6667: -0.7969, + -0.3333: -0.5, + 0: 0, + 0.2: 0.18, + 0.4: 0.38, + 0.6: 0.61, + 0.8: 0.79, + 1.0: 1.0, + } + + DFLT_WDTH_MAPPING = {-1.0: -1.0, -0.6667: -0.7, -0.3333: -0.36664, 0: 0, 1.0: 1.0} + + @pytest.fixture + def varfont(self): + fvarAxes = ("wght", (100, 400, 900)), ("wdth", (62.5, 100, 100)) + avarSegments = { + "wght": self.quantizeF2Dot14Floats(self.DFLT_WGHT_MAPPING), + "wdth": self.quantizeF2Dot14Floats(self.DFLT_WDTH_MAPPING), + } + varfont = ttLib.TTFont() + varfont["name"] = ttLib.newTable("name") + varLib._add_fvar(varfont, _makeDSAxesDict(fvarAxes), instances=()) + avar = varfont["avar"] = ttLib.newTable("avar") + avar.segments = avarSegments + return varfont + + @pytest.mark.parametrize( + "axisLimits, expectedSegments", + [ + pytest.param( + {"wght": (100, 900)}, + {"wght": DFLT_WGHT_MAPPING, "wdth": DFLT_WDTH_MAPPING}, + id="wght=100:900", + ), + pytest.param( + {"wght": (400, 900)}, + { + "wght": { + -1.0: -1.0, + 0: 0, + 0.2: 0.18, + 0.4: 0.38, + 0.6: 0.61, + 0.8: 0.79, + 1.0: 1.0, + }, + "wdth": DFLT_WDTH_MAPPING, + }, + id="wght=400:900", + ), + pytest.param( + {"wght": (100, 400)}, + { + "wght": { + -1.0: -1.0, + -0.6667: -0.7969, + -0.3333: -0.5, + 0: 0, + 1.0: 1.0, + }, + "wdth": DFLT_WDTH_MAPPING, + }, + id="wght=100:400", + ), + pytest.param( + {"wght": (400, 800)}, + { + "wght": { + -1.0: -1.0, + 0: 0, + 0.25: 0.22784, + 0.50006: 0.48103, + 0.75: 0.77214, + 1.0: 1.0, + }, + "wdth": DFLT_WDTH_MAPPING, + }, + id="wght=400:800", + ), + pytest.param( + {"wght": (400, 700)}, + { + "wght": { + -1.0: -1.0, + 0: 0, + 0.3334: 0.2951, + 0.66675: 0.623, + 1.0: 1.0, + }, + "wdth": DFLT_WDTH_MAPPING, + }, + id="wght=400:700", + ), + pytest.param( + {"wght": (400, 600)}, + { + "wght": {-1.0: -1.0, 0: 0, 0.5: 0.47363, 1.0: 1.0}, + "wdth": DFLT_WDTH_MAPPING, + }, + id="wght=400:600", + ), + pytest.param( + {"wdth": (62.5, 100)}, + { + "wght": DFLT_WGHT_MAPPING, + "wdth": { + -1.0: -1.0, + -0.6667: -0.7, + -0.3333: -0.36664, + 0: 0, + 1.0: 1.0, + }, + }, + id="wdth=62.5:100", + ), + pytest.param( + {"wdth": (70, 100)}, + { + "wght": DFLT_WGHT_MAPPING, + "wdth": { + -1.0: -1.0, + -0.8334: -0.85364, + -0.4166: -0.44714, + 0: 0, + 1.0: 1.0, + }, + }, + id="wdth=70:100", + ), + pytest.param( + {"wdth": (75, 100)}, + { + "wght": DFLT_WGHT_MAPPING, + "wdth": {-1.0: -1.0, -0.49994: -0.52374, 0: 0, 1.0: 1.0}, + }, + id="wdth=75:100", + ), + pytest.param( + {"wdth": (77, 100)}, + { + "wght": DFLT_WGHT_MAPPING, + "wdth": {-1.0: -1.0, -0.54346: -0.56696, 0: 0, 1.0: 1.0}, + }, + id="wdth=77:100", + ), + pytest.param( + {"wdth": (87.5, 100)}, + {"wght": DFLT_WGHT_MAPPING, "wdth": {-1.0: -1.0, 0: 0, 1.0: 1.0}}, + id="wdth=87.5:100", + ), + ], + ) + def test_limit_axes(self, varfont, axisLimits, expectedSegments): + instancer.instantiateAvar(varfont, axisLimits) + + newSegments = varfont["avar"].segments + expectedSegments = { + axisTag: self.quantizeF2Dot14Floats(mapping) + for axisTag, mapping in expectedSegments.items() + } + assert newSegments == expectedSegments + + @pytest.mark.parametrize( + "invalidSegmentMap", + [ + pytest.param({0.5: 0.5}, id="missing-required-maps-1"), + pytest.param({-1.0: -1.0, 1.0: 1.0}, id="missing-required-maps-2"), + pytest.param( + {-1.0: -1.0, 0: 0, 0.5: 0.5, 0.6: 0.4, 1.0: 1.0}, + id="retrograde-value-maps", + ), + ], + ) + def test_drop_invalid_segment_map(self, varfont, invalidSegmentMap, caplog): + varfont["avar"].segments["wght"] = invalidSegmentMap + + with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): + instancer.instantiateAvar(varfont, {"wght": (100, 400)}) + + assert "Invalid avar" in caplog.text + assert "wght" not in varfont["avar"].segments + + def test_isValidAvarSegmentMap(self): + assert instancer._isValidAvarSegmentMap("FOOO", {}) + assert instancer._isValidAvarSegmentMap("FOOO", {-1.0: -1.0, 0: 0, 1.0: 1.0}) + assert instancer._isValidAvarSegmentMap( + "FOOO", {-1.0: -1.0, 0: 0, 0.5: 0.5, 1.0: 1.0} + ) + assert instancer._isValidAvarSegmentMap( + "FOOO", {-1.0: -1.0, 0: 0, 0.5: 0.5, 0.7: 0.5, 1.0: 1.0} + ) + + +class InstantiateFvarTest(object): + @pytest.mark.parametrize( + "location, instancesLeft", + [ + ( + {"wght": 400.0}, + ["Regular", "SemiCondensed", "Condensed", "ExtraCondensed"], + ), + ( + {"wght": 100.0}, + ["Thin", "SemiCondensed Thin", "Condensed Thin", "ExtraCondensed Thin"], + ), + ( + {"wdth": 100.0}, + [ + "Thin", + "ExtraLight", + "Light", + "Regular", + "Medium", + "SemiBold", + "Bold", + "ExtraBold", + "Black", + ], + ), + # no named instance at pinned location + ({"wdth": 90.0}, []), + ], + ) + def test_pin_and_drop_axis(self, varfont, location, instancesLeft): + instancer.instantiateFvar(varfont, location) + + fvar = varfont["fvar"] + assert {a.axisTag for a in fvar.axes}.isdisjoint(location) + + for instance in fvar.instances: + assert set(instance.coordinates).isdisjoint(location) + + name = varfont["name"] + assert [ + name.getDebugName(instance.subfamilyNameID) for instance in fvar.instances + ] == instancesLeft + + def test_full_instance(self, varfont): + instancer.instantiateFvar(varfont, {"wght": 0.0, "wdth": 0.0}) + + assert "fvar" not in varfont + + +class InstantiateSTATTest(object): + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": 400}, ["Regular", "Condensed", "Upright", "Normal"]), + ({"wdth": 100}, ["Thin", "Regular", "Black", "Upright", "Normal"]), + ], + ) + def test_pin_and_drop_axis(self, varfont, location, expected): + instancer.instantiateSTAT(varfont, location) + + stat = varfont["STAT"].table + designAxes = {a.AxisTag for a in stat.DesignAxisRecord.Axis} + + assert designAxes == {"wght", "wdth", "ital"} + + name = varfont["name"] + valueNames = [] + for axisValueTable in stat.AxisValueArray.AxisValue: + valueName = name.getDebugName(axisValueTable.ValueNameID) + valueNames.append(valueName) + + assert valueNames == expected + + def test_skip_table_no_axis_value_array(self, varfont): + varfont["STAT"].table.AxisValueArray = None + + instancer.instantiateSTAT(varfont, {"wght": 100}) + + assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3 + assert varfont["STAT"].table.AxisValueArray is None + + def test_skip_table_axis_value_array_empty(self, varfont): + varfont["STAT"].table.AxisValueArray.AxisValue = [] + + instancer.instantiateSTAT(varfont, {"wght": 100}) + + assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3 + assert not varfont["STAT"].table.AxisValueArray.AxisValue + + def test_skip_table_no_design_axes(self, varfont): + stat = otTables.STAT() + stat.Version = 0x00010001 + stat.populateDefaults() + assert not stat.DesignAxisRecord + assert not stat.AxisValueArray + varfont["STAT"].table = stat + + instancer.instantiateSTAT(varfont, {"wght": 100}) + + assert not varfont["STAT"].table.DesignAxisRecord + + @staticmethod + def get_STAT_axis_values(stat): + axes = stat.DesignAxisRecord.Axis + result = [] + for axisValue in stat.AxisValueArray.AxisValue: + if axisValue.Format == 1: + result.append((axes[axisValue.AxisIndex].AxisTag, axisValue.Value)) + elif axisValue.Format == 3: + result.append( + ( + axes[axisValue.AxisIndex].AxisTag, + (axisValue.Value, axisValue.LinkedValue), + ) + ) + elif axisValue.Format == 2: + result.append( + ( + axes[axisValue.AxisIndex].AxisTag, + ( + axisValue.RangeMinValue, + axisValue.NominalValue, + axisValue.RangeMaxValue, + ), + ) + ) + elif axisValue.Format == 4: + result.append( + tuple( + (axes[rec.AxisIndex].AxisTag, rec.Value) + for rec in axisValue.AxisValueRecord + ) + ) + else: + raise AssertionError(axisValue.Format) + return result + + def test_limit_axes(self, varfont2): + instancer.instantiateSTAT(varfont2, {"wght": (400, 500), "wdth": (75, 100)}) + + assert len(varfont2["STAT"].table.AxisValueArray.AxisValue) == 5 + assert self.get_STAT_axis_values(varfont2["STAT"].table) == [ + ("wght", (400.0, 700.0)), + ("wght", 500.0), + ("wdth", (93.75, 100.0, 100.0)), + ("wdth", (81.25, 87.5, 93.75)), + ("wdth", (68.75, 75.0, 81.25)), + ] + + def test_limit_axis_value_format_4(self, varfont2): + stat = varfont2["STAT"].table + + axisValue = otTables.AxisValue() + axisValue.Format = 4 + axisValue.AxisValueRecord = [] + for tag, value in (("wght", 575), ("wdth", 90)): + rec = otTables.AxisValueRecord() + rec.AxisIndex = next( + i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag + ) + rec.Value = value + axisValue.AxisValueRecord.append(rec) + stat.AxisValueArray.AxisValue.append(axisValue) + + instancer.instantiateSTAT(varfont2, {"wght": (100, 600)}) + + assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue + + instancer.instantiateSTAT(varfont2, {"wdth": (62.5, 87.5)}) + + assert axisValue not in varfont2["STAT"].table.AxisValueArray.AxisValue + + def test_unknown_axis_value_format(self, varfont2, caplog): + stat = varfont2["STAT"].table + axisValue = otTables.AxisValue() + axisValue.Format = 5 + stat.AxisValueArray.AxisValue.append(axisValue) + + with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): + instancer.instantiateSTAT(varfont2, {"wght": 400}) + + assert "Unknown AxisValue table format (5)" in caplog.text + assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue + + +def test_setMacOverlapFlags(): + flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND + flagOverlapSimple = _g_l_y_f.flagOverlapSimple + + glyf = ttLib.newTable("glyf") + glyf.glyphOrder = ["a", "b", "c"] + a = _g_l_y_f.Glyph() + a.numberOfContours = 1 + a.flags = [0] + b = _g_l_y_f.Glyph() + b.numberOfContours = -1 + comp = _g_l_y_f.GlyphComponent() + comp.flags = 0 + b.components = [comp] + c = _g_l_y_f.Glyph() + c.numberOfContours = 0 + glyf.glyphs = {"a": a, "b": b, "c": c} + + instancer.setMacOverlapFlags(glyf) + + assert a.flags[0] & flagOverlapSimple != 0 + assert b.components[0].flags & flagOverlapCompound != 0 + + +def _strip_ttLibVersion(string): + return re.sub(' ttLibVersion=".*"', "", string) + + +@pytest.fixture +def varfont2(): + f = ttLib.TTFont(recalcTimestamp=False) + f.importXML(os.path.join(TESTDATA, "PartialInstancerTest2-VF.ttx")) + return f + + +@pytest.fixture +def varfont3(): + f = ttLib.TTFont(recalcTimestamp=False) + f.importXML(os.path.join(TESTDATA, "PartialInstancerTest3-VF.ttx")) + return f + + +def _dump_ttx(ttFont): + # compile to temporary bytes stream, reload and dump to XML + tmp = BytesIO() + ttFont.save(tmp) + tmp.seek(0) + ttFont2 = ttLib.TTFont(tmp, recalcBBoxes=False, recalcTimestamp=False) + s = StringIO() + ttFont2.saveXML(s, newlinestr="\n") + return _strip_ttLibVersion(s.getvalue()) + + +def _get_expected_instance_ttx( + name, *locations, overlap=instancer.OverlapMode.KEEP_AND_SET_FLAGS +): + filename = f"{name}-VF-instance-{','.join(str(loc) for loc in locations)}" + if overlap == instancer.OverlapMode.KEEP_AND_DONT_SET_FLAGS: + filename += "-no-overlap-flags" + elif overlap == instancer.OverlapMode.REMOVE: + filename += "-no-overlaps" + with open( + os.path.join(TESTDATA, "test_results", f"{filename}.ttx"), + "r", + encoding="utf-8", + ) as fp: + return _strip_ttLibVersion(fp.read()) + + +class InstantiateVariableFontTest(object): + @pytest.mark.parametrize( + "wght, wdth", + [(100, 100), (400, 100), (900, 100), (100, 62.5), (400, 62.5), (900, 62.5)], + ) + def test_multiple_instancing(self, varfont2, wght, wdth): + partial = instancer.instantiateVariableFont(varfont2, {"wght": wght}) + instance = instancer.instantiateVariableFont(partial, {"wdth": wdth}) + + expected = _get_expected_instance_ttx("PartialInstancerTest2", wght, wdth) + + assert _dump_ttx(instance) == expected + + def test_default_instance(self, varfont2): + instance = instancer.instantiateVariableFont( + varfont2, {"wght": None, "wdth": None} + ) + + expected = _get_expected_instance_ttx("PartialInstancerTest2", 400, 100) + + assert _dump_ttx(instance) == expected + + @pytest.mark.parametrize( + "overlap, wght", + [ + (instancer.OverlapMode.KEEP_AND_DONT_SET_FLAGS, 400), + (instancer.OverlapMode.REMOVE, 400), + (instancer.OverlapMode.REMOVE, 700), + ], + ) + def test_overlap(self, varfont3, wght, overlap): + pytest.importorskip("pathops") + + location = {"wght": wght} + + instance = instancer.instantiateVariableFont( + varfont3, location, overlap=overlap + ) + + expected = _get_expected_instance_ttx( + "PartialInstancerTest3", wght, overlap=overlap + ) + + assert _dump_ttx(instance) == expected + + +def _conditionSetAsDict(conditionSet, axisOrder): + result = {} + for cond in conditionSet.ConditionTable: + assert cond.Format == 1 + axisTag = axisOrder[cond.AxisIndex] + result[axisTag] = (cond.FilterRangeMinValue, cond.FilterRangeMaxValue) + return result + + +def _getSubstitutions(gsub, lookupIndices): + subs = {} + for index, lookup in enumerate(gsub.LookupList.Lookup): + if index in lookupIndices: + for subtable in lookup.SubTable: + subs.update(subtable.mapping) + return subs + + +def makeFeatureVarsFont(conditionalSubstitutions): + axes = set() + glyphs = set() + for region, substitutions in conditionalSubstitutions: + for box in region: + axes.update(box.keys()) + glyphs.update(*substitutions.items()) + + varfont = ttLib.TTFont() + varfont.setGlyphOrder(sorted(glyphs)) + + fvar = varfont["fvar"] = ttLib.newTable("fvar") + fvar.axes = [] + for axisTag in sorted(axes): + axis = _f_v_a_r.Axis() + axis.axisTag = Tag(axisTag) + fvar.axes.append(axis) + + featureVars.addFeatureVariations(varfont, conditionalSubstitutions) + + return varfont + + +class InstantiateFeatureVariationsTest(object): + @pytest.mark.parametrize( + "location, appliedSubs, expectedRecords", + [ + ({"wght": 0}, {}, [({"cntr": (0.75, 1.0)}, {"uni0041": "uni0061"})]), + ( + {"wght": -1.0}, + {}, + [ + ({"cntr": (0, 0.25)}, {"uni0061": "uni0041"}), + ({"cntr": (0.75, 1.0)}, {"uni0041": "uni0061"}), + ], + ), + ( + {"wght": 1.0}, + {"uni0024": "uni0024.nostroke"}, + [ + ( + {"cntr": (0.75, 1.0)}, + {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"}, + ) + ], + ), + ( + {"cntr": 0}, + {}, + [ + ({"wght": (-1.0, -0.45654)}, {"uni0061": "uni0041"}), + ({"wght": (0.20886, 1.0)}, {"uni0024": "uni0024.nostroke"}), + ], + ), + ( + {"cntr": 1.0}, + {"uni0041": "uni0061"}, + [ + ( + {"wght": (0.20886, 1.0)}, + {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"}, + ) + ], + ), + ], + ) + def test_partial_instance(self, location, appliedSubs, expectedRecords): + font = makeFeatureVarsFont( + [ + ([{"wght": (0.20886, 1.0)}], {"uni0024": "uni0024.nostroke"}), + ([{"cntr": (0.75, 1.0)}], {"uni0041": "uni0061"}), + ( + [{"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}], + {"uni0061": "uni0041"}, + ), + ] + ) + + instancer.instantiateFeatureVariations(font, location) + + gsub = font["GSUB"].table + featureVariations = gsub.FeatureVariations + + assert featureVariations.FeatureVariationCount == len(expectedRecords) + + axisOrder = [a.axisTag for a in font["fvar"].axes if a.axisTag not in location] + for i, (expectedConditionSet, expectedSubs) in enumerate(expectedRecords): + rec = featureVariations.FeatureVariationRecord[i] + conditionSet = _conditionSetAsDict(rec.ConditionSet, axisOrder) + + assert conditionSet == expectedConditionSet + + subsRecord = rec.FeatureTableSubstitution.SubstitutionRecord[0] + lookupIndices = subsRecord.Feature.LookupListIndex + substitutions = _getSubstitutions(gsub, lookupIndices) + + assert substitutions == expectedSubs + + appliedLookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex + + assert _getSubstitutions(gsub, appliedLookupIndices) == appliedSubs + + @pytest.mark.parametrize( + "location, appliedSubs", + [ + ({"wght": 0, "cntr": 0}, None), + ({"wght": -1.0, "cntr": 0}, {"uni0061": "uni0041"}), + ({"wght": 1.0, "cntr": 0}, {"uni0024": "uni0024.nostroke"}), + ({"wght": 0.0, "cntr": 1.0}, {"uni0041": "uni0061"}), + ( + {"wght": 1.0, "cntr": 1.0}, + {"uni0041": "uni0061", "uni0024": "uni0024.nostroke"}, + ), + ({"wght": -1.0, "cntr": 0.3}, None), + ], + ) + def test_full_instance(self, location, appliedSubs): + font = makeFeatureVarsFont( + [ + ([{"wght": (0.20886, 1.0)}], {"uni0024": "uni0024.nostroke"}), + ([{"cntr": (0.75, 1.0)}], {"uni0041": "uni0061"}), + ( + [{"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}], + {"uni0061": "uni0041"}, + ), + ] + ) + + instancer.instantiateFeatureVariations(font, location) + + gsub = font["GSUB"].table + assert not hasattr(gsub, "FeatureVariations") + + if appliedSubs: + lookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex + assert _getSubstitutions(gsub, lookupIndices) == appliedSubs + else: + assert not gsub.FeatureList.FeatureRecord + + def test_unsupported_condition_format(self, caplog): + font = makeFeatureVarsFont( + [ + ( + [{"wdth": (-1.0, -0.5), "wght": (0.5, 1.0)}], + {"dollar": "dollar.nostroke"}, + ) + ] + ) + featureVariations = font["GSUB"].table.FeatureVariations + rec1 = featureVariations.FeatureVariationRecord[0] + assert len(rec1.ConditionSet.ConditionTable) == 2 + rec1.ConditionSet.ConditionTable[0].Format = 2 + + with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): + instancer.instantiateFeatureVariations(font, {"wdth": 0}) + + assert ( + "Condition table 0 of FeatureVariationRecord 0 " + "has unsupported format (2); ignored" + ) in caplog.text + + # check that record with unsupported condition format (but whose other + # conditions do not reference pinned axes) is kept as is + featureVariations = font["GSUB"].table.FeatureVariations + assert featureVariations.FeatureVariationRecord[0] is rec1 + assert len(rec1.ConditionSet.ConditionTable) == 2 + assert rec1.ConditionSet.ConditionTable[0].Format == 2 + + def test_GSUB_FeatureVariations_is_None(self, varfont2): + varfont2["GSUB"].table.Version = 0x00010001 + varfont2["GSUB"].table.FeatureVariations = None + tmp = BytesIO() + varfont2.save(tmp) + varfont = ttLib.TTFont(tmp) + + # DO NOT raise an exception when the optional 'FeatureVariations' attribute is + # present but is set to None (e.g. with GSUB 1.1); skip and do nothing. + assert varfont["GSUB"].table.FeatureVariations is None + instancer.instantiateFeatureVariations(varfont, {"wght": 400, "wdth": 100}) + assert varfont["GSUB"].table.FeatureVariations is None + + +class LimitTupleVariationAxisRangesTest: + def check_limit_single_var_axis_range(self, var, axisTag, axisRange, expected): + result = instancer.limitTupleVariationAxisRange(var, axisTag, axisRange) + print(result) + + assert len(result) == len(expected) + for v1, v2 in zip(result, expected): + assert v1.coordinates == pytest.approx(v2.coordinates) + assert v1.axes.keys() == v2.axes.keys() + for k in v1.axes: + p, q = v1.axes[k], v2.axes[k] + assert p == pytest.approx(q) + + @pytest.mark.parametrize( + "var, axisTag, newMax, expected", + [ + ( + TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), + "wdth", + 0.5, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), + "wght", + 0.5, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [50, 50])], + ), + ( + TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), + "wght", + 0.8, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [80, 80])], + ), + ( + TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), + "wght", + 1.0, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])], + ), + (TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), "wght", 0.0, []), + (TupleVariation({"wght": (0.5, 1.0, 1.0)}, [100, 100]), "wght", 0.4, []), + ( + TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]), + "wght", + 0.5, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]), + "wght", + 0.4, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [80, 80])], + ), + ( + TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]), + "wght", + 0.6, + [TupleVariation({"wght": (0.0, 0.833334, 1.666667)}, [100, 100])], + ), + ( + TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]), + "wght", + 0.4, + [ + TupleVariation({"wght": (0.0, 0.5, 1.99994)}, [100, 100]), + TupleVariation({"wght": (0.5, 1.0, 1.0)}, [8.33333, 8.33333]), + ], + ), + ( + TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]), + "wght", + 0.5, + [TupleVariation({"wght": (0.0, 0.4, 1.99994)}, [100, 100])], + ), + ( + TupleVariation({"wght": (0.5, 0.5, 1.0)}, [100, 100]), + "wght", + 0.5, + [TupleVariation({"wght": (1.0, 1.0, 1.0)}, [100, 100])], + ), + ], + ) + def test_positive_var(self, var, axisTag, newMax, expected): + axisRange = instancer.NormalizedAxisRange(0, newMax) + self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) + + @pytest.mark.parametrize( + "var, axisTag, newMin, expected", + [ + ( + TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), + "wdth", + -0.5, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), + "wght", + -0.5, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [50, 50])], + ), + ( + TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), + "wght", + -0.8, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [80, 80])], + ), + ( + TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), + "wght", + -1.0, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])], + ), + (TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), "wght", 0.0, []), + ( + TupleVariation({"wght": (-1.0, -1.0, -0.5)}, [100, 100]), + "wght", + -0.4, + [], + ), + ( + TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]), + "wght", + -0.5, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]), + "wght", + -0.4, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [80, 80])], + ), + ( + TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]), + "wght", + -0.6, + [TupleVariation({"wght": (-1.666667, -0.833334, 0.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]), + "wght", + -0.4, + [ + TupleVariation({"wght": (-2.0, -0.5, -0.0)}, [100, 100]), + TupleVariation({"wght": (-1.0, -1.0, -0.5)}, [8.33333, 8.33333]), + ], + ), + ( + TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]), + "wght", + -0.5, + [TupleVariation({"wght": (-2.0, -0.4, 0.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (-1.0, -0.5, -0.5)}, [100, 100]), + "wght", + -0.5, + [TupleVariation({"wght": (-1.0, -1.0, -1.0)}, [100, 100])], + ), + ], + ) + def test_negative_var(self, var, axisTag, newMin, expected): + axisRange = instancer.NormalizedAxisRange(newMin, 0) + self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) + + +@pytest.mark.parametrize( + "oldRange, newRange, expected", + [ + ((1.0, -1.0), (-1.0, 1.0), None), # invalid oldRange min > max + ((0.6, 1.0), (0, 0.5), None), + ((-1.0, -0.6), (-0.5, 0), None), + ((0.4, 1.0), (0, 0.5), (0.8, 1.0)), + ((-1.0, -0.4), (-0.5, 0), (-1.0, -0.8)), + ((0.4, 1.0), (0, 0.4), (1.0, 1.0)), + ((-1.0, -0.4), (-0.4, 0), (-1.0, -1.0)), + ((-0.5, 0.5), (-0.4, 0.4), (-1.0, 1.0)), + ((0, 1.0), (-1.0, 0), (0, 0)), # or None? + ((-1.0, 0), (0, 1.0), (0, 0)), # or None? + ], +) +def test_limitFeatureVariationConditionRange(oldRange, newRange, expected): + condition = featureVars.buildConditionTable(0, *oldRange) + + result = instancer._limitFeatureVariationConditionRange( + condition, instancer.NormalizedAxisRange(*newRange) + ) + + assert result == expected + + +@pytest.mark.parametrize( + "limits, expected", + [ + (["wght=400", "wdth=100"], {"wght": 400, "wdth": 100}), + (["wght=400:900"], {"wght": (400, 900)}), + (["slnt=11.4"], {"slnt": pytest.approx(11.399994)}), + (["ABCD=drop"], {"ABCD": None}), + ], +) +def test_parseLimits(limits, expected): + assert instancer.parseLimits(limits) == expected + + +@pytest.mark.parametrize( + "limits", [["abcde=123", "=0", "wght=:", "wght=1:", "wght=abcd", "wght=x:y"]] +) +def test_parseLimits_invalid(limits): + with pytest.raises(ValueError, match="invalid location format"): + instancer.parseLimits(limits) + + +def test_normalizeAxisLimits_tuple(varfont): + normalized = instancer.normalizeAxisLimits(varfont, {"wght": (100, 400)}) + assert normalized == {"wght": (-1.0, 0)} + + +def test_normalizeAxisLimits_unsupported_range(varfont): + with pytest.raises(NotImplementedError, match="Unsupported range"): + instancer.normalizeAxisLimits(varfont, {"wght": (401, 700)}) + + +def test_normalizeAxisLimits_no_avar(varfont): + del varfont["avar"] + + normalized = instancer.normalizeAxisLimits(varfont, {"wght": (400, 500)}) + + assert normalized["wght"] == pytest.approx((0, 0.2), 1e-4) + + +def test_normalizeAxisLimits_missing_from_fvar(varfont): + with pytest.raises(ValueError, match="not present in fvar"): + instancer.normalizeAxisLimits(varfont, {"ZZZZ": 1000}) + + +def test_sanityCheckVariableTables(varfont): + font = ttLib.TTFont() + with pytest.raises(ValueError, match="Missing required table fvar"): + instancer.sanityCheckVariableTables(font) + + del varfont["glyf"] + + with pytest.raises(ValueError, match="Can't have gvar without glyf"): + instancer.sanityCheckVariableTables(varfont) + + +def test_main(varfont, tmpdir): + fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf") + varfont.save(fontfile) + args = [fontfile, "wght=400"] + + # exits without errors + assert instancer.main(args) is None + + +def test_main_exit_nonexistent_file(capsys): + with pytest.raises(SystemExit): + instancer.main([""]) + captured = capsys.readouterr() + + assert "No such file ''" in captured.err + + +def test_main_exit_invalid_location(varfont, tmpdir, capsys): + fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf") + varfont.save(fontfile) + + with pytest.raises(SystemExit): + instancer.main([fontfile, "wght:100"]) + captured = capsys.readouterr() + + assert "invalid location format" in captured.err + + +def test_main_exit_multiple_limits(varfont, tmpdir, capsys): + fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf") + varfont.save(fontfile) + + with pytest.raises(SystemExit): + instancer.main([fontfile, "wght=400", "wght=90"]) + captured = capsys.readouterr() + + assert "Specified multiple limits for the same axis" in captured.err |