543 lines
17 KiB
Python
Raw Normal View History

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,
)
2020-02-10 17:31:14 +00:00
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]]
_ColorInput = Union[int, _Kwargs, ot.Color]
_ColorStopTuple = Tuple[_ScalarInput, _ColorInput]
_ColorStopsList = Sequence[Union[_ColorStopTuple, ot.ColorStop]]
_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(
2020-03-09 15:01:50 +00:00
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_:
2020-02-10 17:31:14 +00:00
"""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.
2020-02-10 17:31:14 +00:00
Return:
A new COLR table.
2020-02-10 17:31:14 +00:00
"""
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 varStore:
colr.VarStore = varStore
2020-02-10 17:31:14 +00:00
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:
self.table = colr
return self
2020-02-10 17:31:14 +00:00
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
]
2020-02-17 12:11:32 +00:00
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_:
2020-02-10 17:31:14 +00:00
"""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.
2020-02-10 17:31:14 +00:00
Return:
A new CPAL v0 or v1 table, if custom palette types or labels are specified.
2020-02-10 17:31:14 +00:00
"""
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_()
2020-02-10 17:31:14 +00:00
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
2020-02-10 17:31:14 +00:00
return cpal
def _splitSolidAndGradientGlyphs(
colorGlyphs: _ColorGlyphsDict,
) -> Tuple[_ColorGlyphsV0Dict, _ColorGlyphsDict]:
colorGlyphsV0 = {}
colorGlyphsV1 = {}
for baseGlyph, layers in colorGlyphs.items():
layersV0 = []
allSolidColors = True
for layerGlyph, paint in layers:
if isinstance(paint, ot.Paint):
if (
paint.Format == 1
and paint.Color.Transparency.value == _DEFAULT_TRANSPARENCY.value
):
paint = paint.Color.PaletteIndex
else:
allSolidColors = False
break
elif isinstance(paint, int):
pass
else:
raise TypeError(paint)
layersV0.append((layerGlyph, paint))
if allSolidColors:
colorGlyphsV0[baseGlyph] = layersV0
else:
colorGlyphsV1[baseGlyph] = layers
# sanity check
assert set(colorGlyphs) == (set(colorGlyphsV0) | set(colorGlyphsV1))
return colorGlyphsV0, colorGlyphsV1
# COLR v1 tables
# See draft proposal at: https://github.com/googlefonts/colr-gradients-spec
_DEFAULT_TRANSPARENCY = VariableFloat(0.0)
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, color: _ColorInput) -> ot.ColorStop:
self = ot.ColorStop()
self.StopOffset = _to_variable_float(offset)
if isinstance(color, int):
color = buildColor(paletteIndex=color)
elif not isinstance(color, ot.Color):
color = buildColor(**color)
self.Color = color
return self
def _to_extend_mode(v):
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: ExtendMode = 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(offset=stop[0], color=stop[1])
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
if isinstance(pt, tuple):
return buildPoint(*pt)
raise TypeError(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)