culors 1.6.0

Rust port of the culori color library. Color spaces, CSS Color Module 4 parsing, interpolation, gamut mapping, ΔE, blending, filters.
Documentation
//! `clamp_gamut` (naïve per-channel clip) and `clamp_chroma` (chroma
//! bisection in an LCh-like space). Both mirror culori 4.0.2's `clamp.js`.

use crate::gamut::in_gamut::{
    color_to_a98, color_to_p3, color_to_prophoto, color_to_rec2020, color_to_rgb, in_gamut,
};
use crate::spaces::{Hsl, Hsv, Hwb, Lch, LinearRgb, Oklch, ProphotoRgb, Rec2020, Rgb, A98, P3};
use crate::Color;

/// Naïve per-channel clamp into the gamut named by `mode`.
///
/// Mirrors culori's `clampGamut(mode)`: convert the color to the gamut's
/// underlying RGB space, clamp each channel to `[0, 1]`, then convert back
/// to the source mode. For `mode` values without a gamut definition
/// (`lab`/`lch`/`oklab`/`oklch`/`xyz*`/`jab`/`jch`/`dlab`/`dlch`/`itp`/
/// `xyb`/`luv`/`lchuv`/`cubehelix`/`yiq`/`lab65`/`lch65`) the input is
/// returned as-is, matching culori's `if (!gamut) return color => prepare(color)`.
///
/// The output stays in the source mode (the variant of the input
/// `Color`). Hue is not preserved — per-channel clipping shifts both
/// chroma and hue.
pub fn clamp_gamut(color: Color, mode: &str) -> Color {
    match mode {
        // gamut: 'rgb' — sRGB cylindricals plus the cylindrical-look-alikes
        // (`hsi`, `okhsl`, `okhsv`) and the culors-only spaces that route
        // their gamut check through rgb.
        "rgb" | "hsl" | "hsv" | "hwb" | "hsi" | "okhsl" | "okhsv" | "hsluv" | "hpluv"
        | "prismatic" => {
            if in_gamut(&color, mode) {
                return color;
            }
            let clamped_rgb = clamp_rgb_channels(color_to_rgb(color));
            convert_rgb_back_to_source_mode(clamped_rgb, color)
        }
        // gamut: true on `lrgb` — clamp on linear-RGB channels.
        "lrgb" => {
            if in_gamut(&color, mode) {
                return color;
            }
            let v = match color {
                Color::LinearRgb(x) => x,
                other => crate::convert::convert::<crate::spaces::Xyz65, crate::spaces::LinearRgb>(
                    to_xyz65(other),
                ),
            };
            let clamped = crate::spaces::LinearRgb {
                r: clamp01(v.r),
                g: clamp01(v.g),
                b: clamp01(v.b),
                alpha: v.alpha,
            };
            // Same source mode short-circuit; otherwise round-trip via XYZ.
            if let Color::LinearRgb(_) = color {
                Color::LinearRgb(clamped)
            } else {
                let xyz = to_xyz65(Color::LinearRgb(clamped));
                from_xyz65_in_mode_of(xyz, color)
            }
        }
        "p3" => clamp_wide_gamut(color, mode, color_to_p3, |v| {
            Color::P3(P3 {
                r: clamp01(v.r),
                g: clamp01(v.g),
                b: clamp01(v.b),
                alpha: v.alpha,
            })
        }),
        "rec2020" => clamp_wide_gamut(color, mode, color_to_rec2020, |v| {
            Color::Rec2020(Rec2020 {
                r: clamp01(v.r),
                g: clamp01(v.g),
                b: clamp01(v.b),
                alpha: v.alpha,
            })
        }),
        "a98" => clamp_wide_gamut(color, mode, color_to_a98, |v| {
            Color::A98(A98 {
                r: clamp01(v.r),
                g: clamp01(v.g),
                b: clamp01(v.b),
                alpha: v.alpha,
            })
        }),
        "prophoto" => clamp_wide_gamut(color, mode, color_to_prophoto, |v| {
            Color::ProphotoRgb(ProphotoRgb {
                r: clamp01(v.r),
                g: clamp01(v.g),
                b: clamp01(v.b),
                alpha: v.alpha,
            })
        }),
        // No gamut definition in culori — pass through. Mirrors culori's
        // `if (!gamut) return color => prepare(color)`.
        "lab" | "lab65" | "lch" | "lch65" | "oklab" | "oklch" | "xyz50" | "xyz65" | "jab"
        | "jch" | "dlab" | "dlch" | "itp" | "xyb" | "luv" | "lchuv" | "cubehelix" | "yiq" => color,
        // Truly unknown — degrade through rgb rather than panic.
        _ => {
            if in_gamut(&color, "rgb") {
                return color;
            }
            let clamped_rgb = clamp_rgb_channels(color_to_rgb(color));
            convert_rgb_back_to_source_mode(clamped_rgb, color)
        }
    }
}

// Generic wide-gamut clamp helper. Converts the input to the destination
// gamut, returns the original if already in gamut, else clamps each
// channel to [0, 1] and converts back to the source mode. Mirrors
// culori's `clampGamut('p3' | 'rec2020' | …)`.
fn clamp_wide_gamut<T, F, C>(color: Color, mode: &str, to_dest: F, clamp_channels: C) -> Color
where
    F: Fn(Color) -> T,
    C: Fn(T) -> Color,
{
    if in_gamut(&color, mode) {
        return color;
    }
    let dest_color = clamp_channels(to_dest(color));
    convert_color_back_to_source_mode(dest_color, color)
}

// Convert `dest_color` (already in the wide-gamut destination space) back
// to the source `template`'s mode. Falls back to round-tripping through
// XYZ65 when the source mode differs from the destination.
fn convert_color_back_to_source_mode(dest_color: Color, template: Color) -> Color {
    if std::mem::discriminant(&dest_color) == std::mem::discriminant(&template) {
        return dest_color;
    }
    let xyz = to_xyz65(dest_color);
    crate::gamut::clamp::from_xyz65_in_mode_of(xyz, template)
}

fn clamp_rgb_channels(c: Rgb) -> Rgb {
    Rgb {
        // culori's `fixup_rgb`: `Math.max(0, Math.min(c.r !== undefined ? c.r
        // : 0, 1))`. A missing channel becomes `0`. Our typed struct can
        // carry NaN through the conversion stack; treat NaN as absent and
        // map it to `0` to match.
        r: clamp01(c.r),
        g: clamp01(c.g),
        b: clamp01(c.b),
        alpha: c.alpha,
    }
}

fn clamp01(v: f64) -> f64 {
    if v.is_nan() {
        0.0
    } else {
        v.clamp(0.0, 1.0)
    }
}

/// Convert an `Rgb` back to whatever mode `template` is in. We need the
/// source mode (not the source value), so the caller passes a representative
/// `Color` of that mode.
fn convert_rgb_back_to_source_mode(rgb: Rgb, template: Color) -> Color {
    match template {
        Color::Rgb(_) => Color::Rgb(rgb),
        Color::LinearRgb(_) => Color::LinearRgb(LinearRgb::from(rgb)),
        Color::Hsl(_) => Color::Hsl(Hsl::from(rgb)),
        Color::Hsv(_) => Color::Hsv(Hsv::from(rgb)),
        Color::Hwb(_) => Color::Hwb(Hwb::from(Hsv::from(rgb))),
        Color::Lab(_) => Color::Lab(rgb.into()),
        Color::Lab65(_) => Color::Lab65(rgb.into()),
        Color::Lch(_) => Color::Lch(rgb.into()),
        Color::Lch65(_) => Color::Lch65(rgb.into()),
        Color::Oklab(_) => Color::Oklab(rgb.into()),
        Color::Oklch(_) => Color::Oklch(rgb.into()),
        Color::Xyz50(_) => Color::Xyz50(crate::convert(rgb)),
        Color::Xyz65(_) => Color::Xyz65(crate::convert(rgb)),
        Color::P3(_) => Color::P3(crate::convert(rgb)),
        Color::Rec2020(_) => Color::Rec2020(crate::convert(rgb)),
        Color::A98(_) => Color::A98(crate::convert(rgb)),
        Color::ProphotoRgb(_) => Color::ProphotoRgb(crate::convert(rgb)),
        Color::Cubehelix(_) => Color::Cubehelix(rgb.into()),
        Color::Dlab(_) => Color::Dlab(crate::convert(rgb)),
        Color::Dlch(_) => Color::Dlch(crate::convert(rgb)),
        Color::Jab(_) => Color::Jab(crate::convert(rgb)),
        Color::Jch(_) => Color::Jch(crate::convert(rgb)),
        Color::Yiq(_) => Color::Yiq(rgb.into()),
        Color::Hsi(_) => Color::Hsi(rgb.into()),
        Color::Hsluv(_) => Color::Hsluv(rgb.into()),
        Color::Hpluv(_) => Color::Hpluv(rgb.into()),
        Color::Okhsl(_) => Color::Okhsl(rgb.into()),
        Color::Okhsv(_) => Color::Okhsv(rgb.into()),
        Color::Itp(_) => Color::Itp(crate::convert(rgb)),
        Color::Xyb(_) => Color::Xyb(rgb.into()),
        Color::Luv(_) => Color::Luv(rgb.into()),
        Color::Lchuv(_) => Color::Lchuv(rgb.into()),
        Color::Prismatic(_) => Color::Prismatic(rgb.into()),
    }
}

/// Clamp the chroma of `color` until the result fits inside the sRGB gamut.
///
/// Mirrors culori's `clampChroma(color, mode)` with `rgbGamut = 'rgb'` (the
/// only value culori uses internally). `mode` is the LCh-like space used
/// for the bisection; pass `"lch"` for CIELCh or `"oklch"` for OkLCh.
///
/// When the input is already displayable, it is returned unchanged. When
/// even chroma = 0 is not displayable (a degenerate L outside `[0, 1]` /
/// `[0, 100]` for OkLCh / Lch) the function falls back to per-channel sRGB
/// clipping, matching culori.
///
/// The result is returned in the source mode of `color`.
pub fn clamp_chroma(color: Color, mode: &str) -> Color {
    if in_gamut(&color, "rgb") {
        return color;
    }

    // Bisect in `mode`. culori limits this to LCh-like spaces; we do the
    // same via the explicit match. Other mode strings are an error.
    let (start_l, start_c, start_h, alpha, range_max) = match mode {
        "lch" => {
            let lch: Lch = match color {
                Color::Lch(x) => x,
                _ => crate::convert::<crate::spaces::Xyz65, Lch>(to_xyz65(color)),
            };
            (lch.l, lch.c, lch.h, lch.alpha, 150.0_f64)
        }
        "oklch" => {
            let oklch: Oklch = match color {
                Color::Oklch(x) => x,
                _ => {
                    use crate::traits::ColorSpace;
                    Oklch::from(crate::spaces::Oklab::from_xyz65(to_xyz65(color)))
                }
            };
            (oklch.l, oklch.c, oklch.h, oklch.alpha, 0.4_f64)
        }
        other => panic!("clamp_chroma: mode must be 'lch' or 'oklch', got '{other}'"),
    };

    // culori's `resolution = (range_max - range_min) / 2^13`. Both lch and
    // oklch have `range_min = 0`, so `resolution = range_max / 8192`.
    const ITER_DENOM: f64 = 8192.0;
    let resolution = range_max / ITER_DENOM;

    // Try chroma = 0 first. If even that is out of gamut, fall back to
    // per-channel rgb clipping of the chroma-zero color.
    let mut clamped = make_polar(mode, start_l, 0.0, start_h, alpha);
    if !in_gamut(&clamped, "rgb") {
        let rgb = clamp_rgb_channels(color_to_rgb(clamped));
        return convert_rgb_back_to_source_mode(rgb, color);
    }

    // Bisect: `start` is the largest chroma known to be in gamut, `end` is
    // the largest chroma known to be out.
    let mut start = 0.0;
    let mut end = if start_c.is_nan() { 0.0 } else { start_c };
    let mut last_good_c = 0.0;
    while end - start > resolution {
        let mid = start + (end - start) * 0.5;
        clamped = make_polar(mode, start_l, mid, start_h, alpha);
        if in_gamut(&clamped, "rgb") {
            last_good_c = mid;
            start = mid;
        } else {
            end = mid;
        }
    }

    // After the loop, `clamped` carries the last tested chroma. If that
    // chroma is in gamut, return as-is; otherwise back off to the last
    // known-good chroma. Mirrors culori's final ternary.
    let final_color = if in_gamut(&clamped, "rgb") {
        clamped
    } else {
        make_polar(mode, start_l, last_good_c, start_h, alpha)
    };

    // Convert the result back to the source mode.
    convert_polar_to_source_mode(final_color, color)
}

fn make_polar(mode: &str, l: f64, c: f64, h: f64, alpha: Option<f64>) -> Color {
    match mode {
        "lch" => Color::Lch(Lch { l, c, h, alpha }),
        "oklch" => Color::Oklch(Oklch { l, c, h, alpha }),
        _ => unreachable!(),
    }
}

fn convert_polar_to_source_mode(polar: Color, template: Color) -> Color {
    // Same source mode → return as-is.
    if std::mem::discriminant(&polar) == std::mem::discriminant(&template) {
        return polar;
    }
    // Otherwise fall through `XYZ65`. For the `Lab`/`Oklab` ↔ `Lch`/`Oklch`
    // sub-cases we could shave a step, but every other case routes through
    // the hub anyway.
    let xyz = to_xyz65(polar);
    from_xyz65_in_mode_of(xyz, template)
}

pub(crate) fn to_xyz65(c: Color) -> crate::spaces::Xyz65 {
    use crate::traits::ColorSpace;
    match c {
        Color::Rgb(x) => x.to_xyz65(),
        Color::LinearRgb(x) => x.to_xyz65(),
        Color::Hsl(x) => x.to_xyz65(),
        Color::Hsv(x) => x.to_xyz65(),
        Color::Hwb(x) => x.to_xyz65(),
        Color::Lab(x) => x.to_xyz65(),
        Color::Lab65(x) => x.to_xyz65(),
        Color::Lch(x) => x.to_xyz65(),
        Color::Lch65(x) => x.to_xyz65(),
        Color::Oklab(x) => x.to_xyz65(),
        Color::Oklch(x) => x.to_xyz65(),
        Color::Xyz50(x) => x.to_xyz65(),
        Color::Xyz65(x) => x,
        Color::P3(x) => x.to_xyz65(),
        Color::Rec2020(x) => x.to_xyz65(),
        Color::A98(x) => x.to_xyz65(),
        Color::ProphotoRgb(x) => x.to_xyz65(),
        Color::Cubehelix(x) => x.to_xyz65(),
        Color::Dlab(x) => x.to_xyz65(),
        Color::Dlch(x) => x.to_xyz65(),
        Color::Jab(x) => x.to_xyz65(),
        Color::Jch(x) => x.to_xyz65(),
        Color::Yiq(x) => x.to_xyz65(),
        Color::Hsi(x) => x.to_xyz65(),
        Color::Hsluv(x) => x.to_xyz65(),
        Color::Hpluv(x) => x.to_xyz65(),
        Color::Okhsl(x) => x.to_xyz65(),
        Color::Okhsv(x) => x.to_xyz65(),
        Color::Itp(x) => x.to_xyz65(),
        Color::Xyb(x) => x.to_xyz65(),
        Color::Luv(x) => x.to_xyz65(),
        Color::Lchuv(x) => x.to_xyz65(),
        Color::Prismatic(x) => x.to_xyz65(),
    }
}

fn from_xyz65_in_mode_of(xyz: crate::spaces::Xyz65, template: Color) -> Color {
    use crate::traits::ColorSpace;
    match template {
        Color::Rgb(_) => Color::Rgb(Rgb::from_xyz65(xyz)),
        Color::LinearRgb(_) => Color::LinearRgb(LinearRgb::from_xyz65(xyz)),
        Color::Hsl(_) => Color::Hsl(Hsl::from_xyz65(xyz)),
        Color::Hsv(_) => Color::Hsv(Hsv::from_xyz65(xyz)),
        Color::Hwb(_) => Color::Hwb(Hwb::from_xyz65(xyz)),
        Color::Lab(_) => Color::Lab(crate::spaces::Lab::from_xyz65(xyz)),
        Color::Lab65(_) => Color::Lab65(crate::spaces::Lab65::from_xyz65(xyz)),
        Color::Lch(_) => Color::Lch(Lch::from_xyz65(xyz)),
        Color::Lch65(_) => Color::Lch65(crate::spaces::Lch65::from_xyz65(xyz)),
        Color::Oklab(_) => Color::Oklab(crate::spaces::Oklab::from_xyz65(xyz)),
        Color::Oklch(_) => Color::Oklch(Oklch::from_xyz65(xyz)),
        Color::Xyz50(_) => Color::Xyz50(crate::spaces::Xyz50::from_xyz65(xyz)),
        Color::Xyz65(_) => Color::Xyz65(xyz),
        Color::P3(_) => Color::P3(crate::spaces::P3::from_xyz65(xyz)),
        Color::Rec2020(_) => Color::Rec2020(crate::spaces::Rec2020::from_xyz65(xyz)),
        Color::A98(_) => Color::A98(crate::spaces::A98::from_xyz65(xyz)),
        Color::ProphotoRgb(_) => Color::ProphotoRgb(crate::spaces::ProphotoRgb::from_xyz65(xyz)),
        Color::Cubehelix(_) => Color::Cubehelix(crate::spaces::Cubehelix::from_xyz65(xyz)),
        Color::Dlab(_) => Color::Dlab(crate::spaces::Dlab::from_xyz65(xyz)),
        Color::Dlch(_) => Color::Dlch(crate::spaces::Dlch::from_xyz65(xyz)),
        Color::Jab(_) => Color::Jab(crate::spaces::Jab::from_xyz65(xyz)),
        Color::Jch(_) => Color::Jch(crate::spaces::Jch::from_xyz65(xyz)),
        Color::Yiq(_) => Color::Yiq(crate::spaces::Yiq::from_xyz65(xyz)),
        Color::Hsi(_) => Color::Hsi(crate::spaces::Hsi::from_xyz65(xyz)),
        Color::Hsluv(_) => Color::Hsluv(crate::spaces::Hsluv::from_xyz65(xyz)),
        Color::Hpluv(_) => Color::Hpluv(crate::spaces::Hpluv::from_xyz65(xyz)),
        Color::Okhsl(_) => Color::Okhsl(crate::spaces::Okhsl::from_xyz65(xyz)),
        Color::Okhsv(_) => Color::Okhsv(crate::spaces::Okhsv::from_xyz65(xyz)),
        Color::Itp(_) => Color::Itp(crate::spaces::Itp::from_xyz65(xyz)),
        Color::Xyb(_) => Color::Xyb(crate::spaces::Xyb::from_xyz65(xyz)),
        Color::Luv(_) => Color::Luv(crate::spaces::Luv::from_xyz65(xyz)),
        Color::Lchuv(_) => Color::Lchuv(crate::spaces::Lchuv::from_xyz65(xyz)),
        Color::Prismatic(_) => Color::Prismatic(crate::spaces::Prismatic::from_xyz65(xyz)),
    }
}