Merge pull request #770 from dotCooCoo/feat/swatch-libraries

Swatches: Save Swatch Library writes swatch exchange (.ase) files
This commit is contained in:
BFlat
2026-10-10 01:53:32 +07:00
committed by GitHub
21 changed files with 530 additions and 51 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary};
use super::menucmds::squash;
use super::swatch::str_list;
use super::swatchlib::{LibraryFile, LibraryInfo, stem};
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -211,7 +211,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
let lib = StyleLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?;
let mut out = json!({ "count": lib.len() });
s.style_libraries.write(p, &lib.name, STYLES_EXT, style_libs::write(&lib), &mut out, C)?;
s.style_libraries.write(p, &lib.name, STYLES_EXT, FileData::Text(style_libs::write(&lib)), &mut out, C)?;
Ok(out)
}
+36 -14
View File
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Swatch Library…",
["Window", "Swatches"],
None,
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}",
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
has_doc,
save
),
@@ -117,6 +117,14 @@ impl LibraryFile for SwatchLibrary {
/// The extensions of library files [`palette_io::read_bytes`] reads.
pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl", "ase"];
/// A library file the save commands write ([`Libraries::write`]).
pub(crate) enum FileData {
/// Text (`.vcswatches`, `.gpl`, CSS, `.vcstyles`), returned as `data`.
Text(String),
/// Bytes (`.ase`), returned as `dataBase64`.
Binary(Vec<u8>),
}
impl<L: LibraryFile> Libraries<L> {
pub fn user_dir(&self) -> Option<&str> {
self.user_dir.as_deref()
@@ -155,29 +163,34 @@ impl<L: LibraryFile> Libraries<L> {
self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref())
}
/// Write library file `text` as the save commands do: into the user library folder as
/// Write library file `data` as the save commands do: into the user library folder as
/// `name`.`ext` with `user: true` (→ `path`, and `library`: its id), to `path`, else back as
/// `data`; the results go into `out`.
pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, text: String, out: &mut Value, cmd: &str) -> Result<()> {
/// `data` (text) or `dataBase64` (bytes); the results go into `out`.
pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, data: FileData, out: &mut Value, cmd: &str) -> Result<()> {
let bytes = match &data {
FileData::Text(text) => text.as_bytes(),
FileData::Binary(bytes) => bytes.as_slice(),
};
if bool_or(p, "user", false) {
let dir = self
.user_dir()
.ok_or_else(|| bad(cmd, "no user library folder here (save with a path, or without one to get the data)"))?
.to_string();
// A file name from the library's name, without characters file systems reject.
let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect();
let file = format!("{}.{ext}", base.trim_matches(['.', ' ']));
let file = library_file_name(name, ext);
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
create_dir(&dir)?;
write_file(&path, text.as_bytes())?;
write_file(&path, bytes)?;
self.rescan();
out["path"] = json!(path);
out["library"] = json!(format!("user/{file}"));
} else if let Some(path) = str_param(p, "path") {
write_file(path, text.as_bytes())?;
write_file(path, bytes)?;
out["path"] = json!(path);
} else {
out["data"] = json!(text);
match data {
FileData::Text(text) => out["data"] = json!(text),
FileData::Binary(bytes) => out["dataBase64"] = json!(vectorcraft_format::base64_encode(&bytes)),
}
}
Ok(())
}
@@ -207,6 +220,14 @@ impl<L: LibraryFile> Libraries<L> {
}
}
/// The file name of library `name` with extension `ext`: characters file systems reject become
/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`.
pub fn library_file_name(name: &str, ext: &str) -> String {
let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect();
let base = base.trim_matches(['.', ' ']);
format!("{}.{ext}", if base.is_empty() { "Library" } else { base })
}
/// The library files with extensions `exts` in folder `dir`, sorted by name.
#[cfg(not(target_arch = "wasm32"))]
fn library_files(dir: &str, exts: &[&str]) -> Vec<String> {
@@ -343,15 +364,16 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.library.save";
let path = str_param(p, "path");
let format = match str_param(p, "format").or_else(|| path.map(|p| p.rsplit_once('.').map_or("", |(_, e)| e))).filter(|f| !f.is_empty()) {
Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl or css)")))?,
Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl, ase or css)")))?,
None => PaletteFormat::Native,
};
let st = s.doc()?;
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?;
let (count, text) = (lib.len(), palette_io::write(&lib, format));
let mut out = json!({"format": format.id(), "count": count});
s.swatch_libraries.write(p, &lib.name, format.id(), text, &mut out, C)?;
let bytes = palette_io::write(&lib, format).map_err(|e| bad(C, e))?;
let data = if format.binary() { FileData::Binary(bytes) } else { FileData::Text(String::from_utf8_lossy(&bytes).into_owned()) };
let mut out = json!({"format": format.id(), "count": lib.len()});
s.swatch_libraries.write(p, &lib.name, format.id(), data, &mut out, C)?;
Ok(out)
}
+48
View File
@@ -182,6 +182,10 @@ fn libraries_saved_in_the_user_folder_are_user_defined() {
let css = dir.join("out.css").to_string_lossy().to_string();
run(&mut s, "swatch.library.save", json!({ "path": css }));
assert!(std::fs::read_to_string(&css).unwrap().contains(" --white: #ffffff;"));
// A name with nothing a file name can keep is saved as Library.vcswatches, and User Defined lists it.
let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "..."}));
assert_eq!(r["library"], "user/Library.vcswatches");
assert_eq!(cmd::swatchlib::library(&s, "user/Library.vcswatches").map(|(info, _)| info.name), Some("...".into()));
let _ = std::fs::remove_dir_all(dir);
}
@@ -235,6 +239,50 @@ fn swatch_exchange_files_load_add_and_list_as_user_libraries() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn swatch_exchange_files_save_with_models_kinds_and_groups() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true}));
run(&mut s, "swatch.new", json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}}));
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]}));
let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "names": ["Ink", "Clay", "Brand", "Grays", "Sunset"], "name": "Brand Kit"}));
assert_eq!(r["format"], "ase");
assert!(r.get("data").is_none(), "a binary file comes back as dataBase64");
let b64 = r["dataBase64"].as_str().unwrap().to_string();
assert!(vectorcraft_format::base64_decode(&b64).unwrap().starts_with(b"ASEF"));
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Brand Kit.ase"}));
let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap();
assert_eq!(lib.name, "Brand Kit", "named after the file");
let ink = lib.swatch("Ink").unwrap();
assert!(ink.spot && ink.global);
assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2)));
assert_eq!(lib.swatch("Clay").unwrap().paint.color(), Some(Color::lab(50.0, 20.0, -30.0)));
assert!(lib.swatch("Sunset").is_none(), "a gradient is left out");
let groups: Vec<(&str, usize)> = lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.len())).collect();
assert_eq!(groups, [("Grays", 9), ("Brand", 2)], "in document order");
// Gray colors come back within float rounding.
let grays = &doc(&s).swatch_groups.iter().find(|g| g.name == "Grays").unwrap().swatches;
for (w, want) in lib.groups[0].swatches.iter().zip(grays) {
let (Some(Color::Gray { k }), Some(Color::Gray { k: want_k })) = (w.paint.color(), want.paint.color()) else {
panic!("{} isn't gray", w.name)
};
assert!(w.name == want.name && (k - want_k).abs() < 1e-4, "{}: {k} for {want_k}", w.name);
}
// Into the user library folder, and to a path ending in .ase.
let dir = temp_dir("ase-save");
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "user": true, "name": "Brand Kit"}));
assert_eq!(r["library"], "user/Brand Kit.ase");
assert!(std::fs::read(r["path"].as_str().unwrap()).unwrap().starts_with(b"ASEF"));
assert!(cmd::swatchlib::library(&s, "user/Brand Kit.ase").is_some(), "the user folder lists it");
let path = dir.join("out.ase").to_string_lossy().to_string();
assert_eq!(run(&mut s, "swatch.library.save", json!({ "path": path }))["format"], "ase");
assert!(std::fs::read(&path).unwrap().starts_with(b"ASEF"));
let e = s.execute("swatch.library.save", &json!({"format": "aco"})).unwrap_err().to_string();
assert!(e.contains("vcswatches, gpl, ase or css"), "{e}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_library_group_stays_one_group_when_a_swatch_has_its_name() {
use vectorcraft_testkit::ase::{Block, ase};