New Document: Color Profile, the working space or another profile of the mode (#321)

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 <noreply@anthropic.com>
This commit is contained in:
Higgs32584
2026-10-15 18:25:28 -04:00
co-authored by Claude Opus 5.5
parent af54f4aed1
commit 5f9c7d307e
4 changed files with 121 additions and 1 deletions
+13 -1
View File
@@ -467,7 +467,7 @@ fn build() -> Vec<CommandSpec> {
"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<CommandSpec> {
}
};
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 }))
}
@@ -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 {
+25
View File
@@ -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();
+82
View File
@@ -231,6 +231,33 @@ pub fn apply_preset(f: &mut Map<String, Value>, 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<photocraft_color::ColorMode> {
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<photocraft_cms::ColorSpace>,
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<String, Value>) -> 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<Strin
110.0,
) {
f.insert("mode".into(), json!(mode));
// A profile of the old mode's colour space doesn't suit the new one.
f.remove("profile");
f.remove("__savedPreset");
}
let mut depth = f.get("depth").and_then(Value::as_u64).unwrap_or(8);
@@ -705,6 +734,30 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
}
});
ui.add_space(4.0);
// Photoshop's Advanced Options › Color Profile (#321): the working space by default,
// or another profile of the mode's colour space, built in or installed in the system.
small_label(ui, tl!("Color Profile"));
{
let mode = get_s(f, "mode", "rgb");
let space = color_mode_of(&mode).map(photocraft_engine::color_cmds::mode_space);
let working = color_mode_of(&mode).map(|m| app.session.color.working(m).description.clone()).unwrap_or_default();
let installed = photocraft_engine::installed_profiles::installed();
let opts = profile_options(space, &working, &installed);
let mut profile = get_s(f, "profile", "working");
if !opts.iter().any(|(v, _)| *v == profile) {
profile = "working".into();
}
let shown: Vec<(String, &str)> = 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<Strin
mod tests {
use super::*;
/// #321: New Document offers the working space and the other profiles of the mode's space.
#[test]
fn color_profile_menu_lists_the_profiles_of_the_modes_space() {
use photocraft_cms::{Builtin, ColorSpace, ProfileClass};
use photocraft_engine::installed_profiles::InstalledProfile;
let installed = [
InstalledProfile { path: "press.icc".into(), description: "Press".into(), class: ProfileClass::Output, color_space: ColorSpace::Cmyk },
InstalledProfile { path: "panel.icc".into(), description: "Panel".into(), class: ProfileClass::Display, color_space: ColorSpace::Rgb },
];
let values = |mode: &str| profile_options(color_mode_of(mode).map(photocraft_engine::color_cmds::mode_space), "sRGB", &installed)
.into_iter()
.map(|(v, _)| v)
.collect::<Vec<_>>();
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);