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/// Re-export so callers in `stet-pdf-reader` (and other consumers
25/// without a direct moxcms dependency) can specify the rendering intent
26/// for [`IccCache::convert_color_with_intent`] etc. without taking a
27/// moxcms dep themselves.
28pub use moxcms::RenderingIntent as IccRenderingIntent;
29
30/// Map a PDF gstate `rendering_intent` byte (0=Perceptual, 1=RelCol,
31/// 2=Saturation, 3=AbsCol — the encoding used by
32/// `stet-pdf-reader::content::graphics_state::PdfGraphicsState`) to the
33/// corresponding [`IccRenderingIntent`]. Unknown values fall back to
34/// Perceptual, matching the PDF spec's default.
35#[inline]
36pub fn intent_from_pdf_byte(b: u8) -> IccRenderingIntent {
37    match b {
38        1 => IccRenderingIntent::RelativeColorimetric,
39        2 => IccRenderingIntent::Saturation,
40        3 => IccRenderingIntent::AbsoluteColorimetric,
41        _ => IccRenderingIntent::Perceptual,
42    }
43}
44
45/// SHA-256 hash used as profile key.
46pub type ProfileHash = [u8; 32];
47
48/// Cache key for per-intent single-colour lookups: `(hash-prefix,
49/// quantized_components, intent_byte)`.
50type IntentColorKey = (u64, [u16; 4], u8);
51
52/// Black Point Compensation mode for CMYK→sRGB conversion.
53///
54/// Reference renderers (Ghostscript, Acrobat, Firefox via lcms2) apply BPC by
55/// default for relative-colorimetric CMYK→sRGB. moxcms 0.8.1 ships BPC
56/// commented out, so without it K-heavy colors render visibly lighter than
57/// reference renderers. See `docs/PLAN-BPC.md` for the full design.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum BpcMode {
60    /// Skip BPC; matches stet's pre-fix behavior. Useful for proofing-style
61    /// renders that should preserve actual densities, or for bit-for-bit
62    /// reproduction of older baselines.
63    Off,
64    /// Always apply BPC during CMYK→sRGB conversion.
65    On,
66    /// Default — currently equivalent to `On`. Reserved for forward
67    /// compatibility (eventually could honor PDF rendering-intent or
68    /// output-intent hints).
69    #[default]
70    Auto,
71}
72
73impl BpcMode {
74    /// True when BPC should be applied at conversion time.
75    #[inline]
76    pub fn is_enabled(self) -> bool {
77        matches!(self, BpcMode::On | BpcMode::Auto)
78    }
79}
80
81/// Construction-time options for [`IccCache`].
82///
83/// Bundles together the BPC mode and an optional pre-supplied source CMYK
84/// profile (overriding the automatic system-profile search). Created via
85/// [`IccCache::new_with_options`].
86#[derive(Clone, Default)]
87pub struct IccCacheOptions {
88    /// BPC mode for CMYK→sRGB conversion.
89    pub bpc_mode: BpcMode,
90    /// Raw bytes of a source CMYK profile to register as the system profile.
91    /// When `None`, the cache is created empty and the caller is responsible
92    /// for invoking [`IccCache::search_system_cmyk_profile`] (or providing
93    /// bytes some other way).
94    pub source_cmyk_profile: Option<Vec<u8>>,
95}
96
97/// Identity Gray→RGB transform: maps each gray value to equal R=G=B.
98/// Used as fallback when a Gray ICC profile can't produce a proper transform.
99struct GrayToRgbIdentity;
100
101impl TransformExecutor<u8> for GrayToRgbIdentity {
102    fn transform(&self, src: &[u8], dst: &mut [u8]) -> Result<(), CmsError> {
103        for (g, rgb) in src.iter().zip(dst.chunks_exact_mut(3)) {
104            rgb[0] = *g;
105            rgb[1] = *g;
106            rgb[2] = *g;
107        }
108        Ok(())
109    }
110}
111
112impl TransformExecutor<f64> for GrayToRgbIdentity {
113    fn transform(&self, src: &[f64], dst: &mut [f64]) -> Result<(), CmsError> {
114        for (g, rgb) in src.iter().zip(dst.chunks_exact_mut(3)) {
115            rgb[0] = *g;
116            rgb[1] = *g;
117            rgb[2] = *g;
118        }
119        Ok(())
120    }
121}
122
123/// `TransformExecutor` adapter that resolves CMYK → sRGB through an existing
124/// `Clut4`. Used as stage 2 of the proofing chain so the chain output goes
125/// through the same hand-rolled colorimetric path as direct DeviceCMYK
126/// conversion, avoiding the moxcms over-saturation cited in
127/// `perceptual.rs`.
128struct Clut4ToRgb {
129    clut4: Clut4,
130}
131
132impl TransformExecutor<u8> for Clut4ToRgb {
133    fn transform(&self, src: &[u8], dst: &mut [u8]) -> Result<(), CmsError> {
134        let pixel_count = dst.len() / 3;
135        let rgb = apply_clut4_cmyk_to_rgb(&self.clut4, src, pixel_count);
136        dst[..rgb.len()].copy_from_slice(&rgb);
137        Ok(())
138    }
139}
140
141impl TransformExecutor<f64> for Clut4ToRgb {
142    fn transform(&self, src: &[f64], dst: &mut [f64]) -> Result<(), CmsError> {
143        let pixel_count = dst.len() / 3;
144        for px in 0..pixel_count {
145            let c = src[px * 4];
146            let m = src[px * 4 + 1];
147            let y = src[px * 4 + 2];
148            let k = src[px * 4 + 3];
149            let (r, g, b) = sample_clut4_single_f64(&self.clut4, c, m, y, k);
150            dst[px * 3] = r;
151            dst[px * 3 + 1] = g;
152            dst[px * 3 + 2] = b;
153        }
154        Ok(())
155    }
156}
157
158/// Two-stage proofing transform: `source → intermediate (OutputIntent) → sRGB`.
159///
160/// PDF/X workflows specify a target device via `/OutputIntents`. Source
161/// colours (DeviceCMYK, ICCBased CMYK/RGB/Gray, etc.) are color-managed
162/// through that target before reaching the simulated display, so every
163/// source space converges through the same final `OutputIntent → sRGB`
164/// stage and a CMYK swatch designed to match a DeviceCMYK background
165/// renders identically to it.
166///
167/// `intermediate_n` is the number of components in the OutputIntent's
168/// colour space (typically 4 for CMYK). The intermediate buffer is sized
169/// per `transform()` call from the destination pixel count.
170struct ChainedTransform<T: Copy + Default + Send + Sync + 'static> {
171    /// First leg: `source → OutputIntent`.
172    stage1: Arc<dyn TransformExecutor<T> + Send + Sync>,
173    /// Second leg: `OutputIntent → sRGB`. Reused from the OutputIntent
174    /// profile's own cached transform so byte-identical output is
175    /// produced for direct DeviceCMYK paints and the proofing chain.
176    stage2: Arc<dyn TransformExecutor<T> + Send + Sync>,
177    /// Number of components in the intermediate (OutputIntent) layout.
178    intermediate_n: usize,
179}
180
181impl<T: Copy + Default + Send + Sync + 'static> TransformExecutor<T> for ChainedTransform<T> {
182    fn transform(&self, src: &[T], dst: &mut [T]) -> Result<(), CmsError> {
183        // Destination is always packed sRGB (3 components). Derive pixel
184        // count from `dst.len()` so we don't have to know the source
185        // component count here.
186        let pixel_count = dst.len() / 3;
187        let mid_len = pixel_count * self.intermediate_n;
188        let mut mid = vec![T::default(); mid_len];
189        self.stage1.transform(src, &mut mid)?;
190        self.stage2.transform(&mid, dst)
191    }
192}
193
194/// Pre-baked 4D CLUT sampling a CMYK ICC transform on a regular grid.
195///
196/// At profile-registration time we sample moxcms at `grid_n^4` evenly-spaced
197/// CMYK points and store the sRGB output. At image-conversion time we do
198/// K-slice plus 3D tetrahedral interpolation inside each slice. This is ~30×
199/// faster than direct moxcms for LUT-based CMYK profiles (e.g., SWOP) while
200/// staying well inside imperceptible ΔE for typical print-workflow inputs.
201#[derive(Clone)]
202struct Clut4 {
203    /// Grid points per axis (typical: 17).
204    grid_n: u8,
205    /// Flat LUT in order (k, y, m, c) with C fastest, K slowest.
206    /// Length = grid_n^4 * 3 bytes (packed sRGB).
207    data: Arc<Vec<u8>>,
208}
209
210impl Clut4 {
211    /// Construct a Clut4 from a pre-baked byte buffer with the same memory
212    /// layout `bake_clut4` produces (K outermost, Y, M, C innermost; 3 bytes
213    /// per grid point). Used by [`perceptual::bake_clut4_perceptual`] so its
214    /// output is byte-compatible with [`apply_clut4_cmyk_to_rgb`].
215    fn from_baked(grid_n: u8, data: Vec<u8>) -> Self {
216        debug_assert_eq!(data.len(), (grid_n as usize).pow(4) * 3);
217        Self {
218            grid_n,
219            data: Arc::new(data),
220        }
221    }
222}
223
224/// Cached ICC transform to sRGB (specific to source layout).
225#[derive(Clone)]
226struct CachedTransform {
227    /// 8-bit transform for image data. When proofing is enabled and the
228    /// source is RGB, this is the Perceptual-intent chain so existing
229    /// callers (no intent plumbing yet) keep producing the GWG 13.0
230    /// baseline byte-for-byte.
231    transform_8bit: Arc<dyn TransformExecutor<u8> + Send + Sync>,
232    /// f64 transform for single-color conversions. Same intent default
233    /// as `transform_8bit`.
234    transform_f64: Arc<dyn TransformExecutor<f64> + Send + Sync>,
235    /// Per-intent proofing chains, indexed by ICC `RenderingIntent`
236    /// discriminant: `[Perceptual=0, RelCol=1, Saturation=2, AbsCol=3]`.
237    /// Populated only for n=3 RGB sources when proofing is enabled and
238    /// the source has a viable A2B / OI B2A pair; `None` slots fall back
239    /// to `transform_*bit` / `transform_*_f64` at lookup time. AbsCol
240    /// currently shares the RelCol tables (pending step 4 BPC + AbsCol
241    /// white-point handling).
242    chain_per_intent_8bit: [Option<Arc<dyn TransformExecutor<u8> + Send + Sync>>; 4],
243    chain_per_intent_f64: [Option<Arc<dyn TransformExecutor<f64> + Send + Sync>>; 4],
244    /// Per-intent stage-1 only sampler (source RGB → OutputIntent CMYK,
245    /// without the trailing CMYK→sRGB stage 2). Same indexing as the
246    /// `chain_per_intent_*` arrays. Used by
247    /// [`IccCache::convert_to_oi_cmyk`] so the PDF reader can record the
248    /// chain's intermediate CMYK as `DeviceColor::native_cmyk`, which
249    /// in turn lets the renderer's `cmyk_group_blend` gate fire on
250    /// ICCBased RGB swatches inside a `/CS DeviceCMYK` page group
251    /// (GWG 16.1).
252    chain_stage1_per_intent: [Option<Arc<perceptual::HandRolledChainStage1Rgb>>; 4],
253    /// Number of source components.
254    n: u32,
255    /// Whether the source profile is Lab (needs value normalization).
256    is_lab: bool,
257    /// Pre-baked 4D CLUT for fast CMYK→sRGB image conversion.
258    /// Only built for `n == 4` profiles; None otherwise.
259    clut4: Option<Clut4>,
260    /// Cached Black Point Compensation parameters for this profile. Computed
261    /// when `n == 4` and `IccCache::bpc_mode` is enabled. Applied as a
262    /// post-correction on the moxcms output (sRGB → XYZ-D50 → BPC shift →
263    /// back to sRGB) so K-heavy CMYK colours map to true zero black.
264    bpc_params: Option<BpcParams>,
265}
266
267/// ICC color profile cache and transform manager.
268#[derive(Clone)]
269pub struct IccCache {
270    /// SHA-256 hash → parsed ColorProfile.
271    profiles: HashMap<ProfileHash, Arc<ColorProfile>>,
272    /// Cached transforms: hash → CachedTransform.
273    transforms: HashMap<ProfileHash, CachedTransform>,
274    /// Single-color conversion cache: `(hash-prefix, quantized_components)
275    /// → (r, g, b)`. Used by [`Self::convert_color`] (the legacy /
276    /// default-Perceptual path). Uses first 8 bytes of hash as u64 key
277    /// for compactness.
278    color_cache: HashMap<(u64, [u16; 4]), (f64, f64, f64)>,
279    /// Per-intent single-color conversion cache: `(hash-prefix,
280    /// quantized_components, intent_byte) → (r, g, b)`. Populated by
281    /// [`Self::convert_color_with_intent`] for non-Perceptual intents
282    /// so step 3's per-intent renderer plumbing can stay cheap when a
283    /// page calls the same conversion repeatedly under e.g. Saturation.
284    color_cache_intent: HashMap<IntentColorKey, (f64, f64, f64)>,
285    /// Default system CMYK profile hash (if found at startup).
286    default_cmyk_hash: Option<ProfileHash>,
287    /// Raw bytes of the system CMYK profile (for re-registration in render threads).
288    system_cmyk_bytes: Option<Arc<Vec<u8>>>,
289    /// Raw profile bytes for each registered profile (for PDF embedding).
290    raw_bytes: HashMap<ProfileHash, Arc<Vec<u8>>>,
291    /// sRGB output profile (created once).
292    srgb_profile: ColorProfile,
293    /// Cached sRGB→CMYK reverse transform (for RGB round-trip through CMYK page groups).
294    reverse_cmyk_f64: Option<Arc<dyn TransformExecutor<f64> + Send + Sync>>,
295    /// Black Point Compensation mode for CMYK→sRGB conversion. Set at
296    /// construction time via [`IccCacheOptions`]; consulted by future BPC
297    /// apply paths (commit 2 of `docs/PLAN-BPC.md`).
298    bpc_mode: BpcMode,
299    /// Enable PDF/X-style proofing: ICCBased source profiles convert through
300    /// the default CMYK profile (the document's OutputIntent) before going
301    /// to sRGB, so all source colour spaces converge through the same
302    /// `OutputIntent → sRGB` final stage. Set by
303    /// `PdfDocument::apply_output_intent_as_default_cmyk` when the PDF has
304    /// an `/OutputIntents` entry; left `false` when only the system CMYK
305    /// fallback is available, so non-PDF/X documents keep their direct
306    /// `source → sRGB` conversion.
307    proofing_enabled: bool,
308    /// Per-intent Lab → OutputIntent CMYK samplers. Built lazily by
309    /// [`Self::prepare_lab_to_oi_cmyk`] from the OI's `B2A*` LUTs so
310    /// `convert_lab_to_oi_cmyk` can populate `DeviceColor::native_cmyk`
311    /// for Lab fills with a direct `Lab → PCS → OI B2A → CMYK` value
312    /// (matching Acrobat's ACE) instead of the sRGB→ICC-reverse approximation
313    /// the parallel CMYK buffer would otherwise compute. Surfaced under
314    /// CMYK-group blends — GWG 22.1's ColorBurn form over a Lab BG is the
315    /// canonical case where the indirect path drifts visibly.
316    lab_to_oi_per_intent: [Option<Arc<perceptual::LabToCmykSampler>>; 4],
317}
318
319impl Default for IccCache {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325/// Apply BPC to an sRGB triple if `params` is `Some`; otherwise return the
326/// triple unchanged. Centralised so every conversion entry point stays in
327/// sync.
328#[inline]
329fn bpc_post_correct(rgb: [f64; 3], params: Option<&BpcParams>) -> [f64; 3] {
330    match params {
331        Some(p) => apply_bpc_f64(rgb, p),
332        None => rgb,
333    }
334}
335
336impl IccCache {
337    /// Create an empty ICC cache with default options (BPC `Auto`, no
338    /// pre-supplied source CMYK profile).
339    pub fn new() -> Self {
340        Self::new_with_options(IccCacheOptions::default())
341    }
342
343    /// Create an ICC cache with the given options.
344    ///
345    /// When `opts.source_cmyk_profile` is `Some`, the bytes are registered as
346    /// the system CMYK profile (overriding any later
347    /// [`Self::search_system_cmyk_profile`] call). Otherwise the cache starts
348    /// empty and the caller is expected to supply a profile separately.
349    pub fn new_with_options(opts: IccCacheOptions) -> Self {
350        let mut cache = Self {
351            profiles: HashMap::new(),
352            transforms: HashMap::new(),
353            color_cache: HashMap::new(),
354            color_cache_intent: HashMap::new(),
355            default_cmyk_hash: None,
356            system_cmyk_bytes: None,
357            raw_bytes: HashMap::new(),
358            srgb_profile: ColorProfile::new_srgb(),
359            reverse_cmyk_f64: None,
360            bpc_mode: opts.bpc_mode,
361            proofing_enabled: false,
362            lab_to_oi_per_intent: [None, None, None, None],
363        };
364        if let Some(bytes) = opts.source_cmyk_profile {
365            cache.load_cmyk_profile_bytes(&bytes);
366        }
367        cache
368    }
369
370    /// Current Black Point Compensation mode.
371    #[inline]
372    pub fn bpc_mode(&self) -> BpcMode {
373        self.bpc_mode
374    }
375
376    /// Compute the SHA-256 hash of an ICC profile without registering it.
377    pub fn hash_profile(bytes: &[u8]) -> ProfileHash {
378        use sha2::{Digest, Sha256};
379        Sha256::digest(bytes).into()
380    }
381
382    /// Register an ICC profile from raw bytes. Returns the SHA-256 hash on success.
383    pub fn register_profile(&mut self, bytes: &[u8]) -> Option<ProfileHash> {
384        self.register_profile_with_n(bytes, None)
385    }
386
387    /// Register an ICC profile, validating that its color space matches the
388    /// expected component count `expected_n`. When the profile's actual color
389    /// space has a different number of components (e.g. an RGB profile stored
390    /// with PDF `/N 1`), the profile is rejected so the caller can fall back
391    /// to the alternate color space.
392    pub fn register_profile_with_n(
393        &mut self,
394        bytes: &[u8],
395        expected_n: Option<u32>,
396    ) -> Option<ProfileHash> {
397        use sha2::{Digest, Sha256};
398        let hash: ProfileHash = Sha256::digest(bytes).into();
399
400        // Already registered?
401        if self.transforms.contains_key(&hash) {
402            return Some(hash);
403        }
404
405        // Store raw bytes for PDF embedding
406        self.raw_bytes
407            .entry(hash)
408            .or_insert_with(|| Arc::new(bytes.to_vec()));
409
410        let profile = match ColorProfile::new_from_slice(bytes) {
411            Ok(p) => p,
412            Err(e) => {
413                eprintln!("[ICC] Failed to parse profile: {e}");
414                return None;
415            }
416        };
417
418        let n = match profile.color_space {
419            DataColorSpace::Gray => 1u32,
420            DataColorSpace::Rgb => 3,
421            DataColorSpace::Cmyk => 4,
422            DataColorSpace::Lab => 3,
423            _ => {
424                eprintln!(
425                    "[ICC] Unsupported profile color space: {:?}",
426                    profile.color_space
427                );
428                return None;
429            }
430        };
431
432        // Reject profile when its actual component count doesn't match the
433        // PDF's /N declaration — the input data won't match the profile's
434        // expected input layout.
435        if let Some(expected) = expected_n {
436            if n != expected {
437                return None;
438            }
439        }
440
441        let (src_layout_8, src_layout_f64) = match n {
442            1 => (Layout::Gray, Layout::Gray),
443            3 => (Layout::Rgb, Layout::Rgb),
444            4 => (Layout::Rgba, Layout::Rgba),
445            _ => return None,
446        };
447
448        let dst_layout_8 = Layout::Rgb;
449        let dst_layout_f64 = Layout::Rgb;
450
451        // Try multiple rendering intents — Perceptual first so the
452        // moxcms-driven `transform_f64` Arc honours the profile's perceptual
453        // table. Most CMYK paths route through the perceptual A2B0 CLUT
454        // (`bake_clut4_perceptual`) instead, but a few sites still call the
455        // f64 transform directly (e.g. `Luminosity` soft-mask conversion in
456        // the renderer); for those sites, picking the Perceptual transform
457        // here keeps the per-pixel result aligned with the CLUT path. ICC v4
458        // profiles may only have A2B0, so this also covers those.
459        let intents = [
460            RenderingIntent::Perceptual,
461            RenderingIntent::RelativeColorimetric,
462            RenderingIntent::AbsoluteColorimetric,
463            RenderingIntent::Saturation,
464        ];
465
466        let mut transform_8bit = None;
467        for &intent in &intents {
468            let options = TransformOptions {
469                rendering_intent: intent,
470                ..TransformOptions::default()
471            };
472            match profile.create_transform_8bit(
473                src_layout_8,
474                &self.srgb_profile,
475                dst_layout_8,
476                options,
477            ) {
478                Ok(t) => {
479                    transform_8bit = Some(t);
480                    break;
481                }
482                Err(_) => continue,
483            }
484        }
485        let transform_8bit = match transform_8bit {
486            Some(t) => t,
487            None if n == 1 => {
488                // Gray profiles that can't produce Gray→sRGB transforms (e.g.
489                // minimal Linotype profiles with only a TRC): fall back to the
490                // sRGB gray curve, which is functionally correct for most Gray
491                // profiles encountered in PDFs.
492                return self.register_gray_identity(hash, profile);
493            }
494            None => {
495                eprintln!(
496                    "[ICC] Failed to create 8-bit transform (cs={:?})",
497                    profile.color_space
498                );
499                return None;
500            }
501        };
502
503        let mut transform_f64 = None;
504        for &intent in &intents {
505            let options = TransformOptions {
506                rendering_intent: intent,
507                ..TransformOptions::default()
508            };
509            match profile.create_transform_f64(
510                src_layout_f64,
511                &self.srgb_profile,
512                dst_layout_f64,
513                options,
514            ) {
515                Ok(t) => {
516                    transform_f64 = Some(t);
517                    break;
518                }
519                Err(_) => continue,
520            }
521        }
522        let transform_f64 = match transform_f64 {
523            Some(t) => t,
524            None if n == 1 => {
525                // Same Gray fallback for f64 path
526                return self.register_gray_identity(hash, profile);
527            }
528            None => {
529                eprintln!(
530                    "[ICC] Failed to create f64 transform (cs={:?})",
531                    profile.color_space
532                );
533                return None;
534            }
535        };
536
537        let is_lab = profile.color_space == DataColorSpace::Lab;
538
539        // PDF/X proofing chain: when proofing is enabled and a non-OutputIntent
540        // profile is being registered, route source colours through the
541        // OutputIntent (`source → OutputIntent → sRGB`) so that an ICCBased
542        // CMYK swatch designed to match a DeviceCMYK background renders
543        // identically to it. The OutputIntent itself is registered with a
544        // direct transform; subsequent profiles are chained.
545        type ChainPair = (
546            Arc<dyn TransformExecutor<u8> + Send + Sync>,
547            Arc<dyn TransformExecutor<f64> + Send + Sync>,
548        );
549        let mut chain_per_intent_8bit: [Option<Arc<dyn TransformExecutor<u8> + Send + Sync>>; 4] =
550            Default::default();
551        let mut chain_per_intent_f64: [Option<Arc<dyn TransformExecutor<f64> + Send + Sync>>; 4] =
552            Default::default();
553        let mut chain_stage1_per_intent: [Option<Arc<perceptual::HandRolledChainStage1Rgb>>; 4] =
554            Default::default();
555        let chain_data: Option<ChainPair> = if self.proofing_enabled
556            && let Some(oi_hash) = self.default_cmyk_hash
557            && oi_hash != hash
558            && let Some(oi_profile) = self.profiles.get(&oi_hash).cloned()
559            && let Some(oi_cached) = self.transforms.get(&oi_hash)
560            && oi_cached.n == 4
561        {
562            // OI is always CMYK in PDF/X workflows; intermediate is 4-component.
563            let oi_layout_8 = Layout::Rgba;
564            let oi_layout_f64 = Layout::Rgba;
565
566            // Stage 2 (`OutputIntent → sRGB`): prefer OI's hand-rolled
567            // CLUT4 over its moxcms transform so chain output matches
568            // direct DeviceCMYK conversion at the byte level. The same
569            // stage-2 is reused across every intent's chain.
570            let stage2_8bit: Arc<dyn TransformExecutor<u8> + Send + Sync> =
571                if let Some(oi_clut) = oi_cached.clut4.clone() {
572                    Arc::new(Clut4ToRgb { clut4: oi_clut })
573                } else {
574                    oi_cached.transform_8bit.clone()
575                };
576            let stage2_f64: Arc<dyn TransformExecutor<f64> + Send + Sync> =
577                if let Some(oi_clut) = oi_cached.clut4.clone() {
578                    Arc::new(Clut4ToRgb { clut4: oi_clut })
579                } else {
580                    oi_cached.transform_f64.clone()
581                };
582
583            // Hand-rolled chain stage 1 for RGB sources. moxcms's
584            // `create_transform` over-saturates sRGB-style first legs by
585            // ~5–15% (visible as the GWG 16.1 "X" marks), so we compose
586            // `source.A2B[i] → OI.B2A[i]` per-pixel using the same
587            // primitives moxcms exposes. Per-pixel composition (vs. an
588            // intermediate 17³ CLUT bake) avoids the quantization layer
589            // that drifts GWG 13.0's BG-vs-X match by ~6 RGB levels. Only
590            // n=3 (RGB) sources go through this path; CMYK sources keep
591            // using moxcms (no diagnostic shows that leg drifting).
592            //
593            // For each of the four ICC rendering intents we try to build
594            // a chain using that intent's tables on both sides. Intents
595            // whose A2B / B2A tables are missing (or whose profile is in
596            // an unsupported shape) skip silently and the slot stays
597            // `None`; lookup-time callers fall back to moxcms.
598            if n == 3 {
599                use moxcms::RenderingIntent;
600                for &intent in &[
601                    RenderingIntent::Perceptual,
602                    RenderingIntent::RelativeColorimetric,
603                    RenderingIntent::Saturation,
604                    RenderingIntent::AbsoluteColorimetric,
605                ] {
606                    let Some(stage1) =
607                        perceptual::HandRolledChainStage1Rgb::new(&profile, &oi_profile, intent)
608                    else {
609                        continue;
610                    };
611                    let stage1_arc = Arc::new(stage1);
612                    let chain_8: Arc<dyn TransformExecutor<u8> + Send + Sync> =
613                        Arc::new(ChainedTransform {
614                            stage1: stage1_arc.clone(),
615                            stage2: stage2_8bit.clone(),
616                            intermediate_n: 4,
617                        });
618                    let chain_f: Arc<dyn TransformExecutor<f64> + Send + Sync> =
619                        Arc::new(ChainedTransform {
620                            stage1: stage1_arc.clone(),
621                            stage2: stage2_f64.clone(),
622                            intermediate_n: 4,
623                        });
624                    let i = intent as usize;
625                    chain_per_intent_8bit[i] = Some(chain_8);
626                    chain_per_intent_f64[i] = Some(chain_f);
627                    chain_stage1_per_intent[i] = Some(stage1_arc);
628                }
629            }
630
631            // Default chain (used when the lookup path doesn't yet pass an
632            // intent — the current state of `convert_color`). Picks the
633            // Perceptual hand-rolled chain when available; otherwise falls
634            // back to moxcms's transform-driven build, preserving the
635            // n=4 CMYK source path.
636            let perceptual_idx = moxcms::RenderingIntent::Perceptual as usize;
637            if let (Some(c8), Some(cf)) = (
638                chain_per_intent_8bit[perceptual_idx].clone(),
639                chain_per_intent_f64[perceptual_idx].clone(),
640            ) {
641                Some((c8, cf))
642            } else {
643                let mut stage1_8bit_opt: Option<Arc<dyn TransformExecutor<u8> + Send + Sync>> =
644                    None;
645                let mut stage1_f64_opt: Option<Arc<dyn TransformExecutor<f64> + Send + Sync>> =
646                    None;
647                for &intent in &intents {
648                    let options = TransformOptions {
649                        rendering_intent: intent,
650                        ..TransformOptions::default()
651                    };
652                    if let Ok(t) = profile.create_transform_8bit(
653                        src_layout_8,
654                        &oi_profile,
655                        oi_layout_8,
656                        options,
657                    ) {
658                        stage1_8bit_opt = Some(t);
659                        break;
660                    }
661                }
662                for &intent in &intents {
663                    let options = TransformOptions {
664                        rendering_intent: intent,
665                        ..TransformOptions::default()
666                    };
667                    if let Ok(t) = profile.create_transform_f64(
668                        src_layout_f64,
669                        &oi_profile,
670                        oi_layout_f64,
671                        options,
672                    ) {
673                        stage1_f64_opt = Some(t);
674                        break;
675                    }
676                }
677                match (stage1_8bit_opt, stage1_f64_opt) {
678                    (Some(s1_8), Some(s1_f)) => {
679                        let chain_8: Arc<dyn TransformExecutor<u8> + Send + Sync> =
680                            Arc::new(ChainedTransform {
681                                stage1: s1_8,
682                                stage2: stage2_8bit,
683                                intermediate_n: 4,
684                            });
685                        let chain_f: Arc<dyn TransformExecutor<f64> + Send + Sync> =
686                            Arc::new(ChainedTransform {
687                                stage1: s1_f,
688                                stage2: stage2_f64,
689                                intermediate_n: 4,
690                            });
691                        Some((chain_8, chain_f))
692                    }
693                    _ => None,
694                }
695            }
696        } else {
697            None
698        };
699
700        // When the chain build succeeded, override the cached transforms with
701        // the chain versions. The CLUT4 below is then baked from the chain
702        // (for CMYK source profiles), so single-color and image conversions
703        // share the same `OutputIntent → sRGB` final stage as DeviceCMYK.
704        let (transform_8bit, transform_f64, chain_active) = match chain_data {
705            Some((c8, cf)) => (c8, cf, true),
706            None => (transform_8bit, transform_f64, false),
707        };
708
709        // For 4-channel (CMYK) profiles, pre-bake a 17^4 CLUT for fast image
710        // conversion. Two paths produce the same Clut4 layout:
711        //
712        // 1. `bake_clut4_perceptual` samples the profile's own A2B1
713        //    (colorimetric) table directly, decodes the legacy v2 PCS-Lab
714        //    encoding, and clips out-of-gamut colours to the sRGB boundary.
715        //    Output matches lcms2's `cmsDoTransform(RelCol)` to ±1 RGB level.
716        //    Available for v2 mft2 CMYK profiles. BPC is computed inside the
717        //    bake against this sampler's own (1,1,1,1) output so the source
718        //    black-point matches what we're actually producing.
719        // 2. `bake_clut4` invokes the 8-bit moxcms transform on a grid;
720        //    fallback for profiles whose tables are missing or in a shape we
721        //    don't yet handle (mAB, mft1, XYZ-PCS). BPC is calibrated against
722        //    moxcms's transform output (`detect_source_black_point`).
723        //
724        // The runtime CLUT lookup is identical regardless of which path
725        // produced the table.
726        //
727        // `bpc_params` is stored on `CachedTransform` for the moxcms-fallback
728        // path's `convert_color` / `convert_color_readonly` callers when the
729        // bake returned `None`. The hand-rolled sampler folds BPC in directly
730        // and leaves this `None`; the cached `transform_f64` Arc is only
731        // exercised in fallback contexts and shouldn't double-apply BPC.
732        let bpc_enabled = n == 4 && self.bpc_mode.is_enabled();
733        let mut bpc_params: Option<BpcParams> = None;
734        let clut4 = if n == 4 && !chain_active {
735            // Direct (non-proofing) CMYK profiles: pre-bake a CLUT for fast
736            // image conversion.
737            let c = perceptual::bake_clut4_perceptual(&profile, 17, bpc_enabled).or_else(|| {
738                let params = if bpc_enabled {
739                    detect_source_black_point(transform_8bit.as_ref())
740                        .map(|sbp| compute_bpc_params(sbp, [0.0; 3], bpc::WP_D50))
741                } else {
742                    None
743                };
744                let r = bake_clut4(transform_8bit.as_ref(), 17, params.as_ref());
745                bpc_params = params;
746                r
747            });
748            if std::env::var_os("STET_ICC_VERIFY").is_some()
749                && let Some(ref clut) = c
750            {
751                verify_clut4(clut, transform_8bit.as_ref(), bpc_params.as_ref());
752            }
753            c
754        } else {
755            // Chain-mode: do NOT pre-bake the source profile's CLUT. A
756            // pre-baked source CLUT would compose two CLUT4 quantizations
757            // (source-bake + stage 2's OutputIntent-bake) and drift a few
758            // sRGB levels relative to direct DeviceCMYK paints. Routing every
759            // call through the chain transform keeps just one CLUT
760            // quantization (OutputIntent's, in stage 2), so a designed-equal
761            // ICCBased CMYK swatch and DeviceCMYK background converge.
762            None
763        };
764
765        self.profiles.insert(hash, Arc::new(profile));
766        self.transforms.insert(
767            hash,
768            CachedTransform {
769                transform_8bit,
770                transform_f64,
771                chain_per_intent_8bit,
772                chain_per_intent_f64,
773                chain_stage1_per_intent,
774                n,
775                is_lab,
776                clut4,
777                bpc_params,
778            },
779        );
780
781        Some(hash)
782    }
783
784    /// Register a Gray profile with an identity Gray→RGB fallback transform.
785    /// Used when the ICC library can't create a proper transform from the profile
786    /// (e.g. minimal profiles with only a TRC and no A2B/B2A tables).
787    fn register_gray_identity(
788        &mut self,
789        hash: ProfileHash,
790        profile: ColorProfile,
791    ) -> Option<ProfileHash> {
792        self.profiles.insert(hash, Arc::new(profile));
793        self.transforms.insert(
794            hash,
795            CachedTransform {
796                transform_8bit: Arc::new(GrayToRgbIdentity),
797                transform_f64: Arc::new(GrayToRgbIdentity),
798                chain_per_intent_8bit: Default::default(),
799                chain_per_intent_f64: Default::default(),
800                chain_stage1_per_intent: Default::default(),
801                n: 1,
802                is_lab: false,
803                clut4: None,
804                bpc_params: None,
805            },
806        );
807        Some(hash)
808    }
809
810    /// Convert RGB components through the proofing chain's stage 1 to
811    /// the OutputIntent's CMYK ink values. Returns `None` when no
812    /// hand-rolled chain exists for this profile (the document isn't
813    /// PDF/X, or the profile shape is unsupported), in which case the
814    /// caller should leave `DeviceColor::native_cmyk` as `None` and the
815    /// renderer falls back to its sRGB-derived approximation. The
816    /// `intent` parameter selects which per-intent chain to use; falls
817    /// back to the Perceptual chain when the requested intent slot is
818    /// empty.
819    pub fn convert_to_oi_cmyk(
820        &self,
821        hash: &ProfileHash,
822        components: &[f64],
823        intent: RenderingIntent,
824    ) -> Option<[f64; 4]> {
825        let cached = self.transforms.get(hash)?;
826        if cached.n != 3 {
827            return None;
828        }
829        let stage1 = cached.chain_stage1_per_intent[intent as usize]
830            .as_ref()
831            .or(cached.chain_stage1_per_intent[RenderingIntent::Perceptual as usize].as_ref())?;
832        let r = components.first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
833        let g = components.get(1).copied().unwrap_or(0.0).clamp(0.0, 1.0);
834        let b = components.get(2).copied().unwrap_or(0.0).clamp(0.0, 1.0);
835        Some(stage1.sample_cmyk_f64(r, g, b))
836    }
837
838    /// Convert a single color through an ICC profile to sRGB.
839    /// Returns (r, g, b) in [0, 1] range.
840    pub fn convert_color(
841        &mut self,
842        hash: &ProfileHash,
843        components: &[f64],
844    ) -> Option<(f64, f64, f64)> {
845        let cached = self.transforms.get(hash)?;
846        let n = cached.n as usize;
847        let is_lab = cached.is_lab;
848
849        // Normalize input values to [0,1] range.
850        // Lab profiles need special mapping: L/100, (a+128)/255, (b+128)/255.
851        let mut src = vec![0.0f64; n];
852        for (i, s) in src.iter_mut().enumerate() {
853            let v = components.get(i).copied().unwrap_or(0.0);
854            *s = if is_lab {
855                match i {
856                    0 => (v / 100.0).clamp(0.0, 1.0),
857                    _ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
858                }
859            } else {
860                v.clamp(0.0, 1.0)
861            };
862        }
863
864        // Quantize normalized values for cache key
865        let hash_prefix = u64::from_le_bytes(hash[..8].try_into().ok()?);
866        let mut quantized = [0u16; 4];
867        for (i, &c) in src.iter().take(4).enumerate() {
868            quantized[i] = (c * 65535.0).round() as u16;
869        }
870
871        // Check cache
872        let cache_key = (hash_prefix, quantized);
873        if let Some(&cached) = self.color_cache.get(&cache_key) {
874            return Some(cached);
875        }
876
877        let result = if n == 4
878            && let Some(clut) = cached.clut4.as_ref()
879        {
880            // Route single-color CMYK through the same baked CLUT image
881            // conversions use, so a flat fill matches the surrounding gradient
882            // stops byte-for-byte. BPC and the perceptual A2B0 sampling are
883            // already folded into the CLUT.
884            let (r, g, b) = sample_clut4_single_f64(clut, src[0], src[1], src[2], src[3]);
885            (r, g, b)
886        } else {
887            let mut dst = [0.0f64; 3];
888            if cached.transform_f64.transform(&src, &mut dst).is_err() {
889                return None;
890            }
891            let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
892            (
893                dst[0].clamp(0.0, 1.0),
894                dst[1].clamp(0.0, 1.0),
895                dst[2].clamp(0.0, 1.0),
896            )
897        };
898
899        // Cache (limit size to avoid unbounded growth)
900        if self.color_cache.len() < 65536 {
901            self.color_cache.insert(cache_key, result);
902        }
903
904        Some(result)
905    }
906
907    /// Cached single-color conversion under a specific rendering
908    /// intent. Delegates to the legacy [`Self::convert_color`] for
909    /// Perceptual (which uses the Perceptual chain stored in
910    /// `transform_f64` and the Perceptual-keyed cache); for the other
911    /// intents it goes through [`Self::convert_color_readonly_with_intent`]
912    /// and caches per-intent.
913    pub fn convert_color_with_intent(
914        &mut self,
915        hash: &ProfileHash,
916        components: &[f64],
917        intent: RenderingIntent,
918    ) -> Option<(f64, f64, f64)> {
919        if matches!(intent, RenderingIntent::Perceptual) {
920            return self.convert_color(hash, components);
921        }
922        // Quantize for cache key. Have to recompute here because
923        // `convert_color_readonly_with_intent` doesn't return the
924        // quantized buffer. The arithmetic mirrors `convert_color`.
925        let cached = self.transforms.get(hash)?;
926        let n = cached.n as usize;
927        let is_lab = cached.is_lab;
928        let mut src = vec![0.0f64; n];
929        for (i, s) in src.iter_mut().enumerate() {
930            let v = components.get(i).copied().unwrap_or(0.0);
931            *s = if is_lab {
932                match i {
933                    0 => (v / 100.0).clamp(0.0, 1.0),
934                    _ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
935                }
936            } else {
937                v.clamp(0.0, 1.0)
938            };
939        }
940        let hash_prefix = u64::from_le_bytes(hash[..8].try_into().ok()?);
941        let mut quantized = [0u16; 4];
942        for (i, &c) in src.iter().take(4).enumerate() {
943            quantized[i] = (c * 65535.0).round() as u16;
944        }
945        let cache_key = (hash_prefix, quantized, intent as u8);
946        if let Some(&hit) = self.color_cache_intent.get(&cache_key) {
947            return Some(hit);
948        }
949        let result = self.convert_color_readonly_with_intent(hash, components, intent)?;
950        if self.color_cache_intent.len() < 65536 {
951            self.color_cache_intent.insert(cache_key, result);
952        }
953        Some(result)
954    }
955
956    /// Convert a single color through an ICC profile using a specific
957    /// rendering intent. Falls back to the cached default chain (built
958    /// from the Perceptual tables) when no per-intent chain is
959    /// available — that path matches [`Self::convert_color_readonly`]
960    /// byte-for-byte and is the common case for non-PDF/X documents and
961    /// CMYK source profiles.
962    pub fn convert_color_readonly_with_intent(
963        &self,
964        hash: &ProfileHash,
965        components: &[f64],
966        intent: RenderingIntent,
967    ) -> Option<(f64, f64, f64)> {
968        let cached = self.transforms.get(hash)?;
969        let n = cached.n as usize;
970        let is_lab = cached.is_lab;
971
972        let mut src = vec![0.0f64; n];
973        for (i, s) in src.iter_mut().enumerate() {
974            let v = components.get(i).copied().unwrap_or(0.0);
975            *s = if is_lab {
976                match i {
977                    0 => (v / 100.0).clamp(0.0, 1.0),
978                    _ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
979                }
980            } else {
981                v.clamp(0.0, 1.0)
982            };
983        }
984
985        // CMYK profiles: route through the pre-baked CLUT4 (no per-intent
986        // variant exists on this path; intent-driven CMYK B2A selection
987        // is deferred until the chain-side per-intent OI bake).
988        if n == 4
989            && let Some(clut) = cached.clut4.as_ref()
990        {
991            let (r, g, b) = sample_clut4_single_f64(clut, src[0], src[1], src[2], src[3]);
992            return Some((r, g, b));
993        }
994
995        let transform = cached.chain_per_intent_f64[intent as usize]
996            .as_ref()
997            .unwrap_or(&cached.transform_f64);
998        let mut dst = [0.0f64; 3];
999        if transform.transform(&src, &mut dst).is_err() {
1000            return None;
1001        }
1002
1003        let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
1004        Some((
1005            dst[0].clamp(0.0, 1.0),
1006            dst[1].clamp(0.0, 1.0),
1007            dst[2].clamp(0.0, 1.0),
1008        ))
1009    }
1010
1011    /// Convert a single color through an ICC profile (read-only, no caching).
1012    ///
1013    /// Same as `convert_color` but takes `&self` instead of `&mut self`,
1014    /// suitable for use from immutable contexts like rendering.
1015    pub fn convert_color_readonly(
1016        &self,
1017        hash: &ProfileHash,
1018        components: &[f64],
1019    ) -> Option<(f64, f64, f64)> {
1020        let cached = self.transforms.get(hash)?;
1021        let n = cached.n as usize;
1022        let is_lab = cached.is_lab;
1023
1024        let mut src = vec![0.0f64; n];
1025        for (i, s) in src.iter_mut().enumerate() {
1026            let v = components.get(i).copied().unwrap_or(0.0);
1027            *s = if is_lab {
1028                match i {
1029                    0 => (v / 100.0).clamp(0.0, 1.0),
1030                    _ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
1031                }
1032            } else {
1033                v.clamp(0.0, 1.0)
1034            };
1035        }
1036
1037        if n == 4
1038            && let Some(clut) = cached.clut4.as_ref()
1039        {
1040            let (r, g, b) = sample_clut4_single_f64(clut, src[0], src[1], src[2], src[3]);
1041            return Some((r, g, b));
1042        }
1043
1044        let mut dst = [0.0f64; 3];
1045        if cached.transform_f64.transform(&src, &mut dst).is_err() {
1046            return None;
1047        }
1048
1049        let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
1050        Some((
1051            dst[0].clamp(0.0, 1.0),
1052            dst[1].clamp(0.0, 1.0),
1053            dst[2].clamp(0.0, 1.0),
1054        ))
1055    }
1056
1057    /// Bulk-convert 8-bit image samples through an ICC profile to RGB
1058    /// using a specific rendering intent. Falls back to the cached
1059    /// default 8-bit transform (built from the Perceptual tables) when
1060    /// no per-intent chain is available — that path matches
1061    /// [`Self::convert_image_8bit`] byte-for-byte.
1062    pub fn convert_image_8bit_with_intent(
1063        &self,
1064        hash: &ProfileHash,
1065        samples: &[u8],
1066        pixel_count: usize,
1067        intent: RenderingIntent,
1068    ) -> Option<Vec<u8>> {
1069        let cached = self.transforms.get(hash)?;
1070        let n = cached.n as usize;
1071        let expected_len = pixel_count * n;
1072        if samples.len() < expected_len {
1073            return None;
1074        }
1075
1076        // CMYK profiles route through the pre-baked CLUT4. Per-intent
1077        // CMYK B2A selection on this side of the chain is deferred —
1078        // step 4 will revisit when BPC + AbsCol land.
1079        if let Some(clut) = &cached.clut4 {
1080            return Some(apply_clut4_cmyk_to_rgb(
1081                clut,
1082                &samples[..expected_len],
1083                pixel_count,
1084            ));
1085        }
1086
1087        let transform = cached.chain_per_intent_8bit[intent as usize]
1088            .as_ref()
1089            .unwrap_or(&cached.transform_8bit);
1090        let src = &samples[..expected_len];
1091        let mut dst = vec![0u8; pixel_count * 3];
1092        match transform.transform(src, &mut dst) {
1093            Ok(()) => {
1094                if let Some(p) = cached.bpc_params.as_ref() {
1095                    for px in dst.chunks_exact_mut(3) {
1096                        let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
1097                        px[0] = out[0];
1098                        px[1] = out[1];
1099                        px[2] = out[2];
1100                    }
1101                }
1102                Some(dst)
1103            }
1104            Err(e) => {
1105                eprintln!("[ICC] Image transform failed (intent {intent:?}): {e}");
1106                None
1107            }
1108        }
1109    }
1110
1111    /// Bulk-convert 8-bit image samples through an ICC profile to RGB.
1112    /// Input: packed samples (Gray/RGB/CMYK depending on profile).
1113    /// Output: packed RGB bytes (3 bytes per pixel).
1114    pub fn convert_image_8bit(
1115        &self,
1116        hash: &ProfileHash,
1117        samples: &[u8],
1118        pixel_count: usize,
1119    ) -> Option<Vec<u8>> {
1120        let cached = self.transforms.get(hash)?;
1121        let n = cached.n as usize;
1122        let expected_len = pixel_count * n;
1123        if samples.len() < expected_len {
1124            return None;
1125        }
1126
1127        // Fast path: pre-baked 4D CLUT for CMYK profiles. BPC is already
1128        // baked into the CLUT (when enabled), so no per-pixel correction
1129        // is needed here.
1130        if let Some(clut) = &cached.clut4 {
1131            return Some(apply_clut4_cmyk_to_rgb(
1132                clut,
1133                &samples[..expected_len],
1134                pixel_count,
1135            ));
1136        }
1137
1138        let src = &samples[..expected_len];
1139        let mut dst = vec![0u8; pixel_count * 3];
1140
1141        match cached.transform_8bit.transform(src, &mut dst) {
1142            Ok(()) => {
1143                // Apply BPC per pixel for non-CLUT bulk paths (CMYK profiles
1144                // whose CLUT bake failed; today no other layouts populate
1145                // bpc_params, so this is a no-op for RGB/Gray/Lab).
1146                if let Some(p) = cached.bpc_params.as_ref() {
1147                    for px in dst.chunks_exact_mut(3) {
1148                        let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
1149                        px[0] = out[0];
1150                        px[1] = out[1];
1151                        px[2] = out[2];
1152                    }
1153                }
1154                Some(dst)
1155            }
1156            Err(e) => {
1157                eprintln!("[ICC] Image transform failed: {e}");
1158                None
1159            }
1160        }
1161    }
1162
1163    /// Search system paths for a CMYK ICC profile and register it.
1164    pub fn search_system_cmyk_profile(&mut self) {
1165        if let Some(bytes) = find_system_cmyk_profile()
1166            && let Some(hash) = self.register_profile(&bytes)
1167        {
1168            eprintln!("[ICC] Loaded system CMYK profile");
1169            self.system_cmyk_bytes = Some(Arc::new(bytes));
1170            self.default_cmyk_hash = Some(hash);
1171        }
1172    }
1173
1174    /// Load a CMYK ICC profile from raw bytes (for environments without filesystem access).
1175    pub fn load_cmyk_profile_bytes(&mut self, bytes: &[u8]) {
1176        if let Some(hash) = self.register_profile(bytes) {
1177            self.system_cmyk_bytes = Some(Arc::new(bytes.to_vec()));
1178            self.default_cmyk_hash = Some(hash);
1179        }
1180    }
1181
1182    /// Get the default CMYK profile hash, if a system CMYK profile was found.
1183    pub fn default_cmyk_hash(&self) -> Option<&ProfileHash> {
1184        self.default_cmyk_hash.as_ref()
1185    }
1186
1187    /// Check if a profile hash has been registered.
1188    pub fn has_profile(&self, hash: &ProfileHash) -> bool {
1189        self.transforms.contains_key(hash)
1190    }
1191
1192    /// Get the raw bytes of a registered ICC profile (for PDF embedding).
1193    pub fn get_profile_bytes(&self, hash: &ProfileHash) -> Option<Arc<Vec<u8>>> {
1194        self.raw_bytes.get(hash).cloned()
1195    }
1196
1197    /// Get the raw bytes of the system CMYK profile (for re-registration in render threads).
1198    pub fn system_cmyk_bytes(&self) -> Option<&Arc<Vec<u8>>> {
1199        self.system_cmyk_bytes.as_ref()
1200    }
1201
1202    /// Set the system CMYK profile from pre-loaded bytes and hash.
1203    ///
1204    /// Used by `--output-profile` to substitute the auto-detected system CMYK
1205    /// profile with a user-specified one.
1206    pub fn set_system_cmyk(&mut self, bytes: &[u8], hash: ProfileHash) {
1207        self.system_cmyk_bytes = Some(Arc::new(bytes.to_vec()));
1208        self.default_cmyk_hash = Some(hash);
1209    }
1210
1211    /// Enable PDF/X-style proofing: ICCBased profiles registered while this
1212    /// flag is set route through the default CMYK profile (the OutputIntent)
1213    /// before reaching sRGB. See [`Self::proofing_enabled`].
1214    pub fn set_proofing_enabled(&mut self, enabled: bool) {
1215        self.proofing_enabled = enabled;
1216    }
1217
1218    /// Whether PDF/X-style proofing is enabled. When `true`, source ICC
1219    /// profiles other than the default CMYK convert through the default
1220    /// CMYK ("OutputIntent") instead of going directly to sRGB. This makes
1221    /// per-swatch ICC colours and surrounding DeviceCMYK paints converge
1222    /// through the same final `OutputIntent → sRGB` stage. When `false`,
1223    /// every profile converts directly (the historical behaviour).
1224    pub fn proofing_enabled(&self) -> bool {
1225        self.proofing_enabled
1226    }
1227
1228    /// Set the default CMYK profile hash (used when building render-thread caches).
1229    pub fn set_default_cmyk_hash(&mut self, hash: ProfileHash) {
1230        self.default_cmyk_hash = Some(hash);
1231    }
1232
1233    /// Temporarily remove the default CMYK hash, returning the old value.
1234    /// Used to disable ICC CMYK conversion inside soft mask form rendering,
1235    /// where PLRM formulas produce correct luminosity values (ICC profiles
1236    /// map 100% K to non-zero RGB, breaking luminosity soft masks).
1237    pub fn suspend_default_cmyk(&mut self) -> Option<ProfileHash> {
1238        self.default_cmyk_hash.take()
1239    }
1240
1241    /// Restore a previously suspended default CMYK hash.
1242    pub fn restore_default_cmyk(&mut self, hash: Option<ProfileHash>) {
1243        self.default_cmyk_hash = hash;
1244    }
1245
1246    /// Convert CMYK to (r, g, b) using the default system CMYK profile.
1247    /// Returns None if no system CMYK profile is loaded.
1248    #[inline]
1249    pub fn convert_cmyk(&mut self, c: f64, m: f64, y: f64, k: f64) -> Option<(f64, f64, f64)> {
1250        let hash = *self.default_cmyk_hash.as_ref()?;
1251        self.convert_color(&hash, &[c, m, y, k])
1252    }
1253
1254    /// Convert CMYK to (r, g, b) using the default system CMYK profile (read-only, no caching).
1255    /// Used by band renderers that only have `&self` access.
1256    pub fn convert_cmyk_readonly(&self, c: f64, m: f64, y: f64, k: f64) -> Option<(f64, f64, f64)> {
1257        let hash = self.default_cmyk_hash.as_ref()?;
1258        let cached = self.transforms.get(hash)?;
1259        let src = [
1260            c.clamp(0.0, 1.0),
1261            m.clamp(0.0, 1.0),
1262            y.clamp(0.0, 1.0),
1263            k.clamp(0.0, 1.0),
1264        ];
1265        if let Some(clut) = cached.clut4.as_ref() {
1266            // Sample the same baked CLUT image conversions use, so a flat fill
1267            // matches the surrounding gradient stops byte-for-byte. BPC and the
1268            // perceptual A2B0 sampling are already folded into the CLUT.
1269            let (r, g, b) = sample_clut4_single_f64(clut, src[0], src[1], src[2], src[3]);
1270            return Some((r, g, b));
1271        }
1272        let mut dst = [0.0f64; 3];
1273        if cached.transform_f64.transform(&src, &mut dst).is_err() {
1274            return None;
1275        }
1276        let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
1277        Some((
1278            dst[0].clamp(0.0, 1.0),
1279            dst[1].clamp(0.0, 1.0),
1280            dst[2].clamp(0.0, 1.0),
1281        ))
1282    }
1283
1284    /// Build the lazy sRGB→CMYK reverse transform from the system CMYK profile.
1285    /// Returns `Some(())` if the transform is now present (built or already
1286    /// cached). Returns `None` if no system CMYK profile is registered or no
1287    /// rendering intent could create a transform.
1288    fn ensure_reverse_cmyk_transform(&mut self) -> Option<()> {
1289        if self.reverse_cmyk_f64.is_some() {
1290            return Some(());
1291        }
1292        let hash = *self.default_cmyk_hash.as_ref()?;
1293        let cmyk_profile = self.profiles.get(&hash)?.clone();
1294        let intents = [
1295            RenderingIntent::RelativeColorimetric,
1296            RenderingIntent::Perceptual,
1297            RenderingIntent::AbsoluteColorimetric,
1298            RenderingIntent::Saturation,
1299        ];
1300        for &intent in &intents {
1301            let options = TransformOptions {
1302                rendering_intent: intent,
1303                ..TransformOptions::default()
1304            };
1305            if let Ok(t) = self.srgb_profile.create_transform_f64(
1306                Layout::Rgb,
1307                &cmyk_profile,
1308                Layout::Rgba,
1309                options,
1310            ) {
1311                self.reverse_cmyk_f64 = Some(t);
1312                return Some(());
1313            }
1314        }
1315        None
1316    }
1317
1318    /// Pre-warm the lazy sRGB→CMYK reverse transform. Should be called once on
1319    /// the build thread that owns `&mut IccCache`, after the system CMYK
1320    /// profile has been registered, so that band renderers (which only hold
1321    /// `&IccCache`) can use [`Self::convert_rgb_to_cmyk_readonly`] without
1322    /// having to mutate state.
1323    pub fn prepare_reverse_cmyk(&mut self) {
1324        let _ = self.ensure_reverse_cmyk_transform();
1325    }
1326
1327    /// Pre-build per-intent `Lab → OutputIntent CMYK` samplers from the
1328    /// document's OutputIntent profile. Call once after the OI is registered
1329    /// so [`Self::convert_lab_to_oi_cmyk`] can run from `&IccCache`.
1330    /// No-op when no default CMYK profile is registered, when the profile
1331    /// shape doesn't expose B2A LUTs (shaper-matrix CMYK with no LUT, etc.),
1332    /// or when proofing is disabled.
1333    pub fn prepare_lab_to_oi_cmyk(&mut self) {
1334        let Some(hash) = self.default_cmyk_hash else {
1335            return;
1336        };
1337        let Some(profile) = self.profiles.get(&hash).cloned() else {
1338            return;
1339        };
1340        use moxcms::RenderingIntent;
1341        for &intent in &[
1342            RenderingIntent::Perceptual,
1343            RenderingIntent::RelativeColorimetric,
1344            RenderingIntent::Saturation,
1345            RenderingIntent::AbsoluteColorimetric,
1346        ] {
1347            let i = intent as usize;
1348            if self.lab_to_oi_per_intent[i].is_some() {
1349                continue;
1350            }
1351            if let Some(sampler) = perceptual::LabToCmykSampler::build(&profile, intent) {
1352                self.lab_to_oi_per_intent[i] = Some(Arc::new(sampler));
1353            }
1354        }
1355    }
1356
1357    /// Convert a PDF Lab triplet (L\* ∈ [0, 100], a\*/b\* ∈ [-128, 127]) to
1358    /// OutputIntent CMYK using the OI's per-intent B2A table. Returns `None`
1359    /// when the per-intent sampler hasn't been built (call
1360    /// [`Self::prepare_lab_to_oi_cmyk`] first) or when the intent slot is
1361    /// empty (falls back to Perceptual when available).
1362    ///
1363    /// Used by `lab_to_device_color` to populate `DeviceColor::native_cmyk`
1364    /// so the renderer's parallel CMYK buffer holds the same direct
1365    /// `Lab → OI CMYK` value Acrobat's ACE produces. Without this, the
1366    /// renderer falls back to `convert_rgb_to_cmyk_readonly` (sRGB → ICC
1367    /// reverse), which drifts visibly under CMYK-group blends. GWG 22.1's
1368    /// ColorBurn form over a Lab BG is the canonical surfacing case.
1369    pub fn convert_lab_to_oi_cmyk(
1370        &self,
1371        l_star: f64,
1372        a_star: f64,
1373        b_star: f64,
1374        intent: IccRenderingIntent,
1375    ) -> Option<[f64; 4]> {
1376        let sampler = self.lab_to_oi_per_intent[intent as usize]
1377            .as_ref()
1378            .or(self.lab_to_oi_per_intent[IccRenderingIntent::Perceptual as usize].as_ref())?;
1379        Some(sampler.sample_pdf_lab(l_star, a_star, b_star))
1380    }
1381
1382    /// Convert an sRGB color to CMYK using the system CMYK profile, without
1383    /// mutating any state. Returns `None` when the reverse transform has not
1384    /// been pre-built (call [`Self::prepare_reverse_cmyk`] first) or when no
1385    /// system CMYK profile is registered.
1386    ///
1387    /// The returned components are clamped to `[0, 1]`.
1388    pub fn convert_rgb_to_cmyk_readonly(&self, r: f64, g: f64, b: f64) -> Option<[f64; 4]> {
1389        let reverse = self.reverse_cmyk_f64.as_ref()?;
1390        let src_rgb = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
1391        let mut cmyk = [0.0f64; 4];
1392        reverse.transform(&src_rgb, &mut cmyk).ok()?;
1393        Some([
1394            cmyk[0].clamp(0.0, 1.0),
1395            cmyk[1].clamp(0.0, 1.0),
1396            cmyk[2].clamp(0.0, 1.0),
1397            cmyk[3].clamp(0.0, 1.0),
1398        ])
1399    }
1400
1401    /// Round-trip an RGB color through the system CMYK profile: sRGB→CMYK→sRGB.
1402    /// Used when compositing in a DeviceCMYK page group — saturated RGB colors
1403    /// become more muted after passing through the CMYK gamut.
1404    /// Returns None if no CMYK profile is loaded.
1405    pub fn round_trip_rgb_via_cmyk(&mut self, r: f64, g: f64, b: f64) -> Option<(f64, f64, f64)> {
1406        self.ensure_reverse_cmyk_transform()?;
1407        let hash = *self.default_cmyk_hash.as_ref()?;
1408        let reverse = self.reverse_cmyk_f64.as_ref()?;
1409
1410        // sRGB → CMYK
1411        let src_rgb = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
1412        let mut cmyk = [0.0f64; 4];
1413        reverse.transform(&src_rgb, &mut cmyk).ok()?;
1414
1415        // CMYK → sRGB (via existing forward transform)
1416        let forward = self.transforms.get(&hash)?;
1417        let mut dst = [0.0f64; 3];
1418        forward.transform_f64.transform(&cmyk, &mut dst).ok()?;
1419
1420        let dst = bpc_post_correct(dst, forward.bpc_params.as_ref());
1421        Some((
1422            dst[0].clamp(0.0, 1.0),
1423            dst[1].clamp(0.0, 1.0),
1424            dst[2].clamp(0.0, 1.0),
1425        ))
1426    }
1427
1428    /// Disable all ICC color management — clears all profiles, transforms,
1429    /// and caches. Equivalent to the CLI's `--no-icc` flag.
1430    pub fn disable(&mut self) {
1431        self.profiles.clear();
1432        self.transforms.clear();
1433        self.color_cache.clear();
1434        self.raw_bytes.clear();
1435        self.default_cmyk_hash = None;
1436        self.system_cmyk_bytes = None;
1437        self.reverse_cmyk_f64 = None;
1438    }
1439}
1440
1441/// Bake a 4D CLUT by sampling an 8-bit CMYK→sRGB transform on a regular grid.
1442///
1443/// Generates `grid_n^4` CMYK sample points (each channel stepping `0..=255` in
1444/// `grid_n` steps), invokes moxcms once on the full batch, and stores the
1445/// packed sRGB output. Storage order is K outermost, then Y, M, C innermost,
1446/// matching the interpolation access pattern in `apply_clut4_cmyk_to_rgb`.
1447///
1448/// Returns `None` if the transform invocation fails — callers fall back to
1449/// direct moxcms calls per image.
1450fn bake_clut4(
1451    transform: &(dyn TransformExecutor<u8> + Send + Sync),
1452    grid_n: u8,
1453    bpc_params: Option<&BpcParams>,
1454) -> Option<Clut4> {
1455    let n = grid_n as usize;
1456    if !(2..=33).contains(&n) {
1457        return None;
1458    }
1459    let total = n * n * n * n;
1460    // Sample grid: for each (k, y, m, c) grid index, emit bytes (c, m, y, k).
1461    // moxcms consumes this as packed 4-channel input.
1462    let mut src = Vec::with_capacity(total * 4);
1463    let step = |i: usize| -> u8 {
1464        // Spread grid indices evenly across 0..=255 (endpoints inclusive).
1465        ((i as u32 * 255) / (n as u32 - 1)) as u8
1466    };
1467    for k in 0..n {
1468        let kv = step(k);
1469        for y in 0..n {
1470            let yv = step(y);
1471            for m in 0..n {
1472                let mv = step(m);
1473                for c in 0..n {
1474                    let cv = step(c);
1475                    src.extend_from_slice(&[cv, mv, yv, kv]);
1476                }
1477            }
1478        }
1479    }
1480    let mut dst = vec![0u8; total * 3];
1481    transform.transform(&src, &mut dst).ok()?;
1482
1483    // Bake BPC into every grid point so runtime CLUT lookup stays at zero
1484    // per-pixel cost.
1485    if let Some(p) = bpc_params {
1486        for px in dst.chunks_exact_mut(3) {
1487            let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
1488            px[0] = out[0];
1489            px[1] = out[1];
1490            px[2] = out[2];
1491        }
1492    }
1493
1494    Some(Clut4 {
1495        grid_n,
1496        data: Arc::new(dst),
1497    })
1498}
1499
1500/// Single-pixel CMYK→sRGB lookup against the baked CLUT, with f64 inputs and
1501/// outputs. Performs the same K-bracket × 3D tetrahedral interpolation as the
1502/// bulk image path, but in floating point so a flat fill (which would otherwise
1503/// quantize the input to u8) matches a gradient stop's byte output to within
1504/// the CLUT's grid-interpolation error.
1505fn sample_clut4_single_f64(clut: &Clut4, c: f64, m: f64, y: f64, k: f64) -> (f64, f64, f64) {
1506    let n = clut.grid_n as usize;
1507    let nm1 = (n - 1) as f64;
1508    let lut = clut.data.as_slice();
1509    let stride_c: usize = 3;
1510    let stride_m: usize = n * stride_c;
1511    let stride_y: usize = n * stride_m;
1512    let stride_k: usize = n * stride_y;
1513
1514    #[inline]
1515    fn axis(v: f64, nm1: f64, n: usize) -> (usize, usize, f64) {
1516        let scaled = v.clamp(0.0, 1.0) * nm1;
1517        let lo = scaled.floor();
1518        let frac = scaled - lo;
1519        let lo_i = lo as usize;
1520        let hi_i = (lo_i + 1).min(n - 1);
1521        (lo_i, hi_i, frac)
1522    }
1523
1524    let (ci, ci1, fc) = axis(c, nm1, n);
1525    let (mi, mi1, fm) = axis(m, nm1, n);
1526    let (yi, yi1, fy) = axis(y, nm1, n);
1527    let (ki, ki1, fk) = axis(k, nm1, n);
1528
1529    // Pick tetrahedron vertices (Kasson '94) — same logic as the u8 path.
1530    let (a_dxmy, b_dxmy, w1, w2, w3) = if fc >= fm {
1531        if fm >= fy {
1532            ((1, 0, 0), (1, 1, 0), fc, fm, fy)
1533        } else if fc >= fy {
1534            ((1, 0, 0), (1, 0, 1), fc, fy, fm)
1535        } else {
1536            ((0, 0, 1), (1, 0, 1), fy, fc, fm)
1537        }
1538    } else if fc >= fy {
1539        ((0, 1, 0), (1, 1, 0), fm, fc, fy)
1540    } else if fm >= fy {
1541        ((0, 1, 0), (0, 1, 1), fm, fy, fc)
1542    } else {
1543        ((0, 0, 1), (0, 1, 1), fy, fm, fc)
1544    };
1545
1546    let corner = |d: (u8, u8, u8)| -> usize {
1547        let (dc, dm, dy) = d;
1548        let cx = if dc == 0 { ci } else { ci1 };
1549        let mx = if dm == 0 { mi } else { mi1 };
1550        let yx = if dy == 0 { yi } else { yi1 };
1551        yx * stride_y + mx * stride_m + cx * stride_c
1552    };
1553
1554    let o000 = corner((0, 0, 0));
1555    let o111 = corner((1, 1, 1));
1556    let oa = corner(a_dxmy);
1557    let ob = corner(b_dxmy);
1558
1559    let base_lo = ki * stride_k;
1560    let base_hi = ki1 * stride_k;
1561
1562    let tetra_channel = |base: usize, ch: usize| -> f64 {
1563        let v000 = lut[base + o000 + ch] as f64;
1564        let va = lut[base + oa + ch] as f64;
1565        let vb = lut[base + ob + ch] as f64;
1566        let v111 = lut[base + o111 + ch] as f64;
1567        v000 + (va - v000) * w1 + (vb - va) * w2 + (v111 - vb) * w3
1568    };
1569
1570    let r_lo = tetra_channel(base_lo, 0);
1571    let g_lo = tetra_channel(base_lo, 1);
1572    let b_lo = tetra_channel(base_lo, 2);
1573    let (r_hi, g_hi, b_hi) = if ki == ki1 {
1574        (r_lo, g_lo, b_lo)
1575    } else {
1576        (
1577            tetra_channel(base_hi, 0),
1578            tetra_channel(base_hi, 1),
1579            tetra_channel(base_hi, 2),
1580        )
1581    };
1582
1583    let inv_fk = 1.0 - fk;
1584    let r = (r_lo * inv_fk + r_hi * fk) / 255.0;
1585    let g = (g_lo * inv_fk + g_hi * fk) / 255.0;
1586    let b = (b_lo * inv_fk + b_hi * fk) / 255.0;
1587    (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
1588}
1589
1590/// Convert an 8-bit packed CMYK buffer to 8-bit packed sRGB using the baked
1591/// 4D CLUT. For each pixel: bracket the K axis into two slices, run 3D
1592/// tetrahedral (Kasson) interpolation on (C,M,Y) in each slice, then linearly
1593/// blend the two results by the K fraction.
1594///
1595/// This preserves the profile's behavior across the K axis (UCR/black-point
1596/// transitions) while giving image-rate throughput.
1597fn apply_clut4_cmyk_to_rgb(clut: &Clut4, src: &[u8], pixel_count: usize) -> Vec<u8> {
1598    let n = clut.grid_n as usize;
1599    let nm1 = (n - 1) as u32;
1600    let lut = clut.data.as_slice();
1601
1602    // Strides in bytes within the flat LUT (K outermost, then Y, M; C innermost).
1603    let stride_c: usize = 3;
1604    let stride_m: usize = n * stride_c;
1605    let stride_y: usize = n * stride_m;
1606    let stride_k: usize = n * stride_y;
1607
1608    let mut out = vec![0u8; pixel_count * 3];
1609
1610    // Per-axis: quantize byte → (lo_idx, hi_idx, frac_in_0_255).
1611    #[inline(always)]
1612    fn axis(v: u8, nm1: u32) -> (usize, usize, u32) {
1613        let scaled = v as u32 * nm1;
1614        let lo = scaled / 255;
1615        let frac = scaled - lo * 255;
1616        let hi = if lo < nm1 { lo + 1 } else { lo };
1617        (lo as usize, hi as usize, frac)
1618    }
1619
1620    for i in 0..pixel_count {
1621        let o = i * 4;
1622        let c = src[o];
1623        let m = src[o + 1];
1624        let y = src[o + 2];
1625        let k = src[o + 3];
1626
1627        let (ci, ci1, fc) = axis(c, nm1);
1628        let (mi, mi1, fm) = axis(m, nm1);
1629        let (yi, yi1, fy) = axis(y, nm1);
1630        let (ki, ki1, fk) = axis(k, nm1);
1631
1632        // Pick tetrahedron vertices and sorted weights ONCE per pixel
1633        // (previously done per channel — 3× waste). Kasson '94:
1634        //   out = V000 + (Va - V000)*w1 + (Vb - Va)*w2 + (V111 - Vb)*w3
1635        // with w1 >= w2 >= w3 and Va, Vb the two intermediate corners.
1636        let (a_dxmy, b_dxmy, w1, w2, w3) = if fc >= fm {
1637            if fm >= fy {
1638                // C,M,Y
1639                ((1, 0, 0), (1, 1, 0), fc, fm, fy)
1640            } else if fc >= fy {
1641                // C,Y,M
1642                ((1, 0, 0), (1, 0, 1), fc, fy, fm)
1643            } else {
1644                // Y,C,M
1645                ((0, 0, 1), (1, 0, 1), fy, fc, fm)
1646            }
1647        } else if fc >= fy {
1648            // M,C,Y
1649            ((0, 1, 0), (1, 1, 0), fm, fc, fy)
1650        } else if fm >= fy {
1651            // M,Y,C
1652            ((0, 1, 0), (0, 1, 1), fm, fy, fc)
1653        } else {
1654            // Y,M,C
1655            ((0, 0, 1), (0, 1, 1), fy, fm, fc)
1656        };
1657
1658        // Map tetrahedron corner selector (dc, dm, dy) → LUT offset within a K slice.
1659        let corner = |d: (u8, u8, u8)| -> usize {
1660            let (dc, dm, dy) = d;
1661            let cx = if dc == 0 { ci } else { ci1 };
1662            let mx = if dm == 0 { mi } else { mi1 };
1663            let yx = if dy == 0 { yi } else { yi1 };
1664            yx * stride_y + mx * stride_m + cx * stride_c
1665        };
1666
1667        let o000 = corner((0, 0, 0));
1668        let o111 = corner((1, 1, 1));
1669        let oa = corner(a_dxmy);
1670        let ob = corner(b_dxmy);
1671
1672        // Two K slices, 3 channels. Compute inline (no closures, no per-channel branching).
1673        let base_lo = ki * stride_k;
1674        let base_hi = ki1 * stride_k;
1675
1676        // Per-channel tetrahedral formula in integer:
1677        //   accum = v000*255 + (va - v000)*w1 + (vb - va)*w2 + (v111 - vb)*w3
1678        // accum is in units of (value * 255), in range [0, 255*255].
1679        let tetra_channel = |base: usize, ch: usize| -> i32 {
1680            let v000 = lut[base + o000 + ch] as i32;
1681            let va = lut[base + oa + ch] as i32;
1682            let vb = lut[base + ob + ch] as i32;
1683            let v111 = lut[base + o111 + ch] as i32;
1684            v000 * 255 + (va - v000) * w1 as i32 + (vb - va) * w2 as i32 + (v111 - vb) * w3 as i32
1685        };
1686
1687        let r_lo = tetra_channel(base_lo, 0);
1688        let g_lo = tetra_channel(base_lo, 1);
1689        let b_lo = tetra_channel(base_lo, 2);
1690        let (r_hi, g_hi, b_hi) = if ki == ki1 {
1691            (r_lo, g_lo, b_lo)
1692        } else {
1693            (
1694                tetra_channel(base_hi, 0),
1695                tetra_channel(base_hi, 1),
1696                tetra_channel(base_hi, 2),
1697            )
1698        };
1699
1700        // Linear blend across K slices and rescale to u8.
1701        let inv_fk = (255 - fk) as i32;
1702        let fk_i = fk as i32;
1703        let round = 255 * 255 / 2;
1704        let finish = |lo: i32, hi: i32| -> u8 {
1705            let combined = lo * inv_fk + hi * fk_i + round;
1706            let v = combined / (255 * 255);
1707            v.clamp(0, 255) as u8
1708        };
1709
1710        let di = i * 3;
1711        out[di] = finish(r_lo, r_hi);
1712        out[di + 1] = finish(g_lo, g_hi);
1713        out[di + 2] = finish(b_lo, b_hi);
1714    }
1715
1716    out
1717}
1718
1719/// Validate a baked CLUT against the direct moxcms transform over a
1720/// pseudorandom sample of CMYK inputs. Reports median and max per-channel
1721/// deviation (in u8 units) to stderr. Invoked only when `STET_ICC_VERIFY` is
1722/// set in the environment.
1723fn verify_clut4(
1724    clut: &Clut4,
1725    transform: &(dyn TransformExecutor<u8> + Send + Sync),
1726    bpc_params: Option<&BpcParams>,
1727) {
1728    const N_SAMPLES: usize = 4096;
1729    let mut rng: u64 = 0xa8b3c4d5e6f70819;
1730    let mut next = || {
1731        rng = rng
1732            .wrapping_mul(6364136223846793005)
1733            .wrapping_add(1442695040888963407);
1734        rng
1735    };
1736    let mut cmyk = Vec::with_capacity(N_SAMPLES * 4);
1737    for _ in 0..N_SAMPLES {
1738        let r = next();
1739        cmyk.extend_from_slice(&[
1740            (r & 0xff) as u8,
1741            ((r >> 8) & 0xff) as u8,
1742            ((r >> 16) & 0xff) as u8,
1743            ((r >> 24) & 0xff) as u8,
1744        ]);
1745    }
1746    let mut reference = vec![0u8; N_SAMPLES * 3];
1747    if transform.transform(&cmyk, &mut reference).is_err() {
1748        eprintln!("[ICC VERIFY] reference transform failed");
1749        return;
1750    }
1751    // Mirror the CLUT bake's BPC step in the reference path so the
1752    // comparison measures interpolation error, not whether BPC was applied.
1753    if let Some(p) = bpc_params {
1754        for px in reference.chunks_exact_mut(3) {
1755            let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
1756            px[0] = out[0];
1757            px[1] = out[1];
1758            px[2] = out[2];
1759        }
1760    }
1761    let interp = apply_clut4_cmyk_to_rgb(clut, &cmyk, N_SAMPLES);
1762    // Per-pixel Euclidean distance in 8-bit sRGB (crude ΔE proxy).
1763    let mut dists: Vec<f64> = Vec::with_capacity(N_SAMPLES);
1764    let mut max_ch: u8 = 0;
1765    for i in 0..N_SAMPLES {
1766        let dr = interp[i * 3] as i32 - reference[i * 3] as i32;
1767        let dg = interp[i * 3 + 1] as i32 - reference[i * 3 + 1] as i32;
1768        let db = interp[i * 3 + 2] as i32 - reference[i * 3 + 2] as i32;
1769        let d = ((dr * dr + dg * dg + db * db) as f64).sqrt();
1770        dists.push(d);
1771        max_ch = max_ch
1772            .max(dr.unsigned_abs() as u8)
1773            .max(dg.unsigned_abs() as u8)
1774            .max(db.unsigned_abs() as u8);
1775    }
1776    dists.sort_by(|a, b| a.partial_cmp(b).unwrap());
1777    let median = dists[N_SAMPLES / 2];
1778    let p99 = dists[(N_SAMPLES * 99) / 100];
1779    let max = dists[N_SAMPLES - 1];
1780    eprintln!(
1781        "[ICC VERIFY] N=17 CLUT vs direct moxcms (sRGB u8): median={:.2}, p99={:.2}, max={:.2}, max_per_channel={}",
1782        median, p99, max, max_ch
1783    );
1784}
1785
1786/// Search system paths for CMYK ICC profile bytes without parsing or logging.
1787///
1788/// Returns the raw bytes suitable for passing to the viewer for ICC-aware rendering.
1789pub fn find_system_cmyk_profile_bytes() -> Option<Arc<Vec<u8>>> {
1790    find_system_cmyk_profile().map(Arc::new)
1791}
1792
1793/// Search common system paths for a CMYK ICC profile.
1794fn find_system_cmyk_profile() -> Option<Vec<u8>> {
1795    #[cfg(target_os = "linux")]
1796    {
1797        let paths = [
1798            "/usr/share/color/icc/ghostscript/default_cmyk.icc",
1799            "/usr/share/color/icc/ghostscript/ps_cmyk.icc",
1800            "/usr/share/color/icc/colord/FOGRA39L_coated.icc",
1801        ];
1802        for path in &paths {
1803            if let Ok(bytes) = std::fs::read(path) {
1804                return Some(bytes);
1805            }
1806        }
1807        // Glob for SWOP profiles
1808        if let Ok(entries) = glob::glob("/usr/share/color/icc/colord/SWOP*.icc") {
1809            for entry in entries.flatten() {
1810                if let Ok(bytes) = std::fs::read(&entry) {
1811                    return Some(bytes);
1812                }
1813            }
1814        }
1815    }
1816
1817    #[cfg(target_os = "macos")]
1818    {
1819        let dirs = [
1820            "/Library/ColorSync/Profiles",
1821            "/System/Library/ColorSync/Profiles",
1822        ];
1823        if let Some(home) = std::env::var_os("HOME") {
1824            let home_dir = std::path::PathBuf::from(home).join("Library/ColorSync/Profiles");
1825            if let Some(bytes) = scan_dir_for_cmyk_icc(&home_dir) {
1826                return Some(bytes);
1827            }
1828        }
1829        for dir in &dirs {
1830            if let Some(bytes) = scan_dir_for_cmyk_icc(std::path::Path::new(dir)) {
1831                return Some(bytes);
1832            }
1833        }
1834    }
1835
1836    #[cfg(target_os = "windows")]
1837    {
1838        if let Some(sysroot) = std::env::var_os("SYSTEMROOT") {
1839            let dir = std::path::PathBuf::from(sysroot).join("System32/spool/drivers/color");
1840            if let Some(bytes) = scan_dir_for_cmyk_icc(&dir) {
1841                return Some(bytes);
1842            }
1843        }
1844    }
1845
1846    None
1847}
1848
1849/// Scan a directory for ICC files with CMYK color space.
1850#[cfg(any(target_os = "macos", target_os = "windows"))]
1851fn scan_dir_for_cmyk_icc(dir: &std::path::Path) -> Option<Vec<u8>> {
1852    let entries = std::fs::read_dir(dir).ok()?;
1853    for entry in entries.flatten() {
1854        let path = entry.path();
1855        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1856        if ext.eq_ignore_ascii_case("icc") || ext.eq_ignore_ascii_case("icm") {
1857            if let Ok(bytes) = std::fs::read(&path) {
1858                // Check ICC header: color space at offset 16, 'CMYK' = 0x434D594B
1859                if bytes.len() >= 20 && &bytes[16..20] == b"CMYK" {
1860                    return Some(bytes);
1861                }
1862            }
1863        }
1864    }
1865    None
1866}
1867
1868#[cfg(test)]
1869mod tests {
1870    use super::*;
1871
1872    #[test]
1873    fn test_icc_cache_new() {
1874        let cache = IccCache::new();
1875        assert!(cache.default_cmyk_hash.is_none());
1876        assert!(cache.profiles.is_empty());
1877        assert_eq!(cache.bpc_mode(), BpcMode::Auto);
1878    }
1879
1880    #[test]
1881    fn test_icc_cache_options_default_matches_new() {
1882        let cache = IccCache::new_with_options(IccCacheOptions::default());
1883        assert_eq!(cache.bpc_mode(), BpcMode::Auto);
1884        assert!(cache.default_cmyk_hash.is_none());
1885    }
1886
1887    #[test]
1888    fn test_icc_cache_options_bpc_off() {
1889        let cache = IccCache::new_with_options(IccCacheOptions {
1890            bpc_mode: BpcMode::Off,
1891            source_cmyk_profile: None,
1892        });
1893        assert_eq!(cache.bpc_mode(), BpcMode::Off);
1894        assert!(!cache.bpc_mode().is_enabled());
1895    }
1896
1897    #[test]
1898    fn test_icc_cache_options_preloads_cmyk_profile() {
1899        // Skip when no system CMYK profile is available.
1900        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1901            return;
1902        };
1903        let cache = IccCache::new_with_options(IccCacheOptions {
1904            bpc_mode: BpcMode::On,
1905            source_cmyk_profile: Some(cmyk_bytes.clone()),
1906        });
1907        assert!(cache.default_cmyk_hash().is_some());
1908        assert_eq!(cache.bpc_mode(), BpcMode::On);
1909    }
1910
1911    #[test]
1912    fn test_bpc_darkens_pure_k_per_color() {
1913        // Skip when no system CMYK profile is available.
1914        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1915            return;
1916        };
1917
1918        // Without BPC: K=1 through default_cmyk.icc lands near RGB(55, 53, 53)
1919        // — the profile's as-mapped black projected through moxcms's sRGB B2A.
1920        let mut off = IccCache::new_with_options(IccCacheOptions {
1921            bpc_mode: BpcMode::Off,
1922            source_cmyk_profile: Some(cmyk_bytes.clone()),
1923        });
1924        let off_rgb = off.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1925
1926        // With BPC: K=1 must land significantly darker. Adobe Acrobat (lcms2)
1927        // produces RGB(35, 31, 32). moxcms's sRGB B2A handles very-dark XYZ
1928        // slightly differently from lcms2, so our post-correct lands a few
1929        // levels brighter than Acrobat — the meaningful invariant is "K=1 is
1930        // visibly darker than the no-BPC baseline by a substantial margin."
1931        let mut on = IccCache::new_with_options(IccCacheOptions {
1932            bpc_mode: BpcMode::On,
1933            source_cmyk_profile: Some(cmyk_bytes),
1934        });
1935        let on_rgb = on.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1936
1937        // Precondition: this test only exercises BPC's darkening effect, which
1938        // requires a profile whose black point has non-zero luminance. Some
1939        // system-supplied CMYK profiles (e.g. macOS's default ColorSync CMYK)
1940        // already map K=1 to (near-)zero XYZ, so BPC has nothing to correct
1941        // and off == on. Skip in that case — there's no regression to anchor
1942        // here, just a profile that doesn't benefit from BPC.
1943        if (on_rgb.1 - off_rgb.1).abs() < 0.005 {
1944            eprintln!(
1945                "Skipping: system CMYK profile's black point is already ~zero; \
1946                 BPC is a no-op here. off={off_rgb:?} on={on_rgb:?}"
1947            );
1948            return;
1949        }
1950
1951        assert!(
1952            on_rgb.1 + 0.03 < off_rgb.1,
1953            "BPC should darken K=1 by ≥0.03 (~8 RGB levels): off={off_rgb:?} on={on_rgb:?}"
1954        );
1955        // And the resulting RGB should be in the "deep gray" range — well
1956        // under 0.25 (RGB ≤ ~64) on every channel.
1957        assert!(
1958            on_rgb.0 < 0.25 && on_rgb.1 < 0.25 && on_rgb.2 < 0.25,
1959            "Expected deep gray after BPC, got {on_rgb:?}"
1960        );
1961    }
1962
1963    #[test]
1964    fn test_bpc_white_anchored_per_color() {
1965        // Skip when no system CMYK profile is available.
1966        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1967            return;
1968        };
1969        let mut cache = IccCache::new_with_options(IccCacheOptions {
1970            bpc_mode: BpcMode::On,
1971            source_cmyk_profile: Some(cmyk_bytes),
1972        });
1973        // CMYK white (no ink) must still render as sRGB white under BPC.
1974        let (r, g, b) = cache.convert_cmyk(0.0, 0.0, 0.0, 0.0).unwrap();
1975        assert!(r > 0.99 && g > 0.99 && b > 0.99, "({r}, {g}, {b})");
1976    }
1977
1978    #[test]
1979    fn test_bpc_image_clut_path_darkens_pure_k() {
1980        // Skip when no system CMYK profile is available.
1981        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1982            return;
1983        };
1984
1985        // Build a 1-pixel CMYK image at K=1 and route it through the CLUT.
1986        // Without BPC vs with BPC, the K=1 pixel must shift darker, mirroring
1987        // the per-color path behaviour.
1988        let off = IccCache::new_with_options(IccCacheOptions {
1989            bpc_mode: BpcMode::Off,
1990            source_cmyk_profile: Some(cmyk_bytes.clone()),
1991        });
1992        let on = IccCache::new_with_options(IccCacheOptions {
1993            bpc_mode: BpcMode::On,
1994            source_cmyk_profile: Some(cmyk_bytes),
1995        });
1996        let off_hash = *off.default_cmyk_hash().unwrap();
1997        let on_hash = *on.default_cmyk_hash().unwrap();
1998
1999        let pixel = [0u8, 0, 0, 255]; // C=0 M=0 Y=0 K=255
2000        let off_rgb = off.convert_image_8bit(&off_hash, &pixel, 1).unwrap();
2001        let on_rgb = on.convert_image_8bit(&on_hash, &pixel, 1).unwrap();
2002
2003        // Precondition, same as `test_bpc_darkens_pure_k_per_color`: BPC only
2004        // shifts pixels when the profile's black point has non-zero luminance.
2005        // Skip when the system profile already maps K=1 to near-zero XYZ.
2006        if (on_rgb[1] as i32 - off_rgb[1] as i32).abs() <= 1 {
2007            eprintln!(
2008                "Skipping: system CMYK profile's black point is already ~zero; \
2009                 BPC is a no-op here. off={off_rgb:?} on={on_rgb:?}"
2010            );
2011            return;
2012        }
2013
2014        // BPC must darken the green channel by ≥8 RGB levels (mirrors the
2015        // per-color path's anchor in test_bpc_darkens_pure_k_per_color).
2016        assert!(
2017            (on_rgb[1] as i32) + 8 < (off_rgb[1] as i32),
2018            "CLUT BPC should darken K=1 image green by ≥8 levels: off={off_rgb:?} on={on_rgb:?}"
2019        );
2020        // And land in the deep-gray range.
2021        assert!(
2022            on_rgb[0] < 64 && on_rgb[1] < 64 && on_rgb[2] < 64,
2023            "Expected deep gray after CLUT BPC, got {on_rgb:?}"
2024        );
2025    }
2026
2027    #[test]
2028    fn test_bpc_off_image_matches_per_color_off() {
2029        // With --bpc off, the bulk image path's K=1 output must match the
2030        // per-color path's K=1 output (within u8 quantization). Anchors that
2031        // disabling BPC reproduces stet's pre-fix behaviour bit-for-bit on
2032        // the dominant CMYK image path.
2033        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
2034            return;
2035        };
2036        let mut cache = IccCache::new_with_options(IccCacheOptions {
2037            bpc_mode: BpcMode::Off,
2038            source_cmyk_profile: Some(cmyk_bytes),
2039        });
2040        let hash = *cache.default_cmyk_hash().unwrap();
2041
2042        let pixel = [0u8, 0, 0, 255];
2043        let img = cache.convert_image_8bit(&hash, &pixel, 1).unwrap();
2044        let (r, g, b) = cache.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
2045        let pc = [
2046            (r * 255.0).round() as i32,
2047            (g * 255.0).round() as i32,
2048            (b * 255.0).round() as i32,
2049        ];
2050        // CLUT interpolation drift can introduce ±1 vs the direct f64 path.
2051        assert!((img[0] as i32 - pc[0]).abs() <= 2, "img={img:?} pc={pc:?}");
2052        assert!((img[1] as i32 - pc[1]).abs() <= 2, "img={img:?} pc={pc:?}");
2053        assert!((img[2] as i32 - pc[2]).abs() <= 2, "img={img:?} pc={pc:?}");
2054    }
2055
2056    #[test]
2057    fn test_register_invalid_profile() {
2058        let mut cache = IccCache::new();
2059        assert!(cache.register_profile(b"not a valid ICC profile").is_none());
2060    }
2061
2062    #[test]
2063    fn test_srgb_identity_transform() {
2064        // Create an sRGB profile, register it, and verify identity-ish conversion
2065        let srgb = ColorProfile::new_srgb();
2066        let bytes = srgb.encode().unwrap();
2067        let mut cache = IccCache::new();
2068        let hash = cache.register_profile(&bytes).unwrap();
2069
2070        // Red should stay approximately red
2071        let (r, g, b) = cache.convert_color(&hash, &[1.0, 0.0, 0.0]).unwrap();
2072        assert!((r - 1.0).abs() < 0.02, "r={r}");
2073        assert!(g < 0.02, "g={g}");
2074        assert!(b < 0.02, "b={b}");
2075
2076        // White
2077        let (r, g, b) = cache.convert_color(&hash, &[1.0, 1.0, 1.0]).unwrap();
2078        assert!((r - 1.0).abs() < 0.02);
2079        assert!((g - 1.0).abs() < 0.02);
2080        assert!((b - 1.0).abs() < 0.02);
2081    }
2082
2083    #[test]
2084    fn test_srgb_image_transform() {
2085        let srgb = ColorProfile::new_srgb();
2086        let bytes = srgb.encode().unwrap();
2087        let mut cache = IccCache::new();
2088        let hash = cache.register_profile(&bytes).unwrap();
2089
2090        // 2 pixels: red, green
2091        let src = [255u8, 0, 0, 0, 255, 0];
2092        let result = cache.convert_image_8bit(&hash, &src, 2).unwrap();
2093        assert_eq!(result.len(), 6);
2094        // Red pixel should be approximately (255, 0, 0)
2095        assert!(result[0] > 240);
2096        assert!(result[1] < 15);
2097        assert!(result[2] < 15);
2098    }
2099
2100    #[test]
2101    fn test_convert_rgb_to_cmyk_readonly() {
2102        // Skip when no system CMYK profile is available (CI without ICC packs).
2103        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
2104            return;
2105        };
2106        let mut cache = IccCache::new();
2107        let hash = cache.register_profile(&cmyk_bytes).unwrap();
2108        cache.set_default_cmyk_hash(hash);
2109
2110        // Before pre-warming the reverse transform must be unavailable.
2111        assert!(cache.convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0).is_none());
2112
2113        cache.prepare_reverse_cmyk();
2114
2115        // Pure black sRGB should land deep in K (any reasonable CMYK profile
2116        // produces a high K component).
2117        let cmyk = cache
2118            .convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0)
2119            .expect("reverse transform should be available after prepare");
2120        assert!(
2121            cmyk[3] > 0.5,
2122            "expected K>0.5 for sRGB black, got cmyk={cmyk:?}"
2123        );
2124
2125        // Pure white sRGB should land near (0,0,0,0) — minimal ink.
2126        let cmyk = cache
2127            .convert_rgb_to_cmyk_readonly(1.0, 1.0, 1.0)
2128            .expect("reverse transform should be available");
2129        for (i, v) in cmyk.iter().enumerate() {
2130            assert!(*v < 0.05, "expected near-zero ink at chan {i}, got {v}");
2131        }
2132    }
2133
2134    /// White CMYK (0,0,0,0) routed through the perceptual CLUT must land at
2135    /// pure white sRGB. Catches scaling errors in the PCS-Lab decode (the
2136    /// most likely place to introduce a uniform brightness shift).
2137    #[test]
2138    fn test_perceptual_clut_white_anchor() {
2139        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
2140            return;
2141        };
2142        let mut cache = IccCache::new_with_options(IccCacheOptions {
2143            bpc_mode: BpcMode::Off,
2144            source_cmyk_profile: Some(cmyk_bytes),
2145        });
2146        let rgb = cache.convert_cmyk(0.0, 0.0, 0.0, 0.0).unwrap();
2147        // Tolerate a few u8 levels of grid-interpolation drift; the bound
2148        // catches order-of-magnitude bugs (a ~0.5x scale would land at ~127).
2149        assert!(
2150            rgb.0 > 0.97 && rgb.1 > 0.97 && rgb.2 > 0.97,
2151            "CMYK white should map near sRGB white, got {rgb:?}"
2152        );
2153    }
2154
2155    /// Single-color CLUT lookup must agree with bulk image conversion to
2156    /// within u8 quantization on the same input. Anchors the requirement
2157    /// that flat CMYK fills match adjacent gradient stops byte-for-byte.
2158    #[test]
2159    fn test_perceptual_clut_single_matches_bulk() {
2160        let Some(cmyk_bytes) = find_system_cmyk_profile() else {
2161            return;
2162        };
2163        let mut cache = IccCache::new_with_options(IccCacheOptions {
2164            bpc_mode: BpcMode::On,
2165            source_cmyk_profile: Some(cmyk_bytes),
2166        });
2167        let hash = *cache.default_cmyk_hash().unwrap();
2168        // A handful of saturated and midtone CMYK samples.
2169        let samples: &[(u8, u8, u8, u8)] = &[
2170            (0, 0, 0, 0),
2171            (255, 0, 0, 0),
2172            (0, 255, 0, 0),
2173            (0, 0, 255, 0),
2174            (0, 0, 0, 255),
2175            (38, 255, 255, 0), // ≈ (0.15, 1.0, 1.0, 0.0) — the GWG demo input
2176            (128, 128, 128, 0),
2177        ];
2178        let pixels: Vec<u8> = samples
2179            .iter()
2180            .flat_map(|&(c, m, y, k)| [c, m, y, k])
2181            .collect();
2182        let bulk = cache
2183            .convert_image_8bit(&hash, &pixels, samples.len())
2184            .unwrap();
2185        for (i, &(c, m, y, k)) in samples.iter().enumerate() {
2186            let single = cache
2187                .convert_color(
2188                    &hash,
2189                    &[
2190                        c as f64 / 255.0,
2191                        m as f64 / 255.0,
2192                        y as f64 / 255.0,
2193                        k as f64 / 255.0,
2194                    ],
2195                )
2196                .unwrap();
2197            let single_u8 = [
2198                (single.0 * 255.0).round() as i32,
2199                (single.1 * 255.0).round() as i32,
2200                (single.2 * 255.0).round() as i32,
2201            ];
2202            let b = &bulk[i * 3..i * 3 + 3];
2203            for ch in 0..3 {
2204                let delta = (single_u8[ch] - b[ch] as i32).abs();
2205                assert!(
2206                    delta <= 2,
2207                    "single vs bulk mismatch at sample {i} chan {ch}: \
2208                     single={single_u8:?} bulk={:?} delta={delta}",
2209                    [b[0], b[1], b[2]]
2210                );
2211            }
2212        }
2213    }
2214}