Skip to main content

sioul_app/
colour.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Colours of the sites on the screen, and calmer colours (docs/colour.md).
5//!
6//! Chromium, inside Qt WebEngine, paints every page in sRGB numbers, and Qt
7//! Quick hands them to the screen as they are: on a screen wider than sRGB
8//! (an Adobe RGB panel, a P3 one) every colour comes out more saturated than
9//! its page meant. A site's picture goes through a shader
10//! (`shaders/colour.frag`) that looks each pixel up in the two tables made
11//! here, once per screen and setting:
12//!
13//! - **the cube**, 33 × 33 × 33 entries over the sRGB numbers a page holds,
14//!   each the colour the screen must show, as the screen's own *linear*
15//!   values, unclipped (a colour just outside the screen's gamut keeps its
16//!   small negative value, so that interpolating across the edge of the gamut
17//!   stays smooth); packed in two dimensions, 33 tiles of 33 × 33 side by
18//!   side, blue choosing the tile;
19//! - **the curve**, 1,024 entries per channel turning linear values into the
20//!   screen's numbers (the inverse of its tone curves), read at
21//!   `u = y^(1/2.4)`, which spreads the entries where the eye sees most.
22//!
23//! "Calmer colours" softens loud colours before that: each colour's chroma in
24//! OKLCh is limited softly, `C' = k · tanh(C / k)`, its lightness and hue kept
25//! (OKLab: Björn Ottosson, "A perceptual color space for image processing",
26//! 2020). Greys and soft tints barely move; a pure red becomes a brick red.
27//!
28//! The screen's profile is read by `cpp/screencolours.cpp` (the X11 atoms
29//! `_ICC_PROFILE`, matched to screens through colord), which calls
30//! [`sioul_colour_tables`]. Little CMS (`lcms2`) reads the profile, on Linux
31//! only: Sioul converts to the screen on X11 alone (macOS converts by itself,
32//! Windows and Wayland too or not at all), while calmer colours, computed
33//! here, work on every computer. Measured against Little CMS's own transform,
34//! the tables give every colour within one 8-bit code (`tests`, and the
35//! research note docs/research/colour.md).
36
37#[cfg(target_os = "linux")]
38use lcms2::{InfoType, Intent, Locale, PixelFormat, Profile, Tag, TagSignature, ToneCurve, Transform};
39use std::sync::Mutex;
40
41/// Entries of the cube on each side.
42pub(crate) const CUBE: usize = 33;
43/// Entries of the curve.
44pub(crate) const CURVE: usize = 1024;
45
46/// How much calmer the sites' colours are (Settings ▸ Display).
47#[derive(Clone, Copy, PartialEq, Eq, Debug)]
48pub(crate) enum Calmer {
49    Off,
50    /// "A little": chroma softly limited at 0.12 in OKLCh.
51    Little,
52    /// "More": at 0.08.
53    More,
54}
55
56impl Calmer {
57    /// As `cpp/screencolours.cpp` passes it: 0 off, 1 "A little", 2 "More"
58    /// (the configuration's "", "little", "more", mapped by `SitesPage.qml`).
59    fn from_index(index: i32) -> Calmer {
60        match index {
61            1 => Calmer::Little,
62            2 => Calmer::More,
63            _ => Calmer::Off,
64        }
65    }
66
67    /// The OKLCh chroma the soft limit tends to; none when off. Pure sRGB red
68    /// has a chroma of 0.26, a pastel 0.03, a face 0.05 to 0.08.
69    fn limit(self) -> Option<f64> {
70        match self {
71            Calmer::Off => None,
72            Calmer::Little => Some(0.12),
73            Calmer::More => Some(0.08),
74        }
75    }
76}
77
78/// The two tables the shader reads, as RGBA floats (alpha unused, 1).
79pub(crate) struct Tables {
80    /// `CUBE`² × `CUBE` texels, rows of `CUBE`² texels: the texel of the
81    /// sRGB numbers (r, g, b), in steps of 1/32, is at x = r + b · 33, y = g.
82    pub cube: Vec<f32>,
83    /// `CURVE` texels: entry j holds the screen's numbers for the linear
84    /// value (j / 1023)^2.4.
85    pub curve: Vec<f32>,
86}
87
88/// What the screen does with linear values, as the tables need it.
89enum Screen {
90    /// sRGB: no profile, or conversion left to the desktop.
91    Srgb,
92    /// A matrix/shaper profile: its linear RGB from XYZ (D50), and the
93    /// inverse of its three tone curves.
94    #[cfg(target_os = "linux")]
95    Matrix { from_xyz: [[f64; 3]; 3], inverse: [ToneCurve; 3] },
96    /// A profile of tables: Little CMS's own transform from sRGB, whose
97    /// numbers the cube then holds (the curve being identity).
98    #[cfg(target_os = "linux")]
99    Tables(Transform<[f32; 3], [f32; 3]>),
100}
101
102/// The tables for a screen's profile (none: sRGB) and calmer colours, or
103/// None when they would change nothing (an sRGB screen, calmer colours off).
104/// An error when the profile cannot be read, or is not an RGB one.
105pub(crate) fn tables(profile: Option<&[u8]>, calmer: Calmer) -> Result<Option<Tables>, String> {
106    let screen = match profile {
107        Some(bytes) => screen_of(bytes)?,
108        None => Screen::Srgb,
109    };
110    if calmer == Calmer::Off && matches!(screen, Screen::Srgb) {
111        return Ok(None);
112    }
113    let made = make(&screen, calmer);
114    if calmer == Calmer::Off && is_identity(&made) {
115        return Ok(None);
116    }
117    Ok(Some(made))
118}
119
120/// The screen a profile describes; profiles are read on Linux only.
121#[cfg(not(target_os = "linux"))]
122fn screen_of(_bytes: &[u8]) -> Result<Screen, String> {
123    Err("colour profiles are read on Linux only".into())
124}
125
126/// The screen a profile describes.
127#[cfg(target_os = "linux")]
128fn screen_of(bytes: &[u8]) -> Result<Screen, String> {
129    let profile = Profile::new_icc(bytes).map_err(|e| format!("the profile cannot be read: {e}"))?;
130    if profile.color_space() != lcms2::ColorSpaceSignature::RgbData {
131        return Err("not an RGB profile".into());
132    }
133    // A profile of tables (some hold a matrix too): Little CMS reads its
134    // tables, which describe the screen better than the matrix.
135    let has_tables = [TagSignature::BToA0Tag, TagSignature::BToA1Tag].iter().any(|&tag| profile.has_tag(tag));
136    if profile.is_matrix_shaper() && !has_tables {
137        let colorant = |tag| match profile.read_tag(tag) {
138            Tag::CIEXYZ(xyz) => Ok([xyz.X, xyz.Y, xyz.Z]),
139            _ => Err("a colorant is missing".to_string()),
140        };
141        let (r, g, b) = (colorant(TagSignature::RedColorantTag)?, colorant(TagSignature::GreenColorantTag)?, colorant(TagSignature::BlueColorantTag)?);
142        let to_xyz = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
143        let from_xyz = invert(&to_xyz).ok_or("the profile's primaries are not independent")?;
144        let curve = |tag| match profile.read_tag(tag) {
145            Tag::ToneCurve(curve) => Ok(curve.reversed()),
146            _ => Err("a tone curve is missing".to_string()),
147        };
148        let inverse = [curve(TagSignature::RedTRCTag)?, curve(TagSignature::GreenTRCTag)?, curve(TagSignature::BlueTRCTag)?];
149        return Ok(Screen::Matrix { from_xyz, inverse });
150    }
151    let transform = Transform::new(&Profile::new_srgb(), PixelFormat::RGB_FLT, &profile, PixelFormat::RGB_FLT, Intent::RelativeColorimetric)
152        .map_err(|e| format!("no transform to the profile: {e}"))?;
153    Ok(Screen::Tables(transform))
154}
155
156/// The cube and the curve.
157fn make(screen: &Screen, calmer: Calmer) -> Tables {
158    let n = CUBE;
159    let mut cube = vec![1.0f32; n * n * n * 4];
160    // sRGB's linear values to XYZ (D50), as Little CMS's own sRGB profile has them.
161    #[cfg(target_os = "linux")]
162    let srgb_to_xyz = srgb_to_xyz();
163    #[cfg(target_os = "linux")]
164    let mut encoded = Vec::new();
165    for b in 0..n {
166        for g in 0..n {
167            for r in 0..n {
168                let numbers = [r, g, b].map(|i| i as f64 / (n - 1) as f64);
169                let mut linear = numbers.map(srgb_decode);
170                if let Some(limit) = calmer.limit() {
171                    linear = soften(linear, limit);
172                }
173                let at = ((r + b * n) + g * n * n) * 4;
174                match screen {
175                    Screen::Srgb => cube[at..at + 3].copy_from_slice(&linear.map(|v| v as f32)),
176                    #[cfg(target_os = "linux")]
177                    Screen::Matrix { from_xyz, .. } => {
178                        let screen_linear = mul(from_xyz, &mul(&srgb_to_xyz, &linear));
179                        cube[at..at + 3].copy_from_slice(&screen_linear.map(|v| v as f32));
180                    }
181                    #[cfg(target_os = "linux")]
182                    Screen::Tables(_) => encoded.push(linear.map(|v| srgb_encode(v) as f32)),
183                }
184            }
185        }
186    }
187    // A profile of tables: the cube holds the screen's numbers, turned
188    // linear by u^2.4 so that the shader's curve (identity, below) gives them back.
189    #[cfg(target_os = "linux")]
190    if let Screen::Tables(transform) = screen {
191        let mut out = vec![[0.0f32; 3]; encoded.len()];
192        transform.transform_pixels(&encoded, &mut out);
193        let mut i = 0;
194        for b in 0..n {
195            for g in 0..n {
196                for r in 0..n {
197                    let at = ((r + b * n) + g * n * n) * 4;
198                    cube[at..at + 3].copy_from_slice(&out[i].map(|v| v.clamp(0.0, 1.0).powf(2.4)));
199                    i += 1;
200                }
201            }
202        }
203    }
204    let mut curve = vec![1.0f32; CURVE * 4];
205    for j in 0..CURVE {
206        let linear = (j as f64 / (CURVE - 1) as f64).powf(2.4);
207        let numbers: [f64; 3] = match screen {
208            Screen::Srgb => [srgb_encode(linear); 3],
209            #[cfg(target_os = "linux")]
210            Screen::Matrix { inverse, .. } => [0, 1, 2].map(|c| f64::from(inverse[c].eval(linear as f32))),
211            #[cfg(target_os = "linux")]
212            Screen::Tables(_) => [linear.powf(1.0 / 2.4); 3],
213        };
214        curve[j * 4..j * 4 + 3].copy_from_slice(&numbers.map(|v| v.clamp(0.0, 1.0) as f32));
215    }
216    Tables { cube, curve }
217}
218
219/// Whether the tables give back what they are given, within a fifth of an
220/// 8-bit code: an sRGB screen's profile, calmer colours off.
221fn is_identity(tables: &Tables) -> bool {
222    let n = CUBE;
223    (0..n).all(|b| {
224        (0..n).all(|g| {
225            (0..n).all(|r| {
226                let at = ((r + b * n) + g * n * n) * 4;
227                [r, g, b].iter().enumerate().all(|(c, &i)| {
228                    let want = i as f64 / (n - 1) as f64;
229                    let got = curve_at(tables, c, f64::from(tables.cube[at + c]));
230                    (got - want).abs() < 0.2 / 255.0
231                })
232            })
233        })
234    })
235}
236
237/// The curve at a linear value, as the shader reads it (linear interpolation in u).
238fn curve_at(tables: &Tables, channel: usize, linear: f64) -> f64 {
239    let u = linear.clamp(0.0, 1.0).powf(1.0 / 2.4) * (CURVE - 1) as f64;
240    let j = (u.floor() as usize).min(CURVE - 2);
241    let f = u - j as f64;
242    let at = |k: usize| f64::from(tables.curve[k * 4 + channel]);
243    at(j) * (1.0 - f) + at(j + 1) * f
244}
245
246/// sRGB's numbers to linear light (IEC 61966-2-1).
247fn srgb_decode(v: f64) -> f64 {
248    if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
249}
250
251/// Linear light to sRGB's numbers.
252fn srgb_encode(v: f64) -> f64 {
253    let v = v.clamp(0.0, 1.0);
254    if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
255}
256
257/// sRGB's linear values to XYZ relative to D50: the colorants of Little
258/// CMS's own sRGB profile, as every transform from sRGB uses them.
259#[cfg(target_os = "linux")]
260fn srgb_to_xyz() -> [[f64; 3]; 3] {
261    let srgb = Profile::new_srgb();
262    let colorant = |tag| match srgb.read_tag(tag) {
263        Tag::CIEXYZ(xyz) => [xyz.X, xyz.Y, xyz.Z],
264        _ => [0.0; 3],
265    };
266    let (r, g, b) = (colorant(TagSignature::RedColorantTag), colorant(TagSignature::GreenColorantTag), colorant(TagSignature::BlueColorantTag));
267    [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]]
268}
269
270// OKLab, from linear sRGB (Björn Ottosson, 2020).
271const TO_LMS: [[f64; 3]; 3] = [[0.412_221_470_8, 0.536_332_536_3, 0.051_445_992_9], [0.211_903_498_2, 0.680_699_545_1, 0.107_396_956_6], [0.088_302_461_9, 0.281_718_837_6, 0.629_978_700_5]];
272const TO_LAB: [[f64; 3]; 3] = [[0.210_454_255_3, 0.793_617_785_0, -0.004_072_046_8], [1.977_998_495_1, -2.428_592_205_0, 0.450_593_709_9], [0.025_904_037_1, 0.782_771_766_2, -0.808_675_766_0]];
273const FROM_LAB: [[f64; 3]; 3] = [[1.0, 0.396_337_777_4, 0.215_803_757_3], [1.0, -0.105_561_345_8, -0.063_854_172_8], [1.0, -0.089_484_177_5, -1.291_485_548_0]];
274const FROM_LMS: [[f64; 3]; 3] = [[4.076_741_662_1, -3.307_711_591_3, 0.230_969_929_2], [-1.268_438_004_6, 2.609_757_401_1, -0.341_319_396_5], [-0.004_196_086_3, -0.703_418_614_7, 1.707_614_701_0]];
275
276/// Linear sRGB to OKLab.
277fn oklab(linear: [f64; 3]) -> [f64; 3] {
278    mul(&TO_LAB, &mul(&TO_LMS, &linear).map(f64::cbrt))
279}
280
281/// OKLab to linear sRGB.
282fn from_oklab(lab: [f64; 3]) -> [f64; 3] {
283    mul(&FROM_LMS, &mul(&FROM_LAB, &lab).map(|v| v * v * v))
284}
285
286/// The OKLCh chroma of a linear sRGB colour.
287#[cfg(test)]
288fn chroma(linear: [f64; 3]) -> f64 {
289    let lab = oklab(linear);
290    lab[1].hypot(lab[2])
291}
292
293/// A colour's chroma softly limited at `limit` (OKLCh), its lightness and hue kept.
294fn soften(linear: [f64; 3], limit: f64) -> [f64; 3] {
295    let mut lab = oklab(linear);
296    let c = lab[1].hypot(lab[2]);
297    if c > 1e-9 {
298        let k = limit * (c / limit).tanh() / c;
299        lab[1] *= k;
300        lab[2] *= k;
301    }
302    from_oklab(lab).map(|v| v.clamp(0.0, 1.0))
303}
304
305fn mul(m: &[[f64; 3]; 3], v: &[f64; 3]) -> [f64; 3] {
306    [0, 1, 2].map(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2])
307}
308
309#[cfg(target_os = "linux")]
310fn invert(m: &[[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
311    let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
312    if det.abs() < 1e-12 {
313        return None;
314    }
315    let c = |r: usize, k: usize| {
316        let (r1, r2, k1, k2) = ((r + 1) % 3, (r + 2) % 3, (k + 1) % 3, (k + 2) % 3);
317        m[r1][k1] * m[r2][k2] - m[r1][k2] * m[r2][k1]
318    };
319    Some([0, 1, 2].map(|i| [0, 1, 2].map(|k| c(k, i) / det)))
320}
321
322/// A profile's own name ("Built-in display, calibrated"), for the sentence in
323/// Settings; none where profiles are not read.
324#[cfg(not(target_os = "linux"))]
325pub(crate) fn profile_name(_bytes: &[u8]) -> Option<String> {
326    None
327}
328
329/// A profile's own name ("Built-in display, calibrated"), for the sentence in Settings.
330#[cfg(target_os = "linux")]
331pub(crate) fn profile_name(bytes: &[u8]) -> Option<String> {
332    let profile = Profile::new_icc(bytes).ok()?;
333    profile.info(InfoType::Description, Locale::none()).map(|name| name.trim().to_string()).filter(|name| !name.is_empty())
334}
335
336/// What `cpp/screencolours.cpp` said last of the screen Sioul's window is on,
337/// for the sentence under "Colours for this screen" in Settings ▸ Display.
338#[derive(Clone, Debug, Default, PartialEq)]
339pub(crate) enum Said {
340    /// Not said yet: the Sites page has not been opened.
341    #[default]
342    Unknown,
343    /// Converted to the screen's profile, named.
344    Converted(String),
345    /// The desktop converts by itself (Wayland, macOS).
346    Desktop,
347    /// No profile, or an sRGB one: colours as they are.
348    Plain,
349    /// Turned off.
350    Off,
351}
352
353static SAID: Mutex<Said> = Mutex::new(Said::Unknown);
354
355/// The last state said.
356pub(crate) fn said() -> Said {
357    SAID.lock().map(|s| s.clone()).unwrap_or_default()
358}
359
360/// The tables for `cpp/screencolours.cpp`: the profile's `length` bytes (none
361/// when null), how calm (0, 1, 2), and where to write them: `cube_size`³ × 4
362/// floats at `cube`, `curve_size` × 4 at `curve`, which must be `CUBE` and
363/// `CURVE`. Returns 1 when written, 0 when they would change nothing, -1 when
364/// the profile cannot be used (then nothing is written), -2 for other sizes.
365///
366/// # Safety
367/// `profile` holds `length` bytes, or is null; `cube` and `curve` have room
368/// for what is said above.
369// SAFETY: no other symbol of the program has this name.
370#[unsafe(no_mangle)]
371pub unsafe extern "C" fn sioul_colour_tables(profile: *const u8, length: usize, calmer: i32, cube_size: usize, cube: *mut f32, curve_size: usize, curve: *mut f32) -> i32 {
372    if cube_size != CUBE || curve_size != CURVE || cube.is_null() || curve.is_null() {
373        return -2;
374    }
375    // SAFETY: the caller gives `length` bytes at `profile`, or null.
376    let bytes = (!profile.is_null() && length > 0).then(|| unsafe { std::slice::from_raw_parts(profile, length) });
377    // A panic in Little CMS's wrapper must not cross into C++.
378    let made = std::panic::catch_unwind(|| tables(bytes, Calmer::from_index(calmer)));
379    match made {
380        Ok(Ok(Some(tables))) => {
381            // SAFETY: the caller gives room for CUBE³ × 4 and CURVE × 4 floats.
382            unsafe {
383                std::ptr::copy_nonoverlapping(tables.cube.as_ptr(), cube, tables.cube.len());
384                std::ptr::copy_nonoverlapping(tables.curve.as_ptr(), curve, tables.curve.len());
385            }
386            1
387        }
388        Ok(Ok(None)) => 0,
389        Ok(Err(e)) => {
390            eprintln!("Sioul: the screen's colour profile: {e}");
391            -1
392        }
393        Err(_) => -1,
394    }
395}
396
397/// The profile's name, written to `out` as UTF-8 with a final zero, cut to
398/// `capacity`; returns its length in bytes without the zero (0: no name).
399///
400/// # Safety
401/// `profile` holds `length` bytes; `out` has room for `capacity` bytes.
402// SAFETY: no other symbol of the program has this name.
403#[unsafe(no_mangle)]
404pub unsafe extern "C" fn sioul_colour_profile_name(profile: *const u8, length: usize, out: *mut u8, capacity: usize) -> usize {
405    if profile.is_null() || out.is_null() || capacity == 0 {
406        return 0;
407    }
408    // SAFETY: as the caller says.
409    let bytes = unsafe { std::slice::from_raw_parts(profile, length) };
410    let name = std::panic::catch_unwind(|| profile_name(bytes)).ok().flatten().unwrap_or_default();
411    // Cut on a character's boundary, room kept for the zero.
412    let mut end = name.len().min(capacity - 1);
413    while !name.is_char_boundary(end) {
414        end -= 1;
415    }
416    // SAFETY: `end` < `capacity`.
417    unsafe {
418        std::ptr::copy_nonoverlapping(name.as_ptr(), out, end);
419        *out.add(end) = 0;
420    }
421    end
422}
423
424/// What the screen Sioul's window is on gets: 0 converted (to the profile
425/// `name`, UTF-8, may be null), 1 by the desktop, 2 nothing (no profile),
426/// 3 turned off.
427///
428/// # Safety
429/// `name` is null or a C string.
430// SAFETY: no other symbol of the program has this name.
431#[unsafe(no_mangle)]
432pub unsafe extern "C" fn sioul_colour_said(state: i32, name: *const std::ffi::c_char) {
433    // SAFETY: as the caller says.
434    let name = if name.is_null() { String::new() } else { unsafe { std::ffi::CStr::from_ptr(name) }.to_string_lossy().into_owned() };
435    let said = match state {
436        0 => Said::Converted(name),
437        1 => Said::Desktop,
438        3 => Said::Off,
439        _ => Said::Plain,
440    };
441    if let Ok(mut now) = SAID.lock() {
442        *now = said;
443    }
444}
445
446// Little CMS is the reference: on Linux, where it is built.
447#[cfg(all(test, target_os = "linux"))]
448mod tests {
449    use super::*;
450    use lcms2::{CIExyY, CIExyYTRIPLE};
451
452    /// A float as RGBA16F keeps it: rounded to 11 significant bits.
453    fn half(v: f32) -> f32 {
454        if v == 0.0 || !v.is_finite() {
455            return v;
456        }
457        let exponent = v.abs().log2().floor().max(-14.0);
458        let step = 2f32.powf(exponent - 10.0);
459        (v / step).round_ties_even() * step
460    }
461
462    /// What the shader gives for 8-bit sRGB numbers, as the GPU computes it:
463    /// tables in half floats, the cube read bilinearly in a tile and mixed
464    /// along blue, clamped, the curve read linearly at u = y^(1/2.4).
465    fn shade(tables: &Tables, rgb: [u8; 3]) -> [f64; 3] {
466        let n = CUBE;
467        let cube: Vec<f64> = tables.cube.iter().map(|&v| f64::from(half(v))).collect();
468        let curve = Tables { cube: Vec::new(), curve: tables.curve.iter().map(|&v| half(v)).collect() };
469        let c = rgb.map(|v| f64::from(v) / 255.0 * (n - 1) as f64);
470        let (r0, g0, b0) = (c[0].floor().min((n - 2) as f64), c[1].floor().min((n - 2) as f64), c[2].floor().min((n - 2) as f64));
471        let (fr, fg, fb) = (c[0] - r0, c[1] - g0, c[2] - b0);
472        let texel = |r: usize, g: usize, b: usize, k: usize| cube[((r + b * n) + g * n * n) * 4 + k];
473        let (r0, g0, b0) = (r0 as usize, g0 as usize, b0 as usize);
474        [0, 1, 2].map(|k| {
475            let tile = |b: usize| {
476                let low = texel(r0, g0, b, k) * (1.0 - fr) + texel(r0 + 1, g0, b, k) * fr;
477                let high = texel(r0, g0 + 1, b, k) * (1.0 - fr) + texel(r0 + 1, g0 + 1, b, k) * fr;
478                low * (1.0 - fg) + high * fg
479            };
480            let linear = tile(b0) * (1.0 - fb) + tile(b0 + 1) * fb;
481            curve_at(&curve, k, linear) * 255.0
482        })
483    }
484
485    /// The colours checked: an 8-bit cube in steps of 17, and loud colours of the web.
486    fn colours() -> Vec<[u8; 3]> {
487        let mut all = Vec::new();
488        for r in (0..=255).step_by(17) {
489            for g in (0..=255).step_by(17) {
490                for b in (0..=255).step_by(17) {
491                    all.push([r as u8, g as u8, b as u8]);
492                }
493            }
494        }
495        all.extend([[0x25, 0xd3, 0x66], [0x58, 0x65, 0xf2], [0x42, 0x85, 0xf4], [0xff, 0x8c, 0x00], [0xff, 0x00, 0x33], [0x4c, 0x6b, 0x5c], [1, 2, 3], [252, 8, 4]]);
496        all
497    }
498
499    /// An RGB profile from its primaries and white (xy) and a curve per channel.
500    fn profile(white: (f64, f64), primaries: [(f64, f64); 3], curve: &ToneCurve) -> Vec<u8> {
501        let xy = |(x, y): (f64, f64)| CIExyY { x, y, Y: 1.0 };
502        let triple = CIExyYTRIPLE { Red: xy(primaries[0]), Green: xy(primaries[1]), Blue: xy(primaries[2]) };
503        Profile::new_rgb(&xy(white), &triple, &[curve, curve, curve]).unwrap().icc().unwrap()
504    }
505
506    /// Compatible with Adobe RGB (1998): its published primaries and white, gamma 563/256.
507    fn adobe_like() -> Vec<u8> {
508        profile((0.3127, 0.3290), [(0.64, 0.33), (0.21, 0.71), (0.15, 0.06)], &ToneCurve::new(563.0 / 256.0))
509    }
510
511    /// A screen wider than sRGB in green and red, with 256-entry curves
512    /// (a gamma of 2.4 with a linear toe), as profiling tools write them.
513    fn table_curves() -> Vec<u8> {
514        let values: Vec<u16> = (0..256)
515            .map(|i| {
516                let x = f64::from(i) / 255.0;
517                let y = if x < 0.03 { x / 16.0 } else { ((x + 0.04) / 1.04).powf(2.4) };
518                (y * 65535.0).round() as u16
519            })
520            .collect();
521        profile((0.3127, 0.3290), [(0.68, 0.32), (0.20, 0.72), (0.15, 0.06)], &ToneCurve::new_tabulated(&values))
522    }
523
524    /// Little CMS's own transform, from 8-bit sRGB numbers to the profile's, in 8-bit units.
525    fn reference(bytes: &[u8], colours: &[[u8; 3]]) -> Vec<[f64; 3]> {
526        let screen = Profile::new_icc(bytes).unwrap();
527        let transform = Transform::new(&Profile::new_srgb(), PixelFormat::RGB_FLT, &screen, PixelFormat::RGB_FLT, Intent::RelativeColorimetric).unwrap();
528        let input: Vec<[f32; 3]> = colours.iter().map(|c| c.map(|v| f32::from(v) / 255.0)).collect();
529        let mut output = vec![[0.0f32; 3]; input.len()];
530        transform.transform_pixels(&input, &mut output);
531        output.iter().map(|o| o.map(|v| f64::from(v.clamp(0.0, 1.0)) * 255.0)).collect()
532    }
533
534    /// The largest and the mean difference, in 8-bit codes.
535    fn differences(tables: &Tables, bytes: &[u8]) -> (f64, f64) {
536        let colours = colours();
537        let want = reference(bytes, &colours);
538        let mut worst: f64 = 0.0;
539        let mut sum = 0.0;
540        for (colour, want) in colours.iter().zip(&want) {
541            let got = shade(tables, *colour);
542            for k in 0..3 {
543                let d = (got[k] - want[k]).abs();
544                worst = worst.max(d);
545                sum += d;
546            }
547        }
548        (worst, sum / (colours.len() * 3) as f64)
549    }
550
551    #[test]
552    fn a_screen_like_adobe_rgb_is_within_one_code_of_little_cms() {
553        let bytes = adobe_like();
554        let tables = tables(Some(&bytes), Calmer::Off).unwrap().expect("a wide screen changes colours");
555        let (worst, mean) = differences(&tables, &bytes);
556        assert!(worst <= 1.0 && mean < 0.1, "worst {worst:.3}, mean {mean:.3}");
557    }
558
559    #[test]
560    fn table_curves_are_within_one_code_of_little_cms() {
561        let bytes = table_curves();
562        let tables = tables(Some(&bytes), Calmer::Off).unwrap().unwrap();
563        let (worst, mean) = differences(&tables, &bytes);
564        assert!(worst <= 1.0 && mean < 0.1, "worst {worst:.3}, mean {mean:.3}");
565    }
566
567    #[test]
568    fn an_srgb_screen_changes_nothing() {
569        let srgb = Profile::new_srgb().icc().unwrap();
570        assert!(tables(Some(&srgb), Calmer::Off).unwrap().is_none());
571        assert!(tables(None, Calmer::Off).unwrap().is_none());
572        // An sRGB profile made from sRGB's own primaries and curve, as a desktop writes one.
573        let curve = ToneCurve::new_parametric(4, &[2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045]).unwrap();
574        let made = profile((0.3127, 0.3290), [(0.64, 0.33), (0.30, 0.60), (0.15, 0.06)], &curve);
575        assert!(tables(Some(&made), Calmer::Off).unwrap().is_none());
576    }
577
578    #[test]
579    fn unreadable_profiles_are_refused() {
580        assert!(tables(Some(b"not a profile"), Calmer::Off).is_err());
581        let grey = Profile::new_gray(&CIExyY { x: 0.3127, y: 0.3290, Y: 1.0 }, &ToneCurve::new(2.2)).unwrap().icc().unwrap();
582        assert!(tables(Some(&grey), Calmer::Off).is_err());
583    }
584
585    #[test]
586    fn a_profile_of_tables_goes_through_little_cms() {
587        // Little CMS's own Lab profile is no screen; a device link would not
588        // be read either. A profile of tables is made from the Adobe-like one
589        // by Little CMS's own writer, keeping its matrix and adding tables.
590        let bytes = adobe_like();
591        let screen = screen_of(&bytes).unwrap();
592        assert!(matches!(screen, Screen::Matrix { .. }));
593        let transform = Transform::new(&Profile::new_srgb(), PixelFormat::RGB_FLT, &Profile::new_icc(&bytes).unwrap(), PixelFormat::RGB_FLT, Intent::RelativeColorimetric).unwrap();
594        let tables = make(&Screen::Tables(transform), Calmer::Off);
595        // The cube holds the screen's numbers there: near-black colours err
596        // most, a few codes at most (docs/colour.md, "Profiles of tables").
597        let (worst, mean) = differences(&tables, &bytes);
598        assert!(worst <= 4.0 && mean < 0.2, "worst {worst:.3}, mean {mean:.3}");
599    }
600
601    #[test]
602    fn calmer_colours_soften_loud_colours_and_keep_greys() {
603        let little = tables(None, Calmer::Little).unwrap().unwrap();
604        let more = tables(None, Calmer::More).unwrap().unwrap();
605        let linear = |rgb: [f64; 3]| rgb.map(|v| srgb_decode(v / 255.0));
606        let hue = |lin: [f64; 3]| {
607            let lab = oklab(lin);
608            lab[2].atan2(lab[1]).to_degrees()
609        };
610        for colour in colours() {
611            let before = linear(colour.map(f64::from));
612            for (tables, limit) in [(&little, 0.12), (&more, 0.08)] {
613                let after = linear(shade(tables, colour));
614                let (c0, c1) = (chroma(before), chroma(after));
615                // Within half a code's worth of chroma of the soft limit.
616                assert!(c1 <= limit * (c0 / limit).tanh() + 0.004, "{colour:?}: {c0:.3} -> {c1:.3}");
617                // Lightness kept within 1 %; hue within 2° wherever there is some colour left.
618                let (l0, l1) = (oklab(before)[0], oklab(after)[0]);
619                assert!((l0 - l1).abs() < 0.01, "{colour:?}: lightness {l0:.3} -> {l1:.3}");
620                if c1 > 0.02 {
621                    let turn = (hue(before) - hue(after) + 540.0) % 360.0 - 180.0;
622                    assert!(turn.abs() < 2.0, "{colour:?}: hue turned {turn:.2}°");
623                }
624            }
625            // Greys stay grey, within half a code.
626            if colour[0] == colour[1] && colour[1] == colour[2] {
627                for tables in [&little, &more] {
628                    let got = shade(tables, colour);
629                    assert!(got.iter().all(|v| (v - f64::from(colour[0])).abs() < 0.5), "{colour:?} -> {got:?}");
630                }
631            }
632        }
633        // A pure red loses about half its chroma with "A little", more with "More".
634        let red = linear([255.0, 0.0, 0.0]);
635        let soft = |t: &Tables| chroma(linear(shade(t, [255, 0, 0]))) / chroma(red);
636        assert!((0.4..0.55).contains(&soft(&little)) && soft(&more) < soft(&little), "{} {}", soft(&little), soft(&more));
637    }
638
639    #[test]
640    fn calmer_colours_on_a_wide_screen_are_both_at_once() {
641        // Calmer, then converted: the same as Little CMS converting the softened colour.
642        let bytes = adobe_like();
643        let tables = tables(Some(&bytes), Calmer::Little).unwrap().unwrap();
644        let screen = Profile::new_icc(&bytes).unwrap();
645        let transform = Transform::new(&Profile::new_srgb(), PixelFormat::RGB_FLT, &screen, PixelFormat::RGB_FLT, Intent::RelativeColorimetric).unwrap();
646        let mut worst: f64 = 0.0;
647        for colour in colours() {
648            let soft = soften(colour.map(|v| srgb_decode(f64::from(v) / 255.0)), 0.12).map(|v| srgb_encode(v) as f32);
649            let mut want = [[0.0f32; 3]];
650            transform.transform_pixels(&[soft], &mut want);
651            let got = shade(&tables, colour);
652            for k in 0..3 {
653                worst = worst.max((got[k] - f64::from(want[0][k].clamp(0.0, 1.0)) * 255.0).abs());
654            }
655        }
656        assert!(worst <= 1.0, "worst {worst:.3}");
657    }
658
659    #[test]
660    fn the_c_functions_write_what_they_say() {
661        let bytes = adobe_like();
662        let mut cube = vec![0.0f32; CUBE * CUBE * CUBE * 4];
663        let mut curve = vec![0.0f32; CURVE * 4];
664        // SAFETY: the sizes given are those of the vectors.
665        let written = unsafe { sioul_colour_tables(bytes.as_ptr(), bytes.len(), 0, CUBE, cube.as_mut_ptr(), CURVE, curve.as_mut_ptr()) };
666        assert_eq!(written, 1);
667        assert_eq!(cube[3], 1.0, "alpha is 1");
668        assert!(curve[(CURVE - 1) * 4] > 0.99);
669        // SAFETY: as above.
670        assert_eq!(unsafe { sioul_colour_tables(std::ptr::null(), 0, 0, CUBE, cube.as_mut_ptr(), CURVE, curve.as_mut_ptr()) }, 0);
671        // SAFETY: as above.
672        assert_eq!(unsafe { sioul_colour_tables(b"junk".as_ptr(), 4, 0, CUBE, cube.as_mut_ptr(), CURVE, curve.as_mut_ptr()) }, -1);
673        // SAFETY: wrong sizes are refused before anything is written.
674        assert_eq!(unsafe { sioul_colour_tables(bytes.as_ptr(), bytes.len(), 0, 17, cube.as_mut_ptr(), CURVE, curve.as_mut_ptr()) }, -2);
675        let mut name = [0u8; 8];
676        // A profile made by Little CMS has a name; cut to the room given, with its zero.
677        // SAFETY: `name` has 8 bytes.
678        let length = unsafe { sioul_colour_profile_name(bytes.as_ptr(), bytes.len(), name.as_mut_ptr(), name.len()) };
679        assert!(length <= 7 && name[length] == 0);
680        // SAFETY: a C string.
681        unsafe { sioul_colour_said(0, c"Screen".as_ptr()) };
682        assert_eq!(said(), Said::Converted("Screen".into()));
683        // SAFETY: null is allowed.
684        unsafe { sioul_colour_said(1, std::ptr::null()) };
685        assert_eq!(said(), Said::Desktop);
686    }
687}