//! TRACES: FR-DSP-8 //! Reading just enough of an ICC profile to say which of four spaces it is. //! //! # Deliberately not a profile parser //! //! ICC.1 is a large specification, and a complete reader of it is the front //! half of a colour management module: LUT-based transforms, rendering //! intents, per-intent tag sets, v2 and v4 divergence. None of that helps //! here, because the pipeline does not *apply* a profile — it encodes into one //! of four spaces it already knows the numbers for (`dr_types::colour`). The //! only question a display profile has to answer is which of the four it is //! nearest to, and three tags answer it. //! //! So this reads the tag table, pulls the three colorants and the description, //! and refuses everything else with a reason the About page can show. A //! profile it refuses is not a failure of the photographer's setup and must //! not read like one; it means "this panel is described in a way we do not //! approximate", and sRGB is assumed as FR-DSP-8 defines. //! //! # What it does read //! //! `rXYZ`, `gXYZ`, `bXYZ` — the three colorant tags of a matrix/TRC profile, //! which are the space's primaries already adapted to the D50 connection //! space. That is the same reduction `ColourSpace::to_pcs_xyz` produces, which //! is what makes the comparison in [`super::nearest_by_colorants`] an //! apples-to-apples one, and what makes a profile this project *wrote* //! round-trip back to the space it was written from. //! //! `desc` (v2) or `mluc` (v4) for the profile's own name, which exists only to //! be shown: a photographer recognises the string their calibrator wrote and //! can tell at a glance whether we are reading the profile they think we are. /// What a display profile was reduced to. #[derive(Debug, Clone, PartialEq)] pub struct IccSummary { /// The three colorant columns, row-major, in the D50 connection space. pub colorants: [f32; 9], /// The profile's own description, where it carried a readable one. pub description: Option, } /// The ICC header's fixed size. Everything before the tag count. const HEADER: usize = 128; /// Reduce a profile to the parts that decide an output space. /// /// `Err` carries a phrase for [`super::FallbackReason::Unreadable`], so it is /// written to read after "the profile could not be read (…)" on the About page /// rather than as a developer's error string. pub fn read_profile(bytes: &[u8]) -> Result { if bytes.len() < HEADER + 4 { return Err("it is too short to be a profile".to_string()); } // Offset 36 is `acsp`, the profile file signature. Checked because the X11 // property is a byte array with no type discipline at all: anything can // write anything to a root window, and a profile-shaped blob that is not // one would otherwise be read as colorants of arbitrary magnitude. if &bytes[36..40] != b"acsp" { return Err("it does not carry the ICC file signature".to_string()); } // The header's own length field. A profile whose declared size exceeds the // property is truncated — X11 properties have a fetch length, and reading // colorants out of a half-transferred profile would be silently wrong. let declared = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; if declared > bytes.len() { return Err(format!( "it declares {declared} bytes and only {} arrived", bytes.len() )); } let count = be_u32(bytes, HEADER)? as usize; // 12 bytes per tag table entry. The bound is not paranoia: `count` comes // from the profile, and a corrupt one multiplied out is how a reader ends // up indexing megabytes past the end. let table_end = HEADER + 4 + count.checked_mul(12).ok_or("its tag table is absurd")?; if table_end > bytes.len() { return Err("its tag table runs past the end of the profile".to_string()); } let mut tags: Vec<([u8; 4], usize, usize)> = Vec::with_capacity(count); for i in 0..count { let at = HEADER + 4 + i * 12; let sig = [bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]; let offset = be_u32(bytes, at + 4)? as usize; let size = be_u32(bytes, at + 8)? as usize; // Silently skipping a tag that does not fit rather than rejecting the // whole profile: the three we want may all be well-formed while some // fourth tag we will never look at is not. if offset .checked_add(size) .is_some_and(|end| end <= bytes.len()) { tags.push((sig, offset, size)); } } let find = |want: &[u8; 4]| { tags.iter() .find(|(sig, _, _)| sig == want) .map(|&(_, offset, size)| &bytes[offset..offset + size]) }; let (Some(r), Some(g), Some(b)) = (find(b"rXYZ"), find(b"gXYZ"), find(b"bXYZ")) else { // The honest description of a LUT-based profile, which is what a // hardware calibrator often produces. It describes the panel more // accurately than a matrix could, and approximating it would need the // CMM this module exists to avoid. Named as a shape rather than as an // error, because nothing is wrong with such a profile. return Err("it is not a matrix/TRC profile".to_string()); }; let r = xyz_tag(r)?; let g = xyz_tag(g)?; let b = xyz_tag(b)?; Ok(IccSummary { // Row-major, columns R/G/B — the layout `to_pcs_xyz` produces, so the // two can be subtracted entry by entry. Transposing one of them is the // single most plausible bug in this file, which is why the round-trip // test asserts a written-then-read profile lands back on its own space // rather than merely on *some* space. colorants: [r[0], g[0], b[0], r[1], g[1], b[1], r[2], g[2], b[2]], description: find(b"desc").and_then(text_tag), }) } /// An `XYZType` tag: signature, four reserved bytes, then s15Fixed16 triples. /// /// Only the first triple is read. A colorant tag carries exactly one; the /// array form exists for other tags that share the type. fn xyz_tag(data: &[u8]) -> Result<[f32; 3], String> { if data.len() < 20 || &data[0..4] != b"XYZ " { return Err("a colorant tag is not an XYZ value".to_string()); } Ok([ s15_fixed16(be_i32(data, 8)?), s15_fixed16(be_i32(data, 12)?), s15_fixed16(be_i32(data, 16)?), ]) } /// A profile's description, from either of the two types that carry one. /// /// Returns `None` rather than an error throughout: the name is decoration. A /// profile with unreadable text still has perfectly good colorants, and /// refusing it over a string would put a correctly-described display on the /// fallback for a cosmetic reason. fn text_tag(data: &[u8]) -> Option { match data.get(0..4)? { // ICC v2 `textDescriptionType`: signature, reserved, an ASCII byte // count, then that many bytes with a trailing NUL included in the // count. b"desc" => { let count = be_u32(data, 8).ok()? as usize; let text = data.get(12..12 + count)?; let text = text.split(|&b| b == 0).next().unwrap_or(text); Some(String::from_utf8_lossy(text).trim().to_string()).filter(|s| !s.is_empty()) } // ICC v4 `multiLocalizedUnicodeType`: UTF-16BE records. The first // record is taken rather than the one matching the user's locale — a // profile name is an identifier here, shown so it can be recognised, // and picking a locale would be answering a question nobody asked. b"mluc" => { let records = be_u32(data, 8).ok()? as usize; let size = be_u32(data, 12).ok()? as usize; if records == 0 || size < 12 { return None; } let length = be_u32(data, 16 + 4).ok()? as usize; let offset = be_u32(data, 16 + 8).ok()? as usize; let raw = data.get(offset..offset + length)?; let units: Vec = raw .chunks_exact(2) .map(|p| u16::from_be_bytes([p[0], p[1]])) .collect(); Some(String::from_utf16_lossy(&units).trim().to_string()).filter(|s| !s.is_empty()) } _ => None, } } fn be_u32(bytes: &[u8], at: usize) -> Result { bytes .get(at..at + 4) .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])) .ok_or_else(|| "it ends in the middle of a field".to_string()) } fn be_i32(bytes: &[u8], at: usize) -> Result { be_u32(bytes, at).map(|v| v as i32) } /// ICC's fixed-point number: sixteen integer bits, sixteen fractional. fn s15_fixed16(v: i32) -> f32 { v as f32 / 65536.0 } #[cfg(test)] mod tests { use super::*; use crate::display::{nearest_by_colorants, nearest_space}; use dr_types::ColourSpace; /// A minimal matrix/TRC profile carrying one space's colorants. /// /// Assembled here rather than taken from `dr-export`, deliberately. That /// crate writes profiles from the same `to_pcs_xyz` this reads back, so a /// round-trip through it would confirm the two halves of *one* set of /// assumptions agree with each other and would still pass if both were /// transposed. Laying the bytes out by hand against ICC.1's field offsets /// is the independent statement. fn profile_for(space: ColourSpace, name: &str) -> Vec { let m = space.to_pcs_xyz(); let colorant = |col: usize| { let mut tag = b"XYZ \0\0\0\0".to_vec(); for row in 0..3 { let v = (m[row * 3 + col] * 65536.0).round() as i32; tag.extend_from_slice(&v.to_be_bytes()); } tag }; let mut desc = b"desc\0\0\0\0".to_vec(); let text = format!("{name}\0"); desc.extend_from_slice(&(text.len() as u32).to_be_bytes()); desc.extend_from_slice(text.as_bytes()); let tags: Vec<(&[u8; 4], Vec)> = vec![ (b"rXYZ", colorant(0)), (b"gXYZ", colorant(1)), (b"bXYZ", colorant(2)), (b"desc", desc), ]; let mut header = vec![0u8; HEADER]; header[36..40].copy_from_slice(b"acsp"); let mut table = (tags.len() as u32).to_be_bytes().to_vec(); let mut body = Vec::new(); let base = HEADER + 4 + tags.len() * 12; for (sig, data) in &tags { table.extend_from_slice(*sig); table.extend_from_slice(&((base + body.len()) as u32).to_be_bytes()); table.extend_from_slice(&(data.len() as u32).to_be_bytes()); body.extend_from_slice(data); } let mut out = header; out.extend_from_slice(&table); out.extend_from_slice(&body); let len = out.len() as u32; out[0..4].copy_from_slice(&len.to_be_bytes()); out } #[test] fn a_profile_is_recognised_as_the_space_it_describes() { // The whole acquisition path in one assertion: bytes that a colour // manager would publish for a P3 panel must reach the pipeline as // Display P3, and not as sRGB. for space in ColourSpace::ALL { let bytes = profile_for(space, space.label()); let summary = read_profile(&bytes).expect("a well-formed profile"); let (found, exact) = nearest_by_colorants(&summary.colorants); assert_eq!(found, space, "{} was read as {found:?}", space.label()); assert!( exact, "{} did not survive s15Fixed16 rounding", space.label() ); } } #[test] fn the_colorant_matrix_is_not_transposed() { // Reading the three tags into rows rather than columns produces a // matrix that is still plausible, still finite, and describes a // different gamut. Adobe RGB is the case that catches it: it differs // from sRGB in one primary, so a transposition moves it onto a space // that is genuinely close and the nearest-match would hide it. let bytes = profile_for(ColourSpace::AdobeRgb, "Adobe RGB"); let summary = read_profile(&bytes).expect("a well-formed profile"); let want = ColourSpace::AdobeRgb.to_pcs_xyz(); // Not `assert_eq`: the values went out through s15Fixed16 and came // back, so they agree to about 1e-5 and never exactly. The tolerance // is three orders of magnitude tighter than the smallest off-diagonal // difference below, so it still catches the thing it is here for. for (have, want) in summary.colorants.iter().zip(want.iter()) { assert!((have - want).abs() < 1e-4, "{:?}", summary.colorants); } // And the guard that makes the assertion above mean something: a // symmetric matrix would satisfy it transposed as well. assert!( (want[1] - want[3]).abs() > 1e-2, "the colorant matrix must not be symmetric or this proves nothing" ); } #[test] fn a_profile_names_itself_where_it_can() { let bytes = profile_for(ColourSpace::Srgb, "EIZO CG279X calibrated 2024-03"); let summary = read_profile(&bytes).expect("a well-formed profile"); assert_eq!( summary.description.as_deref(), Some("EIZO CG279X calibrated 2024-03") ); } #[test] fn a_v4_profile_names_itself_from_its_unicode_records() { let mut mluc = b"mluc\0\0\0\0".to_vec(); let text: Vec = "LG UltraFine" .encode_utf16() .flat_map(u16::to_be_bytes) .collect(); mluc.extend_from_slice(&1u32.to_be_bytes()); // one record mluc.extend_from_slice(&12u32.to_be_bytes()); // record size mluc.extend_from_slice(b"enUS"); mluc.extend_from_slice(&(text.len() as u32).to_be_bytes()); mluc.extend_from_slice(&28u32.to_be_bytes()); // offset within the tag mluc.extend_from_slice(&text); assert_eq!(text_tag(&mluc).as_deref(), Some("LG UltraFine")); } #[test] fn a_lut_profile_is_refused_by_shape_and_not_by_crashing() { // A hardware calibrator's `mAB `-based profile. There is nothing wrong // with it; we simply cannot approximate it without the CMM this module // exists to avoid, and the About page has to be able to say so. let mut header = vec![0u8; HEADER]; header[36..40].copy_from_slice(b"acsp"); header.extend_from_slice(&0u32.to_be_bytes()); let len = header.len() as u32; header[0..4].copy_from_slice(&len.to_be_bytes()); let err = read_profile(&header).expect_err("no colorants to read"); assert!(err.contains("matrix/TRC"), "{err}"); } #[test] fn rubbish_on_a_root_window_is_refused_rather_than_read_as_colour() { // An X11 property is untyped and world-writable by convention. None of // these may panic, and none may produce a colour space. assert!(read_profile(&[]).is_err()); assert!(read_profile(&[0u8; 200]).is_err()); let mut truncated = profile_for(ColourSpace::Srgb, "sRGB"); truncated.truncate(truncated.len() / 2); assert!(read_profile(&truncated).is_err()); // A profile whose tag table claims far more tags than there are bytes. let mut absurd = profile_for(ColourSpace::Srgb, "sRGB"); absurd[HEADER..HEADER + 4].copy_from_slice(&u32::MAX.to_be_bytes()); assert!(read_profile(&absurd).is_err()); } #[test] fn the_two_entry_points_agree() { // `nearest_space` (chromaticities, from Wayland) and // `nearest_by_colorants` (a matrix, from ICC) must not drift apart: // they are the same decision reached from the two halves of the same // definition, and a session that answered differently depending on // which display server it was on would be the worst kind of bug to // reproduce. for space in ColourSpace::ALL { let bytes = profile_for(space, "x"); let summary = read_profile(&bytes).expect("a well-formed profile"); assert_eq!( nearest_by_colorants(&summary.colorants).0, nearest_space(&space.chromaticities()).0 ); } } }