aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Touhey <thomas@touhey.fr>2022-01-02 16:36:16 +0100
committerThomas Touhey <thomas@touhey.fr>2022-02-02 22:12:58 +0100
commit3708f0dd0bc98d9688fcf0dada282c12d0cecd95 (patch)
treeca135a1b76b72d2ae1315513806d352c11aca8eb
parent4a947057b709c6536f6a940645254bf0feadcd8c (diff)
Adding in more tests and fixing a few style issues
-rw-r--r--.gitignore2
-rw-r--r--.python-version1
-rw-r--r--docs/discuss/color-expressions.rst4
-rw-r--r--docs/guides/defining-decoders.rst3
-rwxr-xr-xtests/test_angles.py14
-rwxr-xr-xtests/test_builtin.py74
-rwxr-xr-xtests/test_colors.py204
-rwxr-xr-xtests/test_decoders.py100
-rw-r--r--thcolor/angles.py16
-rw-r--r--thcolor/builtin.py12
-rw-r--r--thcolor/colors.py175
-rw-r--r--thcolor/decoders.py101
-rw-r--r--thcolor/utils.py11
13 files changed, 537 insertions, 180 deletions
diff --git a/.gitignore b/.gitignore
index fceabbb..dd52273 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,10 +3,12 @@ __pycache__
/*.egg-info
/dist
/.spyproject
+/*.kdev4
/build
/docs/_build
/venv
/README.html
/.pytest_cache
+/.python-version
/docs/_build
diff --git a/.python-version b/.python-version
deleted file mode 100644
index f870be2..0000000
--- a/.python-version
+++ /dev/null
@@ -1 +0,0 @@
-3.10.1
diff --git a/docs/discuss/color-expressions.rst b/docs/discuss/color-expressions.rst
index 0bd3d89..f29578e 100644
--- a/docs/discuss/color-expressions.rst
+++ b/docs/discuss/color-expressions.rst
@@ -22,7 +22,7 @@ argument types:
* Angles.
* Colors.
-These elements are separated by separators (either commas, slashes, or simple
+These elements are separated by separators (either commas, slashes, or
spaces) and can be passed to functions, and the calls themselves can be passed
to other functions. A function call is made in the following fashion:
@@ -30,7 +30,7 @@ to other functions. A function call is made in the following fashion:
<function name>([<number | percentage | angle | color> [<separator> …]])
-If at least one separator (even simple spaces) are required between arguments,
+If at least one separator (even spaces) are required between arguments,
extraneous separators between and after the arguments are ignored. Other than
if spaces are used as separators, spaces around the parenthesis or the
separators (and "between" the separators as spaces are recognized as
diff --git a/docs/guides/defining-decoders.rst b/docs/guides/defining-decoders.rst
index 51bab99..c9c1da5 100644
--- a/docs/guides/defining-decoders.rst
+++ b/docs/guides/defining-decoders.rst
@@ -74,8 +74,7 @@ numbers; the type of these numbers is not given, so we have three options:
function will receive both depending on what the syntax was in the
original expression.
-Here, for simplicity, we choose to use ``float``. The resulting code is
-the following:
+Here, we choose to use ``float``. The resulting code is the following:
.. code-block::
diff --git a/tests/test_angles.py b/tests/test_angles.py
index 4408431..6309c96 100755
--- a/tests/test_angles.py
+++ b/tests/test_angles.py
@@ -11,7 +11,9 @@ import pytest
from thcolor.angles import * # NOQA
-class TestAngle:
+class TestAngles:
+ """ Test angle definitions and conversions. """
+
@pytest.mark.parametrize('fst,snd', (
(GradiansAngle(.1), GradiansAngle(.2)),
))
@@ -41,5 +43,15 @@ class TestAngle:
assert isinstance(angle, type(expected))
assert angle == expected
+ @pytest.mark.parametrize('rad,deg', (
+ (0, 0),
+ (-pi / 2, 270),
+ (pi / 2, 90),
+ (3 * pi / 4, 135),
+ (-3 * pi / 4, 225),
+ ))
+ def test_radians_to_degrees(self, rad, deg):
+ return RadiansAngle(rad).asprincipal().asdegrees().degrees == deg
+
# End of file.
diff --git a/tests/test_builtin.py b/tests/test_builtin.py
new file mode 100755
index 0000000..16a50f8
--- /dev/null
+++ b/tests/test_builtin.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+# *****************************************************************************
+# Copyright (C) 2019-2021 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr>
+# This file is part of the thcolor project, which is MIT-licensed.
+# *****************************************************************************
+""" Unit tests for the thcolor color decoding and management module. """
+
+import pytest
+from thcolor.angles import * # NOQA
+from thcolor.colors import * # NOQA
+from thcolor.decoders import * # NOQA
+from thcolor.builtin import * # NOQA
+from thcolor.errors import * # NOQA
+
+
+class TestDefaultDecoder:
+ """ Test the default decoder with all features. """
+
+ @pytest.fixture
+ def decoder(self):
+ return DefaultColorDecoder()
+
+ @pytest.mark.parametrize('test_input,expected', (
+ ('blue', SRGBColor.frombytes(0, 0, 255, 1.00)),
+ ('rgb(110%, 0%, 0%)', SRGBColor(1.0, 0, 0, 1.00)),
+ ('rgb(1, 22,242)', SRGBColor.frombytes(1, 22, 242, 1.00)),
+ (' rgb (1,22, 242 , 50.0% )', SRGBColor.frombytes(1, 22, 242, 0.50)),
+ (' rgb (1 22/ 242,50.0%,/)', SRGBColor.frombytes(1, 22, 242, 0.50)),
+ ('rgba(1,22,242,0.500)', SRGBColor.frombytes(1, 22, 242, 0.50)),
+ ('rbga(5, 7)', SRGBColor.frombytes(5, 0, 7, 1.00)),
+ ('hsl(0, 1,50.0%)', HSLColor(DegreesAngle(0.0), 1.0, 0.5, 1.00)),
+ (
+ 'hls(0 / 1 0.5 , 0.2)',
+ HSLColor(DegreesAngle(0.0), 0.5, 1.0, 0.20),
+ ),
+ ('hwb(0 0% 0)', HWBColor(DegreesAngle(0), 0.0, 0.0, 1.00)),
+ ('hbw(127 .5)', HWBColor(DegreesAngle(127), 0.0, 0.5)),
+ ('gray(100)', SRGBColor.frombytes(100, 100, 100, 1.00)),
+ ('gray(100 / 55 %)', SRGBColor.frombytes(100, 100, 100, 0.55)),
+ ('gray(red( #123456 )/0.2/)', SRGBColor.frombytes(18, 18, 18, 0.20)),
+ ('B20 50% 32%', HWBColor(DegreesAngle(252), .5, .32, 1.00)),
+ ('ncol(B20 / 50% 32%)', HWBColor(DegreesAngle(252), .5, .32, 1.00)),
+ ('cmyk(0% 37% 0.13 .78)', CMYKColor(0, .37, .13, .78, 1.00)),
+ (
+ 'darker(10%, hsl(0, 1, 50.0%))',
+ HSLColor(DegreesAngle(0), 1.00, 0.40, 1.00),
+ ),
+ (
+ 'lighter(50%, hsl(0, 1, 60.0%))',
+ HSLColor(DegreesAngle(0), 1.00, 1.00, 1.00),
+ ),
+ (
+ 'saturate(10%, hls(0, 1, 85.0%))',
+ HSLColor(DegreesAngle(0), 0.95, 1.00, 1.00),
+ ),
+ (
+ '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),
+ ),
+ (
+ 'Y40, 33%, 55%',
+ HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00),
+ ),
+ ('cmyk(0% 37% 0.13 .78)', CMYKColor(0.00, 0.37, 0.13, 0.78, 1.00)),
+ ))
+ def test_decoding_colors(self, decoder, test_input, expected):
+ result = decoder.decode(test_input, prefer_colors=True)
+ assert result[0] == expected
+
+# End of file.
diff --git a/tests/test_colors.py b/tests/test_colors.py
index bccc06e..505a43d 100755
--- a/tests/test_colors.py
+++ b/tests/test_colors.py
@@ -6,24 +6,18 @@
""" Unit tests for the thcolor color decoding and management module. """
import pytest
-from thcolor.angles import DegreesAngle
-from thcolor.colors import SRGBColor, HSLColor, HWBColor
+from thcolor.angles import * # NOQA
+from thcolor.colors import * # NOQA
+from thcolor.utils import round_half_up
class TestSRGBColors:
""" Test the sRGB color conversions. """
@pytest.mark.parametrize('args', (
- (-.5, 0, 0),
- (1.2, 0, 0),
- (0, -.5, 0),
- (0, 1.2, 0),
- (0, 0, -.5),
- (0, 0, 1.2),
- (0, 0, 0, -.5),
- (0, 0, 0, 1.2),
(None, 0, 0),
- ('1.2', 0, 0),
+ (0, None, 0),
+ (0, 0, None),
))
def test_invalid_values(self, args):
""" Try to instanciate the class using invalid values. """
@@ -96,10 +90,16 @@ class TestSRGBColors:
assert (hue, round(sat, 2), round(lgt, 2)) == hsl
@pytest.mark.parametrize('args,hsv', (
- # TODO
+ ((0, 0, 0), (DegreesAngle(0), 0, 0)),
+ ((255, 255, 255), (DegreesAngle(0), 0, 1)),
+ ((0, 255, 0), (DegreesAngle(120), 1, 1)),
+ ((0, 120, 0), (DegreesAngle(120), 1, .47)),
+ ((73, 33, 86), (DegreesAngle(285), .62, .34)),
+ ((10, 20, 30), (DegreesAngle(210), .67, .12)),
))
def test_hsv(self, args, hsv):
hue, sat, val, *_ = SRGBColor.frombytes(*args).ashsv()
+ hue = DegreesAngle(int(hue.asdegrees().degrees))
assert (hue, round(sat, 2), round(val, 2)) == hsv
@pytest.mark.parametrize('args,hwb', (
@@ -113,23 +113,56 @@ class TestSRGBColors:
assert (hue, round(wht, 2), round(blk, 2)) == hwb
@pytest.mark.parametrize('args,yiq', (
- # TODO
+ ((0, 0, 0), (0, 0, 0)),
+ ((255, 255, 255), (1, 0, 0)),
+ ((0, 255, 0), (.587, -.275, -.523)),
+ ((0, 120, 0), (.276, -.129, -.246)),
+ ((73, 33, 86), (.2, .0271, .0983)),
+ ((10, 20, 30), (.07098, -.03611, .00389)),
))
def test_yiq(self, args, yiq):
y, i, q, *_ = SRGBColor.frombytes(*args).asyiq()
- assert (round(y, 2), round(i, 2), round(q, 2)) == yiq
+ print((y, i, q), yiq)
+
+ yiq2 = tuple(map(lambda x: round_half_up(x, 3), (y, i, q)))
+ yiq = tuple(map(lambda x: round_half_up(x, 3), yiq))
+ assert yiq == yiq2
- @pytest.mark.parametrize('args,yuv', (
- # TODO
+ @pytest.mark.parametrize('args,cmyk', (
+ ((0, 0, 0), (0, 0, 0, 1)),
+ ((255, 255, 255), (0, 0, 0, 0)),
+ ((0, 255, 0), (1, 0, 1, 0)),
+ ((0, 120, 0), (1, 0, 1, .53)),
+ ((73, 33, 86), (.15, .62, 0, .66)),
+ ((10, 20, 30), (.67, .33, 0, .88)),
))
- def test_yuv(self, args, yuv):
- y, u, v, *_ = SRGBColor.frombytes(*args).asyuv()
- assert (round(y, 2), round(u, 2), round(v, 2)) == yuv
+ def test_cmyk(self, args, cmyk):
+ c, m, y, k, *_ = SRGBColor.frombytes(*args).ascmyk()
+ assert (
+ round_half_up(c, 2),
+ round_half_up(m, 2),
+ round_half_up(y, 2),
+ round_half_up(k, 2),
+ ) == cmyk
class TestHSLColors:
""" Test the HSL color conversions. """
+ @pytest.mark.parametrize('args', (
+ (0, 0, 0),
+ ('hello', 0, 0),
+ (DegreesAngle(0), -.5, 0),
+ (DegreesAngle(0), 1.5, 0),
+ (DegreesAngle(0), 0, -.5),
+ (DegreesAngle(0), 0, 1.5),
+ ))
+ def test_invalid_values(self, args):
+ """ Try to instanciate the class using invalid values. """
+
+ with pytest.raises(ValueError):
+ HSLColor(*args)
+
@pytest.mark.parametrize('args,rgb', (
((DegreesAngle(195), 1, .5), (0, 191, 255)),
((DegreesAngle(240), 1, .25), (0, 0, 128)),
@@ -158,6 +191,20 @@ class TestHSLColors:
assert tuple(HSLColor(*args).css()) == expected
+class TestHSVColor:
+ """ Test the HSV color conversions. """
+
+ @pytest.mark.parametrize('args,rgb', (
+ ((DegreesAngle(120), 0, 0), (0, 0, 0)),
+ ((DegreesAngle(120), .5, .5), (64, 128, 64)),
+ ((DegreesAngle(120), 1, .5), (0, 128, 0)),
+ ((DegreesAngle(120), .5, 1), (128, 255, 128)),
+ ((DegreesAngle(355), .2, .8), (204, 163, 167)),
+ ))
+ def test_srgb(self, args, rgb):
+ assert HSVColor(*args).assrgb().asbytes() == rgb
+
+
class TestHWBColors:
""" Test the HWB color conversions. """
@@ -177,4 +224,123 @@ class TestHWBColors:
def test_css(self, args, expected):
assert tuple(HWBColor(*args).css()) == expected
+
+class TestCMYKColors:
+ """ Test the CMYK color conversions. """
+
+ @pytest.mark.parametrize('args,rgb', (
+ ((0, 0, 0, 0), (255, 255, 255)),
+ ((0, 0, 0, 1), (0, 0, 0)),
+ ((0, 0, 0, .45), (140, 140, 140)),
+ ((0, .25, 0, .45), (140, 105, 140)),
+ ((0, .25, .77, .45), (140, 105, 32)),
+ ((.02, .04, .06, .08), (230, 225, 221)),
+ ))
+ def test_srgb(self, args, rgb):
+ assert CMYKColor(*args).assrgb().asbytes() == rgb
+
+
+class TestLABColors:
+ """ Test the LAB color conversions. """
+
+ @pytest.mark.parametrize('args,lch', (
+ # Examples created using the following online converter:
+ # http://www.easyrgb.com/en/convert.php
+
+ ((0, 0, 0), (0, 0, DegreesAngle(0))),
+ ((.5, -128, 128), (.5, 181.019, DegreesAngle(135))),
+ ))
+ def test_lch(self, args, lch):
+ l, c, h, *_ = LABColor(*args).aslch()
+ assert (
+ round_half_up(l, 3),
+ round_half_up(c, 3),
+ h.asdegrees(),
+ ) == lch
+
+
+class TestLCHColors:
+ """ Test the LCH color conversions. """
+
+ @pytest.mark.parametrize('args,lab', (
+ # Examples created using the following online converter:
+ # http://www.easyrgb.com/en/convert.php
+
+ ((0, 0, DegreesAngle(0)), (0, 0, 0)),
+ ((0, 50, DegreesAngle(0)), (0, 50, 0)),
+ ((.5, 50, DegreesAngle(0)), (.5, 50, 0)),
+ ((.5, 50, DegreesAngle(235)), (.5, -28.679, -40.958)),
+ ((.6, 200, DegreesAngle(146)), (.6, -165.808, 111.839)),
+ ((1, 200, DegreesAngle(0)), (1, 200, 0)),
+ ))
+ def test_lab(self, args, lab):
+ l, a, b, *_ = LCHColor(*args).aslab()
+ l2, a2, b2 = lab
+ assert (
+ round_half_up(l, 3),
+ round_half_up(a, 3),
+ round_half_up(b, 3),
+ ) == (
+ round_half_up(l2, 3),
+ round_half_up(a2, 3),
+ round_half_up(b2, 3),
+ )
+
+
+class TestXYZColors:
+ """ Test the XYZ color conversions. """
+
+ @pytest.mark.parametrize('args', (
+ (-.5, 0, 0),
+ (1.5, 0, 0),
+ (0, -.5, 0),
+ (0, 1.5, 0),
+ (0, 0, -.5),
+ (0, 0, 1.5),
+ (None, 0, 0),
+ (0, None, 0),
+ (0, 0, None),
+ ))
+ def test_invalid_values(self, args):
+ """ Try to instanciate the class using invalid values. """
+
+ with pytest.raises(ValueError):
+ XYZColor(*args)
+
+ @pytest.mark.parametrize('args,rgb', (
+ ((0, 0, 0), (0, 0, 0)),
+ ((1, 1, 1), (277, 249, 244)),
+ ((.1, .2, .3), (-438, 147, 145)),
+ ((1, .5, 0), (378, -103, -153)),
+ ))
+ def test_rgb(self, args, rgb):
+ assert XYZColor(*args).assrgb().asbytes() == rgb
+
+ @pytest.mark.parametrize('args,lab', (
+ ((1, .5, 0), (.76069, 111.688, 131.154)),
+ ((0, .5, 1), (.76069, -327.885, -35.666)),
+ ((.2, .3, .4), (.61654, -37.321, -9.353)),
+ ))
+ def test_lab(self, args, lab):
+ l, a, b, *_ = XYZColor(*args).aslab()
+ l2, a2, b2 = lab
+
+ l, a, b = (
+ round_half_up(l, 3),
+ round_half_up(a, 3),
+ round_half_up(b, 3),
+ )
+ l2, a2, b2 = (
+ round_half_up(l2, 3),
+ round_half_up(a2, 3),
+ round_half_up(b2, 3),
+ )
+
+ assert (l, a, b) == (l2, a2, b2)
+
+
+# TODO: add tests for invalid values in constructors.
+# TODO: test YIQ colors.
+# TODO: test YUV colors.
+
# End of file.
diff --git a/tests/test_decoders.py b/tests/test_decoders.py
index 747beb4..785ba32 100755
--- a/tests/test_decoders.py
+++ b/tests/test_decoders.py
@@ -11,7 +11,6 @@ import pytest
from thcolor.angles import * # NOQA
from thcolor.colors import * # NOQA
from thcolor.decoders import * # NOQA
-from thcolor.builtin import * # NOQA
from thcolor.errors import * # NOQA
@@ -45,14 +44,19 @@ class TestInvalidDecoders:
g = alias('f', args=('b',))
+ def test_varargs(self):
+ with pytest.raises(ValueError, match=r'variable arg'):
+ class BogusDecoder(MetaColorDecoder):
+ def f(a: int, b: int = 0, *args) -> int:
+ return a + b + sum(args)
+
class TestBaseDecoder:
+ """ Test the base decoder with no options enabled. """
+
@pytest.fixture
def decoder(self):
class StrictDecoder(MetaColorDecoder):
- __ncol_support__ = False
- __defaults_to_netscape_color__ = False
-
B20 = 12.34
heLLo = 56.78
@@ -121,6 +125,33 @@ class TestBaseDecoder:
with pytest.raises(ColorExpressionSyntaxError, match=r'unknown value'):
decoder.decode('Y40')
+ def test_disabled_extended_hex(self, decoder):
+ """ Test decoding an expression using an extended hex color.
+
+ This should be disabled in this state.
+ """
+
+ with pytest.raises(ColorExpressionSyntaxError, match=r'extended hex'):
+ decoder.decode('#1234')
+
+
+class TestExtendedHexDecoder:
+ """ Test base decoder with extended hex support. """
+
+ @pytest.fixture
+ def decoder(self):
+ class Decoder(MetaColorDecoder):
+ __extended_hex_support__ = True
+
+ return Decoder()
+
+ @pytest.mark.parametrize('test_input,expected', (
+ ('#0003', SRGBColor(0, 0, 0, alpha=.2)),
+ ('#000A', SRGBColor(0, 0, 0, alpha=.666667)),
+ ))
+ def test_extended_hex(self, decoder, test_input, expected):
+ assert decoder.decode(test_input) == (expected,)
+
class TestNColDecoder:
""" Test base decoder with ncol support. """
@@ -129,7 +160,6 @@ class TestNColDecoder:
def decoder(self):
class Decoder(MetaColorDecoder):
__ncol_support__ = True
- __defaults_to_netscape_color__ = False
def sum2(a: int, b: int) -> int:
return a + b
@@ -180,7 +210,7 @@ class TestNColDecoder:
def test_invalid_ncol(self, decoder):
with pytest.raises(ColorExpressionSyntaxError, match=r'unknown value'):
- decoder.decode('Y105, 50%, 50%')
+ assert decoder.decode('Y105, 50%, 50%')
class TestNetscapeDecoder:
@@ -223,62 +253,4 @@ class TestNetscapeDecoder:
assert result == (expected,)
-class TestDefaultDecoder:
- """ Test the default decoder with all features. """
-
- @pytest.fixture
- def decoder(self):
- return DefaultColorDecoder()
-
- @pytest.mark.parametrize('test_input,expected', (
- ('blue', SRGBColor.frombytes(0, 0, 255, 1.00)),
- ('rgb(110%, 0%, 0%)', SRGBColor(1.0, 0, 0, 1.00)),
- ('rgb(1, 22,242)', SRGBColor.frombytes(1, 22, 242, 1.00)),
- (' rgb (1,22, 242 , 50.0% )', SRGBColor.frombytes(1, 22, 242, 0.50)),
- (' rgb (1 22/ 242,50.0%,/)', SRGBColor.frombytes(1, 22, 242, 0.50)),
- ('rgba(1,22,242,0.500)', SRGBColor.frombytes(1, 22, 242, 0.50)),
- ('rbga(5, 7)', SRGBColor.frombytes(5, 0, 7, 1.00)),
- ('hsl(0, 1,50.0%)', HSLColor(DegreesAngle(0.0), 1.0, 0.5, 1.00)),
- (
- 'hls(0 / 1 0.5 , 0.2)',
- HSLColor(DegreesAngle(0.0), 0.5, 1.0, 0.20),
- ),
- ('hwb(0 0% 0)', HWBColor(DegreesAngle(0), 0.0, 0.0, 1.00)),
- ('hbw(127 .5)', HWBColor(DegreesAngle(127), 0.0, 0.5)),
- ('gray(100)', SRGBColor.frombytes(100, 100, 100, 1.00)),
- ('gray(100 / 55 %)', SRGBColor.frombytes(100, 100, 100, 0.55)),
- ('gray(red( #123456 )/0.2/)', SRGBColor.frombytes(18, 18, 18, 0.20)),
- ('B20 50% 32%', HWBColor(DegreesAngle(252), .5, .32, 1.00)),
- ('ncol(B20 / 50% 32%)', HWBColor(DegreesAngle(252), .5, .32, 1.00)),
- ('cmyk(0% 37% 0.13 .78)', CMYKColor(0, .37, .13, .78, 1.00)),
- (
- 'darker(10%, hsl(0, 1, 50.0%))',
- HSLColor(DegreesAngle(0), 1.00, 0.40, 1.00),
- ),
- (
- 'lighter(50%, hsl(0, 1, 60.0%))',
- HSLColor(DegreesAngle(0), 1.00, 1.00, 1.00),
- ),
- (
- 'saturate(10%, hls(0, 1, 85.0%))',
- HSLColor(DegreesAngle(0), 0.95, 1.00, 1.00),
- ),
- (
- '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),
- ),
- (
- 'Y40, 33%, 55%',
- HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00),
- ),
- ('cmyk(0% 37% 0.13 .78)', CMYKColor(0.00, 0.37, 0.13, 0.78, 1.00)),
- ))
- def test_decoding_colors(self, decoder, test_input, expected):
- result = decoder.decode(test_input, prefer_colors=True)
- assert result[0] == expected
-
# End of file.
diff --git a/thcolor/angles.py b/thcolor/angles.py
index 02e9711..3f0ca06 100644
--- a/thcolor/angles.py
+++ b/thcolor/angles.py
@@ -8,6 +8,8 @@
from math import pi as _pi
from typing import Any as _Any, Optional as _Optional
+from .utils import round_half_up as _round_half_up
+
__all__ = [
'Angle', 'DegreesAngle', 'GradiansAngle', 'RadiansAngle', 'TurnsAngle',
]
@@ -166,7 +168,7 @@ class DegreesAngle(Angle):
def __str__(self):
x = self._value
- return f'{int(x) if x == int(x) else x}deg'
+ return f'{_round_half_up(x, 4)}deg'
@property
def degrees(self) -> float:
@@ -198,7 +200,7 @@ class GradiansAngle(Angle):
def __str__(self):
x = self._value
- return f'{int(x) if x == int(x) else x}grad'
+ return f'{_round_half_up(x, 4)}grad'
@property
def gradians(self) -> float:
@@ -234,12 +236,8 @@ class RadiansAngle(Angle):
return f'{int(x) if x == int(x) else x}rad'
def __repr__(self):
- r = self.radians / _pi
- ir = int(r)
- if r == ir:
- r = ir
-
- return f"{self.__class__.__name__}(radians = {f'{r}π' if r else '0'})"
+ r = _round_half_up(self.radians / _pi, 4)
+ return f"{self.__class__.__name__}(radians={f'{r}π' if r else '0'})"
@property
def radians(self) -> float:
@@ -271,7 +269,7 @@ class TurnsAngle(Angle):
def __str__(self):
x = self._value
- return f'{int(x) if x == int(x) else x}turn'
+ return f'{_round_half_up(x, 4)}turn'
@property
def turns(self) -> float:
diff --git a/thcolor/builtin.py b/thcolor/builtin.py
index b42f6a4..229a746 100644
--- a/thcolor/builtin.py
+++ b/thcolor/builtin.py
@@ -8,16 +8,16 @@
from typing import Union as _Union
from .angles import Angle as _Angle
-from .decoders import (
- MetaColorDecoder as _MetaColorDecoder,
- alias as _alias, fallback as _fallback,
-)
from .colors import (
CMYKColor as _CMYKColor, Color as _Color, HSLColor as _HSLColor,
HSVColor as _HSVColor, HWBColor as _HWBColor, LABColor as _LABColor,
LCHColor as _LCHColor, SRGBColor as _SRGBColor, XYZColor as _XYZColor,
YIQColor as _YIQColor, YUVColor as _YUVColor,
)
+from .decoders import (
+ MetaColorDecoder as _MetaColorDecoder,
+ alias as _alias, fallback as _fallback,
+)
from .utils import factor as _factor
__all__ = [
@@ -51,6 +51,8 @@ class CSS1ColorDecoder(_MetaColorDecoder):
See `<https://www.w3.org/TR/CSS1/>`_ for more information.
"""
+ __defaults_to_netscape_color__ = True
+
black = _rgb('#000000')
silver = _rgb('#c0c0c0')
gray = _rgb('#808080')
@@ -497,7 +499,7 @@ class DefaultColorDecoder(CSS4ColorDecoder):
This method is actually compatibility with w3color.js.
NCols are managed directly without the function, so
- the function just needs to return the color.
+ the function only needs to return the color.
"""
return color
diff --git a/thcolor/colors.py b/thcolor/colors.py
index d049871..2be1985 100644
--- a/thcolor/colors.py
+++ b/thcolor/colors.py
@@ -9,7 +9,7 @@ from math import (
atan2 as _atan2, ceil as _ceil, cos as _cos, sin as _sin, sqrt as _sqrt,
)
from typing import (
- Any as _Any, Optional as _Optional, Tuple as _Tuple, Sequence as _Sequence,
+ Any as _Any, Optional as _Optional, Sequence as _Sequence, Tuple as _Tuple,
)
from .angles import (
@@ -335,25 +335,16 @@ class SRGBColor(Color):
red = float(red)
except (TypeError, ValueError):
raise ValueError(f'red should be a float, is {red!r}')
- else:
- if red < 0 or red > 1:
- raise ValueError('red should be between 0.0 and 1.0')
try:
green = float(green)
except (TypeError, ValueError):
raise ValueError(f'green should be a float, is {green!r}')
- else:
- if green < 0 or green > 1:
- raise ValueError('green should be between 0.0 and 1.0')
try:
blue = float(blue)
except (TypeError, ValueError):
raise ValueError(f'blue should be a float, is {blue!r}')
- else:
- if blue < 0 or blue > 1:
- raise ValueError('blue should be between 0.0 and 1.0')
self._red = red
self._green = green
@@ -594,20 +585,14 @@ class SRGBColor(Color):
""" Get an YIQColor out of the current object. """
r, g, b = self.red, self.green, self.blue
- y = .3 * r + .59 * g + .11 * b
return YIQColor(
- y=y,
- i=.74 * (r - y) - .27 * (b - y),
- q=.48 * (r - y) + .41 * (b - y),
+ y=.587 * g + .114 * b + .299 * r,
+ i=-.275 * g - .321 * b + .596 * r,
+ q=-.523 * g + .311 * b + .212 * r,
alpha=self.alpha,
)
- def asyuv(self) -> 'YUVColor':
- """ Get an YUVColor out of the current object. """
-
- raise NotImplementedError # TODO
-
def asbytes(self) -> _Tuple[int, int, int]:
""" Get the red, blue and green bytes. """
@@ -639,6 +624,27 @@ class HSLColor(Color):
):
super().__init__(alpha)
+ if not isinstance(hue, _Angle):
+ raise ValueError('hue should be an angle')
+
+ try:
+ saturation = float(saturation)
+ except (TypeError, ValueError):
+ raise ValueError(
+ f'saturation should be a float, is {saturation!r}',
+ )
+ else:
+ if saturation < 0 or saturation > 1:
+ raise ValueError('saturation should be between 0.0 and 1.0')
+
+ try:
+ lightness = float(lightness)
+ except (TypeError, ValueError):
+ raise ValueError(f'lightness should be a float, is {lightness!r}')
+ else:
+ if lightness < 0 or lightness > 1:
+ raise ValueError('lightness should be between 0.0 and 1.0')
+
self._hue = hue
self._saturation = saturation
self._lightness = lightness
@@ -953,7 +959,7 @@ class CMYKColor(Color):
def assrgb(self) -> 'SRGBColor':
""" Get an SRGBColor out of the current object. """
- c, m, y, k, a = self
+ c, m, y, k = self.cyan, self.magenta, self.yellow, self.black
r = 1 - min(1, c * (1 - k) + k)
g = 1 - min(1, m * (1 - k) + k)
@@ -963,7 +969,7 @@ class CMYKColor(Color):
red=r,
green=g,
blue=b,
- alpha=a,
+ alpha=self.alpha,
)
def ascmyk(self) -> 'CMYKColor':
@@ -1031,7 +1037,7 @@ class LABColor(Color):
def assrgb(self) -> 'SRGBColor':
""" Get an SRGBColor out of the current object. """
- raise NotImplementedError # TODO
+ return self.asxyz().assrgb()
def aslab(self) -> 'LABColor':
""" Get a LABColor out of the current object. """
@@ -1050,8 +1056,8 @@ class LABColor(Color):
return LCHColor(
lightness=l,
- chroma=_sqrt(a * a + b * b),
- hue=_RadiansAngle(_atan2(b, a)),
+ chroma=_sqrt(a ** 2 + b ** 2),
+ hue=_RadiansAngle(_atan2(b, a)).asprincipal(),
alpha=self.alpha,
)
@@ -1077,6 +1083,25 @@ class LCHColor(Color):
):
super().__init__(alpha)
+ try:
+ lightness = float(lightness)
+ except (TypeError, ValueError):
+ raise ValueError(f'lightness should be a float, is {lightness!r}')
+ else:
+ if lightness < 0 or lightness > 1:
+ raise ValueError('lightness should be between 0.0 and 1.0')
+
+ try:
+ chroma = float(chroma)
+ except (TypeError, ValueError):
+ raise ValueError(f'chroma should be a float, is {chroma!r}')
+ else:
+ if chroma < 0:
+ chroma = 0.0
+
+ if not isinstance(hue, _Angle):
+ raise TypeError(f'hue should be an Angle, is {hue!r}')
+
self._lightness = lightness
self._chroma = chroma
self._hue = hue
@@ -1109,6 +1134,11 @@ class LCHColor(Color):
return self._hue
+ def assrgb(self) -> 'SRGBColor':
+ """ Get an SRGBColor out of the current object. """
+
+ return self.aslab().asxyz().assrgb()
+
def aslab(self) -> 'LABColor':
""" Get a LABColor out of the current object. """
@@ -1147,6 +1177,30 @@ class XYZColor(Color):
def __init__(self, x: float, y: float, z: float, alpha: float = 1.0):
super().__init__(alpha)
+ try:
+ x = float(x)
+ except (TypeError, ValueError):
+ raise ValueError(f'x should be a float, is {x!r}')
+ else:
+ if x < 0 or x > 1:
+ raise ValueError('x should be between 0.0 and 1.0')
+
+ try:
+ y = float(y)
+ except (TypeError, ValueError):
+ raise ValueError(f'y should be a float, is {y!r}')
+ else:
+ if y < 0 or y > 1:
+ raise ValueError('y should be between 0.0 and 1.0')
+
+ try:
+ z = float(z)
+ except (TypeError, ValueError):
+ raise ValueError(f'z should be a float, is {z!r}')
+ else:
+ if z < 0 or z > 1:
+ raise ValueError('z should be between 0.0 and 1.0')
+
self._x = x
self._y = y
self._z = z
@@ -1172,10 +1226,76 @@ class XYZColor(Color):
return self._z
+ def assrgb(self) -> 'SRGBColor':
+ """ Get an SRGBColor out of the current object. """
+
+ # For more information about this algorithm, see these links:
+ #
+ # * http://www.easyrgb.com/en/math.php#text9
+ # (insufficient precision but you get the gist of the algorithm).
+ # * https://stackoverflow.com/a/45238704
+
+ x, y, z = self.x, self.y, self.z
+
+ r = x * 3.2404542 + y * -1.5371385 + z * -.4985314
+ g = x * -.9692660 + y * 1.8760108 + z * .0415560
+ b = x * .0556434 + y * -.2040259 + z * 1.0572252
+
+ r, g, b = map(
+ lambda x: (
+ 1.055 * (x ** (1 / 2.4)) - .055
+ if x > .0031308
+ else 12.92 * x
+ ),
+ (r, g, b),
+ )
+
+ return SRGBColor(
+ red=r,
+ green=g,
+ blue=b,
+ alpha=self.alpha,
+ )
+
+ def aslab(self) -> 'LABColor':
+ """ Get a LABColor out of the current object. """
+
+ x, y, z = self.x, self.y, self.z
+
+ # Uses the current industry standard formula, delta E 2000,
+ # on D65/2°.
+ # For more information, see the following links:
+ #
+ # * http://www.easyrgb.com/en/math.php#text9
+ # * https://github.com/cangoektas/xyz-to-lab/blob/master/src/index.js
+
+ D65 = (95.047, 100, 108.883)
+
+ def calculate(data):
+ value, ref = data
+ value = value * 100 / ref
+ if value > .008856:
+ return value ** (1 / 3)
+ return value * 7.878 + 16 / 116
+
+ x, y, z = map(calculate, zip((x, y, z), D65))
+
+ return LABColor(
+ lightness=(116 * y - 16) / 100,
+ a=500 * (x - y),
+ b=200 * (y - z),
+ alpha=self.alpha,
+ )
+
def asxyz(self) -> 'XYZColor':
""" Get an XYZColor out of the current object. """
- return self
+ return XYZColor(
+ x=self.x,
+ y=self.y,
+ z=self.z,
+ alpha=self.alpha,
+ )
class YIQColor(Color):
@@ -1299,11 +1419,6 @@ class YUVColor(Color):
return self._v
- def assrgb(self) -> 'SRGBColor':
- """ Get an SRGBColor out of the current object. """
-
- raise NotImplementedError # TODO
-
def asyuv(self) -> 'YUVColor':
""" Get an YUVColor out of the current object. """
diff --git a/thcolor/decoders.py b/thcolor/decoders.py
index cc74101..f8383be 100644
--- a/thcolor/decoders.py
+++ b/thcolor/decoders.py
@@ -9,22 +9,22 @@ import re as _re
from abc import ABCMeta as _ABCMeta
from collections.abc import Mapping as _Mapping
-from enum import auto as _auto, Enum as _Enum
-from typing import (
- Any as _Any, Optional as _Optional, Union as _Union,
- Sequence as _Sequence, Tuple as _Tuple, List as _List,
-)
+from enum import Enum as _Enum, auto as _auto
from inspect import getfullargspec as _getfullargspec
from itertools import zip_longest as _zip_longest
+from typing import (
+ Any as _Any, List as _List, Optional as _Optional, Sequence as _Sequence,
+ Tuple as _Tuple, Union as _Union,
+)
-from .angles import Angle as _Angle
+from .angles import (
+ Angle as _Angle, DegreesAngle as _DegreesAngle,
+ GradiansAngle as _GradiansAngle, RadiansAngle as _RadiansAngle,
+ TurnsAngle as _TurnsAngle,
+)
from .colors import (
Color as _Color, HWBColor as _HWBColor, SRGBColor as _SRGBColor,
)
-from .angles import (
- DegreesAngle as _DegreesAngle, GradiansAngle as _GradiansAngle,
- RadiansAngle as _RadiansAngle, TurnsAngle as _TurnsAngle,
-)
from .errors import ColorExpressionSyntaxError as _ColorExpressionSyntaxError
from .utils import factor as _factor
@@ -92,23 +92,20 @@ def _isinstance(value, types):
def _get_args(func) -> _Sequence[_Tuple[str, _Any, _Any]]:
- """ Get the arguments from a function in order to
- call it using optional or make an alias.
+ """ Get the arguments definition from a function.
Each argument is returned as a tuple containing:
* The name of the argument.
* The type of the argument.
* The default value of the argument;
- ``_NO_DEFAULT_VALUE`` if the argument has no default
- value.
+ ``_NO_DEFAULT_VALUE`` if the argument has no default
+ value.
In case an argument is keyword-only with no default
value, the function will raise an exception.
"""
- # TODO: manage functions with variable positional arguments.
-
argspec = _getfullargspec(func)
try:
kwarg = next(
@@ -123,6 +120,11 @@ def _get_args(func) -> _Sequence[_Tuple[str, _Any, _Any]]:
f'keyword-only argument {kwarg} has no default value',
)
+ if argspec.varargs is not None:
+ raise ValueError(
+ 'function has variable arguments, which is unsupported',
+ )
+
annotations = getattr(func, '__annotations__', {})
argnames = argspec.args
@@ -172,7 +174,7 @@ def _make_function(func, name: str, args: _Optional[_Sequence[_Any]]):
index = args_names.index(arg)
except ValueError:
raise ValueError(
- f'{arg!r} is not an argument of the aliased function'
+ f'{arg!r} is not an argument of the aliased function',
)
args_index[index] = proxy_index_i
@@ -252,7 +254,7 @@ def _make_function(func, name: str, args: _Optional[_Sequence[_Any]]):
f'__func = __define_alias({keys})\n'
)
- exec(code, {}, locals_)
+ exec(code, {}, locals_) # noqa: S102
func = locals_['__func']
return func
@@ -489,7 +491,7 @@ def _get_color_tokens(string: str, extended_hex: bool = False):
yield _ColorExpressionToken(
_ColorExpressionToken.TYPE_NCOL,
value=_DegreesAngle(
- 'RYGCBM'.find(letter) * 60 + number / 100 * 60
+ 'RYGCBM'.find(letter) * 60 + number / 100 * 60,
),
column=column,
rawtext=result['ncol'],
@@ -498,7 +500,8 @@ def _get_color_tokens(string: str, extended_hex: bool = False):
value_s = result['hex']
if len(value_s) in (4, 8) and not extended_hex:
raise _ColorExpressionSyntaxError(
- f'extended hex values are forbidden: {"#" + value_s!r}',
+ 'extended hex values are forbidden: '
+ f'{"#" + value_s!r}',
column=start,
)
@@ -609,10 +612,14 @@ class alias:
@property
def name(self):
+ """ Get the name of the function the current alias targets. """
+
return self._name
@property
def args(self) -> _Sequence[str]:
+ """ Get the arguments' order of the alias. """
+
return self._args
@@ -652,7 +659,7 @@ class ColorDecoder(_Mapping):
__mapping__: _Mapping = {}
__ncol_support__: bool = False
__extended_hex_support__: bool = False
- __defaults_to_netscape_color__: bool = True
+ __defaults_to_netscape_color__: bool = False
def __init__(self):
cls = self.__class__
@@ -662,9 +669,11 @@ class ColorDecoder(_Mapping):
mapping[_canonicalkey(key)] = value
self._mapping = mapping
- self._ncol_support = cls.__ncol_support__
- self._extended_hex_support = cls.__extended_hex_support__
- self._defaults_to_netscape_color = cls.__defaults_to_netscape_color__
+ self._ncol_support = bool(cls.__ncol_support__)
+ self._extended_hex_support = bool(cls.__extended_hex_support__)
+ self._defaults_to_netscape_color = bool(
+ cls.__defaults_to_netscape_color__,
+ )
def __getattr__(self, key):
try:
@@ -705,9 +714,9 @@ class ColorDecoder(_Mapping):
global _color_pattern
- ncol_support = bool(self._ncol_support)
- extended_hex_support = bool(self._extended_hex_support)
- defaults_to_netscape = bool(self._defaults_to_netscape_color)
+ ncol_support = self._ncol_support
+ extended_hex_support = self._extended_hex_support
+ defaults_to_netscape = self._defaults_to_netscape_color
# Parsing stage; the results will be in ``current``.
#
@@ -745,8 +754,8 @@ class ColorDecoder(_Mapping):
and ncol_support
):
func_stack.insert(0, token)
- stack.insert(0, current)
implicit_stack.insert(0, 3)
+ stack.insert(0, current)
current = []
elif token.type_ == _ColorExpressionToken.TYPE_NCOL:
current.append(_ColorExpressionToken(
@@ -764,7 +773,7 @@ class ColorDecoder(_Mapping):
_ColorExpressionToken.TYPE_HEX,
_ColorExpressionToken.TYPE_EMPTY,
):
- # Current token is a value, we simply add it.
+ # Current token is a value, we add it.
current.append(token)
elif token.type_ == _ColorExpressionToken.TYPE_CALL_START:
@@ -801,7 +810,7 @@ class ColorDecoder(_Mapping):
if name_token.type_ == _ColorExpressionToken.TYPE_NAME:
break
- # We just pop the function out of the stack with the
+ # We pop the function out of the stack with the
# arguments we have.
implicit_stack.pop(0)
@@ -829,25 +838,27 @@ class ColorDecoder(_Mapping):
f'unknown token type: {token.type_!r}',
)
- if func_stack and (
+ while 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)
+ if implicit_stack[0] > 0:
+ break
- # We have the required number of tokens for this
- # to work.
+ ncol = func_stack.pop(0)
+ implicit_stack.pop(0)
- 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 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,
@@ -866,7 +877,7 @@ class ColorDecoder(_Mapping):
func=_get_parent_func(),
)
- # We just pop the function out of the stack with the
+ # We pop the function out of the stack with the
# arguments we have.
implicit_stack.pop(0)
diff --git a/thcolor/utils.py b/thcolor/utils.py
index ad71367..7d60d0c 100644
--- a/thcolor/utils.py
+++ b/thcolor/utils.py
@@ -41,9 +41,16 @@ def round_half_up(number: float, ndigits: _Optional[int] = None) -> float:
base = 10 ** -ndigits
- return (number // base) * base + (
- base if (number % base) >= (base / 2) else 0
+ result = round(
+ (number // base) * base + (
+ base if (number % base) >= (base / 2) else 0
+ ),
+ ndigits=ndigits,
)
+ if result == int(result):
+ result = int(result)
+ return result
+
# End of file.