Repository: googlefonts/ufo2ft
Pull request #869 (2024-09-04 16:12:12)

markFeatureWriter: Support contextual anchors
This merges glyphsLib’s ContextualMarkFeatureWriter subclass back into MarkFeatureWriter.


Patch:
diff --git a/Lib/ufo2ft/featureWriters/markFeatureWriter.py b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
index 9a79bb4..1acf65d 100644
--- a/Lib/ufo2ft/featureWriters/markFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
@@ -3,7 +3,7 @@ import re
 from collections import OrderedDict, defaultdict
 from functools import partial
 
-from ufo2ft.constants import INDIC_SCRIPTS, USE_SCRIPTS
+from ufo2ft.constants import INDIC_SCRIPTS, OBJECT_LIBS_KEY, USE_SCRIPTS
 from ufo2ft.featureWriters import BaseFeatureWriter, ast
 from ufo2ft.util import (
     classifyGlyphs,
@@ -127,9 +127,15 @@ def parseAnchorName(
     three elements above.
     """
     number = None
+    isContextual = False
     if ignoreRE is not None:
         anchorName = re.sub(ignoreRE, "", anchorName)
 
+    if anchorName[0] == "*":
+        isContextual = True
+        anchorName = anchorName[1:]
+        anchorName = re.sub(r"\..*", "", anchorName)
+
     m = ligaNumRE.match(anchorName)
     if not m:
         key = anchorName
@@ -156,13 +162,26 @@ def parseAnchorName(
     else:
         isMark = False
 
-    return isMark, key, number
+    isIgnorable = key and not key[0].isalpha()
+
+    return isMark, key, number, isContextual, isIgnorable
 
 
 class NamedAnchor:
     """A position with a name, and an associated markClass."""
 
-    __slots__ = ("name", "x", "y", "isMark", "key", "number", "markClass")
+    __slots__ = (
+        "name",
+        "x",
+        "y",
+        "isMark",
+        "key",
+        "number",
+        "markClass",
+        "isContextual",
+        "isIgnorable",
+        "libData",
+    )
 
     # subclasses can customize these to use different anchor naming schemes
     markPrefix = MARK_PREFIX
@@ -170,11 +189,11 @@ class NamedAnchor:
     ligaSeparator = LIGA_SEPARATOR
     ligaNumRE = LIGA_NUM_RE
 
-    def __init__(self, name, x, y, markClass=None):
+    def __init__(self, name, x, y, markClass=None, libData=None):
         self.name = name
         self.x = x
         self.y = y
-        isMark, key, number = parseAnchorName(
+        isMark, key, number, isContextual, isIgnorable = parseAnchorName(
             name,
             markPrefix=self.markPrefix,
             ligaSeparator=self.ligaSeparator,
@@ -190,6 +209,9 @@ class NamedAnchor:
         self.key = key
         self.number = number
         self.markClass = markClass
+        self.isContextual = isContextual
+        self.isIgnorable = isIgnorable
+        self.libData = libData
 
     @property
     def markAnchorName(self):
@@ -357,7 +379,14 @@ class MarkFeatureWriter(BaseFeatureWriter):
                         "duplicate anchor '%s' in glyph '%s'", anchorName, glyphName
                     )
                 x, y = self._getAnchor(glyphName, anchorName, anchor=anchor)
-                a = self.NamedAnchor(name=anchorName, x=x, y=y)
+                libData = None
+                if anchor.identifier:
+                    libData = glyph.lib[OBJECT_LIBS_KEY].get(anchor.identifier)
+                a = self.NamedAnchor(name=anchorName, x=x, y=y, libData=libData)
+                if a.isContextual and not libData:
+                    continue
+                if a.isIgnorable:
+                    continue
                 anchorDict[anchorName] = a
             if anchorDict:
                 result[glyphName] = list(anchorDict.values())
@@ -620,6 +649,9 @@ class MarkFeatureWriter(BaseFeatureWriter):
                     # skip '_1', '_2', etc. suffixed anchors for this lookup
                     # type; these will be are added in the mark2liga lookup
                     continue
+                if anchor.isContextual:
+                    # skip contextual anchors. They are handled separately.
+                    continue
                 assert not anchor.isMark
                 baseMarks.append(anchor)
             if not baseMarks:
@@ -640,6 +672,9 @@ class MarkFeatureWriter(BaseFeatureWriter):
                 # skip anchors for which no mark class is defined
                 if anchor.markClass is None or anchor.isMark:
                     continue
+                if anchor.isContextual:
+                    # skip contextual anchors. They are handled separately.
+                    continue
                 if anchor.number is not None:
                     self.log.warning(
                         "invalid ligature anchor '%s' in mark glyph '%s'; " "skipped",
@@ -671,6 +706,9 @@ class MarkFeatureWriter(BaseFeatureWriter):
                 if number is None:
                     # we handled these in the mark2base lookup
                     continue
+                if anchor.isContextual:
+                    # skip contextual anchors. They are handled separately.
+                    continue
                 # unnamed anchors with only a number suffix "_1", "_2", etc.
                 # are understood as the ligature component having <anchor NULL>
                 if not anchor.key:
@@ -766,6 +804,91 @@ class MarkFeatureWriter(BaseFeatureWriter):
             feature.statements.append(ligaLkp)
         return feature
 
+    def _makeContextualMarkFeature(self, feature):
+        ctx = self.context
+
+        # Arrange by context
+        by_context = defaultdict(list)
+        markGlyphNames = ctx.markGlyphNames
+
+        for glyphName, anchors in sorted(ctx.anchorLists.items()):
+            if glyphName in markGlyphNames:
+                continue
+            for anchor in anchors:
+                if not anchor.isContextual:
+                    continue
+                anchor_context = anchor.libData["GPOS_Context"].strip()
+                by_context[anchor_context].append((glyphName, anchor))
+        if not by_context:
+            return feature, []
+
+        if feature is None:
+            feature = ast.FeatureBlock("mark")
+
+        # Pull the lookups from the feature and replace them with lookup references,
+        # to ensure the order is correct
+        lookups = feature.statements
+        feature.statements = [ast.LookupReferenceStatement(lu) for lu in lookups]
+        dispatch_lookups = {}
+        # We sort the full context by longest first. This isn't perfect
+        # but it gives us the best chance that more specific contexts
+        # (typically longer) will take precedence over more general ones.
+        for ix, (fullcontext, glyph_anchor_pair) in enumerate(
+            sorted(by_context.items(), key=lambda x: -len(x[0]))
+        ):
+            # Make the contextual lookup
+            lookupname = "ContextualMark_%i" % ix
+            if ";" in fullcontext:
+                before, after = fullcontext.split(";")
+                # I know it's not really a comment but this is the easiest way
+                # to get the lookup flag in there without reparsing it.
+            else:
+                after = fullcontext
+                before = ""
+            after = after.strip()
+            if before not in dispatch_lookups:
+                dispatch_lookups[before] = ast.LookupBlock(
+                    "ContextualMarkDispatch_%i" % len(dispatch_lookups.keys())
+                )
+                if before:
+                    dispatch_lookups[before].statements.append(
+                        ast.Comment(f"{before};")
+                    )
+                feature.statements.append(
+                    ast.LookupReferenceStatement(dispatch_lookups[before])
+                )
+            lkp = dispatch_lookups[before]
+            lkp.statements.append(ast.Comment(f"# {after}"))
+            lookup = ast.LookupBlock(lookupname)
+            for glyph, anchor in glyph_anchor_pair:
+                lookup.statements.append(MarkToBasePos(glyph, [anchor]).asAST())
+            lookups.append(lookup)
+
+            # Insert mark glyph names after base glyph names if not specified otherwise.
+            if "&" not in after:
+                after = after.replace("*", "* &")
+
+            # Group base glyphs by anchor
+            glyphs = {}
+            for glyph, anchor in glyph_anchor_pair:
+                glyphs.setdefault(anchor.key, [anchor, []])[1].append(glyph)
+
+            for anchor, bases in glyphs.values():
+                bases = " ".join(bases)
+                marks = ast.GlyphClass(
+                    self.context.markClasses[anchor.key].glyphs.keys()
+                ).asFea()
+
+                # Replace * with base glyph names
+                contextual = after.replace("*", f"[{bases}]")
+
+                # Replace & with mark glyph names
+                contextual = contextual.replace("&", f"{marks}' lookup {lookupname}")
+                lkp.statements.append(ast.Comment(f"pos {contextual}; # {anchor.name}"))
+
+        lookups.extend(dispatch_lookups.values())
+        return feature, lookups
+
     def _makeMkmkFeature(self, include):
         feature = ast.FeatureBlock("mkmk")
 
@@ -854,6 +977,7 @@ class MarkFeatureWriter(BaseFeatureWriter):
     def _makeFeatures(self):
         ctx = self.context
 
+        # First do non-contextual lookups
         ctx.groupedMarkToBaseAttachments = self._groupAttachments(
             self._makeMarkToBaseAttachments()
         )
@@ -871,11 +995,14 @@ class MarkFeatureWriter(BaseFeatureWriter):
             return glyphName in notAbvmGlyphs
 
         features = {}
+        lookups = []
         todo = ctx.todo
         if "mark" in todo:
             mark = self._makeMarkFeature(include=isNotAbvm)
+            mark, markLookups = self._makeContextualMarkFeature(mark)
             if mark is not None:
                 features["mark"] = mark
+                lookups.extend(markLookups)
         if "mkmk" in todo:
             mkmk = self._makeMkmkFeature(include=isNotAbvm)
             if mkmk is not None:
@@ -889,7 +1016,7 @@ class MarkFeatureWriter(BaseFeatureWriter):
                     if feature is not None:
                         features[tag] = feature
 
-        return features
+        return features, lookups
 
     def _getAbvmGlyphs(self):
         glyphSet = set(self.getOrderedGlyphSet().keys())
@@ -937,7 +1064,7 @@ class MarkFeatureWriter(BaseFeatureWriter):
         newClassDefs = self._makeMarkClassDefinitions()
         self._setBaseAnchorMarkClasses()
 
-        features = self._makeFeatures()
+        features, lookups = self._makeFeatures()
         if not features:
             return False
 
@@ -947,6 +1074,7 @@ class MarkFeatureWriter(BaseFeatureWriter):
             feaFile=feaFile,
             markClassDefs=newClassDefs,
             features=[features[tag] for tag in sorted(features.keys())],
+            lookups=lookups,
         )
 
         return True


Test patch:
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/features.fea b/tests/data/ContextualAnchorsTest-Regular.ufo/features.fea
new file mode 100644
index 0000000..4188cfc
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/features.fea
@@ -0,0 +1,37 @@
+# Prefix: Languagesystems
+# automatic
+languagesystem DFLT dflt;
+
+languagesystem arab dflt;
+
+
+feature aalt {
+# automatic
+feature init;
+feature medi;
+feature fina;
+
+} aalt;
+
+feature ccmp {
+sub beh-ar by behDotless-ar dotbelow-ar;
+
+} ccmp;
+
+feature init {
+# automatic
+sub behDotless-ar by behDotless-ar.init;
+
+} init;
+
+feature medi {
+# automatic
+sub behDotless-ar by behDotless-ar.medi;
+
+} medi;
+
+feature fina {
+# automatic
+sub behDotless-ar by behDotless-ar.fina;
+
+} fina;
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/fontinfo.plist b/tests/data/ContextualAnchorsTest-Regular.ufo/fontinfo.plist
new file mode 100644
index 0000000..63ee46b
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/fontinfo.plist
@@ -0,0 +1,76 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>ascender</key>
+    <integer>800</integer>
+    <key>capHeight</key>
+    <integer>700</integer>
+    <key>descender</key>
+    <integer>-200</integer>
+    <key>familyName</key>
+    <string>Contextual Anchors Test</string>
+    <key>guidelines</key>
+    <array>
+      <dict>
+        <key>angle</key>
+        <integer>0</integer>
+        <key>name</key>
+        <string> [locked]</string>
+        <key>x</key>
+        <integer>-126</integer>
+        <key>y</key>
+        <integer>90</integer>
+      </dict>
+    </array>
+    <key>italicAngle</key>
+    <integer>0</integer>
+    <key>openTypeHeadCreated</key>
+    <string>2023/07/31 15:25:34</string>
+    <key>openTypeOS2Type</key>
+    <array>
+      <integer>3</integer>
+    </array>
+    <key>postscriptBlueValues</key>
+    <array>
+      <integer>-12</integer>
+      <integer>0</integer>
+      <integer>480</integer>
+      <integer>492</integer>
+      <integer>700</integer>
+      <integer>712</integer>
+      <integer>800</integer>
+      <integer>812</integer>
+    </array>
+    <key>postscriptOtherBlues</key>
+    <array>
+      <integer>-212</integer>
+      <integer>-200</integer>
+    </array>
+    <key>postscriptStemSnapH</key>
+    <array>
+      <integer>80</integer>
+      <integer>88</integer>
+      <integer>91</integer>
+    </array>
+    <key>postscriptStemSnapV</key>
+    <array>
+      <integer>90</integer>
+      <integer>93</integer>
+    </array>
+    <key>postscriptUnderlinePosition</key>
+    <integer>-100</integer>
+    <key>postscriptUnderlineThickness</key>
+    <integer>50</integer>
+    <key>styleName</key>
+    <string>Regular</string>
+    <key>unitsPerEm</key>
+    <integer>1000</integer>
+    <key>versionMajor</key>
+    <integer>1</integer>
+    <key>versionMinor</key>
+    <integer>0</integer>
+    <key>xHeight</key>
+    <integer>480</integer>
+  </dict>
+</plist>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/beh-ar.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/beh-ar.glif
new file mode 100644
index 0000000..9b02ed8
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/beh-ar.glif
@@ -0,0 +1,9 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="beh-ar" format="2">
+  <advance width="550"/>
+  <unicode hex="0628"/>
+  <outline>
+    <component base="behDotless-ar"/>
+    <component base="dotbelow-ar" xOffset="140" yOffset="-75"/>
+  </outline>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.fina.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.fina.glif
new file mode 100644
index 0000000..1fdb5d7
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.fina.glif
@@ -0,0 +1,22 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="behDotless-ar.fina" format="2">
+  <advance width="550"/>
+  <anchor x="290" y="-25" name="bottom"/>
+  <outline>
+    <contour>
+      <point x="152" y="0" type="curve" smooth="yes"/>
+      <point x="550" y="0" type="line"/>
+      <point x="550" y="100" type="line"/>
+      <point x="500" y="100" type="line"/>
+      <point x="500" y="300" type="line"/>
+      <point x="400" y="200" type="line"/>
+      <point x="400" y="100" type="line"/>
+      <point x="153" y="100" type="line" smooth="yes"/>
+      <point x="115" y="100"/>
+      <point x="22" y="110"/>
+      <point x="0" y="200" type="curve"/>
+      <point x="0" y="36"/>
+      <point x="92" y="0"/>
+    </contour>
+  </outline>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.glif
new file mode 100644
index 0000000..78f7fb4
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.glif
@@ -0,0 +1,22 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="behDotless-ar" format="2">
+  <advance width="550"/>
+  <unicode hex="066E"/>
+  <anchor x="290" y="-25" name="bottom"/>
+  <anchor x="243" y="182" name="top"/>
+  <outline>
+    <contour>
+      <point x="152" y="0" type="curve" smooth="yes"/>
+      <point x="500" y="0" type="line"/>
+      <point x="500" y="300" type="line"/>
+      <point x="400" y="200" type="line"/>
+      <point x="400" y="100" type="line"/>
+      <point x="153" y="100" type="line" smooth="yes"/>
+      <point x="115" y="100"/>
+      <point x="22" y="110"/>
+      <point x="0" y="200" type="curve"/>
+      <point x="0" y="36"/>
+      <point x="92" y="0"/>
+    </contour>
+  </outline>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.init.alt.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.init.alt.glif
new file mode 100644
index 0000000..90e8991
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.init.alt.glif
@@ -0,0 +1,28 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="behDotless-ar.init.alt" format="2">
+  <advance width="150"/>
+  <anchor x="55" y="-259" name="*bottom.vtwodots" identifier="051ABFBB-3A07-48C5-A5A3-82D4291DEAD1"/>
+  <anchor x="55" y="-100" name="bottom"/>
+  <outline>
+    <contour>
+      <point x="-90" y="0" type="line"/>
+      <point x="100" y="0" type="line"/>
+      <point x="100" y="300" type="line"/>
+      <point x="0" y="200" type="line"/>
+      <point x="0" y="100" type="line"/>
+      <point x="-90" y="100" type="line"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>public.objectLibs</key>
+      <dict>
+        <key>051ABFBB-3A07-48C5-A5A3-82D4291DEAD1</key>
+        <dict>
+          <key>GPOS_Context</key>
+          <string>lookupflag UseMarrkFilteringSet [twodotsverticalbelow]; reh-ar *</string>
+        </dict>
+      </dict>
+    </dict>
+  </lib>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.init.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.init.glif
new file mode 100644
index 0000000..abf21cf
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.init.glif
@@ -0,0 +1,40 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="behDotless-ar.init" format="2">
+  <advance width="150"/>
+  <anchor x="55" y="-50" name="*bottom" identifier="575ADDBE-67A1-4781-8244-18DEC9396567"/>
+  <anchor x="55" y="-200" name="*bottom.twodots" identifier="8AEC6EF0-8B9B-481A-AC0E-A95051FD2D12"/>
+  <anchor x="55" y="-259" name="*bottom.vtwodots" identifier="50838DB6-85ED-49AE-8935-C6201FC3FCD8"/>
+  <anchor x="55" y="-100" name="bottom"/>
+  <outline>
+    <contour>
+      <point x="-90" y="0" type="line"/>
+      <point x="100" y="0" type="line"/>
+      <point x="100" y="300" type="line"/>
+      <point x="0" y="200" type="line"/>
+      <point x="0" y="100" type="line"/>
+      <point x="-90" y="100" type="line"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>public.objectLibs</key>
+      <dict>
+        <key>50838DB6-85ED-49AE-8935-C6201FC3FCD8</key>
+        <dict>
+          <key>GPOS_Context</key>
+          <string>lookupflag UseMarrkFilteringSet [twodotsverticalbelow]; reh-ar *</string>
+        </dict>
+        <key>575ADDBE-67A1-4781-8244-18DEC9396567</key>
+        <dict>
+          <key>GPOS_Context</key>
+          <string>reh-ar *</string>
+        </dict>
+        <key>8AEC6EF0-8B9B-481A-AC0E-A95051FD2D12</key>
+        <dict>
+          <key>GPOS_Context</key>
+          <string>lookupflag UseMarrkFilteringSet [twodotshorizontalbelow]; reh-ar * behDotess-ar.medi &amp;</string>
+        </dict>
+      </dict>
+    </dict>
+  </lib>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.medi.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.medi.glif
new file mode 100644
index 0000000..8d5220a
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behD_otless-ar.medi.glif
@@ -0,0 +1,17 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="behDotless-ar.medi" format="2">
+  <advance width="151"/>
+  <anchor x="55" y="-100" name="bottom"/>
+  <outline>
+    <contour>
+      <point x="-49" y="0" type="line"/>
+      <point x="151" y="0" type="line"/>
+      <point x="151" y="100" type="line"/>
+      <point x="101" y="100" type="line"/>
+      <point x="101" y="300" type="line"/>
+      <point x="1" y="200" type="line"/>
+      <point x="1" y="100" type="line"/>
+      <point x="-49" y="100" type="line"/>
+    </contour>
+  </outline>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behT_wodotsbelowD_otabove-ar.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behT_wodotsbelowD_otabove-ar.glif
new file mode 100644
index 0000000..236b966
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/behT_wodotsbelowD_otabove-ar.glif
@@ -0,0 +1,10 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="behTwodotsbelowDotabove-ar" format="2">
+  <advance width="550"/>
+  <unicode hex="0754"/>
+  <outline>
+    <component base="behDotless-ar"/>
+    <component base="twodotshorizontalbelow-ar" xOffset="70" yOffset="-75"/>
+    <component base="dotabove-ar" xOffset="93" yOffset="231"/>
+  </outline>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/contents.plist b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/contents.plist
new file mode 100644
index 0000000..d40b99a
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/contents.plist
@@ -0,0 +1,30 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>beh-ar</key>
+    <string>beh-ar.glif</string>
+    <key>behDotless-ar</key>
+    <string>behD_otless-ar.glif</string>
+    <key>behDotless-ar.fina</key>
+    <string>behD_otless-ar.fina.glif</string>
+    <key>behDotless-ar.init</key>
+    <string>behD_otless-ar.init.glif</string>
+    <key>behDotless-ar.init.alt</key>
+    <string>behD_otless-ar.init.alt.glif</string>
+    <key>behDotless-ar.medi</key>
+    <string>behD_otless-ar.medi.glif</string>
+    <key>behTwodotsbelowDotabove-ar</key>
+    <string>behT_wodotsbelowD_otabove-ar.glif</string>
+    <key>dotabove-ar</key>
+    <string>dotabove-ar.glif</string>
+    <key>dotbelow-ar</key>
+    <string>dotbelow-ar.glif</string>
+    <key>reh-ar</key>
+    <string>reh-ar.glif</string>
+    <key>twodotshorizontalbelow-ar</key>
+    <string>twodotshorizontalbelow-ar.glif</string>
+    <key>twodotsverticalbelow-ar</key>
+    <string>twodotsverticalbelow-ar.glif</string>
+  </dict>
+</plist>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/dotabove-ar.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/dotabove-ar.glif
new file mode 100644
index 0000000..73d6c94
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/dotabove-ar.glif
@@ -0,0 +1,24 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="dotabove-ar" format="2">
+  <anchor x="150" y="-49" name="_top"/>
+  <outline>
+    <component base="dotbelow-ar"/>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.ComponentInfo</key>
+      <array>
+        <dict>
+          <key>alignment</key>
+          <integer>1</integer>
+          <key>index</key>
+          <integer>0</integer>
+          <key>name</key>
+          <string>dotbelow-ar</string>
+        </dict>
+      </array>
+      <key>com.schriftgestaltung.Glyphs.originalWidth</key>
+      <integer>300</integer>
+    </dict>
+  </lib>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/dotbelow-ar.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/dotbelow-ar.glif
new file mode 100644
index 0000000..2e36ec0
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/dotbelow-ar.glif
@@ -0,0 +1,26 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="dotbelow-ar" format="2">
+  <anchor x="150" y="50" name="_bottom"/>
+  <outline>
+    <contour>
+      <point x="150" y="-50" type="curve" smooth="yes"/>
+      <point x="178" y="-50"/>
+      <point x="200" y="-28"/>
+      <point x="200" y="0" type="curve" smooth="yes"/>
+      <point x="200" y="28"/>
+      <point x="178" y="50"/>
+      <point x="150" y="50" type="curve" smooth="yes"/>
+      <point x="122" y="50"/>
+      <point x="100" y="28"/>
+      <point x="100" y="0" type="curve" smooth="yes"/>
+      <point x="100" y="-28"/>
+      <point x="122" y="-50"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.originalWidth</key>
+      <integer>300</integer>
+    </dict>
+  </lib>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/layerinfo.plist b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/layerinfo.plist
new file mode 100644
index 0000000..6dad622
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/layerinfo.plist
@@ -0,0 +1,35 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>lib</key>
+    <dict>
+      <key>com.schriftgestaltung.layerId</key>
+      <string>3E7589AA-8194-470F-8E2F-13C1C581BE24</string>
+      <key>com.schriftgestaltung.layerOrderInGlyph.beh-ar</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.behDotless-ar</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.behDotless-ar.fina</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.behDotless-ar.init</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.behDotless-ar.init.alt</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.behDotless-ar.medi</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.behTwodotsbelowDotabove-ar</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.dotabove-ar</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.dotbelow-ar</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.reh-ar</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.twodotshorizontalbelow-ar</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.twodotsverticalbelow-ar</key>
+      <integer>1</integer>
+    </dict>
+  </dict>
+</plist>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/reh-ar.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/reh-ar.glif
new file mode 100644
index 0000000..392b26a
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/reh-ar.glif
@@ -0,0 +1,18 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="reh-ar" format="2">
+  <advance width="200"/>
+  <unicode hex="0631"/>
+  <outline>
+    <contour>
+      <point x="-50" y="-150" type="curve"/>
+      <point x="114" y="-150"/>
+      <point x="150" y="-58"/>
+      <point x="150" y="2" type="curve" smooth="yes"/>
+      <point x="150" y="300" type="line"/>
+      <point x="50" y="200" type="line"/>
+      <point x="50" y="3" type="line" smooth="yes"/>
+      <point x="50" y="-35"/>
+      <point x="40" y="-128"/>
+    </contour>
+  </outline>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/twodotshorizontalbelow-ar.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/twodotshorizontalbelow-ar.glif
new file mode 100644
index 0000000..3dd307f
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/twodotshorizontalbelow-ar.glif
@@ -0,0 +1,40 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="twodotshorizontalbelow-ar" format="2">
+  <anchor x="220" y="50" name="_bottom"/>
+  <outline>
+    <contour>
+      <point x="150" y="-50" type="curve" smooth="yes"/>
+      <point x="178" y="-50"/>
+      <point x="200" y="-28"/>
+      <point x="200" y="0" type="curve" smooth="yes"/>
+      <point x="200" y="28"/>
+      <point x="178" y="50"/>
+      <point x="150" y="50" type="curve" smooth="yes"/>
+      <point x="122" y="50"/>
+      <point x="100" y="28"/>
+      <point x="100" y="0" type="curve" smooth="yes"/>
+      <point x="100" y="-28"/>
+      <point x="122" y="-50"/>
+    </contour>
+    <contour>
+      <point x="290" y="-50" type="curve" smooth="yes"/>
+      <point x="318" y="-50"/>
+      <point x="340" y="-28"/>
+      <point x="340" y="0" type="curve" smooth="yes"/>
+      <point x="340" y="28"/>
+      <point x="318" y="50"/>
+      <point x="290" y="50" type="curve" smooth="yes"/>
+      <point x="262" y="50"/>
+      <point x="240" y="28"/>
+      <point x="240" y="0" type="curve" smooth="yes"/>
+      <point x="240" y="-28"/>
+      <point x="262" y="-50"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.originalWidth</key>
+      <integer>440</integer>
+    </dict>
+  </lib>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/twodotsverticalbelow-ar.glif b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/twodotsverticalbelow-ar.glif
new file mode 100644
index 0000000..9b13552
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/glyphs/twodotsverticalbelow-ar.glif
@@ -0,0 +1,40 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="twodotsverticalbelow-ar" format="2">
+  <anchor x="150" y="50" name="_bottom"/>
+  <outline>
+    <contour>
+      <point x="150" y="-50" type="curve" smooth="yes"/>
+      <point x="178" y="-50"/>
+      <point x="200" y="-28"/>
+      <point x="200" y="0" type="curve" smooth="yes"/>
+      <point x="200" y="28"/>
+      <point x="178" y="50"/>
+      <point x="150" y="50" type="curve" smooth="yes"/>
+      <point x="122" y="50"/>
+      <point x="100" y="28"/>
+      <point x="100" y="0" type="curve" smooth="yes"/>
+      <point x="100" y="-28"/>
+      <point x="122" y="-50"/>
+    </contour>
+    <contour>
+      <point x="150" y="-170" type="curve" smooth="yes"/>
+      <point x="178" y="-170"/>
+      <point x="200" y="-148"/>
+      <point x="200" y="-120" type="curve" smooth="yes"/>
+      <point x="200" y="-92"/>
+      <point x="178" y="-70"/>
+      <point x="150" y="-70" type="curve" smooth="yes"/>
+      <point x="122" y="-70"/>
+      <point x="100" y="-92"/>
+      <point x="100" y="-120" type="curve" smooth="yes"/>
+      <point x="100" y="-148"/>
+      <point x="122" y="-170"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.originalWidth</key>
+      <integer>300</integer>
+    </dict>
+  </lib>
+</glyph>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/layercontents.plist b/tests/data/ContextualAnchorsTest-Regular.ufo/layercontents.plist
new file mode 100644
index 0000000..b9c1a4f
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/layercontents.plist
@@ -0,0 +1,10 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <array>
+    <array>
+      <string>public.default</string>
+      <string>glyphs</string>
+    </array>
+  </array>
+</plist>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/lib.plist b/tests/data/ContextualAnchorsTest-Regular.ufo/lib.plist
new file mode 100644
index 0000000..1ddfd4d
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/lib.plist
@@ -0,0 +1,151 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>GSOffsetHorizontal</key>
+    <integer>45</integer>
+    <key>GSOffsetMakeStroke</key>
+    <integer>1</integer>
+    <key>GSOffsetVertical</key>
+    <integer>44</integer>
+    <key>GSRoughenHorizontal</key>
+    <integer>15</integer>
+    <key>GSRoughenSegmentLength</key>
+    <integer>15</integer>
+    <key>GSRoughenVertical</key>
+    <integer>10</integer>
+    <key>com.github.googlei18n.ufo2ft.featureWriters</key>
+    <array>
+      <dict>
+        <key>class</key>
+        <string>CursFeatureWriter</string>
+      </dict>
+      <dict>
+        <key>class</key>
+        <string>KernFeatureWriter</string>
+      </dict>
+      <dict>
+        <key>class</key>
+        <string>ContextualMarkFeatureWriter</string>
+        <key>module</key>
+        <string>glyphsLib.featureWriters.markFeatureWriter</string>
+      </dict>
+      <dict>
+        <key>class</key>
+        <string>GdefFeatureWriter</string>
+      </dict>
+    </array>
+    <key>com.github.googlei18n.ufo2ft.filters</key>
+    <array>
+      <dict>
+        <key>name</key>
+        <string>eraseOpenCorners</string>
+        <key>namespace</key>
+        <string>glyphsLib.filters</string>
+        <key>pre</key>
+        <true/>
+      </dict>
+    </array>
+    <key>com.schriftgestaltung.customParameter.GSFont.Propagate Anchors</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFont.Write DisplayStrings</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFont.Write lastChange</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFont.disablesAutomaticAlignment</key>
+    <false/>
+    <key>com.schriftgestaltung.customParameter.GSFont.useNiceNames</key>
+    <integer>1</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.TTFStems</key>
+    <array>
+      <dict>
+        <key>horizontal</key>
+        <integer>1</integer>
+        <key>name</key>
+        <string>Thin</string>
+        <key>width</key>
+        <integer>80</integer>
+      </dict>
+      <dict>
+        <key>horizontal</key>
+        <integer>1</integer>
+        <key>name</key>
+        <string>Lowercase</string>
+        <key>width</key>
+        <integer>88</integer>
+      </dict>
+      <dict>
+        <key>horizontal</key>
+        <integer>1</integer>
+        <key>name</key>
+        <string>Uppercase</string>
+        <key>width</key>
+        <integer>91</integer>
+      </dict>
+    </array>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue1</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue2</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue3</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.iconName</key>
+    <string></string>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.weightValue</key>
+    <integer>90</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.widthValue</key>
+    <integer>100</integer>
+    <key>com.schriftgestaltung.fontMasterID</key>
+    <string>3E7589AA-8194-470F-8E2F-13C1C581BE24</string>
+    <key>com.schriftgestaltung.fontMasterOrder</key>
+    <integer>1</integer>
+    <key>com.schriftgestaltung.weightValue</key>
+    <integer>90</integer>
+    <key>com.schriftgestaltung.widthValue</key>
+    <integer>100</integer>
+    <key>public.glyphOrder</key>
+    <array>
+      <string>behDotless-ar</string>
+      <string>behDotless-ar.fina</string>
+      <string>behDotless-ar.medi</string>
+      <string>behDotless-ar.init</string>
+      <string>behDotless-ar.init.alt</string>
+      <string>beh-ar</string>
+      <string>behTwodotsbelowDotabove-ar</string>
+      <string>reh-ar</string>
+      <string>dotabove-ar</string>
+      <string>dotbelow-ar</string>
+      <string>twodotsverticalbelow-ar</string>
+      <string>twodotshorizontalbelow-ar</string>
+    </array>
+    <key>public.postscriptNames</key>
+    <dict>
+      <key>beh-ar</key>
+      <string>uni0628</string>
+      <key>behDotless-ar</key>
+      <string>uni066E</string>
+      <key>behDotless-ar.fina</key>
+      <string>uni066E.fina</string>
+      <key>behDotless-ar.init</key>
+      <string>uni066E.init</string>
+      <key>behDotless-ar.init.alt</key>
+      <string>uni066E.init.alt</string>
+      <key>behDotless-ar.medi</key>
+      <string>uni066E.medi</string>
+      <key>behTwodotsbelowDotabove-ar</key>
+      <string>uni0754</string>
+      <key>dotabove-ar</key>
+      <string>dotabovear</string>
+      <key>dotbelow-ar</key>
+      <string>dotbelowar</string>
+      <key>reh-ar</key>
+      <string>uni0631</string>
+      <key>twodotshorizontalbelow-ar</key>
+      <string>twodotshorizontalbelowar</string>
+      <key>twodotsverticalbelow-ar</key>
+      <string>twodotsverticalbelowar</string>
+    </dict>
+  </dict>
+</plist>
diff --git a/tests/data/ContextualAnchorsTest-Regular.ufo/metainfo.plist b/tests/data/ContextualAnchorsTest-Regular.ufo/metainfo.plist
new file mode 100644
index 0000000..7b8b34a
--- /dev/null
+++ b/tests/data/ContextualAnchorsTest-Regular.ufo/metainfo.plist
@@ -0,0 +1,10 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>creator</key>
+    <string>com.github.fonttools.ufoLib</string>
+    <key>formatVersion</key>
+    <integer>3</integer>
+  </dict>
+</plist>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/features.fea b/tests/data/IgnoreAnchorsTest-Thin.ufo/features.fea
new file mode 100644
index 0000000..24c3a6b
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/features.fea
@@ -0,0 +1,4 @@
+# Prefix: Languagesystems
+languagesystem DFLT dflt;
+languagesystem latn dflt;
+
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/fontinfo.plist b/tests/data/IgnoreAnchorsTest-Thin.ufo/fontinfo.plist
new file mode 100644
index 0000000..32b741b
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/fontinfo.plist
@@ -0,0 +1,52 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>ascender</key>
+    <integer>800</integer>
+    <key>capHeight</key>
+    <integer>700</integer>
+    <key>descender</key>
+    <integer>-200</integer>
+    <key>familyName</key>
+    <string>Ignore Anchors Test</string>
+    <key>italicAngle</key>
+    <integer>0</integer>
+    <key>openTypeHeadCreated</key>
+    <string>2023/11/22 14:08:36</string>
+    <key>openTypeOS2Type</key>
+    <array>
+      <integer>3</integer>
+    </array>
+    <key>postscriptBlueValues</key>
+    <array>
+      <integer>-10</integer>
+      <integer>0</integer>
+      <integer>470</integer>
+      <integer>480</integer>
+      <integer>700</integer>
+      <integer>710</integer>
+      <integer>800</integer>
+      <integer>810</integer>
+    </array>
+    <key>postscriptOtherBlues</key>
+    <array>
+      <integer>-210</integer>
+      <integer>-200</integer>
+    </array>
+    <key>postscriptUnderlinePosition</key>
+    <integer>-100</integer>
+    <key>postscriptUnderlineThickness</key>
+    <integer>50</integer>
+    <key>styleName</key>
+    <string>Thin</string>
+    <key>unitsPerEm</key>
+    <integer>1000</integer>
+    <key>versionMajor</key>
+    <integer>1</integer>
+    <key>versionMinor</key>
+    <integer>0</integer>
+    <key>xHeight</key>
+    <integer>470</integer>
+  </dict>
+</plist>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/A_.glif b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/A_.glif
new file mode 100644
index 0000000..941e324
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/A_.glif
@@ -0,0 +1,13 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="A" format="2">
+  <advance width="593"/>
+  <unicode hex="0041"/>
+  <anchor x="297" y="700" name="top"/>
+  <outline>
+    <contour>
+      <point x="548" y="0" type="line"/>
+      <point x="297" y="700" type="line"/>
+      <point x="45" y="0" type="line"/>
+    </contour>
+  </outline>
+</glyph>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/A_dieresis.glif b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/A_dieresis.glif
new file mode 100644
index 0000000..495ce13
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/A_dieresis.glif
@@ -0,0 +1,10 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="Adieresis" format="2">
+  <advance width="593"/>
+  <unicode hex="00C4"/>
+  <anchor x="298" y="880" name="top"/>
+  <outline>
+    <component base="A"/>
+    <component base="dieresiscomb" xOffset="110" yOffset="230"/>
+  </outline>
+</glyph>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/a.glif b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/a.glif
new file mode 100644
index 0000000..34f8ea8
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/a.glif
@@ -0,0 +1,17 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="a" format="2">
+  <advance width="456"/>
+  <unicode hex="0061"/>
+  <anchor x="226" y="512" name="#top"/>
+  <anchor x="226" y="471" name="top"/>
+  <outline>
+    <contour>
+      <point x="69" y="150"/>
+      <point x="40" y="0"/>
+      <point x="212" y="0" type="curve" smooth="yes"/>
+      <point x="369" y="0" type="line"/>
+      <point x="369" y="428" type="line"/>
+      <point x="113" y="190" type="line" smooth="yes"/>
+    </contour>
+  </outline>
+</glyph>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/adieresis.glif b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/adieresis.glif
new file mode 100644
index 0000000..0710ad0
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/adieresis.glif
@@ -0,0 +1,11 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="adieresis" format="2">
+  <advance width="456"/>
+  <unicode hex="00E4"/>
+  <anchor x="226" y="512" name="#top"/>
+  <anchor x="226" y="588" name="top"/>
+  <outline>
+    <component base="a"/>
+    <component base="dieresiscomb" xOffset="38" yOffset="-62"/>
+  </outline>
+</glyph>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/contents.plist b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/contents.plist
new file mode 100644
index 0000000..ab734fd
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/contents.plist
@@ -0,0 +1,16 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>A</key>
+    <string>A_.glif</string>
+    <key>Adieresis</key>
+    <string>A_dieresis.glif</string>
+    <key>a</key>
+    <string>a.glif</string>
+    <key>adieresis</key>
+    <string>adieresis.glif</string>
+    <key>dieresiscomb</key>
+    <string>dieresiscomb.glif</string>
+  </dict>
+</plist>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/dieresiscomb.glif b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/dieresiscomb.glif
new file mode 100644
index 0000000..972f7af
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/dieresiscomb.glif
@@ -0,0 +1,27 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="dieresiscomb" format="2">
+  <unicode hex="0308"/>
+  <anchor x="188" y="574" name="_#top"/>
+  <anchor x="187" y="470" name="_top"/>
+  <anchor x="188" y="650" name="top"/>
+  <outline>
+    <contour>
+      <point x="289" y="650" type="line"/>
+      <point x="261" y="650" type="line"/>
+      <point x="261" y="621" type="line"/>
+      <point x="289" y="621" type="line"/>
+    </contour>
+    <contour>
+      <point x="116" y="650" type="line"/>
+      <point x="88" y="650" type="line"/>
+      <point x="88" y="621" type="line"/>
+      <point x="116" y="621" type="line"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.originalWidth</key>
+      <integer>600</integer>
+    </dict>
+  </lib>
+</glyph>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/layerinfo.plist b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/layerinfo.plist
new file mode 100644
index 0000000..c0b74db
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/glyphs/layerinfo.plist
@@ -0,0 +1,21 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>lib</key>
+    <dict>
+      <key>com.schriftgestaltung.layerId</key>
+      <string>C4872ECA-A3A9-40AB-960A-1DB2202F16DE</string>
+      <key>com.schriftgestaltung.layerOrderInGlyph.A</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.Adieresis</key>
+      <integer>0</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.a</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.adieresis</key>
+      <integer>0</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.dieresiscomb</key>
+      <integer>0</integer>
+    </dict>
+  </dict>
+</plist>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/groups.plist b/tests/data/IgnoreAnchorsTest-Thin.ufo/groups.plist
new file mode 100644
index 0000000..3a57a41
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/groups.plist
@@ -0,0 +1,22 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>public.kern1.A</key>
+    <array>
+      <string>A</string>
+    </array>
+    <key>public.kern1.a</key>
+    <array>
+      <string>a</string>
+    </array>
+    <key>public.kern2.A</key>
+    <array>
+      <string>A</string>
+    </array>
+    <key>public.kern2.a</key>
+    <array>
+      <string>a</string>
+    </array>
+  </dict>
+</plist>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/layercontents.plist b/tests/data/IgnoreAnchorsTest-Thin.ufo/layercontents.plist
new file mode 100644
index 0000000..b9c1a4f
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/layercontents.plist
@@ -0,0 +1,10 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <array>
+    <array>
+      <string>public.default</string>
+      <string>glyphs</string>
+    </array>
+  </array>
+</plist>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/lib.plist b/tests/data/IgnoreAnchorsTest-Thin.ufo/lib.plist
new file mode 100644
index 0000000..923b190
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/lib.plist
@@ -0,0 +1,81 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>com.github.googlei18n.ufo2ft.featureWriters</key>
+    <array>
+      <dict>
+        <key>class</key>
+        <string>CursFeatureWriter</string>
+      </dict>
+      <dict>
+        <key>class</key>
+        <string>KernFeatureWriter</string>
+      </dict>
+      <dict>
+        <key>class</key>
+        <string>ContextualMarkFeatureWriter</string>
+        <key>module</key>
+        <string>glyphsLib.featureWriters.markFeatureWriter</string>
+      </dict>
+      <dict>
+        <key>class</key>
+        <string>GdefFeatureWriter</string>
+      </dict>
+    </array>
+    <key>com.github.googlei18n.ufo2ft.filters</key>
+    <array>
+      <dict>
+        <key>name</key>
+        <string>eraseOpenCorners</string>
+        <key>namespace</key>
+        <string>glyphsLib.filters</string>
+        <key>pre</key>
+        <true/>
+      </dict>
+    </array>
+    <key>com.schriftgestaltung.customParameter.GSFont.Write DisplayStrings</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFont.Write lastChange</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFont.disablesAutomaticAlignment</key>
+    <false/>
+    <key>com.schriftgestaltung.customParameter.GSFont.useNiceNames</key>
+    <integer>1</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue1</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue2</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue3</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.iconName</key>
+    <string>Light</string>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.weightValue</key>
+    <integer>100</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.widthValue</key>
+    <integer>100</integer>
+    <key>com.schriftgestaltung.fontMasterID</key>
+    <string>C4872ECA-A3A9-40AB-960A-1DB2202F16DE</string>
+    <key>com.schriftgestaltung.fontMasterOrder</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.weightValue</key>
+    <integer>100</integer>
+    <key>com.schriftgestaltung.widthValue</key>
+    <integer>100</integer>
+    <key>public.glyphOrder</key>
+    <array>
+      <string>A</string>
+      <string>Adieresis</string>
+      <string>a</string>
+      <string>adieresis</string>
+      <string>dieresiscomb</string>
+    </array>
+    <key>public.postscriptNames</key>
+    <dict>
+      <key>dieresiscomb</key>
+      <string>uni0308</string>
+    </dict>
+  </dict>
+</plist>
diff --git a/tests/data/IgnoreAnchorsTest-Thin.ufo/metainfo.plist b/tests/data/IgnoreAnchorsTest-Thin.ufo/metainfo.plist
new file mode 100644
index 0000000..7b8b34a
--- /dev/null
+++ b/tests/data/IgnoreAnchorsTest-Thin.ufo/metainfo.plist
@@ -0,0 +1,10 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>creator</key>
+    <string>com.github.fonttools.ufoLib</string>
+    <key>formatVersion</key>
+    <integer>3</integer>
+  </dict>
+</plist>
diff --git a/tests/featureWriters/markFeatureWriter_test.py b/tests/featureWriters/markFeatureWriter_test.py
index 75d8307..a056679 100644
--- a/tests/featureWriters/markFeatureWriter_test.py
+++ b/tests/featureWriters/markFeatureWriter_test.py
@@ -5,6 +5,7 @@ from textwrap import dedent
 
 import pytest
 
+from ufo2ft.constants import OBJECT_LIBS_KEY
 from ufo2ft.errors import InvalidFeaturesData
 from ufo2ft.featureCompiler import FeatureCompiler, parseLayoutFeatures
 from ufo2ft.featureWriters import ast
@@ -34,17 +35,23 @@ def testufo(FontClass):
 @pytest.mark.parametrize(
     "input_expected",
     [
-        ("top", (False, "top", None)),
-        ("top_", (False, "top_", None)),
-        ("top1", (False, "top1", None)),
-        ("_bottom", (True, "bottom", None)),
-        ("bottom_2", (False, "bottom", 2)),
-        ("top_right_1", (False, "top_right", 1)),
+        ("top", (False, "top", None, False, False)),
+        ("top_", (False, "top_", None, False, False)),
+        ("top1", (False, "top1", None, False, False)),
+        ("_bottom", (True, "bottom", None, False, False)),
+        ("bottom_2", (False, "bottom", 2, False, False)),
+        ("top_right_1", (False, "top_right", 1, False, False)),
     ],
 )
 def test_parseAnchorName(input_expected):
-    anchorName, (isMark, key, number) = input_expected
-    assert parseAnchorName(anchorName) == (isMark, key, number)
+    anchorName, (isMark, key, number, isContextual, isIgnorable) = input_expected
+    assert parseAnchorName(anchorName) == (
+        isMark,
+        key,
+        number,
+        isContextual,
+        isIgnorable,
+    )
 
 
 def test_parseAnchorName_invalid():
@@ -1706,6 +1713,173 @@ class MarkFeatureWriterTest(FeatureWriterTest):
             "uuMatra-oriya.BRACKET.varAlt01]" in str(generated)
         )
 
+    def test_contextual_anchors(self, FontClass):
+        dirname = os.path.dirname(os.path.dirname(__file__))
+        fontPath = os.path.join(dirname, "data", "ContextualAnchorsTest-Regular.ufo")
+        testufo = FontClass(fontPath)
+
+        writer = MarkFeatureWriter()
+        feaFile = ast.FeatureFile()
+        assert str(feaFile) == ""
+        assert writer.write(testufo, feaFile)
+
+        assert len(feaFile.markClasses) == 2
+        assert "MC_bottom" in feaFile.markClasses
+
+        feature = feaFile.statements[-1]
+        assert feature.name == "mark"
+        # note there are two mark2base lookups because ufo2ft v3 generates one lookup
+        # per mark class (previously 'top' and 'bottom' would go into one lookup)
+        assert str(feature) == dedent(
+            """\
+            feature mark {
+                lookup mark2base;
+                lookup mark2base_1;
+                lookup ContextualMarkDispatch_0;
+                lookup ContextualMarkDispatch_1;
+                lookup ContextualMarkDispatch_2;
+            } mark;
+            """
+        )
+
+        lookup = feature.statements[-3].lookup
+        assert str(lookup) == (
+            "lookup ContextualMarkDispatch_0 {\n"
+            "    lookupflag UseMarrkFilteringSet [twodotshorizontalbelow];\n"
+            "    # reh-ar * behDotess-ar.medi &\n"
+            "    pos reh-ar [behDotless-ar.init] behDotess-ar.medi"
+            " [dotbelow-ar twodotsverticalbelow-ar twodotshorizontalbelow-ar]'"
+            " lookup ContextualMark_0; # *bottom.twodots\n"
+            "} ContextualMarkDispatch_0;\n"
+        )
+
+        lookup = feature.statements[-2].lookup
+        assert str(lookup) == (
+            "lookup ContextualMarkDispatch_1 {\n"
+            "    lookupflag UseMarrkFilteringSet [twodotsverticalbelow];\n"
+            "    # reh-ar *\n"
+            "    pos reh-ar [behDotless-ar.init behDotless-ar.init.alt]"
+            " [dotbelow-ar twodotsverticalbelow-ar twodotshorizontalbelow-ar]'"
+            " lookup ContextualMark_1; # *bottom.vtwodots\n"
+            "} ContextualMarkDispatch_1;\n"
+        )
+
+        lookup = feature.statements[-1].lookup
+        assert str(lookup) == (
+            "lookup ContextualMarkDispatch_2 {\n"
+            "    # reh-ar *\n"
+            "    pos reh-ar [behDotless-ar.init]"
+            " [dotbelow-ar twodotsverticalbelow-ar twodotshorizontalbelow-ar]'"
+            " lookup ContextualMark_2; # *bottom\n"
+            "} ContextualMarkDispatch_2;\n"
+        )
+
+    def test_contextual_anchors_no_mark_feature(self, testufo):
+        a = testufo["a"]
+
+        a.appendAnchor({"name": "*top", "x": 200, "y": 200, "identifier": "*top"})
+        a.lib[OBJECT_LIBS_KEY] = {
+            "*top": {
+                "GPOS_Context": "f *",
+            },
+        }
+
+        writer = MarkFeatureWriter(features=["mkmk"])
+        feaFile = ast.FeatureFile()
+        assert str(feaFile) == ""
+        assert writer.write(testufo, feaFile)
+
+        assert str(feaFile) == dedent(
+            """\
+            markClass acutecomb <anchor 100 200> @MC_top;
+            markClass tildecomb <anchor 100 200> @MC_top;
+
+            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;
+            """
+        )
+
+        writer = MarkFeatureWriter()
+        feaFile = ast.FeatureFile()
+        assert str(feaFile) == ""
+        assert writer.write(testufo, feaFile)
+
+        assert str(feaFile) == dedent(
+            """\
+            markClass acutecomb <anchor 100 200> @MC_top;
+            markClass tildecomb <anchor 100 200> @MC_top;
+
+            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;
+
+            lookup ContextualMark_0 {
+                pos base a
+                    <anchor 200 200> mark @MC_top;
+            } ContextualMark_0;
+
+            lookup ContextualMarkDispatch_0 {
+                # f *
+                pos f [a] [acutecomb tildecomb]' lookup ContextualMark_0; # *top
+            } ContextualMarkDispatch_0;
+
+            feature mark {
+                lookup mark2base;
+                lookup mark2liga;
+                lookup ContextualMarkDispatch_0;
+            } 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_ignorable_anchors(self, FontClass):
+        dirname = os.path.dirname(os.path.dirname(__file__))
+        fontPath = os.path.join(dirname, "data", "IgnoreAnchorsTest-Thin.ufo")
+        testufo = FontClass(fontPath)
+
+        writer = MarkFeatureWriter()
+        feaFile = ast.FeatureFile()
+        assert str(feaFile) == ""
+        assert writer.write(testufo, feaFile)
+
+        assert len(feaFile.markClasses) == 1
+        assert "MC_top" in feaFile.markClasses
+
+        feature = feaFile.statements[-2]
+        assert feature.name == "mark"
+        assert len(feature.statements) == 1
+
+        lookup = feature.statements[0]
+        assert len(lookup.statements) == 4
+        for statement in lookup.statements:
+            assert isinstance(statement, ast.MarkBasePosStatement)
+            assert len(statement.marks) == 1
+            assert statement.marks[0][1].name == "MC_top"
+
 
 if __name__ == "__main__":
     import sys