Skip to main content

pdfboss_icc/
lib.rs

1//! Cleanroom ICC profile parser and colour transform (ICC.1:2010, v2 and v4
2//! profiles) for pdfboss.
3//!
4//! [`parse`] reads a profile's header, tag table, and default device-to-PCS
5//! transform: matrix/TRC and grayTRC models (Annex F), or an A2B0 lookup
6//! transform (lut8Type, lut16Type, lutAToBType). [`Profile::transform`] then
7//! maps device components to non-linear sRGB in 0..=1: PCS values are
8//! chromatically adapted from the D50 PCS illuminant to D65 with the linear
9//! Bradford matrix (Annex E) and converted through the IEC 61966-2-1 primary
10//! matrix and transfer. Everything is combined at parse time; a transform
11//! call is curve lookups, one 3x3 multiply, and the sRGB encode, with no
12//! allocation.
13//!
14//! A 3-channel matrix/TRC profile whose composed transform is the identity
15//! within [`SRGB_TOLERANCE`] at the probe points reports
16//! [`DeviceSpace::Rgb`] from [`Profile::device_equivalent`] (a gray profile
17//! analogously reports [`DeviceSpace::Gray`]), so callers can keep painting
18//! in device space. Only the profile's default transform is used; rendering
19//! intents are not switched.
20
21mod curve;
22mod lut;
23mod math;
24
25use curve::Curve;
26use lut::Lut;
27
28pub use math::{lab_to_xyz, mat_apply, mat_mul, srgb_encode, xyz_to_linear_srgb, Mat3, D50};
29
30/// Why a profile would not parse.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum IccError {
33    /// The data ends before a declared structure does.
34    Truncated,
35    /// The 'acsp' profile signature is missing.
36    Signature,
37    /// The profile is well-formed but uses no transform shape supported
38    /// here.
39    Unsupported,
40    /// A structural invariant of the format is violated.
41    Malformed,
42}
43
44impl std::fmt::Display for IccError {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        let what = match self {
47            IccError::Truncated => "truncated ICC profile",
48            IccError::Signature => "missing ICC profile signature",
49            IccError::Unsupported => "unsupported ICC transform shape",
50            IccError::Malformed => "malformed ICC profile structure",
51        };
52        f.write_str(what)
53    }
54}
55
56impl std::error::Error for IccError {}
57
58/// The device space a profile's transform is indistinguishable from.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum DeviceSpace {
61    Rgb,
62    Gray,
63}
64
65/// Per-channel tolerance for the composed-identity probe behind
66/// [`Profile::device_equivalent`]: about 2,5 steps of 8-bit output. A pure
67/// gamma-2,2 curve misses the sRGB transfer by ~0,03 at 1/16 input, so it
68/// honestly fails.
69pub const SRGB_TOLERANCE: f32 = 0.01;
70
71const PROBES: [f32; 5] = [1.0 / 16.0, 0.25, 0.5, 0.75, 15.0 / 16.0];
72
73#[derive(Debug, Clone, PartialEq)]
74enum Pipeline {
75    MatrixTrc { trc: [Curve; 3], m: Mat3 },
76    GrayTrc { curve: Curve },
77    Lut(Lut),
78}
79
80impl Pipeline {
81    fn eval(&self, input: &[f32]) -> [f32; 3] {
82        let comp = |i: usize| -> f32 {
83            let v = input.get(i).copied().unwrap_or(0.0);
84            if v.is_finite() {
85                v.clamp(0.0, 1.0)
86            } else {
87                0.0
88            }
89        };
90        match self {
91            Pipeline::MatrixTrc { trc, m } => {
92                let lin = [
93                    trc[0].eval(comp(0)),
94                    trc[1].eval(comp(1)),
95                    trc[2].eval(comp(2)),
96                ];
97                let rgb = mat_apply(m, lin);
98                [
99                    srgb_encode(rgb[0]),
100                    srgb_encode(rgb[1]),
101                    srgb_encode(rgb[2]),
102                ]
103            }
104            Pipeline::GrayTrc { curve } => {
105                let v = srgb_encode(curve.eval(comp(0)));
106                [v, v, v]
107            }
108            Pipeline::Lut(lut) => lut.eval(input),
109        }
110    }
111}
112
113/// A parsed profile: its device channel count and compiled transform.
114#[derive(Debug, Clone, PartialEq)]
115pub struct Profile {
116    channels: usize,
117    pipeline: Pipeline,
118    equivalent: Option<DeviceSpace>,
119}
120
121impl Profile {
122    /// Number of device components [`Profile::transform`] reads.
123    pub fn channels(&self) -> usize {
124        self.channels
125    }
126
127    /// The device space this transform is the identity for, within
128    /// [`SRGB_TOLERANCE`], if any.
129    pub fn device_equivalent(&self) -> Option<DeviceSpace> {
130        self.equivalent
131    }
132
133    /// Maps device components (0..=1 each; missing read as 0, non-finite
134    /// and out-of-range values clamp) to non-linear sRGB in 0..=1.
135    pub fn transform(&self, input: &[f32]) -> [f32; 3] {
136        self.pipeline.eval(input)
137    }
138}
139
140fn be32(data: &[u8], at: usize) -> u32 {
141    u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]])
142}
143
144fn channel_count(sig: &[u8]) -> Option<usize> {
145    match sig {
146        b"GRAY" => Some(1),
147        b"RGB " | b"CMY " | b"XYZ " | b"Lab " | b"Luv " | b"YCbr" | b"Yxy " | b"HSV " | b"HLS " => {
148            Some(3)
149        }
150        b"CMYK" => Some(4),
151        [d @ b'2'..=b'9', b'C', b'L', b'R'] => Some((d - b'0') as usize),
152        [d @ b'A'..=b'F', b'C', b'L', b'R'] => Some((d - b'A') as usize + 10),
153        _ => None,
154    }
155}
156
157/// The profile's tag table as (signature, data) pairs; entries whose bytes
158/// fall outside the data are dropped.
159struct Tags<'a> {
160    data: &'a [u8],
161    count: usize,
162}
163
164impl<'a> Tags<'a> {
165    fn get(&self, sig: &[u8; 4]) -> Option<&'a [u8]> {
166        for k in 0..self.count {
167            let at = 132 + 12 * k;
168            if &self.data[at..at + 4] != sig {
169                continue;
170            }
171            let offset = be32(self.data, at + 4) as usize;
172            let size = be32(self.data, at + 8) as usize;
173            let end = offset.checked_add(size)?;
174            if end > self.data.len() {
175                return None;
176            }
177            return Some(&self.data[offset..end]);
178        }
179        None
180    }
181}
182
183fn xyz_column(tag: &[u8]) -> Option<[f32; 3]> {
184    if tag.len() < 20 || &tag[0..4] != b"XYZ " {
185        return None;
186    }
187    Some([
188        math::s15f16(be32(tag, 8)),
189        math::s15f16(be32(tag, 12)),
190        math::s15f16(be32(tag, 16)),
191    ])
192}
193
194fn matrix_trc(tags: &Tags<'_>, to_srgb: &Mat3) -> Option<Pipeline> {
195    let r = xyz_column(tags.get(b"rXYZ")?)?;
196    let g = xyz_column(tags.get(b"gXYZ")?)?;
197    let b = xyz_column(tags.get(b"bXYZ")?)?;
198    let colorants: Mat3 = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
199    let trc = [
200        Curve::parse(tags.get(b"rTRC")?).ok()?.0,
201        Curve::parse(tags.get(b"gTRC")?).ok()?.0,
202        Curve::parse(tags.get(b"bTRC")?).ok()?.0,
203    ];
204    Some(Pipeline::MatrixTrc {
205        trc,
206        m: mat_mul(to_srgb, &colorants),
207    })
208}
209
210fn probes_identity(pipeline: &Pipeline, channels: usize) -> bool {
211    for axis in 0..channels {
212        for v in PROBES {
213            let mut input = [0.0f32; 3];
214            input[axis] = v;
215            let out = pipeline.eval(&input[..channels]);
216            let want = if channels == 1 { [v, v, v] } else { input };
217            if out
218                .iter()
219                .zip(want)
220                .any(|(o, w)| (o - w).abs() > SRGB_TOLERANCE)
221            {
222                return false;
223            }
224        }
225    }
226    let input = [1.0f32; 3];
227    let out = pipeline.eval(&input[..channels]);
228    out.iter().all(|o| (o - 1.0).abs() <= SRGB_TOLERANCE)
229}
230
231/// Parses an ICC profile and compiles its default device-to-sRGB transform.
232///
233/// Tag precedence follows clause 8.10 — an A2B0 transform outranks the
234/// matrix/TRC tags — with one deliberate exception: a matrix/TRC model that
235/// probes as sRGB wins, since for such profiles both models encode the same
236/// transform and the device-equivalence report lets callers skip the
237/// conversion entirely.
238pub fn parse(data: &[u8]) -> Result<Profile, IccError> {
239    if data.len() < 132 {
240        return Err(IccError::Truncated);
241    }
242    if &data[36..40] != b"acsp" {
243        return Err(IccError::Signature);
244    }
245    if !(2..=4).contains(&data[8]) {
246        return Err(IccError::Unsupported);
247    }
248    if be32(data, 0) as usize > data.len() {
249        return Err(IccError::Truncated);
250    }
251    let channels = channel_count(&data[16..20]).ok_or(IccError::Unsupported)?;
252    let pcs_lab = match &data[20..24] {
253        b"XYZ " => false,
254        b"Lab " => true,
255        _ => return Err(IccError::Unsupported),
256    };
257    let declared = be32(data, 128) as usize;
258    let count = (data.len() - 132) / 12;
259    if declared > count {
260        return Err(IccError::Malformed);
261    }
262    let tags = Tags {
263        data,
264        count: declared,
265    };
266    let to_srgb = xyz_to_linear_srgb(D50);
267
268    let matrix = if channels == 3 && !pcs_lab {
269        matrix_trc(&tags, &to_srgb)
270    } else {
271        None
272    };
273    if let Some(pipeline) = &matrix {
274        if probes_identity(pipeline, 3) {
275            return Ok(Profile {
276                channels,
277                pipeline: matrix.unwrap(),
278                equivalent: Some(DeviceSpace::Rgb),
279            });
280        }
281    }
282    let gray = if channels == 1 {
283        tags.get(b"kTRC")
284            .and_then(|tag| Curve::parse(tag).ok())
285            .map(|(curve, _)| Pipeline::GrayTrc { curve })
286    } else {
287        None
288    };
289    if let Some(pipeline) = &gray {
290        if probes_identity(pipeline, 1) {
291            return Ok(Profile {
292                channels,
293                pipeline: gray.unwrap(),
294                equivalent: Some(DeviceSpace::Gray),
295            });
296        }
297    }
298    let lut = tags
299        .get(b"A2B0")
300        .and_then(|tag| Lut::parse(tag, &data[16..20] == b"XYZ ", pcs_lab, to_srgb).ok());
301    if let Some(lut) = lut {
302        if lut.inputs() != channels {
303            return Err(IccError::Malformed);
304        }
305        return Ok(Profile {
306            channels,
307            pipeline: Pipeline::Lut(lut),
308            equivalent: None,
309        });
310    }
311    let pipeline = matrix.or(gray).ok_or(IccError::Unsupported)?;
312    Ok(Profile {
313        channels,
314        pipeline,
315        equivalent: None,
316    })
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn near(a: f32, b: f32, tol: f32) -> bool {
324        (a - b).abs() <= tol
325    }
326
327    fn fx(v: f64) -> [u8; 4] {
328        (((v * 65536.0).round()) as i32).to_be_bytes()
329    }
330
331    fn xyz_tag(col: [f64; 3]) -> Vec<u8> {
332        let mut out = b"XYZ \0\0\0\0".to_vec();
333        for v in col {
334            out.extend_from_slice(&fx(v));
335        }
336        out
337    }
338
339    fn para3_srgb() -> Vec<u8> {
340        let mut out = b"para\0\0\0\0\0\x03\0\0".to_vec();
341        for v in [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045] {
342            out.extend_from_slice(&fx(v));
343        }
344        out
345    }
346
347    fn gamma_curv(g: f64) -> Vec<u8> {
348        let mut out = b"curv\0\0\0\0\0\0\0\x01".to_vec();
349        out.extend_from_slice(&(((g * 256.0).round()) as u16).to_be_bytes());
350        out
351    }
352
353    fn build(colour: &[u8; 4], pcs: &[u8; 4], tags: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
354        let mut header = vec![0u8; 128];
355        header[8] = 4;
356        header[16..20].copy_from_slice(colour);
357        header[20..24].copy_from_slice(pcs);
358        header[36..40].copy_from_slice(b"acsp");
359        let mut table = (tags.len() as u32).to_be_bytes().to_vec();
360        let mut body = Vec::new();
361        let mut at = 132 + 12 * tags.len();
362        for (sig, data) in tags {
363            table.extend_from_slice(sig);
364            table.extend_from_slice(&(at as u32).to_be_bytes());
365            table.extend_from_slice(&(data.len() as u32).to_be_bytes());
366            body.extend_from_slice(data);
367            let pad = data.len().div_ceil(4) * 4 - data.len();
368            body.extend_from_slice(&vec![0u8; pad]);
369            at += data.len() + pad;
370        }
371        let mut out = header;
372        out.extend_from_slice(&table);
373        out.extend_from_slice(&body);
374        let size = (out.len() as u32).to_be_bytes();
375        out[0..4].copy_from_slice(&size);
376        out
377    }
378
379    /// The IEC 61966-2-1 primaries, Bradford-adapted into the D50 PCS: the
380    /// colorant columns a real sRGB profile carries.
381    const SRGB_D50: [[f64; 3]; 3] = [
382        [0.4360, 0.2225, 0.0139],
383        [0.3851, 0.7169, 0.0971],
384        [0.1431, 0.0606, 0.7139],
385    ];
386
387    fn srgb_profile() -> Vec<u8> {
388        build(
389            b"RGB ",
390            b"XYZ ",
391            &[
392                (*b"rXYZ", xyz_tag(SRGB_D50[0])),
393                (*b"gXYZ", xyz_tag(SRGB_D50[1])),
394                (*b"bXYZ", xyz_tag(SRGB_D50[2])),
395                (*b"rTRC", para3_srgb()),
396                (*b"gTRC", para3_srgb()),
397                (*b"bTRC", para3_srgb()),
398            ],
399        )
400    }
401
402    /// A byte-built matrix/TRC profile equal to sRGB probes as the
403    /// device-RGB identity, and its transform round-trips inputs.
404    #[test]
405    fn srgb_profile_reports_rgb_equivalence() {
406        let profile = parse(&srgb_profile()).unwrap();
407        assert_eq!(profile.channels(), 3);
408        assert_eq!(profile.device_equivalent(), Some(DeviceSpace::Rgb));
409        let out = profile.transform(&[0.2, 0.5, 0.8]);
410        for (o, w) in out.iter().zip([0.2, 0.5, 0.8]) {
411            assert!(near(*o, w, 0.01), "{out:?}");
412        }
413    }
414
415    /// Swapping the sRGB transfer for gamma 1,8 keeps the primaries but
416    /// breaks the identity: mid-gray comes out lighter by a hand-computed
417    /// amount (encode(0,5^1,8) is about 0,576), and the equivalence report
418    /// is gone.
419    #[test]
420    fn gamma_18_profile_is_not_srgb() {
421        let data = build(
422            b"RGB ",
423            b"XYZ ",
424            &[
425                (*b"rXYZ", xyz_tag(SRGB_D50[0])),
426                (*b"gXYZ", xyz_tag(SRGB_D50[1])),
427                (*b"bXYZ", xyz_tag(SRGB_D50[2])),
428                (*b"rTRC", gamma_curv(1.8)),
429                (*b"gTRC", gamma_curv(1.8)),
430                (*b"bTRC", gamma_curv(1.8)),
431            ],
432        );
433        let profile = parse(&data).unwrap();
434        assert_eq!(profile.device_equivalent(), None);
435        let out = profile.transform(&[0.5, 0.5, 0.5]);
436        let want = srgb_encode(0.5f32.powf(1.8));
437        for o in out {
438            assert!(near(o, want, 0.01), "{out:?} want {want}");
439        }
440        assert!(out[0] > 0.55, "gamma 1.8 renders mid-gray lighter");
441    }
442
443    /// A gray profile with the sRGB transfer probes as the device-gray
444    /// identity; a linear (gamma 1) gray profile does not, and brightens
445    /// mid-gray to encode(0,5).
446    #[test]
447    fn gray_profiles() {
448        let data = build(b"GRAY", b"XYZ ", &[(*b"kTRC", para3_srgb())]);
449        let profile = parse(&data).unwrap();
450        assert_eq!(profile.channels(), 1);
451        assert_eq!(profile.device_equivalent(), Some(DeviceSpace::Gray));
452
453        let linear = build(b"GRAY", b"XYZ ", &[(*b"kTRC", gamma_curv(1.0))]);
454        let profile = parse(&linear).unwrap();
455        assert_eq!(profile.device_equivalent(), None);
456        let out = profile.transform(&[0.5]);
457        assert!(near(out[0], srgb_encode(0.5), 1e-4), "{out:?}");
458        assert_eq!(out[0], out[1]);
459    }
460
461    /// Headers select the parse outcome: a bad signature, an impossible
462    /// declared tag count, an unknown colour space, and an unsupported
463    /// version all error cleanly.
464    #[test]
465    fn header_validation() {
466        let mut bad_magic = srgb_profile();
467        bad_magic[36] = b'x';
468        assert_eq!(parse(&bad_magic), Err(IccError::Signature));
469
470        let mut bad_count = srgb_profile();
471        bad_count[128..132].copy_from_slice(&u32::MAX.to_be_bytes());
472        assert_eq!(parse(&bad_count), Err(IccError::Malformed));
473
474        let bad_space = build(b"????", b"XYZ ", &[]);
475        assert_eq!(parse(&bad_space), Err(IccError::Unsupported));
476
477        let mut v5 = srgb_profile();
478        v5[8] = 5;
479        assert_eq!(parse(&v5), Err(IccError::Unsupported));
480
481        let mut v2 = srgb_profile();
482        v2[8] = 2;
483        assert!(parse(&v2).is_ok(), "v2 headers share the layout");
484
485        let empty = build(b"RGB ", b"XYZ ", &[]);
486        assert_eq!(parse(&empty), Err(IccError::Unsupported));
487    }
488
489    /// Every prefix of a valid profile errors instead of panicking, and a
490    /// tag entry pointing past the end reads as absent.
491    #[test]
492    fn truncation_and_hostile_offsets() {
493        let data = srgb_profile();
494        for cut in 0..data.len() {
495            let result = parse(&data[..cut]);
496            assert!(result.is_err(), "cut {cut}");
497        }
498        let mut hostile = data.clone();
499        hostile[136..140].copy_from_slice(&u32::MAX.to_be_bytes());
500        assert!(parse(&hostile).is_err());
501    }
502
503    /// An RGB profile whose A2B0 collapses everything to the PCS white
504    /// paints white for any input — proof the lookup transform is selected
505    /// when the matrix/TRC set is absent.
506    #[test]
507    fn a2b0_lut_profile() {
508        let mut lut = Vec::new();
509        lut.extend_from_slice(b"mft2\0\0\0\0");
510        lut.push(3);
511        lut.push(3);
512        lut.push(2);
513        lut.push(0);
514        for r in 0..3 {
515            for c in 0..3 {
516                let v: i32 = if r == c { 0x0001_0000 } else { 0 };
517                lut.extend_from_slice(&v.to_be_bytes());
518            }
519        }
520        lut.extend_from_slice(&2u16.to_be_bytes());
521        lut.extend_from_slice(&2u16.to_be_bytes());
522        for _ in 0..3 {
523            lut.extend_from_slice(&0u16.to_be_bytes());
524            lut.extend_from_slice(&65535u16.to_be_bytes());
525        }
526        let white: [u16; 3] = [0x7B6B, 0x8000, 0x6996];
527        for _ in 0..8 {
528            for w in white {
529                lut.extend_from_slice(&w.to_be_bytes());
530            }
531        }
532        for _ in 0..3 {
533            lut.extend_from_slice(&0u16.to_be_bytes());
534            lut.extend_from_slice(&65535u16.to_be_bytes());
535        }
536        let data = build(b"RGB ", b"XYZ ", &[(*b"A2B0", lut)]);
537        let profile = parse(&data).unwrap();
538        assert_eq!(profile.device_equivalent(), None);
539        let out = profile.transform(&[0.3, 0.9, 0.1]);
540        assert!(out.iter().all(|&v| near(v, 1.0, 2e-3)), "{out:?}");
541        for cut in 0..data.len() {
542            assert!(parse(&data[..cut]).is_err(), "cut {cut}");
543        }
544    }
545
546    /// xCLR signatures map to their channel counts.
547    #[test]
548    fn xclr_channel_counts() {
549        assert_eq!(channel_count(b"2CLR"), Some(2));
550        assert_eq!(channel_count(b"9CLR"), Some(9));
551        assert_eq!(channel_count(b"ACLR"), Some(10));
552        assert_eq!(channel_count(b"FCLR"), Some(15));
553        assert_eq!(channel_count(b"GCLR"), None);
554    }
555}