Skip to content

markFeatureWriter: mkmk MarkAttachmentType hides base marks whose _anchor family differs from the lookup family #35

Description

@pauldhunt

Confirmed against main @ 56401be (fetched 2026-07-28). Affects make_lookup_wrappers (lines 617–638) and make_mkmk_lookup (lines 721–745).

Behavior

make_mkmk_lookup filters each MKMK_MARK_<family> lookup with lookupflag MarkAttachmentType @MC_<family>. _MarkAttachmentType_ selects on the GDEF mark attachment class, of which a glyph has exactly one — set by its attaching _anchor. A MarkMarkPos lookup (GPOS type 6) fires only when both the stacking mark and the base mark it lands on are visible to the lookup flag.

This is correct only when every base mark a lookup stacks onto attaches in that lookup's own family. It fails when a mark carries a base anchor of family X while attaching via an _anchor of family Y: the mark belongs to @MC_Y, is invisible to MarkAttachmentType @MC_X, and its mark-to-mark stack silently never fires. The feature compiles without error and emits no diagnostic.

Reproduction

A mark glyph carries a base anchor of one family and an attaching _anchor of another. Concrete cases from an Urdu build:

glyph attaching _anchor → markClass base anchor → mkmk lookup that lists it
B6 _commaAR → @MC_commaAR bottomAR → MKMK_MARK_bottomAR
SA, GR _strokeAR → @MC_strokeAR topAR → MKMK_MARK_topAR
C0 _centerAR → @MC_centerAR topAR → MKMK_MARK_topAR

MarkAttachmentType @MC_bottomAR hides B6; MarkAttachmentType @MC_topAR hides SA/GR/C0. Below-marks do not stack under B6; above-marks do not stack on SA/GR/C0.

Fix

Filter mkmk lookups with UseMarkFilteringSet instead of MarkAttachmentType. A glyph may belong to many filtering sets while retaining its single attachment class, so the set can be widened on the base side without disturbing any mark's attaching identity. The set is @MC_<family> ∪ the base marks the lookup stacks onto.

# make_lookup_wrappers
-    def make_lookup_wrappers(self, anchor_name, lookup_prefix, mkmk=False):
+    def make_lookup_wrappers(self, anchor_name, lookup_prefix, mkmk=False,
+                             mkmk_filter=None):
         . . .
         if mkmk:
-            lookup_flag.append(f'MarkAttachmentType @MC_{anchor_name}')
+            # UseMarkFilteringSet (not MarkAttachmentType): a glyph has only one
+            # GDEF attachment class (from its `_anchor`), so MarkAttachmentType
+            # hides any base mark whose `_anchor` family differs from this
+            # lookup's family, killing its stack. A filtering set can include
+            # those base marks while leaving each mark's attaching identity intact.
+            filter_class = mkmk_filter or f'@MC_{anchor_name}'
+            lookup_flag.append(f'UseMarkFilteringSet {filter_class}')
# make_mkmk_lookup — build the set before opening the lookup, then pass it in
         pos_to_gname.sort(key=lambda x: self.glyph_order[x[1][0]])
+
+        # Set = @MC_<family> ∪ every base mark this lookup stacks onto. Base
+        # marks whose own `_anchor` family differs from anchor_name are NOT in
+        # @MC_<family>, so they must be named explicitly or the stack dies.
+        base_gnames = self.sort_gnames(
+            [g for _, g_list in pos_to_gname for g in g_list])
+        mkmk_filter = f'[@MC_{anchor_name} {" ".join(base_gnames)}]'
+
+        open_lookup, close_lookup = self.make_lookup_wrappers(
+            anchor_name, 'MKMK_MARK_', mkmk=True, mkmk_filter=mkmk_filter)

The original make_lookup_wrappers(..., mkmk=True) call at the top of make_mkmk_lookup moves down to after the set is built (shown above).

Compatibility

Where no cross-family base marks exist, the emitted set is [@MC_<family> <marks that are already @MC_<family> members>], which is behaviorally identical to MarkAttachmentType @MC_<family> — so existing families are unaffected in behavior. Myriad Arabic, for example, has zero cross-family base marks across all four of its mkmk lookups. Behavior changes only where the previous flag was silently dropping a mark. Generated FEA bytes change for every mkmk lookup (the flag keyword and its operand), so comparefamily/QE runs will surface the swap.

Emitted output, topAR (Urdu):

  • lookupflag RightToLeft MarkAttachmentType @MC_topAR; # SA/GR/C0 invisible → dead stack
  • lookupflag RightToLeft UseMarkFilteringSet [@MC_topAR arTahA … SA GR C0];

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions