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 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-17 13:49:48 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 51f8582c25
commit a8e45f0e2d
31 changed files with 605 additions and 18 deletions
+12 -3
View File
@@ -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<String> = 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.
+94
View File
@@ -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<Value> {
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", &params.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::<Value>(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::<Vec<u8>>();
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));
}
+7 -1
View File
@@ -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));
+25
View File
@@ -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<PathBuf> {
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::*;
+19 -3
View File
@@ -257,9 +257,25 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
Ok(info)
}
/// A number that changes whenever [`register_icc`] registers a profile: 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.
/// Forget a profile [`register_icc`] registered (by its name); `false` when there is none of that
/// name (the built-in profiles can't be removed). Settings that name it no longer resolve, so a
/// caller checks [`active_settings`] first.
pub fn unregister_icc(name: &str) -> 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)
}
+36 -4
View File
@@ -39,10 +39,19 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
}
#[cfg(not(target_arch = "wasm32"))]
fn load_profile(_: &mut Session, p: &Value) -> Result<Value> {
fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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")]
+17 -5
View File
@@ -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<PdfPreset>,
pub(crate) presets: Vec<PdfPreset>,
}
impl PresetFile {
pub(crate) fn new(presets: Vec<PdfPreset>) -> Self {
Self { format: PRESET_FORMAT.into(), presets }
}
}
pub fn specs() -> Vec<CommandSpec> {
@@ -112,6 +118,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
}
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<Value> {
};
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<Value> {
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<Value> {
@@ -196,12 +206,14 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
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);
}
}
+11
View File
@@ -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<std::path::PathBuf>,
/// Where the ICC profiles `color.loadProfile` loads are kept; `None`: only for the process
/// ([`Session::attach_profile_store`]).
profile_dir: Option<std::path::PathBuf>,
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
pub recent_urls: Vec<String>,
/// 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;
+211
View File
@@ -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<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else { return vec![] };
let mut files: Vec<PathBuf> = 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<Vec<u8>, 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<PdfPreset> {
let mut presets: Vec<PdfPreset> = vec![];
for path in list_files(dir, PRESET_FORMAT) {
let file = read_capped(&path, MAX_PRESET_FILE).and_then(|bytes| serde_json::from_slice::<PresetFile>(&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
}
+128
View File
@@ -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<String> {
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<u8> {
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::<Vec<u8>>();
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();
}
+1
View File
@@ -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 в клипборда
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 στο πρόχειρο
Can't render this file because it contains an unexpected character in line 1897 and column 19.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 をクリップボードに読み込む
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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를 클립보드로 불러오기
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 в буфер обмена
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -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 у буфер обміну
Can't render this file because it contains an unexpected character in line 720 and column 40.
+1
View File
@@ -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
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1833
1834
1835
1836
1837
1838
1839
+1
View File
@@ -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 载入剪贴板
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 載入剪貼簿
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -85,7 +85,8 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
|---|---|
| `VECTORCRAFT_CONTROL_PORT` | Same as `--control <port>`, 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` |
+24 -1
View File
@@ -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"}}}