xulbux 1.6.1__py3-none-any.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.

Potentially problematic release.


This version of xulbux might be problematic. Click here for more details.

xulbux/xx_color.py ADDED
@@ -0,0 +1,955 @@
1
+ """
2
+ `rgba`:
3
+ An RGB/RGBA color: is a tuple of 3 integers, representing the red (`0`-`255`), green (`0`-`255`), and blue (`0`-`255`).
4
+ Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).
5
+ `hsla`:
6
+ An HSL/HSB color: is a tuple of 3 integers, representing the hue (`0`-`360`), saturation (`0`-`100`), and lightness (`0`-`100`).
7
+ Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).
8
+ `hexa`:
9
+ A HEX color: is a string in the format `RGB`, `RGBA`, `RRGGBB` or `RRGGBBAA` (where `R` `G` `B` `A` are hexadecimal digits).
10
+
11
+ -------------------------------------------------------------------------------------------------------------------------------------
12
+ The `Color` class, which contains all sorts of different color-related methods:
13
+ - validate colors:
14
+ - is valid rgba
15
+ - is valid hsla
16
+ - is valid hexa
17
+ - is valid in any format
18
+ - check if a color has an alpha channel
19
+ - convert between different color formats:
20
+ - color to rgba
21
+ - color to hsla
22
+ - color to hexa
23
+ - recognize colors inside strings and convert them to color types:
24
+ - string to rgba
25
+ - convert an RGBA color to a HEX integer
26
+ - convert a HEX integer to an RGBA color
27
+ - get a colors luminance from the RGB channels
28
+ - get the optimal text color for on a colored background
29
+ - adjust different color channels:
30
+ - brightness
31
+ - saturation
32
+ """
33
+
34
+ from .xx_regex import Regex
35
+
36
+ import re as _re
37
+
38
+
39
+ class rgba:
40
+ """An RGB/RGBA color: is a tuple of 3 integers, representing the red (`0`-`255`), green (`0`-`255`), and blue (`0`-`255`).\n
41
+ Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).\n
42
+ -----------------------------------------------------------------------------------------------------------------------------
43
+ Includes methods:
44
+ - `to_hsla()` to convert to HSL color
45
+ - `to_hexa()` to convert to HEX color
46
+ - `has_alpha()` to check if the color has an alpha channel
47
+ - `lighten(amount)` to create a lighter version of the color
48
+ - `darken(amount)` to create a darker version of the color
49
+ - `saturate(amount)` to increase color saturation
50
+ - `desaturate(amount)` to decrease color saturation
51
+ - `rotate(degrees)` to rotate the hue by degrees
52
+ - `invert()` to get the inverse color
53
+ - `grayscale()` to convert to grayscale
54
+ - `blend(other, ratio)` to blend with another color
55
+ - `is_dark()` to check if the color is considered dark
56
+ - `is_light()` to check if the color is considered light
57
+ - `is_grayscale()` to check if the color is grayscale
58
+ - `is_opaque()` to check if the color has no transparency
59
+ - `with_alpha(alpha)` to create a new color with different alpha
60
+ - `complementary()` to get the complementary color"""
61
+
62
+ def __init__(self, r: int, g: int, b: int, a: float = None):
63
+ if any(isinstance(x, rgba) for x in (r, g, b)):
64
+ raise ValueError("Color is already a rgba() color")
65
+ elif not all(isinstance(x, int) and 0 <= x <= 255 for x in (r, g, b)):
66
+ raise ValueError(
67
+ "RGBA color must have R G B as integers in [0, 255]: got",
68
+ (r, g, b),
69
+ )
70
+ elif a is not None and not (isinstance(a, (int, float)) and 0 <= a <= 1):
71
+ raise ValueError(f"Alpha channel must be a float/int in [0.0, 1.0]: got '{a}'")
72
+ self.r, self.g, self.b = r, g, b
73
+ self.a = (1.0 if a > 1.0 else float(a)) if a else None
74
+
75
+ def __len__(self):
76
+ return 4 if self.a else 3
77
+
78
+ def __iter__(self):
79
+ return iter((self.r, self.g, self.b) + ((self.a,) if self.a else ()))
80
+
81
+ def __dict__(self):
82
+ return self.dict()
83
+
84
+ def __getitem__(self, index):
85
+ return ((self.r, self.g, self.b) + ((self.a,) if self.a else ()))[index]
86
+
87
+ def __repr__(self):
88
+ return f'rgba({self.r}, {self.g}, {self.b}{f", {self.a}" if self.a else ""})'
89
+
90
+ def __str__(self):
91
+ return f'({self.r}, {self.g}, {self.b}{f", {self.a}" if self.a else ""})'
92
+
93
+ def __eq__(self, other):
94
+ if not isinstance(other, rgba):
95
+ return False
96
+ return (self.r, self.g, self.b, self.a) == (
97
+ other[0],
98
+ other[1],
99
+ other[2],
100
+ other[3],
101
+ )
102
+
103
+ def dict(self) -> dict:
104
+ """Returns the color components as a dictionary with keys `'r'`, `'g'`, `'b'` and optionally `'a'`"""
105
+ return dict(r=self.r, g=self.g, b=self.b, a=self.a) if self.a else dict(r=self.r, g=self.g, b=self.b)
106
+
107
+ def values(self) -> tuple:
108
+ """Returns the color components as separate values `r, g, b, a`"""
109
+ return self.r, self.g, self.b, self.a
110
+
111
+ def to_hsla(self) -> "hsla":
112
+ """Returns the color as a `hsla()` color"""
113
+ return hsla(*self._rgb_to_hsl(self.r, self.g, self.b), self.a)
114
+
115
+ def to_hexa(self) -> "hexa":
116
+ """Returns the color as a `hexa()` color"""
117
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
118
+
119
+ def has_alpha(self) -> bool:
120
+ """Returns `True` if the color has an alpha channel and `False` otherwise"""
121
+ return self.a is not None
122
+
123
+ def lighten(self, amount: float) -> "rgba":
124
+ """Increases the colors lightness by the specified amount (`0.0`-`1.0`)"""
125
+ self.r, self.g, self.b, self.a = self.to_hsla().lighten(amount).to_rgba().values()
126
+ return rgba(self.r, self.g, self.b, self.a)
127
+
128
+ def darken(self, amount: float) -> "rgba":
129
+ """Decreases the colors lightness by the specified amount (`0.0`-`1.0`)"""
130
+ self.r, self.g, self.b, self.a = self.to_hsla().darken(amount).to_rgba().values()
131
+ return rgba(self.r, self.g, self.b, self.a)
132
+
133
+ def saturate(self, amount: float) -> "rgba":
134
+ """Increases the colors saturation by the specified amount (`0.0`-`1.0`)"""
135
+ self.r, self.g, self.b, self.a = self.to_hsla().saturate(amount).to_rgba().values()
136
+ return rgba(self.r, self.g, self.b, self.a)
137
+
138
+ def desaturate(self, amount: float) -> "rgba":
139
+ """Decreases the colors saturation by the specified amount (`0.0`-`1.0`)"""
140
+ self.r, self.g, self.b, self.a = self.to_hsla().desaturate(amount).to_rgba().values()
141
+ return rgba(self.r, self.g, self.b, self.a)
142
+
143
+ def rotate(self, degrees: int) -> "rgba":
144
+ """Rotates the colors hue by the specified number of degrees"""
145
+ self.r, self.g, self.b, self.a = self.to_hsla().rotate(degrees).to_rgba().values()
146
+ return rgba(self.r, self.g, self.b, self.a)
147
+
148
+ def invert(self, invert_alpha: bool = False) -> "rgba":
149
+ """Inverts the color by rotating hue by 180 degrees and inverting lightness"""
150
+ self.r, self.g, self.b = 255 - self.r, 255 - self.g, 255 - self.b
151
+ if invert_alpha:
152
+ self.a = 1 - self.a
153
+ return rgba(self.r, self.g, self.b, self.a)
154
+
155
+ def grayscale(self) -> "rgba":
156
+ """Converts the color to grayscale using the luminance formula"""
157
+ self.r = self.g = self.b = Color.luminance(self.r, self.g, self.b)
158
+ return rgba(self.r, self.g, self.b, self.a)
159
+
160
+ def blend(self, other: "rgba", ratio: float = 0.5, additive_alpha: bool = False) -> "rgba":
161
+ """Blends the current color with another color using the specified ratio (`0.0`-`1.0`):
162
+ - if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
163
+ - if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
164
+ - if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)"""
165
+ if not (isinstance(ratio, (int, float)) and 0 <= ratio <= 1):
166
+ raise ValueError("'ratio' must be a float/int in [0.0, 1.0]")
167
+ elif not isinstance(other, rgba):
168
+ if Color.is_valid_rgba(other):
169
+ other = rgba(*other)
170
+ else:
171
+ raise TypeError("'other' must be a valid RGBA color")
172
+ ratio *= 2
173
+ self.r = max(0, min(255, int(round((self.r * (2 - ratio)) + (other.r * ratio)))))
174
+ self.g = max(0, min(255, int(round((self.g * (2 - ratio)) + (other.g * ratio)))))
175
+ self.b = max(0, min(255, int(round((self.b * (2 - ratio)) + (other.b * ratio)))))
176
+ none_alpha = self.a is None and (len(other) <= 3 or other[3] is None)
177
+ if not none_alpha:
178
+ self_a = self.a if self.a is not None else 1
179
+ other_a = (other[3] if other[3] is not None else 1) if len(other) > 3 else 1
180
+ if additive_alpha:
181
+ self.a = max(0, min(1, (self_a * (2 - ratio)) + (other_a * ratio)))
182
+ else:
183
+ self.a = max(
184
+ 0,
185
+ min(
186
+ 1,
187
+ (self_a * (1 - (ratio / 2))) + (other_a * (ratio / 2)),
188
+ ),
189
+ )
190
+ else:
191
+ self.a = None
192
+ return rgba(self.r, self.g, self.b, None if none_alpha else self.a)
193
+
194
+ def is_dark(self) -> bool:
195
+ """Returns `True` if the color is considered dark (`lightness < 50%`)"""
196
+ return (0.299 * self.r + 0.587 * self.g + 0.114 * self.b) < 128
197
+
198
+ def is_light(self) -> bool:
199
+ """Returns `True` if the color is considered light (`lightness >= 50%`)"""
200
+ return not self.is_dark()
201
+
202
+ def is_grayscale(self) -> bool:
203
+ """Returns `True` if the color is grayscale"""
204
+ return self.r == self.g == self.b
205
+
206
+ def is_opaque(self) -> bool:
207
+ """Returns `True` if the color has no transparency"""
208
+ return self.a == 1 or self.a is None
209
+
210
+ def with_alpha(self, alpha: float) -> "rgba":
211
+ """Returns a new color with the specified alpha value"""
212
+ if not (isinstance(alpha, (int, float)) and 0 <= alpha <= 1):
213
+ raise ValueError("'alpha' must be a float/int in [0.0, 1.0]")
214
+ return rgba(self.r, self.g, self.b, alpha)
215
+
216
+ def complementary(self) -> "rgba":
217
+ """Returns the complementary color (180 degrees on the color wheel)"""
218
+ return self.to_hsla().complementary().to_rgba()
219
+
220
+ def _rgb_to_hsl(self, r: int, g: int, b: int) -> tuple:
221
+ r, g, b = r / 255.0, g / 255.0, b / 255.0
222
+ max_c, min_c = max(r, g, b), min(r, g, b)
223
+ l = (max_c + min_c) / 2
224
+ if max_c == min_c:
225
+ h = s = 0
226
+ else:
227
+ delta = max_c - min_c
228
+ s = delta / (1 - abs(2 * l - 1))
229
+ if max_c == r:
230
+ h = ((g - b) / delta) % 6
231
+ elif max_c == g:
232
+ h = ((b - r) / delta) + 2
233
+ else:
234
+ h = ((r - g) / delta) + 4
235
+ h /= 6
236
+ return int(round(h * 360)), int(round(s * 100)), int(round(l * 100))
237
+
238
+
239
+ class hsla:
240
+ """A HSL/HSLA color: is a tuple of 3 integers, representing hue (`0`-`360`), saturation (`0`-`100`), and lightness (`0`-`100`).\n
241
+ Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).\n
242
+ ----------------------------------------------------------------------------------------------------------------------------------
243
+ Includes methods:
244
+ - `to_rgba()` to convert to RGB color
245
+ - `to_hexa()` to convert to HEX color
246
+ - `has_alpha()` to check if the color has an alpha channel
247
+ - `lighten(amount)` to create a lighter version of the color
248
+ - `darken(amount)` to create a darker version of the color
249
+ - `saturate(amount)` to increase color saturation
250
+ - `desaturate(amount)` to decrease color saturation
251
+ - `rotate(degrees)` to rotate the hue by degrees
252
+ - `invert()` to get the inverse color
253
+ - `grayscale()` to convert to grayscale
254
+ - `blend(other, ratio)` to blend with another color
255
+ - `is_dark()` to check if the color is considered dark
256
+ - `is_light()` to check if the color is considered light
257
+ - `is_grayscale()` to check if the color is grayscale
258
+ - `is_opaque()` to check if the color has no transparency
259
+ - `with_alpha(alpha)` to create a new color with different alpha
260
+ - `complementary()` to get the complementary color"""
261
+
262
+ def __init__(self, h: int, s: int, l: int, a: float = None):
263
+ if any(isinstance(x, hsla) for x in (h, s, l)):
264
+ raise ValueError("Color is already a hsla() color")
265
+ elif not (isinstance(h, int) and (0 <= h <= 360) and all(isinstance(x, int) and (0 <= x <= 100) for x in (s, l))):
266
+ raise ValueError(
267
+ "HSL color must have H as integer in [0, 360] and S L as integers in [0, 100]: got",
268
+ (h, s, l),
269
+ )
270
+ elif a is not None and (not isinstance(a, (int, float)) or not 0 <= a <= 1):
271
+ raise ValueError(f"Alpha channel must be a float/int in [0.0, 1.0]: got '{a}'")
272
+ self.h, self.s, self.l = h, s, l
273
+ self.a = (1.0 if a > 1.0 else float(a)) if a else None
274
+
275
+ def __len__(self):
276
+ return 4 if self.a else 3
277
+
278
+ def __iter__(self):
279
+ return iter((self.h, self.s, self.l) + ((self.a,) if self.a else ()))
280
+
281
+ def __dict__(self):
282
+ return self.dict()
283
+
284
+ def __getitem__(self, index):
285
+ return ((self.h, self.s, self.l) + ((self.a,) if self.a else ()))[index]
286
+
287
+ def __repr__(self):
288
+ return f'hsla({self.h}, {self.s}, {self.l}{f", {self.a}" if self.a else ""})'
289
+
290
+ def __str__(self):
291
+ return f'({self.h}, {self.s}, {self.l}{f", {self.a}" if self.a else ""})'
292
+
293
+ def __eq__(self, other):
294
+ if not isinstance(other, hsla):
295
+ return False
296
+ return (self.h, self.s, self.l, self.a) == (
297
+ other[0],
298
+ other[1],
299
+ other[2],
300
+ other[3],
301
+ )
302
+
303
+ def dict(self) -> dict:
304
+ """Returns the color components as a dictionary with keys `'h'`, `'s'`, `'l'` and optionally `'a'`"""
305
+ return dict(h=self.h, s=self.s, l=self.l, a=self.a) if self.a else dict(h=self.h, s=self.s, l=self.l)
306
+
307
+ def values(self) -> tuple:
308
+ """Returns the color components as separate values `h, s, l, a`"""
309
+ return self.h, self.s, self.l, self.a
310
+
311
+ def to_rgba(self) -> "rgba":
312
+ """Returns the color as a `rgba()` color"""
313
+ return rgba(*self._hsl_to_rgb(self.h, self.s, self.l), self.a)
314
+
315
+ def to_hexa(self) -> "hexa":
316
+ """Returns the color as a `hexa()` color"""
317
+ r, g, b = self._hsl_to_rgb(self.h, self.s, self.l)
318
+ return hexa(f'#{r:02X}{g:02X}{b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
319
+
320
+ def has_alpha(self) -> bool:
321
+ """Returns `True` if the color has an alpha channel and `False` otherwise"""
322
+ return self.a is not None
323
+
324
+ def lighten(self, amount: float) -> "hsla":
325
+ """Increases the colors lightness by the specified amount (`0.0`-`1.0`)"""
326
+ if not (isinstance(amount, (int, float)) and 0 <= amount <= 1):
327
+ raise ValueError("'amount' must be a float/int in [0.0, 1.0]")
328
+ self.l = int(min(100, self.l + (100 - self.l) * amount))
329
+ return hsla(self.h, self.s, self.l, self.a)
330
+
331
+ def darken(self, amount: float) -> "hsla":
332
+ """Decreases the colors lightness by the specified amount (`0.0`-`1.0`)"""
333
+ if not (isinstance(amount, (int, float)) and 0 <= amount <= 1):
334
+ raise ValueError("'amount' must be a float/int in [0.0, 1.0]")
335
+ self.l = int(max(0, self.l * (1 - amount)))
336
+ return hsla(self.h, self.s, self.l, self.a)
337
+
338
+ def saturate(self, amount: float) -> "hsla":
339
+ """Increases the colors saturation by the specified amount (`0.0`-`1.0`)"""
340
+ if not (isinstance(amount, (int, float)) and 0 <= amount <= 1):
341
+ raise ValueError("'amount' must be a float/int in [0.0, 1.0]")
342
+ self.s = int(min(100, self.s + (100 - self.s) * amount))
343
+ return hsla(self.h, self.s, self.l, self.a)
344
+
345
+ def desaturate(self, amount: float) -> "hsla":
346
+ """Decreases the colors saturation by the specified amount (`0.0`-`1.0`)"""
347
+ if not (isinstance(amount, (int, float)) and 0 <= amount <= 1):
348
+ raise ValueError("'amount' must be a float/int in [0.0, 1.0]")
349
+ self.s = int(max(0, self.s * (1 - amount)))
350
+ return hsla(self.h, self.s, self.l, self.a)
351
+
352
+ def rotate(self, degrees: int) -> "hsla":
353
+ """Rotates the colors hue by the specified number of degrees"""
354
+ self.h = (self.h + degrees) % 360
355
+ return hsla(self.h, self.s, self.l, self.a)
356
+
357
+ def invert(self, invert_alpha: bool = False) -> "hsla":
358
+ """Inverts the color by rotating hue by 180 degrees and inverting lightness"""
359
+ self.h = (self.h + 180) % 360
360
+ self.l = 100 - self.l
361
+ if invert_alpha:
362
+ self.a = 1 - self.a
363
+ return hsla(self.h, self.s, self.l, self.a)
364
+
365
+ def grayscale(self) -> "hsla":
366
+ """Converts the color to grayscale using the luminance formula"""
367
+ l = Color.luminance(*self._hsl_to_rgb(self.h, self.s, self.l))
368
+ self.h, self.s, self.l, _ = rgba(l, l, l).to_hsla().values()
369
+ return hsla(self.h, self.s, self.l, self.a)
370
+
371
+ def blend(self, other: "hsla", ratio: float = 0.5, additive_alpha: bool = False) -> "rgba":
372
+ """Blends the current color with another color using the specified ratio (`0.0`-`1.0`):
373
+ - if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
374
+ - if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
375
+ - if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)"""
376
+ self.h, self.s, self.l, self.a = self.to_rgba().blend(Color.to_rgba(other), ratio, additive_alpha).to_hsla().values()
377
+ return hsla(self.h, self.s, self.l, self.a)
378
+
379
+ def is_dark(self) -> bool:
380
+ """Returns `True` if the color is considered dark (`lightness < 50%`)"""
381
+ return self.l < 50
382
+
383
+ def is_light(self) -> bool:
384
+ """Returns `True` if the color is considered light (`lightness >= 50%`)"""
385
+ return not self.is_dark()
386
+
387
+ def is_grayscale(self) -> bool:
388
+ """Returns `True` if the color is considered grayscale"""
389
+ return self.s == 0
390
+
391
+ def is_opaque(self) -> bool:
392
+ """Returns `True` if the color has no transparency"""
393
+ return self.a == 1 or self.a is None
394
+
395
+ def with_alpha(self, alpha: float) -> "hsla":
396
+ """Returns a new color with the specified alpha value"""
397
+ if not (isinstance(alpha, (int, float)) and 0 <= alpha <= 1):
398
+ raise ValueError("'alpha' must be a float/int in [0.0, 1.0]")
399
+ return hsla(self.h, self.s, self.l, alpha)
400
+
401
+ def complementary(self) -> "hsla":
402
+ """Returns the complementary color (180 degrees on the color wheel)"""
403
+ return hsla((self.h + 180) % 360, self.s, self.l, self.a)
404
+
405
+ def _hsl_to_rgb(self, h: int, s: int, l: int) -> tuple:
406
+ h, s, l = h / 360, s / 100, l / 100
407
+ if s == 0:
408
+ r = g = b = int(l * 255)
409
+ else:
410
+
411
+ def hue_to_rgb(p, q, t):
412
+ if t < 0:
413
+ t += 1
414
+ if t > 1:
415
+ t -= 1
416
+ if t < 1 / 6:
417
+ return p + (q - p) * 6 * t
418
+ if t < 1 / 2:
419
+ return q
420
+ if t < 2 / 3:
421
+ return p + (q - p) * (2 / 3 - t) * 6
422
+ return p
423
+
424
+ q = l * (1 + s) if l < 0.5 else l + s - l * s
425
+ p = 2 * l - q
426
+ r = int(round(hue_to_rgb(p, q, h + 1 / 3) * 255))
427
+ g = int(round(hue_to_rgb(p, q, h) * 255))
428
+ b = int(round(hue_to_rgb(p, q, h - 1 / 3) * 255))
429
+ return r, g, b
430
+
431
+
432
+ class hexa:
433
+ """A HEX color: is a string representing a hexadecimal color code with optional alpha channel.\n
434
+ -------------------------------------------------------------------------------------------------
435
+ Supports formats: RGB, RGBA, RRGGBB, RRGGBBAA (with or without prefix)
436
+ Includes methods:
437
+ - `to_rgba()` to convert to RGB color
438
+ - `to_hsla()` to convert to HSL color
439
+ - `has_alpha()` to check if the color has an alpha channel
440
+ - `lighten(amount)` to create a lighter version of the color
441
+ - `darken(amount)` to create a darker version of the color
442
+ - `saturate(amount)` to increase color saturation
443
+ - `desaturate(amount)` to decrease color saturation
444
+ - `rotate(degrees)` to rotate the hue by degrees
445
+ - `invert()` to get the inverse color
446
+ - `grayscale()` to convert to grayscale
447
+ - `blend(other, ratio)` to blend with another color
448
+ - `is_dark()` to check if the color is considered dark
449
+ - `is_light()` to check if the color is considered light
450
+ - `is_grayscale()` to check if the color is grayscale
451
+ - `is_opaque()` to check if the color has no transparency
452
+ - `with_alpha(alpha)` to create a new color with different alpha
453
+ - `complementary()` to get the complementary color"""
454
+
455
+ def __init__(self, color: str | int):
456
+ if isinstance(color, hexa):
457
+ raise ValueError("Color is already a hexa() color")
458
+ if isinstance(color, str):
459
+ if color.startswith("#"):
460
+ color = color[1:].upper()
461
+ elif color.startswith("0x"):
462
+ color = color[2:].upper()
463
+ if len(color) == 3: # RGB
464
+ self.r, self.g, self.b, self.a = (
465
+ int(color[0] * 2, 16),
466
+ int(color[1] * 2, 16),
467
+ int(color[2] * 2, 16),
468
+ None,
469
+ )
470
+ elif len(color) == 4: # RGBA
471
+ self.r, self.g, self.b, self.a = (
472
+ int(color[0] * 2, 16),
473
+ int(color[1] * 2, 16),
474
+ int(color[2] * 2, 16),
475
+ int(color[3] * 2, 16) / 255.0,
476
+ )
477
+ elif len(color) == 6: # RRGGBB
478
+ self.r, self.g, self.b, self.a = (
479
+ int(color[0:2], 16),
480
+ int(color[2:4], 16),
481
+ int(color[4:6], 16),
482
+ None,
483
+ )
484
+ elif len(color) == 8: # RRGGBBAA
485
+ self.r, self.g, self.b, self.a = (
486
+ int(color[0:2], 16),
487
+ int(color[2:4], 16),
488
+ int(color[4:6], 16),
489
+ int(color[6:8], 16) / 255.0,
490
+ )
491
+ else:
492
+ raise ValueError(f"Invalid HEX format '{color}'")
493
+ elif isinstance(color, int):
494
+ self.r, self.g, self.b, self.a = Color.hex_int_to_rgba(color)
495
+ else:
496
+ raise TypeError(f"HEX color must be of type 'str' or 'int': got '{type(color)}'")
497
+
498
+ def __len__(self):
499
+ return 4 if self.a else 3
500
+
501
+ def __iter__(self):
502
+ return iter((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}") + ((f"{int(self.a * 255):02X}",) if self.a else ()))
503
+
504
+ def __dict__(self):
505
+ return self.dict()
506
+
507
+ def __getitem__(self, index):
508
+ return ((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}") + ((f"{int(self.a * 255):02X}",) if self.a else ()))[index]
509
+
510
+ def __repr__(self):
511
+ return f'hexa(#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""})'
512
+
513
+ def __str__(self):
514
+ return f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}'
515
+
516
+ def __eq__(self, other):
517
+ if not isinstance(other, hexa):
518
+ return False
519
+ return (self.r, self.g, self.b, self.a) == (
520
+ other[0],
521
+ other[1],
522
+ other[2],
523
+ other[3],
524
+ )
525
+
526
+ def dict(self) -> dict:
527
+ """Returns the color components as a dictionary with hex string values for keys `'r'`, `'g'`, `'b'` and optionally `'a'`"""
528
+ return (
529
+ dict(
530
+ r=f"{self.r:02X}",
531
+ g=f"{self.g:02X}",
532
+ b=f"{self.b:02X}",
533
+ a=f"{int(self.a * 255):02X}",
534
+ )
535
+ if self.a
536
+ else dict(r=f"{self.r:02X}", g=f"{self.g:02X}", b=f"{self.b:02X}")
537
+ )
538
+
539
+ def values(self) -> tuple:
540
+ """Returns the color components as separate values `r, g, b, a`"""
541
+ return self.r, self.g, self.b, self.a
542
+
543
+ def to_rgba(self, round_alpha: bool = True) -> "rgba":
544
+ """Returns the color as a `rgba()` color"""
545
+ return rgba(
546
+ self.r,
547
+ self.g,
548
+ self.b,
549
+ (round(self.a, 2) if round_alpha else self.a) if self.a else None,
550
+ )
551
+
552
+ def to_hsla(self, round_alpha: bool = True) -> "hsla":
553
+ """Returns the color as a `hsla()` color"""
554
+ return self.to_rgba(round_alpha).to_hsla()
555
+
556
+ def has_alpha(self) -> bool:
557
+ """Returns `True` if the color has an alpha channel and `False` otherwise"""
558
+ return self.a is not None
559
+
560
+ def lighten(self, amount: float) -> "hexa":
561
+ """Increases the colors lightness by the specified amount (`0.0`-`1.0`)"""
562
+ self.r, self.g, self.b, self.a = self.to_rgba(False).lighten(amount).values()
563
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
564
+
565
+ def darken(self, amount: float) -> "hexa":
566
+ """Decreases the colors lightness by the specified amount (`0.0`-`1.0`)"""
567
+ self.r, self.g, self.b, self.a = self.to_rgba(False).darken(amount).values()
568
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
569
+
570
+ def saturate(self, amount: float) -> "hexa":
571
+ """Increases the colors saturation by the specified amount (`0.0`-`1.0`)"""
572
+ self.r, self.g, self.b, self.a = self.to_rgba(False).saturate(amount).values()
573
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
574
+
575
+ def desaturate(self, amount: float) -> "hexa":
576
+ """Decreases the colors saturation by the specified amount (`0.0`-`1.0`)"""
577
+ self.r, self.g, self.b, self.a = self.to_rgba(False).desaturate(amount).values()
578
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
579
+
580
+ def rotate(self, degrees: int) -> "hexa":
581
+ """Rotates the colors hue by the specified number of degrees"""
582
+ self.r, self.g, self.b, self.a = self.to_rgba(False).rotate(degrees).values()
583
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
584
+
585
+ def invert(self, invert_alpha: bool = False) -> "hexa":
586
+ """Inverts the color by rotating hue by 180 degrees and inverting lightness"""
587
+ self.r, self.g, self.b, self.a = self.to_rgba(False).invert().values()
588
+ if invert_alpha:
589
+ self.a = 1 - self.a
590
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
591
+
592
+ def grayscale(self) -> "hexa":
593
+ """Converts the color to grayscale using the luminance formula"""
594
+ self.r = self.g = self.b = Color.luminance(self.r, self.g, self.b)
595
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
596
+
597
+ def blend(self, other: "hexa", ratio: float = 0.5, additive_alpha: bool = False) -> "rgba":
598
+ """Blends the current color with another color using the specified ratio (`0.0`-`1.0`):
599
+ - if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
600
+ - if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
601
+ - if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)"""
602
+ self.r, self.g, self.b, self.a = self.to_rgba(False).blend(Color.to_rgba(other), ratio, additive_alpha).values()
603
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(self.a * 255):02X}" if self.a else ""}')
604
+
605
+ def is_dark(self) -> bool:
606
+ """Returns `True` if the color is considered dark (`lightness < 50%`)"""
607
+ return self.to_hsla(False).is_dark()
608
+
609
+ def is_light(self) -> bool:
610
+ """Returns `True` if the color is considered light (`lightness >= 50%`)"""
611
+ return self.to_hsla(False).is_light()
612
+
613
+ def is_grayscale(self) -> bool:
614
+ """Returns `True` if the color is grayscale (`saturation == 0`)"""
615
+ return self.to_hsla(False).is_grayscale()
616
+
617
+ def is_opaque(self) -> bool:
618
+ """Returns `True` if the color has no transparency (`alpha == 1.0`)"""
619
+ return self.to_hsla(False).is_opaque()
620
+
621
+ def with_alpha(self, alpha: float) -> "hexa":
622
+ """Returns a new color with the specified alpha value"""
623
+ if not (isinstance(alpha, (int, float)) and 0 <= alpha <= 1):
624
+ raise ValueError("'alpha' must be in [0.0, 1.0]")
625
+ return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f"{int(alpha * 255):02X}" if alpha else ""}')
626
+
627
+ def complementary(self) -> "hexa":
628
+ """Returns the complementary color (180 degrees on the color wheel)"""
629
+ return self.to_hsla(False).complementary().to_hexa()
630
+
631
+
632
+ class Color:
633
+
634
+ @staticmethod
635
+ def is_valid_rgba(color: str | list | tuple | dict, allow_alpha: bool = True) -> bool:
636
+ try:
637
+ if isinstance(color, rgba):
638
+ return True
639
+ elif isinstance(color, (list, tuple)):
640
+ if allow_alpha and Color.has_alpha(color):
641
+ return (
642
+ 0 <= color[0] <= 255
643
+ and 0 <= color[1] <= 255
644
+ and 0 <= color[2] <= 255
645
+ and (0 <= color[3] <= 1 or color[3] is None)
646
+ )
647
+ return 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255
648
+ elif isinstance(color, dict):
649
+ if allow_alpha and Color.has_alpha(color):
650
+ return (
651
+ 0 <= color["r"] <= 255
652
+ and 0 <= color["g"] <= 255
653
+ and 0 <= color["b"] <= 255
654
+ and (0 <= color["a"] <= 1 or color["a"] is None)
655
+ )
656
+ return 0 <= color["r"] <= 255 and 0 <= color["g"] <= 255 and 0 <= color["b"] <= 255
657
+ elif isinstance(color, str):
658
+ return bool(_re.fullmatch(Regex.rgba_str(allow_alpha=allow_alpha), color))
659
+ return False
660
+ except Exception:
661
+ return False
662
+
663
+ @staticmethod
664
+ def is_valid_hsla(color: str | list | tuple | dict, allow_alpha: bool = True) -> bool:
665
+ try:
666
+ if isinstance(color, hsla):
667
+ return True
668
+ elif isinstance(color, (list, tuple)):
669
+ if allow_alpha and Color.has_alpha(color):
670
+ return (
671
+ 0 <= color[0] <= 360
672
+ and 0 <= color[1] <= 100
673
+ and 0 <= color[2] <= 100
674
+ and (0 <= color[3] <= 1 or color[3] is None)
675
+ )
676
+ else:
677
+ return 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100
678
+ elif isinstance(color, dict):
679
+ if allow_alpha and Color.has_alpha(color):
680
+ return (
681
+ 0 <= color["h"] <= 360
682
+ and 0 <= color["s"] <= 100
683
+ and 0 <= color["l"] <= 100
684
+ and (0 <= color["a"] <= 1 or color["a"] is None)
685
+ )
686
+ else:
687
+ return 0 <= color["h"] <= 360 and 0 <= color["s"] <= 100 and 0 <= color["l"] <= 100
688
+ elif isinstance(color, str):
689
+ return bool(_re.fullmatch(Regex.hsla_str(allow_alpha=allow_alpha), color))
690
+ except Exception:
691
+ return False
692
+
693
+ @staticmethod
694
+ def is_valid_hexa(color: str | int, allow_alpha: bool = True, get_prefix: bool = False) -> bool | tuple[bool, str]:
695
+ try:
696
+ if isinstance(color, hexa):
697
+ return (True, "#")
698
+ elif isinstance(color, int):
699
+ is_valid = 0 <= color <= (0xFFFFFFFF if allow_alpha else 0xFFFFFF)
700
+ return (is_valid, "0x") if get_prefix else is_valid
701
+ elif isinstance(color, str):
702
+ color, prefix = (
703
+ (color[1:], "#")
704
+ if color.startswith("#")
705
+ else (color[2:], "0x") if color.startswith("0x") else (color, None)
706
+ )
707
+ return (
708
+ (bool(_re.fullmatch(Regex.hexa_str(allow_alpha=allow_alpha), color)), prefix)
709
+ if get_prefix
710
+ else bool(_re.fullmatch(Regex.hexa_str(allow_alpha=allow_alpha), color))
711
+ )
712
+ except Exception:
713
+ return (False, None) if get_prefix else False
714
+
715
+ @staticmethod
716
+ def is_valid(color: str | list | tuple | dict, allow_alpha: bool = True) -> bool:
717
+ return (
718
+ Color.is_valid_rgba(color, allow_alpha)
719
+ or Color.is_valid_hsla(color, allow_alpha)
720
+ or Color.is_valid_hexa(color, allow_alpha)
721
+ )
722
+
723
+ @staticmethod
724
+ def has_alpha(color: rgba | hsla | hexa) -> bool:
725
+ """Check if the given color has an alpha channel.\n
726
+ ---------------------------------------------------------------------------
727
+ Input a RGBA, HSLA or HEXA color as `color`.
728
+ Returns `True` if the color has an alpha channel and `False` otherwise."""
729
+ if isinstance(color, (rgba, hsla, hexa)):
730
+ return color.has_alpha()
731
+ if Color.is_valid_hexa(color):
732
+ if isinstance(color, str):
733
+ if color.startswith("#"):
734
+ color = color[1:]
735
+ return len(color) == 4 or len(color) == 8
736
+ if isinstance(color, int):
737
+ hex_length = len(f"{color:X}")
738
+ return hex_length == 4 or hex_length == 8
739
+ elif isinstance(color, (list, tuple)) and len(color) == 4 and color[3] is not None:
740
+ return True
741
+ elif isinstance(color, dict) and len(color) == 4 and color["a"] is not None:
742
+ return True
743
+ return False
744
+
745
+ @staticmethod
746
+ def to_rgba(color: hsla | hexa) -> rgba:
747
+ """Will try to convert any color type to a color of type RGBA."""
748
+ if isinstance(color, (hsla, hexa)):
749
+ return color.to_rgba()
750
+ elif Color.is_valid_hsla(color):
751
+ return hsla(*color).to_rgba() if Color.has_alpha(color) else hsla(color[0], color[1], color[2]).to_rgba()
752
+ elif Color.is_valid_hexa(color):
753
+ return hexa(color).to_rgba()
754
+ elif Color.is_valid_rgba(color):
755
+ return (
756
+ color
757
+ if isinstance(color, rgba)
758
+ else (rgba(*color) if Color.has_alpha(color) else rgba(color[0], color[1], color[2]))
759
+ )
760
+ raise ValueError(f"Invalid color format '{color}'")
761
+
762
+ @staticmethod
763
+ def to_hsla(color: rgba | hexa) -> hsla:
764
+ """Will try to convert any color type to a color of type HSLA."""
765
+ if isinstance(color, (rgba, hexa)):
766
+ return color.to_hsla()
767
+ elif Color.is_valid_rgba(color):
768
+ return rgba(*color).to_hsla() if Color.has_alpha(color) else rgba(color[0], color[1], color[2]).to_hsla()
769
+ elif Color.is_valid_hexa(color):
770
+ return hexa(color).to_hsla()
771
+ elif Color.is_valid_hsla(color):
772
+ return (
773
+ color
774
+ if isinstance(color, hsla)
775
+ else (hsla(*color) if Color.has_alpha(color) else hsla(color[0], color[1], color[2]))
776
+ )
777
+ raise ValueError(f"Invalid color format '{color}'")
778
+
779
+ @staticmethod
780
+ def to_hexa(color: rgba | hsla) -> hexa:
781
+ """Will try to convert any color type to a color of type HEXA."""
782
+ if isinstance(color, (rgba, hsla)):
783
+ return color.to_hexa()
784
+ elif Color.is_valid_rgba(color):
785
+ return rgba(*color).to_hexa() if Color.has_alpha(color) else rgba(color[0], color[1], color[2]).to_hexa()
786
+ elif Color.is_valid_hsla(color):
787
+ return hsla(*color).to_hexa() if Color.has_alpha(color) else hsla(color[0], color[1], color[2]).to_hexa()
788
+ elif Color.is_valid_hexa(color):
789
+ return color if isinstance(color, hexa) else hexa(f"#{color}")
790
+ raise ValueError(f"Invalid color format '{color}'")
791
+
792
+ @staticmethod
793
+ def str_to_rgba(string: str, only_first: bool = False) -> rgba | list[rgba] | None:
794
+ """Will try to recognize RGBA colors inside a string and output the found ones as RGBA objects.\n
795
+ --------------------------------------------------------------------------------------------------
796
+ If `only_first` is `True` only the first found color will be returned (not as a list)."""
797
+ if only_first:
798
+ match = _re.search(Regex.rgb_str(allow_alpha=True), string)
799
+ if not match:
800
+ return None
801
+ m = match.groups()
802
+ return rgba(
803
+ int(m[0]),
804
+ int(m[1]),
805
+ int(m[2]),
806
+ ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None),
807
+ )
808
+ else:
809
+ matches = _re.findall(Regex.rgb_str(allow_alpha=True), string)
810
+ if not matches:
811
+ return None
812
+ return [
813
+ rgba(
814
+ int(m[0]),
815
+ int(m[1]),
816
+ int(m[2]),
817
+ ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None),
818
+ )
819
+ for m in matches
820
+ ]
821
+
822
+ @staticmethod
823
+ def rgba_to_hex_int(
824
+ r: int,
825
+ g: int,
826
+ b: int,
827
+ a: float = None,
828
+ preserve_original: bool = False,
829
+ ) -> int:
830
+ """Convert RGBA channels to a HEXA integer (alpha is optional).\n
831
+ --------------------------------------------------------------------------------------------
832
+ To preserve leading zeros, the function will add a `1` at the beginning, if the HEX integer
833
+ would start with a `0`.
834
+ This could affect the color a little bit, but will make sure, that it won't be interpreted
835
+ as a completely different color, when initializing it as a `hexa()` color or changing it
836
+ back to RGBA using `Color.hex_int_to_rgba()`.\n
837
+ ⇾ You can disable this behavior by setting `preserve_original` to `True`"""
838
+ r = max(0, min(255, int(r)))
839
+ g = max(0, min(255, int(g)))
840
+ b = max(0, min(255, int(b)))
841
+ if a is not None:
842
+ a = max(0, min(255, int(a * 255)))
843
+ hex_int = (r << 24) | (g << 16) | (b << 8) | a
844
+ if not preserve_original and r == 0:
845
+ hex_int |= 0x01000000
846
+ else:
847
+ hex_int = (r << 16) | (g << 8) | b
848
+ if not preserve_original and (hex_int & 0xF00000) == 0:
849
+ hex_int |= 0x010000
850
+ return hex_int
851
+
852
+ @staticmethod
853
+ def hex_int_to_rgba(hex_int: int, preserve_original: bool = False) -> tuple[int, int, int, float | None]:
854
+ """Convert a HEX integer to RGBA channels.\n
855
+ -------------------------------------------------------------------------------------------
856
+ If the red channel is `1` after conversion, it will be set to `0`, because when converting
857
+ from RGBA to a HEX integer, the first `0` will be set to `1` to preserve leading zeros.
858
+ This is the correction, so the color doesn't even look slightly different.\n
859
+ ⇾ You can disable this behavior by setting `preserve_original` to `True`"""
860
+ if not isinstance(hex_int, int):
861
+ raise ValueError("Input must be an integer")
862
+ hex_str = f"{hex_int:x}"
863
+ if len(hex_str) <= 6:
864
+ hex_str = hex_str.zfill(6)
865
+ return (
866
+ r if (r := int(hex_str[0:2], 16)) != 1 or preserve_original else 0,
867
+ int(hex_str[2:4], 16),
868
+ int(hex_str[4:6], 16),
869
+ None,
870
+ )
871
+ elif len(hex_str) <= 8:
872
+ hex_str = hex_str.zfill(8)
873
+ return (
874
+ r if (r := int(hex_str[0:2], 16)) != 1 or preserve_original else 0,
875
+ int(hex_str[2:4], 16),
876
+ int(hex_str[4:6], 16),
877
+ int(hex_str[6:8], 16) / 255.0,
878
+ )
879
+ else:
880
+ raise ValueError(f"Invalid HEX integer '0x{hex_str}': expected in range [0x000000, 0xFFFFFF]")
881
+
882
+ @staticmethod
883
+ def luminance(r: int, g: int, b: int, output_type: type = None) -> int | float:
884
+ """Gets the colors luminance using the luminance formula.\n
885
+ ------------------------------------------------------------
886
+ The param `output_type` can be set to:
887
+ - `int` =⠀integer in [0, 100]
888
+ - `float` =⠀float in [0.0, 1.0]
889
+ - `None` =⠀integer in [0, 255]"""
890
+ r, g, b = r / 255.0, g / 255.0, b / 255.0
891
+ if r < 0.03928:
892
+ r = r / 12.92
893
+ else:
894
+ r = ((r + 0.055) / 1.055) ** 2.4
895
+ if g < 0.03928:
896
+ g = g / 12.92
897
+ else:
898
+ g = ((g + 0.055) / 1.055) ** 2.4
899
+ if b < 0.03928:
900
+ b = b / 12.92
901
+ else:
902
+ b = ((b + 0.055) / 1.055) ** 2.4
903
+ l = 0.2126 * r + 0.7152 * g + 0.0722 * b
904
+ return round(l * 100) if isinstance(output_type, int) else round(l * 255) if output_type is None else l
905
+
906
+ @staticmethod
907
+ def text_color_for_on_bg(
908
+ text_bg_color: rgba | hexa = "#FFF",
909
+ ) -> rgba | hexa:
910
+ was_hexa, was_int = Color.is_valid_hexa(text_bg_color), isinstance(text_bg_color, int)
911
+ text_bg_color = Color.to_rgba(text_bg_color)
912
+ brightness = 0.2126 * text_bg_color[0] + 0.7152 * text_bg_color[1] + 0.0722 * text_bg_color[2]
913
+ return (
914
+ (hexa("#FFF") if was_hexa else rgba(255, 255, 255))
915
+ if brightness < 128
916
+ else ((0x000 if was_int else hexa("#000")) if was_hexa else rgba(0, 0, 0))
917
+ )
918
+
919
+ @staticmethod
920
+ def adjust_lightness(color: rgba | hexa, lightness_change: float) -> rgba | hexa:
921
+ """In- or decrease the lightness of the input color.\n
922
+ -----------------------------------------------------------------------------------------------------
923
+ - color (rgba|hexa): HEX or RGBA color
924
+ - lightness_change (float): float between -1.0 (darken by `100%`) and 1.0 (lighten by `100%`)\n
925
+ -----------------------------------------------------------------------------------------------------
926
+ returns (rgba|hexa): the adjusted color in the format of the input color"""
927
+ was_hexa = Color.is_valid_hexa(color)
928
+ color = Color.to_hsla(color)
929
+ h, s, l, a = (
930
+ color[0],
931
+ color[1],
932
+ color[2],
933
+ color[3] if Color.has_alpha(color) else None,
934
+ )
935
+ l = int(max(0, min(100, l + lightness_change * 100)))
936
+ return Color.to_hexa((h, s, l, a)) if was_hexa else Color.to_rgba((h, s, l, a))
937
+
938
+ @staticmethod
939
+ def adjust_saturation(color: rgba | hexa, saturation_change: float) -> rgba | hexa:
940
+ """In- or decrease the saturation of the input color.\n
941
+ -----------------------------------------------------------------------------------------------------------
942
+ - color (rgba|hexa): HEX or RGBA color
943
+ - saturation_change (float): float between -1.0 (saturate by `100%`) and 1.0 (desaturate by `100%`)\n
944
+ -----------------------------------------------------------------------------------------------------------
945
+ returns (rgba|hexa): the adjusted color in the format of the input color"""
946
+ was_hexa = Color.is_valid_hexa(color)
947
+ color = Color.to_hsla(color)
948
+ h, s, l, a = (
949
+ color[0],
950
+ color[1],
951
+ color[2],
952
+ color[3] if Color.has_alpha(color) else None,
953
+ )
954
+ s = int(max(0, min(100, s + saturation_change * 100)))
955
+ return Color.to_hexa((h, s, l, a)) if was_hexa else Color.to_rgba((h, s, l, a))