blob: 3d75567eb22005d5196c4f48656530b5a00d70cf [file] [log] [blame]
"""
colorLib.builder: Build COLR/CPAL tables from scratch
"""
import collections
import copy
import enum
from functools import partial
from typing import (
Any,
Dict,
Generator,
Iterable,
List,
Mapping,
Optional,
Sequence,
Tuple,
Type,
TypeVar,
Union,
)
from fontTools.misc.fixedTools import fixedToFloat
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.otBase import BaseTable
from fontTools.ttLib.tables import otTables as ot
from fontTools.ttLib.tables.otTables import (
ExtendMode,
CompositeMode,
VariableValue,
VariableFloat,
VariableInt,
)
from .errors import ColorLibError
# 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]]]
_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]
_CompositeInput = Union[int, str, CompositeMode]
_ColorLineInput = Union[_Kwargs, ot.ColorLine]
_PointTuple = Tuple[_ScalarInput, _ScalarInput]
_AffineTuple = Tuple[
_ScalarInput, _ScalarInput, _ScalarInput, _ScalarInput, _ScalarInput, _ScalarInput
]
_AffineInput = Union[_AffineTuple, ot.Affine2x3]
MAX_PAINT_COLR_LAYER_COUNT = 255
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 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.
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 = _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 = 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.LayerV1List, colr.BaseGlyphV1List = buildColrV1(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_ALPHA = VariableFloat(1.0)
def _split_color_glyphs_by_version(
colorGlyphs: _ColorGlyphsDict,
) -> Tuple[_ColorGlyphsV0Dict, _ColorGlyphsDict]:
colorGlyphsV0 = {}
colorGlyphsV1 = {}
for baseGlyph, layers in colorGlyphs.items():
if all(isinstance(l, tuple) and isinstance(l[1], int) for l in layers):
colorGlyphsV0[baseGlyph] = layers
else:
colorGlyphsV1[baseGlyph] = layers
# sanity check
assert set(colorGlyphs) == (set(colorGlyphsV0) | set(colorGlyphsV1))
return colorGlyphsV0, colorGlyphsV1
def _to_variable_value(
value: _ScalarInput,
minValue: _Number,
maxValue: _Number,
cls: Type[VariableValue],
) -> VariableValue:
if not isinstance(value, cls):
try:
it = iter(value)
except TypeError: # not iterable
value = cls(value)
else:
value = cls._make(it)
if value.value < minValue:
raise OverflowError(f"{cls.__name__}: {value.value} < {minValue}")
if value.value > maxValue:
raise OverflowError(f"{cls.__name__}: {value.value} < {maxValue}")
return value
_to_variable_f16dot16_float = partial(
_to_variable_value,
cls=VariableFloat,
minValue=-(2 ** 15),
maxValue=fixedToFloat(2 ** 31 - 1, 16),
)
_to_variable_f2dot14_float = partial(
_to_variable_value,
cls=VariableFloat,
minValue=-2.0,
maxValue=fixedToFloat(2 ** 15 - 1, 14),
)
_to_variable_int16 = partial(
_to_variable_value,
cls=VariableInt,
minValue=-(2 ** 15),
maxValue=2 ** 15 - 1,
)
_to_variable_uint16 = partial(
_to_variable_value,
cls=VariableInt,
minValue=0,
maxValue=2 ** 16,
)
def buildColorIndex(
paletteIndex: int, alpha: _ScalarInput = _DEFAULT_ALPHA
) -> ot.ColorIndex:
self = ot.ColorIndex()
self.PaletteIndex = int(paletteIndex)
self.Alpha = _to_variable_f2dot14_float(alpha)
return self
def buildColorStop(
offset: _ScalarInput,
paletteIndex: int,
alpha: _ScalarInput = _DEFAULT_ALPHA,
) -> ot.ColorStop:
self = ot.ColorStop()
self.StopOffset = _to_variable_f2dot14_float(offset)
self.Color = buildColorIndex(paletteIndex, alpha)
return self
def _to_enum_value(v: Union[str, int, T], enumClass: Type[T]) -> T:
if isinstance(v, enumClass):
return v
elif isinstance(v, str):
try:
return getattr(enumClass, v.upper())
except AttributeError:
raise ValueError(f"{v!r} is not a valid {enumClass.__name__}")
return enumClass(v)
def _to_extend_mode(v: _ExtendInput) -> ExtendMode:
return _to_enum_value(v, ExtendMode)
def _to_composite_mode(v: _CompositeInput) -> CompositeMode:
return _to_enum_value(v, CompositeMode)
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 _to_color_line(obj):
if isinstance(obj, ot.ColorLine):
return obj
elif isinstance(obj, collections.abc.Mapping):
return buildColorLine(**obj)
raise TypeError(obj)
def _as_tuple(obj) -> Tuple[Any, ...]:
# 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 value.__dict__.items())
elif isinstance(value, collections.abc.MutableSequence):
return tuple(_tuple_safe(e) for e in value)
return value
return tuple(_tuple_safe(obj))
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):
# TODO may want a max length to limit scope of search
# 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, num_layers + 1):
yield (lbound, ubound)
class LayerV1ListBuilder:
slices: List[ot.Paint]
layers: List[ot.Paint]
reusePool: Mapping[Tuple[Any, ...], int]
def __init__(self):
self.slices = []
self.layers = []
self.reusePool = {}
def buildPaintSolid(
self, paletteIndex: int, alpha: _ScalarInput = _DEFAULT_ALPHA
) -> ot.Paint:
ot_paint = ot.Paint()
ot_paint.Format = int(ot.Paint.Format.PaintSolid)
ot_paint.Color = buildColorIndex(paletteIndex, alpha)
return ot_paint
def buildPaintLinearGradient(
self,
colorLine: _ColorLineInput,
p0: _PointTuple,
p1: _PointTuple,
p2: Optional[_PointTuple] = None,
) -> ot.Paint:
ot_paint = ot.Paint()
ot_paint.Format = int(ot.Paint.Format.PaintLinearGradient)
ot_paint.ColorLine = _to_color_line(colorLine)
if p2 is None:
p2 = copy.copy(p1)
for i, (x, y) in enumerate((p0, p1, p2)):
setattr(ot_paint, f"x{i}", _to_variable_int16(x))
setattr(ot_paint, f"y{i}", _to_variable_int16(y))
return ot_paint
def buildPaintRadialGradient(
self,
colorLine: _ColorLineInput,
c0: _PointTuple,
c1: _PointTuple,
r0: _ScalarInput,
r1: _ScalarInput,
) -> ot.Paint:
ot_paint = ot.Paint()
ot_paint.Format = int(ot.Paint.Format.PaintRadialGradient)
ot_paint.ColorLine = _to_color_line(colorLine)
for i, (x, y), r in [(0, c0, r0), (1, c1, r1)]:
setattr(ot_paint, f"x{i}", _to_variable_int16(x))
setattr(ot_paint, f"y{i}", _to_variable_int16(y))
setattr(ot_paint, f"r{i}", _to_variable_uint16(r))
return ot_paint
def buildPaintGlyph(self, glyph: str, paint: _PaintInput) -> ot.Paint:
ot_paint = ot.Paint()
ot_paint.Format = int(ot.Paint.Format.PaintGlyph)
ot_paint.Glyph = glyph
ot_paint.Paint = self.buildPaint(paint)
return ot_paint
def buildPaintColrGlyph(self, glyph: str) -> ot.Paint:
ot_paint = ot.Paint()
ot_paint.Format = int(ot.Paint.Format.PaintColrGlyph)
ot_paint.Glyph = glyph
return ot_paint
def buildPaintTransform(
self, transform: _AffineInput, paint: _PaintInput
) -> ot.Paint:
ot_paint = ot.Paint()
ot_paint.Format = int(ot.Paint.Format.PaintTransform)
if not isinstance(transform, ot.Affine2x3):
transform = buildAffine2x3(transform)
ot_paint.Transform = transform
ot_paint.Paint = self.buildPaint(paint)
return ot_paint
def buildPaintComposite(
self,
mode: _CompositeInput,
source: _PaintInput,
backdrop: _PaintInput,
):
ot_paint = ot.Paint()
ot_paint.Format = int(ot.Paint.Format.PaintComposite)
ot_paint.SourcePaint = self.buildPaint(source)
ot_paint.CompositeMode = _to_composite_mode(mode)
ot_paint.BackdropPaint = self.buildPaint(backdrop)
return ot_paint
def buildColrLayers(self, paints: List[_PaintInput]) -> ot.Paint:
ot_paint = ot.Paint()
ot_paint.Format = int(ot.Paint.Format.PaintColrLayers)
self.slices.append(ot_paint)
paints = [self.buildPaint(p) for p in paints]
# Look for reuse, with preference to longer sequences
found_reuse = True
while found_reuse:
found_reuse = False
ranges = sorted(
_reuse_ranges(len(paints)),
key=lambda t: (t[1] - t[0], t[1], t[0]),
reverse=True,
)
for lbound, ubound in ranges:
reuse_lbound = self.reusePool.get(_as_tuple(paints[lbound:ubound]), -1)
if reuse_lbound == -1:
continue
new_slice = ot.Paint()
new_slice.Format = int(ot.Paint.Format.PaintColrLayers)
new_slice.NumLayers = ubound - lbound
new_slice.FirstLayerIndex = reuse_lbound
paints = paints[:lbound] + [new_slice] + paints[ubound:]
found_reuse = True
break
ot_paint.NumLayers = len(paints)
ot_paint.FirstLayerIndex = len(self.layers)
self.layers.extend(paints)
# Register our parts for reuse
for lbound, ubound in _reuse_ranges(len(paints)):
self.reusePool[_as_tuple(paints[lbound:ubound])] = (
lbound + ot_paint.FirstLayerIndex
)
return ot_paint
def buildPaint(self, paint: _PaintInput) -> ot.Paint:
if isinstance(paint, ot.Paint):
return paint
elif isinstance(paint, int):
paletteIndex = paint
return self.buildPaintSolid(paletteIndex)
elif isinstance(paint, tuple):
layerGlyph, paint = paint
return self.buildPaintGlyph(layerGlyph, paint)
elif isinstance(paint, list):
# implicit PaintColrLayers for a list of > 1
if len(paint) == 0:
raise ValueError("An empty list is hard to paint")
elif len(paint) == 1:
return self.buildPaint(paint[0])
else:
return self.buildColrLayers(paint)
elif isinstance(paint, collections.abc.Mapping):
kwargs = dict(paint)
fmt = kwargs.pop("format")
try:
return LayerV1ListBuilder._buildFunctions[fmt](self, **kwargs)
except KeyError:
raise NotImplementedError(fmt)
raise TypeError(f"Not sure what to do with {type(paint).__name__}: {paint!r}")
def build(self) -> ot.LayerV1List:
layers = ot.LayerV1List()
layers.LayerCount = len(self.layers)
layers.Paint = self.layers
return layers
LayerV1ListBuilder._buildFunctions = {
pf.value: getattr(LayerV1ListBuilder, "build" + pf.name)
for pf in ot.Paint.Format
if pf != ot.Paint.Format.PaintColrLayers
}
def buildAffine2x3(transform: _AffineTuple) -> ot.Affine2x3:
if len(transform) != 6:
raise ValueError(f"Expected 6-tuple of floats, found: {transform!r}")
self = ot.Affine2x3()
# 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.
# Their fundamental meaning is the same though.
# COLRv1 Affine2x3 follows the names found in FreeType and Cairo.
# In all case, the second element in the 6-tuple correspond to the
# y-part of the x basis vector, and the third to the x-part of the y
# basis vector.
# See https://github.com/googlefonts/colr-gradients-spec/pull/85
for i, attr in enumerate(("xx", "yx", "xy", "yy", "dx", "dy")):
setattr(self, attr, _to_variable_f16dot16_float(transform[i]))
return self
def buildBaseGlyphV1Record(
baseGlyph: str, layerBuilder: LayerV1ListBuilder, paint: _PaintInput
) -> ot.BaseGlyphV1List:
self = ot.BaseGlyphV1Record()
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,
) -> Tuple[ot.LayerV1List, ot.BaseGlyphV1List]:
if glyphMap is not None:
colorGlyphItems = sorted(
colorGlyphs.items(), key=lambda item: glyphMap[item[0]]
)
else:
colorGlyphItems = colorGlyphs.items()
errors = {}
baseGlyphs = []
layerBuilder = LayerV1ListBuilder()
for baseGlyph, paint in colorGlyphItems:
try:
baseGlyphs.append(buildBaseGlyphV1Record(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 BaseGlyphV1List:\n{failed_glyphs}")
exc.errors = errors
raise exc from next(iter(errors.values()))
layers = layerBuilder.build()
glyphs = ot.BaseGlyphV1List()
glyphs.BaseGlyphCount = len(baseGlyphs)
glyphs.BaseGlyphV1Record = baseGlyphs
return (layers, glyphs)