stdl 0.6.7__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.
stdl/color.py ADDED
@@ -0,0 +1,1505 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+ import re
5
+ import typing as T
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Mapping, Sequence
8
+ from collections.abc import Sequence as SequenceType
9
+
10
+
11
+ class ColorValueError(Exception):
12
+ pass
13
+
14
+
15
+ class Color(ABC):
16
+ """Base class for all color representations"""
17
+
18
+ __slots__ = ("_frozen",)
19
+
20
+ def __init__(self) -> None:
21
+ object.__setattr__(self, "_frozen", False)
22
+
23
+ def _freeze(self) -> None:
24
+ object.__setattr__(self, "_frozen", True)
25
+
26
+ def to_str(self) -> builtins.str:
27
+ return self.__repr__()
28
+
29
+ @abstractmethod
30
+ def validate(self) -> None:
31
+ pass
32
+
33
+ @abstractmethod
34
+ def get_value_keys(self) -> list[builtins.str]:
35
+ pass
36
+
37
+ @abstractmethod
38
+ def to_rgb(self) -> RGB:
39
+ """Convert to RGB color space"""
40
+ pass
41
+
42
+ @abstractmethod
43
+ def to_hex(self) -> HEX:
44
+ """Convert to HEX color space"""
45
+ pass
46
+
47
+ @abstractmethod
48
+ def to_hsv(self) -> HSV:
49
+ """Convert to HSV color space"""
50
+ pass
51
+
52
+ @abstractmethod
53
+ def to_hsl(self) -> HSL:
54
+ """Convert to HSL color space"""
55
+ pass
56
+
57
+ @abstractmethod
58
+ def to_cmyk(self) -> CMYK:
59
+ """Convert to CMYK color space"""
60
+ pass
61
+
62
+ @abstractmethod
63
+ def to_assa(self) -> ASSA:
64
+ """Convert to ASS color format"""
65
+ pass
66
+
67
+ def copy(self) -> Color:
68
+ return self
69
+
70
+ def dict(self) -> dict[builtins.str, int | float | builtins.str]:
71
+ return {k: getattr(self, k) for k in self.get_value_keys()}
72
+
73
+ def __setattr__(self, name: builtins.str, value: T.Any) -> None:
74
+ if getattr(self, "_frozen", False):
75
+ raise AttributeError(f"{self.__class__.__name__} is immutable")
76
+ object.__setattr__(self, name, value)
77
+
78
+ def __delattr__(self, name: builtins.str) -> None:
79
+ raise AttributeError(f"{self.__class__.__name__} is immutable")
80
+
81
+
82
+ class RGB(Color):
83
+ __slots__ = ("red", "green", "blue")
84
+ red: int
85
+ green: int
86
+ blue: int
87
+
88
+ def __init__(self, red: int, green: int, blue: int):
89
+ super().__init__()
90
+ self.red = int(red)
91
+ self.green = int(green)
92
+ self.blue = int(blue)
93
+ self.validate()
94
+ self._freeze()
95
+
96
+ def validate(self) -> None:
97
+ if not all(isinstance(x, int) and 0 <= x <= 255 for x in self.tuple()):
98
+ raise ColorValueError("RGB values must be between 0 and 255")
99
+
100
+ def get_value_keys(self) -> list[str]:
101
+ return ["red", "green", "blue"]
102
+
103
+ def __repr__(self) -> str:
104
+ return f"rgb({self.red}, {self.green}, {self.blue})"
105
+
106
+ def __eq__(self, other: object) -> bool:
107
+ if not isinstance(other, RGB):
108
+ return NotImplemented
109
+ return (self.red, self.green, self.blue) == (other.red, other.green, other.blue)
110
+
111
+ def tuple(self) -> tuple[int, int, int]:
112
+ return (self.red, self.green, self.blue)
113
+
114
+ def to_rgb(self) -> RGB:
115
+ return self
116
+
117
+ def to_hsv(self) -> HSV:
118
+ r, g, b = self.red / 255, self.green / 255, self.blue / 255
119
+ cmax = max(r, g, b)
120
+ cmin = min(r, g, b)
121
+ diff = cmax - cmin
122
+
123
+ if cmax == cmin:
124
+ h = 0.0
125
+ elif cmax == r:
126
+ h = (60 * ((g - b) / diff) + 360) % 360
127
+ elif cmax == g:
128
+ h = (60 * ((b - r) / diff) + 120) % 360
129
+ else:
130
+ h = (60 * ((r - g) / diff) + 240) % 360
131
+
132
+ s = 0 if cmax == 0 else diff / cmax
133
+ v = cmax
134
+
135
+ return HSV(h, s * 100, v * 100)
136
+
137
+ def to_hsl(self) -> HSL:
138
+ r, g, b = self.red / 255, self.green / 255, self.blue / 255
139
+ cmax = max(r, g, b)
140
+ cmin = min(r, g, b)
141
+ diff = cmax - cmin
142
+
143
+ l = (cmax + cmin) / 2
144
+
145
+ if diff == 0:
146
+ h = 0.0
147
+ s = 0.0
148
+ else:
149
+ s = diff / (2 - cmax - cmin) if l > 0.5 else diff / (cmax + cmin)
150
+
151
+ if cmax == r:
152
+ h = (60 * ((g - b) / diff) + 360) % 360
153
+ elif cmax == g:
154
+ h = (60 * ((b - r) / diff) + 120) % 360
155
+ else:
156
+ h = (60 * ((r - g) / diff) + 240) % 360
157
+
158
+ return HSL(h, s * 100, l * 100)
159
+
160
+ def to_cmyk(self) -> CMYK:
161
+ r, g, b = self.red / 255, self.green / 255, self.blue / 255
162
+
163
+ k = 1 - max(r, g, b)
164
+ if k == 1:
165
+ c = m = y = 0.0
166
+ else:
167
+ c = (1 - r - k) / (1 - k)
168
+ m = (1 - g - k) / (1 - k)
169
+ y = (1 - b - k) / (1 - k)
170
+
171
+ return CMYK(c * 100, m * 100, y * 100, k * 100)
172
+
173
+ def to_hex(self) -> HEX:
174
+ return HEX(f"#{self.red:02x}{self.green:02x}{self.blue:02x}")
175
+
176
+ def to_rgba(self, alpha: float = 1.0) -> RGBA:
177
+ return RGBA(self.red, self.green, self.blue, alpha)
178
+
179
+ def to_assa(self) -> ASSA:
180
+ return ASSA.from_rgb(self)
181
+
182
+
183
+ class RGBA(Color):
184
+ __slots__ = ("red", "green", "blue", "alpha")
185
+ red: int
186
+ green: int
187
+ blue: int
188
+ alpha: float
189
+
190
+ def __init__(self, red: int, green: int, blue: int, alpha: float = 1.0):
191
+ super().__init__()
192
+ self.red = int(red)
193
+ self.green = int(green)
194
+ self.blue = int(blue)
195
+ self.alpha = float(alpha)
196
+ self.validate()
197
+ self._freeze()
198
+
199
+ def __repr__(self) -> str:
200
+ return f"rgba({self.red}, {self.green}, {self.blue}, {self.alpha})"
201
+
202
+ def __eq__(self, other: object) -> bool:
203
+ if not isinstance(other, RGBA):
204
+ return NotImplemented
205
+ return (self.red, self.green, self.blue, self.alpha) == (
206
+ other.red,
207
+ other.green,
208
+ other.blue,
209
+ other.alpha,
210
+ )
211
+
212
+ def validate(self) -> None:
213
+ if not all(
214
+ isinstance(x, (int, float)) and 0 <= x <= 255 for x in (self.red, self.green, self.blue)
215
+ ):
216
+ raise ColorValueError("RGB values must be 0-255")
217
+ if not isinstance(self.alpha, (int, float)) or not 0 <= self.alpha <= 1:
218
+ raise ColorValueError("Alpha value must be 0-1")
219
+
220
+ def get_value_keys(self) -> list[str]:
221
+ return ["red", "green", "blue", "alpha"]
222
+
223
+ def tuple(self) -> tuple[int, int, int, float]:
224
+ return (self.red, self.green, self.blue, self.alpha)
225
+
226
+ def to_rgb(self) -> RGB:
227
+ return RGB(self.red, self.green, self.blue)
228
+
229
+ def to_rgba(self) -> RGBA:
230
+ return self
231
+
232
+ def to_hsv(self) -> HSV:
233
+ return self.to_rgb().to_hsv()
234
+
235
+ def to_hsl(self) -> HSL:
236
+ return self.to_rgb().to_hsl()
237
+
238
+ def to_cmyk(self) -> CMYK:
239
+ return self.to_rgb().to_cmyk()
240
+
241
+ def to_hex(self) -> HEX:
242
+ return self.to_rgb().to_hex()
243
+
244
+ def to_assa(self) -> ASSA:
245
+ return ASSA.from_rgba(self)
246
+
247
+ @classmethod
248
+ def from_assa(cls, assa: ASSA) -> RGBA:
249
+ rgb = assa.to_rgb()
250
+ alpha = assa.alpha
251
+ if alpha is None:
252
+ return cls(rgb.red, rgb.green, rgb.blue, 1.0)
253
+ # ASSA alpha (FF=transparent) -> RGBA alpha (1=opaque)
254
+ return cls(rgb.red, rgb.green, rgb.blue, 1 - (alpha / 255))
255
+
256
+
257
+ class HEX(Color):
258
+ __slots__ = ("value",)
259
+ value: str
260
+
261
+ def __init__(self, value: str):
262
+ super().__init__()
263
+ if value.startswith("#"):
264
+ value = value[1:]
265
+ self.value = f"#{value.lower()}"
266
+ self.validate()
267
+ self._freeze()
268
+
269
+ def validate(self) -> None:
270
+ if not isinstance(self.value, str):
271
+ raise ColorValueError("HEX value must be a string")
272
+ hex_value = self.value.lstrip("#")
273
+ if len(hex_value) != 6 or not all(c in "0123456789ABCDEFabcdef" for c in hex_value):
274
+ raise ColorValueError(f"Invalid HEX color format ({hex_value})")
275
+
276
+ def get_value_keys(self) -> list[str]:
277
+ return ["value"]
278
+
279
+ def __repr__(self) -> str:
280
+ return f"hex({self.value})"
281
+
282
+ def __eq__(self, other: object) -> bool:
283
+ if not isinstance(other, HEX):
284
+ return NotImplemented
285
+ return self.value.lower() == other.value.lower()
286
+
287
+ def to_str(self) -> builtins.str:
288
+ return self.value
289
+
290
+ def to_rgb(self) -> RGB:
291
+ hex_value = self.value.lstrip("#")
292
+ return RGB(int(hex_value[0:2], 16), int(hex_value[2:4], 16), int(hex_value[4:6], 16))
293
+
294
+ def to_hsv(self) -> HSV:
295
+ return self.to_rgb().to_hsv()
296
+
297
+ def to_hsl(self) -> HSL:
298
+ return self.to_rgb().to_hsl()
299
+
300
+ def to_cmyk(self) -> CMYK:
301
+ return self.to_rgb().to_cmyk()
302
+
303
+ def to_hex(self) -> HEX:
304
+ return self
305
+
306
+ def to_assa(self) -> ASSA:
307
+ return self.to_rgb().to_assa()
308
+
309
+ def to_webcolor(self) -> webcolor:
310
+ return webcolor.from_hex(self.value)
311
+
312
+
313
+ class HSV(Color):
314
+ __slots__ = ("hue", "saturation", "value")
315
+ hue: float
316
+ saturation: float
317
+ value: float
318
+
319
+ def __init__(self, hue: float, saturation: float, value: float):
320
+ super().__init__()
321
+ self.hue = float(hue)
322
+ self.saturation = float(saturation)
323
+ self.value = float(value)
324
+ self.validate()
325
+ self._freeze()
326
+
327
+ def validate(self) -> None:
328
+ if not (0 <= self.hue <= 360 and 0 <= self.saturation <= 100 and 0 <= self.value <= 100):
329
+ raise ColorValueError("HSV values must be: H[0-360], S[0-100], V[0-100]")
330
+
331
+ def get_value_keys(self) -> list[str]:
332
+ return ["hue", "saturation", "value"]
333
+
334
+ def __repr__(self) -> str:
335
+ return f"hsv({self.hue}, {self.saturation}, {self.value})"
336
+
337
+ def to_str(self) -> builtins.str:
338
+ return f"hsv({self.hue}°, {self.saturation}%, {self.value}%)"
339
+
340
+ def __eq__(self, other: object) -> bool:
341
+ if not isinstance(other, HSV):
342
+ return NotImplemented
343
+ return (self.hue, self.saturation, self.value) == (
344
+ other.hue,
345
+ other.saturation,
346
+ other.value,
347
+ )
348
+
349
+ def tuple(self) -> tuple[float, float, float]:
350
+ return (self.hue, self.saturation, self.value)
351
+
352
+ def to_rgb(self) -> RGB:
353
+ h, s, v = self.hue, self.saturation / 100, self.value / 100
354
+ c = v * s
355
+ x = c * (1 - abs((h / 60) % 2 - 1))
356
+ m = v - c
357
+
358
+ if 0 <= h < 60:
359
+ r, g, b = c, x, 0.0
360
+ elif 60 <= h < 120:
361
+ r, g, b = x, c, 0.0
362
+ elif 120 <= h < 180:
363
+ r, g, b = 0.0, c, x
364
+ elif 180 <= h < 240:
365
+ r, g, b = 0.0, x, c
366
+ elif 240 <= h < 300:
367
+ r, g, b = x, 0.0, c
368
+ else:
369
+ r, g, b = c, 0.0, x
370
+
371
+ return RGB(round((r + m) * 255), round((g + m) * 255), round((b + m) * 255))
372
+
373
+ def to_hsv(self) -> HSV:
374
+ return self
375
+
376
+ def to_hsl(self) -> HSL:
377
+ return self.to_rgb().to_hsl()
378
+
379
+ def to_cmyk(self) -> CMYK:
380
+ return self.to_rgb().to_cmyk()
381
+
382
+ def to_hex(self) -> HEX:
383
+ return self.to_rgb().to_hex()
384
+
385
+ def to_assa(self) -> ASSA:
386
+ return self.to_rgb().to_assa()
387
+
388
+
389
+ class HSL(Color):
390
+ __slots__ = ("hue", "saturation", "lightness")
391
+ hue: float
392
+ saturation: float
393
+ lightness: float
394
+
395
+ def __init__(self, hue: float, saturation: float, lightness: float):
396
+ super().__init__()
397
+ self.hue = float(hue)
398
+ self.saturation = float(saturation)
399
+ self.lightness = float(lightness)
400
+ self.validate()
401
+ self._freeze()
402
+
403
+ def validate(self) -> None:
404
+ if not (
405
+ 0 <= self.hue <= 360 and 0 <= self.saturation <= 100 and 0 <= self.lightness <= 100
406
+ ):
407
+ raise ColorValueError("HSL values must be: H[0-360], S[0-100], L[0-100]")
408
+
409
+ def get_value_keys(self) -> list[str]:
410
+ return ["hue", "saturation", "lightness"]
411
+
412
+ def __repr__(self) -> str:
413
+ return f"hsl({self.hue}, {self.saturation}, {self.lightness})"
414
+
415
+ def to_str(self) -> builtins.str:
416
+ return f"hsl({self.hue}°, {self.saturation}%, {self.lightness}%)"
417
+
418
+ def __eq__(self, other: object) -> bool:
419
+ if not isinstance(other, HSL):
420
+ return NotImplemented
421
+ return (self.hue, self.saturation, self.lightness) == (
422
+ other.hue,
423
+ other.saturation,
424
+ other.lightness,
425
+ )
426
+
427
+ def tuple(self) -> tuple[float, float, float]:
428
+ return (self.hue, self.saturation, self.lightness)
429
+
430
+ def to_rgb(self) -> RGB:
431
+ hue, saturation, lightness = self.hue / 360, self.saturation / 100, self.lightness / 100
432
+
433
+ if saturation == 0:
434
+ r = g = b = lightness
435
+ else:
436
+
437
+ def hue_to_rgb(p: float, q: float, t: float) -> float:
438
+ if t < 0:
439
+ t += 1
440
+ if t > 1:
441
+ t -= 1
442
+ if t < 1 / 6:
443
+ return p + (q - p) * 6 * t
444
+ if t < 1 / 2:
445
+ return q
446
+ if t < 2 / 3:
447
+ return p + (q - p) * (2 / 3 - t) * 6
448
+ return p
449
+
450
+ q = (
451
+ lightness * (1 + saturation)
452
+ if lightness < 0.5
453
+ else lightness + saturation - lightness * saturation
454
+ )
455
+ p = 2 * lightness - q
456
+
457
+ r = hue_to_rgb(p, q, hue + 1 / 3)
458
+ g = hue_to_rgb(p, q, hue)
459
+ b = hue_to_rgb(p, q, hue - 1 / 3)
460
+
461
+ return RGB(round(r * 255), round(g * 255), round(b * 255))
462
+
463
+ def to_hsv(self) -> HSV:
464
+ return self.to_rgb().to_hsv()
465
+
466
+ def to_hsl(self) -> HSL:
467
+ return self
468
+
469
+ def to_cmyk(self) -> CMYK:
470
+ return self.to_rgb().to_cmyk()
471
+
472
+ def to_hex(self) -> HEX:
473
+ return self.to_rgb().to_hex()
474
+
475
+ def to_assa(self) -> ASSA:
476
+ return self.to_rgb().to_assa()
477
+
478
+
479
+ class CMYK(Color):
480
+ __slots__ = ("cyan", "magenta", "yellow", "key")
481
+ cyan: float
482
+ magenta: float
483
+ yellow: float
484
+ key: float
485
+
486
+ def __init__(self, cyan: float, magenta: float, yellow: float, key: float):
487
+ super().__init__()
488
+ self.cyan = float(cyan)
489
+ self.magenta = float(magenta)
490
+ self.yellow = float(yellow)
491
+ self.key = float(key)
492
+ self.validate()
493
+ self._freeze()
494
+
495
+ def validate(self) -> None:
496
+ if not all(0 <= x <= 100 for x in (self.cyan, self.magenta, self.yellow, self.key)):
497
+ raise ColorValueError("CMYK values must be between 0 and 100")
498
+
499
+ def get_value_keys(self) -> list[str]:
500
+ return ["cyan", "magenta", "yellow", "key"]
501
+
502
+ def __repr__(self) -> str:
503
+ return f"cmyk({self.cyan}, {self.magenta}, {self.yellow}, {self.key})"
504
+
505
+ def to_str(self) -> builtins.str:
506
+ return f"cmyk({self.cyan}%, {self.magenta}%, {self.yellow}%, {self.key}%)"
507
+
508
+ def __eq__(self, other: object) -> bool:
509
+ if not isinstance(other, CMYK):
510
+ return NotImplemented
511
+ return (self.cyan, self.magenta, self.yellow, self.key) == (
512
+ other.cyan,
513
+ other.magenta,
514
+ other.yellow,
515
+ other.key,
516
+ )
517
+
518
+ def tuple(self) -> tuple[float, float, float, float]:
519
+ return (self.cyan, self.magenta, self.yellow, self.key)
520
+
521
+ def to_rgb(self) -> RGB:
522
+ c, m, y, k = self.cyan / 100, self.magenta / 100, self.yellow / 100, self.key / 100
523
+
524
+ r = 255 * (1 - c) * (1 - k)
525
+ g = 255 * (1 - m) * (1 - k)
526
+ b = 255 * (1 - y) * (1 - k)
527
+
528
+ return RGB(round(r), round(g), round(b))
529
+
530
+ def to_hsv(self) -> HSV:
531
+ return self.to_rgb().to_hsv()
532
+
533
+ def to_hsl(self) -> HSL:
534
+ return self.to_rgb().to_hsl()
535
+
536
+ def to_cmyk(self) -> CMYK:
537
+ return self
538
+
539
+ def to_hex(self) -> HEX:
540
+ return self.to_rgb().to_hex()
541
+
542
+ def to_assa(self) -> ASSA:
543
+ return self.to_rgb().to_assa()
544
+
545
+
546
+ class ASSA(Color):
547
+ __slots__ = ("value",)
548
+ value: str
549
+
550
+ def __init__(self, value: str):
551
+ """
552
+ Initialize ASSA color with just hex numbers:
553
+ - 'BBGGRR' for color
554
+ - 'AABBGGRR' for color with alpha
555
+ """
556
+ super().__init__()
557
+ clean_value = "".join(c for c in value.lower() if c in "0123456789abcdef")
558
+ self.value = f"&H{clean_value}&"
559
+ self.validate()
560
+ self._freeze()
561
+
562
+ def get_value_keys(self) -> list[str]:
563
+ return ["value"]
564
+
565
+ @property
566
+ def clean_value(self) -> str:
567
+ value = self.value
568
+ if (value.startswith("&H") or value.startswith("&h")) and value.endswith("&"):
569
+ return value[2:-1].lower()
570
+ return value.lower()
571
+
572
+ def __repr__(self) -> str:
573
+ return f"assa({self.clean_value})"
574
+
575
+ def to_str(self) -> builtins.str:
576
+ return self.value
577
+
578
+ def __eq__(self, other: object) -> bool:
579
+ if not isinstance(other, ASSA):
580
+ return NotImplemented
581
+ return self.value == other.value
582
+
583
+ def validate(self) -> None:
584
+ if not self.is_valid_format(self.value):
585
+ raise ColorValueError(
586
+ "Color value must be either 6 (BBGGRR) or 8 (AABBGGRR) hexadecimal digits"
587
+ )
588
+
589
+ def is_BBGGRR_format(self) -> bool:
590
+ return len(self.clean_value) == 6
591
+
592
+ def is_AABBGGRR_format(self) -> bool:
593
+ return len(self.clean_value) == 8
594
+
595
+ def to_rgb(self) -> RGB:
596
+ clean = self.clean_value
597
+ if len(clean) == 6:
598
+ blue_hex = clean[0:2]
599
+ green_hex = clean[2:4]
600
+ red_hex = clean[4:6]
601
+ else:
602
+ blue_hex = clean[2:4]
603
+ green_hex = clean[4:6]
604
+ red_hex = clean[6:8]
605
+ return RGB(int(red_hex, 16), int(green_hex, 16), int(blue_hex, 16))
606
+
607
+ @property
608
+ def blue(self) -> int:
609
+ clean = self.clean_value
610
+ if len(clean) == 6:
611
+ return int(clean[0:2], 16)
612
+ else:
613
+ return int(clean[2:4], 16)
614
+
615
+ @property
616
+ def green(self) -> int:
617
+ clean = self.clean_value
618
+ if len(clean) == 6:
619
+ return int(clean[2:4], 16)
620
+ else:
621
+ return int(clean[4:6], 16)
622
+
623
+ @property
624
+ def red(self) -> int:
625
+ clean = self.clean_value
626
+ if len(clean) == 6:
627
+ return int(clean[4:6], 16)
628
+ else:
629
+ return int(clean[6:8], 16)
630
+
631
+ @property
632
+ def alpha(self) -> int | None:
633
+ clean = self.clean_value
634
+ if len(clean) == 6:
635
+ return None
636
+ else:
637
+ return int(clean[0:2], 16)
638
+
639
+ def to_hsv(self) -> HSV:
640
+ return self.to_rgb().to_hsv()
641
+
642
+ def to_hsl(self) -> HSL:
643
+ return self.to_rgb().to_hsl()
644
+
645
+ def to_cmyk(self) -> CMYK:
646
+ return self.to_rgb().to_cmyk()
647
+
648
+ def to_hex(self) -> HEX:
649
+ return self.to_rgb().to_hex()
650
+
651
+ def to_assa(self) -> ASSA:
652
+ return self
653
+
654
+ def embed_text(self, text: str) -> str:
655
+ """Embed text with this color in ASS format"""
656
+ return f"{{\\c{self.value}}}{text}{{\\c}}"
657
+
658
+ @staticmethod
659
+ def is_valid_format(value: str) -> bool:
660
+ clean_value = ASSA._clean_hex(value)
661
+ return len(clean_value) in (6, 8)
662
+
663
+ @classmethod
664
+ def from_alpha(cls, alpha: int) -> ASSA:
665
+ if not 0 <= alpha <= 255:
666
+ raise ColorValueError("Alpha value must be 0-255")
667
+ return cls(f"{alpha:02X}000000")
668
+
669
+ @classmethod
670
+ def from_rgb(cls, rgb: RGB, alpha: int | None = None) -> ASSA:
671
+ if alpha is not None and not 0 <= alpha <= 255:
672
+ raise ColorValueError("Alpha value must be 0-255")
673
+ if alpha is None:
674
+ return cls(f"{rgb.blue:02X}{rgb.green:02X}{rgb.red:02X}")
675
+ else:
676
+ return cls(f"{alpha:02X}{rgb.blue:02X}{rgb.green:02X}{rgb.red:02X}")
677
+
678
+ @classmethod
679
+ def from_rgba(cls, rgba: RGBA) -> ASSA:
680
+ return cls.from_rgb(rgba.to_rgb(), alpha=round((1 - rgba.alpha) * 255))
681
+
682
+ @classmethod
683
+ def from_clean_value(cls, value: str) -> ASSA:
684
+ clean = cls._clean_hex(value)
685
+ if len(clean) not in (6, 8):
686
+ raise ColorValueError(
687
+ "Color value must be either 6 (BBGGRR) or 8 (AABBGGRR) hex digits"
688
+ )
689
+ return cls(clean)
690
+
691
+ @classmethod
692
+ def from_value(cls, value: str) -> ASSA:
693
+ """Create an ASSA color from a string (with or without &H markers)."""
694
+ clean_value = "".join(c for c in value.lower() if c in "0123456789abcdef")
695
+ return cls(clean_value)
696
+
697
+ @staticmethod
698
+ def _clean_hex(value: str) -> str:
699
+ return "".join(c for c in value.upper() if c in "0123456789ABCDEF")
700
+
701
+
702
+ CssColorName = T.Literal[
703
+ "aliceblue",
704
+ "antiquewhite",
705
+ "aqua",
706
+ "aquamarine",
707
+ "azure",
708
+ "beige",
709
+ "bisque",
710
+ "black",
711
+ "blanchedalmond",
712
+ "blue",
713
+ "blueviolet",
714
+ "brown",
715
+ "burlywood",
716
+ "cadetblue",
717
+ "chartreuse",
718
+ "chocolate",
719
+ "coral",
720
+ "cornflowerblue",
721
+ "cornsilk",
722
+ "crimson",
723
+ "cyan",
724
+ "darkblue",
725
+ "darkcyan",
726
+ "darkgoldenrod",
727
+ "darkgray",
728
+ "darkgrey",
729
+ "darkgreen",
730
+ "darkkhaki",
731
+ "darkmagenta",
732
+ "darkolivegreen",
733
+ "darkorange",
734
+ "darkorchid",
735
+ "darkred",
736
+ "darksalmon",
737
+ "darkseagreen",
738
+ "darkslateblue",
739
+ "darkslategray",
740
+ "darkslategrey",
741
+ "darkturquoise",
742
+ "darkviolet",
743
+ "deeppink",
744
+ "deepskyblue",
745
+ "dimgray",
746
+ "dimgrey",
747
+ "dodgerblue",
748
+ "firebrick",
749
+ "floralwhite",
750
+ "forestgreen",
751
+ "fuchsia",
752
+ "gainsboro",
753
+ "ghostwhite",
754
+ "gold",
755
+ "goldenrod",
756
+ "gray",
757
+ "grey",
758
+ "green",
759
+ "greenyellow",
760
+ "honeydew",
761
+ "hotpink",
762
+ "indianred",
763
+ "indigo",
764
+ "ivory",
765
+ "khaki",
766
+ "lavender",
767
+ "lavenderblush",
768
+ "lawngreen",
769
+ "lemonchiffon",
770
+ "lightblue",
771
+ "lightcoral",
772
+ "lightcyan",
773
+ "lightgoldenrodyellow",
774
+ "lightgray",
775
+ "lightgrey",
776
+ "lightgreen",
777
+ "lightpink",
778
+ "lightsalmon",
779
+ "lightseagreen",
780
+ "lightskyblue",
781
+ "lightslategray",
782
+ "lightslategrey",
783
+ "lightsteelblue",
784
+ "lightyellow",
785
+ "lime",
786
+ "limegreen",
787
+ "linen",
788
+ "magenta",
789
+ "maroon",
790
+ "mediumaquamarine",
791
+ "mediumblue",
792
+ "mediumorchid",
793
+ "mediumpurple",
794
+ "mediumseagreen",
795
+ "mediumslateblue",
796
+ "mediumspringgreen",
797
+ "mediumturquoise",
798
+ "mediumvioletred",
799
+ "midnightblue",
800
+ "mintcream",
801
+ "mistyrose",
802
+ "moccasin",
803
+ "navajowhite",
804
+ "navy",
805
+ "oldlace",
806
+ "olive",
807
+ "olivedrab",
808
+ "orange",
809
+ "orangered",
810
+ "orchid",
811
+ "palegoldenrod",
812
+ "palegreen",
813
+ "paleturquoise",
814
+ "palevioletred",
815
+ "papayawhip",
816
+ "peachpuff",
817
+ "peru",
818
+ "pink",
819
+ "plum",
820
+ "powderblue",
821
+ "purple",
822
+ "rebeccapurple",
823
+ "red",
824
+ "rosybrown",
825
+ "royalblue",
826
+ "saddlebrown",
827
+ "salmon",
828
+ "sandybrown",
829
+ "seagreen",
830
+ "seashell",
831
+ "sienna",
832
+ "silver",
833
+ "skyblue",
834
+ "slateblue",
835
+ "slategray",
836
+ "slategrey",
837
+ "snow",
838
+ "springgreen",
839
+ "steelblue",
840
+ "tan",
841
+ "teal",
842
+ "thistle",
843
+ "tomato",
844
+ "turquoise",
845
+ "violet",
846
+ "wheat",
847
+ "white",
848
+ "whitesmoke",
849
+ "yellow",
850
+ "yellowgreen",
851
+ ]
852
+
853
+ CSS_COLOR_TO_HEX = {
854
+ "aliceblue": "#F0F8FF",
855
+ "antiquewhite": "#FAEBD7",
856
+ "aqua": "#00FFFF",
857
+ "aquamarine": "#7FFFD4",
858
+ "azure": "#F0FFFF",
859
+ "beige": "#F5F5DC",
860
+ "bisque": "#FFE4C4",
861
+ "black": "#000000",
862
+ "blanchedalmond": "#FFEBCD",
863
+ "blue": "#0000FF",
864
+ "blueviolet": "#8A2BE2",
865
+ "brown": "#A52A2A",
866
+ "burlywood": "#DEB887",
867
+ "cadetblue": "#5F9EA0",
868
+ "chartreuse": "#7FFF00",
869
+ "chocolate": "#D2691E",
870
+ "coral": "#FF7F50",
871
+ "cornflowerblue": "#6495ED",
872
+ "cornsilk": "#FFF8DC",
873
+ "crimson": "#DC143C",
874
+ "cyan": "#00FFFF",
875
+ "darkblue": "#00008B",
876
+ "darkcyan": "#008B8B",
877
+ "darkgoldenrod": "#B8860B",
878
+ "darkgray": "#A9A9A9",
879
+ "darkgrey": "#A9A9A9",
880
+ "darkgreen": "#006400",
881
+ "darkkhaki": "#BDB76B",
882
+ "darkmagenta": "#8B008B",
883
+ "darkolivegreen": "#556B2F",
884
+ "darkorange": "#FF8C00",
885
+ "darkorchid": "#9932CC",
886
+ "darkred": "#8B0000",
887
+ "darksalmon": "#E9967A",
888
+ "darkseagreen": "#8FBC8F",
889
+ "darkslateblue": "#483D8B",
890
+ "darkslategray": "#2F4F4F",
891
+ "darkslategrey": "#2F4F4F",
892
+ "darkturquoise": "#00CED1",
893
+ "darkviolet": "#9400D3",
894
+ "deeppink": "#FF1493",
895
+ "deepskyblue": "#00BFFF",
896
+ "dimgray": "#696969",
897
+ "dimgrey": "#696969",
898
+ "dodgerblue": "#1E90FF",
899
+ "firebrick": "#B22222",
900
+ "floralwhite": "#FFFAF0",
901
+ "forestgreen": "#228B22",
902
+ "fuchsia": "#FF00FF",
903
+ "gainsboro": "#DCDCDC",
904
+ "ghostwhite": "#F8F8FF",
905
+ "gold": "#FFD700",
906
+ "goldenrod": "#DAA520",
907
+ "gray": "#808080",
908
+ "grey": "#808080",
909
+ "green": "#008000",
910
+ "greenyellow": "#ADFF2F",
911
+ "honeydew": "#F0FFF0",
912
+ "hotpink": "#FF69B4",
913
+ "indianred": "#CD5C5C",
914
+ "indigo": "#4B0082",
915
+ "ivory": "#FFFFF0",
916
+ "khaki": "#F0E68C",
917
+ "lavender": "#E6E6FA",
918
+ "lavenderblush": "#FFF0F5",
919
+ "lawngreen": "#7CFC00",
920
+ "lemonchiffon": "#FFFACD",
921
+ "lightblue": "#ADD8E6",
922
+ "lightcoral": "#F08080",
923
+ "lightcyan": "#E0FFFF",
924
+ "lightgoldenrodyellow": "#FAFAD2",
925
+ "lightgray": "#D3D3D3",
926
+ "lightgrey": "#D3D3D3",
927
+ "lightgreen": "#90EE90",
928
+ "lightpink": "#FFB6C1",
929
+ "lightsalmon": "#FFA07A",
930
+ "lightseagreen": "#20B2AA",
931
+ "lightskyblue": "#87CEFA",
932
+ "lightslategray": "#778899",
933
+ "lightslategrey": "#778899",
934
+ "lightsteelblue": "#B0C4DE",
935
+ "lightyellow": "#FFFFE0",
936
+ "lime": "#00FF00",
937
+ "limegreen": "#32CD32",
938
+ "linen": "#FAF0E6",
939
+ "magenta": "#FF00FF",
940
+ "maroon": "#800000",
941
+ "mediumaquamarine": "#66CDAA",
942
+ "mediumblue": "#0000CD",
943
+ "mediumorchid": "#BA55D3",
944
+ "mediumpurple": "#9370DB",
945
+ "mediumseagreen": "#3CB371",
946
+ "mediumslateblue": "#7B68EE",
947
+ "mediumspringgreen": "#00FA9A",
948
+ "mediumturquoise": "#48D1CC",
949
+ "mediumvioletred": "#C71585",
950
+ "midnightblue": "#191970",
951
+ "mintcream": "#F5FFFA",
952
+ "mistyrose": "#FFE4E1",
953
+ "moccasin": "#FFE4B5",
954
+ "navajowhite": "#FFDEAD",
955
+ "navy": "#000080",
956
+ "oldlace": "#FDF5E6",
957
+ "olive": "#808000",
958
+ "olivedrab": "#6B8E23",
959
+ "orange": "#FFA500",
960
+ "orangered": "#FF4500",
961
+ "orchid": "#DA70D6",
962
+ "palegoldenrod": "#EEE8AA",
963
+ "palegreen": "#98FB98",
964
+ "paleturquoise": "#AFEEEE",
965
+ "palevioletred": "#DB7093",
966
+ "papayawhip": "#FFEFD5",
967
+ "peachpuff": "#FFDAB9",
968
+ "peru": "#CD853F",
969
+ "pink": "#FFC0CB",
970
+ "plum": "#DDA0DD",
971
+ "powderblue": "#B0E0E6",
972
+ "purple": "#800080",
973
+ "rebeccapurple": "#663399",
974
+ "red": "#FF0000",
975
+ "rosybrown": "#BC8F8F",
976
+ "royalblue": "#4169E1",
977
+ "saddlebrown": "#8B4513",
978
+ "salmon": "#FA8072",
979
+ "sandybrown": "#F4A460",
980
+ "seagreen": "#2E8B57",
981
+ "seashell": "#FFF5EE",
982
+ "sienna": "#A0522D",
983
+ "silver": "#C0C0C0",
984
+ "skyblue": "#87CEEB",
985
+ "slateblue": "#6A5ACD",
986
+ "slategray": "#708090",
987
+ "slategrey": "#708090",
988
+ "snow": "#FFFAFA",
989
+ "springgreen": "#00FF7F",
990
+ "steelblue": "#4682B4",
991
+ "tan": "#D2B48C",
992
+ "teal": "#008080",
993
+ "thistle": "#D8BFD8",
994
+ "tomato": "#FF6347",
995
+ "turquoise": "#40E0D0",
996
+ "violet": "#EE82EE",
997
+ "wheat": "#F5DEB3",
998
+ "white": "#FFFFFF",
999
+ "whitesmoke": "#F5F5F5",
1000
+ "yellow": "#FFFF00",
1001
+ "yellowgreen": "#9ACD32",
1002
+ }
1003
+
1004
+
1005
+ class webcolor(Color):
1006
+ __slots__ = ("name", "hex_value")
1007
+ name: str
1008
+ hex_value: HEX
1009
+ COLORS = CSS_COLOR_TO_HEX
1010
+
1011
+ def __init__(self, name: CssColorName):
1012
+ super().__init__()
1013
+ name = name.lower() # type:ignore
1014
+ self.name = name
1015
+ self.validate()
1016
+ self.hex_value = HEX(self.COLORS[name])
1017
+ self._freeze()
1018
+
1019
+ def validate(self) -> None:
1020
+ if self.name not in self.COLORS:
1021
+ raise ColorValueError(f"Unknown web color name '{self.name}'")
1022
+
1023
+ def get_value_keys(self) -> list[str]:
1024
+ return ["name"]
1025
+
1026
+ def __repr__(self) -> str:
1027
+ return f"webcolor('{self.name}')"
1028
+
1029
+ def __eq__(self, other: object) -> bool:
1030
+ if not isinstance(other, webcolor):
1031
+ return NotImplemented
1032
+ return self.name == other.name
1033
+
1034
+ def to_str(self) -> builtins.str:
1035
+ return self.name
1036
+
1037
+ def to_rgb(self) -> RGB:
1038
+ return self.hex_value.to_rgb()
1039
+
1040
+ def to_hsv(self) -> HSV:
1041
+ return self.hex_value.to_hsv()
1042
+
1043
+ def to_hsl(self) -> HSL:
1044
+ return self.hex_value.to_hsl()
1045
+
1046
+ def to_cmyk(self) -> CMYK:
1047
+ return self.hex_value.to_cmyk()
1048
+
1049
+ def to_hex(self) -> HEX:
1050
+ return self.hex_value
1051
+
1052
+ def to_webcolor(self) -> webcolor:
1053
+ return self
1054
+
1055
+ def to_assa(self) -> ASSA:
1056
+ return self.hex_value.to_assa()
1057
+
1058
+ @classmethod
1059
+ def from_hex(cls, value: str) -> webcolor:
1060
+ value = value.lower()
1061
+ for name, color in cls.COLORS.items():
1062
+ if color.lower() == value:
1063
+ return cls(name)
1064
+ raise ColorValueError(f"No web color name matches hex value {value}")
1065
+
1066
+
1067
+ def hex_to_rgb(color: str) -> tuple[int, int, int]:
1068
+ c = color.lstrip("#")
1069
+ if len(c) != 6:
1070
+ raise ValueError(color)
1071
+ r = int(c[0:2], 16)
1072
+ g = int(c[2:4], 16)
1073
+ b = int(c[4:6], 16)
1074
+ return (r, g, b)
1075
+
1076
+
1077
+ def normalize_color(
1078
+ color: (
1079
+ str
1080
+ | CssColorName
1081
+ | tuple[int, int, int]
1082
+ | tuple[int, int, int, float]
1083
+ | Sequence[int]
1084
+ | Sequence[float]
1085
+ ),
1086
+ ) -> RGB | RGBA | HEX | ASSA | webcolor:
1087
+ """Convert various color formats to a Color object."""
1088
+ if isinstance(color, SequenceType) and all(isinstance(x, (int, float)) for x in color):
1089
+ if len(color) == 3:
1090
+ return RGB(*color)
1091
+ elif len(color) == 4:
1092
+ return RGBA(*color)
1093
+ raise ColorValueError(f"Invalid sequence length: {len(color)}")
1094
+ elif isinstance(color, str):
1095
+ if color.startswith("#"):
1096
+ return HEX(color)
1097
+ elif color.startswith("&h") or color.startswith("&H"):
1098
+ return ASSA.from_value(color)
1099
+ else:
1100
+ return webcolor(color)
1101
+ raise ColorValueError(f"Unsupported color format: {color}")
1102
+
1103
+
1104
+ try:
1105
+ from pydantic import GetCoreSchemaHandler as _GetCoreSchemaHandler
1106
+ from pydantic import GetJsonSchemaHandler as _GetJsonSchemaHandler
1107
+ from pydantic.json_schema import JsonSchemaValue as _JsonSchemaValue
1108
+ from pydantic_core import core_schema as _core_schema
1109
+ except Exception: # pragma: no cover
1110
+ pass # Pydantic not installed
1111
+ else:
1112
+
1113
+ def parse_rgb(value: str | Mapping[str, T.Any]) -> RGB:
1114
+ if isinstance(value, Mapping):
1115
+ data = dict(value)
1116
+ if {"red", "green", "blue"}.issubset(data):
1117
+ return RGB(data["red"], data["green"], data["blue"])
1118
+ if {"r", "g", "b"}.issubset(data):
1119
+ return RGB(data["r"], data["g"], data["b"])
1120
+ raise ValueError(f"Invalid RGB mapping: {value!r}")
1121
+
1122
+ match = re.fullmatch(
1123
+ r"\s*rgb\(\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*\)\s*", value
1124
+ )
1125
+ if not match:
1126
+ raise ValueError(f"Invalid RGB repr: {value!r}")
1127
+
1128
+ r, g, b = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
1129
+ return RGB(r, g, b)
1130
+
1131
+ def parse_rgba(value: str | Mapping[str, T.Any]) -> RGBA:
1132
+ if isinstance(value, Mapping):
1133
+ data = dict(value)
1134
+ if {"red", "green", "blue", "alpha"}.issubset(data):
1135
+ return RGBA(data["red"], data["green"], data["blue"], data["alpha"])
1136
+ if {"r", "g", "b", "a"}.issubset(data):
1137
+ return RGBA(data["r"], data["g"], data["b"], data["a"])
1138
+ raise ValueError(f"Invalid RGBA mapping: {value!r}")
1139
+
1140
+ match = re.fullmatch(
1141
+ r"\s*rgba\(\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*,\s*([+-]?\d+)\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*\)\s*",
1142
+ value,
1143
+ )
1144
+ if not match:
1145
+ raise ValueError(f"Invalid RGBA repr: {value!r}")
1146
+
1147
+ r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3))
1148
+ a = float(match.group(4))
1149
+ return RGBA(r, g, b, a)
1150
+
1151
+ def parse_hex(value: str | Mapping[str, T.Any]) -> HEX:
1152
+ if isinstance(value, Mapping):
1153
+ data = dict(value)
1154
+ if "value" in data:
1155
+ return HEX(data["value"])
1156
+ if "hex" in data:
1157
+ return HEX(data["hex"])
1158
+ raise ValueError(f"Invalid HEX mapping: {value!r}")
1159
+
1160
+ match = re.fullmatch(r"\s*hex\(\s*(#?[0-9A-Fa-f]{6})\s*\)\s*", value)
1161
+ if not match:
1162
+ raise ValueError(f"Invalid HEX repr: {value!r}")
1163
+ v = match.group(1)
1164
+ if not v.startswith("#"):
1165
+ v = f"#{v}"
1166
+
1167
+ return HEX(v)
1168
+
1169
+ def parse_hsv(value: str | Mapping[str, T.Any]) -> HSV:
1170
+ if isinstance(value, Mapping):
1171
+ data = dict(value)
1172
+ if {"hue", "saturation", "value"}.issubset(data):
1173
+ return HSV(data["hue"], data["saturation"], data["value"])
1174
+ if {"h", "s", "v"}.issubset(data):
1175
+ return HSV(data["h"], data["s"], data["v"])
1176
+ raise ValueError(f"Invalid HSV mapping: {value!r}")
1177
+
1178
+ match = re.fullmatch(
1179
+ r"\s*hsv\(\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*\)\s*",
1180
+ value,
1181
+ )
1182
+ if not match:
1183
+ raise ValueError(f"Invalid HSV repr: {value!r}")
1184
+
1185
+ h, s, v = float(match.group(1)), float(match.group(2)), float(match.group(3))
1186
+ return HSV(h, s, v)
1187
+
1188
+ def parse_hsl(value: str | Mapping[str, T.Any]) -> HSL:
1189
+ if isinstance(value, Mapping):
1190
+ data = dict(value)
1191
+ if {"hue", "saturation", "lightness"}.issubset(data):
1192
+ return HSL(data["hue"], data["saturation"], data["lightness"])
1193
+ if {"h", "s", "l"}.issubset(data):
1194
+ return HSL(data["h"], data["s"], data["l"])
1195
+ raise ValueError(f"Invalid HSL mapping: {value!r}")
1196
+
1197
+ match = re.fullmatch(
1198
+ r"\s*hsl\(\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*\)\s*",
1199
+ value,
1200
+ )
1201
+ if not match:
1202
+ raise ValueError(f"Invalid HSL repr: {value!r}")
1203
+
1204
+ h, s, l = float(match.group(1)), float(match.group(2)), float(match.group(3))
1205
+ return HSL(h, s, l)
1206
+
1207
+ def parse_cmyk(value: str | Mapping[str, T.Any]) -> CMYK:
1208
+ if isinstance(value, Mapping):
1209
+ data = dict(value)
1210
+ if {"cyan", "magenta", "yellow", "key"}.issubset(data):
1211
+ return CMYK(data["cyan"], data["magenta"], data["yellow"], data["key"])
1212
+ if {"c", "m", "y", "k"}.issubset(data):
1213
+ return CMYK(data["c"], data["m"], data["y"], data["k"])
1214
+ raise ValueError(f"Invalid CMYK mapping: {value!r}")
1215
+
1216
+ match = re.fullmatch(
1217
+ r"\s*cmyk\(\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*\)\s*",
1218
+ value,
1219
+ )
1220
+ if not match:
1221
+ raise ValueError(f"Invalid CMYK repr: {value!r}")
1222
+
1223
+ c, m, y, k = (
1224
+ float(match.group(1)),
1225
+ float(match.group(2)),
1226
+ float(match.group(3)),
1227
+ float(match.group(4)),
1228
+ )
1229
+ return CMYK(c, m, y, k)
1230
+
1231
+ def parse_assa(value: str | Mapping[str, T.Any]) -> ASSA:
1232
+ if isinstance(value, Mapping):
1233
+ data = dict(value)
1234
+ if "value" in data:
1235
+ return ASSA(data["value"])
1236
+ if "clean_value" in data:
1237
+ return ASSA(data["clean_value"])
1238
+ raise ValueError(f"Invalid ASSA mapping: {value!r}")
1239
+
1240
+ match = re.fullmatch(r"\s*assa\(\s*([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})\s*\)\s*", value)
1241
+ if not match:
1242
+ raise ValueError(f"Invalid ASSA repr: {value!r}")
1243
+
1244
+ return ASSA(match.group(1))
1245
+
1246
+ def parse_webcolor(value: str | Mapping[str, T.Any]) -> webcolor:
1247
+ if isinstance(value, Mapping):
1248
+ data = dict(value)
1249
+ if "name" in data:
1250
+ return webcolor(data["name"])
1251
+ raise ValueError(f"Invalid webcolor mapping: {value!r}")
1252
+
1253
+ match = re.fullmatch(r"\s*webcolor\(\s*(['\"])\s*([A-Za-z]+)\s*\1\s*\)\s*", value)
1254
+ if not match:
1255
+ raise ValueError(f"Invalid webcolor repr: {value!r}")
1256
+
1257
+ return webcolor(match.group(2))
1258
+
1259
+ def _string_schema(
1260
+ *,
1261
+ title: str,
1262
+ description: str | None = None,
1263
+ pattern: str | None = None,
1264
+ examples: list[str] | None = None,
1265
+ ) -> _JsonSchemaValue:
1266
+ schema: dict[str, T.Any] = {"type": "string", "title": title}
1267
+ if description:
1268
+ schema["description"] = description
1269
+ if pattern:
1270
+ schema["pattern"] = pattern
1271
+ if examples:
1272
+ schema["examples"] = examples
1273
+ return schema
1274
+
1275
+ def _object_schema(
1276
+ *,
1277
+ title: str,
1278
+ description: str | None,
1279
+ properties: dict[str, T.Any],
1280
+ required: list[str],
1281
+ ) -> _JsonSchemaValue:
1282
+ return {
1283
+ "type": "object",
1284
+ "title": title,
1285
+ "description": description,
1286
+ "properties": properties,
1287
+ "required": required,
1288
+ "additionalProperties": False,
1289
+ }
1290
+
1291
+ def _register_color(
1292
+ cls: type[Color],
1293
+ parser: T.Callable[[str | Mapping[str, T.Any]], Color],
1294
+ *,
1295
+ title: str,
1296
+ string_description: str,
1297
+ string_pattern: str,
1298
+ string_examples: list[str],
1299
+ object_description: str,
1300
+ object_properties: dict[str, T.Any],
1301
+ object_required: list[str],
1302
+ ) -> None:
1303
+ string_schema = _string_schema(
1304
+ title=title,
1305
+ description=string_description,
1306
+ pattern=string_pattern,
1307
+ examples=string_examples,
1308
+ )
1309
+ object_schema = _object_schema(
1310
+ title=title,
1311
+ description=object_description,
1312
+ properties=object_properties,
1313
+ required=object_required,
1314
+ )
1315
+
1316
+ def _validator(v: T.Any) -> Color:
1317
+ if isinstance(v, cls):
1318
+ return v
1319
+ if isinstance(v, str):
1320
+ return parser(v)
1321
+ if isinstance(v, Mapping):
1322
+ return parser(v)
1323
+ raise ValueError(
1324
+ f"Expected {cls.__name__} instance, repr string, or mapping, got {type(v).__name__}"
1325
+ )
1326
+
1327
+ def _serializer(v: T.Any, info: _core_schema.SerializationInfo) -> T.Any:
1328
+ if info.mode == "json":
1329
+ return repr(v)
1330
+ return v
1331
+
1332
+ core_schema = _core_schema.no_info_plain_validator_function(
1333
+ _validator,
1334
+ serialization=_core_schema.plain_serializer_function_ser_schema(
1335
+ _serializer, info_arg=True
1336
+ ),
1337
+ )
1338
+
1339
+ def _core_schema_factory(
1340
+ _cls: type[Color], _source: T.Any, _handler: _GetCoreSchemaHandler
1341
+ ) -> _core_schema.CoreSchema:
1342
+ return core_schema
1343
+
1344
+ def _json_schema_factory(
1345
+ _cls: type[Color],
1346
+ _core_schema_obj: _core_schema.CoreSchema,
1347
+ handler: _GetJsonSchemaHandler,
1348
+ ) -> _JsonSchemaValue:
1349
+ return {
1350
+ "title": title,
1351
+ "anyOf": [
1352
+ handler.resolve_ref_schema(string_schema),
1353
+ handler.resolve_ref_schema(object_schema),
1354
+ ],
1355
+ }
1356
+
1357
+ cls.__get_pydantic_core_schema__ = classmethod(_core_schema_factory) # type: ignore[attr-defined]
1358
+ cls.__get_pydantic_json_schema__ = classmethod(_json_schema_factory) # type: ignore[attr-defined]
1359
+
1360
+ _register_color(
1361
+ RGB,
1362
+ parse_rgb,
1363
+ title="RGB",
1364
+ string_description="String representation rgb(r, g, b) with 0-255 integer channels",
1365
+ string_pattern=r"^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$",
1366
+ string_examples=["rgb(255, 0, 0)", "rgb(0, 0, 0)"],
1367
+ object_description="Object with integer channels 0-255",
1368
+ object_properties={
1369
+ "red": {"type": "integer", "minimum": 0, "maximum": 255},
1370
+ "green": {"type": "integer", "minimum": 0, "maximum": 255},
1371
+ "blue": {"type": "integer", "minimum": 0, "maximum": 255},
1372
+ },
1373
+ object_required=["red", "green", "blue"],
1374
+ )
1375
+
1376
+ _register_color(
1377
+ RGBA,
1378
+ parse_rgba,
1379
+ title="RGBA",
1380
+ string_description="String representation rgba(r, g, b, a) with 0-255 integer channels and alpha 0-1",
1381
+ string_pattern=r"^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(?:0|1|0?\.\d+)\s*\)$",
1382
+ string_examples=["rgba(255, 0, 0, 1)", "rgba(0, 128, 255, 0.5)"],
1383
+ object_description="Object with integer channels 0-255 and alpha 0-1",
1384
+ object_properties={
1385
+ "red": {"type": "integer", "minimum": 0, "maximum": 255},
1386
+ "green": {"type": "integer", "minimum": 0, "maximum": 255},
1387
+ "blue": {"type": "integer", "minimum": 0, "maximum": 255},
1388
+ "alpha": {"type": "number", "minimum": 0, "maximum": 1},
1389
+ },
1390
+ object_required=["red", "green", "blue", "alpha"],
1391
+ )
1392
+
1393
+ _register_color(
1394
+ HEX,
1395
+ parse_hex,
1396
+ title="HEX",
1397
+ string_description="String representation hex(#RRGGBB)",
1398
+ string_pattern=r"^hex\(\s*#?[0-9A-Fa-f]{6}\s*\)$",
1399
+ string_examples=["hex(#ffffff)", "hex(#0a1b2c)"],
1400
+ object_description="Object with hex string value #RRGGBB",
1401
+ object_properties={
1402
+ "value": {
1403
+ "type": "string",
1404
+ "pattern": r"^#?[0-9A-Fa-f]{6}$",
1405
+ "examples": ["#ffffff", "0A1B2C"],
1406
+ }
1407
+ },
1408
+ object_required=["value"],
1409
+ )
1410
+
1411
+ _register_color(
1412
+ HSV,
1413
+ parse_hsv,
1414
+ title="HSV",
1415
+ string_description="String representation hsv(h, s, v)",
1416
+ string_pattern=r"^hsv\(\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)\s*,\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)\s*,\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)\s*\)$",
1417
+ string_examples=["hsv(120.0, 100.0, 100.0)"],
1418
+ object_description="Object with hue 0-360, saturation/value 0-100",
1419
+ object_properties={
1420
+ "hue": {"type": "number", "minimum": 0, "maximum": 360},
1421
+ "saturation": {"type": "number", "minimum": 0, "maximum": 100},
1422
+ "value": {"type": "number", "minimum": 0, "maximum": 100},
1423
+ },
1424
+ object_required=["hue", "saturation", "value"],
1425
+ )
1426
+
1427
+ _register_color(
1428
+ HSL,
1429
+ parse_hsl,
1430
+ title="HSL",
1431
+ string_description="String representation hsl(h, s, l)",
1432
+ string_pattern=r"^hsl\(\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)\s*,\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)\s*,\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)\s*\)$",
1433
+ string_examples=["hsl(240.0, 100.0, 50.0)"],
1434
+ object_description="Object with hue 0-360, saturation/lightness 0-100",
1435
+ object_properties={
1436
+ "hue": {"type": "number", "minimum": 0, "maximum": 360},
1437
+ "saturation": {"type": "number", "minimum": 0, "maximum": 100},
1438
+ "lightness": {"type": "number", "minimum": 0, "maximum": 100},
1439
+ },
1440
+ object_required=["hue", "saturation", "lightness"],
1441
+ )
1442
+
1443
+ _register_color(
1444
+ CMYK,
1445
+ parse_cmyk,
1446
+ title="CMYK",
1447
+ string_description="String representation cmyk(c, m, y, k)",
1448
+ string_pattern=r"^cmyk\(\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)\s*,\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)\s*,\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)\s*,\s*[+-]?(?:\d+(?:\.\d+)?|\.\d+)\s*\)$",
1449
+ string_examples=["cmyk(0, 100, 100, 0)", "cmyk(10.5, 0, 25, 5)"],
1450
+ object_description="Object with CMYK percentages 0-100",
1451
+ object_properties={
1452
+ "cyan": {"type": "number", "minimum": 0, "maximum": 100},
1453
+ "magenta": {"type": "number", "minimum": 0, "maximum": 100},
1454
+ "yellow": {"type": "number", "minimum": 0, "maximum": 100},
1455
+ "key": {"type": "number", "minimum": 0, "maximum": 100},
1456
+ },
1457
+ object_required=["cyan", "magenta", "yellow", "key"],
1458
+ )
1459
+
1460
+ _register_color(
1461
+ ASSA,
1462
+ parse_assa,
1463
+ title="ASSA",
1464
+ string_description="String representation assa(BBGGRR) or assa(AABBGGRR)",
1465
+ string_pattern=r"^assa\(\s*[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?\s*\)$",
1466
+ string_examples=["assa(00ff00)", "assa(ff00ff00)"],
1467
+ object_description="Object with ASSA hex value (BBGGRR or AABBGGRR)",
1468
+ object_properties={
1469
+ "value": {
1470
+ "type": "string",
1471
+ "pattern": r"^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$",
1472
+ "examples": ["00ff00", "ff00ff00"],
1473
+ }
1474
+ },
1475
+ object_required=["value"],
1476
+ )
1477
+
1478
+ _register_color(
1479
+ webcolor,
1480
+ parse_webcolor,
1481
+ title="Web Color",
1482
+ string_description="Named CSS color via webcolor('name')",
1483
+ string_pattern=r"^webcolor\(\s*['\"]?[A-Za-z]+['\"]?\s*\)$",
1484
+ string_examples=["webcolor('red')", 'webcolor("aliceblue")'],
1485
+ object_description="Object with CSS color name",
1486
+ object_properties={
1487
+ "name": {"type": "string", "enum": list(CSS_COLOR_TO_HEX.keys())},
1488
+ },
1489
+ object_required=["name"],
1490
+ )
1491
+ __all__ = [
1492
+ "Color",
1493
+ "RGB",
1494
+ "RGBA",
1495
+ "HEX",
1496
+ "HSV",
1497
+ "HSL",
1498
+ "CMYK",
1499
+ "ASSA",
1500
+ "webcolor",
1501
+ "CSS_COLOR_TO_HEX",
1502
+ "CssColorName",
1503
+ "ColorValueError",
1504
+ "normalize_color",
1505
+ ]