//! TRACES: FR-EXP-2 //! Minimal ICC v2 matrix/TRC profiles, generated. //! //! # Why generated rather than shipped //! //! A profile is a description of what the pixels in a file mean, and the //! pixels here were produced by [`dr_types::colour`]'s matrices. Embedding a //! profile downloaded from elsewhere would mean two independent statements //! about the same space, agreeing until one of them was revised. Deriving both //! from the same primaries makes agreement structural. //! //! It is also the only pure-Rust route. Little-CMS is the obvious library and //! it is C, which the whole workspace avoids so it cross-compiles under the //! Android NDK — the same reasoning behind rustls, bundled SQLite and the //! Lensfun port. //! //! # What "minimal" leaves out //! //! A matrix/TRC display profile and nothing else: three colorants, three tone //! curves, a white point and the chromatic adaptation that got it there. No //! A2B/B2A lookup tables, no gamut tag, no named colours. That is the whole of //! what an RGB working space *is*, and it is what every reader — a browser, an //! operating system compositor, Photoshop — takes from a profile like this //! one. The tags omitted describe device behaviour these spaces do not have. //! //! Profiles come out around 2 KB, which matters more than it sounds: a JPEG //! carries the profile in APP2 segments capped at 64 KB each, and one that fits //! in a single segment avoids the chunked form that some older readers //! mishandle. use dr_types::{ColourSpace, Transfer}; /// The ICC profile describing `space`, ready to embed. /// /// Deterministic — the same space always produces the same bytes. Two exports /// of the same frame must be byte-identical files, which a creation timestamp /// read from the clock would quietly break, along with any deduplication /// downstream of it. pub fn profile(space: ColourSpace) -> Vec { let colorants = space.to_pcs_xyz(); let trc = trc_curve(space.transfer()); // Sorted by signature, as the specification asks a tag table to be. Some // readers binary-search it. let mut tags: Vec<(&[u8; 4], Vec)> = vec![ (b"bTRC", trc.clone()), // Columns, not rows: a colorant tag is where one primary lands in XYZ. (b"bXYZ", xyz_type(colorants[2], colorants[5], colorants[8])), (b"cprt", text_type(COPYRIGHT)), (b"desc", description_type(&description(space))), (b"gTRC", trc.clone()), (b"gXYZ", xyz_type(colorants[1], colorants[4], colorants[7])), (b"rTRC", trc), (b"rXYZ", xyz_type(colorants[0], colorants[3], colorants[6])), // The PCS illuminant itself, not the space's own white. The space's // white is recoverable from this and `chad`, and a profile that put // its native white here would have every reader adapt it twice. (b"wtpt", xyz_type(PCS_D50[0], PCS_D50[1], PCS_D50[2])), ]; // Only where there is an adaptation to declare. ProPhoto is a D50 space // already, and an identity `chad` is a tag saying nothing. let adaptation = space.adaptation_to_pcs(); if !is_identity(&adaptation) { tags.push((b"chad", sf32_type(&adaptation))); } tags.sort_by_key(|(sig, _)| **sig); assemble(&tags) } /// What a colour-management dialogue will show this profile as. /// /// Deliberately not the canonical names. "sRGB IEC61966-2.1" is the reference /// profile, and this is not it — it is a profile derived from the same /// primaries, which is a different and weaker claim. "Adobe RGB (1998)" is /// additionally a name belonging to someone else. A distinct name also tells a /// user opening the file where the profile came from, which is the question /// they are asking when they look. fn description(space: ColourSpace) -> String { format!("DarkRoom {}", space.label()) } /// The copyright tag, which ICC requires a profile to carry. /// /// A set of chromaticity coordinates from a published specification is not /// something to claim rights over, and a profile nobody may redistribute would /// make the files carrying it awkward to share — which is the whole purpose of /// an export. const COPYRIGHT: &str = "Generated by DarkRoom. No rights reserved."; /// The profile connection space illuminant, as s15Fixed16 exactly. const PCS_D50: [f32; 3] = [0.9642, 1.0, 0.8249]; /// Samples in a tabulated tone curve. /// /// 1024 is what the reference sRGB profiles use. The curve is interpolated /// linearly between samples, so this is far finer than the 8-bit values it /// describes; halving it would still be adequate and would save a kilobyte /// nobody is counting. const TRC_SAMPLES: usize = 1024; /// A tone reproduction curve for the space's transfer function. /// /// ICC curves run *towards* the connection space — device value to linear — /// which is the opposite direction from the shader's final encode. Getting it /// backwards produces a file that looks washed out or crushed by exactly the /// amount the curve bends. fn trc_curve(transfer: Transfer) -> Vec { // A pure power curve has an exact representation: a single u8Fixed8 // gamma. Adobe RGB's 563/256 lands on it precisely, where a 1024-entry // table would be an approximation of a number the format can hold. if let Transfer::Gamma(g) = transfer { let mut out = tag_header(b"curv"); out.extend_from_slice(&1u32.to_be_bytes()); out.extend_from_slice(&((g * 256.0).round() as u16).to_be_bytes()); return out; } let mut out = tag_header(b"curv"); out.extend_from_slice(&(TRC_SAMPLES as u32).to_be_bytes()); for i in 0..TRC_SAMPLES { let device = i as f32 / (TRC_SAMPLES - 1) as f32; let linear = transfer.decode(device); out.extend_from_slice(&((linear * 65535.0).round() as u16).to_be_bytes()); } out } /// An `XYZType` tag: one colour in the connection space. fn xyz_type(x: f32, y: f32, z: f32) -> Vec { let mut out = tag_header(b"XYZ "); for v in [x, y, z] { out.extend_from_slice(&s15_fixed16(v).to_be_bytes()); } out } /// An `s15Fixed16ArrayType` tag, which is how `chad` is stored. fn sf32_type(m: &[f32; 9]) -> Vec { let mut out = tag_header(b"sf32"); for v in m { out.extend_from_slice(&s15_fixed16(*v).to_be_bytes()); } out } /// A `textType` tag: ASCII with a terminating NUL. fn text_type(s: &str) -> Vec { let mut out = tag_header(b"text"); out.extend_from_slice(s.as_bytes()); out.push(0); out } /// A `textDescriptionType` tag — the v2 profile's name field. /// /// Baroque, and not optional: v2 has no plain `mluc`, and the ASCII string is /// followed by empty Unicode and ScriptCode blocks that a reader will walk /// whether or not they hold anything. The 67-byte Macintosh field is fixed /// width by specification, so it is written out zeroed rather than omitted. fn description_type(s: &str) -> Vec { let ascii = s.as_bytes(); let mut out = tag_header(b"desc"); out.extend_from_slice(&(ascii.len() as u32 + 1).to_be_bytes()); out.extend_from_slice(ascii); out.push(0); // Unicode language code, then Unicode character count: none of either. out.extend_from_slice(&[0; 8]); // ScriptCode code (u16), length (u8), and the fixed 67-byte field. out.extend_from_slice(&[0; 3]); out.extend_from_slice(&[0; 67]); out } /// Every tag element opens with its type signature and four reserved bytes. fn tag_header(sig: &[u8; 4]) -> Vec { let mut out = Vec::from(*sig); out.extend_from_slice(&[0; 4]); out } /// ICC's fixed-point number: 16 integer bits, 16 fractional. fn s15_fixed16(v: f32) -> i32 { (f64::from(v) * 65536.0).round() as i32 } fn is_identity(m: &[f32; 9]) -> bool { const IDENTITY: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; // One step of s15Fixed16, the format the matrix would be stored in. Below // that it *is* the identity — ProPhoto's own white and the PCS illuminant // differ in the sixth decimal place, and a `chad` recording that would be // nine copies of 1.0000 and 0.0000 dressed up as information. const STEP: f32 = 1.0 / 65536.0; m.iter().zip(IDENTITY).all(|(a, b)| (a - b).abs() < STEP) } /// Header, tag table, and the tag data, with the size written back in. fn assemble(tags: &[(&[u8; 4], Vec)]) -> Vec { let mut out = header(); out.extend_from_slice(&(tags.len() as u32).to_be_bytes()); let table_at = out.len(); out.resize(table_at + tags.len() * 12, 0); for (i, (sig, data)) in tags.iter().enumerate() { // Identical elements share one copy, which the specification allows // explicitly. The three tone curves of a grey-balanced space are the // same 2 KB table, so this is two thirds of the profile. let offset = find(&out, data).unwrap_or_else(|| { let at = out.len(); out.extend_from_slice(data); // Every element starts on a four-byte boundary. while !out.len().is_multiple_of(4) { out.push(0); } at }); let entry = table_at + i * 12; out[entry..entry + 4].copy_from_slice(*sig); out[entry + 4..entry + 8].copy_from_slice(&(offset as u32).to_be_bytes()); out[entry + 8..entry + 12].copy_from_slice(&(data.len() as u32).to_be_bytes()); } let size = out.len() as u32; out[0..4].copy_from_slice(&size.to_be_bytes()); out } /// Where `needle` already sits in `haystack`, if it does. /// /// Only ever called with tag elements, which begin on four-byte boundaries and /// start with a type signature — so a match cannot be a coincidental overlap /// of two other tags' bytes. fn find(haystack: &[u8], needle: &[u8]) -> Option { haystack .windows(needle.len()) .position(|w| w == needle) .filter(|at| at.is_multiple_of(4)) } /// The fixed 128-byte profile header. fn header() -> Vec { let mut h = Vec::with_capacity(128); // Size, filled in once the profile is complete. h.extend_from_slice(&[0; 4]); // Preferred CMM: no preference. h.extend_from_slice(&[0; 4]); // Version 2.1.0. v2 rather than v4 because it is what every reader // handles, and because nothing here needs a v4 tag type. h.extend_from_slice(&[0x02, 0x10, 0x00, 0x00]); h.extend_from_slice(b"mntr"); h.extend_from_slice(b"RGB "); h.extend_from_slice(b"XYZ "); // Creation date. Fixed, for the determinism the module docs describe. for field in [2025u16, 1, 1, 0, 0, 0] { h.extend_from_slice(&field.to_be_bytes()); } h.extend_from_slice(b"acsp"); // Primary platform, flags, manufacturer, model, attributes: unspecified. h.extend_from_slice(&[0; 24]); // Rendering intent: perceptual, as the reference RGB working-space // profiles declare. For a matrix/TRC profile the field is advisory — // there is only one transform in here to apply. h.extend_from_slice(&[0; 4]); for v in PCS_D50 { h.extend_from_slice(&s15_fixed16(v).to_be_bytes()); } // Creator, profile ID, and the reserved tail. h.extend_from_slice(&[0; 4]); h.extend_from_slice(&[0; 16]); h.extend_from_slice(&[0; 28]); debug_assert_eq!(h.len(), 128); h } #[cfg(test)] mod tests { use super::*; /// A tag's element data, located through the profile's own tag table — /// so these tests read the profile the way a colour engine would rather /// than the way it was written. fn tag<'a>(profile: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> { let count = u32::from_be_bytes(profile[128..132].try_into().unwrap()) as usize; for i in 0..count { let at = 132 + i * 12; if &profile[at..at + 4] == want { let off = u32::from_be_bytes(profile[at + 4..at + 8].try_into().unwrap()) as usize; let len = u32::from_be_bytes(profile[at + 8..at + 12].try_into().unwrap()) as usize; return Some(&profile[off..off + len]); } } None } fn xyz(data: &[u8]) -> [f32; 3] { let read = |at: usize| i32::from_be_bytes(data[at..at + 4].try_into().unwrap()) as f32 / 65536.0; [read(8), read(12), read(16)] } #[test] fn a_profile_declares_its_own_length() { // The first field a reader trusts. A profile whose header says it is // longer than the buffer is one a strict parser rejects outright and a // lax one reads past the end of. for space in ColourSpace::ALL { let p = profile(space); let declared = u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize; assert_eq!(declared, p.len(), "{space:?}"); } } #[test] fn a_profile_carries_the_signature_that_identifies_it_as_one() { // `acsp` at offset 36 is how every reader recognises an ICC profile. for space in ColourSpace::ALL { assert_eq!(&profile(space)[36..40], b"acsp", "{space:?}"); } } #[test] fn every_tag_lies_inside_the_profile_and_on_a_boundary() { // A tag table is offsets and lengths, and nothing checks them for us. // An off-by-four here produces a profile that parses as far as the // tag a reader happens to want. for space in ColourSpace::ALL { let p = profile(space); let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize; for i in 0..count { let at = 132 + i * 12; let off = u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap()) as usize; let len = u32::from_be_bytes(p[at + 8..at + 12].try_into().unwrap()) as usize; assert!(off.is_multiple_of(4), "{space:?} tag {i} starts at {off}"); assert!(off + len <= p.len(), "{space:?} tag {i} runs off the end"); } } } #[test] fn every_profile_carries_the_tags_a_matrix_trc_profile_requires() { // The ICC v2 required set for a display profile. A reader missing any // one of these falls back to assuming sRGB, which is the silent // failure this whole feature exists to prevent. for space in ColourSpace::ALL { let p = profile(space); for required in [ b"desc", b"cprt", b"wtpt", b"rXYZ", b"gXYZ", b"bXYZ", b"rTRC", b"gTRC", b"bTRC", ] { assert!(tag(&p, required).is_some(), "{space:?} has no {required:?}"); } } } #[test] fn the_colorants_are_the_ones_the_shader_encoded_with() { // The property the file's honesty rests on. The composer converts the // pixels with `to_pcs_xyz`'s primaries; if the profile described any // others the file would be a precise, confident lie. for space in ColourSpace::ALL { let p = profile(space); let want = space.to_pcs_xyz(); for (i, sig) in [b"rXYZ", b"gXYZ", b"bXYZ"].into_iter().enumerate() { let got = xyz(tag(&p, sig).expect("colorant")); for (row, g) in got.iter().enumerate() { let expected = want[row * 3 + i]; assert!( (g - expected).abs() < 1e-4, "{space:?} {sig:?} row {row}: profile says {g}, shader used {expected}" ); } } } } #[test] fn the_white_point_is_the_connection_space_illuminant() { // Not the space's own white. ProPhoto's is D50 anyway, but P3's is // D65, and a profile advertising D65 as its media white would have // every neutral adapted a second time. for space in ColourSpace::ALL { let got = xyz(tag(&profile(space), b"wtpt").expect("wtpt")); for (i, want) in PCS_D50.iter().enumerate() { assert!((got[i] - want).abs() < 1e-4, "{space:?} white {i}: {got:?}"); } } } #[test] fn a_tabulated_curve_reproduces_the_transfer_function_it_came_from() { // Read back out of the profile and compared against the function the // shader encodes with. The curve runs device-to-linear, and writing it // the other way round would still produce a monotonic curve of the // right length — this is what catches the direction. for space in [ColourSpace::Srgb, ColourSpace::ProPhoto] { let p = profile(space); let curve = tag(&p, b"rTRC").expect("rTRC"); let count = u32::from_be_bytes(curve[8..12].try_into().unwrap()) as usize; assert_eq!(count, TRC_SAMPLES, "{space:?}"); let transfer = space.transfer(); for i in [0, 1, count / 4, count / 2, count - 1] { let at = 12 + i * 2; let got = f32::from(u16::from_be_bytes(curve[at..at + 2].try_into().unwrap())) / 65535.0; let want = transfer.decode(i as f32 / (count - 1) as f32); assert!( (got - want).abs() < 1e-4, "{space:?} sample {i}: profile {got}, transfer {want}" ); } } } #[test] fn adobe_rgb_stores_its_gamma_exactly_rather_than_sampling_it() { // 563/256 is representable in a u8Fixed8, so the curve is one number. // A 1024-entry table would approximate a value the format can hold // exactly, and would round-trip through other software as 2.2. let p = profile(ColourSpace::AdobeRgb); let curve = tag(&p, b"rTRC").expect("rTRC"); assert_eq!(u32::from_be_bytes(curve[8..12].try_into().unwrap()), 1); assert_eq!(u16::from_be_bytes(curve[12..14].try_into().unwrap()), 563); } #[test] fn the_three_tone_curves_share_one_copy() { // Not a size optimisation for its own sake: it keeps the profile under // the 64 KB a single JPEG APP2 segment holds, so the chunked form that // older readers mishandle is never needed. let p = profile(ColourSpace::Srgb); let count = u32::from_be_bytes(p[128..132].try_into().unwrap()) as usize; let offsets: Vec = ["rTRC", "gTRC", "bTRC"] .iter() .map(|sig| { (0..count) .map(|i| 132 + i * 12) .find(|at| &p[*at..at + 4] == sig.as_bytes()) .map(|at| u32::from_be_bytes(p[at + 4..at + 8].try_into().unwrap())) .expect("curve present") }) .collect(); assert_eq!(offsets[0], offsets[1]); assert_eq!(offsets[1], offsets[2]); assert!(p.len() < 8 * 1024, "{} bytes is too large", p.len()); } #[test] fn a_d65_space_declares_its_adaptation_and_a_d50_space_does_not() { // `chad` is what lets a reader recover the space's native white from // colorants that have already been adapted. Without it, D65 primaries // adapted to D50 and genuine D50 primaries are the same nine numbers. assert!(tag(&profile(ColourSpace::DisplayP3), b"chad").is_some()); assert!( tag(&profile(ColourSpace::ProPhoto), b"chad").is_none(), "ProPhoto is a D50 space; an identity chad says nothing" ); } #[test] fn the_same_space_always_produces_the_same_bytes() { // Two exports of one frame must be identical files. A creation // timestamp from the clock is the obvious way to lose that. for space in ColourSpace::ALL { assert_eq!(profile(space), profile(space), "{space:?}"); } } #[test] fn each_space_is_described_by_its_own_name() { // A file whose profile says "sRGB" while carrying P3 pixels is exactly // as misleading as no profile at all, and harder to notice. for space in ColourSpace::ALL { let p = profile(space); let desc = tag(&p, b"desc").expect("desc"); let len = u32::from_be_bytes(desc[8..12].try_into().unwrap()) as usize; let name = std::str::from_utf8(&desc[12..12 + len - 1]).expect("ascii"); assert_eq!(name, format!("DarkRoom {}", space.label())); } } }