• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1from fontTools.misc.py23 import Tag
2from fontTools.misc.fixedTools import floatToFixedToFloat
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
940class InstantiateAvarTest(object):
941    @pytest.mark.parametrize("location", [{"wght": 0.0}, {"wdth": 0.0}])
942    def test_pin_and_drop_axis(self, varfont, location):
943        instancer.instantiateAvar(varfont, location)
944
945        assert set(varfont["avar"].segments).isdisjoint(location)
946
947    def test_full_instance(self, varfont):
948        instancer.instantiateAvar(varfont, {"wght": 0.0, "wdth": 0.0})
949
950        assert "avar" not in varfont
951
952    @staticmethod
953    def quantizeF2Dot14Floats(mapping):
954        return {
955            floatToFixedToFloat(k, 14): floatToFixedToFloat(v, 14)
956            for k, v in mapping.items()
957        }
958
959    # the following values come from NotoSans-VF.ttf
960    DFLT_WGHT_MAPPING = {
961        -1.0: -1.0,
962        -0.6667: -0.7969,
963        -0.3333: -0.5,
964        0: 0,
965        0.2: 0.18,
966        0.4: 0.38,
967        0.6: 0.61,
968        0.8: 0.79,
969        1.0: 1.0,
970    }
971
972    DFLT_WDTH_MAPPING = {-1.0: -1.0, -0.6667: -0.7, -0.3333: -0.36664, 0: 0, 1.0: 1.0}
973
974    @pytest.fixture
975    def varfont(self):
976        fvarAxes = ("wght", (100, 400, 900)), ("wdth", (62.5, 100, 100))
977        avarSegments = {
978            "wght": self.quantizeF2Dot14Floats(self.DFLT_WGHT_MAPPING),
979            "wdth": self.quantizeF2Dot14Floats(self.DFLT_WDTH_MAPPING),
980        }
981        varfont = ttLib.TTFont()
982        varfont["name"] = ttLib.newTable("name")
983        varLib._add_fvar(varfont, _makeDSAxesDict(fvarAxes), instances=())
984        avar = varfont["avar"] = ttLib.newTable("avar")
985        avar.segments = avarSegments
986        return varfont
987
988    @pytest.mark.parametrize(
989        "axisLimits, expectedSegments",
990        [
991            pytest.param(
992                {"wght": (100, 900)},
993                {"wght": DFLT_WGHT_MAPPING, "wdth": DFLT_WDTH_MAPPING},
994                id="wght=100:900",
995            ),
996            pytest.param(
997                {"wght": (400, 900)},
998                {
999                    "wght": {
1000                        -1.0: -1.0,
1001                        0: 0,
1002                        0.2: 0.18,
1003                        0.4: 0.38,
1004                        0.6: 0.61,
1005                        0.8: 0.79,
1006                        1.0: 1.0,
1007                    },
1008                    "wdth": DFLT_WDTH_MAPPING,
1009                },
1010                id="wght=400:900",
1011            ),
1012            pytest.param(
1013                {"wght": (100, 400)},
1014                {
1015                    "wght": {
1016                        -1.0: -1.0,
1017                        -0.6667: -0.7969,
1018                        -0.3333: -0.5,
1019                        0: 0,
1020                        1.0: 1.0,
1021                    },
1022                    "wdth": DFLT_WDTH_MAPPING,
1023                },
1024                id="wght=100:400",
1025            ),
1026            pytest.param(
1027                {"wght": (400, 800)},
1028                {
1029                    "wght": {
1030                        -1.0: -1.0,
1031                        0: 0,
1032                        0.25: 0.22784,
1033                        0.50006: 0.48103,
1034                        0.75: 0.77214,
1035                        1.0: 1.0,
1036                    },
1037                    "wdth": DFLT_WDTH_MAPPING,
1038                },
1039                id="wght=400:800",
1040            ),
1041            pytest.param(
1042                {"wght": (400, 700)},
1043                {
1044                    "wght": {
1045                        -1.0: -1.0,
1046                        0: 0,
1047                        0.3334: 0.2951,
1048                        0.66675: 0.623,
1049                        1.0: 1.0,
1050                    },
1051                    "wdth": DFLT_WDTH_MAPPING,
1052                },
1053                id="wght=400:700",
1054            ),
1055            pytest.param(
1056                {"wght": (400, 600)},
1057                {
1058                    "wght": {-1.0: -1.0, 0: 0, 0.5: 0.47363, 1.0: 1.0},
1059                    "wdth": DFLT_WDTH_MAPPING,
1060                },
1061                id="wght=400:600",
1062            ),
1063            pytest.param(
1064                {"wdth": (62.5, 100)},
1065                {
1066                    "wght": DFLT_WGHT_MAPPING,
1067                    "wdth": {
1068                        -1.0: -1.0,
1069                        -0.6667: -0.7,
1070                        -0.3333: -0.36664,
1071                        0: 0,
1072                        1.0: 1.0,
1073                    },
1074                },
1075                id="wdth=62.5:100",
1076            ),
1077            pytest.param(
1078                {"wdth": (70, 100)},
1079                {
1080                    "wght": DFLT_WGHT_MAPPING,
1081                    "wdth": {
1082                        -1.0: -1.0,
1083                        -0.8334: -0.85364,
1084                        -0.4166: -0.44714,
1085                        0: 0,
1086                        1.0: 1.0,
1087                    },
1088                },
1089                id="wdth=70:100",
1090            ),
1091            pytest.param(
1092                {"wdth": (75, 100)},
1093                {
1094                    "wght": DFLT_WGHT_MAPPING,
1095                    "wdth": {-1.0: -1.0, -0.49994: -0.52374, 0: 0, 1.0: 1.0},
1096                },
1097                id="wdth=75:100",
1098            ),
1099            pytest.param(
1100                {"wdth": (77, 100)},
1101                {
1102                    "wght": DFLT_WGHT_MAPPING,
1103                    "wdth": {-1.0: -1.0, -0.54346: -0.56696, 0: 0, 1.0: 1.0},
1104                },
1105                id="wdth=77:100",
1106            ),
1107            pytest.param(
1108                {"wdth": (87.5, 100)},
1109                {"wght": DFLT_WGHT_MAPPING, "wdth": {-1.0: -1.0, 0: 0, 1.0: 1.0}},
1110                id="wdth=87.5:100",
1111            ),
1112        ],
1113    )
1114    def test_limit_axes(self, varfont, axisLimits, expectedSegments):
1115        instancer.instantiateAvar(varfont, axisLimits)
1116
1117        newSegments = varfont["avar"].segments
1118        expectedSegments = {
1119            axisTag: self.quantizeF2Dot14Floats(mapping)
1120            for axisTag, mapping in expectedSegments.items()
1121        }
1122        assert newSegments == expectedSegments
1123
1124    @pytest.mark.parametrize(
1125        "invalidSegmentMap",
1126        [
1127            pytest.param({0.5: 0.5}, id="missing-required-maps-1"),
1128            pytest.param({-1.0: -1.0, 1.0: 1.0}, id="missing-required-maps-2"),
1129            pytest.param(
1130                {-1.0: -1.0, 0: 0, 0.5: 0.5, 0.6: 0.4, 1.0: 1.0},
1131                id="retrograde-value-maps",
1132            ),
1133        ],
1134    )
1135    def test_drop_invalid_segment_map(self, varfont, invalidSegmentMap, caplog):
1136        varfont["avar"].segments["wght"] = invalidSegmentMap
1137
1138        with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"):
1139            instancer.instantiateAvar(varfont, {"wght": (100, 400)})
1140
1141        assert "Invalid avar" in caplog.text
1142        assert "wght" not in varfont["avar"].segments
1143
1144    def test_isValidAvarSegmentMap(self):
1145        assert instancer._isValidAvarSegmentMap("FOOO", {})
1146        assert instancer._isValidAvarSegmentMap("FOOO", {-1.0: -1.0, 0: 0, 1.0: 1.0})
1147        assert instancer._isValidAvarSegmentMap(
1148            "FOOO", {-1.0: -1.0, 0: 0, 0.5: 0.5, 1.0: 1.0}
1149        )
1150        assert instancer._isValidAvarSegmentMap(
1151            "FOOO", {-1.0: -1.0, 0: 0, 0.5: 0.5, 0.7: 0.5, 1.0: 1.0}
1152        )
1153
1154
1155class InstantiateFvarTest(object):
1156    @pytest.mark.parametrize(
1157        "location, instancesLeft",
1158        [
1159            (
1160                {"wght": 400.0},
1161                ["Regular", "SemiCondensed", "Condensed", "ExtraCondensed"],
1162            ),
1163            (
1164                {"wght": 100.0},
1165                ["Thin", "SemiCondensed Thin", "Condensed Thin", "ExtraCondensed Thin"],
1166            ),
1167            (
1168                {"wdth": 100.0},
1169                [
1170                    "Thin",
1171                    "ExtraLight",
1172                    "Light",
1173                    "Regular",
1174                    "Medium",
1175                    "SemiBold",
1176                    "Bold",
1177                    "ExtraBold",
1178                    "Black",
1179                ],
1180            ),
1181            # no named instance at pinned location
1182            ({"wdth": 90.0}, []),
1183        ],
1184    )
1185    def test_pin_and_drop_axis(self, varfont, location, instancesLeft):
1186        instancer.instantiateFvar(varfont, location)
1187
1188        fvar = varfont["fvar"]
1189        assert {a.axisTag for a in fvar.axes}.isdisjoint(location)
1190
1191        for instance in fvar.instances:
1192            assert set(instance.coordinates).isdisjoint(location)
1193
1194        name = varfont["name"]
1195        assert [
1196            name.getDebugName(instance.subfamilyNameID) for instance in fvar.instances
1197        ] == instancesLeft
1198
1199    def test_full_instance(self, varfont):
1200        instancer.instantiateFvar(varfont, {"wght": 0.0, "wdth": 0.0})
1201
1202        assert "fvar" not in varfont
1203
1204
1205class InstantiateSTATTest(object):
1206    @pytest.mark.parametrize(
1207        "location, expected",
1208        [
1209            ({"wght": 400}, ["Regular", "Condensed", "Upright", "Normal"]),
1210            ({"wdth": 100}, ["Thin", "Regular", "Black", "Upright", "Normal"]),
1211        ],
1212    )
1213    def test_pin_and_drop_axis(self, varfont, location, expected):
1214        instancer.instantiateSTAT(varfont, location)
1215
1216        stat = varfont["STAT"].table
1217        designAxes = {a.AxisTag for a in stat.DesignAxisRecord.Axis}
1218
1219        assert designAxes == {"wght", "wdth", "ital"}
1220
1221        name = varfont["name"]
1222        valueNames = []
1223        for axisValueTable in stat.AxisValueArray.AxisValue:
1224            valueName = name.getDebugName(axisValueTable.ValueNameID)
1225            valueNames.append(valueName)
1226
1227        assert valueNames == expected
1228
1229    def test_skip_table_no_axis_value_array(self, varfont):
1230        varfont["STAT"].table.AxisValueArray = None
1231
1232        instancer.instantiateSTAT(varfont, {"wght": 100})
1233
1234        assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3
1235        assert varfont["STAT"].table.AxisValueArray is None
1236
1237    def test_skip_table_axis_value_array_empty(self, varfont):
1238        varfont["STAT"].table.AxisValueArray.AxisValue = []
1239
1240        instancer.instantiateSTAT(varfont, {"wght": 100})
1241
1242        assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3
1243        assert not varfont["STAT"].table.AxisValueArray.AxisValue
1244
1245    def test_skip_table_no_design_axes(self, varfont):
1246        stat = otTables.STAT()
1247        stat.Version = 0x00010001
1248        stat.populateDefaults()
1249        assert not stat.DesignAxisRecord
1250        assert not stat.AxisValueArray
1251        varfont["STAT"].table = stat
1252
1253        instancer.instantiateSTAT(varfont, {"wght": 100})
1254
1255        assert not varfont["STAT"].table.DesignAxisRecord
1256
1257    @staticmethod
1258    def get_STAT_axis_values(stat):
1259        axes = stat.DesignAxisRecord.Axis
1260        result = []
1261        for axisValue in stat.AxisValueArray.AxisValue:
1262            if axisValue.Format == 1:
1263                result.append((axes[axisValue.AxisIndex].AxisTag, axisValue.Value))
1264            elif axisValue.Format == 3:
1265                result.append(
1266                    (
1267                        axes[axisValue.AxisIndex].AxisTag,
1268                        (axisValue.Value, axisValue.LinkedValue),
1269                    )
1270                )
1271            elif axisValue.Format == 2:
1272                result.append(
1273                    (
1274                        axes[axisValue.AxisIndex].AxisTag,
1275                        (
1276                            axisValue.RangeMinValue,
1277                            axisValue.NominalValue,
1278                            axisValue.RangeMaxValue,
1279                        ),
1280                    )
1281                )
1282            elif axisValue.Format == 4:
1283                result.append(
1284                    tuple(
1285                        (axes[rec.AxisIndex].AxisTag, rec.Value)
1286                        for rec in axisValue.AxisValueRecord
1287                    )
1288                )
1289            else:
1290                raise AssertionError(axisValue.Format)
1291        return result
1292
1293    def test_limit_axes(self, varfont2):
1294        instancer.instantiateSTAT(varfont2, {"wght": (400, 500), "wdth": (75, 100)})
1295
1296        assert len(varfont2["STAT"].table.AxisValueArray.AxisValue) == 5
1297        assert self.get_STAT_axis_values(varfont2["STAT"].table) == [
1298            ("wght", (400.0, 700.0)),
1299            ("wght", 500.0),
1300            ("wdth", (93.75, 100.0, 100.0)),
1301            ("wdth", (81.25, 87.5, 93.75)),
1302            ("wdth", (68.75, 75.0, 81.25)),
1303        ]
1304
1305    def test_limit_axis_value_format_4(self, varfont2):
1306        stat = varfont2["STAT"].table
1307
1308        axisValue = otTables.AxisValue()
1309        axisValue.Format = 4
1310        axisValue.AxisValueRecord = []
1311        for tag, value in (("wght", 575), ("wdth", 90)):
1312            rec = otTables.AxisValueRecord()
1313            rec.AxisIndex = next(
1314                i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag
1315            )
1316            rec.Value = value
1317            axisValue.AxisValueRecord.append(rec)
1318        stat.AxisValueArray.AxisValue.append(axisValue)
1319
1320        instancer.instantiateSTAT(varfont2, {"wght": (100, 600)})
1321
1322        assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue
1323
1324        instancer.instantiateSTAT(varfont2, {"wdth": (62.5, 87.5)})
1325
1326        assert axisValue not in varfont2["STAT"].table.AxisValueArray.AxisValue
1327
1328    def test_unknown_axis_value_format(self, varfont2, caplog):
1329        stat = varfont2["STAT"].table
1330        axisValue = otTables.AxisValue()
1331        axisValue.Format = 5
1332        stat.AxisValueArray.AxisValue.append(axisValue)
1333
1334        with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"):
1335            instancer.instantiateSTAT(varfont2, {"wght": 400})
1336
1337        assert "Unknown AxisValue table format (5)" in caplog.text
1338        assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue
1339
1340
1341def test_setMacOverlapFlags():
1342    flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND
1343    flagOverlapSimple = _g_l_y_f.flagOverlapSimple
1344
1345    glyf = ttLib.newTable("glyf")
1346    glyf.glyphOrder = ["a", "b", "c"]
1347    a = _g_l_y_f.Glyph()
1348    a.numberOfContours = 1
1349    a.flags = [0]
1350    b = _g_l_y_f.Glyph()
1351    b.numberOfContours = -1
1352    comp = _g_l_y_f.GlyphComponent()
1353    comp.flags = 0
1354    b.components = [comp]
1355    c = _g_l_y_f.Glyph()
1356    c.numberOfContours = 0
1357    glyf.glyphs = {"a": a, "b": b, "c": c}
1358
1359    instancer.setMacOverlapFlags(glyf)
1360
1361    assert a.flags[0] & flagOverlapSimple != 0
1362    assert b.components[0].flags & flagOverlapCompound != 0
1363
1364
1365def _strip_ttLibVersion(string):
1366    return re.sub(' ttLibVersion=".*"', "", string)
1367
1368
1369@pytest.fixture
1370def varfont2():
1371    f = ttLib.TTFont(recalcTimestamp=False)
1372    f.importXML(os.path.join(TESTDATA, "PartialInstancerTest2-VF.ttx"))
1373    return f
1374
1375
1376@pytest.fixture
1377def varfont3():
1378    f = ttLib.TTFont(recalcTimestamp=False)
1379    f.importXML(os.path.join(TESTDATA, "PartialInstancerTest3-VF.ttx"))
1380    return f
1381
1382
1383def _dump_ttx(ttFont):
1384    # compile to temporary bytes stream, reload and dump to XML
1385    tmp = BytesIO()
1386    ttFont.save(tmp)
1387    tmp.seek(0)
1388    ttFont2 = ttLib.TTFont(tmp, recalcBBoxes=False, recalcTimestamp=False)
1389    s = StringIO()
1390    ttFont2.saveXML(s, newlinestr="\n")
1391    return _strip_ttLibVersion(s.getvalue())
1392
1393
1394def _get_expected_instance_ttx(
1395    name, *locations, overlap=instancer.OverlapMode.KEEP_AND_SET_FLAGS
1396):
1397    filename = f"{name}-VF-instance-{','.join(str(loc) for loc in locations)}"
1398    if overlap == instancer.OverlapMode.KEEP_AND_DONT_SET_FLAGS:
1399        filename += "-no-overlap-flags"
1400    elif overlap == instancer.OverlapMode.REMOVE:
1401        filename += "-no-overlaps"
1402    with open(
1403        os.path.join(TESTDATA, "test_results", f"{filename}.ttx"),
1404        "r",
1405        encoding="utf-8",
1406    ) as fp:
1407        return _strip_ttLibVersion(fp.read())
1408
1409
1410class InstantiateVariableFontTest(object):
1411    @pytest.mark.parametrize(
1412        "wght, wdth",
1413        [(100, 100), (400, 100), (900, 100), (100, 62.5), (400, 62.5), (900, 62.5)],
1414    )
1415    def test_multiple_instancing(self, varfont2, wght, wdth):
1416        partial = instancer.instantiateVariableFont(varfont2, {"wght": wght})
1417        instance = instancer.instantiateVariableFont(partial, {"wdth": wdth})
1418
1419        expected = _get_expected_instance_ttx("PartialInstancerTest2", wght, wdth)
1420
1421        assert _dump_ttx(instance) == expected
1422
1423    def test_default_instance(self, varfont2):
1424        instance = instancer.instantiateVariableFont(
1425            varfont2, {"wght": None, "wdth": None}
1426        )
1427
1428        expected = _get_expected_instance_ttx("PartialInstancerTest2", 400, 100)
1429
1430        assert _dump_ttx(instance) == expected
1431
1432    @pytest.mark.parametrize(
1433        "overlap, wght",
1434        [
1435            (instancer.OverlapMode.KEEP_AND_DONT_SET_FLAGS, 400),
1436            (instancer.OverlapMode.REMOVE, 400),
1437            (instancer.OverlapMode.REMOVE, 700),
1438        ],
1439    )
1440    def test_overlap(self, varfont3, wght, overlap):
1441        pytest.importorskip("pathops")
1442
1443        location = {"wght": wght}
1444
1445        instance = instancer.instantiateVariableFont(
1446            varfont3, location, overlap=overlap
1447        )
1448
1449        expected = _get_expected_instance_ttx(
1450            "PartialInstancerTest3", wght, overlap=overlap
1451        )
1452
1453        assert _dump_ttx(instance) == expected
1454
1455
1456def _conditionSetAsDict(conditionSet, axisOrder):
1457    result = {}
1458    for cond in conditionSet.ConditionTable:
1459        assert cond.Format == 1
1460        axisTag = axisOrder[cond.AxisIndex]
1461        result[axisTag] = (cond.FilterRangeMinValue, cond.FilterRangeMaxValue)
1462    return result
1463
1464
1465def _getSubstitutions(gsub, lookupIndices):
1466    subs = {}
1467    for index, lookup in enumerate(gsub.LookupList.Lookup):
1468        if index in lookupIndices:
1469            for subtable in lookup.SubTable:
1470                subs.update(subtable.mapping)
1471    return subs
1472
1473
1474def makeFeatureVarsFont(conditionalSubstitutions):
1475    axes = set()
1476    glyphs = set()
1477    for region, substitutions in conditionalSubstitutions:
1478        for box in region:
1479            axes.update(box.keys())
1480        glyphs.update(*substitutions.items())
1481
1482    varfont = ttLib.TTFont()
1483    varfont.setGlyphOrder(sorted(glyphs))
1484
1485    fvar = varfont["fvar"] = ttLib.newTable("fvar")
1486    fvar.axes = []
1487    for axisTag in sorted(axes):
1488        axis = _f_v_a_r.Axis()
1489        axis.axisTag = Tag(axisTag)
1490        fvar.axes.append(axis)
1491
1492    featureVars.addFeatureVariations(varfont, conditionalSubstitutions)
1493
1494    return varfont
1495
1496
1497class InstantiateFeatureVariationsTest(object):
1498    @pytest.mark.parametrize(
1499        "location, appliedSubs, expectedRecords",
1500        [
1501            ({"wght": 0}, {}, [({"cntr": (0.75, 1.0)}, {"uni0041": "uni0061"})]),
1502            (
1503                {"wght": -1.0},
1504                {},
1505                [
1506                    ({"cntr": (0, 0.25)}, {"uni0061": "uni0041"}),
1507                    ({"cntr": (0.75, 1.0)}, {"uni0041": "uni0061"}),
1508                ],
1509            ),
1510            (
1511                {"wght": 1.0},
1512                {"uni0024": "uni0024.nostroke"},
1513                [
1514                    (
1515                        {"cntr": (0.75, 1.0)},
1516                        {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"},
1517                    )
1518                ],
1519            ),
1520            (
1521                {"cntr": 0},
1522                {},
1523                [
1524                    ({"wght": (-1.0, -0.45654)}, {"uni0061": "uni0041"}),
1525                    ({"wght": (0.20886, 1.0)}, {"uni0024": "uni0024.nostroke"}),
1526                ],
1527            ),
1528            (
1529                {"cntr": 1.0},
1530                {"uni0041": "uni0061"},
1531                [
1532                    (
1533                        {"wght": (0.20886, 1.0)},
1534                        {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"},
1535                    )
1536                ],
1537            ),
1538        ],
1539    )
1540    def test_partial_instance(self, location, appliedSubs, expectedRecords):
1541        font = makeFeatureVarsFont(
1542            [
1543                ([{"wght": (0.20886, 1.0)}], {"uni0024": "uni0024.nostroke"}),
1544                ([{"cntr": (0.75, 1.0)}], {"uni0041": "uni0061"}),
1545                (
1546                    [{"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}],
1547                    {"uni0061": "uni0041"},
1548                ),
1549            ]
1550        )
1551
1552        instancer.instantiateFeatureVariations(font, location)
1553
1554        gsub = font["GSUB"].table
1555        featureVariations = gsub.FeatureVariations
1556
1557        assert featureVariations.FeatureVariationCount == len(expectedRecords)
1558
1559        axisOrder = [a.axisTag for a in font["fvar"].axes if a.axisTag not in location]
1560        for i, (expectedConditionSet, expectedSubs) in enumerate(expectedRecords):
1561            rec = featureVariations.FeatureVariationRecord[i]
1562            conditionSet = _conditionSetAsDict(rec.ConditionSet, axisOrder)
1563
1564            assert conditionSet == expectedConditionSet
1565
1566            subsRecord = rec.FeatureTableSubstitution.SubstitutionRecord[0]
1567            lookupIndices = subsRecord.Feature.LookupListIndex
1568            substitutions = _getSubstitutions(gsub, lookupIndices)
1569
1570            assert substitutions == expectedSubs
1571
1572        appliedLookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex
1573
1574        assert _getSubstitutions(gsub, appliedLookupIndices) == appliedSubs
1575
1576    @pytest.mark.parametrize(
1577        "location, appliedSubs",
1578        [
1579            ({"wght": 0, "cntr": 0}, None),
1580            ({"wght": -1.0, "cntr": 0}, {"uni0061": "uni0041"}),
1581            ({"wght": 1.0, "cntr": 0}, {"uni0024": "uni0024.nostroke"}),
1582            ({"wght": 0.0, "cntr": 1.0}, {"uni0041": "uni0061"}),
1583            (
1584                {"wght": 1.0, "cntr": 1.0},
1585                {"uni0041": "uni0061", "uni0024": "uni0024.nostroke"},
1586            ),
1587            ({"wght": -1.0, "cntr": 0.3}, None),
1588        ],
1589    )
1590    def test_full_instance(self, location, appliedSubs):
1591        font = makeFeatureVarsFont(
1592            [
1593                ([{"wght": (0.20886, 1.0)}], {"uni0024": "uni0024.nostroke"}),
1594                ([{"cntr": (0.75, 1.0)}], {"uni0041": "uni0061"}),
1595                (
1596                    [{"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}],
1597                    {"uni0061": "uni0041"},
1598                ),
1599            ]
1600        )
1601
1602        instancer.instantiateFeatureVariations(font, location)
1603
1604        gsub = font["GSUB"].table
1605        assert not hasattr(gsub, "FeatureVariations")
1606
1607        if appliedSubs:
1608            lookupIndices = gsub.FeatureList.FeatureRecord[0].Feature.LookupListIndex
1609            assert _getSubstitutions(gsub, lookupIndices) == appliedSubs
1610        else:
1611            assert not gsub.FeatureList.FeatureRecord
1612
1613    def test_unsupported_condition_format(self, caplog):
1614        font = makeFeatureVarsFont(
1615            [
1616                (
1617                    [{"wdth": (-1.0, -0.5), "wght": (0.5, 1.0)}],
1618                    {"dollar": "dollar.nostroke"},
1619                )
1620            ]
1621        )
1622        featureVariations = font["GSUB"].table.FeatureVariations
1623        rec1 = featureVariations.FeatureVariationRecord[0]
1624        assert len(rec1.ConditionSet.ConditionTable) == 2
1625        rec1.ConditionSet.ConditionTable[0].Format = 2
1626
1627        with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"):
1628            instancer.instantiateFeatureVariations(font, {"wdth": 0})
1629
1630        assert (
1631            "Condition table 0 of FeatureVariationRecord 0 "
1632            "has unsupported format (2); ignored"
1633        ) in caplog.text
1634
1635        # check that record with unsupported condition format (but whose other
1636        # conditions do not reference pinned axes) is kept as is
1637        featureVariations = font["GSUB"].table.FeatureVariations
1638        assert featureVariations.FeatureVariationRecord[0] is rec1
1639        assert len(rec1.ConditionSet.ConditionTable) == 2
1640        assert rec1.ConditionSet.ConditionTable[0].Format == 2
1641
1642    def test_GSUB_FeatureVariations_is_None(self, varfont2):
1643        varfont2["GSUB"].table.Version = 0x00010001
1644        varfont2["GSUB"].table.FeatureVariations = None
1645        tmp = BytesIO()
1646        varfont2.save(tmp)
1647        varfont = ttLib.TTFont(tmp)
1648
1649        # DO NOT raise an exception when the optional 'FeatureVariations' attribute is
1650        # present but is set to None (e.g. with GSUB 1.1); skip and do nothing.
1651        assert varfont["GSUB"].table.FeatureVariations is None
1652        instancer.instantiateFeatureVariations(varfont, {"wght": 400, "wdth": 100})
1653        assert varfont["GSUB"].table.FeatureVariations is None
1654
1655
1656class LimitTupleVariationAxisRangesTest:
1657    def check_limit_single_var_axis_range(self, var, axisTag, axisRange, expected):
1658        result = instancer.limitTupleVariationAxisRange(var, axisTag, axisRange)
1659        print(result)
1660
1661        assert len(result) == len(expected)
1662        for v1, v2 in zip(result, expected):
1663            assert v1.coordinates == pytest.approx(v2.coordinates)
1664            assert v1.axes.keys() == v2.axes.keys()
1665            for k in v1.axes:
1666                p, q = v1.axes[k], v2.axes[k]
1667                assert p == pytest.approx(q)
1668
1669    @pytest.mark.parametrize(
1670        "var, axisTag, newMax, expected",
1671        [
1672            (
1673                TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]),
1674                "wdth",
1675                0.5,
1676                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])],
1677            ),
1678            (
1679                TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]),
1680                "wght",
1681                0.5,
1682                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [50, 50])],
1683            ),
1684            (
1685                TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]),
1686                "wght",
1687                0.8,
1688                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [80, 80])],
1689            ),
1690            (
1691                TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]),
1692                "wght",
1693                1.0,
1694                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])],
1695            ),
1696            (TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), "wght", 0.0, []),
1697            (TupleVariation({"wght": (0.5, 1.0, 1.0)}, [100, 100]), "wght", 0.4, []),
1698            (
1699                TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]),
1700                "wght",
1701                0.5,
1702                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])],
1703            ),
1704            (
1705                TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]),
1706                "wght",
1707                0.4,
1708                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [80, 80])],
1709            ),
1710            (
1711                TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]),
1712                "wght",
1713                0.6,
1714                [TupleVariation({"wght": (0.0, 0.833334, 1.666667)}, [100, 100])],
1715            ),
1716            (
1717                TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]),
1718                "wght",
1719                0.4,
1720                [
1721                    TupleVariation({"wght": (0.0, 0.5, 1.99994)}, [100, 100]),
1722                    TupleVariation({"wght": (0.5, 1.0, 1.0)}, [8.33333, 8.33333]),
1723                ],
1724            ),
1725            (
1726                TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]),
1727                "wght",
1728                0.5,
1729                [TupleVariation({"wght": (0.0, 0.4, 1.99994)}, [100, 100])],
1730            ),
1731            (
1732                TupleVariation({"wght": (0.5, 0.5, 1.0)}, [100, 100]),
1733                "wght",
1734                0.5,
1735                [TupleVariation({"wght": (1.0, 1.0, 1.0)}, [100, 100])],
1736            ),
1737        ],
1738    )
1739    def test_positive_var(self, var, axisTag, newMax, expected):
1740        axisRange = instancer.NormalizedAxisRange(0, newMax)
1741        self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected)
1742
1743    @pytest.mark.parametrize(
1744        "var, axisTag, newMin, expected",
1745        [
1746            (
1747                TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]),
1748                "wdth",
1749                -0.5,
1750                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])],
1751            ),
1752            (
1753                TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]),
1754                "wght",
1755                -0.5,
1756                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [50, 50])],
1757            ),
1758            (
1759                TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]),
1760                "wght",
1761                -0.8,
1762                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [80, 80])],
1763            ),
1764            (
1765                TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]),
1766                "wght",
1767                -1.0,
1768                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])],
1769            ),
1770            (TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), "wght", 0.0, []),
1771            (
1772                TupleVariation({"wght": (-1.0, -1.0, -0.5)}, [100, 100]),
1773                "wght",
1774                -0.4,
1775                [],
1776            ),
1777            (
1778                TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]),
1779                "wght",
1780                -0.5,
1781                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])],
1782            ),
1783            (
1784                TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]),
1785                "wght",
1786                -0.4,
1787                [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [80, 80])],
1788            ),
1789            (
1790                TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]),
1791                "wght",
1792                -0.6,
1793                [TupleVariation({"wght": (-1.666667, -0.833334, 0.0)}, [100, 100])],
1794            ),
1795            (
1796                TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]),
1797                "wght",
1798                -0.4,
1799                [
1800                    TupleVariation({"wght": (-2.0, -0.5, -0.0)}, [100, 100]),
1801                    TupleVariation({"wght": (-1.0, -1.0, -0.5)}, [8.33333, 8.33333]),
1802                ],
1803            ),
1804            (
1805                TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]),
1806                "wght",
1807                -0.5,
1808                [TupleVariation({"wght": (-2.0, -0.4, 0.0)}, [100, 100])],
1809            ),
1810            (
1811                TupleVariation({"wght": (-1.0, -0.5, -0.5)}, [100, 100]),
1812                "wght",
1813                -0.5,
1814                [TupleVariation({"wght": (-1.0, -1.0, -1.0)}, [100, 100])],
1815            ),
1816        ],
1817    )
1818    def test_negative_var(self, var, axisTag, newMin, expected):
1819        axisRange = instancer.NormalizedAxisRange(newMin, 0)
1820        self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected)
1821
1822
1823@pytest.mark.parametrize(
1824    "oldRange, newRange, expected",
1825    [
1826        ((1.0, -1.0), (-1.0, 1.0), None),  # invalid oldRange min > max
1827        ((0.6, 1.0), (0, 0.5), None),
1828        ((-1.0, -0.6), (-0.5, 0), None),
1829        ((0.4, 1.0), (0, 0.5), (0.8, 1.0)),
1830        ((-1.0, -0.4), (-0.5, 0), (-1.0, -0.8)),
1831        ((0.4, 1.0), (0, 0.4), (1.0, 1.0)),
1832        ((-1.0, -0.4), (-0.4, 0), (-1.0, -1.0)),
1833        ((-0.5, 0.5), (-0.4, 0.4), (-1.0, 1.0)),
1834        ((0, 1.0), (-1.0, 0), (0, 0)),  # or None?
1835        ((-1.0, 0), (0, 1.0), (0, 0)),  # or None?
1836    ],
1837)
1838def test_limitFeatureVariationConditionRange(oldRange, newRange, expected):
1839    condition = featureVars.buildConditionTable(0, *oldRange)
1840
1841    result = instancer._limitFeatureVariationConditionRange(
1842        condition, instancer.NormalizedAxisRange(*newRange)
1843    )
1844
1845    assert result == expected
1846
1847
1848@pytest.mark.parametrize(
1849    "limits, expected",
1850    [
1851        (["wght=400", "wdth=100"], {"wght": 400, "wdth": 100}),
1852        (["wght=400:900"], {"wght": (400, 900)}),
1853        (["slnt=11.4"], {"slnt": pytest.approx(11.399994)}),
1854        (["ABCD=drop"], {"ABCD": None}),
1855    ],
1856)
1857def test_parseLimits(limits, expected):
1858    assert instancer.parseLimits(limits) == expected
1859
1860
1861@pytest.mark.parametrize(
1862    "limits", [["abcde=123", "=0", "wght=:", "wght=1:", "wght=abcd", "wght=x:y"]]
1863)
1864def test_parseLimits_invalid(limits):
1865    with pytest.raises(ValueError, match="invalid location format"):
1866        instancer.parseLimits(limits)
1867
1868
1869def test_normalizeAxisLimits_tuple(varfont):
1870    normalized = instancer.normalizeAxisLimits(varfont, {"wght": (100, 400)})
1871    assert normalized == {"wght": (-1.0, 0)}
1872
1873
1874def test_normalizeAxisLimits_unsupported_range(varfont):
1875    with pytest.raises(NotImplementedError, match="Unsupported range"):
1876        instancer.normalizeAxisLimits(varfont, {"wght": (401, 700)})
1877
1878
1879def test_normalizeAxisLimits_no_avar(varfont):
1880    del varfont["avar"]
1881
1882    normalized = instancer.normalizeAxisLimits(varfont, {"wght": (400, 500)})
1883
1884    assert normalized["wght"] == pytest.approx((0, 0.2), 1e-4)
1885
1886
1887def test_normalizeAxisLimits_missing_from_fvar(varfont):
1888    with pytest.raises(ValueError, match="not present in fvar"):
1889        instancer.normalizeAxisLimits(varfont, {"ZZZZ": 1000})
1890
1891
1892def test_sanityCheckVariableTables(varfont):
1893    font = ttLib.TTFont()
1894    with pytest.raises(ValueError, match="Missing required table fvar"):
1895        instancer.sanityCheckVariableTables(font)
1896
1897    del varfont["glyf"]
1898
1899    with pytest.raises(ValueError, match="Can't have gvar without glyf"):
1900        instancer.sanityCheckVariableTables(varfont)
1901
1902
1903def test_main(varfont, tmpdir):
1904    fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf")
1905    varfont.save(fontfile)
1906    args = [fontfile, "wght=400"]
1907
1908    # exits without errors
1909    assert instancer.main(args) is None
1910
1911
1912def test_main_exit_nonexistent_file(capsys):
1913    with pytest.raises(SystemExit):
1914        instancer.main([""])
1915    captured = capsys.readouterr()
1916
1917    assert "No such file ''" in captured.err
1918
1919
1920def test_main_exit_invalid_location(varfont, tmpdir, capsys):
1921    fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf")
1922    varfont.save(fontfile)
1923
1924    with pytest.raises(SystemExit):
1925        instancer.main([fontfile, "wght:100"])
1926    captured = capsys.readouterr()
1927
1928    assert "invalid location format" in captured.err
1929
1930
1931def test_main_exit_multiple_limits(varfont, tmpdir, capsys):
1932    fontfile = str(tmpdir / "PartialInstancerTest-VF.ttf")
1933    varfont.save(fontfile)
1934
1935    with pytest.raises(SystemExit):
1936        instancer.main([fontfile, "wght=400", "wght=90"])
1937    captured = capsys.readouterr()
1938
1939    assert "Specified multiple limits for the same axis" in captured.err
1940