1pub 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
24pub use moxcms::RenderingIntent as IccRenderingIntent;
29
30#[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
45pub type ProfileHash = [u8; 32];
47
48type IntentColorKey = (u64, [u16; 4], u8);
51
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum BpcMode {
60 Off,
64 On,
66 #[default]
70 Auto,
71}
72
73impl BpcMode {
74 #[inline]
76 pub fn is_enabled(self) -> bool {
77 matches!(self, BpcMode::On | BpcMode::Auto)
78 }
79}
80
81#[derive(Clone, Default)]
87pub struct IccCacheOptions {
88 pub bpc_mode: BpcMode,
90 pub source_cmyk_profile: Option<Vec<u8>>,
95}
96
97struct 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
123struct 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
158struct ChainedTransform<T: Copy + Default + Send + Sync + 'static> {
171 stage1: Arc<dyn TransformExecutor<T> + Send + Sync>,
173 stage2: Arc<dyn TransformExecutor<T> + Send + Sync>,
177 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 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#[derive(Clone)]
202struct Clut4 {
203 grid_n: u8,
205 data: Arc<Vec<u8>>,
208}
209
210impl Clut4 {
211 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#[derive(Clone)]
226struct CachedTransform {
227 transform_8bit: Arc<dyn TransformExecutor<u8> + Send + Sync>,
232 transform_f64: Arc<dyn TransformExecutor<f64> + Send + Sync>,
235 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 chain_stage1_per_intent: [Option<Arc<perceptual::HandRolledChainStage1Rgb>>; 4],
253 n: u32,
255 is_lab: bool,
257 clut4: Option<Clut4>,
260 bpc_params: Option<BpcParams>,
265}
266
267#[derive(Clone)]
269pub struct IccCache {
270 profiles: HashMap<ProfileHash, Arc<ColorProfile>>,
272 transforms: HashMap<ProfileHash, CachedTransform>,
274 color_cache: HashMap<(u64, [u16; 4]), (f64, f64, f64)>,
279 color_cache_intent: HashMap<IntentColorKey, (f64, f64, f64)>,
285 default_cmyk_hash: Option<ProfileHash>,
287 system_cmyk_bytes: Option<Arc<Vec<u8>>>,
289 raw_bytes: HashMap<ProfileHash, Arc<Vec<u8>>>,
291 srgb_profile: ColorProfile,
293 reverse_cmyk_f64: Option<Arc<dyn TransformExecutor<f64> + Send + Sync>>,
295 bpc_mode: BpcMode,
299 proofing_enabled: bool,
308 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#[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 pub fn new() -> Self {
340 Self::new_with_options(IccCacheOptions::default())
341 }
342
343 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 #[inline]
372 pub fn bpc_mode(&self) -> BpcMode {
373 self.bpc_mode
374 }
375
376 pub fn hash_profile(bytes: &[u8]) -> ProfileHash {
378 use sha2::{Digest, Sha256};
379 Sha256::digest(bytes).into()
380 }
381
382 pub fn register_profile(&mut self, bytes: &[u8]) -> Option<ProfileHash> {
384 self.register_profile_with_n(bytes, None)
385 }
386
387 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 if self.transforms.contains_key(&hash) {
402 return Some(hash);
403 }
404
405 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 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 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 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 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 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 let oi_layout_8 = Layout::Rgba;
564 let oi_layout_f64 = Layout::Rgba;
565
566 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if self.color_cache.len() < 65536 {
901 self.color_cache.insert(cache_key, result);
902 }
903
904 Some(result)
905 }
906
907 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 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 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 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 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 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 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 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 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 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 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 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 pub fn default_cmyk_hash(&self) -> Option<&ProfileHash> {
1184 self.default_cmyk_hash.as_ref()
1185 }
1186
1187 pub fn has_profile(&self, hash: &ProfileHash) -> bool {
1189 self.transforms.contains_key(hash)
1190 }
1191
1192 pub fn get_profile_bytes(&self, hash: &ProfileHash) -> Option<Arc<Vec<u8>>> {
1194 self.raw_bytes.get(hash).cloned()
1195 }
1196
1197 pub fn system_cmyk_bytes(&self) -> Option<&Arc<Vec<u8>>> {
1199 self.system_cmyk_bytes.as_ref()
1200 }
1201
1202 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 pub fn set_proofing_enabled(&mut self, enabled: bool) {
1215 self.proofing_enabled = enabled;
1216 }
1217
1218 pub fn proofing_enabled(&self) -> bool {
1225 self.proofing_enabled
1226 }
1227
1228 pub fn set_default_cmyk_hash(&mut self, hash: ProfileHash) {
1230 self.default_cmyk_hash = Some(hash);
1231 }
1232
1233 pub fn suspend_default_cmyk(&mut self) -> Option<ProfileHash> {
1238 self.default_cmyk_hash.take()
1239 }
1240
1241 pub fn restore_default_cmyk(&mut self, hash: Option<ProfileHash>) {
1243 self.default_cmyk_hash = hash;
1244 }
1245
1246 #[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 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 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 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 pub fn prepare_reverse_cmyk(&mut self) {
1324 let _ = self.ensure_reverse_cmyk_transform();
1325 }
1326
1327 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 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 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 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 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 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 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
1441fn 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 let mut src = Vec::with_capacity(total * 4);
1463 let step = |i: usize| -> u8 {
1464 ((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 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
1500fn 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 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
1590fn 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 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 #[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 let (a_dxmy, b_dxmy, w1, w2, w3) = if fc >= fm {
1637 if fm >= fy {
1638 ((1, 0, 0), (1, 1, 0), fc, fm, fy)
1640 } else if fc >= fy {
1641 ((1, 0, 0), (1, 0, 1), fc, fy, fm)
1643 } else {
1644 ((0, 0, 1), (1, 0, 1), fy, fc, fm)
1646 }
1647 } else if fc >= fy {
1648 ((0, 1, 0), (1, 1, 0), fm, fc, fy)
1650 } else if fm >= fy {
1651 ((0, 1, 0), (0, 1, 1), fm, fy, fc)
1653 } else {
1654 ((0, 0, 1), (0, 1, 1), fy, fm, fc)
1656 };
1657
1658 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 let base_lo = ki * stride_k;
1674 let base_hi = ki1 * stride_k;
1675
1676 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 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
1719fn 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 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 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
1786pub fn find_system_cmyk_profile_bytes() -> Option<Arc<Vec<u8>>> {
1790 find_system_cmyk_profile().map(Arc::new)
1791}
1792
1793fn 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 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#[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 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 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 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1915 return;
1916 };
1917
1918 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 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 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 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 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 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 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1982 return;
1983 };
1984
1985 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]; 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 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 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 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 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 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 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 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 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 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 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 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 assert!(cache.convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0).is_none());
2112
2113 cache.prepare_reverse_cmyk();
2114
2115 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 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 #[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 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 #[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 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), (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}