//! El lector de ICC contra **perfiles reales**, no sintéticos. //! //! Los tests unitarios de `perfil.rs` arman sus propios ICC, así que prueban la //! aritmética y el recorrido de tags pero no que un archivo escrito por otra //! gente entre. Esto lo cubre con los perfiles que trae `colord`, que están en //! casi cualquier Linux de escritorio. //! //! **Se saltea solo** si la máquina no los tiene: el test no puede exigir que //! haya un paquete instalado, pero tampoco tiene sentido tirar la única //! verificación contra archivos ajenos que hay. //! //! Certificado por texto (§8): son valores de píxel, no una pantalla. use llimphi_image::perfil::{ convertir_a_srgb, convertir_desde_srgb, es_identidad, leer_icc, matriz_a_srgb, }; const DIR: &str = "/usr/share/color/icc/colord"; fn cargar(nombre: &str) -> Option> { std::fs::read(std::path::Path::new(DIR).join(nombre)).ok() } /// Convierte rojo puro, verde puro y un gris medio. fn convertir_muestras(icc: &[u8]) -> Option<(bool, [u8; 3], [u8; 3], [u8; 3])> { let p = leer_icc(icc)?; let mut px = vec![255u8, 0, 0, 255, 0, 255, 0, 255, 128, 128, 128, 255]; let toco = convertir_a_srgb(&mut px, &p); Some(( toco, [px[0], px[1], px[2]], [px[4], px[5], px[6]], [px[8], px[9], px[10]], )) } #[test] fn un_perfil_srgb_de_verdad_no_toca_la_imagen() { let Some(icc) = cargar("sRGB.icc") else { return }; let p = leer_icc(&icc).expect("sRGB.icc es matrix-shaper"); assert!( es_identidad(&matriz_a_srgb(&p.a_xyz_d50), 1.0 / 512.0), "la matriz de un sRGB real tendría que dar la identidad" ); let (toco, r, g, gris) = convertir_muestras(&icc).unwrap(); assert!(!toco, "no hay nada que convertir y no se debería recorrer la imagen"); assert_eq!((r, g, gris), ([255, 0, 0], [0, 255, 0], [128, 128, 128])); // Rec.709 comparte primarios con sRGB: también identidad. Si diera otra // cosa, el que estaría mal sería el lector. if let Some(icc) = cargar("Rec709.icc") { let p = leer_icc(&icc).expect("Rec709.icc es matrix-shaper"); assert!(es_identidad(&matriz_a_srgb(&p.a_xyz_d50), 1.0 / 512.0)); } } #[test] fn el_perfil_que_intercambia_rojo_y_verde_intercambia_rojo_y_verde() { // El mejor caso de prueba que se puede pedir: la respuesta correcta no // depende de creerle a ninguna constante de colorimetría. let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return }; let (toco, r, g, gris) = convertir_muestras(&icc).unwrap(); assert!(toco); assert!(r[1] > 250 && r[0] < 8, "el rojo tendría que salir verde, salió {r:?}"); assert!(g[0] > 250 && g[1] < 8, "el verde tendría que salir rojo, salió {g:?}"); assert_eq!(gris, [128, 128, 128], "un gris no se altera al permutar canales"); } /// Mete un perfil ICC en un JPEG ya codificado, como marcador APP2. /// /// JPEG guarda el ICC **sin comprimir** (a diferencia del `iCCP` de PNG, que /// va en deflate), así que inyectarlo es concatenar bytes y no hace falta /// ninguna dependencia nueva para armar el caso de prueba. fn jpeg_con_icc(jpeg: &[u8], icc: &[u8]) -> Vec { let mut out = Vec::with_capacity(jpeg.len() + icc.len() + 32); out.extend_from_slice(&jpeg[..2]); // SOI let largo = 2 + 12 + 2 + icc.len(); out.extend_from_slice(&[0xFF, 0xE2]); out.extend_from_slice(&(largo as u16).to_be_bytes()); out.extend_from_slice(b"ICC_PROFILE\0"); out.extend_from_slice(&[1, 1]); // secuencia 1 de 1 out.extend_from_slice(icc); out.extend_from_slice(&jpeg[2..]); out } #[test] fn decodificar_un_archivo_con_perfil_lo_lleva_a_srgb() { // El lazo entero: bytes de archivo → `decode_bytes` → píxeles convertidos. // Los otros tests prueban el lector y la aritmética por separado; éste es // el único que verifica que el decodificador **le pregunta** el perfil al // decoder en vez de tirarlo, que es lo que pasaba hasta 2026-08-05. let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return }; assert!(icc.len() < 65_000, "el perfil no entra en un solo APP2"); // Un JPEG chico de rojo puro. Se codifica con calidad máxima para que el // subsampling de croma no se coma lo que se está midiendo. let rojo = image::RgbImage::from_pixel(16, 16, image::Rgb([255, 0, 0])); let mut jpeg = Vec::new(); image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100) .encode_image(&image::DynamicImage::ImageRgb8(rojo)) .expect("encode"); let sin_perfil = llimphi_image::decode_bytes(&jpeg).expect("decode"); let con_perfil = llimphi_image::decode_bytes(&jpeg_con_icc(&jpeg, &icc)).expect("decode con icc"); let centro = |img: &llimphi_image::Image| -> [u8; 3] { let d: &[u8] = img.image.data.as_ref(); let o = ((8 * 16 + 8) * 4) as usize; [d[o], d[o + 1], d[o + 2]] }; let a = centro(&sin_perfil); let b = centro(&con_perfil); assert!(a[0] > 240 && a[1] < 20, "sin perfil el rojo queda rojo, dio {a:?}"); assert!( b[1] > 240 && b[0] < 20, "con el perfil que permuta canales el rojo tiene que salir verde, dio {b:?}" ); } #[test] fn ningun_perfil_del_sistema_tiñe_un_gris() { // La invariante que vale para **toda** conversión colorimétrica relativa // entre dos espacios RGB: el blanco va al blanco, así que los neutros // quedan neutros. El valor puede cambiar —un perfil de gamma 1,8 aclara el // gris medio— pero los tres canales tienen que seguir siendo iguales. // // Es la prueba que agarró el atajo de las curvas paramétricas: aproximarlas // por su exponente dejaba el gris de `SwappedRedAndGreen` en 121 y el de // `ECI-RGBv2` en 100, cuando la curva bien evaluada da 128 y 119. let Ok(dir) = std::fs::read_dir(DIR) else { return }; let (mut leidos, mut salteados) = (0, 0); for e in dir.flatten() { let ruta = e.path(); if ruta.extension().and_then(|s| s.to_str()) != Some("icc") { continue; } let Ok(bytes) = std::fs::read(&ruta) else { continue }; let nombre = ruta.file_name().unwrap().to_string_lossy().into_owned(); match convertir_muestras(&bytes) { Some((_, _, _, gris)) => { leidos += 1; assert!( gris[0] == gris[1] && gris[1] == gris[2], "{nombre} tiñó un gris: {gris:?}" ); } // Los de imprenta (FOGRA, SWOP, GRACoL…) son CMYK con LUT: no se // arreglan con una matriz de 3×3 y se dejan pasar sin tocar. None => salteados += 1, } } // Guarda del arnés: si el directorio existiera vacío, el bucle no probaría // nada y el test pasaría igual. assert!(leidos >= 10, "sólo {leidos} perfiles leídos; el arnés no está probando"); assert!(salteados >= 1, "ninguno salteado: los CMYK deberían caer afuera"); } // ── El camino de vuelta, contra los mismos archivos ajenos ─────────────── #[test] fn abrir_y_volver_a_guardar_devuelve_los_bytes_que_habia() { // El lazo completo de un editor: `tullpu` abre un archivo con perfil, los // píxeles le llegan en sRGB, y al guardar los devuelve al espacio de origen // para no degradar el archivo del usuario por el solo hecho de haberlo // abierto. // // Con `SwappedRedAndGreen` el ida y vuelta es verificable a ojo cerrado y // sin creerle a ninguna constante: el rojo del archivo se abre como verde y // al guardar tiene que volver a ser rojo. let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return }; let rojo = image::RgbImage::from_pixel(8, 8, image::Rgb([230, 40, 60])); let mut jpeg = Vec::new(); image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100) .encode_image(&image::DynamicImage::ImageRgb8(rojo)) .expect("encode"); let d = llimphi_image::decode_bytes_con_origen(&jpeg_con_icc(&jpeg, &icc)) .expect("decode con icc"); assert!(d.origen.is_some(), "el perfil del archivo tiene que llegar al caller"); assert!(d.convertida, "y con este perfil hay conversión de verdad"); let mut px: Vec = d.imagen.image.data.as_ref().to_vec(); let abierto = [px[0], px[1], px[2]]; assert!( abierto[1] > abierto[0], "abierto tendría que venir con los canales permutados, dio {abierto:?}" ); assert!(d.devolver_al_origen(&mut px), "hay a dónde volver"); let guardado = [px[0], px[1], px[2]]; // El JPEG a calidad 100 no es exacto: la tolerancia cubre eso más las dos // cuantizaciones a 8 bits, no un error de la conversión. for (i, (dio, habia)) in guardado.iter().zip([230u8, 40, 60].iter()).enumerate() { let delta = (*dio as i32 - *habia as i32).abs(); assert!(delta <= 4, "canal {i}: se guardó {dio} donde el archivo tenía {habia}"); } } #[test] fn ningun_perfil_del_sistema_corre_un_gris_al_ir_y_volver() { // La invariante del párrafo anterior, pero **sobre los 38 archivos**, no // sobre uno elegido. Se usa un gris porque es el único color que ningún // espacio RGB puede recortar: el blanco va al blanco en toda conversión // colorimétrica relativa, así que la ida y vuelta no pierde por gamut y lo // que quede es error de la aritmética. // // Es la prueba que le pone dientes a `desde_lineal`: una inversa de curva // mal despejada corre el gris decenas de valores, y ninguna cuenta de // matrices lo taparía. let Ok(dir) = std::fs::read_dir(DIR) else { return }; let mut probados = 0; for e in dir.flatten() { let ruta = e.path(); if ruta.extension().and_then(|s| s.to_str()) != Some("icc") { continue; } let Ok(bytes) = std::fs::read(&ruta) else { continue }; let Some(p) = leer_icc(&bytes) else { continue }; let nombre = ruta.file_name().unwrap().to_string_lossy().into_owned(); // Los dos primeros son **sombras**, y están puestos a propósito: el // tramo recto que las paramétricas de tipo 3 tienen abajo sólo se pisa // ahí. Con grises de 32 para arriba, una inversa que ignorara ese tramo // —el atajo obvio— pasaría el test sin que nadie se enterara. let original: Vec = vec![ 4, 4, 4, 255, // 9, 9, 9, 255, // 32, 32, 32, 255, // 128, 128, 128, 255, // 200, 200, 200, 255, ]; let mut px = original.clone(); // Un perfil que es sRGB no toca nada en ninguno de los dos sentidos, y // entonces no hay ida y vuelta que probar. if !convertir_desde_srgb(&mut px, &p) { continue; } assert!(convertir_a_srgb(&mut px, &p), "{nombre}: fue pero no volvió"); probados += 1; for (i, (dio, habia)) in px.iter().zip(original.iter()).enumerate() { let delta = (*dio as i32 - *habia as i32).abs(); assert!(delta <= 3, "{nombre} corrió el canal {i}: {habia} → {dio}"); } } assert!(probados >= 5, "sólo {probados} perfiles con ida y vuelta; el arnés no prueba nada"); }