Repository: fonttools/fonttools

Issue:
ConditionSets and VariableScalars should have `avar` applied to coordinates
I found a case where a mapping in a Designspace file like so:

```xml
  <axes>
    <axis tag="wght" name="Weight" minimum="100" maximum="900" default="100">
      <map input="100" output="22"/>
      <map input="600" output="115"/>
      <map input="900" output="185"/>
    </axis>
  </axes>
  <rules processing="last">
    <rule name="conditional_sub">
      <conditionset>
        <condition name="Weight" minimum="116" maximum="185"/>
      </conditionset>
      <sub name="e" with="a"/>
    </rule>
  </rules>
```

means that "e" is substituted by "a" at wght=606 and upwards (116 in design coordinates is a tad over 600 in user coordinates), whereas with a feature file snippet like this:

```
lookup conditional_sub {
    sub e by a;
} conditional_sub;

conditionset test {
    wght 606 900;
} test;

variation rlig test {
    lookup conditional_sub;
} rlig;
```

it is substituted at wght=644 and upwards.


Discussion:
From @simoncozens' https://github.com/adobe-type-tools/afdko/pull/1350#issuecomment-955038971

> userspace values as taken from the fvar table (wght=400 in the Nunito example), which is how we’ve interpreted things so far

so, yes, whenever fvar userspace coordinates are given, avar needs to be taken into account when normalizing. 

E.g. by doing https://github.com/fonttools/fonttools/blob/032616b10685bfca139ff702d1ffc6458891d980/Lib/fontTools/varLib/instancer/__init__.py#L1069-L1074
This is similarly true for locations used in variable scalars.
Hm, so, at what level/where should I fix this? There are a couple of normalization functions floating around, i.e. `feaLib.builder.Builder.add_conditionset` is using `varLib.models.normalizeValue`.
So, at least when changing add_conditionset like so:

```
        if "avar" in self.font:
            mapping = self.font["avar"].segments
            value = {
                axis: tuple(
                    floatToFixedToFloat(piecewiseLinearMap(v, mapping[axis]), 14)
                    for v in condition_range
                )
                for axis, condition_range in value.items()
            }
```

I get almost the same cross-over point (606 instead of 605 with the Designspace). So I guess there's a rounding issue in here somewhere.
> So I guess there's a rounding issue in here somewhere.

the rounding issue is entirely possible, because the avar mappings are stored as f2dot14 so.. it's unlikely you'll be able to get exactly the same value as using the DS axis maps (which use floats). Since you no longer have that from FEA, but only the avar, that's all you got.
So we're ok with the rounding error then?
there's no way around it
@simoncozens notes that VariableScalars, used in FEA to construct variable ValueRecords in GPOS pos or GDEF anchors will also need to be avar-mapped.

Resolving patch:
diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py
--- a/Lib/fontTools/feaLib/builder.py
+++ b/Lib/fontTools/feaLib/builder.py
@@ -176,6 +176,10 @@ def __init__(self, font, featurefile):
         self.stat_ = {}
         # for conditionsets
         self.conditionsets_ = {}
+        # We will often use exactly the same locations (i.e. the font's masters)
+        # for a large number of variable scalars. Instead of creating a model
+        # for each, let's share the models.
+        self.model_cache = {}
 
     def build(self, tables=None, debug=False):
         if self.parseTree is None:
@@ -771,7 +775,7 @@ def buildGDEF(self):
                 gdef.remap_device_varidxes(varidx_map)
                 if "GPOS" in self.font:
                     self.font["GPOS"].table.remap_device_varidxes(varidx_map)
-            VariableScalar.clear_cache()
+            self.model_cache.clear()
         if any(
             (
                 gdef.GlyphClassDef,
@@ -1596,7 +1600,8 @@ def add_conditionset(self, location, key, value):
             mapping = self.font["avar"].segments
             value = {
                 axis: tuple(
-                    piecewiseLinearMap(v, mapping[axis]) for v in condition_range
+                    piecewiseLinearMap(v, mapping[axis]) if axis in mapping else v
+                    for v in condition_range
                 )
                 for axis, condition_range in value.items()
             }
@@ -1613,8 +1618,10 @@ def makeOpenTypeAnchor(self, location, anchor):
             deviceX = otl.buildDevice(dict(anchor.xDeviceTable))
         if anchor.yDeviceTable is not None:
             deviceY = otl.buildDevice(dict(anchor.yDeviceTable))
+        avar = self.font.get("avar")
         for dim in ("x", "y"):
-            if not isinstance(getattr(anchor, dim), VariableScalar):
+            varscalar = getattr(anchor, dim)
+            if not isinstance(varscalar, VariableScalar):
                 continue
             if getattr(anchor, dim + "DeviceTable") is not None:
                 raise FeatureLibError(
@@ -1624,9 +1631,10 @@ def makeOpenTypeAnchor(self, location, anchor):
                 raise FeatureLibError(
                     "Can't define a variable scalar in a non-variable font", location
                 )
-            varscalar = getattr(anchor, dim)
             varscalar.axes = self.axes
-            default, index = varscalar.add_to_variation_store(self.varstorebuilder)
+            default, index = varscalar.add_to_variation_store(
+                self.varstorebuilder, self.model_cache, avar
+            )
             setattr(anchor, dim, default)
             if index is not None and index != 0xFFFFFFFF:
                 if dim == "x":
@@ -1653,8 +1661,8 @@ def makeOpenTypeValueRecord(self, location, v, pairPosContext):
         if not v:
             return None
 
+        avar = self.font.get("avar")
         vr = {}
-        variable = False
         for astName, (otName, isDevice) in self._VALUEREC_ATTRS.items():
             val = getattr(v, astName, None)
             if not val:
@@ -1674,11 +1682,12 @@ def makeOpenTypeValueRecord(self, location, v, pairPosContext):
                         location,
                     )
                 val.axes = self.axes
-                default, index = val.add_to_variation_store(self.varstorebuilder)
+                default, index = val.add_to_variation_store(
+                    self.varstorebuilder, self.model_cache, avar
+                )
                 vr[otName] = default
                 if index is not None and index != 0xFFFFFFFF:
                     vr[otDeviceName] = buildVarDevTable(index)
-                    variable = True
             else:
                 vr[otName] = val
 
diff --git a/Lib/fontTools/feaLib/variableScalar.py b/Lib/fontTools/feaLib/variableScalar.py
--- a/Lib/fontTools/feaLib/variableScalar.py
+++ b/Lib/fontTools/feaLib/variableScalar.py
@@ -1,4 +1,4 @@
-from fontTools.varLib.models import VariationModel, normalizeValue
+from fontTools.varLib.models import VariationModel, normalizeValue, piecewiseLinearMap
 
 
 def Location(loc):
@@ -8,15 +8,6 @@ def Location(loc):
 class VariableScalar:
     """A scalar with different values at different points in the designspace."""
 
-    # We will often use exactly the same locations (i.e. the font's
-    # masters) for a large number of variable scalars. Instead of
-    # creating a model for each, let's share the models.
-    model_pool = {}
-
-    @classmethod
-    def clear_cache(cls):
-        cls.model_pool = {}
-
     def __init__(self, location_value={}):
         self.values = {}
         self.axes = {}
@@ -83,29 +74,39 @@ def default(self):
             # I *guess* we could interpolate one, but I don't know how.
         return self.values[key]
 
-    def value_at_location(self, location):
+    def value_at_location(self, location, model_cache=None, avar=None):
         loc = location
         if loc in self.values.keys():
             return self.values[loc]
         values = list(self.values.values())
-        return self.model.interpolateFromMasters(loc, values)
+        return self.model(model_cache, avar).interpolateFromMasters(loc, values)
 
-    @property
-    def model(self):
-        key = tuple(self.values.keys())
-        if key in self.model_pool:
-            return self.model_pool[key]
+    def model(self, model_cache=None, avar=None):
+        if model_cache is not None:
+            key = tuple(self.values.keys())
+            if key in model_cache:
+                return model_cache[key]
         locations = [dict(self._normalized_location(k)) for k in self.values.keys()]
+        if avar is not None:
+            mapping = avar.segments
+            locations = [
+                {
+                    k: piecewiseLinearMap(v, mapping[k]) if k in mapping else v
+                    for k, v in location.items()
+                }
+                for location in locations
+            ]
         m = VariationModel(locations)
-        self.model_pool[key] = m
+        if model_cache is not None:
+            model_cache[key] = m
         return m
 
-    def get_deltas_and_supports(self):
+    def get_deltas_and_supports(self, model_cache=None, avar=None):
         values = list(self.values.values())
-        return self.model.getDeltasAndSupports(values)
+        return self.model(model_cache, avar).getDeltasAndSupports(values)
 
-    def add_to_variation_store(self, store_builder):
-        deltas, supports = self.get_deltas_and_supports()
+    def add_to_variation_store(self, store_builder, model_cache=None, avar=None):
+        deltas, supports = self.get_deltas_and_supports(model_cache, avar)
         store_builder.setSupports(supports)
         index = store_builder.storeDeltas(deltas)
         return int(self.default), index


Test patch:
diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py
--- a/Tests/feaLib/builder_test.py
+++ b/Tests/feaLib/builder_test.py
@@ -988,6 +988,7 @@ def test_condition_set_avar(self):
 
             conditionset test {
                 wght 600 1000;
+                wdth 150 200;
             } test;
 
             variation rlig test {
@@ -998,33 +999,98 @@ def test_condition_set_avar(self):
         def make_mock_vf():
             font = makeTTFont()
             font["name"] = newTable("name")
-            addFvar(font, [("wght", 0, 0, 1000, "Weight")], [])
+            addFvar(
+                font,
+                [("wght", 0, 0, 1000, "Weight"), ("wdth", 100, 100, 200, "Width")],
+                [],
+            )
             del font["name"]
             return font
 
         # Without `avar`:
         font = make_mock_vf()
         addOpenTypeFeaturesFromString(font, features)
-        assert (
+        condition_table = (
             font.tables["GSUB"]
             .table.FeatureVariations.FeatureVariationRecord[0]
-            .ConditionSet.ConditionTable[0]
-            .FilterRangeMinValue
-            == 0.6  # user-space 600
+            .ConditionSet.ConditionTable
         )
+        # user-space wdth=150 and wght=600:
+        assert condition_table[0].FilterRangeMinValue == 0.5
+        assert condition_table[1].FilterRangeMinValue == 0.6
 
-        # With `avar`, shifting the positive midpoint 0.5 a bit to the right:
+        # With `avar`, shifting the wght axis' positive midpoint 0.5 a bit to
+        # the right, but leaving the wdth axis alone:
         font = make_mock_vf()
         font["avar"] = newTable("avar")
         font["avar"].segments = {"wght": {-1.0: -1.0, 0.0: 0.0, 0.5: 0.625, 1.0: 1.0}}
         addOpenTypeFeaturesFromString(font, features)
-        assert (
+        condition_table = (
             font.tables["GSUB"]
             .table.FeatureVariations.FeatureVariationRecord[0]
-            .ConditionSet.ConditionTable[0]
-            .FilterRangeMinValue
-            == 0.7  # user-space 600 shifted to the right,
+            .ConditionSet.ConditionTable
         )
+        # user-space wdth=150 as before and wght=600 shifted to the right:
+        assert condition_table[0].FilterRangeMinValue == 0.5
+        assert condition_table[1].FilterRangeMinValue == 0.7
+
+    def test_variable_scalar_avar(self):
+        """Test that the `avar` table is consulted when normalizing user-space
+        values."""
+
+        features = """
+            languagesystem DFLT dflt;
+        
+            feature kern {
+                pos cursive one <anchor 0 (wght=200:12 wght=900:22 wdth=150,wght=900:42)> <anchor NULL>;
+                pos two <0 (wght=200:12 wght=900:22 wdth=150,wght=900:42) 0 0>;
+            } kern;
+        """
+
+        def make_mock_vf():
+            font = makeTTFont()
+            font["name"] = newTable("name")
+            addFvar(font, self.VARFONT_AXES, [])
+            del font["name"]
+            return font
+
+        def get_region(var_region_axis):
+            return (
+                var_region_axis.StartCoord,
+                var_region_axis.PeakCoord,
+                var_region_axis.EndCoord,
+            )
+
+        # Without `avar` (wght=200, wdth=100 is the default location):
+        font = make_mock_vf()
+        addOpenTypeFeaturesFromString(font, features)
+
+        var_region_list = font.tables["GDEF"].table.VarStore.VarRegionList
+        var_region_axis_wght = var_region_list.Region[0].VarRegionAxis[0]
+        var_region_axis_wdth = var_region_list.Region[0].VarRegionAxis[1]
+        assert get_region(var_region_axis_wght) == (0.0, 0.875, 0.875)
+        assert get_region(var_region_axis_wdth) == (0.0, 0.0, 0.0)
+        var_region_axis_wght = var_region_list.Region[1].VarRegionAxis[0]
+        var_region_axis_wdth = var_region_list.Region[1].VarRegionAxis[1]
+        assert get_region(var_region_axis_wght) == (0.0, 0.875, 0.875)
+        assert get_region(var_region_axis_wdth) == (0.0, 0.5, 0.5)
+
+        # With `avar`, shifting the wght axis' positive midpoint 0.5 a bit to
+        # the right, but leaving the wdth axis alone:
+        font = make_mock_vf()
+        font["avar"] = newTable("avar")
+        font["avar"].segments = {"wght": {-1.0: -1.0, 0.0: 0.0, 0.5: 0.625, 1.0: 1.0}}
+        addOpenTypeFeaturesFromString(font, features)
+
+        var_region_list = font.tables["GDEF"].table.VarStore.VarRegionList
+        var_region_axis_wght = var_region_list.Region[0].VarRegionAxis[0]
+        var_region_axis_wdth = var_region_list.Region[0].VarRegionAxis[1]
+        assert get_region(var_region_axis_wght) == (0.0, 0.90625, 0.90625)
+        assert get_region(var_region_axis_wdth) == (0.0, 0.0, 0.0)
+        var_region_axis_wght = var_region_list.Region[1].VarRegionAxis[0]
+        var_region_axis_wdth = var_region_list.Region[1].VarRegionAxis[1]
+        assert get_region(var_region_axis_wght) == (0.0, 0.90625, 0.90625)
+        assert get_region(var_region_axis_wdth) == (0.0, 0.5, 0.5)
 
 
 def generate_feature_file_test(name):