Merge pull request #2177 from fonttools/subset-colrv1
Subset COLRv1 & prune unused CPAL entries
This commit is contained in:
commit
6fa5aa5ea6
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
|
Loading…
x
Reference in New Issue
Block a user