Skip to main content

otf_pixels_ops/
icc.rs

1//! Conversion from an embedded ICC profile to sRGB: [`ToSrgb`].
2//!
3//! Covers the profiles cameras, phones and editors actually embed for RGB
4//! and grey images — **matrix/TRC** display profiles (Display P3, Adobe RGB,
5//! ProPhoto, BT.2020, ...) and grey `kTRC` profiles. A pixel is linearised
6//! through the profile's tone curves, taken to the D50 profile connection
7//! space by its colorant matrix, out of it by sRGB's, clipped to the sRGB
8//! gamut and encoded with the sRGB curve: the relative-colorimetric
9//! matrix-shaper transform ICC.1 §F.3 defines, which is what lcms2 builds
10//! for the same pair of profiles. LUT-based (`A2B`-only) profiles, CMYK and
11//! Lab-PCS profiles are not converted; [`ToSrgb::from_profile`] says so and
12//! the caller keeps the profile with the pixels instead.
13//!
14//! Alpha is untouched.
15
16#![allow(
17    clippy::indexing_slicing,
18    reason = "3x3 matrices and fixed-size lookup tables indexed within their bounds"
19)]
20
21use otf_pixels_core::{
22    ChannelLayout, ImageDescriptor, Op, PixelFormat, PixelsError, Region, Result, SampleKind, Tile,
23    TileMut,
24};
25use std::sync::{Arc, OnceLock};
26
27/// The ICC profile connection space white, D50 (ICC.1 §7.2.16).
28const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
29/// Entries in the table that encodes linear light as sRGB.
30const ENCODE_TABLE: usize = 1 << 12;
31
32/// A tone curve: `curv` (identity, gamma or sampled) or `para`.
33#[derive(Debug, Clone, PartialEq)]
34enum Curve {
35    Gamma(f64),
36    Table(Vec<f64>),
37    /// ICC.1 Table 68: function type 0-4 and its parameters `g a b c d e f`.
38    Parametric(u16, [f64; 7]),
39}
40
41impl Curve {
42    /// Device value `x` in 0..=1 to linear light, clamped to 0..=1.
43    fn eval(&self, x: f64) -> f64 {
44        let y = match self {
45            Self::Gamma(g) => x.powf(*g),
46            Self::Table(table) => {
47                let last = table.len().saturating_sub(1);
48                let at = x.clamp(0.0, 1.0) * last as f64;
49                let i = (at.floor() as usize).min(last.saturating_sub(1));
50                let (a, b) = (
51                    table.get(i).copied().unwrap_or(0.0),
52                    table.get(i + 1).copied().unwrap_or(1.0),
53                );
54                a + (b - a) * (at - i as f64)
55            }
56            Self::Parametric(kind, [g, a, b, c, d, e, f]) => match kind {
57                0 => x.powf(*g),
58                1 => {
59                    if x >= -b / a {
60                        (a * x + b).powf(*g)
61                    } else {
62                        0.0
63                    }
64                }
65                2 => {
66                    if x >= -b / a {
67                        (a * x + b).powf(*g) + c
68                    } else {
69                        *c
70                    }
71                }
72                3 => {
73                    if x >= *d {
74                        (a * x + b).powf(*g)
75                    } else {
76                        c * x
77                    }
78                }
79                _ => {
80                    if x >= *d {
81                        (a * x + b).powf(*g) + e
82                    } else {
83                        c * x + f
84                    }
85                }
86            },
87        };
88        if y.is_nan() { 0.0 } else { y.clamp(0.0, 1.0) }
89    }
90}
91
92/// sRGB's curve, IEC 61966-2-1, from linear light.
93fn srgb_encode(linear: f64) -> f64 {
94    if linear <= 0.003_130_8 {
95        12.92 * linear
96    } else {
97        1.055 * linear.powf(1.0 / 2.4) - 0.055
98    }
99}
100
101/// sRGB's curve to linear light.
102fn srgb_decode(value: f64) -> f64 {
103    if value <= 0.040_45 {
104        value / 12.92
105    } else {
106        ((value + 0.055) / 1.055).powf(2.4)
107    }
108}
109
110type Matrix = [[f64; 3]; 3];
111
112fn mat_mul(a: &Matrix, b: &Matrix) -> Matrix {
113    let mut out = [[0.0; 3]; 3];
114    for (i, row) in out.iter_mut().enumerate() {
115        for (j, cell) in row.iter_mut().enumerate() {
116            *cell = (0..3).map(|k| a[i][k] * b[k][j]).sum();
117        }
118    }
119    out
120}
121
122fn mat_vec(a: &Matrix, v: [f64; 3]) -> [f64; 3] {
123    [0, 1, 2].map(|i| a[i][0] * v[0] + a[i][1] * v[1] + a[i][2] * v[2])
124}
125
126fn mat_inv(m: &Matrix) -> Option<Matrix> {
127    let [[a, b, c], [d, e, f], [g, h, i]] = *m;
128    let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
129    if det.abs() < 1e-12 {
130        return None;
131    }
132    Some([
133        [
134            (e * i - f * h) / det,
135            (c * h - b * i) / det,
136            (b * f - c * e) / det,
137        ],
138        [
139            (f * g - d * i) / det,
140            (a * i - c * g) / det,
141            (c * d - a * f) / det,
142        ],
143        [
144            (d * h - e * g) / det,
145            (b * g - a * h) / det,
146            (a * e - b * d) / det,
147        ],
148    ])
149}
150
151/// sRGB's colorants adapted to D50 with Bradford, as lcms2 builds its sRGB
152/// profile: BT.709 primaries, white `x, y = 0.3127, 0.3290`.
153fn srgb_colorants() -> Matrix {
154    const BRADFORD: Matrix = [
155        [0.8951, 0.2664, -0.1614],
156        [-0.7502, 1.7135, 0.0367],
157        [0.0389, -0.0685, 1.0296],
158    ];
159    let xyz = |x: f64, y: f64| [x / y, 1.0, (1.0 - x - y) / y];
160    let primaries = [xyz(0.64, 0.33), xyz(0.30, 0.60), xyz(0.15, 0.06)];
161    let m: Matrix = [0, 1, 2].map(|i| [0, 1, 2].map(|j| primaries[j][i]));
162    let white = xyz(0.3127, 0.3290);
163    let Some(inverse) = mat_inv(&m) else {
164        return [[0.0; 3]; 3];
165    };
166    let s = mat_vec(&inverse, white);
167    let rgb_to_xyz: Matrix = [0, 1, 2].map(|i| [0, 1, 2].map(|j| m[i][j] * s[j]));
168    let (from, to) = (mat_vec(&BRADFORD, white), mat_vec(&BRADFORD, D50));
169    let scale: Matrix =
170        [0, 1, 2].map(|i| [0, 1, 2].map(|j| if i == j { to[i] / from[i] } else { 0.0 }));
171    let Some(bradford_inverse) = mat_inv(&BRADFORD) else {
172        return [[0.0; 3]; 3];
173    };
174    mat_mul(
175        &bradford_inverse,
176        &mat_mul(&scale, &mat_mul(&BRADFORD, &rgb_to_xyz)),
177    )
178}
179
180/// Why a profile is not converted.
181#[derive(Debug, Clone, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum Unconvertible {
184    /// Not an ICC profile, or a damaged one.
185    Malformed(&'static str),
186    /// A valid profile this converter does not handle (LUT-based, CMYK,
187    /// Lab PCS, ...), named for the caller.
188    Unsupported(String),
189}
190
191/// What [`ToSrgb::from_profile`] makes of a profile.
192#[derive(Debug, Clone)]
193#[non_exhaustive]
194pub enum Conversion {
195    /// The pixels need this op to become sRGB.
196    Convert(ToSrgb),
197    /// The profile is sRGB in all but name: the pixels already are.
198    AlreadySrgb,
199    /// The profile cannot be converted here; keep it with the pixels.
200    Keep(Unconvertible),
201}
202
203/// Converts pixels in an ICC profile's colour space to sRGB.
204#[derive(Debug, Clone)]
205pub struct ToSrgb {
206    shared: Arc<Shared>,
207}
208
209#[derive(Debug)]
210struct Shared {
211    /// One curve per colour channel: three for RGB, one for grey.
212    curves: Vec<Curve>,
213    /// Linear source RGB to linear sRGB; unused for grey.
214    matrix: Matrix,
215    /// Each channel's curve at every 8-bit value.
216    lut8: Vec<[f32; 256]>,
217    /// The same at every 16-bit value, built on first use.
218    lut16: OnceLock<Vec<Vec<f32>>>,
219    /// Linear light at `i / (ENCODE_TABLE - 1)` encoded as sRGB.
220    encode: Vec<f32>,
221}
222
223/// The pieces of a profile this needs, read from its tag table.
224struct Tags<'a> {
225    data: &'a [u8],
226    entries: Vec<([u8; 4], usize, usize)>,
227}
228
229fn be32(data: &[u8], at: usize) -> Option<u32> {
230    Some(u32::from_be_bytes(data.get(at..at + 4)?.try_into().ok()?))
231}
232
233fn be16(data: &[u8], at: usize) -> Option<u16> {
234    Some(u16::from_be_bytes(data.get(at..at + 2)?.try_into().ok()?))
235}
236
237fn s15(data: &[u8], at: usize) -> Option<f64> {
238    Some(f64::from(be32(data, at)? as i32) / 65536.0)
239}
240
241impl<'a> Tags<'a> {
242    fn parse(data: &'a [u8]) -> std::result::Result<Self, Unconvertible> {
243        let malformed = Unconvertible::Malformed;
244        if data.len() < 132 || data.get(36..40) != Some(b"acsp") {
245            return Err(malformed("not an ICC profile"));
246        }
247        let count = be32(data, 128).ok_or(malformed("truncated tag table"))? as usize;
248        if count > 1024 || 132 + count * 12 > data.len() {
249            return Err(malformed("tag table runs past the profile"));
250        }
251        let mut entries = Vec::with_capacity(count);
252        for i in 0..count {
253            let at = 132 + i * 12;
254            let sig: [u8; 4] = data
255                .get(at..at + 4)
256                .and_then(|s| s.try_into().ok())
257                .ok_or(malformed("truncated tag entry"))?;
258            let offset = be32(data, at + 4).ok_or(malformed("truncated tag entry"))? as usize;
259            let size = be32(data, at + 8).ok_or(malformed("truncated tag entry"))? as usize;
260            if offset.checked_add(size).is_none_or(|end| end > data.len()) {
261                return Err(malformed("a tag runs past the profile"));
262            }
263            entries.push((sig, offset, size));
264        }
265        Ok(Self { data, entries })
266    }
267
268    fn get(&self, sig: &[u8; 4]) -> Option<&'a [u8]> {
269        let &(_, offset, size) = self.entries.iter().find(|(s, _, _)| s == sig)?;
270        self.data.get(offset..offset + size)
271    }
272
273    fn xyz(&self, sig: &[u8; 4]) -> std::result::Result<[f64; 3], Unconvertible> {
274        let tag = self.get(sig).ok_or_else(|| missing(sig))?;
275        if tag.get(..4) != Some(b"XYZ ") {
276            return Err(Unconvertible::Malformed("a colorant is not an XYZType"));
277        }
278        let v = |i: usize| s15(tag, 8 + 4 * i).ok_or(Unconvertible::Malformed("truncated XYZType"));
279        Ok([v(0)?, v(1)?, v(2)?])
280    }
281
282    fn curve(&self, sig: &[u8; 4]) -> std::result::Result<Curve, Unconvertible> {
283        let malformed = Unconvertible::Malformed;
284        let tag = self.get(sig).ok_or_else(|| missing(sig))?;
285        match tag.get(..4) {
286            Some(b"curv") => {
287                let n = be32(tag, 8).ok_or(malformed("truncated curv"))? as usize;
288                match n {
289                    0 => Ok(Curve::Gamma(1.0)),
290                    1 => Ok(Curve::Gamma(
291                        f64::from(be16(tag, 12).ok_or(malformed("truncated curv"))?) / 256.0,
292                    )),
293                    _ => {
294                        let table: Option<Vec<f64>> = (0..n)
295                            .map(|i| be16(tag, 12 + 2 * i).map(|v| f64::from(v) / 65535.0))
296                            .collect();
297                        Ok(Curve::Table(
298                            table.ok_or(malformed("truncated curv table"))?,
299                        ))
300                    }
301                }
302            }
303            Some(b"para") => {
304                let kind = be16(tag, 8).ok_or(malformed("truncated para"))?;
305                let count = match kind {
306                    0 => 1,
307                    1 => 3,
308                    2 => 4,
309                    3 => 5,
310                    4 => 7,
311                    _ => return Err(malformed("unknown parametric curve type")),
312                };
313                let mut params = [0.0; 7];
314                for (i, p) in params.iter_mut().enumerate().take(count) {
315                    *p = s15(tag, 12 + 4 * i).ok_or(malformed("truncated para"))?;
316                }
317                if kind != 0 && params[1] == 0.0 {
318                    return Err(malformed("parametric curve with a zero slope"));
319                }
320                Ok(Curve::Parametric(kind, params))
321            }
322            _ => Err(malformed("a tone curve is neither curv nor para")),
323        }
324    }
325}
326
327fn missing(sig: &[u8; 4]) -> Unconvertible {
328    Unconvertible::Unsupported(format!(
329        "the profile has no `{}` tag, so it is not a matrix/TRC profile",
330        String::from_utf8_lossy(sig)
331    ))
332}
333
334impl ToSrgb {
335    /// What converting pixels in `profile` to sRGB takes.
336    ///
337    /// `Keep` for anything this converter does not handle, which is never an
338    /// error: the profile stays with the pixels, and whatever displays them
339    /// can still manage their colour.
340    #[must_use]
341    pub fn from_profile(profile: &[u8]) -> Conversion {
342        match Self::build(profile) {
343            Ok(Some(op)) => Conversion::Convert(op),
344            Ok(None) => Conversion::AlreadySrgb,
345            Err(why) => Conversion::Keep(why),
346        }
347    }
348
349    fn build(profile: &[u8]) -> std::result::Result<Option<Self>, Unconvertible> {
350        let tags = Tags::parse(profile)?;
351        let space = profile.get(16..20).unwrap_or_default();
352        let pcs = profile.get(20..24).unwrap_or_default();
353        if pcs != b"XYZ " {
354            return Err(Unconvertible::Unsupported(
355                "the profile connects through Lab, not XYZ".into(),
356            ));
357        }
358        let (curves, matrix) = match space {
359            b"RGB " => {
360                let columns = [tags.xyz(b"rXYZ")?, tags.xyz(b"gXYZ")?, tags.xyz(b"bXYZ")?];
361                let source: Matrix = [0, 1, 2].map(|i| [0, 1, 2].map(|j| columns[j][i]));
362                let to_srgb = mat_inv(&srgb_colorants())
363                    .ok_or(Unconvertible::Malformed("sRGB colorants are singular"))?;
364                let matrix = mat_mul(&to_srgb, &source);
365                let curves = vec![
366                    tags.curve(b"rTRC")?,
367                    tags.curve(b"gTRC")?,
368                    tags.curve(b"bTRC")?,
369                ];
370                (curves, matrix)
371            }
372            b"GRAY" => (
373                vec![tags.curve(b"kTRC")?],
374                [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
375            ),
376            other => {
377                return Err(Unconvertible::Unsupported(format!(
378                    "a `{}` colour space profile",
379                    String::from_utf8_lossy(other).trim_end()
380                )));
381            }
382        };
383        if is_srgb(&curves, &matrix) {
384            return Ok(None);
385        }
386        let lut8 = curves
387            .iter()
388            .map(|curve| std::array::from_fn(|i| curve.eval(i as f64 / 255.0) as f32))
389            .collect();
390        let encode = (0..ENCODE_TABLE)
391            .map(|i| srgb_encode(i as f64 / (ENCODE_TABLE - 1) as f64) as f32)
392            .collect();
393        Ok(Some(Self {
394            shared: Arc::new(Shared {
395                curves,
396                matrix,
397                lut8,
398                lut16: OnceLock::new(),
399                encode,
400            }),
401        }))
402    }
403
404    /// Whether this converts grey (one colour channel) rather than RGB.
405    #[must_use]
406    pub fn is_grey(&self) -> bool {
407        self.shared.curves.len() == 1
408    }
409
410    /// Whether pixels of `format` are what this profile describes: grey for
411    /// a grey profile, RGB for an RGB one, 8- or 16-bit or float.
412    #[must_use]
413    pub fn applies_to(&self, format: PixelFormat) -> bool {
414        let grey = matches!(
415            format.layout(),
416            ChannelLayout::Gray | ChannelLayout::GrayAlpha
417        );
418        grey == self.is_grey()
419    }
420}
421
422/// Whether a profile is sRGB to within a fraction of an 8-bit step: an
423/// identity matrix and sRGB's curve on every channel.
424fn is_srgb(curves: &[Curve], matrix: &Matrix) -> bool {
425    let identity =
426        (0..3).all(|i| (0..3).all(|j| (matrix[i][j] - f64::from(u8::from(i == j))).abs() < 2e-3));
427    let srgb_curve = |curve: &Curve| {
428        (0..=32).all(|k| {
429            let x = f64::from(k) / 32.0;
430            (curve.eval(x) - srgb_decode(x)).abs() < 1e-3
431        })
432    };
433    identity && curves.iter().all(srgb_curve)
434}
435
436impl Shared {
437    /// Linear light in 0..=1 to sRGB in 0..=1, through the encode table.
438    fn encode(&self, linear: f32) -> f32 {
439        let at = linear.clamp(0.0, 1.0) * (ENCODE_TABLE - 1) as f32;
440        let i = (at as usize).min(ENCODE_TABLE - 2);
441        let (a, b) = (
442            self.encode.get(i).copied().unwrap_or(0.0),
443            self.encode.get(i + 1).copied().unwrap_or(1.0),
444        );
445        a + (b - a) * (at - i as f32)
446    }
447
448    /// Linear device values to sRGB values, clipped to the gamut.
449    fn convert(&self, linear: [f32; 3]) -> [f32; 3] {
450        let m = &self.matrix;
451        [0, 1, 2].map(|i| {
452            let v = m[i][0] as f32 * linear[0]
453                + m[i][1] as f32 * linear[1]
454                + m[i][2] as f32 * linear[2];
455            self.encode(v)
456        })
457    }
458
459    fn lut16(&self) -> &[Vec<f32>] {
460        self.lut16.get_or_init(|| {
461            self.curves
462                .iter()
463                .map(|curve| {
464                    (0..=u16::MAX)
465                        .map(|i| curve.eval(f64::from(i) / 65535.0) as f32)
466                        .collect()
467                })
468                .collect()
469        })
470    }
471}
472
473impl Op for ToSrgb {
474    fn name(&self) -> &'static str {
475        "to_srgb"
476    }
477
478    /// Pointwise, like `modulate`: resolution means nothing to it.
479    fn rescaled(&self) -> Option<Arc<dyn Op>> {
480        Some(Arc::new(self.clone()))
481    }
482
483    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
484        let [input] = inputs else {
485            return Err(PixelsError::graph("to_srgb takes exactly one input"));
486        };
487        if !self.applies_to(input.pixel) {
488            return Err(PixelsError::invalid_argument(
489                "profile",
490                format!(
491                    "a {} profile cannot describe {} pixels",
492                    if self.is_grey() { "grey" } else { "RGB" },
493                    input.pixel
494                ),
495            ));
496        }
497        Ok(*input)
498    }
499
500    fn input_regions(&self, output: Region, _inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
501        Ok(vec![output])
502    }
503
504    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
505        let [input] = inputs else {
506            return Err(PixelsError::graph("to_srgb takes exactly one input"));
507        };
508        let format = output.pixel();
509        let region = output.region();
510        let channels = format.channels();
511        let colour = if self.is_grey() { 1 } else { 3 };
512        let shared = &*self.shared;
513        for y in region.y..region.y + region.height {
514            let Some(source) = input.row(y) else { continue };
515            let Some(target) = output.row_mut(y) else {
516                continue;
517            };
518            match format.sample_kind() {
519                SampleKind::U8 => {
520                    for (from, to) in source
521                        .chunks_exact(channels)
522                        .zip(target.chunks_exact_mut(channels))
523                    {
524                        let linear: [f32; 3] = std::array::from_fn(|c| {
525                            let lut = shared.lut8.get(c.min(colour - 1));
526                            let v = from.get(c.min(colour - 1)).copied().unwrap_or(0);
527                            lut.and_then(|l| l.get(usize::from(v)))
528                                .copied()
529                                .unwrap_or(0.0)
530                        });
531                        let out = if colour == 1 {
532                            [shared.encode(linear[0]); 3]
533                        } else {
534                            shared.convert(linear)
535                        };
536                        for (c, slot) in to.iter_mut().enumerate() {
537                            *slot = if c < colour {
538                                (out[c] * 255.0 + 0.5) as u8
539                            } else {
540                                from.get(c).copied().unwrap_or(255)
541                            };
542                        }
543                    }
544                }
545                SampleKind::U16 => {
546                    let lut = shared.lut16();
547                    let bytes = channels * 2;
548                    for (from, to) in source
549                        .chunks_exact(bytes)
550                        .zip(target.chunks_exact_mut(bytes))
551                    {
552                        let sample = |c: usize| {
553                            u16::from_ne_bytes([
554                                from.get(2 * c).copied().unwrap_or(0),
555                                from.get(2 * c + 1).copied().unwrap_or(0),
556                            ])
557                        };
558                        let linear: [f32; 3] = std::array::from_fn(|c| {
559                            let c = c.min(colour - 1);
560                            lut.get(c)
561                                .and_then(|l| l.get(usize::from(sample(c))))
562                                .copied()
563                                .unwrap_or(0.0)
564                        });
565                        let out = if colour == 1 {
566                            [shared.encode(linear[0]); 3]
567                        } else {
568                            shared.convert(linear)
569                        };
570                        for c in 0..channels {
571                            let value = if let Some(v) = out.get(c).filter(|_| c < colour) {
572                                (v * 65535.0 + 0.5) as u16
573                            } else {
574                                sample(c)
575                            };
576                            if let Some(slot) = to.get_mut(2 * c..2 * c + 2) {
577                                slot.copy_from_slice(&value.to_ne_bytes());
578                            }
579                        }
580                    }
581                }
582                SampleKind::F32 => {
583                    let bytes = channels * 4;
584                    for (from, to) in source
585                        .chunks_exact(bytes)
586                        .zip(target.chunks_exact_mut(bytes))
587                    {
588                        let sample = |c: usize| {
589                            let mut b = [0_u8; 4];
590                            if let Some(s) = from.get(4 * c..4 * c + 4) {
591                                b.copy_from_slice(s);
592                            }
593                            f32::from_ne_bytes(b)
594                        };
595                        let linear: [f32; 3] = std::array::from_fn(|c| {
596                            let c = c.min(colour - 1);
597                            shared
598                                .curves
599                                .get(c)
600                                .map_or(0.0, |curve| curve.eval(f64::from(sample(c))) as f32)
601                        });
602                        let out = if colour == 1 {
603                            [srgb_encode(f64::from(linear[0])) as f32; 3]
604                        } else {
605                            let m = &shared.matrix;
606                            [0, 1, 2].map(|i| {
607                                let v = (0..3).map(|k| m[i][k] * f64::from(linear[k])).sum::<f64>();
608                                srgb_encode(v.clamp(0.0, 1.0)) as f32
609                            })
610                        };
611                        for c in 0..channels {
612                            let value = out
613                                .get(c)
614                                .filter(|_| c < colour)
615                                .copied()
616                                .unwrap_or_else(|| sample(c));
617                            if let Some(slot) = to.get_mut(4 * c..4 * c + 4) {
618                                slot.copy_from_slice(&value.to_ne_bytes());
619                            }
620                        }
621                    }
622                }
623            }
624        }
625        Ok(())
626    }
627}
628
629#[cfg(test)]
630#[allow(
631    clippy::unwrap_used,
632    clippy::indexing_slicing,
633    reason = "tests operate on known-good values"
634)]
635mod tests {
636    use super::*;
637
638    /// A minimal profile: header, tag table, tags.
639    fn profile(space: &[u8; 4], pcs: &[u8; 4], tags: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
640        let mut header = vec![0_u8; 128];
641        header[16..20].copy_from_slice(space);
642        header[20..24].copy_from_slice(pcs);
643        header[36..40].copy_from_slice(b"acsp");
644        let mut table = (tags.len() as u32).to_be_bytes().to_vec();
645        let mut data = Vec::new();
646        let base = 128 + 4 + 12 * tags.len();
647        for (sig, body) in tags {
648            table.extend_from_slice(*sig);
649            table.extend_from_slice(&((base + data.len()) as u32).to_be_bytes());
650            table.extend_from_slice(&(body.len() as u32).to_be_bytes());
651            data.extend_from_slice(body);
652            while data.len() % 4 != 0 {
653                data.push(0);
654            }
655        }
656        [header, table, data].concat()
657    }
658
659    fn xyz(v: [f64; 3]) -> Vec<u8> {
660        let mut out = b"XYZ \0\0\0\0".to_vec();
661        for x in v {
662            out.extend_from_slice(&((x * 65536.0).round() as i32).to_be_bytes());
663        }
664        out
665    }
666
667    fn gamma(g: f64) -> Vec<u8> {
668        let mut out = b"curv\0\0\0\0\0\0\0\x01".to_vec();
669        out.extend_from_slice(&((g * 256.0).round() as u16).to_be_bytes());
670        out
671    }
672
673    fn para(kind: u16, params: &[f64]) -> Vec<u8> {
674        let mut out = b"para\0\0\0\0".to_vec();
675        out.extend_from_slice(&kind.to_be_bytes());
676        out.extend_from_slice(&[0, 0]);
677        for p in params {
678            out.extend_from_slice(&((p * 65536.0).round() as i32).to_be_bytes());
679        }
680        out
681    }
682
683    fn rgb_profile(trc: Vec<u8>) -> Vec<u8> {
684        let m = srgb_colorants();
685        profile(
686            b"RGB ",
687            b"XYZ ",
688            &[
689                (b"rXYZ", xyz([m[0][0], m[1][0], m[2][0]])),
690                (b"gXYZ", xyz([m[0][1], m[1][1], m[2][1]])),
691                (b"bXYZ", xyz([m[0][2], m[1][2], m[2][2]])),
692                (b"rTRC", trc.clone()),
693                (b"gTRC", trc.clone()),
694                (b"bTRC", trc),
695            ],
696        )
697    }
698
699    #[test]
700    fn srgb_colorants_match_the_published_d50_values() {
701        // The rXYZ/gXYZ/bXYZ of the ubiquitous sRGB IEC61966-2.1 profiles.
702        let m = srgb_colorants();
703        let published = [
704            [0.4361, 0.3851, 0.1431],
705            [0.2225, 0.7169, 0.0606],
706            [0.0139, 0.0971, 0.7141],
707        ];
708        for i in 0..3 {
709            for j in 0..3 {
710                assert!(
711                    (m[i][j] - published[i][j]).abs() < 2e-4,
712                    "{i}{j}: {}",
713                    m[i][j]
714                );
715            }
716        }
717    }
718
719    #[test]
720    fn an_srgb_profile_is_recognised_and_left_alone() {
721        let srgb = rgb_profile(para(
722            3,
723            &[2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045],
724        ));
725        assert!(matches!(
726            ToSrgb::from_profile(&srgb),
727            Conversion::AlreadySrgb
728        ));
729        // Same primaries, a different curve: a conversion.
730        assert!(matches!(
731            ToSrgb::from_profile(&rgb_profile(gamma(1.8))),
732            Conversion::Convert(_)
733        ));
734    }
735
736    #[test]
737    fn parametric_curves_follow_icc_table_68() {
738        let curve = |kind, params: [f64; 7]| Curve::Parametric(kind, params);
739        assert!((curve(0, [2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]).eval(0.5) - 0.25).abs() < 1e-12);
740        // Type 1 is zero below -b/a.
741        let t1 = curve(1, [1.0, 2.0, -0.5, 0.0, 0.0, 0.0, 0.0]);
742        assert_eq!(t1.eval(0.2), 0.0);
743        assert!((t1.eval(0.5) - 0.5).abs() < 1e-12);
744        // Type 2 floors at c.
745        assert!((curve(2, [1.0, 2.0, -0.5, 0.1, 0.0, 0.0, 0.0]).eval(0.2) - 0.1).abs() < 1e-12);
746        // Type 3 is sRGB's shape: linear below d.
747        let t3 = curve(
748            3,
749            [
750                2.4,
751                1.0 / 1.055,
752                0.055 / 1.055,
753                1.0 / 12.92,
754                0.04045,
755                0.0,
756                0.0,
757            ],
758        );
759        for k in 0..=20 {
760            let x = f64::from(k) / 20.0;
761            assert!((t3.eval(x) - srgb_decode(x)).abs() < 1e-9);
762        }
763        // Type 4 adds the offsets e and f.
764        let t4 = curve(4, [1.0, 1.0, 0.0, 0.5, 0.5, 0.1, 0.05]);
765        assert!((t4.eval(0.2) - 0.15).abs() < 1e-12);
766        assert!((t4.eval(0.8) - 0.9).abs() < 1e-12);
767        // Tables interpolate; the ends are exact.
768        let table = Curve::Table(vec![0.0, 0.25, 1.0]);
769        assert!((table.eval(0.25) - 0.125).abs() < 1e-12);
770        assert_eq!((table.eval(0.0), table.eval(1.0)), (0.0, 1.0));
771    }
772
773    #[test]
774    fn unconvertible_profiles_are_kept_not_refused() {
775        let cmyk = profile(b"CMYK", b"Lab ", &[]);
776        assert!(matches!(
777            ToSrgb::from_profile(&cmyk),
778            Conversion::Keep(Unconvertible::Unsupported(_))
779        ));
780        let lab_pcs = profile(b"RGB ", b"Lab ", &[]);
781        assert!(matches!(
782            ToSrgb::from_profile(&lab_pcs),
783            Conversion::Keep(Unconvertible::Unsupported(_))
784        ));
785        // An RGB profile with only an A2B0 LUT has no colorants.
786        let lut_only = profile(b"RGB ", b"XYZ ", &[(b"A2B0", b"mft2\0\0\0\0".to_vec())]);
787        assert!(matches!(
788            ToSrgb::from_profile(&lut_only),
789            Conversion::Keep(Unconvertible::Unsupported(_))
790        ));
791        for broken in [&b"not a profile"[..], &[0_u8; 200][..]] {
792            assert!(matches!(
793                ToSrgb::from_profile(broken),
794                Conversion::Keep(Unconvertible::Malformed(_))
795            ));
796        }
797        // A tag pointing past the end.
798        let mut truncated = rgb_profile(gamma(2.2));
799        truncated.truncate(truncated.len() - 4);
800        assert!(matches!(
801            ToSrgb::from_profile(&truncated),
802            Conversion::Keep(Unconvertible::Malformed(_))
803        ));
804    }
805
806    #[test]
807    fn grey_profiles_convert_grey_only() {
808        let grey = profile(b"GRAY", b"XYZ ", &[(b"kTRC", gamma(1.0))]);
809        let Conversion::Convert(op) = ToSrgb::from_profile(&grey) else {
810            unreachable!("a grey kTRC profile converts")
811        };
812        assert!(op.applies_to(PixelFormat::Gray8) && op.applies_to(PixelFormat::GrayA8));
813        assert!(!op.applies_to(PixelFormat::Rgb8));
814        // Linear grey 0.5 is sRGB 188.
815        let encoded = (op.shared.encode(op.shared.lut8[0][128]) * 255.0 + 0.5) as u8;
816        assert_eq!(encoded, 188);
817    }
818}