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 /// Build a format-aware row-level transform from CICP codes to an ICC profile.
308 ///
309 /// Like [`build_transform_for_format`](Self::build_transform_for_format),
310 /// but the source profile is constructed from CICP code points instead of
311 /// parsed from ICC bytes. Avoids the ICC serialize→deserialize round-trip
312 /// when [`ColorAuthority::Cicp`](crate::ColorAuthority) indicates that CICP
313 /// is the authoritative color description.
314 ///
315 /// The default implementation synthesizes an ICC profile from the CICP codes
316 /// and delegates to [`build_transform_for_format`](Self::build_transform_for_format).
317 /// Backends that can construct source profiles directly from CICP (e.g.,
318 /// `ColorProfile::new_from_cicp()` in moxcms) should override this for
319 /// better performance.
320 fn build_transform_from_cicp(
321 &self,
322 src_cicp: crate::Cicp,
323 dst_icc: &[u8],
324 src_format: PixelFormat,
325 dst_format: PixelFormat,
326 ) -> Result<Box<dyn RowTransform>, Self::Error>;
327
328 /// Identify whether an ICC profile matches a known CICP combination.
329 ///
330 /// Two-tier matching:
331 /// 1. Hash table of known ICC byte sequences for instant lookup.
332 /// 2. Semantic comparison: parse matrix + TRC, compare against known
333 /// values within tolerance.
334 ///
335 /// Returns `Some(cicp)` if the profile matches a standard combination,
336 /// `None` if the profile is custom.
337 fn identify_profile(&self, icc: &[u8]) -> Option<crate::Cicp>;
338}