Repository: googlefonts/ufo2ft
Pull request #654 (2022-08-29 10:59:23)

Support TrueType hinting, 4th try
Continued from #577


Patch:
diff --git a/Lib/ufo2ft/constants.py b/Lib/ufo2ft/constants.py
index 6f26739..da8c5a2 100644
--- a/Lib/ufo2ft/constants.py
+++ b/Lib/ufo2ft/constants.py
@@ -1,7 +1,7 @@
 from types import MappingProxyType
 
 SPARSE_TTF_MASTER_TABLES = frozenset(
-    ["glyf", "head", "hmtx", "loca", "maxp", "post", "vmtx"]
+    ["glyf", "head", "hmtx", "loca", "maxp", "post", "vmtx", "cvt ", "fpgm", "prep"]
 )
 SPARSE_OTF_MASTER_TABLES = frozenset(["CFF ", "VORG", "head", "hmtx", "maxp", "vmtx"])
 
@@ -35,9 +35,13 @@ COLOR_LAYER_MAPPING_KEY = UFO2FT_PREFIX + "colorLayerMapping"
 # ]
 COLR_CLIP_BOXES_KEY = UFO2FT_PREFIX + "colrClipBoxes"
 
+OBJECT_LIBS_KEY = "public.objectLibs"
 OPENTYPE_CATEGORIES_KEY = "public.openTypeCategories"
 OPENTYPE_META_KEY = "public.openTypeMeta"
-
+TRUETYPE_INSTRUCTIONS_KEY = "public.truetype.instructions"
+TRUETYPE_METRICS_KEY = "public.truetype.useMyMetrics"
+TRUETYPE_OVERLAP_KEY = "public.truetype.overlap"
+TRUETYPE_ROUND_KEY = "public.truetype.roundOffsetToGrid"
 UNICODE_VARIATION_SEQUENCES_KEY = "public.unicodeVariationSequences"
 
 COMMON_SCRIPT = "Zyyy"
diff --git a/Lib/ufo2ft/instructionCompiler.py b/Lib/ufo2ft/instructionCompiler.py
new file mode 100644
index 0000000..989a515
--- /dev/null
+++ b/Lib/ufo2ft/instructionCompiler.py
@@ -0,0 +1,324 @@
+from __future__ import annotations
+
+import array
+import logging
+from typing import TYPE_CHECKING, Optional
+
+from fontTools import ttLib
+from fontTools.pens.hashPointPen import HashPointPen
+from fontTools.ttLib import newTable
+from fontTools.ttLib.tables._g_l_y_f import (
+    OVERLAP_COMPOUND,
+    ROUND_XY_TO_GRID,
+    USE_MY_METRICS,
+)
+
+from ufo2ft.constants import (
+    OBJECT_LIBS_KEY,
+    TRUETYPE_INSTRUCTIONS_KEY,
+    TRUETYPE_METRICS_KEY,
+    TRUETYPE_OVERLAP_KEY,
+    TRUETYPE_ROUND_KEY,
+)
+from ufo2ft.fontInfoData import intListToNum
+
+if TYPE_CHECKING:
+    from fontTools.ttLib.tables._g_l_y_f import Glyph as TTGlyph
+    from ufoLib2 import Font, Glyph
+
+
+logger = logging.getLogger(__name__)
+
+
+class InstructionCompiler:
+    def __init__(
+        self, ufo: Font, otf: ttLib.TTFont, autoUseMyMetrics: bool = True
+    ) -> None:
+        self.ufo = ufo
+        self.otf = otf
+        if not autoUseMyMetrics:
+            # If autoUseMyMetrics is False, replace the method with a no-op
+            self.autoUseMyMetrics = lambda ttGlyph, glyphName: None
+
+    def _check_glyph_hash(
+        self, glyphName: str, ttglyph: TTGlyph, glyph_hash: Optional[str]
+    ) -> bool:
+        """Check if the supplied glyph hash from the ufo matches the current outlines."""
+        if glyph_hash is None:
+            # The glyph hash is required
+            logger.error(
+                f"Glyph hash missing, glyph '{glyphName}' will have "
+                "no instructions in font."
+            )
+            return False
+
+        # Check the glyph hash against the TTGlyph that is being built
+
+        ttwidth = self.otf["hmtx"][glyphName][0]
+        hash_pen = HashPointPen(ttwidth, self.otf.getGlyphSet())
+        ttglyph.drawPoints(hash_pen, self.otf["glyf"])
+
+        if glyph_hash != hash_pen.hash:
+            logger.error(
+                f"The stored hash for glyph '{glyphName}' does not match the TrueType "
+                "output glyph. Glyph will have no instructions in the font."
+            )
+            return False
+        return True
+
+    @staticmethod
+    def _check_tt_data_format(ttdata: dict, name: str) -> None:
+        """Make sure we understand the format version, currently only version 1
+        is supported."""
+        formatVersion = ttdata.get("formatVersion", None)
+        if not isinstance(formatVersion, str):
+            raise TypeError(
+                f"Illegal type '{type(formatVersion).__name__}' instead of 'str' for "
+                f"formatVersion for instructions in {name}."
+            )
+        if formatVersion != "1":
+            raise NotImplementedError(
+                f"Unknown formatVersion {formatVersion} for instructions in {name}."
+            )
+
+    def _compile_program(self, key: str, table_tag: str) -> None:
+        """Compile the program for prep or fpgm."""
+        assert key in ("controlValueProgram", "fontProgram")
+        assert table_tag in ("prep", "fpgm")
+        ttdata = self.ufo.lib.get(TRUETYPE_INSTRUCTIONS_KEY, None)
+        if ttdata:
+            self._check_tt_data_format(ttdata, f"lib key '{key}'")
+            asm = ttdata.get(key, None)
+            if asm is None:
+                # The optional key is not there, quit right here
+                return
+            if not asm:
+                # If assembly code is empty, don't bother to add the table
+                logger.debug(
+                    f"Assembly for table '{table_tag}' is empty, "
+                    "table not added to font."
+                )
+                return
+
+            self.otf[table_tag] = table = ttLib.newTable(table_tag)
+            table.program = ttLib.tables.ttProgram.Program()
+            table.program.fromAssembly(asm.splitlines())
+
+    def compileGlyphInstructions(self, ttGlyph, name) -> None:
+        """Compile the glyph instructions from the UFO glyph `name` to bytecode
+        and add it to `ttGlyph`."""
+        if name not in self.ufo:
+            # Skip glyphs that are not in the UFO, e.g. '.notdef'
+            logger.info(
+                f"Skipping compilation of instructions for glyph '{name}' because it "
+                "is not in the input UFO."
+            )
+            return
+
+        glyph = self.ufo[name]
+        ttdata = glyph.lib.get(TRUETYPE_INSTRUCTIONS_KEY, None)
+        if ttdata is not None:
+            self._compile_tt_glyph_program(glyph, ttGlyph, ttdata)
+        if ttGlyph.isComposite():
+            self._set_composite_flags(glyph, ttGlyph)
+
+    def _compile_tt_glyph_program(
+        self, glyph: Glyph, ttglyph: TTGlyph, ttdata: dict
+    ) -> None:
+        self._check_tt_data_format(ttdata, f"glyph '{glyph.name}'")
+        glyph_hash = ttdata.get("id", None)
+        if not self._check_glyph_hash(glyph.name, ttglyph, glyph_hash):
+            return
+
+        # Compile the glyph program
+        asm = ttdata.get("assembly", None)
+        if asm is None:
+            # The "assembly" key is required.
+            logger.error(
+                f"Glyph assembly missing, glyph '{glyph.name}' will have "
+                "no instructions in font."
+            )
+            return
+
+        if not asm:
+            # If the assembly code is empty, don't bother adding a program
+            logger.debug(f"Glyph '{glyph.name}' has no instructions.")
+            return
+
+        ttglyph.program = ttLib.tables.ttProgram.Program()
+        ttglyph.program.fromAssembly(asm.splitlines())
+
+    def autoUseMyMetrics(self, ttGlyph, glyphName):
+        """Set the "USE_MY_METRICS" flag on the first component having the
+        same advance width as the composite glyph, no transform and no
+        horizontal shift (but allow it to shift vertically).
+        This forces the composite glyph to use the possibly hinted horizontal
+        metrics of the sub-glyph, instead of those from the "hmtx" table.
+        """
+        hmtx = self.otf["hmtx"]
+        width = hmtx[glyphName][0]
+        for component in ttGlyph.components:
+            try:
+                baseName, transform = component.getComponentInfo()
+            except AttributeError:
+                # component uses '{first,second}Pt' instead of 'x' and 'y'
+                continue
+            try:
+                baseMetrics = hmtx[baseName]
+            except KeyError:
+                continue  # ignore missing components
+            else:
+                if baseMetrics[0] == width and transform[:-1] == (1, 0, 0, 1, 0):
+                    component.flags |= USE_MY_METRICS
+                    break
+
+    def _set_composite_flags(self, glyph: Glyph, ttglyph: TTGlyph) -> None:
+        # Set component flags
+
+        if len(ttglyph.components) != len(glyph.components):
+            # May happen if nested components have been flattened by a filter
+            logger.error(
+                "Number of components differ between UFO and TTF "
+                f"in glyph '{glyph.name}' ({len(glyph.components)} vs. "
+                f"{len(ttglyph.components)}, not setting component flags from"
+                "UFO. They may still be set heuristically."
+            )
+            self.autoUseMyMetrics(ttglyph, glyph.name)
+            return
+
+        # We need to decide when to set the flags.
+        # Let's assume if any lib key is not there, or the component
+        # doesn't have an identifier, we should leave the flags alone.
+
+        # Keep track of which component has the USE_MY_METRICS flag
+        # and whether any component lib contains the useMyMetrics key
+        use_my_metrics_comp = None
+        lib_contains_use_my_metrics_key = False
+
+        for i, c in enumerate(ttglyph.components):
+            # Set OVERLAP_COMPOUND on the first component only
+            if i == 0 and TRUETYPE_OVERLAP_KEY in glyph.lib:
+                if glyph.lib.get(TRUETYPE_OVERLAP_KEY, False):
+                    c.flags |= OVERLAP_COMPOUND
+                else:
+                    c.flags &= ~OVERLAP_COMPOUND
+
+            # Check if we have information about the current component in the glyph lib
+            ufo_component_id = glyph.components[i].identifier
+            if ufo_component_id is None:
+                # No information about component flags is stored in the UFO.
+                # We don’t modify the flags. Two flags have already been set elsewhere:
+                # - ROUND_XY_TO_GRID is set in TTGlyphPointPen.glyph() called from
+                #                    OutlineTTFCompiler.compileGlyphs()
+                # - USE_MY_METRICS   is set in OutlineTTFCompiler.setupTable_glyf()
+                continue
+
+            if (
+                OBJECT_LIBS_KEY in glyph.lib
+                and ufo_component_id in glyph.lib[OBJECT_LIBS_KEY]
+                and (
+                    TRUETYPE_ROUND_KEY in glyph.lib[OBJECT_LIBS_KEY][ufo_component_id]
+                    or TRUETYPE_METRICS_KEY
+                    in glyph.lib[OBJECT_LIBS_KEY][ufo_component_id]
+                )
+            ):
+                component_lib = glyph.lib[OBJECT_LIBS_KEY][ufo_component_id]
+
+                # ROUND_XY_TO_GRID
+
+                # https://github.com/googlefonts/ufo2ft/pull/425 recommends
+                # to always set the ROUND_XY_TO_GRID flag, so we only
+                # unset it if explicitly done so in the lib
+                if not component_lib.get(TRUETYPE_ROUND_KEY, True):
+                    c.flags &= ~ROUND_XY_TO_GRID
+
+                # USE_MY_METRICS
+                if component_lib.get(TRUETYPE_METRICS_KEY, False):
+                    if use_my_metrics_comp is None:
+                        c.flags |= USE_MY_METRICS
+                        use_my_metrics_comp = ufo_component_id
+                    else:
+                        logger.warning(
+                            f"Ignoring USE_MY_METRICS flag on component {i}, "
+                            f"'{ufo_component_id}' because it has been set on "
+                            f"component '{use_my_metrics_comp}' already "
+                            f"in glyph {glyph.name}."
+                        )
+                        c.flags &= ~USE_MY_METRICS
+                else:
+                    c.flags &= ~USE_MY_METRICS
+                lib_contains_use_my_metrics_key |= TRUETYPE_METRICS_KEY in component_lib
+
+        # If no UFO component has the 'public.truetype.useMyMetrics' key defined
+        # we try to automatically set it
+        if not lib_contains_use_my_metrics_key:
+            self.autoUseMyMetrics(ttglyph, glyph.name)
+
+    def update_maxp(self) -> None:
+        """Update the maxp table with relevant values from the UFO and compiled
+        font.
+        """
+        maxp = self.otf["maxp"]
+        ttdata = self.ufo.lib.get(TRUETYPE_INSTRUCTIONS_KEY, None)
+        if ttdata:
+            for name in (
+                "maxStorage",
+                "maxFunctionDefs",
+                "maxInstructionDefs",
+                "maxStackElements",
+                # "maxSizeOfInstructions",  # Is recalculated below
+                "maxZones",
+                "maxTwilightPoints",
+            ):
+                value = ttdata.get(name, None)
+                if value is not None:
+                    setattr(maxp, name, value)
+
+        # Recalculate maxp.maxSizeOfInstructions
+        sizes = [
+            len(ttglyph.program.getBytecode())
+            for ttglyph in self.otf["glyf"].glyphs.values()
+            if hasattr(ttglyph, "program")
+        ]
+        maxp.maxSizeOfInstructions = max(sizes, default=0)
+
+    def setupTable_cvt(self) -> None:
+        """Make the cvt table."""
+        cvts = []
+        ttdata = self.ufo.lib.get(TRUETYPE_INSTRUCTIONS_KEY, None)
+        if ttdata:
+            self._check_tt_data_format(ttdata, "key 'controlValue'")
+            cvt_dict = ttdata.get("controlValue", None)
+            if cvt_dict:
+                # Convert string keys to int
+                cvt_dict = {int(k): v for k, v in cvt_dict.items()}
+                # Find the maximum cvt index.
+                # We can't just use the dict keys because the cvt must be
+                # filled consecutively.
+                max_cvt = max(cvt_dict.keys())
+                # Make value list, filling entries for missing keys with 0
+                cvts = [cvt_dict.get(i, 0) for i in range(max_cvt + 1)]
+
+        if cvts:
+            # Only write cvt to font if it contains any values
+            self.otf["cvt "] = cvt = newTable("cvt ")
+            cvt.values = array.array("h", cvts)
+
+    def setupTable_fpgm(self) -> None:
+        self._compile_program("fontProgram", "fpgm")
+
+    def setupTable_gasp(self):
+        if not self.ufo.info.openTypeGaspRangeRecords:
+            return
+
+        self.otf["gasp"] = gasp = newTable("gasp")
+        gasp_ranges = dict()
+        for record in self.ufo.info.openTypeGaspRangeRecords:
+            rangeMaxPPEM = record["rangeMaxPPEM"]
+            behavior_bits = record["rangeGaspBehavior"]
+            rangeGaspBehavior = intListToNum(behavior_bits, 0, 4)
+            gasp_ranges[rangeMaxPPEM] = rangeGaspBehavior
+        gasp.gaspRange = gasp_ranges
+
+    def setupTable_prep(self) -> None:
+        self._compile_program("controlValueProgram", "prep")
diff --git a/Lib/ufo2ft/outlineCompiler.py b/Lib/ufo2ft/outlineCompiler.py
index c371bff..16615a6 100644
--- a/Lib/ufo2ft/outlineCompiler.py
+++ b/Lib/ufo2ft/outlineCompiler.py
@@ -22,7 +22,7 @@ from fontTools.pens.t2CharStringPen import T2CharStringPen
 from fontTools.pens.ttGlyphPen import TTGlyphPointPen
 from fontTools.ttLib import TTFont, newTable
 from fontTools.ttLib.standardGlyphOrder import standardGlyphOrder
-from fontTools.ttLib.tables._g_l_y_f import USE_MY_METRICS, Glyph
+from fontTools.ttLib.tables._g_l_y_f import Glyph
 from fontTools.ttLib.tables._h_e_a_d import mac_epoch_diff
 from fontTools.ttLib.tables.O_S_2f_2 import Panose
 
@@ -41,10 +41,12 @@ from ufo2ft.fontInfoData import (
     intListToNum,
     normalizeStringForPostscript,
 )
+from ufo2ft.instructionCompiler import InstructionCompiler
 from ufo2ft.util import (
     _copyGlyph,
     calcCodePageRanges,
     colrClipBoxQuantization,
+    getMaxComponentDepth,
     makeOfficialGlyphOrder,
     makeUnicodeToGlyphNameMapping,
 )
@@ -127,6 +129,7 @@ class BaseOutlineCompiler:
         self._glyphBoundingBoxes = None
         self._fontBoundingBox = None
         self._compiledGlyphs = None
+        self._maxComponentDepths = None
 
     def compile(self):
         """
@@ -290,19 +293,6 @@ class BaseOutlineCompiler:
     # Table Builders
     # --------------
 
-    def setupTable_gasp(self):
-        if "gasp" not in self.tables:
-            return
-
-        self.otf["gasp"] = gasp = newTable("gasp")
-        gasp_ranges = dict()
-        for record in self.ufo.info.openTypeGaspRangeRecords:
-            rangeMaxPPEM = record["rangeMaxPPEM"]
-            behavior_bits = record["rangeGaspBehavior"]
-            rangeGaspBehavior = intListToNum(behavior_bits, 0, 4)
-            gasp_ranges[rangeMaxPPEM] = rangeGaspBehavior
-        gasp.gaspRange = gasp_ranges
-
     def setupTable_head(self):
         """
         Make the head table.
@@ -1415,7 +1405,14 @@ class OutlineTTFCompiler(BaseOutlineCompiler):
     """Compile a .ttf font with TrueType outlines."""
 
     sfntVersion = "\000\001\000\000"
-    tables = BaseOutlineCompiler.tables | {"loca", "gasp", "glyf"}
+    tables = BaseOutlineCompiler.tables | {
+        "cvt ",
+        "fpgm",
+        "gasp",
+        "glyf",
+        "loca",
+        "prep",
+    }
 
     def compileGlyphs(self):
         """Compile and return the TrueType glyphs for this font."""
@@ -1452,6 +1449,23 @@ class OutlineTTFCompiler(BaseOutlineCompiler):
             glyphBoxes[glyphName] = bounds
         return glyphBoxes
 
+    def getMaxComponentDepths(self):
+        """Collect glyphs max components depths.
+
+        Return a dictionary of non zero max components depth keyed by glyph names.
+        The max component depth of composite glyphs is 1 or more.
+        Simple glyphs are not keyed.
+        """
+        if self._maxComponentDepths:
+            return self._maxComponentDepths
+        maxComponentDepths = dict()
+        for name, glyph in self.allGlyphs.items():
+            depth = getMaxComponentDepth(glyph, self.allGlyphs)
+            if depth > 0:
+                maxComponentDepths[name] = depth
+        self._maxComponentDepths = maxComponentDepths
+        return self._maxComponentDepths
+
     def setupTable_maxp(self):
         """Make the maxp table."""
         if "maxp" not in self.tables:
@@ -1470,6 +1484,7 @@ class OutlineTTFCompiler(BaseOutlineCompiler):
         maxp.maxComponentElements = max(
             len(g.components) for g in self.allGlyphs.values()
         )
+        maxp.maxComponentDepth = max(self.getMaxComponentDepths().values(), default=0)
 
     def setupTable_post(self):
         """Make a format 2 post table with the compiler's glyph order."""
@@ -1487,9 +1502,22 @@ class OutlineTTFCompiler(BaseOutlineCompiler):
         post.glyphOrder = self.glyphOrder
 
     def setupOtherTables(self):
+        self.instructionCompiler = InstructionCompiler(
+            self.ufo, self.otf, autoUseMyMetrics=self.autoUseMyMetrics
+        )
+
         self.setupTable_glyf()
-        if self.ufo.info.openTypeGaspRangeRecords:
-            self.setupTable_gasp()
+
+        if "cvt " in self.tables:
+            self.instructionCompiler.setupTable_cvt()
+        if "fpgm" in self.tables:
+            self.instructionCompiler.setupTable_fpgm()
+        if "gasp" in self.tables:
+            self.instructionCompiler.setupTable_gasp()
+        if "prep" in self.tables:
+            self.instructionCompiler.setupTable_prep()
+
+        self.instructionCompiler.update_maxp()
 
     def setupTable_glyf(self):
         """Make the glyf table."""
@@ -1501,41 +1529,32 @@ class OutlineTTFCompiler(BaseOutlineCompiler):
         glyf.glyphs = {}
         glyf.glyphOrder = self.glyphOrder
 
-        hmtx = self.otf.get("hmtx")
         ttGlyphs = self.getCompiledGlyphs()
-        for name in self.glyphOrder:
+        # Sort the glyphs so that simple glyphs are compiled first, and composite
+        # glyphs are compiled later. Otherwise the glyph hashes may not be ready
+        # to calculate when a base glyph of a composite glyph is not in the font yet.
+        maxComponentDepths = self.getMaxComponentDepths()
+        for name in sorted(self.glyphOrder, key=lambda n: maxComponentDepths.get(n, 0)):
             ttGlyph = ttGlyphs[name]
-            if ttGlyph.isComposite() and hmtx is not None and self.autoUseMyMetrics:
-                self.autoUseMyMetrics(ttGlyph, name, hmtx)
+            self.instructionCompiler.compileGlyphInstructions(ttGlyph, name)
             glyf[name] = ttGlyph
 
         # update various maxp fields based on glyf without needing to compile the font
         if "maxp" in self.otf:
             self.otf["maxp"].recalc(self.otf)
 
-    @staticmethod
-    def autoUseMyMetrics(ttGlyph, glyphName, hmtx):
-        """Set the "USE_MY_METRICS" flag on the first component having the
-        same advance width as the composite glyph, no transform and no
-        horizontal shift (but allow it to shift vertically).
-        This forces the composite glyph to use the possibly hinted horizontal
-        metrics of the sub-glyph, instead of those from the "hmtx" table.
-        """
-        width = hmtx[glyphName][0]
-        for component in ttGlyph.components:
-            try:
-                baseName, transform = component.getComponentInfo()
-            except AttributeError:
-                # component uses '{first,second}Pt' instead of 'x' and 'y'
-                continue
-            try:
-                baseMetrics = hmtx[baseName]
-            except KeyError:
-                continue  # ignore missing components
-            else:
-                if baseMetrics[0] == width and transform[:-1] == (1, 0, 0, 1, 0):
-                    component.flags |= USE_MY_METRICS
-                    break
+    # NOTE: the previous 'autoUseMyMetrics' method was moved to the InstructionCompiler
+    # This property setter is kept for backward compatibility to support the relatively
+    # obscure use-case (present in tests) of setting compiler.autoUseMyMetrics = None
+    # in order to disable the feature. It seems to me it's unlikely that one would like
+    # actually disable this at all...
+    @property
+    def autoUseMyMetrics(self) -> bool:
+        return getattr(self, "_autoUseMyMetrics", True)
+
+    @autoUseMyMetrics.setter
+    def autoUseMyMetrics(self, value):
+        self._autoUseMyMetrics = bool(value)
 
 
 class StubGlyph:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 1036185..fbf93b5 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -102,6 +102,35 @@ def designspace_v5(FontClass):
         pen.lineTo((0 + x_offset, 10 + y_offset))
         pen.closePath()
 
+    def add_cvt(font, index):
+        font.lib["public.truetype.instructions"] = {
+            "controlValue": {0: 0, 2: 30 + 10 * index, 3: 100 - index**2},
+            "formatVersion": "1",
+            "maxFunctionDefs": 1,
+            "maxInstructionDefs": 0,
+            "maxStackElements": 2,
+            "maxStorage": 0,
+            "maxTwilightPoints": 0,
+            "maxZones": 1,
+        }
+
+    def add_programs(font):
+        font.lib["public.truetype.instructions"][
+            "controlValueProgram"
+        ] = "PUSHB[ ]\n4 3\nINSTCTRL[ ]"
+        font.lib["public.truetype.instructions"][
+            "fontProgram"
+        ] = "PUSHB[ ]\n0\nFDEF[ ]\nPOP[ ]\nENDF[ ]"
+
+    def add_glyph_program(glyph, hash):
+        # The hash must be passed as an argument. We could probably calculate it here,
+        # but it must match the outline after it has been passed through cu2qu.
+        glyph.lib["public.truetype.instructions"] = {
+            "assembly": "PUSHB[ ]\n0 0\nSVTCA[0]\nMDRP[01100]",
+            "formatVersion": "1",
+            "id": hash,
+        }
+
     def draw_something(glyph, number, is_sans):
         # Ensure Sans and Serif sources are incompatible to make sure that the
         # DS5 code treats them separately when using e.g. cu2qu. Use some number
@@ -122,9 +151,15 @@ def designspace_v5(FontClass):
         if source.layerName is not None:
             continue
         font = FontClass()
+        add_cvt(font, index)
+        if index == 0:
+            # Add some instructions to the default source
+            add_programs(font)
         for name in ("I", "S", "I.narrow", "S.closed", "a"):
             glyph = font.newGlyph(name)
             draw_something(glyph, index, "Serif" not in source.filename)
+        if index == 0:
+            add_glyph_program(font["a"], "w0l0+0l0+10l10+10l10+0|")
         font.lib["public.glyphOrder"] = sorted(font.keys())
         sources[source.filename] = font
 
diff --git a/tests/data/DSv5/MutatorSansVariable_Weight-TTF.ttx b/tests/data/DSv5/MutatorSansVariable_Weight-TTF.ttx
index a2d8d3d..424f48e 100644
--- a/tests/data/DSv5/MutatorSansVariable_Weight-TTF.ttx
+++ b/tests/data/DSv5/MutatorSansVariable_Weight-TTF.ttx
@@ -63,10 +63,10 @@
     <maxZones value="1"/>
     <maxTwilightPoints value="0"/>
     <maxStorage value="0"/>
-    <maxFunctionDefs value="0"/>
+    <maxFunctionDefs value="1"/>
     <maxInstructionDefs value="0"/>
-    <maxStackElements value="0"/>
-    <maxSizeOfInstructions value="0"/>
+    <maxStackElements value="2"/>
+    <maxSizeOfInstructions value="5"/>
     <maxComponentElements value="0"/>
     <maxComponentDepth value="0"/>
   </maxp>
@@ -141,6 +141,31 @@
     </cmap_format_4>
   </cmap>
 
+  <fpgm>
+    <assembly>
+      PUSHB[ ]
+      0
+      FDEF[ ]
+        POP[ ]
+      ENDF[ ]
+    </assembly>
+  </fpgm>
+
+  <prep>
+    <assembly>
+      PUSHB[ ]
+      4 3
+      INSTCTRL[ ]
+    </assembly>
+  </prep>
+
+  <cvt>
+    <cv index="0" value="0"/>
+    <cv index="1" value="0"/>
+    <cv index="2" value="30"/>
+    <cv index="3" value="100"/>
+  </cvt>
+
   <loca>
     <!-- The 'loca' table will be calculated by the compiler -->
   </loca>
@@ -213,7 +238,14 @@
         <pt x="10" y="10" on="1"/>
         <pt x="10" y="0" on="1"/>
       </contour>
-      <instructions/>
+      <instructions>
+        <assembly>
+          PUSHB[ ]
+          0 0
+          SVTCA[0]
+          MDRP[01100]
+        </assembly>
+      </instructions>
     </TTGlyph>
 
   </glyf>
@@ -555,6 +587,17 @@
     <ElidedFallbackNameID value="262"/>  <!-- Regular -->
   </STAT>
 
+  <cvar>
+    <version major="1" minor="0"/>
+    <tuple>
+      <coord axis="wght" min="0.7" value="1.0" max="1.0"/>
+      <delta cvt="0" value="0"/>
+      <delta cvt="1" value="0"/>
+      <delta cvt="2" value="10"/>
+      <delta cvt="3" value="-1"/>
+    </tuple>
+  </cvar>
+
   <fvar>
 
     <!-- Weight -->
diff --git a/tests/data/DSv5/MutatorSansVariable_Weight_Width-TTF.ttx b/tests/data/DSv5/MutatorSansVariable_Weight_Width-TTF.ttx
index 3dee6e6..09a64ef 100644
--- a/tests/data/DSv5/MutatorSansVariable_Weight_Width-TTF.ttx
+++ b/tests/data/DSv5/MutatorSansVariable_Weight_Width-TTF.ttx
@@ -63,10 +63,10 @@
     <maxZones value="1"/>
     <maxTwilightPoints value="0"/>
     <maxStorage value="0"/>
-    <maxFunctionDefs value="0"/>
+    <maxFunctionDefs value="1"/>
     <maxInstructionDefs value="0"/>
-    <maxStackElements value="0"/>
-    <maxSizeOfInstructions value="0"/>
+    <maxStackElements value="2"/>
+    <maxSizeOfInstructions value="5"/>
     <maxComponentElements value="0"/>
     <maxComponentDepth value="0"/>
   </maxp>
@@ -141,6 +141,31 @@
     </cmap_format_4>
   </cmap>
 
+  <fpgm>
+    <assembly>
+      PUSHB[ ]
+      0
+      FDEF[ ]
+        POP[ ]
+      ENDF[ ]
+    </assembly>
+  </fpgm>
+
+  <prep>
+    <assembly>
+      PUSHB[ ]
+      4 3
+      INSTCTRL[ ]
+    </assembly>
+  </prep>
+
+  <cvt>
+    <cv index="0" value="0"/>
+    <cv index="1" value="0"/>
+    <cv index="2" value="30"/>
+    <cv index="3" value="100"/>
+  </cvt>
+
   <loca>
     <!-- The 'loca' table will be calculated by the compiler -->
   </loca>
@@ -213,7 +238,14 @@
         <pt x="10" y="10" on="1"/>
         <pt x="10" y="0" on="1"/>
       </contour>
-      <instructions/>
+      <instructions>
+        <assembly>
+          PUSHB[ ]
+          0 0
+          SVTCA[0]
+          MDRP[01100]
+        </assembly>
+      </instructions>
     </TTGlyph>
 
   </glyf>
@@ -822,6 +854,40 @@
     </segment>
   </avar>
 
+  <cvar>
+    <version major="1" minor="0"/>
+    <tuple>
+      <coord axis="wght" min="0.7" value="1.0" max="1.0"/>
+      <delta cvt="0" value="0"/>
+      <delta cvt="1" value="0"/>
+      <delta cvt="2" value="10"/>
+      <delta cvt="3" value="-1"/>
+    </tuple>
+    <tuple>
+      <coord axis="wdth" value="1.0"/>
+      <delta cvt="0" value="0"/>
+      <delta cvt="1" value="0"/>
+      <delta cvt="2" value="20"/>
+      <delta cvt="3" value="-4"/>
+    </tuple>
+    <tuple>
+      <coord axis="wght" min="0.0" value="0.7" max="1.0"/>
+      <coord axis="wdth" value="1.0"/>
+      <delta cvt="0" value="0"/>
+      <delta cvt="1" value="0"/>
+      <delta cvt="2" value="-20"/>
+      <delta cvt="3" value="4"/>
+    </tuple>
+    <tuple>
+      <coord axis="wght" min="0.7" value="1.0" max="1.0"/>
+      <coord axis="wdth" value="1.0"/>
+      <delta cvt="0" value="0"/>
+      <delta cvt="1" value="0"/>
+      <delta cvt="2" value="0"/>
+      <delta cvt="3" value="-4"/>
+    </tuple>
+  </cvar>
+
   <fvar>
 
     <!-- Weight -->
diff --git a/tests/data/DSv5/MutatorSansVariable_Width-TTF.ttx b/tests/data/DSv5/MutatorSansVariable_Width-TTF.ttx
index 9e4a525..6d73545 100644
--- a/tests/data/DSv5/MutatorSansVariable_Width-TTF.ttx
+++ b/tests/data/DSv5/MutatorSansVariable_Width-TTF.ttx
@@ -63,10 +63,10 @@
     <maxZones value="1"/>
     <maxTwilightPoints value="0"/>
     <maxStorage value="0"/>
-    <maxFunctionDefs value="0"/>
+    <maxFunctionDefs value="1"/>
     <maxInstructionDefs value="0"/>
-    <maxStackElements value="0"/>
-    <maxSizeOfInstructions value="0"/>
+    <maxStackElements value="2"/>
+    <maxSizeOfInstructions value="5"/>
     <maxComponentElements value="0"/>
     <maxComponentDepth value="0"/>
   </maxp>
@@ -141,6 +141,31 @@
     </cmap_format_4>
   </cmap>
 
+  <fpgm>
+    <assembly>
+      PUSHB[ ]
+      0
+      FDEF[ ]
+        POP[ ]
+      ENDF[ ]
+    </assembly>
+  </fpgm>
+
+  <prep>
+    <assembly>
+      PUSHB[ ]
+      4 3
+      INSTCTRL[ ]
+    </assembly>
+  </prep>
+
+  <cvt>
+    <cv index="0" value="0"/>
+    <cv index="1" value="0"/>
+    <cv index="2" value="30"/>
+    <cv index="3" value="100"/>
+  </cvt>
+
   <loca>
     <!-- The 'loca' table will be calculated by the compiler -->
   </loca>
@@ -213,7 +238,14 @@
         <pt x="10" y="10" on="1"/>
         <pt x="10" y="0" on="1"/>
       </contour>
-      <instructions/>
+      <instructions>
+        <assembly>
+          PUSHB[ ]
+          0 0
+          SVTCA[0]
+          MDRP[01100]
+        </assembly>
+      </instructions>
     </TTGlyph>
 
   </glyf>
@@ -548,6 +580,17 @@
     </segment>
   </avar>
 
+  <cvar>
+    <version major="1" minor="0"/>
+    <tuple>
+      <coord axis="wdth" value="1.0"/>
+      <delta cvt="0" value="0"/>
+      <delta cvt="1" value="0"/>
+      <delta cvt="2" value="20"/>
+      <delta cvt="3" value="-4"/>
+    </tuple>
+  </cvar>
+
   <fvar>
 
     <!-- Width -->
diff --git a/tests/data/DSv5/MutatorSerifVariable_Width-TTF.ttx b/tests/data/DSv5/MutatorSerifVariable_Width-TTF.ttx
index 090c5eb..c911761 100644
--- a/tests/data/DSv5/MutatorSerifVariable_Width-TTF.ttx
+++ b/tests/data/DSv5/MutatorSerifVariable_Width-TTF.ttx
@@ -63,9 +63,9 @@
     <maxZones value="1"/>
     <maxTwilightPoints value="0"/>
     <maxStorage value="0"/>
-    <maxFunctionDefs value="0"/>
+    <maxFunctionDefs value="1"/>
     <maxInstructionDefs value="0"/>
-    <maxStackElements value="0"/>
+    <maxStackElements value="2"/>
     <maxSizeOfInstructions value="0"/>
     <maxComponentElements value="0"/>
     <maxComponentDepth value="0"/>
@@ -141,6 +141,13 @@
     </cmap_format_4>
   </cmap>
 
+  <cvt>
+    <cv index="0" value="0"/>
+    <cv index="1" value="0"/>
+    <cv index="2" value="100"/>
+    <cv index="3" value="51"/>
+  </cvt>
+
   <loca>
     <!-- The 'loca' table will be calculated by the compiler -->
   </loca>
@@ -472,6 +479,17 @@
     </segment>
   </avar>
 
+  <cvar>
+    <version major="1" minor="0"/>
+    <tuple>
+      <coord axis="wdth" value="1.0"/>
+      <delta cvt="0" value="0"/>
+      <delta cvt="1" value="0"/>
+      <delta cvt="2" value="10"/>
+      <delta cvt="3" value="-15"/>
+    </tuple>
+  </cvar>
+
   <fvar>
 
     <!-- Width -->
diff --git a/tests/instructionCompiler_test.py b/tests/instructionCompiler_test.py
new file mode 100644
index 0000000..e02f510
--- /dev/null
+++ b/tests/instructionCompiler_test.py
@@ -0,0 +1,771 @@
+import logging
+
+import pytest
+from cu2qu.ufo import font_to_quadratic
+from fontTools.pens.hashPointPen import HashPointPen
+from fontTools.ttLib.tables._g_l_y_f import (
+    OVERLAP_COMPOUND,
+    ROUND_XY_TO_GRID,
+    USE_MY_METRICS,
+)
+from fontTools.ttLib.ttFont import TTFont
+
+from ufo2ft.instructionCompiler import InstructionCompiler
+
+from .outlineCompiler_test import getpath
+
+TRUETYPE_INSTRUCTIONS_KEY = "public.truetype.instructions"
+
+
+def expect_maxp(
+    font,
+    maxStorage=0,
+    maxFunctionDefs=0,
+    maxInstructionDefs=0,
+    maxStackElements=0,
+    maxSizeOfInstructions=0,
+    maxZones=1,
+    maxTwilightPoints=0,
+):
+    maxp = font["maxp"]
+    assert maxp.maxStorage == maxStorage
+    assert maxp.maxFunctionDefs == maxFunctionDefs
+    assert maxp.maxInstructionDefs == maxInstructionDefs
+    assert maxp.maxStackElements == maxStackElements
+    assert maxp.maxSizeOfInstructions == maxSizeOfInstructions
+    assert maxp.maxZones == maxZones
+    assert maxp.maxTwilightPoints == maxTwilightPoints
+
+
+def get_hash_ufo(glyph, ufo):
+    hash_pen = HashPointPen(glyph.width, ufo)
+    glyph.drawPoints(hash_pen)
+    return hash_pen.hash
+
+
+def get_hash_ttf(glyph_name, ttf):
+    aw, _lsb = ttf["hmtx"][glyph_name]
+    gs = ttf.getGlyphSet()
+    hash_pen = HashPointPen(aw, gs)
+    ttf["glyf"][glyph_name].drawPoints(hash_pen, ttf["glyf"])
+    return hash_pen.hash
+
+
+@pytest.fixture
+def quadfont():
+    font = TTFont()
+    font.importXML(getpath("TestFont-TTF-post3.ttx"))
+    return font
+
+
+@pytest.fixture
+def quadufo(FontClass):
+    font = FontClass(getpath("TestFont.ufo"))
+    font_to_quadratic(font)
+    return font
+
+
+@pytest.fixture
+def quaduforeversed(FontClass):
+    font = FontClass(getpath("TestFont.ufo"))
+    font_to_quadratic(font=font, reverse_direction=True)
+    return font
+
+
+@pytest.fixture
+def testufo(FontClass):
+    font = FontClass(getpath("TestFont.ufo"))
+    del font.lib["public.postscriptNames"]
+    return font
+
+
+class InstructionCompilerTest:
+    # _check_glyph_hash
+
+    def test_check_glyph_hash_match(self, quaduforeversed, quadfont):
+        glyph = quaduforeversed["a"]
+        ufo_hash = get_hash_ufo(glyph, quaduforeversed)
+        ttglyph = quadfont["glyf"]["a"]
+
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+        result = ic._check_glyph_hash(glyph.name, ttglyph, ufo_hash)
+        assert result
+
+    def test_check_glyph_hash_missing(self, quaduforeversed, quadfont):
+        glyph = quaduforeversed["a"]
+
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+        result = ic._check_glyph_hash(
+            glyph.name,
+            quadfont["glyf"]["a"],
+            None,
+        )
+        assert not result
+
+    def test_check_glyph_hash_mismatch(self, testufo, quadfont):
+        glyph = testufo["a"]
+        ufo_hash = get_hash_ufo(glyph, testufo)
+        ttglyph = quadfont["glyf"]["a"]
+
+        # The contour direction is reversed in testufo vs. quadfont, so the
+        # hash should not match
+
+        ic = InstructionCompiler(testufo, quadfont)
+        result = ic._check_glyph_hash(
+            glyph.name,
+            ttglyph,
+            ufo_hash,
+        )
+        assert not result
+
+    def test_check_glyph_hash_mismatch_composite(self, testufo, quadfont):
+        glyph = testufo["h"]
+        ufo_hash = get_hash_ufo(glyph, testufo)
+        ttglyph = quadfont["glyf"]["h"]
+
+        # The contour direction is reversed in testufo vs. quadfont, so the
+        # hash should not match
+
+        ic = InstructionCompiler(testufo, quadfont)
+        result = ic._check_glyph_hash(
+            glyph.name,
+            ttglyph,
+            ufo_hash,
+        )
+        assert not result
+
+    def test_check_glyph_hash_mismatch_width(self, quaduforeversed, quadfont):
+        glyph = quaduforeversed["a"]
+
+        # Modify the glyph width in the UFO to trigger the mismatch
+        glyph.width += 10
+
+        ufo_hash = get_hash_ufo(glyph, quaduforeversed)
+        ttglyph = quadfont["glyf"]["a"]
+
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+        result = ic._check_glyph_hash(
+            glyph.name,
+            ttglyph,
+            ufo_hash,
+        )
+        assert not result
+
+    # _check_tt_data_format
+
+    def test_check_tt_data_format_match_str(self):
+        result = InstructionCompiler._check_tt_data_format(
+            ttdata={"formatVersion": "1"},
+            name="",
+        )
+        assert result is None
+
+    def test_check_tt_data_format_type_error(self):
+        with pytest.raises(
+            TypeError,
+            match=(
+                "Illegal type 'int' instead of 'str' for formatVersion "
+                "for instructions in location."
+            ),
+        ):
+            InstructionCompiler._check_tt_data_format(
+                ttdata={"formatVersion": 1},  # Spec requires a str
+                name="location",
+            )
+
+    def test_check_tt_data_format_mismatch_str(self):
+        with pytest.raises(
+            NotImplementedError,
+            match="Unknown formatVersion 1.5 for instructions in location.",
+        ):
+            InstructionCompiler._check_tt_data_format(
+                ttdata={"formatVersion": "1.5"},  # Maps to the correct int
+                name="location",
+            )
+
+    # _compile_program
+
+    def test_compile_program_no_ttdata(self, quadufo):
+        # UFO contains no "public.truetype.instructions" lib key
+        ic = InstructionCompiler(quadufo, TTFont())
+        for key, tag in (
+            ("controlValueProgram", "prep"),
+            ("fontProgram", "fpgm"),
+        ):
+            ic._compile_program(key=key, table_tag=tag)
+        assert "fpgm" not in ic.otf
+        assert "prep" not in ic.otf
+
+    def test_compile_program_no_programs(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, but the font and
+        # control value programs are not there. (They are optional)
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+        }
+        for key, tag in (
+            ("controlValueProgram", "prep"),
+            ("fontProgram", "fpgm"),
+        ):
+            ic._compile_program(key=key, table_tag=tag)
+        assert "fpgm" not in ic.otf
+        assert "prep" not in ic.otf
+
+    def test_compile_program_none(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, but the font and
+        # control value programs are None.
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "controlValueProgram": None,
+            "fontProgram": None,
+        }
+        for key, tag in (
+            ("controlValueProgram", "prep"),
+            ("fontProgram", "fpgm"),
+        ):
+            ic._compile_program(key=key, table_tag=tag)
+        assert "fpgm" not in ic.otf
+        assert "prep" not in ic.otf
+
+    def test_compile_program_empty(self, quadufo, caplog):
+        # UFO contains the "public.truetype.instructions" lib key, but the font and
+        # control value programs are empty.
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "controlValueProgram": "",
+            "fontProgram": "",
+        }
+        with caplog.at_level(logging.DEBUG, logger="ufo2ft.instructionCompiler"):
+            for key, tag in (
+                ("controlValueProgram", "prep"),
+                ("fontProgram", "fpgm"),
+            ):
+                ic._compile_program(key=key, table_tag=tag)
+        assert (
+            "Assembly for table 'fpgm' is empty, table not added to font."
+            in caplog.text
+        )
+        assert (
+            "Assembly for table 'prep' is empty, table not added to font."
+            in caplog.text
+        )
+        assert "fpgm" not in ic.otf
+        assert "prep" not in ic.otf
+
+    def test_compile_program(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, and the font and
+        # control value programs are present.
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "controlValueProgram": "PUSHW[]\n511\nSCANCTRL[]",
+            "fontProgram": "PUSHB[]\n0\nFDEF[]\nPOP[]\nENDF[]",
+        }
+        for key, tag in (
+            ("controlValueProgram", "prep"),
+            ("fontProgram", "fpgm"),
+        ):
+            ic._compile_program(key=key, table_tag=tag)
+
+        assert "fpgm" in ic.otf
+        assert "prep" in ic.otf
+
+        # Check if the bytecode is correct, though this may be out of scope
+        assert ic.otf["fpgm"].program.getBytecode() == b"\xb0\x00\x2C\x21\x2D"
+        assert ic.otf["prep"].program.getBytecode() == b"\xb8\x01\xff\x85"
+
+    # compileGlyphInstructions
+
+    def test_compileGlyphInstructions_missing_glyph(self, caplog):
+        # The method logs an info when trying to compile a glyph which is
+        # missing in the UFO, e.g. '.notdef'
+        ic = InstructionCompiler(dict(), None)
+        with caplog.at_level(logging.INFO, logger="ufo2ft.instructionCompiler"):
+            ic.compileGlyphInstructions(None, "A")
+        assert "Skipping compilation of instructions for glyph 'A'" in caplog.text
+
+    # _compile_tt_glyph_program
+
+    def test_compile_tt_glyph_program_empty(self, quaduforeversed, quadfont):
+        # UFO glyph contains no "public.truetype.instructions" lib key
+        with pytest.raises(
+            TypeError,
+            match=(
+                "Illegal type 'NoneType' instead of 'str' for formatVersion "
+                "for instructions in glyph 'a'."
+            ),
+        ):
+            InstructionCompiler(quaduforeversed, quadfont)._compile_tt_glyph_program(
+                glyph=quaduforeversed["a"],
+                ttglyph=quadfont["glyf"]["a"],
+                ttdata={},
+            )
+
+    def test_compile_tt_glyph_program_no_asm(self, quaduforeversed, quadfont, caplog):
+        # UFO glyph contains "public.truetype.instructions" lib key, but no
+        # assembly code entry
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+
+        assert not ic.otf["glyf"]["a"].isComposite()
+
+        glyph = ic.ufo["a"]
+        glyph_hash = get_hash_ufo(glyph, ic.ufo)
+
+        with caplog.at_level(logging.ERROR, logger="ufo2ft.instructionCompiler"):
+            ic._compile_tt_glyph_program(
+                glyph=ic.ufo["a"],
+                ttglyph=ic.otf["glyf"]["a"],
+                ttdata={
+                    "formatVersion": "1",
+                    "id": glyph_hash,
+                    # "assembly": "",
+                },
+            )
+        assert (
+            "Glyph assembly missing, glyph 'a' will have no instructions in font."
+            in caplog.text
+        )
+
+    def test_compile_tt_glyph_program_empty_asm(
+        self, quaduforeversed, quadfont, caplog
+    ):
+        # UFO glyph contains "public.truetype.instructions" lib key, but the
+        # assembly code entry is empty
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+
+        assert not ic.otf["glyf"]["a"].isComposite()
+
+        glyph = ic.ufo["a"]
+        glyph_hash = get_hash_ufo(glyph, ic.ufo)
+
+        with caplog.at_level(logging.DEBUG, logger="ufo2ft.instructionCompiler"):
+            ic._compile_tt_glyph_program(
+                glyph=ic.ufo["a"],
+                ttglyph=ic.otf["glyf"]["a"],
+                ttdata={
+                    "formatVersion": "1",
+                    "id": glyph_hash,
+                    "assembly": "",
+                },
+            )
+        assert "Glyph 'a' has no instructions." in caplog.text
+        assert not hasattr(ic.otf["glyf"]["h"], "program")
+
+    def test_compile_tt_glyph_program_empty_asm_composite(
+        self, quaduforeversed, quadfont
+    ):
+        # UFO glyph contains "public.truetype.instructions" lib key, but the
+        # assembly code entry is empty. The glyph is a composite.
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+
+        glyph = ic.ufo["h"]
+        glyph_hash = get_hash_ufo(glyph, ic.ufo)
+
+        assert ic.otf["glyf"]["h"].isComposite()
+
+        ic._compile_tt_glyph_program(
+            glyph=ic.ufo["h"],
+            ttglyph=ic.otf["glyf"]["h"],
+            ttdata={
+                "formatVersion": "1",
+                "id": glyph_hash,
+                "assembly": "",
+            },
+        )
+        # Components must not have an empty program
+        assert not hasattr(ic.otf["glyf"]["h"], "program")
+
+    def test_compile_tt_glyph_program(self, quaduforeversed, quadfont):
+        # UFO glyph contains "public.truetype.instructions" lib key, and the
+        # assembly code entry is present.
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+
+        assert not ic.otf["glyf"]["a"].isComposite()
+
+        glyph = ic.ufo["a"]
+        glyph_hash = get_hash_ufo(glyph, ic.ufo)
+
+        ic._compile_tt_glyph_program(
+            glyph=ic.ufo["a"],
+            ttglyph=ic.otf["glyf"]["a"],
+            ttdata={
+                "formatVersion": "1",
+                "id": glyph_hash,
+                "assembly": "PUSHB[]\n0\nMDAP[1]",
+            },
+        )
+        assert ic.otf["glyf"]["a"].program.getBytecode() == b"\xb0\x00\x2f"
+
+    def test_compile_tt_glyph_program_composite(self, quaduforeversed, quadfont):
+        # UFO glyph contains "public.truetype.instructions" lib key, and the
+        # assembly code entry is present. The glyph is a composite.
+        name = "k"  # Name of the composite glyph
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+
+        assert ic.otf["glyf"][name].isComposite()
+
+        glyph_hash = get_hash_ufo(ic.ufo[name], ic.ufo)
+
+        ic._compile_tt_glyph_program(
+            glyph=ic.ufo[name],
+            ttglyph=ic.otf["glyf"][name],
+            ttdata={
+                "formatVersion": "1",
+                "id": glyph_hash,
+                "assembly": "PUSHB[]\n0\nMDAP[1]",
+            },
+        )
+        ttglyph = ic.otf["glyf"][name]
+        assert hasattr(ttglyph, "program")
+        assert ttglyph.program.getBytecode() == b"\xb0\x00\x2f"
+
+    # _set_composite_flags
+
+    def test_set_composite_flags_no_ttdata(self, quadufo, quadfont):
+        name = "h"  # Name of the composite glyph
+        ic = InstructionCompiler(quadufo, quadfont)
+
+        glyph = quadufo[name]
+        ttglyph = quadfont["glyf"][name]
+
+        ic._set_composite_flags(
+            glyph=glyph,
+            ttglyph=ttglyph,
+        )
+
+        # Flags have been set by heuristics
+        assert not ttglyph.components[0].flags & OVERLAP_COMPOUND
+        assert ttglyph.components[0].flags & ROUND_XY_TO_GRID
+        assert not ttglyph.components[0].flags & USE_MY_METRICS
+        assert not ttglyph.components[1].flags & OVERLAP_COMPOUND
+        assert ttglyph.components[1].flags & ROUND_XY_TO_GRID
+        assert ttglyph.components[1].flags & USE_MY_METRICS
+
+    def test_set_composite_flags_compound(self, quadufo, quadfont):
+        name = "k"  # Name of the composite glyph
+        ic = InstructionCompiler(quadufo, quadfont)
+
+        glyph = quadufo[name]
+        glyph.components[0].identifier = "component0"
+        glyph.components[1].identifier = "component1"
+        glyph.lib = {"public.truetype.overlap": True}
+        ttglyph = quadfont["glyf"][name]
+
+        ic._set_composite_flags(
+            glyph=glyph,
+            ttglyph=ttglyph,
+        )
+        # The OVERLAP_COMPOUND flag is only set on 1st component
+        assert ttglyph.components[0].flags & OVERLAP_COMPOUND
+        assert not ttglyph.components[1].flags & OVERLAP_COMPOUND
+
+    def test_set_composite_flags_no_compound(self, quadufo, quadfont):
+        name = "k"  # Name of the composite glyph
+        ic = InstructionCompiler(quadufo, quadfont)
+
+        glyph = quadufo[name]
+        glyph.components[0].identifier = "component0"
+        glyph.components[1].identifier = "component1"
+        glyph.lib = {"public.truetype.overlap": False}
+        ttglyph = quadfont["glyf"][name]
+
+        ic._set_composite_flags(
+            glyph=glyph,
+            ttglyph=ttglyph,
+        )
+        assert not ttglyph.components[0].flags & OVERLAP_COMPOUND
+        assert not ttglyph.components[1].flags & OVERLAP_COMPOUND
+
+    def test_set_composite_flags(self, quadufo, quadfont):
+        name = "h"  # Name of the composite glyph
+        ic = InstructionCompiler(quadufo, quadfont)
+
+        glyph = quadufo[name]
+        glyph.components[0].identifier = "component0"
+        glyph.components[1].identifier = "component1"
+        glyph.lib = {
+            "public.objectLibs": {
+                "component0": {
+                    "public.truetype.roundOffsetToGrid": False,
+                    "public.truetype.useMyMetrics": False,
+                },
+                "component1": {
+                    "public.truetype.roundOffsetToGrid": True,
+                    "public.truetype.useMyMetrics": True,
+                },
+            },
+        }
+        ttglyph = quadfont["glyf"][name]
+
+        ic._set_composite_flags(
+            glyph=glyph,
+            ttglyph=ttglyph,
+        )
+
+        assert not ttglyph.components[0].flags & OVERLAP_COMPOUND
+        assert not ttglyph.components[0].flags & ROUND_XY_TO_GRID
+        assert not ttglyph.components[0].flags & USE_MY_METRICS
+
+        assert not ttglyph.components[1].flags & OVERLAP_COMPOUND
+        assert ttglyph.components[1].flags & ROUND_XY_TO_GRID
+        assert ttglyph.components[1].flags & USE_MY_METRICS
+
+    def test_set_composite_flags_metrics_first_only(self, quadufo, quadfont):
+        name = "h"  # Name of the composite glyph
+        ic = InstructionCompiler(quadufo, quadfont)
+
+        glyph = quadufo[name]
+        glyph.components[0].identifier = "component0"
+        glyph.components[1].identifier = "component1"
+        glyph.lib = {
+            "public.objectLibs": {
+                "component0": {
+                    "public.truetype.useMyMetrics": True,
+                },
+                "component1": {
+                    "public.truetype.useMyMetrics": True,
+                },
+            },
+        }
+        ttglyph = quadfont["glyf"][name]
+
+        ic._set_composite_flags(
+            glyph=glyph,
+            ttglyph=ttglyph,
+        )
+
+        # Flag on component 1 should have been ignored
+        assert ttglyph.components[0].flags & USE_MY_METRICS
+        assert not ttglyph.components[1].flags & USE_MY_METRICS
+
+    def test_set_composite_flags_metrics_no_id(self, quadufo, quadfont):
+        name = "h"  # Name of the composite glyph
+        ic = InstructionCompiler(quadufo, quadfont)
+
+        glyph = quadufo[name]
+        # First component has no identifier
+        glyph.components[0].identifier = None
+        glyph.components[1].identifier = "component1"
+        glyph.lib = {
+            "public.objectLibs": {
+                "component1": {
+                    "public.truetype.useMyMetrics": False,
+                },
+            },
+        }
+        ttglyph = quadfont["glyf"][name]
+
+        ic._set_composite_flags(
+            glyph=glyph,
+            ttglyph=ttglyph,
+        )
+
+        # Flag on both components should have been unset
+        assert not ttglyph.components[0].flags & USE_MY_METRICS
+        assert not ttglyph.components[1].flags & USE_MY_METRICS
+
+    # update_maxp
+
+    def test_update_maxp_no_ttdata(self, quaduforeversed, quadfont):
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+
+        ic.update_maxp()
+        expect_maxp(ic.otf)
+
+    def test_update_maxp(self, quaduforeversed, quadfont):
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "maxStorage": 1,
+            "maxFunctionDefs": 1,
+            "maxInstructionDefs": 1,
+            "maxStackElements": 1,
+            "maxSizeOfInstructions": 1,
+            "maxZones": 2,
+            "maxTwilightPoints": 1,
+        }
+        # Make a glyph program of size 3 in "a"
+        self.test_compile_tt_glyph_program(quaduforeversed, quadfont)
+        ic.update_maxp()
+        # maxSizeOfInstructions should be 3 because it is calculated from the font
+        expect_maxp(ic.otf, 1, 1, 1, 1, 3, 2, 1)
+
+    # setupTable_cvt
+
+    def test_setupTable_cvt(self, quaduforeversed, quadfont):
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "controlValue": {
+                "1": 500,
+                "2": 750,
+                "3": -250,
+            },
+        }
+        ic.setupTable_cvt()
+        assert "cvt " in ic.otf
+        assert list(ic.otf["cvt "].values) == [0, 500, 750, -250]
+
+    def test_setupTable_cvt_empty(self, quaduforeversed, quadfont):
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "controlValue": {},
+        }
+        ic.setupTable_cvt()
+        assert "cvt " not in ic.otf
+
+    def test_setupTable_cvt_none(self, quaduforeversed, quadfont):
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "controlValue": None,
+        }
+        ic.setupTable_cvt()
+        assert "cvt " not in ic.otf
+
+    def test_setupTable_cvt_missing(self, quaduforeversed, quadfont):
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+        }
+        ic.setupTable_cvt()
+        assert "cvt " not in ic.otf
+
+    def test_setupTable_cvt_no_ttdata(self, quaduforeversed, quadfont):
+        ic = InstructionCompiler(quaduforeversed, quadfont)
+        ic.setupTable_cvt()
+        assert "cvt " not in ic.otf
+
+    # setupTable_fpgm
+
+    def test_setupTable_fpgm_no_ttdata(self, quadufo):
+        # UFO contains no "public.truetype.instructions" lib key
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.setupTable_fpgm()
+        assert "fpgm" not in ic.otf
+
+    def test_setupTable_fpgm_no_program(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, but the font and
+        # control value programs are not there. (They are optional)
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+        }
+        ic.setupTable_fpgm()
+        assert "fpgm" not in ic.otf
+
+    def test_setupTable_fpgm_none(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, but the font and
+        # control value programs are None.
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "fontProgram": None,
+        }
+        ic.setupTable_fpgm()
+        assert "fpgm" not in ic.otf
+
+    def test_setupTable_fpgm_empty(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, but the font and
+        # control value programs are empty.
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "fontProgram": "",
+        }
+        ic.setupTable_fpgm()
+        assert "fpgm" not in ic.otf
+
+    def test_setupTable_fpgm(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, and the font and
+        # control value programs are present.
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "fontProgram": "PUSHB[]\n0\nFDEF[]\nPOP[]\nENDF[]",
+        }
+        ic.setupTable_fpgm()
+
+        assert "fpgm" in ic.otf
+
+        # Check if the bytecode is correct, though this may be out of scope
+        assert ic.otf["fpgm"].program.getBytecode() == b"\xb0\x00\x2C\x21\x2D"
+
+    # setupTable_gasp
+
+    def test_setupTable_gasp(self, testufo):
+        ic = InstructionCompiler(testufo, TTFont())
+        ic.setupTable_gasp()
+        assert "gasp" in ic.otf
+        assert ic.otf["gasp"].gaspRange == {7: 10, 65535: 15}
+
+    def test_compile_without_gasp(self, testufo):
+        testufo.info.openTypeGaspRangeRecords = None
+        ic = InstructionCompiler(testufo, TTFont())
+        ic.setupTable_gasp()
+        assert "gasp" not in ic.otf
+
+    def test_compile_empty_gasp(self, testufo):
+        # ignore empty gasp
+        testufo.info.openTypeGaspRangeRecords = []
+        ic = InstructionCompiler(testufo, TTFont())
+        ic.setupTable_gasp()
+        assert "gasp" not in ic.otf
+
+    # setupTable_prep
+
+    def test_setupTable_prep_no_ttdata(self, quadufo):
+        # UFO contains no "public.truetype.instructions" lib key
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.setupTable_prep()
+        assert "prep" not in ic.otf
+
+    def test_setupTable_prep_no_program(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, but the font and
+        # control value programs are not there. (They are optional)
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+        }
+        ic.setupTable_prep()
+        assert "prep" not in ic.otf
+
+    def test_setupTable_prep_none(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, but the font and
+        # control value programs are None.
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "controlValueProgram": None,
+        }
+        ic.setupTable_prep()
+        assert "prep" not in ic.otf
+
+    def test_setupTable_prep_empty(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, but the font and
+        # control value programs are empty.
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "controlValueProgram": "",
+        }
+        ic.setupTable_prep()
+        assert "prep" not in ic.otf
+
+    def test_setupTable_prep(self, quadufo):
+        # UFO contains the "public.truetype.instructions" lib key, and the font and
+        # control value programs are present.
+        ic = InstructionCompiler(quadufo, TTFont())
+        ic.ufo.lib[TRUETYPE_INSTRUCTIONS_KEY] = {
+            "formatVersion": "1",
+            "controlValueProgram": "PUSHW[]\n511\nSCANCTRL[]",
+        }
+        ic.setupTable_prep()
+
+        assert "prep" in ic.otf
+
+        # Check if the bytecode is correct, though this may be out of scope
+        assert ic.otf["prep"].program.getBytecode() == b"\xb8\x01\xff\x85"
diff --git a/tests/outlineCompiler_test.py b/tests/outlineCompiler_test.py
index 4dd5f0d..bb357c3 100644
--- a/tests/outlineCompiler_test.py
+++ b/tests/outlineCompiler_test.py
@@ -44,6 +44,12 @@ def quadufo(FontClass):
     return font
 
 
+@pytest.fixture
+def nestedcomponentsufo(FontClass):
+    font = FontClass(getpath("NestedComponents-Regular.ufo"))
+    return font
+
+
 @pytest.fixture
 def use_my_metrics_ufo(FontClass):
     return FontClass(getpath("UseMyMetrics.ufo"))
@@ -63,13 +69,6 @@ def emptyufo(FontClass):
 
 
 class OutlineTTFCompilerTest:
-    def test_setupTable_gasp(self, testufo):
-        compiler = OutlineTTFCompiler(testufo)
-        compiler.otf = TTFont()
-        compiler.setupTable_gasp()
-        assert "gasp" in compiler.otf
-        assert compiler.otf["gasp"].gaspRange == {7: 10, 65535: 15}
-
     def test_compile_with_gasp(self, testufo):
         compiler = OutlineTTFCompiler(testufo)
         compiler.compile()
@@ -97,6 +96,14 @@ class OutlineTTFCompilerTest:
         # float coordinates are rounded, so is the bbox
         assert compiler.glyphBoundingBoxes["d"] == (90, 77, 211, 197)
 
+    def test_getMaxComponentDepths(self, nestedcomponentsufo):
+        compiler = OutlineTTFCompiler(nestedcomponentsufo)
+        assert "a" not in compiler.getMaxComponentDepths()
+        assert "b" not in compiler.getMaxComponentDepths()
+        assert compiler.getMaxComponentDepths()["c"] == 1
+        assert compiler.getMaxComponentDepths()["d"] == 1
+        assert compiler.getMaxComponentDepths()["e"] == 2
+
     def test_autoUseMyMetrics(self, use_my_metrics_ufo):
         compiler = OutlineTTFCompiler(use_my_metrics_ufo)
         ttf = compiler.compile()