Skip to main content

zenjxl_decoder/api/
color.rs

1// Copyright (c) the JPEG XL Project Authors. All rights reserved.
2//
3// Use of this source code is governed by a BSD-style
4// license that can be found in the LICENSE file.
5
6use std::{borrow::Cow, fmt};
7
8use crate::{
9    color::tf::{hlg_to_scene, linear_to_pq_precise, pq_to_linear_precise},
10    error::{Error, Result},
11    headers::color_encoding::{
12        ColorEncoding, ColorSpace, Primaries, RenderingIntent, TransferFunction, WhitePoint,
13    },
14    util::{Matrix3x3, Vector3, inv_3x3_matrix, mul_3x3_matrix, mul_3x3_vector},
15};
16
17// Bradford matrices for chromatic adaptation
18const K_BRADFORD: Matrix3x3<f64> = [
19    [0.8951, 0.2664, -0.1614],
20    [-0.7502, 1.7135, 0.0367],
21    [0.0389, -0.0685, 1.0296],
22];
23
24const K_BRADFORD_INV: Matrix3x3<f64> = [
25    [0.9869929, -0.1470543, 0.1599627],
26    [0.4323053, 0.5183603, 0.0492912],
27    [-0.0085287, 0.0400428, 0.9684867],
28];
29
30pub fn compute_md5(data: &[u8]) -> [u8; 16] {
31    let mut sum = [0u8; 16];
32    let mut data64 = data.to_vec();
33    data64.push(128);
34
35    // Add bytes such that ((size + 8) & 63) == 0
36    let extra = (64 - ((data64.len() + 8) & 63)) & 63;
37    data64.resize(data64.len() + extra, 0);
38
39    // Append length in bits as 64-bit little-endian
40    let bit_len = (data.len() as u64) << 3;
41    for i in (0..64).step_by(8) {
42        data64.push((bit_len >> i) as u8);
43    }
44
45    const SINEPARTS: [u32; 64] = [
46        0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613,
47        0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193,
48        0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d,
49        0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
50        0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122,
51        0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa,
52        0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244,
53        0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
54        0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb,
55        0xeb86d391,
56    ];
57
58    const SHIFT: [u32; 64] = [
59        7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5,
60        9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10,
61        15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
62    ];
63
64    let mut a0: u32 = 0x67452301;
65    let mut b0: u32 = 0xefcdab89;
66    let mut c0: u32 = 0x98badcfe;
67    let mut d0: u32 = 0x10325476;
68
69    for i in (0..data64.len()).step_by(64) {
70        let mut a = a0;
71        let mut b = b0;
72        let mut c = c0;
73        let mut d = d0;
74
75        for j in 0..64 {
76            let (f, g) = if j < 16 {
77                ((b & c) | ((!b) & d), j)
78            } else if j < 32 {
79                ((d & b) | ((!d) & c), (5 * j + 1) & 0xf)
80            } else if j < 48 {
81                (b ^ c ^ d, (3 * j + 5) & 0xf)
82            } else {
83                (c ^ (b | (!d)), (7 * j) & 0xf)
84            };
85
86            let dg0 = data64[i + g * 4] as u32;
87            let dg1 = data64[i + g * 4 + 1] as u32;
88            let dg2 = data64[i + g * 4 + 2] as u32;
89            let dg3 = data64[i + g * 4 + 3] as u32;
90            let u = dg0 | (dg1 << 8) | (dg2 << 16) | (dg3 << 24);
91
92            let f = f.wrapping_add(a).wrapping_add(SINEPARTS[j]).wrapping_add(u);
93            a = d;
94            d = c;
95            c = b;
96            b = b.wrapping_add(f.rotate_left(SHIFT[j]));
97        }
98
99        a0 = a0.wrapping_add(a);
100        b0 = b0.wrapping_add(b);
101        c0 = c0.wrapping_add(c);
102        d0 = d0.wrapping_add(d);
103    }
104
105    sum[0] = a0 as u8;
106    sum[1] = (a0 >> 8) as u8;
107    sum[2] = (a0 >> 16) as u8;
108    sum[3] = (a0 >> 24) as u8;
109    sum[4] = b0 as u8;
110    sum[5] = (b0 >> 8) as u8;
111    sum[6] = (b0 >> 16) as u8;
112    sum[7] = (b0 >> 24) as u8;
113    sum[8] = c0 as u8;
114    sum[9] = (c0 >> 8) as u8;
115    sum[10] = (c0 >> 16) as u8;
116    sum[11] = (c0 >> 24) as u8;
117    sum[12] = d0 as u8;
118    sum[13] = (d0 >> 8) as u8;
119    sum[14] = (d0 >> 16) as u8;
120    sum[15] = (d0 >> 24) as u8;
121    sum
122}
123
124#[allow(clippy::too_many_arguments)]
125pub(crate) fn primaries_to_xyz(
126    rx: f32,
127    ry: f32,
128    gx: f32,
129    gy: f32,
130    bx: f32,
131    by: f32,
132    wx: f32,
133    wy: f32,
134) -> Result<Matrix3x3<f64>, Error> {
135    // Validate white point coordinates
136    if !((0.0..=1.0).contains(&wx) && (wy > 0.0 && wy <= 1.0)) {
137        return Err(Error::IccInvalidWhitePoint(
138            wx,
139            wy,
140            "White point coordinates out of range ([0,1] for x, (0,1] for y)".to_string(),
141        ));
142    }
143    // Comment from libjxl:
144    // TODO(lode): also require rx, ry, gx, gy, bx, to be in range 0-1? ICC
145    // profiles in theory forbid negative XYZ values, but in practice the ACES P0
146    // color space uses a negative y for the blue primary.
147
148    // Construct the primaries matrix P. Its columns are the XYZ coordinates
149    // of the R, G, B primaries (derived from their chromaticities x, y, z=1-x-y).
150    // P = [[xr, xg, xb],
151    //      [yr, yg, yb],
152    //      [zr, zg, zb]]
153    let rz = 1.0 - rx as f64 - ry as f64;
154    let gz = 1.0 - gx as f64 - gy as f64;
155    let bz = 1.0 - bx as f64 - by as f64;
156    let p_matrix = [
157        [rx as f64, gx as f64, bx as f64],
158        [ry as f64, gy as f64, by as f64],
159        [rz, gz, bz],
160    ];
161
162    let p_inv_matrix = inv_3x3_matrix(&p_matrix)?;
163
164    // Convert reference white point (wx, wy) to XYZ form with Y=1
165    // This is WhitePoint_XYZ_wp = [wx/wy, 1, (1-wx-wy)/wy]
166    let x_over_y_wp = wx as f64 / wy as f64;
167    let z_over_y_wp = (1.0 - wx as f64 - wy as f64) / wy as f64;
168
169    if !x_over_y_wp.is_finite() || !z_over_y_wp.is_finite() {
170        return Err(Error::IccInvalidWhitePoint(
171            wx,
172            wy,
173            "Calculated X/Y or Z/Y for white point is not finite.".to_string(),
174        ));
175    }
176    let white_point_xyz_vec: Vector3<f64> = [x_over_y_wp, 1.0, z_over_y_wp];
177
178    // Calculate scaling factors S = [Sr, Sg, Sb] such that P * S = WhitePoint_XYZ_wp
179    // So, S = P_inv * WhitePoint_XYZ_wp
180    let s_vec = mul_3x3_vector(&p_inv_matrix, &white_point_xyz_vec);
181
182    // Construct diagonal matrix S_diag from s_vec
183    let s_diag_matrix = [
184        [s_vec[0], 0.0, 0.0],
185        [0.0, s_vec[1], 0.0],
186        [0.0, 0.0, s_vec[2]],
187    ];
188    // The final RGB-to-XYZ matrix is P * S_diag
189    let result_matrix = mul_3x3_matrix(&p_matrix, &s_diag_matrix);
190
191    Ok(result_matrix)
192}
193
194pub(crate) fn adapt_to_xyz_d50(wx: f32, wy: f32) -> Result<Matrix3x3<f64>, Error> {
195    if !((0.0..=1.0).contains(&wx) && (wy > 0.0 && wy <= 1.0)) {
196        return Err(Error::IccInvalidWhitePoint(
197            wx,
198            wy,
199            "White point coordinates out of range ([0,1] for x, (0,1] for y)".to_string(),
200        ));
201    }
202
203    // Convert white point (wx, wy) to XYZ with Y=1
204    let x_over_y = wx as f64 / wy as f64;
205    let z_over_y = (1.0 - wx as f64 - wy as f64) / wy as f64;
206
207    // Check for finiteness, as 1.0 / tiny float can overflow.
208    if !x_over_y.is_finite() || !z_over_y.is_finite() {
209        return Err(Error::IccInvalidWhitePoint(
210            wx,
211            wy,
212            "Calculated X/Y or Z/Y for white point is not finite.".to_string(),
213        ));
214    }
215    let w: Vector3<f64> = [x_over_y, 1.0, z_over_y];
216
217    // D50 white point in XYZ (Y=1 form)
218    // These are X_D50/Y_D50, 1.0, Z_D50/Y_D50
219    let w50: Vector3<f64> = [0.96422, 1.0, 0.82521];
220
221    // Transform to LMS color space
222    let lms_source = mul_3x3_vector(&K_BRADFORD, &w);
223    let lms_d50 = mul_3x3_vector(&K_BRADFORD, &w50);
224
225    // Check for invalid LMS values which would lead to division by zero
226    if lms_source.contains(&0.0) {
227        return Err(Error::IccInvalidWhitePoint(
228            wx,
229            wy,
230            "LMS components for source white point are zero, leading to division by zero."
231                .to_string(),
232        ));
233    }
234
235    // Create diagonal scaling matrix in LMS space
236    let mut a_diag_matrix: Matrix3x3<f64> = [[0.0; 3]; 3];
237    for i in 0..3 {
238        a_diag_matrix[i][i] = lms_d50[i] / lms_source[i];
239        if !a_diag_matrix[i][i].is_finite() {
240            return Err(Error::IccInvalidWhitePoint(
241                wx,
242                wy,
243                format!("Diagonal adaptation matrix component {i} is not finite."),
244            ));
245        }
246    }
247
248    // Combine transformations
249    let b_matrix = mul_3x3_matrix(&a_diag_matrix, &K_BRADFORD);
250    let final_adaptation_matrix = mul_3x3_matrix(&K_BRADFORD_INV, &b_matrix);
251
252    Ok(final_adaptation_matrix)
253}
254
255#[allow(clippy::too_many_arguments)]
256pub(crate) fn primaries_to_xyz_d50(
257    rx: f32,
258    ry: f32,
259    gx: f32,
260    gy: f32,
261    bx: f32,
262    by: f32,
263    wx: f32,
264    wy: f32,
265) -> Result<Matrix3x3<f64>, Error> {
266    // Get the matrix to convert RGB to XYZ, adapted to its native white point (wx, wy).
267    let rgb_to_xyz_native_wp_matrix = primaries_to_xyz(rx, ry, gx, gy, bx, by, wx, wy)?;
268
269    // Get the chromatic adaptation matrix from the native white point (wx, wy) to D50.
270    let adaptation_to_d50_matrix = adapt_to_xyz_d50(wx, wy)?;
271    // This matrix converts XYZ values relative to white point (wx, wy)
272    // to XYZ values relative to D50.
273
274    // Combine the matrices: M_RGBtoD50XYZ = M_AdaptToD50 * M_RGBtoNativeXYZ
275    // Applying M_RGBtoNativeXYZ first gives XYZ relative to native white point.
276    // Then applying M_AdaptToD50 converts these XYZ values to be relative to D50.
277    let result_matrix = mul_3x3_matrix(&adaptation_to_d50_matrix, &rgb_to_xyz_native_wp_matrix);
278
279    Ok(result_matrix)
280}
281
282#[allow(clippy::too_many_arguments)]
283fn create_icc_rgb_matrix(
284    rx: f32,
285    ry: f32,
286    gx: f32,
287    gy: f32,
288    bx: f32,
289    by: f32,
290    wx: f32,
291    wy: f32,
292) -> Result<Matrix3x3<f32>, Error> {
293    // TODO: think about if we need/want to change precision to f64 for some calculations here
294    let result_f64 = primaries_to_xyz_d50(rx, ry, gx, gy, bx, by, wx, wy)?;
295    Ok(std::array::from_fn(|r_idx| {
296        std::array::from_fn(|c_idx| result_f64[r_idx][c_idx] as f32)
297    }))
298}
299
300#[derive(Clone, Debug, PartialEq)]
301pub enum JxlWhitePoint {
302    D65,
303    E,
304    DCI,
305    Chromaticity { wx: f32, wy: f32 },
306}
307
308impl fmt::Display for JxlWhitePoint {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        match self {
311            JxlWhitePoint::D65 => f.write_str("D65"),
312            JxlWhitePoint::E => f.write_str("EER"),
313            JxlWhitePoint::DCI => f.write_str("DCI"),
314            JxlWhitePoint::Chromaticity { wx, wy } => write!(f, "{wx:.7};{wy:.7}"),
315        }
316    }
317}
318
319impl JxlWhitePoint {
320    pub fn to_xy_coords(&self) -> (f32, f32) {
321        match self {
322            JxlWhitePoint::Chromaticity { wx, wy } => (*wx, *wy),
323            JxlWhitePoint::D65 => (0.3127, 0.3290),
324            JxlWhitePoint::DCI => (0.314, 0.351),
325            JxlWhitePoint::E => (1.0 / 3.0, 1.0 / 3.0),
326        }
327    }
328}
329
330#[derive(Clone, Debug, PartialEq)]
331pub enum JxlPrimaries {
332    SRGB,
333    BT2100,
334    P3,
335    Chromaticities {
336        rx: f32,
337        ry: f32,
338        gx: f32,
339        gy: f32,
340        bx: f32,
341        by: f32,
342    },
343}
344
345impl fmt::Display for JxlPrimaries {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        match self {
348            JxlPrimaries::SRGB => f.write_str("SRG"),
349            JxlPrimaries::BT2100 => f.write_str("202"),
350            JxlPrimaries::P3 => f.write_str("DCI"),
351            JxlPrimaries::Chromaticities {
352                rx,
353                ry,
354                gx,
355                gy,
356                bx,
357                by,
358            } => write!(f, "{rx:.7},{ry:.7};{gx:.7},{gy:.7};{bx:.7},{by:.7}"),
359        }
360    }
361}
362
363impl JxlPrimaries {
364    pub fn to_xy_coords(&self) -> [(f32, f32); 3] {
365        match self {
366            JxlPrimaries::Chromaticities {
367                rx,
368                ry,
369                gx,
370                gy,
371                bx,
372                by,
373            } => [(*rx, *ry), (*gx, *gy), (*bx, *by)],
374            JxlPrimaries::SRGB => [
375                // libjxl has these weird numbers for some reason.
376                (0.639_998_7, 0.330_010_15),
377                //(0.640, 0.330), // R
378                (0.300_003_8, 0.600_003_36),
379                //(0.300, 0.600), // G
380                (0.150_002_05, 0.059_997_204),
381                //(0.150, 0.060), // B
382            ],
383            JxlPrimaries::BT2100 => [
384                (0.708, 0.292), // R
385                (0.170, 0.797), // G
386                (0.131, 0.046), // B
387            ],
388            JxlPrimaries::P3 => [
389                (0.680, 0.320), // R
390                (0.265, 0.690), // G
391                (0.150, 0.060), // B
392            ],
393        }
394    }
395}
396
397#[derive(Clone, Debug, PartialEq)]
398pub enum JxlTransferFunction {
399    BT709,
400    Linear,
401    SRGB,
402    PQ,
403    DCI,
404    HLG,
405    Gamma(f32),
406}
407
408impl fmt::Display for JxlTransferFunction {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        match self {
411            JxlTransferFunction::BT709 => f.write_str("709"),
412            JxlTransferFunction::Linear => f.write_str("Lin"),
413            JxlTransferFunction::SRGB => f.write_str("SRG"),
414            JxlTransferFunction::PQ => f.write_str("PeQ"),
415            JxlTransferFunction::DCI => f.write_str("DCI"),
416            JxlTransferFunction::HLG => f.write_str("HLG"),
417            JxlTransferFunction::Gamma(g) => write!(f, "g{g:.7}"),
418        }
419    }
420}
421
422#[derive(Clone, Debug, PartialEq)]
423pub enum JxlColorEncoding {
424    RgbColorSpace {
425        white_point: JxlWhitePoint,
426        primaries: JxlPrimaries,
427        transfer_function: JxlTransferFunction,
428        rendering_intent: RenderingIntent,
429    },
430    GrayscaleColorSpace {
431        white_point: JxlWhitePoint,
432        transfer_function: JxlTransferFunction,
433        rendering_intent: RenderingIntent,
434    },
435    XYB {
436        rendering_intent: RenderingIntent,
437    },
438}
439
440impl fmt::Display for JxlColorEncoding {
441    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
442        match self {
443            Self::RgbColorSpace { .. } => f.write_str("RGB"),
444            Self::GrayscaleColorSpace { .. } => f.write_str("Gra"),
445            Self::XYB { .. } => f.write_str("XYB"),
446        }
447    }
448}
449
450impl JxlColorEncoding {
451    pub fn from_internal(internal: &ColorEncoding) -> Result<Self> {
452        let rendering_intent = internal.rendering_intent;
453        if internal.color_space == ColorSpace::XYB {
454            if rendering_intent != RenderingIntent::Perceptual {
455                return Err(Error::InvalidRenderingIntent);
456            }
457            return Ok(Self::XYB { rendering_intent });
458        }
459
460        let white_point = match internal.white_point {
461            WhitePoint::D65 => JxlWhitePoint::D65,
462            WhitePoint::E => JxlWhitePoint::E,
463            WhitePoint::DCI => JxlWhitePoint::DCI,
464            WhitePoint::Custom => {
465                let (wx, wy) = internal.white.as_f32_coords();
466                JxlWhitePoint::Chromaticity { wx, wy }
467            }
468        };
469        let transfer_function = if internal.tf.have_gamma {
470            JxlTransferFunction::Gamma(internal.tf.gamma())
471        } else {
472            match internal.tf.transfer_function {
473                TransferFunction::BT709 => JxlTransferFunction::BT709,
474                TransferFunction::Linear => JxlTransferFunction::Linear,
475                TransferFunction::SRGB => JxlTransferFunction::SRGB,
476                TransferFunction::PQ => JxlTransferFunction::PQ,
477                TransferFunction::DCI => JxlTransferFunction::DCI,
478                TransferFunction::HLG => JxlTransferFunction::HLG,
479                TransferFunction::Unknown => {
480                    return Err(Error::InvalidColorEncoding);
481                }
482            }
483        };
484
485        if internal.color_space == ColorSpace::Gray {
486            return Ok(Self::GrayscaleColorSpace {
487                white_point,
488                transfer_function,
489                rendering_intent,
490            });
491        }
492
493        let primaries = match internal.primaries {
494            Primaries::SRGB => JxlPrimaries::SRGB,
495            Primaries::BT2100 => JxlPrimaries::BT2100,
496            Primaries::P3 => JxlPrimaries::P3,
497            Primaries::Custom => {
498                let (rx, ry) = internal.custom_primaries[0].as_f32_coords();
499                let (gx, gy) = internal.custom_primaries[1].as_f32_coords();
500                let (bx, by) = internal.custom_primaries[2].as_f32_coords();
501                JxlPrimaries::Chromaticities {
502                    rx,
503                    ry,
504                    gx,
505                    gy,
506                    bx,
507                    by,
508                }
509            }
510        };
511
512        match internal.color_space {
513            ColorSpace::Gray | ColorSpace::XYB => unreachable!(),
514            ColorSpace::RGB => Ok(Self::RgbColorSpace {
515                white_point,
516                primaries,
517                transfer_function,
518                rendering_intent,
519            }),
520            ColorSpace::Unknown => Err(Error::InvalidColorSpace),
521        }
522    }
523
524    fn create_icc_cicp_tag_data(&self, tags_data: &mut Vec<u8>) -> Result<Option<TagInfo>, Error> {
525        let JxlColorEncoding::RgbColorSpace {
526            white_point,
527            primaries,
528            transfer_function,
529            ..
530        } = self
531        else {
532            return Ok(None);
533        };
534
535        // Determine the CICP value for primaries.
536        let primaries_val: u8 = match (white_point, primaries) {
537            (JxlWhitePoint::D65, JxlPrimaries::SRGB) => 1,
538            (JxlWhitePoint::D65, JxlPrimaries::BT2100) => 9,
539            (JxlWhitePoint::D65, JxlPrimaries::P3) => 12,
540            (JxlWhitePoint::DCI, JxlPrimaries::P3) => 11,
541            _ => return Ok(None),
542        };
543
544        let tf_val = match transfer_function {
545            JxlTransferFunction::BT709 => 1,
546            JxlTransferFunction::Linear => 8,
547            JxlTransferFunction::SRGB => 13,
548            JxlTransferFunction::PQ => 16,
549            JxlTransferFunction::DCI => 17,
550            JxlTransferFunction::HLG => 18,
551            // Custom gamma cannot be represented.
552            JxlTransferFunction::Gamma(_) => return Ok(None),
553        };
554
555        let signature = b"cicp";
556        let start_offset = tags_data.len() as u32;
557        tags_data.extend_from_slice(signature);
558        let data_len = tags_data.len();
559        tags_data.resize(tags_data.len() + 4, 0);
560        write_u32_be(tags_data, data_len, 0)?;
561        tags_data.push(primaries_val);
562        tags_data.push(tf_val);
563        // Matrix Coefficients (RGB is non-constant luminance)
564        tags_data.push(0);
565        // Video Full Range Flag
566        tags_data.push(1);
567
568        Ok(Some(TagInfo {
569            signature: *signature,
570            offset_in_tags_blob: start_offset,
571            size_unpadded: 12,
572        }))
573    }
574
575    fn can_tone_map_for_icc(&self) -> bool {
576        let JxlColorEncoding::RgbColorSpace {
577            white_point,
578            primaries,
579            transfer_function,
580            ..
581        } = self
582        else {
583            return false;
584        };
585        // This function determines if an ICC profile can be used for tone mapping.
586        // The logic is ported from the libjxl `CanToneMap` function.
587        // The core idea is that if the color space can be represented by a CICP tag
588        // in the ICC profile, then there's more freedom to use other parts of the
589        // profile (like the A2B0 LUT) for tone mapping. Otherwise, the profile must
590        // unambiguously describe the color space.
591
592        // The conditions for being able to tone map are:
593        // 1. The color space must be RGB.
594        // 2. The transfer function must be either PQ (Perceptual Quantizer) or HLG (Hybrid Log-Gamma).
595        // 3. The combination of primaries and white point must be one that is commonly
596        //    describable by a standard CICP value. This includes:
597        //    a) P3 primaries with either a D65 or DCI white point.
598        //    b) Any non-custom primaries, as long as the white point is D65.
599
600        if let JxlPrimaries::Chromaticities { .. } = primaries {
601            return false;
602        }
603
604        matches!(
605            transfer_function,
606            JxlTransferFunction::PQ | JxlTransferFunction::HLG
607        ) && (*white_point == JxlWhitePoint::D65
608            || (*white_point == JxlWhitePoint::DCI && *primaries == JxlPrimaries::P3))
609    }
610
611    pub fn get_color_encoding_description(&self) -> String {
612        // Handle special known color spaces first
613        if let Some(common_name) = match self {
614            JxlColorEncoding::RgbColorSpace {
615                white_point: JxlWhitePoint::D65,
616                primaries: JxlPrimaries::SRGB,
617                transfer_function: JxlTransferFunction::SRGB,
618                rendering_intent: RenderingIntent::Perceptual,
619            } => Some("sRGB"),
620            JxlColorEncoding::RgbColorSpace {
621                white_point: JxlWhitePoint::D65,
622                primaries: JxlPrimaries::P3,
623                transfer_function: JxlTransferFunction::SRGB,
624                rendering_intent: RenderingIntent::Perceptual,
625            } => Some("DisplayP3"),
626            JxlColorEncoding::RgbColorSpace {
627                white_point: JxlWhitePoint::D65,
628                primaries: JxlPrimaries::BT2100,
629                transfer_function: JxlTransferFunction::PQ,
630                rendering_intent: RenderingIntent::Relative,
631            } => Some("Rec2100PQ"),
632            JxlColorEncoding::RgbColorSpace {
633                white_point: JxlWhitePoint::D65,
634                primaries: JxlPrimaries::BT2100,
635                transfer_function: JxlTransferFunction::HLG,
636                rendering_intent: RenderingIntent::Relative,
637            } => Some("Rec2100HLG"),
638            _ => None,
639        } {
640            return common_name.to_string();
641        }
642
643        // Build the string part by part for other case
644        let mut d = String::with_capacity(64);
645
646        match self {
647            JxlColorEncoding::RgbColorSpace {
648                white_point,
649                primaries,
650                transfer_function,
651                rendering_intent,
652            } => {
653                d.push_str("RGB_");
654                d.push_str(&white_point.to_string());
655                d.push('_');
656                d.push_str(&primaries.to_string());
657                d.push('_');
658                d.push_str(&rendering_intent.to_string());
659                d.push('_');
660                d.push_str(&transfer_function.to_string());
661            }
662            JxlColorEncoding::GrayscaleColorSpace {
663                white_point,
664                transfer_function,
665                rendering_intent,
666            } => {
667                d.push_str("Gra_");
668                d.push_str(&white_point.to_string());
669                d.push('_');
670                d.push_str(&rendering_intent.to_string());
671                d.push('_');
672                d.push_str(&transfer_function.to_string());
673            }
674            JxlColorEncoding::XYB { rendering_intent } => {
675                d.push_str("XYB_");
676                d.push_str(&rendering_intent.to_string());
677            }
678        }
679
680        d
681    }
682
683    fn create_icc_header(&self) -> Result<Vec<u8>, Error> {
684        let mut header_data = vec![0u8; 128];
685
686        // Profile size - To be filled in at the end of profile creation.
687        write_u32_be(&mut header_data, 0, 0)?;
688        const CMM_TAG: &str = "jxl ";
689        // CMM Type
690        write_icc_tag(&mut header_data, 4, CMM_TAG)?;
691
692        // Profile version - ICC v4.4 (0x04400000)
693        // Conformance tests have v4.3, libjxl produces v4.4
694        write_u32_be(&mut header_data, 8, 0x04400000u32)?;
695
696        let profile_class_str = match self {
697            JxlColorEncoding::XYB { .. } => "scnr",
698            _ => "mntr",
699        };
700        write_icc_tag(&mut header_data, 12, profile_class_str)?;
701
702        // Data color space
703        let data_color_space_str = match self {
704            JxlColorEncoding::GrayscaleColorSpace { .. } => "GRAY",
705            _ => "RGB ",
706        };
707        write_icc_tag(&mut header_data, 16, data_color_space_str)?;
708
709        // PCS - Profile Connection Space
710        // Corresponds to: if (kEnable3DToneMapping && CanToneMap(c))
711        // Assuming kEnable3DToneMapping is true for this port for now.
712        const K_ENABLE_3D_ICC_TONEMAPPING: bool = true;
713        if K_ENABLE_3D_ICC_TONEMAPPING && self.can_tone_map_for_icc() {
714            write_icc_tag(&mut header_data, 20, "Lab ")?;
715        } else {
716            write_icc_tag(&mut header_data, 20, "XYZ ")?;
717        }
718
719        // Date and Time - Placeholder values from libjxl
720        write_u16_be(&mut header_data, 24, 2019)?; // Year
721        write_u16_be(&mut header_data, 26, 12)?; // Month
722        write_u16_be(&mut header_data, 28, 1)?; // Day
723        write_u16_be(&mut header_data, 30, 0)?; // Hours
724        write_u16_be(&mut header_data, 32, 0)?; // Minutes
725        write_u16_be(&mut header_data, 34, 0)?; // Seconds
726
727        write_icc_tag(&mut header_data, 36, "acsp")?;
728        write_icc_tag(&mut header_data, 40, "APPL")?;
729
730        // Profile flags
731        write_u32_be(&mut header_data, 44, 0)?;
732        // Device manufacturer
733        write_u32_be(&mut header_data, 48, 0)?;
734        // Device model
735        write_u32_be(&mut header_data, 52, 0)?;
736        // Device attributes
737        write_u32_be(&mut header_data, 56, 0)?;
738        write_u32_be(&mut header_data, 60, 0)?;
739
740        // Rendering Intent
741        let rendering_intent = match self {
742            JxlColorEncoding::RgbColorSpace {
743                rendering_intent, ..
744            }
745            | JxlColorEncoding::GrayscaleColorSpace {
746                rendering_intent, ..
747            }
748            | JxlColorEncoding::XYB { rendering_intent } => rendering_intent,
749        };
750        write_u32_be(&mut header_data, 64, *rendering_intent as u32)?;
751
752        // Whitepoint is fixed to D50 for ICC.
753        write_u32_be(&mut header_data, 68, 0x0000F6D6)?;
754        write_u32_be(&mut header_data, 72, 0x00010000)?;
755        write_u32_be(&mut header_data, 76, 0x0000D32D)?;
756
757        // Profile Creator
758        write_icc_tag(&mut header_data, 80, CMM_TAG)?;
759
760        // Profile ID (MD5 checksum) (offset 84) - 16 bytes.
761        // This is calculated at the end of profile creation and written here.
762
763        // Reserved (offset 100-127) - already zeroed here.
764
765        Ok(header_data)
766    }
767
768    pub fn maybe_create_profile(&self) -> Result<Option<Vec<u8>>, Error> {
769        if let JxlColorEncoding::XYB { rendering_intent } = self
770            && *rendering_intent != RenderingIntent::Perceptual
771        {
772            return Err(Error::InvalidRenderingIntent);
773        }
774        let header = self.create_icc_header()?;
775        let mut tags_data: Vec<u8> = Vec::new();
776        let mut collected_tags: Vec<TagInfo> = Vec::new();
777
778        // Create 'desc' (ProfileDescription) tag
779        let description_string = self.get_color_encoding_description();
780
781        let desc_tag_start_offset = tags_data.len() as u32; // 0 at this point ...
782        create_icc_mluc_tag(&mut tags_data, &description_string)?;
783        let desc_tag_unpadded_size = (tags_data.len() as u32) - desc_tag_start_offset;
784        pad_to_4_byte_boundary(&mut tags_data);
785        collected_tags.push(TagInfo {
786            signature: *b"desc",
787            offset_in_tags_blob: desc_tag_start_offset,
788            size_unpadded: desc_tag_unpadded_size,
789        });
790
791        // Create 'cprt' (Copyright) tag
792        let copyright_string = "CC0";
793        let cprt_tag_start_offset = tags_data.len() as u32;
794        create_icc_mluc_tag(&mut tags_data, copyright_string)?;
795        let cprt_tag_unpadded_size = (tags_data.len() as u32) - cprt_tag_start_offset;
796        pad_to_4_byte_boundary(&mut tags_data);
797        collected_tags.push(TagInfo {
798            signature: *b"cprt",
799            offset_in_tags_blob: cprt_tag_start_offset,
800            size_unpadded: cprt_tag_unpadded_size,
801        });
802
803        match self {
804            JxlColorEncoding::GrayscaleColorSpace { white_point, .. } => {
805                let (wx, wy) = white_point.to_xy_coords();
806                collected_tags.push(create_icc_xyz_tag(
807                    &mut tags_data,
808                    &cie_xyz_from_white_cie_xy(wx, wy)?,
809                )?);
810            }
811            _ => {
812                // Ok, in this case we will add the chad tag below
813                const D50: [f32; 3] = [0.964203f32, 1.0, 0.824905];
814                collected_tags.push(create_icc_xyz_tag(&mut tags_data, &D50)?);
815            }
816        }
817        pad_to_4_byte_boundary(&mut tags_data);
818        if !matches!(self, JxlColorEncoding::GrayscaleColorSpace { .. }) {
819            let (wx, wy) = match self {
820                JxlColorEncoding::GrayscaleColorSpace { .. } => unreachable!(),
821                JxlColorEncoding::RgbColorSpace { white_point, .. } => white_point.to_xy_coords(),
822                JxlColorEncoding::XYB { .. } => JxlWhitePoint::D65.to_xy_coords(),
823            };
824            let chad_matrix_f64 = adapt_to_xyz_d50(wx, wy)?;
825            let chad_matrix = std::array::from_fn(|r_idx| {
826                std::array::from_fn(|c_idx| chad_matrix_f64[r_idx][c_idx] as f32)
827            });
828            collected_tags.push(create_icc_chad_tag(&mut tags_data, &chad_matrix)?);
829            pad_to_4_byte_boundary(&mut tags_data);
830        }
831
832        if let JxlColorEncoding::RgbColorSpace {
833            white_point,
834            primaries,
835            ..
836        } = self
837        {
838            if let Some(tag_info) = self.create_icc_cicp_tag_data(&mut tags_data)? {
839                collected_tags.push(tag_info);
840                // Padding here not necessary, since we add 12 bytes to already 4-byte aligned
841                // buffer
842                // pad_to_4_byte_boundary(&mut tags_data);
843            }
844
845            // Get colorant and white point coordinates to build the conversion matrix.
846            let primaries_coords = primaries.to_xy_coords();
847            let (rx, ry) = primaries_coords[0];
848            let (gx, gy) = primaries_coords[1];
849            let (bx, by) = primaries_coords[2];
850            let (wx, wy) = white_point.to_xy_coords();
851
852            // Calculate the RGB to XYZD50 matrix.
853            let m = create_icc_rgb_matrix(rx, ry, gx, gy, bx, by, wx, wy)?;
854
855            // Extract the columns, which are the XYZ values for the R, G, and B primaries.
856            let r_xyz = [m[0][0], m[1][0], m[2][0]];
857            let g_xyz = [m[0][1], m[1][1], m[2][1]];
858            let b_xyz = [m[0][2], m[1][2], m[2][2]];
859
860            // Helper to create the raw data for any 'XYZ ' type tag.
861            let create_xyz_type_tag_data =
862                |tags: &mut Vec<u8>, xyz: &[f32; 3]| -> Result<u32, Error> {
863                    let start_offset = tags.len();
864                    // The tag *type* is 'XYZ ' for all three
865                    tags.extend_from_slice(b"XYZ ");
866                    tags.extend_from_slice(&0u32.to_be_bytes());
867                    for &val in xyz {
868                        append_s15_fixed_16(tags, val)?;
869                    }
870                    Ok((tags.len() - start_offset) as u32)
871                };
872
873            // Create the 'rXYZ' tag.
874            let r_xyz_tag_start_offset = tags_data.len() as u32;
875            let r_xyz_tag_unpadded_size = create_xyz_type_tag_data(&mut tags_data, &r_xyz)?;
876            pad_to_4_byte_boundary(&mut tags_data);
877            collected_tags.push(TagInfo {
878                signature: *b"rXYZ", // Making the *signature* is unique.
879                offset_in_tags_blob: r_xyz_tag_start_offset,
880                size_unpadded: r_xyz_tag_unpadded_size,
881            });
882
883            // Create the 'gXYZ' tag.
884            let g_xyz_tag_start_offset = tags_data.len() as u32;
885            let g_xyz_tag_unpadded_size = create_xyz_type_tag_data(&mut tags_data, &g_xyz)?;
886            pad_to_4_byte_boundary(&mut tags_data);
887            collected_tags.push(TagInfo {
888                signature: *b"gXYZ",
889                offset_in_tags_blob: g_xyz_tag_start_offset,
890                size_unpadded: g_xyz_tag_unpadded_size,
891            });
892
893            // Create the 'bXYZ' tag.
894            let b_xyz_tag_start_offset = tags_data.len() as u32;
895            let b_xyz_tag_unpadded_size = create_xyz_type_tag_data(&mut tags_data, &b_xyz)?;
896            pad_to_4_byte_boundary(&mut tags_data);
897            collected_tags.push(TagInfo {
898                signature: *b"bXYZ",
899                offset_in_tags_blob: b_xyz_tag_start_offset,
900                size_unpadded: b_xyz_tag_unpadded_size,
901            });
902        }
903        if self.can_tone_map_for_icc() {
904            // Create A2B0 tag for HDR tone mapping
905            if let JxlColorEncoding::RgbColorSpace {
906                white_point,
907                primaries,
908                transfer_function,
909                ..
910            } = self
911            {
912                let a2b0_start = tags_data.len() as u32;
913                create_icc_lut_atob_tag_for_hdr(
914                    transfer_function,
915                    primaries,
916                    white_point,
917                    &mut tags_data,
918                )?;
919                pad_to_4_byte_boundary(&mut tags_data);
920                let a2b0_size = (tags_data.len() as u32) - a2b0_start;
921                collected_tags.push(TagInfo {
922                    signature: *b"A2B0",
923                    offset_in_tags_blob: a2b0_start,
924                    size_unpadded: a2b0_size,
925                });
926
927                // Create B2A0 tag (no-op, required by Apple software including Safari/Preview)
928                let b2a0_start = tags_data.len() as u32;
929                create_icc_noop_btoa_tag(&mut tags_data)?;
930                pad_to_4_byte_boundary(&mut tags_data);
931                let b2a0_size = (tags_data.len() as u32) - b2a0_start;
932                collected_tags.push(TagInfo {
933                    signature: *b"B2A0",
934                    offset_in_tags_blob: b2a0_start,
935                    size_unpadded: b2a0_size,
936                });
937            }
938        } else {
939            match self {
940                JxlColorEncoding::XYB { .. } => {
941                    // Create A2B0 tag for XYB color space
942                    let a2b0_start = tags_data.len() as u32;
943                    create_icc_lut_atob_tag_for_xyb(&mut tags_data)?;
944                    pad_to_4_byte_boundary(&mut tags_data);
945                    let a2b0_size = (tags_data.len() as u32) - a2b0_start;
946                    collected_tags.push(TagInfo {
947                        signature: *b"A2B0",
948                        offset_in_tags_blob: a2b0_start,
949                        size_unpadded: a2b0_size,
950                    });
951
952                    // Create B2A0 tag (no-op, required by Apple software)
953                    let b2a0_start = tags_data.len() as u32;
954                    create_icc_noop_btoa_tag(&mut tags_data)?;
955                    pad_to_4_byte_boundary(&mut tags_data);
956                    let b2a0_size = (tags_data.len() as u32) - b2a0_start;
957                    collected_tags.push(TagInfo {
958                        signature: *b"B2A0",
959                        offset_in_tags_blob: b2a0_start,
960                        size_unpadded: b2a0_size,
961                    });
962                }
963                JxlColorEncoding::RgbColorSpace {
964                    transfer_function, ..
965                }
966                | JxlColorEncoding::GrayscaleColorSpace {
967                    transfer_function, ..
968                } => {
969                    let trc_tag_start_offset = tags_data.len() as u32;
970                    let trc_tag_unpadded_size = match transfer_function {
971                        JxlTransferFunction::Gamma(g) => {
972                            // Type 0 parametric curve: Y = X^gamma
973                            let gamma = 1.0 / g;
974                            create_icc_curv_para_tag(&mut tags_data, &[gamma], 0)?
975                        }
976                        JxlTransferFunction::SRGB => {
977                            // Type 3 parametric curve for sRGB standard.
978                            const PARAMS: [f32; 5] =
979                                [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045];
980                            create_icc_curv_para_tag(&mut tags_data, &PARAMS, 3)?
981                        }
982                        JxlTransferFunction::BT709 => {
983                            // Type 3 parametric curve for BT.709 standard.
984                            const PARAMS: [f32; 5] =
985                                [1.0 / 0.45, 1.0 / 1.099, 0.099 / 1.099, 1.0 / 4.5, 0.081];
986                            create_icc_curv_para_tag(&mut tags_data, &PARAMS, 3)?
987                        }
988                        JxlTransferFunction::Linear => {
989                            // Type 3 can also represent a linear response (gamma=1.0).
990                            const PARAMS: [f32; 5] = [1.0, 1.0, 0.0, 1.0, 0.0];
991                            create_icc_curv_para_tag(&mut tags_data, &PARAMS, 3)?
992                        }
993                        JxlTransferFunction::DCI => {
994                            // Type 3 can also represent a pure power curve (gamma=2.6).
995                            const PARAMS: [f32; 5] = [2.6, 1.0, 0.0, 1.0, 0.0];
996                            create_icc_curv_para_tag(&mut tags_data, &PARAMS, 3)?
997                        }
998                        JxlTransferFunction::HLG | JxlTransferFunction::PQ => {
999                            let params = create_table_curve(64, transfer_function, false)?;
1000                            create_icc_curv_para_tag(&mut tags_data, params.as_slice(), 3)?
1001                        }
1002                    };
1003                    pad_to_4_byte_boundary(&mut tags_data);
1004
1005                    match self {
1006                        JxlColorEncoding::GrayscaleColorSpace { .. } => {
1007                            // Grayscale profiles use a single 'kTRC' tag.
1008                            collected_tags.push(TagInfo {
1009                                signature: *b"kTRC",
1010                                offset_in_tags_blob: trc_tag_start_offset,
1011                                size_unpadded: trc_tag_unpadded_size,
1012                            });
1013                        }
1014                        _ => {
1015                            // For RGB, rTRC, gTRC, and bTRC all point to the same curve data,
1016                            // an optimization to keep the profile size small.
1017                            collected_tags.push(TagInfo {
1018                                signature: *b"rTRC",
1019                                offset_in_tags_blob: trc_tag_start_offset,
1020                                size_unpadded: trc_tag_unpadded_size,
1021                            });
1022                            collected_tags.push(TagInfo {
1023                                signature: *b"gTRC",
1024                                offset_in_tags_blob: trc_tag_start_offset, // Same offset
1025                                size_unpadded: trc_tag_unpadded_size,      // Same size
1026                            });
1027                            collected_tags.push(TagInfo {
1028                                signature: *b"bTRC",
1029                                offset_in_tags_blob: trc_tag_start_offset, // Same offset
1030                                size_unpadded: trc_tag_unpadded_size,      // Same size
1031                            });
1032                        }
1033                    }
1034                }
1035            }
1036        }
1037
1038        // Construct the Tag Table bytes
1039        let mut tag_table_bytes: Vec<u8> = Vec::new();
1040        // First, the number of tags (u32)
1041        tag_table_bytes.extend_from_slice(&(collected_tags.len() as u32).to_be_bytes());
1042
1043        let header_size = header.len() as u32;
1044        // Each entry in the tag table on disk is 12 bytes: signature (4), offset (4), size (4)
1045        let tag_table_on_disk_size = 4 + (collected_tags.len() as u32 * 12);
1046
1047        for tag_info in &collected_tags {
1048            tag_table_bytes.extend_from_slice(&tag_info.signature);
1049            // The offset in the tag table is absolute from the start of the ICC profile file
1050            let final_profile_offset_for_tag =
1051                header_size + tag_table_on_disk_size + tag_info.offset_in_tags_blob;
1052            tag_table_bytes.extend_from_slice(&final_profile_offset_for_tag.to_be_bytes());
1053            // In https://www.color.org/specification/ICC.1-2022-05.pdf, section 7.3.5 reads:
1054            //
1055            // "The value of the tag data element size shall be the number of actual data
1056            // bytes and shall not include any padding at the end of the tag data element."
1057            //
1058            // The reference from conformance tests and libjxl use the padded size here instead.
1059
1060            tag_table_bytes.extend_from_slice(&tag_info.size_unpadded.to_be_bytes());
1061            // In order to get byte_exact the same output as libjxl, remove the line above
1062            // and uncomment the lines below
1063            // let padded_size = tag_info.size_unpadded.next_multiple_of(4);
1064            // tag_table_bytes.extend_from_slice(&padded_size.to_be_bytes());
1065        }
1066
1067        // Assemble the final ICC profile parts: header + tag_table + tags_data
1068        let mut final_icc_profile_data: Vec<u8> =
1069            Vec::with_capacity(header.len() + tag_table_bytes.len() + tags_data.len());
1070        final_icc_profile_data.extend_from_slice(&header);
1071        final_icc_profile_data.extend_from_slice(&tag_table_bytes);
1072        final_icc_profile_data.extend_from_slice(&tags_data);
1073
1074        // Update the profile size in the header (at offset 0)
1075        let total_profile_size = final_icc_profile_data.len() as u32;
1076        write_u32_be(&mut final_icc_profile_data, 0, total_profile_size)?;
1077
1078        // Assemble the final ICC profile parts: header + tag_table + tags_data
1079        let mut final_icc_profile_data: Vec<u8> =
1080            Vec::with_capacity(header.len() + tag_table_bytes.len() + tags_data.len());
1081        final_icc_profile_data.extend_from_slice(&header);
1082        final_icc_profile_data.extend_from_slice(&tag_table_bytes);
1083        final_icc_profile_data.extend_from_slice(&tags_data);
1084
1085        // Update the profile size in the header (at offset 0)
1086        let total_profile_size = final_icc_profile_data.len() as u32;
1087        write_u32_be(&mut final_icc_profile_data, 0, total_profile_size)?;
1088
1089        // The MD5 checksum (Profile ID) must be computed on the profile with
1090        // specific header fields zeroed out, as per the ICC specification.
1091        let mut profile_for_checksum = final_icc_profile_data.clone();
1092
1093        if profile_for_checksum.len() >= 84 {
1094            // Zero out Profile Flags at offset 44.
1095            profile_for_checksum[44..48].fill(0);
1096            // Zero out Rendering Intent at offset 64.
1097            profile_for_checksum[64..68].fill(0);
1098            // The Profile ID field at offset 84 is already zero at this stage.
1099        }
1100
1101        // Compute the MD5 hash on the modified profile data.
1102        let checksum = compute_md5(&profile_for_checksum);
1103
1104        // Write the 16-byte checksum into the "Profile ID" field of the *original*
1105        // profile data buffer, starting at offset 84.
1106        if final_icc_profile_data.len() >= 100 {
1107            final_icc_profile_data[84..100].copy_from_slice(&checksum);
1108        }
1109
1110        Ok(Some(final_icc_profile_data))
1111    }
1112
1113    pub fn srgb(grayscale: bool) -> Self {
1114        if grayscale {
1115            JxlColorEncoding::GrayscaleColorSpace {
1116                white_point: JxlWhitePoint::D65,
1117                transfer_function: JxlTransferFunction::SRGB,
1118                rendering_intent: RenderingIntent::Relative,
1119            }
1120        } else {
1121            JxlColorEncoding::RgbColorSpace {
1122                white_point: JxlWhitePoint::D65,
1123                primaries: JxlPrimaries::SRGB,
1124                transfer_function: JxlTransferFunction::SRGB,
1125                rendering_intent: RenderingIntent::Relative,
1126            }
1127        }
1128    }
1129
1130    /// Creates linear sRGB color encoding (sRGB primaries with linear transfer function).
1131    /// This is the fallback output color space for XYB images when the embedded
1132    /// color profile cannot be output to without a CMS.
1133    pub fn linear_srgb(grayscale: bool) -> Self {
1134        if grayscale {
1135            JxlColorEncoding::GrayscaleColorSpace {
1136                white_point: JxlWhitePoint::D65,
1137                transfer_function: JxlTransferFunction::Linear,
1138                rendering_intent: RenderingIntent::Relative,
1139            }
1140        } else {
1141            JxlColorEncoding::RgbColorSpace {
1142                white_point: JxlWhitePoint::D65,
1143                primaries: JxlPrimaries::SRGB,
1144                transfer_function: JxlTransferFunction::Linear,
1145                rendering_intent: RenderingIntent::Relative,
1146            }
1147        }
1148    }
1149
1150    /// Returns a copy of this encoding with linear transfer function.
1151    /// For XYB encoding, returns linear sRGB as fallback.
1152    pub fn with_linear_tf(&self) -> Self {
1153        match self {
1154            JxlColorEncoding::RgbColorSpace {
1155                white_point,
1156                primaries,
1157                rendering_intent,
1158                ..
1159            } => JxlColorEncoding::RgbColorSpace {
1160                white_point: white_point.clone(),
1161                primaries: primaries.clone(),
1162                transfer_function: JxlTransferFunction::Linear,
1163                rendering_intent: *rendering_intent,
1164            },
1165            JxlColorEncoding::GrayscaleColorSpace {
1166                white_point,
1167                rendering_intent,
1168                ..
1169            } => JxlColorEncoding::GrayscaleColorSpace {
1170                white_point: white_point.clone(),
1171                transfer_function: JxlTransferFunction::Linear,
1172                rendering_intent: *rendering_intent,
1173            },
1174            JxlColorEncoding::XYB { .. } => Self::linear_srgb(false),
1175        }
1176    }
1177
1178    /// Returns the number of color channels for this encoding.
1179    /// RGB/XYB = 3, Grayscale = 1.
1180    pub fn channels(&self) -> usize {
1181        match self {
1182            JxlColorEncoding::RgbColorSpace { .. } => 3,
1183            JxlColorEncoding::GrayscaleColorSpace { .. } => 1,
1184            JxlColorEncoding::XYB { .. } => 3,
1185        }
1186    }
1187}
1188
1189#[derive(Clone, Debug, PartialEq)]
1190pub enum JxlColorProfile {
1191    Icc(Vec<u8>),
1192    Simple(JxlColorEncoding),
1193}
1194
1195impl JxlColorProfile {
1196    /// Returns the ICC profile, panicking if unavailable.
1197    ///
1198    /// # Panics
1199    /// Panics if the color encoding cannot generate an ICC profile.
1200    /// Consider using `try_as_icc` for fallible conversion.
1201    pub fn as_icc(&self) -> Cow<'_, Vec<u8>> {
1202        match self {
1203            Self::Icc(x) => Cow::Borrowed(x),
1204            Self::Simple(encoding) => Cow::Owned(encoding.maybe_create_profile().unwrap().unwrap()),
1205        }
1206    }
1207
1208    /// Attempts to get an ICC profile, returning None if unavailable.
1209    ///
1210    /// Returns `None` for color encodings that cannot generate ICC profiles.
1211    pub fn try_as_icc(&self) -> Option<Cow<'_, Vec<u8>>> {
1212        match self {
1213            Self::Icc(x) => Some(Cow::Borrowed(x)),
1214            Self::Simple(encoding) => encoding
1215                .maybe_create_profile()
1216                .ok()
1217                .flatten()
1218                .map(Cow::Owned),
1219        }
1220    }
1221
1222    /// Returns true if both profiles represent the same color encoding.
1223    ///
1224    /// Two profiles are the same if they are both simple color encodings
1225    /// with matching color space (primaries, white point) and transfer function,
1226    /// or if they are both ICC profiles with identical bytes.
1227    pub fn same_color_encoding(&self, other: &Self) -> bool {
1228        match (self, other) {
1229            (Self::Simple(a), Self::Simple(b)) => {
1230                use JxlColorEncoding::*;
1231                match (a, b) {
1232                    (
1233                        RgbColorSpace {
1234                            white_point: wp_a,
1235                            primaries: prim_a,
1236                            transfer_function: tf_a,
1237                            ..
1238                        },
1239                        RgbColorSpace {
1240                            white_point: wp_b,
1241                            primaries: prim_b,
1242                            transfer_function: tf_b,
1243                            ..
1244                        },
1245                    ) => wp_a == wp_b && prim_a == prim_b && tf_a == tf_b,
1246                    (
1247                        GrayscaleColorSpace {
1248                            white_point: wp_a,
1249                            transfer_function: tf_a,
1250                            ..
1251                        },
1252                        GrayscaleColorSpace {
1253                            white_point: wp_b,
1254                            transfer_function: tf_b,
1255                            ..
1256                        },
1257                    ) => wp_a == wp_b && tf_a == tf_b,
1258                    // Different color space types (RGB vs Gray vs XYB)
1259                    _ => false,
1260                }
1261            }
1262            // Identical ICC bytes means identical transform — CMS is a no-op.
1263            // This is the dominant case for non-XYB images where no custom output
1264            // profile is set (output is a clone of the embedded ICC profile).
1265            // Exclude CMYK: CMS is always needed for CMYK even with identical
1266            // profiles, because the pipeline may need to consume the K channel.
1267            // This matches libjxl's `!cmyk && !other.cmyk` guard.
1268            (Self::Icc(a), Self::Icc(b)) => a == b && !self.is_cmyk(),
1269            // Mixed types (Simple vs Icc) always differ
1270            _ => false,
1271        }
1272    }
1273
1274    /// Returns the transfer function if this is a simple color profile.
1275    /// Returns None for ICC profiles or XYB.
1276    pub fn transfer_function(&self) -> Option<&JxlTransferFunction> {
1277        match self {
1278            Self::Simple(JxlColorEncoding::RgbColorSpace {
1279                transfer_function, ..
1280            })
1281            | Self::Simple(JxlColorEncoding::GrayscaleColorSpace {
1282                transfer_function, ..
1283            }) => Some(transfer_function),
1284            _ => None,
1285        }
1286    }
1287
1288    /// Returns true if the decoder can output to this color profile without a CMS.
1289    ///
1290    /// This is the equivalent of libjxl's `CanOutputToColorEncoding`. Output is possible
1291    /// when the profile is a simple encoding (not ICC) with a natively-supported transfer
1292    /// function. For grayscale, the white point must be D65.
1293    pub fn can_output_to(&self) -> bool {
1294        match self {
1295            Self::Icc(_) => false,
1296            Self::Simple(JxlColorEncoding::RgbColorSpace { .. }) => true,
1297            Self::Simple(JxlColorEncoding::GrayscaleColorSpace { white_point, .. }) => {
1298                // libjxl requires D65 white point for grayscale output without CMS
1299                *white_point == JxlWhitePoint::D65
1300            }
1301            Self::Simple(JxlColorEncoding::XYB { .. }) => {
1302                // XYB as output doesn't make sense without further conversion
1303                false
1304            }
1305        }
1306    }
1307
1308    /// Returns a copy of this profile with linear transfer function.
1309    /// For ICC profiles, returns None since we can't modify embedded ICC profiles.
1310    /// This is used to create the CMS input profile for XYB images where XybStage
1311    /// outputs linear data.
1312    pub fn with_linear_tf(&self) -> Option<Self> {
1313        match self {
1314            Self::Icc(_) => None,
1315            Self::Simple(encoding) => Some(Self::Simple(encoding.with_linear_tf())),
1316        }
1317    }
1318
1319    /// Returns the number of color channels (1 for grayscale, 3 for RGB, 4 for CMYK).
1320    ///
1321    /// For ICC profiles, this parses the profile header to determine the color space.
1322    /// Falls back to 3 (RGB) if the ICC profile cannot be parsed.
1323    pub fn channels(&self) -> usize {
1324        match self {
1325            Self::Simple(enc) => enc.channels(),
1326            Self::Icc(icc_data) => {
1327                // ICC color space signature is at bytes 16-19 in the header
1328                if icc_data.len() >= 20 {
1329                    match &icc_data[16..20] {
1330                        b"GRAY" => 1,
1331                        b"RGB " => 3,
1332                        b"CMYK" => 4,
1333                        _ => 3, // Default to RGB for unknown color spaces
1334                    }
1335                } else {
1336                    3 // Default to RGB if profile is too short
1337                }
1338            }
1339        }
1340    }
1341
1342    /// Returns true if this profile is for a CMYK color space.
1343    ///
1344    /// For ICC profiles, this parses the profile header to check the color space.
1345    /// Simple color encodings are never CMYK.
1346    pub fn is_cmyk(&self) -> bool {
1347        match self {
1348            Self::Simple(_) => false, // Simple encodings are never CMYK
1349            Self::Icc(icc_data) => {
1350                // ICC color space signature is at bytes 16-19 in the header
1351                if icc_data.len() >= 20 {
1352                    &icc_data[16..20] == b"CMYK"
1353                } else {
1354                    false
1355                }
1356            }
1357        }
1358    }
1359}
1360
1361impl fmt::Display for JxlColorProfile {
1362    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1363        match self {
1364            Self::Icc(_) => f.write_str("ICC"),
1365            Self::Simple(enc) => write!(f, "{}", enc),
1366        }
1367    }
1368}
1369
1370pub trait JxlCmsTransformer {
1371    /// Runs a single transform. The buffers each contain `num_pixels` x `num_channels` interleaved
1372    /// floating point (0..1) samples, where `num_channels` is the number of color channels of
1373    /// their respective color profiles. For CMYK data, 0 represents the maximum amount of ink
1374    /// while 1 represents no ink.
1375    fn do_transform(&mut self, input: &[f32], output: &mut [f32]) -> Result<()>;
1376
1377    /// Runs a single transform in-place. The buffer contains `num_pixels` x `num_channels`
1378    /// interleaved floating point (0..1) samples, where `num_channels` is the number of color
1379    /// channels of the input and output color profiles. For CMYK data, 0 represents the maximum
1380    /// amount of ink while 1 represents no ink.
1381    fn do_transform_inplace(&mut self, inout: &mut [f32]) -> Result<()>;
1382}
1383
1384pub trait JxlCms {
1385    /// Initializes `n` transforms (different transforms might be used in parallel) to
1386    /// convert from color space `input` to colorspace `output`, assuming an intensity of 1.0 for
1387    /// non-absolute luminance colorspaces of `intensity_target`.
1388    /// It is an error to not return `n` transforms.
1389    /// Returns the number of channels the ICC outputs, and the transforms.
1390    fn initialize_transforms(
1391        &self,
1392        n: usize,
1393        max_pixels_per_transform: usize,
1394        input: JxlColorProfile,
1395        output: JxlColorProfile,
1396        intensity_target: f32,
1397    ) -> Result<(usize, Vec<Box<dyn JxlCmsTransformer + Send + Sync>>)>;
1398}
1399
1400/// Writes a u32 value in big-endian format to the slice at the given position.
1401fn write_u32_be(slice: &mut [u8], pos: usize, value: u32) -> Result<(), Error> {
1402    if pos.checked_add(4).is_none_or(|end| end > slice.len()) {
1403        return Err(Error::IccWriteOutOfBounds);
1404    }
1405    slice[pos..pos + 4].copy_from_slice(&value.to_be_bytes());
1406    Ok(())
1407}
1408
1409/// Writes a u16 value in big-endian format to the slice at the given position.
1410fn write_u16_be(slice: &mut [u8], pos: usize, value: u16) -> Result<(), Error> {
1411    if pos.checked_add(2).is_none_or(|end| end > slice.len()) {
1412        return Err(Error::IccWriteOutOfBounds);
1413    }
1414    slice[pos..pos + 2].copy_from_slice(&value.to_be_bytes());
1415    Ok(())
1416}
1417
1418/// Writes a 4-character ASCII tag string to the slice at the given position.
1419fn write_icc_tag(slice: &mut [u8], pos: usize, tag_str: &str) -> Result<(), Error> {
1420    if tag_str.len() != 4 || !tag_str.is_ascii() {
1421        return Err(Error::IccInvalidTagString(tag_str.to_string()));
1422    }
1423    if pos.checked_add(4).is_none_or(|end| end > slice.len()) {
1424        return Err(Error::IccWriteOutOfBounds);
1425    }
1426    slice[pos..pos + 4].copy_from_slice(tag_str.as_bytes());
1427    Ok(())
1428}
1429
1430/// Creates an ICC 'mluc' tag with a single "enUS" record.
1431///
1432/// The input `text` must be ASCII, as it will be encoded as UTF-16BE by prepending
1433/// a null byte to each ASCII character.
1434fn create_icc_mluc_tag(tags: &mut Vec<u8>, text: &str) -> Result<(), Error> {
1435    // libjxl comments that "The input text must be ASCII".
1436    // We enforce this.
1437    if !text.is_ascii() {
1438        return Err(Error::IccMlucTextNotAscii(text.to_string()));
1439    }
1440    // Tag signature 'mluc' (4 bytes)
1441    tags.extend_from_slice(b"mluc");
1442    // Reserved, must be 0 (4 bytes)
1443    tags.extend_from_slice(&0u32.to_be_bytes());
1444    // Number of records (u32, 4 bytes) - Hardcoded to 1.
1445    tags.extend_from_slice(&1u32.to_be_bytes());
1446    // Record size (u32, 4 bytes) - Each record descriptor is 12 bytes.
1447    // (Language Code [2] + Country Code [2] + String Length [4] + String Offset [4])
1448    tags.extend_from_slice(&12u32.to_be_bytes());
1449    // Language Code (2 bytes) - "en" for English
1450    tags.extend_from_slice(b"en");
1451    // Country Code (2 bytes) - "US" for United States
1452    tags.extend_from_slice(b"US");
1453    // Length of the string (u32, 4 bytes)
1454    // For ASCII text encoded as UTF-16BE, each char becomes 2 bytes.
1455    let string_actual_byte_length = text.len() * 2;
1456    tags.extend_from_slice(&(string_actual_byte_length as u32).to_be_bytes());
1457    // Offset of the string (u32, 4 bytes)
1458    // The string data for this record starts at offset 28.
1459    tags.extend_from_slice(&28u32.to_be_bytes());
1460    // The actual string data, encoded as UTF-16BE.
1461    // For ASCII char 'X', UTF-16BE is 0x00 0x58.
1462    for ascii_char_code in text.as_bytes() {
1463        tags.push(0u8);
1464        tags.push(*ascii_char_code);
1465    }
1466
1467    Ok(())
1468}
1469
1470struct TagInfo {
1471    signature: [u8; 4],
1472    // Offset of this tag's data relative to the START of the `tags_data` block
1473    offset_in_tags_blob: u32,
1474    // Unpadded size of this tag's actual data content.
1475    size_unpadded: u32,
1476}
1477
1478fn pad_to_4_byte_boundary(data: &mut Vec<u8>) {
1479    data.resize(data.len().next_multiple_of(4), 0u8);
1480}
1481
1482/// Converts an f32 to s15Fixed16 format and appends it as big-endian bytes.
1483/// s15Fixed16 is a signed 32-bit number with 1 sign bit, 15 integer bits,
1484/// and 16 fractional bits.
1485fn append_s15_fixed_16(tags_data: &mut Vec<u8>, value: f32) -> Result<(), Error> {
1486    // In libjxl, the following specific range check is used: (-32767.995f <= value) && (value <= 32767.995f)
1487    // This is slightly tighter than the theoretical max positive s15.16 value.
1488    // We replicate this for consistency.
1489    if !(value.is_finite() && (-32767.995..=32767.995).contains(&value)) {
1490        return Err(Error::IccValueOutOfRangeS15Fixed16(value));
1491    }
1492
1493    // Multiply by 2^16 and round to nearest integer
1494    let scaled_value = (value * 65536.0).round();
1495    // Cast to i32 for correct two's complement representation
1496    let int_value = scaled_value as i32;
1497    tags_data.extend_from_slice(&int_value.to_be_bytes());
1498    Ok(())
1499}
1500
1501/// Creates the data for an ICC 'XYZ ' tag and appends it to `tags_data`.
1502/// The 'XYZ ' tag contains three s15Fixed16Number values.
1503fn create_icc_xyz_tag(tags_data: &mut Vec<u8>, xyz_color: &[f32; 3]) -> Result<TagInfo, Error> {
1504    // Tag signature 'XYZ ' (4 bytes, note the trailing space)
1505    let start_offset = tags_data.len() as u32;
1506    let signature = b"XYZ ";
1507    tags_data.extend_from_slice(signature);
1508
1509    // Reserved, must be 0 (4 bytes)
1510    tags_data.extend_from_slice(&0u32.to_be_bytes());
1511
1512    // XYZ data (3 * s15Fixed16Number = 3 * 4 bytes)
1513    for &val in xyz_color {
1514        append_s15_fixed_16(tags_data, val)?;
1515    }
1516
1517    Ok(TagInfo {
1518        signature: *b"wtpt",
1519        offset_in_tags_blob: start_offset,
1520        size_unpadded: (tags_data.len() as u32) - start_offset,
1521    })
1522}
1523
1524fn create_icc_chad_tag(
1525    tags_data: &mut Vec<u8>,
1526    chad_matrix: &Matrix3x3<f32>,
1527) -> Result<TagInfo, Error> {
1528    // The tag type signature "sf32" (4 bytes).
1529    let signature = b"sf32";
1530    let start_offset = tags_data.len() as u32;
1531    tags_data.extend_from_slice(signature);
1532
1533    // A reserved field (4 bytes), which must be set to 0.
1534    tags_data.extend_from_slice(&0u32.to_be_bytes());
1535
1536    // The 9 matrix elements as s15Fixed16Number values.
1537    // m[0][0], m[0][1], m[0][2], m[1][0], ..., m[2][2]
1538    for row_array in chad_matrix.iter() {
1539        for &value in row_array.iter() {
1540            append_s15_fixed_16(tags_data, value)?;
1541        }
1542    }
1543    Ok(TagInfo {
1544        signature: *b"chad",
1545        offset_in_tags_blob: start_offset,
1546        size_unpadded: (tags_data.len() as u32) - start_offset,
1547    })
1548}
1549
1550/// Converts CIE xy white point coordinates to CIE XYZ values (Y is normalized to 1.0).
1551fn cie_xyz_from_white_cie_xy(wx: f32, wy: f32) -> Result<[f32; 3], Error> {
1552    // Check for wy being too close to zero to prevent division by zero or extreme values.
1553    if wy.abs() < 1e-12 {
1554        return Err(Error::IccInvalidWhitePointY(wy));
1555    }
1556    let factor = 1.0 / wy;
1557    let x_val = wx * factor;
1558    let y_val = 1.0f32;
1559    let z_val = (1.0 - wx - wy) * factor;
1560    Ok([x_val, y_val, z_val])
1561}
1562
1563/// Creates the data for an ICC `para` (parametricCurveType) tag.
1564/// It writes `12 + 4 * params.len()` bytes.
1565fn create_icc_curv_para_tag(
1566    tags_data: &mut Vec<u8>,
1567    params: &[f32],
1568    curve_type: u16,
1569) -> Result<u32, Error> {
1570    let start_offset = tags_data.len();
1571    // Tag type 'para' (4 bytes)
1572    tags_data.extend_from_slice(b"para");
1573    // Reserved, must be 0 (4 bytes)
1574    tags_data.extend_from_slice(&0u32.to_be_bytes());
1575    // Function type (u16, 2 bytes)
1576    tags_data.extend_from_slice(&curve_type.to_be_bytes());
1577    // Reserved, must be 0 (u16, 2 bytes)
1578    tags_data.extend_from_slice(&0u16.to_be_bytes());
1579    // Parameters (s15Fixed16Number each)
1580    for &param in params {
1581        append_s15_fixed_16(tags_data, param)?;
1582    }
1583    Ok((tags_data.len() - start_offset) as u32)
1584}
1585
1586fn display_from_encoded_pq(display_intensity_target: f32, mut e: f64) -> f64 {
1587    const M1: f64 = 2610.0 / 16384.0;
1588    const M2: f64 = (2523.0 / 4096.0) * 128.0;
1589    const C1: f64 = 3424.0 / 4096.0;
1590    const C2: f64 = (2413.0 / 4096.0) * 32.0;
1591    const C3: f64 = (2392.0 / 4096.0) * 32.0;
1592    // Handle the zero case directly.
1593    if e == 0.0 {
1594        return 0.0;
1595    }
1596
1597    // Handle negative inputs by using their absolute
1598    // value for the calculation and reapplying the sign at the end.
1599    let original_sign = e.signum();
1600    e = e.abs();
1601
1602    // Core PQ EOTF formula from ST 2084.
1603    let xp = e.powf(1.0 / M2);
1604    let num = (xp - C1).max(0.0);
1605    let den = C2 - C3 * xp;
1606
1607    // In release builds, a zero denominator would lead to `inf` or `NaN`,
1608    // which is handled by the assertion below. For valid inputs (e in [0,1]),
1609    // the denominator is always positive.
1610    debug_assert!(den != 0.0, "PQ transfer function denominator is zero.");
1611
1612    let d = (num / den).powf(1.0 / M1);
1613
1614    // The result `d` should always be non-negative for non-negative inputs.
1615    debug_assert!(
1616        d >= 0.0,
1617        "PQ intermediate value `d` should not be negative."
1618    );
1619
1620    // The libjxl implementation includes a scaling factor. Note that `d` represents
1621    // a value normalized to a 10,000 nit peak.
1622    let scaled_d = d * (10000.0 / display_intensity_target as f64);
1623
1624    // Re-apply the original sign.
1625    scaled_d.copysign(original_sign)
1626}
1627
1628/// TF_HLG_Base class for BT.2100 HLG.
1629///
1630/// This struct provides methods to convert between non-linear encoded HLG signals
1631/// and linear display-referred light, following the definitions in BT.2100-2.
1632///
1633/// - **"display"**: linear light, normalized to [0, 1].
1634/// - **"encoded"**: a non-linear HLG signal, nominally in [0, 1].
1635/// - **"scene"**: scene-referred linear light, normalized to [0, 1].
1636///
1637/// The functions are designed to be unbounded to handle inputs outside the
1638/// nominal [0, 1] range, which can occur during color space conversions. Negative
1639/// inputs are handled by mirroring the function (`f(-x) = -f(x)`).
1640#[allow(non_camel_case_types)]
1641struct TF_HLG;
1642
1643impl TF_HLG {
1644    // Constants for the HLG formula, as defined in BT.2100.
1645    const A: f64 = 0.17883277;
1646    const RA: f64 = 1.0 / Self::A;
1647    const B: f64 = 1.0 - 4.0 * Self::A;
1648    const C: f64 = 0.5599107295;
1649    const INV_12: f64 = 1.0 / 12.0;
1650
1651    /// Converts a non-linear encoded signal to a linear display value (EOTF).
1652    ///
1653    /// This corresponds to `DisplayFromEncoded(e) = OOTF(InvOETF(e))`.
1654    /// Since the OOTF is simplified to an identity function, this is equivalent
1655    /// to calling `inv_oetf(e)`.
1656    #[inline]
1657    fn display_from_encoded(e: f64) -> f64 {
1658        Self::inv_oetf(e)
1659    }
1660
1661    /// Converts a linear display value to a non-linear encoded signal (inverse EOTF).
1662    ///
1663    /// This corresponds to `EncodedFromDisplay(d) = OETF(InvOOTF(d))`.
1664    /// Since the InvOOTF is an identity function, this is equivalent to `oetf(d)`.
1665    #[inline]
1666    #[allow(dead_code)]
1667    fn encoded_from_display(d: f64) -> f64 {
1668        Self::oetf(d)
1669    }
1670
1671    /// The private HLG OETF, converting scene-referred light to a non-linear signal.
1672    fn oetf(mut s: f64) -> f64 {
1673        if s == 0.0 {
1674            return 0.0;
1675        }
1676        let original_sign = s.signum();
1677        s = s.abs();
1678
1679        let e = if s <= Self::INV_12 {
1680            (3.0 * s).sqrt()
1681        } else {
1682            Self::A * (12.0 * s - Self::B).ln() + Self::C
1683        };
1684
1685        // The result should be positive for positive inputs.
1686        debug_assert!(e > 0.0);
1687
1688        e.copysign(original_sign)
1689    }
1690
1691    /// The private HLG inverse OETF, converting a non-linear signal back to scene-referred light.
1692    fn inv_oetf(mut e: f64) -> f64 {
1693        if e == 0.0 {
1694            return 0.0;
1695        }
1696        let original_sign = e.signum();
1697        e = e.abs();
1698
1699        let s = if e <= 0.5 {
1700            // The `* (1.0 / 3.0)` is slightly more efficient than `/ 3.0`.
1701            e * e * (1.0 / 3.0)
1702        } else {
1703            (((e - Self::C) * Self::RA).exp() + Self::B) * Self::INV_12
1704        };
1705
1706        // The result should be non-negative for non-negative inputs.
1707        debug_assert!(s >= 0.0);
1708
1709        s.copysign(original_sign)
1710    }
1711}
1712
1713/// Creates a lookup table for an ICC `curv` tag from a transfer function.
1714///
1715/// This function generates a vector of 16-bit integers representing the response
1716/// of the HLG or PQ electro-optical transfer functions (EOTF).
1717///
1718/// ### Arguments
1719/// * `n` - The number of entries in the lookup table. Must not exceed 4096.
1720/// * `tf` - The transfer function to model, either `TransferFunction::HLG` or `TransferFunction::PQ`.
1721/// * `tone_map` - A boolean to enable tone mapping for PQ curves. Currently a stub.
1722///
1723/// ### Returns
1724/// A `Result` containing the `Vec<f32>` lookup table or an `Error`.
1725fn create_table_curve(
1726    n: usize,
1727    tf: &JxlTransferFunction,
1728    _tone_map: bool,
1729) -> Result<Vec<f32>, Error> {
1730    // ICC Specification (v4.4, section 10.6) for `curveType` with `curv`
1731    // processing elements states the table can have at most 4096 entries.
1732    if n > 4096 {
1733        return Err(Error::IccTableSizeExceeded(n));
1734    }
1735
1736    if !matches!(tf, JxlTransferFunction::PQ | JxlTransferFunction::HLG) {
1737        return Err(Error::IccUnsupportedTransferFunction);
1738    }
1739
1740    // The peak luminance for PQ decoding, as specified in the original C++ code.
1741    const PQ_INTENSITY_TARGET: f64 = 10000.0;
1742
1743    let mut table = Vec::with_capacity(n);
1744    for i in 0..n {
1745        // `x` represents the normalized input signal, from 0.0 to 1.0.
1746        let x = i as f64 / (n - 1) as f64;
1747
1748        // Apply the specified EOTF to get the linear light value `y`.
1749        // The output `y` is normalized to the range [0.0, 1.0].
1750        let y = match tf {
1751            JxlTransferFunction::HLG => TF_HLG::display_from_encoded(x),
1752            JxlTransferFunction::PQ => {
1753                // For PQ, the output of the EOTF is absolute luminance, so we
1754                // normalize it back to [0, 1] relative to the peak luminance.
1755                display_from_encoded_pq(PQ_INTENSITY_TARGET as f32, x) / PQ_INTENSITY_TARGET
1756            }
1757            _ => unreachable!(), // Already checked above.
1758        };
1759
1760        // Note: the tone_map parameter is unused in the default build. When
1761        // kEnable3DToneMapping is true (the default, matching libjxl), HDR
1762        // content takes the 3D LUT path via create_icc_lut_atob_tag_for_hdr()
1763        // instead of this 1D curve, so tone_map is always false here. The
1764        // parameter is retained for API compatibility with a hypothetical
1765        // non-3D-LUT build.
1766
1767        // Clamp the final value to the valid range [0.0, 1.0]. This is
1768        // particularly important for HLG, which can exceed 1.0.
1769        let y_clamped = y.clamp(0.0, 1.0);
1770
1771        // table.push((y_clamped * 65535.0).round() as u16);
1772        table.push(y_clamped as f32);
1773    }
1774
1775    Ok(table)
1776}
1777
1778// ============================================================================
1779// HDR Tone Mapping Implementation
1780// ============================================================================
1781
1782/// BT.2408 HDR to SDR tone mapper.
1783/// Maps PQ content from source range (e.g., 0-10000 nits) to target range (e.g., 0-250 nits).
1784struct Rec2408ToneMapper {
1785    source_range: (f32, f32), // (min, max) in nits
1786    target_range: (f32, f32),
1787    luminances: [f32; 3], // RGB luminance coefficients (Y values)
1788
1789    // Precomputed values
1790    pq_mastering_min: f32,
1791    #[allow(dead_code)] // Stored for potential future use / debugging
1792    pq_mastering_max: f32,
1793    pq_mastering_range: f32,
1794    inv_pq_mastering_range: f32,
1795    min_lum: f32,
1796    max_lum: f32,
1797    ks: f32,
1798    inv_one_minus_ks: f32,
1799    normalizer: f32,
1800    inv_target_peak: f32,
1801}
1802
1803impl Rec2408ToneMapper {
1804    fn new(source_range: (f32, f32), target_range: (f32, f32), luminances: [f32; 3]) -> Self {
1805        let pq_mastering_min = Self::linear_to_pq(source_range.0);
1806        let pq_mastering_max = Self::linear_to_pq(source_range.1);
1807        let pq_mastering_range = pq_mastering_max - pq_mastering_min;
1808        let inv_pq_mastering_range = 1.0 / pq_mastering_range;
1809
1810        let min_lum =
1811            (Self::linear_to_pq(target_range.0) - pq_mastering_min) * inv_pq_mastering_range;
1812        let max_lum =
1813            (Self::linear_to_pq(target_range.1) - pq_mastering_min) * inv_pq_mastering_range;
1814        let ks = 1.5 * max_lum - 0.5;
1815
1816        Self {
1817            source_range,
1818            target_range,
1819            luminances,
1820            pq_mastering_min,
1821            pq_mastering_max,
1822            pq_mastering_range,
1823            inv_pq_mastering_range,
1824            min_lum,
1825            max_lum,
1826            ks,
1827            inv_one_minus_ks: 1.0 / (1.0 - ks).max(1e-6),
1828            normalizer: source_range.1 / target_range.1,
1829            inv_target_peak: 1.0 / target_range.1,
1830        }
1831    }
1832
1833    /// PQ inverse EOTF - converts luminance (nits) to PQ encoded value.
1834    /// Uses the existing `linear_to_pq_precise` from color::tf.
1835    fn linear_to_pq(luminance: f32) -> f32 {
1836        let mut val = [luminance / 10000.0]; // Normalize to 0-1 for 10000 nits
1837        linear_to_pq_precise(10000.0, &mut val);
1838        val[0]
1839    }
1840
1841    /// PQ EOTF - converts PQ encoded value to luminance (nits).
1842    /// Uses the existing `pq_to_linear_precise` from color::tf.
1843    fn pq_to_linear(encoded: f32) -> f32 {
1844        let mut val = [encoded];
1845        pq_to_linear_precise(10000.0, &mut val);
1846        val[0] * 10000.0
1847    }
1848
1849    fn t(&self, a: f32) -> f32 {
1850        (a - self.ks) * self.inv_one_minus_ks
1851    }
1852
1853    fn p(&self, b: f32) -> f32 {
1854        let t_b = self.t(b);
1855        let t_b_2 = t_b * t_b;
1856        let t_b_3 = t_b_2 * t_b;
1857        (2.0 * t_b_3 - 3.0 * t_b_2 + 1.0) * self.ks
1858            + (t_b_3 - 2.0 * t_b_2 + t_b) * (1.0 - self.ks)
1859            + (-2.0 * t_b_3 + 3.0 * t_b_2) * self.max_lum
1860    }
1861
1862    /// Apply tone mapping to RGB values (in-place)
1863    fn tone_map(&self, rgb: &mut [f32; 3]) {
1864        let luminance = self.source_range.1
1865            * (self.luminances[0] * rgb[0]
1866                + self.luminances[1] * rgb[1]
1867                + self.luminances[2] * rgb[2]);
1868
1869        let normalized_pq = ((Self::linear_to_pq(luminance) - self.pq_mastering_min)
1870            * self.inv_pq_mastering_range)
1871            .min(1.0);
1872
1873        let e2 = if normalized_pq < self.ks {
1874            normalized_pq
1875        } else {
1876            self.p(normalized_pq)
1877        };
1878
1879        let one_minus_e2 = 1.0 - e2;
1880        let one_minus_e2_2 = one_minus_e2 * one_minus_e2;
1881        let one_minus_e2_4 = one_minus_e2_2 * one_minus_e2_2;
1882        let e3 = self.min_lum * one_minus_e2_4 + e2;
1883        let e4 = e3 * self.pq_mastering_range + self.pq_mastering_min;
1884        let d4 = Self::pq_to_linear(e4);
1885        let new_luminance = d4.clamp(0.0, self.target_range.1);
1886
1887        let min_luminance = 1e-6;
1888        let use_cap = luminance <= min_luminance;
1889        let ratio = new_luminance / luminance.max(min_luminance);
1890        let cap = new_luminance * self.inv_target_peak;
1891        let multiplier = ratio * self.normalizer;
1892
1893        for c in rgb.iter_mut() {
1894            *c = if use_cap { cap } else { *c * multiplier };
1895        }
1896    }
1897}
1898
1899/// Apply HLG OOTF for tone mapping HLG content to SDR.
1900/// This implements the HLG OOTF inline for a single pixel, based on the same math
1901/// as `color::tf::hlg_scene_to_display` but avoiding the bulk-processing API.
1902fn apply_hlg_ootf(rgb: &mut [f32; 3], target_luminance: f32, luminances: [f32; 3]) {
1903    // HLG OOTF: scene-referred to display-referred conversion
1904    // system_gamma = 1.2 * 1.111^log2(intensity_display / 1000)
1905    let system_gamma = 1.2_f32 * 1.111_f32.powf((target_luminance / 1e3).log2());
1906    let exp = system_gamma - 1.0;
1907
1908    if exp.abs() < 0.1 {
1909        return;
1910    }
1911
1912    // Compute luminance and apply OOTF
1913    let mixed = rgb[0] * luminances[0] + rgb[1] * luminances[1] + rgb[2] * luminances[2];
1914    let mult = crate::util::fast_powf(mixed, exp);
1915    rgb[0] *= mult;
1916    rgb[1] *= mult;
1917    rgb[2] *= mult;
1918}
1919
1920/// Desaturate out-of-gamut pixels while preserving luminance.
1921fn gamut_map(rgb: &mut [f32; 3], luminances: &[f32; 3], preserve_saturation: f32) {
1922    let luminance = luminances[0] * rgb[0] + luminances[1] * rgb[1] + luminances[2] * rgb[2];
1923
1924    let mut gray_mix_saturation = 0.0_f32;
1925    let mut gray_mix_luminance = 0.0_f32;
1926
1927    for &val in rgb.iter() {
1928        let val_minus_gray = val - luminance;
1929        let inv_val_minus_gray = if val_minus_gray == 0.0 {
1930            1.0
1931        } else {
1932            1.0 / val_minus_gray
1933        };
1934        let val_over_val_minus_gray = val * inv_val_minus_gray;
1935
1936        if val_minus_gray < 0.0 {
1937            gray_mix_saturation = gray_mix_saturation.max(val_over_val_minus_gray);
1938        }
1939
1940        gray_mix_luminance = gray_mix_luminance.max(if val_minus_gray <= 0.0 {
1941            gray_mix_saturation
1942        } else {
1943            val_over_val_minus_gray - inv_val_minus_gray
1944        });
1945    }
1946
1947    let gray_mix = (preserve_saturation * (gray_mix_saturation - gray_mix_luminance)
1948        + gray_mix_luminance)
1949        .clamp(0.0, 1.0);
1950
1951    for val in rgb.iter_mut() {
1952        *val = gray_mix * (luminance - *val) + *val;
1953    }
1954
1955    let max_clr = rgb[0].max(rgb[1]).max(rgb[2]).max(1.0);
1956    let normalizer = 1.0 / max_clr;
1957    for v in rgb.iter_mut() {
1958        *v *= normalizer;
1959    }
1960}
1961
1962/// Tone map a single pixel and convert to PCS Lab for ICC profile.
1963fn tone_map_pixel(
1964    transfer_function: &JxlTransferFunction,
1965    primaries: &JxlPrimaries,
1966    white_point: &JxlWhitePoint,
1967    input: [f32; 3],
1968) -> Result<[u8; 3], Error> {
1969    // Get primaries coordinates
1970    let primaries_coords = primaries.to_xy_coords();
1971    let (rx, ry) = primaries_coords[0];
1972    let (gx, gy) = primaries_coords[1];
1973    let (bx, by) = primaries_coords[2];
1974    let (wx, wy) = white_point.to_xy_coords();
1975
1976    // Get the RGB to XYZ matrix (not adapted to D50 yet)
1977    let primaries_xyz = primaries_to_xyz(rx, ry, gx, gy, bx, by, wx, wy)?;
1978
1979    // Extract luminances from Y row of the matrix
1980    let luminances = [
1981        primaries_xyz[1][0] as f32,
1982        primaries_xyz[1][1] as f32,
1983        primaries_xyz[1][2] as f32,
1984    ];
1985
1986    // Apply EOTF to get linear values
1987    let mut linear = match transfer_function {
1988        JxlTransferFunction::PQ => {
1989            // PQ EOTF - convert from encoded to linear (normalized to 0-1 range for 10000 nits)
1990            [
1991                Rec2408ToneMapper::pq_to_linear(input[0]) / 10000.0,
1992                Rec2408ToneMapper::pq_to_linear(input[1]) / 10000.0,
1993                Rec2408ToneMapper::pq_to_linear(input[2]) / 10000.0,
1994            ]
1995        }
1996        JxlTransferFunction::HLG => {
1997            // Use existing hlg_to_scene from color::tf
1998            let mut vals = [input[0], input[1], input[2]];
1999            hlg_to_scene(&mut vals);
2000            vals
2001        }
2002        _ => return Err(Error::IccUnsupportedTransferFunction),
2003    };
2004
2005    // Apply tone mapping
2006    match transfer_function {
2007        JxlTransferFunction::PQ => {
2008            let tone_mapper = Rec2408ToneMapper::new(
2009                (0.0, 10000.0), // PQ source range
2010                (0.0, 250.0),   // SDR target range
2011                luminances,
2012            );
2013            tone_mapper.tone_map(&mut linear);
2014        }
2015        JxlTransferFunction::HLG => {
2016            // Apply HLG OOTF (80 nit SDR target)
2017            apply_hlg_ootf(&mut linear, 80.0, luminances);
2018        }
2019        _ => {}
2020    }
2021
2022    // Gamut map
2023    gamut_map(&mut linear, &luminances, 0.3);
2024
2025    // Get chromatic adaptation matrix
2026    let chad = adapt_to_xyz_d50(wx, wy)?;
2027
2028    // Combine matrices: to_xyzd50 = chad * primaries_xyz
2029    // Use mul_3x3_matrix from util which works with f64
2030    let to_xyzd50_f64 = mul_3x3_matrix(&chad, &primaries_xyz);
2031
2032    // Convert to f32 for the final calculation
2033    let to_xyzd50: [[f32; 3]; 3] =
2034        std::array::from_fn(|r| std::array::from_fn(|c| to_xyzd50_f64[r][c] as f32));
2035
2036    // Apply matrix to get XYZ D50
2037    let xyz = [
2038        linear[0] * to_xyzd50[0][0] + linear[1] * to_xyzd50[0][1] + linear[2] * to_xyzd50[0][2],
2039        linear[0] * to_xyzd50[1][0] + linear[1] * to_xyzd50[1][1] + linear[2] * to_xyzd50[1][2],
2040        linear[0] * to_xyzd50[2][0] + linear[1] * to_xyzd50[2][1] + linear[2] * to_xyzd50[2][2],
2041    ];
2042
2043    // Convert XYZ to Lab
2044    // D50 reference white
2045    const XN: f32 = 0.964212;
2046    const YN: f32 = 1.0;
2047    const ZN: f32 = 0.825188;
2048    const DELTA: f32 = 6.0 / 29.0;
2049
2050    let lab_f = |x: f32| -> f32 {
2051        if x <= DELTA * DELTA * DELTA {
2052            x * (1.0 / (3.0 * DELTA * DELTA)) + 4.0 / 29.0
2053        } else {
2054            x.cbrt()
2055        }
2056    };
2057
2058    let f_x = lab_f(xyz[0] / XN);
2059    let f_y = lab_f(xyz[1] / YN);
2060    let f_z = lab_f(xyz[2] / ZN);
2061
2062    // Convert to ICC PCS Lab encoding (8-bit)
2063    // L* = 116 * f(Y/Yn) - 16, encoded as L* / 100 * 255
2064    // a* = 500 * (f(X/Xn) - f(Y/Yn)), encoded as (a* + 128) for 8-bit
2065    // b* = 200 * (f(Y/Yn) - f(Z/Zn)), encoded as (b* + 128) for 8-bit
2066    Ok([
2067        (255.0 * (1.16 * f_y - 0.16).clamp(0.0, 1.0)).round() as u8,
2068        (128.0 + (500.0 * (f_x - f_y)).clamp(-128.0, 127.0)).round() as u8,
2069        (128.0 + (200.0 * (f_y - f_z)).clamp(-128.0, 127.0)).round() as u8,
2070    ])
2071}
2072
2073/// Create mAB A2B0 tag for XYB color space.
2074fn create_icc_lut_atob_tag_for_xyb(tags: &mut Vec<u8>) -> Result<(), Error> {
2075    use super::xyb_constants::*;
2076    use byteorder::{BigEndian, WriteBytesExt};
2077
2078    // Tag signature: 'mAB '
2079    tags.extend_from_slice(b"mAB ");
2080    // 4 reserved bytes set to 0
2081    tags.write_u32::<BigEndian>(0)
2082        .map_err(|_| Error::InvalidIccStream)?;
2083    // Number of input channels
2084    tags.push(3);
2085    // Number of output channels
2086    tags.push(3);
2087    // 2 reserved bytes for padding
2088    tags.write_u16::<BigEndian>(0)
2089        .map_err(|_| Error::InvalidIccStream)?;
2090
2091    // Offsets (calculated based on structure size)
2092    // offset to first B curve: 32
2093    tags.write_u32::<BigEndian>(32)
2094        .map_err(|_| Error::InvalidIccStream)?;
2095    // offset to matrix: 244
2096    tags.write_u32::<BigEndian>(244)
2097        .map_err(|_| Error::InvalidIccStream)?;
2098    // offset to first M curve: 148
2099    tags.write_u32::<BigEndian>(148)
2100        .map_err(|_| Error::InvalidIccStream)?;
2101    // offset to CLUT: 80
2102    tags.write_u32::<BigEndian>(80)
2103        .map_err(|_| Error::InvalidIccStream)?;
2104    // offset to first A curve (reuse linear B curves): 32
2105    tags.write_u32::<BigEndian>(32)
2106        .map_err(|_| Error::InvalidIccStream)?;
2107
2108    // offset = 32: B curves (3 identity/linear curves)
2109    // Each curve is 12 bytes: 'para' (4) + reserved (4) + function type (2) + reserved (2)
2110    // For type 0: Y = X^gamma, with gamma = 1.0 (identity)
2111    for _ in 0..3 {
2112        create_icc_curv_para_tag(tags, &[1.0], 0)?;
2113    }
2114
2115    // offset = 80: CLUT
2116    // 16 bytes for grid points (only first 3 used, rest 0)
2117    for i in 0..16 {
2118        tags.push(if i < 3 { 2 } else { 0 });
2119    }
2120    // precision = 2 (16-bit)
2121    tags.push(2);
2122    // 3 bytes padding
2123    tags.push(0);
2124    tags.write_u16::<BigEndian>(0)
2125        .map_err(|_| Error::InvalidIccStream)?;
2126
2127    // 2x2x2x3 entries of 2 bytes each = 48 bytes
2128    let cube = unscaled_a2b_cube_full();
2129    for row_x in &cube {
2130        for row_y in row_x {
2131            for out_f in row_y {
2132                for &val_f in out_f {
2133                    let val = (65535.0 * val_f).round().clamp(0.0, 65535.0) as u16;
2134                    tags.write_u16::<BigEndian>(val)
2135                        .map_err(|_| Error::InvalidIccStream)?;
2136                }
2137            }
2138        }
2139    }
2140
2141    // offset = 148: M curves (3 parametric curves)
2142    // Type 3 parametric curve: Y = (aX + b)^gamma + c for X >= d, else Y = cX
2143    // Each curve: 12 + 5*4 = 32 bytes
2144    let scale = xyb_scale();
2145    for i in 0..3 {
2146        let b = -XYB_OFFSET[i] - NEG_OPSIN_ABSORBANCE_BIAS_RGB[i].cbrt();
2147        let params = [
2148            3.0,                      // gamma
2149            1.0 / scale[i],           // a
2150            b,                        // b
2151            0.0,                      // c (unused)
2152            (-b * scale[i]).max(0.0), // d (make skcms happy)
2153        ];
2154        create_icc_curv_para_tag(tags, &params, 3)?;
2155    }
2156
2157    // offset = 244: Matrix (12 values as s15Fixed16)
2158    // 9 matrix values + 3 intercepts = 12 * 4 = 48 bytes
2159    for v in XYB_ICC_MATRIX {
2160        append_s15_fixed_16(tags, v as f32)?;
2161    }
2162
2163    // Intercepts
2164    for i in 0..3 {
2165        let mut intercept: f64 = 0.0;
2166        for j in 0..3 {
2167            intercept += XYB_ICC_MATRIX[i * 3 + j] * (NEG_OPSIN_ABSORBANCE_BIAS_RGB[j] as f64);
2168        }
2169        append_s15_fixed_16(tags, intercept as f32)?;
2170    }
2171
2172    Ok(())
2173}
2174
2175/// Create mft1 (8-bit LUT) A2B0 tag for HDR tone mapping.
2176fn create_icc_lut_atob_tag_for_hdr(
2177    transfer_function: &JxlTransferFunction,
2178    primaries: &JxlPrimaries,
2179    white_point: &JxlWhitePoint,
2180    tags: &mut Vec<u8>,
2181) -> Result<(), Error> {
2182    const LUT_DIM: usize = 9; // 9x9x9 3D LUT
2183
2184    // Tag signature: 'mft1'
2185    tags.extend_from_slice(b"mft1");
2186    // Reserved
2187    tags.extend_from_slice(&0u32.to_be_bytes());
2188    // Number of input channels
2189    tags.push(3);
2190    // Number of output channels
2191    tags.push(3);
2192    // Number of CLUT grid points
2193    tags.push(LUT_DIM as u8);
2194    // Padding
2195    tags.push(0);
2196
2197    // Identity matrix (3x3, s15Fixed16)
2198    for i in 0..3 {
2199        for j in 0..3 {
2200            let val: f32 = if i == j { 1.0 } else { 0.0 };
2201            append_s15_fixed_16(tags, val)?;
2202        }
2203    }
2204
2205    // Input tables (identity, 256 entries per channel)
2206    for _ in 0..3 {
2207        for i in 0..256 {
2208            tags.push(i as u8);
2209        }
2210    }
2211
2212    // 3D CLUT
2213    for ix in 0..LUT_DIM {
2214        for iy in 0..LUT_DIM {
2215            for ib in 0..LUT_DIM {
2216                let input = [
2217                    ix as f32 / (LUT_DIM - 1) as f32,
2218                    iy as f32 / (LUT_DIM - 1) as f32,
2219                    ib as f32 / (LUT_DIM - 1) as f32,
2220                ];
2221                let pcslab = tone_map_pixel(transfer_function, primaries, white_point, input)?;
2222                tags.extend_from_slice(&pcslab);
2223            }
2224        }
2225    }
2226
2227    // Output tables (identity, 256 entries per channel)
2228    for _ in 0..3 {
2229        for i in 0..256 {
2230            tags.push(i as u8);
2231        }
2232    }
2233
2234    Ok(())
2235}
2236
2237/// Create mBA B2A0 tag (no-op, required by some software like Safari).
2238fn create_icc_noop_btoa_tag(tags: &mut Vec<u8>) -> Result<(), Error> {
2239    // Tag signature: 'mBA '
2240    tags.extend_from_slice(b"mBA ");
2241    // Reserved
2242    tags.extend_from_slice(&0u32.to_be_bytes());
2243    // Number of input channels
2244    tags.push(3);
2245    // Number of output channels
2246    tags.push(3);
2247    // Padding
2248    tags.extend_from_slice(&0u16.to_be_bytes());
2249    // Offset to first B curve
2250    tags.extend_from_slice(&32u32.to_be_bytes());
2251    // Offset to matrix (0 = none)
2252    tags.extend_from_slice(&0u32.to_be_bytes());
2253    // Offset to first M curve (0 = none)
2254    tags.extend_from_slice(&0u32.to_be_bytes());
2255    // Offset to CLUT (0 = none)
2256    tags.extend_from_slice(&0u32.to_be_bytes());
2257    // Offset to first A curve (0 = none)
2258    tags.extend_from_slice(&0u32.to_be_bytes());
2259
2260    // Three identity parametric curves (gamma = 1.0)
2261    // Each curve is a 'para' type with function type 0 (simple gamma)
2262    for _ in 0..3 {
2263        create_icc_curv_para_tag(tags, &[1.0], 0)?;
2264    }
2265
2266    Ok(())
2267}
2268
2269#[cfg(test)]
2270mod test {
2271    use super::*;
2272
2273    #[test]
2274    fn test_md5() {
2275        // Test vectors
2276        let test_cases = vec![
2277            ("", "d41d8cd98f00b204e9800998ecf8427e"),
2278            (
2279                "The quick brown fox jumps over the lazy dog",
2280                "9e107d9d372bb6826bd81d3542a419d6",
2281            ),
2282            ("abc", "900150983cd24fb0d6963f7d28e17f72"),
2283            ("message digest", "f96b697d7cb7938d525a2f31aaf161d0"),
2284            (
2285                "abcdefghijklmnopqrstuvwxyz",
2286                "c3fcd3d76192e4007dfb496cca67e13b",
2287            ),
2288            (
2289                "12345678901234567890123456789012345678901234567890123456789012345678901234567890",
2290                "57edf4a22be3c955ac49da2e2107b67a",
2291            ),
2292        ];
2293
2294        for (input, expected) in test_cases {
2295            let hash = compute_md5(input.as_bytes());
2296            let hex: String = hash.iter().map(|e| format!("{:02x}", e)).collect();
2297            assert_eq!(hex, expected, "Failed for input: '{}'", input);
2298        }
2299    }
2300
2301    #[test]
2302    fn test_description() {
2303        assert_eq!(
2304            JxlColorEncoding::srgb(false).get_color_encoding_description(),
2305            "RGB_D65_SRG_Rel_SRG"
2306        );
2307        assert_eq!(
2308            JxlColorEncoding::srgb(true).get_color_encoding_description(),
2309            "Gra_D65_Rel_SRG"
2310        );
2311        assert_eq!(
2312            JxlColorEncoding::RgbColorSpace {
2313                white_point: JxlWhitePoint::D65,
2314                primaries: JxlPrimaries::BT2100,
2315                transfer_function: JxlTransferFunction::Gamma(1.7),
2316                rendering_intent: RenderingIntent::Relative
2317            }
2318            .get_color_encoding_description(),
2319            "RGB_D65_202_Rel_g1.7000000"
2320        );
2321        assert_eq!(
2322            JxlColorEncoding::RgbColorSpace {
2323                white_point: JxlWhitePoint::D65,
2324                primaries: JxlPrimaries::P3,
2325                transfer_function: JxlTransferFunction::SRGB,
2326                rendering_intent: RenderingIntent::Perceptual
2327            }
2328            .get_color_encoding_description(),
2329            "DisplayP3"
2330        );
2331    }
2332
2333    #[test]
2334    fn test_rec2408_tone_mapper() {
2335        // Test the Rec2408ToneMapper with BT.2100 luminances
2336        let luminances = [0.2627, 0.6780, 0.0593]; // BT.2100/BT.2020
2337        let tone_mapper = Rec2408ToneMapper::new((0.0, 10000.0), (0.0, 250.0), luminances);
2338
2339        // Test with a bright HDR pixel (should be compressed)
2340        let mut rgb = [0.8, 0.8, 0.8]; // High values in PQ space = very bright
2341        tone_mapper.tone_map(&mut rgb);
2342        // Result should be within valid range
2343        assert!(rgb[0] >= 0.0 && rgb[0] <= 1.0, "R out of range: {}", rgb[0]);
2344        assert!(rgb[1] >= 0.0 && rgb[1] <= 1.0, "G out of range: {}", rgb[1]);
2345        assert!(rgb[2] >= 0.0 && rgb[2] <= 1.0, "B out of range: {}", rgb[2]);
2346
2347        // Test with a dark pixel (should not be affected much)
2348        let mut rgb_dark = [0.1, 0.1, 0.1];
2349        tone_mapper.tone_map(&mut rgb_dark);
2350        assert!(
2351            rgb_dark[0] >= 0.0 && rgb_dark[0] <= 1.0,
2352            "R out of range: {}",
2353            rgb_dark[0]
2354        );
2355    }
2356
2357    #[test]
2358    fn test_hlg_ootf() {
2359        let luminances = [0.2627, 0.6780, 0.0593];
2360
2361        let mut rgb = [0.5, 0.5, 0.5];
2362        apply_hlg_ootf(&mut rgb, 80.0, luminances);
2363        // Result should be in valid range
2364        assert!(rgb[0] >= 0.0, "R should be non-negative");
2365        assert!(rgb[1] >= 0.0, "G should be non-negative");
2366        assert!(rgb[2] >= 0.0, "B should be non-negative");
2367    }
2368
2369    #[test]
2370    fn test_gamut_map() {
2371        let luminances = [0.2627, 0.6780, 0.0593];
2372
2373        // Test out-of-gamut pixel (negative value)
2374        let mut rgb = [-0.1, 0.5, 0.5];
2375        gamut_map(&mut rgb, &luminances, 0.3);
2376        // All values should be non-negative after gamut mapping
2377        assert!(rgb[0] >= 0.0, "R should be non-negative after gamut map");
2378        assert!(rgb[1] >= 0.0, "G should be non-negative after gamut map");
2379        assert!(rgb[2] >= 0.0, "B should be non-negative after gamut map");
2380
2381        // Test in-gamut pixel (should not change much)
2382        let mut rgb_valid = [0.5, 0.3, 0.2];
2383        gamut_map(&mut rgb_valid, &luminances, 0.3);
2384        assert!(rgb_valid[0] >= 0.0 && rgb_valid[0] <= 1.0);
2385        assert!(rgb_valid[1] >= 0.0 && rgb_valid[1] <= 1.0);
2386        assert!(rgb_valid[2] >= 0.0 && rgb_valid[2] <= 1.0);
2387    }
2388
2389    #[test]
2390    fn test_tone_map_pixel_pq() {
2391        let result = tone_map_pixel(
2392            &JxlTransferFunction::PQ,
2393            &JxlPrimaries::BT2100,
2394            &JxlWhitePoint::D65,
2395            [0.5, 0.5, 0.5],
2396        );
2397        assert!(result.is_ok());
2398        let lab = result.unwrap();
2399        // Lab L* should be in reasonable range for mid-gray after tone mapping
2400        assert!(lab[0] > 0, "L* should be positive for non-black input");
2401        // a* and b* should be near neutral (128) for achromatic input
2402        assert!(
2403            (lab[1] as i32 - 128).abs() < 10,
2404            "a* should be near neutral"
2405        );
2406        assert!(
2407            (lab[2] as i32 - 128).abs() < 10,
2408            "b* should be near neutral"
2409        );
2410    }
2411
2412    #[test]
2413    fn test_tone_map_pixel_hlg() {
2414        let result = tone_map_pixel(
2415            &JxlTransferFunction::HLG,
2416            &JxlPrimaries::BT2100,
2417            &JxlWhitePoint::D65,
2418            [0.5, 0.5, 0.5],
2419        );
2420        assert!(result.is_ok());
2421        let lab = result.unwrap();
2422        // Lab L* should be in reasonable range
2423        assert!(lab[0] > 0, "L* should be positive for non-black input");
2424        // a* and b* should be near neutral (128) for achromatic input
2425        assert!(
2426            (lab[1] as i32 - 128).abs() < 10,
2427            "a* should be near neutral"
2428        );
2429        assert!(
2430            (lab[2] as i32 - 128).abs() < 10,
2431            "b* should be near neutral"
2432        );
2433    }
2434
2435    #[test]
2436    fn test_hdr_icc_profile_generation_pq() {
2437        // Test that PQ HDR color encoding generates an ICC profile with A2B0/B2A0 tags.
2438        // This tests the complete HDR tone mapping pipeline without needing an actual
2439        // HDR JXL file - the color encoding is constructed programmatically.
2440        let encoding = JxlColorEncoding::RgbColorSpace {
2441            white_point: JxlWhitePoint::D65,
2442            primaries: JxlPrimaries::BT2100,
2443            transfer_function: JxlTransferFunction::PQ,
2444            rendering_intent: RenderingIntent::Relative,
2445        };
2446
2447        assert!(encoding.can_tone_map_for_icc());
2448
2449        let result = encoding.maybe_create_profile();
2450        assert!(result.is_ok(), "Profile creation should succeed");
2451        let profile_opt = result.unwrap();
2452        assert!(profile_opt.is_some(), "Profile should be generated for PQ");
2453
2454        let profile = profile_opt.unwrap();
2455        // Profile should be a valid ICC profile (starts with profile size, then signature)
2456        assert!(profile.len() > 128, "Profile should have header + tags");
2457
2458        // Verify header has Lab PCS (bytes 20-23 should be "Lab ")
2459        assert_eq!(
2460            &profile[20..24],
2461            b"Lab ",
2462            "PCS should be Lab for HDR profiles"
2463        );
2464
2465        // Check for 'mft1' (A2B0 tag type) somewhere in the profile
2466        assert!(
2467            profile.windows(4).any(|w| w == b"mft1"),
2468            "Profile should contain mft1 tag (A2B0)"
2469        );
2470        assert!(
2471            profile.windows(4).any(|w| w == b"mBA "),
2472            "Profile should contain mBA tag (B2A0)"
2473        );
2474
2475        // Verify CICP tag is present (for standard primaries)
2476        assert!(
2477            profile.windows(4).any(|w| w == b"cicp"),
2478            "Profile should contain cicp tag"
2479        );
2480    }
2481
2482    #[test]
2483    fn test_hdr_icc_profile_generation_hlg() {
2484        // Test that HLG HDR color encoding generates an ICC profile with A2B0/B2A0 tags
2485        let encoding = JxlColorEncoding::RgbColorSpace {
2486            white_point: JxlWhitePoint::D65,
2487            primaries: JxlPrimaries::BT2100,
2488            transfer_function: JxlTransferFunction::HLG,
2489            rendering_intent: RenderingIntent::Relative,
2490        };
2491
2492        assert!(encoding.can_tone_map_for_icc());
2493
2494        let result = encoding.maybe_create_profile();
2495        assert!(result.is_ok(), "Profile creation should succeed");
2496        let profile_opt = result.unwrap();
2497        assert!(profile_opt.is_some(), "Profile should be generated for HLG");
2498
2499        let profile = profile_opt.unwrap();
2500        assert!(profile.len() > 128, "Profile should have header + tags");
2501    }
2502
2503    #[test]
2504    fn test_pq_eotf_inv_eotf_roundtrip() {
2505        // Test that linear_to_pq and pq_to_linear are inverses
2506        let test_values: [f32; 5] = [0.0, 100.0, 1000.0, 5000.0, 10000.0];
2507        for &luminance in &test_values {
2508            let encoded = Rec2408ToneMapper::linear_to_pq(luminance);
2509            let decoded = Rec2408ToneMapper::pq_to_linear(encoded);
2510            let diff = (luminance - decoded).abs();
2511            assert!(
2512                diff < 1.0,
2513                "Roundtrip failed for {}: got {}, diff {}",
2514                luminance,
2515                decoded,
2516                diff
2517            );
2518        }
2519    }
2520
2521    #[test]
2522    fn test_can_tone_map_for_icc() {
2523        // PQ with D65 and standard primaries should be able to tone map
2524        let pq_bt2100 = JxlColorEncoding::RgbColorSpace {
2525            white_point: JxlWhitePoint::D65,
2526            primaries: JxlPrimaries::BT2100,
2527            transfer_function: JxlTransferFunction::PQ,
2528            rendering_intent: RenderingIntent::Relative,
2529        };
2530        assert!(pq_bt2100.can_tone_map_for_icc());
2531
2532        // HLG with D65 and standard primaries should be able to tone map
2533        let hlg_bt2100 = JxlColorEncoding::RgbColorSpace {
2534            white_point: JxlWhitePoint::D65,
2535            primaries: JxlPrimaries::BT2100,
2536            transfer_function: JxlTransferFunction::HLG,
2537            rendering_intent: RenderingIntent::Relative,
2538        };
2539        assert!(hlg_bt2100.can_tone_map_for_icc());
2540
2541        // sRGB should NOT be able to tone map (not HDR)
2542        let srgb = JxlColorEncoding::srgb(false);
2543        assert!(!srgb.can_tone_map_for_icc());
2544
2545        // Custom primaries should NOT be able to tone map
2546        let custom_pq = JxlColorEncoding::RgbColorSpace {
2547            white_point: JxlWhitePoint::D65,
2548            primaries: JxlPrimaries::Chromaticities {
2549                rx: 0.7,
2550                ry: 0.3,
2551                gx: 0.2,
2552                gy: 0.8,
2553                bx: 0.15,
2554                by: 0.05,
2555            },
2556            transfer_function: JxlTransferFunction::PQ,
2557            rendering_intent: RenderingIntent::Relative,
2558        };
2559        assert!(!custom_pq.can_tone_map_for_icc());
2560    }
2561
2562    /// Integration test: decode actual HDR PQ test file and verify ICC profile
2563    #[test]
2564    fn test_hdr_pq_file_icc_profile() {
2565        use crate::api::{JxlDecoder, JxlDecoderOptions, ProcessingResult};
2566
2567        let data = std::fs::read("resources/test/hdr_pq_test.jxl")
2568            .expect("Failed to read hdr_pq_test.jxl - run from jxl crate directory");
2569
2570        let options = JxlDecoderOptions::default();
2571        let decoder = JxlDecoder::new(options);
2572        let mut input: &[u8] = &data;
2573
2574        let decoder_info = match decoder.process(&mut input).unwrap() {
2575            ProcessingResult::Complete { result } => result,
2576            _ => panic!("Expected complete decoding"),
2577        };
2578
2579        // Get the color profile
2580        let color_profile = decoder_info.output_color_profile();
2581
2582        // For HDR PQ content, we should be able to generate an ICC profile
2583        let icc = color_profile.try_as_icc();
2584        assert!(
2585            icc.is_some(),
2586            "Should generate ICC profile for HDR PQ content"
2587        );
2588
2589        let profile = icc.unwrap();
2590        // Verify it's an HDR profile with Lab PCS
2591        assert_eq!(&profile[20..24], b"Lab ", "PCS should be Lab for HDR");
2592        // Verify A2B0 tag exists
2593        assert!(
2594            profile.windows(4).any(|w| w == b"A2B0"),
2595            "Should have A2B0 tag"
2596        );
2597        // Verify B2A0 tag exists
2598        assert!(
2599            profile.windows(4).any(|w| w == b"B2A0"),
2600            "Should have B2A0 tag"
2601        );
2602    }
2603
2604    /// Integration test: decode actual HDR HLG test file and verify ICC profile
2605    #[test]
2606    fn test_hdr_hlg_file_icc_profile() {
2607        use crate::api::{JxlDecoder, JxlDecoderOptions, ProcessingResult};
2608
2609        let data = std::fs::read("resources/test/hdr_hlg_test.jxl")
2610            .expect("Failed to read hdr_hlg_test.jxl - run from jxl crate directory");
2611
2612        let options = JxlDecoderOptions::default();
2613        let decoder = JxlDecoder::new(options);
2614        let mut input: &[u8] = &data;
2615
2616        let decoder_info = match decoder.process(&mut input).unwrap() {
2617            ProcessingResult::Complete { result } => result,
2618            _ => panic!("Expected complete decoding"),
2619        };
2620
2621        // Get the color profile
2622        let color_profile = decoder_info.output_color_profile();
2623
2624        // For HDR HLG content, we should be able to generate an ICC profile
2625        let icc = color_profile.try_as_icc();
2626        assert!(
2627            icc.is_some(),
2628            "Should generate ICC profile for HDR HLG content"
2629        );
2630
2631        let profile = icc.unwrap();
2632        // Verify it's an HDR profile with Lab PCS
2633        assert_eq!(&profile[20..24], b"Lab ", "PCS should be Lab for HDR");
2634        // Verify A2B0 tag exists
2635        assert!(
2636            profile.windows(4).any(|w| w == b"A2B0"),
2637            "Should have A2B0 tag"
2638        );
2639    }
2640
2641    #[test]
2642    fn test_same_color_encoding_identical() {
2643        // Identical encodings → same
2644        let srgb1 = JxlColorProfile::Simple(JxlColorEncoding::RgbColorSpace {
2645            white_point: JxlWhitePoint::D65,
2646            primaries: JxlPrimaries::SRGB,
2647            transfer_function: JxlTransferFunction::SRGB,
2648            rendering_intent: RenderingIntent::Relative,
2649        });
2650        let srgb2 = JxlColorProfile::Simple(JxlColorEncoding::RgbColorSpace {
2651            white_point: JxlWhitePoint::D65,
2652            primaries: JxlPrimaries::SRGB,
2653            transfer_function: JxlTransferFunction::SRGB,
2654            rendering_intent: RenderingIntent::Relative,
2655        });
2656        assert!(srgb1.same_color_encoding(&srgb2));
2657    }
2658
2659    #[test]
2660    fn test_same_color_encoding_different_transfer() {
2661        // Same primaries and white point, different transfer function → NOT same
2662        let srgb_gamma = JxlColorProfile::Simple(JxlColorEncoding::RgbColorSpace {
2663            white_point: JxlWhitePoint::D65,
2664            primaries: JxlPrimaries::SRGB,
2665            transfer_function: JxlTransferFunction::SRGB,
2666            rendering_intent: RenderingIntent::Relative,
2667        });
2668        let srgb_linear = JxlColorProfile::Simple(JxlColorEncoding::RgbColorSpace {
2669            white_point: JxlWhitePoint::D65,
2670            primaries: JxlPrimaries::SRGB,
2671            transfer_function: JxlTransferFunction::Linear,
2672            rendering_intent: RenderingIntent::Relative,
2673        });
2674        assert!(!srgb_gamma.same_color_encoding(&srgb_linear));
2675        assert!(!srgb_linear.same_color_encoding(&srgb_gamma));
2676    }
2677
2678    #[test]
2679    fn test_same_color_encoding_different_primaries() {
2680        // Different primaries → NOT same
2681        let srgb = JxlColorProfile::Simple(JxlColorEncoding::RgbColorSpace {
2682            white_point: JxlWhitePoint::D65,
2683            primaries: JxlPrimaries::SRGB,
2684            transfer_function: JxlTransferFunction::SRGB,
2685            rendering_intent: RenderingIntent::Relative,
2686        });
2687        let p3 = JxlColorProfile::Simple(JxlColorEncoding::RgbColorSpace {
2688            white_point: JxlWhitePoint::D65,
2689            primaries: JxlPrimaries::P3,
2690            transfer_function: JxlTransferFunction::SRGB,
2691            rendering_intent: RenderingIntent::Relative,
2692        });
2693        assert!(!srgb.same_color_encoding(&p3));
2694        assert!(!p3.same_color_encoding(&srgb));
2695    }
2696
2697    #[test]
2698    fn test_same_color_encoding_different_white_point() {
2699        // Different white point → NOT same
2700        let d65 = JxlColorProfile::Simple(JxlColorEncoding::RgbColorSpace {
2701            white_point: JxlWhitePoint::D65,
2702            primaries: JxlPrimaries::SRGB,
2703            transfer_function: JxlTransferFunction::SRGB,
2704            rendering_intent: RenderingIntent::Relative,
2705        });
2706        let dci = JxlColorProfile::Simple(JxlColorEncoding::RgbColorSpace {
2707            white_point: JxlWhitePoint::DCI,
2708            primaries: JxlPrimaries::SRGB,
2709            transfer_function: JxlTransferFunction::SRGB,
2710            rendering_intent: RenderingIntent::Relative,
2711        });
2712        assert!(!d65.same_color_encoding(&dci));
2713    }
2714
2715    #[test]
2716    fn test_same_color_encoding_grayscale() {
2717        // Grayscale with same white point, different transfer → NOT same
2718        let gray_srgb = JxlColorProfile::Simple(JxlColorEncoding::GrayscaleColorSpace {
2719            white_point: JxlWhitePoint::D65,
2720            transfer_function: JxlTransferFunction::SRGB,
2721            rendering_intent: RenderingIntent::Relative,
2722        });
2723        let gray_linear = JxlColorProfile::Simple(JxlColorEncoding::GrayscaleColorSpace {
2724            white_point: JxlWhitePoint::D65,
2725            transfer_function: JxlTransferFunction::Linear,
2726            rendering_intent: RenderingIntent::Relative,
2727        });
2728        assert!(!gray_srgb.same_color_encoding(&gray_linear));
2729        // But identical grayscale encodings are same
2730        let gray_srgb2 = JxlColorProfile::Simple(JxlColorEncoding::GrayscaleColorSpace {
2731            white_point: JxlWhitePoint::D65,
2732            transfer_function: JxlTransferFunction::SRGB,
2733            rendering_intent: RenderingIntent::Relative,
2734        });
2735        assert!(gray_srgb.same_color_encoding(&gray_srgb2));
2736    }
2737
2738    #[test]
2739    fn test_same_color_encoding_icc_profile() {
2740        let srgb = JxlColorProfile::Simple(JxlColorEncoding::RgbColorSpace {
2741            white_point: JxlWhitePoint::D65,
2742            primaries: JxlPrimaries::SRGB,
2743            transfer_function: JxlTransferFunction::SRGB,
2744            rendering_intent: RenderingIntent::Relative,
2745        });
2746        let icc_a = JxlColorProfile::Icc(vec![0u8; 100]);
2747        let icc_b = JxlColorProfile::Icc(vec![0u8; 100]); // Same bytes as icc_a
2748        let icc_c = JxlColorProfile::Icc(vec![1u8; 100]); // Different bytes
2749
2750        // Mixed types never match
2751        assert!(!srgb.same_color_encoding(&icc_a));
2752        assert!(!icc_a.same_color_encoding(&srgb));
2753
2754        // Identical ICC bytes → same encoding (CMS is a no-op)
2755        assert!(icc_a.same_color_encoding(&icc_a));
2756        assert!(icc_a.same_color_encoding(&icc_b));
2757
2758        // Different ICC bytes → different encoding
2759        assert!(!icc_a.same_color_encoding(&icc_c));
2760
2761        // CMYK ICC profiles are never considered same (CMS always needed for K channel)
2762        let mut cmyk_data = vec![0u8; 100];
2763        cmyk_data[16..20].copy_from_slice(b"CMYK");
2764        let cmyk_a = JxlColorProfile::Icc(cmyk_data.clone());
2765        let cmyk_b = JxlColorProfile::Icc(cmyk_data);
2766        assert!(!cmyk_a.same_color_encoding(&cmyk_b));
2767    }
2768
2769    #[test]
2770    fn test_same_color_encoding_rgb_vs_grayscale() {
2771        // RGB vs Grayscale → NOT same
2772        let rgb = JxlColorProfile::Simple(JxlColorEncoding::RgbColorSpace {
2773            white_point: JxlWhitePoint::D65,
2774            primaries: JxlPrimaries::SRGB,
2775            transfer_function: JxlTransferFunction::SRGB,
2776            rendering_intent: RenderingIntent::Relative,
2777        });
2778        let gray = JxlColorProfile::Simple(JxlColorEncoding::GrayscaleColorSpace {
2779            white_point: JxlWhitePoint::D65,
2780            transfer_function: JxlTransferFunction::SRGB,
2781            rendering_intent: RenderingIntent::Relative,
2782        });
2783        assert!(!rgb.same_color_encoding(&gray));
2784        assert!(!gray.same_color_encoding(&rgb));
2785    }
2786
2787    /// Verify XYB color profiles generate valid ICC profiles with A2B0/B2A0 tags.
2788    #[test]
2789    fn test_xyb_icc_profile_generation() {
2790        let xyb = JxlColorProfile::Simple(JxlColorEncoding::XYB {
2791            rendering_intent: RenderingIntent::Perceptual,
2792        });
2793
2794        let icc = xyb.try_as_icc().expect("XYB should generate ICC profile");
2795        assert!(!icc.is_empty());
2796        assert!(icc.windows(4).any(|w| w == b"mAB "));
2797        assert!(icc.windows(4).any(|w| w == b"mBA "));
2798    }
2799}