Repository: googlefonts/ufo2ft
Pull request #679 (2022-11-14 20:50:05)

Split kerning by script, not by direction (third attempt)
This is a clean-up PR for #667.

This implements:

- Making classifyGlyphs also deal with tuples instead of just lists and sets.
- Making the KernFeatureWriter emit scripts independently of the globally declared languagesystems. This is arguably a separate concern. I removed some tests that no longer make sense.
    - The code now looks at the declared languagesystems _and_ the font's codepoints that have just one script associated with them, as a heuristic for what the font is supposed to support. We don't depend on the designer to add all desired script declarations.
- Having KerningPair deal with just pair information, and deal with scripts and bidi metadata outside. This simplifies the code a lot by collecting concerns in one place.
- A method of not triggering erroneous subtable breaks in otlLib (swallowing kerning) by:
    - Using a simpler kern splitter that passes [kerning validation](https://github.com/daltonmaag/kerning-validator/) and produces no overlapping kerning groups
    - Sorting the kerning pairs (glyph-to-glyph first, then glyph-to-class, class-to-glyph and class-to-class)
- Some more tests to test bidi and script splitting
- Dropping kerning classes from being written to the feature file
- Various code simplifications of the KernFeatureWriter.

Note that for reasons I have not investigated, various Noto Serif Tamil sources have some errant kerning, but they have the same problem with the current mainline kern writer.

Closes https://github.com/googlefonts/ufo2ft/issues/658


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/featureCompiler.py b/Lib/ufo2ft/featureCompiler.py
index 7b5b946..b5e589b 100644
--- a/Lib/ufo2ft/featureCompiler.py
+++ b/Lib/ufo2ft/featureCompiler.py
@@ -281,8 +281,14 @@ class FeatureCompiler(BaseFeatureCompiler):
             if self.featureWriters:
                 featureFile = parseLayoutFeatures(self.ufo, self.feaIncludeDir)
 
+                path = self.ufo.path
                 for writer in self.featureWriters:
-                    writer.write(self.ufo, featureFile, compiler=self)
+                    try:
+                        writer.write(self.ufo, featureFile, compiler=self)
+                    except FeatureLibError:
+                        if path is None:
+                            self._write_temporary_feature_file(featureFile.asFea())
+                        raise
 
                 # stringify AST to get correct line numbers in error messages
                 self.features = featureFile.asFea()
@@ -316,15 +322,16 @@ class FeatureCompiler(BaseFeatureCompiler):
                 addOpenTypeFeaturesFromString(self.ttFont, self.features, filename=path)
             except FeatureLibError:
                 if path is None:
-                    # if compilation fails, create temporary file for inspection
-                    data = self.features.encode("utf-8")
-                    with NamedTemporaryFile(delete=False) as tmp:
-                        tmp.write(data)
-                    logger.error(
-                        "Compilation failed! Inspect temporary file: %r", tmp.name
-                    )
+                    self._write_temporary_feature_file(self.features)
                 raise
 
+    def _write_temporary_feature_file(self, features: str) -> None:
+        # if compilation fails, create temporary file for inspection
+        data = features.encode("utf-8")
+        with NamedTemporaryFile(delete=False) as tmp:
+            tmp.write(data)
+        logger.error("Compilation failed! Inspect temporary file: %r", tmp.name)
+
 
 class MtiFeatureCompiler(BaseFeatureCompiler):
     """Compile OpenType layout tables from MTI feature files using
diff --git a/Lib/ufo2ft/featureWriters/baseFeatureWriter.py b/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
index 3a1bfbb..8e83d0a 100644
--- a/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
@@ -2,6 +2,8 @@ import logging
 from collections import OrderedDict, namedtuple
 from types import SimpleNamespace
 
+from fontTools import unicodedata
+
 from ufo2ft.constants import OPENTYPE_CATEGORIES_KEY
 from ufo2ft.errors import InvalidFeaturesData
 from ufo2ft.featureWriters import ast
@@ -122,8 +124,12 @@ class BaseFeatureWriter:
     def write(self, font, feaFile, compiler=None):
         """Write features and class definitions for this font to a feaLib
         FeatureFile object.
-        Returns True if feature file was modified, False if no new features
-        were generated.
+
+        The main entry point for the FeatureCompiler to any of the
+        FeatureWriters.
+
+        Returns True if feature file was modified, False if no new features were
+        generated.
         """
         self.setContext(font, feaFile, compiler=compiler)
         try:
@@ -386,3 +392,34 @@ class BaseFeatureWriter:
             frozenset(marks),
             frozenset(components),
         )
+
+    def guessFontScripts(self):
+        """Returns a set of scripts the font probably supports.
+
+        This is done by:
+
+        1. Looking at all defined codepoints in a font and remembering the
+           script of any of the codepoints if it is associated with just one
+           script. This would remember the script of U+0780 THAANA LETTER HAA
+           (Thaa) but not U+061F ARABIC QUESTION MARK (multiple scripts).
+        2. Adding explicitly declared `languagesystem` scripts on top.
+        """
+        font = self.context.font
+        glyphSet = self.context.glyphSet
+        feaFile = self.context.feaFile
+        single_scripts = set()
+
+        # First, detect scripts from the codepoints.
+        for glyph in font:
+            if glyph.name not in glyphSet or glyph.unicodes is None:
+                continue
+            for codepoint in glyph.unicodes:
+                scripts = unicodedata.script_extension(chr(codepoint))
+                if len(scripts) == 1:
+                    single_scripts.update(scripts)
+
+        # Then, add explicitly declared languagesystems on top.
+        feaScripts = ast.getScriptLanguageSystems(feaFile)
+        single_scripts.update(feaScripts.keys())
+
+        return single_scripts
diff --git a/Lib/ufo2ft/featureWriters/kernFeatureWriter.py b/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
index a071eff..bc71ee4 100644
--- a/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
@@ -1,10 +1,19 @@
+from __future__ import annotations
+
+import itertools
+import logging
+from dataclasses import dataclass
 from types import SimpleNamespace
+from typing import Iterator, Mapping
 
 from fontTools import unicodedata
+from fontTools.unicodedata import script_horizontal_direction
 
-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
+
+LOGGER = logging.getLogger(__name__)
 
 SIDE1_PREFIX = "public.kern1."
 SIDE2_PREFIX = "public.kern2."
@@ -20,6 +29,9 @@ DIST_ENABLED_SCRIPTS = set(INDIC_SCRIPTS) | set(["Khmr", "Mymr"]) | set(USE_SCRI
 
 RTL_BIDI_TYPES = {"R", "AL"}
 LTR_BIDI_TYPES = {"L", "AN", "EN"}
+AMBIGUOUS_BIDIS = {"R", "L"}
+COMMON_SCRIPTS_SET = {COMMON_SCRIPT}
+COMMON_CLASS_NAME = "Default"
 
 
 def unicodeBidiType(uv):
@@ -36,73 +48,142 @@ def unicodeBidiType(uv):
         return None
 
 
+@dataclass(frozen=True, order=False)
 class KerningPair:
+    __slots__ = ("side1", "side2", "value")
 
-    __slots__ = ("side1", "side2", "value", "directions", "bidiTypes")
-
-    def __init__(self, side1, side2, value, directions=None, bidiTypes=None):
-        if isinstance(side1, str):
-            self.side1 = ast.GlyphName(side1)
-        elif isinstance(side1, ast.GlyphClassDefinition):
-            self.side1 = ast.GlyphClassName(side1)
-        else:
-            raise AssertionError(side1)
+    side1: str | tuple[str, ...]
+    side2: str | tuple[str, ...]
+    value: float
 
-        if isinstance(side2, str):
-            self.side2 = ast.GlyphName(side2)
-        elif isinstance(side2, ast.GlyphClassDefinition):
-            self.side2 = ast.GlyphClassName(side2)
-        else:
-            raise AssertionError(side2)
+    def __lt__(self, other: KerningPair) -> bool:
+        if not isinstance(other, KerningPair):
+            return NotImplemented
 
-        self.value = value
-        self.directions = directions or set()
-        self.bidiTypes = bidiTypes or set()
+        # Sort Kerning pairs so that glyph to glyph comes first, then glyph to
+        # class, class to glyph, and finally class to class. This makes "kerning
+        # exceptions" work, where more specific glyph pair values override less
+        # specific class kerning. NOTE: Since comparisons terminate early, this
+        # is never going to compare a str to a tuple.
+        selfTuple = (self.firstIsClass, self.secondIsClass, self.side1, self.side2)
+        otherTuple = (other.firstIsClass, other.secondIsClass, other.side1, other.side2)
+        return selfTuple < otherTuple
 
     @property
-    def firstIsClass(self):
-        return isinstance(self.side1, ast.GlyphClassName)
+    def firstIsClass(self) -> bool:
+        return isinstance(self.side1, tuple)
 
     @property
-    def secondIsClass(self):
-        return isinstance(self.side2, ast.GlyphClassName)
+    def secondIsClass(self) -> bool:
+        return isinstance(self.side2, tuple)
 
     @property
-    def glyphs(self):
-        if self.firstIsClass:
-            classDef1 = self.side1.glyphclass
-            glyphs1 = {g.asFea() for g in classDef1.glyphSet()}
+    def firstGlyphs(self) -> tuple[str, ...]:
+        if isinstance(self.side1, tuple):
+            return self.side1
         else:
-            glyphs1 = {self.side1.asFea()}
-        if self.secondIsClass:
-            classDef2 = self.side2.glyphclass
-            glyphs2 = {g.asFea() for g in classDef2.glyphSet()}
+            return (self.side1,)
+
+    @property
+    def secondGlyphs(self) -> tuple[str, ...]:
+        if isinstance(self.side2, tuple):
+            return self.side2
         else:
-            glyphs2 = {self.side2.asFea()}
-        return glyphs1 | glyphs2
-
-    def __repr__(self):
-        return "<{} {} {} {}{}{}>".format(
-            self.__class__.__name__,
-            self.side1,
-            self.side2,
-            self.value,
-            " %r" % self.directions if self.directions else "",
-            " %r" % self.bidiTypes if self.bidiTypes else "",
-        )
+            return (self.side2,)
+
+    @property
+    def glyphs(self) -> tuple[str, ...]:
+        return (*self.firstGlyphs, *self.secondGlyphs)
 
 
 class KernFeatureWriter(BaseFeatureWriter):
     """Generates a kerning feature based on groups and rules contained
     in an UFO's kerning data.
 
-    There are currently two possible writing modes:
-    2) "skip" (default) will not write anything if the features are already present;
-    1) "append" will add additional lookups to an existing feature, if present,
-       or it will add a new one at the end of all features.
-
     If the `quantization` argument is given in the filter options, the resulting
     anchors are rounded to the nearest multiple of the quantization value.
+
+    ## Implementation Notes
+
+    The algorithm works like this:
+
+    * Parse GDEF GlyphClassDefinition from UFO features.fea to get the set of
+      "Mark" glyphs (this will be used later to decide whether to add
+      ignoreMarks flag to kern lookups containing pairs between base and mark
+      glyphs).
+    * Get the ordered glyphset for the font, for filtering kerning groups and
+      kernings that reference unknown glyphs.
+    * Determine which scripts the kerning affects (read: "the font most probably
+      supports"), to know which lookups to generate later:
+        * First, determine the unambiguous script associations for each
+          (Unicoded) glyph in the glyphset, as in, glyphs that have a single
+          entry for their Unicode script extensions property;
+        * then, parse the `languagesystem` statements in the provided feature
+          file to add on top.
+    * Compile a Unicode cmap from the UFO and a GSUB table from the features so
+      far, so we can determine:
+        * the script (extensions) for each glyph in the glyphset, including
+          glyphs reachable via substitution, using the fontTools subsetter with
+          its `closure_glyphs` machinery; the scripts are cut down to the ones
+          we think the font supports;
+        * and the bidirectionality class, so we can later filter out kerning
+          pairs that would mix RTL and LTR glyphs, which will not occur in
+          applications. Unicode BiDi classes L, AN and EN are considered L, R
+          and AL are considered R.
+    * Get the kerning groups from the UFO and filter out glyphs not in the
+      glyphset and empty groups. Remember which group a glyph is a member of,
+      for kern1 and kern2, so we can later reconstruct per-script groups.
+    * Get the bare kerning pairs from the UFO, filtering out pairs with unknown
+      groups or glyphs not in the glyphset and (redundant) zero class-to-class
+      kernings and optionally quantizing kerning values.
+    * Start generating lookups. By default, the ignore marks flag is added to
+      each lookup. Kerning pairs that kern bases against marks or marks against
+      marks, according to the glyphs' GDEF category, then get split off into a
+      second lookup without the ignore marks flag.
+    * Go through all kerning pairs and split them up by script, to put them in
+      different lookups. This reduces the size of each lookup compared to
+      splitting by direction, as previously done.
+        * Partition the first and second side of a pair by script and emit only
+          those with the same script (e.g. `a` and `b` are both "Latn", `period`
+          and `period` are both "Default", but `a` and `a-cy` would mix "Latn"
+          and "Cyrl" and are dropped) or those that kern an explicit against a
+          "common" or "inherited" script, e.g. `a` and `period`.
+        * Glyphs can have multiple scripts assigned to them (legitimately, e.g.
+          U+0951 DEVANAGARI STRESS SIGN UDATTA, or for random reasons like
+          having both `sub h by h.sc` and `sub Etaprosgegrammeni by h.sc;`).
+          Only scripts that were determined earlier to be supported by the font
+          will be considered. Usually, we will emit pairs where both sides have
+          the same script and no splitting is necessary. The only mixed script
+          pairs we emit are common or inherited (Zyyy or Zinh) against explicit
+          (e.g. Latn) scripts. A glyph can be part of both for weird reasons, so
+          we always treat any glyph with a common or inherited script as a
+          purely common (not inherited) glyph for bucketing purposes. This
+          avoids creating overlapping groups with the multi-script glyph in a
+          lookup.
+        * Some glyphs may have a script of Zyyy or Zinh but have a disjoint set
+          of explicit scripts as their script extension. By looking only at the
+          script extension, we treat many of them as being part of an explicit
+          script rather than as a common or inherited glyph.
+        * Preserve the type of the kerning pair, so class-to-class kerning stays
+          that way, even when there's only one glyph on each side.
+    * Reconstruct kerning group names for the newly split classes. This is done
+      for debuggability; it makes no difference for the final font binary.
+        * This first looks at the common lookups and then all others, assigning
+          new group names are it goes. A class like `@kern1.A = [A A-cy
+          increment]` may be split up into `@kern1.Latn.A = [A]`, `@kern1.Cyrl.A
+          = [A-cy]` and `@kern1.Default.A = [increment]`. Note: If there is no
+          dedicated Default lookup, common glyph classes like `[period]` might
+          carry the name `@kern1.Grek.foo` if the class was first encountered
+          while going over the Grek lookup.
+    * Discard pairs that mix RTL and LTR BiDi types, because they won't show up
+      in applications due to how Unicode text is split into runs.
+    * Discard empty lookups, if they were created but all their pairs were
+      discarded.
+    * Make a `kern` (and potentially `dist`) feature block and register the
+      lookups for each script. Some scripts need to be registered in the `dist`
+      feature for some shapers to discover them, e.g. Yezi.
+    * Write the new glyph class definitions and then the lookups and feature
+      blocks to the feature file.
     """
 
     tableTag = "GPOS"
@@ -112,10 +193,25 @@ class KernFeatureWriter(BaseFeatureWriter):
     def setContext(self, font, feaFile, compiler=None):
         ctx = super().setContext(font, feaFile, compiler=compiler)
         ctx.gdefClasses = self.getGDEFGlyphClasses()
-        ctx.kerning = self.getKerningData(font, feaFile, self.getOrderedGlyphSet())
+        ctx.glyphSet = self.getOrderedGlyphSet()
+
+        # TODO: Also include substitution information from Designspace rules to
+        # correctly set the scripts of variable substitution glyphs, maybe add
+        # `glyphUnicodeMapping: dict[str, int] | None` to `BaseFeatureCompiler`?
+        cmap = self.makeUnicodeToGlyphNameMapping()
+        gsub = self.compileGSUB()
+        ctx.knownScripts = self.guessFontScripts()
+        scriptGlyphs = classifyGlyphs(self.knownScriptsPerCodepoint, cmap, gsub)
+        bidiGlyphs = classifyGlyphs(unicodeBidiType, cmap, gsub)
+        ctx.bidiGlyphs = bidiGlyphs
 
-        feaScripts = ast.getScriptLanguageSystems(feaFile)
-        ctx.scriptGroups = self._groupScriptsByTagAndDirection(feaScripts)
+        glyphScripts = {}
+        for script, glyphs in scriptGlyphs.items():
+            for g in glyphs:
+                glyphScripts.setdefault(g, set()).add(script)
+        ctx.glyphScripts = glyphScripts
+
+        ctx.kerning = self.getKerningData()
 
         return ctx
 
@@ -124,13 +220,6 @@ class KernFeatureWriter(BaseFeatureWriter):
             self.log.debug("No kerning data; skipped")
             return False
 
-        if "dist" in self.context.todo and "dist" not in self.context.scriptGroups:
-            self.log.debug(
-                "No dist-enabled scripts defined in languagesystem "
-                "statements; dist feature will not be generated"
-            )
-            self.context.todo.remove("dist")
-
         return super().shouldContinue()
 
     def _write(self):
@@ -148,16 +237,14 @@ class KernFeatureWriter(BaseFeatureWriter):
         feaFile = self.context.feaFile
 
         # first add the glyph class definitions
-        side1Classes = self.context.kerning.side1Classes
-        side2Classes = self.context.kerning.side2Classes
-        newClassDefs = []
-        for classes in (side1Classes, side2Classes):
-            newClassDefs.extend([c for _, c in sorted(classes.items())])
+        classDefs = self.context.kerning.classDefs
+        newClassDefs = [c for _, c in sorted(classDefs.items())]
 
         lookupGroups = []
         for _, lookupGroup in sorted(lookups.items()):
-            lookupGroups.extend(lookupGroup)
+            lookupGroups.extend(lookupGroup.values())
 
+        # NOTE: We don't write classDefs because we literalise all classes.
         self._insert(
             feaFile=feaFile,
             classDefs=newClassDefs,
@@ -166,223 +253,130 @@ class KernFeatureWriter(BaseFeatureWriter):
         )
         return True
 
-    @classmethod
-    def getKerningData(cls, font, feaFile=None, glyphSet=None):
-        side1Classes, side2Classes = cls.getKerningClasses(font, feaFile, glyphSet)
-        pairs = cls.getKerningPairs(font, side1Classes, side2Classes, glyphSet)
+    def getKerningData(self):
+        side1Groups, side2Groups = self.getKerningGroups()
+        pairs = self.getKerningPairs(side1Groups, side2Groups)
+        # side(1|2)Classes and classDefs will hold the feaLib AST to write out.
         return SimpleNamespace(
-            side1Classes=side1Classes, side2Classes=side2Classes, pairs=pairs
+            side1Classes={}, side2Classes={}, classDefs={}, pairs=pairs
         )
 
-    @staticmethod
-    def getKerningGroups(font, glyphSet=None):
-        if glyphSet:
-            allGlyphs = set(glyphSet.keys())
-        else:
-            allGlyphs = set(font.keys())
+    def getKerningGroups(self):
+        font = self.context.font
+        allGlyphs = self.context.glyphSet
         side1Groups = {}
         side2Groups = {}
+        side1Membership = {}
+        side2Membership = {}
         for name, members in font.groups.items():
             # prune non-existent or skipped glyphs
-            members = [g for g in members if g in allGlyphs]
+            members = {g for g in members if g in allGlyphs}
             if not members:
                 # skip empty groups
                 continue
             # skip groups without UFO3 public.kern{1,2} prefix
             if name.startswith(SIDE1_PREFIX):
-                side1Groups[name] = members
+                side1Groups[name] = tuple(sorted(members))
+                name_truncated = name[len(SIDE1_PREFIX) :]
+                for member in members:
+                    side1Membership[member] = name_truncated
             elif name.startswith(SIDE2_PREFIX):
-                side2Groups[name] = members
+                side2Groups[name] = tuple(sorted(members))
+                name_truncated = name[len(SIDE2_PREFIX) :]
+                for member in members:
+                    side2Membership[member] = name_truncated
+        self.context.side1Membership = side1Membership
+        self.context.side2Membership = side2Membership
         return side1Groups, side2Groups
 
-    @classmethod
-    def getKerningClasses(cls, font, feaFile=None, glyphSet=None):
-        side1Groups, side2Groups = cls.getKerningGroups(font, glyphSet)
-        side1Classes = ast.makeGlyphClassDefinitions(
-            side1Groups, feaFile, stripPrefix="public."
-        )
-        side2Classes = ast.makeGlyphClassDefinitions(
-            side2Groups, feaFile, stripPrefix="public."
-        )
-        return side1Classes, side2Classes
-
-    @staticmethod
-    def getKerningPairs(font, side1Classes, side2Classes, glyphSet=None):
-        if glyphSet:
-            allGlyphs = set(glyphSet.keys())
-        else:
-            allGlyphs = set(font.keys())
+    def getKerningPairs(self, side1Classes, side2Classes):
+        glyphSet = self.context.glyphSet
+        font = self.context.font
         kerning = font.kerning
+        quantization = self.options.quantization
 
-        pairsByFlags = {}
-        for (side1, side2) in kerning:
-            # filter out pairs that reference missing groups or glyphs
-            if side1 not in side1Classes and side1 not in allGlyphs:
+        kerning = font.kerning
+        result = []
+        for (side1, side2), value in kerning.items():
+            firstIsClass, secondIsClass = (side1 in side1Classes, side2 in side2Classes)
+            # Filter out pairs that reference missing groups or glyphs.
+            if not firstIsClass and side1 not in glyphSet:
+                continue
+            if not secondIsClass and side2 not in glyphSet:
                 continue
-            if side2 not in side2Classes and side2 not in allGlyphs:
+            # Ignore zero-valued class kern pairs. They are the most general
+            # kerns, so they don't override anything else like glyph kerns would
+            # and zero is the default.
+            if firstIsClass and secondIsClass and value == 0:
                 continue
-            flags = (side1 in side1Classes, side2 in side2Classes)
-            pairsByFlags.setdefault(flags, set()).add((side1, side2))
+            if firstIsClass:
+                side1 = side1Classes[side1]
+            if secondIsClass:
+                side2 = side2Classes[side2]
+            value = quantize(value, quantization)
+            result.append(KerningPair(side1, side2, value))
 
-        result = []
-        for flags, pairs in sorted(pairsByFlags.items()):
-            for side1, side2 in sorted(pairs):
-                value = kerning[side1, side2]
-                if all(flags) and value == 0:
-                    # ignore zero-valued class kern pairs
-                    continue
-                firstIsClass, secondIsClass = flags
-                if firstIsClass:
-                    side1 = side1Classes[side1]
-                if secondIsClass:
-                    side2 = side2Classes[side2]
-                result.append(KerningPair(side1, side2, value))
         return result
 
-    def _intersectPairs(self, attribute, glyphSets):
-        allKeys = set()
-        for pair in self.context.kerning.pairs:
-            for key, glyphs in glyphSets.items():
-                if not pair.glyphs.isdisjoint(glyphs):
-                    getattr(pair, attribute).add(key)
-                    allKeys.add(key)
-        return allKeys
-
-    @staticmethod
-    def _groupScriptsByTagAndDirection(feaScripts):
-        # Read scripts/languages defined in feaFile's 'languagesystem'
-        # statements and group them by the feature tag (kern or dist)
-        # they are associated with, and the global script's horizontal
-        # direction (DFLT is excluded)
-        scriptGroups = {}
-        for scriptCode, scriptLangSys in feaScripts.items():
-            if scriptCode:
-                direction = unicodedata.script_horizontal_direction(scriptCode)
-            else:
-                direction = "LTR"
-            if scriptCode in DIST_ENABLED_SCRIPTS:
-                tag = "dist"
-            else:
-                tag = "kern"
-            scriptGroups.setdefault(tag, {}).setdefault(direction, []).extend(
-                scriptLangSys
-            )
-        return scriptGroups
-
-    @staticmethod
-    def _makePairPosRule(pair, rtl=False, quantization=1):
+    def _makePairPosRule(self, pair, side1Classes, side2Classes, rtl=False):
         enumerated = pair.firstIsClass ^ pair.secondIsClass
-        value = quantize(pair.value, quantization)
-        if rtl and "L" in pair.bidiTypes:
-            # numbers are always shaped LTR even in RTL scripts
-            rtl = False
         valuerecord = ast.ValueRecord(
-            xPlacement=value if rtl else None,
+            xPlacement=pair.value if rtl else None,
             yPlacement=0 if rtl else None,
-            xAdvance=value,
+            xAdvance=pair.value,
             yAdvance=0 if rtl else None,
         )
+
+        if pair.firstIsClass:
+            glyphs1 = ast.GlyphClassName(side1Classes[pair.side1])
+        else:
+            glyphs1 = ast.GlyphName(pair.side1)
+        if pair.secondIsClass:
+            glyphs2 = ast.GlyphClassName(side2Classes[pair.side2])
+        else:
+            glyphs2 = ast.GlyphName(pair.side2)
+
         return ast.PairPosStatement(
-            glyphs1=pair.side1,
+            glyphs1=glyphs1,
             valuerecord1=valuerecord,
-            glyphs2=pair.side2,
+            glyphs2=glyphs2,
             valuerecord2=None,
             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
-                )
-            )
-
-        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 _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)
+    def _makeKerningLookup(self, name, ignoreMarks=True):
+        lookup = ast.LookupBlock(name)
+        if ignoreMarks and self.options.ignoreMarks:
+            lookup.statements.append(ast.makeLookupFlag("IgnoreMarks"))
+        return lookup
+
+    def knownScriptsPerCodepoint(self, uv: int) -> set[str]:
+        if not self.context.knownScripts:
+            # If there are no languagesystems and nothing to derive from Unicode
+            # codepoints, consider everything common; it'll all end in DFLT/dflt
+            # anyway.
+            return {COMMON_SCRIPT}
         else:
-            shouldSplit = False
+            script_extension = unicodedata.script_extension(chr(uv))
+            return script_extension & (self.context.knownScripts | 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)
+        pairs = self.context.kerning.pairs
+
+        if self.options.ignoreMarks:
+            basePairs, markPairs = self._splitBaseAndMarkPairs(
+                self.context.kerning.pairs, marks
+            )
+            if basePairs:
+                self._makeSplitScriptKernLookups(lookups, basePairs)
+            if markPairs:
+                self._makeSplitScriptKernLookups(
+                    lookups, markPairs, 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)
         return lookups
 
     def _splitBaseAndMarkPairs(self, pairs, marks):
@@ -397,111 +391,282 @@ class KernFeatureWriter(BaseFeatureWriter):
             basePairs[:] = pairs
         return basePairs, markPairs
 
-    def _makeSplitDirectionKernLookups(
-        self, lookups, pairs, 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,
+    def _makeSplitScriptKernLookups(self, lookups, pairs, ignoreMarks=True, suffix=""):
+        bidiGlyphs = self.context.bidiGlyphs
+        glyphScripts = self.context.glyphScripts
+        kerningPerScript = splitKerning(pairs, glyphScripts)
+
+        classDefs, side1Classes, side2Classes = makeAllGlyphClassDefinitions(
+            kerningPerScript, self.context, self.context.feaFile
         )
-        if rtlKern:
-            lookups.setdefault("RTL", []).append(rtlKern)
+        assert not classDefs.keys() & self.context.kerning.classDefs.keys()
+        self.context.kerning.classDefs.update(classDefs)
+
+        for script, pairs in kerningPerScript.items():
+            scriptLookups = lookups.setdefault(script, {})
+
+            key = f"kern_{script}{suffix}"
+            lookup = scriptLookups.get(key)
+            if not lookup:
+                # For neatness:
+                lookup = self._makeKerningLookup(
+                    key.replace(COMMON_SCRIPT, COMMON_CLASS_NAME),
+                    ignoreMarks=ignoreMarks,
+                )
+                scriptLookups[key] = lookup
+            for pair in pairs:
+                bidiTypes = {
+                    direction
+                    for direction, glyphs in bidiGlyphs.items()
+                    if not set(pair.glyphs).isdisjoint(glyphs)
+                }
+                if bidiTypes.issuperset(AMBIGUOUS_BIDIS):
+                    LOGGER.info(
+                        "Skipping kerning pair <%s %s %s> with ambiguous direction",
+                        pair.side1,
+                        pair.side2,
+                        pair.value,
+                    )
+                    continue
+                scriptIsRtl = script_horizontal_direction(script, "LTR") == "RTL"
+                # Numbers are always shaped LTR even in RTL scripts:
+                pairIsRtl = scriptIsRtl and "L" not in bidiTypes
+                rule = self._makePairPosRule(
+                    pair, side1Classes, side2Classes, pairIsRtl
+                )
+                lookup.statements.append(rule)
+
+        # Clean out empty lookups.
+        for script, scriptLookups in list(lookups.items()):
+            for lookup_name, lookup in list(scriptLookups.items()):
+                if not any(
+                    stmt
+                    for stmt in lookup.statements
+                    if not isinstance(stmt, ast.LookupFlagStatement)
+                ):
+                    del scriptLookups[lookup_name]
+            if not scriptLookups:
+                del lookups[script]
 
     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"])
+    @staticmethod
+    def _registerLookups(
+        feature: ast.FeatureBlock, lookups: dict[str, dict[str, ast.LookupBlock]]
+    ) -> None:
+        # Ensure we have kerning for pure common script runs (e.g. ">1")
+        isKernBlock = feature.name == "kern"
+        if isKernBlock and COMMON_SCRIPT in lookups:
+            ast.addLookupReferences(
+                feature, lookups[COMMON_SCRIPT].values(), "DFLT", ["dflt"]
+            )
 
-        scriptGroups = self.context.scriptGroups
-        if "dist" in self.context.todo:
-            distScripts = scriptGroups["dist"]
+        # Feature blocks use script tags to distinguish what to run for a
+        # Unicode script.
+        #
+        # "Script tags generally correspond to a Unicode script. However, the
+        # associations between them may not always be one-to-one, and the
+        # OpenType script tags are not guaranteed to be the same as Unicode
+        # Script property-value aliases or ISO 15924 script IDs."
+        #
+        # E.g. {"latn": "Latn", "telu": "Telu", "tel2": "Telu"}
+        #
+        # Skip DFLT script because we always take care of it above for `kern`.
+        # It never occurs in `dist`.
+        if isKernBlock:
+            scriptsToReference = lookups.keys() - DIST_ENABLED_SCRIPTS
         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)
+            scriptsToReference = DIST_ENABLED_SCRIPTS.intersection(lookups.keys())
+        for script in sorted(scriptsToReference - DFLT_SCRIPTS):
+            for tag in unicodedata.ot_tags_from_script(script):
+                # Insert line breaks between statements for niceness :).
+                if feature.statements:
+                    feature.statements.append(ast.Comment(""))
+                # We have something for this script. First add the default
+                # lookups, then the script-specific ones
+                lookupsForThisScript = []
+                for dfltScript in DFLT_SCRIPTS:
+                    if dfltScript in lookups:
+                        lookupsForThisScript.extend(lookups[dfltScript].values())
+                lookupsForThisScript.extend(lookups[script].values())
+                # NOTE: We always use the `dflt` language because there is no
+                # language-specific kerning to be derived from UFO (kerning.plist)
+                # sources and we are independent of what's going on in the rest of
+                # the features.fea file.
+                ast.addLookupReferences(feature, lookupsForThisScript, tag, ["dflt"])
+
+
+def splitKerning(pairs, glyphScripts):
+    # Split kerning into per-script buckets, so we can post-process them before
+    # continuing.
+    kerningPerScript = {}
+    for pair in pairs:
+        for script, splitPair in partitionByScript(pair, glyphScripts):
+            kerningPerScript.setdefault(script, []).append(splitPair)
+
+    for pairs in kerningPerScript.values():
+        pairs.sort()
+
+    return kerningPerScript
+
+
+def partitionByScript(
+    pair: KerningPair,
+    glyphScripts: Mapping[str, set[str]],
+) -> Iterator[tuple[str, KerningPair]]:
+    """Split a potentially mixed-script pair into pairs that make sense based
+    on the dominant script, and yield each combination with its dominant script."""
+
+    side1Scripts: dict[str, set[str]] = {}
+    side2Scripts: dict[str, set[str]] = {}
+    for glyph in pair.firstGlyphs:
+        scripts = glyphScripts.get(glyph, DFLT_SCRIPTS)
+        # If a glyph is both common or inherited *and* another script, treat it
+        # as just common (throwing Zyyy and Zinh into the same bucket for
+        # simplicity). This ensures that a pair appears to the shaper exactly
+        # once, as long as every script sees at most 2 lookups (or 3 with mark
+        # lookups, but they contain distinct pairs), the common one and the
+        # script-specific one.
+        if scripts & DFLT_SCRIPTS:
+            scripts = COMMON_SCRIPTS_SET
+        for script in scripts:
+            side1Scripts.setdefault(script, set()).add(glyph)
+    for glyph in pair.secondGlyphs:
+        scripts = glyphScripts.get(glyph, DFLT_SCRIPTS)
+        if scripts & DFLT_SCRIPTS:
+            scripts = COMMON_SCRIPTS_SET
+        for script in scripts:
+            side2Scripts.setdefault(script, set()).add(glyph)
+
+    for firstScript, secondScript in itertools.product(side1Scripts, side2Scripts):
+        # Preserve the type (glyph or class) of each side.
+        localGlyphs: set[str] = set()
+        localSide1: str | tuple[str, ...]
+        localSide2: str | tuple[str, ...]
+        if pair.firstIsClass:
+            localSide1 = tuple(sorted(side1Scripts[firstScript]))
+            localGlyphs.update(localSide1)
+        else:
+            assert len(side1Scripts[firstScript]) == 1
+            (localSide1,) = side1Scripts[firstScript]
+            localGlyphs.add(localSide1)
+        if pair.secondIsClass:
+            localSide2 = tuple(sorted(side2Scripts[secondScript]))
+            localGlyphs.update(localSide2)
+        else:
+            assert len(side2Scripts[secondScript]) == 1
+            (localSide2,) = side2Scripts[secondScript]
+            localGlyphs.add(localSide2)
+
+        if firstScript == secondScript or secondScript == COMMON_SCRIPT:
+            localScript = firstScript
+        elif firstScript == COMMON_SCRIPT:
+            localScript = secondScript
+        # Two different explicit scripts:
+        else:
+            LOGGER.info(
+                "Skipping kerning pair <%s %s %s> with mixed script (%s, %s)",
+                pair.side1,
+                pair.side2,
+                pair.value,
+                firstScript,
+                secondScript,
+            )
+            continue
+
+        yield localScript, KerningPair(
+            localSide1,
+            localSide2,
+            pair.value,
+        )
+
+
+def makeAllGlyphClassDefinitions(kerningPerScript, context, feaFile=None):
+    side1Classes = {}
+    side2Classes = {}
+    side1Membership = context.side1Membership
+    side2Membership = context.side2Membership
+
+    classDefs = {}
+    if feaFile is not None:
+        classNames = {cdef.name for cdef in ast.iterClassDefinitions(feaFile)}
+    else:
+        classNames = set()
+
+    # Generate common class names first so that common classes are correctly
+    # named in other lookups.
+    if COMMON_SCRIPT in kerningPerScript:
+        common_pairs = kerningPerScript[COMMON_SCRIPT]
+        for pair in common_pairs:
+            if pair.firstIsClass and pair.side1 not in side1Classes:
+                addClassDefinition(
+                    "kern1",
+                    pair.side1,
+                    side1Classes,
+                    side1Membership,
+                    classDefs,
+                    classNames,
+                    COMMON_CLASS_NAME,
+                )
+            if pair.secondIsClass and pair.side2 not in side2Classes:
+                addClassDefinition(
+                    "kern2",
+                    pair.side2,
+                    side2Classes,
+                    side2Membership,
+                    classDefs,
+                    classNames,
+                    COMMON_CLASS_NAME,
+                )
+
+    sortedKerningPerScript = sorted(kerningPerScript.items())
+    for script, pairs in sortedKerningPerScript:
+        if script == COMMON_SCRIPT:
+            continue
+        for pair in pairs:
+            if pair.firstIsClass and pair.side1 not in side1Classes:
+                addClassDefinition(
+                    "kern1",
+                    pair.side1,
+                    side1Classes,
+                    side1Membership,
+                    classDefs,
+                    classNames,
+                    script,
+                )
+            if pair.secondIsClass and pair.side2 not in side2Classes:
+                addClassDefinition(
+                    "kern2",
+                    pair.side2,
+                    side2Classes,
+                    side2Membership,
+                    classDefs,
+                    classNames,
+                    script,
+                )
+
+    return classDefs, side1Classes, side2Classes
+
+
+def addClassDefinition(
+    prefix, group, classes, originalMembership, classDefs, classNames, script
+):
+    firstGlyph = next(iter(group))
+    originalGroupName = originalMembership[firstGlyph]
+    groupName = f"{prefix}.{script}.{originalGroupName}"
+    className = ast.makeFeaClassName(groupName, classNames)
+    classNames.add(className)
+    classDef = ast.makeGlyphClassDefinition(className, group)
+    classes[group] = classDefs[className] = classDef
diff --git a/Lib/ufo2ft/featureWriters/markFeatureWriter.py b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
index a7042d1..6e4154d 100644
--- a/Lib/ufo2ft/featureWriters/markFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
@@ -4,7 +4,6 @@ from collections import OrderedDict, defaultdict
 from functools import partial
 
 from fontTools.misc.fixedTools import otRound
-from fontTools.unicodedata import script_extension
 
 from ufo2ft.constants import INDIC_SCRIPTS, USE_SCRIPTS
 from ufo2ft.featureWriters import BaseFeatureWriter, ast
@@ -301,7 +300,6 @@ class MarkFeatureWriter(BaseFeatureWriter):
         ctx.gdefClasses = self.getGDEFGlyphClasses()
         ctx.anchorLists = self._getAnchorLists()
         ctx.anchorPairs = self._getAnchorPairs()
-        ctx.feaScripts = set(ast.getScriptLanguageSystems(feaFile).keys())
 
     def shouldContinue(self):
         if not self.context.anchorPairs:
@@ -824,13 +822,13 @@ class MarkFeatureWriter(BaseFeatureWriter):
         )
         ctx.markToMarkAttachments = self._makeMarkToMarkAttachments()
 
-        abvmGlyphs, notAbvmGlyphs = self._getAbvmGlyphs()
+        abvmGlyphs = self._getAbvmGlyphs()
 
         def isAbvm(glyphName):
             return glyphName in abvmGlyphs
 
         def isNotAbvm(glyphName):
-            return glyphName in notAbvmGlyphs
+            return glyphName not in abvmGlyphs
 
         features = {}
         todo = ctx.todo
@@ -854,43 +852,19 @@ class MarkFeatureWriter(BaseFeatureWriter):
         return features
 
     def _getAbvmGlyphs(self):
-        glyphSet = set(self.getOrderedGlyphSet().keys())
-        scriptsUsingAbvm = self.scriptsUsingAbvm
-        if self.context.feaScripts:
-            # https://github.com/googlefonts/ufo2ft/issues/579 Some characters
-            # can be used in multiple scripts and some of these scripts might
-            # need an abvm feature and some might not, so we filter-out the
-            # abvm scripts that the font does not intend to support.
-            scriptsUsingAbvm = scriptsUsingAbvm & self.context.feaScripts
-        if scriptsUsingAbvm:
-            cmap = self.makeUnicodeToGlyphNameMapping()
-            unicodeIsAbvm = partial(unicodeInScripts, scripts=scriptsUsingAbvm)
-
-            def unicodeIsNotAbvm(uv):
-                return bool(script_extension(chr(uv)) - self.scriptsUsingAbvm)
-
-            if any(unicodeIsAbvm(uv) for uv in cmap):
-                # If there are any characters from Indic/USE/Khmer scripts in
-                # the cmap, we compile a temporary GSUB table to resolve
-                # substitutions and get the set of all the relevant glyphs,
-                # including alternate glyphs.
-                gsub = self.compileGSUB()
-                glyphGroups = classifyGlyphs(unicodeIsAbvm, cmap, gsub)
-                # the 'glyphGroups' dict is keyed by the return value of the
-                # classifying include, so here 'True' means all the
-                # Indic/USE/Khmer glyphs
-                abvmGlyphs = glyphGroups.get(True, set())
-
-                # If a character can be used in Indic/USE/Khmer scripts as well
-                # as other scripts, we want to return it in both 'abvmGlyphs'
-                # (done above) and 'notAbvmGlyphs' (done below) sets.
-                glyphGroups = classifyGlyphs(unicodeIsNotAbvm, cmap, gsub)
-                notAbvmGlyphs = glyphGroups.get(True, set())
-                # Since cmap might not cover all glyphs, we union with the
-                # glyph set.
-                notAbvmGlyphs |= glyphSet - abvmGlyphs
-                return abvmGlyphs, notAbvmGlyphs
-        return set(), glyphSet
+        cmap = self.makeUnicodeToGlyphNameMapping()
+        unicodeIsAbvm = partial(unicodeInScripts, scripts=self.scriptsUsingAbvm)
+        if any(unicodeIsAbvm(uv) for uv in cmap):
+            # If there are any characters from Indic/USE/Khmer scripts in the cmap, we
+            # compile a temporary GSUB table to resolve substitutions and get
+            # the set of all the relevant glyphs, including alternate glyphs.
+            gsub = self.compileGSUB()
+            glyphGroups = classifyGlyphs(unicodeIsAbvm, cmap, gsub)
+            # the 'glyphGroups' dict is keyed by the return value of the
+            # classifying include, so here 'True' means all the Indic/USE/Khmer glyphs
+            return glyphGroups.get(True, set())
+        else:
+            return set()
 
     def _write(self):
         self._pruneUnusedAnchors()
diff --git a/Lib/ufo2ft/util.py b/Lib/ufo2ft/util.py
index 34f5d54..19a6616 100644
--- a/Lib/ufo2ft/util.py
+++ b/Lib/ufo2ft/util.py
@@ -281,12 +281,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.
@@ -297,7 +297,7 @@ def classifyGlyphs(unicodeFunc, cmap, gsub=None):
         key_or_keys = unicodeFunc(uv)
         if key_or_keys is None:
             neutralGlyphs.add(glyphName)
-        elif isinstance(key_or_keys, (list, set)):
+        elif isinstance(key_or_keys, (list, set, tuple)):
             for key in key_or_keys:
                 glyphSets.setdefault(key, set()).add(glyphName)
         else:
diff --git a/tox.ini b/tox.ini
index 98a50ef..79f3af6 100644
--- a/tox.ini
+++ b/tox.ini
@@ -54,3 +54,4 @@ exclude = .git, __pycache__, build, dist, .eggs, .tox, venv, venv*, .venv, .venv
 
 [isort]
 profile = black
+known_first_party = ufo2ft


Test patch:
diff --git a/tests/data/TestFont-CFF-compreffor.ttx b/tests/data/TestFont-CFF-compreffor.ttx
index 9877c3e..49e8a21 100644
--- a/tests/data/TestFont-CFF-compreffor.ttx
+++ b/tests/data/TestFont-CFF-compreffor.ttx
@@ -392,7 +392,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-CFF.ttx b/tests/data/TestFont-CFF.ttx
index 0a916f6..6eec632 100644
--- a/tests/data/TestFont-CFF.ttx
+++ b/tests/data/TestFont-CFF.ttx
@@ -408,7 +408,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-CFF2-cffsubr.ttx b/tests/data/TestFont-CFF2-cffsubr.ttx
index 687b563..7eb0e60 100644
--- a/tests/data/TestFont-CFF2-cffsubr.ttx
+++ b/tests/data/TestFont-CFF2-cffsubr.ttx
@@ -414,7 +414,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-CFF2-compreffor.ttx b/tests/data/TestFont-CFF2-compreffor.ttx
index f8b7b60..d36629f 100644
--- a/tests/data/TestFont-CFF2-compreffor.ttx
+++ b/tests/data/TestFont-CFF2-compreffor.ttx
@@ -407,7 +407,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-CFF2-post3.ttx b/tests/data/TestFont-CFF2-post3.ttx
index 5975f6a..93e056b 100644
--- a/tests/data/TestFont-CFF2-post3.ttx
+++ b/tests/data/TestFont-CFF2-post3.ttx
@@ -389,7 +389,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-NoOptimize-CFF.ttx b/tests/data/TestFont-NoOptimize-CFF.ttx
index 144327b..a0ad563 100644
--- a/tests/data/TestFont-NoOptimize-CFF.ttx
+++ b/tests/data/TestFont-NoOptimize-CFF.ttx
@@ -411,7 +411,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-NoOptimize-CFF2.ttx b/tests/data/TestFont-NoOptimize-CFF2.ttx
index 76b856f..fa52e2d 100644
--- a/tests/data/TestFont-NoOptimize-CFF2.ttx
+++ b/tests/data/TestFont-NoOptimize-CFF2.ttx
@@ -423,7 +423,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-NoOverlaps-CFF-pathops.ttx b/tests/data/TestFont-NoOverlaps-CFF-pathops.ttx
index 8801b2f..f990dd5 100644
--- a/tests/data/TestFont-NoOverlaps-CFF-pathops.ttx
+++ b/tests/data/TestFont-NoOverlaps-CFF-pathops.ttx
@@ -403,7 +403,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-NoOverlaps-CFF.ttx b/tests/data/TestFont-NoOverlaps-CFF.ttx
index 5ebd047..7c32912 100644
--- a/tests/data/TestFont-NoOverlaps-CFF.ttx
+++ b/tests/data/TestFont-NoOverlaps-CFF.ttx
@@ -400,7 +400,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-NoOverlaps-TTF-pathops.ttx b/tests/data/TestFont-NoOverlaps-TTF-pathops.ttx
index 7e912b2..0b30acf 100644
--- a/tests/data/TestFont-NoOverlaps-TTF-pathops.ttx
+++ b/tests/data/TestFont-NoOverlaps-TTF-pathops.ttx
@@ -487,7 +487,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-NoOverlaps-TTF.ttx b/tests/data/TestFont-NoOverlaps-TTF.ttx
index 9632608..07f028f 100644
--- a/tests/data/TestFont-NoOverlaps-TTF.ttx
+++ b/tests/data/TestFont-NoOverlaps-TTF.ttx
@@ -487,7 +487,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-Specialized-CFF.ttx b/tests/data/TestFont-Specialized-CFF.ttx
index ab7c821..419fdc9 100644
--- a/tests/data/TestFont-Specialized-CFF.ttx
+++ b/tests/data/TestFont-Specialized-CFF.ttx
@@ -389,7 +389,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-Specialized-CFF2.ttx b/tests/data/TestFont-Specialized-CFF2.ttx
index 344702e..6871bbc 100644
--- a/tests/data/TestFont-Specialized-CFF2.ttx
+++ b/tests/data/TestFont-Specialized-CFF2.ttx
@@ -401,7 +401,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont-TTF-post3.ttx b/tests/data/TestFont-TTF-post3.ttx
index fbf4798..44ef3ea 100644
--- a/tests/data/TestFont-TTF-post3.ttx
+++ b/tests/data/TestFont-TTF-post3.ttx
@@ -460,7 +460,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/data/TestFont.ttx b/tests/data/TestFont.ttx
index 76dd3b8..dc20d9c 100644
--- a/tests/data/TestFont.ttx
+++ b/tests/data/TestFont.ttx
@@ -485,7 +485,7 @@
     <ScriptList>
       <!-- ScriptCount=1 -->
       <ScriptRecord index="0">
-        <ScriptTag value="DFLT"/>
+        <ScriptTag value="latn"/>
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
diff --git a/tests/featureCompiler_test.py b/tests/featureCompiler_test.py
index 8613d83..71cb83d 100644
--- a/tests/featureCompiler_test.py
+++ b/tests/featureCompiler_test.py
@@ -231,7 +231,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)
@@ -327,13 +331,15 @@ class FeatureCompilerTest:
             } liga;
 
 
-            lookup kern_ltr {
+            lookup kern_Default {
                 lookupflag IgnoreMarks;
                 pos a v -40;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
             """
         )
diff --git a/tests/featureWriters/kernFeatureWriter_test.py b/tests/featureWriters/kernFeatureWriter_test.py
index a7fe869..b40b657 100644
--- a/tests/featureWriters/kernFeatureWriter_test.py
+++ b/tests/featureWriters/kernFeatureWriter_test.py
@@ -2,10 +2,12 @@ import logging
 from textwrap import dedent
 
 import pytest
+from fontTools import unicodedata
 
 from ufo2ft.errors import InvalidFeaturesData
 from ufo2ft.featureCompiler import parseLayoutFeatures
 from ufo2ft.featureWriters import KernFeatureWriter, ast
+from ufo2ft.util import DFLT_SCRIPTS
 
 from . import FeatureWriterTest
 
@@ -73,18 +75,19 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         classDefs = getClassDefs(feaFile)
         assert len(classDefs) == 2
-        assert classDefs[0].name == "kern1.A"
-        assert classDefs[1].name == "kern2.B"
+        assert classDefs[0].name == "kern1.Default.A"
+        assert classDefs[1].name == "kern2.Default.B"
         assert getGlyphs(classDefs[0]) == ["A", "Aacute", "Acircumflex"]
         assert getGlyphs(classDefs[1]) == ["B", "E", "F"]
 
         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_Default"
+        rules = getPairPosRules(kern_lookup)
         assert len(rules) == 1
-        assert str(rules[0]) == "pos @kern1.A @kern2.B 10;"
+        assert str(rules[0]) == "pos @kern1.Default.A @kern2.Default.B 10;"
 
     def test_ignoreMarks(self, FontClass):
         font = FontClass()
@@ -98,14 +101,16 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         assert str(feaFile) == dedent(
             """\
-            lookup kern_ltr {
+            lookup kern_Default {
                 lookupflag IgnoreMarks;
                 pos four six -55;
                 pos one six -30;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
             """
         )
@@ -116,13 +121,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         assert str(feaFile) == dedent(
             """\
-            lookup kern_ltr {
+            lookup kern_Default {
                 pos four six -55;
                 pos one six -30;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
             """
         )
@@ -130,7 +137,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})
 
@@ -148,18 +155,20 @@ class KernFeatureWriterTest(FeatureWriterTest):
         feaFile = self.writeFeatures(font)
         assert str(feaFile) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Latn {
                 lookupflag IgnoreMarks;
                 pos B C -30;
-            } kern_ltr;
+            } kern_Latn;
 
-            lookup kern_ltr_marks {
+            lookup kern_Latn_marks {
                 pos A acutecomb -55;
-            } kern_ltr_marks;
+            } kern_Latn_marks;
 
             feature kern {
-                lookup kern_ltr;
-                lookup kern_ltr_marks;
+                script latn;
+                language dflt;
+                lookup kern_Latn;
+                lookup kern_Latn_marks;
             } kern;
             """
         )
@@ -167,13 +176,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
         feaFile = self.writeFeatures(font, ignoreMarks=False)
         assert str(feaFile) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Latn {
                 pos A acutecomb -55;
                 pos B C -30;
-            } kern_ltr;
+            } kern_Latn;
 
             feature kern {
-                lookup kern_ltr;
+                script latn;
+                language dflt;
+                lookup kern_Latn;
             } kern;
             """
         )
@@ -199,12 +210,14 @@ class KernFeatureWriterTest(FeatureWriterTest):
         feaFile = self.writeFeatures(font)
         assert str(feaFile) == dedent(
             """
-            lookup kern_ltr_marks {
+            lookup kern_Default_marks {
                 pos A acutecomb -55;
-            } kern_ltr_marks;
+            } kern_Default_marks;
 
             feature kern {
-                lookup kern_ltr_marks;
+                script DFLT;
+                language dflt;
+                lookup kern_Default_marks;
             } kern;
             """
         )
@@ -237,13 +250,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
         expected = existing + dedent(
             """
 
-            lookup kern_ltr {
+            lookup kern_Default {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
             """
         )
@@ -279,13 +294,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         expected = dedent(
             """\
-            lookup kern_ltr {
+            lookup kern_Default {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
 
             feature kern {
@@ -302,13 +319,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
         generated = self.writeFeatures(ufo, mode="append")
         assert str(generated) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Default {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
             """
         )
@@ -336,13 +355,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         expected = dedent(
             """\
-            lookup kern_ltr {
+            lookup kern_Default {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
 
             feature kern {
@@ -385,13 +406,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
             } kern;
 
 
-            lookup kern_ltr {
+            lookup kern_Default {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
             """
         )
@@ -402,13 +425,15 @@ class KernFeatureWriterTest(FeatureWriterTest):
         generated = self.writeFeatures(ufo, mode="append")
         assert str(generated) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Default {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
             """
         )
@@ -445,176 +470,220 @@ class KernFeatureWriterTest(FeatureWriterTest):
         generated = self.writeFeatures(ufo, mode="append")
         assert str(generated) == dedent(
             """
-            lookup kern_ltr {
+            lookup kern_Default {
                 lookupflag IgnoreMarks;
                 pos seven six 25;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
             """
         )
 
     def test_arabic_numerals(self, FontClass):
         """Test that arabic numerals (with bidi type AN) are kerned LTR.
-        https://github.com/googlei18n/ufo2ft/issues/198
-        https://github.com/googlei18n/ufo2ft/pull/200
+
+        See:
+
+        * https://github.com/googlei18n/ufo2ft/issues/198
+        * https://github.com/googlei18n/ufo2ft/pull/200
+
+        Additionally, some Arabic numerals are used in more than one script. One
+        approach is to look at other glyphs with distinct script associations
+        and consider the font to be supporting those.
         """
         ufo = FontClass()
         for name, code in [("four-ar", 0x664), ("seven-ar", 0x667)]:
             glyph = ufo.newGlyph(name)
             glyph.unicode = code
         ufo.kerning.update({("four-ar", "seven-ar"): -30})
-        ufo.features.text = dedent(
-            """
-            languagesystem DFLT dflt;
-            languagesystem arab dflt;
+
+        generated = self.writeFeatures(ufo)
+
+        assert (
+            dedent(str(generated))
+            == dedent(
+                """
+            lookup kern_Default {
+                lookupflag IgnoreMarks;
+                pos four-ar seven-ar -30;
+            } kern_Default;
+
+            feature kern {
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
+            } kern;
             """
+            ).lstrip("\n")
         )
 
+        ufo.newGlyph("alef-ar").unicode = 0x627
         generated = self.writeFeatures(ufo)
 
-        assert str(generated) == dedent(
-            """
-            lookup kern_rtl {
+        assert dedent(str(generated)) == dedent(
+            """\
+            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;
             """
         )
 
-    def test__groupScriptsByTagAndDirection(self, FontClass):
-        font = FontClass()
-        font.features.text = dedent(
-            """
+        ufo.features.text = """
             languagesystem DFLT dflt;
-            languagesystem latn dflt;
-            languagesystem latn TRK;
-            languagesystem arab dflt;
-            languagesystem arab URD;
-            languagesystem deva dflt;
-            languagesystem dev2 dflt;
-            languagesystem math dflt;
-            """
-        )
+            languagesystem Thaa dflt;
+        """
+        generated = self.writeFeatures(ufo)
 
-        feaFile = parseLayoutFeatures(font)
-        scripts = ast.getScriptLanguageSystems(feaFile)
-        scriptGroups = KernFeatureWriter._groupScriptsByTagAndDirection(scripts)
+        assert dedent(str(generated)) == dedent(
+            """
+            lookup kern_Arab {
+                lookupflag IgnoreMarks;
+                pos four-ar seven-ar -30;
+            } kern_Arab;
 
-        assert "kern" in scriptGroups
-        assert list(scriptGroups["kern"]["LTR"]) == [
-            ("latn", ["dflt", "TRK "]),
-            ("math", ["dflt"]),
-        ]
-        assert list(scriptGroups["kern"]["RTL"]) == [("arab", ["dflt", "URD "])]
+            lookup kern_Thaa {
+                lookupflag IgnoreMarks;
+                pos four-ar seven-ar -30;
+            } kern_Thaa;
 
-        assert "dist" in scriptGroups
-        assert list(scriptGroups["dist"]["LTR"]) == [
-            ("deva", ["dflt"]),
-            ("dev2", ["dflt"]),
-        ]
+            feature kern {
+                script arab;
+                language dflt;
+                lookup kern_Arab;
 
-    def test_getKerningClasses(self, FontClass):
-        font = FontClass()
-        for i in range(65, 65 + 6):  # A..F
-            font.newGlyph(chr(i))
-        font.groups.update({"public.kern1.A": ["A", "B"], "public.kern2.C": ["C", "D"]})
-        # simulate a name clash between pre-existing class definitions in
-        # feature file, and those generated by the feature writer
-        font.features.text = "@kern1.A = [E F];"
-
-        feaFile = parseLayoutFeatures(font)
-        side1Classes, side2Classes = KernFeatureWriter.getKerningClasses(font, feaFile)
-
-        assert "public.kern1.A" in side1Classes
-        # the new class gets a unique name
-        assert side1Classes["public.kern1.A"].name == "kern1.A_1"
-        assert getGlyphs(side1Classes["public.kern1.A"]) == ["A", "B"]
-
-        assert "public.kern2.C" in side2Classes
-        assert side2Classes["public.kern2.C"].name == "kern2.C"
-        assert getGlyphs(side2Classes["public.kern2.C"]) == ["C", "D"]
-
-    def test_correct_invalid_class_names(self, FontClass):
-        font = FontClass()
-        for i in range(65, 65 + 12):  # A..L
-            font.newGlyph(chr(i))
-        font.groups.update(
-            {
-                "public.kern1.foo$": ["A", "B", "C"],
-                "public.kern1.foo@": ["D", "E", "F"],
-                "@public.kern2.bar": ["G", "H", "I"],
-                "public.kern2.bar&": ["J", "K", "L"],
-            }
-        )
-        font.kerning.update(
-            {
-                ("public.kern1.foo$", "@public.kern2.bar"): 10,
-                ("public.kern1.foo@", "public.kern2.bar&"): -10,
-            }
+                script thaa;
+                language dflt;
+                lookup kern_Thaa;
+            } kern;
+            """
         )
 
-        side1Classes, side2Classes = KernFeatureWriter.getKerningClasses(font)
+        del ufo["alef-ar"]
+        generated = self.writeFeatures(ufo)
 
-        assert side1Classes["public.kern1.foo$"].name == "kern1.foo"
-        assert side1Classes["public.kern1.foo@"].name == "kern1.foo_1"
-        # no valid 'public.kern{1,2}.' prefix, skipped
-        assert "@public.kern2.bar" not in side2Classes
-        assert side2Classes["public.kern2.bar&"].name == "kern2.bar"
+        assert dedent(str(generated)) == dedent(
+            """
+            lookup kern_Thaa {
+                lookupflag IgnoreMarks;
+                pos four-ar seven-ar -30;
+            } kern_Thaa;
 
-    def test_getKerningPairs(self, FontClass):
-        font = FontClass()
-        for i in range(65, 65 + 8):  # A..H
-            font.newGlyph(chr(i))
-        font.groups.update(
-            {
-                "public.kern1.foo": ["A", "B"],
-                "public.kern2.bar": ["C", "D"],
-                "public.kern1.baz": ["E", "F"],
-                "public.kern2.nul": ["G", "H"],
-            }
+            feature kern {
+                script thaa;
+                language dflt;
+                lookup kern_Thaa;
+            } kern;
+            """
         )
-        font.kerning.update(
-            {
-                ("public.kern1.foo", "public.kern2.bar"): 10,
-                ("public.kern1.baz", "public.kern2.bar"): -10,
-                ("public.kern1.foo", "D"): 15,
-                ("A", "public.kern2.bar"): 5,
-                ("G", "H"): -5,
-                # class-class zero-value pairs are skipped
-                ("public.kern1.foo", "public.kern2.nul"): 0,
-            }
+
+    def test_skip_zero_class_kerns(self, FontClass):
+        glyphs = {
+            "A": ord("A"),
+            "B": ord("B"),
+            "C": ord("C"),
+            "D": ord("D"),
+            "E": ord("E"),
+            "F": ord("F"),
+            "G": ord("G"),
+            "H": ord("H"),
+        }
+        groups = {
+            "public.kern1.foo": ["A", "B"],
+            "public.kern2.bar": ["C", "D"],
+            "public.kern1.baz": ["E", "F"],
+            "public.kern2.nul": ["G", "H"],
+        }
+        kerning = {
+            ("public.kern1.foo", "public.kern2.bar"): 10,
+            ("public.kern1.baz", "public.kern2.bar"): -10,
+            ("public.kern1.foo", "D"): 15,
+            ("A", "public.kern2.bar"): 5,
+            ("G", "H"): -5,
+            # class-class zero-value pairs are skipped
+            ("public.kern1.foo", "public.kern2.nul"): 0,
+        }
+        expectation = dedent(
+            """\
+            @kern1.Latn.baz = [E F];
+            @kern1.Latn.foo = [A B];
+            @kern2.Latn.bar = [C D];
+
+            lookup kern_Latn {
+                lookupflag IgnoreMarks;
+                pos G H -5;
+                enum pos A @kern2.Latn.bar 5;
+                enum pos @kern1.Latn.foo D 15;
+                pos @kern1.Latn.foo @kern2.Latn.bar 10;
+                pos @kern1.Latn.baz @kern2.Latn.bar -10;
+            } kern_Latn;
+
+            feature kern {
+                script latn;
+                language dflt;
+                lookup kern_Latn;
+            } kern;
+            """
         )
 
-        s1c, s2c = KernFeatureWriter.getKerningClasses(font)
-        pairs = KernFeatureWriter.getKerningPairs(font, s1c, s2c)
-        assert len(pairs) == 5
+        ufo = makeUFO(FontClass, glyphs, groups, kerning)
+        newFeatures = KernFeatureWriterTest.writeFeatures(ufo)
 
-        assert "G H -5" in repr(pairs[0])
-        assert (pairs[0].firstIsClass, pairs[0].secondIsClass) == (False, False)
-        assert pairs[0].glyphs == {"G", "H"}
+        assert dedent(str(newFeatures)).lstrip("\n") == expectation
 
-        assert "A @kern2.bar 5" in repr(pairs[1])
-        assert (pairs[1].firstIsClass, pairs[1].secondIsClass) == (False, True)
-        assert pairs[1].glyphs == {"A", "C", "D"}
+    def test_kern_uniqueness(self, FontClass):
+        glyphs = {
+            ".notdef": None,
+            "questiondown": 0xBF,
+            "y": 0x79,
+        }
+        groups = {
+            "public.kern1.questiondown": ["questiondown"],
+            "public.kern2.y": ["y"],
+        }
+        kerning = {
+            ("public.kern1.questiondown", "public.kern2.y"): 15,
+            ("public.kern1.questiondown", "y"): 35,
+            ("questiondown", "public.kern2.y"): -35,
+            ("questiondown", "y"): 35,
+        }
+        ufo = makeUFO(FontClass, glyphs, groups, kerning)
 
-        assert "@kern1.foo D 15" in repr(pairs[2])
-        assert (pairs[2].firstIsClass, pairs[2].secondIsClass) == (True, False)
-        assert pairs[2].glyphs == {"A", "B", "D"}
+        newFeatures = self.writeFeatures(ufo)
 
-        assert "@kern1.baz @kern2.bar -10" in repr(pairs[3])
-        assert (pairs[3].firstIsClass, pairs[3].secondIsClass) == (True, True)
-        assert pairs[3].glyphs == {"C", "D", "E", "F"}
+        # The final kerning value for questiondown, y is 35 and all variants
+        # must be present. Ensures the uniqueness filter doesn't filter things
+        # out.
+        assert dedent(str(newFeatures)) == dedent(
+            """\
+            @kern1.Latn.questiondown = [questiondown];
+            @kern2.Latn.y = [y];
 
-        assert "@kern1.foo @kern2.bar 10" in repr(pairs[4])
-        assert (pairs[4].firstIsClass, pairs[4].secondIsClass) == (True, True)
-        assert pairs[4].glyphs == {"A", "B", "C", "D"}
+            lookup kern_Latn {
+                lookupflag IgnoreMarks;
+                pos questiondown y 35;
+                enum pos questiondown @kern2.Latn.y -35;
+                enum pos @kern1.Latn.questiondown y 35;
+                pos @kern1.Latn.questiondown @kern2.Latn.y 15;
+            } kern_Latn;
+
+            feature kern {
+                script latn;
+                language dflt;
+                lookup kern_Latn;
+            } kern;
+            """
+        )
 
     def test_kern_LTR_and_RTL(self, FontClass):
         glyphs = {
@@ -667,6 +736,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 +748,40 @@ 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];
+            @kern1.Arab.reh = [reh-ar reh-ar.fina zain-ar];
+            @kern1.Latn.A = [A Aacute];
+            @kern2.Arab.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;
+                pos @kern1.Arab.reh @kern2.Arab.alef <-100 0 -100 0>;
+            } kern_Arab;
+
+            lookup kern_Latn {
+                enum pos @kern1.Latn.A V -40;
+            } kern_Latn;
+
+            lookup kern_Default {
+                pos seven four -25;
+            } kern_Default;
 
             feature kern {
-                lookup kern_dflt;
-                script latn;
+                script DFLT;
                 language dflt;
-                lookup kern_ltr;
-                language TRK;
+                lookup kern_Default;
+
                 script arab;
                 language dflt;
-                lookup kern_rtl;
-                language URD;
+                lookup kern_Default;
+                lookup kern_Arab;
+
+                script latn;
+                language dflt;
+                lookup kern_Default;
+                lookup kern_Latn;
             } kern;
             """
         )
@@ -724,8 +802,8 @@ class KernFeatureWriterTest(FeatureWriterTest):
             "four-ar": 0x664,
             "seven-ar": 0x667,
             "fatha-ar": 0x64E,
-            # # we also add glyphs without unicode codepoint, but linked to
-            # # an encoded 'character' glyph by some GSUB rule
+            # we also add glyphs without unicode codepoint, but linked to
+            # an encoded 'character' glyph by some GSUB rule
             "alef-ar.isol": None,
             "lam-ar.init": None,
             "reh-ar.fina": None,
@@ -764,6 +842,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 +860,53 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         newFeatures = self.writeFeatures(ufo)
 
-        assert str(newFeatures) == dedent(
+        assert dedent(str(newFeatures)).lstrip("\n") == dedent(
             """\
-            @kern1.A = [A Aacute];
-            @kern1.reh = [reh-ar zain-ar reh-ar.fina];
-            @kern2.alef = [alef-ar alef-ar.isol];
+            @kern1.Arab.reh = [reh-ar reh-ar.fina zain-ar];
+            @kern1.Latn.A = [A Aacute];
+            @kern2.Arab.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.Arab.reh @kern2.Arab.alef <-100 0 -100 0>;
+            } kern_Arab;
+
+            lookup kern_Arab_marks {
+                pos reh-ar fatha-ar <80 0 80 0>;
+            } kern_Arab_marks;
 
-            lookup kern_ltr {
+            lookup kern_Latn {
                 lookupflag IgnoreMarks;
-                enum pos @kern1.A V -40;
-            } kern_ltr;
+                enum pos @kern1.Latn.A V -40;
+            } 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_Default {
                 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_Default;
 
             feature kern {
-                lookup kern_dflt;
-                script latn;
+                script DFLT;
                 language dflt;
-                lookup kern_ltr;
-                lookup kern_ltr_marks;
-                language TRK;
+                lookup kern_Default;
+
                 script arab;
                 language dflt;
-                lookup kern_rtl;
-                lookup kern_rtl_marks;
-                language URD;
+                lookup kern_Default;
+                lookup kern_Arab;
+                lookup kern_Arab_marks;
+
+                script latn;
+                language dflt;
+                lookup kern_Default;
+                lookup kern_Latn;
+                lookup kern_Latn_marks;
             } kern;
             """
         )
@@ -864,6 +951,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,91 +968,67 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         newFeatures = self.writeFeatures(ufo)
 
-        assert str(newFeatures) == dedent(
+        assert dedent(str(newFeatures)).lstrip("\n") == dedent(
             """\
-            @kern1.reh = [reh-ar zain-ar reh-ar.fina];
-            @kern2.alef = [alef-ar alef-ar.isol];
+            @kern1.Arab.reh = [reh-ar reh-ar.fina zain-ar];
+            @kern2.Arab.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;
+                pos @kern1.Arab.reh @kern2.Arab.alef <-100 0 -100 0>;
+            } 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;
             } kern;
             """
         )
 
-    def test_kern_LTR_and_RTL_one_uses_DFLT(self, FontClass):
+    def test_kern_independent_of_languagesystem(self, FontClass):
         glyphs = {"A": 0x41, "V": 0x56, "reh-ar": 0x631, "alef-ar": 0x627}
         kerning = {("A", "V"): -40, ("reh-ar", "alef-ar"): -100}
-        features = "languagesystem latn dflt;"
-        ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features)
+        # No languagesystems decalred.
+        ufo = makeUFO(FontClass, glyphs, kerning=kerning)
         generated = self.writeFeatures(ufo)
 
-        assert str(generated) == dedent(
-            """
-            lookup kern_ltr {
+        expectation = dedent(
+            """\
+            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_Latn {
                 lookupflag IgnoreMarks;
-                pos reh-ar alef-ar <-100 0 -100 0>;
-            } kern_rtl;
+                pos A V -40;
+            } kern_Latn;
 
             feature kern {
-                script DFLT;
+                script arab;
                 language dflt;
-                lookup kern_rtl;
+                lookup kern_Arab;
+
                 script latn;
                 language dflt;
-                lookup kern_ltr;
+                lookup kern_Latn;
             } kern;
             """
         )
+        assert dedent(str(generated)) == expectation
 
         features = dedent("languagesystem arab dflt;")
         ufo = makeUFO(FontClass, glyphs, kerning=kerning, features=features)
         generated = self.writeFeatures(ufo)
 
-        assert str(generated) == dedent(
-            """
-            lookup kern_ltr {
-                lookupflag IgnoreMarks;
-                pos A V -40;
-            } kern_ltr;
-
-            lookup kern_rtl {
-                lookupflag IgnoreMarks;
-                pos reh-ar alef-ar <-100 0 -100 0>;
-            } kern_rtl;
-
-            feature kern {
-                script DFLT;
-                language dflt;
-                lookup kern_ltr;
-                script arab;
-                language dflt;
-                lookup kern_rtl;
-            } kern;
-            """
-        )
-
-    def test_kern_LTR_and_RTL_cannot_use_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)
+        assert dedent(str(generated)).lstrip("\n") == expectation
 
     def test_dist_LTR(self, FontClass):
         glyphs = {"aaMatra_kannada": 0x0CBE, "ailength_kannada": 0xCD6}
@@ -981,32 +1049,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];
+            @kern1.Knda.KND_aaMatra_R = [aaMatra_kannada];
+            @kern2.Knda.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;
+                pos @kern1.Knda.KND_aaMatra_R @kern2.Knda.KND_ailength_L 34;
+            } 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 +1086,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 +1126,38 @@ class KernFeatureWriterTest(FeatureWriterTest):
         ufo = makeUFO(FontClass, glyphs, groups, kerning, features)
         generated = self.writeFeatures(ufo)
 
-        assert str(generated) == dedent(
+        assert dedent(str(generated)).lstrip("\n") == dedent(
             """\
-            @kern1.KND_aaMatra_R = [aaMatra_kannada];
-            @kern2.KND_ailength_L = [aaMatra_kannada];
+            @kern1.Knda.KND_aaMatra_R = [aaMatra_kannada];
+            @kern2.Knda.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.Knda.KND_aaMatra_R @kern2.Knda.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
@@ -1131,13 +1182,14 @@ class KernFeatureWriterTest(FeatureWriterTest):
             """
         )
 
-        logger = "ufo2ft.featureWriters.kernFeatureWriter.KernFeatureWriter"
-        with caplog.at_level(logging.WARNING, logger=logger):
+        with caplog.at_level(logging.INFO):
             generated = self.writeFeatures(ufo)
 
         assert not generated
-        assert len(caplog.records) == 5
-        assert "skipped kern pair with ambiguous direction" in caplog.text
+        assert (
+            len([r for r in caplog.records if "with ambiguous direction" in r.message])
+            == 5
+        )
 
     def test_kern_RTL_and_DFLT_numbers(self, FontClass):
         glyphs = {"four": 0x34, "seven": 0x37, "bet-hb": 0x5D1, "yod-hb": 0x5D9}
@@ -1152,21 +1204,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_Default {
                 lookupflag IgnoreMarks;
-                pos yod-hb bet-hb <-100 0 -100 0>;
-            } kern_rtl;
+                pos seven four -25;
+            } kern_Default;
 
             feature kern {
-                lookup kern_dflt;
-                lookup kern_rtl;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
+
+                script hebr;
+                language dflt;
+                lookup kern_Default;
+                lookup kern_Hebr;
             } kern;
             """
         )
@@ -1182,19 +1240,530 @@ class KernFeatureWriterTest(FeatureWriterTest):
 
         assert str(feaFile) == dedent(
             """\
-            lookup kern_ltr {
+            lookup kern_Default {
                 lookupflag IgnoreMarks;
                 pos four six -55;
                 pos one six -25;
-            } kern_ltr;
+            } kern_Default;
 
             feature kern {
-                lookup kern_ltr;
+                script DFLT;
+                language dflt;
+                lookup kern_Default;
             } kern;
             """
         )
 
 
+def test_kern_split_multi_glyph_class(FontClass):
+    glyphs = {
+        "a": ord("a"),
+        "b": ord("b"),
+        "period": ord("."),
+    }
+    groups = {
+        "public.kern1.foo": ["a", "period"],
+        "public.kern2.foo": ["b", "period"],
+    }
+    kerning = {
+        ("a", "a"): 1,
+        ("a", "b"): 2,
+        ("a", "period"): 3,
+        ("b", "a"): 4,
+        ("b", "b"): 5,
+        ("b", "period"): 6,
+        ("period", "a"): 7,
+        ("period", "b"): 8,
+        ("period", "period"): 9,
+        # Class-to-glyph
+        ("public.kern1.foo", "b"): 10,
+        ("public.kern1.foo", "period"): 11,
+        # Glyph-to-class
+        ("a", "public.kern2.foo"): 12,
+        ("period", "public.kern2.foo"): 13,
+        # Class-to-class
+        ("public.kern1.foo", "public.kern2.foo"): 14,
+    }
+    expectation = dedent(
+        """\
+        @kern1.Default.foo = [period];
+        @kern1.Latn.foo = [a];
+        @kern2.Default.foo = [period];
+        @kern2.Latn.foo = [b];
+
+        lookup kern_Latn {
+            lookupflag IgnoreMarks;
+            pos a a 1;
+            pos a b 2;
+            pos a period 3;
+            pos b a 4;
+            pos b b 5;
+            pos b period 6;
+            pos period a 7;
+            pos period b 8;
+            enum pos a @kern2.Latn.foo 12;
+            enum pos a @kern2.Default.foo 12;
+            enum pos period @kern2.Latn.foo 13;
+            enum pos @kern1.Latn.foo b 10;
+            enum pos @kern1.Latn.foo period 11;
+            enum pos @kern1.Default.foo b 10;
+            pos @kern1.Latn.foo @kern2.Latn.foo 14;
+            pos @kern1.Latn.foo @kern2.Default.foo 14;
+            pos @kern1.Default.foo @kern2.Latn.foo 14;
+        } kern_Latn;
+
+        lookup kern_Default {
+            lookupflag IgnoreMarks;
+            pos period period 9;
+            enum pos period @kern2.Default.foo 13;
+            enum pos @kern1.Default.foo period 11;
+            pos @kern1.Default.foo @kern2.Default.foo 14;
+        } kern_Default;
+
+        feature kern {
+            script DFLT;
+            language dflt;
+            lookup kern_Default;
+
+            script latn;
+            language dflt;
+            lookup kern_Default;
+            lookup kern_Latn;
+        } kern;
+        """
+    )
+
+    ufo = makeUFO(FontClass, glyphs, groups, kerning)
+    newFeatures = KernFeatureWriterTest.writeFeatures(ufo)
+
+    assert dedent(str(newFeatures)).lstrip("\n") == expectation
+
+    # Making a common glyph implicitly have an explicit script assigned (GSUB
+    # closure) will still keep it in the common section.
+    features = dedent(
+        """
+        feature ss01 {
+            sub a by period; # Make period be both Latn and Zyyy.
+        } ss01;
+        """
+    )
+    ufo = makeUFO(FontClass, glyphs, groups, kerning, features)
+    newFeatures = KernFeatureWriterTest.writeFeatures(ufo)
+
+    assert dedent(str(newFeatures)).lstrip("\n") == expectation
+
+
+def test_kern_split_and_drop(FontClass, caplog):
+    glyphs = {
+        "a": ord("a"),
+        "alpha": ord("α"),
+        "a-orya": 0x0B05,
+        "a-cy": 0x0430,
+        "alef-ar": 0x627,
+        "period": ord("."),
+    }
+    groups = {
+        "public.kern1.foo": ["a", "alpha", "a-orya"],
+        "public.kern2.foo": ["a", "alpha", "a-orya"],
+        "public.kern1.bar": ["a-cy", "alef-ar", "period"],
+        "public.kern2.bar": ["a-cy", "alef-ar", "period"],
+    }
+    kerning = {
+        ("public.kern1.foo", "public.kern2.bar"): 20,
+        ("public.kern1.bar", "public.kern2.foo"): 20,
+    }
+
+    ufo = makeUFO(FontClass, glyphs, groups, kerning)
+    with caplog.at_level(logging.INFO):
+        newFeatures = KernFeatureWriterTest.writeFeatures(ufo)
+
+    assert dedent(str(newFeatures)) == dedent(
+        """\
+        @kern1.Grek.bar = [period];
+        @kern1.Grek.foo = [alpha];
+        @kern1.Latn.foo = [a];
+        @kern1.Orya.foo = [a-orya];
+        @kern2.Grek.bar = [period];
+        @kern2.Grek.foo = [alpha];
+        @kern2.Latn.foo = [a];
+        @kern2.Orya.foo = [a-orya];
+
+        lookup kern_Grek {
+            lookupflag IgnoreMarks;
+            pos @kern1.Grek.foo @kern2.Grek.bar 20;
+            pos @kern1.Grek.bar @kern2.Grek.foo 20;
+        } kern_Grek;
+
+        lookup kern_Latn {
+            lookupflag IgnoreMarks;
+            pos @kern1.Latn.foo @kern2.Grek.bar 20;
+            pos @kern1.Grek.bar @kern2.Latn.foo 20;
+        } kern_Latn;
+
+        lookup kern_Orya {
+            lookupflag IgnoreMarks;
+            pos @kern1.Orya.foo @kern2.Grek.bar 20;
+            pos @kern1.Grek.bar @kern2.Orya.foo 20;
+        } kern_Orya;
+
+        feature kern {
+            script grek;
+            language dflt;
+            lookup kern_Grek;
+
+            script latn;
+            language dflt;
+            lookup kern_Latn;
+        } kern;
+
+        feature dist {
+            script ory2;
+            language dflt;
+            lookup kern_Orya;
+
+            script orya;
+            language dflt;
+            lookup kern_Orya;
+        } dist;
+        """
+    )
+
+    msgs = sorted(msg[-30:] for msg in caplog.messages)
+    assert msgs == [
+        "with mixed script (Arab, Grek)",
+        "with mixed script (Arab, Latn)",
+        "with mixed script (Arab, Orya)",
+        "with mixed script (Cyrl, Grek)",
+        "with mixed script (Cyrl, Latn)",
+        "with mixed script (Cyrl, Orya)",
+        "with mixed script (Grek, Arab)",
+        "with mixed script (Grek, Cyrl)",
+        "with mixed script (Latn, Arab)",
+        "with mixed script (Latn, Cyrl)",
+        "with mixed script (Orya, Arab)",
+        "with mixed script (Orya, Cyrl)",
+    ]
+
+
+def test_kern_split_and_drop_mixed(caplog, FontClass):
+    """Test that mixed script pairs don't go anywhere."""
+    glyphs = {"V": ord("V"), "W": ord("W"), "gba-nko": 0x07DC}
+    groups = {"public.kern1.foo": ["V", "W"], "public.kern2.foo": ["gba-nko", "W"]}
+    kerning = {("public.kern1.foo", "public.kern2.foo"): -20}
+    ufo = makeUFO(FontClass, glyphs, groups, kerning)
+    with caplog.at_level(logging.INFO):
+        newFeatures = KernFeatureWriterTest.writeFeatures(ufo)
+
+    assert dedent(str(newFeatures)) == dedent(
+        """\
+        @kern1.Latn.foo = [V W];
+        @kern2.Latn.foo = [W];
+
+        lookup kern_Latn {
+            lookupflag IgnoreMarks;
+            pos @kern1.Latn.foo @kern2.Latn.foo -20;
+        } kern_Latn;
+
+        feature kern {
+            script latn;
+            language dflt;
+            lookup kern_Latn;
+        } kern;
+        """
+    )
+    assert (
+        "Skipping kerning pair <('V', 'W') ('W', 'gba-nko') -20> with mixed script (Latn, Nkoo)"
+        in caplog.text
+    )
+
+
+def test_kern_split_and_mix_common(FontClass):
+    """Test that that everyone gets common-script glyphs, but they get it
+    per-script."""
+    glyphs = {"V": ord("V"), "W": ord("W"), "gba-nko": 0x07DC, "period": ord(".")}
+    groups = {"public.kern1.foo": ["V", "gba-nko", "W"]}
+    kerning = {("public.kern1.foo", "period"): -20}
+    ufo = makeUFO(FontClass, glyphs, groups, kerning)
+    newFeatures = KernFeatureWriterTest.writeFeatures(ufo)
+
+    assert dedent(str(newFeatures)) == dedent(
+        """\
+        @kern1.Latn.foo = [V W];
+        @kern1.Nkoo.foo = [gba-nko];
+
+        lookup kern_Latn {
+            lookupflag IgnoreMarks;
+            enum pos @kern1.Latn.foo period -20;
+        } kern_Latn;
+
+        lookup kern_Nkoo {
+            lookupflag IgnoreMarks;
+            enum pos @kern1.Nkoo.foo period <-20 0 -20 0>;
+        } kern_Nkoo;
+
+        feature kern {
+            script latn;
+            language dflt;
+            lookup kern_Latn;
+
+            script nko;
+            language dflt;
+            lookup kern_Nkoo;
+        } kern;
+        """
+    )
+
+
+def test_kern_keep_common(FontClass):
+    """Test that if both sides are common, the output is common."""
+    glyphs = {"period": ord(".")}
+    kerning = {("period", "period"): -20}
+    ufo = makeUFO(FontClass, glyphs, None, kerning)
+    newFeatures = KernFeatureWriterTest.writeFeatures(ufo)
+
+    assert dedent(str(newFeatures)) == dedent(
+        """\
+        lookup kern_Default {
+            lookupflag IgnoreMarks;
+            pos period period -20;
+        } kern_Default;
+
+        feature kern {
+            script DFLT;
+            language dflt;
+            lookup kern_Default;
+        } kern;
+        """
+    )
+
+
+def test_kern_multi_script(FontClass):
+    """Test that glyphs with more than one script get associated with all of the
+    relevant scripts in the pair."""
+    glyphs = {"gba-nko": 0x07DC, "comma-ar": 0x060C, "lam-ar": 0x0644}
+    groups = {
+        "public.kern1.foo": ["lam-ar", "gba-nko"],
+        "public.kern2.foo": ["comma-ar"],
+    }
+    kerning = {("public.kern1.foo", "public.kern2.foo"): -20}
+    ufo = makeUFO(FontClass, glyphs, groups, kerning)
+    newFeatures = KernFeatureWriterTest.writeFeatures(ufo)
+
+    assert dedent(str(newFeatures)) == dedent(
+        """\
+        @kern1.Arab.foo = [lam-ar];
+        @kern1.Nkoo.foo = [gba-nko];
+        @kern2.Arab.foo = [comma-ar];
+
+        lookup kern_Arab {
+            lookupflag IgnoreMarks;
+            pos @kern1.Arab.foo @kern2.Arab.foo <-20 0 -20 0>;
+        } kern_Arab;
+
+        lookup kern_Nkoo {
+            lookupflag IgnoreMarks;
+            pos @kern1.Nkoo.foo @kern2.Arab.foo <-20 0 -20 0>;
+        } kern_Nkoo;
+
+        feature kern {
+            script arab;
+            language dflt;
+            lookup kern_Arab;
+
+            script nko;
+            language dflt;
+            lookup kern_Nkoo;
+        } kern;
+        """
+    )
+
+
+def test_kern_mixed_bidis(caplog, FontClass):
+    """Test that BiDi types for pairs are respected."""
+    glyphs = {
+        "a": ord("a"),
+        "comma": ord(","),
+        "alef-ar": 0x0627,
+        "comma-ar": 0x060C,
+        "one-ar": 0x0661,
+    }
+    kerning = {
+        # Undetermined: LTR
+        ("comma", "comma"): -1,
+        # LTR
+        ("a", "a"): 1,
+        ("a", "comma"): 2,
+        ("comma", "a"): 3,
+        # RTL
+        ("alef-ar", "alef-ar"): 4,
+        ("alef-ar", "comma-ar"): 5,
+        ("comma-ar", "alef-ar"): 6,
+        # Mixed: should be dropped
+        ("alef-ar", "one-ar"): 7,
+        ("one-ar", "alef-ar"): 8,
+        # LTR despite being an RTL script
+        ("one-ar", "one-ar"): 9,
+    }
+    ufo = makeUFO(FontClass, glyphs, None, kerning)
+    with caplog.at_level(logging.INFO):
+        newFeatures = KernFeatureWriterTest.writeFeatures(ufo)
+
+    assert dedent(str(newFeatures)) == dedent(
+        """\
+        lookup kern_Arab {
+            lookupflag IgnoreMarks;
+            pos alef-ar alef-ar <4 0 4 0>;
+            pos alef-ar comma-ar <5 0 5 0>;
+            pos comma-ar alef-ar <6 0 6 0>;
+            pos one-ar one-ar 9;
+        } kern_Arab;
+
+        lookup kern_Latn {
+            lookupflag IgnoreMarks;
+            pos a a 1;
+            pos a comma 2;
+            pos comma a 3;
+        } kern_Latn;
+
+        lookup kern_Default {
+            lookupflag IgnoreMarks;
+            pos comma comma -1;
+        } kern_Default;
+
+        feature kern {
+            script DFLT;
+            language dflt;
+            lookup kern_Default;
+
+            script arab;
+            language dflt;
+            lookup kern_Default;
+            lookup kern_Arab;
+
+            script latn;
+            language dflt;
+            lookup kern_Default;
+            lookup kern_Latn;
+        } kern;
+        """
+    )
+    assert "<alef-ar one-ar 7> with ambiguous direction" in caplog.text
+    assert "<one-ar alef-ar 8> with ambiguous direction" in caplog.text
+
+
+def test_kern_zyyy_zinh(FontClass):
+    """Test that a sampling of glyphs with a common or inherited script, but a
+    disjoint set of explicit script extensions end up in the correct lookups."""
+    glyphs = {}
+    for i in range(0, 0x110000, 0x10):
+        script = unicodedata.script(chr(i))
+        script_extension = unicodedata.script_extension(chr(i))
+        if script not in script_extension:
+            assert script in DFLT_SCRIPTS
+            name = f"uni{i:04X}"
+            glyphs[name] = i
+    kerning = {(glyph, glyph): i for i, glyph in enumerate(glyphs)}
+    ufo = makeUFO(FontClass, glyphs, None, kerning)
+    newFeatures = KernFeatureWriterTest.writeFeatures(ufo)
+
+    assert dedent(str(newFeatures)) == dedent(
+        """\
+        lookup kern_Deva {
+            lookupflag IgnoreMarks;
+            pos uni1CD0 uni1CD0 3;
+            pos uni1CE0 uni1CE0 4;
+            pos uni1CF0 uni1CF0 5;
+            pos uni20F0 uni20F0 7;
+            pos uniA830 uniA830 28;
+        } kern_Deva;
+
+        lookup kern_Dupl {
+            lookupflag IgnoreMarks;
+            pos uni1BCA0 uni1BCA0 36;
+        } kern_Dupl;
+
+        lookup kern_Grek {
+            lookupflag IgnoreMarks;
+            pos uni1DC0 uni1DC0 6;
+        } kern_Grek;
+
+        lookup kern_Hani {
+            lookupflag IgnoreMarks;
+            pos uni1D360 uni1D360 37;
+            pos uni1D370 uni1D370 38;
+            pos uni1F250 uni1F250 39;
+            pos uni3010 uni3010 8;
+            pos uni3030 uni3030 9;
+            pos uni3190 uni3190 11;
+            pos uni31C0 uni31C0 12;
+            pos uni31D0 uni31D0 13;
+            pos uni31E0 uni31E0 14;
+            pos uni3220 uni3220 15;
+            pos uni3230 uni3230 16;
+            pos uni3240 uni3240 17;
+            pos uni3280 uni3280 18;
+            pos uni3290 uni3290 19;
+            pos uni32A0 uni32A0 20;
+            pos uni32B0 uni32B0 21;
+            pos uni32C0 uni32C0 22;
+            pos uni3360 uni3360 23;
+            pos uni3370 uni3370 24;
+            pos uni33E0 uni33E0 25;
+            pos uni33F0 uni33F0 26;
+            pos uniA700 uniA700 27;
+        } kern_Hani;
+
+        lookup kern_Default {
+            lookupflag IgnoreMarks;
+            pos uni0640 uni0640 0;
+            pos uni0650 uni0650 1;
+            pos uni0670 uni0670 2;
+            pos uni10100 uni10100 30;
+            pos uni10110 uni10110 31;
+            pos uni10120 uni10120 32;
+            pos uni10130 uni10130 33;
+            pos uni102E0 uni102E0 34;
+            pos uni102F0 uni102F0 35;
+            pos uni30A0 uni30A0 10;
+            pos uniFF70 uniFF70 29;
+        } kern_Default;
+
+        feature kern {
+            script DFLT;
+            language dflt;
+            lookup kern_Default;
+
+            script grek;
+            language dflt;
+            lookup kern_Default;
+            lookup kern_Grek;
+
+            script hani;
+            language dflt;
+            lookup kern_Default;
+            lookup kern_Hani;
+        } kern;
+
+        feature dist {
+            script dev2;
+            language dflt;
+            lookup kern_Default;
+            lookup kern_Deva;
+
+            script deva;
+            language dflt;
+            lookup kern_Default;
+            lookup kern_Deva;
+
+            script dupl;
+            language dflt;
+            lookup kern_Default;
+            lookup kern_Dupl;
+        } dist;
+        """
+    )
+
+
 if __name__ == "__main__":
     import sys
 
diff --git a/tests/featureWriters/markFeatureWriter_test.py b/tests/featureWriters/markFeatureWriter_test.py
index 2445195..3ea705d 100644
--- a/tests/featureWriters/markFeatureWriter_test.py
+++ b/tests/featureWriters/markFeatureWriter_test.py
@@ -824,95 +824,6 @@ class MarkFeatureWriterTest(FeatureWriterTest):
             """  # noqa: B950
         )
 
-    def test_shared_script_char(self, FontClass):
-        ufo = FontClass()
-        ufo.info.unitsPerEm = 1000
-
-        dottedCircle = ufo.newGlyph("kashida-ar")
-        dottedCircle.unicode = 0x0640
-        dottedCircle.anchors = [
-            {"name": "top", "x": 100, "y": 100},
-            {"name": "bottom", "x": 100, "y": -100},
-        ]
-
-        nukta = ufo.newGlyph("fatha-ar")
-        nukta.unicode = 0x064E
-        nukta.appendAnchor({"name": "_top", "x": 0, "y": 0})
-
-        nukta = ufo.newGlyph("kasra-ar")
-        nukta.unicode = 0x0650
-        nukta.appendAnchor({"name": "_bottom", "x": 0, "y": 547})
-
-        ufo.features.text = dedent(
-            """\
-            languagesystem DFLT dflt;
-            languagesystem arab dflt;
-            """
-        )
-        generated = self.writeFeatures(ufo)
-
-        assert str(generated) == dedent(
-            """\
-            markClass kasra-ar <anchor 0 547> @MC_bottom;
-            markClass fatha-ar <anchor 0 0> @MC_top;
-
-            feature mark {
-                lookup mark2base {
-                    pos base kashida-ar
-                        <anchor 100 -100> mark @MC_bottom
-                        <anchor 100 100> mark @MC_top;
-                } mark2base;
-
-            } mark;
-            """  # noqa: B950
-        )
-
-        expected = dedent(
-            """\
-            markClass kasra-ar <anchor 0 547> @MC_bottom;
-            markClass fatha-ar <anchor 0 0> @MC_top;
-
-            feature abvm {
-                lookup abvm_mark2base {
-                    pos base kashida-ar
-                        <anchor 100 100> mark @MC_top;
-                } abvm_mark2base;
-
-            } abvm;
-
-            feature blwm {
-                lookup blwm_mark2base {
-                    pos base kashida-ar
-                        <anchor 100 -100> mark @MC_bottom;
-                } blwm_mark2base;
-
-            } blwm;
-
-            feature mark {
-                lookup mark2base {
-                    pos base kashida-ar
-                        <anchor 100 -100> mark @MC_bottom
-                        <anchor 100 100> mark @MC_top;
-                } mark2base;
-
-            } mark;
-            """  # noqa: B950
-        )
-
-        ufo.features.text = ""
-        generated = self.writeFeatures(ufo)
-        assert str(generated) == expected
-
-        ufo.features.text = dedent(
-            """\
-            languagesystem DFLT dflt;
-            languagesystem arab dflt;
-            languagesystem adlm dflt;
-            """
-        )
-        generated = self.writeFeatures(ufo)
-        assert str(generated) == expected
-
     def test_all_features(self, testufo):
         ufo = testufo
         ufo.info.unitsPerEm = 1000
@@ -978,12 +889,6 @@ class MarkFeatureWriterTest(FeatureWriterTest):
             "foo_bar_baz",
             "bar_foo",
         ]
-        ufo.features.text = dedent(
-            """\
-            languagesystem DFLT dflt;
-            languagesystem taml dflt;
-            """
-        )
         generated = self.writeFeatures(testufo)
 
         assert str(generated) == dedent(