diff --git a/docs/changelog.rst b/docs/changelog.rst
index 51e09a171..e45f21d57 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -157,6 +157,9 @@ Detailed list of changes
- Fix tab bar rendering glitches when using :opt:`tab_bar_filter` in some
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]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/wide-gamut-colors.rst b/docs/wide-gamut-colors.rst
new file mode 100644
index 000000000..7c551e7a3
--- /dev/null
+++ b/docs/wide-gamut-colors.rst
@@ -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 `_
+- `OKLCH Color Space `_
diff --git a/kitty/options/definition.py b/kitty/options/definition.py
index e7024b969..b885e8acc 100644
--- a/kitty/options/definition.py
+++ b/kitty/options/definition.py
@@ -3140,6 +3140,18 @@ opt('color255', '#eeeeee',
documented=False,
)
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() # }}}
diff --git a/kitty/rgb.py b/kitty/rgb.py
index 606d6291d..f0ddeccfa 100644
--- a/kitty/rgb.py
+++ b/kitty/rgb.py
@@ -1,6 +1,7 @@
#!/usr/bin/env python
# License: GPL v3 Copyright: 2017, Kovid Goyal
+import math
import re
from contextlib import suppress
@@ -41,7 +42,11 @@ def parse_rgb(spec: str) -> Color | None:
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:
@@ -67,8 +72,403 @@ 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 ((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:
# 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()
ans = color_names.get(x)
if ans is not None:
@@ -77,6 +477,10 @@ def to_color(raw: str, validate: bool = False) -> Color | None:
with suppress(Exception):
if raw.startswith('#'):
val = parse_sharp(raw[1:])
+ elif x.startswith('oklch('):
+ val = parse_oklch(x[6:])
+ elif x.startswith('lab('):
+ val = parse_lab(x[4:])
else:
k, sep, v = raw.partition(':')
if k == 'rgb':
diff --git a/kitty_tests/datatypes.py b/kitty_tests/datatypes.py
index fbe55e5cf..970a71b2f 100644
--- a/kitty_tests/datatypes.py
+++ b/kitty_tests/datatypes.py
@@ -77,6 +77,12 @@ class TestDataTypes(BaseTest):
c('rgb:23/45/67', 0x23, 0x45, 0x67)
c('rgb:abc/abc/def', 0xab, 0xab, 0xde)
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)
base = Color(12, 12, 12)
a = Color(23, 23, 23)
@@ -87,6 +93,155 @@ class TestDataTypes(BaseTest):
self.ae(Color(1, 2, 3, 4).as_sharp, '#04010203')
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):
old = filled_line_buf(2, 3, filled_cursor())
new = LineBuf(1, 3)
diff --git a/tools/utils/style/colorspaces.go b/tools/utils/style/colorspaces.go
new file mode 100644
index 000000000..e1cbff3e3
--- /dev/null
+++ b/tools/utils/style/colorspaces.go
@@ -0,0 +1,357 @@
+// License: GPLv3 Copyright: 2025, Kovid Goyal,
+
+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")
diff --git a/tools/utils/style/colorspaces_bench_test.go b/tools/utils/style/colorspaces_bench_test.go
new file mode 100644
index 000000000..2774ab960
--- /dev/null
+++ b/tools/utils/style/colorspaces_bench_test.go
@@ -0,0 +1,80 @@
+// License: GPLv3 Copyright: 2025, Kovid Goyal,
+
+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)
+ }
+ }
+}
diff --git a/tools/utils/style/colorspaces_test.go b/tools/utils/style/colorspaces_test.go
new file mode 100644
index 000000000..8b343bea4
--- /dev/null
+++ b/tools/utils/style/colorspaces_test.go
@@ -0,0 +1,126 @@
+// License: GPLv3 Copyright: 2025, Kovid Goyal,
+
+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)
+ }
+ })
+ }
+}
diff --git a/tools/utils/style/wrapper.go b/tools/utils/style/wrapper.go
index c45db683a..4cfcd485b 100644
--- a/tools/utils/style/wrapper.go
+++ b/tools/utils/style/wrapper.go
@@ -148,6 +148,22 @@ func parse_rgb(color string) (ans RGBA, err 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))
if val, ok := ColorNames[raw]; ok {
return val, nil
@@ -155,6 +171,12 @@ func ParseColor(color string) (RGBA, error) {
if strings.HasPrefix(raw, "#") {
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:") {
return parse_rgb(raw[4:])
}