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