// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// Bringing a colour into a gamut.
//
// CSS Color 4 §13.2 defines this as a chroma reduction in OkLCh with a
// just-noticeable-difference stop, rather than a clip: clipping turns a colour
// outside the gamut into the nearest point on the surface, which shifts its hue
// and flattens its light and dark ends; reducing chroma at constant lightness
// and hue keeps what the author asked for and gives up only what the display
// cannot show.
//
// The loop is a bisection on chroma. Where the plain reduction would still be
// too far from the original — more than a JND — the result is clipped instead,
// which is the "local-MINDE" step: it keeps a colour that cannot be reached by
// reducing chroma at all, such as a saturated blue in sRGB, from collapsing to
// grey.
///|
/// Whether a space has a bounded gamut at all. XYZ and the polar spaces do not:
/// every triple is a colour, and only the RGB spaces say which ones a display
/// can show.
pub fn Space::has_gamut(self : Space) -> Bool {
match self {
Srgb
| SrgbLinear
| DisplayP3
| DisplayP3Linear
| Rec2020
| A98Rgb
| ProPhoto => true
_ => false
}
}
///|
/// The largest amount by which a colour may leave the gamut and still count as
/// inside it. Matrices do not land on 1.0 exactly, and a colour that is 1e-17
/// over the edge is not out of gamut in any sense a viewer could see.
let gamut_epsilon : Double = 1.0e-6
///|
/// The difference below which two colours look the same: 0.02 in Oklab, which
/// is the just-noticeable difference CSS Color 4 uses for this algorithm.
let jnd : Double = 0.02
///|
/// The smallest chroma step worth taking; the bisection stops here.
let chroma_step : Double = 0.0001
///|
/// Whether the colour is inside the gamut of its space, or of `space` if one is
/// given.
///
/// A space with no gamut answers `true`: there is nothing for a colour to be
/// outside of.
pub fn Color::in_gamut(self : Color, space? : Space) -> Bool {
let target = space.unwrap_or(self.space)
let color = self.to_space(target)
match target {
Srgb
| SrgbLinear
| DisplayP3
| DisplayP3Linear
| Rec2020
| A98Rgb
| ProPhoto => {
let inside = fn(value : Double) {
// A missing component is not outside anything.
if value.is_nan() {
return true
}
value >= -gamut_epsilon && value <= 1.0 + gamut_epsilon
}
inside(color.x) && inside(color.y) && inside(color.z)
}
_ => true
}
}
///|
/// The colour with every component clamped into 0–1 of its space, which is what
/// a display does with a colour it cannot show.
///
/// Clipping is a blunt instrument: it moves a colour along whichever axis
/// overflows and leaves hue and lightness to follow. `to_gamut` is the answer
/// that keeps them; this is the answer that costs nothing, and the one to use
/// when the colour is already close to the edge.
pub fn Color::clip(self : Color, space? : Space) -> Color {
let target = space.unwrap_or(self.space)
let color = self.to_space(target)
if !target.has_gamut() {
return color
}
let clamp = fn(value : Double) {
if value.is_nan() {
value
} else if value < 0.0 {
0.0
} else if value > 1.0 {
1.0
} else {
value
}
}
color.with_components(clamp(color.x), clamp(color.y), clamp(color.z))
}
///|
/// The colour brought into the gamut of its space, or of `space` if one is
/// given, by CSS Color 4's chroma reduction.
///
/// A colour already inside comes back unchanged, in the space asked for. One
/// outside loses chroma at constant lightness and hue until it fits, and only in
/// the cases where that cannot work — a colour too light or too dark to exist at
/// all — falls back to clipping.
pub fn Color::to_gamut(self : Color, space? : Space) -> Color {
let target = space.unwrap_or(self.space)
let color = self.to_space(target)
if !target.has_gamut() || color.in_gamut() {
return color
}
let oklch = color.to_oklch()
// Nothing at either end of the lightness scale can be shown at all, and the
// bisection would spend its steps finding that out.
if oklch.x >= 1.0 {
return Color::white().to_space(target).with_alpha(color.alpha)
}
if oklch.x <= 0.0 {
return Color::black().to_space(target).with_alpha(color.alpha)
}
let mut low = 0.0
let mut high = oklch.y
let mut low_is_in_gamut = true
let mut current = color.clip()
while high - low > chroma_step {
let chroma = (low + high) / 2.0
let candidate = oklch.with_components(oklch.x, chroma, oklch.z)
let in_target = candidate.to_space(target)
if low_is_in_gamut && in_target.in_gamut() {
low = chroma
current = in_target
} else {
let clipped = in_target.clip()
let error = clipped.delta_e(in_target, delta=Ok)
if error < jnd {
if jnd - error < chroma_step {
return clipped.with_alpha(color.alpha)
}
// The clipped colour is closer to the candidate than a JND, so the
// candidate is inside after all; keep going up.
low_is_in_gamut = false
low = chroma
current = clipped
} else {
high = chroma
}
}
}
current.with_alpha(color.alpha)
}