Skip to main content

zpdf_color/
icc.rs

1//! ICC profile parsing and profile→sRGB transforms.
2//!
3//! Wraps the pure-Rust `moxcms` CMS behind crate-local types so the rest of
4//! the workspace never names the CMS crate. A profile that fails to parse or
5//! cannot be connected to sRGB is an `Err` from [`IccTransform::from_profile_bytes`]
6//! (and a cached `None` in [`IccCache`]); callers fall back to the
7//! component-count behaviour instead of failing the render.
8
9use 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/// PDF colour rendering intent (ISO 32000-1 §8.6.5.8) — the `ri` operator,
19/// ExtGState `/RI`, and image `/Intent`. Defaults to media-relative
20/// colorimetric, the PDF default.
21#[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    /// Map a PDF intent name to an intent. Unknown names fall back to the
32    /// media-relative colorimetric default (per the spec's recommendation).
33    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
52/// A compiled ICC-profile → sRGB transform for 1/3/4-component input.
53///
54/// The underlying executor is `Send + Sync`, so a transform can be shared
55/// across threads behind its usual `Arc`.
56pub 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    /// Parse an ICC profile and compile its device→sRGB transform.
71    ///
72    /// Supported data colour spaces: Gray (1 component), RGB and Lab (3),
73    /// CMYK (4). Anything else — including malformed or truncated profiles —
74    /// is an error so the caller can keep its `/N`-based fallback.
75    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            // Lab data profiles are 3-channel; raster samples use the ICC
81            // 8-bit Lab encoding, which the CMS handles internally.
82            DataColorSpace::Rgb | DataColorSpace::Lab => (Layout::Rgb, 3),
83            // moxcms convention: 8-bit CMYK shares the 4-channel Rgba layout.
84            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        // Try the requested PDF rendering intent first, then fall back through
93        // media-relative colorimetric and perceptual — the ICC-mandated order
94        // for LUT profiles that only carry a subset of A2B tables.
95        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    /// Input components per colour (1, 3, or 4).
121    pub fn components(&self) -> usize {
122        self.ncomp as usize
123    }
124
125    /// Convert one colour with components in 0..=1 to sRGB in 0..=1.
126    ///
127    /// Components are quantized to 8 bits — the same precision as the raster
128    /// path. Missing components read as 0.
129    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    /// Convert one colour of 8-bit components (first `components()` entries
140    /// used) to 8-bit sRGB.
141    pub fn comps8_to_rgb8(&self, comps: &[u8; 4]) -> [u8; 3] {
142        let mut dst = [0u8; 3];
143        // The executor only errs on slice-length mismatches, which the fixed
144        // sizes here rule out; keep black as the impossible-case fallback.
145        let _ = self
146            .executor
147            .transform(&comps[..self.components()], &mut dst);
148        dst
149    }
150
151    /// Buffer-level conversion of interleaved `components()`-byte samples to
152    /// interleaved 8-bit RGB. Both slices must describe the same pixel count.
153    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    /// Convert a palette of `components()`-byte entries into 3-byte RGB
160    /// entries (bakes Indexed-with-ICC-base lookup tables at resolve time).
161    /// Trailing bytes that do not form a whole entry are dropped.
162    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/// Per-document cache of ICCBased profile streams → compiled transforms,
174/// keyed by the profile stream's object id AND the rendering intent (the same
175/// profile may be requested under different intents on one page). Failures are
176/// cached as `None` so a malformed profile is parsed (and warned about) once.
177#[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    /// The cached transform for profile stream `id` under `intent`, building it
188    /// from the bytes returned by `data` on first use. `data` returning `None`
189    /// (unresolvable stream) also caches as a failure.
190    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    /// The default media-relative colorimetric intent, for tests that don't
224    /// exercise intent selection.
225    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        // A gamma-2.2 gray curve is close to (but not exactly) sRGB's
251        // transfer: 128 → encode(0.502^2.2) ≈ 129.
252        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        // Linear gray re-encoded with the sRGB curve: 128 → ≈188, a visible
264        // departure from the old pass-through (which would keep 128).
265        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        // white, black (K only), cyan
276        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        // A wide-gamut profile must visibly move saturated colours; built
290        // with moxcms here so no large fixture is needed.
291        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        // Unknown / unspecified → media-relative colorimetric default.
315        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            // sRGB→sRGB stays near identity for every intent (no gamut clip).
333            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        // A different intent must NOT reuse the perceptual entry.
347        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        // Same (id, intent) reuses.
353        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}