tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-image/src/perfil.rs Rust · 810 siq'ikuna · 32.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! Perfiles ICC: en qué espacio de color viene una imagen, y cómo llevarla al
2//! de trabajo (sRGB).
3//!
4//! ## El agujero que esto tapa
5//!
6//! Hasta 2026-08-05 el decodificador tiraba el perfil ICC del archivo y
7//! entregaba los bytes RGB crudos. Para una imagen sRGB eso está bien; para
8//! cualquier otra es **mostrar los números en el espacio equivocado**. Una foto
9//! Display P3 —lo que saca cualquier teléfono desde 2016— se ve **sobresaturada**
10//! porque sus rojos, que son más rojos que los de sRGB, se pintan como si fueran
11//! los de sRGB. Un escaneo con perfil de imprenta se ve lavado. Y no es un
12//! detalle estético: `tullpu` dice tener paridad Photoshop, y editar color sin
13//! saber en qué espacio estás no es paridad de nada.
14//!
15//! ## Qué cubre y qué no
16//!
17//! Los perfiles **matrix-shaper** (matriz de 3×3 + una curva por canal), que es
18//! lo que son sRGB, Display P3, Adobe RGB, Rec.2020 y la enorme mayoría de los
19//! perfiles de pantalla y de cámara. **No** cubre los perfiles basados en LUT
20//! (`A2B0`), que es como vienen los de imprenta CMYK: ahí devuelve `None` y la
21//! imagen queda como estaba — sin conversión es mejor que con una inventada.
22//!
23//! La conversión es **colorimétrica relativa** y recorta a gamut sRGB por
24//! saturación de canal. Un rojo P3 puro no existe en sRGB: se lo lleva al rojo
25//! sRGB más cercano. Eso es un recorte, no un error — pero conviene saberlo, y
26//! por eso está escrito acá y no oculto.
27
28/// Curva de transferencia de un canal (el «shaper» del matrix-shaper).
29#[derive(Debug, Clone, PartialEq)]
30pub enum Curva {
31    /// Identidad — el perfil declara los valores ya lineales.
32    Lineal,
33    /// Una gamma sola: `lineal = v^γ`.
34    Gamma(f32),
35    /// Tabla de muestras equiespaciadas en 0..=1, con interpolación lineal
36    /// entre ellas. Es como vienen casi todos los perfiles reales.
37    Tabla(Vec<u16>),
38    /// Curva paramétrica (`para`): los cinco tipos de la especificación, con
39    /// sus parámetros `g, a, b, c, d, e, f`. Es la forma en que vienen la curva
40    /// de sRGB y la de L*, y **no** se puede aproximar por su exponente: con
41    /// ECI-RGBv2 (que usa L*) esa aproximación erraba un gris medio por unos
42    /// 20 valores de 8 bits.
43    Parametrica { tipo: u16, p: [f32; 7] },
44}
45
46impl Curva {
47    /// Lleva un valor codificado (0..=1) a lineal (0..=1).
48    pub fn a_lineal(&self, v: f32) -> f32 {
49        match self {
50            Curva::Lineal => v,
51            Curva::Gamma(g) => v.max(0.0).powf(*g),
52            Curva::Tabla(t) if t.len() >= 2 => {
53                let v = v.clamp(0.0, 1.0);
54                let pos = v * (t.len() - 1) as f32;
55                let i = pos.floor() as usize;
56                let f = pos - i as f32;
57                let a = t[i] as f32 / 65535.0;
58                let b = t[(i + 1).min(t.len() - 1)] as f32 / 65535.0;
59                a + (b - a) * f
60            }
61            // Una tabla de 0 ó 1 muestras no describe nada; identidad.
62            Curva::Tabla(_) => v,
63            // Los cinco tipos de la especificación, tal cual. El 3 es la
64            // familia de sRGB y de L*: un tramo recto abajo y una potencia
65            // arriba, que es justo lo que se pierde al mirar sólo `g`.
66            Curva::Parametrica { tipo, p } => {
67                let [g, a, b, c, d, e, f] = *p;
68                let pot = |x: f32| (a * x + b).max(0.0).powf(g);
69                match tipo {
70                    0 => v.max(0.0).powf(g),
71                    1 => {
72                        if a != 0.0 && v >= -b / a {
73                            pot(v)
74                        } else {
75                            0.0
76                        }
77                    }
78                    2 => {
79                        if a != 0.0 && v >= -b / a {
80                            pot(v) + c
81                        } else {
82                            c
83                        }
84                    }
85                    3 => {
86                        if v >= d {
87                            pot(v)
88                        } else {
89                            c * v
90                        }
91                    }
92                    4 => {
93                        if v >= d {
94                            pot(v) + e
95                        } else {
96                            c * v + f
97                        }
98                    }
99                    // Un tipo que la especificación no define todavía: mejor
100                    // dejar el valor como está que inventar una curva.
101                    _ => v,
102                }
103            }
104        }
105    }
106
107    /// Inversa de [`Curva::a_lineal`]: de un valor **lineal** (0..=1) al valor
108    /// **codificado** que este perfil espera.
109    ///
110    /// Hace falta para *escribir* una imagen en el espacio del perfil, no sólo
111    /// para leerla: sin esto, exponer el perfil de origen no serviría de nada
112    /// porque no habría con qué volver a él.
113    pub fn desde_lineal(&self, lin: f32) -> f32 {
114        let lin = lin.clamp(0.0, 1.0);
115        let salida = match self {
116            Curva::Lineal => lin,
117            Curva::Gamma(g) if *g > 0.0 => lin.powf(1.0 / *g),
118            // Una gamma de 0 no describe una curva invertible; identidad.
119            Curva::Gamma(_) => lin,
120            Curva::Tabla(t) if t.len() >= 2 => {
121                let objetivo = lin * 65535.0;
122                // Las TRC reales son monótonas no decrecientes, así que se puede
123                // buscar el tramo por bisección. Si alguna no lo fuera, esto
124                // devuelve *un* valor válido, no basura.
125                let i = t.partition_point(|m| (*m as f32) < objetivo);
126                if i == 0 {
127                    0.0
128                } else if i >= t.len() {
129                    1.0
130                } else {
131                    let (a, b) = (t[i - 1] as f32, t[i] as f32);
132                    // Tramo plano: cualquier punto de él mapea al mismo lineal.
133                    let f = if b > a { (objetivo - a) / (b - a) } else { 0.0 };
134                    (i as f32 - 1.0 + f) / (t.len() - 1) as f32
135                }
136            }
137            Curva::Tabla(_) => lin,
138            Curva::Parametrica { tipo, p } => {
139                let [g, a, b, c, d, e, f] = *p;
140                // Despeje de `(a·x + b)^g = y`.
141                let raiz = |y: f32| -> f32 {
142                    if g == 0.0 || a == 0.0 {
143                        0.0
144                    } else {
145                        (y.max(0.0).powf(1.0 / g) - b) / a
146                    }
147                };
148                let pot = |x: f32| (a * x + b).max(0.0).powf(g);
149                match tipo {
150                    0 => {
151                        if g > 0.0 {
152                            lin.powf(1.0 / g)
153                        } else {
154                            lin
155                        }
156                    }
157                    1 => raiz(lin),
158                    2 => raiz(lin - c),
159                    // El corte va sobre el valor lineal que le toca a `d`, no
160                    // sobre `d` mismo: acá se entra por el otro lado de la curva.
161                    3 => {
162                        if lin >= pot(d) {
163                            raiz(lin)
164                        } else if c != 0.0 {
165                            lin / c
166                        } else {
167                            0.0
168                        }
169                    }
170                    4 => {
171                        if lin >= pot(d) + e {
172                            raiz(lin - e)
173                        } else if c != 0.0 {
174                            (lin - f) / c
175                        } else {
176                            0.0
177                        }
178                    }
179                    _ => lin,
180                }
181            }
182        };
183        salida.clamp(0.0, 1.0)
184    }
185}
186
187/// Un perfil matrix-shaper leído de un ICC.
188#[derive(Debug, Clone, PartialEq)]
189pub struct Perfil {
190    /// Matriz RGB-del-perfil → XYZ del PCS, **por filas**. El PCS de un ICC de
191    /// display siempre es XYZ adaptado a D50: eso lo fija la especificación, no
192    /// una elección nuestra.
193    pub a_xyz_d50: [f32; 9],
194    pub curva: Curva,
195}
196
197// ── Constantes de colorimetría ───────────────────────────────────────────
198//
199// Las tres son valores publicados, no derivados acá. El test
200// `un_perfil_srgb_no_cambia_nada` las cruza entre sí: si cualquiera estuviera
201// mal, la matriz de sRGB no daría la identidad.
202
203/// XYZ (D65) → sRGB lineal, por filas.
204const XYZ_D65_A_SRGB: [f32; 9] = [
205    3.2404542, -1.5371385, -0.4985314, //
206    -0.9692660, 1.8760108, 0.0415560, //
207    0.0556434, -0.2040259, 1.0572252,
208];
209
210/// Adaptación cromática Bradford D50 → D65, por filas.
211const BRADFORD_D50_A_D65: [f32; 9] = [
212    0.9555766, -0.0230393, 0.0631636, //
213    -0.0282895, 1.0099416, 0.0210077, //
214    0.0122982, -0.0204830, 1.3299098,
215];
216
217/// RGB sRGB → XYZ D50, por filas. Es la matriz que **trae adentro** cualquier
218/// perfil sRGB; sirve de patrón para el test de identidad.
219pub const SRGB_A_XYZ_D50: [f32; 9] = [
220    0.4360747, 0.3850649, 0.1430804, //
221    0.2225045, 0.7168786, 0.0606169, //
222    0.0139322, 0.0971045, 0.7141733,
223];
224
225fn multiplicar(a: &[f32; 9], b: &[f32; 9]) -> [f32; 9] {
226    let mut m = [0.0f32; 9];
227    for f in 0..3 {
228        for c in 0..3 {
229            m[f * 3 + c] = (0..3).map(|k| a[f * 3 + k] * b[k * 3 + c]).sum();
230        }
231    }
232    m
233}
234
235/// Matriz que lleva el RGB **lineal** del perfil al RGB **lineal** de sRGB.
236pub fn matriz_a_srgb(a_xyz_d50: &[f32; 9]) -> [f32; 9] {
237    multiplicar(&XYZ_D65_A_SRGB, &multiplicar(&BRADFORD_D50_A_D65, a_xyz_d50))
238}
239
240/// Inversa de una 3×3. `None` si es singular — no pasa con primarios de verdad
241/// (tres colores independientes), pero invertir a ciegas daría infinitos.
242fn invertir(m: &[f32; 9]) -> Option<[f32; 9]> {
243    let det = m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6])
244        + m[2] * (m[3] * m[7] - m[4] * m[6]);
245    if det.abs() < 1e-9 {
246        return None;
247    }
248    let k = 1.0 / det;
249    Some([
250        (m[4] * m[8] - m[5] * m[7]) * k,
251        (m[2] * m[7] - m[1] * m[8]) * k,
252        (m[1] * m[5] - m[2] * m[4]) * k,
253        (m[5] * m[6] - m[3] * m[8]) * k,
254        (m[0] * m[8] - m[2] * m[6]) * k,
255        (m[2] * m[3] - m[0] * m[5]) * k,
256        (m[3] * m[7] - m[4] * m[6]) * k,
257        (m[1] * m[6] - m[0] * m[7]) * k,
258        (m[0] * m[4] - m[1] * m[3]) * k,
259    ])
260}
261
262/// Matriz que lleva el RGB **lineal** de sRGB al RGB **lineal** del perfil: la
263/// inversa de [`matriz_a_srgb`]. Es el camino de **salida**, el que hace falta
264/// para re-exportar al espacio del que vino la imagen.
265pub fn matriz_desde_srgb(a_xyz_d50: &[f32; 9]) -> Option<[f32; 9]> {
266    invertir(&matriz_a_srgb(a_xyz_d50))
267}
268
269/// ¿Esta matriz es la identidad dentro de `tol`? Se usa para saltear la
270/// conversión cuando el perfil ya es sRGB: recorrer ocho millones de píxeles
271/// para multiplicarlos por 1 es la clase de trabajo que no se paga.
272pub fn es_identidad(m: &[f32; 9], tol: f32) -> bool {
273    for f in 0..3 {
274        for c in 0..3 {
275            let esperado = if f == c { 1.0 } else { 0.0 };
276            if (m[f * 3 + c] - esperado).abs() > tol {
277                return false;
278            }
279        }
280    }
281    true
282}
283
284// ── Lectura del ICC ──────────────────────────────────────────────────────
285
286fn u32_be(b: &[u8], o: usize) -> Option<u32> {
287    Some(u32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?))
288}
289
290/// s15Fixed16: entero con signo de 16 bits + 16 bits de fracción.
291fn s15f16(b: &[u8], o: usize) -> Option<f32> {
292    Some(i32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?) as f32 / 65536.0)
293}
294
295/// Busca un tag por su firma de 4 letras y devuelve su rebanada.
296fn tag<'a>(icc: &'a [u8], firma: &[u8; 4]) -> Option<&'a [u8]> {
297    let n = u32_be(icc, 128)? as usize;
298    for i in 0..n {
299        let e = 132 + i * 12;
300        if icc.get(e..e + 4)? == firma {
301            let off = u32_be(icc, e + 4)? as usize;
302            let len = u32_be(icc, e + 8)? as usize;
303            return icc.get(off..off.checked_add(len)?);
304        }
305    }
306    None
307}
308
309/// Lee un tag de tipo `XYZ ` y devuelve su terna.
310fn xyz(icc: &[u8], firma: &[u8; 4]) -> Option<[f32; 3]> {
311    let t = tag(icc, firma)?;
312    if t.get(0..4)? != b"XYZ " {
313        return None;
314    }
315    Some([s15f16(t, 8)?, s15f16(t, 12)?, s15f16(t, 16)?])
316}
317
318/// Lee un tag de curva (`curv` o `para`).
319fn curva(icc: &[u8], firma: &[u8; 4]) -> Option<Curva> {
320    let t = tag(icc, firma)?;
321    match t.get(0..4)? {
322        b"curv" => {
323            let n = u32_be(t, 8)? as usize;
324            match n {
325                0 => Some(Curva::Lineal),
326                // Una sola muestra es una gamma en u8Fixed8.
327                1 => {
328                    let g = u16::from_be_bytes(t.get(12..14)?.try_into().ok()?) as f32 / 256.0;
329                    Some(Curva::Gamma(g))
330                }
331                _ => {
332                    let datos = t.get(12..12 + n * 2)?;
333                    Some(Curva::Tabla(
334                        datos
335                            .chunks_exact(2)
336                            .map(|c| u16::from_be_bytes([c[0], c[1]]))
337                            .collect(),
338                    ))
339                }
340            }
341        }
342        // Curva paramétrica: u16 de tipo en el offset 8, y de ahí los
343        // parámetros en s15Fixed16, en el orden `g, a, b, c, d, e, f`. Cuántos
344        // hay lo dice el tipo; los que falten quedan en 0.
345        b"para" => {
346            let tipo = u16::from_be_bytes(t.get(8..10)?.try_into().ok()?);
347            let cuantos = match tipo {
348                0 => 1,
349                1 => 3,
350                2 => 4,
351                3 => 5,
352                4 => 7,
353                _ => return None,
354            };
355            let mut p = [0.0f32; 7];
356            for (i, v) in p.iter_mut().enumerate().take(cuantos) {
357                *v = s15f16(t, 12 + i * 4)?;
358            }
359            Some(Curva::Parametrica { tipo, p })
360        }
361        _ => None,
362    }
363}
364
365/// Lee un perfil ICC matrix-shaper. Devuelve `None` si no lo es —un perfil de
366/// imprenta basado en LUT, un ICC truncado, cualquier cosa que no se entienda—
367/// y entonces la imagen se deja como está: sin conversión es mejor que con una
368/// inventada.
369pub fn leer_icc(icc: &[u8]) -> Option<Perfil> {
370    if icc.len() < 132 {
371        return None;
372    }
373    // El espacio de datos tiene que ser RGB: un CMYK no se arregla con una
374    // matriz de 3×3.
375    if icc.get(16..20)? != b"RGB " {
376        return None;
377    }
378    let r = xyz(icc, b"rXYZ")?;
379    let g = xyz(icc, b"gXYZ")?;
380    let b = xyz(icc, b"bXYZ")?;
381    // Los tags vienen por columna (cada uno es el XYZ de un primario); la
382    // matriz se guarda por filas.
383    let a_xyz_d50 = [
384        r[0], g[0], b[0], //
385        r[1], g[1], b[1], //
386        r[2], g[2], b[2],
387    ];
388    // Se usa la curva del rojo para los tres. Un perfil con curvas distintas
389    // por canal existe en el papel; en la práctica los tres tags apuntan al
390    // mismo offset. Si algún día aparece uno que no, esto lo trata como gris.
391    let curva = curva(icc, b"rTRC").unwrap_or(Curva::Gamma(2.2));
392    Some(Perfil { a_xyz_d50, curva })
393}
394
395// ── Conversión de píxeles ────────────────────────────────────────────────
396
397/// Codifica un valor lineal 0..=1 a sRGB 0..=255.
398fn a_srgb_u8(lin: f32) -> u8 {
399    let v = lin.clamp(0.0, 1.0);
400    let s = if v <= 0.0031308 {
401        v * 12.92
402    } else {
403        1.055 * v.powf(1.0 / 2.4) - 0.055
404    };
405    (s * 255.0 + 0.5) as u8
406}
407
408/// Decodifica un valor sRGB 0..=1 a lineal 0..=1. La inversa exacta de
409/// [`a_srgb_u8`], con el mismo tramo recto abajo.
410fn srgb_a_lineal(v: f32) -> f32 {
411    let v = v.clamp(0.0, 1.0);
412    if v <= 0.04045 {
413        v / 12.92
414    } else {
415        ((v + 0.055) / 1.055).powf(2.4)
416    }
417}
418
419/// Muestras de la tabla lineal→sRGB. 16.384 alcanzan para que el error de
420/// cuantización quede debajo de 0,2 de un valor de 8 bits incluso en el tramo
421/// recto de abajo, que es el más empinado.
422const MUESTRAS_LUT: usize = 16_384;
423
424/// Convierte una imagen RGBA8 del espacio de `perfil` a sRGB, **in situ**.
425///
426/// Devuelve `false` y no toca nada si el perfil ya es sRGB (o tan parecido que
427/// la diferencia no llega a un valor de 8 bits): recorrer ocho millones de
428/// píxeles para multiplicarlos por la identidad es trabajo que no se paga.
429///
430/// El alfa no se toca: no es color.
431pub fn convertir_a_srgb(rgba: &mut [u8], perfil: &Perfil) -> bool {
432    let m = matriz_a_srgb(&perfil.a_xyz_d50);
433    // 1/512 en lineal es menos de medio valor de 8 bits en casi todo el rango.
434    if es_identidad(&m, 1.0 / 512.0) {
435        return false;
436    }
437
438    // Tabla de decodificación: 256 entradas, una por valor de entrada.
439    let mut a_lineal = [0.0f32; 256];
440    for (i, v) in a_lineal.iter_mut().enumerate() {
441        *v = perfil.curva.a_lineal(i as f32 / 255.0);
442    }
443    // Tabla de codificación: el `powf` de la curva sRGB por píxel y canal serían
444    // veinticinco millones de llamadas en una foto 4K.
445    let mut a_byte = vec![0u8; MUESTRAS_LUT];
446    for (i, v) in a_byte.iter_mut().enumerate() {
447        *v = a_srgb_u8(i as f32 / (MUESTRAS_LUT - 1) as f32);
448    }
449    let esc = (MUESTRAS_LUT - 1) as f32;
450
451    for px in rgba.chunks_exact_mut(4) {
452        let (r, g, b) = (
453            a_lineal[px[0] as usize],
454            a_lineal[px[1] as usize],
455            a_lineal[px[2] as usize],
456        );
457        for (c, salida) in px[..3].iter_mut().enumerate() {
458            let lin = m[c * 3] * r + m[c * 3 + 1] * g + m[c * 3 + 2] * b;
459            // El recorte a gamut es por saturación de canal: un rojo P3 puro no
460            // existe en sRGB y termina en el rojo sRGB más cercano.
461            let idx = (lin.clamp(0.0, 1.0) * esc + 0.5) as usize;
462            *salida = a_byte[idx];
463        }
464    }
465    true
466}
467
468/// Convierte una imagen RGBA8 **de sRGB al espacio de `perfil`**, in situ. Es
469/// el camino de vuelta de [`convertir_a_srgb`]: lo que hace falta para
470/// **re-exportar** una imagen al espacio del archivo del que salió.
471///
472/// Devuelve `false` y no toca nada si el perfil ya es sRGB, o si su matriz no
473/// se puede invertir.
474///
475/// **No es una operación sin pérdida, y hay dos razones distintas.** La segunda
476/// vuelta de cuantización a 8 bits mete ±1 casi en cualquier valor. Y si la
477/// imagen ya pasó por [`convertir_a_srgb`] desde un espacio más ancho, lo que
478/// se recortó al entrar (un rojo P3 que en sRGB no existe) **no vuelve**: se
479/// perdió en el recorte, no acá. Editar sin pérdida un P3 pide trabajar en P3,
480/// no ir y venir — para eso está el [`Perfil`] expuesto junto a los píxeles.
481pub fn convertir_desde_srgb(rgba: &mut [u8], perfil: &Perfil) -> bool {
482    let Some(m) = matriz_desde_srgb(&perfil.a_xyz_d50) else {
483        return false;
484    };
485    if es_identidad(&m, 1.0 / 512.0) {
486        return false;
487    }
488
489    // Decodificación de sRGB: 256 entradas, una por valor de entrada.
490    let mut a_lineal = [0.0f32; 256];
491    for (i, v) in a_lineal.iter_mut().enumerate() {
492        *v = srgb_a_lineal(i as f32 / 255.0);
493    }
494    // Codificación con la curva del perfil, tabulada por la misma razón que en
495    // el otro sentido: la inversa de una paramétrica también es un `powf`.
496    let mut a_byte = vec![0u8; MUESTRAS_LUT];
497    for (i, v) in a_byte.iter_mut().enumerate() {
498        let cod = perfil.curva.desde_lineal(i as f32 / (MUESTRAS_LUT - 1) as f32);
499        *v = (cod * 255.0 + 0.5) as u8;
500    }
501    let esc = (MUESTRAS_LUT - 1) as f32;
502
503    for px in rgba.chunks_exact_mut(4) {
504        let (r, g, b) = (
505            a_lineal[px[0] as usize],
506            a_lineal[px[1] as usize],
507            a_lineal[px[2] as usize],
508        );
509        for (c, salida) in px[..3].iter_mut().enumerate() {
510            let lin = m[c * 3] * r + m[c * 3 + 1] * g + m[c * 3 + 2] * b;
511            let idx = (lin.clamp(0.0, 1.0) * esc + 0.5) as usize;
512            *salida = a_byte[idx];
513        }
514    }
515    true
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    /// Arma un ICC matrix-shaper mínimo pero **válido** con la matriz y la
523    /// curva que se le pidan. Sirve para probar el lector sin depender de
524    /// archivos del sistema.
525    fn icc_sintetico(a_xyz_d50: &[f32; 9], curva_gamma: f32) -> Vec<u8> {
526        fn s15(v: f32) -> [u8; 4] {
527            ((v * 65536.0).round() as i32).to_be_bytes()
528        }
529        let tags: [(&[u8; 4], usize); 4] = [(b"rXYZ", 0), (b"gXYZ", 1), (b"bXYZ", 2), (b"rTRC", 3)];
530        let mut cuerpo: Vec<u8> = Vec::new();
531        let mut entradas: Vec<(&[u8; 4], u32, u32)> = Vec::new();
532        let base = 128 + 4 + tags.len() * 12;
533        for (firma, col) in tags {
534            let off = base + cuerpo.len();
535            if *firma == *b"rTRC" {
536                cuerpo.extend_from_slice(b"curv");
537                cuerpo.extend_from_slice(&[0; 4]);
538                cuerpo.extend_from_slice(&1u32.to_be_bytes());
539                cuerpo.extend_from_slice(&(((curva_gamma * 256.0) as u16).to_be_bytes()));
540                cuerpo.extend_from_slice(&[0, 0]); // relleno a 4
541            } else {
542                cuerpo.extend_from_slice(b"XYZ ");
543                cuerpo.extend_from_slice(&[0; 4]);
544                for fila in 0..3 {
545                    cuerpo.extend_from_slice(&s15(a_xyz_d50[fila * 3 + col]));
546                }
547            }
548            entradas.push((firma, off as u32, (base + cuerpo.len() - off) as u32));
549        }
550        let mut icc = vec![0u8; 128];
551        icc[16..20].copy_from_slice(b"RGB ");
552        icc[20..24].copy_from_slice(b"XYZ ");
553        icc.extend_from_slice(&(tags.len() as u32).to_be_bytes());
554        for (firma, off, len) in &entradas {
555            icc.extend_from_slice(*firma);
556            icc.extend_from_slice(&off.to_be_bytes());
557            icc.extend_from_slice(&len.to_be_bytes());
558        }
559        icc.extend_from_slice(&cuerpo);
560        let n = icc.len() as u32;
561        icc[0..4].copy_from_slice(&n.to_be_bytes());
562        icc
563    }
564
565    #[test]
566    fn un_perfil_srgb_no_cambia_nada() {
567        // Éste es el test que **cruza las tres constantes publicadas**: la
568        // matriz de sRGB en D50 (la que trae adentro cualquier perfil sRGB),
569        // pasada por Bradford D50→D65 y por XYZ→sRGB, tiene que dar la
570        // identidad. Si cualquiera de las tres estuviera mal transcrita, no da.
571        let m = matriz_a_srgb(&SRGB_A_XYZ_D50);
572        assert!(
573            es_identidad(&m, 1e-3),
574            "sRGB → sRGB tendría que ser la identidad, dio {m:?}"
575        );
576
577        // Y de punta a punta: los píxeles quedan intactos y se dice que no se
578        // hizo nada, para que el caller no pague el recorrido.
579        let perfil = leer_icc(&icc_sintetico(&SRGB_A_XYZ_D50, 2.2)).expect("matrix-shaper");
580        let original = vec![10u8, 128, 240, 255, 0, 255, 7, 33];
581        let mut px = original.clone();
582        assert!(!convertir_a_srgb(&mut px, &perfil));
583        assert_eq!(px, original);
584    }
585
586    #[test]
587    fn el_lector_saca_los_numeros_que_se_le_pusieron() {
588        // El lector por separado de la colorimetría: que sepa recorrer la tabla
589        // de tags y desarmar un s15Fixed16.
590        let m = [
591            0.5, 0.25, 0.125, //
592            0.75, 0.5, 0.25, //
593            0.0625, 0.125, 0.875,
594        ];
595        let p = leer_icc(&icc_sintetico(&m, 1.8)).expect("matrix-shaper");
596        for (leido, puesto) in p.a_xyz_d50.iter().zip(m.iter()) {
597            assert!((leido - puesto).abs() < 1e-4, "{:?} vs {m:?}", p.a_xyz_d50);
598        }
599        // La gamma de una sola muestra viaja en u8Fixed8, así que 1,8 no es
600        // representable: vuelve 1,796875. No es pérdida nuestra, es el formato.
601        let Curva::Gamma(g) = p.curva else { panic!("se esperaba una gamma, vino {:?}", p.curva) };
602        assert!((g - 1.8).abs() < 1.0 / 256.0, "gamma leída {g}");
603    }
604
605    #[test]
606    fn un_perfil_de_gamut_ancho_desatura_al_convertir() {
607        // Display P3 en D50, tal como lo trae el perfil de Apple. Lo que se
608        // afirma acá no es el valor exacto sino la **dirección**: el rojo puro
609        // de P3 es más rojo que el de sRGB, así que al llevarlo a sRGB satura
610        // el rojo y **tiene que meter verde y azul negativos** — que el recorte
611        // deja en 0. Y un gris se mantiene gris, porque la conversión no toca
612        // el punto blanco.
613        let p3_d50 = [
614            0.5151, 0.2920, 0.1571, //
615            0.2412, 0.6922, 0.0666, //
616            -0.0011, 0.0419, 0.7841,
617        ];
618        let m = matriz_a_srgb(&p3_d50);
619        assert!(m[0] > 1.0, "el rojo P3 debería exceder el rojo sRGB: {m:?}");
620        assert!(m[1] < 0.0 && m[2] < 0.02, "y compensar con verde/azul: {m:?}");
621
622        let perfil = Perfil { a_xyz_d50: p3_d50, curva: Curva::Gamma(2.2) };
623        // Rojo puro, blanco, gris medio.
624        let mut px = vec![255u8, 0, 0, 255, 255, 255, 255, 255, 128, 128, 128, 255];
625        assert!(convertir_a_srgb(&mut px, &perfil));
626        assert_eq!(&px[0..4], &[255, 0, 0, 255], "rojo P3 → rojo sRGB saturado");
627        for (i, c) in px[4..7].iter().enumerate() {
628            assert!(*c >= 253, "el blanco tiene que seguir blanco (canal {i} = {c})");
629        }
630        let gris = &px[8..11];
631        assert!(
632            gris.iter().all(|c| (*c as i32 - 128).abs() <= 2),
633            "un gris no debería teñirse: {gris:?}"
634        );
635    }
636
637    #[test]
638    fn lo_que_no_se_entiende_se_deja_en_paz() {
639        // Un perfil CMYK de imprenta no se arregla con una matriz de 3×3, y un
640        // ICC truncado no se arregla con nada. En los dos casos la respuesta es
641        // no tocar la imagen.
642        let mut cmyk = icc_sintetico(&SRGB_A_XYZ_D50, 2.2);
643        cmyk[16..20].copy_from_slice(b"CMYK");
644        assert!(leer_icc(&cmyk).is_none());
645        assert!(leer_icc(&[]).is_none());
646        assert!(leer_icc(&[0u8; 200]).is_none());
647
648        // Y un tag con offset fuera del archivo no puede hacer pánico.
649        let mut roto = icc_sintetico(&SRGB_A_XYZ_D50, 2.2);
650        let n = roto.len();
651        roto[136..140].copy_from_slice(&(n as u32 + 9999).to_be_bytes());
652        assert!(leer_icc(&roto).is_none());
653    }
654
655    #[test]
656    fn la_curva_parametrica_de_srgb_es_la_curva_de_srgb() {
657        // Tipo 3 con los parámetros canónicos de sRGB. La prueba es contra la
658        // fórmula publicada, no contra mi implementación: si el tramo recto de
659        // abajo o el corte en `d` estuvieran mal, los valores chicos fallan.
660        let srgb = Curva::Parametrica {
661            tipo: 3,
662            p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0],
663        };
664        for v in [0.0f32, 0.01, 0.04, 0.05, 0.2, 0.5, 0.8, 1.0] {
665            let esperado = if v <= 0.04045 {
666                v / 12.92
667            } else {
668                ((v + 0.055) / 1.055).powf(2.4)
669            };
670            let dio = srgb.a_lineal(v);
671            assert!((dio - esperado).abs() < 1e-5, "en {v}: dio {dio}, esperaba {esperado}");
672        }
673
674        // Y la razón por la que esto se implementó: aproximar por el exponente
675        // —lo que hacía antes— erra feo en el medio del rango. Con L* (tipo 3,
676        // g=3) un gris medio se iba unos 20 valores de 8 bits.
677        let l_estrella = Curva::Parametrica {
678            tipo: 3,
679            p: [3.0, 1.0 / 1.16, 0.16 / 1.16, 1.0 / 9.033, 0.08, 0.0, 0.0],
680        };
681        let bien = l_estrella.a_lineal(128.0 / 255.0);
682        let atajo = Curva::Gamma(3.0).a_lineal(128.0 / 255.0);
683        assert!(bien > atajo * 1.4, "el atajo por el exponente daba {atajo}, la curva da {bien}");
684    }
685
686    #[test]
687    fn la_curva_de_tabla_interpola_entre_muestras() {
688        let c = Curva::Tabla(vec![0, 16384, 65535]);
689        assert!((c.a_lineal(0.0) - 0.0).abs() < 1e-4);
690        assert!((c.a_lineal(0.5) - 0.25).abs() < 1e-3);
691        assert!((c.a_lineal(1.0) - 1.0).abs() < 1e-4);
692        // A mitad de camino entre la primera y la segunda muestra.
693        assert!((c.a_lineal(0.25) - 0.125).abs() < 1e-3);
694        // Una tabla que no describe nada no puede romper.
695        assert_eq!(Curva::Tabla(vec![]).a_lineal(0.7), 0.7);
696    }
697
698    // ── El camino de vuelta ──────────────────────────────────────────────
699
700    #[test]
701    fn cada_curva_deshace_lo_que_hizo() {
702        // `desde_lineal` es la inversa de `a_lineal`, y la única forma honesta
703        // de probarlo es componerlas: no hay ninguna constante que creerle.
704        // Van las cinco formas que el crate sabe leer, no una de muestra.
705        let curvas = [
706            Curva::Lineal,
707            Curva::Gamma(2.2),
708            Curva::Gamma(1.8),
709            // Una tabla no lineal, como la que trae casi cualquier perfil real.
710            Curva::Tabla((0..=64).map(|i| ((i as f32 / 64.0).powf(2.2) * 65535.0) as u16).collect()),
711            // sRGB (tipo 3) y L* (tipo 3 con g=3): las dos con tramo recto.
712            Curva::Parametrica {
713                tipo: 3,
714                p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0],
715            },
716            Curva::Parametrica {
717                tipo: 3,
718                p: [3.0, 1.0 / 1.16, 0.16 / 1.16, 1.0 / 9.033, 0.08, 0.0, 0.0],
719            },
720            Curva::Parametrica { tipo: 0, p: [2.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] },
721            Curva::Parametrica { tipo: 1, p: [2.4, 0.9, 0.1, 0.0, 0.0, 0.0, 0.0] },
722            Curva::Parametrica { tipo: 2, p: [2.4, 0.9, 0.05, 0.02, 0.0, 0.0, 0.0] },
723            Curva::Parametrica {
724                tipo: 4,
725                p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.01, 0.005],
726            },
727        ];
728        for c in &curvas {
729            for i in 0..=32 {
730                let v = i as f32 / 32.0;
731                let ida = c.a_lineal(v);
732                // Un tramo fuera de 0..=1 (la tipo 2 con `c` desplaza el cero)
733                // no se puede exigir que vuelva: el clamp ya lo perdió.
734                if !(0.0..=1.0).contains(&ida) {
735                    continue;
736                }
737                let vuelta = c.desde_lineal(ida);
738                assert!(
739                    (vuelta - v).abs() < 0.01,
740                    "{c:?}: {v} → {ida} → {vuelta}, no volvió"
741                );
742            }
743        }
744    }
745
746    #[test]
747    fn la_matriz_de_vuelta_deshace_la_de_ida() {
748        // P3 en D50. Componer la matriz de ida con la de vuelta tiene que dar
749        // la identidad — y ésta es la prueba de que `invertir` invierte, sin
750        // depender de ningún valor publicado de P3.
751        let p3_d50 = [
752            0.5151, 0.2920, 0.1571, //
753            0.2412, 0.6922, 0.0666, //
754            -0.0011, 0.0419, 0.7841,
755        ];
756        let ida = matriz_a_srgb(&p3_d50);
757        let vuelta = matriz_desde_srgb(&p3_d50).expect("P3 no es singular");
758        assert!(es_identidad(&multiplicar(&ida, &vuelta), 1e-4));
759        assert!(es_identidad(&multiplicar(&vuelta, &ida), 1e-4));
760
761        // Una matriz con dos primarios iguales no tiene inversa, y eso se dice
762        // en vez de devolver infinitos.
763        let degenerada = [1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0];
764        assert!(invertir(&degenerada).is_none());
765    }
766
767    #[test]
768    fn una_imagen_srgb_va_a_p3_y_vuelve_igual() {
769        // El lazo que justifica exponer el perfil: tullpu edita en sRGB y
770        // re-exporta al espacio del archivo. sRGB **cabe entero** dentro de P3,
771        // así que esta ida y vuelta no recorta nada y lo único que queda es la
772        // cuantización a 8 bits.
773        let p3 = Perfil {
774            a_xyz_d50: [
775                0.5151, 0.2920, 0.1571, //
776                0.2412, 0.6922, 0.0666, //
777                -0.0011, 0.0419, 0.7841,
778            ],
779            curva: Curva::Gamma(2.2),
780        };
781        let original: Vec<u8> = vec![
782            255, 0, 0, 255, // rojo sRGB puro
783            0, 255, 0, 255, // verde
784            0, 0, 255, 255, // azul
785            128, 128, 128, 255, // gris medio
786            200, 120, 40, 255, // un naranja cualquiera
787            12, 34, 56, 255, // y una sombra
788        ];
789        let mut px = original.clone();
790        assert!(convertir_desde_srgb(&mut px, &p3), "sRGB → P3 tiene que hacer algo");
791        assert_ne!(px, original, "si no cambió nada, no se convirtió");
792
793        assert!(convertir_a_srgb(&mut px, &p3), "y P3 → sRGB también");
794        for (i, (dio, esperaba)) in px.iter().zip(original.iter()).enumerate() {
795            let d = (*dio as i32 - *esperaba as i32).abs();
796            assert!(d <= 3, "canal {i}: volvió {dio}, había salido de {esperaba}");
797        }
798    }
799
800    #[test]
801    fn volver_a_srgb_desde_srgb_no_recorre_la_imagen() {
802        // La misma guarda que en el otro sentido: multiplicar ocho millones de
803        // píxeles por la identidad es trabajo que no se paga.
804        let perfil = leer_icc(&icc_sintetico(&SRGB_A_XYZ_D50, 2.2)).expect("matrix-shaper");
805        let original = vec![10u8, 128, 240, 255, 0, 255, 7, 33];
806        let mut px = original.clone();
807        assert!(!convertir_desde_srgb(&mut px, &perfil));
808        assert_eq!(px, original);
809    }
810}