Repository: googlefonts/ufo2ft
Pull request #636 (2022-07-20 14:43:59)

Split kerning by script, not by direction
Currently we split kerning into lookups based on a single factor, horizontal direction. However, shaping engines will perform script segmentation and so there will never be any cross-script kerning. By splitting the kerning into lookups based on the script of the glyphs involved, we can produce smaller lookups for large multi-script fonts, hopefully causing less overflows (faster compilation) and reducing file space by giving the binary compiler a better starting point for splitting lookups into subtables.

There are a few failsafes, such as glyphs without identifiable scripts (as well as purely common-script glyphs) go into a "Common" pot which is added to DFLT/dflt.

This may be easiest to review commit by commit; the changes are fairly small and self-contained apart from f56eaf6 which is the big rewrite.

behdad: > There are a few failsafes, such as glyphs without identifiable scripts (as well as purely common-script glyphs) go into a "Common" pot which is added to DFLT/dflt.

They are added to all other lookups as well, right?

The main deficiency I see in this PR is lack of support for Script_Extensions data-file. That's not a huge deal though. If glyphs with script A and B are kerned and you insert that kern in both lookups for A and for B, that handles most of the cases already.

Patch:
diff --git a/Lib/ufo2ft/constants.py b/Lib/ufo2ft/constants.py
index de033e5..0c8327f 100644
--- a/Lib/ufo2ft/constants.py
+++ b/Lib/ufo2ft/constants.py
@@ -38,6 +38,8 @@ OPENTYPE_META_KEY = "public.openTypeMeta"
 
 UNICODE_VARIATION_SEQUENCES_KEY = "public.unicodeVariationSequences"
 
+COMMON_SCRIPT = "Zyyy"
+
 INDIC_SCRIPTS = [
     "Beng",  # Bengali
     "Cham",  # Cham
diff --git a/Lib/ufo2ft/featureWriters/kernFeatureWriter.py b/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
index a071eff..66bfd0f 100644
--- a/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
@@ -1,10 +1,12 @@
+import itertools
+import logging
 from types import SimpleNamespace
 
 from fontTools import unicodedata
 
-from ufo2ft.constants import INDIC_SCRIPTS, USE_SCRIPTS
+from ufo2ft.constants import COMMON_SCRIPT, INDIC_SCRIPTS, USE_SCRIPTS
 from ufo2ft.featureWriters import BaseFeatureWriter, ast
-from ufo2ft.util import classifyGlyphs, quantize, unicodeScriptDirection
+from ufo2ft.util import DFLT_SCRIPTS, classifyGlyphs, quantize, unicodeScriptDirection
 
 SIDE1_PREFIX = "public.kern1."
 SIDE2_PREFIX = "public.kern2."
@@ -38,13 +40,20 @@ def unicodeBidiType(uv):
 
 class KerningPair:
 
-    __slots__ = ("side1", "side2", "value", "directions", "bidiTypes")
+    __slots__ = ("side1", "side2", "value", "scripts", "directions", "bidiTypes")
 
-    def __init__(self, side1, side2, value, directions=None, bidiTypes=None):
+    def __init__(
+        self, side1, side2, value, scripts=None, directions=None, bidiTypes=None
+    ):
         if isinstance(side1, str):
             self.side1 = ast.GlyphName(side1)
         elif isinstance(side1, ast.GlyphClassDefinition):
             self.side1 = ast.GlyphClassName(side1)
+        elif isinstance(side1, (list, set)):
+            if len(side1) == 1:
+                self.side1 = ast.GlyphName(list(side1)[0])
+            else:
+                self.side1 = ast.GlyphClass([ast.GlyphName(g) for g in sorted(side1)])
         else:
             raise AssertionError(side1)
 
@@ -52,42 +61,150 @@ class KerningPair:
             self.side2 = ast.GlyphName(side2)
         elif isinstance(side2, ast.GlyphClassDefinition):
             self.side2 = ast.GlyphClassName(side2)
+        elif isinstance(side2, (list, set)):
+            if len(side2) == 1:
+                self.side2 = ast.GlyphName(list(side2)[0])
+            else:
+                self.side2 = ast.GlyphClass([ast.GlyphName(g) for g in sorted(side2)])
         else:
             raise AssertionError(side2)
 
         self.value = value
+        self.scripts = scripts or set()
         self.directions = directions or set()
         self.bidiTypes = bidiTypes or set()
 
+    def partitionByScript(self, glyphScripts):
+        """Split a potentially mixed-script pair into pairs that make sense based
+        on the dominant script, and yield each combination with its dominant script."""
+
+        # First, partition the pair by their assigned scripts
+        allFirstScripts = {}
+        allSecondScripts = {}
+        for g in self.firstGlyphs:
+            if g not in glyphScripts:
+                glyphScripts[g] = set([COMMON_SCRIPT])
+            allFirstScripts.setdefault(tuple(glyphScripts[g]), []).append(g)
+        for g in self.secondGlyphs:
+            if g not in glyphScripts:
+                glyphScripts[g] = set([COMMON_SCRIPT])
+            allSecondScripts.setdefault(tuple(glyphScripts[g]), []).append(g)
+
+        # Super common case
+        if (
+            len(allFirstScripts.keys()) == 1
+            and allFirstScripts.keys() == allSecondScripts.keys()
+        ):
+            for script in list(allFirstScripts.keys())[0]:
+                yield script, self
+            return
+
+        # Now let's go through the script combinations
+        for firstScripts, secondScripts in itertools.product(
+            allFirstScripts.keys(), allSecondScripts.keys()
+        ):
+            localPair = KerningPair(
+                sorted(allFirstScripts[firstScripts]),
+                sorted(allSecondScripts[secondScripts]),
+                self.value,
+                scripts=self.scripts,
+                directions=self.directions,
+                bidiTypes=self.bidiTypes,
+            )
+            # Handle very obvious common cases: one script, same on both sides
+            if (
+                len(firstScripts) == 1
+                and len(secondScripts) == 1
+                and firstScripts == secondScripts
+            ):
+                localPair.scripts = set([firstScripts[0]])
+                yield firstScripts[0], localPair
+            # First is single script, second is common
+            elif len(firstScripts) == 1 and set(secondScripts).issubset(DFLT_SCRIPTS):
+                localPair.scripts = set([firstScripts[0]])
+                yield firstScripts[0], localPair
+            # First is common, second is single script
+            elif set(firstScripts).issubset(DFLT_SCRIPTS) and len(secondScripts) == 1:
+                localPair.scripts = set([secondScripts[0]])
+                yield secondScripts[0], localPair
+            # One script and it's different on both sides and it's not common
+            elif len(firstScripts) == 1 and len(secondScripts) == 1:
+                logger = ".".join([self.__class__.__module__, self.__class__.__name__])
+                logging.getLogger(logger).info(
+                    "Mixed script kerning pair %s ignored" % localPair
+                )
+                pass
+            else:
+                # At this point, we have a pair which has different sets of
+                # scripts on each side, and we have to find commonalities.
+                # For example, the pair
+                #   [A A-cy] {Latn, Cyrl}  --  [T Te-cy Tau] {Latn, Cyrl, Grek}
+                # must be split into
+                #   A -- T
+                #   A-cy -- Te-cy
+                # and the Tau ignored.
+                commonScripts = set(firstScripts) & set(secondScripts)
+                commonFirstGlyphs = set()
+                commonSecondGlyphs = set()
+                for scripts, g in allFirstScripts.items():
+                    if commonScripts.issubset(set(scripts)):
+                        commonFirstGlyphs |= set(g)
+                for scripts, g in allSecondScripts.items():
+                    if commonScripts.issubset(set(scripts)):
+                        commonSecondGlyphs |= set(g)
+                for common in commonScripts:
+                    localPair = KerningPair(
+                        commonFirstGlyphs,
+                        commonSecondGlyphs,
+                        self.value,
+                        directions=self.directions,
+                        bidiTypes=self.bidiTypes,
+                        scripts=set([common]),
+                    )
+                    yield common, localPair
+
     @property
     def firstIsClass(self):
-        return isinstance(self.side1, ast.GlyphClassName)
+        return isinstance(self.side1, (ast.GlyphClassName, ast.GlyphClass))
 
     @property
     def secondIsClass(self):
-        return isinstance(self.side2, ast.GlyphClassName)
+        return isinstance(self.side2, (ast.GlyphClassName, ast.GlyphClass))
 
     @property
-    def glyphs(self):
+    def firstGlyphs(self):
         if self.firstIsClass:
-            classDef1 = self.side1.glyphclass
-            glyphs1 = {g.asFea() for g in classDef1.glyphSet()}
+            if isinstance(self.side1, ast.GlyphClassName):
+                classDef1 = self.side1.glyphclass
+            else:
+                classDef1 = self.side1
+            return {g.asFea() for g in classDef1.glyphSet()}
         else:
-            glyphs1 = {self.side1.asFea()}
+            return {self.side1.asFea()}
+
+    @property
+    def secondGlyphs(self):
         if self.secondIsClass:
-            classDef2 = self.side2.glyphclass
-            glyphs2 = {g.asFea() for g in classDef2.glyphSet()}
+            if isinstance(self.side2, ast.GlyphClassName):
+                classDef2 = self.side2.glyphclass
+            else:
+                classDef2 = self.side2
+            return {g.asFea() for g in classDef2.glyphSet()}
         else:
-            glyphs2 = {self.side2.asFea()}
-        return glyphs1 | glyphs2
+            return {self.side2.asFea()}
+
+    @property
+    def glyphs(self):
+        return self.firstGlyphs | self.secondGlyphs
 
     def __repr__(self):
-        return "<{} {} {} {}{}{}>".format(
+        return "<{} {} {} {}{}{}{}>".format(
             self.__class__.__name__,
             self.side1,
             self.side2,
             self.value,
             " %r" % self.directions if self.directions else "",
+            " %r" % self.scripts if self.scripts else "",
             " %r" % self.bidiTypes if self.bidiTypes else "",
         )
 
@@ -116,7 +233,7 @@ class KernFeatureWriter(BaseFeatureWriter):
 
         feaScripts = ast.getScriptLanguageSystems(feaFile)
         ctx.scriptGroups = self._groupScriptsByTagAndDirection(feaScripts)
-
+        ctx.knownScripts = feaScripts.keys()
         return ctx
 
     def shouldContinue(self):
@@ -156,7 +273,7 @@ class KernFeatureWriter(BaseFeatureWriter):
 
         lookupGroups = []
         for _, lookupGroup in sorted(lookups.items()):
-            lookupGroups.extend(lookupGroup)
+            lookupGroups.extend(lookupGroup.values())
 
         self._insert(
             feaFile=feaFile,
@@ -290,99 +407,59 @@ class KernFeatureWriter(BaseFeatureWriter):
             enumerated=enumerated,
         )
 
-    def _makeKerningLookup(
-        self, name, pairs, exclude=None, rtl=False, ignoreMarks=True
-    ):
-        assert pairs
-        rules = []
-        for pair in pairs:
-            if exclude is not None and exclude(pair):
-                self.log.debug("pair excluded from '%s' lookup: %r", name, pair)
-                continue
-            rules.append(
-                self._makePairPosRule(
-                    pair, rtl=rtl, quantization=self.options.quantization
-                )
-            )
+    def _makeKerningLookup(self, name, ignoreMarks=True):
+        lookup = ast.LookupBlock(name)
+        if ignoreMarks and self.options.ignoreMarks:
+            lookup.statements.append(ast.makeLookupFlag("IgnoreMarks"))
+        return lookup
 
-        if rules:
-            lookup = ast.LookupBlock(name)
-            if ignoreMarks and self.options.ignoreMarks:
-                lookup.statements.append(ast.makeLookupFlag("IgnoreMarks"))
-            lookup.statements.extend(rules)
-            return lookup
+    def _addPairToLookup(self, lookup, pair, rtl=False):
+        lookup.statements.append(
+            self._makePairPosRule(pair, rtl=rtl, quantization=self.options.quantization)
+        )
 
-    def _makeKerningLookups(self):
-        cmap = self.makeUnicodeToGlyphNameMapping()
-        if any(unicodeScriptDirection(uv) == "RTL" for uv in cmap):
-            # If there are any characters from globally RTL scripts in the
-            # cmap, we compile a temporary GSUB table to resolve substitutions
-            # and group glyphs by script horizontal direction and bidirectional
-            # type. We then mark each kerning pair with these properties when
-            # any of the glyphs involved in a pair intersects these groups.
-            gsub = self.compileGSUB()
-            dirGlyphs = classifyGlyphs(unicodeScriptDirection, cmap, gsub)
-            directions = self._intersectPairs("directions", dirGlyphs)
-            shouldSplit = "RTL" in directions
-            if shouldSplit:
-                bidiGlyphs = classifyGlyphs(unicodeBidiType, cmap, gsub)
-                self._intersectPairs("bidiTypes", bidiGlyphs)
-        else:
-            shouldSplit = False
+    def knownScriptsPerCodepoint(self, uv):
+        if not self.context.knownScripts:
+            # If there are no languagesystems, consider everything common;
+            # it'll all end in DFLT/dflt anyway
+            return COMMON_SCRIPT
+        return [
+            x
+            for x in unicodedata.script_extension(chr(uv))
+            if x in self.context.knownScripts or x in DFLT_SCRIPTS
+        ]
 
+    def _makeKerningLookups(self):
         marks = self.context.gdefClasses.mark
         lookups = {}
-        if shouldSplit:
-            # make one DFLT lookup with script-agnostic characters, and two
-            # LTR/RTL lookups excluding pairs from the opposite group.
-            # We drop kerning pairs with ambiguous direction: i.e. those containing
-            # glyphs from scripts with different overall horizontal direction, or
-            # glyphs with incompatible bidirectional type (e.g. arabic letters vs
-            # arabic numerals).
-            pairs = []
-            for pair in self.context.kerning.pairs:
-                if ("RTL" in pair.directions and "LTR" in pair.directions) or (
-                    "R" in pair.bidiTypes and "L" in pair.bidiTypes
-                ):
-                    self.log.warning(
-                        "skipped kern pair with ambiguous direction: %r", pair
-                    )
-                    continue
-                pairs.append(pair)
-            if not pairs:
-                return lookups
-
-            if self.options.ignoreMarks:
-                # If there are pairs with a mix of mark/base then the IgnoreMarks
-                # flag is unnecessary and should not be set
-                basePairs, markPairs = self._splitBaseAndMarkPairs(pairs, marks)
-                if basePairs:
-                    self._makeSplitDirectionKernLookups(lookups, basePairs)
-                if markPairs:
-                    self._makeSplitDirectionKernLookups(
-                        lookups, markPairs, ignoreMarks=False, suffix="_marks"
-                    )
-            else:
-                self._makeSplitDirectionKernLookups(lookups, pairs)
+        cmap = self.makeUnicodeToGlyphNameMapping()
+        gsub = self.compileGSUB()
+        dirGlyphs = classifyGlyphs(unicodeScriptDirection, cmap, gsub)
+        self._intersectPairs("directions", dirGlyphs)
+
+        scriptGlyphs = classifyGlyphs(self.knownScriptsPerCodepoint, cmap, gsub)
+        self._intersectPairs("scripts", scriptGlyphs)
+        bidiGlyphs = classifyGlyphs(unicodeBidiType, cmap, gsub)
+        self._intersectPairs("bidiTypes", bidiGlyphs)
+        pairs = self.context.kerning.pairs
+
+        glyphScripts = {}
+        for script, glyphs in scriptGlyphs.items():
+            for g in glyphs:
+                glyphScripts.setdefault(g, set()).add(script)
+
+        if self.options.ignoreMarks:
+            basePairs, markPairs = self._splitBaseAndMarkPairs(
+                self.context.kerning.pairs, marks
+            )
+            if basePairs:
+                self._makeSplitScriptKernLookups(lookups, basePairs, glyphScripts)
+            if markPairs:
+                self._makeSplitScriptKernLookups(
+                    lookups, markPairs, glyphScripts, ignoreMarks=False, suffix="_marks"
+                )
         else:
-            # only make a single (implicitly LTR) lookup including all base/base pairs
-            # and a single lookup including all base/mark pairs (if any)
-            pairs = self.context.kerning.pairs
-            if self.options.ignoreMarks:
-                basePairs, markPairs = self._splitBaseAndMarkPairs(pairs, marks)
-                lookups["LTR"] = []
-                if basePairs:
-                    lookups["LTR"].append(
-                        self._makeKerningLookup("kern_ltr", basePairs)
-                    )
-                if markPairs:
-                    lookups["LTR"].append(
-                        self._makeKerningLookup(
-                            "kern_ltr_marks", markPairs, ignoreMarks=False
-                        )
-                    )
-            else:
-                lookups["LTR"] = [self._makeKerningLookup("kern_ltr", pairs)]
+            self._makeSplitScriptKernLookups(lookups, pairs, glyphScripts)
         return lookups
 
     def _splitBaseAndMarkPairs(self, pairs, marks):
@@ -397,111 +474,68 @@ class KernFeatureWriter(BaseFeatureWriter):
             basePairs[:] = pairs
         return basePairs, markPairs
 
-    def _makeSplitDirectionKernLookups(
-        self, lookups, pairs, ignoreMarks=True, suffix=""
+    def _makeSplitScriptKernLookups(
+        self, lookups, pairs, glyphScripts, ignoreMarks=True, suffix=""
     ):
-        dfltKern = self._makeKerningLookup(
-            "kern_dflt" + suffix,
-            pairs,
-            exclude=(lambda pair: {"LTR", "RTL"}.intersection(pair.directions)),
-            rtl=False,
-            ignoreMarks=ignoreMarks,
-        )
-        if dfltKern:
-            lookups.setdefault("DFLT", []).append(dfltKern)
-
-        ltrKern = self._makeKerningLookup(
-            "kern_ltr" + suffix,
-            pairs,
-            exclude=(lambda pair: not pair.directions or "RTL" in pair.directions),
-            rtl=False,
-            ignoreMarks=ignoreMarks,
-        )
-        if ltrKern:
-            lookups.setdefault("LTR", []).append(ltrKern)
-
-        rtlKern = self._makeKerningLookup(
-            "kern_rtl" + suffix,
-            pairs,
-            exclude=(lambda pair: not pair.directions or "LTR" in pair.directions),
-            rtl=True,
-            ignoreMarks=ignoreMarks,
-        )
-        if rtlKern:
-            lookups.setdefault("RTL", []).append(rtlKern)
+        for pair in pairs:
+            for script, splitpair in pair.partitionByScript(glyphScripts):
+                key = "kern_" + script + suffix
+                script_lookups = lookups.setdefault(script, {})
+                lookup = script_lookups.get(key)
+                if not lookup:
+                    lookup = self._makeKerningLookup(
+                        key.replace(COMMON_SCRIPT, "Common"),  # For neatness
+                        ignoreMarks=ignoreMarks,
+                    )
+                    script_lookups[key] = lookup
+                self._addPairToLookup(lookup, splitpair, rtl="RTL" in pair.directions)
 
     def _makeFeatureBlocks(self, lookups):
         features = {}
         if "kern" in self.context.todo:
             kern = ast.FeatureBlock("kern")
-            self._registerKernLookups(kern, lookups)
+            self._registerLookups(kern, lookups)
             if kern.statements:
                 features["kern"] = kern
         if "dist" in self.context.todo:
             dist = ast.FeatureBlock("dist")
-            self._registerDistLookups(dist, lookups)
+            self._registerLookups(dist, lookups)
             if dist.statements:
                 features["dist"] = dist
         return features
 
-    def _registerKernLookups(self, feature, lookups):
-        if "DFLT" in lookups:
-            ast.addLookupReferences(feature, lookups["DFLT"])
-
+    def _registerLookups(self, feature, lookups):
         scriptGroups = self.context.scriptGroups
-        if "dist" in self.context.todo:
-            distScripts = scriptGroups["dist"]
-        else:
-            distScripts = {}
-        kernScripts = scriptGroups.get("kern", {})
-        ltrScripts = kernScripts.get("LTR", [])
-        rtlScripts = kernScripts.get("RTL", [])
-
-        ltrLookups = lookups.get("LTR")
-        rtlLookups = lookups.get("RTL")
-        if ltrLookups and rtlLookups:
-            if ltrScripts and rtlScripts:
-                for script, langs in ltrScripts:
-                    ast.addLookupReferences(feature, ltrLookups, script, langs)
-                for script, langs in rtlScripts:
-                    ast.addLookupReferences(feature, rtlLookups, script, langs)
-            elif ltrScripts:
-                ast.addLookupReferences(feature, rtlLookups, script="DFLT")
-                for script, langs in ltrScripts:
-                    ast.addLookupReferences(feature, ltrLookups, script, langs)
-            elif rtlScripts:
-                ast.addLookupReferences(feature, ltrLookups, script="DFLT")
-                for script, langs in rtlScripts:
-                    ast.addLookupReferences(feature, rtlLookups, script, langs)
-            else:
-                if not (distScripts.get("LTR") and distScripts.get("RTL")):
-                    raise ValueError(
-                        "cannot use DFLT script for both LTR and RTL kern "
-                        "lookups; add 'languagesystems' to features for at "
-                        "least one LTR or RTL script using the kern feature"
-                    )
-        elif ltrLookups:
-            if not (rtlScripts or distScripts):
-                ast.addLookupReferences(feature, ltrLookups)
-            else:
-                ast.addLookupReferences(feature, ltrLookups, script="DFLT")
-                for script, langs in ltrScripts:
-                    ast.addLookupReferences(feature, ltrLookups, script, langs)
-        elif rtlLookups:
-            if not (ltrScripts or distScripts):
-                ast.addLookupReferences(feature, rtlLookups)
-            else:
-                ast.addLookupReferences(feature, rtlLookups, script="DFLT")
-                for script, langs in rtlScripts:
-                    ast.addLookupReferences(feature, rtlLookups, script, langs)
-
-    def _registerDistLookups(self, feature, lookups):
-        scripts = self.context.scriptGroups["dist"]
-        ltrLookups = lookups.get("LTR")
-        if ltrLookups:
-            for script, langs in scripts.get("LTR", []):
-                ast.addLookupReferences(feature, ltrLookups, script, langs)
-        rtlLookups = lookups.get("RTL")
-        if rtlLookups:
-            for script, langs in scripts.get("RTL", []):
-                ast.addLookupReferences(feature, rtlLookups, script, langs)
+        scripts = scriptGroups.get(feature.name, {})
+
+        # Ensure we have kerning for pure common script runs (e.g. ">1")
+        if feature.name == "kern" and COMMON_SCRIPT in lookups:
+            ast.addLookupReferences(
+                feature, lookups[COMMON_SCRIPT].values(), "DFLT", ["dflt"]
+            )
+        if not scripts:
+            return
+
+        # Collapse scripts
+        scripts = scripts.get("LTR", []) + scripts.get("RTL", [])
+        otscript2uniscript = {}
+        for uniscript in lookups.keys():
+            for ot2script in unicodedata.ot_tags_from_script(uniscript):
+                otscript2uniscript[ot2script] = uniscript
+
+        for script, langs in sorted(scripts):
+            if script not in otscript2uniscript:
+                continue
+            uniscript = otscript2uniscript[script]
+            lookups_for_this_script = []
+            if uniscript not in lookups:
+                continue
+            if feature.statements:
+                feature.statements.append(ast.Comment(""))
+            # We have something for this script. First add the default
+            # lookups, then the script-specific ones
+            for dflt_script in DFLT_SCRIPTS:
+                if dflt_script in lookups:
+                    lookups_for_this_script.extend(lookups[dflt_script].values())
+            lookups_for_this_script.extend(lookups[uniscript].values())
+            ast.addLookupReferences(feature, lookups_for_this_script, script, langs)
diff --git a/Lib/ufo2ft/featureWriters/markFeatureWriter.py b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
index 473541e..6e4154d 100644
--- a/Lib/ufo2ft/featureWriters/markFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
@@ -559,7 +559,7 @@ class MarkFeatureWriter(BaseFeatureWriter):
                 # We keep the NamedAnchor if the markClass is allowed in the
                 # current lookup.
                 def include(anchor):
-                    return anchor.markClass.name in markClasses
+                    return anchor.markClass.name in markClasses  # noqa: B023
 
                 filteredAttachment = attachment.filter(include)
                 if filteredAttachment:
diff --git a/Lib/ufo2ft/util.py b/Lib/ufo2ft/util.py
index 87a2b7f..371efc8 100644
--- a/Lib/ufo2ft/util.py
+++ b/Lib/ufo2ft/util.py
@@ -264,12 +264,12 @@ def closeGlyphsOverGSUB(gsub, glyphs):
 
 def classifyGlyphs(unicodeFunc, cmap, gsub=None):
     """'unicodeFunc' is a callable that takes a Unicode codepoint and
-    returns a string denoting some Unicode property associated with the
-    given character (or None if a character is considered 'neutral').
-    'cmap' is a dictionary mapping Unicode codepoints to glyph names.
-    'gsub' is an (optional) fonttools GSUB table object, used to find all
-    the glyphs that are "reachable" via substitutions from the initial
-    sets of glyphs defined in the cmap.
+    returns a string, or collection of strings, denoting some Unicode
+    property associated with the given character (or None if a character
+    is considered 'neutral'). 'cmap' is a dictionary mapping Unicode
+    codepoints to glyph names. 'gsub' is an (optional) fonttools GSUB
+    table object, used to find all the glyphs that are "reachable" via
+    substitutions from the initial sets of glyphs defined in the cmap.
 
     Returns a dictionary of glyph sets associated with the given Unicode
     properties.
@@ -277,11 +277,14 @@ def classifyGlyphs(unicodeFunc, cmap, gsub=None):
     glyphSets = {}
     neutralGlyphs = set()
     for uv, glyphName in cmap.items():
-        key = unicodeFunc(uv)
-        if key is None:
+        key_or_keys = unicodeFunc(uv)
+        if key_or_keys is None:
             neutralGlyphs.add(glyphName)
+        elif isinstance(key_or_keys, (list, set)):
+            for key in key_or_keys:
+                glyphSets.setdefault(key, set()).add(glyphName)
         else:
-            glyphSets.setdefault(key, set()).add(glyphName)
+            glyphSets.setdefault(key_or_keys, set()).add(glyphName)
 
     if gsub is not None:
         if neutralGlyphs:


Test patch:
diff --git a/tests/featureCompiler_test.py b/tests/featureCompiler_test.py
index 56d88b8..1a413ce 100644
--- a/tests/featureCompiler_test.py
+++ b/tests/featureCompiler_test.py
@@ -208,7 +208,11 @@ class FeatureCompilerTest:
                 foo = ast.FeatureBlock("FOO ")
                 foo.statements.append(
                     ast.SingleSubstStatement(
-                        "a", "v", prefix="", suffix="", forceChain=None
+                        [ast.GlyphName("a")],
+                        [ast.GlyphName("v")],
+                        prefix="",
+                        suffix="",
+                        forceChain=None,
                     )
                 )
                 feaFile.statements.append(foo)
diff --git a/tests/featureWriters/kernFeatureWriter_test.py b/tests/featureWriters/kernFeatureWriter_test.py
index a7fe869..0dc0d66 100644
--- a/tests/featureWriters/kernFeatureWriter_test.py
+++ b/tests/featureWriters/kernFeatureWriter_test.py
@@ -6,6 +6,7 @@ import pytest
 from ufo2ft.errors import InvalidFeaturesData
 from ufo2ft.featureCompiler import parseLayoutFeatures
 from ufo2ft.featureWriters import KernFeatureWriter, ast
+from ufo2ft.featureWriters.kernFeatureWriter import KerningPair
 
 from . import FeatureWriterTest
 
@@ -45,6 +46,50 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
     FeatureWriter = KernFeatureWriter
 
+    def test_split_pair(self, caplog):
+        caplog.set_level(logging.INFO)
+        glyphScripts = {
+            "V": {"Latn"},
+            "W": {"Latn"},
+            "gba-nko": {"Nkoo"},
+            "period": {"Zyyy"},
+            "comma-ar": {"Arab", "Nkoo", "Syrc", "Thaa", "Rohg", "Yezi"},
+            "lam-ar": {"Arab"},
+        }
+
+        # Mixed script pairs don't go anywhere
+        pair = KerningPair(["V", "W"], ["gba-nko", "W"], -20)
+        split = dict(pair.partitionByScript(glyphScripts))
+        assert len(split) == 1
+        assert str(split["Latn"]) == "<KerningPair [V W] W -20 {'Latn'}>"
+        assert (
+            "Mixed script kerning pair <KerningPair [V W] gba-nko -20> ignored"
+            in caplog.text
+        )
+
+        # Everyone gets common-script glyphs, but they get it per-script
+        pair = KerningPair(["V", "gba-nko", "W"], "period", -20)
+        split = dict(pair.partitionByScript(glyphScripts))
+        assert len(split) == 2
+        assert "Latn" in split and "Nkoo" in split
+        assert str(split["Latn"]) == "<KerningPair [V W] period -20 {'Latn'}>"
+        assert str(split["Nkoo"]) == "<KerningPair gba-nko period -20 {'Nkoo'}>"
+
+        # But if both sides are common then the output is common
+        pair = KerningPair("period", "period", -20)
+        split = dict(pair.partitionByScript(glyphScripts))
+        assert len(split) == 1
+        assert "Zyyy" in split
+
+        # Glyphs with more than one script get associated with all of the
+        # relevant scripts in the pair
+        pair = KerningPair(["lam-ar", "gba-nko"], ["comma-ar"], -20)
+        split = dict(pair.partitionByScript(glyphScripts))
+        assert len(split) == 2
+        assert "Arab" in split and "Nkoo" in split
+        assert str(split["Arab"]) == "<KerningPair lam-ar comma-ar -20 {'Arab'}>"
+        assert str(split["Nkoo"]) == "<KerningPair gba-nko comma-ar -20 {'Nkoo'}>"
+
     def test_cleanup_missing_glyphs(self, FontClass):
         groups = {
             "public.kern1.A": ["A", "Aacute", "Abreve", "Acircumflex"],
@@ -80,9 +125,10 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         lookups = getLookups(feaFile)
         assert len(lookups) == 1
-        kern_ltr = lookups[0]
-        assert kern_ltr.name == "kern_ltr"
-        rules = getPairPosRules(kern_ltr)
+        kern_lookup = lookups[0]
+        # We have no codepoints defined for these, so they're considered common
+        assert kern_lookup.name == "kern_Common"
+        rules = getPairPosRules(kern_lookup)
         assert len(rules) == 1
         assert str(rules[0]) == "pos @kern1.A @kern2.B 10;"
 
@@ -98,14 +144,16 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         assert str(feaFile) == dedent(
             """\
-            lookup kern_ltr {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos four six -55;
                 pos one six -30;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
             """
         )
@@ -116,13 +164,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         assert str(feaFile) == dedent(
             """\
-            lookup kern_ltr {
+            lookup kern_Common {
                 pos four six -55;
                 pos one six -30;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
             """
         )
@@ -130,7 +180,7 @@ class KernFeatureWriterTest(FeatureWriterTest):
     def test_mark_to_base_kern(self, FontClass):
         font = FontClass()
         for name in ("A", "B", "C"):
-            font.newGlyph(name)
+            font.newGlyph(name).unicode = ord(name)
         font.newGlyph("acutecomb").unicode = 0x0301
         font.kerning.update({("A", "acutecomb"): -55.0, ("B", "C"): -30.0})
 
@@ -146,20 +196,23 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         # default is ignoreMarks=True
         feaFile = self.writeFeatures(font)
+        # Zyyy because no languagesystems defined
         assert str(feaFile) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos B C -30;
-            } kern_ltr;
+            } kern_Common;
 
-            lookup kern_ltr_marks {
+            lookup kern_Common_marks {
                 pos A acutecomb -55;
-            } kern_ltr_marks;
+            } kern_Common_marks;
 
             feature kern {
-                lookup kern_ltr;
-                lookup kern_ltr_marks;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
+                lookup kern_Common_marks;
             } kern;
             """
         )
@@ -167,13 +220,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
         feaFile = self.writeFeatures(font, ignoreMarks=False)
         assert str(feaFile) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Common {
                 pos A acutecomb -55;
                 pos B C -30;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
             """
         )
@@ -199,12 +254,14 @@ class KernFeatureWriterTest(FeatureWriterTest):
         feaFile = self.writeFeatures(font)
         assert str(feaFile) == dedent(
             """
-            lookup kern_ltr_marks {
+            lookup kern_Common_marks {
                 pos A acutecomb -55;
-            } kern_ltr_marks;
+            } kern_Common_marks;
 
             feature kern {
-                lookup kern_ltr_marks;
+                script DFLT;
+                language dflt;
+                lookup kern_Common_marks;
             } kern;
             """
         )
@@ -237,13 +294,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
         expected = existing + dedent(
             """
 
-            lookup kern_ltr {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
             """
         )
@@ -279,13 +338,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         expected = dedent(
             """\
-            lookup kern_ltr {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
 
             feature kern {
@@ -302,13 +363,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
         generated = self.writeFeatures(ufo, mode="append")
         assert str(generated) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
             """
         )
@@ -336,13 +399,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         expected = dedent(
             """\
-            lookup kern_ltr {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
 
             feature kern {
@@ -385,13 +450,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
             } kern;
 
 
-            lookup kern_ltr {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
             """
         )
@@ -402,13 +469,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
         generated = self.writeFeatures(ufo, mode="append")
         assert str(generated) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
             """
         )
@@ -445,13 +514,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
         generated = self.writeFeatures(ufo, mode="append")
         assert str(generated) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
             """
         )
@@ -475,15 +546,17 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         generated = self.writeFeatures(ufo)
 
-        assert str(generated) == dedent(
+        assert dedent(str(generated)) == dedent(
             """
-            lookup kern_rtl {
+            lookup kern_Arab {
                 lookupflag IgnoreMarks;
                 pos four-ar seven-ar -30;
-            } kern_rtl;
+            } kern_Arab;
 
             feature kern {
-                lookup kern_rtl;
+                script arab;
+                language dflt;
+                lookup kern_Arab;
             } kern;
             """
         )
@@ -667,6 +740,11 @@ class KernFeatureWriterTest(FeatureWriterTest):
                 sub reh-ar by reh-ar.fina;
                 language URD;
             } fina;
+
+            feature isol {
+                script arab;
+                sub alef-ar by alef-ar.isol;
+            } isol;
             """
         )
 
@@ -674,36 +752,42 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         newFeatures = self.writeFeatures(ufo, ignoreMarks=False)
 
-        assert str(newFeatures) == dedent(
+        assert dedent(str(newFeatures)) == dedent(
             """\
             @kern1.A = [A Aacute];
             @kern1.reh = [reh-ar zain-ar reh-ar.fina];
             @kern2.alef = [alef-ar alef-ar.isol];
 
-            lookup kern_dflt {
-                pos seven four -25;
-            } kern_dflt;
-
-            lookup kern_ltr {
-                enum pos @kern1.A V -40;
-            } kern_ltr;
-
-            lookup kern_rtl {
+            lookup kern_Arab {
                 pos four-ar seven-ar -30;
                 pos reh-ar.fina lam-ar.init <-80 0 -80 0>;
                 pos @kern1.reh @kern2.alef <-100 0 -100 0>;
-            } kern_rtl;
+            } kern_Arab;
+
+            lookup kern_Latn {
+                enum pos @kern1.A V -40;
+            } kern_Latn;
+
+            lookup kern_Common {
+                pos seven four -25;
+            } kern_Common;
 
             feature kern {
-                lookup kern_dflt;
-                script latn;
+                script DFLT;
                 language dflt;
-                lookup kern_ltr;
-                language TRK;
+                lookup kern_Common;
+
                 script arab;
                 language dflt;
-                lookup kern_rtl;
+                lookup kern_Common;
+                lookup kern_Arab;
                 language URD;
+
+                script latn;
+                language dflt;
+                lookup kern_Common;
+                lookup kern_Latn;
+                language TRK;
             } kern;
             """
         )
@@ -764,6 +848,11 @@ class KernFeatureWriterTest(FeatureWriterTest):
                 language URD;
             } fina;
 
+            feature isol {
+                script arab;
+                sub alef-ar by alef-ar.isol;
+            } isol;
+
             @Bases = [A V Aacute alef-ar reh-ar zain-ar lam-ar
                       alef-ar.isol lam-ar.init reh-ar.fina];
             @Marks = [acutecomb fatha-ar];
@@ -777,49 +866,55 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         newFeatures = self.writeFeatures(ufo)
 
-        assert str(newFeatures) == dedent(
+        assert dedent(str(newFeatures)) == dedent(
             """\
             @kern1.A = [A Aacute];
             @kern1.reh = [reh-ar zain-ar reh-ar.fina];
             @kern2.alef = [alef-ar alef-ar.isol];
 
-            lookup kern_dflt {
+            lookup kern_Arab {
                 lookupflag IgnoreMarks;
-                pos seven four -25;
-            } kern_dflt;
+                pos four-ar seven-ar -30;
+                pos reh-ar.fina lam-ar.init <-80 0 -80 0>;
+                pos @kern1.reh @kern2.alef <-100 0 -100 0>;
+            } kern_Arab;
 
-            lookup kern_ltr {
+            lookup kern_Arab_marks {
+                pos reh-ar fatha-ar <80 0 80 0>;
+            } kern_Arab_marks;
+
+            lookup kern_Latn {
                 lookupflag IgnoreMarks;
                 enum pos @kern1.A V -40;
-            } kern_ltr;
+            } kern_Latn;
 
-            lookup kern_ltr_marks {
+            lookup kern_Latn_marks {
                 pos V acutecomb 70;
-            } kern_ltr_marks;
+            } kern_Latn_marks;
 
-            lookup kern_rtl {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
-                pos four-ar seven-ar -30;
-                pos reh-ar.fina lam-ar.init <-80 0 -80 0>;
-                pos @kern1.reh @kern2.alef <-100 0 -100 0>;
-            } kern_rtl;
-
-            lookup kern_rtl_marks {
-                pos reh-ar fatha-ar <80 0 80 0>;
-            } kern_rtl_marks;
+                pos seven four -25;
+            } kern_Common;
 
             feature kern {
-                lookup kern_dflt;
-                script latn;
+                script DFLT;
                 language dflt;
-                lookup kern_ltr;
-                lookup kern_ltr_marks;
-                language TRK;
+                lookup kern_Common;
+
                 script arab;
                 language dflt;
-                lookup kern_rtl;
-                lookup kern_rtl_marks;
+                lookup kern_Common;
+                lookup kern_Arab;
+                lookup kern_Arab_marks;
                 language URD;
+
+                script latn;
+                language dflt;
+                lookup kern_Common;
+                lookup kern_Latn;
+                lookup kern_Latn_marks;
+                language TRK;
             } kern;
             """
         )
@@ -864,6 +959,11 @@ class KernFeatureWriterTest(FeatureWriterTest):
                 sub reh-ar by reh-ar.fina;
             } fina;
 
+            feature isol {
+                script arab;
+                sub alef-ar by alef-ar.isol;
+            } isol;
+
             @Bases = [alef-ar reh-ar zain-ar lam-ar alef-ar.isol lam-ar.init reh-ar.fina];
             @Marks = [fatha-ar];
             table GDEF {
@@ -876,24 +976,27 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         newFeatures = self.writeFeatures(ufo)
 
-        assert str(newFeatures) == dedent(
+        assert dedent(str(newFeatures)) == dedent(
             """\
             @kern1.reh = [reh-ar zain-ar reh-ar.fina];
             @kern2.alef = [alef-ar alef-ar.isol];
 
-            lookup kern_rtl {
+            lookup kern_Arab {
                 lookupflag IgnoreMarks;
                 pos reh-ar.fina lam-ar.init <-80 0 -80 0>;
                 pos @kern1.reh @kern2.alef <-100 0 -100 0>;
-            } kern_rtl;
+            } kern_Arab;
 
-            lookup kern_rtl_marks {
+            lookup kern_Arab_marks {
                 pos reh-ar fatha-ar <80 0 80 0>;
-            } kern_rtl_marks;
+            } kern_Arab_marks;
 
             feature kern {
-                lookup kern_rtl;
-                lookup kern_rtl_marks;
+                script arab;
+                language dflt;
+                lookup kern_Arab;
+                lookup kern_Arab_marks;
+                language ARA;
             } kern;
             """
         )
@@ -905,25 +1008,27 @@ class KernFeatureWriterTest(FeatureWriterTest):
         ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features)
         generated = self.writeFeatures(ufo)
 
-        assert str(generated) == dedent(
+        assert dedent(str(generated)) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Latn {
                 lookupflag IgnoreMarks;
                 pos A V -40;
-            } kern_ltr;
+            } kern_Latn;
 
-            lookup kern_rtl {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos reh-ar alef-ar <-100 0 -100 0>;
-            } kern_rtl;
+            } kern_Common;
 
             feature kern {
                 script DFLT;
                 language dflt;
-                lookup kern_rtl;
+                lookup kern_Common;
+
                 script latn;
                 language dflt;
-                lookup kern_ltr;
+                lookup kern_Common;
+                lookup kern_Latn;
             } kern;
             """
         )
@@ -932,35 +1037,51 @@ class KernFeatureWriterTest(FeatureWriterTest):
         ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features)
         generated = self.writeFeatures(ufo)
 
-        assert str(generated) == dedent(
+        assert dedent(str(generated)) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Arab {
                 lookupflag IgnoreMarks;
-                pos A V -40;
-            } kern_ltr;
+                pos reh-ar alef-ar <-100 0 -100 0>;
+            } kern_Arab;
 
-            lookup kern_rtl {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
-                pos reh-ar alef-ar <-100 0 -100 0>;
-            } kern_rtl;
+                pos A V -40;
+            } kern_Common;
 
             feature kern {
                 script DFLT;
                 language dflt;
-                lookup kern_ltr;
+                lookup kern_Common;
+
                 script arab;
                 language dflt;
-                lookup kern_rtl;
+                lookup kern_Common;
+                lookup kern_Arab;
             } kern;
             """
         )
 
-    def test_kern_LTR_and_RTL_cannot_use_DFLT(self, FontClass):
+    def test_kern_LTR_and_RTL_no_DFLT(self, FontClass):
         glyphs = {"A": 0x41, "V": 0x56, "reh-ar": 0x631, "alef-ar": 0x627}
         kerning = {("A", "V"): -40, ("reh-ar", "alef-ar"): -100}
         ufo = makeUFO(FontClass, glyphs, kerning=kerning)
-        with pytest.raises(ValueError, match="cannot use DFLT script"):
-            self.writeFeatures(ufo)
+        generated = self.writeFeatures(ufo)
+        assert "\n" + str(generated) == dedent(
+            """
+            lookup kern_Common {
+                lookupflag IgnoreMarks;
+                pos A V -40;
+                pos reh-ar alef-ar <-100 0 -100 0>;
+            } kern_Common;
+
+            feature kern {
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
+            } kern;
+            """
+        )
 
     def test_dist_LTR(self, FontClass):
         glyphs = {"aaMatra_kannada": 0x0CBE, "ailength_kannada": 0xCD6}
@@ -981,32 +1102,24 @@ class KernFeatureWriterTest(FeatureWriterTest):
         ufo = makeUFO(FontClass, glyphs, groups, kerning, features)
         generated = self.writeFeatures(ufo)
 
-        assert str(generated) == dedent(
+        assert dedent(str(generated)) == dedent(
             """\
             @kern1.KND_aaMatra_R = [aaMatra_kannada];
             @kern2.KND_ailength_L = [aaMatra_kannada];
 
-            lookup kern_ltr {
+            lookup kern_Knda {
                 lookupflag IgnoreMarks;
                 pos @kern1.KND_aaMatra_R @kern2.KND_ailength_L 34;
-            } kern_ltr;
+            } kern_Knda;
 
-            feature kern {
-                script DFLT;
-                language dflt;
-                lookup kern_ltr;
-                script latn;
+            feature dist {
+                script knd2;
                 language dflt;
-                lookup kern_ltr;
-            } kern;
+                lookup kern_Knda;
 
-            feature dist {
                 script knda;
                 language dflt;
-                lookup kern_ltr;
-                script knd2;
-                language dflt;
-                lookup kern_ltr;
+                lookup kern_Knda;
             } dist;
             """
         )
@@ -1026,24 +1139,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         assert str(generated) == dedent(
             """
-            lookup kern_rtl {
+            lookup kern_Khar {
                 lookupflag IgnoreMarks;
                 pos u10A1E u10A06 <117 0 117 0>;
-            } kern_rtl;
-
-            feature kern {
-                script DFLT;
-                language dflt;
-                lookup kern_rtl;
-                script arab;
-                language dflt;
-                lookup kern_rtl;
-            } kern;
+            } kern_Khar;
 
             feature dist {
                 script khar;
                 language dflt;
-                lookup kern_rtl;
+                lookup kern_Khar;
             } dist;
             """
         )
@@ -1075,38 +1179,38 @@ class KernFeatureWriterTest(FeatureWriterTest):
         ufo = makeUFO(FontClass, glyphs, groups, kerning, features)
         generated = self.writeFeatures(ufo)
 
-        assert str(generated) == dedent(
+        assert dedent(str(generated)) == dedent(
             """\
             @kern1.KND_aaMatra_R = [aaMatra_kannada];
             @kern2.KND_ailength_L = [aaMatra_kannada];
 
-            lookup kern_ltr {
+            lookup kern_Khar {
                 lookupflag IgnoreMarks;
-                pos @kern1.KND_aaMatra_R @kern2.KND_ailength_L 34;
-            } kern_ltr;
+                pos u10A1E u10A06 <117 0 117 0>;
+            } kern_Khar;
 
-            lookup kern_rtl {
+            lookup kern_Knda {
                 lookupflag IgnoreMarks;
-                pos u10A1E u10A06 <117 0 117 0>;
-            } kern_rtl;
+                pos @kern1.KND_aaMatra_R @kern2.KND_ailength_L 34;
+            } kern_Knda;
 
             feature dist {
-                script knda;
+                script khar;
                 language dflt;
-                lookup kern_ltr;
+                lookup kern_Khar;
+
                 script knd2;
                 language dflt;
-                lookup kern_ltr;
-                script khar;
+                lookup kern_Knda;
+
+                script knda;
                 language dflt;
-                lookup kern_rtl;
+                lookup kern_Knda;
             } dist;
             """
         )
 
-    def test_skip_ambiguous_direction_pair(self, FontClass, caplog):
-        caplog.set_level(logging.ERROR)
-
+    def test_ambiguous_direction_pair(self, FontClass, caplog):
         ufo = FontClass()
         ufo.newGlyph("A").unicode = 0x41
         ufo.newGlyph("one").unicode = 0x31
@@ -1135,9 +1239,42 @@ class KernFeatureWriterTest(FeatureWriterTest):
         with caplog.at_level(logging.WARNING, logger=logger):
             generated = self.writeFeatures(ufo)
 
-        assert not generated
-        assert len(caplog.records) == 5
-        assert "skipped kern pair with ambiguous direction" in caplog.text
+        assert dedent(str(generated)) == dedent(
+            """
+            lookup kern_Arab {
+                lookupflag IgnoreMarks;
+                pos bar bar 1;
+                pos reh-ar one-ar 4;
+            } kern_Arab;
+
+            lookup kern_Latn {
+                lookupflag IgnoreMarks;
+                pos bar A 2;
+                pos bar bar 1;
+            } kern_Latn;
+
+            lookup kern_Common {
+                lookupflag IgnoreMarks;
+                pos yod-hb one 5;
+            } kern_Common;
+
+            feature kern {
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
+
+                script arab;
+                language dflt;
+                lookup kern_Common;
+                lookup kern_Arab;
+
+                script latn;
+                language dflt;
+                lookup kern_Common;
+                lookup kern_Latn;
+            } kern;
+        """
+        )
 
     def test_kern_RTL_and_DFLT_numbers(self, FontClass):
         glyphs = {"four": 0x34, "seven": 0x37, "bet-hb": 0x5D1, "yod-hb": 0x5D9}
@@ -1152,21 +1289,27 @@ class KernFeatureWriterTest(FeatureWriterTest):
         ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features)
         generated = self.writeFeatures(ufo)
 
-        assert str(generated) == dedent(
+        assert dedent(str(generated)) == dedent(
             """
-            lookup kern_dflt {
+            lookup kern_Hebr {
                 lookupflag IgnoreMarks;
-                pos seven four -25;
-            } kern_dflt;
+                pos yod-hb bet-hb <-100 0 -100 0>;
+            } kern_Hebr;
 
-            lookup kern_rtl {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
-                pos yod-hb bet-hb <-100 0 -100 0>;
-            } kern_rtl;
+                pos seven four -25;
+            } kern_Common;
 
             feature kern {
-                lookup kern_dflt;
-                lookup kern_rtl;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
+
+                script hebr;
+                language dflt;
+                lookup kern_Common;
+                lookup kern_Hebr;
             } kern;
             """
         )
@@ -1182,14 +1325,16 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         assert str(feaFile) == dedent(
             """\
-            lookup kern_ltr {
+            lookup kern_Common {
                 lookupflag IgnoreMarks;
                 pos four six -55;
                 pos one six -25;
-            } kern_ltr;
+            } kern_Common;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Common;
             } kern;
             """
         )