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