aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas Touhey <thomas@touhey.fr>2022-01-01 20:33:44 +0100
committerThomas Touhey <thomas@touhey.fr>2022-01-01 21:36:20 +0100
commite12ebaf20f09b4243b3d96cf5cbcbf73dd719a34 (patch)
tree61fbb0c55c51c35b832244fd56ab1baa8d37fb34
parentc2be6ab5f053b4a18c91a397173ac5704af848bd (diff)
Added color tests, fixed a few things
-rw-r--r--docs/api/angles.rst2
-rw-r--r--docs/api/colors.rst3
-rwxr-xr-xtests/test_angles.py50
-rwxr-xr-xtests/test_colors.py190
-rw-r--r--thcolor/angles.py11
-rw-r--r--thcolor/builtin.py14
-rw-r--r--thcolor/colors.py57
-rw-r--r--thcolor/utils.py35
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.