// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

// Conversions between colour spaces.
//
// Every route runs through one of two hubs: D65 XYZ for the spaces defined by
// primaries, and sRGB for the ones defined by a hue. Two hubs rather than one
// because HSL, HSV, HWB and YCbCr are defined on gamma-encoded sRGB itself, and
// routing them through XYZ would add two transfer-curve round trips to every
// conversion.
//
// The matrices, the transfer curves and the white points are the ones CSS
// Color 4 prints in its sample code, to the last digit, so that a value here
// and a value from that code agree to the bit rather than to five places.
//
// Nothing here clips. A colour outside its space's gamut keeps its value, and
// `to_gamut` is where the decision about what to do with it is made.

///|
/// A missing component behaves as zero in conversion (CSS Color 4 §12.2).
fn or_zero(value : Double) -> Double {
  if value.is_nan() {
    0.0
  } else {
    value
  }
}

///|
fn clamp01(value : Double) -> Double {
  if value < 0.0 {
    0.0
  } else if value > 1.0 {
    1.0
  } else {
    value
  }
}

///|
/// Degrees brought into 0–360, which every hue is before it is used.
fn hue_of(value : Double) -> Double {
  let wrapped = or_zero(value) % 360.0
  if wrapped < 0.0 {
    wrapped + 360.0
  } else {
    wrapped
  }
}

///|
/// The extended sRGB transfer curve: below the linear segment the sign is
/// carried through, so that a component outside 0–1 still means something.
fn srgb_decode(value : Double) -> Double {
  let magnitude = value.abs()
  let sign = if value < 0.0 { -1.0 } else { 1.0 }
  if magnitude <= 0.04045 {
    value / 12.92
  } else {
    sign * @math.pow((magnitude + 0.055) / 1.055, 2.4)
  }
}

///|
fn srgb_encode(value : Double) -> Double {
  let magnitude = value.abs()
  let sign = if value < 0.0 { -1.0 } else { 1.0 }
  if magnitude > 0.0031308 {
    sign * (1.055 * @math.pow(magnitude, 1.0 / 2.4) - 0.055)
  } else {
    12.92 * value
  }
}

///|
/// ProPhoto's curve: gamma 1.8 with a linear toe of 1/512.
fn prophoto_decode(value : Double) -> Double {
  let magnitude = value.abs()
  let sign = if value < 0.0 { -1.0 } else { 1.0 }
  if magnitude <= 16.0 / 512.0 {
    value / 16.0
  } else {
    sign * @math.pow(magnitude, 1.8)
  }
}

///|
fn prophoto_encode(value : Double) -> Double {
  let magnitude = value.abs()
  let sign = if value < 0.0 { -1.0 } else { 1.0 }
  if magnitude >= 1.0 / 512.0 {
    sign * @math.pow(magnitude, 1.0 / 1.8)
  } else {
    16.0 * value
  }
}

///|
/// Rec. 2020's curve is BT.1886 with no black lift, which is a pure 2.4 power.
fn rec2020_decode(value : Double) -> Double {
  let magnitude = value.abs()
  let sign = if value < 0.0 { -1.0 } else { 1.0 }
  sign * @math.pow(magnitude, 2.4)
}

///|
fn rec2020_encode(value : Double) -> Double {
  let magnitude = value.abs()
  let sign = if value < 0.0 { -1.0 } else { 1.0 }
  sign * @math.pow(magnitude, 1.0 / 2.4)
}

///|
/// Adobe RGB's curve is a pure 563/256 power, which is close to but not 2.2.
fn a98_decode(value : Double) -> Double {
  let magnitude = value.abs()
  let sign = if value < 0.0 { -1.0 } else { 1.0 }
  sign * @math.pow(magnitude, 563.0 / 256.0)
}

///|
fn a98_encode(value : Double) -> Double {
  let magnitude = value.abs()
  let sign = if value < 0.0 { -1.0 } else { 1.0 }
  sign * @math.pow(magnitude, 256.0 / 563.0)
}

// Linear light to D65 XYZ, one function per space. Written out rather than kept
// in a table so that each number stays next to the space it belongs to.

///|
fn lin_srgb_to_xyz(
  r : Double,
  g : Double,
  b : Double,
) -> (Double, Double, Double) {
  (
    0.4123907992659595 * r + 0.35758433938387796 * g + 0.1804807884018343 * b,
    0.21263900587151036 * r + 0.7151686787677559 * g + 0.07219231536073371 * b,
    0.01933081871559185 * r + 0.11919477979462599 * g + 0.9505321522496606 * b,
  )
}

///|
fn xyz_to_lin_srgb(
  x : Double,
  y : Double,
  z : Double,
) -> (Double, Double, Double) {
  (
    3.2409699419045213 * x - 1.5373831775700935 * y - 0.4986107602930033 * z,
    -0.9692436362808798 * x + 1.8759675015077206 * y + 0.04155505740717561 * z,
    0.05563007969699361 * x - 0.20397695888897657 * y + 1.0569715142428786 * z,
  )
}

///|
fn lin_p3_to_xyz(
  r : Double,
  g : Double,
  b : Double,
) -> (Double, Double, Double) {
  (
    0.48657094864821626 * r + 0.26566769316909294 * g + 0.1982172852343625 * b,
    0.22897456406974884 * r + 0.6917385218365062 * g + 0.079286914093745 * b,
    0.045113381858902575 * g + 1.0439443689009757 * b,
  )
}

///|
fn xyz_to_lin_p3(
  x : Double,
  y : Double,
  z : Double,
) -> (Double, Double, Double) {
  (
    2.4934969119414245 * x - 0.9313836179191236 * y - 0.40271078445071684 * z,
    -0.829488969561575 * x + 1.7626640603183468 * y + 0.02362468584194359 * z,
    0.035845830243784335 * x - 0.07617238926804171 * y + 0.9568845240076873 * z,
  )
}

///|
fn lin_2020_to_xyz(
  r : Double,
  g : Double,
  b : Double,
) -> (Double, Double, Double) {
  (
    0.6369580483012913 * r + 0.14461690358620838 * g + 0.16888097516417205 * b,
    0.26270021201126703 * r + 0.677998071518871 * g + 0.059301716469861945 * b,
    0.028072693049087508 * g + 1.0609850577107909 * b,
  )
}

///|
fn xyz_to_lin_2020(
  x : Double,
  y : Double,
  z : Double,
) -> (Double, Double, Double) {
  (
    1.7166511879712676 * x - 0.3556707837763924 * y - 0.2533662813736598 * z,
    -0.666684351832489 * x + 1.616481236634939 * y + 0.01576854581391113 * z,
    0.017639857445310915 * x - 0.042770613257808655 * y + 0.942103121235474 * z,
  )
}

///|
fn lin_a98_to_xyz(
  r : Double,
  g : Double,
  b : Double,
) -> (Double, Double, Double) {
  (
    0.5766690429101308 * r + 0.18555823790654627 * g + 0.18822864623499472 * b,
    0.29734497525053616 * r + 0.627363566255466 * g + 0.07529145849399789 * b,
    0.027031361386412378 * r + 0.07068885253582714 * g + 0.9913375368376389 * b,
  )
}

///|
fn xyz_to_lin_a98(
  x : Double,
  y : Double,
  z : Double,
) -> (Double, Double, Double) {
  (
    2.041587903810746 * x - 0.5650069742788596 * y - 0.3447313507783295 * z,
    -0.9692436362808798 * x + 1.8759675015077206 * y + 0.04155505740717561 * z,
    0.013444280632031024 * x - 0.11836239223101824 * y + 1.0151749943912054 * z,
  )
}

///|
fn lin_prophoto_to_xyz(
  r : Double,
  g : Double,
  b : Double,
) -> (Double, Double, Double) {
  (
    0.7977666449006423 * r + 0.13518129740053308 * g + 0.0313477341283922 * b,
    0.2880748288194013 * r + 0.711835234241873 * g + 0.00008993693872564 * b,
    0.8251046025104602 * b,
  )
}

///|
fn xyz_to_lin_prophoto(
  x : Double,
  y : Double,
  z : Double,
) -> (Double, Double, Double) {
  (
    1.3457868816471583 * x - 0.25557208737979464 * y - 0.05110186497554526 * z,
    -0.5446307051249019 * x + 1.5082477428451468 * y + 0.02052744743642139 * z,
    1.2119675456389452 * z,
  )
}

///|
/// Bradford chromatic adaptation, D65 to D50. Lab and ProPhoto are defined
/// against D50, everything else against D65, so this is on every route between
/// the two halves.
fn d65_to_d50(x : Double, y : Double, z : Double) -> (Double, Double, Double) {
  (
    1.0479297925449969 * x + 0.022946870601609652 * y - 0.05019226628920524 * z,
    0.02962780877005599 * x + 0.9904344267538799 * y - 0.017073799063418826 * z,
    -0.009243040646204504 * x +
    0.015055191490298152 * y +
    0.7518742814281371 * z,
  )
}

///|
fn d50_to_d65(x : Double, y : Double, z : Double) -> (Double, Double, Double) {
  (
    0.955473421488075 * x - 0.02309845494876471 * y + 0.06325924320057072 * z,
    -0.0283697093338637 * x + 1.0099953980813041 * y + 0.021041441191917323 * z,
    0.012314014864481998 * x - 0.020507649298898964 * y + 1.330365926242124 * z,
  )
}

///|
/// The D50 white the Lab and ProPhoto conversions divide by.
let d50 : (Double, Double, Double) = (
  0.9642956764295677, 1.0, 0.8251046025104602,
)

///|
fn xyz_d50_to_lab(
  x : Double,
  y : Double,
  z : Double,
) -> (Double, Double, Double) {
  let epsilon = 216.0 / 24389.0
  let kappa = 24389.0 / 27.0
  let fx = {
    let v = x / d50.0
    if v > epsilon {
      @math.cbrt(v)
    } else {
      (kappa * v + 16.0) / 116.0
    }
  }
  let fy = {
    let v = y / d50.1
    if v > epsilon {
      @math.cbrt(v)
    } else {
      (kappa * v + 16.0) / 116.0
    }
  }
  let fz = {
    let v = z / d50.2
    if v > epsilon {
      @math.cbrt(v)
    } else {
      (kappa * v + 16.0) / 116.0
    }
  }
  (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
}

///|
fn lab_to_xyz_d50(
  l : Double,
  a : Double,
  b : Double,
) -> (Double, Double, Double) {
  let kappa = 24389.0 / 27.0
  let epsilon = 216.0 / 24389.0
  // Keeping L out of L + 16 keeps precision near black.
  let lightness = l / 116.0
  let fy = lightness + 16.0 / 116.0
  let fx = fy + a / 500.0
  let fz = fy - b / 200.0
  let x = if fx * fx * fx > epsilon {
    fx * fx * fx
  } else {
    (lightness + a / 500.0) * (116.0 / kappa)
  }
  let y = if l > kappa * epsilon { fy * fy * fy } else { l / kappa }
  let z = if fz * fz * fz > epsilon {
    fz * fz * fz
  } else {
    (lightness - b / 200.0) * (116.0 / kappa)
  }
  (x * d50.0, y * d50.1, z * d50.2)
}

///|
fn xyz_d65_to_oklab(
  x : Double,
  y : Double,
  z : Double,
) -> (Double, Double, Double) {
  let l = 0.819022437996703 * x +
    0.3619062600528904 * y -
    0.1288737815209879 * z
  let m = 0.0329836539323885 * x +
    0.9292868615863434 * y +
    0.0361446663506424 * z
  let s = 0.0481771893596242 * x +
    0.2642395317527308 * y +
    0.6335478284694309 * z
  let l = @math.cbrt(l)
  let m = @math.cbrt(m)
  let s = @math.cbrt(s)
  (
    0.210454268309314 * l + 0.7936177747023054 * m - 0.0040720430116193 * s,
    1.9779985324311684 * l - 2.4285922420485799 * m + 0.450593709617411 * s,
    0.0259040424655478 * l + 0.7827717124575296 * m - 0.8086757549230774 * s,
  )
}

///|
fn oklab_to_xyz_d65(
  l : Double,
  a : Double,
  b : Double,
) -> (Double, Double, Double) {
  let long = l + 0.3963377773761749 * a + 0.2158037573099136 * b
  let medium = l - 0.1055613458156586 * a - 0.0638541728258133 * b
  let short = l - 0.0894841775298119 * a - 1.2914855480194092 * b
  let long = long * long * long
  let medium = medium * medium * medium
  let short = short * short * short
  (
    1.2268798758459243 * long -
    0.5578149944602171 * medium +
    0.2813910456659647 * short,
    -0.0405757452148008 * long +
    1.112286803280317 * medium -
    0.0717110580655164 * short,
    -0.0763729366746601 * long -
    0.4214933324022432 * medium +
    1.5869240198367816 * short,
  )
}

///|
/// The chroma below which a hue is meaningless, so that a grey reports no hue
/// at all rather than the hue of rounding noise.
fn polar_of(a : Double, b : Double, epsilon : Double) -> (Double, Double) {
  let chroma = @math.hypot(a, b)
  let hue = if chroma <= epsilon {
    @double.not_a_number
  } else {
    let degrees = @math.atan2(b, a) * 180.0 / @math.PI
    if degrees < 0.0 {
      degrees + 360.0
    } else {
      degrees
    }
  }
  (chroma, hue)
}

///|
fn max3(a : Double, b : Double, c : Double) -> Double {
  let largest = if a > b { a } else { b }
  if c > largest {
    c
  } else {
    largest
  }
}

///|
fn min3(a : Double, b : Double, c : Double) -> Double {
  let smallest = if a < b { a } else { b }
  if c < smallest {
    c
  } else {
    smallest
  }
}

///|
/// HSL to sRGB, as CSS Color 4 writes it: the hue picks a point on the
/// lightness-dependent hexagon, the saturation the distance from the grey axis.
fn hsl_to_srgb(h : Double, s : Double, l : Double) -> (Double, Double, Double) {
  let h = hue_of(h)
  let s = clamp01(or_zero(s))
  let l = clamp01(or_zero(l))
  let a = s * (if l < 0.5 { l } else { 1.0 - l })
  let channel : (Double) -> Double = fn(n) {
    let k = (n + h / 30.0) % 12.0
    let m = if k - 3.0 < 9.0 - k { k - 3.0 } else { 9.0 - k }
    let m = if m < 1.0 { m } else { 1.0 }
    let m = if m > -1.0 { m } else { -1.0 }
    l - a * m
  }
  (channel(0.0), channel(8.0), channel(4.0))
}

///|
fn srgb_to_hsl(r : Double, g : Double, b : Double) -> (Double, Double, Double) {
  let maximum = max3(r, g, b)
  let minimum = min3(r, g, b)
  let lightness = (minimum + maximum) / 2.0
  let delta = maximum - minimum
  // A grey has no hue, and saying so is better than saying zero: a hue of zero
  // is red, and interpolating towards it from a real hue would swing through it.
  if delta == 0.0 {
    return (@double.not_a_number, 0.0, lightness)
  }
  let half = if lightness < 0.5 { lightness } else { 1.0 - lightness }
  let mut saturation = if lightness == 0.0 || lightness == 1.0 {
    0.0
  } else {
    (maximum - lightness) / half
  }
  let mut hue = if maximum == r {
    (g - b) / delta + (if g < b { 6.0 } else { 0.0 })
  } else if maximum == g {
    (b - r) / delta + 2.0
  } else {
    (r - g) / delta + 4.0
  }
  hue = hue * 60.0
  if saturation < 0.0 {
    hue = hue + 180.0
    saturation = -saturation
  }
  if hue >= 360.0 {
    hue = hue - 360.0
  }
  (hue, saturation, lightness)
}

///|
fn hsv_to_srgb(h : Double, s : Double, v : Double) -> (Double, Double, Double) {
  let s = clamp01(or_zero(s))
  let v = clamp01(or_zero(v))
  // The fully saturated colour at this hue, scaled down to a value and lifted
  // by the chroma the pair leaves over.
  let (r, g, b) = hsl_to_srgb(h, 1.0, 0.5)
  let chroma = v * s
  let lift = v - chroma
  (r * chroma + lift, g * chroma + lift, b * chroma + lift)
}

///|
fn srgb_to_hsv(r : Double, g : Double, b : Double) -> (Double, Double, Double) {
  let maximum = max3(r, g, b)
  let minimum = min3(r, g, b)
  let delta = maximum - minimum
  let saturation = if maximum == 0.0 { 0.0 } else { delta / maximum }
  let hue = if delta == 0.0 {
    @double.not_a_number
  } else if maximum == r {
    ((g - b) / delta + (if g < b { 6.0 } else { 0.0 })) * 60.0
  } else if maximum == g {
    ((b - r) / delta + 2.0) * 60.0
  } else {
    ((r - g) / delta + 4.0) * 60.0
  }
  (hue, saturation, maximum)
}

///|
fn hwb_to_srgb(h : Double, w : Double, b : Double) -> (Double, Double, Double) {
  let w = clamp01(or_zero(w))
  let b = clamp01(or_zero(b))
  if w + b >= 1.0 {
    let grey = w / (w + b)
    return (grey, grey, grey)
  }
  let (r, g, bl) = hsl_to_srgb(h, 1.0, 0.5)
  let scale = 1.0 - w - b
  (r * scale + w, g * scale + w, bl * scale + w)
}

///|
fn srgb_to_hwb(r : Double, g : Double, b : Double) -> (Double, Double, Double) {
  let (hue, _, _) = srgb_to_hsl(r, g, b)
  (hue, min3(r, g, b), 1.0 - max3(r, g, b))
}

///|
/// The luma coefficients of a `YCbCr` space: red, green, blue weights.
fn luma_weights(luma : Luma) -> (Double, Double, Double) {
  match luma {
    Bt601 => (0.299, 0.587, 0.114)
    Bt709 => (0.2126, 0.7152, 0.0722)
    Bt2020 => (0.2627, 0.678, 0.0593)
  }
}

///|
fn srgb_to_ycbcr(
  r : Double,
  g : Double,
  b : Double,
  luma : Luma,
) -> (Double, Double, Double) {
  let (kr, kg, kb) = luma_weights(luma)
  let y = kr * r + kg * g + kb * b
  let cb = (b - y) / (2.0 * (1.0 - kb))
  let cr = (r - y) / (2.0 * (1.0 - kr))
  (y, cb, cr)
}

///|
fn ycbcr_to_srgb(
  y : Double,
  cb : Double,
  cr : Double,
  luma : Luma,
) -> (Double, Double, Double) {
  let (kr, kg, kb) = luma_weights(luma)
  let r = y + 2.0 * (1.0 - kr) * cr
  let b = y + 2.0 * (1.0 - kb) * cb
  let g = (y - kr * r - kb * b) / kg
  (r, g, b)
}

///|
/// The colour as linear-light sRGB, the form luma is defined on. Everything
/// that needs linear light for a different space goes through here or through
/// `xyz_d65`, never both.
fn Color::linear_srgb(self : Color) -> (Double, Double, Double) {
  match self.space {
    Srgb => (srgb_decode(self.x), srgb_decode(self.y), srgb_decode(self.z))
    SrgbLinear => (self.x, self.y, self.z)
    Hsl => {
      let (r, g, b) = hsl_to_srgb(self.x, self.y, self.z)
      (srgb_decode(r), srgb_decode(g), srgb_decode(b))
    }
    Hsv => {
      let (r, g, b) = hsv_to_srgb(self.x, self.y, self.z)
      (srgb_decode(r), srgb_decode(g), srgb_decode(b))
    }
    Hwb => {
      let (r, g, b) = hwb_to_srgb(self.x, self.y, self.z)
      (srgb_decode(r), srgb_decode(g), srgb_decode(b))
    }
    YCbCr(luma) => {
      let (r, g, b) = ycbcr_to_srgb(self.x, self.y, self.z, luma)
      (srgb_decode(r), srgb_decode(g), srgb_decode(b))
    }
    _ => {
      let (x, y, z) = self.xyz65()
      xyz_to_lin_srgb(x, y, z)
    }
  }
}

///|
/// The colour in D65 XYZ, the hub every primaries-based space is reached from.
fn Color::xyz65(self : Color) -> (Double, Double, Double) {
  match self.space {
    XyzD65 => (self.x, self.y, self.z)
    XyzD50 => d50_to_d65(self.x, self.y, self.z)
    Lab => {
      let (x, y, z) = lab_to_xyz_d50(
        or_zero(self.x),
        or_zero(self.y),
        or_zero(self.z),
      )
      d50_to_d65(x, y, z)
    }
    Lch => {
      let (l, c, h) = (or_zero(self.x), or_zero(self.y), hue_of(self.z))
      let radians = h * @math.PI / 180.0
      let (x, y, z) = lab_to_xyz_d50(
        l,
        c * @math.cos(radians),
        c * @math.sin(radians),
      )
      d50_to_d65(x, y, z)
    }
    Oklab => oklab_to_xyz_d65(or_zero(self.x), or_zero(self.y), or_zero(self.z))
    Oklch => {
      let (l, c, h) = (or_zero(self.x), or_zero(self.y), hue_of(self.z))
      let radians = h * @math.PI / 180.0
      oklab_to_xyz_d65(l, c * @math.cos(radians), c * @math.sin(radians))
    }
    DisplayP3 =>
      lin_p3_to_xyz(
        srgb_decode(or_zero(self.x)),
        srgb_decode(or_zero(self.y)),
        srgb_decode(or_zero(self.z)),
      )
    DisplayP3Linear =>
      lin_p3_to_xyz(or_zero(self.x), or_zero(self.y), or_zero(self.z))
    Rec2020 =>
      lin_2020_to_xyz(
        rec2020_decode(or_zero(self.x)),
        rec2020_decode(or_zero(self.y)),
        rec2020_decode(or_zero(self.z)),
      )
    A98Rgb =>
      lin_a98_to_xyz(
        a98_decode(or_zero(self.x)),
        a98_decode(or_zero(self.y)),
        a98_decode(or_zero(self.z)),
      )
    ProPhoto => {
      let (x, y, z) = lin_prophoto_to_xyz(
        prophoto_decode(or_zero(self.x)),
        prophoto_decode(or_zero(self.y)),
        prophoto_decode(or_zero(self.z)),
      )
      d50_to_d65(x, y, z)
    }
    Srgb | SrgbLinear | Hsl | Hsv | Hwb | YCbCr(_) => {
      let (r, g, b) = self.linear_srgb()
      lin_srgb_to_xyz(r, g, b)
    }
    Current => (0.0, 0.0, 0.0)
  }
}

///|
/// A colour from D65 XYZ, in the requested space.
fn of_xyz_d65(
  space : Space,
  x : Double,
  y : Double,
  z : Double,
  alpha : Double,
) -> Color {
  match space {
    XyzD65 => { space, x, y, z, alpha, }
    XyzD50 => {
      let (x, y, z) = d65_to_d50(x, y, z)
      { space: XyzD50, x, y, z, alpha, }
    }
    Lab => {
      let (x, y, z) = d65_to_d50(x, y, z)
      let (l, a, b) = xyz_d50_to_lab(x, y, z)
      { space: Lab, x: l, y: a, z: b, alpha, }
    }
    Lch => {
      let (x, y, z) = d65_to_d50(x, y, z)
      let (l, a, b) = xyz_d50_to_lab(x, y, z)
      let (c, h) = polar_of(a, b, 0.0015)
      { space: Lch, x: l, y: c, z: h, alpha, }
    }
    Oklab => {
      let (l, a, b) = xyz_d65_to_oklab(x, y, z)
      { space: Oklab, x: l, y: a, z: b, alpha, }
    }
    Oklch => {
      let (l, a, b) = xyz_d65_to_oklab(x, y, z)
      let (c, h) = polar_of(a, b, 0.000004)
      { space: Oklch, x: l, y: c, z: h, alpha, }
    }
    DisplayP3Linear => {
      let (r, g, b) = xyz_to_lin_p3(x, y, z)
      { space: DisplayP3Linear, x: r, y: g, z: b, alpha, }
    }
    DisplayP3 => {
      let (r, g, b) = xyz_to_lin_p3(x, y, z)
      {
        space: DisplayP3,
        x: srgb_encode(r),
        y: srgb_encode(g),
        z: srgb_encode(b),
        alpha,
      }
    }
    Rec2020 => {
      let (r, g, b) = xyz_to_lin_2020(x, y, z)
      {
        space: Rec2020,
        x: rec2020_encode(r),
        y: rec2020_encode(g),
        z: rec2020_encode(b),
        alpha,
      }
    }
    A98Rgb => {
      let (r, g, b) = xyz_to_lin_a98(x, y, z)
      {
        space: A98Rgb,
        x: a98_encode(r),
        y: a98_encode(g),
        z: a98_encode(b),
        alpha,
      }
    }
    ProPhoto => {
      let (x, y, z) = d65_to_d50(x, y, z)
      let (r, g, b) = xyz_to_lin_prophoto(x, y, z)
      {
        space: ProPhoto,
        x: prophoto_encode(r),
        y: prophoto_encode(g),
        z: prophoto_encode(b),
        alpha,
      }
    }
    Srgb | SrgbLinear => {
      let (r, g, b) = xyz_to_lin_srgb(x, y, z)
      if space is Srgb {
        {
          space: Srgb,
          x: srgb_encode(r),
          y: srgb_encode(g),
          z: srgb_encode(b),
          alpha,
        }
      } else {
        { space: SrgbLinear, x: r, y: g, z: b, alpha, }
      }
    }
    Hsl => {
      let (r, g, b) = xyz_to_lin_srgb(x, y, z)
      let (h, s, l) = srgb_to_hsl(
        srgb_encode(r),
        srgb_encode(g),
        srgb_encode(b),
      )
      { space: Hsl, x: h, y: s, z: l, alpha, }
    }
    Hsv => {
      let (r, g, b) = xyz_to_lin_srgb(x, y, z)
      let (h, s, v) = srgb_to_hsv(
        srgb_encode(r),
        srgb_encode(g),
        srgb_encode(b),
      )
      { space: Hsv, x: h, y: s, z: v, alpha, }
    }
    Hwb => {
      let (r, g, b) = xyz_to_lin_srgb(x, y, z)
      let (h, w, bl) = srgb_to_hwb(
        srgb_encode(r),
        srgb_encode(g),
        srgb_encode(b),
      )
      { space: Hwb, x: h, y: w, z: bl, alpha, }
    }
    YCbCr(luma) => {
      let (r, g, b) = xyz_to_lin_srgb(x, y, z)
      let (y, cb, cr) = srgb_to_ycbcr(
        srgb_encode(r),
        srgb_encode(g),
        srgb_encode(b),
        luma,
      )
      { space: YCbCr(luma), x: y, y: cb, z: cr, alpha, }
    }
    Current => { space: Current, x: 0.0, y: 0.0, z: 0.0, alpha: 1.0, }
  }
}

///|
/// The same colour in another space.
///
/// This is the one conversion every `to_*` goes through, so that a pair of
/// spaces is only ever defined once. Converting to the space a colour is
/// already in returns it unchanged rather than round-tripping it through a hub,
/// which would otherwise cost a matrix inversion for nothing.
pub fn Color::to_space(self : Color, space : Space) -> Color {
  if self.space is Current || space is Current {
    return self
  }
  if self.space == space {
    return self
  }
  // HSL, HSV, HWB and YCbCr are defined on sRGB rather than on XYZ, so a
  // conversion between two of them goes through sRGB and keeps its exact
  // inverses instead of paying two transfer-curve round trips.
  match (self.space, space) {
    (Hsl | Hsv | Hwb | YCbCr(_), Hsl | Hsv | Hwb | YCbCr(_)) =>
      self.to_space(Srgb).to_space(space)
    _ => {
      let (x, y, z) = self.xyz65()
      of_xyz_d65(space, x, y, z, self.alpha)
    }
  }
}

///|
/// sRGB, components 0–1.
pub fn Color::to_srgb(self : Color) -> Color {
  self.to_space(Srgb)
}

///|
/// Linear-light sRGB, components 0–1.
pub fn Color::to_srgb_linear(self : Color) -> Color {
  self.to_space(SrgbLinear)
}

///|
/// HSL: hue in degrees, saturation and lightness 0–1.
pub fn Color::to_hsl(self : Color) -> Color {
  self.to_space(Hsl)
}

///|
/// HSV: hue in degrees, saturation and value 0–1.
pub fn Color::to_hsv(self : Color) -> Color {
  self.to_space(Hsv)
}

///|
/// HWB: hue in degrees, whiteness and blackness 0–1.
pub fn Color::to_hwb(self : Color) -> Color {
  self.to_space(Hwb)
}

///|
/// CIE Lab relative to D50, lightness 0–100.
pub fn Color::to_lab(self : Color) -> Color {
  self.to_space(Lab)
}

///|
/// CIE LCh relative to D50, lightness 0–100.
pub fn Color::to_lch(self : Color) -> Color {
  self.to_space(Lch)
}

///|
/// Oklab, lightness 0–1.
pub fn Color::to_oklab(self : Color) -> Color {
  self.to_space(Oklab)
}

///|
/// OkLCh, lightness 0–1.
pub fn Color::to_oklch(self : Color) -> Color {
  self.to_space(Oklch)
}

///|
/// CIE XYZ relative to D65, Y = 1 for white.
pub fn Color::to_xyz_d65(self : Color) -> Color {
  self.to_space(XyzD65)
}

///|
/// CIE XYZ relative to D50, Y = 1 for white.
pub fn Color::to_xyz_d50(self : Color) -> Color {
  self.to_space(XyzD50)
}

///|
/// Display P3, components 0–1.
pub fn Color::to_display_p3(self : Color) -> Color {
  self.to_space(DisplayP3)
}

///|
/// Rec. 2020, components 0–1.
pub fn Color::to_rec2020(self : Color) -> Color {
  self.to_space(Rec2020)
}

///|
/// Adobe RGB (1998), components 0–1.
pub fn Color::to_a98_rgb(self : Color) -> Color {
  self.to_space(A98Rgb)
}

///|
/// ProPhoto RGB, components 0–1.
pub fn Color::to_prophoto(self : Color) -> Color {
  self.to_space(ProPhoto)
}

///|
/// YCbCr with the given luma coefficients: luma 0–1, chroma −0.5–0.5.
pub fn Color::to_ycbcr(self : Color, luma~ : Luma) -> Color {
  self.to_space(YCbCr(luma))
}

///|
/// CMYK for print, as four inks in 0–1.
///
/// The conversion is the arithmetic one — full black is all key, and the other
/// three inks spread what is left — not the one an ICC profile for a particular
/// press and paper would give.
///
/// Four inks do not fit in three channels, so the arithmetic has to choose: for
/// one colour there are many ink combinations, and `to_cmyk` returns the one with
/// as much key as possible, which is the usual choice because key ink is cheap
/// and the three colour inks are not. That means a colour survives the round trip
/// but its inks may not: `cmyk(0.2 0.4 0.6 0.1)` prints the same colour as
/// `cmyk(0 0.25 0.5 0.28)`, and only the second comes back.
pub(all) struct Cmyk {
  c : Double
  m : Double
  y : Double
  k : Double
} derive(Eq, Debug)

///|
pub extend Cmyk with Eq::{equal, not_equal}

///|
pub extend Cmyk with Debug::{to_repr}

///|
/// Four inks. Nothing is clamped here: an ink of 1.2 is a number no press can
/// lay down, and deciding that is the printer's business rather than the type's.
/// `to_color` clamps on the way back out, so the arithmetic stays total.
pub fn Cmyk::of(c : Double, m : Double, y : Double, k : Double) -> Cmyk {
  { c, m, y, k, }
}

///|
/// The colour as four inks: the arithmetic conversion, through sRGB, taking as
/// much key as the colour allows. Black is all key, and a white has none.
///
/// This is the inverse of `Cmyk::to_color` for the colour but not for the inks —
/// the note on `Cmyk` says why, and the platform test that reads it back says it
/// in numbers.
pub fn Color::to_cmyk(self : Color) -> Cmyk {
  let (r, g, b) = self.to_srgb().components()
  let (r, g, b) = (
    clamp01(or_zero(r)),
    clamp01(or_zero(g)),
    clamp01(or_zero(b)),
  )
  let k = 1.0 -
    (if r > g { if r > b { r } else { b } } else if g > b { g } else { b })
  if k >= 1.0 {
    return { c: 0.0, m: 0.0, y: 0.0, k: 1.0, }
  }
  {
    c: (1.0 - r - k) / (1.0 - k),
    m: (1.0 - g - k) / (1.0 - k),
    y: (1.0 - b - k) / (1.0 - k),
    k,
  }
}

///|
/// A colour from CMYK inks, in sRGB.
pub fn Cmyk::to_color(self : Cmyk, alpha? : Double) -> Color {
  let k = clamp01(or_zero(self.k))
  let channel = fn(ink : Double) { (1.0 - clamp01(or_zero(ink))) * (1.0 - k) }
  Color::srgb(channel(self.c), channel(self.m), channel(self.y), alpha?)
}