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