diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py
index 57f757e0c..a61351e20 100644
--- a/Lib/fontTools/colorLib/builder.py
+++ b/Lib/fontTools/colorLib/builder.py
@@ -21,6 +21,7 @@ from typing import (
TypeVar,
Union,
)
+from fontTools.misc.arrayTools import intRect
from fontTools.misc.fixedTools import fixedToFloat
from fontTools.ttLib.tables import C_O_L_R_
from fontTools.ttLib.tables import C_P_A_L_
@@ -39,6 +40,11 @@ _PaintInput = Union[int, _Kwargs, ot.Paint, Tuple[str, "_PaintInput"]]
_PaintInputList = Sequence[_PaintInput]
_ColorGlyphsDict = Dict[str, Union[_PaintInputList, _PaintInput]]
_ColorGlyphsV0Dict = Dict[str, Sequence[Tuple[str, int]]]
+_ClipBoxInput = Union[
+ Tuple[int, int, int, int, int], # format 1, variable
+ Tuple[int, int, int, int], # format 0, non-variable
+ ot.ClipBox,
+]
MAX_PAINT_COLR_LAYER_COUNT = 255
@@ -183,6 +189,7 @@ def buildCOLR(
glyphMap: Optional[Mapping[str, int]] = None,
varStore: Optional[ot.VarStore] = None,
varIndexMap: Optional[ot.DeltaSetIndexMap] = None,
+ clipBoxes: Optional[Dict[str, _ClipBoxInput]] = None,
) -> C_O_L_R_.table_C_O_L_R_:
"""Build COLR table from color layers mapping.
Args:
@@ -197,6 +204,8 @@ def buildCOLR(
TTFont.getReverseGlyphMap(), to optionally sort base records by GID.
varStore: Optional ItemVarationStore for deltas associated with v1 layer.
varIndexMap: Optional DeltaSetIndexMap for deltas associated with v1 layer.
+ clipBoxes: Optional map of base glyph name to clip box 4- or 5-tuples:
+ (xMin, yMin, xMax, yMax) or (xMin, yMin, xMax, yMax, varIndexBase).
Return:
A new COLR table.
"""
@@ -230,6 +239,10 @@ def buildCOLR(
if version == 0:
self.ColorLayers = self._decompileColorLayersV0(colr)
else:
+ clipBoxes = {
+ name: clipBoxes[name] for name in clipBoxes or {} if name in colorGlyphsV1
+ }
+ colr.ClipList = buildClipList(clipBoxes) if clipBoxes else None
colr.VarIndexMap = varIndexMap
colr.VarStore = varStore
self.table = colr
@@ -237,6 +250,28 @@ def buildCOLR(
return self
+def buildClipList(clipBoxes: Dict[str, _ClipBoxInput]) -> ot.ClipList:
+ clipList = ot.ClipList()
+ clipList.clips = {name: buildClipBox(box) for name, box in clipBoxes.items()}
+ return clipList
+
+
+def buildClipBox(clipBox: _ClipBoxInput) -> ot.ClipBox:
+ if isinstance(clipBox, ot.ClipBox):
+ return clipBox
+ n = len(clipBox)
+ clip = ot.ClipBox()
+ if n < 4 or n > 5:
+ raise ValueError(f"Invalid ClipBox: expected 4 or 5 values, found {n}")
+ clip.xMin, clip.yMin, clip.xMax, clip.yMax = intRect(clipBox[:4])
+ if n == 5:
+ clip.Format = 1
+ clip.VarIndexBase = int(clipBox[4])
+ else:
+ clip.Format = 0
+ return clip
+
+
class ColorPaletteType(enum.IntFlag):
USABLE_WITH_LIGHT_BACKGROUND = 0x0001
USABLE_WITH_DARK_BACKGROUND = 0x0002
diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py
index e33370829..554df3373 100755
--- a/Lib/fontTools/ttLib/tables/otData.py
+++ b/Lib/fontTools/ttLib/tables/otData.py
@@ -1562,6 +1562,7 @@ otData = [
('uint16', 'LayerRecordCount', None, None, 'Number of Layer Records.'),
('LOffset', 'BaseGlyphList', None, 'Version >= 1', 'Offset (from beginning of COLR table) to array of Version-1 Base Glyph records.'),
('LOffset', 'LayerList', None, 'Version >= 1', 'Offset (from beginning of COLR table) to LayerList.'),
+ ('LOffset', 'ClipList', None, 'Version >= 1', 'Offset to ClipList table (may be NULL)'),
('LOffsetTo(DeltaSetIndexMap)', 'VarIndexMap', None, 'Version >= 1', 'Offset to DeltaSetIndexMap table (may be NULL)'),
('LOffset', 'VarStore', None, 'Version >= 1', 'Offset to variation store (may be NULL)'),
]),
@@ -1600,6 +1601,34 @@ otData = [
('LOffset', 'Paint', 'LayerCount', 0, 'Array of offsets to Paint tables, from the start of the LayerList table.'),
]),
+ ('ClipList', [
+ ('uint32', 'ClipCount', None, None, 'Number of Clip records.'),
+ ('struct', 'ClipRecord', 'ClipCount', 0, 'Array of Clip records sorted by glyph ID.'),
+ ]),
+
+ ('ClipRecord', [
+ ('uint16', 'StartGlyphID', None, None, 'First glyph ID in the range.'),
+ ('uint16', 'EndGlyphID', None, None, 'Last glyph ID in the range.'),
+ ('Offset24', 'ClipBox', None, None, 'Offset to a ClipBox table.'),
+ ]),
+
+ ('ClipBoxFormat0', [
+ ('uint8', 'Format', None, None, 'Format for ClipBox without variation: set to 0.'),
+ ('int16', 'xMin', None, None, 'Minimum x of clip box.'),
+ ('int16', 'yMin', None, None, 'Minimum y of clip box.'),
+ ('int16', 'xMax', None, None, 'Maximum x of clip box.'),
+ ('int16', 'yMax', None, None, 'Maximum y of clip box.'),
+ ]),
+
+ ('ClipBoxFormat1', [
+ ('uint8', 'Format', None, None, 'Format for variable ClipBox: set to 1.'),
+ ('int16', 'xMin', None, None, 'Minimum x of clip box.'),
+ ('int16', 'yMin', None, None, 'Minimum y of clip box.'),
+ ('int16', 'xMax', None, None, 'Maximum x of clip box.'),
+ ('int16', 'yMax', None, None, 'Maximum y of clip box.'),
+ ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'),
+ ]),
+
# COLRv1 Affine2x3 uses the same column-major order to serialize a 2D
# Affine Transformation as the one used by fontTools.misc.transform.
# However, for historical reasons, the labels 'xy' and 'yx' are swapped.
diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py
index 339331873..49116cba9 100644
--- a/Lib/fontTools/ttLib/tables/otTables.py
+++ b/Lib/fontTools/ttLib/tables/otTables.py
@@ -5,9 +5,10 @@ OpenType subtables.
Most are constructed upon import from data in otData.py, all are populated with
converter objects from otConverters.py.
"""
+import copy
from enum import IntEnum
import itertools
-from collections import namedtuple
+from collections import defaultdict, namedtuple
from fontTools.misc.py23 import bytesjoin
from fontTools.misc.roundTools import otRound
from fontTools.misc.textTools import pad, safeEval
@@ -1297,6 +1298,144 @@ class BaseGlyphList(BaseTable):
return self.__dict__.copy()
+class ClipBox(getFormatSwitchingBaseTableClass("uint8")):
+
+ def as_tuple(self):
+ return tuple(getattr(self, conv.name) for conv in self.getConverters())
+
+ def __repr__(self):
+ return f"{self.__class__.__name__}{self.as_tuple()}"
+
+
+class ClipList(BaseTable):
+
+ def populateDefaults(self, propagator=None):
+ if not hasattr(self, "clips"):
+ self.clips = {}
+
+ def postRead(self, rawTable, font):
+ clips = {}
+ glyphOrder = font.getGlyphOrder()
+ for i, rec in enumerate(rawTable["ClipRecord"]):
+ rangesOverlap = False
+ for glyphID in range(rec.StartGlyphID, rec.EndGlyphID + 1):
+ try:
+ glyph = glyphOrder[glyphID]
+ except IndexError:
+ continue
+ if glyph not in clips:
+ clips[glyph] = copy.copy(rec.ClipBox)
+ else:
+ rangesOverlap = True
+ if rangesOverlap:
+ log.warning(
+ "ClipRecord %i overlap previous records; "
+ "redefined clip boxes are skipped",
+ i,
+ )
+ self.clips = clips
+
+ def groups(self):
+ glyphsByClip = defaultdict(list)
+ uniqueClips = {}
+ for glyphName, clipBox in self.clips.items():
+ key = hash(clipBox.as_tuple())
+ glyphsByClip[key].append(glyphName)
+ if key not in uniqueClips:
+ uniqueClips[key] = clipBox
+ return {
+ frozenset(glyphs): uniqueClips[key]
+ for key, glyphs in glyphsByClip.items()
+ }
+
+ def preWrite(self, font):
+ if not hasattr(self, "clips"):
+ self.clips = {}
+ clipBoxRanges = {}
+ glyphMap = font.getReverseGlyphMap()
+ for glyphs, clipBox in self.groups().items():
+ glyphIDs = sorted(
+ glyphMap[glyphName] for glyphName in glyphs
+ if glyphName in glyphMap
+ )
+ if not glyphIDs:
+ continue
+ last = glyphIDs[0]
+ ranges = [[last]]
+ for glyphID in glyphIDs[1:]:
+ if glyphID != last + 1:
+ ranges[-1].append(last)
+ ranges.append([glyphID])
+ last = glyphID
+ ranges[-1].append(last)
+ for start, end in ranges:
+ assert (start, end) not in clipBoxRanges
+ clipBoxRanges[(start, end)] = clipBox
+
+ clipRecords = []
+ for (start, end), clipBox in sorted(clipBoxRanges.items()):
+ record = ClipRecord()
+ record.StartGlyphID = start
+ record.EndGlyphID = end
+ record.ClipBox = clipBox
+ clipRecords.append(record)
+ rawTable = {
+ "ClipCount": len(clipRecords),
+ "ClipRecord": clipRecords,
+ }
+ return rawTable
+
+ def toXML(self, xmlWriter, font, attrs=None, name=None):
+ tableName = name if name else self.__class__.__name__
+ if attrs is None:
+ attrs = []
+ xmlWriter.begintag(tableName, attrs)
+ xmlWriter.newline()
+ # sort clips alphabetically to ensure deterministic XML dump
+ for glyphs, clipBox in sorted(
+ self.groups().items(), key=lambda item: min(item[0])
+ ):
+ xmlWriter.begintag("Clip")
+ xmlWriter.newline()
+ for glyphName in sorted(glyphs):
+ xmlWriter.simpletag("Glyph", value=glyphName)
+ xmlWriter.newline()
+ xmlWriter.begintag("ClipBox", [("Format", clipBox.Format)])
+ xmlWriter.newline()
+ clipBox.toXML2(xmlWriter, font)
+ xmlWriter.endtag("ClipBox")
+ xmlWriter.newline()
+ xmlWriter.endtag("Clip")
+ xmlWriter.newline()
+ xmlWriter.endtag(tableName)
+ xmlWriter.newline()
+
+ def fromXML(self, name, attrs, content, font):
+ clips = getattr(self, "clips", None)
+ if clips is None:
+ self.clips = clips = {}
+ assert name == "Clip"
+ glyphs = []
+ clipBox = None
+ for elem in content:
+ if not isinstance(elem, tuple):
+ continue
+ name, attrs, content = elem
+ if name == "Glyph":
+ glyphs.append(attrs["value"])
+ elif name == "ClipBox":
+ clipBox = ClipBox()
+ clipBox.Format = safeEval(attrs["Format"])
+ for elem in content:
+ if not isinstance(elem, tuple):
+ continue
+ name, attrs, content = elem
+ clipBox.fromXML(name, attrs, content, font)
+ if clipBox:
+ for glyphName in glyphs:
+ clips[glyphName] = clipBox
+
+
class ExtendMode(IntEnum):
PAD = 0
REPEAT = 1
diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py
index 987841aec..205ef053e 100644
--- a/Tests/colorLib/builder_test.py
+++ b/Tests/colorLib/builder_test.py
@@ -1660,6 +1660,37 @@ class BuildCOLRTest(object):
assert paint.Format == ot.PaintFormat.PaintGlyph
assert paint.Paint.Format == ot.PaintFormat.PaintSolid
+ def test_build_clip_list(self):
+ colr = builder.buildCOLR(
+ {
+ "a": (
+ ot.PaintFormat.PaintGlyph,
+ (ot.PaintFormat.PaintSolid, 0),
+ "b",
+ ),
+ "c": (
+ ot.PaintFormat.PaintGlyph,
+ (ot.PaintFormat.PaintSolid, 1),
+ "d",
+ ),
+ },
+ clipBoxes={
+ "a": (0, 0, 1000, 1000, 0), # optional 5th: varIndexBase
+ "c": (-100.8, -200.4, 1100.1, 1200.5), # floats get rounded
+ "e": (0, 0, 10, 10), # missing base glyph 'e' is ignored
+ },
+ )
+
+ clipBoxes = colr.table.ClipList.clips
+ assert [
+ (baseGlyph, clipBox.as_tuple()) for baseGlyph, clipBox in clipBoxes.items()
+ ] == [
+ ("a", (0, 0, 1000, 1000, 0)),
+ ("c", (-101, -201, 1101, 1201)),
+ ]
+ assert clipBoxes["a"].Format == 1
+ assert clipBoxes["c"].Format == 0
+
class TrickyRadialGradientTest:
@staticmethod
diff --git a/Tests/ttLib/tables/C_O_L_R_test.py b/Tests/ttLib/tables/C_O_L_R_test.py
index f170dbe58..63fc96c54 100644
--- a/Tests/ttLib/tables/C_O_L_R_test.py
+++ b/Tests/ttLib/tables/C_O_L_R_test.py
@@ -104,13 +104,14 @@ COLR_V1_SAMPLE = (
(b"\x00\x01", "Version (1)"),
(b"\x00\x01", "BaseGlyphRecordCount (1)"),
(
- b"\x00\x00\x00\x1e",
- "Offset to BaseGlyphRecordArray from beginning of table (30)",
+ b"\x00\x00\x00\x22",
+ "Offset to BaseGlyphRecordArray from beginning of table (34)",
),
- (b"\x00\x00\x00\x24", "Offset to LayerRecordArray from beginning of table (36)"),
+ (b"\x00\x00\x00\x28", "Offset to LayerRecordArray from beginning of table (40)"),
(b"\x00\x03", "LayerRecordCount (3)"),
- (b"\x00\x00\x00\x30", "Offset to BaseGlyphList from beginning of table (48)"),
- (b"\x00\x00\x00\x9b", "Offset to LayerList from beginning of table (155)"),
+ (b"\x00\x00\x00\x34", "Offset to BaseGlyphList from beginning of table (52)"),
+ (b"\x00\x00\x00\x9f", "Offset to LayerList from beginning of table (159)"),
+ (b"\x00\x00\x01\x62", "Offset to ClipList (354)"),
(b"\x00\x00\x00\x00", "Offset to DeltaSetIndexMap (NULL)"),
(b"\x00\x00\x00\x00", "Offset to VarStore (NULL)"),
(b"\x00\x06", "BaseGlyphRecord[0].BaseGlyph (6)"),
@@ -295,6 +296,28 @@ COLR_V1_SAMPLE = (
(b"\x02", "LayerList.Paint[0].Paint.Paint.Paint.Paint.Format (2)"),
(b"\x00\x02", "Paint.PaletteIndex (2)"),
(b" \x00", "Paint.Alpha (0.5)"),
+
+ # ClipList
+ (b'\x00\x00\x00\x02', "ClipList.ClipCount (2)"),
+ (b'\x00\x0a', "ClipRecord[0].StartGlyphID (10)"),
+ (b'\x00\x0a', "ClipRecord[0].EndGlyphID (10)"),
+ (b'\x00\x00\x12', "Offset to ClipBox subtable from beginning of ClipList (18)"),
+ (b'\x00\x0e', "ClipRecord[1].StartGlyphID (14)"),
+ (b'\x00\x0f', "ClipRecord[1].EndGlyphID (15)"),
+ (b'\x00\x00\x1f', "Offset to ClipBox subtable from beginning of ClipList (31)"),
+
+ (b'\x01', "ClipBox.Format (1)"),
+ (b'\x00\x00', "ClipBox.xMin (0)"),
+ (b'\x00\x00', "ClipBox.yMin (0)"),
+ (b'\x01\xf4', "ClipBox.xMax (500)"),
+ (b'\x01\xf4', "ClipBox.yMax (500)"),
+ (b'\x00\x00\x00\t', "ClipBox.VarIndexBase (9)"),
+
+ (b'\x00', "ClipBox.Format (0)"),
+ (b'\x00\x00', "ClipBox.xMin (0)"),
+ (b'\x00\x00', "ClipBox.yMin (0)"),
+ (b'\x03\xe8', "ClipBox.xMax (1000)"),
+ (b'\x03\xe8', "ClipBox.yMax (1000)"),
)
COLR_V1_DATA = b"".join(t[0] for t in COLR_V1_SAMPLE)
@@ -482,6 +505,28 @@ COLR_V1_XML = [
' ',
" ",
"",
+ "",
+ " ",
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ " ",
+ " ",
+ " ",
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ " ",
+ " ",
+ "",
]
COLR_V1_VAR_XML = [
@@ -495,6 +540,10 @@ COLR_V1_VAR_XML = [
' ',
' ',
' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
"",
'',
' ',
@@ -511,13 +560,14 @@ COLR_V1_VAR_XML = [
" ",
" ",
' ',
- " ",
+ " ",
' ',
" ",
' ',
' ',
' ',
' ',
+ ' ',
" ",
' ',
" ",