Skip to main content

rawshift_image/metadata/
icc.rs

1//! ICC profile handling for image export.
2//!
3//! Provides ICC profile embedding for JPEG and other formats.
4
5/// Error type for ICC operations.
6#[derive(Debug)]
7#[allow(dead_code)]
8pub enum IccError {
9    /// Invalid ICC profile data
10    InvalidProfile(String),
11    /// Failed to manipulate image container
12    Container(String),
13}
14
15impl std::fmt::Display for IccError {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            IccError::InvalidProfile(msg) => write!(f, "Invalid ICC profile: {}", msg),
19            IccError::Container(msg) => write!(f, "Container manipulation error: {}", msg),
20        }
21    }
22}
23
24impl std::error::Error for IccError {}
25
26#[cfg(feature = "container-embed")]
27impl From<img_parts::Error> for IccError {
28    fn from(e: img_parts::Error) -> Self {
29        IccError::Container(e.to_string())
30    }
31}
32
33impl From<std::io::Error> for IccError {
34    fn from(e: std::io::Error) -> Self {
35        IccError::Container(e.to_string())
36    }
37}
38
39/// Embedded ICC profile data.
40#[derive(Debug, Clone)]
41pub struct IccProfile {
42    data: Vec<u8>,
43}
44
45impl IccProfile {
46    /// Create an sRGB ICC profile.
47    ///
48    /// This is a minimal sRGB profile suitable for embedding in most images.
49    /// The profile is based on the sRGB IEC61966-2.1 specification.
50    pub fn srgb() -> Self {
51        Self::create_minimal_srgb()
52    }
53
54    /// Create a minimal sRGB ICC profile programmatically.
55    fn create_minimal_srgb() -> Self {
56        // sRGB primaries and white point (D65) in fixed-point XYZ (s15Fixed16Number)
57        // Values are multiplied by 65536 (16.16 fixed point)
58        const SRGB_RED_X: u32 = 0x00006FA2; // 0.4360
59        const SRGB_RED_Y: u32 = 0x000038F5; // 0.2224
60        const SRGB_RED_Z: u32 = 0x00000390; // 0.0139
61
62        const SRGB_GREEN_X: u32 = 0x00006299; // 0.3851
63        const SRGB_GREEN_Y: u32 = 0x0000B786; // 0.7169
64        const SRGB_GREEN_Z: u32 = 0x00001852; // 0.0971
65
66        const SRGB_BLUE_X: u32 = 0x000024A0; // 0.1431
67        const SRGB_BLUE_Y: u32 = 0x00000F84; // 0.0606
68        const SRGB_BLUE_Z: u32 = 0x0000B6CF; // 0.7141
69
70        const D50_X: u32 = 0x0000F6D6; // 0.9642 (D50 for ICC PCS)
71        const D50_Y: u32 = 0x00010000; // 1.0000
72        const D50_Z: u32 = 0x0000D32D; // 0.8249
73
74        let mut profile = Vec::with_capacity(560);
75
76        // === HEADER (128 bytes) ===
77        let profile_size: u32 = 0; // Will update at end
78        profile.extend_from_slice(&profile_size.to_be_bytes()); // Profile size (offset 0)
79        profile.extend_from_slice(b"\0\0\0\0"); // Preferred CMM (offset 4)
80        profile.extend_from_slice(&[0x02, 0x10, 0x00, 0x00]); // Version 2.1.0 (offset 8)
81        profile.extend_from_slice(b"mntr"); // Device class: monitor (offset 12)
82        profile.extend_from_slice(b"RGB "); // Color space: RGB (offset 16)
83        profile.extend_from_slice(b"XYZ "); // PCS: XYZ (offset 20)
84        profile.extend_from_slice(&[0u8; 12]); // Creation date/time (offset 24)
85        profile.extend_from_slice(b"acsp"); // Profile signature (offset 36)
86        profile.extend_from_slice(b"\0\0\0\0"); // Platform (offset 40)
87        profile.extend_from_slice(&[0u8; 4]); // Flags (offset 44)
88        profile.extend_from_slice(&[0u8; 4]); // Device manufacturer (offset 48)
89        profile.extend_from_slice(&[0u8; 4]); // Device model (offset 52)
90        profile.extend_from_slice(&[0u8; 8]); // Device attributes (offset 56)
91        profile.extend_from_slice(&[0, 0, 0, 0]); // Rendering intent: perceptual (offset 64)
92        // PCS illuminant (D50) (offset 68)
93        profile.extend_from_slice(&D50_X.to_be_bytes());
94        profile.extend_from_slice(&D50_Y.to_be_bytes());
95        profile.extend_from_slice(&D50_Z.to_be_bytes());
96        profile.extend_from_slice(&[0u8; 4]); // Profile creator (offset 80)
97        profile.extend_from_slice(&[0u8; 16]); // Profile ID/MD5 (offset 84)
98        profile.extend_from_slice(&[0u8; 28]); // Reserved (offset 100)
99
100        assert_eq!(profile.len(), 128, "Header must be 128 bytes");
101
102        // === TAG TABLE ===
103        // Tags: wtpt, rXYZ, gXYZ, bXYZ, rTRC, gTRC, bTRC, cprt, desc
104        let tag_count: u32 = 9;
105        profile.extend_from_slice(&tag_count.to_be_bytes());
106
107        // Calculate data offsets
108        // Tag table starts at 128, each entry is 12 bytes
109        // Data starts after 128 + 4 + (9 * 12) = 128 + 4 + 108 = 240
110        let tag_data_start: u32 = 128 + 4 + (tag_count * 12);
111        let mut data_offset = tag_data_start;
112
113        // Helper to add tag entry
114        fn add_tag_entry(buf: &mut Vec<u8>, sig: &[u8; 4], offset: u32, size: u32) {
115            buf.extend_from_slice(sig);
116            buf.extend_from_slice(&offset.to_be_bytes());
117            buf.extend_from_slice(&size.to_be_bytes());
118        }
119
120        // XYZ type is 20 bytes: sig(4) + reserved(4) + X(4) + Y(4) + Z(4)
121        // curv type with 1 entry is 14 bytes: sig(4) + reserved(4) + count(4) + gamma(2)
122        // text type is 8 + strlen + 1
123        // desc type is 12 + strlen + 1 (simplified)
124
125        let wtpt_offset = data_offset;
126        add_tag_entry(&mut profile, b"wtpt", wtpt_offset, 20);
127        data_offset += 20;
128
129        let rxyz_offset = data_offset;
130        add_tag_entry(&mut profile, b"rXYZ", rxyz_offset, 20);
131        data_offset += 20;
132
133        let gxyz_offset = data_offset;
134        add_tag_entry(&mut profile, b"gXYZ", gxyz_offset, 20);
135        data_offset += 20;
136
137        let bxyz_offset = data_offset;
138        add_tag_entry(&mut profile, b"bXYZ", bxyz_offset, 20);
139        data_offset += 20;
140
141        // TRC tags can share the same data if identical
142        let trc_offset = data_offset;
143        let trc_size: u32 = 14;
144        add_tag_entry(&mut profile, b"rTRC", trc_offset, trc_size);
145        data_offset += (trc_size + 3) & !3; // Align to 4 bytes
146        add_tag_entry(&mut profile, b"gTRC", trc_offset, trc_size); // Share same data
147        add_tag_entry(&mut profile, b"bTRC", trc_offset, trc_size); // Share same data
148
149        let cprt_text = b"Public Domain";
150        let cprt_size: u32 = 8 + cprt_text.len() as u32 + 1;
151        let cprt_offset = data_offset;
152        add_tag_entry(&mut profile, b"cprt", cprt_offset, cprt_size);
153        data_offset += (cprt_size + 3) & !3;
154
155        let desc_text = b"sRGB";
156        let desc_size: u32 = 12 + desc_text.len() as u32 + 1;
157        let desc_offset = data_offset;
158        add_tag_entry(&mut profile, b"desc", desc_offset, desc_size);
159        let _data_offset = data_offset + ((desc_size + 3) & !3);
160
161        assert_eq!(
162            profile.len(),
163            tag_data_start as usize,
164            "Tag table size mismatch"
165        );
166
167        // === TAG DATA ===
168
169        // wtpt (white point)
170        fn write_xyz(buf: &mut Vec<u8>, x: u32, y: u32, z: u32) {
171            buf.extend_from_slice(b"XYZ ");
172            buf.extend_from_slice(&[0u8; 4]); // Reserved
173            buf.extend_from_slice(&x.to_be_bytes());
174            buf.extend_from_slice(&y.to_be_bytes());
175            buf.extend_from_slice(&z.to_be_bytes());
176        }
177
178        // D50 white point (ICC PCS standard)
179        write_xyz(&mut profile, D50_X, D50_Y, D50_Z);
180
181        // rXYZ (red primary)
182        write_xyz(&mut profile, SRGB_RED_X, SRGB_RED_Y, SRGB_RED_Z);
183
184        // gXYZ (green primary)
185        write_xyz(&mut profile, SRGB_GREEN_X, SRGB_GREEN_Y, SRGB_GREEN_Z);
186
187        // bXYZ (blue primary)
188        write_xyz(&mut profile, SRGB_BLUE_X, SRGB_BLUE_Y, SRGB_BLUE_Z);
189
190        // TRC (gamma curve) - using gamma 2.2 approximation
191        // u8Fixed8Number: 0x0238 ≈ 2.21875
192        let gamma_22: u16 = 0x0238;
193        profile.extend_from_slice(b"curv");
194        profile.extend_from_slice(&[0u8; 4]); // Reserved
195        profile.extend_from_slice(&1u32.to_be_bytes()); // Count = 1 means gamma value
196        profile.extend_from_slice(&gamma_22.to_be_bytes());
197        // Pad to 4 bytes
198        profile.extend_from_slice(&[0u8; 2]);
199
200        // cprt (copyright)
201        profile.extend_from_slice(b"text");
202        profile.extend_from_slice(&[0u8; 4]); // Reserved
203        profile.extend_from_slice(cprt_text);
204        profile.push(0); // Null terminator
205        // Pad to 4 bytes
206        while profile.len() % 4 != 0 {
207            profile.push(0);
208        }
209
210        // desc (description)
211        profile.extend_from_slice(b"desc");
212        profile.extend_from_slice(&[0u8; 4]); // Reserved
213        profile.extend_from_slice(&(desc_text.len() as u32 + 1).to_be_bytes()); // Count
214        profile.extend_from_slice(desc_text);
215        profile.push(0); // Null terminator
216        // Pad to 4 bytes
217        while profile.len() % 4 != 0 {
218            profile.push(0);
219        }
220
221        // Update profile size in header
222        let final_size = profile.len() as u32;
223        profile[0..4].copy_from_slice(&final_size.to_be_bytes());
224
225        Self { data: profile }
226    }
227
228    /// Create an ICC profile from raw bytes.
229    #[allow(dead_code)]
230    pub fn from_bytes(data: Vec<u8>) -> Self {
231        Self { data }
232    }
233
234    /// Get the ICC profile bytes.
235    #[allow(dead_code)]
236    pub fn as_bytes(&self) -> &[u8] {
237        &self.data
238    }
239
240    /// Check if this is a valid ICC profile.
241    ///
242    /// Performs basic validation by checking the magic bytes.
243    #[allow(dead_code)]
244    pub fn is_valid(&self) -> bool {
245        if self.data.len() < 40 {
246            return false;
247        }
248        // ICC magic at offset 36: 'acsp'
249        &self.data[36..40] == b"acsp"
250    }
251
252    /// Append ICC profile to existing JPEG data.
253    ///
254    /// Uses img-parts for segment manipulation.
255    #[cfg(feature = "container-embed")]
256    pub fn append_to_jpeg(&self, jpeg_data: Vec<u8>) -> Result<Vec<u8>, IccError> {
257        use img_parts::jpeg::Jpeg;
258        use img_parts::{Bytes, ImageICC};
259        use std::io::Cursor;
260
261        let mut jpeg = Jpeg::from_bytes(Bytes::from(jpeg_data))?;
262        jpeg.set_icc_profile(Some(Bytes::from(self.data.clone())));
263
264        let mut output = Cursor::new(Vec::new());
265        jpeg.encoder().write_to(&mut output)?;
266        Ok(output.into_inner())
267    }
268
269    /// Embed ICC profile into AVIF (ISOBMFF) data.
270    ///
271    /// Replaces or inserts a `colr rICC` box in `meta/iprp/ipco` and patches
272    /// `iloc` extent offsets to account for the file-size change.
273    pub fn append_to_avif(&self, avif_data: Vec<u8>) -> Result<Vec<u8>, IccError> {
274        let mut data = avif_data;
275
276        // Find top-level meta box
277        let data_len = data.len();
278        let meta_start = find_box(&data, 0, data_len, b"meta")
279            .ok_or_else(|| IccError::Container("no meta box in AVIF".into()))?;
280        let meta_size = read_u32_be(&data, meta_start) as usize;
281        let meta_end = meta_start + meta_size;
282        // meta is FullBox: size(4)+type(4)+version+flags(4) = 12-byte header
283        let meta_content_start = meta_start + 12;
284
285        // Find iprp inside meta
286        let iprp_start = find_box(&data, meta_content_start, meta_end, b"iprp")
287            .ok_or_else(|| IccError::Container("no iprp box in AVIF".into()))?;
288        let iprp_size = read_u32_be(&data, iprp_start) as usize;
289        let iprp_end = iprp_start + iprp_size;
290        let iprp_content_start = iprp_start + 8;
291
292        // Find ipco inside iprp
293        let ipco_start = find_box(&data, iprp_content_start, iprp_end, b"ipco")
294            .ok_or_else(|| IccError::Container("no ipco box in AVIF".into()))?;
295        let ipco_size = read_u32_be(&data, ipco_start) as usize;
296        let ipco_end = ipco_start + ipco_size;
297        let ipco_content_start = ipco_start + 8;
298
299        // Build new colr rICC box: size(4) + "colr"(4) + "rICC"(4) + icc_bytes
300        let icc_bytes = self.as_bytes();
301        let new_colr_size = 12 + icc_bytes.len();
302        let mut new_colr_box = Vec::with_capacity(new_colr_size);
303        new_colr_box.extend_from_slice(&(new_colr_size as u32).to_be_bytes());
304        new_colr_box.extend_from_slice(b"colr");
305        new_colr_box.extend_from_slice(b"rICC");
306        new_colr_box.extend_from_slice(icc_bytes);
307
308        // Replace existing colr box or insert at start of ipco content
309        let (splice_start, splice_end) =
310            if let Some(colr_start) = find_box(&data, ipco_content_start, ipco_end, b"colr") {
311                let old_size = read_u32_be(&data, colr_start) as usize;
312                (colr_start, colr_start + old_size)
313            } else {
314                (ipco_content_start, ipco_content_start)
315            };
316
317        let delta = new_colr_size as isize - (splice_end - splice_start) as isize;
318        data.splice(splice_start..splice_end, new_colr_box);
319
320        // Patch parent box sizes (all starts are before splice point, still valid)
321        write_u32_be(&mut data, ipco_start, (ipco_size as isize + delta) as u32);
322        write_u32_be(&mut data, iprp_start, (iprp_size as isize + delta) as u32);
323        write_u32_be(&mut data, meta_start, (meta_size as isize + delta) as u32);
324
325        // Patch iloc extent offsets: mdat shifted by delta bytes
326        let new_meta_end = meta_start + (meta_size as isize + delta) as usize;
327        if let Some(iloc_start) = find_box(&data, meta_content_start, new_meta_end, b"iloc") {
328            patch_iloc_extents(&mut data, iloc_start, delta)?;
329        }
330
331        Ok(data)
332    }
333
334    /// Embed ICC profile into JXL data.
335    ///
336    /// If `jxl_data` is a naked codestream (starts with `[0xFF, 0x0A]`), it is
337    /// first wrapped in a JXL container. An `iccp` box is then inserted before
338    /// the first `Exif`/`xml ` box, or appended at the end.
339    pub fn append_to_jxl(&self, jxl_data: Vec<u8>) -> Result<Vec<u8>, IccError> {
340        let mut data = jxl_data;
341
342        // Ensure container format
343        if data.starts_with(&[0xFF, 0x0A]) {
344            let codestream = std::mem::take(&mut data);
345            let jxlc_size = (8 + codestream.len()) as u32;
346            let mut container = Vec::new();
347            // JXL signature box (12 bytes): size=12, type="JXL ", data=[0D 0A 87 0A]
348            container.extend_from_slice(&[0x00, 0x00, 0x00, 0x0C]);
349            container.extend_from_slice(b"JXL ");
350            container.extend_from_slice(&[0x0D, 0x0A, 0x87, 0x0A]);
351            // ftyp box (20 bytes)
352            container.extend_from_slice(&[0x00, 0x00, 0x00, 0x14]);
353            container.extend_from_slice(b"ftyp");
354            container.extend_from_slice(b"jxl ");
355            container.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
356            container.extend_from_slice(b"jxl ");
357            // jxlc box
358            container.extend_from_slice(&jxlc_size.to_be_bytes());
359            container.extend_from_slice(b"jxlc");
360            container.extend_from_slice(&codestream);
361            data = container;
362        } else if data.get(4..8) != Some(b"JXL ") {
363            return Err(IccError::Container("unrecognized JXL format".into()));
364        }
365
366        // Find insert position (before first Exif/xml /jbrd box, or end)
367        let insert_pos = find_jxl_insert_pos(&data);
368
369        // Build and splice iccp box
370        let icc_bytes = self.as_bytes();
371        let iccp_size = (8 + icc_bytes.len()) as u32;
372        let mut iccp_box = Vec::with_capacity(iccp_size as usize);
373        iccp_box.extend_from_slice(&iccp_size.to_be_bytes());
374        iccp_box.extend_from_slice(b"iccp");
375        iccp_box.extend_from_slice(icc_bytes);
376        data.splice(insert_pos..insert_pos, iccp_box);
377
378        Ok(data)
379    }
380}
381
382// --- Private ISOBMFF / JXL container helpers ---
383
384fn read_u32_be(data: &[u8], offset: usize) -> u32 {
385    u32::from_be_bytes(data[offset..offset + 4].try_into().unwrap())
386}
387
388fn write_u32_be(data: &mut [u8], offset: usize, value: u32) {
389    data[offset..offset + 4].copy_from_slice(&value.to_be_bytes());
390}
391
392fn read_uint_be(data: &[u8], offset: usize, size: usize) -> u64 {
393    match size {
394        0 => 0,
395        1 => data[offset] as u64,
396        2 => u16::from_be_bytes(data[offset..offset + 2].try_into().unwrap()) as u64,
397        4 => u32::from_be_bytes(data[offset..offset + 4].try_into().unwrap()) as u64,
398        8 => u64::from_be_bytes(data[offset..offset + 8].try_into().unwrap()),
399        _ => 0,
400    }
401}
402
403fn write_uint_be(data: &mut [u8], offset: usize, size: usize, value: u64) {
404    match size {
405        0 => {}
406        1 => data[offset] = value as u8,
407        2 => data[offset..offset + 2].copy_from_slice(&(value as u16).to_be_bytes()),
408        4 => data[offset..offset + 4].copy_from_slice(&(value as u32).to_be_bytes()),
409        8 => data[offset..offset + 8].copy_from_slice(&value.to_be_bytes()),
410        _ => {}
411    }
412}
413
414/// Find the first box of `box_type` in byte range `[start, end)`.
415fn find_box(data: &[u8], start: usize, end: usize, box_type: &[u8; 4]) -> Option<usize> {
416    let mut pos = start;
417    while pos + 8 <= end.min(data.len()) {
418        let size = read_u32_be(data, pos) as usize;
419        if size < 8 || pos + size > data.len() {
420            break;
421        }
422        if &data[pos + 4..pos + 8] == box_type {
423            return Some(pos);
424        }
425        pos += size;
426    }
427    None
428}
429
430/// Patch iloc extent offsets by adding `delta` (mdat shifted by this amount).
431fn patch_iloc_extents(data: &mut [u8], iloc_start: usize, delta: isize) -> Result<(), IccError> {
432    if iloc_start + 16 > data.len() {
433        return Err(IccError::Container("iloc box too small".into()));
434    }
435    // FullBox: size(4)+type(4)+version(1)+flags(3); version at +8
436    let version = data[iloc_start + 8];
437    // Nibble fields at +12 and +13
438    let offset_size = ((data[iloc_start + 12] >> 4) & 0xF) as usize;
439    let length_size = (data[iloc_start + 12] & 0xF) as usize;
440    let base_offset_size = ((data[iloc_start + 13] >> 4) & 0xF) as usize;
441    let index_size = if version >= 1 {
442        (data[iloc_start + 13] & 0xF) as usize
443    } else {
444        0
445    };
446    let (item_count, mut pos) = if version < 2 {
447        let count = u16::from_be_bytes([data[iloc_start + 14], data[iloc_start + 15]]) as usize;
448        (count, iloc_start + 16)
449    } else {
450        let count = read_u32_be(data, iloc_start + 14) as usize;
451        (count, iloc_start + 18)
452    };
453
454    for _ in 0..item_count {
455        // item_id
456        pos += if version < 2 { 2 } else { 4 };
457        // construction_method (v1/2)
458        if version >= 1 {
459            pos += 2;
460        }
461        // data_reference_index
462        pos += 2;
463        // base_data_offset (patch if stored and non-zero)
464        if base_offset_size > 0 {
465            if pos + base_offset_size > data.len() {
466                return Err(IccError::Container("iloc base_data_offset OOB".into()));
467            }
468            let v = read_uint_be(data, pos, base_offset_size);
469            if v > 0 {
470                write_uint_be(
471                    data,
472                    pos,
473                    base_offset_size,
474                    (v as i64 + delta as i64) as u64,
475                );
476            }
477        }
478        pos += base_offset_size;
479        // extent_count
480        if pos + 2 > data.len() {
481            return Err(IccError::Container("iloc extent_count OOB".into()));
482        }
483        let extent_count = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
484        pos += 2;
485        for _ in 0..extent_count {
486            if version >= 1 {
487                pos += index_size;
488            }
489            // extent_offset — patch
490            if offset_size > 0 {
491                if pos + offset_size > data.len() {
492                    return Err(IccError::Container("iloc extent_offset OOB".into()));
493                }
494                let v = read_uint_be(data, pos, offset_size);
495                write_uint_be(data, pos, offset_size, (v as i64 + delta as i64) as u64);
496            }
497            pos += offset_size;
498            pos += length_size;
499        }
500    }
501    Ok(())
502}
503
504/// Return the offset at which to insert an `iccp` box in a JXL container.
505fn find_jxl_insert_pos(data: &[u8]) -> usize {
506    let mut pos = 0;
507    while pos + 8 <= data.len() {
508        let size = u32::from_be_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
509        if size < 8 || pos + size > data.len() {
510            break;
511        }
512        if matches!(&data[pos + 4..pos + 8], b"Exif" | b"xml " | b"jbrd") {
513            return pos;
514        }
515        pos += size;
516    }
517    data.len()
518}
519
520impl Default for IccProfile {
521    fn default() -> Self {
522        Self::srgb()
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn test_srgb_profile_valid() {
532        let profile = IccProfile::srgb();
533        assert!(profile.is_valid(), "sRGB profile should be valid");
534    }
535
536    #[test]
537    fn test_srgb_profile_size() {
538        let profile = IccProfile::srgb();
539        let bytes = profile.as_bytes();
540        // Minimal sRGB profile should be ~300-600 bytes
541        assert!(
542            bytes.len() > 200,
543            "sRGB profile should be at least 200 bytes, got {}",
544            bytes.len()
545        );
546        assert!(
547            bytes.len() < 2000,
548            "sRGB profile should be under 2KB, got {}",
549            bytes.len()
550        );
551    }
552
553    #[test]
554    fn test_srgb_profile_header() {
555        let profile = IccProfile::srgb();
556        let bytes = profile.as_bytes();
557
558        // Check magic 'acsp' at offset 36
559        assert_eq!(&bytes[36..40], b"acsp", "ICC magic should be 'acsp'");
560
561        // Check profile version (2.1.0.0)
562        assert_eq!(bytes[8], 2, "Major version should be 2");
563        assert_eq!(bytes[9], 0x10, "Minor version should be 1.0");
564
565        // Check device class 'mntr' at offset 12
566        assert_eq!(&bytes[12..16], b"mntr", "Device class should be 'mntr'");
567
568        // Check color space 'RGB ' at offset 16
569        assert_eq!(&bytes[16..20], b"RGB ", "Color space should be 'RGB '");
570    }
571
572    #[test]
573    fn test_profile_from_bytes() {
574        let fake_profile = vec![0u8; 100];
575        let profile = IccProfile::from_bytes(fake_profile);
576        assert!(!profile.is_valid(), "Fake profile should not be valid");
577    }
578
579    // Build a minimal valid JXL container with just a signature + ftyp + jxlc box.
580    fn make_jxl_container(codestream: &[u8]) -> Vec<u8> {
581        let jxlc_size = (8 + codestream.len()) as u32;
582        let mut c = Vec::new();
583        // signature box
584        c.extend_from_slice(&[0x00, 0x00, 0x00, 0x0C]);
585        c.extend_from_slice(b"JXL ");
586        c.extend_from_slice(&[0x0D, 0x0A, 0x87, 0x0A]);
587        // ftyp
588        c.extend_from_slice(&[0x00, 0x00, 0x00, 0x14]);
589        c.extend_from_slice(b"ftyp");
590        c.extend_from_slice(b"jxl ");
591        c.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
592        c.extend_from_slice(b"jxl ");
593        // jxlc
594        c.extend_from_slice(&jxlc_size.to_be_bytes());
595        c.extend_from_slice(b"jxlc");
596        c.extend_from_slice(codestream);
597        c
598    }
599
600    fn jxl_has_iccp(data: &[u8]) -> bool {
601        let mut pos = 0;
602        while pos + 8 <= data.len() {
603            let sz = u32::from_be_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
604            if sz < 8 {
605                break;
606            }
607            if &data[pos + 4..pos + 8] == b"iccp" {
608                return true;
609            }
610            pos += sz;
611        }
612        false
613    }
614
615    #[test]
616    fn test_append_to_jxl_naked_codestream() {
617        // Fake naked JXL codestream (just the magic bytes + padding)
618        let mut naked = vec![0xFF, 0x0A];
619        naked.extend_from_slice(&[0u8; 16]);
620
621        let profile = IccProfile::srgb();
622        let result = profile.append_to_jxl(naked).expect("append_to_jxl failed");
623
624        // Must be a container
625        assert!(
626            result.get(4..8) == Some(b"JXL "),
627            "Output should be JXL container"
628        );
629        // Must contain iccp box
630        assert!(jxl_has_iccp(&result), "Output should have iccp box");
631    }
632
633    #[test]
634    fn test_append_to_jxl_container_form() {
635        let container = make_jxl_container(&[0xFF, 0x0A, 0x00]);
636        let profile = IccProfile::srgb();
637        let result = profile
638            .append_to_jxl(container)
639            .expect("append_to_jxl failed");
640
641        assert!(
642            result.get(4..8) == Some(b"JXL "),
643            "Output should still be JXL container"
644        );
645        assert!(jxl_has_iccp(&result), "Output should have iccp box");
646    }
647
648    #[test]
649    fn test_append_to_jxl_invalid_data() {
650        let bad_data = vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07];
651        let profile = IccProfile::srgb();
652        let result = profile.append_to_jxl(bad_data);
653        assert!(result.is_err(), "Should error on unrecognized JXL data");
654    }
655}