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 type ProfileHash = [u8; 32];
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum BpcMode {
35 Off,
39 On,
41 #[default]
45 Auto,
46}
47
48impl BpcMode {
49 #[inline]
51 pub fn is_enabled(self) -> bool {
52 matches!(self, BpcMode::On | BpcMode::Auto)
53 }
54}
55
56#[derive(Clone, Default)]
62pub struct IccCacheOptions {
63 pub bpc_mode: BpcMode,
65 pub source_cmyk_profile: Option<Vec<u8>>,
70}
71
72struct GrayToRgbIdentity;
75
76impl TransformExecutor<u8> for GrayToRgbIdentity {
77 fn transform(&self, src: &[u8], dst: &mut [u8]) -> Result<(), CmsError> {
78 for (g, rgb) in src.iter().zip(dst.chunks_exact_mut(3)) {
79 rgb[0] = *g;
80 rgb[1] = *g;
81 rgb[2] = *g;
82 }
83 Ok(())
84 }
85}
86
87impl TransformExecutor<f64> for GrayToRgbIdentity {
88 fn transform(&self, src: &[f64], dst: &mut [f64]) -> Result<(), CmsError> {
89 for (g, rgb) in src.iter().zip(dst.chunks_exact_mut(3)) {
90 rgb[0] = *g;
91 rgb[1] = *g;
92 rgb[2] = *g;
93 }
94 Ok(())
95 }
96}
97
98#[derive(Clone)]
106struct Clut4 {
107 grid_n: u8,
109 data: Arc<Vec<u8>>,
112}
113
114impl Clut4 {
115 fn from_baked(grid_n: u8, data: Vec<u8>) -> Self {
120 debug_assert_eq!(data.len(), (grid_n as usize).pow(4) * 3);
121 Self {
122 grid_n,
123 data: Arc::new(data),
124 }
125 }
126}
127
128#[derive(Clone)]
130struct CachedTransform {
131 transform_8bit: Arc<dyn TransformExecutor<u8> + Send + Sync>,
133 transform_f64: Arc<dyn TransformExecutor<f64> + Send + Sync>,
135 n: u32,
137 is_lab: bool,
139 clut4: Option<Clut4>,
142 bpc_params: Option<BpcParams>,
147}
148
149#[derive(Clone)]
151pub struct IccCache {
152 profiles: HashMap<ProfileHash, Arc<ColorProfile>>,
154 transforms: HashMap<ProfileHash, CachedTransform>,
156 color_cache: HashMap<(u64, [u16; 4]), (f64, f64, f64)>,
159 default_cmyk_hash: Option<ProfileHash>,
161 system_cmyk_bytes: Option<Arc<Vec<u8>>>,
163 raw_bytes: HashMap<ProfileHash, Arc<Vec<u8>>>,
165 srgb_profile: ColorProfile,
167 reverse_cmyk_f64: Option<Arc<dyn TransformExecutor<f64> + Send + Sync>>,
169 bpc_mode: BpcMode,
173}
174
175impl Default for IccCache {
176 fn default() -> Self {
177 Self::new()
178 }
179}
180
181#[inline]
185fn bpc_post_correct(rgb: [f64; 3], params: Option<&BpcParams>) -> [f64; 3] {
186 match params {
187 Some(p) => apply_bpc_f64(rgb, p),
188 None => rgb,
189 }
190}
191
192impl IccCache {
193 pub fn new() -> Self {
196 Self::new_with_options(IccCacheOptions::default())
197 }
198
199 pub fn new_with_options(opts: IccCacheOptions) -> Self {
206 let mut cache = Self {
207 profiles: HashMap::new(),
208 transforms: HashMap::new(),
209 color_cache: HashMap::new(),
210 default_cmyk_hash: None,
211 system_cmyk_bytes: None,
212 raw_bytes: HashMap::new(),
213 srgb_profile: ColorProfile::new_srgb(),
214 reverse_cmyk_f64: None,
215 bpc_mode: opts.bpc_mode,
216 };
217 if let Some(bytes) = opts.source_cmyk_profile {
218 cache.load_cmyk_profile_bytes(&bytes);
219 }
220 cache
221 }
222
223 #[inline]
225 pub fn bpc_mode(&self) -> BpcMode {
226 self.bpc_mode
227 }
228
229 pub fn hash_profile(bytes: &[u8]) -> ProfileHash {
231 use sha2::{Digest, Sha256};
232 Sha256::digest(bytes).into()
233 }
234
235 pub fn register_profile(&mut self, bytes: &[u8]) -> Option<ProfileHash> {
237 self.register_profile_with_n(bytes, None)
238 }
239
240 pub fn register_profile_with_n(
246 &mut self,
247 bytes: &[u8],
248 expected_n: Option<u32>,
249 ) -> Option<ProfileHash> {
250 use sha2::{Digest, Sha256};
251 let hash: ProfileHash = Sha256::digest(bytes).into();
252
253 if self.transforms.contains_key(&hash) {
255 return Some(hash);
256 }
257
258 self.raw_bytes
260 .entry(hash)
261 .or_insert_with(|| Arc::new(bytes.to_vec()));
262
263 let profile = match ColorProfile::new_from_slice(bytes) {
264 Ok(p) => p,
265 Err(e) => {
266 eprintln!("[ICC] Failed to parse profile: {e}");
267 return None;
268 }
269 };
270
271 let n = match profile.color_space {
272 DataColorSpace::Gray => 1u32,
273 DataColorSpace::Rgb => 3,
274 DataColorSpace::Cmyk => 4,
275 DataColorSpace::Lab => 3,
276 _ => {
277 eprintln!(
278 "[ICC] Unsupported profile color space: {:?}",
279 profile.color_space
280 );
281 return None;
282 }
283 };
284
285 if let Some(expected) = expected_n {
289 if n != expected {
290 return None;
291 }
292 }
293
294 let (src_layout_8, src_layout_f64) = match n {
295 1 => (Layout::Gray, Layout::Gray),
296 3 => (Layout::Rgb, Layout::Rgb),
297 4 => (Layout::Rgba, Layout::Rgba),
298 _ => return None,
299 };
300
301 let dst_layout_8 = Layout::Rgb;
302 let dst_layout_f64 = Layout::Rgb;
303
304 let intents = [
313 RenderingIntent::Perceptual,
314 RenderingIntent::RelativeColorimetric,
315 RenderingIntent::AbsoluteColorimetric,
316 RenderingIntent::Saturation,
317 ];
318
319 let mut transform_8bit = None;
320 for &intent in &intents {
321 let options = TransformOptions {
322 rendering_intent: intent,
323 ..TransformOptions::default()
324 };
325 match profile.create_transform_8bit(
326 src_layout_8,
327 &self.srgb_profile,
328 dst_layout_8,
329 options,
330 ) {
331 Ok(t) => {
332 transform_8bit = Some(t);
333 break;
334 }
335 Err(_) => continue,
336 }
337 }
338 let transform_8bit = match transform_8bit {
339 Some(t) => t,
340 None if n == 1 => {
341 return self.register_gray_identity(hash, profile);
346 }
347 None => {
348 eprintln!(
349 "[ICC] Failed to create 8-bit transform (cs={:?})",
350 profile.color_space
351 );
352 return None;
353 }
354 };
355
356 let mut transform_f64 = None;
357 for &intent in &intents {
358 let options = TransformOptions {
359 rendering_intent: intent,
360 ..TransformOptions::default()
361 };
362 match profile.create_transform_f64(
363 src_layout_f64,
364 &self.srgb_profile,
365 dst_layout_f64,
366 options,
367 ) {
368 Ok(t) => {
369 transform_f64 = Some(t);
370 break;
371 }
372 Err(_) => continue,
373 }
374 }
375 let transform_f64 = match transform_f64 {
376 Some(t) => t,
377 None if n == 1 => {
378 return self.register_gray_identity(hash, profile);
380 }
381 None => {
382 eprintln!(
383 "[ICC] Failed to create f64 transform (cs={:?})",
384 profile.color_space
385 );
386 return None;
387 }
388 };
389
390 let is_lab = profile.color_space == DataColorSpace::Lab;
391
392 let bpc_enabled = n == 4 && self.bpc_mode.is_enabled();
416 let mut bpc_params: Option<BpcParams> = None;
417 let clut4 = if n == 4 {
418 let c = perceptual::bake_clut4_perceptual(&profile, 17, bpc_enabled).or_else(|| {
419 let params = if bpc_enabled {
420 detect_source_black_point(transform_8bit.as_ref())
421 .map(|sbp| compute_bpc_params(sbp, [0.0; 3], bpc::WP_D50))
422 } else {
423 None
424 };
425 let r = bake_clut4(transform_8bit.as_ref(), 17, params.as_ref());
426 bpc_params = params;
427 r
428 });
429 if std::env::var_os("STET_ICC_VERIFY").is_some()
430 && let Some(ref clut) = c
431 {
432 verify_clut4(clut, transform_8bit.as_ref(), bpc_params.as_ref());
433 }
434 c
435 } else {
436 None
437 };
438
439 self.profiles.insert(hash, Arc::new(profile));
440 self.transforms.insert(
441 hash,
442 CachedTransform {
443 transform_8bit,
444 transform_f64,
445 n,
446 is_lab,
447 clut4,
448 bpc_params,
449 },
450 );
451
452 Some(hash)
453 }
454
455 fn register_gray_identity(
459 &mut self,
460 hash: ProfileHash,
461 profile: ColorProfile,
462 ) -> Option<ProfileHash> {
463 self.profiles.insert(hash, Arc::new(profile));
464 self.transforms.insert(
465 hash,
466 CachedTransform {
467 transform_8bit: Arc::new(GrayToRgbIdentity),
468 transform_f64: Arc::new(GrayToRgbIdentity),
469 n: 1,
470 is_lab: false,
471 clut4: None,
472 bpc_params: None,
473 },
474 );
475 Some(hash)
476 }
477
478 pub fn convert_color(
481 &mut self,
482 hash: &ProfileHash,
483 components: &[f64],
484 ) -> Option<(f64, f64, f64)> {
485 let cached = self.transforms.get(hash)?;
486 let n = cached.n as usize;
487 let is_lab = cached.is_lab;
488
489 let mut src = vec![0.0f64; n];
492 for (i, s) in src.iter_mut().enumerate() {
493 let v = components.get(i).copied().unwrap_or(0.0);
494 *s = if is_lab {
495 match i {
496 0 => (v / 100.0).clamp(0.0, 1.0),
497 _ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
498 }
499 } else {
500 v.clamp(0.0, 1.0)
501 };
502 }
503
504 let hash_prefix = u64::from_le_bytes(hash[..8].try_into().ok()?);
506 let mut quantized = [0u16; 4];
507 for (i, &c) in src.iter().take(4).enumerate() {
508 quantized[i] = (c * 65535.0).round() as u16;
509 }
510
511 let cache_key = (hash_prefix, quantized);
513 if let Some(&cached) = self.color_cache.get(&cache_key) {
514 return Some(cached);
515 }
516
517 let result = if n == 4
518 && let Some(clut) = cached.clut4.as_ref()
519 {
520 let (r, g, b) = sample_clut4_single_f64(clut, src[0], src[1], src[2], src[3]);
525 (r, g, b)
526 } else {
527 let mut dst = [0.0f64; 3];
528 if cached.transform_f64.transform(&src, &mut dst).is_err() {
529 return None;
530 }
531 let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
532 (
533 dst[0].clamp(0.0, 1.0),
534 dst[1].clamp(0.0, 1.0),
535 dst[2].clamp(0.0, 1.0),
536 )
537 };
538
539 if self.color_cache.len() < 65536 {
541 self.color_cache.insert(cache_key, result);
542 }
543
544 Some(result)
545 }
546
547 pub fn convert_color_readonly(
552 &self,
553 hash: &ProfileHash,
554 components: &[f64],
555 ) -> Option<(f64, f64, f64)> {
556 let cached = self.transforms.get(hash)?;
557 let n = cached.n as usize;
558 let is_lab = cached.is_lab;
559
560 let mut src = vec![0.0f64; n];
561 for (i, s) in src.iter_mut().enumerate() {
562 let v = components.get(i).copied().unwrap_or(0.0);
563 *s = if is_lab {
564 match i {
565 0 => (v / 100.0).clamp(0.0, 1.0),
566 _ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
567 }
568 } else {
569 v.clamp(0.0, 1.0)
570 };
571 }
572
573 if n == 4
574 && let Some(clut) = cached.clut4.as_ref()
575 {
576 let (r, g, b) = sample_clut4_single_f64(clut, src[0], src[1], src[2], src[3]);
577 return Some((r, g, b));
578 }
579
580 let mut dst = [0.0f64; 3];
581 if cached.transform_f64.transform(&src, &mut dst).is_err() {
582 return None;
583 }
584
585 let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
586 Some((
587 dst[0].clamp(0.0, 1.0),
588 dst[1].clamp(0.0, 1.0),
589 dst[2].clamp(0.0, 1.0),
590 ))
591 }
592
593 pub fn convert_image_8bit(
597 &self,
598 hash: &ProfileHash,
599 samples: &[u8],
600 pixel_count: usize,
601 ) -> Option<Vec<u8>> {
602 let cached = self.transforms.get(hash)?;
603 let n = cached.n as usize;
604 let expected_len = pixel_count * n;
605 if samples.len() < expected_len {
606 return None;
607 }
608
609 if let Some(clut) = &cached.clut4 {
613 return Some(apply_clut4_cmyk_to_rgb(
614 clut,
615 &samples[..expected_len],
616 pixel_count,
617 ));
618 }
619
620 let src = &samples[..expected_len];
621 let mut dst = vec![0u8; pixel_count * 3];
622
623 match cached.transform_8bit.transform(src, &mut dst) {
624 Ok(()) => {
625 if let Some(p) = cached.bpc_params.as_ref() {
629 for px in dst.chunks_exact_mut(3) {
630 let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
631 px[0] = out[0];
632 px[1] = out[1];
633 px[2] = out[2];
634 }
635 }
636 Some(dst)
637 }
638 Err(e) => {
639 eprintln!("[ICC] Image transform failed: {e}");
640 None
641 }
642 }
643 }
644
645 pub fn search_system_cmyk_profile(&mut self) {
647 if let Some(bytes) = find_system_cmyk_profile()
648 && let Some(hash) = self.register_profile(&bytes)
649 {
650 eprintln!("[ICC] Loaded system CMYK profile");
651 self.system_cmyk_bytes = Some(Arc::new(bytes));
652 self.default_cmyk_hash = Some(hash);
653 }
654 }
655
656 pub fn load_cmyk_profile_bytes(&mut self, bytes: &[u8]) {
658 if let Some(hash) = self.register_profile(bytes) {
659 self.system_cmyk_bytes = Some(Arc::new(bytes.to_vec()));
660 self.default_cmyk_hash = Some(hash);
661 }
662 }
663
664 pub fn default_cmyk_hash(&self) -> Option<&ProfileHash> {
666 self.default_cmyk_hash.as_ref()
667 }
668
669 pub fn has_profile(&self, hash: &ProfileHash) -> bool {
671 self.transforms.contains_key(hash)
672 }
673
674 pub fn get_profile_bytes(&self, hash: &ProfileHash) -> Option<Arc<Vec<u8>>> {
676 self.raw_bytes.get(hash).cloned()
677 }
678
679 pub fn system_cmyk_bytes(&self) -> Option<&Arc<Vec<u8>>> {
681 self.system_cmyk_bytes.as_ref()
682 }
683
684 pub fn set_system_cmyk(&mut self, bytes: &[u8], hash: ProfileHash) {
689 self.system_cmyk_bytes = Some(Arc::new(bytes.to_vec()));
690 self.default_cmyk_hash = Some(hash);
691 }
692
693 pub fn set_default_cmyk_hash(&mut self, hash: ProfileHash) {
695 self.default_cmyk_hash = Some(hash);
696 }
697
698 pub fn suspend_default_cmyk(&mut self) -> Option<ProfileHash> {
703 self.default_cmyk_hash.take()
704 }
705
706 pub fn restore_default_cmyk(&mut self, hash: Option<ProfileHash>) {
708 self.default_cmyk_hash = hash;
709 }
710
711 #[inline]
714 pub fn convert_cmyk(&mut self, c: f64, m: f64, y: f64, k: f64) -> Option<(f64, f64, f64)> {
715 let hash = *self.default_cmyk_hash.as_ref()?;
716 self.convert_color(&hash, &[c, m, y, k])
717 }
718
719 pub fn convert_cmyk_readonly(&self, c: f64, m: f64, y: f64, k: f64) -> Option<(f64, f64, f64)> {
722 let hash = self.default_cmyk_hash.as_ref()?;
723 let cached = self.transforms.get(hash)?;
724 let src = [
725 c.clamp(0.0, 1.0),
726 m.clamp(0.0, 1.0),
727 y.clamp(0.0, 1.0),
728 k.clamp(0.0, 1.0),
729 ];
730 if let Some(clut) = cached.clut4.as_ref() {
731 let (r, g, b) = sample_clut4_single_f64(clut, src[0], src[1], src[2], src[3]);
735 return Some((r, g, b));
736 }
737 let mut dst = [0.0f64; 3];
738 if cached.transform_f64.transform(&src, &mut dst).is_err() {
739 return None;
740 }
741 let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
742 Some((
743 dst[0].clamp(0.0, 1.0),
744 dst[1].clamp(0.0, 1.0),
745 dst[2].clamp(0.0, 1.0),
746 ))
747 }
748
749 fn ensure_reverse_cmyk_transform(&mut self) -> Option<()> {
754 if self.reverse_cmyk_f64.is_some() {
755 return Some(());
756 }
757 let hash = *self.default_cmyk_hash.as_ref()?;
758 let cmyk_profile = self.profiles.get(&hash)?.clone();
759 let intents = [
760 RenderingIntent::RelativeColorimetric,
761 RenderingIntent::Perceptual,
762 RenderingIntent::AbsoluteColorimetric,
763 RenderingIntent::Saturation,
764 ];
765 for &intent in &intents {
766 let options = TransformOptions {
767 rendering_intent: intent,
768 ..TransformOptions::default()
769 };
770 if let Ok(t) = self.srgb_profile.create_transform_f64(
771 Layout::Rgb,
772 &cmyk_profile,
773 Layout::Rgba,
774 options,
775 ) {
776 self.reverse_cmyk_f64 = Some(t);
777 return Some(());
778 }
779 }
780 None
781 }
782
783 pub fn prepare_reverse_cmyk(&mut self) {
789 let _ = self.ensure_reverse_cmyk_transform();
790 }
791
792 pub fn convert_rgb_to_cmyk_readonly(&self, r: f64, g: f64, b: f64) -> Option<[f64; 4]> {
799 let reverse = self.reverse_cmyk_f64.as_ref()?;
800 let src_rgb = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
801 let mut cmyk = [0.0f64; 4];
802 reverse.transform(&src_rgb, &mut cmyk).ok()?;
803 Some([
804 cmyk[0].clamp(0.0, 1.0),
805 cmyk[1].clamp(0.0, 1.0),
806 cmyk[2].clamp(0.0, 1.0),
807 cmyk[3].clamp(0.0, 1.0),
808 ])
809 }
810
811 pub fn round_trip_rgb_via_cmyk(&mut self, r: f64, g: f64, b: f64) -> Option<(f64, f64, f64)> {
816 self.ensure_reverse_cmyk_transform()?;
817 let hash = *self.default_cmyk_hash.as_ref()?;
818 let reverse = self.reverse_cmyk_f64.as_ref()?;
819
820 let src_rgb = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
822 let mut cmyk = [0.0f64; 4];
823 reverse.transform(&src_rgb, &mut cmyk).ok()?;
824
825 let forward = self.transforms.get(&hash)?;
827 let mut dst = [0.0f64; 3];
828 forward.transform_f64.transform(&cmyk, &mut dst).ok()?;
829
830 let dst = bpc_post_correct(dst, forward.bpc_params.as_ref());
831 Some((
832 dst[0].clamp(0.0, 1.0),
833 dst[1].clamp(0.0, 1.0),
834 dst[2].clamp(0.0, 1.0),
835 ))
836 }
837
838 pub fn disable(&mut self) {
841 self.profiles.clear();
842 self.transforms.clear();
843 self.color_cache.clear();
844 self.raw_bytes.clear();
845 self.default_cmyk_hash = None;
846 self.system_cmyk_bytes = None;
847 self.reverse_cmyk_f64 = None;
848 }
849}
850
851fn bake_clut4(
861 transform: &(dyn TransformExecutor<u8> + Send + Sync),
862 grid_n: u8,
863 bpc_params: Option<&BpcParams>,
864) -> Option<Clut4> {
865 let n = grid_n as usize;
866 if !(2..=33).contains(&n) {
867 return None;
868 }
869 let total = n * n * n * n;
870 let mut src = Vec::with_capacity(total * 4);
873 let step = |i: usize| -> u8 {
874 ((i as u32 * 255) / (n as u32 - 1)) as u8
876 };
877 for k in 0..n {
878 let kv = step(k);
879 for y in 0..n {
880 let yv = step(y);
881 for m in 0..n {
882 let mv = step(m);
883 for c in 0..n {
884 let cv = step(c);
885 src.extend_from_slice(&[cv, mv, yv, kv]);
886 }
887 }
888 }
889 }
890 let mut dst = vec![0u8; total * 3];
891 transform.transform(&src, &mut dst).ok()?;
892
893 if let Some(p) = bpc_params {
896 for px in dst.chunks_exact_mut(3) {
897 let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
898 px[0] = out[0];
899 px[1] = out[1];
900 px[2] = out[2];
901 }
902 }
903
904 Some(Clut4 {
905 grid_n,
906 data: Arc::new(dst),
907 })
908}
909
910fn sample_clut4_single_f64(clut: &Clut4, c: f64, m: f64, y: f64, k: f64) -> (f64, f64, f64) {
916 let n = clut.grid_n as usize;
917 let nm1 = (n - 1) as f64;
918 let lut = clut.data.as_slice();
919 let stride_c: usize = 3;
920 let stride_m: usize = n * stride_c;
921 let stride_y: usize = n * stride_m;
922 let stride_k: usize = n * stride_y;
923
924 #[inline]
925 fn axis(v: f64, nm1: f64, n: usize) -> (usize, usize, f64) {
926 let scaled = v.clamp(0.0, 1.0) * nm1;
927 let lo = scaled.floor();
928 let frac = scaled - lo;
929 let lo_i = lo as usize;
930 let hi_i = (lo_i + 1).min(n - 1);
931 (lo_i, hi_i, frac)
932 }
933
934 let (ci, ci1, fc) = axis(c, nm1, n);
935 let (mi, mi1, fm) = axis(m, nm1, n);
936 let (yi, yi1, fy) = axis(y, nm1, n);
937 let (ki, ki1, fk) = axis(k, nm1, n);
938
939 let (a_dxmy, b_dxmy, w1, w2, w3) = if fc >= fm {
941 if fm >= fy {
942 ((1, 0, 0), (1, 1, 0), fc, fm, fy)
943 } else if fc >= fy {
944 ((1, 0, 0), (1, 0, 1), fc, fy, fm)
945 } else {
946 ((0, 0, 1), (1, 0, 1), fy, fc, fm)
947 }
948 } else if fc >= fy {
949 ((0, 1, 0), (1, 1, 0), fm, fc, fy)
950 } else if fm >= fy {
951 ((0, 1, 0), (0, 1, 1), fm, fy, fc)
952 } else {
953 ((0, 0, 1), (0, 1, 1), fy, fm, fc)
954 };
955
956 let corner = |d: (u8, u8, u8)| -> usize {
957 let (dc, dm, dy) = d;
958 let cx = if dc == 0 { ci } else { ci1 };
959 let mx = if dm == 0 { mi } else { mi1 };
960 let yx = if dy == 0 { yi } else { yi1 };
961 yx * stride_y + mx * stride_m + cx * stride_c
962 };
963
964 let o000 = corner((0, 0, 0));
965 let o111 = corner((1, 1, 1));
966 let oa = corner(a_dxmy);
967 let ob = corner(b_dxmy);
968
969 let base_lo = ki * stride_k;
970 let base_hi = ki1 * stride_k;
971
972 let tetra_channel = |base: usize, ch: usize| -> f64 {
973 let v000 = lut[base + o000 + ch] as f64;
974 let va = lut[base + oa + ch] as f64;
975 let vb = lut[base + ob + ch] as f64;
976 let v111 = lut[base + o111 + ch] as f64;
977 v000 + (va - v000) * w1 + (vb - va) * w2 + (v111 - vb) * w3
978 };
979
980 let r_lo = tetra_channel(base_lo, 0);
981 let g_lo = tetra_channel(base_lo, 1);
982 let b_lo = tetra_channel(base_lo, 2);
983 let (r_hi, g_hi, b_hi) = if ki == ki1 {
984 (r_lo, g_lo, b_lo)
985 } else {
986 (
987 tetra_channel(base_hi, 0),
988 tetra_channel(base_hi, 1),
989 tetra_channel(base_hi, 2),
990 )
991 };
992
993 let inv_fk = 1.0 - fk;
994 let r = (r_lo * inv_fk + r_hi * fk) / 255.0;
995 let g = (g_lo * inv_fk + g_hi * fk) / 255.0;
996 let b = (b_lo * inv_fk + b_hi * fk) / 255.0;
997 (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0))
998}
999
1000fn apply_clut4_cmyk_to_rgb(clut: &Clut4, src: &[u8], pixel_count: usize) -> Vec<u8> {
1008 let n = clut.grid_n as usize;
1009 let nm1 = (n - 1) as u32;
1010 let lut = clut.data.as_slice();
1011
1012 let stride_c: usize = 3;
1014 let stride_m: usize = n * stride_c;
1015 let stride_y: usize = n * stride_m;
1016 let stride_k: usize = n * stride_y;
1017
1018 let mut out = vec![0u8; pixel_count * 3];
1019
1020 #[inline(always)]
1022 fn axis(v: u8, nm1: u32) -> (usize, usize, u32) {
1023 let scaled = v as u32 * nm1;
1024 let lo = scaled / 255;
1025 let frac = scaled - lo * 255;
1026 let hi = if lo < nm1 { lo + 1 } else { lo };
1027 (lo as usize, hi as usize, frac)
1028 }
1029
1030 for i in 0..pixel_count {
1031 let o = i * 4;
1032 let c = src[o];
1033 let m = src[o + 1];
1034 let y = src[o + 2];
1035 let k = src[o + 3];
1036
1037 let (ci, ci1, fc) = axis(c, nm1);
1038 let (mi, mi1, fm) = axis(m, nm1);
1039 let (yi, yi1, fy) = axis(y, nm1);
1040 let (ki, ki1, fk) = axis(k, nm1);
1041
1042 let (a_dxmy, b_dxmy, w1, w2, w3) = if fc >= fm {
1047 if fm >= fy {
1048 ((1, 0, 0), (1, 1, 0), fc, fm, fy)
1050 } else if fc >= fy {
1051 ((1, 0, 0), (1, 0, 1), fc, fy, fm)
1053 } else {
1054 ((0, 0, 1), (1, 0, 1), fy, fc, fm)
1056 }
1057 } else if fc >= fy {
1058 ((0, 1, 0), (1, 1, 0), fm, fc, fy)
1060 } else if fm >= fy {
1061 ((0, 1, 0), (0, 1, 1), fm, fy, fc)
1063 } else {
1064 ((0, 0, 1), (0, 1, 1), fy, fm, fc)
1066 };
1067
1068 let corner = |d: (u8, u8, u8)| -> usize {
1070 let (dc, dm, dy) = d;
1071 let cx = if dc == 0 { ci } else { ci1 };
1072 let mx = if dm == 0 { mi } else { mi1 };
1073 let yx = if dy == 0 { yi } else { yi1 };
1074 yx * stride_y + mx * stride_m + cx * stride_c
1075 };
1076
1077 let o000 = corner((0, 0, 0));
1078 let o111 = corner((1, 1, 1));
1079 let oa = corner(a_dxmy);
1080 let ob = corner(b_dxmy);
1081
1082 let base_lo = ki * stride_k;
1084 let base_hi = ki1 * stride_k;
1085
1086 let tetra_channel = |base: usize, ch: usize| -> i32 {
1090 let v000 = lut[base + o000 + ch] as i32;
1091 let va = lut[base + oa + ch] as i32;
1092 let vb = lut[base + ob + ch] as i32;
1093 let v111 = lut[base + o111 + ch] as i32;
1094 v000 * 255 + (va - v000) * w1 as i32 + (vb - va) * w2 as i32 + (v111 - vb) * w3 as i32
1095 };
1096
1097 let r_lo = tetra_channel(base_lo, 0);
1098 let g_lo = tetra_channel(base_lo, 1);
1099 let b_lo = tetra_channel(base_lo, 2);
1100 let (r_hi, g_hi, b_hi) = if ki == ki1 {
1101 (r_lo, g_lo, b_lo)
1102 } else {
1103 (
1104 tetra_channel(base_hi, 0),
1105 tetra_channel(base_hi, 1),
1106 tetra_channel(base_hi, 2),
1107 )
1108 };
1109
1110 let inv_fk = (255 - fk) as i32;
1112 let fk_i = fk as i32;
1113 let round = 255 * 255 / 2;
1114 let finish = |lo: i32, hi: i32| -> u8 {
1115 let combined = lo * inv_fk + hi * fk_i + round;
1116 let v = combined / (255 * 255);
1117 v.clamp(0, 255) as u8
1118 };
1119
1120 let di = i * 3;
1121 out[di] = finish(r_lo, r_hi);
1122 out[di + 1] = finish(g_lo, g_hi);
1123 out[di + 2] = finish(b_lo, b_hi);
1124 }
1125
1126 out
1127}
1128
1129fn verify_clut4(
1134 clut: &Clut4,
1135 transform: &(dyn TransformExecutor<u8> + Send + Sync),
1136 bpc_params: Option<&BpcParams>,
1137) {
1138 const N_SAMPLES: usize = 4096;
1139 let mut rng: u64 = 0xa8b3c4d5e6f70819;
1140 let mut next = || {
1141 rng = rng
1142 .wrapping_mul(6364136223846793005)
1143 .wrapping_add(1442695040888963407);
1144 rng
1145 };
1146 let mut cmyk = Vec::with_capacity(N_SAMPLES * 4);
1147 for _ in 0..N_SAMPLES {
1148 let r = next();
1149 cmyk.extend_from_slice(&[
1150 (r & 0xff) as u8,
1151 ((r >> 8) & 0xff) as u8,
1152 ((r >> 16) & 0xff) as u8,
1153 ((r >> 24) & 0xff) as u8,
1154 ]);
1155 }
1156 let mut reference = vec![0u8; N_SAMPLES * 3];
1157 if transform.transform(&cmyk, &mut reference).is_err() {
1158 eprintln!("[ICC VERIFY] reference transform failed");
1159 return;
1160 }
1161 if let Some(p) = bpc_params {
1164 for px in reference.chunks_exact_mut(3) {
1165 let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
1166 px[0] = out[0];
1167 px[1] = out[1];
1168 px[2] = out[2];
1169 }
1170 }
1171 let interp = apply_clut4_cmyk_to_rgb(clut, &cmyk, N_SAMPLES);
1172 let mut dists: Vec<f64> = Vec::with_capacity(N_SAMPLES);
1174 let mut max_ch: u8 = 0;
1175 for i in 0..N_SAMPLES {
1176 let dr = interp[i * 3] as i32 - reference[i * 3] as i32;
1177 let dg = interp[i * 3 + 1] as i32 - reference[i * 3 + 1] as i32;
1178 let db = interp[i * 3 + 2] as i32 - reference[i * 3 + 2] as i32;
1179 let d = ((dr * dr + dg * dg + db * db) as f64).sqrt();
1180 dists.push(d);
1181 max_ch = max_ch
1182 .max(dr.unsigned_abs() as u8)
1183 .max(dg.unsigned_abs() as u8)
1184 .max(db.unsigned_abs() as u8);
1185 }
1186 dists.sort_by(|a, b| a.partial_cmp(b).unwrap());
1187 let median = dists[N_SAMPLES / 2];
1188 let p99 = dists[(N_SAMPLES * 99) / 100];
1189 let max = dists[N_SAMPLES - 1];
1190 eprintln!(
1191 "[ICC VERIFY] N=17 CLUT vs direct moxcms (sRGB u8): median={:.2}, p99={:.2}, max={:.2}, max_per_channel={}",
1192 median, p99, max, max_ch
1193 );
1194}
1195
1196pub fn find_system_cmyk_profile_bytes() -> Option<Arc<Vec<u8>>> {
1200 find_system_cmyk_profile().map(Arc::new)
1201}
1202
1203fn find_system_cmyk_profile() -> Option<Vec<u8>> {
1205 #[cfg(target_os = "linux")]
1206 {
1207 let paths = [
1208 "/usr/share/color/icc/ghostscript/default_cmyk.icc",
1209 "/usr/share/color/icc/ghostscript/ps_cmyk.icc",
1210 "/usr/share/color/icc/colord/FOGRA39L_coated.icc",
1211 ];
1212 for path in &paths {
1213 if let Ok(bytes) = std::fs::read(path) {
1214 return Some(bytes);
1215 }
1216 }
1217 if let Ok(entries) = glob::glob("/usr/share/color/icc/colord/SWOP*.icc") {
1219 for entry in entries.flatten() {
1220 if let Ok(bytes) = std::fs::read(&entry) {
1221 return Some(bytes);
1222 }
1223 }
1224 }
1225 }
1226
1227 #[cfg(target_os = "macos")]
1228 {
1229 let dirs = [
1230 "/Library/ColorSync/Profiles",
1231 "/System/Library/ColorSync/Profiles",
1232 ];
1233 if let Some(home) = std::env::var_os("HOME") {
1234 let home_dir = std::path::PathBuf::from(home).join("Library/ColorSync/Profiles");
1235 if let Some(bytes) = scan_dir_for_cmyk_icc(&home_dir) {
1236 return Some(bytes);
1237 }
1238 }
1239 for dir in &dirs {
1240 if let Some(bytes) = scan_dir_for_cmyk_icc(std::path::Path::new(dir)) {
1241 return Some(bytes);
1242 }
1243 }
1244 }
1245
1246 #[cfg(target_os = "windows")]
1247 {
1248 if let Some(sysroot) = std::env::var_os("SYSTEMROOT") {
1249 let dir = std::path::PathBuf::from(sysroot).join("System32/spool/drivers/color");
1250 if let Some(bytes) = scan_dir_for_cmyk_icc(&dir) {
1251 return Some(bytes);
1252 }
1253 }
1254 }
1255
1256 None
1257}
1258
1259#[cfg(any(target_os = "macos", target_os = "windows"))]
1261fn scan_dir_for_cmyk_icc(dir: &std::path::Path) -> Option<Vec<u8>> {
1262 let entries = std::fs::read_dir(dir).ok()?;
1263 for entry in entries.flatten() {
1264 let path = entry.path();
1265 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1266 if ext.eq_ignore_ascii_case("icc") || ext.eq_ignore_ascii_case("icm") {
1267 if let Ok(bytes) = std::fs::read(&path) {
1268 if bytes.len() >= 20 && &bytes[16..20] == b"CMYK" {
1270 return Some(bytes);
1271 }
1272 }
1273 }
1274 }
1275 None
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280 use super::*;
1281
1282 #[test]
1283 fn test_icc_cache_new() {
1284 let cache = IccCache::new();
1285 assert!(cache.default_cmyk_hash.is_none());
1286 assert!(cache.profiles.is_empty());
1287 assert_eq!(cache.bpc_mode(), BpcMode::Auto);
1288 }
1289
1290 #[test]
1291 fn test_icc_cache_options_default_matches_new() {
1292 let cache = IccCache::new_with_options(IccCacheOptions::default());
1293 assert_eq!(cache.bpc_mode(), BpcMode::Auto);
1294 assert!(cache.default_cmyk_hash.is_none());
1295 }
1296
1297 #[test]
1298 fn test_icc_cache_options_bpc_off() {
1299 let cache = IccCache::new_with_options(IccCacheOptions {
1300 bpc_mode: BpcMode::Off,
1301 source_cmyk_profile: None,
1302 });
1303 assert_eq!(cache.bpc_mode(), BpcMode::Off);
1304 assert!(!cache.bpc_mode().is_enabled());
1305 }
1306
1307 #[test]
1308 fn test_icc_cache_options_preloads_cmyk_profile() {
1309 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1311 return;
1312 };
1313 let cache = IccCache::new_with_options(IccCacheOptions {
1314 bpc_mode: BpcMode::On,
1315 source_cmyk_profile: Some(cmyk_bytes.clone()),
1316 });
1317 assert!(cache.default_cmyk_hash().is_some());
1318 assert_eq!(cache.bpc_mode(), BpcMode::On);
1319 }
1320
1321 #[test]
1322 fn test_bpc_darkens_pure_k_per_color() {
1323 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1325 return;
1326 };
1327
1328 let mut off = IccCache::new_with_options(IccCacheOptions {
1331 bpc_mode: BpcMode::Off,
1332 source_cmyk_profile: Some(cmyk_bytes.clone()),
1333 });
1334 let off_rgb = off.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1335
1336 let mut on = IccCache::new_with_options(IccCacheOptions {
1342 bpc_mode: BpcMode::On,
1343 source_cmyk_profile: Some(cmyk_bytes),
1344 });
1345 let on_rgb = on.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1346
1347 if (on_rgb.1 - off_rgb.1).abs() < 0.005 {
1354 eprintln!(
1355 "Skipping: system CMYK profile's black point is already ~zero; \
1356 BPC is a no-op here. off={off_rgb:?} on={on_rgb:?}"
1357 );
1358 return;
1359 }
1360
1361 assert!(
1362 on_rgb.1 + 0.03 < off_rgb.1,
1363 "BPC should darken K=1 by ≥0.03 (~8 RGB levels): off={off_rgb:?} on={on_rgb:?}"
1364 );
1365 assert!(
1368 on_rgb.0 < 0.25 && on_rgb.1 < 0.25 && on_rgb.2 < 0.25,
1369 "Expected deep gray after BPC, got {on_rgb:?}"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_bpc_white_anchored_per_color() {
1375 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1377 return;
1378 };
1379 let mut cache = IccCache::new_with_options(IccCacheOptions {
1380 bpc_mode: BpcMode::On,
1381 source_cmyk_profile: Some(cmyk_bytes),
1382 });
1383 let (r, g, b) = cache.convert_cmyk(0.0, 0.0, 0.0, 0.0).unwrap();
1385 assert!(r > 0.99 && g > 0.99 && b > 0.99, "({r}, {g}, {b})");
1386 }
1387
1388 #[test]
1389 fn test_bpc_image_clut_path_darkens_pure_k() {
1390 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1392 return;
1393 };
1394
1395 let off = IccCache::new_with_options(IccCacheOptions {
1399 bpc_mode: BpcMode::Off,
1400 source_cmyk_profile: Some(cmyk_bytes.clone()),
1401 });
1402 let on = IccCache::new_with_options(IccCacheOptions {
1403 bpc_mode: BpcMode::On,
1404 source_cmyk_profile: Some(cmyk_bytes),
1405 });
1406 let off_hash = *off.default_cmyk_hash().unwrap();
1407 let on_hash = *on.default_cmyk_hash().unwrap();
1408
1409 let pixel = [0u8, 0, 0, 255]; let off_rgb = off.convert_image_8bit(&off_hash, &pixel, 1).unwrap();
1411 let on_rgb = on.convert_image_8bit(&on_hash, &pixel, 1).unwrap();
1412
1413 if (on_rgb[1] as i32 - off_rgb[1] as i32).abs() <= 1 {
1417 eprintln!(
1418 "Skipping: system CMYK profile's black point is already ~zero; \
1419 BPC is a no-op here. off={off_rgb:?} on={on_rgb:?}"
1420 );
1421 return;
1422 }
1423
1424 assert!(
1427 (on_rgb[1] as i32) + 8 < (off_rgb[1] as i32),
1428 "CLUT BPC should darken K=1 image green by ≥8 levels: off={off_rgb:?} on={on_rgb:?}"
1429 );
1430 assert!(
1432 on_rgb[0] < 64 && on_rgb[1] < 64 && on_rgb[2] < 64,
1433 "Expected deep gray after CLUT BPC, got {on_rgb:?}"
1434 );
1435 }
1436
1437 #[test]
1438 fn test_bpc_off_image_matches_per_color_off() {
1439 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1444 return;
1445 };
1446 let mut cache = IccCache::new_with_options(IccCacheOptions {
1447 bpc_mode: BpcMode::Off,
1448 source_cmyk_profile: Some(cmyk_bytes),
1449 });
1450 let hash = *cache.default_cmyk_hash().unwrap();
1451
1452 let pixel = [0u8, 0, 0, 255];
1453 let img = cache.convert_image_8bit(&hash, &pixel, 1).unwrap();
1454 let (r, g, b) = cache.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1455 let pc = [
1456 (r * 255.0).round() as i32,
1457 (g * 255.0).round() as i32,
1458 (b * 255.0).round() as i32,
1459 ];
1460 assert!((img[0] as i32 - pc[0]).abs() <= 2, "img={img:?} pc={pc:?}");
1462 assert!((img[1] as i32 - pc[1]).abs() <= 2, "img={img:?} pc={pc:?}");
1463 assert!((img[2] as i32 - pc[2]).abs() <= 2, "img={img:?} pc={pc:?}");
1464 }
1465
1466 #[test]
1467 fn test_register_invalid_profile() {
1468 let mut cache = IccCache::new();
1469 assert!(cache.register_profile(b"not a valid ICC profile").is_none());
1470 }
1471
1472 #[test]
1473 fn test_srgb_identity_transform() {
1474 let srgb = ColorProfile::new_srgb();
1476 let bytes = srgb.encode().unwrap();
1477 let mut cache = IccCache::new();
1478 let hash = cache.register_profile(&bytes).unwrap();
1479
1480 let (r, g, b) = cache.convert_color(&hash, &[1.0, 0.0, 0.0]).unwrap();
1482 assert!((r - 1.0).abs() < 0.02, "r={r}");
1483 assert!(g < 0.02, "g={g}");
1484 assert!(b < 0.02, "b={b}");
1485
1486 let (r, g, b) = cache.convert_color(&hash, &[1.0, 1.0, 1.0]).unwrap();
1488 assert!((r - 1.0).abs() < 0.02);
1489 assert!((g - 1.0).abs() < 0.02);
1490 assert!((b - 1.0).abs() < 0.02);
1491 }
1492
1493 #[test]
1494 fn test_srgb_image_transform() {
1495 let srgb = ColorProfile::new_srgb();
1496 let bytes = srgb.encode().unwrap();
1497 let mut cache = IccCache::new();
1498 let hash = cache.register_profile(&bytes).unwrap();
1499
1500 let src = [255u8, 0, 0, 0, 255, 0];
1502 let result = cache.convert_image_8bit(&hash, &src, 2).unwrap();
1503 assert_eq!(result.len(), 6);
1504 assert!(result[0] > 240);
1506 assert!(result[1] < 15);
1507 assert!(result[2] < 15);
1508 }
1509
1510 #[test]
1511 fn test_convert_rgb_to_cmyk_readonly() {
1512 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1514 return;
1515 };
1516 let mut cache = IccCache::new();
1517 let hash = cache.register_profile(&cmyk_bytes).unwrap();
1518 cache.set_default_cmyk_hash(hash);
1519
1520 assert!(cache.convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0).is_none());
1522
1523 cache.prepare_reverse_cmyk();
1524
1525 let cmyk = cache
1528 .convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0)
1529 .expect("reverse transform should be available after prepare");
1530 assert!(
1531 cmyk[3] > 0.5,
1532 "expected K>0.5 for sRGB black, got cmyk={cmyk:?}"
1533 );
1534
1535 let cmyk = cache
1537 .convert_rgb_to_cmyk_readonly(1.0, 1.0, 1.0)
1538 .expect("reverse transform should be available");
1539 for (i, v) in cmyk.iter().enumerate() {
1540 assert!(*v < 0.05, "expected near-zero ink at chan {i}, got {v}");
1541 }
1542 }
1543
1544 #[test]
1548 fn test_perceptual_clut_white_anchor() {
1549 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1550 return;
1551 };
1552 let mut cache = IccCache::new_with_options(IccCacheOptions {
1553 bpc_mode: BpcMode::Off,
1554 source_cmyk_profile: Some(cmyk_bytes),
1555 });
1556 let rgb = cache.convert_cmyk(0.0, 0.0, 0.0, 0.0).unwrap();
1557 assert!(
1560 rgb.0 > 0.97 && rgb.1 > 0.97 && rgb.2 > 0.97,
1561 "CMYK white should map near sRGB white, got {rgb:?}"
1562 );
1563 }
1564
1565 #[test]
1569 fn test_perceptual_clut_single_matches_bulk() {
1570 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1571 return;
1572 };
1573 let mut cache = IccCache::new_with_options(IccCacheOptions {
1574 bpc_mode: BpcMode::On,
1575 source_cmyk_profile: Some(cmyk_bytes),
1576 });
1577 let hash = *cache.default_cmyk_hash().unwrap();
1578 let samples: &[(u8, u8, u8, u8)] = &[
1580 (0, 0, 0, 0),
1581 (255, 0, 0, 0),
1582 (0, 255, 0, 0),
1583 (0, 0, 255, 0),
1584 (0, 0, 0, 255),
1585 (38, 255, 255, 0), (128, 128, 128, 0),
1587 ];
1588 let pixels: Vec<u8> = samples
1589 .iter()
1590 .flat_map(|&(c, m, y, k)| [c, m, y, k])
1591 .collect();
1592 let bulk = cache
1593 .convert_image_8bit(&hash, &pixels, samples.len())
1594 .unwrap();
1595 for (i, &(c, m, y, k)) in samples.iter().enumerate() {
1596 let single = cache
1597 .convert_color(
1598 &hash,
1599 &[
1600 c as f64 / 255.0,
1601 m as f64 / 255.0,
1602 y as f64 / 255.0,
1603 k as f64 / 255.0,
1604 ],
1605 )
1606 .unwrap();
1607 let single_u8 = [
1608 (single.0 * 255.0).round() as i32,
1609 (single.1 * 255.0).round() as i32,
1610 (single.2 * 255.0).round() as i32,
1611 ];
1612 let b = &bulk[i * 3..i * 3 + 3];
1613 for ch in 0..3 {
1614 let delta = (single_u8[ch] - b[ch] as i32).abs();
1615 assert!(
1616 delta <= 2,
1617 "single vs bulk mismatch at sample {i} chan {ch}: \
1618 single={single_u8:?} bulk={:?} delta={delta}",
1619 [b[0], b[1], b[2]]
1620 );
1621 }
1622 }
1623 }
1624}