Skip to main content

stet_graphics/
icc.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! ICC color profile support via moxcms.
6//!
7//! Parses embedded ICC profiles from `[/ICCBased stream]` color spaces and
8//! converts colors to sRGB. Also searches for system CMYK profiles to improve
9//! DeviceCMYK → RGB conversion beyond the naive PLRM formula.
10
11pub mod bpc;
12mod perceptual;
13
14use bpc::{
15    BpcParams, apply_bpc_f64, apply_bpc_rgb_u8, compute_bpc_params, detect_source_black_point,
16};
17use moxcms::{
18    CmsError, ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor,
19    TransformOptions,
20};
21use std::collections::HashMap;
22use std::sync::Arc;
23
24/// SHA-256 hash used as profile key.
25pub type ProfileHash = [u8; 32];
26
27/// Black Point Compensation mode for CMYK→sRGB conversion.
28///
29/// Reference renderers (Ghostscript, Acrobat, Firefox via lcms2) apply BPC by
30/// default for relative-colorimetric CMYK→sRGB. moxcms 0.8.1 ships BPC
31/// commented out, so without it K-heavy colors render visibly lighter than
32/// reference renderers. See `docs/PLAN-BPC.md` for the full design.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum BpcMode {
35    /// Skip BPC; matches stet's pre-fix behavior. Useful for proofing-style
36    /// renders that should preserve actual densities, or for bit-for-bit
37    /// reproduction of older baselines.
38    Off,
39    /// Always apply BPC during CMYK→sRGB conversion.
40    On,
41    /// Default — currently equivalent to `On`. Reserved for forward
42    /// compatibility (eventually could honor PDF rendering-intent or
43    /// output-intent hints).
44    #[default]
45    Auto,
46}
47
48impl BpcMode {
49    /// True when BPC should be applied at conversion time.
50    #[inline]
51    pub fn is_enabled(self) -> bool {
52        matches!(self, BpcMode::On | BpcMode::Auto)
53    }
54}
55
56/// Construction-time options for [`IccCache`].
57///
58/// Bundles together the BPC mode and an optional pre-supplied source CMYK
59/// profile (overriding the automatic system-profile search). Created via
60/// [`IccCache::new_with_options`].
61#[derive(Clone, Default)]
62pub struct IccCacheOptions {
63    /// BPC mode for CMYK→sRGB conversion.
64    pub bpc_mode: BpcMode,
65    /// Raw bytes of a source CMYK profile to register as the system profile.
66    /// When `None`, the cache is created empty and the caller is responsible
67    /// for invoking [`IccCache::search_system_cmyk_profile`] (or providing
68    /// bytes some other way).
69    pub source_cmyk_profile: Option<Vec<u8>>,
70}
71
72/// Identity Gray→RGB transform: maps each gray value to equal R=G=B.
73/// Used as fallback when a Gray ICC profile can't produce a proper transform.
74struct GrayToRgbIdentity;
75
76impl TransformExecutor<u8> for GrayToRgbIdentity {
77    fn transform(&self, src: &[u8], dst: &mut [u8]) -> Result<(), CmsError> {
78        for (g, rgb) in src.iter().zip(dst.chunks_exact_mut(3)) {
79            rgb[0] = *g;
80            rgb[1] = *g;
81            rgb[2] = *g;
82        }
83        Ok(())
84    }
85}
86
87impl TransformExecutor<f64> for GrayToRgbIdentity {
88    fn transform(&self, src: &[f64], dst: &mut [f64]) -> Result<(), CmsError> {
89        for (g, rgb) in src.iter().zip(dst.chunks_exact_mut(3)) {
90            rgb[0] = *g;
91            rgb[1] = *g;
92            rgb[2] = *g;
93        }
94        Ok(())
95    }
96}
97
98/// Pre-baked 4D CLUT sampling a CMYK ICC transform on a regular grid.
99///
100/// At profile-registration time we sample moxcms at `grid_n^4` evenly-spaced
101/// CMYK points and store the sRGB output. At image-conversion time we do
102/// K-slice plus 3D tetrahedral interpolation inside each slice. This is ~30×
103/// faster than direct moxcms for LUT-based CMYK profiles (e.g., SWOP) while
104/// staying well inside imperceptible ΔE for typical print-workflow inputs.
105#[derive(Clone)]
106struct Clut4 {
107    /// Grid points per axis (typical: 17).
108    grid_n: u8,
109    /// Flat LUT in order (k, y, m, c) with C fastest, K slowest.
110    /// Length = grid_n^4 * 3 bytes (packed sRGB).
111    data: Arc<Vec<u8>>,
112}
113
114impl Clut4 {
115    /// Construct a Clut4 from a pre-baked byte buffer with the same memory
116    /// layout `bake_clut4` produces (K outermost, Y, M, C innermost; 3 bytes
117    /// per grid point). Used by [`perceptual::bake_clut4_perceptual`] so its
118    /// output is byte-compatible with [`apply_clut4_cmyk_to_rgb`].
119    fn from_baked(grid_n: u8, data: Vec<u8>) -> Self {
120        debug_assert_eq!(data.len(), (grid_n as usize).pow(4) * 3);
121        Self {
122            grid_n,
123            data: Arc::new(data),
124        }
125    }
126}
127
128/// Cached ICC transform to sRGB (specific to source layout).
129#[derive(Clone)]
130struct CachedTransform {
131    /// 8-bit transform for image data.
132    transform_8bit: Arc<dyn TransformExecutor<u8> + Send + Sync>,
133    /// f64 transform for single-color conversions.
134    transform_f64: Arc<dyn TransformExecutor<f64> + Send + Sync>,
135    /// Number of source components.
136    n: u32,
137    /// Whether the source profile is Lab (needs value normalization).
138    is_lab: bool,
139    /// Pre-baked 4D CLUT for fast CMYK→sRGB image conversion.
140    /// Only built for `n == 4` profiles; None otherwise.
141    clut4: Option<Clut4>,
142    /// Cached Black Point Compensation parameters for this profile. Computed
143    /// when `n == 4` and `IccCache::bpc_mode` is enabled. Applied as a
144    /// post-correction on the moxcms output (sRGB → XYZ-D50 → BPC shift →
145    /// back to sRGB) so K-heavy CMYK colours map to true zero black.
146    bpc_params: Option<BpcParams>,
147}
148
149/// ICC color profile cache and transform manager.
150#[derive(Clone)]
151pub struct IccCache {
152    /// SHA-256 hash → parsed ColorProfile.
153    profiles: HashMap<ProfileHash, Arc<ColorProfile>>,
154    /// Cached transforms: hash → CachedTransform.
155    transforms: HashMap<ProfileHash, CachedTransform>,
156    /// Single-color conversion cache: (hash-prefix, quantized_components) → (r, g, b).
157    /// Uses first 8 bytes of hash as u64 key for compactness.
158    color_cache: HashMap<(u64, [u16; 4]), (f64, f64, f64)>,
159    /// Default system CMYK profile hash (if found at startup).
160    default_cmyk_hash: Option<ProfileHash>,
161    /// Raw bytes of the system CMYK profile (for re-registration in render threads).
162    system_cmyk_bytes: Option<Arc<Vec<u8>>>,
163    /// Raw profile bytes for each registered profile (for PDF embedding).
164    raw_bytes: HashMap<ProfileHash, Arc<Vec<u8>>>,
165    /// sRGB output profile (created once).
166    srgb_profile: ColorProfile,
167    /// Cached sRGB→CMYK reverse transform (for RGB round-trip through CMYK page groups).
168    reverse_cmyk_f64: Option<Arc<dyn TransformExecutor<f64> + Send + Sync>>,
169    /// Black Point Compensation mode for CMYK→sRGB conversion. Set at
170    /// construction time via [`IccCacheOptions`]; consulted by future BPC
171    /// apply paths (commit 2 of `docs/PLAN-BPC.md`).
172    bpc_mode: BpcMode,
173}
174
175impl Default for IccCache {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181/// Apply BPC to an sRGB triple if `params` is `Some`; otherwise return the
182/// triple unchanged. Centralised so every conversion entry point stays in
183/// sync.
184#[inline]
185fn bpc_post_correct(rgb: [f64; 3], params: Option<&BpcParams>) -> [f64; 3] {
186    match params {
187        Some(p) => apply_bpc_f64(rgb, p),
188        None => rgb,
189    }
190}
191
192impl IccCache {
193    /// Create an empty ICC cache with default options (BPC `Auto`, no
194    /// pre-supplied source CMYK profile).
195    pub fn new() -> Self {
196        Self::new_with_options(IccCacheOptions::default())
197    }
198
199    /// Create an ICC cache with the given options.
200    ///
201    /// When `opts.source_cmyk_profile` is `Some`, the bytes are registered as
202    /// the system CMYK profile (overriding any later
203    /// [`Self::search_system_cmyk_profile`] call). Otherwise the cache starts
204    /// empty and the caller is expected to supply a profile separately.
205    pub fn new_with_options(opts: IccCacheOptions) -> Self {
206        let mut cache = Self {
207            profiles: HashMap::new(),
208            transforms: HashMap::new(),
209            color_cache: HashMap::new(),
210            default_cmyk_hash: None,
211            system_cmyk_bytes: None,
212            raw_bytes: HashMap::new(),
213            srgb_profile: ColorProfile::new_srgb(),
214            reverse_cmyk_f64: None,
215            bpc_mode: opts.bpc_mode,
216        };
217        if let Some(bytes) = opts.source_cmyk_profile {
218            cache.load_cmyk_profile_bytes(&bytes);
219        }
220        cache
221    }
222
223    /// Current Black Point Compensation mode.
224    #[inline]
225    pub fn bpc_mode(&self) -> BpcMode {
226        self.bpc_mode
227    }
228
229    /// Compute the SHA-256 hash of an ICC profile without registering it.
230    pub fn hash_profile(bytes: &[u8]) -> ProfileHash {
231        use sha2::{Digest, Sha256};
232        Sha256::digest(bytes).into()
233    }
234
235    /// Register an ICC profile from raw bytes. Returns the SHA-256 hash on success.
236    pub fn register_profile(&mut self, bytes: &[u8]) -> Option<ProfileHash> {
237        self.register_profile_with_n(bytes, None)
238    }
239
240    /// Register an ICC profile, validating that its color space matches the
241    /// expected component count `expected_n`. When the profile's actual color
242    /// space has a different number of components (e.g. an RGB profile stored
243    /// with PDF `/N 1`), the profile is rejected so the caller can fall back
244    /// to the alternate color space.
245    pub fn register_profile_with_n(
246        &mut self,
247        bytes: &[u8],
248        expected_n: Option<u32>,
249    ) -> Option<ProfileHash> {
250        use sha2::{Digest, Sha256};
251        let hash: ProfileHash = Sha256::digest(bytes).into();
252
253        // Already registered?
254        if self.transforms.contains_key(&hash) {
255            return Some(hash);
256        }
257
258        // Store raw bytes for PDF embedding
259        self.raw_bytes
260            .entry(hash)
261            .or_insert_with(|| Arc::new(bytes.to_vec()));
262
263        let profile = match ColorProfile::new_from_slice(bytes) {
264            Ok(p) => p,
265            Err(e) => {
266                eprintln!("[ICC] Failed to parse profile: {e}");
267                return None;
268            }
269        };
270
271        let n = match profile.color_space {
272            DataColorSpace::Gray => 1u32,
273            DataColorSpace::Rgb => 3,
274            DataColorSpace::Cmyk => 4,
275            DataColorSpace::Lab => 3,
276            _ => {
277                eprintln!(
278                    "[ICC] Unsupported profile color space: {:?}",
279                    profile.color_space
280                );
281                return None;
282            }
283        };
284
285        // Reject profile when its actual component count doesn't match the
286        // PDF's /N declaration — the input data won't match the profile's
287        // expected input layout.
288        if let Some(expected) = expected_n {
289            if n != expected {
290                return None;
291            }
292        }
293
294        let (src_layout_8, src_layout_f64) = match n {
295            1 => (Layout::Gray, Layout::Gray),
296            3 => (Layout::Rgb, Layout::Rgb),
297            4 => (Layout::Rgba, Layout::Rgba),
298            _ => return None,
299        };
300
301        let dst_layout_8 = Layout::Rgb;
302        let dst_layout_f64 = Layout::Rgb;
303
304        // Try multiple rendering intents — Perceptual first so the
305        // moxcms-driven `transform_f64` Arc honours the profile's perceptual
306        // table. Most CMYK paths route through the perceptual A2B0 CLUT
307        // (`bake_clut4_perceptual`) instead, but a few sites still call the
308        // f64 transform directly (e.g. `Luminosity` soft-mask conversion in
309        // the renderer); for those sites, picking the Perceptual transform
310        // here keeps the per-pixel result aligned with the CLUT path. ICC v4
311        // profiles may only have A2B0, so this also covers those.
312        let intents = [
313            RenderingIntent::Perceptual,
314            RenderingIntent::RelativeColorimetric,
315            RenderingIntent::AbsoluteColorimetric,
316            RenderingIntent::Saturation,
317        ];
318
319        let mut transform_8bit = None;
320        for &intent in &intents {
321            let options = TransformOptions {
322                rendering_intent: intent,
323                ..TransformOptions::default()
324            };
325            match profile.create_transform_8bit(
326                src_layout_8,
327                &self.srgb_profile,
328                dst_layout_8,
329                options,
330            ) {
331                Ok(t) => {
332                    transform_8bit = Some(t);
333                    break;
334                }
335                Err(_) => continue,
336            }
337        }
338        let transform_8bit = match transform_8bit {
339            Some(t) => t,
340            None if n == 1 => {
341                // Gray profiles that can't produce Gray→sRGB transforms (e.g.
342                // minimal Linotype profiles with only a TRC): fall back to the
343                // sRGB gray curve, which is functionally correct for most Gray
344                // profiles encountered in PDFs.
345                return self.register_gray_identity(hash, profile);
346            }
347            None => {
348                eprintln!(
349                    "[ICC] Failed to create 8-bit transform (cs={:?})",
350                    profile.color_space
351                );
352                return None;
353            }
354        };
355
356        let mut transform_f64 = None;
357        for &intent in &intents {
358            let options = TransformOptions {
359                rendering_intent: intent,
360                ..TransformOptions::default()
361            };
362            match profile.create_transform_f64(
363                src_layout_f64,
364                &self.srgb_profile,
365                dst_layout_f64,
366                options,
367            ) {
368                Ok(t) => {
369                    transform_f64 = Some(t);
370                    break;
371                }
372                Err(_) => continue,
373            }
374        }
375        let transform_f64 = match transform_f64 {
376            Some(t) => t,
377            None if n == 1 => {
378                // Same Gray fallback for f64 path
379                return self.register_gray_identity(hash, profile);
380            }
381            None => {
382                eprintln!(
383                    "[ICC] Failed to create f64 transform (cs={:?})",
384                    profile.color_space
385                );
386                return None;
387            }
388        };
389
390        let is_lab = profile.color_space == DataColorSpace::Lab;
391
392        // For 4-channel (CMYK) profiles, pre-bake a 17^4 CLUT for fast image
393        // conversion. Two paths produce the same Clut4 layout:
394        //
395        // 1. `bake_clut4_perceptual` samples the profile's own A2B1
396        //    (colorimetric) table directly, decodes the legacy v2 PCS-Lab
397        //    encoding, and clips out-of-gamut colours to the sRGB boundary.
398        //    Output matches lcms2's `cmsDoTransform(RelCol)` to ±1 RGB level.
399        //    Available for v2 mft2 CMYK profiles. BPC is computed inside the
400        //    bake against this sampler's own (1,1,1,1) output so the source
401        //    black-point matches what we're actually producing.
402        // 2. `bake_clut4` invokes the 8-bit moxcms transform on a grid;
403        //    fallback for profiles whose tables are missing or in a shape we
404        //    don't yet handle (mAB, mft1, XYZ-PCS). BPC is calibrated against
405        //    moxcms's transform output (`detect_source_black_point`).
406        //
407        // The runtime CLUT lookup is identical regardless of which path
408        // produced the table.
409        //
410        // `bpc_params` is stored on `CachedTransform` for the moxcms-fallback
411        // path's `convert_color` / `convert_color_readonly` callers when the
412        // bake returned `None`. The hand-rolled sampler folds BPC in directly
413        // and leaves this `None`; the cached `transform_f64` Arc is only
414        // exercised in fallback contexts and shouldn't double-apply BPC.
415        let bpc_enabled = n == 4 && self.bpc_mode.is_enabled();
416        let mut bpc_params: Option<BpcParams> = None;
417        let clut4 = if n == 4 {
418            let c = perceptual::bake_clut4_perceptual(&profile, 17, bpc_enabled).or_else(|| {
419                let params = if bpc_enabled {
420                    detect_source_black_point(transform_8bit.as_ref())
421                        .map(|sbp| compute_bpc_params(sbp, [0.0; 3], bpc::WP_D50))
422                } else {
423                    None
424                };
425                let r = bake_clut4(transform_8bit.as_ref(), 17, params.as_ref());
426                bpc_params = params;
427                r
428            });
429            if std::env::var_os("STET_ICC_VERIFY").is_some()
430                && let Some(ref clut) = c
431            {
432                verify_clut4(clut, transform_8bit.as_ref(), bpc_params.as_ref());
433            }
434            c
435        } else {
436            None
437        };
438
439        self.profiles.insert(hash, Arc::new(profile));
440        self.transforms.insert(
441            hash,
442            CachedTransform {
443                transform_8bit,
444                transform_f64,
445                n,
446                is_lab,
447                clut4,
448                bpc_params,
449            },
450        );
451
452        Some(hash)
453    }
454
455    /// Register a Gray profile with an identity Gray→RGB fallback transform.
456    /// Used when the ICC library can't create a proper transform from the profile
457    /// (e.g. minimal profiles with only a TRC and no A2B/B2A tables).
458    fn register_gray_identity(
459        &mut self,
460        hash: ProfileHash,
461        profile: ColorProfile,
462    ) -> Option<ProfileHash> {
463        self.profiles.insert(hash, Arc::new(profile));
464        self.transforms.insert(
465            hash,
466            CachedTransform {
467                transform_8bit: Arc::new(GrayToRgbIdentity),
468                transform_f64: Arc::new(GrayToRgbIdentity),
469                n: 1,
470                is_lab: false,
471                clut4: None,
472                bpc_params: None,
473            },
474        );
475        Some(hash)
476    }
477
478    /// Convert a single color through an ICC profile to sRGB.
479    /// Returns (r, g, b) in [0, 1] range.
480    pub fn convert_color(
481        &mut self,
482        hash: &ProfileHash,
483        components: &[f64],
484    ) -> Option<(f64, f64, f64)> {
485        let cached = self.transforms.get(hash)?;
486        let n = cached.n as usize;
487        let is_lab = cached.is_lab;
488
489        // Normalize input values to [0,1] range.
490        // Lab profiles need special mapping: L/100, (a+128)/255, (b+128)/255.
491        let mut src = vec![0.0f64; n];
492        for (i, s) in src.iter_mut().enumerate() {
493            let v = components.get(i).copied().unwrap_or(0.0);
494            *s = if is_lab {
495                match i {
496                    0 => (v / 100.0).clamp(0.0, 1.0),
497                    _ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
498                }
499            } else {
500                v.clamp(0.0, 1.0)
501            };
502        }
503
504        // Quantize normalized values for cache key
505        let hash_prefix = u64::from_le_bytes(hash[..8].try_into().ok()?);
506        let mut quantized = [0u16; 4];
507        for (i, &c) in src.iter().take(4).enumerate() {
508            quantized[i] = (c * 65535.0).round() as u16;
509        }
510
511        // Check cache
512        let cache_key = (hash_prefix, quantized);
513        if let Some(&cached) = self.color_cache.get(&cache_key) {
514            return Some(cached);
515        }
516
517        let result = if n == 4
518            && let Some(clut) = cached.clut4.as_ref()
519        {
520            // Route single-color CMYK through the same baked CLUT image
521            // conversions use, so a flat fill matches the surrounding gradient
522            // stops byte-for-byte. BPC and the perceptual A2B0 sampling are
523            // already folded into the CLUT.
524            let (r, g, b) = sample_clut4_single_f64(clut, src[0], src[1], src[2], src[3]);
525            (r, g, b)
526        } else {
527            let mut dst = [0.0f64; 3];
528            if cached.transform_f64.transform(&src, &mut dst).is_err() {
529                return None;
530            }
531            let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
532            (
533                dst[0].clamp(0.0, 1.0),
534                dst[1].clamp(0.0, 1.0),
535                dst[2].clamp(0.0, 1.0),
536            )
537        };
538
539        // Cache (limit size to avoid unbounded growth)
540        if self.color_cache.len() < 65536 {
541            self.color_cache.insert(cache_key, result);
542        }
543
544        Some(result)
545    }
546
547    /// Convert a single color through an ICC profile (read-only, no caching).
548    ///
549    /// Same as `convert_color` but takes `&self` instead of `&mut self`,
550    /// suitable for use from immutable contexts like rendering.
551    pub fn convert_color_readonly(
552        &self,
553        hash: &ProfileHash,
554        components: &[f64],
555    ) -> Option<(f64, f64, f64)> {
556        let cached = self.transforms.get(hash)?;
557        let n = cached.n as usize;
558        let is_lab = cached.is_lab;
559
560        let mut src = vec![0.0f64; n];
561        for (i, s) in src.iter_mut().enumerate() {
562            let v = components.get(i).copied().unwrap_or(0.0);
563            *s = if is_lab {
564                match i {
565                    0 => (v / 100.0).clamp(0.0, 1.0),
566                    _ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
567                }
568            } else {
569                v.clamp(0.0, 1.0)
570            };
571        }
572
573        if n == 4
574            && let Some(clut) = cached.clut4.as_ref()
575        {
576            let (r, g, b) = sample_clut4_single_f64(clut, src[0], src[1], src[2], src[3]);
577            return Some((r, g, b));
578        }
579
580        let mut dst = [0.0f64; 3];
581        if cached.transform_f64.transform(&src, &mut dst).is_err() {
582            return None;
583        }
584
585        let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
586        Some((
587            dst[0].clamp(0.0, 1.0),
588            dst[1].clamp(0.0, 1.0),
589            dst[2].clamp(0.0, 1.0),
590        ))
591    }
592
593    /// Bulk-convert 8-bit image samples through an ICC profile to RGB.
594    /// Input: packed samples (Gray/RGB/CMYK depending on profile).
595    /// Output: packed RGB bytes (3 bytes per pixel).
596    pub fn convert_image_8bit(
597        &self,
598        hash: &ProfileHash,
599        samples: &[u8],
600        pixel_count: usize,
601    ) -> Option<Vec<u8>> {
602        let cached = self.transforms.get(hash)?;
603        let n = cached.n as usize;
604        let expected_len = pixel_count * n;
605        if samples.len() < expected_len {
606            return None;
607        }
608
609        // Fast path: pre-baked 4D CLUT for CMYK profiles. BPC is already
610        // baked into the CLUT (when enabled), so no per-pixel correction
611        // is needed here.
612        if let Some(clut) = &cached.clut4 {
613            return Some(apply_clut4_cmyk_to_rgb(
614                clut,
615                &samples[..expected_len],
616                pixel_count,
617            ));
618        }
619
620        let src = &samples[..expected_len];
621        let mut dst = vec![0u8; pixel_count * 3];
622
623        match cached.transform_8bit.transform(src, &mut dst) {
624            Ok(()) => {
625                // Apply BPC per pixel for non-CLUT bulk paths (CMYK profiles
626                // whose CLUT bake failed; today no other layouts populate
627                // bpc_params, so this is a no-op for RGB/Gray/Lab).
628                if let Some(p) = cached.bpc_params.as_ref() {
629                    for px in dst.chunks_exact_mut(3) {
630                        let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
631                        px[0] = out[0];
632                        px[1] = out[1];
633                        px[2] = out[2];
634                    }
635                }
636                Some(dst)
637            }
638            Err(e) => {
639                eprintln!("[ICC] Image transform failed: {e}");
640                None
641            }
642        }
643    }
644
645    /// Search system paths for a CMYK ICC profile and register it.
646    pub fn search_system_cmyk_profile(&mut self) {
647        if let Some(bytes) = find_system_cmyk_profile()
648            && let Some(hash) = self.register_profile(&bytes)
649        {
650            eprintln!("[ICC] Loaded system CMYK profile");
651            self.system_cmyk_bytes = Some(Arc::new(bytes));
652            self.default_cmyk_hash = Some(hash);
653        }
654    }
655
656    /// Load a CMYK ICC profile from raw bytes (for environments without filesystem access).
657    pub fn load_cmyk_profile_bytes(&mut self, bytes: &[u8]) {
658        if let Some(hash) = self.register_profile(bytes) {
659            self.system_cmyk_bytes = Some(Arc::new(bytes.to_vec()));
660            self.default_cmyk_hash = Some(hash);
661        }
662    }
663
664    /// Get the default CMYK profile hash, if a system CMYK profile was found.
665    pub fn default_cmyk_hash(&self) -> Option<&ProfileHash> {
666        self.default_cmyk_hash.as_ref()
667    }
668
669    /// Check if a profile hash has been registered.
670    pub fn has_profile(&self, hash: &ProfileHash) -> bool {
671        self.transforms.contains_key(hash)
672    }
673
674    /// Get the raw bytes of a registered ICC profile (for PDF embedding).
675    pub fn get_profile_bytes(&self, hash: &ProfileHash) -> Option<Arc<Vec<u8>>> {
676        self.raw_bytes.get(hash).cloned()
677    }
678
679    /// Get the raw bytes of the system CMYK profile (for re-registration in render threads).
680    pub fn system_cmyk_bytes(&self) -> Option<&Arc<Vec<u8>>> {
681        self.system_cmyk_bytes.as_ref()
682    }
683
684    /// Set the system CMYK profile from pre-loaded bytes and hash.
685    ///
686    /// Used by `--output-profile` to substitute the auto-detected system CMYK
687    /// profile with a user-specified one.
688    pub fn set_system_cmyk(&mut self, bytes: &[u8], hash: ProfileHash) {
689        self.system_cmyk_bytes = Some(Arc::new(bytes.to_vec()));
690        self.default_cmyk_hash = Some(hash);
691    }
692
693    /// Set the default CMYK profile hash (used when building render-thread caches).
694    pub fn set_default_cmyk_hash(&mut self, hash: ProfileHash) {
695        self.default_cmyk_hash = Some(hash);
696    }
697
698    /// Temporarily remove the default CMYK hash, returning the old value.
699    /// Used to disable ICC CMYK conversion inside soft mask form rendering,
700    /// where PLRM formulas produce correct luminosity values (ICC profiles
701    /// map 100% K to non-zero RGB, breaking luminosity soft masks).
702    pub fn suspend_default_cmyk(&mut self) -> Option<ProfileHash> {
703        self.default_cmyk_hash.take()
704    }
705
706    /// Restore a previously suspended default CMYK hash.
707    pub fn restore_default_cmyk(&mut self, hash: Option<ProfileHash>) {
708        self.default_cmyk_hash = hash;
709    }
710
711    /// Convert CMYK to (r, g, b) using the default system CMYK profile.
712    /// Returns None if no system CMYK profile is loaded.
713    #[inline]
714    pub fn convert_cmyk(&mut self, c: f64, m: f64, y: f64, k: f64) -> Option<(f64, f64, f64)> {
715        let hash = *self.default_cmyk_hash.as_ref()?;
716        self.convert_color(&hash, &[c, m, y, k])
717    }
718
719    /// Convert CMYK to (r, g, b) using the default system CMYK profile (read-only, no caching).
720    /// Used by band renderers that only have `&self` access.
721    pub fn convert_cmyk_readonly(&self, c: f64, m: f64, y: f64, k: f64) -> Option<(f64, f64, f64)> {
722        let hash = self.default_cmyk_hash.as_ref()?;
723        let cached = self.transforms.get(hash)?;
724        let src = [
725            c.clamp(0.0, 1.0),
726            m.clamp(0.0, 1.0),
727            y.clamp(0.0, 1.0),
728            k.clamp(0.0, 1.0),
729        ];
730        if let Some(clut) = cached.clut4.as_ref() {
731            // Sample the same baked CLUT image conversions use, so a flat fill
732            // matches the surrounding gradient stops byte-for-byte. BPC and the
733            // perceptual A2B0 sampling are already folded into the CLUT.
734            let (r, g, b) = sample_clut4_single_f64(clut, src[0], src[1], src[2], src[3]);
735            return Some((r, g, b));
736        }
737        let mut dst = [0.0f64; 3];
738        if cached.transform_f64.transform(&src, &mut dst).is_err() {
739            return None;
740        }
741        let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
742        Some((
743            dst[0].clamp(0.0, 1.0),
744            dst[1].clamp(0.0, 1.0),
745            dst[2].clamp(0.0, 1.0),
746        ))
747    }
748
749    /// Build the lazy sRGB→CMYK reverse transform from the system CMYK profile.
750    /// Returns `Some(())` if the transform is now present (built or already
751    /// cached). Returns `None` if no system CMYK profile is registered or no
752    /// rendering intent could create a transform.
753    fn ensure_reverse_cmyk_transform(&mut self) -> Option<()> {
754        if self.reverse_cmyk_f64.is_some() {
755            return Some(());
756        }
757        let hash = *self.default_cmyk_hash.as_ref()?;
758        let cmyk_profile = self.profiles.get(&hash)?.clone();
759        let intents = [
760            RenderingIntent::RelativeColorimetric,
761            RenderingIntent::Perceptual,
762            RenderingIntent::AbsoluteColorimetric,
763            RenderingIntent::Saturation,
764        ];
765        for &intent in &intents {
766            let options = TransformOptions {
767                rendering_intent: intent,
768                ..TransformOptions::default()
769            };
770            if let Ok(t) = self.srgb_profile.create_transform_f64(
771                Layout::Rgb,
772                &cmyk_profile,
773                Layout::Rgba,
774                options,
775            ) {
776                self.reverse_cmyk_f64 = Some(t);
777                return Some(());
778            }
779        }
780        None
781    }
782
783    /// Pre-warm the lazy sRGB→CMYK reverse transform. Should be called once on
784    /// the build thread that owns `&mut IccCache`, after the system CMYK
785    /// profile has been registered, so that band renderers (which only hold
786    /// `&IccCache`) can use [`Self::convert_rgb_to_cmyk_readonly`] without
787    /// having to mutate state.
788    pub fn prepare_reverse_cmyk(&mut self) {
789        let _ = self.ensure_reverse_cmyk_transform();
790    }
791
792    /// Convert an sRGB color to CMYK using the system CMYK profile, without
793    /// mutating any state. Returns `None` when the reverse transform has not
794    /// been pre-built (call [`Self::prepare_reverse_cmyk`] first) or when no
795    /// system CMYK profile is registered.
796    ///
797    /// The returned components are clamped to `[0, 1]`.
798    pub fn convert_rgb_to_cmyk_readonly(&self, r: f64, g: f64, b: f64) -> Option<[f64; 4]> {
799        let reverse = self.reverse_cmyk_f64.as_ref()?;
800        let src_rgb = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
801        let mut cmyk = [0.0f64; 4];
802        reverse.transform(&src_rgb, &mut cmyk).ok()?;
803        Some([
804            cmyk[0].clamp(0.0, 1.0),
805            cmyk[1].clamp(0.0, 1.0),
806            cmyk[2].clamp(0.0, 1.0),
807            cmyk[3].clamp(0.0, 1.0),
808        ])
809    }
810
811    /// Round-trip an RGB color through the system CMYK profile: sRGB→CMYK→sRGB.
812    /// Used when compositing in a DeviceCMYK page group — saturated RGB colors
813    /// become more muted after passing through the CMYK gamut.
814    /// Returns None if no CMYK profile is loaded.
815    pub fn round_trip_rgb_via_cmyk(&mut self, r: f64, g: f64, b: f64) -> Option<(f64, f64, f64)> {
816        self.ensure_reverse_cmyk_transform()?;
817        let hash = *self.default_cmyk_hash.as_ref()?;
818        let reverse = self.reverse_cmyk_f64.as_ref()?;
819
820        // sRGB → CMYK
821        let src_rgb = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
822        let mut cmyk = [0.0f64; 4];
823        reverse.transform(&src_rgb, &mut cmyk).ok()?;
824
825        // CMYK → sRGB (via existing forward transform)
826        let forward = self.transforms.get(&hash)?;
827        let mut dst = [0.0f64; 3];
828        forward.transform_f64.transform(&cmyk, &mut dst).ok()?;
829
830        let dst = bpc_post_correct(dst, forward.bpc_params.as_ref());
831        Some((
832            dst[0].clamp(0.0, 1.0),
833            dst[1].clamp(0.0, 1.0),
834            dst[2].clamp(0.0, 1.0),
835        ))
836    }
837
838    /// Disable all ICC color management — clears all profiles, transforms,
839    /// and caches. Equivalent to the CLI's `--no-icc` flag.
840    pub fn disable(&mut self) {
841        self.profiles.clear();
842        self.transforms.clear();
843        self.color_cache.clear();
844        self.raw_bytes.clear();
845        self.default_cmyk_hash = None;
846        self.system_cmyk_bytes = None;
847        self.reverse_cmyk_f64 = None;
848    }
849}
850
851/// Bake a 4D CLUT by sampling an 8-bit CMYK→sRGB transform on a regular grid.
852///
853/// Generates `grid_n^4` CMYK sample points (each channel stepping `0..=255` in
854/// `grid_n` steps), invokes moxcms once on the full batch, and stores the
855/// packed sRGB output. Storage order is K outermost, then Y, M, C innermost,
856/// matching the interpolation access pattern in `apply_clut4_cmyk_to_rgb`.
857///
858/// Returns `None` if the transform invocation fails — callers fall back to
859/// direct moxcms calls per image.
860fn bake_clut4(
861    transform: &(dyn TransformExecutor<u8> + Send + Sync),
862    grid_n: u8,
863    bpc_params: Option<&BpcParams>,
864) -> Option<Clut4> {
865    let n = grid_n as usize;
866    if !(2..=33).contains(&n) {
867        return None;
868    }
869    let total = n * n * n * n;
870    // Sample grid: for each (k, y, m, c) grid index, emit bytes (c, m, y, k).
871    // moxcms consumes this as packed 4-channel input.
872    let mut src = Vec::with_capacity(total * 4);
873    let step = |i: usize| -> u8 {
874        // Spread grid indices evenly across 0..=255 (endpoints inclusive).
875        ((i as u32 * 255) / (n as u32 - 1)) as u8
876    };
877    for k in 0..n {
878        let kv = step(k);
879        for y in 0..n {
880            let yv = step(y);
881            for m in 0..n {
882                let mv = step(m);
883                for c in 0..n {
884                    let cv = step(c);
885                    src.extend_from_slice(&[cv, mv, yv, kv]);
886                }
887            }
888        }
889    }
890    let mut dst = vec![0u8; total * 3];
891    transform.transform(&src, &mut dst).ok()?;
892
893    // Bake BPC into every grid point so runtime CLUT lookup stays at zero
894    // per-pixel cost.
895    if let Some(p) = bpc_params {
896        for px in dst.chunks_exact_mut(3) {
897            let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
898            px[0] = out[0];
899            px[1] = out[1];
900            px[2] = out[2];
901        }
902    }
903
904    Some(Clut4 {
905        grid_n,
906        data: Arc::new(dst),
907    })
908}
909
910/// Single-pixel CMYK→sRGB lookup against the baked CLUT, with f64 inputs and
911/// outputs. Performs the same K-bracket × 3D tetrahedral interpolation as the
912/// bulk image path, but in floating point so a flat fill (which would otherwise
913/// quantize the input to u8) matches a gradient stop's byte output to within
914/// the CLUT's grid-interpolation error.
915fn sample_clut4_single_f64(clut: &Clut4, c: f64, m: f64, y: f64, k: f64) -> (f64, f64, f64) {
916    let n = clut.grid_n as usize;
917    let nm1 = (n - 1) as f64;
918    let lut = clut.data.as_slice();
919    let stride_c: usize = 3;
920    let stride_m: usize = n * stride_c;
921    let stride_y: usize = n * stride_m;
922    let stride_k: usize = n * stride_y;
923
924    #[inline]
925    fn axis(v: f64, nm1: f64, n: usize) -> (usize, usize, f64) {
926        let scaled = v.clamp(0.0, 1.0) * nm1;
927        let lo = scaled.floor();
928        let frac = scaled - lo;
929        let lo_i = lo as usize;
930        let hi_i = (lo_i + 1).min(n - 1);
931        (lo_i, hi_i, frac)
932    }
933
934    let (ci, ci1, fc) = axis(c, nm1, n);
935    let (mi, mi1, fm) = axis(m, nm1, n);
936    let (yi, yi1, fy) = axis(y, nm1, n);
937    let (ki, ki1, fk) = axis(k, nm1, n);
938
939    // Pick tetrahedron vertices (Kasson '94) — same logic as the u8 path.
940    let (a_dxmy, b_dxmy, w1, w2, w3) = if fc >= fm {
941        if fm >= fy {
942            ((1, 0, 0), (1, 1, 0), fc, fm, fy)
943        } else if fc >= fy {
944            ((1, 0, 0), (1, 0, 1), fc, fy, fm)
945        } else {
946            ((0, 0, 1), (1, 0, 1), fy, fc, fm)
947        }
948    } else if fc >= fy {
949        ((0, 1, 0), (1, 1, 0), fm, fc, fy)
950    } else if fm >= fy {
951        ((0, 1, 0), (0, 1, 1), fm, fy, fc)
952    } else {
953        ((0, 0, 1), (0, 1, 1), fy, fm, fc)
954    };
955
956    let corner = |d: (u8, u8, u8)| -> usize {
957        let (dc, dm, dy) = d;
958        let cx = if dc == 0 { ci } else { ci1 };
959        let mx = if dm == 0 { mi } else { mi1 };
960        let yx = if dy == 0 { yi } else { yi1 };
961        yx * stride_y + mx * stride_m + cx * stride_c
962    };
963
964    let o000 = corner((0, 0, 0));
965    let o111 = corner((1, 1, 1));
966    let oa = corner(a_dxmy);
967    let ob = corner(b_dxmy);
968
969    let base_lo = ki * stride_k;
970    let base_hi = ki1 * stride_k;
971
972    let tetra_channel = |base: usize, ch: usize| -> f64 {
973        let v000 = lut[base + o000 + ch] as f64;
974        let va = lut[base + oa + ch] as f64;
975        let vb = lut[base + ob + ch] as f64;
976        let v111 = lut[base + o111 + ch] as f64;
977        v000 + (va - v000) * w1 + (vb - va) * w2 + (v111 - vb) * w3
978    };
979
980    let r_lo = tetra_channel(base_lo, 0);
981    let g_lo = tetra_channel(base_lo, 1);
982    let b_lo = tetra_channel(base_lo, 2);
983    let (r_hi, g_hi, b_hi) = if ki == ki1 {
984        (r_lo, g_lo, b_lo)
985    } else {
986        (
987            tetra_channel(base_hi, 0),
988            tetra_channel(base_hi, 1),
989            tetra_channel(base_hi, 2),
990        )
991    };
992
993    let inv_fk = 1.0 - fk;
994    let r = (r_lo * inv_fk + r_hi * fk) / 255.0;
995    let g = (g_lo * inv_fk + g_hi * fk) / 255.0;
996    let b = (b_lo * inv_fk + b_hi * fk) / 255.0;
997    (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
998}
999
1000/// Convert an 8-bit packed CMYK buffer to 8-bit packed sRGB using the baked
1001/// 4D CLUT. For each pixel: bracket the K axis into two slices, run 3D
1002/// tetrahedral (Kasson) interpolation on (C,M,Y) in each slice, then linearly
1003/// blend the two results by the K fraction.
1004///
1005/// This preserves the profile's behavior across the K axis (UCR/black-point
1006/// transitions) while giving image-rate throughput.
1007fn apply_clut4_cmyk_to_rgb(clut: &Clut4, src: &[u8], pixel_count: usize) -> Vec<u8> {
1008    let n = clut.grid_n as usize;
1009    let nm1 = (n - 1) as u32;
1010    let lut = clut.data.as_slice();
1011
1012    // Strides in bytes within the flat LUT (K outermost, then Y, M; C innermost).
1013    let stride_c: usize = 3;
1014    let stride_m: usize = n * stride_c;
1015    let stride_y: usize = n * stride_m;
1016    let stride_k: usize = n * stride_y;
1017
1018    let mut out = vec![0u8; pixel_count * 3];
1019
1020    // Per-axis: quantize byte → (lo_idx, hi_idx, frac_in_0_255).
1021    #[inline(always)]
1022    fn axis(v: u8, nm1: u32) -> (usize, usize, u32) {
1023        let scaled = v as u32 * nm1;
1024        let lo = scaled / 255;
1025        let frac = scaled - lo * 255;
1026        let hi = if lo < nm1 { lo + 1 } else { lo };
1027        (lo as usize, hi as usize, frac)
1028    }
1029
1030    for i in 0..pixel_count {
1031        let o = i * 4;
1032        let c = src[o];
1033        let m = src[o + 1];
1034        let y = src[o + 2];
1035        let k = src[o + 3];
1036
1037        let (ci, ci1, fc) = axis(c, nm1);
1038        let (mi, mi1, fm) = axis(m, nm1);
1039        let (yi, yi1, fy) = axis(y, nm1);
1040        let (ki, ki1, fk) = axis(k, nm1);
1041
1042        // Pick tetrahedron vertices and sorted weights ONCE per pixel
1043        // (previously done per channel — 3× waste). Kasson '94:
1044        //   out = V000 + (Va - V000)*w1 + (Vb - Va)*w2 + (V111 - Vb)*w3
1045        // with w1 >= w2 >= w3 and Va, Vb the two intermediate corners.
1046        let (a_dxmy, b_dxmy, w1, w2, w3) = if fc >= fm {
1047            if fm >= fy {
1048                // C,M,Y
1049                ((1, 0, 0), (1, 1, 0), fc, fm, fy)
1050            } else if fc >= fy {
1051                // C,Y,M
1052                ((1, 0, 0), (1, 0, 1), fc, fy, fm)
1053            } else {
1054                // Y,C,M
1055                ((0, 0, 1), (1, 0, 1), fy, fc, fm)
1056            }
1057        } else if fc >= fy {
1058            // M,C,Y
1059            ((0, 1, 0), (1, 1, 0), fm, fc, fy)
1060        } else if fm >= fy {
1061            // M,Y,C
1062            ((0, 1, 0), (0, 1, 1), fm, fy, fc)
1063        } else {
1064            // Y,M,C
1065            ((0, 0, 1), (0, 1, 1), fy, fm, fc)
1066        };
1067
1068        // Map tetrahedron corner selector (dc, dm, dy) → LUT offset within a K slice.
1069        let corner = |d: (u8, u8, u8)| -> usize {
1070            let (dc, dm, dy) = d;
1071            let cx = if dc == 0 { ci } else { ci1 };
1072            let mx = if dm == 0 { mi } else { mi1 };
1073            let yx = if dy == 0 { yi } else { yi1 };
1074            yx * stride_y + mx * stride_m + cx * stride_c
1075        };
1076
1077        let o000 = corner((0, 0, 0));
1078        let o111 = corner((1, 1, 1));
1079        let oa = corner(a_dxmy);
1080        let ob = corner(b_dxmy);
1081
1082        // Two K slices, 3 channels. Compute inline (no closures, no per-channel branching).
1083        let base_lo = ki * stride_k;
1084        let base_hi = ki1 * stride_k;
1085
1086        // Per-channel tetrahedral formula in integer:
1087        //   accum = v000*255 + (va - v000)*w1 + (vb - va)*w2 + (v111 - vb)*w3
1088        // accum is in units of (value * 255), in range [0, 255*255].
1089        let tetra_channel = |base: usize, ch: usize| -> i32 {
1090            let v000 = lut[base + o000 + ch] as i32;
1091            let va = lut[base + oa + ch] as i32;
1092            let vb = lut[base + ob + ch] as i32;
1093            let v111 = lut[base + o111 + ch] as i32;
1094            v000 * 255 + (va - v000) * w1 as i32 + (vb - va) * w2 as i32 + (v111 - vb) * w3 as i32
1095        };
1096
1097        let r_lo = tetra_channel(base_lo, 0);
1098        let g_lo = tetra_channel(base_lo, 1);
1099        let b_lo = tetra_channel(base_lo, 2);
1100        let (r_hi, g_hi, b_hi) = if ki == ki1 {
1101            (r_lo, g_lo, b_lo)
1102        } else {
1103            (
1104                tetra_channel(base_hi, 0),
1105                tetra_channel(base_hi, 1),
1106                tetra_channel(base_hi, 2),
1107            )
1108        };
1109
1110        // Linear blend across K slices and rescale to u8.
1111        let inv_fk = (255 - fk) as i32;
1112        let fk_i = fk as i32;
1113        let round = 255 * 255 / 2;
1114        let finish = |lo: i32, hi: i32| -> u8 {
1115            let combined = lo * inv_fk + hi * fk_i + round;
1116            let v = combined / (255 * 255);
1117            v.clamp(0, 255) as u8
1118        };
1119
1120        let di = i * 3;
1121        out[di] = finish(r_lo, r_hi);
1122        out[di + 1] = finish(g_lo, g_hi);
1123        out[di + 2] = finish(b_lo, b_hi);
1124    }
1125
1126    out
1127}
1128
1129/// Validate a baked CLUT against the direct moxcms transform over a
1130/// pseudorandom sample of CMYK inputs. Reports median and max per-channel
1131/// deviation (in u8 units) to stderr. Invoked only when `STET_ICC_VERIFY` is
1132/// set in the environment.
1133fn verify_clut4(
1134    clut: &Clut4,
1135    transform: &(dyn TransformExecutor<u8> + Send + Sync),
1136    bpc_params: Option<&BpcParams>,
1137) {
1138    const N_SAMPLES: usize = 4096;
1139    let mut rng: u64 = 0xa8b3c4d5e6f70819;
1140    let mut next = || {
1141        rng = rng
1142            .wrapping_mul(6364136223846793005)
1143            .wrapping_add(1442695040888963407);
1144        rng
1145    };
1146    let mut cmyk = Vec::with_capacity(N_SAMPLES * 4);
1147    for _ in 0..N_SAMPLES {
1148        let r = next();
1149        cmyk.extend_from_slice(&[
1150            (r & 0xff) as u8,
1151            ((r >> 8) & 0xff) as u8,
1152            ((r >> 16) & 0xff) as u8,
1153            ((r >> 24) & 0xff) as u8,
1154        ]);
1155    }
1156    let mut reference = vec![0u8; N_SAMPLES * 3];
1157    if transform.transform(&cmyk, &mut reference).is_err() {
1158        eprintln!("[ICC VERIFY] reference transform failed");
1159        return;
1160    }
1161    // Mirror the CLUT bake's BPC step in the reference path so the
1162    // comparison measures interpolation error, not whether BPC was applied.
1163    if let Some(p) = bpc_params {
1164        for px in reference.chunks_exact_mut(3) {
1165            let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
1166            px[0] = out[0];
1167            px[1] = out[1];
1168            px[2] = out[2];
1169        }
1170    }
1171    let interp = apply_clut4_cmyk_to_rgb(clut, &cmyk, N_SAMPLES);
1172    // Per-pixel Euclidean distance in 8-bit sRGB (crude ΔE proxy).
1173    let mut dists: Vec<f64> = Vec::with_capacity(N_SAMPLES);
1174    let mut max_ch: u8 = 0;
1175    for i in 0..N_SAMPLES {
1176        let dr = interp[i * 3] as i32 - reference[i * 3] as i32;
1177        let dg = interp[i * 3 + 1] as i32 - reference[i * 3 + 1] as i32;
1178        let db = interp[i * 3 + 2] as i32 - reference[i * 3 + 2] as i32;
1179        let d = ((dr * dr + dg * dg + db * db) as f64).sqrt();
1180        dists.push(d);
1181        max_ch = max_ch
1182            .max(dr.unsigned_abs() as u8)
1183            .max(dg.unsigned_abs() as u8)
1184            .max(db.unsigned_abs() as u8);
1185    }
1186    dists.sort_by(|a, b| a.partial_cmp(b).unwrap());
1187    let median = dists[N_SAMPLES / 2];
1188    let p99 = dists[(N_SAMPLES * 99) / 100];
1189    let max = dists[N_SAMPLES - 1];
1190    eprintln!(
1191        "[ICC VERIFY] N=17 CLUT vs direct moxcms (sRGB u8): median={:.2}, p99={:.2}, max={:.2}, max_per_channel={}",
1192        median, p99, max, max_ch
1193    );
1194}
1195
1196/// Search system paths for CMYK ICC profile bytes without parsing or logging.
1197///
1198/// Returns the raw bytes suitable for passing to the viewer for ICC-aware rendering.
1199pub fn find_system_cmyk_profile_bytes() -> Option<Arc<Vec<u8>>> {
1200    find_system_cmyk_profile().map(Arc::new)
1201}
1202
1203/// Search common system paths for a CMYK ICC profile.
1204fn find_system_cmyk_profile() -> Option<Vec<u8>> {
1205    #[cfg(target_os = "linux")]
1206    {
1207        let paths = [
1208            "/usr/share/color/icc/ghostscript/default_cmyk.icc",
1209            "/usr/share/color/icc/ghostscript/ps_cmyk.icc",
1210            "/usr/share/color/icc/colord/FOGRA39L_coated.icc",
1211        ];
1212        for path in &paths {
1213            if let Ok(bytes) = std::fs::read(path) {
1214                return Some(bytes);
1215            }
1216        }
1217        // Glob for SWOP profiles
1218        if let Ok(entries) = glob::glob("/usr/share/color/icc/colord/SWOP*.icc") {
1219            for entry in entries.flatten() {
1220                if let Ok(bytes) = std::fs::read(&entry) {
1221                    return Some(bytes);
1222                }
1223            }
1224        }
1225    }
1226
1227    #[cfg(target_os = "macos")]
1228    {
1229        let dirs = [
1230            "/Library/ColorSync/Profiles",
1231            "/System/Library/ColorSync/Profiles",
1232        ];
1233        if let Some(home) = std::env::var_os("HOME") {
1234            let home_dir = std::path::PathBuf::from(home).join("Library/ColorSync/Profiles");
1235            if let Some(bytes) = scan_dir_for_cmyk_icc(&home_dir) {
1236                return Some(bytes);
1237            }
1238        }
1239        for dir in &dirs {
1240            if let Some(bytes) = scan_dir_for_cmyk_icc(std::path::Path::new(dir)) {
1241                return Some(bytes);
1242            }
1243        }
1244    }
1245
1246    #[cfg(target_os = "windows")]
1247    {
1248        if let Some(sysroot) = std::env::var_os("SYSTEMROOT") {
1249            let dir = std::path::PathBuf::from(sysroot).join("System32/spool/drivers/color");
1250            if let Some(bytes) = scan_dir_for_cmyk_icc(&dir) {
1251                return Some(bytes);
1252            }
1253        }
1254    }
1255
1256    None
1257}
1258
1259/// Scan a directory for ICC files with CMYK color space.
1260#[cfg(any(target_os = "macos", target_os = "windows"))]
1261fn scan_dir_for_cmyk_icc(dir: &std::path::Path) -> Option<Vec<u8>> {
1262    let entries = std::fs::read_dir(dir).ok()?;
1263    for entry in entries.flatten() {
1264        let path = entry.path();
1265        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1266        if ext.eq_ignore_ascii_case("icc") || ext.eq_ignore_ascii_case("icm") {
1267            if let Ok(bytes) = std::fs::read(&path) {
1268                // Check ICC header: color space at offset 16, 'CMYK' = 0x434D594B
1269                if bytes.len() >= 20 && &bytes[16..20] == b"CMYK" {
1270                    return Some(bytes);
1271                }
1272            }
1273        }
1274    }
1275    None
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280    use super::*;
1281
1282    #[test]
1283    fn test_icc_cache_new() {
1284        let cache = IccCache::new();
1285        assert!(cache.default_cmyk_hash.is_none());
1286        assert!(cache.profiles.is_empty());
1287        assert_eq!(cache.bpc_mode(), BpcMode::Auto);
1288    }
1289
1290    #[test]
1291    fn test_icc_cache_options_default_matches_new() {
1292        let cache = IccCache::new_with_options(IccCacheOptions::default());
1293        assert_eq!(cache.bpc_mode(), BpcMode::Auto);
1294        assert!(cache.default_cmyk_hash.is_none());
1295    }
1296
1297    #[test]
1298    fn test_icc_cache_options_bpc_off() {
1299        let cache = IccCache::new_with_options(IccCacheOptions {
1300            bpc_mode: BpcMode::Off,
1301            source_cmyk_profile: None,
1302        });
1303        assert_eq!(cache.bpc_mode(), BpcMode::Off);
1304        assert!(!cache.bpc_mode().is_enabled());
1305    }
1306
1307    #[test]
1308    fn test_icc_cache_options_preloads_cmyk_profile() {
1309        // Skip when no system CMYK profile is available.
1310        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1311            return;
1312        };
1313        let cache = IccCache::new_with_options(IccCacheOptions {
1314            bpc_mode: BpcMode::On,
1315            source_cmyk_profile: Some(cmyk_bytes.clone()),
1316        });
1317        assert!(cache.default_cmyk_hash().is_some());
1318        assert_eq!(cache.bpc_mode(), BpcMode::On);
1319    }
1320
1321    #[test]
1322    fn test_bpc_darkens_pure_k_per_color() {
1323        // Skip when no system CMYK profile is available.
1324        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1325            return;
1326        };
1327
1328        // Without BPC: K=1 through default_cmyk.icc lands near RGB(55, 53, 53)
1329        // — the profile's as-mapped black projected through moxcms's sRGB B2A.
1330        let mut off = IccCache::new_with_options(IccCacheOptions {
1331            bpc_mode: BpcMode::Off,
1332            source_cmyk_profile: Some(cmyk_bytes.clone()),
1333        });
1334        let off_rgb = off.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1335
1336        // With BPC: K=1 must land significantly darker. Adobe Acrobat (lcms2)
1337        // produces RGB(35, 31, 32). moxcms's sRGB B2A handles very-dark XYZ
1338        // slightly differently from lcms2, so our post-correct lands a few
1339        // levels brighter than Acrobat — the meaningful invariant is "K=1 is
1340        // visibly darker than the no-BPC baseline by a substantial margin."
1341        let mut on = IccCache::new_with_options(IccCacheOptions {
1342            bpc_mode: BpcMode::On,
1343            source_cmyk_profile: Some(cmyk_bytes),
1344        });
1345        let on_rgb = on.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1346
1347        // Precondition: this test only exercises BPC's darkening effect, which
1348        // requires a profile whose black point has non-zero luminance. Some
1349        // system-supplied CMYK profiles (e.g. macOS's default ColorSync CMYK)
1350        // already map K=1 to (near-)zero XYZ, so BPC has nothing to correct
1351        // and off == on. Skip in that case — there's no regression to anchor
1352        // here, just a profile that doesn't benefit from BPC.
1353        if (on_rgb.1 - off_rgb.1).abs() < 0.005 {
1354            eprintln!(
1355                "Skipping: system CMYK profile's black point is already ~zero; \
1356                 BPC is a no-op here. off={off_rgb:?} on={on_rgb:?}"
1357            );
1358            return;
1359        }
1360
1361        assert!(
1362            on_rgb.1 + 0.03 < off_rgb.1,
1363            "BPC should darken K=1 by ≥0.03 (~8 RGB levels): off={off_rgb:?} on={on_rgb:?}"
1364        );
1365        // And the resulting RGB should be in the "deep gray" range — well
1366        // under 0.25 (RGB ≤ ~64) on every channel.
1367        assert!(
1368            on_rgb.0 < 0.25 && on_rgb.1 < 0.25 && on_rgb.2 < 0.25,
1369            "Expected deep gray after BPC, got {on_rgb:?}"
1370        );
1371    }
1372
1373    #[test]
1374    fn test_bpc_white_anchored_per_color() {
1375        // Skip when no system CMYK profile is available.
1376        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1377            return;
1378        };
1379        let mut cache = IccCache::new_with_options(IccCacheOptions {
1380            bpc_mode: BpcMode::On,
1381            source_cmyk_profile: Some(cmyk_bytes),
1382        });
1383        // CMYK white (no ink) must still render as sRGB white under BPC.
1384        let (r, g, b) = cache.convert_cmyk(0.0, 0.0, 0.0, 0.0).unwrap();
1385        assert!(r > 0.99 && g > 0.99 && b > 0.99, "({r}, {g}, {b})");
1386    }
1387
1388    #[test]
1389    fn test_bpc_image_clut_path_darkens_pure_k() {
1390        // Skip when no system CMYK profile is available.
1391        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1392            return;
1393        };
1394
1395        // Build a 1-pixel CMYK image at K=1 and route it through the CLUT.
1396        // Without BPC vs with BPC, the K=1 pixel must shift darker, mirroring
1397        // the per-color path behaviour.
1398        let off = IccCache::new_with_options(IccCacheOptions {
1399            bpc_mode: BpcMode::Off,
1400            source_cmyk_profile: Some(cmyk_bytes.clone()),
1401        });
1402        let on = IccCache::new_with_options(IccCacheOptions {
1403            bpc_mode: BpcMode::On,
1404            source_cmyk_profile: Some(cmyk_bytes),
1405        });
1406        let off_hash = *off.default_cmyk_hash().unwrap();
1407        let on_hash = *on.default_cmyk_hash().unwrap();
1408
1409        let pixel = [0u8, 0, 0, 255]; // C=0 M=0 Y=0 K=255
1410        let off_rgb = off.convert_image_8bit(&off_hash, &pixel, 1).unwrap();
1411        let on_rgb = on.convert_image_8bit(&on_hash, &pixel, 1).unwrap();
1412
1413        // Precondition, same as `test_bpc_darkens_pure_k_per_color`: BPC only
1414        // shifts pixels when the profile's black point has non-zero luminance.
1415        // Skip when the system profile already maps K=1 to near-zero XYZ.
1416        if (on_rgb[1] as i32 - off_rgb[1] as i32).abs() <= 1 {
1417            eprintln!(
1418                "Skipping: system CMYK profile's black point is already ~zero; \
1419                 BPC is a no-op here. off={off_rgb:?} on={on_rgb:?}"
1420            );
1421            return;
1422        }
1423
1424        // BPC must darken the green channel by ≥8 RGB levels (mirrors the
1425        // per-color path's anchor in test_bpc_darkens_pure_k_per_color).
1426        assert!(
1427            (on_rgb[1] as i32) + 8 < (off_rgb[1] as i32),
1428            "CLUT BPC should darken K=1 image green by ≥8 levels: off={off_rgb:?} on={on_rgb:?}"
1429        );
1430        // And land in the deep-gray range.
1431        assert!(
1432            on_rgb[0] < 64 && on_rgb[1] < 64 && on_rgb[2] < 64,
1433            "Expected deep gray after CLUT BPC, got {on_rgb:?}"
1434        );
1435    }
1436
1437    #[test]
1438    fn test_bpc_off_image_matches_per_color_off() {
1439        // With --bpc off, the bulk image path's K=1 output must match the
1440        // per-color path's K=1 output (within u8 quantization). Anchors that
1441        // disabling BPC reproduces stet's pre-fix behaviour bit-for-bit on
1442        // the dominant CMYK image path.
1443        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1444            return;
1445        };
1446        let mut cache = IccCache::new_with_options(IccCacheOptions {
1447            bpc_mode: BpcMode::Off,
1448            source_cmyk_profile: Some(cmyk_bytes),
1449        });
1450        let hash = *cache.default_cmyk_hash().unwrap();
1451
1452        let pixel = [0u8, 0, 0, 255];
1453        let img = cache.convert_image_8bit(&hash, &pixel, 1).unwrap();
1454        let (r, g, b) = cache.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1455        let pc = [
1456            (r * 255.0).round() as i32,
1457            (g * 255.0).round() as i32,
1458            (b * 255.0).round() as i32,
1459        ];
1460        // CLUT interpolation drift can introduce ±1 vs the direct f64 path.
1461        assert!((img[0] as i32 - pc[0]).abs() <= 2, "img={img:?} pc={pc:?}");
1462        assert!((img[1] as i32 - pc[1]).abs() <= 2, "img={img:?} pc={pc:?}");
1463        assert!((img[2] as i32 - pc[2]).abs() <= 2, "img={img:?} pc={pc:?}");
1464    }
1465
1466    #[test]
1467    fn test_register_invalid_profile() {
1468        let mut cache = IccCache::new();
1469        assert!(cache.register_profile(b"not a valid ICC profile").is_none());
1470    }
1471
1472    #[test]
1473    fn test_srgb_identity_transform() {
1474        // Create an sRGB profile, register it, and verify identity-ish conversion
1475        let srgb = ColorProfile::new_srgb();
1476        let bytes = srgb.encode().unwrap();
1477        let mut cache = IccCache::new();
1478        let hash = cache.register_profile(&bytes).unwrap();
1479
1480        // Red should stay approximately red
1481        let (r, g, b) = cache.convert_color(&hash, &[1.0, 0.0, 0.0]).unwrap();
1482        assert!((r - 1.0).abs() < 0.02, "r={r}");
1483        assert!(g < 0.02, "g={g}");
1484        assert!(b < 0.02, "b={b}");
1485
1486        // White
1487        let (r, g, b) = cache.convert_color(&hash, &[1.0, 1.0, 1.0]).unwrap();
1488        assert!((r - 1.0).abs() < 0.02);
1489        assert!((g - 1.0).abs() < 0.02);
1490        assert!((b - 1.0).abs() < 0.02);
1491    }
1492
1493    #[test]
1494    fn test_srgb_image_transform() {
1495        let srgb = ColorProfile::new_srgb();
1496        let bytes = srgb.encode().unwrap();
1497        let mut cache = IccCache::new();
1498        let hash = cache.register_profile(&bytes).unwrap();
1499
1500        // 2 pixels: red, green
1501        let src = [255u8, 0, 0, 0, 255, 0];
1502        let result = cache.convert_image_8bit(&hash, &src, 2).unwrap();
1503        assert_eq!(result.len(), 6);
1504        // Red pixel should be approximately (255, 0, 0)
1505        assert!(result[0] > 240);
1506        assert!(result[1] < 15);
1507        assert!(result[2] < 15);
1508    }
1509
1510    #[test]
1511    fn test_convert_rgb_to_cmyk_readonly() {
1512        // Skip when no system CMYK profile is available (CI without ICC packs).
1513        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1514            return;
1515        };
1516        let mut cache = IccCache::new();
1517        let hash = cache.register_profile(&cmyk_bytes).unwrap();
1518        cache.set_default_cmyk_hash(hash);
1519
1520        // Before pre-warming the reverse transform must be unavailable.
1521        assert!(cache.convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0).is_none());
1522
1523        cache.prepare_reverse_cmyk();
1524
1525        // Pure black sRGB should land deep in K (any reasonable CMYK profile
1526        // produces a high K component).
1527        let cmyk = cache
1528            .convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0)
1529            .expect("reverse transform should be available after prepare");
1530        assert!(
1531            cmyk[3] > 0.5,
1532            "expected K>0.5 for sRGB black, got cmyk={cmyk:?}"
1533        );
1534
1535        // Pure white sRGB should land near (0,0,0,0) — minimal ink.
1536        let cmyk = cache
1537            .convert_rgb_to_cmyk_readonly(1.0, 1.0, 1.0)
1538            .expect("reverse transform should be available");
1539        for (i, v) in cmyk.iter().enumerate() {
1540            assert!(*v < 0.05, "expected near-zero ink at chan {i}, got {v}");
1541        }
1542    }
1543
1544    /// White CMYK (0,0,0,0) routed through the perceptual CLUT must land at
1545    /// pure white sRGB. Catches scaling errors in the PCS-Lab decode (the
1546    /// most likely place to introduce a uniform brightness shift).
1547    #[test]
1548    fn test_perceptual_clut_white_anchor() {
1549        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1550            return;
1551        };
1552        let mut cache = IccCache::new_with_options(IccCacheOptions {
1553            bpc_mode: BpcMode::Off,
1554            source_cmyk_profile: Some(cmyk_bytes),
1555        });
1556        let rgb = cache.convert_cmyk(0.0, 0.0, 0.0, 0.0).unwrap();
1557        // Tolerate a few u8 levels of grid-interpolation drift; the bound
1558        // catches order-of-magnitude bugs (a ~0.5x scale would land at ~127).
1559        assert!(
1560            rgb.0 > 0.97 && rgb.1 > 0.97 && rgb.2 > 0.97,
1561            "CMYK white should map near sRGB white, got {rgb:?}"
1562        );
1563    }
1564
1565    /// Single-color CLUT lookup must agree with bulk image conversion to
1566    /// within u8 quantization on the same input. Anchors the requirement
1567    /// that flat CMYK fills match adjacent gradient stops byte-for-byte.
1568    #[test]
1569    fn test_perceptual_clut_single_matches_bulk() {
1570        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1571            return;
1572        };
1573        let mut cache = IccCache::new_with_options(IccCacheOptions {
1574            bpc_mode: BpcMode::On,
1575            source_cmyk_profile: Some(cmyk_bytes),
1576        });
1577        let hash = *cache.default_cmyk_hash().unwrap();
1578        // A handful of saturated and midtone CMYK samples.
1579        let samples: &[(u8, u8, u8, u8)] = &[
1580            (0, 0, 0, 0),
1581            (255, 0, 0, 0),
1582            (0, 255, 0, 0),
1583            (0, 0, 255, 0),
1584            (0, 0, 0, 255),
1585            (38, 255, 255, 0), // ≈ (0.15, 1.0, 1.0, 0.0) — the GWG demo input
1586            (128, 128, 128, 0),
1587        ];
1588        let pixels: Vec<u8> = samples
1589            .iter()
1590            .flat_map(|&(c, m, y, k)| [c, m, y, k])
1591            .collect();
1592        let bulk = cache
1593            .convert_image_8bit(&hash, &pixels, samples.len())
1594            .unwrap();
1595        for (i, &(c, m, y, k)) in samples.iter().enumerate() {
1596            let single = cache
1597                .convert_color(
1598                    &hash,
1599                    &[
1600                        c as f64 / 255.0,
1601                        m as f64 / 255.0,
1602                        y as f64 / 255.0,
1603                        k as f64 / 255.0,
1604                    ],
1605                )
1606                .unwrap();
1607            let single_u8 = [
1608                (single.0 * 255.0).round() as i32,
1609                (single.1 * 255.0).round() as i32,
1610                (single.2 * 255.0).round() as i32,
1611            ];
1612            let b = &bulk[i * 3..i * 3 + 3];
1613            for ch in 0..3 {
1614                let delta = (single_u8[ch] - b[ch] as i32).abs();
1615                assert!(
1616                    delta <= 2,
1617                    "single vs bulk mismatch at sample {i} chan {ch}: \
1618                     single={single_u8:?} bulk={:?} delta={delta}",
1619                    [b[0], b[1], b[2]]
1620                );
1621            }
1622        }
1623    }
1624}