Commit 42feab91 authored by Christopher Lam's avatar Christopher Lam Committed by Commit Bot

[style_var_gen] Add a CSS Variable Generator.

This CL adds a python script that generates a file with CSS Variables
that are specified in a JSON5 file. This will allow unified semantic
color (and other properties) specification in Chrome OS.

Bug: 1018654
Change-Id: I3a427cbb04f59659143739f717689810da661fa9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2041110Reviewed-by: default avatarBruce Dawson <brucedawson@chromium.org>
Reviewed-by: default avatarcalamity <calamity@chromium.org>
Reviewed-by: default avatarGiovanni Ortuño Urquidi <ortuno@chromium.org>
Commit-Queue: calamity <calamity@chromium.org>
Cr-Commit-Position: refs/heads/master@{#745282}
parent b30ee2af
calamity@chromium.org
ortuno@chromium.org
# 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.
"""Presubmit script for changes affecting tools/style_variable_generator/
See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts
for more details about the presubmit API built into depot_tools.
"""
WHITELIST = [r'.+_test.py$']
def CheckChangeOnUpload(input_api, output_api):
return input_api.canned_checks.RunUnitTestsInDirectory(
input_api, output_api, '.', whitelist=WHITELIST)
def CheckChangeOnCommit(input_api, output_api):
return input_api.canned_checks.RunUnitTestsInDirectory(
input_api, output_api, '.', whitelist=WHITELIST)
# style_variable_generator
This is a python tool that generates cross-platform style variables in order to
centralize UI constants.
This script uses third_party/pyjson5 to read input json5 files and then
generates various output formats as needed by clients (e.g CSS Variables,
preview HTML page).
For input format examples, see the \*_test.json5 files which contain up to date
illustrations of each feature, as well as expected outputs in the corresponding
\*_test_expected.\* files.
Run `python style_variable_generator.py -h` for usage details.
# 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 color import Color
_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'
ALL = [LIGHT, DARK]
class ModeVariables:
'''A representation of variables to generate for a single Mode.
'''
def __init__(self):
self.colors = collections.OrderedDict()
def AddColor(self, name, value_str):
if name in self.colors:
raise ValueError('%s defined more than once' % name)
try:
self.colors[name] = Color(value_str)
except Exception as err:
raise ValueError('Error parsing "%s": %s' % (value_str, err))
class BaseGenerator:
'''A generic style variable generator.
Subclasses should provide format-specific generation templates, filters and
globals to render their output.
'''
def __init__(self):
self._mode_variables = dict()
# 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.
self._default_mode = Modes.LIGHT
for mode in Modes.ALL:
self._mode_variables[mode] = ModeVariables()
def AddColor(self, name, value_obj):
if isinstance(value_obj, unicode):
self._mode_variables[self._default_mode].AddColor(name, value_obj)
elif isinstance(value_obj, dict):
for mode in Modes.ALL:
if mode in value_obj:
self._mode_variables[mode].AddColor(name, value_obj[mode])
def AddJSONFileToModel(self, path):
with open(path, 'r') as f:
# TODO(calamity): Add allow_duplicate_keys=False once pyjson5 is
# rolled.
data = json5.loads(
f.read(), object_pairs_hook=collections.OrderedDict)
try:
for name, value in data['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)
except Exception as err:
raise ValueError('\n%s:\n %s' % (path, err))
def ApplyTemplate(self, style_generator, path_to_template, params):
current_dir = os.path.dirname(os.path.realpath(__file__))
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(current_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_to_template)
return template.render(params)
def Validate(self):
def CheckIsInDefaultModel(name):
if name not in self._mode_variables[self._default_mode].colors:
raise ValueError(
"%s not defined in '%s' mode" % (name, self._default_mode))
# Check all colors in all models refer to colors that exist in the
# default mode.
for m in self._mode_variables.values():
for name, value in m.colors.items():
if value.var:
CheckIsInDefaultModel(value.var)
if value.rgb_var:
CheckIsInDefaultModel(value.rgb_var[:-4])
# TODO(calamity): Check for circular references.
# TODO(crbug.com/1053372): Prune unused rgb values.
# 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 re
import textwrap
class Color:
'''A representation of a single color value.
This color can be of the following formats:
- #RRGGBB
- rgb(r, g, b)
- rgba(r, g, b, a)
- $other_color
- rgb($other_color_rgb)
- rgba($other_color_rgb, a)
NB: The color components that refer to other colors' RGB values must end
with '_rgb'.
'''
def __init__(self, value_str):
# TODO(calamity): Add opacity-only values
self.var = None
self.rgb_var = None
self.r = 0
self.g = 0
self.b = 0
self.a = 1
self.Parse(value_str)
def _AssignRGB(self, rgb):
for v in rgb:
if not (0 <= v <= 255):
raise ValueError('RGB value out of bounds')
(self.r, self.g, self.b) = rgb
def _ParseRGBRef(self, rgb_ref):
match = re.match('^\$([a-z0-9_]+_rgb)$', rgb_ref)
if not match:
raise ValueError('Expected a reference to an RGB variable')
self.rgb_var = match.group(1)
def _ParseAlpha(self, alpha_value):
self.a = float(alpha_value)
if not (0 <= self.a <= 1):
raise ValueError('Alpha expected to be between 0 and 1')
def Parse(self, value):
def ParseHex(value):
match = re.match('^#([0-9a-f]*)$', value)
if not match:
return False
value = match.group(1)
if len(value) != 6:
raise ValueError('Expected #RRGGBB')
self._AssignRGB([int(x, 16) for x in textwrap.wrap(value, 2)])
return True
def ParseRGB(value):
match = re.match('^rgb\((.*)\)$', value)
if not match:
return False
values = match.group(1).split(',')
if len(values) == 1:
self._ParseRGBRef(values[0])
return True
if len(values) == 3:
self._AssignRGB([int(x) for x in values])
return True
raise ValueError(
'rgb() expected to have either 1 reference or 3 ints')
def ParseRGBA(value):
match = re.match('^rgba\((.*)\)$', value)
if not match:
return False
values = match.group(1).split(',')
if len(values) == 2:
self._ParseRGBRef(values[0])
self._ParseAlpha(values[1])
return True
if len(values) == 4:
self._AssignRGB([int(x) for x in values[0:3]])
self._ParseAlpha(values[3])
return True
raise ValueError('rgba() expected to have either'
'1 reference + alpha, or 3 ints + alpha')
def ParseVariableReference(value):
match = re.match('^\$(.*)$', value)
if not match:
return False
if value.endswith('_rgb'):
raise ValueError(
'color reference cannot resolve to an rgb reference')
self.var = match.group(1)
return True
parsers = [
ParseHex,
ParseRGB,
ParseRGBA,
ParseVariableReference,
]
parsed = False
for p in parsers:
parsed = p(value)
if parsed:
break
if not parsed:
raise ValueError('Malformed color value')
def __repr__(self):
if self.var:
return 'var(--%s)' % self.var
if self.rgb_var:
return 'rgba(var(--%s), %g)' % (self.rgb_var, self.a)
return 'rgba(%d, %d, %d, %g)' % (self.r, self.g, self.b, self.a)
# 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.
from color import Color
import unittest
class ColorTest(unittest.TestCase):
def testHexColors(self):
c = Color('#0102ff')
self.assertEqual(c.r, 1)
self.assertEqual(c.g, 2)
self.assertEqual(c.b, 255)
self.assertEqual(c.a, 1)
def testRGBColors(self):
c = Color('rgb(100, 200, 123)')
self.assertEqual(c.r, 100)
self.assertEqual(c.g, 200)
self.assertEqual(c.b, 123)
self.assertEqual(c.a, 1)
c = Color('rgb($some_color_rgb)')
self.assertEqual(c.rgb_var, 'some_color_rgb')
self.assertEqual(c.a, 1)
def testRGBAColors(self):
c = Color('rgba(100, 200, 123, 0.5)')
self.assertEqual(c.r, 100)
self.assertEqual(c.g, 200)
self.assertEqual(c.b, 123)
self.assertEqual(c.a, 0.5)
c = Color('rgba($some_color_400_rgb, 0.1)')
self.assertEqual(c.rgb_var, 'some_color_400_rgb')
self.assertEqual(c.a, 0.1)
def testReferenceColor(self):
c = Color('$some_color')
self.assertEqual(c.var, 'some_color')
def testMalformedColors(self):
with self.assertRaises(ValueError):
# #RRGGBBAA not supported.
Color('#11223311')
with self.assertRaises(ValueError):
# #RGB not supported.
Color('#fff')
with self.assertRaises(ValueError):
Color('rgb($non_rgb_var)')
with self.assertRaises(ValueError):
Color('rgba($non_rgb_var, 0.4)')
with self.assertRaises(ValueError):
# Invalid alpha.
Color('rgba(1, 2, 4, 2.5)')
with self.assertRaises(ValueError):
# Invalid alpha.
Color('rgba($non_rgb_var, -1)')
with self.assertRaises(ValueError):
# Invalid rgb values.
Color('rgb(-1, 5, 5)')
with self.assertRaises(ValueError):
# Invalid rgb values.
Color('rgb(0, 256, 5)')
with self.assertRaises(ValueError):
# Color reference points to rgb reference.
Color('$some_color_rgb')
if __name__ == '__main__':
unittest.main()
{
"colors": {
"google_grey_900": "#202124",
"cros_default_text_color": {
"light": "$google_grey_900",
"dark": "rgb(255, 255, 255)",
},
"cros_toggle_color": {
"light": "rgba($cros_default_text_color_rgb, 0.1)"
}
},
}
html {
--google-grey-900-rgb: 32, 33, 36;
--google-grey-900: rgb(var(--google-grey-900-rgb));
--cros-default-text-color-rgb: var(--google-grey-900-rgb);
--cros-default-text-color: rgb(var(--cros-default-text-color-rgb));
--cros-toggle-color-rgb: var(--cros-default-text-color-rgb);
--cros-toggle-color: rgba(var(--cros-toggle-color-rgb), 0.1);
}
@media (prefers-color-scheme: dark) {
html {
--cros-default-text-color-rgb: 255, 255, 255;
--cros-default-text-color: rgb(var(--cros-default-text-color-rgb));
}
}
# 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.
from base_generator import Color, Modes, BaseGenerator
class CSSStyleGenerator(BaseGenerator):
'''Generator for CSS Variables'''
def Render(self):
self.Validate()
return self.ApplyTemplate(self, 'css_generator.tmpl',
self.GetParameters())
def GetParameters(self):
return {
'light_variables': self._mode_variables[Modes.LIGHT],
'dark_variables': self._mode_variables[Modes.DARK],
}
def GetFilters(self):
return {
'to_var_name': self._ToVarName,
'css_color': self._CssColor,
'css_color_rgb': self._CssColorRGB,
}
def GetGlobals(self):
return {
'css_color_from_rgb_var': self._CssColorFromRGBVar,
}
def _ToVarName(self, var_name):
return '--%s' % var_name.replace('_', '-')
def _CssColor(self, c):
'''Returns the CSS color representation of |c|'''
assert (isinstance(c, Color))
if c.var:
return 'var(%s)' % self._ToVarName(c.var)
if c.rgb_var:
if c.a != 1:
return 'rgba(var(%s), %g)' % (self._ToVarName(c.rgb_var), c.a)
else:
return 'rgb(var(%s))' % self._ToVarName(c.rgb_var)
if c.a != 1:
return 'rgba(%d, %d, %d, %g)' % (c.r, c.g, c.b, c.a)
else:
return 'rgb(%d, %d, %d)' % (c.r, c.g, c.b)
def _CssColorRGB(self, c):
'''Returns the CSS rgb representation of |c|'''
if c.var:
return 'var(%s-rgb)' % self._ToVarName(c.var)
if c.rgb_var:
return 'var(%s)' % self._ToVarName(c.rgb_var)
return '%d, %d, %d' % (c.r, c.g, c.b)
def _CssColorFromRGBVar(self, name, alpha):
'''Returns the CSS color representation given a color name and alpha'''
if alpha != 1:
return 'rgba(var(%s-rgb), %g)' % (self._ToVarName(name), alpha)
else:
return 'rgb(var(%s-rgb))' % self._ToVarName(name)
html {
{%- for var_name, color in light_variables.colors.items() %}
{{var_name | to_var_name}}-rgb: {{color | css_color_rgb}};
{{var_name | to_var_name}}: {{css_color_from_rgb_var(var_name, color.a)}};
{% endfor %}
}
{% if dark_variables.colors -%}
@media (prefers-color-scheme: dark) {
html {
{%- for var_name, color in dark_variables.colors.items() %}
{{var_name | to_var_name}}-rgb: {{color | css_color_rgb}};
{{var_name | to_var_name}}: {{css_color_from_rgb_var(var_name, color.a)}};
{% endfor %}
}
}
{%- endif %}
# 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.
from css_generator import CSSStyleGenerator
import unittest
class CSSStyleGeneratorTest(unittest.TestCase):
def setUp(self):
self.generator = CSSStyleGenerator()
def assertEqualToFile(self, value, filename):
with open(filename) as f:
self.assertEqual(value, f.read())
def testColorTestJSON(self):
self.generator.AddJSONFileToModel('colors_test.json5')
self.assertEqualToFile(self.generator.Render(),
'colors_test_expected.css')
if __name__ == '__main__':
unittest.main()
# 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 argparse
import sys
from css_generator import CSSStyleGenerator
def main():
parser = argparse.ArgumentParser(
description='Generate style variables from JSON5 color file.')
parser.add_argument(
'--generator',
choices=['CSS'],
required=True,
help='type of file to generate')
parser.add_argument('--out-file', help='file to write output to')
parser.add_argument('targets', nargs='+', help='source json5 color files')
args = parser.parse_args()
if args.generator == 'CSS':
style_generator = CSSStyleGenerator()
for t in args.targets:
style_generator.AddJSONFileToModel(t)
if args.out_file:
with open(args.out_file, 'w') as f:
f.write(style_generator.Render())
else:
print(style_generator.Render())
return 0
if __name__ == '__main__':
sys.exit(main())
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment