| # Copyright 2022 The Chromium Authors |
| # 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 ColorRGB, ParseColor, ColorBlend, ColorRGBVar, ColorVar |
| from style_variable_generator.opacity import Opacity |
| from abc import ABC, abstractmethod |
| |
| |
| def full_token_name(name, context): |
| namespace = context['token_namespace'] |
| if namespace: |
| return f'{namespace}.{name}' |
| return name |
| |
| |
| 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' |
| FONT_FACE = 'font_face' |
| LEGACY_MAPPING = 'legacy_mappings' |
| |
| |
| 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 __init__(self): |
| # A map of all variables to their |StyleVariable| object. |
| self.variable_map = dict() |
| |
| 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) |
| |
| variable = StyleVariable(self.variable_type, name, value_obj, context) |
| self.variable_map[name] = variable |
| return [variable] |
| |
| # 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): |
| super().__init__() |
| self.variable_type = VariableType.OPACITY |
| |
| def Add(self, name, value_obj, context): |
| name = full_token_name(name, context) |
| return super().Add(name, value_obj, context) |
| |
| # 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): |
| super().__init__() |
| 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 isinstance(self._CreateValue(value_obj), ColorBlend): |
| # 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 isinstance(color, ColorVar): |
| return self.ResolveToRGBA(color.var, mode) |
| |
| if isinstance(color, ColorBlend) and len(color.blended_colors) == 2: |
| return self._BlendColors(color.blended_colors[0], |
| color.blended_colors[1], mode) |
| |
| result = ColorRGB() |
| assert color.opacity |
| result.opacity = self.opacity_model.ResolveOpacity(color.opacity, mode) |
| |
| rgb = color |
| if isinstance(color, ColorRGBVar): |
| rgb = self.ResolveToRGBA(color.ToVar(), mode) |
| |
| (result.r, result.g, result.b) = (rgb.r, rgb.g, rgb.b) |
| return result |
| |
| def _ProcessBlendedColors(self, default_preblend): |
| # 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(): |
| context = self.variable_map[name].context |
| should_preblend = context.get('CSS', |
| {}).get('preblend', |
| default_preblend) |
| if isinstance(color, ColorBlend) and should_preblend: |
| 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) |
| |
| return ColorRGB((r_out, g_out, b_out), Opacity(alpha_out)) |
| |
| def _CreateValue(self, value): |
| return ParseColor(value) or ColorRGB() |
| |
| |
| 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)] |
| |
| |
| # A simple model where all variables are prefixed with the current namespace. |
| class NamespacedModel(SimpleModel): |
| def Add(self, name, value_obj, context): |
| name = full_token_name(name, context) |
| return super().Add(name, value_obj, context) |
| |
| |
| # A color model where all variables are prefixed with the current namespace. |
| class NamespacedColorModel(ColorModel): |
| def Add(self, name, value_obj, context): |
| name = full_token_name(name, context) |
| return super().Add(name, value_obj, context) |
| |
| |
| # A color model specifically for storing mappings of arbitrary css vars to some |
| # known StyleVariable. |
| class LegacyMappingsModel(ColorModel): |
| def Add(self, name, value_obj, context): |
| if isinstance(value_obj, dict): |
| raise ValueError( |
| 'Legacy mappings can only be singular references.') |
| return super().Add(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 = NamespacedColorModel(self.opacities) |
| self.submodels[VariableType.COLOR] = self.colors |
| |
| self.untyped_css = NamespacedModel(VariableType.UNTYPED_CSS) |
| self.submodels[VariableType.UNTYPED_CSS] = self.untyped_css |
| |
| self.legacy_mappings = LegacyMappingsModel(self.opacities) |
| self.submodels[VariableType.LEGACY_MAPPING] = self.legacy_mappings |
| |
| 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 = NamespacedModel(VariableType.TYPEFACE, CheckTypeFace) |
| self.submodels[VariableType.TYPEFACE] = self.typefaces |
| |
| def CheckFontFamily(name, value_obj, context): |
| assert name.startswith('font_family_') |
| |
| self.font_families = NamespacedModel(VariableType.FONT_FAMILY, |
| CheckFontFamily) |
| self.submodels[VariableType.FONT_FAMILY] = self.font_families |
| |
| def CheckFontFace(name, value_obj, context): |
| assert name.startswith('face_') |
| |
| self.font_faces = NamespacedModel(VariableType.FONT_FACE, |
| CheckFontFace) |
| self.submodels[VariableType.FONT_FACE] = self.font_faces |
| |
| def Add(self, variable_type, name, value_obj, context): |
| '''Adds a new variable to the submodel for |variable_type|. |
| ''' |
| try: |
| added = self.submodels[variable_type].Add(name, value_obj, context) |
| except ValueError as err: |
| raise ValueError( |
| f'Error parsing {variable_type} "{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 PostProcess(self, default_preblend=True): |
| '''Called after all variables have been added to perform operations that |
| require a complete worldview. |
| ''' |
| |
| # Resolve blended colors after all the files are added because some |
| # color dependencies are between different files. |
| self.colors._ProcessBlendedColors(default_preblend) |
| |
| 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 isinstance(color, ColorVar): |
| CheckColorReference(color.var, name) |
| if isinstance(color, ColorRGBVar): |
| CheckColorReference(color.ToVar(), name) |
| if isinstance(color, |
| (ColorRGB, ColorRGBVar)) and color.opacity.var: |
| CheckOpacityReference(color.opacity.var, name) |
| if isinstance(color, ColorBlend): |
| 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. |