Skip to main content

zenpixels_convert/
cms_moxcms.rs

1//! CMS backend using [moxcms](https://crates.io/crates/moxcms).
2//!
3//! Provides a concrete [`ColorManagement`] implementation backed by the moxcms
4//! ICC profile engine. Requires the `cms-moxcms` feature.
5//!
6//! # Supported formats
7//!
8//! Transforms are created at the native bit depth (u8, u16, or f32) and layout
9//! (RGB, RGBA, Gray, GrayAlpha) of the source and destination pixel formats.
10//! Formats without a direct moxcms layout mapping (Bgra, Rgbx, Bgrx, Oklab)
11//! fall back to u8 RGB.
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use zenpixels_convert::cms_moxcms::MoxCms;
17//! use zenpixels_convert::output::{finalize_for_output, OutputProfile};
18//!
19//! let ready = finalize_for_output(
20//!     &buffer, &origin,
21//!     OutputProfile::Icc(dst_icc.into()),
22//!     PixelFormat::Rgb8,
23//!     &MoxCms,
24//! )?;
25//! ```
26
27use alloc::boxed::Box;
28use alloc::format;
29use alloc::sync::Arc;
30
31use moxcms::{
32    BarycentricWeightScale, ColorProfile, InterpolationMethod, Layout, TransformExecutor,
33    TransformOptions,
34};
35
36use crate::cms::{ColorPriority, RenderingIntent};
37
38/// Build moxcms [`TransformOptions`] from a [`ColorPriority`] and
39/// [`RenderingIntent`].
40///
41/// This is the single entry point for constructing moxcms transform options.
42/// It applies our quality defaults (tetrahedral interpolation, high-precision
43/// barycentric weights) and maps the backend-agnostic enums to moxcms types.
44///
45/// # Parameters
46///
47/// - `priority` — which transfer function metadata to trust. Use
48///   [`ColorPriority::PreferIcc`] for standard ICC workflows (JPEG, PNG, TIFF,
49///   WebP). Use [`ColorPriority::PreferCicp`] for CICP-native formats (JPEG XL,
50///   HEIF, AVIF) where the CICP code is the authoritative description and the
51///   ICC profile is a backwards-compatibility fallback.
52///
53/// - `intent` — ICC rendering intent. Use
54///   [`RenderingIntent::RelativeColorimetric`] (the default) for display output.
55///   See [`RenderingIntent`] docs for when to use other intents.
56///
57/// # Quality settings
58///
59/// The following are always applied regardless of arguments:
60///
61/// - **Tetrahedral interpolation** over trilinear for 3D CLUTs. Produces
62///   higher accuracy in saturated regions where trilinear interpolation
63///   crosses cube diagonals. No measurable performance cost for the image
64///   sizes we handle.
65///
66/// - **High barycentric weight scale.** Cuts LUT interpolation error from
67///   max ≤ 14 to max ≤ 2 (code values, u8 scale) vs. lcms2 for standard
68///   ICC LUT profiles. The 5% performance cost cited in moxcms docs is
69///   negligible at our call granularity (row-level transforms, not
70///   pixel-level).
71///
72/// # Rendering intent vs. profile LUT availability
73///
74/// Requesting an intent whose LUT is absent in the profile causes a silent
75/// fallback to the profile's default intent (typically relative colorimetric).
76/// Most display profiles only ship one LUT. See [`RenderingIntent`] docs for
77/// details on which profiles actually honor which intents.
78///
79/// # Examples
80///
81/// ```rust,ignore
82/// use zenpixels_convert::cms::{ColorPriority, RenderingIntent};
83/// use zenpixels_convert::cms_moxcms::transform_opts;
84///
85/// // Standard ICC workflow (JPEG, PNG, etc.)
86/// let opts = transform_opts(ColorPriority::PreferIcc, RenderingIntent::RelativeColorimetric);
87///
88/// // JPEG XL decode — trust CICP transfer characteristics
89/// let opts = transform_opts(ColorPriority::PreferCicp, RenderingIntent::RelativeColorimetric);
90///
91/// // Soft-proofing: simulate print appearance on screen
92/// let opts = transform_opts(ColorPriority::PreferIcc, RenderingIntent::AbsoluteColorimetric);
93/// ```
94pub fn transform_opts(priority: ColorPriority, intent: RenderingIntent) -> TransformOptions {
95    TransformOptions {
96        rendering_intent: match intent {
97            RenderingIntent::Perceptual => moxcms::RenderingIntent::Perceptual,
98            RenderingIntent::RelativeColorimetric => moxcms::RenderingIntent::RelativeColorimetric,
99            RenderingIntent::Saturation => moxcms::RenderingIntent::Saturation,
100            RenderingIntent::AbsoluteColorimetric => moxcms::RenderingIntent::AbsoluteColorimetric,
101        },
102        allow_use_cicp_transfer: matches!(priority, ColorPriority::PreferCicp),
103        barycentric_weight_scale: BarycentricWeightScale::High,
104        interpolation_method: InterpolationMethod::Tetrahedral,
105        ..Default::default()
106    }
107}
108
109/// Standard moxcms transform options for ICC LUT transforms.
110///
111/// # Deprecated
112///
113/// Use [`transform_opts`]`(`[`ColorPriority::PreferIcc`]`,
114/// `[`RenderingIntent::RelativeColorimetric`]`)` instead, which lets you
115/// specify the rendering intent explicitly.
116#[deprecated(
117    since = "0.2.3",
118    note = "use transform_opts(ColorPriority::PreferIcc, RenderingIntent::RelativeColorimetric) instead"
119)]
120pub fn lut_transform_opts() -> TransformOptions {
121    transform_opts(
122        ColorPriority::PreferIcc,
123        RenderingIntent::RelativeColorimetric,
124    )
125}
126
127/// Standard moxcms transform options for CICP-native formats (e.g. JXL, HEIF).
128///
129/// # Deprecated
130///
131/// Use [`transform_opts`]`(`[`ColorPriority::PreferCicp`]`,
132/// `[`RenderingIntent::RelativeColorimetric`]`)` instead, which lets you
133/// specify the rendering intent explicitly.
134#[deprecated(
135    since = "0.2.3",
136    note = "use transform_opts(ColorPriority::PreferCicp, RenderingIntent::RelativeColorimetric) instead"
137)]
138pub fn cicp_transform_opts() -> TransformOptions {
139    transform_opts(
140        ColorPriority::PreferCicp,
141        RenderingIntent::RelativeColorimetric,
142    )
143}
144
145#[allow(deprecated)]
146use crate::cms::{ColorManagement, RowTransform};
147use crate::{ChannelType, Cicp, PixelFormat};
148
149/// CMS backend using moxcms.
150///
151/// Stateless — all configuration comes from the ICC profiles and pixel formats
152/// passed to each method call. Safe to share across threads.
153#[derive(Debug, Clone, Copy, Default)]
154pub struct MoxCms;
155
156/// Map a [`PixelFormat`] to the corresponding moxcms [`Layout`].
157///
158/// Returns `None` for formats that don't have a direct moxcms mapping
159/// (Bgra, Rgbx, Bgrx, Oklab variants).
160///
161/// **CMYK note.** `Cmyk8` maps to [`Layout::Rgba`] per moxcms's
162/// convention (see `moxcms::Layout` docs: "Cmyk8 uses the same layout
163/// as Rgba8"). The `DataColorSpace` on the source profile is what
164/// distinguishes CMYK from RGBA in moxcms — the layout is just a
165/// channel-count + interleave hint.
166fn pixel_format_to_layout(format: PixelFormat) -> Option<Layout> {
167    match format {
168        PixelFormat::Rgb8 | PixelFormat::Rgb16 | PixelFormat::RgbF32 => Some(Layout::Rgb),
169        PixelFormat::Rgba8 | PixelFormat::Rgba16 | PixelFormat::RgbaF32 => Some(Layout::Rgba),
170        PixelFormat::Gray8 | PixelFormat::Gray16 | PixelFormat::GrayF32 => Some(Layout::Gray),
171        PixelFormat::GrayA8 | PixelFormat::GrayA16 | PixelFormat::GrayAF32 => {
172            Some(Layout::GrayAlpha)
173        }
174        // CMYK shares the 4-channel interleaved layout with RGBA in moxcms;
175        // moxcms's `check_layout` validates `DataColorSpace::Cmyk` against
176        // `Layout::Rgba` (see moxcms/src/profile.rs).
177        PixelFormat::Cmyk8 => Some(Layout::Rgba),
178        _ => None,
179    }
180}
181
182// ---------------------------------------------------------------------------
183// RowTransform wrapper
184// ---------------------------------------------------------------------------
185
186/// Internal wrapper around moxcms transform executors at different bit depths.
187enum MoxTransformInner {
188    U8(Arc<dyn TransformExecutor<u8> + Send + Sync>),
189    U16(Arc<dyn TransformExecutor<u16> + Send + Sync>),
190    F32(Arc<dyn TransformExecutor<f32> + Send + Sync>),
191}
192
193struct MoxRowTransform {
194    inner: MoxTransformInner,
195}
196
197#[allow(deprecated)]
198impl RowTransform for MoxRowTransform {
199    fn transform_row(&self, src: &[u8], dst: &mut [u8], _width: u32) {
200        match &self.inner {
201            MoxTransformInner::U8(xform) => {
202                xform
203                    .transform(src, dst)
204                    .expect("moxcms u8 transform: buffer size mismatch");
205            }
206            MoxTransformInner::U16(xform) => {
207                let src_u16: &[u16] = bytemuck::cast_slice(src);
208                let dst_u16: &mut [u16] = bytemuck::cast_slice_mut(dst);
209                xform
210                    .transform(src_u16, dst_u16)
211                    .expect("moxcms u16 transform: buffer size mismatch");
212            }
213            MoxTransformInner::F32(xform) => {
214                let src_f32: &[f32] = bytemuck::cast_slice(src);
215                let dst_f32: &mut [f32] = bytemuck::cast_slice_mut(dst);
216                xform
217                    .transform(src_f32, dst_f32)
218                    .expect("moxcms f32 transform: buffer size mismatch");
219            }
220        }
221    }
222}
223
224// ---------------------------------------------------------------------------
225// ColorManagement implementation
226// ---------------------------------------------------------------------------
227
228/// Build a [`RowTransform`] from two already-parsed [`ColorProfile`]s.
229///
230/// Shared implementation for both ICC-to-ICC and CICP-to-ICC paths.
231/// Always uses `PreferIcc` / `RelativeColorimetric` — CICP-in-ICC tags
232/// are never trusted for TRC (see moxcms issue #154).
233fn build_transform_inner(
234    src_profile: &ColorProfile,
235    dst_profile: &ColorProfile,
236    src_format: PixelFormat,
237    dst_format: PixelFormat,
238) -> Result<Box<dyn RowTransform>, MoxCmsError> {
239    let src_layout = pixel_format_to_layout(src_format).unwrap_or(Layout::Rgb);
240    let dst_layout = pixel_format_to_layout(dst_format).unwrap_or(Layout::Rgb);
241    let opts = transform_opts(ColorPriority::PreferIcc, RenderingIntent::default());
242
243    let depth = src_format.channel_type();
244
245    let inner = match depth {
246        ChannelType::U8 => {
247            let xform = src_profile
248                .create_transform_8bit(src_layout, dst_profile, dst_layout, opts)
249                .map_err(|e| MoxCmsError(format!("failed to create u8 transform: {e}")))?;
250            MoxTransformInner::U8(xform)
251        }
252        ChannelType::U16 => {
253            let xform = src_profile
254                .create_transform_16bit(src_layout, dst_profile, dst_layout, opts)
255                .map_err(|e| MoxCmsError(format!("failed to create u16 transform: {e}")))?;
256            MoxTransformInner::U16(xform)
257        }
258        // F16 and F32 both use the f32 transform path (F16 data must be
259        // converted to f32 before CMS — IEEE 754 half-floats are not
260        // integer-encoded u16 values).
261        ChannelType::F16 | ChannelType::F32 | _ => {
262            let xform = src_profile
263                .create_transform_f32(src_layout, dst_profile, dst_layout, opts)
264                .map_err(|e| MoxCmsError(format!("failed to create f32 transform: {e}")))?;
265            MoxTransformInner::F32(xform)
266        }
267    };
268
269    Ok(Box::new(MoxRowTransform { inner }))
270}
271
272#[allow(deprecated)]
273impl ColorManagement for MoxCms {
274    type Error = MoxCmsError;
275
276    fn build_transform(
277        &self,
278        src_icc: &[u8],
279        dst_icc: &[u8],
280    ) -> Result<Box<dyn RowTransform>, Self::Error> {
281        self.build_transform_for_format(src_icc, dst_icc, PixelFormat::Rgb8, PixelFormat::Rgb8)
282    }
283
284    fn build_transform_for_format(
285        &self,
286        src_icc: &[u8],
287        dst_icc: &[u8],
288        src_format: PixelFormat,
289        dst_format: PixelFormat,
290    ) -> Result<Box<dyn RowTransform>, Self::Error> {
291        let src_profile = ColorProfile::new_from_slice(src_icc)
292            .map_err(|e| MoxCmsError(format!("failed to parse source ICC profile: {e}")))?;
293        let dst_profile = ColorProfile::new_from_slice(dst_icc)
294            .map_err(|e| MoxCmsError(format!("failed to parse destination ICC profile: {e}")))?;
295
296        build_transform_inner(&src_profile, &dst_profile, src_format, dst_format)
297    }
298
299    fn identify_profile(&self, icc: &[u8]) -> Option<Cicp> {
300        let profile = ColorProfile::new_from_slice(icc).ok()?;
301
302        // If the profile has embedded CICP metadata, use it directly.
303        if let Some(cicp) = &profile.cicp {
304            return Some(Cicp::new(
305                cicp.color_primaries as u8,
306                cicp.transfer_characteristics as u8,
307                cicp.matrix_coefficients as u8,
308                cicp.full_range,
309            ));
310        }
311
312        // Fall back to comparing colorant matrices against known profiles.
313        identify_by_colorants(&profile)
314    }
315
316    // TODO(0.3.0): implement build_source_transform once the trait method
317    // is added. The plumbing (source_to_moxcms_profile) is already here.
318}
319
320// ---------------------------------------------------------------------------
321// PluggableCms — the dispatch chain RowConverter actually consults.
322// ---------------------------------------------------------------------------
323
324/// `RowTransformMut` wrapper for moxcms transform executors at the three
325/// supported bit depths. Mirrors [`MoxRowTransform`] but exposes the
326/// `RowTransformMut` (`&mut self`) shape that [`PluggableCms`] expects.
327///
328/// moxcms `TransformExecutor::transform` is `&self`, so there's no actual
329/// per-call mutable state — the `&mut self` shape is a trait-level
330/// convenience and matches the [`RowConverter`] ownership model
331/// (`Box<dyn RowTransformMut>` per converter).
332///
333/// [`RowConverter`]: crate::RowConverter
334struct MoxRowTransformMut {
335    inner: MoxTransformInner,
336}
337
338impl crate::cms::RowTransformMut for MoxRowTransformMut {
339    fn transform_row(&mut self, src: &[u8], dst: &mut [u8], _width: u32) {
340        match &self.inner {
341            MoxTransformInner::U8(xform) => {
342                xform
343                    .transform(src, dst)
344                    .expect("moxcms u8 transform: buffer size mismatch");
345            }
346            MoxTransformInner::U16(xform) => {
347                let src_u16: &[u16] = bytemuck::cast_slice(src);
348                let dst_u16: &mut [u16] = bytemuck::cast_slice_mut(dst);
349                xform
350                    .transform(src_u16, dst_u16)
351                    .expect("moxcms u16 transform: buffer size mismatch");
352            }
353            MoxTransformInner::F32(xform) => {
354                let src_f32: &[f32] = bytemuck::cast_slice(src);
355                let dst_f32: &mut [f32] = bytemuck::cast_slice_mut(dst);
356                xform
357                    .transform(src_f32, dst_f32)
358                    .expect("moxcms f32 transform: buffer size mismatch");
359            }
360        }
361    }
362}
363
364impl crate::cms::PluggableCms for MoxCms {
365    /// Build a moxcms-backed row transform for the given
366    /// `(src, dst, src_format, dst_format)`.
367    ///
368    /// **Decline (`None`)** when either source can't be mapped to a moxcms
369    /// `ColorProfile` (custom signature we don't recognize), when the
370    /// pixel formats don't have a `Layout` mapping, or when the
371    /// `(src, dst, src_format, dst_format)` tuple is the trivial identity
372    /// (let the built-in mechanical plan handle it).
373    ///
374    /// **Fail (`Some(Err(_))`)** when we recognized the pair and started
375    /// to build profiles or a transform but the construction itself
376    /// failed (ICC parse errors, CMYK-without-ICC, moxcms's
377    /// `check_layout` rejecting the combination, …). The dispatch chain
378    /// stops here — falling back to ZenCmsLite or the built-in plan would
379    /// silently produce different output.
380    ///
381    /// **CMYK ↔ RGB** is the primary new path enabled by this impl. moxcms
382    /// requires a real CMYK ICC profile to populate the device→PCS LUT
383    /// (no synthesizable default exists for a device-dependent ink
384    /// space), so callers must pass `ColorProfileSource::Icc(...)` for
385    /// the CMYK side. A `PrimariesTransferPair` for a CMYK descriptor
386    /// without an attached ICC is declined (`None`) rather than failed —
387    /// upstream paths (the no-CMS extension entry points) already
388    /// answer `NeedsCms`, and we want the user to provide an actual ICC.
389    fn build_source_transform(
390        &self,
391        src: crate::ColorProfileSource<'_>,
392        dst: crate::ColorProfileSource<'_>,
393        src_format: PixelFormat,
394        dst_format: PixelFormat,
395        _options: &crate::policy::ConvertOptions,
396    ) -> Option<Result<Box<dyn crate::cms::RowTransformMut>, whereat::At<crate::cms::CmsPluginError>>>
397    {
398        use crate::cms::CmsPluginError;
399        // Decline when the format pair has no `Layout` mapping (e.g. Bgra
400        // swizzles, Rgbx alpha-padding, Oklab variants). moxcms can't
401        // describe those; let the built-in pipeline handle layout
402        // shuffling and route the colorimetric work back through after.
403        let src_layout = pixel_format_to_layout(src_format)?;
404        let dst_layout = pixel_format_to_layout(dst_format)?;
405
406        // Build moxcms profiles. CMYK as a `PrimariesTransferPair` has no
407        // synthesizable mapping — decline so the caller knows to attach an
408        // ICC via `ColorProfileSource::Icc(...)`.
409        let src_profile = match build_moxcms_profile_for_format(&src, src_format) {
410            Ok(Some(p)) => p,
411            Ok(None) => return None,
412            Err(e) => {
413                return Some(Err(whereat::at!(CmsPluginError::msg(format!(
414                    "moxcms source profile build failed: {e}"
415                )))));
416            }
417        };
418        let dst_profile = match build_moxcms_profile_for_format(&dst, dst_format) {
419            Ok(Some(p)) => p,
420            Ok(None) => return None,
421            Err(e) => {
422                return Some(Err(whereat::at!(CmsPluginError::msg(format!(
423                    "moxcms destination profile build failed: {e}"
424                )))));
425            }
426        };
427
428        // Identity bytes-out (same profile, same format) is left to the
429        // built-in mechanical plan via `None`; the dispatch chain falls
430        // through to `ConvertPlan::new_explicit` which emits an Identity
431        // step. But this only fires for the truly trivial case — the
432        // mismatch dispatch above already handled CMYK by reaching this
433        // function, so a CMYK→CMYK identity is fine to decline.
434        //
435        // We don't have a cheap profile-equality check, so we just attempt
436        // the transform; moxcms returns Ok with a no-op LUT when the
437        // device→PCS→device round-trip is identity.
438
439        let opts = transform_opts(ColorPriority::PreferIcc, RenderingIntent::default());
440
441        // Dispatch on the source `ChannelType`. F16 is widened to f32
442        // before the CMS step in this crate, so we route F16 to the f32
443        // transform path (the same convention `build_transform_inner`
444        // above uses). Note: when src and dst differ on channel type, we
445        // pick the source side; the layout/bit-depth conversion happens
446        // outside moxcms in a separate plan step.
447        let depth = src_format.channel_type();
448        let inner_result = match depth {
449            ChannelType::U8 => src_profile
450                .create_transform_8bit(src_layout, &dst_profile, dst_layout, opts)
451                .map(MoxTransformInner::U8),
452            ChannelType::U16 => src_profile
453                .create_transform_16bit(src_layout, &dst_profile, dst_layout, opts)
454                .map(MoxTransformInner::U16),
455            ChannelType::F16 | ChannelType::F32 | _ => src_profile
456                .create_transform_f32(src_layout, &dst_profile, dst_layout, opts)
457                .map(MoxTransformInner::F32),
458        };
459
460        let inner = match inner_result {
461            Ok(i) => i,
462            Err(e) => {
463                return Some(Err(whereat::at!(CmsPluginError::msg(format!(
464                    "moxcms create_transform_{:?}bit failed: {e}",
465                    depth
466                )))));
467            }
468        };
469
470        Some(Ok(Box::new(MoxRowTransformMut { inner })))
471    }
472}
473
474/// Build a moxcms `ColorProfile` for the given source, with a
475/// pixel-format hint that determines whether the profile must describe
476/// CMYK ink (no synthesizable default) or an RGB / Gray colorimetric
477/// space (synthesizable from primaries + transfer).
478///
479/// Outcomes mirror the `PluggableCms` decline-vs-fail contract:
480/// - `Ok(Some(profile))` — we built a profile and the caller can use it.
481/// - `Ok(None)` — we declined (no information / not our problem); the
482///   caller should keep walking the dispatch chain.
483/// - `Err(_)` — we tried (recognized the inputs) but construction
484///   failed; the caller should surface as a tried-and-failed.
485fn build_moxcms_profile_for_format(
486    src: &crate::ColorProfileSource<'_>,
487    format: PixelFormat,
488) -> Result<Option<ColorProfile>, MoxCmsError> {
489    let is_cmyk = matches!(format, PixelFormat::Cmyk8);
490    match src {
491        // ICC bytes are authoritative for every color model — parse and
492        // hand them straight to moxcms. The profile's `data_color_space`
493        // will validate against the layout downstream
494        // (`check_layout`).
495        crate::ColorProfileSource::Icc(icc) => ColorProfile::new_from_slice(icc)
496            .map(Some)
497            .map_err(|e| MoxCmsError(format!("failed to parse ICC: {e}"))),
498        // CICP describes RGB colorimetry; CMYK descriptors with CICP
499        // (which is unusual — CICP rarely tags CMYK) decline so the
500        // caller can attach a real CMYK ICC instead.
501        crate::ColorProfileSource::Cicp(cicp) if !is_cmyk => Ok(Some(cicp_to_moxcms_profile(cicp))),
502        crate::ColorProfileSource::Named(named) if !is_cmyk => {
503            let (p, t) = named.to_primaries_transfer();
504            primaries_transfer_to_moxcms_profile(p, t)
505        }
506        crate::ColorProfileSource::PrimariesTransferPair {
507            primaries,
508            transfer,
509        } if !is_cmyk => primaries_transfer_to_moxcms_profile(*primaries, *transfer),
510        // CMYK without ICC bytes: decline. The PluggableCms chain falls
511        // through to the built-in plan path, which answers
512        // `NeedsCms` so the caller knows to attach an ICC.
513        _ => Ok(None),
514    }
515}
516
517/// Convert a [`ColorProfileSource`](crate::ColorProfileSource) to a moxcms [`ColorProfile`].
518///
519/// Returns `Ok(None)` if the source can't be mapped to moxcms.
520// TODO(0.3.0): used by build_source_transform once trait is redesigned.
521#[allow(dead_code)]
522fn source_to_moxcms_profile(
523    src: &crate::ColorProfileSource<'_>,
524) -> Result<Option<ColorProfile>, MoxCmsError> {
525    match src {
526        crate::ColorProfileSource::Icc(icc) => ColorProfile::new_from_slice(icc)
527            .map(Some)
528            .map_err(|e| MoxCmsError(format!("failed to parse ICC: {e}"))),
529        crate::ColorProfileSource::Cicp(cicp) => Ok(Some(cicp_to_moxcms_profile(cicp))),
530        crate::ColorProfileSource::Named(named) => {
531            let (p, t) = named.to_primaries_transfer();
532            primaries_transfer_to_moxcms_profile(p, t)
533        }
534        crate::ColorProfileSource::PrimariesTransferPair {
535            primaries,
536            transfer,
537        } => primaries_transfer_to_moxcms_profile(*primaries, *transfer),
538        _ => Ok(None),
539    }
540}
541
542/// Generate ICC profile bytes for a CICP via moxcms, or `None` if moxcms doesn't
543/// recognize the **color-defining** code points (primaries / transfer).
544///
545/// Strict on purpose: unlike [`cicp_to_moxcms_profile`] (the transform path, which
546/// falls back to Bt709/sRGB defaults), synthesis must never emit a profile whose
547/// TRC/gamut contradicts the source — a `None` here surfaces as
548/// [`SynthesizedIcc::CmsUnsupported`](crate::icc_profiles::SynthesizedIcc::CmsUnsupported)
549/// so the caller carries the color via CICP instead of embedding a wrong profile.
550/// Matrix coefficients are irrelevant to an RGB ICC, so they're defaulted rather
551/// than required.
552///
553/// Test-only: the bundled blob (generated from this exact logic at build time) is
554/// the runtime coverage source, so `synthesize_icc_for_cicp` no longer calls this.
555/// It's retained as the oracle the `blob_decodes_byte_identical_to_moxcms` guard
556/// compares the committed blob against — catching a moxcms version bump that would
557/// shift the canonical bytes.
558#[cfg(test)]
559pub(crate) fn icc_bytes_for_cicp(cicp: &Cicp) -> Option<alloc::vec::Vec<u8>> {
560    // `try_from` on these moxcms enums never errors: every u8 maps to a variant,
561    // with reserved/unassigned codes folding into `Reserved`. So these conversions
562    // are NOT the validity gate — the real check is whether moxcms could populate
563    // the colorimetry below.
564    let color_primaries = moxcms::CicpColorPrimaries::try_from(cicp.color_primaries).ok()?;
565    let transfer_characteristics =
566        moxcms::TransferCharacteristics::try_from(cicp.transfer_characteristics).ok()?;
567    // Matrix coefficients don't affect an RGB ICC's colorimetry; default rather
568    // than reject so an unusual matrix code doesn't block synthesis.
569    let matrix_coefficients = moxcms::MatrixCoefficients::try_from(cicp.matrix_coefficients)
570        .unwrap_or(moxcms::MatrixCoefficients::Identity);
571
572    let profile = ColorProfile::new_from_cicp(moxcms::CicpProfile {
573        color_primaries,
574        transfer_characteristics,
575        matrix_coefficients,
576        full_range: cicp.full_range,
577    });
578
579    // `new_from_cicp` discards the bool from `update_rgb_colorimetry_from_cicp`, so
580    // for `Reserved`/`Unspecified` primaries or transfer it silently returns a base
581    // profile with no colorants and no TRC. moxcms sets `red_trc` only after every
582    // primaries + white-point + transfer-curve gate passes, so a populated `red_trc`
583    // is the signal synthesis was faithful. No TRC ⇒ moxcms can't represent this
584    // CICP — bail with None (surfaces as `CmsUnsupported`) rather than emit a
585    // degenerate profile whose colorimetry omits or contradicts the requested color.
586    profile.red_trc.as_ref()?;
587    profile.encode().ok()
588}
589
590/// moxcms's D50 white point (`white_point_from_temperature(5003)`), reconstructed so
591/// the gray oracle below builds against the whole `>=0.8.1, <0.10` moxcms range.
592///
593/// moxcms exposed this value as the const `WHITE_POINT_D50` (moxcms ≤ 0.8) and then
594/// renamed it to the fn `white_point_d50()` (moxcms ≥ 0.9) — identical value,
595/// mutually-exclusive spellings. Reproducing it through the stable [`moxcms::XyY`]
596/// API (public f64 fields + f64→f32 `to_xyz`, unchanged across the range) avoids
597/// pinning this crate to either moxcms minor. Kept bit-identical to upstream by
598/// `moxcms_d50_tests::moxcms_d50_matches_upstream`.
599#[cfg(test)]
600fn moxcms_d50_xyz() -> moxcms::Xyz {
601    // moxcms's McCamy CCT→xy for T = 5003 K (its `4000 < T ≤ 7000` branch), in f64:
602    const T: f64 = 5003.0;
603    const X: f64 =
604        -4.6070 * (1e9 / (T * T * T)) + 2.9678 * (1e6 / (T * T)) + 0.09911 * (1e3 / T) + 0.244063;
605    const Y: f64 = -3.000 * X * X + 2.870 * X - 0.275;
606    moxcms::XyY::new(X, Y, 1.0).to_xyz()
607}
608
609/// Synthesize a **GRAY-class** ICC for a CICP, exactly as `icc-gen`'s
610/// `cicp_bundle_gen` generator does for the committed gray bundle: `kTRC` =
611/// the transfer's tone curve (taken from a throwaway RGB synthesis so the
612/// gray and RGB recipes can never disagree about a curve), media white point
613/// = the primaries' H.273 white, and a per-white Bradford white→D50 `chad`.
614/// The generator zeroes the creation timestamp for reproducibility; this
615/// fresh path leaves it — the roundtrip test masks bytes 24..36 on both
616/// sides.
617///
618/// Test-only, mirroring [`icc_bytes_for_cicp`]: the bundled gray blob is
619/// the runtime coverage source; this is the oracle the gray
620/// `blob_decodes_byte_identical_to_moxcms` guard compares the committed
621/// blob against.
622#[cfg(test)]
623pub(crate) fn gray_icc_bytes_for_cicp(cicp: &Cicp) -> Option<alloc::vec::Vec<u8>> {
624    let color_primaries = moxcms::CicpColorPrimaries::try_from(cicp.color_primaries).ok()?;
625    let transfer_characteristics =
626        moxcms::TransferCharacteristics::try_from(cicp.transfer_characteristics).ok()?;
627    let matrix_coefficients = moxcms::MatrixCoefficients::try_from(cicp.matrix_coefficients)
628        .unwrap_or(moxcms::MatrixCoefficients::Identity);
629
630    let rgb = ColorProfile::new_from_cicp(moxcms::CicpProfile {
631        color_primaries,
632        transfer_characteristics,
633        matrix_coefficients,
634        full_range: cicp.full_range,
635    });
636    // Same faithful-synthesis gate as the RGB path.
637    let trc = rgb.red_trc.as_ref()?.clone();
638
639    let (white_name, wx, wy) = h273_white_xy(cicp.color_primaries)?;
640    let white = moxcms::Xyzd {
641        x: wx / wy,
642        y: 1.0,
643        z: (1.0 - wx - wy) / wy,
644    };
645
646    let mut gray = ColorProfile::new_gray_with_gamma(2.2);
647    gray.gray_trc = Some(trc);
648    gray.media_white_point = Some(white);
649    gray.chromatic_adaptation = Some(moxcms::adaption_matrix_d(white.to_xyz(), moxcms_d50_xyz()));
650    gray.description = Some(moxcms::ProfileText::Localizable(alloc::vec![
651        moxcms::LocalizableString::new(
652            "en".into(),
653            "US".into(),
654            format!(
655                "Gray H.273 TC{} {white_name} white",
656                cicp.transfer_characteristics
657            ),
658        )
659    ]));
660
661    gray.encode().ok()
662}
663
664/// The white point of an H.273 colour-primaries code, as CIE xy
665/// (Rec. ITU-T H.273 Table 2). Mirror of the table in `icc-gen`'s
666/// `cicp_bundle_gen` — the gray-bundle roundtrip test pins the two copies
667/// together. The name keys the gray profile's description so primaries
668/// sharing a white dedup to identical bytes.
669#[cfg(test)]
670fn h273_white_xy(primaries: u8) -> Option<(&'static str, f64, f64)> {
671    Some(match primaries {
672        // D65: BT.709, BT.470BG, SMPTE 170M, SMPTE 240M, BT.2020,
673        // P3-D65 (SMPTE EG 432-1), EBU Tech 3213-E.
674        1 | 5 | 6 | 7 | 9 | 12 | 22 => ("D65", 0.3127, 0.3290),
675        // Illuminant C: BT.470M, generic film.
676        4 | 8 => ("C", 0.310, 0.316),
677        // Illuminant E: SMPTE ST 428-1 (CIE XYZ).
678        10 => ("E", 1.0 / 3.0, 1.0 / 3.0),
679        // DCI white: SMPTE RP 431-2 (P3-DCI theater white).
680        11 => ("DCI", 0.314, 0.351),
681        _ => return None,
682    })
683}
684
685/// Convert CICP to a moxcms ColorProfile.
686#[allow(dead_code)]
687fn cicp_to_moxcms_profile(cicp: &Cicp) -> ColorProfile {
688    ColorProfile::new_from_cicp(moxcms::CicpProfile {
689        color_primaries: moxcms::CicpColorPrimaries::try_from(cicp.color_primaries)
690            .unwrap_or(moxcms::CicpColorPrimaries::Bt709),
691        transfer_characteristics: moxcms::TransferCharacteristics::try_from(
692            cicp.transfer_characteristics,
693        )
694        .unwrap_or(moxcms::TransferCharacteristics::Srgb),
695        matrix_coefficients: moxcms::MatrixCoefficients::try_from(cicp.matrix_coefficients)
696            .unwrap_or(moxcms::MatrixCoefficients::Identity),
697        full_range: cicp.full_range,
698    })
699}
700
701/// Convert primaries + transfer to a moxcms ColorProfile via CICP mapping.
702#[allow(dead_code)]
703fn primaries_transfer_to_moxcms_profile(
704    primaries: crate::ColorPrimaries,
705    transfer: crate::TransferFunction,
706) -> Result<Option<ColorProfile>, MoxCmsError> {
707    let cp = match primaries.to_cicp() {
708        Some(c) => c,
709        None => return Ok(None),
710    };
711    let tc = match transfer.to_cicp() {
712        Some(c) => c,
713        None => return Ok(None),
714    };
715    Ok(Some(cicp_to_moxcms_profile(&Cicp::new(cp, tc, 0, true))))
716}
717
718// ---------------------------------------------------------------------------
719// Profile identification by colorant comparison
720// ---------------------------------------------------------------------------
721
722/// Compare XYZ colorants to identify well-known profiles.
723///
724/// Checks the profile's red/green/blue colorants against sRGB (BT.709),
725/// Display P3, and BT.2020. The colorant values are in PCS (D50-adapted)
726/// space, as stored in ICC profiles after Bradford chromatic adaptation
727/// from D65. Tolerance is 0.003 in XYZ, tight enough to distinguish
728/// these gamuts while tolerating s15Fixed16 quantization.
729fn identify_by_colorants(profile: &ColorProfile) -> Option<Cicp> {
730    // Known colorant values in D50 PCS space (Bradford-adapted from D65).
731    // Computed by applying the standard D65→D50 Bradford matrix to the
732    // absolute D65 XYZ colorant matrices from ITU-R specifications.
733    struct KnownProfile {
734        primaries_code: u8,
735        rx: f64,
736        ry: f64,
737        gx: f64,
738        gy: f64,
739        bx: f64,
740        by: f64,
741    }
742
743    const KNOWN: &[KnownProfile] = &[
744        // sRGB / BT.709 (D50-adapted)
745        KnownProfile {
746            primaries_code: 1,
747            rx: 0.4361,
748            ry: 0.2225,
749            gx: 0.3851,
750            gy: 0.7169,
751            bx: 0.1431,
752            by: 0.0606,
753        },
754        // Display P3 (D50-adapted)
755        KnownProfile {
756            primaries_code: 12,
757            rx: 0.5151,
758            ry: 0.2412,
759            gx: 0.2919,
760            gy: 0.6922,
761            bx: 0.1572,
762            by: 0.0666,
763        },
764        // BT.2020 (D50-adapted)
765        KnownProfile {
766            primaries_code: 9,
767            rx: 0.6734,
768            ry: 0.2790,
769            gx: 0.1656,
770            gy: 0.6753,
771            bx: 0.1251,
772            by: 0.0456,
773        },
774    ];
775
776    let r = &profile.red_colorant;
777    let g = &profile.green_colorant;
778    let b = &profile.blue_colorant;
779
780    const TOL: f64 = 0.003;
781
782    for known in KNOWN {
783        let matches = (r.x - known.rx).abs() < TOL
784            && (r.y - known.ry).abs() < TOL
785            && (g.x - known.gx).abs() < TOL
786            && (g.y - known.gy).abs() < TOL
787            && (b.x - known.bx).abs() < TOL
788            && (b.y - known.by).abs() < TOL;
789
790        if matches {
791            // Map known primaries to their standard transfer characteristic.
792            // sRGB (1) and Display P3 (12) both use the sRGB TRC (13).
793            // BT.2020 (9) uses BT.709 TRC (1) as a safe default since
794            // the actual TRC (PQ, HLG, or BT.709) can't be identified
795            // from colorants alone.
796            let transfer = match known.primaries_code {
797                1 | 12 => 13, // sRGB and Display P3 use sRGB TRC
798                _ => 1,       // BT.2020 etc. default to BT.709 TRC
799            };
800            return Some(Cicp::new(
801                known.primaries_code,
802                transfer,
803                0, // Identity (RGB)
804                true,
805            ));
806        }
807    }
808
809    None
810}
811
812// ---------------------------------------------------------------------------
813// Error type
814// ---------------------------------------------------------------------------
815
816/// Error from the moxcms CMS backend.
817#[derive(Debug, Clone)]
818pub struct MoxCmsError(pub String);
819
820impl core::fmt::Display for MoxCmsError {
821    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
822        f.write_str(&self.0)
823    }
824}
825
826#[cfg(test)]
827mod moxcms_d50_tests {
828    use super::moxcms_d50_xyz;
829
830    /// The reconstructed D50 white must stay bit-identical to moxcms's own D50
831    /// (`white_point_from_temperature(5003).to_xyz()`, ≈ (0.96391, 1.0, 0.82475)).
832    /// This golden was verified equal to `moxcms::WHITE_POINT_D50.to_xyz()`
833    /// (moxcms 0.8.x) and `moxcms::white_point_d50().to_xyz()` (moxcms 0.9.x) when
834    /// written; the exact f32 bits guard against an upstream `XyY::to_xyz` change
835    /// within the `>=0.8.1, <0.10` range.
836    #[test]
837    fn moxcms_d50_matches_upstream() {
838        let d50 = moxcms_d50_xyz();
839        assert_eq!(d50.x.to_bits(), 0x3f76_c28e); // 0.9639062
840        assert_eq!(d50.y, 1.0_f32);
841        assert_eq!(d50.z.to_bits(), 0x3f53_2290); // 0.8247461
842    }
843}