Repository: googlefonts/ufo2ft
Pull request #863 (2024-08-06 12:11:19)

cursFeatureWriter: Support multiple entry/exit anchor pairs
Support dot suffixed entry and exit anchor names, in addition to unsuffixed names. Each anchor pair creates a new lookups. Lookups are sorted by anchor names.


Patch:
diff --git a/Lib/ufo2ft/featureWriters/cursFeatureWriter.py b/Lib/ufo2ft/featureWriters/cursFeatureWriter.py
index 70f4ae4..86ac75c 100644
--- a/Lib/ufo2ft/featureWriters/cursFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/cursFeatureWriter.py
@@ -18,6 +18,25 @@ class CursFeatureWriter(BaseFeatureWriter):
     tableTag = "GPOS"
     features = frozenset(["curs"])
 
+    @staticmethod
+    def _getCursiveAnchorPairs(glyphs):
+        anchors = set()
+        for _, glyph in glyphs:
+            anchors.update(a.name for a in glyph.anchors)
+
+        anchorPairs = []
+        if "entry" in anchors and "exit" in anchors:
+            anchorPairs.append(("entry", "exit"))
+        for anchor in anchors:
+            if anchor.startswith("entry.") and f"exit.{anchor[6:]}" in anchors:
+                anchorPairs.append((anchor, f"exit.{anchor[6:]}"))
+
+        return sorted(anchorPairs)
+
+    @staticmethod
+    def _hasAnchor(glyph, anchorName):
+        return any(a.name == anchorName for a in glyph.anchors)
+
     def _makeCursiveFeature(self):
         cmap = self.makeUnicodeToGlyphNameMapping()
         if any(unicodeScriptDirection(uv) == "LTR" for uv in cmap):
@@ -29,54 +48,64 @@ class CursFeatureWriter(BaseFeatureWriter):
             shouldSplit = False
 
         lookups = []
-        ordereredGlyphSet = self.getOrderedGlyphSet().items()
-        if shouldSplit:
-            # Make LTR lookup
-            LTRlookup = self._makeCursiveLookup(
-                (
-                    glyph
-                    for (glyphName, glyph) in ordereredGlyphSet
-                    if glyphName in dirGlyphs["LTR"]
-                ),
-                direction="LTR",
-            )
-            if LTRlookup:
-                lookups.append(LTRlookup)
-
-            # Make RTL lookup with other glyphs
-            RTLlookup = self._makeCursiveLookup(
-                (
-                    glyph
-                    for (glyphName, glyph) in ordereredGlyphSet
-                    if glyphName not in dirGlyphs["LTR"]
-                ),
-                direction="RTL",
-            )
-            if RTLlookup:
-                lookups.append(RTLlookup)
-        else:
-            lookup = self._makeCursiveLookup(
-                (glyph for (glyphName, glyph) in ordereredGlyphSet)
-            )
-            if lookup:
-                lookups.append(lookup)
+        orderedGlyphSet = self.getOrderedGlyphSet().items()
+        cursiveAnchorsPairs = self._getCursiveAnchorPairs(orderedGlyphSet)
+        for entryName, exitName in cursiveAnchorsPairs:
+            if shouldSplit:
+                # Make LTR lookup
+                LTRlookup = self._makeCursiveLookup(
+                    (
+                        glyph
+                        for (glyphName, glyph) in orderedGlyphSet
+                        if glyphName in dirGlyphs["LTR"]
+                    ),
+                    entryName,
+                    exitName,
+                    direction="LTR",
+                )
+                if LTRlookup:
+                    lookups.append(LTRlookup)
+
+                # Make RTL lookup with other glyphs
+                RTLlookup = self._makeCursiveLookup(
+                    (
+                        glyph
+                        for (glyphName, glyph) in orderedGlyphSet
+                        if glyphName not in dirGlyphs["LTR"]
+                    ),
+                    entryName,
+                    exitName,
+                    direction="RTL",
+                )
+                if RTLlookup:
+                    lookups.append(RTLlookup)
+            else:
+                lookup = self._makeCursiveLookup(
+                    (glyph for (glyphName, glyph) in orderedGlyphSet),
+                    entryName,
+                    exitName,
+                )
+                if lookup:
+                    lookups.append(lookup)
 
         if lookups:
             feature = ast.FeatureBlock("curs")
             feature.statements.extend(lookups)
             return feature
 
-    def _makeCursiveLookup(self, glyphs, direction=None):
-        statements = self._makeCursiveStatements(glyphs)
+    def _makeCursiveLookup(self, glyphs, entryName, exitName, direction=None):
+        statements = self._makeCursiveStatements(glyphs, entryName, exitName)
 
         if not statements:
             return
 
         suffix = ""
+        if entryName != "entry":
+            suffix = f"_{entryName[6:]}"
         if direction == "LTR":
-            suffix = "_ltr"
+            suffix += "_ltr"
         elif direction == "RTL":
-            suffix = "_rtl"
+            suffix += "_rtl"
         lookup = ast.LookupBlock(name=f"curs{suffix}")
 
         if direction != "LTR":
@@ -86,13 +115,15 @@ class CursFeatureWriter(BaseFeatureWriter):
 
         lookup.statements.extend(statements)
 
+        print(str(lookup))
+
         return lookup
 
-    def _getAnchors(self, glyphName, glyph=None):
+    def _getAnchors(self, glyphName, entryName, exitName):
         entryAnchor = None
         exitAnchor = None
-        entryAnchorXY = self._getAnchor(glyphName, "entry")
-        exitAnchorXY = self._getAnchor(glyphName, "exit")
+        entryAnchorXY = self._getAnchor(glyphName, entryName)
+        exitAnchorXY = self._getAnchor(glyphName, exitName)
         if entryAnchorXY:
             entryAnchor = ast.Anchor(
                 x=otRoundIgnoringVariable(entryAnchorXY[0]),
@@ -105,11 +136,11 @@ class CursFeatureWriter(BaseFeatureWriter):
             )
         return entryAnchor, exitAnchor
 
-    def _makeCursiveStatements(self, glyphs):
+    def _makeCursiveStatements(self, glyphs, entryName, exitName):
         cursiveAnchors = dict()
         statements = []
         for glyph in glyphs:
-            entryAnchor, exitAnchor = self._getAnchors(glyph.name, glyph)
+            entryAnchor, exitAnchor = self._getAnchors(glyph.name, entryName, exitName)
             # A glyph can have only one of the cursive anchors (e.g. if it
             # attaches on one side only)
             if entryAnchor or exitAnchor:


Test patch:
diff --git a/tests/featureWriters/cursFeatureWriter_test.py b/tests/featureWriters/cursFeatureWriter_test.py
index 76a3730..157bb78 100644
--- a/tests/featureWriters/cursFeatureWriter_test.py
+++ b/tests/featureWriters/cursFeatureWriter_test.py
@@ -130,3 +130,168 @@ class CursFeatureWriterTest(FeatureWriterTest):
             } curs;
             """
         )
+
+    def test_curs_feature_multiple_anchors(self, testufo):
+        glyph = testufo.newGlyph("d")
+        glyph.appendAnchor({"name": "entry.1", "x": 100, "y": 200})
+        glyph.appendAnchor({"name": "exit.1", "x": 0, "y": 300})
+        glyph = testufo.newGlyph("e")
+        glyph.appendAnchor({"name": "entry.1", "x": 100, "y": 200})
+        glyph = testufo.newGlyph("f")
+        glyph.appendAnchor({"name": "exit.1", "x": 0, "y": 300})
+        glyph.appendAnchor({"name": "exit.2", "x": 0, "y": 400})
+        glyph = testufo.newGlyph("g")
+        glyph.appendAnchor({"name": "entry.2", "x": 100, "y": 200})
+        generated = self.writeFeatures(testufo)
+
+        assert str(generated) == dedent(
+            """\
+            feature curs {
+                lookup curs {
+                    lookupflag RightToLeft IgnoreMarks;
+                    pos cursive a <anchor NULL> <anchor 100 200>;
+                    pos cursive b <anchor 0 200> <anchor 111 200>;
+                    pos cursive c <anchor 100 200> <anchor NULL>;
+                } curs;
+
+                lookup curs_1 {
+                    lookupflag RightToLeft IgnoreMarks;
+                    pos cursive d <anchor 100 200> <anchor 0 300>;
+                    pos cursive e <anchor 100 200> <anchor NULL>;
+                    pos cursive f <anchor NULL> <anchor 0 300>;
+                } curs_1;
+
+                lookup curs_2 {
+                    lookupflag RightToLeft IgnoreMarks;
+                    pos cursive f <anchor NULL> <anchor 0 400>;
+                    pos cursive g <anchor 100 200> <anchor NULL>;
+                } curs_2;
+
+            } curs;
+            """
+        )
+
+    def test_curs_feature_multiple_anchors_LTR(self, testufo):
+        testufo["a"].unicode = ord("a")
+        testufo["b"].unicode = ord("b")
+        testufo["c"].unicode = ord("c")
+        glyph = testufo.newGlyph("d")
+        glyph.unicode = ord("d")
+        glyph.appendAnchor({"name": "entry.1", "x": 100, "y": 200})
+        glyph.appendAnchor({"name": "exit.1", "x": 0, "y": 300})
+        glyph = testufo.newGlyph("e")
+        glyph.unicode = ord("e")
+        glyph.appendAnchor({"name": "entry.1", "x": 100, "y": 200})
+        glyph = testufo.newGlyph("f")
+        glyph.unicode = ord("f")
+        glyph.appendAnchor({"name": "exit.1", "x": 0, "y": 300})
+        glyph.appendAnchor({"name": "exit.2", "x": 0, "y": 400})
+        glyph = testufo.newGlyph("g")
+        glyph.unicode = ord("g")
+        glyph.appendAnchor({"name": "entry.2", "x": 100, "y": 200})
+        generated = self.writeFeatures(testufo)
+
+        assert str(generated) == dedent(
+            """\
+            feature curs {
+                lookup curs_ltr {
+                    lookupflag IgnoreMarks;
+                    pos cursive a <anchor NULL> <anchor 100 200>;
+                    pos cursive b <anchor 0 200> <anchor 111 200>;
+                    pos cursive c <anchor 100 200> <anchor NULL>;
+                } curs_ltr;
+
+                lookup curs_1_ltr {
+                    lookupflag IgnoreMarks;
+                    pos cursive d <anchor 100 200> <anchor 0 300>;
+                    pos cursive e <anchor 100 200> <anchor NULL>;
+                    pos cursive f <anchor NULL> <anchor 0 300>;
+                } curs_1_ltr;
+
+                lookup curs_2_ltr {
+                    lookupflag IgnoreMarks;
+                    pos cursive f <anchor NULL> <anchor 0 400>;
+                    pos cursive g <anchor 100 200> <anchor NULL>;
+                } curs_2_ltr;
+
+            } curs;
+            """
+        )
+
+    def test_curs_feature_multiple_anchors_mixed(self, testufo):
+        testufo["a"].unicode = ord("a")
+        testufo["b"].unicode = ord("b")
+        testufo["c"].unicode = ord("c")
+        glyph = testufo.newGlyph("d")
+        glyph.unicode = ord("d")
+        glyph.appendAnchor({"name": "entry.1", "x": 100, "y": 200})
+        glyph.appendAnchor({"name": "exit.1", "x": 0, "y": 300})
+        glyph = testufo.newGlyph("e")
+        glyph.unicode = ord("e")
+        glyph.appendAnchor({"name": "entry.1", "x": 100, "y": 200})
+        glyph = testufo.newGlyph("f")
+        glyph.unicode = ord("f")
+        glyph.appendAnchor({"name": "exit.1", "x": 0, "y": 300})
+        glyph.appendAnchor({"name": "exit.2", "x": 0, "y": 400})
+        glyph = testufo.newGlyph("g")
+        glyph.unicode = ord("g")
+        glyph.appendAnchor({"name": "entry.2", "x": 100, "y": 200})
+        glyph = testufo.newGlyph("alef-ar")
+        glyph.appendAnchor({"name": "entry", "x": 100, "y": 200})
+        glyph.appendAnchor({"name": "exit", "x": 0, "y": 200})
+        glyph.appendAnchor({"name": "exit.1", "x": 0, "y": 300})
+        glyph = testufo.newGlyph("beh-ar")
+        glyph.unicode = 0x0628
+        glyph.appendAnchor({"name": "entry.1", "x": 100, "y": 200})
+        glyph.appendAnchor({"name": "exit.1", "x": 0, "y": 200})
+        glyph.appendAnchor({"name": "exit.2", "x": 0, "y": 100})
+        glyph = testufo.newGlyph("hah-ar")
+        glyph.unicode = 0x0647
+        glyph.appendAnchor({"name": "entry", "x": 100, "y": 100})
+        glyph.appendAnchor({"name": "entry.2", "x": 100, "y": 200})
+        generated = self.writeFeatures(testufo)
+
+        assert str(generated) == dedent(
+            """\
+            feature curs {
+                lookup curs_ltr {
+                    lookupflag IgnoreMarks;
+                    pos cursive a <anchor NULL> <anchor 100 200>;
+                    pos cursive b <anchor 0 200> <anchor 111 200>;
+                    pos cursive c <anchor 100 200> <anchor NULL>;
+                } curs_ltr;
+
+                lookup curs_rtl {
+                    lookupflag RightToLeft IgnoreMarks;
+                    pos cursive alef-ar <anchor 100 200> <anchor 0 200>;
+                    pos cursive hah-ar <anchor 100 100> <anchor NULL>;
+                } curs_rtl;
+
+                lookup curs_1_ltr {
+                    lookupflag IgnoreMarks;
+                    pos cursive d <anchor 100 200> <anchor 0 300>;
+                    pos cursive e <anchor 100 200> <anchor NULL>;
+                    pos cursive f <anchor NULL> <anchor 0 300>;
+                } curs_1_ltr;
+
+                lookup curs_1_rtl {
+                    lookupflag RightToLeft IgnoreMarks;
+                    pos cursive alef-ar <anchor NULL> <anchor 0 300>;
+                    pos cursive beh-ar <anchor 100 200> <anchor 0 200>;
+                } curs_1_rtl;
+
+                lookup curs_2_ltr {
+                    lookupflag IgnoreMarks;
+                    pos cursive f <anchor NULL> <anchor 0 400>;
+                    pos cursive g <anchor 100 200> <anchor NULL>;
+                } curs_2_ltr;
+
+                lookup curs_2_rtl {
+                    lookupflag RightToLeft IgnoreMarks;
+                    pos cursive beh-ar <anchor NULL> <anchor 0 100>;
+                    pos cursive hah-ar <anchor 100 200> <anchor NULL>;
+                } curs_2_rtl;
+
+            } curs;
+            """
+        )