Repository: fonttools/fonttools

Issue:
Subsetter does not remove empty feature
QUESTION:
Empty features (lookupCount==0) are not removed in any case.
If the feature is a stylistic set with an UI name (FeatureParams), the feature is kept even if no lookups are assigned.
I think the source for this behaviour is here:
subset.__init__py lines 1298
```python
@_add_method(otTables.Feature)
def subset_lookups(self, lookup_indices):
	""""Returns True if feature is non-empty afterwards."""
	self.LookupListIndex = [l for l in self.LookupListIndex
				  if l in lookup_indices]
	# Now map them.
	self.LookupListIndex = [lookup_indices.index(l)
				for l in self.LookupListIndex]
	self.LookupCount = len(self.LookupListIndex)
	return self.LookupCount or self.FeatureParams
``` 
Is this intended and if so, what is the background?
Thanx
Eigi


Discussion:
yes, I think it's intentional, given that the mere presence of features carry meaning for shaping engines, e.g. for complex indic scripts. But @behdad can confirm
> yes, I think it's intentional, given that the mere presence of features carry meaning for shaping engines, e.g. for complex indic scripts.

Right. But the block after what Eigi quoted seems to be more relevant for that.
https://github.com/fonttools/fonttools/blob/ee2aa264d5e99158d71dc6a279e38ba6021e557a/Lib/fontTools/subset/__init__.py#L1313-L1322

As this line says clearly:
```
	return self.LookupCount or self.FeatureParams
```
the mere presence of `FeatureParams` is enough to retain a feature. That's useful for the `size` feature, and only that feature, but useless for pretty much all other features. So I suggest we adjust the logic to reflect that.
cc @garretrieger @qxliu76 to keep HB subsetter in sync.
ah ok, I mis-remembered. We do drop empty feature records, except for "pref"

https://github.com/fonttools/fonttools/blob/ee2aa264d5e99158d71dc6a279e38ba6021e557a/Lib/fontTools/subset/__init__.py#L1313-L1322

> the mere presence of FeatureParams is enough to retain a feature. That's useful for the size feature, and only that feature

so the fix would be drop empty features (no lookups) unless they have FeatureParams and the tag is "size", correct?
> so the fix would be drop empty features (no lookups) unless they have FeatureParams and the tag is "size", correct?

That's my current inclination.

Resolving patch:
diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py
--- a/Lib/fontTools/subset/__init__.py
+++ b/Lib/fontTools/subset/__init__.py
@@ -1308,7 +1308,13 @@ def subset_lookups(self, lookup_indices):
 	self.LookupListIndex = [lookup_indices.index(l)
 				for l in self.LookupListIndex]
 	self.LookupCount = len(self.LookupListIndex)
-	return self.LookupCount or self.FeatureParams
+	# keep 'size' feature even if it contains no lookups; but drop any other
+	# empty feature (e.g. FeatureParams for stylistic set names)
+	# https://github.com/fonttools/fonttools/issues/2324
+	return (
+	  self.LookupCount or
+	  isinstance(self.FeatureParams, otTables.FeatureParamsSize)
+	)
 
 @_add_method(otTables.FeatureList)
 def subset_lookups(self, lookup_indices):


Test patch:
diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py
--- a/Tests/subset/subset_test.py
+++ b/Tests/subset/subset_test.py
@@ -1261,5 +1261,58 @@ def test_subset_COLRv1_drop_all_v0_glyphs(colrv1_path):
     assert colr.table.LayerRecordCount is 0
 
 
+def test_subset_keep_size_drop_empty_stylistic_set():
+    fb = FontBuilder(unitsPerEm=1000, isTTF=True)
+    glyph_order = [".notdef", "a", "b", "b.ss01"]
+    fb.setupGlyphOrder(glyph_order)
+    fb.setupGlyf({g: TTGlyphPen(None).glyph() for g in glyph_order})
+    fb.setupCharacterMap({ord("a"): "a", ord("b"): "b"})
+    fb.setupHorizontalMetrics({g: (500, 0) for g in glyph_order})
+    fb.setupHorizontalHeader()
+    fb.setupOS2()
+    fb.setupPost()
+    fb.setupNameTable({"familyName": "TestKeepSizeFeature", "styleName": "Regular"})
+    fb.addOpenTypeFeatures("""
+        feature size {
+          parameters 10.0 0;
+        } size;
+        feature ss01 {
+          featureNames {
+            name "Alternate b";
+          };
+          sub b by b.ss01;
+        } ss01;
+    """)
+
+    buf = io.BytesIO()
+    fb.save(buf)
+    buf.seek(0)
+
+    font = TTFont(buf)
+
+    gpos_features = font["GPOS"].table.FeatureList.FeatureRecord
+    assert gpos_features[0].FeatureTag == "size"
+    assert isinstance(gpos_features[0].Feature.FeatureParams, ot.FeatureParamsSize)
+    assert gpos_features[0].Feature.LookupCount == 0
+    gsub_features = font["GSUB"].table.FeatureList.FeatureRecord
+    assert gsub_features[0].FeatureTag == "ss01"
+    assert isinstance(
+        gsub_features[0].Feature.FeatureParams, ot.FeatureParamsStylisticSet
+    )
+
+    options = subset.Options(layout_features=["*"])
+    subsetter = subset.Subsetter(options)
+    subsetter.populate(unicodes=[ord("a")])
+    subsetter.subset(font)
+
+    # empty size feature was kept
+    gpos_features = font["GPOS"].table.FeatureList.FeatureRecord
+    assert gpos_features[0].FeatureTag == "size"
+    assert isinstance(gpos_features[0].Feature.FeatureParams, ot.FeatureParamsSize)
+    assert gpos_features[0].Feature.LookupCount == 0
+    # empty ss01 feature was dropped
+    assert font["GSUB"].table.FeatureList.FeatureCount == 0
+
+
 if __name__ == "__main__":
     sys.exit(unittest.main())