Merge branch 'wide-gamut-colors' of https://github.com/jokull/kitty
This commit is contained in:
commit
62d5fcc074
9 changed files with 1242 additions and 1 deletions
|
|
@ -157,6 +157,9 @@ Detailed list of changes
|
||||||
- Fix tab bar rendering glitches when using :opt:`tab_bar_filter` in some
|
- Fix tab bar rendering glitches when using :opt:`tab_bar_filter` in some
|
||||||
circumstances (:iss:`9328`)
|
circumstances (:iss:`9328`)
|
||||||
|
|
||||||
|
- Add support for specifying colors in :file:`kitty.conf` in OKLCH and LAB
|
||||||
|
color spaces (:pull:`9325`)
|
||||||
|
|
||||||
0.45.0 [2025-12-24]
|
0.45.0 [2025-12-24]
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
||||||
82
docs/wide-gamut-colors.rst
Normal file
82
docs/wide-gamut-colors.rst
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
Wide gamut color formats
|
||||||
|
=========================
|
||||||
|
|
||||||
|
kitty supports modern wide gamut color formats for precise color specification.
|
||||||
|
These formats can be used anywhere a color value is accepted in the configuration
|
||||||
|
(foreground, background, color0-color255, etc.).
|
||||||
|
|
||||||
|
OKLCH Colors
|
||||||
|
------------
|
||||||
|
|
||||||
|
OKLCH is a perceptually uniform color space, ideal for creating color themes.
|
||||||
|
The format is::
|
||||||
|
|
||||||
|
foreground oklch(0.9 0.05 140)
|
||||||
|
color1 oklch(0.7 0.25 25)
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- **L** (Lightness): 0 to 1, where 0 is black and 1 is white
|
||||||
|
- **C** (Chroma): 0 to approximately 0.4, represents color saturation
|
||||||
|
- **H** (Hue): 0 to 360 degrees (0=red, 120=green, 240=blue)
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
- Perceptually uniform - equal changes produce equal perceived differences
|
||||||
|
- Adjusting lightness preserves hue (unlike HSL)
|
||||||
|
- Industry standard for modern color design
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
foreground oklch(0.9 0.05 140)
|
||||||
|
color1 oklch(0.65 0.25 29) # Vibrant red-orange
|
||||||
|
color2 oklch(0.65 0.25 142) # Vibrant green
|
||||||
|
color3 oklch(0.70 0.19 90) # Warm yellow
|
||||||
|
|
||||||
|
CIE LAB Colors
|
||||||
|
--------------
|
||||||
|
|
||||||
|
CIE LAB is a device-independent color space designed to approximate human vision.
|
||||||
|
|
||||||
|
The format is::
|
||||||
|
|
||||||
|
background lab(20 5 -10)
|
||||||
|
color4 lab(50 0 -50)
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- **L**: Lightness, 0 to 100 (0 = black, 100 = white)
|
||||||
|
- **a**: Green (-) to red (+), typically -100 to +100
|
||||||
|
- **b**: Blue (-) to yellow (+), typically -100 to +100
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
background lab(10 0 0) # Very dark neutral gray
|
||||||
|
foreground lab(90 0 0) # Very light neutral gray
|
||||||
|
color1 lab(50 60 40) # Red
|
||||||
|
color4 lab(50 0 -50) # Blue
|
||||||
|
|
||||||
|
Gamut Mapping
|
||||||
|
-------------
|
||||||
|
|
||||||
|
When you specify colors in OKLCH or CIE LAB formats that are outside the sRGB
|
||||||
|
color gamut, kitty automatically converts them using the CSS Color Module Level 4
|
||||||
|
gamut mapping algorithm:
|
||||||
|
|
||||||
|
- Preserves the original lightness and hue as much as possible
|
||||||
|
- Reduces chroma (saturation) until the color fits within the displayable range
|
||||||
|
- Uses perceptual color difference (deltaE OK) to minimize visible changes
|
||||||
|
- Maximizes color saturation while staying in gamut
|
||||||
|
|
||||||
|
This ensures that wide gamut colors gracefully degrade on standard sRGB displays while
|
||||||
|
taking full advantage of wide gamut displays when available. The mapping happens
|
||||||
|
automatically - you don't need to do anything special.
|
||||||
|
|
||||||
|
For example, :code:`oklch(0.7 0.4 25)` might be too saturated for sRGB but will be
|
||||||
|
automatically adjusted to fit while preserving the perceived hue and lightness.
|
||||||
|
|
||||||
|
References
|
||||||
|
----------
|
||||||
|
|
||||||
|
- `CSS Color Module Level 4 <https://www.w3.org/TR/css-color-4/>`_
|
||||||
|
- `OKLCH Color Space <https://bottosson.github.io/posts/oklab/>`_
|
||||||
|
|
@ -3140,6 +3140,18 @@
|
||||||
documented=False,
|
documented=False,
|
||||||
)
|
)
|
||||||
egr() # }}}
|
egr() # }}}
|
||||||
|
|
||||||
|
# colors.wide_gamut {{{
|
||||||
|
agr('colors.wide_gamut', 'Wide gamut color formats', '''
|
||||||
|
kitty supports modern wide gamut color formats including OKLCH and CIE LAB for precise
|
||||||
|
color specification. These formats can be used anywhere a color value is accepted
|
||||||
|
(foreground, background, color0-color255, etc.).
|
||||||
|
|
||||||
|
For detailed documentation on wide gamut color formats, syntax, and examples,
|
||||||
|
see :doc:`/wide-gamut-colors`.
|
||||||
|
''')
|
||||||
|
|
||||||
|
egr() # }}}
|
||||||
egr() # }}}
|
egr() # }}}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
406
kitty/rgb.py
generated
406
kitty/rgb.py
generated
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
|
# License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
|
|
@ -41,7 +42,11 @@ def parse_rgb(spec: str) -> Color | None:
|
||||||
|
|
||||||
|
|
||||||
def parse_single_intensity(x: str) -> int:
|
def parse_single_intensity(x: str) -> int:
|
||||||
return int(max(0, min(abs(float(x)), 1)) * 255)
|
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:
|
def parse_rgbi(spec: str) -> Color | None:
|
||||||
|
|
@ -67,8 +72,403 @@ def color_as_sgr(x: Color) -> str:
|
||||||
return x.as_sgr
|
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 ((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 * (c ** (1 / 2.4)) - 0.055
|
||||||
|
|
||||||
|
|
||||||
|
def oklch_to_srgb(l: float, c: float, h: float) -> tuple[float, float, float]:
|
||||||
|
"""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_
|
||||||
|
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]:
|
||||||
|
"""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
|
||||||
|
h_rad = math.radians(h)
|
||||||
|
original_a = c * math.cos(h_rad)
|
||||||
|
original_b = c * math.sin(h_rad)
|
||||||
|
original_lab = (l, original_a, original_b)
|
||||||
|
|
||||||
|
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]:
|
||||||
|
"""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])
|
||||||
|
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
|
||||||
|
|
||||||
|
# Clamp to reasonable ranges
|
||||||
|
l = max(0.0, min(1.0, l))
|
||||||
|
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])
|
||||||
|
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
|
||||||
|
|
||||||
|
# Handle percentage for L
|
||||||
|
if '%' in parts[0]:
|
||||||
|
l = l # L is already 0-100, so percentage would be the same
|
||||||
|
|
||||||
|
# Clamp L to 0-100
|
||||||
|
l = max(0.0, min(100.0, l))
|
||||||
|
|
||||||
|
# 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:
|
def to_color(raw: str, validate: bool = False) -> Color | None:
|
||||||
# See man XParseColor
|
# See man XParseColor
|
||||||
|
# Strip inline comments (e.g., "oklch(...) # comment")
|
||||||
|
# For hex colors like "#ff0000", preserve the first #, but strip comments after spaces
|
||||||
|
raw = raw.strip()
|
||||||
|
if raw.startswith('#'):
|
||||||
|
# For hex colors, only strip comments after whitespace
|
||||||
|
# e.g., "#ff0000 # comment" -> "#ff0000"
|
||||||
|
parts = raw.split()
|
||||||
|
if len(parts) > 1:
|
||||||
|
raw = parts[0] # Keep only the hex color part
|
||||||
|
else:
|
||||||
|
# For non-hex colors, strip everything after #
|
||||||
|
raw = raw.split('#')[0].strip()
|
||||||
x = raw.strip().lower()
|
x = raw.strip().lower()
|
||||||
ans = color_names.get(x)
|
ans = color_names.get(x)
|
||||||
if ans is not None:
|
if ans is not None:
|
||||||
|
|
@ -77,6 +477,10 @@ def to_color(raw: str, validate: bool = False) -> Color | None:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
if raw.startswith('#'):
|
if raw.startswith('#'):
|
||||||
val = parse_sharp(raw[1:])
|
val = parse_sharp(raw[1:])
|
||||||
|
elif x.startswith('oklch('):
|
||||||
|
val = parse_oklch(x[6:])
|
||||||
|
elif x.startswith('lab('):
|
||||||
|
val = parse_lab(x[4:])
|
||||||
else:
|
else:
|
||||||
k, sep, v = raw.partition(':')
|
k, sep, v = raw.partition(':')
|
||||||
if k == 'rgb':
|
if k == 'rgb':
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,12 @@ def c(spec, r=0, g=0, b=0, a=0):
|
||||||
c('rgb:23/45/67', 0x23, 0x45, 0x67)
|
c('rgb:23/45/67', 0x23, 0x45, 0x67)
|
||||||
c('rgb:abc/abc/def', 0xab, 0xab, 0xde)
|
c('rgb:abc/abc/def', 0xab, 0xab, 0xde)
|
||||||
c('red', 0xff)
|
c('red', 0xff)
|
||||||
|
|
||||||
|
# Wide gamut color formats
|
||||||
|
c('oklch(0.7 0.15 140)', 0x67, 0xb4, 0x56) # OKLCH green
|
||||||
|
c('oklch(0.9 0.05 265)', 0xcd, 0xde, 0xfe) # OKLCH light blue
|
||||||
|
c('lab(70 50 -30)', 0xea, 0x88, 0xe2) # CIE LAB purple-ish
|
||||||
|
|
||||||
self.ae(int(Color(1, 2, 3)), 0x10203)
|
self.ae(int(Color(1, 2, 3)), 0x10203)
|
||||||
base = Color(12, 12, 12)
|
base = Color(12, 12, 12)
|
||||||
a = Color(23, 23, 23)
|
a = Color(23, 23, 23)
|
||||||
|
|
@ -87,6 +93,155 @@ def c(spec, r=0, g=0, b=0, a=0):
|
||||||
self.ae(Color(1, 2, 3, 4).as_sharp, '#04010203')
|
self.ae(Color(1, 2, 3, 4).as_sharp, '#04010203')
|
||||||
self.ae(Color(1, 2, 3, 4).rgb, 0x10203)
|
self.ae(Color(1, 2, 3, 4).rgb, 0x10203)
|
||||||
|
|
||||||
|
def test_oklch_gamut_mapping(self):
|
||||||
|
"""Test OKLCH color format with CSS Color 4 gamut mapping"""
|
||||||
|
def c(spec, r=0, g=0, b=0):
|
||||||
|
color = to_color(spec)
|
||||||
|
self.assertIsNotNone(color, f'Failed to parse: {spec}')
|
||||||
|
self.ae(color.red, r)
|
||||||
|
self.ae(color.green, g)
|
||||||
|
self.ae(color.blue, b)
|
||||||
|
|
||||||
|
def in_range(spec):
|
||||||
|
"""Verify color values are in valid 0-255 range"""
|
||||||
|
color = to_color(spec)
|
||||||
|
self.assertIsNotNone(color, f'Failed to parse: {spec}')
|
||||||
|
self.assertTrue(0 <= color.red <= 255, f'Red out of range: {color.red}')
|
||||||
|
self.assertTrue(0 <= color.green <= 255, f'Green out of range: {color.green}')
|
||||||
|
self.assertTrue(0 <= color.blue <= 255, f'Blue out of range: {color.blue}')
|
||||||
|
return color
|
||||||
|
|
||||||
|
# In-gamut colors should parse unchanged
|
||||||
|
c('oklch(0.5 0.1 180)', 0x00, 0x75, 0x65) # Mid-tone cyan with moderate chroma
|
||||||
|
|
||||||
|
# Out-of-gamut colors should be mapped to sRGB gamut
|
||||||
|
# High chroma red - should be mapped but remain reddish
|
||||||
|
color = in_range('oklch(0.7 0.35 25)')
|
||||||
|
self.assertGreater(color.red, 200, 'High chroma red should have high red component')
|
||||||
|
self.assertLess(color.green, 100, 'High chroma red should have low green component')
|
||||||
|
|
||||||
|
# Edge cases
|
||||||
|
c('oklch(0 0 0)', 0x00, 0x00, 0x00) # Pure black
|
||||||
|
c('oklch(1 0 0)', 0xff, 0xff, 0xff) # Pure white
|
||||||
|
|
||||||
|
# Achromatic colors (zero chroma)
|
||||||
|
c('oklch(0.5 0 180)', 0xbb, 0xbb, 0xbb) # Mid gray, hue irrelevant
|
||||||
|
c('oklch(0.25 0 90)', 0x88, 0x88, 0x88) # Dark gray
|
||||||
|
|
||||||
|
# Test various hues with moderate chroma
|
||||||
|
in_range('oklch(0.6 0.15 0)') # Red hue
|
||||||
|
in_range('oklch(0.6 0.15 60)') # Yellow hue
|
||||||
|
in_range('oklch(0.6 0.15 120)') # Green hue
|
||||||
|
in_range('oklch(0.6 0.15 180)') # Cyan hue
|
||||||
|
in_range('oklch(0.6 0.15 240)') # Blue hue
|
||||||
|
in_range('oklch(0.6 0.15 300)') # Magenta hue
|
||||||
|
|
||||||
|
# Test with different comma/space separators
|
||||||
|
c('oklch(0.5, 0.1, 180)', 0x00, 0x75, 0x65)
|
||||||
|
c('oklch(0.5,0.1,180)', 0x00, 0x75, 0x65)
|
||||||
|
|
||||||
|
# Test percentage lightness
|
||||||
|
color = to_color('oklch(50% 0.1 180)')
|
||||||
|
self.assertIsNotNone(color)
|
||||||
|
|
||||||
|
# Very high chroma should trigger gamut mapping
|
||||||
|
# These should all succeed and return valid RGB values
|
||||||
|
in_range('oklch(0.5 0.5 0)')
|
||||||
|
in_range('oklch(0.5 0.5 180)')
|
||||||
|
in_range('oklch(0.9 0.3 120)')
|
||||||
|
|
||||||
|
def test_inline_comments(self):
|
||||||
|
"""Test inline comments in color values"""
|
||||||
|
def c(spec, r=0, g=0, b=0):
|
||||||
|
color = to_color(spec)
|
||||||
|
self.assertIsNotNone(color, f'Failed to parse: {spec}')
|
||||||
|
self.ae(color.red, r)
|
||||||
|
self.ae(color.green, g)
|
||||||
|
self.ae(color.blue, b)
|
||||||
|
|
||||||
|
# OKLCH with inline comment
|
||||||
|
c('oklch(0.5 0.1 180) # Cyan color', 0x00, 0x75, 0x65)
|
||||||
|
c('oklch(0.7 0.15 140) # Green', 0x67, 0xb4, 0x56)
|
||||||
|
|
||||||
|
# Hex colors with inline comments
|
||||||
|
c('#ff0000 # Red', 0xff, 0x00, 0x00)
|
||||||
|
c('#00ff00 # Green', 0x00, 0xff, 0x00)
|
||||||
|
c('#0000ff # Blue', 0x00, 0x00, 0xff)
|
||||||
|
|
||||||
|
# LAB colors with inline comments
|
||||||
|
c('lab(70 50 -30) # Purple-ish', 0xea, 0x88, 0xe2)
|
||||||
|
|
||||||
|
# RGB with inline comments
|
||||||
|
c('rgb:ff/00/00 # RGB Red', 0xff, 0x00, 0x00)
|
||||||
|
|
||||||
|
# Named color should not be affected by text after it
|
||||||
|
# (not a comment, just ignored)
|
||||||
|
c('red', 0xff, 0x00, 0x00)
|
||||||
|
|
||||||
|
def test_lab_parsing(self):
|
||||||
|
"""Test CIE LAB color format parsing"""
|
||||||
|
def c(spec, r=0, g=0, b=0):
|
||||||
|
color = to_color(spec)
|
||||||
|
self.assertIsNotNone(color, f'Failed to parse: {spec}')
|
||||||
|
self.ae(color.red, r)
|
||||||
|
self.ae(color.green, g)
|
||||||
|
self.ae(color.blue, b)
|
||||||
|
|
||||||
|
# LAB basic colors
|
||||||
|
c('lab(0 0 0)', 0x00, 0x00, 0x00) # LAB black
|
||||||
|
c('lab(100 0 0)', 0xfe, 0xfe, 0xfe) # LAB white (slightly clipped in conversion)
|
||||||
|
c('lab(50 0 0)', 0xc6, 0xc6, 0xc6) # LAB mid-gray
|
||||||
|
|
||||||
|
# LAB with color components
|
||||||
|
c('lab(70 50 -30)', 0xea, 0x88, 0xe2) # Purple-ish
|
||||||
|
color = to_color('lab(50 50 50)') # Orange/red-ish (positive a and b)
|
||||||
|
self.assertIsNotNone(color)
|
||||||
|
self.assertGreater(color.red, 0xc0) # Should have high red
|
||||||
|
self.assertLess(color.blue, 0x50) # Should have low blue
|
||||||
|
|
||||||
|
# LAB with different separators
|
||||||
|
color = to_color('lab(70, 50, -30)')
|
||||||
|
self.assertIsNotNone(color)
|
||||||
|
color = to_color('lab(70,50,-30)')
|
||||||
|
self.assertIsNotNone(color)
|
||||||
|
|
||||||
|
# LAB with negative values (valid for a and b channels)
|
||||||
|
color = to_color('lab(50 -50 -50)')
|
||||||
|
self.assertIsNotNone(color)
|
||||||
|
color = to_color('lab(50 -50 50)')
|
||||||
|
self.assertIsNotNone(color)
|
||||||
|
|
||||||
|
def test_color_format_errors(self):
|
||||||
|
"""Test error handling for invalid color formats"""
|
||||||
|
# Invalid OKLCH
|
||||||
|
self.assertIsNone(to_color('oklch()'))
|
||||||
|
self.assertIsNone(to_color('oklch(0.5)'))
|
||||||
|
self.assertIsNone(to_color('oklch(0.5 0.1)'))
|
||||||
|
self.assertIsNone(to_color('oklch(a b c)'))
|
||||||
|
|
||||||
|
# Invalid LAB
|
||||||
|
self.assertIsNone(to_color('lab()'))
|
||||||
|
self.assertIsNone(to_color('lab(50)'))
|
||||||
|
self.assertIsNone(to_color('lab(50 0)'))
|
||||||
|
self.assertIsNone(to_color('lab(a b c)'))
|
||||||
|
|
||||||
|
# Invalid color() function
|
||||||
|
self.assertIsNone(to_color('color()'))
|
||||||
|
self.assertIsNone(to_color('color(unknown 1 0 0)'))
|
||||||
|
|
||||||
|
# Empty and whitespace
|
||||||
|
self.assertIsNone(to_color(''))
|
||||||
|
self.assertIsNone(to_color(' '))
|
||||||
|
|
||||||
|
# Malformed hex
|
||||||
|
self.assertIsNone(to_color('#'))
|
||||||
|
self.assertIsNone(to_color('#12'))
|
||||||
|
self.assertIsNone(to_color('#1234'))
|
||||||
|
|
||||||
|
# Malformed rgb
|
||||||
|
self.assertIsNone(to_color('rgb:'))
|
||||||
|
self.assertIsNone(to_color('rgb:a/b'))
|
||||||
|
|
||||||
def test_linebuf(self):
|
def test_linebuf(self):
|
||||||
old = filled_line_buf(2, 3, filled_cursor())
|
old = filled_line_buf(2, 3, filled_cursor())
|
||||||
new = LineBuf(1, 3)
|
new = LineBuf(1, 3)
|
||||||
|
|
|
||||||
357
tools/utils/style/colorspaces.go
Normal file
357
tools/utils/style/colorspaces.go
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
// License: GPLv3 Copyright: 2025, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
package style
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Color space conversion functions for wide gamut color support
|
||||||
|
// Implements OKLCH, Display P3, and CIE LAB color formats with
|
||||||
|
// CSS Color Module Level 4 gamut mapping.
|
||||||
|
|
||||||
|
// srgbToLinear converts sRGB component (0-1) to linear light
|
||||||
|
func srgbToLinear(c float64) float64 {
|
||||||
|
if c <= 0.04045 {
|
||||||
|
return c / 12.92
|
||||||
|
}
|
||||||
|
return math.Pow((c+0.055)/1.055, 2.4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// linearToSrgb converts linear light component (0-1) to sRGB
|
||||||
|
func linearToSrgb(c float64) float64 {
|
||||||
|
if c <= 0.0031308 {
|
||||||
|
return c * 12.92
|
||||||
|
}
|
||||||
|
return 1.055*math.Pow(c, 1.0/2.4) - 0.055
|
||||||
|
}
|
||||||
|
|
||||||
|
// oklabToLinearSrgb converts OKLab to linear sRGB
|
||||||
|
func oklabToLinearSrgb(l, a, b float64) (float64, float64, float64) {
|
||||||
|
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_cubed := l_ * l_ * l_
|
||||||
|
m_cubed := m_ * m_ * m_
|
||||||
|
s_cubed := s_ * s_ * s_
|
||||||
|
|
||||||
|
r := +4.0767416621*l_cubed - 3.3077115913*m_cubed + 0.2309699292*s_cubed
|
||||||
|
g := -1.2684380046*l_cubed + 2.6097574011*m_cubed - 0.3413193965*s_cubed
|
||||||
|
b_val := -0.0041960863*l_cubed - 0.7034186147*m_cubed + 1.7076147010*s_cubed
|
||||||
|
|
||||||
|
return r, g, b_val
|
||||||
|
}
|
||||||
|
|
||||||
|
// oklchToSrgb converts OKLCH to sRGB (without gamut mapping)
|
||||||
|
func oklchToSrgb(l, c, h float64) (float64, float64, float64) {
|
||||||
|
// Convert OKLCH to OKLab
|
||||||
|
hRad := h * math.Pi / 180.0
|
||||||
|
a := c * math.Cos(hRad)
|
||||||
|
b := c * math.Sin(hRad)
|
||||||
|
|
||||||
|
// Convert OKLab to linear sRGB
|
||||||
|
rLin, gLin, bLin := oklabToLinearSrgb(l, a, b)
|
||||||
|
|
||||||
|
// Apply sRGB transfer function
|
||||||
|
r := linearToSrgb(rLin)
|
||||||
|
g := linearToSrgb(gLin)
|
||||||
|
bVal := linearToSrgb(bLin)
|
||||||
|
|
||||||
|
return r, g, bVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// srgbToOklab converts sRGB to OKLab (for deltaE calculations)
|
||||||
|
func srgbToOklab(r, g, b float64) (float64, float64, float64) {
|
||||||
|
rLin := srgbToLinear(r)
|
||||||
|
gLin := srgbToLinear(g)
|
||||||
|
bLin := srgbToLinear(b)
|
||||||
|
|
||||||
|
l_ := 0.4122214708*rLin + 0.5363325363*gLin + 0.0514459929*bLin
|
||||||
|
m_ := 0.2119034982*rLin + 0.6806995451*gLin + 0.1073969566*bLin
|
||||||
|
s_ := 0.0883024619*rLin + 0.2817188376*gLin + 0.6299787005*bLin
|
||||||
|
|
||||||
|
l_ = math.Cbrt(l_)
|
||||||
|
m_ = math.Cbrt(m_)
|
||||||
|
s_ = math.Cbrt(s_)
|
||||||
|
|
||||||
|
l := 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_
|
||||||
|
a := 1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_
|
||||||
|
bVal := 0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_
|
||||||
|
|
||||||
|
return l, a, bVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// deltaEOk calculates perceptual color difference in OKLab space
|
||||||
|
func deltaEOk(lab1, lab2 [3]float64) float64 {
|
||||||
|
dl := lab1[0] - lab2[0]
|
||||||
|
da := lab1[1] - lab2[1]
|
||||||
|
db := lab1[2] - lab2[2]
|
||||||
|
return math.Sqrt(dl*dl + da*da + db*db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// oklchToSrgbGamutMap converts OKLCH to sRGB with CSS Color Module Level 4 gamut mapping
|
||||||
|
func oklchToSrgbGamutMap(l, c, h float64) (float64, float64, float64) {
|
||||||
|
// Validate for NaN and infinity
|
||||||
|
if !math.IsInf(l, 0) && !math.IsInf(c, 0) && !math.IsInf(h, 0) &&
|
||||||
|
!math.IsNaN(l) && !math.IsNaN(c) && !math.IsNaN(h) {
|
||||||
|
// Valid input
|
||||||
|
} else {
|
||||||
|
return 0.0, 0.0, 0.0 // Fallback to black
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants from CSS Color Module Level 4
|
||||||
|
const jnd = 0.02 // Just Noticeable Difference threshold
|
||||||
|
const minConvergence = 0.0001 // Binary search precision
|
||||||
|
const epsilon = 0.00001 // Small value for floating point comparisons
|
||||||
|
|
||||||
|
// Edge cases: pure black or white
|
||||||
|
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 achromatic
|
||||||
|
if c < epsilon {
|
||||||
|
gray := linearToSrgb(l)
|
||||||
|
return gray, gray, gray
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the original color first
|
||||||
|
r, g, b := oklchToSrgb(l, c, h)
|
||||||
|
|
||||||
|
// Check if already in gamut
|
||||||
|
if r >= 0.0 && r <= 1.0 && g >= 0.0 && g <= 1.0 && b >= 0.0 && b <= 1.0 {
|
||||||
|
return r, g, b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary search for maximum in-gamut chroma
|
||||||
|
lowChroma := 0.0
|
||||||
|
highChroma := c
|
||||||
|
|
||||||
|
for (highChroma - lowChroma) > minConvergence {
|
||||||
|
midChroma := (highChroma + lowChroma) * 0.5
|
||||||
|
|
||||||
|
// Try this chroma value
|
||||||
|
rTest, gTest, bTest := oklchToSrgb(l, midChroma, h)
|
||||||
|
|
||||||
|
// Check if in gamut (before clipping)
|
||||||
|
inGamut := rTest >= 0.0 && rTest <= 1.0 &&
|
||||||
|
gTest >= 0.0 && gTest <= 1.0 &&
|
||||||
|
bTest >= 0.0 && bTest <= 1.0
|
||||||
|
|
||||||
|
if inGamut {
|
||||||
|
// In gamut - try higher chroma
|
||||||
|
lowChroma = midChroma
|
||||||
|
} else {
|
||||||
|
// Out of gamut - clip and check deltaE
|
||||||
|
rClipped := math.Max(0.0, math.Min(1.0, rTest))
|
||||||
|
gClipped := math.Max(0.0, math.Min(1.0, gTest))
|
||||||
|
bClipped := math.Max(0.0, math.Min(1.0, bTest))
|
||||||
|
|
||||||
|
// Convert both to OKLab for comparison
|
||||||
|
lTest, aTest, bTestLab := srgbToOklab(rTest, gTest, bTest)
|
||||||
|
testLab := [3]float64{lTest, aTest, bTestLab}
|
||||||
|
|
||||||
|
lClip, aClip, bClip := srgbToOklab(rClipped, gClipped, bClipped)
|
||||||
|
clippedLab := [3]float64{lClip, aClip, bClip}
|
||||||
|
|
||||||
|
// Calculate perceptual difference
|
||||||
|
de := deltaEOk(testLab, clippedLab)
|
||||||
|
|
||||||
|
if de < jnd {
|
||||||
|
// Difference is imperceptible - accept this chroma
|
||||||
|
lowChroma = midChroma
|
||||||
|
} else {
|
||||||
|
// Difference is noticeable - reduce chroma more
|
||||||
|
highChroma = midChroma
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the final chroma value and clip to ensure in-gamut
|
||||||
|
rFinal, gFinal, bFinal := oklchToSrgb(l, lowChroma, h)
|
||||||
|
return math.Max(0.0, math.Min(1.0, rFinal)),
|
||||||
|
math.Max(0.0, math.Min(1.0, gFinal)),
|
||||||
|
math.Max(0.0, math.Min(1.0, bFinal))
|
||||||
|
}
|
||||||
|
|
||||||
|
// labToOklch converts CIE LAB to OKLCH for gamut mapping
|
||||||
|
func labToOklch(l, a, b float64) (float64, float64, float64) {
|
||||||
|
// LAB to XYZ (using D65 illuminant)
|
||||||
|
y := (l + 16) / 116
|
||||||
|
x := a/500 + y
|
||||||
|
z := y - b/200
|
||||||
|
|
||||||
|
fInv := func(t float64) float64 {
|
||||||
|
delta := 6.0 / 29.0
|
||||||
|
if t > delta {
|
||||||
|
return t * t * t
|
||||||
|
}
|
||||||
|
return 3 * delta * delta * (t - 4.0/29.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// D65 white point
|
||||||
|
const xN = 0.95047
|
||||||
|
const yN = 1.00000
|
||||||
|
const zN = 1.08883
|
||||||
|
|
||||||
|
xVal := xN * fInv(x)
|
||||||
|
yVal := yN * fInv(y)
|
||||||
|
zVal := zN * fInv(z)
|
||||||
|
|
||||||
|
// XYZ to linear sRGB
|
||||||
|
rLin := +3.2404542*xVal - 1.5371385*yVal - 0.4985314*zVal
|
||||||
|
gLin := -0.9692660*xVal + 1.8760108*yVal + 0.0415560*zVal
|
||||||
|
bLin := +0.0556434*xVal - 0.2040259*yVal + 1.0572252*zVal
|
||||||
|
|
||||||
|
// Convert to OKLab
|
||||||
|
l_ := 0.4122214708*rLin + 0.5363325363*gLin + 0.0514459929*bLin
|
||||||
|
m_ := 0.2119034982*rLin + 0.6806995451*gLin + 0.1073969566*bLin
|
||||||
|
s_ := 0.0883024619*rLin + 0.2817188376*gLin + 0.6299787005*bLin
|
||||||
|
|
||||||
|
l_ = math.Cbrt(l_)
|
||||||
|
m_ = math.Cbrt(m_)
|
||||||
|
s_ = math.Cbrt(s_)
|
||||||
|
|
||||||
|
lOk := 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_
|
||||||
|
aOk := 1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_
|
||||||
|
bOk := 0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_
|
||||||
|
|
||||||
|
// Convert OKLab to OKLCH
|
||||||
|
c := math.Sqrt(aOk*aOk + bOk*bOk)
|
||||||
|
h := math.Atan2(bOk, aOk) * 180.0 / math.Pi
|
||||||
|
if h < 0 {
|
||||||
|
h += 360
|
||||||
|
}
|
||||||
|
|
||||||
|
return lOk, c, h
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOklch parses OKLCH color: oklch(l c h) or oklch(l, c, h)
|
||||||
|
func parseOklch(spec string) (RGBA, error) {
|
||||||
|
spec = strings.Trim(spec, "()")
|
||||||
|
parts := splitColorComponents(spec)
|
||||||
|
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return RGBA{}, errInvalidColor
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := parseFloatValue(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return RGBA{}, err
|
||||||
|
}
|
||||||
|
c, err := parseFloatValue(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return RGBA{}, err
|
||||||
|
}
|
||||||
|
h, err := parseFloatValue(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return RGBA{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate for NaN and infinity
|
||||||
|
if math.IsNaN(l) || math.IsInf(l, 0) ||
|
||||||
|
math.IsNaN(c) || math.IsInf(c, 0) ||
|
||||||
|
math.IsNaN(h) || math.IsInf(h, 0) {
|
||||||
|
return RGBA{}, errInvalidColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle percentages for L
|
||||||
|
if strings.Contains(parts[0], "%") {
|
||||||
|
l = l / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to reasonable ranges
|
||||||
|
l = math.Max(0.0, math.Min(1.0, l))
|
||||||
|
c = math.Max(0.0, c) // Chroma is unbounded
|
||||||
|
h = math.Mod(h, 360) // Wrap hue to 0-360
|
||||||
|
if h < 0 {
|
||||||
|
h += 360
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert OKLCH to sRGB with gamut mapping
|
||||||
|
r, g, b := oklchToSrgbGamutMap(l, c, h)
|
||||||
|
|
||||||
|
return RGBA{
|
||||||
|
Red: uint8(r * 255),
|
||||||
|
Green: uint8(g * 255),
|
||||||
|
Blue: uint8(b * 255),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLab parses LAB color: lab(l a b) or lab(l, a, b)
|
||||||
|
func parseLab(spec string) (RGBA, error) {
|
||||||
|
spec = strings.Trim(spec, "()")
|
||||||
|
parts := splitColorComponents(spec)
|
||||||
|
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return RGBA{}, errInvalidColor
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := parseFloatValue(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return RGBA{}, err
|
||||||
|
}
|
||||||
|
a, err := parseFloatValue(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return RGBA{}, err
|
||||||
|
}
|
||||||
|
b, err := parseFloatValue(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return RGBA{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate for NaN and infinity
|
||||||
|
if math.IsNaN(l) || math.IsInf(l, 0) ||
|
||||||
|
math.IsNaN(a) || math.IsInf(a, 0) ||
|
||||||
|
math.IsNaN(b) || math.IsInf(b, 0) {
|
||||||
|
return RGBA{}, errInvalidColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp L to 0-100
|
||||||
|
l = math.Max(0.0, math.Min(100.0, l))
|
||||||
|
|
||||||
|
// Convert LAB to OKLCH, then use gamut mapping to sRGB
|
||||||
|
lOk, c, h := labToOklch(l, a, b)
|
||||||
|
|
||||||
|
// Apply gamut mapping in OKLCH space
|
||||||
|
r, g, bVal := oklchToSrgbGamutMap(lOk, c, h)
|
||||||
|
|
||||||
|
return RGBA{
|
||||||
|
Red: uint8(r * 255),
|
||||||
|
Green: uint8(g * 255),
|
||||||
|
Blue: uint8(bVal * 255),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitColorComponents splits color components by comma or whitespace
|
||||||
|
func splitColorComponents(spec string) []string {
|
||||||
|
re := regexp.MustCompile(`[,\s]+`)
|
||||||
|
parts := re.Split(spec, -1)
|
||||||
|
|
||||||
|
var result []string
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
part = strings.TrimRight(part, "%,")
|
||||||
|
if part != "" {
|
||||||
|
result = append(result, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFloatValue parses a float value, handling percentages
|
||||||
|
func parseFloatValue(s string) (float64, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.TrimRight(s, "%,")
|
||||||
|
return strconv.ParseFloat(s, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errInvalidColor = fmt.Errorf("invalid color format")
|
||||||
80
tools/utils/style/colorspaces_bench_test.go
Normal file
80
tools/utils/style/colorspaces_bench_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
// License: GPLv3 Copyright: 2025, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
package style
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Benchmark color parsing functions to demonstrate performance
|
||||||
|
|
||||||
|
func BenchmarkParseOklch(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = parseOklch("0.5 0.1 180")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkParseLab(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = parseLab("50 0 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkParseColorHex(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = ParseColor("#ff0000")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkParseColorOklch(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = ParseColor("oklch(0.5 0.1 180)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkParseColorLab(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = ParseColor("lab(50 0 0)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkParseColorWithComment(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = ParseColor("oklch(0.5 0.1 180) # vibrant color")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark the gamut mapping algorithm specifically
|
||||||
|
func BenchmarkOklchToSrgbGamutMap(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
oklchToSrgbGamutMap(0.7, 0.4, 25) // Very saturated color requiring gamut mapping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkOklchToSrgbGamutMapInGamut(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
oklchToSrgbGamutMap(0.5, 0.05, 180) // Already in gamut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark parsing many colors (simulating config file parsing)
|
||||||
|
func BenchmarkParseManyColors(b *testing.B) {
|
||||||
|
colors := []string{
|
||||||
|
"#ff0000",
|
||||||
|
"#00ff00",
|
||||||
|
"#0000ff",
|
||||||
|
"oklch(0.5 0.1 180)",
|
||||||
|
"lab(50 20 -30)",
|
||||||
|
"rgb:ff/00/00",
|
||||||
|
"red",
|
||||||
|
"blue",
|
||||||
|
"green",
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
for _, color := range colors {
|
||||||
|
_, _ = ParseColor(color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
tools/utils/style/colorspaces_test.go
Normal file
126
tools/utils/style/colorspaces_test.go
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
// License: GPLv3 Copyright: 2025, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
package style
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseOklch(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want RGBA
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic oklch",
|
||||||
|
input: "0.5 0.1 180",
|
||||||
|
want: RGBA{Red: 0, Green: 117, Blue: 101}, // cyan-ish with gamut mapping
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "white",
|
||||||
|
input: "1.0 0 0",
|
||||||
|
want: RGBA{Red: 255, Green: 255, Blue: 255},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "black",
|
||||||
|
input: "0 0 0",
|
||||||
|
want: RGBA{Red: 0, Green: 0, Blue: 0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseOklch(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("parseOklch() error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Allow some tolerance due to rounding
|
||||||
|
if math.Abs(float64(got.Red)-float64(tt.want.Red)) > 2 ||
|
||||||
|
math.Abs(float64(got.Green)-float64(tt.want.Green)) > 2 ||
|
||||||
|
math.Abs(float64(got.Blue)-float64(tt.want.Blue)) > 2 {
|
||||||
|
t.Errorf("parseOklch() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLab(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want RGBA
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic lab",
|
||||||
|
input: "50 0 0",
|
||||||
|
want: RGBA{Red: 198, Green: 198, Blue: 198}, // light gray (LAB 50 is lighter than sRGB 50%)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "white",
|
||||||
|
input: "100 0 0",
|
||||||
|
want: RGBA{Red: 255, Green: 255, Blue: 255},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "black",
|
||||||
|
input: "0 0 0",
|
||||||
|
want: RGBA{Red: 0, Green: 0, Blue: 0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseLab(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("parseLab() error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Allow some tolerance due to rounding
|
||||||
|
if math.Abs(float64(got.Red)-float64(tt.want.Red)) > 2 ||
|
||||||
|
math.Abs(float64(got.Green)-float64(tt.want.Green)) > 2 ||
|
||||||
|
math.Abs(float64(got.Blue)-float64(tt.want.Blue)) > 2 {
|
||||||
|
t.Errorf("parseLab() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseColor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "oklch format",
|
||||||
|
input: "oklch(0.5 0.1 180)",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lab format",
|
||||||
|
input: "lab(50 0 0)",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with inline comment",
|
||||||
|
input: "oklch(0.5 0.1 180) # vibrant color",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hex color",
|
||||||
|
input: "#ff0000",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseColor(tt.input)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseColor() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -148,6 +148,22 @@ func parse_rgb(color string) (ans RGBA, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseColor(color string) (RGBA, error) {
|
func ParseColor(color string) (RGBA, error) {
|
||||||
|
// Strip inline comments (e.g., "oklch(...) # comment")
|
||||||
|
// For hex colors like "#ff0000", preserve the first #, but strip comments after spaces
|
||||||
|
color = strings.TrimSpace(color)
|
||||||
|
if strings.HasPrefix(color, "#") {
|
||||||
|
// For hex colors, only strip comments after whitespace
|
||||||
|
parts := strings.Fields(color)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
color = parts[0] // Keep only the hex color part
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-hex colors, strip everything after #
|
||||||
|
if idx := strings.Index(color, "#"); idx >= 0 {
|
||||||
|
color = strings.TrimSpace(color[:idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
raw := strings.TrimSpace(strings.ToLower(color))
|
raw := strings.TrimSpace(strings.ToLower(color))
|
||||||
if val, ok := ColorNames[raw]; ok {
|
if val, ok := ColorNames[raw]; ok {
|
||||||
return val, nil
|
return val, nil
|
||||||
|
|
@ -155,6 +171,12 @@ func ParseColor(color string) (RGBA, error) {
|
||||||
if strings.HasPrefix(raw, "#") {
|
if strings.HasPrefix(raw, "#") {
|
||||||
return parse_sharp(raw[1:])
|
return parse_sharp(raw[1:])
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(raw, "oklch(") {
|
||||||
|
return parseOklch(raw[6:])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "lab(") {
|
||||||
|
return parseLab(raw[4:])
|
||||||
|
}
|
||||||
if strings.HasPrefix(raw, "rgb:") {
|
if strings.HasPrefix(raw, "rgb:") {
|
||||||
return parse_rgb(raw[4:])
|
return parse_rgb(raw[4:])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue