Swatches: Save Swatch Library writes swatch exchange (.ase) files

The Save Swatch Library dialog lists Swatch Exchange (.ase) as a
format, and swatch.library.save takes format: "ase" or a path ending
in .ase. Each solid color keeps its model (RGB, CMYK, Lab or Gray) and
its type (spot, global or process), and color groups stay groups.
Gradients and patterns are left out, and a tint swatch is written as
the color it shows. Without a path or user: true, the command returns
the file as dataBase64.

A swatch exchange library opens under the name of its file. When Save
Swatch Library or Save Graphic Style Library saves to a file, the save
panel suggests the Name from the dialog as the file name, as a save to
User Defined does, and the web app downloads the file under that name.
A name made only of dots and spaces, such as "...", gives the file name
Library (Library.vcswatches) in place of a hidden file.

palette_io::write returns the file's bytes. A property test writes
generated libraries and reads them back, and an import fuzz test loads
mutated copies of a file the writer wrote.
This commit is contained in:
dotCooCoo committed 2026-10-09 08:15:09 -07:00
1 parent 0a91fd9b44
commit a61d058f1b
21 files changed
+530 -51

No files matched your search

+1 -1
View File
@@ -151,7 +151,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 (`object.cropImage {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` or CSS, and loaded from `.vcswatches`, `.gpl` and swatch exchange (`.ase`) files (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). 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.
+312 -12
View File
@@ -1,7 +1,7 @@
//! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients
//! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment
//! headers) and CSS custom properties (written only). Swatch exchange files (`.ase`) are read
//! only ([`read_bytes`]).
//! headers), swatch exchange `.ase` files (binary: solid colors in their own model, global, spot
//! and process colors, color groups) and CSS custom properties (written only).
use std::collections::{HashMap, HashSet};
@@ -16,18 +16,22 @@ pub enum PaletteFormat {
Native,
/// `.gpl`: a plain-text RGB palette many paint and design tools read.
Gpl,
/// `.ase`: a swatch exchange file (binary): solid colors in their own model, global, spot or
/// process, and color groups.
Ase,
/// `.css`: custom properties on `:root` (colours and gradients; written only).
Css,
}
impl PaletteFormat {
pub const ALL: [PaletteFormat; 3] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Css];
pub const ALL: [PaletteFormat; 4] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase, PaletteFormat::Css];
/// The format's id, which is also its extension.
pub fn id(self) -> &'static str {
match self {
PaletteFormat::Native => "vcswatches",
PaletteFormat::Gpl => "gpl",
PaletteFormat::Ase => "ase",
PaletteFormat::Css => "css",
}
}
@@ -35,6 +39,7 @@ impl PaletteFormat {
match self {
PaletteFormat::Native => "VectorCraft Swatches (.vcswatches)",
PaletteFormat::Gpl => "GPL Palette (.gpl)",
PaletteFormat::Ase => "Swatch Exchange (.ase)",
PaletteFormat::Css => "CSS Custom Properties (.css)",
}
}
@@ -47,6 +52,10 @@ impl PaletteFormat {
pub fn readable(self) -> bool {
self != PaletteFormat::Css
}
/// Are files of this format binary rather than text?
pub fn binary(self) -> bool {
self == PaletteFormat::Ase
}
}
/// The native file: a header around the library.
@@ -71,11 +80,13 @@ const ASE_MAX_ENTRIES: usize = 100_000;
const ASE_MAX_NAME: usize = 1024;
const ASE_CUT_SHORT: &str = "the swatch exchange file is cut short";
/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left
/// out; `.gpl` keeps solid colours only, as 8-bit RGB.
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String {
/// Write `lib` in `format` → the file's bytes. Pattern swatches (their tiles live in a document)
/// and None are left out; `.gpl` keeps solid colours only, as 8-bit RGB, and `.ase` keeps solid
/// colors in their own model. Writing `.ase` fails for a library with more colors and groups than
/// [`read_bytes`] reads from one file.
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> Result<Vec<u8>, String> {
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
match format {
let text = match format {
PaletteFormat::Native => {
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
let library = SwatchLibrary {
@@ -87,8 +98,10 @@ pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String {
serde_json::to_string_pretty(&file).unwrap_or_default()
}
PaletteFormat::Gpl => write_gpl(lib),
PaletteFormat::Ase => return write_ase(lib),
PaletteFormat::Css => write_css(lib, keep),
}
};
Ok(text.into_bytes())
}
/// One line of text: no line breaks or tabs.
@@ -184,6 +197,102 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
out
}
/// `.ase`, as [`read_ase`] reads it: version 1.0, the ungrouped colors, then each color group as a
/// group start (its name), its colors and a group end, also for a group without colors. A color is
/// written in its own model: RGB, CMYK and gray components from 0 to 1 (gray as a level, 1 is
/// white), Lab lightness as a fraction of 100 and a and b from −128 to 127. Components are clamped
/// to those ranges, and a component that isn't a finite number is written as 0. The color type is
/// 1 for a spot color, 0 for another global color and 2 for a process color.
///
/// A swatch exchange file holds solid colors only: gradients, patterns and None are left out, and
/// a tint swatch is written as the color it shows, a process color without its link to its base.
/// The file has no library name ([`read_ase`] names a library after its file). More than
/// [`ASE_MAX_ENTRIES`] colors and groups is an error.
fn write_ase(lib: &SwatchLibrary) -> Result<Vec<u8>, String> {
let solid = |list: &[Swatch]| list.iter().filter(|w| w.paint.color().is_some()).count();
let entries = solid(&lib.swatches) + lib.groups.iter().map(|g| 1 + solid(&g.swatches)).sum::<usize>();
if entries > ASE_MAX_ENTRIES {
return Err(format!(
"VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries}"
));
}
let mut body = vec![];
let mut blocks = put_ase_colors(&mut body, &lib.swatches)?;
for g in &lib.groups {
let mut name = vec![];
put_ase_name(&mut name, &g.name)?;
put_ase_block(&mut body, ASE_GROUP_START, &name)?;
blocks += 2 + put_ase_colors(&mut body, &g.swatches)?;
put_ase_block(&mut body, ASE_GROUP_END, &[])?;
}
let blocks = u32::try_from(blocks).map_err(|_| "too many swatch exchange blocks".to_string())?;
let mut out = ASE_SIGNATURE.to_vec();
out.extend(1u16.to_be_bytes());
out.extend(0u16.to_be_bytes());
out.extend(blocks.to_be_bytes());
out.extend(body);
Ok(out)
}
/// The color blocks of the solid colors in `list` → how many were written.
fn put_ase_colors(out: &mut Vec<u8>, list: &[Swatch]) -> Result<usize, String> {
let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
let ab = |v: f32| if v.is_finite() { v.clamp(-128.0, 127.0) } else { 0.0 };
let mut n = 0;
for (w, color) in list.iter().filter_map(|w| Some((w, w.paint.color()?))) {
let mut body = vec![];
put_ase_name(&mut body, &w.name)?;
let (model, values): (&[u8; 4], Vec<f32>) = match color {
Color::Rgb { r, g, b } => (b"RGB ", vec![unit(r), unit(g), unit(b)]),
Color::Cmyk { c, m, y, k } => (b"CMYK", vec![unit(c), unit(m), unit(y), unit(k)]),
Color::Lab { l, a, b } => (b"LAB ", vec![unit(l / 100.0), ab(a), ab(b)]),
Color::Gray { k } => (b"Gray", vec![1.0 - unit(k)]),
};
body.extend(model);
body.extend(values.iter().flat_map(|v| v.to_be_bytes()));
let kind: u16 = if w.spot {
1
} else if w.global {
0
} else {
2
};
body.extend(kind.to_be_bytes());
put_ase_block(out, ASE_COLOR, &body)?;
n += 1;
}
Ok(n)
}
/// A block: its type, the length of its body and the body.
fn put_ase_block(out: &mut Vec<u8>, kind: u16, body: &[u8]) -> Result<(), String> {
let len = u32::try_from(body.len()).map_err(|_| "a swatch exchange block is too long".to_string())?;
out.extend(kind.to_be_bytes());
out.extend(len.to_be_bytes());
out.extend_from_slice(body);
Ok(())
}
/// A name as `.ase` stores it: a `u16` count of UTF-16 code units, the terminating zero included,
/// then the units. NUL characters are left out, and a name longer than [`ASE_MAX_NAME`] code units
/// is cut before the character that would pass that limit.
fn put_ase_name(out: &mut Vec<u8>, name: &str) -> Result<(), String> {
let mut units: Vec<u16> = vec![];
for c in name.chars().filter(|&c| c != '\0') {
let mut buf = [0; 2];
let encoded = c.encode_utf16(&mut buf);
if units.len() + encoded.len() > ASE_MAX_NAME {
break;
}
units.extend_from_slice(encoded);
}
units.push(0);
let count = u16::try_from(units.len()).map_err(|_| "a swatch exchange name is too long".to_string())?;
out.extend(count.to_be_bytes());
out.extend(units.iter().flat_map(|u| u.to_be_bytes()));
Ok(())
}
/// Read a `.vcswatches` or `.gpl` library (detected from its content). `name` names it when the
/// file doesn't.
pub fn read(text: &str, name: &str) -> Result<SwatchLibrary, String> {
@@ -442,6 +551,7 @@ impl UniqueNames {
mod tests {
use super::*;
use crate::{Gradient, GradientPaint, GradientStop};
use proptest::prelude::*;
fn sample() -> SwatchLibrary {
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
@@ -468,10 +578,15 @@ mod tests {
}
}
/// `lib` written as text format `f`.
fn written(lib: &SwatchLibrary, f: PaletteFormat) -> String {
String::from_utf8(write(lib, f).unwrap()).unwrap()
}
#[test]
fn native_round_trip_keeps_cmyk_spot_and_groups() {
let lib = sample();
let text = write(&lib, PaletteFormat::Native);
let text = written(&lib, PaletteFormat::Native);
assert!(sniff(&text));
let back = read(&text, "fallback").unwrap();
let mut expected = lib.clone();
@@ -484,7 +599,7 @@ mod tests {
#[test]
fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() {
let text = write(&sample(), PaletteFormat::Gpl);
let text = written(&sample(), PaletteFormat::Gpl);
assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n"));
assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}");
assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours");
@@ -514,7 +629,7 @@ mod tests {
assert_eq!(css_property("#FF00CC"), "--\\#ff00cc");
assert_eq!(css_property("Café 50%"), "--café-50\\%");
assert_eq!(css_property(" "), "--swatch");
let text = write(&sample(), PaletteFormat::Css);
let text = written(&sample(), PaletteFormat::Css);
assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}");
assert!(text.contains(" --ink: #"), "{text}");
assert!(text.contains("; /* spot */"));
@@ -528,7 +643,7 @@ mod tests {
swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(),
groups: vec![],
};
let text = write(&lib, PaletteFormat::Css);
let text = written(&lib, PaletteFormat::Css);
assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;"));
assert!(read(&text, "x").is_err(), "CSS is written only");
}
@@ -645,4 +760,189 @@ mod tests {
assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than"));
assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit");
}
#[test]
fn ase_writes_each_model_kind_and_group() {
let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot };
let tint = Swatch {
name: "Ink 40%".into(),
paint: Paint::Solid { color: Color::cmyk(0.4, 0.2, 0.0, 0.08), swatch: Some("Ink".into()), tint: 0.4 },
global: false,
spot: false,
};
// `sample()` holds the spot color Ink, the gradient Fade, the pattern Tiles and the group Neutrals.
let mut lib = sample();
lib.swatches.insert(0, sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false));
lib.swatches.push(tint);
lib.groups[0].swatches = vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)];
lib.groups.push(SwatchGroup { name: "Empty".into(), swatches: vec![] });
let bytes = write(&lib, PaletteFormat::Ase).unwrap();
let expected = ase(&[
ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0),
ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1),
ase_color("Ink 40%", b"CMYK", &[0.4, 0.2, 0.0, 0.08], 2),
(ASE_GROUP_START, ase_name("Neutrals")),
ase_color("Mist", b"Gray", &[0.75], 2),
ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2),
(ASE_GROUP_END, vec![]),
(ASE_GROUP_START, ase_name("Empty")),
(ASE_GROUP_END, vec![]),
]);
assert_eq!(bytes, expected, "no gradient or pattern; the tint as the process color it shows");
let back = read_bytes(&bytes, "Brand").unwrap();
assert_eq!((back.len(), back.groups.len()), (5, 2), "the empty group stays");
assert_eq!(PaletteFormat::parse(".ASE"), Some(PaletteFormat::Ase));
assert!(PaletteFormat::Ase.readable() && PaletteFormat::Ase.binary() && !PaletteFormat::Gpl.binary());
}
#[test]
fn ase_names_lose_nul_and_stop_at_the_reader_limit() {
let names = ["Re\0d".to_string(), "é".repeat(1030), format!("a{}", "🎨".repeat(600))];
let swatches = names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).collect();
let lib = SwatchLibrary { name: "x".into(), swatches, groups: vec![] };
let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap();
let got: Vec<&str> = back.swatches.iter().map(|w| w.name.as_str()).collect();
// At most 1024 UTF-16 code units, cut between characters: "a" and 511 two-unit emoji.
assert_eq!(got, ["Red".to_string(), "é".repeat(1024), format!("a{}", "🎨".repeat(511))]);
}
#[test]
fn ase_clamps_components_and_writes_non_finite_ones_as_zero() {
let sw = |name: &str, color| Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false };
let swatches = vec![
sw("Lab", Color::lab(100.5, 300.0, -300.0)),
sw("Rgb", Color::rgb(2.0, -1.0, f32::NAN)),
sw("Cmyk", Color::cmyk(1.5, 0.0, 0.0, f32::INFINITY)),
sw("Gray", Color::gray(1.5)),
];
let bytes = write(&SwatchLibrary { name: "x".into(), swatches, groups: vec![] }, PaletteFormat::Ase).unwrap();
let expected = ase(&[
ase_color("Lab", b"LAB ", &[1.0, 127.0, -128.0], 2),
ase_color("Rgb", b"RGB ", &[1.0, 0.0, 0.0], 2),
ase_color("Cmyk", b"CMYK", &[1.0, 0.0, 0.0, 0.0], 2),
ase_color("Gray", b"Gray", &[0.0], 2),
]);
assert_eq!(bytes, expected, "clamped to the ranges .ase holds, a component that isn't finite as 0");
// Lightness 100.5 is written as 1, which reads back as L* 100.
let back = read_bytes(&bytes, "x").unwrap();
assert_eq!(back.swatches[0].paint.color(), Some(Color::lab(100.0, 127.0, -128.0)));
}
#[test]
fn ase_write_refuses_more_entries_than_the_reader_takes() {
let gray = |i: usize| Swatch { name: format!("G{i}"), paint: Paint::solid(Color::gray(0.5)), global: false, spot: false };
// The limit counts colors and groups: a group and `ASE_MAX_ENTRIES - 1` colors reach it.
let mut lib = SwatchLibrary {
name: "x".into(),
swatches: (1..ASE_MAX_ENTRIES).map(gray).collect(),
groups: vec![SwatchGroup { name: "Group".into(), swatches: vec![] }],
};
let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap();
assert_eq!((back.len(), back.groups.len()), (ASE_MAX_ENTRIES - 1, 1));
lib.swatches.push(gray(0));
let e = write(&lib, PaletteFormat::Ase).unwrap_err();
assert!(e.contains("reads at most 100000 colors and groups from a swatch exchange file, and this library has 100001"), "{e}");
// A gradient is left out and is not counted.
lib.swatches.last_mut().unwrap().paint = sample().swatches[1].paint.clone();
assert!(write(&lib, PaletteFormat::Ase).is_ok());
}
/// A color in one of the four models, in the ranges `.ase` holds.
fn arb_color() -> impl Strategy<Value = Color> {
let unit = || 0.0f32..=1.0;
prop_oneof![
(unit(), unit(), unit()).prop_map(|(r, g, b)| Color::rgb(r, g, b)),
(unit(), unit(), unit(), unit()).prop_map(|(c, m, y, k)| Color::cmyk(c, m, y, k)),
unit().prop_map(Color::gray),
(0.0f32..=100.0, -128.0f32..=127.0, -128.0f32..=127.0).prop_map(|(l, a, b)| Color::lab(l, a, b)),
]
}
/// A swatch's paint with its global and spot flags: a solid color with any flags, a tint swatch
/// (both flags off) or a gradient.
fn arb_paint() -> impl Strategy<Value = (Paint, bool, bool)> {
prop_oneof![
4 => (arb_color(), any::<bool>(), any::<bool>()).prop_map(|(c, global, spot)| (Paint::solid(c), global, spot)),
1 => arb_color().prop_map(|c| (Paint::Solid { color: c, swatch: Some("Base".into()), tint: 0.4 }, false, false)),
1 => Just((sample().swatches[1].paint.clone(), false, false)),
]
}
/// Ungrouped swatches and color groups. A name is a number and up to 12 characters of any script
/// but NUL, trimmed, so swatches and groups have unique names, as in a document.
fn arb_library() -> impl Strategy<Value = SwatchLibrary> {
let swatches = || prop::collection::vec((arb_paint(), "[^\\x00]{0,12}"), 0..12);
(swatches(), prop::collection::vec(("[^\\x00]{0,12}", swatches()), 0..4)).prop_map(|(loose, groups)| {
let mut n = 0;
let mut name = |prefix: &str, base: &str| {
n += 1;
format!("{prefix}{n} {base}").trim().to_string()
};
let mut lib = SwatchLibrary { name: "Fuzz".into(), ..Default::default() };
for ((paint, global, spot), base) in loose {
lib.swatches.push(Swatch { name: name("", &base), paint, global, spot });
}
for (base, list) in groups {
let mut g = SwatchGroup { name: name("G", &base), swatches: vec![] };
for ((paint, global, spot), base) in list {
g.swatches.push(Swatch { name: name("", &base), paint, global, spot });
}
lib.groups.push(g);
}
lib
})
}
/// Are `got` and `want` in the same model, with every component within 1e-4? Lab lightness and
/// gray read back within `f32` rounding.
fn close(got: Color, want: Color) -> bool {
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
match (got, want) {
(Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => near(r, r2) && near(g, g2) && near(b, b2),
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => near(c, c2) && near(m, m2) && near(y, y2) && near(k, k2),
(Color::Gray { k }, Color::Gray { k: k2 }) => near(k, k2),
(Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => near(l, l2) && near(a, a2) && near(b, b2),
_ => false,
}
}
/// The swatches of `list` as an `.ase` file reads them back: solid colors only, unlinked, spot
/// colors global.
fn ase_expected(list: &[Swatch]) -> Vec<Swatch> {
list.iter()
.filter_map(|w| Some(Swatch { name: w.name.clone(), paint: Paint::solid(w.paint.color()?), global: w.global || w.spot, spot: w.spot }))
.collect()
}
/// Do swatches `got` have the names, flags and unlinked colors (within 1e-4) of `want`?
fn same_swatches(got: &[Swatch], want: &[Swatch]) -> bool {
got.len() == want.len()
&& got.iter().zip(want).all(|(g, w)| {
let color = match (&g.paint, w.paint.color()) {
(Paint::Solid { color, swatch: None, .. }, Some(c)) => close(*color, c),
_ => false,
};
g.name == w.name && g.global == w.global && g.spot == w.spot && color
})
}
proptest! {
#![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })]
/// A written `.ase` file reads back as the library's solid colors in their own model, with
/// their names, kinds and color groups.
#[test]
fn ase_round_trip_keeps_models_kinds_and_groups(lib in arb_library()) {
let bytes = write(&lib, PaletteFormat::Ase).unwrap();
prop_assert!(sniff_bytes(&bytes));
let back = read_bytes(&bytes, &lib.name).unwrap();
prop_assert_eq!(&back.name, &lib.name);
prop_assert!(same_swatches(&back.swatches, &ase_expected(&lib.swatches)), "{:?}", back.swatches);
prop_assert_eq!(back.groups.len(), lib.groups.len());
for (g, want) in back.groups.iter().zip(&lib.groups) {
prop_assert_eq!(&g.name, &want.name);
prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches);
}
}
}
}
+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};
+27
View File
@@ -823,6 +823,20 @@ fn swatch_exchange(what: &str, bytes: &[u8]) -> Result<(), TestCaseError> {
})
}
/// The rich document's swatches with a CMYK spot color and a global Lab color added, saved as a
/// swatch exchange file, made once.
fn saved_ase() -> Vec<u8> {
static ASE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
ASE.get_or_init(|| {
let mut s = rich_session();
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 1, "m": 0.5, "y": 0, "k": 0.2}, "spot": true})).unwrap();
s.execute("swatch.new", &json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}, "global": true})).unwrap();
let r = s.execute("swatch.library.save", &json!({"format": "ase"})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
})
.clone()
}
/// A random swatch exchange block (type, body). The body holds a name, a color model when the block
/// is a color, and random bytes for the rest.
fn arb_ase_block() -> impl Strategy<Value = (u16, Vec<u8>)> {
@@ -899,6 +913,19 @@ proptest! {
}
swatch_exchange("mutated swatch exchange file", &bytes)?;
}
#[test]
fn mutated_saved_swatch_exchange_files_never_panic(cut in prop::option::of(0usize..4_000), edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
let mut bytes = saved_ase();
for (at, b) in edits {
let n = bytes.len();
bytes[at % n] = b;
}
if let Some(cut) = cut {
bytes.truncate(cut);
}
swatch_exchange("mutated saved swatch exchange file", &bytes)?;
}
}
/// A small image of each raster format Place reads, with resolution metadata where it has some.
@@ -2,7 +2,7 @@
//! under Window → Swatch Libraries → User Defined) or a file picked in a save dialog (a download
//! on the web). OK runs `swatch.library.save`.
//!
//! Fields: `name`, `format` (`vcswatches`, `gpl` or `css`), `user` (save to the user library
//! Fields: `name`, `format` (`vcswatches`, `gpl`, `ase` or `css`), `user` (save to the user library
//! folder), `selectedOnly` and `names` (the swatches selected in the Swatches panel), `__user`
//! (there is a user library folder).
//!
@@ -34,12 +34,17 @@ pub fn open(app: &mut VectorcraftApp, names: Vec<String>) -> Result<Value, Strin
/// The fields a Save … Library dialog opens with: the document's name, Save To the user library
/// folder when there is one (`user`), and the panel's selected `names`.
pub(super) fn fields(app: &VectorcraftApp, user: bool, names: Vec<String>) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let title = st.title();
let name = title.rsplit_once('.').map_or(title.as_str(), |(s, _)| s).to_string();
let name = document_name(app).ok_or("no document open")?;
Ok(json!({"name": name, "user": user, "__user": user, "selectedOnly": false, "names": names}))
}
/// The active document's name without its extension: the library name the save commands use when
/// none is given.
fn document_name(app: &VectorcraftApp) -> Option<String> {
let title = app.session.active()?.title();
Some(title.rsplit_once('.').map_or(title.as_str(), |(s, _)| s).to_string())
}
fn format_of(d: &Dialog) -> PaletteFormat {
PaletteFormat::parse(&d.str("format")).unwrap_or(PaletteFormat::Native)
}
@@ -62,6 +67,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if format_of(d) == PaletteFormat::Gpl {
widgets::dim_label(ui, tl!("GPL palettes keep solid colours only, as RGB."));
}
if format_of(d) == PaletteFormat::Ase {
widgets::dim_label(ui, tl!("Swatch exchange files keep solid colors only."));
}
false
}
@@ -104,8 +112,10 @@ pub(super) fn params(d: &Dialog) -> Value {
p
}
/// Run save command `cmd` with `p`: into the user library folder, else to a file the user picks
/// (extension `ext`).
/// Run save command `cmd` with `p`: into the user library folder, else to a file the user picks.
/// The save panel suggests the file name that a save into the user library folder uses: the name
/// (the document's when it is empty) with extension `ext`. The web downloads the file under that
/// name.
pub(super) fn save(app: &mut VectorcraftApp, d: &Dialog, cmd: &str, ext: &str, p: Value) -> Result<Value, String> {
if p["user"] == json!(true) {
let r = run_and_close(app, cmd, p)?;
@@ -113,7 +123,9 @@ pub(super) fn save(app: &mut VectorcraftApp, d: &Dialog, cmd: &str, ext: &str, p
return Ok(r);
}
app.ui.dialog = None;
crate::io::save_command_output(app, cmd, ext, p).map(|path| json!({ "path": path }))
let name = Some(d.str("name").trim().to_string()).filter(|n| !n.is_empty()).or_else(|| document_name(app)).unwrap_or_default();
let file = vectorcraft_engine::cmd::swatchlib::library_file_name(&name, ext);
crate::io::save_command_output_named(app, cmd, &file, p).map(|path| json!({ "path": path }))
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
+1
View File
@@ -2639,6 +2639,7 @@
Swash Decorativa
Swatch Conflict Conflicto de muestras
Swatch Conflicts Conflictos de muestras
Swatch exchange files keep solid colors only. Los archivos de intercambio de muestras solo conservan colores sólidos.
Swatch Libraries Bibliotecas de muestras
Swatch Libraries Menu Menú Bibliotecas de muestras
Swatch Library Biblioteca de muestras
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2636,6 +2636,7 @@
Swash Lettres ornées
Swatch Conflict Conflit de nuances
Swatch Conflicts Conflits de nuances
Swatch exchange files keep solid colors only. Les fichiers d’échange de nuances ne conservent que les couleurs unies.
Swatch Libraries Bibliothèques de nuances
Swatch Libraries Menu Menu Bibliothèques de nuances
Swatch Library Bibliothèque de nuances
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2639,6 +2639,7 @@
Swash Ornamentale
Swatch Conflict Conflitto campioni
Swatch Conflicts Conflitti campioni
Swatch exchange files keep solid colors only. I file di scambio campioni conservano solo i colori pieni.
Swatch Libraries Librerie campioni
Swatch Libraries Menu Menu Librerie campioni
Swatch Library Libreria campioni
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2416,6 +2416,7 @@
Swash スワッシュ
Swatch Conflict スウォッチの競合
Swatch Conflicts スウォッチの競合
Swatch exchange files keep solid colors only. スウォッチ交換ファイルにはベタ塗りのカラーのみが保存されます。
Swatch Library スウォッチライブラリ
Swatch Name: スウォッチ名:
Swatch Options スウォッチオプション
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2297,6 +2297,7 @@
Swatch Options Opções de Amostra
Swatch Options… Opções de Amostra…
Swatch Tiles Blocos de Amostra
Swatch exchange files keep solid colors only. Arquivos de intercâmbio de amostras mantêm apenas cores sólidas.
Swatches Amostras
Switch workspace Trocar espaço de trabalho
Symbol Instance Instância de Símbolo
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2635,6 +2635,7 @@
Swash Росчерк
Swatch Conflict Конфликт образцов
Swatch Conflicts Конфликты образцов
Swatch exchange files keep solid colors only. Файлы обмена образцами сохраняют только сплошные цвета.
Swatch Libraries Библиотеки образцов
Swatch Libraries Menu Меню «Библиотеки образцов»
Swatch Library Библиотека образцов
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2631,6 +2631,7 @@
Swash Розчерк
Swatch Conflict Конфлікт зразків
Swatch Conflicts Конфлікти зразків
Swatch exchange files keep solid colors only. Файли обміну зразками зберігають лише суцільні кольори.
Swatch Libraries Бібліотеки зразків
Swatch Libraries Menu Меню "Бібліотеки зразків"
Swatch Library Бібліотека зразків
Can't render this file because it contains an unexpected character in line 621 and column 40.
+1
View File
@@ -2556,6 +2556,7 @@
Swash 花饰字
Swatch Conflict 色板冲突
Swatch Conflicts 色板冲突
Swatch exchange files keep solid colors only. 色板交换文件仅保留纯色。
Swatch Libraries 色板库
Swatch Libraries Menu 色板库菜单
Swatch Library 色板库
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2642,6 +2642,7 @@
Swash 花體字
Swatch Conflict 色票衝突
Swatch Conflicts 色票衝突
Swatch exchange files keep solid colors only. 色票交換檔案僅保留純色。
Swatch Libraries 色票資料庫
Swatch Libraries Menu 色票資料庫選單
Swatch Library 色票資料庫
Can't render this file because it contains an unexpected character in line 6 and column 47.
+24 -8
View File
@@ -516,6 +516,29 @@ pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params
Ok(path)
}
/// Run an engine command that returns `{data}` or `{dataBase64}` and write the bytes to a file
/// picked with file name `name` suggested next to the document (the web downloads them as `name`)
/// → where they went.
pub(crate) fn save_command_output_named(app: &mut VectorcraftApp, id: &str, name: &str, params: Value) -> Result<String, String> {
let mut v = app.session.execute(id, &params).map_err(|e| e.to_string())?;
let bytes = take_output(&mut v)?;
let path = write_named(app, None, name, &bytes)?;
app.status(format!("Saved {path}"));
Ok(path)
}
/// The bytes a command returned, taken out of its result `v`, which keeps the rest: binary output
/// comes as base64, text (swatch libraries) as is.
fn take_output(v: &mut Value) -> Result<Vec<u8>, String> {
let o = v.as_object_mut().ok_or("no data")?;
let bytes = match (o.remove("dataBase64"), o.remove("data")) {
(Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64),
(_, Some(Value::String(text))) => Some(text.into_bytes()),
_ => None,
};
bytes.ok_or_else(|| "no data".into())
}
/// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to
/// that path (else a picked or suggested name) → (path, the command's result without the data).
/// The command runs before a path is asked for, so bad params never open a save dialog, except
@@ -532,14 +555,7 @@ pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut par
path = Some(picked);
}
let mut v = app.session.execute(id, &params).map_err(|e| e.to_string())?;
// Binary output comes as base64, text (swatch libraries) as is; the result keeps the rest.
let o = v.as_object_mut().ok_or("no data")?;
let bytes = match (o.remove("dataBase64"), o.remove("data")) {
(Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64),
(_, Some(Value::String(text))) => Some(text.into_bytes()),
_ => None,
};
let bytes = bytes.ok_or("no data")?;
let bytes = take_output(&mut v)?;
let path = match path {
Some(p) => p,
None => target_path(app, None, ext)?,
+1 -1
View File
@@ -365,7 +365,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.saveSwatchLibrary",
"Save Swatch Library…",
"",
"{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save",
"{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save",
),
("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"),
("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"),
@@ -617,6 +617,47 @@ mod tests {
assert!(!builtin_library(&open.id) && builtin_library(vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES));
}
#[test]
fn the_save_dialog_writes_swatch_exchange_files() {
let written = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
let w = written.clone();
let services = crate::Services {
pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))),
write: Some(Box::new(move |p: &str, b: &[u8]| {
w.borrow_mut().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("ui.saveSwatchLibrary", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("format".into(), json!("ase"));
d.fields.insert("name".into(), json!("Brand Kit"));
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
assert!(text.contains("Swatch Exchange (.ase)") && text.contains("Swatch exchange files keep solid colors only."), "{text}");
crate::dialogs::confirm(&mut app).unwrap();
let (path, bytes) = written.borrow()[0].clone();
assert_eq!(path, "/tmp/Brand Kit.ase", "the save panel suggests the name");
let lib = vectorcraft_color::palette_io::read_bytes(&bytes, "x").unwrap();
let solid = app.session.doc().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count();
assert_eq!(lib.len(), solid, "every solid color of the document");
// The web downloads the file under that name.
let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
let dl = downloads.clone();
let services =
crate::Services { download: Some(Box::new(move |name: &str, _: &[u8]| dl.borrow_mut().push(name.to_string()))), ..Default::default() };
let mut web = VectorcraftApp::new(Session::new(), services);
web.run("file.new", json!({"width": 100, "height": 100})).unwrap();
web.run("ui.saveSwatchLibrary", json!({})).unwrap();
let d = web.ui.dialog.as_mut().unwrap();
d.fields.insert("format".into(), json!("ase"));
d.fields.insert("name".into(), json!(" Brand: Kit. "));
crate::dialogs::confirm(&mut web).unwrap();
assert_eq!(*downloads.borrow(), ["Brand- Kit.ase"], "the name made safe as a file name, as for User Defined");
}
#[test]
fn swatch_exchange_files_without_a_path_open_in_the_library_panel() {
// A file opened on the web has no path; open_bytes passes its bytes as dataBase64.
+4 -3
View File
@@ -155,8 +155,9 @@ one with Alt), so one undo step adds and applies it; Shift/Cmd-clicks select swa
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
`saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`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).
`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).
Graphic style libraries open in the same panel: `window.graphicStyleLibrary {library}` (`library_panel: {kind:
"graphicStyles", id}`; `library: null` closes it). Clicking a style there runs `graphicStyle.addFromLibrary {library,
@@ -166,7 +167,7 @@ document's graphic styles) and opens it there; opening a `.vcstyles` file with `
Defined libraries are `window.userGraphicStyleLibrary1`…`10` in Window → Graphic Style Libraries.
`ui.saveGraphicStyleLibrary {names?}` opens the `saveGraphicStyleLibrary` dialog (fields `name`, `user`: save to the
user library folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `graphicStyle.saveLibrary` (to a file it
asks for a path).
asks for a path and suggests `name` with `.vcstyles` as the file name; the web downloads the file under that name).
Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`) opens the `tileEdgeColor` dialog (field
`color`: `#rrggbb` or a preset name such as "Light Blue"); `ui.dialog.confirm` sets the preference
+6 -3
View File
@@ -778,9 +778,12 @@ swatch in the library panel does). `swatch.resetDefaults {replace?}` brings back
{"name":"run_command","arguments":{"command":"swatch.library.get","params":{"library":"earth-tones"}}}
{"name":"run_command","arguments":{"command":"swatch.library.add","params":{"library":"earth-tones","names":["Clay"]}}}
```
`swatch.library.save {path?, format?: "vcswatches"|"gpl"|"css", names?, name?, user?}` writes the document's swatches
as a library (`.vcswatches` keeps colour models, global, spot, gradients and colour groups; `.gpl` is 8-bit RGB;
CSS writes custom properties); without `path` it returns `{data}`, and `user: true` saves into the user library
`swatch.library.save {path?, format?: "vcswatches"|"gpl"|"ase"|"css", names?, name?, user?}` writes the document's
swatches as a library (`.vcswatches` keeps colour models, global, spot, gradients and colour
groups; `.gpl` is 8-bit RGB; a swatch exchange `.ase` file keeps solid colors in their own model
(RGB, CMYK, Lab or Gray) as global, spot or process colors, and color groups, writes a tint swatch
as the color it shows and leaves gradients out; CSS writes custom properties); without `path`
it returns `{data}`, or `{dataBase64}` for `.ase`, and `user: true` saves into the user library
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