From d72ee1606d621110ae9e1e7ec5ce9cbf419989a5 Mon Sep 17 00:00:00 2001 From: Axel Schlindwein Date: Fri, 24 Jun 2022 17:27:33 +0200 Subject: [PATCH 01/20] add new module to support CSS Color Level 4 Implementation of level 4 specifications is currently limited to (1) space-seperated arguments with an optional slash-seperated opacity, (2) definition of 'rebeccapurple', (3) percentages and numbers are accepted as opacity value, (4) the hwb() function and (5) hsla()/rgba() being aliases to hsl()/rgb(). --- tinycss2/color4.py | 134 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tinycss2/color4.py diff --git a/tinycss2/color4.py b/tinycss2/color4.py new file mode 100644 index 0000000..dbe52a5 --- /dev/null +++ b/tinycss2/color4.py @@ -0,0 +1,134 @@ +from colorsys import hls_to_rgb + +from .color3 import ( + _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS, + _SPECIAL_COLOR_KEYWORDS, RGBA, _parse_hsl, _parse_rgb) +from .parser import parse_one_component_value + + +def parse_color(input): + """Parse a color value as defined in `CSS Color Level 4 + `. Implementation of Level 4 + is currently limited to space-seperated arguments with an optional + slash-seperated opacity, definition of 'rebeccapurple', + percentages and numbers are accepted as opacity values, + the hwb() function and hsla()/rgba() being aliases to hsl()/rgb(). + + :type input: :obj:`str` or :term:`iterable` + :param input: A string or an iterable of :term:`component values`. + :returns: + * :obj:`None` if the input is not a valid color value. + (No exception is raised.) + * The string ``'currentColor'`` for the ``currentColor`` keyword + * Or a :class:`RGBA` object for every other values + (including keywords, HSL and HSLA.) + The alpha channel is clipped to [0, 1] + but red, green, or blue can be out of range + (eg. ``rgb(-10%, 120%, 0%)`` is represented as + ``(-0.1, 1.2, 0, 1)``.) + + """ + if isinstance(input, str): + token = parse_one_component_value(input, skip_comments=True) + else: + token = input + if token.type == 'ident': + return _COLOR_KEYWORDS.get(token.lower_value) + elif token.type == 'hash': + for multiplier, regexp in _HASH_REGEXPS: + match = regexp(token.value) + if match: + channels = [ + int(group * multiplier, 16) / 255 + for group in match.groups()] + if len(channels) == 3: + channels.append(1.) + return RGBA(*channels) + elif token.type == 'function': + args = _parse_separated_args(token.arguments) + if args: + name = token.lower_name + if name == 'rgb' or name == 'rgba': + alpha = _parse_alpha(args[3:]) + if alpha is not None: + return _parse_rgb(args[:3], alpha) + else: + return _parse_rgb(args, alpha=1.) + elif name == 'hsl' or name == 'hsla': + alpha = _parse_alpha(args[3:]) + if alpha is not None: + return _parse_hsl(args[:3], alpha) + else: + return _parse_hsl(args, alpha=1.) + elif name == 'hwb': + alpha = _parse_alpha(args[3:]) + if alpha is not None: + return _parse_hwb(args[:3], alpha) + else: + return _parse_hwb(args, alpha=1.) + + +def _parse_separated_args(tokens): + """Parse a list of tokens (typically the content of a function token) + as arguments made of a single token each, either comma seperated or + space-seperated with an optional slash-seperated opacity. + + return the argument list without commas or white space; + or None if the function token content do not match the description above. + + """ + tokens = [token for token in tokens + if token.type not in ('whitespace', 'comment')] + if len(tokens) % 2 == 1 and all(token == ',' for token in tokens[1::2]): + return tokens[::2] + elif len(tokens) == 3 and all( + token.type in ('number', 'percentage') for token in tokens): + return tokens + elif len(tokens) == 5 and tokens[3] == '/': + args = [token for token in tokens if token != '/'] + return args + + +def _parse_alpha(args): + """Parse a list of one alpha value. + + If args is a list of a single INTEGER, NUMBER or PERCENTAGE token, + return its value clipped to the 0..1 range. Otherwise, return None. + + """ + if len(args) == 1 and args[0].type == 'number': + return min(1, max(0, args[0].value)) + if len(args) == 1 and args[0].type == 'percentage': + return min(1, max(0, args[0].value/100)) + + +def _parse_hwb(args, alpha): + """Parse a list of HWB channels. + + If args is a list of 1 INTEGER token and 2 PERCENTAGE tokens, return RGB + values as a tuple of 3 floats in 0..1. Otherwise, return None. + + """ + types = [arg.type for arg in args] + if types == ['number', 'percentage', 'percentage'] and args[0].is_integer: + white = args[1].value / 100 + black = args[2].value / 100 + if white + black >= 1: + gray = white / (white + black) + return RGBA(gray, gray, gray, alpha) + else: + rgb = hls_to_rgb(args[0].int_value / 360, 0.5, 1) + r, g, b = ((channel * (1 - white - black)) + white + for channel in rgb) + return RGBA(r, g, b, alpha) + + +# (r, g, b) in 0..255 +_EXTENDED_COLOR_KEYWORDS.insert(119, ('rebeccapurple', (102, 51, 153))) + +# RGBA named tuples of (r, g, b, a) in 0..1 or a string marker +_COLOR_KEYWORDS = _SPECIAL_COLOR_KEYWORDS.copy() +_COLOR_KEYWORDS.update( + # 255 maps to 1, 0 to 0, the rest is linear. + (keyword, RGBA(r / 255., g / 255., b / 255., 1.)) + for keyword, (r, g, b) in _BASIC_COLOR_KEYWORDS + _EXTENDED_COLOR_KEYWORDS) From c166d47c8f4159e808f795f2f0260ab45b93054a Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 6 Sep 2022 15:23:44 +0200 Subject: [PATCH 02/20] Clean a docstring --- tinycss2/color3.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tinycss2/color3.py b/tinycss2/color3.py index aa26255..048d859 100644 --- a/tinycss2/color3.py +++ b/tinycss2/color3.py @@ -30,8 +30,9 @@ class RGBA(collections.namedtuple('RGBA', ['red', 'green', 'blue', 'alpha'])): def parse_color(input): - """Parse a color value as defined in `CSS Color Level 3 - `_. + """Parse a color value as defined in CSS Color Level 3. + + https://www.w3.org/TR/css-color-3/ :type input: :obj:`str` or :term:`iterable` :param input: A string or an iterable of :term:`component values`. From 4dd710da074b1289ad99130da261834c6a2c61cd Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 6 Sep 2022 15:25:34 +0200 Subject: [PATCH 03/20] Clean and improve color4 support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clean code and docstrings - handle angles in hwb() - copy extended colors to avoid a modification of color3’s list --- tinycss2/color4.py | 117 +++++++++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 51 deletions(-) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index dbe52a5..44c872c 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -1,4 +1,5 @@ from colorsys import hls_to_rgb +from math import tau from .color3 import ( _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS, @@ -7,12 +8,14 @@ def parse_color(input): - """Parse a color value as defined in `CSS Color Level 4 - `. Implementation of Level 4 - is currently limited to space-seperated arguments with an optional - slash-seperated opacity, definition of 'rebeccapurple', - percentages and numbers are accepted as opacity values, - the hwb() function and hsla()/rgba() being aliases to hsl()/rgb(). + """Parse a color value as defined in CSS Color Level 4. + + https://www.w3.org/TR/css-color-4/ + + Implementation of Level 4 is currently limited to space-seperated arguments + with an optional slash-seperated opacity, definition of 'rebeccapurple', + percentages and numbers are accepted as opacity values, the hwb() function, + and hsla()/rgba() being aliases to hsl()/rgb(). :type input: :obj:`str` or :term:`iterable` :param input: A string or an iterable of :term:`component values`. @@ -46,47 +49,41 @@ def parse_color(input): return RGBA(*channels) elif token.type == 'function': args = _parse_separated_args(token.arguments) - if args: + if args and len(args) in (3, 4): name = token.lower_name - if name == 'rgb' or name == 'rgba': - alpha = _parse_alpha(args[3:]) - if alpha is not None: - return _parse_rgb(args[:3], alpha) - else: - return _parse_rgb(args, alpha=1.) - elif name == 'hsl' or name == 'hsla': - alpha = _parse_alpha(args[3:]) - if alpha is not None: - return _parse_hsl(args[:3], alpha) - else: - return _parse_hsl(args, alpha=1.) + alpha = _parse_alpha(args[3:]) + if alpha is None: + alpha = 1. + if name in ('rgb', 'rgba'): + return _parse_rgb(args[:3], alpha) + elif name in ('hsl', 'hsla'): + return _parse_hsl(args[:3], alpha) elif name == 'hwb': - alpha = _parse_alpha(args[3:]) - if alpha is not None: - return _parse_hwb(args[:3], alpha) - else: - return _parse_hwb(args, alpha=1.) + return _parse_hwb(args[:3], alpha) def _parse_separated_args(tokens): - """Parse a list of tokens (typically the content of a function token) - as arguments made of a single token each, either comma seperated or - space-seperated with an optional slash-seperated opacity. + """Parse a list of tokens given to a color function. - return the argument list without commas or white space; - or None if the function token content do not match the description above. + According to CSS Color Level 4, arguments must be made of a single token + each, either comma seperated or space-seperated with an optional + slash-seperated opacity. + + Return the argument list without commas or white spaces, or None if the + function token content do not match the description above. """ - tokens = [token for token in tokens - if token.type not in ('whitespace', 'comment')] + tokens = [ + token for token in tokens + if token.type not in ('whitespace', 'comment')] if len(tokens) % 2 == 1 and all(token == ',' for token in tokens[1::2]): return tokens[::2] elif len(tokens) == 3 and all( token.type in ('number', 'percentage') for token in tokens): return tokens elif len(tokens) == 5 and tokens[3] == '/': - args = [token for token in tokens if token != '/'] - return args + tokens.pop(4) + return tokens def _parse_alpha(args): @@ -96,35 +93,53 @@ def _parse_alpha(args): return its value clipped to the 0..1 range. Otherwise, return None. """ - if len(args) == 1 and args[0].type == 'number': - return min(1, max(0, args[0].value)) - if len(args) == 1 and args[0].type == 'percentage': - return min(1, max(0, args[0].value/100)) + if len(args) == 1: + if args[0].type == 'number': + return min(1, max(0, args[0].value)) + elif args[0].type == 'percentage': + return min(1, max(0, args[0].value / 100)) def _parse_hwb(args, alpha): """Parse a list of HWB channels. - If args is a list of 1 INTEGER token and 2 PERCENTAGE tokens, return RGB - values as a tuple of 3 floats in 0..1. Otherwise, return None. + If args is a list of 1 NUMBER or DIMENSION (angle) token and 2 PERCENTAGE + tokens, return RGB values as a tuple of 3 floats clipped to the 0..1 range. + Otherwise, return None. """ - types = [arg.type for arg in args] - if types == ['number', 'percentage', 'percentage'] and args[0].is_integer: - white = args[1].value / 100 - black = args[2].value / 100 - if white + black >= 1: - gray = white / (white + black) - return RGBA(gray, gray, gray, alpha) + if {args[1].type, args[2].type} != {'percentage'}: + return + + if args[0].type == 'number': + hue = args[0].value / 360 + elif args[0].type == 'dimension': + if args[0].unit == 'deg': + hue = args[0].value / 360 + elif args[0].unit == 'grad': + hue = args[0].value / 400 + elif args[0].unit == 'rad': + hue = args[0].value / tau + elif args[0].unit == 'turn': + hue = args[0].value else: - rgb = hls_to_rgb(args[0].int_value / 360, 0.5, 1) - r, g, b = ((channel * (1 - white - black)) + white - for channel in rgb) - return RGBA(r, g, b, alpha) + return + else: + return + + white, black = (arg.value / 100 for arg in args[1:]) + if white + black >= 1: + gray = white / (white + black) + return RGBA(gray, gray, gray, alpha) + else: + rgb = hls_to_rgb(hue, 0.5, 1) + r, g, b = ((channel * (1 - white - black)) + white for channel in rgb) + return RGBA(r, g, b, alpha) # (r, g, b) in 0..255 -_EXTENDED_COLOR_KEYWORDS.insert(119, ('rebeccapurple', (102, 51, 153))) +_EXTENDED_COLOR_KEYWORDS = _EXTENDED_COLOR_KEYWORDS.copy() +_EXTENDED_COLOR_KEYWORDS.append(('rebeccapurple', (102, 51, 153))) # RGBA named tuples of (r, g, b, a) in 0..1 or a string marker _COLOR_KEYWORDS = _SPECIAL_COLOR_KEYWORDS.copy() From 29e2e7cfcb7c78b8b7f8e126455a4dead484e357 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 6 Sep 2022 15:35:59 +0200 Subject: [PATCH 04/20] Only allow 4- and 8-digit hashes in Color Level 4 --- tinycss2/color3.py | 2 -- tinycss2/color4.py | 6 ++++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tinycss2/color3.py b/tinycss2/color3.py index 048d859..3d50ede 100644 --- a/tinycss2/color3.py +++ b/tinycss2/color3.py @@ -142,8 +142,6 @@ def _parse_comma_separated(tokens): _HASH_REGEXPS = ( - (2, re.compile('^{}$'.format(4 * '([\\da-f])'), re.I).match), - (1, re.compile('^{}$'.format(4 * '([\\da-f]{2})'), re.I).match), (2, re.compile('^{}$'.format(3 * '([\\da-f])'), re.I).match), (1, re.compile('^{}$'.format(3 * '([\\da-f]{2})'), re.I).match), ) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index 44c872c..8c37eb2 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -1,3 +1,4 @@ +import re from colorsys import hls_to_rgb from math import tau @@ -137,6 +138,11 @@ def _parse_hwb(args, alpha): return RGBA(r, g, b, alpha) +_HASH_REGEXPS += ( + (2, re.compile('^{}$'.format(4 * '([\\da-f])'), re.I).match), + (1, re.compile('^{}$'.format(4 * '([\\da-f]{2})'), re.I).match), +) + # (r, g, b) in 0..255 _EXTENDED_COLOR_KEYWORDS = _EXTENDED_COLOR_KEYWORDS.copy() _EXTENDED_COLOR_KEYWORDS.append(('rebeccapurple', (102, 51, 153))) From 6401046b2e8acba71df4edd87ef1fe1e3767cc02 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 6 Sep 2022 21:33:50 +0200 Subject: [PATCH 05/20] Minor improvements to color4 --- tinycss2/color4.py | 83 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index 8c37eb2..eeaa584 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -4,7 +4,7 @@ from .color3 import ( _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS, - _SPECIAL_COLOR_KEYWORDS, RGBA, _parse_hsl, _parse_rgb) + _SPECIAL_COLOR_KEYWORDS, RGBA) from .parser import parse_one_component_value @@ -52,15 +52,15 @@ def parse_color(input): args = _parse_separated_args(token.arguments) if args and len(args) in (3, 4): name = token.lower_name - alpha = _parse_alpha(args[3:]) + args, alpha = args[:3], _parse_alpha(args[3:]) if alpha is None: - alpha = 1. + return if name in ('rgb', 'rgba'): - return _parse_rgb(args[:3], alpha) + return _parse_rgb(args, alpha) elif name in ('hsl', 'hsla'): - return _parse_hsl(args[:3], alpha) + return _parse_hsl(args, alpha) elif name == 'hwb': - return _parse_hwb(args[:3], alpha) + return _parse_hwb(args, alpha) def _parse_separated_args(tokens): @@ -83,7 +83,7 @@ def _parse_separated_args(tokens): token.type in ('number', 'percentage') for token in tokens): return tokens elif len(tokens) == 5 and tokens[3] == '/': - tokens.pop(4) + tokens.pop(3) return tokens @@ -94,36 +94,66 @@ def _parse_alpha(args): return its value clipped to the 0..1 range. Otherwise, return None. """ - if len(args) == 1: + if len(args) == 0: + return 1. + elif len(args) == 1: if args[0].type == 'number': return min(1, max(0, args[0].value)) elif args[0].type == 'percentage': return min(1, max(0, args[0].value / 100)) +def _parse_rgb(args, alpha): + """Parse a list of RGB channels. + + If args is a list of 3 NUMBER tokens or 3 PERCENTAGE tokens, return RGB + values as a tuple of 3 floats in 0..1. Otherwise, return None. + + """ + types = [arg.type for arg in args] + if types == ['number', 'number', 'number']: + return RGBA(*[arg.value / 255 for arg in args], alpha) + elif types == ['percentage', 'percentage', 'percentage']: + return RGBA(*[arg.value / 100 for arg in args], alpha) + + +def _parse_hsl(args, alpha): + """Parse a list of HSL channels. + + If args is a list of 1 NUMBER or ANGLE token and 2 PERCENTAGE tokens, + return RGB values as a tuple of 3 floats in 0..1. Otherwise, return None. + + """ + if (args[1].type, args[2].type) != ('percentage', 'percentage'): + return + + if args[0].type == 'number': + hue = args[0].value / 360 + elif args[0].type == 'dimension': + hue = _angle_to_turn(args[0]) + if hue is None: + return + else: + return + r, g, b = hls_to_rgb(hue, args[2].value / 100, args[1].value / 100) + return RGBA(r, g, b, alpha) + + def _parse_hwb(args, alpha): """Parse a list of HWB channels. - If args is a list of 1 NUMBER or DIMENSION (angle) token and 2 PERCENTAGE - tokens, return RGB values as a tuple of 3 floats clipped to the 0..1 range. - Otherwise, return None. + If args is a list of 1 NUMBER or ANGLE token and 2 PERCENTAGE tokens, + return RGB values as a tuple of 3 floats in 0..1. Otherwise, return None. """ - if {args[1].type, args[2].type} != {'percentage'}: + if (args[1].type, args[2].type) != ('percentage', 'percentage'): return if args[0].type == 'number': hue = args[0].value / 360 elif args[0].type == 'dimension': - if args[0].unit == 'deg': - hue = args[0].value / 360 - elif args[0].unit == 'grad': - hue = args[0].value / 400 - elif args[0].unit == 'rad': - hue = args[0].value / tau - elif args[0].unit == 'turn': - hue = args[0].value - else: + hue = _angle_to_turn(args[0]) + if hue is None: return else: return @@ -138,6 +168,17 @@ def _parse_hwb(args, alpha): return RGBA(r, g, b, alpha) +def _angle_to_turn(token): + if token.unit == 'deg': + return token.value / 360 + elif token.unit == 'grad': + return token.value / 400 + elif token.unit == 'rad': + return token.value / tau + elif token.unit == 'turn': + return token.value + + _HASH_REGEXPS += ( (2, re.compile('^{}$'.format(4 * '([\\da-f])'), re.I).match), (1, re.compile('^{}$'.format(4 * '([\\da-f]{2})'), re.I).match), From 61715ec6fa5e5415d09f8233f014ba6716382523 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 6 Sep 2022 22:38:04 +0200 Subject: [PATCH 06/20] Revert "Only allow 4- and 8-digit hashes in Color Level 4" 4- and 8-digit hashes have first been added in a different CSS specification, independant from Color Level 3. This reverts commit c3f4c96cac21ff19c1eecfd37ec0ec819278b6ab. --- tinycss2/color3.py | 2 ++ tinycss2/color4.py | 6 ------ 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tinycss2/color3.py b/tinycss2/color3.py index 3d50ede..048d859 100644 --- a/tinycss2/color3.py +++ b/tinycss2/color3.py @@ -142,6 +142,8 @@ def _parse_comma_separated(tokens): _HASH_REGEXPS = ( + (2, re.compile('^{}$'.format(4 * '([\\da-f])'), re.I).match), + (1, re.compile('^{}$'.format(4 * '([\\da-f]{2})'), re.I).match), (2, re.compile('^{}$'.format(3 * '([\\da-f])'), re.I).match), (1, re.compile('^{}$'.format(3 * '([\\da-f]{2})'), re.I).match), ) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index eeaa584..d2a1fd7 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -1,4 +1,3 @@ -import re from colorsys import hls_to_rgb from math import tau @@ -179,11 +178,6 @@ def _angle_to_turn(token): return token.value -_HASH_REGEXPS += ( - (2, re.compile('^{}$'.format(4 * '([\\da-f])'), re.I).match), - (1, re.compile('^{}$'.format(4 * '([\\da-f]{2})'), re.I).match), -) - # (r, g, b) in 0..255 _EXTENDED_COLOR_KEYWORDS = _EXTENDED_COLOR_KEYWORDS.copy() _EXTENDED_COLOR_KEYWORDS.append(('rebeccapurple', (102, 51, 153))) From f112fb948bbbdff8623fda98c6854ff514e04545 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 18 Jun 2024 22:58:19 +0200 Subject: [PATCH 07/20] Launch color3 tests with color4 module --- tests/test_tinycss2.py | 55 ++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/tests/test_tinycss2.py b/tests/test_tinycss2.py index 999fde8..84bfc46 100644 --- a/tests/test_tinycss2.py +++ b/tests/test_tinycss2.py @@ -8,15 +8,17 @@ from tinycss2 import ( # isort:skip parse_blocks_contents, parse_component_value_list, parse_declaration_list, - parse_one_component_value, parse_one_declaration, parse_one_rule, parse_rule_list, - parse_stylesheet, parse_stylesheet_bytes, serialize) + parse_one_component_value, parse_one_declaration, parse_one_rule, + parse_rule_list, parse_stylesheet, parse_stylesheet_bytes, serialize) from tinycss2.ast import ( # isort:skip - AtKeywordToken, AtRule, Comment, CurlyBracketsBlock, Declaration, DimensionToken, - FunctionBlock, HashToken, IdentToken, LiteralToken, NumberToken, ParenthesesBlock, - ParseError, PercentageToken, QualifiedRule, SquareBracketsBlock, StringToken, - UnicodeRangeToken, URLToken, WhitespaceToken) -from tinycss2.color3 import RGBA, parse_color -from tinycss2.nth import parse_nth + AtKeywordToken, AtRule, Comment, CurlyBracketsBlock, Declaration, + DimensionToken, FunctionBlock, HashToken, IdentToken, LiteralToken, + NumberToken, ParenthesesBlock, ParseError, PercentageToken, QualifiedRule, + SquareBracketsBlock, StringToken, UnicodeRangeToken, URLToken, + WhitespaceToken) +from tinycss2.color3 import RGBA, parse_color as parse_color3 # isort:skip +from tinycss2.color4 import parse_color as parse_color4 # isort:skip +from tinycss2.nth import parse_nth # isort:skip def generic(func): @@ -136,25 +138,45 @@ def test_one_rule(input): return parse_one_rule(input, skip_comments=True) -@json_test() -def test_color3(input): - return parse_color(input) - - @json_test(filename='An+B.json') def test_nth(input): return parse_nth(input) +@json_test() +def test_color3(input): + return parse_color3(input) + + # Do not use @pytest.mark.parametrize because it is slow with that many values. def test_color3_hsl(): for css, expected in load_json('color3_hsl.json'): - assert to_json(parse_color(css)) == expected + assert to_json(parse_color3(css)) == expected def test_color3_keywords(): for css, expected in load_json('color3_keywords.json'): - result = parse_color(css) + result = parse_color3(css) + if result is not None: + r, g, b, a = result + result = [r * 255, g * 255, b * 255, a] + assert result == expected + + +@json_test(filename='color3.json') +def test_color4_compatibility(input): + return parse_color4(input) + + +# Do not use @pytest.mark.parametrize because it is slow with that many values. +def test_color4_hsl_compatibility(): + for css, expected in load_json('color3_hsl.json'): + assert to_json(parse_color4(css)) == expected + + +def test_color4_keywords_compatibility(): + for css, expected in load_json('color3_keywords.json'): + result = parse_color4(css) if result is not None: r, g, b, a = result result = [r * 255, g * 255, b * 255, a] @@ -205,7 +227,8 @@ def test_parse_declaration_value_color(): source = 'color:#369' declaration = parse_one_declaration(source) (value_token,) = declaration.value - assert parse_color(value_token) == (.2, .4, .6, 1) + assert parse_color3(value_token) == (.2, .4, .6, 1) + assert parse_color4(value_token) == (.2, .4, .6, 1) assert declaration.serialize() == source From d13b72e448df0d65808c565449c4df0a38fe6c1b Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 18 Jun 2024 22:59:13 +0200 Subject: [PATCH 08/20] Fix imports (and discover that tests fail :/) --- tests/test_tinycss2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_tinycss2.py b/tests/test_tinycss2.py index 84bfc46..22899f5 100644 --- a/tests/test_tinycss2.py +++ b/tests/test_tinycss2.py @@ -16,7 +16,8 @@ NumberToken, ParenthesesBlock, ParseError, PercentageToken, QualifiedRule, SquareBracketsBlock, StringToken, UnicodeRangeToken, URLToken, WhitespaceToken) -from tinycss2.color3 import RGBA, parse_color as parse_color3 # isort:skip +from tinycss2.color3 import RGBA # isort:skip +from tinycss2.color3 import parse_color as parse_color3 # isort:skip from tinycss2.color4 import parse_color as parse_color4 # isort:skip from tinycss2.nth import parse_nth # isort:skip From 8332f55d0fe9e7e8eb26efa075e731e4b16d158e Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 18 Jun 2024 23:01:46 +0200 Subject: [PATCH 09/20] Handle CIELab and OKLab colors --- tests/test_tinycss2.py | 53 +++++--- tinycss2/color4.py | 298 +++++++++++++++++++++++++++++++---------- 2 files changed, 261 insertions(+), 90 deletions(-) diff --git a/tests/test_tinycss2.py b/tests/test_tinycss2.py index 22899f5..1886f1e 100644 --- a/tests/test_tinycss2.py +++ b/tests/test_tinycss2.py @@ -18,6 +18,7 @@ WhitespaceToken) from tinycss2.color3 import RGBA # isort:skip from tinycss2.color3 import parse_color as parse_color3 # isort:skip +from tinycss2.color4 import Color # isort:skip from tinycss2.color4 import parse_color as parse_color4 # isort:skip from tinycss2.nth import parse_nth # isort:skip @@ -73,6 +74,7 @@ def numeric(t): 'qualified rule', to_json(r.prelude), to_json(r.content)], RGBA: lambda v: [round(c, 10) for c in v], + Color: lambda v: [round(c, 10) for c in v], } @@ -144,40 +146,47 @@ def test_nth(input): return parse_nth(input) -@json_test() -def test_color3(input): +@json_test(filename='color.json') +def test_color_parse3(input): return parse_color3(input) -# Do not use @pytest.mark.parametrize because it is slow with that many values. -def test_color3_hsl(): - for css, expected in load_json('color3_hsl.json'): - assert to_json(parse_color3(css)) == expected +@json_test(filename='color.json') +def test_color_common_parse3(input): + return parse_color3(input) -def test_color3_keywords(): - for css, expected in load_json('color3_keywords.json'): - result = parse_color3(css) - if result is not None: - r, g, b, a = result - result = [r * 255, g * 255, b * 255, a] - assert result == expected +@json_test(filename='color.json') +def test_color_common_parse4(input): + return parse_color4(input) -@json_test(filename='color3.json') -def test_color4_compatibility(input): +@json_test() +def test_color3(input): + return parse_color3(input) + + +@json_test() +def test_color4(input): return parse_color4(input) -# Do not use @pytest.mark.parametrize because it is slow with that many values. -def test_color4_hsl_compatibility(): - for css, expected in load_json('color3_hsl.json'): - assert to_json(parse_color4(css)) == expected +# Do not use @json_test because parametrize is slow with that many values. +@pytest.mark.parametrize(('parse_color'), (parse_color3, parse_color4)) +def test_color_hsl(parse_color): + for css, expected in load_json('color_hsl.json'): + assert to_json(parse_color(css)) == expected -def test_color4_keywords_compatibility(): - for css, expected in load_json('color3_keywords.json'): - result = parse_color4(css) +@pytest.mark.parametrize(('filename', 'parse_color'), ( + ('color_keywords.json', parse_color3), + ('color_keywords.json', parse_color4), + ('color3_keywords.json', parse_color3), + ('color4_keywords.json', parse_color4), +)) +def test_color_keywords(filename, parse_color): + for css, expected in load_json(filename): + result = parse_color(css) if result is not None: r, g, b, a = result result = [r * 255, g * 255, b * 255, a] diff --git a/tinycss2/color4.py b/tinycss2/color4.py index d2a1fd7..f645418 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -1,12 +1,55 @@ from colorsys import hls_to_rgb -from math import tau +from math import cos, sin, tau from .color3 import ( - _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS, - _SPECIAL_COLOR_KEYWORDS, RGBA) + _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS) from .parser import parse_one_component_value +class Color: + """A specified color in a defined color space. + + The color space is ``srgb``, ``srgb-linear``, ``display-p3``, ``a98-rgb``, + ``prophoto-rgb``, ``rec2020``, ``xyz-d50`` or ``xyz-d65``. + + The alpha channel is clipped to [0, 1] but params have undefined range. + + For example, ``rgb(-10%, 120%, 0%)`` is represented as + ``'srgb', (-0.1, 1.2, 0, 1), 1``. + + """ + def __init__(self, space, params, alpha=1): + self.space = space + self.params = tuple(float(param) for param in params) + self.alpha = float(alpha) + + def __repr__(self): + return ( + f'color({self.space} ' + f'{" ".join(str(param) for param in self.params)} ' + f'/ {self.alpha})') + + def __iter__(self): + yield from self.params + yield self.alpha + + def __getitem__(self, key): + return (self.params + (self.alpha,))[key] + + def __hash__(self): + return hash(f'{self.space}{self.params}{self.alpha}') + + def __eq__(self, other): + return ( + tuple(self) == other if isinstance(other, tuple) + else super().__eq__(other)) + + +def srgb(red, green, blue, alpha=1): + """Create a :class:`Color` whose color space is sRGB.""" + return Color('srgb', (red, green, blue), alpha) + + def parse_color(input): """Parse a color value as defined in CSS Color Level 4. @@ -23,12 +66,8 @@ def parse_color(input): * :obj:`None` if the input is not a valid color value. (No exception is raised.) * The string ``'currentColor'`` for the ``currentColor`` keyword - * Or a :class:`RGBA` object for every other values - (including keywords, HSL and HSLA.) - The alpha channel is clipped to [0, 1] - but red, green, or blue can be out of range - (eg. ``rgb(-10%, 120%, 0%)`` is represented as - ``(-0.1, 1.2, 0, 1)``.) + * A :class:`SRGB` object for colors whose color space is sRGB + * A :class:`Color` object for every other values, including keywords. """ if isinstance(input, str): @@ -46,20 +85,40 @@ def parse_color(input): for group in match.groups()] if len(channels) == 3: channels.append(1.) - return RGBA(*channels) + return srgb(*channels) elif token.type == 'function': - args = _parse_separated_args(token.arguments) - if args and len(args) in (3, 4): - name = token.lower_name - args, alpha = args[:3], _parse_alpha(args[3:]) - if alpha is None: - return - if name in ('rgb', 'rgba'): - return _parse_rgb(args, alpha) - elif name in ('hsl', 'hsla'): - return _parse_hsl(args, alpha) - elif name == 'hwb': - return _parse_hwb(args, alpha) + tokens = [ + token for token in token.arguments + if token.type not in ('whitespace', 'comment')] + length = len(tokens) + if length in (5, 7) and all(token == ',' for token in tokens[1::2]): + old_syntax = True + tokens = tokens[::2] + elif length == 3: + old_syntax = False + elif length == 5 and tokens[3] == '/': + tokens.pop(3) + old_syntax = False + else: + return + name = token.lower_name + args, alpha = tokens[:3], _parse_alpha(tokens[3:]) + if alpha is None: + return + if name in ('rgb', 'rgba'): + return _parse_rgb(args, alpha) + elif name in ('hsl', 'hsla'): + return _parse_hsl(args, alpha) + elif name == 'hwb': + return _parse_hwb(args, alpha) + elif name == 'lab' and not old_syntax: + return _parse_lab(args, alpha) + elif name == 'lch' and not old_syntax: + return _parse_lch(args, alpha) + elif name == 'oklab' and not old_syntax: + return _parse_oklab(args, alpha) + elif name == 'oklch' and not old_syntax: + return _parse_oklch(args, alpha) def _parse_separated_args(tokens): @@ -69,21 +128,12 @@ def _parse_separated_args(tokens): each, either comma seperated or space-seperated with an optional slash-seperated opacity. - Return the argument list without commas or white spaces, or None if the - function token content do not match the description above. + Return a tuple containing: + * the argument list without commas or white spaces, or None if the + function token content do not match the description above; and + * a boolean telling if a comma was found in the function parameters. """ - tokens = [ - token for token in tokens - if token.type not in ('whitespace', 'comment')] - if len(tokens) % 2 == 1 and all(token == ',' for token in tokens[1::2]): - return tokens[::2] - elif len(tokens) == 3 and all( - token.type in ('number', 'percentage') for token in tokens): - return tokens - elif len(tokens) == 5 and tokens[3] == '/': - tokens.pop(3) - return tokens def _parse_alpha(args): @@ -105,86 +155,198 @@ def _parse_alpha(args): def _parse_rgb(args, alpha): """Parse a list of RGB channels. - If args is a list of 3 NUMBER tokens or 3 PERCENTAGE tokens, return RGB - values as a tuple of 3 floats in 0..1. Otherwise, return None. + If args is a list of 3 NUMBER tokens or 3 PERCENTAGE tokens, return + sRGB :class:`Color`. Otherwise, return None. """ types = [arg.type for arg in args] if types == ['number', 'number', 'number']: - return RGBA(*[arg.value / 255 for arg in args], alpha) + return srgb(*[arg.value / 255 for arg in args], alpha) elif types == ['percentage', 'percentage', 'percentage']: - return RGBA(*[arg.value / 100 for arg in args], alpha) + return srgb(*[arg.value / 100 for arg in args], alpha) def _parse_hsl(args, alpha): """Parse a list of HSL channels. If args is a list of 1 NUMBER or ANGLE token and 2 PERCENTAGE tokens, - return RGB values as a tuple of 3 floats in 0..1. Otherwise, return None. + return sRGB :class:`Color`. Otherwise, return None. """ if (args[1].type, args[2].type) != ('percentage', 'percentage'): return - if args[0].type == 'number': - hue = args[0].value / 360 - elif args[0].type == 'dimension': - hue = _angle_to_turn(args[0]) - if hue is None: - return - else: + hue = _parse_hue(args[0]) + if hue is None: return r, g, b = hls_to_rgb(hue, args[2].value / 100, args[1].value / 100) - return RGBA(r, g, b, alpha) + return srgb(r, g, b, alpha) def _parse_hwb(args, alpha): """Parse a list of HWB channels. If args is a list of 1 NUMBER or ANGLE token and 2 PERCENTAGE tokens, - return RGB values as a tuple of 3 floats in 0..1. Otherwise, return None. + return sRGB :class:`Color`. Otherwise, return None. """ if (args[1].type, args[2].type) != ('percentage', 'percentage'): return - if args[0].type == 'number': - hue = args[0].value / 360 - elif args[0].type == 'dimension': - hue = _angle_to_turn(args[0]) - if hue is None: - return - else: + hue = _parse_hue(args[0]) + if hue is None: return white, black = (arg.value / 100 for arg in args[1:]) if white + black >= 1: gray = white / (white + black) - return RGBA(gray, gray, gray, alpha) + return srgb(gray, gray, gray, alpha) else: rgb = hls_to_rgb(hue, 0.5, 1) r, g, b = ((channel * (1 - white - black)) + white for channel in rgb) - return RGBA(r, g, b, alpha) + return srgb(r, g, b, alpha) + + +def _parse_lab(args, alpha): + """Parse a list of CIE Lab channels. + + If args is a list of 3 NUMBER or PERCENTAGE tokens, return xyz-d50 + :class:`Color`. Otherwise, return None. + + """ + if len(args) != 3 or {arg.type for arg in args} > {'number', 'percentage'}: + return + L = args[0].value + a = args[1].value * (1 if args[1].type == 'number' else 1.25) + b = args[2].value * (1 if args[2].type == 'number' else 1.25) + return Color('xyz-d50', _lab_to_xyz(L, a, b), alpha) + + +def _parse_lch(args, alpha): + """Parse a list of CIE LCH channels. + If args is a list of 2 NUMBER or PERCENTAGE tokens and 1 NUMBER or ANGLE + token, return xyz-d50 :class:`Color`. Otherwise, return None. -def _angle_to_turn(token): - if token.unit == 'deg': + """ + if len(args) != 3: + return + if {args[0].type, args[1].type} > {'number', 'percentage'}: + return + L = args[0].value + C = args[1].value * (1 if args[1].type == 'number' else 1.5) + H = _parse_hue(args[2]) + if H is None: + return + a = C * cos(H * tau) + b = C * sin(H * tau) + return Color('xyz-d50', _lab_to_xyz(L, a, b), alpha) + + +def _lab_to_xyz(L, a, b): + # Code from https://www.w3.org/TR/css-color-4/#color-conversion-code + κ = 24389 / 27 + ε = 216 / 24389 + f1 = (L + 16) / 116 + f0 = a / 500 + f1 + f2 = f1 - b / 200 + X = (f0 ** 3 if f0 ** 3 > ε else (116 * f0 - 16) / κ) * 0.3457 / 0.3585 + Y = (((L + 16) / 116) ** 3 if L > κ * ε else L / κ) + Z = (f2 ** 3 if f2 ** 3 > ε else (116 * f2 - 16) / κ) * 0.2958 / 0.3585 + return X, Y, Z + + +def _parse_oklab(args, alpha): + """Parse a list of OKLab channels. + + If args is a list of 3 NUMBER or PERCENTAGE tokens, return xyz-d65 + :class:`Color`. Otherwise, return None. + + """ + if len(args) != 3 or {arg.type for arg in args} > {'number', 'percentage'}: + return + L = args[0].value + a = args[1].value * (1 if args[1].type == 'number' else 0.004) + b = args[2].value * (1 if args[2].type == 'number' else 0.004) + return Color('xyz-d65', _oklab_to_xyz(L, a, b), alpha) + + +def _parse_oklch(args, alpha): + """Parse a list of OKLCH channels. + + If args is a list of 2 NUMBER or PERCENTAGE tokens and 1 NUMBER or ANGLE + token, return xyz-d65 :class:`Color`. Otherwise, return None. + + """ + if len(args) != 3: + return + if {args[0].type, args[1].type} > {'number', 'percentage'}: + return + L = args[0].value + C = args[1].value * (1 if args[1].type == 'number' else 1.5) + H = _parse_hue(args[2]) + if H is None: + return + a = C * cos(H * tau) + b = C * sin(H * tau) + return Color('xyz-d65', _oklab_to_xyz(L, a, b), alpha) + + +def _oklab_to_xyz(L, a, b): + # Code from https://www.w3.org/TR/css-color-4/#color-conversion-code + lab = (L / 100, a, b) + lms = [ + sum(_OKLAB_TO_LMS[i][j] * lab[j] for j in range(3)) + for i in range(3)] + X, Y, Z = [ + sum(_LMS_TO_XYZ[i][j] * lms[j] ** 3 for j in range(3)) + for i in range(3)] + return X, Y, Z + + +def _parse_hue(token): + if token.type == 'number': return token.value / 360 - elif token.unit == 'grad': - return token.value / 400 - elif token.unit == 'rad': - return token.value / tau - elif token.unit == 'turn': - return token.value + elif token.type == 'dimension': + if token.unit == 'deg': + return token.value / 360 + elif token.unit == 'grad': + return token.value / 400 + elif token.unit == 'rad': + return token.value / tau + elif token.unit == 'turn': + return token.value # (r, g, b) in 0..255 _EXTENDED_COLOR_KEYWORDS = _EXTENDED_COLOR_KEYWORDS.copy() _EXTENDED_COLOR_KEYWORDS.append(('rebeccapurple', (102, 51, 153))) + +# (r, g, b, a) in 0..1 or a string marker +_SPECIAL_COLOR_KEYWORDS = { + 'currentcolor': 'currentColor', + 'transparent': srgb(0, 0, 0, 0), +} + + # RGBA named tuples of (r, g, b, a) in 0..1 or a string marker _COLOR_KEYWORDS = _SPECIAL_COLOR_KEYWORDS.copy() _COLOR_KEYWORDS.update( # 255 maps to 1, 0 to 0, the rest is linear. - (keyword, RGBA(r / 255., g / 255., b / 255., 1.)) - for keyword, (r, g, b) in _BASIC_COLOR_KEYWORDS + _EXTENDED_COLOR_KEYWORDS) + (keyword, srgb(red / 255, green / 255, blue / 255, 1)) + for keyword, (red, green, blue) + in _BASIC_COLOR_KEYWORDS + _EXTENDED_COLOR_KEYWORDS) + + +# Transformation matrices for OKLab +_LMS_TO_XYZ = ( + (1.2268798733741557, -0.5578149965554813, 0.28139105017721583), + (-0.04057576262431372, 1.1122868293970594, -0.07171106666151701), + (-0.07637294974672142, -0.4214933239627914, 1.5869240244272418), +) +_OKLAB_TO_LMS = ( + (0.99999999845051981432, 0.39633779217376785678, 0.21580375806075880339), + (1.0000000088817607767, -0.1055613423236563494, -0.063854174771705903402), + (1.0000000546724109177, -0.089484182094965759684, -1.2914855378640917399), +) From 097314afcc11cc900e5c8edd114b4d6efb6c3b1e Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Mon, 19 Sep 2022 20:59:10 +0200 Subject: [PATCH 10/20] Minor fixes --- tinycss2/color4.py | 37 +++++++------------------------------ 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index f645418..216ce4f 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -24,10 +24,8 @@ def __init__(self, space, params, alpha=1): self.alpha = float(alpha) def __repr__(self): - return ( - f'color({self.space} ' - f'{" ".join(str(param) for param in self.params)} ' - f'/ {self.alpha})') + params = ' '.join(str(param) for param in self.params) + return f'color({self.space} {params} / {self.alpha})' def __iter__(self): yield from self.params @@ -84,7 +82,7 @@ def parse_color(input): int(group * multiplier, 16) / 255 for group in match.groups()] if len(channels) == 3: - channels.append(1.) + channels.append(1) return srgb(*channels) elif token.type == 'function': tokens = [ @@ -121,21 +119,6 @@ def parse_color(input): return _parse_oklch(args, alpha) -def _parse_separated_args(tokens): - """Parse a list of tokens given to a color function. - - According to CSS Color Level 4, arguments must be made of a single token - each, either comma seperated or space-seperated with an optional - slash-seperated opacity. - - Return a tuple containing: - * the argument list without commas or white spaces, or None if the - function token content do not match the description above; and - * a boolean telling if a comma was found in the function parameters. - - """ - - def _parse_alpha(args): """Parse a list of one alpha value. @@ -175,7 +158,6 @@ def _parse_hsl(args, alpha): """ if (args[1].type, args[2].type) != ('percentage', 'percentage'): return - hue = _parse_hue(args[0]) if hue is None: return @@ -192,11 +174,9 @@ def _parse_hwb(args, alpha): """ if (args[1].type, args[2].type) != ('percentage', 'percentage'): return - hue = _parse_hue(args[0]) if hue is None: return - white, black = (arg.value / 100 for arg in args[1:]) if white + black >= 1: gray = white / (white + black) @@ -278,9 +258,8 @@ def _parse_oklch(args, alpha): token, return xyz-d65 :class:`Color`. Otherwise, return None. """ - if len(args) != 3: - return - if {args[0].type, args[1].type} > {'number', 'percentage'}: + if len(args) != 3 or ( + {args[0].type, args[1].type} > {'number', 'percentage'}): return L = args[0].value C = args[1].value * (1 if args[1].type == 'number' else 1.5) @@ -296,11 +275,9 @@ def _oklab_to_xyz(L, a, b): # Code from https://www.w3.org/TR/css-color-4/#color-conversion-code lab = (L / 100, a, b) lms = [ - sum(_OKLAB_TO_LMS[i][j] * lab[j] for j in range(3)) - for i in range(3)] + sum(_OKLAB_TO_LMS[i][j] * lab[j] for j in range(3)) for i in range(3)] X, Y, Z = [ - sum(_LMS_TO_XYZ[i][j] * lms[j] ** 3 for j in range(3)) - for i in range(3)] + sum(_LMS_TO_XYZ[i][j] * lms[j]**3 for j in range(3)) for i in range(3)] return X, Y, Z From 706e3406dab9e3aef8b18f10de1c72b46b875be5 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 18 Jun 2024 23:30:03 +0200 Subject: [PATCH 11/20] Update tests --- tests/css-parsing-tests | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/css-parsing-tests b/tests/css-parsing-tests index 43e65b2..30efc00 160000 --- a/tests/css-parsing-tests +++ b/tests/css-parsing-tests @@ -1 +1 @@ -Subproject commit 43e65b244133f17eb8a4d4404d5774672b94824f +Subproject commit 30efc000d5931d7562815b5cd7001e4298563dde From 6b54e6185fc03d88058cb4938b8dd1f0fad13c95 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 18 Jun 2024 23:59:46 +0200 Subject: [PATCH 12/20] Update ruff rules --- pyproject.toml | 2 +- tinycss2/color4.py | 14 +++++--------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a12a4eb..d1ef18c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,4 +62,4 @@ extend-exclude = ['tests/css-parsing-tests'] [tool.ruff.lint] select = ['E', 'W', 'F', 'I', 'N', 'RUF'] -ignore = ['RUF001', 'RUF002', 'RUF003'] +ignore = ['RUF001', 'RUF002', 'RUF003', 'N803', 'N806'] diff --git a/tinycss2/color4.py b/tinycss2/color4.py index 216ce4f..6f018cf 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -1,8 +1,7 @@ from colorsys import hls_to_rgb from math import cos, sin, tau -from .color3 import ( - _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS) +from .color3 import _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS from .parser import parse_one_component_value @@ -32,7 +31,7 @@ def __iter__(self): yield self.alpha def __getitem__(self, key): - return (self.params + (self.alpha,))[key] + return (*self.params, self.alpha)[key] def __hash__(self): return hash(f'{self.space}{self.params}{self.alpha}') @@ -258,8 +257,7 @@ def _parse_oklch(args, alpha): token, return xyz-d65 :class:`Color`. Otherwise, return None. """ - if len(args) != 3 or ( - {args[0].type, args[1].type} > {'number', 'percentage'}): + if len(args) != 3 or {args[0].type, args[1].type} > {'number', 'percentage'}: return L = args[0].value C = args[1].value * (1 if args[1].type == 'number' else 1.5) @@ -274,10 +272,8 @@ def _parse_oklch(args, alpha): def _oklab_to_xyz(L, a, b): # Code from https://www.w3.org/TR/css-color-4/#color-conversion-code lab = (L / 100, a, b) - lms = [ - sum(_OKLAB_TO_LMS[i][j] * lab[j] for j in range(3)) for i in range(3)] - X, Y, Z = [ - sum(_LMS_TO_XYZ[i][j] * lms[j]**3 for j in range(3)) for i in range(3)] + lms = [sum(_OKLAB_TO_LMS[i][j] * lab[j] for j in range(3)) for i in range(3)] + X, Y, Z = [sum(_LMS_TO_XYZ[i][j] * lms[j]**3 for j in range(3)) for i in range(3)] return X, Y, Z From 3ffe0e6b94acc6fceb65d7b231a5d98b0d7ef5b2 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Thu, 20 Jun 2024 17:41:36 +0200 Subject: [PATCH 13/20] Handle None values --- tests/test_tinycss2.py | 15 ++- tinycss2/color4.py | 278 ++++++++++++++++++++++++++--------------- 2 files changed, 185 insertions(+), 108 deletions(-) diff --git a/tests/test_tinycss2.py b/tests/test_tinycss2.py index 1886f1e..22f4376 100644 --- a/tests/test_tinycss2.py +++ b/tests/test_tinycss2.py @@ -73,8 +73,14 @@ def numeric(t): QualifiedRule: lambda r: [ 'qualified rule', to_json(r.prelude), to_json(r.content)], - RGBA: lambda v: [round(c, 10) for c in v], - Color: lambda v: [round(c, 10) for c in v], + RGBA: lambda v: [round(c, 6) for c in v], + Color: lambda v: [ + v.space, + [round(c, 6) for c in v.params], + v.function_name, + [None if arg is None else round(arg, 6) for arg in v.args], + v.alpha, + ], } @@ -158,7 +164,8 @@ def test_color_common_parse3(input): @json_test(filename='color.json') def test_color_common_parse4(input): - return parse_color4(input) + result = parse_color4(input) + return RGBA(*result) if (result and result != 'currentColor') else result @json_test() @@ -175,7 +182,7 @@ def test_color4(input): @pytest.mark.parametrize(('parse_color'), (parse_color3, parse_color4)) def test_color_hsl(parse_color): for css, expected in load_json('color_hsl.json'): - assert to_json(parse_color(css)) == expected + assert to_json(RGBA(*parse_color(css))) == expected @pytest.mark.parametrize(('filename', 'parse_color'), ( diff --git a/tinycss2/color4.py b/tinycss2/color4.py index 6f018cf..8a7e34e 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -1,9 +1,58 @@ from colorsys import hls_to_rgb -from math import cos, sin, tau +from math import cbrt, cos, sin, tau from .color3 import _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS from .parser import parse_one_component_value +# Code adapted from https://www.w3.org/TR/css-color-4/#color-conversion-code. +κ = 24389 / 27 +ε = 216 / 24389 +D50 = (0.3457 / 0.3585, 1, (1 - 0.3457 - 0.3585) / 0.3585) +D65 = (0.3127 / 0.3290, 1, (1 - 0.3127 - 0.3290) / 0.3290) +_LMS_TO_XYZ = ( + (1.2268798733741557, -0.5578149965554813, 0.28139105017721583), + (-0.04057576262431372, 1.1122868293970594, -0.07171106666151701), + (-0.07637294974672142, -0.4214933239627914, 1.5869240244272418), +) +_OKLAB_TO_LMS = ( + (0.99999999845051981432, 0.39633779217376785678, 0.21580375806075880339), + (1.0000000088817607767, -0.1055613423236563494, -0.063854174771705903402), + (1.0000000546724109177, -0.089484182094965759684, -1.2914855378640917399), +) + + +def xyz_to_lab(X, Y, Z, d=(1, 1, 1)): + x = X / d[0] + y = Y / d[1] + z = Z / d[2] + f0 = cbrt(x) if x > ε else (κ * x + 16) / 116 + f1 = cbrt(y) if y > ε else (κ * y + 16) / 116 + f2 = cbrt(z) if z > ε else (κ * z + 16) / 116 + L = (116 * f1) - 16 + a = 500 * (f0 - f1) + b = 200 * (f1 - f2) + return L, a, b + + +def lab_to_xyz(L, a, b, d=(1, 1, 1)): + f1 = (L + 16) / 116 + f0 = a / 500 + f1 + f2 = f1 - b / 200 + x = (f0 ** 3 if f0 ** 3 > ε else (116 * f0 - 16) / κ) + y = (((L + 16) / 116) ** 3 if L > κ * ε else L / κ) + z = (f2 ** 3 if f2 ** 3 > ε else (116 * f2 - 16) / κ) + X = x * d[0] + Y = y * d[1] + Z = z * d[2] + return X, Y, Z + + +def _oklab_to_xyz(L, a, b): + lab = (L, a, b) + lms = [sum(_OKLAB_TO_LMS[i][j] * lab[j] for j in range(3)) for i in range(3)] + X, Y, Z = [sum(_LMS_TO_XYZ[i][j] * lms[j]**3 for j in range(3)) for i in range(3)] + return X, Y, Z + class Color: """A specified color in a defined color space. @@ -16,8 +65,13 @@ class Color: For example, ``rgb(-10%, 120%, 0%)`` is represented as ``'srgb', (-0.1, 1.2, 0, 1), 1``. + Original values, used for interpolation, are stored in ``function_names`` + and ``args``. + """ - def __init__(self, space, params, alpha=1): + def __init__(self, function_name, args, space, params, alpha): + self.function_name = function_name + self.args = args self.space = space self.params = tuple(float(param) for param in params) self.alpha = float(alpha) @@ -37,14 +91,13 @@ def __hash__(self): return hash(f'{self.space}{self.params}{self.alpha}') def __eq__(self, other): - return ( - tuple(self) == other if isinstance(other, tuple) - else super().__eq__(other)) - - -def srgb(red, green, blue, alpha=1): - """Create a :class:`Color` whose color space is sRGB.""" - return Color('srgb', (red, green, blue), alpha) + if isinstance(other, str): + return False + elif isinstance(other, tuple): + return tuple(self) == other + elif isinstance(other, Color): + return self.space == other.space and self.params == other.params + return super().__eq__(other) def parse_color(input): @@ -52,18 +105,12 @@ def parse_color(input): https://www.w3.org/TR/css-color-4/ - Implementation of Level 4 is currently limited to space-seperated arguments - with an optional slash-seperated opacity, definition of 'rebeccapurple', - percentages and numbers are accepted as opacity values, the hwb() function, - and hsla()/rgba() being aliases to hsl()/rgb(). - :type input: :obj:`str` or :term:`iterable` :param input: A string or an iterable of :term:`component values`. :returns: * :obj:`None` if the input is not a valid color value. (No exception is raised.) * The string ``'currentColor'`` for the ``currentColor`` keyword - * A :class:`SRGB` object for colors whose color space is sRGB * A :class:`Color` object for every other values, including keywords. """ @@ -72,7 +119,13 @@ def parse_color(input): else: token = input if token.type == 'ident': - return _COLOR_KEYWORDS.get(token.lower_value) + if token.lower_value == 'currentcolor': + return 'currentColor' + elif token.lower_value == 'transparent': + return Color('rgb', (0, 0, 0), 'srgb', (0, 0, 0), 0) + elif color := _COLOR_KEYWORDS.get(token.lower_value): + rgb = tuple(channel / 255 for channel in color) + return Color('rgb', rgb, 'srgb', rgb, 1) elif token.type == 'hash': for multiplier, regexp in _HASH_REGEXPS: match = regexp(token.value) @@ -80,9 +133,8 @@ def parse_color(input): channels = [ int(group * multiplier, 16) / 255 for group in match.groups()] - if len(channels) == 3: - channels.append(1) - return srgb(*channels) + alpha = channels.pop() if len(channels) == 4 else 1 + return Color('rgb', channels, 'srgb', channels, alpha) elif token.type == 'function': tokens = [ token for token in token.arguments @@ -141,11 +193,22 @@ def _parse_rgb(args, alpha): sRGB :class:`Color`. Otherwise, return None. """ + if len(args) != 3: + return types = [arg.type for arg in args] + values = [arg.value for arg in args] + for i, arg in enumerate(args): + if arg.type == 'ident' and arg.lower_value == 'none': + types[i] = 'number' if 'number' in types else 'percentage' + values[i] = 0 if types == ['number', 'number', 'number']: - return srgb(*[arg.value / 255 for arg in args], alpha) + params = tuple(value / 255 for value in values) elif types == ['percentage', 'percentage', 'percentage']: - return srgb(*[arg.value / 100 for arg in args], alpha) + params = tuple(value / 100 for value in values) + else: + return + args = [None if arg.type == 'ident' else param for arg, param in zip(args, params)] + return Color('rgb', args, 'srgb', params, alpha) def _parse_hsl(args, alpha): @@ -155,13 +218,22 @@ def _parse_hsl(args, alpha): return sRGB :class:`Color`. Otherwise, return None. """ - if (args[1].type, args[2].type) != ('percentage', 'percentage'): + if len(args) != 3: return - hue = _parse_hue(args[0]) - if hue is None: + values = [arg.value for arg in args] + for i in (1, 2): + if args[i].type == 'ident' and args[i].lower_value == 'none': + values[i] = 0 + elif args[i].type != 'percentage': + return + values[0] = _parse_hue(args[0]) + if values[0] is None: return - r, g, b = hls_to_rgb(hue, args[2].value / 100, args[1].value / 100) - return srgb(r, g, b, alpha) + values[1] /= 100 + values[2] /= 100 + args = [None if arg.type == 'ident' else value for arg, value in zip(args, values)] + params = hls_to_rgb(values[0], values[2], values[1]) + return Color('hsl', args, 'srgb', params, alpha) def _parse_hwb(args, alpha): @@ -171,19 +243,26 @@ def _parse_hwb(args, alpha): return sRGB :class:`Color`. Otherwise, return None. """ - if (args[1].type, args[2].type) != ('percentage', 'percentage'): + if len(args) != 3: return - hue = _parse_hue(args[0]) - if hue is None: + values = [arg.value for arg in args] + for i in (1, 2): + if args[i].type == 'ident' and args[i].lower_value == 'none': + values[i] = 0 + elif args[i].type != 'percentage': + return + values[0] = _parse_hue(args[0]) + if values[0] is None: return - white, black = (arg.value / 100 for arg in args[1:]) + values[1:] = (value / 100 for value in values[1:]) + args = [None if arg.type == 'ident' else value for arg, value in zip(args, values)] + white, black = values[1:] if white + black >= 1: - gray = white / (white + black) - return srgb(gray, gray, gray, alpha) + params = (white / (white + black),) * 3 else: - rgb = hls_to_rgb(hue, 0.5, 1) - r, g, b = ((channel * (1 - white - black)) + white for channel in rgb) - return srgb(r, g, b, alpha) + rgb = hls_to_rgb(values[0], 0.5, 1) + params = ((channel * (1 - white - black)) + white for channel in rgb) + return Color('hwb', args, 'srgb', params, alpha) def _parse_lab(args, alpha): @@ -193,12 +272,22 @@ def _parse_lab(args, alpha): :class:`Color`. Otherwise, return None. """ - if len(args) != 3 or {arg.type for arg in args} > {'number', 'percentage'}: + if len(args) != 3: return - L = args[0].value - a = args[1].value * (1 if args[1].type == 'number' else 1.25) - b = args[2].value * (1 if args[2].type == 'number' else 1.25) - return Color('xyz-d50', _lab_to_xyz(L, a, b), alpha) + values = [arg.value for arg in args] + for i in range(3): + if args[i].type == 'ident': + if args[i].lower_value == 'none': + values[i] = 0 + else: + return + elif args[i].type not in ('percentage', 'number'): + return + L = values[0] + a = values[1] * (1 if args[1].type == 'number' else 1.25) + b = values[2] * (1 if args[2].type == 'number' else 1.25) + xyz = lab_to_xyz(L, a, b, D50) + return Color('lab', (L / 100, a / 125, b / 125), 'xyz-d50', xyz, alpha) def _parse_lch(args, alpha): @@ -210,29 +299,24 @@ def _parse_lch(args, alpha): """ if len(args) != 3: return - if {args[0].type, args[1].type} > {'number', 'percentage'}: - return - L = args[0].value - C = args[1].value * (1 if args[1].type == 'number' else 1.5) + values = [arg.value for arg in args] + for i in range(2): + if args[i].type == 'ident': + if args[i].lower_value == 'none': + values[i] = 0 + else: + return + elif args[i].type not in ('percentage', 'number'): + return + L = values[0] + C = values[1] * (1 if args[1].type == 'number' else 1.5) H = _parse_hue(args[2]) if H is None: return a = C * cos(H * tau) b = C * sin(H * tau) - return Color('xyz-d50', _lab_to_xyz(L, a, b), alpha) - - -def _lab_to_xyz(L, a, b): - # Code from https://www.w3.org/TR/css-color-4/#color-conversion-code - κ = 24389 / 27 - ε = 216 / 24389 - f1 = (L + 16) / 116 - f0 = a / 500 + f1 - f2 = f1 - b / 200 - X = (f0 ** 3 if f0 ** 3 > ε else (116 * f0 - 16) / κ) * 0.3457 / 0.3585 - Y = (((L + 16) / 116) ** 3 if L > κ * ε else L / κ) - Z = (f2 ** 3 if f2 ** 3 > ε else (116 * f2 - 16) / κ) * 0.2958 / 0.3585 - return X, Y, Z + xyz = lab_to_xyz(L, a, b, D50) + return Color('lch', (L / 100, C / 150, H), 'xyz-d50', xyz, alpha) def _parse_oklab(args, alpha): @@ -242,12 +326,22 @@ def _parse_oklab(args, alpha): :class:`Color`. Otherwise, return None. """ - if len(args) != 3 or {arg.type for arg in args} > {'number', 'percentage'}: + if len(args) != 3: return - L = args[0].value - a = args[1].value * (1 if args[1].type == 'number' else 0.004) - b = args[2].value * (1 if args[2].type == 'number' else 0.004) - return Color('xyz-d65', _oklab_to_xyz(L, a, b), alpha) + values = [arg.value for arg in args] + for i in range(3): + if args[i].type == 'ident': + if args[i].lower_value == 'none': + values[i] = 0 + else: + return + elif args[i].type not in ('percentage', 'number'): + return + L = values[0] * (1 if args[0].type == 'number' else (1 / 100)) + a = values[1] * (1 if args[1].type == 'number' else (0.4 / 100)) + b = values[2] * (1 if args[2].type == 'number' else (0.4 / 100)) + xyz = _oklab_to_xyz(L, a, b) + return Color('oklab', (L, a / 0.4, b / 0.4), 'xyz-d65', xyz, alpha) def _parse_oklch(args, alpha): @@ -259,22 +353,24 @@ def _parse_oklch(args, alpha): """ if len(args) != 3 or {args[0].type, args[1].type} > {'number', 'percentage'}: return - L = args[0].value - C = args[1].value * (1 if args[1].type == 'number' else 1.5) + values = [arg.value for arg in args] + for i in range(2): + if args[i].type == 'ident': + if args[i].lower_value == 'none': + values[i] = 0 + else: + return + elif args[i].type not in ('percentage', 'number'): + return + L = values[0] * (1 if args[0].type == 'number' else (1 / 100)) + C = values[1] * (1 if args[1].type == 'number' else (0.4 / 100)) H = _parse_hue(args[2]) if H is None: return a = C * cos(H * tau) b = C * sin(H * tau) - return Color('xyz-d65', _oklab_to_xyz(L, a, b), alpha) - - -def _oklab_to_xyz(L, a, b): - # Code from https://www.w3.org/TR/css-color-4/#color-conversion-code - lab = (L / 100, a, b) - lms = [sum(_OKLAB_TO_LMS[i][j] * lab[j] for j in range(3)) for i in range(3)] - X, Y, Z = [sum(_LMS_TO_XYZ[i][j] * lms[j]**3 for j in range(3)) for i in range(3)] - return X, Y, Z + xyz = _oklab_to_xyz(L, a, b) + return Color('oklch', (L, C / 0.4, H), 'xyz-d65', xyz, alpha) def _parse_hue(token): @@ -289,37 +385,11 @@ def _parse_hue(token): return token.value / tau elif token.unit == 'turn': return token.value + elif token.type == 'ident' and token.lower_value == 'none': + return 0 # (r, g, b) in 0..255 _EXTENDED_COLOR_KEYWORDS = _EXTENDED_COLOR_KEYWORDS.copy() _EXTENDED_COLOR_KEYWORDS.append(('rebeccapurple', (102, 51, 153))) - - -# (r, g, b, a) in 0..1 or a string marker -_SPECIAL_COLOR_KEYWORDS = { - 'currentcolor': 'currentColor', - 'transparent': srgb(0, 0, 0, 0), -} - - -# RGBA named tuples of (r, g, b, a) in 0..1 or a string marker -_COLOR_KEYWORDS = _SPECIAL_COLOR_KEYWORDS.copy() -_COLOR_KEYWORDS.update( - # 255 maps to 1, 0 to 0, the rest is linear. - (keyword, srgb(red / 255, green / 255, blue / 255, 1)) - for keyword, (red, green, blue) - in _BASIC_COLOR_KEYWORDS + _EXTENDED_COLOR_KEYWORDS) - - -# Transformation matrices for OKLab -_LMS_TO_XYZ = ( - (1.2268798733741557, -0.5578149965554813, 0.28139105017721583), - (-0.04057576262431372, 1.1122868293970594, -0.07171106666151701), - (-0.07637294974672142, -0.4214933239627914, 1.5869240244272418), -) -_OKLAB_TO_LMS = ( - (0.99999999845051981432, 0.39633779217376785678, 0.21580375806075880339), - (1.0000000088817607767, -0.1055613423236563494, -0.063854174771705903402), - (1.0000000546724109177, -0.089484182094965759684, -1.2914855378640917399), -) +_COLOR_KEYWORDS = dict(_BASIC_COLOR_KEYWORDS + _EXTENDED_COLOR_KEYWORDS) From 911de4898e60c0d55b9016cacd1c92504c3dcc7b Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sat, 22 Jun 2024 17:22:25 +0200 Subject: [PATCH 14/20] Support color function --- tinycss2/color4.py | 75 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index 8a7e34e..504d1ed 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -9,6 +9,11 @@ ε = 216 / 24389 D50 = (0.3457 / 0.3585, 1, (1 - 0.3457 - 0.3585) / 0.3585) D65 = (0.3127 / 0.3290, 1, (1 - 0.3127 - 0.3290) / 0.3290) +SPACES = { + 'srgb', 'srgb-linear', + 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec2020', + 'xyz', 'xyz-d50', 'xyz-d65' +} _LMS_TO_XYZ = ( (1.2268798733741557, -0.5578149965554813, 0.28139105017721583), (-0.04057576262431372, 1.1122868293970594, -0.07171106666151701), @@ -139,6 +144,9 @@ def parse_color(input): tokens = [ token for token in token.arguments if token.type not in ('whitespace', 'comment')] + name = token.lower_name + if name == 'color': + space, *tokens = tokens length = len(tokens) if length in (5, 7) and all(token == ',' for token in tokens[1::2]): old_syntax = True @@ -150,7 +158,6 @@ def parse_color(input): old_syntax = False else: return - name = token.lower_name args, alpha = tokens[:3], _parse_alpha(tokens[3:]) if alpha is None: return @@ -168,6 +175,8 @@ def parse_color(input): return _parse_oklab(args, alpha) elif name == 'oklch' and not old_syntax: return _parse_oklch(args, alpha) + elif name == 'color' and not old_syntax: + return _parse_color(space, args, alpha) def _parse_alpha(args): @@ -193,8 +202,6 @@ def _parse_rgb(args, alpha): sRGB :class:`Color`. Otherwise, return None. """ - if len(args) != 3: - return types = [arg.type for arg in args] values = [arg.value for arg in args] for i, arg in enumerate(args): @@ -218,8 +225,6 @@ def _parse_hsl(args, alpha): return sRGB :class:`Color`. Otherwise, return None. """ - if len(args) != 3: - return values = [arg.value for arg in args] for i in (1, 2): if args[i].type == 'ident' and args[i].lower_value == 'none': @@ -243,8 +248,6 @@ def _parse_hwb(args, alpha): return sRGB :class:`Color`. Otherwise, return None. """ - if len(args) != 3: - return values = [arg.value for arg in args] for i in (1, 2): if args[i].type == 'ident' and args[i].lower_value == 'none': @@ -272,8 +275,6 @@ def _parse_lab(args, alpha): :class:`Color`. Otherwise, return None. """ - if len(args) != 3: - return values = [arg.value for arg in args] for i in range(3): if args[i].type == 'ident': @@ -286,8 +287,13 @@ def _parse_lab(args, alpha): L = values[0] a = values[1] * (1 if args[1].type == 'number' else 1.25) b = values[2] * (1 if args[2].type == 'number' else 1.25) + args = [ + None if args[0].type == 'ident' else L / 100, + None if args[1].type == 'ident' else a / 125, + None if args[2].type == 'ident' else b / 125, + ] xyz = lab_to_xyz(L, a, b, D50) - return Color('lab', (L / 100, a / 125, b / 125), 'xyz-d50', xyz, alpha) + return Color('lab', args, 'xyz-d50', xyz, alpha) def _parse_lch(args, alpha): @@ -297,8 +303,6 @@ def _parse_lch(args, alpha): token, return xyz-d50 :class:`Color`. Otherwise, return None. """ - if len(args) != 3: - return values = [arg.value for arg in args] for i in range(2): if args[i].type == 'ident': @@ -313,10 +317,15 @@ def _parse_lch(args, alpha): H = _parse_hue(args[2]) if H is None: return + args = [ + None if args[0].type == 'ident' else L / 100, + None if args[1].type == 'ident' else C / 150, + None if args[2].type == 'ident' else H, + ] a = C * cos(H * tau) b = C * sin(H * tau) xyz = lab_to_xyz(L, a, b, D50) - return Color('lch', (L / 100, C / 150, H), 'xyz-d50', xyz, alpha) + return Color('lch', args, 'xyz-d50', xyz, alpha) def _parse_oklab(args, alpha): @@ -326,8 +335,6 @@ def _parse_oklab(args, alpha): :class:`Color`. Otherwise, return None. """ - if len(args) != 3: - return values = [arg.value for arg in args] for i in range(3): if args[i].type == 'ident': @@ -340,8 +347,13 @@ def _parse_oklab(args, alpha): L = values[0] * (1 if args[0].type == 'number' else (1 / 100)) a = values[1] * (1 if args[1].type == 'number' else (0.4 / 100)) b = values[2] * (1 if args[2].type == 'number' else (0.4 / 100)) + args = [ + None if args[0].type == 'ident' else L, + None if args[1].type == 'ident' else a / 0.4, + None if args[2].type == 'ident' else b / 0.4, + ] xyz = _oklab_to_xyz(L, a, b) - return Color('oklab', (L, a / 0.4, b / 0.4), 'xyz-d65', xyz, alpha) + return Color('oklab', args, 'xyz-d65', xyz, alpha) def _parse_oklch(args, alpha): @@ -351,7 +363,7 @@ def _parse_oklch(args, alpha): token, return xyz-d65 :class:`Color`. Otherwise, return None. """ - if len(args) != 3 or {args[0].type, args[1].type} > {'number', 'percentage'}: + if {args[0].type, args[1].type} > {'number', 'percentage'}: return values = [arg.value for arg in args] for i in range(2): @@ -367,10 +379,37 @@ def _parse_oklch(args, alpha): H = _parse_hue(args[2]) if H is None: return + args = [ + None if args[0].type == 'ident' else L, + None if args[1].type == 'ident' else C / 0.4, + None if args[2].type == 'ident' else H, + ] a = C * cos(H * tau) b = C * sin(H * tau) xyz = _oklab_to_xyz(L, a, b) - return Color('oklch', (L, C / 0.4, H), 'xyz-d65', xyz, alpha) + return Color('oklch', args, 'xyz-d65', xyz, alpha) + + +def _parse_color(space, args, alpha): + """Parse a color space name list of channels.""" + values = [arg.value for arg in args] + for i in range(3): + if args[i].type == 'ident': + if args[i].lower_value == 'none': + values[i] = 0 + else: + return + elif args[i].type == 'percentage': + values[i] /= 100 + elif args[i].type != 'number': + return + args = [ + None if args[0].type == 'ident' else values[0], + None if args[1].type == 'ident' else values[1], + None if args[2].type == 'ident' else values[2], + ] + if space.type == 'ident' and (space := space.lower_value) in SPACES: + return Color('color', args, space, values, alpha) def _parse_hue(token): From 2c4233db986556d8de83e577540656030fc6c5d0 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 25 Jun 2024 23:36:06 +0200 Subject: [PATCH 15/20] WIP --- tests/test_tinycss2.py | 77 +++++++- tinycss2/color4.py | 412 ++++++++++++++++++++++------------------- 2 files changed, 290 insertions(+), 199 deletions(-) diff --git a/tests/test_tinycss2.py b/tests/test_tinycss2.py index 22f4376..539114c 100644 --- a/tests/test_tinycss2.py +++ b/tests/test_tinycss2.py @@ -1,6 +1,7 @@ import functools import json import pprint +from colorsys import hls_to_rgb from pathlib import Path import pytest @@ -164,8 +165,14 @@ def test_color_common_parse3(input): @json_test(filename='color.json') def test_color_common_parse4(input): - result = parse_color4(input) - return RGBA(*result) if (result and result != 'currentColor') else result + color = parse_color4(input) + if not color or color == 'currentColor': + return color + elif color.space == 'srgb': + return RGBA(*color) + elif color.space == 'hsl': + rgb = hls_to_rgb(color[0] / 360, color[2] / 100, color[1] / 100) + return RGBA(*rgb, color.alpha) @json_test() @@ -173,16 +180,68 @@ def test_color3(input): return parse_color3(input) +@json_test(filename='color_hsl.json') +def test_color3_hsl(input): + return parse_color3(input) + + +@json_test(filename='color_hsl.json') +def test_color4_hsl(input): + color = parse_color4(input) + assert color.space == 'hsl' + rgb = hls_to_rgb(color[0] / 360, color[2] / 100, color[1] / 100) + return RGBA(*rgb, color.alpha) if (color and color != 'currentColor') else color + + @json_test() -def test_color4(input): - return parse_color4(input) +def test_color4_hwb(input): + color = parse_color4(input) + assert color.space == 'hwb' + white, black = color[1:3] + if white + black >= 100: + rgb = (255 * white / (white + black),) * 3 + else: + rgb = hls_to_rgb(color[0] / 360, 0.5, 1) + rgb = (2.55 * ((channel * (100 - white - black)) + white) for channel in rgb) + rgb = (round(coordinate + 0.001) for coordinate in rgb) + coordinates = ', '.join( + str(int(coordinate) if coordinate.is_integer() else coordinate) + for coordinate in rgb) + if color.alpha == 0: + return f'rgba({coordinates}, 0)' + elif color.alpha == 1: + return f'rgb({coordinates})' + else: + return f'rgba({coordinates}, {color.alpha})' + return RGBA(*rgb, color.alpha) if (color and color != 'currentColor') else color -# Do not use @json_test because parametrize is slow with that many values. -@pytest.mark.parametrize(('parse_color'), (parse_color3, parse_color4)) -def test_color_hsl(parse_color): - for css, expected in load_json('color_hsl.json'): - assert to_json(RGBA(*parse_color(css))) == expected +@json_test() +def test_color4_color_function(input): + color = parse_color4(input) + coordinates = ' '.join( + str(int(coordinate) if coordinate.is_integer() else round(coordinate, 3)) + for coordinate in color.coordinates) + if color.alpha == 0: + return f'color({color.space} {coordinates} / 0)' + elif color.alpha == 1: + return f'color({color.space} {coordinates})' + else: + return f'color({color.space} {coordinates} / {color.alpha})' + + +@json_test() +def test_color4_lab_lch_oklab_oklch(input): + color = parse_color4(input) + coordinates = ' '.join( + str(int(coordinate) if coordinate.is_integer() else round(coordinate, 3)) + for coordinate in color.coordinates) + if color.alpha == 0: + return f'{color.space}({coordinates} / 0)' + elif color.alpha == 1: + return f'{color.space}({coordinates})' + else: + return f'{color.space}({coordinates} / {color.alpha})' @pytest.mark.parametrize(('filename', 'parse_color'), ( diff --git a/tinycss2/color4.py b/tinycss2/color4.py index 504d1ed..df1b455 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -4,16 +4,18 @@ from .color3 import _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS from .parser import parse_one_component_value -# Code adapted from https://www.w3.org/TR/css-color-4/#color-conversion-code. -κ = 24389 / 27 -ε = 216 / 24389 D50 = (0.3457 / 0.3585, 1, (1 - 0.3457 - 0.3585) / 0.3585) D65 = (0.3127 / 0.3290, 1, (1 - 0.3127 - 0.3290) / 0.3290) -SPACES = { +_FUNCTION_SPACES = { 'srgb', 'srgb-linear', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec2020', 'xyz', 'xyz-d50', 'xyz-d65' } +COLOR_SPACES = _FUNCTION_SPACES | {'hsl', 'hwb', 'lab', 'lch', 'oklab', 'oklch'} + +# Code adapted from https://www.w3.org/TR/css-color-4/#color-conversion-code. +_κ = 24389 / 27 +_ε = 216 / 24389 _LMS_TO_XYZ = ( (1.2268798733741557, -0.5578149965554813, 0.28139105017721583), (-0.04057576262431372, 1.1122868293970594, -0.07171106666151701), @@ -25,27 +27,26 @@ (1.0000000546724109177, -0.089484182094965759684, -1.2914855378640917399), ) - -def xyz_to_lab(X, Y, Z, d=(1, 1, 1)): +def _xyz_to_lab(X, Y, Z, d): x = X / d[0] y = Y / d[1] z = Z / d[2] - f0 = cbrt(x) if x > ε else (κ * x + 16) / 116 - f1 = cbrt(y) if y > ε else (κ * y + 16) / 116 - f2 = cbrt(z) if z > ε else (κ * z + 16) / 116 + f0 = cbrt(x) if x > _ε else (_κ * x + 16) / 116 + f1 = cbrt(y) if y > _ε else (_κ * y + 16) / 116 + f2 = cbrt(z) if z > _ε else (_κ * z + 16) / 116 L = (116 * f1) - 16 a = 500 * (f0 - f1) b = 200 * (f1 - f2) return L, a, b -def lab_to_xyz(L, a, b, d=(1, 1, 1)): +def _lab_to_xyz(L, a, b, d): f1 = (L + 16) / 116 f0 = a / 500 + f1 f2 = f1 - b / 200 - x = (f0 ** 3 if f0 ** 3 > ε else (116 * f0 - 16) / κ) - y = (((L + 16) / 116) ** 3 if L > κ * ε else L / κ) - z = (f2 ** 3 if f2 ** 3 > ε else (116 * f2 - 16) / κ) + x = (f0 ** 3 if f0 ** 3 > _ε else (116 * f0 - 16) / _κ) + y = (((L + 16) / 116) ** 3 if L > _κ * _ε else L / _κ) + z = (f2 ** 3 if f2 ** 3 > _ε else (116 * f2 - 16) / _κ) X = x * d[0] Y = y * d[1] Z = z * d[2] @@ -62,38 +63,33 @@ def _oklab_to_xyz(L, a, b): class Color: """A specified color in a defined color space. - The color space is ``srgb``, ``srgb-linear``, ``display-p3``, ``a98-rgb``, - ``prophoto-rgb``, ``rec2020``, ``xyz-d50`` or ``xyz-d65``. - - The alpha channel is clipped to [0, 1] but params have undefined range. + The color space is one of ``COLOR_SPACES``. - For example, ``rgb(-10%, 120%, 0%)`` is represented as - ``'srgb', (-0.1, 1.2, 0, 1), 1``. - - Original values, used for interpolation, are stored in ``function_names`` - and ``args``. + Coordinates are floats with undefined ranges, but alpha channel is clipped + to [0, 1]. Coordinates can also be set to ``None`` when undefined. """ - def __init__(self, function_name, args, space, params, alpha): - self.function_name = function_name - self.args = args + def __init__(self, space, coordinates, alpha): + assert space in COLOR_SPACES, f"{space} is not a supported color space" self.space = space - self.params = tuple(float(param) for param in params) - self.alpha = float(alpha) + self.coordinates = tuple( + None if coordinate is None else float(coordinate) + for coordinate in coordinates) + self.alpha = max(0., min(1., float(alpha))) def __repr__(self): - params = ' '.join(str(param) for param in self.params) - return f'color({self.space} {params} / {self.alpha})' + coordinates = ' '.join(str(coordinate) for coordinate in self.coordinates) + return f'color({self.space} {coordinates} / {self.alpha})' def __iter__(self): - yield from self.params + yield from self.coordinates yield self.alpha def __getitem__(self, key): - return (*self.params, self.alpha)[key] + return (*self.coordinates, self.alpha)[key] def __hash__(self): - return hash(f'{self.space}{self.params}{self.alpha}') + return hash(str(self)) def __eq__(self, other): if isinstance(other, str): @@ -101,9 +97,82 @@ def __eq__(self, other): elif isinstance(other, tuple): return tuple(self) == other elif isinstance(other, Color): - return self.space == other.space and self.params == other.params + return self.space == other.space and self.coordinates == other.coordinates return super().__eq__(other) + def to(self, space): + """Return new instance with coordinates transformed to given ``space``. + + The destination color space is one of ``SPACES``. + + ``None`` coordinates are always transformed into ``0`` values. + + Many space combinations are not supported. + + """ + coordinates = tuple(coordinate or 0 for coordinate in self.coordinates) + if space == 'xyz': + space = 'xyz-d65' + if space == self.space: + return Color(space, coordinates, self.alpha) + elif space == 'srgb': + if self.space == 'hsl': + rgb = hls_to_rgb( + coordinates[0] / 360, + coordinates[2] / 100, + coordinates[1] / 100, + ) + return Color(space, rgb, self.alpha) + elif self.space == 'hwb': + white, black = coordinates[1:] + if white + black >= 100: + rgb = (white / (white + black),) * 3 + else: + rgb = ( + ((channel * (100 - white - black)) + white) / 100 + for channel in hls_to_rgb(coordinates[0] / 360, 0.5, 1)) + return Color(space, rgb, self.alpha) + elif space == 'xyz-d50': + if self.space == 'lab': + xyz = _lab_to_xyz(*coordinates, D50) + return Color(space, xyz, self.alpha) + elif self.space == 'lch': + a = coordinates[1] * cos(coordinates[2] / 360 * tau) + b = coordinates[1] * sin(coordinates[2] / 360 * tau) + xyz = _lab_to_xyz(coordinates[0], a, b, D50) + return Color(space, xyz, self.alpha) + elif space == 'xyz-d65': + if self.space == 'oklab': + xyz = _oklab_to_xyz(*coordinates) + return Color(space, xyz, self.alpha) + elif self.space == 'oklch': + a = coordinates[1] * cos(coordinates[2] / 360 * tau) + b = coordinates[1] * sin(coordinates[2] / 360 * tau) + xyz = _oklab_to_xyz(coordinates[0], a, b) + return Color(space, xyz, self.alpha) + elif space == 'lab': + if self.space == 'xyz-d50': + lab = _xyz_to_lab(*coordinates, D50) + return Color(space, lab, self.alpha) + elif self.space == 'xyz-d65': + lab = _xyz_to_lab(*coordinates, D65) + return Color(space, lab, self.alpha) + elif self.space == 'lch': + a = coordinates[1] * cos(coordinates[2] / 360 * tau) + b = coordinates[1] * sin(coordinates[2] / 360 * tau) + return Color(space, (coordinates[0], a, b), self.alpha) + elif self.space == 'oklab': + xyz = _oklab_to_xyz(*coordinates) + lab = _xyz_to_lab(*xyz, D65) + return Color(space, lab, self.alpha) + elif self.space == 'oklch': + a = coordinates[1] * cos(coordinates[2] / 360 * tau) + b = coordinates[1] * sin(coordinates[2] / 360 * tau) + xyz = _oklab_to_xyz(coordinates[0], a, b) + lab = _xyz_to_lab(*xyz, D65) + return Color(space, lab, self.alpha) + raise NotImplementedError + def parse_color(input): """Parse a color value as defined in CSS Color Level 4. @@ -127,10 +196,10 @@ def parse_color(input): if token.lower_value == 'currentcolor': return 'currentColor' elif token.lower_value == 'transparent': - return Color('rgb', (0, 0, 0), 'srgb', (0, 0, 0), 0) + return Color('srgb', (0, 0, 0), 0) elif color := _COLOR_KEYWORDS.get(token.lower_value): rgb = tuple(channel / 255 for channel in color) - return Color('rgb', rgb, 'srgb', rgb, 1) + return Color('srgb', rgb, 1) elif token.type == 'hash': for multiplier, regexp in _HASH_REGEXPS: match = regexp(token.value) @@ -139,7 +208,7 @@ def parse_color(input): int(group * multiplier, 16) / 255 for group in match.groups()] alpha = channels.pop() if len(channels) == 4 else 1 - return Color('rgb', channels, 'srgb', channels, alpha) + return Color('srgb', channels, alpha) elif token.type == 'function': tokens = [ token for token in token.arguments @@ -201,233 +270,196 @@ def _parse_rgb(args, alpha): If args is a list of 3 NUMBER tokens or 3 PERCENTAGE tokens, return sRGB :class:`Color`. Otherwise, return None. + Input R, G, B ranges are [0, 255], output are [0, 1]. + """ - types = [arg.type for arg in args] - values = [arg.value for arg in args] - for i, arg in enumerate(args): - if arg.type == 'ident' and arg.lower_value == 'none': - types[i] = 'number' if 'number' in types else 'percentage' - values[i] = 0 - if types == ['number', 'number', 'number']: - params = tuple(value / 255 for value in values) - elif types == ['percentage', 'percentage', 'percentage']: - params = tuple(value / 100 for value in values) - else: + if _types(args) not in ({'number'}, {'percentage'}): return - args = [None if arg.type == 'ident' else param for arg, param in zip(args, params)] - return Color('rgb', args, 'srgb', params, alpha) + coordinates = [ + arg.value / 255 if arg.type == 'number' else + arg.value / 100 if arg.type == 'percentage' else None + for arg in args] + return Color('srgb', coordinates, alpha) def _parse_hsl(args, alpha): """Parse a list of HSL channels. If args is a list of 1 NUMBER or ANGLE token and 2 PERCENTAGE tokens, - return sRGB :class:`Color`. Otherwise, return None. + return HSL :class:`Color`. Otherwise, return None. + + H range is [0, 360). S, L ranges are [0, 100]. """ - values = [arg.value for arg in args] - for i in (1, 2): - if args[i].type == 'ident' and args[i].lower_value == 'none': - values[i] = 0 - elif args[i].type != 'percentage': - return - values[0] = _parse_hue(args[0]) - if values[0] is None: + if _types(args[1:]) not in ({'number'}, {'percentage'}): + return + if (hue := _parse_hue(args[0])) is None: return - values[1] /= 100 - values[2] /= 100 - args = [None if arg.type == 'ident' else value for arg, value in zip(args, values)] - params = hls_to_rgb(values[0], values[2], values[1]) - return Color('hsl', args, 'srgb', params, alpha) + coordinates = [ + None if args[0].type == 'ident' else hue, + None if args[1].type == 'ident' else args[1].value, + None if args[2].type == 'ident' else args[2].value, + ] + return Color('hsl', coordinates, alpha) def _parse_hwb(args, alpha): """Parse a list of HWB channels. If args is a list of 1 NUMBER or ANGLE token and 2 PERCENTAGE tokens, - return sRGB :class:`Color`. Otherwise, return None. + return HWB :class:`Color`. Otherwise, return None. + + H range is [0, 360). W, B ranges are [0, 100]. """ - values = [arg.value for arg in args] - for i in (1, 2): - if args[i].type == 'ident' and args[i].lower_value == 'none': - values[i] = 0 - elif args[i].type != 'percentage': - return - values[0] = _parse_hue(args[0]) - if values[0] is None: + if _types(args[1:]) > {'percentage'}: return - values[1:] = (value / 100 for value in values[1:]) - args = [None if arg.type == 'ident' else value for arg, value in zip(args, values)] - white, black = values[1:] - if white + black >= 1: - params = (white / (white + black),) * 3 - else: - rgb = hls_to_rgb(values[0], 0.5, 1) - params = ((channel * (1 - white - black)) + white for channel in rgb) - return Color('hwb', args, 'srgb', params, alpha) + if (hue := _parse_hue(args[0])) is None: + return + coordinates = [ + None if args[0].type == 'ident' else hue, + None if args[1].type == 'ident' else args[1].value, + None if args[2].type == 'ident' else args[2].value, + ] + return Color('hwb', coordinates, alpha) def _parse_lab(args, alpha): """Parse a list of CIE Lab channels. - If args is a list of 3 NUMBER or PERCENTAGE tokens, return xyz-d50 + If args is a list of 3 NUMBER or PERCENTAGE tokens, return Lab :class:`Color`. Otherwise, return None. + L range is [0, 100]. a, b ranges are [-125, 125]. + """ - values = [arg.value for arg in args] - for i in range(3): - if args[i].type == 'ident': - if args[i].lower_value == 'none': - values[i] = 0 - else: - return - elif args[i].type not in ('percentage', 'number'): - return - L = values[0] - a = values[1] * (1 if args[1].type == 'number' else 1.25) - b = values[2] * (1 if args[2].type == 'number' else 1.25) - args = [ - None if args[0].type == 'ident' else L / 100, - None if args[1].type == 'ident' else a / 125, - None if args[2].type == 'ident' else b / 125, + if _types(args) > {'number', 'percentage'}: + return + coordinates = [ + None if args[0].type == 'ident' else args[0].value, + None if args[1].type == 'ident' else ( + args[1].value * (1 if args[1].type == 'number' else 1.25)), + None if args[2].type == 'ident' else ( + args[2].value * (1 if args[2].type == 'number' else 1.25)), ] - xyz = lab_to_xyz(L, a, b, D50) - return Color('lab', args, 'xyz-d50', xyz, alpha) + return Color('lab', coordinates, alpha) def _parse_lch(args, alpha): """Parse a list of CIE LCH channels. If args is a list of 2 NUMBER or PERCENTAGE tokens and 1 NUMBER or ANGLE - token, return xyz-d50 :class:`Color`. Otherwise, return None. + token, return LCH :class:`Color`. Otherwise, return None. + + L range is [0, 100]. C range is [0, 150]. H ranges is [0, 360). """ - values = [arg.value for arg in args] - for i in range(2): - if args[i].type == 'ident': - if args[i].lower_value == 'none': - values[i] = 0 - else: - return - elif args[i].type not in ('percentage', 'number'): - return - L = values[0] - C = values[1] * (1 if args[1].type == 'number' else 1.5) - H = _parse_hue(args[2]) - if H is None: + if _types(args[:2]) > {'number', 'percentage'}: return - args = [ - None if args[0].type == 'ident' else L / 100, - None if args[1].type == 'ident' else C / 150, - None if args[2].type == 'ident' else H, + if (hue := _parse_hue(args[2])) is None: + return + coordinates = [ + None if args[0].type == 'ident' else args[0].value, + None if args[1].type == 'ident' else ( + args[1].value * (1 if args[1].type == 'number' else 1.5)), + None if args[0].type == 'ident' else hue, ] - a = C * cos(H * tau) - b = C * sin(H * tau) - xyz = lab_to_xyz(L, a, b, D50) - return Color('lch', args, 'xyz-d50', xyz, alpha) + return Color('lch', coordinates, alpha) def _parse_oklab(args, alpha): - """Parse a list of OKLab channels. + """Parse a list of Oklab channels. - If args is a list of 3 NUMBER or PERCENTAGE tokens, return xyz-d65 + If args is a list of 3 NUMBER or PERCENTAGE tokens, return Oklab :class:`Color`. Otherwise, return None. + L range is [0, 100]. a, b ranges are [-0.4, 0.4]. + """ - values = [arg.value for arg in args] - for i in range(3): - if args[i].type == 'ident': - if args[i].lower_value == 'none': - values[i] = 0 - else: - return - elif args[i].type not in ('percentage', 'number'): - return - L = values[0] * (1 if args[0].type == 'number' else (1 / 100)) - a = values[1] * (1 if args[1].type == 'number' else (0.4 / 100)) - b = values[2] * (1 if args[2].type == 'number' else (0.4 / 100)) - args = [ - None if args[0].type == 'ident' else L, - None if args[1].type == 'ident' else a / 0.4, - None if args[2].type == 'ident' else b / 0.4, + if _types(args) > {'number', 'percentage'}: + return + coordinates = [ + None if args[0].type == 'ident' else ( + args[0].value * (1 if args[0].type == 'number' else 0.01)), + None if args[1].type == 'ident' else ( + args[1].value * (1 if args[1].type == 'number' else 0.004)), + None if args[2].type == 'ident' else ( + args[2].value * (1 if args[2].type == 'number' else 0.004)), ] - xyz = _oklab_to_xyz(L, a, b) - return Color('oklab', args, 'xyz-d65', xyz, alpha) + return Color('oklab', coordinates, alpha) def _parse_oklch(args, alpha): - """Parse a list of OKLCH channels. + """Parse a list of Oklch channels. If args is a list of 2 NUMBER or PERCENTAGE tokens and 1 NUMBER or ANGLE - token, return xyz-d65 :class:`Color`. Otherwise, return None. + token, return Oklch :class:`Color`. Otherwise, return None. + + L range is [0, 1]. C range is [0, 0.4]. H range is [0, 360). """ - if {args[0].type, args[1].type} > {'number', 'percentage'}: + if _types(args[:2]) > {'number', 'percentage'}: return - values = [arg.value for arg in args] - for i in range(2): - if args[i].type == 'ident': - if args[i].lower_value == 'none': - values[i] = 0 - else: - return - elif args[i].type not in ('percentage', 'number'): - return - L = values[0] * (1 if args[0].type == 'number' else (1 / 100)) - C = values[1] * (1 if args[1].type == 'number' else (0.4 / 100)) - H = _parse_hue(args[2]) - if H is None: + if (hue := _parse_hue(args[2])) is None: return - args = [ - None if args[0].type == 'ident' else L, - None if args[1].type == 'ident' else C / 0.4, - None if args[2].type == 'ident' else H, + coordinates = [ + None if args[0].type == 'ident' else ( + args[0].value * (1 if args[0].type == 'number' else 0.01)), + None if args[1].type == 'ident' else ( + args[1].value * (1 if args[1].type == 'number' else 0.004)), + None if args[0].type == 'ident' else hue, ] - a = C * cos(H * tau) - b = C * sin(H * tau) - xyz = _oklab_to_xyz(L, a, b) - return Color('oklch', args, 'xyz-d65', xyz, alpha) + return Color('oklch', coordinates, alpha) def _parse_color(space, args, alpha): - """Parse a color space name list of channels.""" - values = [arg.value for arg in args] - for i in range(3): - if args[i].type == 'ident': - if args[i].lower_value == 'none': - values[i] = 0 - else: - return - elif args[i].type == 'percentage': - values[i] /= 100 - elif args[i].type != 'number': - return - args = [ - None if args[0].type == 'ident' else values[0], - None if args[1].type == 'ident' else values[1], - None if args[2].type == 'ident' else values[2], - ] - if space.type == 'ident' and (space := space.lower_value) in SPACES: - return Color('color', args, space, values, alpha) + """Parse a color space name list of coordinates. + + Ranges are [0, 1]. + + """ + if space.type != 'ident' or (space := space.lower_value) not in _FUNCTION_SPACES: + return + if space == 'xyz': + space = 'xyz-d65' + coordinates = [ + arg.value if arg.type == 'number' else + arg.value / 100 if arg.type == 'percentage' else None + for arg in args] + return Color(space, coordinates, alpha) def _parse_hue(token): + """Parse hue token. + + Range is [0, 360). ``none`` value is 0. + + """ if token.type == 'number': - return token.value / 360 + return token.value % 360 elif token.type == 'dimension': if token.unit == 'deg': - return token.value / 360 + return token.value % 360 elif token.unit == 'grad': - return token.value / 400 + return token.value / 400 * 360 % 360 elif token.unit == 'rad': - return token.value / tau + return token.value / tau * 360 % 360 elif token.unit == 'turn': - return token.value + return token.value * 360 % 360 elif token.type == 'ident' and token.lower_value == 'none': return 0 +def _types(tokens): + """Get a set of token types, ignoring ``none`` values.""" + types = set() + for token in tokens: + if token.type == 'ident' and token.lower_value == 'none': + continue + types.add(token.type) + return types + + # (r, g, b) in 0..255 _EXTENDED_COLOR_KEYWORDS = _EXTENDED_COLOR_KEYWORDS.copy() _EXTENDED_COLOR_KEYWORDS.append(('rebeccapurple', (102, 51, 153))) From a9d5371c05a3fc239951b5666b4ea79ffd2fec90 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 2 Jul 2024 23:08:45 +0200 Subject: [PATCH 16/20] Update tests for color specifications --- tests/css-parsing-tests | 2 +- tests/test_tinycss2.py | 299 +++++++++++++++++++++++++++++----------- tinycss2/color3.py | 6 +- tinycss2/color4.py | 24 ++-- 4 files changed, 232 insertions(+), 99 deletions(-) diff --git a/tests/css-parsing-tests b/tests/css-parsing-tests index 30efc00..530ab15 160000 --- a/tests/css-parsing-tests +++ b/tests/css-parsing-tests @@ -1 +1 @@ -Subproject commit 30efc000d5931d7562815b5cd7001e4298563dde +Subproject commit 530ab150796b959240fb09eae3b764d8bae6d182 diff --git a/tests/test_tinycss2.py b/tests/test_tinycss2.py index 539114c..0df3582 100644 --- a/tests/test_tinycss2.py +++ b/tests/test_tinycss2.py @@ -1,7 +1,6 @@ import functools import json import pprint -from colorsys import hls_to_rgb from pathlib import Path import pytest @@ -105,7 +104,7 @@ def test(css, expected): return decorator -SKIP = dict(skip_comments=True, skip_whitespace=True) +SKIP = {'skip_comments': True, 'skip_whitespace': True} @json_test() @@ -153,110 +152,242 @@ def test_nth(input): return parse_nth(input) -@json_test(filename='color.json') -def test_color_parse3(input): - return parse_color3(input) +def _number(value): + if value is None: + return 'none' + value = round(value + 0.0000001, 6) + return str(int(value) if value.is_integer() else value) -@json_test(filename='color.json') -def test_color_common_parse3(input): - return parse_color3(input) +def test_color_currentcolor_3(): + for value in ('currentcolor', 'currentColor', 'CURRENTCOLOR'): + assert parse_color3(value) == 'currentColor' -@json_test(filename='color.json') -def test_color_common_parse4(input): - color = parse_color4(input) - if not color or color == 'currentColor': - return color - elif color.space == 'srgb': - return RGBA(*color) - elif color.space == 'hsl': - rgb = hls_to_rgb(color[0] / 360, color[2] / 100, color[1] / 100) - return RGBA(*rgb, color.alpha) +def test_color_currentcolor_4(): + for value in ('currentcolor', 'currentColor', 'CURRENTCOLOR'): + assert parse_color4(value) == 'currentcolor' + + +@json_test() +def test_color_function_4(input): + if not (color := parse_color4(input)): + return None + (*coordinates, alpha) = color + result = f'color({color.space}' + for coordinate in coordinates: + result += f' {_number(coordinate)}' + if alpha != 1: + result += f' / {_number(alpha)}' + result += ')' + return result @json_test() -def test_color3(input): - return parse_color3(input) +def test_color_hexadecimal_3(input): + if not (color := parse_color3(input)): + return None + (*coordinates, alpha) = color + result = f'rgb{"a" if alpha != 1 else ""}(' + result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}' + if alpha != 1: + result += f', {_number(alpha)}' + result += ')' + return result -@json_test(filename='color_hsl.json') -def test_color3_hsl(input): - return parse_color3(input) +@json_test() +def test_color_hexadecimal_4(input): + if not (color := parse_color4(input)): + return None + assert color.space == 'srgb' + (*coordinates, alpha) = color + result = f'rgb{"a" if alpha != 1 else ""}(' + result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}' + if alpha != 1: + result += f', {_number(alpha)}' + result += ')' + return result + + +@json_test(filename='color_hexadecimal_3.json') +def test_color_hexadecimal_3_with_4(input): + if not (color := parse_color4(input)): + return None + assert color.space == 'srgb' + (*coordinates, alpha) = color + result = f'rgb{"a" if alpha != 1 else ""}(' + result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}' + if alpha != 1: + result += f', {_number(alpha)}' + result += ')' + return result -@json_test(filename='color_hsl.json') -def test_color4_hsl(input): - color = parse_color4(input) +@json_test() +def test_color_hsl_3(input): + if not (color := parse_color3(input)): + return None + (*coordinates, alpha) = color + result = f'rgb{"a" if alpha != 1 else ""}(' + result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}' + if alpha != 1: + result += f', {_number(alpha)}' + result += ')' + return result + + +@json_test(filename='color_hsl_3.json') +def test_color_hsl_3_with_4(input): + if not (color := parse_color4(input)): + return None assert color.space == 'hsl' - rgb = hls_to_rgb(color[0] / 360, color[2] / 100, color[1] / 100) - return RGBA(*rgb, color.alpha) if (color and color != 'currentColor') else color + (*coordinates, alpha) = color.to('srgb') + result = f'rgb{"a" if alpha != 1 else ""}(' + result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}' + if alpha != 1: + result += f', {_number(alpha)}' + result += ')' + return result @json_test() -def test_color4_hwb(input): - color = parse_color4(input) +def test_color_hsl_4(input): + if not (color := parse_color4(input)): + return None + assert color.space == 'hsl' + (*coordinates, alpha) = color.to('srgb') + result = f'rgb{"a" if alpha != 1 else ""}(' + result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}' + if alpha != 1: + result += f', {_number(alpha)}' + result += ')' + return result + + +@json_test() +def test_color_hwb_4(input): + if not (color := parse_color4(input)): + return None assert color.space == 'hwb' - white, black = color[1:3] - if white + black >= 100: - rgb = (255 * white / (white + black),) * 3 - else: - rgb = hls_to_rgb(color[0] / 360, 0.5, 1) - rgb = (2.55 * ((channel * (100 - white - black)) + white) for channel in rgb) - rgb = (round(coordinate + 0.001) for coordinate in rgb) - coordinates = ', '.join( - str(int(coordinate) if coordinate.is_integer() else coordinate) - for coordinate in rgb) - if color.alpha == 0: - return f'rgba({coordinates}, 0)' - elif color.alpha == 1: - return f'rgb({coordinates})' - else: - return f'rgba({coordinates}, {color.alpha})' - return RGBA(*rgb, color.alpha) if (color and color != 'currentColor') else color + (*coordinates, alpha) = color.to('srgb') + result = f'rgb{"a" if alpha != 1 else ""}(' + result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}' + if alpha != 1: + result += f', {_number(alpha)}' + result += ')' + return result @json_test() -def test_color4_color_function(input): - color = parse_color4(input) - coordinates = ' '.join( - str(int(coordinate) if coordinate.is_integer() else round(coordinate, 3)) - for coordinate in color.coordinates) - if color.alpha == 0: - return f'color({color.space} {coordinates} / 0)' - elif color.alpha == 1: - return f'color({color.space} {coordinates})' - else: - return f'color({color.space} {coordinates} / {color.alpha})' +def test_color_keywords_3(input): + if not (color := parse_color3(input)): + return None + elif isinstance(color, str): + return color + (*coordinates, alpha) = color + result = f'rgb{"a" if alpha != 1 else ""}(' + result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}' + if alpha != 1: + result += f', {_number(alpha)}' + result += ')' + return result + + +@json_test(filename='color_keywords_3.json') +def test_color_keywords_3_with_4(input): + if not (color := parse_color4(input)): + return None + elif isinstance(color, str): + return color + assert color.space == 'srgb' + (*coordinates, alpha) = color + result = f'rgb{"a" if alpha != 1 else ""}(' + result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}' + if alpha != 1: + result += f', {_number(alpha)}' + result += ')' + return result @json_test() -def test_color4_lab_lch_oklab_oklch(input): - color = parse_color4(input) - coordinates = ' '.join( - str(int(coordinate) if coordinate.is_integer() else round(coordinate, 3)) - for coordinate in color.coordinates) - if color.alpha == 0: - return f'{color.space}({coordinates} / 0)' - elif color.alpha == 1: - return f'{color.space}({coordinates})' - else: - return f'{color.space}({coordinates} / {color.alpha})' - - -@pytest.mark.parametrize(('filename', 'parse_color'), ( - ('color_keywords.json', parse_color3), - ('color_keywords.json', parse_color4), - ('color3_keywords.json', parse_color3), - ('color4_keywords.json', parse_color4), -)) -def test_color_keywords(filename, parse_color): - for css, expected in load_json(filename): - result = parse_color(css) - if result is not None: - r, g, b, a = result - result = [r * 255, g * 255, b * 255, a] - assert result == expected +def test_color_keywords_4(input): + if not (color := parse_color4(input)): + return None + elif isinstance(color, str): + return color + assert color.space == 'srgb' + (*coordinates, alpha) = color + result = f'rgb{"a" if alpha != 1 else ""}(' + result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}' + if alpha != 1: + result += f', {_number(alpha)}' + result += ')' + return result + + +@json_test() +def test_color_lab_4(input): + if not (color := parse_color4(input)): + return None + elif isinstance(color, str): + return color + assert color.space == 'lab' + (*coordinates, alpha) = color + result = f'{color.space}(' + result += f'{" ".join(_number(coordinate) for coordinate in coordinates)}' + if alpha != 1: + result += f' / {_number(alpha)}' + result += ')' + return result + + +@json_test() +def test_color_oklab_4(input): + if not (color := parse_color4(input)): + return None + elif isinstance(color, str): + return color + assert color.space == 'oklab' + (*coordinates, alpha) = color + result = f'{color.space}(' + result += f'{" ".join(_number(coordinate) for coordinate in coordinates)}' + if alpha != 1: + result += f' / {_number(alpha)}' + result += ')' + return result + + +@json_test() +def test_color_lch_4(input): + if not (color := parse_color4(input)): + return None + elif isinstance(color, str): + return color + assert color.space == 'lch' + (*coordinates, alpha) = color + result = f'{color.space}(' + result += f'{" ".join(_number(coordinate) for coordinate in coordinates)}' + if alpha != 1: + result += f' / {_number(alpha)}' + result += ')' + return result + + +@json_test() +def test_color_oklch_4(input): + if not (color := parse_color4(input)): + return None + elif isinstance(color, str): + return color + assert color.space == 'oklch' + (*coordinates, alpha) = color + result = f'{color.space}(' + result += f'{" ".join(_number(coordinate) for coordinate in coordinates)}' + if alpha != 1: + result += f' / {_number(alpha)}' + result += ')' + return result @json_test() diff --git a/tinycss2/color3.py b/tinycss2/color3.py index 048d859..238c40a 100644 --- a/tinycss2/color3.py +++ b/tinycss2/color3.py @@ -113,14 +113,14 @@ def _parse_rgb(args, alpha): def _parse_hsl(args, alpha): """Parse a list of HSL channels. - If args is a list of 1 INTEGER token and 2 PERCENTAGE tokens, return RGB + If args is a list of 1 NUMBER token and 2 PERCENTAGE tokens, return RGB values as a tuple of 3 floats in 0..1. Otherwise, return None. """ types = [arg.type for arg in args] - if types == ['number', 'percentage', 'percentage'] and args[0].is_integer: + if types == ['number', 'percentage', 'percentage']: r, g, b = hls_to_rgb( - args[0].int_value / 360, args[2].value / 100, args[1].value / 100) + args[0].value / 360, args[2].value / 100, args[1].value / 100) return RGBA(r, g, b, alpha) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index df1b455..6296760 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -184,7 +184,7 @@ def parse_color(input): :returns: * :obj:`None` if the input is not a valid color value. (No exception is raised.) - * The string ``'currentColor'`` for the ``currentColor`` keyword + * The string ``'currentcolor'`` for the ``currentcolor`` keyword * A :class:`Color` object for every other values, including keywords. """ @@ -194,7 +194,7 @@ def parse_color(input): token = input if token.type == 'ident': if token.lower_value == 'currentcolor': - return 'currentColor' + return 'currentcolor' elif token.lower_value == 'transparent': return Color('srgb', (0, 0, 0), 0) elif color := _COLOR_KEYWORDS.get(token.lower_value): @@ -273,7 +273,7 @@ def _parse_rgb(args, alpha): Input R, G, B ranges are [0, 255], output are [0, 1]. """ - if _types(args) not in ({'number'}, {'percentage'}): + if _types(args) not in (set(), {'number'}, {'percentage'}): return coordinates = [ arg.value / 255 if arg.type == 'number' else @@ -291,7 +291,7 @@ def _parse_hsl(args, alpha): H range is [0, 360). S, L ranges are [0, 100]. """ - if _types(args[1:]) not in ({'number'}, {'percentage'}): + if _types(args[1:]) not in (set(), {'number'}, {'percentage'}): return if (hue := _parse_hue(args[0])) is None: return @@ -306,13 +306,13 @@ def _parse_hsl(args, alpha): def _parse_hwb(args, alpha): """Parse a list of HWB channels. - If args is a list of 1 NUMBER or ANGLE token and 2 PERCENTAGE tokens, - return HWB :class:`Color`. Otherwise, return None. + If args is a list of 1 NUMBER or ANGLE token and 2 NUMBER or PERCENTAGE + tokens, return HWB :class:`Color`. Otherwise, return None. H range is [0, 360). W, B ranges are [0, 100]. """ - if _types(args[1:]) > {'percentage'}: + if not _types(args[1:]) <= {'number', 'percentage'}: return if (hue := _parse_hue(args[0])) is None: return @@ -333,7 +333,7 @@ def _parse_lab(args, alpha): L range is [0, 100]. a, b ranges are [-125, 125]. """ - if _types(args) > {'number', 'percentage'}: + if not _types(args) <= {'number', 'percentage'}: return coordinates = [ None if args[0].type == 'ident' else args[0].value, @@ -354,7 +354,7 @@ def _parse_lch(args, alpha): L range is [0, 100]. C range is [0, 150]. H ranges is [0, 360). """ - if _types(args[:2]) > {'number', 'percentage'}: + if not _types(args[:2]) <= {'number', 'percentage'}: return if (hue := _parse_hue(args[2])) is None: return @@ -376,7 +376,7 @@ def _parse_oklab(args, alpha): L range is [0, 100]. a, b ranges are [-0.4, 0.4]. """ - if _types(args) > {'number', 'percentage'}: + if not _types(args) <= {'number', 'percentage'}: return coordinates = [ None if args[0].type == 'ident' else ( @@ -398,7 +398,7 @@ def _parse_oklch(args, alpha): L range is [0, 1]. C range is [0, 0.4]. H range is [0, 360). """ - if _types(args[:2]) > {'number', 'percentage'}: + if not _types(args[:2]) <= {'number', 'percentage'}: return if (hue := _parse_hue(args[2])) is None: return @@ -418,6 +418,8 @@ def _parse_color(space, args, alpha): Ranges are [0, 1]. """ + if not _types(args) <= {'number', 'percentage'}: + return if space.type != 'ident' or (space := space.lower_value) not in _FUNCTION_SPACES: return if space == 'xyz': From 95d681fa16e635a30e89a1dba5209713b3a78c4c Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Tue, 2 Jul 2024 23:34:20 +0200 Subject: [PATCH 17/20] =?UTF-8?q?Don=E2=80=99t=20use=20too=20recent=20math?= =?UTF-8?q?.cbrt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It’s been added in Python 3.11 (!) --- tinycss2/color4.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index 6296760..72387c4 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -1,5 +1,5 @@ from colorsys import hls_to_rgb -from math import cbrt, cos, sin, tau +from math import cos, sin, tau from .color3 import _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS from .parser import parse_one_component_value @@ -31,9 +31,9 @@ def _xyz_to_lab(X, Y, Z, d): x = X / d[0] y = Y / d[1] z = Z / d[2] - f0 = cbrt(x) if x > _ε else (_κ * x + 16) / 116 - f1 = cbrt(y) if y > _ε else (_κ * y + 16) / 116 - f2 = cbrt(z) if z > _ε else (_κ * z + 16) / 116 + f0 = x ** (1 / 3) if x > _ε else (_κ * x + 16) / 116 + f1 = y ** (1 / 3) if y > _ε else (_κ * y + 16) / 116 + f2 = z ** (1 / 3) if z > _ε else (_κ * z + 16) / 116 L = (116 * f1) - 16 a = 500 * (f0 - f1) b = 200 * (f1 - f2) From 56c498b5b1510e3ea8ad44b6830089a0599f83fa Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sat, 6 Jul 2024 17:07:46 +0200 Subject: [PATCH 18/20] Use degrees and radians functions instead of manual conversion --- tinycss2/color4.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index 72387c4..8369b4c 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -1,5 +1,5 @@ from colorsys import hls_to_rgb -from math import cos, sin, tau +from math import cos, degrees, radians, sin from .color3 import _BASIC_COLOR_KEYWORDS, _EXTENDED_COLOR_KEYWORDS, _HASH_REGEXPS from .parser import parse_one_component_value @@ -137,8 +137,8 @@ def to(self, space): xyz = _lab_to_xyz(*coordinates, D50) return Color(space, xyz, self.alpha) elif self.space == 'lch': - a = coordinates[1] * cos(coordinates[2] / 360 * tau) - b = coordinates[1] * sin(coordinates[2] / 360 * tau) + a = coordinates[1] * cos(radians(coordinates[2])) + b = coordinates[1] * sin(radians(coordinates[2])) xyz = _lab_to_xyz(coordinates[0], a, b, D50) return Color(space, xyz, self.alpha) elif space == 'xyz-d65': @@ -146,8 +146,8 @@ def to(self, space): xyz = _oklab_to_xyz(*coordinates) return Color(space, xyz, self.alpha) elif self.space == 'oklch': - a = coordinates[1] * cos(coordinates[2] / 360 * tau) - b = coordinates[1] * sin(coordinates[2] / 360 * tau) + a = coordinates[1] * cos(radians(coordinates[2])) + b = coordinates[1] * sin(radians(coordinates[2])) xyz = _oklab_to_xyz(coordinates[0], a, b) return Color(space, xyz, self.alpha) elif space == 'lab': @@ -158,16 +158,16 @@ def to(self, space): lab = _xyz_to_lab(*coordinates, D65) return Color(space, lab, self.alpha) elif self.space == 'lch': - a = coordinates[1] * cos(coordinates[2] / 360 * tau) - b = coordinates[1] * sin(coordinates[2] / 360 * tau) + a = coordinates[1] * cos(radians(coordinates[2])) + b = coordinates[1] * sin(radians(coordinates[2])) return Color(space, (coordinates[0], a, b), self.alpha) elif self.space == 'oklab': xyz = _oklab_to_xyz(*coordinates) lab = _xyz_to_lab(*xyz, D65) return Color(space, lab, self.alpha) elif self.space == 'oklch': - a = coordinates[1] * cos(coordinates[2] / 360 * tau) - b = coordinates[1] * sin(coordinates[2] / 360 * tau) + a = coordinates[1] * cos(radians(coordinates[2])) + b = coordinates[1] * sin(radians(coordinates[2])) xyz = _oklab_to_xyz(coordinates[0], a, b) lab = _xyz_to_lab(*xyz, D65) return Color(space, lab, self.alpha) @@ -445,7 +445,7 @@ def _parse_hue(token): elif token.unit == 'grad': return token.value / 400 * 360 % 360 elif token.unit == 'rad': - return token.value / tau * 360 % 360 + return degrees(token.value) % 360 elif token.unit == 'turn': return token.value * 360 % 360 elif token.type == 'ident' and token.lower_value == 'none': From a2f4d22b02d824cdc5742697d770f2390d04132d Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sat, 6 Jul 2024 17:08:05 +0200 Subject: [PATCH 19/20] Move color conversion code to the bottom of the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It’s internal undocumented code, Color is better higher in the code. --- tinycss2/color4.py | 93 +++++++++++++++++++++++----------------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index 8369b4c..fb29d1c 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -13,52 +13,6 @@ } COLOR_SPACES = _FUNCTION_SPACES | {'hsl', 'hwb', 'lab', 'lch', 'oklab', 'oklch'} -# Code adapted from https://www.w3.org/TR/css-color-4/#color-conversion-code. -_κ = 24389 / 27 -_ε = 216 / 24389 -_LMS_TO_XYZ = ( - (1.2268798733741557, -0.5578149965554813, 0.28139105017721583), - (-0.04057576262431372, 1.1122868293970594, -0.07171106666151701), - (-0.07637294974672142, -0.4214933239627914, 1.5869240244272418), -) -_OKLAB_TO_LMS = ( - (0.99999999845051981432, 0.39633779217376785678, 0.21580375806075880339), - (1.0000000088817607767, -0.1055613423236563494, -0.063854174771705903402), - (1.0000000546724109177, -0.089484182094965759684, -1.2914855378640917399), -) - -def _xyz_to_lab(X, Y, Z, d): - x = X / d[0] - y = Y / d[1] - z = Z / d[2] - f0 = x ** (1 / 3) if x > _ε else (_κ * x + 16) / 116 - f1 = y ** (1 / 3) if y > _ε else (_κ * y + 16) / 116 - f2 = z ** (1 / 3) if z > _ε else (_κ * z + 16) / 116 - L = (116 * f1) - 16 - a = 500 * (f0 - f1) - b = 200 * (f1 - f2) - return L, a, b - - -def _lab_to_xyz(L, a, b, d): - f1 = (L + 16) / 116 - f0 = a / 500 + f1 - f2 = f1 - b / 200 - x = (f0 ** 3 if f0 ** 3 > _ε else (116 * f0 - 16) / _κ) - y = (((L + 16) / 116) ** 3 if L > _κ * _ε else L / _κ) - z = (f2 ** 3 if f2 ** 3 > _ε else (116 * f2 - 16) / _κ) - X = x * d[0] - Y = y * d[1] - Z = z * d[2] - return X, Y, Z - - -def _oklab_to_xyz(L, a, b): - lab = (L, a, b) - lms = [sum(_OKLAB_TO_LMS[i][j] * lab[j] for j in range(3)) for i in range(3)] - X, Y, Z = [sum(_LMS_TO_XYZ[i][j] * lms[j]**3 for j in range(3)) for i in range(3)] - return X, Y, Z - class Color: """A specified color in a defined color space. @@ -462,6 +416,53 @@ def _types(tokens): return types +# Code adapted from https://www.w3.org/TR/css-color-4/#color-conversion-code. +_κ = 24389 / 27 +_ε = 216 / 24389 +_LMS_TO_XYZ = ( + (1.2268798733741557, -0.5578149965554813, 0.28139105017721583), + (-0.04057576262431372, 1.1122868293970594, -0.07171106666151701), + (-0.07637294974672142, -0.4214933239627914, 1.5869240244272418), +) +_OKLAB_TO_LMS = ( + (0.99999999845051981432, 0.39633779217376785678, 0.21580375806075880339), + (1.0000000088817607767, -0.1055613423236563494, -0.063854174771705903402), + (1.0000000546724109177, -0.089484182094965759684, -1.2914855378640917399), +) + +def _xyz_to_lab(X, Y, Z, d): + x = X / d[0] + y = Y / d[1] + z = Z / d[2] + f0 = x ** (1 / 3) if x > _ε else (_κ * x + 16) / 116 + f1 = y ** (1 / 3) if y > _ε else (_κ * y + 16) / 116 + f2 = z ** (1 / 3) if z > _ε else (_κ * z + 16) / 116 + L = (116 * f1) - 16 + a = 500 * (f0 - f1) + b = 200 * (f1 - f2) + return L, a, b + + +def _lab_to_xyz(L, a, b, d): + f1 = (L + 16) / 116 + f0 = a / 500 + f1 + f2 = f1 - b / 200 + x = (f0 ** 3 if f0 ** 3 > _ε else (116 * f0 - 16) / _κ) + y = (((L + 16) / 116) ** 3 if L > _κ * _ε else L / _κ) + z = (f2 ** 3 if f2 ** 3 > _ε else (116 * f2 - 16) / _κ) + X = x * d[0] + Y = y * d[1] + Z = z * d[2] + return X, Y, Z + + +def _oklab_to_xyz(L, a, b): + lab = (L, a, b) + lms = [sum(_OKLAB_TO_LMS[i][j] * lab[j] for j in range(3)) for i in range(3)] + X, Y, Z = [sum(_LMS_TO_XYZ[i][j] * lms[j]**3 for j in range(3)) for i in range(3)] + return X, Y, Z + + # (r, g, b) in 0..255 _EXTENDED_COLOR_KEYWORDS = _EXTENDED_COLOR_KEYWORDS.copy() _EXTENDED_COLOR_KEYWORDS.append(('rebeccapurple', (102, 51, 153))) From ebef89927bab3465e92e0ceac4ddf39c7b3aac8a Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Wed, 10 Jul 2024 20:10:49 +0200 Subject: [PATCH 20/20] List supported combinations for color space conversion --- tinycss2/color4.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tinycss2/color4.py b/tinycss2/color4.py index fb29d1c..4c78f1a 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -61,7 +61,12 @@ def to(self, space): ``None`` coordinates are always transformed into ``0`` values. - Many space combinations are not supported. + Here are the supported combinations: + + - from hsl and hwb to srgb; + - from lab and lch to xyz-d50; + - from oklab and oklch to xyz-d65; + - from xyz-d50, xyz-d65, lch, oklab and oklch to lab. """ coordinates = tuple(coordinate or 0 for coordinate in self.coordinates)