diff --git a/tests/test_tinycss2.py b/tests/test_tinycss2.py index 76b08be..dc3500f 100644 --- a/tests/test_tinycss2.py +++ b/tests/test_tinycss2.py @@ -16,6 +16,7 @@ WhitespaceToken) from tinycss2.color3 import RGBA from tinycss2.color3 import parse_color as parse_color3 +from tinycss2.color4 import Color from tinycss2.color4 import parse_color as parse_color4 from tinycss2.nth import parse_nth from webencodings import Encoding, lookup @@ -58,7 +59,7 @@ def numeric(t): NumberToken: lambda t: ['number'] + numeric(t), PercentageToken: lambda t: ['percentage'] + numeric(t), DimensionToken: lambda t: ['dimension'] + numeric(t) + [t.unit], - UnicodeRangeToken: lambda t: ['unicode-range', t.start, t.end], + UnicodeRangeToken: lambda t: ['urange', t.start, t.end], CurlyBracketsBlock: lambda t: ['{}'] + to_json(t.content), SquareBracketsBlock: lambda t: ['[]'] + to_json(t.content), @@ -73,6 +74,7 @@ def numeric(t): to_json(r.content)], RGBA: lambda v: [round(c, 10) for c in v], + Color: lambda v: [round(c, 10) for c in v], } @@ -139,40 +141,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), +)