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