Repository: googlefonts/ufo2ft
Pull request #577 (2022-01-14 11:27:44)

Support Truetype instruction compilation
Continuing from #335.

Main changes:
- Format of controlValue lib entry was not interpreted according to UFO spec
- Moved the instruction compilation from the post processor to the outline compiler
- Previous change allowed to remove the recompilation step of the whole font (https://github.com/googlefonts/ufo2ft/pull/335#discussion_r714668544)
- Don't write empty programs/tables to the font
- Always compile TrueType instructions if data is present (https://github.com/googlefonts/ufo2ft/pull/335#discussion_r714666011)

I also removed the separate additional UFOs from the test and implemented small tests for all methods of the InstructionCompiler as @anthrotype requested.


Patch:
diff --git a/Lib/ufo2ft/constants.py b/Lib/ufo2ft/constants.py
index 0c8327f..2694dab 100644
--- a/Lib/ufo2ft/constants.py
+++ b/Lib/ufo2ft/constants.py
@@ -40,6 +40,12 @@ UNICODE_VARIATION_SEQUENCES_KEY = "public.unicodeVariationSequences"
 
 COMMON_SCRIPT = "Zyyy"
 
+TRUETYPE_INSTRUCTIONS_KEY = "public.truetype.instructions"
+TRUETYPE_ROUND_KEY = "public.truetype.roundOffsetToGrid"
+TRUETYPE_METRICS_KEY = "public.truetype.useMyMetrics"
+TRUETYPE_OVERLAP_KEY = "public.truetype.overlap"
+OBJECT_LIBS_KEY = "public.objectLibs"
+
 INDIC_SCRIPTS = [
     "Beng",  # Bengali
     "Cham",  # Cham
diff --git a/Lib/ufo2ft/instructionCompiler.py b/Lib/ufo2ft/instructionCompiler.py
new file mode 100644
index 0000000..524d25a
--- /dev/null
+++ b/Lib/ufo2ft/instructionCompiler.py
@@ -0,0 +1,267 @@
+import array
+import logging
+
+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,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class InstructionCompiler:
+    def _check_glyph_hash(self, glyph, ttglyph, glyph_hash):
+        """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 '{glyph.name}' will have "
+                "no instructions in font."
+            )
+            return False
+
+        # Check the glyph hash from the UFO lib against the current UFO glyph
+        hash_pen = HashPointPen(glyph.width, self.ufo)
+        glyph.drawPoints(hash_pen)
+
+        if glyph_hash != hash_pen.hash:
+            logger.error(
+                f"The current glyph '{glyph.name}' does not match the stored hash. "
+                "Ignoring TrueType instructions for this glyph."
+            )
+            return False
+
+        # Check the glyph hash against the TTGlyph that is being built
+
+        ttwidth = self.otf["hmtx"][glyph.name][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 '{glyph.name}' does not match the TrueType "
+                "output glyph. Glyph will have no instructions in the font."
+            )
+            return False
+        return True
+
+    def _check_tt_data_format(self, ttdata, name):
+        """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, table_tag):
+        """Compile the program for prep or fpgm."""
+        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)
+
+    def compileGlyphInstructions(self, ttGlyph, name):
+        """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():
+            # Remove empty glyph programs from composite glyphs
+            if hasattr(ttGlyph, "program") and not ttGlyph.program:
+                logger.debug(f"Removing empty program from composite glyph '{name}'")
+                delattr(ttGlyph, "program")
+            self._set_composite_flags(glyph, ttGlyph)
+
+    def _compile_tt_glyph_program(self, glyph, ttglyph, ttdata):
+        self._check_tt_data_format(ttdata, f"glyph '{glyph.name}'")
+        glyph_hash = ttdata.get("id", None)
+        if not self._check_glyph_hash(glyph, 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)
+
+    def _set_composite_flags(self, glyph, ttglyph):
+        # 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."
+            )
+            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
+        use_my_metrics_comp = None
+
+        for i, c in enumerate(ttglyph.components):
+            ufo_component_id = glyph.components[i].identifier
+            if ufo_component_id is None:
+                # No information about component flags is stored in the UFO,
+                # use heuristics.
+
+                # https://github.com/googlefonts/ufo2ft/pull/425 recommends
+                # to always set the ROUND_XY_TO_GRID flag
+                c.flags |= ROUND_XY_TO_GRID
+            elif (
+                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]
+
+                # 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 component_lib.get(TRUETYPE_ROUND_KEY, True):
+                    logger.info("    ROUND_XY_TO_GRID")
+                    c.flags |= ROUND_XY_TO_GRID
+                else:
+                    c.flags &= ~ROUND_XY_TO_GRID
+
+                if not self.autoUseMyMetrics and component_lib.get(
+                    TRUETYPE_METRICS_KEY, False
+                ):
+                    c.flags &= ~USE_MY_METRICS
+                    if use_my_metrics_comp:
+                        logger.warning(
+                            "Ignoring USE_MY_METRICS flag on component "
+                            f"'{ufo_component_id}' because it has been set on "
+                            f"component '{use_my_metrics_comp}' already "
+                            f"in glyph {glyph.name}."
+                        )
+                    else:
+                        c.flags |= USE_MY_METRICS
+                        use_my_metrics_comp = ufo_component_id
+
+            if i == 0 and TRUETYPE_OVERLAP_KEY in glyph.lib:
+                # Set OVERLAP_COMPOUND on the first component only
+                if glyph.lib.get(TRUETYPE_OVERLAP_KEY, False):
+                    c.flags |= OVERLAP_COMPOUND
+                else:
+                    c.flags &= ~OVERLAP_COMPOUND
+
+    def update_maxp(self):
+        """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):
+        """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):
+        self._compile_program("fontProgram", "fpgm")
+
+    def setupTable_prep(self):
+        self._compile_program("controlValueProgram", "prep")
diff --git a/Lib/ufo2ft/outlineCompiler.py b/Lib/ufo2ft/outlineCompiler.py
index 424f706..139abf5 100644
--- a/Lib/ufo2ft/outlineCompiler.py
+++ b/Lib/ufo2ft/outlineCompiler.py
@@ -40,6 +40,7 @@ from ufo2ft.fontInfoData import (
     intListToNum,
     normalizeStringForPostscript,
 )
+from ufo2ft.instructionCompiler import InstructionCompiler
 from ufo2ft.util import (
     _copyGlyph,
     calcCodePageRanges,
@@ -1388,7 +1389,7 @@ class OutlineOTFCompiler(BaseOutlineCompiler):
         topDict.FontBBox = self.fontBoundingBox
 
 
-class OutlineTTFCompiler(BaseOutlineCompiler):
+class OutlineTTFCompiler(BaseOutlineCompiler, InstructionCompiler):
     """Compile a .ttf font with TrueType outlines."""
 
     sfntVersion = "\000\001\000\000"
@@ -1407,7 +1408,7 @@ class OutlineTTFCompiler(BaseOutlineCompiler):
                 logger.error("%r has invalid curve format; skipped", name)
                 ttGlyph = Glyph()
             else:
-                ttGlyph = pen.glyph()
+                ttGlyph = pen.glyph(componentFlags=0x0)
             ttGlyphs[name] = ttGlyph
         return ttGlyphs
 
@@ -1464,6 +1465,10 @@ class OutlineTTFCompiler(BaseOutlineCompiler):
         self.setupTable_glyf()
         if self.ufo.info.openTypeGaspRangeRecords:
             self.setupTable_gasp()
+        self.setupTable_cvt()
+        self.setupTable_fpgm()
+        self.setupTable_prep()
+        self.update_maxp()
 
     def setupTable_glyf(self):
         """Make the glyf table."""
@@ -1477,10 +1482,20 @@ class OutlineTTFCompiler(BaseOutlineCompiler):
 
         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.
+        compilation_order = [
+            name
+            for _, name in sorted(
+                [(ttGlyphs[name].isComposite(), name) for name in self.glyphOrder]
+            )
+        ]
+        for name in compilation_order:
             ttGlyph = ttGlyphs[name]
             if ttGlyph.isComposite() and hmtx is not None and self.autoUseMyMetrics:
                 self.autoUseMyMetrics(ttGlyph, name, hmtx)
+            self.compileGlyphInstructions(ttGlyph, name)
             glyf[name] = ttGlyph
 
     @staticmethod
diff --git a/Lib/ufo2ft/postProcessor.py b/Lib/ufo2ft/postProcessor.py
index ea0c39c..4d242d2 100644
--- a/Lib/ufo2ft/postProcessor.py
+++ b/Lib/ufo2ft/postProcessor.py
@@ -98,6 +98,8 @@ class PostProcessor:
           is True and CFF or CFF2 table is present. Choose between "cffsubr" or
           "compreffor". By default "cffsubr" is used for both CFF 1 and CFF 2.
           NOTE: compreffor currently doesn't support input fonts with CFF2 table.
+
+
         """
         if self._get_cff_version(self.otf):
             self.process_cff(


Test patch:
diff --git a/tests/instructionCompiler_test.py b/tests/instructionCompiler_test.py
new file mode 100644
index 0000000..db7413a
--- /dev/null
+++ b/tests/instructionCompiler_test.py
@@ -0,0 +1,778 @@
+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
+
+
+def get_instruction_compiler(ufo, otf):
+    ic = InstructionCompiler()
+    ic.ufo = ufo
+    ic.otf = otf
+    return ic
+
+
+@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 = get_instruction_compiler(quaduforeversed, quadfont)
+        result = ic._check_glyph_hash(glyph=glyph, ttglyph=ttglyph, glyph_hash=ufo_hash)
+        assert result
+
+    def test_check_glyph_hash_missing(self, quaduforeversed, quadfont):
+        glyph = quaduforeversed["a"]
+
+        ic = get_instruction_compiler(quaduforeversed, quadfont)
+        result = ic._check_glyph_hash(
+            glyph=glyph,
+            ttglyph=quadfont["glyf"]["a"],
+            glyph_hash=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 = get_instruction_compiler(testufo, quadfont)
+        result = ic._check_glyph_hash(
+            glyph=glyph,
+            ttglyph=ttglyph,
+            glyph_hash=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 = get_instruction_compiler(testufo, quadfont)
+        result = ic._check_glyph_hash(
+            glyph=glyph,
+            ttglyph=ttglyph,
+            glyph_hash=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 = get_instruction_compiler(quaduforeversed, quadfont)
+        result = ic._check_glyph_hash(
+            glyph=glyph,
+            ttglyph=ttglyph,
+            glyph_hash=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_invalid_tag(self):
+        with pytest.raises(AssertionError):
+            # table_tag must be "fpgm" or "prep"
+            InstructionCompiler()._compile_program(key="foo", table_tag="bar")
+
+    def test_compile_program_no_ttdata(self, quadufo):
+        # UFO contains no "public.truetype.instructions" lib key
+        ic = get_instruction_compiler(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 = get_instruction_compiler(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 = get_instruction_compiler(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 = get_instruction_compiler(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 = get_instruction_compiler(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 = get_instruction_compiler(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()._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()
+        ic.ufo = quaduforeversed
+        ic.otf = 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 = get_instruction_compiler(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 = get_instruction_compiler(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 = get_instruction_compiler(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 = get_instruction_compiler(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()
+        ic.autoUseMyMetrics = False
+
+        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()
+        ic.autoUseMyMetrics = False
+
+        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()
+        ic.autoUseMyMetrics = False
+
+        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()
+        ic.autoUseMyMetrics = False
+
+        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()
+        ic.autoUseMyMetrics = False
+
+        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
+
+    # update_maxp
+
+    def test_update_maxp_no_ttdata(self, quaduforeversed, quadfont):
+        ic = InstructionCompiler()
+        ic.otf = quadfont
+        ic.ufo = quaduforeversed
+
+        ic.update_maxp()
+        expect_maxp(ic.otf)
+
+    def test_update_maxp(self, quaduforeversed, quadfont):
+        ic = InstructionCompiler()
+        ic.otf = quadfont
+        ic.ufo = quaduforeversed
+        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()
+        ic.otf = quadfont
+        ic.ufo = quaduforeversed
+        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()
+        ic.otf = quadfont
+        ic.ufo = quaduforeversed
+        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()
+        ic.otf = quadfont
+        ic.ufo = quaduforeversed
+        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()
+        ic.otf = quadfont
+        ic.ufo = quaduforeversed
+        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()
+        ic.otf = quadfont
+        ic.ufo = quaduforeversed
+        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()
+        ic.ufo = quadufo
+        ic.otf = 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()
+        ic.ufo = quadufo
+        ic.otf = 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()
+        ic.ufo = quadufo
+        ic.otf = 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()
+        ic.ufo = quadufo
+        ic.otf = 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()
+        ic.ufo = quadufo
+        ic.otf = 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_prep
+
+    def test_setupTable_prep_no_ttdata(self, quadufo):
+        # UFO contains no "public.truetype.instructions" lib key
+        ic = InstructionCompiler()
+        ic.ufo = quadufo
+        ic.otf = 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()
+        ic.ufo = quadufo
+        ic.otf = 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()
+        ic.ufo = quadufo
+        ic.otf = 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()
+        ic.ufo = quadufo
+        ic.otf = 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()
+        ic.ufo = quadufo
+        ic.otf = 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 b31319c..d194b08 100644
--- a/tests/outlineCompiler_test.py
+++ b/tests/outlineCompiler_test.py
@@ -1046,7 +1046,11 @@ def test_custom_layer_compilation_interpolatable(layertestrgufo, layertestbdufo)
         "edotabove",
     ]
 
-    sparse_tables = [tag for tag in master_ttfs[1].keys() if tag != "GlyphOrder"]
+    sparse_tables = [
+        tag
+        for tag in master_ttfs[1].keys()
+        if tag not in ("GlyphOrder", "cvt ", "fpgm", "prep")
+    ]
     assert SPARSE_TTF_MASTER_TABLES.issuperset(sparse_tables)
 
 
@@ -1075,7 +1079,12 @@ def test_custom_layer_compilation_interpolatable_from_ds(designspace, inplace):
         "edotabove",
     ]
 
-    sparse_tables = [tag for tag in master_ttfs[1].keys() if tag != "GlyphOrder"]
+    sparse_tables = [
+        tag
+        for tag in master_ttfs[1].keys()
+        if tag not in ("GlyphOrder", "cvt ", "fpgm", "prep")
+    ]
+    # raise NotImplementedError(f"{SPARSE_TTF_MASTER_TABLES} issuperset {sparse_tables}")
     assert SPARSE_TTF_MASTER_TABLES.issuperset(sparse_tables)
 
     # sentinel value used by varLib to ignore the post table for this sparse