• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""
2colorLib.builder: Build COLR/CPAL tables from scratch
3
4"""
5import collections
6import copy
7import enum
8from functools import partial
9from math import ceil, log
10from typing import (
11    Any,
12    Dict,
13    Generator,
14    Iterable,
15    List,
16    Mapping,
17    Optional,
18    Sequence,
19    Tuple,
20    Type,
21    TypeVar,
22    Union,
23)
24from fontTools.misc.fixedTools import fixedToFloat
25from fontTools.ttLib.tables import C_O_L_R_
26from fontTools.ttLib.tables import C_P_A_L_
27from fontTools.ttLib.tables import _n_a_m_e
28from fontTools.ttLib.tables import otTables as ot
29from fontTools.ttLib.tables.otTables import (
30    ExtendMode,
31    CompositeMode,
32    VariableValue,
33    VariableFloat,
34    VariableInt,
35)
36from .errors import ColorLibError
37from .geometry import round_start_circle_stable_containment
38from .table_builder import (
39    convertTupleClass,
40    BuildCallback,
41    TableBuilder,
42)
43
44
45# TODO move type aliases to colorLib.types?
46T = TypeVar("T")
47_Kwargs = Mapping[str, Any]
48_PaintInput = Union[int, _Kwargs, ot.Paint, Tuple[str, "_PaintInput"]]
49_PaintInputList = Sequence[_PaintInput]
50_ColorGlyphsDict = Dict[str, Union[_PaintInputList, _PaintInput]]
51_ColorGlyphsV0Dict = Dict[str, Sequence[Tuple[str, int]]]
52
53
54MAX_PAINT_COLR_LAYER_COUNT = 255
55_DEFAULT_ALPHA = VariableFloat(1.0)
56_MAX_REUSE_LEN = 32
57
58
59def _beforeBuildPaintVarRadialGradient(paint, source, srcMapFn=lambda v: v):
60    # normalize input types (which may or may not specify a varIdx)
61    x0 = convertTupleClass(VariableFloat, source["x0"])
62    y0 = convertTupleClass(VariableFloat, source["y0"])
63    r0 = convertTupleClass(VariableFloat, source["r0"])
64    x1 = convertTupleClass(VariableFloat, source["x1"])
65    y1 = convertTupleClass(VariableFloat, source["y1"])
66    r1 = convertTupleClass(VariableFloat, source["r1"])
67
68    # TODO apparently no builder_test confirms this works (?)
69
70    # avoid abrupt change after rounding when c0 is near c1's perimeter
71    c = round_start_circle_stable_containment(
72        (x0.value, y0.value), r0.value, (x1.value, y1.value), r1.value
73    )
74    x0, y0 = x0._replace(value=c.centre[0]), y0._replace(value=c.centre[1])
75    r0 = r0._replace(value=c.radius)
76
77    # update source to ensure paint is built with corrected values
78    source["x0"] = srcMapFn(x0)
79    source["y0"] = srcMapFn(y0)
80    source["r0"] = srcMapFn(r0)
81    source["x1"] = srcMapFn(x1)
82    source["y1"] = srcMapFn(y1)
83    source["r1"] = srcMapFn(r1)
84
85    return paint, source
86
87
88def _beforeBuildPaintRadialGradient(paint, source):
89    return _beforeBuildPaintVarRadialGradient(paint, source, lambda v: v.value)
90
91
92def _defaultColorIndex():
93    colorIndex = ot.ColorIndex()
94    colorIndex.Alpha = _DEFAULT_ALPHA.value
95    return colorIndex
96
97
98def _defaultVarColorIndex():
99    colorIndex = ot.VarColorIndex()
100    colorIndex.Alpha = _DEFAULT_ALPHA
101    return colorIndex
102
103
104def _defaultColorLine():
105    colorLine = ot.ColorLine()
106    colorLine.Extend = ExtendMode.PAD
107    return colorLine
108
109
110def _defaultVarColorLine():
111    colorLine = ot.VarColorLine()
112    colorLine.Extend = ExtendMode.PAD
113    return colorLine
114
115
116def _buildPaintCallbacks():
117    return {
118        (
119            BuildCallback.BEFORE_BUILD,
120            ot.Paint,
121            ot.PaintFormat.PaintRadialGradient,
122        ): _beforeBuildPaintRadialGradient,
123        (
124            BuildCallback.BEFORE_BUILD,
125            ot.Paint,
126            ot.PaintFormat.PaintVarRadialGradient,
127        ): _beforeBuildPaintVarRadialGradient,
128        (BuildCallback.CREATE_DEFAULT, ot.ColorIndex): _defaultColorIndex,
129        (BuildCallback.CREATE_DEFAULT, ot.VarColorIndex): _defaultVarColorIndex,
130        (BuildCallback.CREATE_DEFAULT, ot.ColorLine): _defaultColorLine,
131        (BuildCallback.CREATE_DEFAULT, ot.VarColorLine): _defaultVarColorLine,
132    }
133
134
135def populateCOLRv0(
136    table: ot.COLR,
137    colorGlyphsV0: _ColorGlyphsV0Dict,
138    glyphMap: Optional[Mapping[str, int]] = None,
139):
140    """Build v0 color layers and add to existing COLR table.
141
142    Args:
143        table: a raw otTables.COLR() object (not ttLib's table_C_O_L_R_).
144        colorGlyphsV0: map of base glyph names to lists of (layer glyph names,
145            color palette index) tuples.
146        glyphMap: a map from glyph names to glyph indices, as returned from
147            TTFont.getReverseGlyphMap(), to optionally sort base records by GID.
148    """
149    if glyphMap is not None:
150        colorGlyphItems = sorted(
151            colorGlyphsV0.items(), key=lambda item: glyphMap[item[0]]
152        )
153    else:
154        colorGlyphItems = colorGlyphsV0.items()
155    baseGlyphRecords = []
156    layerRecords = []
157    for baseGlyph, layers in colorGlyphItems:
158        baseRec = ot.BaseGlyphRecord()
159        baseRec.BaseGlyph = baseGlyph
160        baseRec.FirstLayerIndex = len(layerRecords)
161        baseRec.NumLayers = len(layers)
162        baseGlyphRecords.append(baseRec)
163
164        for layerGlyph, paletteIndex in layers:
165            layerRec = ot.LayerRecord()
166            layerRec.LayerGlyph = layerGlyph
167            layerRec.PaletteIndex = paletteIndex
168            layerRecords.append(layerRec)
169
170    table.BaseGlyphRecordCount = len(baseGlyphRecords)
171    table.BaseGlyphRecordArray = ot.BaseGlyphRecordArray()
172    table.BaseGlyphRecordArray.BaseGlyphRecord = baseGlyphRecords
173    table.LayerRecordArray = ot.LayerRecordArray()
174    table.LayerRecordArray.LayerRecord = layerRecords
175    table.LayerRecordCount = len(layerRecords)
176
177
178def buildCOLR(
179    colorGlyphs: _ColorGlyphsDict,
180    version: Optional[int] = None,
181    glyphMap: Optional[Mapping[str, int]] = None,
182    varStore: Optional[ot.VarStore] = None,
183) -> C_O_L_R_.table_C_O_L_R_:
184    """Build COLR table from color layers mapping.
185    Args:
186        colorGlyphs: map of base glyph name to, either list of (layer glyph name,
187            color palette index) tuples for COLRv0; or a single Paint (dict) or
188            list of Paint for COLRv1.
189        version: the version of COLR table. If None, the version is determined
190            by the presence of COLRv1 paints or variation data (varStore), which
191            require version 1; otherwise, if all base glyphs use only simple color
192            layers, version 0 is used.
193        glyphMap: a map from glyph names to glyph indices, as returned from
194            TTFont.getReverseGlyphMap(), to optionally sort base records by GID.
195        varStore: Optional ItemVarationStore for deltas associated with v1 layer.
196    Return:
197        A new COLR table.
198    """
199    self = C_O_L_R_.table_C_O_L_R_()
200
201    if varStore is not None and version == 0:
202        raise ValueError("Can't add VarStore to COLRv0")
203
204    if version in (None, 0) and not varStore:
205        # split color glyphs into v0 and v1 and encode separately
206        colorGlyphsV0, colorGlyphsV1 = _split_color_glyphs_by_version(colorGlyphs)
207        if version == 0 and colorGlyphsV1:
208            raise ValueError("Can't encode COLRv1 glyphs in COLRv0")
209    else:
210        # unless explicitly requested for v1 or have variations, in which case
211        # we encode all color glyph as v1
212        colorGlyphsV0, colorGlyphsV1 = None, colorGlyphs
213
214    colr = ot.COLR()
215
216    if colorGlyphsV0:
217        populateCOLRv0(colr, colorGlyphsV0, glyphMap)
218    else:
219        colr.BaseGlyphRecordCount = colr.LayerRecordCount = 0
220        colr.BaseGlyphRecordArray = colr.LayerRecordArray = None
221
222    if colorGlyphsV1:
223        colr.LayerV1List, colr.BaseGlyphV1List = buildColrV1(colorGlyphsV1, glyphMap)
224
225    if version is None:
226        version = 1 if (varStore or colorGlyphsV1) else 0
227    elif version not in (0, 1):
228        raise NotImplementedError(version)
229    self.version = colr.Version = version
230
231    if version == 0:
232        self.ColorLayers = self._decompileColorLayersV0(colr)
233    else:
234        colr.VarStore = varStore
235        self.table = colr
236
237    return self
238
239
240class ColorPaletteType(enum.IntFlag):
241    USABLE_WITH_LIGHT_BACKGROUND = 0x0001
242    USABLE_WITH_DARK_BACKGROUND = 0x0002
243
244    @classmethod
245    def _missing_(cls, value):
246        # enforce reserved bits
247        if isinstance(value, int) and (value < 0 or value & 0xFFFC != 0):
248            raise ValueError(f"{value} is not a valid {cls.__name__}")
249        return super()._missing_(value)
250
251
252# None, 'abc' or {'en': 'abc', 'de': 'xyz'}
253_OptionalLocalizedString = Union[None, str, Dict[str, str]]
254
255
256def buildPaletteLabels(
257    labels: Iterable[_OptionalLocalizedString], nameTable: _n_a_m_e.table__n_a_m_e
258) -> List[Optional[int]]:
259    return [
260        nameTable.addMultilingualName(l, mac=False)
261        if isinstance(l, dict)
262        else C_P_A_L_.table_C_P_A_L_.NO_NAME_ID
263        if l is None
264        else nameTable.addMultilingualName({"en": l}, mac=False)
265        for l in labels
266    ]
267
268
269def buildCPAL(
270    palettes: Sequence[Sequence[Tuple[float, float, float, float]]],
271    paletteTypes: Optional[Sequence[ColorPaletteType]] = None,
272    paletteLabels: Optional[Sequence[_OptionalLocalizedString]] = None,
273    paletteEntryLabels: Optional[Sequence[_OptionalLocalizedString]] = None,
274    nameTable: Optional[_n_a_m_e.table__n_a_m_e] = None,
275) -> C_P_A_L_.table_C_P_A_L_:
276    """Build CPAL table from list of color palettes.
277
278    Args:
279        palettes: list of lists of colors encoded as tuples of (R, G, B, A) floats
280            in the range [0..1].
281        paletteTypes: optional list of ColorPaletteType, one for each palette.
282        paletteLabels: optional list of palette labels. Each lable can be either:
283            None (no label), a string (for for default English labels), or a
284            localized string (as a dict keyed with BCP47 language codes).
285        paletteEntryLabels: optional list of palette entry labels, one for each
286            palette entry (see paletteLabels).
287        nameTable: optional name table where to store palette and palette entry
288            labels. Required if either paletteLabels or paletteEntryLabels is set.
289
290    Return:
291        A new CPAL v0 or v1 table, if custom palette types or labels are specified.
292    """
293    if len({len(p) for p in palettes}) != 1:
294        raise ColorLibError("color palettes have different lengths")
295
296    if (paletteLabels or paletteEntryLabels) and not nameTable:
297        raise TypeError(
298            "nameTable is required if palette or palette entries have labels"
299        )
300
301    cpal = C_P_A_L_.table_C_P_A_L_()
302    cpal.numPaletteEntries = len(palettes[0])
303
304    cpal.palettes = []
305    for i, palette in enumerate(palettes):
306        colors = []
307        for j, color in enumerate(palette):
308            if not isinstance(color, tuple) or len(color) != 4:
309                raise ColorLibError(
310                    f"In palette[{i}][{j}]: expected (R, G, B, A) tuple, got {color!r}"
311                )
312            if any(v > 1 or v < 0 for v in color):
313                raise ColorLibError(
314                    f"palette[{i}][{j}] has invalid out-of-range [0..1] color: {color!r}"
315                )
316            # input colors are RGBA, CPAL encodes them as BGRA
317            red, green, blue, alpha = color
318            colors.append(
319                C_P_A_L_.Color(*(round(v * 255) for v in (blue, green, red, alpha)))
320            )
321        cpal.palettes.append(colors)
322
323    if any(v is not None for v in (paletteTypes, paletteLabels, paletteEntryLabels)):
324        cpal.version = 1
325
326        if paletteTypes is not None:
327            if len(paletteTypes) != len(palettes):
328                raise ColorLibError(
329                    f"Expected {len(palettes)} paletteTypes, got {len(paletteTypes)}"
330                )
331            cpal.paletteTypes = [ColorPaletteType(t).value for t in paletteTypes]
332        else:
333            cpal.paletteTypes = [C_P_A_L_.table_C_P_A_L_.DEFAULT_PALETTE_TYPE] * len(
334                palettes
335            )
336
337        if paletteLabels is not None:
338            if len(paletteLabels) != len(palettes):
339                raise ColorLibError(
340                    f"Expected {len(palettes)} paletteLabels, got {len(paletteLabels)}"
341                )
342            cpal.paletteLabels = buildPaletteLabels(paletteLabels, nameTable)
343        else:
344            cpal.paletteLabels = [C_P_A_L_.table_C_P_A_L_.NO_NAME_ID] * len(palettes)
345
346        if paletteEntryLabels is not None:
347            if len(paletteEntryLabels) != cpal.numPaletteEntries:
348                raise ColorLibError(
349                    f"Expected {cpal.numPaletteEntries} paletteEntryLabels, "
350                    f"got {len(paletteEntryLabels)}"
351                )
352            cpal.paletteEntryLabels = buildPaletteLabels(paletteEntryLabels, nameTable)
353        else:
354            cpal.paletteEntryLabels = [
355                C_P_A_L_.table_C_P_A_L_.NO_NAME_ID
356            ] * cpal.numPaletteEntries
357    else:
358        cpal.version = 0
359
360    return cpal
361
362
363# COLR v1 tables
364# See draft proposal at: https://github.com/googlefonts/colr-gradients-spec
365
366
367def _is_colrv0_layer(layer: Any) -> bool:
368    # Consider as COLRv0 layer any sequence of length 2 (be it tuple or list) in which
369    # the first element is a str (the layerGlyph) and the second element is an int
370    # (CPAL paletteIndex).
371    # https://github.com/googlefonts/ufo2ft/issues/426
372    try:
373        layerGlyph, paletteIndex = layer
374    except (TypeError, ValueError):
375        return False
376    else:
377        return isinstance(layerGlyph, str) and isinstance(paletteIndex, int)
378
379
380def _split_color_glyphs_by_version(
381    colorGlyphs: _ColorGlyphsDict,
382) -> Tuple[_ColorGlyphsV0Dict, _ColorGlyphsDict]:
383    colorGlyphsV0 = {}
384    colorGlyphsV1 = {}
385    for baseGlyph, layers in colorGlyphs.items():
386        if all(_is_colrv0_layer(l) for l in layers):
387            colorGlyphsV0[baseGlyph] = layers
388        else:
389            colorGlyphsV1[baseGlyph] = layers
390
391    # sanity check
392    assert set(colorGlyphs) == (set(colorGlyphsV0) | set(colorGlyphsV1))
393
394    return colorGlyphsV0, colorGlyphsV1
395
396
397def _reuse_ranges(num_layers: int) -> Generator[Tuple[int, int], None, None]:
398    # TODO feels like something itertools might have already
399    for lbound in range(num_layers):
400        # Reuse of very large #s of layers is relatively unlikely
401        # +2: we want sequences of at least 2
402        # otData handles single-record duplication
403        for ubound in range(
404            lbound + 2, min(num_layers + 1, lbound + 2 + _MAX_REUSE_LEN)
405        ):
406            yield (lbound, ubound)
407
408
409class LayerV1ListBuilder:
410    slices: List[ot.Paint]
411    layers: List[ot.Paint]
412    reusePool: Mapping[Tuple[Any, ...], int]
413    tuples: Mapping[int, Tuple[Any, ...]]
414    keepAlive: List[ot.Paint]  # we need id to remain valid
415
416    def __init__(self):
417        self.slices = []
418        self.layers = []
419        self.reusePool = {}
420        self.tuples = {}
421        self.keepAlive = []
422
423        # We need to intercept construction of PaintColrLayers
424        callbacks = _buildPaintCallbacks()
425        callbacks[
426            (
427                BuildCallback.BEFORE_BUILD,
428                ot.Paint,
429                ot.PaintFormat.PaintColrLayers,
430            )
431        ] = self._beforeBuildPaintColrLayers
432        self.tableBuilder = TableBuilder(callbacks)
433
434    def _paint_tuple(self, paint: ot.Paint):
435        # start simple, who even cares about cyclic graphs or interesting field types
436        def _tuple_safe(value):
437            if isinstance(value, enum.Enum):
438                return value
439            elif hasattr(value, "__dict__"):
440                return tuple(
441                    (k, _tuple_safe(v)) for k, v in sorted(value.__dict__.items())
442                )
443            elif isinstance(value, collections.abc.MutableSequence):
444                return tuple(_tuple_safe(e) for e in value)
445            return value
446
447        # Cache the tuples for individual Paint instead of the whole sequence
448        # because the seq could be a transient slice
449        result = self.tuples.get(id(paint), None)
450        if result is None:
451            result = _tuple_safe(paint)
452            self.tuples[id(paint)] = result
453            self.keepAlive.append(paint)
454        return result
455
456    def _as_tuple(self, paints: Sequence[ot.Paint]) -> Tuple[Any, ...]:
457        return tuple(self._paint_tuple(p) for p in paints)
458
459    # COLR layers is unusual in that it modifies shared state
460    # so we need a callback into an object
461    def _beforeBuildPaintColrLayers(self, dest, source):
462        paint = ot.Paint()
463        paint.Format = int(ot.PaintFormat.PaintColrLayers)
464        self.slices.append(paint)
465
466        # Sketchy gymnastics: a sequence input will have dropped it's layers
467        # into NumLayers; get it back
468        if isinstance(source.get("NumLayers", None), collections.abc.Sequence):
469            layers = source["NumLayers"]
470        else:
471            layers = source["Layers"]
472
473        # Convert maps seqs or whatever into typed objects
474        layers = [self.buildPaint(l) for l in layers]
475
476        # No reason to have a colr layers with just one entry
477        if len(layers) == 1:
478            return layers[0], {}
479
480        # Look for reuse, with preference to longer sequences
481        # This may make the layer list smaller
482        found_reuse = True
483        while found_reuse:
484            found_reuse = False
485
486            ranges = sorted(
487                _reuse_ranges(len(layers)),
488                key=lambda t: (t[1] - t[0], t[1], t[0]),
489                reverse=True,
490            )
491            for lbound, ubound in ranges:
492                reuse_lbound = self.reusePool.get(
493                    self._as_tuple(layers[lbound:ubound]), -1
494                )
495                if reuse_lbound == -1:
496                    continue
497                new_slice = ot.Paint()
498                new_slice.Format = int(ot.PaintFormat.PaintColrLayers)
499                new_slice.NumLayers = ubound - lbound
500                new_slice.FirstLayerIndex = reuse_lbound
501                layers = layers[:lbound] + [new_slice] + layers[ubound:]
502                found_reuse = True
503                break
504
505        # The layer list is now final; if it's too big we need to tree it
506        is_tree = len(layers) > MAX_PAINT_COLR_LAYER_COUNT
507        layers = _build_n_ary_tree(layers, n=MAX_PAINT_COLR_LAYER_COUNT)
508
509        # We now have a tree of sequences with Paint leaves.
510        # Convert the sequences into PaintColrLayers.
511        def listToColrLayers(layer):
512            if isinstance(layer, collections.abc.Sequence):
513                return self.buildPaint(
514                    {
515                        "Format": ot.PaintFormat.PaintColrLayers,
516                        "Layers": [listToColrLayers(l) for l in layer],
517                    }
518                )
519            return layer
520
521        layers = [listToColrLayers(l) for l in layers]
522
523        paint.NumLayers = len(layers)
524        paint.FirstLayerIndex = len(self.layers)
525        self.layers.extend(layers)
526
527        # Register our parts for reuse provided we aren't a tree
528        # If we are a tree the leaves registered for reuse and that will suffice
529        if not is_tree:
530            for lbound, ubound in _reuse_ranges(len(layers)):
531                self.reusePool[self._as_tuple(layers[lbound:ubound])] = (
532                    lbound + paint.FirstLayerIndex
533                )
534
535        # we've fully built dest; empty source prevents generalized build from kicking in
536        return paint, {}
537
538    def buildPaint(self, paint: _PaintInput) -> ot.Paint:
539        return self.tableBuilder.build(ot.Paint, paint)
540
541    def build(self) -> ot.LayerV1List:
542        layers = ot.LayerV1List()
543        layers.LayerCount = len(self.layers)
544        layers.Paint = self.layers
545        return layers
546
547
548def buildBaseGlyphV1Record(
549    baseGlyph: str, layerBuilder: LayerV1ListBuilder, paint: _PaintInput
550) -> ot.BaseGlyphV1List:
551    self = ot.BaseGlyphV1Record()
552    self.BaseGlyph = baseGlyph
553    self.Paint = layerBuilder.buildPaint(paint)
554    return self
555
556
557def _format_glyph_errors(errors: Mapping[str, Exception]) -> str:
558    lines = []
559    for baseGlyph, error in sorted(errors.items()):
560        lines.append(f"    {baseGlyph} => {type(error).__name__}: {error}")
561    return "\n".join(lines)
562
563
564def buildColrV1(
565    colorGlyphs: _ColorGlyphsDict,
566    glyphMap: Optional[Mapping[str, int]] = None,
567) -> Tuple[ot.LayerV1List, ot.BaseGlyphV1List]:
568    if glyphMap is not None:
569        colorGlyphItems = sorted(
570            colorGlyphs.items(), key=lambda item: glyphMap[item[0]]
571        )
572    else:
573        colorGlyphItems = colorGlyphs.items()
574
575    errors = {}
576    baseGlyphs = []
577    layerBuilder = LayerV1ListBuilder()
578    for baseGlyph, paint in colorGlyphItems:
579        try:
580            baseGlyphs.append(buildBaseGlyphV1Record(baseGlyph, layerBuilder, paint))
581
582        except (ColorLibError, OverflowError, ValueError, TypeError) as e:
583            errors[baseGlyph] = e
584
585    if errors:
586        failed_glyphs = _format_glyph_errors(errors)
587        exc = ColorLibError(f"Failed to build BaseGlyphV1List:\n{failed_glyphs}")
588        exc.errors = errors
589        raise exc from next(iter(errors.values()))
590
591    layers = layerBuilder.build()
592    glyphs = ot.BaseGlyphV1List()
593    glyphs.BaseGlyphCount = len(baseGlyphs)
594    glyphs.BaseGlyphV1Record = baseGlyphs
595    return (layers, glyphs)
596
597
598def _build_n_ary_tree(leaves, n):
599    """Build N-ary tree from sequence of leaf nodes.
600
601    Return a list of lists where each non-leaf node is a list containing
602    max n nodes.
603    """
604    if not leaves:
605        return []
606
607    assert n > 1
608
609    depth = ceil(log(len(leaves), n))
610
611    if depth <= 1:
612        return list(leaves)
613
614    # Fully populate complete subtrees of root until we have enough leaves left
615    root = []
616    unassigned = None
617    full_step = n ** (depth - 1)
618    for i in range(0, len(leaves), full_step):
619        subtree = leaves[i : i + full_step]
620        if len(subtree) < full_step:
621            unassigned = subtree
622            break
623        while len(subtree) > n:
624            subtree = [subtree[k : k + n] for k in range(0, len(subtree), n)]
625        root.append(subtree)
626
627    if unassigned:
628        # Recurse to fill the last subtree, which is the only partially populated one
629        subtree = _build_n_ary_tree(unassigned, n)
630        if len(subtree) <= n - len(root):
631            # replace last subtree with its children if they can still fit
632            root.extend(subtree)
633        else:
634            root.append(subtree)
635        assert len(root) <= n
636
637    return root
638