Merge pull request #845 from dotCooCoo/feat/copy-to-user-defined

Swatches: swatch.library.copyToUser copies a loaded library into User Defined
This commit is contained in:
BFlat authored and GitHub committed 2026-10-10 06:21:36 +07:00
commit 5b27fdc321
19 files changed
+269 -14

No files matched your search

+1 -1
View File
@@ -153,7 +153,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
+21
View File
@@ -725,6 +725,22 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// Write `bytes` to a new file at `path`. A file already at `path` is an error and is never
/// replaced. When the write fails part way, the file it started is removed.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
use std::io::Write as _;
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?;
let written = f.write_all(bytes).and_then(|()| f.sync_all());
drop(f);
written.map_err(|e| {
// Best effort: the partly written file is all there is to clean up.
let _ = std::fs::remove_file(path);
err(e)
})
}
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
/// keeps one); `None` when there is no file at `path`.
#[cfg(not(target_arch = "wasm32"))]
@@ -786,6 +802,11 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> {
Err(no_fs(path))
}
/// File-name parts for artboards `boards` of `doc`: the artboard's name with unsafe characters as
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
/// case: `Icon` and `icon` are one file on most desktop file systems).
+12
View File
@@ -396,3 +396,15 @@ fn oversized_raster_exports_are_refused() {
assert!(e.contains("too large"), "{e}");
assert!(s.execute("document.export", &json!({"format": "png", "scale": 40})).is_err(), "80000 px a side");
}
#[test]
fn new_files_never_replace_a_file() {
let d = tmp_dir("new-file");
let path = d.join("Brand.ase").to_string_lossy().to_string();
let _ = std::fs::remove_file(&path);
write_new_file(&path, b"first").unwrap();
let e = write_new_file(&path, b"second").unwrap_err().to_string();
assert!(e.starts_with(&format!("{path}: ")), "{e}");
assert_eq!(std::fs::read(&path).unwrap(), b"first");
let _ = std::fs::remove_dir_all(d);
}
+1 -1
View File
@@ -32,7 +32,7 @@ type Entry = (String, Vec<u8>);
/// `name`, else the first free `stem 2.ext`, `stem 3.ext`… (compared without case, as most
/// desktop file systems do); the name is taken.
fn free_name(name: &str, taken: &mut HashSet<String>) -> String {
pub(crate) fn free_name(name: &str, taken: &mut HashSet<String>) -> String {
let p = Path::new(name);
let stem = p.file_stem().map_or_else(|| name.to_string(), |s| s.to_string_lossy().into_owned());
let ext = p.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
+1 -1
View File
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Other Library…",
["Window", "Graphic Style Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits) → {library: id, name, count}",
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}",
always,
load
),
+110 -7
View File
@@ -3,7 +3,9 @@
//! [`vectorcraft_color::libraries`]; User Defined ones are the library files in the user library
//! folder ([`Libraries`]), and Other Library… loads more from files. `swatch.library.add` copies
//! swatches into the document; `swatch.library.save` writes the document's ([`palette_io`]).
//! `swatch.library.copyToUser` copies a loaded library into the user library folder.
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
@@ -12,8 +14,9 @@ use vectorcraft_color::palette_io::{self, PaletteFormat};
use vectorcraft_color::recolor::Palette;
use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches};
use super::fileio::{create_dir, read_file, write_file};
use super::fileio::{create_dir, extension, read_file, write_file, write_new_file};
use super::menucmds::squash;
use super::package::free_name;
use super::swatch::{map_default_paints, str_list, swatch_json};
use super::*;
@@ -69,10 +72,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Other Library…",
["Window", "Swatch Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}",
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}",
always,
load
),
cmd!(
"swatch.library.copyToUser",
"Copy to User Defined",
["Window", "Swatch Libraries"],
None,
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}",
has_user_folder,
copy_to_user
),
]
}
@@ -85,6 +97,9 @@ pub struct Libraries<L = SwatchLibrary> {
/// there are no User Defined libraries.
user_dir: Option<String>,
extra: Vec<Extra<L>>,
/// Loaded libraries copied into the user library folder ([`Libraries::copy_to_user`]): the
/// loaded id and the User Defined id [`Libraries::get`] finds for it.
copied: Vec<(String, String)>,
}
#[derive(Clone, Debug)]
@@ -153,9 +168,11 @@ impl<L: LibraryFile> Libraries<L> {
self.extra.iter().map(|e| &e.info)
}
/// The User Defined or loaded library `id`.
/// The User Defined or loaded library `id`. The id of a loaded library copied into the user
/// library folder finds the User Defined copy.
pub fn get(&self, id: &str) -> Option<(LibraryInfo, Arc<L>)> {
self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone()))
let find = |id: &str| self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone()));
find(id).or_else(|| self.copied.iter().find(|(loaded, _)| loaded == id).and_then(|(_, user)| find(user)))
}
/// The file library `id` was read from.
@@ -163,6 +180,14 @@ impl<L: LibraryFile> Libraries<L> {
self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref())
}
/// The User Defined library whose file has the extension of file name `file` and holds `bytes`.
fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra<L>> {
let ext = extension(file);
self.extra
.iter()
.find(|e| e.info.category == "user" && e.path.as_deref().is_some_and(|p| extension(p) == ext && read_file(p).is_ok_and(|b| b == bytes)))
}
/// 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` (text) or `dataBase64` (bytes); the results go into `out`.
@@ -196,8 +221,9 @@ impl<L: LibraryFile> Libraries<L> {
}
/// Load a library from `p` (`path`, `data` or `dataBase64`; `name`: the file's name) as the
/// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder is
/// its User Defined library; others are listed as loaded until the app quits.
/// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder,
/// or a file with the same extension and bytes as one there, is that User Defined library;
/// others are listed as loaded until the app quits.
pub(crate) fn load(&mut self, p: &Value, cmd: &str, parse: impl FnOnce(&[u8], &str) -> Result<L>) -> Result<(LibraryInfo, Arc<L>)> {
let path = str_param(p, "path");
let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
@@ -209,7 +235,9 @@ impl<L: LibraryFile> Libraries<L> {
let file = str_param(p, "name").map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into());
let lib = parse(&bytes, &file)?;
self.rescan();
if let Some(e) = path.and_then(|p| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p))) {
let user =
|p: &str| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p)).or_else(|| self.user_file(p, &bytes));
if let Some(e) = path.and_then(user) {
return Ok((e.info.clone(), e.lib.clone()));
}
let info = LibraryInfo { id: format!("loaded/{}", path.unwrap_or(&file)), name: lib.name().to_string(), category: "loaded" };
@@ -218,6 +246,58 @@ impl<L: LibraryFile> Libraries<L> {
self.extra.push(Extra { info: info.clone(), path: path.map(str::to_string), lib: lib.clone() });
Ok((info, lib))
}
/// Copy loaded library `id` into the user library folder, where it is listed as User Defined in
/// place of the loaded entry; `id` then finds the copy ([`Libraries::get`]). A library read
/// from a library file (an extension of `L::EXTS`) is copied as that file's bytes under the
/// file's name; any other (the swatches of a document, a library loaded from data) is written
/// by `encode` as a `.{ext}` file named after the library. A name the folder already has,
/// compared without case, gets a number ("Brand 2.ase"), and no file is replaced. When a file
/// of the folder with the same extension holds the same bytes, its library is used and nothing
/// is written. → the User Defined library, and whether a file was written.
pub(crate) fn copy_to_user(
&mut self,
id: &str,
ext: &str,
encode: impl FnOnce(&L) -> Result<Vec<u8>>,
cmd: &str,
) -> Result<(LibraryInfo, Arc<L>, bool)> {
let dir = self.user_dir().ok_or_else(|| bad(cmd, "no user library folder here"))?.to_string();
let (source, lib) = self
.extra
.iter()
.find(|e| e.info.id == id && e.info.category == "loaded")
.map(|e| (e.path.clone(), e.lib.clone()))
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
Some(p) => (file_name(&p), read_file(&p)?),
None => (library_file_name(lib.name(), ext), encode(&lib)?),
};
self.rescan();
let (user_id, written) = match self.user_file(&file, &bytes).map(|e| e.info.id.clone()) {
Some(same) => (same, None),
None => {
create_dir(&dir)?;
let mut taken: HashSet<String> = library_files(&dir, L::EXTS).iter().map(|p| file_name(p).to_lowercase()).collect();
let file = free_name(&file, &mut taken);
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
write_new_file(&path, &bytes)?;
self.rescan();
(format!("user/{file}"), Some(path))
}
};
let Some((info, lib)) = self.get(&user_id) else {
if let Some(path) = &written {
// Best effort: the copy that can't be read is all there is to clean up.
let _ = std::fs::remove_file(path);
}
return Err(bad(cmd, format!("the copy `{user_id}` can't be read as a library")));
};
self.extra.retain(|x| x.info.id != id);
self.copied.retain(|(loaded, _)| loaded != id);
self.copied.push((id.to_string(), info.id.clone()));
Ok((info, lib, written.is_some()))
}
}
/// The file name of library `name` with extension `ext`: characters file systems reject become
@@ -405,6 +485,29 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
)
}
/// There is a user library folder to copy libraries into (the desktop app sets one).
fn has_user_folder(s: &Session) -> std::result::Result<(), String> {
match s.swatch_libraries.user_dir() {
Some(_) => Ok(()),
None => Err("no user library folder here".into()),
}
}
fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.library.copyToUser";
let (info, lib) = library_param(s, p, C)?;
let (info, lib, copied) = match info.category {
"loaded" => {
let native = |lib: &SwatchLibrary| palette_io::write(lib, PaletteFormat::Native).map_err(|e| bad(C, e));
s.swatch_libraries.copy_to_user(&info.id, PaletteFormat::Native.id(), native, C)?
}
"user" => (info, lib, false),
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
};
let path = s.swatch_libraries.path(&info.id);
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
}
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
/// ungrouped): a group's name brings the group, a swatch's name the swatch alone; no names, all.
fn picked<'a>(lib: &'a SwatchLibrary, names: &[String], cmd: &str) -> Result<Vec<(Option<&'a str>, &'a Swatch)>> {
+8 -1
View File
@@ -192,8 +192,15 @@ fn user_libraries_and_other_documents_styles() {
assert_eq!(user.len(), 1);
assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Looks"), Some(doc(&s).graphic_styles.len() as u64)));
assert_eq!(list["userFolder"].as_str(), Some(dir.to_string_lossy().as_ref()));
// Opening a file of the user folder gives its User Defined library.
// Opening a file of the user folder gives its User Defined library, and so does a file with the
// same bytes in another folder.
assert_eq!(run(&mut s, "graphicStyle.loadLibrary", json!({ "path": path }))["library"], "user/My- Looks.vcstyles");
let elsewhere = dir.with_file_name(format!("vc-stylelib-elsewhere-{}", std::process::id()));
std::fs::create_dir_all(&elsewhere).unwrap();
let copy = elsewhere.join("Looks.vcstyles");
std::fs::copy(&path, &copy).unwrap();
assert_eq!(run(&mut s, "graphicStyle.loadLibrary", json!({ "path": copy.to_string_lossy() }))["library"], "user/My- Looks.vcstyles");
let _ = std::fs::remove_dir_all(elsewhere);
let _ = std::fs::remove_dir_all(dir);
// Another document's graphic styles load as a library.
+89
View File
@@ -283,6 +283,95 @@ fn swatch_exchange_files_save_with_models_kinds_and_groups() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() {
use vectorcraft_testkit::ase::{Block, ase, sample};
let dir = temp_dir("copy");
let (user, elsewhere) = (dir.join("user"), dir.join("elsewhere"));
std::fs::create_dir_all(&user).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
let source = elsewhere.join("Brand.ase");
std::fs::write(&source, sample()).unwrap();
let source = source.to_string_lossy().to_string();
// Another library whose file name differs only in case.
let other = ase(&[Block::Color("Only", b"RGB ", &[1.0, 0.0, 0.0], 2)]);
std::fs::write(user.join("brand.ase"), &other).unwrap();
let mut s = session();
s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string()));
let loaded = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
assert_eq!(loaded, format!("loaded/{source}"));
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
// A swatch exchange library is named after its file.
assert_eq!(
(r["library"].as_str(), r["name"].as_str(), r["count"].as_u64(), r["copied"].as_bool()),
(Some("user/Brand 2.ase"), Some("Brand 2"), Some(4), Some(true))
);
assert_eq!(r["path"], user.join("Brand 2.ase").to_string_lossy().to_string());
assert_eq!(std::fs::read(user.join("Brand 2.ase")).unwrap(), sample(), "the file as it is");
assert_eq!(std::fs::read(user.join("brand.ase")).unwrap(), other, "never replaced");
let listed = |s: &mut Session| -> Vec<String> {
let list = run(s, "swatch.library.list", json!({}));
let libs = list["libraries"].as_array().unwrap().iter().filter(|l| matches!(l["category"].as_str(), Some("user" | "loaded")));
libs.map(|l| l["id"].as_str().unwrap().to_string()).collect()
};
assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"], "listed as User Defined only");
// The loaded id finds the copy, so choices made with it (Limit to Library) keep their colors.
let found = cmd::swatchlib::library(&s, loaded.as_str().unwrap()).map(|(info, _)| info.id);
assert_eq!(found.as_deref(), Some("user/Brand 2.ase"));
assert!(cmd::swatchlib::limit_palette(&s, loaded.as_str().unwrap()).is_some());
// The same file again opens the copy, which is listed once; copying it writes nothing.
let again = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
assert_eq!(again, "user/Brand 2.ase");
assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"]);
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": again }));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Brand 2.ase"), Some(false)));
assert_eq!(std::fs::read_dir(&user).unwrap().count(), 2);
// A document's swatches and a library loaded from data are written as .vcswatches.
run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"}));
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
let poster = elsewhere.join("Poster.vectorcraft");
std::fs::write(&poster, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap();
let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone();
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(true)));
assert!(cmd::swatchlib::library(&s, "user/Poster.vcswatches").unwrap().1.swatch("Signal").is_some());
// Copied again, the document's swatches are written as the same bytes: the copy there is used.
let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone();
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(false)));
let kit = vectorcraft_format::base64_encode(&sample());
let loaded = run(&mut s, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone();
assert_eq!(run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }))["library"], "user/Kit.vcswatches");
// A library without a name is copied as Library.
let loaded = run(&mut s, "swatch.library.load", json!({"data": "GIMP Palette\nName:\n0 0 0 Black\n"}))["library"].clone();
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Library.vcswatches"), Some("Library"), Some(true)));
// When the source no longer reads as a library, the copy fails and no new file is left in the folder.
let junk = elsewhere.join("Junk.ase");
std::fs::write(&junk, ase(&[Block::Color("Junk", b"RGB ", &[0.0, 1.0, 0.0], 2)])).unwrap();
let loaded = run(&mut s, "swatch.library.load", json!({ "path": junk.to_string_lossy() }))["library"].clone();
std::fs::write(&junk, b"not a library").unwrap();
let files = std::fs::read_dir(&user).unwrap().count();
let e = s.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string();
assert!(e.contains("the copy `user/Junk.ase` can't be read as a library"), "{e}");
assert_eq!(std::fs::read_dir(&user).unwrap().count(), files);
// A User Defined library comes back as it is; a built-in one is an error.
let r = run(&mut s, "swatch.library.copyToUser", json!({"library": "user/brand.ase"}));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/brand.ase"), Some(false)));
assert!(s.execute("swatch.library.copyToUser", &json!({"library": "pastels"})).unwrap_err().to_string().contains("built in"));
assert!(s.execute("swatch.library.copyToUser", &json!({})).is_err());
// Without a user library folder (the web, headless sessions) there is nowhere to copy to, and
// the command is off.
let mut headless = session();
let info = find_command("swatch.library.copyToUser").unwrap().info(&headless);
assert!(!info.enabled && info.disabled_reason.as_deref() == Some("no user library folder here"), "{info:?}");
assert!(find_command("swatch.library.copyToUser").unwrap().info(&s).enabled);
let loaded = run(&mut headless, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone();
let e = headless.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string();
assert!(e.contains("no user library folder"), "{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};
+2
View File
@@ -497,6 +497,7 @@
Copy Fonts Used in the Document Copiar fuentes usadas en el documento
Copy Links Copiar vínculos
Copy Selected Style Copiar estilo seleccionado
Copy to User Defined Copiar en Definido por el usuario
Copy Width Point Copiar punto de anchura
Copyright Copyright
Copyright Info URL: URL de información de copyright:
@@ -3458,6 +3459,7 @@ axis Both Ambos
@msg no symbol instances selected no hay instancias de símbolo seleccionadas
@msg no target object has an opacity mask: select one or give `id` ningún objeto de destino tiene máscara de opacidad: selecciona uno o indica `id`
@msg no threaded text selected no hay texto enlazado seleccionado
@msg no user library folder here aquí no hay ninguna carpeta de bibliotecas de usuario
@msg no user workspace `{from}` no existe el espacio de trabajo de usuario `{from}`
@msg node {_1} is not text el nodo {_1} no es texto
@msg not a DXF drawing no es un dibujo DXF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -498,6 +498,7 @@
Copy Fonts Used in the Document Copier les polices utilisées dans le document
Copy Links Copier les liens
Copy Selected Style Copier le style sélectionné
Copy to User Defined Copier dans Défini par l’utilisateur
Copy Width Point Copier le point de largeur
Copyright Copyright
Copyright Info URL: URL des informations de copyright :
@@ -3455,6 +3456,7 @@ axis Both Les deux
@msg no symbol instances selected aucune occurrence de symbole sélectionnée
@msg no target object has an opacity mask: select one or give `id` aucun objet ciblé n’a de masque d’opacité : sélectionnez-en un ou indiquez `id`
@msg no threaded text selected aucun texte lié sélectionné
@msg no user library folder here aucun dossier de bibliothèques utilisateur ici
@msg no user workspace `{from}` aucun espace de travail utilisateur `{from}`
@msg node {_1} is not text le nœud {_1} n’est pas du texte
@msg not a DXF drawing ce n’est pas un dessin DXF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -497,6 +497,7 @@
Copy Fonts Used in the Document Copia i font usati nel documento
Copy Links Copia collegamenti
Copy Selected Style Copia stile selezionato
Copy to User Defined Copia in Definito dall'utente
Copy Width Point Copia punto di larghezza
Copyright Copyright
Copyright Info URL: URL informazioni copyright:
@@ -3458,6 +3459,7 @@ axis Both Entrambi
@msg no symbol instances selected nessuna istanza di simbolo selezionata
@msg no target object has an opacity mask: select one or give `id` nessun oggetto di destinazione ha una maschera di opacità: selezionane uno o specifica `id`
@msg no threaded text selected nessun testo concatenato selezionato
@msg no user library folder here nessuna cartella di librerie utente qui
@msg no user workspace `{from}` nessuna area di lavoro utente `{from}`
@msg node {_1} is not text il nodo {_1} non è testo
@msg not a DXF drawing non è un disegno DXF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -982,6 +982,7 @@
Copy All Styles すべてのスタイルをコピー
Copy Color Value (Hex) カラー値をコピー (16 進)
Copy Selected Style 選択したスタイルをコピー
Copy to User Defined ユーザー定義にコピー
Copy Width Point 幅ポイントをコピー
Copyright 著作権
Copyright Info URL: 著作権情報の URL:
@@ -3686,6 +3687,7 @@ theme Appearance 外観
@msg Unembedded to {_1} 埋め込みを解除して {_1} に保存しました
@msg Updated {_1} image(s) 画像を {_1} 枚更新しました
@msg no swatch library `{l}` (see swatch.library.list) スウォッチライブラリ `{l}` が見つかりません (swatch.library.list を参照)
@msg no user library folder here この環境にはユーザーライブラリフォルダーがありません
@msg Couldn't place {name}: the document it was dropped on was closed {name} を配置できませんでした: ドロップ先のドキュメントは閉じられています
@msg no files to place 配置するファイルがありません
@msg paths: give at least one file paths: ファイルを 1 つ以上指定してください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -480,6 +480,7 @@
Copy Color Value (Hex) Copiar Valor da Cor (Hex)
Copy Selected Style Copiar Estilo Selecionado
Copy Width Point Copiar Ponto de Largura
Copy to User Defined Copiar para Definido pelo Usuário
Copyright Direitos Autorais
Copyright Info URL: URL de Informações de Direitos Autorais:
Copyright Notice: Aviso de Direitos Autorais:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -495,6 +495,7 @@
Copy Fonts Used in the Document Копировать шрифты, использованные в документе
Copy Links Копировать связи
Copy Selected Style Копировать выбранный стиль
Copy to User Defined Копировать в «Пользовательский»
Copy Width Point Копировать точку ширины
Copyright Copyright
Copyright Info URL: URL информации об авторских правах:
@@ -3454,6 +3455,7 @@ axis Both Оба
@msg no symbol instances selected экземпляры символов не выделены
@msg no target object has an opacity mask: select one or give `id` ни у одного целевого объекта нет непрозрачной маски: выделите один или укажите `id`
@msg no threaded text selected связанный текст не выделен
@msg no user library folder here здесь нет папки пользовательских библиотек
@msg no user workspace `{from}` нет пользовательской рабочей среды `{from}`
@msg node {_1} is not text узел {_1} не является текстом
@msg not a DXF drawing не является чертежом DXF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -491,6 +491,7 @@
Copy Fonts Used in the Document Копіювати шрифти, використані у документі
Copy Links Копіювати зв’язки
Copy Selected Style Копіювати вибраний стиль
Copy to User Defined Копіювати в «Користувацькі»
Copy Width Point Копіювати точку ширини
Copyright Авторські права
Copyright Info URL: URL інформації про авторські права:
@@ -3441,6 +3442,7 @@ axis Both Обидві
@msg no symbol instances selected екземпляри символів не виділені
@msg no target object has an opacity mask: select one or give `id` у жодного цільового об’єкта немає маски непрозорості: виділіть один або вкажіть `id`
@msg no threaded text selected пов’язаний текст не виділено
@msg no user library folder here тут немає папки користувацьких бібліотек
@msg no user workspace `{from}` немає користувача робочого середовища `{from}`
@msg node {_1} is not text вузол {_1} не є текстом
@msg not a DXF drawing не є кресленням DXF
Can't render this file because it contains an unexpected character in line 627 and column 40.
+1
View File
@@ -481,6 +481,7 @@
Copy Fonts Used in the Document 复制文档中使用的字体
Copy Links 复制链接
Copy Selected Style 复制所选样式
Copy to User Defined 复制到用户定义
Copy Width Point 复制宽度点
Copyright 版权
Copyright Info URL: 版权信息 URL:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -495,6 +495,7 @@
Copy Fonts Used in the Document 複製文件中使用的字體
Copy Links 複製連結
Copy Selected Style 複製所選樣式
Copy to User Defined 複製到使用者定義
Copy Width Point 複製寬度點
Copyright 版權
Copyright Info URL: 著作權資訊網址:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -154,7 +154,8 @@ Clicking a swatch there runs `swatch.library.add {library, names: [name], apply}
one with Alt), so one undo step adds and applies it; Shift/Cmd-clicks select swatches and colour groups for
Add to Swatches.
`window.swatchLibrary.other {path?}` loads a library file (or another document's swatches) and opens it there;
opening a `.vcswatches`, `.gpl` or `.ase` file with `app.open` does the same. `ui.saveSwatchLibrary {names?}` opens the
opening a `.vcswatches`, `.gpl` or `.ase` file with `app.open` does the same. A file with the same extension and
bytes as a User Defined library opens as that library. `ui.saveSwatchLibrary {names?}` opens the
`saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`ase`/`css`, `user`: save to the user library
folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path
and suggests `name` with the format's extension as the file name; the web downloads the file under that name).
+9 -2
View File
@@ -804,7 +804,13 @@ it returns `{data}`, or `{dataBase64}` for `.ase`, and `user: true` saves into t
folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? |
dataBase64?, name?}` loads a `.vcswatches`, `.gpl` or swatch exchange (`.ase`) file, or another document's swatches,
as a library to add from. From an `.ase` file it reads RGB, CMYK, Lab and Gray colors as global, spot or process
swatches and keeps their color groups.
swatches and keeps their color groups. A file in the user library folder, or a file with the
same extension and bytes as one there, loads as that User Defined library (category `user`).
`swatch.library.copyToUser {library}` copies a loaded library into the user library folder of the desktop
app (a library file as it is; a document's swatches or a library loaded from `data` or `dataBase64`
as `.vcswatches`) under a name no file there has, and lists it as User Defined from then on; until the app
quits, commands given its loaded id use the copy → `{library, name, count, path, copied}` (`copied: false`
when the folder already held the same file). Without a user library folder the command is disabled.
## Graphic style libraries
@@ -827,7 +833,8 @@ its bounds.
the styles unlinked from swatches, with their opacity, blend mode, isolate and knockout, and the patterns they paint
with); without `path` it returns `{data}`, and `user: true` saves into the user library folder of the desktop app
(category `user`, User Defined). `graphicStyle.loadLibrary {path? | data? | dataBase64?, name?}` loads a `.vcstyles`
file, or another document's graphic styles, as a library to add from.
file, or another document's graphic styles, as a library to add from; a file in the user library folder, or a file
with the same extension and bytes as one there, loads as that User Defined library.
## Libraries