aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Touhey <thomas@touhey.fr>2022-01-02 16:36:16 +0100
committerThomas Touhey <thomas@touhey.fr>2022-01-02 19:15:52 +0100
commitc8f0664221d53795b118635f6579ddae020bbbe1 (patch)
tree1360a3f313954ce653722dd4ee549cf4989ba916
parent4a947057b709c6536f6a940645254bf0feadcd8c (diff)
Adding in more tests and fixing a few style issues
-rw-r--r--.gitignore1
-rw-r--r--.python-version1
-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.py10
-rw-r--r--thcolor/colors.py175
-rw-r--r--thcolor/decoders.py95
-rw-r--r--thcolor/utils.py11
11 files changed, 529 insertions, 172 deletions
diff --git a/.gitignore b/.gitignore
index fceabbb..098c992 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,5 +8,6 @@ __pycache__
/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/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..c1531a7 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')
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..ea7e4a3 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(
@@ -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,
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.