Colour: craft-cms ace3622 (Result APIs, device white is no ink)

This commit is contained in:
aiongg
2026-10-13 02:11:23 +00:00
parent eef4648175
commit a7eba8a2ad
9 changed files with 64 additions and 67 deletions
Generated
+1 -1
View File
@@ -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",
]
+1 -1
View File
@@ -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.
+2 -15
View File
@@ -53,7 +53,7 @@ fn eval(x: &Transform, src: &[f32], outputs: usize) -> Option<Vec<f32>> {
}
let src: Vec<f32> = 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<f32> {
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<Vec<f32>> {
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<Vec<f32>> {
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())
}
+38 -33
View File
@@ -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<Profile> = 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<Result<Profile, CmsError>> = OnceLock::new();
P.get_or_init(build_generic_cmyk).as_ref().map_err(Clone::clone)
}
fn build_generic_cmyk() -> Result<Profile, CmsError> {
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<std::sync::Arc<Vec<u8>>> = 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<Result<std::sync::Arc<Vec<u8>>, 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");
+6 -6
View File
@@ -327,8 +327,8 @@ impl Cms {
pub fn cmyk_icc_profile(&self) -> Option<Arc<craft_cms::Profile>> {
match &self.cmyk {
CmykSpace::Generic(_) => {
static P: OnceLock<Arc<craft_cms::Profile>> = OnceLock::new();
Some(P.get_or_init(|| Arc::new(icc_out::generic_cmyk_profile().clone())).clone())
static P: OnceLock<Option<Arc<craft_cms::Profile>>> = 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<OutputIntent, CmsError> {
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.
+6 -3
View File
@@ -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::<f32>() <= 3.3 + 1e-3, "Coated FOGRA39 total ink limit (330 %): {k:?}");
+7 -5
View File
@@ -122,13 +122,15 @@ fn to_srgb(src: &Profile, options: TransformOptions) -> Option<Arc<Transform>> {
}
/// 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
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -46,7 +46,7 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option<Vec<u8>> {
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!("<</N 4/Length {}>>\nstream\n", intent.icc.len()).into_bytes();
s.extend_from_slice(&intent.icc);