| import collections |
| import copy |
| import enum |
| from functools import partial |
| from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union |
| 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, |
| VariableValue, |
| VariableFloat, |
| VariableInt, |
| ) |
| from .errors import ColorLibError |
| |
| |
| # TODO move type aliases to colorLib.types? |
| _Kwargs = Mapping[str, Any] |
| _PaintInput = Union[int, _Kwargs, ot.Paint] |
| _LayerTuple = Tuple[str, _PaintInput] |
| _LayersList = Sequence[_LayerTuple] |
| _ColorGlyphsDict = Dict[str, _LayersList] |
| _ColorGlyphsV0Dict = Dict[str, Sequence[Tuple[str, int]]] |
| _Number = Union[int, float] |
| _ScalarInput = Union[_Number, VariableValue, Tuple[_Number, int]] |
| _ColorStopTuple = Tuple[_ScalarInput, int] |
| _ColorStopInput = Union[_ColorStopTuple, _Kwargs, ot.ColorStop] |
| _ColorStopsList = Sequence[_ColorStopInput] |
| _ExtendInput = Union[int, str, ExtendMode] |
| _ColorLineInput = Union[_Kwargs, ot.ColorLine] |
| _PointTuple = Tuple[_ScalarInput, _ScalarInput] |
| _PointInput = Union[_PointTuple, ot.Point] |
| _AffineTuple = Tuple[_ScalarInput, _ScalarInput, _ScalarInput, _ScalarInput] |
| _AffineInput = Union[_AffineTuple, ot.Affine2x2] |
| |
| |
| 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. |
| 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.BaseGlyphRecordCount = len(baseGlyphRecords) |
| table.BaseGlyphRecordArray = ot.BaseGlyphRecordArray() |
| table.BaseGlyphRecordArray.BaseGlyphRecord = baseGlyphRecords |
| table.LayerRecordArray = ot.LayerRecordArray() |
| table.LayerRecordArray.LayerRecord = layerRecords |
| table.LayerRecordCount = len(layerRecords) |
| |
| |
| def buildCOLR( |
| colorGlyphs: _ColorGlyphsDict, |
| version: Optional[int] = None, |
| glyphMap: Optional[Mapping[str, int]] = None, |
| varStore: Optional[ot.VarStore] = None, |
| ) -> C_O_L_R_.table_C_O_L_R_: |
| """Build COLR table from color layers mapping. |
| |
| Args: |
| colorGlyphs: map of base glyph names to lists of (layer glyph names, |
| Paint) tuples. For COLRv0, a paint is simply the color palette index |
| (int); for COLRv1, paint can be either solid colors (with variable |
| opacity), linear gradients or radial gradients. |
| version: the version of COLR table. If None, the version is determined |
| by the presence of gradients or variation data (varStore), which |
| require version 1; otherwise, if there are only simple colors, 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. |
| |
| Return: |
| 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 = _splitSolidAndGradientGlyphs(colorGlyphs) |
| if version == 0 and colorGlyphsV1: |
| # TODO Derive "average" solid color from gradients? |
| raise ValueError("Can't encode gradients in COLRv0") |
| else: |
| # unless explicitly requested for v1 or have variations, in which case |
| # we encode all color glyph as v1 |
| colorGlyphsV0, colorGlyphsV1 = None, colorGlyphs |
| |
| colr = ot.COLR() |
| |
| if colorGlyphsV0: |
| populateCOLRv0(colr, colorGlyphsV0, glyphMap) |
| else: |
| colr.BaseGlyphRecordCount = colr.LayerRecordCount = 0 |
| colr.BaseGlyphRecordArray = colr.LayerRecordArray = None |
| |
| if colorGlyphsV1: |
| colr.BaseGlyphV1Array = buildBaseGlyphV1Array(colorGlyphsV1, glyphMap) |
| |
| 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._fromOTTable(colr) |
| else: |
| colr.VarStore = varStore |
| self.table = colr |
| |
| return self |
| |
| |
| 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 |
| |
| _DEFAULT_TRANSPARENCY = VariableFloat(0.0) |
| |
| |
| def _splitSolidAndGradientGlyphs( |
| colorGlyphs: _ColorGlyphsDict, |
| ) -> Tuple[Dict[str, List[Tuple[str, int]]], Dict[str, List[Tuple[str, ot.Paint]]]]: |
| colorGlyphsV0 = {} |
| colorGlyphsV1 = {} |
| for baseGlyph, layers in colorGlyphs.items(): |
| newLayers = [] |
| allSolidColors = True |
| for layerGlyph, paint in layers: |
| paint = _to_ot_paint(paint) |
| if ( |
| paint.Format != 1 |
| or paint.Color.Transparency.value != _DEFAULT_TRANSPARENCY.value |
| ): |
| allSolidColors = False |
| newLayers.append((layerGlyph, paint)) |
| if allSolidColors: |
| colorGlyphsV0[baseGlyph] = [ |
| (layerGlyph, paint.Color.PaletteIndex) |
| for layerGlyph, paint in newLayers |
| ] |
| else: |
| colorGlyphsV1[baseGlyph] = newLayers |
| |
| # sanity check |
| assert set(colorGlyphs) == (set(colorGlyphsV0) | set(colorGlyphsV1)) |
| |
| return colorGlyphsV0, colorGlyphsV1 |
| |
| |
| def _to_variable_value(value: _ScalarInput, cls=VariableValue) -> VariableValue: |
| if isinstance(value, cls): |
| return value |
| try: |
| it = iter(value) |
| except TypeError: # not iterable |
| return cls(value) |
| else: |
| return cls._make(it) |
| |
| |
| _to_variable_float = partial(_to_variable_value, cls=VariableFloat) |
| _to_variable_int = partial(_to_variable_value, cls=VariableInt) |
| |
| |
| def buildColor( |
| paletteIndex: int, transparency: _ScalarInput = _DEFAULT_TRANSPARENCY |
| ) -> ot.Color: |
| self = ot.Color() |
| self.PaletteIndex = int(paletteIndex) |
| self.Transparency = _to_variable_float(transparency) |
| return self |
| |
| |
| def buildSolidColorPaint( |
| paletteIndex: int, transparency: _ScalarInput = _DEFAULT_TRANSPARENCY |
| ) -> ot.Paint: |
| self = ot.Paint() |
| self.Format = 1 |
| self.Color = buildColor(paletteIndex, transparency) |
| return self |
| |
| |
| def buildColorStop( |
| offset: _ScalarInput, |
| paletteIndex: int, |
| transparency: _ScalarInput = _DEFAULT_TRANSPARENCY, |
| ) -> ot.ColorStop: |
| self = ot.ColorStop() |
| self.StopOffset = _to_variable_float(offset) |
| self.Color = buildColor(paletteIndex, transparency) |
| return self |
| |
| |
| def _to_extend_mode(v: _ExtendInput) -> ExtendMode: |
| if isinstance(v, ExtendMode): |
| return v |
| elif isinstance(v, str): |
| try: |
| return getattr(ExtendMode, v.upper()) |
| except AttributeError: |
| raise ValueError(f"{v!r} is not a valid ExtendMode") |
| return ExtendMode(v) |
| |
| |
| def buildColorLine( |
| stops: _ColorStopsList, extend: _ExtendInput = ExtendMode.PAD |
| ) -> ot.ColorLine: |
| self = ot.ColorLine() |
| self.Extend = _to_extend_mode(extend) |
| self.StopCount = len(stops) |
| self.ColorStop = [ |
| stop |
| if isinstance(stop, ot.ColorStop) |
| else buildColorStop(**stop) |
| if isinstance(stop, collections.abc.Mapping) |
| else buildColorStop(*stop) |
| for stop in stops |
| ] |
| return self |
| |
| |
| def buildPoint(x: _ScalarInput, y: _ScalarInput) -> ot.Point: |
| self = ot.Point() |
| # positions are encoded as Int16 so round to int |
| self.x = _to_variable_int(x) |
| self.y = _to_variable_int(y) |
| return self |
| |
| |
| def _to_variable_point(pt: _PointInput) -> ot.Point: |
| if isinstance(pt, ot.Point): |
| return pt |
| return buildPoint(*pt) |
| |
| |
| def _to_color_line(obj): |
| if isinstance(obj, ot.ColorLine): |
| return obj |
| elif isinstance(obj, collections.abc.Mapping): |
| return buildColorLine(**obj) |
| raise TypeError(obj) |
| |
| |
| def buildLinearGradientPaint( |
| colorLine: _ColorLineInput, |
| p0: _PointInput, |
| p1: _PointInput, |
| p2: Optional[_PointInput] = None, |
| ) -> ot.Paint: |
| self = ot.Paint() |
| self.Format = 2 |
| self.ColorLine = _to_color_line(colorLine) |
| |
| if p2 is None: |
| p2 = copy.copy(p1) |
| for i, pt in enumerate((p0, p1, p2)): |
| setattr(self, f"p{i}", _to_variable_point(pt)) |
| |
| return self |
| |
| |
| def buildAffine2x2( |
| xx: _ScalarInput, xy: _ScalarInput, yx: _ScalarInput, yy: _ScalarInput |
| ) -> ot.Affine2x2: |
| self = ot.Affine2x2() |
| locs = locals() |
| for attr in ("xx", "xy", "yx", "yy"): |
| value = locs[attr] |
| setattr(self, attr, _to_variable_float(value)) |
| return self |
| |
| |
| def buildRadialGradientPaint( |
| colorLine: _ColorLineInput, |
| c0: _PointInput, |
| c1: _PointInput, |
| r0: _ScalarInput, |
| r1: _ScalarInput, |
| affine: Optional[_AffineInput] = None, |
| ) -> ot.Paint: |
| |
| self = ot.Paint() |
| self.Format = 3 |
| self.ColorLine = _to_color_line(colorLine) |
| |
| for i, pt in [(0, c0), (1, c1)]: |
| setattr(self, f"c{i}", _to_variable_point(pt)) |
| |
| for i, r in [(0, r0), (1, r1)]: |
| # distances are encoded as UShort so we round to int |
| setattr(self, f"r{i}", _to_variable_int(r)) |
| |
| if affine is not None and not isinstance(affine, ot.Affine2x2): |
| affine = buildAffine2x2(*affine) |
| self.Affine = affine |
| |
| return self |
| |
| |
| def _to_ot_paint(paint: _PaintInput) -> ot.Paint: |
| if isinstance(paint, ot.Paint): |
| return paint |
| elif isinstance(paint, int): |
| paletteIndex = paint |
| return buildSolidColorPaint(paletteIndex) |
| elif isinstance(paint, collections.abc.Mapping): |
| return buildPaint(**paint) |
| raise TypeError(f"expected int, Mapping or ot.Paint, found {type(paint.__name__)}") |
| |
| |
| def buildLayerV1Record(layerGlyph: str, paint: _PaintInput) -> ot.LayerV1Record: |
| self = ot.LayerV1Record() |
| self.LayerGlyph = layerGlyph |
| self.Paint = _to_ot_paint(paint) |
| return self |
| |
| |
| def buildLayerV1Array( |
| layers: Sequence[Union[_LayerTuple, ot.LayerV1Record]] |
| ) -> ot.LayerV1Array: |
| self = ot.LayerV1Array() |
| self.LayerCount = len(layers) |
| records = [] |
| for layer in layers: |
| if isinstance(layer, ot.LayerV1Record): |
| record = layer |
| else: |
| layerGlyph, paint = layer |
| record = buildLayerV1Record(layerGlyph, paint) |
| records.append(record) |
| self.LayerV1Record = records |
| return self |
| |
| |
| def buildBaseGlyphV1Record( |
| baseGlyph: str, layers: Union[_LayersList, ot.LayerV1Array] |
| ) -> ot.BaseGlyphV1Array: |
| self = ot.BaseGlyphV1Record() |
| self.BaseGlyph = baseGlyph |
| if not isinstance(layers, ot.LayerV1Array): |
| layers = buildLayerV1Array(layers) |
| self.LayerV1Array = layers |
| return self |
| |
| |
| def buildBaseGlyphV1Array( |
| colorGlyphs: Union[_ColorGlyphsDict, Dict[str, ot.LayerV1Array]], |
| glyphMap: Optional[Mapping[str, int]] = None, |
| ) -> ot.BaseGlyphV1Array: |
| if glyphMap is not None: |
| colorGlyphItems = sorted( |
| colorGlyphs.items(), key=lambda item: glyphMap[item[0]] |
| ) |
| else: |
| colorGlyphItems = colorGlyphs.items() |
| records = [ |
| buildBaseGlyphV1Record(baseGlyph, layers) |
| for baseGlyph, layers in colorGlyphItems |
| ] |
| self = ot.BaseGlyphV1Array() |
| self.BaseGlyphCount = len(records) |
| self.BaseGlyphV1Record = records |
| return self |
| |
| |
| _PAINT_BUILDERS = { |
| 1: buildSolidColorPaint, |
| 2: buildLinearGradientPaint, |
| 3: buildRadialGradientPaint, |
| } |
| |
| |
| def buildPaint(format: int, **kwargs) -> ot.Paint: |
| try: |
| return _PAINT_BUILDERS[format](**kwargs) |
| except KeyError: |
| raise NotImplementedError(format) |