From a7eba8a2ade77ddfc5933e4b90502b4f838437f6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 02:11:23 +0000 Subject: [PATCH] Colour: craft-cms ace3622 (Result APIs, device white is no ink) --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/color/src/cms/icc.rs | 17 +------- crates/color/src/cms/icc_out.rs | 71 +++++++++++++++++-------------- crates/color/src/cms/mod.rs | 12 +++--- crates/color/src/cms/tests.rs | 9 ++-- crates/images/src/colour.rs | 12 +++--- crates/images/src/colour/tests.rs | 4 +- crates/pdf/src/pdfx.rs | 2 +- 9 files changed, 64 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0fdbaeb..ceeef46f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -811,7 +811,7 @@ dependencies = [ [[package]] name = "craft-cms" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs.git?rev=6710c2353842eab1be23840aafaf16a20811d25c#6710c2353842eab1be23840aafaf16a20811d25c" +source = "git+https://github.com/aiongg/craft-libs.git?rev=ace3622d427387cb197e7997ce116241ea290ed9#ace3622d427387cb197e7997ce116241ea290ed9" dependencies = [ "rayon", ] diff --git a/Cargo.toml b/Cargo.toml index 8ea44c50..7a6e2d30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ indd = { git = "https://github.com/aiongg/indd-utils", branch = "main" } craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } # ICC colour management: working spaces, transforms, black point compensation (craft-libs). -craft-cms = { git = "https://github.com/aiongg/craft-libs.git", rev = "6710c2353842eab1be23840aafaf16a20811d25c" } +craft-cms = { git = "https://github.com/aiongg/craft-libs.git", rev = "ace3622d427387cb197e7997ce116241ea290ed9" } # ui # Shared frontend components; keep this dependency above the engine boundary. diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index e1777602..ceed81a5 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -53,7 +53,7 @@ fn eval(x: &Transform, src: &[f32], outputs: usize) -> Option> { } let src: Vec = src.iter().map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }).collect(); let mut out = vec![0.0f32; outputs]; - x.eval(&src, &mut out); + x.eval(&src, &mut out).ok()?; out.iter().all(|o| o.is_finite()).then(|| out.into_iter().map(|v| v.clamp(0.0, 1.0)).collect()) } @@ -118,19 +118,9 @@ impl IccProfile { eval3(&*self.to_xf(intent, bpc)?, &src) } - /// Device values of the medium's white: no ink, or full RGB / grey. - fn device_white(&self) -> Vec { - vec![if self.kind == ProfileKind::Cmyk { 0.0 } else { 1.0 }; self.channels()] - } - - /// sRGB → device values. White is the medium's white (no ink) in every intent but absolute - /// colorimetric, exactly: craft-cms's exact evaluation leaves a few tenths of a percent of - /// ink there. + /// sRGB → device values. pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent, bpc: bool) -> Option> { let x = self.inverse_xf(intent, bpc)?; - if intent != Intent::AbsoluteColorimetric && rgb.iter().all(|v| *v >= 1.0) { - return Some(self.device_white()); - } eval(&x, &rgb, self.channels()) } @@ -148,9 +138,6 @@ impl IccProfile { /// Lab (D50) → device values with `intent`. pub fn from_lab(&self, lab: Lab, intent: Intent, bpc: bool) -> Option> { let x = slot(&self.from_lab, intent, bpc).get_or_init(|| build(Builtin::LabD50.profile(), &self.profile, intent, bpc)).clone()?; - if intent != Intent::AbsoluteColorimetric && lab.l >= 100.0 && lab.a.abs() < 1e-3 && lab.b.abs() < 1e-3 { - return Some(self.device_white()); - } eval(&x, &[lab.l / 100.0, (lab.a + 128.0) / 255.0, (lab.b + 128.0) / 255.0], self.channels()) } diff --git a/crates/color/src/cms/icc_out.rs b/crates/color/src/cms/icc_out.rs index 75795124..0f900f70 100644 --- a/crates/color/src/cms/icc_out.rs +++ b/crates/color/src/cms/icc_out.rs @@ -8,8 +8,8 @@ use std::sync::OnceLock; use craft_cms::profile::{Lut, LutKind}; use craft_cms::{Builtin, Clut, Curve, Intent as CmsIntent, Profile, pipeline::Stage}; -use super::Intent; use super::lab::Lab; +use super::{CmsError, Intent}; const A2B_GRID: usize = 9; const B2A_GRID: usize = 17; @@ -28,46 +28,51 @@ fn dec_lab(v: &[f32]) -> Lab { } fn lut16(clut: Clut) -> Lut { - let (i, o) = (clut.inputs, clut.outputs); + let (i, o) = (clut.inputs(), clut.outputs()); let ident = |n: usize| Stage::Curves(vec![Curve::Table(vec![0.0, 1.0]); n]); Lut { kind: LutKind::Lut16, inputs: i, outputs: o, stages: vec![ident(i), Stage::Clut(clut), ident(o)] } } /// The Generic CMYK model as an ICC output profile (built once). -pub fn generic_cmyk_profile() -> &'static Profile { - static P: OnceLock = OnceLock::new(); - P.get_or_init(|| { - let g = super::generic(); - // CMYK → Lab (media-relative). - let a2b = Clut::sample(vec![A2B_GRID; 4], 3, |inp, out| { - let at = |i: usize| inp.get(i).copied().unwrap_or(0.0); - out.copy_from_slice(&enc_lab(g.to_lab_rel([at(0), at(1), at(2), at(3)]))); - }); - // Lab → CMYK through the model's separation. - let b2a = Clut::sample(vec![B2A_GRID; 3], 4, |inp, out| { - let cmyk = g.from_lab(dec_lab(inp), Intent::RelativeColorimetric, true); - out.copy_from_slice(&cmyk.map(|v| v.clamp(0.0, 1.0))); - }); - let (a2b, b2a) = (lut16(a2b), lut16(b2a)); - // craft-cms profiles can't be constructed field by field outside the crate, so the - // tables replace those of a built-in CMYK output profile and the result is re-encoded. - let mut p = Builtin::CoatedCmyk.profile().clone(); - p.rendering_intent = CmsIntent::RelativeColorimetric; - p.description = super::GENERIC_CMYK.into(); - p.copyright = "No copyright; made by DesignCraft from its parametric press model. Use freely.".into(); - p.white_point = [0.9642, 1.0, 0.8249]; - p.black_point = None; - p.chad = None; - p.a2b = [Some(a2b.clone()), Some(a2b.clone()), Some(a2b)]; - p.b2a = [Some(b2a.clone()), Some(b2a.clone()), Some(b2a)]; - p.with_encoded_bytes() +pub fn generic_cmyk_profile() -> Result<&'static Profile, CmsError> { + static P: OnceLock> = OnceLock::new(); + P.get_or_init(build_generic_cmyk).as_ref().map_err(Clone::clone) +} + +fn build_generic_cmyk() -> Result { + let bad = |e: craft_cms::CmsError| CmsError::BadProfile(format!("{}: {e}", super::GENERIC_CMYK)); + let g = super::generic(); + // CMYK → Lab (media-relative). + let a2b = Clut::sample(vec![A2B_GRID; 4], 3, |inp, out| { + let at = |i: usize| inp.get(i).copied().unwrap_or(0.0); + out.copy_from_slice(&enc_lab(g.to_lab_rel([at(0), at(1), at(2), at(3)]))); }) + .map_err(bad)?; + // Lab → CMYK through the model's separation. + let b2a = Clut::sample(vec![B2A_GRID; 3], 4, |inp, out| { + let cmyk = g.from_lab(dec_lab(inp), Intent::RelativeColorimetric, true); + out.copy_from_slice(&cmyk.map(|v| v.clamp(0.0, 1.0))); + }) + .map_err(bad)?; + let (a2b, b2a) = (lut16(a2b), lut16(b2a)); + // craft-cms profiles can't be constructed field by field outside the crate, so the + // tables replace those of a built-in CMYK output profile and the result is re-encoded. + let mut p = Builtin::CoatedCmyk.profile().clone(); + p.rendering_intent = CmsIntent::RelativeColorimetric; + p.description = super::GENERIC_CMYK.into(); + p.copyright = "No copyright; made by DesignCraft from its parametric press model. Use freely.".into(); + p.white_point = [0.9642, 1.0, 0.8249]; + p.black_point = None; + p.chad = None; + p.a2b = [Some(a2b.clone()), Some(a2b.clone()), Some(a2b)]; + p.b2a = [Some(b2a.clone()), Some(b2a.clone()), Some(b2a)]; + Ok(p.with_encoded_bytes()) } /// [`generic_cmyk_profile`]'s ICC bytes. -pub fn generic_cmyk_icc() -> &'static [u8] { - static B: OnceLock>> = OnceLock::new(); - B.get_or_init(|| generic_cmyk_profile().to_bytes()).as_slice() +pub fn generic_cmyk_icc() -> Result<&'static [u8], CmsError> { + static B: OnceLock>, CmsError>> = OnceLock::new(); + B.get_or_init(|| generic_cmyk_profile().map(Profile::to_bytes)).as_ref().map(|b| b.as_slice()).map_err(Clone::clone) } #[cfg(test)] @@ -76,7 +81,7 @@ mod tests { #[test] fn generic_cmyk_profile_round_trips() { - let bytes = generic_cmyk_icc(); + let bytes = generic_cmyk_icc().unwrap(); assert!(bytes.len() > 1000); assert_eq!(&bytes[36..40], b"acsp"); assert_eq!(&bytes[16..20], b"CMYK"); diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 59e7b9d6..45445b1d 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -327,8 +327,8 @@ impl Cms { pub fn cmyk_icc_profile(&self) -> Option> { match &self.cmyk { CmykSpace::Generic(_) => { - static P: OnceLock> = OnceLock::new(); - Some(P.get_or_init(|| Arc::new(icc_out::generic_cmyk_profile().clone())).clone()) + static P: OnceLock>> = OnceLock::new(); + P.get_or_init(|| icc_out::generic_cmyk_profile().ok().map(|p| Arc::new(p.clone()))).clone() } CmykSpace::Device => None, CmykSpace::Icc(p) => Some(p.profile().clone()), @@ -337,16 +337,16 @@ impl Cms { /// The PDF/X output intent for the working CMYK space. Device CMYK has no profile and is /// described by the Generic CMYK one. - pub fn output_intent(&self) -> OutputIntent { - match &self.cmyk { + pub fn output_intent(&self) -> Result { + Ok(match &self.cmyk { CmykSpace::Icc(p) if p.name == COATED_FOGRA39 => { OutputIntent { icc: Cow::Borrowed(craft_cms::builtin::COATED_FOGRA39_ICC), identifier: "FOGRA39", condition: COATED_FOGRA39.into() } } CmykSpace::Icc(p) => OutputIntent { icc: Cow::Owned(p.profile().to_bytes().to_vec()), identifier: "Custom", condition: p.name.clone() }, CmykSpace::Generic(_) | CmykSpace::Device => { - OutputIntent { icc: Cow::Borrowed(icc_out::generic_cmyk_icc()), identifier: "Custom", condition: GENERIC_CMYK.into() } + OutputIntent { icc: Cow::Borrowed(icc_out::generic_cmyk_icc()?), identifier: "Custom", condition: GENERIC_CMYK.into() } } - } + }) } /// Working RGB → display sRGB. diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index c69620b4..14fa68b7 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -77,13 +77,13 @@ fn saved_cmyk_names_still_load() { #[test] fn output_intent_follows_the_working_cmyk_space() { - let fogra = cms().output_intent(); + let fogra = cms().output_intent().unwrap(); assert_eq!((fogra.identifier, fogra.condition.as_str()), ("FOGRA39", COATED_FOGRA39)); assert_eq!(&*fogra.icc, craft_cms::builtin::COATED_FOGRA39_ICC); for name in [GENERIC_CMYK, DEVICE_CMYK] { - let g = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap().output_intent(); + let g = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap().output_intent().unwrap(); assert_eq!((g.identifier, g.condition.as_str()), ("Custom", GENERIC_CMYK), "{name}"); - assert_eq!(&*g.icc, icc_out::generic_cmyk_icc()); + assert_eq!(&*g.icc, icc_out::generic_cmyk_icc().unwrap()); } } @@ -91,6 +91,9 @@ fn output_intent_follows_the_working_cmyk_space() { fn paper_white_and_rich_black() { let c = cms(); assert_eq!(c.srgb_to_cmyk([1.0, 1.0, 1.0], Intent::RelativeColorimetric), [0.0; 4]); + for intent in [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation] { + assert_eq!(c.lab_to_cmyk(lab::Lab::new(100.0, 0.0, 0.0), intent), [0.0; 4], "{intent:?}"); + } let k = c.srgb_to_cmyk([0.0, 0.0, 0.0], Intent::RelativeColorimetric); assert!(k[3] > 0.8, "black generation puts most of the black in K: {k:?}"); assert!(k.iter().sum::() <= 3.3 + 1e-3, "Coated FOGRA39 total ink limit (330 %): {k:?}"); diff --git a/crates/images/src/colour.rs b/crates/images/src/colour.rs index 27fdc090..dcbdeee8 100644 --- a/crates/images/src/colour.rs +++ b/crates/images/src/colour.rs @@ -122,13 +122,15 @@ fn to_srgb(src: &Profile, options: TransformOptions) -> Option> { } /// Convert interleaved pixels (`src_stride` / `dst_stride` values per pixel, colour channels -/// first; the rest of `dst` is left alone). `false` when the strides don't fit the transform. +/// first; the rest of `dst` is left alone). `false` when the buffers don't fit the transform. fn convert(x: &Transform, src: &[u8], src_stride: usize, dst: &mut [u8], dst_stride: usize) -> bool { - if x.inputs() == 0 || x.inputs() > src_stride || x.outputs() == 0 || x.outputs() > dst_stride { - return false; + match x.convert_u8(src, src_stride, dst, dst_stride, false) { + Ok(()) => true, + Err(e) => { + log::warn!("image ICC conversion: {e}; profile ignored"); + false + } } - x.convert_u8(src, src_stride, dst, dst_stride, false); - true } /// Whether an RGB transform leaves every value within 1 of where it was (an sRGB profile): the diff --git a/crates/images/src/colour/tests.rs b/crates/images/src/colour/tests.rs index 6a66f841..ca160796 100644 --- a/crates/images/src/colour/tests.rs +++ b/crates/images/src/colour/tests.rs @@ -184,7 +184,7 @@ fn cmyk_uses_its_own_profile_before_the_working_space() { assert_eq!(img.get_pixel(0, 0).0, [0, 255, 255, 255]); assert_eq!(img.get_pixel(1, 0).0, [0, 0, 0, 128]); // An embedded profile wins over it. - let icc = cms::icc_out::generic_cmyk_icc(); + let icc = cms::icc_out::generic_cmyk_icc().unwrap(); let img = to_display_rgba(Pixels::Cmyk(raster()), Some(icc), &device).unwrap(); assert_eq!(img.get_pixel(0, 0).0, shown([255, 0, 0, 0], &DisplayColour::of(&generic(true)))); assert_eq!(img.get_pixel(1, 0).0[3], 128, "alpha kept"); @@ -227,7 +227,7 @@ fn gray_with_an_embedded_profile_is_converted() { fn unusable_profiles_leave_the_pixels_alone() { let px = [64, 192, 64, 255]; let adobe = adobe_rgb_icc(); - let cmyk = cms::icc_out::generic_cmyk_icc().to_vec(); + let cmyk = cms::icc_out::generic_cmyk_icc().unwrap().to_vec(); let mut huge = adobe.clone(); huge.resize(MAX_ICC_BYTES + 1, 0); let mut lying = adobe.clone(); diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 8679c66c..ec2a9570 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -46,7 +46,7 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> { out.extend_from_slice(b"\nendobj\n"); }; // The output profile and the output intent. - let intent = designcraft_color::cms::active().output_intent(); + let intent = designcraft_color::cms::active().output_intent().ok()?; let (icc_id, oi_id) = (size, size + 1); let mut s = format!("<>\nstream\n", intent.icc.len()).into_bytes(); s.extend_from_slice(&intent.icc);