Skip to content

Commit

Permalink
Merge pull request #53 from Kozea/color4
Browse files Browse the repository at this point in the history
Support CSS Color Module Level 4
  • Loading branch information
grewn0uille authored Oct 24, 2024
2 parents 5d54488 + ebef899 commit 58e4fa2
Show file tree
Hide file tree
Showing 5 changed files with 739 additions and 34 deletions.
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

0 comments on commit 58e4fa2

Please sign in to comment.