diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 840f8bca6..cade429e6 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -25,7 +25,6 @@ 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, @@ -36,6 +35,11 @@ from fontTools.ttLib.tables.otTables import ( ) from .errors import ColorLibError from .geometry import round_start_circle_stable_containment +from .table_builder import ( + convertTupleClass, + BuildCallback, + TableBuilder, +) # TODO move type aliases to colorLib.types? @@ -45,21 +49,64 @@ _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 +_DEFAULT_ALPHA = VariableFloat(1.0) +_MAX_REUSE_LEN = 32 + + +def _beforeBuildPaintRadialGradient(paint, source): + # normalize input types (which may or may not specify a varIdx) + x0 = convertTupleClass(VariableFloat, source["x0"]) + y0 = convertTupleClass(VariableFloat, source["y0"]) + r0 = convertTupleClass(VariableFloat, source["r0"]) + x1 = convertTupleClass(VariableFloat, source["x1"]) + y1 = convertTupleClass(VariableFloat, source["y1"]) + r1 = convertTupleClass(VariableFloat, 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.value, y0.value), r0.value, (x1.value, y1.value), r1.value + ) + x0, y0 = x0._replace(value=c.centre[0]), y0._replace(value=c.centre[1]) + r0 = r0._replace(value=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 _defaultColorIndex(): + colorIndex = ot.ColorIndex() + colorIndex.Alpha = _DEFAULT_ALPHA + return colorIndex + + +def _defaultColorLine(): + colorLine = ot.ColorLine() + colorLine.Extend = ExtendMode.PAD + return colorLine + + +def _buildPaintCallbacks(): + return { + ( + BuildCallback.BEFORE_BUILD, + ot.Paint, + ot.PaintFormat.PaintRadialGradient, + ): _beforeBuildPaintRadialGradient, + (BuildCallback.CREATE_DEFAULT, ot.ColorIndex): _defaultColorIndex, + (BuildCallback.CREATE_DEFAULT, ot.ColorLine): _defaultColorLine, + } def populateCOLRv0( @@ -112,7 +159,6 @@ def buildCOLR( 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 @@ -124,7 +170,6 @@ def buildCOLR( 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. """ @@ -295,8 +340,6 @@ def buildCPAL( # COLR v1 tables # See draft proposal at: https://github.com/googlefonts/colr-gradients-spec -_DEFAULT_ALPHA = VariableFloat(1.0) - def _is_colrv0_layer(layer: Any) -> bool: # Consider as COLRv0 layer any sequence of length 2 (be it tuple or list) in which @@ -328,124 +371,15 @@ def _split_color_glyphs_by_version( return colorGlyphsV0, colorGlyphsV1 -def _to_variable_value( - value: _ScalarInput, - cls: Type[VariableValue] = VariableFloat, - minValue: Optional[_Number] = None, - maxValue: Optional[_Number] = None, -) -> VariableValue: - if not isinstance(value, cls): - try: - it = iter(value) - except TypeError: # not iterable - value = cls(value) - else: - value = cls._make(it) - if minValue is not None and value.value < minValue: - raise OverflowError(f"{cls.__name__}: {value.value} < {minValue}") - if maxValue is not None and 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 _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): + for ubound in range( + lbound + 2, min(num_layers + 1, lbound + 2 + _MAX_REUSE_LEN) + ): yield (lbound, ubound) @@ -463,6 +397,17 @@ class LayerV1ListBuilder: self.tuples = {} self.keepAlive = [] + # We need to intercept construction of PaintColrLayers + callbacks = _buildPaintCallbacks() + callbacks[ + ( + BuildCallback.BEFORE_BUILD, + ot.Paint, + ot.PaintFormat.PaintColrLayers, + ) + ] = self._beforeBuildPaintColrLayers + self.tableBuilder = TableBuilder(callbacks) + def _paint_tuple(self, paint: ot.Paint): # start simple, who even cares about cyclic graphs or interesting field types def _tuple_safe(value): @@ -488,186 +433,41 @@ class LayerV1ListBuilder: def _as_tuple(self, paints: Sequence[ot.Paint]) -> Tuple[Any, ...]: return tuple(self._paint_tuple(p) for p in paints) - def buildPaintSolid( - self, paletteIndex: int, alpha: _ScalarInput = _DEFAULT_ALPHA - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintSolid) - ot_paint.Color = buildColorIndex(paletteIndex, alpha) - return ot_paint + # COLR layers is unusual in that it modifies shared state + # so we need a callback into an object + def _beforeBuildPaintColrLayers(self, dest, source): + paint = ot.Paint() + paint.Format = int(ot.PaintFormat.PaintColrLayers) + self.slices.append(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.PaintFormat.PaintLinearGradient) - ot_paint.ColorLine = _to_color_line(colorLine) + # 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"] - 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)) + # Convert maps seqs or whatever into typed objects + layers = [self.buildPaint(l) for l in layers] - 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.PaintFormat.PaintRadialGradient) - ot_paint.ColorLine = _to_color_line(colorLine) - - # normalize input types (which may or may not specify a varIdx) - x0, y0 = _to_variable_value(c0[0]), _to_variable_value(c0[1]) - r0 = _to_variable_value(r0) - x1, y1 = _to_variable_value(c1[0]), _to_variable_value(c1[1]) - r1 = _to_variable_value(r1) - - # avoid abrupt change after rounding when c0 is near c1's perimeter - c = round_start_circle_stable_containment( - (x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value - ) - x0, y0 = x0._replace(value=c.centre[0]), y0._replace(value=c.centre[1]) - r0 = r0._replace(value=c.radius) - - for i, (x, y, r) in enumerate(((x0, y0, r0), (x1, y1, r1))): - # rounding happens here as floats are converted to integers - 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 buildPaintSweepGradient( - self, - colorLine: _ColorLineInput, - centerX: _ScalarInput, - centerY: _ScalarInput, - startAngle: _ScalarInput, - endAngle: _ScalarInput, - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintSweepGradient) - ot_paint.ColorLine = _to_color_line(colorLine) - ot_paint.centerX = _to_variable_int16(centerX) - ot_paint.centerY = _to_variable_int16(centerY) - ot_paint.startAngle = _to_variable_f16dot16_float(startAngle) - ot_paint.endAngle = _to_variable_f16dot16_float(endAngle) - return ot_paint - - def buildPaintGlyph(self, glyph: str, paint: _PaintInput) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.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.PaintFormat.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.PaintFormat.PaintTransform) - if not isinstance(transform, ot.Affine2x3): - transform = buildAffine2x3(transform) - ot_paint.Transform = transform - ot_paint.Paint = self.buildPaint(paint) - return ot_paint - - def buildPaintTranslate( - self, paint: _PaintInput, dx: _ScalarInput, dy: _ScalarInput - ): - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintTranslate) - ot_paint.Paint = self.buildPaint(paint) - ot_paint.dx = _to_variable_f16dot16_float(dx) - ot_paint.dy = _to_variable_f16dot16_float(dy) - return ot_paint - - def buildPaintRotate( - self, - paint: _PaintInput, - angle: _ScalarInput, - centerX: _ScalarInput, - centerY: _ScalarInput, - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintRotate) - ot_paint.Paint = self.buildPaint(paint) - ot_paint.angle = _to_variable_f16dot16_float(angle) - ot_paint.centerX = _to_variable_f16dot16_float(centerX) - ot_paint.centerY = _to_variable_f16dot16_float(centerY) - return ot_paint - - def buildPaintSkew( - self, - paint: _PaintInput, - xSkewAngle: _ScalarInput, - ySkewAngle: _ScalarInput, - centerX: _ScalarInput, - centerY: _ScalarInput, - ) -> ot.Paint: - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.PaintSkew) - ot_paint.Paint = self.buildPaint(paint) - ot_paint.xSkewAngle = _to_variable_f16dot16_float(xSkewAngle) - ot_paint.ySkewAngle = _to_variable_f16dot16_float(ySkewAngle) - ot_paint.centerX = _to_variable_f16dot16_float(centerX) - ot_paint.centerY = _to_variable_f16dot16_float(centerY) - return ot_paint - - def buildPaintComposite( - self, - mode: _CompositeInput, - source: _PaintInput, - backdrop: _PaintInput, - ): - ot_paint = ot.Paint() - ot_paint.Format = int(ot.PaintFormat.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.PaintFormat.PaintColrLayers) - self.slices.append(ot_paint) - - paints = [ - self.buildPaint(p) - for p in _build_n_ary_tree(paints, n=MAX_PAINT_COLR_LAYER_COUNT) - ] + # No reason to have a colr layers with just one entry + if len(layers) == 1: + return layers[0], {} # Look for reuse, with preference to longer sequences + # This may make the layer list smaller found_reuse = True while found_reuse: found_reuse = False ranges = sorted( - _reuse_ranges(len(paints)), + _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(paints[lbound:ubound]), -1 + self._as_tuple(layers[lbound:ubound]), -1 ) if reuse_lbound == -1: continue @@ -675,47 +475,45 @@ class LayerV1ListBuilder: new_slice.Format = int(ot.PaintFormat.PaintColrLayers) new_slice.NumLayers = ubound - lbound new_slice.FirstLayerIndex = reuse_lbound - paints = paints[:lbound] + [new_slice] + paints[ubound:] + layers = layers[:lbound] + [new_slice] + layers[ubound:] found_reuse = True break - ot_paint.NumLayers = len(paints) - ot_paint.FirstLayerIndex = len(self.layers) - self.layers.extend(paints) + # 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) - # Register our parts for reuse - for lbound, ubound in _reuse_ranges(len(paints)): - self.reusePool[self._as_tuple(paints[lbound:ubound])] = ( - lbound + ot_paint.FirstLayerIndex - ) + # 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 - return ot_paint + layers = [listToColrLayers(l) for l in layers] + + 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 not is_tree: + for lbound, ubound in _reuse_ranges(len(layers)): + self.reusePool[self._as_tuple(layers[lbound:ubound])] = ( + lbound + paint.FirstLayerIndex + ) + + # we've fully built dest; empty source prevents generalized build from kicking in + return 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}") + return self.tableBuilder.build(ot.Paint, paint) def build(self) -> ot.LayerV1List: layers = ot.LayerV1List() @@ -724,31 +522,6 @@ class LayerV1ListBuilder: return layers -LayerV1ListBuilder._buildFunctions = { - pf.value: getattr(LayerV1ListBuilder, "build" + pf.name) - for pf in ot.PaintFormat - if pf != ot.PaintFormat.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: diff --git a/Lib/fontTools/colorLib/table_builder.py b/Lib/fontTools/colorLib/table_builder.py new file mode 100644 index 000000000..18e2de181 --- /dev/null +++ b/Lib/fontTools/colorLib/table_builder.py @@ -0,0 +1,234 @@ +""" +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, + VarInt16, + VarUInt16, + IntValue, + FloatValue, +) +from fontTools.misc.fixedTools 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). + 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 convertTupleClass(tupleClass, value): + if isinstance(value, tupleClass): + return value + if isinstance(value, tuple): + return tupleClass(*value) + return tupleClass(value) + + +def _isNonStrSequence(value): + return isinstance(value, collections.abc.Sequence) and not isinstance(value, str) + + +def _set_format(dest, source): + if _isNonStrSequence(source): + assert len(source) > 0, f"{type(dest)} needs at least format from {source}" + dest.Format = source[0] + source = source[1:] + elif isinstance(source, collections.abc.Mapping): + assert "Format" in source, f"{type(dest)} needs at least Format from {source}" + dest.Format = source["Format"] + else: + raise ValueError(f"Not sure how to populate {type(dest)} from {source}") + + assert isinstance( + dest.Format, collections.abc.Hashable + ), f"{type(dest)} Format is not hashable: {dest.Format}" + assert ( + dest.Format in dest.convertersByName + ), f"{dest.Format} invalid Format of {cls}" + + return source + + +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): + tupleClass = getattr(converter, "tupleClass", None) + enumClass = getattr(converter, "enumClass", None) + + if tupleClass: + value = convertTupleClass(tupleClass, value) + + elif 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,) + 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): + source = _set_format(dest, source) + + convByName = _assignable(convByName[dest.Format]) + skippedFields.add("Format") + callbackKey = (cls, dest.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,)) + + 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) + + tupleClass = getattr(converter, "tupleClass", None) + enumClass = getattr(converter, "enumClass", None) + if tupleClass: + source[converter.name] = tuple(value) + elif 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 diff --git a/Lib/fontTools/colorLib/unbuilder.py b/Lib/fontTools/colorLib/unbuilder.py index 1c29d5dd2..43582bde3 100644 --- a/Lib/fontTools/colorLib/unbuilder.py +++ b/Lib/fontTools/colorLib/unbuilder.py @@ -1,47 +1,15 @@ from fontTools.ttLib.tables import otTables as ot +from .table_builder import TableUnbuilder -def unbuildColrV1(layerV1List, baseGlyphV1List, ignoreVarIdx=False): - unbuilder = LayerV1ListUnbuilder(layerV1List.Paint, ignoreVarIdx=ignoreVarIdx) +def unbuildColrV1(layerV1List, baseGlyphV1List): + unbuilder = LayerV1ListUnbuilder(layerV1List.Paint) return { rec.BaseGlyph: unbuilder.unbuildPaint(rec.Paint) for rec in baseGlyphV1List.BaseGlyphV1Record } -def _unbuildVariableValue(v, ignoreVarIdx=False): - return v.value if ignoreVarIdx else (v.value, v.varIdx) - - -def unbuildColorStop(colorStop, ignoreVarIdx=False): - return { - "offset": _unbuildVariableValue( - colorStop.StopOffset, ignoreVarIdx=ignoreVarIdx - ), - "paletteIndex": colorStop.Color.PaletteIndex, - "alpha": _unbuildVariableValue( - colorStop.Color.Alpha, ignoreVarIdx=ignoreVarIdx - ), - } - - -def unbuildColorLine(colorLine, ignoreVarIdx=False): - return { - "stops": [ - unbuildColorStop(stop, ignoreVarIdx=ignoreVarIdx) - for stop in colorLine.ColorStop - ], - "extend": colorLine.Extend.name.lower(), - } - - -def unbuildAffine2x3(transform, ignoreVarIdx=False): - return tuple( - _unbuildVariableValue(getattr(transform, attr), ignoreVarIdx=ignoreVarIdx) - for attr in ("xx", "yx", "xy", "yy", "dx", "dy") - ) - - def _flatten(lst): for el in lst: if isinstance(el, list): @@ -51,142 +19,40 @@ def _flatten(lst): class LayerV1ListUnbuilder: - def __init__(self, layers, ignoreVarIdx=False): + def __init__(self, layers): self.layers = layers - self.ignoreVarIdx = ignoreVarIdx + + callbacks = { + ( + ot.Paint, + ot.PaintFormat.PaintColrLayers, + ): self._unbuildPaintColrLayers, + } + self.tableUnbuilder = TableUnbuilder(callbacks) def unbuildPaint(self, paint): - try: - return self._unbuildFunctions[paint.Format](self, paint) - except KeyError: - raise ValueError(f"Unrecognized paint format: {paint.Format}") + assert isinstance(paint, ot.Paint) + return self.tableUnbuilder.unbuild(paint) - def unbuildVariableValue(self, value): - return _unbuildVariableValue(value, ignoreVarIdx=self.ignoreVarIdx) + def _unbuildPaintColrLayers(self, source): + assert source["Format"] == ot.PaintFormat.PaintColrLayers - def unbuildPaintColrLayers(self, paint): - return list( + layers = list( _flatten( [ self.unbuildPaint(childPaint) for childPaint in self.layers[ - paint.FirstLayerIndex : paint.FirstLayerIndex + paint.NumLayers + source["FirstLayerIndex"] : source["FirstLayerIndex"] + + source["NumLayers"] ] ] ) ) - def unbuildPaintSolid(self, paint): - return { - "format": int(paint.Format), - "paletteIndex": paint.Color.PaletteIndex, - "alpha": self.unbuildVariableValue(paint.Color.Alpha), - } + if len(layers) == 1: + return layers[0] - def unbuildPaintLinearGradient(self, paint): - p0 = (self.unbuildVariableValue(paint.x0), self.unbuildVariableValue(paint.y0)) - p1 = (self.unbuildVariableValue(paint.x1), self.unbuildVariableValue(paint.y1)) - p2 = (self.unbuildVariableValue(paint.x2), self.unbuildVariableValue(paint.y2)) - return { - "format": int(paint.Format), - "colorLine": unbuildColorLine( - paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx - ), - "p0": p0, - "p1": p1, - "p2": p2, - } - - def unbuildPaintRadialGradient(self, paint): - c0 = (self.unbuildVariableValue(paint.x0), self.unbuildVariableValue(paint.y0)) - r0 = self.unbuildVariableValue(paint.r0) - c1 = (self.unbuildVariableValue(paint.x1), self.unbuildVariableValue(paint.y1)) - r1 = self.unbuildVariableValue(paint.r1) - return { - "format": int(paint.Format), - "colorLine": unbuildColorLine( - paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx - ), - "c0": c0, - "r0": r0, - "c1": c1, - "r1": r1, - } - - def unbuildPaintSweepGradient(self, paint): - return { - "format": int(paint.Format), - "colorLine": unbuildColorLine( - paint.ColorLine, ignoreVarIdx=self.ignoreVarIdx - ), - "centerX": self.unbuildVariableValue(paint.centerX), - "centerY": self.unbuildVariableValue(paint.centerY), - "startAngle": self.unbuildVariableValue(paint.startAngle), - "endAngle": self.unbuildVariableValue(paint.endAngle), - } - - def unbuildPaintGlyph(self, paint): - return { - "format": int(paint.Format), - "glyph": paint.Glyph, - "paint": self.unbuildPaint(paint.Paint), - } - - def unbuildPaintColrGlyph(self, paint): - return { - "format": int(paint.Format), - "glyph": paint.Glyph, - } - - def unbuildPaintTransform(self, paint): - return { - "format": int(paint.Format), - "transform": unbuildAffine2x3( - paint.Transform, ignoreVarIdx=self.ignoreVarIdx - ), - "paint": self.unbuildPaint(paint.Paint), - } - - def unbuildPaintTranslate(self, paint): - return { - "format": int(paint.Format), - "dx": self.unbuildVariableValue(paint.dx), - "dy": self.unbuildVariableValue(paint.dy), - "paint": self.unbuildPaint(paint.Paint), - } - - def unbuildPaintRotate(self, paint): - return { - "format": int(paint.Format), - "angle": self.unbuildVariableValue(paint.angle), - "centerX": self.unbuildVariableValue(paint.centerX), - "centerY": self.unbuildVariableValue(paint.centerY), - "paint": self.unbuildPaint(paint.Paint), - } - - def unbuildPaintSkew(self, paint): - return { - "format": int(paint.Format), - "xSkewAngle": self.unbuildVariableValue(paint.xSkewAngle), - "ySkewAngle": self.unbuildVariableValue(paint.ySkewAngle), - "centerX": self.unbuildVariableValue(paint.centerX), - "centerY": self.unbuildVariableValue(paint.centerY), - "paint": self.unbuildPaint(paint.Paint), - } - - def unbuildPaintComposite(self, paint): - return { - "format": int(paint.Format), - "mode": paint.CompositeMode.name.lower(), - "source": self.unbuildPaint(paint.SourcePaint), - "backdrop": self.unbuildPaint(paint.BackdropPaint), - } - - -LayerV1ListUnbuilder._unbuildFunctions = { - pf.value: getattr(LayerV1ListUnbuilder, "unbuild" + pf.name) - for pf in ot.PaintFormat -} + return {"Format": source["Format"], "Layers": layers} if __name__ == "__main__": diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index 389ac5c42..a5c5ad600 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -1588,6 +1588,15 @@ otData = [ ('LOffset', 'Paint', 'LayerCount', 0, 'Array of offsets to Paint tables, from the start of the LayerV1List table.'), ]), + # 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 ('Affine2x3', [ ('VarFixed', 'xx', None, None, 'x-part of x basis vector'), ('VarFixed', 'yx', None, None, 'y-part of x basis vector'), diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index a705b38f3..f9ffdd242 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -3,11 +3,20 @@ from fontTools.ttLib.tables import otTables as ot from fontTools.colorLib import builder from fontTools.colorLib.geometry import round_start_circle_stable_containment, Circle from fontTools.colorLib.builder import LayerV1ListBuilder, _build_n_ary_tree +from fontTools.colorLib.table_builder import TableBuilder from fontTools.colorLib.errors import ColorLibError import pytest from typing import List +def _build(cls, source): + return LayerV1ListBuilder().tableBuilder.build(cls, source) + + +def _buildPaint(source): + return LayerV1ListBuilder().buildPaint(source) + + def test_buildCOLR_v0(): color_layer_lists = { "a": [("a.color0", 0), ("a.color1", 1)], @@ -223,37 +232,45 @@ def test_buildCPAL_invalid_color(): def test_buildColorIndex(): - c = builder.buildColorIndex(0) - assert c.PaletteIndex == 0 + c = _build(ot.ColorIndex, 1) + assert c.PaletteIndex == 1 assert c.Alpha.value == 1.0 assert c.Alpha.varIdx == 0 - c = builder.buildColorIndex(1, alpha=0.5) + +def test_buildColorIndex_Alpha(): + c = _build(ot.ColorIndex, (1, 0.5)) assert c.PaletteIndex == 1 assert c.Alpha.value == 0.5 assert c.Alpha.varIdx == 0 - c = builder.buildColorIndex(3, alpha=builder.VariableFloat(0.5, varIdx=2)) + +def test_buildColorIndex_Variable(): + c = _build(ot.ColorIndex, (3, builder.VariableFloat(0.5, varIdx=2))) assert c.PaletteIndex == 3 assert c.Alpha.value == 0.5 assert c.Alpha.varIdx == 2 def test_buildPaintSolid(): - p = LayerV1ListBuilder().buildPaintSolid(0) + p = _buildPaint((ot.PaintFormat.PaintSolid, 0)) assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 0 assert p.Color.Alpha.value == 1.0 assert p.Color.Alpha.varIdx == 0 - p = LayerV1ListBuilder().buildPaintSolid(1, alpha=0.5) + +def test_buildPaintSolid_Alpha(): + p = _buildPaint((ot.PaintFormat.PaintSolid, (1, 0.5))) assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 1 assert p.Color.Alpha.value == 0.5 assert p.Color.Alpha.varIdx == 0 - p = LayerV1ListBuilder().buildPaintSolid( - 3, alpha=builder.VariableFloat(0.5, varIdx=2) + +def test_buildPaintSolid_Variable(): + p = _buildPaint( + (ot.PaintFormat.PaintSolid, (3, builder.VariableFloat(0.5, varIdx=2))) ) assert p.Format == ot.PaintFormat.PaintSolid assert p.Color.PaletteIndex == 3 @@ -261,67 +278,88 @@ def test_buildPaintSolid(): assert p.Color.Alpha.varIdx == 2 -def test_buildColorStop(): - s = builder.buildColorStop(0.1, 2) +def test_buildColorStop_DefaultAlpha(): + s = _build(ot.ColorStop, (0.1, 2)) assert s.StopOffset == builder.VariableFloat(0.1) assert s.Color.PaletteIndex == 2 assert s.Color.Alpha == builder._DEFAULT_ALPHA - s = builder.buildColorStop(offset=0.2, paletteIndex=3, alpha=0.4) - assert s.StopOffset == builder.VariableFloat(0.2) - assert s.Color == builder.buildColorIndex(3, alpha=0.4) - s = builder.buildColorStop( - offset=builder.VariableFloat(0.0, varIdx=1), - paletteIndex=0, - alpha=builder.VariableFloat(0.3, varIdx=2), +def test_buildColorStop(): + s = _build( + ot.ColorStop, {"StopOffset": 0.2, "Color": {"PaletteIndex": 3, "Alpha": 0.4}} + ) + assert s.StopOffset == builder.VariableFloat(0.2) + assert s.Color == _build(ot.ColorIndex, (3, 0.4)) + + +def test_buildColorStop_Variable(): + s = _build( + ot.ColorStop, + { + "StopOffset": builder.VariableFloat(0.0, varIdx=1), + "Color": { + "PaletteIndex": 0, + "Alpha": builder.VariableFloat(0.3, varIdx=2), + }, + }, ) assert s.StopOffset == builder.VariableFloat(0.0, varIdx=1) assert s.Color.PaletteIndex == 0 assert s.Color.Alpha == builder.VariableFloat(0.3, varIdx=2) -def test_buildColorLine(): +def test_buildColorLine_StopList(): stops = [(0.0, 0), (0.5, 1), (1.0, 2)] - cline = builder.buildColorLine(stops) + cline = _build(ot.ColorLine, {"ColorStop": stops}) assert cline.Extend == builder.ExtendMode.PAD assert cline.StopCount == 3 assert [ (cs.StopOffset.value, cs.Color.PaletteIndex) for cs in cline.ColorStop ] == stops - cline = builder.buildColorLine(stops, extend="pad") + cline = _build(ot.ColorLine, {"Extend": "pad", "ColorStop": stops}) assert cline.Extend == builder.ExtendMode.PAD - cline = builder.buildColorLine(stops, extend=builder.ExtendMode.REPEAT) + cline = _build( + ot.ColorLine, {"ColorStop": stops, "Extend": builder.ExtendMode.REPEAT} + ) assert cline.Extend == builder.ExtendMode.REPEAT - cline = builder.buildColorLine(stops, extend=builder.ExtendMode.REFLECT) + cline = _build( + ot.ColorLine, {"ColorStop": stops, "Extend": builder.ExtendMode.REFLECT} + ) assert cline.Extend == builder.ExtendMode.REFLECT - cline = builder.buildColorLine([builder.buildColorStop(*s) for s in stops]) + cline = _build( + ot.ColorLine, {"ColorStop": [_build(ot.ColorStop, s) for s in stops]} + ) assert [ (cs.StopOffset.value, cs.Color.PaletteIndex) for cs in cline.ColorStop ] == stops + +def test_buildColorLine_StopMap_Variations(): stops = [ - {"offset": (0.0, 1), "paletteIndex": 0, "alpha": (0.5, 2)}, - {"offset": (1.0, 3), "paletteIndex": 1, "alpha": (0.3, 4)}, + {"StopOffset": (0.0, (1,)), "Color": {"PaletteIndex": 0, "Alpha": (0.5, 2)}}, + {"StopOffset": (1.0, (3,)), "Color": {"PaletteIndex": 1, "Alpha": (0.3, 4)}}, ] - cline = builder.buildColorLine(stops) + cline = _build(ot.ColorLine, {"ColorStop": stops}) assert [ { - "offset": cs.StopOffset, - "paletteIndex": cs.Color.PaletteIndex, - "alpha": cs.Color.Alpha, + "StopOffset": cs.StopOffset, + "Color": { + "PaletteIndex": cs.Color.PaletteIndex, + "Alpha": cs.Color.Alpha, + }, } for cs in cline.ColorStop ] == stops def test_buildAffine2x3(): - matrix = builder.buildAffine2x3((1.5, 0, 0.5, 2.0, 1.0, -3.0)) + matrix = _build(ot.Affine2x3, (1.5, 0, 0.5, 2.0, 1.0, -3.0)) assert matrix.xx == builder.VariableFloat(1.5) assert matrix.yx == builder.VariableFloat(0.0) assert matrix.xy == builder.VariableFloat(0.5) @@ -330,47 +368,55 @@ def test_buildAffine2x3(): assert matrix.dy == builder.VariableFloat(-3.0) -def test_buildPaintLinearGradient(): - layerBuilder = LayerV1ListBuilder() - color_stops = [ - builder.buildColorStop(0.0, 0), - builder.buildColorStop(0.5, 1), - builder.buildColorStop(1.0, 2, alpha=0.8), +def _sample_stops(): + return [ + _build(ot.ColorStop, (0.0, 0)), + _build(ot.ColorStop, (0.5, 1)), + _build(ot.ColorStop, (1.0, (2, 0.8))), ] - color_line = builder.buildColorLine(color_stops, extend=builder.ExtendMode.REPEAT) - p0 = (builder.VariableInt(100), builder.VariableInt(200)) - p1 = (builder.VariableInt(150), builder.VariableInt(250)) - gradient = layerBuilder.buildPaintLinearGradient(color_line, p0, p1) - assert gradient.Format == 3 - assert gradient.ColorLine == color_line - assert (gradient.x0, gradient.y0) == p0 - assert (gradient.x1, gradient.y1) == p1 - assert (gradient.x2, gradient.y2) == p1 - gradient = layerBuilder.buildPaintLinearGradient({"stops": color_stops}, p0, p1) +def test_buildPaintLinearGradient(): + color_stops = _sample_stops() + x0, y0, x1, y1, x2, y2 = tuple(builder.VariableInt(v) for v in (1, 2, 3, 4, 5, 6)) + gradient = _buildPaint( + { + "Format": ot.PaintFormat.PaintLinearGradient, + "ColorLine": {"ColorStop": color_stops}, + "x0": x0, + "y0": y0, + "x1": x1, + "y1": y1, + "x2": x2, + "y2": y2, + }, + ) assert gradient.ColorLine.Extend == builder.ExtendMode.PAD assert gradient.ColorLine.ColorStop == color_stops - gradient = layerBuilder.buildPaintLinearGradient(color_line, p0, p1, p2=(150, 230)) - assert (gradient.x2.value, gradient.y2.value) == (150, 230) - assert (gradient.x2, gradient.y2) != (gradient.x1, gradient.y1) + gradient = _buildPaint(gradient) + assert (gradient.x0.value, gradient.y0.value) == (1, 2) + assert (gradient.x1.value, gradient.y1.value) == (3, 4) + assert (gradient.x2.value, gradient.y2.value) == (5, 6) def test_buildPaintRadialGradient(): - layerBuilder = LayerV1ListBuilder() color_stops = [ - builder.buildColorStop(0.0, 0), - builder.buildColorStop(0.5, 1), - builder.buildColorStop(1.0, 2, alpha=0.8), + _build(ot.ColorStop, (0.0, (0,))), + _build(ot.ColorStop, (0.5, 1)), + _build(ot.ColorStop, (1.0, (2, 0.8))), ] - color_line = builder.buildColorLine(color_stops, extend=builder.ExtendMode.REPEAT) + color_line = _build( + ot.ColorLine, {"ColorStop": color_stops, "Extend": builder.ExtendMode.REPEAT} + ) c0 = (builder.VariableInt(100), builder.VariableInt(200)) c1 = (builder.VariableInt(150), builder.VariableInt(250)) r0 = builder.VariableInt(10) r1 = builder.VariableInt(5) - gradient = layerBuilder.buildPaintRadialGradient(color_line, c0, c1, r0, r1) + gradient = _build( + ot.Paint, (ot.PaintFormat.PaintRadialGradient, color_line, *c0, r0, *c1, r1) + ) assert gradient.Format == ot.PaintFormat.PaintRadialGradient assert gradient.ColorLine == color_line assert (gradient.x0, gradient.y0) == c0 @@ -378,27 +424,43 @@ def test_buildPaintRadialGradient(): assert gradient.r0 == r0 assert gradient.r1 == r1 - gradient = layerBuilder.buildPaintRadialGradient( - {"stops": color_stops}, c0, c1, r0, r1 + gradient = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintRadialGradient, + "ColorLine": {"ColorStop": color_stops}, + "x0": c0[0], + "y0": c0[1], + "x1": c1[0], + "y1": c1[1], + "r0": r0, + "r1": r1, + }, ) assert gradient.ColorLine.Extend == builder.ExtendMode.PAD assert gradient.ColorLine.ColorStop == color_stops + assert (gradient.x0, gradient.y0) == c0 + assert (gradient.x1, gradient.y1) == c1 + assert gradient.r0 == r0 + assert gradient.r1 == r1 def test_buildPaintSweepGradient(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintSweepGradient( - colorLine=builder.buildColorLine( - stops=[ - builder.buildColorStop(0.0, 0), - builder.buildColorStop(0.5, 1), - builder.buildColorStop(1.0, 2, alpha=0.8), - ], - ), - centerX=127, - centerY=129, - startAngle=15, - endAngle=42, + paint = _buildPaint( + { + "Format": ot.PaintFormat.PaintSweepGradient, + "ColorLine": { + "ColorStop": ( + (0.0, 0), + (0.5, 1), + (1.0, (2, 0.8)), + ) + }, + "centerX": 127, + "centerY": 129, + "startAngle": 15, + "endAngle": 42, + } ) assert paint.Format == ot.PaintFormat.PaintSweepGradient @@ -409,25 +471,53 @@ def test_buildPaintSweepGradient(): def test_buildPaintGlyph_Solid(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph("a", 2) + layer = _build( + ot.Paint, + ( + ot.PaintFormat.PaintGlyph, + ( + ot.PaintFormat.PaintSolid, + 2, + ), + "a", + ), + ) + assert layer.Format == ot.PaintFormat.PaintGlyph assert layer.Glyph == "a" assert layer.Paint.Format == ot.PaintFormat.PaintSolid assert layer.Paint.Color.PaletteIndex == 2 - layer = layerBuilder.buildPaintGlyph("a", layerBuilder.buildPaintSolid(3, 0.9)) + layer = _build( + ot.Paint, + ( + ot.PaintFormat.PaintGlyph, + ( + ot.PaintFormat.PaintSolid, + (3, 0.9), + ), + "a", + ), + ) assert layer.Paint.Format == ot.PaintFormat.PaintSolid assert layer.Paint.Color.PaletteIndex == 3 assert layer.Paint.Color.Alpha.value == 0.9 def test_buildPaintGlyph_LinearGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", - layerBuilder.buildPaintLinearGradient( - {"stops": [(0.0, 3), (1.0, 4)]}, (100, 200), (150, 250) - ), + layer = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "a", + "Paint": { + "Format": ot.PaintFormat.PaintLinearGradient, + "ColorLine": {"ColorStop": [(0.0, 3), (1.0, 4)]}, + "x0": 100, + "y0": 200, + "x1": 150, + "y1": 250, + }, + }, ) assert layer.Paint.Format == ot.PaintFormat.PaintLinearGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 @@ -441,23 +531,31 @@ def test_buildPaintGlyph_LinearGradient(): def test_buildPaintGlyph_RadialGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", - layerBuilder.buildPaintRadialGradient( - { - "stops": [ - (0.0, 5), - {"offset": 0.5, "paletteIndex": 6, "alpha": 0.8}, - (1.0, 7), - ] - }, - (50, 50), - (75, 75), - 30, - 10, + layer = _build( + ot.Paint, + ( + int(ot.PaintFormat.PaintGlyph), + ( + ot.PaintFormat.PaintRadialGradient, + ( + "pad", + [ + (0.0, 5), + {"StopOffset": 0.5, "Color": {"PaletteIndex": 6, "Alpha": 0.8}}, + (1.0, 7), + ], + ), + 50, + 50, + 30, + 75, + 75, + 10, + ), + "a", ), ) + assert layer.Format == ot.PaintFormat.PaintGlyph assert layer.Paint.Format == ot.PaintFormat.PaintRadialGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 assert layer.Paint.ColorLine.ColorStop[0].Color.PaletteIndex == 5 @@ -475,39 +573,58 @@ def test_buildPaintGlyph_RadialGradient(): def test_buildPaintGlyph_Dict_Solid(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph("a", {"format": 2, "paletteIndex": 0}) + layer = _build( + ot.Paint, + ( + int(ot.PaintFormat.PaintGlyph), + (int(ot.PaintFormat.PaintSolid), 1), + "a", + ), + ) + assert layer.Format == ot.PaintFormat.PaintGlyph + assert layer.Format == ot.PaintFormat.PaintGlyph assert layer.Glyph == "a" assert layer.Paint.Format == ot.PaintFormat.PaintSolid - assert layer.Paint.Color.PaletteIndex == 0 + assert layer.Paint.Color.PaletteIndex == 1 def test_buildPaintGlyph_Dict_LinearGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", + layer = _build( + ot.Paint, { - "format": 3, - "colorLine": {"stops": [(0.0, 0), (1.0, 1)]}, - "p0": (0, 0), - "p1": (10, 10), + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "a", + "Paint": { + "Format": 3, + "ColorLine": {"ColorStop": [(0.0, 0), (1.0, 1)]}, + "x0": 0, + "y0": 0, + "x1": 10, + "y1": 10, + }, }, ) + assert layer.Format == ot.PaintFormat.PaintGlyph + assert layer.Glyph == "a" assert layer.Paint.Format == ot.PaintFormat.PaintLinearGradient assert layer.Paint.ColorLine.ColorStop[0].StopOffset.value == 0.0 def test_buildPaintGlyph_Dict_RadialGradient(): - layerBuilder = LayerV1ListBuilder() - layer = layerBuilder.buildPaintGlyph( - "a", + layer = _buildPaint( { - "format": 4, - "colorLine": {"stops": [(0.0, 0), (1.0, 1)]}, - "c0": (0, 0), - "c1": (10, 10), - "r0": 4, - "r1": 0, + "Glyph": "a", + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": {"ColorStop": [(0.0, 0), (1.0, 1)]}, + "x0": 0, + "y0": 0, + "r0": 4, + "x1": 10, + "y1": 10, + "r1": 0, + }, + "Format": int(ot.PaintFormat.PaintGlyph), }, ) assert layer.Paint.Format == ot.PaintFormat.PaintRadialGradient @@ -515,18 +632,17 @@ def test_buildPaintGlyph_Dict_RadialGradient(): def test_buildPaintColrGlyph(): - paint = LayerV1ListBuilder().buildPaintColrGlyph("a") + paint = _buildPaint((int(ot.PaintFormat.PaintColrGlyph), "a")) assert paint.Format == ot.PaintFormat.PaintColrGlyph assert paint.Glyph == "a" def test_buildPaintTransform(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintTransform( - transform=builder.buildAffine2x3((1, 2, 3, 4, 5, 6)), - paint=layerBuilder.buildPaintGlyph( - glyph="a", - paint=layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0), + paint = _buildPaint( + ( + int(ot.PaintFormat.PaintTransform), + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, (0, 1.0)), "a"), + _build(ot.Affine2x3, (1, 2, 3, 4, 5, 6)), ), ) @@ -541,22 +657,28 @@ def test_buildPaintTransform(): assert paint.Transform.dx.value == 5.0 assert paint.Transform.dy.value == 6.0 - paint = layerBuilder.buildPaintTransform( - (1, 0, 0, 0.3333, 10, 10), + paint = _build( + ot.Paint, { - "format": 4, - "colorLine": {"stops": [(0.0, 0), (1.0, 1)]}, - "c0": (100, 100), - "c1": (100, 100), - "r0": 0, - "r1": 50, + "Format": ot.PaintFormat.PaintTransform, + "Transform": (1, 2, 3, 0.3333, 10, 10), + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": {"ColorStop": [(0.0, 0), (1.0, 1)]}, + "x0": 100, + "y0": 101, + "x1": 102, + "y1": 103, + "r0": 0, + "r1": 50, + }, }, ) assert paint.Format == ot.PaintFormat.PaintTransform assert paint.Transform.xx.value == 1.0 - assert paint.Transform.yx.value == 0.0 - assert paint.Transform.xy.value == 0.0 + assert paint.Transform.yx.value == 2.0 + assert paint.Transform.xy.value == 3.0 assert paint.Transform.yy.value == 0.3333 assert paint.Transform.dx.value == 10 assert paint.Transform.dy.value == 10 @@ -564,18 +686,34 @@ def test_buildPaintTransform(): def test_buildPaintComposite(): - layerBuilder = LayerV1ListBuilder() - composite = layerBuilder.buildPaintComposite( - mode=ot.CompositeMode.SRC_OVER, - source={ - "format": 12, - "mode": "src_over", - "source": {"format": 6, "glyph": "c", "paint": 2}, - "backdrop": {"format": 6, "glyph": "b", "paint": 1}, + composite = _build( + ot.Paint, + { + "Format": int(ot.PaintFormat.PaintComposite), + "CompositeMode": "src_over", + "SourcePaint": { + "Format": ot.PaintFormat.PaintComposite, + "CompositeMode": "src_over", + "SourcePaint": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "c", + "Paint": (ot.PaintFormat.PaintSolid, 2), + }, + "BackdropPaint": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Glyph": "b", + "Paint": (ot.PaintFormat.PaintSolid, 1), + }, + }, + "BackdropPaint": { + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "a", + "Paint": { + "Format": ot.PaintFormat.PaintSolid, + "Color": (0, 1.0), + }, + }, }, - backdrop=layerBuilder.buildPaintGlyph( - "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) - ), ) assert composite.Format == ot.PaintFormat.PaintComposite @@ -587,9 +725,7 @@ def test_buildPaintComposite(): assert composite.SourcePaint.CompositeMode == ot.CompositeMode.SRC_OVER assert composite.SourcePaint.BackdropPaint.Format == ot.PaintFormat.PaintGlyph assert composite.SourcePaint.BackdropPaint.Glyph == "b" - assert ( - composite.SourcePaint.BackdropPaint.Paint.Format == ot.PaintFormat.PaintSolid - ) + assert composite.SourcePaint.BackdropPaint.Paint.Format == ot.PaintFormat.PaintSolid assert composite.SourcePaint.BackdropPaint.Paint.Color.PaletteIndex == 1 assert composite.CompositeMode == ot.CompositeMode.SRC_OVER assert composite.BackdropPaint.Format == ot.PaintFormat.PaintGlyph @@ -599,13 +735,18 @@ def test_buildPaintComposite(): def test_buildPaintTranslate(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintTranslate( - paint=layerBuilder.buildPaintGlyph( - "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) - ), - dx=123, - dy=-345, + paint = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintTranslate, + "Paint": ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (0, 1.0)), + "a", + ), + "dx": 123, + "dy": -345, + }, ) assert paint.Format == ot.PaintFormat.PaintTranslate @@ -615,14 +756,19 @@ def test_buildPaintTranslate(): def test_buildPaintRotate(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintRotate( - paint=layerBuilder.buildPaintGlyph( - "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) - ), - angle=15, - centerX=127, - centerY=129, + paint = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintRotate, + "Paint": ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (0, 1.0)), + "a", + ), + "angle": 15, + "centerX": 127, + "centerY": 129, + }, ) assert paint.Format == ot.PaintFormat.PaintRotate @@ -633,15 +779,20 @@ def test_buildPaintRotate(): def test_buildPaintSkew(): - layerBuilder = LayerV1ListBuilder() - paint = layerBuilder.buildPaintSkew( - paint=layerBuilder.buildPaintGlyph( - "a", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=1.0) - ), - xSkewAngle=15, - ySkewAngle=42, - centerX=127, - centerY=129, + paint = _build( + ot.Paint, + { + "Format": ot.PaintFormat.PaintSkew, + "Paint": ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (0, 1.0)), + "a", + ), + "xSkewAngle": 15, + "ySkewAngle": 42, + "centerX": 127, + "centerY": 129, + }, ) assert paint.Format == ot.PaintFormat.PaintSkew @@ -654,22 +805,44 @@ def test_buildPaintSkew(): def test_buildColrV1(): colorGlyphs = { - "a": [("b", 0), ("c", 1)], - "d": [ - ("e", {"format": 2, "paletteIndex": 2, "alpha": 0.8}), - ( - "f", - { - "format": 4, - "colorLine": {"stops": [(0.0, 3), (1.0, 4)], "extend": "reflect"}, - "c0": (0, 0), - "c1": (0, 0), - "r0": 10, - "r1": 0, - }, - ), - ], - "g": [("h", 5)], + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "b"), + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 1), "c"), + ], + ), + "d": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + {"Format": 2, "Color": {"PaletteIndex": 2, "Alpha": 0.8}}, + "e", + ), + ( + ot.PaintFormat.PaintGlyph, + { + "Format": 4, + "ColorLine": { + "ColorStop": [(0.0, 3), (1.0, 4)], + "Extend": "reflect", + }, + "x0": 0, + "y0": 0, + "x1": 0, + "y1": 0, + "r0": 10, + "r1": 0, + }, + "f", + ), + ], + ), + "g": ( + ot.PaintFormat.PaintColrLayers, + [(ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 5), "h")], + ), } glyphMap = { ".notdef": 0, @@ -700,14 +873,17 @@ def test_buildColrV1(): def test_buildColrV1_more_than_255_paints(): num_paints = 364 colorGlyphs = { - "a": [ - { - "format": 6, # PaintGlyph - "paint": 0, - "glyph": name, - } - for name in (f"glyph{i}" for i in range(num_paints)) - ], + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": (ot.PaintFormat.PaintSolid, 0), + "Glyph": name, + } + for name in (f"glyph{i}" for i in range(num_paints)) + ], + ), } layers, baseGlyphs = builder.buildColrV1(colorGlyphs) paints = layers.Paint @@ -750,9 +926,7 @@ def test_split_color_glyphs_by_version(): assert colorGlyphsV0 == {"a": [("b", 0), ("c", 1), ("d", 2), ("e", 3)]} assert not colorGlyphsV1 - colorGlyphs = { - "a": [("b", layerBuilder.buildPaintSolid(paletteIndex=0, alpha=0.0))] - } + colorGlyphs = {"a": (ot.PaintFormat.PaintGlyph, 0, "b")} colorGlyphsV0, colorGlyphsV1 = builder._split_color_glyphs_by_version(colorGlyphs) @@ -798,32 +972,35 @@ def assertNoV0Content(colr): def test_build_layerv1list_empty(): - # Nobody uses PaintColrLayers (format 1), no layerlist + # Nobody uses PaintColrLayers, no layerlist colr = builder.buildCOLR( { - "a": { - "format": 6, # PaintGlyph - "paint": {"format": 2, "paletteIndex": 2, "alpha": 0.8}, - "glyph": "b", - }, - # A list of 1 shouldn't become a PaintColrLayers - "b": [ - { - "format": 6, # PaintGlyph - "paint": { - "format": 3, - "colorLine": { - "stops": [(0.0, 2), (1.0, 3)], - "extend": "reflect", - }, - "p0": (1, 2), - "p1": (3, 4), - "p2": (2, 2), + # BaseGlyph, tuple form + "a": ( + int(ot.PaintFormat.PaintGlyph), + (2, (2, 0.8)), + "b", + ), + # BaseGlyph, map form + "b": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "ColorStop": [(0.0, 2), (1.0, 3)], + "Extend": "reflect", }, - "glyph": "bb", - } - ], - } + "x0": 1, + "y0": 2, + "x1": 3, + "y1": 4, + "x2": 2, + "y2": 2, + }, + "Glyph": "bb", + }, + }, + version=1, ) assertIsColrV1(colr) @@ -853,35 +1030,42 @@ def _paint_names(paints) -> List[str]: def test_build_layerv1list_simple(): # Two colr glyphs, each with two layers the first of which is common # All layers use the same solid paint - solid_paint = {"format": 2, "paletteIndex": 2, "alpha": 0.8} + solid_paint = {"Format": 2, "Color": {"PaletteIndex": 2, "Alpha": 0.8}} backdrop = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "back", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "back", } a_foreground = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "a_fore", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "a_fore", } b_foreground = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "b_fore", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "b_fore", } - # list => PaintColrLayers, which means contents should be in LayerV1List + # list => PaintColrLayers, contents should land in LayerV1List colr = builder.buildCOLR( { - "a": [ - backdrop, - a_foreground, - ], - "b": [ - backdrop, - b_foreground, - ], - } + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + backdrop, + a_foreground, + ], + ), + "b": { + "Format": ot.PaintFormat.PaintColrLayers, + "Layers": [ + backdrop, + b_foreground, + ], + }, + }, + version=1, ) assertIsColrV1(colr) @@ -902,47 +1086,51 @@ def test_build_layerv1list_simple(): def test_build_layerv1list_with_sharing(): # Three colr glyphs, each with two layers in common - solid_paint = {"format": 2, "paletteIndex": 2, "alpha": 0.8} + solid_paint = {"Format": 2, "Color": (2, 0.8)} backdrop = [ { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "back1", + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": solid_paint, + "Glyph": "back1", }, { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "back2", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "back2", }, ] a_foreground = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "a_fore", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "a_fore", } b_background = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "b_back", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "b_back", } b_foreground = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "b_fore", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "b_fore", } c_background = { - "format": 6, # PaintGlyph - "paint": solid_paint, - "glyph": "c_back", + "Format": ot.PaintFormat.PaintGlyph, + "Paint": solid_paint, + "Glyph": "c_back", } # list => PaintColrLayers, which means contents should be in LayerV1List colr = builder.buildCOLR( { - "a": backdrop + [a_foreground], - "b": [b_background] + backdrop + [b_foreground], - "c": [c_background] + backdrop, - } + "a": (ot.PaintFormat.PaintColrLayers, backdrop + [a_foreground]), + "b": ( + ot.PaintFormat.PaintColrLayers, + [b_background] + backdrop + [b_foreground], + ), + "c": (ot.PaintFormat.PaintColrLayers, [c_background] + backdrop), + }, + version=1, ) assertIsColrV1(colr) @@ -974,9 +1162,12 @@ def test_build_layerv1list_with_sharing(): def test_build_layerv1list_with_overlaps(): paints = [ { - "format": 6, # PaintGlyph - "paint": {"format": 2, "paletteIndex": 2, "alpha": 0.8}, - "glyph": c, + "Format": ot.PaintFormat.PaintGlyph, + "Paint": { + "Format": ot.PaintFormat.PaintSolid, + "Color": {"PaletteIndex": 2, "Alpha": 0.8}, + }, + "Glyph": c, } for c in "abcdefghi" ] @@ -984,10 +1175,11 @@ def test_build_layerv1list_with_overlaps(): # list => PaintColrLayers, which means contents should be in LayerV1List colr = builder.buildCOLR( { - "a": paints[0:4], - "b": paints[0:6], - "c": paints[2:8], - } + "a": (ot.PaintFormat.PaintColrLayers, paints[0:4]), + "b": (ot.PaintFormat.PaintColrLayers, paints[0:6]), + "c": (ot.PaintFormat.PaintColrLayers, paints[2:8]), + }, + version=1, ) assertIsColrV1(colr) @@ -1017,6 +1209,26 @@ def test_build_layerv1list_with_overlaps(): assert colr.table.LayerV1List.LayerCount == 11 +def test_explicit_version_1(): + colr = builder.buildCOLR( + { + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 0), "b"), + (ot.PaintFormat.PaintGlyph, (ot.PaintFormat.PaintSolid, 1), "c"), + ], + ) + }, + version=1, + ) + assert colr.version == 1 + assert not hasattr(colr, "ColorLayers") + assert hasattr(colr, "table") + assert isinstance(colr.table, ot.COLR) + assert colr.table.VarStore is None + + class BuildCOLRTest(object): def test_automatic_version_all_solid_color_glyphs(self): colr = builder.buildCOLR({"a": [("b", 0), ("c", 1)]}) @@ -1028,38 +1240,55 @@ class BuildCOLRTest(object): def test_automatic_version_no_solid_color_glyphs(self): colr = builder.buildCOLR( { - "a": [ - ( - "b", - { - "format": 4, - "colorLine": { - "stops": [(0.0, 0), (1.0, 1)], - "extend": "repeat", + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": { + "ColorStop": [(0.0, 0), (1.0, 1)], + "Extend": "repeat", + }, + "x0": 1, + "y0": 0, + "x1": 10, + "y1": 0, + "r0": 4, + "r1": 2, }, - "c0": (1, 0), - "c1": (10, 0), - "r0": 4, - "r1": 2, - }, - ), - ("c", {"format": 2, "paletteIndex": 2, "alpha": 0.8}), - ], - "d": [ - ( - "e", + "b", + ), + ( + ot.PaintFormat.PaintGlyph, + {"Format": 2, "Color": {"PaletteIndex": 2, "Alpha": 0.8}}, + "c", + ), + ], + ), + "d": ( + ot.PaintFormat.PaintColrLayers, + [ { - "format": 3, - "colorLine": { - "stops": [(0.0, 2), (1.0, 3)], - "extend": "reflect", + "Format": ot.PaintFormat.PaintGlyph, + "Glyph": "e", + "Paint": { + "Format": ot.PaintFormat.PaintLinearGradient, + "ColorLine": { + "ColorStop": [(0.0, 2), (1.0, 3)], + "Extend": "reflect", + }, + "x0": 1, + "y0": 2, + "x1": 3, + "y1": 4, + "x2": 2, + "y2": 2, }, - "p0": (1, 2), - "p1": (3, 4), - "p2": (2, 2), - }, - ), - ], + } + ], + ), } ) assertIsColrV1(colr) @@ -1072,19 +1301,30 @@ class BuildCOLRTest(object): colr = builder.buildCOLR( { "a": [("b", 0), ("c", 1)], - "d": [ - ( - "e", - { - "format": 3, - "colorLine": {"stops": [(0.0, 2), (1.0, 3)]}, - "p0": (1, 2), - "p1": (3, 4), - "p2": (2, 2), - }, - ), - ("f", {"format": 2, "paletteIndex": 2, "alpha": 0.8}), - ], + "d": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + { + "Format": ot.PaintFormat.PaintLinearGradient, + "ColorLine": {"ColorStop": [(0.0, 2), (1.0, 3)]}, + "x0": 1, + "y0": 2, + "x1": 3, + "y1": 4, + "x2": 2, + "y2": 2, + }, + "e", + ), + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, (2, 0.8)), + "f", + ), + ], + ), } ) assertIsColrV1(colr) @@ -1110,13 +1350,55 @@ class BuildCOLRTest(object): assert hasattr(colr, "ColorLayers") def test_explicit_version_1(self): - colr = builder.buildCOLR({"a": [("b", 0), ("c", 1)]}, version=1) + colr = builder.buildCOLR( + { + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, 0), + "b", + ), + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, 1), + "c", + ), + ], + ) + }, + version=1, + ) assert colr.version == 1 assert not hasattr(colr, "ColorLayers") assert hasattr(colr, "table") assert isinstance(colr.table, ot.COLR) assert colr.table.VarStore is None + def test_paint_one_colr_layers(self): + # A set of one layers should flip to just that layer + colr = builder.buildCOLR( + { + "a": ( + ot.PaintFormat.PaintColrLayers, + [ + ( + ot.PaintFormat.PaintGlyph, + (ot.PaintFormat.PaintSolid, 0), + "b", + ), + ], + ) + }, + ) + + assert len(colr.table.LayerV1List.Paint) == 0, "PaintColrLayers should be gone" + assert colr.table.BaseGlyphV1List.BaseGlyphCount == 1 + paint = colr.table.BaseGlyphV1List.BaseGlyphV1Record[0].Paint + assert paint.Format == ot.PaintFormat.PaintGlyph + assert paint.Paint.Format == ot.PaintFormat.PaintSolid + class TrickyRadialGradientTest: @staticmethod diff --git a/Tests/colorLib/table_builder_test.py b/Tests/colorLib/table_builder_test.py new file mode 100644 index 000000000..d0a76f5ad --- /dev/null +++ b/Tests/colorLib/table_builder_test.py @@ -0,0 +1,15 @@ +from fontTools.ttLib.tables import otTables # trigger setup to occur +from fontTools.ttLib.tables.otConverters import UShort +from fontTools.colorLib.table_builder import TableBuilder +import pytest + + +class WriteMe: + value = None + + +def test_intValue_otRound(): + dest = WriteMe() + converter = UShort("value", None, None) + TableBuilder()._convert(dest, "value", converter, 85.6) + assert dest.value == 86, "Should have used otRound" diff --git a/Tests/colorLib/unbuilder_test.py b/Tests/colorLib/unbuilder_test.py index decd1856b..6728720f6 100644 --- a/Tests/colorLib/unbuilder_test.py +++ b/Tests/colorLib/unbuilder_test.py @@ -5,155 +5,206 @@ import pytest TEST_COLOR_GLYPHS = { - "glyph00010": [ - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00011", - "paint": { - "format": int(ot.PaintFormat.PaintSolid), - "paletteIndex": 2, - "alpha": 0.5, - }, - }, - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00012", - "paint": { - "format": int(ot.PaintFormat.PaintLinearGradient), - "colorLine": { - "stops": [ - {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, - {"offset": 0.5, "paletteIndex": 4, "alpha": 1.0}, - {"offset": 1.0, "paletteIndex": 5, "alpha": 1.0}, - ], - "extend": "repeat", + "glyph00010": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "Color": {"PaletteIndex": 2, "Alpha": (0.5, 0)}, }, - "p0": (1, 2), - "p1": (-3, -4), - "p2": (5, 6), + "Glyph": "glyph00011", }, - }, - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00013", - "paint": { - "format": int(ot.PaintFormat.PaintTransform), - "transform": (-13.0, 14.0, 15.0, -17.0, 18.0, 19.0), - "paint": { - "format": int(ot.PaintFormat.PaintRadialGradient), - "colorLine": { - "stops": [ - {"offset": 0.0, "paletteIndex": 6, "alpha": 1.0}, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "Extend": "repeat", + "ColorStop": [ { - "offset": 1.0, - "paletteIndex": 7, - "alpha": 0.4, + "StopOffset": (0.0, 0), + "Color": {"PaletteIndex": 3, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (0.5, 0), + "Color": {"PaletteIndex": 4, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (1.0, 0), + "Color": {"PaletteIndex": 5, "Alpha": (1.0, 0)}, }, ], - "extend": "pad", }, - "c0": (7, 8), - "r0": 9, - "c1": (10, 11), - "r1": 12, + "x0": (1, 0), + "y0": (2, 0), + "x1": (-3, 0), + "y1": (-4, 0), + "x2": (5, 0), + "y2": (6, 0), }, + "Glyph": "glyph00012", }, - }, - { - "format": int(ot.PaintFormat.PaintTranslate), - "dx": 257.0, - "dy": 258.0, - "paint": { - "format": int(ot.PaintFormat.PaintRotate), - "angle": 45.0, - "centerX": 255.0, - "centerY": 256.0, - "paint": { - "format": int(ot.PaintFormat.PaintSkew), - "xSkewAngle": -11.0, - "ySkewAngle": 5.0, - "centerX": 253.0, - "centerY": 254.0, - "paint": { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00011", - "paint": { - "format": int(ot.PaintFormat.PaintSolid), - "paletteIndex": 2, - "alpha": 0.5, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintTransform), + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": { + "Extend": "pad", + "ColorStop": [ + { + "StopOffset": (0.0, 0), + "Color": {"PaletteIndex": 6, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (1.0, 0), + "Color": {"PaletteIndex": 7, "Alpha": (0.4, 0)}, + }, + ], }, + "x0": (7, 0), + "y0": (8, 0), + "r0": (9, 0), + "x1": (10, 0), + "y1": (11, 0), + "r1": (12, 0), + }, + "Transform": { + "xx": (-13.0, 0), + "yx": (14.0, 0), + "xy": (15.0, 0), + "yy": (-17.0, 0), + "dx": (18.0, 0), + "dy": (19.0, 0), }, }, + "Glyph": "glyph00013", }, - }, - ], + { + "Format": int(ot.PaintFormat.PaintTranslate), + "Paint": { + "Format": int(ot.PaintFormat.PaintRotate), + "Paint": { + "Format": int(ot.PaintFormat.PaintSkew), + "Paint": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "Color": {"PaletteIndex": 2, "Alpha": (0.5, 0)}, + }, + "Glyph": "glyph00011", + }, + "xSkewAngle": (-11.0, 0), + "ySkewAngle": (5.0, 0), + "centerX": (253.0, 0), + "centerY": (254.0, 0), + }, + "angle": (45.0, 0), + "centerX": (255.0, 0), + "centerY": (256.0, 0), + }, + "dx": (257.0, 0), + "dy": (258.0, 0), + }, + ], + }, "glyph00014": { - "format": int(ot.PaintFormat.PaintComposite), - "mode": "src_over", - "source": { - "format": int(ot.PaintFormat.PaintColrGlyph), - "glyph": "glyph00010", + "Format": int(ot.PaintFormat.PaintComposite), + "SourcePaint": { + "Format": int(ot.PaintFormat.PaintColrGlyph), + "Glyph": "glyph00010", }, - "backdrop": { - "format": int(ot.PaintFormat.PaintTransform), - "transform": (1.0, 0.0, 0.0, 1.0, 300.0, 0.0), - "paint": { - "format": int(ot.PaintFormat.PaintColrGlyph), - "glyph": "glyph00010", + "CompositeMode": "src_over", + "BackdropPaint": { + "Format": int(ot.PaintFormat.PaintTransform), + "Paint": { + "Format": int(ot.PaintFormat.PaintColrGlyph), + "Glyph": "glyph00010", + }, + "Transform": { + "xx": (1.0, 0), + "yx": (0.0, 0), + "xy": (0.0, 0), + "yy": (1.0, 0), + "dx": (300.0, 0), + "dy": (0.0, 0), }, }, }, "glyph00015": { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00011", - "paint": { - "format": int(ot.PaintFormat.PaintSweepGradient), - "colorLine": { - "stops": [ - {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, - {"offset": 1.0, "paletteIndex": 5, "alpha": 1.0}, + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSweepGradient), + "ColorLine": { + "Extend": "pad", + "ColorStop": [ + { + "StopOffset": (0.0, 0), + "Color": {"PaletteIndex": 3, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (1.0, 0), + "Color": {"PaletteIndex": 5, "Alpha": (1.0, 0)}, + }, ], - "extend": "pad", }, - "centerX": 259, - "centerY": 300, - "startAngle": 45.0, - "endAngle": 135.0, + "centerX": (259, 0), + "centerY": (300, 0), + "startAngle": (45.0, 0), + "endAngle": (135.0, 0), }, + "Glyph": "glyph00011", }, - "glyph00016": [ - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00011", - "paint": { - "format": int(ot.PaintFormat.PaintSolid), - "paletteIndex": 2, - "alpha": 0.5, - }, - }, - { - "format": int(ot.PaintFormat.PaintGlyph), - "glyph": "glyph00012", - "paint": { - "format": int(ot.PaintFormat.PaintLinearGradient), - "colorLine": { - "stops": [ - {"offset": 0.0, "paletteIndex": 3, "alpha": 1.0}, - {"offset": 0.5, "paletteIndex": 4, "alpha": 1.0}, - {"offset": 1.0, "paletteIndex": 5, "alpha": 1.0}, - ], - "extend": "repeat", + "glyph00016": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "Color": {"PaletteIndex": 2, "Alpha": (0.5, 0)}, }, - "p0": (1, 2), - "p1": (-3, -4), - "p2": (5, 6), + "Glyph": "glyph00011", }, - }, - ], + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "Extend": "repeat", + "ColorStop": [ + { + "StopOffset": (0.0, 0), + "Color": {"PaletteIndex": 3, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (0.5, 0), + "Color": {"PaletteIndex": 4, "Alpha": (1.0, 0)}, + }, + { + "StopOffset": (1.0, 0), + "Color": {"PaletteIndex": 5, "Alpha": (1.0, 0)}, + }, + ], + }, + "x0": (1, 0), + "y0": (2, 0), + "x1": (-3, 0), + "y1": (-4, 0), + "x2": (5, 0), + "y2": (6, 0), + }, + "Glyph": "glyph00012", + }, + ], + }, } def test_unbuildColrV1(): layersV1, baseGlyphsV1 = buildColrV1(TEST_COLOR_GLYPHS) - colorGlyphs = unbuildColrV1(layersV1, baseGlyphsV1, ignoreVarIdx=True) + colorGlyphs = unbuildColrV1(layersV1, baseGlyphsV1) assert colorGlyphs == TEST_COLOR_GLYPHS