1use std::collections::HashMap;
10use std::fmt;
11use std::sync::Arc;
12
13use moxcms::{
14 ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor, TransformOptions,
15};
16use zpdf_core::{Error, ObjectId, Result};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
22pub enum RenderIntent {
23 Perceptual,
24 #[default]
25 RelativeColorimetric,
26 Saturation,
27 AbsoluteColorimetric,
28}
29
30impl RenderIntent {
31 pub fn from_pdf_name(name: &str) -> Self {
34 match name {
35 "Perceptual" => Self::Perceptual,
36 "Saturation" => Self::Saturation,
37 "AbsoluteColorimetric" => Self::AbsoluteColorimetric,
38 _ => Self::RelativeColorimetric,
39 }
40 }
41
42 fn to_moxcms(self) -> RenderingIntent {
43 match self {
44 Self::Perceptual => RenderingIntent::Perceptual,
45 Self::RelativeColorimetric => RenderingIntent::RelativeColorimetric,
46 Self::Saturation => RenderingIntent::Saturation,
47 Self::AbsoluteColorimetric => RenderingIntent::AbsoluteColorimetric,
48 }
49 }
50}
51
52pub struct IccTransform {
57 ncomp: u8,
58 executor: Arc<Transform8BitExecutor>,
59}
60
61impl fmt::Debug for IccTransform {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 f.debug_struct("IccTransform")
64 .field("ncomp", &self.ncomp)
65 .finish_non_exhaustive()
66 }
67}
68
69impl IccTransform {
70 pub fn from_profile_bytes(data: &[u8], intent: RenderIntent) -> Result<Self> {
76 let profile = ColorProfile::new_from_slice(data)
77 .map_err(|e| Error::StreamDecode(format!("ICC profile parse failed: {e:?}")))?;
78 let (layout, ncomp) = match profile.color_space {
79 DataColorSpace::Gray => (Layout::Gray, 1u8),
80 DataColorSpace::Rgb | DataColorSpace::Lab => (Layout::Rgb, 3),
83 DataColorSpace::Cmyk => (Layout::Rgba, 4),
85 other => {
86 return Err(Error::StreamDecode(format!(
87 "unsupported ICC data colour space {other:?}"
88 )))
89 }
90 };
91 let srgb = ColorProfile::new_srgb();
92 let executor = [
96 intent.to_moxcms(),
97 RenderingIntent::RelativeColorimetric,
98 RenderingIntent::Perceptual,
99 ]
100 .into_iter()
101 .find_map(|intent| {
102 profile
103 .create_transform_8bit(
104 layout,
105 &srgb,
106 Layout::Rgb,
107 TransformOptions {
108 rendering_intent: intent,
109 ..TransformOptions::default()
110 },
111 )
112 .ok()
113 })
114 .ok_or_else(|| {
115 Error::StreamDecode("ICC profile cannot be connected to sRGB".to_string())
116 })?;
117 Ok(Self { ncomp, executor })
118 }
119
120 pub fn components(&self) -> usize {
122 self.ncomp as usize
123 }
124
125 pub fn color_to_rgb(&self, comps: &[f64]) -> (f64, f64, f64) {
130 let mut src = [0u8; 4];
131 for (i, s) in src.iter_mut().enumerate().take(self.components()) {
132 let v = comps.get(i).copied().unwrap_or(0.0);
133 *s = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
134 }
135 let [r, g, b] = self.comps8_to_rgb8(&src);
136 (r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0)
137 }
138
139 pub fn comps8_to_rgb8(&self, comps: &[u8; 4]) -> [u8; 3] {
142 let mut dst = [0u8; 3];
143 let _ = self
146 .executor
147 .transform(&comps[..self.components()], &mut dst);
148 dst
149 }
150
151 pub fn slice_to_rgb(&self, src: &[u8], dst: &mut [u8]) -> Result<()> {
154 self.executor
155 .transform(src, dst)
156 .map_err(|e| Error::StreamDecode(format!("ICC transform failed: {e:?}")))
157 }
158
159 pub fn palette_to_rgb(&self, palette: &[u8]) -> Vec<u8> {
163 let n = self.components();
164 let entries = palette.len() / n;
165 let mut out = vec![0u8; entries * 3];
166 if let Err(e) = self.slice_to_rgb(&palette[..entries * n], &mut out) {
167 tracing::warn!("ICC palette conversion failed: {e}");
168 }
169 out
170 }
171}
172
173#[derive(Debug, Default)]
178pub struct IccCache {
179 transforms: HashMap<(ObjectId, RenderIntent), Option<Arc<IccTransform>>>,
180}
181
182impl IccCache {
183 pub fn new() -> Self {
184 Self::default()
185 }
186
187 pub fn get_or_build(
191 &mut self,
192 id: ObjectId,
193 intent: RenderIntent,
194 data: impl FnOnce() -> Option<Vec<u8>>,
195 ) -> Option<Arc<IccTransform>> {
196 self.transforms
197 .entry((id, intent))
198 .or_insert_with(|| {
199 let bytes = data()?;
200 match IccTransform::from_profile_bytes(&bytes, intent) {
201 Ok(t) => Some(Arc::new(t)),
202 Err(e) => {
203 tracing::warn!(
204 "ICC profile {id}: {e}; using component-count colour fallback"
205 );
206 None
207 }
208 }
209 })
210 .clone()
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 const SRGB: &[u8] = include_bytes!("testdata/srgb.icc");
219 const GRAY_GAMMA22: &[u8] = include_bytes!("testdata/gray_gamma22.icc");
220 const GRAY_LINEAR: &[u8] = include_bytes!("testdata/gray_linear.icc");
221 const CMYK_LUT: &[u8] = include_bytes!("testdata/cmyk_lut.icc");
222
223 fn ri() -> RenderIntent {
226 RenderIntent::default()
227 }
228
229 #[test]
230 fn srgb_profile_is_identity() {
231 let t = IccTransform::from_profile_bytes(SRGB, ri()).unwrap();
232 assert_eq!(t.components(), 3);
233 let mut out = [0u8; 6];
234 t.slice_to_rgb(&[10, 128, 240, 0, 255, 64], &mut out)
235 .unwrap();
236 for (a, b) in out.iter().zip([10u8, 128, 240, 0, 255, 64]) {
237 assert!((*a as i16 - b as i16).abs() <= 2, "not identity: {out:?}");
238 }
239 }
240
241 #[test]
242 fn srgb_float_color_roundtrips() {
243 let t = IccTransform::from_profile_bytes(SRGB, ri()).unwrap();
244 let (r, g, b) = t.color_to_rgb(&[1.0, 0.0, 0.0]);
245 assert!(r > 0.98 && g < 0.02 && b < 0.02, "got {r} {g} {b}");
246 }
247
248 #[test]
249 fn gray_gamma22_tone_curve_applies() {
250 let t = IccTransform::from_profile_bytes(GRAY_GAMMA22, ri()).unwrap();
253 assert_eq!(t.components(), 1);
254 let mut out = [0u8; 9];
255 t.slice_to_rgb(&[0, 128, 255], &mut out).unwrap();
256 assert_eq!(&out[0..3], &[0, 0, 0]);
257 assert!((out[3] as i16 - 129).abs() <= 2, "midtone: {out:?}");
258 assert_eq!(&out[6..9], &[255, 255, 255]);
259 }
260
261 #[test]
262 fn gray_linear_brightens_midtones() {
263 let t = IccTransform::from_profile_bytes(GRAY_LINEAR, ri()).unwrap();
266 let mut out = [0u8; 3];
267 t.slice_to_rgb(&[128], &mut out).unwrap();
268 assert!((out[0] as i16 - 188).abs() <= 2, "midtone: {out:?}");
269 }
270
271 #[test]
272 fn cmyk_lut_profile_converts_through_lut() {
273 let t = IccTransform::from_profile_bytes(CMYK_LUT, ri()).unwrap();
274 assert_eq!(t.components(), 4);
275 let src = [0u8, 0, 0, 0, 0, 0, 0, 255, 255, 0, 0, 0];
277 let mut out = [0u8; 9];
278 t.slice_to_rgb(&src, &mut out).unwrap();
279 assert!(
280 out[0] > 220 && out[1] > 220 && out[2] > 220,
281 "white: {out:?}"
282 );
283 assert!(out[3] < 30 && out[4] < 30 && out[5] < 30, "black: {out:?}");
284 assert!(out[6] < 60 && out[7] > 180 && out[8] > 180, "cyan: {out:?}");
285 }
286
287 #[test]
288 fn adobe_rgb_differs_from_passthrough() {
289 let bytes = moxcms::ColorProfile::new_adobe_rgb().encode().unwrap();
292 let t = IccTransform::from_profile_bytes(&bytes, ri()).unwrap();
293 let mut out = [0u8; 3];
294 t.slice_to_rgb(&[60, 200, 60], &mut out).unwrap();
295 assert!(
296 (out[0] as i16 - 60).abs() > 30,
297 "expected a visible shift, got {out:?}"
298 );
299 }
300
301 #[test]
302 fn render_intent_name_mapping() {
303 use RenderIntent::*;
304 assert_eq!(RenderIntent::from_pdf_name("Perceptual"), Perceptual);
305 assert_eq!(
306 RenderIntent::from_pdf_name("RelativeColorimetric"),
307 RelativeColorimetric
308 );
309 assert_eq!(RenderIntent::from_pdf_name("Saturation"), Saturation);
310 assert_eq!(
311 RenderIntent::from_pdf_name("AbsoluteColorimetric"),
312 AbsoluteColorimetric
313 );
314 assert_eq!(RenderIntent::from_pdf_name("Bogus"), RelativeColorimetric);
316 assert_eq!(RenderIntent::default(), RelativeColorimetric);
317 }
318
319 #[test]
320 fn every_intent_compiles_a_working_transform() {
321 use RenderIntent::*;
322 for intent in [
323 Perceptual,
324 RelativeColorimetric,
325 Saturation,
326 AbsoluteColorimetric,
327 ] {
328 let t = IccTransform::from_profile_bytes(SRGB, intent)
329 .unwrap_or_else(|e| panic!("intent {intent:?} failed: {e}"));
330 let mut out = [0u8; 3];
331 t.slice_to_rgb(&[200, 50, 50], &mut out).unwrap();
332 assert!((out[0] as i16 - 200).abs() <= 4, "{intent:?}: {out:?}");
334 }
335 }
336
337 #[test]
338 fn cache_separates_transforms_by_intent() {
339 let mut cache = IccCache::new();
340 let id = ObjectId(11, 0);
341 let mut calls = 0;
342 let _a = cache.get_or_build(id, RenderIntent::Perceptual, || {
343 calls += 1;
344 Some(SRGB.to_vec())
345 });
346 let _b = cache.get_or_build(id, RenderIntent::AbsoluteColorimetric, || {
348 calls += 1;
349 Some(SRGB.to_vec())
350 });
351 assert_eq!(calls, 2, "intents must be cached separately");
352 let _c = cache.get_or_build(id, RenderIntent::Perceptual, || unreachable!());
354 }
355
356 #[test]
357 fn malformed_profile_is_rejected() {
358 assert!(IccTransform::from_profile_bytes(&[0u8; 256], ri()).is_err());
359 assert!(IccTransform::from_profile_bytes(&SRGB[..100], ri()).is_err());
360 assert!(IccTransform::from_profile_bytes(b"", ri()).is_err());
361 }
362
363 #[test]
364 fn cache_remembers_failures_without_reparsing() {
365 let mut cache = IccCache::new();
366 let id = ObjectId(7, 0);
367 let mut calls = 0;
368 for _ in 0..2 {
369 let t = cache.get_or_build(id, ri(), || {
370 calls += 1;
371 Some(vec![0u8; 64])
372 });
373 assert!(t.is_none());
374 }
375 assert_eq!(calls, 1, "failure was not cached");
376 }
377
378 #[test]
379 fn cache_shares_one_transform_per_id() {
380 let mut cache = IccCache::new();
381 let id = ObjectId(3, 0);
382 let a = cache
383 .get_or_build(id, ri(), || Some(SRGB.to_vec()))
384 .unwrap();
385 let b = cache.get_or_build(id, ri(), || unreachable!()).unwrap();
386 assert!(Arc::ptr_eq(&a, &b));
387 }
388
389 #[test]
390 fn palette_bakes_through_transform() {
391 let t = IccTransform::from_profile_bytes(GRAY_LINEAR, ri()).unwrap();
392 let rgb = t.palette_to_rgb(&[0, 128, 255]);
393 assert_eq!(rgb.len(), 9);
394 assert_eq!(&rgb[0..3], &[0, 0, 0]);
395 assert!((rgb[3] as i16 - 188).abs() <= 2);
396 assert_eq!(&rgb[6..9], &[255, 255, 255]);
397 }
398}