Repository: googlefonts/ufo2ft
Pull request #772 (2023-08-01 14:58:05)

Fix issue with sparse masters when .notdef contain cubic curves
I first push a reproducer for the issue I raised in https://github.com/googlefonts/ufo2ft/issues/501#issuecomment-1660173297

and then follow up with a fix.

EDIT:

previously we were copying ".notdef" glyph from the default master when a sparse layer didn't contain one already, but if the notdef contains some cubic curves then our own check fails because the .notdef that we inserted in the sparse masters' OutlineCompiler was not passed through cu2qu...
Our intention is to have sparse masters _not_ participate in the interpolation of that particular .notdef glyph so we make it empty (a gid0=.notdef still needs to be there for a valid TTF so we must have it there).
Currently the same trick does not work for CFF2 variable fonts (https://github.com/fonttools/fonttools/issues/3233) but those are fine with cubics in .notdef glyph any way.. For now at least


Patch:
diff --git a/Lib/ufo2ft/__init__.py b/Lib/ufo2ft/__init__.py
index 47d3ad8..aad7e05 100644
--- a/Lib/ufo2ft/__init__.py
+++ b/Lib/ufo2ft/__init__.py
@@ -408,7 +408,7 @@ def compileInterpolatableTTFsFromDS(designSpaceDoc, **kwargs):
     kwargs["skipExportGlyphs"] = designSpaceDoc.lib.get("public.skipExportGlyphs", [])
 
     if kwargs["notdefGlyph"] is None:
-        kwargs["notdefGlyph"] = _getDefaultNotdefGlyph(designSpaceDoc)
+        kwargs["notdefGlyph"] = _getDefaultNotdefGlyph(designSpaceDoc, empty=True)
 
     kwargs["extraSubstitutions"] = defaultdict(set)
     for rule in designSpaceDoc.rules:
diff --git a/Lib/ufo2ft/util.py b/Lib/ufo2ft/util.py
index cf515d6..08aacc7 100644
--- a/Lib/ufo2ft/util.py
+++ b/Lib/ufo2ft/util.py
@@ -475,7 +475,7 @@ def getDefaultMasterFont(designSpaceDoc):
     return defaultSource.font
 
 
-def _getDefaultNotdefGlyph(designSpaceDoc):
+def _getDefaultNotdefGlyph(designSpaceDoc, empty=False):
     from ufo2ft.errors import InvalidDesignSpaceData
 
     try:
@@ -488,6 +488,18 @@ def _getDefaultNotdefGlyph(designSpaceDoc):
             notdefGlyph = baseUfo[".notdef"]
         except KeyError:
             notdefGlyph = None
+        else:
+            if empty:
+                # create a new empty .notdef glyph with the same width/height
+                # as the default master's .notdef glyph to be use for sparse layer
+                # master TTFs, so that it won't participate in gvar interpolation
+                # TODO(anthrotype): Use sentinel values for width/height to
+                # mark the glyph as non-participating for HVAR if/when fonttools
+                # supports that, https://github.com/googlefonts/ufo2ft/issues/501
+                emptyGlyph = _getNewGlyphFactory(notdefGlyph)(".notdef")
+                emptyGlyph.width = notdefGlyph.width
+                emptyGlyph.height = notdefGlyph.height
+                notdefGlyph = emptyGlyph
     return notdefGlyph
 
 


Test patch:
diff --git a/tests/integration_test.py b/tests/integration_test.py
index 4bdbd7b..01ff5b3 100644
--- a/tests/integration_test.py
+++ b/tests/integration_test.py
@@ -7,7 +7,11 @@ from pathlib import Path
 
 import pytest
 from fontTools.pens.boundsPen import BoundsPen
-from fontTools.ttLib.tables._g_l_y_f import OVERLAP_COMPOUND, flagOverlapSimple
+from fontTools.ttLib.tables._g_l_y_f import (
+    OVERLAP_COMPOUND,
+    flagCubic,
+    flagOverlapSimple,
+)
 
 from ufo2ft import (
     compileInterpolatableTTFs,
@@ -450,17 +454,21 @@ class IntegrationTest:
 
         assert ttf["head"].glyphDataFormat == 1
 
+    @staticmethod
+    def drawCurvedContour(glyph):
+        pen = glyph.getPen()
+        pen.moveTo((500, 0))
+        pen.curveTo((500, 277.614), (388.072, 500), (250, 500))
+        pen.curveTo((111.928, 500), (0, 277.614), (0, 0))
+        pen.closePath()
+
     def test_compileVariableTTF_glyf1_not_allQuadratic(self, designspace):
         base_master = designspace.findDefault()
         assert base_master is not None
         # add a glyph with some curveTo to exercise the cu2qu codepath
         glyph = base_master.font.newGlyph("curved")
         glyph.width = 1000
-        pen = glyph.getPen()
-        pen.moveTo((500, 0))
-        pen.curveTo((500, 277.614), (388.072, 500), (250, 500))
-        pen.curveTo((111.928, 500), (0, 277.614), (0, 0))
-        pen.closePath()
+        self.drawCurvedContour(glyph)
 
         vf = compileVariableTTF(designspace, allQuadratic=False)
         expectTTX(vf, "TestVariableFont-TTF-not-allQuadratic.ttx", tables=["glyf"])
@@ -480,6 +488,26 @@ class IntegrationTest:
         assert not ttf["glyf"]["g"].components[0].flags & OVERLAP_COMPOUND
         assert ttf["glyf"]["h"].components[0].flags & OVERLAP_COMPOUND
 
+    def test_compileVariableTTF_notdefGlyph_with_curves(self, designspace):
+        # The test DS contains two full masters (Regular and Bold) and one intermediate
+        # 'sparse' (Medium) master, which does not contain a .notdef glyph and as such
+        # is supposed to inherit one from the default master. If the notdef contains
+        # any curves, an error occured because these are weren't been converted to
+        # quadratic: https://github.com/googlefonts/ufo2ft/issues/501
+
+        # First we draw an additional contour containing cubic curves in the Regular
+        # and Bold's .notdef glyphs
+        for src_idx in (0, 2):
+            notdef = designspace.sources[src_idx].font[".notdef"]
+            self.drawCurvedContour(notdef)
+        assert ".notdef" not in designspace.sources[1].font.layers["Medium"]
+
+        # this must NOT fail!
+        vf = compileVariableTTF(designspace, convertCubics=True, allQuadratic=True)
+
+        # and because allQuadratic=True, we expect .notdef contains no cubic curves
+        assert not any(f & flagCubic for f in vf["glyf"][".notdef"].flags)
+
 
 if __name__ == "__main__":
     sys.exit(pytest.main(sys.argv))