diff options
author | Thomas Touhey <thomas@touhey.fr> | 2022-01-01 20:33:44 +0100 |
---|---|---|
committer | Thomas Touhey <thomas@touhey.fr> | 2022-01-01 21:36:20 +0100 |
commit | e12ebaf20f09b4243b3d96cf5cbcbf73dd719a34 (patch) | |
tree | 61fbb0c55c51c35b832244fd56ab1baa8d37fb34 | |
parent | c2be6ab5f053b4a18c91a397173ac5704af848bd (diff) |
Added color tests, fixed a few things
-rw-r--r-- | docs/api/angles.rst | 2 | ||||
-rw-r--r-- | docs/api/colors.rst | 3 | ||||
-rwxr-xr-x | tests/test_angles.py | 50 | ||||
-rwxr-xr-x | tests/test_colors.py | 190 | ||||
-rw-r--r-- | thcolor/angles.py | 11 | ||||
-rw-r--r-- | thcolor/builtin.py | 14 | ||||
-rw-r--r-- | thcolor/colors.py | 57 | ||||
-rw-r--r-- | thcolor/utils.py | 35 |
8 files changed, 276 insertions, 86 deletions
diff --git a/docs/api/angles.rst b/docs/api/angles.rst index 1525dfc..67a37a2 100644 --- a/docs/api/angles.rst +++ b/docs/api/angles.rst @@ -7,7 +7,7 @@ Some color representations use angles as some of their properties. The base class for angles is the following: .. autoclass:: Angle - :members: asdegrees, asgradians, asradians, asturns, fromtext + :members: asdegrees, asgradians, asradians, asturns, asprincipal, fromtext Subclasses are the following: diff --git a/docs/api/colors.rst b/docs/api/colors.rst index ddd1988..7db7dd5 100644 --- a/docs/api/colors.rst +++ b/docs/api/colors.rst @@ -31,7 +31,8 @@ Subclasses are the following: :members: lightness, chroma, hue, alpha .. autoclass:: SRGBColor - :members: red, green, blue, alpha, frombytes, fromnetscapecolorname + :members: red, green, blue, alpha, frombytes, + fromnetscapecolorname, tobytes .. autoclass:: XYZColor :members: x, y, z, alpha diff --git a/tests/test_angles.py b/tests/test_angles.py index d5358a4..4408431 100755 --- a/tests/test_angles.py +++ b/tests/test_angles.py @@ -5,23 +5,41 @@ # ***************************************************************************** """ Unit tests for the thcolor color decoding and management module. """ +from math import pi + import pytest -from thcolor.angles import ( - Angle, DegreesAngle, GradiansAngle, RadiansAngle, TurnsAngle, -) - - -@pytest.mark.parametrize('test_input,expected', ( - ('120deg', DegreesAngle(120)), - ('5rad', RadiansAngle(5)), - ('3grad', GradiansAngle(3)), - ('6.turns', TurnsAngle(6)), - ('355', DegreesAngle(355)), -)) -def test_angles(test_input, expected): - angle = Angle.fromtext(test_input) - assert isinstance(angle, type(expected)) - assert angle == expected +from thcolor.angles import * # NOQA + + +class TestAngle: + @pytest.mark.parametrize('fst,snd', ( + (GradiansAngle(.1), GradiansAngle(.2)), + )) + def test_not_equal(self, fst, snd): + assert fst != snd + + @pytest.mark.parametrize('angle,expected', ( + (DegreesAngle(400), DegreesAngle(40)), + (DegreesAngle(-10), DegreesAngle(350)), + (TurnsAngle(-1.5), TurnsAngle(1)), + (RadiansAngle(6 * pi), RadiansAngle(0)), + (RadiansAngle(7.5 * pi), RadiansAngle(1.5 * pi)), + (GradiansAngle(-975), GradiansAngle(225)), + )) + def test_principal_angles(self, angle, expected): + return angle.asprincipal() == expected + + @pytest.mark.parametrize('test_input,expected', ( + ('120deg', DegreesAngle(120)), + ('5rad', RadiansAngle(5)), + ('3grad', GradiansAngle(3)), + ('6.turns', TurnsAngle(6)), + ('355', DegreesAngle(355)), + )) + def test_expr(self, test_input, expected): + angle = Angle.fromtext(test_input) + assert isinstance(angle, type(expected)) + assert angle == expected # End of file. diff --git a/tests/test_colors.py b/tests/test_colors.py index eb6d07a..bccc06e 100755 --- a/tests/test_colors.py +++ b/tests/test_colors.py @@ -10,29 +10,171 @@ from thcolor.angles import DegreesAngle from thcolor.colors import SRGBColor, HSLColor, HWBColor -@pytest.mark.parametrize('color,expected', ( - (SRGBColor.frombytes(0, 0, 255), ( - '#0000FF', - )), - (SRGBColor.frombytes(1, 22, 242, alpha=.5), ( - '#0116F2', - 'rgba(1, 22, 242, 50%)', - )), - (HSLColor(DegreesAngle(0), 1, .4), ( - '#CC0000', - 'hsl(0deg, 100%, 40%)', - )), - (HSLColor(DegreesAngle(0), .5, 1, alpha=.2), ( - '#FFFFFF', - 'rgba(255, 255, 255, 20%)', - 'hsla(0deg, 50%, 100%, 20%)', - )), - (HWBColor(DegreesAngle(127), blackness=.5), ( - '#00800F', - 'hwb(127deg, 0%, 50%)', - )), -)) -def test_css(color, expected): - assert tuple(color.css()) == expected +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), + )) + def test_invalid_values(self, args): + """ Try to instanciate the class using invalid values. """ + + with pytest.raises(ValueError): + SRGBColor(*args) + + @pytest.mark.parametrize('args,bytes_', ( + ((0, 0, 0), (0, 0, 0)), + ((.0019, .002, .005), (0, 1, 1)), + ((.1, .2, .3), (26, 51, 77)), + )) + def test_to_bytes(self, args, bytes_): + """ Try converting to bytes and test the rounding up. """ + + assert SRGBColor(*args).asbytes() == bytes_ + + @pytest.mark.parametrize('args,bytes_', ( + ((0, .003921, .003925), (0, 1, 1)), + )) + def test_from_bytes(self, args, bytes_): + """ Try converting from bytes and test the rounding up. """ + + assert SRGBColor(*args) == SRGBColor.frombytes(*bytes_) + + @pytest.mark.parametrize('name,bytes_', ( + # https://stackoverflow.com/q/8318911 + ('chucknorris', (192, 0, 0)), + ('ninjaturtle', (0, 160, 0)), + ('crap', (192, 160, 0)), + ('grass', (0, 160, 0)), + + # https://bugzilla.mozilla.org/show_bug.cgi?id=121738 + ('navyblue', (160, 176, 224)), + + # http://web.archive.org/web/20050403162633/http://www.jgc.org:80/tsc/index.htm + # See the "Flex Hex" technique. + ('FxFxFx', (240, 240, 240)), + ('#FxFxFx', (240, 240, 240)), + ('FqFeFm', (240, 254, 240)), + + # http://scrappy-do.blogspot.com/2004/08/little-rant-about-microsoft-internet.html + ('zqbttv', (0, 176, 0)), + ('6db6ec49efd278cd0bc92d1e5e072d680', (110, 205, 224)), + )) + def test_netscape_decoding(self, name, bytes_): + """ Try decoding colors using Netscape color parsing. """ + + assert SRGBColor.fromnetscapecolorname(name).asbytes() == bytes_ + + @pytest.mark.parametrize('args,expected', ( + ((0, 0, 255), ('#0000FF',)), + ((1, 22, 242, .5), ( + '#0116F2', + 'rgba(1, 22, 242, 50%)', + )), + )) + def test_css(self, args, expected): + assert tuple(SRGBColor.frombytes(*args).css()) == expected + + @pytest.mark.parametrize('args,hsl', ( + ((0, 0, 0), (DegreesAngle(0), 0, 0)), + ((1, 2, 3), (DegreesAngle(210), .5, .01)), + ((50, 100, 150), (DegreesAngle(210), .5, .39)), + ((255, 255, 255), (DegreesAngle(0), 0, 1)), + ((82, 122, 122), (DegreesAngle(180), .2, .4)), + )) + def test_hsl(self, args, hsl): + hue, sat, lgt, *_ = SRGBColor.frombytes(*args).ashsl() + assert (hue, round(sat, 2), round(lgt, 2)) == hsl + + @pytest.mark.parametrize('args,hsv', ( + # TODO + )) + def test_hsv(self, args, hsv): + hue, sat, val, *_ = SRGBColor.frombytes(*args).ashsv() + assert (hue, round(sat, 2), round(val, 2)) == hsv + + @pytest.mark.parametrize('args,hwb', ( + ((82, 122, 122), (DegreesAngle(180), .32, .52)), + ((13, 157, 230), (DegreesAngle(200), .05, .1)), + ((212, 212, 212), (DegreesAngle(0), .83, .17)), + )) + def test_hwb(self, args, hwb): + hue, wht, blk, *_ = SRGBColor.frombytes(*args).ashwb() + hue = DegreesAngle(int(hue.asdegrees().degrees)) + assert (hue, round(wht, 2), round(blk, 2)) == hwb + + @pytest.mark.parametrize('args,yiq', ( + # TODO + )) + def test_yiq(self, args, yiq): + y, i, q, *_ = SRGBColor.frombytes(*args).asyiq() + assert (round(y, 2), round(i, 2), round(q, 2)) == yiq + + @pytest.mark.parametrize('args,yuv', ( + # TODO + )) + def test_yuv(self, args, yuv): + y, u, v, *_ = SRGBColor.frombytes(*args).asyuv() + assert (round(y, 2), round(u, 2), round(v, 2)) == yuv + + +class TestHSLColors: + """ Test the HSL color conversions. """ + + @pytest.mark.parametrize('args,rgb', ( + ((DegreesAngle(195), 1, .5), (0, 191, 255)), + ((DegreesAngle(240), 1, .25), (0, 0, 128)), + ((DegreesAngle(0), 1, .5), (255, 0, 0)), + ((DegreesAngle(0), 0, 0), (0, 0, 0)), + ((DegreesAngle(0), 0, 1), (255, 255, 255)), + ((DegreesAngle(0), 0, .01), (3, 3, 3)), + ((DegreesAngle(145), .30, .60), (122, 184, 148)), + )) + def test_srgb(self, args, rgb): + r, g, b = HSLColor(*args).assrgb().asbytes() + assert (r, g, b) == rgb + + @pytest.mark.parametrize('args,expected', ( + ((DegreesAngle(0), 1, .4), ( + '#CC0000', + 'hsl(0deg, 100%, 40%)', + )), + ((DegreesAngle(0), .5, 1, .2), ( + '#FFFFFF', + 'rgba(255, 255, 255, 20%)', + 'hsla(0deg, 50%, 100%, 20%)', + )), + )) + def test_css(self, args, expected): + assert tuple(HSLColor(*args).css()) == expected + + +class TestHWBColors: + """ Test the HWB color conversions. """ + + @pytest.mark.parametrize('args,rgb', ( + ((DegreesAngle(145), .48, .28), (122, 184, 148)), + )) + def test_srgb(self, args, rgb): + r, g, b = HWBColor(*args).assrgb().asbytes() + assert (r, g, b) == rgb + + @pytest.mark.parametrize('args,expected', ( + ((DegreesAngle(127), 0, .5), ( + '#00800F', + 'hwb(127deg, 0%, 50%)', + )), + )) + def test_css(self, args, expected): + assert tuple(HWBColor(*args).css()) == expected # End of file. diff --git a/thcolor/angles.py b/thcolor/angles.py index 5a581fb..02e9711 100644 --- a/thcolor/angles.py +++ b/thcolor/angles.py @@ -44,7 +44,7 @@ class Angle: return False return ( - round(self.asturns().turns, 3) == round(other.asturns().turns, 3) + round(self.asturns().turns, 6) == round(other.asturns().turns, 6) ) def asdegrees(self) -> 'DegreesAngle': @@ -107,6 +107,15 @@ class Angle: return TurnsAngle((value - ob) / (ot - ob) * (nt - nb) + nb) + def asprincipal(self): + """ Get the principal angle. """ + + cls = self.__class__ + value = self._value + bottom, top = cls._bottom, cls._top + + return cls((value - bottom) % (top - bottom) + bottom) + @classmethod def fromtext( cls, diff --git a/thcolor/builtin.py b/thcolor/builtin.py index e5191c7..b42f6a4 100644 --- a/thcolor/builtin.py +++ b/thcolor/builtin.py @@ -18,7 +18,7 @@ from .colors import ( LCHColor as _LCHColor, SRGBColor as _SRGBColor, XYZColor as _XYZColor, YIQColor as _YIQColor, YUVColor as _YUVColor, ) -from .utils import factor as _factor, rgb as _rgb +from .utils import factor as _factor __all__ = [ 'CSS1ColorDecoder', 'CSS2ColorDecoder', 'CSS3ColorDecoder', @@ -28,6 +28,18 @@ __all__ = [ _number = _Union[int, float] +def _rgb(x): + """ Return an RGB color out of the given 6-digit hexadecimal code. """ + + from thcolor.colors import SRGBColor as _SRGBColor + + return _SRGBColor( + int(x[1:3], 16) / 255, + int(x[3:5], 16) / 255, + int(x[5:7], 16) / 255, + ) + + # --- # Main colors. # --- diff --git a/thcolor/colors.py b/thcolor/colors.py index e6c924e..d049871 100644 --- a/thcolor/colors.py +++ b/thcolor/colors.py @@ -16,6 +16,7 @@ from .angles import ( Angle as _Angle, DegreesAngle as _DegreesAngle, RadiansAngle as _RadiansAngle, TurnsAngle as _TurnsAngle, ) +from .utils import round_half_up as _round_half_up __all__ = [ 'CMYKColor', 'Color', 'HSLColor', 'HSVColor', 'HWBColor', @@ -60,11 +61,8 @@ class Color: if not isinstance(other, Color): return False - sc = self.assrgb() - oc = other.assrgb() - - srgb = sc.red, sc.green, sc.blue, sc.alpha - orgb = oc.red, oc.green, oc.blue, oc.alpha + srgb = tuple(map(lambda x: round(x, 3), self.assrgb())) + orgb = tuple(map(lambda x: round(x, 3), other.assrgb())) return srgb == orgb @@ -251,30 +249,27 @@ class Color: """ def _percent(prop): - per = round(prop, 4) * 100 + per = _round_half_up(prop, 4) * 100 if per == int(per): per = int(per) return per def _deg(agl): - agl = round(agl.asdegrees().degrees, 2) - if agl == int(agl): - agl = int(agl) - return agl + return int(agl.asdegrees().degrees) def statements(): # Start by yelling a #RRGGBB color, compatible with most # web browsers around the world, followed by the rgba() # notation if the alpha value isn't 1.0. - a = round(self.alpha, 3) + a = _round_half_up(self.alpha, 3) try: rgb = self.assrgb() except NotImplementedError: pass else: - r, g, b = rgb.tobytes() + r, g, b = rgb.asbytes() yield f'#{r:02X}{g:02X}{b:02X}' @@ -421,6 +416,8 @@ class SRGBColor(Color): """ Get an sRGB color from a Netscape color name. """ name = str(name) + if name[0] == '#': + name = name[1:] # Find more about this here: https://stackoverflow.com/a/8333464 # @@ -507,9 +504,9 @@ class SRGBColor(Color): s /= 2 - max_value - min_value return HSLColor( - hue=_DegreesAngle(round(hue, 2)), - saturation=round(s, 2), - lightness=round(lgt, 2), + hue=_DegreesAngle(_round_half_up(hue, 2)), + saturation=_round_half_up(s, 2), + lightness=_round_half_up(lgt, 2), alpha=self.alpha, ) @@ -546,27 +543,27 @@ class SRGBColor(Color): def ashwb(self) -> 'HWBColor': """ Get an HWBColor out of the current object. """ - r, g, b, _ = self + r, g, b = self.red, self.green, self.blue - max_value = max((r, g, b)) - min_value = min((r, g, b)) - chroma = max_value - min_value + max_ = max((r, g, b)) + min_ = min((r, g, b)) + chroma = max_ - min_ if chroma == 0: hue = 0 - elif r == max_value: + elif r == max_: hue = (g - b) / chroma - elif g == max_value: + elif g == max_: hue = (b - r) / chroma + 2 - elif g == max_value: + elif b == max_: hue = (r - g) / chroma + 4 - hue = (hue % 6) * 360 - w = min_value - b = max_value + hue /= 6 + w = min_ + b = 1 - max_ return HWBColor( - hue=_DegreesAngle(hue), + hue=_TurnsAngle(hue), whiteness=w, blackness=b, alpha=self.alpha, @@ -611,13 +608,13 @@ class SRGBColor(Color): raise NotImplementedError # TODO - def tobytes(self) -> _Tuple[int, int, int]: + def asbytes(self) -> _Tuple[int, int, int]: """ Get the red, blue and green bytes. """ return ( - int(round(self.red * 255)), - int(round(self.green * 255)), - int(round(self.blue * 255)), + int(_round_half_up(self.red * 255)), + int(_round_half_up(self.green * 255)), + int(_round_half_up(self.blue * 255)), ) diff --git a/thcolor/utils.py b/thcolor/utils.py index f18482e..ad71367 100644 --- a/thcolor/utils.py +++ b/thcolor/utils.py @@ -5,19 +5,9 @@ # ***************************************************************************** """ Utilities for the thcolor module. """ -from thcolor.colors import SRGBColor as _SRGBColor +from typing import Optional as _Optional -__all__ = ['factor', 'rgb'] - - -def rgb(x): - """ Return an RGB color out of the given 6-digit hexadecimal code. """ - - return _SRGBColor( - int(x[1:3], 16) / 255, - int(x[3:5], 16) / 255, - int(x[5:7], 16) / 255, - ) +__all__ = ['factor', 'round_half_up'] def factor(x, max_: int = 100, clip: bool = False): @@ -35,4 +25,25 @@ def factor(x, max_: int = 100, clip: bool = False): return x + +def round_half_up(number: float, ndigits: _Optional[int] = None) -> float: + """ Round a number to the nearest integer. + + This function exists because Python's built-in ``round`` function + uses half-to-even rounding, also called "Banker's rounding". + This means that 1.5 is rounded to 2 and 2.5 is also rounded to 2. + + What we want is a half-to-up rounding, so we have this function. + """ + + if ndigits is None: + ndigits = 0 + + base = 10 ** -ndigits + + return (number // base) * base + ( + base if (number % base) >= (base / 2) else 0 + ) + + # End of file. |