Repository: fonttools/fonttools

Issue:
addFeatureVariation doesn't work on a glyph that's also being swapped by another opentype feature
Please see the attached file, the ".tf" glyphs have an extra shape inserted in front so I can test and see if they're being swapped properly. I built these with fontmake then ran the attached script.

This is the progression:
![nov-09-2018 18-54-26](https://user-images.githubusercontent.com/1271897/48250029-06a8a680-e451-11e8-88fc-8de7527076f2.gif)

This is how it should look at the heaviest end where the swap is supposed to have kicked in
<img width="602" alt="screen shot 2018-11-09 at 6 55 15 pm" src="https://user-images.githubusercontent.com/1271897/48250033-090b0080-e451-11e8-8226-96a306215470.png">

[Archive.zip](https://github.com/fonttools/fonttools/files/2565029/Archive.zip)


Discussion:
Hm, I can't find anything wrong with your font, but I can reproduce the (non)behavior with harfbuzz/hb-shape and axis-praxis on Chrome.

@behdad, can you see why the font doesn't behave as intended?

Here's an improved build script (see #1370):

```python
### Based on a script by Stephen Nixon

import os
import sys
import fontTools
from fontTools.ttLib import TTFont
from fontTools.varLib.featureVars import addFeatureVariations

inputTTF = sys.argv[1]
##style = sys.argv[2]

f = TTFont(inputTTF)
condSubst = [
    # A list of (Region, Substitution) tuples.
    ([{"wght": (0.158, 0.564)}], {"uniF8FF": "uniF8FF.001"}),
    ([{"wght": (0.868, 1)}], {"uni20B5": "uni20B5.201_230",
                              "cent": "cent.201_230",
                              "colonmonetary": "colonmonetary.201_230",
                              "dollar": "dollar.201_230",
                              "uni20B2": "uni20B2.201_230",
                              "dollar.tf": "dollar.tf.201_230",
                              "cent.tf": "cent.tf.201_230"}),
    ([{"wght": (0.71, 1)}], {"uni20A6": "uni20A6.169_230",
                             "peseta": "peseta.169_230",
                             "uni20A9": "uni20A9.169_230"}),
    ([{"wght": (0.564, 1)}], {"uni20B1": "uni20B1.136_230",
                              "Adieresis.tf": "Adieresis.tf.136_230",
                              "Udieresis.tf": "Udieresis.tf.136_230"}),
    ([{"wght": (0.424, 1)}], {"uni2761": "uni2761.79_230",
                              "paragraph": "paragraph.79_230",
                              "Odieresis.tf": "Odieresis.tf.106_230"}),
]


addFeatureVariations(f, condSubst)

outputTTF = inputTTF.replace('.ttf', '-swap.ttf')
f.save(outputTTF)
```
What's the test string?
Just, with your script I see this in the GSUB table:
```
      <FeatureRecord index="1"> 
        <FeatureTag value="rvrn"/> 
        <Feature> 
          <!-- LookupCount=0 --> 
        </Feature> 
      </FeatureRecord> 
```

`LookupCount=0`.
Teststring: "ÖÄÜ$¢"
> LookupCount=0

That is correct. The `rvrn` feature has no lookups by default, the FeatureVariation mechanism swaps in a non-empty feature, if a condition is met.
Oh.  I forgot how this works.. :)
...but do you otherwise see anything why this doesn't work? Could it be a hb bug?
> ...but do you otherwise see anything why this doesn't work? Could it be a hb bug?

Haven't dug into it.  Found more fun thing to do (optimizing featureVars module. )
Will look into after.
I managed to reproduce the problem with a minimal test case.

Test string: "12"

If you slide the weight slider, halfway through the 2 swaps correctly to 3.

The font contains a `tnum` feature, which substitutes the 1 for 2.

If you enable `tnum` in axis-praxis, the substitution works, but the substituted 2 does _not_ get replaced by 3 along the weight axis.

Summarizing: with `tnum` on and `wght` set to max, I expect the output to look like "33", but we get "23" instead.

I wonder if featureVars.py is setting up the lookups wrong, or if something else is wrong. Such as our expectations given the source data :)

[TestCase.zip](https://github.com/fonttools/fonttools/files/2568222/TestCase.zip)

Oh, @weiweihuanghuang, I took the glyphs one, two and three from your WorkSans fonts to make the test UFOs. I hope you don't mind.
```
$ hb-shape variable_ttf/TestWork-VF.ttf "12"
[one=0+390|two=1+583]

$ hb-shape variable_ttf/TestWork-VF.ttf --features=tnum --variations=wght=100 "12"
[two=0+583|two=1+583]

$ hb-shape variable_ttf/TestWork-VF.ttf --features=-tnum --variations=wght=900 "12"
[one=0+459|three=1+600]

$ hb-shape variable_ttf/TestWork-VF.ttf --features=tnum --variations=wght=900 "12"
[two=0+606|three=1+600]
```
So in the last case I expected `[three=0+600|three=1+600]`
Maybe this is the problem.
From https://docs.microsoft.com/en-us/typography/opentype/spec/features_pt#rvrn:
> The feature is applied only during the process of deriving final glyph IDs (GSUB); it is not used for glyph positioning (GPOS). It should be processed early in GSUB processing, before application of the localized forms feature or features related to shaping of complex scripts or discretionary typographic effects.

Does this imply `rvrn` is a special case, and its associated lookups are not processed in the order of the LookupList, but earlier?

From a design standpoint the spec fragment makes no sense to me. I expect `rvrn` to be processed last (that's why featureVars.py puts its lookups at the end of the LookupList).

I can elaborate what bothers me about this formulation and (possible) behavior, but let's first see if this is indeed the culprit.
The problem with early processing is that the switchover points (conditions) are those of the first `rvrn` substitution, forcing alternate glyphs to use the switchover point of their source glyph, instead of their own. I hope I'm missing something crucial, because this sounds like a rather dramatic issue otherwise.
@justvanrossum no problem! just happy this is looked into 
@PeterCon @robmck-ms 
Also from the spec:

> Feature interaction: The feature should be processed early after initial character-to-glyph mapping, before application of the localized forms ('locl') feature, any features related to shaping of complex scripts, or any discretionary features.

Why? Again, this makes no sense to me. If I need to switch to an alternative dollar from a `smcp` dollar or an `onum` dollar, I certainly don't want an `rvrn` substitution to take place _before_ `smcp` or `onum` have been processed.
I think one would have to do:
```
feature rvrn {
  sub dollar by dollar.rvrn;
} rvrn;

feature smcp {
  sub dollar by dollar.smcp;
  sub dollar.rvrn by dollar.rvrn.smcp;
} smcp;
```

Instead of:
```
feature rvrn {
  sub dollar by dollar.rvrn;
  sub dollar.smcp by dollar.rvrn.smcp;
} rvrn;

feature smcp {
  sub dollar by dollar.smcp;
} smcp;
```
I fear you're right, and I still hope you're wrong. This is completely reversed of what is actually useful. I'm curious about the motivation.
This matches my experience, @khaledhosny @justvanrossum.

I have observed rvrn substitutions always happening _before_ typographic substitutions such as tnum, sups and liga, in accordance with the paragraph from the spec that Just quotes. This means that we must write our lookups twice — with and without rvrn forms — if _any_ of the components of the ligature _or_ the ligature itself needs FeatureVariations.

@irenevl confirms the same from her experience with TT Portada and other fonts. Here is TT Portada’s liga feature which she wrote, and which works :)

```
feature liga {
  sub f f i by ffi ;
  sub f f l by ffl ;
  sub f f by ff ;
  sub f i by fi ;
  sub f l by fl ;
  sub f iacute by f_iacute ;
  sub f.rvrn f.rvrn i by ffi.rvrn ;
  sub f.rvrn f.rvrn l by ffl.rvrn ;
  sub f.rvrn f.rvrn by ff.rvrn ;
  sub f.rvrn i by fi.rvrn ;
  sub f.rvrn iacute by f_iacute ;
  sub f.rvrn l by fl.rvrn ;
} liga ;
```

In this case, there is an alternative form of f _and_ an alternative form of all f ligatures.

An interesting point is that — if we had FeatureVariation f ligatures but a non-flipping f — we would have still needed a “fake” f.rvrn glyph (a duplicate of f) to pick up the logical path leading to the variant ligatures.
Ugh, my eyes, they are bleeding.
BTW when hunting bugs in FeatureVariations **always type at least two characters!** If you use Safari, that is. A single glyph in Safari does not trigger FeatureVariations.

It’s because of an optimization, where macOS apps can use “fast path” text rendering if it thinks a text run is simple and can avoid costly OpenType processing. Simple text runs include single glyph runs.

Avoid the problem by:
* switching to Chrome or Firefox
* setting any OpenType features using CSS, even bogus ones e.g. `font-feature-settings: "FOOB";`
Okay.  Yeah.  Now I remember the full details for this issue..

Basically, 'rvrn' was NOT designed to be *the* feature used with FeatureVariations.  'rvrn' was added to carry the feature variations to 'cmap' table mapping.  The idea was that other GSUB features will be substituted with alternative forms themselves, not requiring 'rvrn' to do it for them.

Now, this doesn't mean the designer needs to write those.  I see two options:
1. Have code in varLib.featureVars to write mapped version of all other lookups when needed,
2. Use some other always-on GSUB feature 'rlig'? 'clig'? that is late-processed, to put all the mappings at the end.
3. When generating rvrn lookups, append them to all features declared.

All of these are ugly, I agree, with second solution being the least ugly one.  Only if we had an 'rvrn' alternative that would happen at the end... Maybe we should register one.
Never have I been more disappointed by not discovering a bug in my code :(

Yeah, we need a variant of `rvrn` that applies last. I'll post a note to the OpenType list.

I think it's a really bad idea to try to kludge this from varLib, so I think we should just close this.

@weiweihuanghuang, you probably have enough info now to know how to make it work, right?
Btw., before 'rvrn' was widely implemented, we used 'kern' as a hack-y placeholder feature. It's always on, even as GSUB. If I hack the font so the feavar feature is 'kern' instead of 'rvrn', the font actually works as expected. At least with harfbuzz. So yeah, @behdad's suggestion # 2 works...
@justvanrossum yes I understand @irenevl’a example 
Can I just ask here, in the condSubst list... those regions, what do they correspond to? I thought it was corresponding to the weight value in the glyphs file (as a decimal) but it's clearly not. Is it the "to" (or "from") in the avar table or something else.
> Is it the "to" (or "from") in the avar table or something else.

It's normalized axis values.  Yes, same as "to" in avar if avar exists.
You can use the FeatureVariations mechanism with any feature, not just rvrn. The rvrn feature is just the feature where the FeatureVariations is used at the early stage, and which should be on by default. 

You can use FeatureVariations with any feature (e.g. ss01 or smcp or swsh or dlig — if you only want discretionary feature variations behavior). 

Any engine that only uses FeatureVariations with the rvrn feature is buggy. 
> Any engine that only uses FeatureVariations with the rvrn feature is buggy.

Maybe the only problem is that there's no generic feature available that is always-enabled. I can hack around it by using 'kern' instead of 'rvrn', and I'm curious to learn how well that works across engines. But it's obviously a hack, as 'kern' has a very different purpose.
Hey was there any progress on this issue?
So far, issue #1625 has been filed, but has not yet been implemented.

Resolving patch:
diff --git a/Lib/fontTools/designspaceLib/__init__.py b/Lib/fontTools/designspaceLib/__init__.py
--- a/Lib/fontTools/designspaceLib/__init__.py
+++ b/Lib/fontTools/designspaceLib/__init__.py
@@ -358,7 +358,7 @@ def getRuleDescriptor(cls):
     def __init__(self, documentPath, documentObject):
         self.path = documentPath
         self.documentObject = documentObject
-        self.documentVersion = "4.0"
+        self.documentVersion = "4.1"
         self.root = ET.Element("designspace")
         self.root.attrib['format'] = self.documentVersion
         self._axes = []     # for use by the writer only
@@ -371,7 +371,11 @@ def write(self, pretty=True, encoding="UTF-8", xml_declaration=True):
             self._addAxis(axisObject)
 
         if self.documentObject.rules:
-            self.root.append(ET.Element("rules"))
+            if getattr(self.documentObject, "rulesProcessingLast", False):
+                attributes = {"processing": "last"}
+            else:
+                attributes = {}
+            self.root.append(ET.Element("rules", attributes))
         for ruleObject in self.documentObject.rules:
             self._addRule(ruleObject)
 
@@ -675,6 +679,14 @@ def read(self):
     def readRules(self):
         # we also need to read any conditions that are outside of a condition set.
         rules = []
+        rulesElement = self.root.find(".rules")
+        if rulesElement is not None:
+            processingValue = rulesElement.attrib.get("processing", "first")
+            if processingValue not in {"first", "last"}:
+                raise DesignSpaceDocumentError(
+                    "<rules> processing attribute value is not valid: %r, "
+                    "expected 'first' or 'last'" % processingValue)
+            self.documentObject.rulesProcessingLast = processingValue == "last"
         for ruleElement in self.root.findall(".rules/rule"):
             ruleObject = self.ruleDescriptorClass()
             ruleName = ruleObject.name = ruleElement.attrib.get("name")
@@ -996,6 +1008,7 @@ def __init__(self, readerClass=None, writerClass=None):
         self.instances = []
         self.axes = []
         self.rules = []
+        self.rulesProcessingLast = False
         self.default = None         # name of the default master
 
         self.lib = {}
diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py
--- a/Lib/fontTools/varLib/__init__.py
+++ b/Lib/fontTools/varLib/__init__.py
@@ -613,7 +613,7 @@ def _merge_OTL(font, model, master_fonts, axisTags):
 		font['GPOS'].table.remap_device_varidxes(varidx_map)
 
 
-def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules):
+def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules, rulesProcessingLast):
 
 	def normalize(name, value):
 		return models.normalizeLocation(
@@ -648,7 +648,11 @@ def normalize(name, value):
 
 		conditional_subs.append((region, subs))
 
-	addFeatureVariations(font, conditional_subs)
+	if rulesProcessingLast:
+		featureTag = 'rclt'
+	else:
+		featureTag = 'rvrn'
+	addFeatureVariations(font, conditional_subs, featureTag)
 
 
 _DesignSpaceData = namedtuple(
@@ -661,6 +665,7 @@ def normalize(name, value):
 		"masters",
 		"instances",
 		"rules",
+		"rulesProcessingLast",
 	],
 )
 
@@ -761,6 +766,7 @@ def load_designspace(designspace):
 		masters,
 		instances,
 		ds.rules,
+		ds.rulesProcessingLast,
 	)
 
 
@@ -865,7 +871,7 @@ def build(designspace, master_finder=lambda s:s, exclude=[], optimize=True):
 	if 'cvar' not in exclude and 'glyf' in vf:
 		_merge_TTHinting(vf, model, master_fonts)
 	if 'GSUB' not in exclude and ds.rules:
-		_add_GSUB_feature_variations(vf, ds.axes, ds.internal_axis_supports, ds.rules)
+		_add_GSUB_feature_variations(vf, ds.axes, ds.internal_axis_supports, ds.rules, ds.rulesProcessingLast)
 	if 'CFF2' not in exclude and 'CFF ' in vf:
 		_add_CFF2(vf, model, master_fonts)
 		if "post" in vf:
diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py
--- a/Lib/fontTools/varLib/featureVars.py
+++ b/Lib/fontTools/varLib/featureVars.py
@@ -11,7 +11,7 @@
 from collections import OrderedDict
 
 
-def addFeatureVariations(font, conditionalSubstitutions):
+def addFeatureVariations(font, conditionalSubstitutions, featureTag='rvrn'):
     """Add conditional substitutions to a Variable Font.
 
     The `conditionalSubstitutions` argument is a list of (Region, Substitutions)
@@ -43,7 +43,8 @@ def addFeatureVariations(font, conditionalSubstitutions):
     """
 
     addFeatureVariationsRaw(font,
-                            overlayFeatureVariations(conditionalSubstitutions))
+                            overlayFeatureVariations(conditionalSubstitutions),
+                            featureTag)
 
 def overlayFeatureVariations(conditionalSubstitutions):
     """Compute overlaps between all conditional substitutions.
@@ -255,15 +256,15 @@ def cleanupBox(box):
 # Low level implementation
 #
 
-def addFeatureVariationsRaw(font, conditionalSubstitutions):
+def addFeatureVariationsRaw(font, conditionalSubstitutions, featureTag='rvrn'):
     """Low level implementation of addFeatureVariations that directly
     models the possibilities of the FeatureVariations table."""
 
     #
-    # assert there is no 'rvrn' feature
-    # make dummy 'rvrn' feature with no lookups
-    # sort features, get 'rvrn' feature index
-    # add 'rvrn' feature to all scripts
+    # if there is no <featureTag> feature:
+    #     make empty <featureTag> feature
+    #     sort features, get <featureTag> feature index
+    #     add <featureTag> feature to all scripts
     # make lookups
     # add feature variations
     #
@@ -278,20 +279,25 @@ def addFeatureVariationsRaw(font, conditionalSubstitutions):
 
     gsub.FeatureVariations = None  # delete any existing FeatureVariations
 
-    for feature in gsub.FeatureList.FeatureRecord:
-        assert feature.FeatureTag != 'rvrn'
+    varFeatureIndices = []
+    for index, feature in enumerate(gsub.FeatureList.FeatureRecord):
+        if feature.FeatureTag == featureTag:
+            varFeatureIndices.append(index)
 
-    rvrnFeature = buildFeatureRecord('rvrn', [])
-    gsub.FeatureList.FeatureRecord.append(rvrnFeature)
-    gsub.FeatureList.FeatureCount = len(gsub.FeatureList.FeatureRecord)
+    if not varFeatureIndices:
+        varFeature = buildFeatureRecord(featureTag, [])
+        gsub.FeatureList.FeatureRecord.append(varFeature)
+        gsub.FeatureList.FeatureCount = len(gsub.FeatureList.FeatureRecord)
 
-    sortFeatureList(gsub)
-    rvrnFeatureIndex = gsub.FeatureList.FeatureRecord.index(rvrnFeature)
+        sortFeatureList(gsub)
+        varFeatureIndex = gsub.FeatureList.FeatureRecord.index(varFeature)
 
-    for scriptRecord in gsub.ScriptList.ScriptRecord:
-        langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord]
-        for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems:
-            langSys.FeatureIndex.append(rvrnFeatureIndex)
+        for scriptRecord in gsub.ScriptList.ScriptRecord:
+            langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord]
+            for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems:
+                langSys.FeatureIndex.append(varFeatureIndex)
+
+        varFeatureIndices = [varFeatureIndex]
 
     # setup lookups
 
@@ -311,8 +317,11 @@ def addFeatureVariationsRaw(font, conditionalSubstitutions):
             conditionTable.append(ct)
 
         lookupIndices = [lookupMap[subst] for subst in substitutions]
-        record = buildFeatureTableSubstitutionRecord(rvrnFeatureIndex, lookupIndices)
-        featureVariationRecords.append(buildFeatureVariationRecord(conditionTable, [record]))
+        records = []
+        for varFeatureIndex in varFeatureIndices:
+            existingLookupIndices = gsub.FeatureList.FeatureRecord[varFeatureIndex].Feature.LookupListIndex
+            records.append(buildFeatureTableSubstitutionRecord(varFeatureIndex, existingLookupIndices + lookupIndices))
+        featureVariationRecords.append(buildFeatureVariationRecord(conditionTable, records))
 
     gsub.FeatureVariations = buildFeatureVariations(featureVariationRecords)
 


Test patch:
diff --git a/Tests/designspaceLib/data/test.designspace b/Tests/designspaceLib/data/test.designspace
--- a/Tests/designspaceLib/data/test.designspace
+++ b/Tests/designspaceLib/data/test.designspace
@@ -1,5 +1,5 @@
 <?xml version='1.0' encoding='UTF-8'?>
-<designspace format="4.0">
+<designspace format="4.1">
   <axes>
     <axis tag="wght" name="weight" minimum="0" maximum="1000" default="0">
       <labelname xml:lang="en">Wéíght</labelname>
@@ -13,7 +13,7 @@
       <map input="1000" output="990"/>
     </axis>
   </axes>
-  <rules>
+  <rules processing="last">
     <rule name="named.rule.1">
       <conditionset>
         <condition name="axisName_a" minimum="0" maximum="1"/>
diff --git a/Tests/designspaceLib/designspace_test.py b/Tests/designspaceLib/designspace_test.py
--- a/Tests/designspaceLib/designspace_test.py
+++ b/Tests/designspaceLib/designspace_test.py
@@ -49,6 +49,7 @@ def test_fill_document(tmpdir):
     instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
     instancePath2 = os.path.join(tmpdir, "instances", "instanceTest2.ufo")
     doc = DesignSpaceDocument()
+    doc.rulesProcessingLast = True
 
     # write some axes
     a1 = AxisDescriptor()
@@ -698,6 +699,7 @@ def test_rulesDocument(tmpdir):
     testDocPath = os.path.join(tmpdir, "testRules.designspace")
     testDocPath2 = os.path.join(tmpdir, "testRules_roundtrip.designspace")
     doc = DesignSpaceDocument()
+    doc.rulesProcessingLast = True
     a1 = AxisDescriptor()
     a1.minimum = 0
     a1.maximum = 1000
@@ -741,6 +743,7 @@ def test_rulesDocument(tmpdir):
     _addUnwrappedCondition(testDocPath)
     doc2 = DesignSpaceDocument()
     doc2.read(testDocPath)
+    assert doc2.rulesProcessingLast
     assert len(doc2.axes) == 2
     assert len(doc2.rules) == 1
     assert len(doc2.rules[0].conditionSets) == 2
diff --git a/Tests/varLib/data/FeatureVars.designspace b/Tests/varLib/data/FeatureVars.designspace
--- a/Tests/varLib/data/FeatureVars.designspace
+++ b/Tests/varLib/data/FeatureVars.designspace
@@ -6,7 +6,7 @@
             <labelname xml:lang="en">Contrast</labelname>
         </axis>
     </axes>
-    <rules>
+    <rules processing="last">
         <rule name="dollar-stroke">
             <conditionset>
                 <condition name="weight" minimum="500" /> <!-- intentionally omitted maximum -->
diff --git a/Tests/varLib/data/test_results/FeatureVars.ttx b/Tests/varLib/data/test_results/FeatureVars.ttx
--- a/Tests/varLib/data/test_results/FeatureVars.ttx
+++ b/Tests/varLib/data/test_results/FeatureVars.ttx
@@ -43,7 +43,7 @@
     <FeatureList>
       <!-- FeatureCount=1 -->
       <FeatureRecord index="0">
-        <FeatureTag value="rvrn"/>
+        <FeatureTag value="rclt"/>
         <Feature>
           <!-- LookupCount=0 -->
         </Feature>
diff --git a/Tests/varLib/data/test_results/FeatureVars_rclt.ttx b/Tests/varLib/data/test_results/FeatureVars_rclt.ttx
new file mode 100644
--- /dev/null
+++ b/Tests/varLib/data/test_results/FeatureVars_rclt.ttx
@@ -0,0 +1,249 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="3.29">
+
+  <fvar>
+
+    <!-- Weight -->
+    <Axis>
+      <AxisTag>wght</AxisTag>
+      <Flags>0x0</Flags>
+      <MinValue>0.0</MinValue>
+      <DefaultValue>368.0</DefaultValue>
+      <MaxValue>1000.0</MaxValue>
+      <AxisNameID>256</AxisNameID>
+    </Axis>
+
+    <!-- Contrast -->
+    <Axis>
+      <AxisTag>cntr</AxisTag>
+      <Flags>0x0</Flags>
+      <MinValue>0.0</MinValue>
+      <DefaultValue>0.0</DefaultValue>
+      <MaxValue>100.0</MaxValue>
+      <AxisNameID>257</AxisNameID>
+    </Axis>
+  </fvar>
+
+  <GSUB>
+    <Version value="0x00010001"/>
+    <ScriptList>
+      <!-- ScriptCount=1 -->
+      <ScriptRecord index="0">
+        <ScriptTag value="latn"/>
+        <Script>
+          <DefaultLangSys>
+            <ReqFeatureIndex value="65535"/>
+            <!-- FeatureCount=1 -->
+            <FeatureIndex index="0" value="1"/>
+          </DefaultLangSys>
+          <!-- LangSysCount=1 -->
+          <LangSysRecord index="0">
+            <LangSysTag value="NLD "/>
+            <LangSys>
+              <ReqFeatureIndex value="65535"/>
+              <!-- FeatureCount=1 -->
+              <FeatureIndex index="0" value="0"/>
+            </LangSys>
+          </LangSysRecord>
+        </Script>
+      </ScriptRecord>
+    </ScriptList>
+    <FeatureList>
+      <!-- FeatureCount=2 -->
+      <FeatureRecord index="0">
+        <FeatureTag value="rclt"/>
+        <Feature>
+          <!-- LookupCount=1 -->
+          <LookupListIndex index="0" value="0"/>
+        </Feature>
+      </FeatureRecord>
+      <FeatureRecord index="1">
+        <FeatureTag value="rclt"/>
+        <Feature>
+          <!-- LookupCount=1 -->
+          <LookupListIndex index="0" value="1"/>
+        </Feature>
+      </FeatureRecord>
+    </FeatureList>
+    <LookupList>
+      <!-- LookupCount=5 -->
+      <Lookup index="0">
+        <LookupType value="1"/>
+        <LookupFlag value="0"/>
+        <!-- SubTableCount=1 -->
+        <SingleSubst index="0" Format="1">
+          <Substitution in="uni0041" out="uni0061"/>
+        </SingleSubst>
+      </Lookup>
+      <Lookup index="1">
+        <LookupType value="1"/>
+        <LookupFlag value="0"/>
+        <!-- SubTableCount=1 -->
+        <SingleSubst index="0" Format="1">
+          <Substitution in="uni0041" out="uni0061"/>
+        </SingleSubst>
+      </Lookup>
+      <Lookup index="2">
+        <LookupType value="1"/>
+        <LookupFlag value="0"/>
+        <!-- SubTableCount=1 -->
+        <SingleSubst index="0" Format="1">
+          <Substitution in="uni0024" out="uni0024.nostroke"/>
+        </SingleSubst>
+      </Lookup>
+      <Lookup index="3">
+        <LookupType value="1"/>
+        <LookupFlag value="0"/>
+        <!-- SubTableCount=1 -->
+        <SingleSubst index="0" Format="1">
+          <Substitution in="uni0041" out="uni0061"/>
+        </SingleSubst>
+      </Lookup>
+      <Lookup index="4">
+        <LookupType value="1"/>
+        <LookupFlag value="0"/>
+        <!-- SubTableCount=1 -->
+        <SingleSubst index="0" Format="1">
+          <Substitution in="uni0061" out="uni0041"/>
+        </SingleSubst>
+      </Lookup>
+    </LookupList>
+    <FeatureVariations>
+      <Version value="0x00010000"/>
+      <!-- FeatureVariationCount=4 -->
+      <FeatureVariationRecord index="0">
+        <ConditionSet>
+          <!-- ConditionCount=2 -->
+          <ConditionTable index="0" Format="1">
+            <AxisIndex value="1"/>
+            <FilterRangeMinValue value="0.75"/>
+            <FilterRangeMaxValue value="1.0"/>
+          </ConditionTable>
+          <ConditionTable index="1" Format="1">
+            <AxisIndex value="0"/>
+            <FilterRangeMinValue value="0.20886"/>
+            <FilterRangeMaxValue value="1.0"/>
+          </ConditionTable>
+        </ConditionSet>
+        <FeatureTableSubstitution>
+          <Version value="0x00010000"/>
+          <!-- SubstitutionCount=2 -->
+          <SubstitutionRecord index="0">
+            <FeatureIndex value="0"/>
+            <Feature>
+              <!-- LookupCount=3 -->
+              <LookupListIndex index="0" value="0"/>
+              <LookupListIndex index="1" value="2"/>
+              <LookupListIndex index="2" value="3"/>
+            </Feature>
+          </SubstitutionRecord>
+          <SubstitutionRecord index="1">
+            <FeatureIndex value="1"/>
+            <Feature>
+              <!-- LookupCount=3 -->
+              <LookupListIndex index="0" value="1"/>
+              <LookupListIndex index="1" value="2"/>
+              <LookupListIndex index="2" value="3"/>
+            </Feature>
+          </SubstitutionRecord>
+        </FeatureTableSubstitution>
+      </FeatureVariationRecord>
+      <FeatureVariationRecord index="1">
+        <ConditionSet>
+          <!-- ConditionCount=2 -->
+          <ConditionTable index="0" Format="1">
+            <AxisIndex value="1"/>
+            <FilterRangeMinValue value="0.0"/>
+            <FilterRangeMaxValue value="0.25"/>
+          </ConditionTable>
+          <ConditionTable index="1" Format="1">
+            <AxisIndex value="0"/>
+            <FilterRangeMinValue value="-1.0"/>
+            <FilterRangeMaxValue value="-0.45654"/>
+          </ConditionTable>
+        </ConditionSet>
+        <FeatureTableSubstitution>
+          <Version value="0x00010000"/>
+          <!-- SubstitutionCount=2 -->
+          <SubstitutionRecord index="0">
+            <FeatureIndex value="0"/>
+            <Feature>
+              <!-- LookupCount=2 -->
+              <LookupListIndex index="0" value="0"/>
+              <LookupListIndex index="1" value="4"/>
+            </Feature>
+          </SubstitutionRecord>
+          <SubstitutionRecord index="1">
+            <FeatureIndex value="1"/>
+            <Feature>
+              <!-- LookupCount=2 -->
+              <LookupListIndex index="0" value="1"/>
+              <LookupListIndex index="1" value="4"/>
+            </Feature>
+          </SubstitutionRecord>
+        </FeatureTableSubstitution>
+      </FeatureVariationRecord>
+      <FeatureVariationRecord index="2">
+        <ConditionSet>
+          <!-- ConditionCount=1 -->
+          <ConditionTable index="0" Format="1">
+            <AxisIndex value="1"/>
+            <FilterRangeMinValue value="0.75"/>
+            <FilterRangeMaxValue value="1.0"/>
+          </ConditionTable>
+        </ConditionSet>
+        <FeatureTableSubstitution>
+          <Version value="0x00010000"/>
+          <!-- SubstitutionCount=2 -->
+          <SubstitutionRecord index="0">
+            <FeatureIndex value="0"/>
+            <Feature>
+              <!-- LookupCount=2 -->
+              <LookupListIndex index="0" value="0"/>
+              <LookupListIndex index="1" value="3"/>
+            </Feature>
+          </SubstitutionRecord>
+          <SubstitutionRecord index="1">
+            <FeatureIndex value="1"/>
+            <Feature>
+              <!-- LookupCount=2 -->
+              <LookupListIndex index="0" value="1"/>
+              <LookupListIndex index="1" value="3"/>
+            </Feature>
+          </SubstitutionRecord>
+        </FeatureTableSubstitution>
+      </FeatureVariationRecord>
+      <FeatureVariationRecord index="3">
+        <ConditionSet>
+          <!-- ConditionCount=1 -->
+          <ConditionTable index="0" Format="1">
+            <AxisIndex value="0"/>
+            <FilterRangeMinValue value="0.20886"/>
+            <FilterRangeMaxValue value="1.0"/>
+          </ConditionTable>
+        </ConditionSet>
+        <FeatureTableSubstitution>
+          <Version value="0x00010000"/>
+          <!-- SubstitutionCount=2 -->
+          <SubstitutionRecord index="0">
+            <FeatureIndex value="0"/>
+            <Feature>
+              <!-- LookupCount=2 -->
+              <LookupListIndex index="0" value="0"/>
+              <LookupListIndex index="1" value="2"/>
+            </Feature>
+          </SubstitutionRecord>
+          <SubstitutionRecord index="1">
+            <FeatureIndex value="1"/>
+            <Feature>
+              <!-- LookupCount=2 -->
+              <LookupListIndex index="0" value="1"/>
+              <LookupListIndex index="1" value="2"/>
+            </Feature>
+          </SubstitutionRecord>
+        </FeatureTableSubstitution>
+      </FeatureVariationRecord>
+    </FeatureVariations>
+  </GSUB>
+
+</ttFont>
diff --git a/Tests/varLib/varLib_test.py b/Tests/varLib/varLib_test.py
--- a/Tests/varLib/varLib_test.py
+++ b/Tests/varLib/varLib_test.py
@@ -7,6 +7,7 @@
 from fontTools.designspaceLib import (
     DesignSpaceDocumentError, DesignSpaceDocument, SourceDescriptor,
 )
+from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
 import difflib
 import os
 import shutil
@@ -107,7 +108,8 @@ def compile_font(self, path, suffix, temp_dir):
         return font, savepath
 
     def _run_varlib_build_test(self, designspace_name, font_name, tables,
-                               expected_ttx_name, save_before_dump=False):
+                               expected_ttx_name, save_before_dump=False,
+                               post_process_master=None):
         suffix = '.ttf'
         ds_path = self.get_test_input(designspace_name + '.designspace')
         ufo_dir = self.get_test_input('master_ufo')
@@ -116,7 +118,9 @@ def _run_varlib_build_test(self, designspace_name, font_name, tables,
         self.temp_dir()
         ttx_paths = self.get_file_list(ttx_dir, '.ttx', font_name + '-')
         for path in ttx_paths:
-            self.compile_font(path, suffix, self.tempdir)
+            font, savepath = self.compile_font(path, suffix, self.tempdir)
+            if post_process_master is not None:
+                post_process_master(font, savepath)
 
         finder = lambda s: s.replace(ufo_dir, self.tempdir).replace('.ufo', suffix)
         varfont, model, _ = build(ds_path, finder)
@@ -213,6 +217,47 @@ def test_varlib_build_feature_variations(self):
             save_before_dump=True,
         )
 
+    def test_varlib_build_feature_variations_with_existing_rclt(self):
+        """Designspace file contains <rules> element, used to build GSUB
+        FeatureVariations table. <rules> is specified to do its OT processing
+        "last", so a 'rclt' feature will be used or created. This test covers
+        the case when a 'rclt' already exists in the masters.
+
+        We dynamically add a 'rclt' feature to an existing set of test
+        masters, to avoid adding more test data.
+
+        The multiple languages are done to verify whether multiple existing
+        'rclt' features are updated correctly.
+        """
+        def add_rclt(font, savepath):
+            features = """
+            languagesystem DFLT dflt;
+            languagesystem latn dflt;
+            languagesystem latn NLD;
+
+            feature rclt {
+                script latn;
+                language NLD;
+                lookup A {
+                    sub uni0041 by uni0061;
+                } A;
+                language dflt;
+                lookup B {
+                    sub uni0041 by uni0061;
+                } B;
+            } rclt;
+            """
+            addOpenTypeFeaturesFromString(font, features)
+            font.save(savepath)
+        self._run_varlib_build_test(
+            designspace_name="FeatureVars",
+            font_name="TestFamily",
+            tables=["fvar", "GSUB"],
+            expected_ttx_name="FeatureVars_rclt",
+            save_before_dump=True,
+            post_process_master=add_rclt,
+        )
+
     def test_varlib_gvar_explicit_delta(self):
         """The variable font contains a composite glyph odieresis which does not
         need a gvar entry, because all its deltas are 0, but it must be added