Skip to content

Commit 58e4fa2

Browse files
authored
Merge pull request #53 from Kozea/color4
Support CSS Color Module Level 4
2 parents 5d54488 + ebef899 commit 58e4fa2

File tree

5 files changed

+739
-34
lines changed

5 files changed

+739
-34
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,4 @@ extend-exclude = ['tests/css-parsing-tests']
6262

6363
[tool.ruff.lint]
6464
select = ['E', 'W', 'F', 'I', 'N', 'RUF']
65-
ignore = ['RUF001', 'RUF002', 'RUF003']
65+
ignore = ['RUF001', 'RUF002', 'RUF003', 'N803', 'N806']

tests/css-parsing-tests

tests/test_tinycss2.py

Lines changed: 257 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,19 @@
88

99
from tinycss2 import ( # isort:skip
1010
parse_blocks_contents, parse_component_value_list, parse_declaration_list,
11-
parse_one_component_value, parse_one_declaration, parse_one_rule, parse_rule_list,
12-
parse_stylesheet, parse_stylesheet_bytes, serialize)
11+
parse_one_component_value, parse_one_declaration, parse_one_rule,
12+
parse_rule_list, parse_stylesheet, parse_stylesheet_bytes, serialize)
1313
from tinycss2.ast import ( # isort:skip
14-
AtKeywordToken, AtRule, Comment, CurlyBracketsBlock, Declaration, DimensionToken,
15-
FunctionBlock, HashToken, IdentToken, LiteralToken, NumberToken, ParenthesesBlock,
16-
ParseError, PercentageToken, QualifiedRule, SquareBracketsBlock, StringToken,
17-
UnicodeRangeToken, URLToken, WhitespaceToken)
18-
from tinycss2.color3 import RGBA, parse_color
19-
from tinycss2.nth import parse_nth
14+
AtKeywordToken, AtRule, Comment, CurlyBracketsBlock, Declaration,
15+
DimensionToken, FunctionBlock, HashToken, IdentToken, LiteralToken,
16+
NumberToken, ParenthesesBlock, ParseError, PercentageToken, QualifiedRule,
17+
SquareBracketsBlock, StringToken, UnicodeRangeToken, URLToken,
18+
WhitespaceToken)
19+
from tinycss2.color3 import RGBA # isort:skip
20+
from tinycss2.color3 import parse_color as parse_color3 # isort:skip
21+
from tinycss2.color4 import Color # isort:skip
22+
from tinycss2.color4 import parse_color as parse_color4 # isort:skip
23+
from tinycss2.nth import parse_nth # isort:skip
2024

2125

2226
def generic(func):
@@ -69,7 +73,14 @@ def numeric(t):
6973
QualifiedRule: lambda r: [
7074
'qualified rule', to_json(r.prelude), to_json(r.content)],
7175

72-
RGBA: lambda v: [round(c, 10) for c in v],
76+
RGBA: lambda v: [round(c, 6) for c in v],
77+
Color: lambda v: [
78+
v.space,
79+
[round(c, 6) for c in v.params],
80+
v.function_name,
81+
[None if arg is None else round(arg, 6) for arg in v.args],
82+
v.alpha,
83+
],
7384
}
7485

7586

@@ -93,7 +104,7 @@ def test(css, expected):
93104
return decorator
94105

95106

96-
SKIP = dict(skip_comments=True, skip_whitespace=True)
107+
SKIP = {'skip_comments': True, 'skip_whitespace': True}
97108

98109

99110
@json_test()
@@ -136,29 +147,247 @@ def test_one_rule(input):
136147
return parse_one_rule(input, skip_comments=True)
137148

138149

139-
@json_test()
140-
def test_color3(input):
141-
return parse_color(input)
142-
143-
144150
@json_test(filename='An+B.json')
145151
def test_nth(input):
146152
return parse_nth(input)
147153

148154

149-
# Do not use @pytest.mark.parametrize because it is slow with that many values.
150-
def test_color3_hsl():
151-
for css, expected in load_json('color3_hsl.json'):
152-
assert to_json(parse_color(css)) == expected
155+
def _number(value):
156+
if value is None:
157+
return 'none'
158+
value = round(value + 0.0000001, 6)
159+
return str(int(value) if value.is_integer() else value)
160+
161+
162+
def test_color_currentcolor_3():
163+
for value in ('currentcolor', 'currentColor', 'CURRENTCOLOR'):
164+
assert parse_color3(value) == 'currentColor'
165+
166+
167+
def test_color_currentcolor_4():
168+
for value in ('currentcolor', 'currentColor', 'CURRENTCOLOR'):
169+
assert parse_color4(value) == 'currentcolor'
170+
171+
172+
@json_test()
173+
def test_color_function_4(input):
174+
if not (color := parse_color4(input)):
175+
return None
176+
(*coordinates, alpha) = color
177+
result = f'color({color.space}'
178+
for coordinate in coordinates:
179+
result += f' {_number(coordinate)}'
180+
if alpha != 1:
181+
result += f' / {_number(alpha)}'
182+
result += ')'
183+
return result
184+
153185

186+
@json_test()
187+
def test_color_hexadecimal_3(input):
188+
if not (color := parse_color3(input)):
189+
return None
190+
(*coordinates, alpha) = color
191+
result = f'rgb{"a" if alpha != 1 else ""}('
192+
result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}'
193+
if alpha != 1:
194+
result += f', {_number(alpha)}'
195+
result += ')'
196+
return result
197+
198+
199+
@json_test()
200+
def test_color_hexadecimal_4(input):
201+
if not (color := parse_color4(input)):
202+
return None
203+
assert color.space == 'srgb'
204+
(*coordinates, alpha) = color
205+
result = f'rgb{"a" if alpha != 1 else ""}('
206+
result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}'
207+
if alpha != 1:
208+
result += f', {_number(alpha)}'
209+
result += ')'
210+
return result
211+
212+
213+
@json_test(filename='color_hexadecimal_3.json')
214+
def test_color_hexadecimal_3_with_4(input):
215+
if not (color := parse_color4(input)):
216+
return None
217+
assert color.space == 'srgb'
218+
(*coordinates, alpha) = color
219+
result = f'rgb{"a" if alpha != 1 else ""}('
220+
result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}'
221+
if alpha != 1:
222+
result += f', {_number(alpha)}'
223+
result += ')'
224+
return result
225+
226+
227+
@json_test()
228+
def test_color_hsl_3(input):
229+
if not (color := parse_color3(input)):
230+
return None
231+
(*coordinates, alpha) = color
232+
result = f'rgb{"a" if alpha != 1 else ""}('
233+
result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}'
234+
if alpha != 1:
235+
result += f', {_number(alpha)}'
236+
result += ')'
237+
return result
238+
239+
240+
@json_test(filename='color_hsl_3.json')
241+
def test_color_hsl_3_with_4(input):
242+
if not (color := parse_color4(input)):
243+
return None
244+
assert color.space == 'hsl'
245+
(*coordinates, alpha) = color.to('srgb')
246+
result = f'rgb{"a" if alpha != 1 else ""}('
247+
result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}'
248+
if alpha != 1:
249+
result += f', {_number(alpha)}'
250+
result += ')'
251+
return result
252+
253+
254+
@json_test()
255+
def test_color_hsl_4(input):
256+
if not (color := parse_color4(input)):
257+
return None
258+
assert color.space == 'hsl'
259+
(*coordinates, alpha) = color.to('srgb')
260+
result = f'rgb{"a" if alpha != 1 else ""}('
261+
result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}'
262+
if alpha != 1:
263+
result += f', {_number(alpha)}'
264+
result += ')'
265+
return result
154266

155-
def test_color3_keywords():
156-
for css, expected in load_json('color3_keywords.json'):
157-
result = parse_color(css)
158-
if result is not None:
159-
r, g, b, a = result
160-
result = [r * 255, g * 255, b * 255, a]
161-
assert result == expected
267+
268+
@json_test()
269+
def test_color_hwb_4(input):
270+
if not (color := parse_color4(input)):
271+
return None
272+
assert color.space == 'hwb'
273+
(*coordinates, alpha) = color.to('srgb')
274+
result = f'rgb{"a" if alpha != 1 else ""}('
275+
result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}'
276+
if alpha != 1:
277+
result += f', {_number(alpha)}'
278+
result += ')'
279+
return result
280+
281+
282+
@json_test()
283+
def test_color_keywords_3(input):
284+
if not (color := parse_color3(input)):
285+
return None
286+
elif isinstance(color, str):
287+
return color
288+
(*coordinates, alpha) = color
289+
result = f'rgb{"a" if alpha != 1 else ""}('
290+
result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}'
291+
if alpha != 1:
292+
result += f', {_number(alpha)}'
293+
result += ')'
294+
return result
295+
296+
297+
@json_test(filename='color_keywords_3.json')
298+
def test_color_keywords_3_with_4(input):
299+
if not (color := parse_color4(input)):
300+
return None
301+
elif isinstance(color, str):
302+
return color
303+
assert color.space == 'srgb'
304+
(*coordinates, alpha) = color
305+
result = f'rgb{"a" if alpha != 1 else ""}('
306+
result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}'
307+
if alpha != 1:
308+
result += f', {_number(alpha)}'
309+
result += ')'
310+
return result
311+
312+
313+
@json_test()
314+
def test_color_keywords_4(input):
315+
if not (color := parse_color4(input)):
316+
return None
317+
elif isinstance(color, str):
318+
return color
319+
assert color.space == 'srgb'
320+
(*coordinates, alpha) = color
321+
result = f'rgb{"a" if alpha != 1 else ""}('
322+
result += f'{", ".join(_number(coordinate * 255) for coordinate in coordinates)}'
323+
if alpha != 1:
324+
result += f', {_number(alpha)}'
325+
result += ')'
326+
return result
327+
328+
329+
@json_test()
330+
def test_color_lab_4(input):
331+
if not (color := parse_color4(input)):
332+
return None
333+
elif isinstance(color, str):
334+
return color
335+
assert color.space == 'lab'
336+
(*coordinates, alpha) = color
337+
result = f'{color.space}('
338+
result += f'{" ".join(_number(coordinate) for coordinate in coordinates)}'
339+
if alpha != 1:
340+
result += f' / {_number(alpha)}'
341+
result += ')'
342+
return result
343+
344+
345+
@json_test()
346+
def test_color_oklab_4(input):
347+
if not (color := parse_color4(input)):
348+
return None
349+
elif isinstance(color, str):
350+
return color
351+
assert color.space == 'oklab'
352+
(*coordinates, alpha) = color
353+
result = f'{color.space}('
354+
result += f'{" ".join(_number(coordinate) for coordinate in coordinates)}'
355+
if alpha != 1:
356+
result += f' / {_number(alpha)}'
357+
result += ')'
358+
return result
359+
360+
361+
@json_test()
362+
def test_color_lch_4(input):
363+
if not (color := parse_color4(input)):
364+
return None
365+
elif isinstance(color, str):
366+
return color
367+
assert color.space == 'lch'
368+
(*coordinates, alpha) = color
369+
result = f'{color.space}('
370+
result += f'{" ".join(_number(coordinate) for coordinate in coordinates)}'
371+
if alpha != 1:
372+
result += f' / {_number(alpha)}'
373+
result += ')'
374+
return result
375+
376+
377+
@json_test()
378+
def test_color_oklch_4(input):
379+
if not (color := parse_color4(input)):
380+
return None
381+
elif isinstance(color, str):
382+
return color
383+
assert color.space == 'oklch'
384+
(*coordinates, alpha) = color
385+
result = f'{color.space}('
386+
result += f'{" ".join(_number(coordinate) for coordinate in coordinates)}'
387+
if alpha != 1:
388+
result += f' / {_number(alpha)}'
389+
result += ')'
390+
return result
162391

163392

164393
@json_test()
@@ -205,7 +434,8 @@ def test_parse_declaration_value_color():
205434
source = 'color:#369'
206435
declaration = parse_one_declaration(source)
207436
(value_token,) = declaration.value
208-
assert parse_color(value_token) == (.2, .4, .6, 1)
437+
assert parse_color3(value_token) == (.2, .4, .6, 1)
438+
assert parse_color4(value_token) == (.2, .4, .6, 1)
209439
assert declaration.serialize() == source
210440

211441

tinycss2/color3.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ class RGBA(collections.namedtuple('RGBA', ['red', 'green', 'blue', 'alpha'])):
3030

3131

3232
def parse_color(input):
33-
"""Parse a color value as defined in `CSS Color Level 3
34-
<https://www.w3.org/TR/css-color-3/>`_.
33+
"""Parse a color value as defined in CSS Color Level 3.
34+
35+
https://www.w3.org/TR/css-color-3/
3536
3637
:type input: :obj:`str` or :term:`iterable`
3738
:param input: A string or an iterable of :term:`component values`.
@@ -112,14 +113,14 @@ def _parse_rgb(args, alpha):
112113
def _parse_hsl(args, alpha):
113114
"""Parse a list of HSL channels.
114115
115-
If args is a list of 1 INTEGER token and 2 PERCENTAGE tokens, return RGB
116+
If args is a list of 1 NUMBER token and 2 PERCENTAGE tokens, return RGB
116117
values as a tuple of 3 floats in 0..1. Otherwise, return None.
117118
118119
"""
119120
types = [arg.type for arg in args]
120-
if types == ['number', 'percentage', 'percentage'] and args[0].is_integer:
121+
if types == ['number', 'percentage', 'percentage']:
121122
r, g, b = hls_to_rgb(
122-
args[0].int_value / 360, args[2].value / 100, args[1].value / 100)
123+
args[0].value / 360, args[2].value / 100, args[1].value / 100)
123124
return RGBA(r, g, b, alpha)
124125

125126

0 commit comments

Comments
 (0)