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 = [ " ", " ", ' ', - " ", + " ", ' ', " ", ' ', ' ', ' ', ' ', + ' ', " ", ' ', " ",