diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 985d54fa50..256578f9b2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), profile?: a category of file.newPresets|null (the New Document profile the document starts from, see file.newProfiles; default: a built-in preset's category, kept by the saved and Recent presets made from it; null or no preset: the defaults), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode, profile?: the profile applied, profileError?: {profile, reason} (its file could not be read, was larger than 64 MiB or is not a native document, or the request may not read the profiles folder: the document has the defaults)}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), profile?: a category of file.newPresets|null (the New Document profile the document starts from, see file.newProfiles; default: a built-in preset's category, kept by the saved and Recent presets made from it; null or no preset: the defaults), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode, profile?: the profile applied, warnings?: [text] (a color profile the profile's file carries that can't be used), profileError?: {profile, reason} (its file could not be read, was larger than 64 MiB or is not a native document, or the request may not read the profiles folder, a reason that leaves out the folder's path: the document has the defaults)}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index f4aaa841ca..c7c82c7625 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -798,9 +798,9 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { None => (None, None), }; let applied = profile.is_some(); - let mut d = match profile { - Some(p) => settings.document_from_profile(p), - None => settings.document(), + let (mut d, links_from, warnings) = match profile { + Some(p) => (settings.document_from_profile(p.doc), Some(p.file), p.warnings), + None => (settings.document(), None, vec![]), }; d.grid = s.prefs.grid(); d.title = match str_param(p, "name").or(str_param(p, "title")).map(str::trim).filter(|t| !t.is_empty()) { @@ -811,6 +811,9 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { }; d.metadata.created = clock_date(s, "created", date_param(p, "created", C)?); let i = s.add_document(d, None); + // The profile's links keep their relative paths, which name places from the profile's file, as + // a new document from a history state keeps its source's. + s.doc_mut()?.links_from = links_from; // Overprint Preview is the engine's (the UI turns Pixel Preview on from `previewMode`). Both // are views of the whole app, so Default leaves them as they are. if settings.preview_mode == PreviewMode::Overprint { @@ -821,6 +824,9 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result { if applied { out["profile"] = json!(settings.profile); } + if !warnings.is_empty() { + out["warnings"] = json!(warnings); + } if let Some(e) = profile_error { out["profileError"] = json!({ "profile": settings.profile, "reason": e }); } @@ -883,6 +889,19 @@ fn delete(s: &mut Session, p: &Value) -> Result { /// larger one is reported and the document starts with the defaults. pub const MAX_PROFILE_BYTES: u64 = 64 << 20; +/// `file.new`'s reason for a profile when the request being served may not read the profiles +/// folder: the folder's path is left out. +const PROFILE_DENIED: &str = "the request may not read the New Document profiles folder"; + +/// A profile's file as [`Session::read_profile`] read it. +struct ReadProfile { + doc: Document, + /// The file's path, which its links' relative paths name places from. + file: String, + /// A warning for each color profile the file carries that can't be used. + warnings: Vec, +} + /// The New Document profiles' names: the built-in categories, in tab order. pub fn profile_names() -> impl Iterator { CATEGORIES.iter().map(|c| c.0) @@ -919,24 +938,29 @@ impl Session { self.profile_path(name).is_some_and(|p| p.is_file()) } - /// Profile `name`'s document: `None` when it has no file (the shipped profile, the defaults), + /// Profile `name`'s document, its file's path and a warning for each color profile the file + /// carries that can't be used: `None` when it has no file (the shipped profile, the defaults), /// the reason when the request being served may not read it, or its file can't be read, is /// larger than [`MAX_PROFILE_BYTES`] or isn't a native document VectorCraft reads. - fn read_profile(&self, name: &str) -> std::result::Result, String> { + fn read_profile(&self, name: &str) -> std::result::Result, String> { let Some(path) = self.profile_path(name) else { return Ok(None) }; + let file = path.to_string_lossy().to_string(); // Checked before the file is looked for, so a request that may not read the folder learns - // nothing of its files. - crate::file_access::check_read(&path.to_string_lossy())?; + // nothing of its files, nor where the folder is. + let denied = |_| PROFILE_DENIED.to_string(); + crate::file_access::check_read(&file).map_err(denied)?; if std::fs::symlink_metadata(&path).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) { return Ok(None); } - crate::file_access::check_read(&path.to_string_lossy())?; + crate::file_access::check_read(&file).map_err(denied)?; let bytes = crate::store::read_capped(&path, MAX_PROFILE_BYTES)?; - let mut doc = vectorcraft_format::load(&bytes).map_err(|e| e.to_string())?; + // The color profiles the file carries are installed where missing, as opening it installs + // them, so the new document's assigned profiles draw and are saved with it. + let (mut doc, _, warnings) = super::fileio::native_file(&bytes).map_err(|e| e.to_string())?; // Linked images in its symbols, patterns and brushes are read from their files, as placing // a document reads them. - super::links::resolve(&mut doc, super::links::LinkBase::file(Some(&path.to_string_lossy())), false, false); - Ok(Some(doc)) + super::links::resolve(&mut doc, super::links::LinkBase::file(Some(&file)), false, false); + Ok(Some(ReadProfile { doc, file, warnings })) } } diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 247a037e45..deaf60b64f 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -597,6 +597,10 @@ fn the_profile_follows_the_preset_access_and_reset() { let roots = crate::file_access::AutomationRoots::new(&[elsewhere.as_path()], &[dir.as_path()]).unwrap().unwrap(); let r = crate::file_access::confine(Some(&roots), || s.execute("file.new", &json!({"preset": "A4"}))).unwrap(); assert!(r.get("profile").is_none() && r["profileError"]["profile"] == "Print", "{r}"); + // The reason does not say where the folder is. + let reason = r["profileError"]["reason"].as_str().unwrap_or_default(); + let folder = dir.file_name().unwrap().to_string_lossy().to_string(); + assert!(!reason.is_empty() && !reason.contains(&folder), "{reason}"); assert!(!swatch_names(&s).contains(&"Brand Red".to_string())); // Nothing tells such a request which profiles have files. let r = crate::file_access::confine(Some(&roots), || s.execute("file.new", &json!({"preset": "Web 1920×1080"}))).unwrap(); @@ -627,6 +631,58 @@ fn the_profile_follows_the_preset_access_and_reset() { let _ = std::fs::remove_dir_all(&dir); } +/// The new document's links find their files from the profile's file, as a document opened from +/// that file does; a document without a profile has no such file. +#[test] +fn the_new_document_links_from_the_profile_file() { + let (mut s, dir) = with_profiles("links"); + edit_print_profile(&mut s, &dir); + assert_eq!(s.execute("file.new", &json!({"preset": "A4"})).unwrap()["profile"], "Print"); + let file = dir.join("Print.vectorcraft").to_string_lossy().to_string(); + assert_eq!(s.doc().unwrap().links_from.as_deref(), Some(file.as_str())); + s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap(); + assert_eq!(s.doc().unwrap().links_from, None); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A color profile the profile's file carries is installed when this machine lacks it, as opening +/// the file installs it, so the new document keeps it and a save carries it; one that can't be used +/// is a warning. +#[test] +fn a_profile_brings_the_color_profile_its_file_carries() { + use vectorcraft_color::cms; + let (mut s, dir) = with_profiles("icc"); + let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); + let icc = cms::icc_bytes(&builtin.name).unwrap().to_vec(); + let name = format!("Profile RGB {}", std::process::id()); + cms::register_icc(&icc, Some(name.clone())).unwrap(); + let bytes_of = |v: &Value| vectorcraft_format::base64_decode(v.as_str().expect("base64")).unwrap(); + s.execute("file.new", &json!({"preset": "Web 1920×1080", "profile": null})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).color_profiles.rgb = Some(name.clone()); + let file = String::from_utf8(bytes_of(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()["dataBase64"])).unwrap(); + cms::unregister_icc(&name); + // A profile made on another machine names a color profile this one lacks. + let elsewhere = format!("Profile RGB elsewhere {}", std::process::id()); + assert!(cms::profile(&elsewhere).is_none()); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Web.vectorcraft"), file.replace(&name, &elsewhere)).unwrap(); + let r = s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap(); + assert!(r["profile"] == "Web" && r.get("warnings").is_none(), "{r}"); + assert_eq!(cms::profile(&elsewhere).map(|p| p.kind), Some(cms::ProfileKind::Rgb)); + assert_eq!(s.doc().unwrap().doc.color_profiles.rgb.as_deref(), Some(elsewhere.as_str())); + let saved: Value = serde_json::from_slice(&bytes_of(&s.execute("file.saveCopy", &json!({"compress": false})).unwrap()["dataBase64"])).unwrap(); + assert_eq!(vectorcraft_format::base64_decode(saved["profiles"][&elsewhere]["data"].as_str().unwrap()).unwrap(), icc); + cms::unregister_icc(&elsewhere); + // Damaged profile data starts the document with a warning. + let broken = format!("Profile RGB broken {}", std::process::id()); + let mut v: Value = serde_json::from_str(&file.replace(&name, &broken)).unwrap(); + v["profiles"][&broken]["data"] = json!("AAAA"); + std::fs::write(dir.join("Web.vectorcraft"), serde_json::to_vec(&v).unwrap()).unwrap(); + let r = s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap(); + assert!(r["profile"] == "Web" && r["warnings"][0].as_str().unwrap_or_default().contains(&broken), "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported() { let (mut s, dir) = with_profiles("damaged"); diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 96e33684bc..cfd6c3b0d3 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -151,11 +151,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { r } -/// Say in the status bar when `file.new`'s result `r` reports a profile it could not read. +/// Say in the status bar when `file.new`'s result `r` reports a profile it could not read, or +/// color profiles its file carries that can't be used. fn report_profile(app: &mut VectorcraftApp, r: &Value) { if let Some(e) = r.get("profileError") { let (name, reason) = (e["profile"].as_str().unwrap_or_default(), e["reason"].as_str().unwrap_or_default()); app.status(format!("The {name} profile could not be read, so the new document has the default settings: {reason}")); + } else if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { + app.status(w.iter().filter_map(Value::as_str).collect::>().join("; ")); } } @@ -1592,6 +1595,15 @@ mod tests { } } + /// A color profile in the profile's file that can't be used, which `file.new` reports, shows in + /// the status bar. + #[test] + fn profile_warnings_show_in_the_status_bar() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + super::report_profile(&mut app, &json!({"index": 0, "warnings": ["the color profile X can't be used"]})); + assert_eq!(app.ui.status, "the color profile X can't be used"); + } + /// Manage Profiles lists a row per profile; Reset is off until a profile has been edited, Escape /// closes only the pop-up, and Edit opens the profile as a document and closes New Document. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 0dd9f958b3..dbb68acfda 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5849,8 +5849,11 @@ The profile is read on every `file.new`, so a replayed journal or a recorded act runs, as `document.open` reads its file when it runs. A file larger than 64 MiB (VectorCraft's own limit) is not read. When the file cannot be read, is too large or is not a native document, or the request's read roots leave out the folder (whether or not the file exists), the document starts with the defaults and -`file.new`'s result carries `profileError: {profile, reason}`; the desktop app shows the reason in the status bar. In -the New Document dialog, Manage Profiles lists the profiles with Edit and Reset buttons. Edit is available only when +`file.new`'s result carries `profileError: {profile, reason}`; the desktop app shows the reason in the status bar. The +reason given when the read roots leave out the folder does not name the folder's path. Color profiles the profile's +file carries are installed when they are missing, as `document.open` installs them, and each one that can't be used +adds a line to `file.new`'s `warnings`. The new document's links find their files through relative paths from the +profile's file until the document is saved. In the New Document dialog, Manage Profiles lists the profiles with Edit and Reset buttons. Edit is available only when there is a profiles folder, and Reset only for a profile that has a file. An unknown profile name is an error that lists the names.