#[inline]
#[must_use]
pub fn div255(x: u32) -> u8 {
let shifted = (x + (x >> 8) + 0x80) >> 8;
shifted.min(255) as u8
}
#[inline]
#[must_use]
pub fn lerp_u8(a: u8, b: u8, t: u32) -> u8 {
debug_assert!(t <= 256, "lerp_u8: t={t} out of range [0, 256]");
div255(u32::from(a) * (256 - t) + u32::from(b) * t)
}
#[inline]
#[must_use]
pub const fn cmyk_to_rgb(c: u8, m: u8, y: u8, k: u8) -> (u8, u8, u8) {
(
255u8.saturating_sub(c).saturating_sub(k),
255u8.saturating_sub(m).saturating_sub(k),
255u8.saturating_sub(y).saturating_sub(k),
)
}
#[inline]
fn reflectance_blend(ink: u8, inv_k: u32) -> u8 {
u8::try_from((u32::from(255 - ink) * inv_k + 127) / 255)
.expect("reflectance_blend: ((255−ink)×inv_k+127)/255 ≤ 255")
}
#[inline]
#[must_use]
pub fn cmyk_to_rgb_reflectance(c: u8, m: u8, y: u8, k: u8) -> (u8, u8, u8) {
let inv_k = u32::from(255 - k);
(
reflectance_blend(c, inv_k),
reflectance_blend(m, inv_k),
reflectance_blend(y, inv_k),
)
}
#[inline]
#[must_use]
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "value is clamped to [0, 1] and scaled to [0.0, 255.0]; round() output fits u8"
)]
pub fn gray_to_u8(v: f64) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
#[inline]
#[must_use]
pub fn rgb_to_bytes(r: f64, g: f64, b: f64) -> [u8; 3] {
[gray_to_u8(r), gray_to_u8(g), gray_to_u8(b)]
}
#[inline]
#[must_use]
#[expect(
clippy::many_single_char_names,
reason = "CMYK and RGB are conventional single-letter colour channel names"
)]
pub fn cmyk_to_rgb_bytes(c: f64, m: f64, y: f64, k: f64) -> [u8; 3] {
let k = k.clamp(0.0, 1.0);
let r = 1.0 - (c.clamp(0.0, 1.0) + k).min(1.0);
let g = 1.0 - (m.clamp(0.0, 1.0) + k).min(1.0);
let b = 1.0 - (y.clamp(0.0, 1.0) + k).min(1.0);
rgb_to_bytes(r, g, b)
}
#[inline]
fn saturate_f64_to_i32(x: f64) -> i32 {
if !x.is_finite() {
return if x == f64::INFINITY {
i32::MAX
} else {
i32::MIN
};
}
#[expect(
clippy::cast_possible_truncation,
reason = "f64 → i64 cast; try_from on the next line saturates out-of-range values"
)]
let v = x as i64;
i32::try_from(v).unwrap_or(if v > 0 { i32::MAX } else { i32::MIN })
}
#[inline]
#[must_use]
pub fn splash_floor(x: f64) -> i32 {
saturate_f64_to_i32(x.floor())
}
#[inline]
#[must_use]
pub fn splash_ceil(x: f64) -> i32 {
saturate_f64_to_i32(x.ceil())
}
#[inline]
#[must_use]
pub fn splash_round(x: f64) -> i32 {
splash_floor(x + 0.5)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn div255_exhaustive() {
for x in 0u32..=65535 {
let got = f64::from(div255(x));
let expected = (f64::from(x) / 255.0).round().min(255.0);
assert!(
(got - expected).abs() <= 1.0,
"div255({x}) = {got}, expected ≈ {expected}"
);
}
}
#[test]
fn div255_boundary_products() {
for a in 0u32..=255 {
for b in 0u32..=255 {
let got = f64::from(div255(a * b));
let expected = (f64::from(a) * f64::from(b) / 255.0).round();
assert!(
(got - expected).abs() <= 1.0,
"div255({a}*{b}) = {got}, expected ≈ {expected}"
);
}
}
}
#[test]
fn lerp_endpoints() {
assert_eq!(lerp_u8(100, 200, 0), 100);
let v = lerp_u8(100, 200, 256);
assert!(
(i32::from(v) - 200).abs() <= 1,
"lerp t=256 gave {v}, expected ≈200"
);
}
#[test]
fn lerp_t0_near_a() {
for a in 0u8..=255 {
let v0 = lerp_u8(a, 0, 0);
let v255 = lerp_u8(a, 255, 0);
assert_eq!(
v0, v255,
"lerp_u8({a}, b, 0) must not depend on b: got {v0} vs {v255}"
);
assert!(
(i32::from(v0) - i32::from(a)).abs() <= 1,
"lerp_u8({a}, _, 0) = {v0}, expected within ±1 of {a}"
);
}
}
#[test]
fn splash_floor_ceil_round() {
let cases = [
(0.0f64, 0, 0, 0),
(0.5, 0, 1, 1),
(0.9, 0, 1, 1),
(1.0, 1, 1, 1),
(-0.1, -1, 0, 0),
(-0.5, -1, 0, 0),
(-0.6, -1, 0, -1),
(-1.0, -1, -1, -1),
];
for (x, fl, ce, ro) in cases {
assert_eq!(splash_floor(x), fl, "floor({x})");
assert_eq!(splash_ceil(x), ce, "ceil({x})");
assert_eq!(splash_round(x), ro, "round({x})");
}
}
#[test]
fn splash_round_half_integers() {
assert_eq!(splash_round(0.5), 1, "0.5 rounds toward +inf");
assert_eq!(splash_round(-0.5), 0, "-0.5 rounds toward +inf (i.e. 0)");
assert_eq!(splash_round(1.5), 2);
assert_eq!(splash_round(-1.5), -1);
}
#[test]
fn splash_floor_ceil_round_non_finite() {
assert_eq!(splash_floor(f64::INFINITY), i32::MAX);
assert_eq!(splash_ceil(f64::INFINITY), i32::MAX);
assert_eq!(splash_round(f64::INFINITY), i32::MAX);
assert_eq!(splash_floor(f64::NEG_INFINITY), i32::MIN);
assert_eq!(splash_ceil(f64::NEG_INFINITY), i32::MIN);
assert_eq!(splash_round(f64::NEG_INFINITY), i32::MIN);
assert_eq!(splash_floor(f64::NAN), i32::MIN);
assert_eq!(splash_ceil(f64::NAN), i32::MIN);
assert_eq!(splash_round(f64::NAN), i32::MIN);
}
#[test]
fn gray_extremes() {
assert_eq!(gray_to_u8(0.0), 0);
assert_eq!(gray_to_u8(1.0), 255);
}
#[test]
fn gray_clamped() {
assert_eq!(gray_to_u8(-1.0), 0);
assert_eq!(gray_to_u8(2.0), 255);
}
#[test]
fn gray_nan_is_zero() {
assert_eq!(gray_to_u8(f64::NAN), 0);
}
#[test]
fn cmyk_bytes_black() {
assert_eq!(cmyk_to_rgb_bytes(0.0, 0.0, 0.0, 1.0), [0, 0, 0]);
}
#[test]
fn cmyk_bytes_white() {
assert_eq!(cmyk_to_rgb_bytes(0.0, 0.0, 0.0, 0.0), [255, 255, 255]);
}
#[test]
fn cmyk_bytes_nan_channel_is_zero() {
assert_eq!(cmyk_to_rgb_bytes(f64::NAN, 0.0, 0.0, 0.0), [0, 255, 255]);
assert_eq!(cmyk_to_rgb_bytes(0.0, 0.0, 0.0, f64::NAN), [0, 0, 0]);
}
#[test]
fn cmyk_reflectance_no_ink_is_white() {
assert_eq!(cmyk_to_rgb_reflectance(0, 0, 0, 0), (255, 255, 255));
}
#[test]
fn cmyk_reflectance_full_k_is_black() {
assert_eq!(cmyk_to_rgb_reflectance(0, 0, 0, 255), (0, 0, 0));
}
#[test]
fn cmyk_reflectance_full_cyan_no_k() {
let (r, g, b) = cmyk_to_rgb_reflectance(255, 0, 0, 0);
assert_eq!(r, 0);
assert_eq!(g, 255);
assert_eq!(b, 255);
}
#[test]
fn cmyk_reflectance_midtone() {
let (r, g, b) = cmyk_to_rgb_reflectance(128, 0, 0, 0);
assert!((127..=128).contains(&r), "r={r}");
assert_eq!(g, 255);
assert_eq!(b, 255);
}
#[test]
fn cmyk_saturation() {
let (r, g, b) = cmyk_to_rgb(200, 0, 0, 200);
assert_eq!(r, 0, "saturated red channel must be 0");
assert_eq!(g, 55, "green = 255 - 200 = 55");
assert_eq!(b, 55, "blue = 255 - 200 = 55");
let (r, g, b) = cmyk_to_rgb(0, 0, 0, 255);
assert_eq!((r, g, b), (0, 0, 0));
let (r, g, b) = cmyk_to_rgb(0, 0, 0, 0);
assert_eq!((r, g, b), (255, 255, 255));
}
}