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}