blob: 0efa4e2aec8ced4164c499788d0e7d88b27fd065 [file] [log] [blame]
# Copyright 2019 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 os
import sys
import collections
import re
import textwrap
from style_variable_generator import path_overrides
from style_variable_generator.color import Color
from style_variable_generator.opacity import Opacity
import copy
_FILE_PATH = os.path.dirname(os.path.realpath(__file__))
_JSON5_PATH = os.path.join(_FILE_PATH, os.pardir, os.pardir, 'third_party',
'pyjson5', 'src')
sys.path.insert(1, _JSON5_PATH)
import json5
_JINJA2_PATH = os.path.join(_FILE_PATH, os.pardir, os.pardir, 'third_party')
sys.path.insert(1, _JINJA2_PATH)
import jinja2
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]
RESERVED_SUFFIXES = ['_' + s for s in Modes.ALL + ['rgb', 'inverted']]
class VariableType:
COLOR = 'color'
OPACITY = 'opacity'
TYPOGRAPHY = 'typography'
UNTYPED_CSS = 'untyped_css'
ALL = [
COLOR,
OPACITY,
TYPOGRAPHY,
UNTYPED_CSS,
]
class ModeKeyedModel(object):
def __init__(self, generator):
self.variables = collections.OrderedDict()
self.generator = generator
def Add(self, name, value_obj, context):
self.generator.SetVariableContext(name, context)
if name not in self.variables:
self.variables[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 and mode not in self.variables[name]
self.variables[name][mode] = value
else:
self.variables[name][Modes.DEFAULT] = self._CreateValue(value_obj)
# 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.variables[name]:
return self.variables[name][mode]
return self.variables[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
def keys(self):
return self.variables.keys()
def items(self):
return self.variables.items()
def __getitem__(self, key):
return self.variables[key]
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, generator):
super(OpacityModel, self).__init__(generator)
# 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, generator, opacity_model):
super(ColorModel, self).__init__(generator)
self.opacity_model = opacity_model
def Add(self, name, value_obj, context):
# 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
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')
ModeKeyedModel.Add(
self, name + '_inverted', {
Modes.LIGHT: '$' + name + '_dark',
Modes.DARK: '$' + name + '_light'
}, generated_context)
ModeKeyedModel.Add(self, name, value_obj, context)
# 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 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
# 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 TypographyModel(object):
def __init__(self):
self.font_families = collections.OrderedDict()
self.typefaces = collections.OrderedDict()
def AddFontFamily(self, name, value):
assert name.startswith('font_family_')
self.font_families[name] = value
def AddTypeface(self, name, value_obj):
assert value_obj['font_family']
assert value_obj['font_size']
assert value_obj['font_weight']
assert value_obj['line_height']
self.typefaces[name] = value_obj
class BaseGenerator:
'''A generic style variable generator.
Subclasses should provide format-specific generation templates, filters and
globals to render their output.
'''
@staticmethod
def GetName():
return None
def __init__(self):
self.out_file_path = None
# A map of input filepaths to their context object.
self.in_file_to_context = dict()
# If specified, only generates the given mode.
self.generate_single_mode = None
opacity_model = OpacityModel(self)
color_model = ColorModel(self, opacity_model)
# A dictionary of |VariableType| to models containing mappings of
# variable names to values.
self.model = {
VariableType.COLOR:
color_model,
VariableType.OPACITY:
opacity_model,
VariableType.TYPOGRAPHY:
TypographyModel(),
# A dict of client-defined groups to corresponding dicts of variable
# names to values. This is used to store CSS that doesn't have a
# dedicated model type. This is used for more freeform variables, or
# for variable types that haven't been implemented yet.
# See https://crbug.com/1018654.
VariableType.UNTYPED_CSS:
dict(),
}
# A dictionary of variable names to objects containing information about
# how the generator should run for that variable. All variables must
# populate this dictionary and as such, its keys can be used as a list
# of all variable names,
self.context_map = dict()
# A dictionary of options used to alter generator function. See
# ./README.md for each generators list of options.
self.generator_options = {}
def SetVariableContext(self, name, context):
if name in self.context_map.keys():
raise ValueError('Variable name "%s" is reused' % name)
self.context_map[name] = context or {}
def GetContextKey(self):
return self.GetName()
def AddColor(self, name, value_obj, context=None):
try:
self.model[VariableType.COLOR].Add(name, value_obj, context)
except ValueError as err:
raise ValueError('Error parsing color "%s": %s' % (value_obj, err))
# Add all the colors in the data to the model.
def _AddColors(self, data, generator_context):
for name, value in data.get('colors', {}).items():
if not re.match('^[a-z0-9_]+$', name):
raise ValueError(
'%s is not a valid variable name (lower case, 0-9, _)' %
name)
self.AddColor(name, value, generator_context)
def _ResolveBlendedColors(self):
# Calculate the final RGBA for all blended colors because the
# generator's subclasses can't blend yet.
color_model = self.model[VariableType.COLOR]
temp_model = {}
for name, value in color_model.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] = color_model.ResolveToRGBA(
name, mode)
for name, value in temp_model.items():
for mode, color in value.items():
color_model[name][mode] = temp_model[name][mode]
def AddOpacity(self, name, value_obj, context=None):
try:
self.model[VariableType.OPACITY].Add(name, value_obj, context)
except ValueError as err:
raise ValueError('Error parsing opacity "%s": %s' %
(value_obj, err))
def AddUntypedCSSGroup(self, group_name, value_obj, context=None):
for var_name in value_obj.keys():
self.SetVariableContext(var_name, context)
self.model[VariableType.UNTYPED_CSS][group_name] = value_obj
def AddJSONFilesToModel(self, paths):
'''Adds one or more JSON files to the model.
'''
for path in paths:
try:
with open(path, 'r') as f:
self.AddJSONToModel(f.read(), path)
except ValueError as err:
raise ValueError('\n%s:\n %s' % (path, err))
# Resolve blended colors after all the files are added because some
# color dependencies are between different files.
self._ResolveBlendedColors()
def AddJSONToModel(self, json_string, in_file=None):
'''Adds a |json_string| with variable definitions to the model.
See *test.json5 files for a defacto format reference.
|in_file| is used to populate a file-to-context map.
'''
# TODO(calamity): Add allow_duplicate_keys=False once pyjson5 is
# rolled.
data = json5.loads(json_string,
object_pairs_hook=collections.OrderedDict)
# Use the generator's name to get the generator-specific context from
# the input.
generator_context = data.get('options', {})
self.in_file_to_context[in_file] = generator_context
self._AddColors(data, generator_context)
for name, value in data.get('opacities', {}).items():
if not re.match('^[a-z0-9_]+_opacity$', name):
raise ValueError(
name + ' is not a valid opacity name ' +
'(lower case, 0-9, _, must end with _opacity)')
self.AddOpacity(name, value, generator_context)
typography = data.get('typography')
if typography:
typography_model = self.model[VariableType.TYPOGRAPHY]
for name, value in typography['font_families'].items():
self.SetVariableContext(name, generator_context)
typography_model.AddFontFamily(name, value)
for name, value_obj in typography['typefaces'].items():
self.SetVariableContext(name, generator_context)
typography_model.AddTypeface(name, value_obj)
for name, value in data.get('untyped_css', {}).items():
self.AddUntypedCSSGroup(name, value, generator_context)
return generator_context
def ApplyTemplate(self, style_generator, path_to_template, params):
loader_root_dir = path_overrides.GetFileSystemLoaderRootDirectory()
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(loader_root_dir),
keep_trailing_newline=True)
jinja_env.globals.update(style_generator.GetGlobals())
jinja_env.filters.update(style_generator.GetFilters())
template = jinja_env.get_template(
path_overrides.GetPathToTemplate(path_to_template))
return template.render(params)
def Validate(self):
colors = self.model[VariableType.COLOR]
color_names = set(colors.keys())
opacities = self.model[VariableType.OPACITY]
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))
# 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.context_map[name].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():
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
CheckColorReference(color.blended_colors[0], name)
CheckColorReference(color.blended_colors[1], 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.