aboutsummaryrefslogtreecommitdiff
path: root/Tests/varLib/instancer/instancer_test.py
diff options
context:
space:
mode:
Diffstat (limited to 'Tests/varLib/instancer/instancer_test.py')
-rw-r--r--Tests/varLib/instancer/instancer_test.py1939
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