# -*- coding: utf-8 -*-
# cython: language_level=3, always_allow_keywords=True
## Copyright 2004-2024 by LivingLogic AG, Bayreuth/Germany
## Copyright 2004-2024 by Walter Dörwald
##
## All Rights Reserved
##
## See ll/xist/__init__.py for the license
"""
:mod:`!ll.color` provides classes and functions for handling RGBA colors.
"""
import colorsys
from ll import ul4c
from typing import *
Number = int | float
__docformat__ = "reStructuredText"
def _interpolate(lower:float, upper:float, factor:float) -> float:
return factor*upper + (1-factor) * lower
[docs]
class Color(tuple):
"""
A :class:`Color` object represents a color with 8-bit red, green and blue
components and opacity.
"""
ul4_type = ul4c.InstantiableType("color", "Color", "An RGBA color object with 8-bit red, green and blue components and opacity.")
ul4_attrs = {"r", "g", "b", "a", "hsv", "hsva", "hls", "hlsa", "hue", "sat", "light", "lum", "withhue", "withlight", "withsat", "withlum", "witha", "abslight", "rellight", "abslum", "rellum", "combine", "invert"}
[docs]
def __new__(cls, r:int=0x0, g:int=0x0, b:int=0x0, a:int=0xff):
"""
Create a :class:`!Color` with the 8 bit red, green, blue and alpha
components ``r``, ``g``, ``b`` and ``a``. Values will be
clipped to the range [0; 255].
"""
return tuple.__new__(cls, (max(0, min(int(r), 255)), max(0, min(int(g), 255)), max(0, min(int(b), 255)), max(0, min(int(a), 255))))
@classmethod
def fromrepr(cls, s:str) -> "Color":
try:
if len(s) == 9:
return cls(int(s[1:3], 16), int(s[3:5], 16), int(s[5:7], 16), int(s[7:], 16))
elif len(s) == 7:
return cls(int(s[1:3], 16), int(s[3:5], 16), int(s[5:], 16))
elif len(s) == 5:
return cls(17*int(s[1], 16), 17*int(s[2], 16), 17*int(s[3], 16), 17*int(s[4], 16))
elif len(s) == 4:
return cls(17*int(s[1], 16), 17*int(s[2], 16), 17*int(s[3], 16))
except ValueError:
pass
raise ValueError(f"can't interpret {s!r} as color repr value")
[docs]
@classmethod
def fromcss(cls, s:str) -> "Color":
"""
Create a :class:`Color` object from the CSS__ color string ``s``.
All formats from CSS2 are supported (i.e. ``'#xxx'``, ``'#xxxxxx'``,
``rgb(r, g, b)``, ``rgb(r%, g%, b%)``, ``rgba(r, g, b, a)``,
``rgba(r%, g%, b%, a)`` and color names like ``'red'``).
__ http://www.w3.org/TR/css3-color/#colorunits
"""
if s.startswith("#"):
if len(s) == 4:
return cls(17*int(s[1], 16), 17*int(s[2], 16), 17*int(s[3], 16))
elif len(s) == 5:
return cls(17*int(s[1], 16), 17*int(s[2], 16), 17*int(s[3], 16), 17*int(s[4], 16))
elif len(s) == 7:
return cls(int(s[1:3], 16), int(s[3:5], 16), int(s[5:], 16))
elif len(s) == 9:
return cls(int(s[1:3], 16), int(s[3:5], 16), int(s[5:7], 16), int(s[7:], 16))
elif s.startswith("rgb(") and s.endswith(")"):
channels = []
for x in s[4:-1].split(","):
x = x.strip()
if x.endswith("%"):
v = float(x[:-1])*0xff/100
else:
v = int(x)
channels.append(v)
return cls(*channels)
elif s.startswith("rgba(") and s.endswith(")"):
channels = []
for x in s[5:-1].split(","):
x = x.strip()
if x.endswith("%"):
v = float(x[:-1])*0xff/100
elif len(channels) == 3: # alpha value
v = float(x)*0xff
else:
v = int(x)
channels.append(v)
return cls(*channels)
elif s in csscolors:
return csscolors[s]
raise ValueError(f"can't interpret {s!r} as CSS color")
[docs]
@classmethod
def fromrgb(cls, r:Number, g:Number, b:Number, a:Number=1.0) -> "Color":
"""
Create a :class:`Color` object from the red, green, blue and alpha values
``r``, ``g``, ``b`` and ``a``. All values will be clipped to the range
[0; 1].
"""
return cls(255*r, 255*g, 255*b, 255*a)
[docs]
@classmethod
def fromhsv(cls, h:Number, s:Number, v:Number, a:Number=1.0) -> "Color":
"""
Create a :class:`Color` object from the hue, saturation and value values
``h``, ``s`` and ``v`` and the alpha value ``a``. The hue value will be
used modulo 1.0, saturation, value and alpha will be clipped to the range
[0; 1].
"""
rgb = colorsys.hsv_to_rgb(h % 1.0, max(0., min(s, 1.)), max(0., min(v, 1.)))
return cls.fromrgb(*(rgb + (a,)))
[docs]
@classmethod
def fromhls(cls, h:Number, l:Number, s:Number, a:Number=1.0) -> "Color":
"""
Create a :class:`Color` object from the hue, luminance and saturation
values ``h``, ``l`` and ``s`` and the alpha value ``a``. The hue value
will be used modulo 1.0, luminance, saturation and alpha will be clipped
to the range [0; 1].
"""
rgb = colorsys.hls_to_rgb(h % 1.0, max(0., min(l, 1.)), max(0., min(s, 1.)))
return cls.fromrgb(*(rgb + (a,)))
def __repr__(self) -> str:
if self[3] != 0xff:
return f"Color({self[0]:#04x}, {self[1]:#04x}, {self[2]:#04x}, {self[3]:#04x})"
else:
return f"Color({self[0]:#04x}, {self[1]:#04x}, {self[2]:#04x})"
[docs]
def __str__(self) -> str:
"""
``self`` formatted as a CSS color string.
"""
if self[3] != 0xff:
return f"rgba({self[0]},{self[1]},{self[2]},{self[3]/255.:.3f})"
else:
s = f"#{self[0]:02x}{self[1]:02x}{self[2]:02x}"
if s[1] == s[2] and s[3] == s[4] and s[5] == s[6]:
s = f"#{s[1]}{s[3]}{s[5]}"
return s
[docs]
def r(self) -> int:
"""
The red value as an int between 0 and 255.
"""
return self[0]
[docs]
def g(self) -> int:
"""
The green value as an int between 0 and 255.
"""
return self[1]
[docs]
def b(self) -> int:
"""
The blue value as an int between 0 and 255.
"""
return self[2]
[docs]
def a(self) -> int:
"""
The alpha value as an int between 0 and 255.
"""
return self[3]
[docs]
def rgb(self) -> Tuple[float, float, float]:
"""
The red, green and blue value as a float tuple with values between
0.0 and 1.0.
"""
return (self[0]/255., self[1]/255., self[2]/255.)
[docs]
def rgba(self) -> Tuple[float, float, float, float]:
"""
The red, green, blue and alpha value as a float tuple with values between
0.0 and 1.0.
"""
return (self[0]/255., self[1]/255., self[2]/255., self[3]/255.)
[docs]
def hsv(self) -> Tuple[float, float, float]:
"""
``self`` as a HSV tuple ("hue", "saturation", "value").
All three values are between 0.0 and 1.0.
"""
return colorsys.rgb_to_hsv(self[0]/255., self[1]/255., self[2]/255.)
[docs]
def hsva(self) -> Tuple[float, float, float, float]:
"""
``self`` as a HSV+alpha tuple ("hue", "saturation", "value", "alpha").
All four values are between 0.0 and 1.0.
"""
return self.hsv() + (self[3]/255.,)
[docs]
def hls(self) -> Tuple[float, float, float]:
"""
``self`` as a HLS tuple ("hue, luminance, saturation"). All three
values are between 0.0 and 1.0.
"""
return colorsys.rgb_to_hls(self[0]/255., self[1]/255., self[2]/255.)
[docs]
def hlsa(self) -> Tuple[float, float, float, float]:
"""
``self`` as a HLS+alpha tuple ("hue, luminance, saturation, alpha").
All four values are between 0.0 and 1.0.
"""
return self.hls() + (self[3]/255.,)
[docs]
def hue(self) -> float:
"""
The hue value from :meth:`hls`.
"""
return self.hls()[0]
[docs]
def light(self) -> float:
"""
The lightness value from :meth:`hls`.
"""
return self.hls()[1]
[docs]
def sat(self) -> float:
"""
The saturation value from :meth:`hls`.
"""
return self.hls()[2]
[docs]
def lum(self) -> float:
"""
Luminance according to sRGB:
.. sourcecode:: python
(0.2126*r + 0.7152*g + 0.0722*b)/255
"""
return (0.2126 * self[0] + 0.7152 * self[1] + 0.0722 * self[2])/255.
[docs]
def withhue(self, hue:Number) -> "Color":
"""
Return a new color with the HLS hue replaced by ``hue``.
"""
(h, l, s, a) = self.hlsa()
return self.fromhls(hue, l, s, a)
[docs]
def withlight(self, light:Number) -> "Color":
"""
Return a new color with the HLS lightness replaced by ``light``
"""
(h, l, s, a) = self.hlsa()
return self.fromhls(h, light, s, a)
[docs]
def withsat(self, sat:Number) -> "Color":
"""
Return a new color with the HLS saturation replaced by ``sat``
"""
(h, l, s, a) = self.hlsa()
return self.fromhls(h, l, sat, a)
[docs]
def withlum(self, lum:Number) -> "Color":
"""
Return a copy of ``self`` where the luminance has been replace with ``lum``.
"""
lum_old = self.lum()
if lum_old == 0.0 or lum_old == 1.0:
v = lum*255
return Color(v, v, v, self[3])
elif lum > lum_old:
f = (lum-lum_old)/(1-lum_old)
return Color(
_interpolate(self[0], 255, f),
_interpolate(self[1], 255, f),
_interpolate(self[2], 255, f),
self[3],
)
elif lum < lum_old:
f = lum/lum_old
return Color(
_interpolate(0, self[0], f),
_interpolate(0, self[1], f),
_interpolate(0, self[2], f),
self[3],
)
else:
return self
[docs]
def witha(self, a:int) -> "Color":
"""
Return a copy of ``self`` with the alpha value replaced with ``a``.
"""
(r, g, b, olda) = self
return self.__class__(r, g, b, a)
[docs]
def abslight(self, f:Number) -> "Color":
"""
Return a copy of ``self`` with ``f`` added to the HLS lightness.
"""
(h, l, s, a) = self.hlsa()
return self.fromhls(h, l+f, s, a)
[docs]
def rellight(self, f:Number) -> "Color":
"""
Return a copy of ``self`` where the lightness has been modified:
If ``f`` is positive the lightness will be increased, with ``f==1``
giving a lightness of 1. If ``f`` is negative, the lightness will be
decreased with ``f==-1`` giving a lightness of 0. ``f==0`` will leave
the lightness unchanged.
"""
(h, l, s, a) = self.hlsa()
if f > 0:
l += (1-l)*f
elif f < 0:
l += l*f
return self.fromhls(h, l, s, a)
[docs]
def abslum(self, f:Number) -> "Color":
"""
Return a copy of ``self`` where ``f`` has been added to the lum value.
"""
return self.withlum(self.lum() + f)
[docs]
def rellum(self, f:Number) -> "Color":
"""
Return a copy of ``self`` where the luminance has been modified:
If ``f`` is positive the luminance will be increased, with ``f==1``
giving a luminance of 1. If ``f`` is negative, the luminance will be
decreased with ``f==-1`` giving a luminance of 0. ``f==0`` will leave
the luminance unchanged. All other values return a linear interpolation.
"""
lum = self.lum()
if f > 0:
lum += (1-lum)*f
elif f < 0:
lum += lum*f
return self.withlum(lum)
[docs]
def combine(self, r:Number=None, g:Number=None, b:Number=None, a:Number=None) -> "Color":
"""
Return a copy of ``self`` with some of its components replaced by the
arguments. If a component is nont passed (or the value ``None`` is given)
the component will be unchanged in the resulting color.
"""
channels = list(self)
if r is not None:
channels[0] = r
if g is not None:
channels[1] = g
if b is not None:
channels[2] = b
if a is not None:
channels[3] = a
return self.__class__(*channels)
[docs]
def invert(self, f:Number=1.0) -> "Color":
"""
Return an inverted version of ``self``, i.e. for each color ``c``
the following prints ``True`` three times:
.. sourcecode:: ul4
<?print c.invert().r() == 255 - c.r()?>
<?print c.invert().g() == 255 - c.g()?>
<?print c.invert().b() == 255 - c.b()?>
``f`` specifies the amount of inversion, with 1 returning a complete
inversion, and 0 returning the original color. Values between 0 and 1
return an interpolation of both extreme values. (And 0.5 always returns
medium grey).
"""
invf = 1.0 - f
return self.__class__(
invf * self[0] + f * (255-self[0]),
invf * self[1] + f * (255-self[1]),
invf * self[2] + f * (255-self[2]),
self[3],
)
def __add__(self, other):
raise NotImplementedError
def __mul__(self, factor:Number) -> "Color":
return self.__class__(factor*self[0], factor*self[1], factor*self[2], self[3])
def __rmul__(self, factor:Number) -> "Color":
return self.__class__(factor*self[0], factor*self[1], factor*self[2], self[3])
def __truediv__(self, factor:Number) -> "Color":
return self.__class__(self[0]/factor, self[1]/factor, self[2]/factor, self[3])
def __floordiv__(self, factor:Number) -> "Color":
return self.__class__(self[0]//factor, self[1]//factor, self[2]//factor, self[3])
[docs]
def __mod__(self, other:"Color") -> "Color":
"""
Blends ``self`` with the background color ``other`` according to the
`CSS specification`__
__ https://www.w3.org/TR/2013/WD-compositing-1-20131010/#simplealphacompositing
"""
# Scale our values to the range [0, 1]
rt = self[0]/255.
gt = self[1]/255.
bt = self[2]/255.
at = self[3]/255.
# Convert to premultiplied alpha
rt *= at
gt *= at
bt *= at
# Scale other values to the range [0, 1]
ro = other[0]/255.
go = other[1]/255.
bo = other[2]/255.
ao = other[3]/255.
# Convert to premultiplied alpha
ro *= ao
go *= ao
bo *= ao
# Blend colors
rf = rt + ro * (1 - at)
gf = gt + go * (1 - at)
bf = bt + bo * (1 - at)
af = at + ao * (1 - at)
# Unmultiply alpha
if af:
rf /= af
gf /= af
bf /= af
# Scale back to [0, 255]
r = int(255*rf)
g = int(255*gf)
b = int(255*bf)
a = int(255*af)
# create final color
return self.__class__(r, g, b, a)
###
### CSS color constants (see http://www.w3.org/TR/css3-color/#html4)
###
maroon = Color(0x80, 0x00, 0x00)
red = Color(0xff, 0x00, 0x00)
orange = Color(0xff, 0xa5, 0x00)
yellow = Color(0xff, 0xff, 0x00)
olive = Color(0x80, 0x80, 0x00)
purple = Color(0x80, 0x00, 0x80)
fuchsia = Color(0xff, 0x00, 0xff)
white = Color(0xff, 0xff, 0xff)
lime = Color(0x00, 0xff, 0x00)
green = Color(0x00, 0x80, 0x00)
navy = Color(0x00, 0x00, 0x80)
blue = Color(0x00, 0x00, 0xff)
aqua = Color(0x00, 0xff, 0xff)
teal = Color(0x00, 0x80, 0x80)
black = Color(0x00, 0x00, 0x00)
silver = Color(0xc0, 0xc0, 0xc0)
gray = Color(0x80, 0x80, 0x80)
# aliases
magenta = purple
cyan = aqua
transparent = Color(0, 0, 0, 0)
csscolors = {
"maroon": maroon,
"red": red,
"orange": orange,
"yellow": yellow,
"olive": olive,
"purple": purple,
"fuchsia": fuchsia,
"white": white,
"lime": lime,
"green": green,
"navy": navy,
"blue": blue,
"aqua": aqua,
"teal": teal,
"black": black,
"silver": silver,
"gray": gray,
"magenta": magenta,
"cyan": cyan,
}
_missing = object()
[docs]
def css(value:str, default:str|None=_missing, /) -> "Color":
"""
Create a :class:`Color` object from the CSS__ color string ``value`` via
:meth:`Color.fromcss`.
If ``value`` is no valid CSS color string and ``default`` is given,
return ``default`` instead.
__ http://www.w3.org/TR/css3-color/#colorunits
"""
if default is _missing:
return Color.fromcss(value)
else:
try:
return Color.fromcss(value)
except ValueError:
return default
[docs]
def dist(c1:"Color", c2:"Color", /) -> float:
"""
Return the distance between two colors.
"""
d0 = c1[0]-c2[0]
d1 = c1[1]-c2[1]
d2 = c1[2]-c2[2]
return d0*d0+d1*d1+d2*d2
[docs]
def multiply(c1:"Color", c2:"Color", /) -> "Color":
"""
Multiplies the colors ``c1`` and ``c2``.
"""
return Color(c1[0]*c2[0], c1[1]*c2[1], c1[2]*c2[2], 1.-(1.-c1[3])*(1.-c2[3]))
[docs]
def screen(c1:"Color", c2:"Color", /) -> "Color":
"""
Does a negative multiplication of the colors ``c1`` and ``c2``.
"""
return Color(*(1.-(1.-x)*(1.-y) for (x, y) in zip(c1, c2)))
[docs]
def mix(*args) -> "Color":
"""
Calculates a weighted mix of the colors from ``args``. Items in
``args`` are either colors or weights. The following example mixes
two parts black with one part white::
>>> from ll import color
>>> color.mix(2, color.black, 1, color.white)
Color(0x55, 0x55, 0x55)
"""
channels = [0., 0., 0., 0.]
weight = 1.
sumweights = 0.
for arg in args:
if isinstance(arg, Color):
sumweights += weight
for i in range(len(arg)):
channels[i] += weight*arg[i]
elif isinstance(arg, tuple):
sumweights += arg[1]
for i in range(len(arg)):
channels[i] += arg[1]*arg[0][i]
else:
weight = arg
if not sumweights:
raise ValueError("at least one of the arguments must be a color and at least one of the weights must be >0")
return Color(channels[0]/sumweights, channels[1]/sumweights, channels[2]/sumweights, channels[3]/sumweights)