From 5f9c7d307ef6abd6ec4336d8564328246da3cc44 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 18:25:28 -0400 Subject: [PATCH] New Document: Color Profile, the working space or another profile of the mode (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop's New Document has Advanced Options › Color Profile; PhotoCraft's always opened in the working space, so a document in another space had to be made and then assigned a profile. `file.new` takes `profile` (working, a built-in id or an .icc path), checked against the colour mode before the document opens, and the New Document dialog lists the working space (by its profile) and the built-in and installed profiles of the mode's colour space. Changing the mode drops a profile of the old space. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 14 +++- crates/engine/src/document_preset_cmds.rs | 1 + crates/engine/src/tests.rs | 25 +++++++ crates/ui-egui/src/new_doc_ui.rs | 82 +++++++++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2f04187128..df882a5ee5 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -467,7 +467,7 @@ fn build() -> Vec { "New…", ["File"], Some("Cmd+N"), - r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str}"##, + r##"{"width":1..300000=1920,"height":1..300000=1080 (pixel counts are rounded),"mode":"rgb|gray|grayscale|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","backgroundColor":[r,g,b]? (0..1; defaults to toolbox colour),"resolution":1..30000=72 (ppi),"name":str,"profile":"working"|built-in id|path to an .icc file="working" (the colour profile it is tagged with; it must suit the mode)}"##, always, |s, p| { use crate::document_preset_cmds::{MAX_DIMENSION, MAX_RESOLUTION, background_color, color_mode, sample_type}; @@ -503,6 +503,18 @@ fn build() -> Vec { } }; doc.resolution_dpi = res; + // Photoshop's New Document › Color Profile: tag the document with another profile + // than the working space (#321). Checked before the document opens. + if let Some(spec) = p.get("profile").and_then(Value::as_str).filter(|spec| !matches!(*spec, "working" | "none")) { + let prof = s.color.resolve(spec, Some(&doc), None)?; + if prof.color_space != crate::color_cmds::mode_space(doc.mode) { + return Err(EngineError::BadParams { + cmd: "file.new".into(), + msg: format!("profile `{}` is {:?}; the document is {:?}", prof.description, prof.color_space, doc.mode), + }); + } + doc.icc_profile = Some(prof.to_bytes()); + } let i = s.add_document(doc, None); Ok(json!({ "document": i })) } diff --git a/crates/engine/src/document_preset_cmds.rs b/crates/engine/src/document_preset_cmds.rs index 0bb409b818..f0847e4b8b 100644 --- a/crates/engine/src/document_preset_cmds.rs +++ b/crates/engine/src/document_preset_cmds.rs @@ -78,6 +78,7 @@ pub(crate) fn validate_new_params(s: &Session, p: &Value) -> Result<()> { continue; } "name" => (value.is_string(), "a string".into()), + "profile" => (value.as_str().is_some_and(|v| !v.is_empty()), "working, a built-in profile id or an .icc path".into()), _ => return Err(bad(cmd, format!("unknown parameter `{key}`"))), }; if !valid { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 04063d3073..9c105158c9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -158,6 +158,31 @@ fn file_new_keeps_defaults_and_supported_modes_and_depths() { } } +#[test] +fn file_new_tags_the_document_with_the_chosen_profile() { + // #321: New Document › Color Profile, as in Photoshop: another profile than the working space. + let desc = |s: &Session| { + let bytes = s.active().unwrap().doc.icc_profile.clone(); + bytes.map(|b| photocraft_cms::Profile::parse(&b).unwrap().description) + }; + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4, "profile": "adobe-rgb-compat"})).unwrap(); + assert_eq!(desc(&s).as_deref(), Some(photocraft_cms::Builtin::AdobeRgbCompat.description())); + s.execute("file.new", json!({"width": 4, "height": 4, "mode": "cmyk", "profile": "coated-cmyk"})).unwrap(); + assert!(desc(&s).is_some()); + // The working space (the default, or asked for) leaves it as before. + for p in [json!({"width": 4, "height": 4}), json!({"width": 4, "height": 4, "profile": "working"})] { + s.execute("file.new", p).unwrap(); + assert_eq!(desc(&s), None); + } + // A profile that doesn't suit the mode, an unknown one or a wrong type opens nothing. + let open = s.documents().len(); + for bad in [json!("coated-cmyk"), json!("no-such-profile"), json!(""), json!(7), json!(null)] { + assert!(s.execute("file.new", json!({"width": 4, "height": 4, "profile": bad})).is_err(), "{bad}"); + } + assert_eq!(s.documents().len(), open); +} + #[test] fn file_new_variants() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index e615620d4c..fd43bc1535 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -231,6 +231,33 @@ pub fn apply_preset(f: &mut Map, p: &Preset, units: &UnitsAndRule f.insert("__unit".into(), json!(unit)); } +/// The colour mode of the dialog's `mode` value. +fn color_mode_of(mode: &str) -> Option { + use photocraft_color::ColorMode; + match mode { + "rgb" => Some(ColorMode::Rgb), + "gray" | "grayscale" => Some(ColorMode::Grayscale), + "cmyk" => Some(ColorMode::Cmyk), + "lab" => Some(ColorMode::Lab), + _ => None, + } +} + +/// The Color Profile menu for a document of colour `space` (#321): the working space (shown with +/// its profile's description), then the built-in and installed profiles of that space, as +/// `file.new`'s `profile` values (an id or a path) with their labels. +fn profile_options( + space: Option, + working: &str, + installed: &[photocraft_engine::installed_profiles::InstalledProfile], +) -> Vec<(String, String)> { + let mut v = vec![("working".to_string(), crate::i18n::fmt(tl!("Working: {working}"), &[("working", working)]))]; + let Some(space) = space else { return v }; + v.extend(photocraft_cms::Builtin::ALL.iter().filter(|b| b.profile().color_space == space).map(|b| (b.id().to_string(), b.description().to_string()))); + v.extend(installed.iter().filter(|i| i.is_destination() && i.color_space == space).map(|i| (i.path.clone(), i.description.clone()))); + v +} + /// Fields `file.new` takes (drops the dialog's `__` UI keys). pub fn command_params(f: &Map) -> Value { Value::Object(f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect()) @@ -695,6 +722,8 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = opts.iter().map(|(v, l)| (v.clone(), l.as_str())).collect(); + if widgets::dropdown(ui, "nd-profile", &mut profile, &shown, 240.0) { + if profile == "working" { + f.remove("profile"); + } else { + f.insert("profile".into(), json!(profile)); + } + f.remove("__savedPreset"); + } + } + ui.add_space(4.0); small_label(ui, tl!("Background Contents")); // A custom colour is kept as its "#rrggbb", which `file.new` takes as is (#4789). let current = get_s(f, "background", "white"); @@ -768,6 +821,35 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map>(); + let rgb = values("rgb"); + assert_eq!(rgb[0], "working"); + assert!(rgb.contains(&Builtin::AdobeRgbCompat.id().to_string()) && rgb.contains(&"panel.icc".to_string())); + assert!(!rgb.contains(&Builtin::CoatedCmyk.id().to_string()) && !rgb.contains(&"press.icc".to_string())); + let cmyk = values("cmyk"); + assert!(cmyk.contains(&"press.icc".to_string()) && !cmyk.contains(&Builtin::Srgb.id().to_string())); + assert!(profile_options(None, "sRGB", &installed).len() == 1, "an unknown mode offers only the working space"); + // Every entry is one `file.new` takes for that mode. + for mode in ["rgb", "gray", "cmyk", "lab"] { + for v in values(mode).into_iter().filter(|v| !v.ends_with(".icc")) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 2, "height": 2, "mode": mode, "profile": v})).unwrap_or_else(|e| panic!("{mode} {v}: {e}")); + } + } + } + #[test] fn units_round_trip_through_pixels() { assert_eq!(to_unit(2100.0, "in", 300.0), 7.0);