From a8e45f0e2d1f30140b1bf3d5296de05e1023a38a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 22:45:24 +0200 Subject: [PATCH] Persistence: saved PDF presets and loaded ICC profiles survive a restart (#2168, #2174) PDF presets are kept one file each in a PDF Presets folder, ICC profiles as copies in a Color Profiles folder, next to the preferences; the desktop app and vectorcraft-cli use them. Adds color.removeProfile. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft-cli/src/main.rs | 15 +- apps/vectorcraft-cli/tests/cli.rs | 94 +++++++++++++ apps/vectorcraft/src/main.rs | 8 +- apps/vectorcraft/src/prefs_dir.rs | 25 ++++ crates/color/src/cms/mod.rs | 22 ++- crates/engine/src/cmd/colormgmt.rs | 40 +++++- crates/engine/src/cmd/pdfcmds.rs | 22 ++- crates/engine/src/lib.rs | 11 ++ crates/engine/src/store.rs | 211 ++++++++++++++++++++++++++++ crates/engine/src/tests_store.rs | 128 +++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/development.md | 3 +- docs/mcp.md | 25 +++- 31 files changed, 605 insertions(+), 18 deletions(-) create mode 100644 crates/engine/src/store.rs create mode 100644 crates/engine/src/tests_store.rs diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index b843af67f..8d09dcb68 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -159,6 +159,15 @@ fn main() -> ExitCode { } } +/// A headless session that remembers what the user keeps between runs: the PDF presets saved and +/// the ICC profiles loaded (#2168, #2174), in the folders next to the preferences that the desktop +/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there. +fn with_stores(mut h: Headless) -> Headless { + prefs_dir::attach_profile_store(&mut h.session); + prefs_dir::attach_pdf_preset_store(&mut h.session); + h +} + fn mcp(args: &[String]) -> Result<(), String> { let mut connect: Option = None; let mut headless = false; @@ -198,14 +207,14 @@ fn mcp(args: &[String]) -> Result<(), String> { eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); Box::new(Headless::with_document().with_automation_roots(Some(roots))) } else if headless { - Box::new(Headless::with_document()) + Box::new(with_stores(Headless::with_document())) } else if let Some(addr) = connect { // Explicit address: fail loudly if the app isn't there. Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?) } else { match Remote::connect(DEFAULT_ADDR) { Ok(r) => Box::new(r), - Err(_) => Box::new(Headless::with_document()), + Err(_) => Box::new(with_stores(Headless::with_document())), } }; eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe()); @@ -327,7 +336,7 @@ fn run(args: &[String]) -> Result<(), String> { } } - let mut h = Headless::new(); + let mut h = with_stores(Headless::new()); let mut out = std::io::stdout().lock(); // A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later // steps (an export…) still run, unprinted. diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 133c51df1..32c5e7f09 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -367,3 +367,97 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() { let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap(); assert!(!out.status.success()); } + +/// A fresh settings folder for one test (`VECTORCRAFT_CONFIG_DIR`), so no run reads or writes the +/// developer's real profile. +fn config_dir(name: &str) -> std::path::PathBuf { + let dir = tmp(&format!("config-{name}")); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`; +/// the result of each step. +fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec { + let mut cmd = Command::new(BIN); + cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS"); + for (id, params) in steps { + cmd.args(["--cmd", id, "--params", ¶ms.to_string()]); + } + let out = cmd.output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str::(l).unwrap()["result"].clone()).collect() +} + +/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a +/// name of the same length). +fn icc_file_named(path: &std::path::Path, name: &str) { + let builtin = "Display P3"; + assert_eq!(builtin.len(), name.len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = vectorcraft_color::cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(name)); + let at = bytes.windows(from.len()).position(|w| w == from).expect("the description in the profile"); + bytes[at..at + to.len()].copy_from_slice(&to); + std::fs::write(path, bytes).unwrap(); +} + +fn profile_names(settings: &Value) -> Vec<&str> { + settings["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect() +} + +/// #2168, #2174: the PDF presets saved and the ICC profiles loaded in one headless run are there in +/// the next run, and gone again once deleted. +#[test] +fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() { + let config = config_dir("restart"); + let icc = tmp("studio-p3.icc"); + icc_file_named(&icc, "StudioRGB3"); + let r = run_in( + &config, + &[ + ("pdf.preset.save", json!({"name": "Press", "description": "for the printer"})), + ("color.loadProfile", json!({"path": icc.to_str().unwrap()})), + ], + ); + assert_eq!(r[1]["name"], "StudioRGB3"); + + let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]); + let presets: Vec<&str> = + r[0]["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!(presets, ["Press"]); + assert_eq!(r[0]["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Press").unwrap()["description"], "for the printer"); + assert!(profile_names(&r[1]).contains(&"StudioRGB3"), "{:?}", profile_names(&r[1])); + // The profile works as a working space, and the file it came from can go. + std::fs::remove_file(&icc).unwrap(); + let steps = [ + ("edit.colorSettings", json!({"rgb": "StudioRGB3"})), + ("pdf.preset.delete", json!({"name": "Press"})), + ("edit.colorSettings", json!({"rgb": vectorcraft_color::cms::SRGB})), + ("color.removeProfile", json!({"name": "StudioRGB3"})), + ]; + let r = run_in(&config, &steps); + assert_eq!(r[0]["rgb"], "StudioRGB3"); + assert_eq!(r[3]["removed"], "StudioRGB3"); + + let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]); + assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true)); + assert!(!profile_names(&r[1]).contains(&"StudioRGB3")); +} + +/// Runs without preferences (`VECTORCRAFT_NO_PREFS`) keep what they save for the run only, and +/// leave nothing in the settings folder. +#[test] +fn runs_without_preferences_remember_nothing() { + let config = config_dir("noprefs"); + let out = Command::new(BIN) + .args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#]) + .env("VECTORCRAFT_CONFIG_DIR", &config) + .env("VECTORCRAFT_NO_PREFS", "1") + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(!config.exists(), "nothing written"); + let r = run_in(&config, &[("pdf.preset.list", json!({}))]); + assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true)); +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 23aaea179..6046bcaa6 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -854,8 +854,14 @@ fn main() -> std::process::ExitCode { "VectorCraft", options, Box::new(move |cc| { - let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone())); + let mut session = Session::new(); + // The profiles loaded before come back before the preferences name any of them. + prefs_dir::attach_profile_store(&mut session); + let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); load_prefs(&mut app, saved); + // The saved PDF presets live in their own folder (also read by vectorcraft-cli), + // which takes over from the copy the preferences file used to hold. + prefs_dir::attach_pdf_preset_store(&mut app.session); // Fit the window to its monitor, or put it back where it was (still hidden). if let Some(w) = cc.winit_window() { app.ui.window = Some(window::restore(w, saved_window)); diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 4dd258afa..00298342d 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -24,6 +24,31 @@ pub fn install_fonts() { } } +/// The folder `name` next to the settings, for what the user keeps: none in a run without +/// preferences (`VECTORCRAFT_NO_PREFS`), which keeps nothing. +fn store_dir(name: &str) -> Option { + if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() { + return None; + } + Some(prefs_path_for("VectorCraft", "vectorcraft")?.parent()?.join(name)) +} + +/// Register the ICC profiles `color.loadProfile` kept in the `Color Profiles` folder (#2174), and +/// keep the ones loaded from now on there. Before anything names a profile. +pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("Color Profiles") { + session.attach_profile_store(dir); + } +} + +/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from +/// now on there. After the preferences were restored: the folder is the one place the presets live. +pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("PDF Presets") { + session.attach_pdf_preset_store(dir); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 9eeafdb2c..f2a705857 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -257,9 +257,25 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result bool { + let mut u = USER.write().unwrap_or_else(|e| e.into_inner()); + let before = u.len(); + u.retain(|x| x.name != name); + let removed = u.len() != before; + drop(u); + if removed { + clear_proof_cache(); + PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed); + } + removed +} + +/// A number that changes whenever [`register_icc`] registers a profile or [`unregister_icc`] +/// removes one: conversions through a profile looked up by name, such as those of a +/// [`ProofTarget::Cmyk`] proof, give the same colors as long as the number stays the same. pub fn profiles_generation() -> u64 { PROFILES_GENERATION.load(Ordering::Relaxed) } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index f034d6164..91afee24c 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -39,10 +39,19 @@ pub fn specs() -> Vec { "Load Color Profile…", [], None, - "{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}", + "{path: .icc/.icm file of at most 32 MB} register an ICC profile (native only). The desktop app and vectorcraft-cli keep a copy of the file in the `Color Profiles` folder next to the preferences and register it again at every start (not with VECTORCRAFT_NO_PREFS, or in a confined `mcp --automation-*-root` server: there it lasts until the process ends) → {name, kind, remembered: whether a copy was kept}", always, load_profile ), + cmd!( + "color.removeProfile", + "Remove Color Profile…", + [], + None, + "{name} forget a profile color.loadProfile loaded, and the copy kept of it (built-in profiles and the working spaces stay) → {removed: name}", + always, + remove_profile + ), cmd!( "edit.assignProfile", "Assign Profile…", @@ -178,12 +187,35 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { } #[cfg(not(target_arch = "wasm32"))] -fn load_profile(_: &mut Session, p: &Value) -> Result { +fn load_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "color.loadProfile"; let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; crate::file_access::check_read(path).map_err(EngineError::Other)?; - let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?; - Ok(json!({"name": info.name, "kind": info.kind})) + let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?; + let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?; + // The bytes are kept, not the path: the file may move or go away. + let remembered = s.store_profile(&info.name, &bytes); + Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered})) +} + +/// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it. +fn remove_profile(s: &mut Session, p: &Value) -> Result { + const C: &str = "color.removeProfile"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?; + if info.builtin { + return Err(bad(C, format!("`{name}` is a built-in profile and stays"))); + } + let working = cms::active_settings(); + if working.rgb == name || working.cmyk == name { + return Err(bad(C, format!("`{name}` is a working space: choose another with edit.colorSettings first"))); + } + if !cms::unregister_icc(&info.name) { + return Err(bad(C, format!("no profile named `{name}`"))); + } + s.unstore_profile(&info.name); + touch_all(s); + Ok(json!({"removed": info.name})) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/pdfcmds.rs b/crates/engine/src/cmd/pdfcmds.rs index fb63257b1..faa986795 100644 --- a/crates/engine/src/cmd/pdfcmds.rs +++ b/crates/engine/src/cmd/pdfcmds.rs @@ -18,10 +18,16 @@ pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT]; /// What `pdf.preset.export` writes and `pdf.preset.import` reads. #[derive(Serialize, Deserialize)] -struct PresetFile { - format: String, +pub(crate) struct PresetFile { + pub(crate) format: String, #[serde(default)] - presets: Vec, + pub(crate) presets: Vec, +} + +impl PresetFile { + pub(crate) fn new(presets: Vec) -> Self { + Self { format: PRESET_FORMAT.into(), presets } + } } pub fn specs() -> Vec { @@ -112,6 +118,7 @@ fn save(s: &mut Session, p: &Value) -> Result { } let at = s.pdf_saved_index(&name); let saved = at.and_then(|i| s.prefs.pdf_presets.get(i)).cloned(); + let old_name = saved.as_ref().map(|old| old.name.clone()); // A saved preset changes from its own settings unless `preset` says where to start. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let (Some(old), None) = (&saved, str_param(p, "preset")) { @@ -127,6 +134,7 @@ fn save(s: &mut Session, p: &Value) -> Result { }; let description = str_param(p, "description").map(str::to_string).or_else(|| saved.map(|old| old.description)).unwrap_or_default(); let preset = PdfPreset { name: name.clone(), description, settings: settings.clone() }; + s.store_pdf_preset(old_name.as_deref(), &preset); match at.and_then(|i| s.prefs.pdf_presets.get_mut(i)) { Some(slot) => *slot = preset, None => s.prefs.pdf_presets.push(preset), @@ -141,7 +149,9 @@ fn delete(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("`{name}` is a built-in preset and stays"))); } let i = s.pdf_saved_index(name).ok_or_else(|| bad(C, format!("no saved PDF preset named `{name}`")))?; - Ok(json!({ "deleted": s.prefs.pdf_presets.remove(i).name })) + let deleted = s.prefs.pdf_presets.remove(i).name; + s.unstore_pdf_preset(&deleted); + Ok(json!({ "deleted": deleted })) } fn export(s: &mut Session, p: &Value) -> Result { @@ -196,12 +206,14 @@ fn import(s: &mut Session, p: &Value) -> Result { if let Some(slot) = s.prefs.pdf_presets.get_mut(i) { preset.name = slot.name.clone(); imported.push(preset.name.clone()); - *slot = preset; + *slot = preset.clone(); + s.store_pdf_preset(Some(&preset.name), &preset); } } None => { preset.name = unique_name(&base, |n| s.pdf_preset_taken(n, None)); imported.push(preset.name.clone()); + s.store_pdf_preset(None, &preset); s.prefs.pdf_presets.push(preset); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 30ffda9f4..15ade244c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod journal; pub mod link_watch; pub mod namematch; pub mod steps; +pub mod store; mod tooling; pub mod units; @@ -1176,6 +1177,12 @@ pub struct Session { pub style_libraries: cmd::stylelib::Libraries, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, + /// Where the saved PDF presets are kept, one file each; `None`: only for the session + /// ([`Session::attach_pdf_preset_store`]). + pdf_preset_dir: Option, + /// Where the ICC profiles `color.loadProfile` loads are kept; `None`: only for the process + /// ([`Session::attach_profile_store`]). + profile_dir: Option, /// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved. pub recent_urls: Vec, /// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame; @@ -1268,6 +1275,8 @@ impl Session { freeform_point: None, style_libraries: Default::default(), libraries: Default::default(), + pdf_preset_dir: None, + profile_dir: None, recent_urls: vec![], ui_language: None, journal_note: Default::default(), @@ -2130,6 +2139,8 @@ mod tests_slices; #[cfg(test)] mod tests_smartguides; #[cfg(test)] +mod tests_store; +#[cfg(test)] mod tests_strokegeom; #[cfg(test)] mod tests_strokegradient; diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs new file mode 100644 index 000000000..9de359b44 --- /dev/null +++ b/crates/engine/src/store.rs @@ -0,0 +1,211 @@ +//! The user's own PDF presets and ICC profiles, kept in folders next to the preferences so that +//! they outlive the process (#2168, #2174). The apps decide where ([`Session::attach_pdf_preset_store`], +//! [`Session::attach_profile_store`]); a session nobody attached a folder to keeps nothing, which +//! is how the engine's own tests and embedders run. +//! +//! * **PDF presets**: one `.vcpdfpresets` file per preset (the format `pdf.preset.export` writes, +//! so a file can also be imported by hand), named after the preset. A session that attaches the +//! folder replaces its saved presets with the ones in it; a folder that doesn't exist yet takes +//! the presets the session has (the ones an older version kept in the preferences file). +//! * **ICC profiles**: the bytes of each profile `color.loadProfile` registered, as `.icc` files +//! named after the profile, never the path they came from (a profile in a system folder may +//! move or go away). Attaching the folder registers them again. +//! +//! The files are untrusted input like any other: sizes and counts are capped, a file that does not +//! parse is skipped with a log record, and every write is atomic. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use vectorcraft_pdf::{PdfPreset, builtin_preset}; + +use crate::Session; +use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile}; + +/// Files read from one store folder, at most (the rest are ignored). +pub const MAX_STORE_FILES: usize = 1000; +/// The largest PDF presets file read (a preset is a few kilobytes). +pub const MAX_PRESET_FILE: u64 = 1 << 20; +/// The largest ICC profile file read or loaded (CMYK profiles with large tables are a few MB). +pub const MAX_PROFILE_FILE: u64 = 32 << 20; +/// The extension of a stored ICC profile. +const PROFILE_EXT: &str = "icc"; + +/// The file name (without extension) a stored item called `name` gets: its name made safe for any +/// file system, then a hash of the name, so that names that differ only in characters a file name +/// can't hold, or in case, can't share a file. Names equal in any case are one item. +pub(crate) fn file_stem(name: &str) -> String { + let safe: String = name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }).take(48).collect(); + // FNV-1a over the lower-cased name: stable across versions and platforms, unlike `DefaultHasher`. + let hash = name.trim().to_lowercase().bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); + format!("{}-{hash:016x}", safe.trim()) +} + +/// The regular files of `dir` with the extension `ext` (any case), by file name, at most +/// [`MAX_STORE_FILES`]; none when the folder can't be read. +fn list_files(dir: &Path, ext: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return vec![] }; + let mut files: Vec = entries + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext)) && p.is_file()) + .collect(); + files.sort_by_key(|p| p.file_name().map(|n| n.to_string_lossy().to_lowercase())); + files.truncate(MAX_STORE_FILES); + files +} + +/// The content of `path` when it is at most `max` bytes; the file is never read past `max`. +pub(crate) fn read_capped(path: &Path, max: u64) -> Result, String> { + let file = std::fs::File::open(path).map_err(|e| e.to_string())?; + let mut bytes = vec![]; + file.take(max + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?; + if bytes.len() as u64 > max { + return Err(too_large(max)); + } + Ok(bytes) +} + +/// What a file over its cap is told (a detail after the file's name, in the log or an error). +fn too_large(max: u64) -> String { + format!("larger than {max} bytes") +} + +/// Write `bytes` to `dir/file`, making the folder; nothing is written when the file has them +/// already. A failure is logged: remembering is best effort and never fails the command. +fn put(dir: &Path, file: &str, bytes: &[u8]) -> bool { + let path = dir.join(file); + if std::fs::read(&path).is_ok_and(|old| old == bytes) { + return true; + } + let written = std::fs::create_dir_all(dir).and_then(|()| write_atomic(&path, bytes)); + if let Err(e) = &written { + log::warn!("can't save {}: {e}", path.display()); + } + written.is_ok() +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + vectorcraft_format::write_atomic(path, bytes) +} + +/// The browser has no folders to keep anything in (the apps there never attach a store). +#[cfg(target_arch = "wasm32")] +fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system")) +} + +/// Remove `dir/file`; a file that isn't there is what was wanted. +fn take(dir: &Path, file: &str) { + let path = dir.join(file); + if let Err(e) = std::fs::remove_file(&path) + && e.kind() != std::io::ErrorKind::NotFound + { + log::warn!("can't remove {}: {e}", path.display()); + } +} + +impl Session { + /// Keep the PDF presets this session saves in `dir`, and take the ones already there as the + /// saved presets (see the module docs). Call it once, after the preferences were restored. + pub fn attach_pdf_preset_store(&mut self, dir: PathBuf) { + if dir.is_dir() { + self.prefs.pdf_presets = load_presets(&dir); + self.pdf_preset_dir = Some(dir); + } else { + self.pdf_preset_dir = Some(dir); + for preset in self.prefs.pdf_presets.clone() { + self.store_pdf_preset(None, &preset); + } + } + } + + /// Write the saved preset `preset` to the store, which held it as `old_name` when that is set + /// (a rename). Without a store, nothing happens. + pub(crate) fn store_pdf_preset(&self, old_name: Option<&str>, preset: &PdfPreset) { + let Some(dir) = &self.pdf_preset_dir else { return }; + let mut clean = preset.clone(); + clean.settings.clear_passwords(); + let file = format!("{}.{PRESET_FORMAT}", file_stem(&clean.name)); + if let Some(old) = old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o))) + && old != file + { + take(dir, &old); + } + match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) { + Ok(bytes) => { + put(dir, &file, &bytes); + } + Err(e) => log::warn!("can't save the PDF preset `{}`: {e}", preset.name), + } + } + + /// Remove the preset `name` from the store. + pub(crate) fn unstore_pdf_preset(&self, name: &str) { + if let Some(dir) = &self.pdf_preset_dir { + take(dir, &format!("{}.{PRESET_FORMAT}", file_stem(name))); + } + } + + /// Keep the ICC profiles this session loads in `dir`, and register the ones already there. + /// Call it before anything that names a profile (restoring the preferences, the first command). + pub fn attach_profile_store(&mut self, dir: PathBuf) { + for path in list_files(&dir, PROFILE_EXT) { + let loaded = + read_capped(&path, MAX_PROFILE_FILE).and_then(|bytes| vectorcraft_color::cms::register_icc(&bytes, None).map_err(|e| e.to_string())); + if let Err(e) = loaded { + log::warn!("skipping the stored colour profile {}: {e}", path.display()); + } + } + self.profile_dir = Some(dir); + } + + /// Write the profile `name` (its file's `bytes`) to the store; `false` when it didn't go there. + pub(crate) fn store_profile(&self, name: &str, bytes: &[u8]) -> bool { + self.profile_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)), bytes)) + } + + /// Remove the profile `name` from the store. + pub(crate) fn unstore_profile(&self, name: &str) { + if let Some(dir) = &self.profile_dir { + take(dir, &format!("{}.{PROFILE_EXT}", file_stem(name))); + } + } +} + +/// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a +/// built-in preset, at most [`MAX_STORE_FILES`]. +fn load_presets(dir: &Path) -> Vec { + let mut presets: Vec = vec![]; + for path in list_files(dir, PRESET_FORMAT) { + let file = read_capped(&path, MAX_PRESET_FILE).and_then(|bytes| serde_json::from_slice::(&bytes).map_err(|e| e.to_string())); + let file = match file { + Ok(f) if f.format == PRESET_FORMAT => f, + Ok(f) => { + log::warn!("skipping {}: not a PDF presets file (format `{}`)", path.display(), f.format); + continue; + } + Err(e) => { + log::warn!("skipping the stored PDF preset {}: {e}", path.display()); + continue; + } + }; + for mut preset in file.presets { + let name = preset.name.trim().to_string(); + let usable = !name.is_empty() + && builtin_preset(&name).is_none() + && !presets.iter().any(|q| q.name.eq_ignore_ascii_case(&name)) + && preset.settings.check_values().is_ok() + && presets.len() < MAX_STORE_FILES; + if usable { + preset.name = name; + preset.settings.clear_passwords(); + presets.push(preset); + } else { + log::warn!("skipping the PDF preset `{name}` of {}", path.display()); + } + } + } + presets +} diff --git a/crates/engine/src/tests_store.rs b/crates/engine/src/tests_store.rs new file mode 100644 index 000000000..f630d78eb --- /dev/null +++ b/crates/engine/src/tests_store.rs @@ -0,0 +1,128 @@ +//! The stores of PDF presets and ICC profiles (#2168, #2174): what one session saves, the next one +//! finds; damaged or oversized files are skipped, never fatal. + +use serde_json::{Value, json}; +use vectorcraft_color::cms; + +use crate::Session; +use crate::store::{MAX_PRESET_FILE, file_stem}; + +fn fresh_dir(tag: &str) -> std::path::PathBuf { + let dir = vectorcraft_testkit::temp_dir(&format!("store-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +fn saved_names(s: &mut Session) -> Vec { + let v = s.execute("pdf.preset.list", &json!({})).unwrap(); + v["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow() { + let dir = fresh_dir("presets").join("PDF Presets"); + let mut a = Session::new(); + a.attach_pdf_preset_store(dir.clone()); + a.execute("pdf.preset.save", &json!({"name": "Press", "description": "for the printer", "preset": "Press Quality"})).unwrap(); + a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap(); + a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Screen"})).unwrap(); + a.execute("pdf.preset.save", &json!({"name": "Old"})).unwrap(); + a.execute("pdf.preset.delete", &json!({"name": "Old"})).unwrap(); + + let mut b = Session::new(); + b.attach_pdf_preset_store(dir.clone()); + assert_eq!(saved_names(&mut b), ["Press", "Screen"]); + let press = b.prefs.pdf_presets.iter().find(|p| p.name == "Press").unwrap(); + assert_eq!(press.description, "for the printer"); + assert_eq!(press.settings, b.pdf_presets().into_iter().find(|p| p.name == "Press Quality").unwrap().settings); + // The file of a name that is gone is gone. + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); + assert!(!dir.join(format!("{}.vcpdfpresets", file_stem("Old"))).exists()); +} + +#[test] +fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() { + let dir = fresh_dir("seed").join("PDF Presets"); + let mut a = Session::new(); + a.execute("pdf.preset.save", &json!({"name": "From Preferences"})).unwrap(); + a.attach_pdf_preset_store(dir.clone()); + assert!(dir.is_dir(), "written at once"); + let mut b = Session::new(); + b.attach_pdf_preset_store(dir); + assert_eq!(saved_names(&mut b), ["From Preferences"]); +} + +#[test] +fn damaged_unsafe_and_oversized_preset_files_are_skipped() { + let dir = fresh_dir("damaged").join("PDF Presets"); + let mut a = Session::new(); + a.attach_pdf_preset_store(dir.clone()); + a.execute("pdf.preset.save", &json!({"name": "Good"})).unwrap(); + let write = |name: &str, bytes: &[u8]| std::fs::write(dir.join(name), bytes).unwrap(); + write("a-not-json.vcpdfpresets", b"{ not json"); + write("b-other-format.vcpdfpresets", br#"{"format": "something else", "presets": []}"#); + let huge = vec![b' '; MAX_PRESET_FILE as usize + 1]; + write("c-huge.vcpdfpresets", &huge); + write("d-readme.txt", b"ignored"); + let good = std::fs::read_to_string(dir.join(format!("{}.vcpdfpresets", file_stem("Good")))).unwrap(); + // A built-in name, an empty name, a second "good" in another case, and values no export accepts. + let mut file: Value = serde_json::from_str(&good).unwrap(); + let mut preset = file["presets"][0].clone(); + for name in ["High Quality Print", " ", "GOOD"] { + preset["name"] = json!(name); + file["presets"].as_array_mut().unwrap().push(preset.clone()); + } + preset["name"] = json!("Bad Values"); + preset["settings"]["advanced"]["fontSubsetPercent"] = json!(500); + file["presets"].as_array_mut().unwrap().push(preset); + write("e-mixed.vcpdfpresets", file.to_string().as_bytes()); + + let mut b = Session::new(); + b.attach_pdf_preset_store(dir); + assert_eq!(saved_names(&mut b), ["Good"]); +} + +/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a +/// name of the same length). +fn icc_named(name: &str) -> Vec { + let builtin = cms::DISPLAY_P3; + assert_eq!(builtin.len(), name.len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(name)); + let at = bytes.windows(from.len()).position(|w| w == from).unwrap(); + bytes[at..at + to.len()].copy_from_slice(&to); + bytes +} + +#[test] +fn stored_profiles_register_again_and_removing_one_removes_its_copy() { + let dir = fresh_dir("profiles").join("Color Profiles"); + std::fs::create_dir_all(&dir).unwrap(); + let name = "StoreRGB-1"; + let file = format!("{}.icc", file_stem(name)); + std::fs::write(dir.join("junk.icc"), b"not a profile").unwrap(); + std::fs::write(dir.join("empty.icc"), b"").unwrap(); + std::fs::write(dir.join(&file), icc_named(name)).unwrap(); + assert!(cms::profile(name).is_none()); + + let mut s = Session::new(); + s.attach_profile_store(dir.clone()); + assert!(cms::profile(name).is_some_and(|p| !p.builtin), "registered from the store"); + + // A profile loaded from elsewhere is copied in, whatever the file is called. + let other = "StoreRGB-2"; + let loose = vectorcraft_testkit::temp_dir("store-loose").join("whatever.icm"); + std::fs::write(&loose, icc_named(other)).unwrap(); + let r = s.execute("color.loadProfile", &json!({"path": loose.to_str().unwrap()})).unwrap(); + assert_eq!((r["name"].as_str(), r["remembered"].as_bool()), (Some(other), Some(true))); + std::fs::remove_file(&loose).unwrap(); + assert_eq!(std::fs::read(dir.join(format!("{}.icc", file_stem(other)))).unwrap(), icc_named(other)); + + let r = s.execute("color.removeProfile", &json!({"name": name})).unwrap(); + assert_eq!(r["removed"], name); + assert!(cms::profile(name).is_none() && !dir.join(&file).exists()); + assert!(s.execute("color.removeProfile", &json!({"name": name})).is_err(), "already gone"); + assert!(s.execute("color.removeProfile", &json!({"name": cms::SRGB})).is_err(), "built-in profiles stay"); + s.execute("color.removeProfile", &json!({"name": other})).unwrap(); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9acbb54a9..e3e8d7a51 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1559,6 +1559,7 @@ Live Paint Selection Tool Инструмент за селекция на динамично оцветяване Live Shape Properties Свойства на динамична фигура Load Color Profile… Зареждане на файлов цвят… + Remove Color Profile… Премахване на цветови профил… Load Image into Clipboard Зареждане на изображение в клипборда Load Metafile into Clipboard Зареждане на метафайл в клипборда Load PDF into Clipboard Зареждане на PDF в клипборда diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d30d0166c..979004bf3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2864,6 +2864,7 @@ theme Appearance Motiv Convert Document Color Mode Převést barevný režim dokumentu Gamut Warning Upozornění na gamut Load Color Profile… Načíst barevný profil… + Remove Color Profile… Odebrat barevný profil… Plates Desky Spot Color Přímá barva Add to Swatches Přidat do vzorníku diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 92074af90..fdbab8116 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1584,6 +1584,7 @@ Live Paint Selection Tool Interaktiv-malen-Auswahlwerkzeug Live Shape Properties Eigenschaften interaktiver Formen Load Color Profile… Farbprofil laden… + Remove Color Profile… Farbprofil entfernen… Load Image into Clipboard Bild in die Zwischenablage laden Load Metafile into Clipboard Metadatei in die Zwischenablage laden Load PDF into Clipboard PDF in die Zwischenablage laden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5cc3023d9..608c2749f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2319,6 +2319,7 @@ theme Appearance Εμφάνιση Live Paint Selection Tool Επιλογή δυναμικής βαφής (Live Paint Selection Tool) Live Shape Properties Ιδιότητες δυναμικού σχήματος Load Color Profile… Φόρτωση προφίλ χρώματος… + Remove Color Profile… Αφαίρεση προφίλ χρώματος… Load Image into Clipboard Φόρτωση εικόνας στο πρόχειρο Load Metafile into Clipboard Φόρτωση μετααρχείου στο πρόχειρο Load PDF into Clipboard Φόρτωση PDF στο πρόχειρο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ce0da6fe8..282e5dd11 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1600,6 +1600,7 @@ Live Paint Selection Tool Herramienta Selección de pintura interactiva Live Shape Properties Propiedades de forma dinámica Load Color Profile… Cargar perfil de color… + Remove Color Profile… Quitar perfil de color… Load Image into Clipboard Cargar imagen en el portapapeles Load Metafile into Clipboard Cargar metarchivo en el portapapeles Load PDF into Clipboard Cargar PDF en el portapapeles diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 4e5bb0b35..a0fd4b8c1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2814,6 +2814,7 @@ theme Appearance Ulkoasu Convert Document Color Mode Muunna asiakirjan väritila Gamut Warning Väriavaruusvaroitus Load Color Profile… Lataa väriprofiili… + Remove Color Profile… Poista väriprofiili… Plates Erottelut Spot Color Spottiväri Add to Swatches Lisää värimalleihin diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6c011a2ce..344b340dc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1599,6 +1599,7 @@ Live Paint Selection Tool Outil Sélection de peinture dynamique Live Shape Properties Propriétés de la forme dynamique Load Color Profile… Charger un profil colorimétrique… + Remove Color Profile… Supprimer un profil colorimétrique… Load Image into Clipboard Charger une image dans le presse-papiers Load Metafile into Clipboard Charger un métafichier dans le presse-papiers Load PDF into Clipboard Charger un PDF dans le presse-papiers diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 27a6eb2b5..cf747808f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2023,6 +2023,7 @@ Live Paint Selection Tool Élőfestés-kijelölés Live Shape Properties Élő alakzat tulajdonságai Load Color Profile… Színprofil betöltése… + Remove Color Profile… Színprofil eltávolítása… Load Image into Clipboard Kép betöltése a vágólapra Load Metafile into Clipboard Metafájl betöltése a vágólapra Load PDF into Clipboard PDF betöltése a vágólapra diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7cffb8995..fe5f1a4dc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1602,6 +1602,7 @@ Live Paint Selection Tool Strumento Selezione pittura dinamica Live Shape Properties Proprietà forma dinamica Load Color Profile… Carica profilo colore… + Remove Color Profile… Rimuovi profilo colore… Load Image into Clipboard Carica immagine negli appunti Load Metafile into Clipboard Carica metafile negli appunti Load PDF into Clipboard Carica PDF negli appunti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index abe633da1..af96e8078 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1781,6 +1781,7 @@ Live Paint Selection Tool ライブペイント選択ツール Live Shape Properties ライブシェイプのプロパティ Load Color Profile… カラープロファイルを読み込む… + Remove Color Profile… カラープロファイルを削除… Load Image into Clipboard 画像をクリップボードに読み込む Load Metafile into Clipboard メタファイルをクリップボードに読み込む Load PDF into Clipboard PDF をクリップボードに読み込む diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2d36be8b6..4268d7378 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1776,6 +1776,7 @@ Live Paint Selection Tool 라이브 페인트 선택 도구 Live Shape Properties 라이브 모양 속성 Load Color Profile… 색상 프로파일 불러오기… + Remove Color Profile… 색상 프로파일 제거… Load Image into Clipboard 이미지를 클립보드로 불러오기 Load Metafile into Clipboard 메타파일을 클립보드로 불러오기 Load PDF into Clipboard PDF를 클립보드로 불러오기 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08b10404f..ba1a49344 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2735,6 +2735,7 @@ theme Appearance Wygląd Convert Document Color Mode Konwertuj tryb koloru dokumentu Gamut Warning Ostrzeżenie gamutu Load Color Profile… Wczytaj profil kolorów… + Remove Color Profile… Usuń profil kolorów… Plates Płyty Spot Color Kolor dodatkowy Add to Swatches Dodaj do próbek diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4fe9c2ca5..32bf7d118 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1341,6 +1341,7 @@ Live Paint Selection Tool Ferramenta Seleção de Pintura ao Vivo Live Shape Properties Propriedades de Forma ao Vivo Load Color Profile… Carregar Perfil de Cor… + Remove Color Profile… Remover Perfil de Cor… Load Image into Clipboard Carregar Imagem na Área de Transferência Load Metafile into Clipboard Carregar Metarquivo na Área de Transferência Load PDF into Clipboard Carregar PDF na Área de Transferência diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c3ca047a8..8d1287235 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1600,6 +1600,7 @@ Live Paint Selection Tool Инструмент «Выделение быстрой заливки» Live Shape Properties Свойства динамической фигуры Load Color Profile… Загрузить профиль цвета… + Remove Color Profile… Удалить профиль цвета… Load Image into Clipboard Загрузить изображение в буфер обмена Load Metafile into Clipboard Загрузить метафайл в буфер обмена Load PDF into Clipboard Загрузить PDF в буфер обмена diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 367aeab82..713a977d0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1559,6 +1559,7 @@ Live Paint Selection Tool Markeringsverktyg för direktmålning Live Shape Properties Egenskaper för direktform Load Color Profile… Läs in färgprofil… + Remove Color Profile… Ta bort färgprofil… Load Image into Clipboard Läs in bild till urklipp Load Metafile into Clipboard Läs in metafil till urklipp Load PDF into Clipboard Läs in PDF till urklipp diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 22c386082..5d64483ec 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1596,6 +1596,7 @@ Live Paint Selection Tool Інструмент «Виділення швидкої заливки» Live Shape Properties Властивості динамічної фігури Load Color Profile… Завантажити профіль кольору… + Remove Color Profile… Видалити профіль кольору… Load Image into Clipboard Завантажити зображення в буфер обміну Load Metafile into Clipboard Завантажити метафайл у буфер обміну Load PDF into Clipboard Завантажити PDF у буфер обміну diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 62232418c..dd552e948 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1833,6 +1833,7 @@ Live Paint Selection Tool Công cụ Chọn Tô trực tiếp Live Shape Properties Thuộc tính hình dạng trực tiếp Load Color Profile… Tải hồ sơ màu… + Remove Color Profile… Xóa hồ sơ màu… Load Image into Clipboard Tải ảnh vào bảng tạm Load Metafile into Clipboard Tải metafile vào bảng tạm Load PDF into Clipboard Tải PDF vào bảng tạm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3e5782fd2..251768913 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1499,6 +1499,7 @@ Live Paint Selection Tool 实时上色选择工具 Live Shape Properties 实时形状属性 Load Color Profile… 载入颜色配置文件… + Remove Color Profile… 移除颜色配置文件… Load Image into Clipboard 将图像载入剪贴板 Load Metafile into Clipboard 将图元文件载入剪贴板 Load PDF into Clipboard 将 PDF 载入剪贴板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f215ef7bd..23bdde637 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1574,6 +1574,7 @@ Live Paint Selection Tool 即時上色選取工具 Live Shape Properties 即時形狀內容 Load Color Profile… 載入色彩描述檔… + Remove Color Profile… 移除色彩描述檔… Load Image into Clipboard 將影像載入剪貼簿 Load Metafile into Clipboard 將中繼檔載入剪貼簿 Load PDF into Clipboard 將 PDF 載入剪貼簿 diff --git a/docs/development.md b/docs/development.md index 978fa2e4a..7627cbfb2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -85,7 +85,8 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`. |---|---| | `VECTORCRAFT_CONTROL_PORT` | Same as `--control `, which overrides it; a value that isn't a port from 1 to 65535 stops the app | | `VECTORCRAFT_IN_WINDOW_MENUS` | Any value but empty or `0`: same as `--in-window-menus` (see [macOS: the menu bar](#macos-the-menu-bar)) | -| `VECTORCRAFT_NO_PREFS` | No preferences read or written, no default Data Recovery folder and no log file (agents' test runs) | +| `VECTORCRAFT_NO_PREFS` | No preferences read or written, no default Data Recovery folder and no log file, and no saved PDF presets or loaded ICC profiles kept (agents' test runs; also for `vectorcraft-cli`) | +| `VECTORCRAFT_CONFIG_DIR` | The folder for the preferences and the folders next to them (`PDF Presets`, `Color Profiles`, …), for the app and `vectorcraft-cli` (see `docs/control-protocol.md`) | | `WGPU_POWER_PREF` | Graphics adapter: `low`, `high` or `none` (see [Desktop graphics processor](#desktop-graphics-processor)) | | `WGPU_ADAPTER_NAME` | Graphics adapter by (part of) its name, any case (see [Desktop graphics processor](#desktop-graphics-processor)) | | `WGPU_BACKEND` | Graphics backends: `vulkan`, `dx12`, `metal`, `gl` | diff --git a/docs/mcp.md b/docs/mcp.md index e0f2fb9e5..a1b97ef2d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -771,6 +771,27 @@ applies to the `--cmd` just before it. `run` also accepts the host commands `fil `file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads (`vectorcraft-cli --help` lists them). +### What a headless session remembers + +`vectorcraft-cli run` and the headless `mcp` server keep two things between runs, in folders next to the +preferences (the folder the desktop app uses, so both see the same ones; `VECTORCRAFT_CONFIG_DIR` moves it): + +- **Saved PDF presets** (`pdf.preset.save`, `.delete`, `.import`): one `.vcpdfpresets` file per preset in + `PDF Presets`, written as the command runs. Presets saved earlier are there at the next start and work as + `preset` in the PDF options. +- **ICC profiles** (`color.loadProfile`): a copy of the file goes into `Color Profiles` (the result says + `remembered: true`), and every profile there is registered again at the next start, before the first + command, so `edit.colorSettings {"cmyk": "…"}` or a preset's output intent can name it in a later run. The + copy is what counts: the original may move or be deleted. `color.removeProfile {name}` forgets a profile and its + copy (not while it is a working space). + +Not remembered: Color Settings, the document, anything else. Nothing is read or written when +`VECTORCRAFT_NO_PREFS` is set, and a server confined with `--automation-read-root` or +`--automation-write-root` keeps nothing either (the folders are outside its roots). A file that is damaged, too large +(a profile over 32 MB, a presets file over 1 MB) or not a valid preset or profile is skipped and logged. When the +desktop app starts, the presets it kept in its preferences file before this folder existed are moved into it; after +that the folder is the one place they live, so a preset deleted by `vectorcraft-cli` is gone in the app too. + ### Parameter keys `command_run`, each step of `command_batch`, `run --cmd … --params` and the control channel's `engine.execute` check @@ -2476,8 +2497,10 @@ newName?, description?, preset?, …document.exportPdf options}` creates or chan the preset's own settings; `newName` renames it; built-in presets are read-only; passwords are never stored); `pdf.preset.delete {name}` deletes one. `pdf.preset.export {names?, path?}` writes them as a `.vcpdfpresets` JSON file (without `path` it returns `data`), and `pdf.preset.import {path? | data? | dataBase64?, replace?}` adds a file's -presets (names in use get a number unless `replace`). Saved presets live with the preferences and work as `preset` +presets (names in use get a number unless `replace`). Saved presets work as `preset` in `document.exportPdf`, `document.pdfSettings`, `document.export`/`serialize` with format `pdf` and `.ai` saves. +They are kept across restarts, in the desktop app and in `vectorcraft-cli` alike (see +[What a headless session remembers](#what-a-headless-session-remembers)). ```json {"name":"run_command","arguments":{"command":"pdf.preset.save","params":{"name":"Web","preset":"Smallest File Size","compatibility":"1.5"}}}