diff --git a/crates/cms/README.md b/crates/cms/README.md index 02c2c534..c39e67bf 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -18,14 +18,19 @@ use craft_cms::{Builtin, Intent, Profile, Transform}; let src = Profile::parse(&icc_bytes)?; // v2/v4: matrix/TRC, gray TRC, mft1/mft2/mAB/mBA let dst = Builtin::CoatedCmyk.profile(); let t = Transform::new(&src, dst, Intent::RelativeColorimetric, /*bpc*/ true)?; -t.convert_u8(&rgba8, 4, &mut cmyka8, 5, /*copy alpha*/ true); // also convert_u16 / convert_f32 / apply (in place) +t.convert_u8(&rgba8, 4, &mut cmyka8, 5, /*copy alpha*/ true)?; // also convert_u16 / convert_f32 / apply (in place) let mut out = [0.0; 4]; -t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colour +t.eval(&[1.0, 0.0, 0.0], &mut out)?; // exact single colour ``` +Calls never panic on caller input: strides below the channel count, buffers that are not +whole pixels, a destination with fewer pixels than the source, slices shorter than the +channel count and out-of-range table or LUT dimensions return `CmsError::InvalidArgument`. +Buffers are checked once per call, before any pixel is written. + * `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain. * `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning). -* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture. +* `Lut3d::from_transform(&t, 33)?.to_rgba16f_bytes()`: display transform as a 3D texture. * `transform::cached(src, dst, opts)`: process-wide transform cache. * `lutfile::parse(name, bytes)` / `lutfile::write_cube(&lut)`: `.cube`, `.3dl` and `.look` LUT files; `lutfile::builtin(id)` returns generated looks (`lutfile::BUILTIN`). diff --git a/crates/cms/src/builtin.rs b/crates/cms/src/builtin.rs index 7c53013f..a5939282 100644 --- a/crates/cms/src/builtin.rs +++ b/crates/cms/src/builtin.rs @@ -323,12 +323,35 @@ fn build(b: Builtin) -> Profile { Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)), Builtin::SGray => gray(b.description(), srgb_trc()), Builtin::LabD50 => lab(), - // The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test); rebuild - // them rather than crash if they ever fail to parse. - Builtin::CoatedCmyk => Profile::parse(COATED_CMYK_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()), + // The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test). + Builtin::CoatedCmyk => shipped_cmyk(COATED_CMYK_ICC, b), // The fit needs the measurement data, which only the tests have; the `fogra39` tests - // check that the shipped bytes parse, so the synthetic coated profile is a fallback - // that does not happen. - Builtin::CoatedFogra39 => Profile::parse(COATED_FOGRA39_ICC).unwrap_or_else(|_| crate::synth::coated_cmyk()), + // check that the shipped bytes parse. + Builtin::CoatedFogra39 => shipped_cmyk(COATED_FOGRA39_ICC, b), } } + +/// A shipped CMYK profile. Should its bytes ever fail to parse, the synthetic coated profile +/// is generated instead, and failing that a CMYK profile without tables stands in (transforms +/// refuse it with an error) rather than crash. +fn shipped_cmyk(bytes: &[u8], b: Builtin) -> Profile { + Profile::parse(bytes).or_else(|_| crate::synth::coated_cmyk()).unwrap_or_else(|_| Profile { + version: (4, 0x30), + class: ProfileClass::Output, + color_space: ColorSpace::Cmyk, + pcs: Pcs::Lab, + rendering_intent: Intent::Perceptual, + description: b.description().into(), + copyright: COPYRIGHT.into(), + white_point: math::d50_quantized(), + black_point: None, + chad: None, + matrix: None, + trc: None, + gray_trc: None, + a2b: [None, None, None], + b2a: [None, None, None], + bytes: None, + hash: Default::default(), + }) +} diff --git a/crates/cms/src/clut.rs b/crates/cms/src/clut.rs index 186cd3f9..e551638d 100644 --- a/crates/cms/src/clut.rs +++ b/crates/cms/src/clut.rs @@ -5,48 +5,90 @@ //! Kasson et al. 1995); 4-input tables interpolate linearly along the first input between //! two tetrahedral evaluations; other input counts use multilinear interpolation. +use crate::{CmsError, check_len, invalid_arg}; + /// Maximum number of input or output channels of a CLUT. pub const MAX_CHANNELS: usize = 15; +/// Largest number of stored values (nodes × outputs) a table may hold. +pub const MAX_VALUES: usize = 16 * 1024 * 1024; + #[derive(Clone, Debug, PartialEq)] pub struct Clut { - pub inputs: usize, - pub outputs: usize, - /// Grid points per input dimension (each ≥ 2, or 1 for a degenerate dimension). - pub grid: Vec, - /// Normalised node values, `outputs` per node. - pub data: Vec, + pub(crate) inputs: usize, + pub(crate) outputs: usize, + pub(crate) grid: Vec, + pub(crate) data: Vec, strides: Vec, } impl Clut { - /// Builds a table from node data. Panics if `data` has the wrong length. - pub fn new(grid: Vec, outputs: usize, data: Vec) -> Self { + /// Builds a table from node data: `grid` holds the points per input dimension (1 to + /// [`MAX_CHANNELS`] dimensions, each ≥ 2, or 1 for a degenerate dimension), `data` holds + /// `outputs` values per node. Refuses inconsistent dimensions with + /// [`CmsError::InvalidArgument`]. + pub fn new(grid: Vec, outputs: usize, data: Vec) -> Result { + let total = Self::check_dims(&grid, outputs)?; + if data.len() != total { + return Err(invalid_arg(format!("CLUT data has {} values, the grid needs {total}", data.len()))); + } + Ok(Self::from_checked(grid, outputs, data)) + } + + /// Number of values a `grid` × `outputs` table stores, or why it is refused. + fn check_dims(grid: &[usize], outputs: usize) -> Result { + if !(1..=MAX_CHANNELS).contains(&grid.len()) || !(1..=MAX_CHANNELS).contains(&outputs) { + return Err(invalid_arg(format!("CLUT with {} inputs and {outputs} outputs (1 to {MAX_CHANNELS} each)", grid.len()))); + } + if grid.contains(&0) { + return Err(invalid_arg("CLUT grid with zero points in a dimension")); + } + grid.iter() + .try_fold(outputs, |n, g| n.checked_mul(*g)) + .filter(|n| *n <= MAX_VALUES) + .ok_or_else(|| invalid_arg(format!("CLUT grid {grid:?} × {outputs} exceeds {MAX_VALUES} values"))) + } + + fn from_checked(grid: Vec, outputs: usize, data: Vec) -> Self { let inputs = grid.len(); - assert!((1..=MAX_CHANNELS).contains(&inputs) && (1..=MAX_CHANNELS).contains(&outputs), "bad CLUT dimensions"); let mut strides = vec![0usize; inputs]; let mut s = outputs; for i in (0..inputs).rev() { strides[i] = s; - s *= grid[i].max(1); + s *= grid[i]; } - assert_eq!(data.len(), s, "CLUT data length"); Clut { inputs, outputs, grid, data, strides } } + pub fn inputs(&self) -> usize { + self.inputs + } + pub fn outputs(&self) -> usize { + self.outputs + } + /// Grid points per input dimension. + pub fn grid(&self) -> &[usize] { + &self.grid + } + /// Normalised node values, `outputs` per node, first input varying slowest. + pub fn data(&self) -> &[f32] { + &self.data + } + /// Number of nodes. pub fn nodes(&self) -> usize { self.data.len() / self.outputs } - /// Builds a table by sampling `f` at every node (inputs normalised to `[0, 1]`). - pub fn sample(grid: Vec, outputs: usize, mut f: impl FnMut(&[f32], &mut [f32])) -> Self { + /// Builds a table by sampling `f` at every node (inputs normalised to `[0, 1]`); the + /// dimensions are checked as in [`Clut::new`]. + pub fn sample(grid: Vec, outputs: usize, mut f: impl FnMut(&[f32], &mut [f32])) -> Result { + let total = Self::check_dims(&grid, outputs)?; let inputs = grid.len(); - let total: usize = grid.iter().product(); - let mut data = vec![0.0f32; total * outputs]; + let mut data = vec![0.0f32; total]; let mut idx = vec![0usize; inputs]; let mut inp = vec![0.0f32; inputs]; - for node in 0..total { + for (node, out) in data.chunks_exact_mut(outputs).enumerate() { let mut rem = node; for d in (0..inputs).rev() { idx[d] = rem % grid[d]; @@ -55,9 +97,9 @@ impl Clut { for d in 0..inputs { inp[d] = if grid[d] > 1 { idx[d] as f32 / (grid[d] - 1) as f32 } else { 0.0 }; } - f(&inp, &mut data[node * outputs..(node + 1) * outputs]); + f(&inp, out); } - Clut::new(grid, outputs, data) + Ok(Self::from_checked(grid, outputs, data)) } #[inline] @@ -71,9 +113,22 @@ impl Clut { (i * self.strides[d], p - i as f32, self.strides[d]) } - /// Interpolates at `input` (`inputs` values) into `out` (`outputs` values). + /// Interpolates at `input` (at least `inputs` values) into `out` (at least `outputs` + /// values). + pub fn eval(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> { + self.check_io(input, out)?; + self.interp(input, out); + Ok(()) + } + + fn check_io(&self, input: &[f32], out: &[f32]) -> Result<(), CmsError> { + check_len("CLUT input", input.len(), self.inputs)?; + check_len("CLUT output", out.len(), self.outputs) + } + + /// [`Clut::eval`] for callers that guarantee the slice lengths. #[inline] - pub fn eval(&self, input: &[f32], out: &mut [f32]) { + pub(crate) fn interp(&self, input: &[f32], out: &mut [f32]) { match self.inputs { 1 => self.eval1(input[0], out), 3 => { @@ -100,7 +155,7 @@ impl Clut { out[..self.outputs].copy_from_slice(&lo[..self.outputs]); } } - _ => self.eval_multilinear(input, out), + _ => self.multilinear(input, out), } } @@ -153,13 +208,21 @@ impl Clut { } /// Trilinear interpolation (3 inputs), kept as a reference for accuracy comparisons. - pub fn eval_trilinear(&self, input: &[f32], out: &mut [f32]) { - assert_eq!(self.inputs, 3); - self.eval_multilinear(input, out); + pub fn eval_trilinear(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> { + if self.inputs != 3 { + return Err(invalid_arg(format!("trilinear interpolation of a {}-input CLUT", self.inputs))); + } + self.eval_multilinear(input, out) } /// Multilinear interpolation over all 2^n cube corners. - pub fn eval_multilinear(&self, input: &[f32], out: &mut [f32]) { + pub fn eval_multilinear(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> { + self.check_io(input, out)?; + self.multilinear(input, out); + Ok(()) + } + + fn multilinear(&self, input: &[f32], out: &mut [f32]) { let n = self.inputs; let mut base = 0usize; let mut r = [0.0f32; MAX_CHANNELS]; @@ -205,28 +268,28 @@ mod tests { #[test] fn exact_on_nodes_and_linear_functions() { - let c = Clut::sample(vec![5, 5, 5], 3, f3); + let c = Clut::sample(vec![5, 5, 5], 3, f3).unwrap(); let mut o = [0.0; 3]; - c.eval(&[0.25, 0.5, 0.75], &mut o); + c.eval(&[0.25, 0.5, 0.75], &mut o).unwrap(); let mut w = [0.0; 3]; f3(&[0.25, 0.5, 0.75], &mut w); for i in 0..3 { assert!((o[i] - w[i]).abs() < 1e-6); } // Linear function is reproduced exactly anywhere. - c.eval(&[0.13, 0.77, 0.41], &mut o); + c.eval(&[0.13, 0.77, 0.41], &mut o).unwrap(); assert!((o[0] - (0.13 * 0.5 + 0.77 * 0.3 + 0.41 * 0.2)).abs() < 1e-6); } #[test] fn four_d_and_multilinear_agree_on_linear() { let f = |p: &[f32], o: &mut [f32]| o[0] = 0.1 * p[0] + 0.2 * p[1] + 0.3 * p[2] + 0.4 * p[3]; - let c = Clut::sample(vec![3, 4, 5, 6], 1, f); + let c = Clut::sample(vec![3, 4, 5, 6], 1, f).unwrap(); let mut a = [0.0]; let mut b = [0.0]; for p in [[0.1f32, 0.9, 0.3, 0.7], [1.0, 0.0, 0.5, 0.5], [0.33, 0.66, 0.99, 0.01]] { - c.eval(&p, &mut a); - c.eval_multilinear(&p, &mut b); + c.eval(&p, &mut a).unwrap(); + c.eval_multilinear(&p, &mut b).unwrap(); let want = 0.1 * p[0] + 0.2 * p[1] + 0.3 * p[2] + 0.4 * p[3]; assert!((a[0] - want).abs() < 1e-5 && (b[0] - want).abs() < 1e-5); } @@ -237,12 +300,13 @@ mod tests { let c = Clut::sample(vec![3], 2, |p, o| { o[0] = p[0]; o[1] = 1.0 - p[0]; - }); + }) + .unwrap(); let mut o = [0.0; 2]; - c.eval(&[0.25], &mut o); + c.eval(&[0.25], &mut o).unwrap(); assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.75).abs() < 1e-6); - let c = Clut::sample(vec![2, 2], 1, |p, o| o[0] = p[0] + p[1]); - c.eval(&[0.5, 0.25], &mut o); + let c = Clut::sample(vec![2, 2], 1, |p, o| o[0] = p[0] + p[1]).unwrap(); + c.eval(&[0.5, 0.25], &mut o).unwrap(); assert!((o[0] - 0.75).abs() < 1e-6); } } diff --git a/crates/cms/src/curve.rs b/crates/cms/src/curve.rs index fe23e6e0..f50f2d66 100644 --- a/crates/cms/src/curve.rs +++ b/crates/cms/src/curve.rs @@ -94,9 +94,10 @@ impl Curve { } } - /// Samples `n` evenly spaced points over `[0, 1]`. + /// Samples `n` evenly spaced points over `[0, 1]` (one sample is the value at 0). pub fn sample(&self, n: usize) -> Vec { - (0..n).map(|i| self.eval64(i as f64 / (n - 1) as f64) as f32).collect() + let step = n.saturating_sub(1).max(1) as f64; + (0..n).map(|i| self.eval64(i as f64 / step) as f32).collect() } /// `true` when the curve never decreases over `[0, 1]`. diff --git a/crates/cms/src/gamut.rs b/crates/cms/src/gamut.rs index 8b1abe1d..247b183f 100644 --- a/crates/cms/src/gamut.rs +++ b/crates/cms/src/gamut.rs @@ -4,7 +4,7 @@ use crate::clut::Clut; use crate::pipeline::{Pipeline, Stage}; use crate::profile::Profile; use crate::transform::link_stages; -use crate::{CmsError, Intent}; +use crate::{CmsError, Intent, check_len}; /// Default ΔE76 threshold above which a colour counts as out of gamut. pub const DEFAULT_THRESHOLD: f32 = 4.0; @@ -25,23 +25,23 @@ pub struct GamutCheck { impl GamutCheck { pub fn new(src: &Profile, proof: &Profile, threshold: f32) -> Result { let lab = crate::Builtin::LabD50.profile(); - let direct = Pipeline::new(src.channels(), link_stages(src, lab, Intent::RelativeColorimetric, false)?); + let direct = Pipeline::new(src.channels(), link_stages(src, lab, Intent::RelativeColorimetric, false)?)?; let mut via = link_stages(src, proof, Intent::RelativeColorimetric, false)?; via.push(Stage::Clamp01); via.extend(link_stages(proof, lab, Intent::RelativeColorimetric, false)?); - let via = Pipeline::new(src.channels(), via); + let via = Pipeline::new(src.channels(), via)?; let de = |p: &[f32], o: &mut [f32]| { let (mut a, mut b) = ([0.0f32; 16], [0.0f32; 16]); - direct.eval(p, &mut a); - via.eval(p, &mut b); + direct.run(p, &mut a); + via.run(p, &mut b); let d = |i: usize, s: f32| (a[i] - b[i]) * s; o[0] = (d(0, 100.0).powi(2) + d(1, 255.0).powi(2) + d(2, 255.0).powi(2)).sqrt(); }; let n = src.channels(); let grid = match n { - 1 => Some(Clut::sample(vec![256], 1, de)), - 3 => Some(Clut::sample(vec![33; 3], 1, de)), - 4 => Some(Clut::sample(vec![17; 4], 1, de)), + 1 => Some(Clut::sample(vec![256], 1, de)?), + 3 => Some(Clut::sample(vec![33; 3], 1, de)?), + 4 => Some(Clut::sample(vec![17; 4], 1, de)?), _ => None, }; Ok(GamutCheck { inputs: n, grid, exact: (direct, via), threshold }) @@ -51,8 +51,9 @@ impl GamutCheck { self.inputs } - /// ΔE76 between the colour and its proof round trip. - pub fn delta_e(&self, px: &[f32]) -> f32 { + /// ΔE76 between the colour (at least `inputs` normalised values) and its proof round trip. + pub fn delta_e(&self, px: &[f32]) -> Result { + check_len("gamut check input", px.len(), self.inputs)?; let mut o = [0.0f32; 1]; match &self.grid { Some(g) => { @@ -60,19 +61,19 @@ impl GamutCheck { for (d, s) in c.iter_mut().zip(&px[..self.inputs]) { *d = s.clamp(0.0, 1.0); } - g.eval(&c[..self.inputs], &mut o); + g.interp(&c[..self.inputs], &mut o); } None => { let (mut a, mut b) = ([0.0f32; 16], [0.0f32; 16]); - self.exact.0.eval(px, &mut a); - self.exact.1.eval(px, &mut b); + self.exact.0.run(px, &mut a); + self.exact.1.run(px, &mut b); o[0] = ((a[0] - b[0]) * 100.0).hypot((a[1] - b[1]) * 255.0).hypot((a[2] - b[2]) * 255.0); } } - o[0] + Ok(o[0]) } - pub fn out_of_gamut(&self, px: &[f32]) -> bool { - self.delta_e(px) > self.threshold + pub fn out_of_gamut(&self, px: &[f32]) -> Result { + Ok(self.delta_e(px)? > self.threshold) } } diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs index bb34ebb8..613d0db6 100644 --- a/crates/cms/src/lib.rs +++ b/crates/cms/src/lib.rs @@ -83,7 +83,7 @@ impl Intent { } } -/// Errors from parsing profiles or building transforms. +/// Errors from parsing profiles, building transforms, or arguments that do not fit an operation. #[derive(Clone, Debug, PartialEq, Eq)] pub enum CmsError { Truncated, @@ -91,6 +91,10 @@ pub enum CmsError { BadSignature, Invalid(String), Unsupported(String), + /// A caller argument does not fit the operation: a pixel stride below the channel count, a + /// buffer that is not whole pixels or is too short, a slice shorter than the channel count, + /// or table dimensions out of range. + InvalidArgument(String), } impl std::fmt::Display for CmsError { @@ -100,8 +104,18 @@ impl std::fmt::Display for CmsError { CmsError::BadSignature => write!(f, "not an ICC profile (missing 'acsp' signature)"), CmsError::Invalid(m) => write!(f, "invalid ICC profile: {m}"), CmsError::Unsupported(m) => write!(f, "unsupported ICC feature: {m}"), + CmsError::InvalidArgument(m) => write!(f, "invalid argument: {m}"), } } } impl std::error::Error for CmsError {} + +pub(crate) fn invalid_arg(m: impl Into) -> CmsError { + CmsError::InvalidArgument(m.into()) +} + +/// Checks that `slice` holds at least `n` values (single-colour evaluation). +pub(crate) fn check_len(what: &str, len: usize, n: usize) -> Result<(), CmsError> { + if len < n { Err(invalid_arg(format!("{what} has {len} values, {n} needed"))) } else { Ok(()) } +} diff --git a/crates/cms/src/lut3d.rs b/crates/cms/src/lut3d.rs index f38112ff..9103b238 100644 --- a/crates/cms/src/lut3d.rs +++ b/crates/cms/src/lut3d.rs @@ -1,34 +1,50 @@ //! 3D lookup tables for applying a display transform on the GPU. -use crate::Transform; +use crate::lutfile::MAX_SIZE; +use crate::{CmsError, Transform, invalid_arg}; /// An `size³` RGBA lookup table: entry `r + g·size + b·size²` holds the output for input /// `(r, g, b) / (size − 1)`, alpha 1. This is the memory order of a 3D texture whose x axis is /// red, so it uploads directly (sample at `(c·(size−1) + 0.5) / size` with linear filtering). #[derive(Clone, Debug, PartialEq)] pub struct Lut3d { - pub size: usize, - pub data: Vec<[f32; 4]>, + size: usize, + data: Vec<[f32; 4]>, } impl Lut3d { - /// Samples a 3-input transform (1-output transforms are replicated to gray). - pub fn from_transform(t: &Transform, size: usize) -> Lut3d { - assert!(t.inputs() == 3 && (t.outputs() == 3 || t.outputs() == 1), "display LUTs need a 3→3 or 3→1 transform"); - assert!(size >= 2, "LUT size"); + /// Samples a 3-input transform (1-output transforms are replicated to gray) on a `size`³ + /// grid, `size` from 2 to [`MAX_SIZE`]. + pub fn from_transform(t: &Transform, size: usize) -> Result { + if t.inputs() != 3 || !(t.outputs() == 3 || t.outputs() == 1) { + return Err(invalid_arg(format!("display LUTs need a 3→3 or 3→1 transform, not {}→{}", t.inputs(), t.outputs()))); + } + if !(2..=MAX_SIZE).contains(&size) { + return Err(invalid_arg(format!("3D LUT size {size} (2 to {MAX_SIZE})"))); + } let mut data = vec![[0.0f32, 0.0, 0.0, 1.0]; size * size * size]; let s = (size - 1) as f32; let mut out = [0.0f32; 16]; for b in 0..size { for g in 0..size { for r in 0..size { - t.eval(&[r as f32 / s, g as f32 / s, b as f32 / s], &mut out); + t.eval(&[r as f32 / s, g as f32 / s, b as f32 / s], &mut out)?; let v = if t.outputs() == 1 { [out[0]; 3] } else { [out[0], out[1], out[2]] }; data[r + g * size + b * size * size] = [v[0], v[1], v[2], 1.0]; } } } - Lut3d { size, data } + Ok(Lut3d { size, data }) + } + + /// Edge length. + pub fn size(&self) -> usize { + self.size + } + + /// `size`³ RGBA entries, red fastest. + pub fn data(&self) -> &[[f32; 4]] { + &self.data } /// Trilinear lookup (what GPU filtering computes). diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index d5918f31..880d9a64 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -50,15 +50,16 @@ fn checked_data_len(size: usize) -> Result<(usize, usize), LutError> { } impl LutFile { - /// The identity table of edge `size`. - pub fn identity(size: usize) -> Self { + /// The identity table of edge `size` (2 to [`MAX_SIZE`]). + pub fn identity(size: usize) -> Result { Self::from_fn("Identity", size, |c| c) } - /// Samples `f` on an `size`³ grid. - pub fn from_fn(title: &str, size: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Self { - let m = (size.max(2) - 1) as f32; - let mut data = Vec::with_capacity(size * size * size * 3); + /// Samples `f` on an `size`³ grid, `size` from 2 to [`MAX_SIZE`]. + pub fn from_fn(title: &str, size: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> Result { + let (_, data_len) = checked_data_len(size)?; + let m = (size - 1) as f32; + let mut data = Vec::with_capacity(data_len); for b in 0..size { for g in 0..size { for r in 0..size { @@ -67,7 +68,7 @@ impl LutFile { } } } - LutFile { title: title.to_string(), size, data, domain_min: [0.0; 3], domain_max: [1.0; 3] } + Ok(LutFile { title: title.to_string(), size, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }) } /// Whether the table expects plain 0..1 inputs (the common case; Photoshop's default). @@ -164,7 +165,7 @@ pub fn parse_cube(text: &str) -> Result { let f = x - i as f32; rows[i * 3 + ch] * (1.0 - f) + rows[(i + 1) * 3 + ch] * f }; - let mut l = LutFile::from_fn(&title, 33, |c| [curve(0, c[0]), curve(1, c[1]), curve(2, c[2])]); + let mut l = LutFile::from_fn(&title, 33, |c| [curve(0, c[0]), curve(1, c[1]), curve(2, c[2])])?; l.title = title; return l.check(); } @@ -294,7 +295,7 @@ pub fn builtin(id: &str) -> Option { "monoContrast" => Box::new(|c: [f32; 3]| [smooth(smooth(luma(c))); 3]), _ /* crossProcess */ => Box::new(|c: [f32; 3]| [smooth(c[0]) * 1.05, c[1] * 1.1 - 0.03, c[2] * 0.7 + 0.15]), }; - Some(LutFile::from_fn(label, 33, f)) + LutFile::from_fn(label, 33, f).ok() } #[cfg(test)] @@ -362,7 +363,7 @@ DOMAIN_MAX 0.5 1 1 #[test] fn cube_roundtrip_and_identity() { - let id = LutFile::identity(5); + let id = LutFile::identity(5).unwrap(); let text = write_cube(&LutFile { title: "Id".into(), ..id.clone() }); let back = parse_cube(&text).unwrap(); assert_eq!(back.size, 5); @@ -408,12 +409,12 @@ DOMAIN_MAX 0.5 1 1 } let l = parse_3dl(&text).unwrap(); assert_eq!(l.size, 2); - assert_eq!(l.data, LutFile::identity(2).data); + assert_eq!(l.data, LutFile::identity(2).unwrap().data); } #[test] fn look_hex_floats() { - let id = LutFile::identity(2); + let id = LutFile::identity(2).unwrap(); let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); let xml = format!("\"2\"\"{hex}\""); assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data); diff --git a/crates/cms/src/pipeline.rs b/crates/cms/src/pipeline.rs index 761a13c4..c0d0f7bc 100644 --- a/crates/cms/src/pipeline.rs +++ b/crates/cms/src/pipeline.rs @@ -4,7 +4,7 @@ use crate::clut::Clut; use crate::curve::Curve; -use crate::math; +use crate::{CmsError, check_len, invalid_arg, math}; /// Largest channel count flowing through a pipeline. pub const MAX: usize = 16; @@ -54,6 +54,25 @@ impl Stage { } } + /// Checks that the stage can run on `n` channels: matrix shapes match their data and + /// channel counts stay within [`MAX`]. + fn check(&self, n: usize) -> Result<(), CmsError> { + if let Stage::Matrix { rows, cols, m, offset } = self + && (!(1..=MAX).contains(rows) || !(1..=MAX).contains(cols) || m.len() != rows * cols || offset.len() != *rows) + { + return Err(invalid_arg(format!( + "{rows}×{cols} matrix stage with {} coefficients and {} offsets (1 to {MAX} rows and columns)", + m.len(), + offset.len() + ))); + } + let out = self.outputs(n); + if !(1..=MAX).contains(&out) { + return Err(invalid_arg(format!("pipeline stage with {out} outputs (1 to {MAX})"))); + } + Ok(()) + } + pub fn matrix3(m: &math::Mat3) -> Stage { Stage::Matrix { rows: 3, cols: 3, m: m.iter().flatten().copied().collect(), offset: vec![0.0; 3] } } @@ -89,7 +108,7 @@ impl Stage { let mut o = [0.0f32; MAX]; let mut inp = [0.0f32; MAX]; inp[..n].copy_from_slice(&v[..n]); - c.eval(&inp[..c.inputs], &mut o[..c.outputs]); + c.interp(&inp[..c.inputs], &mut o[..c.outputs]); v[..c.outputs].copy_from_slice(&o[..c.outputs]); c.outputs } @@ -152,28 +171,59 @@ impl Stage { /// A sequence of stages with fixed input and output channel counts. #[derive(Clone, Debug, PartialEq)] pub struct Pipeline { - pub inputs: usize, - pub outputs: usize, - pub stages: Vec, + inputs: usize, + outputs: usize, + stages: Vec, } impl Pipeline { - pub fn new(inputs: usize, stages: Vec) -> Self { - let outputs = stages.iter().fold(inputs, |n, s| s.outputs(n)); - Pipeline { inputs, outputs, stages } - } - - /// Appends stages. - pub fn extend(&mut self, stages: impl IntoIterator) { - for s in stages { - self.outputs = s.outputs(self.outputs); - self.stages.push(s); + /// A pipeline over `inputs` channels (1 to [`MAX`]). Refuses stages that cannot run on the + /// channels reaching them with [`CmsError::InvalidArgument`]. + pub fn new(inputs: usize, stages: Vec) -> Result { + if !(1..=MAX).contains(&inputs) { + return Err(invalid_arg(format!("pipeline with {inputs} inputs (1 to {MAX})"))); } + let mut p = Pipeline { inputs, outputs: inputs, stages: Vec::with_capacity(stages.len()) }; + p.extend(stages)?; + Ok(p) } - /// Evaluates one pixel: `input` has `inputs` values, `out` receives `outputs` values. + pub fn inputs(&self) -> usize { + self.inputs + } + pub fn outputs(&self) -> usize { + self.outputs + } + pub fn stages(&self) -> &[Stage] { + &self.stages + } + + /// Appends stages, checked as in [`Pipeline::new`]; on error the pipeline is unchanged. + pub fn extend(&mut self, stages: impl IntoIterator) -> Result<(), CmsError> { + let mut n = self.outputs; + let mut added = Vec::new(); + for s in stages { + s.check(n)?; + n = s.outputs(n); + added.push(s); + } + self.stages.extend(added); + self.outputs = n; + Ok(()) + } + + /// Evaluates one pixel: `input` has at least `inputs` values, `out` receives `outputs` + /// values. + pub fn eval(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> { + check_len("pipeline input", input.len(), self.inputs)?; + check_len("pipeline output", out.len(), self.outputs)?; + self.run(input, out); + Ok(()) + } + + /// [`Pipeline::eval`] for callers that guarantee the slice lengths. #[inline] - pub fn eval(&self, input: &[f32], out: &mut [f32]) { + pub(crate) fn run(&self, input: &[f32], out: &mut [f32]) { let mut v = [0.0f32; MAX]; v[..self.inputs].copy_from_slice(&input[..self.inputs]); let mut n = self.inputs; diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index 99f5710e..ef8cbcee 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -252,9 +252,9 @@ impl Profile { k /= steps; } let (mut la, mut lb) = ([0f32; 3], [0f32; 3]); - a.eval(&input[..n], &mut la); - b.eval(&input[..n], &mut lb); - math::delta_e76(decode(la), decode(lb)) <= SAME_COLORS_MAX_DELTA_E + a.eval(&input[..n], &mut la).is_ok() + && b.eval(&input[..n], &mut lb).is_ok() + && math::delta_e76(decode(la), decode(lb)) <= SAME_COLORS_MAX_DELTA_E }) } @@ -438,7 +438,7 @@ fn sig(s: &[u8; 4]) -> u32 { } /// Upper bound on CLUT nodes × outputs we accept, to reject hostile files. -const MAX_CLUT_VALUES: usize = 16 * 1024 * 1024; +const MAX_CLUT_VALUES: usize = crate::clut::MAX_VALUES; fn parse_profile(bytes: &[u8]) -> Result { let r = Reader { b: bytes }; @@ -674,7 +674,7 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { stages.push(Stage::matrix3(&m)); } stages.push(Stage::Curves(in_curves)); - stages.push(Stage::Clut(Clut::new(vec![g; i], o, data))); + stages.push(Stage::Clut(Clut::new(vec![g; i], o, data)?)); stages.push(Stage::Curves(out_curves)); Ok(Lut { kind: if wide { LutKind::Lut16 } else { LutKind::Lut8 }, inputs: i, outputs: o, stages }) } @@ -712,7 +712,7 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result { 2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), _ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))), }; - Ok(Stage::Clut(Clut::new(grid, cout, data))) + Ok(Stage::Clut(Clut::new(grid, cout, data)?)) }; let mut stages = Vec::new(); if ty == b"mAB " { diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index 63e077d5..8bed08eb 100644 --- a/crates/cms/src/synth.rs +++ b/crates/cms/src/synth.rs @@ -31,7 +31,7 @@ use crate::curve::Curve; use crate::math; use crate::pipeline::Stage; use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass}; -use crate::{CmsError, Intent}; +use crate::{CmsError, Intent, invalid_arg}; pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)"; @@ -168,7 +168,7 @@ struct TableForward(Clut); impl Forward for TableForward { fn lab(&self, cmyk: [f64; 4]) -> [f64; 3] { let mut o = [0.0f32; 3]; - self.0.eval(&cmyk.map(|v| v.clamp(0.0, 1.0) as f32), &mut o); + self.0.interp(&cmyk.map(|v| v.clamp(0.0, 1.0) as f32), &mut o); decode_v2(o.map(f64::from)) } } @@ -404,7 +404,7 @@ fn par_map(n: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec { (0..n).map(f).collect() } -fn b2a_table(model: &F, p: &CmykParams, gbd: Option<&Gbd>) -> Clut { +fn b2a_table(model: &F, p: &CmykParams, gbd: Option<&Gbd>) -> Result { let n = p.grid_b2a.max(2); let nodes = n * n * n; let vals = par_map(n * n, |row| { @@ -429,6 +429,17 @@ fn b2a_table(model: &F, p: &CmykParams, gbd: Option<&Gbd>) Clut::new(vec![n; 3], 4, data) } +/// Refuses grids below 2 nodes or above what a [`Clut`] holds, before anything is sampled. +fn check_grids(p: &CmykParams) -> Result<(), CmsError> { + let fits = |grid: usize, dims: u32, outputs: usize| { + grid >= 2 && grid.checked_pow(dims).and_then(|n| n.checked_mul(outputs)).is_some_and(|n| n <= crate::clut::MAX_VALUES) + }; + if !fits(p.grid_a2b, 4, 3) || !fits(p.grid_b2a, 3, 4) { + return Err(invalid_arg(format!("CMYK profile grids {}⁴/{}³ need at least 2 nodes and must fit a CLUT", p.grid_a2b, p.grid_b2a))); + } + Ok(()) +} + fn lut16(clut: Clut) -> Lut { let (i, o) = (clut.inputs, clut.outputs); Lut { @@ -439,16 +450,17 @@ fn lut16(clut: Clut) -> Lut { } } -/// Generates a synthetic CMYK profile. -pub fn cmyk_profile(p: &CmykParams) -> Profile { +/// Generates a synthetic CMYK profile. Grids are refused as in [`cmyk_profile_from_model`]. +pub fn cmyk_profile(p: &CmykParams) -> Result { + check_grids(p)?; let model = CmykModel::coated(p.tvi); - let a2b = sample_a2b(&model, p.grid_a2b); + let a2b = sample_a2b(&model, p.grid_a2b)?; lut_profile(&model, a2b, model.paper, p) } /// Samples a forward model into an AToB table with `grid`⁴ nodes (at least 2⁴) and legacy Lab /// encoding. -fn sample_a2b(model: &F, grid: usize) -> Clut { +fn sample_a2b(model: &F, grid: usize) -> Result { Clut::sample(vec![grid.max(2); 4], 3, |inp, out| { let lab = model.lab([inp[0] as f64, inp[1] as f64, inp[2] as f64, inp[3] as f64]); out.copy_from_slice(&encode_v2(lab)); @@ -458,27 +470,25 @@ fn sample_a2b(model: &F, grid: usize) -> Clut { /// A CMYK profile of any forward model on paper with absolute XYZ `paper`: the AToB table /// samples `model` on `p.grid_a2b`⁴ nodes, and the BToA tables invert that table rather than the /// model, so Lab → CMYK → Lab round trips through the profile are as close as the separation -/// allows. Grids below 2 nodes and a zero, negative or non-finite `paper` are refused with -/// [`CmsError::Invalid`]. +/// allows. Grids below 2 nodes or too large for a [`Clut`], and a zero, negative or +/// non-finite `paper`, are refused with [`CmsError::InvalidArgument`]. pub fn cmyk_profile_from_model(model: &F, paper: [f64; 3], p: &CmykParams) -> Result { - if p.grid_a2b < 2 || p.grid_b2a < 2 { - return Err(CmsError::Invalid(format!("CMYK profile grids {}⁴/{}³ need at least 2 nodes", p.grid_a2b, p.grid_b2a))); - } + check_grids(p)?; if !paper.iter().all(|v| v.is_finite() && *v > 0.0) { - return Err(CmsError::Invalid(format!("paper XYZ {paper:?}"))); + return Err(invalid_arg(format!("paper XYZ {paper:?}"))); } - let a2b = sample_a2b(model, p.grid_a2b); + let a2b = sample_a2b(model, p.grid_a2b)?; let forward = TableForward(a2b.clone()); - Ok(lut_profile(&forward, a2b, paper, p)) + lut_profile(&forward, a2b, paper, p) } -fn lut_profile(model: &F, a2b: Clut, paper: [f64; 3], p: &CmykParams) -> Profile { +fn lut_profile(model: &F, a2b: Clut, paper: [f64; 3], p: &CmykParams) -> Result { let gbd = Gbd::new(model, p); - let colorimetric = b2a_table(model, p, None); - let perceptual = b2a_table(model, p, Some(&gbd)); + let colorimetric = b2a_table(model, p, None)?; + let perceptual = b2a_table(model, p, Some(&gbd))?; let a2b = lut16(a2b); let (b0, b1) = (lut16(perceptual), lut16(colorimetric)); - Profile { + Ok(Profile { version: (4, 0x30), class: ProfileClass::Output, color_space: ColorSpace::Cmyk, @@ -497,11 +507,11 @@ fn lut_profile(model: &F, a2b: Clut, paper: [f64; 3], p: &C bytes: None, hash: Default::default(), } - .with_encoded_bytes() + .with_encoded_bytes()) } /// The default coated profile (what `profiles/photocraft-coated-cmyk.icc` contains). -pub fn coated_cmyk() -> Profile { +pub fn coated_cmyk() -> Result { cmyk_profile(&CmykParams::default()) } diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs index eeb2ed76..c8d26b15 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -9,7 +9,7 @@ use crate::curve::Curve; use crate::math; use crate::pipeline::{Pipeline, Stage}; use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass}; -use crate::{CmsError, Intent}; +use crate::{CmsError, Intent, check_len, invalid_arg}; /// Sample storage of a byte buffer for [`Transform::convert_bytes_many`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -101,7 +101,7 @@ impl Transform { pub fn with_options(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result { let stages = link_stages(src, dst, opts.intent, opts.bpc)?; - let mut t = Self::from_pipeline(Pipeline::new(src.channels(), stages), opts); + let mut t = Self::from_pipeline(Pipeline::new(src.channels(), stages)?, opts)?; if opts.intent != Intent::AbsoluteColorimetric { t.fix_white(src.color_space, dst.color_space); } @@ -117,15 +117,15 @@ impl Transform { stages.push(Stage::Clamp01); let (i2, b2) = if simulate_paper { (Intent::AbsoluteColorimetric, false) } else { (Intent::RelativeColorimetric, true) }; stages.extend(link_stages(proof, dst, i2, b2)?); - Ok(Self::from_pipeline(Pipeline::new(src.channels(), stages), TransformOptions { intent, bpc, precise_float: true })) + Self::from_pipeline(Pipeline::new(src.channels(), stages)?, TransformOptions { intent, bpc, precise_float: true }) } /// Builds a transform from an arbitrary pipeline over normalised values. - pub fn from_pipeline(mut pipeline: Pipeline, opts: TransformOptions) -> Transform { + pub fn from_pipeline(mut pipeline: Pipeline, opts: TransformOptions) -> Result { pipeline.optimize(); - let (inputs, outputs) = (pipeline.inputs, pipeline.outputs); + let (inputs, outputs) = (pipeline.inputs(), pipeline.outputs()); // Split trailing inverse curves into accurate output tables. - let mut head = pipeline.stages.clone(); + let mut head = pipeline.stages().to_vec(); let mut post = None; if let Some(Stage::InvCurves(cs)) = head.last() && cs.len() == outputs @@ -147,8 +147,13 @@ impl Transform { head.pop(); } } - let head_pipe = Pipeline::new(inputs, head.clone()); - let mid = head_pipe.outputs; + let head_pipe = Pipeline::new(inputs, head.clone())?; + let mid = head_pipe.outputs(); + let sampled = |grid: Vec| match Clut::sample(grid, mid, |i, o| head_pipe.run(i, o)) { + Ok(c) => Core::Grid(c), + // More outputs than a table holds: evaluate exactly. + Err(_) => Core::Exact, + }; let core = match head.as_slice() { [] => Core::Shaper { curves8: Vec::new(), curves: Vec::new(), matrix: None }, [Stage::Curves(cs)] if cs.len() == inputs => shaper(cs, None), @@ -158,10 +163,10 @@ impl Transform { // a grid would amplify interpolation error through steep output curves. _ if !head.iter().any(|s| matches!(s, Stage::Clut(_))) => Core::Exact, _ => match inputs { - 1 => Core::Grid(Clut::sample(vec![TABLE_1D], mid, |i, o| head_pipe.eval(i, o))), - 3 => Core::Grid(Clut::sample(vec![GRID_3D; 3], mid, |i, o| head_pipe.eval(i, o))), - 4 => Core::Grid(Clut::sample(vec![GRID_4D; 4], mid, |i, o| head_pipe.eval(i, o))), - 2 => Core::Grid(Clut::sample(vec![GRID_3D; 2], mid, |i, o| head_pipe.eval(i, o))), + 1 => sampled(vec![TABLE_1D]), + 3 => sampled(vec![GRID_3D; 3]), + 4 => sampled(vec![GRID_4D; 4]), + 2 => sampled(vec![GRID_3D; 2]), _ => Core::Exact, }, }; @@ -179,7 +184,7 @@ impl Transform { .collect(), _ => Vec::new(), }; - Transform { inputs, outputs, opts, pipeline, core, post, grid8 } + Ok(Transform { inputs, outputs, opts, pipeline, core, post, grid8 }) } /// White preservation: the device-link node at the source's device white gets exactly the @@ -226,24 +231,38 @@ impl Transform { &self.pipeline } - /// Exact evaluation of one colour (`inputs` → `outputs` normalised values). - #[inline] - pub fn eval(&self, input: &[f32], out: &mut [f32]) { - self.pipeline.eval(input, out); + /// Exact evaluation of one colour: `input` holds at least `inputs` normalised values, `out` + /// receives `outputs` values. + pub fn eval(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> { + self.check_io(input, out)?; + self.pipeline.run(input, out); + Ok(()) + } + + /// Lookup-table evaluation of one colour (what integer buffers use); slices as in + /// [`Transform::eval`]. + pub fn eval_fast(&self, input: &[f32], out: &mut [f32]) -> Result<(), CmsError> { + self.check_io(input, out)?; + self.fast(input, out); + Ok(()) + } + + fn check_io(&self, input: &[f32], out: &[f32]) -> Result<(), CmsError> { + check_len("transform input", input.len(), self.inputs)?; + check_len("transform output", out.len(), self.outputs) } - /// Lookup-table evaluation of one colour (what integer buffers use). #[inline] - pub fn eval_fast(&self, input: &[f32], out: &mut [f32]) { + fn fast(&self, input: &[f32], out: &mut [f32]) { let mut mid = [0.0f32; 16]; match &self.core { - Core::Exact => return self.pipeline.eval(input, out), + Core::Exact => return self.pipeline.run(input, out), Core::Grid(c) => { let mut inp = [0.0f32; 16]; for (d, s) in inp.iter_mut().zip(&input[..self.inputs]) { *d = s.clamp(0.0, 1.0); } - c.eval(&inp[..self.inputs], &mut mid[..c.outputs]); + c.interp(&inp[..self.inputs], &mut mid[..c.outputs]); } Core::Shaper { curves, matrix, .. } => { let mut v = [0.0f32; 16]; @@ -269,20 +288,24 @@ impl Transform { } /// Converts float pixels. `src`/`dst` hold whole pixels of `src_stride`/`dst_stride` - /// values; colour channels come first. Trailing channels are copied when `copy_extra`. - pub fn convert_f32(&self, src: &[f32], src_stride: usize, dst: &mut [f32], dst_stride: usize, copy_extra: bool) { - self.check(src_stride, dst_stride); + /// values (strides at least `inputs`/`outputs`); colour channels come first. `dst` holds at + /// least as many pixels as `src`; pixels past that are left alone. Trailing channels are + /// copied when `copy_extra`. Buffers that do not fit are refused with + /// [`CmsError::InvalidArgument`] before any pixel is written. + pub fn convert_f32(&self, src: &[f32], src_stride: usize, dst: &mut [f32], dst_stride: usize, copy_extra: bool) -> Result<(), CmsError> { + self.check_buffers(src.len(), src_stride, dst.len(), dst_stride)?; let extra = extra_channels(self, src_stride, dst_stride, copy_extra); par_rows(src, src_stride, dst, dst_stride, |s, d| self.run_f32(s, src_stride, d, dst_stride, extra)); + Ok(()) } fn run_f32(&self, s: &[f32], ss: usize, d: &mut [f32], ds: usize, extra: usize) { let precise = self.opts.precise_float || matches!(self.core, Core::Exact); for (sp, dp) in s.chunks_exact(ss).zip(d.chunks_exact_mut(ds)) { if precise { - self.pipeline.eval(sp, dp); + self.pipeline.run(sp, dp); } else { - self.eval_fast(sp, dp); + self.fast(sp, dp); } for e in 0..extra { dp[self.outputs + e] = sp[self.inputs + e]; @@ -290,29 +313,37 @@ impl Transform { } } - /// In-place float conversion; `stride ≥ max(inputs, outputs)`. - pub fn apply(&self, buf: &mut [f32], stride: usize) { - assert!(stride >= self.inputs.max(self.outputs), "stride too small"); + /// In-place float conversion of whole pixels of `stride ≥ max(inputs, outputs)` values. + pub fn apply(&self, buf: &mut [f32], stride: usize) -> Result<(), CmsError> { + let need = self.inputs.max(self.outputs); + if stride < need { + return Err(invalid_arg(format!("pixel stride {stride} is below the channel count {need}"))); + } + if !buf.len().is_multiple_of(stride) { + return Err(invalid_arg(format!("buffer of {} values is not whole pixels of {stride}", buf.len()))); + } let precise = self.opts.precise_float || matches!(self.core, Core::Exact); let body = |chunk: &mut [f32]| { let mut out = [0.0f32; 16]; for p in chunk.chunks_exact_mut(stride) { if precise { - self.pipeline.eval(p, &mut out); + self.pipeline.run(p, &mut out); } else { - self.eval_fast(p, &mut out); + self.fast(p, &mut out); } p[..self.outputs].copy_from_slice(&out[..self.outputs]); } }; par_inplace(buf, stride, body); + Ok(()) } /// Converts 8-bit pixels (see [`Transform::convert_f32`] for the layout). - pub fn convert_u8(&self, src: &[u8], src_stride: usize, dst: &mut [u8], dst_stride: usize, copy_extra: bool) { - self.check(src_stride, dst_stride); + pub fn convert_u8(&self, src: &[u8], src_stride: usize, dst: &mut [u8], dst_stride: usize, copy_extra: bool) -> Result<(), CmsError> { + self.check_buffers(src.len(), src_stride, dst.len(), dst_stride)?; let extra = extra_channels(self, src_stride, dst_stride, copy_extra); par_rows(src, src_stride, dst, dst_stride, |s, d| self.run_u8(s, src_stride, d, dst_stride, extra)); + Ok(()) } fn run_u8(&self, s: &[u8], ss: usize, d: &mut [u8], ds: usize, extra: usize) { @@ -384,7 +415,7 @@ impl Transform { for k in 0..self.inputs { inp[k] = sp[k] as f32 / 255.0; } - self.eval_fast(&inp, &mut out); + self.fast(&inp, &mut out); for k in 0..self.outputs { dp[k] = q(out[k]); } @@ -397,10 +428,11 @@ impl Transform { } /// Converts 16-bit pixels (see [`Transform::convert_f32`] for the layout). - pub fn convert_u16(&self, src: &[u16], src_stride: usize, dst: &mut [u16], dst_stride: usize, copy_extra: bool) { - self.check(src_stride, dst_stride); + pub fn convert_u16(&self, src: &[u16], src_stride: usize, dst: &mut [u16], dst_stride: usize, copy_extra: bool) -> Result<(), CmsError> { + self.check_buffers(src.len(), src_stride, dst.len(), dst_stride)?; let extra = extra_channels(self, src_stride, dst_stride, copy_extra); par_rows(src, src_stride, dst, dst_stride, |s, d| self.run_u16(s, src_stride, d, dst_stride, extra)); + Ok(()) } fn run_u16(&self, s: &[u16], ss: usize, d: &mut [u16], ds: usize, extra: usize) { @@ -410,7 +442,7 @@ impl Transform { for k in 0..self.inputs { inp[k] = sp[k] as f32 / 65535.0; } - self.eval_fast(&inp, &mut out); + self.fast(&inp, &mut out); for k in 0..self.outputs { dp[k] = (out[k].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; } @@ -422,9 +454,27 @@ impl Transform { /// Converts many independent buffers of native-endian samples in one parallel pass (e.g. /// all tiles of a surface): far cheaper than one parallel call per small buffer. Each job - /// is `(src bytes, dst bytes)` holding whole pixels of `src_stride`/`dst_stride` samples. - pub fn convert_bytes_many(&self, sample: SampleKind, jobs: Vec<(&[u8], &mut [u8])>, src_stride: usize, dst_stride: usize, copy_extra: bool) { - self.check(src_stride, dst_stride); + /// is `(src bytes, dst bytes)` holding whole pixels of `src_stride`/`dst_stride` samples, + /// as in [`Transform::convert_f32`]. Every job is checked before any is converted. + pub fn convert_bytes_many( + &self, + sample: SampleKind, + jobs: Vec<(&[u8], &mut [u8])>, + src_stride: usize, + dst_stride: usize, + copy_extra: bool, + ) -> Result<(), CmsError> { + let size = match sample { + SampleKind::U8 => 1, + SampleKind::U16 => 2, + SampleKind::F32 => 4, + }; + for (s, d) in &jobs { + if !s.len().is_multiple_of(size) || !d.len().is_multiple_of(size) { + return Err(invalid_arg(format!("byte buffers of {}/{} bytes are not whole {size}-byte samples", s.len(), d.len()))); + } + self.check_buffers(s.len() / size, src_stride, d.len() / size, dst_stride)?; + } let extra = extra_channels(self, src_stride, dst_stride, copy_extra); let run = |(s, d): (&[u8], &mut [u8])| match sample { SampleKind::U8 => self.run_u8(s, src_stride, d, dst_stride, extra), @@ -452,10 +502,21 @@ impl Transform { } #[cfg(target_arch = "wasm32")] jobs.into_iter().for_each(run); + Ok(()) } - fn check(&self, ss: usize, ds: usize) { - assert!(ss >= self.inputs && ds >= self.outputs, "pixel stride smaller than the channel count"); + /// Checks a conversion's buffers once per call, so the per-pixel loops need no checks. + fn check_buffers(&self, src_len: usize, ss: usize, dst_len: usize, ds: usize) -> Result<(), CmsError> { + if ss < self.inputs || ds < self.outputs { + return Err(invalid_arg(format!("pixel strides {ss}/{ds} are below the channel counts {}/{}", self.inputs, self.outputs))); + } + if !src_len.is_multiple_of(ss) || !dst_len.is_multiple_of(ds) { + return Err(invalid_arg(format!("buffers of {src_len}/{dst_len} samples are not whole pixels of {ss}/{ds} samples"))); + } + if dst_len / ds < src_len / ss { + return Err(invalid_arg(format!("destination holds {} pixels, source {}", dst_len / ds, src_len / ss))); + } + Ok(()) } } @@ -644,21 +705,21 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result= 4 && matches!(intent, Intent::Perceptual | Intent::Saturation) { return Ok(PERCEPTUAL_BLACK[1]); } - let pcs_y = |st: Vec, pcs: Pcs, dev: &[f32]| -> f64 { - let pipe = Pipeline::new(p.channels(), st); + let pcs_y = |st: Vec, pcs: Pcs, dev: &[f32]| -> Result { + let pipe = Pipeline::new(p.channels(), st)?; let mut o = [0.0f32; 16]; - pipe.eval(dev, &mut o); + pipe.eval(dev, &mut o)?; let y = match pcs { Pcs::Xyz => o[1] as f64, Pcs::Lab => math::lab_to_xyz([o[0] as f64, 0.0, 0.0], math::D50)[1], }; - y.clamp(0.0, 0.5) + Ok(y.clamp(0.0, 0.5)) }; let (to_pcs, pcs) = p.device_to_pcs(intent)?; let additive = matches!(p.color_space, ColorSpace::Rgb | ColorSpace::Gray); if additive || !lut_based { let zero = vec![0.0f32; p.channels()]; - return Ok(pcs_y(to_pcs, pcs, &zero)); + return pcs_y(to_pcs, pcs, &zero); } // Subtractive LUT profile: darkest reachable colour. let colorimetric = if intent == Intent::Saturation { Intent::Saturation } else { Intent::RelativeColorimetric }; @@ -669,14 +730,14 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result { let ones = vec![1.0f32; p.channels()]; - Ok(pcs_y(to_pcs, pcs, &ones)) + pcs_y(to_pcs, pcs, &ones) } } } diff --git a/crates/cms/tests/bench.rs b/crates/cms/tests/bench.rs index 07ffe711..1d140886 100644 --- a/crates/cms/tests/bench.rs +++ b/crates/cms/tests/bench.rs @@ -19,11 +19,11 @@ fn bench_srgb8_to_cmyk8() { } let mut dst = vec![0u8; n * 5]; let t1 = std::time::Instant::now(); - t.convert_u8(&src, 4, &mut dst, 5, true); + t.convert_u8(&src, 4, &mut dst, 5, true).unwrap(); eprintln!("convert 6016² RGBA8 → CMYKA8 (one buffer): {:?}", t1.elapsed()); let t2 = std::time::Instant::now(); for (s, d) in src.chunks(65536 * 4).zip(dst.chunks_mut(65536 * 5)) { - t.convert_u8(s, 4, d, 5, true); + t.convert_u8(s, 4, d, 5, true).unwrap(); } eprintln!("convert per 256² tile: {:?}", t2.elapsed()); } diff --git a/crates/cms/tests/cms.rs b/crates/cms/tests/cms.rs index b39563d8..a4717859 100644 --- a/crates/cms/tests/cms.rs +++ b/crates/cms/tests/cms.rs @@ -26,7 +26,7 @@ fn enc(l: [f64; 3]) -> [f32; 3] { fn to_lab(p: &Profile, dev: &[f32], intent: Intent) -> [f64; 3] { let t = Transform::new(p, lab(), intent, false).unwrap(); let mut o = [0.0f32; 3]; - t.eval(dev, &mut o); + t.eval(dev, &mut o).unwrap(); dec(&o) } @@ -83,16 +83,16 @@ fn rgb_lab_rgb_roundtrip_8bit_and_float() { } let f: Vec = src8.iter().map(|v| *v as f32 / 255.0).collect(); let mut labf = vec![0.0f32; f.len()]; - to.convert_f32(&f, 3, &mut labf, 3, false); + to.convert_f32(&f, 3, &mut labf, 3, false).unwrap(); let mut rgbf = vec![0.0f32; f.len()]; - back.convert_f32(&labf, 3, &mut rgbf, 3, false); + back.convert_f32(&labf, 3, &mut rgbf, 3, false).unwrap(); for (a, b) in src8.iter().zip(&rgbf) { assert!((*a as f32 - b * 255.0).abs() <= 1.0, "{a} vs {}", b * 255.0); } // 8-bit RGB stored as 16-bit Lab then back to 8-bit through the lookup tables. let lab16: Vec = labf.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect(); let mut rgb16 = vec![0u16; lab16.len()]; - back.convert_u16(&lab16, 3, &mut rgb16, 3, false); + back.convert_u16(&lab16, 3, &mut rgb16, 3, false).unwrap(); for (a, b) in src8.iter().zip(&rgb16) { assert!((*a as f32 - *b as f32 / 257.0).abs() <= 1.0, "{a} vs {}", *b as f32 / 257.0); } @@ -102,7 +102,7 @@ fn rgb_lab_rgb_roundtrip_8bit_and_float() { } // Fast 8-bit path: RGB8 → Lab8-grid → compare with exact within 1 level of Lab encoding. let mut lab8 = vec![0u8; src8.len()]; - to.convert_u8(&src8, 3, &mut lab8, 3, false); + to.convert_u8(&src8, 3, &mut lab8, 3, false).unwrap(); for (q, e) in lab8.iter().zip(&labf) { assert!((*q as f32 - e * 255.0).abs() <= 1.0); } @@ -115,10 +115,10 @@ fn rgb_to_rgb_matrix_shaper_paths_agree() { let t = Transform::new(srgb(), dst.profile(), Intent::RelativeColorimetric, false).unwrap(); let src: Vec = (0..=255u8).flat_map(|v| [v, v / 2, 255 - v]).collect(); let mut out = vec![0u8; src.len()]; - t.convert_u8(&src, 3, &mut out, 3, false); + t.convert_u8(&src, 3, &mut out, 3, false).unwrap(); for (px, o) in src.chunks(3).zip(out.chunks(3)) { let mut e = [0.0f32; 3]; - t.eval(&[px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0], &mut e); + t.eval(&[px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0], &mut e).unwrap(); for k in 0..3 { assert!((o[k] as f32 - e[k].clamp(0.0, 1.0) * 255.0).abs() <= 0.6, "{dst:?} {px:?}: {o:?} vs {e:?}"); } @@ -131,7 +131,7 @@ fn identity_transform_is_identity() { let t = Transform::new(srgb(), srgb(), Intent::RelativeColorimetric, true).unwrap(); let src: Vec = (0..=255u8).flat_map(|v| [v, 255 - v, v / 3, 77]).collect(); let mut out = vec![0u8; src.len()]; - t.convert_u8(&src, 4, &mut out, 4, true); + t.convert_u8(&src, 4, &mut out, 4, true).unwrap(); assert_eq!(src, out); } @@ -162,13 +162,13 @@ fn cmyk_profile_is_monotonic_and_in_range() { for v in (0..=255).rev().step_by(5) { let g = v as f32 / 255.0; let mut o = [0.0f32; 4]; - t.eval(&[g, g, g], &mut o); + t.eval(&[g, g, g], &mut o).unwrap(); assert!(o.iter().all(|c| (-1e-6..=1.0 + 1e-6).contains(c)), "{o:?}"); assert!(o.iter().sum::() <= 3.0 + 0.02, "TAC {o:?}"); assert!(o[3] >= prev_k - 0.01, "{intent:?} K not monotonic at {v}: {o:?}"); prev_k = o[3]; let mut l = [0.0f32; 3]; - back.eval(&o, &mut l); + back.eval(&o, &mut l).unwrap(); let lab = dec(&l); assert!(lab[0] <= prev_l + 0.3, "{intent:?} L* not monotonic at {v}: {lab:?}"); assert!(lab[1].abs() < 3.0 && lab[2].abs() < 3.0, "{intent:?} gray {v} not neutral: {lab:?}"); @@ -179,7 +179,7 @@ fn cmyk_profile_is_monotonic_and_in_range() { let t = Transform::new(srgb(), p, Intent::RelativeColorimetric, true).unwrap(); let src: Vec = (0..4096u32).flat_map(|i| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 13 % 256) as u8]).collect(); let mut out = vec![0u8; 4096 * 4]; - t.convert_u8(&src, 3, &mut out, 4, false); + t.convert_u8(&src, 3, &mut out, 4, false).unwrap(); for px in out.chunks(4) { assert!(px.iter().map(|v| *v as u32).sum::() <= 3 * 255 + 8, "{px:?}"); } @@ -191,9 +191,9 @@ fn in_gamut_colours_survive_cmyk_roundtrip() { let back = Transform::new(cmyk(), lab(), Intent::RelativeColorimetric, false).unwrap(); for target in [[50.0, 0.0, 0.0], [70.0, 10.0, 10.0], [60.0, -20.0, -20.0], [80.0, 0.0, 40.0], [40.0, 30.0, -10.0]] { let mut c = [0.0f32; 4]; - to.eval(&enc(target), &mut c); + to.eval(&enc(target), &mut c).unwrap(); let mut l = [0.0f32; 3]; - back.eval(&c, &mut l); + back.eval(&c, &mut l).unwrap(); assert!(delta_e76(dec(&l), target) < 2.0, "{target:?} -> {c:?} -> {:?}", dec(&l)); } } @@ -205,9 +205,9 @@ fn intents_differ_as_expected() { let sat = Transform::new(srgb(), cmyk(), Intent::Saturation, false).unwrap(); let (mut a, mut b, mut s) = ([0.0f32; 4], [0.0f32; 4], [0.0f32; 4]); // Out-of-gamut sRGB blue: perceptual compresses, relative clips: different separations. - per.eval(&[0.0, 0.2, 1.0], &mut a); - rel.eval(&[0.0, 0.2, 1.0], &mut b); - sat.eval(&[0.0, 0.2, 1.0], &mut s); + per.eval(&[0.0, 0.2, 1.0], &mut a).unwrap(); + rel.eval(&[0.0, 0.2, 1.0], &mut b).unwrap(); + sat.eval(&[0.0, 0.2, 1.0], &mut s).unwrap(); let d: f32 = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).sum(); assert!(d > 0.03, "perceptual {a:?} vs relative {b:?}"); assert_eq!(a, s, "saturation shares the perceptual table"); @@ -216,8 +216,8 @@ fn intents_differ_as_expected() { let rel = Transform::new(cmyk(), srgb(), Intent::RelativeColorimetric, false).unwrap(); let abs = Transform::new(cmyk(), srgb(), Intent::AbsoluteColorimetric, false).unwrap(); let (mut w1, mut w2) = ([0.0f32; 3], [0.0f32; 3]); - rel.eval(&[0.0; 4], &mut w1); - abs.eval(&[0.0; 4], &mut w2); + rel.eval(&[0.0; 4], &mut w1).unwrap(); + abs.eval(&[0.0; 4], &mut w2).unwrap(); assert!(w1.iter().all(|v| *v > 0.995), "{w1:?}"); assert!(w2[0] < 0.97 && w2[2] > w2[0], "{w2:?}"); let l = to_lab(cmyk(), &[0.0; 4], Intent::AbsoluteColorimetric); @@ -259,7 +259,7 @@ fn absolute_colorimetric_refuses_a_degenerate_media_white() { Err(e) => panic!("{name}: wrong error {e}"), Ok(t) => { let mut out = [0.0f32; 3]; - t.eval(&[0.2, 0.4, 0.6], &mut out); + t.eval(&[0.2, 0.4, 0.6], &mut out).unwrap(); panic!("{name}: built a transform that gives {out:?}"); } } @@ -270,17 +270,17 @@ fn absolute_colorimetric_refuses_a_degenerate_media_white() { // Without paper simulation the proof leg is relative colorimetric and stays finite. let t = Transform::proof(srgb(), bad, srgb(), Intent::RelativeColorimetric, true, false).unwrap(); let mut out = [0.0f32; 3]; - t.eval(&[0.2, 0.4, 0.6], &mut out); + t.eval(&[0.2, 0.4, 0.6], &mut out).unwrap(); assert!(out.iter().all(|v| v.is_finite()), "{name}: {out:?}"); let t = Transform::new(srgb(), bad, Intent::RelativeColorimetric, false).unwrap(); - t.eval(&[0.2, 0.4, 0.6], &mut out); + t.eval(&[0.2, 0.4, 0.6], &mut out).unwrap(); assert!(out.iter().all(|v| v.is_finite()), "{name}: {out:?}"); } // A sane patched white still builds and scales. let d50 = srgb_with_wtpt([0xF6D6, 0x10000, 0xD32D]); let t = Transform::new(srgb(), &d50, Intent::AbsoluteColorimetric, false).unwrap(); let mut out = [0.0f32; 3]; - t.eval(&[0.2, 0.4, 0.6], &mut out); + t.eval(&[0.2, 0.4, 0.6], &mut out).unwrap(); assert!(out.iter().all(|v| v.is_finite()), "{out:?}"); } @@ -297,9 +297,9 @@ fn black_point_compensation() { let back = Transform::new(cmyk(), lab(), Intent::RelativeColorimetric, false).unwrap(); let l_of = |t: &Transform, v: f32| { let mut c = [0.0f32; 4]; - t.eval(&[v, v, v], &mut c); + t.eval(&[v, v, v], &mut c).unwrap(); let mut l = [0.0f32; 3]; - back.eval(&c, &mut l); + back.eval(&c, &mut l).unwrap(); dec(&l)[0] }; let (w0, w1) = (l_of(&with, 0.0), l_of(&with, 0.08)); @@ -309,14 +309,14 @@ fn black_point_compensation() { // CMYK → sRGB: with BPC the darkest CMYK maps to (near) sRGB black. let dark = { let mut c = [0.0f32; 4]; - with.eval(&[0.0, 0.0, 0.0], &mut c); + with.eval(&[0.0, 0.0, 0.0], &mut c).unwrap(); c }; let rgb_with = Transform::new(cmyk(), srgb(), Intent::RelativeColorimetric, true).unwrap(); let rgb_without = Transform::new(cmyk(), srgb(), Intent::RelativeColorimetric, false).unwrap(); let (mut a, mut b) = ([0.0f32; 3], [0.0f32; 3]); - rgb_with.eval(&dark, &mut a); - rgb_without.eval(&dark, &mut b); + rgb_with.eval(&dark, &mut a).unwrap(); + rgb_without.eval(&dark, &mut b).unwrap(); assert!(a[1] < 0.03 && b[1] > a[1] + 0.03, "with {a:?} without {b:?}"); } @@ -324,7 +324,7 @@ fn black_point_compensation() { fn tetrahedral_beats_trilinear() { // Sample sRGB → Lab on a coarse grid and compare interpolation against the exact pipeline. let exact = Transform::new(srgb(), lab(), Intent::RelativeColorimetric, false).unwrap(); - let clut = Clut::sample(vec![9, 9, 9], 3, |i, o| exact.eval(i, o)); + let clut = Clut::sample(vec![9, 9, 9], 3, |i, o| exact.eval(i, o).unwrap()).unwrap(); let (mut tet_max, mut tri_max, mut tet_sum, mut tri_sum, mut tet_neutral, mut tri_neutral) = (0.0f64, 0.0f64, 0.0, 0.0, 0.0f64, 0.0f64); let mut n = 0.0; let mut seed = 12345u32; @@ -340,9 +340,9 @@ fn tetrahedral_beats_trilinear() { [rnd(), rnd(), rnd()] }; let (mut e, mut a, mut b) = ([0.0f32; 3], [0.0f32; 3], [0.0f32; 3]); - exact.eval(&p, &mut e); - clut.eval(&p, &mut a); - clut.eval_trilinear(&p, &mut b); + exact.eval(&p, &mut e).unwrap(); + clut.eval(&p, &mut a).unwrap(); + clut.eval_trilinear(&p, &mut b).unwrap(); let (da, db) = (delta_e76(dec(&a), dec(&e)), delta_e76(dec(&b), dec(&e))); if i < 200 { tet_neutral = tet_neutral.max(dec(&a)[1].abs().max(dec(&a)[2].abs())); @@ -366,24 +366,24 @@ fn proof_and_display_lut() { let p = Transform::proof(srgb(), cmyk(), srgb(), Intent::RelativeColorimetric, true, false).unwrap(); let mut o = [0.0f32; 3]; // Neutral gray stays (nearly) neutral through the proof. - p.eval(&[0.5, 0.5, 0.5], &mut o); + p.eval(&[0.5, 0.5, 0.5], &mut o).unwrap(); assert!((o[0] - o[1]).abs() < 0.02 && (o[1] - o[2]).abs() < 0.03, "{o:?}"); // Saturated blue is desaturated by the CMYK gamut. - p.eval(&[0.0, 0.0, 1.0], &mut o); + p.eval(&[0.0, 0.0, 1.0], &mut o).unwrap(); assert!(o[0] > 0.1 || o[1] > 0.1, "{o:?}"); // Paper simulation darkens white. let sim = Transform::proof(srgb(), cmyk(), srgb(), Intent::RelativeColorimetric, true, true).unwrap(); - sim.eval(&[1.0, 1.0, 1.0], &mut o); + sim.eval(&[1.0, 1.0, 1.0], &mut o).unwrap(); assert!(o[0] < 0.97, "{o:?}"); let t = Transform::new(srgb(), Builtin::DisplayP3.profile(), Intent::RelativeColorimetric, false).unwrap(); - let lut = Lut3d::from_transform(&t, 33); + let lut = Lut3d::from_transform(&t, 33).unwrap(); assert_eq!(lut.to_rgba16f_bytes().len(), 33 * 33 * 33 * 8); let mut worst = 0.0f32; for i in 0..500u32 { let c = [(i * 7 % 101) as f32 / 100.0, (i * 13 % 101) as f32 / 100.0, (i * 29 % 101) as f32 / 100.0]; let mut e = [0.0f32; 3]; - t.eval(&c, &mut e); + t.eval(&c, &mut e).unwrap(); let s = lut.sample(c); for k in 0..3 { worst = worst.max((s[k] - e[k]).abs()); @@ -397,16 +397,16 @@ fn gray_profiles() { let t = Transform::new(Builtin::SGray.profile(), srgb(), Intent::RelativeColorimetric, false).unwrap(); for v in [0.0f32, 0.2, 0.5, 0.9, 1.0] { let mut o = [0.0f32; 3]; - t.eval(&[v], &mut o); + t.eval(&[v], &mut o).unwrap(); for c in o { assert!((c - v).abs() < 1e-3, "sGray {v} -> {o:?}"); } } let t = Transform::new(srgb(), Builtin::GrayGamma22.profile(), Intent::RelativeColorimetric, false).unwrap(); let mut o = [0.0f32; 1]; - t.eval(&[1.0, 1.0, 1.0], &mut o); + t.eval(&[1.0, 1.0, 1.0], &mut o).unwrap(); assert!((o[0] - 1.0).abs() < 1e-3); - t.eval(&[0.0, 0.0, 0.0], &mut o); + t.eval(&[0.0, 0.0, 0.0], &mut o).unwrap(); assert!(o[0].abs() < 1e-3); } @@ -423,8 +423,8 @@ fn hdr_floats_survive_matrix_transforms() { let src = [4.0f32, 2.0, 0.5]; let mut mid = [0.0f32; 3]; let mut out = [0.0f32; 3]; - t.eval(&src, &mut mid); - back.eval(&mid, &mut out); + t.eval(&src, &mut mid).unwrap(); + back.eval(&mid, &mut out).unwrap(); for k in 0..3 { assert!((out[k] - src[k]).abs() < 1e-3 * src[k].max(1.0), "{src:?} -> {mid:?} -> {out:?}"); } @@ -482,9 +482,12 @@ fn a_light_black_cmyk_lut_has_the_known_relative_ramp() { outputs: 3, stages: vec![ Stage::Curves(vec![Curve::Identity; 4]), - Stage::Clut(Clut::sample(vec![2; 4], 3, |ink, out| { - out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0])); - })), + Stage::Clut( + Clut::sample(vec![2; 4], 3, |ink, out| { + out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0])); + }) + .unwrap(), + ), Stage::Curves(vec![Curve::Identity; 3]), ], }); @@ -557,7 +560,7 @@ fn system_profiles() { let t = Transform::new(&p, lab(), intent, true).unwrap_or_else(|e| panic!("{} {intent:?}: {e}", path.display())); let n = p.channels(); let mut o = [0.0f32; 3]; - t.eval(&vec![0.5; n], &mut o); + t.eval(&vec![0.5; n], &mut o).unwrap(); assert!(o.iter().all(|v| v.is_finite()), "{}", path.display()); } // Additive white is (near) L* 100 for display profiles in relative colorimetric. @@ -576,9 +579,9 @@ fn system_profiles() { let to = Transform::new(lab(), &p, Intent::RelativeColorimetric, false).unwrap(); let back = Transform::new(&p, lab(), Intent::RelativeColorimetric, false).unwrap(); let mut c = [0.0f32; 4]; - to.eval(&enc([60.0, 10.0, -10.0]), &mut c); + to.eval(&enc([60.0, 10.0, -10.0]), &mut c).unwrap(); let mut l = [0.0f32; 3]; - back.eval(&c, &mut l); + back.eval(&c, &mut l).unwrap(); assert!(delta_e76(dec(&l), [60.0, 10.0, -10.0]) < 3.0, "{}: {:?}", path.display(), dec(&l)); } } @@ -601,7 +604,7 @@ fn oracle_moxcms_rgb() { mt.transform(&src, &mut theirs).unwrap(); let ours_t = Transform::new(srgb(), dst.profile(), Intent::RelativeColorimetric, false).unwrap(); let mut ours = vec![0u8; src.len()]; - ours_t.convert_u8(&src, 3, &mut ours, 3, false); + ours_t.convert_u8(&src, 3, &mut ours, 3, false).unwrap(); let worst = ours.iter().zip(&theirs).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap(); assert!(worst <= 2, "{dst:?}: max diff {worst}"); } @@ -622,7 +625,7 @@ fn oracle_moxcms_system_cmyk() { mt.transform(&src, &mut theirs).unwrap(); let t = Transform::new(&ours_p, srgb(), Intent::RelativeColorimetric, false).unwrap(); let mut ours = vec![0u8; 2048 * 3]; - t.convert_u8(&src, 4, &mut ours, 3, false); + t.convert_u8(&src, 4, &mut ours, 3, false).unwrap(); let diffs: Vec = ours.iter().zip(&theirs).map(|(a, b)| (*a as i32 - *b as i32).abs()).collect(); let worst = *diffs.iter().max().unwrap(); let mean = diffs.iter().sum::() as f64 / diffs.len() as f64; @@ -824,3 +827,69 @@ fn device_links_parse_and_are_reported_unsupported() { // The output space still bounds the tag: a CMYK→CMYK link with a 4→3 table is malformed. assert!(matches!(Profile::parse(&device_link(b"CMYK", b"CMYK", 4, 3)), Err(craft_cms::CmsError::Invalid(_)))); } + +fn is_arg_err(r: Result) -> bool { + matches!(r, Err(craft_cms::CmsError::InvalidArgument(_))) +} + +/// Strides, buffer lengths and slices that do not fit are refused before anything is written. +#[test] +fn transform_rejects_bad_buffers() { + let t = Transform::new(srgb(), cmyk(), Intent::RelativeColorimetric, true).unwrap(); + let src = [255u8; 12]; + let mut dst = [7u8; 16]; + assert!(is_arg_err(t.convert_u8(&src, 2, &mut dst, 4, false)), "source stride below 3 inputs"); + assert!(is_arg_err(t.convert_u8(&src, 3, &mut dst, 3, false)), "destination stride below 4 outputs"); + assert!(is_arg_err(t.convert_u8(&src[..11], 3, &mut dst, 4, false)), "partial source pixel"); + assert!(is_arg_err(t.convert_u8(&src, 3, &mut dst[..15], 4, false)), "partial destination pixel"); + assert!(is_arg_err(t.convert_u8(&src, 3, &mut dst[..12], 4, false)), "destination too short"); + assert_eq!(dst, [7u8; 16], "refused calls write nothing"); + t.convert_u8(&src, 3, &mut dst, 4, false).unwrap(); + assert!(is_arg_err(t.convert_u16(&[0u16; 6], 3, &mut [0u16; 4], 4, false))); + assert!(is_arg_err(t.convert_f32(&[0.0; 7], 3, &mut [0.0; 12], 4, false))); + assert!(is_arg_err(t.apply(&mut [0.0; 12], 3)), "in-place stride below 4 outputs"); + assert!(is_arg_err(t.apply(&mut [0.0; 10], 4)), "partial in-place pixel"); + let (s, mut d) = ([0u8; 6], [0u8; 7]); + assert!(is_arg_err(t.convert_bytes_many(craft_cms::SampleKind::U16, vec![(&s[..], &mut d[..])], 3, 4, false))); + let mut out = [0.0f32; 4]; + assert!(is_arg_err(t.eval(&[1.0, 1.0], &mut out))); + assert!(is_arg_err(t.eval(&[1.0; 3], &mut out[..3]))); + assert!(is_arg_err(t.eval_fast(&[1.0, 1.0], &mut out))); + let g = craft_cms::GamutCheck::new(srgb(), cmyk(), 4.0).unwrap(); + assert!(is_arg_err(g.delta_e(&[0.5, 0.5]))); + assert!(is_arg_err(Lut3d::from_transform(&t, 33)), "3→4 transform"); + let d = Transform::new(srgb(), Builtin::DisplayP3.profile(), Intent::RelativeColorimetric, true).unwrap(); + assert!(is_arg_err(Lut3d::from_transform(&d, 1))); + assert!(is_arg_err(Lut3d::from_transform(&d, 100_000))); +} + +/// Table and pipeline dimensions that do not fit are refused. +#[test] +fn tables_and_pipelines_reject_bad_dimensions() { + use craft_cms::pipeline::{Pipeline, Stage}; + assert!(is_arg_err(Clut::new(vec![2, 2], 3, vec![0.0; 11])), "data length"); + assert!(is_arg_err(Clut::new(vec![2, 0], 3, Vec::new())), "zero grid points"); + assert!(is_arg_err(Clut::new(vec![2; 16], 1, vec![0.0; 1 << 16])), "16 inputs"); + assert!(is_arg_err(Clut::new(vec![2], 0, Vec::new())), "no outputs"); + assert!(is_arg_err(Clut::sample(vec![usize::MAX; 3], 3, |_, _| {})), "overflowing grid"); + assert!(is_arg_err(Clut::sample(vec![1000; 3], 3, |_, _| {})), "grid over the size limit"); + let c = Clut::sample(vec![2; 4], 3, |_, _| {}).unwrap(); + assert!(is_arg_err(c.eval(&[0.0; 3], &mut [0.0; 3]))); + assert!(is_arg_err(c.eval(&[0.0; 4], &mut [0.0; 2]))); + assert!(is_arg_err(c.eval_trilinear(&[0.0; 4], &mut [0.0; 3])), "trilinear needs 3 inputs"); + assert!(is_arg_err(Pipeline::new(0, Vec::new()))); + assert!(is_arg_err(Pipeline::new(17, Vec::new()))); + let bad = Stage::Matrix { rows: 3, cols: 3, m: vec![1.0; 8], offset: vec![0.0; 3] }; + assert!(is_arg_err(Pipeline::new(3, vec![bad.clone()])), "matrix data shorter than its shape"); + let wide = Stage::Matrix { rows: 40, cols: 1, m: vec![1.0; 40], offset: vec![0.0; 40] }; + assert!(is_arg_err(Pipeline::new(1, vec![wide])), "too many rows"); + let mut p = Pipeline::new(3, vec![Stage::Clamp01]).unwrap(); + assert!(is_arg_err(p.extend([bad]))); + assert_eq!(p.stages().len(), 1, "a refused extend leaves the pipeline unchanged"); + assert!(is_arg_err(p.eval(&[0.0; 2], &mut [0.0; 3]))); + assert!(craft_cms::lutfile::LutFile::from_fn("x", 1, |c| c).is_err()); + assert!(craft_cms::lutfile::LutFile::identity(1 << 20).is_err()); + let params = |a2b, b2a| craft_cms::synth::CmykParams { grid_a2b: a2b, grid_b2a: b2a, ..Default::default() }; + assert!(is_arg_err(craft_cms::synth::cmyk_profile(¶ms(1, 21)))); + assert!(is_arg_err(craft_cms::synth::cmyk_profile(¶ms(11, 100_000)))); +} diff --git a/crates/cms/tests/fogra39.rs b/crates/cms/tests/fogra39.rs index dfa5a153..27412ecf 100644 --- a/crates/cms/tests/fogra39.rs +++ b/crates/cms/tests/fogra39.rs @@ -84,7 +84,7 @@ fn profile_lab(profile: &Profile) -> impl Fn([f64; 4]) -> [f64; 3] { let t = Transform::new(profile, Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap(); move |c| { let mut o = [0.0f32; 3]; - t.eval(&c.map(|v| v as f32), &mut o); + t.eval(&c.map(|v| v as f32), &mut o).unwrap(); [o[0] as f64 * 100.0, o[1] as f64 * 255.0 - 128.0, o[2] as f64 * 255.0 - 128.0] } } @@ -444,7 +444,7 @@ fn in_gamut_lab_round_trips() { let target = to_lab(p.cmyk); let enc = [(target[0] / 100.0) as f32, ((target[1] + 128.0) / 255.0) as f32, ((target[2] + 128.0) / 255.0) as f32]; let mut c = [0.0f32; 4]; - to_cmyk.eval(&enc, &mut c); + to_cmyk.eval(&enc, &mut c).unwrap(); assert!(c.iter().map(|v| *v as f64).sum::() <= 3.3 + 0.02, "{target:?} -> {c:?}"); delta_e2000(to_lab(c.map(f64::from)), target) }) @@ -473,7 +473,7 @@ fn to_srgb(profile: &Profile) -> impl Fn([f64; 4]) -> [u8; 3] { let t = Transform::new(profile, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap(); move |c| { let mut o = [0.0f32; 3]; - t.eval(&c.map(|v| v as f32), &mut o); + t.eval(&c.map(|v| v as f32), &mut o).unwrap(); o.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8) } } @@ -481,7 +481,7 @@ fn to_srgb(profile: &Profile) -> impl Fn([f64; 4]) -> [u8; 3] { fn srgb_lab(rgb: [u8; 3]) -> [f64; 3] { let t = Transform::new(Builtin::Srgb.profile(), Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap(); let mut o = [0.0f32; 3]; - t.eval(&rgb.map(|v| v as f32 / 255.0), &mut o); + t.eval(&rgb.map(|v| v as f32 / 255.0), &mut o).unwrap(); [o[0] as f64 * 100.0, o[1] as f64 * 255.0 - 128.0, o[2] as f64 * 255.0 - 128.0] } diff --git a/crates/cms/tests/regen.rs b/crates/cms/tests/regen.rs index 15f16986..25581a1f 100644 --- a/crates/cms/tests/regen.rs +++ b/crates/cms/tests/regen.rs @@ -15,7 +15,7 @@ fn regen_or_check_coated_cmyk() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/profiles/photocraft-coated-cmyk.icc"); if std::env::var_os("CRAFT_CMS_REGEN_PROFILES").is_some() { let t = std::time::Instant::now(); - let p = craft_cms::synth::coated_cmyk(); + let p = craft_cms::synth::coated_cmyk().unwrap(); let bytes = p.to_bytes(); std::fs::write(path, &*bytes).unwrap(); eprintln!("wrote {} bytes in {:?}", bytes.len(), t.elapsed()); @@ -32,7 +32,7 @@ fn regen_or_check_coated_cmyk() { for y in [0.0, 0.55, 1.0] { for k in [0.0, 0.5, 1.0] { let mut o = [0.0f32; 3]; - t.eval(&[c as f32, m as f32, y as f32, k as f32], &mut o); + t.eval(&[c as f32, m as f32, y as f32, k as f32], &mut o).unwrap(); let got = [o[0] as f64 * 100.0, o[1] as f64 * 255.0 - 128.0, o[2] as f64 * 255.0 - 128.0]; let want = model.lab([c, m, y, k]); worst = worst.max(craft_cms::math::delta_e76(got, want));