diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 90fdd4f1f..821244af0 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -229,7 +229,7 @@ def buildCOLR( self.version = colr.Version = version if version == 0: - self._fromOTTable(colr) + self.ColorLayers = self._decompileColorLayersV0(colr) else: colr.VarStore = varStore self.table = colr diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index 82605d51b..8162c09c2 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -14,7 +14,7 @@ import sys import struct import array import logging -from collections import Counter +from collections import Counter, defaultdict from types import MethodType __usage__ = "pyftsubset font-file [glyph...] [--option=value]..." @@ -1983,27 +1983,130 @@ def subset_glyphs(self, s): else: assert False, "unknown 'prop' format %s" % prop.Format +def _paint_glyph_names(paint, colr): + result = set() + + def callback(paint): + if paint.Format in { + otTables.PaintFormat.PaintGlyph, + otTables.PaintFormat.PaintColrGlyph, + }: + result.add(paint.Glyph) + + paint.traverse(colr, callback) + return result + @_add_method(ttLib.getTableClass('COLR')) def closure_glyphs(self, s): + if self.version > 0: + # on decompiling COLRv1, we only keep around the raw otTables + # but for subsetting we need dicts with fully decompiled layers; + # we store them temporarily in the C_O_L_R_ instance and delete + # them after we have finished subsetting. + self.ColorLayers = self._decompileColorLayersV0(self.table) + self.ColorLayersV1 = { + rec.BaseGlyph: rec.Paint + for rec in self.table.BaseGlyphV1List.BaseGlyphV1Record + } + decompose = s.glyphs while decompose: layers = set() for g in decompose: - for l in self.ColorLayers.get(g, []): - layers.add(l.name) + for layer in self.ColorLayers.get(g, []): + layers.add(layer.name) + + if self.version > 0: + paint = self.ColorLayersV1.get(g) + if paint is not None: + layers.update(_paint_glyph_names(paint, self.table)) + layers -= s.glyphs s.glyphs.update(layers) decompose = layers @_add_method(ttLib.getTableClass('COLR')) def subset_glyphs(self, s): - self.ColorLayers = {g: self.ColorLayers[g] for g in s.glyphs if g in self.ColorLayers} - return bool(self.ColorLayers) + from fontTools.colorLib.unbuilder import unbuildColrV1 + from fontTools.colorLib.builder import buildColrV1, populateCOLRv0 + + self.ColorLayers = {g: self.ColorLayers[g] for g in s.glyphs if g in self.ColorLayers} + if self.version == 0: + return bool(self.ColorLayers) + + colorGlyphsV1 = unbuildColrV1(self.table.LayerV1List, self.table.BaseGlyphV1List) + self.table.LayerV1List, self.table.BaseGlyphV1List = buildColrV1( + {g: colorGlyphsV1[g] for g in colorGlyphsV1 if g in s.glyphs} + ) + del self.ColorLayersV1 + + layersV0 = self.ColorLayers + if not self.table.BaseGlyphV1List.BaseGlyphV1Record: + # no more COLRv1 glyphs: downgrade to version 0 + self.version = 0 + del self.table + return bool(layersV0) + + if layersV0: + populateCOLRv0( + self.table, + { + g: [(layer.name, layer.colorID) for layer in layersV0[g]] + for g in layersV0 + }, + ) + del self.ColorLayers + + # TODO: also prune ununsed varIndices in COLR.VarStore + return True -# TODO: prune unused palettes @_add_method(ttLib.getTableClass('CPAL')) def prune_post_subset(self, font, options): - return True + colr = font.get("COLR") + if not colr: # drop CPAL if COLR was subsetted to empty + return False + + colors_by_index = defaultdict(list) + + def collect_colors_by_index(paint): + if hasattr(paint, "Color"): # either solid colors... + colors_by_index[paint.Color.PaletteIndex].append(paint.Color) + elif hasattr(paint, "ColorLine"): # ... or gradient color stops + for stop in paint.ColorLine.ColorStop: + colors_by_index[stop.Color.PaletteIndex].append(stop.Color) + + if colr.version == 0: + for layers in colr.ColorLayers.values(): + for layer in layers: + colors_by_index[layer.colorID].append(layer) + else: + if colr.table.LayerRecordArray: + for layer in colr.table.LayerRecordArray.LayerRecord: + colors_by_index[layer.PaletteIndex].append(layer) + for record in colr.table.BaseGlyphV1List.BaseGlyphV1Record: + record.Paint.traverse(colr.table, collect_colors_by_index) + + retained_palette_indices = set(colors_by_index.keys()) + for palette in self.palettes: + palette[:] = [c for i, c in enumerate(palette) if i in retained_palette_indices] + assert len(palette) == len(retained_palette_indices) + + for new_index, old_index in enumerate(sorted(retained_palette_indices)): + for record in colors_by_index[old_index]: + if hasattr(record, "colorID"): # v0 + record.colorID = new_index + elif hasattr(record, "PaletteIndex"): # v1 + record.PaletteIndex = new_index + else: + raise AssertionError(record) + + self.numPaletteEntries = len(self.palettes[0]) + + if self.version == 1: + self.paletteEntryLabels = [ + label for i, label in self.paletteEntryLabels if i in retained_palette_indices + ] + return bool(self.numPaletteEntries) @_add_method(otTables.MathGlyphConstruction) def closure_glyphs(self, glyphs): diff --git a/Lib/fontTools/ttLib/tables/C_O_L_R_.py b/Lib/fontTools/ttLib/tables/C_O_L_R_.py index 7a9442ded..db490520c 100644 --- a/Lib/fontTools/ttLib/tables/C_O_L_R_.py +++ b/Lib/fontTools/ttLib/tables/C_O_L_R_.py @@ -14,9 +14,11 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): ttFont['COLR'][] = will set the color layers for any glyph. """ - def _fromOTTable(self, table): - self.version = 0 - self.ColorLayers = colorLayerLists = {} + @staticmethod + def _decompileColorLayersV0(table): + if not table.LayerRecordArray: + return {} + colorLayerLists = {} layerRecords = table.LayerRecordArray.LayerRecord numLayerRecords = len(layerRecords) for baseRec in table.BaseGlyphRecordArray.BaseGlyphRecord: @@ -31,6 +33,7 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): LayerRecord(layerRec.LayerGlyph, layerRec.PaletteIndex) ) colorLayerLists[baseGlyph] = layers + return colorLayerLists def _toOTTable(self, ttFont): from . import otTables @@ -61,12 +64,12 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): table = tableClass() table.decompile(reader, ttFont) - if table.Version == 0: - self._fromOTTable(table) + self.version = table.Version + if self.version == 0: + self.ColorLayers = self._decompileColorLayersV0(table) else: # for new versions, keep the raw otTables around self.table = table - self.version = table.Version def compile(self, ttFont): from .otBase import OTTableWriter @@ -120,6 +123,7 @@ class table_C_O_L_R_(DefaultTable.DefaultTable): self.table = tableClass() self.table.fromXML(name, attrs, content, ttFont) self.table.populateDefaults() + self.version = self.table.Version def __getitem__(self, glyphName): if not isinstance(glyphName, str): diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py index 43c40d5c5..008909bdd 100644 --- a/Lib/fontTools/ttLib/tables/otTables.py +++ b/Lib/fontTools/ttLib/tables/otTables.py @@ -1367,6 +1367,40 @@ class Paint(getFormatSwitchingBaseTableClass("uint8")): xmlWriter.endtag(tableName) xmlWriter.newline() + def getChildren(self, colr): + if self.Format == PaintFormat.PaintColrLayers: + return colr.LayerV1List.Paint[ + self.FirstLayerIndex : self.FirstLayerIndex + self.NumLayers + ] + + if self.Format == PaintFormat.PaintColrGlyph: + for record in colr.BaseGlyphV1List.BaseGlyphV1Record: + if record.BaseGlyph == self.Glyph: + return [record.Paint] + else: + raise KeyError(f"{self.Glyph!r} not in colr.BaseGlyphV1List") + + children = [] + for conv in self.getConverters(): + if conv.tableClass is not None and issubclass(conv.tableClass, type(self)): + children.append(getattr(self, conv.name)) + + return children + + def traverse(self, colr: COLR, callback): + """Depth-first traversal of graph rooted at self, callback on each node.""" + if not callable(callback): + raise TypeError("callback must be callable") + stack = [self] + visited = set() + while stack: + current = stack.pop() + if id(current) in visited: + continue + callback(current) + visited.add(id(current)) + stack.extend(reversed(current.getChildren(colr))) + # For each subtable format there is a class. However, we don't really distinguish # between "field name" and "format name": often these are the same. Yet there's diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py index d37634f12..370f9b626 100644 --- a/Tests/subset/subset_test.py +++ b/Tests/subset/subset_test.py @@ -3,7 +3,9 @@ from fontTools.misc.py23 import * from fontTools.misc.testTools import getXML from fontTools import subset from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen from fontTools.ttLib import TTFont, newTable +from fontTools.ttLib.tables import otTables as ot from fontTools.misc.loggingTools import CapturingLogHandler import difflib import logging @@ -930,5 +932,256 @@ def test_subset_empty_glyf(tmp_path, ttf_path): assert all(loc == 0 for loc in loca) +@pytest.fixture +def colrv1_path(tmp_path): + base_glyph_names = ["uni%04X" % i for i in range(0xE000, 0xE000 + 10)] + layer_glyph_names = ["glyph%05d" % i for i in range(10, 20)] + glyph_order = [".notdef"] + base_glyph_names + layer_glyph_names + + pen = TTGlyphPen(glyphSet=None) + pen.moveTo((0, 0)) + pen.lineTo((0, 500)) + pen.lineTo((500, 500)) + pen.lineTo((500, 0)) + pen.closePath() + glyph = pen.glyph() + glyphs = {g: glyph for g in glyph_order} + + fb = FontBuilder(unitsPerEm=1024, isTTF=True) + fb.setupGlyphOrder(glyph_order) + fb.setupCharacterMap({int(name[3:], 16): name for name in base_glyph_names}) + fb.setupGlyf(glyphs) + fb.setupHorizontalMetrics({g: (500, 0) for g in glyph_order}) + fb.setupHorizontalHeader() + fb.setupOS2() + fb.setupPost() + fb.setupNameTable({"familyName": "TestCOLRv1", "styleName": "Regular"}) + + fb.setupCOLR( + { + "uniE000": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, 0), + "Glyph": "glyph00010", + }, + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, (2, 0.3)), + "Glyph": "glyph00011", + }, + ], + ), + "uniE001": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": ot.PaintFormat.PaintTransform, + "Paint": { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": { + "Format": ot.PaintFormat.PaintRadialGradient, + "x0": 250, + "y0": 250, + "r0": 250, + "x1": 200, + "y1": 200, + "r1": 0, + "ColorLine": { + "ColorStop": [(0.0, 1), (1.0, 2)], + "Extend": "repeat", + }, + }, + "Glyph": "glyph00012", + }, + "Transform": (0.7071, 0.7071, -0.7071, 0.7071, 0, 0), + }, + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, (1, 0.5)), + "Glyph": "glyph00013", + }, + ], + ), + "uniE002": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": { + "Format": ot.PaintFormat.PaintLinearGradient, + "x0": 0, + "y0": 0, + "x1": 500, + "y1": 500, + "x2": -500, + "y2": 500, + "ColorLine": {"ColorStop": [(0.0, 1), (1.0, 2)]}, + }, + "Glyph": "glyph00014", + }, + { + "Format": ot.PaintFormat.PaintTransform, + "Paint": { + "Format": ot.PaintFormat.PaintGlyph, + "Paint": (ot.PaintFormat.PaintSolid, 1), + "Glyph": "glyph00015", + }, + "Transform": (1, 0, 0, 1, 400, 400), + }, + ], + ), + "uniE003": { + "Format": ot.PaintFormat.PaintRotate, + "Paint": { + "Format": ot.PaintFormat.PaintColrGlyph, + "Glyph": "uniE001", + }, + "angle": 45, + "centerX": 250, + "centerY": 250, + }, + "uniE004": [ + ("glyph00016", 1), + ("glyph00017", 2), + ], + }, + ) + fb.setupCPAL( + [ + [ + (1.0, 0.0, 0.0, 1.0), # red + (0.0, 1.0, 0.0, 1.0), # green + (0.0, 0.0, 1.0, 1.0), # blue + ], + ], + ) + + output_path = tmp_path / "TestCOLRv1.ttf" + fb.save(output_path) + + return output_path + + +def test_subset_COLRv1_and_CPAL(colrv1_path): + subset_path = colrv1_path.parent / (colrv1_path.name + ".subset") + + subset.main( + [ + str(colrv1_path), + "--glyph-names", + f"--output-file={subset_path}", + "--unicodes=E002,E003,E004", + ] + ) + subset_font = TTFont(subset_path) + + glyph_set = set(subset_font.getGlyphOrder()) + + # uniE000 and its children are excluded from subset + assert "uniE000" not in glyph_set + assert "glyph00010" not in glyph_set + assert "glyph00011" not in glyph_set + + # uniE001 and children are pulled in indirectly as PaintColrGlyph by uniE003 + assert "uniE001" in glyph_set + assert "glyph00012" in glyph_set + assert "glyph00013" in glyph_set + + assert "uniE002" in glyph_set + assert "glyph00014" in glyph_set + assert "glyph00015" in glyph_set + + assert "uniE003" in glyph_set + + assert "uniE004" in glyph_set + assert "glyph00016" in glyph_set + assert "glyph00017" in glyph_set + + assert "COLR" in subset_font + colr = subset_font["COLR"].table + assert colr.Version == 1 + assert len(colr.BaseGlyphRecordArray.BaseGlyphRecord) == 1 + assert len(colr.BaseGlyphV1List.BaseGlyphV1Record) == 3 # was 4 + + base = colr.BaseGlyphV1List.BaseGlyphV1Record[0] + assert base.BaseGlyph == "uniE001" + layers = colr.LayerV1List.Paint[ + base.Paint.FirstLayerIndex: base.Paint.FirstLayerIndex + base.Paint.NumLayers + ] + assert len(layers) == 2 + # check v1 palette indices were remapped + assert layers[0].Paint.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 0 + assert layers[0].Paint.Paint.ColorLine.ColorStop[1].Color.PaletteIndex == 1 + assert layers[1].Paint.Color.PaletteIndex == 0 + + baseRecV0 = colr.BaseGlyphRecordArray.BaseGlyphRecord[0] + assert baseRecV0.BaseGlyph == "uniE004" + layersV0 = colr.LayerRecordArray.LayerRecord + assert len(layersV0) == 2 + # check v0 palette indices were remapped + assert layersV0[0].PaletteIndex == 0 + assert layersV0[1].PaletteIndex == 1 + + assert "CPAL" in subset_font + cpal = subset_font["CPAL"] + assert [ + tuple(v / 255 for v in (c.red, c.green, c.blue, c.alpha)) + for c in cpal.palettes[0] + ] == [ + # the first color 'red' was pruned + (0.0, 1.0, 0.0, 1.0), # green + (0.0, 0.0, 1.0, 1.0), # blue + ] + + +def test_subset_COLRv1_and_CPAL_drop_empty(colrv1_path): + subset_path = colrv1_path.parent / (colrv1_path.name + ".subset") + + subset.main( + [ + str(colrv1_path), + "--glyph-names", + f"--output-file={subset_path}", + "--glyphs=glyph00010", + ] + ) + subset_font = TTFont(subset_path) + + glyph_set = set(subset_font.getGlyphOrder()) + + assert "glyph00010" in glyph_set + assert "uniE000" not in glyph_set + + assert "COLR" not in subset_font + assert "CPAL" not in subset_font + + +def test_subset_COLRv1_downgrade_version(colrv1_path): + subset_path = colrv1_path.parent / (colrv1_path.name + ".subset") + + subset.main( + [ + str(colrv1_path), + "--glyph-names", + f"--output-file={subset_path}", + "--unicodes=E004", + ] + ) + subset_font = TTFont(subset_path) + + assert set(subset_font.getGlyphOrder()) == { + ".notdef", + "uniE004", + "glyph00016", + "glyph00017", + } + + assert "COLR" in subset_font + assert subset_font["COLR"].version == 0 + + if __name__ == "__main__": sys.exit(unittest.main())