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()