diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 8b0270ed4..79763ca2e 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -29,6 +29,7 @@ pub mod jpeg_par; mod jxl; mod other; mod png_codec; +pub mod proof; mod psd; mod sniff; pub mod space; diff --git a/crates/codecs/src/proof.rs b/crates/codecs/src/proof.rs new file mode 100644 index 000000000..55b63b25e --- /dev/null +++ b/crates/codecs/src/proof.rs @@ -0,0 +1,174 @@ +//! Soft proofing against an output-device ICC profile (a printer/paper profile, RGB or CMYK, matrix +//! or LUT based): a 3D table that takes a colour in the pipeline's working space (linear Rec.2020) +//! through the device and back, so the loupe shows what the print can hold. +//! +//! - Forward with the chosen intent (perceptual or relative colorimetric), back with relative +//! colorimetric: colours outside the device gamut come back mapped into it. +//! - **Simulate Paper & Ink**: absolute colorimetric rendering of the way back. The paper's white +//! (the profile's media white point) replaces the screen's white, and the ink's black is the +//! darkest the device tables give (no black-point compensation). The white is applied as a +//! per-channel scale in linear Rec.2020 (a von Kries adaptation in RGB, close enough for the +//! near-neutral whites of papers). +//! - Each entry carries a gamut flag: how far the relative-colorimetric round trip moved the colour. +//! +//! Sources: ICC.1:2022 (rendering intents, media white point, absolute colorimetric scaling); +//! the transforms are moxcms's. Own design otherwise. + +use lightcraft_color::{D50, D65, REC2020, bradford}; +use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformOptions}; + +use crate::display::MAX_PROFILE_BYTES; + +/// Rendering intent of the forward (image → device) conversion. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum ProofIntent { + #[default] + Perceptual, + Relative, +} + +impl ProofIntent { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "perceptual" => Some(ProofIntent::Perceptual), + "relative" | "relativecolorimetric" => Some(ProofIntent::Relative), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + ProofIntent::Perceptual => "perceptual", + ProofIntent::Relative => "relative", + } + } +} + +/// A proof table: `n³` entries over working-space RGB encoded with the sRGB curve (index +/// `(r * n + g) * n + b`), each the simulated linear Rec.2020 colour plus a gamut distance +/// (`[3]`, 0 = in gamut; colours whose round trip moved them more than ~0.02 are outside). +#[derive(Clone, Debug, PartialEq)] +pub struct ProofTable { + pub n: usize, + pub data: Vec<[f32; 4]>, + pub description: Option, +} + +/// Grid size of [`ProofTable`]s. +pub const PROOF_GRID: usize = 33; + +fn guarded(f: impl FnOnce() -> Option) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten() +} + +/// Build the proof table for an output profile. Errors say what's wrong with the file; nothing here +/// panics on a hostile profile. +pub fn proof_table(bytes: &[u8], intent: ProofIntent, paper_ink: bool) -> Result { + if bytes.len() > MAX_PROFILE_BYTES { + return Err("the profile is too large".into()); + } + let dev = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?; + let (layout, ch) = match dev.color_space { + DataColorSpace::Rgb => (Layout::Rgb, 3), + DataColorSpace::Cmyk => (Layout::Rgba, 4), + DataColorSpace::Gray => (Layout::Gray, 1), + _ => return Err("only RGB, CMYK and grey output profiles can be proofed".into()), + }; + let lin = crate::icc::linear_rec2020_profile(); + let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() }; + let fwd_i = match intent { + ProofIntent::Perceptual => RenderingIntent::Perceptual, + ProofIntent::Relative => RenderingIntent::RelativeColorimetric, + }; + let rel = RenderingIntent::RelativeColorimetric; + let fwd = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(fwd_i)).ok()).ok_or("can't convert to this profile")?; + let fwd_rel = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rel)).ok()).ok_or("can't convert to this profile")?; + let back = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(rel)).ok()).ok_or("can't convert from this profile")?; + + let n = PROOF_GRID; + let grid: Vec = (0..n * n * n) + .flat_map(|i| [i / (n * n), (i / n) % n, i % n].map(|k| lightcraft_color::transfer::srgb_to_linear(k as f32 / (n - 1) as f32))) + .collect(); + let round_trip = |there: &dyn moxcms::TransformExecutor| -> Option> { + let mut device = vec![0f32; n * n * n * ch]; + there.transform(&grid, &mut device).ok()?; + let mut out = vec![0f32; n * n * n * 3]; + back.transform(&device, &mut out).ok()?; + Some(out) + }; + let sim = guarded(|| round_trip(fwd.as_ref())).ok_or("the profile's tables can't be applied")?; + let colorimetric = guarded(|| round_trip(fwd_rel.as_ref())).ok_or("the profile's tables can't be applied")?; + let paper = if paper_ink { paper_white(&dev) } else { [1.0; 3] }; + let enc = |v: f32| lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)); + let data = (0..n * n * n) + .map(|i| { + let c = |v: &[f32], k: usize| v.get(i * 3 + k).copied().filter(|x| x.is_finite()).unwrap_or(0.0); + let dist = (0..3).map(|k| (enc(c(&colorimetric, k)) - enc(c(&grid, k))).abs()).fold(0.0f32, f32::max); + [c(&sim, 0) * paper[0], c(&sim, 1) * paper[1], c(&sim, 2) * paper[2], dist] + }) + .collect(); + let description = crate::icc::parse(bytes).and_then(|i| i.description); + Ok(ProofTable { n, data, description }) +} + +/// The paper's white in linear Rec.2020 (screen white = (1, 1, 1)), from the media white point. +fn paper_white(p: &ColorProfile) -> [f32; 3] { + let Some(w) = p.media_white_point else { return [1.0; 3] }; + let to_xyz = bradford(D65, D50).mul(&REC2020.to_xyz()); + let Some(from_xyz) = to_xyz.inverse() else { return [1.0; 3] }; + // the PCS white is D50 = Rec.2020 (1, 1, 1) after adaptation; the paper is relative to it + let d50 = to_xyz.apply([1.0; 3]); + let s = [w.x / d50[0], w.y / d50[1], w.z / d50[2]]; + let rgb = from_xyz.apply([d50[0] * s[0], d50[1] * s[1], d50[2] * s[2]]); + if rgb.iter().all(|v| v.is_finite() && *v > 0.05 && *v <= 1.5) { rgb.map(|v| (v as f32).min(1.0)) } else { [1.0; 3] } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::NamedSpace; + + fn at(t: &ProofTable, rgb: [usize; 3]) -> [f32; 4] { + t.data[(rgb[0] * t.n + rgb[1]) * t.n + rgb[2]] + } + + #[test] + fn srgb_proof_flags_wide_colours_and_keeps_grey() { + let t = proof_table(&crate::icc::write_named(NamedSpace::Srgb), ProofIntent::Relative, false).unwrap(); + let top = t.n - 1; + // Rec.2020 pure green is far outside sRGB; mid grey and white are inside and unchanged + assert!(at(&t, [0, top, 0])[3] > 0.05, "{:?}", at(&t, [0, top, 0])); + let g = at(&t, [16, 16, 16]); + assert!(g[3] < 0.01, "{g:?}"); + let lin = lightcraft_color::transfer::srgb_to_linear(16.0 / 32.0); + assert!((0..3).all(|k| (g[k] - lin).abs() < 0.01), "{g:?} vs {lin}"); + let w = at(&t, [top; 3]); + assert!((0..3).all(|k| (w[k] - 1.0).abs() < 0.01), "{w:?}"); + } + + #[test] + fn paper_white_dims_and_tints_white() { + let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::AdobeRgb)).unwrap(); + // a warm, slightly dull paper + p.media_white_point = Some(moxcms::Xyzd { x: 0.9642 * 0.92, y: 0.90, z: 0.8249 * 0.80 }); + let bytes = p.encode().unwrap(); + let top = PROOF_GRID - 1; + let plain = proof_table(&bytes, ProofIntent::Perceptual, false).unwrap(); + let paper = proof_table(&bytes, ProofIntent::Perceptual, true).unwrap(); + let (w0, w1) = (at(&plain, [top; 3]), at(&paper, [top; 3])); + assert!((0..3).all(|k| (w0[k] - 1.0).abs() < 0.01), "{w0:?}"); + assert!(w1[1] < 0.95 && w1[2] < w1[0], "paper white is dimmer and warmer: {w1:?}"); + } + + #[test] + fn hostile_profiles_are_errors() { + assert!(proof_table(&[], ProofIntent::Perceptual, false).is_err()); + assert!(proof_table(&[0u8; 400], ProofIntent::Relative, true).is_err()); + let good = crate::icc::write_named(NamedSpace::DisplayP3); + for i in (0..good.len()).step_by(11) { + let mut b = good.clone(); + b[i] ^= 0x5a; + let _ = proof_table(&b, ProofIntent::Perceptual, true); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index be6b13de5..1d2ffa2a8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -54,6 +54,7 @@ pub mod originals; pub mod preset_import; pub mod preset_luminar; pub mod presets; +pub mod proof; pub mod rename; pub mod segment; pub mod sidecar; diff --git a/crates/engine/src/proof.rs b/crates/engine/src/proof.rs new file mode 100644 index 000000000..8c974ada3 --- /dev/null +++ b/crates/engine/src/proof.rs @@ -0,0 +1,62 @@ +//! Soft proofing against an output-device ICC profile (a printer and paper): loads the profile, +//! builds its proof table through the CMS and registers it with the pipeline, which a +//! [`Proof`](lightcraft_pipeline::Proof) then names by id. + +use lightcraft_codecs::display::MAX_PROFILE_BYTES; +pub use lightcraft_codecs::proof::ProofIntent; + +/// A device profile ready to proof against. +#[derive(Clone, Debug, PartialEq)] +pub struct ProofProfile { + /// Pass as [`Proof::profile`](lightcraft_pipeline::Proof::profile). + pub id: u64, + /// The profile's description, else the file name. + pub name: String, +} + +/// Load the profile at `path` for proofing with `intent`, simulating paper and ink or not. +pub fn load(path: &str, intent: ProofIntent, paper_ink: bool) -> Result { + let path = path.trim(); + let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; + if meta.len() > MAX_PROFILE_BYTES as u64 { + return Err(format!("{path}: too large for an ICC profile")); + } + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let fallback = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + from_bytes(&bytes, intent, paper_ink, &fallback).map_err(|e| format!("{path}: {e}")) +} + +/// [`load`] from the profile's bytes; `fallback_name` names a profile without a description. +pub fn from_bytes(bytes: &[u8], intent: ProofIntent, paper_ink: bool, fallback_name: &str) -> Result { + let table = lightcraft_codecs::proof::proof_table(bytes, intent, paper_ink)?; + let lut = lightcraft_pipeline::ProofLut::new(table.n, table.data).ok_or("the profile's tables give no usable colours")?; + let id = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (bytes, intent, paper_ink).hash(&mut h); + h.finish() + }; + let id = lightcraft_pipeline::register_proof_profile(id, lut); + Ok(ProofProfile { id, name: table.description.unwrap_or_else(|| fallback_name.to_string()) }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_codecs::space::NamedSpace; + + #[test] + fn profiles_register_by_content_and_options() { + let p3 = lightcraft_codecs::icc::write_named(NamedSpace::DisplayP3); + let a = from_bytes(&p3, ProofIntent::Perceptual, false, "x").unwrap(); + let b = from_bytes(&p3, ProofIntent::Perceptual, true, "x").unwrap(); + assert_ne!(a.id, b.id); + assert_eq!(a, from_bytes(&p3, ProofIntent::Perceptual, false, "x").unwrap()); + assert!(lightcraft_pipeline::proof_profile(a.id).is_some()); + assert!(!a.name.is_empty()); + assert!(from_bytes(b"nope", ProofIntent::Relative, false, "x").is_err()); + assert!(load("/nonexistent/printer.icc", ProofIntent::Relative, false).is_err()); + assert_eq!(ProofIntent::parse("Relative"), Some(ProofIntent::Relative)); + assert_eq!(ProofIntent::parse("saturation"), None); + } +} diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 886ec9eee..d15f377a2 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -584,6 +584,13 @@ pub(crate) fn finish_with( // soft proofing maps into the proof space first and shows that in the output space let mut warn = None; let mut r = match &fp.proof { + Some(pp) if pp.table.is_some() => { + let (q, dist) = pp.table.as_ref().map_or((d, 0.0), |t| t.sample(d)); + if pp.dest_warning && dist > crate::output::PROOF_GAMUT_TOLERANCE { + warn = Some(crate::output::PROOF_DEST_WARNING); + } + mul3(to_out, q) + } Some(pp) => { let q0 = mul3(&pp.to_proof, d); let (q, t) = gamut_map_to(q0, pp.luma, peak); @@ -595,7 +602,7 @@ pub(crate) fn finish_with( None => mul3(to_out, d), }; let (mapped, t) = gamut_map_to(r, *out_luma, peak); - if fp.proof.is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) { + if fp.proof.as_ref().is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) { warn = Some(crate::output::PROOF_DISPLAY_WARNING); } r = mapped; diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index e8fe57c98..0ed30eff6 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -44,7 +44,7 @@ pub mod transform; pub mod upright; pub mod visualize; -pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof}; +pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof, ProofLut, proof_profile, register_proof_profile}; pub use visualize::{MaskView, Overlay}; use lightcraft_develop::{DevelopSettings, Treatment}; diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index 5acae0921..09f2bbe6a 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -191,6 +191,81 @@ pub struct Proof { pub space: OutputSpace, pub dest_warning: bool, pub display_warning: bool, + /// An output-device profile to proof against instead of `space` (a printer / paper profile): + /// the id [`register_proof_profile`] gave its table; 0 = none. An id with no table is ignored. + #[serde(skip_serializing_if = "is_zero_id")] + pub profile: u64, +} + +fn is_zero_id(v: &u64) -> bool { + *v == 0 +} + +/// A soft-proof table for an output-device profile, built by the caller's CMS: `n³` entries over +/// working-space (linear Rec.2020) RGB encoded with the sRGB curve, index `(r * n + g) * n + b`, +/// each the colour the device reproduces (linear Rec.2020, paper white already applied when the +/// proof simulates paper and ink) and, in `[3]`, how far outside the device gamut the colour is +/// (0 inside; above [`PROOF_GAMUT_TOLERANCE`] counts as outside). +#[derive(Clone, Debug, PartialEq)] +pub struct ProofLut { + n: usize, + data: Vec<[f32; 4]>, +} + +/// Gamut distance (sRGB-curve code values, 0..1) above which a colour is out of the proof gamut. +pub const PROOF_GAMUT_TOLERANCE: f32 = 0.02; + +impl ProofLut { + /// `None` unless `data` holds `n³` finite entries with `2 ≤ n ≤ 129`. + pub fn new(n: usize, data: Vec<[f32; 4]>) -> Option { + let ok = (2..=129).contains(&n) && data.len() == n * n * n && data.iter().flatten().all(|v| v.is_finite()); + ok.then_some(ProofLut { n, data }) + } + + /// The proofed colour and gamut distance of working-space `rgb` (trilinear). + pub fn sample(&self, rgb: [f32; 3]) -> ([f32; 3], f32) { + let m = (self.n - 1) as f32; + let f = rgb.map(|v| lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * m); + let i0 = f.map(|v| (v.floor() as usize).min(self.n - 2)); + let t = [f[0] - i0[0] as f32, f[1] - i0[1] as f32, f[2] - i0[2] as f32]; + let mut acc = [0.0f32; 4]; + for corner in 0..8usize { + let b = [corner >> 2 & 1, corner >> 1 & 1, corner & 1]; + let w: f32 = (0..3).map(|k| if b[k] == 1 { t[k] } else { 1.0 - t[k] }).product(); + let idx = ((i0[0] + b[0]) * self.n + i0[1] + b[1]) * self.n + i0[2] + b[2]; + if let Some(e) = self.data.get(idx) { + for k in 0..4 { + acc[k] += e[k] * w; + } + } + } + ([acc[0], acc[1], acc[2]], acc[3]) + } +} + +/// Proof tables by id (a few: the profiles a session proofs against). +static PROOF_PROFILES: std::sync::Mutex)>> = std::sync::Mutex::new(Vec::new()); +const PROOF_PROFILES_KEPT: usize = 8; + +/// Make `lut` available to renders as [`Proof::profile`] `id` (any nonzero number that identifies +/// the profile and its options, e.g. a hash). Returns `id` (`1` for `0`). +pub fn register_proof_profile(id: u64, lut: ProofLut) -> u64 { + let id = id.max(1); + let mut v = PROOF_PROFILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + v.retain(|(k, _)| *k != id); + v.push((id, std::sync::Arc::new(lut))); + let extra = v.len().saturating_sub(PROOF_PROFILES_KEPT); + v.drain(..extra); + id +} + +/// The table registered as `id`. +pub fn proof_profile(id: u64) -> Option> { + if id == 0 { + return None; + } + let v = PROOF_PROFILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + v.iter().find(|(k, _)| *k == id).map(|(_, l)| l.clone()) } /// Warning colours (sRGB-encoded): destination gamut red, display gamut blue. @@ -198,8 +273,10 @@ pub const PROOF_DEST_WARNING: [f32; 3] = [1.0, 0.0, 0.0]; pub const PROOF_DISPLAY_WARNING: [f32; 3] = [0.0, 0.25, 1.0]; /// [`Proof`] as the per-pixel stage uses it. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct ProofParams { + /// The output-device table (replaces `to_proof` / `proof_to_out` when present). + pub table: Option>, /// Linear Rec.2020 → linear proof RGB, its luminance weights, linear proof → linear output RGB. pub to_proof: [[f32; 3]; 3], pub luma: [f32; 3], @@ -210,11 +287,12 @@ pub struct ProofParams { impl Proof { pub fn key(self) -> u64 { - (self.space as u64 + 1) | (self.dest_warning as u64) << 8 | (self.display_warning as u64) << 9 + ((self.space as u64 + 1) | (self.dest_warning as u64) << 8 | (self.display_warning as u64) << 9) ^ self.profile.rotate_left(16) } pub fn params(self, out: OutputSpace) -> ProofParams { ProofParams { + table: proof_profile(self.profile), to_proof: self.space.from_working(), luma: self.space.luma(), proof_to_out: self.space.rgb_space().to_space(&out.rgb_space()).to_f32(), diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 54a75ff8d..44a672926 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -369,8 +369,13 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { let plain = render(&src, &info, &s, &req(OutputSpace::Srgb, None)).image; let same = render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image; assert_eq!(plain, same); - let warned = - render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false }))).image; + let warned = render( + &src, + &info, + &s, + &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false, profile: 0 })), + ) + .image; let n = red(&warned); assert!(n > 0 && n < warned.len(), "{n}"); // …and nothing is flagged on an unsaturated edit of a neutral ramp @@ -379,7 +384,10 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { &grey, &info, &DevelopSettings::default(), - &RenderRequest { proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: true }), ..RenderRequest::fit(64, 8) }, + &RenderRequest { + proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: true, profile: 0 }), + ..RenderRequest::fit(64, 8) + }, ); assert_eq!(red(&g.image) + blue(&g.image), 0); @@ -388,9 +396,13 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { let proofed = render(&src, &info, &s, &req(OutputSpace::DisplayP3, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image; assert_ne!(wide, proofed); // proofing a wider space than the display: what the display can't show is flagged blue - let pro = - render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::ProPhoto, dest_warning: true, display_warning: true }))) - .image; + let pro = render( + &src, + &info, + &s, + &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::ProPhoto, dest_warning: true, display_warning: true, profile: 0 })), + ) + .image; assert!(blue(&pro) > 0); assert!(red(&pro) < n, "ProPhoto holds more than sRGB"); } @@ -419,7 +431,7 @@ fn display_space_renders_like_the_matching_output_space() { // soft proofing on a display: the display gamut warning is about *this* display let blue = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[2] == 255 && p[0] == 0 && p[1] < 80).count(); - let pro = Some(Proof { space: OutputSpace::ProPhoto, dest_warning: false, display_warning: true }); + let pro = Some(Proof { space: OutputSpace::ProPhoto, dest_warning: false, display_warning: true, profile: 0 }); let on_srgb = blue(&render(&src, &info, &s, &req(OutputSpace::Srgb, Some(DisplaySpace::of(OutputSpace::Srgb, 2).unwrap()), pro)).image); let on_wide = blue(&render(&src, &info, &s, &req(OutputSpace::Srgb, Some(DisplaySpace::of(OutputSpace::Rec2020, 3).unwrap()), pro)).image); assert!(on_srgb > 0 && on_wide < on_srgb, "{on_srgb} {on_wide}"); @@ -427,6 +439,41 @@ fn display_space_renders_like_the_matching_output_space() { assert_eq!(plain, on_srgb, "an sRGB display warns like no display profile"); } +/// Proofing against an output-device table: the table's colours are what the render shows, and its +/// gamut distances drive the destination warning. +#[test] +fn soft_proof_through_a_device_table() { + use crate::{OutputSpace, Proof, ProofLut, register_proof_profile}; + let n = 5; + // a "paper" that dims everything to 80 % and calls every non-grey colour out of gamut + let data: Vec<[f32; 4]> = (0..n * n * n) + .map(|i| { + let c = [i / (n * n), (i / n) % n, i % n].map(|k| lightcraft_color::transfer::srgb_to_linear(k as f32 / (n - 1) as f32)); + let grey = c[0] == c[1] && c[1] == c[2]; + [c[0] * 0.8, c[1] * 0.8, c[2] * 0.8, if grey { 0.0 } else { 1.0 }] + }) + .collect(); + assert!(ProofLut::new(n, data[1..].to_vec()).is_none()); + assert!(ProofLut::new(n, vec![[f32::NAN; 4]; n * n * n]).is_none()); + let id = register_proof_profile(0xfeed, ProofLut::new(n, data).unwrap()); + let info = SourceInfo { raw: true, ..Default::default() }; + let white = Rgb32f::from_fn(16, 4, |_, _| [100.0; 3]); + let req = |proof| RenderRequest { space: OutputSpace::Srgb, proof, ..RenderRequest::fit(16, 4) }; + let plain = render(&white, &info, &DevelopSettings::default(), &req(None)).image; + let pr = Proof { profile: id, dest_warning: true, ..Default::default() }; + let paper = render(&white, &info, &DevelopSettings::default(), &req(Some(pr))).image; + assert!(plain.data[0][0] > 250 && paper.data[0][0] < 240 && paper.data[0][0] > 200, "{:?} {:?}", plain.data[0], paper.data[0]); + // an unknown id falls back to the space proof (sRGB on sRGB: no change) + let none = render(&white, &info, &DevelopSettings::default(), &req(Some(Proof { profile: 12345, ..Default::default() }))).image; + assert_eq!(none, plain); + // saturated colours are flagged + let src = scene(); + let mut s = DevelopSettings::default(); + s.color.saturation = 100.0; + let warned = render(&src, &info, &s, &req(Some(pr))).image; + assert!(warned.data.iter().any(|p| p[..3] == [255, 0, 0])); +} + #[test] fn display_space_rejects_degenerate_matrices() { use crate::DisplaySpace; diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 5c62067d0..dfefa2160 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2057,5 +2057,10 @@ "Looking up places": "Orte werden nachgeschlagen", "Downloading place names": "Ortsnamen werden heruntergeladen", "Place names downloaded": "Ortsnamen heruntergeladen", - "Place names download cancelled": "Download der Ortsnamen abgebrochen" + "Place names download cancelled": "Download der Ortsnamen abgebrochen", + "Other…": "Andere…", + "Intent:": "Priorität:", + "Perceptual": "Perzeptiv", + "Relative": "Relativ", + "Simulate Paper & Ink": "Papier und Druckfarbe simulieren" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 24a6797d2..b76b75662 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1902,5 +1902,10 @@ "Looking up places": "Buscando lugares", "Downloading place names": "Descargando nombres de lugares", "Place names downloaded": "Nombres de lugares descargados", - "Place names download cancelled": "Descarga de nombres de lugares cancelada" + "Place names download cancelled": "Descarga de nombres de lugares cancelada", + "Other…": "Otro…", + "Intent:": "Propósito:", + "Perceptual": "Perceptual", + "Relative": "Relativo", + "Simulate Paper & Ink": "Simular papel y tinta" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index ed6c0084a..35554261c 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1902,5 +1902,10 @@ "Looking up places": "Recherche des lieux", "Downloading place names": "Téléchargement des noms de lieux", "Place names downloaded": "Noms de lieux téléchargés", - "Place names download cancelled": "Téléchargement des noms de lieux annulé" + "Place names download cancelled": "Téléchargement des noms de lieux annulé", + "Other…": "Autre…", + "Intent:": "Mode de rendu :", + "Perceptual": "Perceptif", + "Relative": "Relatif", + "Simulate Paper & Ink": "Simuler le papier et l'encre" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 6f0ea5f30..3d940d4ba 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1904,5 +1904,10 @@ "Looking up places": "場所を検索中", "Downloading place names": "地名データをダウンロード中", "Place names downloaded": "地名データをダウンロードしました", - "Place names download cancelled": "地名データのダウンロードを中止しました" + "Place names download cancelled": "地名データのダウンロードを中止しました", + "Other…": "その他…", + "Intent:": "マッチング方法:", + "Perceptual": "知覚的", + "Relative": "相対的", + "Simulate Paper & Ink": "紙とインキをシミュレート" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 648a93855..df296d9a4 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1902,5 +1902,10 @@ "Looking up places": "Procurando lugares", "Downloading place names": "Baixando nomes de lugares", "Place names downloaded": "Nomes de lugares baixados", - "Place names download cancelled": "Download dos nomes de lugares cancelado" + "Place names download cancelled": "Download dos nomes de lugares cancelado", + "Other…": "Outro…", + "Intent:": "Tipo de conversão:", + "Perceptual": "Perceptual", + "Relative": "Relativo", + "Simulate Paper & Ink": "Simular papel e tinta" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c38029ef5..0f1990e3a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1616,5 +1616,10 @@ "Looking up places": "Поиск мест", "Downloading place names": "Загрузка названий мест", "Place names downloaded": "Названия мест загружены", - "Place names download cancelled": "Загрузка названий мест отменена" + "Place names download cancelled": "Загрузка названий мест отменена", + "Other…": "Другой…", + "Intent:": "Метод:", + "Perceptual": "Перцепционный", + "Relative": "Относительный", + "Simulate Paper & Ink": "Имитировать бумагу и краску" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 45559669a..cdf08d16e 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2096,5 +2096,10 @@ "Looking up places": "Пошук місць", "Downloading place names": "Завантаження назв місць", "Place names downloaded": "Назви місць завантажено", - "Place names download cancelled": "Завантаження назв місць скасовано" + "Place names download cancelled": "Завантаження назв місць скасовано", + "Other…": "Інший…", + "Intent:": "Метод:", + "Perceptual": "Перцепційний", + "Relative": "Відносний", + "Simulate Paper & Ink": "Імітувати папір і фарбу" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index bd653c085..0a092d901 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1902,5 +1902,10 @@ "Looking up places": "正在查找地点", "Downloading place names": "正在下载地名数据", "Place names downloaded": "地名数据已下载", - "Place names download cancelled": "已取消下载地名数据" + "Place names download cancelled": "已取消下载地名数据", + "Other…": "其他…", + "Intent:": "方法:", + "Perceptual": "可感知", + "Relative": "相对", + "Simulate Paper & Ink": "模拟纸张和油墨" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index e1e274c54..54509e3c9 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1902,5 +1902,10 @@ "Looking up places": "正在查找地點", "Downloading place names": "正在下載地名資料", "Place names downloaded": "地名資料已下載", - "Place names download cancelled": "已取消下載地名資料" + "Place names download cancelled": "已取消下載地名資料", + "Other…": "其他…", + "Intent:": "方式:", + "Perceptual": "感知", + "Relative": "相對", + "Simulate Paper & Ink": "模擬紙張和油墨" } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 48b228df6..45f8c90f8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -623,14 +623,47 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // {on?, space?, destWarning?, displayWarning?}; no params toggles + // {on?, space?, profile?: ICC path | null, intent?: perceptual|relative, paperInk?, + // destWarning?, displayWarning?}; no params toggles let has = |k: &str| p.get(k).is_some(); if let Some(s) = p.get("space").and_then(Value::as_str) { match lightcraft_engine::pipeline::OutputSpace::parse(s) { - Some(sp) => app.ui.proof.space = sp, + Some(sp) => { + app.ui.proof.space = sp; + // a standard space replaces a device profile (unless one is given too) + app.ui.proof_profile = None; + } None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))), } } + if let Some(i) = p.get("intent") { + match i.as_str().and_then(lightcraft_engine::proof::ProofIntent::parse) { + Some(i) => app.ui.proof_intent = i, + None => return Some(Err("view.softProof: intent must be perceptual or relative".into())), + } + } + if let Some(b) = p.get("paperInk").and_then(Value::as_bool) { + app.ui.proof_paper_ink = b; + } + let path = match p.get("profile") { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Null) => { + app.ui.proof_profile = None; + None + } + Some(_) => return Some(Err("view.softProof: profile must be an ICC file path or null".into())), + None => app.ui.proof_profile.as_ref().map(|(f, _)| f.clone()), + }; + match path { + Some(path) => match lightcraft_engine::proof::load(&path, app.ui.proof_intent, app.ui.proof_paper_ink) { + Ok(pp) => { + app.ui.proof.profile = pp.id; + app.ui.proof_profile = Some((path, pp.name)); + } + Err(e) => return Some(Err(format!("view.softProof: {e}"))), + }, + None => app.ui.proof.profile = 0, + } if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { app.ui.proof.dest_warning = b; } @@ -639,14 +672,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option b, - None if has("space") || has("destWarning") || has("displayWarning") => app.ui.soft_proof, + None if ["space", "destWarning", "displayWarning", "profile", "intent", "paperInk"].iter().any(|k| has(k)) => app.ui.soft_proof, None => !app.ui.soft_proof, }; if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { app.ui.view = ViewMode::Detail; } let pr = app.ui.proof; - Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) + let profile = app.ui.proof_profile.as_ref().map(|(f, n)| json!({"path": f, "name": n})); + Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "profile": profile, "intent": app.ui.proof_intent.name(), + "paperInk": app.ui.proof_paper_ink, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) } "view.histogram" => { app.ui.histogram = !app.ui.histogram; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 303723937..19f7ed1bb 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -625,18 +625,53 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { ui.label(RichText::new(crate::i18n::tr("Profile:")).color(t.text_label)); let cur = app.ui.proof.space; - egui::ComboBox::from_id_salt("proof-space").width(170.0).selected_text(cur.label()).show_ui(ui, |ui| { + let device = app.ui.proof_profile.as_ref().map(|(_, n)| n.clone()); + let shown = device.clone().unwrap_or_else(|| cur.label().to_string()); + let mut pick = false; + egui::ComboBox::from_id_salt("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { for s in OutputSpace::ALL { - if ui.selectable_label(cur == s, s.label()).clicked() { + if ui.selectable_label(device.is_none() && cur == s, s.label()).clicked() { let _ = app.run("view.softProof", json!({"space": s})); } } + if let Some(n) = &device { + let _ = ui.selectable_label(true, n); + } + if app.services.pick_display_profile.is_some() && ui.selectable_label(false, crate::i18n::tr("Other…")).clicked() { + pick = true; + } }); + if pick + && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) + && let Err(e) = app.run("view.softProof", json!({"profile": path})) + { + app.toast(ui.ctx(), e); + } }); + if app.ui.proof_profile.is_some() { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Intent:")).color(t.text_label)); + use lightcraft_engine::proof::ProofIntent; + for (i, label) in [(ProofIntent::Perceptual, "Perceptual"), (ProofIntent::Relative, "Relative")] { + let r = ui.selectable_label(app.ui.proof_intent == i, crate::i18n::tr(label)); + register(ui.ctx(), format!("button:proofIntent-{}", i.name()), r.rect); + if r.clicked() && app.ui.proof_intent != i { + let _ = app.run("view.softProof", json!({"intent": i.name()})); + } + } + }); + let mut paper = app.ui.proof_paper_ink; + let r = ui.checkbox(&mut paper, crate::i18n::tr("Simulate Paper & Ink")); + register(ui.ctx(), "button:proofPaperInk", r.rect); + if r.changed() { + let _ = app.run("view.softProof", json!({"paperInk": paper})); + } + } let r = ui.button(crate::i18n::tr("Create Proof Copy")); register(ui.ctx(), "button:createProofCopy", r.rect); if r.clicked() { - let name = crate::i18n::tr_format!("Proof Copy ({})", app.ui.proof.space.label()); + let target = app.ui.proof_profile.as_ref().map_or_else(|| app.ui.proof.space.label().to_string(), |(_, n)| n.clone()); + let name = crate::i18n::tr_format!("Proof Copy ({})", target); if app.run("photo.virtualCopy", json!({"ids": [id.0], "name": name})).is_ok() { app.toast(ui.ctx(), crate::i18n::tr("Proof copy created")); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5fce830ee..2029e6001 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -357,6 +357,13 @@ pub struct UiState { /// Soft proofing (S in the loupe): render as `proof` would hold the photo. pub soft_proof: bool, pub proof: lightcraft_engine::pipeline::Proof, + /// Soft proofing against a printer / paper ICC profile: (file, name), the intent and Simulate + /// Paper & Ink. With a profile, `proof.profile` names its table. + #[serde(skip)] + pub proof_profile: Option<(String, String)>, + #[serde(skip)] + pub proof_intent: lightcraft_engine::proof::ProofIntent, + pub proof_paper_ink: bool, /// Masking: show the selected mask as a rendered overlay (O), how (`MaskView` name, ⇧O cycles), /// in which colour and opacity (0..100, colour views), and whether pins are drawn. pub mask_overlay: bool, @@ -850,6 +857,9 @@ impl Default for UiState { histogram: true, soft_proof: false, proof: lightcraft_engine::pipeline::Proof { dest_warning: false, ..Default::default() }, + proof_profile: None, + proof_intent: lightcraft_engine::proof::ProofIntent::Perceptual, + proof_paper_ink: false, mask_overlay: true, mask_overlay_mode: "color".into(), mask_overlay_color: [230, 30, 40], diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 31ba2a8c7..a6444c0f2 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1062,11 +1062,30 @@ fn soft_proofing_flags_out_of_gamut_colours_and_makes_proof_copies() { assert_eq!(r["ok"], true, "{r}"); assert!(h.app.ui.soft_proof); let st = exec(&mut h, "view.softProof", json!({"space": "srgb", "destWarning": true})); - assert_eq!(st, json!({"on": true, "space": "srgb", "destWarning": true, "displayWarning": false})); + assert_eq!( + st, + json!({"on": true, "space": "srgb", "profile": null, "intent": "perceptual", "paperInk": false, "destWarning": true, "displayWarning": false}) + ); h.settle(SETTLE); assert!(red(&h) > before, "out-of-gamut colours are painted red"); let r = h.request("engine.execute", json!({"command": "view.softProof", "params": {"space": "cmyk"}}), T); assert_ne!(r["ok"], true); + // a device (printer) profile, its intent and Simulate Paper & Ink + let dir = std::env::temp_dir().join(format!("proof-profile-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("printer.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::Srgb)).unwrap(); + let st = exec(&mut h, "view.softProof", json!({"profile": icc.to_string_lossy(), "intent": "relative", "paperInk": true})); + assert_eq!((st["intent"].as_str(), st["paperInk"].as_bool()), (Some("relative"), Some(true)), "{st}"); + assert!(st["profile"]["name"].as_str().is_some_and(|n| !n.is_empty()), "{st}"); + assert_ne!(h.app.ui.proof.profile, 0); + h.settle(SETTLE); + assert!(red(&h) > before, "the device gamut is flagged too"); + let r = h.request("engine.execute", json!({"command": "view.softProof", "params": {"profile": "/nonexistent.icc"}}), T); + assert_ne!(r["ok"], true); + let st = exec(&mut h, "view.softProof", json!({"profile": null})); + assert_eq!((st["profile"].clone(), h.app.ui.proof.profile), (json!(null), 0)); + let _ = std::fs::remove_dir_all(&dir); // Create Proof Copy: a virtual copy named after the proof let n = h.app.session.catalog.photos().count(); let r = h.request("ui.clickWidget", json!({"id": "button:createProofCopy"}), T);