mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #928
# 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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "紙とインキをシミュレート"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Имитировать бумагу и краску"
|
||||
}
|
||||
|
||||
@@ -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": "Імітувати папір і фарбу"
|
||||
}
|
||||
|
||||
@@ -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": "模拟纸张和油墨"
|
||||
}
|
||||
|
||||
@@ -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": "模擬紙張和油墨"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user