blob: 763115b96bf67dec49fe9992807ad466ed5bd0f0 [file] [log] [blame]
"""
colorLib.table_builder: Generic helper for filling in BaseTable derivatives from tuples and maps and such.
"""
import collections
import enum
from fontTools.ttLib.tables.otBase import (
BaseTable,
FormatSwitchingBaseTable,
UInt8FormatSwitchingBaseTable,
)
from fontTools.ttLib.tables.otConverters import (
ComputedInt,
SimpleValue,
Struct,
Short,
UInt8,
UShort,
IntValue,
FloatValue,
OptionalValue,
)
from fontTools.misc.roundTools import otRound
class BuildCallback(enum.Enum):
"""Keyed on (BEFORE_BUILD, class[, Format if available]).
Receives (dest, source).
Should return (dest, source), which can be new objects.
"""
BEFORE_BUILD = enum.auto()
"""Keyed on (AFTER_BUILD, class[, Format if available]).
Receives (dest).
Should return dest, which can be a new object.
"""
AFTER_BUILD = enum.auto()
"""Keyed on (CREATE_DEFAULT, class[, Format if available]).
Receives no arguments.
Should return a new instance of class.
"""
CREATE_DEFAULT = enum.auto()
def _assignable(convertersByName):
return {k: v for k, v in convertersByName.items() if not isinstance(v, ComputedInt)}
def _isNonStrSequence(value):
return isinstance(value, collections.abc.Sequence) and not isinstance(value, str)
def _split_format(cls, source):
if _isNonStrSequence(source):
assert len(source) > 0, f"{cls} needs at least format from {source}"
fmt, remainder = source[0], source[1:]
elif isinstance(source, collections.abc.Mapping):
assert "Format" in source, f"{cls} needs at least Format from {source}"
remainder = source.copy()
fmt = remainder.pop("Format")
else:
raise ValueError(f"Not sure how to populate {cls} from {source}")
assert isinstance(
fmt, collections.abc.Hashable
), f"{cls} Format is not hashable: {fmt!r}"
assert (
fmt in cls.convertersByName
), f"{cls} invalid Format: {fmt!r}"
return fmt, remainder
class TableBuilder:
"""
Helps to populate things derived from BaseTable from maps, tuples, etc.
A table of lifecycle callbacks may be provided to add logic beyond what is possible
based on otData info for the target class. See BuildCallbacks.
"""
def __init__(self, callbackTable=None):
if callbackTable is None:
callbackTable = {}
self._callbackTable = callbackTable
def _convert(self, dest, field, converter, value):
enumClass = getattr(converter, "enumClass", None)
if enumClass:
if isinstance(value, enumClass):
pass
elif isinstance(value, str):
try:
value = getattr(enumClass, value.upper())
except AttributeError:
raise ValueError(f"{value} is not a valid {enumClass}")
else:
value = enumClass(value)
elif isinstance(converter, IntValue):
value = otRound(value)
elif isinstance(converter, FloatValue):
value = float(value)
elif isinstance(converter, Struct):
if converter.repeat:
if _isNonStrSequence(value):
value = [self.build(converter.tableClass, v) for v in value]
else:
value = [self.build(converter.tableClass, value)]
setattr(dest, converter.repeat, len(value))
else:
value = self.build(converter.tableClass, value)
elif callable(converter):
value = converter(value)
setattr(dest, field, value)
def build(self, cls, source):
assert issubclass(cls, BaseTable)
if isinstance(source, cls):
return source
callbackKey = (cls,)
fmt = None
if issubclass(cls, FormatSwitchingBaseTable):
fmt, source = _split_format(cls, source)
callbackKey = (cls, fmt)
dest = self._callbackTable.get(
(BuildCallback.CREATE_DEFAULT,) + callbackKey, lambda: cls()
)()
assert isinstance(dest, cls)
convByName = _assignable(cls.convertersByName)
skippedFields = set()
# For format switchers we need to resolve converters based on format
if issubclass(cls, FormatSwitchingBaseTable):
dest.Format = fmt
convByName = _assignable(convByName[dest.Format])
skippedFields.add("Format")
# Convert sequence => mapping so before thunk only has to handle one format
if _isNonStrSequence(source):
# Sequence (typically list or tuple) assumed to match fields in declaration order
assert len(source) <= len(
convByName
), f"Sequence of {len(source)} too long for {cls}; expected <= {len(convByName)} values"
source = dict(zip(convByName.keys(), source))
dest, source = self._callbackTable.get(
(BuildCallback.BEFORE_BUILD,) + callbackKey, lambda d, s: (d, s)
)(dest, source)
if isinstance(source, collections.abc.Mapping):
for field, value in source.items():
if field in skippedFields:
continue
converter = convByName.get(field, None)
if not converter:
raise ValueError(
f"Unrecognized field {field} for {cls}; expected one of {sorted(convByName.keys())}"
)
self._convert(dest, field, converter, value)
else:
# let's try as a 1-tuple
dest = self.build(cls, (source,))
for field, conv in convByName.items():
if not hasattr(dest, field) and isinstance(conv, OptionalValue):
setattr(dest, field, conv.DEFAULT)
dest = self._callbackTable.get(
(BuildCallback.AFTER_BUILD,) + callbackKey, lambda d: d
)(dest)
return dest
class TableUnbuilder:
def __init__(self, callbackTable=None):
if callbackTable is None:
callbackTable = {}
self._callbackTable = callbackTable
def unbuild(self, table):
assert isinstance(table, BaseTable)
source = {}
callbackKey = (type(table),)
if isinstance(table, FormatSwitchingBaseTable):
source["Format"] = int(table.Format)
callbackKey += (table.Format,)
for converter in table.getConverters():
if isinstance(converter, ComputedInt):
continue
value = getattr(table, converter.name)
enumClass = getattr(converter, "enumClass", None)
if enumClass:
source[converter.name] = value.name.lower()
elif isinstance(converter, Struct):
if converter.repeat:
source[converter.name] = [self.unbuild(v) for v in value]
else:
source[converter.name] = self.unbuild(value)
elif isinstance(converter, SimpleValue):
# "simple" values (e.g. int, float, str) need no further un-building
source[converter.name] = value
else:
raise NotImplementedError(
"Don't know how unbuild {value!r} with {converter!r}"
)
source = self._callbackTable.get(callbackKey, lambda s: s)(source)
return source