mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
Merge pull request #7012 from dotCooCoo/fix/followup-6887
New Document: profiles bring their color profiles and link base
This commit is contained in:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -5884,8 +5884,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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user