Move color parsing code to C for performance

This commit is contained in:
Kovid Goyal 2025-12-30 21:43:57 +05:30
parent a4d88beddb
commit 7c13c04c84
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
4 changed files with 318 additions and 437 deletions

View file

@ -8,6 +8,9 @@
#include "state.h"
#include <structmember.h>
#include "colors.h"
#include <locale.h>
float strtof_l(const char *restrict nptr, char **restrict endptr, locale_t locale);
locale_t c_locale;
static uint32_t FG_BG_256[256] = {
@ -776,8 +779,311 @@ contrast(Color* self, PyObject *o) {
return PyFloat_FromDouble(rgb_contrast(self->color, other->color));
}
static char
hexchar_to_int(char c) {
if ('0' <= c && c <= '9') return c - '0';
if ('a' <= c && c <= 'f') return c - 'a' + 10;
if ('A' <= c && c <= 'F') return c - 'A' + 10;
return -1;
}
static bool
parse_base16_uchar(const char *hex, unsigned char *out) {
const char hi = hexchar_to_int(hex[0]);
const char lo = hexchar_to_int(hex[1]);
if (hi < 0 || lo < 0) return false;
*out = (unsigned char)((hi << 4) | lo);
return true;
}
static bool
parse_double(const char *src, double *out) {
char *endptr;
errno = 0;
*out = strtod_l(src, &endptr, c_locale);
return endptr != src && *endptr == 0 && errno == 0;
}
static bool
parse_single_color(const char *c, size_t len, unsigned char *out) {
char buf[2];
if (len == 1) { buf[0] = c[0]; buf[1] = c[0]; c = buf; }
return parse_base16_uchar(c, out);
}
static PyObject*
parse_sharp(const char *spec, size_t len) {
unsigned char r, g, b;
switch(len) {
case 3:
if (!parse_single_color(spec, 1, &r) || !parse_single_color(spec + 1, 1, &g) || !parse_single_color(spec + 2, 1, &b)) Py_RETURN_NONE;
break;
case 6: case 9: case 12:
if (!parse_single_color(spec, 2, &r) || !parse_single_color(spec + len/3, 2, &g) || !parse_single_color(spec + 2 * len / 3, 2, &b)) Py_RETURN_NONE;
break;
default:
Py_RETURN_NONE;
}
return (PyObject*)alloc_color(r, g, b, 0);
}
static PyObject*
parse_rgb(const char *spec, size_t len) {
char buf[32];
if (len >= sizeof(buf)) Py_RETURN_NONE;
memcpy(buf, spec, len); buf[len] = 0;
unsigned char r, g, b; char *tok;
#define p(buf, out) if (!(tok = strtok(buf, "/")) || !parse_single_color(tok, strlen(tok), &out)) Py_RETURN_NONE;
p(buf, r); p(NULL, g); p(NULL, b);
#undef p
return (PyObject*)alloc_color(r, g, b, 0);
}
static unsigned char as8bit(double f) { return (unsigned char)((MAX(0., MIN(f, 1.))) * 255.); }
static bool
parse_single_intensity(const char *s, unsigned char *out) {
double f; if (!parse_double(s, &f)) return false;
*out = as8bit(f);
return true;
}
static PyObject*
parse_rgbi(const char *spec, size_t len) {
char buf[256];
if (len >= sizeof(buf)) Py_RETURN_NONE;
memcpy(buf, spec, len); buf[len] = 0;
unsigned char r, g, b; char *tok;
#define p(buf, out) if (!(tok = strtok(buf, "/")) || !parse_single_intensity(tok, &out)) Py_RETURN_NONE;
p(buf, r); p(NULL, g); p(NULL, b);
#undef p
return (PyObject*)alloc_color(r, g, b, 0);
}
static bool
parse_double_intensity(char *s, double *out, double percentage_divider) {
size_t l = strlen(s);
if (l == 0) return false;
double divisor = 1;
if (s[l-1] == '%') { s[l-1] = 0; divisor = percentage_divider; }
if (!parse_double(s, out)) return false;
*out /= divisor;
return true;
}
static double clamp(const double f) { return MAX(0, MIN(f, 1)); }
static double
linear_to_srgb(double c) { return c <= 0.0031308 ? c * 12.92 : (1.055 * pow(c, (1 / 2.4)) - 0.055); }
static double degrees_to_radians(double degrees) { return degrees * (M_PI / 180); }
static double radians_to_degrees(double radians) { return 180 * radians / M_PI; }
static void
oklch_to_srgb(double l, double c, double h, double *r, double *g, double *b) {
// Convert OKLCH to OKLab
const double h_rad = degrees_to_radians(h);
const double a = c * cos(h_rad);
const double lb = c * sin(h_rad);
// Convert OKLab to Linear sRGB
// Using the OKLab to Linear sRGB transformation
const double l_ = l + 0.3963377774 * a + 0.2158037573 * lb;
const double m_ = l - 0.1055613458 * a - 0.0638541728 * lb;
const double s_ = l - 0.0894841775 * a - 1.2914855480 * lb;
const double l_lin = l_ * l_ * l_;
const double m_lin = m_ * m_ * m_;
const double s_lin = s_ * s_ * s_;
const double r_lin = +4.0767416621 * l_lin - 3.3077115913 * m_lin + 0.2309699292 * s_lin;
const double g_lin = -1.2684380046 * l_lin + 2.6097574011 * m_lin - 0.3413193965 * s_lin;
const double b_lin = -0.0041960863 * l_lin - 0.7034186147 * m_lin + 1.7076147010 * s_lin;
*r = linear_to_srgb(clamp(r_lin)); *g = linear_to_srgb(clamp(g_lin)); *b = linear_to_srgb(clamp(b_lin));
}
static double srgb_to_linear(double c) { return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); }
static void
srgb_to_oklab(double r, double g, double b, double *l, double *a, double *lb) {
// Convert sRGB to linear sRGB
const double r_lin = srgb_to_linear(r);
const double g_lin = srgb_to_linear(g);
const double b_lin = srgb_to_linear(b);
// Convert Linear sRGB to OKLab (inverse of oklch_to_srgb)
const double l_lin = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin;
const double m_lin = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin;
const double s_lin = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin;
const double l_ = l_lin != 0 ? copysign(pow(fabs(l_lin), 1./3.), l_lin) : 0;
const double m_ = m_lin != 0 ? copysign(pow(fabs(m_lin), 1./3.), m_lin) : 0;
const double s_ = s_lin != 0 ? copysign(pow(fabs(s_lin), 1./3.), s_lin) : 0;
// OKLab coordinates
*l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
*a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
*lb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
}
static double
distance(double x_l, double x_a, double x_b, double y_l, double y_a, double y_b) {
return sqrt((x_l - y_l)*(x_l - y_l) + (x_a - y_a)*(x_a - y_a) + (x_b - y_b)*(x_b - y_b));
}
static void
oklch_to_srgb_gamut_map(double l, double c, double h, double *r, double *g, double *b) {
// Edge cases: pure black or white don't need gamut mapping
if (!isfinite(l) || !isfinite(c) || !isfinite(h) || l <= 0) { *r = 0; *g = 0; *b = 0; return; }
if (l >= 1) { *r = 1; *g = 1; *b = 1; return; }
// Constants from CSS Color Module Level 4
static const double JND = 0.02; // Just Noticeable Difference threshold (2% in deltaEOK)
static const double MIN_CONVERGENCE = 0.0001; // Binary search precision (0.01% chroma)
static const double EPSILON = 0.00001; // Small value for doubleing point comparisons
// If chroma is very small, color is essentially achromatic
if (c < EPSILON) { *r = linear_to_srgb(l); *g = *r; *b = *r; return; }
// Try the original color first
oklch_to_srgb(l, c, h, r, g, b);
#define in_gamut(r,g,b) (0. <= r && r <= 1. && 0. <= g && g <= 1. && 0. <= b && b <= 1.)
if (in_gamut(*r,*g,*b)) return;
// Binary search for maximum in-gamut chroma
double low_chroma = 0, high_chroma = c, r_test, g_test, b_test, r_clipped, g_clipped, b_clipped;
// Convert original color to OKLab for deltaE calculations
while ((high_chroma - low_chroma) > MIN_CONVERGENCE) {
double mid_chroma = (high_chroma + low_chroma) * 0.5;
// Try this chroma value
oklch_to_srgb(l, mid_chroma, h, &r_test, &g_test, &b_test);
// Check if in gamut (before clipping)
if (in_gamut(r_test, g_test, b_test)) {
// In gamut - try higher chroma
low_chroma = mid_chroma;
} else {
// Out of gamut - clip and check deltaE
r_clipped = clamp(r_test); g_clipped = clamp(g_test); b_clipped = clamp(b_test);
// Convert both to OKLab for comparison
double l_test, a_test, lb_test, l_clipped, a_clipped, lb_clipped;
srgb_to_oklab(r_test, g_test, b_test, &l_test, &a_test, &lb_test);
srgb_to_oklab(r_clipped, g_clipped, b_clipped, &l_clipped, &a_clipped, &lb_clipped);
// Calculate perceptual difference
double de = distance(l_test, a_test, lb_test, l_clipped, a_clipped, lb_clipped);
if (de < JND) {
// Difference is imperceptible - accept this chroma
low_chroma = mid_chroma;
} else {
// Difference is noticeable - reduce chroma more
high_chroma = mid_chroma;
}
}
}
// Use the final chroma value and clip to ensure in-gamut
oklch_to_srgb(l, low_chroma, h, r, g, b);
*r = clamp(*r); *g = clamp(*g); *b = clamp(*b);
#undef in_gamut
}
static double
f_inv(double t) {
static const double delta = 6. / 29.;
return t > delta ? t*t*t : 3 * delta * delta * (t - 4. / 29.);
}
static void
lab_to_oklch(double l, double a, double b, double *okl, double *c, double *h) {
const double y = (l + 16.) / 116.;
const double x = a / 500. + y;
const double z = y - b / 200.;
const double x_val = 0.95047 * f_inv(x);
const double y_val = f_inv(y);
const double z_val = 1.08883 * f_inv(z);
// XYZ to Linear sRGB (don't clip here to preserve out-of-gamut info)
const double r_lin = +3.2404542 * x_val - 1.5371385 * y_val - 0.4985314 * z_val;
const double g_lin = -0.9692660 * x_val + 1.8760108 * y_val + 0.0415560 * z_val;
const double b_lin = +0.0556434 * x_val - 0.2040259 * y_val + 1.0572252 * z_val;
// Convert linear sRGB to sRGB gamma
const double r_srgb = r_lin >= 0 ? linear_to_srgb(r_lin) : 0;
const double g_srgb = g_lin >= 0 ? linear_to_srgb(g_lin) : 0;
const double b_srgb = b_lin >= 0 ? linear_to_srgb(b_lin) : 0;
// Convert to OKLab
double a_ok, b_ok;
srgb_to_oklab(r_srgb, g_srgb, b_srgb, okl, &a_ok, &b_ok);
// Convert OKLab to OKLCH
*c = sqrt(a_ok * a_ok + b_ok * b_ok);
*h = fmod(radians_to_degrees(atan2(b_ok, a_ok)), 360.f);
}
static PyObject*
parse_oklch(const char *spec, size_t len) {
if (len < 10 || spec[--len] != ')') Py_RETURN_NONE;
if (spec[0] != 'k' || spec[1] != 'l' || spec[2] != 'c' || spec[3] != 'h' || spec[4] != '(') Py_RETURN_NONE;
spec += 5; len -= 5;
char buf[256]; if (len >= sizeof(buf)) Py_RETURN_NONE;
memcpy(buf, spec, len); buf[len] = 0;
double l, c, h; char *tok;
#define p(buf, out) if (!(tok = strtok(buf, " ,")) || !parse_double_intensity(tok, &out, 100)) Py_RETURN_NONE;
p(buf, l); p(NULL, c); p(NULL, h);
#undef p
// Clamp to reasonable ranges
l = clamp(l);
c = MAX(0.f, c); // Chroma is unbounded but we don't clamp high end
h = fmod(h, 360); // Wrap hue to 0-360
double r, g, b;
oklch_to_srgb_gamut_map(l, c, h, &r, &g, &b);
return (PyObject*)alloc_color(as8bit(r), as8bit(g), as8bit(b), 0);
}
static PyObject*
parse_lab(const char *spec, size_t len) {
if (len < 8 || spec[--len] != ')') Py_RETURN_NONE;
if (spec[0] != 'a' || spec[1] != 'b' || spec[2] != '(') Py_RETURN_NONE;
spec += 3; len -= 3;
char buf[256]; if (len >= sizeof(buf)) Py_RETURN_NONE;
memcpy(buf, spec, len); buf[len] = 0;
double l, a, b; char *tok;
#define p(buf, out) if (!(tok = strtok(buf, " ,")) || !parse_double_intensity(tok, &out, 1)) Py_RETURN_NONE;
p(buf, l); p(NULL, a); p(NULL, b);
#undef p
// Clamp to reasonable ranges
double okl, c, h, r, g, bb;
lab_to_oklch(MAX(0., MIN(l, 100.)), a, b, &okl, &c, &h);
oklch_to_srgb_gamut_map(okl, c, h, &r, &g, &bb);
return (PyObject*)alloc_color(as8bit(r), as8bit(g), as8bit(bb), 0);
}
static PyObject*
parse_color(PyTypeObject *type UNUSED, PyObject *pspec) {
if (!PyUnicode_Check(pspec)) { PyErr_SetString(PyExc_TypeError, "spec must be a string"); return NULL; }
Py_ssize_t len;
const char *spec = PyUnicode_AsUTF8AndSize(pspec, &len);
if (len < 4) Py_RETURN_NONE;
switch (spec[0]) {
case '#': return parse_sharp(spec + 1, len - 1);
case 'r':
if (spec[1] != 'g' || spec[2] != 'b' || len < 6) Py_RETURN_NONE;
switch(spec[3]) {
case ':': return parse_rgb(spec + 4, len - 4);
case 'i':
if (spec[4] == 'i' && spec[5] == ':') return parse_rgbi(spec + 5, len - 5);
}
Py_RETURN_NONE;
case 'o': return parse_oklch(spec + 1, len - 1);
case 'l': return parse_lab(spec + 1, len - 1);
}
Py_RETURN_NONE;
}
static PyMethodDef color_methods[] = {
METHODB(contrast, METH_O),
METHODB(parse_color, METH_O | METH_CLASS),
{NULL} /* Sentinel */
};
@ -816,6 +1122,7 @@ static PyMethodDef module_methods[] = {
};
int init_ColorProfile(PyObject *module) {\
c_locale = newlocale(LC_NUMERIC_MASK, "C", (locale_t)0);
if (PyType_Ready(&ColorProfile_Type) < 0) return 0;
if (PyModule_AddObject(module, "ColorProfile", (PyObject *)&ColorProfile_Type) != 0) return 0;
Py_INCREF(&ColorProfile_Type);

View file

@ -740,6 +740,9 @@ def patch_global_colors(spec: Dict[str, Optional[int]], configured: bool) -> Non
class Color:
@classmethod
def parse_color(cls, spec: str) -> Color | None: ...
@property
def rgb(self) -> int:
pass
@ -1071,6 +1074,7 @@ def mark_tab_bar_dirty(os_window_id: int, should_be_shown: bool) -> None:
def is_tab_bar_visible(os_window_id: int) -> bool: ...
def detach_window(os_window_id: int, tab_id: int, window_id: int) -> None:
pass

436
kitty/rgb.py generated
View file

@ -1,10 +1,6 @@
#!/usr/bin/env python
# License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
import math
import re
from contextlib import suppress
from .fast_data_types import Color
@ -20,42 +16,6 @@ def alpha_blend(top_color: Color, bottom_color: Color, alpha: float) -> Color:
)
def parse_single_color(c: str) -> int:
if len(c) == 1:
c += c
return int(c[:2], 16)
def parse_sharp(spec: str) -> Color | None:
if len(spec) in (3, 6, 9, 12):
part_len = len(spec) // 3
colors = re.findall(fr'[a-fA-F0-9]{{{part_len}}}', spec)
return Color(*map(parse_single_color, colors))
return None
def parse_rgb(spec: str) -> Color | None:
colors = spec.split('/')
if len(colors) == 3:
return Color(*map(parse_single_color, colors))
return None
def parse_single_intensity(x: str) -> int:
val = float(x)
# Validate for NaN and infinity
if not math.isfinite(val):
return 0
return int(max(0, min(abs(val), 1)) * 255)
def parse_rgbi(spec: str) -> Color | None:
colors = spec.split('/')
if len(colors) == 3:
return Color(*map(parse_single_intensity, colors))
return None
def color_from_int(x: int) -> Color:
return Color((x >> 16) & 255, (x >> 8) & 255, x & 255)
@ -72,381 +32,6 @@ def color_as_sgr(x: Color) -> str:
return x.as_sgr
# Color space conversion functions
def srgb_to_linear(c: float) -> float:
"""Convert sRGB component (0-1) to linear light"""
if c <= 0.04045:
return c / 12.92
return math.pow((c + 0.055) / 1.055, 2.4)
def linear_to_srgb(c: float) -> float:
"""Convert linear light component (0-1) to sRGB"""
if c <= 0.0031308:
return c * 12.92
return 1.055 * math.pow(c, (1 / 2.4)) - 0.055
def oklch_to_srgb(l: float, c: float, h: float) -> tuple[float, float, float]: # noqa: E741
"""Convert OKLCH to sRGB RGB (0-1)
OKLCH is a perceptual color space based on OKLab.
L: Lightness (0-1, typically 0-1)
C: Chroma (0-0.4, unbounded but practical max ~0.4)
H: Hue (0-360 degrees)
Conversion path: OKLCH -> OKLab -> Linear sRGB -> sRGB
"""
# Convert OKLCH to OKLab
h_rad = math.radians(h)
a = c * math.cos(h_rad)
b = c * math.sin(h_rad)
# Convert OKLab to Linear sRGB
# Using the OKLab to Linear sRGB transformation
l_ = l + 0.3963377774 * a + 0.2158037573 * b
m_ = l - 0.1055613458 * a - 0.0638541728 * b
s_ = l - 0.0894841775 * a - 1.2914855480 * b
l_lin = l_ * l_ * l_
m_lin = m_ * m_ * m_
s_lin = s_ * s_ * s_
r_lin = +4.0767416621 * l_lin - 3.3077115913 * m_lin + 0.2309699292 * s_lin
g_lin = -1.2684380046 * l_lin + 2.6097574011 * m_lin - 0.3413193965 * s_lin
b_lin = -0.0041960863 * l_lin - 0.7034186147 * m_lin + 1.7076147010 * s_lin
# Clip to valid range
r_lin = max(0.0, min(1.0, r_lin))
g_lin = max(0.0, min(1.0, g_lin))
b_lin = max(0.0, min(1.0, b_lin))
# Convert linear sRGB to sRGB
return (linear_to_srgb(r_lin), linear_to_srgb(g_lin), linear_to_srgb(b_lin))
def srgb_to_oklab(r: float, g: float, b: float) -> tuple[float, float, float]:
"""Convert sRGB RGB (0-1) to OKLab
Reverse conversion from sRGB to OKLab.
Needed for deltaE calculations in gamut mapping.
Conversion path: sRGB -> Linear sRGB -> OKLab
"""
# Convert sRGB to linear sRGB
r_lin = srgb_to_linear(r)
g_lin = srgb_to_linear(g)
b_lin = srgb_to_linear(b)
# Convert Linear sRGB to OKLab (inverse of oklch_to_srgb)
l_lin = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin
m_lin = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin
s_lin = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin
l_ = math.copysign(abs(l_lin) ** (1/3), l_lin) if l_lin != 0 else 0
m_ = math.copysign(abs(m_lin) ** (1/3), m_lin) if m_lin != 0 else 0
s_ = math.copysign(abs(s_lin) ** (1/3), s_lin) if s_lin != 0 else 0
# OKLab coordinates
l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ # noqa: E741
a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
return (l, a, b)
def deltaE_ok(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float:
"""Calculate deltaE in OKLab space (Euclidean distance)
This is the color difference metric used in CSS Color Module Level 4
for gamut mapping. It measures perceptual difference between two colors.
Args:
lab1: First color in OKLab coordinates (L, a, b)
lab2: Second color in OKLab coordinates (L, a, b)
Returns:
Perceptual color difference (deltaE OK)
"""
return math.sqrt(
(lab1[0] - lab2[0]) ** 2 +
(lab1[1] - lab2[1]) ** 2 +
(lab1[2] - lab2[2]) ** 2
)
def oklch_to_srgb_gamut_map(l: float, c: float, h: float) -> tuple[float, float, float]: # noqa: E741
"""Convert OKLCH to sRGB with CSS Color Module Level 4 gamut mapping
For colors outside the sRGB gamut, this uses binary search chroma reduction
to find the maximum displayable chroma while preserving lightness and hue.
This implements the algorithm from CSS Color Module Level 4 Section 13:
https://www.w3.org/TR/css-color-4/#css-gamut-mapping
Args:
l: Lightness (0-1)
c: Chroma (0-0.4+, unbounded)
h: Hue (0-360 degrees)
Returns:
tuple: sRGB values (r, g, b) in range 0-1
"""
# Validate for NaN and infinity as a safety check
if not (math.isfinite(l) and math.isfinite(c) and math.isfinite(h)):
return (0.0, 0.0, 0.0) # Fallback to black
# Constants from CSS Color Module Level 4
JND = 0.02 # Just Noticeable Difference threshold (2% in deltaEOK)
MIN_CONVERGENCE = 0.0001 # Binary search precision (0.01% chroma)
EPSILON = 0.00001 # Small value for floating point comparisons
# Edge cases: pure black or white don't need gamut mapping
if l <= 0.0:
return (0.0, 0.0, 0.0)
if l >= 1.0:
return (1.0, 1.0, 1.0)
# If chroma is very small, color is essentially achromatic
if c < EPSILON:
gray = linear_to_srgb(l)
return (gray, gray, gray)
# Try the original color first
r, g, b = oklch_to_srgb(l, c, h)
# Check if already in gamut (no clipping needed)
if 0.0 <= r <= 1.0 and 0.0 <= g <= 1.0 and 0.0 <= b <= 1.0:
return (r, g, b)
# Binary search for maximum in-gamut chroma
low_chroma = 0.0
high_chroma = c
# Convert original color to OKLab for deltaE calculations
while (high_chroma - low_chroma) > MIN_CONVERGENCE:
mid_chroma = (high_chroma + low_chroma) * 0.5
# Try this chroma value
r_test, g_test, b_test = oklch_to_srgb(l, mid_chroma, h)
# Check if in gamut (before clipping)
in_gamut = (0.0 <= r_test <= 1.0 and
0.0 <= g_test <= 1.0 and
0.0 <= b_test <= 1.0)
if in_gamut:
# In gamut - try higher chroma
low_chroma = mid_chroma
else:
# Out of gamut - clip and check deltaE
r_clipped = max(0.0, min(1.0, r_test))
g_clipped = max(0.0, min(1.0, g_test))
b_clipped = max(0.0, min(1.0, b_test))
# Convert both to OKLab for comparison
test_lab = srgb_to_oklab(r_test, g_test, b_test)
clipped_lab = srgb_to_oklab(r_clipped, g_clipped, b_clipped)
# Calculate perceptual difference
de = deltaE_ok(test_lab, clipped_lab)
if de < JND:
# Difference is imperceptible - accept this chroma
low_chroma = mid_chroma
else:
# Difference is noticeable - reduce chroma more
high_chroma = mid_chroma
# Use the final chroma value and clip to ensure in-gamut
r_final, g_final, b_final = oklch_to_srgb(l, low_chroma, h)
return (
max(0.0, min(1.0, r_final)),
max(0.0, min(1.0, g_final)),
max(0.0, min(1.0, b_final))
)
def lab_to_srgb(l: float, a: float, b: float) -> tuple[float, float, float]: # noqa: E741
"""Convert CIE LAB to sRGB RGB (0-1)
LAB is a device-independent color space.
L: Lightness (0-100)
a: Green-red axis (-128 to +127, typically -100 to +100)
b: Blue-yellow axis (-128 to +127, typically -100 to +100)
Conversion path: LAB -> XYZ -> Linear sRGB -> sRGB
"""
# LAB to XYZ (using D65 illuminant)
y = (l + 16) / 116
x = a / 500 + y
z = y - b / 200
def f_inv(t: float) -> float:
delta = 6 / 29
if t > delta:
return t ** 3
return 3 * delta * delta * (t - 4 / 29)
# D65 white point
x_n, y_n, z_n = 0.95047, 1.00000, 1.08883
x_val = x_n * f_inv(x)
y_val = y_n * f_inv(y)
z_val = z_n * f_inv(z)
# XYZ to Linear sRGB
r_lin = +3.2404542 * x_val - 1.5371385 * y_val - 0.4985314 * z_val
g_lin = -0.9692660 * x_val + 1.8760108 * y_val + 0.0415560 * z_val
b_lin = +0.0556434 * x_val - 0.2040259 * y_val + 1.0572252 * z_val
# Clip to valid range
r_lin = max(0.0, min(1.0, r_lin))
g_lin = max(0.0, min(1.0, g_lin))
b_lin = max(0.0, min(1.0, b_lin))
# Convert linear sRGB to sRGB
return (linear_to_srgb(r_lin), linear_to_srgb(g_lin), linear_to_srgb(b_lin))
def lab_to_oklch(l_lab: float, a_lab: float, b_lab: float) -> tuple[float, float, float]:
"""Convert CIE LAB to OKLCH
Conversion path: LAB -> XYZ -> Linear sRGB -> sRGB -> OKLab -> OKLCH
"""
# First convert LAB to sRGB (unclipped to preserve out-of-gamut values)
# LAB to XYZ (using D65 illuminant)
y = (l_lab + 16) / 116
x = a_lab / 500 + y
z = y - b_lab / 200
def f_inv(t: float) -> float:
delta = 6 / 29
if t > delta:
return t ** 3
return 3 * delta * delta * (t - 4 / 29)
# D65 white point
x_n, y_n, z_n = 0.95047, 1.00000, 1.08883
x_val = x_n * f_inv(x)
y_val = y_n * f_inv(y)
z_val = z_n * f_inv(z)
# XYZ to Linear sRGB (don't clip here to preserve out-of-gamut info)
r_lin = +3.2404542 * x_val - 1.5371385 * y_val - 0.4985314 * z_val
g_lin = -0.9692660 * x_val + 1.8760108 * y_val + 0.0415560 * z_val
b_lin = +0.0556434 * x_val - 0.2040259 * y_val + 1.0572252 * z_val
# Convert linear sRGB to sRGB gamma
r_srgb = linear_to_srgb(max(0.0, r_lin)) if r_lin >= 0 else 0.0
g_srgb = linear_to_srgb(max(0.0, g_lin)) if g_lin >= 0 else 0.0
b_srgb = linear_to_srgb(max(0.0, b_lin)) if b_lin >= 0 else 0.0
# Convert to OKLab
l_ok, a_ok, b_ok = srgb_to_oklab(r_srgb, g_srgb, b_srgb)
# Convert OKLab to OKLCH
c = math.sqrt(a_ok * a_ok + b_ok * b_ok)
h = math.degrees(math.atan2(b_ok, a_ok)) % 360
return (l_ok, c, h)
# Color parsing functions for new formats
def parse_oklch(spec: str) -> Color | None:
"""Parse OKLCH color: oklch(l c h) or oklch(l, c, h)
L: 0-1 (lightness)
C: 0-0.4 (chroma, unbounded but practical max)
H: 0-360 (hue in degrees)
"""
# Remove parentheses and split
spec = spec.strip('()')
parts = [p.strip().rstrip('%,') for p in re.split(r'[,\s]+', spec) if p.strip()]
if len(parts) != 3:
return None
try:
l = float(parts[0]) # noqa: E741
c = float(parts[1])
h = float(parts[2])
# Validate for NaN and infinity
if not (math.isfinite(l) and math.isfinite(c) and math.isfinite(h)):
return None
# Handle percentages for L
if '%' in parts[0]:
l = l / 100.0 # noqa: E741
# Clamp to reasonable ranges
l = max(0.0, min(1.0, l)) # noqa: E741
c = max(0.0, c) # Chroma is unbounded but we don't clamp high end
h = h % 360 # Wrap hue to 0-360
# Convert OKLCH to sRGB with gamut mapping
# This uses CSS Color Module Level 4 algorithm for out-of-gamut colors
r, g, b = oklch_to_srgb_gamut_map(l, c, h)
return Color(
int(r * 255),
int(g * 255),
int(b * 255)
)
except (ValueError, OverflowError):
return None
def parse_lab(spec: str) -> Color | None:
"""Parse LAB color: lab(l a b) or lab(l, a, b)
L: 0-100 (lightness)
a: -128 to 127 (green-red)
b: -128 to 127 (blue-yellow)
Uses CSS Color Module Level 4 gamut mapping for out-of-gamut colors.
Conversion path: LAB -> OKLCH -> gamut-mapped sRGB
This preserves perceptual characteristics better than simple clipping.
"""
# Remove parentheses and split
spec = spec.strip('()')
parts = [p.strip().rstrip('%,') for p in re.split(r'[,\s]+', spec) if p.strip()]
if len(parts) != 3:
return None
try:
l = float(parts[0]) # noqa: E741
a = float(parts[1])
b = float(parts[2])
# Validate for NaN and infinity
if not (math.isfinite(l) and math.isfinite(a) and math.isfinite(b)):
return None
# Clamp L to 0-100
l = max(0.0, min(100.0, l)) # noqa: E741
# Convert LAB to OKLCH, then use gamut mapping to sRGB
# This is better than simple LAB -> sRGB clipping as it preserves
# perceptual properties (lightness and hue) while reducing chroma
l_ok, c, h = lab_to_oklch(l, a, b)
# Apply gamut mapping in OKLCH space (reduces chroma if needed)
r, g, b = oklch_to_srgb_gamut_map(l_ok, c, h)
return Color(
int(r * 255),
int(g * 255),
int(b * 255)
)
except (ValueError, OverflowError):
return None
def to_color(raw: str, validate: bool = False) -> Color | None:
# See man XParseColor
# Strip inline comments (e.g., "oklch(...) # comment")
@ -458,26 +43,11 @@ def to_color(raw: str, validate: bool = False) -> Color | None:
raw = raw.partition(' ')[0]
else:
# For non-hex colors, strip everything after #
raw = raw.partition('#')[0]
x = raw.strip().lower()
raw = raw.partition('#')[0].strip()
x = raw.lower()
if ans := color_names.get(x):
return ans
val: Color | None = None
with suppress(Exception):
match raw[0]:
case '#':
val = parse_sharp(raw[1:])
case 'o':
val = parse_oklch(x[6:])
case 'l':
val = parse_lab(x[4:])
case 'r':
k, _, v = raw.partition(':')
if k == 'rgb':
val = parse_rgb(v)
elif k == 'rgbi':
val = parse_rgbi(v)
if val is None and validate:
if (val := Color.parse_color(x)) is None and validate:
raise ValueError(f'Invalid color name: {raw!r}')
return val

View file

@ -65,10 +65,10 @@ def test_to_color(self):
def c(spec, r=0, g=0, b=0, a=0):
c = to_color(spec)
self.ae(c.red, r)
self.ae(c.green, g)
self.ae(c.blue, b)
self.ae(c.alpha, a)
self.ae(c.red, r, spec)
self.ae(c.green, g, spec)
self.ae(c.blue, b, spec)
self.ae(c.alpha, a, spec)
c('#eee', 0xee, 0xee, 0xee)
c('#234567', 0x23, 0x45, 0x67)