Repository: googlefonts/ufo2ft (python)

Issue:
Write GDEF feature writer
Storing GDEF information in features.fea is possible and done today but is also very much annoying. One, it needs separate maintenance and two, it instance interpolation is not made to interpolate feature files, so ligature caret positions are taken from the default source.

The UFO spec now allows to generate a complete GDEF at compile time:

1. LigatureCaretByPos can be generated from https://github.com/unified-font-object/ufo-spec/blob/gh-pages/versions/ufo3/glyphs/glif.md#ligature-carets (existing issue https://github.com/googlefonts/ufo2ft/issues/329)
2. GlyphClassDef can be generated from https://unifiedfontobject.org/versions/ufo3/glyphs/glif/#publicopentypecategory

So, _someone_ needs to sit down and write a GDEF writer for ufo2ft. Since it runs after all (anchor propagation) filters, we might not even need to store any categories, or only when you want to override something somewhere. GDEF generation is already done by code in glyphsLib, so that can be used as a start.

Pull request description:
Add GDEF feature writer
Fixes #456.
This adds a GDEF feature writer to the default feature writers (kern feature writer, mark feature writer).


The GdefFeatureWriter first compiles the GDEF from the feature code (feaLib uses the GDEF table definition or builds it from lookups) and uses it to get Glyph Class Definitions (bases, ligatures, marks, components) and Ligature Carets.
It then updates the Glyph Class Definitions with the UFO lib "public.openTypeCategories" data, except for glyphs already defined, and updates the Ligature Carets with the UFO anchors with names starting with "caret_" and "vcaret_", except for glyphs with ligature carets already defined.

Patch:
diff --git a/Lib/ufo2ft/constants.py b/Lib/ufo2ft/constants.py
index 1bf3161..6d84ff7 100644
--- a/Lib/ufo2ft/constants.py
+++ b/Lib/ufo2ft/constants.py
@@ -19,3 +19,5 @@ KEEP_GLYPH_NAMES = UFO2FT_PREFIX + "keepGlyphNames"
 COLOR_LAYERS_KEY = UFO2FT_PREFIX + "colorLayers"
 COLOR_PALETTES_KEY = UFO2FT_PREFIX + "colorPalettes"
 COLOR_LAYER_MAPPING_KEY = UFO2FT_PREFIX + "colorLayerMapping"
+
+OPENTYPE_CATEGORIES_KEY = "public.openTypeCategories"
diff --git a/Lib/ufo2ft/featureCompiler.py b/Lib/ufo2ft/featureCompiler.py
index e85b910..0626fd9 100644
--- a/Lib/ufo2ft/featureCompiler.py
+++ b/Lib/ufo2ft/featureCompiler.py
@@ -12,6 +12,7 @@ from fontTools.feaLib.parser import Parser
 
 from ufo2ft.constants import MTI_FEATURES_PREFIX
 from ufo2ft.featureWriters import (
+    GdefFeatureWriter,
     KernFeatureWriter,
     MarkFeatureWriter,
     ast,
@@ -147,7 +148,7 @@ class FeatureCompiler(BaseFeatureCompiler):
     Feature File stored in the UFO, using fontTools.feaLib as compiler.
     """
 
-    defaultFeatureWriters = [KernFeatureWriter, MarkFeatureWriter]
+    defaultFeatureWriters = [KernFeatureWriter, MarkFeatureWriter, GdefFeatureWriter]
 
     def __init__(self, ufo, ttFont=None, glyphSet=None, featureWriters=None, **kwargs):
         """
diff --git a/Lib/ufo2ft/featureWriters/__init__.py b/Lib/ufo2ft/featureWriters/__init__.py
index 494d76b..1fc64b0 100644
--- a/Lib/ufo2ft/featureWriters/__init__.py
+++ b/Lib/ufo2ft/featureWriters/__init__.py
@@ -6,11 +6,13 @@ from ufo2ft.constants import FEATURE_WRITERS_KEY
 from ufo2ft.util import _loadPluginFromString
 
 from .baseFeatureWriter import BaseFeatureWriter
+from .gdefFeatureWriter import GdefFeatureWriter
 from .kernFeatureWriter import KernFeatureWriter
 from .markFeatureWriter import MarkFeatureWriter
 
 __all__ = [
     "BaseFeatureWriter",
+    "GdefFeatureWriter",
     "KernFeatureWriter",
     "MarkFeatureWriter",
     "loadFeatureWriters",
diff --git a/Lib/ufo2ft/featureWriters/ast.py b/Lib/ufo2ft/featureWriters/ast.py
index 09644e7..c697998 100644
--- a/Lib/ufo2ft/featureWriters/ast.py
+++ b/Lib/ufo2ft/featureWriters/ast.py
@@ -64,6 +64,12 @@ def findCommentPattern(feaFile, pattern):
                 yield (statement,)
 
 
+def findTable(feaLib, tag):
+    for statement in feaLib.statements:
+        if isinstance(statement, ast.TableBlock) and statement.name == tag:
+            return statement
+
+
 def iterClassDefinitions(feaFile, featureTag=None):
     if featureTag is None:
         # start from top-level class definitions
diff --git a/Lib/ufo2ft/featureWriters/baseFeatureWriter.py b/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
index b1a37a9..a7148e5 100644
--- a/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
@@ -1,7 +1,8 @@
 import logging
-from collections import OrderedDict
+from collections import OrderedDict, namedtuple
 from types import SimpleNamespace
 
+from ufo2ft.constants import OPENTYPE_CATEGORIES_KEY
 from ufo2ft.errors import InvalidFeaturesData
 from ufo2ft.featureWriters import ast
 
@@ -84,13 +85,15 @@ class BaseFeatureWriter:
         todo = set(self.features)
         insertComments = None
         if self.mode == "skip":
-            insertComments = self.collectInsertMarkers(
-                feaFile, self.insertFeatureMarker, todo
-            )
+            if self.insertFeatureMarker is not None:
+                insertComments = self.collectInsertMarkers(
+                    feaFile, self.insertFeatureMarker, todo
+                )
             # find existing feature blocks
             existing = ast.findFeatureTags(feaFile)
             # ignore features with insert marker
-            existing.difference_update(insertComments.keys())
+            if insertComments:
+                existing.difference_update(insertComments.keys())
             # remove existing feature without insert marker from todo list
             todo.difference_update(existing)
 
@@ -306,3 +309,66 @@ class BaseFeatureWriter:
         if compiler and not hasattr(compiler, "_gsub"):
             compiler._gsub = gsub
         return gsub
+
+    def getOpenTypeCategories(self):
+        """Return 'public.openTypeCategories' values as a tuple of sets of
+        unassigned, bases, ligatures, marks, components."""
+        font = self.context.font
+        unassigned, bases, ligatures, marks, components = (
+            set(),
+            set(),
+            set(),
+            set(),
+            set(),
+        )
+        openTypeCategories = font.lib.get(OPENTYPE_CATEGORIES_KEY, {})
+
+        for glyphName, category in openTypeCategories.items():
+            if category == "unassigned":
+                unassigned.add(glyphName)
+            elif category == "base":
+                bases.add(glyphName)
+            elif category == "ligature":
+                ligatures.add(glyphName)
+            elif category == "mark":
+                marks.add(glyphName)
+            elif category == "component":
+                components.add(glyphName)
+            else:
+                self.log.warning(
+                    f"The '{OPENTYPE_CATEGORIES_KEY}' value of {glyphName} in "
+                    f"{font.info.familyName} {font.info.styleName} is '{category}' "
+                    "when it should be 'unassigned', 'base', 'ligature', 'mark' "
+                    "or 'component'."
+                )
+        return namedtuple(
+            "OpenTypeCategories", "unassigned base ligature mark component"
+        )(
+            frozenset(unassigned),
+            frozenset(bases),
+            frozenset(ligatures),
+            frozenset(marks),
+            frozenset(components),
+        )
+
+    def getGDEFGlyphClasses(self):
+        """Return a tuple of GDEF GlyphClassDef base, ligature, mark, component
+        glyph names.
+        Sets are `None` if no 'public.openTypeCategories' values are defined or
+        if no GDEF table is defined in the feature file.
+        """
+        feaFile = self.context.feaFile
+
+        if ast.findTable(feaFile, "GDEF") is not None:
+            return ast.getGDEFGlyphClasses(feaFile)
+
+        unassigned, bases, ligatures, marks, components = self.getOpenTypeCategories()
+
+        if not any((unassigned, bases, ligatures, marks, components)):
+            return ast._GDEFGlyphClasses(None, None, None, None)
+        return ast._GDEFGlyphClasses(
+            frozenset(bases),
+            frozenset(ligatures),
+            frozenset(marks),
+            frozenset(components),
+        )
diff --git a/Lib/ufo2ft/featureWriters/gdefFeatureWriter.py b/Lib/ufo2ft/featureWriters/gdefFeatureWriter.py
new file mode 100644
index 0000000..1452759
--- /dev/null
+++ b/Lib/ufo2ft/featureWriters/gdefFeatureWriter.py
@@ -0,0 +1,98 @@
+from ufo2ft.featureWriters import BaseFeatureWriter, ast
+
+
+class GdefFeatureWriter(BaseFeatureWriter):
+    """Generates a GDEF table based on OpenType Category and glyph anchors.
+
+    It skips generating the GDEF if a GDEF is defined in the features.
+
+    It uses the 'public.openTypeCategories' values to create the GDEF ClassDefs
+    and the ligature caret anchors to create the GDEF ligature carets.
+
+    """
+
+    tableTag = "GDEF"
+    features = frozenset(["GlyphClassDefs", "LigatureCarets"])
+    insertFeatureMarker = None
+
+    def setContext(self, font, feaFile, compiler=None):
+        ctx = super().setContext(font, feaFile, compiler=compiler)
+
+        ctx.gdefTableBlock = ast.findTable(self.context.feaFile, "GDEF")
+        if ctx.gdefTableBlock:
+            for fea in ctx.gdefTableBlock.statements:
+                if isinstance(fea, ast.GlyphClassDefStatement):
+                    ctx.todo.discard("GlyphClassDefs")
+                elif isinstance(fea, ast.LigatureCaretByIndexStatement) or isinstance(
+                    fea, ast.LigatureCaretByPosStatement
+                ):
+                    ctx.todo.discard("LigatureCarets")
+
+                if not ctx.todo:
+                    break
+
+        ctx.orderedGlyphSet = self.getOrderedGlyphSet()
+
+        if "GlyphClassDefs" in ctx.todo:
+            ctx.openTypeCategories = self.getOpenTypeCategories()
+            if not any(ctx.openTypeCategories):
+                ctx.todo.remove("GlyphClassDefs")
+        if "LigatureCarets" in ctx.todo:
+            ctx.ligatureCarets = self._getLigatureCarets()
+            if not ctx.ligatureCarets:
+                ctx.todo.remove("LigatureCarets")
+
+        return ctx
+
+    def _getLigatureCarets(self):
+        carets = dict()
+
+        for glyphName, glyph in self.context.orderedGlyphSet.items():
+            glyphCarets = set()
+            for anchor in glyph.anchors:
+                if (
+                    anchor.name
+                    and anchor.name.startswith("caret_")
+                    and anchor.x is not None
+                ):
+                    glyphCarets.add(round(anchor.x))
+                elif (
+                    anchor.name
+                    and anchor.name.startswith("vcaret_")
+                    and anchor.y is not None
+                ):
+                    glyphCarets.add(round(anchor.y))
+
+            if glyphCarets:
+                carets[glyphName] = sorted(glyphCarets)
+
+        return carets
+
+    def _sortedGlyphClass(self, glyphNames):
+        return sorted(n for n in self.context.orderedGlyphSet if n in glyphNames)
+
+    def _write(self):
+        feaFile = self.context.feaFile
+        gdefTableBlock = self.context.gdefTableBlock
+        if not gdefTableBlock:
+            gdefTableBlock = ast.TableBlock("GDEF")
+            feaFile.statements.append(gdefTableBlock)
+
+        if "GlyphClassDefs" in self.context.todo:
+            categories = self.context.openTypeCategories
+            glyphClassDefs = ast.GlyphClassDefStatement(
+                ast.GlyphClass(self._sortedGlyphClass(categories.base)),
+                ast.GlyphClass(self._sortedGlyphClass(categories.mark)),
+                ast.GlyphClass(self._sortedGlyphClass(categories.ligature)),
+                ast.GlyphClass(self._sortedGlyphClass(categories.component)),
+            )
+            gdefTableBlock.statements.append(glyphClassDefs)
+
+        if "LigatureCarets" in self.context.todo:
+            ligatureCarets = [
+                ast.LigatureCaretByPosStatement(ast.GlyphName(glyphName), carets)
+                for glyphName, carets in self.context.ligatureCarets.items()
+            ]
+            gdefTableBlock.statements.extend(ligatureCarets)
+
+        return True
diff --git a/Lib/ufo2ft/featureWriters/kernFeatureWriter.py b/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
index fbd9513..a7de7ac 100644
--- a/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
@@ -203,7 +203,7 @@ class KernFeatureWriter(BaseFeatureWriter):
 
     def setContext(self, font, feaFile, compiler=None):
         ctx = super().setContext(font, feaFile, compiler=compiler)
-        ctx.gdefClasses = ast.getGDEFGlyphClasses(feaFile)
+        ctx.gdefClasses = self.getGDEFGlyphClasses()
         ctx.kerning = self.getKerningData(font, feaFile, self.getOrderedGlyphSet())
 
         feaScripts = ast.getScriptLanguageSystems(feaFile)
diff --git a/Lib/ufo2ft/featureWriters/markFeatureWriter.py b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
index 0db8f0f..0ed9bf1 100644
--- a/Lib/ufo2ft/featureWriters/markFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
@@ -296,7 +296,7 @@ class MarkFeatureWriter(BaseFeatureWriter):
 
     def setContext(self, font, feaFile, compiler=None):
         ctx = super().setContext(font, feaFile, compiler=compiler)
-        ctx.gdefClasses = ast.getGDEFGlyphClasses(feaFile)
+        ctx.gdefClasses = self.getGDEFGlyphClasses()
         ctx.anchorLists = self._getAnchorLists()
         ctx.anchorPairs = self._getAnchorPairs()
 
diff --git a/Lib/ufo2ft/util.py b/Lib/ufo2ft/util.py
index 210852f..55981e1 100644
--- a/Lib/ufo2ft/util.py
+++ b/Lib/ufo2ft/util.py
@@ -228,6 +228,26 @@ def compileGSUB(featureFile, glyphOrder):
     return font.get("GSUB")
 
 
+def compileGDEF(featureFile, glyphOrder):
+    """Compile and return a GDEF table from `featureFile` (feaLib FeatureFile),
+    using the given `glyphOrder` (list of glyph names).
+    """
+    from fontTools.feaLib.ast import TableBlock
+
+    font = ttLib.TTFont()
+    font.setGlyphOrder(glyphOrder)
+    gdefDefined = False
+    for statement in featureFile.statements:
+        if isinstance(statement, TableBlock) and statement.name == "GDEF":
+            gdefDefined = True
+
+    if not gdefDefined:
+        addOpenTypeFeatures(font, featureFile, tables={"GDEF", "GPOS", "GSUB"})
+    else:
+        addOpenTypeFeatures(font, featureFile, tables={"GDEF"})
+    return font.get("GDEF")
+
+
 def closeGlyphsOverGSUB(gsub, glyphs):
     """Use the FontTools subsetter to perform a closure over the GSUB table
     given the initial `glyphs` (set of glyph names, str). Update the set


Test patch:
diff --git a/tests/featureWriters/gdefFeatureWriter_test.py b/tests/featureWriters/gdefFeatureWriter_test.py
new file mode 100644
index 0000000..e793c2d
--- /dev/null
+++ b/tests/featureWriters/gdefFeatureWriter_test.py
@@ -0,0 +1,224 @@
+import logging
+from textwrap import dedent
+
+import pytest
+
+from ufo2ft.featureCompiler import parseLayoutFeatures
+from ufo2ft.featureWriters import GdefFeatureWriter
+
+from . import FeatureWriterTest
+
+
+@pytest.fixture
+def testufo(FontClass):
+    ufo = FontClass()
+    ufo.newGlyph("a")
+    ufo.newGlyph("f")
+    ufo.newGlyph("f.component")
+    ufo.newGlyph("i")
+    liga = ufo.newGlyph("f_f_i")
+    liga.appendAnchor({"name": "caret_2", "x": 400, "y": 0})
+    liga.appendAnchor({"name": "caret_1", "x": 200, "y": 0})
+    liga = ufo.newGlyph("f_i")
+    liga.appendAnchor({"name": "caret_", "x": 200, "y": 0})
+    ufo.newGlyph("acutecomb")
+    ufo.newGlyph("tildecomb")
+
+    ufo.lib["public.glyphOrder"] = [
+        "a",
+        "f",
+        "f.component",
+        "i",
+        "f_f_i",
+        "f_i",
+        "acutecomb",
+        "tildecomb",
+    ]
+    return ufo
+
+
+class GdefFeatureWriterTest(FeatureWriterTest):
+
+    FeatureWriter = GdefFeatureWriter
+
+    @classmethod
+    def writeGDEF(cls, ufo, **kwargs):
+        writer = cls.FeatureWriter(**kwargs)
+        feaFile = parseLayoutFeatures(ufo)
+        if writer.write(ufo, feaFile):
+            return feaFile
+
+    def test_no_GDEF_no_openTypeCategories_in_font(self, testufo):
+        newFea = self.writeGDEF(testufo)
+        assert str(newFea) == dedent(
+            """\
+            table GDEF {
+                LigatureCaretByPos f_f_i 200 400;
+                LigatureCaretByPos f_i 200;
+            } GDEF;
+            """
+        )
+
+    def test_GDEF_in_font(self, testufo):
+        testufo.features.text = dedent(
+            """\
+            table GDEF {
+                GlyphClassDef [a], [], [acutecomb], [];
+                LigatureCaretByPos f_i 300;
+            } GDEF;
+            """
+        )
+        assert self.writeGDEF(testufo) is None
+
+    def test_openTypeCategories_in_font(self, testufo):
+        testufo.lib["public.openTypeCategories"] = {
+            "a": "base",
+            "f.component": "component",
+            "f_i": "ligature",
+            "acutecomb": "mark",
+        }
+        newFea = self.writeGDEF(testufo)
+        assert str(newFea) == dedent(
+            """\
+            table GDEF {
+                GlyphClassDef [a], [f_i], [acutecomb], [f.component];
+                LigatureCaretByPos f_f_i 200 400;
+                LigatureCaretByPos f_i 200;
+            } GDEF;
+            """
+        )
+
+    def test_GDEF_and_openTypeCategories_in_font(self, testufo):
+        testufo.lib["public.openTypeCategories"] = {
+            "a": "base",
+            "f.component": "component",
+            "f_i": "ligature",
+            "acutecomb": "mark",
+        }
+        testufo.features.text = dedent(
+            """\
+            table GDEF {
+                 GlyphClassDef [i], [], [tildecomb], [];
+                 LigatureCaretByPos f_i 100;
+             } GDEF;
+             """
+        )
+        assert self.writeGDEF(testufo) is None
+
+    def test_GDEF_LigatureCarets_and_openTypeCategories_in_font(self, testufo):
+        testufo.lib["public.openTypeCategories"] = {
+            "a": "base",
+            "f.component": "component",
+            "f_i": "ligature",
+            "acutecomb": "mark",
+        }
+        testufo.features.text = dedent(
+            """\
+            table GDEF {
+                LigatureCaretByPos f_i 100;
+            } GDEF;
+             """
+        )
+        newFea = self.writeGDEF(testufo)
+        assert str(newFea) == dedent(
+            """\
+            table GDEF {
+                LigatureCaretByPos f_i 100;
+                GlyphClassDef [a], [f_i], [acutecomb], [f.component];
+            } GDEF;
+            """
+        )
+
+    def test_GDEF_GlyphClassDef_and_carets_in_font(self, testufo):
+        testufo.lib["public.openTypeCategories"] = {
+            "a": "base",
+            "f.component": "component",
+            "f_i": "ligature",
+            "acutecomb": "mark",
+        }
+        testufo.features.text = dedent(
+            """\
+            table GDEF {
+                GlyphClassDef [], [], [acutecomb tildecomb], [];
+            } GDEF;
+             """
+        )
+        newFea = self.writeGDEF(testufo)
+        assert str(newFea) == dedent(
+            """\
+            table GDEF {
+                GlyphClassDef [], [], [acutecomb tildecomb], [];
+                LigatureCaretByPos f_f_i 200 400;
+                LigatureCaretByPos f_i 200;
+            } GDEF;
+            """
+        )
+
+    def test_mark_and_openTypeCategories_in_font(self, testufo):
+        testufo.lib["public.openTypeCategories"] = {
+            "a": "base",
+            "f.component": "component",
+            "f_f_i": "base",
+            "f_i": "ligature",
+            "acutecomb": "mark",
+            "tildecomb": "component",
+        }
+        testufo.features.text = old = dedent(
+            """\
+            feature mark {
+                markClass tildecomb <anchor 0 500> @TOP_MARKS;
+                pos base a <anchor 250 500> mark @TOP_MARKS;
+                pos base f <anchor 250 500> mark @TOP_MARKS;
+                pos ligature f_f_i <anchor 150 700> mark @TOP_MARKS
+                    ligComponent <anchor 450 700> mark @TOP_MARKS
+                    ligComponent <anchor 600 700> mark @TOP_MARKS;
+            } mark;
+            """
+        )
+        newFea = self.writeGDEF(testufo)
+        assert str(newFea) == old + "\n" + dedent(
+            """\
+            table GDEF {
+                GlyphClassDef [a f_f_i], [f_i], [acutecomb], [f.component tildecomb];
+                LigatureCaretByPos f_f_i 200 400;
+                LigatureCaretByPos f_i 200;
+            } GDEF;
+            """
+        )
+
+    def test_vertical_carets(self, testufo):
+        vliga = testufo.newGlyph("vi_li_ga")
+        vliga.appendAnchor({"name": "vcaret_1", "x": 0, "y": 100})
+        vliga.appendAnchor({"name": "vcaret_2", "x": 0, "y": 200})
+        vliga = testufo.newGlyph("vli_ga")
+        vliga.appendAnchor({"name": "vcaret_", "x": 0, "y": 100})
+
+        newFea = self.writeGDEF(testufo)
+        assert str(newFea) == dedent(
+            """\
+            table GDEF {
+                LigatureCaretByPos f_f_i 200 400;
+                LigatureCaretByPos f_i 200;
+                LigatureCaretByPos vi_li_ga 100 200;
+                LigatureCaretByPos vli_ga 100;
+            } GDEF;
+            """
+        )
+
+    def test_getOpenTypeCategories_invalid(self, testufo, caplog):
+        caplog.set_level(logging.WARNING)
+        testufo.lib["public.openTypeCategories"] = {
+            "a": "base",
+            "f.component": "component",
+            "f_f_i": "base",
+            "f_i": "ligature",
+            "acutecomb": "mark",
+            "tildecomb": "components",
+        }
+        logger = "ufo2ft.featureWriters.gdefFeatureWriter.GdefFeatureWriter"
+        with caplog.at_level(logging.WARNING, logger=logger):
+            self.writeGDEF(testufo)
+
+        assert len(caplog.records) == 1
+        assert "The 'public.openTypeCategories' value of tildecomb in" in caplog.text
+        assert "is 'components' when it should be" in caplog.text
diff --git a/tests/featureWriters/markFeatureWriter_test.py b/tests/featureWriters/markFeatureWriter_test.py
index c977be8..ccc69fa 100644
--- a/tests/featureWriters/markFeatureWriter_test.py
+++ b/tests/featureWriters/markFeatureWriter_test.py
@@ -934,6 +934,83 @@ class MarkFeatureWriterTest(FeatureWriterTest):
             """
         )
 
+    def test_mark_mkmk_features_with_GDEF_and_openTypeCategories(self, testufo):
+        # this glyph has compatible anchors and has an openTypeCategories "base"
+        # value
+        D = testufo.newGlyph("D")
+        D.anchors = [
+            {"name": "top", "x": 300, "y": 700},
+            {"name": "center", "x": 320, "y": 360},
+        ]
+        # these glyphs have compatible anchors but since they not listed in
+        # the GDEF groups, they won't be included in the mark/mkmk feature
+        testufo.newGlyph("Alpha").appendAnchor({"name": "topleft", "x": -10, "y": 400})
+        testufo.newGlyph("psili").appendAnchor({"name": "_topleft", "x": 0, "y": 50})
+        dotaccentcomb = testufo.newGlyph("dotaccentcomb")
+        # this mark glyph has more than one mark anchor, and both will be
+        # generated. Since the two mark anchors cannot cohabit in the same
+        # mark lookup, two lookups will be generated.
+        dotaccentcomb.anchors = [
+            {"name": "_center", "x": 0, "y": 0},
+            {"name": "_top", "x": 0, "y": 0},
+            {"name": "top", "x": 0, "y": 300},
+        ]
+        # will be ignored because in GDEF table below
+        testufo.lib["public.openTypeCategories"] = {
+            "D": "base",
+            "dotaccentcomb": "mark",
+            "tildecomb": "base",
+        }
+        testufo.features.text = dedent(
+            """\
+            @Bases = [a];
+            @Marks = [acutecomb tildecomb];
+            table GDEF {
+                GlyphClassDef @Bases, [f_i], @Marks, ;
+            } GDEF;
+            """
+        )
+        testufo.glyphOrder = [
+            "Alpha",
+            "D",
+            "a",
+            "acutecomb",
+            "dotaccentcomb",
+            "f_i",
+            "psili",
+            "tildecomb",
+        ]
+
+        generated = self.writeFeatures(testufo)
+
+        assert str(generated) == dedent(
+            """\
+            markClass acutecomb <anchor 100 200> @MC_top;
+            markClass tildecomb <anchor 100 200> @MC_top;
+
+            feature mark {
+                lookup mark2base {
+                    pos base a <anchor 100 200> 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_top {
+                    @MFS_mark2mark_top = [acutecomb tildecomb];
+                    lookupflag UseMarkFilteringSet @MFS_mark2mark_top;
+                    pos mark tildecomb <anchor 100 300> mark @MC_top;
+                } mark2mark_top;
+
+            } mkmk;
+            """
+        )
+
     def test_multiple_anchor_classes_base(self, FontClass):
         dirname = os.path.dirname(os.path.dirname(__file__))
         fontPath = os.path.join(dirname, "data", "MultipleAnchorClasses.ufo")