1mod curve;
22mod lut;
23mod math;
24
25use curve::Curve;
26use lut::Lut;
27
28pub use math::{lab_to_xyz, mat_apply, mat_mul, srgb_encode, xyz_to_linear_srgb, Mat3, D50};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum IccError {
33 Truncated,
35 Signature,
37 Unsupported,
40 Malformed,
42}
43
44impl std::fmt::Display for IccError {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 let what = match self {
47 IccError::Truncated => "truncated ICC profile",
48 IccError::Signature => "missing ICC profile signature",
49 IccError::Unsupported => "unsupported ICC transform shape",
50 IccError::Malformed => "malformed ICC profile structure",
51 };
52 f.write_str(what)
53 }
54}
55
56impl std::error::Error for IccError {}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum DeviceSpace {
61 Rgb,
62 Gray,
63}
64
65pub const SRGB_TOLERANCE: f32 = 0.01;
70
71const PROBES: [f32; 5] = [1.0 / 16.0, 0.25, 0.5, 0.75, 15.0 / 16.0];
72
73#[derive(Debug, Clone, PartialEq)]
74enum Pipeline {
75 MatrixTrc { trc: [Curve; 3], m: Mat3 },
76 GrayTrc { curve: Curve },
77 Lut(Lut),
78}
79
80impl Pipeline {
81 fn eval(&self, input: &[f32]) -> [f32; 3] {
82 let comp = |i: usize| -> f32 {
83 let v = input.get(i).copied().unwrap_or(0.0);
84 if v.is_finite() {
85 v.clamp(0.0, 1.0)
86 } else {
87 0.0
88 }
89 };
90 match self {
91 Pipeline::MatrixTrc { trc, m } => {
92 let lin = [
93 trc[0].eval(comp(0)),
94 trc[1].eval(comp(1)),
95 trc[2].eval(comp(2)),
96 ];
97 let rgb = mat_apply(m, lin);
98 [
99 srgb_encode(rgb[0]),
100 srgb_encode(rgb[1]),
101 srgb_encode(rgb[2]),
102 ]
103 }
104 Pipeline::GrayTrc { curve } => {
105 let v = srgb_encode(curve.eval(comp(0)));
106 [v, v, v]
107 }
108 Pipeline::Lut(lut) => lut.eval(input),
109 }
110 }
111}
112
113#[derive(Debug, Clone, PartialEq)]
115pub struct Profile {
116 channels: usize,
117 pipeline: Pipeline,
118 equivalent: Option<DeviceSpace>,
119}
120
121impl Profile {
122 pub fn channels(&self) -> usize {
124 self.channels
125 }
126
127 pub fn device_equivalent(&self) -> Option<DeviceSpace> {
130 self.equivalent
131 }
132
133 pub fn transform(&self, input: &[f32]) -> [f32; 3] {
136 self.pipeline.eval(input)
137 }
138}
139
140fn be32(data: &[u8], at: usize) -> u32 {
141 u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]])
142}
143
144fn channel_count(sig: &[u8]) -> Option<usize> {
145 match sig {
146 b"GRAY" => Some(1),
147 b"RGB " | b"CMY " | b"XYZ " | b"Lab " | b"Luv " | b"YCbr" | b"Yxy " | b"HSV " | b"HLS " => {
148 Some(3)
149 }
150 b"CMYK" => Some(4),
151 [d @ b'2'..=b'9', b'C', b'L', b'R'] => Some((d - b'0') as usize),
152 [d @ b'A'..=b'F', b'C', b'L', b'R'] => Some((d - b'A') as usize + 10),
153 _ => None,
154 }
155}
156
157struct Tags<'a> {
160 data: &'a [u8],
161 count: usize,
162}
163
164impl<'a> Tags<'a> {
165 fn get(&self, sig: &[u8; 4]) -> Option<&'a [u8]> {
166 for k in 0..self.count {
167 let at = 132 + 12 * k;
168 if &self.data[at..at + 4] != sig {
169 continue;
170 }
171 let offset = be32(self.data, at + 4) as usize;
172 let size = be32(self.data, at + 8) as usize;
173 let end = offset.checked_add(size)?;
174 if end > self.data.len() {
175 return None;
176 }
177 return Some(&self.data[offset..end]);
178 }
179 None
180 }
181}
182
183fn xyz_column(tag: &[u8]) -> Option<[f32; 3]> {
184 if tag.len() < 20 || &tag[0..4] != b"XYZ " {
185 return None;
186 }
187 Some([
188 math::s15f16(be32(tag, 8)),
189 math::s15f16(be32(tag, 12)),
190 math::s15f16(be32(tag, 16)),
191 ])
192}
193
194fn matrix_trc(tags: &Tags<'_>, to_srgb: &Mat3) -> Option<Pipeline> {
195 let r = xyz_column(tags.get(b"rXYZ")?)?;
196 let g = xyz_column(tags.get(b"gXYZ")?)?;
197 let b = xyz_column(tags.get(b"bXYZ")?)?;
198 let colorants: Mat3 = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
199 let trc = [
200 Curve::parse(tags.get(b"rTRC")?).ok()?.0,
201 Curve::parse(tags.get(b"gTRC")?).ok()?.0,
202 Curve::parse(tags.get(b"bTRC")?).ok()?.0,
203 ];
204 Some(Pipeline::MatrixTrc {
205 trc,
206 m: mat_mul(to_srgb, &colorants),
207 })
208}
209
210fn probes_identity(pipeline: &Pipeline, channels: usize) -> bool {
211 for axis in 0..channels {
212 for v in PROBES {
213 let mut input = [0.0f32; 3];
214 input[axis] = v;
215 let out = pipeline.eval(&input[..channels]);
216 let want = if channels == 1 { [v, v, v] } else { input };
217 if out
218 .iter()
219 .zip(want)
220 .any(|(o, w)| (o - w).abs() > SRGB_TOLERANCE)
221 {
222 return false;
223 }
224 }
225 }
226 let input = [1.0f32; 3];
227 let out = pipeline.eval(&input[..channels]);
228 out.iter().all(|o| (o - 1.0).abs() <= SRGB_TOLERANCE)
229}
230
231pub fn parse(data: &[u8]) -> Result<Profile, IccError> {
239 if data.len() < 132 {
240 return Err(IccError::Truncated);
241 }
242 if &data[36..40] != b"acsp" {
243 return Err(IccError::Signature);
244 }
245 if !(2..=4).contains(&data[8]) {
246 return Err(IccError::Unsupported);
247 }
248 if be32(data, 0) as usize > data.len() {
249 return Err(IccError::Truncated);
250 }
251 let channels = channel_count(&data[16..20]).ok_or(IccError::Unsupported)?;
252 let pcs_lab = match &data[20..24] {
253 b"XYZ " => false,
254 b"Lab " => true,
255 _ => return Err(IccError::Unsupported),
256 };
257 let declared = be32(data, 128) as usize;
258 let count = (data.len() - 132) / 12;
259 if declared > count {
260 return Err(IccError::Malformed);
261 }
262 let tags = Tags {
263 data,
264 count: declared,
265 };
266 let to_srgb = xyz_to_linear_srgb(D50);
267
268 let matrix = if channels == 3 && !pcs_lab {
269 matrix_trc(&tags, &to_srgb)
270 } else {
271 None
272 };
273 if let Some(pipeline) = &matrix {
274 if probes_identity(pipeline, 3) {
275 return Ok(Profile {
276 channels,
277 pipeline: matrix.unwrap(),
278 equivalent: Some(DeviceSpace::Rgb),
279 });
280 }
281 }
282 let gray = if channels == 1 {
283 tags.get(b"kTRC")
284 .and_then(|tag| Curve::parse(tag).ok())
285 .map(|(curve, _)| Pipeline::GrayTrc { curve })
286 } else {
287 None
288 };
289 if let Some(pipeline) = &gray {
290 if probes_identity(pipeline, 1) {
291 return Ok(Profile {
292 channels,
293 pipeline: gray.unwrap(),
294 equivalent: Some(DeviceSpace::Gray),
295 });
296 }
297 }
298 let lut = tags
299 .get(b"A2B0")
300 .and_then(|tag| Lut::parse(tag, &data[16..20] == b"XYZ ", pcs_lab, to_srgb).ok());
301 if let Some(lut) = lut {
302 if lut.inputs() != channels {
303 return Err(IccError::Malformed);
304 }
305 return Ok(Profile {
306 channels,
307 pipeline: Pipeline::Lut(lut),
308 equivalent: None,
309 });
310 }
311 let pipeline = matrix.or(gray).ok_or(IccError::Unsupported)?;
312 Ok(Profile {
313 channels,
314 pipeline,
315 equivalent: None,
316 })
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn near(a: f32, b: f32, tol: f32) -> bool {
324 (a - b).abs() <= tol
325 }
326
327 fn fx(v: f64) -> [u8; 4] {
328 (((v * 65536.0).round()) as i32).to_be_bytes()
329 }
330
331 fn xyz_tag(col: [f64; 3]) -> Vec<u8> {
332 let mut out = b"XYZ \0\0\0\0".to_vec();
333 for v in col {
334 out.extend_from_slice(&fx(v));
335 }
336 out
337 }
338
339 fn para3_srgb() -> Vec<u8> {
340 let mut out = b"para\0\0\0\0\0\x03\0\0".to_vec();
341 for v in [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045] {
342 out.extend_from_slice(&fx(v));
343 }
344 out
345 }
346
347 fn gamma_curv(g: f64) -> Vec<u8> {
348 let mut out = b"curv\0\0\0\0\0\0\0\x01".to_vec();
349 out.extend_from_slice(&(((g * 256.0).round()) as u16).to_be_bytes());
350 out
351 }
352
353 fn build(colour: &[u8; 4], pcs: &[u8; 4], tags: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
354 let mut header = vec![0u8; 128];
355 header[8] = 4;
356 header[16..20].copy_from_slice(colour);
357 header[20..24].copy_from_slice(pcs);
358 header[36..40].copy_from_slice(b"acsp");
359 let mut table = (tags.len() as u32).to_be_bytes().to_vec();
360 let mut body = Vec::new();
361 let mut at = 132 + 12 * tags.len();
362 for (sig, data) in tags {
363 table.extend_from_slice(sig);
364 table.extend_from_slice(&(at as u32).to_be_bytes());
365 table.extend_from_slice(&(data.len() as u32).to_be_bytes());
366 body.extend_from_slice(data);
367 let pad = data.len().div_ceil(4) * 4 - data.len();
368 body.extend_from_slice(&vec![0u8; pad]);
369 at += data.len() + pad;
370 }
371 let mut out = header;
372 out.extend_from_slice(&table);
373 out.extend_from_slice(&body);
374 let size = (out.len() as u32).to_be_bytes();
375 out[0..4].copy_from_slice(&size);
376 out
377 }
378
379 const SRGB_D50: [[f64; 3]; 3] = [
382 [0.4360, 0.2225, 0.0139],
383 [0.3851, 0.7169, 0.0971],
384 [0.1431, 0.0606, 0.7139],
385 ];
386
387 fn srgb_profile() -> Vec<u8> {
388 build(
389 b"RGB ",
390 b"XYZ ",
391 &[
392 (*b"rXYZ", xyz_tag(SRGB_D50[0])),
393 (*b"gXYZ", xyz_tag(SRGB_D50[1])),
394 (*b"bXYZ", xyz_tag(SRGB_D50[2])),
395 (*b"rTRC", para3_srgb()),
396 (*b"gTRC", para3_srgb()),
397 (*b"bTRC", para3_srgb()),
398 ],
399 )
400 }
401
402 #[test]
405 fn srgb_profile_reports_rgb_equivalence() {
406 let profile = parse(&srgb_profile()).unwrap();
407 assert_eq!(profile.channels(), 3);
408 assert_eq!(profile.device_equivalent(), Some(DeviceSpace::Rgb));
409 let out = profile.transform(&[0.2, 0.5, 0.8]);
410 for (o, w) in out.iter().zip([0.2, 0.5, 0.8]) {
411 assert!(near(*o, w, 0.01), "{out:?}");
412 }
413 }
414
415 #[test]
420 fn gamma_18_profile_is_not_srgb() {
421 let data = build(
422 b"RGB ",
423 b"XYZ ",
424 &[
425 (*b"rXYZ", xyz_tag(SRGB_D50[0])),
426 (*b"gXYZ", xyz_tag(SRGB_D50[1])),
427 (*b"bXYZ", xyz_tag(SRGB_D50[2])),
428 (*b"rTRC", gamma_curv(1.8)),
429 (*b"gTRC", gamma_curv(1.8)),
430 (*b"bTRC", gamma_curv(1.8)),
431 ],
432 );
433 let profile = parse(&data).unwrap();
434 assert_eq!(profile.device_equivalent(), None);
435 let out = profile.transform(&[0.5, 0.5, 0.5]);
436 let want = srgb_encode(0.5f32.powf(1.8));
437 for o in out {
438 assert!(near(o, want, 0.01), "{out:?} want {want}");
439 }
440 assert!(out[0] > 0.55, "gamma 1.8 renders mid-gray lighter");
441 }
442
443 #[test]
447 fn gray_profiles() {
448 let data = build(b"GRAY", b"XYZ ", &[(*b"kTRC", para3_srgb())]);
449 let profile = parse(&data).unwrap();
450 assert_eq!(profile.channels(), 1);
451 assert_eq!(profile.device_equivalent(), Some(DeviceSpace::Gray));
452
453 let linear = build(b"GRAY", b"XYZ ", &[(*b"kTRC", gamma_curv(1.0))]);
454 let profile = parse(&linear).unwrap();
455 assert_eq!(profile.device_equivalent(), None);
456 let out = profile.transform(&[0.5]);
457 assert!(near(out[0], srgb_encode(0.5), 1e-4), "{out:?}");
458 assert_eq!(out[0], out[1]);
459 }
460
461 #[test]
465 fn header_validation() {
466 let mut bad_magic = srgb_profile();
467 bad_magic[36] = b'x';
468 assert_eq!(parse(&bad_magic), Err(IccError::Signature));
469
470 let mut bad_count = srgb_profile();
471 bad_count[128..132].copy_from_slice(&u32::MAX.to_be_bytes());
472 assert_eq!(parse(&bad_count), Err(IccError::Malformed));
473
474 let bad_space = build(b"????", b"XYZ ", &[]);
475 assert_eq!(parse(&bad_space), Err(IccError::Unsupported));
476
477 let mut v5 = srgb_profile();
478 v5[8] = 5;
479 assert_eq!(parse(&v5), Err(IccError::Unsupported));
480
481 let mut v2 = srgb_profile();
482 v2[8] = 2;
483 assert!(parse(&v2).is_ok(), "v2 headers share the layout");
484
485 let empty = build(b"RGB ", b"XYZ ", &[]);
486 assert_eq!(parse(&empty), Err(IccError::Unsupported));
487 }
488
489 #[test]
492 fn truncation_and_hostile_offsets() {
493 let data = srgb_profile();
494 for cut in 0..data.len() {
495 let result = parse(&data[..cut]);
496 assert!(result.is_err(), "cut {cut}");
497 }
498 let mut hostile = data.clone();
499 hostile[136..140].copy_from_slice(&u32::MAX.to_be_bytes());
500 assert!(parse(&hostile).is_err());
501 }
502
503 #[test]
507 fn a2b0_lut_profile() {
508 let mut lut = Vec::new();
509 lut.extend_from_slice(b"mft2\0\0\0\0");
510 lut.push(3);
511 lut.push(3);
512 lut.push(2);
513 lut.push(0);
514 for r in 0..3 {
515 for c in 0..3 {
516 let v: i32 = if r == c { 0x0001_0000 } else { 0 };
517 lut.extend_from_slice(&v.to_be_bytes());
518 }
519 }
520 lut.extend_from_slice(&2u16.to_be_bytes());
521 lut.extend_from_slice(&2u16.to_be_bytes());
522 for _ in 0..3 {
523 lut.extend_from_slice(&0u16.to_be_bytes());
524 lut.extend_from_slice(&65535u16.to_be_bytes());
525 }
526 let white: [u16; 3] = [0x7B6B, 0x8000, 0x6996];
527 for _ in 0..8 {
528 for w in white {
529 lut.extend_from_slice(&w.to_be_bytes());
530 }
531 }
532 for _ in 0..3 {
533 lut.extend_from_slice(&0u16.to_be_bytes());
534 lut.extend_from_slice(&65535u16.to_be_bytes());
535 }
536 let data = build(b"RGB ", b"XYZ ", &[(*b"A2B0", lut)]);
537 let profile = parse(&data).unwrap();
538 assert_eq!(profile.device_equivalent(), None);
539 let out = profile.transform(&[0.3, 0.9, 0.1]);
540 assert!(out.iter().all(|&v| near(v, 1.0, 2e-3)), "{out:?}");
541 for cut in 0..data.len() {
542 assert!(parse(&data[..cut]).is_err(), "cut {cut}");
543 }
544 }
545
546 #[test]
548 fn xclr_channel_counts() {
549 assert_eq!(channel_count(b"2CLR"), Some(2));
550 assert_eq!(channel_count(b"9CLR"), Some(9));
551 assert_eq!(channel_count(b"ACLR"), Some(10));
552 assert_eq!(channel_count(b"FCLR"), Some(15));
553 assert_eq!(channel_count(b"GCLR"), None);
554 }
555}