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}