Upgrade fonttools to 4.49.0

This project was upgraded with external_updater.
Usage: tools/external_updater/updater.sh update external/fonttools
For more info, check https://cs.android.com/android/platform/superproject/+/main:tools/external_updater/README.md

Test: TreeHugger
Change-Id: I5054ab1711063135fa20c2ca2eca8301c07d0267
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d97c77f..2cf71c2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -19,9 +19,9 @@
     # https://github.community/t/github-actions-does-not-respect-skip-ci/17325/8
     if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python 3.x
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: "3.x"
     - name: Install packages
@@ -43,9 +43,9 @@
           - platform: windows-latest
             python-version: 3.11
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
         allow-prereleases: true
@@ -60,7 +60,7 @@
         coverage combine
         coverage xml
     - name: Upload coverage to Codecov
-      uses: codecov/codecov-action@v3
+      uses: codecov/codecov-action@v4
       with:
         file: coverage.xml
         flags: unittests
@@ -74,9 +74,9 @@
     runs-on: ubuntu-latest
     if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python 3.x
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: "3.11"
     - name: Install packages
@@ -88,9 +88,9 @@
     runs-on: ubuntu-latest
     if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python pypy3
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: "pypy-3.9"
     - name: Install packages
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 59ba1b0..68dbf4c 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -21,9 +21,9 @@
   build_pure:
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: '3.x'
     - name: Install dependencies
@@ -32,8 +32,9 @@
     - name: Build source distribution and pure-python wheel
       run: |
         python setup.py sdist bdist_wheel
-    - uses: actions/upload-artifact@v3
+    - uses: actions/upload-artifact@v4
       with:
+        name: pure
         path: |
           dist/*.whl
           dist/*.tar.gz
@@ -55,11 +56,11 @@
           - os: windows-latest
             arch: auto32
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         submodules: recursive
     - name: Set up Python
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
         python-version: "3.x"
     - name: Install dependencies
@@ -70,8 +71,9 @@
       env:
         CIBW_MANYLINUX_X86_64_IMAGE: manylinux2014
         CIBW_ARCHS: ${{ matrix.arch }}
-    - uses: actions/upload-artifact@v3
+    - uses: actions/upload-artifact@v4
       with:
+        name: wheels-${{ matrix.os }}-${{ matrix.arch }}
         path: wheelhouse/*.whl
 
   build_arch_wheels:
@@ -83,10 +85,10 @@
         python: [38, 39, 310, 311, 312]
         arch: [aarch64]
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         submodules: true
-    - uses: docker/setup-qemu-action@v2.2.0
+    - uses: docker/setup-qemu-action@v3.0.0
       with:
         platforms: all
     - name: Install dependencies
@@ -96,8 +98,9 @@
       env:
         CIBW_BUILD: cp${{ matrix.python }}-*
         CIBW_ARCHS: ${{ matrix.arch }}
-    - uses: actions/upload-artifact@v3
+    - uses: actions/upload-artifact@v4
       with:
+        name: wheels-py${{ matrix.python }}-linux-${{ matrix.arch }}
         path: wheelhouse/*.whl
 
   deploy:
@@ -110,11 +113,12 @@
     # only run if the commit is tagged...
     if: startsWith(github.ref, 'refs/tags/')
     steps:
-    - uses: actions/download-artifact@v3
+    - uses: actions/download-artifact@v4
       with:
-        name: artifact
+        # so that all artifacts are downloaded in the same directory specified by 'path'
+        merge-multiple: true
         path: dist
-    - uses: pypa/gh-action-pypi-publish@v1.8.8
+    - uses: pypa/gh-action-pypi-publish@v1.8.11
       with:
         user: __token__
         password: ${{ secrets.PYPI_PASSWORD }}
diff --git a/Doc/docs-requirements.txt b/Doc/docs-requirements.txt
index f8f93c1..65c2982 100644
--- a/Doc/docs-requirements.txt
+++ b/Doc/docs-requirements.txt
@@ -1,4 +1,4 @@
 sphinx==7.2.6
-sphinx_rtd_theme==1.3.0
-reportlab==4.0.6
+sphinx_rtd_theme==2.0.0
+reportlab==4.1.0
 freetype-py==2.4.0
diff --git a/Doc/source/designspaceLib/index.rst b/Doc/source/designspaceLib/index.rst
index 7b8b487..6bf2975 100644
--- a/Doc/source/designspaceLib/index.rst
+++ b/Doc/source/designspaceLib/index.rst
@@ -120,6 +120,41 @@
 glyphs should be exported, regardless of what the same lib key in any of the
 UFOs says.
 
+public.fontInfo
+-----------------------
+
+This lib key, when included in the ``<lib>`` element inside an ``<instance>`` 
+or ``<variable-font>`` tag, or the ``<lib>`` element at the root of a 
+designspace document, allows for direct manipulation of font info data in 
+instances and variable fonts. The lib value must follow the 
+`UFO3 fontinfo.plist specification <https://unifiedfontobject.org/versions/ufo3/fontinfo.plist/>`_,
+and should functionally appear to be a property list dictionary with the same 
+structure as the ``fontinfo.plist`` file in a UFO.
+
+All font info items in the UFO fontinfo.plist specification should be able to 
+be defined in the ``public.fontInfo`` lib. Checking validity of the data using 
+``fontTools.ufoLib.validators`` is recommended but not required. 
+
+All font info items for a variable font or an instance must be inherited using 
+the following order, in order of descending priority:
+
+#. The ``public.fontInfo`` key in the ``<lib>`` element of the ``<variable-font>``
+   or ``<instance>`` elements.
+#. XML attributes for names (i.e. ``familyname``, ``stylename``, etc.), if the
+   target is an ``<instance>`` element.
+#. The ``public.fontInfo`` key found in the ``<lib>`` element of the designspace
+   document's root.
+#. The ``fontinfo.plist`` in the UFO source at the origin of the interpolation 
+   space.
+
+Absence of a font info key from the value of a ``public.fontInfo`` lib does 
+**not** mean a that piece of font info should be interpreted as being undefined. 
+A tool generating the variable font or instance should recursively continue on 
+to the next level of the inheritence order and apply the value found there, if 
+any. If the tool makes it to the end of the inheritence order without finding a 
+valid value for a given font info key, it should then be considered undefined. 
+In the case of any conflicting values for a font info key, the value highest in 
+the inheritance order must be chosen over the others.
 
 Implementation and differences
 ==============================
diff --git a/Doc/source/designspaceLib/v5_class_diagram.png b/Doc/source/designspaceLib/v5_class_diagram.png
index 7c75bcb..38bcf37 100644
--- a/Doc/source/designspaceLib/v5_class_diagram.png
+++ b/Doc/source/designspaceLib/v5_class_diagram.png
Binary files differ
diff --git a/Doc/source/designspaceLib/v5_class_diagram.puml b/Doc/source/designspaceLib/v5_class_diagram.puml
index 31f9e9c..1cc0082 100644
--- a/Doc/source/designspaceLib/v5_class_diagram.puml
+++ b/Doc/source/designspaceLib/v5_class_diagram.puml
@@ -112,7 +112,7 @@
 + path: str
 + layerName: Optional[str]
 + <color:brown><s><<Deprecated>> location: Location
-+ <color:green><b><<New>> designLocation: AnisotropicLocation
++ <color:green><b><<New>> designLocation: SimpleLocation
 ....
 + font: Optional[Font]
 ....
diff --git a/Doc/source/designspaceLib/xml.rst b/Doc/source/designspaceLib/xml.rst
index 4e3492e..7b59dbb 100644
--- a/Doc/source/designspaceLib/xml.rst
+++ b/Doc/source/designspaceLib/xml.rst
@@ -263,11 +263,14 @@
 ``<mappings>`` element
 ======================
 
--  Define axis mappings.
+-  Define an axis mappings group.
 -  Child element of ``axes``
 
+.. rubric:: Attributes
 
- .. versionadded:: 5.1
+- ``description``: optional, string. the description of this mappings group
+
+ .. versionadded:: 5.2
 
 
 ``<mapping>`` element
@@ -276,8 +279,11 @@
 -  Defines an axis mapping.
 -  Child element of ``<mappings>``
 
+.. rubric:: Attributes
 
- .. versionadded:: 5.1
+- ``description``: optional, string. the description of this mapping
+
+ .. versionadded:: 5.2
 
 
 ``<input>`` element
@@ -438,8 +444,8 @@
       See the following issues for more information:
       `fontTools#1371 <https://github.com/fonttools/fonttools/issues/1371#issuecomment-590214572>`__
       `fontTools#2050 <https://github.com/fonttools/fonttools/issues/2050#issuecomment-678691020>`__
-   -  If you want to use a different feature altogether, e.g. ``calt``,
-      use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag``
+   -  If you want to use a different feature(s) altogether, e.g. ``calt``,
+      use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag``.
 
       .. code:: xml
 
@@ -450,6 +456,9 @@
                </dict>
            </lib>
 
+      This can also take a comma-separated list of feature tags, e.g. ``salt,ss01``,
+      if you wish the same rules to be applied with several features.
+
 
 
 ``<rule>`` element
@@ -790,7 +799,7 @@
      but the design space is sliced at the given location. *Note:* While valid to have a
      specific value that doesn’t have a matching ``<source>`` at that value, currently there
      isn’t an implentation that supports this. See `this fontmake issue
-     <https://github.com/googlefonts/fontmake/issues/920>`.
+     <https://github.com/googlefonts/fontmake/issues/920>`_.
 
      .. code:: xml
 
@@ -842,6 +851,38 @@
 
 .. seealso:: :ref:`lib`
 
+Here is an example of using the ``public.fontInfo`` lib key to gain more granular
+control over the font info of a variable font, in this case setting some names to
+reflect the fact that this is a Narrow variable font subset from the larger designspace. 
+This lib key allows font info in variable fonts to be more specific than the font 
+info of the sources.
+
+.. rubric:: Example
+
+.. code:: xml
+
+    <variable-font name="MyFontNarrVF">
+      <axis-subsets>
+        <axis-subset name="Weight"/>
+        <axis-subset name="Width" uservalue="75"/>
+      </axis-subsets>
+      <lib>
+        <dict>
+          <key>public.fontInfo</key>
+          <dict>
+            <key>familyName</key>
+            <string>My Font Narrow VF</string>
+            <key>styleName</key>
+            <string>Regular</string>
+            <key>postscriptFontName</key>
+            <string>MyFontNarrVF-Regular</string>
+            <key>trademark</key>
+            <string>My Font Narrow VF is a registered trademark...</string>
+          </dict>
+        </dict>
+      </lib>
+    </variable-font>
+
 
 Instances included in the variable font
 ---------------------------------------
@@ -989,6 +1030,57 @@
         </instances>
     </designspace>
 
+Here is an example of using the ``public.fontInfo`` lib key to gain more granular
+control over the font info of the instances. 
+
+``openTypeNameWWSFamilyName`` and ``openTypeNameWWSSubfamilyName`` are not able to 
+be set by attributes on the ``<instance>`` element. The ``openTypeOS2WeightClass`` 
+key is superseding the value that would have been set by the ``weight`` axis value. 
+The ``trademark`` key is superseding the value that would have been set by UFO source 
+at the origin. If the designer wishes to set name records for other encodings, 
+platforms or laguages, they should do so using the ``openTypeNameRecords`` key, like 
+they would in a UFO source.
+
+See `UFO3 fontinfo.plist specification <https://unifiedfontobject.org/versions/ufo3/fontinfo.plist/>`_.
+
+.. code:: xml
+
+    <instance familyname="My Font" stylename="Text Light" filename="instances/MyFont-TextLight.ufo" postscriptfontname="MyFont-TextLight" stylemapfamilyname="My Font Text Light" stylemapstylename="regular">
+        <location>
+            <dimension name="optical" xvalue="6"/>
+            <dimension name="weight" xvalue="325"/>
+        </location>
+        <lib>
+            <dict>
+                <key>public.fontInfo</key>
+                <dict>
+                    <key>openTypeNameWWSFamilyName</key>
+                    <string>My Font Text</string>
+                    <key>openTypeNameWWSSubfamilyName</key>
+                    <string>Light</string>
+                    <key>openTypeOS2WeightClass</key>
+                    <integer>300</integer>
+                    <key>trademark</key>
+                    <string>My Font Text Light is a registered trademark...</string>
+                    <key>openTypeNameRecords</key>
+                    <array>
+                        <dict>
+                            <key>encodingID</key>
+                            <integer>1</integer>
+                            <key>languageID</key>
+                            <integer>1031</integer>
+                            <key>nameID</key>
+                            <integer>7</integer>
+                            <key>platformID</key>
+                            <integer>3</integer>
+                            <key>string</key>
+                            <string>Meine Schrift Text Leicht ist eine registrierte Marke...</string>
+                        </dict>
+                    </array>
+                </dict>
+            </dict>
+        </lib>
+    </instance>
 
 ``<glyphs>`` element (instance)
 -------------------------------
diff --git a/Lib/fontTools/__init__.py b/Lib/fontTools/__init__.py
index 9a59504..e6a745b 100644
--- a/Lib/fontTools/__init__.py
+++ b/Lib/fontTools/__init__.py
@@ -3,6 +3,6 @@
 
 log = logging.getLogger(__name__)
 
-version = __version__ = "4.44.0"
+version = __version__ = "4.49.0"
 
 __all__ = ["version", "log", "configLogger"]
diff --git a/Lib/fontTools/afmLib.py b/Lib/fontTools/afmLib.py
index 935a1e8..0aabf7f 100644
--- a/Lib/fontTools/afmLib.py
+++ b/Lib/fontTools/afmLib.py
@@ -45,7 +45,6 @@
 
 """
 
-
 import re
 
 # every single line starts with a "word"
@@ -82,7 +81,10 @@
 # regular expressions to parse composite info lines of the form:
 # Aacute 2 ; PCC A 0 0 ; PCC acute 182 211 ;
 compositeRE = re.compile(
-    r"([.A-Za-z0-9_]+)" r"\s+" r"(\d+)" r"\s*;\s*"  # char name  # number of parts
+    r"([.A-Za-z0-9_]+)"  # char name
+    r"\s+"
+    r"(\d+)"  # number of parts
+    r"\s*;\s*"
 )
 componentRE = re.compile(
     r"PCC\s+"  # PPC
diff --git a/Lib/fontTools/cffLib/__init__.py b/Lib/fontTools/cffLib/__init__.py
index 644508c..0ad41c5 100644
--- a/Lib/fontTools/cffLib/__init__.py
+++ b/Lib/fontTools/cffLib/__init__.py
@@ -2880,7 +2880,6 @@
 
 
 class IndexedStrings(object):
-
     """SID -> string mapping."""
 
     def __init__(self, file=None):
diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py
index 442bc20..6e45e7a 100644
--- a/Lib/fontTools/colorLib/builder.py
+++ b/Lib/fontTools/colorLib/builder.py
@@ -2,6 +2,7 @@
 colorLib.builder: Build COLR/CPAL tables from scratch
 
 """
+
 import collections
 import copy
 import enum
@@ -298,11 +299,15 @@
     labels: Iterable[_OptionalLocalizedString], nameTable: _n_a_m_e.table__n_a_m_e
 ) -> List[Optional[int]]:
     return [
-        nameTable.addMultilingualName(l, mac=False)
-        if isinstance(l, dict)
-        else C_P_A_L_.table_C_P_A_L_.NO_NAME_ID
-        if l is None
-        else nameTable.addMultilingualName({"en": l}, mac=False)
+        (
+            nameTable.addMultilingualName(l, mac=False)
+            if isinstance(l, dict)
+            else (
+                C_P_A_L_.table_C_P_A_L_.NO_NAME_ID
+                if l is None
+                else nameTable.addMultilingualName({"en": l}, mac=False)
+            )
+        )
         for l in labels
     ]
 
diff --git a/Lib/fontTools/config/__init__.py b/Lib/fontTools/config/__init__.py
index c106fe5..41ab8f7 100644
--- a/Lib/fontTools/config/__init__.py
+++ b/Lib/fontTools/config/__init__.py
@@ -6,6 +6,7 @@
 An instance of the Config class can be attached to a TTFont object, so that
 the various modules can access their configuration options from it.
 """
+
 from textwrap import dedent
 
 from fontTools.misc.configTools import *
diff --git a/Lib/fontTools/designspaceLib/__init__.py b/Lib/fontTools/designspaceLib/__init__.py
index 1c71fd0..342f1de 100644
--- a/Lib/fontTools/designspaceLib/__init__.py
+++ b/Lib/fontTools/designspaceLib/__init__.py
@@ -312,7 +312,7 @@
         return self.designLocation
 
     @location.setter
-    def location(self, location: Optional[AnisotropicLocationDict]):
+    def location(self, location: Optional[SimpleLocationDict]):
         self.designLocation = location or {}
 
     def setFamilyName(self, familyName, languageCode="en"):
@@ -329,15 +329,13 @@
         """
         return self.localisedFamilyName.get(languageCode)
 
-    def getFullDesignLocation(
-        self, doc: "DesignSpaceDocument"
-    ) -> AnisotropicLocationDict:
+    def getFullDesignLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict:
         """Get the complete design location of this source, from its
         :attr:`designLocation` and the document's axis defaults.
 
         .. versionadded:: 5.0
         """
-        result: AnisotropicLocationDict = {}
+        result: SimpleLocationDict = {}
         for axis in doc.axes:
             if axis.name in self.designLocation:
                 result[axis.name] = self.designLocation[axis.name]
@@ -478,7 +476,14 @@
 
     _attrs = ["inputLocation", "outputLocation"]
 
-    def __init__(self, *, inputLocation=None, outputLocation=None):
+    def __init__(
+        self,
+        *,
+        inputLocation=None,
+        outputLocation=None,
+        description=None,
+        groupDescription=None,
+    ):
         self.inputLocation: SimpleLocationDict = inputLocation or {}
         """dict. Axis values for the input of the mapping, in design space coordinates.
 
@@ -493,6 +498,20 @@
 
         .. versionadded:: 5.1
         """
+        self.description = description
+        """string. A description of the mapping.
+
+        varLib.
+
+        .. versionadded:: 5.2
+        """
+        self.groupDescription = groupDescription
+        """string. A description of the group of mappings.
+
+        varLib.
+
+        .. versionadded:: 5.2
+        """
 
 
 class InstanceDescriptor(SimpleDescriptor):
@@ -1415,18 +1434,27 @@
         ):
             axesElement = ET.Element("axes")
             if self.documentObject.elidedFallbackName is not None:
-                axesElement.attrib[
-                    "elidedfallbackname"
-                ] = self.documentObject.elidedFallbackName
+                axesElement.attrib["elidedfallbackname"] = (
+                    self.documentObject.elidedFallbackName
+                )
             self.root.append(axesElement)
         for axisObject in self.documentObject.axes:
             self._addAxis(axisObject)
 
         if self.documentObject.axisMappings:
-            mappingsElement = ET.Element("mappings")
-            self.root.findall(".axes")[0].append(mappingsElement)
+            mappingsElement = None
+            lastGroup = object()
             for mappingObject in self.documentObject.axisMappings:
+                if getattr(mappingObject, "groupDescription", None) != lastGroup:
+                    if mappingsElement is not None:
+                        self.root.findall(".axes")[0].append(mappingsElement)
+                    lastGroup = getattr(mappingObject, "groupDescription", None)
+                    mappingsElement = ET.Element("mappings")
+                    if lastGroup is not None:
+                        mappingsElement.attrib["description"] = lastGroup
                 self._addAxisMapping(mappingsElement, mappingObject)
+            if mappingsElement is not None:
+                self.root.findall(".axes")[0].append(mappingsElement)
 
         if self.documentObject.locationLabels:
             labelsElement = ET.Element("labels")
@@ -1588,6 +1616,8 @@
 
     def _addAxisMapping(self, mappingsElement, mappingObject):
         mappingElement = ET.Element("mapping")
+        if getattr(mappingObject, "description", None) is not None:
+            mappingElement.attrib["description"] = mappingObject.description
         for what in ("inputLocation", "outputLocation"):
             whatObject = getattr(mappingObject, what, None)
             if whatObject is None:
@@ -1746,17 +1776,17 @@
         if instanceObject.filename is not None:
             instanceElement.attrib["filename"] = instanceObject.filename
         if instanceObject.postScriptFontName is not None:
-            instanceElement.attrib[
-                "postscriptfontname"
-            ] = instanceObject.postScriptFontName
+            instanceElement.attrib["postscriptfontname"] = (
+                instanceObject.postScriptFontName
+            )
         if instanceObject.styleMapFamilyName is not None:
-            instanceElement.attrib[
-                "stylemapfamilyname"
-            ] = instanceObject.styleMapFamilyName
+            instanceElement.attrib["stylemapfamilyname"] = (
+                instanceObject.styleMapFamilyName
+            )
         if instanceObject.styleMapStyleName is not None:
-            instanceElement.attrib[
-                "stylemapstylename"
-            ] = instanceObject.styleMapStyleName
+            instanceElement.attrib["stylemapstylename"] = (
+                instanceObject.styleMapStyleName
+            )
         if self.effectiveFormatTuple < (5, 0):
             # Deprecated members as of version 5.0
             if instanceObject.glyphs:
@@ -2083,10 +2113,11 @@
             self.documentObject.axes.append(axisObject)
             self.axisDefaults[axisObject.name] = axisObject.default
 
-        mappingsElement = self.root.find(".axes/mappings")
         self.documentObject.axisMappings = []
-        if mappingsElement is not None:
+        for mappingsElement in self.root.findall(".axes/mappings"):
+            groupDescription = mappingsElement.attrib.get("description")
             for mappingElement in mappingsElement.findall("mapping"):
+                description = mappingElement.attrib.get("description")
                 inputElement = mappingElement.find("input")
                 outputElement = mappingElement.find("output")
                 inputLoc = {}
@@ -2100,7 +2131,10 @@
                     value = float(dimElement.attrib["xvalue"])
                     outputLoc[name] = value
                 axisMappingObject = self.axisMappingDescriptorClass(
-                    inputLocation=inputLoc, outputLocation=outputLoc
+                    inputLocation=inputLoc,
+                    outputLocation=outputLoc,
+                    description=description,
+                    groupDescription=groupDescription,
                 )
                 self.documentObject.axisMappings.append(axisMappingObject)
 
@@ -3281,3 +3315,23 @@
         finally:
             for source, font in zip(self.sources, fonts):
                 source.font = font
+
+
+def main(args=None):
+    """Roundtrip .designspace file through the DesignSpaceDocument class"""
+
+    if args is None:
+        import sys
+
+        args = sys.argv[1:]
+
+    from argparse import ArgumentParser
+
+    parser = ArgumentParser(prog="designspaceLib", description=main.__doc__)
+    parser.add_argument("input")
+    parser.add_argument("output")
+
+    options = parser.parse_args(args)
+
+    ds = DesignSpaceDocument.fromfile(options.input)
+    ds.write(options.output)
diff --git a/Lib/fontTools/designspaceLib/__main__.py b/Lib/fontTools/designspaceLib/__main__.py
new file mode 100644
index 0000000..8f5e44e
--- /dev/null
+++ b/Lib/fontTools/designspaceLib/__main__.py
@@ -0,0 +1,6 @@
+import sys
+from fontTools.designspaceLib import main
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/Lib/fontTools/designspaceLib/statNames.py b/Lib/fontTools/designspaceLib/statNames.py
index a164169..1474e5f 100644
--- a/Lib/fontTools/designspaceLib/statNames.py
+++ b/Lib/fontTools/designspaceLib/statNames.py
@@ -8,6 +8,7 @@
     names = getStatNames(doc, instance.getFullUserLocation(doc))
     print(names.styleNames)
 """
+
 from __future__ import annotations
 
 from dataclasses import dataclass
diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py
index cfaf54d..7921a3f 100644
--- a/Lib/fontTools/feaLib/builder.py
+++ b/Lib/fontTools/feaLib/builder.py
@@ -36,6 +36,7 @@
 from fontTools.varLib.featureVars import addFeatureVariationsRaw
 from fontTools.varLib.models import normalizeValue, piecewiseLinearMap
 from collections import defaultdict
+import copy
 import itertools
 from io import StringIO
 import logging
@@ -284,7 +285,11 @@
     def build_feature_aalt_(self):
         if not self.aalt_features_ and not self.aalt_alternates_:
             return
-        alternates = {g: set(a) for g, a in self.aalt_alternates_.items()}
+        # > alternate glyphs will be sorted in the order that the source features
+        # > are named in the aalt definition, not the order of the feature definitions
+        # > in the file. Alternates defined explicitly ... will precede all others.
+        # https://github.com/fonttools/fonttools/issues/836
+        alternates = {g: list(a) for g, a in self.aalt_alternates_.items()}
         for location, name in self.aalt_features_ + [(None, "aalt")]:
             feature = [
                 (script, lang, feature, lookups)
@@ -301,17 +306,14 @@
                         lookuplist = [lookuplist]
                     for lookup in lookuplist:
                         for glyph, alts in lookup.getAlternateGlyphs().items():
-                            alternates.setdefault(glyph, set()).update(alts)
+                            alts_for_glyph = alternates.setdefault(glyph, [])
+                            alts_for_glyph.extend(
+                                g for g in alts if g not in alts_for_glyph
+                            )
         single = {
-            glyph: list(repl)[0] for glyph, repl in alternates.items() if len(repl) == 1
+            glyph: repl[0] for glyph, repl in alternates.items() if len(repl) == 1
         }
-        # TODO: Figure out the glyph alternate ordering used by makeotf.
-        # https://github.com/fonttools/fonttools/issues/836
-        multi = {
-            glyph: sorted(repl, key=self.font.getGlyphID)
-            for glyph, repl in alternates.items()
-            if len(repl) > 1
-        }
+        multi = {glyph: repl for glyph, repl in alternates.items() if len(repl) > 1}
         if not single and not multi:
             return
         self.features_ = {
@@ -1248,8 +1250,9 @@
     def add_single_subst(self, location, prefix, suffix, mapping, forceChain):
         if self.cur_feature_name_ == "aalt":
             for from_glyph, to_glyph in mapping.items():
-                alts = self.aalt_alternates_.setdefault(from_glyph, set())
-                alts.add(to_glyph)
+                alts = self.aalt_alternates_.setdefault(from_glyph, [])
+                if to_glyph not in alts:
+                    alts.append(to_glyph)
             return
         if prefix or suffix or forceChain:
             self.add_single_subst_chained_(location, prefix, suffix, mapping)
@@ -1302,8 +1305,8 @@
     # GSUB 3
     def add_alternate_subst(self, location, prefix, glyph, suffix, replacement):
         if self.cur_feature_name_ == "aalt":
-            alts = self.aalt_alternates_.setdefault(glyph, set())
-            alts.update(replacement)
+            alts = self.aalt_alternates_.setdefault(glyph, [])
+            alts.extend(g for g in replacement if g not in alts)
             return
         if prefix or suffix:
             chain = self.get_lookup_(location, ChainContextSubstBuilder)
@@ -1337,7 +1340,7 @@
         # substitutions to be specified on target sequences that contain
         # glyph classes, the implementation software will enumerate
         # all specific glyph sequences if glyph classes are detected"
-        for g in sorted(itertools.product(*glyphs)):
+        for g in itertools.product(*glyphs):
             lookup.ligatures[g] = replacement
 
     # GSUB 5/6
@@ -1516,7 +1519,7 @@
                 for mark in markClassDef.glyphs.glyphSet():
                     if mark not in lookupBuilder.marks:
                         otMarkAnchor = self.makeOpenTypeAnchor(
-                            location, markClassDef.anchor
+                            location, copy.deepcopy(markClassDef.anchor)
                         )
                         lookupBuilder.marks[mark] = (markClass.name, otMarkAnchor)
                     else:
diff --git a/Lib/fontTools/feaLib/lexer.py b/Lib/fontTools/feaLib/lexer.py
index e0ae0ae..5867f70 100644
--- a/Lib/fontTools/feaLib/lexer.py
+++ b/Lib/fontTools/feaLib/lexer.py
@@ -111,10 +111,6 @@
             glyphclass = text[start + 1 : self.pos_]
             if len(glyphclass) < 1:
                 raise FeatureLibError("Expected glyph class name", location)
-            if len(glyphclass) > 63:
-                raise FeatureLibError(
-                    "Glyph class names must not be longer than 63 characters", location
-                )
             if not Lexer.RE_GLYPHCLASS.match(glyphclass):
                 raise FeatureLibError(
                     "Glyph class names must consist of letters, digits, "
diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py
index 8ffdf64..8cbe795 100644
--- a/Lib/fontTools/feaLib/parser.py
+++ b/Lib/fontTools/feaLib/parser.py
@@ -2071,13 +2071,7 @@
     def expect_glyph_(self):
         self.advance_lexer_()
         if self.cur_token_type_ is Lexer.NAME:
-            self.cur_token_ = self.cur_token_.lstrip("\\")
-            if len(self.cur_token_) > 63:
-                raise FeatureLibError(
-                    "Glyph names must not be longer than 63 characters",
-                    self.cur_token_location_,
-                )
-            return self.cur_token_
+            return self.cur_token_.lstrip("\\")
         elif self.cur_token_type_ is Lexer.CID:
             return "cid%05d" % self.cur_token_
         raise FeatureLibError("Expected a glyph name or CID", self.cur_token_location_)
diff --git a/Lib/fontTools/merge/__init__.py b/Lib/fontTools/merge/__init__.py
index 8d8a521..7653e4a 100644
--- a/Lib/fontTools/merge/__init__.py
+++ b/Lib/fontTools/merge/__init__.py
@@ -139,6 +139,7 @@
             *(vars(table).keys() for table in tables if table is not NotImplemented),
         )
         for key in allKeys:
+            log.info(" %s", key)
             try:
                 mergeLogic = logic[key]
             except KeyError:
@@ -181,17 +182,50 @@
         args = sys.argv[1:]
 
     options = Options()
-    args = options.parse_opts(args, ignore_unknown=["output-file"])
-    outfile = "merged.ttf"
+    args = options.parse_opts(args)
     fontfiles = []
+    if options.input_file:
+        with open(options.input_file) as inputfile:
+            fontfiles = [
+                line.strip()
+                for line in inputfile.readlines()
+                if not line.lstrip().startswith("#")
+            ]
     for g in args:
-        if g.startswith("--output-file="):
-            outfile = g[14:]
-            continue
         fontfiles.append(g)
 
-    if len(args) < 1:
-        print("usage: pyftmerge font...", file=sys.stderr)
+    if len(fontfiles) < 1:
+        print(
+            "usage: pyftmerge [font1 ... fontN] [--input-file=filelist.txt] [--output-file=merged.ttf] [--import-file=tables.ttx]",
+            file=sys.stderr,
+        )
+        print(
+            "                                   [--drop-tables=tags] [--verbose] [--timing]",
+            file=sys.stderr,
+        )
+        print("", file=sys.stderr)
+        print(" font1 ... fontN              Files to merge.", file=sys.stderr)
+        print(
+            " --input-file=<filename>      Read files to merge from a text file, each path new line. # Comment lines allowed.",
+            file=sys.stderr,
+        )
+        print(
+            " --output-file=<filename>     Specify output file name (default: merged.ttf).",
+            file=sys.stderr,
+        )
+        print(
+            " --import-file=<filename>     TTX file to import after merging. This can be used to set metadata.",
+            file=sys.stderr,
+        )
+        print(
+            " --drop-tables=<table tags>   Comma separated list of table tags to skip, case sensitive.",
+            file=sys.stderr,
+        )
+        print(
+            " --verbose                    Output progress information.",
+            file=sys.stderr,
+        )
+        print(" --timing                     Output progress timing.", file=sys.stderr)
         return 1
 
     configLogger(level=logging.INFO if options.verbose else logging.WARNING)
@@ -202,8 +236,12 @@
 
     merger = Merger(options=options)
     font = merger.merge(fontfiles)
+
+    if options.import_file:
+        font.importXML(options.import_file)
+
     with timer("compile and save font"):
-        font.save(outfile)
+        font.save(options.output_file)
 
 
 if __name__ == "__main__":
diff --git a/Lib/fontTools/merge/layout.py b/Lib/fontTools/merge/layout.py
index 6b85cd5..e1b504e 100644
--- a/Lib/fontTools/merge/layout.py
+++ b/Lib/fontTools/merge/layout.py
@@ -169,20 +169,16 @@
     "BaselineTag": sumLists,
 }
 
-otTables.GDEF.mergeMap = (
-    otTables.GSUB.mergeMap
-) = (
-    otTables.GPOS.mergeMap
-) = otTables.BASE.mergeMap = otTables.JSTF.mergeMap = otTables.MATH.mergeMap = {
+otTables.GDEF.mergeMap = otTables.GSUB.mergeMap = otTables.GPOS.mergeMap = (
+    otTables.BASE.mergeMap
+) = otTables.JSTF.mergeMap = otTables.MATH.mergeMap = {
     "*": mergeObjects,
     "Version": max,
 }
 
-ttLib.getTableClass("GDEF").mergeMap = ttLib.getTableClass(
-    "GSUB"
-).mergeMap = ttLib.getTableClass("GPOS").mergeMap = ttLib.getTableClass(
-    "BASE"
-).mergeMap = ttLib.getTableClass(
+ttLib.getTableClass("GDEF").mergeMap = ttLib.getTableClass("GSUB").mergeMap = (
+    ttLib.getTableClass("GPOS").mergeMap
+) = ttLib.getTableClass("BASE").mergeMap = ttLib.getTableClass(
     "JSTF"
 ).mergeMap = ttLib.getTableClass(
     "MATH"
diff --git a/Lib/fontTools/merge/options.py b/Lib/fontTools/merge/options.py
index f134009..8bc8947 100644
--- a/Lib/fontTools/merge/options.py
+++ b/Lib/fontTools/merge/options.py
@@ -11,6 +11,9 @@
         self.verbose = False
         self.timing = False
         self.drop_tables = []
+        self.input_file = None
+        self.output_file = "merged.ttf"
+        self.import_file = None
 
         self.set(**kwargs)
 
diff --git a/Lib/fontTools/misc/bezierTools.py b/Lib/fontTools/misc/bezierTools.py
index 21ab0a5..a1a707b 100644
--- a/Lib/fontTools/misc/bezierTools.py
+++ b/Lib/fontTools/misc/bezierTools.py
@@ -1370,6 +1370,11 @@
     return unique_values
 
 
+def _is_linelike(segment):
+    maybeline = _alignment_transformation(segment).transformPoints(segment)
+    return all(math.isclose(p[1], 0.0) for p in maybeline)
+
+
 def curveCurveIntersections(curve1, curve2):
     """Finds intersections between a curve and a curve.
 
@@ -1391,6 +1396,17 @@
         >>> intersections[0].pt
         (81.7831487395506, 109.88904552375288)
     """
+    if _is_linelike(curve1):
+        line1 = curve1[0], curve1[-1]
+        if _is_linelike(curve2):
+            line2 = curve2[0], curve2[-1]
+            return lineLineIntersections(*line1, *line2)
+        else:
+            return curveLineIntersections(curve2, line1)
+    elif _is_linelike(curve2):
+        line2 = curve2[0], curve2[-1]
+        return curveLineIntersections(curve1, line2)
+
     intersection_ts = _curve_curve_intersections_t(curve1, curve2)
     return [
         Intersection(pt=segmentPointAtT(curve1, ts[0]), t1=ts[0], t2=ts[1])
diff --git a/Lib/fontTools/misc/classifyTools.py b/Lib/fontTools/misc/classifyTools.py
index 2235bbd..aed7ca6 100644
--- a/Lib/fontTools/misc/classifyTools.py
+++ b/Lib/fontTools/misc/classifyTools.py
@@ -3,7 +3,6 @@
 
 
 class Classifier(object):
-
     """
     Main Classifier object, used to classify things into similar sets.
     """
diff --git a/Lib/fontTools/misc/cliTools.py b/Lib/fontTools/misc/cliTools.py
index 8322ea9..8a64235 100644
--- a/Lib/fontTools/misc/cliTools.py
+++ b/Lib/fontTools/misc/cliTools.py
@@ -1,4 +1,5 @@
 """Collection of utilities for command-line interfaces and console scripts."""
+
 import os
 import re
 
diff --git a/Lib/fontTools/misc/configTools.py b/Lib/fontTools/misc/configTools.py
index 38bbada..7eb1854 100644
--- a/Lib/fontTools/misc/configTools.py
+++ b/Lib/fontTools/misc/configTools.py
@@ -8,6 +8,7 @@
 ``options`` class variable set to your instance of Options.
 
 """
+
 from __future__ import annotations
 
 import logging
diff --git a/Lib/fontTools/misc/dictTools.py b/Lib/fontTools/misc/dictTools.py
index e3c0df7..cd3d394 100644
--- a/Lib/fontTools/misc/dictTools.py
+++ b/Lib/fontTools/misc/dictTools.py
@@ -1,6 +1,5 @@
 """Misc dict tools."""
 
-
 __all__ = ["hashdict"]
 
 
diff --git a/Lib/fontTools/misc/etree.py b/Lib/fontTools/misc/etree.py
index 9d4a65c..d0967b5 100644
--- a/Lib/fontTools/misc/etree.py
+++ b/Lib/fontTools/misc/etree.py
@@ -11,6 +11,7 @@
 only availble in lxml, like OrderedDict for attributes, pretty_print and
 iterwalk.
 """
+
 from fontTools.misc.textTools import tostr
 
 
diff --git a/Lib/fontTools/misc/filenames.py b/Lib/fontTools/misc/filenames.py
index d279f89..ddedc52 100644
--- a/Lib/fontTools/misc/filenames.py
+++ b/Lib/fontTools/misc/filenames.py
@@ -17,7 +17,6 @@
 -	Just van Rossum
 """
 
-
 illegalCharacters = r"\" * + / : < > ? [ \ ] | \0".split(" ")
 illegalCharacters += [chr(i) for i in range(1, 32)]
 illegalCharacters += [chr(0x7F)]
diff --git a/Lib/fontTools/misc/textTools.py b/Lib/fontTools/misc/textTools.py
index f7ca1ac..f5484a8 100644
--- a/Lib/fontTools/misc/textTools.py
+++ b/Lib/fontTools/misc/textTools.py
@@ -1,6 +1,5 @@
 """fontTools.misc.textTools.py -- miscellaneous routines."""
 
-
 import ast
 import string
 
diff --git a/Lib/fontTools/misc/transform.py b/Lib/fontTools/misc/transform.py
index f85b54b..0f9f3a5 100644
--- a/Lib/fontTools/misc/transform.py
+++ b/Lib/fontTools/misc/transform.py
@@ -76,7 +76,6 @@
 
 
 class Transform(NamedTuple):
-
     """2x2 transformation matrix plus offset, a.k.a. Affine transform.
     Transform instances are immutable: all transforming methods, eg.
     rotate(), return a new Transform instance.
diff --git a/Lib/fontTools/misc/vector.py b/Lib/fontTools/misc/vector.py
index 666ff15..02c62e6 100644
--- a/Lib/fontTools/misc/vector.py
+++ b/Lib/fontTools/misc/vector.py
@@ -8,7 +8,6 @@
 
 
 class Vector(tuple):
-
     """A math-like vector.
 
     Represents an n-dimensional numeric vector. ``Vector`` objects support
diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py
index 3508a7e..70fd87a 100644
--- a/Lib/fontTools/otlLib/builder.py
+++ b/Lib/fontTools/otlLib/builder.py
@@ -1,11 +1,13 @@
 from collections import namedtuple, OrderedDict
 import os
 from fontTools.misc.fixedTools import fixedToFloat
+from fontTools.misc.roundTools import otRound
 from fontTools import ttLib
 from fontTools.ttLib.tables import otTables as ot
 from fontTools.ttLib.tables.otBase import (
     ValueRecord,
     valueRecordFormatDict,
+    OTLOffsetOverflowError,
     OTTableWriter,
     CountReference,
 )
@@ -350,16 +352,14 @@
         return [x for x in ruleset if len(x.rules) > 0]
 
     def getCompiledSize_(self, subtables):
-        size = 0
-        for st in subtables:
-            w = OTTableWriter()
-            w["LookupType"] = CountReference(
-                {"LookupType": st.LookupType}, "LookupType"
-            )
-            # We need to make a copy here because compiling
-            # modifies the subtable (finalizing formats etc.)
-            copy.deepcopy(st).compile(w, self.font)
-            size += len(w.getAllData())
+        if not subtables:
+            return 0
+        # We need to make a copy here because compiling
+        # modifies the subtable (finalizing formats etc.)
+        table = self.buildLookup_(copy.deepcopy(subtables))
+        w = OTTableWriter()
+        table.compile(w, self.font)
+        size = len(w.getAllData())
         return size
 
     def build(self):
@@ -410,22 +410,23 @@
             if not ruleset.hasAnyGlyphClasses:
                 candidates[1] = [self.buildFormat1Subtable(ruleset, chaining)]
 
+            candidates_by_size = []
             for i in [1, 2, 3]:
                 if candidates[i]:
                     try:
-                        self.getCompiledSize_(candidates[i])
-                    except Exception as e:
+                        size = self.getCompiledSize_(candidates[i])
+                    except OTLOffsetOverflowError as e:
                         log.warning(
                             "Contextual format %i at %s overflowed (%s)"
                             % (i, str(self.location), e)
                         )
-                        candidates[i] = None
+                    else:
+                        candidates_by_size.append((size, candidates[i]))
 
-            candidates = [x for x in candidates if x is not None]
-            if not candidates:
+            if not candidates_by_size:
                 raise OpenTypeLibError("All candidates overflowed", self.location)
 
-            winner = min(candidates, key=self.getCompiledSize_)
+            _min_size, winner = min(candidates_by_size, key=lambda x: x[0])
             subtables.extend(winner)
 
         # If we are not chaining, lookup type will be automatically fixed by
@@ -774,7 +775,10 @@
                     if lookup is not None:
                         alts = lookup.getAlternateGlyphs()
                         for glyph, replacements in alts.items():
-                            result.setdefault(glyph, set()).update(replacements)
+                            alts_for_glyph = result.setdefault(glyph, [])
+                            alts_for_glyph.extend(
+                                g for g in replacements if g not in alts_for_glyph
+                            )
         return result
 
     def find_chainable_single_subst(self, mapping):
@@ -1238,7 +1242,7 @@
         return self.buildLookup_(subtables)
 
     def getAlternateGlyphs(self):
-        return {glyph: set([repl]) for glyph, repl in self.mapping.items()}
+        return {glyph: [repl] for glyph, repl in self.mapping.items()}
 
     def add_subtable_break(self, location):
         self.mapping[(self.SUBTABLE_BREAK_, location)] = self.SUBTABLE_BREAK_
@@ -1567,19 +1571,6 @@
     return self
 
 
-def _getLigatureKey(components):
-    # Computes a key for ordering ligatures in a GSUB Type-4 lookup.
-
-    # When building the OpenType lookup, we need to make sure that
-    # the longest sequence of components is listed first, so we
-    # use the negative length as the primary key for sorting.
-    # To make buildLigatureSubstSubtable() deterministic, we use the
-    # component sequence as the secondary key.
-
-    # For example, this will sort (f,f,f) < (f,f,i) < (f,f) < (f,i) < (f,l).
-    return (-len(components), components)
-
-
 def buildLigatureSubstSubtable(mapping):
     """Builds a ligature substitution (GSUB4) subtable.
 
@@ -1613,7 +1604,7 @@
     # with fontTools >= 3.1:
     # self.ligatures = dict(mapping)
     self.ligatures = {}
-    for components in sorted(mapping.keys(), key=_getLigatureKey):
+    for components in sorted(mapping.keys(), key=self._getLigatureSortKey):
         ligature = ot.Ligature()
         ligature.Component = components[1:]
         ligature.CompCount = len(ligature.Component) + 1
@@ -2781,14 +2772,13 @@
     """
     ttFont["STAT"] = ttLib.newTable("STAT")
     statTable = ttFont["STAT"].table = ot.STAT()
-    nameTable = ttFont["name"]
     statTable.ElidedFallbackNameID = _addName(
-        nameTable, elidedFallbackName, windows=windowsNames, mac=macNames
+        ttFont, elidedFallbackName, windows=windowsNames, mac=macNames
     )
 
     # 'locations' contains data for AxisValue Format 4
     axisRecords, axisValues = _buildAxisRecords(
-        axes, nameTable, windowsNames=windowsNames, macNames=macNames
+        axes, ttFont, windowsNames=windowsNames, macNames=macNames
     )
     if not locations:
         statTable.Version = 0x00010001
@@ -2797,10 +2787,10 @@
         # requires a higher table version
         statTable.Version = 0x00010002
         multiAxisValues = _buildAxisValuesFormat4(
-            locations, axes, nameTable, windowsNames=windowsNames, macNames=macNames
+            locations, axes, ttFont, windowsNames=windowsNames, macNames=macNames
         )
         axisValues = multiAxisValues + axisValues
-    nameTable.names.sort()
+    ttFont["name"].names.sort()
 
     # Store AxisRecords
     axisRecordArray = ot.AxisRecordArray()
@@ -2820,14 +2810,14 @@
         statTable.AxisValueCount = len(axisValues)
 
 
-def _buildAxisRecords(axes, nameTable, windowsNames=True, macNames=True):
+def _buildAxisRecords(axes, ttFont, windowsNames=True, macNames=True):
     axisRecords = []
     axisValues = []
     for axisRecordIndex, axisDict in enumerate(axes):
         axis = ot.AxisRecord()
         axis.AxisTag = axisDict["tag"]
         axis.AxisNameID = _addName(
-            nameTable, axisDict["name"], 256, windows=windowsNames, mac=macNames
+            ttFont, axisDict["name"], 256, windows=windowsNames, mac=macNames
         )
         axis.AxisOrdering = axisDict.get("ordering", axisRecordIndex)
         axisRecords.append(axis)
@@ -2837,7 +2827,7 @@
             axisValRec.AxisIndex = axisRecordIndex
             axisValRec.Flags = axisVal.get("flags", 0)
             axisValRec.ValueNameID = _addName(
-                nameTable, axisVal["name"], windows=windowsNames, mac=macNames
+                ttFont, axisVal["name"], windows=windowsNames, mac=macNames
             )
 
             if "value" in axisVal:
@@ -2863,9 +2853,7 @@
     return axisRecords, axisValues
 
 
-def _buildAxisValuesFormat4(
-    locations, axes, nameTable, windowsNames=True, macNames=True
-):
+def _buildAxisValuesFormat4(locations, axes, ttFont, windowsNames=True, macNames=True):
     axisTagToIndex = {}
     for axisRecordIndex, axisDict in enumerate(axes):
         axisTagToIndex[axisDict["tag"]] = axisRecordIndex
@@ -2875,7 +2863,7 @@
         axisValRec = ot.AxisValue()
         axisValRec.Format = 4
         axisValRec.ValueNameID = _addName(
-            nameTable, axisLocationDict["name"], windows=windowsNames, mac=macNames
+            ttFont, axisLocationDict["name"], windows=windowsNames, mac=macNames
         )
         axisValRec.Flags = axisLocationDict.get("flags", 0)
         axisValueRecords = []
@@ -2891,7 +2879,8 @@
     return axisValues
 
 
-def _addName(nameTable, value, minNameID=0, windows=True, mac=True):
+def _addName(ttFont, value, minNameID=0, windows=True, mac=True):
+    nameTable = ttFont["name"]
     if isinstance(value, int):
         # Already a nameID
         return value
@@ -2916,5 +2905,296 @@
     else:
         raise TypeError("value must be int, str, dict or list")
     return nameTable.addMultilingualName(
-        names, windows=windows, mac=mac, minNameID=minNameID
+        names, ttFont=ttFont, windows=windows, mac=mac, minNameID=minNameID
     )
+
+
+def buildMathTable(
+    ttFont,
+    constants=None,
+    italicsCorrections=None,
+    topAccentAttachments=None,
+    extendedShapes=None,
+    mathKerns=None,
+    minConnectorOverlap=0,
+    vertGlyphVariants=None,
+    horizGlyphVariants=None,
+    vertGlyphAssembly=None,
+    horizGlyphAssembly=None,
+):
+    """
+    Add a 'MATH' table to 'ttFont'.
+
+    'constants' is a dictionary of math constants. The keys are the constant
+    names from the MATH table specification (with capital first letter), and the
+    values are the constant values as numbers.
+
+    'italicsCorrections' is a dictionary of italic corrections. The keys are the
+    glyph names, and the values are the italic corrections as numbers.
+
+    'topAccentAttachments' is a dictionary of top accent attachments. The keys
+    are the glyph names, and the values are the top accent horizontal positions
+    as numbers.
+
+    'extendedShapes' is a set of extended shape glyphs.
+
+    'mathKerns' is a dictionary of math kerns. The keys are the glyph names, and
+    the values are dictionaries. The keys of these dictionaries are the side
+    names ('TopRight', 'TopLeft', 'BottomRight', 'BottomLeft'), and the values
+    are tuples of two lists. The first list contains the correction heights as
+    numbers, and the second list contains the kern values as numbers.
+
+    'minConnectorOverlap' is the minimum connector overlap as a number.
+
+    'vertGlyphVariants' is a dictionary of vertical glyph variants. The keys are
+    the glyph names, and the values are tuples of glyph name and full advance height.
+
+    'horizGlyphVariants' is a dictionary of horizontal glyph variants. The keys
+    are the glyph names, and the values are tuples of glyph name and full
+    advance width.
+
+    'vertGlyphAssembly' is a dictionary of vertical glyph assemblies. The keys
+    are the glyph names, and the values are tuples of assembly parts and italics
+    correction. The assembly parts are tuples of glyph name, flags, start
+    connector length, end connector length, and full advance height.
+
+    'horizGlyphAssembly' is a dictionary of horizontal glyph assemblies. The
+    keys are the glyph names, and the values are tuples of assembly parts
+    and italics correction. The assembly parts are tuples of glyph name, flags,
+    start connector length, end connector length, and full advance width.
+
+    Where a number is expected, an integer or a float can be used. The floats
+    will be rounded.
+
+    Example::
+
+        constants = {
+            "ScriptPercentScaleDown": 70,
+            "ScriptScriptPercentScaleDown": 50,
+            "DelimitedSubFormulaMinHeight": 24,
+            "DisplayOperatorMinHeight": 60,
+            ...
+        }
+        italicsCorrections = {
+            "fitalic-math": 100,
+            "fbolditalic-math": 120,
+            ...
+        }
+        topAccentAttachments = {
+            "circumflexcomb": 500,
+            "acutecomb": 400,
+            "A": 300,
+            "B": 340,
+            ...
+        }
+        extendedShapes = {"parenleft", "parenright", ...}
+        mathKerns = {
+            "A": {
+                "TopRight": ([-50, -100], [10, 20, 30]),
+                "TopLeft": ([50, 100], [10, 20, 30]),
+                ...
+            },
+            ...
+        }
+        vertGlyphVariants = {
+            "parenleft": [("parenleft", 700), ("parenleft.size1", 1000), ...],
+            "parenright": [("parenright", 700), ("parenright.size1", 1000), ...],
+            ...
+        }
+        vertGlyphAssembly = {
+            "braceleft": [
+                (
+                    ("braceleft.bottom", 0, 0, 200, 500),
+                    ("braceleft.extender", 1, 200, 200, 200)),
+                    ("braceleft.middle", 0, 100, 100, 700),
+                    ("braceleft.extender", 1, 200, 200, 200),
+                    ("braceleft.top", 0, 200, 0, 500),
+                ),
+                100,
+            ],
+            ...
+        }
+    """
+    glyphMap = ttFont.getReverseGlyphMap()
+
+    ttFont["MATH"] = math = ttLib.newTable("MATH")
+    math.table = table = ot.MATH()
+    table.Version = 0x00010000
+    table.populateDefaults()
+
+    table.MathConstants = _buildMathConstants(constants)
+    table.MathGlyphInfo = _buildMathGlyphInfo(
+        glyphMap,
+        italicsCorrections,
+        topAccentAttachments,
+        extendedShapes,
+        mathKerns,
+    )
+    table.MathVariants = _buildMathVariants(
+        glyphMap,
+        minConnectorOverlap,
+        vertGlyphVariants,
+        horizGlyphVariants,
+        vertGlyphAssembly,
+        horizGlyphAssembly,
+    )
+
+
+def _buildMathConstants(constants):
+    if not constants:
+        return None
+
+    mathConstants = ot.MathConstants()
+    for conv in mathConstants.getConverters():
+        value = otRound(constants.get(conv.name, 0))
+        if conv.tableClass:
+            assert issubclass(conv.tableClass, ot.MathValueRecord)
+            value = _mathValueRecord(value)
+        setattr(mathConstants, conv.name, value)
+    return mathConstants
+
+
+def _buildMathGlyphInfo(
+    glyphMap,
+    italicsCorrections,
+    topAccentAttachments,
+    extendedShapes,
+    mathKerns,
+):
+    if not any([extendedShapes, italicsCorrections, topAccentAttachments, mathKerns]):
+        return None
+
+    info = ot.MathGlyphInfo()
+    info.populateDefaults()
+
+    if italicsCorrections:
+        coverage = buildCoverage(italicsCorrections.keys(), glyphMap)
+        info.MathItalicsCorrectionInfo = ot.MathItalicsCorrectionInfo()
+        info.MathItalicsCorrectionInfo.Coverage = coverage
+        info.MathItalicsCorrectionInfo.ItalicsCorrectionCount = len(coverage.glyphs)
+        info.MathItalicsCorrectionInfo.ItalicsCorrection = [
+            _mathValueRecord(italicsCorrections[n]) for n in coverage.glyphs
+        ]
+
+    if topAccentAttachments:
+        coverage = buildCoverage(topAccentAttachments.keys(), glyphMap)
+        info.MathTopAccentAttachment = ot.MathTopAccentAttachment()
+        info.MathTopAccentAttachment.TopAccentCoverage = coverage
+        info.MathTopAccentAttachment.TopAccentAttachmentCount = len(coverage.glyphs)
+        info.MathTopAccentAttachment.TopAccentAttachment = [
+            _mathValueRecord(topAccentAttachments[n]) for n in coverage.glyphs
+        ]
+
+    if extendedShapes:
+        info.ExtendedShapeCoverage = buildCoverage(extendedShapes, glyphMap)
+
+    if mathKerns:
+        coverage = buildCoverage(mathKerns.keys(), glyphMap)
+        info.MathKernInfo = ot.MathKernInfo()
+        info.MathKernInfo.MathKernCoverage = coverage
+        info.MathKernInfo.MathKernCount = len(coverage.glyphs)
+        info.MathKernInfo.MathKernInfoRecords = []
+        for glyph in coverage.glyphs:
+            record = ot.MathKernInfoRecord()
+            for side in {"TopRight", "TopLeft", "BottomRight", "BottomLeft"}:
+                if side in mathKerns[glyph]:
+                    correctionHeights, kernValues = mathKerns[glyph][side]
+                    assert len(correctionHeights) == len(kernValues) - 1
+                    kern = ot.MathKern()
+                    kern.HeightCount = len(correctionHeights)
+                    kern.CorrectionHeight = [
+                        _mathValueRecord(h) for h in correctionHeights
+                    ]
+                    kern.KernValue = [_mathValueRecord(v) for v in kernValues]
+                    setattr(record, f"{side}MathKern", kern)
+            info.MathKernInfo.MathKernInfoRecords.append(record)
+
+    return info
+
+
+def _buildMathVariants(
+    glyphMap,
+    minConnectorOverlap,
+    vertGlyphVariants,
+    horizGlyphVariants,
+    vertGlyphAssembly,
+    horizGlyphAssembly,
+):
+    if not any(
+        [vertGlyphVariants, horizGlyphVariants, vertGlyphAssembly, horizGlyphAssembly]
+    ):
+        return None
+
+    variants = ot.MathVariants()
+    variants.populateDefaults()
+
+    variants.MinConnectorOverlap = minConnectorOverlap
+
+    if vertGlyphVariants or vertGlyphAssembly:
+        variants.VertGlyphCoverage, variants.VertGlyphConstruction = (
+            _buildMathGlyphConstruction(
+                glyphMap,
+                vertGlyphVariants,
+                vertGlyphAssembly,
+            )
+        )
+
+    if horizGlyphVariants or horizGlyphAssembly:
+        variants.HorizGlyphCoverage, variants.HorizGlyphConstruction = (
+            _buildMathGlyphConstruction(
+                glyphMap,
+                horizGlyphVariants,
+                horizGlyphAssembly,
+            )
+        )
+
+    return variants
+
+
+def _buildMathGlyphConstruction(glyphMap, variants, assemblies):
+    glyphs = set()
+    if variants:
+        glyphs.update(variants.keys())
+    if assemblies:
+        glyphs.update(assemblies.keys())
+    coverage = buildCoverage(glyphs, glyphMap)
+    constructions = []
+
+    for glyphName in coverage.glyphs:
+        construction = ot.MathGlyphConstruction()
+        construction.populateDefaults()
+
+        if variants and glyphName in variants:
+            construction.VariantCount = len(variants[glyphName])
+            construction.MathGlyphVariantRecord = []
+            for variantName, advance in variants[glyphName]:
+                record = ot.MathGlyphVariantRecord()
+                record.VariantGlyph = variantName
+                record.AdvanceMeasurement = otRound(advance)
+                construction.MathGlyphVariantRecord.append(record)
+
+        if assemblies and glyphName in assemblies:
+            parts, ic = assemblies[glyphName]
+            construction.GlyphAssembly = ot.GlyphAssembly()
+            construction.GlyphAssembly.ItalicsCorrection = _mathValueRecord(ic)
+            construction.GlyphAssembly.PartCount = len(parts)
+            construction.GlyphAssembly.PartRecords = []
+            for part in parts:
+                part_name, flags, start, end, advance = part
+                record = ot.GlyphPartRecord()
+                record.glyph = part_name
+                record.PartFlags = int(flags)
+                record.StartConnectorLength = otRound(start)
+                record.EndConnectorLength = otRound(end)
+                record.FullAdvance = otRound(advance)
+                construction.GlyphAssembly.PartRecords.append(record)
+
+        constructions.append(construction)
+
+    return coverage, constructions
+
+
+def _mathValueRecord(value):
+    value_record = ot.MathValueRecord()
+    value_record.Value = otRound(value)
+    return value_record
diff --git a/Lib/fontTools/pens/basePen.py b/Lib/fontTools/pens/basePen.py
index ac8abd4..5d2cf50 100644
--- a/Lib/fontTools/pens/basePen.py
+++ b/Lib/fontTools/pens/basePen.py
@@ -148,7 +148,6 @@
 
 
 class NullPen(AbstractPen):
-
     """A pen that does nothing."""
 
     def moveTo(self, pt):
@@ -187,7 +186,6 @@
 
 
 class DecomposingPen(LoggingPen):
-
     """Implements a 'addComponent' method that decomposes components
     (i.e. draws them onto self as simple contours).
     It can also be used as a mixin class (e.g. see ContourRecordingPen).
@@ -229,7 +227,6 @@
 
 
 class BasePen(DecomposingPen):
-
     """Base class for drawing pens. You must override _moveTo, _lineTo and
     _curveToOne. You may additionally override _closePath, _endPath,
     addComponent, addVarComponent, and/or _qCurveToOne. You should not
diff --git a/Lib/fontTools/pens/boundsPen.py b/Lib/fontTools/pens/boundsPen.py
index d833cc8..c921844 100644
--- a/Lib/fontTools/pens/boundsPen.py
+++ b/Lib/fontTools/pens/boundsPen.py
@@ -7,7 +7,6 @@
 
 
 class ControlBoundsPen(BasePen):
-
     """Pen to calculate the "control bounds" of a shape. This is the
     bounding box of all control points, so may be larger than the
     actual bounding box if there are curves that don't have points
@@ -67,7 +66,6 @@
 
 
 class BoundsPen(ControlBoundsPen):
-
     """Pen to calculate the bounds of a shape. It calculates the
     correct bounds even when the shape contains curves that don't
     have points on their extremes. This is somewhat slower to compute
diff --git a/Lib/fontTools/pens/filterPen.py b/Lib/fontTools/pens/filterPen.py
index 8142310..6c8712c 100644
--- a/Lib/fontTools/pens/filterPen.py
+++ b/Lib/fontTools/pens/filterPen.py
@@ -9,7 +9,6 @@
 
 
 class FilterPen(_PassThruComponentsMixin, AbstractPen):
-
     """Base class for pens that apply some transformation to the coordinates
     they receive and pass them to another pen.
 
diff --git a/Lib/fontTools/pens/hashPointPen.py b/Lib/fontTools/pens/hashPointPen.py
index b82468e..f15dcab 100644
--- a/Lib/fontTools/pens/hashPointPen.py
+++ b/Lib/fontTools/pens/hashPointPen.py
@@ -31,6 +31,20 @@
     >    # The hash values are identical, the outline has not changed.
     >    # Compile the hinting code ...
     >    pass
+
+    If you want to compare a glyph from a source format which supports floating point
+    coordinates and transformations against a glyph from a format which has restrictions
+    on the precision of floats, e.g. UFO vs. TTF, you must use an appropriate rounding
+    function to make the values comparable. For TTF fonts with composites, this
+    construct can be used to make the transform values conform to F2Dot14:
+
+    > ttf_hash_pen = HashPointPen(ttf_glyph_width, ttFont.getGlyphSet())
+    > ttf_round_pen = RoundingPointPen(ttf_hash_pen, transformRoundFunc=partial(floatToFixedToFloat, precisionBits=14))
+    > ufo_hash_pen = HashPointPen(ufo_glyph.width, ufo)
+    > ttf_glyph.drawPoints(ttf_round_pen, ttFont["glyf"])
+    > ufo_round_pen = RoundingPointPen(ufo_hash_pen, transformRoundFunc=partial(floatToFixedToFloat, precisionBits=14))
+    > ufo_glyph.drawPoints(ufo_round_pen)
+    > assert ttf_hash_pen.hash == ufo_hash_pen.hash
     """
 
     def __init__(self, glyphWidth=0, glyphSet=None):
diff --git a/Lib/fontTools/pens/momentsPen.py b/Lib/fontTools/pens/momentsPen.py
index dab0d10..4c7ddfe 100644
--- a/Lib/fontTools/pens/momentsPen.py
+++ b/Lib/fontTools/pens/momentsPen.py
@@ -36,8 +36,7 @@
     def _endPath(self):
         p0 = self._getCurrentPoint()
         if p0 != self.__startPoint:
-            # Green theorem is not defined on open contours.
-            raise OpenContourError("Green theorem is not defined on open contours.")
+            raise OpenContourError("Glyph statistics not defined on open contours.")
 
     @cython.locals(r0=cython.double)
     @cython.locals(r1=cython.double)
diff --git a/Lib/fontTools/pens/pointInsidePen.py b/Lib/fontTools/pens/pointInsidePen.py
index 8a579ae..e1fbbbc 100644
--- a/Lib/fontTools/pens/pointInsidePen.py
+++ b/Lib/fontTools/pens/pointInsidePen.py
@@ -10,7 +10,6 @@
 
 
 class PointInsidePen(BasePen):
-
     """This pen implements "point inside" testing: to test whether
     a given point lies inside the shape (black) or outside (white).
     Instances of this class can be recycled, as long as the
diff --git a/Lib/fontTools/pens/quartzPen.py b/Lib/fontTools/pens/quartzPen.py
index 6e1228d..2b8a927 100644
--- a/Lib/fontTools/pens/quartzPen.py
+++ b/Lib/fontTools/pens/quartzPen.py
@@ -9,7 +9,6 @@
 
 
 class QuartzPen(BasePen):
-
     """A pen that creates a CGPath
 
     Parameters
diff --git a/Lib/fontTools/pens/recordingPen.py b/Lib/fontTools/pens/recordingPen.py
index 6c3b661..4f44a4d 100644
--- a/Lib/fontTools/pens/recordingPen.py
+++ b/Lib/fontTools/pens/recordingPen.py
@@ -1,4 +1,5 @@
 """Pen recording operations that can be accessed or replayed."""
+
 from fontTools.pens.basePen import AbstractPen, DecomposingPen
 from fontTools.pens.pointPen import AbstractPointPen
 
@@ -8,6 +9,7 @@
     "RecordingPen",
     "DecomposingRecordingPen",
     "RecordingPointPen",
+    "lerpRecordings",
 ]
 
 
@@ -76,6 +78,8 @@
     def replay(self, pen):
         replayRecording(self.value, pen)
 
+    draw = replay
+
 
 class DecomposingRecordingPen(DecomposingPen, RecordingPen):
     """Same as RecordingPen, except that it doesn't keep components
@@ -167,6 +171,36 @@
         for operator, args, kwargs in self.value:
             getattr(pointPen, operator)(*args, **kwargs)
 
+    drawPoints = replay
+
+
+def lerpRecordings(recording1, recording2, factor=0.5):
+    """Linearly interpolate between two recordings. The recordings
+    must be decomposed, i.e. they must not contain any components.
+
+    Factor is typically between 0 and 1. 0 means the first recording,
+    1 means the second recording, and 0.5 means the average of the
+    two recordings. Other values are possible, and can be useful to
+    extrapolate. Defaults to 0.5.
+
+    Returns a generator with the new recording.
+    """
+    if len(recording1) != len(recording2):
+        raise ValueError(
+            "Mismatched lengths: %d and %d" % (len(recording1), len(recording2))
+        )
+    for (op1, args1), (op2, args2) in zip(recording1, recording2):
+        if op1 != op2:
+            raise ValueError("Mismatched operations: %s, %s" % (op1, op2))
+        if op1 == "addComponent":
+            raise ValueError("Cannot interpolate components")
+        else:
+            mid_args = [
+                (x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor)
+                for (x1, y1), (x2, y2) in zip(args1, args2)
+            ]
+        yield (op1, mid_args)
+
 
 if __name__ == "__main__":
     pen = RecordingPen()
diff --git a/Lib/fontTools/pens/reportLabPen.py b/Lib/fontTools/pens/reportLabPen.py
index 2cb89c8..20c9065 100644
--- a/Lib/fontTools/pens/reportLabPen.py
+++ b/Lib/fontTools/pens/reportLabPen.py
@@ -6,7 +6,6 @@
 
 
 class ReportLabPen(BasePen):
-
     """A pen for drawing onto a ``reportlab.graphics.shapes.Path`` object."""
 
     def __init__(self, glyphSet, path=None):
diff --git a/Lib/fontTools/pens/roundingPen.py b/Lib/fontTools/pens/roundingPen.py
index 2a7c476..176bcc7 100644
--- a/Lib/fontTools/pens/roundingPen.py
+++ b/Lib/fontTools/pens/roundingPen.py
@@ -1,4 +1,4 @@
-from fontTools.misc.roundTools import otRound
+from fontTools.misc.roundTools import noRound, otRound
 from fontTools.misc.transform import Transform
 from fontTools.pens.filterPen import FilterPen, FilterPointPen
 
@@ -8,7 +8,9 @@
 
 class RoundingPen(FilterPen):
     """
-    Filter pen that rounds point coordinates and component XY offsets to integer.
+    Filter pen that rounds point coordinates and component XY offsets to integer. For
+    rounding the component transform values, a separate round function can be passed to
+    the pen.
 
     >>> from fontTools.pens.recordingPen import RecordingPen
     >>> recpen = RecordingPen()
@@ -28,9 +30,10 @@
     True
     """
 
-    def __init__(self, outPen, roundFunc=otRound):
+    def __init__(self, outPen, roundFunc=otRound, transformRoundFunc=noRound):
         super().__init__(outPen)
         self.roundFunc = roundFunc
+        self.transformRoundFunc = transformRoundFunc
 
     def moveTo(self, pt):
         self._outPen.moveTo((self.roundFunc(pt[0]), self.roundFunc(pt[1])))
@@ -49,12 +52,16 @@
         )
 
     def addComponent(self, glyphName, transformation):
+        xx, xy, yx, yy, dx, dy = transformation
         self._outPen.addComponent(
             glyphName,
             Transform(
-                *transformation[:4],
-                self.roundFunc(transformation[4]),
-                self.roundFunc(transformation[5]),
+                self.transformRoundFunc(xx),
+                self.transformRoundFunc(xy),
+                self.transformRoundFunc(yx),
+                self.transformRoundFunc(yy),
+                self.roundFunc(dx),
+                self.roundFunc(dy),
             ),
         )
 
@@ -62,6 +69,8 @@
 class RoundingPointPen(FilterPointPen):
     """
     Filter point pen that rounds point coordinates and component XY offsets to integer.
+    For rounding the component scale values, a separate round function can be passed to
+    the pen.
 
     >>> from fontTools.pens.recordingPen import RecordingPointPen
     >>> recpen = RecordingPointPen()
@@ -87,26 +96,35 @@
     True
     """
 
-    def __init__(self, outPen, roundFunc=otRound):
+    def __init__(self, outPen, roundFunc=otRound, transformRoundFunc=noRound):
         super().__init__(outPen)
         self.roundFunc = roundFunc
+        self.transformRoundFunc = transformRoundFunc
 
-    def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs):
+    def addPoint(
+        self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs
+    ):
         self._outPen.addPoint(
             (self.roundFunc(pt[0]), self.roundFunc(pt[1])),
             segmentType=segmentType,
             smooth=smooth,
             name=name,
+            identifier=identifier,
             **kwargs,
         )
 
-    def addComponent(self, baseGlyphName, transformation, **kwargs):
+    def addComponent(self, baseGlyphName, transformation, identifier=None, **kwargs):
+        xx, xy, yx, yy, dx, dy = transformation
         self._outPen.addComponent(
-            baseGlyphName,
-            Transform(
-                *transformation[:4],
-                self.roundFunc(transformation[4]),
-                self.roundFunc(transformation[5]),
+            baseGlyphName=baseGlyphName,
+            transformation=Transform(
+                self.transformRoundFunc(xx),
+                self.transformRoundFunc(xy),
+                self.transformRoundFunc(yx),
+                self.transformRoundFunc(yy),
+                self.roundFunc(dx),
+                self.roundFunc(dy),
             ),
+            identifier=identifier,
             **kwargs,
         )
diff --git a/Lib/fontTools/pens/statisticsPen.py b/Lib/fontTools/pens/statisticsPen.py
index 39f319e..699b14c 100644
--- a/Lib/fontTools/pens/statisticsPen.py
+++ b/Lib/fontTools/pens/statisticsPen.py
@@ -1,32 +1,19 @@
 """Pen calculating area, center of mass, variance and standard-deviation,
 covariance and correlation, and slant, of glyph shapes."""
-import math
+
+from math import sqrt, degrees, atan
+from fontTools.pens.basePen import BasePen, OpenContourError
 from fontTools.pens.momentsPen import MomentsPen
 
-__all__ = ["StatisticsPen"]
+__all__ = ["StatisticsPen", "StatisticsControlPen"]
 
 
-class StatisticsPen(MomentsPen):
+class StatisticsBase:
+    def __init__(self):
+        self._zero()
 
-    """Pen calculating area, center of mass, variance and
-    standard-deviation, covariance and correlation, and slant,
-    of glyph shapes.
-
-    Note that all the calculated values are 'signed'. Ie. if the
-    glyph shape is self-intersecting, the values are not correct
-    (but well-defined). As such, area will be negative if contour
-    directions are clockwise.  Moreover, variance might be negative
-    if the shapes are self-intersecting in certain ways."""
-
-    def __init__(self, glyphset=None):
-        MomentsPen.__init__(self, glyphset=glyphset)
-        self.__zero()
-
-    def _closePath(self):
-        MomentsPen._closePath(self)
-        self.__update()
-
-    def __zero(self):
+    def _zero(self):
+        self.area = 0
         self.meanX = 0
         self.meanY = 0
         self.varianceX = 0
@@ -37,10 +24,55 @@
         self.correlation = 0
         self.slant = 0
 
-    def __update(self):
+    def _update(self):
+        # XXX The variance formulas should never produce a negative value,
+        # but due to reasons I don't understand, both of our pens do.
+        # So we take the absolute value here.
+        self.varianceX = abs(self.varianceX)
+        self.varianceY = abs(self.varianceY)
+
+        self.stddevX = stddevX = sqrt(self.varianceX)
+        self.stddevY = stddevY = sqrt(self.varianceY)
+
+        # Correlation(X,Y) = Covariance(X,Y) / ( stddev(X) * stddev(Y) )
+        # https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient
+        if stddevX * stddevY == 0:
+            correlation = float("NaN")
+        else:
+            # XXX The above formula should never produce a value outside
+            # the range [-1, 1], but due to reasons I don't understand,
+            # (probably the same issue as above), it does. So we clamp.
+            correlation = self.covariance / (stddevX * stddevY)
+            correlation = max(-1, min(1, correlation))
+        self.correlation = correlation if abs(correlation) > 1e-3 else 0
+
+        slant = (
+            self.covariance / self.varianceY if self.varianceY != 0 else float("NaN")
+        )
+        self.slant = slant if abs(slant) > 1e-3 else 0
+
+
+class StatisticsPen(StatisticsBase, MomentsPen):
+    """Pen calculating area, center of mass, variance and
+    standard-deviation, covariance and correlation, and slant,
+    of glyph shapes.
+
+    Note that if the glyph shape is self-intersecting, the values
+    are not correct (but well-defined). Moreover, area will be
+    negative if contour directions are clockwise."""
+
+    def __init__(self, glyphset=None):
+        MomentsPen.__init__(self, glyphset=glyphset)
+        StatisticsBase.__init__(self)
+
+    def _closePath(self):
+        MomentsPen._closePath(self)
+        self._update()
+
+    def _update(self):
         area = self.area
         if not area:
-            self.__zero()
+            self._zero()
             return
 
         # Center of mass
@@ -48,29 +80,97 @@
         self.meanX = meanX = self.momentX / area
         self.meanY = meanY = self.momentY / area
 
-        #  Var(X) = E[X^2] - E[X]^2
-        self.varianceX = varianceX = self.momentXX / area - meanX**2
-        self.varianceY = varianceY = self.momentYY / area - meanY**2
+        # Var(X) = E[X^2] - E[X]^2
+        self.varianceX = self.momentXX / area - meanX * meanX
+        self.varianceY = self.momentYY / area - meanY * meanY
 
-        self.stddevX = stddevX = math.copysign(abs(varianceX) ** 0.5, varianceX)
-        self.stddevY = stddevY = math.copysign(abs(varianceY) ** 0.5, varianceY)
+        # Covariance(X,Y) = (E[X.Y] - E[X]E[Y])
+        self.covariance = self.momentXY / area - meanX * meanY
 
-        #  Covariance(X,Y) = ( E[X.Y] - E[X]E[Y] )
-        self.covariance = covariance = self.momentXY / area - meanX * meanY
+        StatisticsBase._update(self)
 
-        #  Correlation(X,Y) = Covariance(X,Y) / ( stddev(X) * stddev(Y) )
-        # https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient
-        if stddevX * stddevY == 0:
-            correlation = float("NaN")
+
+class StatisticsControlPen(StatisticsBase, BasePen):
+    """Pen calculating area, center of mass, variance and
+    standard-deviation, covariance and correlation, and slant,
+    of glyph shapes, using the control polygon only.
+
+    Note that if the glyph shape is self-intersecting, the values
+    are not correct (but well-defined). Moreover, area will be
+    negative if contour directions are clockwise."""
+
+    def __init__(self, glyphset=None):
+        BasePen.__init__(self, glyphset)
+        StatisticsBase.__init__(self)
+        self._nodes = []
+
+    def _moveTo(self, pt):
+        self._nodes.append(complex(*pt))
+
+    def _lineTo(self, pt):
+        self._nodes.append(complex(*pt))
+
+    def _qCurveToOne(self, pt1, pt2):
+        for pt in (pt1, pt2):
+            self._nodes.append(complex(*pt))
+
+    def _curveToOne(self, pt1, pt2, pt3):
+        for pt in (pt1, pt2, pt3):
+            self._nodes.append(complex(*pt))
+
+    def _closePath(self):
+        self._update()
+
+    def _endPath(self):
+        p0 = self._getCurrentPoint()
+        if p0 != self.__startPoint:
+            raise OpenContourError("Glyph statistics not defined on open contours.")
+
+    def _update(self):
+        nodes = self._nodes
+        n = len(nodes)
+
+        # Triangle formula
+        self.area = (
+            sum(
+                (p0.real * p1.imag - p1.real * p0.imag)
+                for p0, p1 in zip(nodes, nodes[1:] + nodes[:1])
+            )
+            / 2
+        )
+
+        # Center of mass
+        # https://en.wikipedia.org/wiki/Center_of_mass#A_system_of_particles
+        sumNodes = sum(nodes)
+        self.meanX = meanX = sumNodes.real / n
+        self.meanY = meanY = sumNodes.imag / n
+
+        if n > 1:
+            # Var(X) = (sum[X^2] - sum[X]^2 / n) / (n - 1)
+            # https://www.statisticshowto.com/probability-and-statistics/descriptive-statistics/sample-variance/
+            self.varianceX = varianceX = (
+                sum(p.real * p.real for p in nodes)
+                - (sumNodes.real * sumNodes.real) / n
+            ) / (n - 1)
+            self.varianceY = varianceY = (
+                sum(p.imag * p.imag for p in nodes)
+                - (sumNodes.imag * sumNodes.imag) / n
+            ) / (n - 1)
+
+            # Covariance(X,Y) = (sum[X.Y] - sum[X].sum[Y] / n) / (n - 1)
+            self.covariance = covariance = (
+                sum(p.real * p.imag for p in nodes)
+                - (sumNodes.real * sumNodes.imag) / n
+            ) / (n - 1)
         else:
-            correlation = covariance / (stddevX * stddevY)
-        self.correlation = correlation if abs(correlation) > 1e-3 else 0
+            self.varianceX = varianceX = 0
+            self.varianceY = varianceY = 0
+            self.covariance = covariance = 0
 
-        slant = covariance / varianceY if varianceY != 0 else float("NaN")
-        self.slant = slant if abs(slant) > 1e-3 else 0
+        StatisticsBase._update(self)
 
 
-def _test(glyphset, upem, glyphs, quiet=False):
+def _test(glyphset, upem, glyphs, quiet=False, *, control=False):
     from fontTools.pens.transformPen import TransformPen
     from fontTools.misc.transform import Scale
 
@@ -81,7 +181,10 @@
     slnt_sum_perceptual = 0
     for glyph_name in glyphs:
         glyph = glyphset[glyph_name]
-        pen = StatisticsPen(glyphset=glyphset)
+        if control:
+            pen = StatisticsControlPen(glyphset=glyphset)
+        else:
+            pen = StatisticsPen(glyphset=glyphset)
         transformer = TransformPen(pen, Scale(1.0 / upem))
         glyph.draw(transformer)
 
@@ -127,10 +230,10 @@
     print("width:  %g" % (wdth_sum / upem / len(glyphs)))
     slant = slnt_sum / len(glyphs)
     print("slant:  %g" % slant)
-    print("slant angle:  %g" % -math.degrees(math.atan(slant)))
+    print("slant angle:  %g" % -degrees(atan(slant)))
     slant_perceptual = slnt_sum_perceptual / wdth_sum
     print("slant (perceptual):  %g" % slant_perceptual)
-    print("slant (perceptual) angle:  %g" % -math.degrees(math.atan(slant_perceptual)))
+    print("slant (perceptual) angle:  %g" % -degrees(atan(slant_perceptual)))
 
 
 def main(args):
@@ -155,6 +258,12 @@
         help="Face index into a collection to open. Zero based.",
     )
     parser.add_argument(
+        "-c",
+        "--control",
+        action="store_true",
+        help="Use the control-box pen instead of the Green therem.",
+    )
+    parser.add_argument(
         "-q", "--quiet", action="store_true", help="Only report font-wide statistics."
     )
     parser.add_argument(
@@ -188,6 +297,7 @@
         font["head"].unitsPerEm,
         glyphs,
         quiet=options.quiet,
+        control=options.control,
     )
 
 
diff --git a/Lib/fontTools/pens/svgPathPen.py b/Lib/fontTools/pens/svgPathPen.py
index ae6ebfb..29d41a8 100644
--- a/Lib/fontTools/pens/svgPathPen.py
+++ b/Lib/fontTools/pens/svgPathPen.py
@@ -220,13 +220,19 @@
         "fonttools pens.svgPathPen", description="Generate SVG from text"
     )
     parser.add_argument("font", metavar="font.ttf", help="Font file.")
-    parser.add_argument("text", metavar="text", help="Text string.")
+    parser.add_argument("text", metavar="text", nargs="?", help="Text string.")
     parser.add_argument(
         "-y",
         metavar="<number>",
         help="Face index into a collection to open. Zero based.",
     )
     parser.add_argument(
+        "--glyphs",
+        metavar="whitespace-separated list of glyph names",
+        type=str,
+        help="Glyphs to show. Exclusive with text option",
+    )
+    parser.add_argument(
         "--variations",
         metavar="AXIS=LOC",
         default="",
@@ -241,12 +247,13 @@
 
     font = TTFont(options.font, fontNumber=fontNumber)
     text = options.text
+    glyphs = options.glyphs
 
     location = {}
     for tag_v in options.variations.split():
         fields = tag_v.split("=")
         tag = fields[0].strip()
-        v = int(fields[1])
+        v = float(fields[1])
         location[tag] = v
 
     hhea = font["hhea"]
@@ -255,10 +262,17 @@
     glyphset = font.getGlyphSet(location=location)
     cmap = font["cmap"].getBestCmap()
 
+    if glyphs is not None and text is not None:
+        raise ValueError("Options --glyphs and --text are exclusive")
+
+    if glyphs is None:
+        glyphs = " ".join(cmap[ord(u)] for u in text)
+
+    glyphs = glyphs.split()
+
     s = ""
     width = 0
-    for u in text:
-        g = cmap[ord(u)]
+    for g in glyphs:
         glyph = glyphset[g]
 
         pen = SVGPathPen(glyphset)
diff --git a/Lib/fontTools/pens/teePen.py b/Lib/fontTools/pens/teePen.py
index 2828175..939f049 100644
--- a/Lib/fontTools/pens/teePen.py
+++ b/Lib/fontTools/pens/teePen.py
@@ -1,4 +1,5 @@
 """Pen multiplexing drawing to one or more pens."""
+
 from fontTools.pens.basePen import AbstractPen
 
 
diff --git a/Lib/fontTools/pens/transformPen.py b/Lib/fontTools/pens/transformPen.py
index 2e572f6..ff98dbd 100644
--- a/Lib/fontTools/pens/transformPen.py
+++ b/Lib/fontTools/pens/transformPen.py
@@ -5,7 +5,6 @@
 
 
 class TransformPen(FilterPen):
-
     """Pen that transforms all coordinates using a Affine transformation,
     and passes them to another pen.
     """
diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py
index bd826ed..250a07e 100644
--- a/Lib/fontTools/subset/__init__.py
+++ b/Lib/fontTools/subset/__init__.py
@@ -407,6 +407,10 @@
     *not* be switched on if an intersection is found.  [default]
 --no-prune-unicode-ranges
     Don't change the 'OS/2 ulUnicodeRange*' bits.
+--prune-codepage-ranges
+    Update the 'OS/2 ulCodePageRange*' bits after subsetting.  [default]
+--no-prune-codepage-ranges
+    Don't change the 'OS/2 ulCodePageRange*' bits.
 --recalc-average-width
     Update the 'OS/2 xAvgCharWidth' field after subsetting.
 --no-recalc-average-width
@@ -3086,6 +3090,7 @@
         self.recalc_bounds = False  # Recalculate font bounding boxes
         self.recalc_timestamp = False  # Recalculate font modified timestamp
         self.prune_unicode_ranges = True  # Clear unused 'ulUnicodeRange' bits
+        self.prune_codepage_ranges = True  # Clear unused 'ulCodePageRange' bits
         self.recalc_average_width = False  # update 'xAvgCharWidth'
         self.recalc_max_context = False  # update 'usMaxContext'
         self.canonical_order = None  # Order tables as recommended
@@ -3450,6 +3455,17 @@
                         log.info(
                             "%s Unicode ranges pruned: %s", tag, sorted(new_uniranges)
                         )
+                if self.options.prune_codepage_ranges and font[tag].version >= 1:
+                    # codepage range fields were added with OS/2 format 1
+                    # https://learn.microsoft.com/en-us/typography/opentype/spec/os2#version-1
+                    old_codepages = font[tag].getCodePageRanges()
+                    new_codepages = font[tag].recalcCodePageRanges(font, pruneOnly=True)
+                    if old_codepages != new_codepages:
+                        log.info(
+                            "%s CodePage ranges pruned: %s",
+                            tag,
+                            sorted(new_codepages),
+                        )
                 if self.options.recalc_average_width:
                     old_avg_width = font[tag].xAvgCharWidth
                     new_avg_width = font[tag].recalcAvgCharWidth(font)
@@ -3717,6 +3733,3 @@
     "parse_unicodes",
     "main",
 ]
-
-if __name__ == "__main__":
-    sys.exit(main())
diff --git a/Lib/fontTools/subset/cff.py b/Lib/fontTools/subset/cff.py
index dd79f6d..03fc565 100644
--- a/Lib/fontTools/subset/cff.py
+++ b/Lib/fontTools/subset/cff.py
@@ -502,7 +502,7 @@
         # Renumber glyph charstrings
         for g in font.charset:
             c, _ = cs.getItemAndSelector(g)
-            subrs = getattr(c.private, "Subrs", [])
+            subrs = getattr(c.private, "Subrs", None)
             c.subset_subroutines(subrs, font.GlobalSubrs)
 
         # Renumber subroutines themselves
@@ -511,7 +511,7 @@
                 if not hasattr(font, "FDArray") and hasattr(font.Private, "Subrs"):
                     local_subrs = font.Private.Subrs
                 else:
-                    local_subrs = []
+                    local_subrs = None
             else:
                 local_subrs = subrs
 
diff --git a/Lib/fontTools/svgLib/path/arc.py b/Lib/fontTools/svgLib/path/arc.py
index 3e0a211..4b2aa5c 100644
--- a/Lib/fontTools/svgLib/path/arc.py
+++ b/Lib/fontTools/svgLib/path/arc.py
@@ -4,6 +4,7 @@
 https://github.com/chromium/chromium/blob/93831f2/third_party/
 blink/renderer/core/svg/svg_path_parser.cc#L169-L278
 """
+
 from fontTools.misc.transform import Identity, Scale
 from math import atan2, ceil, cos, fabs, isfinite, pi, radians, sin, sqrt, tan
 
diff --git a/Lib/fontTools/t1Lib/__init__.py b/Lib/fontTools/t1Lib/__init__.py
index a64f780..0475881 100644
--- a/Lib/fontTools/t1Lib/__init__.py
+++ b/Lib/fontTools/t1Lib/__init__.py
@@ -15,6 +15,7 @@
 	part should be written as hexadecimal or binary, but only if kind
 	is 'OTHER'.
 """
+
 import fontTools
 from fontTools.misc import eexec
 from fontTools.misc.macCreatorType import getMacCreatorAndType
@@ -49,7 +50,6 @@
 
 
 class T1Font(object):
-
     """Type 1 font class.
 
     Uses a minimal interpeter that supports just about enough PS to parse
diff --git a/Lib/fontTools/ttLib/macUtils.py b/Lib/fontTools/ttLib/macUtils.py
index 468a75a..0959a6f 100644
--- a/Lib/fontTools/ttLib/macUtils.py
+++ b/Lib/fontTools/ttLib/macUtils.py
@@ -1,4 +1,5 @@
 """ttLib.macUtils.py -- Various Mac-specific stuff."""
+
 from io import BytesIO
 from fontTools.misc.macRes import ResourceReader, ResourceError
 
@@ -35,7 +36,6 @@
 
 
 class SFNTResourceReader(BytesIO):
-
     """Simple read-only file wrapper for 'sfnt' resources."""
 
     def __init__(self, path, res_name_or_index):
diff --git a/Lib/fontTools/ttLib/removeOverlaps.py b/Lib/fontTools/ttLib/removeOverlaps.py
index 624cd47..4795320 100644
--- a/Lib/fontTools/ttLib/removeOverlaps.py
+++ b/Lib/fontTools/ttLib/removeOverlaps.py
@@ -202,9 +202,11 @@
     glyphNames = sorted(
         glyphNames,
         key=lambda name: (
-            glyfTable[name].getCompositeMaxpValues(glyfTable).maxComponentDepth
-            if glyfTable[name].isComposite()
-            else 0,
+            (
+                glyfTable[name].getCompositeMaxpValues(glyfTable).maxComponentDepth
+                if glyfTable[name].isComposite()
+                else 0
+            ),
             name,
         ),
     )
diff --git a/Lib/fontTools/ttLib/scaleUpem.py b/Lib/fontTools/ttLib/scaleUpem.py
index 3f9b22a..2909bfc 100644
--- a/Lib/fontTools/ttLib/scaleUpem.py
+++ b/Lib/fontTools/ttLib/scaleUpem.py
@@ -3,7 +3,6 @@
 AAT and Graphite tables are not supported. CFF/CFF2 fonts
 are de-subroutinized."""
 
-
 from fontTools.ttLib.ttVisitor import TTVisitor
 import fontTools.ttLib as ttLib
 import fontTools.ttLib.tables.otBase as otBase
diff --git a/Lib/fontTools/ttLib/tables/C_O_L_R_.py b/Lib/fontTools/ttLib/tables/C_O_L_R_.py
index 2f03ec0..df85784 100644
--- a/Lib/fontTools/ttLib/tables/C_O_L_R_.py
+++ b/Lib/fontTools/ttLib/tables/C_O_L_R_.py
@@ -7,7 +7,6 @@
 
 
 class table_C_O_L_R_(DefaultTable.DefaultTable):
-
     """This table is structured so that you can treat it like a dictionary keyed by glyph name.
 
     ``ttFont['COLR'][<glyphName>]`` will return the color layers for any glyph.
diff --git a/Lib/fontTools/ttLib/tables/O_S_2f_2.py b/Lib/fontTools/ttLib/tables/O_S_2f_2.py
index 7b40302..0c739bc 100644
--- a/Lib/fontTools/ttLib/tables/O_S_2f_2.py
+++ b/Lib/fontTools/ttLib/tables/O_S_2f_2.py
@@ -113,7 +113,6 @@
 
 
 class table_O_S_2f_2(DefaultTable.DefaultTable):
-
     """the OS/2 table"""
 
     dependencies = ["head"]
@@ -340,6 +339,49 @@
         self.setUnicodeRanges(bits)
         return bits
 
+    def getCodePageRanges(self):
+        """Return the set of 'ulCodePageRange*' bits currently enabled."""
+        bits = set()
+        if self.version < 1:
+            return bits
+        ul1, ul2 = self.ulCodePageRange1, self.ulCodePageRange2
+        for i in range(32):
+            if ul1 & (1 << i):
+                bits.add(i)
+            if ul2 & (1 << i):
+                bits.add(i + 32)
+        return bits
+
+    def setCodePageRanges(self, bits):
+        """Set the 'ulCodePageRange*' fields to the specified 'bits'."""
+        ul1, ul2 = 0, 0
+        for bit in bits:
+            if 0 <= bit < 32:
+                ul1 |= 1 << bit
+            elif 32 <= bit < 64:
+                ul2 |= 1 << (bit - 32)
+            else:
+                raise ValueError(f"expected 0 <= int <= 63, found: {bit:r}")
+        if self.version < 1:
+            self.version = 1
+        self.ulCodePageRange1, self.ulCodePageRange2 = ul1, ul2
+
+    def recalcCodePageRanges(self, ttFont, pruneOnly=False):
+        unicodes = set()
+        for table in ttFont["cmap"].tables:
+            if table.isUnicode():
+                unicodes.update(table.cmap.keys())
+        bits = calcCodePageRanges(unicodes)
+        if pruneOnly:
+            bits &= self.getCodePageRanges()
+        # when no codepage ranges can be enabled, fall back to enabling bit 0
+        # (Latin 1) so that the font works in MS Word:
+        # https://github.com/googlei18n/fontmake/issues/468
+        if not bits:
+            bits = {0}
+        self.setCodePageRanges(bits)
+        return bits
+
     def recalcAvgCharWidth(self, ttFont):
         """Recalculate xAvgCharWidth using metrics from ttFont's 'hmtx' table.
 
@@ -611,6 +653,92 @@
     return set(range(len(OS2_UNICODE_RANGES))) - bits if inverse else bits
 
 
+def calcCodePageRanges(unicodes):
+    """Given a set of Unicode codepoints (integers), calculate the
+    corresponding OS/2 CodePage range bits.
+    This is a direct translation of FontForge implementation:
+    https://github.com/fontforge/fontforge/blob/7b2c074/fontforge/tottf.c#L3158
+    """
+    bits = set()
+    hasAscii = set(range(0x20, 0x7E)).issubset(unicodes)
+    hasLineart = ord("┤") in unicodes
+
+    for uni in unicodes:
+        if uni == ord("Þ") and hasAscii:
+            bits.add(0)  # Latin 1
+        elif uni == ord("Ľ") and hasAscii:
+            bits.add(1)  # Latin 2: Eastern Europe
+            if hasLineart:
+                bits.add(58)  # Latin 2
+        elif uni == ord("Б"):
+            bits.add(2)  # Cyrillic
+            if ord("Ѕ") in unicodes and hasLineart:
+                bits.add(57)  # IBM Cyrillic
+            if ord("╜") in unicodes and hasLineart:
+                bits.add(49)  # MS-DOS Russian
+        elif uni == ord("Ά"):
+            bits.add(3)  # Greek
+            if hasLineart and ord("½") in unicodes:
+                bits.add(48)  # IBM Greek
+            if hasLineart and ord("√") in unicodes:
+                bits.add(60)  # Greek, former 437 G
+        elif uni == ord("İ") and hasAscii:
+            bits.add(4)  # Turkish
+            if hasLineart:
+                bits.add(56)  # IBM turkish
+        elif uni == ord("א"):
+            bits.add(5)  # Hebrew
+            if hasLineart and ord("√") in unicodes:
+                bits.add(53)  # Hebrew
+        elif uni == ord("ر"):
+            bits.add(6)  # Arabic
+            if ord("√") in unicodes:
+                bits.add(51)  # Arabic
+            if hasLineart:
+                bits.add(61)  # Arabic; ASMO 708
+        elif uni == ord("ŗ") and hasAscii:
+            bits.add(7)  # Windows Baltic
+            if hasLineart:
+                bits.add(59)  # MS-DOS Baltic
+        elif uni == ord("₫") and hasAscii:
+            bits.add(8)  # Vietnamese
+        elif uni == ord("ๅ"):
+            bits.add(16)  # Thai
+        elif uni == ord("エ"):
+            bits.add(17)  # JIS/Japan
+        elif uni == ord("ㄅ"):
+            bits.add(18)  # Chinese: Simplified
+        elif uni == ord("ㄱ"):
+            bits.add(19)  # Korean wansung
+        elif uni == ord("央"):
+            bits.add(20)  # Chinese: Traditional
+        elif uni == ord("곴"):
+            bits.add(21)  # Korean Johab
+        elif uni == ord("♥") and hasAscii:
+            bits.add(30)  # OEM Character Set
+        # TODO: Symbol bit has a special meaning (check the spec), we need
+        # to confirm if this is wanted by default.
+        # elif chr(0xF000) <= char <= chr(0xF0FF):
+        #    codepageRanges.add(31)          # Symbol Character Set
+        elif uni == ord("þ") and hasAscii and hasLineart:
+            bits.add(54)  # MS-DOS Icelandic
+        elif uni == ord("╚") and hasAscii:
+            bits.add(62)  # WE/Latin 1
+            bits.add(63)  # US
+        elif hasAscii and hasLineart and ord("√") in unicodes:
+            if uni == ord("Å"):
+                bits.add(50)  # MS-DOS Nordic
+            elif uni == ord("é"):
+                bits.add(52)  # MS-DOS Canadian French
+            elif uni == ord("õ"):
+                bits.add(55)  # MS-DOS Portuguese
+
+    if hasAscii and ord("‰") in unicodes and ord("∑") in unicodes:
+        bits.add(29)  # Macintosh Character Set (US Roman)
+
+    return bits
+
+
 if __name__ == "__main__":
     import doctest, sys
 
diff --git a/Lib/fontTools/ttLib/tables/T_S_I__0.py b/Lib/fontTools/ttLib/tables/T_S_I__0.py
index f15fc67..7790582 100644
--- a/Lib/fontTools/ttLib/tables/T_S_I__0.py
+++ b/Lib/fontTools/ttLib/tables/T_S_I__0.py
@@ -5,6 +5,7 @@
 programs and 'extra' programs ('fpgm', 'prep', and 'cvt') that are contained
 in the TSI1 table.
 """
+
 from . import DefaultTable
 import struct
 
diff --git a/Lib/fontTools/ttLib/tables/T_S_I__1.py b/Lib/fontTools/ttLib/tables/T_S_I__1.py
index 55aca33..a9d04a0 100644
--- a/Lib/fontTools/ttLib/tables/T_S_I__1.py
+++ b/Lib/fontTools/ttLib/tables/T_S_I__1.py
@@ -4,6 +4,7 @@
 TSI1 contains the text of the glyph programs in the form of low-level assembly
 code, as well as the 'extra' programs 'fpgm', 'ppgm' (i.e. 'prep'), and 'cvt'.
 """
+
 from . import DefaultTable
 from fontTools.misc.loggingTools import LogMixin
 from fontTools.misc.textTools import strjoin, tobytes, tostr
diff --git a/Lib/fontTools/ttLib/tables/T_S_I__2.py b/Lib/fontTools/ttLib/tables/T_S_I__2.py
index 4278be1..163ef45 100644
--- a/Lib/fontTools/ttLib/tables/T_S_I__2.py
+++ b/Lib/fontTools/ttLib/tables/T_S_I__2.py
@@ -5,6 +5,7 @@
 programs that are contained in the TSI3 table. It uses the same format as
 the TSI0 table.
 """
+
 from fontTools import ttLib
 
 superclass = ttLib.getTableClass("TSI0")
diff --git a/Lib/fontTools/ttLib/tables/T_S_I__3.py b/Lib/fontTools/ttLib/tables/T_S_I__3.py
index 785ca23..604a7f0 100644
--- a/Lib/fontTools/ttLib/tables/T_S_I__3.py
+++ b/Lib/fontTools/ttLib/tables/T_S_I__3.py
@@ -3,6 +3,7 @@
 
 TSI3 contains the text of the glyph programs in the form of 'VTTTalk' code.
 """
+
 from fontTools import ttLib
 
 superclass = ttLib.getTableClass("TSI1")
diff --git a/Lib/fontTools/ttLib/tables/T_S_I__5.py b/Lib/fontTools/ttLib/tables/T_S_I__5.py
index 5edc86a..d867986 100644
--- a/Lib/fontTools/ttLib/tables/T_S_I__5.py
+++ b/Lib/fontTools/ttLib/tables/T_S_I__5.py
@@ -3,6 +3,7 @@
 
 TSI5 contains the VTT character groups.
 """
+
 from fontTools.misc.textTools import safeEval
 from . import DefaultTable
 import sys
diff --git a/Lib/fontTools/ttLib/tables/TupleVariation.py b/Lib/fontTools/ttLib/tables/TupleVariation.py
index 30d0099..027ac15 100644
--- a/Lib/fontTools/ttLib/tables/TupleVariation.py
+++ b/Lib/fontTools/ttLib/tables/TupleVariation.py
@@ -517,22 +517,22 @@
             return  # no change
         coordWidth = self.getCoordWidth()
         self.coordinates = [
-            None
-            if d is None
-            else d * scalar
-            if coordWidth == 1
-            else (d[0] * scalar, d[1] * scalar)
+            (
+                None
+                if d is None
+                else d * scalar if coordWidth == 1 else (d[0] * scalar, d[1] * scalar)
+            )
             for d in self.coordinates
         ]
 
     def roundDeltas(self):
         coordWidth = self.getCoordWidth()
         self.coordinates = [
-            None
-            if d is None
-            else otRound(d)
-            if coordWidth == 1
-            else (otRound(d[0]), otRound(d[1]))
+            (
+                None
+                if d is None
+                else otRound(d) if coordWidth == 1 else (otRound(d[0]), otRound(d[1]))
+            )
             for d in self.coordinates
         ]
 
diff --git a/Lib/fontTools/ttLib/tables/V_O_R_G_.py b/Lib/fontTools/ttLib/tables/V_O_R_G_.py
index 4508c13..b08737b 100644
--- a/Lib/fontTools/ttLib/tables/V_O_R_G_.py
+++ b/Lib/fontTools/ttLib/tables/V_O_R_G_.py
@@ -4,7 +4,6 @@
 
 
 class table_V_O_R_G_(DefaultTable.DefaultTable):
-
     """This table is structured so that you can treat it like a dictionary keyed by glyph name.
 
     ``ttFont['VORG'][<glyphName>]`` will return the vertical origin for any glyph.
diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py
index bff0d92..683912b 100644
--- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py
+++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py
@@ -1211,6 +1211,9 @@
                 g.recalcBounds(glyfTable, boundsDone=boundsDone)
                 if boundsDone is not None:
                     boundsDone.add(glyphName)
+            # empty components shouldn't update the bounds of the parent glyph
+            if g.numberOfContours == 0:
+                continue
 
             x, y = compo.x, compo.y
             bounds = updateBounds(bounds, (g.xMin + x, g.yMin + y))
diff --git a/Lib/fontTools/ttLib/tables/_k_e_r_n.py b/Lib/fontTools/ttLib/tables/_k_e_r_n.py
index 8f55a31..270b3b7 100644
--- a/Lib/fontTools/ttLib/tables/_k_e_r_n.py
+++ b/Lib/fontTools/ttLib/tables/_k_e_r_n.py
@@ -147,9 +147,9 @@
             except IndexError:
                 # Slower, but will not throw an IndexError on an invalid
                 # glyph id.
-                kernTable[
-                    (ttFont.getGlyphName(left), ttFont.getGlyphName(right))
-                ] = value
+                kernTable[(ttFont.getGlyphName(left), ttFont.getGlyphName(right))] = (
+                    value
+                )
         if len(data) > 6 * nPairs + 4:  # Ignore up to 4 bytes excess
             log.warning(
                 "excess data in 'kern' subtable: %d bytes", len(data) - 6 * nPairs
diff --git a/Lib/fontTools/ttLib/tables/otBase.py b/Lib/fontTools/ttLib/tables/otBase.py
index d565603..53abd13 100644
--- a/Lib/fontTools/ttLib/tables/otBase.py
+++ b/Lib/fontTools/ttLib/tables/otBase.py
@@ -79,7 +79,6 @@
 
 
 class BaseTTXConverter(DefaultTable):
-
     """Generic base class for TTX table converters. It functions as an
     adapter between the TTX (ttLib actually) table model and the model
     we use for OpenType tables, which is necessarily subtly different.
@@ -260,7 +259,6 @@
 
 
 class OTTableReader(object):
-
     """Helper class to retrieve data from an OpenType table."""
 
     __slots__ = ("data", "offset", "pos", "localState", "tableTag")
@@ -392,7 +390,6 @@
 
 
 class OTTableWriter(object):
-
     """Helper class to gather and assemble data for OpenType tables."""
 
     def __init__(self, localState=None, tableTag=None):
@@ -882,7 +879,6 @@
 
 
 class BaseTable(object):
-
     """Generic base class for all OpenType (sub)tables."""
 
     def __getattr__(self, attr):
@@ -1210,7 +1206,6 @@
 
 
 class FormatSwitchingBaseTable(BaseTable):
-
     """Minor specialization of BaseTable, for tables that have multiple
     formats, eg. CoverageFormat1 vs. CoverageFormat2."""
 
@@ -1335,7 +1330,6 @@
 
 
 class ValueRecordFactory(object):
-
     """Given a format code, this object convert ValueRecords."""
 
     def __init__(self, valueFormat):
diff --git a/Lib/fontTools/ttLib/tables/otConverters.py b/Lib/fontTools/ttLib/tables/otConverters.py
index 390f166..afe4e53 100644
--- a/Lib/fontTools/ttLib/tables/otConverters.py
+++ b/Lib/fontTools/ttLib/tables/otConverters.py
@@ -146,7 +146,6 @@
 
 
 class BaseConverter(object):
-
     """Base class for converter objects. Apart from the constructor, this
     is an abstract class."""
 
diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py
index 262f8d4..3505f42 100644
--- a/Lib/fontTools/ttLib/tables/otTables.py
+++ b/Lib/fontTools/ttLib/tables/otTables.py
@@ -1123,6 +1123,35 @@
         self.ligatures = ligatures
         del self.Format  # Don't need this anymore
 
+    @staticmethod
+    def _getLigatureSortKey(components):
+        # Computes a key for ordering ligatures in a GSUB Type-4 lookup.
+
+        # When building the OpenType lookup, we need to make sure that
+        # the longest sequence of components is listed first, so we
+        # use the negative length as the key for sorting.
+        # Note, we no longer need to worry about deterministic order because the
+        # ligature mapping `dict` remembers the insertion order, and this in
+        # turn depends on the order in which the ligatures are written in the FEA.
+        # Since python sort algorithm is stable, the ligatures of equal length
+        # will keep the relative order in which they appear in the feature file.
+        # For example, given the following ligatures (all starting with 'f' and
+        # thus belonging to the same LigatureSet):
+        #
+        #   feature liga {
+        #     sub f i by f_i;
+        #     sub f f f by f_f_f;
+        #     sub f f by f_f;
+        #     sub f f i by f_f_i;
+        #   } liga;
+        #
+        # this should sort to: f_f_f, f_f_i, f_i, f_f
+        # This is also what fea-rs does, see:
+        # https://github.com/adobe-type-tools/afdko/issues/1727
+        # https://github.com/fonttools/fonttools/issues/3428
+        # https://github.com/googlefonts/fontc/pull/680
+        return -len(components)
+
     def preWrite(self, font):
         self.Format = 1
         ligatures = getattr(self, "ligatures", None)
@@ -1135,13 +1164,11 @@
 
             # ligatures is map from components-sequence to lig-glyph
             newLigatures = dict()
-            for comps, lig in sorted(
-                ligatures.items(), key=lambda item: (-len(item[0]), item[0])
-            ):
+            for comps in sorted(ligatures.keys(), key=self._getLigatureSortKey):
                 ligature = Ligature()
                 ligature.Component = comps[1:]
                 ligature.CompCount = len(comps)
-                ligature.LigGlyph = lig
+                ligature.LigGlyph = ligatures[comps]
                 newLigatures.setdefault(comps[0], []).append(ligature)
             ligatures = newLigatures
 
diff --git a/Lib/fontTools/ttLib/tables/otTraverse.py b/Lib/fontTools/ttLib/tables/otTraverse.py
index bf22dcf..ac94218 100644
--- a/Lib/fontTools/ttLib/tables/otTraverse.py
+++ b/Lib/fontTools/ttLib/tables/otTraverse.py
@@ -1,4 +1,5 @@
 """Methods for traversing trees of otData-driven OpenType tables."""
+
 from collections import deque
 from typing import Callable, Deque, Iterable, List, Optional, Tuple
 from .otBase import BaseTable
diff --git a/Lib/fontTools/ttLib/tables/sbixGlyph.py b/Lib/fontTools/ttLib/tables/sbixGlyph.py
index fd687a1..b744a2a 100644
--- a/Lib/fontTools/ttLib/tables/sbixGlyph.py
+++ b/Lib/fontTools/ttLib/tables/sbixGlyph.py
@@ -54,6 +54,10 @@
                 # pad with spaces
                 self.graphicType += "    "[: (4 - len(self.graphicType))]
 
+    def is_reference_type(self):
+        """Returns True if this glyph is a reference to another glyph's image data."""
+        return self.graphicType == "dupe" or self.graphicType == "flip"
+
     def decompile(self, ttFont):
         self.glyphName = ttFont.getGlyphName(self.gid)
         if self.rawdata is None:
@@ -71,7 +75,7 @@
                 sbixGlyphHeaderFormat, self.rawdata[:sbixGlyphHeaderFormatSize], self
             )
 
-            if self.graphicType == "dupe":
+            if self.is_reference_type():
                 # this glyph is a reference to another glyph's image data
                 (gid,) = struct.unpack(">H", self.rawdata[sbixGlyphHeaderFormatSize:])
                 self.referenceGlyphName = ttFont.getGlyphName(gid)
@@ -94,7 +98,7 @@
             rawdata = b""
         else:
             rawdata = sstruct.pack(sbixGlyphHeaderFormat, self)
-            if self.graphicType == "dupe":
+            if self.is_reference_type():
                 rawdata += struct.pack(">H", ttFont.getGlyphID(self.referenceGlyphName))
             else:
                 assert self.imageData is not None
@@ -117,8 +121,8 @@
             originOffsetY=self.originOffsetY,
         )
         xmlWriter.newline()
-        if self.graphicType == "dupe":
-            # graphicType == "dupe" is a reference to another glyph id.
+        if self.is_reference_type():
+            # this glyph is a reference to another glyph id.
             xmlWriter.simpletag("ref", glyphname=self.referenceGlyphName)
         else:
             xmlWriter.begintag("hexdata")
@@ -131,7 +135,7 @@
 
     def fromXML(self, name, attrs, content, ttFont):
         if name == "ref":
-            # glyph is a "dupe", i.e. a reference to another glyph's image data.
+            # this glyph i.e. a reference to another glyph's image data.
             # in this case imageData contains the glyph id of the reference glyph
             # get glyph id from glyphname
             glyphname = safeEval("'''" + attrs["glyphname"] + "'''")
diff --git a/Lib/fontTools/ttLib/tables/ttProgram.py b/Lib/fontTools/ttLib/tables/ttProgram.py
index 84aa63f..32a4ec8 100644
--- a/Lib/fontTools/ttLib/tables/ttProgram.py
+++ b/Lib/fontTools/ttLib/tables/ttProgram.py
@@ -1,4 +1,5 @@
 """ttLib.tables.ttProgram.py -- Assembler/disassembler for TrueType bytecode programs."""
+
 from __future__ import annotations
 
 from fontTools.misc.textTools import num2binary, binary2num, readHex, strjoin
diff --git a/Lib/fontTools/ttLib/ttCollection.py b/Lib/fontTools/ttLib/ttCollection.py
index 70ed4b7..f01bc42 100644
--- a/Lib/fontTools/ttLib/ttCollection.py
+++ b/Lib/fontTools/ttLib/ttCollection.py
@@ -8,7 +8,6 @@
 
 
 class TTCollection(object):
-
     """Object representing a TrueType Collection / OpenType Collection.
     The main API is self.fonts being a list of TTFont instances.
 
diff --git a/Lib/fontTools/ttLib/ttFont.py b/Lib/fontTools/ttLib/ttFont.py
index 6a9ca09..ad62a18 100644
--- a/Lib/fontTools/ttLib/ttFont.py
+++ b/Lib/fontTools/ttLib/ttFont.py
@@ -15,7 +15,6 @@
 
 
 class TTFont(object):
-
     """Represents a TrueType font.
 
     The object manages file input and output, and offers a convenient way of
@@ -735,7 +734,9 @@
         else:
             raise KeyError(tag)
 
-    def getGlyphSet(self, preferCFF=True, location=None, normalized=False):
+    def getGlyphSet(
+        self, preferCFF=True, location=None, normalized=False, recalcBounds=True
+    ):
         """Return a generic GlyphSet, which is a dict-like object
         mapping glyph names to glyph objects. The returned glyph objects
         have a ``.draw()`` method that supports the Pen protocol, and will
@@ -766,7 +767,7 @@
         if ("CFF " in self or "CFF2" in self) and (preferCFF or "glyf" not in self):
             return _TTGlyphSetCFF(self, location)
         elif "glyf" in self:
-            return _TTGlyphSetGlyf(self, location)
+            return _TTGlyphSetGlyf(self, location, recalcBounds=recalcBounds)
         else:
             raise TTLibError("Font contains no outlines")
 
@@ -841,7 +842,6 @@
 
 
 class GlyphOrder(object):
-
     """A pseudo table. The glyph order isn't in the font as a separate
     table, but it's nice to present it as such in the TTX format.
     """
diff --git a/Lib/fontTools/ttLib/ttGlyphSet.py b/Lib/fontTools/ttLib/ttGlyphSet.py
index d4384c8..b4beb3e 100644
--- a/Lib/fontTools/ttLib/ttGlyphSet.py
+++ b/Lib/fontTools/ttLib/ttGlyphSet.py
@@ -9,15 +9,20 @@
 from fontTools.misc.loggingTools import deprecateFunction
 from fontTools.misc.transform import Transform
 from fontTools.pens.transformPen import TransformPen, TransformPointPen
+from fontTools.pens.recordingPen import (
+    DecomposingRecordingPen,
+    lerpRecordings,
+    replayRecording,
+)
 
 
 class _TTGlyphSet(Mapping):
-
     """Generic dict-like GlyphSet class that pulls metrics from hmtx and
     glyph shape from TrueType or CFF.
     """
 
-    def __init__(self, font, location, glyphsMapping):
+    def __init__(self, font, location, glyphsMapping, *, recalcBounds=True):
+        self.recalcBounds = recalcBounds
         self.font = font
         self.defaultLocationNormalized = (
             {axis.axisTag: 0 for axis in self.font["fvar"].axes}
@@ -89,13 +94,13 @@
 
 
 class _TTGlyphSetGlyf(_TTGlyphSet):
-    def __init__(self, font, location):
+    def __init__(self, font, location, recalcBounds=True):
         self.glyfTable = font["glyf"]
-        super().__init__(font, location, self.glyfTable)
+        super().__init__(font, location, self.glyfTable, recalcBounds=recalcBounds)
         self.gvarTable = font.get("gvar")
 
     def __getitem__(self, glyphName):
-        return _TTGlyphGlyf(self, glyphName)
+        return _TTGlyphGlyf(self, glyphName, recalcBounds=self.recalcBounds)
 
 
 class _TTGlyphSetCFF(_TTGlyphSet):
@@ -119,7 +124,6 @@
 
 
 class _TTGlyph(ABC):
-
     """Glyph object that supports the Pen protocol, meaning that it has
     .draw() and .drawPoints() methods that take a pen object as their only
     argument. Additionally there are 'width' and 'lsb' attributes, read from
@@ -129,9 +133,10 @@
     attributes.
     """
 
-    def __init__(self, glyphSet, glyphName):
+    def __init__(self, glyphSet, glyphName, *, recalcBounds=True):
         self.glyphSet = glyphSet
         self.name = glyphName
+        self.recalcBounds = recalcBounds
         self.width, self.lsb = glyphSet.hMetrics[glyphName]
         if glyphSet.vMetrics is not None:
             self.height, self.tsb = glyphSet.vMetrics[glyphName]
@@ -258,7 +263,9 @@
             coordinates += GlyphCoordinates(delta) * scalar
 
         glyph = copy(glyfTable[self.name])  # Shallow copy
-        width, lsb, height, tsb = _setCoordinates(glyph, coordinates, glyfTable)
+        width, lsb, height, tsb = _setCoordinates(
+            glyph, coordinates, glyfTable, recalcBounds=self.recalcBounds
+        )
         self.lsb = lsb
         self.tsb = tsb
         if glyphSet.hvarTable is None:
@@ -276,7 +283,7 @@
         self.glyphSet.charStrings[self.name].draw(pen, self.glyphSet.blender)
 
 
-def _setCoordinates(glyph, coord, glyfTable):
+def _setCoordinates(glyph, coord, glyfTable, *, recalcBounds=True):
     # Handle phantom points for (left, right, top, bottom) positions.
     assert len(coord) >= 4
     leftSideX = coord[-4][0]
@@ -304,7 +311,8 @@
         assert len(coord) == len(glyph.coordinates)
         glyph.coordinates = coord
 
-    glyph.recalcBounds(glyfTable)
+    if recalcBounds:
+        glyph.recalcBounds(glyfTable)
 
     horizontalAdvanceWidth = otRound(rightSideX - leftSideX)
     verticalAdvanceWidth = otRound(topSideY - bottomSideY)
@@ -316,3 +324,52 @@
         verticalAdvanceWidth,
         topSideBearing,
     )
+
+
+class LerpGlyphSet(Mapping):
+    """A glyphset that interpolates between two other glyphsets.
+
+    Factor is typically between 0 and 1. 0 means the first glyphset,
+    1 means the second glyphset, and 0.5 means the average of the
+    two glyphsets. Other values are possible, and can be useful to
+    extrapolate. Defaults to 0.5.
+    """
+
+    def __init__(self, glyphset1, glyphset2, factor=0.5):
+        self.glyphset1 = glyphset1
+        self.glyphset2 = glyphset2
+        self.factor = factor
+
+    def __getitem__(self, glyphname):
+        if glyphname in self.glyphset1 and glyphname in self.glyphset2:
+            return LerpGlyph(glyphname, self)
+        raise KeyError(glyphname)
+
+    def __contains__(self, glyphname):
+        return glyphname in self.glyphset1 and glyphname in self.glyphset2
+
+    def __iter__(self):
+        set1 = set(self.glyphset1)
+        set2 = set(self.glyphset2)
+        return iter(set1.intersection(set2))
+
+    def __len__(self):
+        set1 = set(self.glyphset1)
+        set2 = set(self.glyphset2)
+        return len(set1.intersection(set2))
+
+
+class LerpGlyph:
+    def __init__(self, glyphname, glyphset):
+        self.glyphset = glyphset
+        self.glyphname = glyphname
+
+    def draw(self, pen):
+        recording1 = DecomposingRecordingPen(self.glyphset.glyphset1)
+        self.glyphset.glyphset1[self.glyphname].draw(recording1)
+        recording2 = DecomposingRecordingPen(self.glyphset.glyphset2)
+        self.glyphset.glyphset2[self.glyphname].draw(recording2)
+
+        factor = self.glyphset.factor
+
+        replayRecording(lerpRecordings(recording1.value, recording2.value, factor), pen)
diff --git a/Lib/fontTools/ttx.py b/Lib/fontTools/ttx.py
index d8c2a3a..e7a0687 100644
--- a/Lib/fontTools/ttx.py
+++ b/Lib/fontTools/ttx.py
@@ -103,7 +103,6 @@
              extension is available at https://pypi.python.org/pypi/zopfli
 """
 
-
 from fontTools.ttLib import TTFont, TTLibError
 from fontTools.misc.macCreatorType import getMacCreatorAndType
 from fontTools.unicode import setUnicodeData
diff --git a/Lib/fontTools/ufoLib/__init__.py b/Lib/fontTools/ufoLib/__init__.py
index 1a456a2..c2d2b0b 100755
--- a/Lib/fontTools/ufoLib/__init__.py
+++ b/Lib/fontTools/ufoLib/__init__.py
@@ -197,7 +197,6 @@
 
 
 class UFOReader(_UFOBaseIO):
-
     """
     Read the various components of the .ufo.
 
@@ -881,7 +880,6 @@
 
 
 class UFOWriter(UFOReader):
-
     """
     Write the various components of the .ufo.
 
diff --git a/Lib/fontTools/ufoLib/converters.py b/Lib/fontTools/ufoLib/converters.py
index daccf78..88a26c6 100644
--- a/Lib/fontTools/ufoLib/converters.py
+++ b/Lib/fontTools/ufoLib/converters.py
@@ -2,7 +2,6 @@
 Conversion functions.
 """
 
-
 # adapted from the UFO spec
 
 
diff --git a/Lib/fontTools/ufoLib/etree.py b/Lib/fontTools/ufoLib/etree.py
index 5054f81..77e3c16 100644
--- a/Lib/fontTools/ufoLib/etree.py
+++ b/Lib/fontTools/ufoLib/etree.py
@@ -2,4 +2,5 @@
 for the old ufoLib.etree module, which was moved to fontTools.misc.etree.
 Please use the latter instead.
 """
+
 from fontTools.misc.etree import *
diff --git a/Lib/fontTools/ufoLib/glifLib.py b/Lib/fontTools/ufoLib/glifLib.py
index 6dee9db..62e87db 100755
--- a/Lib/fontTools/ufoLib/glifLib.py
+++ b/Lib/fontTools/ufoLib/glifLib.py
@@ -91,7 +91,6 @@
 
 
 class Glyph:
-
     """
     Minimal glyph object. It has no glyph attributes until either
     the draw() or the drawPoints() method has been called.
@@ -123,7 +122,6 @@
 
 
 class GlyphSet(_UFOBaseIO):
-
     """
     GlyphSet manages a set of .glif files inside one directory.
 
@@ -1228,9 +1226,9 @@
     unicodes = []
     guidelines = []
     anchors = []
-    haveSeenAdvance = (
-        haveSeenImage
-    ) = haveSeenOutline = haveSeenLib = haveSeenNote = False
+    haveSeenAdvance = haveSeenImage = haveSeenOutline = haveSeenLib = haveSeenNote = (
+        False
+    )
     identifiers = set()
     for element in tree:
         if element.tag == "outline":
@@ -1883,7 +1881,6 @@
 
 
 class GLIFPointPen(AbstractPointPen):
-
     """
     Helper class using the PointPen protocol to write the <outline>
     part of .glif files.
diff --git a/Lib/fontTools/ufoLib/plistlib.py b/Lib/fontTools/ufoLib/plistlib.py
index 1f52f20..38bb266 100644
--- a/Lib/fontTools/ufoLib/plistlib.py
+++ b/Lib/fontTools/ufoLib/plistlib.py
@@ -2,6 +2,7 @@
 for the old ufoLib.plistlib module, which was moved to fontTools.misc.plistlib.
 Please use the latter instead.
 """
+
 from fontTools.misc.plistlib import dump, dumps, load, loads
 from fontTools.misc.textTools import tobytes
 
diff --git a/Lib/fontTools/ufoLib/pointPen.py b/Lib/fontTools/ufoLib/pointPen.py
index 3433fdb..baef9a5 100644
--- a/Lib/fontTools/ufoLib/pointPen.py
+++ b/Lib/fontTools/ufoLib/pointPen.py
@@ -2,4 +2,5 @@
 for the old ufoLib.pointPen module, which was moved to fontTools.pens.pointPen.
 Please use the latter instead.
 """
+
 from fontTools.pens.pointPen import *
diff --git a/Lib/fontTools/ufoLib/utils.py b/Lib/fontTools/ufoLib/utils.py
index 85878b4..45ec1c5 100644
--- a/Lib/fontTools/ufoLib/utils.py
+++ b/Lib/fontTools/ufoLib/utils.py
@@ -1,6 +1,7 @@
 """The module contains miscellaneous helpers.
 It's not considered part of the public ufoLib API.
 """
+
 import warnings
 import functools
 
diff --git a/Lib/fontTools/unicodedata/__init__.py b/Lib/fontTools/unicodedata/__init__.py
index 808c9c7..06eb461 100644
--- a/Lib/fontTools/unicodedata/__init__.py
+++ b/Lib/fontTools/unicodedata/__init__.py
@@ -201,15 +201,13 @@
 
 
 @overload
-def script_horizontal_direction(script_code: str, default: T) -> HorizDirection | T:
-    ...
+def script_horizontal_direction(script_code: str, default: T) -> HorizDirection | T: ...
 
 
 @overload
 def script_horizontal_direction(
     script_code: str, default: type[KeyError] = KeyError
-) -> HorizDirection:
-    ...
+) -> HorizDirection: ...
 
 
 def script_horizontal_direction(
diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py
index b130d5b..1e0f2ec 100644
--- a/Lib/fontTools/varLib/__init__.py
+++ b/Lib/fontTools/varLib/__init__.py
@@ -18,6 +18,7 @@
 
 API *will* change in near future.
 """
+
 from typing import List
 from fontTools.misc.vector import Vector
 from fontTools.misc.roundTools import noRound, otRound
@@ -52,7 +53,8 @@
 log = logging.getLogger("fontTools.varLib")
 
 # This is a lib key for the designspace document. The value should be
-# an OpenType feature tag, to be used as the FeatureVariations feature.
+# a comma-separated list of OpenType feature tag(s), to be used as the
+# FeatureVariations feature.
 # If present, the DesignSpace <rules processing="..."> flag is ignored.
 FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag"
 
@@ -215,8 +217,6 @@
     if mappings:
         interesting = True
 
-        hiddenAxes = [axis for axis in axes.values() if axis.hidden]
-
         inputLocations = [
             {
                 axes[name].tag: models.normalizeValue(v, vals_triples[axes[name].tag])
@@ -570,9 +570,11 @@
     sparse_advance = 0xFFFF
     for glyph in glyphOrder:
         vhAdvances = [
-            metrics[glyph][0]
-            if glyph in metrics and metrics[glyph][0] != sparse_advance
-            else None
+            (
+                metrics[glyph][0]
+                if glyph in metrics and metrics[glyph][0] != sparse_advance
+                else None
+            )
             for metrics in advMetricses
         ]
         vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(
@@ -751,10 +753,14 @@
 
 
 def _merge_OTL(font, model, master_fonts, axisTags):
+    otl_tags = ["GSUB", "GDEF", "GPOS"]
+    if not any(tag in font for tag in otl_tags):
+        return
+
     log.info("Merging OpenType Layout tables")
     merger = VariationMerger(model, axisTags, font)
 
-    merger.mergeTables(font, master_fonts, ["GSUB", "GDEF", "GPOS"])
+    merger.mergeTables(font, master_fonts, otl_tags)
     store = merger.store_builder.finish()
     if not store:
         return
@@ -781,7 +787,9 @@
         font["GPOS"].table.remap_device_varidxes(varidx_map)
 
 
-def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules, featureTag):
+def _add_GSUB_feature_variations(
+    font, axes, internal_axis_supports, rules, featureTags
+):
     def normalize(name, value):
         return models.normalizeLocation({name: value}, internal_axis_supports)[name]
 
@@ -812,7 +820,7 @@
 
         conditional_subs.append((region, subs))
 
-    addFeatureVariations(font, conditional_subs, featureTag)
+    addFeatureVariations(font, conditional_subs, featureTags)
 
 
 _DesignSpaceData = namedtuple(
@@ -860,7 +868,7 @@
         colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes)
 
 
-def load_designspace(designspace):
+def load_designspace(designspace, log_enabled=True):
     # TODO: remove this and always assume 'designspace' is a DesignSpaceDocument,
     # never a file path, as that's already handled by caller
     if hasattr(designspace, "sources"):  # Assume a DesignspaceDocument
@@ -908,10 +916,11 @@
                 axis.labelNames["en"] = tostr(axis_name)
 
         axes[axis_name] = axis
-    log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()]))
+    if log_enabled:
+        log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()]))
 
     axisMappings = ds.axisMappings
-    if axisMappings:
+    if axisMappings and log_enabled:
         log.info("Mappings:\n%s", pformat(axisMappings))
 
     # Check all master and instance locations are valid and fill in defaults
@@ -941,20 +950,23 @@
     # Normalize master locations
 
     internal_master_locs = [o.getFullDesignLocation(ds) for o in masters]
-    log.info("Internal master locations:\n%s", pformat(internal_master_locs))
+    if log_enabled:
+        log.info("Internal master locations:\n%s", pformat(internal_master_locs))
 
     # TODO This mapping should ideally be moved closer to logic in _add_fvar/avar
     internal_axis_supports = {}
     for axis in axes.values():
         triple = (axis.minimum, axis.default, axis.maximum)
         internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple]
-    log.info("Internal axis supports:\n%s", pformat(internal_axis_supports))
+    if log_enabled:
+        log.info("Internal axis supports:\n%s", pformat(internal_axis_supports))
 
     normalized_master_locs = [
         models.normalizeLocation(m, internal_axis_supports)
         for m in internal_master_locs
     ]
-    log.info("Normalized master locations:\n%s", pformat(normalized_master_locs))
+    if log_enabled:
+        log.info("Normalized master locations:\n%s", pformat(normalized_master_locs))
 
     # Find base master
     base_idx = None
@@ -969,7 +981,8 @@
         raise VarLibValidationError(
             "Base master not found; no master at default location?"
         )
-    log.info("Index of base master: %s", base_idx)
+    if log_enabled:
+        log.info("Index of base master: %s", base_idx)
 
     return _DesignSpaceData(
         axes,
@@ -1204,11 +1217,9 @@
     if "cvar" not in exclude and "glyf" in vf:
         _merge_TTHinting(vf, model, master_fonts)
     if "GSUB" not in exclude and ds.rules:
-        featureTag = ds.lib.get(
-            FEAVAR_FEATURETAG_LIB_KEY, "rclt" if ds.rulesProcessingLast else "rvrn"
-        )
+        featureTags = _feature_variations_tags(ds)
         _add_GSUB_feature_variations(
-            vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTag
+            vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags
         )
     if "CFF2" not in exclude and ("CFF " in vf or "CFF2" in vf):
         _add_CFF2(vf, model, master_fonts)
@@ -1299,6 +1310,38 @@
         return os.path.normpath(path)
 
 
+def _feature_variations_tags(ds):
+    raw_tags = ds.lib.get(
+        FEAVAR_FEATURETAG_LIB_KEY,
+        "rclt" if ds.rulesProcessingLast else "rvrn",
+    )
+    return sorted({t.strip() for t in raw_tags.split(",")})
+
+
+def addGSUBFeatureVariations(vf, designspace, featureTags=(), *, log_enabled=False):
+    """Add GSUB FeatureVariations table to variable font, based on DesignSpace rules.
+
+    Args:
+        vf: A TTFont object representing the variable font.
+        designspace: A DesignSpaceDocument object.
+        featureTags: Optional feature tag(s) to use for the FeatureVariations records.
+            If unset, the key 'com.github.fonttools.varLib.featureVarsFeatureTag' is
+            looked up in the DS <lib> and used; otherwise the default is 'rclt' if
+            the <rules processing="last"> attribute is set, else 'rvrn'.
+            See <https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#rules-element>
+        log_enabled: If True, log info about DS axes and sources. Default is False, as
+            the same info may have already been logged as part of varLib.build.
+    """
+    ds = load_designspace(designspace, log_enabled=log_enabled)
+    if not ds.rules:
+        return
+    if not featureTags:
+        featureTags = _feature_variations_tags(ds)
+    _add_GSUB_feature_variations(
+        vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags
+    )
+
+
 def main(args=None):
     """Build variable fonts from a designspace file and masters"""
     from argparse import ArgumentParser
diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py
index f0403d7..2e957f5 100644
--- a/Lib/fontTools/varLib/featureVars.py
+++ b/Lib/fontTools/varLib/featureVars.py
@@ -3,6 +3,7 @@
 
 NOTE: The API is experimental and subject to change.
 """
+
 from fontTools.misc.dictTools import hashdict
 from fontTools.misc.intTools import bit_count
 from fontTools.ttLib import newTable
@@ -43,9 +44,18 @@
     # ... ]
     # >>> addFeatureVariations(f, condSubst)
     # >>> f.save(dstPath)
+
+    The `featureTag` parameter takes either a str or a iterable of str (the single str
+    is kept for backwards compatibility), and defines which feature(s) will be
+    associated with the feature variations.
+    Note, if this is "rvrn", then the substitution lookup will be inserted at the
+    beginning of the lookup list so that it is processed before others, otherwise
+    for any other feature tags it will be appended last.
     """
 
-    processLast = featureTag != "rvrn"
+    # process first when "rvrn" is the only listed tag
+    featureTags = [featureTag] if isinstance(featureTag, str) else sorted(featureTag)
+    processLast = "rvrn" not in featureTags or len(featureTags) > 1
 
     _checkSubstitutionGlyphsExist(
         glyphNames=set(font.getGlyphOrder()),
@@ -60,6 +70,14 @@
     )
     if "GSUB" not in font:
         font["GSUB"] = buildGSUB()
+    else:
+        existingTags = _existingVariableFeatures(font["GSUB"].table).intersection(
+            featureTags
+        )
+        if existingTags:
+            raise VarLibError(
+                f"FeatureVariations already exist for feature tag(s): {existingTags}"
+            )
 
     # setup lookups
     lookupMap = buildSubstitutionLookups(
@@ -75,7 +93,17 @@
             (conditionSet, [lookupMap[s] for s in substitutions])
         )
 
-    addFeatureVariationsRaw(font, font["GSUB"].table, conditionsAndLookups, featureTag)
+    addFeatureVariationsRaw(font, font["GSUB"].table, conditionsAndLookups, featureTags)
+
+
+def _existingVariableFeatures(table):
+    existingFeatureVarsTags = set()
+    if hasattr(table, "FeatureVariations") and table.FeatureVariations is not None:
+        features = table.FeatureList.FeatureRecord
+        for fvr in table.FeatureVariations.FeatureVariationRecord:
+            for ftsr in fvr.FeatureTableSubstitution.SubstitutionRecord:
+                existingFeatureVarsTags.add(features[ftsr.FeatureIndex].FeatureTag)
+    return existingFeatureVarsTags
 
 
 def _checkSubstitutionGlyphsExist(glyphNames, substitutions):
@@ -324,51 +352,73 @@
     """Low level implementation of addFeatureVariations that directly
     models the possibilities of the FeatureVariations table."""
 
-    processLast = featureTag != "rvrn"
+    featureTags = [featureTag] if isinstance(featureTag, str) else sorted(featureTag)
+    processLast = "rvrn" not in featureTags or len(featureTags) > 1
 
     #
-    # if there is no <featureTag> feature:
+    # if a <featureTag> feature is not present:
     #     make empty <featureTag> feature
     #     sort features, get <featureTag> feature index
     #     add <featureTag> feature to all scripts
+    # if a <featureTag> feature is present:
+    #     reuse <featureTag> feature index
     # make lookups
     # add feature variations
     #
     if table.Version < 0x00010001:
         table.Version = 0x00010001  # allow table.FeatureVariations
 
-    table.FeatureVariations = None  # delete any existing FeatureVariations
+    varFeatureIndices = set()
 
-    varFeatureIndices = []
-    for index, feature in enumerate(table.FeatureList.FeatureRecord):
-        if feature.FeatureTag == featureTag:
-            varFeatureIndices.append(index)
+    existingTags = {
+        feature.FeatureTag
+        for feature in table.FeatureList.FeatureRecord
+        if feature.FeatureTag in featureTags
+    }
 
-    if not varFeatureIndices:
-        varFeature = buildFeatureRecord(featureTag, [])
-        table.FeatureList.FeatureRecord.append(varFeature)
+    newTags = set(featureTags) - existingTags
+    if newTags:
+        varFeatures = []
+        for featureTag in sorted(newTags):
+            varFeature = buildFeatureRecord(featureTag, [])
+            table.FeatureList.FeatureRecord.append(varFeature)
+            varFeatures.append(varFeature)
         table.FeatureList.FeatureCount = len(table.FeatureList.FeatureRecord)
 
         sortFeatureList(table)
-        varFeatureIndex = table.FeatureList.FeatureRecord.index(varFeature)
 
-        for scriptRecord in table.ScriptList.ScriptRecord:
-            if scriptRecord.Script.DefaultLangSys is None:
-                raise VarLibError(
-                    "Feature variations require that the script "
-                    f"'{scriptRecord.ScriptTag}' defines a default language system."
-                )
-            langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord]
-            for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems:
-                langSys.FeatureIndex.append(varFeatureIndex)
-                langSys.FeatureCount = len(langSys.FeatureIndex)
+        for varFeature in varFeatures:
+            varFeatureIndex = table.FeatureList.FeatureRecord.index(varFeature)
 
-        varFeatureIndices = [varFeatureIndex]
+            for scriptRecord in table.ScriptList.ScriptRecord:
+                if scriptRecord.Script.DefaultLangSys is None:
+                    raise VarLibError(
+                        "Feature variations require that the script "
+                        f"'{scriptRecord.ScriptTag}' defines a default language system."
+                    )
+                langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord]
+                for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems:
+                    langSys.FeatureIndex.append(varFeatureIndex)
+                    langSys.FeatureCount = len(langSys.FeatureIndex)
+            varFeatureIndices.add(varFeatureIndex)
+
+    if existingTags:
+        # indices may have changed if we inserted new features and sorted feature list
+        # so we must do this after the above
+        varFeatureIndices.update(
+            index
+            for index, feature in enumerate(table.FeatureList.FeatureRecord)
+            if feature.FeatureTag in existingTags
+        )
 
     axisIndices = {
         axis.axisTag: axisIndex for axisIndex, axis in enumerate(font["fvar"].axes)
     }
 
+    hasFeatureVariations = (
+        hasattr(table, "FeatureVariations") and table.FeatureVariations is not None
+    )
+
     featureVariationRecords = []
     for conditionSet, lookupIndices in conditionalSubstitutions:
         conditionTable = []
@@ -380,7 +430,7 @@
             ct = buildConditionTable(axisIndices[axisTag], minValue, maxValue)
             conditionTable.append(ct)
         records = []
-        for varFeatureIndex in varFeatureIndices:
+        for varFeatureIndex in sorted(varFeatureIndices):
             existingLookupIndices = table.FeatureList.FeatureRecord[
                 varFeatureIndex
             ].Feature.LookupListIndex
@@ -395,11 +445,30 @@
                     varFeatureIndex, combinedLookupIndices
                 )
             )
-        featureVariationRecords.append(
-            buildFeatureVariationRecord(conditionTable, records)
-        )
+        if hasFeatureVariations and (
+            fvr := findFeatureVariationRecord(table.FeatureVariations, conditionTable)
+        ):
+            fvr.FeatureTableSubstitution.SubstitutionRecord.extend(records)
+            fvr.FeatureTableSubstitution.SubstitutionCount = len(
+                fvr.FeatureTableSubstitution.SubstitutionRecord
+            )
+        else:
+            featureVariationRecords.append(
+                buildFeatureVariationRecord(conditionTable, records)
+            )
 
-    table.FeatureVariations = buildFeatureVariations(featureVariationRecords)
+    if hasFeatureVariations:
+        if table.FeatureVariations.Version != 0x00010000:
+            raise VarLibError(
+                "Unsupported FeatureVariations table version: "
+                f"0x{table.FeatureVariations.Version:08x} (expected 0x00010000)."
+            )
+        table.FeatureVariations.FeatureVariationRecord.extend(featureVariationRecords)
+        table.FeatureVariations.FeatureVariationCount = len(
+            table.FeatureVariations.FeatureVariationRecord
+        )
+    else:
+        table.FeatureVariations = buildFeatureVariations(featureVariationRecords)
 
 
 #
@@ -558,6 +627,21 @@
     return ct
 
 
+def findFeatureVariationRecord(featureVariations, conditionTable):
+    """Find a FeatureVariationRecord that has the same conditionTable."""
+    if featureVariations.Version != 0x00010000:
+        raise VarLibError(
+            "Unsupported FeatureVariations table version: "
+            f"0x{featureVariations.Version:08x} (expected 0x00010000)."
+        )
+
+    for fvr in featureVariations.FeatureVariationRecord:
+        if conditionTable == fvr.ConditionSet.ConditionTable:
+            return fvr
+
+    return None
+
+
 def sortFeatureList(table):
     """Sort the feature list by feature tag, and remap the feature indices
     elsewhere. This is needed after the feature list has been modified.
diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py
index cde1d39..89427dc 100644
--- a/Lib/fontTools/varLib/instancer/__init__.py
+++ b/Lib/fontTools/varLib/instancer/__init__.py
@@ -82,6 +82,7 @@
 The discussion and implementation of these features are tracked at
 https://github.com/fonttools/fonttools/issues/1537
 """
+
 from fontTools.misc.fixedTools import (
     floatToFixedToFloat,
     strToFixedToFloat,
@@ -105,6 +106,7 @@
 from fontTools.varLib.instancer import solver
 import collections
 import dataclasses
+from contextlib import contextmanager
 from copy import deepcopy
 from enum import IntEnum
 import logging
@@ -613,7 +615,7 @@
     if optimize:
         isComposite = glyf[glyphname].isComposite()
         for var in tupleVarStore:
-            var.optimize(coordinates, endPts, isComposite)
+            var.optimize(coordinates, endPts, isComposite=isComposite)
 
 
 def instantiateGvarGlyph(varfont, glyphname, axisLimits, optimize=True):
@@ -642,9 +644,11 @@
     glyphnames = sorted(
         glyf.glyphOrder,
         key=lambda name: (
-            glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
-            if glyf[name].isComposite() or glyf[name].isVarComposite()
-            else 0,
+            (
+                glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
+                if glyf[name].isComposite() or glyf[name].isVarComposite()
+                else 0
+            ),
             name,
         ),
     )
@@ -694,6 +698,43 @@
             )
 
 
+@contextmanager
+def verticalMetricsKeptInSync(varfont):
+    """Ensure hhea vertical metrics stay in sync with OS/2 ones after instancing.
+
+    When applying MVAR deltas to the OS/2 table, if the ascender, descender and
+    line gap change but they were the same as the respective hhea metrics in the
+    original font, this context manager ensures that hhea metrcs also get updated
+    accordingly.
+    The MVAR spec only has tags for the OS/2 metrics, but it is common in fonts
+    to have the hhea metrics be equal to those for compat reasons.
+
+    https://learn.microsoft.com/en-us/typography/opentype/spec/mvar
+    https://googlefonts.github.io/gf-guide/metrics.html#7-hhea-and-typo-metrics-should-be-equal
+    https://github.com/fonttools/fonttools/issues/3297
+    """
+    current_os2_vmetrics = [
+        getattr(varfont["OS/2"], attr)
+        for attr in ("sTypoAscender", "sTypoDescender", "sTypoLineGap")
+    ]
+    metrics_are_synced = current_os2_vmetrics == [
+        getattr(varfont["hhea"], attr) for attr in ("ascender", "descender", "lineGap")
+    ]
+
+    yield metrics_are_synced
+
+    if metrics_are_synced:
+        new_os2_vmetrics = [
+            getattr(varfont["OS/2"], attr)
+            for attr in ("sTypoAscender", "sTypoDescender", "sTypoLineGap")
+        ]
+        if current_os2_vmetrics != new_os2_vmetrics:
+            for attr, value in zip(
+                ("ascender", "descender", "lineGap"), new_os2_vmetrics
+            ):
+                setattr(varfont["hhea"], attr, value)
+
+
 def instantiateMVAR(varfont, axisLimits):
     log.info("Instantiating MVAR table")
 
@@ -701,7 +742,9 @@
     fvarAxes = varfont["fvar"].axes
     varStore = mvar.VarStore
     defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits)
-    setMvarDeltas(varfont, defaultDeltas)
+
+    with verticalMetricsKeptInSync(varfont):
+        setMvarDeltas(varfont, defaultDeltas)
 
     if varStore.VarRegionList.Region:
         varIndexMapping = varStore.optimize()
@@ -1393,7 +1436,7 @@
         nargs="*",
         help="List of space separated locations. A location consists of "
         "the tag of a variation axis, followed by '=' and the literal, "
-        "string 'drop', or comma-separate list of one to three values, "
+        "string 'drop', or colon-separated list of one to three values, "
         "each of which is the empty string, or a number. "
         "E.g.: wdth=100 or wght=75.0:125.0 or wght=100:400:700 or wght=:500: "
         "or wght=drop",
diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py
index 9c568fe..ba5231b 100644
--- a/Lib/fontTools/varLib/instancer/solver.py
+++ b/Lib/fontTools/varLib/instancer/solver.py
@@ -178,7 +178,9 @@
         #
         newUpper = peak + (1 - gain) * (upper - peak)
         assert axisMax <= newUpper  # Because outGain > gain
-        if newUpper <= axisDef + (axisMax - axisDef) * 2:
+        # Disabled because ots doesn't like us:
+        # https://github.com/fonttools/fonttools/issues/3350
+        if False and newUpper <= axisDef + (axisMax - axisDef) * 2:
             upper = newUpper
             if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper:
                 # we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience
diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py
index c3f01f4..5fc12e0 100644
--- a/Lib/fontTools/varLib/interpolatable.py
+++ b/Lib/fontTools/varLib/interpolatable.py
@@ -6,273 +6,234 @@
 $ fonttools varLib.interpolatable font1 font2 ...
 """
 
-from fontTools.pens.basePen import AbstractPen, BasePen
-from fontTools.pens.pointPen import AbstractPointPen, SegmentToPointPen
-from fontTools.pens.recordingPen import RecordingPen
-from fontTools.pens.statisticsPen import StatisticsPen
+from .interpolatableHelpers import *
+from .interpolatableTestContourOrder import test_contour_order
+from .interpolatableTestStartingPoint import test_starting_point
+from fontTools.pens.recordingPen import (
+    RecordingPen,
+    DecomposingRecordingPen,
+    lerpRecordings,
+)
+from fontTools.pens.transformPen import TransformPen
+from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen
 from fontTools.pens.momentsPen import OpenContourError
+from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation
+from fontTools.misc.fixedTools import floatToFixedToStr
+from fontTools.misc.transform import Transform
 from collections import defaultdict
-import math
-import itertools
-import sys
+from types import SimpleNamespace
+from functools import wraps
+from pprint import pformat
+from math import sqrt, atan2, pi
+import logging
+import os
+
+log = logging.getLogger("fontTools.varLib.interpolatable")
+
+DEFAULT_TOLERANCE = 0.95
+DEFAULT_KINKINESS = 0.5
+DEFAULT_KINKINESS_LENGTH = 0.002  # ratio of UPEM
+DEFAULT_UPEM = 1000
 
 
-def _rot_list(l, k):
-    """Rotate list by k items forward.  Ie. item at position 0 will be
-    at position k in returned list.  Negative k is allowed."""
-    return l[-k:] + l[:-k]
+class Glyph:
+    ITEMS = (
+        "recordings",
+        "greenStats",
+        "controlStats",
+        "greenVectors",
+        "controlVectors",
+        "nodeTypes",
+        "isomorphisms",
+        "points",
+        "openContours",
+    )
+
+    def __init__(self, glyphname, glyphset):
+        self.name = glyphname
+        for item in self.ITEMS:
+            setattr(self, item, [])
+        self._populate(glyphset)
+
+    def _fill_in(self, ix):
+        for item in self.ITEMS:
+            if len(getattr(self, item)) == ix:
+                getattr(self, item).append(None)
+
+    def _populate(self, glyphset):
+        glyph = glyphset[self.name]
+        self.doesnt_exist = glyph is None
+        if self.doesnt_exist:
+            return
+
+        perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset)
+        try:
+            glyph.draw(perContourPen, outputImpliedClosingLine=True)
+        except TypeError:
+            glyph.draw(perContourPen)
+        self.recordings = perContourPen.value
+        del perContourPen
+
+        for ix, contour in enumerate(self.recordings):
+            nodeTypes = [op for op, arg in contour.value]
+            self.nodeTypes.append(nodeTypes)
+
+            greenStats = StatisticsPen(glyphset=glyphset)
+            controlStats = StatisticsControlPen(glyphset=glyphset)
+            try:
+                contour.replay(greenStats)
+                contour.replay(controlStats)
+                self.openContours.append(False)
+            except OpenContourError as e:
+                self.openContours.append(True)
+                self._fill_in(ix)
+                continue
+            self.greenStats.append(greenStats)
+            self.controlStats.append(controlStats)
+            self.greenVectors.append(contour_vector_from_stats(greenStats))
+            self.controlVectors.append(contour_vector_from_stats(controlStats))
+
+            # Check starting point
+            if nodeTypes[0] == "addComponent":
+                self._fill_in(ix)
+                continue
+
+            assert nodeTypes[0] == "moveTo"
+            assert nodeTypes[-1] in ("closePath", "endPath")
+            points = SimpleRecordingPointPen()
+            converter = SegmentToPointPen(points, False)
+            contour.replay(converter)
+            # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve;
+            # now check all rotations and mirror-rotations of the contour and build list of isomorphic
+            # possible starting points.
+            self.points.append(points.value)
+
+            isomorphisms = []
+            self.isomorphisms.append(isomorphisms)
+
+            # Add rotations
+            add_isomorphisms(points.value, isomorphisms, False)
+            # Add mirrored rotations
+            add_isomorphisms(points.value, isomorphisms, True)
+
+    def draw(self, pen, countor_idx=None):
+        if countor_idx is None:
+            for contour in self.recordings:
+                contour.draw(pen)
+        else:
+            self.recordings[countor_idx].draw(pen)
 
 
-class PerContourPen(BasePen):
-    def __init__(self, Pen, glyphset=None):
-        BasePen.__init__(self, glyphset)
-        self._glyphset = glyphset
-        self._Pen = Pen
-        self._pen = None
-        self.value = []
+def test_gen(
+    glyphsets,
+    glyphs=None,
+    names=None,
+    ignore_missing=False,
+    *,
+    locations=None,
+    tolerance=DEFAULT_TOLERANCE,
+    kinkiness=DEFAULT_KINKINESS,
+    upem=DEFAULT_UPEM,
+    show_all=False,
+):
+    if tolerance >= 10:
+        tolerance *= 0.01
+    assert 0 <= tolerance <= 1
+    if kinkiness >= 10:
+        kinkiness *= 0.01
+    assert 0 <= kinkiness
 
-    def _moveTo(self, p0):
-        self._newItem()
-        self._pen.moveTo(p0)
+    names = names or [repr(g) for g in glyphsets]
 
-    def _lineTo(self, p1):
-        self._pen.lineTo(p1)
-
-    def _qCurveToOne(self, p1, p2):
-        self._pen.qCurveTo(p1, p2)
-
-    def _curveToOne(self, p1, p2, p3):
-        self._pen.curveTo(p1, p2, p3)
-
-    def _closePath(self):
-        self._pen.closePath()
-        self._pen = None
-
-    def _endPath(self):
-        self._pen.endPath()
-        self._pen = None
-
-    def _newItem(self):
-        self._pen = pen = self._Pen()
-        self.value.append(pen)
-
-
-class PerContourOrComponentPen(PerContourPen):
-    def addComponent(self, glyphName, transformation):
-        self._newItem()
-        self.value[-1].addComponent(glyphName, transformation)
-
-
-class RecordingPointPen(AbstractPointPen):
-    def __init__(self):
-        self.value = []
-
-    def beginPath(self, identifier=None, **kwargs):
-        pass
-
-    def endPath(self) -> None:
-        pass
-
-    def addPoint(self, pt, segmentType=None):
-        self.value.append((pt, False if segmentType is None else True))
-
-
-def _vdiff_hypot2(v0, v1):
-    s = 0
-    for x0, x1 in zip(v0, v1):
-        d = x1 - x0
-        s += d * d
-    return s
-
-
-def _vdiff_hypot2_complex(v0, v1):
-    s = 0
-    for x0, x1 in zip(v0, v1):
-        d = x1 - x0
-        s += d.real * d.real + d.imag * d.imag
-    return s
-
-
-def _matching_cost(G, matching):
-    return sum(G[i][j] for i, j in enumerate(matching))
-
-
-def min_cost_perfect_bipartite_matching_scipy(G):
-    n = len(G)
-    rows, cols = linear_sum_assignment(G)
-    assert (rows == list(range(n))).all()
-    return list(cols), _matching_cost(G, cols)
-
-
-def min_cost_perfect_bipartite_matching_munkres(G):
-    n = len(G)
-    cols = [None] * n
-    for row, col in Munkres().compute(G):
-        cols[row] = col
-    return cols, _matching_cost(G, cols)
-
-
-def min_cost_perfect_bipartite_matching_bruteforce(G):
-    n = len(G)
-
-    if n > 6:
-        raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'")
-
-    # Otherwise just brute-force
-    permutations = itertools.permutations(range(n))
-    best = list(next(permutations))
-    best_cost = _matching_cost(G, best)
-    for p in permutations:
-        cost = _matching_cost(G, p)
-        if cost < best_cost:
-            best, best_cost = list(p), cost
-    return best, best_cost
-
-
-try:
-    from scipy.optimize import linear_sum_assignment
-
-    min_cost_perfect_bipartite_matching = min_cost_perfect_bipartite_matching_scipy
-except ImportError:
-    try:
-        from munkres import Munkres
-
-        min_cost_perfect_bipartite_matching = (
-            min_cost_perfect_bipartite_matching_munkres
-        )
-    except ImportError:
-        min_cost_perfect_bipartite_matching = (
-            min_cost_perfect_bipartite_matching_bruteforce
-        )
-
-
-def test_gen(glyphsets, glyphs=None, names=None, ignore_missing=False):
-    if names is None:
-        names = glyphsets
     if glyphs is None:
         # `glyphs = glyphsets[0].keys()` is faster, certainly, but doesn't allow for sparse TTFs/OTFs given out of order
         # ... risks the sparse master being the first one, and only processing a subset of the glyphs
         glyphs = {g for glyphset in glyphsets for g in glyphset.keys()}
 
-    hist = []
+    parents, order = find_parents_and_order(glyphsets, locations)
+
+    def grand_parent(i, glyphname):
+        if i is None:
+            return None
+        i = parents[i]
+        if i is None:
+            return None
+        while parents[i] is not None and glyphsets[i][glyphname] is None:
+            i = parents[i]
+        return i
 
     for glyph_name in glyphs:
-        try:
-            m0idx = 0
-            allVectors = []
-            allNodeTypes = []
-            allContourIsomorphisms = []
-            allGlyphs = [glyphset[glyph_name] for glyphset in glyphsets]
-            if len([1 for glyph in allGlyphs if glyph is not None]) <= 1:
-                continue
-            for glyph, glyphset, name in zip(allGlyphs, glyphsets, names):
-                if glyph is None:
-                    if not ignore_missing:
-                        yield (glyph_name, {"type": "missing", "master": name})
-                    allNodeTypes.append(None)
-                    allVectors.append(None)
-                    allContourIsomorphisms.append(None)
-                    continue
-
-                perContourPen = PerContourOrComponentPen(
-                    RecordingPen, glyphset=glyphset
-                )
-                try:
-                    glyph.draw(perContourPen, outputImpliedClosingLine=True)
-                except TypeError:
-                    glyph.draw(perContourPen)
-                contourPens = perContourPen.value
-                del perContourPen
-
-                contourVectors = []
-                contourIsomorphisms = []
-                nodeTypes = []
-                allNodeTypes.append(nodeTypes)
-                allVectors.append(contourVectors)
-                allContourIsomorphisms.append(contourIsomorphisms)
-                for ix, contour in enumerate(contourPens):
-                    nodeVecs = tuple(instruction[0] for instruction in contour.value)
-                    nodeTypes.append(nodeVecs)
-
-                    stats = StatisticsPen(glyphset=glyphset)
-                    try:
-                        contour.replay(stats)
-                    except OpenContourError as e:
-                        yield (
-                            glyph_name,
-                            {"master": name, "contour": ix, "type": "open_path"},
-                        )
-                        continue
-                    size = math.sqrt(abs(stats.area)) * 0.5
-                    vector = (
-                        int(size),
-                        int(stats.meanX),
-                        int(stats.meanY),
-                        int(stats.stddevX * 2),
-                        int(stats.stddevY * 2),
-                        int(stats.correlation * size),
-                    )
-                    contourVectors.append(vector)
-                    # print(vector)
-
-                    # Check starting point
-                    if nodeVecs[0] == "addComponent":
-                        continue
-                    assert nodeVecs[0] == "moveTo"
-                    assert nodeVecs[-1] in ("closePath", "endPath")
-                    points = RecordingPointPen()
-                    converter = SegmentToPointPen(points, False)
-                    contour.replay(converter)
-                    # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve;
-                    # now check all rotations and mirror-rotations of the contour and build list of isomorphic
-                    # possible starting points.
-                    bits = 0
-                    for pt, b in points.value:
-                        bits = (bits << 1) | b
-                    n = len(points.value)
-                    mask = (1 << n) - 1
-                    isomorphisms = []
-                    contourIsomorphisms.append(isomorphisms)
-                    complexPoints = [complex(*pt) for pt, bl in points.value]
-                    for i in range(n):
-                        b = ((bits << i) & mask) | ((bits >> (n - i)))
-                        if b == bits:
-                            isomorphisms.append(_rot_list(complexPoints, i))
-                    # Add mirrored rotations
-                    mirrored = list(reversed(points.value))
-                    reversed_bits = 0
-                    for pt, b in mirrored:
-                        reversed_bits = (reversed_bits << 1) | b
-                    complexPoints = list(reversed(complexPoints))
-                    for i in range(n):
-                        b = ((reversed_bits << i) & mask) | ((reversed_bits >> (n - i)))
-                        if b == bits:
-                            isomorphisms.append(_rot_list(complexPoints, i))
-
-            # m0idx should be the index of the first non-None item in allNodeTypes,
-            # else give it the last item.
-            m0idx = next(
-                (i for i, x in enumerate(allNodeTypes) if x is not None),
-                len(allNodeTypes) - 1,
-            )
-            # m0 is the first non-None item in allNodeTypes, or last one if all None
-            m0 = allNodeTypes[m0idx]
-            for i, m1 in enumerate(allNodeTypes[m0idx + 1 :]):
-                if m1 is None:
-                    continue
-                if len(m0) != len(m1):
+        log.info("Testing glyph %s", glyph_name)
+        allGlyphs = [Glyph(glyph_name, glyphset) for glyphset in glyphsets]
+        if len([1 for glyph in allGlyphs if glyph is not None]) <= 1:
+            continue
+        for master_idx, (glyph, glyphset, name) in enumerate(
+            zip(allGlyphs, glyphsets, names)
+        ):
+            if glyph.doesnt_exist:
+                if not ignore_missing:
                     yield (
                         glyph_name,
                         {
-                            "type": "path_count",
-                            "master_1": names[m0idx],
-                            "master_2": names[m0idx + i + 1],
-                            "value_1": len(m0),
-                            "value_2": len(m1),
+                            "type": InterpolatableProblem.MISSING,
+                            "master": name,
+                            "master_idx": master_idx,
                         },
                     )
-                if m0 == m1:
+                continue
+
+            has_open = False
+            for ix, open in enumerate(glyph.openContours):
+                if not open:
                     continue
+                has_open = True
+                yield (
+                    glyph_name,
+                    {
+                        "type": InterpolatableProblem.OPEN_PATH,
+                        "master": name,
+                        "master_idx": master_idx,
+                        "contour": ix,
+                    },
+                )
+            if has_open:
+                continue
+
+        matchings = [None] * len(glyphsets)
+
+        for m1idx in order:
+            glyph1 = allGlyphs[m1idx]
+            if glyph1 is None or not glyph1.nodeTypes:
+                continue
+            m0idx = grand_parent(m1idx, glyph_name)
+            if m0idx is None:
+                continue
+            glyph0 = allGlyphs[m0idx]
+            if glyph0 is None or not glyph0.nodeTypes:
+                continue
+
+            #
+            # Basic compatibility checks
+            #
+
+            m1 = glyph0.nodeTypes
+            m0 = glyph1.nodeTypes
+            if len(m0) != len(m1):
+                yield (
+                    glyph_name,
+                    {
+                        "type": InterpolatableProblem.PATH_COUNT,
+                        "master_1": names[m0idx],
+                        "master_2": names[m1idx],
+                        "master_1_idx": m0idx,
+                        "master_2_idx": m1idx,
+                        "value_1": len(m0),
+                        "value_2": len(m1),
+                    },
+                )
+                continue
+
+            if m0 != m1:
                 for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)):
                     if nodes1 == nodes2:
                         continue
@@ -280,10 +241,12 @@
                         yield (
                             glyph_name,
                             {
-                                "type": "node_count",
+                                "type": InterpolatableProblem.NODE_COUNT,
                                 "path": pathIx,
                                 "master_1": names[m0idx],
-                                "master_2": names[m0idx + i + 1],
+                                "master_2": names[m1idx],
+                                "master_1_idx": m0idx,
+                                "master_2_idx": m1idx,
                                 "value_1": len(nodes1),
                                 "value_2": len(nodes2),
                             },
@@ -294,93 +257,332 @@
                             yield (
                                 glyph_name,
                                 {
-                                    "type": "node_incompatibility",
+                                    "type": InterpolatableProblem.NODE_INCOMPATIBILITY,
                                     "path": pathIx,
                                     "node": nodeIx,
                                     "master_1": names[m0idx],
-                                    "master_2": names[m0idx + i + 1],
+                                    "master_2": names[m1idx],
+                                    "master_1_idx": m0idx,
+                                    "master_2_idx": m1idx,
                                     "value_1": n1,
                                     "value_2": n2,
                                 },
                             )
                             continue
 
-            # m0idx should be the index of the first non-None item in allVectors,
-            # else give it the last item.
-            m0idx = next(
-                (i for i, x in enumerate(allVectors) if x is not None),
-                len(allVectors) - 1,
-            )
-            # m0 is the first non-None item in allVectors, or last one if all None
-            m0 = allVectors[m0idx]
-            if m0 is not None and len(m0) > 1:
-                for i, m1 in enumerate(allVectors[m0idx + 1 :]):
-                    if m1 is None:
-                        continue
-                    if len(m0) != len(m1):
-                        # We already reported this
-                        continue
-                    costs = [[_vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0]
-                    matching, matching_cost = min_cost_perfect_bipartite_matching(costs)
-                    identity_matching = list(range(len(m0)))
-                    identity_cost = sum(costs[i][i] for i in range(len(m0)))
-                    if (
-                        matching != identity_matching
-                        and matching_cost < identity_cost * 0.95
-                    ):
-                        yield (
-                            glyph_name,
-                            {
-                                "type": "contour_order",
-                                "master_1": names[m0idx],
-                                "master_2": names[m0idx + i + 1],
-                                "value_1": list(range(len(m0))),
-                                "value_2": matching,
-                            },
-                        )
-                        break
+            #
+            # InterpolatableProblem.CONTOUR_ORDER check
+            #
 
-            # m0idx should be the index of the first non-None item in allContourIsomorphisms,
-            # else give it the last item.
-            m0idx = next(
-                (i for i, x in enumerate(allContourIsomorphisms) if x is not None),
-                len(allVectors) - 1,
-            )
-            # m0 is the first non-None item in allContourIsomorphisms, or last one if all None
-            m0 = allContourIsomorphisms[m0idx]
-            if m0:
-                for i, m1 in enumerate(allContourIsomorphisms[m0idx + 1 :]):
-                    if m1 is None:
-                        continue
-                    if len(m0) != len(m1):
-                        # We already reported this
-                        continue
-                    for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
-                        c0 = contour0[0]
-                        costs = [_vdiff_hypot2_complex(c0, c1) for c1 in contour1]
-                        min_cost = min(costs)
-                        first_cost = costs[0]
-                        if min_cost < first_cost * 0.95:
+            this_tolerance, matching = test_contour_order(glyph0, glyph1)
+            if this_tolerance < tolerance:
+                yield (
+                    glyph_name,
+                    {
+                        "type": InterpolatableProblem.CONTOUR_ORDER,
+                        "master_1": names[m0idx],
+                        "master_2": names[m1idx],
+                        "master_1_idx": m0idx,
+                        "master_2_idx": m1idx,
+                        "value_1": list(range(len(matching))),
+                        "value_2": matching,
+                        "tolerance": this_tolerance,
+                    },
+                )
+                matchings[m1idx] = matching
+
+            #
+            # wrong-start-point / weight check
+            #
+
+            m0Isomorphisms = glyph0.isomorphisms
+            m1Isomorphisms = glyph1.isomorphisms
+            m0Vectors = glyph0.greenVectors
+            m1Vectors = glyph1.greenVectors
+            recording0 = glyph0.recordings
+            recording1 = glyph1.recordings
+
+            # If contour-order is wrong, adjust it
+            matching = matchings[m1idx]
+            if (
+                matching is not None and m1Isomorphisms
+            ):  # m1 is empty for composite glyphs
+                m1Isomorphisms = [m1Isomorphisms[i] for i in matching]
+                m1Vectors = [m1Vectors[i] for i in matching]
+                recording1 = [recording1[i] for i in matching]
+
+            midRecording = []
+            for c0, c1 in zip(recording0, recording1):
+                try:
+                    r = RecordingPen()
+                    r.value = list(lerpRecordings(c0.value, c1.value))
+                    midRecording.append(r)
+                except ValueError:
+                    # Mismatch because of the reordering above
+                    midRecording.append(None)
+
+            for ix, (contour0, contour1) in enumerate(
+                zip(m0Isomorphisms, m1Isomorphisms)
+            ):
+                if (
+                    contour0 is None
+                    or contour1 is None
+                    or len(contour0) == 0
+                    or len(contour0) != len(contour1)
+                ):
+                    # We already reported this; or nothing to do; or not compatible
+                    # after reordering above.
+                    continue
+
+                this_tolerance, proposed_point, reverse = test_starting_point(
+                    glyph0, glyph1, ix, tolerance, matching
+                )
+
+                if this_tolerance < tolerance:
+                    yield (
+                        glyph_name,
+                        {
+                            "type": InterpolatableProblem.WRONG_START_POINT,
+                            "contour": ix,
+                            "master_1": names[m0idx],
+                            "master_2": names[m1idx],
+                            "master_1_idx": m0idx,
+                            "master_2_idx": m1idx,
+                            "value_1": 0,
+                            "value_2": proposed_point,
+                            "reversed": reverse,
+                            "tolerance": this_tolerance,
+                        },
+                    )
+
+                # Weight check.
+                #
+                # If contour could be mid-interpolated, and the two
+                # contours have the same area sign, proceeed.
+                #
+                # The sign difference can happen if it's a weirdo
+                # self-intersecting contour; ignore it.
+                contour = midRecording[ix]
+
+                if contour and (m0Vectors[ix][0] < 0) == (m1Vectors[ix][0] < 0):
+                    midStats = StatisticsPen(glyphset=None)
+                    contour.replay(midStats)
+
+                    midVector = contour_vector_from_stats(midStats)
+
+                    m0Vec = m0Vectors[ix]
+                    m1Vec = m1Vectors[ix]
+                    size0 = m0Vec[0] * m0Vec[0]
+                    size1 = m1Vec[0] * m1Vec[0]
+                    midSize = midVector[0] * midVector[0]
+
+                    for overweight, problem_type in enumerate(
+                        (
+                            InterpolatableProblem.UNDERWEIGHT,
+                            InterpolatableProblem.OVERWEIGHT,
+                        )
+                    ):
+                        if overweight:
+                            expectedSize = max(size0, size1)
+                            continue
+                        else:
+                            expectedSize = sqrt(size0 * size1)
+
+                        log.debug(
+                            "%s: actual size %g; threshold size %g, master sizes: %g, %g",
+                            problem_type,
+                            midSize,
+                            expectedSize,
+                            size0,
+                            size1,
+                        )
+
+                        if (
+                            not overweight and expectedSize * tolerance > midSize + 1e-5
+                        ) or (overweight and 1e-5 + expectedSize / tolerance < midSize):
+                            try:
+                                if overweight:
+                                    this_tolerance = expectedSize / midSize
+                                else:
+                                    this_tolerance = midSize / expectedSize
+                            except ZeroDivisionError:
+                                this_tolerance = 0
+                            log.debug("tolerance %g", this_tolerance)
                             yield (
                                 glyph_name,
                                 {
-                                    "type": "wrong_start_point",
+                                    "type": problem_type,
                                     "contour": ix,
                                     "master_1": names[m0idx],
-                                    "master_2": names[m0idx + i + 1],
+                                    "master_2": names[m1idx],
+                                    "master_1_idx": m0idx,
+                                    "master_2_idx": m1idx,
+                                    "tolerance": this_tolerance,
                                 },
                             )
 
-        except ValueError as e:
-            yield (
-                glyph_name,
-                {"type": "math_error", "master": name, "error": e},
+            #
+            # "kink" detector
+            #
+            m0 = glyph0.points
+            m1 = glyph1.points
+
+            # If contour-order is wrong, adjust it
+            if matchings[m1idx] is not None and m1:  # m1 is empty for composite glyphs
+                m1 = [m1[i] for i in matchings[m1idx]]
+
+            t = 0.1  # ~sin(radian(6)) for tolerance 0.95
+            deviation_threshold = (
+                upem * DEFAULT_KINKINESS_LENGTH * DEFAULT_KINKINESS / kinkiness
             )
 
+            for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
+                if (
+                    contour0 is None
+                    or contour1 is None
+                    or len(contour0) == 0
+                    or len(contour0) != len(contour1)
+                ):
+                    # We already reported this; or nothing to do; or not compatible
+                    # after reordering above.
+                    continue
 
-def test(glyphsets, glyphs=None, names=None, ignore_missing=False):
+                # Walk the contour, keeping track of three consecutive points, with
+                # middle one being an on-curve. If the three are co-linear then
+                # check for kinky-ness.
+                for i in range(len(contour0)):
+                    pt0 = contour0[i]
+                    pt1 = contour1[i]
+                    if not pt0[1] or not pt1[1]:
+                        # Skip off-curves
+                        continue
+                    pt0_prev = contour0[i - 1]
+                    pt1_prev = contour1[i - 1]
+                    pt0_next = contour0[(i + 1) % len(contour0)]
+                    pt1_next = contour1[(i + 1) % len(contour1)]
+
+                    if pt0_prev[1] and pt1_prev[1]:
+                        # At least one off-curve is required
+                        continue
+                    if pt0_prev[1] and pt1_prev[1]:
+                        # At least one off-curve is required
+                        continue
+
+                    pt0 = complex(*pt0[0])
+                    pt1 = complex(*pt1[0])
+                    pt0_prev = complex(*pt0_prev[0])
+                    pt1_prev = complex(*pt1_prev[0])
+                    pt0_next = complex(*pt0_next[0])
+                    pt1_next = complex(*pt1_next[0])
+
+                    # We have three consecutive points. Check whether
+                    # they are colinear.
+                    d0_prev = pt0 - pt0_prev
+                    d0_next = pt0_next - pt0
+                    d1_prev = pt1 - pt1_prev
+                    d1_next = pt1_next - pt1
+
+                    sin0 = d0_prev.real * d0_next.imag - d0_prev.imag * d0_next.real
+                    sin1 = d1_prev.real * d1_next.imag - d1_prev.imag * d1_next.real
+                    try:
+                        sin0 /= abs(d0_prev) * abs(d0_next)
+                        sin1 /= abs(d1_prev) * abs(d1_next)
+                    except ZeroDivisionError:
+                        continue
+
+                    if abs(sin0) > t or abs(sin1) > t:
+                        # Not colinear / not smooth.
+                        continue
+
+                    # Check the mid-point is actually, well, in the middle.
+                    dot0 = d0_prev.real * d0_next.real + d0_prev.imag * d0_next.imag
+                    dot1 = d1_prev.real * d1_next.real + d1_prev.imag * d1_next.imag
+                    if dot0 < 0 or dot1 < 0:
+                        # Sharp corner.
+                        continue
+
+                    # Fine, if handle ratios are similar...
+                    r0 = abs(d0_prev) / (abs(d0_prev) + abs(d0_next))
+                    r1 = abs(d1_prev) / (abs(d1_prev) + abs(d1_next))
+                    r_diff = abs(r0 - r1)
+                    if abs(r_diff) < t:
+                        # Smooth enough.
+                        continue
+
+                    mid = (pt0 + pt1) / 2
+                    mid_prev = (pt0_prev + pt1_prev) / 2
+                    mid_next = (pt0_next + pt1_next) / 2
+
+                    mid_d0 = mid - mid_prev
+                    mid_d1 = mid_next - mid
+
+                    sin_mid = mid_d0.real * mid_d1.imag - mid_d0.imag * mid_d1.real
+                    try:
+                        sin_mid /= abs(mid_d0) * abs(mid_d1)
+                    except ZeroDivisionError:
+                        continue
+
+                    # ...or if the angles are similar.
+                    if abs(sin_mid) * (tolerance * kinkiness) <= t:
+                        # Smooth enough.
+                        continue
+
+                    # How visible is the kink?
+
+                    cross = sin_mid * abs(mid_d0) * abs(mid_d1)
+                    arc_len = abs(mid_d0 + mid_d1)
+                    deviation = abs(cross / arc_len)
+                    if deviation < deviation_threshold:
+                        continue
+                    deviation_ratio = deviation / arc_len
+                    if deviation_ratio > t:
+                        continue
+
+                    this_tolerance = t / (abs(sin_mid) * kinkiness)
+
+                    log.debug(
+                        "kink: deviation %g; deviation_ratio %g; sin_mid %g; r_diff %g",
+                        deviation,
+                        deviation_ratio,
+                        sin_mid,
+                        r_diff,
+                    )
+                    log.debug("tolerance %g", this_tolerance)
+                    yield (
+                        glyph_name,
+                        {
+                            "type": InterpolatableProblem.KINK,
+                            "contour": ix,
+                            "master_1": names[m0idx],
+                            "master_2": names[m1idx],
+                            "master_1_idx": m0idx,
+                            "master_2_idx": m1idx,
+                            "value": i,
+                            "tolerance": this_tolerance,
+                        },
+                    )
+
+            #
+            # --show-all
+            #
+
+            if show_all:
+                yield (
+                    glyph_name,
+                    {
+                        "type": InterpolatableProblem.NOTHING,
+                        "master_1": names[m0idx],
+                        "master_2": names[m1idx],
+                        "master_1_idx": m0idx,
+                        "master_2_idx": m1idx,
+                    },
+                )
+
+
+@wraps(test_gen)
+def test(*args, **kwargs):
     problems = defaultdict(list)
-    for glyphname, problem in test_gen(glyphsets, glyphs, names, ignore_missing):
+    for glyphname, problem in test_gen(*args, **kwargs):
         problems[glyphname].append(problem)
     return problems
 
@@ -394,9 +596,17 @@
         recursivelyAddGlyph(component.glyphName, glyphset, ttGlyphSet, glyf)
 
 
+def ensure_parent_dir(path):
+    dirname = os.path.dirname(path)
+    if dirname:
+        os.makedirs(dirname, exist_ok=True)
+    return path
+
+
 def main(args=None):
     """Test for interpolatability issues between fonts"""
     import argparse
+    import sys
 
     parser = argparse.ArgumentParser(
         "fonttools varLib.interpolatable",
@@ -408,16 +618,53 @@
         help="Space-separate name of glyphs to check",
     )
     parser.add_argument(
+        "--show-all",
+        action="store_true",
+        help="Show all glyph pairs, even if no problems are found",
+    )
+    parser.add_argument(
+        "--tolerance",
+        action="store",
+        type=float,
+        help="Error tolerance. Between 0 and 1. Default %s" % DEFAULT_TOLERANCE,
+    )
+    parser.add_argument(
+        "--kinkiness",
+        action="store",
+        type=float,
+        help="How aggressively report kinks. Default %s" % DEFAULT_KINKINESS,
+    )
+    parser.add_argument(
         "--json",
         action="store_true",
         help="Output report in JSON format",
     )
     parser.add_argument(
+        "--pdf",
+        action="store",
+        help="Output report in PDF format",
+    )
+    parser.add_argument(
+        "--ps",
+        action="store",
+        help="Output report in PostScript format",
+    )
+    parser.add_argument(
+        "--html",
+        action="store",
+        help="Output report in HTML format",
+    )
+    parser.add_argument(
         "--quiet",
         action="store_true",
         help="Only exit with code 1 or 0, no output",
     )
     parser.add_argument(
+        "--output",
+        action="store",
+        help="Output file for the problem report; Default: stdout",
+    )
+    parser.add_argument(
         "--ignore-missing",
         action="store_true",
         help="Will not report glyphs missing from sparse masters as errors",
@@ -429,37 +676,96 @@
         nargs="+",
         help="Input a single variable font / DesignSpace / Glyphs file, or multiple TTF/UFO files",
     )
+    parser.add_argument(
+        "--name",
+        metavar="NAME",
+        type=str,
+        action="append",
+        help="Name of the master to use in the report. If not provided, all are used.",
+    )
+    parser.add_argument("-v", "--verbose", action="store_true", help="Run verbosely.")
+    parser.add_argument("--debug", action="store_true", help="Run with debug output.")
 
     args = parser.parse_args(args)
 
+    from fontTools import configLogger
+
+    configLogger(level=("INFO" if args.verbose else "ERROR"))
+    if args.debug:
+        configLogger(level="DEBUG")
+
     glyphs = args.glyphs.split() if args.glyphs else None
 
     from os.path import basename
 
     fonts = []
     names = []
+    locations = []
+    upem = DEFAULT_UPEM
+
+    original_args_inputs = tuple(args.inputs)
 
     if len(args.inputs) == 1:
+        designspace = None
         if args.inputs[0].endswith(".designspace"):
             from fontTools.designspaceLib import DesignSpaceDocument
 
             designspace = DesignSpaceDocument.fromfile(args.inputs[0])
             args.inputs = [master.path for master in designspace.sources]
+            locations = [master.location for master in designspace.sources]
+            axis_triples = {
+                a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes
+            }
+            axis_mappings = {a.name: a.map for a in designspace.axes}
+            axis_triples = {
+                k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv)
+                for k, vv in axis_triples.items()
+            }
 
-        elif args.inputs[0].endswith(".glyphs"):
-            from glyphsLib import GSFont, to_ufos
+        elif args.inputs[0].endswith((".glyphs", ".glyphspackage")):
+            from glyphsLib import GSFont, to_designspace
 
             gsfont = GSFont(args.inputs[0])
-            fonts.extend(to_ufos(gsfont))
+            upem = gsfont.upm
+            designspace = to_designspace(gsfont)
+            fonts = [source.font for source in designspace.sources]
             names = ["%s-%s" % (f.info.familyName, f.info.styleName) for f in fonts]
             args.inputs = []
+            locations = [master.location for master in designspace.sources]
+            axis_triples = {
+                a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes
+            }
+            axis_mappings = {a.name: a.map for a in designspace.axes}
+            axis_triples = {
+                k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv)
+                for k, vv in axis_triples.items()
+            }
 
         elif args.inputs[0].endswith(".ttf"):
             from fontTools.ttLib import TTFont
 
             font = TTFont(args.inputs[0])
+            upem = font["head"].unitsPerEm
             if "gvar" in font:
                 # Is variable font
+
+                axisMapping = {}
+                fvar = font["fvar"]
+                for axis in fvar.axes:
+                    axisMapping[axis.axisTag] = {
+                        -1: axis.minValue,
+                        0: axis.defaultValue,
+                        1: axis.maxValue,
+                    }
+                if "avar" in font:
+                    avar = font["avar"]
+                    for axisTag, segments in avar.segments.items():
+                        fvarMapping = axisMapping[axisTag].copy()
+                        for location, value in segments.items():
+                            axisMapping[axisTag][value] = piecewiseLinearMap(
+                                location, fvarMapping
+                            )
+
                 gvar = font["gvar"]
                 glyf = font["glyf"]
                 # Gather all glyphs at their "master" locations
@@ -479,30 +785,56 @@
                         locTuple = tuple(loc)
                         if locTuple not in ttGlyphSets:
                             ttGlyphSets[locTuple] = font.getGlyphSet(
-                                location=locDict, normalized=True
+                                location=locDict, normalized=True, recalcBounds=False
                             )
 
                         recursivelyAddGlyph(
                             glyphname, glyphsets[locTuple], ttGlyphSets[locTuple], glyf
                         )
 
-                names = ["()"]
+                names = ["''"]
                 fonts = [font.getGlyphSet()]
+                locations = [{}]
+                axis_triples = {a: (-1, 0, +1) for a in sorted(axisMapping.keys())}
                 for locTuple in sorted(glyphsets.keys(), key=lambda v: (len(v), v)):
-                    names.append(str(locTuple))
+                    name = (
+                        "'"
+                        + " ".join(
+                            "%s=%s"
+                            % (
+                                k,
+                                floatToFixedToStr(
+                                    piecewiseLinearMap(v, axisMapping[k]), 14
+                                ),
+                            )
+                            for k, v in locTuple
+                        )
+                        + "'"
+                    )
+                    names.append(name)
                     fonts.append(glyphsets[locTuple])
+                    locations.append(dict(locTuple))
                 args.ignore_missing = True
                 args.inputs = []
 
+    if not locations:
+        locations = [{} for _ in fonts]
+
     for filename in args.inputs:
         if filename.endswith(".ufo"):
             from fontTools.ufoLib import UFOReader
 
-            fonts.append(UFOReader(filename))
+            font = UFOReader(filename)
+            info = SimpleNamespace()
+            font.readInfo(info)
+            upem = info.unitsPerEm
+            fonts.append(font)
         else:
             from fontTools.ttLib import TTFont
 
-            fonts.append(TTFont(filename))
+            font = TTFont(filename)
+            upem = font["head"].unitsPerEm
+            fonts.append(font)
 
         names.append(basename(filename).rsplit(".", 1)[0])
 
@@ -514,6 +846,20 @@
             glyphset = font
         glyphsets.append({k: glyphset[k] for k in glyphset.keys()})
 
+    if args.name:
+        accepted_names = set(args.name)
+        glyphsets = [
+            glyphset
+            for name, glyphset in zip(names, glyphsets)
+            if name in accepted_names
+        ]
+        locations = [
+            location
+            for name, location in zip(names, locations)
+            if name in accepted_names
+        ]
+        names = [name for name in names if name in accepted_names]
+
     if not glyphs:
         glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()]))
 
@@ -525,90 +871,248 @@
             for gn in diff:
                 glyphset[gn] = None
 
-    problems_gen = test_gen(
-        glyphsets, glyphs=glyphs, names=names, ignore_missing=args.ignore_missing
-    )
-    problems = defaultdict(list)
+    # Normalize locations
+    locations = [normalizeLocation(loc, axis_triples) for loc in locations]
+    tolerance = args.tolerance or DEFAULT_TOLERANCE
+    kinkiness = args.kinkiness if args.kinkiness is not None else DEFAULT_KINKINESS
 
-    if not args.quiet:
-        if args.json:
-            import json
+    try:
+        log.info("Running on %d glyphsets", len(glyphsets))
+        log.info("Locations: %s", pformat(locations))
+        problems_gen = test_gen(
+            glyphsets,
+            glyphs=glyphs,
+            names=names,
+            locations=locations,
+            upem=upem,
+            ignore_missing=args.ignore_missing,
+            tolerance=tolerance,
+            kinkiness=kinkiness,
+            show_all=args.show_all,
+        )
+        problems = defaultdict(list)
 
+        f = (
+            sys.stdout
+            if args.output is None
+            else open(ensure_parent_dir(args.output), "w")
+        )
+
+        if not args.quiet:
+            if args.json:
+                import json
+
+                for glyphname, problem in problems_gen:
+                    problems[glyphname].append(problem)
+
+                print(json.dumps(problems), file=f)
+            else:
+                last_glyphname = None
+                for glyphname, p in problems_gen:
+                    problems[glyphname].append(p)
+
+                    if glyphname != last_glyphname:
+                        print(f"Glyph {glyphname} was not compatible:", file=f)
+                        last_glyphname = glyphname
+                        last_master_idxs = None
+
+                    master_idxs = (
+                        (p["master_idx"])
+                        if "master_idx" in p
+                        else (p["master_1_idx"], p["master_2_idx"])
+                    )
+                    if master_idxs != last_master_idxs:
+                        master_names = (
+                            (p["master"])
+                            if "master" in p
+                            else (p["master_1"], p["master_2"])
+                        )
+                        print(f"  Masters: %s:" % ", ".join(master_names), file=f)
+                        last_master_idxs = master_idxs
+
+                    if p["type"] == InterpolatableProblem.MISSING:
+                        print(
+                            "    Glyph was missing in master %s" % p["master"], file=f
+                        )
+                    elif p["type"] == InterpolatableProblem.OPEN_PATH:
+                        print(
+                            "    Glyph has an open path in master %s" % p["master"],
+                            file=f,
+                        )
+                    elif p["type"] == InterpolatableProblem.PATH_COUNT:
+                        print(
+                            "    Path count differs: %i in %s, %i in %s"
+                            % (
+                                p["value_1"],
+                                p["master_1"],
+                                p["value_2"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == InterpolatableProblem.NODE_COUNT:
+                        print(
+                            "    Node count differs in path %i: %i in %s, %i in %s"
+                            % (
+                                p["path"],
+                                p["value_1"],
+                                p["master_1"],
+                                p["value_2"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == InterpolatableProblem.NODE_INCOMPATIBILITY:
+                        print(
+                            "    Node %o incompatible in path %i: %s in %s, %s in %s"
+                            % (
+                                p["node"],
+                                p["path"],
+                                p["value_1"],
+                                p["master_1"],
+                                p["value_2"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == InterpolatableProblem.CONTOUR_ORDER:
+                        print(
+                            "    Contour order differs: %s in %s, %s in %s"
+                            % (
+                                p["value_1"],
+                                p["master_1"],
+                                p["value_2"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == InterpolatableProblem.WRONG_START_POINT:
+                        print(
+                            "    Contour %d start point differs: %s in %s, %s in %s; reversed: %s"
+                            % (
+                                p["contour"],
+                                p["value_1"],
+                                p["master_1"],
+                                p["value_2"],
+                                p["master_2"],
+                                p["reversed"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == InterpolatableProblem.UNDERWEIGHT:
+                        print(
+                            "    Contour %d interpolation is underweight: %s, %s"
+                            % (
+                                p["contour"],
+                                p["master_1"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == InterpolatableProblem.OVERWEIGHT:
+                        print(
+                            "    Contour %d interpolation is overweight: %s, %s"
+                            % (
+                                p["contour"],
+                                p["master_1"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == InterpolatableProblem.KINK:
+                        print(
+                            "    Contour %d has a kink at %s: %s, %s"
+                            % (
+                                p["contour"],
+                                p["value"],
+                                p["master_1"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+                    elif p["type"] == InterpolatableProblem.NOTHING:
+                        print(
+                            "    Showing %s and %s"
+                            % (
+                                p["master_1"],
+                                p["master_2"],
+                            ),
+                            file=f,
+                        )
+        else:
             for glyphname, problem in problems_gen:
                 problems[glyphname].append(problem)
 
-            print(json.dumps(problems))
-        else:
-            last_glyphname = None
-            for glyphname, p in problems_gen:
-                problems[glyphname].append(p)
+        problems = sort_problems(problems)
 
-                if glyphname != last_glyphname:
-                    print(f"Glyph {glyphname} was not compatible: ")
-                    last_glyphname = glyphname
+        for p in "ps", "pdf":
+            arg = getattr(args, p)
+            if arg is None:
+                continue
+            log.info("Writing %s to %s", p.upper(), arg)
+            from .interpolatablePlot import InterpolatablePS, InterpolatablePDF
 
-                if p["type"] == "missing":
-                    print("    Glyph was missing in master %s" % p["master"])
-                if p["type"] == "open_path":
-                    print("    Glyph has an open path in master %s" % p["master"])
-                if p["type"] == "path_count":
-                    print(
-                        "    Path count differs: %i in %s, %i in %s"
-                        % (p["value_1"], p["master_1"], p["value_2"], p["master_2"])
+            PlotterClass = InterpolatablePS if p == "ps" else InterpolatablePDF
+
+            with PlotterClass(
+                ensure_parent_dir(arg), glyphsets=glyphsets, names=names
+            ) as doc:
+                doc.add_title_page(
+                    original_args_inputs, tolerance=tolerance, kinkiness=kinkiness
+                )
+                if problems:
+                    doc.add_summary(problems)
+                doc.add_problems(problems)
+                if not problems and not args.quiet:
+                    doc.draw_cupcake()
+                if problems:
+                    doc.add_index()
+                    doc.add_table_of_contents()
+
+        if args.html:
+            log.info("Writing HTML to %s", args.html)
+            from .interpolatablePlot import InterpolatableSVG
+
+            svgs = []
+            glyph_starts = {}
+            with InterpolatableSVG(svgs, glyphsets=glyphsets, names=names) as svg:
+                svg.add_title_page(
+                    original_args_inputs,
+                    show_tolerance=False,
+                    tolerance=tolerance,
+                    kinkiness=kinkiness,
+                )
+                for glyph, glyph_problems in problems.items():
+                    glyph_starts[len(svgs)] = glyph
+                    svg.add_problems(
+                        {glyph: glyph_problems},
+                        show_tolerance=False,
+                        show_page_number=False,
                     )
-                if p["type"] == "node_count":
-                    print(
-                        "    Node count differs in path %i: %i in %s, %i in %s"
-                        % (
-                            p["path"],
-                            p["value_1"],
-                            p["master_1"],
-                            p["value_2"],
-                            p["master_2"],
-                        )
-                    )
-                if p["type"] == "node_incompatibility":
-                    print(
-                        "    Node %o incompatible in path %i: %s in %s, %s in %s"
-                        % (
-                            p["node"],
-                            p["path"],
-                            p["value_1"],
-                            p["master_1"],
-                            p["value_2"],
-                            p["master_2"],
-                        )
-                    )
-                if p["type"] == "contour_order":
-                    print(
-                        "    Contour order differs: %s in %s, %s in %s"
-                        % (
-                            p["value_1"],
-                            p["master_1"],
-                            p["value_2"],
-                            p["master_2"],
-                        )
-                    )
-                if p["type"] == "wrong_start_point":
-                    print(
-                        "    Contour %d start point differs: %s, %s"
-                        % (
-                            p["contour"],
-                            p["master_1"],
-                            p["master_2"],
-                        )
-                    )
-                if p["type"] == "math_error":
-                    print(
-                        "    Miscellaneous error in %s: %s"
-                        % (
-                            p["master"],
-                            p["error"],
-                        )
-                    )
-    else:
-        for glyphname, problem in problems_gen:
-            problems[glyphname].append(problem)
+                if not problems and not args.quiet:
+                    svg.draw_cupcake()
+
+            import base64
+
+            with open(ensure_parent_dir(args.html), "wb") as f:
+                f.write(b"<!DOCTYPE html>\n")
+                f.write(
+                    b'<html><body align="center" style="font-family: sans-serif; text-color: #222">\n'
+                )
+                f.write(b"<title>fonttools varLib.interpolatable report</title>\n")
+                for i, svg in enumerate(svgs):
+                    if i in glyph_starts:
+                        f.write(f"<h1>Glyph {glyph_starts[i]}</h1>\n".encode("utf-8"))
+                    f.write("<img src='data:image/svg+xml;base64,".encode("utf-8"))
+                    f.write(base64.b64encode(svg))
+                    f.write(b"' />\n")
+                    f.write(b"<hr>\n")
+                f.write(b"</body></html>\n")
+
+    except Exception as e:
+        e.args += original_args_inputs
+        log.error(e)
+        raise
 
     if problems:
         return problems
diff --git a/Lib/fontTools/varLib/interpolatableHelpers.py b/Lib/fontTools/varLib/interpolatableHelpers.py
new file mode 100644
index 0000000..2a3540f
--- /dev/null
+++ b/Lib/fontTools/varLib/interpolatableHelpers.py
@@ -0,0 +1,380 @@
+from fontTools.ttLib.ttGlyphSet import LerpGlyphSet
+from fontTools.pens.basePen import AbstractPen, BasePen, DecomposingPen
+from fontTools.pens.pointPen import AbstractPointPen, SegmentToPointPen
+from fontTools.pens.recordingPen import RecordingPen, DecomposingRecordingPen
+from fontTools.misc.transform import Transform
+from collections import defaultdict, deque
+from math import sqrt, copysign, atan2, pi
+from enum import Enum
+import itertools
+
+import logging
+
+log = logging.getLogger("fontTools.varLib.interpolatable")
+
+
+class InterpolatableProblem:
+    NOTHING = "nothing"
+    MISSING = "missing"
+    OPEN_PATH = "open_path"
+    PATH_COUNT = "path_count"
+    NODE_COUNT = "node_count"
+    NODE_INCOMPATIBILITY = "node_incompatibility"
+    CONTOUR_ORDER = "contour_order"
+    WRONG_START_POINT = "wrong_start_point"
+    KINK = "kink"
+    UNDERWEIGHT = "underweight"
+    OVERWEIGHT = "overweight"
+
+    severity = {
+        MISSING: 1,
+        OPEN_PATH: 2,
+        PATH_COUNT: 3,
+        NODE_COUNT: 4,
+        NODE_INCOMPATIBILITY: 5,
+        CONTOUR_ORDER: 6,
+        WRONG_START_POINT: 7,
+        KINK: 8,
+        UNDERWEIGHT: 9,
+        OVERWEIGHT: 10,
+        NOTHING: 11,
+    }
+
+
+def sort_problems(problems):
+    """Sort problems by severity, then by glyph name, then by problem message."""
+    return dict(
+        sorted(
+            problems.items(),
+            key=lambda _: -min(
+                (
+                    (InterpolatableProblem.severity[p["type"]] + p.get("tolerance", 0))
+                    for p in _[1]
+                ),
+            ),
+            reverse=True,
+        )
+    )
+
+
+def rot_list(l, k):
+    """Rotate list by k items forward.  Ie. item at position 0 will be
+    at position k in returned list.  Negative k is allowed."""
+    return l[-k:] + l[:-k]
+
+
+class PerContourPen(BasePen):
+    def __init__(self, Pen, glyphset=None):
+        BasePen.__init__(self, glyphset)
+        self._glyphset = glyphset
+        self._Pen = Pen
+        self._pen = None
+        self.value = []
+
+    def _moveTo(self, p0):
+        self._newItem()
+        self._pen.moveTo(p0)
+
+    def _lineTo(self, p1):
+        self._pen.lineTo(p1)
+
+    def _qCurveToOne(self, p1, p2):
+        self._pen.qCurveTo(p1, p2)
+
+    def _curveToOne(self, p1, p2, p3):
+        self._pen.curveTo(p1, p2, p3)
+
+    def _closePath(self):
+        self._pen.closePath()
+        self._pen = None
+
+    def _endPath(self):
+        self._pen.endPath()
+        self._pen = None
+
+    def _newItem(self):
+        self._pen = pen = self._Pen()
+        self.value.append(pen)
+
+
+class PerContourOrComponentPen(PerContourPen):
+    def addComponent(self, glyphName, transformation):
+        self._newItem()
+        self.value[-1].addComponent(glyphName, transformation)
+
+
+class SimpleRecordingPointPen(AbstractPointPen):
+    def __init__(self):
+        self.value = []
+
+    def beginPath(self, identifier=None, **kwargs):
+        pass
+
+    def endPath(self) -> None:
+        pass
+
+    def addPoint(self, pt, segmentType=None):
+        self.value.append((pt, False if segmentType is None else True))
+
+
+def vdiff_hypot2(v0, v1):
+    s = 0
+    for x0, x1 in zip(v0, v1):
+        d = x1 - x0
+        s += d * d
+    return s
+
+
+def vdiff_hypot2_complex(v0, v1):
+    s = 0
+    for x0, x1 in zip(v0, v1):
+        d = x1 - x0
+        s += d.real * d.real + d.imag * d.imag
+        # This does the same but seems to be slower:
+        # s += (d * d.conjugate()).real
+    return s
+
+
+def matching_cost(G, matching):
+    return sum(G[i][j] for i, j in enumerate(matching))
+
+
+def min_cost_perfect_bipartite_matching_scipy(G):
+    n = len(G)
+    rows, cols = linear_sum_assignment(G)
+    assert (rows == list(range(n))).all()
+    return list(cols), matching_cost(G, cols)
+
+
+def min_cost_perfect_bipartite_matching_munkres(G):
+    n = len(G)
+    cols = [None] * n
+    for row, col in Munkres().compute(G):
+        cols[row] = col
+    return cols, matching_cost(G, cols)
+
+
+def min_cost_perfect_bipartite_matching_bruteforce(G):
+    n = len(G)
+
+    if n > 6:
+        raise Exception("Install Python module 'munkres' or 'scipy >= 0.17.0'")
+
+    # Otherwise just brute-force
+    permutations = itertools.permutations(range(n))
+    best = list(next(permutations))
+    best_cost = matching_cost(G, best)
+    for p in permutations:
+        cost = matching_cost(G, p)
+        if cost < best_cost:
+            best, best_cost = list(p), cost
+    return best, best_cost
+
+
+try:
+    from scipy.optimize import linear_sum_assignment
+
+    min_cost_perfect_bipartite_matching = min_cost_perfect_bipartite_matching_scipy
+except ImportError:
+    try:
+        from munkres import Munkres
+
+        min_cost_perfect_bipartite_matching = (
+            min_cost_perfect_bipartite_matching_munkres
+        )
+    except ImportError:
+        min_cost_perfect_bipartite_matching = (
+            min_cost_perfect_bipartite_matching_bruteforce
+        )
+
+
+def contour_vector_from_stats(stats):
+    # Don't change the order of items here.
+    # It's okay to add to the end, but otherwise, other
+    # code depends on it. Search for "covariance".
+    size = sqrt(abs(stats.area))
+    return (
+        copysign((size), stats.area),
+        stats.meanX,
+        stats.meanY,
+        stats.stddevX * 2,
+        stats.stddevY * 2,
+        stats.correlation * size,
+    )
+
+
+def matching_for_vectors(m0, m1):
+    n = len(m0)
+
+    identity_matching = list(range(n))
+
+    costs = [[vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0]
+    (
+        matching,
+        matching_cost,
+    ) = min_cost_perfect_bipartite_matching(costs)
+    identity_cost = sum(costs[i][i] for i in range(n))
+    return matching, matching_cost, identity_cost
+
+
+def points_characteristic_bits(points):
+    bits = 0
+    for pt, b in reversed(points):
+        bits = (bits << 1) | b
+    return bits
+
+
+_NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR = 4
+
+
+def points_complex_vector(points):
+    vector = []
+    if not points:
+        return vector
+    points = [complex(*pt) for pt, _ in points]
+    n = len(points)
+    assert _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR == 4
+    points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1])
+    while len(points) < _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR:
+        points.extend(points[: _NUM_ITEMS_PER_POINTS_COMPLEX_VECTOR - 1])
+    for i in range(n):
+        # The weights are magic numbers.
+
+        # The point itself
+        p0 = points[i]
+        vector.append(p0)
+
+        # The vector to the next point
+        p1 = points[i + 1]
+        d0 = p1 - p0
+        vector.append(d0 * 3)
+
+        # The turn vector
+        p2 = points[i + 2]
+        d1 = p2 - p1
+        vector.append(d1 - d0)
+
+        # The angle to the next point, as a cross product;
+        # Square root of, to match dimentionality of distance.
+        cross = d0.real * d1.imag - d0.imag * d1.real
+        cross = copysign(sqrt(abs(cross)), cross)
+        vector.append(cross * 4)
+
+    return vector
+
+
+def add_isomorphisms(points, isomorphisms, reverse):
+    reference_bits = points_characteristic_bits(points)
+    n = len(points)
+
+    # if points[0][0] == points[-1][0]:
+    #   abort
+
+    if reverse:
+        points = points[::-1]
+        bits = points_characteristic_bits(points)
+    else:
+        bits = reference_bits
+
+    vector = points_complex_vector(points)
+
+    assert len(vector) % n == 0
+    mult = len(vector) // n
+    mask = (1 << n) - 1
+
+    for i in range(n):
+        b = ((bits << (n - i)) & mask) | (bits >> i)
+        if b == reference_bits:
+            isomorphisms.append(
+                (rot_list(vector, -i * mult), n - 1 - i if reverse else i, reverse)
+            )
+
+
+def find_parents_and_order(glyphsets, locations):
+    parents = [None] + list(range(len(glyphsets) - 1))
+    order = list(range(len(glyphsets)))
+    if locations:
+        # Order base master first
+        bases = (i for i, l in enumerate(locations) if all(v == 0 for v in l.values()))
+        if bases:
+            base = next(bases)
+            logging.info("Base master index %s, location %s", base, locations[base])
+        else:
+            base = 0
+            logging.warning("No base master location found")
+
+        # Form a minimum spanning tree of the locations
+        try:
+            from scipy.sparse.csgraph import minimum_spanning_tree
+
+            graph = [[0] * len(locations) for _ in range(len(locations))]
+            axes = set()
+            for l in locations:
+                axes.update(l.keys())
+            axes = sorted(axes)
+            vectors = [tuple(l.get(k, 0) for k in axes) for l in locations]
+            for i, j in itertools.combinations(range(len(locations)), 2):
+                graph[i][j] = vdiff_hypot2(vectors[i], vectors[j])
+
+            tree = minimum_spanning_tree(graph)
+            rows, cols = tree.nonzero()
+            graph = defaultdict(set)
+            for row, col in zip(rows, cols):
+                graph[row].add(col)
+                graph[col].add(row)
+
+            # Traverse graph from the base and assign parents
+            parents = [None] * len(locations)
+            order = []
+            visited = set()
+            queue = deque([base])
+            while queue:
+                i = queue.popleft()
+                visited.add(i)
+                order.append(i)
+                for j in sorted(graph[i]):
+                    if j not in visited:
+                        parents[j] = i
+                        queue.append(j)
+
+        except ImportError:
+            pass
+
+        log.info("Parents: %s", parents)
+        log.info("Order: %s", order)
+    return parents, order
+
+
+def transform_from_stats(stats, inverse=False):
+    # https://cookierobotics.com/007/
+    a = stats.varianceX
+    b = stats.covariance
+    c = stats.varianceY
+
+    delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5
+    lambda1 = (a + c) * 0.5 + delta  # Major eigenvalue
+    lambda2 = (a + c) * 0.5 - delta  # Minor eigenvalue
+    theta = atan2(lambda1 - a, b) if b != 0 else (pi * 0.5 if a < c else 0)
+    trans = Transform()
+
+    if lambda2 < 0:
+        # XXX This is a hack.
+        # The problem is that the covariance matrix is singular.
+        # This happens when the contour is a line, or a circle.
+        # In that case, the covariance matrix is not a good
+        # representation of the contour.
+        # We should probably detect this earlier and avoid
+        # computing the covariance matrix in the first place.
+        # But for now, we just avoid the division by zero.
+        lambda2 = 0
+
+    if inverse:
+        trans = trans.translate(-stats.meanX, -stats.meanY)
+        trans = trans.rotate(-theta)
+        trans = trans.scale(1 / sqrt(lambda1), 1 / sqrt(lambda2))
+    else:
+        trans = trans.scale(sqrt(lambda1), sqrt(lambda2))
+        trans = trans.rotate(theta)
+        trans = trans.translate(stats.meanX, stats.meanY)
+
+    return trans
diff --git a/Lib/fontTools/varLib/interpolatablePlot.py b/Lib/fontTools/varLib/interpolatablePlot.py
new file mode 100644
index 0000000..3c206c6
--- /dev/null
+++ b/Lib/fontTools/varLib/interpolatablePlot.py
@@ -0,0 +1,1269 @@
+from .interpolatableHelpers import *
+from fontTools.ttLib import TTFont
+from fontTools.ttLib.ttGlyphSet import LerpGlyphSet
+from fontTools.pens.recordingPen import (
+    RecordingPen,
+    DecomposingRecordingPen,
+    RecordingPointPen,
+)
+from fontTools.pens.boundsPen import ControlBoundsPen
+from fontTools.pens.cairoPen import CairoPen
+from fontTools.pens.pointPen import (
+    SegmentToPointPen,
+    PointToSegmentPen,
+    ReverseContourPointPen,
+)
+from fontTools.varLib.interpolatableHelpers import (
+    PerContourOrComponentPen,
+    SimpleRecordingPointPen,
+)
+from itertools import cycle
+from functools import wraps
+from io import BytesIO
+import cairo
+import math
+import os
+import logging
+
+log = logging.getLogger("fontTools.varLib.interpolatable")
+
+
+class OverridingDict(dict):
+    def __init__(self, parent_dict):
+        self.parent_dict = parent_dict
+
+    def __missing__(self, key):
+        return self.parent_dict[key]
+
+
+class InterpolatablePlot:
+    width = 8.5 * 72
+    height = 11 * 72
+    pad = 0.1 * 72
+    title_font_size = 24
+    font_size = 16
+    page_number = 1
+    head_color = (0.3, 0.3, 0.3)
+    label_color = (0.2, 0.2, 0.2)
+    border_color = (0.9, 0.9, 0.9)
+    border_width = 0.5
+    fill_color = (0.8, 0.8, 0.8)
+    stroke_color = (0.1, 0.1, 0.1)
+    stroke_width = 1
+    oncurve_node_color = (0, 0.8, 0, 0.7)
+    oncurve_node_diameter = 6
+    offcurve_node_color = (0, 0.5, 0, 0.7)
+    offcurve_node_diameter = 4
+    handle_color = (0, 0.5, 0, 0.7)
+    handle_width = 0.5
+    corrected_start_point_color = (0, 0.9, 0, 0.7)
+    corrected_start_point_size = 7
+    wrong_start_point_color = (1, 0, 0, 0.7)
+    start_point_color = (0, 0, 1, 0.7)
+    start_arrow_length = 9
+    kink_point_size = 7
+    kink_point_color = (1, 0, 1, 0.7)
+    kink_circle_size = 15
+    kink_circle_stroke_width = 1
+    kink_circle_color = (1, 0, 1, 0.7)
+    contour_colors = ((1, 0, 0), (0, 0, 1), (0, 1, 0), (1, 1, 0), (1, 0, 1), (0, 1, 1))
+    contour_alpha = 0.5
+    weight_issue_contour_color = (0, 0, 0, 0.4)
+    no_issues_label = "Your font's good! Have a cupcake..."
+    no_issues_label_color = (0, 0.5, 0)
+    cupcake_color = (0.3, 0, 0.3)
+    cupcake = r"""
+                          ,@.
+                        ,@.@@,.
+                  ,@@,.@@@.  @.@@@,.
+                ,@@. @@@.     @@. @@,.
+        ,@@@.@,.@.              @.  @@@@,.@.@@,.
+   ,@@.@.     @@.@@.            @,.    .@' @'  @@,
+ ,@@. @.          .@@.@@@.  @@'                  @,
+,@.  @@.                                          @,
+@.     @,@@,.     ,                             .@@,
+@,.       .@,@@,.         .@@,.  ,       .@@,  @, @,
+@.                             .@. @ @@,.    ,      @
+ @,.@@.     @,.      @@,.      @.           @,.    @'
+  @@||@,.  @'@,.       @@,.  @@ @,.        @'@@,  @'
+     \\@@@@'  @,.      @'@@@@'   @@,.   @@@' //@@@'
+      |||||||| @@,.  @@' |||||||  |@@@|@||  ||
+       \\\\\\\  ||@@@||  |||||||  |||||||  //
+        |||||||  ||||||  ||||||   ||||||  ||
+         \\\\\\  ||||||  ||||||  ||||||  //
+          ||||||  |||||  |||||   |||||  ||
+           \\\\\  |||||  |||||  |||||  //
+            |||||  ||||  |||||  ||||  ||
+             \\\\  ||||  ||||  ||||  //
+              ||||||||||||||||||||||||
+"""
+    emoticon_color = (0, 0.3, 0.3)
+    shrug = r"""\_(")_/"""
+    underweight = r"""
+ o
+/|\
+/ \
+"""
+    overweight = r"""
+ o
+/O\
+/ \
+"""
+    yay = r""" \o/ """
+
+    def __init__(self, out, glyphsets, names=None, **kwargs):
+        self.out = out
+        self.glyphsets = glyphsets
+        self.names = names or [repr(g) for g in glyphsets]
+        self.toc = {}
+
+        for k, v in kwargs.items():
+            if not hasattr(self, k):
+                raise TypeError("Unknown keyword argument: %s" % k)
+            setattr(self, k, v)
+
+        self.panel_width = self.width / 2 - self.pad * 3
+        self.panel_height = (
+            self.height / 2 - self.pad * 6 - self.font_size * 2 - self.title_font_size
+        )
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, type, value, traceback):
+        pass
+
+    def show_page(self):
+        self.page_number += 1
+
+    def add_title_page(
+        self, files, *, show_tolerance=True, tolerance=None, kinkiness=None
+    ):
+        pad = self.pad
+        width = self.width - 3 * self.pad
+        height = self.height - 2 * self.pad
+        x = y = pad
+
+        self.draw_label(
+            "Problem report for:",
+            x=x,
+            y=y,
+            bold=True,
+            width=width,
+            font_size=self.title_font_size,
+        )
+        y += self.title_font_size
+
+        import hashlib
+
+        for file in files:
+            base_file = os.path.basename(file)
+            y += self.font_size + self.pad
+            self.draw_label(base_file, x=x, y=y, bold=True, width=width)
+            y += self.font_size + self.pad
+
+            try:
+                h = hashlib.sha1(open(file, "rb").read()).hexdigest()
+                self.draw_label("sha1: %s" % h, x=x + pad, y=y, width=width)
+                y += self.font_size
+            except IsADirectoryError:
+                pass
+
+            if file.endswith(".ttf"):
+                ttFont = TTFont(file)
+                name = ttFont["name"] if "name" in ttFont else None
+                if name:
+                    for what, nameIDs in (
+                        ("Family name", (21, 16, 1)),
+                        ("Version", (5,)),
+                    ):
+                        n = name.getFirstDebugName(nameIDs)
+                        if n is None:
+                            continue
+                        self.draw_label(
+                            "%s: %s" % (what, n), x=x + pad, y=y, width=width
+                        )
+                        y += self.font_size + self.pad
+            elif file.endswith((".glyphs", ".glyphspackage")):
+                from glyphsLib import GSFont
+
+                f = GSFont(file)
+                for what, field in (
+                    ("Family name", "familyName"),
+                    ("VersionMajor", "versionMajor"),
+                    ("VersionMinor", "_versionMinor"),
+                ):
+                    self.draw_label(
+                        "%s: %s" % (what, getattr(f, field)),
+                        x=x + pad,
+                        y=y,
+                        width=width,
+                    )
+                    y += self.font_size + self.pad
+
+        self.draw_legend(
+            show_tolerance=show_tolerance, tolerance=tolerance, kinkiness=kinkiness
+        )
+        self.show_page()
+
+    def draw_legend(self, *, show_tolerance=True, tolerance=None, kinkiness=None):
+        cr = cairo.Context(self.surface)
+
+        x = self.pad
+        y = self.height - self.pad - self.font_size * 2
+        width = self.width - 2 * self.pad
+
+        xx = x + self.pad * 2
+        xxx = x + self.pad * 4
+
+        if show_tolerance:
+            self.draw_label(
+                "Tolerance: badness; closer to zero the worse", x=xxx, y=y, width=width
+            )
+            y -= self.pad + self.font_size
+
+        self.draw_label("Underweight contours", x=xxx, y=y, width=width)
+        cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.font_size)
+        cr.set_source_rgb(*self.fill_color)
+        cr.fill_preserve()
+        if self.stroke_color:
+            cr.set_source_rgb(*self.stroke_color)
+            cr.set_line_width(self.stroke_width)
+            cr.stroke_preserve()
+        cr.set_source_rgba(*self.weight_issue_contour_color)
+        cr.fill()
+        y -= self.pad + self.font_size
+
+        self.draw_label(
+            "Colored contours: contours with the wrong order", x=xxx, y=y, width=width
+        )
+        cr.rectangle(xx - self.pad * 0.7, y, 1.5 * self.pad, self.font_size)
+        if self.fill_color:
+            cr.set_source_rgb(*self.fill_color)
+            cr.fill_preserve()
+        if self.stroke_color:
+            cr.set_source_rgb(*self.stroke_color)
+            cr.set_line_width(self.stroke_width)
+            cr.stroke_preserve()
+        cr.set_source_rgba(*self.contour_colors[0], self.contour_alpha)
+        cr.fill()
+        y -= self.pad + self.font_size
+
+        self.draw_label("Kink artifact", x=xxx, y=y, width=width)
+        self.draw_circle(
+            cr,
+            x=xx,
+            y=y + self.font_size * 0.5,
+            diameter=self.kink_circle_size,
+            stroke_width=self.kink_circle_stroke_width,
+            color=self.kink_circle_color,
+        )
+        y -= self.pad + self.font_size
+
+        self.draw_label("Point causing kink in the contour", x=xxx, y=y, width=width)
+        self.draw_dot(
+            cr,
+            x=xx,
+            y=y + self.font_size * 0.5,
+            diameter=self.kink_point_size,
+            color=self.kink_point_color,
+        )
+        y -= self.pad + self.font_size
+
+        self.draw_label("Suggested new contour start point", x=xxx, y=y, width=width)
+        self.draw_dot(
+            cr,
+            x=xx,
+            y=y + self.font_size * 0.5,
+            diameter=self.corrected_start_point_size,
+            color=self.corrected_start_point_color,
+        )
+        y -= self.pad + self.font_size
+
+        self.draw_label(
+            "Contour start point in contours with wrong direction",
+            x=xxx,
+            y=y,
+            width=width,
+        )
+        self.draw_arrow(
+            cr,
+            x=xx - self.start_arrow_length * 0.3,
+            y=y + self.font_size * 0.5,
+            color=self.wrong_start_point_color,
+        )
+        y -= self.pad + self.font_size
+
+        self.draw_label(
+            "Contour start point when the first two points overlap",
+            x=xxx,
+            y=y,
+            width=width,
+        )
+        self.draw_dot(
+            cr,
+            x=xx,
+            y=y + self.font_size * 0.5,
+            diameter=self.corrected_start_point_size,
+            color=self.start_point_color,
+        )
+        y -= self.pad + self.font_size
+
+        self.draw_label("Contour start point and direction", x=xxx, y=y, width=width)
+        self.draw_arrow(
+            cr,
+            x=xx - self.start_arrow_length * 0.3,
+            y=y + self.font_size * 0.5,
+            color=self.start_point_color,
+        )
+        y -= self.pad + self.font_size
+
+        self.draw_label("Legend:", x=x, y=y, width=width, bold=True)
+        y -= self.pad + self.font_size
+
+        if kinkiness is not None:
+            self.draw_label(
+                "Kink-reporting aggressiveness: %g" % kinkiness,
+                x=xxx,
+                y=y,
+                width=width,
+            )
+            y -= self.pad + self.font_size
+
+        if tolerance is not None:
+            self.draw_label(
+                "Error tolerance: %g" % tolerance,
+                x=xxx,
+                y=y,
+                width=width,
+            )
+            y -= self.pad + self.font_size
+
+        self.draw_label("Parameters:", x=x, y=y, width=width, bold=True)
+        y -= self.pad + self.font_size
+
+    def add_summary(self, problems):
+        pad = self.pad
+        width = self.width - 3 * self.pad
+        height = self.height - 2 * self.pad
+        x = y = pad
+
+        self.draw_label(
+            "Summary of problems",
+            x=x,
+            y=y,
+            bold=True,
+            width=width,
+            font_size=self.title_font_size,
+        )
+        y += self.title_font_size
+
+        glyphs_per_problem = defaultdict(set)
+        for glyphname, problems in sorted(problems.items()):
+            for problem in problems:
+                glyphs_per_problem[problem["type"]].add(glyphname)
+
+        if "nothing" in glyphs_per_problem:
+            del glyphs_per_problem["nothing"]
+
+        for problem_type in sorted(
+            glyphs_per_problem, key=lambda x: InterpolatableProblem.severity[x]
+        ):
+            y += self.font_size
+            self.draw_label(
+                "%s: %d" % (problem_type, len(glyphs_per_problem[problem_type])),
+                x=x,
+                y=y,
+                width=width,
+                bold=True,
+            )
+            y += self.font_size
+
+            for glyphname in sorted(glyphs_per_problem[problem_type]):
+                if y + self.font_size > height:
+                    self.show_page()
+                    y = self.font_size + pad
+                self.draw_label(glyphname, x=x + 2 * pad, y=y, width=width - 2 * pad)
+                y += self.font_size
+
+        self.show_page()
+
+    def _add_listing(self, title, items):
+        pad = self.pad
+        width = self.width - 2 * self.pad
+        height = self.height - 2 * self.pad
+        x = y = pad
+
+        self.draw_label(
+            title, x=x, y=y, bold=True, width=width, font_size=self.title_font_size
+        )
+        y += self.title_font_size + self.pad
+
+        last_glyphname = None
+        for page_no, (glyphname, problems) in items:
+            if glyphname == last_glyphname:
+                continue
+            last_glyphname = glyphname
+            if y + self.font_size > height:
+                self.show_page()
+                y = self.font_size + pad
+            self.draw_label(glyphname, x=x + 5 * pad, y=y, width=width - 2 * pad)
+            self.draw_label(str(page_no), x=x, y=y, width=4 * pad, align=1)
+            y += self.font_size
+
+        self.show_page()
+
+    def add_table_of_contents(self):
+        self._add_listing("Table of contents", sorted(self.toc.items()))
+
+    def add_index(self):
+        self._add_listing("Index", sorted(self.toc.items(), key=lambda x: x[1][0]))
+
+    def add_problems(self, problems, *, show_tolerance=True, show_page_number=True):
+        for glyph, glyph_problems in problems.items():
+            last_masters = None
+            current_glyph_problems = []
+            for p in glyph_problems:
+                masters = (
+                    p["master_idx"]
+                    if "master_idx" in p
+                    else (p["master_1_idx"], p["master_2_idx"])
+                )
+                if masters == last_masters:
+                    current_glyph_problems.append(p)
+                    continue
+                # Flush
+                if current_glyph_problems:
+                    self.add_problem(
+                        glyph,
+                        current_glyph_problems,
+                        show_tolerance=show_tolerance,
+                        show_page_number=show_page_number,
+                    )
+                    self.show_page()
+                    current_glyph_problems = []
+                last_masters = masters
+                current_glyph_problems.append(p)
+            if current_glyph_problems:
+                self.add_problem(
+                    glyph,
+                    current_glyph_problems,
+                    show_tolerance=show_tolerance,
+                    show_page_number=show_page_number,
+                )
+                self.show_page()
+
+    def add_problem(
+        self, glyphname, problems, *, show_tolerance=True, show_page_number=True
+    ):
+        if type(problems) not in (list, tuple):
+            problems = [problems]
+
+        self.toc[self.page_number] = (glyphname, problems)
+
+        problem_type = problems[0]["type"]
+        problem_types = set(problem["type"] for problem in problems)
+        if not all(pt == problem_type for pt in problem_types):
+            problem_type = ", ".join(sorted({problem["type"] for problem in problems}))
+
+        log.info("Drawing %s: %s", glyphname, problem_type)
+
+        master_keys = (
+            ("master_idx",)
+            if "master_idx" in problems[0]
+            else ("master_1_idx", "master_2_idx")
+        )
+        master_indices = [problems[0][k] for k in master_keys]
+
+        if problem_type == InterpolatableProblem.MISSING:
+            sample_glyph = next(
+                i for i, m in enumerate(self.glyphsets) if m[glyphname] is not None
+            )
+            master_indices.insert(0, sample_glyph)
+
+        x = self.pad
+        y = self.pad
+
+        self.draw_label(
+            "Glyph name: " + glyphname,
+            x=x,
+            y=y,
+            color=self.head_color,
+            align=0,
+            bold=True,
+            font_size=self.title_font_size,
+        )
+        tolerance = min(p.get("tolerance", 1) for p in problems)
+        if tolerance < 1 and show_tolerance:
+            self.draw_label(
+                "tolerance: %.2f" % tolerance,
+                x=x,
+                y=y,
+                width=self.width - 2 * self.pad,
+                align=1,
+                bold=True,
+            )
+        y += self.title_font_size + self.pad
+        self.draw_label(
+            "Problems: " + problem_type,
+            x=x,
+            y=y,
+            width=self.width - 2 * self.pad,
+            color=self.head_color,
+            bold=True,
+        )
+        y += self.font_size + self.pad * 2
+
+        scales = []
+        for which, master_idx in enumerate(master_indices):
+            glyphset = self.glyphsets[master_idx]
+            name = self.names[master_idx]
+
+            self.draw_label(
+                name,
+                x=x,
+                y=y,
+                color=self.label_color,
+                width=self.panel_width,
+                align=0.5,
+            )
+            y += self.font_size + self.pad
+
+            if glyphset[glyphname] is not None:
+                scales.append(
+                    self.draw_glyph(glyphset, glyphname, problems, which, x=x, y=y)
+                )
+            else:
+                self.draw_emoticon(self.shrug, x=x, y=y)
+            y += self.panel_height + self.font_size + self.pad
+
+        if any(
+            pt
+            in (
+                InterpolatableProblem.NOTHING,
+                InterpolatableProblem.WRONG_START_POINT,
+                InterpolatableProblem.CONTOUR_ORDER,
+                InterpolatableProblem.KINK,
+                InterpolatableProblem.UNDERWEIGHT,
+                InterpolatableProblem.OVERWEIGHT,
+            )
+            for pt in problem_types
+        ):
+            x = self.pad + self.panel_width + self.pad
+            y = self.pad
+            y += self.title_font_size + self.pad * 2
+            y += self.font_size + self.pad
+
+            glyphset1 = self.glyphsets[master_indices[0]]
+            glyphset2 = self.glyphsets[master_indices[1]]
+
+            # Draw the mid-way of the two masters
+
+            self.draw_label(
+                "midway interpolation",
+                x=x,
+                y=y,
+                color=self.head_color,
+                width=self.panel_width,
+                align=0.5,
+            )
+            y += self.font_size + self.pad
+
+            midway_glyphset = LerpGlyphSet(glyphset1, glyphset2)
+            self.draw_glyph(
+                midway_glyphset,
+                glyphname,
+                [{"type": "midway"}]
+                + [
+                    p
+                    for p in problems
+                    if p["type"]
+                    in (
+                        InterpolatableProblem.KINK,
+                        InterpolatableProblem.UNDERWEIGHT,
+                        InterpolatableProblem.OVERWEIGHT,
+                    )
+                ],
+                None,
+                x=x,
+                y=y,
+                scale=min(scales),
+            )
+
+            y += self.panel_height + self.font_size + self.pad
+
+        if any(
+            pt
+            in (
+                InterpolatableProblem.WRONG_START_POINT,
+                InterpolatableProblem.CONTOUR_ORDER,
+                InterpolatableProblem.KINK,
+            )
+            for pt in problem_types
+        ):
+            # Draw the proposed fix
+
+            self.draw_label(
+                "proposed fix",
+                x=x,
+                y=y,
+                color=self.head_color,
+                width=self.panel_width,
+                align=0.5,
+            )
+            y += self.font_size + self.pad
+
+            overriding1 = OverridingDict(glyphset1)
+            overriding2 = OverridingDict(glyphset2)
+            perContourPen1 = PerContourOrComponentPen(
+                RecordingPen, glyphset=overriding1
+            )
+            perContourPen2 = PerContourOrComponentPen(
+                RecordingPen, glyphset=overriding2
+            )
+            glyphset1[glyphname].draw(perContourPen1)
+            glyphset2[glyphname].draw(perContourPen2)
+
+            for problem in problems:
+                if problem["type"] == InterpolatableProblem.CONTOUR_ORDER:
+                    fixed_contours = [
+                        perContourPen2.value[i] for i in problems[0]["value_2"]
+                    ]
+                    perContourPen2.value = fixed_contours
+
+            for problem in problems:
+                if problem["type"] == InterpolatableProblem.WRONG_START_POINT:
+                    # Save the wrong contours
+                    wrongContour1 = perContourPen1.value[problem["contour"]]
+                    wrongContour2 = perContourPen2.value[problem["contour"]]
+
+                    # Convert the wrong contours to point pens
+                    points1 = RecordingPointPen()
+                    converter = SegmentToPointPen(points1, False)
+                    wrongContour1.replay(converter)
+                    points2 = RecordingPointPen()
+                    converter = SegmentToPointPen(points2, False)
+                    wrongContour2.replay(converter)
+
+                    proposed_start = problem["value_2"]
+
+                    # See if we need reversing; fragile but worth a try
+                    if problem["reversed"]:
+                        new_points2 = RecordingPointPen()
+                        reversedPen = ReverseContourPointPen(new_points2)
+                        points2.replay(reversedPen)
+                        points2 = new_points2
+                        proposed_start = len(points2.value) - 2 - proposed_start
+
+                    # Rotate points2 so that the first point is the same as in points1
+                    beginPath = points2.value[:1]
+                    endPath = points2.value[-1:]
+                    pts = points2.value[1:-1]
+                    pts = pts[proposed_start:] + pts[:proposed_start]
+                    points2.value = beginPath + pts + endPath
+
+                    # Convert the point pens back to segment pens
+                    segment1 = RecordingPen()
+                    converter = PointToSegmentPen(segment1, True)
+                    points1.replay(converter)
+                    segment2 = RecordingPen()
+                    converter = PointToSegmentPen(segment2, True)
+                    points2.replay(converter)
+
+                    # Replace the wrong contours
+                    wrongContour1.value = segment1.value
+                    wrongContour2.value = segment2.value
+                    perContourPen1.value[problem["contour"]] = wrongContour1
+                    perContourPen2.value[problem["contour"]] = wrongContour2
+
+            for problem in problems:
+                # If we have a kink, try to fix it.
+                if problem["type"] == InterpolatableProblem.KINK:
+                    # Save the wrong contours
+                    wrongContour1 = perContourPen1.value[problem["contour"]]
+                    wrongContour2 = perContourPen2.value[problem["contour"]]
+
+                    # Convert the wrong contours to point pens
+                    points1 = RecordingPointPen()
+                    converter = SegmentToPointPen(points1, False)
+                    wrongContour1.replay(converter)
+                    points2 = RecordingPointPen()
+                    converter = SegmentToPointPen(points2, False)
+                    wrongContour2.replay(converter)
+
+                    i = problem["value"]
+
+                    # Position points to be around the same ratio
+                    # beginPath / endPath dance
+                    j = i + 1
+                    pt0 = points1.value[j][1][0]
+                    pt1 = points2.value[j][1][0]
+                    j_prev = (i - 1) % (len(points1.value) - 2) + 1
+                    pt0_prev = points1.value[j_prev][1][0]
+                    pt1_prev = points2.value[j_prev][1][0]
+                    j_next = (i + 1) % (len(points1.value) - 2) + 1
+                    pt0_next = points1.value[j_next][1][0]
+                    pt1_next = points2.value[j_next][1][0]
+
+                    pt0 = complex(*pt0)
+                    pt1 = complex(*pt1)
+                    pt0_prev = complex(*pt0_prev)
+                    pt1_prev = complex(*pt1_prev)
+                    pt0_next = complex(*pt0_next)
+                    pt1_next = complex(*pt1_next)
+
+                    # Find the ratio of the distance between the points
+                    r0 = abs(pt0 - pt0_prev) / abs(pt0_next - pt0_prev)
+                    r1 = abs(pt1 - pt1_prev) / abs(pt1_next - pt1_prev)
+                    r_mid = (r0 + r1) / 2
+
+                    pt0 = pt0_prev + r_mid * (pt0_next - pt0_prev)
+                    pt1 = pt1_prev + r_mid * (pt1_next - pt1_prev)
+
+                    points1.value[j] = (
+                        points1.value[j][0],
+                        (((pt0.real, pt0.imag),) + points1.value[j][1][1:]),
+                        points1.value[j][2],
+                    )
+                    points2.value[j] = (
+                        points2.value[j][0],
+                        (((pt1.real, pt1.imag),) + points2.value[j][1][1:]),
+                        points2.value[j][2],
+                    )
+
+                    # Convert the point pens back to segment pens
+                    segment1 = RecordingPen()
+                    converter = PointToSegmentPen(segment1, True)
+                    points1.replay(converter)
+                    segment2 = RecordingPen()
+                    converter = PointToSegmentPen(segment2, True)
+                    points2.replay(converter)
+
+                    # Replace the wrong contours
+                    wrongContour1.value = segment1.value
+                    wrongContour2.value = segment2.value
+
+            # Assemble
+            fixed1 = RecordingPen()
+            fixed2 = RecordingPen()
+            for contour in perContourPen1.value:
+                fixed1.value.extend(contour.value)
+            for contour in perContourPen2.value:
+                fixed2.value.extend(contour.value)
+            fixed1.draw = fixed1.replay
+            fixed2.draw = fixed2.replay
+
+            overriding1[glyphname] = fixed1
+            overriding2[glyphname] = fixed2
+
+            try:
+                midway_glyphset = LerpGlyphSet(overriding1, overriding2)
+                self.draw_glyph(
+                    midway_glyphset,
+                    glyphname,
+                    {"type": "fixed"},
+                    None,
+                    x=x,
+                    y=y,
+                    scale=min(scales),
+                )
+            except ValueError:
+                self.draw_emoticon(self.shrug, x=x, y=y)
+            y += self.panel_height + self.pad
+
+        else:
+            emoticon = self.shrug
+            if InterpolatableProblem.UNDERWEIGHT in problem_types:
+                emoticon = self.underweight
+            elif InterpolatableProblem.OVERWEIGHT in problem_types:
+                emoticon = self.overweight
+            elif InterpolatableProblem.NOTHING in problem_types:
+                emoticon = self.yay
+            self.draw_emoticon(emoticon, x=x, y=y)
+
+        if show_page_number:
+            self.draw_label(
+                str(self.page_number),
+                x=0,
+                y=self.height - self.font_size - self.pad,
+                width=self.width,
+                color=self.head_color,
+                align=0.5,
+            )
+
+    def draw_label(
+        self,
+        label,
+        *,
+        x=0,
+        y=0,
+        color=(0, 0, 0),
+        align=0,
+        bold=False,
+        width=None,
+        height=None,
+        font_size=None,
+    ):
+        if width is None:
+            width = self.width
+        if height is None:
+            height = self.height
+        if font_size is None:
+            font_size = self.font_size
+        cr = cairo.Context(self.surface)
+        cr.select_font_face(
+            "@cairo:",
+            cairo.FONT_SLANT_NORMAL,
+            cairo.FONT_WEIGHT_BOLD if bold else cairo.FONT_WEIGHT_NORMAL,
+        )
+        cr.set_font_size(font_size)
+        font_extents = cr.font_extents()
+        font_size = font_size * font_size / font_extents[2]
+        cr.set_font_size(font_size)
+        font_extents = cr.font_extents()
+
+        cr.set_source_rgb(*color)
+
+        extents = cr.text_extents(label)
+        if extents.width > width:
+            # Shrink
+            font_size *= width / extents.width
+            cr.set_font_size(font_size)
+            font_extents = cr.font_extents()
+            extents = cr.text_extents(label)
+
+        # Center
+        label_x = x + (width - extents.width) * align
+        label_y = y + font_extents[0]
+        cr.move_to(label_x, label_y)
+        cr.show_text(label)
+
+    def draw_glyph(self, glyphset, glyphname, problems, which, *, x=0, y=0, scale=None):
+        if type(problems) not in (list, tuple):
+            problems = [problems]
+
+        midway = any(problem["type"] == "midway" for problem in problems)
+        problem_type = problems[0]["type"]
+        problem_types = set(problem["type"] for problem in problems)
+        if not all(pt == problem_type for pt in problem_types):
+            problem_type = "mixed"
+        glyph = glyphset[glyphname]
+
+        recording = RecordingPen()
+        glyph.draw(recording)
+        decomposedRecording = DecomposingRecordingPen(glyphset)
+        glyph.draw(decomposedRecording)
+
+        boundsPen = ControlBoundsPen(glyphset)
+        decomposedRecording.replay(boundsPen)
+        bounds = boundsPen.bounds
+        if bounds is None:
+            bounds = (0, 0, 0, 0)
+
+        glyph_width = bounds[2] - bounds[0]
+        glyph_height = bounds[3] - bounds[1]
+
+        if glyph_width:
+            if scale is None:
+                scale = self.panel_width / glyph_width
+            else:
+                scale = min(scale, self.panel_height / glyph_height)
+        if glyph_height:
+            if scale is None:
+                scale = self.panel_height / glyph_height
+            else:
+                scale = min(scale, self.panel_height / glyph_height)
+        if scale is None:
+            scale = 1
+
+        cr = cairo.Context(self.surface)
+        cr.translate(x, y)
+        # Center
+        cr.translate(
+            (self.panel_width - glyph_width * scale) / 2,
+            (self.panel_height - glyph_height * scale) / 2,
+        )
+        cr.scale(scale, -scale)
+        cr.translate(-bounds[0], -bounds[3])
+
+        if self.border_color:
+            cr.set_source_rgb(*self.border_color)
+            cr.rectangle(bounds[0], bounds[1], glyph_width, glyph_height)
+            cr.set_line_width(self.border_width / scale)
+            cr.stroke()
+
+        if self.fill_color or self.stroke_color:
+            pen = CairoPen(glyphset, cr)
+            decomposedRecording.replay(pen)
+
+            if self.fill_color and problem_type != InterpolatableProblem.OPEN_PATH:
+                cr.set_source_rgb(*self.fill_color)
+                cr.fill_preserve()
+
+            if self.stroke_color:
+                cr.set_source_rgb(*self.stroke_color)
+                cr.set_line_width(self.stroke_width / scale)
+                cr.stroke_preserve()
+
+            cr.new_path()
+
+        if (
+            InterpolatableProblem.UNDERWEIGHT in problem_types
+            or InterpolatableProblem.OVERWEIGHT in problem_types
+        ):
+            perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset)
+            recording.replay(perContourPen)
+            for problem in problems:
+                if problem["type"] in (
+                    InterpolatableProblem.UNDERWEIGHT,
+                    InterpolatableProblem.OVERWEIGHT,
+                ):
+                    contour = perContourPen.value[problem["contour"]]
+                    contour.replay(CairoPen(glyphset, cr))
+                    cr.set_source_rgba(*self.weight_issue_contour_color)
+                    cr.fill()
+
+        if any(
+            t in problem_types
+            for t in {
+                InterpolatableProblem.NOTHING,
+                InterpolatableProblem.NODE_COUNT,
+                InterpolatableProblem.NODE_INCOMPATIBILITY,
+            }
+        ):
+            cr.set_line_cap(cairo.LINE_CAP_ROUND)
+
+            # Oncurve nodes
+            for segment, args in decomposedRecording.value:
+                if not args:
+                    continue
+                x, y = args[-1]
+                cr.move_to(x, y)
+                cr.line_to(x, y)
+            cr.set_source_rgba(*self.oncurve_node_color)
+            cr.set_line_width(self.oncurve_node_diameter / scale)
+            cr.stroke()
+
+            # Offcurve nodes
+            for segment, args in decomposedRecording.value:
+                if not args:
+                    continue
+                for x, y in args[:-1]:
+                    cr.move_to(x, y)
+                    cr.line_to(x, y)
+            cr.set_source_rgba(*self.offcurve_node_color)
+            cr.set_line_width(self.offcurve_node_diameter / scale)
+            cr.stroke()
+
+            # Handles
+            for segment, args in decomposedRecording.value:
+                if not args:
+                    pass
+                elif segment in ("moveTo", "lineTo"):
+                    cr.move_to(*args[0])
+                elif segment == "qCurveTo":
+                    for x, y in args:
+                        cr.line_to(x, y)
+                    cr.new_sub_path()
+                    cr.move_to(*args[-1])
+                elif segment == "curveTo":
+                    cr.line_to(*args[0])
+                    cr.new_sub_path()
+                    cr.move_to(*args[1])
+                    cr.line_to(*args[2])
+                    cr.new_sub_path()
+                    cr.move_to(*args[-1])
+                else:
+                    continue
+
+            cr.set_source_rgba(*self.handle_color)
+            cr.set_line_width(self.handle_width / scale)
+            cr.stroke()
+
+        matching = None
+        for problem in problems:
+            if problem["type"] == InterpolatableProblem.CONTOUR_ORDER:
+                matching = problem["value_2"]
+                colors = cycle(self.contour_colors)
+                perContourPen = PerContourOrComponentPen(
+                    RecordingPen, glyphset=glyphset
+                )
+                recording.replay(perContourPen)
+                for i, contour in enumerate(perContourPen.value):
+                    if matching[i] == i:
+                        continue
+                    color = next(colors)
+                    contour.replay(CairoPen(glyphset, cr))
+                    cr.set_source_rgba(*color, self.contour_alpha)
+                    cr.fill()
+
+        for problem in problems:
+            if problem["type"] in (
+                InterpolatableProblem.NOTHING,
+                InterpolatableProblem.WRONG_START_POINT,
+            ):
+                idx = problem.get("contour")
+
+                # Draw suggested point
+                if idx is not None and which == 1 and "value_2" in problem:
+                    perContourPen = PerContourOrComponentPen(
+                        RecordingPen, glyphset=glyphset
+                    )
+                    decomposedRecording.replay(perContourPen)
+                    points = SimpleRecordingPointPen()
+                    converter = SegmentToPointPen(points, False)
+                    perContourPen.value[
+                        idx if matching is None else matching[idx]
+                    ].replay(converter)
+                    targetPoint = points.value[problem["value_2"]][0]
+                    cr.save()
+                    cr.translate(*targetPoint)
+                    cr.scale(1 / scale, 1 / scale)
+                    self.draw_dot(
+                        cr,
+                        diameter=self.corrected_start_point_size,
+                        color=self.corrected_start_point_color,
+                    )
+                    cr.restore()
+
+                # Draw start-point arrow
+                if which == 0 or not problem.get("reversed"):
+                    color = self.start_point_color
+                else:
+                    color = self.wrong_start_point_color
+                first_pt = None
+                i = 0
+                cr.save()
+                for segment, args in decomposedRecording.value:
+                    if segment == "moveTo":
+                        first_pt = args[0]
+                        continue
+                    if first_pt is None:
+                        continue
+                    if segment == "closePath":
+                        second_pt = first_pt
+                    else:
+                        second_pt = args[0]
+
+                    if idx is None or i == idx:
+                        cr.save()
+                        first_pt = complex(*first_pt)
+                        second_pt = complex(*second_pt)
+                        length = abs(second_pt - first_pt)
+                        cr.translate(first_pt.real, first_pt.imag)
+                        if length:
+                            # Draw arrowhead
+                            cr.rotate(
+                                math.atan2(
+                                    second_pt.imag - first_pt.imag,
+                                    second_pt.real - first_pt.real,
+                                )
+                            )
+                            cr.scale(1 / scale, 1 / scale)
+                            self.draw_arrow(cr, color=color)
+                        else:
+                            # Draw circle
+                            cr.scale(1 / scale, 1 / scale)
+                            self.draw_dot(
+                                cr,
+                                diameter=self.corrected_start_point_size,
+                                color=color,
+                            )
+                        cr.restore()
+
+                        if idx is not None:
+                            break
+
+                    first_pt = None
+                    i += 1
+
+                cr.restore()
+
+            if problem["type"] == InterpolatableProblem.KINK:
+                idx = problem.get("contour")
+                perContourPen = PerContourOrComponentPen(
+                    RecordingPen, glyphset=glyphset
+                )
+                decomposedRecording.replay(perContourPen)
+                points = SimpleRecordingPointPen()
+                converter = SegmentToPointPen(points, False)
+                perContourPen.value[idx if matching is None else matching[idx]].replay(
+                    converter
+                )
+
+                targetPoint = points.value[problem["value"]][0]
+                cr.save()
+                cr.translate(*targetPoint)
+                cr.scale(1 / scale, 1 / scale)
+                if midway:
+                    self.draw_circle(
+                        cr,
+                        diameter=self.kink_circle_size,
+                        stroke_width=self.kink_circle_stroke_width,
+                        color=self.kink_circle_color,
+                    )
+                else:
+                    self.draw_dot(
+                        cr,
+                        diameter=self.kink_point_size,
+                        color=self.kink_point_color,
+                    )
+                cr.restore()
+
+        return scale
+
+    def draw_dot(self, cr, *, x=0, y=0, color=(0, 0, 0), diameter=10):
+        cr.save()
+        cr.set_line_width(diameter)
+        cr.set_line_cap(cairo.LINE_CAP_ROUND)
+        cr.move_to(x, y)
+        cr.line_to(x, y)
+        if len(color) == 3:
+            color = color + (1,)
+        cr.set_source_rgba(*color)
+        cr.stroke()
+        cr.restore()
+
+    def draw_circle(
+        self, cr, *, x=0, y=0, color=(0, 0, 0), diameter=10, stroke_width=1
+    ):
+        cr.save()
+        cr.set_line_width(stroke_width)
+        cr.set_line_cap(cairo.LINE_CAP_SQUARE)
+        cr.arc(x, y, diameter / 2, 0, 2 * math.pi)
+        if len(color) == 3:
+            color = color + (1,)
+        cr.set_source_rgba(*color)
+        cr.stroke()
+        cr.restore()
+
+    def draw_arrow(self, cr, *, x=0, y=0, color=(0, 0, 0)):
+        cr.save()
+        if len(color) == 3:
+            color = color + (1,)
+        cr.set_source_rgba(*color)
+        cr.translate(self.start_arrow_length + x, y)
+        cr.move_to(0, 0)
+        cr.line_to(
+            -self.start_arrow_length,
+            -self.start_arrow_length * 0.4,
+        )
+        cr.line_to(
+            -self.start_arrow_length,
+            self.start_arrow_length * 0.4,
+        )
+        cr.close_path()
+        cr.fill()
+        cr.restore()
+
+    def draw_text(self, text, *, x=0, y=0, color=(0, 0, 0), width=None, height=None):
+        if width is None:
+            width = self.width
+        if height is None:
+            height = self.height
+
+        text = text.splitlines()
+        cr = cairo.Context(self.surface)
+        cr.set_source_rgb(*color)
+        cr.set_font_size(self.font_size)
+        cr.select_font_face(
+            "@cairo:monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL
+        )
+        text_width = 0
+        text_height = 0
+        font_extents = cr.font_extents()
+        font_font_size = font_extents[2]
+        font_ascent = font_extents[0]
+        for line in text:
+            extents = cr.text_extents(line)
+            text_width = max(text_width, extents.x_advance)
+            text_height += font_font_size
+        if not text_width:
+            return
+        cr.translate(x, y)
+        scale = min(width / text_width, height / text_height)
+        # center
+        cr.translate(
+            (width - text_width * scale) / 2, (height - text_height * scale) / 2
+        )
+        cr.scale(scale, scale)
+
+        cr.translate(0, font_ascent)
+        for line in text:
+            cr.move_to(0, 0)
+            cr.show_text(line)
+            cr.translate(0, font_font_size)
+
+    def draw_cupcake(self):
+        self.draw_label(
+            self.no_issues_label,
+            x=self.pad,
+            y=self.pad,
+            color=self.no_issues_label_color,
+            width=self.width - 2 * self.pad,
+            align=0.5,
+            bold=True,
+            font_size=self.title_font_size,
+        )
+
+        self.draw_text(
+            self.cupcake,
+            x=self.pad,
+            y=self.pad + self.font_size,
+            width=self.width - 2 * self.pad,
+            height=self.height - 2 * self.pad - self.font_size,
+            color=self.cupcake_color,
+        )
+
+    def draw_emoticon(self, emoticon, x=0, y=0):
+        self.draw_text(
+            emoticon,
+            x=x,
+            y=y,
+            color=self.emoticon_color,
+            width=self.panel_width,
+            height=self.panel_height,
+        )
+
+
+class InterpolatablePostscriptLike(InterpolatablePlot):
+    def __exit__(self, type, value, traceback):
+        self.surface.finish()
+
+    def show_page(self):
+        super().show_page()
+        self.surface.show_page()
+
+
+class InterpolatablePS(InterpolatablePostscriptLike):
+    def __enter__(self):
+        self.surface = cairo.PSSurface(self.out, self.width, self.height)
+        return self
+
+
+class InterpolatablePDF(InterpolatablePostscriptLike):
+    def __enter__(self):
+        self.surface = cairo.PDFSurface(self.out, self.width, self.height)
+        self.surface.set_metadata(
+            cairo.PDF_METADATA_CREATOR, "fonttools varLib.interpolatable"
+        )
+        self.surface.set_metadata(cairo.PDF_METADATA_CREATE_DATE, "")
+        return self
+
+
+class InterpolatableSVG(InterpolatablePlot):
+    def __enter__(self):
+        self.sink = BytesIO()
+        self.surface = cairo.SVGSurface(self.sink, self.width, self.height)
+        return self
+
+    def __exit__(self, type, value, traceback):
+        if self.surface is not None:
+            self.show_page()
+
+    def show_page(self):
+        super().show_page()
+        self.surface.finish()
+        self.out.append(self.sink.getvalue())
+        self.sink = BytesIO()
+        self.surface = cairo.SVGSurface(self.sink, self.width, self.height)
diff --git a/Lib/fontTools/varLib/interpolatableTestContourOrder.py b/Lib/fontTools/varLib/interpolatableTestContourOrder.py
new file mode 100644
index 0000000..9edb1af
--- /dev/null
+++ b/Lib/fontTools/varLib/interpolatableTestContourOrder.py
@@ -0,0 +1,82 @@
+from .interpolatableHelpers import *
+import logging
+
+log = logging.getLogger("fontTools.varLib.interpolatable")
+
+
+def test_contour_order(glyph0, glyph1):
+    # We try matching both the StatisticsControlPen vector
+    # and the StatisticsPen vector.
+    #
+    # If either method found a identity matching, accept it.
+    # This is crucial for fonts like Kablammo[MORF].ttf and
+    # Nabla[EDPT,EHLT].ttf, since they really confuse the
+    # StatisticsPen vector because of their area=0 contours.
+
+    n = len(glyph0.controlVectors)
+    matching = None
+    matching_cost = 0
+    identity_cost = 0
+    done = n <= 1
+    if not done:
+        m0Control = glyph0.controlVectors
+        m1Control = glyph1.controlVectors
+        (
+            matching_control,
+            matching_cost_control,
+            identity_cost_control,
+        ) = matching_for_vectors(m0Control, m1Control)
+        done = matching_cost_control == identity_cost_control
+    if not done:
+        m0Green = glyph0.greenVectors
+        m1Green = glyph1.greenVectors
+        (
+            matching_green,
+            matching_cost_green,
+            identity_cost_green,
+        ) = matching_for_vectors(m0Green, m1Green)
+        done = matching_cost_green == identity_cost_green
+
+    if not done:
+        # See if reversing contours in one master helps.
+        # That's a common problem.  Then the wrong_start_point
+        # test will fix them.
+        #
+        # Reverse the sign of the area (0); the rest stay the same.
+        if not done:
+            m1ControlReversed = [(-m[0],) + m[1:] for m in m1Control]
+            (
+                matching_control_reversed,
+                matching_cost_control_reversed,
+                identity_cost_control_reversed,
+            ) = matching_for_vectors(m0Control, m1ControlReversed)
+            done = matching_cost_control_reversed == identity_cost_control_reversed
+        if not done:
+            m1GreenReversed = [(-m[0],) + m[1:] for m in m1Green]
+            (
+                matching_control_reversed,
+                matching_cost_control_reversed,
+                identity_cost_control_reversed,
+            ) = matching_for_vectors(m0Control, m1ControlReversed)
+            done = matching_cost_control_reversed == identity_cost_control_reversed
+
+        if not done:
+            # Otherwise, use the worst of the two matchings.
+            if (
+                matching_cost_control / identity_cost_control
+                < matching_cost_green / identity_cost_green
+            ):
+                matching = matching_control
+                matching_cost = matching_cost_control
+                identity_cost = identity_cost_control
+            else:
+                matching = matching_green
+                matching_cost = matching_cost_green
+                identity_cost = identity_cost_green
+
+    this_tolerance = matching_cost / identity_cost if identity_cost else 1
+    log.debug(
+        "test-contour-order: tolerance %g",
+        this_tolerance,
+    )
+    return this_tolerance, matching
diff --git a/Lib/fontTools/varLib/interpolatableTestStartingPoint.py b/Lib/fontTools/varLib/interpolatableTestStartingPoint.py
new file mode 100644
index 0000000..e760006
--- /dev/null
+++ b/Lib/fontTools/varLib/interpolatableTestStartingPoint.py
@@ -0,0 +1,105 @@
+from .interpolatableHelpers import *
+
+
+def test_starting_point(glyph0, glyph1, ix, tolerance, matching):
+    if matching is None:
+        matching = list(range(len(glyph0.isomorphisms)))
+    contour0 = glyph0.isomorphisms[ix]
+    contour1 = glyph1.isomorphisms[matching[ix]]
+    m0Vectors = glyph0.greenVectors
+    m1Vectors = [glyph1.greenVectors[i] for i in matching]
+
+    c0 = contour0[0]
+    # Next few lines duplicated below.
+    costs = [vdiff_hypot2_complex(c0[0], c1[0]) for c1 in contour1]
+    min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1])
+    first_cost = costs[0]
+    proposed_point = contour1[min_cost_idx][1]
+    reverse = contour1[min_cost_idx][2]
+
+    if min_cost < first_cost * tolerance:
+        # c0 is the first isomorphism of the m0 master
+        # contour1 is list of all isomorphisms of the m1 master
+        #
+        # If the two shapes are both circle-ish and slightly
+        # rotated, we detect wrong start point. This is for
+        # example the case hundreds of times in
+        # RobotoSerif-Italic[GRAD,opsz,wdth,wght].ttf
+        #
+        # If the proposed point is only one off from the first
+        # point (and not reversed), try harder:
+        #
+        # Find the major eigenvector of the covariance matrix,
+        # and rotate the contours by that angle. Then find the
+        # closest point again.  If it matches this time, let it
+        # pass.
+
+        num_points = len(glyph1.points[ix])
+        leeway = 3
+        if not reverse and (
+            proposed_point <= leeway or proposed_point >= num_points - leeway
+        ):
+            # Try harder
+
+            # Recover the covariance matrix from the GreenVectors.
+            # This is a 2x2 matrix.
+            transforms = []
+            for vector in (m0Vectors[ix], m1Vectors[ix]):
+                meanX = vector[1]
+                meanY = vector[2]
+                stddevX = vector[3] * 0.5
+                stddevY = vector[4] * 0.5
+                correlation = vector[5] / abs(vector[0])
+
+                # https://cookierobotics.com/007/
+                a = stddevX * stddevX  # VarianceX
+                c = stddevY * stddevY  # VarianceY
+                b = correlation * stddevX * stddevY  # Covariance
+
+                delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5
+                lambda1 = (a + c) * 0.5 + delta  # Major eigenvalue
+                lambda2 = (a + c) * 0.5 - delta  # Minor eigenvalue
+                theta = atan2(lambda1 - a, b) if b != 0 else (pi * 0.5 if a < c else 0)
+                trans = Transform()
+                # Don't translate here. We are working on the complex-vector
+                # that includes more than just the points. It's horrible what
+                # we are doing anyway...
+                # trans = trans.translate(meanX, meanY)
+                trans = trans.rotate(theta)
+                trans = trans.scale(sqrt(lambda1), sqrt(lambda2))
+                transforms.append(trans)
+
+            trans = transforms[0]
+            new_c0 = (
+                [complex(*trans.transformPoint((pt.real, pt.imag))) for pt in c0[0]],
+            ) + c0[1:]
+            trans = transforms[1]
+            new_contour1 = []
+            for c1 in contour1:
+                new_c1 = (
+                    [
+                        complex(*trans.transformPoint((pt.real, pt.imag)))
+                        for pt in c1[0]
+                    ],
+                ) + c1[1:]
+                new_contour1.append(new_c1)
+
+            # Next few lines duplicate from above.
+            costs = [
+                vdiff_hypot2_complex(new_c0[0], new_c1[0]) for new_c1 in new_contour1
+            ]
+            min_cost_idx, min_cost = min(enumerate(costs), key=lambda x: x[1])
+            first_cost = costs[0]
+            if min_cost < first_cost * tolerance:
+                # Don't report this
+                # min_cost = first_cost
+                # reverse = False
+                # proposed_point = 0  # new_contour1[min_cost_idx][1]
+                pass
+
+    this_tolerance = min_cost / first_cost if first_cost else 1
+    log.debug(
+        "test-starting-point: tolerance %g",
+        this_tolerance,
+    )
+    return this_tolerance, proposed_point, reverse
diff --git a/Lib/fontTools/varLib/interpolate_layout.py b/Lib/fontTools/varLib/interpolate_layout.py
index aa3f49c..798b295 100644
--- a/Lib/fontTools/varLib/interpolate_layout.py
+++ b/Lib/fontTools/varLib/interpolate_layout.py
@@ -1,6 +1,7 @@
 """
 Interpolate OpenType Layout tables (GDEF / GPOS / GSUB).
 """
+
 from fontTools.ttLib import TTFont
 from fontTools.varLib import models, VarLibError, load_designspace, load_masters
 from fontTools.varLib.merger import InstancerMerger
diff --git a/Lib/fontTools/varLib/merger.py b/Lib/fontTools/varLib/merger.py
index b2c3401..61122f4 100644
--- a/Lib/fontTools/varLib/merger.py
+++ b/Lib/fontTools/varLib/merger.py
@@ -1,6 +1,7 @@
 """
 Merge OpenType Layout tables (GDEF / GPOS / GSUB).
 """
+
 import os
 import copy
 import enum
@@ -1059,7 +1060,7 @@
         Merger.__init__(self, font)
         self.model = model
         self.location = location
-        self.scalars = model.getScalars(location)
+        self.masterScalars = model.getMasterScalars(location)
 
 
 @InstancerMerger.merger(ot.CaretValue)
@@ -1067,8 +1068,10 @@
     assert self.Format == 1
     Coords = [a.Coordinate for a in lst]
     model = merger.model
-    scalars = merger.scalars
-    self.Coordinate = otRound(model.interpolateFromMastersAndScalars(Coords, scalars))
+    masterScalars = merger.masterScalars
+    self.Coordinate = otRound(
+        model.interpolateFromValuesAndScalars(Coords, masterScalars)
+    )
 
 
 @InstancerMerger.merger(ot.Anchor)
@@ -1077,15 +1080,19 @@
     XCoords = [a.XCoordinate for a in lst]
     YCoords = [a.YCoordinate for a in lst]
     model = merger.model
-    scalars = merger.scalars
-    self.XCoordinate = otRound(model.interpolateFromMastersAndScalars(XCoords, scalars))
-    self.YCoordinate = otRound(model.interpolateFromMastersAndScalars(YCoords, scalars))
+    masterScalars = merger.masterScalars
+    self.XCoordinate = otRound(
+        model.interpolateFromValuesAndScalars(XCoords, masterScalars)
+    )
+    self.YCoordinate = otRound(
+        model.interpolateFromValuesAndScalars(YCoords, masterScalars)
+    )
 
 
 @InstancerMerger.merger(otBase.ValueRecord)
 def merge(merger, self, lst):
     model = merger.model
-    scalars = merger.scalars
+    masterScalars = merger.masterScalars
     # TODO Handle differing valueformats
     for name, tableName in [
         ("XAdvance", "XAdvDevice"),
@@ -1097,7 +1104,9 @@
 
         if hasattr(self, name):
             values = [getattr(a, name, 0) for a in lst]
-            value = otRound(model.interpolateFromMastersAndScalars(values, scalars))
+            value = otRound(
+                model.interpolateFromValuesAndScalars(values, masterScalars)
+            )
             setattr(self, name, value)
 
 
diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py
index 5bd66db..5981531 100644
--- a/Lib/fontTools/varLib/models.py
+++ b/Lib/fontTools/varLib/models.py
@@ -4,6 +4,7 @@
     "normalizeValue",
     "normalizeLocation",
     "supportScalar",
+    "piecewiseLinearMap",
     "VariationModel",
 ]
 
@@ -270,6 +271,12 @@
         self._subModels = {}
 
     def getSubModel(self, items):
+        """Return a sub-model and the items that are not None.
+
+        The sub-model is necessary for working with the subset
+        of items when some are None.
+
+        The sub-model is cached."""
         if None not in items:
             return self, items
         key = tuple(v is not None for v in items)
@@ -464,6 +471,10 @@
         return model.getDeltas(items, round=round), model.supports
 
     def getScalars(self, loc):
+        """Return scalars for each delta, for the given location.
+        If interpolating many master-values at the same location,
+        this function allows speed up by fetching the scalars once
+        and using them with interpolateFromMastersAndScalars()."""
         return [
             supportScalar(
                 loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
@@ -471,29 +482,65 @@
             for support in self.supports
         ]
 
+    def getMasterScalars(self, targetLocation):
+        """Return multipliers for each master, for the given location.
+        If interpolating many master-values at the same location,
+        this function allows speed up by fetching the scalars once
+        and using them with interpolateFromValuesAndScalars().
+
+        Note that the scalars used in interpolateFromMastersAndScalars(),
+        are *not* the same as the ones returned here. They are the result
+        of getScalars()."""
+        out = self.getScalars(targetLocation)
+        for i, weights in reversed(list(enumerate(self.deltaWeights))):
+            for j, weight in weights.items():
+                out[j] -= out[i] * weight
+
+        out = [out[self.mapping[i]] for i in range(len(out))]
+        return out
+
     @staticmethod
-    def interpolateFromDeltasAndScalars(deltas, scalars):
+    def interpolateFromValuesAndScalars(values, scalars):
+        """Interpolate from values and scalars coefficients.
+
+        If the values are master-values, then the scalars should be
+        fetched from getMasterScalars().
+
+        If the values are deltas, then the scalars should be fetched
+        from getScalars(); in which case this is the same as
+        interpolateFromDeltasAndScalars().
+        """
         v = None
-        assert len(deltas) == len(scalars)
-        for delta, scalar in zip(deltas, scalars):
+        assert len(values) == len(scalars)
+        for value, scalar in zip(values, scalars):
             if not scalar:
                 continue
-            contribution = delta * scalar
+            contribution = value * scalar
             if v is None:
                 v = contribution
             else:
                 v += contribution
         return v
 
+    @staticmethod
+    def interpolateFromDeltasAndScalars(deltas, scalars):
+        """Interpolate from deltas and scalars fetched from getScalars()."""
+        return VariationModel.interpolateFromValuesAndScalars(deltas, scalars)
+
     def interpolateFromDeltas(self, loc, deltas):
+        """Interpolate from deltas, at location loc."""
         scalars = self.getScalars(loc)
         return self.interpolateFromDeltasAndScalars(deltas, scalars)
 
     def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
-        deltas = self.getDeltas(masterValues, round=round)
-        return self.interpolateFromDeltas(loc, deltas)
+        """Interpolate from master-values, at location loc."""
+        scalars = self.getMasterScalars(loc)
+        return self.interpolateFromValuesAndScalars(masterValues, scalars)
 
     def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
+        """Interpolate from master-values, and scalars fetched from
+        getScalars(), which is useful when you want to interpolate
+        multiple master-values with the same location."""
         deltas = self.getDeltas(masterValues, round=round)
         return self.interpolateFromDeltasAndScalars(deltas, scalars)
 
diff --git a/Lib/fontTools/varLib/mutator.py b/Lib/fontTools/varLib/mutator.py
index d1d123a..c7c37da 100644
--- a/Lib/fontTools/varLib/mutator.py
+++ b/Lib/fontTools/varLib/mutator.py
@@ -3,6 +3,7 @@
 
 $ fonttools varLib.mutator ./NotoSansArabic-VF.ttf wght=140 wdth=85
 """
+
 from fontTools.misc.fixedTools import floatToFixedToFloat, floatToFixed
 from fontTools.misc.roundTools import otRound
 from fontTools.pens.boundsPen import BoundsPen
@@ -198,9 +199,11 @@
         glyphnames = sorted(
             gvar.variations.keys(),
             key=lambda name: (
-                glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
-                if glyf[name].isComposite() or glyf[name].isVarComposite()
-                else 0,
+                (
+                    glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
+                    if glyf[name].isComposite() or glyf[name].isVarComposite()
+                    else 0
+                ),
                 name,
             ),
         )
@@ -304,9 +307,9 @@
             if applies:
                 assert record.FeatureTableSubstitution.Version == 0x00010000
                 for rec in record.FeatureTableSubstitution.SubstitutionRecord:
-                    table.FeatureList.FeatureRecord[
-                        rec.FeatureIndex
-                    ].Feature = rec.Feature
+                    table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = (
+                        rec.Feature
+                    )
                 break
         del table.FeatureVariations
 
diff --git a/METADATA b/METADATA
index 50e856e..3905eb8 100644
--- a/METADATA
+++ b/METADATA
@@ -1,23 +1,20 @@
 # This project was upgraded with external_updater.
-# Usage: tools/external_updater/updater.sh update fonttools
+# Usage: tools/external_updater/updater.sh update external/fonttools
 # For more info, check https://cs.android.com/android/platform/superproject/+/main:tools/external_updater/README.md
 
 name: "fonttools"
 description: "fontTools is a library for manipulating fonts, written in Python."
 third_party {
-  url {
-    type: HOMEPAGE
-    value: "https://github.com/fonttools/fonttools"
-  }
-  url {
-    type: ARCHIVE
-    value: "https://github.com/fonttools/fonttools/archive/4.44.0.zip"
-  }
-  version: "4.44.0"
   license_type: NOTICE
   last_upgrade_date {
-    year: 2023
-    month: 11
-    day: 10
+    year: 2024
+    month: 4
+    day: 1
+  }
+  homepage: "https://github.com/fonttools/fonttools"
+  identifier {
+    type: "Archive"
+    value: "https://github.com/fonttools/fonttools/archive/4.49.0.zip"
+    version: "4.49.0"
   }
 }
diff --git a/NEWS.rst b/NEWS.rst
index cddd851..0a298ac 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -1,3 +1,109 @@
+4.49.0 (released 2024-02-15)
+----------------------------
+
+- [otlLib] Add API for building ``MATH`` table (#3446)
+
+4.48.1 (released 2024-02-06)
+----------------------------
+
+- Fixed uploading wheels to PyPI, no code changes since v4.48.0.
+
+4.48.0 (released 2024-02-06)
+----------------------------
+
+- [varLib] Do not log when there are no OTL tables to be merged.
+- [setup.py] Do not restrict lxml<5 any more, tests pass just fine with lxml>=5.
+- [feaLib] Remove glyph and class names length restrictions in FEA (#3424).
+- [roundingPens] Added ``transformRoundFunc`` parameter to the rounding pens to allow
+  for custom rounding of the components' transforms (#3426).
+- [feaLib] Keep declaration order of ligature components within a ligature set, instead
+  of sorting by glyph name (#3429).
+- [feaLib] Fixed ordering of alternates in ``aalt`` lookups, following the declaration
+  order of feature references within the ``aalt`` feature block (#3430).
+- [varLib.instancer] Fixed a bug in the instancer's IUP optimization (#3432).
+- [sbix] Support sbix glyphs with new graphicType "flip" (#3433).
+- [svgPathPen] Added ``--glyphs`` option to dump the SVG paths for the named glyphs
+  in the font (0572f78).
+- [designspaceLib] Added "description" attribute to ``<mappings>`` and ``<mapping>``
+  elements, and allow multiple ``<mappings>`` elements to group ``<mapping>`` elements
+  that are logically related (#3435, #3437).
+- [otlLib] Correctly choose the most compact GSUB contextual lookup format (#3439).
+
+4.47.2 (released 2024-01-11)
+----------------------------
+
+Minor release to fix uploading wheels to PyPI.
+
+4.47.1 (released 2024-01-11)
+----------------------------
+
+- [merge] Improve help message and add standard command line options (#3408)
+- [otlLib] Pass ``ttFont`` to ``name.addName`` in ``buildStatTable`` (#3406)
+- [featureVars] Re-use ``FeatureVariationRecord``'s when possible (#3413)
+
+4.47.0 (released 2023-12-18)
+----------------------------
+
+- [varLib.models] New API for VariationModel: ``getMasterScalars`` and
+  ``interpolateFromValuesAndScalars``.
+- [varLib.interpolatable] Various bugfixes and rendering improvements. In particular,
+  add a Summary page in the front, and an Index and Table-of-Contents in the back.
+  Change the page size to Letter.
+- [Docs/designspaceLib] Defined a new ``public.fontInfo`` lib key, not used anywhere yet (#3358).
+
+4.46.0 (released 2023-12-02)
+----------------------------
+
+- [featureVars] Allow to register the same set of substitution rules to multiple features.
+  The ``addFeatureVariations`` function can now take a list of featureTags; similarly, the
+  lib key 'com.github.fonttools.varLib.featureVarsFeatureTag' can now take a
+  comma-separateed string of feature tags (e.g. "salt,ss01") instead of a single tag (#3360).
+- [featureVars] Don't overwrite GSUB FeatureVariations, but append new records to it
+  for features which are not already there. But raise ``VarLibError`` if the feature tag
+  already has feature variations associated with it (#3363).
+- [varLib] Added ``addGSUBFeatureVariations`` function to add GSUB Feature Variations
+  to an existing variable font from rules defined in a DesignSpace document (#3362).
+- [varLib.interpolatable] Various bugfixes and rendering improvements. In particular,
+  a new test for "underweight" glyphs. The new test reports quite a few false-positives
+  though. Please send feedback.
+
+4.45.1 (released 2023-11-23)
+----------------------------
+
+- [varLib.interpolatable] Various bugfixes and improvements, better reporting, reduced
+  false positives.
+- [ttGlyphSet] Added option to not recalculate glyf bounds (#3348).
+
+4.45.0 (released 2023-11-20)
+----------------------------
+
+- [varLib.interpolatable] Vastly improved algorithms. Also available now is ``--pdf``
+  and ``--html`` options to generate a PDF or HTML report of the interpolation issues.
+  The PDF/HTML report showcases the problematic masters, the interpolated broken
+  glyph, as well as the proposed fixed version.
+
+4.44.3 (released 2023-11-15)
+----------------------------
+
+- [subset] Only prune codepage ranges for OS/2.version >= 1, ignore otherwise (#3334).
+- [instancer] Ensure hhea vertical metrics stay in sync with OS/2 ones after instancing
+  MVAR table containing 'hasc', 'hdsc' or 'hlgp' tags (#3297).
+
+4.44.2 (released 2023-11-14)
+----------------------------
+
+- [glyf] Have ``Glyph.recalcBounds`` skip empty components (base glyph with no contours)
+  when computing the bounding box of composite glyphs. This simply restores the existing
+  behavior before some changes were introduced in fonttools 4.44.0 (#3333).
+
+4.44.1 (released 2023-11-14)
+----------------------------
+
+- [feaLib] Ensure variable mark anchors are deep-copied while building since they
+  get modified in-place and later reused (#3330).
+- [OS/2|subset] Added method to ``recalcCodePageRanges`` to OS/2 table class; added
+  ``--prune-codepage-ranges`` to `fonttools subset` command (#3328, #2607).
+
 4.44.0 (released 2023-11-03)
 ----------------------------
 
@@ -245,10 +351,10 @@
 ----------------------------
 
 - [varLib.instancer] Added support for L4 instancing, i.e. moving the default value of
-  an axis while keeping it variable. Thanks Behdad! (#2728, #2861).  
+  an axis while keeping it variable. Thanks Behdad! (#2728, #2861).
   It's now also possible to restrict an axis min/max values beyond the current default
   value, e.g. a font wght has min=100, def=400, max=900 and you want a partial VF that
-  only varies between 500 and 700, you can now do that.  
+  only varies between 500 and 700, you can now do that.
   You can either specify two min/max values (wght=500:700), and the new default will be
   set to either the minimum or maximum, depending on which one is closer to the current
   default (e.g. 500 in this case). Or you can specify three values (e.g. wght=500:600:700)
@@ -256,7 +362,7 @@
 - [otlLib/featureVars] Set a few Count values so one doesn't need to compile the font
   to update them (#2860).
 - [varLib.models] Make extrapolation work for 2-master models as well where one master
-  is at the default location (#2843, #2846).  
+  is at the default location (#2843, #2846).
   Add optional extrapolate=False to normalizeLocation() (#2847, #2849).
 - [varLib.cff] Fixed sub-optimal packing of CFF2 deltas by no longer rounding them to
   integer (#2838).
diff --git a/README.rst b/README.rst
index bcb7f0d..2274fbd 100644
--- a/README.rst
+++ b/README.rst
@@ -44,7 +44,7 @@
     # create new virtual environment called e.g. 'fonttools-venv', or anything you like
     python -m virtualenv fonttools-venv
 
-    # source the `activate` shell script to enter the environment (Un*x); to exit, just type `deactivate`
+    # source the `activate` shell script to enter the environment (Unix-like); to exit, just type `deactivate`
     . fonttools-venv/bin/activate
 
     # to activate the virtual environment in Windows `cmd.exe`, do
@@ -138,6 +138,13 @@
   * `munkres <https://pypi.python.org/pypi/munkres>`__: a pure-Python
     module that implements the Hungarian or Kuhn-Munkres algorithm.
 
+  To plot the results to a PDF or HTML format, you also need to install:
+
+  * `pycairo <https://pypi.org/project/pycairo/>`__: Python bindings for the
+    Cairo graphics library. Note that wheels are currently only available for
+    Windows, for other platforms see pycairo's `installation instructions
+    <https://pycairo.readthedocs.io/en/latest/getting_started.html>`__.
+
   *Extra:* ``interpolatable``
 
 - ``Lib/fontTools/varLib/plot.py``
diff --git a/Tests/designspaceLib/data/test_avar2.designspace b/Tests/designspaceLib/data/test_avar2.designspace
index d54588a..4c28651 100644
--- a/Tests/designspaceLib/data/test_avar2.designspace
+++ b/Tests/designspaceLib/data/test_avar2.designspace
@@ -19,8 +19,8 @@
       <map input="87.5" output="89"/>
       <map input="100" output="100"/>
     </axis>
-    <mappings>
-      <mapping>
+    <mappings description="mappings 1">
+      <mapping description="justify low">
         <input>
 	  <dimension name="Justify" xvalue="-100"/>
 	  <dimension name="Width" xvalue="100"/>
@@ -30,6 +30,17 @@
         </output>
       </mapping>
     </mappings>
+    <mappings description="mappings 2">
+      <mapping description="test mapping">
+        <input>
+	  <dimension name="Justify" xvalue="100"/>
+	  <dimension name="Width" xvalue="70"/>
+        </input>
+        <output>
+	  <dimension name="Width" xvalue="100"/>
+        </output>
+      </mapping>
+    </mappings>
   </axes>
   <variable-fonts>
     <variable-font name="NotoSansArabic_Justify_Width">
diff --git a/Tests/designspaceLib/designspace_test.py b/Tests/designspaceLib/designspace_test.py
index ceddfd1..0b4df13 100644
--- a/Tests/designspaceLib/designspace_test.py
+++ b/Tests/designspaceLib/designspace_test.py
@@ -701,7 +701,7 @@
     doc = DesignSpaceDocument()
     doc.read(testDocPath)
     assert doc.axisMappings
-    assert len(doc.axisMappings) == 1
+    assert len(doc.axisMappings) == 2
     assert doc.axisMappings[0].inputLocation == {"Justify": -100.0, "Width": 100.0}
 
     # This is a bit of a hack, but it's the only way to make sure
@@ -720,6 +720,12 @@
     assert [mapping.outputLocation for mapping in doc.axisMappings] == [
         mapping.outputLocation for mapping in doc2.axisMappings
     ]
+    assert [mapping.description for mapping in doc.axisMappings] == [
+        mapping.description for mapping in doc2.axisMappings
+    ]
+    assert [mapping.groupDescription for mapping in doc.axisMappings] == [
+        mapping.groupDescription for mapping in doc2.axisMappings
+    ]
 
 
 def test_rulesConditions(tmpdir):
diff --git a/Tests/designspaceLib/split_test.py b/Tests/designspaceLib/split_test.py
index 3364133..3c888a4 100644
--- a/Tests/designspaceLib/split_test.py
+++ b/Tests/designspaceLib/split_test.py
@@ -217,13 +217,13 @@
     ds = DesignSpaceDocument()
     ds.read(datadir / "test_avar2.designspace")
     _, subDoc = next(splitInterpolable(ds))
-    assert len(subDoc.axisMappings) == 1
+    assert len(subDoc.axisMappings) == 2
 
     subDocs = list(splitVariableFonts(ds))
     assert len(subDocs) == 5
     for i, (_, subDoc) in enumerate(subDocs):
         # Only the first one should have a mapping, according to the document
         if i == 0:
-            assert len(subDoc.axisMappings) == 1
+            assert len(subDoc.axisMappings) == 2
         else:
             assert len(subDoc.axisMappings) == 0
diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py
index adcb058..875223a 100644
--- a/Tests/feaLib/builder_test.py
+++ b/Tests/feaLib/builder_test.py
@@ -81,6 +81,7 @@
         MultipleLookupsPerGlyph MultipleLookupsPerGlyph2 GSUB_6_formats
         GSUB_5_formats delete_glyph STAT_test STAT_test_elidedFallbackNameID
         variable_scalar_valuerecord variable_scalar_anchor variable_conditionset
+        variable_mark_anchor
     """.split()
 
     VARFONT_AXES = [
@@ -989,6 +990,47 @@
                 f'{name}.fea:{line}:12: Ambiguous "ignore {sub}", there should be least one marked glyph'
             )
 
+    def test_conditionset_multiple_features(self):
+        """Test that using the same `conditionset` for multiple features reuses the
+        `FeatureVariationRecord`."""
+
+        features = """
+            languagesystem DFLT dflt;
+
+            conditionset test {
+                wght 600 1000;
+                wdth 150 200;
+            } test;
+
+            variation ccmp test {
+                sub e by a;
+            } ccmp;
+
+            variation rlig test {
+                sub b by c;
+            } rlig;
+        """
+
+        def make_mock_vf():
+            font = makeTTFont()
+            font["name"] = newTable("name")
+            addFvar(
+                font,
+                [("wght", 0, 0, 1000, "Weight"), ("wdth", 100, 100, 200, "Width")],
+                [],
+            )
+            del font["name"]
+            return font
+
+        font = make_mock_vf()
+        addOpenTypeFeaturesFromString(font, features)
+
+        table = font["GSUB"].table
+        assert table.FeatureVariations.FeatureVariationCount == 1
+
+        fvr = table.FeatureVariations.FeatureVariationRecord[0]
+        assert fvr.FeatureTableSubstitution.SubstitutionCount == 2
+
     def test_condition_set_avar(self):
         """Test that the `avar` table is consulted when normalizing user-space
         values."""
diff --git a/Tests/feaLib/data/GSUB_5_formats.ttx b/Tests/feaLib/data/GSUB_5_formats.ttx
index 80196aa..29d57d5 100644
--- a/Tests/feaLib/data/GSUB_5_formats.ttx
+++ b/Tests/feaLib/data/GSUB_5_formats.ttx
@@ -62,9 +62,11 @@
       <Lookup index="1">
         <LookupType value="5"/>
         <LookupFlag value="0"/>
-        <!-- SubTableCount=1 -->
-        <ContextSubst index="0" Format="2">
-          <Coverage>
+        <!-- SubTableCount=3 -->
+        <ContextSubst index="0" Format="3">
+          <!-- GlyphCount=3 -->
+          <!-- SubstCount=0 -->
+          <Coverage index="0">
             <Glyph value="a"/>
             <Glyph value="b"/>
             <Glyph value="c"/>
@@ -92,91 +94,160 @@
             <Glyph value="y"/>
             <Glyph value="z"/>
           </Coverage>
-          <ClassDef>
-            <ClassDef glyph="A" class="3"/>
-            <ClassDef glyph="B" class="3"/>
-            <ClassDef glyph="C" class="3"/>
-            <ClassDef glyph="D" class="3"/>
-            <ClassDef glyph="E" class="3"/>
-            <ClassDef glyph="F" class="3"/>
-            <ClassDef glyph="G" class="3"/>
-            <ClassDef glyph="H" class="3"/>
-            <ClassDef glyph="I" class="2"/>
-            <ClassDef glyph="J" class="2"/>
-            <ClassDef glyph="K" class="2"/>
-            <ClassDef glyph="L" class="2"/>
-            <ClassDef glyph="M" class="2"/>
-            <ClassDef glyph="N" class="2"/>
-            <ClassDef glyph="O" class="2"/>
-            <ClassDef glyph="P" class="2"/>
-            <ClassDef glyph="Q" class="2"/>
-            <ClassDef glyph="R" class="2"/>
-            <ClassDef glyph="S" class="2"/>
-            <ClassDef glyph="T" class="2"/>
-            <ClassDef glyph="U" class="2"/>
-            <ClassDef glyph="V" class="2"/>
-            <ClassDef glyph="W" class="2"/>
-            <ClassDef glyph="X" class="2"/>
-            <ClassDef glyph="Y" class="2"/>
-            <ClassDef glyph="Z" class="2"/>
-            <ClassDef glyph="a" class="1"/>
-            <ClassDef glyph="b" class="1"/>
-            <ClassDef glyph="c" class="1"/>
-            <ClassDef glyph="d" class="1"/>
-            <ClassDef glyph="e" class="1"/>
-            <ClassDef glyph="f" class="1"/>
-            <ClassDef glyph="g" class="1"/>
-            <ClassDef glyph="h" class="1"/>
-            <ClassDef glyph="i" class="1"/>
-            <ClassDef glyph="j" class="1"/>
-            <ClassDef glyph="k" class="1"/>
-            <ClassDef glyph="l" class="1"/>
-            <ClassDef glyph="m" class="1"/>
-            <ClassDef glyph="n" class="1"/>
-            <ClassDef glyph="o" class="1"/>
-            <ClassDef glyph="p" class="1"/>
-            <ClassDef glyph="q" class="1"/>
-            <ClassDef glyph="r" class="1"/>
-            <ClassDef glyph="s" class="1"/>
-            <ClassDef glyph="t" class="1"/>
-            <ClassDef glyph="u" class="1"/>
-            <ClassDef glyph="v" class="1"/>
-            <ClassDef glyph="w" class="1"/>
-            <ClassDef glyph="x" class="1"/>
-            <ClassDef glyph="y" class="1"/>
-            <ClassDef glyph="z" class="1"/>
-          </ClassDef>
-          <!-- SubClassSetCount=4 -->
-          <SubClassSet index="0">
-            <!-- SubClassRuleCount=0 -->
-          </SubClassSet>
-          <SubClassSet index="1">
-            <!-- SubClassRuleCount=3 -->
-            <SubClassRule index="0">
-              <!-- GlyphCount=3 -->
-              <!-- SubstCount=0 -->
-              <Class index="0" value="3"/>
-              <Class index="1" value="2"/>
-            </SubClassRule>
-            <SubClassRule index="1">
-              <!-- GlyphCount=3 -->
-              <!-- SubstCount=0 -->
-              <Class index="0" value="3"/>
-              <Class index="1" value="2"/>
-            </SubClassRule>
-            <SubClassRule index="2">
-              <!-- GlyphCount=3 -->
-              <!-- SubstCount=0 -->
-              <Class index="0" value="2"/>
-              <Class index="1" value="3"/>
-            </SubClassRule>
-          </SubClassSet>
-          <SubClassSet index="2">
-            <!-- SubClassRuleCount=0 -->
-          </SubClassSet>
-          <SubClassSet index="3">
-            <!-- SubClassRuleCount=0 -->
-          </SubClassSet>
+          <Coverage index="1">
+            <Glyph value="A"/>
+            <Glyph value="B"/>
+            <Glyph value="C"/>
+            <Glyph value="D"/>
+            <Glyph value="E"/>
+            <Glyph value="F"/>
+            <Glyph value="G"/>
+            <Glyph value="H"/>
+          </Coverage>
+          <Coverage index="2">
+            <Glyph value="I"/>
+            <Glyph value="J"/>
+            <Glyph value="K"/>
+            <Glyph value="L"/>
+            <Glyph value="M"/>
+            <Glyph value="N"/>
+            <Glyph value="O"/>
+            <Glyph value="P"/>
+            <Glyph value="Q"/>
+            <Glyph value="R"/>
+            <Glyph value="S"/>
+            <Glyph value="T"/>
+            <Glyph value="U"/>
+            <Glyph value="V"/>
+            <Glyph value="W"/>
+            <Glyph value="X"/>
+            <Glyph value="Y"/>
+            <Glyph value="Z"/>
+          </Coverage>
+        </ContextSubst>
+        <ContextSubst index="1" Format="3">
+          <!-- GlyphCount=3 -->
+          <!-- SubstCount=0 -->
+          <Coverage index="0">
+            <Glyph value="a"/>
+            <Glyph value="b"/>
+            <Glyph value="c"/>
+            <Glyph value="d"/>
+            <Glyph value="e"/>
+            <Glyph value="f"/>
+            <Glyph value="g"/>
+            <Glyph value="h"/>
+            <Glyph value="i"/>
+            <Glyph value="j"/>
+            <Glyph value="k"/>
+            <Glyph value="l"/>
+            <Glyph value="m"/>
+            <Glyph value="n"/>
+            <Glyph value="o"/>
+            <Glyph value="p"/>
+            <Glyph value="q"/>
+            <Glyph value="r"/>
+            <Glyph value="s"/>
+            <Glyph value="t"/>
+            <Glyph value="u"/>
+            <Glyph value="v"/>
+            <Glyph value="w"/>
+            <Glyph value="x"/>
+            <Glyph value="y"/>
+            <Glyph value="z"/>
+          </Coverage>
+          <Coverage index="1">
+            <Glyph value="A"/>
+            <Glyph value="B"/>
+            <Glyph value="C"/>
+            <Glyph value="D"/>
+            <Glyph value="E"/>
+            <Glyph value="F"/>
+            <Glyph value="G"/>
+            <Glyph value="H"/>
+          </Coverage>
+          <Coverage index="2">
+            <Glyph value="I"/>
+            <Glyph value="J"/>
+            <Glyph value="K"/>
+            <Glyph value="L"/>
+            <Glyph value="M"/>
+            <Glyph value="N"/>
+            <Glyph value="O"/>
+            <Glyph value="P"/>
+            <Glyph value="Q"/>
+            <Glyph value="R"/>
+            <Glyph value="S"/>
+            <Glyph value="T"/>
+            <Glyph value="U"/>
+            <Glyph value="V"/>
+            <Glyph value="W"/>
+            <Glyph value="X"/>
+            <Glyph value="Y"/>
+            <Glyph value="Z"/>
+          </Coverage>
+        </ContextSubst>
+        <ContextSubst index="2" Format="3">
+          <!-- GlyphCount=3 -->
+          <!-- SubstCount=0 -->
+          <Coverage index="0">
+            <Glyph value="a"/>
+            <Glyph value="b"/>
+            <Glyph value="c"/>
+            <Glyph value="d"/>
+            <Glyph value="e"/>
+            <Glyph value="f"/>
+            <Glyph value="g"/>
+            <Glyph value="h"/>
+            <Glyph value="i"/>
+            <Glyph value="j"/>
+            <Glyph value="k"/>
+            <Glyph value="l"/>
+            <Glyph value="m"/>
+            <Glyph value="n"/>
+            <Glyph value="o"/>
+            <Glyph value="p"/>
+            <Glyph value="q"/>
+            <Glyph value="r"/>
+            <Glyph value="s"/>
+            <Glyph value="t"/>
+            <Glyph value="u"/>
+            <Glyph value="v"/>
+            <Glyph value="w"/>
+            <Glyph value="x"/>
+            <Glyph value="y"/>
+            <Glyph value="z"/>
+          </Coverage>
+          <Coverage index="1">
+            <Glyph value="I"/>
+            <Glyph value="J"/>
+            <Glyph value="K"/>
+            <Glyph value="L"/>
+            <Glyph value="M"/>
+            <Glyph value="N"/>
+            <Glyph value="O"/>
+            <Glyph value="P"/>
+            <Glyph value="Q"/>
+            <Glyph value="R"/>
+            <Glyph value="S"/>
+            <Glyph value="T"/>
+            <Glyph value="U"/>
+            <Glyph value="V"/>
+            <Glyph value="W"/>
+            <Glyph value="X"/>
+            <Glyph value="Y"/>
+            <Glyph value="Z"/>
+          </Coverage>
+          <Coverage index="2">
+            <Glyph value="A"/>
+            <Glyph value="B"/>
+            <Glyph value="C"/>
+            <Glyph value="D"/>
+            <Glyph value="E"/>
+            <Glyph value="F"/>
+            <Glyph value="G"/>
+            <Glyph value="H"/>
+          </Coverage>
         </ContextSubst>
       </Lookup>
       <Lookup index="2">
diff --git a/Tests/feaLib/data/GSUB_6_formats.ttx b/Tests/feaLib/data/GSUB_6_formats.ttx
index ad2a1c5..45d5f3a 100644
--- a/Tests/feaLib/data/GSUB_6_formats.ttx
+++ b/Tests/feaLib/data/GSUB_6_formats.ttx
@@ -72,9 +72,41 @@
       <Lookup index="1">
         <LookupType value="6"/>
         <LookupFlag value="0"/>
-        <!-- SubTableCount=1 -->
-        <ChainContextSubst index="0" Format="2">
-          <Coverage>
+        <!-- SubTableCount=3 -->
+        <ChainContextSubst index="0" Format="3">
+          <!-- BacktrackGlyphCount=2 -->
+          <BacktrackCoverage index="0">
+            <Glyph value="I"/>
+            <Glyph value="J"/>
+            <Glyph value="K"/>
+            <Glyph value="L"/>
+            <Glyph value="M"/>
+            <Glyph value="N"/>
+            <Glyph value="O"/>
+            <Glyph value="P"/>
+            <Glyph value="Q"/>
+            <Glyph value="R"/>
+            <Glyph value="S"/>
+            <Glyph value="T"/>
+            <Glyph value="U"/>
+            <Glyph value="V"/>
+            <Glyph value="W"/>
+            <Glyph value="X"/>
+            <Glyph value="Y"/>
+            <Glyph value="Z"/>
+          </BacktrackCoverage>
+          <BacktrackCoverage index="1">
+            <Glyph value="A"/>
+            <Glyph value="B"/>
+            <Glyph value="C"/>
+            <Glyph value="D"/>
+            <Glyph value="E"/>
+            <Glyph value="F"/>
+            <Glyph value="G"/>
+            <Glyph value="H"/>
+          </BacktrackCoverage>
+          <!-- InputGlyphCount=3 -->
+          <InputCoverage index="0">
             <Glyph value="a"/>
             <Glyph value="b"/>
             <Glyph value="c"/>
@@ -101,134 +133,227 @@
             <Glyph value="x"/>
             <Glyph value="y"/>
             <Glyph value="z"/>
-          </Coverage>
-          <BacktrackClassDef>
-            <ClassDef glyph="A" class="2"/>
-            <ClassDef glyph="B" class="2"/>
-            <ClassDef glyph="C" class="2"/>
-            <ClassDef glyph="D" class="2"/>
-            <ClassDef glyph="E" class="2"/>
-            <ClassDef glyph="F" class="2"/>
-            <ClassDef glyph="G" class="2"/>
-            <ClassDef glyph="H" class="2"/>
-            <ClassDef glyph="I" class="1"/>
-            <ClassDef glyph="J" class="1"/>
-            <ClassDef glyph="K" class="1"/>
-            <ClassDef glyph="L" class="1"/>
-            <ClassDef glyph="M" class="1"/>
-            <ClassDef glyph="N" class="1"/>
-            <ClassDef glyph="O" class="1"/>
-            <ClassDef glyph="P" class="1"/>
-            <ClassDef glyph="Q" class="1"/>
-            <ClassDef glyph="R" class="1"/>
-            <ClassDef glyph="S" class="1"/>
-            <ClassDef glyph="T" class="1"/>
-            <ClassDef glyph="U" class="1"/>
-            <ClassDef glyph="V" class="1"/>
-            <ClassDef glyph="W" class="1"/>
-            <ClassDef glyph="X" class="1"/>
-            <ClassDef glyph="Y" class="1"/>
-            <ClassDef glyph="Z" class="1"/>
-          </BacktrackClassDef>
-          <InputClassDef>
-            <ClassDef glyph="A" class="3"/>
-            <ClassDef glyph="B" class="3"/>
-            <ClassDef glyph="C" class="3"/>
-            <ClassDef glyph="D" class="3"/>
-            <ClassDef glyph="E" class="3"/>
-            <ClassDef glyph="F" class="3"/>
-            <ClassDef glyph="G" class="3"/>
-            <ClassDef glyph="H" class="3"/>
-            <ClassDef glyph="I" class="2"/>
-            <ClassDef glyph="J" class="2"/>
-            <ClassDef glyph="K" class="2"/>
-            <ClassDef glyph="L" class="2"/>
-            <ClassDef glyph="M" class="2"/>
-            <ClassDef glyph="N" class="2"/>
-            <ClassDef glyph="O" class="2"/>
-            <ClassDef glyph="P" class="2"/>
-            <ClassDef glyph="Q" class="2"/>
-            <ClassDef glyph="R" class="2"/>
-            <ClassDef glyph="S" class="2"/>
-            <ClassDef glyph="T" class="2"/>
-            <ClassDef glyph="U" class="2"/>
-            <ClassDef glyph="V" class="2"/>
-            <ClassDef glyph="W" class="2"/>
-            <ClassDef glyph="X" class="2"/>
-            <ClassDef glyph="Y" class="2"/>
-            <ClassDef glyph="Z" class="2"/>
-            <ClassDef glyph="a" class="1"/>
-            <ClassDef glyph="b" class="1"/>
-            <ClassDef glyph="c" class="1"/>
-            <ClassDef glyph="d" class="1"/>
-            <ClassDef glyph="e" class="1"/>
-            <ClassDef glyph="f" class="1"/>
-            <ClassDef glyph="g" class="1"/>
-            <ClassDef glyph="h" class="1"/>
-            <ClassDef glyph="i" class="1"/>
-            <ClassDef glyph="j" class="1"/>
-            <ClassDef glyph="k" class="1"/>
-            <ClassDef glyph="l" class="1"/>
-            <ClassDef glyph="m" class="1"/>
-            <ClassDef glyph="n" class="1"/>
-            <ClassDef glyph="o" class="1"/>
-            <ClassDef glyph="p" class="1"/>
-            <ClassDef glyph="q" class="1"/>
-            <ClassDef glyph="r" class="1"/>
-            <ClassDef glyph="s" class="1"/>
-            <ClassDef glyph="t" class="1"/>
-            <ClassDef glyph="u" class="1"/>
-            <ClassDef glyph="v" class="1"/>
-            <ClassDef glyph="w" class="1"/>
-            <ClassDef glyph="x" class="1"/>
-            <ClassDef glyph="y" class="1"/>
-            <ClassDef glyph="z" class="1"/>
-          </InputClassDef>
-          <LookAheadClassDef>
-          </LookAheadClassDef>
-          <!-- ChainSubClassSetCount=4 -->
-          <ChainSubClassSet index="0">
-            <!-- ChainSubClassRuleCount=0 -->
-          </ChainSubClassSet>
-          <ChainSubClassSet index="1">
-            <!-- ChainSubClassRuleCount=3 -->
-            <ChainSubClassRule index="0">
-              <!-- BacktrackGlyphCount=2 -->
-              <Backtrack index="0" value="1"/>
-              <Backtrack index="1" value="2"/>
-              <!-- InputGlyphCount=3 -->
-              <Input index="0" value="3"/>
-              <Input index="1" value="2"/>
-              <!-- LookAheadGlyphCount=0 -->
-              <!-- SubstCount=0 -->
-            </ChainSubClassRule>
-            <ChainSubClassRule index="1">
-              <!-- BacktrackGlyphCount=2 -->
-              <Backtrack index="0" value="2"/>
-              <Backtrack index="1" value="1"/>
-              <!-- InputGlyphCount=3 -->
-              <Input index="0" value="3"/>
-              <Input index="1" value="2"/>
-              <!-- LookAheadGlyphCount=0 -->
-              <!-- SubstCount=0 -->
-            </ChainSubClassRule>
-            <ChainSubClassRule index="2">
-              <!-- BacktrackGlyphCount=2 -->
-              <Backtrack index="0" value="1"/>
-              <Backtrack index="1" value="2"/>
-              <!-- InputGlyphCount=3 -->
-              <Input index="0" value="2"/>
-              <Input index="1" value="3"/>
-              <!-- LookAheadGlyphCount=0 -->
-              <!-- SubstCount=0 -->
-            </ChainSubClassRule>
-          </ChainSubClassSet>
-          <ChainSubClassSet index="2">
-            <!-- ChainSubClassRuleCount=0 -->
-          </ChainSubClassSet>
-          <ChainSubClassSet index="3">
-            <!-- ChainSubClassRuleCount=0 -->
-          </ChainSubClassSet>
+          </InputCoverage>
+          <InputCoverage index="1">
+            <Glyph value="A"/>
+            <Glyph value="B"/>
+            <Glyph value="C"/>
+            <Glyph value="D"/>
+            <Glyph value="E"/>
+            <Glyph value="F"/>
+            <Glyph value="G"/>
+            <Glyph value="H"/>
+          </InputCoverage>
+          <InputCoverage index="2">
+            <Glyph value="I"/>
+            <Glyph value="J"/>
+            <Glyph value="K"/>
+            <Glyph value="L"/>
+            <Glyph value="M"/>
+            <Glyph value="N"/>
+            <Glyph value="O"/>
+            <Glyph value="P"/>
+            <Glyph value="Q"/>
+            <Glyph value="R"/>
+            <Glyph value="S"/>
+            <Glyph value="T"/>
+            <Glyph value="U"/>
+            <Glyph value="V"/>
+            <Glyph value="W"/>
+            <Glyph value="X"/>
+            <Glyph value="Y"/>
+            <Glyph value="Z"/>
+          </InputCoverage>
+          <!-- LookAheadGlyphCount=0 -->
+          <!-- SubstCount=0 -->
+        </ChainContextSubst>
+        <ChainContextSubst index="1" Format="3">
+          <!-- BacktrackGlyphCount=2 -->
+          <BacktrackCoverage index="0">
+            <Glyph value="A"/>
+            <Glyph value="B"/>
+            <Glyph value="C"/>
+            <Glyph value="D"/>
+            <Glyph value="E"/>
+            <Glyph value="F"/>
+            <Glyph value="G"/>
+            <Glyph value="H"/>
+          </BacktrackCoverage>
+          <BacktrackCoverage index="1">
+            <Glyph value="I"/>
+            <Glyph value="J"/>
+            <Glyph value="K"/>
+            <Glyph value="L"/>
+            <Glyph value="M"/>
+            <Glyph value="N"/>
+            <Glyph value="O"/>
+            <Glyph value="P"/>
+            <Glyph value="Q"/>
+            <Glyph value="R"/>
+            <Glyph value="S"/>
+            <Glyph value="T"/>
+            <Glyph value="U"/>
+            <Glyph value="V"/>
+            <Glyph value="W"/>
+            <Glyph value="X"/>
+            <Glyph value="Y"/>
+            <Glyph value="Z"/>
+          </BacktrackCoverage>
+          <!-- InputGlyphCount=3 -->
+          <InputCoverage index="0">
+            <Glyph value="a"/>
+            <Glyph value="b"/>
+            <Glyph value="c"/>
+            <Glyph value="d"/>
+            <Glyph value="e"/>
+            <Glyph value="f"/>
+            <Glyph value="g"/>
+            <Glyph value="h"/>
+            <Glyph value="i"/>
+            <Glyph value="j"/>
+            <Glyph value="k"/>
+            <Glyph value="l"/>
+            <Glyph value="m"/>
+            <Glyph value="n"/>
+            <Glyph value="o"/>
+            <Glyph value="p"/>
+            <Glyph value="q"/>
+            <Glyph value="r"/>
+            <Glyph value="s"/>
+            <Glyph value="t"/>
+            <Glyph value="u"/>
+            <Glyph value="v"/>
+            <Glyph value="w"/>
+            <Glyph value="x"/>
+            <Glyph value="y"/>
+            <Glyph value="z"/>
+          </InputCoverage>
+          <InputCoverage index="1">
+            <Glyph value="A"/>
+            <Glyph value="B"/>
+            <Glyph value="C"/>
+            <Glyph value="D"/>
+            <Glyph value="E"/>
+            <Glyph value="F"/>
+            <Glyph value="G"/>
+            <Glyph value="H"/>
+          </InputCoverage>
+          <InputCoverage index="2">
+            <Glyph value="I"/>
+            <Glyph value="J"/>
+            <Glyph value="K"/>
+            <Glyph value="L"/>
+            <Glyph value="M"/>
+            <Glyph value="N"/>
+            <Glyph value="O"/>
+            <Glyph value="P"/>
+            <Glyph value="Q"/>
+            <Glyph value="R"/>
+            <Glyph value="S"/>
+            <Glyph value="T"/>
+            <Glyph value="U"/>
+            <Glyph value="V"/>
+            <Glyph value="W"/>
+            <Glyph value="X"/>
+            <Glyph value="Y"/>
+            <Glyph value="Z"/>
+          </InputCoverage>
+          <!-- LookAheadGlyphCount=0 -->
+          <!-- SubstCount=0 -->
+        </ChainContextSubst>
+        <ChainContextSubst index="2" Format="3">
+          <!-- BacktrackGlyphCount=2 -->
+          <BacktrackCoverage index="0">
+            <Glyph value="I"/>
+            <Glyph value="J"/>
+            <Glyph value="K"/>
+            <Glyph value="L"/>
+            <Glyph value="M"/>
+            <Glyph value="N"/>
+            <Glyph value="O"/>
+            <Glyph value="P"/>
+            <Glyph value="Q"/>
+            <Glyph value="R"/>
+            <Glyph value="S"/>
+            <Glyph value="T"/>
+            <Glyph value="U"/>
+            <Glyph value="V"/>
+            <Glyph value="W"/>
+            <Glyph value="X"/>
+            <Glyph value="Y"/>
+            <Glyph value="Z"/>
+          </BacktrackCoverage>
+          <BacktrackCoverage index="1">
+            <Glyph value="A"/>
+            <Glyph value="B"/>
+            <Glyph value="C"/>
+            <Glyph value="D"/>
+            <Glyph value="E"/>
+            <Glyph value="F"/>
+            <Glyph value="G"/>
+            <Glyph value="H"/>
+          </BacktrackCoverage>
+          <!-- InputGlyphCount=3 -->
+          <InputCoverage index="0">
+            <Glyph value="a"/>
+            <Glyph value="b"/>
+            <Glyph value="c"/>
+            <Glyph value="d"/>
+            <Glyph value="e"/>
+            <Glyph value="f"/>
+            <Glyph value="g"/>
+            <Glyph value="h"/>
+            <Glyph value="i"/>
+            <Glyph value="j"/>
+            <Glyph value="k"/>
+            <Glyph value="l"/>
+            <Glyph value="m"/>
+            <Glyph value="n"/>
+            <Glyph value="o"/>
+            <Glyph value="p"/>
+            <Glyph value="q"/>
+            <Glyph value="r"/>
+            <Glyph value="s"/>
+            <Glyph value="t"/>
+            <Glyph value="u"/>
+            <Glyph value="v"/>
+            <Glyph value="w"/>
+            <Glyph value="x"/>
+            <Glyph value="y"/>
+            <Glyph value="z"/>
+          </InputCoverage>
+          <InputCoverage index="1">
+            <Glyph value="I"/>
+            <Glyph value="J"/>
+            <Glyph value="K"/>
+            <Glyph value="L"/>
+            <Glyph value="M"/>
+            <Glyph value="N"/>
+            <Glyph value="O"/>
+            <Glyph value="P"/>
+            <Glyph value="Q"/>
+            <Glyph value="R"/>
+            <Glyph value="S"/>
+            <Glyph value="T"/>
+            <Glyph value="U"/>
+            <Glyph value="V"/>
+            <Glyph value="W"/>
+            <Glyph value="X"/>
+            <Glyph value="Y"/>
+            <Glyph value="Z"/>
+          </InputCoverage>
+          <InputCoverage index="2">
+            <Glyph value="A"/>
+            <Glyph value="B"/>
+            <Glyph value="C"/>
+            <Glyph value="D"/>
+            <Glyph value="E"/>
+            <Glyph value="F"/>
+            <Glyph value="G"/>
+            <Glyph value="H"/>
+          </InputCoverage>
+          <!-- LookAheadGlyphCount=0 -->
+          <!-- SubstCount=0 -->
         </ChainContextSubst>
       </Lookup>
       <Lookup index="2">
diff --git a/Tests/feaLib/data/spec4h1.ttx b/Tests/feaLib/data/spec4h1.ttx
index 0e42fc5..a399ab2 100644
--- a/Tests/feaLib/data/spec4h1.ttx
+++ b/Tests/feaLib/data/spec4h1.ttx
@@ -147,8 +147,8 @@
         <!-- SubTableCount=1 -->
         <LigatureSubst index="0">
           <LigatureSet glyph="c">
-            <Ligature components="s" glyph="c_s"/>
             <Ligature components="t" glyph="c_t"/>
+            <Ligature components="s" glyph="c_s"/>
           </LigatureSet>
         </LigatureSubst>
       </Lookup>
diff --git a/Tests/feaLib/data/spec5d1.fea b/Tests/feaLib/data/spec5d1.fea
index cf7d76f..0b5acdd 100644
--- a/Tests/feaLib/data/spec5d1.fea
+++ b/Tests/feaLib/data/spec5d1.fea
@@ -11,6 +11,16 @@
 # if glyph classes are detected in <glyph sequence>.  Thus, the above
 # example produces an identical representation in the font as if all
 # the sequences were manually enumerated by the font editor:
+#
+# NOTE(anthrotype): The previous sentence is no longer entirely true, since we
+# now preserve the order in which the ligatures (with same length and first glyph)
+# were specified in the feature file and do not sort them alphabetically
+# by the ligature component names. Therefore, the way this particular example from
+# the FEA spec is written will produce two slightly different representations
+# in the font in which the ligatures are enumerated differently, however the two
+# lookups are functionally equivalent.
+# See: https://github.com/fonttools/fonttools/issues/3428
+# https://github.com/adobe-type-tools/afdko/issues/1727
 feature F2 {
   sub one slash two by onehalf;
   sub one.oldstyle slash two by onehalf;
diff --git a/Tests/feaLib/data/spec5d1.ttx b/Tests/feaLib/data/spec5d1.ttx
index 77dfc93..8763c93 100644
--- a/Tests/feaLib/data/spec5d1.ttx
+++ b/Tests/feaLib/data/spec5d1.ttx
@@ -43,16 +43,16 @@
         <!-- SubTableCount=1 -->
         <LigatureSubst index="0">
           <LigatureSet glyph="one">
-            <Ligature components="fraction,two" glyph="onehalf"/>
-            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
             <Ligature components="slash,two" glyph="onehalf"/>
             <Ligature components="slash,two.oldstyle" glyph="onehalf"/>
+            <Ligature components="fraction,two" glyph="onehalf"/>
+            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
           </LigatureSet>
           <LigatureSet glyph="one.oldstyle">
-            <Ligature components="fraction,two" glyph="onehalf"/>
-            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
             <Ligature components="slash,two" glyph="onehalf"/>
             <Ligature components="slash,two.oldstyle" glyph="onehalf"/>
+            <Ligature components="fraction,two" glyph="onehalf"/>
+            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
           </LigatureSet>
         </LigatureSubst>
       </Lookup>
@@ -62,16 +62,16 @@
         <!-- SubTableCount=1 -->
         <LigatureSubst index="0">
           <LigatureSet glyph="one">
-            <Ligature components="fraction,two" glyph="onehalf"/>
-            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
             <Ligature components="slash,two" glyph="onehalf"/>
+            <Ligature components="fraction,two" glyph="onehalf"/>
             <Ligature components="slash,two.oldstyle" glyph="onehalf"/>
+            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
           </LigatureSet>
           <LigatureSet glyph="one.oldstyle">
-            <Ligature components="fraction,two" glyph="onehalf"/>
-            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
             <Ligature components="slash,two" glyph="onehalf"/>
+            <Ligature components="fraction,two" glyph="onehalf"/>
             <Ligature components="slash,two.oldstyle" glyph="onehalf"/>
+            <Ligature components="fraction,two.oldstyle" glyph="onehalf"/>
           </LigatureSet>
         </LigatureSubst>
       </Lookup>
diff --git a/Tests/feaLib/data/spec5f_ii_3.ttx b/Tests/feaLib/data/spec5f_ii_3.ttx
index c03a81f..6ef871b 100644
--- a/Tests/feaLib/data/spec5f_ii_3.ttx
+++ b/Tests/feaLib/data/spec5f_ii_3.ttx
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<ttFont sfntVersion="true" ttLibVersion="3.0">
+<ttFont>
 
   <GSUB>
     <Version value="0x00010000"/>
@@ -32,115 +32,111 @@
       <Lookup index="0">
         <LookupType value="6"/>
         <LookupFlag value="0"/>
-        <!-- SubTableCount=1 -->
-        <ChainContextSubst index="0" Format="2">
-          <Coverage>
+        <!-- SubTableCount=3 -->
+        <ChainContextSubst index="0" Format="3">
+          <!-- BacktrackGlyphCount=1 -->
+          <BacktrackCoverage index="0">
             <Glyph value="a"/>
-          </Coverage>
-          <BacktrackClassDef>
-            <ClassDef glyph="a" class="1"/>
-            <ClassDef glyph="b" class="1"/>
-            <ClassDef glyph="c" class="1"/>
-            <ClassDef glyph="d" class="1"/>
-            <ClassDef glyph="e" class="1"/>
-            <ClassDef glyph="f" class="1"/>
-            <ClassDef glyph="g" class="1"/>
-            <ClassDef glyph="h" class="1"/>
-            <ClassDef glyph="i" class="1"/>
-            <ClassDef glyph="j" class="1"/>
-            <ClassDef glyph="k" class="1"/>
-            <ClassDef glyph="l" class="1"/>
-            <ClassDef glyph="m" class="1"/>
-            <ClassDef glyph="n" class="1"/>
-            <ClassDef glyph="o" class="1"/>
-            <ClassDef glyph="p" class="1"/>
-            <ClassDef glyph="q" class="1"/>
-            <ClassDef glyph="r" class="1"/>
-            <ClassDef glyph="s" class="1"/>
-            <ClassDef glyph="t" class="1"/>
-            <ClassDef glyph="u" class="1"/>
-            <ClassDef glyph="v" class="1"/>
-            <ClassDef glyph="w" class="1"/>
-            <ClassDef glyph="x" class="1"/>
-            <ClassDef glyph="y" class="1"/>
-            <ClassDef glyph="z" class="1"/>
-          </BacktrackClassDef>
-          <InputClassDef>
-            <ClassDef glyph="a" class="1"/>
-            <ClassDef glyph="d" class="2"/>
-            <ClassDef glyph="n" class="3"/>
-          </InputClassDef>
-          <LookAheadClassDef>
-            <ClassDef glyph="a" class="1"/>
-            <ClassDef glyph="b" class="1"/>
-            <ClassDef glyph="c" class="1"/>
-            <ClassDef glyph="d" class="1"/>
-            <ClassDef glyph="e" class="1"/>
-            <ClassDef glyph="f" class="1"/>
-            <ClassDef glyph="g" class="1"/>
-            <ClassDef glyph="h" class="1"/>
-            <ClassDef glyph="i" class="1"/>
-            <ClassDef glyph="j" class="1"/>
-            <ClassDef glyph="k" class="1"/>
-            <ClassDef glyph="l" class="1"/>
-            <ClassDef glyph="m" class="1"/>
-            <ClassDef glyph="n" class="1"/>
-            <ClassDef glyph="o" class="1"/>
-            <ClassDef glyph="p" class="1"/>
-            <ClassDef glyph="q" class="1"/>
-            <ClassDef glyph="r" class="1"/>
-            <ClassDef glyph="s" class="1"/>
-            <ClassDef glyph="t" class="1"/>
-            <ClassDef glyph="u" class="1"/>
-            <ClassDef glyph="v" class="1"/>
-            <ClassDef glyph="w" class="1"/>
-            <ClassDef glyph="x" class="1"/>
-            <ClassDef glyph="y" class="1"/>
-            <ClassDef glyph="z" class="1"/>
-          </LookAheadClassDef>
-          <!-- ChainSubClassSetCount=4 -->
-          <ChainSubClassSet index="0">
-            <!-- ChainSubClassRuleCount=0 -->
-          </ChainSubClassSet>
-          <ChainSubClassSet index="1">
-            <!-- ChainSubClassRuleCount=3 -->
-            <ChainSubClassRule index="0">
-              <!-- BacktrackGlyphCount=1 -->
-              <Backtrack index="0" value="1"/>
-              <!-- InputGlyphCount=3 -->
-              <Input index="0" value="3"/>
-              <Input index="1" value="2"/>
-              <!-- LookAheadGlyphCount=0 -->
-              <!-- SubstCount=0 -->
-            </ChainSubClassRule>
-            <ChainSubClassRule index="1">
-              <!-- BacktrackGlyphCount=0 -->
-              <!-- InputGlyphCount=3 -->
-              <Input index="0" value="3"/>
-              <Input index="1" value="2"/>
-              <!-- LookAheadGlyphCount=1 -->
-              <LookAhead index="0" value="1"/>
-              <!-- SubstCount=0 -->
-            </ChainSubClassRule>
-            <ChainSubClassRule index="2">
-              <!-- BacktrackGlyphCount=0 -->
-              <!-- InputGlyphCount=3 -->
-              <Input index="0" value="3"/>
-              <Input index="1" value="2"/>
-              <!-- LookAheadGlyphCount=0 -->
-              <!-- SubstCount=1 -->
-              <SubstLookupRecord index="0">
-                <SequenceIndex value="0"/>
-                <LookupListIndex value="1"/>
-              </SubstLookupRecord>
-            </ChainSubClassRule>
-          </ChainSubClassSet>
-          <ChainSubClassSet index="2">
-            <!-- ChainSubClassRuleCount=0 -->
-          </ChainSubClassSet>
-          <ChainSubClassSet index="3">
-            <!-- ChainSubClassRuleCount=0 -->
-          </ChainSubClassSet>
+            <Glyph value="b"/>
+            <Glyph value="c"/>
+            <Glyph value="d"/>
+            <Glyph value="e"/>
+            <Glyph value="f"/>
+            <Glyph value="g"/>
+            <Glyph value="h"/>
+            <Glyph value="i"/>
+            <Glyph value="j"/>
+            <Glyph value="k"/>
+            <Glyph value="l"/>
+            <Glyph value="m"/>
+            <Glyph value="n"/>
+            <Glyph value="o"/>
+            <Glyph value="p"/>
+            <Glyph value="q"/>
+            <Glyph value="r"/>
+            <Glyph value="s"/>
+            <Glyph value="t"/>
+            <Glyph value="u"/>
+            <Glyph value="v"/>
+            <Glyph value="w"/>
+            <Glyph value="x"/>
+            <Glyph value="y"/>
+            <Glyph value="z"/>
+          </BacktrackCoverage>
+          <!-- InputGlyphCount=3 -->
+          <InputCoverage index="0">
+            <Glyph value="a"/>
+          </InputCoverage>
+          <InputCoverage index="1">
+            <Glyph value="n"/>
+          </InputCoverage>
+          <InputCoverage index="2">
+            <Glyph value="d"/>
+          </InputCoverage>
+          <!-- LookAheadGlyphCount=0 -->
+          <!-- SubstCount=0 -->
+        </ChainContextSubst>
+        <ChainContextSubst index="1" Format="3">
+          <!-- BacktrackGlyphCount=0 -->
+          <!-- InputGlyphCount=3 -->
+          <InputCoverage index="0">
+            <Glyph value="a"/>
+          </InputCoverage>
+          <InputCoverage index="1">
+            <Glyph value="n"/>
+          </InputCoverage>
+          <InputCoverage index="2">
+            <Glyph value="d"/>
+          </InputCoverage>
+          <!-- LookAheadGlyphCount=1 -->
+          <LookAheadCoverage index="0">
+            <Glyph value="a"/>
+            <Glyph value="b"/>
+            <Glyph value="c"/>
+            <Glyph value="d"/>
+            <Glyph value="e"/>
+            <Glyph value="f"/>
+            <Glyph value="g"/>
+            <Glyph value="h"/>
+            <Glyph value="i"/>
+            <Glyph value="j"/>
+            <Glyph value="k"/>
+            <Glyph value="l"/>
+            <Glyph value="m"/>
+            <Glyph value="n"/>
+            <Glyph value="o"/>
+            <Glyph value="p"/>
+            <Glyph value="q"/>
+            <Glyph value="r"/>
+            <Glyph value="s"/>
+            <Glyph value="t"/>
+            <Glyph value="u"/>
+            <Glyph value="v"/>
+            <Glyph value="w"/>
+            <Glyph value="x"/>
+            <Glyph value="y"/>
+            <Glyph value="z"/>
+          </LookAheadCoverage>
+          <!-- SubstCount=0 -->
+        </ChainContextSubst>
+        <ChainContextSubst index="2" Format="3">
+          <!-- BacktrackGlyphCount=0 -->
+          <!-- InputGlyphCount=3 -->
+          <InputCoverage index="0">
+            <Glyph value="a"/>
+          </InputCoverage>
+          <InputCoverage index="1">
+            <Glyph value="n"/>
+          </InputCoverage>
+          <InputCoverage index="2">
+            <Glyph value="d"/>
+          </InputCoverage>
+          <!-- LookAheadGlyphCount=0 -->
+          <!-- SubstCount=1 -->
+          <SubstLookupRecord index="0">
+            <SequenceIndex value="0"/>
+            <LookupListIndex value="1"/>
+          </SubstLookupRecord>
         </ChainContextSubst>
       </Lookup>
       <Lookup index="1">
diff --git a/Tests/feaLib/data/spec8a.ttx b/Tests/feaLib/data/spec8a.ttx
index 787ecfa..9c8c758 100644
--- a/Tests/feaLib/data/spec8a.ttx
+++ b/Tests/feaLib/data/spec8a.ttx
@@ -99,18 +99,18 @@
         <!-- SubTableCount=1 -->
         <AlternateSubst index="0">
           <AlternateSet glyph="a">
-            <Alternate glyph="A.sc"/>
             <Alternate glyph="a.alt1"/>
             <Alternate glyph="a.alt2"/>
             <Alternate glyph="a.alt3"/>
+            <Alternate glyph="A.sc"/>
           </AlternateSet>
           <AlternateSet glyph="b">
-            <Alternate glyph="B.sc"/>
             <Alternate glyph="b.alt"/>
+            <Alternate glyph="B.sc"/>
           </AlternateSet>
           <AlternateSet glyph="c">
-            <Alternate glyph="C.sc"/>
             <Alternate glyph="c.mid"/>
+            <Alternate glyph="C.sc"/>
           </AlternateSet>
           <AlternateSet glyph="d">
             <Alternate glyph="d.alt"/>
diff --git a/Tests/feaLib/data/variable_mark_anchor.fea b/Tests/feaLib/data/variable_mark_anchor.fea
new file mode 100644
index 0000000..39ead93
--- /dev/null
+++ b/Tests/feaLib/data/variable_mark_anchor.fea
@@ -0,0 +1,10 @@
+markClass macron <anchor 0 (wght=200:150 wght=900:152)> @MC_top;
+
+lookup one {
+ pos base a
+ <anchor 0 0> mark @MC_top;
+} one;
+lookup two {
+  pos base a
+  <anchor 0 0> mark @MC_top;
+} two;
diff --git a/Tests/feaLib/data/variable_mark_anchor.ttx b/Tests/feaLib/data/variable_mark_anchor.ttx
new file mode 100644
index 0000000..962cff7
--- /dev/null
+++ b/Tests/feaLib/data/variable_mark_anchor.ttx
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ttFont>
+
+  <GDEF>
+    <Version value="0x00010003"/>
+    <GlyphClassDef>
+      <ClassDef glyph="a" class="1"/>
+      <ClassDef glyph="macron" class="3"/>
+    </GlyphClassDef>
+    <VarStore Format="1">
+      <Format value="1"/>
+      <VarRegionList>
+        <!-- RegionAxisCount=2 -->
+        <!-- RegionCount=1 -->
+        <Region index="0">
+          <VarRegionAxis index="0">
+            <StartCoord value="0.0"/>
+            <PeakCoord value="0.875"/>
+            <EndCoord value="0.875"/>
+          </VarRegionAxis>
+          <VarRegionAxis index="1">
+            <StartCoord value="0.0"/>
+            <PeakCoord value="0.0"/>
+            <EndCoord value="0.0"/>
+          </VarRegionAxis>
+        </Region>
+      </VarRegionList>
+      <!-- VarDataCount=1 -->
+      <VarData index="0">
+        <!-- ItemCount=1 -->
+        <NumShorts value="0"/>
+        <!-- VarRegionCount=1 -->
+        <VarRegionIndex index="0" value="0"/>
+        <Item index="0" value="[2]"/>
+      </VarData>
+    </VarStore>
+  </GDEF>
+
+  <GPOS>
+    <Version value="0x00010000"/>
+    <ScriptList>
+      <!-- ScriptCount=0 -->
+    </ScriptList>
+    <FeatureList>
+      <!-- FeatureCount=0 -->
+    </FeatureList>
+    <LookupList>
+      <!-- LookupCount=2 -->
+      <Lookup index="0">
+        <LookupType value="4"/>
+        <LookupFlag value="0"/>
+        <!-- SubTableCount=1 -->
+        <MarkBasePos index="0" Format="1">
+          <MarkCoverage>
+            <Glyph value="macron"/>
+          </MarkCoverage>
+          <BaseCoverage>
+            <Glyph value="a"/>
+          </BaseCoverage>
+          <!-- ClassCount=1 -->
+          <MarkArray>
+            <!-- MarkCount=1 -->
+            <MarkRecord index="0">
+              <Class value="0"/>
+              <MarkAnchor Format="3">
+                <XCoordinate value="0"/>
+                <YCoordinate value="150"/>
+                <YDeviceTable>
+                  <StartSize value="0"/>
+                  <EndSize value="0"/>
+                  <DeltaFormat value="32768"/>
+                </YDeviceTable>
+              </MarkAnchor>
+            </MarkRecord>
+          </MarkArray>
+          <BaseArray>
+            <!-- BaseCount=1 -->
+            <BaseRecord index="0">
+              <BaseAnchor index="0" Format="1">
+                <XCoordinate value="0"/>
+                <YCoordinate value="0"/>
+              </BaseAnchor>
+            </BaseRecord>
+          </BaseArray>
+        </MarkBasePos>
+      </Lookup>
+      <Lookup index="1">
+        <LookupType value="4"/>
+        <LookupFlag value="0"/>
+        <!-- SubTableCount=1 -->
+        <MarkBasePos index="0" Format="1">
+          <MarkCoverage>
+            <Glyph value="macron"/>
+          </MarkCoverage>
+          <BaseCoverage>
+            <Glyph value="a"/>
+          </BaseCoverage>
+          <!-- ClassCount=1 -->
+          <MarkArray>
+            <!-- MarkCount=1 -->
+            <MarkRecord index="0">
+              <Class value="0"/>
+              <MarkAnchor Format="3">
+                <XCoordinate value="0"/>
+                <YCoordinate value="150"/>
+                <YDeviceTable>
+                  <StartSize value="0"/>
+                  <EndSize value="0"/>
+                  <DeltaFormat value="32768"/>
+                </YDeviceTable>
+              </MarkAnchor>
+            </MarkRecord>
+          </MarkArray>
+          <BaseArray>
+            <!-- BaseCount=1 -->
+            <BaseRecord index="0">
+              <BaseAnchor index="0" Format="1">
+                <XCoordinate value="0"/>
+                <YCoordinate value="0"/>
+              </BaseAnchor>
+            </BaseRecord>
+          </BaseArray>
+        </MarkBasePos>
+      </Lookup>
+    </LookupList>
+  </GPOS>
+
+</ttFont>
diff --git a/Tests/feaLib/lexer_test.py b/Tests/feaLib/lexer_test.py
index 317a9a8..21db4e5 100644
--- a/Tests/feaLib/lexer_test.py
+++ b/Tests/feaLib/lexer_test.py
@@ -41,9 +41,7 @@
         self.assertEqual(lex("@Vowel-sc"), [(Lexer.GLYPHCLASS, "Vowel-sc")])
         self.assertRaisesRegex(FeatureLibError, "Expected glyph class", lex, "@(a)")
         self.assertRaisesRegex(FeatureLibError, "Expected glyph class", lex, "@ A")
-        self.assertRaisesRegex(
-            FeatureLibError, "not be longer than 63 characters", lex, "@" + ("A" * 64)
-        )
+        self.assertEqual(lex("@" + ("A" * 600)), [(Lexer.GLYPHCLASS, "A" * 600)])
         self.assertRaisesRegex(
             FeatureLibError, "Glyph class names must consist of", lex, "@Ab:c"
         )
diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py
index c140629..bee00d9 100644
--- a/Tests/feaLib/parser_test.py
+++ b/Tests/feaLib/parser_test.py
@@ -54,6 +54,7 @@
 """
     ).split()
     + ["foo.%d" % i for i in range(1, 200)]
+    + ["G" * 600]
 )
 
 
@@ -327,12 +328,10 @@
         self.assertEqual(gc.glyphSet(), ("endash", "emdash", "figuredash"))
 
     def test_glyphclass_glyphNameTooLong(self):
-        self.assertRaisesRegex(
-            FeatureLibError,
-            "must not be longer than 63 characters",
-            self.parse,
-            "@GlyphClass = [%s];" % ("G" * 64),
-        )
+        gname = "G" * 600
+        [gc] = self.parse(f"@GlyphClass = [{gname}];").statements
+        self.assertEqual(gc.name, "GlyphClass")
+        self.assertEqual(gc.glyphSet(), (gname,))
 
     def test_glyphclass_bad(self):
         self.assertRaisesRegex(
diff --git a/Tests/misc/bezierTools_test.py b/Tests/misc/bezierTools_test.py
index 8a3e2ec..ce8a9e1 100644
--- a/Tests/misc/bezierTools_test.py
+++ b/Tests/misc/bezierTools_test.py
@@ -4,6 +4,7 @@
     calcQuadraticArcLength,
     calcCubicBounds,
     curveLineIntersections,
+    curveCurveIntersections,
     segmentPointAtT,
     splitLine,
     splitQuadratic,
@@ -189,3 +190,10 @@
     assert calcQuadraticArcLength(
         (210, 333), (289, 333), (326.5, 290.5)
     ) == pytest.approx(127.9225)
+
+
+def test_intersections_linelike():
+    seg1 = [(0.0, 0.0), (0.0, 0.25), (0.0, 0.75), (0.0, 1.0)]
+    seg2 = [(0.0, 0.5), (0.25, 0.5), (0.75, 0.5), (1.0, 0.5)]
+    pt = curveCurveIntersections(seg1, seg2)[0][0]
+    assert pt == (0.0, 0.5)
diff --git a/Tests/misc/symfont_test.py b/Tests/misc/symfont_test.py
new file mode 100644
index 0000000..3e2feef
--- /dev/null
+++ b/Tests/misc/symfont_test.py
@@ -0,0 +1,44 @@
+try:
+    from fontTools.misc.symfont import AreaPen
+except ImportError:
+    AreaPen = None
+import unittest
+import pytest
+
+precision = 6
+
+
+def draw1_(pen):
+    pen.moveTo((254, 360))
+    pen.lineTo((771, 367))
+    pen.curveTo((800, 393), (808, 399), (819, 412))
+    pen.curveTo((818, 388), (774, 138), (489, 145))
+    pen.curveTo((188, 145), (200, 398), (200, 421))
+    pen.curveTo((209, 409), (220, 394), (254, 360))
+    pen.closePath()
+
+
+class AreaPenTest(unittest.TestCase):
+    @pytest.mark.skipif(AreaPen is None, reason="sympy not installed")
+    def test_PScontour_clockwise_line_first(self):
+        pen = AreaPen(glyphset=None)
+        draw1_(pen)
+        self.assertEqual(-104561.35, round(pen.value, precision))
+
+    @pytest.mark.skipif(AreaPen is None, reason="sympy not installed")
+    def test_openPaths(self):
+        pen = AreaPen()
+        pen.moveTo((0, 0))
+        pen.endPath()
+        self.assertEqual(0, pen.value)
+
+        pen.moveTo((0, 0))
+        pen.lineTo((1, 0))
+        with self.assertRaises(NotImplementedError):
+            pen.endPath()
+
+
+if __name__ == "__main__":
+    import sys
+
+    sys.exit(unittest.main())
diff --git a/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB b/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB
index 5ad2018..d757511 100644
--- a/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB
+++ b/Tests/mtiLib/data/mti/gsubligature.ttx.GSUB
@@ -16,20 +16,20 @@
           <Ligature components="Jsmall" glyph="IJsmall"/>
         </LigatureSet>
         <LigatureSet glyph="f">
-          <Ligature components="f,b" glyph="ffb"/>
-          <Ligature components="f,h" glyph="ffh"/>
           <Ligature components="f,i" glyph="ffi"/>
-          <Ligature components="f,k" glyph="ffk"/>
           <Ligature components="f,l" glyph="ffl"/>
           <Ligature components="f,t" glyph="fft"/>
-          <Ligature components="b" glyph="fb"/>
-          <Ligature components="f" glyph="ff"/>
-          <Ligature components="h" glyph="fh"/>
+          <Ligature components="f,b" glyph="ffb"/>
+          <Ligature components="f,h" glyph="ffh"/>
+          <Ligature components="f,k" glyph="ffk"/>
           <Ligature components="i" glyph="fi"/>
-          <Ligature components="j" glyph="fj"/>
-          <Ligature components="k" glyph="fk"/>
           <Ligature components="l" glyph="fl"/>
+          <Ligature components="f" glyph="ff"/>
           <Ligature components="t" glyph="ft"/>
+          <Ligature components="b" glyph="fb"/>
+          <Ligature components="h" glyph="fh"/>
+          <Ligature components="k" glyph="fk"/>
+          <Ligature components="j" glyph="fj"/>
         </LigatureSet>
         <LigatureSet glyph="i">
           <Ligature components="j" glyph="ij"/>
diff --git a/Tests/otlLib/builder_test.py b/Tests/otlLib/builder_test.py
index b7a6caa..0d0b213 100644
--- a/Tests/otlLib/builder_test.py
+++ b/Tests/otlLib/builder_test.py
@@ -1051,11 +1051,11 @@
         func = lambda writer, font: value.toXML(writer, font, valueName="Val")
         assert getXML(func) == ['<Val XPlacement="7" YPlacement="23"/>']
 
-    def test_getLigatureKey(self):
+    def test_getLigatureSortKey(self):
         components = lambda s: [tuple(word) for word in s.split()]
         c = components("fi fl ff ffi fff")
-        c.sort(key=builder._getLigatureKey)
-        assert c == components("fff ffi ff fi fl")
+        c.sort(key=otTables.LigatureSubst._getLigatureSortKey)
+        assert c == components("ffi fff fi fl ff")
 
     def test_getSinglePosValueKey(self):
         device = builder.buildDevice({10: 1, 11: 3})
@@ -1549,6 +1549,310 @@
     assert struct.pack(">l", posInf) == b"\x7f\xff\xff\xff"
 
 
+def test_buildMathTable_empty():
+    ttFont = ttLib.TTFont()
+    ttFont.setGlyphOrder([])
+    builder.buildMathTable(ttFont)
+
+    assert "MATH" in ttFont
+    mathTable = ttFont["MATH"].table
+    assert mathTable.Version == 0x00010000
+
+    assert mathTable.MathConstants is None
+    assert mathTable.MathGlyphInfo is None
+    assert mathTable.MathVariants is None
+
+
+def test_buildMathTable_constants():
+    ttFont = ttLib.TTFont()
+    ttFont.setGlyphOrder([])
+    constants = {
+        "AccentBaseHeight": 516,
+        "AxisHeight": 262,
+        "DelimitedSubFormulaMinHeight": 1500,
+        "DisplayOperatorMinHeight": 2339,
+        "FlattenedAccentBaseHeight": 698,
+        "FractionDenomDisplayStyleGapMin": 198,
+        "FractionDenominatorDisplayStyleShiftDown": 698,
+        "FractionDenominatorGapMin": 66,
+        "FractionDenominatorShiftDown": 465,
+        "FractionNumDisplayStyleGapMin": 198,
+        "FractionNumeratorDisplayStyleShiftUp": 774,
+        "FractionNumeratorGapMin": 66,
+        "FractionNumeratorShiftUp": 516,
+        "FractionRuleThickness": 66,
+        "LowerLimitBaselineDropMin": 585,
+        "LowerLimitGapMin": 132,
+        "MathLeading": 300,
+        "OverbarExtraAscender": 66,
+        "OverbarRuleThickness": 66,
+        "OverbarVerticalGap": 198,
+        "RadicalDegreeBottomRaisePercent": 75,
+        "RadicalDisplayStyleVerticalGap": 195,
+        "RadicalExtraAscender": 66,
+        "RadicalKernAfterDegree": -556,
+        "RadicalKernBeforeDegree": 278,
+        "RadicalRuleThickness": 66,
+        "RadicalVerticalGap": 82,
+        "ScriptPercentScaleDown": 70,
+        "ScriptScriptPercentScaleDown": 55,
+        "SkewedFractionHorizontalGap": 66,
+        "SkewedFractionVerticalGap": 77,
+        "SpaceAfterScript": 42,
+        "StackBottomDisplayStyleShiftDown": 698,
+        "StackBottomShiftDown": 465,
+        "StackDisplayStyleGapMin": 462,
+        "StackGapMin": 198,
+        "StackTopDisplayStyleShiftUp": 774,
+        "StackTopShiftUp": 516,
+        "StretchStackBottomShiftDown": 585,
+        "StretchStackGapAboveMin": 132,
+        "StretchStackGapBelowMin": 132,
+        "StretchStackTopShiftUp": 165,
+        "SubSuperscriptGapMin": 264,
+        "SubscriptBaselineDropMin": 105,
+        "SubscriptShiftDown": 140,
+        "SubscriptTopMax": 413,
+        "SuperscriptBaselineDropMax": 221,
+        "SuperscriptBottomMaxWithSubscript": 413,
+        "SuperscriptBottomMin": 129,
+        "SuperscriptShiftUp": 477,
+        "SuperscriptShiftUpCramped": 358,
+        "UnderbarExtraDescender": 66,
+        "UnderbarRuleThickness": 66,
+        "UnderbarVerticalGap": 198,
+        "UpperLimitBaselineRiseMin": 165,
+        "UpperLimitGapMin": 132,
+    }
+    builder.buildMathTable(ttFont, constants=constants)
+    mathTable = ttFont["MATH"].table
+    assert mathTable.MathConstants
+    assert mathTable.MathGlyphInfo is None
+    assert mathTable.MathVariants is None
+    for k, v in constants.items():
+        r = getattr(mathTable.MathConstants, k)
+        try:
+            r = r.Value
+        except AttributeError:
+            pass
+        assert r == v
+
+
+def test_buildMathTable_italicsCorrection():
+    ttFont = ttLib.TTFont()
+    ttFont.setGlyphOrder(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"])
+    italicsCorrections = {"A": 100, "C": 300, "D": 400, "E": 500}
+    builder.buildMathTable(ttFont, italicsCorrections=italicsCorrections)
+    mathTable = ttFont["MATH"].table
+    assert mathTable.MathConstants is None
+    assert mathTable.MathGlyphInfo
+    assert mathTable.MathVariants is None
+    assert set(
+        mathTable.MathGlyphInfo.MathItalicsCorrectionInfo.Coverage.glyphs
+    ) == set(italicsCorrections.keys())
+    for glyph, correction in zip(
+        mathTable.MathGlyphInfo.MathItalicsCorrectionInfo.Coverage.glyphs,
+        mathTable.MathGlyphInfo.MathItalicsCorrectionInfo.ItalicsCorrection,
+    ):
+        assert correction.Value == italicsCorrections[glyph]
+
+
+def test_buildMathTable_topAccentAttachment():
+    ttFont = ttLib.TTFont()
+    ttFont.setGlyphOrder(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"])
+    topAccentAttachments = {"A": 10, "B": 20, "C": 30, "E": 50}
+    builder.buildMathTable(ttFont, topAccentAttachments=topAccentAttachments)
+    mathTable = ttFont["MATH"].table
+    assert mathTable.MathConstants is None
+    assert mathTable.MathGlyphInfo
+    assert mathTable.MathVariants is None
+    assert set(
+        mathTable.MathGlyphInfo.MathTopAccentAttachment.TopAccentCoverage.glyphs
+    ) == set(topAccentAttachments.keys())
+    for glyph, attachment in zip(
+        mathTable.MathGlyphInfo.MathTopAccentAttachment.TopAccentCoverage.glyphs,
+        mathTable.MathGlyphInfo.MathTopAccentAttachment.TopAccentAttachment,
+    ):
+        assert attachment.Value == topAccentAttachments[glyph]
+
+
+def test_buildMathTable_extendedShape():
+    ttFont = ttLib.TTFont()
+    ttFont.setGlyphOrder(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"])
+    extendedShapes = {"A", "C", "E", "F"}
+    builder.buildMathTable(ttFont, extendedShapes=extendedShapes)
+    mathTable = ttFont["MATH"].table
+    assert mathTable.MathConstants is None
+    assert mathTable.MathGlyphInfo
+    assert mathTable.MathVariants is None
+    assert set(mathTable.MathGlyphInfo.ExtendedShapeCoverage.glyphs) == extendedShapes
+
+
+def test_buildMathTable_mathKern():
+    ttFont = ttLib.TTFont()
+    ttFont.setGlyphOrder(["A", "B"])
+    mathKerns = {
+        "A": {
+            "TopRight": ([10, 20], [10, 20, 30]),
+            "BottomRight": ([], [10]),
+            "TopLeft": ([10], [0, 20]),
+            "BottomLeft": ([-10, 0], [0, 10, 20]),
+        },
+    }
+    builder.buildMathTable(ttFont, mathKerns=mathKerns)
+    mathTable = ttFont["MATH"].table
+    assert mathTable.MathConstants is None
+    assert mathTable.MathGlyphInfo
+    assert mathTable.MathVariants is None
+    assert set(mathTable.MathGlyphInfo.MathKernInfo.MathKernCoverage.glyphs) == set(
+        mathKerns.keys()
+    )
+    for glyph, record in zip(
+        mathTable.MathGlyphInfo.MathKernInfo.MathKernCoverage.glyphs,
+        mathTable.MathGlyphInfo.MathKernInfo.MathKernInfoRecords,
+    ):
+        h, k = mathKerns[glyph]["TopRight"]
+        assert [v.Value for v in record.TopRightMathKern.CorrectionHeight] == h
+        assert [v.Value for v in record.TopRightMathKern.KernValue] == k
+        h, k = mathKerns[glyph]["BottomRight"]
+        assert [v.Value for v in record.BottomRightMathKern.CorrectionHeight] == h
+        assert [v.Value for v in record.BottomRightMathKern.KernValue] == k
+        h, k = mathKerns[glyph]["TopLeft"]
+        assert [v.Value for v in record.TopLeftMathKern.CorrectionHeight] == h
+        assert [v.Value for v in record.TopLeftMathKern.KernValue] == k
+        h, k = mathKerns[glyph]["BottomLeft"]
+        assert [v.Value for v in record.BottomLeftMathKern.CorrectionHeight] == h
+        assert [v.Value for v in record.BottomLeftMathKern.KernValue] == k
+
+
+def test_buildMathTable_vertVariants():
+    ttFont = ttLib.TTFont()
+    ttFont.setGlyphOrder(["A", "A.size1", "A.size2"])
+    vertGlyphVariants = {"A": [("A.size1", 100), ("A.size2", 200)]}
+    builder.buildMathTable(ttFont, vertGlyphVariants=vertGlyphVariants)
+    mathTable = ttFont["MATH"].table
+    assert mathTable.MathConstants is None
+    assert mathTable.MathGlyphInfo is None
+    assert mathTable.MathVariants
+    assert set(mathTable.MathVariants.VertGlyphCoverage.glyphs) == set(
+        vertGlyphVariants.keys()
+    )
+    for glyph, construction in zip(
+        mathTable.MathVariants.VertGlyphCoverage.glyphs,
+        mathTable.MathVariants.VertGlyphConstruction,
+    ):
+        assert [
+            (r.VariantGlyph, r.AdvanceMeasurement)
+            for r in construction.MathGlyphVariantRecord
+        ] == vertGlyphVariants[glyph]
+
+
+def test_buildMathTable_horizVariants():
+    ttFont = ttLib.TTFont()
+    ttFont.setGlyphOrder(["A", "A.size1", "A.size2"])
+    horizGlyphVariants = {"A": [("A.size1", 100), ("A.size2", 200)]}
+    builder.buildMathTable(ttFont, horizGlyphVariants=horizGlyphVariants)
+    mathTable = ttFont["MATH"].table
+    assert mathTable.MathConstants is None
+    assert mathTable.MathGlyphInfo is None
+    assert mathTable.MathVariants
+    assert set(mathTable.MathVariants.HorizGlyphCoverage.glyphs) == set(
+        horizGlyphVariants.keys()
+    )
+    for glyph, construction in zip(
+        mathTable.MathVariants.HorizGlyphCoverage.glyphs,
+        mathTable.MathVariants.HorizGlyphConstruction,
+    ):
+        assert [
+            (r.VariantGlyph, r.AdvanceMeasurement)
+            for r in construction.MathGlyphVariantRecord
+        ] == horizGlyphVariants[glyph]
+
+
+def test_buildMathTable_vertAssembly():
+    ttFont = ttLib.TTFont()
+    ttFont.setGlyphOrder(["A", "A.top", "A.middle", "A.bottom", "A.extender"])
+    vertGlyphAssembly = {
+        "A": [
+            [
+                ("A.bottom", 0, 0, 100, 200),
+                ("A.extender", 1, 50, 50, 100),
+                ("A.middle", 0, 100, 100, 200),
+                ("A.extender", 1, 50, 50, 100),
+                ("A.top", 0, 100, 0, 200),
+            ],
+            10,
+        ],
+    }
+    builder.buildMathTable(ttFont, vertGlyphAssembly=vertGlyphAssembly)
+    mathTable = ttFont["MATH"].table
+    assert mathTable.MathConstants is None
+    assert mathTable.MathGlyphInfo is None
+    assert mathTable.MathVariants
+    assert set(mathTable.MathVariants.VertGlyphCoverage.glyphs) == set(
+        vertGlyphAssembly.keys()
+    )
+    for glyph, construction in zip(
+        mathTable.MathVariants.VertGlyphCoverage.glyphs,
+        mathTable.MathVariants.VertGlyphConstruction,
+    ):
+        assert [
+            [
+                (
+                    r.glyph,
+                    r.PartFlags,
+                    r.StartConnectorLength,
+                    r.EndConnectorLength,
+                    r.FullAdvance,
+                )
+                for r in construction.GlyphAssembly.PartRecords
+            ],
+            construction.GlyphAssembly.ItalicsCorrection.Value,
+        ] == vertGlyphAssembly[glyph]
+
+
+def test_buildMathTable_horizAssembly():
+    ttFont = ttLib.TTFont()
+    ttFont.setGlyphOrder(["A", "A.top", "A.middle", "A.bottom", "A.extender"])
+    horizGlyphAssembly = {
+        "A": [
+            [
+                ("A.bottom", 0, 0, 100, 200),
+                ("A.extender", 1, 50, 50, 100),
+                ("A.middle", 0, 100, 100, 200),
+                ("A.extender", 1, 50, 50, 100),
+                ("A.top", 0, 100, 0, 200),
+            ],
+            10,
+        ],
+    }
+    builder.buildMathTable(ttFont, horizGlyphAssembly=horizGlyphAssembly)
+    mathTable = ttFont["MATH"].table
+    assert mathTable.MathConstants is None
+    assert mathTable.MathGlyphInfo is None
+    assert mathTable.MathVariants
+    assert set(mathTable.MathVariants.HorizGlyphCoverage.glyphs) == set(
+        horizGlyphAssembly.keys()
+    )
+    for glyph, construction in zip(
+        mathTable.MathVariants.HorizGlyphCoverage.glyphs,
+        mathTable.MathVariants.HorizGlyphConstruction,
+    ):
+        assert [
+            [
+                (
+                    r.glyph,
+                    r.PartFlags,
+                    r.StartConnectorLength,
+                    r.EndConnectorLength,
+                    r.FullAdvance,
+                )
+                for r in construction.GlyphAssembly.PartRecords
+            ],
+            construction.GlyphAssembly.ItalicsCorrection.Value,
+        ] == horizGlyphAssembly[glyph]
+
+
 class ChainContextualRulesetTest(object):
     def test_makeRulesets(self):
         font = ttLib.TTFont()
diff --git a/Tests/pens/roundingPen_test.py b/Tests/pens/roundingPen_test.py
new file mode 100644
index 0000000..3c1f00e
--- /dev/null
+++ b/Tests/pens/roundingPen_test.py
@@ -0,0 +1,69 @@
+from fontTools.misc.fixedTools import floatToFixedToFloat
+from fontTools.pens.recordingPen import RecordingPen, RecordingPointPen
+from fontTools.pens.roundingPen import RoundingPen, RoundingPointPen
+from functools import partial
+
+
+tt_scale_round = partial(floatToFixedToFloat, precisionBits=14)
+
+
+class RoundingPenTest(object):
+    def test_general(self):
+        recpen = RecordingPen()
+        roundpen = RoundingPen(recpen)
+        roundpen.moveTo((0.4, 0.6))
+        roundpen.lineTo((1.6, 2.5))
+        roundpen.qCurveTo((2.4, 4.6), (3.3, 5.7), (4.9, 6.1))
+        roundpen.curveTo((6.4, 8.6), (7.3, 9.7), (8.9, 10.1))
+        roundpen.addComponent("a", (1.5, 0, 0, 1.5, 10.5, -10.5))
+        assert recpen.value == [
+            ("moveTo", ((0, 1),)),
+            ("lineTo", ((2, 3),)),
+            ("qCurveTo", ((2, 5), (3, 6), (5, 6))),
+            ("curveTo", ((6, 9), (7, 10), (9, 10))),
+            ("addComponent", ("a", (1.5, 0, 0, 1.5, 11, -10))),
+        ]
+
+    def test_transform_round(self):
+        recpen = RecordingPen()
+        roundpen = RoundingPen(recpen, transformRoundFunc=tt_scale_round)
+        # The 0.913 is equal to 91.3% scale in a source editor
+        roundpen.addComponent("a", (0.9130000305, 0, 0, -1, 10.5, -10.5))
+        # The value should compare equal to its F2Dot14 representation
+        assert recpen.value == [
+            ("addComponent", ("a", (0.91302490234375, 0, 0, -1, 11, -10))),
+        ]
+
+
+class RoundingPointPenTest(object):
+    def test_general(self):
+        recpen = RecordingPointPen()
+        roundpen = RoundingPointPen(recpen)
+        roundpen.beginPath()
+        roundpen.addPoint((0.4, 0.6), "line")
+        roundpen.addPoint((1.6, 2.5), "line")
+        roundpen.addPoint((2.4, 4.6))
+        roundpen.addPoint((3.3, 5.7))
+        roundpen.addPoint((4.9, 6.1), "qcurve")
+        roundpen.endPath()
+        roundpen.addComponent("a", (1.5, 0, 0, 1.5, 10.5, -10.5))
+        assert recpen.value == [
+            ("beginPath", (), {}),
+            ("addPoint", ((0, 1), "line", False, None), {}),
+            ("addPoint", ((2, 3), "line", False, None), {}),
+            ("addPoint", ((2, 5), None, False, None), {}),
+            ("addPoint", ((3, 6), None, False, None), {}),
+            ("addPoint", ((5, 6), "qcurve", False, None), {}),
+            ("endPath", (), {}),
+            ("addComponent", ("a", (1.5, 0, 0, 1.5, 11, -10)), {}),
+        ]
+
+    def test_transform_round(self):
+        recpen = RecordingPointPen()
+        roundpen = RoundingPointPen(recpen, transformRoundFunc=tt_scale_round)
+        # The 0.913 is equal to 91.3% scale in a source editor
+        roundpen.addComponent("a", (0.913, 0, 0, -1, 10.5, -10.5))
+        # The value should compare equal to its F2Dot14 representation
+        assert recpen.value == [
+            ("addComponent", ("a", (0.91302490234375, 0, 0, -1, 11, -10)), {}),
+        ]
diff --git a/Tests/subset/data/NotoSansCJKjp-Regular.subset.ttx b/Tests/subset/data/NotoSansCJKjp-Regular.subset.ttx
index 4dfc0b2..2f9501d 100644
--- a/Tests/subset/data/NotoSansCJKjp-Regular.subset.ttx
+++ b/Tests/subset/data/NotoSansCJKjp-Regular.subset.ttx
@@ -98,7 +98,7 @@
     <sTypoLineGap value="0"/>
     <usWinAscent value="1160"/>
     <usWinDescent value="288"/>
-    <ulCodePageRange1 value="01100000 00101110 00000001 00000111"/>
+    <ulCodePageRange1 value="00000000 00000000 00000000 00000001"/>
     <ulCodePageRange2 value="00000000 00000000 00000000 00000000"/>
     <sxHeight value="543"/>
     <sCapHeight value="733"/>
diff --git a/Tests/subset/data/TestContextSubstFormat3.ttx b/Tests/subset/data/TestContextSubstFormat3.ttx
index 0ed43ee..c24ac16 100644
--- a/Tests/subset/data/TestContextSubstFormat3.ttx
+++ b/Tests/subset/data/TestContextSubstFormat3.ttx
@@ -117,8 +117,8 @@
     <sTypoLineGap value="0"/>
     <usWinAscent value="977"/>
     <usWinDescent value="272"/>
-    <ulCodePageRange1 value="00100000 00000000 00000001 00011111"/>
-    <ulCodePageRange2 value="11000100 00000000 00000000 00000000"/>
+    <ulCodePageRange1 value="00000000 00000000 00000000 00000001"/>
+    <ulCodePageRange2 value="00000000 00000000 00000000 00000000"/>
     <sxHeight value="530"/>
     <sCapHeight value="735"/>
     <usDefaultChar value="0"/>
diff --git a/Tests/svgLib/path/path_test.py b/Tests/svgLib/path/path_test.py
index 0b82193..c92ca68 100644
--- a/Tests/svgLib/path/path_test.py
+++ b/Tests/svgLib/path/path_test.py
@@ -7,7 +7,7 @@
 
 
 SVG_DATA = """\
-<?xml version="1.0" standalone="no"?>
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
  "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
 <svg version="1.0" xmlns="http://www.w3.org/2000/svg"
@@ -15,7 +15,9 @@
 <path d="M 100 100 L 300 100 L 200 300 z"/>
 <path d="M100,200 C100,100 250,100 250,200 S400,300 400,200"/>
 </svg>
-"""
+""".encode(
+    "utf-8"
+)
 
 EXPECTED_PEN_COMMANDS = [
     ("moveTo", ((100.0, 100.0),)),
diff --git a/Tests/ttLib/tables/O_S_2f_2_test.py b/Tests/ttLib/tables/O_S_2f_2_test.py
index 9567b9e..7138856 100644
--- a/Tests/ttLib/tables/O_S_2f_2_test.py
+++ b/Tests/ttLib/tables/O_S_2f_2_test.py
@@ -4,6 +4,19 @@
 
 
 class OS2TableTest(unittest.TestCase):
+    @staticmethod
+    def makeOS2_cmap(mapping):
+        font = TTFont()
+        font["OS/2"] = os2 = newTable("OS/2")
+        os2.version = 4
+        font["cmap"] = cmap = newTable("cmap")
+        st = getTableModule("cmap").CmapSubtable.newSubtable(4)
+        st.platformID, st.platEncID, st.language = 3, 1, 0
+        st.cmap = mapping
+        cmap.tables = []
+        cmap.tables.append(st)
+        return font, os2, cmap
+
     def test_getUnicodeRanges(self):
         table = table_O_S_2f_2()
         table.ulUnicodeRange1 = 0xFFFFFFFF
@@ -27,14 +40,9 @@
             table.setUnicodeRanges([-1, 127, 255])
 
     def test_recalcUnicodeRanges(self):
-        font = TTFont()
-        font["OS/2"] = os2 = newTable("OS/2")
-        font["cmap"] = cmap = newTable("cmap")
-        st = getTableModule("cmap").CmapSubtable.newSubtable(4)
-        st.platformID, st.platEncID, st.language = 3, 1, 0
-        st.cmap = {0x0041: "A", 0x03B1: "alpha", 0x0410: "Acyr"}
-        cmap.tables = []
-        cmap.tables.append(st)
+        font, os2, cmap = self.makeOS2_cmap(
+            {0x0041: "A", 0x03B1: "alpha", 0x0410: "Acyr"}
+        )
         os2.setUnicodeRanges({0, 1, 9})
         # 'pruneOnly' will clear any bits for which there's no intersection:
         # bit 1 ('Latin 1 Supplement'), in this case. However, it won't set
@@ -43,7 +51,7 @@
         # try again with pruneOnly=False: bit 7 is now set.
         self.assertEqual(os2.recalcUnicodeRanges(font), {0, 7, 9})
         # add a non-BMP char from 'Mahjong Tiles' block (bit 122)
-        st.cmap[0x1F000] = "eastwindtile"
+        cmap.tables[0].cmap[0x1F000] = "eastwindtile"
         # the bit 122 and the special bit 57 ('Non Plane 0') are also enabled
         self.assertEqual(os2.recalcUnicodeRanges(font), {0, 7, 9, 57, 122})
 
@@ -55,6 +63,65 @@
             (set(range(123)) - {9, 57, 122}),
         )
 
+    def test_getCodePageRanges(self):
+        table = table_O_S_2f_2()
+        # version 0 doesn't define these fields so by definition defines no cp ranges
+        table.version = 0
+        self.assertEqual(table.getCodePageRanges(), set())
+        # version 1 and above do contain ulCodePageRange1 and 2 fields
+        table.version = 1
+        table.ulCodePageRange1 = 0xFFFFFFFF
+        table.ulCodePageRange2 = 0xFFFFFFFF
+        bits = table.getCodePageRanges()
+        for i in range(63):
+            self.assertIn(i, bits)
+
+    def test_setCodePageRanges(self):
+        table = table_O_S_2f_2()
+        table.version = 4
+        table.ulCodePageRange1 = 0
+        table.ulCodePageRange2 = 0
+        bits = set(range(64))
+        table.setCodePageRanges(bits)
+        self.assertEqual(table.getCodePageRanges(), bits)
+        with self.assertRaises(ValueError):
+            table.setCodePageRanges([-1])
+        with self.assertRaises(ValueError):
+            table.setCodePageRanges([64])
+        with self.assertRaises(ValueError):
+            table.setCodePageRanges([255])
+
+    def test_setCodePageRanges_bump_version(self):
+        # Setting codepage ranges on a OS/2 table version 0 automatically makes it
+        # a version 1 table
+        table = table_O_S_2f_2()
+        table.version = 0
+        self.assertEqual(table.getCodePageRanges(), set())
+        table.setCodePageRanges({0, 1, 2})
+        self.assertEqual(table.getCodePageRanges(), {0, 1, 2})
+        self.assertEqual(table.version, 1)
+
+    def test_recalcCodePageRanges(self):
+        font, os2, cmap = self.makeOS2_cmap(
+            {ord("A"): "A", ord("Ά"): "Alphatonos", ord("Б"): "Be"}
+        )
+        os2.setCodePageRanges({0, 2, 9})
+
+        # With pruneOnly=True, should clear any CodePage for which there are no
+        # characters in the cmap.
+        self.assertEqual(os2.recalcCodePageRanges(font, pruneOnly=True), {2})
+
+        # With pruneOnly=False, should also set CodePages not initially set.
+        self.assertEqual(os2.recalcCodePageRanges(font), {2, 3})
+
+        # Add a Korean character, should set CodePage 21 (Korean Johab)
+        cmap.tables[0].cmap[ord("곴")] = "goss"
+        self.assertEqual(os2.recalcCodePageRanges(font), {2, 3, 21})
+
+        # Remove all characters from cmap, should still set CodePage 0 (Latin 1)
+        cmap.tables[0].cmap = {}
+        self.assertEqual(os2.recalcCodePageRanges(font), {0})
+
 
 if __name__ == "__main__":
     import sys
diff --git a/Tests/ttLib/tables/_g_l_y_f_test.py b/Tests/ttLib/tables/_g_l_y_f_test.py
index ce2e0e5..39f48b2 100644
--- a/Tests/ttLib/tables/_g_l_y_f_test.py
+++ b/Tests/ttLib/tables/_g_l_y_f_test.py
@@ -562,6 +562,23 @@
         assert glyphSet["percent"].getCompositeMaxpValues(glyphSet)[2] == 2
         assert glyphSet["perthousand"].getCompositeMaxpValues(glyphSet)[2] == 2
 
+    def test_recalcBounds_empty_components(self):
+        glyphSet = {}
+        pen = TTGlyphPen(glyphSet)
+        # empty simple glyph
+        foo = glyphSet["foo"] = pen.glyph()
+        # use the empty 'foo' glyph as a component in 'bar' with some x/y offsets
+        pen.addComponent("foo", (1, 0, 0, 1, -80, 50))
+        bar = glyphSet["bar"] = pen.glyph()
+
+        foo.recalcBounds(glyphSet)
+        bar.recalcBounds(glyphSet)
+
+        # we expect both the empty simple glyph and the composite referencing it
+        # to have empty bounding boxes (0, 0, 0, 0) no matter the component's shift
+        assert (foo.xMin, foo.yMin, foo.xMax, foo.yMax) == (0, 0, 0, 0)
+        assert (bar.xMin, bar.yMin, bar.xMax, bar.yMax) == (0, 0, 0, 0)
+
 
 class GlyphComponentTest:
     def test_toXML_no_transform(self):
diff --git a/Tests/ttLib/ttGlyphSet_test.py b/Tests/ttLib/ttGlyphSet_test.py
index 5651446..177b8a4 100644
--- a/Tests/ttLib/ttGlyphSet_test.py
+++ b/Tests/ttLib/ttGlyphSet_test.py
@@ -1,5 +1,6 @@
 from fontTools.ttLib import TTFont
 from fontTools.ttLib import ttGlyphSet
+from fontTools.ttLib.ttGlyphSet import LerpGlyphSet
 from fontTools.pens.recordingPen import (
     RecordingPen,
     RecordingPointPen,
@@ -164,6 +165,53 @@
 
         assert actual == expected, (location, actual, expected)
 
+    @pytest.mark.parametrize(
+        "fontfile, locations, factor, expected",
+        [
+            (
+                "I.ttf",
+                ({"wght": 400}, {"wght": 1000}),
+                0.5,
+                [
+                    ("moveTo", ((151.5, 0.0),)),
+                    ("lineTo", ((458.5, 0.0),)),
+                    ("lineTo", ((458.5, 1456.0),)),
+                    ("lineTo", ((151.5, 1456.0),)),
+                    ("closePath", ()),
+                ],
+            ),
+            (
+                "I.ttf",
+                ({"wght": 400}, {"wght": 1000}),
+                0.25,
+                [
+                    ("moveTo", ((163.25, 0.0),)),
+                    ("lineTo", ((412.75, 0.0),)),
+                    ("lineTo", ((412.75, 1456.0),)),
+                    ("lineTo", ((163.25, 1456.0),)),
+                    ("closePath", ()),
+                ],
+            ),
+        ],
+    )
+    def test_lerp_glyphset(self, fontfile, locations, factor, expected):
+        font = TTFont(self.getpath(fontfile))
+        glyphset1 = font.getGlyphSet(location=locations[0])
+        glyphset2 = font.getGlyphSet(location=locations[1])
+        glyphset = LerpGlyphSet(glyphset1, glyphset2, factor)
+
+        assert "I" in glyphset
+
+        pen = RecordingPen()
+        glyph = glyphset["I"]
+
+        assert glyphset.get("foobar") is None
+
+        glyph.draw(pen)
+        actual = pen.value
+
+        assert actual == expected, (locations, actual, expected)
+
     def test_glyphset_varComposite_components(self):
         font = TTFont(self.getpath("varc-ac00-ac01.ttf"))
         glyphset = font.getGlyphSet()
diff --git a/Tests/varLib/data/FeatureVarsCustomTag.designspace b/Tests/varLib/data/FeatureVarsCustomTag.designspace
index 45b06f3..ef24ccf 100644
--- a/Tests/varLib/data/FeatureVarsCustomTag.designspace
+++ b/Tests/varLib/data/FeatureVarsCustomTag.designspace
@@ -71,7 +71,7 @@
     <lib>
         <dict>
             <key>com.github.fonttools.varLib.featureVarsFeatureTag</key>
-            <string>calt</string>
+            <string>rclt,calt</string>
         </dict>
     </lib>
 </designspace>
diff --git a/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx b/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx
index 3f9e1e0..5ad62a9 100644
--- a/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx
+++ b/Tests/varLib/data/test_results/FeatureVarsCustomTag.ttx
@@ -33,21 +33,28 @@
         <Script>
           <DefaultLangSys>
             <ReqFeatureIndex value="65535"/>
-            <!-- FeatureCount=1 -->
+            <!-- FeatureCount=2 -->
             <FeatureIndex index="0" value="0"/>
+            <FeatureIndex index="1" value="1"/>
           </DefaultLangSys>
           <!-- LangSysCount=0 -->
         </Script>
       </ScriptRecord>
     </ScriptList>
     <FeatureList>
-      <!-- FeatureCount=1 -->
+      <!-- FeatureCount=2 -->
       <FeatureRecord index="0">
         <FeatureTag value="calt"/>
         <Feature>
           <!-- LookupCount=0 -->
         </Feature>
       </FeatureRecord>
+      <FeatureRecord index="1">
+        <FeatureTag value="rclt"/>
+        <Feature>
+          <!-- LookupCount=0 -->
+        </Feature>
+      </FeatureRecord>
     </FeatureList>
     <LookupList>
       <!-- LookupCount=3 -->
@@ -95,7 +102,7 @@
         </ConditionSet>
         <FeatureTableSubstitution>
           <Version value="0x00010000"/>
-          <!-- SubstitutionCount=1 -->
+          <!-- SubstitutionCount=2 -->
           <SubstitutionRecord index="0">
             <FeatureIndex value="0"/>
             <Feature>
@@ -104,6 +111,14 @@
               <LookupListIndex index="1" value="1"/>
             </Feature>
           </SubstitutionRecord>
+          <SubstitutionRecord index="1">
+            <FeatureIndex value="1"/>
+            <Feature>
+              <!-- LookupCount=2 -->
+              <LookupListIndex index="0" value="0"/>
+              <LookupListIndex index="1" value="1"/>
+            </Feature>
+          </SubstitutionRecord>
         </FeatureTableSubstitution>
       </FeatureVariationRecord>
       <FeatureVariationRecord index="1">
@@ -122,7 +137,7 @@
         </ConditionSet>
         <FeatureTableSubstitution>
           <Version value="0x00010000"/>
-          <!-- SubstitutionCount=1 -->
+          <!-- SubstitutionCount=2 -->
           <SubstitutionRecord index="0">
             <FeatureIndex value="0"/>
             <Feature>
@@ -130,6 +145,13 @@
               <LookupListIndex index="0" value="2"/>
             </Feature>
           </SubstitutionRecord>
+          <SubstitutionRecord index="1">
+            <FeatureIndex value="1"/>
+            <Feature>
+              <!-- LookupCount=1 -->
+              <LookupListIndex index="0" value="2"/>
+            </Feature>
+          </SubstitutionRecord>
         </FeatureTableSubstitution>
       </FeatureVariationRecord>
       <FeatureVariationRecord index="2">
@@ -143,7 +165,7 @@
         </ConditionSet>
         <FeatureTableSubstitution>
           <Version value="0x00010000"/>
-          <!-- SubstitutionCount=1 -->
+          <!-- SubstitutionCount=2 -->
           <SubstitutionRecord index="0">
             <FeatureIndex value="0"/>
             <Feature>
@@ -151,6 +173,13 @@
               <LookupListIndex index="0" value="1"/>
             </Feature>
           </SubstitutionRecord>
+          <SubstitutionRecord index="1">
+            <FeatureIndex value="1"/>
+            <Feature>
+              <!-- LookupCount=1 -->
+              <LookupListIndex index="0" value="1"/>
+            </Feature>
+          </SubstitutionRecord>
         </FeatureTableSubstitution>
       </FeatureVariationRecord>
       <FeatureVariationRecord index="3">
@@ -164,7 +193,7 @@
         </ConditionSet>
         <FeatureTableSubstitution>
           <Version value="0x00010000"/>
-          <!-- SubstitutionCount=1 -->
+          <!-- SubstitutionCount=2 -->
           <SubstitutionRecord index="0">
             <FeatureIndex value="0"/>
             <Feature>
@@ -172,6 +201,13 @@
               <LookupListIndex index="0" value="0"/>
             </Feature>
           </SubstitutionRecord>
+          <SubstitutionRecord index="1">
+            <FeatureIndex value="1"/>
+            <Feature>
+              <!-- LookupCount=1 -->
+              <LookupListIndex index="0" value="0"/>
+            </Feature>
+          </SubstitutionRecord>
         </FeatureTableSubstitution>
       </FeatureVariationRecord>
     </FeatureVariations>
diff --git a/Tests/varLib/featureVars_test.py b/Tests/varLib/featureVars_test.py
index 7a3a665..ef32ee0 100644
--- a/Tests/varLib/featureVars_test.py
+++ b/Tests/varLib/featureVars_test.py
@@ -1,4 +1,168 @@
-from fontTools.varLib.featureVars import overlayFeatureVariations, overlayBox
+from collections import OrderedDict
+from fontTools.designspaceLib import AxisDescriptor
+from fontTools.ttLib import TTFont, newTable
+from fontTools import varLib
+from fontTools.varLib.featureVars import (
+    addFeatureVariations,
+    overlayFeatureVariations,
+    overlayBox,
+)
+import pytest
+
+
+def makeVariableFont(glyphOrder, axes):
+    font = TTFont()
+    font.setGlyphOrder(glyphOrder)
+    font["name"] = newTable("name")
+    ds_axes = OrderedDict()
+    for axisTag, (minimum, default, maximum) in axes.items():
+        axis = AxisDescriptor()
+        axis.name = axis.tag = axis.labelNames["en"] = axisTag
+        axis.minimum, axis.default, axis.maximum = minimum, default, maximum
+        ds_axes[axisTag] = axis
+    varLib._add_fvar(font, ds_axes, instances=())
+    return font
+
+
+@pytest.fixture
+def varfont():
+    return makeVariableFont(
+        [".notdef", "space", "A", "B", "A.alt", "B.alt"],
+        {"wght": (100, 400, 900)},
+    )
+
+
+def test_addFeatureVariations(varfont):
+    assert "GSUB" not in varfont
+
+    addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})])
+
+    assert "GSUB" in varfont
+    gsub = varfont["GSUB"].table
+
+    assert len(gsub.ScriptList.ScriptRecord) == 1
+    assert gsub.ScriptList.ScriptRecord[0].ScriptTag == "DFLT"
+
+    assert len(gsub.FeatureList.FeatureRecord) == 1
+    assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rvrn"
+
+    assert len(gsub.LookupList.Lookup) == 1
+    assert gsub.LookupList.Lookup[0].LookupType == 1
+    assert len(gsub.LookupList.Lookup[0].SubTable) == 1
+    assert gsub.LookupList.Lookup[0].SubTable[0].mapping == {"A": "A.alt"}
+
+    assert gsub.FeatureVariations is not None
+    assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1
+    fvr = gsub.FeatureVariations.FeatureVariationRecord[0]
+    assert len(fvr.ConditionSet.ConditionTable) == 1
+    cst = fvr.ConditionSet.ConditionTable[0]
+    assert cst.AxisIndex == 0
+    assert cst.FilterRangeMinValue == 0.5
+    assert cst.FilterRangeMaxValue == 1.0
+    assert len(fvr.FeatureTableSubstitution.SubstitutionRecord) == 1
+    ftsr = fvr.FeatureTableSubstitution.SubstitutionRecord[0]
+    assert ftsr.FeatureIndex == 0
+    assert ftsr.Feature.LookupListIndex == [0]
+
+
+def _substitution_features(gsub, rec_index):
+    fea_tags = [feature.FeatureTag for feature in gsub.FeatureList.FeatureRecord]
+    fea_indices = [
+        gsub.FeatureVariations.FeatureVariationRecord[rec_index]
+        .FeatureTableSubstitution.SubstitutionRecord[i]
+        .FeatureIndex
+        for i in range(
+            len(
+                gsub.FeatureVariations.FeatureVariationRecord[
+                    rec_index
+                ].FeatureTableSubstitution.SubstitutionRecord
+            )
+        )
+    ]
+    return [(i, fea_tags[i]) for i in fea_indices]
+
+
+def test_addFeatureVariations_existing_variable_feature(varfont):
+    assert "GSUB" not in varfont
+
+    addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})])
+
+    gsub = varfont["GSUB"].table
+    assert len(gsub.FeatureList.FeatureRecord) == 1
+    assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rvrn"
+    assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1
+    assert _substitution_features(gsub, rec_index=0) == [(0, "rvrn")]
+
+    # can't add feature variations for an existing feature tag that already has some,
+    # in this case the default 'rvrn'
+    with pytest.raises(
+        varLib.VarLibError,
+        match=r"FeatureVariations already exist for feature tag\(s\): {'rvrn'}",
+    ):
+        addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})])
+
+
+def test_addFeatureVariations_new_feature(varfont):
+    assert "GSUB" not in varfont
+
+    addFeatureVariations(varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})])
+
+    gsub = varfont["GSUB"].table
+    assert len(gsub.FeatureList.FeatureRecord) == 1
+    assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rvrn"
+    assert len(gsub.LookupList.Lookup) == 1
+    assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1
+    assert _substitution_features(gsub, rec_index=0) == [(0, "rvrn")]
+
+    # we can add feature variations for a feature tag that does not have
+    # any feature variations yet
+    addFeatureVariations(
+        varfont, [([{"wght": (-1.0, 0.0)}], {"B": "B.alt"})], featureTag="rclt"
+    )
+
+    assert len(gsub.FeatureList.FeatureRecord) == 2
+    # Note 'rclt' is now first (index=0) in the feature list sorted by tag, and
+    # 'rvrn' is second (index=1)
+    assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "rclt"
+    assert gsub.FeatureList.FeatureRecord[1].FeatureTag == "rvrn"
+    assert len(gsub.LookupList.Lookup) == 2
+    assert len(gsub.FeatureVariations.FeatureVariationRecord) == 2
+    # The new 'rclt' feature variation record is appended to the end;
+    # the feature index for 'rvrn' feature table substitution record is now 1
+    assert _substitution_features(gsub, rec_index=0) == [(1, "rvrn")]
+    assert _substitution_features(gsub, rec_index=1) == [(0, "rclt")]
+
+
+def test_addFeatureVariations_existing_condition(varfont):
+    assert "GSUB" not in varfont
+
+    # Add a feature variation for 'ccmp' feature tag with a condition
+    addFeatureVariations(
+        varfont, [([{"wght": (0.5, 1.0)}], {"A": "A.alt"})], featureTag="ccmp"
+    )
+
+    gsub = varfont["GSUB"].table
+
+    # Should now have one feature record, one lookup, and one feature variation record
+    assert len(gsub.FeatureList.FeatureRecord) == 1
+    assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "ccmp"
+    assert len(gsub.LookupList.Lookup) == 1
+    assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1
+    assert _substitution_features(gsub, rec_index=0) == [(0, "ccmp")]
+
+    # Add a feature variation for 'rlig' feature tag with the same condition
+    addFeatureVariations(
+        varfont, [([{"wght": (0.5, 1.0)}], {"B": "B.alt"})], featureTag="rlig"
+    )
+
+    # Should now have two feature records, two lookups, and one feature variation
+    # record, since the condition is the same for both feature variations
+    assert len(gsub.FeatureList.FeatureRecord) == 2
+    assert gsub.FeatureList.FeatureRecord[0].FeatureTag == "ccmp"
+    assert gsub.FeatureList.FeatureRecord[1].FeatureTag == "rlig"
+    assert len(gsub.LookupList.Lookup) == 2
+    assert len(gsub.FeatureVariations.FeatureVariationRecord) == 1
+    assert _substitution_features(gsub, rec_index=0) == [(0, "ccmp"), (1, "rlig")]
 
 
 def _test_linear(n):
diff --git a/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx
index 2f1754b..cee1884 100644
--- a/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx
+++ b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx
@@ -108,9 +108,9 @@
     <fsSelection value="00000000 01000000"/>
     <usFirstCharIndex value="32"/>
     <usLastCharIndex value="8722"/>
-    <sTypoAscender value="800"/>
+    <sTypoAscender value="1000"/>
     <sTypoDescender value="-200"/>
-    <sTypoLineGap value="200"/>
+    <sTypoLineGap value="0"/>
     <usWinAscent value="1000"/>
     <usWinDescent value="200"/>
     <ulCodePageRange1 value="00000000 00000000 00000000 00000001"/>
@@ -687,6 +687,7 @@
         <!-- VarRegionCount=1 -->
         <VarRegionIndex index="0" value="0"/>
         <Item index="0" value="[30]"/>
+        <Item index="1" value="[100]"/>
       </VarData>
     </VarStore>
     <ValueRecord index="0">
@@ -705,6 +706,10 @@
       <ValueTag value="xhgt"/>
       <VarIdx value="65536"/>
     </ValueRecord>
+    <ValueRecord index="3">
+      <ValueTag value="hasc"/>
+      <VarIdx value="65537"/>
+    </ValueRecord>
   </MVAR>
 
   <STAT>
diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py
index 20d9194..0ace29f 100644
--- a/Tests/varLib/instancer/instancer_test.py
+++ b/Tests/varLib/instancer/instancer_test.py
@@ -304,39 +304,69 @@
         assert len(mvar.VarStore.VarData) == 1
 
     @pytest.mark.parametrize(
-        "location, expected",
+        "location, expected, sync_vmetrics",
         [
             pytest.param(
                 {"wght": 1.0, "wdth": 0.0},
-                {"strs": 100, "undo": -200, "unds": 150},
+                {"strs": 100, "undo": -200, "unds": 150, "hasc": 1100},
+                True,
                 id="wght=1.0,wdth=0.0",
             ),
             pytest.param(
                 {"wght": 0.0, "wdth": -1.0},
-                {"strs": 20, "undo": -100, "unds": 50},
+                {"strs": 20, "undo": -100, "unds": 50, "hasc": 1000},
+                True,
                 id="wght=0.0,wdth=-1.0",
             ),
             pytest.param(
                 {"wght": 0.5, "wdth": -0.5},
-                {"strs": 55, "undo": -145, "unds": 95},
+                {"strs": 55, "undo": -145, "unds": 95, "hasc": 1050},
+                True,
                 id="wght=0.5,wdth=-0.5",
             ),
             pytest.param(
                 {"wght": 1.0, "wdth": -1.0},
-                {"strs": 50, "undo": -180, "unds": 130},
+                {"strs": 50, "undo": -180, "unds": 130, "hasc": 1100},
+                True,
                 id="wght=0.5,wdth=-0.5",
             ),
+            pytest.param(
+                {"wght": 1.0, "wdth": 0.0},
+                {"strs": 100, "undo": -200, "unds": 150, "hasc": 1100},
+                False,
+                id="wght=1.0,wdth=0.0,no_sync_vmetrics",
+            ),
         ],
     )
-    def test_full_instance(self, varfont, location, expected):
+    def test_full_instance(self, varfont, location, sync_vmetrics, expected):
         location = instancer.NormalizedAxisLimits(location)
 
+        # check vertical metrics are in sync before...
+        if sync_vmetrics:
+            assert varfont["OS/2"].sTypoAscender == varfont["hhea"].ascender
+            assert varfont["OS/2"].sTypoDescender == varfont["hhea"].descender
+            assert varfont["OS/2"].sTypoLineGap == varfont["hhea"].lineGap
+        else:
+            # force them not to be in sync
+            varfont["OS/2"].sTypoDescender -= 100
+            varfont["OS/2"].sTypoLineGap += 200
+
         instancer.instantiateMVAR(varfont, location)
 
         for mvar_tag, expected_value in expected.items():
             table_tag, item_name = MVAR_ENTRIES[mvar_tag]
             assert getattr(varfont[table_tag], item_name) == expected_value
 
+        # ... as well as after instancing, but only if they were already
+        # https://github.com/fonttools/fonttools/issues/3297
+        if sync_vmetrics:
+            assert varfont["OS/2"].sTypoAscender == varfont["hhea"].ascender
+            assert varfont["OS/2"].sTypoDescender == varfont["hhea"].descender
+            assert varfont["OS/2"].sTypoLineGap == varfont["hhea"].lineGap
+        else:
+            assert varfont["OS/2"].sTypoDescender != varfont["hhea"].descender
+            assert varfont["OS/2"].sTypoLineGap != varfont["hhea"].lineGap
+
         assert "MVAR" not in varfont
 
 
@@ -1956,7 +1986,10 @@
                 TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]),
                 "wght",
                 0.6,
-                [TupleVariation({"wght": (0.0, 0.833334, 1.666667)}, [100, 100])],
+                [
+                    TupleVariation({"wght": (0.0, 0.833334, 1.0)}, [100, 100]),
+                    TupleVariation({"wght": (0.833334, 1.0, 1.0)}, [80, 80]),
+                ],
             ),
             (
                 TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]),
@@ -1971,7 +2004,10 @@
                 TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]),
                 "wght",
                 0.5,
-                [TupleVariation({"wght": (0.0, 0.4, 1.99994)}, [100, 100])],
+                [
+                    TupleVariation({"wght": (0.0, 0.4, 1)}, [100, 100]),
+                    TupleVariation({"wght": (0.4, 1, 1)}, [62.5, 62.5]),
+                ],
             ),
             (
                 TupleVariation({"wght": (0.5, 0.5, 1.0)}, [100, 100]),
@@ -2035,7 +2071,10 @@
                 TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]),
                 "wght",
                 -0.6,
-                [TupleVariation({"wght": (-1.666667, -0.833334, 0.0)}, [100, 100])],
+                [
+                    TupleVariation({"wght": (-1.0, -0.833334, 0.0)}, [100, 100]),
+                    TupleVariation({"wght": (-1.0, -1.0, -0.833334)}, [80, 80]),
+                ],
             ),
             (
                 TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]),
@@ -2050,7 +2089,10 @@
                 TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]),
                 "wght",
                 -0.5,
-                [TupleVariation({"wght": (-2.0, -0.4, 0.0)}, [100, 100])],
+                [
+                    TupleVariation({"wght": (-1.0, -0.4, 0.0)}, [100, 100]),
+                    TupleVariation({"wght": (-1.0, -1.0, -0.4)}, [62.5, 62.5]),
+                ],
             ),
             (
                 TupleVariation({"wght": (-1.0, -0.5, -0.5)}, [100, 100]),
diff --git a/Tests/varLib/instancer/solver_test.py b/Tests/varLib/instancer/solver_test.py
index b9acf82..7bcab63 100644
--- a/Tests/varLib/instancer/solver_test.py
+++ b/Tests/varLib/instancer/solver_test.py
@@ -43,7 +43,8 @@
                 (0, 0.2, 1),
                 (-1, 0, 0.8),
                 [
-                    (1, (0, 0.25, 1.25)),
+                    (1, (0, 0.25, 1)),
+                    (0.25, (0.25, 1, 1)),
                 ],
             ),
             # Case 3 boundary
@@ -51,7 +52,8 @@
                 (0, 0.4, 1),
                 (-1, 0, 0.5),
                 [
-                    (1, (0, 0.8, 1.99994)),
+                    (1, (0, 0.8, 1)),
+                    (2.5 / 3, (0.8, 1, 1)),
                 ],
             ),
             # Case 4
@@ -234,7 +236,8 @@
                 (0, 0.2, 1),
                 (0, 0, 0.5),
                 [
-                    (1, (0, 0.4, 1.99994)),
+                    (1, (0, 0.4, 1)),
+                    (0.625, (0.4, 1, 1)),
                 ],
             ),
             # https://github.com/fonttools/fonttools/issues/3139
diff --git a/Tests/varLib/interpolatable_test.py b/Tests/varLib/interpolatable_test.py
index 10b9cc3..c97edd3 100644
--- a/Tests/varLib/interpolatable_test.py
+++ b/Tests/varLib/interpolatable_test.py
@@ -47,7 +47,7 @@
         for p in all_files:
             if p.startswith(prefix) and p.endswith(suffix):
                 file_list.append(os.path.abspath(os.path.join(folder, p)))
-        return file_list
+        return sorted(file_list)
 
     def temp_path(self, suffix):
         self.temp_dir()
@@ -136,18 +136,20 @@
         # without --ignore-missing
         problems = interpolatable_main(["--quiet"] + ttf_paths)
         self.assertEqual(
-            problems["a"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+            problems["a"],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
-            problems["s"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+            problems["s"],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
             problems["edotabove"],
-            [{"type": "missing", "master": "SparseMasters-Medium"}],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
             problems["dotabovecomb"],
-            [{"type": "missing", "master": "SparseMasters-Medium"}],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
 
         # normal order, with --ignore-missing
@@ -172,18 +174,20 @@
         # without --ignore-missing
         problems = interpolatable_main(["--quiet"] + ufo_paths)
         self.assertEqual(
-            problems["a"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+            problems["a"],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
-            problems["s"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+            problems["s"],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
             problems["edotabove"],
-            [{"type": "missing", "master": "SparseMasters-Medium"}],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
             problems["dotabovecomb"],
-            [{"type": "missing", "master": "SparseMasters-Medium"}],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
 
         # normal order, with --ignore-missing
@@ -206,18 +210,20 @@
 
         problems = interpolatable_main(["--quiet", designspace_path])
         self.assertEqual(
-            problems["a"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+            problems["a"],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
-            problems["s"], [{"type": "missing", "master": "SparseMasters-Medium"}]
+            problems["s"],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
             problems["edotabove"],
-            [{"type": "missing", "master": "SparseMasters-Medium"}],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
             problems["dotabovecomb"],
-            [{"type": "missing", "master": "SparseMasters-Medium"}],
+            [{"type": "missing", "master": "SparseMasters-Medium", "master_idx": 1}],
         )
 
         # normal order, with --ignore-missing
@@ -229,18 +235,20 @@
 
         problems = interpolatable_main(["--quiet", glyphsapp_path])
         self.assertEqual(
-            problems["a"], [{"type": "missing", "master": "Sparse Masters-Medium"}]
+            problems["a"],
+            [{"type": "missing", "master": "Sparse Masters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
-            problems["s"], [{"type": "missing", "master": "Sparse Masters-Medium"}]
+            problems["s"],
+            [{"type": "missing", "master": "Sparse Masters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
             problems["edotabove"],
-            [{"type": "missing", "master": "Sparse Masters-Medium"}],
+            [{"type": "missing", "master": "Sparse Masters-Medium", "master_idx": 1}],
         )
         self.assertEqual(
             problems["dotabovecomb"],
-            [{"type": "missing", "master": "Sparse Masters-Medium"}],
+            [{"type": "missing", "master": "Sparse Masters-Medium", "master_idx": 1}],
         )
 
         # normal order, with --ignore-missing
diff --git a/Tests/varLib/models_test.py b/Tests/varLib/models_test.py
index 11ec1a1..7a05c52 100644
--- a/Tests/varLib/models_test.py
+++ b/Tests/varLib/models_test.py
@@ -192,6 +192,17 @@
         #    print("{:d}	{:.2}	{:.2}".format(i, err, err_bad))
 
 
+locationsA = [{}, {"wght": 1}, {"wdth": 1}]
+locationsB = [{}, {"wght": 1}, {"wdth": 1}, {"wght": 1, "wdth": 1}]
+locationsC = [
+    {},
+    {"wght": 0.5},
+    {"wght": 1},
+    {"wdth": 1},
+    {"wght": 1, "wdth": 1},
+]
+
+
 class VariationModelTest(object):
     @pytest.mark.parametrize(
         "locations, axisOrder, sortedLocs, supports, deltaWeights",
@@ -397,7 +408,7 @@
             )
 
     @pytest.mark.parametrize(
-        "locations, axisOrder, masterValues, instanceLocation, expectedValue",
+        "locations, axisOrder, masterValues, instanceLocation, expectedValue, masterScalars",
         [
             (
                 [
@@ -422,6 +433,7 @@
                     "axis_B": 0.5,
                 },
                 37.5,
+                [0.25, 0.0, 0.0, -0.25, 0.5, 0.5],
             ),
         ],
     )
@@ -432,8 +444,93 @@
         masterValues,
         instanceLocation,
         expectedValue,
+        masterScalars,
     ):
         model = VariationModel(locations, axisOrder=axisOrder)
-        interpolatedValue = model.interpolateFromMasters(instanceLocation, masterValues)
 
+        interpolatedValue = model.interpolateFromMasters(instanceLocation, masterValues)
         assert interpolatedValue == expectedValue
+
+        assert masterScalars == model.getMasterScalars(instanceLocation)
+
+        assert model.interpolateFromValuesAndScalars(
+            masterValues, masterScalars
+        ) == pytest.approx(interpolatedValue)
+
+    @pytest.mark.parametrize(
+        "masterLocations, location, expected",
+        [
+            (locationsA, {"wght": 0, "wdth": 0}, [1, 0, 0]),
+            (
+                locationsA,
+                {"wght": 0.5, "wdth": 0},
+                [0.5, 0.5, 0],
+            ),
+            (locationsA, {"wght": 1, "wdth": 0}, [0, 1, 0]),
+            (
+                locationsA,
+                {"wght": 0, "wdth": 0.5},
+                [0.5, 0, 0.5],
+            ),
+            (locationsA, {"wght": 0, "wdth": 1}, [0, 0, 1]),
+            (locationsA, {"wght": 1, "wdth": 1}, [-1, 1, 1]),
+            (
+                locationsA,
+                {"wght": 0.5, "wdth": 0.5},
+                [0, 0.5, 0.5],
+            ),
+            (
+                locationsA,
+                {"wght": 0.75, "wdth": 0.75},
+                [-0.5, 0.75, 0.75],
+            ),
+            (
+                locationsB,
+                {"wght": 1, "wdth": 1},
+                [0, 0, 0, 1],
+            ),
+            (
+                locationsB,
+                {"wght": 0.5, "wdth": 0},
+                [0.5, 0.5, 0, 0],
+            ),
+            (
+                locationsB,
+                {"wght": 1, "wdth": 0.5},
+                [0, 0.5, 0, 0.5],
+            ),
+            (
+                locationsB,
+                {"wght": 0.5, "wdth": 0.5},
+                [0.25, 0.25, 0.25, 0.25],
+            ),
+            (
+                locationsC,
+                {"wght": 0.5, "wdth": 0},
+                [0, 1, 0, 0, 0],
+            ),
+            (
+                locationsC,
+                {"wght": 0.25, "wdth": 0},
+                [0.5, 0.5, 0, 0, 0],
+            ),
+            (
+                locationsC,
+                {"wght": 0.75, "wdth": 0},
+                [0, 0.5, 0.5, 0, 0],
+            ),
+            (
+                locationsC,
+                {"wght": 0.5, "wdth": 1},
+                [-0.5, 1, -0.5, 0.5, 0.5],
+            ),
+            (
+                locationsC,
+                {"wght": 0.75, "wdth": 1},
+                [-0.25, 0.5, -0.25, 0.25, 0.75],
+            ),
+        ],
+    )
+    def test_getMasterScalars(self, masterLocations, location, expected):
+        model = VariationModel(masterLocations)
+        assert model.getMasterScalars(location) == expected
diff --git a/Tests/varLib/varLib_test.py b/Tests/varLib/varLib_test.py
index 87616ae..53acc16 100644
--- a/Tests/varLib/varLib_test.py
+++ b/Tests/varLib/varLib_test.py
@@ -1,7 +1,13 @@
 from fontTools.colorLib.builder import buildCOLR
 from fontTools.ttLib import TTFont, newTable
 from fontTools.ttLib.tables import otTables as ot
-from fontTools.varLib import build, build_many, load_designspace, _add_COLR
+from fontTools.varLib import (
+    build,
+    build_many,
+    load_designspace,
+    _add_COLR,
+    addGSUBFeatureVariations,
+)
 from fontTools.varLib.errors import VarLibValidationError
 import fontTools.varLib.errors as varLibErrors
 from fontTools.varLib.models import VariationModel
@@ -1009,6 +1015,32 @@
             save_before_dump=True,
         )
 
+    def test_varlib_addGSUBFeatureVariations(self):
+        ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
+
+        ds = DesignSpaceDocument.fromfile(
+            self.get_test_input("FeatureVars.designspace")
+        )
+        for source in ds.sources:
+            ttx_dump = TTFont()
+            ttx_dump.importXML(
+                os.path.join(
+                    ttx_dir, os.path.basename(source.filename).replace(".ufo", ".ttx")
+                )
+            )
+            source.font = ttx_dump
+
+        varfont, _, _ = build(ds, exclude=["GSUB"])
+        assert "GSUB" not in varfont
+
+        addGSUBFeatureVariations(varfont, ds)
+        assert "GSUB" in varfont
+
+        tables = ["fvar", "GSUB"]
+        expected_ttx_path = self.get_test_output("FeatureVars.ttx")
+        self.expect_ttx(varfont, expected_ttx_path, tables)
+        self.check_ttx_dump(varfont, expected_ttx_path, tables, ".ttf")
+
 
 def test_load_masters_layerName_without_required_font():
     ds = DesignSpaceDocument()
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 69601f3..f104af9 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -5,4 +5,4 @@
 mypy>=0.782
 
 # Pin black as each version could change formatting, breaking CI randomly.
-black==23.10.0
+black==24.1.1
diff --git a/requirements.txt b/requirements.txt
index 8a76410..1802521 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,16 +4,17 @@
 brotlicffi==1.1.0.0; platform_python_implementation == "PyPy"
 unicodedata2==15.1.0; python_version <= '3.11'
 scipy==1.10.0; platform_python_implementation != "PyPy" and python_version <= '3.8'  # pyup: ignore
-scipy==1.11.3; platform_python_implementation != "PyPy" and python_version >= '3.9'
+scipy==1.12.0; platform_python_implementation != "PyPy" and python_version >= '3.9'
 munkres==1.1.4; platform_python_implementation == "PyPy"
 zopfli==0.2.3
 fs==2.4.16
 skia-pathops==0.8.0.post1; platform_python_implementation != "PyPy"
 # this is only required to run Tests/cu2qu/{ufo,cli}_test.py
 ufoLib2==0.16.0
-ufo2ft==2.33.4
-pyobjc==10.0; sys_platform == "darwin"
+ufo2ft==3.0.1
+pyobjc==10.1; sys_platform == "darwin"
 freetype-py==2.4.0
-uharfbuzz==0.37.3
-glyphsLib==6.4.1 # this is only required to run Tests/varLib/interpolatable_test.py
-lxml==4.9.3
+uharfbuzz==0.39.0
+glyphsLib==6.6.3 # this is only required to run Tests/varLib/interpolatable_test.py
+lxml==5.1.0
+sympy==1.12
diff --git a/setup.cfg b/setup.cfg
index 3c41de2..aa7f46c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 4.44.0
+current_version = 4.49.0
 commit = True
 tag = False
 tag_name = {new_version}
diff --git a/setup.py b/setup.py
index da61690..2d24105 100755
--- a/setup.py
+++ b/setup.py
@@ -46,9 +46,7 @@
 with_cython = (
     True
     if env_with_cython in {"1", "true", "yes"}
-    else False
-    if env_with_cython in {"0", "false", "no"}
-    else None
+    else False if env_with_cython in {"0", "false", "no"} else None
 )
 # --with-cython/--without-cython options override environment variables
 opt_with_cython = {"--with-cython"}.intersection(sys.argv)
@@ -97,7 +95,7 @@
     # for fontTools.misc.etree and fontTools.misc.plistlib: use lxml to
     # read/write XML files (faster/safer than built-in ElementTree)
     "lxml": [
-        "lxml >= 4.0, < 5",
+        "lxml >= 4.0",
     ],
     # for fontTools.sfnt and fontTools.woff2: to compress/uncompress
     # WOFF 1.0 and WOFF 2.0 webfonts.
@@ -120,6 +118,9 @@
         # use pure-python alternative on pypy
         "scipy; platform_python_implementation != 'PyPy'",
         "munkres; platform_python_implementation == 'PyPy'",
+        # to output PDF or HTML reports. NOTE: wheels are only available for
+        # windows currently, other platforms will need to build from source.
+        "pycairo",
     ],
     # for fontTools.varLib.plot, to visualize DesignSpaceDocument and resulting
     # VariationModel
@@ -241,7 +242,7 @@
     ]
 
     changelog_name = "NEWS.rst"
-    version_RE = re.compile("^[0-9]+\.[0-9]+")
+    version_RE = re.compile(r"^[0-9]+\.[0-9]+")
     date_fmt = "%Y-%m-%d"
     header_fmt = "%s (released %s)"
     commit_message = "Release {new_version}"
@@ -467,7 +468,7 @@
 
 setup_params = dict(
     name="fonttools",
-    version="4.44.0",
+    version="4.49.0",
     description="Tools to manipulate font files",
     author="Just van Rossum",
     author_email="just@letterror.com",