aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Touhey <thomas@touhey.fr>2022-01-01 10:06:22 +0100
committerThomas Touhey <thomas@touhey.fr>2022-01-01 11:53:32 +0100
commitabbfaaff3ea7d19a36933898ec2c11df00b6f4c5 (patch)
tree243a7dedf152c67350c33bc0393436afe0d54d46
parentbbef529d1b7c52f09be399ca27f86d36d1267d50 (diff)
Fixed ncol parsing to have dynamic percentages
-rwxr-xr-xtests/test_decoders.py40
-rw-r--r--thcolor/builtin.py21
-rw-r--r--thcolor/decoders.py269
-rw-r--r--thcolor/utils.py32
4 files changed, 238 insertions, 124 deletions
diff --git a/tests/test_decoders.py b/tests/test_decoders.py
index 975af04..2f89316 100755
--- a/tests/test_decoders.py
+++ b/tests/test_decoders.py
@@ -5,6 +5,8 @@
# *****************************************************************************
""" Unit tests for the thcolor color decoding and management module. """
+from typing import Any
+
import pytest
from thcolor.angles import * # NOQA
from thcolor.colors import * # NOQA
@@ -98,6 +100,15 @@ class TestNColDecoder:
__ncol_support__ = True
__defaults_to_netscape_color__ = False
+ def sum2(a: int, b: int) -> int:
+ return a + b
+
+ def sum5(a: int, b: int, c: int, d: int, e: int) -> int:
+ return a + b + c + d + e
+
+ def col(a) -> Any:
+ return a
+
return Decoder()
@pytest.mark.parametrize('test_input,expected', (
@@ -106,9 +117,29 @@ class TestNColDecoder:
HWBColor(DegreesAngle(252), .5, .32, 1.00),
),
(
+ 'B20 / / 16%',
+ HWBColor(DegreesAngle(252), 0, .16, 1.00),
+ ),
+ (
'Y40, 33%, 55%',
HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00),
),
+ (
+ 'Y40, sum2(35, -2), 55%',
+ HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00),
+ ),
+ (
+ 'Y40, sum5(1, 1, 1, 1, 29), 55%',
+ HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00),
+ ),
+ (
+ 'Y40',
+ HWBColor(DegreesAngle(84), 0, 0),
+ ),
+ (
+ 'col(Y40)',
+ HWBColor(DegreesAngle(84), 0, 0),
+ ),
))
def test_ncol(self, decoder, test_input, expected):
""" Test natural colors. """
@@ -116,6 +147,10 @@ class TestNColDecoder:
result = decoder.decode(test_input)
assert result == (expected,)
+ def test_invalid_ncol(self, decoder):
+ with pytest.raises(ColorExpressionSyntaxError, match=r'unknown value'):
+ decoder.decode('Y105, 50%, 50%')
+
class TestNetscapeDecoder:
""" Test base decoder with netscape color support. """
@@ -196,7 +231,10 @@ class TestDefaultDecoder:
'desaturate(10%, hls(0turn, 1, 5%, 0.2))',
HSLColor(DegreesAngle(0), 0.00, 1.00, 0.20),
),
- ('rgba(255, 0, 0, 20 %)', HSLColor(DegreesAngle(0), 1.00, 0.50, 0.20)),
+ (
+ 'rgba(255, 0, 0, 20 %)',
+ HSLColor(DegreesAngle(0), 1.00, 0.50, 0.20),
+ ),
(
'Y40, 33%, 55%',
HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00),
diff --git a/thcolor/builtin.py b/thcolor/builtin.py
index d3f42cf..077cf09 100644
--- a/thcolor/builtin.py
+++ b/thcolor/builtin.py
@@ -18,6 +18,7 @@ from .colors import (
LCHColor as _LCHColor, SRGBColor as _SRGBColor, XYZColor as _XYZColor,
YIQColor as _YIQColor, YUVColor as _YUVColor,
)
+from .utils import factor as _factor, rgb as _rgb
__all__ = [
'CSS1ColorDecoder', 'CSS2ColorDecoder', 'CSS3ColorDecoder',
@@ -27,26 +28,6 @@ __all__ = [
_number = _Union[int, float]
-def _rgb(x):
- """ Return an RGB color out of the given 6-digit hexadecimal code. """
-
- return _SRGBColor(
- int(x[1:3], 16) / 255,
- int(x[3:5], 16) / 255,
- int(x[5:7], 16) / 255,
- )
-
-
-def _factor(x, max_: int = 100):
- """ Return a factor based on if something is a float or an int. """
-
- if isinstance(x, float):
- return x
- if x in (0, 1) and max_ == 100:
- return float(x)
- return x / max_
-
-
# ---
# Main colors.
# ---
diff --git a/thcolor/decoders.py b/thcolor/decoders.py
index 9cda965..3aae9cc 100644
--- a/thcolor/decoders.py
+++ b/thcolor/decoders.py
@@ -15,6 +15,7 @@ from typing import (
Sequence as _Sequence, Tuple as _Tuple, List as _List,
)
from inspect import getfullargspec as _getfullargspec
+from itertools import zip_longest as _zip_longest
from .angles import Angle as _Angle
from .colors import (
@@ -25,6 +26,7 @@ from .angles import (
RadiansAngle as _RadiansAngle, TurnsAngle as _TurnsAngle,
)
from .errors import ColorExpressionSyntaxError as _ColorExpressionSyntaxError
+from .utils import factor as _factor
__all__ = [
'ColorDecoder', 'MetaColorDecoder',
@@ -40,14 +42,6 @@ def _canonicalkey(x):
return str(x).replace('-', '_').casefold()
-def _factor(x, max_=100):
- """ Return a factor based on if something is a float or an int. """
-
- if isinstance(x, float):
- return x
- return x / max_
-
-
def _issubclass(cls, types):
""" Check if ``cls`` is a subclass of ``types``.
@@ -110,6 +104,8 @@ def _get_args(func) -> _Sequence[_Tuple[str, _Any, _Any]]:
value, the function will raise an exception.
"""
+ # TODO: manage functions with variable positional arguments.
+
argspec = _getfullargspec(func)
try:
kwarg = next(
@@ -259,6 +255,18 @@ def _make_function(func, name: str, args: _Optional[_Sequence[_Any]]):
return func
+def _ncol_func(
+ angle: _Angle,
+ whiteness: _Union[int, float] = 0,
+ blackness: _Union[int, float] = 0,
+):
+ return _HWBColor(
+ hue=angle,
+ whiteness=_factor(whiteness),
+ blackness=_factor(blackness),
+ )
+
+
# ---
# Lexer.
# ---
@@ -460,12 +468,24 @@ def _get_color_tokens(string: str):
rawtext=result['flt_val'],
)
elif result['ncol'] is not None:
- yield _ColorExpressionToken(
- _ColorExpressionToken.TYPE_NCOL,
- value=result['ncol'],
- column=column,
- rawtext=result['ncol'],
- )
+ letter = result['ncol'][0]
+ number = float(result['ncol'][1:])
+
+ if number < 0 or number >= 100:
+ yield _ColorExpressionToken(
+ _ColorExpressionToken.TYPE_NAME,
+ value=result['ncol'],
+ column=column,
+ )
+ else:
+ yield _ColorExpressionToken(
+ _ColorExpressionToken.TYPE_NCOL,
+ value=_DegreesAngle(
+ 'RYGCBM'.find(letter) * 60 + number / 100 * 60
+ ),
+ column=column,
+ rawtext=result['ncol'],
+ )
elif result['hex'] is not None:
value_s = result['hex']
if len(value_s) <= 4:
@@ -660,13 +680,30 @@ class ColorDecoder(_Mapping):
defaults_to_netscape = bool(self._defaults_to_netscape_color)
# Parsing stage; the results will be in ``current``.
+ #
+ # * ``stack``: the stack of current values.
+ # * ``func_stack``: the function name or special type, as tokens.
+ # Accepted token types here are ``NAME`` and ``NCOL``.
+ # * ``implicit_stack``: when the current function is an implicit
+ # one, this stack contains the arguments left to read in this
+ # implicit function.
+ # * ``current``: the current list of elements, which is put on
+ # top of the stack when a call is started and is popped out of
+ # the top of the stack when a call is ended.
stack: _List[_List[_ColorExpressionToken]] = []
- func_stack = [_ColorExpressionToken(
- _ColorExpressionToken.TYPE_NAME,
- value=None,
- )]
- current = []
+ func_stack: _List[_ColorExpressionToken] = []
+ implicit_stack: _List[int] = []
+ current: _List[_ColorExpressionToken] = []
+
+ def _get_parent_func():
+ """ Get the parent function name for exceptions. """
+
+ if not func_stack:
+ return None
+ if func_stack[0].type_ == _ColorExpressionToken.TYPE_NCOL:
+ return '<implicit ncol function>'
+ return func_stack[0].value
token_iter = _get_color_tokens(expr)
for token in token_iter:
@@ -674,59 +711,10 @@ class ColorDecoder(_Mapping):
token.type_ == _ColorExpressionToken.TYPE_NCOL
and ncol_support
):
- letter = token.value[0]
- number = float(token.value[1:])
-
- if number < 0 or number >= 100:
- raise _ColorExpressionSyntaxError(
- 'ncol number should be between 0 and 100 excluded, '
- f'is {number!r}',
- column=token.column,
- func=func_stack[0].value,
- )
-
- # Get the two next tokens, which should be numbers.
- # Note that they cannot result from function calls, they
- # should be defined in the syntax directly.
- #
- # TODO: make this an implicit function with two arguments,
- # so that percentages can be determined dynamically.
- # TODO: if the NColor is not valid, maybe it is a symbol
- # name, in which case a default should be set.
-
- first_token, second_token = None, None
- try:
- first_token = next(token_iter)
- second_token = next(token_iter)
- except StopIteration:
- break
-
- if first_token is None or second_token is None or any(
- token.type_ not in (
- _ColorExpressionToken.TYPE_INTEGER,
- _ColorExpressionToken.TYPE_FLOAT,
- _ColorExpressionToken.TYPE_PERCENTAGE,
- )
- for token in (first_token, second_token)
- ):
- raise _ColorExpressionSyntaxError(
- 'ncol should be followed by the whiteness and '
- 'the blackness as two constant values',
- column=token.column,
- func=func_stack[0].value,
- )
-
- current.append(_ColorExpressionToken(
- type_=_ColorExpressionToken.TYPE_HEX,
- column=token.column,
- value=_HWBColor(
- hue=_DegreesAngle(
- 'RYGCBM'.find(letter) * 60 + number / 100 * 60
- ),
- whiteness=_factor(first_token.value),
- blackness=_factor(second_token.value),
- ),
- ))
+ func_stack.insert(0, token)
+ stack.insert(0, current)
+ implicit_stack.insert(0, 3)
+ current = []
elif token.type_ == _ColorExpressionToken.TYPE_NCOL:
current.append(_ColorExpressionToken(
_ColorExpressionToken.TYPE_NAME,
@@ -747,29 +735,55 @@ class ColorDecoder(_Mapping):
current.append(token)
elif token.type_ == _ColorExpressionToken.TYPE_CALL_START:
+ if func_stack and (
+ func_stack[0].type_ == _ColorExpressionToken.TYPE_NCOL
+ ):
+ implicit_stack[0] += 1
+
name_token = current.pop(-1)
if name_token.type_ != _ColorExpressionToken.TYPE_NAME:
raise _ColorExpressionSyntaxError(
'expected the name of the function to call, '
f'got a {name_token.type_.name}',
column=name_token.column,
- func=func_stack[0].value,
+ func=_get_parent_func(),
)
func_stack.insert(0, name_token)
stack.insert(0, current)
current = []
elif token.type_ == _ColorExpressionToken.TYPE_CALL_END:
- try:
+ # First, pop out all of the implicit functions.
+
+ while func_stack:
+ try:
+ name_token = func_stack.pop(0)
+ except IndexError:
+ raise _ColorExpressionSyntaxError(
+ 'extraneous closing parenthesis',
+ column=token.column,
+ func=_get_parent_func(),
+ ) from None
+
+ if name_token.type_ == _ColorExpressionToken.TYPE_NAME:
+ break
+
+ # We just pop the function out of the stack with the
+ # arguments we have.
+
+ implicit_stack.pop(0)
old_current = stack.pop(0)
- except IndexError:
- raise _ColorExpressionSyntaxError(
- 'extraneous closing parenthesis',
- column=token.column,
- func=func_stack[0].value,
- ) from None
+ old_current.append(_ColorExpressionToken(
+ type_=_ColorExpressionToken.TYPE_CALL,
+ column=name_token.column,
+ name=_ncol_func,
+ value=(name_token, *current),
+ ))
+ current = old_current
- name_token = func_stack.pop(0)
+ # We have a function name and it is not implicit.
+
+ old_current = stack.pop(0)
old_current.append(_ColorExpressionToken(
_ColorExpressionTokenType.CALL,
name=name_token.value,
@@ -782,12 +796,55 @@ class ColorDecoder(_Mapping):
f'unknown token type: {token.type_!r}',
)
- if stack:
- raise _ColorExpressionSyntaxError(
- 'missing closing parenthesis',
- column=len(expr),
- func=func_stack[0].value,
- )
+ if func_stack and (
+ func_stack[0].type_ == _ColorExpressionToken.TYPE_NCOL
+ ):
+ implicit_stack[0] -= 1
+ if implicit_stack[0] <= 0:
+ ncol = func_stack.pop(0)
+ implicit_stack.pop(0)
+
+ # We have the required number of tokens for this
+ # to work.
+
+ old_current = stack.pop(0)
+ old_current.append(_ColorExpressionToken(
+ type_=_ColorExpressionToken.TYPE_CALL,
+ column=ncol.column,
+ name=_ncol_func,
+ value=(ncol, *current),
+ ))
+ current = old_current
+
+ # We want to pop out all implicit functions we have.
+ # If we still have an explicit function in the function stack,
+ # that means a parenthesis was omitted.
+
+ while func_stack:
+ try:
+ name_token = func_stack.pop(0)
+ except IndexError:
+ break
+
+ if name_token.type_ == _ColorExpressionToken.TYPE_NAME:
+ raise _ColorExpressionSyntaxError(
+ 'missing closing parenthesis',
+ column=len(expr),
+ func=_get_parent_func(),
+ )
+
+ # We just pop the function out of the stack with the
+ # arguments we have.
+
+ implicit_stack.pop(0)
+ old_current = stack.pop(0)
+ old_current.append(_ColorExpressionToken(
+ type_=_ColorExpressionToken.TYPE_CALL,
+ column=name_token.column,
+ name=_ncol_func,
+ value=(name_token, *current),
+ ))
+ current = old_current
# Evaluating stage.
@@ -847,15 +904,18 @@ class ColorDecoder(_Mapping):
return (element.value, element.rawtext)
func_name = element.name
-
- try:
- func = self[func_name]
- except KeyError:
- raise _ColorExpressionSyntaxError(
- f'function {func_name!r} not found',
- column=element.column,
- func=parent_func,
- )
+ if callable(func_name):
+ func = func_name
+ func_name = '<implicit ncol function>'
+ else:
+ try:
+ func = self[func_name]
+ except KeyError:
+ raise _ColorExpressionSyntaxError(
+ f'function {func_name!r} not found',
+ column=element.column,
+ func=parent_func,
+ )
# Check the function.
@@ -895,16 +955,19 @@ class ColorDecoder(_Mapping):
new_args = []
- for token, (argname, argtype, argdefvalue) in zip(
- unevaluated_args, args_spec
+ for token, (argname, argtype, argdefvalue) in _zip_longest(
+ unevaluated_args, args_spec, fillvalue=None,
):
- arg, *_, fallback_string = evaluate(token)
- arg = arg if arg is not None else argdefvalue
+ arg, fallback_string, column = None, None, element.column
+ if token is not None:
+ column = token.column
+ arg, *_, fallback_string = evaluate(token)
- if arg is None:
+ arg = arg if arg is not None else argdefvalue
+ if arg is _NO_DEFAULT_VALUE:
raise _ColorExpressionSyntaxError(
f'expected a value for {argname!r}',
- column=token.column,
+ column=column,
func=func_name,
)
@@ -924,7 +987,7 @@ class ColorDecoder(_Mapping):
raise _ColorExpressionSyntaxError(
f'{arg!r} did not match expected type {argtype!r}'
f' for argument {argname!r}',
- column=token.column,
+ column=column,
func=func_name,
)
diff --git a/thcolor/utils.py b/thcolor/utils.py
new file mode 100644
index 0000000..cf7173d
--- /dev/null
+++ b/thcolor/utils.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+# *****************************************************************************
+# Copyright (C) 2022 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the thcolor project, which is MIT-licensed.
+# *****************************************************************************
+""" Utilities for the thcolor module. """
+
+from thcolor.colors import SRGBColor as _SRGBColor
+
+__all__ = ['factor', 'rgb']
+
+
+def rgb(x):
+ """ Return an RGB color out of the given 6-digit hexadecimal code. """
+
+ return _SRGBColor(
+ int(x[1:3], 16) / 255,
+ int(x[3:5], 16) / 255,
+ int(x[5:7], 16) / 255,
+ )
+
+
+def factor(x, max_: int = 100):
+ """ Return a factor based on if something is a float or an int. """
+
+ if isinstance(x, float):
+ return x
+ if x in (0, 1) and max_ == 100:
+ return float(x)
+ return x / max_
+
+# End of file.