Repository: fonttools/fonttools

Issue:
[feaLib] Tweak sort order of ligatures within a ligature set
In discussion with @anthrotype, it would be nice if we could make fea-rs and fonttools use the same sort order for ligatures within a ligature set. Currently fea-rs sorts by (decreasing) length, breaking ties by GID; fonttools is breaking ties by glyph name. Ultimately both approaches are arbitrary, and it isn't trivial for either of us to adopt the behaviour of the other, so I think there is a simple solution: we shouldn't break ties at all, and just use the declaration order in the source. That is, we should do a stable sort by decreasing length.


This will be changed in fea-rs shortly, and I'm opening this as a tracking issue for eventually making this change here as well.

I have written up my current understanding of this problem (or non problem, I think?) here: https://github.com/adobe-type-tools/afdko/issues/1727#issuecomment-1906703943



Discussion:
SGTM. 
SGTM too, I'll send a PR

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
@@ -1338,7 +1338,7 @@ def add_ligature_subst(
         # substitutions to be specified on target sequences that contain
         # glyph classes, the implementation software will enumerate
         # all specific glyph sequences if glyph classes are detected"
-        for g in sorted(itertools.product(*glyphs)):
+        for g in itertools.product(*glyphs):
             lookup.ligatures[g] = replacement
 
     # GSUB 5/6
diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py
--- a/Lib/fontTools/otlLib/builder.py
+++ b/Lib/fontTools/otlLib/builder.py
@@ -1567,19 +1567,6 @@ def buildAlternateSubstSubtable(mapping):
     return self
 
 
-def _getLigatureKey(components):
-    # Computes a key for ordering ligatures in a GSUB Type-4 lookup.
-
-    # When building the OpenType lookup, we need to make sure that
-    # the longest sequence of components is listed first, so we
-    # use the negative length as the primary key for sorting.
-    # To make buildLigatureSubstSubtable() deterministic, we use the
-    # component sequence as the secondary key.
-
-    # For example, this will sort (f,f,f) < (f,f,i) < (f,f) < (f,i) < (f,l).
-    return (-len(components), components)
-
-
 def buildLigatureSubstSubtable(mapping):
     """Builds a ligature substitution (GSUB4) subtable.
 
@@ -1613,7 +1600,7 @@ def buildLigatureSubstSubtable(mapping):
     # with fontTools >= 3.1:
     # self.ligatures = dict(mapping)
     self.ligatures = {}
-    for components in sorted(mapping.keys(), key=_getLigatureKey):
+    for components in sorted(mapping.keys(), key=self._getLigatureSortKey):
         ligature = ot.Ligature()
         ligature.Component = components[1:]
         ligature.CompCount = len(ligature.Component) + 1
diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py
--- a/Lib/fontTools/ttLib/tables/otTables.py
+++ b/Lib/fontTools/ttLib/tables/otTables.py
@@ -1123,6 +1123,35 @@ def postRead(self, rawTable, font):
         self.ligatures = ligatures
         del self.Format  # Don't need this anymore
 
+    @staticmethod
+    def _getLigatureSortKey(components):
+        # Computes a key for ordering ligatures in a GSUB Type-4 lookup.
+
+        # When building the OpenType lookup, we need to make sure that
+        # the longest sequence of components is listed first, so we
+        # use the negative length as the key for sorting.
+        # Note, we no longer need to worry about deterministic order because the
+        # ligature mapping `dict` remembers the insertion order, and this in
+        # turn depends on the order in which the ligatures are written in the FEA.
+        # Since python sort algorithm is stable, the ligatures of equal length
+        # will keep the relative order in which they appear in the feature file.
+        # For example, given the following ligatures (all starting with 'f' and
+        # thus belonging to the same LigatureSet):
+        #
+        #   feature liga {
+        #     sub f i by f_i;
+        #     sub f f f by f_f_f;
+        #     sub f f by f_f;
+        #     sub f f i by f_f_i;
+        #   } liga;
+        #
+        # this should sort to: f_f_f, f_f_i, f_i, f_f
+        # This is also what fea-rs does, see:
+        # https://github.com/adobe-type-tools/afdko/issues/1727
+        # https://github.com/fonttools/fonttools/issues/3428
+        # https://github.com/googlefonts/fontc/pull/680
+        return -len(components)
+
     def preWrite(self, font):
         self.Format = 1
         ligatures = getattr(self, "ligatures", None)
@@ -1135,13 +1164,11 @@ def preWrite(self, font):
 
             # ligatures is map from components-sequence to lig-glyph
             newLigatures = dict()
-            for comps, lig in sorted(
-                ligatures.items(), key=lambda item: (-len(item[0]), item[0])
-            ):
+            for comps in sorted(ligatures.keys(), key=self._getLigatureSortKey):
                 ligature = Ligature()
                 ligature.Component = comps[1:]
                 ligature.CompCount = len(comps)
-                ligature.LigGlyph = lig
+                ligature.LigGlyph = ligatures[comps]
                 newLigatures.setdefault(comps[0], []).append(ligature)
             ligatures = newLigatures
 


Test patch:
diff --git a/Tests/feaLib/data/spec4h1.ttx b/Tests/feaLib/data/spec4h1.ttx
--- a/Tests/feaLib/data/spec4h1.ttx
+++ b/Tests/feaLib/data/spec4h1.ttx
@@ -147,8 +147,8 @@
         <!-- SubTableCount=1 -->
         <LigatureSubst index="0">
           <LigatureSet glyph="c">
-            <Ligature components="s" glyph="c_s"/>
             <Ligature components="t" glyph="c_t"/>
+            <Ligature components="s" glyph="c_s"/>
           </LigatureSet>
         </LigatureSubst>
       </Lookup>
diff --git a/Tests/feaLib/data/spec5d1.fea b/Tests/feaLib/data/spec5d1.fea
--- a/Tests/feaLib/data/spec5d1.fea
+++ b/Tests/feaLib/data/spec5d1.fea
@@ -11,6 +11,16 @@ feature F1 {
 # if glyph classes are detected in <glyph sequence>.  Thus, the above
 # example produces an identical representation in the font as if all
 # the sequences were manually enumerated by the font editor:
+#
+# NOTE(anthrotype): The previous sentence is no longer entirely true, since we
+# now preserve the order in which the ligatures (with same length and first glyph)
+# were specified in the feature file and do not sort them alphabetically
+# by the ligature component names. Therefore, the way this particular example from
+# the FEA spec is written will produce two slightly different representations
+# in the font in which the ligatures are enumerated differently, however the two
+# lookups are functionally equivalent.
+# See: https://github.com/fonttools/fonttools/issues/3428
+# https://github.com/adobe-type-tools/afdko/issues/1727
 feature F2 {
   sub one slash two by onehalf;
   sub one.oldstyle slash two by onehalf;
diff --git a/Tests/feaLib/data/spec5d1.ttx b/Tests/feaLib/data/spec5d1.ttx
--- a/Tests/feaLib/data/spec5d1.ttx
+++ b/Tests/feaLib/data/spec5d1.ttx
@@ -43,16 +43,16 @@
         <!-- SubTableCount=1 -->
         <LigatureSubst index="0">
           <LigatureSet glyph="one">
-            <Ligature components="fraction,two" glyph="onehalf"/>
-            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
             <Ligature components="slash,two" glyph="onehalf"/>
             <Ligature components="slash,two.oldstyle" glyph="onehalf"/>
-          </LigatureSet>
-          <LigatureSet glyph="one.oldstyle">
             <Ligature components="fraction,two" glyph="onehalf"/>
             <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
+          </LigatureSet>
+          <LigatureSet glyph="one.oldstyle">
             <Ligature components="slash,two" glyph="onehalf"/>
             <Ligature components="slash,two.oldstyle" glyph="onehalf"/>
+            <Ligature components="fraction,two" glyph="onehalf"/>
+            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
           </LigatureSet>
         </LigatureSubst>
       </Lookup>
@@ -62,16 +62,16 @@
         <!-- SubTableCount=1 -->
         <LigatureSubst index="0">
           <LigatureSet glyph="one">
-            <Ligature components="fraction,two" glyph="onehalf"/>
-            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
             <Ligature components="slash,two" glyph="onehalf"/>
+            <Ligature components="fraction,two" glyph="onehalf"/>
             <Ligature components="slash,two.oldstyle" glyph="onehalf"/>
+            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
           </LigatureSet>
           <LigatureSet glyph="one.oldstyle">
-            <Ligature components="fraction,two" glyph="onehalf"/>
-            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
             <Ligature components="slash,two" glyph="onehalf"/>
+            <Ligature components="fraction,two" glyph="onehalf"/>
             <Ligature components="slash,two.oldstyle" glyph="onehalf"/>
+            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
           </LigatureSet>
         </LigatureSubst>
       </Lookup>
diff --git a/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB b/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB
--- a/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB
+++ b/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB
@@ -16,20 +16,20 @@
           <Ligature components="Jsmall" glyph="IJsmall"/>
         </LigatureSet>
         <LigatureSet glyph="f">
-          <Ligature components="f,b" glyph="ffb"/>
-          <Ligature components="f,h" glyph="ffh"/>
           <Ligature components="f,i" glyph="ffi"/>
-          <Ligature components="f,k" glyph="ffk"/>
           <Ligature components="f,l" glyph="ffl"/>
           <Ligature components="f,t" glyph="fft"/>
-          <Ligature components="b" glyph="fb"/>
-          <Ligature components="f" glyph="ff"/>
-          <Ligature components="h" glyph="fh"/>
+          <Ligature components="f,b" glyph="ffb"/>
+          <Ligature components="f,h" glyph="ffh"/>
+          <Ligature components="f,k" glyph="ffk"/>
           <Ligature components="i" glyph="fi"/>
-          <Ligature components="j" glyph="fj"/>
-          <Ligature components="k" glyph="fk"/>
           <Ligature components="l" glyph="fl"/>
+          <Ligature components="f" glyph="ff"/>
           <Ligature components="t" glyph="ft"/>
+          <Ligature components="b" glyph="fb"/>
+          <Ligature components="h" glyph="fh"/>
+          <Ligature components="k" glyph="fk"/>
+          <Ligature components="j" glyph="fj"/>
         </LigatureSet>
         <LigatureSet glyph="i">
           <Ligature components="j" glyph="ij"/>
diff --git a/Tests/otlLib/builder_test.py b/Tests/otlLib/builder_test.py
--- a/Tests/otlLib/builder_test.py
+++ b/Tests/otlLib/builder_test.py
@@ -1051,11 +1051,11 @@ def test_buildValue(self):
         func = lambda writer, font: value.toXML(writer, font, valueName="Val")
         assert getXML(func) == ['<Val XPlacement="7" YPlacement="23"/>']
 
-    def test_getLigatureKey(self):
+    def test_getLigatureSortKey(self):
         components = lambda s: [tuple(word) for word in s.split()]
         c = components("fi fl ff ffi fff")
-        c.sort(key=builder._getLigatureKey)
-        assert c == components("fff ffi ff fi fl")
+        c.sort(key=otTables.LigatureSubst._getLigatureSortKey)
+        assert c == components("ffi fff fi fl ff")
 
     def test_getSinglePosValueKey(self):
         device = builder.buildDevice({10: 1, 11: 3})