diff --git a/Lib/fontTools/colorLib/builder.py b/Lib/fontTools/colorLib/builder.py index 2577fa765..442bc20e4 100644 --- a/Lib/fontTools/colorLib/builder.py +++ b/Lib/fontTools/colorLib/builder.py @@ -23,6 +23,7 @@ from typing import ( ) from fontTools.misc.arrayTools import intRect from fontTools.misc.fixedTools import fixedToFloat +from fontTools.misc.treeTools import build_n_ary_tree 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 @@ -186,10 +187,12 @@ def populateCOLRv0( def buildCOLR( colorGlyphs: _ColorGlyphsDict, version: Optional[int] = None, + *, glyphMap: Optional[Mapping[str, int]] = None, varStore: Optional[ot.VarStore] = None, varIndexMap: Optional[ot.DeltaSetIndexMap] = None, clipBoxes: Optional[Dict[str, _ClipBoxInput]] = None, + allowLayerReuse: bool = True, ) -> C_O_L_R_.table_C_O_L_R_: """Build COLR table from color layers mapping. @@ -231,7 +234,11 @@ def buildCOLR( populateCOLRv0(colr, colorGlyphsV0, glyphMap) - colr.LayerList, colr.BaseGlyphList = buildColrV1(colorGlyphsV1, glyphMap) + colr.LayerList, colr.BaseGlyphList = buildColrV1( + colorGlyphsV1, + glyphMap, + allowLayerReuse=allowLayerReuse, + ) if version is None: version = 1 if (varStore or colorGlyphsV1) else 0 @@ -242,9 +249,6 @@ def buildCOLR( if version == 0: self.ColorLayers = self._decompileColorLayersV0(colr) else: - clipBoxes = { - name: clipBoxes[name] for name in clipBoxes or {} if name in colorGlyphsV1 - } colr.ClipList = buildClipList(clipBoxes) if clipBoxes else None colr.VarIndexMap = varIndexMap colr.VarStore = varStore @@ -443,29 +447,16 @@ def _reuse_ranges(num_layers: int) -> Generator[Tuple[int, int], None, None]: yield (lbound, ubound) -class LayerListBuilder: - layers: List[ot.Paint] +class LayerReuseCache: reusePool: Mapping[Tuple[Any, ...], int] tuples: Mapping[int, Tuple[Any, ...]] keepAlive: List[ot.Paint] # we need id to remain valid def __init__(self): - self.layers = [] self.reusePool = {} 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): @@ -491,25 +482,7 @@ class LayerListBuilder: def _as_tuple(self, paints: Sequence[ot.Paint]) -> Tuple[Any, ...]: return tuple(self._paint_tuple(p) for p in paints) - # COLR layers is unusual in that it modifies shared state - # so we need a callback into an object - def _beforeBuildPaintColrLayers(self, dest, source): - # 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"] - - # Convert maps seqs or whatever into typed objects - layers = [self.buildPaint(l) for l in layers] - - # 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 + def try_reuse(self, layers: List[ot.Paint]) -> List[ot.Paint]: found_reuse = True while found_reuse: found_reuse = False @@ -532,10 +505,63 @@ class LayerListBuilder: layers = layers[:lbound] + [new_slice] + layers[ubound:] found_reuse = True break + return layers + + def add(self, layers: List[ot.Paint], first_layer_index: int): + for lbound, ubound in _reuse_ranges(len(layers)): + self.reusePool[self._as_tuple(layers[lbound:ubound])] = ( + lbound + first_layer_index + ) + + +class LayerListBuilder: + layers: List[ot.Paint] + cache: LayerReuseCache + allowLayerReuse: bool + + def __init__(self, *, allowLayerReuse=True): + self.layers = [] + if allowLayerReuse: + self.cache = LayerReuseCache() + else: + self.cache = None + + # We need to intercept construction of PaintColrLayers + callbacks = _buildPaintCallbacks() + callbacks[ + ( + BuildCallback.BEFORE_BUILD, + ot.Paint, + ot.PaintFormat.PaintColrLayers, + ) + ] = self._beforeBuildPaintColrLayers + self.tableBuilder = TableBuilder(callbacks) + + # COLR layers is unusual in that it modifies shared state + # so we need a callback into an object + def _beforeBuildPaintColrLayers(self, dest, source): + # 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"] + + # Convert maps seqs or whatever into typed objects + layers = [self.buildPaint(l) for l in layers] + + # No reason to have a colr layers with just one entry + if len(layers) == 1: + return layers[0], {} + + if self.cache is not None: + # Look for reuse, with preference to longer sequences + # This may make the layer list smaller + layers = self.cache.try_reuse(layers) # 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) + layers = build_n_ary_tree(layers, n=MAX_PAINT_COLR_LAYER_COUNT) # We now have a tree of sequences with Paint leaves. # Convert the sequences into PaintColrLayers. @@ -563,11 +589,8 @@ class LayerListBuilder: # 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 - ) + if self.cache is not None and not is_tree: + self.cache.add(layers, paint.FirstLayerIndex) # we've fully built dest; empty source prevents generalized build from kicking in return paint, {} @@ -603,6 +626,8 @@ def _format_glyph_errors(errors: Mapping[str, Exception]) -> str: def buildColrV1( colorGlyphs: _ColorGlyphsDict, glyphMap: Optional[Mapping[str, int]] = None, + *, + allowLayerReuse: bool = True, ) -> Tuple[Optional[ot.LayerList], ot.BaseGlyphList]: if glyphMap is not None: colorGlyphItems = sorted( @@ -613,7 +638,7 @@ def buildColrV1( errors = {} baseGlyphs = [] - layerBuilder = LayerListBuilder() + layerBuilder = LayerListBuilder(allowLayerReuse=allowLayerReuse) for baseGlyph, paint in colorGlyphItems: try: baseGlyphs.append(buildBaseGlyphPaintRecord(baseGlyph, layerBuilder, paint)) @@ -632,45 +657,3 @@ def buildColrV1( glyphs.BaseGlyphCount = len(baseGlyphs) glyphs.BaseGlyphPaintRecord = baseGlyphs return (layers, glyphs) - - -def _build_n_ary_tree(leaves, n): - """Build N-ary tree from sequence of leaf nodes. - - Return a list of lists where each non-leaf node is a list containing - max n nodes. - """ - if not leaves: - return [] - - assert n > 1 - - depth = ceil(log(len(leaves), n)) - - if depth <= 1: - return list(leaves) - - # Fully populate complete subtrees of root until we have enough leaves left - root = [] - unassigned = None - full_step = n ** (depth - 1) - for i in range(0, len(leaves), full_step): - subtree = leaves[i : i + full_step] - if len(subtree) < full_step: - unassigned = subtree - break - while len(subtree) > n: - subtree = [subtree[k : k + n] for k in range(0, len(subtree), n)] - root.append(subtree) - - if unassigned: - # Recurse to fill the last subtree, which is the only partially populated one - subtree = _build_n_ary_tree(unassigned, n) - if len(subtree) <= n - len(root): - # replace last subtree with its children if they can still fit - root.extend(subtree) - else: - root.append(subtree) - assert len(root) <= n - - return root diff --git a/Lib/fontTools/colorLib/unbuilder.py b/Lib/fontTools/colorLib/unbuilder.py index 034589071..ac243550b 100644 --- a/Lib/fontTools/colorLib/unbuilder.py +++ b/Lib/fontTools/colorLib/unbuilder.py @@ -13,12 +13,12 @@ def unbuildColrV1(layerList, baseGlyphList): } -def _flatten(lst): - for el in lst: - if isinstance(el, list): - yield from _flatten(el) +def _flatten_layers(lst): + for paint in lst: + if paint["Format"] == ot.PaintFormat.PaintColrLayers: + yield from _flatten_layers(paint["Layers"]) else: - yield el + yield paint class LayerListUnbuilder: @@ -41,7 +41,7 @@ class LayerListUnbuilder: assert source["Format"] == ot.PaintFormat.PaintColrLayers layers = list( - _flatten( + _flatten_layers( [ self.unbuildPaint(childPaint) for childPaint in self.layers[ diff --git a/Lib/fontTools/fontBuilder.py b/Lib/fontTools/fontBuilder.py index ad7180cb8..603826838 100644 --- a/Lib/fontTools/fontBuilder.py +++ b/Lib/fontTools/fontBuilder.py @@ -838,6 +838,7 @@ class FontBuilder(object): varStore=None, varIndexMap=None, clipBoxes=None, + allowLayerReuse=True, ): """Build new COLR table using color layers dictionary. @@ -853,6 +854,7 @@ class FontBuilder(object): varStore=varStore, varIndexMap=varIndexMap, clipBoxes=clipBoxes, + allowLayerReuse=allowLayerReuse, ) def setupCPAL( diff --git a/Lib/fontTools/misc/treeTools.py b/Lib/fontTools/misc/treeTools.py new file mode 100644 index 000000000..24e10ba5b --- /dev/null +++ b/Lib/fontTools/misc/treeTools.py @@ -0,0 +1,45 @@ +"""Generic tools for working with trees.""" + +from math import ceil, log + + +def build_n_ary_tree(leaves, n): + """Build N-ary tree from sequence of leaf nodes. + + Return a list of lists where each non-leaf node is a list containing + max n nodes. + """ + if not leaves: + return [] + + assert n > 1 + + depth = ceil(log(len(leaves), n)) + + if depth <= 1: + return list(leaves) + + # Fully populate complete subtrees of root until we have enough leaves left + root = [] + unassigned = None + full_step = n ** (depth - 1) + for i in range(0, len(leaves), full_step): + subtree = leaves[i : i + full_step] + if len(subtree) < full_step: + unassigned = subtree + break + while len(subtree) > n: + subtree = [subtree[k : k + n] for k in range(0, len(subtree), n)] + root.append(subtree) + + if unassigned: + # Recurse to fill the last subtree, which is the only partially populated one + subtree = build_n_ary_tree(unassigned, n) + if len(subtree) <= n - len(root): + # replace last subtree with its children if they can still fit + root.extend(subtree) + else: + root.append(subtree) + assert len(root) <= n + + return root diff --git a/Lib/fontTools/ttLib/tables/otBase.py b/Lib/fontTools/ttLib/tables/otBase.py index 1b5077524..926a0ab41 100644 --- a/Lib/fontTools/ttLib/tables/otBase.py +++ b/Lib/fontTools/ttLib/tables/otBase.py @@ -6,7 +6,8 @@ import sys import array import struct import logging -from typing import Iterator, NamedTuple, Optional +from functools import lru_cache +from typing import Iterator, NamedTuple, Optional, Tuple log = logging.getLogger(__name__) @@ -220,8 +221,8 @@ class BaseTTXConverter(DefaultTable): self.table.fromXML(name, attrs, content, font) self.table.populateDefaults() - def ensureDecompiled(self): - self.table.ensureDecompiled(recurse=True) + def ensureDecompiled(self, recurse=True): + self.table.ensureDecompiled(recurse=recurse) # https://github.com/fonttools/fonttools/pull/2285#issuecomment-834652928 @@ -864,6 +865,9 @@ class BaseTable(object): #elif not conv.isCount: # # Warn? # pass + if hasattr(conv, "DEFAULT"): + # OptionalValue converters (e.g. VarIndex) + setattr(self, conv.name, conv.DEFAULT) def decompile(self, reader, font): self.readFormat(reader) @@ -1098,6 +1102,10 @@ class BaseTable(object): if isinstance(v, BaseTable) ) + # instance (not @class)method for consistency with FormatSwitchingBaseTable + def getVariableAttrs(self): + return getVariableAttrs(self.__class__) + class FormatSwitchingBaseTable(BaseTable): @@ -1132,6 +1140,9 @@ class FormatSwitchingBaseTable(BaseTable): def toXML(self, xmlWriter, font, attrs=None, name=None): BaseTable.toXML(self, xmlWriter, font, attrs, name) + def getVariableAttrs(self): + return getVariableAttrs(self.__class__, self.Format) + class UInt8FormatSwitchingBaseTable(FormatSwitchingBaseTable): def readFormat(self, reader): @@ -1153,6 +1164,33 @@ def getFormatSwitchingBaseTableClass(formatType): raise TypeError(f"Unsupported format type: {formatType!r}") +# memoize since these are parsed from otData.py, thus stay constant +@lru_cache() +def getVariableAttrs(cls: BaseTable, fmt: Optional[int] = None) -> Tuple[str]: + """Return sequence of variable table field names (can be empty). + + Attributes are deemed "variable" when their otData.py's description contain + 'VarIndexBase + {offset}', e.g. COLRv1 PaintVar* tables. + """ + if not issubclass(cls, BaseTable): + raise TypeError(cls) + if issubclass(cls, FormatSwitchingBaseTable): + if fmt is None: + raise TypeError(f"'fmt' is required for format-switching {cls.__name__}") + converters = cls.convertersByName[fmt] + else: + converters = cls.convertersByName + # assume if no 'VarIndexBase' field is present, table has no variable fields + if "VarIndexBase" not in converters: + return () + varAttrs = {} + for name, conv in converters.items(): + offset = conv.getVarIndexOffset() + if offset is not None: + varAttrs[name] = offset + return tuple(sorted(varAttrs, key=varAttrs.__getitem__)) + + # # Support for ValueRecords # diff --git a/Lib/fontTools/ttLib/tables/otConverters.py b/Lib/fontTools/ttLib/tables/otConverters.py index 44fcd0ab3..61125878f 100644 --- a/Lib/fontTools/ttLib/tables/otConverters.py +++ b/Lib/fontTools/ttLib/tables/otConverters.py @@ -15,10 +15,13 @@ from .otTables import (lookupTypes, AATStateTable, AATState, AATAction, ContextualMorphAction, LigatureMorphAction, InsertionMorphAction, MorxSubtable, ExtendMode as _ExtendMode, - CompositeMode as _CompositeMode) + CompositeMode as _CompositeMode, + NO_VARIATION_INDEX) from itertools import zip_longest from functools import partial +import re import struct +from typing import Optional import logging @@ -60,7 +63,7 @@ def buildConverters(tableSpec, tableNamespace): else: converterClass = eval(tp, tableNamespace, converterMapping) - conv = converterClass(name, repeat, aux) + conv = converterClass(name, repeat, aux, description=descr) if conv.tableClass: # A "template" such as OffsetTo(AType) knowss the table class already @@ -136,7 +139,7 @@ class BaseConverter(object): """Base class for converter objects. Apart from the constructor, this is an abstract class.""" - def __init__(self, name, repeat, aux, tableClass=None): + def __init__(self, name, repeat, aux, tableClass=None, *, description=""): self.name = name self.repeat = repeat self.aux = aux @@ -159,6 +162,7 @@ class BaseConverter(object): "BaseGlyphRecordCount", "LayerRecordCount", ] + self.description = description def readArray(self, reader, font, tableDict, count): """Read an array of values from the reader.""" @@ -211,6 +215,15 @@ class BaseConverter(object): """Write a value to XML.""" raise NotImplementedError(self) + varIndexBasePlusOffsetRE = re.compile(r"VarIndexBase\s*\+\s*(\d+)") + + def getVarIndexOffset(self) -> Optional[int]: + """If description has `VarIndexBase + {offset}`, return the offset else None.""" + m = self.varIndexBasePlusOffsetRE.search(self.description) + if not m: + return None + return int(m.group(1)) + class SimpleValue(BaseConverter): @staticmethod @@ -270,7 +283,7 @@ class Flags32(ULong): return "0x%08X" % value class VarIndex(OptionalValue, ULong): - DEFAULT = 0xFFFFFFFF + DEFAULT = NO_VARIATION_INDEX class Short(IntValue): staticSize = 2 @@ -402,40 +415,50 @@ class DeciPoints(FloatValue): def write(self, writer, font, tableDict, value, repeatIndex=None): writer.writeUShort(round(value * 10)) -class Fixed(FloatValue): - staticSize = 4 +class BaseFixedValue(FloatValue): + staticSize = NotImplemented + precisionBits = NotImplemented + readerMethod = NotImplemented + writerMethod = NotImplemented def read(self, reader, font, tableDict): - return fi2fl(reader.readLong(), 16) + return self.fromInt(getattr(reader, self.readerMethod)()) def write(self, writer, font, tableDict, value, repeatIndex=None): - writer.writeLong(fl2fi(value, 16)) - @staticmethod - def fromString(value): - return str2fl(value, 16) - @staticmethod - def toString(value): - return fl2str(value, 16) + getattr(writer, self.writerMethod)(self.toInt(value)) + @classmethod + def fromInt(cls, value): + return fi2fl(value, cls.precisionBits) + @classmethod + def toInt(cls, value): + return fl2fi(value, cls.precisionBits) + @classmethod + def fromString(cls, value): + return str2fl(value, cls.precisionBits) + @classmethod + def toString(cls, value): + return fl2str(value, cls.precisionBits) -class F2Dot14(FloatValue): +class Fixed(BaseFixedValue): + staticSize = 4 + precisionBits = 16 + readerMethod = "readLong" + writerMethod = "writeLong" + +class F2Dot14(BaseFixedValue): staticSize = 2 - def read(self, reader, font, tableDict): - return fi2fl(reader.readShort(), 14) - def write(self, writer, font, tableDict, value, repeatIndex=None): - writer.writeShort(fl2fi(value, 14)) - @staticmethod - def fromString(value): - return str2fl(value, 14) - @staticmethod - def toString(value): - return fl2str(value, 14) + precisionBits = 14 + readerMethod = "readShort" + writerMethod = "writeShort" class Angle(F2Dot14): # angles are specified in degrees, and encoded as F2Dot14 fractions of half # circle: e.g. 1.0 => 180, -0.5 => -90, -2.0 => -360, etc. factor = 1.0/(1<<14) * 180 # 0.010986328125 - def read(self, reader, font, tableDict): - return super().read(reader, font, tableDict) * 180 - def write(self, writer, font, tableDict, value, repeatIndex=None): - super().write(writer, font, tableDict, value / 180, repeatIndex=repeatIndex) + @classmethod + def fromInt(cls, value): + return super().fromInt(value) * 180 + @classmethod + def toInt(cls, value): + return super().toInt(value / 180) @classmethod def fromString(cls, value): # quantize to nearest multiples of minimum fixed-precision angle @@ -686,8 +709,10 @@ class FeatureParams(Table): class ValueFormat(IntValue): staticSize = 2 - def __init__(self, name, repeat, aux, tableClass=None): - BaseConverter.__init__(self, name, repeat, aux, tableClass) + def __init__(self, name, repeat, aux, tableClass=None, *, description=""): + BaseConverter.__init__( + self, name, repeat, aux, tableClass, description=description + ) self.which = "ValueFormat" + ("2" if name[-1] == "2" else "1") def read(self, reader, font, tableDict): format = reader.readUShort() @@ -720,8 +745,10 @@ class ValueRecord(ValueFormat): class AATLookup(BaseConverter): BIN_SEARCH_HEADER_SIZE = 10 - def __init__(self, name, repeat, aux, tableClass): - BaseConverter.__init__(self, name, repeat, aux, tableClass) + def __init__(self, name, repeat, aux, tableClass, *, description=""): + BaseConverter.__init__( + self, name, repeat, aux, tableClass, description=description + ) if issubclass(self.tableClass, SimpleValue): self.converter = self.tableClass(name='Value', repeat=None, aux=None) else: @@ -1019,8 +1046,10 @@ class MorxSubtableConverter(BaseConverter): val: key for key, val in _PROCESSING_ORDERS.items() } - def __init__(self, name, repeat, aux): - BaseConverter.__init__(self, name, repeat, aux) + def __init__(self, name, repeat, aux, tableClass=None, *, description=""): + BaseConverter.__init__( + self, name, repeat, aux, tableClass, description=description + ) def _setTextDirectionFromCoverageFlags(self, flags, subtable): if (flags & 0x20) != 0: @@ -1140,8 +1169,10 @@ class MorxSubtableConverter(BaseConverter): # https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6Tables.html#ExtendedStateHeader # TODO: Untangle the implementation of the various lookup-specific formats. class STXHeader(BaseConverter): - def __init__(self, name, repeat, aux, tableClass): - BaseConverter.__init__(self, name, repeat, aux, tableClass) + def __init__(self, name, repeat, aux, tableClass, *, description=""): + BaseConverter.__init__( + self, name, repeat, aux, tableClass, description=description + ) assert issubclass(self.tableClass, AATAction) self.classLookup = AATLookup("GlyphClasses", None, None, UShort) if issubclass(self.tableClass, ContextualMorphAction): diff --git a/Lib/fontTools/ttLib/tables/otData.py b/Lib/fontTools/ttLib/tables/otData.py index dd4033e43..8357ecd2f 100755 --- a/Lib/fontTools/ttLib/tables/otData.py +++ b/Lib/fontTools/ttLib/tables/otData.py @@ -1623,10 +1623,10 @@ otData = [ ('ClipBoxFormat2', [ ('uint8', 'Format', None, None, 'Format for variable ClipBox: set to 2.'), - ('int16', 'xMin', None, None, 'Minimum x of clip box.'), - ('int16', 'yMin', None, None, 'Minimum y of clip box.'), - ('int16', 'xMax', None, None, 'Maximum x of clip box.'), - ('int16', 'yMax', None, None, 'Maximum y of clip box.'), + ('int16', 'xMin', None, None, 'Minimum x of clip box. VarIndexBase + 0.'), + ('int16', 'yMin', None, None, 'Minimum y of clip box. VarIndexBase + 1.'), + ('int16', 'xMax', None, None, 'Maximum x of clip box. VarIndexBase + 2.'), + ('int16', 'yMax', None, None, 'Maximum y of clip box. VarIndexBase + 3.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1648,12 +1648,12 @@ otData = [ ('Fixed', 'dy', None, None, 'Translation in y direction'), ]), ('VarAffine2x3', [ - ('Fixed', 'xx', None, None, 'x-part of x basis vector'), - ('Fixed', 'yx', None, None, 'y-part of x basis vector'), - ('Fixed', 'xy', None, None, 'x-part of y basis vector'), - ('Fixed', 'yy', None, None, 'y-part of y basis vector'), - ('Fixed', 'dx', None, None, 'Translation in x direction'), - ('Fixed', 'dy', None, None, 'Translation in y direction'), + ('Fixed', 'xx', None, None, 'x-part of x basis vector. VarIndexBase + 0.'), + ('Fixed', 'yx', None, None, 'y-part of x basis vector. VarIndexBase + 1.'), + ('Fixed', 'xy', None, None, 'x-part of y basis vector. VarIndexBase + 2.'), + ('Fixed', 'yy', None, None, 'y-part of y basis vector. VarIndexBase + 3.'), + ('Fixed', 'dx', None, None, 'Translation in x direction. VarIndexBase + 4.'), + ('Fixed', 'dy', None, None, 'Translation in y direction. VarIndexBase + 5.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1663,9 +1663,9 @@ otData = [ ('F2Dot14', 'Alpha', None, None, 'Values outsided [0.,1.] reserved'), ]), ('VarColorStop', [ - ('F2Dot14', 'StopOffset', None, None, 'VarIndexBase + 0'), + ('F2Dot14', 'StopOffset', None, None, 'VarIndexBase + 0.'), ('uint16', 'PaletteIndex', None, None, 'Index for a CPAL palette entry.'), - ('F2Dot14', 'Alpha', None, None, 'Values outsided [0.,1.] reserved. VarIndexBase + 1'), + ('F2Dot14', 'Alpha', None, None, 'Values outsided [0.,1.] reserved. VarIndexBase + 1.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1697,7 +1697,7 @@ otData = [ ('PaintFormat3', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 3'), ('uint16', 'PaletteIndex', None, None, 'Index for a CPAL palette entry.'), - ('F2Dot14', 'Alpha', None, None, 'Values outsided [0.,1.] reserved. VarIndexBase + 0'), + ('F2Dot14', 'Alpha', None, None, 'Values outsided [0.,1.] reserved. VarIndexBase + 0.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1716,12 +1716,12 @@ otData = [ ('PaintFormat5', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 5'), ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintVarLinearGradient table) to VarColorLine subtable.'), - ('int16', 'x0', None, None, ''), - ('int16', 'y0', None, None, ''), - ('int16', 'x1', None, None, ''), - ('int16', 'y1', None, None, ''), - ('int16', 'x2', None, None, ''), - ('int16', 'y2', None, None, ''), + ('int16', 'x0', None, None, 'VarIndexBase + 0.'), + ('int16', 'y0', None, None, 'VarIndexBase + 1.'), + ('int16', 'x1', None, None, 'VarIndexBase + 2.'), + ('int16', 'y1', None, None, 'VarIndexBase + 3.'), + ('int16', 'x2', None, None, 'VarIndexBase + 4.'), + ('int16', 'y2', None, None, 'VarIndexBase + 5.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1740,12 +1740,12 @@ otData = [ ('PaintFormat7', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 7'), ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintVarRadialGradient table) to VarColorLine subtable.'), - ('int16', 'x0', None, None, ''), - ('int16', 'y0', None, None, ''), - ('uint16', 'r0', None, None, ''), - ('int16', 'x1', None, None, ''), - ('int16', 'y1', None, None, ''), - ('uint16', 'r1', None, None, ''), + ('int16', 'x0', None, None, 'VarIndexBase + 0.'), + ('int16', 'y0', None, None, 'VarIndexBase + 1.'), + ('uint16', 'r0', None, None, 'VarIndexBase + 2.'), + ('int16', 'x1', None, None, 'VarIndexBase + 3.'), + ('int16', 'y1', None, None, 'VarIndexBase + 4.'), + ('uint16', 'r1', None, None, 'VarIndexBase + 5.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1762,10 +1762,10 @@ otData = [ ('PaintFormat9', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 9'), ('LOffset24To(VarColorLine)', 'ColorLine', None, None, 'Offset (from beginning of PaintVarSweepGradient table) to VarColorLine subtable.'), - ('int16', 'centerX', None, None, 'Center x coordinate.'), - ('int16', 'centerY', None, None, 'Center y coordinate.'), - ('Angle', 'startAngle', None, None, 'Start of the angular range of the gradient.'), - ('Angle', 'endAngle', None, None, 'End of the angular range of the gradient.'), + ('int16', 'centerX', None, None, 'Center x coordinate. VarIndexBase + 0.'), + ('int16', 'centerY', None, None, 'Center y coordinate. VarIndexBase + 1.'), + ('Angle', 'startAngle', None, None, 'Start of the angular range of the gradient. VarIndexBase + 2.'), + ('Angle', 'endAngle', None, None, 'End of the angular range of the gradient. VarIndexBase + 3.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1806,8 +1806,8 @@ otData = [ ('PaintFormat15', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 15'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarTranslate table) to Paint subtable.'), - ('int16', 'dx', None, None, 'Translation in x direction.'), - ('int16', 'dy', None, None, 'Translation in y direction.'), + ('int16', 'dx', None, None, 'Translation in x direction. VarIndexBase + 0.'), + ('int16', 'dy', None, None, 'Translation in y direction. VarIndexBase + 1.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1822,8 +1822,8 @@ otData = [ ('PaintFormat17', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 17'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarScale table) to Paint subtable.'), - ('F2Dot14', 'scaleX', None, None, ''), - ('F2Dot14', 'scaleY', None, None, ''), + ('F2Dot14', 'scaleX', None, None, 'VarIndexBase + 0.'), + ('F2Dot14', 'scaleY', None, None, 'VarIndexBase + 1.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1840,10 +1840,10 @@ otData = [ ('PaintFormat19', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 19'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarScaleAroundCenter table) to Paint subtable.'), - ('F2Dot14', 'scaleX', None, None, ''), - ('F2Dot14', 'scaleY', None, None, ''), - ('int16', 'centerX', None, None, ''), - ('int16', 'centerY', None, None, ''), + ('F2Dot14', 'scaleX', None, None, 'VarIndexBase + 0.'), + ('F2Dot14', 'scaleY', None, None, 'VarIndexBase + 1.'), + ('int16', 'centerX', None, None, 'VarIndexBase + 2.'), + ('int16', 'centerY', None, None, 'VarIndexBase + 3.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1857,7 +1857,7 @@ otData = [ ('PaintFormat21', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 21'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarScaleUniform table) to Paint subtable.'), - ('F2Dot14', 'scale', None, None, ''), + ('F2Dot14', 'scale', None, None, 'VarIndexBase + 0.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1873,9 +1873,9 @@ otData = [ ('PaintFormat23', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 23'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarScaleUniformAroundCenter table) to Paint subtable.'), - ('F2Dot14', 'scale', None, None, ''), - ('int16', 'centerX', None, None, ''), - ('int16', 'centerY', None, None, ''), + ('F2Dot14', 'scale', None, None, 'VarIndexBase + 0'), + ('int16', 'centerX', None, None, 'VarIndexBase + 1'), + ('int16', 'centerY', None, None, 'VarIndexBase + 2'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1889,7 +1889,7 @@ otData = [ ('PaintFormat25', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 25'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarRotate table) to Paint subtable.'), - ('Angle', 'angle', None, None, ''), + ('Angle', 'angle', None, None, 'VarIndexBase + 0.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1905,9 +1905,9 @@ otData = [ ('PaintFormat27', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 27'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarRotateAroundCenter table) to Paint subtable.'), - ('Angle', 'angle', None, None, ''), - ('int16', 'centerX', None, None, ''), - ('int16', 'centerY', None, None, ''), + ('Angle', 'angle', None, None, 'VarIndexBase + 0.'), + ('int16', 'centerX', None, None, 'VarIndexBase + 1.'), + ('int16', 'centerY', None, None, 'VarIndexBase + 2.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1922,8 +1922,8 @@ otData = [ ('PaintFormat29', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 29'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarSkew table) to Paint subtable.'), - ('Angle', 'xSkewAngle', None, None, ''), - ('Angle', 'ySkewAngle', None, None, ''), + ('Angle', 'xSkewAngle', None, None, 'VarIndexBase + 0.'), + ('Angle', 'ySkewAngle', None, None, 'VarIndexBase + 1.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), @@ -1940,10 +1940,10 @@ otData = [ ('PaintFormat31', [ ('uint8', 'PaintFormat', None, None, 'Format identifier-format = 31'), ('Offset24', 'Paint', None, None, 'Offset (from beginning of PaintVarSkewAroundCenter table) to Paint subtable.'), - ('Angle', 'xSkewAngle', None, None, ''), - ('Angle', 'ySkewAngle', None, None, ''), - ('int16', 'centerX', None, None, ''), - ('int16', 'centerY', None, None, ''), + ('Angle', 'xSkewAngle', None, None, 'VarIndexBase + 0.'), + ('Angle', 'ySkewAngle', None, None, 'VarIndexBase + 1.'), + ('int16', 'centerX', None, None, 'VarIndexBase + 2.'), + ('int16', 'centerY', None, None, 'VarIndexBase + 3.'), ('VarIndex', 'VarIndexBase', None, None, 'Base index into DeltaSetIndexMap.'), ]), diff --git a/Lib/fontTools/ttLib/tables/otTables.py b/Lib/fontTools/ttLib/tables/otTables.py index fbd9db7bd..6e7f3dfb1 100644 --- a/Lib/fontTools/ttLib/tables/otTables.py +++ b/Lib/fontTools/ttLib/tables/otTables.py @@ -600,6 +600,11 @@ class Coverage(FormatSwitchingBaseTable): glyphs.append(attrs["value"]) +# The special 0xFFFFFFFF delta-set index is used to indicate that there +# is no variation data in the ItemVariationStore for a given variable field +NO_VARIATION_INDEX = 0xFFFFFFFF + + class DeltaSetIndexMap(getFormatSwitchingBaseTableClass("uint8")): def populateDefaults(self, propagator=None): @@ -647,12 +652,19 @@ class DeltaSetIndexMap(getFormatSwitchingBaseTableClass("uint8")): return rawTable def toXML2(self, xmlWriter, font): + # Make xml dump less verbose, by omitting no-op entries like: + # + xmlWriter.comment( + "Omitted values default to 0xFFFF/0xFFFF (no variations)" + ) + xmlWriter.newline() for i, value in enumerate(getattr(self, "mapping", [])): - attrs = ( - ('index', i), - ('outer', value >> 16), - ('inner', value & 0xFFFF), - ) + attrs = [('index', i)] + if value != NO_VARIATION_INDEX: + attrs.extend([ + ('outer', value >> 16), + ('inner', value & 0xFFFF), + ]) xmlWriter.simpletag("Map", attrs) xmlWriter.newline() @@ -661,8 +673,8 @@ class DeltaSetIndexMap(getFormatSwitchingBaseTableClass("uint8")): if mapping is None: self.mapping = mapping = [] index = safeEval(attrs['index']) - outer = safeEval(attrs['outer']) - inner = safeEval(attrs['inner']) + outer = safeEval(attrs.get('outer', '0xFFFF')) + inner = safeEval(attrs.get('inner', '0xFFFF')) assert inner <= 0xFFFF mapping.insert(index, (outer << 16) | inner) @@ -1257,7 +1269,19 @@ class BaseGlyphList(BaseTable): return self.__dict__.copy() +class ClipBoxFormat(IntEnum): + Static = 1 + Variable = 2 + + def is_variable(self): + return self is self.Variable + + def as_variable(self): + return self.Variable + + class ClipBox(getFormatSwitchingBaseTableClass("uint8")): + formatEnum = ClipBoxFormat def as_tuple(self): return tuple(getattr(self, conv.name) for conv in self.getConverters()) @@ -1492,12 +1516,24 @@ class PaintFormat(IntEnum): PaintVarSkewAroundCenter = 31 PaintComposite = 32 + def is_variable(self): + return self.name.startswith("PaintVar") + + def as_variable(self): + if self.is_variable(): + return self + try: + return PaintFormat.__members__[f"PaintVar{self.name[5:]}"] + except KeyError: + return None + class Paint(getFormatSwitchingBaseTableClass("uint8")): + formatEnum = PaintFormat def getFormatName(self): try: - return PaintFormat(self.Format).name + return self.formatEnum(self.Format).name except ValueError: raise NotImplementedError(f"Unknown Paint format: {self.Format}") @@ -1962,6 +1998,14 @@ def _buildClasses(): cls.DontShare = True namespace[name] = cls + # link Var{Table} <-> {Table} (e.g. ColorStop <-> VarColorStop, etc.) + for name, _ in otData: + if name.startswith("Var") and len(name) > 3 and name[3:] in namespace: + varType = namespace[name] + noVarType = namespace[name[3:]] + varType.NoVarType = noVarType + noVarType.VarType = varType + for base, alts in _equivalents.items(): base = namespace[base] for alt in alts: diff --git a/Lib/fontTools/ttLib/tables/otTraverse.py b/Lib/fontTools/ttLib/tables/otTraverse.py new file mode 100644 index 000000000..40b28b2bb --- /dev/null +++ b/Lib/fontTools/ttLib/tables/otTraverse.py @@ -0,0 +1,137 @@ +"""Methods for traversing trees of otData-driven OpenType tables.""" +from collections import deque +from typing import Callable, Deque, Iterable, List, Optional, Tuple +from .otBase import BaseTable + + +__all__ = [ + "bfs_base_table", + "dfs_base_table", + "SubTablePath", +] + + +class SubTablePath(Tuple[BaseTable.SubTableEntry, ...]): + + def __str__(self) -> str: + path_parts = [] + for entry in self: + path_part = entry.name + if entry.index is not None: + path_part += f"[{entry.index}]" + path_parts.append(path_part) + return ".".join(path_parts) + + +# Given f(current frontier, new entries) add new entries to frontier +AddToFrontierFn = Callable[[Deque[SubTablePath], List[SubTablePath]], None] + + +def dfs_base_table( + root: BaseTable, + root_accessor: Optional[str] = None, + skip_root: bool = False, + predicate: Optional[Callable[[SubTablePath], bool]] = None, +) -> Iterable[SubTablePath]: + """Depth-first search tree of BaseTables. + + Args: + root (BaseTable): the root of the tree. + root_accessor (Optional[str]): attribute name for the root table, if any (mostly + useful for debugging). + skip_root (Optional[bool]): if True, the root itself is not visited, only its + children. + predicate (Optional[Callable[[SubTablePath], bool]]): function to filter out + paths. If True, the path is yielded and its subtables are added to the + queue. If False, the path is skipped and its subtables are not traversed. + + Yields: + SubTablePath: tuples of BaseTable.SubTableEntry(name, table, index) namedtuples + for each of the nodes in the tree. The last entry in a path is the current + subtable, whereas preceding ones refer to its parent tables all the way up to + the root. + """ + yield from _traverse_ot_data( + root, + root_accessor, + skip_root, + predicate, + lambda frontier, new: frontier.extendleft(reversed(new)), + ) + + +def bfs_base_table( + root: BaseTable, + root_accessor: Optional[str] = None, + skip_root: bool = False, + predicate: Optional[Callable[[SubTablePath], bool]] = None, +) -> Iterable[SubTablePath]: + """Breadth-first search tree of BaseTables. + + Args: + root (BaseTable): the root of the tree. + root_accessor (Optional[str]): attribute name for the root table, if any (mostly + useful for debugging). + skip_root (Optional[bool]): if True, the root itself is not visited, only its + children. + predicate (Optional[Callable[[SubTablePath], bool]]): function to filter out + paths. If True, the path is yielded and its subtables are added to the + queue. If False, the path is skipped and its subtables are not traversed. + + Yields: + SubTablePath: tuples of BaseTable.SubTableEntry(name, table, index) namedtuples + for each of the nodes in the tree. The last entry in a path is the current + subtable, whereas preceding ones refer to its parent tables all the way up to + the root. + """ + yield from _traverse_ot_data( + root, + root_accessor, + skip_root, + predicate, + lambda frontier, new: frontier.extend(new), + ) + + +def _traverse_ot_data( + root: BaseTable, + root_accessor: Optional[str], + skip_root: bool, + predicate: Optional[Callable[[SubTablePath], bool]], + add_to_frontier_fn: AddToFrontierFn, +) -> Iterable[SubTablePath]: + # no visited because general otData cannot cycle (forward-offset only) + if root_accessor is None: + root_accessor = type(root).__name__ + + if predicate is None: + + def predicate(path): + return True + + frontier: Deque[SubTablePath] = deque() + + root_entry = BaseTable.SubTableEntry(root_accessor, root) + if not skip_root: + frontier.append((root_entry,)) + else: + add_to_frontier_fn( + frontier, + [(root_entry, subtable_entry) for subtable_entry in root.iterSubTables()], + ) + + while frontier: + # path is (value, attr_name) tuples. attr_name is attr of parent to get value + path = frontier.popleft() + current = path[-1].value + + if not predicate(path): + continue + + yield SubTablePath(path) + + new_entries = [ + path + (subtable_entry,) for subtable_entry in current.iterSubTables() + ] + + add_to_frontier_fn(frontier, new_entries) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index eb8a3a9bc..367d6b1bc 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -30,13 +30,15 @@ from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.ttLib.tables import otTables as ot from fontTools.ttLib.tables.otBase import OTTableWriter from fontTools.varLib import builder, models, varStore -from fontTools.varLib.merger import VariationMerger +from fontTools.varLib.merger import VariationMerger, COLRVariationMerger from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.iup import iup_delta_optimize from fontTools.varLib.featureVars import addFeatureVariations from fontTools.designspaceLib import DesignSpaceDocument, InstanceDescriptor from fontTools.designspaceLib.split import splitInterpolable, splitVariableFonts from fontTools.varLib.stat import buildVFStatTable +from fontTools.colorLib.builder import buildColrV1 +from fontTools.colorLib.unbuilder import unbuildColrV1 from functools import partial from collections import OrderedDict, namedtuple import os.path @@ -711,6 +713,19 @@ def _add_CFF2(varFont, model, master_fonts): merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder) +def _add_COLR(font, model, master_fonts, axisTags, colr_layer_reuse=True): + merger = COLRVariationMerger(model, axisTags, font, allowLayerReuse=colr_layer_reuse) + merger.mergeTables(font, master_fonts) + store = merger.store_builder.finish() + + colr = font["COLR"].table + if store: + mapping = store.optimize() + colr.VarStore = store + varIdxes = [mapping[v] for v in merger.varIdxes] + colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes) + + def load_designspace(designspace): # TODO: remove this and always assume 'designspace' is a DesignSpaceDocument, # never a file path, as that's already handled by caller @@ -865,7 +880,14 @@ def set_default_weight_width_slant(font, location): font["post"].italicAngle = italicAngle -def build_many(designspace: DesignSpaceDocument, master_finder=lambda s:s, exclude=[], optimize=True, skip_vf=lambda vf_name: False): +def build_many( + designspace: DesignSpaceDocument, + master_finder=lambda s:s, + exclude=[], + optimize=True, + skip_vf=lambda vf_name: False, + colr_layer_reuse=True, +): """ Build variable fonts from a designspace file, version 5 which can define several VFs, or version 4 which has implicitly one VF covering the whole doc. @@ -897,7 +919,13 @@ def build_many(designspace: DesignSpaceDocument, master_finder=lambda s:s, exclu res[name] = vf return res -def build(designspace, master_finder=lambda s:s, exclude=[], optimize=True): +def build( + designspace, + master_finder=lambda s:s, + exclude=[], + optimize=True, + colr_layer_reuse=True, +): """ Build variation font from a designspace file. @@ -975,6 +1003,8 @@ def build(designspace, master_finder=lambda s:s, exclude=[], optimize=True): post.formatType = 2.0 post.extraNames = [] post.mapping = {} + if 'COLR' not in exclude and 'COLR' in vf and vf['COLR'].version > 0: + _add_COLR(vf, model, master_fonts, axisTags, colr_layer_reuse) set_default_weight_width_slant( vf, location={axis.axisTag: axis.defaultValue for axis in vf["fvar"].axes} @@ -1082,6 +1112,12 @@ def main(args=None): action='store_false', help='do not perform IUP optimization' ) + parser.add_argument( + '--no-colr-layer-reuse', + dest='colr_layer_reuse', + action='store_false', + help='do not rebuild variable COLR table to optimize COLR layer reuse', + ) parser.add_argument( '--master-finder', default='master_ttf_interpolatable/{stem}.ttf', @@ -1120,7 +1156,8 @@ def main(args=None): designspace_filename, finder, exclude=options.exclude, - optimize=options.optimize + optimize=options.optimize, + colr_layer_reuse=options.colr_layer_reuse, ) outfile = options.outfile diff --git a/Lib/fontTools/varLib/errors.py b/Lib/fontTools/varLib/errors.py index 76e704d80..fb3708a17 100644 --- a/Lib/fontTools/varLib/errors.py +++ b/Lib/fontTools/varLib/errors.py @@ -42,7 +42,10 @@ class VarLibMergeError(VarLibError): index = [x == self.cause["expected"] for x in self.cause["got"]].index( False ) - return index, self._master_name(index) + master_name = self._master_name(index) + if "location" in self.cause: + master_name = f"{master_name} ({self.cause['location']})" + return index, master_name return None, None @property @@ -50,7 +53,7 @@ class VarLibMergeError(VarLibError): if "expected" in self.cause and "got" in self.cause: offender_index, offender = self.offender got = self.cause["got"][offender_index] - return f"Expected to see {self.stack[0]}=={self.cause['expected']}, instead saw {got}\n" + return f"Expected to see {self.stack[0]}=={self.cause['expected']!r}, instead saw {got!r}\n" return "" def __str__(self): @@ -138,9 +141,17 @@ class InconsistentExtensions(VarLibMergeError): class UnsupportedFormat(VarLibMergeError): """an OpenType subtable (%s) had a format I didn't expect""" + def __init__(self, merger=None, **kwargs): + super().__init__(merger, **kwargs) + if not self.stack: + self.stack = [".Format"] + @property def reason(self): - return self.__doc__ % self.cause["subtable"] + s = self.__doc__ % self.cause["subtable"] + if "value" in self.cause: + s += f" ({self.cause['value']!r})" + return s class InconsistentFormats(UnsupportedFormat): diff --git a/Lib/fontTools/varLib/merger.py b/Lib/fontTools/varLib/merger.py index f8e940207..2669c1b93 100644 --- a/Lib/fontTools/varLib/merger.py +++ b/Lib/fontTools/varLib/merger.py @@ -3,15 +3,20 @@ Merge OpenType Layout tables (GDEF / GPOS / GSUB). """ import os import copy +import enum from operator import ior import logging +from fontTools.colorLib.builder import MAX_PAINT_COLR_LAYER_COUNT, LayerReuseCache from fontTools.misc import classifyTools from fontTools.misc.roundTools import otRound +from fontTools.misc.treeTools import build_n_ary_tree from fontTools.ttLib.tables import otTables as ot from fontTools.ttLib.tables import otBase as otBase +from fontTools.ttLib.tables.otConverters import BaseFixedValue +from fontTools.ttLib.tables.otTraverse import dfs_base_table from fontTools.ttLib.tables.DefaultTable import DefaultTable from fontTools.varLib import builder, models, varStore -from fontTools.varLib.models import nonNone, allNone, allEqual, allEqualTo +from fontTools.varLib.models import nonNone, allNone, allEqual, allEqualTo, subList from fontTools.varLib.varStore import VarStoreInstancer from functools import reduce from fontTools.otlLib.builder import buildSinglePos @@ -39,13 +44,15 @@ class Merger(object): def __init__(self, font=None): self.font = font + # mergeTables populates this from the parent's master ttfs + self.ttfs = None @classmethod def merger(celf, clazzes, attrs=(None,)): assert celf != Merger, 'Subclass Merger instead.' if 'mergers' not in celf.__dict__: celf.mergers = {} - if type(clazzes) == type: + if type(clazzes) in (type, enum.EnumMeta): clazzes = (clazzes,) if type(attrs) == str: attrs = (attrs,) @@ -81,10 +88,10 @@ class Merger(object): def mergeObjects(self, out, lst, exclude=()): if hasattr(out, "ensureDecompiled"): - out.ensureDecompiled() + out.ensureDecompiled(recurse=False) for item in lst: if hasattr(item, "ensureDecompiled"): - item.ensureDecompiled() + item.ensureDecompiled(recurse=False) keys = sorted(vars(out).keys()) if not all(keys == sorted(vars(v).keys()) for v in lst): raise KeysDiffer(self, expected=keys, @@ -122,6 +129,11 @@ class Merger(object): mergerFunc = self.mergersFor(out).get(None, None) if mergerFunc is not None: mergerFunc(self, out, lst) + elif isinstance(out, enum.Enum): + # need to special-case Enums as have __dict__ but are not regular 'objects', + # otherwise mergeObjects/mergeThings get trapped in a RecursionError + if not allEqualTo(out, lst): + raise ShouldBeConstant(self, expected=out, got=lst) elif hasattr(out, '__dict__'): self.mergeObjects(out, lst) elif isinstance(out, list): @@ -134,9 +146,8 @@ class Merger(object): for tag in tableTags: if tag not in font: continue try: - self.ttfs = [m for m in master_ttfs if tag in m] - self.mergeThings(font[tag], [m[tag] if tag in m else None - for m in master_ttfs]) + self.ttfs = master_ttfs + self.mergeThings(font[tag], [m.get(tag) for m in master_ttfs]) except VarLibMergeError as e: e.stack.append(tag) raise @@ -1036,11 +1047,19 @@ class VariationMerger(AligningMerger): def mergeThings(self, out, lst): masterModel = None + origTTFs = None if None in lst: if allNone(lst): if out is not None: raise FoundANone(self, got=lst) return + + # temporarily subset the list of master ttfs to the ones for which + # master values are not None + origTTFs = self.ttfs + if self.ttfs: + self.ttfs = subList([v is not None for v in lst], self.ttfs) + masterModel = self.model model, lst = masterModel.getSubModel(lst) self.setModel(model) @@ -1049,6 +1068,8 @@ class VariationMerger(AligningMerger): if masterModel: self.setModel(masterModel) + if origTTFs: + self.ttfs = origTTFs def buildVarDevTable(store_builder, master_values): @@ -1099,3 +1120,408 @@ def merge(merger, self, lst): setattr(self, name, value) if deviceTable: setattr(self, tableName, deviceTable) + + +class COLRVariationMerger(VariationMerger): + """A specialized VariationMerger that takes multiple master fonts containing + COLRv1 tables, and builds a variable COLR font. + + COLR tables are special in that variable subtables can be associated with + multiple delta-set indices (via VarIndexBase). + They also contain tables that must change their type (not simply the Format) + as they become variable (e.g. Affine2x3 -> VarAffine2x3) so this merger takes + care of that too. + """ + + def __init__(self, model, axisTags, font, allowLayerReuse=True): + VariationMerger.__init__(self, model, axisTags, font) + # maps {tuple(varIdxes): VarIndexBase} to facilitate reuse of VarIndexBase + # between variable tables with same varIdxes. + self.varIndexCache = {} + # flat list of all the varIdxes generated while merging + self.varIdxes = [] + # set of id()s of the subtables that contain variations after merging + # and need to be upgraded to the associated VarType. + self.varTableIds = set() + # we keep these around for rebuilding a LayerList while merging PaintColrLayers + self.layers = [] + self.layerReuseCache = None + if allowLayerReuse: + self.layerReuseCache = LayerReuseCache() + # flag to ensure BaseGlyphList is fully merged before LayerList gets processed + self._doneBaseGlyphs = False + + def mergeTables(self, font, master_ttfs, tableTags=("COLR",)): + if "COLR" in tableTags and "COLR" in font: + # The merger modifies the destination COLR table in-place. If this contains + # multiple PaintColrLayers referencing the same layers from LayerList, it's + # a problem because we may risk modifying the same paint more than once, or + # worse, fail while attempting to do that. + # We don't know whether the master COLR table was built with layer reuse + # disabled, thus to be safe we rebuild its LayerList so that it contains only + # unique layers referenced from non-overlapping PaintColrLayers throughout + # the base paint graphs. + self.expandPaintColrLayers(font["COLR"].table) + VariationMerger.mergeTables(self, font, master_ttfs, tableTags) + + def checkFormatEnum(self, out, lst, validate=lambda _: True): + fmt = out.Format + formatEnum = out.formatEnum + ok = False + try: + fmt = formatEnum(fmt) + except ValueError: + pass + else: + ok = validate(fmt) + if not ok: + raise UnsupportedFormat( + self, subtable=type(out).__name__, value=fmt + ) + expected = fmt + got = [] + for v in lst: + fmt = getattr(v, "Format", None) + try: + fmt = formatEnum(fmt) + except ValueError: + pass + got.append(fmt) + if not allEqualTo(expected, got): + raise InconsistentFormats( + self, + subtable=type(out).__name__, + expected=expected, + got=got, + ) + return expected + + def mergeSparseDict(self, out, lst): + for k in out.keys(): + try: + self.mergeThings(out[k], [v.get(k) for v in lst]) + except VarLibMergeError as e: + e.stack.append(f"[{k!r}]") + raise + + def mergeAttrs(self, out, lst, attrs): + for attr in attrs: + value = getattr(out, attr) + values = [getattr(item, attr) for item in lst] + try: + self.mergeThings(value, values) + except VarLibMergeError as e: + e.stack.append(f".{attr}") + raise + + def storeMastersForAttr(self, out, lst, attr): + master_values = [getattr(item, attr) for item in lst] + + # VarStore treats deltas for fixed-size floats as integers, so we + # must convert master values to int before storing them in the builder + # then back to float. + is_fixed_size_float = False + conv = out.getConverterByName(attr) + if isinstance(conv, BaseFixedValue): + is_fixed_size_float = True + master_values = [conv.toInt(v) for v in master_values] + + baseValue = master_values[0] + varIdx = ot.NO_VARIATION_INDEX + if not allEqual(master_values): + baseValue, varIdx = self.store_builder.storeMasters(master_values) + + if is_fixed_size_float: + baseValue = conv.fromInt(baseValue) + + return baseValue, varIdx + + def storeVariationIndices(self, varIdxes) -> int: + # try to reuse an existing VarIndexBase for the same varIdxes, or else + # create a new one + key = tuple(varIdxes) + varIndexBase = self.varIndexCache.get(key) + + if varIndexBase is None: + # scan for a full match anywhere in the self.varIdxes + for i in range(len(self.varIdxes) - len(varIdxes) + 1): + if self.varIdxes[i:i+len(varIdxes)] == varIdxes: + self.varIndexCache[key] = varIndexBase = i + break + + if varIndexBase is None: + # try find a partial match at the end of the self.varIdxes + for n in range(len(varIdxes)-1, 0, -1): + if self.varIdxes[-n:] == varIdxes[:n]: + varIndexBase = len(self.varIdxes) - n + self.varIndexCache[key] = varIndexBase + self.varIdxes.extend(varIdxes[n:]) + break + + if varIndexBase is None: + # no match found, append at the end + self.varIndexCache[key] = varIndexBase = len(self.varIdxes) + self.varIdxes.extend(varIdxes) + + return varIndexBase + + def mergeVariableAttrs(self, out, lst, attrs) -> int: + varIndexBase = ot.NO_VARIATION_INDEX + varIdxes = [] + for attr in attrs: + baseValue, varIdx = self.storeMastersForAttr(out, lst, attr) + setattr(out, attr, baseValue) + varIdxes.append(varIdx) + + if any(v != ot.NO_VARIATION_INDEX for v in varIdxes): + varIndexBase = self.storeVariationIndices(varIdxes) + + return varIndexBase + + @classmethod + def convertSubTablesToVarType(cls, table): + for path in dfs_base_table( + table, + skip_root=True, + predicate=lambda path: ( + getattr(type(path[-1].value), "VarType", None) is not None + ) + ): + st = path[-1] + subTable = st.value + varType = type(subTable).VarType + newSubTable = varType() + newSubTable.__dict__.update(subTable.__dict__) + newSubTable.populateDefaults() + parent = path[-2].value + if st.index is not None: + getattr(parent, st.name)[st.index] = newSubTable + else: + setattr(parent, st.name, newSubTable) + + @staticmethod + def expandPaintColrLayers(colr): + """Rebuild LayerList without PaintColrLayers reuse. + + Each base paint graph is fully DFS-traversed (with exception of PaintColrGlyph + which are irrelevant for this); any layers referenced via PaintColrLayers are + collected into a new LayerList and duplicated when reuse is detected, to ensure + that all paints are distinct objects at the end of the process. + PaintColrLayers's FirstLayerIndex/NumLayers are updated so that no overlap + is left. Also, any consecutively nested PaintColrLayers are flattened. + The COLR table's LayerList is replaced with the new unique layers. + A side effect is also that any layer from the old LayerList which is not + referenced by any PaintColrLayers is dropped. + """ + if not colr.LayerList: + # if no LayerList, there's nothing to expand + return + uniqueLayerIDs = set() + newLayerList = [] + for rec in colr.BaseGlyphList.BaseGlyphPaintRecord: + frontier = [rec.Paint] + while frontier: + paint = frontier.pop() + if paint.Format == ot.PaintFormat.PaintColrGlyph: + # don't traverse these, we treat them as constant for merging + continue + elif paint.Format == ot.PaintFormat.PaintColrLayers: + # de-treeify any nested PaintColrLayers, append unique copies to + # the new layer list and update PaintColrLayers index/count + children = list(_flatten_layers(paint, colr)) + first_layer_index = len(newLayerList) + for layer in children: + if id(layer) in uniqueLayerIDs: + layer = copy.deepcopy(layer) + assert id(layer) not in uniqueLayerIDs + newLayerList.append(layer) + uniqueLayerIDs.add(id(layer)) + paint.FirstLayerIndex = first_layer_index + paint.NumLayers = len(children) + else: + children = paint.getChildren(colr) + frontier.extend(reversed(children)) + # sanity check all the new layers are distinct objects + assert len(newLayerList) == len(uniqueLayerIDs) + colr.LayerList.Paint = newLayerList + colr.LayerList.LayerCount = len(newLayerList) + + +@COLRVariationMerger.merger(ot.BaseGlyphList) +def merge(merger, self, lst): + # ignore BaseGlyphCount, allow sparse glyph sets across masters + out = {rec.BaseGlyph: rec for rec in self.BaseGlyphPaintRecord} + masters = [{rec.BaseGlyph: rec for rec in m.BaseGlyphPaintRecord} for m in lst] + + for i, g in enumerate(out.keys()): + try: + # missing base glyphs don't participate in the merge + merger.mergeThings(out[g], [v.get(g) for v in masters]) + except VarLibMergeError as e: + e.stack.append(f".BaseGlyphPaintRecord[{i}]") + e.cause["location"] = f"base glyph {g!r}" + raise + + merger._doneBaseGlyphs = True + + +@COLRVariationMerger.merger(ot.LayerList) +def merge(merger, self, lst): + # nothing to merge for LayerList, assuming we have already merged all PaintColrLayers + # found while traversing the paint graphs rooted at BaseGlyphPaintRecords. + assert merger._doneBaseGlyphs, "BaseGlyphList must be merged before LayerList" + # Simply flush the final list of layers and go home. + self.LayerCount = len(merger.layers) + self.Paint = merger.layers + + +def _flatten_layers(root, colr): + assert root.Format == ot.PaintFormat.PaintColrLayers + for paint in root.getChildren(colr): + if paint.Format == ot.PaintFormat.PaintColrLayers: + yield from _flatten_layers(paint, colr) + else: + yield paint + + +def _merge_PaintColrLayers(self, out, lst): + # we only enforce that the (flat) number of layers is the same across all masters + # but we allow FirstLayerIndex to differ to acommodate for sparse glyph sets. + + out_layers = list(_flatten_layers(out, self.font["COLR"].table)) + + # sanity check ttfs are subset to current values (see VariationMerger.mergeThings) + # before matching each master PaintColrLayers to its respective COLR by position + assert len(self.ttfs) == len(lst) + master_layerses = [ + list(_flatten_layers(lst[i], self.ttfs[i]["COLR"].table)) + for i in range(len(lst)) + ] + + try: + self.mergeLists(out_layers, master_layerses) + except VarLibMergeError as e: + # NOTE: This attribute doesn't actually exist in PaintColrLayers but it's + # handy to have it in the stack trace for debugging. + e.stack.append(".Layers") + raise + + # following block is very similar to LayerListBuilder._beforeBuildPaintColrLayers + # but I couldn't find a nice way to share the code between the two... + + if self.layerReuseCache is not None: + # successful reuse can make the list smaller + out_layers = self.layerReuseCache.try_reuse(out_layers) + + # if the list is still too big we need to tree-fy it + is_tree = len(out_layers) > MAX_PAINT_COLR_LAYER_COUNT + out_layers = build_n_ary_tree(out_layers, n=MAX_PAINT_COLR_LAYER_COUNT) + + # We now have a tree of sequences with Paint leaves. + # Convert the sequences into PaintColrLayers. + def listToColrLayers(paint): + if isinstance(paint, list): + layers = [listToColrLayers(l) for l in paint] + paint = ot.Paint() + paint.Format = int(ot.PaintFormat.PaintColrLayers) + paint.NumLayers = len(layers) + paint.FirstLayerIndex = len(self.layers) + self.layers.exend(layers) + if self.layerReuseCache is not None: + self.layerReuseCache.add(layers, paint.FirstLayerIndex) + return paint + + out_layers = [listToColrLayers(l) for l in out_layers] + + if len(out_layers) == 1 and out_layers[0].Format == ot.PaintFormat.PaintColrLayers: + # special case when the reuse cache finds a single perfect PaintColrLayers match + # (it can only come from a successful reuse, _flatten_layers has gotten rid of + # all nested PaintColrLayers already); we assign it directly and avoid creating + # an extra table + out.NumLayers = out_layers[0].NumLayers + out.FirstLayerIndex = out_layers[0].FirstLayerIndex + else: + out.NumLayers = len(out_layers) + out.FirstLayerIndex = len(self.layers) + + self.layers.extend(out_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 self.layerReuseCache is not None and not is_tree: + self.layerReuseCache.add(out_layers, out.FirstLayerIndex) + + +@COLRVariationMerger.merger((ot.Paint, ot.ClipBox)) +def merge(merger, self, lst): + fmt = merger.checkFormatEnum(self, lst, lambda fmt: not fmt.is_variable()) + + if fmt is ot.PaintFormat.PaintColrLayers: + _merge_PaintColrLayers(merger, self, lst) + return + + varFormat = fmt.as_variable() + + varAttrs = () + if varFormat is not None: + varAttrs = otBase.getVariableAttrs(type(self), varFormat) + staticAttrs = (c.name for c in self.getConverters() if c.name not in varAttrs) + + merger.mergeAttrs(self, lst, staticAttrs) + + varIndexBase = merger.mergeVariableAttrs(self, lst, varAttrs) + + subTables = [st.value for st in self.iterSubTables()] + + # Convert table to variable if itself has variations or any subtables have + isVariable = ( + varIndexBase != ot.NO_VARIATION_INDEX + or any(id(table) in merger.varTableIds for table in subTables) + ) + + if isVariable: + if varAttrs: + # Some PaintVar* don't have any scalar attributes that can vary, + # only indirect offsets to other variable subtables, thus have + # no VarIndexBase of their own (e.g. PaintVarTransform) + self.VarIndexBase = varIndexBase + + if subTables: + # Convert Affine2x3 -> VarAffine2x3, ColorLine -> VarColorLine, etc. + merger.convertSubTablesToVarType(self) + + assert varFormat is not None + self.Format = int(varFormat) + + +@COLRVariationMerger.merger((ot.Affine2x3, ot.ColorStop)) +def merge(merger, self, lst): + varType = type(self).VarType + + varAttrs = otBase.getVariableAttrs(varType) + staticAttrs = (c.name for c in self.getConverters() if c.name not in varAttrs) + + merger.mergeAttrs(self, lst, staticAttrs) + + varIndexBase = merger.mergeVariableAttrs(self, lst, varAttrs) + + if varIndexBase != ot.NO_VARIATION_INDEX: + self.VarIndexBase = varIndexBase + # mark as having variations so the parent table will convert to Var{Type} + merger.varTableIds.add(id(self)) + + +@COLRVariationMerger.merger(ot.ColorLine) +def merge(merger, self, lst): + merger.mergeAttrs(self, lst, (c.name for c in self.getConverters())) + + if any(id(stop) in merger.varTableIds for stop in self.ColorStop): + merger.convertSubTablesToVarType(self) + merger.varTableIds.add(id(self)) + + +@COLRVariationMerger.merger(ot.ClipList, "clips") +def merge(merger, self, lst): + # 'sparse' in that we allow non-default masters to omit ClipBox entries + # for some/all glyphs (i.e. they don't participate) + merger.mergeSparseDict(self, lst) diff --git a/Lib/fontTools/varLib/varStore.py b/Lib/fontTools/varLib/varStore.py index 5e2155870..74a7212da 100644 --- a/Lib/fontTools/varLib/varStore.py +++ b/Lib/fontTools/varLib/varStore.py @@ -7,7 +7,7 @@ from functools import partial from collections import defaultdict -NO_VARIATION_INDEX = 0xFFFFFFFF +NO_VARIATION_INDEX = ot.NO_VARIATION_INDEX ot.VarStore.NO_VARIATION_INDEX = NO_VARIATION_INDEX diff --git a/Tests/colorLib/builder_test.py b/Tests/colorLib/builder_test.py index 7259db4d8..3cdb2e9a5 100644 --- a/Tests/colorLib/builder_test.py +++ b/Tests/colorLib/builder_test.py @@ -3,7 +3,7 @@ from fontTools.ttLib import newTable 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 LayerListBuilder, _build_n_ary_tree +from fontTools.colorLib.builder import LayerListBuilder from fontTools.colorLib.table_builder import TableBuilder from fontTools.colorLib.errors import ColorLibError import pytest @@ -1678,7 +1678,7 @@ class BuildCOLRTest(object): clipBoxes={ "a": (0, 0, 1000, 1000, 0), # optional 5th: varIndexBase "c": (-100.8, -200.4, 1100.1, 1200.5), # floats get rounded - "e": (0, 0, 10, 10), # missing base glyph 'e' is ignored + "e": (0, 0, 10, 10), # 'e' does _not_ get ignored despite being missing }, ) @@ -1689,9 +1689,11 @@ class BuildCOLRTest(object): ] == [ ("a", (0, 0, 1000, 1000, 0)), ("c", (-101, -201, 1101, 1201)), + ("e", (0, 0, 10, 10)), ] assert clipBoxes["a"].Format == 2 assert clipBoxes["c"].Format == 1 + assert clipBoxes["e"].Format == 1 def test_duplicate_base_glyphs(self): # If > 1 base glyphs refer to equivalent list of layers we expect them to share @@ -1778,81 +1780,3 @@ class TrickyRadialGradientTest: ) def test_nudge_start_circle_position(self, c0, r0, c1, r1, inside, expected): assert self.round_start_circle(c0, r0, c1, r1, inside) == expected - - -@pytest.mark.parametrize( - "lst, n, expected", - [ - ([0], 2, [0]), - ([0, 1], 2, [0, 1]), - ([0, 1, 2], 2, [[0, 1], 2]), - ([0, 1, 2], 3, [0, 1, 2]), - ([0, 1, 2, 3], 2, [[0, 1], [2, 3]]), - ([0, 1, 2, 3], 3, [[0, 1, 2], 3]), - ([0, 1, 2, 3, 4], 3, [[0, 1, 2], 3, 4]), - ([0, 1, 2, 3, 4, 5], 3, [[0, 1, 2], [3, 4, 5]]), - (list(range(7)), 3, [[0, 1, 2], [3, 4, 5], 6]), - (list(range(8)), 3, [[0, 1, 2], [3, 4, 5], [6, 7]]), - (list(range(9)), 3, [[0, 1, 2], [3, 4, 5], [6, 7, 8]]), - (list(range(10)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], 9]), - (list(range(11)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], 9, 10]), - (list(range(12)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11]]), - (list(range(13)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11], 12]), - ( - list(range(14)), - 3, - [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [[9, 10, 11], 12, 13]], - ), - ( - list(range(15)), - 3, - [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11], [12, 13, 14]], - ), - ( - list(range(16)), - 3, - [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [[9, 10, 11], [12, 13, 14], 15]], - ), - ( - list(range(23)), - 3, - [ - [[0, 1, 2], [3, 4, 5], [6, 7, 8]], - [[9, 10, 11], [12, 13, 14], [15, 16, 17]], - [[18, 19, 20], 21, 22], - ], - ), - ( - list(range(27)), - 3, - [ - [[0, 1, 2], [3, 4, 5], [6, 7, 8]], - [[9, 10, 11], [12, 13, 14], [15, 16, 17]], - [[18, 19, 20], [21, 22, 23], [24, 25, 26]], - ], - ), - ( - list(range(28)), - 3, - [ - [ - [[0, 1, 2], [3, 4, 5], [6, 7, 8]], - [[9, 10, 11], [12, 13, 14], [15, 16, 17]], - [[18, 19, 20], [21, 22, 23], [24, 25, 26]], - ], - 27, - ], - ), - (list(range(257)), 256, [list(range(256)), 256]), - (list(range(258)), 256, [list(range(256)), 256, 257]), - (list(range(512)), 256, [list(range(256)), list(range(256, 512))]), - (list(range(512 + 1)), 256, [list(range(256)), list(range(256, 512)), 512]), - ( - list(range(256 ** 2)), - 256, - [list(range(k * 256, k * 256 + 256)) for k in range(256)], - ), - ], -) -def test_build_n_ary_tree(lst, n, expected): - assert _build_n_ary_tree(lst, n) == expected diff --git a/Tests/colorLib/unbuilder_test.py b/Tests/colorLib/unbuilder_test.py index 354896805..fe5dc7d58 100644 --- a/Tests/colorLib/unbuilder_test.py +++ b/Tests/colorLib/unbuilder_test.py @@ -221,7 +221,26 @@ TEST_COLOR_GLYPHS = { "Glyph": "glyph00012", }, ], - } + }, + # When PaintColrLayers contains more than 255 layers, we build a tree + # of nested PaintColrLayers of max 255 items (NumLayers field is a uint8). + # Below we test that unbuildColrV1 restores a flat list of layers without + # nested PaintColrLayers. + "glyph00017": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": i, + "Alpha": 1.0, + }, + "Glyph": "glyph{str(18 + i).zfill(5)}", + } + for i in range(256) + ], + }, } @@ -230,7 +249,8 @@ def test_unbuildColrV1(): colorGlyphs = unbuildColrV1(layers, baseGlyphs) assert colorGlyphs == TEST_COLOR_GLYPHS + def test_unbuildColrV1_noLayers(): _, baseGlyphsV1 = buildColrV1(TEST_COLOR_GLYPHS) # Just looking to see we don't crash - unbuildColrV1(None, baseGlyphsV1) \ No newline at end of file + unbuildColrV1(None, baseGlyphsV1) diff --git a/Tests/misc/treeTools_test.py b/Tests/misc/treeTools_test.py new file mode 100644 index 000000000..467a5c575 --- /dev/null +++ b/Tests/misc/treeTools_test.py @@ -0,0 +1,80 @@ +from fontTools.misc.treeTools import build_n_ary_tree +import pytest + + +@pytest.mark.parametrize( + "lst, n, expected", + [ + ([0], 2, [0]), + ([0, 1], 2, [0, 1]), + ([0, 1, 2], 2, [[0, 1], 2]), + ([0, 1, 2], 3, [0, 1, 2]), + ([0, 1, 2, 3], 2, [[0, 1], [2, 3]]), + ([0, 1, 2, 3], 3, [[0, 1, 2], 3]), + ([0, 1, 2, 3, 4], 3, [[0, 1, 2], 3, 4]), + ([0, 1, 2, 3, 4, 5], 3, [[0, 1, 2], [3, 4, 5]]), + (list(range(7)), 3, [[0, 1, 2], [3, 4, 5], 6]), + (list(range(8)), 3, [[0, 1, 2], [3, 4, 5], [6, 7]]), + (list(range(9)), 3, [[0, 1, 2], [3, 4, 5], [6, 7, 8]]), + (list(range(10)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], 9]), + (list(range(11)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], 9, 10]), + (list(range(12)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11]]), + (list(range(13)), 3, [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11], 12]), + ( + list(range(14)), + 3, + [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [[9, 10, 11], 12, 13]], + ), + ( + list(range(15)), + 3, + [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [9, 10, 11], [12, 13, 14]], + ), + ( + list(range(16)), + 3, + [[[0, 1, 2], [3, 4, 5], [6, 7, 8]], [[9, 10, 11], [12, 13, 14], 15]], + ), + ( + list(range(23)), + 3, + [ + [[0, 1, 2], [3, 4, 5], [6, 7, 8]], + [[9, 10, 11], [12, 13, 14], [15, 16, 17]], + [[18, 19, 20], 21, 22], + ], + ), + ( + list(range(27)), + 3, + [ + [[0, 1, 2], [3, 4, 5], [6, 7, 8]], + [[9, 10, 11], [12, 13, 14], [15, 16, 17]], + [[18, 19, 20], [21, 22, 23], [24, 25, 26]], + ], + ), + ( + list(range(28)), + 3, + [ + [ + [[0, 1, 2], [3, 4, 5], [6, 7, 8]], + [[9, 10, 11], [12, 13, 14], [15, 16, 17]], + [[18, 19, 20], [21, 22, 23], [24, 25, 26]], + ], + 27, + ], + ), + (list(range(257)), 256, [list(range(256)), 256]), + (list(range(258)), 256, [list(range(256)), 256, 257]), + (list(range(512)), 256, [list(range(256)), list(range(256, 512))]), + (list(range(512 + 1)), 256, [list(range(256)), list(range(256, 512)), 512]), + ( + list(range(256 ** 2)), + 256, + [list(range(k * 256, k * 256 + 256)) for k in range(256)], + ), + ], +) +def test_build_n_ary_tree(lst, n, expected): + assert build_n_ary_tree(lst, n) == expected diff --git a/Tests/ttLib/tables/C_O_L_R_test.py b/Tests/ttLib/tables/C_O_L_R_test.py index aaf330030..11ddd67f5 100644 --- a/Tests/ttLib/tables/C_O_L_R_test.py +++ b/Tests/ttLib/tables/C_O_L_R_test.py @@ -532,17 +532,18 @@ COLR_V1_XML = [ COLR_V1_VAR_XML = [ '', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', ' ', ' ', ' ', - ' ', - ' ', + ' ', + ' ', ' ', ' ', "", @@ -571,12 +572,11 @@ COLR_V1_VAR_XML = [ ' ', " ", ' ', - " ", + " ", ' ', " ", ' ', - ' ', - ' ', + ' ', " ", "", ] diff --git a/Tests/varLib/data/TestVariableCOLR.designspace b/Tests/varLib/data/TestVariableCOLR.designspace new file mode 100644 index 000000000..0feddfab7 --- /dev/null +++ b/Tests/varLib/data/TestVariableCOLR.designspace @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/master_ttx_varcolr_ttf/TestVariableCOLR-Bold.ttx b/Tests/varLib/data/master_ttx_varcolr_ttf/TestVariableCOLR-Bold.ttx new file mode 100644 index 000000000..4202dabc7 --- /dev/null +++ b/Tests/varLib/data/master_ttx_varcolr_ttf/TestVariableCOLR-Bold.ttx @@ -0,0 +1,357 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + An Emoji Family + + + Bold + + + 1.000;NONE;AnEmojiFamily-Bold + + + An Emoji Family Bold + + + Version 1.000 + + + AnEmojiFamily-Bold + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/master_ttx_varcolr_ttf/TestVariableCOLR-Regular.ttx b/Tests/varLib/data/master_ttx_varcolr_ttf/TestVariableCOLR-Regular.ttx new file mode 100644 index 000000000..195be50a4 --- /dev/null +++ b/Tests/varLib/data/master_ttx_varcolr_ttf/TestVariableCOLR-Regular.ttx @@ -0,0 +1,335 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + An Emoji Family + + + Regular + + + 1.000;NONE;AnEmojiFamily-Regular + + + An Emoji Family Regular + + + Version 1.000 + + + AnEmojiFamily-Regular + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/TestVariableCOLR-VF.ttx b/Tests/varLib/data/test_results/TestVariableCOLR-VF.ttx new file mode 100644 index 000000000..8d0177abd --- /dev/null +++ b/Tests/varLib/data/test_results/TestVariableCOLR-VF.ttx @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + wght + 0x0 + 400.0 + 400.0 + 700.0 + 256 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/merger_test.py b/Tests/varLib/merger_test.py new file mode 100644 index 000000000..8b9970c58 --- /dev/null +++ b/Tests/varLib/merger_test.py @@ -0,0 +1,1844 @@ +from copy import deepcopy +import string +from fontTools.colorLib.builder import LayerListBuilder, buildCOLR, buildClipList +from fontTools.misc.testTools import getXML +from fontTools.varLib.merger import COLRVariationMerger +from fontTools.varLib.models import VariationModel +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables import otTables as ot +from fontTools.ttLib.tables.otBase import OTTableReader, OTTableWriter +import pytest + + +NO_VARIATION_INDEX = ot.NO_VARIATION_INDEX + + +def dump_xml(table, ttFont=None): + xml = getXML(table.toXML, ttFont) + print("[") + for line in xml: + print(f" {line!r},") + print("]") + return xml + + +def compile_decompile(table, ttFont): + writer = OTTableWriter(tableTag="COLR") + # compile itself may modify a table, safer to copy it first + table = deepcopy(table) + table.compile(writer, ttFont) + data = writer.getAllData() + + reader = OTTableReader(data, tableTag="COLR") + table2 = table.__class__() + table2.decompile(reader, ttFont) + + return table2 + + +@pytest.fixture +def ttFont(): + font = TTFont() + font.setGlyphOrder([".notdef"] + list(string.ascii_letters)) + return font + + +def build_paint(data): + return LayerListBuilder().buildPaint(data) + + +class COLRVariationMergerTest: + @pytest.mark.parametrize( + "paints, expected_xml, expected_varIdxes", + [ + pytest.param( + [ + { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + ], + [ + '', + ' ', + ' ', + "", + ], + [], + id="solid-same", + ), + pytest.param( + [ + { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 0.5, + }, + ], + [ + '', + ' ', + ' ', + ' ', + "", + ], + [0], + id="solid-alpha", + ), + pytest.param( + [ + { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + {"StopOffset": 0.0, "PaletteIndex": 0, "Alpha": 1.0}, + {"StopOffset": 1.0, "PaletteIndex": 1, "Alpha": 1.0}, + ], + }, + "x0": 0, + "y0": 0, + "x1": 1, + "y1": 1, + "x2": 2, + "y2": 2, + }, + { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + {"StopOffset": 0.1, "PaletteIndex": 0, "Alpha": 1.0}, + {"StopOffset": 0.9, "PaletteIndex": 1, "Alpha": 1.0}, + ], + }, + "x0": 0, + "y0": 0, + "x1": 1, + "y1": 1, + "x2": 2, + "y2": 2, + }, + ], + [ + '', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + "", + ], + [0, NO_VARIATION_INDEX, 1, NO_VARIATION_INDEX], + id="linear_grad-stop-offsets", + ), + pytest.param( + [ + { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + {"StopOffset": 0.0, "PaletteIndex": 0, "Alpha": 1.0}, + {"StopOffset": 1.0, "PaletteIndex": 1, "Alpha": 1.0}, + ], + }, + "x0": 0, + "y0": 0, + "x1": 1, + "y1": 1, + "x2": 2, + "y2": 2, + }, + { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + {"StopOffset": 0.0, "PaletteIndex": 0, "Alpha": 0.5}, + {"StopOffset": 1.0, "PaletteIndex": 1, "Alpha": 1.0}, + ], + }, + "x0": 0, + "y0": 0, + "x1": 1, + "y1": 1, + "x2": 2, + "y2": 2, + }, + ], + [ + '', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + "", + ], + [NO_VARIATION_INDEX, 0], + id="linear_grad-stop[0].alpha", + ), + pytest.param( + [ + { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + {"StopOffset": 0.0, "PaletteIndex": 0, "Alpha": 1.0}, + {"StopOffset": 1.0, "PaletteIndex": 1, "Alpha": 1.0}, + ], + }, + "x0": 0, + "y0": 0, + "x1": 1, + "y1": 1, + "x2": 2, + "y2": 2, + }, + { + "Format": int(ot.PaintFormat.PaintLinearGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + {"StopOffset": -0.5, "PaletteIndex": 0, "Alpha": 1.0}, + {"StopOffset": 1.0, "PaletteIndex": 1, "Alpha": 1.0}, + ], + }, + "x0": 0, + "y0": 0, + "x1": 1, + "y1": 1, + "x2": 2, + "y2": -200, + }, + ], + [ + '', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + "", + ], + [ + 0, + NO_VARIATION_INDEX, + NO_VARIATION_INDEX, + NO_VARIATION_INDEX, + NO_VARIATION_INDEX, + NO_VARIATION_INDEX, + 1, + ], + id="linear_grad-stop[0].offset-y2", + ), + pytest.param( + [ + { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + {"StopOffset": 0.0, "PaletteIndex": 0, "Alpha": 1.0}, + {"StopOffset": 1.0, "PaletteIndex": 1, "Alpha": 1.0}, + ], + }, + "x0": 0, + "y0": 0, + "r0": 0, + "x1": 1, + "y1": 1, + "r1": 1, + }, + { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + {"StopOffset": 0.1, "PaletteIndex": 0, "Alpha": 0.6}, + {"StopOffset": 0.9, "PaletteIndex": 1, "Alpha": 0.7}, + ], + }, + "x0": -1, + "y0": -2, + "r0": 3, + "x1": -4, + "y1": -5, + "r1": 6, + }, + ], + [ + '', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + "", + ], + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + id="radial_grad-all-different", + ), + pytest.param( + [ + { + "Format": int(ot.PaintFormat.PaintSweepGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.REPEAT), + "ColorStop": [ + {"StopOffset": 0.4, "PaletteIndex": 0, "Alpha": 1.0}, + {"StopOffset": 0.6, "PaletteIndex": 1, "Alpha": 1.0}, + ], + }, + "centerX": 0, + "centerY": 0, + "startAngle": 0, + "endAngle": 180.0, + }, + { + "Format": int(ot.PaintFormat.PaintSweepGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.REPEAT), + "ColorStop": [ + {"StopOffset": 0.4, "PaletteIndex": 0, "Alpha": 1.0}, + {"StopOffset": 0.6, "PaletteIndex": 1, "Alpha": 1.0}, + ], + }, + "centerX": 0, + "centerY": 0, + "startAngle": 90.0, + "endAngle": 180.0, + }, + ], + [ + '', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + "", + ], + [NO_VARIATION_INDEX, NO_VARIATION_INDEX, 0, NO_VARIATION_INDEX], + id="sweep_grad-startAngle", + ), + pytest.param( + [ + { + "Format": int(ot.PaintFormat.PaintSweepGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + {"StopOffset": 0.0, "PaletteIndex": 0, "Alpha": 1.0}, + {"StopOffset": 1.0, "PaletteIndex": 1, "Alpha": 1.0}, + ], + }, + "centerX": 0, + "centerY": 0, + "startAngle": 0.0, + "endAngle": 180.0, + }, + { + "Format": int(ot.PaintFormat.PaintSweepGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + {"StopOffset": 0.0, "PaletteIndex": 0, "Alpha": 0.5}, + {"StopOffset": 1.0, "PaletteIndex": 1, "Alpha": 0.5}, + ], + }, + "centerX": 0, + "centerY": 0, + "startAngle": 0.0, + "endAngle": 180.0, + }, + ], + [ + '', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + "", + ], + [NO_VARIATION_INDEX, 0], + id="sweep_grad-stops-alpha-reuse-varidxbase", + ), + pytest.param( + [ + { + "Format": int(ot.PaintFormat.PaintTransform), + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + { + "StopOffset": 0.0, + "PaletteIndex": 0, + "Alpha": 1.0, + }, + { + "StopOffset": 1.0, + "PaletteIndex": 1, + "Alpha": 1.0, + }, + ], + }, + "x0": 0, + "y0": 0, + "r0": 0, + "x1": 1, + "y1": 1, + "r1": 1, + }, + "Transform": { + "xx": 1.0, + "xy": 0.0, + "yx": 0.0, + "yy": 1.0, + "dx": 0.0, + "dy": 0.0, + }, + }, + { + "Format": int(ot.PaintFormat.PaintTransform), + "Paint": { + "Format": int(ot.PaintFormat.PaintRadialGradient), + "ColorLine": { + "Extend": int(ot.ExtendMode.PAD), + "ColorStop": [ + { + "StopOffset": 0.0, + "PaletteIndex": 0, + "Alpha": 1.0, + }, + { + "StopOffset": 1.0, + "PaletteIndex": 1, + "Alpha": 1.0, + }, + ], + }, + "x0": 0, + "y0": 0, + "r0": 0, + "x1": 1, + "y1": 1, + "r1": 1, + }, + "Transform": { + "xx": 1.0, + "xy": 0.0, + "yx": 0.0, + "yy": 0.5, + "dx": 0.0, + "dy": -100.0, + }, + }, + ], + [ + '', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + "", + ], + [ + NO_VARIATION_INDEX, + NO_VARIATION_INDEX, + NO_VARIATION_INDEX, + 0, + NO_VARIATION_INDEX, + 1, + ], + id="transform-yy-dy", + ), + pytest.param( + [ + { + "Format": ot.PaintFormat.PaintTransform, + "Paint": { + "Format": ot.PaintFormat.PaintSweepGradient, + "ColorLine": { + "Extend": ot.ExtendMode.PAD, + "ColorStop": [ + {"StopOffset": 0.0, "PaletteIndex": 0}, + { + "StopOffset": 1.0, + "PaletteIndex": 1, + "Alpha": 1.0, + }, + ], + }, + "centerX": 0, + "centerY": 0, + "startAngle": -360, + "endAngle": 0, + }, + "Transform": (1.0, 0, 0, 1.0, 0, 0), + }, + { + "Format": ot.PaintFormat.PaintTransform, + "Paint": { + "Format": ot.PaintFormat.PaintSweepGradient, + "ColorLine": { + "Extend": ot.ExtendMode.PAD, + "ColorStop": [ + {"StopOffset": 0.0, "PaletteIndex": 0}, + { + "StopOffset": 1.0, + "PaletteIndex": 1, + "Alpha": 1.0, + }, + ], + }, + "centerX": 256, + "centerY": 0, + "startAngle": -360, + "endAngle": 0, + }, + # Transform.xx below produces the same VarStore delta as the + # above PaintSweepGradient's centerX because, when Fixed16.16 + # is converted to integer, it becomes: + # floatToFixed(1.00390625, 16) == 256 + # Because there is overlap between the varIdxes of the + # PaintVarTransform's Affine2x3 and the PaintSweepGradient's + # the VarIndexBase is reused (0 for both) + "Transform": (1.00390625, 0, 0, 1.0, 10, 0), + }, + ], + [ + '', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + "", + ], + [ + 0, + NO_VARIATION_INDEX, + NO_VARIATION_INDEX, + NO_VARIATION_INDEX, + 1, + NO_VARIATION_INDEX, + ], + id="transform-xx-sweep_grad-centerx-same-varidxbase", + ), + ], + ) + def test_merge_Paint(self, paints, ttFont, expected_xml, expected_varIdxes): + paints = [build_paint(p) for p in paints] + out = deepcopy(paints[0]) + + model = VariationModel([{}, {"ZZZZ": 1.0}]) + merger = COLRVariationMerger(model, ["ZZZZ"], ttFont) + + merger.mergeThings(out, paints) + + assert compile_decompile(out, ttFont) == out + assert dump_xml(out, ttFont) == expected_xml + assert merger.varIdxes == expected_varIdxes + + def test_merge_ClipList(self, ttFont): + clipLists = [ + buildClipList(clips) + for clips in [ + { + "A": (0, 0, 1000, 1000), + "B": (0, 0, 1000, 1000), + "C": (0, 0, 1000, 1000), + "D": (0, 0, 1000, 1000), + }, + { + # non-default masters' clip boxes can be 'sparse' + # (i.e. can omit explicit clip box for some glyphs) + # "A": (0, 0, 1000, 1000), + "B": (10, 0, 1000, 1000), + "C": (20, 20, 1020, 1020), + "D": (20, 20, 1020, 1020), + }, + ] + ] + out = deepcopy(clipLists[0]) + + model = VariationModel([{}, {"ZZZZ": 1.0}]) + merger = COLRVariationMerger(model, ["ZZZZ"], ttFont) + + merger.mergeThings(out, clipLists) + + assert compile_decompile(out, ttFont) == out + assert dump_xml(out, ttFont) == [ + '', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + "", + ] + assert merger.varIdxes == [ + 0, + NO_VARIATION_INDEX, + NO_VARIATION_INDEX, + NO_VARIATION_INDEX, + 1, + 1, + 1, + 1, + ] + + @pytest.mark.parametrize( + "master_layer_reuse", + [ + pytest.param(False, id="no-reuse"), + pytest.param(True, id="with-reuse"), + ], + ) + @pytest.mark.parametrize( + "color_glyphs, output_layer_reuse, expected_xml, expected_varIdxes", + [ + pytest.param( + [ + { + "A": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + }, + { + "A": { + "Format": ot.PaintFormat.PaintColrLayers, + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + }, + ], + False, + [ + "", + ' ', + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + " ", + "", + ], + [], + id="no-variation", + ), + pytest.param( + [ + { + "A": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + "C": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 3, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + }, + { + # NOTE: 'A' is missing from non-default master + "C": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 0.5, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 3, + "Alpha": 0.5, + }, + "Glyph": "B", + }, + ], + }, + }, + ], + False, + [ + "", + ' ', + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + " ", + "", + ], + [0], + id="sparse-masters", + ), + pytest.param( + [ + { + "A": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + "C": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + # 'C' reuses layers 1-3 from 'A' + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + "D": { # identical to 'C' + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + "E": { # superset of 'C' or 'D' + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 3, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + }, + { + # NOTE: 'A' is missing from non-default master + "C": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 0.5, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 0.5, + }, + "Glyph": "B", + }, + ], + }, + "D": { # same as 'C' + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 0.5, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 0.5, + }, + "Glyph": "B", + }, + ], + }, + "E": { # first two layers vary the same way as 'C' or 'D' + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 0.5, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 0.5, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 3, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + }, + ], + True, # reuse + [ + "", + ' ', + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + " ", + "", + ], + [0], + id="sparse-masters-with-reuse", + ), + pytest.param( + [ + { + "A": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + "C": { # 'C' shares layer 1 and 2 with 'A' + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + }, + { + "A": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 0.9, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + "C": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 0.5, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + ], + }, + }, + ], + True, + [ + # a different Alpha variation is applied to a shared layer between + # 'A' and 'C' and thus they are no longer shared. + "", + ' ', + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + " ", + "", + ], + [0, 1], + id="shared-master-layers-different-variations", + ), + ], + ) + def test_merge_full_table( + self, + color_glyphs, + ttFont, + expected_xml, + expected_varIdxes, + master_layer_reuse, + output_layer_reuse, + ): + master_ttfs = [deepcopy(ttFont) for _ in range(len(color_glyphs))] + for ttf, glyphs in zip(master_ttfs, color_glyphs): + # merge algorithm is expected to work the same even if the master COLRs + # may differ as to the layer reuse, hence we try both ways + ttf["COLR"] = buildCOLR(glyphs, allowLayerReuse=master_layer_reuse) + vf = deepcopy(master_ttfs[0]) + + model = VariationModel([{}, {"ZZZZ": 1.0}]) + merger = COLRVariationMerger( + model, ["ZZZZ"], vf, allowLayerReuse=output_layer_reuse + ) + + merger.mergeTables(vf, master_ttfs) + + out = vf["COLR"].table + + assert compile_decompile(out, vf) == out + assert dump_xml(out, vf) == expected_xml + assert merger.varIdxes == expected_varIdxes + + @pytest.mark.parametrize( + "color_glyphs, before_xml, expected_xml", + [ + pytest.param( + { + "A": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "C", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "D", + }, + ], + }, + "E": { + "Format": int(ot.PaintFormat.PaintColrLayers), + "Layers": [ + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 1, + "Alpha": 1.0, + }, + "Glyph": "C", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 2, + "Alpha": 1.0, + }, + "Glyph": "D", + }, + { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 3, + "Alpha": 1.0, + }, + "Glyph": "F", + }, + ], + }, + "G": { + "Format": int(ot.PaintFormat.PaintColrGlyph), + "Glyph": "E", + }, + }, + [ + "", + ' ', + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + " ", + "", + ], + [ + "", + ' ', + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + " ", + "", + ], + id="simple-reuse", + ), + pytest.param( + { + "A": { + "Format": int(ot.PaintFormat.PaintGlyph), + "Paint": { + "Format": int(ot.PaintFormat.PaintSolid), + "PaletteIndex": 0, + "Alpha": 1.0, + }, + "Glyph": "B", + }, + }, + [ + "", + ' ', + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + " ", + " ", + "", + ], + [ + "", + ' ', + " ", + " ", + " ", + " ", + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + " ", + ' ', + " ", + " ", + " ", + "", + ], + id="no-layer-list", + ), + ], + ) + def test_expandPaintColrLayers( + self, color_glyphs, ttFont, before_xml, expected_xml + ): + colr = buildCOLR(color_glyphs, allowLayerReuse=True) + + assert dump_xml(colr.table, ttFont) == before_xml + + before_layer_count = 0 + reuses_colr_layers = False + if colr.table.LayerList: + before_layer_count = len(colr.table.LayerList.Paint) + reuses_colr_layers = any( + p.Format == ot.PaintFormat.PaintColrLayers + for p in colr.table.LayerList.Paint + ) + + COLRVariationMerger.expandPaintColrLayers(colr.table) + + assert dump_xml(colr.table, ttFont) == expected_xml + + after_layer_count = ( + 0 if not colr.table.LayerList else len(colr.table.LayerList.Paint) + ) + + if reuses_colr_layers: + assert not any( + p.Format == ot.PaintFormat.PaintColrLayers + for p in colr.table.LayerList.Paint + ) + assert after_layer_count > before_layer_count + else: + assert after_layer_count == before_layer_count + + if colr.table.LayerList: + assert len({id(p) for p in colr.table.LayerList.Paint}) == after_layer_count diff --git a/Tests/varLib/varLib_test.py b/Tests/varLib/varLib_test.py index d1e7762b1..29f909ae6 100644 --- a/Tests/varLib/varLib_test.py +++ b/Tests/varLib/varLib_test.py @@ -841,7 +841,7 @@ Got: kern. def test_varlib_build_incompatible_lookup_types(self): with pytest.raises( varLibErrors.MismatchedTypes, - match = r"MarkBasePos, instead saw PairPos" + match = r"'MarkBasePos', instead saw 'PairPos'" ): self._run_varlib_build_test( designspace_name="IncompatibleLookupTypes", @@ -871,6 +871,15 @@ Expected to see .ScriptCount==1, instead saw 0""" save_before_dump=True, ) + def test_varlib_build_variable_colr(self): + self._run_varlib_build_test( + designspace_name='TestVariableCOLR', + font_name='TestVariableCOLR', + tables=["GlyphOrder", "fvar", "glyf", "COLR", "CPAL"], + expected_ttx_name='TestVariableCOLR-VF', + save_before_dump=True, + ) + def test_load_masters_layerName_without_required_font(): ds = DesignSpaceDocument() s = SourceDescriptor()