oxipdf-ir 0.1.0

Intermediate representation types for the oxipdf PDF engine
Documentation
//! Color representation supporting sRGB and CMYK color spaces.
//!
//! Colors in the oxipdf IR can be sRGB (ยง5.4) or CMYK. ICC profiles
//! are not currently supported.

/// A color with alpha channel, supporting sRGB and CMYK color spaces.
///
/// All components are in the range `0.0..=1.0`.
/// Alpha of `1.0` is fully opaque; `0.0` is fully transparent.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Color {
    /// sRGB color space.
    Srgb {
        /// Red component (0.0-1.0).
        r: f32,
        /// Green component (0.0-1.0).
        g: f32,
        /// Blue component (0.0-1.0).
        b: f32,
        /// Alpha component (0.0 = transparent, 1.0 = opaque).
        a: f32,
    },
    /// CMYK color space.
    Cmyk {
        /// Cyan component (0.0-1.0).
        c: f32,
        /// Magenta component (0.0-1.0).
        m: f32,
        /// Yellow component (0.0-1.0).
        y: f32,
        /// Key/black component (0.0-1.0).
        k: f32,
        /// Alpha component (0.0 = transparent, 1.0 = opaque).
        a: f32,
    },
}

impl Color {
    pub const BLACK: Self = Self::Srgb {
        r: 0.0,
        g: 0.0,
        b: 0.0,
        a: 1.0,
    };
    pub const WHITE: Self = Self::Srgb {
        r: 1.0,
        g: 1.0,
        b: 1.0,
        a: 1.0,
    };
    pub const TRANSPARENT: Self = Self::Srgb {
        r: 0.0,
        g: 0.0,
        b: 0.0,
        a: 0.0,
    };

    /// Create a new opaque sRGB color.
    #[must_use]
    pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
        Self::Srgb { r, g, b, a: 1.0 }
    }

    /// Create a new sRGB color with alpha.
    #[must_use]
    pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
        Self::Srgb { r, g, b, a }
    }

    /// Create a new opaque CMYK color.
    #[must_use]
    pub const fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Self {
        Self::Cmyk { c, m, y, k, a: 1.0 }
    }

    /// Create a new CMYK color with alpha.
    #[must_use]
    pub const fn cmyka(c: f32, m: f32, y: f32, k: f32, a: f32) -> Self {
        Self::Cmyk { c, m, y, k, a }
    }

    /// Create a checked opaque sRGB color, clamping components to `0.0..=1.0`.
    ///
    /// Use this when constructing colors from untrusted input.
    /// NaN values are clamped to 0.0.
    #[must_use]
    pub fn rgb_clamped(r: f32, g: f32, b: f32) -> Self {
        Self::Srgb {
            r: clamp_component(r),
            g: clamp_component(g),
            b: clamp_component(b),
            a: 1.0,
        }
    }

    /// Create a checked sRGB color with alpha, clamping components to `0.0..=1.0`.
    #[must_use]
    pub fn rgba_clamped(r: f32, g: f32, b: f32, a: f32) -> Self {
        Self::Srgb {
            r: clamp_component(r),
            g: clamp_component(g),
            b: clamp_component(b),
            a: clamp_component(a),
        }
    }

    /// Create a checked opaque CMYK color, clamping components to `0.0..=1.0`.
    #[must_use]
    pub fn cmyk_clamped(c: f32, m: f32, y: f32, k: f32) -> Self {
        Self::Cmyk {
            c: clamp_component(c),
            m: clamp_component(m),
            y: clamp_component(y),
            k: clamp_component(k),
            a: 1.0,
        }
    }

    /// Create a checked CMYK color with alpha, clamping components to `0.0..=1.0`.
    #[must_use]
    pub fn cmyka_clamped(c: f32, m: f32, y: f32, k: f32, a: f32) -> Self {
        Self::Cmyk {
            c: clamp_component(c),
            m: clamp_component(m),
            y: clamp_component(y),
            k: clamp_component(k),
            a: clamp_component(a),
        }
    }

    /// Create a color from 8-bit sRGB components (0-255).
    #[must_use]
    pub fn from_rgb8(r: u8, g: u8, b: u8) -> Self {
        Self::Srgb {
            r: r as f32 / 255.0,
            g: g as f32 / 255.0,
            b: b as f32 / 255.0,
            a: 1.0,
        }
    }

    /// Create a color from 8-bit sRGB components with alpha (0-255).
    #[must_use]
    pub fn from_rgba8(r: u8, g: u8, b: u8, a: u8) -> Self {
        Self::Srgb {
            r: r as f32 / 255.0,
            g: g as f32 / 255.0,
            b: b as f32 / 255.0,
            a: a as f32 / 255.0,
        }
    }

    /// Returns `true` if this color uses the sRGB color space.
    #[must_use]
    pub const fn is_srgb(&self) -> bool {
        matches!(self, Self::Srgb { .. })
    }

    /// Returns `true` if this color uses the CMYK color space.
    #[must_use]
    pub const fn is_cmyk(&self) -> bool {
        matches!(self, Self::Cmyk { .. })
    }

    /// Returns the alpha component regardless of color space.
    #[must_use]
    pub const fn alpha(&self) -> f32 {
        match self {
            Self::Srgb { a, .. } | Self::Cmyk { a, .. } => *a,
        }
    }

    /// Returns `true` if this color has any transparency (alpha < 1.0).
    #[must_use]
    pub fn has_transparency(self) -> bool {
        self.alpha() < 1.0
    }

    /// Returns `true` if this color is fully transparent (alpha == 0.0).
    #[must_use]
    pub fn is_transparent(self) -> bool {
        self.alpha() == 0.0
    }

    /// Validate that all components are finite and in `0.0..=1.0`.
    ///
    /// Returns a list of `(component_name, value)` pairs for components
    /// that are out of range.
    pub fn validate_components(&self) -> Vec<(&'static str, f32)> {
        let mut invalid = Vec::new();
        let check = |name: &'static str, val: f32, out: &mut Vec<(&'static str, f32)>| {
            if !val.is_finite() || !(0.0..=1.0).contains(&val) {
                out.push((name, val));
            }
        };
        match *self {
            Self::Srgb { r, g, b, a } => {
                check("r", r, &mut invalid);
                check("g", g, &mut invalid);
                check("b", b, &mut invalid);
                check("a", a, &mut invalid);
            }
            Self::Cmyk { c, m, y, k, a } => {
                check("c", c, &mut invalid);
                check("m", m, &mut invalid);
                check("y", y, &mut invalid);
                check("k", k, &mut invalid);
                check("a", a, &mut invalid);
            }
        }
        invalid
    }
}

/// Clamp a color component to `0.0..=1.0`, treating NaN as 0.0.
fn clamp_component(v: f32) -> f32 {
    if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }
}

impl Default for Color {
    fn default() -> Self {
        Self::BLACK
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn from_rgb8() {
        let c = Color::from_rgb8(255, 128, 0);
        match c {
            Color::Srgb { r, g, b, a } => {
                assert_eq!(r, 1.0);
                assert!((g - 128.0 / 255.0).abs() < 1e-6);
                assert_eq!(b, 0.0);
                assert_eq!(a, 1.0);
            }
            _ => panic!("expected Srgb variant"),
        }
    }

    #[test]
    fn transparency_detection() {
        assert!(!Color::BLACK.has_transparency());
        assert!(Color::TRANSPARENT.has_transparency());
        assert!(Color::rgba(1.0, 0.0, 0.0, 0.5).has_transparency());
    }

    #[test]
    fn default_is_black() {
        assert_eq!(Color::default(), Color::BLACK);
    }

    #[test]
    fn cmyk_constructors() {
        let c = Color::cmyk(0.5, 0.3, 0.1, 0.0);
        assert!(c.is_cmyk());
        assert!(!c.is_srgb());
        assert!(!c.has_transparency());
        assert_eq!(c.alpha(), 1.0);
    }

    #[test]
    fn cmyka_with_alpha() {
        let c = Color::cmyka(1.0, 0.0, 0.0, 0.0, 0.5);
        assert!(c.is_cmyk());
        assert!(c.has_transparency());
        assert_eq!(c.alpha(), 0.5);
    }

    #[test]
    fn srgb_detection() {
        assert!(Color::rgb(1.0, 0.0, 0.0).is_srgb());
        assert!(!Color::rgb(1.0, 0.0, 0.0).is_cmyk());
    }

    #[test]
    fn validate_components_srgb() {
        let valid = Color::rgb(0.5, 0.5, 0.5);
        assert!(valid.validate_components().is_empty());

        let invalid = Color::rgba(1.5, 0.0, -0.1, 1.0);
        let errs = invalid.validate_components();
        assert_eq!(errs.len(), 2);
    }

    #[test]
    fn validate_components_cmyk() {
        let valid = Color::cmyk(0.0, 0.5, 1.0, 0.3);
        assert!(valid.validate_components().is_empty());

        let invalid = Color::cmyka(0.0, 1.5, 0.0, 0.0, 0.5);
        let errs = invalid.validate_components();
        assert_eq!(errs.len(), 1);
    }

    #[test]
    fn rgb_clamped_clamps_out_of_range() {
        let c = Color::rgb_clamped(1.5, -0.3, 0.5);
        assert!(c.validate_components().is_empty());
        match c {
            Color::Srgb { r, g, b, .. } => {
                assert_eq!(r, 1.0);
                assert_eq!(g, 0.0);
                assert_eq!(b, 0.5);
            }
            _ => panic!("expected Srgb"),
        }
    }

    #[test]
    fn cmyk_clamped_clamps_out_of_range() {
        let c = Color::cmyk_clamped(2.0, -1.0, 0.5, 0.3);
        assert!(c.validate_components().is_empty());
        match c {
            Color::Cmyk { c, m, y, k, .. } => {
                assert_eq!(c, 1.0);
                assert_eq!(m, 0.0);
                assert_eq!(y, 0.5);
                assert_eq!(k, 0.3);
            }
            _ => panic!("expected Cmyk"),
        }
    }

    #[test]
    fn clamped_handles_nan() {
        let c = Color::rgba_clamped(f32::NAN, 0.5, f32::NAN, 1.0);
        assert!(c.validate_components().is_empty());
        match c {
            Color::Srgb { r, g, b, a } => {
                assert_eq!(r, 0.0);
                assert_eq!(g, 0.5);
                assert_eq!(b, 0.0);
                assert_eq!(a, 1.0);
            }
            _ => panic!("expected Srgb"),
        }
    }
}