mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
51f8582c25
commit
a8e45f0e2d
@@ -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.
|
||||
|
||||
@@ -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", ¶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::<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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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
@@ -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
@@ -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"}}}
|
||||
|
||||
Reference in New Issue
Block a user