xulbux 1.9.5__cp311-cp311-macosx_11_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 455848faf89d8974b22a__mypyc.cpython-311-darwin.so +0 -0
- xulbux/__init__.cpython-311-darwin.so +0 -0
- xulbux/__init__.py +46 -0
- xulbux/base/consts.cpython-311-darwin.so +0 -0
- xulbux/base/consts.py +172 -0
- xulbux/base/decorators.cpython-311-darwin.so +0 -0
- xulbux/base/decorators.py +28 -0
- xulbux/base/exceptions.cpython-311-darwin.so +0 -0
- xulbux/base/exceptions.py +23 -0
- xulbux/base/types.cpython-311-darwin.so +0 -0
- xulbux/base/types.py +118 -0
- xulbux/cli/help.cpython-311-darwin.so +0 -0
- xulbux/cli/help.py +77 -0
- xulbux/code.cpython-311-darwin.so +0 -0
- xulbux/code.py +137 -0
- xulbux/color.cpython-311-darwin.so +0 -0
- xulbux/color.py +1331 -0
- xulbux/console.cpython-311-darwin.so +0 -0
- xulbux/console.py +2069 -0
- xulbux/data.cpython-311-darwin.so +0 -0
- xulbux/data.py +798 -0
- xulbux/env_path.cpython-311-darwin.so +0 -0
- xulbux/env_path.py +123 -0
- xulbux/file.cpython-311-darwin.so +0 -0
- xulbux/file.py +74 -0
- xulbux/file_sys.cpython-311-darwin.so +0 -0
- xulbux/file_sys.py +266 -0
- xulbux/format_codes.cpython-311-darwin.so +0 -0
- xulbux/format_codes.py +722 -0
- xulbux/json.cpython-311-darwin.so +0 -0
- xulbux/json.py +200 -0
- xulbux/regex.cpython-311-darwin.so +0 -0
- xulbux/regex.py +247 -0
- xulbux/string.cpython-311-darwin.so +0 -0
- xulbux/string.py +161 -0
- xulbux/system.cpython-311-darwin.so +0 -0
- xulbux/system.py +313 -0
- xulbux-1.9.5.dist-info/METADATA +271 -0
- xulbux-1.9.5.dist-info/RECORD +43 -0
- xulbux-1.9.5.dist-info/WHEEL +6 -0
- xulbux-1.9.5.dist-info/entry_points.txt +2 -0
- xulbux-1.9.5.dist-info/licenses/LICENSE +21 -0
- xulbux-1.9.5.dist-info/top_level.txt +2 -0
xulbux/color.py
ADDED
|
@@ -0,0 +1,1331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the `rgba`, `hsla` and `hexa` classes, which offer
|
|
3
|
+
methods to manipulate colors in their respective color spaces.<br>
|
|
4
|
+
|
|
5
|
+
This module also provides the `Color` class, which
|
|
6
|
+
includes methods to work with colors in various formats.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .base.types import AnyRgba, AnyHsla, AnyHexa, Rgba, Hsla, Hexa
|
|
10
|
+
from .regex import Regex
|
|
11
|
+
|
|
12
|
+
from typing import Iterator, Optional, Literal, cast
|
|
13
|
+
import re as _re
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class rgba:
|
|
17
|
+
"""An RGB/RGBA color object that includes a bunch of methods to manipulate the color.\n
|
|
18
|
+
----------------------------------------------------------------------------------------
|
|
19
|
+
- `r` -⠀the red channel in range [0, 255] inclusive
|
|
20
|
+
- `g` -⠀the green channel in range [0, 255] inclusive
|
|
21
|
+
- `b` -⠀the blue channel in range [0, 255] inclusive
|
|
22
|
+
- `a` -⠀the alpha channel in range [0.0, 1.0] inclusive
|
|
23
|
+
or `None` if the color has no alpha channel\n
|
|
24
|
+
----------------------------------------------------------------------------------------
|
|
25
|
+
Includes methods:
|
|
26
|
+
- `to_hsla()` to convert to HSL color
|
|
27
|
+
- `to_hexa()` to convert to HEX color
|
|
28
|
+
- `has_alpha()` to check if the color has an alpha channel
|
|
29
|
+
- `lighten(amount)` to create a lighter version of the color
|
|
30
|
+
- `darken(amount)` to create a darker version of the color
|
|
31
|
+
- `saturate(amount)` to increase color saturation
|
|
32
|
+
- `desaturate(amount)` to decrease color saturation
|
|
33
|
+
- `rotate(degrees)` to rotate the hue by degrees
|
|
34
|
+
- `invert()` to get the inverse color
|
|
35
|
+
- `grayscale()` to convert to grayscale
|
|
36
|
+
- `blend(other, ratio)` to blend with another color
|
|
37
|
+
- `is_dark()` to check if the color is considered dark
|
|
38
|
+
- `is_light()` to check if the color is considered light
|
|
39
|
+
- `is_grayscale()` to check if the color is grayscale
|
|
40
|
+
- `is_opaque()` to check if the color has no transparency
|
|
41
|
+
- `with_alpha(alpha)` to create a new color with different alpha
|
|
42
|
+
- `complementary()` to get the complementary color"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, r: int, g: int, b: int, a: Optional[float] = None, _validate: bool = True):
|
|
45
|
+
self.r: int
|
|
46
|
+
"""The red channel in range [0, 255] inclusive."""
|
|
47
|
+
self.g: int
|
|
48
|
+
"""The green channel in range [0, 255] inclusive."""
|
|
49
|
+
self.b: int
|
|
50
|
+
"""The blue channel in range [0, 255] inclusive."""
|
|
51
|
+
self.a: Optional[float]
|
|
52
|
+
"""The alpha channel in range [0.0, 1.0] inclusive or `None` if not set."""
|
|
53
|
+
|
|
54
|
+
if not _validate:
|
|
55
|
+
self.r, self.g, self.b, self.a = r, g, b, a
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if not all((0 <= x <= 255) for x in (r, g, b)):
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"The 'r', 'g' and 'b' parameters must be integers in range [0, 255] inclusive, got {r=} {g=} {b=}"
|
|
61
|
+
)
|
|
62
|
+
if a is not None and not (0.0 <= a <= 1.0):
|
|
63
|
+
raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0] inclusive, got {a!r}")
|
|
64
|
+
|
|
65
|
+
self.r, self.g, self.b = r, g, b
|
|
66
|
+
self.a = None if a is None else (1.0 if a > 1.0 else float(a))
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
"""The number of components in the color (3 or 4)."""
|
|
70
|
+
return 3 if self.a is None else 4
|
|
71
|
+
|
|
72
|
+
def __iter__(self) -> Iterator:
|
|
73
|
+
return iter((self.r, self.g, self.b) + (() if self.a is None else (self.a, )))
|
|
74
|
+
|
|
75
|
+
def __getitem__(self, index: int) -> int | float:
|
|
76
|
+
return ((self.r, self.g, self.b) + (() if self.a is None else (self.a, )))[index]
|
|
77
|
+
|
|
78
|
+
def __eq__(self, other: object) -> bool:
|
|
79
|
+
"""Check if two `rgba` objects are the same color."""
|
|
80
|
+
if not isinstance(other, rgba):
|
|
81
|
+
return False
|
|
82
|
+
return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a)
|
|
83
|
+
|
|
84
|
+
def __ne__(self, other: object) -> bool:
|
|
85
|
+
"""Check if two `rgba` objects are different colors."""
|
|
86
|
+
return not self.__eq__(other)
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return f"rgba({self.r}, {self.g}, {self.b}{'' if self.a is None else f', {self.a}'})"
|
|
90
|
+
|
|
91
|
+
def __str__(self) -> str:
|
|
92
|
+
return self.__repr__()
|
|
93
|
+
|
|
94
|
+
def dict(self) -> dict:
|
|
95
|
+
"""Returns the color components as a dictionary with keys `"r"`, `"g"`, `"b"` and optionally `"a"`."""
|
|
96
|
+
return dict(r=self.r, g=self.g, b=self.b) if self.a is None else dict(r=self.r, g=self.g, b=self.b, a=self.a)
|
|
97
|
+
|
|
98
|
+
def values(self) -> tuple:
|
|
99
|
+
"""Returns the color components as separate values `r, g, b, a`."""
|
|
100
|
+
return self.r, self.g, self.b, self.a
|
|
101
|
+
|
|
102
|
+
def to_hsla(self) -> "hsla":
|
|
103
|
+
"""Returns the color as `hsla()` color object."""
|
|
104
|
+
h, s, l = self._rgb_to_hsl(self.r, self.g, self.b)
|
|
105
|
+
return hsla(h, s, l, self.a, _validate=False)
|
|
106
|
+
|
|
107
|
+
def to_hexa(self) -> "hexa":
|
|
108
|
+
"""Returns the color as `hexa()` color object."""
|
|
109
|
+
return hexa("", self.r, self.g, self.b, self.a)
|
|
110
|
+
|
|
111
|
+
def has_alpha(self) -> bool:
|
|
112
|
+
"""Returns `True` if the color has an alpha channel and `False` otherwise."""
|
|
113
|
+
return self.a is not None
|
|
114
|
+
|
|
115
|
+
def lighten(self, amount: float) -> "rgba":
|
|
116
|
+
"""Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
|
|
117
|
+
if not isinstance(amount, float):
|
|
118
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
119
|
+
elif not (0.0 <= amount <= 1.0):
|
|
120
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
121
|
+
|
|
122
|
+
self.r, self.g, self.b, self.a = self.to_hsla().lighten(amount).to_rgba().values()
|
|
123
|
+
return rgba(self.r, self.g, self.b, self.a, _validate=False)
|
|
124
|
+
|
|
125
|
+
def darken(self, amount: float) -> "rgba":
|
|
126
|
+
"""Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
|
|
127
|
+
if not isinstance(amount, float):
|
|
128
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
129
|
+
elif not (0.0 <= amount <= 1.0):
|
|
130
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
131
|
+
|
|
132
|
+
self.r, self.g, self.b, self.a = self.to_hsla().darken(amount).to_rgba().values()
|
|
133
|
+
return rgba(self.r, self.g, self.b, self.a, _validate=False)
|
|
134
|
+
|
|
135
|
+
def saturate(self, amount: float) -> "rgba":
|
|
136
|
+
"""Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
|
|
137
|
+
if not isinstance(amount, float):
|
|
138
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
139
|
+
elif not (0.0 <= amount <= 1.0):
|
|
140
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
141
|
+
|
|
142
|
+
self.r, self.g, self.b, self.a = self.to_hsla().saturate(amount).to_rgba().values()
|
|
143
|
+
return rgba(self.r, self.g, self.b, self.a, _validate=False)
|
|
144
|
+
|
|
145
|
+
def desaturate(self, amount: float) -> "rgba":
|
|
146
|
+
"""Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
|
|
147
|
+
if not isinstance(amount, float):
|
|
148
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
149
|
+
elif not (0.0 <= amount <= 1.0):
|
|
150
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
151
|
+
|
|
152
|
+
self.r, self.g, self.b, self.a = self.to_hsla().desaturate(amount).to_rgba().values()
|
|
153
|
+
return rgba(self.r, self.g, self.b, self.a, _validate=False)
|
|
154
|
+
|
|
155
|
+
def rotate(self, degrees: int) -> "rgba":
|
|
156
|
+
"""Rotates the colors hue by the specified number of degrees."""
|
|
157
|
+
if not isinstance(degrees, int):
|
|
158
|
+
raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}")
|
|
159
|
+
|
|
160
|
+
self.r, self.g, self.b, self.a = self.to_hsla().rotate(degrees).to_rgba().values()
|
|
161
|
+
return rgba(self.r, self.g, self.b, self.a, _validate=False)
|
|
162
|
+
|
|
163
|
+
def invert(self, invert_alpha: bool = False) -> "rgba":
|
|
164
|
+
"""Inverts the color by rotating hue by 180 degrees and inverting lightness."""
|
|
165
|
+
if not isinstance(invert_alpha, bool):
|
|
166
|
+
raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}")
|
|
167
|
+
|
|
168
|
+
self.r, self.g, self.b = 255 - self.r, 255 - self.g, 255 - self.b
|
|
169
|
+
if invert_alpha and self.a is not None:
|
|
170
|
+
self.a = 1 - self.a
|
|
171
|
+
|
|
172
|
+
return rgba(self.r, self.g, self.b, self.a, _validate=False)
|
|
173
|
+
|
|
174
|
+
def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "rgba":
|
|
175
|
+
"""Converts the color to grayscale using the luminance formula.\n
|
|
176
|
+
---------------------------------------------------------------------------
|
|
177
|
+
- `method` -⠀the luminance calculation method to use:
|
|
178
|
+
* `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
|
|
179
|
+
* `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
|
|
180
|
+
* `"simple"` Simple arithmetic mean (less accurate)
|
|
181
|
+
* `"bt601"` ITU-R BT.601 standard (older TV standard)"""
|
|
182
|
+
# THE 'method' PARAM IS CHECKED IN 'Color.luminance()'
|
|
183
|
+
self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method))
|
|
184
|
+
return rgba(self.r, self.g, self.b, self.a, _validate=False)
|
|
185
|
+
|
|
186
|
+
def blend(self, other: Rgba, ratio: float = 0.5, additive_alpha: bool = False) -> "rgba":
|
|
187
|
+
"""Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n
|
|
188
|
+
----------------------------------------------------------------------------------------------------------
|
|
189
|
+
- `other` -⠀the other RGBA color to blend with
|
|
190
|
+
- `ratio` -⠀the blend ratio between the two colors:
|
|
191
|
+
* if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
|
|
192
|
+
* if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
|
|
193
|
+
* if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)
|
|
194
|
+
- `additive_alpha` -⠀whether to blend the alpha channels additively or not"""
|
|
195
|
+
if not isinstance(other, rgba):
|
|
196
|
+
if Color.is_valid_rgba(other):
|
|
197
|
+
other = Color.to_rgba(other)
|
|
198
|
+
else:
|
|
199
|
+
raise TypeError(f"The 'other' parameter must be a valid RGBA color, got {type(other)}")
|
|
200
|
+
if not isinstance(ratio, float):
|
|
201
|
+
raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}")
|
|
202
|
+
elif not (0.0 <= ratio <= 1.0):
|
|
203
|
+
raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}")
|
|
204
|
+
if not isinstance(additive_alpha, bool):
|
|
205
|
+
raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}")
|
|
206
|
+
|
|
207
|
+
ratio *= 2
|
|
208
|
+
self.r = int(max(0, min(255, int(round((self.r * (2 - ratio)) + (other.r * ratio))))))
|
|
209
|
+
self.g = int(max(0, min(255, int(round((self.g * (2 - ratio)) + (other.g * ratio))))))
|
|
210
|
+
self.b = int(max(0, min(255, int(round((self.b * (2 - ratio)) + (other.b * ratio))))))
|
|
211
|
+
none_alpha = self.a is None and (len(other) <= 3 or other[3] is None)
|
|
212
|
+
|
|
213
|
+
if not none_alpha:
|
|
214
|
+
self_a = 1 if self.a is None else self.a
|
|
215
|
+
other_a = (other[3] if other[3] is not None else 1) if len(other) > 3 else 1
|
|
216
|
+
|
|
217
|
+
if additive_alpha:
|
|
218
|
+
self.a = max(0, min(1, (self_a * (2 - ratio)) + (other_a * ratio)))
|
|
219
|
+
else:
|
|
220
|
+
self.a = max(0, min(1, (self_a * (1 - (ratio / 2))) + (other_a * (ratio / 2))))
|
|
221
|
+
|
|
222
|
+
else:
|
|
223
|
+
self.a = None
|
|
224
|
+
|
|
225
|
+
return rgba(self.r, self.g, self.b, None if none_alpha else self.a, _validate=False)
|
|
226
|
+
|
|
227
|
+
def is_dark(self) -> bool:
|
|
228
|
+
"""Returns `True` if the color is considered dark (`lightness < 50%`)."""
|
|
229
|
+
return self.to_hsla().is_dark()
|
|
230
|
+
|
|
231
|
+
def is_light(self) -> bool:
|
|
232
|
+
"""Returns `True` if the color is considered light (`lightness >= 50%`)."""
|
|
233
|
+
return not self.is_dark()
|
|
234
|
+
|
|
235
|
+
def is_grayscale(self) -> bool:
|
|
236
|
+
"""Returns `True` if the color is grayscale."""
|
|
237
|
+
return self.r == self.g == self.b
|
|
238
|
+
|
|
239
|
+
def is_opaque(self) -> bool:
|
|
240
|
+
"""Returns `True` if the color has no transparency."""
|
|
241
|
+
return self.a == 1 or self.a is None
|
|
242
|
+
|
|
243
|
+
def with_alpha(self, alpha: float) -> "rgba":
|
|
244
|
+
"""Returns a new color with the specified alpha value."""
|
|
245
|
+
if not isinstance(alpha, float):
|
|
246
|
+
raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}")
|
|
247
|
+
elif not (0.0 <= alpha <= 1.0):
|
|
248
|
+
raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}")
|
|
249
|
+
|
|
250
|
+
return rgba(self.r, self.g, self.b, alpha, _validate=False)
|
|
251
|
+
|
|
252
|
+
def complementary(self) -> "rgba":
|
|
253
|
+
"""Returns the complementary color (180 degrees on the color wheel)."""
|
|
254
|
+
return self.to_hsla().complementary().to_rgba()
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _rgb_to_hsl(r: int, g: int, b: int) -> tuple:
|
|
258
|
+
"""Internal method to convert RGB to HSL color space."""
|
|
259
|
+
_r, _g, _b = r / 255.0, g / 255.0, b / 255.0
|
|
260
|
+
max_c, min_c = max(_r, _g, _b), min(_r, _g, _b)
|
|
261
|
+
l = (max_c + min_c) / 2
|
|
262
|
+
|
|
263
|
+
if max_c == min_c:
|
|
264
|
+
h = s = 0.0
|
|
265
|
+
else:
|
|
266
|
+
delta = max_c - min_c
|
|
267
|
+
s = delta / (1 - abs(2 * l - 1))
|
|
268
|
+
|
|
269
|
+
if max_c == _r:
|
|
270
|
+
h = ((_g - _b) / delta) % 6
|
|
271
|
+
elif max_c == _g:
|
|
272
|
+
h = ((_b - _r) / delta) + 2
|
|
273
|
+
else:
|
|
274
|
+
h = ((_r - _g) / delta) + 4
|
|
275
|
+
h /= 6
|
|
276
|
+
|
|
277
|
+
return int(round(h * 360)), int(round(s * 100)), int(round(l * 100))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class hsla:
|
|
281
|
+
"""A HSL/HSLA color object that includes a bunch of methods to manipulate the color.\n
|
|
282
|
+
---------------------------------------------------------------------------------------
|
|
283
|
+
- `h` -⠀the hue channel in range [0, 360] inclusive
|
|
284
|
+
- `s` -⠀the saturation channel in range [0, 100] inclusive
|
|
285
|
+
- `l` -⠀the lightness channel in range [0, 100] inclusive
|
|
286
|
+
- `a` -⠀the alpha channel in range [0.0, 1.0] inclusive
|
|
287
|
+
or `None` if the color has no alpha channel\n
|
|
288
|
+
---------------------------------------------------------------------------------------
|
|
289
|
+
Includes methods:
|
|
290
|
+
- `to_rgba()` to convert to RGB color
|
|
291
|
+
- `to_hexa()` to convert to HEX color
|
|
292
|
+
- `has_alpha()` to check if the color has an alpha channel
|
|
293
|
+
- `lighten(amount)` to create a lighter version of the color
|
|
294
|
+
- `darken(amount)` to create a darker version of the color
|
|
295
|
+
- `saturate(amount)` to increase color saturation
|
|
296
|
+
- `desaturate(amount)` to decrease color saturation
|
|
297
|
+
- `rotate(degrees)` to rotate the hue by degrees
|
|
298
|
+
- `invert()` to get the inverse color
|
|
299
|
+
- `grayscale()` to convert to grayscale
|
|
300
|
+
- `blend(other, ratio)` to blend with another color
|
|
301
|
+
- `is_dark()` to check if the color is considered dark
|
|
302
|
+
- `is_light()` to check if the color is considered light
|
|
303
|
+
- `is_grayscale()` to check if the color is grayscale
|
|
304
|
+
- `is_opaque()` to check if the color has no transparency
|
|
305
|
+
- `with_alpha(alpha)` to create a new color with different alpha
|
|
306
|
+
- `complementary()` to get the complementary color"""
|
|
307
|
+
|
|
308
|
+
def __init__(self, h: int, s: int, l: int, a: Optional[float] = None, _validate: bool = True):
|
|
309
|
+
self.h: int
|
|
310
|
+
"""The hue channel in range [0, 360] inclusive."""
|
|
311
|
+
self.s: int
|
|
312
|
+
"""The saturation channel in range [0, 100] inclusive."""
|
|
313
|
+
self.l: int
|
|
314
|
+
"""The lightness channel in range [0, 100] inclusive."""
|
|
315
|
+
self.a: Optional[float]
|
|
316
|
+
"""The alpha channel in range [0.0, 1.0] inclusive or `None` if not set."""
|
|
317
|
+
|
|
318
|
+
if not _validate:
|
|
319
|
+
self.h, self.s, self.l, self.a = h, s, l, a
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
if not (0 <= h <= 360):
|
|
323
|
+
raise ValueError(f"The 'h' parameter must be in range [0, 360] inclusive, got {h!r}")
|
|
324
|
+
if not all((0 <= x <= 100) for x in (s, l)):
|
|
325
|
+
raise ValueError(f"The 's' and 'l' parameters must be in range [0, 100] inclusive, got {s=} {l=}")
|
|
326
|
+
if a is not None and not (0.0 <= a <= 1.0):
|
|
327
|
+
raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0] inclusive, got {a!r}")
|
|
328
|
+
|
|
329
|
+
self.h, self.s, self.l = h, s, l
|
|
330
|
+
self.a = None if a is None else (1.0 if a > 1.0 else float(a))
|
|
331
|
+
|
|
332
|
+
def __len__(self) -> int:
|
|
333
|
+
"""The number of components in the color (3 or 4)."""
|
|
334
|
+
return 3 if self.a is None else 4
|
|
335
|
+
|
|
336
|
+
def __iter__(self) -> Iterator:
|
|
337
|
+
return iter((self.h, self.s, self.l) + (() if self.a is None else (self.a, )))
|
|
338
|
+
|
|
339
|
+
def __getitem__(self, index: int) -> int | float:
|
|
340
|
+
return ((self.h, self.s, self.l) + (() if self.a is None else (self.a, )))[index]
|
|
341
|
+
|
|
342
|
+
def __eq__(self, other: object) -> bool:
|
|
343
|
+
"""Check if two `hsla` objects are the same color."""
|
|
344
|
+
if not isinstance(other, hsla):
|
|
345
|
+
return False
|
|
346
|
+
return (self.h, self.s, self.l, self.a) == (other.h, other.s, other.l, other.a)
|
|
347
|
+
|
|
348
|
+
def __ne__(self, other: object) -> bool:
|
|
349
|
+
"""Check if two `hsla` objects are different colors."""
|
|
350
|
+
return not self.__eq__(other)
|
|
351
|
+
|
|
352
|
+
def __repr__(self) -> str:
|
|
353
|
+
return f"hsla({self.h}°, {self.s}%, {self.l}%{'' if self.a is None else f', {self.a}'})"
|
|
354
|
+
|
|
355
|
+
def __str__(self) -> str:
|
|
356
|
+
return self.__repr__()
|
|
357
|
+
|
|
358
|
+
def dict(self) -> dict:
|
|
359
|
+
"""Returns the color components as a dictionary with keys `"h"`, `"s"`, `"l"` and optionally `"a"`."""
|
|
360
|
+
return dict(h=self.h, s=self.s, l=self.l) if self.a is None else dict(h=self.h, s=self.s, l=self.l, a=self.a)
|
|
361
|
+
|
|
362
|
+
def values(self) -> tuple:
|
|
363
|
+
"""Returns the color components as separate values `h, s, l, a`."""
|
|
364
|
+
return self.h, self.s, self.l, self.a
|
|
365
|
+
|
|
366
|
+
def to_rgba(self) -> "rgba":
|
|
367
|
+
"""Returns the color as `rgba()` color object."""
|
|
368
|
+
r, g, b = self._hsl_to_rgb(self.h, self.s, self.l)
|
|
369
|
+
return rgba(r, g, b, self.a, _validate=False)
|
|
370
|
+
|
|
371
|
+
def to_hexa(self) -> "hexa":
|
|
372
|
+
"""Returns the color as `hexa()` color object."""
|
|
373
|
+
r, g, b = self._hsl_to_rgb(self.h, self.s, self.l)
|
|
374
|
+
return hexa("", r, g, b, self.a)
|
|
375
|
+
|
|
376
|
+
def has_alpha(self) -> bool:
|
|
377
|
+
"""Returns `True` if the color has an alpha channel and `False` otherwise."""
|
|
378
|
+
return self.a is not None
|
|
379
|
+
|
|
380
|
+
def lighten(self, amount: float) -> "hsla":
|
|
381
|
+
"""Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
|
|
382
|
+
if not isinstance(amount, float):
|
|
383
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
384
|
+
elif not (0.0 <= amount <= 1.0):
|
|
385
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
386
|
+
|
|
387
|
+
self.l = int(min(100, self.l + (100 - self.l) * amount))
|
|
388
|
+
return hsla(self.h, self.s, self.l, self.a, _validate=False)
|
|
389
|
+
|
|
390
|
+
def darken(self, amount: float) -> "hsla":
|
|
391
|
+
"""Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
|
|
392
|
+
if not isinstance(amount, float):
|
|
393
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
394
|
+
elif not (0.0 <= amount <= 1.0):
|
|
395
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
396
|
+
|
|
397
|
+
self.l = int(max(0, self.l * (1 - amount)))
|
|
398
|
+
return hsla(self.h, self.s, self.l, self.a, _validate=False)
|
|
399
|
+
|
|
400
|
+
def saturate(self, amount: float) -> "hsla":
|
|
401
|
+
"""Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
|
|
402
|
+
if not isinstance(amount, float):
|
|
403
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
404
|
+
elif not (0.0 <= amount <= 1.0):
|
|
405
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
406
|
+
|
|
407
|
+
self.s = int(min(100, self.s + (100 - self.s) * amount))
|
|
408
|
+
return hsla(self.h, self.s, self.l, self.a, _validate=False)
|
|
409
|
+
|
|
410
|
+
def desaturate(self, amount: float) -> "hsla":
|
|
411
|
+
"""Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
|
|
412
|
+
if not isinstance(amount, float):
|
|
413
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
414
|
+
elif not (0.0 <= amount <= 1.0):
|
|
415
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
416
|
+
|
|
417
|
+
self.s = int(max(0, self.s * (1 - amount)))
|
|
418
|
+
return hsla(self.h, self.s, self.l, self.a, _validate=False)
|
|
419
|
+
|
|
420
|
+
def rotate(self, degrees: int) -> "hsla":
|
|
421
|
+
"""Rotates the colors hue by the specified number of degrees."""
|
|
422
|
+
if not isinstance(degrees, int):
|
|
423
|
+
raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}")
|
|
424
|
+
|
|
425
|
+
self.h = (self.h + degrees) % 360
|
|
426
|
+
return hsla(self.h, self.s, self.l, self.a, _validate=False)
|
|
427
|
+
|
|
428
|
+
def invert(self, invert_alpha: bool = False) -> "hsla":
|
|
429
|
+
"""Inverts the color by rotating hue by 180 degrees and inverting lightness."""
|
|
430
|
+
if not isinstance(invert_alpha, bool):
|
|
431
|
+
raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}")
|
|
432
|
+
|
|
433
|
+
self.h = (self.h + 180) % 360
|
|
434
|
+
self.l = 100 - self.l
|
|
435
|
+
if invert_alpha and self.a is not None:
|
|
436
|
+
self.a = 1 - self.a
|
|
437
|
+
|
|
438
|
+
return hsla(self.h, self.s, self.l, self.a, _validate=False)
|
|
439
|
+
|
|
440
|
+
def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "hsla":
|
|
441
|
+
"""Converts the color to grayscale using the luminance formula.\n
|
|
442
|
+
---------------------------------------------------------------------------
|
|
443
|
+
- `method` -⠀the luminance calculation method to use:
|
|
444
|
+
* `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
|
|
445
|
+
* `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
|
|
446
|
+
* `"simple"` Simple arithmetic mean (less accurate)
|
|
447
|
+
* `"bt601"` ITU-R BT.601 standard (older TV standard)"""
|
|
448
|
+
# THE 'method' PARAM IS CHECKED IN 'Color.luminance()'
|
|
449
|
+
r, g, b = self._hsl_to_rgb(self.h, self.s, self.l)
|
|
450
|
+
l = int(Color.luminance(r, g, b, output_type=None, method=method))
|
|
451
|
+
self.h, self.s, self.l, _ = rgba(l, l, l, _validate=False).to_hsla().values()
|
|
452
|
+
return hsla(self.h, self.s, self.l, self.a, _validate=False)
|
|
453
|
+
|
|
454
|
+
def blend(self, other: Hsla, ratio: float = 0.5, additive_alpha: bool = False) -> "hsla":
|
|
455
|
+
"""Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n
|
|
456
|
+
----------------------------------------------------------------------------------------------------------
|
|
457
|
+
- `other` -⠀the other HSLA color to blend with
|
|
458
|
+
- `ratio` -⠀the blend ratio between the two colors:
|
|
459
|
+
* if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
|
|
460
|
+
* if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
|
|
461
|
+
* if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)
|
|
462
|
+
- `additive_alpha` -⠀whether to blend the alpha channels additively or not"""
|
|
463
|
+
if not Color.is_valid_hsla(other):
|
|
464
|
+
raise TypeError(f"The 'other' parameter must be a valid HSLA color, got {type(other)}")
|
|
465
|
+
if not isinstance(ratio, float):
|
|
466
|
+
raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}")
|
|
467
|
+
elif not (0.0 <= ratio <= 1.0):
|
|
468
|
+
raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}")
|
|
469
|
+
if not isinstance(additive_alpha, bool):
|
|
470
|
+
raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}")
|
|
471
|
+
|
|
472
|
+
self.h, self.s, self.l, self.a = self.to_rgba().blend(Color.to_rgba(other), ratio, additive_alpha).to_hsla().values()
|
|
473
|
+
return hsla(self.h, self.s, self.l, self.a, _validate=False)
|
|
474
|
+
|
|
475
|
+
def is_dark(self) -> bool:
|
|
476
|
+
"""Returns `True` if the color is considered dark (`lightness < 50%`)."""
|
|
477
|
+
return self.l < 50
|
|
478
|
+
|
|
479
|
+
def is_light(self) -> bool:
|
|
480
|
+
"""Returns `True` if the color is considered light (`lightness >= 50%`)."""
|
|
481
|
+
return not self.is_dark()
|
|
482
|
+
|
|
483
|
+
def is_grayscale(self) -> bool:
|
|
484
|
+
"""Returns `True` if the color is considered grayscale."""
|
|
485
|
+
return self.s == 0
|
|
486
|
+
|
|
487
|
+
def is_opaque(self) -> bool:
|
|
488
|
+
"""Returns `True` if the color has no transparency."""
|
|
489
|
+
return self.a == 1 or self.a is None
|
|
490
|
+
|
|
491
|
+
def with_alpha(self, alpha: float) -> "hsla":
|
|
492
|
+
"""Returns a new color with the specified alpha value."""
|
|
493
|
+
if not isinstance(alpha, float):
|
|
494
|
+
raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}")
|
|
495
|
+
elif not (0.0 <= alpha <= 1.0):
|
|
496
|
+
raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}")
|
|
497
|
+
|
|
498
|
+
return hsla(self.h, self.s, self.l, alpha, _validate=False)
|
|
499
|
+
|
|
500
|
+
def complementary(self) -> "hsla":
|
|
501
|
+
"""Returns the complementary color (180 degrees on the color wheel)."""
|
|
502
|
+
return hsla((self.h + 180) % 360, self.s, self.l, self.a, _validate=False)
|
|
503
|
+
|
|
504
|
+
@classmethod
|
|
505
|
+
def _hsl_to_rgb(cls, h: int, s: int, l: int) -> tuple:
|
|
506
|
+
"""Internal method to convert HSL to RGB color space."""
|
|
507
|
+
_h, _s, _l = h / 360, s / 100, l / 100
|
|
508
|
+
|
|
509
|
+
if _s == 0:
|
|
510
|
+
r = g = b = int(_l * 255)
|
|
511
|
+
else:
|
|
512
|
+
q = _l * (1 + _s) if _l < 0.5 else _l + _s - _l * _s
|
|
513
|
+
p = 2 * _l - q
|
|
514
|
+
r = int(round(cls._hue_to_rgb(p, q, _h + 1 / 3) * 255))
|
|
515
|
+
g = int(round(cls._hue_to_rgb(p, q, _h) * 255))
|
|
516
|
+
b = int(round(cls._hue_to_rgb(p, q, _h - 1 / 3) * 255))
|
|
517
|
+
|
|
518
|
+
return r, g, b
|
|
519
|
+
|
|
520
|
+
@staticmethod
|
|
521
|
+
def _hue_to_rgb(p: float, q: float, t: float) -> float:
|
|
522
|
+
if t < 0:
|
|
523
|
+
t += 1
|
|
524
|
+
if t > 1:
|
|
525
|
+
t -= 1
|
|
526
|
+
if t < 1 / 6:
|
|
527
|
+
return p + (q - p) * 6 * t
|
|
528
|
+
if t < 1 / 2:
|
|
529
|
+
return q
|
|
530
|
+
if t < 2 / 3:
|
|
531
|
+
return p + (q - p) * (2 / 3 - t) * 6
|
|
532
|
+
return p
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
class hexa:
|
|
536
|
+
"""A HEXA color object that includes a bunch of methods to manipulate the color.\n
|
|
537
|
+
--------------------------------------------------------------------------------------------
|
|
538
|
+
- `color` -⠀the HEXA color string (prefix optional) or HEX integer, that can be in formats:
|
|
539
|
+
* `RGB` short format without alpha (only for strings)
|
|
540
|
+
* `RGBA` short format with alpha (only for strings)
|
|
541
|
+
* `RRGGBB` long format without alpha (for strings and HEX integers)
|
|
542
|
+
* `RRGGBBAA` long format with alpha (for strings and HEX integers)
|
|
543
|
+
--------------------------------------------------------------------------------------------
|
|
544
|
+
Includes methods:
|
|
545
|
+
- `to_rgba()` to convert to RGB color
|
|
546
|
+
- `to_hsla()` to convert to HSL color
|
|
547
|
+
- `has_alpha()` to check if the color has an alpha channel
|
|
548
|
+
- `lighten(amount)` to create a lighter version of the color
|
|
549
|
+
- `darken(amount)` to create a darker version of the color
|
|
550
|
+
- `saturate(amount)` to increase color saturation
|
|
551
|
+
- `desaturate(amount)` to decrease color saturation
|
|
552
|
+
- `rotate(degrees)` to rotate the hue by degrees
|
|
553
|
+
- `invert()` to get the inverse color
|
|
554
|
+
- `grayscale()` to convert to grayscale
|
|
555
|
+
- `blend(other, ratio)` to blend with another color
|
|
556
|
+
- `is_dark()` to check if the color is considered dark
|
|
557
|
+
- `is_light()` to check if the color is considered light
|
|
558
|
+
- `is_grayscale()` to check if the color is grayscale
|
|
559
|
+
- `is_opaque()` to check if the color has no transparency
|
|
560
|
+
- `with_alpha(alpha)` to create a new color with different alpha
|
|
561
|
+
- `complementary()` to get the complementary color"""
|
|
562
|
+
|
|
563
|
+
def __init__(
|
|
564
|
+
self,
|
|
565
|
+
color: str | int,
|
|
566
|
+
_r: Optional[int] = None,
|
|
567
|
+
_g: Optional[int] = None,
|
|
568
|
+
_b: Optional[int] = None,
|
|
569
|
+
_a: Optional[float] = None,
|
|
570
|
+
):
|
|
571
|
+
self.r: int
|
|
572
|
+
"""The red channel in range [0, 255] inclusive."""
|
|
573
|
+
self.g: int
|
|
574
|
+
"""The green channel in range [0, 255] inclusive."""
|
|
575
|
+
self.b: int
|
|
576
|
+
"""The blue channel in range [0, 255] inclusive."""
|
|
577
|
+
self.a: Optional[float]
|
|
578
|
+
"""The alpha channel in range [0.0, 1.0] inclusive or `None` if not set."""
|
|
579
|
+
|
|
580
|
+
if all(x is not None for x in (_r, _g, _b)):
|
|
581
|
+
self.r, self.g, self.b, self.a = cast(int, _r), cast(int, _g), cast(int, _b), _a
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
if isinstance(color, hexa):
|
|
585
|
+
raise ValueError("Color is already a hexa() color object.")
|
|
586
|
+
|
|
587
|
+
elif isinstance(color, str):
|
|
588
|
+
if color.startswith("#"):
|
|
589
|
+
color = color[1:].upper()
|
|
590
|
+
elif color.startswith("0x"):
|
|
591
|
+
color = color[2:].upper()
|
|
592
|
+
|
|
593
|
+
if len(color) == 3: # RGB
|
|
594
|
+
self.r, self.g, self.b, self.a = (
|
|
595
|
+
int(color[0] * 2, 16),
|
|
596
|
+
int(color[1] * 2, 16),
|
|
597
|
+
int(color[2] * 2, 16),
|
|
598
|
+
None,
|
|
599
|
+
)
|
|
600
|
+
elif len(color) == 4: # RGBA
|
|
601
|
+
self.r, self.g, self.b, self.a = (
|
|
602
|
+
int(color[0] * 2, 16),
|
|
603
|
+
int(color[1] * 2, 16),
|
|
604
|
+
int(color[2] * 2, 16),
|
|
605
|
+
int(color[3] * 2, 16) / 255.0,
|
|
606
|
+
)
|
|
607
|
+
elif len(color) == 6: # RRGGBB
|
|
608
|
+
self.r, self.g, self.b, self.a = (
|
|
609
|
+
int(color[0:2], 16),
|
|
610
|
+
int(color[2:4], 16),
|
|
611
|
+
int(color[4:6], 16),
|
|
612
|
+
None,
|
|
613
|
+
)
|
|
614
|
+
elif len(color) == 8: # RRGGBBAA
|
|
615
|
+
self.r, self.g, self.b, self.a = (
|
|
616
|
+
int(color[0:2], 16),
|
|
617
|
+
int(color[2:4], 16),
|
|
618
|
+
int(color[4:6], 16),
|
|
619
|
+
int(color[6:8], 16) / 255.0,
|
|
620
|
+
)
|
|
621
|
+
else:
|
|
622
|
+
raise ValueError(f"Invalid HEXA color string '{color}'. Must be in formats RGB, RGBA, RRGGBB or RRGGBBAA.")
|
|
623
|
+
|
|
624
|
+
elif isinstance(color, int):
|
|
625
|
+
self.r, self.g, self.b, self.a = Color.hex_int_to_rgba(color).values()
|
|
626
|
+
else:
|
|
627
|
+
raise TypeError(f"The 'color' parameter must be a string or integer, got {type(color)}")
|
|
628
|
+
|
|
629
|
+
def __len__(self) -> int:
|
|
630
|
+
"""The number of components in the color (3 or 4)."""
|
|
631
|
+
return 3 if self.a is None else 4
|
|
632
|
+
|
|
633
|
+
def __iter__(self) -> Iterator:
|
|
634
|
+
return iter((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}")
|
|
635
|
+
+ (() if self.a is None else (f"{int(self.a * 255):02X}", )))
|
|
636
|
+
|
|
637
|
+
def __getitem__(self, index: int) -> str | int:
|
|
638
|
+
return ((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}") \
|
|
639
|
+
+ (() if self.a is None else (f"{int(self.a * 255):02X}", )))[index]
|
|
640
|
+
|
|
641
|
+
def __eq__(self, other: object) -> bool:
|
|
642
|
+
"""Check if two `hexa` objects are the same color."""
|
|
643
|
+
if not isinstance(other, hexa):
|
|
644
|
+
return False
|
|
645
|
+
return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a)
|
|
646
|
+
|
|
647
|
+
def __ne__(self, other: object) -> bool:
|
|
648
|
+
"""Check if two `hexa` objects are different colors."""
|
|
649
|
+
return not self.__eq__(other)
|
|
650
|
+
|
|
651
|
+
def __repr__(self) -> str:
|
|
652
|
+
return f"hexa(#{self.r:02X}{self.g:02X}{self.b:02X}{'' if self.a is None else f'{int(self.a * 255):02X}'})"
|
|
653
|
+
|
|
654
|
+
def __str__(self) -> str:
|
|
655
|
+
return f"#{self.r:02X}{self.g:02X}{self.b:02X}{'' if self.a is None else f'{int(self.a * 255):02X}'}"
|
|
656
|
+
|
|
657
|
+
def dict(self) -> dict:
|
|
658
|
+
"""Returns the color components as a dictionary with hex string values for keys `"r"`, `"g"`, `"b"` and optionally `"a"`."""
|
|
659
|
+
return (
|
|
660
|
+
dict(r=f"{self.r:02X}", g=f"{self.g:02X}", b=f"{self.b:02X}") if self.a is None else dict(
|
|
661
|
+
r=f"{self.r:02X}",
|
|
662
|
+
g=f"{self.g:02X}",
|
|
663
|
+
b=f"{self.b:02X}",
|
|
664
|
+
a=f"{int(self.a * 255):02X}",
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
def values(self, round_alpha: bool = True) -> tuple:
|
|
669
|
+
"""Returns the color components as separate values `r, g, b, a`."""
|
|
670
|
+
if not isinstance(round_alpha, bool):
|
|
671
|
+
raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}")
|
|
672
|
+
|
|
673
|
+
return self.r, self.g, self.b, None if self.a is None else (round(self.a, 2) if round_alpha else self.a)
|
|
674
|
+
|
|
675
|
+
def to_rgba(self, round_alpha: bool = True) -> "rgba":
|
|
676
|
+
"""Returns the color as `rgba()` color object."""
|
|
677
|
+
if not isinstance(round_alpha, bool):
|
|
678
|
+
raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}")
|
|
679
|
+
|
|
680
|
+
return rgba(
|
|
681
|
+
self.r,
|
|
682
|
+
self.g,
|
|
683
|
+
self.b,
|
|
684
|
+
None if self.a is None else (round(self.a, 2) if round_alpha else self.a),
|
|
685
|
+
_validate=False,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
def to_hsla(self, round_alpha: bool = True) -> "hsla":
|
|
689
|
+
"""Returns the color as `hsla()` color object."""
|
|
690
|
+
if not isinstance(round_alpha, bool):
|
|
691
|
+
raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}")
|
|
692
|
+
|
|
693
|
+
return self.to_rgba(round_alpha).to_hsla()
|
|
694
|
+
|
|
695
|
+
def has_alpha(self) -> bool:
|
|
696
|
+
"""Returns `True` if the color has an alpha channel and `False` otherwise."""
|
|
697
|
+
return self.a is not None
|
|
698
|
+
|
|
699
|
+
def lighten(self, amount: float) -> "hexa":
|
|
700
|
+
"""Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
|
|
701
|
+
if not isinstance(amount, float):
|
|
702
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
703
|
+
elif not (0.0 <= amount <= 1.0):
|
|
704
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
705
|
+
|
|
706
|
+
self.r, self.g, self.b, self.a = self.to_rgba(False).lighten(amount).values()
|
|
707
|
+
return hexa("", self.r, self.g, self.b, self.a)
|
|
708
|
+
|
|
709
|
+
def darken(self, amount: float) -> "hexa":
|
|
710
|
+
"""Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive."""
|
|
711
|
+
if not isinstance(amount, float):
|
|
712
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
713
|
+
elif not (0.0 <= amount <= 1.0):
|
|
714
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
715
|
+
|
|
716
|
+
self.r, self.g, self.b, self.a = self.to_rgba(False).darken(amount).values()
|
|
717
|
+
return hexa("", self.r, self.g, self.b, self.a)
|
|
718
|
+
|
|
719
|
+
def saturate(self, amount: float) -> "hexa":
|
|
720
|
+
"""Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
|
|
721
|
+
if not isinstance(amount, float):
|
|
722
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
723
|
+
elif not (0.0 <= amount <= 1.0):
|
|
724
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
725
|
+
|
|
726
|
+
self.r, self.g, self.b, self.a = self.to_rgba(False).saturate(amount).values()
|
|
727
|
+
return hexa("", self.r, self.g, self.b, self.a)
|
|
728
|
+
|
|
729
|
+
def desaturate(self, amount: float) -> "hexa":
|
|
730
|
+
"""Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive."""
|
|
731
|
+
if not isinstance(amount, float):
|
|
732
|
+
raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}")
|
|
733
|
+
elif not (0.0 <= amount <= 1.0):
|
|
734
|
+
raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}")
|
|
735
|
+
|
|
736
|
+
self.r, self.g, self.b, self.a = self.to_rgba(False).desaturate(amount).values()
|
|
737
|
+
return hexa("", self.r, self.g, self.b, self.a)
|
|
738
|
+
|
|
739
|
+
def rotate(self, degrees: int) -> "hexa":
|
|
740
|
+
"""Rotates the colors hue by the specified number of degrees."""
|
|
741
|
+
if not isinstance(degrees, int):
|
|
742
|
+
raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}")
|
|
743
|
+
|
|
744
|
+
self.r, self.g, self.b, self.a = self.to_rgba(False).rotate(degrees).values()
|
|
745
|
+
return hexa("", self.r, self.g, self.b, self.a)
|
|
746
|
+
|
|
747
|
+
def invert(self, invert_alpha: bool = False) -> "hexa":
|
|
748
|
+
"""Inverts the color by rotating hue by 180 degrees and inverting lightness."""
|
|
749
|
+
if not isinstance(invert_alpha, bool):
|
|
750
|
+
raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}")
|
|
751
|
+
|
|
752
|
+
self.r, self.g, self.b, self.a = self.to_rgba(False).invert().values()
|
|
753
|
+
if invert_alpha and self.a is not None:
|
|
754
|
+
self.a = 1 - self.a
|
|
755
|
+
|
|
756
|
+
return hexa("", self.r, self.g, self.b, self.a)
|
|
757
|
+
|
|
758
|
+
def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "hexa":
|
|
759
|
+
"""Converts the color to grayscale using the luminance formula.\n
|
|
760
|
+
---------------------------------------------------------------------------
|
|
761
|
+
- `method` -⠀the luminance calculation method to use:
|
|
762
|
+
* `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
|
|
763
|
+
* `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
|
|
764
|
+
* `"simple"` Simple arithmetic mean (less accurate)
|
|
765
|
+
* `"bt601"` ITU-R BT.601 standard (older TV standard)"""
|
|
766
|
+
# THE 'method' PARAM IS CHECKED IN 'Color.luminance()'
|
|
767
|
+
self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method))
|
|
768
|
+
return hexa("", self.r, self.g, self.b, self.a)
|
|
769
|
+
|
|
770
|
+
def blend(self, other: Hexa, ratio: float = 0.5, additive_alpha: bool = False) -> "hexa":
|
|
771
|
+
"""Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n
|
|
772
|
+
----------------------------------------------------------------------------------------------------------
|
|
773
|
+
- `other` -⠀the other HEXA color to blend with
|
|
774
|
+
- `ratio` -⠀the blend ratio between the two colors:
|
|
775
|
+
* if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
|
|
776
|
+
* if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
|
|
777
|
+
* if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)
|
|
778
|
+
- `additive_alpha` -⠀whether to blend the alpha channels additively or not"""
|
|
779
|
+
if not Color.is_valid_hexa(other):
|
|
780
|
+
raise TypeError(f"The 'other' parameter must be a valid HEXA color, got {type(other)}")
|
|
781
|
+
if not isinstance(ratio, float):
|
|
782
|
+
raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}")
|
|
783
|
+
elif not (0.0 <= ratio <= 1.0):
|
|
784
|
+
raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}")
|
|
785
|
+
if not isinstance(additive_alpha, bool):
|
|
786
|
+
raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}")
|
|
787
|
+
|
|
788
|
+
self.r, self.g, self.b, self.a = self.to_rgba(False).blend(Color.to_rgba(other), ratio, additive_alpha).values()
|
|
789
|
+
return hexa("", self.r, self.g, self.b, self.a)
|
|
790
|
+
|
|
791
|
+
def is_dark(self) -> bool:
|
|
792
|
+
"""Returns `True` if the color is considered dark (`lightness < 50%`)."""
|
|
793
|
+
return self.to_hsla(False).is_dark()
|
|
794
|
+
|
|
795
|
+
def is_light(self) -> bool:
|
|
796
|
+
"""Returns `True` if the color is considered light (`lightness >= 50%`)."""
|
|
797
|
+
return not self.is_dark()
|
|
798
|
+
|
|
799
|
+
def is_grayscale(self) -> bool:
|
|
800
|
+
"""Returns `True` if the color is grayscale (`saturation == 0`)."""
|
|
801
|
+
return self.to_hsla(False).is_grayscale()
|
|
802
|
+
|
|
803
|
+
def is_opaque(self) -> bool:
|
|
804
|
+
"""Returns `True` if the color has no transparency (`alpha == 1.0`)."""
|
|
805
|
+
return self.a == 1 or self.a is None
|
|
806
|
+
|
|
807
|
+
def with_alpha(self, alpha: float) -> "hexa":
|
|
808
|
+
"""Returns a new color with the specified alpha value."""
|
|
809
|
+
if not isinstance(alpha, float):
|
|
810
|
+
raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}")
|
|
811
|
+
elif not (0.0 <= alpha <= 1.0):
|
|
812
|
+
raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}")
|
|
813
|
+
|
|
814
|
+
return hexa("", self.r, self.g, self.b, alpha)
|
|
815
|
+
|
|
816
|
+
def complementary(self) -> "hexa":
|
|
817
|
+
"""Returns the complementary color (180 degrees on the color wheel)."""
|
|
818
|
+
return self.to_hsla(False).complementary().to_hexa()
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
class Color:
|
|
822
|
+
"""This class includes methods to work with colors in different formats."""
|
|
823
|
+
|
|
824
|
+
@classmethod
|
|
825
|
+
def is_valid_rgba(cls, color: AnyRgba, allow_alpha: bool = True) -> bool:
|
|
826
|
+
"""Check if the given color is a valid RGBA color.\n
|
|
827
|
+
-----------------------------------------------------------------
|
|
828
|
+
- `color` -⠀the color to check (can be in any supported format)
|
|
829
|
+
- `allow_alpha` -⠀whether to allow alpha channel in the color"""
|
|
830
|
+
if not isinstance(allow_alpha, bool):
|
|
831
|
+
raise TypeError(f"The 'new_tab_size' parameter must be an boolean, got {type(allow_alpha)}")
|
|
832
|
+
|
|
833
|
+
try:
|
|
834
|
+
if isinstance(color, rgba):
|
|
835
|
+
return True
|
|
836
|
+
|
|
837
|
+
elif isinstance(color, (list, tuple)):
|
|
838
|
+
if allow_alpha and cls.has_alpha(color):
|
|
839
|
+
return (
|
|
840
|
+
0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255
|
|
841
|
+
and (0 <= color[3] <= 1 or color[3] is None)
|
|
842
|
+
)
|
|
843
|
+
elif len(color) == 3:
|
|
844
|
+
return 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255
|
|
845
|
+
else:
|
|
846
|
+
return False
|
|
847
|
+
|
|
848
|
+
elif isinstance(color, dict):
|
|
849
|
+
if allow_alpha and cls.has_alpha(color):
|
|
850
|
+
return (
|
|
851
|
+
0 <= color["r"] <= 255 and 0 <= color["g"] <= 255 and 0 <= color["b"] <= 255
|
|
852
|
+
and (0 <= color["a"] <= 1 or color["a"] is None)
|
|
853
|
+
)
|
|
854
|
+
elif len(color) == 3:
|
|
855
|
+
return 0 <= color["r"] <= 255 and 0 <= color["g"] <= 255 and 0 <= color["b"] <= 255
|
|
856
|
+
else:
|
|
857
|
+
return False
|
|
858
|
+
|
|
859
|
+
elif isinstance(color, str):
|
|
860
|
+
return bool(_re.fullmatch(Regex.rgba_str(fix_sep=None, allow_alpha=allow_alpha), color))
|
|
861
|
+
|
|
862
|
+
except Exception:
|
|
863
|
+
pass
|
|
864
|
+
return False
|
|
865
|
+
|
|
866
|
+
@classmethod
|
|
867
|
+
def is_valid_hsla(cls, color: AnyHsla, allow_alpha: bool = True) -> bool:
|
|
868
|
+
"""Check if the given color is a valid HSLA color.\n
|
|
869
|
+
-----------------------------------------------------------------
|
|
870
|
+
- `color` -⠀the color to check (can be in any supported format)
|
|
871
|
+
- `allow_alpha` -⠀whether to allow alpha channel in the color"""
|
|
872
|
+
try:
|
|
873
|
+
if isinstance(color, hsla):
|
|
874
|
+
return True
|
|
875
|
+
|
|
876
|
+
elif isinstance(color, (list, tuple)):
|
|
877
|
+
if allow_alpha and cls.has_alpha(color):
|
|
878
|
+
return (
|
|
879
|
+
0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100
|
|
880
|
+
and (0 <= color[3] <= 1 or color[3] is None)
|
|
881
|
+
)
|
|
882
|
+
elif len(color) == 3:
|
|
883
|
+
return 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100
|
|
884
|
+
else:
|
|
885
|
+
return False
|
|
886
|
+
|
|
887
|
+
elif isinstance(color, dict):
|
|
888
|
+
if allow_alpha and cls.has_alpha(color):
|
|
889
|
+
return (
|
|
890
|
+
0 <= color["h"] <= 360 and 0 <= color["s"] <= 100 and 0 <= color["l"] <= 100
|
|
891
|
+
and (0 <= color["a"] <= 1 or color["a"] is None)
|
|
892
|
+
)
|
|
893
|
+
elif len(color) == 3:
|
|
894
|
+
return 0 <= color["h"] <= 360 and 0 <= color["s"] <= 100 and 0 <= color["l"] <= 100
|
|
895
|
+
else:
|
|
896
|
+
return False
|
|
897
|
+
|
|
898
|
+
elif isinstance(color, str):
|
|
899
|
+
return bool(_re.fullmatch(Regex.hsla_str(fix_sep=None, allow_alpha=allow_alpha), color))
|
|
900
|
+
|
|
901
|
+
except Exception:
|
|
902
|
+
pass
|
|
903
|
+
return False
|
|
904
|
+
|
|
905
|
+
@classmethod
|
|
906
|
+
def is_valid_hexa(
|
|
907
|
+
cls,
|
|
908
|
+
color: AnyHexa,
|
|
909
|
+
allow_alpha: bool = True,
|
|
910
|
+
get_prefix: bool = False,
|
|
911
|
+
) -> bool | tuple[bool, Optional[Literal["#", "0x"]]]:
|
|
912
|
+
"""Check if the given color is a valid HEXA color.\n
|
|
913
|
+
---------------------------------------------------------------------------------------------------
|
|
914
|
+
- `color` -⠀the color to check (can be in any supported format)
|
|
915
|
+
- `allow_alpha` -⠀whether to allow alpha channel in the color
|
|
916
|
+
- `get_prefix` -⠀if true, the prefix used in the color (if any) is returned along with validity"""
|
|
917
|
+
try:
|
|
918
|
+
if isinstance(color, hexa):
|
|
919
|
+
return (True, "#") if get_prefix else True
|
|
920
|
+
|
|
921
|
+
elif isinstance(color, int):
|
|
922
|
+
is_valid = 0x000000 <= color <= (0xFFFFFFFF if allow_alpha else 0xFFFFFF)
|
|
923
|
+
return (is_valid, "0x") if get_prefix else is_valid
|
|
924
|
+
|
|
925
|
+
elif isinstance(color, str):
|
|
926
|
+
prefix: Optional[Literal["#", "0x"]]
|
|
927
|
+
color, prefix = ((color[1:], "#") if color.startswith("#") else
|
|
928
|
+
(color[2:], "0x") if color.startswith("0x") else (color, None))
|
|
929
|
+
return (
|
|
930
|
+
(bool(_re.fullmatch(Regex.hexa_str(allow_alpha=allow_alpha), color)), prefix) \
|
|
931
|
+
if get_prefix else bool(_re.fullmatch(Regex.hexa_str(allow_alpha=allow_alpha), color))
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
except Exception:
|
|
935
|
+
pass
|
|
936
|
+
return (False, None) if get_prefix else False
|
|
937
|
+
|
|
938
|
+
@classmethod
|
|
939
|
+
def is_valid(cls, color: AnyRgba | AnyHsla | AnyHexa, allow_alpha: bool = True) -> bool:
|
|
940
|
+
"""Check if the given color is a valid RGBA, HSLA or HEXA color.\n
|
|
941
|
+
-------------------------------------------------------------------
|
|
942
|
+
- `color` -⠀the color to check (can be in any supported format)
|
|
943
|
+
- `allow_alpha` -⠀whether to allow alpha channel in the color"""
|
|
944
|
+
return bool(
|
|
945
|
+
cls.is_valid_rgba(color, allow_alpha) \
|
|
946
|
+
or cls.is_valid_hsla(color, allow_alpha) \
|
|
947
|
+
or cls.is_valid_hexa(color, allow_alpha)
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
@classmethod
|
|
951
|
+
def has_alpha(cls, color: Rgba | Hsla | Hexa) -> bool:
|
|
952
|
+
"""Check if the given color has an alpha channel.\n
|
|
953
|
+
---------------------------------------------------------------------------
|
|
954
|
+
- `color` -⠀the color to check (can be in any supported format)"""
|
|
955
|
+
if isinstance(color, (rgba, hsla, hexa)):
|
|
956
|
+
return color.has_alpha()
|
|
957
|
+
|
|
958
|
+
if cls.is_valid_hexa(color):
|
|
959
|
+
if isinstance(color, str):
|
|
960
|
+
if color.startswith("#"):
|
|
961
|
+
color = color[1:]
|
|
962
|
+
elif color.startswith("0x"):
|
|
963
|
+
color = color[2:]
|
|
964
|
+
return len(color) == 4 or len(color) == 8
|
|
965
|
+
if isinstance(color, int):
|
|
966
|
+
hex_length = len(f"{color:X}")
|
|
967
|
+
return hex_length == 4 or hex_length == 8
|
|
968
|
+
|
|
969
|
+
elif isinstance(color, str):
|
|
970
|
+
if parsed_rgba := cls.str_to_rgba(color, only_first=True):
|
|
971
|
+
return cast(rgba, parsed_rgba).has_alpha()
|
|
972
|
+
if parsed_hsla := cls.str_to_hsla(color, only_first=True):
|
|
973
|
+
return cast(hsla, parsed_hsla).has_alpha()
|
|
974
|
+
|
|
975
|
+
elif isinstance(color, (list, tuple)) and len(color) == 4 and color[3] is not None:
|
|
976
|
+
return True
|
|
977
|
+
elif isinstance(color, dict) and len(color) == 4 and color["a"] is not None:
|
|
978
|
+
return True
|
|
979
|
+
|
|
980
|
+
return False
|
|
981
|
+
|
|
982
|
+
@classmethod
|
|
983
|
+
def to_rgba(cls, color: Rgba | Hsla | Hexa) -> rgba:
|
|
984
|
+
"""Will try to convert any color type to a color of type RGBA.\n
|
|
985
|
+
---------------------------------------------------------------------
|
|
986
|
+
- `color` -⠀the color to convert (can be in any supported format)"""
|
|
987
|
+
if isinstance(color, (hsla, hexa)):
|
|
988
|
+
return color.to_rgba()
|
|
989
|
+
elif cls.is_valid_hsla(color):
|
|
990
|
+
return cls._parse_hsla(color).to_rgba()
|
|
991
|
+
elif cls.is_valid_hexa(color):
|
|
992
|
+
return hexa(cast(str | int, color)).to_rgba()
|
|
993
|
+
elif cls.is_valid_rgba(color):
|
|
994
|
+
return cls._parse_rgba(color)
|
|
995
|
+
raise ValueError(f"Could not convert color {color!r} to RGBA.")
|
|
996
|
+
|
|
997
|
+
@classmethod
|
|
998
|
+
def to_hsla(cls, color: Rgba | Hsla | Hexa) -> hsla:
|
|
999
|
+
"""Will try to convert any color type to a color of type HSLA.\n
|
|
1000
|
+
---------------------------------------------------------------------
|
|
1001
|
+
- `color` -⠀the color to convert (can be in any supported format)"""
|
|
1002
|
+
if isinstance(color, (rgba, hexa)):
|
|
1003
|
+
return color.to_hsla()
|
|
1004
|
+
elif cls.is_valid_rgba(color):
|
|
1005
|
+
return cls._parse_rgba(color).to_hsla()
|
|
1006
|
+
elif cls.is_valid_hexa(color):
|
|
1007
|
+
return hexa(cast(str | int, color)).to_hsla()
|
|
1008
|
+
elif cls.is_valid_hsla(color):
|
|
1009
|
+
return cls._parse_hsla(color)
|
|
1010
|
+
raise ValueError(f"Could not convert color {color!r} to HSLA.")
|
|
1011
|
+
|
|
1012
|
+
@classmethod
|
|
1013
|
+
def to_hexa(cls, color: Rgba | Hsla | Hexa) -> hexa:
|
|
1014
|
+
"""Will try to convert any color type to a color of type HEXA.\n
|
|
1015
|
+
---------------------------------------------------------------------
|
|
1016
|
+
- `color` -⠀the color to convert (can be in any supported format)"""
|
|
1017
|
+
if isinstance(color, (rgba, hsla)):
|
|
1018
|
+
return color.to_hexa()
|
|
1019
|
+
elif cls.is_valid_rgba(color):
|
|
1020
|
+
return cls._parse_rgba(color).to_hexa()
|
|
1021
|
+
elif cls.is_valid_hsla(color):
|
|
1022
|
+
return cls._parse_hsla(color).to_hexa()
|
|
1023
|
+
elif cls.is_valid_hexa(color):
|
|
1024
|
+
return color if isinstance(color, hexa) else hexa(cast(str | int, color))
|
|
1025
|
+
raise ValueError(f"Could not convert color {color!r} to HEXA")
|
|
1026
|
+
|
|
1027
|
+
@classmethod
|
|
1028
|
+
def str_to_rgba(cls, string: str, only_first: bool = False) -> Optional[rgba | list[rgba]]:
|
|
1029
|
+
"""Will try to recognize RGBA colors inside a string and output the found ones as RGBA objects.\n
|
|
1030
|
+
---------------------------------------------------------------------------------------------------------------
|
|
1031
|
+
- `string` -⠀the string to search for RGBA colors
|
|
1032
|
+
- `only_first` -⠀if true, only the first found color will be returned, otherwise a list of all found colors"""
|
|
1033
|
+
if only_first:
|
|
1034
|
+
if not (match := _re.search(Regex.rgba_str(allow_alpha=True), string)):
|
|
1035
|
+
return None
|
|
1036
|
+
m = match.groups()
|
|
1037
|
+
return rgba(
|
|
1038
|
+
int(m[0]),
|
|
1039
|
+
int(m[1]),
|
|
1040
|
+
int(m[2]),
|
|
1041
|
+
((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None),
|
|
1042
|
+
_validate=False,
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
else:
|
|
1046
|
+
if not (matches := _re.findall(Regex.rgba_str(allow_alpha=True), string)):
|
|
1047
|
+
return None
|
|
1048
|
+
return [
|
|
1049
|
+
rgba(
|
|
1050
|
+
int(m[0]),
|
|
1051
|
+
int(m[1]),
|
|
1052
|
+
int(m[2]),
|
|
1053
|
+
((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None),
|
|
1054
|
+
_validate=False,
|
|
1055
|
+
) for m in matches
|
|
1056
|
+
]
|
|
1057
|
+
|
|
1058
|
+
@classmethod
|
|
1059
|
+
def str_to_hsla(cls, string: str, only_first: bool = False) -> Optional[hsla | list[hsla]]:
|
|
1060
|
+
"""Will try to recognize HSLA colors inside a string and output the found ones as HSLA objects.\n
|
|
1061
|
+
---------------------------------------------------------------------------------------------------------------
|
|
1062
|
+
- `string` -⠀the string to search for HSLA colors
|
|
1063
|
+
- `only_first` -⠀if true, only the first found color will be returned, otherwise a list of all found colors"""
|
|
1064
|
+
if only_first:
|
|
1065
|
+
if not (match := _re.search(Regex.hsla_str(allow_alpha=True), string)):
|
|
1066
|
+
return None
|
|
1067
|
+
m = match.groups()
|
|
1068
|
+
return hsla(
|
|
1069
|
+
int(m[0]),
|
|
1070
|
+
int(m[1]),
|
|
1071
|
+
int(m[2]),
|
|
1072
|
+
((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None),
|
|
1073
|
+
_validate=False,
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
else:
|
|
1077
|
+
if not (matches := _re.findall(Regex.hsla_str(allow_alpha=True), string)):
|
|
1078
|
+
return None
|
|
1079
|
+
return [
|
|
1080
|
+
hsla(
|
|
1081
|
+
int(m[0]),
|
|
1082
|
+
int(m[1]),
|
|
1083
|
+
int(m[2]),
|
|
1084
|
+
((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None),
|
|
1085
|
+
_validate=False,
|
|
1086
|
+
) for m in matches
|
|
1087
|
+
]
|
|
1088
|
+
|
|
1089
|
+
@classmethod
|
|
1090
|
+
def rgba_to_hex_int(
|
|
1091
|
+
cls,
|
|
1092
|
+
r: int,
|
|
1093
|
+
g: int,
|
|
1094
|
+
b: int,
|
|
1095
|
+
a: Optional[float] = None,
|
|
1096
|
+
preserve_original: bool = False,
|
|
1097
|
+
) -> int:
|
|
1098
|
+
"""Convert RGBA channels to a HEXA integer (alpha is optional).\n
|
|
1099
|
+
--------------------------------------------------------------------------------------------
|
|
1100
|
+
- `r`, `g`, `b` -⠀the red, green and blue channels (`0` – `255`)
|
|
1101
|
+
- `a` -⠀the alpha channel (`0.0` – `1.0`) or `None` if not set
|
|
1102
|
+
- `preserve_original` -⠀whether to preserve the original color exactly (explained below)\n
|
|
1103
|
+
--------------------------------------------------------------------------------------------
|
|
1104
|
+
To preserve leading zeros, the function will add a `1` at the beginning, if the HEX integer
|
|
1105
|
+
would start with a `0`.
|
|
1106
|
+
This could affect the color a little bit, but will make sure, that it won't be interpreted
|
|
1107
|
+
as a completely different color, when initializing it as a `hexa()` color or changing it
|
|
1108
|
+
back to RGBA using `Color.hex_int_to_rgba()`."""
|
|
1109
|
+
if not all((0 <= c <= 255) for c in (r, g, b)):
|
|
1110
|
+
raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in [0, 255], got {r=} {g=} {b=}")
|
|
1111
|
+
if a is not None and not (0.0 <= a <= 1.0):
|
|
1112
|
+
raise ValueError(f"The 'a' parameter must be a float in [0.0, 1.0] or None, got {a!r}")
|
|
1113
|
+
|
|
1114
|
+
r = max(0, min(255, int(r)))
|
|
1115
|
+
g = max(0, min(255, int(g)))
|
|
1116
|
+
b = max(0, min(255, int(b)))
|
|
1117
|
+
|
|
1118
|
+
if a is None:
|
|
1119
|
+
hex_int = (r << 16) | (g << 8) | b
|
|
1120
|
+
if not preserve_original and (hex_int & 0xF00000) == 0:
|
|
1121
|
+
hex_int |= 0x010000
|
|
1122
|
+
else:
|
|
1123
|
+
a = max(0, min(255, int(a * 255)))
|
|
1124
|
+
hex_int = (r << 24) | (g << 16) | (b << 8) | a
|
|
1125
|
+
if not preserve_original and r == 0:
|
|
1126
|
+
hex_int |= 0x01000000
|
|
1127
|
+
|
|
1128
|
+
return hex_int
|
|
1129
|
+
|
|
1130
|
+
@classmethod
|
|
1131
|
+
def hex_int_to_rgba(cls, hex_int: int, preserve_original: bool = False) -> rgba:
|
|
1132
|
+
"""Convert a HEX integer to RGBA channels.\n
|
|
1133
|
+
-------------------------------------------------------------------------------------------
|
|
1134
|
+
- `hex_int` -⠀the HEX integer to convert
|
|
1135
|
+
- `preserve_original` -⠀whether to preserve the original color exactly (explained below)\n
|
|
1136
|
+
-------------------------------------------------------------------------------------------
|
|
1137
|
+
If the red channel is `1` after conversion, it will be set to `0`, because when converting
|
|
1138
|
+
from RGBA to a HEX integer, the first `0` will be set to `1` to preserve leading zeros.
|
|
1139
|
+
This is the correction, so the color doesn't even look slightly different."""
|
|
1140
|
+
if not (0 <= hex_int <= 0xFFFFFFFF):
|
|
1141
|
+
raise ValueError(f"Expected HEX integer in range [0x000000, 0xFFFFFFFF] inclusive, got 0x{hex_int:X}")
|
|
1142
|
+
|
|
1143
|
+
if len(hex_str := f"{hex_int:X}") <= 6:
|
|
1144
|
+
hex_str = hex_str.zfill(6)
|
|
1145
|
+
return rgba(
|
|
1146
|
+
r if (r := int(hex_str[0:2], 16)) != 1 or preserve_original else 0,
|
|
1147
|
+
int(hex_str[2:4], 16),
|
|
1148
|
+
int(hex_str[4:6], 16),
|
|
1149
|
+
None,
|
|
1150
|
+
_validate=False,
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
elif len(hex_str) <= 8:
|
|
1154
|
+
hex_str = hex_str.zfill(8)
|
|
1155
|
+
return rgba(
|
|
1156
|
+
r if (r := int(hex_str[0:2], 16)) != 1 or preserve_original else 0,
|
|
1157
|
+
int(hex_str[2:4], 16),
|
|
1158
|
+
int(hex_str[4:6], 16),
|
|
1159
|
+
int(hex_str[6:8], 16) / 255.0,
|
|
1160
|
+
_validate=False,
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
else:
|
|
1164
|
+
raise ValueError(f"Could not convert HEX integer 0x{hex_int:X} to RGBA color.")
|
|
1165
|
+
|
|
1166
|
+
@classmethod
|
|
1167
|
+
def luminance(
|
|
1168
|
+
cls,
|
|
1169
|
+
r: int,
|
|
1170
|
+
g: int,
|
|
1171
|
+
b: int,
|
|
1172
|
+
output_type: Optional[type[int | float]] = None,
|
|
1173
|
+
method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2",
|
|
1174
|
+
) -> int | float:
|
|
1175
|
+
"""Calculates the relative luminance of a color according to various standards.\n
|
|
1176
|
+
----------------------------------------------------------------------------------
|
|
1177
|
+
- `r`, `g`, `b` -⠀the red, green and blue channels in range [0, 255] inclusive
|
|
1178
|
+
- `output_type` -⠀the range of the returned luminance value:
|
|
1179
|
+
* `int` returns integer in range [0, 100] inclusive
|
|
1180
|
+
* `float` returns float in range [0.0, 1.0] inclusive
|
|
1181
|
+
* `None` returns integer in range [0, 255] inclusive
|
|
1182
|
+
- `method` -⠀the luminance calculation method to use:
|
|
1183
|
+
* `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
|
|
1184
|
+
* `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
|
|
1185
|
+
* `"simple"` Simple arithmetic mean (less accurate)
|
|
1186
|
+
* `"bt601"` ITU-R BT.601 standard (older TV standard)"""
|
|
1187
|
+
if not all(0 <= c <= 255 for c in (r, g, b)):
|
|
1188
|
+
raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in [0, 255], got {r=} {g=} {b=}")
|
|
1189
|
+
if output_type not in {int, float, None}:
|
|
1190
|
+
raise TypeError(f"The 'output_type' parameter must be either 'int', 'float' or 'None', got {output_type!r}")
|
|
1191
|
+
|
|
1192
|
+
_r, _g, _b = r / 255.0, g / 255.0, b / 255.0
|
|
1193
|
+
|
|
1194
|
+
if method == "simple":
|
|
1195
|
+
luminance = (_r + _g + _b) / 3
|
|
1196
|
+
elif method == "bt601":
|
|
1197
|
+
luminance = 0.299 * _r + 0.587 * _g + 0.114 * _b
|
|
1198
|
+
elif method == "wcag3":
|
|
1199
|
+
_r = cls._linearize_srgb(_r)
|
|
1200
|
+
_g = cls._linearize_srgb(_g)
|
|
1201
|
+
_b = cls._linearize_srgb(_b)
|
|
1202
|
+
luminance = 0.2126729 * _r + 0.7151522 * _g + 0.0721750 * _b
|
|
1203
|
+
else:
|
|
1204
|
+
_r = cls._linearize_srgb(_r)
|
|
1205
|
+
_g = cls._linearize_srgb(_g)
|
|
1206
|
+
_b = cls._linearize_srgb(_b)
|
|
1207
|
+
luminance = 0.2126 * _r + 0.7152 * _g + 0.0722 * _b
|
|
1208
|
+
|
|
1209
|
+
if output_type == int:
|
|
1210
|
+
return round(luminance * 100)
|
|
1211
|
+
elif output_type == float:
|
|
1212
|
+
return luminance
|
|
1213
|
+
else:
|
|
1214
|
+
return round(luminance * 255)
|
|
1215
|
+
|
|
1216
|
+
@classmethod
|
|
1217
|
+
def text_color_for_on_bg(cls, text_bg_color: Rgba | Hexa) -> rgba | hexa | int:
|
|
1218
|
+
"""Returns either black or white text color for optimal contrast on the given background color.\n
|
|
1219
|
+
--------------------------------------------------------------------------------------------------
|
|
1220
|
+
- `text_bg_color` -⠀the background color (can be in RGBA or HEXA format)"""
|
|
1221
|
+
was_hexa, was_int = cls.is_valid_hexa(text_bg_color), isinstance(text_bg_color, int)
|
|
1222
|
+
|
|
1223
|
+
text_bg_color = cls.to_rgba(text_bg_color)
|
|
1224
|
+
brightness = 0.2126 * text_bg_color[0] + 0.7152 * text_bg_color[1] + 0.0722 * text_bg_color[2]
|
|
1225
|
+
|
|
1226
|
+
return (
|
|
1227
|
+
(0xFFFFFF if was_int else hexa("", 255, 255, 255)) if was_hexa \
|
|
1228
|
+
else rgba(255, 255, 255, _validate=False)
|
|
1229
|
+
) if brightness < 128 else (
|
|
1230
|
+
(0x000 if was_int else hexa("", 0, 0, 0)) if was_hexa \
|
|
1231
|
+
else rgba(0, 0, 0, _validate=False)
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
@classmethod
|
|
1235
|
+
def adjust_lightness(cls, color: Rgba | Hexa, lightness_change: float) -> rgba | hexa:
|
|
1236
|
+
"""In- or decrease the lightness of the input color.\n
|
|
1237
|
+
------------------------------------------------------------------
|
|
1238
|
+
- `color` -⠀the color to adjust (can be in RGBA or HEXA format)
|
|
1239
|
+
- `lightness_change` -⠀the amount to change the lightness by,
|
|
1240
|
+
in range `-1.0` (darken by 100%) and `1.0` (lighten by 100%)"""
|
|
1241
|
+
was_hexa = cls.is_valid_hexa(color)
|
|
1242
|
+
|
|
1243
|
+
if not (-1.0 <= lightness_change <= 1.0):
|
|
1244
|
+
raise ValueError(
|
|
1245
|
+
f"The 'lightness_change' parameter must be in range [-1.0, 1.0] inclusive, got {lightness_change!r}"
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
hsla_color: hsla = cls.to_hsla(color)
|
|
1249
|
+
|
|
1250
|
+
h, s, l, a = (
|
|
1251
|
+
int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \
|
|
1252
|
+
hsla_color[3] if cls.has_alpha(hsla_color) else None
|
|
1253
|
+
)
|
|
1254
|
+
l = int(max(0, min(100, l + lightness_change * 100)))
|
|
1255
|
+
|
|
1256
|
+
return (
|
|
1257
|
+
hsla(h, s, l, a, _validate=False).to_hexa() if was_hexa \
|
|
1258
|
+
else hsla(h, s, l, a, _validate=False).to_rgba()
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
@classmethod
|
|
1262
|
+
def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float) -> rgba | hexa:
|
|
1263
|
+
"""In- or decrease the saturation of the input color.\n
|
|
1264
|
+
-----------------------------------------------------------------------
|
|
1265
|
+
- `color` -⠀the color to adjust (can be in RGBA or HEXA format)
|
|
1266
|
+
- `saturation_change` -⠀the amount to change the saturation by,
|
|
1267
|
+
in range `-1.0` (saturate by 100%) and `1.0` (desaturate by 100%)"""
|
|
1268
|
+
was_hexa = cls.is_valid_hexa(color)
|
|
1269
|
+
|
|
1270
|
+
if not (-1.0 <= saturation_change <= 1.0):
|
|
1271
|
+
raise ValueError(
|
|
1272
|
+
f"The 'saturation_change' parameter must be in range [-1.0, 1.0] inclusive, got {saturation_change!r}"
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
hsla_color: hsla = cls.to_hsla(color)
|
|
1276
|
+
|
|
1277
|
+
h, s, l, a = (
|
|
1278
|
+
int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \
|
|
1279
|
+
hsla_color[3] if cls.has_alpha(hsla_color) else None
|
|
1280
|
+
)
|
|
1281
|
+
s = int(max(0, min(100, s + saturation_change * 100)))
|
|
1282
|
+
|
|
1283
|
+
return (
|
|
1284
|
+
hsla(h, s, l, a, _validate=False).to_hexa() if was_hexa \
|
|
1285
|
+
else hsla(h, s, l, a, _validate=False).to_rgba()
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
@classmethod
|
|
1289
|
+
def _parse_rgba(cls, color: AnyRgba) -> rgba:
|
|
1290
|
+
"""Internal method to parse a color to an RGBA object."""
|
|
1291
|
+
if isinstance(color, rgba):
|
|
1292
|
+
return color
|
|
1293
|
+
elif isinstance(color, (list, tuple)):
|
|
1294
|
+
if len(color) == 4:
|
|
1295
|
+
return rgba(color[0], color[1], color[2], color[3], _validate=False)
|
|
1296
|
+
elif len(color) == 3:
|
|
1297
|
+
return rgba(color[0], color[1], color[2], None, _validate=False)
|
|
1298
|
+
elif isinstance(color, dict):
|
|
1299
|
+
return rgba(color["r"], color["g"], color["b"], color.get("a"), _validate=False)
|
|
1300
|
+
elif isinstance(color, str):
|
|
1301
|
+
if parsed := cls.str_to_rgba(color, only_first=True):
|
|
1302
|
+
return cast(rgba, parsed)
|
|
1303
|
+
raise ValueError(f"Could not parse RGBA color: {color!r}")
|
|
1304
|
+
|
|
1305
|
+
@classmethod
|
|
1306
|
+
def _parse_hsla(cls, color: AnyHsla) -> hsla:
|
|
1307
|
+
"""Internal method to parse a color to an HSLA object."""
|
|
1308
|
+
if isinstance(color, hsla):
|
|
1309
|
+
return color
|
|
1310
|
+
elif isinstance(color, (list, tuple)):
|
|
1311
|
+
if len(color) == 4:
|
|
1312
|
+
return hsla(color[0], color[1], color[2], color[3], _validate=False)
|
|
1313
|
+
elif len(color) == 3:
|
|
1314
|
+
return hsla(color[0], color[1], color[2], None, _validate=False)
|
|
1315
|
+
elif isinstance(color, dict):
|
|
1316
|
+
return hsla(color["h"], color["s"], color["l"], color.get("a"), _validate=False)
|
|
1317
|
+
elif isinstance(color, str):
|
|
1318
|
+
if parsed := cls.str_to_hsla(color, only_first=True):
|
|
1319
|
+
return cast(hsla, parsed)
|
|
1320
|
+
raise ValueError(f"Could not parse HSLA color: {color!r}")
|
|
1321
|
+
|
|
1322
|
+
@staticmethod
|
|
1323
|
+
def _linearize_srgb(c: float) -> float:
|
|
1324
|
+
"""Helper method to linearize sRGB component following the WCAG standard."""
|
|
1325
|
+
if not (0.0 <= c <= 1.0):
|
|
1326
|
+
raise ValueError(f"The 'c' parameter must be in range [0.0, 1.0] inclusive, got {c!r}")
|
|
1327
|
+
|
|
1328
|
+
if c <= 0.03928:
|
|
1329
|
+
return c / 12.92
|
|
1330
|
+
else:
|
|
1331
|
+
return ((c + 0.055) / 1.055)**2.4
|