1pub mod bpc;
12
13use bpc::{
14 BpcParams, apply_bpc_f64, apply_bpc_rgb_u8, compute_bpc_params, detect_source_black_point,
15};
16use moxcms::{
17 CmsError, ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor,
18 TransformOptions,
19};
20use std::collections::HashMap;
21use std::sync::Arc;
22
23pub type ProfileHash = [u8; 32];
25
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum BpcMode {
34 Off,
38 On,
40 #[default]
44 Auto,
45}
46
47impl BpcMode {
48 #[inline]
50 pub fn is_enabled(self) -> bool {
51 matches!(self, BpcMode::On | BpcMode::Auto)
52 }
53}
54
55#[derive(Clone, Default)]
61pub struct IccCacheOptions {
62 pub bpc_mode: BpcMode,
64 pub source_cmyk_profile: Option<Vec<u8>>,
69}
70
71struct GrayToRgbIdentity;
74
75impl TransformExecutor<u8> for GrayToRgbIdentity {
76 fn transform(&self, src: &[u8], dst: &mut [u8]) -> Result<(), CmsError> {
77 for (g, rgb) in src.iter().zip(dst.chunks_exact_mut(3)) {
78 rgb[0] = *g;
79 rgb[1] = *g;
80 rgb[2] = *g;
81 }
82 Ok(())
83 }
84}
85
86impl TransformExecutor<f64> for GrayToRgbIdentity {
87 fn transform(&self, src: &[f64], dst: &mut [f64]) -> Result<(), CmsError> {
88 for (g, rgb) in src.iter().zip(dst.chunks_exact_mut(3)) {
89 rgb[0] = *g;
90 rgb[1] = *g;
91 rgb[2] = *g;
92 }
93 Ok(())
94 }
95}
96
97#[derive(Clone)]
105struct Clut4 {
106 grid_n: u8,
108 data: Arc<Vec<u8>>,
111}
112
113#[derive(Clone)]
115struct CachedTransform {
116 transform_8bit: Arc<dyn TransformExecutor<u8> + Send + Sync>,
118 transform_f64: Arc<dyn TransformExecutor<f64> + Send + Sync>,
120 n: u32,
122 is_lab: bool,
124 clut4: Option<Clut4>,
127 bpc_params: Option<BpcParams>,
132}
133
134#[derive(Clone)]
136pub struct IccCache {
137 profiles: HashMap<ProfileHash, Arc<ColorProfile>>,
139 transforms: HashMap<ProfileHash, CachedTransform>,
141 color_cache: HashMap<(u64, [u16; 4]), (f64, f64, f64)>,
144 default_cmyk_hash: Option<ProfileHash>,
146 system_cmyk_bytes: Option<Arc<Vec<u8>>>,
148 raw_bytes: HashMap<ProfileHash, Arc<Vec<u8>>>,
150 srgb_profile: ColorProfile,
152 reverse_cmyk_f64: Option<Arc<dyn TransformExecutor<f64> + Send + Sync>>,
154 bpc_mode: BpcMode,
158}
159
160impl Default for IccCache {
161 fn default() -> Self {
162 Self::new()
163 }
164}
165
166#[inline]
170fn bpc_post_correct(rgb: [f64; 3], params: Option<&BpcParams>) -> [f64; 3] {
171 match params {
172 Some(p) => apply_bpc_f64(rgb, p),
173 None => rgb,
174 }
175}
176
177impl IccCache {
178 pub fn new() -> Self {
181 Self::new_with_options(IccCacheOptions::default())
182 }
183
184 pub fn new_with_options(opts: IccCacheOptions) -> Self {
191 let mut cache = Self {
192 profiles: HashMap::new(),
193 transforms: HashMap::new(),
194 color_cache: HashMap::new(),
195 default_cmyk_hash: None,
196 system_cmyk_bytes: None,
197 raw_bytes: HashMap::new(),
198 srgb_profile: ColorProfile::new_srgb(),
199 reverse_cmyk_f64: None,
200 bpc_mode: opts.bpc_mode,
201 };
202 if let Some(bytes) = opts.source_cmyk_profile {
203 cache.load_cmyk_profile_bytes(&bytes);
204 }
205 cache
206 }
207
208 #[inline]
210 pub fn bpc_mode(&self) -> BpcMode {
211 self.bpc_mode
212 }
213
214 pub fn hash_profile(bytes: &[u8]) -> ProfileHash {
216 use sha2::{Digest, Sha256};
217 Sha256::digest(bytes).into()
218 }
219
220 pub fn register_profile(&mut self, bytes: &[u8]) -> Option<ProfileHash> {
222 self.register_profile_with_n(bytes, None)
223 }
224
225 pub fn register_profile_with_n(
231 &mut self,
232 bytes: &[u8],
233 expected_n: Option<u32>,
234 ) -> Option<ProfileHash> {
235 use sha2::{Digest, Sha256};
236 let hash: ProfileHash = Sha256::digest(bytes).into();
237
238 if self.transforms.contains_key(&hash) {
240 return Some(hash);
241 }
242
243 self.raw_bytes
245 .entry(hash)
246 .or_insert_with(|| Arc::new(bytes.to_vec()));
247
248 let profile = match ColorProfile::new_from_slice(bytes) {
249 Ok(p) => p,
250 Err(e) => {
251 eprintln!("[ICC] Failed to parse profile: {e}");
252 return None;
253 }
254 };
255
256 let n = match profile.color_space {
257 DataColorSpace::Gray => 1u32,
258 DataColorSpace::Rgb => 3,
259 DataColorSpace::Cmyk => 4,
260 DataColorSpace::Lab => 3,
261 _ => {
262 eprintln!(
263 "[ICC] Unsupported profile color space: {:?}",
264 profile.color_space
265 );
266 return None;
267 }
268 };
269
270 if let Some(expected) = expected_n {
274 if n != expected {
275 return None;
276 }
277 }
278
279 let (src_layout_8, src_layout_f64) = match n {
280 1 => (Layout::Gray, Layout::Gray),
281 3 => (Layout::Rgb, Layout::Rgb),
282 4 => (Layout::Rgba, Layout::Rgba),
283 _ => return None,
284 };
285
286 let dst_layout_8 = Layout::Rgb;
287 let dst_layout_f64 = Layout::Rgb;
288
289 let intents = [
291 RenderingIntent::RelativeColorimetric,
292 RenderingIntent::Perceptual,
293 RenderingIntent::AbsoluteColorimetric,
294 RenderingIntent::Saturation,
295 ];
296
297 let mut transform_8bit = None;
298 for &intent in &intents {
299 let options = TransformOptions {
300 rendering_intent: intent,
301 ..TransformOptions::default()
302 };
303 match profile.create_transform_8bit(
304 src_layout_8,
305 &self.srgb_profile,
306 dst_layout_8,
307 options,
308 ) {
309 Ok(t) => {
310 transform_8bit = Some(t);
311 break;
312 }
313 Err(_) => continue,
314 }
315 }
316 let transform_8bit = match transform_8bit {
317 Some(t) => t,
318 None if n == 1 => {
319 return self.register_gray_identity(hash, profile);
324 }
325 None => {
326 eprintln!(
327 "[ICC] Failed to create 8-bit transform (cs={:?})",
328 profile.color_space
329 );
330 return None;
331 }
332 };
333
334 let mut transform_f64 = None;
335 for &intent in &intents {
336 let options = TransformOptions {
337 rendering_intent: intent,
338 ..TransformOptions::default()
339 };
340 match profile.create_transform_f64(
341 src_layout_f64,
342 &self.srgb_profile,
343 dst_layout_f64,
344 options,
345 ) {
346 Ok(t) => {
347 transform_f64 = Some(t);
348 break;
349 }
350 Err(_) => continue,
351 }
352 }
353 let transform_f64 = match transform_f64 {
354 Some(t) => t,
355 None if n == 1 => {
356 return self.register_gray_identity(hash, profile);
358 }
359 None => {
360 eprintln!(
361 "[ICC] Failed to create f64 transform (cs={:?})",
362 profile.color_space
363 );
364 return None;
365 }
366 };
367
368 let is_lab = profile.color_space == DataColorSpace::Lab;
369
370 let bpc_params = if n == 4 && self.bpc_mode.is_enabled() {
377 detect_source_black_point(transform_8bit.as_ref())
378 .map(|sbp| compute_bpc_params(sbp, [0.0; 3], bpc::WP_D50))
379 } else {
380 None
381 };
382
383 let clut4 = if n == 4 {
389 let c = bake_clut4(transform_8bit.as_ref(), 17, bpc_params.as_ref());
390 if std::env::var_os("STET_ICC_VERIFY").is_some()
391 && let Some(ref clut) = c
392 {
393 verify_clut4(clut, transform_8bit.as_ref(), bpc_params.as_ref());
394 }
395 c
396 } else {
397 None
398 };
399
400 self.profiles.insert(hash, Arc::new(profile));
401 self.transforms.insert(
402 hash,
403 CachedTransform {
404 transform_8bit,
405 transform_f64,
406 n,
407 is_lab,
408 clut4,
409 bpc_params,
410 },
411 );
412
413 Some(hash)
414 }
415
416 fn register_gray_identity(
420 &mut self,
421 hash: ProfileHash,
422 profile: ColorProfile,
423 ) -> Option<ProfileHash> {
424 self.profiles.insert(hash, Arc::new(profile));
425 self.transforms.insert(
426 hash,
427 CachedTransform {
428 transform_8bit: Arc::new(GrayToRgbIdentity),
429 transform_f64: Arc::new(GrayToRgbIdentity),
430 n: 1,
431 is_lab: false,
432 clut4: None,
433 bpc_params: None,
434 },
435 );
436 Some(hash)
437 }
438
439 pub fn convert_color(
442 &mut self,
443 hash: &ProfileHash,
444 components: &[f64],
445 ) -> Option<(f64, f64, f64)> {
446 let cached = self.transforms.get(hash)?;
447 let n = cached.n as usize;
448 let is_lab = cached.is_lab;
449
450 let mut src = vec![0.0f64; n];
453 for (i, s) in src.iter_mut().enumerate() {
454 let v = components.get(i).copied().unwrap_or(0.0);
455 *s = if is_lab {
456 match i {
457 0 => (v / 100.0).clamp(0.0, 1.0),
458 _ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
459 }
460 } else {
461 v.clamp(0.0, 1.0)
462 };
463 }
464
465 let hash_prefix = u64::from_le_bytes(hash[..8].try_into().ok()?);
467 let mut quantized = [0u16; 4];
468 for (i, &c) in src.iter().take(4).enumerate() {
469 quantized[i] = (c * 65535.0).round() as u16;
470 }
471
472 let cache_key = (hash_prefix, quantized);
474 if let Some(&cached) = self.color_cache.get(&cache_key) {
475 return Some(cached);
476 }
477
478 let mut dst = [0.0f64; 3];
479 if cached.transform_f64.transform(&src, &mut dst).is_err() {
480 return None;
481 }
482
483 let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
484 let result = (
485 dst[0].clamp(0.0, 1.0),
486 dst[1].clamp(0.0, 1.0),
487 dst[2].clamp(0.0, 1.0),
488 );
489
490 if self.color_cache.len() < 65536 {
492 self.color_cache.insert(cache_key, result);
493 }
494
495 Some(result)
496 }
497
498 pub fn convert_color_readonly(
503 &self,
504 hash: &ProfileHash,
505 components: &[f64],
506 ) -> Option<(f64, f64, f64)> {
507 let cached = self.transforms.get(hash)?;
508 let n = cached.n as usize;
509 let is_lab = cached.is_lab;
510
511 let mut src = vec![0.0f64; n];
512 for (i, s) in src.iter_mut().enumerate() {
513 let v = components.get(i).copied().unwrap_or(0.0);
514 *s = if is_lab {
515 match i {
516 0 => (v / 100.0).clamp(0.0, 1.0),
517 _ => ((v + 128.0) / 255.0).clamp(0.0, 1.0),
518 }
519 } else {
520 v.clamp(0.0, 1.0)
521 };
522 }
523
524 let mut dst = [0.0f64; 3];
525 if cached.transform_f64.transform(&src, &mut dst).is_err() {
526 return None;
527 }
528
529 let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
530 Some((
531 dst[0].clamp(0.0, 1.0),
532 dst[1].clamp(0.0, 1.0),
533 dst[2].clamp(0.0, 1.0),
534 ))
535 }
536
537 pub fn convert_image_8bit(
541 &self,
542 hash: &ProfileHash,
543 samples: &[u8],
544 pixel_count: usize,
545 ) -> Option<Vec<u8>> {
546 let cached = self.transforms.get(hash)?;
547 let n = cached.n as usize;
548 let expected_len = pixel_count * n;
549 if samples.len() < expected_len {
550 return None;
551 }
552
553 if let Some(clut) = &cached.clut4 {
557 return Some(apply_clut4_cmyk_to_rgb(
558 clut,
559 &samples[..expected_len],
560 pixel_count,
561 ));
562 }
563
564 let src = &samples[..expected_len];
565 let mut dst = vec![0u8; pixel_count * 3];
566
567 match cached.transform_8bit.transform(src, &mut dst) {
568 Ok(()) => {
569 if let Some(p) = cached.bpc_params.as_ref() {
573 for px in dst.chunks_exact_mut(3) {
574 let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
575 px[0] = out[0];
576 px[1] = out[1];
577 px[2] = out[2];
578 }
579 }
580 Some(dst)
581 }
582 Err(e) => {
583 eprintln!("[ICC] Image transform failed: {e}");
584 None
585 }
586 }
587 }
588
589 pub fn search_system_cmyk_profile(&mut self) {
591 if let Some(bytes) = find_system_cmyk_profile()
592 && let Some(hash) = self.register_profile(&bytes)
593 {
594 eprintln!("[ICC] Loaded system CMYK profile");
595 self.system_cmyk_bytes = Some(Arc::new(bytes));
596 self.default_cmyk_hash = Some(hash);
597 }
598 }
599
600 pub fn load_cmyk_profile_bytes(&mut self, bytes: &[u8]) {
602 if let Some(hash) = self.register_profile(bytes) {
603 self.system_cmyk_bytes = Some(Arc::new(bytes.to_vec()));
604 self.default_cmyk_hash = Some(hash);
605 }
606 }
607
608 pub fn default_cmyk_hash(&self) -> Option<&ProfileHash> {
610 self.default_cmyk_hash.as_ref()
611 }
612
613 pub fn has_profile(&self, hash: &ProfileHash) -> bool {
615 self.transforms.contains_key(hash)
616 }
617
618 pub fn get_profile_bytes(&self, hash: &ProfileHash) -> Option<Arc<Vec<u8>>> {
620 self.raw_bytes.get(hash).cloned()
621 }
622
623 pub fn system_cmyk_bytes(&self) -> Option<&Arc<Vec<u8>>> {
625 self.system_cmyk_bytes.as_ref()
626 }
627
628 pub fn set_system_cmyk(&mut self, bytes: &[u8], hash: ProfileHash) {
633 self.system_cmyk_bytes = Some(Arc::new(bytes.to_vec()));
634 self.default_cmyk_hash = Some(hash);
635 }
636
637 pub fn set_default_cmyk_hash(&mut self, hash: ProfileHash) {
639 self.default_cmyk_hash = Some(hash);
640 }
641
642 pub fn suspend_default_cmyk(&mut self) -> Option<ProfileHash> {
647 self.default_cmyk_hash.take()
648 }
649
650 pub fn restore_default_cmyk(&mut self, hash: Option<ProfileHash>) {
652 self.default_cmyk_hash = hash;
653 }
654
655 #[inline]
658 pub fn convert_cmyk(&mut self, c: f64, m: f64, y: f64, k: f64) -> Option<(f64, f64, f64)> {
659 let hash = *self.default_cmyk_hash.as_ref()?;
660 self.convert_color(&hash, &[c, m, y, k])
661 }
662
663 pub fn convert_cmyk_readonly(&self, c: f64, m: f64, y: f64, k: f64) -> Option<(f64, f64, f64)> {
666 let hash = self.default_cmyk_hash.as_ref()?;
667 let cached = self.transforms.get(hash)?;
668 let src = [
669 c.clamp(0.0, 1.0),
670 m.clamp(0.0, 1.0),
671 y.clamp(0.0, 1.0),
672 k.clamp(0.0, 1.0),
673 ];
674 let mut dst = [0.0f64; 3];
675 if cached.transform_f64.transform(&src, &mut dst).is_err() {
676 return None;
677 }
678 let dst = bpc_post_correct(dst, cached.bpc_params.as_ref());
679 Some((
680 dst[0].clamp(0.0, 1.0),
681 dst[1].clamp(0.0, 1.0),
682 dst[2].clamp(0.0, 1.0),
683 ))
684 }
685
686 fn ensure_reverse_cmyk_transform(&mut self) -> Option<()> {
691 if self.reverse_cmyk_f64.is_some() {
692 return Some(());
693 }
694 let hash = *self.default_cmyk_hash.as_ref()?;
695 let cmyk_profile = self.profiles.get(&hash)?.clone();
696 let intents = [
697 RenderingIntent::RelativeColorimetric,
698 RenderingIntent::Perceptual,
699 RenderingIntent::AbsoluteColorimetric,
700 RenderingIntent::Saturation,
701 ];
702 for &intent in &intents {
703 let options = TransformOptions {
704 rendering_intent: intent,
705 ..TransformOptions::default()
706 };
707 if let Ok(t) = self.srgb_profile.create_transform_f64(
708 Layout::Rgb,
709 &cmyk_profile,
710 Layout::Rgba,
711 options,
712 ) {
713 self.reverse_cmyk_f64 = Some(t);
714 return Some(());
715 }
716 }
717 None
718 }
719
720 pub fn prepare_reverse_cmyk(&mut self) {
726 let _ = self.ensure_reverse_cmyk_transform();
727 }
728
729 pub fn convert_rgb_to_cmyk_readonly(&self, r: f64, g: f64, b: f64) -> Option<[f64; 4]> {
736 let reverse = self.reverse_cmyk_f64.as_ref()?;
737 let src_rgb = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
738 let mut cmyk = [0.0f64; 4];
739 reverse.transform(&src_rgb, &mut cmyk).ok()?;
740 Some([
741 cmyk[0].clamp(0.0, 1.0),
742 cmyk[1].clamp(0.0, 1.0),
743 cmyk[2].clamp(0.0, 1.0),
744 cmyk[3].clamp(0.0, 1.0),
745 ])
746 }
747
748 pub fn round_trip_rgb_via_cmyk(&mut self, r: f64, g: f64, b: f64) -> Option<(f64, f64, f64)> {
753 self.ensure_reverse_cmyk_transform()?;
754 let hash = *self.default_cmyk_hash.as_ref()?;
755 let reverse = self.reverse_cmyk_f64.as_ref()?;
756
757 let src_rgb = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)];
759 let mut cmyk = [0.0f64; 4];
760 reverse.transform(&src_rgb, &mut cmyk).ok()?;
761
762 let forward = self.transforms.get(&hash)?;
764 let mut dst = [0.0f64; 3];
765 forward.transform_f64.transform(&cmyk, &mut dst).ok()?;
766
767 let dst = bpc_post_correct(dst, forward.bpc_params.as_ref());
768 Some((
769 dst[0].clamp(0.0, 1.0),
770 dst[1].clamp(0.0, 1.0),
771 dst[2].clamp(0.0, 1.0),
772 ))
773 }
774
775 pub fn disable(&mut self) {
778 self.profiles.clear();
779 self.transforms.clear();
780 self.color_cache.clear();
781 self.raw_bytes.clear();
782 self.default_cmyk_hash = None;
783 self.system_cmyk_bytes = None;
784 self.reverse_cmyk_f64 = None;
785 }
786}
787
788fn bake_clut4(
798 transform: &(dyn TransformExecutor<u8> + Send + Sync),
799 grid_n: u8,
800 bpc_params: Option<&BpcParams>,
801) -> Option<Clut4> {
802 let n = grid_n as usize;
803 if !(2..=33).contains(&n) {
804 return None;
805 }
806 let total = n * n * n * n;
807 let mut src = Vec::with_capacity(total * 4);
810 let step = |i: usize| -> u8 {
811 ((i as u32 * 255) / (n as u32 - 1)) as u8
813 };
814 for k in 0..n {
815 let kv = step(k);
816 for y in 0..n {
817 let yv = step(y);
818 for m in 0..n {
819 let mv = step(m);
820 for c in 0..n {
821 let cv = step(c);
822 src.extend_from_slice(&[cv, mv, yv, kv]);
823 }
824 }
825 }
826 }
827 let mut dst = vec![0u8; total * 3];
828 transform.transform(&src, &mut dst).ok()?;
829
830 if let Some(p) = bpc_params {
833 for px in dst.chunks_exact_mut(3) {
834 let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
835 px[0] = out[0];
836 px[1] = out[1];
837 px[2] = out[2];
838 }
839 }
840
841 Some(Clut4 {
842 grid_n,
843 data: Arc::new(dst),
844 })
845}
846
847fn apply_clut4_cmyk_to_rgb(clut: &Clut4, src: &[u8], pixel_count: usize) -> Vec<u8> {
855 let n = clut.grid_n as usize;
856 let nm1 = (n - 1) as u32;
857 let lut = clut.data.as_slice();
858
859 let stride_c: usize = 3;
861 let stride_m: usize = n * stride_c;
862 let stride_y: usize = n * stride_m;
863 let stride_k: usize = n * stride_y;
864
865 let mut out = vec![0u8; pixel_count * 3];
866
867 #[inline(always)]
869 fn axis(v: u8, nm1: u32) -> (usize, usize, u32) {
870 let scaled = v as u32 * nm1;
871 let lo = scaled / 255;
872 let frac = scaled - lo * 255;
873 let hi = if lo < nm1 { lo + 1 } else { lo };
874 (lo as usize, hi as usize, frac)
875 }
876
877 for i in 0..pixel_count {
878 let o = i * 4;
879 let c = src[o];
880 let m = src[o + 1];
881 let y = src[o + 2];
882 let k = src[o + 3];
883
884 let (ci, ci1, fc) = axis(c, nm1);
885 let (mi, mi1, fm) = axis(m, nm1);
886 let (yi, yi1, fy) = axis(y, nm1);
887 let (ki, ki1, fk) = axis(k, nm1);
888
889 let (a_dxmy, b_dxmy, w1, w2, w3) = if fc >= fm {
894 if fm >= fy {
895 ((1, 0, 0), (1, 1, 0), fc, fm, fy)
897 } else if fc >= fy {
898 ((1, 0, 0), (1, 0, 1), fc, fy, fm)
900 } else {
901 ((0, 0, 1), (1, 0, 1), fy, fc, fm)
903 }
904 } else if fc >= fy {
905 ((0, 1, 0), (1, 1, 0), fm, fc, fy)
907 } else if fm >= fy {
908 ((0, 1, 0), (0, 1, 1), fm, fy, fc)
910 } else {
911 ((0, 0, 1), (0, 1, 1), fy, fm, fc)
913 };
914
915 let corner = |d: (u8, u8, u8)| -> usize {
917 let (dc, dm, dy) = d;
918 let cx = if dc == 0 { ci } else { ci1 };
919 let mx = if dm == 0 { mi } else { mi1 };
920 let yx = if dy == 0 { yi } else { yi1 };
921 yx * stride_y + mx * stride_m + cx * stride_c
922 };
923
924 let o000 = corner((0, 0, 0));
925 let o111 = corner((1, 1, 1));
926 let oa = corner(a_dxmy);
927 let ob = corner(b_dxmy);
928
929 let base_lo = ki * stride_k;
931 let base_hi = ki1 * stride_k;
932
933 let tetra_channel = |base: usize, ch: usize| -> i32 {
937 let v000 = lut[base + o000 + ch] as i32;
938 let va = lut[base + oa + ch] as i32;
939 let vb = lut[base + ob + ch] as i32;
940 let v111 = lut[base + o111 + ch] as i32;
941 v000 * 255 + (va - v000) * w1 as i32 + (vb - va) * w2 as i32 + (v111 - vb) * w3 as i32
942 };
943
944 let r_lo = tetra_channel(base_lo, 0);
945 let g_lo = tetra_channel(base_lo, 1);
946 let b_lo = tetra_channel(base_lo, 2);
947 let (r_hi, g_hi, b_hi) = if ki == ki1 {
948 (r_lo, g_lo, b_lo)
949 } else {
950 (
951 tetra_channel(base_hi, 0),
952 tetra_channel(base_hi, 1),
953 tetra_channel(base_hi, 2),
954 )
955 };
956
957 let inv_fk = (255 - fk) as i32;
959 let fk_i = fk as i32;
960 let round = 255 * 255 / 2;
961 let finish = |lo: i32, hi: i32| -> u8 {
962 let combined = lo * inv_fk + hi * fk_i + round;
963 let v = combined / (255 * 255);
964 v.clamp(0, 255) as u8
965 };
966
967 let di = i * 3;
968 out[di] = finish(r_lo, r_hi);
969 out[di + 1] = finish(g_lo, g_hi);
970 out[di + 2] = finish(b_lo, b_hi);
971 }
972
973 out
974}
975
976fn verify_clut4(
981 clut: &Clut4,
982 transform: &(dyn TransformExecutor<u8> + Send + Sync),
983 bpc_params: Option<&BpcParams>,
984) {
985 const N_SAMPLES: usize = 4096;
986 let mut rng: u64 = 0xa8b3c4d5e6f70819;
987 let mut next = || {
988 rng = rng
989 .wrapping_mul(6364136223846793005)
990 .wrapping_add(1442695040888963407);
991 rng
992 };
993 let mut cmyk = Vec::with_capacity(N_SAMPLES * 4);
994 for _ in 0..N_SAMPLES {
995 let r = next();
996 cmyk.extend_from_slice(&[
997 (r & 0xff) as u8,
998 ((r >> 8) & 0xff) as u8,
999 ((r >> 16) & 0xff) as u8,
1000 ((r >> 24) & 0xff) as u8,
1001 ]);
1002 }
1003 let mut reference = vec![0u8; N_SAMPLES * 3];
1004 if transform.transform(&cmyk, &mut reference).is_err() {
1005 eprintln!("[ICC VERIFY] reference transform failed");
1006 return;
1007 }
1008 if let Some(p) = bpc_params {
1011 for px in reference.chunks_exact_mut(3) {
1012 let out = apply_bpc_rgb_u8([px[0], px[1], px[2]], p);
1013 px[0] = out[0];
1014 px[1] = out[1];
1015 px[2] = out[2];
1016 }
1017 }
1018 let interp = apply_clut4_cmyk_to_rgb(clut, &cmyk, N_SAMPLES);
1019 let mut dists: Vec<f64> = Vec::with_capacity(N_SAMPLES);
1021 let mut max_ch: u8 = 0;
1022 for i in 0..N_SAMPLES {
1023 let dr = interp[i * 3] as i32 - reference[i * 3] as i32;
1024 let dg = interp[i * 3 + 1] as i32 - reference[i * 3 + 1] as i32;
1025 let db = interp[i * 3 + 2] as i32 - reference[i * 3 + 2] as i32;
1026 let d = ((dr * dr + dg * dg + db * db) as f64).sqrt();
1027 dists.push(d);
1028 max_ch = max_ch
1029 .max(dr.unsigned_abs() as u8)
1030 .max(dg.unsigned_abs() as u8)
1031 .max(db.unsigned_abs() as u8);
1032 }
1033 dists.sort_by(|a, b| a.partial_cmp(b).unwrap());
1034 let median = dists[N_SAMPLES / 2];
1035 let p99 = dists[(N_SAMPLES * 99) / 100];
1036 let max = dists[N_SAMPLES - 1];
1037 eprintln!(
1038 "[ICC VERIFY] N=17 CLUT vs direct moxcms (sRGB u8): median={:.2}, p99={:.2}, max={:.2}, max_per_channel={}",
1039 median, p99, max, max_ch
1040 );
1041}
1042
1043pub fn find_system_cmyk_profile_bytes() -> Option<Arc<Vec<u8>>> {
1047 find_system_cmyk_profile().map(Arc::new)
1048}
1049
1050fn find_system_cmyk_profile() -> Option<Vec<u8>> {
1052 #[cfg(target_os = "linux")]
1053 {
1054 let paths = [
1055 "/usr/share/color/icc/ghostscript/default_cmyk.icc",
1056 "/usr/share/color/icc/ghostscript/ps_cmyk.icc",
1057 "/usr/share/color/icc/colord/FOGRA39L_coated.icc",
1058 ];
1059 for path in &paths {
1060 if let Ok(bytes) = std::fs::read(path) {
1061 return Some(bytes);
1062 }
1063 }
1064 if let Ok(entries) = glob::glob("/usr/share/color/icc/colord/SWOP*.icc") {
1066 for entry in entries.flatten() {
1067 if let Ok(bytes) = std::fs::read(&entry) {
1068 return Some(bytes);
1069 }
1070 }
1071 }
1072 }
1073
1074 #[cfg(target_os = "macos")]
1075 {
1076 let dirs = [
1077 "/Library/ColorSync/Profiles",
1078 "/System/Library/ColorSync/Profiles",
1079 ];
1080 if let Some(home) = std::env::var_os("HOME") {
1081 let home_dir = std::path::PathBuf::from(home).join("Library/ColorSync/Profiles");
1082 if let Some(bytes) = scan_dir_for_cmyk_icc(&home_dir) {
1083 return Some(bytes);
1084 }
1085 }
1086 for dir in &dirs {
1087 if let Some(bytes) = scan_dir_for_cmyk_icc(std::path::Path::new(dir)) {
1088 return Some(bytes);
1089 }
1090 }
1091 }
1092
1093 #[cfg(target_os = "windows")]
1094 {
1095 if let Some(sysroot) = std::env::var_os("SYSTEMROOT") {
1096 let dir = std::path::PathBuf::from(sysroot).join("System32/spool/drivers/color");
1097 if let Some(bytes) = scan_dir_for_cmyk_icc(&dir) {
1098 return Some(bytes);
1099 }
1100 }
1101 }
1102
1103 None
1104}
1105
1106#[cfg(any(target_os = "macos", target_os = "windows"))]
1108fn scan_dir_for_cmyk_icc(dir: &std::path::Path) -> Option<Vec<u8>> {
1109 let entries = std::fs::read_dir(dir).ok()?;
1110 for entry in entries.flatten() {
1111 let path = entry.path();
1112 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1113 if ext.eq_ignore_ascii_case("icc") || ext.eq_ignore_ascii_case("icm") {
1114 if let Ok(bytes) = std::fs::read(&path) {
1115 if bytes.len() >= 20 && &bytes[16..20] == b"CMYK" {
1117 return Some(bytes);
1118 }
1119 }
1120 }
1121 }
1122 None
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use super::*;
1128
1129 #[test]
1130 fn test_icc_cache_new() {
1131 let cache = IccCache::new();
1132 assert!(cache.default_cmyk_hash.is_none());
1133 assert!(cache.profiles.is_empty());
1134 assert_eq!(cache.bpc_mode(), BpcMode::Auto);
1135 }
1136
1137 #[test]
1138 fn test_icc_cache_options_default_matches_new() {
1139 let cache = IccCache::new_with_options(IccCacheOptions::default());
1140 assert_eq!(cache.bpc_mode(), BpcMode::Auto);
1141 assert!(cache.default_cmyk_hash.is_none());
1142 }
1143
1144 #[test]
1145 fn test_icc_cache_options_bpc_off() {
1146 let cache = IccCache::new_with_options(IccCacheOptions {
1147 bpc_mode: BpcMode::Off,
1148 source_cmyk_profile: None,
1149 });
1150 assert_eq!(cache.bpc_mode(), BpcMode::Off);
1151 assert!(!cache.bpc_mode().is_enabled());
1152 }
1153
1154 #[test]
1155 fn test_icc_cache_options_preloads_cmyk_profile() {
1156 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1158 return;
1159 };
1160 let cache = IccCache::new_with_options(IccCacheOptions {
1161 bpc_mode: BpcMode::On,
1162 source_cmyk_profile: Some(cmyk_bytes.clone()),
1163 });
1164 assert!(cache.default_cmyk_hash().is_some());
1165 assert_eq!(cache.bpc_mode(), BpcMode::On);
1166 }
1167
1168 #[test]
1169 fn test_bpc_darkens_pure_k_per_color() {
1170 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1172 return;
1173 };
1174
1175 let mut off = IccCache::new_with_options(IccCacheOptions {
1178 bpc_mode: BpcMode::Off,
1179 source_cmyk_profile: Some(cmyk_bytes.clone()),
1180 });
1181 let off_rgb = off.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1182
1183 let mut on = IccCache::new_with_options(IccCacheOptions {
1189 bpc_mode: BpcMode::On,
1190 source_cmyk_profile: Some(cmyk_bytes),
1191 });
1192 let on_rgb = on.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1193
1194 if (on_rgb.1 - off_rgb.1).abs() < 0.005 {
1201 eprintln!(
1202 "Skipping: system CMYK profile's black point is already ~zero; \
1203 BPC is a no-op here. off={off_rgb:?} on={on_rgb:?}"
1204 );
1205 return;
1206 }
1207
1208 assert!(
1209 on_rgb.1 + 0.03 < off_rgb.1,
1210 "BPC should darken K=1 by ≥0.03 (~8 RGB levels): off={off_rgb:?} on={on_rgb:?}"
1211 );
1212 assert!(
1215 on_rgb.0 < 0.25 && on_rgb.1 < 0.25 && on_rgb.2 < 0.25,
1216 "Expected deep gray after BPC, got {on_rgb:?}"
1217 );
1218 }
1219
1220 #[test]
1221 fn test_bpc_white_anchored_per_color() {
1222 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1224 return;
1225 };
1226 let mut cache = IccCache::new_with_options(IccCacheOptions {
1227 bpc_mode: BpcMode::On,
1228 source_cmyk_profile: Some(cmyk_bytes),
1229 });
1230 let (r, g, b) = cache.convert_cmyk(0.0, 0.0, 0.0, 0.0).unwrap();
1232 assert!(r > 0.99 && g > 0.99 && b > 0.99, "({r}, {g}, {b})");
1233 }
1234
1235 #[test]
1236 fn test_bpc_image_clut_path_darkens_pure_k() {
1237 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1239 return;
1240 };
1241
1242 let off = IccCache::new_with_options(IccCacheOptions {
1246 bpc_mode: BpcMode::Off,
1247 source_cmyk_profile: Some(cmyk_bytes.clone()),
1248 });
1249 let on = IccCache::new_with_options(IccCacheOptions {
1250 bpc_mode: BpcMode::On,
1251 source_cmyk_profile: Some(cmyk_bytes),
1252 });
1253 let off_hash = *off.default_cmyk_hash().unwrap();
1254 let on_hash = *on.default_cmyk_hash().unwrap();
1255
1256 let pixel = [0u8, 0, 0, 255]; let off_rgb = off.convert_image_8bit(&off_hash, &pixel, 1).unwrap();
1258 let on_rgb = on.convert_image_8bit(&on_hash, &pixel, 1).unwrap();
1259
1260 if (on_rgb[1] as i32 - off_rgb[1] as i32).abs() <= 1 {
1264 eprintln!(
1265 "Skipping: system CMYK profile's black point is already ~zero; \
1266 BPC is a no-op here. off={off_rgb:?} on={on_rgb:?}"
1267 );
1268 return;
1269 }
1270
1271 assert!(
1274 (on_rgb[1] as i32) + 8 < (off_rgb[1] as i32),
1275 "CLUT BPC should darken K=1 image green by ≥8 levels: off={off_rgb:?} on={on_rgb:?}"
1276 );
1277 assert!(
1279 on_rgb[0] < 64 && on_rgb[1] < 64 && on_rgb[2] < 64,
1280 "Expected deep gray after CLUT BPC, got {on_rgb:?}"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_bpc_off_image_matches_per_color_off() {
1286 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1291 return;
1292 };
1293 let mut cache = IccCache::new_with_options(IccCacheOptions {
1294 bpc_mode: BpcMode::Off,
1295 source_cmyk_profile: Some(cmyk_bytes),
1296 });
1297 let hash = *cache.default_cmyk_hash().unwrap();
1298
1299 let pixel = [0u8, 0, 0, 255];
1300 let img = cache.convert_image_8bit(&hash, &pixel, 1).unwrap();
1301 let (r, g, b) = cache.convert_cmyk(0.0, 0.0, 0.0, 1.0).unwrap();
1302 let pc = [
1303 (r * 255.0).round() as i32,
1304 (g * 255.0).round() as i32,
1305 (b * 255.0).round() as i32,
1306 ];
1307 assert!((img[0] as i32 - pc[0]).abs() <= 2, "img={img:?} pc={pc:?}");
1309 assert!((img[1] as i32 - pc[1]).abs() <= 2, "img={img:?} pc={pc:?}");
1310 assert!((img[2] as i32 - pc[2]).abs() <= 2, "img={img:?} pc={pc:?}");
1311 }
1312
1313 #[test]
1314 fn test_register_invalid_profile() {
1315 let mut cache = IccCache::new();
1316 assert!(cache.register_profile(b"not a valid ICC profile").is_none());
1317 }
1318
1319 #[test]
1320 fn test_srgb_identity_transform() {
1321 let srgb = ColorProfile::new_srgb();
1323 let bytes = srgb.encode().unwrap();
1324 let mut cache = IccCache::new();
1325 let hash = cache.register_profile(&bytes).unwrap();
1326
1327 let (r, g, b) = cache.convert_color(&hash, &[1.0, 0.0, 0.0]).unwrap();
1329 assert!((r - 1.0).abs() < 0.02, "r={r}");
1330 assert!(g < 0.02, "g={g}");
1331 assert!(b < 0.02, "b={b}");
1332
1333 let (r, g, b) = cache.convert_color(&hash, &[1.0, 1.0, 1.0]).unwrap();
1335 assert!((r - 1.0).abs() < 0.02);
1336 assert!((g - 1.0).abs() < 0.02);
1337 assert!((b - 1.0).abs() < 0.02);
1338 }
1339
1340 #[test]
1341 fn test_srgb_image_transform() {
1342 let srgb = ColorProfile::new_srgb();
1343 let bytes = srgb.encode().unwrap();
1344 let mut cache = IccCache::new();
1345 let hash = cache.register_profile(&bytes).unwrap();
1346
1347 let src = [255u8, 0, 0, 0, 255, 0];
1349 let result = cache.convert_image_8bit(&hash, &src, 2).unwrap();
1350 assert_eq!(result.len(), 6);
1351 assert!(result[0] > 240);
1353 assert!(result[1] < 15);
1354 assert!(result[2] < 15);
1355 }
1356
1357 #[test]
1358 fn test_convert_rgb_to_cmyk_readonly() {
1359 let Some(cmyk_bytes) = find_system_cmyk_profile() else {
1361 return;
1362 };
1363 let mut cache = IccCache::new();
1364 let hash = cache.register_profile(&cmyk_bytes).unwrap();
1365 cache.set_default_cmyk_hash(hash);
1366
1367 assert!(cache.convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0).is_none());
1369
1370 cache.prepare_reverse_cmyk();
1371
1372 let cmyk = cache
1375 .convert_rgb_to_cmyk_readonly(0.0, 0.0, 0.0)
1376 .expect("reverse transform should be available after prepare");
1377 assert!(
1378 cmyk[3] > 0.5,
1379 "expected K>0.5 for sRGB black, got cmyk={cmyk:?}"
1380 );
1381
1382 let cmyk = cache
1384 .convert_rgb_to_cmyk_readonly(1.0, 1.0, 1.0)
1385 .expect("reverse transform should be available");
1386 for (i, v) in cmyk.iter().enumerate() {
1387 assert!(*v < 0.05, "expected near-zero ink at chan {i}, got {v}");
1388 }
1389 }
1390}