Ask the platform what colour the screen actually is
FR-DSP-8's acquisition half. `dr_plat::display` surveys the session's
displays and reduces each one's profile to an output space the pipeline
can encode into, stating the mechanism per display server as
FR-PLAT-LIN-2 requires:
- X11 reads the `_ICC_PROFILE` / `_ICC_PROFILE_<n>` root-window
properties, enumerating and numbering the outputs through RandR,
which also yields the rectangles a window move is measured against.
- Wayland binds `wp_color_manager_v1` and asks each `wl_output` for
its image description, accepting either an ICC profile on a file
descriptor or primaries stated as chromaticities.
- Where neither answers, sRGB is assumed and the reason travels with
it as data rather than into a log, so the About page can say which
path the session is on.
A display profile is a measurement of one panel and is none of the four
spaces the pipeline knows. Rather than grow an ICC engine, the profile
is reduced to D50-adapted colorants and matched against the four; a
match that is merely nearest is marked as such and shown as such.
Verified on this machine: mutter 50 advertises the colour-management
global and reports eDP-1 as sRGB, and the same session forced onto X11
enumerates the output through RandR and correctly finds no atom.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
//! 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<String>,
|
||||
}
|
||||
|
||||
/// 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<IccSummary, String> {
|
||||
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<String> {
|
||||
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<u16> = 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<u32, String> {
|
||||
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<i32, String> {
|
||||
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<u8> {
|
||||
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<u8>)> = 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<u8> = "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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user