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];
Confirmed against
main@ 56401be (fetched 2026-07-28). Affectsmake_lookup_wrappers(lines 617–638) andmake_mkmk_lookup(lines 721–745).Behavior
make_mkmk_lookupfilters eachMKMK_MARK_<family>lookup withlookupflag MarkAttachmentType @MC_<family>._MarkAttachmentType_selects on theGDEFmark attachment class, of which a glyph has exactly one — set by its attaching_anchor. AMarkMarkPoslookup (GPOStype 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
_anchorof family Y: the mark belongs to@MC_Y, is invisible toMarkAttachmentType @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:
MarkAttachmentType @MC_bottomARhides B6;MarkAttachmentType @MC_topARhides SA/GR/C0. Below-marks do not stack under B6; above-marks do not stack on SA/GR/C0.Fix
Filter mkmk lookups with
UseMarkFilteringSetinstead ofMarkAttachmentType. 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.The original
make_lookup_wrappers(..., mkmk=True)call at the top ofmake_mkmk_lookupmoves 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 toMarkAttachmentType @MC_<family>— so existing families are unaffected in behavior. Myriad Arabic, for example, has zero cross-family base marks across all four of itsmkmklookups. 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):