Repository: googlefonts/ufo2ft
Pull request #796 (2023-11-14 18:11:53)

[markFeatureWriter] disable grouping by default, build one lookup per mark class
this was motivated by discussions in https://github.com/googlefonts/ufo2ft/issues/762 and https://github.com/googlefonts/ufo2ft/issues/591

Also this is more similar to the way we decided to implement the mark feature generation in fontc.

I haven't added tests yet, am undecided whether we need to add an option like I did here or change the default behavior and do not group marks any more.

Also I simply sorted the lookups by the anchor name. I think this is easy enough to understand and work with and should cover the use case  of "more specific anchors should override less specific ones". In general, the more specific anchors qualify the name of more general ones with some suffix so sorting alphabetically should make sure that e.g. 'top.alt' comes after 'top' and thus prevail in case of ambiguous attachments.

/cc @simoncozens @khaledhosny WDYT?


anthrotype: /cc @belluzj @madig 
khaledhosny: Or at least make the default to False for now and see if anything breaks.
belluzj: If having many lookups doesn't matter then I don't have objections to simplify the code
anthrotype: another thing to note about this is that, currently we are somewhat arbitrarily giving priority to "top" and "bottom" anchors when we sort the lookups, I think this was done to match Glyphs.app howeve lots of time has passed and various changes occurred and this may no longer be necessary. In this PR, I am simply sorting alphabetically which means top and bottom will not be treated any special.
I think this is in theory the right thing to do and makes it easier to a font developer to understand what will happen and how to work within this and rename anchors accordingly if they desire a different order. But there might be existing font projects that may rely on the current behavior for one reason or another, which makes me wary of flipping the default...
anthrotype: I just tested with the latest Glyphs.app and it turns out it simply orders each lookup alphabetically and the lookups are named after the mark class they contain so it's not actually true that "top" and "bottom" always prevail. If a glyph has e.g. both "top" and "top.alt" and another mark has both "_top" and "_top.alt" anchors, which one do you expect to win?

Well, for current ufo2ft, "top" should win. But for Glyphs.app and for ufo2ft after this PR, it's "top.alt" that is placed last in the lookup order and thus is the one that sticks.

So we can argue that we are fixing a bug and aligning ourselves back to a major implementation like Glyphs.app and turn off the grouping by default and simply sort alphabetically.

We can keep it as an option just in case one wants the old behavior for compat reasons.

Patch:
diff --git a/Lib/ufo2ft/featureWriters/markFeatureWriter.py b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
index 22fbc16..9a79bb4 100644
--- a/Lib/ufo2ft/featureWriters/markFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
@@ -276,9 +276,21 @@ class MarkFeatureWriter(BaseFeatureWriter):
 
     If the `quantization` argument is given in the filter options, the resulting
     anchors are rounded to the nearest multiple of the quantization value.
+
+    If `groupMarkClases=True`, mark-to-base or mark-to-ligature attachments that
+    reference non-overlapping mark classes will get grouped in the same lookup; and
+    if a mark glyph is in more than one mark class, additional lookups will be generated
+    for those as required. NOTE: this was the default behavior until ufo2ft 2.33.4.
+    The current default behavior was simplified to match other font editors and
+    we now build as many mark-to-base and mark-to-liga lookups as there
+    are mark classes, and lookups are sorted alphabetically by the mark class
+    name so the more specific ('top.alt' instead 'top') would be applied last and
+    wins in case when the same base or ligature glyph can attach to the same mark
+    through multiple mark classes.
+    https://github.com/googlefonts/ufo2ft/issues/591
     """
 
-    options = dict(quantization=1)
+    options = dict(quantization=1, groupMarkClasses=False)
 
     tableTag = "GPOS"
     features = frozenset(["mark", "mkmk", "abvm", "blwm"])
@@ -555,11 +567,23 @@ class MarkFeatureWriter(BaseFeatureWriter):
         #   through different anchor names, we may have to split the attachment
         #   into two attachments, using null anchors instead of one or the other
         #   mark class in each split attachment.
-        markGlyphToMarkClasses = defaultdict(set)
-        for attachment in attachments:
-            for markGlyph, markClasses in attachment.getMarkGlyphToMarkClasses():
-                markGlyphToMarkClasses[markGlyph].update(markClasses)
-        groupedMarkClasses = self._groupMarkClasses(markGlyphToMarkClasses)
+        if self.options.groupMarkClasses:
+            markGlyphToMarkClasses = defaultdict(set)
+            for attachment in attachments:
+                for markGlyph, markClasses in attachment.getMarkGlyphToMarkClasses():
+                    markGlyphToMarkClasses[markGlyph].update(markClasses)
+            groupedMarkClasses = self._groupMarkClasses(markGlyphToMarkClasses)
+        else:
+            # this will generate one lookup per mark class, and sort them
+            # lexicographically by the anchor name, so the lookup for e.g.
+            # '_top.alt01' will occur *after* the one for `_top` (the last wins) thus
+            # allowing some degree of control on potentially ambiguous attachments
+            # https://github.com/googlefonts/ufo2ft/issues/762
+            # https://github.com/googlefonts/ufo2ft/issues/591
+            groupedMarkClasses = [
+                [markClass.name]
+                for _, markClass in sorted(self.context.markClasses.items())
+            ]
         self._logIfAmbiguous(attachments, groupedMarkClasses)
         lookups = []
         for markClasses in groupedMarkClasses:
@@ -717,14 +741,16 @@ class MarkFeatureWriter(BaseFeatureWriter):
 
     def _makeMarkFeature(self, include):
         baseLkps = []
-        for i, attachments in enumerate(self.context.groupedMarkToBaseAttachments):
+        for attachments in self.context.groupedMarkToBaseAttachments:
+            i = len(baseLkps)
             lookup = self._makeMarkLookup(
                 f"mark2base{'_' + str(i) if i > 0 else ''}", attachments, include
             )
             if lookup:
                 baseLkps.append(lookup)
         ligaLkps = []
-        for i, attachments in enumerate(self.context.groupedMarkToLigaAttachments):
+        for attachments in self.context.groupedMarkToLigaAttachments:
+            i = len(ligaLkps)
             lookup = self._makeMarkLookup(
                 f"mark2liga{'_' + str(i) if i > 0 else ''}", attachments, include
             )
@@ -779,7 +805,8 @@ class MarkFeatureWriter(BaseFeatureWriter):
             raise AssertionError(tag)
 
         baseLkps = []
-        for i, attachments in enumerate(self.context.groupedMarkToBaseAttachments):
+        for attachments in self.context.groupedMarkToBaseAttachments:
+            i = len(baseLkps)
             lookup = self._makeMarkLookup(
                 f"{tag}_mark2base{'_' + str(i) if i > 0 else ''}",
                 attachments,
@@ -789,7 +816,8 @@ class MarkFeatureWriter(BaseFeatureWriter):
             if lookup:
                 baseLkps.append(lookup)
         ligaLkps = []
-        for i, attachments in enumerate(self.context.groupedMarkToLigaAttachments):
+        for attachments in self.context.groupedMarkToLigaAttachments:
+            i = len(ligaLkps)
             lookup = self._makeMarkLookup(
                 f"{tag}_mark2liga{'_' + str(i) if i > 0 else ''}",
                 attachments,


Test patch:
diff --git a/tests/featureWriters/markFeatureWriter_test.py b/tests/featureWriters/markFeatureWriter_test.py
index c140476..75d8307 100644
--- a/tests/featureWriters/markFeatureWriter_test.py
+++ b/tests/featureWriters/markFeatureWriter_test.py
@@ -696,7 +696,7 @@ class MarkFeatureWriterTest(FeatureWriterTest):
         )
         ufo.newGlyph("ogonekcomb").appendAnchor({"name": "_bottom", "x": 180, "y": -10})
 
-        writer = RobotoMarkFeatureWriter()
+        writer = RobotoMarkFeatureWriter(groupMarkClasses=True)
         feaFile = ast.FeatureFile()
         writer.write(ufo, feaFile)
 
@@ -740,7 +740,102 @@ class MarkFeatureWriterTest(FeatureWriterTest):
             """  # noqa: B950
         )
 
-    def test_abvm_blwm_features(self, FontClass):
+    @pytest.mark.parametrize(
+        "groupMarkClasses, expected",
+        [
+            (
+                True,
+                dedent(
+                    """\
+                    markClass nukta-kannada <anchor 0 0> @MC_bottom;
+                    markClass candrabindu-kannada <anchor 0 547> @MC_top;
+                    markClass halant-kannada <anchor -456 460> @MC_topright;
+
+                    feature abvm {
+                        lookup abvm_mark2base {
+                            pos base ka-kannada.base
+                                <anchor 291 547> mark @MC_top
+                                <anchor 391 460> mark @MC_topright;
+                        } abvm_mark2base;
+
+                    } abvm;
+
+                    feature blwm {
+                        lookup blwm_mark2base {
+                            pos base ka-kannada
+                                <anchor 290 0> mark @MC_bottom;
+                            pos base ka-kannada.base
+                                <anchor 290 0> mark @MC_bottom;
+                        } blwm_mark2base;
+
+                    } blwm;
+
+                    feature mark {
+                        lookup mark2base {
+                            pos base dottedCircle
+                                <anchor 297 0> mark @MC_bottom
+                                <anchor 297 552> mark @MC_top
+                                <anchor 491 458> mark @MC_topright;
+                        } mark2base;
+
+                    } mark;
+                    """  # noqa: B950
+                ),
+            ),
+            (
+                False,
+                dedent(
+                    """\
+                    markClass nukta-kannada <anchor 0 0> @MC_bottom;
+                    markClass candrabindu-kannada <anchor 0 547> @MC_top;
+                    markClass halant-kannada <anchor -456 460> @MC_topright;
+
+                    feature abvm {
+                        lookup abvm_mark2base {
+                            pos base ka-kannada.base
+                                <anchor 291 547> mark @MC_top;
+                        } abvm_mark2base;
+
+                        lookup abvm_mark2base_1 {
+                            pos base ka-kannada.base
+                                <anchor 391 460> mark @MC_topright;
+                        } abvm_mark2base_1;
+
+                    } abvm;
+
+                    feature blwm {
+                        lookup blwm_mark2base {
+                            pos base ka-kannada
+                                <anchor 290 0> mark @MC_bottom;
+                            pos base ka-kannada.base
+                                <anchor 290 0> mark @MC_bottom;
+                        } blwm_mark2base;
+
+                    } blwm;
+
+                    feature mark {
+                        lookup mark2base {
+                            pos base dottedCircle
+                                <anchor 297 0> mark @MC_bottom;
+                        } mark2base;
+
+                        lookup mark2base_1 {
+                            pos base dottedCircle
+                                <anchor 297 552> mark @MC_top;
+                        } mark2base_1;
+
+                        lookup mark2base_2 {
+                            pos base dottedCircle
+                                <anchor 491 458> mark @MC_topright;
+                        } mark2base_2;
+
+                    } mark;
+                    """  # noqa: B950
+                ),
+            ),
+        ],
+    )
+    def test_abvm_blwm_features(self, FontClass, groupMarkClasses, expected):
         ufo = FontClass()
         ufo.info.unitsPerEm = 1000
 
@@ -784,44 +879,9 @@ class MarkFeatureWriterTest(FeatureWriterTest):
             } psts;
             """
         )
-        generated = self.writeFeatures(ufo)
-
-        assert str(generated) == dedent(
-            """\
-            markClass nukta-kannada <anchor 0 0> @MC_bottom;
-            markClass candrabindu-kannada <anchor 0 547> @MC_top;
-            markClass halant-kannada <anchor -456 460> @MC_topright;
-
-            feature abvm {
-                lookup abvm_mark2base {
-                    pos base ka-kannada.base
-                        <anchor 291 547> mark @MC_top
-                        <anchor 391 460> mark @MC_topright;
-                } abvm_mark2base;
-
-            } abvm;
-
-            feature blwm {
-                lookup blwm_mark2base {
-                    pos base ka-kannada
-                        <anchor 290 0> mark @MC_bottom;
-                    pos base ka-kannada.base
-                        <anchor 290 0> mark @MC_bottom;
-                } blwm_mark2base;
+        generated = self.writeFeatures(ufo, groupMarkClasses=groupMarkClasses)
 
-            } blwm;
-
-            feature mark {
-                lookup mark2base {
-                    pos base dottedCircle
-                        <anchor 297 0> mark @MC_bottom
-                        <anchor 297 552> mark @MC_top
-                        <anchor 491 458> mark @MC_topright;
-                } mark2base;
-
-            } mark;
-            """  # noqa: B950
-        )
+        assert str(generated) == expected
 
     def test_shared_script_char(self, FontClass):
         ufo = FontClass()
@@ -848,7 +908,7 @@ class MarkFeatureWriterTest(FeatureWriterTest):
             languagesystem arab dflt;
             """
         )
-        generated = self.writeFeatures(ufo)
+        generated = self.writeFeatures(ufo, groupMarkClasses=True)
 
         assert str(generated) == dedent(
             """\
@@ -899,7 +959,7 @@ class MarkFeatureWriterTest(FeatureWriterTest):
         )
 
         ufo.features.text = ""
-        generated = self.writeFeatures(ufo)
+        generated = self.writeFeatures(ufo, groupMarkClasses=True)
         assert str(generated) == expected
 
         ufo.features.text = dedent(
@@ -909,10 +969,233 @@ class MarkFeatureWriterTest(FeatureWriterTest):
             languagesystem adlm dflt;
             """
         )
-        generated = self.writeFeatures(ufo)
+        generated = self.writeFeatures(ufo, groupMarkClasses=True)
         assert str(generated) == expected
 
-    def test_all_features(self, testufo):
+    @pytest.mark.parametrize(
+        "groupMarkClasses, expected",
+        [
+            (
+                True,
+                dedent(
+                    """\
+                    markClass barcomb <anchor 100 40> @MC_bar;
+                    markClass cedillacomb <anchor 10 -5> @MC_bottom;
+                    markClass bazcomb <anchor 90 320> @MC_bottom;
+                    markClass acutecomb <anchor 100 200> @MC_top;
+                    markClass tildecomb <anchor 100 200> @MC_top;
+                    markClass foocomb <anchor 100 40> @MC_top;
+
+                    feature abvm {
+                        lookup abvm_mark2liga {
+                            pos ligature foo_bar_baz
+                                    <anchor 100 500> mark @MC_top
+                                ligComponent
+                                    <anchor NULL>
+                                ligComponent
+                                    <anchor 1100 499> mark @MC_bar
+                                    <anchor 1000 500> mark @MC_top;
+                            pos ligature bar_foo
+                                    <anchor NULL>
+                                ligComponent
+                                    <anchor 600 501> mark @MC_top;
+                        } abvm_mark2liga;
+
+                        lookup abvm_mark2mark_bar {
+                            @MFS_abvm_mark2mark_bar = [barcomb];
+                            lookupflag UseMarkFilteringSet @MFS_abvm_mark2mark_bar;
+                            pos mark barcomb
+                                <anchor 100 440> mark @MC_bar;
+                        } abvm_mark2mark_bar;
+
+                        lookup abvm_mark2mark_top {
+                            @MFS_abvm_mark2mark_top = [foocomb];
+                            lookupflag UseMarkFilteringSet @MFS_abvm_mark2mark_top;
+                            pos mark foocomb
+                                <anchor 100 190> mark @MC_top;
+                        } abvm_mark2mark_top;
+
+                    } abvm;
+
+                    feature blwm {
+                        lookup blwm_mark2liga {
+                            pos ligature foo_bar_baz
+                                    <anchor 100 10> mark @MC_bottom
+                                ligComponent
+                                    <anchor NULL>
+                                ligComponent
+                                    <anchor NULL>;
+                        } blwm_mark2liga;
+
+                        lookup blwm_mark2mark_bottom {
+                            @MFS_blwm_mark2mark_bottom = [bazcomb];
+                            lookupflag UseMarkFilteringSet @MFS_blwm_mark2mark_bottom;
+                            pos mark bazcomb
+                                <anchor 100 -34> mark @MC_bottom;
+                        } blwm_mark2mark_bottom;
+
+                    } blwm;
+
+                    feature mark {
+                        lookup mark2base {
+                            pos base a
+                                <anchor 100 200> mark @MC_top;
+                            pos base c
+                                <anchor 240 0> mark @MC_bottom;
+                            pos base dottedCircle
+                                <anchor 491 458> mark @MC_bar
+                                <anchor 297 0> mark @MC_bottom
+                                <anchor 297 552> mark @MC_top;
+                        } mark2base;
+
+                        lookup mark2liga {
+                            pos ligature f_i
+                                    <anchor 100 500> mark @MC_top
+                                ligComponent
+                                    <anchor 600 500> mark @MC_top;
+                        } mark2liga;
+
+                    } mark;
+
+                    feature mkmk {
+                        lookup mark2mark_bottom {
+                            @MFS_mark2mark_bottom = [cedillacomb];
+                            lookupflag UseMarkFilteringSet @MFS_mark2mark_bottom;
+                            pos mark cedillacomb
+                                <anchor 20 -309> mark @MC_bottom;
+                        } mark2mark_bottom;
+
+                        lookup mark2mark_top {
+                            @MFS_mark2mark_top = [acutecomb tildecomb];
+                            lookupflag UseMarkFilteringSet @MFS_mark2mark_top;
+                            pos mark tildecomb
+                                <anchor 100 300> mark @MC_top;
+                        } mark2mark_top;
+
+                    } mkmk;
+                    """  # noqa: B950
+                ),
+            ),
+            (
+                False,
+                dedent(
+                    """\
+                    markClass barcomb <anchor 100 40> @MC_bar;
+                    markClass cedillacomb <anchor 10 -5> @MC_bottom;
+                    markClass bazcomb <anchor 90 320> @MC_bottom;
+                    markClass acutecomb <anchor 100 200> @MC_top;
+                    markClass tildecomb <anchor 100 200> @MC_top;
+                    markClass foocomb <anchor 100 40> @MC_top;
+
+                    feature abvm {
+                        lookup abvm_mark2liga {
+                            pos ligature foo_bar_baz
+                                    <anchor NULL>
+                                ligComponent
+                                    <anchor NULL>
+                                ligComponent
+                                    <anchor 1100 499> mark @MC_bar;
+                        } abvm_mark2liga;
+
+                        lookup abvm_mark2liga_1 {
+                            pos ligature foo_bar_baz
+                                    <anchor 100 500> mark @MC_top
+                                ligComponent
+                                    <anchor NULL>
+                                ligComponent
+                                    <anchor 1000 500> mark @MC_top;
+                            pos ligature bar_foo
+                                    <anchor NULL>
+                                ligComponent
+                                    <anchor 600 501> mark @MC_top;
+                        } abvm_mark2liga_1;
+
+                        lookup abvm_mark2mark_bar {
+                            @MFS_abvm_mark2mark_bar = [barcomb];
+                            lookupflag UseMarkFilteringSet @MFS_abvm_mark2mark_bar;
+                            pos mark barcomb
+                                <anchor 100 440> mark @MC_bar;
+                        } abvm_mark2mark_bar;
+
+                        lookup abvm_mark2mark_top {
+                            @MFS_abvm_mark2mark_top = [foocomb];
+                            lookupflag UseMarkFilteringSet @MFS_abvm_mark2mark_top;
+                            pos mark foocomb
+                                <anchor 100 190> mark @MC_top;
+                        } abvm_mark2mark_top;
+
+                    } abvm;
+
+                    feature blwm {
+                        lookup blwm_mark2liga {
+                            pos ligature foo_bar_baz
+                                    <anchor 100 10> mark @MC_bottom
+                                ligComponent
+                                    <anchor NULL>
+                                ligComponent
+                                    <anchor NULL>;
+                        } blwm_mark2liga;
+
+                        lookup blwm_mark2mark_bottom {
+                            @MFS_blwm_mark2mark_bottom = [bazcomb];
+                            lookupflag UseMarkFilteringSet @MFS_blwm_mark2mark_bottom;
+                            pos mark bazcomb
+                                <anchor 100 -34> mark @MC_bottom;
+                        } blwm_mark2mark_bottom;
+
+                    } blwm;
+
+                    feature mark {
+                        lookup mark2base {
+                            pos base dottedCircle
+                                <anchor 491 458> mark @MC_bar;
+                        } mark2base;
+
+                        lookup mark2base_1 {
+                            pos base c
+                                <anchor 240 0> mark @MC_bottom;
+                            pos base dottedCircle
+                                <anchor 297 0> mark @MC_bottom;
+                        } mark2base_1;
+
+                        lookup mark2base_2 {
+                            pos base a
+                                <anchor 100 200> mark @MC_top;
+                            pos base dottedCircle
+                                <anchor 297 552> mark @MC_top;
+                        } mark2base_2;
+
+                        lookup mark2liga {
+                            pos ligature f_i
+                                    <anchor 100 500> mark @MC_top
+                                ligComponent
+                                    <anchor 600 500> mark @MC_top;
+                        } mark2liga;
+
+                    } mark;
+
+                    feature mkmk {
+                        lookup mark2mark_bottom {
+                            @MFS_mark2mark_bottom = [cedillacomb];
+                            lookupflag UseMarkFilteringSet @MFS_mark2mark_bottom;
+                            pos mark cedillacomb
+                                <anchor 20 -309> mark @MC_bottom;
+                        } mark2mark_bottom;
+
+                        lookup mark2mark_top {
+                            @MFS_mark2mark_top = [acutecomb tildecomb];
+                            lookupflag UseMarkFilteringSet @MFS_mark2mark_top;
+                            pos mark tildecomb
+                                <anchor 100 300> mark @MC_top;
+                        } mark2mark_top;
+
+                    } mkmk;
+                    """  # noqa: B950
+                ),
+            ),
+        ],
+    )
+    def test_all_features(self, testufo, groupMarkClasses, expected):
         ufo = testufo
         ufo.info.unitsPerEm = 1000
 
@@ -983,106 +1266,9 @@ class MarkFeatureWriterTest(FeatureWriterTest):
             languagesystem taml dflt;
             """
         )
-        generated = self.writeFeatures(testufo)
-
-        assert str(generated) == dedent(
-            """\
-            markClass barcomb <anchor 100 40> @MC_bar;
-            markClass cedillacomb <anchor 10 -5> @MC_bottom;
-            markClass bazcomb <anchor 90 320> @MC_bottom;
-            markClass acutecomb <anchor 100 200> @MC_top;
-            markClass tildecomb <anchor 100 200> @MC_top;
-            markClass foocomb <anchor 100 40> @MC_top;
-
-            feature abvm {
-                lookup abvm_mark2liga {
-                    pos ligature foo_bar_baz
-                            <anchor 100 500> mark @MC_top
-                        ligComponent
-                            <anchor NULL>
-                        ligComponent
-                            <anchor 1100 499> mark @MC_bar
-                            <anchor 1000 500> mark @MC_top;
-                    pos ligature bar_foo
-                            <anchor NULL>
-                        ligComponent
-                            <anchor 600 501> mark @MC_top;
-                } abvm_mark2liga;
-
-                lookup abvm_mark2mark_bar {
-                    @MFS_abvm_mark2mark_bar = [barcomb];
-                    lookupflag UseMarkFilteringSet @MFS_abvm_mark2mark_bar;
-                    pos mark barcomb
-                        <anchor 100 440> mark @MC_bar;
-                } abvm_mark2mark_bar;
-
-                lookup abvm_mark2mark_top {
-                    @MFS_abvm_mark2mark_top = [foocomb];
-                    lookupflag UseMarkFilteringSet @MFS_abvm_mark2mark_top;
-                    pos mark foocomb
-                        <anchor 100 190> mark @MC_top;
-                } abvm_mark2mark_top;
-
-            } abvm;
-
-            feature blwm {
-                lookup blwm_mark2liga {
-                    pos ligature foo_bar_baz
-                            <anchor 100 10> mark @MC_bottom
-                        ligComponent
-                            <anchor NULL>
-                        ligComponent
-                            <anchor NULL>;
-                } blwm_mark2liga;
-
-                lookup blwm_mark2mark_bottom {
-                    @MFS_blwm_mark2mark_bottom = [bazcomb];
-                    lookupflag UseMarkFilteringSet @MFS_blwm_mark2mark_bottom;
-                    pos mark bazcomb
-                        <anchor 100 -34> mark @MC_bottom;
-                } blwm_mark2mark_bottom;
-
-            } blwm;
-
-            feature mark {
-                lookup mark2base {
-                    pos base a
-                        <anchor 100 200> mark @MC_top;
-                    pos base c
-                        <anchor 240 0> mark @MC_bottom;
-                    pos base dottedCircle
-                        <anchor 491 458> mark @MC_bar
-                        <anchor 297 0> mark @MC_bottom
-                        <anchor 297 552> mark @MC_top;
-                } mark2base;
-
-                lookup mark2liga {
-                    pos ligature f_i
-                            <anchor 100 500> mark @MC_top
-                        ligComponent
-                            <anchor 600 500> mark @MC_top;
-                } mark2liga;
-
-            } mark;
-
-            feature mkmk {
-                lookup mark2mark_bottom {
-                    @MFS_mark2mark_bottom = [cedillacomb];
-                    lookupflag UseMarkFilteringSet @MFS_mark2mark_bottom;
-                    pos mark cedillacomb
-                        <anchor 20 -309> mark @MC_bottom;
-                } mark2mark_bottom;
-
-                lookup mark2mark_top {
-                    @MFS_mark2mark_top = [acutecomb tildecomb];
-                    lookupflag UseMarkFilteringSet @MFS_mark2mark_top;
-                    pos mark tildecomb
-                        <anchor 100 300> mark @MC_top;
-                } mark2mark_top;
+        generated = self.writeFeatures(testufo, groupMarkClasses=groupMarkClasses)
 
-            } mkmk;
-            """  # noqa: B950
-        )
+        assert str(generated) == expected
 
     def test_mark_mkmk_features_with_GDEF(self, testufo):
         D = testufo.newGlyph("D")
@@ -1275,7 +1461,86 @@ class MarkFeatureWriterTest(FeatureWriterTest):
             """
         )
 
-    def test_multiple_anchor_classes_liga(self, FontClass):
+    @pytest.mark.parametrize(
+        "groupMarkClasses, expected",
+        [
+            (
+                True,
+                # 'MC_top' should be last thanks to the anchorSortKey. Arguably
+                # this is wrong and might not match current Glyphs.app's behavior
+                # but we decided to keep for backward compatibility with existing
+                # projects.
+                dedent(
+                    """\
+                    markClass acutecomb <anchor 100 200> @MC_top;
+                    markClass acutecomb <anchor 150 250> @MC_topOther;
+
+                    feature mark {
+                        lookup mark2liga {
+                            pos ligature f_f
+                                    <anchor 101 501> mark @MC_topOther
+                                ligComponent
+                                    <anchor 601 501> mark @MC_topOther;
+                            pos ligature f_l
+                                    <anchor NULL>
+                                ligComponent
+                                    <anchor 602 502> mark @MC_topOther;
+                        } mark2liga;
+
+                        lookup mark2liga_1 {
+                            pos ligature f_i
+                                    <anchor 100 500> mark @MC_top
+                                ligComponent
+                                    <anchor 600 500> mark @MC_top;
+                            pos ligature f_l
+                                    <anchor 102 502> mark @MC_top
+                                ligComponent
+                                    <anchor NULL>;
+                        } mark2liga_1;
+
+                    } mark;
+                    """
+                ),
+            ),
+            (
+                False,
+                # with groupMarkClasses=False, lookups are simply sorted by mark
+                # class name alphabetically so 'MC_topOther' is last and wins
+                dedent(
+                    """\
+                    markClass acutecomb <anchor 100 200> @MC_top;
+                    markClass acutecomb <anchor 150 250> @MC_topOther;
+
+                    feature mark {
+                        lookup mark2liga {
+                            pos ligature f_i
+                                    <anchor 100 500> mark @MC_top
+                                ligComponent
+                                    <anchor 600 500> mark @MC_top;
+                            pos ligature f_l
+                                    <anchor 102 502> mark @MC_top
+                                ligComponent
+                                    <anchor NULL>;
+                        } mark2liga;
+
+                        lookup mark2liga_1 {
+                            pos ligature f_f
+                                    <anchor 101 501> mark @MC_topOther
+                                ligComponent
+                                    <anchor 601 501> mark @MC_topOther;
+                            pos ligature f_l
+                                    <anchor NULL>
+                                ligComponent
+                                    <anchor 602 502> mark @MC_topOther;
+                        } mark2liga_1;
+
+                    } mark;
+                    """
+                ),
+            ),
+        ],
+    )
+    def test_multiple_anchor_classes_liga(self, FontClass, groupMarkClasses, expected):
         ufo = FontClass()
         liga = ufo.newGlyph("f_i")
         liga.appendAnchor({"name": "top_1", "x": 100, "y": 500})
@@ -1290,42 +1555,66 @@ class MarkFeatureWriterTest(FeatureWriterTest):
         acutecomb.appendAnchor({"name": "_top", "x": 100, "y": 200})
         acutecomb.appendAnchor({"name": "_topOther", "x": 150, "y": 250})
 
-        generated = self.writeFeatures(ufo)
-
-        # MC_top should be last thanks to the anchorSortKey
-        assert str(generated) == dedent(
-            """\
-            markClass acutecomb <anchor 100 200> @MC_top;
-            markClass acutecomb <anchor 150 250> @MC_topOther;
-
-            feature mark {
-                lookup mark2liga {
-                    pos ligature f_f
-                            <anchor 101 501> mark @MC_topOther
-                        ligComponent
-                            <anchor 601 501> mark @MC_topOther;
-                    pos ligature f_l
-                            <anchor NULL>
-                        ligComponent
-                            <anchor 602 502> mark @MC_topOther;
-                } mark2liga;
-
-                lookup mark2liga_1 {
-                    pos ligature f_i
-                            <anchor 100 500> mark @MC_top
-                        ligComponent
-                            <anchor 600 500> mark @MC_top;
-                    pos ligature f_l
-                            <anchor 102 502> mark @MC_top
-                        ligComponent
-                            <anchor NULL>;
-                } mark2liga_1;
+        generated = self.writeFeatures(ufo, groupMarkClasses=groupMarkClasses)
 
-            } mark;
-            """
-        )
+        assert str(generated) == expected
 
-    def test_multiple_anchor_classes_conflict_warning(self, FontClass, caplog):
+    @pytest.mark.parametrize(
+        "groupMarkClasses, warning, expected",
+        [
+            (
+                True,
+                # 'MC_top' should be last thanks to the anchorSortKey
+                "MC_topOther, MC_top",
+                dedent(
+                    """\
+                    markClass acutecomb <anchor 100 200> @MC_top;
+                    markClass acutecomb <anchor 150 250> @MC_topOther;
+
+                    feature mark {
+                        lookup mark2base {
+                            pos base a
+                                <anchor 150 550> mark @MC_topOther;
+                        } mark2base;
+
+                        lookup mark2base_1 {
+                            pos base a
+                                <anchor 100 500> mark @MC_top;
+                        } mark2base_1;
+
+                    } mark;
+                    """
+                ),
+            ),
+            (
+                False,
+                # 'MC_topOther' (sorted alphabetically) should be last
+                "MC_top, MC_topOther",
+                dedent(
+                    """\
+                    markClass acutecomb <anchor 100 200> @MC_top;
+                    markClass acutecomb <anchor 150 250> @MC_topOther;
+
+                    feature mark {
+                        lookup mark2base {
+                            pos base a
+                                <anchor 100 500> mark @MC_top;
+                        } mark2base;
+
+                        lookup mark2base_1 {
+                            pos base a
+                                <anchor 150 550> mark @MC_topOther;
+                        } mark2base_1;
+
+                    } mark;
+                    """
+                ),
+            ),
+        ],
+    )
+    def test_multiple_anchor_classes_conflict_warning(
+        self, FontClass, caplog, groupMarkClasses, warning, expected
+    ):
         """Check that when there is an ambiguity in the form of one base glyph
         and one mark glyph being able to be linked through two different
         anchor pairs, the mark feature writer emits a warning about the
@@ -1344,34 +1633,15 @@ class MarkFeatureWriterTest(FeatureWriterTest):
         acutecomb.appendAnchor({"name": "_top", "x": 100, "y": 200})
         acutecomb.appendAnchor({"name": "_topOther", "x": 150, "y": 250})
 
-        generated = self.writeFeatures(ufo)
+        generated = self.writeFeatures(ufo, groupMarkClasses=groupMarkClasses)
 
         assert (
             "The base glyph a and mark glyph acutecomb are ambiguously "
-            "connected by several anchor classes: MC_topOther, MC_top. "
+            f"connected by several anchor classes: {warning}. "
             "The last one will prevail." in caplog.text
         )
 
-        # MC_top should be last thanks to the anchorSortKey
-        assert str(generated) == dedent(
-            """\
-            markClass acutecomb <anchor 100 200> @MC_top;
-            markClass acutecomb <anchor 150 250> @MC_topOther;
-
-            feature mark {
-                lookup mark2base {
-                    pos base a
-                        <anchor 150 550> mark @MC_topOther;
-                } mark2base;
-
-                lookup mark2base_1 {
-                    pos base a
-                        <anchor 100 500> mark @MC_top;
-                } mark2base_1;
-
-            } mark;
-            """
-        )
+        assert str(generated) == expected
 
     def test_skipExportGlyphs(self, testufo):
         testufo.lib["public.skipExportGlyphs"] = ["f_i", "tildecomb"]