cmx/lib.rs
1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2021-2025, Harbers Bik LLC
3
4//#![allow(dead_code, unused_imports)]
5
6/*!
7This crate provides utilities for working with ICC color profiles
8and integrates with the Colorimetry Library.
9
10## Use Cases
11<details><summary><strong>Parsing ICC profiles and conversion to TOML format for analysis</strong></summary>
12After installing the library, you can parse an ICC profile and convert it to a TOML format using the `cmx` command-line tool:
13
14```bash
15cmx profile.icc -o profile.toml
16```
17
18Each ICC profile tag is mapped to a key in the TOML file, with the
19corresponding values serialized as key-value pairs.
20All values are written as single-line entries to ensure the TOML output
21remains human-readable and easy to inspect.
22
23Example of a parsed ICC profile in TOML format:
24
25```toml
26profile_size = 548
27cmm = "Apple"
28version = "4.0"
29device_class = "Display"
30color_space = "RGB"
31pcs = "XYZ"
32creation_datetime = "2015-10-14 13:08:56 UTC"
33primary_platform = "Apple"
34manufacturer = "APPL"
35rendering_intent = "Perceptual"
36pcs_illuminant = [0.9642, 1.0, 0.8249]
37creator = "appl"
38profile_id = "53410ea9facdd9fb57cc74868defc33f"
39
40[desc]
41ascii = "SMPTE RP 431-2-2007 DCI (P3)"
42
43[cprt]
44text = "Copyright Apple Inc., 2015"
45
46[wtpt]
47xyz = [0.894592, 1.0, 0.954422]
48
49[rXYZ]
50xyz = [0.48616, 0.226685, -0.000809]
51
52[gXYZ]
53xyz = [0.323853, 0.710327, 0.043228]
54
55[bXYZ]
56xyz = [0.15419, 0.062988, 0.782471]
57
58[rTRC]
59g = 2.60001
60
61[chad]
62matrix = [
63 [1.073822, 0.038803, -0.036896],
64 [0.055573, 0.963989, -0.014343],
65 [-0.004272, 0.005295, 0.862778]
66]
67
68[bTRC]
69g = 2.60001
70
71[gTRC]
72g = 2.60001
73
74 ```
75</details>
76
77<details><summary><strong>Generate ICC profiles</strong></summary>
78
79You can also use the `cmx` library to create ICC profiles from scratch, or read existing
80profiles and change them, using Rust.
81
82The library provides a builder-style API for constructing, or read and change profiles,
83allowing you to set or change various tags and properties.
84
85Here is an example for creating a Display P3 ICC profile:
86
87```rust
88use chrono::{DateTime, TimeZone};
89use cmx::tag::tags::*;
90use cmx::profile::DisplayProfile;
91let display_p3_example = DisplayProfile::new()
92 // set creation date, if omitted, the current date and time are used
93 .with_creation_date(chrono::Utc.with_ymd_and_hms(2025, 8, 28, 0, 0, 0).unwrap())
94 .with_tag(ProfileDescriptionTag)
95 .as_text_description(|text| {
96 text.set_ascii("Display P3");
97 })
98 .with_tag(CopyrightTag)
99 .as_text(|text| {
100 text.set_text("CC0");
101 })
102 .with_tag(MediaWhitePointTag)
103 .as_xyz_array(|xyz| {
104 xyz.set([0.950455, 1.00000, 1.08905]);
105 })
106 .with_tag(RedMatrixColumnTag)
107 .as_xyz_array(|xyz| {
108 xyz.set([0.515121, 0.241196, -0.001053]);
109 })
110 .with_tag(GreenMatrixColumnTag)
111 .as_xyz_array(|xyz| {
112 xyz.set([0.291977, 0.692245, 0.041885]);
113 })
114 .with_tag(BlueMatrixColumnTag)
115 .as_xyz_array(|xyz| {
116 xyz.set([0.157104, 0.066574, 0.784073]);
117 })
118 .with_tag(RedTRCTag)
119 .as_parametric_curve(|para| {
120 para.set_parameters([2.39999, 0.94786, 0.05214, 0.07739, 0.04045]);
121 })
122 .with_tag(BlueTRCTag)
123 .as_parametric_curve(|para| {
124 para.set_parameters([2.39999, 0.94786, 0.05214, 0.07739, 0.04045]);
125 })
126 .with_tag(GreenTRCTag)
127 .as_parametric_curve(|para| {
128 para.set_parameters([2.39999, 0.94786, 0.05214, 0.07739, 0.04045]);
129 })
130 .with_tag(ChromaticAdaptationTag)
131 .as_sf15_fixed_16_array(|array| {
132 array.set([
133 1.047882, 0.022919, -0.050201,
134 0.029587, 0.990479, -0.017059,
135 -0.009232, 0.015076, 0.751678
136 ]);
137 })
138 .with_profile_id() // calculate and add profile ID to the profile
139 ;
140
141display_p3_example.write("tmp/display_p3_example.icc").unwrap();
142let display_p3_read_back = cmx::profile::Profile::read("tmp/display_p3_example.icc").unwrap();
143assert_eq!(
144 display_p3_read_back.profile_id_as_hex_string(),
145 "617028e1 e1014e15 91f178a9 fb8efc92"
146);
147assert_eq!(display_p3_read_back.profile_size(), 524);
148```
149
150Not all ICC tag types are supported yet, but please submit a pull request, or an issue, on our
151[GitHub CMX repo](https://github.com/harbik/cmx) if you want additional tag types to be supported.
152
153However, you can use the `as_raw` method to set raw data for tags that are not yet supported.
154
155</details>
156
157
158
159## Installation
160
161Install the `cmx` tool using Cargo:
162
163```bash
164cargo install cmx
165```
166
167To use the `cmx` library in your Rust project:
168
169```bash
170cargo add cmx
171```
172
173Documentation is available at [docs.rs/cmx](https://docs.rs/cmx).
174
175## Roadmap
176
177- [x] Parse full ICC profiles
178- [x] Convert to TOML format
179- [x] Add builder-style API for constructing ICC profiles
180- [x] Support basic ICC Type tags and color models
181- [ ] Read TOML Color profiles and convert to binary ICC profiles
182- [ ] Utilities for commandline profile conversion and manipulation
183- [ ] Calibration and profiling tools
184- [ ] X-Rite I1 Profiler support
185- [ ] Support all ICC Type tags
186- [ ] Enable spectral data and advanced color management
187
188
189
190## Overview
191
192Although the ICC specification is broad and complex, this crate aims
193to provide a robust foundation for working with ICC profiles in Rust.
194
195It supports parsing, constructing, and changing of the primary ICC-defined tags,
196as well as some commonly used non-standard tags.
197
198Even tags that cannot yet be parsed are still preserved when reading
199and serializing profiles, ensuring no data loss.
200
201The long-term goal is to fully support advanced ICC color management,
202including spectral data and extended color models, while maintaining
203compatibility with existing profiles.
204*/
205
206pub mod error;
207pub mod header;
208pub mod profile;
209pub mod signatures;
210pub mod tag;
211
212use std::fmt::Display;
213
214pub use error::Error;
215use num::Zero;
216
217/// Rounds a floating-point value to the specified precision (decimal places).
218/// Example: round_to_precision(1.23456, 2) -> 1.23
219pub(crate) fn round_to_precision(value: f64, precision: i32) -> f64 {
220 let multiplier = 10f64.powi(precision);
221 (value * multiplier).round() / multiplier
222}
223
224/// Generic zero-check used by serde skip_serializing_if for many numeric fields.
225pub(crate) fn is_zero<T: Zero>(n: &T) -> bool {
226 n.is_zero()
227}
228
229/// Treats the string as "empty" if it is empty or equals "none" (case-sensitive),
230/// used to suppress serialization for some optional fields.
231pub(crate) fn is_empty_or_none(s: &String) -> bool {
232 s.is_empty() || s == "none"
233}
234
235/// Convert ICC s15Fixed16Number to f64 by dividing by 65536.0.
236/// This is a signed 32-bit fixed-point with 16 fractional bits.
237pub(crate) fn s15fixed16(v: i32) -> f64 {
238 round_to_precision(v as f64 / 65536.0, 6)
239}
240
241/// Convert ICC u1.15 fixed number (u16) to f64.
242/// Range is [0, ~1.99997]. We scale by (65535 / 32768) and round for compact output.
243pub(crate) fn u1_fixed15_number(v: u16) -> f64 {
244 const SCALE: f64 = 0xFFFF as f64 / 0x8000 as f64;
245 round_to_precision(v as f64 * SCALE, 6)
246}
247
248/// Render bytes as uppercase hex grouped into 4-byte (8-hex) chunks separated by spaces.
249/// Example: [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC] -> "12345678 9abc"
250/// This is used for displaying binary data in a human-readable format.
251///
252/// Example:
253/// ```
254/// let data = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC];
255/// let formatted = cmx::format_hex_with_spaces(&data);
256/// assert_eq!(formatted, "12345678 9abc");
257/// ```
258///
259/// Note: The last chunk may be shorter than 8 characters if the data length is not a multiple of 4.
260///
261pub fn format_hex_with_spaces(data: &[u8]) -> String {
262 let hex = hex::encode(data);
263
264 // Split into chunks of 8 characters and join with spaces
265 hex.as_bytes()
266 .chunks(8)
267 .map(|chunk| std::str::from_utf8(chunk).unwrap())
268 .collect::<Vec<&str>>()
269 .join(" ")
270}
271
272/// Parse a hex string with optional spaces into a byte vector.
273/// Example: "12345678 9abc" -> [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC]
274/// This is used for converting human-readable hex strings back into binary data.
275///
276/// Example:
277/// ```
278/// let hex_str = "12345678 9abc";
279/// let bytes = cmx::parse_hex_string(hex_str).unwrap();
280/// assert_eq!(bytes, vec![0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC]);
281/// ```
282///
283/// Notes:
284/// * The input string can contain spaces and is case-insensitive.
285/// * Non-hex characters and whitespace are ignored.
286///
287pub fn parse_hex_string(s: &str) -> Result<Vec<u8>, hex::FromHexError> {
288 let cleaned: String = s
289 .chars()
290 .filter(|c| !c.is_whitespace() && c.is_ascii_hexdigit())
291 .collect();
292 hex::decode(cleaned)
293}
294
295use zerocopy::{BigEndian, FromBytes, Immutable, IntoBytes, KnownLayout, Unaligned, I32};
296#[derive(FromBytes, IntoBytes, Unaligned, KnownLayout, Immutable, Debug, Clone, Copy)]
297#[repr(C)]
298pub struct S15Fixed16(I32<BigEndian>);
299
300/// A 15.16 fixed-point number, where the first 15 bits are the integer part and the last 16 bits are the fractional part.
301/// This is used in ICC profiles to represent color values.
302/// The value is stored as a 32-bit signed integer in big-endian format.
303impl From<S15Fixed16> for f64 {
304 fn from(value: S15Fixed16) -> Self {
305 let s15 = value.0.get();
306 round_to_precision(s15 as f64 / 65536.0, 5)
307 }
308}
309
310impl From<f64> for S15Fixed16 {
311 fn from(value: f64) -> Self {
312 let s15 = (value * 65536.0).round() as i32;
313 S15Fixed16(I32::new(s15))
314 }
315}
316
317impl From<S15Fixed16> for I32<BigEndian> {
318 fn from(value: S15Fixed16) -> Self {
319 value.0
320 }
321}
322
323impl From<I32<BigEndian>> for S15Fixed16 {
324 fn from(value: I32<BigEndian>) -> Self {
325 Self(value)
326 }
327}
328impl Display for S15Fixed16 {
329 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330 write!(f, "{}", f64::from(*self))
331 }
332}
333
334fn pad_size(len: usize) -> usize {
335 // ((len + 3) / 4) * 4 - len
336 len.div_ceil(4) * 4 - len
337}
338
339fn padded_size(len: usize) -> usize {
340 // ((len + 3) / 4) * 4
341 len.div_ceil(4) * 4
342}
343
344pub fn is_printable_ascii_bytes(b: &[u8]) -> bool {
345 b.iter().all(|&x| (0x20..=0x7E).contains(&x))
346}
347
348#[cfg(test)]
349mod test {
350 use super::*;
351
352 #[test]
353 fn test_round_to_precision() {
354 assert_eq!(round_to_precision(1.23456, 2), 1.23);
355 assert_eq!(round_to_precision(1.23456, 3), 1.235);
356 assert_eq!(round_to_precision(1.23456, 0), 1.0);
357 }
358
359 #[test]
360 fn test_s15fixed16() {
361 let value = S15Fixed16::from(1.5);
362 assert_eq!(f64::from(value), 1.5);
363 assert_eq!(value.to_string(), "1.5");
364 }
365
366 #[test]
367 fn test_format_hex_with_spaces() {
368 let data = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC];
369 let formatted = format_hex_with_spaces(&data);
370 assert_eq!(formatted, "12345678 9abc");
371 }
372 #[test]
373 fn test_pad() {
374 assert_eq!(pad_size(0), 0);
375 assert_eq!(pad_size(1), 3);
376 assert_eq!(pad_size(2), 2);
377 assert_eq!(pad_size(3), 1);
378 assert_eq!(pad_size(4), 0);
379 assert_eq!(pad_size(5), 3);
380 assert_eq!(pad_size(6), 2);
381 assert_eq!(pad_size(7), 1);
382 assert_eq!(pad_size(8), 0);
383 }
384}