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