tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! El lector de ICC contra **perfiles reales**, no sintéticos.
2//!
3//! Los tests unitarios de `perfil.rs` arman sus propios ICC, así que prueban la
4//! aritmética y el recorrido de tags pero no que un archivo escrito por otra
5//! gente entre. Esto lo cubre con los perfiles que trae `colord`, que están en
6//! casi cualquier Linux de escritorio.
7//!
8//! **Se saltea solo** si la máquina no los tiene: el test no puede exigir que
9//! haya un paquete instalado, pero tampoco tiene sentido tirar la única
10//! verificación contra archivos ajenos que hay.
11//!
12//! Certificado por texto (§8): son valores de píxel, no una pantalla.
13
14use llimphi_image::perfil::{
15 convertir_a_srgb, convertir_desde_srgb, es_identidad, leer_icc, matriz_a_srgb,
16};
17
18const DIR: &str = "/usr/share/color/icc/colord";
19
20fn cargar(nombre: &str) -> Option<Vec<u8>> {
21 std::fs::read(std::path::Path::new(DIR).join(nombre)).ok()
22}
23
24/// Convierte rojo puro, verde puro y un gris medio.
25fn convertir_muestras(icc: &[u8]) -> Option<(bool, [u8; 3], [u8; 3], [u8; 3])> {
26 let p = leer_icc(icc)?;
27 let mut px = vec![255u8, 0, 0, 255, 0, 255, 0, 255, 128, 128, 128, 255];
28 let toco = convertir_a_srgb(&mut px, &p);
29 Some((
30 toco,
31 [px[0], px[1], px[2]],
32 [px[4], px[5], px[6]],
33 [px[8], px[9], px[10]],
34 ))
35}
36
37#[test]
38fn un_perfil_srgb_de_verdad_no_toca_la_imagen() {
39 let Some(icc) = cargar("sRGB.icc") else { return };
40 let p = leer_icc(&icc).expect("sRGB.icc es matrix-shaper");
41 assert!(
42 es_identidad(&matriz_a_srgb(&p.a_xyz_d50), 1.0 / 512.0),
43 "la matriz de un sRGB real tendría que dar la identidad"
44 );
45 let (toco, r, g, gris) = convertir_muestras(&icc).unwrap();
46 assert!(!toco, "no hay nada que convertir y no se debería recorrer la imagen");
47 assert_eq!((r, g, gris), ([255, 0, 0], [0, 255, 0], [128, 128, 128]));
48
49 // Rec.709 comparte primarios con sRGB: también identidad. Si diera otra
50 // cosa, el que estaría mal sería el lector.
51 if let Some(icc) = cargar("Rec709.icc") {
52 let p = leer_icc(&icc).expect("Rec709.icc es matrix-shaper");
53 assert!(es_identidad(&matriz_a_srgb(&p.a_xyz_d50), 1.0 / 512.0));
54 }
55}
56
57#[test]
58fn el_perfil_que_intercambia_rojo_y_verde_intercambia_rojo_y_verde() {
59 // El mejor caso de prueba que se puede pedir: la respuesta correcta no
60 // depende de creerle a ninguna constante de colorimetría.
61 let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return };
62 let (toco, r, g, gris) = convertir_muestras(&icc).unwrap();
63 assert!(toco);
64 assert!(r[1] > 250 && r[0] < 8, "el rojo tendría que salir verde, salió {r:?}");
65 assert!(g[0] > 250 && g[1] < 8, "el verde tendría que salir rojo, salió {g:?}");
66 assert_eq!(gris, [128, 128, 128], "un gris no se altera al permutar canales");
67}
68
69/// Mete un perfil ICC en un JPEG ya codificado, como marcador APP2.
70///
71/// JPEG guarda el ICC **sin comprimir** (a diferencia del `iCCP` de PNG, que
72/// va en deflate), así que inyectarlo es concatenar bytes y no hace falta
73/// ninguna dependencia nueva para armar el caso de prueba.
74fn jpeg_con_icc(jpeg: &[u8], icc: &[u8]) -> Vec<u8> {
75 let mut out = Vec::with_capacity(jpeg.len() + icc.len() + 32);
76 out.extend_from_slice(&jpeg[..2]); // SOI
77 let largo = 2 + 12 + 2 + icc.len();
78 out.extend_from_slice(&[0xFF, 0xE2]);
79 out.extend_from_slice(&(largo as u16).to_be_bytes());
80 out.extend_from_slice(b"ICC_PROFILE\0");
81 out.extend_from_slice(&[1, 1]); // secuencia 1 de 1
82 out.extend_from_slice(icc);
83 out.extend_from_slice(&jpeg[2..]);
84 out
85}
86
87#[test]
88fn decodificar_un_archivo_con_perfil_lo_lleva_a_srgb() {
89 // El lazo entero: bytes de archivo → `decode_bytes` → píxeles convertidos.
90 // Los otros tests prueban el lector y la aritmética por separado; éste es
91 // el único que verifica que el decodificador **le pregunta** el perfil al
92 // decoder en vez de tirarlo, que es lo que pasaba hasta 2026-08-05.
93 let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return };
94 assert!(icc.len() < 65_000, "el perfil no entra en un solo APP2");
95
96 // Un JPEG chico de rojo puro. Se codifica con calidad máxima para que el
97 // subsampling de croma no se coma lo que se está midiendo.
98 let rojo = image::RgbImage::from_pixel(16, 16, image::Rgb([255, 0, 0]));
99 let mut jpeg = Vec::new();
100 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100)
101 .encode_image(&image::DynamicImage::ImageRgb8(rojo))
102 .expect("encode");
103
104 let sin_perfil = llimphi_image::decode_bytes(&jpeg).expect("decode");
105 let con_perfil =
106 llimphi_image::decode_bytes(&jpeg_con_icc(&jpeg, &icc)).expect("decode con icc");
107
108 let centro = |img: &llimphi_image::Image| -> [u8; 3] {
109 let d: &[u8] = img.image.data.as_ref();
110 let o = ((8 * 16 + 8) * 4) as usize;
111 [d[o], d[o + 1], d[o + 2]]
112 };
113 let a = centro(&sin_perfil);
114 let b = centro(&con_perfil);
115 assert!(a[0] > 240 && a[1] < 20, "sin perfil el rojo queda rojo, dio {a:?}");
116 assert!(
117 b[1] > 240 && b[0] < 20,
118 "con el perfil que permuta canales el rojo tiene que salir verde, dio {b:?}"
119 );
120}
121
122#[test]
123fn ningun_perfil_del_sistema_tiñe_un_gris() {
124 // La invariante que vale para **toda** conversión colorimétrica relativa
125 // entre dos espacios RGB: el blanco va al blanco, así que los neutros
126 // quedan neutros. El valor puede cambiar —un perfil de gamma 1,8 aclara el
127 // gris medio— pero los tres canales tienen que seguir siendo iguales.
128 //
129 // Es la prueba que agarró el atajo de las curvas paramétricas: aproximarlas
130 // por su exponente dejaba el gris de `SwappedRedAndGreen` en 121 y el de
131 // `ECI-RGBv2` en 100, cuando la curva bien evaluada da 128 y 119.
132 let Ok(dir) = std::fs::read_dir(DIR) else { return };
133 let (mut leidos, mut salteados) = (0, 0);
134 for e in dir.flatten() {
135 let ruta = e.path();
136 if ruta.extension().and_then(|s| s.to_str()) != Some("icc") {
137 continue;
138 }
139 let Ok(bytes) = std::fs::read(&ruta) else { continue };
140 let nombre = ruta.file_name().unwrap().to_string_lossy().into_owned();
141 match convertir_muestras(&bytes) {
142 Some((_, _, _, gris)) => {
143 leidos += 1;
144 assert!(
145 gris[0] == gris[1] && gris[1] == gris[2],
146 "{nombre} tiñó un gris: {gris:?}"
147 );
148 }
149 // Los de imprenta (FOGRA, SWOP, GRACoL…) son CMYK con LUT: no se
150 // arreglan con una matriz de 3×3 y se dejan pasar sin tocar.
151 None => salteados += 1,
152 }
153 }
154 // Guarda del arnés: si el directorio existiera vacío, el bucle no probaría
155 // nada y el test pasaría igual.
156 assert!(leidos >= 10, "sólo {leidos} perfiles leídos; el arnés no está probando");
157 assert!(salteados >= 1, "ninguno salteado: los CMYK deberían caer afuera");
158}
159
160// ── El camino de vuelta, contra los mismos archivos ajenos ───────────────
161
162#[test]
163fn abrir_y_volver_a_guardar_devuelve_los_bytes_que_habia() {
164 // El lazo completo de un editor: `tullpu` abre un archivo con perfil, los
165 // píxeles le llegan en sRGB, y al guardar los devuelve al espacio de origen
166 // para no degradar el archivo del usuario por el solo hecho de haberlo
167 // abierto.
168 //
169 // Con `SwappedRedAndGreen` el ida y vuelta es verificable a ojo cerrado y
170 // sin creerle a ninguna constante: el rojo del archivo se abre como verde y
171 // al guardar tiene que volver a ser rojo.
172 let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return };
173
174 let rojo = image::RgbImage::from_pixel(8, 8, image::Rgb([230, 40, 60]));
175 let mut jpeg = Vec::new();
176 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100)
177 .encode_image(&image::DynamicImage::ImageRgb8(rojo))
178 .expect("encode");
179
180 let d = llimphi_image::decode_bytes_con_origen(&jpeg_con_icc(&jpeg, &icc))
181 .expect("decode con icc");
182 assert!(d.origen.is_some(), "el perfil del archivo tiene que llegar al caller");
183 assert!(d.convertida, "y con este perfil hay conversión de verdad");
184
185 let mut px: Vec<u8> = d.imagen.image.data.as_ref().to_vec();
186 let abierto = [px[0], px[1], px[2]];
187 assert!(
188 abierto[1] > abierto[0],
189 "abierto tendría que venir con los canales permutados, dio {abierto:?}"
190 );
191
192 assert!(d.devolver_al_origen(&mut px), "hay a dónde volver");
193 let guardado = [px[0], px[1], px[2]];
194 // El JPEG a calidad 100 no es exacto: la tolerancia cubre eso más las dos
195 // cuantizaciones a 8 bits, no un error de la conversión.
196 for (i, (dio, habia)) in guardado.iter().zip([230u8, 40, 60].iter()).enumerate() {
197 let delta = (*dio as i32 - *habia as i32).abs();
198 assert!(delta <= 4, "canal {i}: se guardó {dio} donde el archivo tenía {habia}");
199 }
200}
201
202#[test]
203fn ningun_perfil_del_sistema_corre_un_gris_al_ir_y_volver() {
204 // La invariante del párrafo anterior, pero **sobre los 38 archivos**, no
205 // sobre uno elegido. Se usa un gris porque es el único color que ningún
206 // espacio RGB puede recortar: el blanco va al blanco en toda conversión
207 // colorimétrica relativa, así que la ida y vuelta no pierde por gamut y lo
208 // que quede es error de la aritmética.
209 //
210 // Es la prueba que le pone dientes a `desde_lineal`: una inversa de curva
211 // mal despejada corre el gris decenas de valores, y ninguna cuenta de
212 // matrices lo taparía.
213 let Ok(dir) = std::fs::read_dir(DIR) else { return };
214 let mut probados = 0;
215 for e in dir.flatten() {
216 let ruta = e.path();
217 if ruta.extension().and_then(|s| s.to_str()) != Some("icc") {
218 continue;
219 }
220 let Ok(bytes) = std::fs::read(&ruta) else { continue };
221 let Some(p) = leer_icc(&bytes) else { continue };
222 let nombre = ruta.file_name().unwrap().to_string_lossy().into_owned();
223
224 // Los dos primeros son **sombras**, y están puestos a propósito: el
225 // tramo recto que las paramétricas de tipo 3 tienen abajo sólo se pisa
226 // ahí. Con grises de 32 para arriba, una inversa que ignorara ese tramo
227 // —el atajo obvio— pasaría el test sin que nadie se enterara.
228 let original: Vec<u8> = vec![
229 4, 4, 4, 255, //
230 9, 9, 9, 255, //
231 32, 32, 32, 255, //
232 128, 128, 128, 255, //
233 200, 200, 200, 255,
234 ];
235 let mut px = original.clone();
236 // Un perfil que es sRGB no toca nada en ninguno de los dos sentidos, y
237 // entonces no hay ida y vuelta que probar.
238 if !convertir_desde_srgb(&mut px, &p) {
239 continue;
240 }
241 assert!(convertir_a_srgb(&mut px, &p), "{nombre}: fue pero no volvió");
242 probados += 1;
243 for (i, (dio, habia)) in px.iter().zip(original.iter()).enumerate() {
244 let delta = (*dio as i32 - *habia as i32).abs();
245 assert!(delta <= 3, "{nombre} corrió el canal {i}: {habia} → {dio}");
246 }
247 }
248 assert!(probados >= 5, "sólo {probados} perfiles con ida y vuelta; el arnés no prueba nada");
249}