Repository: fonttools/fonttools

Issue:
[feaLib] miscompilation with mixed multi and ligature subst rules
This is related to #3846 /  #3849.

Given the following FEA (from [`NotoSansMalayalam.glyphs`](https://github.com/notofonts/malayalam)),


```fea
feature blwf {
    # GSUB feature: Below-base Forms
    # Lookups: 2
    script mlym;
    # Malayalam
    sub lamlym viramamlym by lasubscriptmlym;
    sub lasubscriptmlym by viramamlym lamlym;
    language MAL;
    # Malayalam
} blwf;
```

feaLib generates two lookups, but fails to add the second lookup to the feature.

I presume this is because the lookup is added to the feature when the rule is encountered, and at that point both rules share a single lookup; the lookups are split afterwards.

cc @khaledhosny, not sure if there's a reasonably simple fix for this?


Discussion:

Patch:
diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py
index ce9515bd68..25b319c0b0 100644
--- a/Lib/fontTools/feaLib/builder.py
+++ b/Lib/fontTools/feaLib/builder.py
@@ -259,12 +259,13 @@ def add_lookup_to_feature_(self, lookup, feature_name):
             key = (script, lang, feature_name)
             self.features_.setdefault(key, []).append(lookup)
 
-    def get_lookup_(self, location, builder_class):
+    def get_lookup_(self, location, builder_class, mapping=None):
         if (
             self.cur_lookup_
             and type(self.cur_lookup_) == builder_class
             and self.cur_lookup_.lookupflag == self.lookupflag_
             and self.cur_lookup_.markFilterSet == self.lookupflag_markFilterSet_
+            and self.cur_lookup_.can_add_mapping(mapping)
         ):
             return self.cur_lookup_
         if self.cur_lookup_name_ and self.cur_lookup_:
@@ -1305,7 +1306,7 @@ def set_size_parameters(
     # GSUB rules
 
     def add_any_subst_(self, location, mapping):
-        lookup = self.get_lookup_(location, AnySubstBuilder)
+        lookup = self.get_lookup_(location, AnySubstBuilder, mapping=mapping)
         for key, value in mapping.items():
             if key in lookup.mapping:
                 if value == lookup.mapping[key]:
diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py
index da00e9c5eb..c4b482f02b 100644
--- a/Lib/fontTools/otlLib/builder.py
+++ b/Lib/fontTools/otlLib/builder.py
@@ -256,6 +256,10 @@ def add_subtable_break(self, location):
             )
         )
 
+    def can_add_mapping(self, _mapping) -> bool:
+        # used by AnySubstBuilder,  below
+        return True
+
 
 class AlternateSubstBuilder(LookupBuilder):
     """Builds an Alternate Substitution (GSUB3) lookup.
@@ -1376,6 +1380,32 @@ def _add_to_multiple_subst(self, builder, key, value):
     def _add_to_ligature_subst(self, builder, key, value):
         builder.ligatures[key] = value[0]
 
+    def can_add_mapping(self, mapping) -> bool:
+        if mapping is None:
+            return True
+        # single sub rules can be treated as (degenerate) liga-or-multi sub
+        # rules, but multi and liga sub rules themselves have incompatible
+        # representations. It is uncommon that these are in the same set of
+        # rules, but it happens.
+        is_multi = any(len(v) > 1 for v in mapping.values())
+        is_liga = any(len(k) > 1 for k in mapping.keys())
+
+        has_existing_multi = False
+        has_existing_liga = False
+
+        for k, v in self.mapping.items():
+            if k[0] == self.SUBTABLE_BREAK_:
+                continue
+            if len(k) > 1:
+                has_existing_liga = True
+            if len(v) > 1:
+                has_existing_multi = True
+
+        can_reuse = not (has_existing_multi and is_liga) and not (
+            has_existing_liga and is_multi
+        )
+        return can_reuse
+
     def promote_lookup_type(self, is_named_lookup):
         # https://github.com/fonttools/fonttools/issues/612
         # A multiple substitution may have a single destination, in which case
diff --git a/Tests/feaLib/data/combo_mult_and_lig_sub.fea b/Tests/feaLib/data/combo_mult_and_lig_sub.fea
new file mode 100644
index 0000000000..be1cfbdfa3
--- /dev/null
+++ b/Tests/feaLib/data/combo_mult_and_lig_sub.fea
@@ -0,0 +1,5 @@
+feature blwf {
+    sub a b by c;
+    sub c by b a;
+} blwf;
+
diff --git a/Tests/feaLib/data/combo_mult_and_lig_sub.ttx b/Tests/feaLib/data/combo_mult_and_lig_sub.ttx
new file mode 100644
index 0000000000..0480122943
--- /dev/null
+++ b/Tests/feaLib/data/combo_mult_and_lig_sub.ttx
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="4.58">
+
+  <GSUB>
+    <Version value="0x00010000"/>
+    <ScriptList>
+      <!-- ScriptCount=1 -->
+      <ScriptRecord index="0">
+        <ScriptTag value="DFLT"/>
+        <Script>
+          <DefaultLangSys>
+            <ReqFeatureIndex value="65535"/>
+            <!-- FeatureCount=1 -->
+            <FeatureIndex index="0" value="0"/>
+          </DefaultLangSys>
+          <!-- LangSysCount=0 -->
+        </Script>
+      </ScriptRecord>
+    </ScriptList>
+    <FeatureList>
+      <!-- FeatureCount=1 -->
+      <FeatureRecord index="0">
+        <FeatureTag value="blwf"/>
+        <Feature>
+          <!-- LookupCount=2 -->
+          <LookupListIndex index="0" value="0"/>
+          <LookupListIndex index="1" value="1"/>
+        </Feature>
+      </FeatureRecord>
+    </FeatureList>
+    <LookupList>
+      <!-- LookupCount=2 -->
+      <Lookup index="0">
+        <LookupType value="4"/>
+        <LookupFlag value="0"/>
+        <!-- SubTableCount=1 -->
+        <LigatureSubst index="0">
+          <LigatureSet glyph="a">
+            <Ligature components="b" glyph="c"/>
+          </LigatureSet>
+        </LigatureSubst>
+      </Lookup>
+      <Lookup index="1">
+        <LookupType value="2"/>
+        <LookupFlag value="0"/>
+        <!-- SubTableCount=1 -->
+        <MultipleSubst index="0">
+          <Substitution in="c" out="b,a"/>
+        </MultipleSubst>
+      </Lookup>
+    </LookupList>
+  </GSUB>
+
+</ttFont>


Test patch:
diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py
index 98d6340309..086d8ea438 100644
--- a/Tests/feaLib/builder_test.py
+++ b/Tests/feaLib/builder_test.py
@@ -96,6 +96,7 @@ class BuilderTest(unittest.TestCase):
         useExtension
         bug3846_1 bug3846_2
         empty_filter_sets_and_mark_classes
+        combo_mult_and_lig_sub
     """.split()
 
     VARFONT_AXES = [
@@ -439,19 +440,25 @@ def test_mixed_singleSubst_multipleSubst_ligatureSubst_feature(self):
 
         assert "GSUB" in font
         lookups = font["GSUB"].table.LookupList.Lookup
-        self.assertEqual(len(lookups), 3)
+        self.assertEqual(len(lookups), 2)
         st = lookups[0].SubTable[0]
-        self.assertEqual(st.LookupType, 1)
-        self.assertEqual(st.mapping, {"A": "A.sc"})
-        st = lookups[1].SubTable[0]
+        # first should get promoted to multi
         self.assertEqual(st.LookupType, 2)
-        self.assertEqual(st.mapping, {"f_f": ("f", "f")})
-        st = lookups[2].SubTable[0]
+        self.assertEqual(st.mapping, {"A": ("A.sc",), "f_f": ("f", "f")})
+        # st = lookups[1].SubTable[0]
+        # self.assertEqual(st.LookupType, 2)
+        # self.assertEqual(st.mapping, {"f_f": ("f", "f")})
+        st = lookups[1].SubTable[0]
         self.assertEqual(st.LookupType, 4)
         self.assertEqual(len(st.ligatures), 1)
         self.assertEqual(len(st.ligatures["f"]), 1)
         self.assertEqual(st.ligatures["f"][0].LigGlyph, "f_f_i")
 
+        features = font["GSUB"].table.FeatureList.FeatureRecord
+        self.assertEqual(len(features), 1)
+        feat = features[0].Feature
+        self.assertEqual(feat.LookupListIndex, [0, 1])
+
     def test_pairPos_redefinition_warning(self):
         # https://github.com/fonttools/fonttools/issues/1147
         logger = logging.getLogger("fontTools.otlLib.builder")