Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support CSS Color Module Level 4 #53

Merged
merged 20 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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']
2 changes: 1 addition & 1 deletion tests/css-parsing-tests
284 changes: 257 additions & 27 deletions tests/test_tinycss2.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@

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 # 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


def generic(func):
Expand Down Expand Up @@ -69,7 +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],
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,
],
}


Expand All @@ -93,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()
Expand Down Expand Up @@ -136,29 +147,247 @@ 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)


# 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
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)


def test_color_currentcolor_3():
for value in ('currentcolor', 'currentColor', 'CURRENTCOLOR'):
assert parse_color3(value) == 'currentColor'


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_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()
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()
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'
(*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)):
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

def test_color3_keywords():
for css, expected in load_json('color3_keywords.json'):
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

@json_test()
def test_color_hwb_4(input):
if not (color := parse_color4(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)):
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_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()
Expand Down Expand Up @@ -205,7 +434,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


Expand Down
11 changes: 6 additions & 5 deletions tinycss2/color3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://www.w3.org/TR/css-color-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`.
Expand Down Expand Up @@ -112,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)


Expand Down
Loading