Merge pull request #2177 from fonttools/subset-colrv1

Subset COLRv1 & prune unused CPAL entries
This commit is contained in:
Cosimo Lupo 2021-02-15 12:12:27 +00:00 committed by GitHub
commit 6fa5aa5ea6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 408 additions and 14 deletions

View File

@ -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

View File

@ -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):

View File

@ -14,9 +14,11 @@ class table_C_O_L_R_(DefaultTable.DefaultTable):
ttFont['COLR'][<glyphName>] = <value> 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):

View File

@ -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

View File

@ -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())