# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
This commit is contained in:
Claude
2026-10-11 11:53:13 +00:00
21 changed files with 542 additions and 28 deletions
+1
View File
@@ -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;
+174
View File
@@ -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<ProofIntent> {
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<String>,
}
/// Grid size of [`ProofTable`]s.
pub const PROOF_GRID: usize = 33;
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
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<ProofTable, String> {
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<f32> = (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<f32>| -> Option<Vec<f32>> {
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);
}
}
}
+1
View File
@@ -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;
+62
View File
@@ -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<ProofProfile, String> {
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<ProofProfile, String> {
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);
}
}
+8 -1
View File
@@ -584,6 +584,13 @@ pub(crate) fn finish_with<T: Copy + Default + Send>(
// 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<T: Copy + Default + Send>(
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;
+1 -1
View File
@@ -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};
+80 -2
View File
@@ -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<ProofLut> {
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<Vec<(u64, std::sync::Arc<ProofLut>)>> = 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<std::sync::Arc<ProofLut>> {
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<std::sync::Arc<ProofLut>>,
/// 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(),
+54 -7
View File
@@ -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;
+6 -1
View File
@@ -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"
}
+6 -1
View File
@@ -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"
}
+6 -1
View File
@@ -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"
}
+6 -1
View File
@@ -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": "紙とインキをシミュレート"
}
+6 -1
View File
@@ -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"
}
+6 -1
View File
@@ -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": "Имитировать бумагу и краску"
}
+6 -1
View File
@@ -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": "Імітувати папір і фарбу"
}
+6 -1
View File
@@ -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": "模拟纸张和油墨"
}
+6 -1
View File
@@ -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": "模擬紙張和油墨"
}
+39 -4
View File
@@ -623,14 +623,47 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(json!({"on": app.ui.hdr_visualize}))
}
"view.softProof" => {
// {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<Re
}
app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) {
Some(b) => 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;
+38 -3
View File
@@ -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"));
}
+10
View File
@@ -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],
+20 -1
View File
@@ -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);