Repository: fonttools/fonttools

Issue:
`varLib.instancer` fails with cvar table and certain options
I can’t figure out what’s wrong here.

I have a VF exported straight from Glyphs. It has three axes, `wght`, `ital`, `YTDE`.  Depending on the command line options, `varLib.instancer` fails.

[SudoUIVariable.ttf.zip](https://github.com/fonttools/fonttools/files/14359607/SudoUIVariable.ttf.zip)

This works:

```
fonttools varLib.instancer SudoUIVariable.ttf wght=400:400:700 ital=0:1
```

This fails:

```
fonttools varLib.instancer SudoUIVariable.ttf wght=400:400:700 ital=0
```

It seems the problem is related to the `cvar` table? But then why does it only fail in one case, when the Italic axis location is pinned?

```
Restricting axes: {'wght': (400, 400, 700), 'ital': (0, 0, 0)}
Loading variable font
Restricted limits: {'wght': (400, 400, 700), 'ital': (0, 0, 0)}
Normalized limits: {'wght': (0, 0, 1, 200, 300), 'ital': (0, 0, 0, 0, 1)}
Instantiating glyf/gvar tables
Instantiating cvt/cvar tables
Instantiating GDEF and GPOS tables
Instantiating FeatureVariations of GSUB table
Instantiating avar table
Instantiating STAT table
Instantiating fvar table
Pruning name table
Saving partial variable font SudoUIVariable-partial.ttf
Traceback (most recent call last):
  File "venv/bin/fonttools", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "venv/lib/python3.12/site-packages/fontTools/__main__.py", line 31, in main
    runpy.run_module(mod, run_name="__main__")
  File "<frozen runpy>", line 229, in run_module
  File "<frozen runpy>", line 88, in _run_code
  File "venv/lib/python3.12/site-packages/fontTools/varLib/instancer/__main__.py", line 5, in <module>
    sys.exit(main())
             ^^^^^^
  File "venv/lib/python3.12/site-packages/fontTools/varLib/instancer/__init__.py", line 1568, in main
    varfont.save(outfile)
  File "venv/lib/python3.12/site-packages/fontTools/ttLib/ttFont.py", line 211, in save
    writer_reordersTables = self._save(tmp)
                            ^^^^^^^^^^^^^^^
  File "venv/lib/python3.12/site-packages/fontTools/ttLib/ttFont.py", line 258, in _save
    self._writeTable(tag, writer, done, tableCache)
  File "venv/lib/python3.12/site-packages/fontTools/ttLib/ttFont.py", line 708, in _writeTable
    tabledata = self.getTableData(tag)
                ^^^^^^^^^^^^^^^^^^^^^^
  File "venv/lib/python3.12/site-packages/fontTools/ttLib/ttFont.py", line 730, in getTableData
    return self.tables[tag].compile(self)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "venv/lib/python3.12/site-packages/fontTools/ttLib/tables/_c_v_a_r.py", line 35, in compile
    tupleVariationCount, tuples, data = compileTupleVariationStore(
                                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "venv/lib/python3.12/site-packages/fontTools/ttLib/tables/TupleVariation.py", line 698, in compileTupleVariationStore
    thisTuple, thisData = v.compile(axisTags, sharedTupleIndices, pointData=p)
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "venv/lib/python3.12/site-packages/fontTools/ttLib/tables/TupleVariation.py", line 131, in compile
    assert set(self.axes.keys()) <= set(axisTags), (
AssertionError: ('Unknown axis tag found.', dict_keys(['wght', 'ital']), ['wght', 'YTDE'])
```


Discussion:
when saving/compiling the instanced font (with ital=0 pinned), the cvar is complaining that ital axis is still there in some of the tuplevariations whereas it expected it to be dropped (from fvar and elsewhere). Looks like a bug.
the cvar for `ital` axis contains something unexpected to me at least, there's a "tent" like (0.0, 0.0, 1.0) that is with peak at 0.0 which doesn't make much sense to me. Peak=0.0 means that axis "does not participate", so it should have been omitted.

```xml
  <cvar>
    <version major="1" minor="0"/>
    <tuple>
      <coord axis="wght" value="1.0"/>
      <coord axis="ital" min="0.0" value="0.0" max="1.0"/>
      <delta cvt="0" value="0"/>
      <delta cvt="1" value="0"/>
      <delta cvt="2" value="0"/>
      <delta cvt="3" value="0"/>
      <delta cvt="4" value="0"/>
      <delta cvt="5" value="0"/>
      <delta cvt="6" value="0"/>
      <delta cvt="7" value="3"/>
      <delta cvt="8" value="0"/>
      <delta cvt="9" value="0"/>
      <delta cvt="10" value="0"/>
      <delta cvt="11" value="0"/>
      <delta cvt="12" value="0"/>
      <delta cvt="13" value="0"/>
      <delta cvt="14" value="0"/>
      <delta cvt="15" value="16"/>
      <delta cvt="16" value="40"/>
      <delta cvt="17" value="16"/>
      <delta cvt="18" value="14"/>
      <delta cvt="19" value="0"/>
    </tuple>
[...]
```

If I delete or comment out the lines like `<coord axis="ital" min="0.0" value="0.0" max="1.0"/>` in the cvar and recompile with ttx, then pinning or dropping the ital axis works without crash.

Somehow the instancer is not handling this situation correctly, so it still counts as a bug. /cc @behdad 
hm it looks like it was intentional, we just pass through a tuple variation when the peak==0:

https://github.com/fonttools/fonttools/blob/d7eed23e34f3371518d7a57389939200f92029c0/Lib/fontTools/varLib/instancer/__init__.py#L535-L537

Should it not be skipped/dropped? If I change it to this, then instantianting Jens' font above no longer fails:

```diff
diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py
index 89427dc53..301203215 100644
--- a/Lib/fontTools/varLib/instancer/__init__.py
+++ b/Lib/fontTools/varLib/instancer/__init__.py
@@ -534,7 +534,7 @@ def changeTupleVariationAxisLimit(var, axisTag, axisLimit):
     # Skip when current axis is missing (i.e. doesn't participate),
     lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1))
     if peak == 0:
-        return [var]
+        return []
     # Drop if the var 'tent' isn't well-formed
     if not (lower <= peak <= upper) or (lower < 0 and upper > 0):
         return []
```

@behdad can you please take a look? thanks
Sounds about right to me. Do the tests pass?
> Do the tests pass?

they don't. I get 78 test failures mostly in instancer_test.py
We were dropping too much. The patch below should work, I'll send a PR

```diff
diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py
index 89427dc53..2a076c693 100644
--- a/Lib/fontTools/varLib/instancer/__init__.py
+++ b/Lib/fontTools/varLib/instancer/__init__.py
@@ -531,9 +531,13 @@ def changeTupleVariationsAxisLimits(variations, axisLimits):
 def changeTupleVariationAxisLimit(var, axisTag, axisLimit):
     assert isinstance(axisLimit, NormalizedAxisTripleAndDistances)

-    # Skip when current axis is missing (i.e. doesn't participate),
+    # Skip when current axis is missing or peaks at 0 (i.e. doesn't participate)
     lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1))
     if peak == 0:
+        # no-op axes can be omitted
+        # https://github.com/fonttools/fonttools/issues/3453
+        if axisTag in var.axes:
+            del var.axes[axisTag]
         return [var]
     # Drop if the var 'tent' isn't well-formed
     if not (lower <= peak <= upper) or (lower < 0 and upper > 0):
```
This seems to do it for me:
```diff
diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py
index 89427dc53..e6513c992 100644
--- a/Lib/fontTools/varLib/instancer/__init__.py
+++ b/Lib/fontTools/varLib/instancer/__init__.py
@@ -534,6 +534,8 @@ def changeTupleVariationAxisLimit(var, axisTag, axisLimit):
     # Skip when current axis is missing (i.e. doesn't participate),
     lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1))
     if peak == 0:
+        if axisTag in var.axes:
+            del var.axes[axisTag]
         return [var]
     # Drop if the var 'tent' isn't well-formed
     if not (lower <= peak <= upper) or (lower < 0 and upper > 0):
```
Lol. We arrived at the same patch.
lol we arrived at the same result :)
Snap.
working on a test now

Resolving patch:
diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py
--- a/Lib/fontTools/varLib/instancer/__init__.py
+++ b/Lib/fontTools/varLib/instancer/__init__.py
@@ -531,9 +531,13 @@ def changeTupleVariationsAxisLimits(variations, axisLimits):
 def changeTupleVariationAxisLimit(var, axisTag, axisLimit):
     assert isinstance(axisLimit, NormalizedAxisTripleAndDistances)
 
-    # Skip when current axis is missing (i.e. doesn't participate),
+    # Skip when current axis is missing or peaks at 0 (i.e. doesn't participate)
     lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1))
     if peak == 0:
+        # explicitly defined, no-op axes can be omitted
+        # https://github.com/fonttools/fonttools/issues/3453
+        if axisTag in var.axes:
+            del var.axes[axisTag]
         return [var]
     # Drop if the var 'tent' isn't well-formed
     if not (lower <= peak <= upper) or (lower < 0 and upper > 0):


Test patch:
diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py
--- a/Tests/varLib/instancer/instancer_test.py
+++ b/Tests/varLib/instancer/instancer_test.py
@@ -2015,6 +2015,19 @@ def check_limit_single_var_axis_range(self, var, axisTag, axisRange, expected):
                 0.5,
                 [TupleVariation({"wght": (1.0, 1.0, 1.0)}, [100, 100])],
             ),
+            # test case from https://github.com/fonttools/fonttools/issues/3453
+            (
+                TupleVariation(
+                    {
+                        "wght": (0.0, 1.0, 1.0),
+                        "ital": (0.0, 0.0, 1.0),  # no-op axis gets dropped
+                    },
+                    [100, 100],
+                ),
+                "ital",
+                0.0,
+                [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])],
+            ),
         ],
     )
     def test_positive_var(self, var, axisTag, newMax, expected):