1#[derive(Debug)]
7#[allow(dead_code)]
8pub enum IccError {
9 InvalidProfile(String),
11 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#[derive(Debug, Clone)]
41pub struct IccProfile {
42 data: Vec<u8>,
43}
44
45impl IccProfile {
46 pub fn srgb() -> Self {
51 Self::create_minimal_srgb()
52 }
53
54 fn create_minimal_srgb() -> Self {
56 const SRGB_RED_X: u32 = 0x00006FA2; const SRGB_RED_Y: u32 = 0x000038F5; const SRGB_RED_Z: u32 = 0x00000390; const SRGB_GREEN_X: u32 = 0x00006299; const SRGB_GREEN_Y: u32 = 0x0000B786; const SRGB_GREEN_Z: u32 = 0x00001852; const SRGB_BLUE_X: u32 = 0x000024A0; const SRGB_BLUE_Y: u32 = 0x00000F84; const SRGB_BLUE_Z: u32 = 0x0000B6CF; const D50_X: u32 = 0x0000F6D6; const D50_Y: u32 = 0x00010000; const D50_Z: u32 = 0x0000D32D; let mut profile = Vec::with_capacity(560);
75
76 let profile_size: u32 = 0; profile.extend_from_slice(&profile_size.to_be_bytes()); profile.extend_from_slice(b"\0\0\0\0"); profile.extend_from_slice(&[0x02, 0x10, 0x00, 0x00]); profile.extend_from_slice(b"mntr"); profile.extend_from_slice(b"RGB "); profile.extend_from_slice(b"XYZ "); profile.extend_from_slice(&[0u8; 12]); profile.extend_from_slice(b"acsp"); profile.extend_from_slice(b"\0\0\0\0"); profile.extend_from_slice(&[0u8; 4]); profile.extend_from_slice(&[0u8; 4]); profile.extend_from_slice(&[0u8; 4]); profile.extend_from_slice(&[0u8; 8]); profile.extend_from_slice(&[0, 0, 0, 0]); 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.extend_from_slice(&[0u8; 16]); profile.extend_from_slice(&[0u8; 28]); assert_eq!(profile.len(), 128, "Header must be 128 bytes");
101
102 let tag_count: u32 = 9;
105 profile.extend_from_slice(&tag_count.to_be_bytes());
106
107 let tag_data_start: u32 = 128 + 4 + (tag_count * 12);
111 let mut data_offset = tag_data_start;
112
113 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 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 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; add_tag_entry(&mut profile, b"gTRC", trc_offset, trc_size); add_tag_entry(&mut profile, b"bTRC", trc_offset, trc_size); 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 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]); 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 write_xyz(&mut profile, D50_X, D50_Y, D50_Z);
180
181 write_xyz(&mut profile, SRGB_RED_X, SRGB_RED_Y, SRGB_RED_Z);
183
184 write_xyz(&mut profile, SRGB_GREEN_X, SRGB_GREEN_Y, SRGB_GREEN_Z);
186
187 write_xyz(&mut profile, SRGB_BLUE_X, SRGB_BLUE_Y, SRGB_BLUE_Z);
189
190 let gamma_22: u16 = 0x0238;
193 profile.extend_from_slice(b"curv");
194 profile.extend_from_slice(&[0u8; 4]); profile.extend_from_slice(&1u32.to_be_bytes()); profile.extend_from_slice(&gamma_22.to_be_bytes());
197 profile.extend_from_slice(&[0u8; 2]);
199
200 profile.extend_from_slice(b"text");
202 profile.extend_from_slice(&[0u8; 4]); profile.extend_from_slice(cprt_text);
204 profile.push(0); while profile.len() % 4 != 0 {
207 profile.push(0);
208 }
209
210 profile.extend_from_slice(b"desc");
212 profile.extend_from_slice(&[0u8; 4]); profile.extend_from_slice(&(desc_text.len() as u32 + 1).to_be_bytes()); profile.extend_from_slice(desc_text);
215 profile.push(0); while profile.len() % 4 != 0 {
218 profile.push(0);
219 }
220
221 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 #[allow(dead_code)]
230 pub fn from_bytes(data: Vec<u8>) -> Self {
231 Self { data }
232 }
233
234 #[allow(dead_code)]
236 pub fn as_bytes(&self) -> &[u8] {
237 &self.data
238 }
239
240 #[allow(dead_code)]
244 pub fn is_valid(&self) -> bool {
245 if self.data.len() < 40 {
246 return false;
247 }
248 &self.data[36..40] == b"acsp"
250 }
251
252 #[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 pub fn append_to_avif(&self, avif_data: Vec<u8>) -> Result<Vec<u8>, IccError> {
274 let mut data = avif_data;
275
276 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 let meta_content_start = meta_start + 12;
284
285 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 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 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 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 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 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 pub fn append_to_jxl(&self, jxl_data: Vec<u8>) -> Result<Vec<u8>, IccError> {
340 let mut data = jxl_data;
341
342 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 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 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 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 let insert_pos = find_jxl_insert_pos(&data);
368
369 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
382fn 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
414fn 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
430fn 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 let version = data[iloc_start + 8];
437 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 pos += if version < 2 { 2 } else { 4 };
457 if version >= 1 {
459 pos += 2;
460 }
461 pos += 2;
463 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 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 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
504fn 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 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 assert_eq!(&bytes[36..40], b"acsp", "ICC magic should be 'acsp'");
560
561 assert_eq!(bytes[8], 2, "Major version should be 2");
563 assert_eq!(bytes[9], 0x10, "Minor version should be 1.0");
564
565 assert_eq!(&bytes[12..16], b"mntr", "Device class should be 'mntr'");
567
568 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 fn make_jxl_container(codestream: &[u8]) -> Vec<u8> {
581 let jxlc_size = (8 + codestream.len()) as u32;
582 let mut c = Vec::new();
583 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 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 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 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 assert!(
626 result.get(4..8) == Some(b"JXL "),
627 "Output should be JXL container"
628 );
629 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}