Skip to main content

zenpixels/
color.rs

1//! Color profile types for CMS integration.
2//!
3//! Provides a unified way to reference the source color space of decoded
4//! pixels, suitable for passing to a CMS backend (e.g., moxcms, lcms2).
5//!
6//! [`ColorContext`] bundles ICC and CICP metadata for cheap sharing
7//! (via `Arc`) across pixel slices and pipeline stages.
8//! Current color state (transfer, primaries, alpha) is tracked on the
9//! pixel descriptor itself, not as a separate enum.
10
11use crate::TransferFunction;
12use crate::cicp::Cicp;
13use alloc::sync::Arc;
14
15/// A source color profile — either ICC bytes or CICP parameters.
16///
17/// This unified type lets consumers pass decoded image color info
18/// directly to a CMS backend without caring whether the source had
19/// an ICC profile, CICP codes, or a well-known named profile.
20#[derive(Clone, Debug, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum ColorProfileSource<'a> {
23    /// Raw ICC profile data.
24    Icc(&'a [u8]),
25    /// CICP parameters (a CMS can synthesize an equivalent profile).
26    Cicp(Cicp),
27    /// Well-known named profile.
28    Named(NamedProfile),
29}
30
31/// Well-known color profiles that any CMS should recognize.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
33#[non_exhaustive]
34pub enum NamedProfile {
35    /// sRGB (IEC 61966-2-1). The web and desktop default.
36    #[default]
37    Srgb,
38    /// Display P3 with sRGB transfer curve.
39    DisplayP3,
40    /// BT.2020 with BT.709 transfer (SDR wide gamut).
41    Bt2020,
42    /// BT.2020 with PQ transfer (HDR10, SMPTE ST 2084).
43    Bt2020Pq,
44    /// BT.2020 with HLG transfer (ARIB STD-B67, HDR broadcast).
45    Bt2020Hlg,
46    /// Adobe RGB (1998). Used in print workflows.
47    AdobeRgb,
48    /// Linear sRGB (sRGB primaries, gamma 1.0).
49    LinearSrgb,
50}
51
52impl NamedProfile {
53    /// Map CICP parameters to a well-known named profile.
54    ///
55    /// Recognizes sRGB, Display P3, BT.2020 (SDR), BT.2100 PQ, BT.2100 HLG,
56    /// and Linear sRGB. Returns `None` for unrecognized combinations.
57    pub const fn from_cicp(cicp: Cicp) -> Option<Self> {
58        // Match on (primaries, transfer, matrix, full_range).
59        // We match matrix_coefficients == 0 (Identity/RGB) for all RGB profiles.
60        match (
61            cicp.color_primaries,
62            cicp.transfer_characteristics,
63            cicp.matrix_coefficients,
64        ) {
65            (1, 13, 0) => Some(Self::Srgb),
66            (12, 13, 0) => Some(Self::DisplayP3),
67            (9, 1, 0) => Some(Self::Bt2020),
68            (9, 16, _) => Some(Self::Bt2020Pq), // BT.2100 PQ (any matrix)
69            (9, 18, _) => Some(Self::Bt2020Hlg), // BT.2100 HLG (any matrix)
70            (1, 8, 0) => Some(Self::LinearSrgb),
71            _ => None,
72        }
73    }
74
75    /// Convert to CICP parameters, if a standard mapping exists.
76    pub const fn to_cicp(self) -> Option<Cicp> {
77        match self {
78            Self::Srgb => Some(Cicp::SRGB),
79            Self::DisplayP3 => Some(Cicp::DISPLAY_P3),
80            Self::Bt2020 => Some(Cicp {
81                color_primaries: 9,
82                transfer_characteristics: 1,
83                matrix_coefficients: 0,
84                full_range: true,
85            }),
86            Self::Bt2020Pq => Some(Cicp::BT2100_PQ),
87            Self::Bt2020Hlg => Some(Cicp::BT2100_HLG),
88            Self::LinearSrgb => Some(Cicp {
89                color_primaries: 1,
90                transfer_characteristics: 8,
91                matrix_coefficients: 0,
92                full_range: true,
93            }),
94            Self::AdobeRgb => None,
95        }
96    }
97}
98
99/// Color space metadata for pixel data.
100///
101/// Bundles ICC profile bytes and/or CICP parameters into a single
102/// shareable context. Carried via `Arc` on pixel slices and pipeline
103/// sources so color metadata travels with pixel data without per-strip
104/// cloning overhead.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct ColorContext {
107    /// Raw ICC profile bytes.
108    pub icc: Option<Arc<[u8]>>,
109    /// CICP parameters (ITU-T H.273).
110    pub cicp: Option<Cicp>,
111}
112
113impl ColorContext {
114    /// Create from an ICC profile.
115    pub fn from_icc(icc: impl Into<Arc<[u8]>>) -> Self {
116        Self {
117            icc: Some(icc.into()),
118            cicp: None,
119        }
120    }
121
122    /// Create from CICP parameters.
123    pub fn from_cicp(cicp: Cicp) -> Self {
124        Self {
125            icc: None,
126            cicp: Some(cicp),
127        }
128    }
129
130    /// Create from both ICC and CICP.
131    pub fn from_icc_and_cicp(icc: impl Into<Arc<[u8]>>, cicp: Cicp) -> Self {
132        Self {
133            icc: Some(icc.into()),
134            cicp: Some(cicp),
135        }
136    }
137
138    /// Get a [`ColorProfileSource`] reference for CMS integration.
139    ///
140    /// Returns CICP if present (takes precedence per AVIF/HEIF specs),
141    /// otherwise returns the ICC profile bytes.
142    pub fn as_profile_source(&self) -> Option<ColorProfileSource<'_>> {
143        if let Some(cicp) = self.cicp {
144            Some(ColorProfileSource::Cicp(cicp))
145        } else {
146            self.icc.as_deref().map(ColorProfileSource::Icc)
147        }
148    }
149
150    /// Derive transfer function from CICP (or `Unknown` if no CICP).
151    pub fn transfer_function(&self) -> TransferFunction {
152        self.cicp
153            .and_then(|c| TransferFunction::from_cicp(c.transfer_characteristics))
154            .unwrap_or(TransferFunction::Unknown)
155    }
156
157    /// True if this describes sRGB (either via CICP or trivially).
158    pub fn is_srgb(&self) -> bool {
159        self.cicp == Some(Cicp::SRGB)
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Color provenance — how the source described its color
165// ---------------------------------------------------------------------------
166
167/// How the source file described its color information.
168///
169/// Preserved for re-encoding and round-trip conversion decisions.
170#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
171#[non_exhaustive]
172pub enum ColorProvenance {
173    /// Color was described via an embedded ICC profile.
174    Icc,
175    /// Color was described via CICP code points.
176    Cicp,
177    /// Color was described via PNG gAMA + cHRM chunks or similar.
178    GamaChrm,
179    /// Color was assumed (no explicit metadata in source).
180    Assumed,
181}
182
183/// Immutable record of how the source file described its color.
184///
185/// Tracks the original color description from the decoded file so
186/// encoders can make provenance-aware decisions (e.g., re-embed the
187/// original ICC profile, or prefer CICP when re-encoding to AVIF).
188///
189/// `ColorOrigin` is immutable once set. It records how color was
190/// described, not what the pixels currently are. The encoder uses
191/// [`PixelDescriptor`](crate::PixelDescriptor) for the current state
192/// and can consult `ColorOrigin` for provenance decisions.
193#[derive(Clone, Debug, PartialEq, Eq)]
194#[non_exhaustive]
195pub struct ColorOrigin {
196    /// Raw ICC profile bytes from the source file, if any.
197    pub icc: Option<Arc<[u8]>>,
198    /// CICP parameters from the source file, if any.
199    pub cicp: Option<Cicp>,
200    /// How the color information was originally described.
201    pub provenance: ColorProvenance,
202}
203
204impl ColorOrigin {
205    /// Create from an ICC profile.
206    pub fn from_icc(icc: impl Into<Arc<[u8]>>) -> Self {
207        Self {
208            icc: Some(icc.into()),
209            cicp: None,
210            provenance: ColorProvenance::Icc,
211        }
212    }
213
214    /// Create from CICP parameters.
215    pub fn from_cicp(cicp: Cicp) -> Self {
216        Self {
217            icc: None,
218            cicp: Some(cicp),
219            provenance: ColorProvenance::Cicp,
220        }
221    }
222
223    /// Create from both ICC and CICP (e.g., AVIF with both).
224    pub fn from_icc_and_cicp(icc: impl Into<Arc<[u8]>>, cicp: Cicp) -> Self {
225        Self {
226            icc: Some(icc.into()),
227            cicp: Some(cicp),
228            provenance: ColorProvenance::Cicp,
229        }
230    }
231
232    /// Create from gAMA/cHRM chunks (no ICC or CICP).
233    pub fn from_gama_chrm() -> Self {
234        Self {
235            icc: None,
236            cicp: None,
237            provenance: ColorProvenance::GamaChrm,
238        }
239    }
240
241    /// Create for assumed/default color (no explicit metadata).
242    pub fn assumed() -> Self {
243        Self {
244            icc: None,
245            cicp: None,
246            provenance: ColorProvenance::Assumed,
247        }
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use alloc::vec;
255
256    #[test]
257    fn named_profile_default_is_srgb() {
258        assert_eq!(NamedProfile::default(), NamedProfile::Srgb);
259    }
260
261    #[test]
262    fn named_profile_to_cicp() {
263        assert_eq!(NamedProfile::Srgb.to_cicp(), Some(Cicp::SRGB));
264        assert_eq!(NamedProfile::Bt2020Pq.to_cicp(), Some(Cicp::BT2100_PQ));
265        assert!(NamedProfile::AdobeRgb.to_cicp().is_none());
266    }
267
268    #[test]
269    fn named_profile_from_cicp() {
270        assert_eq!(
271            NamedProfile::from_cicp(Cicp::SRGB),
272            Some(NamedProfile::Srgb)
273        );
274        assert_eq!(
275            NamedProfile::from_cicp(Cicp::DISPLAY_P3),
276            Some(NamedProfile::DisplayP3)
277        );
278        assert_eq!(
279            NamedProfile::from_cicp(Cicp::BT2100_PQ),
280            Some(NamedProfile::Bt2020Pq)
281        );
282        assert_eq!(
283            NamedProfile::from_cicp(Cicp::BT2100_HLG),
284            Some(NamedProfile::Bt2020Hlg)
285        );
286        // Linear sRGB
287        assert_eq!(
288            NamedProfile::from_cicp(Cicp::new(1, 8, 0, true)),
289            Some(NamedProfile::LinearSrgb)
290        );
291        // BT.2020 SDR
292        assert_eq!(
293            NamedProfile::from_cicp(Cicp::new(9, 1, 0, true)),
294            Some(NamedProfile::Bt2020)
295        );
296        // Unknown combo
297        assert_eq!(NamedProfile::from_cicp(Cicp::new(99, 99, 0, true)), None);
298    }
299
300    #[test]
301    fn named_profile_cicp_roundtrip() {
302        for profile in [
303            NamedProfile::Srgb,
304            NamedProfile::DisplayP3,
305            NamedProfile::Bt2020,
306            NamedProfile::Bt2020Pq,
307            NamedProfile::Bt2020Hlg,
308            NamedProfile::LinearSrgb,
309        ] {
310            let cicp = profile.to_cicp().unwrap();
311            assert_eq!(
312                NamedProfile::from_cicp(cicp),
313                Some(profile),
314                "roundtrip failed for {profile:?}"
315            );
316        }
317    }
318
319    #[test]
320    fn color_context_from_cicp() {
321        let ctx = ColorContext::from_cicp(Cicp::SRGB);
322        assert!(ctx.icc.is_none());
323        assert_eq!(ctx.cicp, Some(Cicp::SRGB));
324    }
325
326    #[test]
327    fn color_context_profile_source_prefers_cicp() {
328        let ctx = ColorContext::from_icc_and_cicp(vec![1, 2, 3], Cicp::SRGB);
329        let src = ctx.as_profile_source().unwrap();
330        assert_eq!(src, ColorProfileSource::Cicp(Cicp::SRGB));
331    }
332
333    #[test]
334    fn color_context_is_srgb() {
335        assert!(ColorContext::from_cicp(Cicp::SRGB).is_srgb());
336        assert!(!ColorContext::from_cicp(Cicp::BT2100_PQ).is_srgb());
337    }
338
339    #[test]
340    fn color_context_transfer_function() {
341        assert_eq!(
342            ColorContext::from_cicp(Cicp::SRGB).transfer_function(),
343            TransferFunction::Srgb
344        );
345        assert_eq!(
346            ColorContext::from_icc(vec![1]).transfer_function(),
347            TransferFunction::Unknown
348        );
349    }
350
351    // --- ColorContext additional coverage ---
352
353    #[test]
354    fn color_context_from_icc() {
355        let ctx = ColorContext::from_icc(vec![10, 20, 30]);
356        assert!(ctx.icc.is_some());
357        assert_eq!(ctx.icc.as_deref(), Some(&[10u8, 20, 30][..]));
358        assert!(ctx.cicp.is_none());
359    }
360
361    #[test]
362    fn color_context_from_icc_and_cicp() {
363        let ctx = ColorContext::from_icc_and_cicp(vec![1, 2], Cicp::BT2100_PQ);
364        assert!(ctx.icc.is_some());
365        assert_eq!(ctx.cicp, Some(Cicp::BT2100_PQ));
366    }
367
368    #[test]
369    fn color_context_profile_source_icc_only() {
370        let ctx = ColorContext::from_icc(vec![42]);
371        let src = ctx.as_profile_source().unwrap();
372        assert_eq!(src, ColorProfileSource::Icc(&[42]));
373    }
374
375    #[test]
376    fn color_context_profile_source_none() {
377        let ctx = ColorContext {
378            icc: None,
379            cicp: None,
380        };
381        assert!(ctx.as_profile_source().is_none());
382    }
383
384    #[test]
385    fn color_context_pq_transfer() {
386        assert_eq!(
387            ColorContext::from_cicp(Cicp::BT2100_PQ).transfer_function(),
388            TransferFunction::Pq
389        );
390    }
391
392    #[test]
393    fn color_context_hlg_transfer() {
394        assert_eq!(
395            ColorContext::from_cicp(Cicp::BT2100_HLG).transfer_function(),
396            TransferFunction::Hlg
397        );
398    }
399
400    #[test]
401    fn color_context_eq_and_clone() {
402        let a = ColorContext::from_cicp(Cicp::SRGB);
403        let b = a.clone();
404        assert_eq!(a, b);
405        let c = ColorContext::from_icc(vec![1]);
406        assert_ne!(a, c);
407    }
408
409    #[test]
410    fn color_context_debug() {
411        let ctx = ColorContext::from_cicp(Cicp::SRGB);
412        let s = alloc::format!("{ctx:?}");
413        assert!(s.contains("ColorContext"));
414    }
415
416    // --- ColorProfileSource coverage ---
417
418    #[test]
419    fn color_profile_source_named() {
420        let src = ColorProfileSource::Named(NamedProfile::DisplayP3);
421        assert_eq!(src, ColorProfileSource::Named(NamedProfile::DisplayP3));
422        assert_ne!(src, ColorProfileSource::Named(NamedProfile::Srgb));
423    }
424
425    #[test]
426    fn color_profile_source_cicp() {
427        let src = ColorProfileSource::Cicp(Cicp::BT2100_PQ);
428        assert_eq!(src, ColorProfileSource::Cicp(Cicp::BT2100_PQ));
429    }
430
431    #[test]
432    fn color_profile_source_icc() {
433        let data: &[u8] = &[1, 2, 3];
434        let src = ColorProfileSource::Icc(data);
435        assert_eq!(src, ColorProfileSource::Icc(&[1, 2, 3]));
436    }
437
438    #[test]
439    fn color_profile_source_debug_clone() {
440        let src = ColorProfileSource::Named(NamedProfile::Srgb);
441        let s = alloc::format!("{src:?}");
442        assert!(s.contains("Named"));
443        let src2 = src.clone();
444        assert_eq!(src, src2);
445    }
446
447    // --- NamedProfile coverage ---
448
449    #[test]
450    fn named_profile_all_variants_to_cicp() {
451        assert!(NamedProfile::Srgb.to_cicp().is_some());
452        assert!(NamedProfile::DisplayP3.to_cicp().is_some());
453        assert!(NamedProfile::Bt2020.to_cicp().is_some());
454        assert!(NamedProfile::Bt2020Pq.to_cicp().is_some());
455        assert!(NamedProfile::Bt2020Hlg.to_cicp().is_some());
456        assert!(NamedProfile::LinearSrgb.to_cicp().is_some());
457        assert!(NamedProfile::AdobeRgb.to_cicp().is_none());
458    }
459
460    #[test]
461    fn named_profile_debug_clone_eq() {
462        let p = NamedProfile::DisplayP3;
463        let _ = alloc::format!("{p:?}");
464        let p2 = p;
465        assert_eq!(p, p2);
466    }
467
468    #[test]
469    #[cfg(feature = "std")]
470    fn named_profile_hash() {
471        use core::hash::{Hash, Hasher};
472        let p = NamedProfile::DisplayP3;
473        let mut h = std::hash::DefaultHasher::new();
474        p.hash(&mut h);
475        let _ = h.finish();
476    }
477
478    // --- ColorProvenance coverage ---
479
480    #[test]
481    fn color_provenance_variants() {
482        assert_ne!(ColorProvenance::Icc, ColorProvenance::Cicp);
483        assert_ne!(ColorProvenance::GamaChrm, ColorProvenance::Assumed);
484        let a = ColorProvenance::Icc;
485        let b = a;
486        assert_eq!(a, b);
487    }
488
489    #[test]
490    fn color_provenance_debug() {
491        let p = ColorProvenance::Cicp;
492        let _ = alloc::format!("{p:?}");
493    }
494
495    #[test]
496    #[cfg(feature = "std")]
497    fn color_provenance_hash() {
498        use core::hash::{Hash, Hasher};
499        let p = ColorProvenance::Cicp;
500        let mut h = std::hash::DefaultHasher::new();
501        p.hash(&mut h);
502        let _ = h.finish();
503    }
504
505    // --- ColorOrigin coverage ---
506
507    #[test]
508    fn color_origin_from_icc() {
509        let o = ColorOrigin::from_icc(vec![1, 2, 3]);
510        assert!(o.icc.is_some());
511        assert!(o.cicp.is_none());
512        assert_eq!(o.provenance, ColorProvenance::Icc);
513    }
514
515    #[test]
516    fn color_origin_from_cicp() {
517        let o = ColorOrigin::from_cicp(Cicp::SRGB);
518        assert!(o.icc.is_none());
519        assert_eq!(o.cicp, Some(Cicp::SRGB));
520        assert_eq!(o.provenance, ColorProvenance::Cicp);
521    }
522
523    #[test]
524    fn color_origin_from_icc_and_cicp() {
525        let o = ColorOrigin::from_icc_and_cicp(vec![10], Cicp::BT2100_PQ);
526        assert!(o.icc.is_some());
527        assert_eq!(o.cicp, Some(Cicp::BT2100_PQ));
528        assert_eq!(o.provenance, ColorProvenance::Cicp);
529    }
530
531    #[test]
532    fn color_origin_from_gama_chrm() {
533        let o = ColorOrigin::from_gama_chrm();
534        assert!(o.icc.is_none());
535        assert!(o.cicp.is_none());
536        assert_eq!(o.provenance, ColorProvenance::GamaChrm);
537    }
538
539    #[test]
540    fn color_origin_assumed() {
541        let o = ColorOrigin::assumed();
542        assert!(o.icc.is_none());
543        assert!(o.cicp.is_none());
544        assert_eq!(o.provenance, ColorProvenance::Assumed);
545    }
546
547    #[test]
548    fn color_origin_eq_clone_debug() {
549        let a = ColorOrigin::from_cicp(Cicp::SRGB);
550        let b = a.clone();
551        assert_eq!(a, b);
552        let _ = alloc::format!("{a:?}");
553        let c = ColorOrigin::assumed();
554        assert_ne!(a, c);
555    }
556}