Repository: googlefonts/ufo2ft
Pull request #607 (2022-04-25 10:44:30)

add ftConfig parameter to set fontTools' TTFont.cfg options
This adds a new `ftConfig` parameter to all compile methods that allows to set fonttools configuration options as defined in (e.g. to turn on/off the serialization of GPOS/GSUB with the harfbuzz repacker):

https://github.com/fonttools/fonttools/blob/main/Lib/fontTools/config/__init__.py

since these config options are set on the TTFont instance itself (in its cfg attribute) but ufo2ft creates a new TTFont instance from scratch, this new parameter will allow a client (e.g. fontmake) to specify these options and have ufo2ft configure the newly created TTFont object with them.

anthrotype: at the risk of bikeshedding.. do we all like the name `ftConfig` for this "fonttools config" parameter to ufo2ft's compile methods? If it isn't clear enough, we can spell it out as "fontToolsConfig" or even "ttFontConfig" to avoid any confusions.

Patch:
diff --git a/Lib/ufo2ft/_compilers/baseCompiler.py b/Lib/ufo2ft/_compilers/baseCompiler.py
index 4175cd0..15a2437 100644
--- a/Lib/ufo2ft/_compilers/baseCompiler.py
+++ b/Lib/ufo2ft/_compilers/baseCompiler.py
@@ -1,13 +1,12 @@
 import logging
-import os
 from collections import defaultdict
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import Callable, Optional, Type
 
 from fontTools import varLib
 from fontTools.designspaceLib.split import splitInterpolable, splitVariableFonts
 from fontTools.misc.loggingTools import Timer
-from fontTools.otlLib.optimize.gpos import GPOS_COMPACT_MODE_ENV_KEY
+from fontTools.otlLib.optimize.gpos import COMPRESSION_LEVEL as GPOS_COMPRESSION_LEVEL
 
 from ufo2ft.constants import MTI_FEATURES_PREFIX
 from ufo2ft.errors import InvalidDesignSpaceData
@@ -22,6 +21,7 @@ from ufo2ft.util import (
     _notdefGlyphFallback,
     colrClipBoxQuantization,
     ensure_all_sources_have_names,
+    getDefaultMasterFont,
     location_to_string,
     prune_unknown_kwargs,
 )
@@ -47,6 +47,7 @@ class BaseCompiler:
     colrClipBoxQuantization: Callable[[object], int] = colrClipBoxQuantization
     feaIncludeDir: Optional[str] = None
     skipFeatureCompilation: bool = False
+    ftConfig: dict = field(default_factory=dict)
     _tables: Optional[list] = None
 
     def __post_init__(self):
@@ -269,38 +270,51 @@ class BaseInterpolatableCompiler(BaseCompiler):
         originalSources = {}
         originalGlyphsets = {}
 
-        # Compile all needed sources in each interpolable subspace to make sure
-        # they're all compatible; that also ensures that sub-vfs within the same
-        # interpolable sub-space are compatible too.
-        for subDoc in interpolableSubDocs:
-            # Only keep the sources that we've identified earlier as need-to-compile
-            subDoc.sources = [s for s in subDoc.sources if s.name in sourcesToCompile]
-            if not subDoc.sources:
-                continue
-
-            # FIXME: Hack until we get a fontTools config module. Disable GPOS
-            # compaction while building masters because the compaction will be undone
-            # anyway by varLib merge and then done again on the VF
-            gpos_compact_value = os.environ.pop(GPOS_COMPACT_MODE_ENV_KEY, None)
-            save_production_names = self.useProductionNames
-            self.useProductionNames = False
-            save_postprocessor = self.postProcessorClass
-            self.postProcessorClass = None
-            self.skipFeatureCompilation = can_optimize_features
-            try:
+        # Disable GPOS compaction while building masters because the compaction
+        # will be undone anyway by varLib merge and then done again on the final VF
+        gpos_compact_value = self.ftConfig.pop(GPOS_COMPRESSION_LEVEL, None)
+        # we want to rename glyphs only on the final VF and skip postprocessing masters
+        save_production_names, self.useProductionNames = self.useProductionNames, False
+        save_postprocessor, self.postProcessorClass = self.postProcessorClass, None
+        # skip per-master feature compilation if we are building variable features
+        save_skip_features, self.skipFeatureCompilation = (
+            self.skipFeatureCompilation,
+            can_optimize_features,
+        )
+        try:
+            # Compile all needed sources in each interpolable subspace to make sure
+            # they're all compatible; that also ensures that sub-vfs within the same
+            # interpolable sub-space are compatible too.
+            for subDoc in interpolableSubDocs:
+                # Only keep the sources that we've identified earlier as need-to-compile
+                subDoc.sources = [
+                    s for s in subDoc.sources if s.name in sourcesToCompile
+                ]
+                if not subDoc.sources:
+                    continue
+
                 ttfDesignSpace = self.compile_designspace(subDoc)
-            finally:
+
                 if gpos_compact_value is not None:
-                    os.environ[GPOS_COMPACT_MODE_ENV_KEY] = gpos_compact_value
+                    # the VF will inherit the config from the base TTF master
+                    baseTtf = getDefaultMasterFont(ttfDesignSpace)
+                    baseTtf.cfg[GPOS_COMPRESSION_LEVEL] = gpos_compact_value
+
+                # Stick TTFs back into original big DS
+                for ttfSource, glyphSet in zip(ttfDesignSpace.sources, self.glyphSets):
+                    if can_optimize_features:
+                        originalSources[ttfSource.name] = sourcesByName[
+                            ttfSource.name
+                        ].font
+                    sourcesByName[ttfSource.name].font = ttfSource.font
+                    originalGlyphsets[ttfSource.name] = glyphSet
+        finally:
+            # can restore self to its original state
+            if gpos_compact_value is not None:
+                self.ftConfig[GPOS_COMPRESSION_LEVEL] = gpos_compact_value
             self.postProcessorClass = save_postprocessor
             self.useProductionNames = save_production_names
-
-            # Stick TTFs back into original big DS
-            for ttfSource, glyphSet in zip(ttfDesignSpace.sources, self.glyphSets):
-                if can_optimize_features:
-                    originalSources[ttfSource.name] = sourcesByName[ttfSource.name].font
-                sourcesByName[ttfSource.name].font = ttfSource.font
-                originalGlyphsets[ttfSource.name] = glyphSet
+            self.skipFeatureCompilation = save_skip_features
 
         return (
             vfNameToBaseUfo,
diff --git a/Lib/ufo2ft/outlineCompiler.py b/Lib/ufo2ft/outlineCompiler.py
index 5b6d851..e785640 100644
--- a/Lib/ufo2ft/outlineCompiler.py
+++ b/Lib/ufo2ft/outlineCompiler.py
@@ -108,6 +108,7 @@ class BaseOutlineCompiler:
         colrLayerReuse=True,
         colrAutoClipBoxes=True,
         colrClipBoxQuantization=colrClipBoxQuantization,
+        ftConfig=None,
     ):
         self.ufo = font
         # use the previously filtered glyphSet, if any
@@ -126,6 +127,7 @@ class BaseOutlineCompiler:
         self.colrLayerReuse = colrLayerReuse
         self.colrAutoClipBoxes = colrAutoClipBoxes
         self.colrClipBoxQuantization = colrClipBoxQuantization
+        self.ftConfig = ftConfig or {}
         # cached values defined later on
         self._glyphBoundingBoxes = None
         self._fontBoundingBox = None
@@ -136,7 +138,7 @@ class BaseOutlineCompiler:
         """
         Compile the OpenType binary.
         """
-        self.otf = TTFont(sfntVersion=self.sfntVersion)
+        self.otf = TTFont(sfntVersion=self.sfntVersion, cfg=self.ftConfig)
 
         # only compile vertical metrics tables if vhea metrics are defined
         vertical_metrics = [
@@ -1104,6 +1106,7 @@ class OutlineOTFCompiler(BaseOutlineCompiler):
         colrLayerReuse=True,
         colrAutoClipBoxes=True,
         colrClipBoxQuantization=colrClipBoxQuantization,
+        ftConfig=None,
     ):
         if roundTolerance is not None:
             self.roundTolerance = float(roundTolerance)
@@ -1119,6 +1122,7 @@ class OutlineOTFCompiler(BaseOutlineCompiler):
             colrLayerReuse=colrLayerReuse,
             colrAutoClipBoxes=colrAutoClipBoxes,
             colrClipBoxQuantization=colrClipBoxQuantization,
+            ftConfig=ftConfig,
         )
         self.optimizeCFF = optimizeCFF
         self._defaultAndNominalWidths = None
@@ -1440,6 +1444,7 @@ class OutlineTTFCompiler(BaseOutlineCompiler):
         autoUseMyMetrics=True,
         roundCoordinates=True,
         glyphDataFormat=0,
+        ftConfig=None,
     ):
         super().__init__(
             font,
@@ -1450,6 +1455,7 @@ class OutlineTTFCompiler(BaseOutlineCompiler):
             colrLayerReuse=colrLayerReuse,
             colrAutoClipBoxes=colrAutoClipBoxes,
             colrClipBoxQuantization=colrClipBoxQuantization,
+            ftConfig=ftConfig,
         )
         self.autoUseMyMetrics = autoUseMyMetrics
         self.dropImpliedOnCurves = dropImpliedOnCurves
diff --git a/Lib/ufo2ft/postProcessor.py b/Lib/ufo2ft/postProcessor.py
index 56f2292..a7c25d1 100644
--- a/Lib/ufo2ft/postProcessor.py
+++ b/Lib/ufo2ft/postProcessor.py
@@ -404,4 +404,5 @@ def _reloadFont(font: TTFont) -> TTFont:
     stream = BytesIO()
     font.save(stream)
     stream.seek(0)
-    return TTFont(stream)
+    # keep the same Config (constructor will make a copy)
+    return TTFont(stream, cfg=font.cfg)


Test patch:
diff --git a/tests/integration_test.py b/tests/integration_test.py
index 0eb2a03..12e8745 100644
--- a/tests/integration_test.py
+++ b/tests/integration_test.py
@@ -1,5 +1,6 @@
 import difflib
 import io
+import logging
 import os
 import re
 import sys
@@ -7,6 +8,8 @@ from pathlib import Path
 from textwrap import dedent
 
 import pytest
+from fontTools.designspaceLib import DesignSpaceDocument
+from fontTools.otlLib.optimize.gpos import COMPRESSION_LEVEL as GPOS_COMPRESSION_LEVEL
 from fontTools.pens.boundsPen import BoundsPen
 from fontTools.pens.transformPen import TransformPen
 from fontTools.ttLib.tables._g_l_y_f import (
@@ -547,6 +550,35 @@ class IntegrationTest:
             tables=["CFF2", "hmtx", "HVAR"],
         )
 
+    @pytest.mark.parametrize("compileMethod", [compileTTF, compileOTF])
+    @pytest.mark.parametrize("compression_level", [0, 9])
+    def test_compile_static_font_with_gpos_compression(
+        self, caplog, compileMethod, testufo, compression_level
+    ):
+        with caplog.at_level(logging.INFO, logger="fontTools"):
+            compileMethod(testufo, ftConfig={GPOS_COMPRESSION_LEVEL: compression_level})
+        disabled = compression_level == 0
+        logged = "Compacting GPOS..." in caplog.text
+        assert logged ^ disabled
+
+    @pytest.mark.parametrize("compileMethod", [compileVariableTTF, compileVariableCFF2])
+    @pytest.mark.parametrize("variableFeatures", [True, False])
+    @pytest.mark.parametrize("compression_level", [0, 9])
+    def test_compile_variable_font_with_gpos_compression(
+        self, caplog, compileMethod, FontClass, variableFeatures, compression_level
+    ):
+        designspace = DesignSpaceDocument.fromfile(getpath("TestVarfea.designspace"))
+        designspace.loadSourceFonts(FontClass)
+        with caplog.at_level(logging.INFO, logger="fontTools"):
+            compileMethod(
+                designspace,
+                ftConfig={GPOS_COMPRESSION_LEVEL: compression_level},
+                variableFeatures=variableFeatures,
+            )
+        disabled = compression_level == 0
+        logged = "Compacting GPOS..." in caplog.text
+        assert logged ^ disabled
+
 
 if __name__ == "__main__":
     sys.exit(pytest.main(sys.argv))