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::PreferIcc). 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/// Shareable, stateless row-level color transform.
248///
249/// Takes `&self` — the same instance can be held behind `Arc<dyn RowTransform>`
250/// and reused across threads, converters, or cached for batch workloads.
251/// Appropriate when the transform carries no per-call mutable state: pure
252/// matrix/LUT math, moxcms `TransformExecutor` (whose `transform(&self, ...)`
253/// is already `&self`), or any stateless formula-based conversion.
254///
255/// When the transform needs scratch buffers or per-call state, use
256/// [`RowTransformMut`] instead.
257pub trait RowTransform: Send + Sync {
258    /// Transform one row of pixels from source to destination color space.
259    ///
260    /// `src` and `dst` may be different lengths if the transform changes
261    /// the pixel format (e.g., CMYK to RGB). `width` is the number of
262    /// pixels, not bytes.
263    fn transform_row(&self, src: &[u8], dst: &mut [u8], width: u32);
264}
265
266/// Owned, stateful row-level color transform.
267///
268/// Takes `&mut self` — each [`RowConverter`] owns its own `Box<dyn
269/// RowTransformMut>`, so implementations can reuse scratch buffers and
270/// update internal state per call without interior mutability.
271///
272/// When the transform is stateless and could be shared, use
273/// [`RowTransform`] instead — [`PluggableCms`] can offer both paths via
274/// [`build_shared_source_transform`](PluggableCms::build_shared_source_transform).
275///
276/// [`RowConverter`]: crate::RowConverter
277pub trait RowTransformMut: Send {
278    /// Transform one row of pixels from source to destination color space.
279    ///
280    /// `src` and `dst` may be different lengths if the transform changes
281    /// the pixel format (e.g., CMYK to RGB). `width` is the number of
282    /// pixels, not bytes.
283    fn transform_row(&mut self, src: &[u8], dst: &mut [u8], width: u32);
284}
285
286/// Color management system interface.
287///
288/// Abstracts over CMS backends (moxcms, lcms2, etc.) to provide
289/// ICC profile transforms and profile identification.
290///
291/// # Feature-gated
292///
293/// The trait is always available for trait bounds and generic code.
294/// Concrete implementations are provided by feature-gated modules
295/// (e.g., `cms-moxcms`).
296///
297/// # Deprecated
298///
299/// Prefer [`PluggableCms`] for new code. `ColorManagement` is generic,
300/// not dyn-safe, and takes raw ICC byte pairs; `PluggableCms` is
301/// dyn-safe, accepts [`ColorProfileSource`] (ICC / CICP / named /
302/// primaries+transfer), carries [`ConvertOptions`], and composes into
303/// the dispatch chain used by
304/// [`RowConverter::new_explicit_with_cms`](crate::RowConverter::new_explicit_with_cms).
305///
306/// [`ColorProfileSource`]: crate::ColorProfileSource
307/// [`ConvertOptions`]: crate::policy::ConvertOptions
308#[deprecated(
309    since = "0.2.8",
310    note = "use PluggableCms (dyn-safe, ColorProfileSource-based)"
311)]
312pub trait ColorManagement {
313    /// Error type for CMS operations.
314    type Error: core::fmt::Debug;
315
316    /// Build a row-level transform between two ICC profiles.
317    ///
318    /// Returns a [`RowTransform`] that converts pixel rows from the
319    /// source profile's color space to the destination profile's.
320    ///
321    /// This method assumes u8 RGB pixel data. For format-aware transforms
322    /// that match the actual source/destination bit depth and layout, use
323    /// [`build_transform_for_format`](Self::build_transform_for_format).
324    fn build_transform(
325        &self,
326        src_icc: &[u8],
327        dst_icc: &[u8],
328    ) -> Result<Box<dyn RowTransform>, Self::Error>;
329
330    /// Build a format-aware row-level transform between two ICC profiles.
331    ///
332    /// Like [`build_transform`](Self::build_transform), but the CMS backend
333    /// can use the pixel format information to create a transform at the
334    /// native bit depth (u8, u16, or f32) and layout (RGB, RGBA, Gray, etc.),
335    /// avoiding unnecessary depth conversions.
336    ///
337    /// The default implementation ignores the format parameters and delegates
338    /// to [`build_transform`](Self::build_transform).
339    fn build_transform_for_format(
340        &self,
341        src_icc: &[u8],
342        dst_icc: &[u8],
343        src_format: PixelFormat,
344        dst_format: PixelFormat,
345    ) -> Result<Box<dyn RowTransform>, Self::Error> {
346        let _ = (src_format, dst_format);
347        self.build_transform(src_icc, dst_icc)
348    }
349
350    /// Identify whether an ICC profile matches a known CICP combination.
351    ///
352    /// Two-tier matching:
353    /// 1. Hash table of known ICC byte sequences for instant lookup.
354    /// 2. Semantic comparison: parse matrix + TRC, compare against known
355    ///    values within tolerance.
356    ///
357    /// Returns `Some(cicp)` if the profile matches a standard combination,
358    /// `None` if the profile is custom.
359    fn identify_profile(&self, icc: &[u8]) -> Option<crate::Cicp>;
360
361    // TODO(0.3.0): Add build_source_transform(ColorProfileSource, ...) as the
362    // single entry point, replacing build_transform / build_transform_for_format.
363    // Deferred until the trait is redesigned with options (rendering intent, HDR
364    // policy) and ZenCmsLite is benchmarked against moxcms on all platforms.
365}
366
367/// Dyn-compatible CMS plugin interface for overriding gamut/profile
368/// conversions inside a [`ConvertPlan`](crate::ConvertPlan).
369///
370/// When a `PluggableCms` is passed to
371/// [`RowConverter::new_explicit_with_cms`](crate::RowConverter::new_explicit_with_cms)
372/// and the source and destination profiles differ, the plan asks the
373/// plugin whether it will handle the exact `(src_format, dst_format)`
374/// pair. If the plugin returns a transform, the plan collapses to a
375/// single external-transform step that drives the row end-to-end —
376/// built-in linearize → gamut-matrix → encode steps (and their fused
377/// matlut kernels) are bypassed for that conversion. If the plugin
378/// returns `None`, the plan falls back to the built-in path.
379///
380/// `PluggableCms` is intentionally narrower than [`ColorManagement`]:
381/// - It accepts [`ColorProfileSource`] instead of raw ICC bytes, so
382///   plugins can use primaries/transfer shortcuts, named profiles, CICP,
383///   or ICC without forcing the caller to serialize to ICC.
384/// - It receives [`ConvertOptions`] so plugins can honor
385///   `clip_out_of_gamut` and future fields like rendering intent.
386/// - It is dyn-compatible (no associated `Error` type; no generics).
387///   This is what lets it live behind `&dyn PluggableCms` in API
388///   signatures without forcing every caller to monomorphize.
389///
390/// # Decline vs. fail
391///
392/// Plugin methods return `Option<Result<T, CmsPluginError>>` with three
393/// outcomes:
394/// - `None` — declined ("not my problem"). The dispatch chain continues
395///   to the next plugin (typically `ZenCmsLite`) or falls through to the
396///   built-in path.
397/// - `Some(Ok(transform))` — accepted. The dispatch chain stops here.
398/// - `Some(Err(e))` — tried-and-failed. The error propagates immediately;
399///   **the chain does not continue**. If a plugin took ownership of a
400///   conversion and failed, we surface that rather than silently producing
401///   different output from a fallback backend.
402///
403/// [`ColorProfileSource`]: crate::ColorProfileSource
404/// [`ConvertOptions`]: crate::policy::ConvertOptions
405pub trait PluggableCms: Send + Sync {
406    /// Attempt to build an owned, stateful row transform covering the full
407    /// source → destination conversion for the given pixel formats.
408    ///
409    /// `options` carries policy flags the plugin may honor (e.g.,
410    /// `clip_out_of_gamut`). The plugin is free to ignore fields that
411    /// don't apply to its implementation.
412    ///
413    /// See the trait docs for decline vs. fail semantics.
414    ///
415    /// The `Err` arm is [`whereat::At<CmsPluginError>`] so the plugin's
416    /// internal failure point is recorded for debugging. Use
417    /// [`whereat::at!`] or `ResultAtExt::at()` to construct.
418    fn build_source_transform(
419        &self,
420        src: crate::ColorProfileSource<'_>,
421        dst: crate::ColorProfileSource<'_>,
422        src_format: PixelFormat,
423        dst_format: PixelFormat,
424        options: &crate::policy::ConvertOptions,
425    ) -> Option<Result<Box<dyn RowTransformMut>, whereat::At<CmsPluginError>>>;
426
427    /// Optionally build a shareable, stateless row transform for the same
428    /// conversion.
429    ///
430    /// When the transform carries no per-call mutable state, returning
431    /// `Arc<dyn RowTransform>` enables sharing across threads, caching for
432    /// batch workloads, and cheap `RowConverter` clones. Default returns
433    /// `None` — plugins without a stateless fast path fall through to the
434    /// owned [`build_source_transform`](Self::build_source_transform).
435    ///
436    /// `RowConverter::new_explicit_with_cms` tries this method first.
437    /// See the trait docs for decline vs. fail semantics. `Err` arm is
438    /// [`whereat::At<CmsPluginError>`] — same location-tracking semantics
439    /// as [`build_source_transform`](Self::build_source_transform).
440    fn build_shared_source_transform(
441        &self,
442        _src: crate::ColorProfileSource<'_>,
443        _dst: crate::ColorProfileSource<'_>,
444        _src_format: PixelFormat,
445        _dst_format: PixelFormat,
446        _options: &crate::policy::ConvertOptions,
447    ) -> Option<Result<alloc::sync::Arc<dyn RowTransform>, whereat::At<CmsPluginError>>> {
448        None
449    }
450}
451
452/// Error produced by a [`PluggableCms`] when a plugin recognized a
453/// conversion pair but failed to build a transform.
454///
455/// Type-erased wrapper over any `core::error::Error + Send + Sync`. Use
456/// [`CmsPluginError::new`] or [`From`] to construct.
457pub struct CmsPluginError(Box<dyn core::error::Error + Send + Sync + 'static>);
458
459impl CmsPluginError {
460    /// Construct from any error that implements `core::error::Error`.
461    pub fn new<E>(err: E) -> Self
462    where
463        E: core::error::Error + Send + Sync + 'static,
464    {
465        Self(Box::new(err))
466    }
467
468    /// Construct from a message string.
469    pub fn msg(s: impl Into<alloc::string::String>) -> Self {
470        struct Msg(alloc::string::String);
471        impl core::fmt::Debug for Msg {
472            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
473                f.write_str(&self.0)
474            }
475        }
476        impl core::fmt::Display for Msg {
477            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
478                f.write_str(&self.0)
479            }
480        }
481        impl core::error::Error for Msg {}
482        Self(Box::new(Msg(s.into())))
483    }
484
485    /// Borrow the inner error.
486    pub fn as_inner(&self) -> &(dyn core::error::Error + Send + Sync + 'static) {
487        &*self.0
488    }
489}
490
491impl core::fmt::Debug for CmsPluginError {
492    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
493        f.debug_tuple("CmsPluginError").field(&self.0).finish()
494    }
495}
496
497impl core::fmt::Display for CmsPluginError {
498    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
499        core::fmt::Display::fmt(&self.0, f)
500    }
501}
502
503impl core::error::Error for CmsPluginError {
504    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
505        Some(self.0.as_ref())
506    }
507}