New Document: profiles bring their color profiles and link base

Follow-up to #6887.

- file.new installs the color profiles a New Document profile's file
  carries when they are missing, as document.open does, so the new
  document's assigned profile draws and a save embeds it. A color
  profile that can't be used is listed in the result's `warnings` and
  shown in the status bar.
- The new document's links find their files through relative paths
  from the profile's file (`links_from`), as
  edit.newDocumentFromState sets it.
- When the read roots leave out the profiles folder, profileError's
  reason no longer names the folder's path.
This commit is contained in:
dotCooCoo
2026-11-12 17:22:28 -08:00
parent 4b344e91ce
commit bc97a8f897
5 changed files with 110 additions and 15 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+35 -11
View File
@@ -798,9 +798,9 @@ pub(crate) fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
};
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<Value> {
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<Value> {
/// 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<String>,
}
/// The New Document profiles' names: the built-in categories, in tab order.
pub fn profile_names() -> impl Iterator<Item = &'static str> {
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<Option<Document>, String> {
fn read_profile(&self, name: &str) -> std::result::Result<Option<ReadProfile>, 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 }))
}
}
+56
View File
@@ -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");
+13 -1
View File
@@ -151,11 +151,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
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::<Vec<_>>().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]
+5 -2
View File
@@ -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.