mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
Merge pull request #770 from dotCooCoo/feat/swatch-libraries
Swatches: Save Swatch Library writes swatch exchange (.ase) files
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user