"""Color management and conversion utilities for matplotlib.
This module provides color loading, registration, and conversion
functionality including support for OKLab, OKLCH, RGB, and hex color
spaces.
"""
import json
import math
from pathlib import Path
import matplotlib.colors as mcolors
import numpy as np
def _parse_color_data(path: str | Path) -> dict[str, str]:
"""
Parse color data from a text file.
Parameters
----------
path : str or Path
Path to the color data file. Each line should contain a
color name and value separated by a colon.
Returns
-------
dict[str, str]
Dictionary mapping color names to color values.
"""
color_dict: dict[str, str] = {}
with open(path) as f:
lines: list[str] = f.readlines()
for line in lines:
# Neglect comment line.
if line.startswith("#"):
continue
# Neglect empty line.
if not line.strip():
continue
k: str
v: str
k, v = line.split(":")
color_dict[k.strip()] = v.strip()
return color_dict
def _load_colors() -> None:
"""
Load all color definitions from asset files and register them.
This function loads colors from text files and JSON files in the
asset/color directory. It adds 'oc.' prefix
to distinguish them from matplotlib's built-in colors.
Tailwind CSS colors are loaded with 'tw.' prefix,
followed by the color name and weight (e.g., 'tw.blue500',
'tw.gray200'). Weights range from 50 to 950 in increments
of 50 or 100.
Material Design colors are loaded with 'md.' prefix
(e.g., 'md.blue500', 'md.red700'). Weights range from 50 to 900.
Ant Design colors are loaded with 'ad.' prefix
(e.g., 'ad.blue5', 'ad.red6'). Weights range from 1 to 10.
Chakra UI colors are loaded with 'cu.' prefix
(e.g., 'cu.blue500', 'cu.red600'). Weights range from
50 to 900.
Primer colors are loaded with 'pr.' prefix
(e.g., 'pr.blue5', 'pr.red6'). Weights range from 0 to 9.
Notes
-----
This function is automatically called when the module is imported.
"""
color_dict: dict[str, str] = {}
root_dir: Path = Path(__file__).parent / "asset/color"
for path in root_dir.glob("*.txt"):
color_dict.update(_parse_color_data(path))
# Append prefix to distinguish them from matplotlib colors.
_color_dict: dict[str, str] = {f"oc.{k}": v for k, v in color_dict.items()}
# Tailwind colors.
with open(root_dir / "tailwind_colors.json") as f:
tailwind_colors: dict[str, list[tuple[int, str]]] = json.load(f)
for k, v in tailwind_colors.items():
k_lower: str = k.lower().replace(" ", "")
for weight, hex_val in v:
# Only use 'tw.' prefix, skip 'tw.' prefix since they
# are identical
_color_dict[f"tw.{k_lower}{weight}"] = f"#{hex_val}"
# Material Design colors.
with open(root_dir / "material_colors.json") as f:
material_colors: dict[str, list[tuple[int, str]]] = json.load(f)
for k, v in material_colors.items():
# Remove spaces (e.g., "Deep Purple" -> "deeppurple")
k_lower: str = k.lower().replace(" ", "")
for weight, hex_val in v:
_color_dict[f"md.{k_lower}{weight}"] = f"#{hex_val}"
# Ant Design colors.
with open(root_dir / "ant_colors.json") as f:
ant_colors: dict[str, list[tuple[int, str]]] = json.load(f)
for k, v in ant_colors.items():
k_lower: str = k.lower().replace(" ", "")
for weight, hex_val in v:
_color_dict[f"ad.{k_lower}{weight}"] = f"#{hex_val}"
# Chakra UI colors.
with open(root_dir / "chakra_colors.json") as f:
chakra_colors: dict[str, list[tuple[int, str]]] = json.load(f)
for k, v in chakra_colors.items():
k_lower: str = k.lower().replace(" ", "")
for weight, hex_val in v:
_color_dict[f"cu.{k_lower}{weight}"] = f"#{hex_val}"
# Primer colors.
with open(root_dir / "primer_colors.json") as f:
primer_colors: dict[str, list[tuple[int, str]]] = json.load(f)
for k, v in primer_colors.items():
k_lower: str = k.lower().replace(" ", "")
for weight, hex_val in v:
_color_dict[f"pr.{k_lower}{weight}"] = f"#{hex_val}"
# Add color dict to matplotlib internal color mapping.
mcolors.get_named_colors_mapping().update(_color_dict)
# Remove xkcd colors from matplotlib's color mapping since we don't
# use them and they clutter the 'other' category in color galleries.
color_mapping: dict[str, str] = mcolors.get_named_colors_mapping()
xkcd_keys: list[str] = [
k for k in list(color_mapping.keys()) if k.startswith("xkcd:")
]
for key in xkcd_keys:
del color_mapping[key]
_load_colors()
# ============================================================================
# Color Conversion Functions
# ============================================================================
def _srgb_to_linear(c: float | np.ndarray) -> float | np.ndarray:
"""
Convert sRGB to linear RGB (gamma decoding).
Parameters
----------
c : float or array
sRGB value(s) in range [0, 1].
Returns
-------
float or array
Linear RGB value(s) in range [0, 1].
"""
c_arr: np.ndarray = np.asarray(c)
mask: np.ndarray = c_arr <= 0.04045
return np.where(mask, c_arr / 12.92, ((c_arr + 0.055) / 1.055) ** 2.4)
def _linear_to_srgb(c: float | np.ndarray) -> float | np.ndarray:
"""
Convert linear RGB to sRGB (gamma encoding).
Parameters
----------
c : float or array
Linear RGB value(s) in range [0, 1].
Returns
-------
float or array
sRGB value(s) in range [0, 1].
"""
c_arr: np.ndarray = np.asarray(c)
mask: np.ndarray = c_arr <= 0.0031308
return np.where(mask, 12.92 * c_arr, 1.055 * (c_arr ** (1.0 / 2.4)) - 0.055)
def _linear_srgb_to_oklab(
r: float, g: float, b: float
) -> tuple[float, float, float]:
"""
Convert linear sRGB to OKLab.
Based on the C++ implementation provided.
Parameters
----------
r, g, b : float
Linear RGB values in range [0, 1].
Returns
-------
tuple[float, float, float]
(L, a, b) OKLab coordinates.
"""
# Matrix multiplication to LMS
lms_l: float = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
lms_m: float = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
lms_s: float = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
# Cube root
lms_l_cbrt: float = np.cbrt(lms_l)
lms_m_cbrt: float = np.cbrt(lms_m)
lms_s_cbrt: float = np.cbrt(lms_s)
# Matrix multiplication to OKLab
L: float = (
0.2104542553 * lms_l_cbrt
+ 0.7936177850 * lms_m_cbrt
- 0.0040720468 * lms_s_cbrt
)
a: float = (
1.9779984951 * lms_l_cbrt
- 2.4285922050 * lms_m_cbrt
+ 0.4505937099 * lms_s_cbrt
)
b_val: float = (
0.0259040371 * lms_l_cbrt
+ 0.7827717662 * lms_m_cbrt
- 0.8086757660 * lms_s_cbrt
)
return (L, a, b_val)
def _oklab_to_linear_srgb(
L: float, a: float, b: float
) -> tuple[float, float, float]:
"""
Convert OKLab to linear sRGB.
Based on the C++ implementation provided.
Parameters
----------
L, a, b : float
OKLab coordinates.
Returns
-------
tuple[float, float, float]
(r, g, b) linear RGB values in range [0, 1].
"""
# Matrix multiplication to LMS
lms_l_cbrt: float = L + 0.3963377774 * a + 0.2158037573 * b
lms_m_cbrt: float = L - 0.1055613458 * a - 0.0638541728 * b
lms_s_cbrt: float = L - 0.0894841775 * a - 1.2914855480 * b
# Cube
lms_l: float = lms_l_cbrt * lms_l_cbrt * lms_l_cbrt
lms_m: float = lms_m_cbrt * lms_m_cbrt * lms_m_cbrt
lms_s: float = lms_s_cbrt * lms_s_cbrt * lms_s_cbrt
# Matrix multiplication to linear RGB
r: float = (
+4.0767416621 * lms_l - 3.3077115913 * lms_m + 0.2309699292 * lms_s
)
g: float = (
-1.2684380046 * lms_l + 2.6097574011 * lms_m - 0.3413193965 * lms_s
)
b_val: float = (
-0.0041960863 * lms_l - 0.7034186147 * lms_m + 1.7076147010 * lms_s
)
return (r, g, b_val)
def _oklab_to_oklch(L: float, a: float, b: float) -> tuple[float, float, float]:
"""
Convert OKLab to OKLCH.
Parameters
----------
L, a, b : float
OKLab coordinates.
Returns
-------
tuple[float, float, float]
(L, C, h) OKLCH coordinates, where h is in radians.
"""
C: float = math.sqrt(a * a + b * b)
h: float = math.atan2(b, a)
return (L, C, h)
def _oklch_to_oklab(L: float, C: float, h: float) -> tuple[float, float, float]:
"""
Convert OKLCH to OKLab.
Parameters
----------
L, C : float
Lightness and Chroma.
h : float
Hue in radians.
Returns
-------
tuple[float, float, float]
(L, a, b) OKLab coordinates.
"""
a: float = C * math.cos(h)
b: float = C * math.sin(h)
return (L, a, b)
def _parse_hex(hex_str: str) -> tuple[float, float, float]:
"""
Parse hex color string to RGB tuple.
Parameters
----------
hex_str : str
Hex color string (#RGB or #RRGGBB).
Returns
-------
tuple[float, float, float]
(r, g, b) in range [0, 1].
Raises
------
ValueError
If the hex string format is invalid.
"""
hex_clean: str = hex_str.strip().lstrip("#")
if len(hex_clean) == 3:
# #RGB format
r: float = int(hex_clean[0] * 2, 16) / 255.0
g: float = int(hex_clean[1] * 2, 16) / 255.0
b: float = int(hex_clean[2] * 2, 16) / 255.0
elif len(hex_clean) == 6:
# #RRGGBB format
r = int(hex_clean[0:2], 16) / 255.0
g = int(hex_clean[2:4], 16) / 255.0
b = int(hex_clean[4:6], 16) / 255.0
else:
raise ValueError(f"Invalid hex color format: {hex_str}")
return (r, g, b)
def _rgb_to_hex(r: float, g: float, b: float) -> str:
"""
Convert RGB to hex string.
Parameters
----------
r, g, b : float
RGB values in range [0, 1].
Returns
-------
str
Hex color string (#RRGGBB).
"""
# Clamp to [0, 1]
r_clamped: float = max(0.0, min(1.0, r))
g_clamped: float = max(0.0, min(1.0, g))
b_clamped: float = max(0.0, min(1.0, b))
# Convert to 0-255 and format as hex
r_int: int = int(round(r_clamped * 255))
g_int: int = int(round(g_clamped * 255))
b_int: int = int(round(b_clamped * 255))
return f"#{r_int:02x}{g_int:02x}{b_int:02x}"
# ============================================================================
# Color View Classes
# ============================================================================
[docs]
class OklabView:
"""
View class for OKLab color space access.
Provides attribute-based access to OKLab coordinates (L, a, b) with
support for reading, writing, unpacking, and indexing.
Parameters
----------
color : Color
The Color instance to view.
Examples
-----
>>> import dartwork_mpl as dm
>>> color = dm.oklab(0.7, 0.1, 0.2)
>>>
>>> # Attribute access
>>> L = color.oklab.L
>>> a = color.oklab.a
>>>
>>> # Unpacking
>>> L, a, b = color.oklab
>>>
>>> # Indexing
>>> a = color.oklab[1]
>>>
>>> # Writing
>>> color.oklab.L += 0.1
>>> color.oklab.a = 0.2
"""
def __init__(self, color: "Color") -> None:
"""
Initialize OklabView.
Parameters
----------
color : Color
The Color instance to view.
"""
self._color: Color = color
@property
def L(self) -> float:
"""
Lightness component.
Returns
-------
float
Lightness value.
"""
return self._color._L
@L.setter
def L(self, value: float) -> None:
"""
Set lightness component.
Parameters
----------
value : float
New lightness value.
"""
self._color._L = float(value)
@property
def a(self) -> float:
"""
Green-red component.
Returns
-------
float
Green-red value.
"""
return self._color._a
@a.setter
def a(self, value: float) -> None:
"""
Set green-red component.
Parameters
----------
value : float
New green-red value.
"""
self._color._a = float(value)
@property
def b(self) -> float:
"""
Blue-yellow component.
Returns
-------
float
Blue-yellow value.
"""
return self._color._b
@b.setter
def b(self, value: float) -> None:
"""
Set blue-yellow component.
Parameters
----------
value : float
New blue-yellow value.
"""
self._color._b = float(value)
def __getitem__(self, index: int) -> float:
"""
Get component by index.
Parameters
----------
index : int
Index (0=L, 1=a, 2=b).
Returns
-------
float
Component value.
Raises
------
IndexError
If index is out of range.
"""
if index == 0:
return self.L
elif index == 1:
return self.a
elif index == 2:
return self.b
else:
raise IndexError(f"Index {index} out of range for OklabView")
def __len__(self) -> int:
"""
Get number of components.
Returns
-------
int
Always 3 (L, a, b).
"""
return 3
def __iter__(self) -> "OklabViewIterator":
"""
Create iterator for unpacking.
Returns
-------
OklabViewIterator
Iterator over (L, a, b).
"""
return OklabViewIterator(self)
def __repr__(self) -> str:
"""
String representation.
Returns
-------
str
String representation showing (L, a, b).
"""
return f"OklabView(L={self.L:.4f}, a={self.a:.4f}, b={self.b:.4f})"
[docs]
class OklabViewIterator:
"""Iterator for OklabView unpacking."""
def __init__(self, view: OklabView) -> None:
"""
Initialize iterator.
Parameters
----------
view : OklabView
The view to iterate over.
"""
self._view: OklabView = view
self._index: int = 0
def __iter__(self) -> "OklabViewIterator":
"""Return self as iterator."""
return self
def __next__(self) -> float:
"""
Get next component.
Returns
-------
float
Next component value.
Raises
------
StopIteration
When all components have been returned.
"""
if self._index >= 3:
raise StopIteration
value: float = self._view[self._index]
self._index += 1
return value
[docs]
class OklchView:
"""
View class for OKLCH color space access.
Provides attribute-based access to OKLCH coordinates (L, C, h) with
support for reading, writing, unpacking, and indexing.
Parameters
----------
color : Color
The Color instance to view.
Examples
-----
>>> import dartwork_mpl as dm
>>> color = dm.oklch(0.7, 0.2, 120)
>>>
>>> # Attribute access
>>> L = color.oklch.L
>>> C = color.oklch.C
>>> h = color.oklch.h
>>>
>>> # Unpacking
>>> L, C, h = color.oklch
>>>
>>> # Indexing
>>> C = color.oklch[1]
>>>
>>> # Writing
>>> color.oklch.C *= 1.2
>>> color.oklch.h = 180
"""
def __init__(self, color: "Color") -> None:
"""
Initialize OklchView.
Parameters
----------
color : Color
The Color instance to view.
"""
self._color: Color = color
def _get_oklch(self) -> tuple[float, float, float]:
"""
Get current OKLCH values.
Returns
-------
tuple[float, float, float]
(L, C, h) where h is in degrees.
"""
return self._color.to_oklch()
def _update_oklab(self, L: float, C: float, h: float) -> None:
"""
Update Color from OKLCH values.
Parameters
----------
L : float
Lightness.
C : float
Chroma.
h : float
Hue in degrees.
"""
h_rad: float = math.radians(h)
_, a, b = _oklch_to_oklab(L, C, h_rad)
self._color._L = float(L)
self._color._a = float(a)
self._color._b = float(b)
@property
def L(self) -> float:
"""
Lightness component.
Returns
-------
float
Lightness value.
"""
L, _, _ = self._get_oklch()
return L
@L.setter
def L(self, value: float) -> None:
"""
Set lightness component.
Parameters
----------
value : float
New lightness value.
"""
_, C, h = self._get_oklch()
self._update_oklab(float(value), C, h)
@property
def C(self) -> float:
"""
Chroma component.
Returns
-------
float
Chroma value.
"""
_, C, _ = self._get_oklch()
return C
@C.setter
def C(self, value: float) -> None:
"""
Set chroma component.
Parameters
----------
value : float
New chroma value (must be >= 0).
"""
if value < 0:
raise ValueError("Chroma must be >= 0")
L, _, h = self._get_oklch()
self._update_oklab(L, float(value), h)
@property
def h(self) -> float:
"""
Hue component in degrees.
Returns
-------
float
Hue value in degrees [0, 360).
"""
_, _, h = self._get_oklch()
return h
@h.setter
def h(self, value: float) -> None:
"""
Set hue component.
Parameters
----------
value : float
New hue value in degrees.
"""
L, C, _ = self._get_oklch()
# Normalize to [0, 360)
h_normalized: float = float(value) % 360.0
self._update_oklab(L, C, h_normalized)
def __getitem__(self, index: int) -> float:
"""
Get component by index.
Parameters
----------
index : int
Index (0=L, 1=C, 2=h).
Returns
-------
float
Component value.
Raises
------
IndexError
If index is out of range.
"""
if index == 0:
return self.L
elif index == 1:
return self.C
elif index == 2:
return self.h
else:
raise IndexError(f"Index {index} out of range for OklchView")
def __len__(self) -> int:
"""
Get number of components.
Returns
-------
int
Always 3 (L, C, h).
"""
return 3
def __iter__(self) -> "OklchViewIterator":
"""
Create iterator for unpacking.
Returns
-------
OklchViewIterator
Iterator over (L, C, h).
"""
return OklchViewIterator(self)
def __repr__(self) -> str:
"""
String representation.
Returns
-------
str
String representation showing (L, C, h).
"""
return f"OklchView(L={self.L:.4f}, C={self.C:.4f}, h={self.h:.1f})"
[docs]
class OklchViewIterator:
"""Iterator for OklchView unpacking."""
def __init__(self, view: OklchView) -> None:
"""
Initialize iterator.
Parameters
----------
view : OklchView
The view to iterate over.
"""
self._view: OklchView = view
self._index: int = 0
def __iter__(self) -> "OklchViewIterator":
"""Return self as iterator."""
return self
def __next__(self) -> float:
"""
Get next component.
Returns
-------
float
Next component value.
Raises
------
StopIteration
When all components have been returned.
"""
if self._index >= 3:
raise StopIteration
value: float = self._view[self._index]
self._index += 1
return value
[docs]
class RgbView:
"""
View class for RGB color space access.
Provides attribute-based access to RGB coordinates (r, g, b) with
support for reading, writing, unpacking, and indexing.
Parameters
----------
color : Color
The Color instance to view.
Examples
-----
>>> import dartwork_mpl as dm
>>> color = dm.rgb(0.8, 0.2, 0.3)
>>>
>>> # Attribute access
>>> r = color.rgb.r
>>> g = color.rgb.g
>>>
>>> # Unpacking
>>> r, g, b = color.rgb
>>>
>>> # Indexing
>>> g = color.rgb[1]
>>>
>>> # Writing
>>> color.rgb.r = 0.9
>>> color.rgb.g += 0.1
"""
def __init__(self, color: "Color") -> None:
"""
Initialize RgbView.
Parameters
----------
color : Color
The Color instance to view.
"""
self._color: Color = color
def _get_rgb(self) -> tuple[float, float, float]:
"""
Get current RGB values.
Returns
-------
tuple[float, float, float]
(r, g, b) in range [0, 1].
"""
return self._color.to_rgb()
def _update_oklab(self, r: float, g: float, b: float) -> None:
"""
Update Color from RGB values.
Parameters
----------
r : float
Red component [0, 1].
g : float
Green component [0, 1].
b : float
Blue component [0, 1].
"""
# Clamp to [0, 1]
r_clamped: float = max(0.0, min(1.0, r))
g_clamped: float = max(0.0, min(1.0, g))
b_clamped: float = max(0.0, min(1.0, b))
# Convert sRGB to linear RGB
r_linear: float | np.ndarray = _srgb_to_linear(r_clamped)
g_linear: float | np.ndarray = _srgb_to_linear(g_clamped)
b_linear: float | np.ndarray = _srgb_to_linear(b_clamped)
# Convert to OKLab
L: float
a: float
b_val: float
L, a, b_val = _linear_srgb_to_oklab(
float(r_linear), float(g_linear), float(b_linear)
)
self._color._L = float(L)
self._color._a = float(a)
self._color._b = float(b_val)
@property
def r(self) -> float:
"""
Red component.
Returns
-------
float
Red value in range [0, 1].
"""
r, _, _ = self._get_rgb()
return r
@r.setter
def r(self, value: float) -> None:
"""
Set red component.
Parameters
----------
value : float
New red value (will be clamped to [0, 1]).
"""
_, g, b = self._get_rgb()
self._update_oklab(float(value), g, b)
@property
def g(self) -> float:
"""
Green component.
Returns
-------
float
Green value in range [0, 1].
"""
_, g, _ = self._get_rgb()
return g
@g.setter
def g(self, value: float) -> None:
"""
Set green component.
Parameters
----------
value : float
New green value (will be clamped to [0, 1]).
"""
r, _, b = self._get_rgb()
self._update_oklab(r, float(value), b)
@property
def b(self) -> float:
"""
Blue component.
Returns
-------
float
Blue value in range [0, 1].
"""
_, _, b = self._get_rgb()
return b
@b.setter
def b(self, value: float) -> None:
"""
Set blue component.
Parameters
----------
value : float
New blue value (will be clamped to [0, 1]).
"""
r, g, _ = self._get_rgb()
self._update_oklab(r, g, float(value))
def __getitem__(self, index: int) -> float:
"""
Get component by index.
Parameters
----------
index : int
Index (0=r, 1=g, 2=b).
Returns
-------
float
Component value.
Raises
------
IndexError
If index is out of range.
"""
if index == 0:
return self.r
elif index == 1:
return self.g
elif index == 2:
return self.b
else:
raise IndexError(f"Index {index} out of range for RgbView")
def __len__(self) -> int:
"""
Get number of components.
Returns
-------
int
Always 3 (r, g, b).
"""
return 3
def __iter__(self) -> "RgbViewIterator":
"""
Create iterator for unpacking.
Returns
-------
RgbViewIterator
Iterator over (r, g, b).
"""
return RgbViewIterator(self)
def __repr__(self) -> str:
"""
String representation.
Returns
-------
str
String representation showing (r, g, b).
"""
return f"RgbView(r={self.r:.4f}, g={self.g:.4f}, b={self.b:.4f})"
[docs]
class RgbViewIterator:
"""Iterator for RgbView unpacking."""
def __init__(self, view: RgbView) -> None:
"""
Initialize iterator.
Parameters
----------
view : RgbView
The view to iterate over.
"""
self._view: RgbView = view
self._index: int = 0
def __iter__(self) -> "RgbViewIterator":
"""Return self as iterator."""
return self
def __next__(self) -> float:
"""
Get next component.
Returns
-------
float
Next component value.
Raises
------
StopIteration
When all components have been returned.
"""
if self._index >= 3:
raise StopIteration
value: float = self._view[self._index]
self._index += 1
return value
# ============================================================================
# Color Class
# ============================================================================
[docs]
class Color:
"""
A color class that supports OKLab, OKLCH, RGB, and hex color spaces.
Colors are stored internally as OKLab coordinates for efficient
conversion. Use classmethods to create Color instances:
from_oklab(), from_oklch(), from_rgb(), from_hex().
"""
def __init__(self, L: float, a: float, b: float) -> None:
"""
Private constructor. Use classmethods to create Color instances.
Parameters
----------
L, a, b : float
OKLab coordinates.
"""
self._L: float = float(L)
self._a: float = float(a)
self._b: float = float(b)
@property
def oklab(self) -> OklabView:
"""
Get OKLab view of the color.
Returns
-------
OklabView
View object for OKLab color space access.
Examples
-----
>>> import dartwork_mpl as dm
>>> color = dm.oklab(0.7, 0.1, 0.2)
>>>
>>> # Attribute access
>>> L = color.oklab.L
>>> a = color.oklab.a
>>>
>>> # Unpacking
>>> L, a, b = color.oklab
>>>
>>> # Writing
>>> color.oklab.L += 0.1
"""
return OklabView(self)
@property
def oklch(self) -> OklchView:
"""
Get OKLCH view of the color.
Returns
-------
OklchView
View object for OKLCH color space access.
Examples
-----
>>> import dartwork_mpl as dm
>>> color = dm.oklch(0.7, 0.2, 120)
>>>
>>> # Attribute access
>>> L = color.oklch.L
>>> C = color.oklch.C
>>> h = color.oklch.h
>>>
>>> # Unpacking
>>> L, C, h = color.oklch
>>>
>>> # Writing
>>> color.oklch.C *= 1.2
"""
return OklchView(self)
@property
def rgb(self) -> RgbView:
"""
Get RGB view of the color.
Returns
-------
RgbView
View object for RGB color space access.
Examples
-----
>>> import dartwork_mpl as dm
>>> color = dm.rgb(0.8, 0.2, 0.3)
>>>
>>> # Attribute access
>>> r = color.rgb.r
>>> g = color.rgb.g
>>>
>>> # Unpacking
>>> r, g, b = color.rgb
>>>
>>> # Writing
>>> color.rgb.r = 0.9
"""
return RgbView(self)
[docs]
@classmethod
def from_oklab(cls, L: float, a: float, b: float) -> "Color":
"""
Create a Color from OKLab coordinates.
Parameters
----------
L, a, b : float
OKLab coordinates (L typically in [0, 1]).
Returns
-------
Color
Color instance.
"""
return cls(L, a, b)
[docs]
@classmethod
def from_oklch(cls, L: float, C: float, h: float) -> "Color":
"""
Create a Color from OKLCH coordinates.
Parameters
----------
L, C : float
Lightness and Chroma (L typically in [0, 1], C >= 0).
h : float
Hue in degrees [0, 360).
Returns
-------
Color
Color instance.
"""
# Convert degrees to radians for internal calculation
h_rad: float = math.radians(h)
_, a, b = _oklch_to_oklab(L, C, h_rad)
return cls(L, a, b)
[docs]
@classmethod
def from_rgb(cls, r: float, g: float, b: float) -> "Color":
"""
Create a Color from RGB values.
Automatically detects if values are in [0, 1] or [0, 255] range.
If all values are <= 1.0, treats as [0, 1]. Otherwise, treats as
[0, 255].
Parameters
----------
r, g, b : float
RGB values (auto-detected range).
Returns
-------
Color
Color instance.
"""
# Auto-detect range
r_norm: float = r
g_norm: float = g
b_norm: float = b
if r > 1.0 or g > 1.0 or b > 1.0:
# Assume 0-255 range
r_norm = r / 255.0
g_norm = g / 255.0
b_norm = b / 255.0
# Convert sRGB to linear RGB
r_linear: float | np.ndarray = _srgb_to_linear(r_norm)
g_linear: float | np.ndarray = _srgb_to_linear(g_norm)
b_linear: float | np.ndarray = _srgb_to_linear(b_norm)
# Convert to OKLab
L: float
a: float
b_val: float
L, a, b_val = _linear_srgb_to_oklab(
float(r_linear), float(g_linear), float(b_linear)
)
return cls(L, a, b_val)
[docs]
@classmethod
def from_hex(cls, hex_str: str) -> "Color":
"""
Create a Color from hex color string.
Parameters
----------
hex_str : str
Hex color string (#RGB or #RRGGBB).
Returns
-------
Color
Color instance.
"""
r: float
g: float
b: float
r, g, b = _parse_hex(hex_str)
return cls.from_rgb(r, g, b)
[docs]
@classmethod
def from_name(cls, name: str) -> "Color":
"""
Create a Color from matplotlib color name.
Supports all matplotlib color names including:
- Basic colors: 'red', 'blue', 'green', etc.
- Named colors: 'aliceblue', 'antiquewhite', etc.
- Custom dartwork-mpl colors: 'oc.red5', 'tw.blue500', etc.
Parameters
----------
name : str
Matplotlib color name (e.g., 'red', 'oc.blue5',
'tw.blue500').
Returns
-------
Color
Color instance.
Raises
------
ValueError
If the color name is not recognized by matplotlib.
"""
try:
# Use matplotlib's to_rgb to convert color name to RGB
r: float
g: float
b: float
r, g, b = mcolors.to_rgb(name)
return cls.from_rgb(r, g, b)
except ValueError as e:
raise ValueError(f"Invalid color name: {name}. {e!s}") from e
[docs]
def to_oklab(self) -> tuple[float, float, float]:
"""
Convert to OKLab coordinates.
Returns
-------
tuple[float, float, float]
(L, a, b) OKLab coordinates.
"""
return (self._L, self._a, self._b)
[docs]
def to_oklch(self) -> tuple[float, float, float]:
"""
Convert to OKLCH coordinates.
Returns
-------
tuple[float, float, float]
(L, C, h) OKLCH coordinates, where h is in degrees [0, 360).
"""
L: float
C: float
h_rad: float
L, C, h_rad = _oklab_to_oklch(self._L, self._a, self._b)
# Convert radians to degrees
h_deg: float = math.degrees(h_rad)
# Normalize to [0, 360)
h_deg = h_deg % 360.0
return (L, C, h_deg)
[docs]
def to_rgb(self) -> tuple[float, float, float]:
"""
Convert to RGB values.
Returns
-------
tuple[float, float, float]
(r, g, b) RGB values in range [0, 1].
"""
# Convert OKLab to linear RGB
r_linear: float
g_linear: float
b_linear: float
r_linear, g_linear, b_linear = _oklab_to_linear_srgb(
self._L, self._a, self._b
)
# Clamp to valid range
r_linear_clamped: float = max(0.0, min(1.0, r_linear))
g_linear_clamped: float = max(0.0, min(1.0, g_linear))
b_linear_clamped: float = max(0.0, min(1.0, b_linear))
# Convert linear RGB to sRGB
r: float | np.ndarray = _linear_to_srgb(r_linear_clamped)
g: float | np.ndarray = _linear_to_srgb(g_linear_clamped)
b: float | np.ndarray = _linear_to_srgb(b_linear_clamped)
# Convert numpy scalars/arrays to Python floats
r_float: float = float(np.asarray(r).item())
g_float: float = float(np.asarray(g).item())
b_float: float = float(np.asarray(b).item())
return (r_float, g_float, b_float)
[docs]
def to_hex(self) -> str:
"""
Convert to hex color string.
Returns
-------
str
Hex color string (#RRGGBB).
"""
r: float
g: float
b: float
r, g, b = self.to_rgb()
return _rgb_to_hex(r, g, b)
[docs]
def copy(self) -> "Color":
"""
Create a copy of the Color object.
Returns
-------
Color
A new Color instance with the same OKLab coordinates.
Examples
-----
>>> import dartwork_mpl as dm
>>> color = dm.oklab(0.7, 0.1, 0.2)
>>> new_color = color.copy()
>>>
>>> # Modify the copy without affecting the original
>>> new_color.oklab.L += 0.1
>>> print(color.oklab.L) # 0.7 (unchanged)
>>> print(new_color.oklab.L) # 0.8 (modified)
"""
return Color(self._L, self._a, self._b)
def __repr__(self) -> str:
"""
String representation of Color.
Returns
-------
str
String representation showing OKLab coordinates.
"""
return f"Color(oklab=({self._L:.4f}, {self._a:.4f}, {self._b:.4f}))"
# ============================================================================
# Color Space Interpolation
# ============================================================================
[docs]
def cspace(
start_color: Color | str,
end_color: Color | str,
n: int,
space: str = "oklch",
) -> list[Color]:
"""
Generate a list of colors by interpolating between two colors.
Inspired by np.linspace, but for colors.
Parameters
----------
start_color : Color or str
Starting color (Color instance or hex string).
end_color : Color or str
Ending color (Color instance or hex string).
n : int
Number of colors to generate (including start and end).
space : str, optional
Color space for interpolation: 'oklch' (default), 'oklab', or
'rgb'. Default is 'oklch'.
Returns
-------
list[Color]
List of interpolated Color objects.
Raises
------
TypeError
If start_color or end_color is not a Color instance or hex
string.
ValueError
If space is not one of the supported color spaces.
"""
# Convert input colors to Color objects if needed
start_color_obj: Color
if isinstance(start_color, str):
start_color_obj = Color.from_hex(start_color)
else:
start_color_obj = start_color
end_color_obj: Color
if isinstance(end_color, str):
end_color_obj = Color.from_hex(end_color)
else:
end_color_obj = end_color
if not isinstance(start_color_obj, Color):
raise TypeError(
f"start_color must be Color instance or hex string, got {type(start_color)}"
)
if not isinstance(end_color_obj, Color):
raise TypeError(
f"end_color must be Color instance or hex string, got {type(end_color)}"
)
# Convert to target color space
if space == "oklch":
start_L: float
start_C: float
start_h: float
start_L, start_C, start_h = start_color_obj.to_oklch()
# h is in degrees
end_L: float
end_C: float
end_h: float
end_L, end_C, end_h = end_color_obj.to_oklch()
# h is in degrees
# Handle hue wrapping (shortest path in degrees)
h_diff: float = end_h - start_h
# Normalize to [-180, 180] range for shortest path
if h_diff > 180:
end_h -= 360
elif h_diff < -180:
end_h += 360
# Interpolate
L_values: np.ndarray = np.linspace(start_L, end_L, n)
C_values: np.ndarray = np.linspace(start_C, end_C, n)
h_values: np.ndarray = np.linspace(start_h, end_h, n)
# Normalize hue values to [0, 360) before creating Color objects
h_values = h_values % 360.0
# Convert back to Color objects
colors: list[Color] = [
Color.from_oklch(L, C, h)
for L, C, h in zip(L_values, C_values, h_values, strict=False)
]
elif space == "oklab":
start_L, start_a, start_b = start_color_obj.to_oklab()
end_L, end_a, end_b = end_color_obj.to_oklab()
# Interpolate
L_values = np.linspace(start_L, end_L, n)
a_values: np.ndarray = np.linspace(start_a, end_a, n)
b_values: np.ndarray = np.linspace(start_b, end_b, n)
# Convert back to Color objects
colors = [
Color.from_oklab(L, a, b)
for L, a, b in zip(L_values, a_values, b_values, strict=False)
]
elif space == "rgb":
start_r: float
start_g: float
start_b: float
start_r, start_g, start_b = start_color_obj.to_rgb()
end_r: float
end_g: float
end_b: float
end_r, end_g, end_b = end_color_obj.to_rgb()
# Interpolate
r_values: np.ndarray = np.linspace(start_r, end_r, n)
g_values: np.ndarray = np.linspace(start_g, end_g, n)
b_values = np.linspace(start_b, end_b, n)
# Convert back to Color objects
colors = [
Color.from_rgb(r, g, b)
for r, g, b in zip(r_values, g_values, b_values, strict=False)
]
else:
raise ValueError(
f"Unsupported color space: {space}. Must be 'oklch', 'oklab', or 'rgb'"
)
return colors
# ============================================================================
# Wrapper Functions
# ============================================================================
[docs]
def oklab(L: float, a: float, b: float) -> Color:
"""
Convenience function to create a Color from OKLab coordinates.
Parameters
----------
L, a, b : float
OKLab coordinates.
Returns
-------
Color
Color instance.
"""
return Color.from_oklab(L, a, b)
[docs]
def oklch(L: float, C: float, h: float) -> Color:
"""
Convenience function to create a Color from OKLCH coordinates.
Parameters
----------
L, C : float
Lightness and Chroma.
h : float
Hue in degrees [0, 360).
Returns
-------
Color
Color instance.
"""
return Color.from_oklch(L, C, h)
[docs]
def rgb(r: float, g: float, b: float) -> Color:
"""
Convenience function to create a Color from RGB values.
Parameters
----------
r, g, b : float
RGB values (auto-detected range: 0-1 or 0-255).
Returns
-------
Color
Color instance.
"""
return Color.from_rgb(r, g, b)
[docs]
def hex(hex_str: str) -> Color:
"""
Convenience function to create a Color from hex color string.
Parameters
----------
hex_str : str
Hex color string (#RGB or #RRGGBB).
Returns
-------
Color
Color instance.
"""
return Color.from_hex(hex_str)
[docs]
def named(color_name: str) -> Color:
"""
Convenience function to create a Color from matplotlib color name.
Parameters
----------
color_name : str
Matplotlib color name (e.g., 'red', 'oc.blue5',
'tw.blue500').
Returns
-------
Color
Color instance.
"""
return Color.from_name(color_name)