diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index 7fd8707a99..3ad4820ce1 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -94,6 +94,50 @@ impl Intent { } } +/// A color management policy (Edit → Color Settings): what opening a document does with a profile +/// it is tagged with that is not the working space of its model. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Policy { + /// The document loses the profile; its color numbers stay as they are. + Off, + /// The document keeps the profile. + #[default] + PreserveEmbedded, + /// The colors are converted from the profile into the working space, which keeps their + /// appearance, and the document loses the profile. + Convert, + /// CMYK only: the document loses the profile; its color numbers stay as they are. + PreserveNumbers, +} + +impl Policy { + /// The RGB policies, in menu order. + pub const RGB: [Policy; 3] = [Policy::Off, Policy::PreserveEmbedded, Policy::Convert]; + /// The CMYK policies, in menu order. + pub const CMYK: [Policy; 4] = [Policy::Off, Policy::PreserveEmbedded, Policy::Convert, Policy::PreserveNumbers]; + /// Parse an id of [`Policy::id`]. + pub fn parse(s: &str) -> Option { + [Policy::Off, Policy::PreserveEmbedded, Policy::Convert, Policy::PreserveNumbers].into_iter().find(|p| p.id() == s) + } + pub fn id(&self) -> &'static str { + match self { + Policy::Off => "off", + Policy::PreserveEmbedded => "preserveEmbedded", + Policy::Convert => "convert", + Policy::PreserveNumbers => "preserveNumbers", + } + } + pub fn label(&self) -> &'static str { + match self { + Policy::Off => "Off", + Policy::PreserveEmbedded => "Preserve Embedded Profiles", + Policy::Convert => "Convert to Working Space", + Policy::PreserveNumbers => "Preserve Numbers", + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProfileKind { @@ -134,11 +178,25 @@ pub struct ColorSettings { pub intent: Intent, /// Black-point compensation. pub bpc: bool, + /// The color management policy for RGB profiles (one of [`Policy::RGB`]). Settings saved + /// without one take the default. + #[serde(default)] + pub rgb_policy: Policy, + /// The color management policy for CMYK profiles. Settings saved without one take the default. + #[serde(default)] + pub cmyk_policy: Policy, } impl Default for ColorSettings { fn default() -> Self { - Self { rgb: SRGB.into(), cmyk: GENERIC_CMYK.into(), intent: Intent::RelativeColorimetric, bpc: true } + Self { + rgb: SRGB.into(), + cmyk: GENERIC_CMYK.into(), + intent: Intent::RelativeColorimetric, + bpc: true, + rgb_policy: Policy::default(), + cmyk_policy: Policy::default(), + } } } @@ -518,6 +576,41 @@ impl Cms { Color::Rgb { r, g, b } } + /// An RGB or CMYK colour of this context's space as the colour of the same model in `dest`'s + /// space that looks the same, converted with `intent` (Edit → Color Settings › Convert to + /// Working Space). RGB converts directly from profile to profile, and CMYK between two loaded + /// profiles through ProPhoto RGB ([`IccProfile::to_same_model_of`]), both with this context's + /// black-point compensation; CMYK goes through Lab when either space is built in. Grey and + /// Lab colours stay as they are. + pub fn convert_into(&self, dest: &Cms, c: &Color, intent: Intent) -> Color { + match *c { + Color::Rgb { r, g, b } => { + let icc = |s: &RgbSpace| match s { + RgbSpace::Icc(p) => Some(p.clone()), + RgbSpace::Srgb => builtin_rgb_cache(SRGB), + }; + let [r, g, b] = icc(&self.rgb) + .zip(icc(&dest.rgb)) + .and_then(|(s, d)| s.to_rgb_of(&d, &[r, g, b], intent, self.settings.bpc)) + .unwrap_or_else(|| dest.srgb_to_rgb(self.rgb_to_srgb([r, g, b]))); + Color::Rgb { r, g, b } + } + Color::Cmyk { c, m, y, k } => { + let inks = [c, m, y, k].map(|v| v.clamp(0.0, 1.0)); + let direct = match (&self.cmyk, &dest.cmyk) { + (CmykSpace::Icc(s), CmykSpace::Icc(d)) => s.to_same_model_of(d, &inks, intent, self.settings.bpc), + _ => None, + }; + let [c, m, y, k] = match direct.as_deref() { + Some(&[c, m, y, k]) => [c, m, y, k], + _ => dest.lab_to_cmyk(self.cmyk_to_lab(inks), intent), + }; + Color::Cmyk { c, m, y, k } + } + Color::Gray { .. } | Color::Lab { .. } => *c, + } + } + /// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours). pub fn gamut_error(&self, c: &Color) -> f32 { match c { @@ -966,8 +1059,11 @@ pub fn working_settings() -> ColorSettings { } /// Replace the working settings (Edit → Color Settings) and make them the active settings (fails, -/// leaving both unchanged, if a profile is unknown). +/// leaving both unchanged, if a profile is unknown or the RGB policy is Preserve Numbers). pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> { + if !Policy::RGB.contains(&settings.rgb_policy) { + return Err(CmsError::Unsupported(format!("the RGB policy can't be {}", settings.rgb_policy.id()))); + } let cms = Arc::new(Cms::new(settings)?); *WORKING.write().unwrap_or_else(|e| e.into_inner()) = Some(cms.clone()); install(cms); diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index f6a2316b89..ffddd6c955 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -34,6 +34,17 @@ type Xf = Option>; /// generation only lets the cache drop what reloads left behind. type DirectKey = (u64, u64, usize, bool); +/// The transforms of [`IccProfile::to_same_model_of`]: into ProPhoto RGB's primaries with linear +/// tone curves, where black-point compensation is a linear remap, and from there into the +/// destination. +struct SameModel { + into: Arc, + out_of: Arc, + /// The source's and the destination's black as luminance in the linear space (both 0 without + /// compensation). + black: [f32; 2], +} + /// A parsed ICC profile plus lazily-built transforms to/from sRGB. pub struct IccProfile { pub name: String, @@ -49,6 +60,9 @@ pub struct IccProfile { /// Direct transforms into other RGB profiles ([`Self::to_rgb_of`]), by destination, intent /// and black-point compensation. direct: Mutex>)>>, + /// Transforms into other profiles of the same model ([`Self::to_same_model_of`]), keyed as + /// [`Self::direct`] is. + same_model: Mutex>)>>, } /// A transform from one profile into an RGB profile without passing through sRGB: into the @@ -99,6 +113,7 @@ impl IccProfile { to_srgb: Default::default(), from_srgb: Default::default(), direct: Mutex::new(Vec::new()), + same_model: Mutex::new(Vec::new()), }) } @@ -264,6 +279,69 @@ impl IccProfile { Some(Direct { to_linear, to_dest: Some(to_dest), black }) } + /// Device values of this profile → device values of `dest`, a profile of the same model, + /// converted with `intent` through ProPhoto RGB rather than sRGB, so colours outside sRGB + /// keep their place. With `bpc` (and an intent other than absolute), this profile's black + /// maps onto the destination's black (black-point compensation). `None` when `dest` is of + /// another model or no transform can be built. + pub fn to_same_model_of(&self, dest: &IccProfile, v: &[f32], intent: Intent, bpc: bool) -> Option> { + if dest.kind != self.kind || v.len() != self.channels() { + return None; + } + let bpc = bpc && intent != Intent::AbsoluteColorimetric; + let generation = profiles_generation(); + let key = (dest.id, generation, intent_index(intent), bpc); + let t = { + let mut cache = self.same_model.lock().unwrap_or_else(|e| e.into_inner()); + cache.retain(|(k, _)| k.1 == generation); + match cache.iter().find(|(k, _)| *k == key) { + Some((_, t)) => t.clone(), + None => { + let t = self.build_same_model(dest, intent, bpc).map(Arc::new); + cache.push((key, t.clone())); + t + } + } + }?; + let mut lin = [0.0f32; 3]; + t.into.transform(v, &mut lin).ok()?; + let [from, to] = t.black; + if from > 0.0 || to > 0.0 { + lin = lin.map(|x| to + (x - from) * (1.0 - to) / (1.0 - from)); + } + let lin = lin.map(|x| x.clamp(0.0, 1.0)); + let mut out = vec![0.0f32; dest.channels()]; + t.out_of.transform(&lin, &mut out).ok()?; + Some(out.into_iter().map(|x| x.clamp(0.0, 1.0)).collect()) + } + + fn build_same_model(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option { + let mut linear = rgb_space(super::PROPHOTO_RGB)?; + for trc in [&mut linear.red_trc, &mut linear.green_trc, &mut linear.blue_trc] { + *trc = Some(ToneReprCurve::Parametric(vec![1.0])); + } + linear.cicp = None; + let into = self.profile.create_transform_f32(self.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?; + let out_of = linear.create_transform_f32(Layout::Rgb, &dest.profile, dest.layout(), Self::opts(intent)).ok()?; + let black = if bpc { + // Each profile's black: the darkest colour it reproduces, found as `build_direct` + // finds it, as luminance in the linear space. + let luminance = |p: &IccProfile, xf: &Arc| -> Option { + let inks = p.from_srgb([0.0; 3], Intent::RelativeColorimetric)?; + let mut lin = [0.0f32; 3]; + xf.transform(&inks, &mut lin).ok()?; + let y = |c: &moxcms::Xyzd| c.y as f32; + let lum = lin[0] * y(&linear.red_colorant) + lin[1] * y(&linear.green_colorant) + lin[2] * y(&linear.blue_colorant); + Some(lum.clamp(0.0, 0.5)) + }; + let back = dest.profile.create_transform_f32(dest.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?; + [luminance(self, &into)?, luminance(dest, &back)?] + } else { + [0.0; 2] + }; + Some(SameModel { into, out_of, black }) + } + /// Whether transforms can be built for this profile (checked at registration). pub fn usable(&self) -> bool { self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some() diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 7b22163525..95e465b623 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -442,3 +442,39 @@ fn a_document_gets_the_same_context_for_the_same_profiles() { let unknown = for_document(Some("no such profile"), Some(SRGB)); assert_eq!(unknown.settings(), &working_settings(), "an unknown profile or one of the other model leaves the working space"); } + +/// The color management policies parse from their ids, Preserve Numbers is a CMYK policy only, and +/// settings saved before the policies existed read with the default policy. +#[test] +fn policies_parse_and_default_in_saved_settings() { + for p in Policy::CMYK { + assert_eq!(Policy::parse(p.id()), Some(p)); + } + assert_eq!(Policy::parse("bogus"), None); + assert!(!Policy::RGB.contains(&Policy::PreserveNumbers)); + let old: ColorSettings = + serde_json::from_str(r#"{"rgb": "sRGB IEC61966-2.1", "cmyk": "Device CMYK (uncalibrated)", "intent": "perceptual", "bpc": false}"#).unwrap(); + assert_eq!((old.rgb_policy, old.cmyk_policy), (Policy::PreserveEmbedded, Policy::PreserveEmbedded)); + let saved = serde_json::to_value(ColorSettings { cmyk_policy: Policy::PreserveNumbers, ..Default::default() }).unwrap(); + assert_eq!((saved["rgbPolicy"].as_str(), saved["cmykPolicy"].as_str()), (Some("preserveEmbedded"), Some("preserveNumbers"))); +} + +/// Convert to Working Space keeps a color's appearance: an RGB color of a wide space converts into +/// sRGB, and a CMYK color of one CMYK space into another, with numbers that look the same. Grey and +/// Lab colors stay as they are. +#[test] +fn convert_into_keeps_the_appearance() { + let wide = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap(); + let working = cms(); + let rgb = Color::Rgb { r: 0.25, g: 0.4, b: 0.6 }; + let moved = wide.convert_into(&working, &rgb, Intent::RelativeColorimetric); + assert_ne!(moved, rgb, "the numbers change"); + assert!(de(wide.display_rgb(&rgb), working.display_rgb(&moved)) < 1.0, "{rgb:?} → {moved:?}"); + let ink = Color::Cmyk { c: 0.2, m: 0.4, y: 0.6, k: 0.1 }; + let moved = wide.convert_into(&working, &ink, Intent::RelativeColorimetric); + assert!(matches!(moved, Color::Cmyk { .. }) && moved != ink, "{ink:?} → {moved:?}"); + assert!(delta_e2000(wide.lab(&ink), working.lab(&moved)) < 3.0, "{ink:?} → {moved:?}"); + for c in [Color::Gray { k: 0.3 }, Color::Lab { l: 50.0, a: 10.0, b: -20.0 }] { + assert_eq!(wide.convert_into(&working, &c, Intent::RelativeColorimetric), c); + } +} diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index b2920c7c7b..bee0f0f9fa 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -274,3 +274,55 @@ fn settings_built_before_a_reload_dont_leave_the_old_transform_to_the_new_ones() assert!(in_srgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "sRGB {in_srgb:?} vs ProPhoto {want:?}"); assert!(in_prophoto.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {in_prophoto:?}, want {want:?}"); } + +/// A CMYK profile converts into another CMYK profile, ink for ink, and refuses a profile +/// of another model or inks of the wrong count. +#[test] +fn cmyk_profiles_convert_into_each_other_directly() { + let generic = IccProfile::from_bytes(Some("A".into()), &icc_bytes(GENERIC_CMYK).unwrap()).unwrap(); + let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap(); + let srgb = IccProfile::from_bytes(None, &icc_bytes(SRGB).unwrap()).unwrap(); + let inks = [0.2, 0.4, 0.6, 0.1]; + let out = generic.to_same_model_of(&device, &inks, Intent::RelativeColorimetric, false).unwrap(); + assert_eq!(out.len(), 4); + assert!(out.iter().zip(inks).any(|(a, b)| (a - b).abs() > 0.01), "{out:?}"); + assert!(generic.to_same_model_of(&srgb, &inks, Intent::RelativeColorimetric, false).is_none()); + assert!(generic.to_same_model_of(&device, &inks[..3], Intent::RelativeColorimetric, false).is_none()); +} + +/// With black-point compensation, a CMYK profile's darkest ink converts into an ink as dark as a +/// darker profile's darkest ink, and a dark grey of a darker profile converts lighter into a +/// profile whose black is lighter; absolute colorimetric never compensates. +#[test] +fn cmyk_to_cmyk_compensates_the_black_point() { + let generic = IccProfile::from_bytes(Some("A".into()), &icc_bytes(GENERIC_CMYK).unwrap()).unwrap(); + let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap(); + let rel = Intent::RelativeColorimetric; + let lightness = |p: &IccProfile, inks: &[f32]| lab::srgb_to_lab(p.to_srgb(inks, rel).unwrap()).l; + let black = |p: &IccProfile| p.from_srgb([0.0; 3], rel).unwrap(); + // Generic CMYK's black is a dark grey, the uncalibrated profile's is black. + let (light, dark) = (lightness(&generic, &black(&generic)), lightness(&device, &black(&device))); + assert!(light > dark + 5.0, "{light} vs {dark}"); + let into_device = |bpc| lightness(&device, &generic.to_same_model_of(&device, &black(&generic), rel, bpc).unwrap()); + let (on, off) = (into_device(true), into_device(false)); + assert!((on - dark).abs() < 1.5 && off > dark + 5.0, "with {on}, without {off}, the black {dark}"); + let grey = device.from_srgb([0.2; 3], rel).unwrap(); + let into_generic = |bpc| lightness(&generic, &device.to_same_model_of(&generic, &grey, rel, bpc).unwrap()); + let (on, off) = (into_generic(true), into_generic(false)); + assert!(on > off + 1.0, "with {on}, without {off}"); + for (from, to, inks) in [(&generic, &device, black(&generic)), (&device, &generic, grey)] { + let abs = |bpc| from.to_same_model_of(to, &inks, Intent::AbsoluteColorimetric, bpc).unwrap(); + assert_eq!(abs(true), abs(false)); + } + // Convert to Working Space (Cms::convert_into) compensates as Color Settings say. + let device_name = register_icc(&icc_bytes(DEVICE_CMYK).unwrap(), Some("Test ICC Device CMYK".into())).unwrap().name; + let rich = Color::Cmyk { c: 0.9, m: 0.8, y: 0.8, k: 1.0 }; + let convert = |bpc| { + let dest = Cms::new(&ColorSettings { cmyk: device_name.clone(), bpc, ..ColorSettings::default() }).unwrap(); + icc_cmyk(bpc).convert_into(&dest, &rich, rel) + }; + assert_ne!(convert(true), convert(false)); + // White is untouched. + let white = generic.to_same_model_of(&device, &[0.0; 4], rel, true).unwrap(); + assert!(white.iter().all(|v| *v < 0.01), "{white:?}"); +} diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 5922787723..d863edb96d 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget}; +use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, Policy, ProofTarget}; use vectorcraft_color::recolor::Neutral; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::ColorMode; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Color Settings…", ["Edit"], Some("Cmd+Shift+K"), - "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", + "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool, rgbPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\", cmykPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\"|\"preserveNumbers\"} set the working spaces and the color management policies → {rgb, cmyk, intent, bpc, rgbPolicy, cmykPolicy, profiles: [{name, kind, builtin}]}. A policy decides what opening a document (document.open, file.newFromTemplate, file.revert) does with an RGB or CMYK profile it is tagged with that is not the working space: preserveEmbedded (default) keeps it; off and preserveNumbers take it off and keep the color numbers; convert converts the colors of that model into the working space with the intent and bpc and takes it off. The open reply's warnings name each profile taken off or converted, and a document a policy changed opens with unsaved changes. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", always, color_settings ), @@ -163,12 +163,26 @@ fn on_param(p: &Value, key: &str, cmd: &str) -> Result> { switch_or(p, key, false, cmd).map(Some) } +/// The policy `p` gives for `key`, one of `allowed`, or `None` when `p` leaves it out. +fn policy_param(p: &Value, key: &str, allowed: &[Policy], cmd: &str) -> Result> { + let Some(v) = text_param(p, key, cmd)? else { return Ok(None) }; + match Policy::parse(v).filter(|x| allowed.contains(x)) { + Some(x) => Ok(Some(x)), + None => { + let ids: Vec<&str> = allowed.iter().map(Policy::id).collect(); + Err(bad(cmd, format!("`{key}` must be one of {}", ids.join(", ")))) + } + } +} + fn settings_json(st: &ColorSettings) -> Value { json!({ "rgb": st.rgb, "cmyk": st.cmyk, "intent": st.intent.id(), "bpc": st.bpc, + "rgbPolicy": st.rgb_policy.id(), + "cmykPolicy": st.cmyk_policy.id(), "profiles": cms::profiles().iter().map(|p| json!({"name": p.name, "kind": p.kind, "builtin": p.builtin})).collect::>(), }) } @@ -205,6 +219,12 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { st.intent = i; } st.bpc = switch_or(p, "bpc", st.bpc, C)?; + if let Some(v) = policy_param(p, "rgbPolicy", &Policy::RGB, C)? { + st.rgb_policy = v; + } + if let Some(v) = policy_param(p, "cmykPolicy", &Policy::CMYK, C)? { + st.cmyk_policy = v; + } if st != before { cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; // Saved for the next start when the app attached a settings file (#6335). @@ -530,7 +550,12 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, if !convert { return 0; } - let conv = mode_converter(mode, intent, grays); + convert_colors(d, &mode_converter(mode, intent, grays)) +} + +/// Every color of `d` that [`set_color_mode`] converts, converted with `conv` → how many colors of +/// the art changed, as [`set_color_mode`] counts them. +fn convert_colors(d: &mut vectorcraft_doc::Document, conv: &dyn Fn(&Color) -> Color) -> usize { for sw in d.swatches_iter_mut() { match &mut sw.paint { Paint::Solid { color, .. } => *color = conv(color), @@ -538,6 +563,11 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, for st in &mut g.gradient.stops { st.color = conv(&st.color); } + if let Some(freeform) = &mut g.freeform { + for point in &mut freeform.points { + point.color = conv(&point.color); + } + } } _ => {} } @@ -621,6 +651,102 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, in_art } +/// Apply the color management policies of Edit → Color Settings to `d`, a document being opened: +/// each RGB or CMYK profile it is tagged with that is not the working space of its model is kept +/// (Preserve Embedded Profiles), taken off with the color numbers kept (Off, Preserve Numbers), or +/// taken off after the colors of that model were converted from it into the working space with the +/// working intent (Convert to Working Space). A profile that is not available here is taken off +/// by Off and Preserve Numbers and kept by Convert to Working Space, which has nothing to convert +/// from. → a note for each profile, and whether `d` changed. +pub(crate) fn apply_open_policies(d: &mut vectorcraft_doc::Document) -> (Vec, bool) { + let working = cms::working_settings(); + let (rgb, cmyk) = doc_profiles(d); + let mut notes = Vec::new(); + // What each model's policy does with its profile: `None` keeps it; `Some` takes it off, after + // converting the colors from the profile it holds. + let mut decide = |label: &str, tag: Option, working: &str, policy: Policy, kind: cms::ProfileKind| -> Option> { + let tag = tag.filter(|t| t != working)?; + let available = cms::profile(&tag).is_some_and(|p| p.kind == kind); + match policy { + Policy::PreserveEmbedded => None, + Policy::Off | Policy::PreserveNumbers => { + notes.push(format!( + "the {label} profile “{tag}” the file is tagged with was not used: Color Settings' {label} policy is {}", + policy.label() + )); + Some(None) + } + Policy::Convert if available => { + notes.push(format!( + "the {label} colors were converted from the profile “{tag}” the file is tagged with into the working space “{working}”" + )); + Some(Some(tag)) + } + Policy::Convert => { + notes.push(format!("the {label} profile “{tag}” the file is tagged with is not available, so its colors were not converted")); + None + } + } + }; + let rgb_do = decide("RGB", rgb, &working.rgb, working.rgb_policy, cms::ProfileKind::Rgb); + let cmyk_do = decide("CMYK", cmyk, &working.cmyk, working.cmyk_policy, cms::ProfileKind::Cmyk); + if rgb_do.is_none() && cmyk_do.is_none() { + return (notes, false); + } + let (from_rgb, from_cmyk) = (rgb_do.clone().flatten(), cmyk_do.clone().flatten()); + if from_rgb.is_some() || from_cmyk.is_some() { + let source = cms::for_document(from_rgb.as_deref(), from_cmyk.as_deref()); + let dest = cms::working(); + let intent = working.intent; + let (rgb_on, cmyk_on) = (from_rgb.is_some(), from_cmyk.is_some()); + let conv = |c: &Color| match c { + Color::Rgb { .. } if rgb_on => source.convert_into(&dest, c, intent), + Color::Cmyk { .. } if cmyk_on => source.convert_into(&dest, c, intent), + _ => *c, + }; + convert_colors(d, &conv); + convert_setting_colors(d, &conv); + } + if rgb_do.is_some() { + d.color_profiles.rgb = None; + } + if cmyk_do.is_some() { + d.color_profiles.cmyk = None; + } + (notes, true) +} + +/// The colors of `d` outside its art that [`convert_colors`] leaves, converted with `conv`: each +/// artboard's background, the transparency grid and simulated paper colors of Document Setup, and +/// the fill and stroke of the character and paragraph styles. +fn convert_setting_colors(d: &mut vectorcraft_doc::Document, conv: &dyn Fn(&Color) -> Color) { + for c in d.artboards.iter_mut().filter_map(|a| a.color.as_mut()) { + *c = conv(c); + } + for c in &mut d.setup.grid_colors { + *c = conv(c); + } + // A tint of a global swatch is the converted swatch's color at its tint, which + // [`convert_colors`] already gave it. + let bases = global_bases(d); + let mut f = |c: &Color, link: proof::Link| mode_color(c, link, &bases, conv); + for styles in [&mut d.char_styles, &mut d.para_styles] { + if !styles.iter().any(|s| s.attrs.contains_key("fill") || s.attrs.contains_key("stroke")) { + continue; + } + for def in styles.iter_mut() { + for key in ["fill", "stroke"] { + let Some(value) = def.attrs.get_mut(key) else { continue }; + let Ok(mut paint) = serde_json::from_value::(value.clone()) else { continue }; + map_paint_colors(&mut paint, &mut f); + if let Ok(v) = serde_json::to_value(&paint) { + *value = v; + } + } + } + } +} + /// [`proof::map_node_colors`] over `n`'s subtree, then [`map_more_colors`]. fn map_art_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) { proof::map_node_colors(n, f); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 27024ef8eb..505c87cc8b 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row 36 pt apart (on trim boxes, plus the bleed on both sides), and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, through the profiles the document keeps after the color management policies of edit.colorSettings, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row 36 pt apart (on trim boxes, plus the bleed on both sides), and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; the color management policies of edit.colorSettings keep, take off or convert from the profiles the file is tagged with, their notes go to warnings, and a document they changed opens with unsaved changes; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 2ca51b3e00..2ff393b685 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -660,6 +660,17 @@ pub struct ReadFile { path: Option, /// What became of its linked files. links: Resolved, + /// Whether the color management policies changed the document ([`apply_policies`]). + policy_changed: bool, +} + +/// Edit › Color Settings: the color management policies keep, take off or convert from the +/// profiles the document `loaded` holds is tagged with, and their notes go to its warnings → +/// whether they changed the document. +fn apply_policies(loaded: &mut Loaded) -> bool { + let (notes, changed) = crate::cmd::colormgmt::apply_open_policies(&mut loaded.doc); + loaded.warnings.extend(notes); + changed } #[cfg(test)] @@ -680,17 +691,35 @@ impl ReadFile { /// ([`super::importer`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - let mut loaded = if opts.link_pages { - linked_pages(name, bytes, path.as_deref(), &opts)? + let (mut loaded, policy_changed) = if opts.link_pages { + let mut loaded = linked_pages(name, bytes, path.as_deref(), &opts)?; + let changed = apply_policies(&mut loaded); + (loaded, changed) } else { match super::importer::pick(name, bytes, p)? { - Some(plugin) => super::importer::load(name, bytes, &plugin, p)?, - None => load_with(name, bytes, &opts)?, + Some(plugin) => { + let mut loaded = super::importer::load(name, bytes, &plugin, p)?; + let changed = apply_policies(&mut loaded); + (loaded, changed) + } + None => { + // The color mode `p` asks for converts the colors after the policies did, through + // the profiles the document keeps. + let mut loaded = load_with(name, bytes, &LoadOptions { color_mode: None, ..opts.clone() })?; + let changed = apply_policies(&mut loaded); + if let Some(mode) = opts.color_mode.filter(|m| *m != loaded.doc.color_mode) { + let doc = &mut loaded.doc; + vectorcraft_color::cms::with_active(super::super::colormgmt::doc_cms(doc), || { + super::super::colormgmt::set_color_mode(doc, mode, true, None, opts.grays); + }); + } + (loaded, changed) + } } }; let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); - Ok(ReadFile { loaded, opts, path, links }) + Ok(ReadFile { loaded, opts, path, links, policy_changed }) } /// The PDF `name` (`bytes`, read from `path`) opened with `linkPages`: its pages as placed documents @@ -705,7 +734,7 @@ fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, opts: &LoadOptions /// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active /// document → `document.open`'s result. pub fn open_read(s: &mut Session, file: ReadFile) -> Result { - open_loaded(s, file.loaded, file.links, file.path, &file.opts, false) + open_loaded(s, file.loaded, file.links, file.path, &file.opts, false, file.policy_changed) } /// File → New from Template: any readable file (at `path`, if it is one: its links are looked for @@ -717,14 +746,24 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st "only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first", )); } + let policy_changed = apply_policies(&mut loaded); let base = crate::cmd::links::LinkBase::file(path); let links = crate::cmd::links::resolve(&mut loaded.doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); - open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true) + open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true, policy_changed) } -/// Make a loaded file the new active document; `links` is what became of its linked files, and -/// `opts` are the options it was read with. -fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { +/// Make a loaded file the new active document; `links` is what became of its linked files, `opts` +/// are the options it was read with, and `policy_changed` says whether the color management +/// policies changed it (it then opens with unsaved changes). +fn open_loaded( + s: &mut Session, + loaded: Loaded, + links: Resolved, + path: Option, + opts: &LoadOptions, + as_template: bool, + policy_changed: bool, +) -> Result { let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing @@ -765,6 +804,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option Result { let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?; let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); doc.template = false; + // The color management policies apply as when the file is opened. + let (notes, policy_changed) = crate::cmd::colormgmt::apply_open_policies(&mut doc); // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; crate::cmd::recovery::forget(s, uid); s.replace_document(index, doc); + if policy_changed { + s.doc_mut()?.mark_unsaved(); + } if let Some(pdf) = native_pdf_compatible { s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf)); } - Ok(json!({ "path": path })) + let mut reply = json!({ "path": path }); + if !notes.is_empty() { + reply["warnings"] = json!(notes); + } + Ok(reply) } /// `file.formatOptions`: a writable format's options with the values Save would use. @@ -1345,7 +1354,7 @@ pub(super) fn specs() -> Vec { "New from Template…", ["File"], Some("Cmd+Shift+N"), - "{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document → {index, title, format, warnings}", + "{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document; the color management policies of edit.colorSettings apply as at document.open → {index, title, format, warnings (with the policies' notes)}", always, super::load::new_from_template ), @@ -1354,7 +1363,7 @@ pub(super) fn specs() -> Vec { "Revert", ["File"], Some("F12"), - "{confirmed?: bool (the app's own: it asks first, then runs the command with it)} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path}. The app asks first (a confirm dialog) unless confirmed: true", + "{confirmed?: bool (the app's own: it asks first, then runs the command with it)} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path, warnings?: the notes of the color management policies of edit.colorSettings, which apply as at document.open}. The app asks first (a confirm dialog) unless confirmed: true", can_revert, revert ), diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index b02e34690b..8ea057cd12 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -823,7 +823,13 @@ fn color_settings_come_back_in_the_next_session() { a.execute("edit.colorSettings", &json!({})).unwrap(); assert!(!file.exists(), "nothing to keep before the settings change"); a.execute("edit.colorSettings", &json!({"rgb": loaded, "cmyk": cms::DEVICE_CMYK, "intent": "perceptual", "bpc": false})).unwrap(); - let chosen = cms::ColorSettings { rgb: loaded.into(), cmyk: cms::DEVICE_CMYK.into(), intent: cms::Intent::Perceptual, bpc: false }; + let chosen = cms::ColorSettings { + rgb: loaded.into(), + cmyk: cms::DEVICE_CMYK.into(), + intent: cms::Intent::Perceptual, + bpc: false, + ..cms::ColorSettings::default() + }; assert_eq!(cms::active_settings(), chosen); // The next start begins with the defaults and without the loaded profile, which the profile @@ -865,3 +871,163 @@ fn saved_color_settings_that_cannot_be_used_leave_the_defaults() { assert!(!saved.bpc && saved.cmyk == cms::GENERIC_CMYK, "written over by the next change: {saved:?}"); } } + +/// Edit › Color Settings' color management policies decide what opening a document tagged with +/// profiles other than the working spaces does with them: Preserve Embedded Profiles keeps them, +/// Off and Preserve Numbers take them off and keep the color numbers, and Convert to Working Space +/// converts the colors into the working spaces, keeping how they look, and takes them off. A +/// document a policy changed opens with unsaved changes, and the open reply names each profile. +/// A profile that is the working space stays, and File › Revert and File › New from Template apply +/// the policies too. +#[test] +fn color_management_policies_keep_take_off_or_convert_a_tagged_profile() { + let _g = settings(); + let dir = fresh_dir("policies"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + std::fs::create_dir_all(&dir).unwrap(); + let tagged = dir.join("tagged.vectorcraft"); + let mut s = session(); + let (blue, ink) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 0.0, 10.0, 10.0)); + exec(&mut s, "paint.setFill", json!({"ids": [blue.0], "color": "#3366cc"})); + exec(&mut s, "paint.setFill", json!({"ids": [ink.0], "color": {"c": 20, "m": 40, "y": 60, "k": 10}})); + exec(&mut s, "edit.assignProfile", json!({"rgb": cms::WIDE_GAMUT_RGB, "cmyk": cms::DEVICE_CMYK})); + // An artboard background, a character style's fill and a freeform gradient swatch, colors + // outside the art. + let paper = vectorcraft_color::Color::Rgb { r: 0.9, g: 0.8, b: 0.6 }; + let free = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + select(&mut s, &[free]); + exec(&mut s, "paint.setFill", json!({"gradient": {"kind": "freeform"}})); + exec(&mut s, "paint.freeform.setPoint", json!({"index": 0, "color": "#e6cc99", "stroke": false})); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.artboards[0].color = Some(paper); + let mut style = vectorcraft_doc::TextStyleDef { name: "Inked".into(), attrs: Default::default() }; + style.attrs.insert("fill".into(), serde_json::to_value(vectorcraft_color::Paint::solid(paper)).unwrap()); + d.char_styles.push(style); + let paint = d.node(free).unwrap().appearance.fill().unwrap().paint.clone(); + d.swatches.push(vectorcraft_color::Swatch { name: "Free".into(), paint, global: false, spot: false }); + } + exec(&mut s, "file.saveAs", json!({"path": tagged.to_str().unwrap()})); + let settings_colors = |s: &Session| { + let d = &s.doc().unwrap().doc; + let style = serde_json::from_value::(d.char_styles[0].attrs["fill"].clone()).unwrap(); + (d.artboards[0].color.unwrap(), style.color().unwrap()) + }; + // The first point of the freeform swatch and of the freeform fill. + let free_points = |s: &Session| { + let d = &s.doc().unwrap().doc; + let first = |p: &vectorcraft_color::Paint| match p { + vectorcraft_color::Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points[0].color), + _ => None, + }; + let swatch = d.swatches.iter().find(|w| w.name == "Free").and_then(|w| first(&w.paint)).unwrap(); + (swatch, first(&d.node(free).unwrap().appearance.fill().unwrap().paint).unwrap()) + }; + let (free0, _) = free_points(&s); + let fill = |s: &Session, id| s.doc().unwrap().doc.node(id).and_then(|n| n.appearance.fill()).and_then(|f| f.paint.color()).unwrap(); + let (blue0, ink0) = (fill(&s, blue), fill(&s, ink)); + let source = cms::for_document(Some(cms::WIDE_GAMUT_RGB), Some(cms::DEVICE_CMYK)); + let notes = |r: &serde_json::Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("profile")).count(); + // A session that opened the file under the policies, and the open reply. + let open = |rgb: &str, cmyk: &str| { + let mut s = session(); + exec(&mut s, "edit.colorSettings", json!({"rgbPolicy": rgb, "cmykPolicy": cmyk})); + let r = exec(&mut s, "document.open", json!({"path": tagged.to_str().unwrap()})); + (r, s) + }; + + let (r, kept) = open("preserveEmbedded", "preserveEmbedded"); + assert_eq!(doc_profiles(&kept.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), Some(cms::DEVICE_CMYK.into()))); + assert_eq!((fill(&kept, blue), fill(&kept, ink), notes(&r)), (blue0, ink0, 0)); + assert!(!kept.doc().unwrap().is_dirty()); + + for (rgb, cmyk) in [("off", "off"), ("off", "preserveNumbers")] { + let (r, off) = open(rgb, cmyk); + assert_eq!(doc_profiles(&off.doc().unwrap().doc), (None, None), "{cmyk}"); + assert_eq!((fill(&off, blue), fill(&off, ink), notes(&r)), (blue0, ink0, 2), "{cmyk}: the numbers stay"); + assert!(off.doc().unwrap().is_dirty(), "{cmyk}: the document changed"); + } + + let (r, converted) = open("convert", "convert"); + assert_eq!(doc_profiles(&converted.doc().unwrap().doc), (None, None)); + assert_eq!(notes(&r), 2); + let working = cms::working(); + let (blue1, ink1) = (fill(&converted, blue), fill(&converted, ink)); + assert!(blue1 != blue0 && ink1 != ink0, "the numbers change: {blue1:?} {ink1:?}"); + assert!(cms::delta_e2000(source.lab(&blue0), working.lab(&blue1)) < 1.0, "{blue0:?} → {blue1:?}"); + assert!(cms::delta_e2000(source.lab(&ink0), working.lab(&ink1)) < 3.0, "{ink0:?} → {ink1:?}"); + assert!(converted.doc().unwrap().is_dirty()); + let (board, styled) = settings_colors(&converted); + assert_eq!(board, styled, "the artboard and the style convert alike"); + assert!(board != paper && cms::delta_e2000(source.lab(&paper), working.lab(&board)) < 1.0, "{paper:?} → {board:?}"); + assert_eq!(settings_colors(&kept), (paper, paper), "kept with the profile"); + let (swatch_point, fill_point) = free_points(&converted); + assert_eq!(swatch_point, fill_point, "the freeform swatch and fill convert alike"); + assert!(swatch_point != free0 && cms::delta_e2000(source.lab(&free0), working.lab(&swatch_point)) < 1.0, "{free0:?} → {swatch_point:?}"); + + // A color mode asked for on open converts the colors the policy converted, through the working + // spaces, also when the document in front draws through another profile. + let mut s2 = session(); + exec(&mut s2, "edit.assignProfile", json!({"rgb": cms::DISPLAY_P3})); + exec(&mut s2, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "convert"})); + exec(&mut s2, "document.open", json!({"path": tagged.to_str().unwrap(), "colorMode": "cmyk"})); + let intent = working.settings().intent; + let want = working.convert(&blue1, cms::Model::Cmyk, intent); + let got = fill(&s2, blue); + assert!(cms::delta_e2000(working.lab(&want), working.lab(&got)) < 0.01, "{want:?} vs {got:?}"); + + // A profile that is the working space is kept whatever the policy. + exec(&mut s, "edit.colorSettings", json!({"rgb": cms::WIDE_GAMUT_RGB})); + let (r, same) = open("off", "off"); + assert_eq!(doc_profiles(&same.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), None)); + assert_eq!(notes(&r), 1); + exec(&mut s, "edit.colorSettings", json!({"rgb": cms::SRGB})); + + // File › Revert reads the file again under the policies. + let (_, mut reverted) = open("preserveEmbedded", "preserveEmbedded"); + exec(&mut reverted, "paint.setFill", json!({"ids": [blue.0], "color": "#ff0000"})); + exec(&mut reverted, "edit.colorSettings", json!({"cmykPolicy": "preserveNumbers"})); + let r = exec(&mut reverted, "file.revert", json!({"confirmed": true})); + assert_eq!(doc_profiles(&reverted.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), None)); + assert_eq!((fill(&reverted, blue), notes(&r)), (blue0, 1)); + + // File › New from Template applies the policies as opening does. + let mut from_template = session(); + exec(&mut from_template, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "off"})); + let r = exec(&mut from_template, "file.newFromTemplate", json!({"path": tagged.to_str().unwrap()})); + assert_eq!(doc_profiles(&from_template.doc().unwrap().doc), (None, None)); + assert_eq!(notes(&r), 2); + assert_eq!(fill(&from_template, ink), ink0, "Off keeps the CMYK numbers"); + let blue2 = fill(&from_template, blue); + assert!(blue2 != blue0 && cms::delta_e2000(source.lab(&blue0), working.lab(&blue2)) < 1.0, "{blue0:?} → {blue2:?}"); + assert!(from_template.doc().unwrap().is_dirty()); +} + +/// The policies are checked, come back in the reply and in `Color Settings.json`, and a saved file +/// that gives the RGB policy Preserve Numbers, a CMYK policy only, leaves the defaults. +#[test] +fn color_management_policies_are_checked_and_kept() { + let _g = settings(); + let dir = fresh_dir("policies-kept"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + let file = dir.join("Color Settings.json"); + let mut s = session(); + s.attach_color_settings(file.clone()); + for bad in [json!({"rgbPolicy": "preserveNumbers"}), json!({"cmykPolicy": "keep"}), json!({"rgbPolicy": 1})] { + assert!(s.execute("edit.colorSettings", &bad).is_err(), "{bad}"); + } + assert!(!file.exists(), "a refused change keeps nothing"); + let r = exec(&mut s, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "preserveNumbers"})); + assert_eq!((r["rgbPolicy"].as_str(), r["cmykPolicy"].as_str()), (Some("convert"), Some("preserveNumbers"))); + let saved: cms::ColorSettings = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!((saved.rgb_policy, saved.cmyk_policy), (cms::Policy::Convert, cms::Policy::PreserveNumbers)); + cms::set_active(&cms::ColorSettings::default()).unwrap(); + std::fs::write( + &file, + json!({"rgb": cms::SRGB, "cmyk": cms::GENERIC_CMYK, "intent": "relative", "bpc": true, "rgbPolicy": "preserveNumbers"}).to_string(), + ) + .unwrap(); + let mut next = Session::new(); + next.attach_color_settings(file); + assert_eq!(cms::active_settings(), cms::ColorSettings::default()); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 0a496adf59..269048c279 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5053,6 +5053,11 @@ axis Both И двете @msg Imported library {name} Импортирана библиотека {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Правило за RGB: + CMYK Policy: Правило за CMYK: + Preserve Embedded Profiles Запазване на вградените профили + Convert to Working Space Преобразуване в работното пространство + Preserve Numbers Запазване на числата Texture Текстура Craquelure… Кракелюр… Grain… Зърнистост… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 71639300c2..07f55531dd 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -986,6 +986,11 @@ NTSC Colors NTSC boje RGB: RGB: CMYK: CMYK: + RGB Policy: RGB pravilo: + CMYK Policy: CMYK pravilo: + Preserve Embedded Profiles Zadrži ugrađene profile + Convert to Working Space Pretvori u radni prostor + Preserve Numbers Zadrži brojeve CMYK CMYK Texture Tekstura Craquelure… Krakelura… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 69ad81c0b5..450cedaf7f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -765,6 +765,11 @@ antiAlias Sharp Nítid CMYK Equivalents Equivalents CMYK CMYK profile in effect Perfil CMYK en vigor CMYK: CMYK: + RGB Policy: Política RGB: + CMYK Policy: Política CMYK: + Preserve Embedded Profiles Conserva els perfils incrustats + Convert to Working Space Converteix a l'espai de treball + Preserve Numbers Conserva els números Coarse Dots Punts gruixuts Collapse Panel Group Replega el grup de panells Collapse Panels to Icons Replega els panells a icones diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 477da82ca4..eafb3089ec 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1005,6 +1005,11 @@ NTSC Colors Barvy NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Zásada RGB: + CMYK Policy: Zásada CMYK: + Preserve Embedded Profiles Zachovat vložené profily + Convert to Working Space Převést do pracovního prostoru + Preserve Numbers Zachovat čísla CMYK CMYK Texture Textura Craquelure… Krakelování… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index ce49ee2762..ebb1f0a8f2 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1538,6 +1538,11 @@ NTSC Colors NTSC-farver RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-politik: + CMYK Policy: CMYK-politik: + Preserve Embedded Profiles Bevar indlejrede profiler + Convert to Working Space Konverter til arbejdsfarverum + Preserve Numbers Bevar tal CMYK CMYK Texture Tekstur Craquelure… Krakelering… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 74190bede1..29d590132c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5381,6 +5381,11 @@ theme Appearance Erscheinungsbild The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Die Hauptfarbe wird zur Konturfarbe. Die anderen Farben wandern ebenso weit um den Farbkreis wie die Hauptfarbe und behalten ihre Sättigung und Helligkeit. RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-Richtlinie: + CMYK Policy: CMYK-Richtlinie: + Preserve Embedded Profiles Eingebettete Profile beibehalten + Convert to Working Space In Arbeitsfarbraum konvertieren + Preserve Numbers Werte beibehalten # PSD files. @msg not a PSD file keine PSD-Datei @msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode PSD-Dateien im Mehrkanalmodus können nicht gelesen werden: Speichern Sie die Datei im RGB-, CMYK- oder Graustufenmodus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 599dfcc80a..d405f649b1 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5353,6 +5353,11 @@ digits Malayalam Μαλαγιάλαμ @msg Imported library {name} Εισήχθη η βιβλιοθήκη {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Πολιτική RGB: + CMYK Policy: Πολιτική CMYK: + Preserve Embedded Profiles Διατήρηση ενσωματωμένων προφίλ + Convert to Working Space Μετατροπή στον χώρο εργασίας + Preserve Numbers Διατήρηση αριθμών System Title Bar Γραμμή τίτλου συστήματος VectorCraft Title Bar on Every Desktop Γραμμή τίτλου του VectorCraft σε κάθε επιφάνεια εργασίας The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Όσο είναι ορισμένη, η μεταβλητή περιβάλλοντος VECTORCRAFT_TITLEBAR επιλέγει τη γραμμή τίτλου. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 32d73e1bb5..4ca92f59dd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5399,6 +5399,11 @@ axis Both Ambos @msg Imported library {name} Biblioteca {name} importada RGB: RGB: CMYK: CMYK: + RGB Policy: Norma RGB: + CMYK Policy: Norma CMYK: + Preserve Embedded Profiles Conservar perfiles incrustados + Convert to Working Space Convertir en espacio de trabajo + Preserve Numbers Conservar números Texture Textura Craquelure… Craquelado… Grain… Granulado… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index de75dd069f..69031c76b9 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -989,6 +989,11 @@ NTSC Colors NTSC-värit RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-käytäntö: + CMYK Policy: CMYK-käytäntö: + Preserve Embedded Profiles Säilytä upotetut profiilit + Convert to Working Space Muunna työväriavaruuteen + Preserve Numbers Säilytä lukuarvot CMYK CMYK Texture Tekstuuri Craquelure… Krakelyyri… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 95d6fb815e..c9e3f12fd7 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5404,6 +5404,11 @@ axis Both Les deux @msg Imported library {name} Bibliothèque {name} importée RGB: RVB : CMYK: CMJN : + RGB Policy: Règle RVB : + CMYK Policy: Règle CMJN : + Preserve Embedded Profiles Conserver les profils incorporés + Convert to Working Space Convertir en espace de travail + Preserve Numbers Conserver les valeurs numériques Texture Texture Craquelure… Craquelure… Grain… Grain… diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index fcc98d7a1c..2ff32c7e3e 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -472,6 +472,11 @@ CMYK Images CMYK ઇમેજ CMYK colours print with their own values; off, they go through the colour settings too. CMYK રંગો પોતાની કિંમતો સાથે છાપે છે; બંધ હોય ત્યારે તે રંગ સેટિંગમાંથી પણ પસાર થાય છે. CMYK: CMYK: + RGB Policy: RGB નીતિ: + CMYK Policy: CMYK નીતિ: + Preserve Embedded Profiles એમ્બેડ કરેલી પ્રોફાઇલ જાળવો + Convert to Working Space કાર્યકારી સ્પેસમાં રૂપાંતરિત કરો + Preserve Numbers સંખ્યાઓ જાળવો CSS Properties CSS ગુણધર્મો Calligraphic Brush કેલિગ્રાફિક બ્રશ Calligraphic Brush Options કેલિગ્રાફિક બ્રશ વિકલ્પો diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 831dfd7ccf..7530afee88 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -892,6 +892,11 @@ antiAlias Sharp शार्प CMYK Images CMYK इमेज CMYK profile in effect इफ़ेक्ट में CMYK प्रोफ़ाइल CMYK: CMYK: + RGB Policy: RGB नीति: + CMYK Policy: CMYK नीति: + Preserve Embedded Profiles एम्बेड की गई प्रोफ़ाइल बनाए रखें + Convert to Working Space वर्किंग स्पेस में बदलें + Preserve Numbers संख्याएँ बनाए रखें Coarse Dots मोटे बिंदु Coincidence: संयोग: Collapse Panel Group पैनल समूह संक्षिप्त करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b4a8e610e4..56a5a711ab 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -696,6 +696,11 @@ font-technology Variable Változó CMYK Equivalents CMYK-megfelelők CMYK profile in effect Érvényes CMYK-profil CMYK: CMYK: + RGB Policy: RGB-házirend: + CMYK Policy: CMYK-házirend: + Preserve Embedded Profiles Beágyazott profilok megtartása + Convert to Working Space Konvertálás a munkaszíntérbe + Preserve Numbers Számértékek megtartása Coarse Dots Durva pontok Collapse Panels to Icons Panelek ikonná csukása Collapse to Icons Ikonná csukás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ce6e304d9e..e1a003c623 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -928,6 +928,11 @@ NTSC Colors Warna NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Kebijakan RGB: + CMYK Policy: Kebijakan CMYK: + Preserve Embedded Profiles Pertahankan Profil Tersemat + Convert to Working Space Konversi ke Ruang Kerja + Preserve Numbers Pertahankan Angka CMYK CMYK Texture Tekstur Craquelure… Craquelure… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5d25a79183..954494b6fd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5449,6 +5449,11 @@ axis Both Entrambi @msg Imported library {name} Libreria {name} importata RGB: RGB: CMYK: CMYK: + RGB Policy: Criterio RGB: + CMYK Policy: Criterio CMYK: + Preserve Embedded Profiles Mantieni profili incorporati + Convert to Working Space Converti nello spazio di lavoro + Preserve Numbers Mantieni valori numerici Texture Texture Craquelure… Craquelé… Grain… Granulosità… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5f6a2324e6..5fa0d599bc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5490,6 +5490,11 @@ swatch Bright Violet ビビッドバイオレット @msg Imported library {name} ライブラリ {name} を読み込みました RGB: RGB: CMYK: CMYK: + RGB Policy: RGB ポリシー: + CMYK Policy: CMYK ポリシー: + Preserve Embedded Profiles 埋め込まれたプロファイルを保持 + Convert to Working Space 作業用スペースに変換 + Preserve Numbers カラー値を保持 Texture テクスチャ Craquelure… クラッキング… Grain… 粒状… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 966d41228a..b8867c2ad6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -894,6 +894,11 @@ NTSC Colors NTSC ფერები RGB: RGB: CMYK: CMYK: + RGB Policy: RGB წესი: + CMYK Policy: CMYK წესი: + Preserve Embedded Profiles ჩაშენებული პროფილების შენარჩუნება + Convert to Working Space სამუშაო სივრცეში გადაყვანა + Preserve Numbers რიცხვების შენარჩუნება CMYK CMYK Texture ტექსტურა Craquelure… კრაკელიური… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a8ce3052bd..e2b4a69d3c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5417,6 +5417,11 @@ theme Appearance 외관 @msg Imported library {name} 라이브러리 {name}을(를) 가져왔습니다 RGB: RGB: CMYK: CMYK: + RGB Policy: RGB 정책: + CMYK Policy: CMYK 정책: + Preserve Embedded Profiles 포함된 프로필 유지 + Convert to Working Space 작업 공간으로 변환 + Preserve Numbers 숫자 유지 Texture 텍스처 Craquelure… 크랙… Grain… 그레인… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e00635ea3b..765f385b81 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1446,6 +1446,11 @@ NTSC Colors NTSC-kleuren RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-beleid: + CMYK Policy: CMYK-beleid: + Preserve Embedded Profiles Ingesloten profielen behouden + Convert to Working Space Omzetten naar werkruimte + Preserve Numbers Waarden behouden CMYK CMYK Texture Textuur Craquelure… Craquelure… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5601243a1a..d0cd94591b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -946,6 +946,11 @@ NTSC Colors Kolory NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Zasada RGB: + CMYK Policy: Zasada CMYK: + Preserve Embedded Profiles Zachowaj osadzone profile + Convert to Working Space Konwertuj do przestrzeni roboczej + Preserve Numbers Zachowaj wartości liczbowe CMYK CMYK Texture Tekstura Craquelure… Spękania… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 212af061b7..faa00f560c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3506,6 +3506,11 @@ imageTrace Outline Contorno NTSC Colors Cores NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Política RGB: + CMYK Policy: Política CMYK: + Preserve Embedded Profiles Preservar perfis incorporados + Convert to Working Space Converter para espaço de trabalho + Preserve Numbers Preservar números Texture Textura Craquelure… Craquelê… Grain… Granulação… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9da9dd0090..7df56e123a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5438,6 +5438,11 @@ axis Both Оба @msg Imported library {name} Импортирована библиотека {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Правило RGB: + CMYK Policy: Правило CMYK: + Preserve Embedded Profiles Сохранять встроенные профили + Convert to Working Space Преобразовывать в рабочее пространство + Preserve Numbers Сохранять числа Texture Текстура Craquelure… Кракелюры… Grain… Зерно… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index eeb57dd01b..4c2c6d29f9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5306,6 +5306,11 @@ axis Both Båda @msg Imported library {name} Importerade biblioteket {name} RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-princip: + CMYK Policy: CMYK-princip: + Preserve Embedded Profiles Behåll inbäddade profiler + Convert to Working Space Konvertera till arbetsfärgrymd + Preserve Numbers Behåll värden Texture Textur Craquelure… Krakelering… Grain… Korn… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2f2328d207..d7055a3647 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5204,6 +5204,11 @@ axis Both Her İkisi @msg Imported library {name} İçe aktarılan kitaplık {name} RGB: RGB: CMYK: CMYK: + RGB Policy: RGB İlkesi: + CMYK Policy: CMYK İlkesi: + Preserve Embedded Profiles Gömülü Profilleri Koru + Convert to Working Space Çalışma Alanına Dönüştür + Preserve Numbers Sayıları Koru Texture Doku Craquelure… Küçük çatlak… Grain… Tahıl… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7f28b55aa1..5c764ad136 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5433,6 +5433,11 @@ axis Both Обидві @msg Imported library {name} Імпортовано бібліотеку {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Правило RGB: + CMYK Policy: Правило CMYK: + Preserve Embedded Profiles Зберігати вбудовані профілі + Convert to Working Space Перетворювати в робочий простір + Preserve Numbers Зберігати числа Texture Текстура Craquelure… Кракелюри… Grain… Зерно… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c2a8ccca71..615c1cf5d7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -608,6 +608,11 @@ font-technology Variable Biến thiên CMYK Equivalents Tương đương CMYK CMYK profile in effect Hồ sơ CMYK đang có hiệu lực CMYK: CMYK: + RGB Policy: Chính sách RGB: + CMYK Policy: Chính sách CMYK: + Preserve Embedded Profiles Giữ cấu hình nhúng + Convert to Working Space Chuyển sang không gian làm việc + Preserve Numbers Giữ giá trị số Coarse Dots Chấm thô Collapse Panels to Icons Thu gọn bảng thành biểu tượng Collapse to Icons Thu gọn thành biểu tượng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9d20c3f591..9ea739311b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5335,6 +5335,11 @@ imageTrace Outline 轮廓 None (Color Preview) 无 (颜色预览) Preserve 保留 CMYK: CMYK: + RGB Policy: RGB 策略: + CMYK Policy: CMYK 策略: + Preserve Embedded Profiles 保留嵌入的配置文件 + Convert to Working Space 转换为工作空间 + Preserve Numbers 保留数值 Art & Illustration 艺术和插画 Branding 品牌 Film & Video 影片和视频 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4e0fe79ff0..f77e02f7f8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5567,6 +5567,11 @@ axis Both 兩者 Interpolation 內插 RGB: RGB: CMYK: CMYK: + RGB Policy: RGB 原則: + CMYK Policy: CMYK 原則: + Preserve Embedded Profiles 保留內嵌描述檔 + Convert to Working Space 轉換為工作空間 + Preserve Numbers 保留數值 Texture 紋理 Craquelure… 龜裂紋… Grain… 顆粒… diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index d3cf595666..bdb4210920 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -1,11 +1,12 @@ //! Separations Preview panel: process plates C/M/Y/K plus spot plates with eye toggles (one visible //! plate renders as greyscale ink coverage; Alt-click an eye to show only that plate), and the //! colour-management controls behind Edit → Color Settings… / Assign Profile… and View → Proof -//! Setup (working spaces, intent, black-point compensation, proof target, gamut check). +//! Setup (working spaces, color management policies, intent, black-point compensation, proof +//! target, gamut check). use egui::{Sense, Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_color::cms::{self, Intent, ProfileKind}; +use vectorcraft_color::cms::{self, Intent, Policy, ProfileKind}; use vectorcraft_render::proof; use crate::VectorcraftApp; @@ -100,8 +101,9 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, Load -/// Profile… and Gamut Check, and the profile the active document is assigned. +/// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, the RGB +/// and CMYK color management policies, Load Profile… and Gamut Check, and the profile the active +/// document is assigned. fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { let st = cms::working_settings(); widgets::section_header(ui, tl!("Color Settings")); @@ -127,6 +129,21 @@ fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::check(ui, tl!("Use Black Point Compensation"), st.bpc, true) { run(app, "edit.colorSettings", json!({"bpc": !st.bpc})); } + // Color management policies: what opening a document does with a profile it is tagged with + // that is not the working space. + let policies: [(&str, &str, Policy, &[Policy]); 2] = + [("rgbPolicy", tl!("RGB Policy:"), st.rgb_policy, &Policy::RGB), ("cmykPolicy", tl!("CMYK Policy:"), st.cmyk_policy, &Policy::CMYK)]; + for (key, label, current, choices) in policies { + ui.horizontal(|ui| { + ui.label(label); + let labels: Vec<&str> = choices.iter().map(|p| policy_label(*p)).collect(); + if let Some(i) = widgets::dropdown_names(ui, key, policy_label(current), &labels, 210.0) + && let Some(p) = choices.get(i) + { + run(app, "edit.colorSettings", json!({ key: p.id() })); + } + }); + } ui.horizontal(|ui| { if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked() { // Its file dialog, shown off the UI thread, loads the profile when it answers. @@ -243,6 +260,16 @@ fn assign_rows(app: &mut VectorcraftApp, ui: &mut Ui, row: &AssignRow) { } } +/// The name of color management policy `p` in the UI language. +fn policy_label(p: Policy) -> &'static str { + match p { + Policy::Off => tl!("Off"), + Policy::PreserveEmbedded => tl!("Preserve Embedded Profiles"), + Policy::Convert => tl!("Convert to Working Space"), + Policy::PreserveNumbers => tl!("Preserve Numbers"), + } +} + /// The status bar's line after Gamut Check finds `n` colors outside the CMYK gamut. pub(crate) fn gamut_check_status(n: usize) -> String { if n == 0 { diff --git a/docs/mcp.md b/docs/mcp.md index 6c47007f2c..c292c66685 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1666,10 +1666,18 @@ preferences (the folder the desktop app uses, so both see the same ones; `VECTOR command, so `edit.colorSettings {"cmyk": "…"}` or a preset's output intent can name it in a later run. The copy is what counts: the original may move or be deleted. `color.removeProfile {name}` forgets a profile and its copy (not while it is a working space). -- **Color Settings** (`edit.colorSettings`): the RGB and CMYK working spaces, intent and black point - compensation go into `Color Settings.json` when they change, and are the active settings again at the next - start, after the profiles above are registered (#6335). Saved settings that name a profile that is no longer - there, or a damaged file, leave the defaults (logged), and the next change writes over the file. +- **Color Settings** (`edit.colorSettings`): the RGB and CMYK working spaces, intent, black point + compensation and color management policies go into `Color Settings.json` when they change, and are the active + settings again at the next start, after the profiles above are registered (#6335). Saved settings that name a + profile that is no longer there, or a damaged file, leave the defaults (logged), and the next change writes over + the file; settings saved without policies take the default, `preserveEmbedded`. `rgbPolicy` (`off`, + `preserveEmbedded`, `convert`) and `cmykPolicy` (the same and `preserveNumbers`) decide what `document.open`, + `file.newFromTemplate` and `file.revert` do with an RGB or CMYK profile the file is tagged with that is not the + working space: keep it, take it off and keep the color numbers (`off`, `preserveNumbers`), or convert the colors + of that model into the working space with the intent and black point compensation and take it off (`convert`; a + profile that is not available stays). The reply's `warnings` name each profile, and a document a policy changed + opens with unsaved changes. A profile that is the working space stays whatever the policy. `document.open`'s + `colorMode` converts the colors after the policies, through the profiles the document keeps. `edit.assignProfile` tags the document only: colors show and convert through the profiles it assigns, and the working spaces and the file stay as they are. The profiles in use are those of the document in front, after `file.new`, `document.open`, `document.activate`, `file.close`, `file.revert`, `edit.undo`, `edit.redo` and a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d0e54a374f..be90c99930 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -758,6 +758,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Translucent art blended before the display profile:** with Use Monitor Profile for Canvas converting, translucent art and the anti-aliased edges of art over an artboard's paper, a colored artboard or the transparency grid are blended with it in sRGB and the blend is converted, so the monitor's profile translates the document's colors as they are composited ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). The frame rendered whole, the part an edit renders again, the render worker's frame, the pan fill and the other windows' panes do the same; over the pasteboard and in Outline the art is converted on its own, and with no profile to convert into nothing changes. Tests `display_profile::tests::translucent_pixels_over_the_paper_are_blended_before_they_are_converted`, `the_transparency_grid_is_blended_cell_by_cell`, `the_backdrop_is_found_through_the_view` and `canvas::tests::translucent_art_over_the_paper_is_blended_before_the_display_profile`. - **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. - **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. With Window › Arrange › Tile or floating document windows, each document behind the front one draws its art, paper and transparency grid through its own assigned profiles, and loading or removing a profile redraws the documents assigned it; placed documents inside it still draw through the front document's profiles. How documents behind the front one draw is VectorCraft's own rule. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. +- **Color management policies:** Edit › Color Settings… (the Separations panel's Color Settings section and `edit.colorSettings`) has an RGB and a CMYK policy: Off, Preserve Embedded Profiles and Convert to Working Space, and Preserve Numbers for CMYK. When a document tagged with a profile other than the working space is opened, reverted or opened as a template, the policy keeps the profile, takes it off with the color numbers kept, or converts that model's colors into the working space, with black point compensation as Color Settings set it, and takes it off. The policies are kept in `Color Settings.json`. Preserve Embedded Profiles as the default for both, and the per-model policy for a document tagged with both an RGB and a CMYK profile, are VectorCraft's own rules; placed images and the Profile Mismatches and Missing Profiles prompts are not covered. Public reference: [Color management policy options](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=157). Tests in `engine/tests/color_settings.rs`, `color/src/cms/tests.rs` and `color/src/cms/tests_icc.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `profile_errors_name_the_file_by_its_name_outside_the_read_roots`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy).