""" colorLib.builder: Build COLR/CPAL tables from scratch """ import collections import copy import enum from functools import partial from math import ceil, log from typing import ( Any, Dict, Generator, Iterable, List, Mapping, Optional, Sequence, Tuple, Type, TypeVar, Union, ) from fontTools.misc.arrayTools import intRect from fontTools.misc.fixedTools import fixedToFloat from fontTools.misc.treeTools import build_n_ary_tree from fontTools.ttLib.tables import C_O_L_R_ from fontTools.ttLib.tables import C_P_A_L_ from fontTools.ttLib.tables import _n_a_m_e from fontTools.ttLib.tables import otTables as ot from fontTools.ttLib.tables.otTables import ExtendMode, CompositeMode from .errors import ColorLibError from .geometry import round_start_circle_stable_containment from .table_builder import BuildCallback, TableBuilder # TODO move type aliases to colorLib.types? T = TypeVar("T") _Kwargs = Mapping[str, Any] _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 _DEFAULT_ALPHA = 1.0 _MAX_REUSE_LEN = 32 def _beforeBuildPaintRadialGradient(paint, source): x0 = source["x0"] y0 = source["y0"] r0 = source["r0"] x1 = source["x1"] y1 = source["y1"] r1 = source["r1"] # TODO apparently no builder_test confirms this works (?) # avoid abrupt change after rounding when c0 is near c1's perimeter c = round_start_circle_stable_containment((x0, y0), r0, (x1, y1), r1) x0, y0 = c.centre r0 = c.radius # update source to ensure paint is built with corrected values source["x0"] = x0 source["y0"] = y0 source["r0"] = r0 source["x1"] = x1 source["y1"] = y1 source["r1"] = r1 return paint, source def _defaultColorStop(): colorStop = ot.ColorStop() colorStop.Alpha = _DEFAULT_ALPHA return colorStop def _defaultVarColorStop(): colorStop = ot.VarColorStop() colorStop.Alpha = _DEFAULT_ALPHA return colorStop def _defaultColorLine(): colorLine = ot.ColorLine() colorLine.Extend = ExtendMode.PAD return colorLine def _defaultVarColorLine(): colorLine = ot.VarColorLine() colorLine.Extend = ExtendMode.PAD return colorLine def _defaultPaintSolid(): paint = ot.Paint() paint.Alpha = _DEFAULT_ALPHA return paint def _buildPaintCallbacks(): return { ( BuildCallback.BEFORE_BUILD, ot.Paint, ot.PaintFormat.PaintRadialGradient, ): _beforeBuildPaintRadialGradient, ( BuildCallback.BEFORE_BUILD, ot.Paint, ot.PaintFormat.PaintVarRadialGradient, ): _beforeBuildPaintRadialGradient, (BuildCallback.CREATE_DEFAULT, ot.ColorStop): _defaultColorStop, (BuildCallback.CREATE_DEFAULT, ot.VarColorStop): _defaultVarColorStop, (BuildCallback.CREATE_DEFAULT, ot.ColorLine): _defaultColorLine, (BuildCallback.CREATE_DEFAULT, ot.VarColorLine): _defaultVarColorLine, ( BuildCallback.CREATE_DEFAULT, ot.Paint, ot.PaintFormat.PaintSolid, ): _defaultPaintSolid, ( BuildCallback.CREATE_DEFAULT, ot.Paint, ot.PaintFormat.PaintVarSolid, ): _defaultPaintSolid, } def populateCOLRv0( table: ot.COLR, colorGlyphsV0: _ColorGlyphsV0Dict, glyphMap: Optional[Mapping[str, int]] = None, ): """Build v0 color layers and add to existing COLR table. Args: table: a raw ``otTables.COLR()`` object (not ttLib's ``table_C_O_L_R_``). colorGlyphsV0: map of base glyph names to lists of (layer glyph names, color palette index) tuples. Can be empty. glyphMap: a map from glyph names to glyph indices, as returned from ``TTFont.getReverseGlyphMap()``, to optionally sort base records by GID. """ if glyphMap is not None: colorGlyphItems = sorted( colorGlyphsV0.items(), key=lambda item: glyphMap[item[0]] ) else: colorGlyphItems = colorGlyphsV0.items() baseGlyphRecords = [] layerRecords = [] for baseGlyph, layers in colorGlyphItems: baseRec = ot.BaseGlyphRecord() baseRec.BaseGlyph = baseGlyph baseRec.FirstLayerIndex = len(layerRecords) baseRec.NumLayers = len(layers) baseGlyphRecords.append(baseRec) for layerGlyph, paletteIndex in layers: layerRec = ot.LayerRecord() layerRec.LayerGlyph = layerGlyph layerRec.PaletteIndex = paletteIndex layerRecords.append(layerRec) table.BaseGlyphRecordArray = table.LayerRecordArray = None if baseGlyphRecords: table.BaseGlyphRecordArray = ot.BaseGlyphRecordArray() table.BaseGlyphRecordArray.BaseGlyphRecord = baseGlyphRecords if layerRecords: table.LayerRecordArray = ot.LayerRecordArray() table.LayerRecordArray.LayerRecord = layerRecords table.BaseGlyphRecordCount = len(baseGlyphRecords) table.LayerRecordCount = len(layerRecords) def buildCOLR( colorGlyphs: _ColorGlyphsDict, version: Optional[int] = None, *, glyphMap: Optional[Mapping[str, int]] = None, varStore: Optional[ot.VarStore] = None, varIndexMap: Optional[ot.DeltaSetIndexMap] = None, clipBoxes: Optional[Dict[str, _ClipBoxInput]] = None, allowLayerReuse: bool = True, ) -> C_O_L_R_.table_C_O_L_R_: """Build COLR table from color layers mapping. Args: colorGlyphs: map of base glyph name to, either list of (layer glyph name, color palette index) tuples for COLRv0; or a single ``Paint`` (dict) or list of ``Paint`` for COLRv1. version: the version of COLR table. If None, the version is determined by the presence of COLRv1 paints or variation data (varStore), which require version 1; otherwise, if all base glyphs use only simple color layers, version 0 is used. glyphMap: a map from glyph names to glyph indices, as returned from 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). Returns: A new COLR table. """ self = C_O_L_R_.table_C_O_L_R_() if varStore is not None and version == 0: raise ValueError("Can't add VarStore to COLRv0") if version in (None, 0) and not varStore: # split color glyphs into v0 and v1 and encode separately colorGlyphsV0, colorGlyphsV1 = _split_color_glyphs_by_version(colorGlyphs) if version == 0 and colorGlyphsV1: raise ValueError("Can't encode COLRv1 glyphs in COLRv0") else: # unless explicitly requested for v1 or have variations, in which case # we encode all color glyph as v1 colorGlyphsV0, colorGlyphsV1 = {}, colorGlyphs colr = ot.COLR() populateCOLRv0(colr, colorGlyphsV0, glyphMap) colr.LayerList, colr.BaseGlyphList = buildColrV1( colorGlyphsV1, glyphMap, allowLayerReuse=allowLayerReuse, ) if version is None: version = 1 if (varStore or colorGlyphsV1) else 0 elif version not in (0, 1): raise NotImplementedError(version) self.version = colr.Version = version if version == 0: self.ColorLayers = self._decompileColorLayersV0(colr) else: colr.ClipList = buildClipList(clipBoxes) if clipBoxes else None colr.VarIndexMap = varIndexMap colr.VarStore = varStore self.table = colr return self def buildClipList(clipBoxes: Dict[str, _ClipBoxInput]) -> ot.ClipList: clipList = ot.ClipList() clipList.Format = 1 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 not in (4, 5): raise ValueError(f"Invalid ClipBox: expected 4 or 5 values, found {n}") clip.xMin, clip.yMin, clip.xMax, clip.yMax = intRect(clipBox[:4]) clip.Format = int(n == 5) + 1 if n == 5: clip.VarIndexBase = int(clipBox[4]) return clip class ColorPaletteType(enum.IntFlag): USABLE_WITH_LIGHT_BACKGROUND = 0x0001 USABLE_WITH_DARK_BACKGROUND = 0x0002 @classmethod def _missing_(cls, value): # enforce reserved bits if isinstance(value, int) and (value < 0 or value & 0xFFFC != 0): raise ValueError(f"{value} is not a valid {cls.__name__}") return super()._missing_(value) # None, 'abc' or {'en': 'abc', 'de': 'xyz'} _OptionalLocalizedString = Union[None, str, Dict[str, str]] def buildPaletteLabels( labels: Iterable[_OptionalLocalizedString], nameTable: _n_a_m_e.table__n_a_m_e ) -> List[Optional[int]]: return [ nameTable.addMultilingualName(l, mac=False) if isinstance(l, dict) else C_P_A_L_.table_C_P_A_L_.NO_NAME_ID if l is None else nameTable.addMultilingualName({"en": l}, mac=False) for l in labels ] def buildCPAL( palettes: Sequence[Sequence[Tuple[float, float, float, float]]], paletteTypes: Optional[Sequence[ColorPaletteType]] = None, paletteLabels: Optional[Sequence[_OptionalLocalizedString]] = None, paletteEntryLabels: Optional[Sequence[_OptionalLocalizedString]] = None, nameTable: Optional[_n_a_m_e.table__n_a_m_e] = None, ) -> C_P_A_L_.table_C_P_A_L_: """Build CPAL table from list of color palettes. Args: palettes: list of lists of colors encoded as tuples of (R, G, B, A) floats in the range [0..1]. paletteTypes: optional list of ColorPaletteType, one for each palette. paletteLabels: optional list of palette labels. Each lable can be either: None (no label), a string (for for default English labels), or a localized string (as a dict keyed with BCP47 language codes). paletteEntryLabels: optional list of palette entry labels, one for each palette entry (see paletteLabels). nameTable: optional name table where to store palette and palette entry labels. Required if either paletteLabels or paletteEntryLabels is set. Return: A new CPAL v0 or v1 table, if custom palette types or labels are specified. """ if len({len(p) for p in palettes}) != 1: raise ColorLibError("color palettes have different lengths") if (paletteLabels or paletteEntryLabels) and not nameTable: raise TypeError( "nameTable is required if palette or palette entries have labels" ) cpal = C_P_A_L_.table_C_P_A_L_() cpal.numPaletteEntries = len(palettes[0]) cpal.palettes = [] for i, palette in enumerate(palettes): colors = [] for j, color in enumerate(palette): if not isinstance(color, tuple) or len(color) != 4: raise ColorLibError( f"In palette[{i}][{j}]: expected (R, G, B, A) tuple, got {color!r}" ) if any(v > 1 or v < 0 for v in color): raise ColorLibError( f"palette[{i}][{j}] has invalid out-of-range [0..1] color: {color!r}" ) # input colors are RGBA, CPAL encodes them as BGRA red, green, blue, alpha = color colors.append( C_P_A_L_.Color(*(round(v * 255) for v in (blue, green, red, alpha))) ) cpal.palettes.append(colors) if any(v is not None for v in (paletteTypes, paletteLabels, paletteEntryLabels)): cpal.version = 1 if paletteTypes is not None: if len(paletteTypes) != len(palettes): raise ColorLibError( f"Expected {len(palettes)} paletteTypes, got {len(paletteTypes)}" ) cpal.paletteTypes = [ColorPaletteType(t).value for t in paletteTypes] else: cpal.paletteTypes = [C_P_A_L_.table_C_P_A_L_.DEFAULT_PALETTE_TYPE] * len( palettes ) if paletteLabels is not None: if len(paletteLabels) != len(palettes): raise ColorLibError( f"Expected {len(palettes)} paletteLabels, got {len(paletteLabels)}" ) cpal.paletteLabels = buildPaletteLabels(paletteLabels, nameTable) else: cpal.paletteLabels = [C_P_A_L_.table_C_P_A_L_.NO_NAME_ID] * len(palettes) if paletteEntryLabels is not None: if len(paletteEntryLabels) != cpal.numPaletteEntries: raise ColorLibError( f"Expected {cpal.numPaletteEntries} paletteEntryLabels, " f"got {len(paletteEntryLabels)}" ) cpal.paletteEntryLabels = buildPaletteLabels(paletteEntryLabels, nameTable) else: cpal.paletteEntryLabels = [ C_P_A_L_.table_C_P_A_L_.NO_NAME_ID ] * cpal.numPaletteEntries else: cpal.version = 0 return cpal # COLR v1 tables # See draft proposal at: https://github.com/googlefonts/colr-gradients-spec def _is_colrv0_layer(layer: Any) -> bool: # Consider as COLRv0 layer any sequence of length 2 (be it tuple or list) in which # the first element is a str (the layerGlyph) and the second element is an int # (CPAL paletteIndex). # https://github.com/googlefonts/ufo2ft/issues/426 try: layerGlyph, paletteIndex = layer except (TypeError, ValueError): return False else: return isinstance(layerGlyph, str) and isinstance(paletteIndex, int) def _split_color_glyphs_by_version( colorGlyphs: _ColorGlyphsDict, ) -> Tuple[_ColorGlyphsV0Dict, _ColorGlyphsDict]: colorGlyphsV0 = {} colorGlyphsV1 = {} for baseGlyph, layers in colorGlyphs.items(): if all(_is_colrv0_layer(l) for l in layers): colorGlyphsV0[baseGlyph] = layers else: colorGlyphsV1[baseGlyph] = layers # sanity check assert set(colorGlyphs) == (set(colorGlyphsV0) | set(colorGlyphsV1)) return colorGlyphsV0, colorGlyphsV1 def _reuse_ranges(num_layers: int) -> Generator[Tuple[int, int], None, None]: # TODO feels like something itertools might have already for lbound in range(num_layers): # Reuse of very large #s of layers is relatively unlikely # +2: we want sequences of at least 2 # otData handles single-record duplication for ubound in range( lbound + 2, min(num_layers + 1, lbound + 2 + _MAX_REUSE_LEN) ): yield (lbound, ubound) class LayerReuseCache: reusePool: Mapping[Tuple[Any, ...], int] tuples: Mapping[int, Tuple[Any, ...]] keepAlive: List[ot.Paint] # we need id to remain valid def __init__(self): self.reusePool = {} self.tuples = {} self.keepAlive = [] def _paint_tuple(self, paint: ot.Paint): # start simple, who even cares about cyclic graphs or interesting field types def _tuple_safe(value): if isinstance(value, enum.Enum): return value elif hasattr(value, "__dict__"): return tuple( (k, _tuple_safe(v)) for k, v in sorted(value.__dict__.items()) ) elif isinstance(value, collections.abc.MutableSequence): return tuple(_tuple_safe(e) for e in value) return value # Cache the tuples for individual Paint instead of the whole sequence # because the seq could be a transient slice result = self.tuples.get(id(paint), None) if result is None: result = _tuple_safe(paint) self.tuples[id(paint)] = result self.keepAlive.append(paint) return result def _as_tuple(self, paints: Sequence[ot.Paint]) -> Tuple[Any, ...]: return tuple(self._paint_tuple(p) for p in paints) def try_reuse(self, layers: List[ot.Paint]) -> List[ot.Paint]: found_reuse = True while found_reuse: found_reuse = False ranges = sorted( _reuse_ranges(len(layers)), key=lambda t: (t[1] - t[0], t[1], t[0]), reverse=True, ) for lbound, ubound in ranges: reuse_lbound = self.reusePool.get( self._as_tuple(layers[lbound:ubound]), -1 ) if reuse_lbound == -1: continue new_slice = ot.Paint() new_slice.Format = int(ot.PaintFormat.PaintColrLayers) new_slice.NumLayers = ubound - lbound new_slice.FirstLayerIndex = reuse_lbound layers = layers[:lbound] + [new_slice] + layers[ubound:] found_reuse = True break return layers def add(self, layers: List[ot.Paint], first_layer_index: int): for lbound, ubound in _reuse_ranges(len(layers)): self.reusePool[self._as_tuple(layers[lbound:ubound])] = ( lbound + first_layer_index ) class LayerListBuilder: layers: List[ot.Paint] cache: LayerReuseCache allowLayerReuse: bool def __init__(self, *, allowLayerReuse=True): self.layers = [] if allowLayerReuse: self.cache = LayerReuseCache() else: self.cache = None # We need to intercept construction of PaintColrLayers callbacks = _buildPaintCallbacks() callbacks[ ( BuildCallback.BEFORE_BUILD, ot.Paint, ot.PaintFormat.PaintColrLayers, ) ] = self._beforeBuildPaintColrLayers self.tableBuilder = TableBuilder(callbacks) # COLR layers is unusual in that it modifies shared state # so we need a callback into an object def _beforeBuildPaintColrLayers(self, dest, source): # Sketchy gymnastics: a sequence input will have dropped it's layers # into NumLayers; get it back if isinstance(source.get("NumLayers", None), collections.abc.Sequence): layers = source["NumLayers"] else: layers = source["Layers"] # Convert maps seqs or whatever into typed objects layers = [self.buildPaint(l) for l in layers] # No reason to have a colr layers with just one entry if len(layers) == 1: return layers[0], {} if self.cache is not None: # Look for reuse, with preference to longer sequences # This may make the layer list smaller layers = self.cache.try_reuse(layers) # The layer list is now final; if it's too big we need to tree it is_tree = len(layers) > MAX_PAINT_COLR_LAYER_COUNT layers = build_n_ary_tree(layers, n=MAX_PAINT_COLR_LAYER_COUNT) # We now have a tree of sequences with Paint leaves. # Convert the sequences into PaintColrLayers. def listToColrLayers(layer): if isinstance(layer, collections.abc.Sequence): return self.buildPaint( { "Format": ot.PaintFormat.PaintColrLayers, "Layers": [listToColrLayers(l) for l in layer], } ) return layer layers = [listToColrLayers(l) for l in layers] # No reason to have a colr layers with just one entry if len(layers) == 1: return layers[0], {} paint = ot.Paint() paint.Format = int(ot.PaintFormat.PaintColrLayers) paint.NumLayers = len(layers) paint.FirstLayerIndex = len(self.layers) self.layers.extend(layers) # Register our parts for reuse provided we aren't a tree # If we are a tree the leaves registered for reuse and that will suffice if self.cache is not None and not is_tree: self.cache.add(layers, paint.FirstLayerIndex) # we've fully built dest; empty source prevents generalized build from kicking in return paint, {} def buildPaint(self, paint: _PaintInput) -> ot.Paint: return self.tableBuilder.build(ot.Paint, paint) def build(self) -> Optional[ot.LayerList]: if not self.layers: return None layers = ot.LayerList() layers.LayerCount = len(self.layers) layers.Paint = self.layers return layers def buildBaseGlyphPaintRecord( baseGlyph: str, layerBuilder: LayerListBuilder, paint: _PaintInput ) -> ot.BaseGlyphList: self = ot.BaseGlyphPaintRecord() self.BaseGlyph = baseGlyph self.Paint = layerBuilder.buildPaint(paint) return self def _format_glyph_errors(errors: Mapping[str, Exception]) -> str: lines = [] for baseGlyph, error in sorted(errors.items()): lines.append(f" {baseGlyph} => {type(error).__name__}: {error}") return "\n".join(lines) def buildColrV1( colorGlyphs: _ColorGlyphsDict, glyphMap: Optional[Mapping[str, int]] = None, *, allowLayerReuse: bool = True, ) -> Tuple[Optional[ot.LayerList], ot.BaseGlyphList]: if glyphMap is not None: colorGlyphItems = sorted( colorGlyphs.items(), key=lambda item: glyphMap[item[0]] ) else: colorGlyphItems = colorGlyphs.items() errors = {} baseGlyphs = [] layerBuilder = LayerListBuilder(allowLayerReuse=allowLayerReuse) for baseGlyph, paint in colorGlyphItems: try: baseGlyphs.append(buildBaseGlyphPaintRecord(baseGlyph, layerBuilder, paint)) except (ColorLibError, OverflowError, ValueError, TypeError) as e: errors[baseGlyph] = e if errors: failed_glyphs = _format_glyph_errors(errors) exc = ColorLibError(f"Failed to build BaseGlyphList:\n{failed_glyphs}") exc.errors = errors raise exc from next(iter(errors.values())) layers = layerBuilder.build() glyphs = ot.BaseGlyphList() glyphs.BaseGlyphCount = len(baseGlyphs) glyphs.BaseGlyphPaintRecord = baseGlyphs return (layers, glyphs)