diff --git a/tests/css-parsing-tests b/tests/css-parsing-tests index 530ab15..88fc3fc 160000 --- a/tests/css-parsing-tests +++ b/tests/css-parsing-tests @@ -1 +1 @@ -Subproject commit 530ab150796b959240fb09eae3b764d8bae6d182 +Subproject commit 88fc3fc33986f835b6658b1b824b50c9550723f8 diff --git a/tests/test_tinycss2.py b/tests/test_tinycss2.py index 0df3582..4580062 100644 --- a/tests/test_tinycss2.py +++ b/tests/test_tinycss2.py @@ -20,6 +20,7 @@ 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.color5 import parse_color as parse_color5 # isort:skip from tinycss2.nth import parse_nth # isort:skip @@ -159,6 +160,19 @@ def _number(value): return str(int(value) if value.is_integer() else value) +def _build_color(color): + if color is None: + return + (*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 + + def test_color_currentcolor_3(): for value in ('currentcolor', 'currentColor', 'CURRENTCOLOR'): assert parse_color3(value) == 'currentColor' @@ -169,23 +183,48 @@ def test_color_currentcolor_4(): assert parse_color4(value) == 'currentcolor' +def test_color_currentcolor_5(): + for value in ('currentcolor', 'currentColor', 'CURRENTCOLOR'): + assert parse_color5(value) == 'currentcolor' + + @json_test() def test_color_function_4(input): - if not (color := parse_color4(input)): + return _build_color(parse_color4(input)) + + +@json_test(filename='color_function_4.json') +def test_color_function_4_with_5(input): + return _build_color(parse_color5(input)) + + +@json_test() +def test_color_functions_5(input): + if input.startswith('light-dark'): + result = [] + result.append(_build_color(parse_color5(input, ('light',)))) + result.append(_build_color(parse_color5(input, ('dark',)))) + else: + result = _build_color(parse_color5(input)) + return result + + +@json_test() +def test_color_hexadecimal_3(input): + if not (color := parse_color3(input)): return None (*coordinates, alpha) = color - result = f'color({color.space}' - for coordinate in coordinates: - result += f' {_number(coordinate)}' + 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 += f', {_number(alpha)}' result += ')' return result -@json_test() -def test_color_hexadecimal_3(input): - if not (color := parse_color3(input)): +@json_test(filename='color_hexadecimal_3.json') +def test_color_hexadecimal_3_with_4(input): + if not (color := parse_color4(input)): return None (*coordinates, alpha) = color result = f'rgb{"a" if alpha != 1 else ""}(' @@ -210,9 +249,23 @@ def test_color_hexadecimal_4(input): return result +@json_test(filename='color_hexadecimal_4.json') +def test_color_hexadecimal_4_with_5(input): + if not (color := parse_color5(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)): +def test_color_hexadecimal_3_with_5(input): + if not (color := parse_color5(input)): return None assert color.space == 'srgb' (*coordinates, alpha) = color @@ -251,6 +304,20 @@ def test_color_hsl_3_with_4(input): return result +@json_test(filename='color_hsl_3.json') +def test_color_hsl_3_with_5(input): + if not (color := parse_color5(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_hsl_4(input): if not (color := parse_color4(input)): @@ -265,6 +332,20 @@ def test_color_hsl_4(input): return result +@json_test(filename='color_hsl_4.json') +def test_color_hsl_4_with_5(input): + if not (color := parse_color5(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)): @@ -279,6 +360,20 @@ def test_color_hwb_4(input): return result +@json_test(filename='color_hwb_4.json') +def test_color_hwb_4_with_5(input): + if not (color := parse_color5(input)): + return None + assert color.space == 'hwb' + (*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_keywords_3(input): if not (color := parse_color3(input)): @@ -310,6 +405,22 @@ def test_color_keywords_3_with_4(input): return result +@json_test(filename='color_keywords_3.json') +def test_color_keywords_3_with_5(input): + if not (color := parse_color5(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_keywords_4(input): if not (color := parse_color4(input)): @@ -326,6 +437,22 @@ def test_color_keywords_4(input): return result +@json_test(filename='color_keywords_4.json') +def test_color_keywords_4_with_5(input): + if not (color := parse_color5(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)): @@ -342,6 +469,22 @@ def test_color_lab_4(input): return result +@json_test(filename='color_lab_4.json') +def test_color_lab_4_with_5(input): + if not (color := parse_color5(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)): @@ -358,6 +501,22 @@ def test_color_oklab_4(input): return result +@json_test(filename='color_oklab_4.json') +def test_color_oklab_4_with_5(input): + if not (color := parse_color5(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)): @@ -374,6 +533,22 @@ def test_color_lch_4(input): return result +@json_test(filename='color_lch_4.json') +def test_color_lch_4_with_5(input): + if not (color := parse_color5(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)): @@ -390,6 +565,22 @@ def test_color_oklch_4(input): return result +@json_test(filename='color_oklch_4.json') +def test_color_oklch_4_with_5(input): + if not (color := parse_color5(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() def test_stylesheet_bytes(kwargs): kwargs['css_bytes'] = kwargs['css_bytes'].encode('latin1') diff --git a/tinycss2/color4.py b/tinycss2/color4.py index 4c78f1a..dd4508e 100644 --- a/tinycss2/color4.py +++ b/tinycss2/color4.py @@ -23,8 +23,11 @@ class Color: to [0, 1]. Coordinates can also be set to ``None`` when undefined. """ + COLOR_SPACES = COLOR_SPACES + def __init__(self, space, coordinates, alpha): - assert space in COLOR_SPACES, f"{space} is not a supported color space" + if self.COLOR_SPACES: + assert space in self.COLOR_SPACES, f"{space} is not a supported color space" self.space = space self.coordinates = tuple( None if coordinate is None else float(coordinate) diff --git a/tinycss2/color5.py b/tinycss2/color5.py new file mode 100644 index 0000000..823fba5 --- /dev/null +++ b/tinycss2/color5.py @@ -0,0 +1,115 @@ +from . import color4 + +COLOR_SPACES = color4.COLOR_SPACES | {'device-cmyk'} +COLOR_SCHEMES = {'light', 'dark'} +D50 = color4.D50 +D65 = color4.D65 + + +class Color(color4.Color): + COLOR_SPACES = None + + +def parse_color(input, color_schemes=None): + color = color4.parse_color(input) + + if color: + return color + + if color_schemes is None or color_schemes == 'normal': + color_scheme = 'light' + else: + for color_scheme in color_schemes: + if color_scheme in COLOR_SCHEMES: + break + else: + color_scheme = 'light' + + if isinstance(input, str): + token = color4.parse_one_component_value(input, skip_comments=True) + else: + token = input + + if token.type == 'function': + tokens = [ + token for token in token.arguments + if token.type not in ('whitespace', 'comment')] + name = token.lower_name + alpha = [] + + if name == 'color': + space, *tokens = tokens + + old_syntax = all(token == ',' for token in tokens[1::2]) + if old_syntax: + tokens = tokens[::2] + else: + for index, token in enumerate(tokens): + if token == '/': + alpha = tokens[index + 1:] + tokens = tokens[:index] + break + + if name == 'device-cmyk': + return _parse_device_cmyk(tokens, color4._parse_alpha(alpha), old_syntax) + elif name == 'color': + return _parse_color(space, tokens, color4._parse_alpha(alpha)) + elif name == 'light-dark': + return _parse_light_dark(tokens, color_scheme) + else: + return + + +def _parse_device_cmyk(args, alpha, old_syntax): + """Parse a list of CMYK channels. + + If args is a list of 4 NUMBER or PERCENTAGE tokens, return + device-cmyk :class:`Color`. Otherwise, return None. + + Input C, M, Y, K ranges are [0, 1], output are [0, 1]. + + """ + if old_syntax: + if color4._types(args) != {'number'}: + return + else: + if not color4._types(args) <= {'number', 'percentage'}: + return + if len(args) != 4: + return + cmyk = [ + arg.value if arg.type == 'number' else + arg.value / 100 if arg.type == 'percentage' else None + for arg in args] + cmyk = [max(0., min(1., float(channel))) for channel in cmyk] + return Color('device-cmyk', cmyk, alpha) + + +def _parse_light_dark(args, color_scheme): + colors = [] + for arg in args: + if color := parse_color(arg, color_scheme): + colors.append(color) + if len(colors) == 2: + if color_scheme == 'light': + return colors[0] + else: + return colors[1] + return + + +def _parse_color(space, args, alpha): + """Parse a color space name list of coordinates. + + Ranges are [0, 1]. + + """ + if not color4._types(args) <= {'number', 'percentage'}: + return + if space.type != 'ident' or not space.value.startswith('--'): + return + coordinates = [ + arg.value if arg.type == 'number' else + arg.value / 100 if arg.type == 'percentage' else None + for arg in args] + return Color(space.value, coordinates, alpha)