#-----------------------------------------------------------------------------
# Copyright (c) Anaconda, Inc., and Bokeh Contributors.
# All rights reserved.
#
# The full license is in the file LICENSE.txt, distributed with this software.
#-----------------------------------------------------------------------------
''' Provide a base class for representing color values.
'''
#-----------------------------------------------------------------------------
# Boilerplate
#-----------------------------------------------------------------------------
from __future__ import annotations
import logging # isort:skip
log = logging.getLogger(__name__)
#-----------------------------------------------------------------------------
# Imports
#-----------------------------------------------------------------------------
# Standard library imports
import colorsys
from abc import ABCMeta, abstractmethod
from math import sqrt
from re import match
from typing import TYPE_CHECKING, TypeAlias
# Bokeh imports
from ..core.serialization import AnyRep, Serializable, Serializer
if TYPE_CHECKING:
from typing_extensions import Self
#-----------------------------------------------------------------------------
# Globals and constants
#-----------------------------------------------------------------------------
__all__ = (
'Color',
'ColorLike',
)
#-----------------------------------------------------------------------------
# General API
#-----------------------------------------------------------------------------
RGBTuple = tuple[int, int, int] | tuple[int, int, int, float]
[docs]
class Color(Serializable, metaclass=ABCMeta):
''' A base class for representing color objects.
'''
def __repr__(self) -> str:
return self.to_css()
[docs]
def to_serializable(self, serializer: Serializer) -> AnyRep:
return self.to_css()
[docs]
@staticmethod
def clamp(value: float, maximum: float | None = None) -> float:
''' Clamp numeric values to be non-negative, an optionally, less than a
given maximum.
Args:
value (float) :
A number to clamp.
maximum (float, optional) :
A max bound to to clamp to. If None, there is no upper bound,
and values are only clamped to be non-negative. (default: None)
Returns:
float
'''
value = max(value, 0)
if maximum is not None:
return min(value, maximum)
else:
return value
[docs]
@abstractmethod
def copy(self) -> Self:
''' Copy this color.
*Subclasses must implement this method.*
'''
raise NotImplementedError
[docs]
def darken(self, amount: float) -> Self:
''' Darken (reduce the luminance) of this color.
*Subclasses must implement this method.*
Args:
amount (float) :
Amount to reduce the luminance by (clamped above zero)
Returns:
Color
'''
return self.lighten(-amount)
[docs]
@classmethod
@abstractmethod
def from_hsl(cls, value: HSL) -> Self:
''' Create a new color by converting from an HSL color.
*Subclasses must implement this method.*
Args:
value (HSL) :
A color to convert from HSL
Returns:
Color
'''
raise NotImplementedError
[docs]
@classmethod
@abstractmethod
def from_rgb(cls, value: RGB) -> Self:
''' Create a new color by converting from an RGB color.
*Subclasses must implement this method.*
Args:
value (:class:`~bokeh.colors.RGB`) :
A color to convert from RGB
Returns:
Color
'''
raise NotImplementedError
[docs]
def lighten(self, amount: float) -> Self:
''' Lighten (increase the luminance) of this color.
*Subclasses must implement this method.*
Args:
amount (float) :
Amount to increase the luminance by (clamped above zero)
Returns:
Color
'''
rgb = self.to_rgb()
h, l, s = colorsys.rgb_to_hls(float(rgb.r)/255, float(rgb.g)/255, float(rgb.b)/255)
new_l = self.clamp(l + amount, 1)
r, g, b = colorsys.hls_to_rgb(h, new_l, s)
rgb.r = round(r * 255)
rgb.g = round(g * 255)
rgb.b = round(b * 255)
return self.from_rgb(rgb)
[docs]
@abstractmethod
def to_css(self) -> str:
''' Return a CSS representation of this color.
*Subclasses must implement this method.*
Returns:
str
'''
raise NotImplementedError
[docs]
@abstractmethod
def to_hsl(self) -> HSL:
''' Create a new HSL color by converting from this color.
*Subclasses must implement this method.*
Returns:
HSL
'''
raise NotImplementedError
[docs]
@abstractmethod
def to_rgb(self) -> RGB:
''' Create a new HSL color by converting from this color.
*Subclasses must implement this method.*
Returns:
:class:`~bokeh.colors.RGB`
'''
raise NotImplementedError
class RGB(Color):
''' Represent colors by specifying their Red, Green, and Blue channels.
Alpha values may also optionally be provided. Otherwise, alpha values
default to 1.
'''
def __init__(self, r: int, g: int, b: int, a: float = 1.0) -> None:
'''
Args:
r (int) :
The value for the red channel in [0, 255]
g (int) :
The value for the green channel in [0, 255]
b (int) :
The value for the blue channel in [0, 255]
a (float, optional) :
An alpha value for this color in [0, 1] (default: 1.0)
'''
self.r = r
self.g = g
self.b = b
self.a = a
def copy(self) -> RGB:
''' Return a copy of this color value.
Returns:
:class:`~bokeh.colors.RGB`
'''
return RGB(self.r, self.g, self.b, self.a)
@classmethod
def from_hsl(cls, value: HSL) -> RGB:
''' Create an RGB color from an HSL color value.
Args:
value (HSL) :
The HSL color to convert.
Returns:
:class:`~bokeh.colors.RGB`
'''
return value.to_rgb()
@classmethod
def from_hex_string(cls, hex_string: str) -> RGB:
''' Create an RGB color from a RGB(A) hex string.
Args:
hex_string (str) :
String containing hex-encoded RGBA(A) values. Valid formats
are '#rrggbb', '#rrggbbaa', '#rgb' and '#rgba'.
Returns:
:class:`~bokeh.colors.RGB`
'''
if isinstance(hex_string, str):
# Hex color as #rrggbbaa or #rrggbb
if match(r"#([\da-fA-F]{2}){3,4}\Z", hex_string):
r = int(hex_string[1:3], 16)
g = int(hex_string[3:5], 16)
b = int(hex_string[5:7], 16)
a = int(hex_string[7:9], 16) / 255.0 if len(hex_string) > 7 else 1.0
return RGB(r, g, b, a)
# Hex color as #rgb or #rgba
if match(r"#[\da-fA-F]{3,4}\Z", hex_string):
r = int(hex_string[1]*2, 16)
g = int(hex_string[2]*2, 16)
b = int(hex_string[3]*2, 16)
a = int(hex_string[4]*2, 16) / 255.0 if len(hex_string) > 4 else 1.0
return RGB(r, g, b, a)
raise ValueError(f"'{hex_string}' is not an RGB(A) hex color string")
@classmethod
def from_tuple(cls, value: RGBTuple) -> RGB:
''' Initialize ``RGB`` instance from a 3- or 4-tuple. '''
if len(value) == 3:
r, g, b = value
return RGB(r, g, b)
else:
r, g, b, a = value
return RGB(r, g, b, a)
@classmethod
def from_rgb(cls, value: RGB) -> RGB:
''' Copy an RGB color from another RGB color value.
Args:
value (:class:`~bokeh.colors.RGB`) :
The RGB color to copy.
Returns:
:class:`~bokeh.colors.RGB`
'''
return value.copy()
def to_css(self) -> str:
''' Generate the CSS representation of this RGB color.
Returns:
str, ``"rgb(...)"`` or ``"rgba(...)"``
'''
if self.a == 1.0:
return f"rgb({self.r}, {self.g}, {self.b})"
else:
return f"rgba({self.r}, {self.g}, {self.b}, {self.a})"
def to_hex(self) -> str:
''' Return a hex color string for this RGB(A) color.
Any alpha value is only included in the output string if it is less
than 1.
Returns:
str, ``"#RRGGBBAA"`` if alpha is less than 1 and ``"#RRGGBB"``
otherwise
'''
if self.a < 1.0:
return f"#{self.r:02x}{self.g:02x}{self.b:02x}{int(round(self.a*255)):02x}"
else:
return f"#{self.r:02x}{self.g:02x}{self.b:02x}"
def to_hsl(self) -> HSL:
''' Return a corresponding HSL color for this RGB color.
Returns:
:class:`~bokeh.colors.HSL`
'''
h, l, s = colorsys.rgb_to_hls(float(self.r)/255, float(self.g)/255, float(self.b)/255)
return HSL(round(h*360), s, l, self.a)
def to_rgb(self) -> RGB:
''' Return a RGB copy for this RGB color.
Returns:
:class:`~bokeh.colors.RGB`
'''
return self.copy()
@property
def brightness(self) -> float:
""" Perceived brightness of a color in [0, 1] range. """
# http://alienryderflex.com/hsp.html
r, g, b = self.r, self.g, self.b
return sqrt(0.299*r**2 + 0.587*g**2 + 0.114*b**2)/255
@property
def luminance(self) -> float:
""" Perceived luminance of a color in [0, 1] range. """
# https://en.wikipedia.org/wiki/Relative_luminance
r, g, b = self.r, self.g, self.b
return (0.2126*r**2.2 + 0.7152*g**2.2 + 0.0722*b**2.2) / 255**2.2
class HSL(Color):
''' Represent colors by specifying their Hue, Saturation, and lightness.
Alpha values may also optionally be provided. Otherwise, alpha values
default to 1.
'''
def __init__(self, h: float, s: float, l: float, a: float = 1.0) -> None:
'''
Args:
h (int) :
The Hue, in [0, 360]
s (int) :
The Saturation, in [0, 1]
l (int) :
The lightness, in [0, 1]
a (float, optional) :
An alpha value for this color in [0, 1] (default: 1.0)
'''
self.h = h
self.s = s
self.l = l
self.a = a
def copy(self) -> HSL:
''' Return a copy of this color value.
Returns:
:class:`~bokeh.colors.HSL`
'''
return HSL(self.h, self.s, self.l, self.a)
def darken(self, amount: float) -> HSL:
''' Darken (reduce the luminance) of this color.
Args:
amount (float) :
Amount to reduce the luminance by (clamped above zero)
Returns:
:class:`~bokeh.colors.HSL`
'''
return self.lighten(-amount)
@classmethod
def from_hsl(cls, value: HSL) -> HSL:
''' Copy an HSL color from another HSL color value.
Args:
value (HSL) :
The HSL color to copy.
Returns:
:class:`~bokeh.colors.hsl.HSL`
'''
return value.copy()
@classmethod
def from_rgb(cls, value: RGB) -> HSL:
''' Create an HSL color from an RGB color value.
Args:
value (:class:`~bokeh.colors.RGB`) :
The RGB color to convert.
Returns:
:class:`~bokeh.colors.HSL`
'''
return value.to_hsl()
def to_css(self) -> str:
''' Generate the CSS representation of this HSL color.
Returns:
str, ``"hsl(...)"`` or ``"hsla(...)"``
'''
if self.a == 1.0:
return f"hsl({self.h}, {self.s*100}%, {self.l*100}%)"
else:
return f"hsla({self.h}, {self.s*100}%, {self.l*100}%, {self.a})"
def to_hsl(self) -> HSL:
''' Return a HSL copy for this HSL color.
Returns:
:class:`~bokeh.colors.HSL`
'''
return self.copy()
def to_rgb(self) -> RGB:
''' Return a corresponding :class:`~bokeh.colors.RGB` color for
this HSL color.
Returns:
:class:`~bokeh.colors.RGB`
'''
r, g, b = colorsys.hls_to_rgb(float(self.h)/360, self.l, self.s)
return RGB(round(r*255), round(g*255), round(b*255), self.a)
def lighten(self, amount: float) -> HSL:
''' Lighten (increase the luminance) of this color.
Args:
amount (float) :
Amount to increase the luminance by (clamped above zero)
Returns:
:class:`~bokeh.colors.HSL`
'''
hsl = self.copy()
hsl.l = self.clamp(hsl.l + amount, 1)
return self.from_hsl(hsl)
ColorLike: TypeAlias = str | Color | RGBTuple
#-----------------------------------------------------------------------------
# Dev API
#-----------------------------------------------------------------------------
#-----------------------------------------------------------------------------
# Private API
#-----------------------------------------------------------------------------
#-----------------------------------------------------------------------------
# Code
#-----------------------------------------------------------------------------