Repository: googlefonts/ufo2ft
Pull request #728 (2023-03-10 19:31:43)

[COLRv1] automatically compute clipBoxes if not provided
This require https://github.com/fonttools/fonttools/pull/3027 (yet unreleased so tests will fail until that's bumped)

The intention is being able to add ClipList to COLRv1 table when UFO doesn't include explicitly defined clipBoxes.
Unlike nanoemoji, glyphsLib doesn't pre-compute those, so COLRv1 fonts generated from .glyphs sources via fontmake won't have ClipList (it is optional, but good to have anyway).

After this PR, they will get clipBoxes.

For variable builds, I disabled this because computing clipboxes which are guaranteed to contain and not clip the outlines throughout the variation space is complicated by angle-based rotation/skew variable transforms.


Patch:
diff --git a/Lib/ufo2ft/__init__.py b/Lib/ufo2ft/__init__.py
index a08bb24..c88907f 100644
--- a/Lib/ufo2ft/__init__.py
+++ b/Lib/ufo2ft/__init__.py
@@ -24,6 +24,7 @@ from ufo2ft.preProcessor import (
 )
 from ufo2ft.util import (
     _getDefaultNotdefGlyph,
+    colrClipBoxQuantization,
     ensure_all_sources_have_names,
     init_kwargs,
     location_to_string,
@@ -105,6 +106,8 @@ base_args = dict(
     debugFeatureFile=None,
     notdefGlyph=None,
     colrLayerReuse=True,
+    colrAutoClipBoxes=True,
+    colrClipBoxQuantization=colrClipBoxQuantization,  # Callable[[Font], int]
     feaIncludeDir=None,
 )
 
@@ -267,6 +270,7 @@ compileInterpolatableTTFs_args = {
         flattenComponents=False,
         layerNames=None,
         colrLayerReuse=False,
+        colrAutoClipBoxes=False,
     ),
 }
 
@@ -402,6 +406,7 @@ compileInterpolatableOTFs_args = {
         roundTolerance=None,
         optimizeCFF=CFFOptimization.NONE,
         colrLayerReuse=False,
+        colrAutoClipBoxes=False,
     ),
 }
 
@@ -530,6 +535,7 @@ compileVariableTTF_args = {
         flattenComponents=False,
         excludeVariationTables=(),
         optimizeGvar=True,
+        colrAutoClipBoxes=False,
     ),
 }
 
@@ -635,6 +641,7 @@ compileVariableCFF2_args = {
         roundTolerance=None,
         excludeVariationTables=(),
         optimizeCFF=CFFOptimization.SPECIALIZE,
+        colrAutoClipBoxes=False,
     ),
 }
 
diff --git a/Lib/ufo2ft/outlineCompiler.py b/Lib/ufo2ft/outlineCompiler.py
index 8f21fef..c371bff 100644
--- a/Lib/ufo2ft/outlineCompiler.py
+++ b/Lib/ufo2ft/outlineCompiler.py
@@ -44,6 +44,7 @@ from ufo2ft.fontInfoData import (
 from ufo2ft.util import (
     _copyGlyph,
     calcCodePageRanges,
+    colrClipBoxQuantization,
     makeOfficialGlyphOrder,
     makeUnicodeToGlyphNameMapping,
 )
@@ -102,6 +103,8 @@ class BaseOutlineCompiler:
         tables=None,
         notdefGlyph=None,
         colrLayerReuse=True,
+        colrAutoClipBoxes=True,
+        colrClipBoxQuantization=colrClipBoxQuantization,
     ):
         self.ufo = font
         # use the previously filtered glyphSet, if any
@@ -118,6 +121,8 @@ class BaseOutlineCompiler:
         if tables is not None:
             self.tables = tables
         self.colrLayerReuse = colrLayerReuse
+        self.colrAutoClipBoxes = colrAutoClipBoxes
+        self.colrClipBoxQuantization = colrClipBoxQuantization
         # cached values defined later on
         self._glyphBoundingBoxes = None
         self._fontBoundingBox = None
@@ -165,6 +170,8 @@ class BaseOutlineCompiler:
         if self.meta:
             self.setupTable_meta()
         self.setupOtherTables()
+        if self.colorLayers and self.colrAutoClipBoxes:
+            self._computeCOLRClipBoxes()
         self.importTTX()
 
         return self.otf
@@ -976,6 +983,17 @@ class BaseOutlineCompiler:
                 allowLayerReuse=self.colrLayerReuse,
             )
 
+    def _computeCOLRClipBoxes(self):
+        if (
+            "COLR" not in self.otf
+            or self.otf["COLR"].version == 0
+            or self.otf["COLR"].table.ClipList is not None
+        ):
+            return
+
+        q = self.colrClipBoxQuantization(self.ufo)
+        self.otf["COLR"].table.computeClipBoxes(self.otf.getGlyphSet(), quantization=q)
+
     def setupTable_CPAL(self):
         """
         Compile the CPAL table.
@@ -1090,6 +1108,9 @@ class OutlineOTFCompiler(BaseOutlineCompiler):
         notdefGlyph=None,
         roundTolerance=None,
         optimizeCFF=True,
+        colrLayerReuse=True,
+        colrAutoClipBoxes=True,
+        colrClipBoxQuantization=colrClipBoxQuantization,
     ):
         if roundTolerance is not None:
             self.roundTolerance = float(roundTolerance)
@@ -1102,6 +1123,9 @@ class OutlineOTFCompiler(BaseOutlineCompiler):
             glyphOrder=glyphOrder,
             tables=tables,
             notdefGlyph=notdefGlyph,
+            colrLayerReuse=colrLayerReuse,
+            colrAutoClipBoxes=colrAutoClipBoxes,
+            colrClipBoxQuantization=colrClipBoxQuantization,
         )
         self.optimizeCFF = optimizeCFF
         self._defaultAndNominalWidths = None
diff --git a/Lib/ufo2ft/util.py b/Lib/ufo2ft/util.py
index f6879a4..cabfabd 100644
--- a/Lib/ufo2ft/util.py
+++ b/Lib/ufo2ft/util.py
@@ -16,6 +16,7 @@ from fontTools.pens.reverseContourPen import ReverseContourPen
 from fontTools.pens.transformPen import TransformPen
 
 from ufo2ft.constants import UNICODE_SCRIPT_ALIASES
+from ufo2ft.fontInfoData import getAttrWithFallback
 
 logger = logging.getLogger(__name__)
 
@@ -443,8 +444,6 @@ class _LazyFontName:
         self.font = font
 
     def __str__(self):
-        from ufo2ft.fontInfoData import getAttrWithFallback
-
         return getAttrWithFallback(self.font.info, "postscriptFontName")
 
 
@@ -631,3 +630,20 @@ def describe_ufo(ufo: Any) -> str:
     elif ufo.info.familyName or ufo.info.styleName:
         return " ".join(n for n in (ufo.info.familyName, ufo.info.styleName) if n)
     return repr(ufo)
+
+
+def colrClipBoxQuantization(ufo: Any) -> int:
+    """Integer value to quantize COLR ClipBoxes.
+
+    The higher the value, the greater the chances that that same clipboxes get
+    reused for multiple color glyphs.
+    This is only called when compile option `colrAutoClipBoxes` is True.
+
+    By default, we quantize to 1/10th of the font's upem, rounded to nearest
+    multiple of 10: e.g. 100 unit intervals for 1000 upem, 200 units for 2048 etc.
+
+    The caller can pass their own callback function to return a different value
+    than the default one.
+    """
+    upem = getAttrWithFallback(ufo.info, "unitsPerEm")
+    return int(round(upem / 10, -1))


Test patch:
diff --git a/tests/data/COLRv1Test.ufo/fontinfo.plist b/tests/data/COLRv1Test.ufo/fontinfo.plist
new file mode 100644
index 0000000..42b76d7
--- /dev/null
+++ b/tests/data/COLRv1Test.ufo/fontinfo.plist
@@ -0,0 +1,34 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>ascender</key>
+    <integer>750</integer>
+    <key>capHeight</key>
+    <integer>750</integer>
+    <key>descender</key>
+    <integer>-250</integer>
+    <key>familyName</key>
+    <string>ColorTest</string>
+    <key>guidelines</key>
+    <array/>
+    <key>postscriptBlueValues</key>
+    <array/>
+    <key>postscriptFamilyBlues</key>
+    <array/>
+    <key>postscriptFamilyOtherBlues</key>
+    <array/>
+    <key>postscriptOtherBlues</key>
+    <array/>
+    <key>postscriptStemSnapH</key>
+    <array/>
+    <key>postscriptStemSnapV</key>
+    <array/>
+    <key>styleName</key>
+    <string>Regular</string>
+    <key>unitsPerEm</key>
+    <integer>1000</integer>
+    <key>xHeight</key>
+    <integer>500</integer>
+  </dict>
+</plist>
diff --git a/tests/data/COLRv1Test.ufo/glyphs/a.color1.glif b/tests/data/COLRv1Test.ufo/glyphs/a.color1.glif
new file mode 100644
index 0000000..96bf9d0
--- /dev/null
+++ b/tests/data/COLRv1Test.ufo/glyphs/a.color1.glif
@@ -0,0 +1,20 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="a.color1" format="2">
+  <advance width="500"/>
+  <outline>
+    <contour>
+      <point x="260" y="82" type="curve" smooth="yes"/>
+      <point x="342" y="82"/>
+      <point x="408" y="189"/>
+      <point x="408" y="321" type="curve" smooth="yes"/>
+      <point x="408" y="452"/>
+      <point x="342" y="560"/>
+      <point x="260" y="560" type="curve" smooth="yes"/>
+      <point x="178" y="560"/>
+      <point x="111" y="452"/>
+      <point x="111" y="321" type="curve" smooth="yes"/>
+      <point x="111" y="189"/>
+      <point x="178" y="82"/>
+    </contour>
+  </outline>
+</glyph>
diff --git a/tests/data/COLRv1Test.ufo/glyphs/a.color2.glif b/tests/data/COLRv1Test.ufo/glyphs/a.color2.glif
new file mode 100644
index 0000000..1c08976
--- /dev/null
+++ b/tests/data/COLRv1Test.ufo/glyphs/a.color2.glif
@@ -0,0 +1,12 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="a.color2" format="2">
+  <advance width="500"/>
+  <outline>
+    <contour>
+      <point x="195" y="230" type="line"/>
+      <point x="485" y="230" type="line"/>
+      <point x="485" y="626" type="line"/>
+      <point x="195" y="626" type="line"/>
+    </contour>
+  </outline>
+</glyph>
diff --git a/tests/data/COLRv1Test.ufo/glyphs/a.glif b/tests/data/COLRv1Test.ufo/glyphs/a.glif
new file mode 100644
index 0000000..8553e0b
--- /dev/null
+++ b/tests/data/COLRv1Test.ufo/glyphs/a.glif
@@ -0,0 +1,13 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="a" format="2">
+  <advance width="500"/>
+  <unicode hex="0061"/>
+  <outline>
+    <contour>
+      <point x="36" y="77" type="line"/>
+      <point x="318" y="77" type="line"/>
+      <point x="318" y="458" type="line"/>
+      <point x="36" y="458" type="line"/>
+    </contour>
+  </outline>
+</glyph>
diff --git a/tests/data/COLRv1Test.ufo/glyphs/contents.plist b/tests/data/COLRv1Test.ufo/glyphs/contents.plist
new file mode 100644
index 0000000..0d46f08
--- /dev/null
+++ b/tests/data/COLRv1Test.ufo/glyphs/contents.plist
@@ -0,0 +1,12 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>a</key>
+    <string>a.glif</string>
+    <key>a.color1</key>
+    <string>a.color1.glif</string>
+    <key>a.color2</key>
+    <string>a.color2.glif</string>
+  </dict>
+</plist>
diff --git a/tests/data/COLRv1Test.ufo/glyphs/layerinfo.plist b/tests/data/COLRv1Test.ufo/glyphs/layerinfo.plist
new file mode 100644
index 0000000..3cf39b4
--- /dev/null
+++ b/tests/data/COLRv1Test.ufo/glyphs/layerinfo.plist
@@ -0,0 +1,8 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>color</key>
+    <string>1,0.75,0,0.7</string>
+  </dict>
+</plist>
diff --git a/tests/data/COLRv1Test.ufo/layercontents.plist b/tests/data/COLRv1Test.ufo/layercontents.plist
new file mode 100644
index 0000000..047e152
--- /dev/null
+++ b/tests/data/COLRv1Test.ufo/layercontents.plist
@@ -0,0 +1,10 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <array>
+    <array>
+      <string>foreground</string>
+      <string>glyphs</string>
+    </array>
+  </array>
+</plist>
diff --git a/tests/data/COLRv1Test.ufo/lib.plist b/tests/data/COLRv1Test.ufo/lib.plist
new file mode 100644
index 0000000..6bd0dec
--- /dev/null
+++ b/tests/data/COLRv1Test.ufo/lib.plist
@@ -0,0 +1,76 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>com.defcon.sortDescriptor</key>
+    <array>
+      <dict>
+        <key>ascending</key>
+        <string>Latin-1</string>
+        <key>type</key>
+        <string>characterSet</string>
+      </dict>
+    </array>
+    <key>com.github.googlei18n.ufo2ft.colorLayers</key>
+    <dict>
+      <key>a</key>
+      <dict>
+        <key>Format</key>
+        <integer>1</integer>
+        <key>Layers</key>
+        <array>
+          <dict>
+            <key>Format</key>
+            <integer>10</integer>
+            <key>Glyph</key>
+            <string>a.color1</string>
+            <key>Paint</key>
+            <dict>
+              <key>Format</key>
+              <integer>2</integer>
+              <key>PaletteIndex</key>
+              <integer>0</integer>
+            </dict>
+          </dict>
+          <dict>
+            <key>Format</key>
+            <integer>10</integer>
+            <key>Glyph</key>
+            <string>a.color2</string>
+            <key>Paint</key>
+            <dict>
+              <key>Format</key>
+              <integer>2</integer>
+              <key>PaletteIndex</key>
+              <integer>1</integer>
+            </dict>
+          </dict>
+        </array>
+      </dict>
+    </dict>
+    <key>com.github.googlei18n.ufo2ft.colorPalettes</key>
+    <array>
+      <array>
+        <array>
+          <integer>1</integer>
+          <real>0.3</real>
+          <real>0.1</real>
+          <integer>1</integer>
+        </array>
+        <array>
+          <integer>0</integer>
+          <real>0.4</real>
+          <real>0.8</real>
+          <integer>1</integer>
+        </array>
+      </array>
+    </array>
+    <key>public.glyphOrder</key>
+    <array>
+      <string>space</string>
+      <string>a</string>
+      <string>a.color1</string>
+      <string>a.color2</string>
+    </array>
+  </dict>
+</plist>
diff --git a/tests/data/COLRv1Test.ufo/metainfo.plist b/tests/data/COLRv1Test.ufo/metainfo.plist
new file mode 100644
index 0000000..7b8b34a
--- /dev/null
+++ b/tests/data/COLRv1Test.ufo/metainfo.plist
@@ -0,0 +1,10 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>creator</key>
+    <string>com.github.fonttools.ufoLib</string>
+    <key>formatVersion</key>
+    <integer>3</integer>
+  </dict>
+</plist>
diff --git a/tests/outlineCompiler_test.py b/tests/outlineCompiler_test.py
index 50f3f4c..4dd5f0d 100644
--- a/tests/outlineCompiler_test.py
+++ b/tests/outlineCompiler_test.py
@@ -3,6 +3,8 @@ import os
 
 import pytest
 from cu2qu.ufo import font_to_quadratic
+from fontTools.colorLib.unbuilder import unbuildColrV1
+from fontTools.misc.arrayTools import quantizeRect
 from fontTools.ttLib import TTFont
 from fontTools.ttLib.tables._g_l_y_f import USE_MY_METRICS
 
@@ -901,6 +903,81 @@ class ColrCpalTest:
             "c": [("c.color2", 1), ("c.color1", 0)],
         }
 
+    @pytest.mark.parametrize("compileFunc", [compileTTF, compileOTF])
+    @pytest.mark.parametrize("manualClipBoxes", [True, False])
+    @pytest.mark.parametrize(
+        "autoClipBoxes, quantization",
+        [
+            (True, 1),
+            (True, 32),
+            (True, 100),
+            (False, None),
+        ],
+    )
+    def test_colrv1_computeClipBoxes(
+        self,
+        FontClass,
+        compileFunc,
+        manualClipBoxes,
+        autoClipBoxes,
+        quantization,
+    ):
+        testufo = FontClass(getpath("COLRv1Test.ufo"))
+        assert "com.github.googlei18n.ufo2ft.colorLayers" in testufo.lib
+        assert "com.github.googlei18n.ufo2ft.colorPalettes" in testufo.lib
+        assert "com.github.googlei18n.ufo2ft.colrClipBoxes" not in testufo.lib
+
+        if manualClipBoxes:
+            testufo.lib["com.github.googlei18n.ufo2ft.colrClipBoxes"] = [
+                ("a", (0, 0, 1000, 1000))
+            ]
+
+        result = compileFunc(
+            testufo,
+            colrAutoClipBoxes=autoClipBoxes,
+            colrClipBoxQuantization=lambda _ufo: quantization,
+        )
+        palettes = [
+            [(c.red, c.green, c.blue, c.alpha) for c in p]
+            for p in result["CPAL"].palettes
+        ]
+        assert palettes == [[(255, 76, 26, 255), (0, 102, 204, 255)]]
+
+        colr = result["COLR"].table
+        layers = unbuildColrV1(colr.LayerList, colr.BaseGlyphList)
+        assert layers == {
+            "a": {
+                "Format": 1,
+                "Layers": [
+                    {
+                        "Format": 10,
+                        "Paint": {"Format": 2, "PaletteIndex": 0, "Alpha": 1.0},
+                        "Glyph": "a.color1",
+                    },
+                    {
+                        "Format": 10,
+                        "Paint": {"Format": 2, "PaletteIndex": 1, "Alpha": 1.0},
+                        "Glyph": "a.color2",
+                    },
+                ],
+            }
+        }
+
+        if manualClipBoxes or autoClipBoxes:
+            assert colr.ClipList is not None
+            clipBoxes = {g: clip.as_tuple() for g, clip in colr.ClipList.clips.items()}
+            if manualClipBoxes:
+                # the one that was set manually always prevails
+                assert clipBoxes == {"a": (0, 0, 1000, 1000)}
+            elif autoClipBoxes:
+                # the clipBox that was computed automatically
+                assert clipBoxes == {
+                    "a": quantizeRect((111, 82, 485, 626), quantization)
+                }
+        else:
+            # no clipboxes, neither manual nor automatic
+            assert colr.ClipList is None
+
 
 class CmapTest:
     def test_cmap_BMP(self, testufo):