Source code for ll.color

# -*- 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)