Skip to main content

zenpixels_convert/
cms.rs

1//! Color Management System (CMS) traits.
2//!
3//! Defines the interface for ICC profile-based color transforms. When a CMS
4//! feature is enabled (e.g., `cms-moxcms`, `cms-lcms2`), the implementation
5//! provides ICC-to-ICC transforms. Named profile conversions (sRGB, P3,
6//! BT.2020) use hardcoded matrices and don't require a CMS.
7//!
8//! # When codecs need a CMS
9//!
10//! Most codecs don't need to interact with the CMS directly.
11//! [`finalize_for_output`](super::finalize_for_output) handles CMS transforms
12//! internally when the [`OutputProfile`](super::OutputProfile) requires one.
13//!
14//! A codec needs CMS awareness only when:
15//!
16//! - **Decoding** an image with an embedded ICC profile that doesn't match
17//!   any known CICP combination. The decoder extracts the ICC bytes and
18//!   stores them on [`ColorContext`](crate::ColorContext). The CMS is used
19//!   later (at encode or processing time), not during decode.
20//!
21//! - **Encoding** with `OutputProfile::Icc(custom_profile)`. The CMS builds
22//!   a source→destination transform, which `finalize_for_output` applies
23//!   row-by-row via [`RowTransform`].
24//!
25//! # Implementing a CMS backend
26//!
27//! To add a new CMS backend (e.g., wrapping Little CMS 2):
28//!
29//! 1. Implement [`ColorManagement`] on your backend struct.
30//! 2. `build_transform` should parse both ICC profiles, create an internal
31//!    transform object, and return it as `Box<dyn RowTransform>`.
32//! 3. `identify_profile` should check if an ICC profile matches a known
33//!    standard (sRGB, Display P3, etc.) and return the corresponding
34//!    [`Cicp`](crate::Cicp). This enables the fast path: if both source
35//!    and destination are known profiles, hardcoded matrices are used
36//!    instead of the CMS.
37//! 4. Feature-gate your implementation behind a cargo feature
38//!    (e.g., `cms-lcms2`).
39//!
40//! ```rust,ignore
41//! struct MyLcms2;
42//!
43//! impl ColorManagement for MyLcms2 {
44//!     type Error = lcms2::Error;
45//!
46//!     fn build_transform(
47//!         &self,
48//!         src_icc: &[u8],
49//!         dst_icc: &[u8],
50//!     ) -> Result<Box<dyn RowTransform>, Self::Error> {
51//!         let src = lcms2::Profile::new_icc(src_icc)?;
52//!         let dst = lcms2::Profile::new_icc(dst_icc)?;
53//!         let xform = lcms2::Transform::new(&src, &dst, ...)?;
54//!         Ok(Box::new(Lcms2RowTransform(xform)))
55//!     }
56//!
57//!     fn identify_profile(&self, icc: &[u8]) -> Option<Cicp> {
58//!         // Fast: check MD5 hash against known profiles
59//!         // Slow: parse TRC+matrix, compare within tolerance
60//!         None
61//!     }
62//! }
63//! ```
64//!
65//! # No-op CMS
66//!
67//! Codecs that don't need ICC support can provide a no-op CMS whose
68//! `build_transform` always returns an error. This satisfies the type
69//! system while making it clear that ICC transforms are unsupported.
70
71use crate::PixelFormat;
72use alloc::boxed::Box;
73
74/// ICC rendering intent — controls how colors outside the destination gamut
75/// are handled during a profile-to-profile transform.
76///
77/// # Which intent to use
78///
79/// For **display-to-display** workflows (web images, app thumbnails, photo
80/// export): use [`RelativeColorimetric`](Self::RelativeColorimetric). It
81/// preserves in-gamut colors exactly and is the de facto standard for screen
82/// output.
83///
84/// For **photographic print** with a profile that has a perceptual table:
85/// use [`Perceptual`](Self::Perceptual). It compresses the full source gamut
86/// smoothly instead of clipping.
87///
88/// For **soft-proofing** ("what will this print look like on screen"): use
89/// [`AbsoluteColorimetric`](Self::AbsoluteColorimetric) to simulate the
90/// paper white.
91///
92/// [`Saturation`](Self::Saturation) is for business graphics (pie charts,
93/// logos). It is almost never correct for photographic images.
94///
95/// # Interaction with ICC profiles
96///
97/// An ICC profile may contain up to four LUTs (AToB0–AToB3), one per intent.
98/// **Most display profiles only ship a single LUT** (relative colorimetric).
99/// When you request an intent whose LUT is absent, the CMS silently falls
100/// back to the profile's default — usually relative colorimetric. This means
101/// `Perceptual` and `RelativeColorimetric` produce **identical output** for
102/// the vast majority of display profiles (sRGB IEC 61966-2.1, Display P3,
103/// etc.). The distinction only matters for print/press profiles that include
104/// dedicated perceptual gamut-mapping tables.
105///
106/// # Bugs and pitfalls
107///
108/// - **Perceptual on display profiles is a no-op.** Requesting `Perceptual`
109///   doesn't add gamut mapping when the profile lacks a perceptual table —
110///   it silently degrades to clipping. If you need actual gamut mapping
111///   between display profiles, you must supply a profile that contains
112///   perceptual intent tables (e.g., a proofing profile or a carefully
113///   authored display profile).
114///
115/// - **AbsoluteColorimetric tints whites.** Source white is preserved
116///   literally, so a D50 source on a D65 display shows yellowish whites.
117///   Never use this for final output — only for proofing previews.
118///
119/// - **Saturation may shift hues.** The ICC spec allows saturation-intent
120///   tables to sacrifice hue accuracy for vividness. Photographs will look
121///   wrong.
122#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
123pub enum RenderingIntent {
124    /// Compress the entire source gamut into the destination gamut,
125    /// preserving the perceptual relationship between colors at the cost
126    /// of shifting all values (including in-gamut ones).
127    ///
128    /// **Requires a perceptual LUT in the profile.** Most display profiles
129    /// omit this table, so the CMS falls back to relative colorimetric
130    /// silently. This intent only behaves differently from
131    /// `RelativeColorimetric` when both source and destination profiles
132    /// contain dedicated perceptual rendering tables — typically print,
133    /// press, or carefully authored proofing profiles.
134    ///
135    /// When it works: smooth, continuous gamut mapping with no hard clips.
136    /// When the LUT is missing: identical to `RelativeColorimetric`.
137    ///
138    /// **CMS compatibility warning:** moxcms's perceptual intent
139    /// implementation does not match lcms2's output and may not be
140    /// accurate for all profile combinations. If cross-CMS consistency
141    /// matters, prefer [`RelativeColorimetric`](Self::RelativeColorimetric).
142    Perceptual,
143
144    /// Preserve in-gamut colors exactly; clip out-of-gamut colors to the
145    /// nearest boundary color. White point is adapted from source to
146    /// destination (source white → destination white).
147    ///
148    /// This is the correct default for virtually all display-to-display
149    /// workflows: web images, app thumbnails, photo export, screen preview.
150    /// Colors that fit in the destination gamut are reproduced without any
151    /// remapping — what the numbers say is what you get.
152    ///
153    /// **Tradeoff:** saturated gradients that cross the gamut boundary can
154    /// show hard clipping artifacts (banding). If the source gamut is much
155    /// wider than the destination (e.g., BT.2020 → sRGB), consider whether
156    /// a perceptual-intent profile or a dedicated gamut-mapping step would
157    /// produce smoother results.
158    #[default]
159    RelativeColorimetric,
160
161    /// Maximize saturation and vividness, sacrificing hue accuracy.
162    /// Designed for business graphics: charts, logos, presentation slides.
163    ///
164    /// **Not suitable for photographs.** Hue shifts are expected and
165    /// intentional — the goal is "vivid", not "accurate".
166    ///
167    /// Like `Perceptual`, many profiles lack a saturation-intent LUT.
168    /// When absent, the CMS falls back to the profile's default intent.
169    Saturation,
170
171    /// Like `RelativeColorimetric` but **without** white point adaptation.
172    /// Source white is preserved literally: a D50 (warm) source displayed
173    /// on a D65 (cool) screen will show yellowish whites.
174    ///
175    /// **Use exclusively for soft-proofing**: simulating how a print will
176    /// look by preserving the paper white and ink gamut on screen. Never
177    /// use for final output — the tinted whites look wrong on every
178    /// display except the exact one being simulated.
179    AbsoluteColorimetric,
180}
181
182/// Controls which transfer function metadata the CMS trusts when building
183/// a transform.
184///
185/// ICC profiles store transfer response curves (TRC) as `curv` or `para`
186/// tags — lookup tables or parametric curves baked into the profile. Modern
187/// container formats (JPEG XL, HEIF/AVIF, AV1) also carry CICP transfer
188/// characteristics — an integer code that names an exact mathematical
189/// transfer function (sRGB, PQ, HLG, etc.).
190///
191/// When both are present, they should agree — but in practice, the ICC TRC
192/// may be a reduced-precision approximation of the CICP function (limited
193/// by `curv` table size or `para` parameter quantization). The question is
194/// which source of truth to prefer.
195///
196/// # Which priority to use
197///
198/// - **Standard ICC workflows** (JPEG, PNG, TIFF, WebP): use
199///   [`PreferIcc`](Self::IccOnly). These formats don't carry CICP metadata;
200///   the ICC profile is the sole authority.
201///
202/// - **CICP-native formats** (JPEG XL, HEIF, AVIF): use
203///   [`PreferCicp`](Self::PreferCicp). The CICP code is the authoritative
204///   description; the ICC profile exists for backwards compatibility with
205///   older software.
206///
207/// # Bugs and pitfalls
208///
209/// - **CICP ≠ ICC is a real bug.** Some encoders embed a generic sRGB ICC
210///   profile alongside a PQ or HLG CICP code. Using `PreferCicp` is correct
211///   here — the ICC profile is wrong (or at best, a tone-mapped fallback).
212///   Using `PreferIcc` would silently apply the wrong transfer function.
213///
214/// - **`PreferIcc` for CICP-native formats loses precision.** If the ICC
215///   profile's `curv` table is a 1024-entry LUT approximating the sRGB
216///   function, you get quantization steps in dark tones. The CICP code
217///   gives the exact closed-form function — no quantization, no table
218///   interpolation error.
219///
220/// - **`PreferCicp` for pure-ICC formats is harmless but pointless.** If
221///   the profile has no embedded CICP metadata, the CMS ignores this flag
222///   and falls back to the TRC. No wrong output, just a wasted branch.
223///
224/// - **Advisory vs. authoritative.** The ICC Votable Proposal on CICP
225///   metadata in ICC profiles designates the CICP fields as *advisory*.
226///   The profile's actual TRC tags remain the normative description.
227///   `PreferIcc` follows this interpretation. `PreferCicp` overrides it
228///   for formats where the container's CICP is known to be authoritative.
229#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
230pub enum ColorPriority {
231    /// Prefer the ICC profile's own `curv`/`para` TRC curves. Ignore any
232    /// embedded CICP transfer characteristics.
233    ///
234    /// Correct for standard ICC workflows (JPEG, PNG, TIFF, WebP) and
235    /// any situation where the ICC profile is the sole color authority.
236    #[default]
237    PreferIcc,
238
239    /// Allow the CMS to use CICP transfer characteristics when available.
240    ///
241    /// Faster (closed-form math vs. LUT interpolation) and more precise
242    /// (no table quantization error). Correct only for formats where CICP
243    /// is the authoritative color description: JPEG XL, HEIF, AVIF.
244    PreferCicp,
245}
246
247/// Row-level color transform produced by a [`ColorManagement`] implementation.
248///
249/// Applies an ICC-to-ICC color conversion to a row of pixel data.
250pub trait RowTransform: Send {
251    /// Transform one row of pixels from source to destination color space.
252    ///
253    /// `src` and `dst` may be different lengths if the transform changes
254    /// the pixel format (e.g., CMYK to RGB). `width` is the number of
255    /// pixels, not bytes.
256    fn transform_row(&self, src: &[u8], dst: &mut [u8], width: u32);
257}
258
259/// Color management system interface.
260///
261/// Abstracts over CMS backends (moxcms, lcms2, etc.) to provide
262/// ICC profile transforms and profile identification.
263///
264/// # Feature-gated
265///
266/// The trait is always available for trait bounds and generic code.
267/// Concrete implementations are provided by feature-gated modules
268/// (e.g., `cms-moxcms`).
269pub trait ColorManagement {
270    /// Error type for CMS operations.
271    type Error: core::fmt::Debug;
272
273    /// Build a row-level transform between two ICC profiles.
274    ///
275    /// Returns a [`RowTransform`] that converts pixel rows from the
276    /// source profile's color space to the destination profile's.
277    ///
278    /// This method assumes u8 RGB pixel data. For format-aware transforms
279    /// that match the actual source/destination bit depth and layout, use
280    /// [`build_transform_for_format`](Self::build_transform_for_format).
281    fn build_transform(
282        &self,
283        src_icc: &[u8],
284        dst_icc: &[u8],
285    ) -> Result<Box<dyn RowTransform>, Self::Error>;
286
287    /// Build a format-aware row-level transform between two ICC profiles.
288    ///
289    /// Like [`build_transform`](Self::build_transform), but the CMS backend
290    /// can use the pixel format information to create a transform at the
291    /// native bit depth (u8, u16, or f32) and layout (RGB, RGBA, Gray, etc.),
292    /// avoiding unnecessary depth conversions.
293    ///
294    /// The default implementation ignores the format parameters and delegates
295    /// to [`build_transform`](Self::build_transform).
296    fn build_transform_for_format(
297        &self,
298        src_icc: &[u8],
299        dst_icc: &[u8],
300        src_format: PixelFormat,
301        dst_format: PixelFormat,
302    ) -> Result<Box<dyn RowTransform>, Self::Error> {
303        let _ = (src_format, dst_format);
304        self.build_transform(src_icc, dst_icc)
305    }
306
307    /// Identify whether an ICC profile matches a known CICP combination.
308    ///
309    /// Two-tier matching:
310    /// 1. Hash table of known ICC byte sequences for instant lookup.
311    /// 2. Semantic comparison: parse matrix + TRC, compare against known
312    ///    values within tolerance.
313    ///
314    /// Returns `Some(cicp)` if the profile matches a standard combination,
315    /// `None` if the profile is custom.
316    fn identify_profile(&self, icc: &[u8]) -> Option<crate::Cicp>;
317
318    // TODO(0.3.0): Add build_source_transform(ColorProfileSource, ...) as the
319    // single entry point, replacing build_transform / build_transform_for_format.
320    // Deferred until the trait is redesigned with options (rendering intent, HDR
321    // policy) and ZenCmsLite is benchmarked against moxcms on all platforms.
322}