123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- # Copyright 2022 The Chromium Authors. All rights reserved.
- # Use of this source code is governed by a BSD-style license that can be
- # found in the LICENSE file.
- import re
- import collections
- from style_variable_generator.color import Color
- from style_variable_generator.opacity import Opacity
- from abc import ABC, abstractmethod
- class Modes:
- LIGHT = 'light'
- DARK = 'dark'
- DEBUG = 'debug'
- # The mode that colors will fallback to when not specified in a
- # non-default mode. An error will be raised if a color in any mode is
- # not specified in the default mode.
- DEFAULT = LIGHT
- ALL = [LIGHT, DARK, DEBUG]
- class VariableType:
- COLOR = 'color'
- OPACITY = 'opacity'
- UNTYPED_CSS = 'untyped_css'
- TYPEFACE = 'typeface'
- FONT_FAMILY = 'font_family'
- class StyleVariable(object):
- '''An intermediate representation of a single variable that the generator
- knows about.
- Some JSON entries will generate multiple StyleVariables (e.g when
- generate_per_mode is true), and different Generators may create multiple
- per-platform variables (e.g CSS generates an var and var-rgb).
- '''
- def __init__(self, variable_type, name, json_value, context):
- if not re.match(r'^[a-z0-9_\.\-]+$', name):
- raise ValueError(name + ' is not a valid variable name ' +
- '(lower case, 0-9, _)')
- self.variable_type = variable_type
- self.name = name
- self.json_value = json_value
- self.context = context or {}
- class Submodel(ABC):
- '''Abstract Base Class for all Submodels.'''
- @abstractmethod
- def Add(self, name, value_obj, context):
- '''Adds the a variable represented by |value_obj| to the submodel.
- Returns a list of |StyleVariable| objects representing the variables
- added.
- '''
- assert False
- # Submodels are expected to provide dict-like interfaces.
- @abstractmethod
- def keys(self):
- assert False
- @abstractmethod
- def items(self):
- assert False
- @abstractmethod
- def __getitem__(self, key):
- assert False
- class ModeKeyedModel(collections.OrderedDict, Submodel):
- def Add(self, name, value_obj, context):
- if name not in self:
- self[name] = {}
- if isinstance(value_obj, dict):
- for mode in value_obj:
- value = self._CreateValue(value_obj[mode])
- if mode == 'default':
- mode = Modes.DEFAULT
- assert mode in Modes.ALL, f"Invalid mode '{mode}' used in' \
- 'definition for '{name}'"
- assert mode not in self[
- name], f"{mode} mode for '{name}' defined multiple times"
- self[name][mode] = value
- else:
- self[name][Modes.DEFAULT] = self._CreateValue(value_obj)
- return [StyleVariable(self.variable_type, name, value_obj, context)]
- # Returns the value that |name| will have in |mode|. Resolves to the default
- # mode's value if the a value for |mode| isn't specified. Always returns a
- # value.
- def Resolve(self, name, mode):
- if mode in self[name]:
- return self[name][mode]
- return self[name][Modes.DEFAULT]
- def Flatten(self, resolve_missing=False):
- '''Builds a name to variable dictionary for each mode.
- If |resolve_missing| is true, colors that aren't specified in |mode|
- will be resolved to their default mode value.'''
- flattened = {}
- for mode in Modes.ALL:
- variables = collections.OrderedDict()
- for name, mode_values in self.items():
- if resolve_missing:
- variables[name] = self.Resolve(name, mode)
- else:
- if mode in mode_values:
- variables[name] = mode_values[mode]
- flattened[mode] = variables
- return flattened
- class OpacityModel(ModeKeyedModel):
- '''A dictionary of opacity names to their values in each mode.
- e.g OpacityModel['disabled_opacity'][Modes.LIGHT] = Opacity(...)
- '''
- def __init__(self):
- self.variable_type = VariableType.OPACITY
- # Returns a float from 0-1 representing the concrete value of |opacity|.
- def ResolveOpacity(self, opacity, mode):
- if opacity.a != -1:
- return opacity
- return self.ResolveOpacity(self.Resolve(opacity.var, mode), mode)
- def _CreateValue(self, value):
- return Opacity(value)
- class ColorModel(ModeKeyedModel):
- '''A dictionary of color names to their values in each mode.
- e.g ColorModel['blue'][Modes.LIGHT] = Color(...)
- '''
- def __init__(self, opacity_model):
- self.opacity_model = opacity_model
- self.variable_type = VariableType.COLOR
- def Add(self, name, value_obj, context):
- added = []
- # If a color has generate_per_mode set, a separate variable will be
- # created for each mode, suffixed by mode name.
- # (e.g my_color_light, my_color_debug)
- generate_per_mode = False
- # If a color has generate_inverted set, a |color_name|_inverted will be
- # generated which uses the dark color for light mode and vice versa.
- generate_inverted = False
- if isinstance(value_obj, dict):
- generate_per_mode = value_obj.pop('generate_per_mode', None)
- generate_inverted = value_obj.pop('generate_inverted', None)
- elif self._CreateValue(value_obj).blended_colors:
- # A blended color could evaluate to different colors in different
- # modes, so add it to all the modes.
- value_obj = {mode: value_obj for mode in Modes.ALL}
- generated_context = dict(context)
- generated_context['generated'] = True
- if generate_per_mode or generate_inverted:
- for mode, value in value_obj.items():
- per_mode_name = name + '_' + mode
- added += ModeKeyedModel.Add(self, per_mode_name, value,
- generated_context)
- value_obj[mode] = '$' + per_mode_name
- if generate_inverted:
- if Modes.LIGHT not in value_obj or Modes.DARK not in value_obj:
- raise ValueError(
- 'generate_inverted requires both dark and light modes to be'
- ' set')
- added += ModeKeyedModel.Add(
- self, name + '_inverted', {
- Modes.LIGHT: '$' + name + '_dark',
- Modes.DARK: '$' + name + '_light'
- }, generated_context)
- added += ModeKeyedModel.Add(self, name, value_obj, context)
- return added
- # Returns a Color that is the final RGBA value for |name| in |mode|.
- def ResolveToRGBA(self, name, mode):
- return self._ResolveColorToRGBA(self.Resolve(name, mode), mode)
- # Returns a Color that is the hexadecimal string for |name| in |mode|.
- def ResolveToHexString(self, name, mode):
- color = self._ResolveColorToRGBA(self.Resolve(name, mode), mode)
- opacity = int(float(repr(color.opacity)) * 255)
- return '#{:02x}{:02x}{:02x}{:02x}'.format(color.r, color.g, color.b,
- opacity)
- # Returns a Color that is the final RGBA value for |color| in |mode|.
- def _ResolveColorToRGBA(self, color, mode):
- if color.var:
- return self.ResolveToRGBA(color.var, mode)
- if len(color.blended_colors) == 2:
- return self._BlendColors(color.blended_colors[0],
- color.blended_colors[1], mode)
- result = Color()
- assert color.opacity
- result.opacity = self.opacity_model.ResolveOpacity(color.opacity, mode)
- rgb = color
- if color.rgb_var:
- rgb = self.ResolveToRGBA(color.RGBVarToVar(), mode)
- (result.r, result.g, result.b) = (rgb.r, rgb.g, rgb.b)
- return result
- def _ResolveBlendedColors(self):
- # Calculate the final RGBA for all blended colors because the
- # generator's subclasses can't blend yet.
- temp_model = {}
- for name, value in self.items():
- for mode, color in value.items():
- if color.blended_colors:
- assert len(color.blended_colors) == 2
- if name not in temp_model:
- temp_model[name] = {}
- temp_model[name][mode] = self.ResolveToRGBA(name, mode)
- for name, value in temp_model.items():
- for mode, color in value.items():
- self[name][mode] = temp_model[name][mode]
- # Returns a Color that is the final RGBA value for |color_a| over |color_b|
- # in |mode|.
- def _BlendColors(self, color_a, color_b, mode):
- # TODO(b/206887565): Check for circular references.
- color_a_res = self._ResolveColorToRGBA(color_a, mode)
- (alpha_a, r_a, g_a, b_a) = (color_a_res.opacity.a, color_a_res.r,
- color_a_res.g, color_a_res.b)
- color_b_res = self._ResolveColorToRGBA(color_b, mode)
- (alpha_b, r_b, g_b, b_b) = (color_b_res.opacity.a, color_b_res.r,
- color_b_res.g, color_b_res.b)
- # Blend using the formula for "A over B" from
- # https://wikipedia.org/wiki/Alpha_compositing.
- alpha_out = alpha_a + (alpha_b * (1 - alpha_a))
- r_out = round(
- (r_a * alpha_a + r_b * alpha_b * (1 - alpha_a)) / alpha_out)
- g_out = round(
- (g_a * alpha_a + g_b * alpha_b * (1 - alpha_a)) / alpha_out)
- b_out = round(
- (b_a * alpha_a + b_b * alpha_b * (1 - alpha_a)) / alpha_out)
- result = Color()
- (result.r, result.g, result.b) = (r_out, g_out, b_out)
- result.opacity = Opacity(alpha_out)
- return result
- def _CreateValue(self, value):
- return Color(value)
- class SimpleModel(collections.OrderedDict, Submodel):
- def __init__(self, variable_type, check_func=None):
- self.variable_type = variable_type
- self.check_func = check_func
- def Add(self, name, value_obj, context):
- if self.check_func:
- self.check_func(name, value_obj, context)
- self[name] = value_obj
- return [StyleVariable(self.variable_type, name, value_obj, context)]
- class Model(object):
- def __init__(self):
- # A map of all variables to their |StyleVariable| object.
- self.variable_map = dict()
- # A map of |VariableType| to its underlying model.
- self.submodels = dict()
- self.opacities = OpacityModel()
- self.submodels[VariableType.OPACITY] = self.opacities
- self.colors = ColorModel(self.opacities)
- self.submodels[VariableType.COLOR] = self.colors
- self.untyped_css = SimpleModel(VariableType.UNTYPED_CSS)
- self.submodels[VariableType.UNTYPED_CSS] = self.untyped_css
- def CheckTypeFace(name, value_obj, context):
- assert value_obj['font_family']
- assert value_obj['font_size']
- assert value_obj['font_weight']
- assert value_obj['line_height']
- self.typefaces = SimpleModel(VariableType.TYPEFACE, CheckTypeFace)
- self.submodels[VariableType.TYPEFACE] = self.typefaces
- def CheckFontFamily(name, value_obj, context):
- assert name.startswith('font_family_')
- self.font_families = SimpleModel(VariableType.FONT_FAMILY,
- CheckFontFamily)
- self.submodels[VariableType.FONT_FAMILY] = self.font_families
- def Add(self, variable_type, name, value_obj, context):
- '''Adds a new variable to the submodel for |variable_type|.
- '''
- try:
- full_name = self.FullTokenName(name, context)
- added = self.submodels[variable_type].Add(full_name, value_obj,
- context)
- except ValueError as err:
- raise ValueError(
- f'Error parsing {variable_type} "{full_name}": {value_obj}'
- ) from err
- for var in added:
- if var.name in self.variable_map:
- raise ValueError('Variable name "%s" is reused' % name)
- self.variable_map[var.name] = var
- def FullTokenName(self, name, context):
- namespace = context['token_namespace']
- if namespace:
- return f'{namespace}.{name}'
- return name
- def PostProcess(self, resolve_blended_colors=True):
- '''Called after all variables have been added to perform operations that
- require a complete worldview.
- '''
- if resolve_blended_colors:
- # Resolve blended colors after all the files are added because some
- # color dependencies are between different files.
- self.colors._ResolveBlendedColors()
- self.Validate()
- def Validate(self):
- colors = self.colors
- color_names = set(colors.keys())
- opacities = self.opacities
- opacity_names = set(opacities.keys())
- def CheckColorReference(name, referrer):
- if name == referrer:
- raise ValueError("{0} refers to itself".format(name))
- if name not in color_names:
- raise ValueError("Cannot find color %s referenced by %s" %
- (name, referrer))
- def CheckOpacityReference(name, referrer):
- if name == referrer:
- raise ValueError("{0} refers to itself".format(name))
- if name not in opacity_names:
- raise ValueError("Cannot find opacity %s referenced by %s" %
- (name, referrer))
- def CheckColor(color, name):
- if color.var:
- CheckColorReference(color.var, name)
- if color.rgb_var:
- CheckColorReference(color.RGBVarToVar(), name)
- if color.opacity and color.opacity.var:
- CheckOpacityReference(color.opacity.var, name)
- if color.blended_colors:
- assert len(color.blended_colors) == 2
- CheckColor(color.blended_colors[0], name)
- CheckColor(color.blended_colors[1], name)
- RESERVED_SUFFIXES = ['_' + s for s in Modes.ALL + ['rgb', 'inverted']]
- # Check all colors in all modes refer to colors that exist in the
- # default mode.
- for name, mode_values in colors.items():
- for suffix in RESERVED_SUFFIXES:
- if not self.variable_map[name].context.get(
- 'generated') and name.endswith(suffix):
- raise ValueError(
- 'Variable name "%s" uses a reserved suffix: %s' %
- (name, suffix))
- if Modes.DEFAULT not in mode_values:
- raise ValueError("Color %s not defined for default mode" %
- name)
- for mode, color in mode_values.items():
- CheckColor(color, name)
- for name, mode_values in opacities.items():
- for mode, opacity in mode_values.items():
- if opacity.var:
- CheckOpacityReference(opacity.var, name)
- # TODO(b/206887565): Check for circular references.
|