diff options
author | Thomas Touhey <thomas@touhey.fr> | 2022-01-02 16:36:16 +0100 |
---|---|---|
committer | Thomas Touhey <thomas@touhey.fr> | 2022-01-02 19:15:52 +0100 |
commit | c8f0664221d53795b118635f6579ddae020bbbe1 (patch) | |
tree | 1360a3f313954ce653722dd4ee549cf4989ba916 | |
parent | 4a947057b709c6536f6a940645254bf0feadcd8c (diff) |
Adding in more tests and fixing a few style issues
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | .python-version | 1 | ||||
-rwxr-xr-x | tests/test_angles.py | 14 | ||||
-rwxr-xr-x | tests/test_builtin.py | 74 | ||||
-rwxr-xr-x | tests/test_colors.py | 204 | ||||
-rwxr-xr-x | tests/test_decoders.py | 100 | ||||
-rw-r--r-- | thcolor/angles.py | 16 | ||||
-rw-r--r-- | thcolor/builtin.py | 10 | ||||
-rw-r--r-- | thcolor/colors.py | 175 | ||||
-rw-r--r-- | thcolor/decoders.py | 95 | ||||
-rw-r--r-- | thcolor/utils.py | 11 |
11 files changed, 529 insertions, 172 deletions
@@ -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. |