tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
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/// Arma un ICC matrix-shaper mínimo pero **válido** con la matriz y la curva
519/// que se le pidan.
520///
521/// Es un **fixture de test**, y está publicado a propósito: probar que una app
522/// no pierde el perfil de color de un archivo pide un archivo con perfil, y
523/// sin esto cada crate de afuera se copiaba las cuarenta líneas de armado de
524/// tags — que se desincronizan del lector de este mismo módulo sin que nada
525/// avise. Lo usan los tests de acá y los de `tullpu-module`.
526///
527/// No sirve para escribir un perfil de verdad: sólo lleva `rXYZ`/`gXYZ`/`bXYZ`
528/// y una `rTRC` de gamma simple, nada de descripción ni punto blanco.
529pub fn icc_sintetico(a_xyz_d50: &[f32; 9], curva_gamma: f32) -> Vec<u8> {
530 fn s15(v: f32) -> [u8; 4] {
531 ((v * 65536.0).round() as i32).to_be_bytes()
532 }
533 let tags: [(&[u8; 4], usize); 4] = [(b"rXYZ", 0), (b"gXYZ", 1), (b"bXYZ", 2), (b"rTRC", 3)];
534 let mut cuerpo: Vec<u8> = Vec::new();
535 let mut entradas: Vec<(&[u8; 4], u32, u32)> = Vec::new();
536 let base = 128 + 4 + tags.len() * 12;
537 for (firma, col) in tags {
538 let off = base + cuerpo.len();
539 if *firma == *b"rTRC" {
540 cuerpo.extend_from_slice(b"curv");
541 cuerpo.extend_from_slice(&[0; 4]);
542 cuerpo.extend_from_slice(&1u32.to_be_bytes());
543 cuerpo.extend_from_slice(&(((curva_gamma * 256.0) as u16).to_be_bytes()));
544 cuerpo.extend_from_slice(&[0, 0]); // relleno a 4
545 } else {
546 cuerpo.extend_from_slice(b"XYZ ");
547 cuerpo.extend_from_slice(&[0; 4]);
548 for fila in 0..3 {
549 cuerpo.extend_from_slice(&s15(a_xyz_d50[fila * 3 + col]));
550 }
551 }
552 entradas.push((firma, off as u32, (base + cuerpo.len() - off) as u32));
553 }
554 let mut icc = vec![0u8; 128];
555 icc[16..20].copy_from_slice(b"RGB ");
556 icc[20..24].copy_from_slice(b"XYZ ");
557 icc.extend_from_slice(&(tags.len() as u32).to_be_bytes());
558 for (firma, off, len) in &entradas {
559 icc.extend_from_slice(*firma);
560 icc.extend_from_slice(&off.to_be_bytes());
561 icc.extend_from_slice(&len.to_be_bytes());
562 }
563 icc.extend_from_slice(&cuerpo);
564 let n = icc.len() as u32;
565 icc[0..4].copy_from_slice(&n.to_be_bytes());
566 icc
567}
568
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn un_perfil_srgb_no_cambia_nada() {
576 // Éste es el test que **cruza las tres constantes publicadas**: la
577 // matriz de sRGB en D50 (la que trae adentro cualquier perfil sRGB),
578 // pasada por Bradford D50→D65 y por XYZ→sRGB, tiene que dar la
579 // identidad. Si cualquiera de las tres estuviera mal transcrita, no da.
580 let m = matriz_a_srgb(&SRGB_A_XYZ_D50);
581 assert!(
582 es_identidad(&m, 1e-3),
583 "sRGB → sRGB tendría que ser la identidad, dio {m:?}"
584 );
585
586 // Y de punta a punta: los píxeles quedan intactos y se dice que no se
587 // hizo nada, para que el caller no pague el recorrido.
588 let perfil = leer_icc(&icc_sintetico(&SRGB_A_XYZ_D50, 2.2)).expect("matrix-shaper");
589 let original = vec![10u8, 128, 240, 255, 0, 255, 7, 33];
590 let mut px = original.clone();
591 assert!(!convertir_a_srgb(&mut px, &perfil));
592 assert_eq!(px, original);
593 }
594
595 #[test]
596 fn el_lector_saca_los_numeros_que_se_le_pusieron() {
597 // El lector por separado de la colorimetría: que sepa recorrer la tabla
598 // de tags y desarmar un s15Fixed16.
599 let m = [
600 0.5, 0.25, 0.125, //
601 0.75, 0.5, 0.25, //
602 0.0625, 0.125, 0.875,
603 ];
604 let p = leer_icc(&icc_sintetico(&m, 1.8)).expect("matrix-shaper");
605 for (leido, puesto) in p.a_xyz_d50.iter().zip(m.iter()) {
606 assert!((leido - puesto).abs() < 1e-4, "{:?} vs {m:?}", p.a_xyz_d50);
607 }
608 // La gamma de una sola muestra viaja en u8Fixed8, así que 1,8 no es
609 // representable: vuelve 1,796875. No es pérdida nuestra, es el formato.
610 let Curva::Gamma(g) = p.curva else { panic!("se esperaba una gamma, vino {:?}", p.curva) };
611 assert!((g - 1.8).abs() < 1.0 / 256.0, "gamma leída {g}");
612 }
613
614 #[test]
615 fn un_perfil_de_gamut_ancho_desatura_al_convertir() {
616 // Display P3 en D50, tal como lo trae el perfil de Apple. Lo que se
617 // afirma acá no es el valor exacto sino la **dirección**: el rojo puro
618 // de P3 es más rojo que el de sRGB, así que al llevarlo a sRGB satura
619 // el rojo y **tiene que meter verde y azul negativos** — que el recorte
620 // deja en 0. Y un gris se mantiene gris, porque la conversión no toca
621 // el punto blanco.
622 let p3_d50 = [
623 0.5151, 0.2920, 0.1571, //
624 0.2412, 0.6922, 0.0666, //
625 -0.0011, 0.0419, 0.7841,
626 ];
627 let m = matriz_a_srgb(&p3_d50);
628 assert!(m[0] > 1.0, "el rojo P3 debería exceder el rojo sRGB: {m:?}");
629 assert!(m[1] < 0.0 && m[2] < 0.02, "y compensar con verde/azul: {m:?}");
630
631 let perfil = Perfil { a_xyz_d50: p3_d50, curva: Curva::Gamma(2.2) };
632 // Rojo puro, blanco, gris medio.
633 let mut px = vec![255u8, 0, 0, 255, 255, 255, 255, 255, 128, 128, 128, 255];
634 assert!(convertir_a_srgb(&mut px, &perfil));
635 assert_eq!(&px[0..4], &[255, 0, 0, 255], "rojo P3 → rojo sRGB saturado");
636 for (i, c) in px[4..7].iter().enumerate() {
637 assert!(*c >= 253, "el blanco tiene que seguir blanco (canal {i} = {c})");
638 }
639 let gris = &px[8..11];
640 assert!(
641 gris.iter().all(|c| (*c as i32 - 128).abs() <= 2),
642 "un gris no debería teñirse: {gris:?}"
643 );
644 }
645
646 #[test]
647 fn lo_que_no_se_entiende_se_deja_en_paz() {
648 // Un perfil CMYK de imprenta no se arregla con una matriz de 3×3, y un
649 // ICC truncado no se arregla con nada. En los dos casos la respuesta es
650 // no tocar la imagen.
651 let mut cmyk = icc_sintetico(&SRGB_A_XYZ_D50, 2.2);
652 cmyk[16..20].copy_from_slice(b"CMYK");
653 assert!(leer_icc(&cmyk).is_none());
654 assert!(leer_icc(&[]).is_none());
655 assert!(leer_icc(&[0u8; 200]).is_none());
656
657 // Y un tag con offset fuera del archivo no puede hacer pánico.
658 let mut roto = icc_sintetico(&SRGB_A_XYZ_D50, 2.2);
659 let n = roto.len();
660 roto[136..140].copy_from_slice(&(n as u32 + 9999).to_be_bytes());
661 assert!(leer_icc(&roto).is_none());
662 }
663
664 #[test]
665 fn la_curva_parametrica_de_srgb_es_la_curva_de_srgb() {
666 // Tipo 3 con los parámetros canónicos de sRGB. La prueba es contra la
667 // fórmula publicada, no contra mi implementación: si el tramo recto de
668 // abajo o el corte en `d` estuvieran mal, los valores chicos fallan.
669 let srgb = Curva::Parametrica {
670 tipo: 3,
671 p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0],
672 };
673 for v in [0.0f32, 0.01, 0.04, 0.05, 0.2, 0.5, 0.8, 1.0] {
674 let esperado = if v <= 0.04045 {
675 v / 12.92
676 } else {
677 ((v + 0.055) / 1.055).powf(2.4)
678 };
679 let dio = srgb.a_lineal(v);
680 assert!((dio - esperado).abs() < 1e-5, "en {v}: dio {dio}, esperaba {esperado}");
681 }
682
683 // Y la razón por la que esto se implementó: aproximar por el exponente
684 // —lo que hacía antes— erra feo en el medio del rango. Con L* (tipo 3,
685 // g=3) un gris medio se iba unos 20 valores de 8 bits.
686 let l_estrella = Curva::Parametrica {
687 tipo: 3,
688 p: [3.0, 1.0 / 1.16, 0.16 / 1.16, 1.0 / 9.033, 0.08, 0.0, 0.0],
689 };
690 let bien = l_estrella.a_lineal(128.0 / 255.0);
691 let atajo = Curva::Gamma(3.0).a_lineal(128.0 / 255.0);
692 assert!(bien > atajo * 1.4, "el atajo por el exponente daba {atajo}, la curva da {bien}");
693 }
694
695 #[test]
696 fn la_curva_de_tabla_interpola_entre_muestras() {
697 let c = Curva::Tabla(vec![0, 16384, 65535]);
698 assert!((c.a_lineal(0.0) - 0.0).abs() < 1e-4);
699 assert!((c.a_lineal(0.5) - 0.25).abs() < 1e-3);
700 assert!((c.a_lineal(1.0) - 1.0).abs() < 1e-4);
701 // A mitad de camino entre la primera y la segunda muestra.
702 assert!((c.a_lineal(0.25) - 0.125).abs() < 1e-3);
703 // Una tabla que no describe nada no puede romper.
704 assert_eq!(Curva::Tabla(vec![]).a_lineal(0.7), 0.7);
705 }
706
707 // ── El camino de vuelta ──────────────────────────────────────────────
708
709 #[test]
710 fn cada_curva_deshace_lo_que_hizo() {
711 // `desde_lineal` es la inversa de `a_lineal`, y la única forma honesta
712 // de probarlo es componerlas: no hay ninguna constante que creerle.
713 // Van las cinco formas que el crate sabe leer, no una de muestra.
714 let curvas = [
715 Curva::Lineal,
716 Curva::Gamma(2.2),
717 Curva::Gamma(1.8),
718 // Una tabla no lineal, como la que trae casi cualquier perfil real.
719 Curva::Tabla((0..=64).map(|i| ((i as f32 / 64.0).powf(2.2) * 65535.0) as u16).collect()),
720 // sRGB (tipo 3) y L* (tipo 3 con g=3): las dos con tramo recto.
721 Curva::Parametrica {
722 tipo: 3,
723 p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0],
724 },
725 Curva::Parametrica {
726 tipo: 3,
727 p: [3.0, 1.0 / 1.16, 0.16 / 1.16, 1.0 / 9.033, 0.08, 0.0, 0.0],
728 },
729 Curva::Parametrica { tipo: 0, p: [2.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] },
730 Curva::Parametrica { tipo: 1, p: [2.4, 0.9, 0.1, 0.0, 0.0, 0.0, 0.0] },
731 Curva::Parametrica { tipo: 2, p: [2.4, 0.9, 0.05, 0.02, 0.0, 0.0, 0.0] },
732 Curva::Parametrica {
733 tipo: 4,
734 p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.01, 0.005],
735 },
736 ];
737 for c in &curvas {
738 for i in 0..=32 {
739 let v = i as f32 / 32.0;
740 let ida = c.a_lineal(v);
741 // Un tramo fuera de 0..=1 (la tipo 2 con `c` desplaza el cero)
742 // no se puede exigir que vuelva: el clamp ya lo perdió.
743 if !(0.0..=1.0).contains(&ida) {
744 continue;
745 }
746 let vuelta = c.desde_lineal(ida);
747 assert!(
748 (vuelta - v).abs() < 0.01,
749 "{c:?}: {v} → {ida} → {vuelta}, no volvió"
750 );
751 }
752 }
753 }
754
755 #[test]
756 fn la_matriz_de_vuelta_deshace_la_de_ida() {
757 // P3 en D50. Componer la matriz de ida con la de vuelta tiene que dar
758 // la identidad — y ésta es la prueba de que `invertir` invierte, sin
759 // depender de ningún valor publicado de P3.
760 let p3_d50 = [
761 0.5151, 0.2920, 0.1571, //
762 0.2412, 0.6922, 0.0666, //
763 -0.0011, 0.0419, 0.7841,
764 ];
765 let ida = matriz_a_srgb(&p3_d50);
766 let vuelta = matriz_desde_srgb(&p3_d50).expect("P3 no es singular");
767 assert!(es_identidad(&multiplicar(&ida, &vuelta), 1e-4));
768 assert!(es_identidad(&multiplicar(&vuelta, &ida), 1e-4));
769
770 // Una matriz con dos primarios iguales no tiene inversa, y eso se dice
771 // en vez de devolver infinitos.
772 let degenerada = [1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0];
773 assert!(invertir(°enerada).is_none());
774 }
775
776 #[test]
777 fn una_imagen_srgb_va_a_p3_y_vuelve_igual() {
778 // El lazo que justifica exponer el perfil: tullpu edita en sRGB y
779 // re-exporta al espacio del archivo. sRGB **cabe entero** dentro de P3,
780 // así que esta ida y vuelta no recorta nada y lo único que queda es la
781 // cuantización a 8 bits.
782 let p3 = Perfil {
783 a_xyz_d50: [
784 0.5151, 0.2920, 0.1571, //
785 0.2412, 0.6922, 0.0666, //
786 -0.0011, 0.0419, 0.7841,
787 ],
788 curva: Curva::Gamma(2.2),
789 };
790 let original: Vec<u8> = vec![
791 255, 0, 0, 255, // rojo sRGB puro
792 0, 255, 0, 255, // verde
793 0, 0, 255, 255, // azul
794 128, 128, 128, 255, // gris medio
795 200, 120, 40, 255, // un naranja cualquiera
796 12, 34, 56, 255, // y una sombra
797 ];
798 let mut px = original.clone();
799 assert!(convertir_desde_srgb(&mut px, &p3), "sRGB → P3 tiene que hacer algo");
800 assert_ne!(px, original, "si no cambió nada, no se convirtió");
801
802 assert!(convertir_a_srgb(&mut px, &p3), "y P3 → sRGB también");
803 for (i, (dio, esperaba)) in px.iter().zip(original.iter()).enumerate() {
804 let d = (*dio as i32 - *esperaba as i32).abs();
805 assert!(d <= 3, "canal {i}: volvió {dio}, había salido de {esperaba}");
806 }
807 }
808
809 #[test]
810 fn volver_a_srgb_desde_srgb_no_recorre_la_imagen() {
811 // La misma guarda que en el otro sentido: multiplicar ocho millones de
812 // píxeles por la identidad es trabajo que no se paga.
813 let perfil = leer_icc(&icc_sintetico(&SRGB_A_XYZ_D50, 2.2)).expect("matrix-shaper");
814 let original = vec![10u8, 128, 240, 255, 0, 255, 7, 33];
815 let mut px = original.clone();
816 assert!(!convertir_desde_srgb(&mut px, &perfil));
817 assert_eq!(px, original);
818 }
819}