Merge pull request #3941 from fabian-co/feat/ai-palette-swatches

Open .ai files with their swatches (palette, colour groups, unused inks); Swatches panel keeps the art's swatch after deselecting
This commit is contained in:
BFlat
2026-10-24 18:09:08 +07:00
committed by GitHub
7 changed files with 402 additions and 8 deletions
@@ -82,6 +82,37 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
}
/// A `.ai` file opens with the swatches its palette holds, spot colours the art doesn't use and colour
/// groups included, in place of the default swatches; art painted with Registration (`XZ`) still
/// opens from the editing data.
#[test]
fn an_ai_file_opens_with_the_swatches_of_its_palette() {
let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\
0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n";
// The square the page draws, in the red ink, after a stroke colour of Registration.
let art = format!("0 0 0 0 0 0 0 ([Registration]) 0 1 XZ\n0.06 0.98 1 0 (Red ink) 0 x\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0));
let data = ai::editing_data(200.0, 100.0, &format!("{palette}{}", ai::layer("Art", &art)));
let mut s = Session::new();
let r = open(&mut s, "inks.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
assert_eq!(r["format"], json!("ai"), "{r}");
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art"], "opened from the editing data: {r}");
let swatches: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect();
assert_eq!(swatches, ["[None]", "White", "Pink ink", "Red ink"]);
let pink = doc.swatch("Pink ink").unwrap();
assert!(pink.spot && pink.global);
assert_eq!(pink.paint.color(), Some(vectorcraft_color::Color::lab(81.96, 21.0, -8.0)), "a Lab ink keeps its Lab values");
let groups: Vec<&str> = doc.swatch_groups.iter().map(|g| g.name.as_str()).collect();
assert_eq!(groups, ["Inks"]);
assert!(doc.swatch("Yellow ink").is_some_and(|s| s.spot), "an ink the art doesn't use");
let red = &doc.layers[0].children().unwrap()[0];
assert!(
matches!(red.appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Solid { swatch: Some(n), .. }) if n == "Red ink"),
"{:?}",
red.appearance.fill()
);
}
/// A 1-bit RGB image opens from the editing data in the colors its PDF part shows.
#[test]
fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
+23 -1
View File
@@ -2495,6 +2495,13 @@ fn ascii85(data: &[u8]) -> String {
/// A pattern's definition in editing data: its name and tile, its art on `%_` lines, its end.
const PATTERN: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n%_0 A\n%_0 Ae\n%_u\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_n\n%_0 0 0 1 K\n%_1 J 1 w\n%_3 3 m\n%_3 3 L\n%_S\n%_U\n%_9 () XW\nE\n%AI3_EndPattern\n";
/// The swatches in editing data: process, spot (one the art doesn't use), global, a gradient that
/// isn't defined and a pattern, a colour group, and versioned content read once.
const PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n%AI17_Begin_Content_if_version_gt:24 15\n1 1 1 1 ([Registration]) 0 Xs\n([Registration])\nPc\n\
0 0 0 1 k\n(Black)\nPc\n0.1 0.9 0.8 0 (Unused ink) 0 x\n(Unused ink)\nPc\n0 0 0 1 0 0 0 (Black global) 0 1 Xk\n(Black global)\nPc\n\
Bb\n2 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(G)\nPc\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Dots)\nPc\n\
%AI17_Alternate_Content\n0 0 0 1 k\n(Black)\nPc\n%AI17_End_Versioned_Content\n1 (Greys) 1 Pg\n0 0 0 0.5 k\n(K50)\nPc\nPB\n%AI5_EndPalette\n";
/// The editing data of a file with every sort of object the layers reader knows, and some it doesn't.
fn editing_text() -> String {
let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4);
@@ -2520,6 +2527,7 @@ fn editing_text() -> String {
"Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n1 0 0 1 0 0 Xm\n0 0 0 0 0 0 Bm\n1 0 0 1 5 5 Bm\n0 BB".into(),
"0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(),
"0 0 0 1 (Black global) 0.4 0 Xk".into(),
"0 0 0 0 0 0 0 ([Registration]) 0.1 1 Xz".into(),
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(),
square(60),
// A 2 × 1 CMYK image with an alpha channel, its samples in hex.
@@ -2532,7 +2540,7 @@ fn editing_text() -> String {
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
};
format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{}{}%%Trailer\n",
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{PALETTE}{}{}%%Trailer\n",
layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))),
layer("Two", 0, &square(80))
)
@@ -2552,6 +2560,20 @@ fn editing_eps(editing: &str) -> Vec<u8> {
.into_bytes()
}
/// The fuzzed editing data, as it is, opens with its palette's swatches in its order and groups,
/// the spot colour the art doesn't use included (the gradient isn't defined: left out).
#[test]
fn the_editing_fixture_reads_its_palette() {
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing_text())].concat();
let (doc, warnings, layers) = vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0);
assert!(layers, "{warnings:?}");
let swatches: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect();
assert_eq!(swatches, ["[None]", "Black", "Unused ink", "Black global", "Dots"]);
assert!(doc.swatch("Unused ink").is_some_and(|s| s.spot && s.global));
let groups: Vec<(&str, usize)> = doc.swatch_groups.iter().map(|g| (g.name.as_str(), g.swatches.len())).collect();
assert_eq!(groups, [("Greys", 1)]);
}
/// The fuzzed editing data, as it is, reads its CMYK image with its ink amounts and its alpha
/// channel as an opacity mask.
#[test]
+1
View File
@@ -22,6 +22,7 @@ mod lex;
mod mesh;
mod obj;
mod paint;
mod palette;
mod read;
mod style;
+28 -2
View File
@@ -4,8 +4,10 @@
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
//! values its equivalent), `Xx`/`XX` a named colour of any model
//! (`c m y k [r g b | L a b] (name) tint type`, type 0 CMYK, 1 RGB, 2 Lab), `Xk`/`XK` a global
//! process colour (its operands as `Xx`'s: a global swatch that isn't an ink of its own). Lower
//! case paints fills, upper case strokes.
//! process colour (its operands as `Xx`'s: a global swatch that isn't an ink of its own),
//! `Xs`/`XS` (`c m y k (name) tint`) and `Xz`/`XZ` (`c m y k r g b (name) tint type`) the
//! Registration colour at a tint, as written in files users sent. Lower case paints fills, upper
//! case strokes.
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
@@ -60,6 +62,7 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
"x" => named(vals, Some(false)),
"xx" => named(vals, None),
"xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))),
"xs" | "xz" => registration(vals),
_ => None,
}
}
@@ -92,6 +95,15 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
Some((paint, Some(Named { name, color, tint, spot: true })))
}
/// `… (name) tint Xs` / `… (name) tint type Xz`: the built-in Registration swatch at the tint (its
/// own colour, whatever values the operands give).
fn registration(vals: &[V]) -> Option<(Paint, Option<Named>)> {
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
let tint = 1.0 - vals.get(at + 1).and_then(V::num).unwrap_or(0.0).clamp(0.0, 1.0) as f32;
let full = Color::cmyk(1.0, 1.0, 1.0, 1.0);
Some((Paint::Solid { color: full.tinted(tint), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint }, None))
}
/// The colour of a gradient stop's colour values and style.
fn stop_color(style: f64, comps: &[V]) -> Option<(Color, Option<Named>)> {
let nums: Vec<f32> = comps.iter().filter_map(V::num).map(|v| v as f32).collect();
@@ -318,6 +330,20 @@ mod tests {
v.iter().map(|n| V::Num(*n)).collect()
}
#[test]
fn registration_paints_with_the_registration_swatch_at_its_tint() {
let mut v = nums(&[0.75, 0.68, 0.67, 0.9, 0.0, 0.0, 0.0]);
v.extend([V::Str(b"[Registration]".to_vec()), V::Num(0.1), V::Num(1.0)]);
let (p, named) = color_op("XZ", &v).unwrap();
assert!(named.is_none());
let Paint::Solid { swatch, tint, color } = p else { panic!() };
assert_eq!((swatch.as_deref(), color), (Some("[Registration]"), Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.9)));
assert!((tint - 0.9).abs() < 1e-6);
let mut v = nums(&[1.0, 1.0, 1.0, 1.0]);
v.extend([V::Str(b"[Registration]".to_vec()), V::Num(0.0)]);
assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if tint == 1.0));
}
#[test]
fn colours() {
let (p, _) = color_op("g", &nums(&[0.7])).unwrap();
+219
View File
@@ -0,0 +1,219 @@
//! The swatches of the editing data: the section between `%AI5_BeginPalette` and
//! `%AI5_EndPalette`, as written in files users sent.
//!
//! - `0 0 Pb` starts the swatches and `PB` ends them.
//! - Each swatch is its paint, then `(name) Pc`. The paint is a colour operator (`g`, `k`, `Xa`: a
//! process colour; `x`, `Xx`: a spot colour; `Xk`: a global process colour), a gradient
//! (`Bb` … `flag (gradient) … Bg` … `BB`) or a pattern (`(pattern) … p`). `Xs` and `Xz` paint
//! with Registration, which every document has.
//! - `flag (name) count Pg` starts a colour group: the swatches up to the next `Pg` or `PB` are
//! its own.
//! - Newer files write the swatches between `%AI17_Begin_Content_if_version_gt` and
//! `%AI17_Alternate_Content`, then again for older versions up to `%AI17_End_Versioned_Content`:
//! the first are read, the others skipped.
//!
//! Anything else in the section (an operator it doesn't know) is left alone: the swatches around
//! it still come in.
use vectorcraft_color::Color;
use super::lex::{Lexer, Tok};
use super::obj::V;
use super::paint::{self, Named};
/// Largest number of swatches read from one palette.
const MAX_SWATCHES: usize = 20_000;
/// Largest number of values kept between two operators.
const MAX_VALUES: usize = 64;
/// A swatch's paint, as the palette gives it.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Cell {
/// A colour: its full colour, whether it's global and whether it's an ink of its own.
Color { color: Color, global: bool, spot: bool },
/// The gradient defined under this name (`%AI5_BeginGradient`).
Gradient(String),
/// The pattern defined under this name (`%AI3_BeginPattern`).
Pattern(String),
}
/// A swatch of the palette: its name and paint.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Entry {
pub name: String,
pub cell: Cell,
}
/// The palette: the swatches outside any group, then each colour group's, in order.
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct Palette {
pub swatches: Vec<Entry>,
pub groups: Vec<(String, Vec<Entry>)>,
}
impl Palette {
pub fn is_empty(&self) -> bool {
self.swatches.is_empty() && self.groups.iter().all(|(_, s)| s.is_empty())
}
fn len(&self) -> usize {
self.swatches.len() + self.groups.iter().map(|(_, s)| s.len()).sum::<usize>()
}
}
/// The palette of `data`: what follows `%AI5_BeginPalette`, up to its end.
pub(super) fn palette(data: &[u8]) -> Palette {
let mut lex = Lexer::new(data);
let mut out = Palette::default();
let mut vals: Vec<V> = Vec::new();
// The paint of the swatch being read.
let mut cell: Option<Cell> = None;
// Inside a gradient's `Bb` … `BB`.
let mut in_gradient = false;
// Inside versioned content's alternate, which repeats what came before it.
let mut alternate = false;
while let Some(t) = lex.next_token() {
if let Tok::Comment(c) = t.tok {
let line = String::from_utf8_lossy(c);
if line.contains("_Alternate_Content") {
alternate = true;
} else if line.contains("_End_Versioned_Content") || line.contains("_Begin_Content_if_version") {
alternate = false;
}
continue;
}
if alternate {
continue;
}
let w = match t.tok {
Tok::Num(v) => {
push(&mut vals, V::Num(v));
continue;
}
Tok::Str(s) | Tok::Hex(s) => {
push(&mut vals, V::Str(s));
continue;
}
Tok::Word(w) => std::str::from_utf8(w).unwrap_or("?"),
_ => continue,
};
match w {
// Array brackets: their numbers are a pattern's matrix, which the swatch doesn't need.
"[" | "]" => continue,
"Bb" => {
in_gradient = true;
cell = None;
}
"Bg" if in_gradient => {
if let Some(name) = last_text(&vals) {
cell = Some(Cell::Gradient(name));
}
}
"BB" => in_gradient = false,
"p" => cell = first_text(&vals).map(Cell::Pattern),
"Xs" | "Xz" | "XS" | "XZ" => cell = None,
"g" | "k" | "x" | "Xa" | "Xx" | "Xk" => {
cell = paint::color_op(w, &vals).and_then(|(p, named)| match named {
Some(Named { color, spot, .. }) => Some(Cell::Color { color, global: true, spot }),
None => p.color().map(|color| Cell::Color { color, global: false, spot: false }),
});
}
"Pg" => {
// Capped as swatches are: a file of many `Pg` would make as many empty groups.
if let Some(name) = first_text(&vals)
&& out.groups.len() < MAX_SWATCHES
{
out.groups.push((name, Vec::new()));
}
}
"Pc" => {
if let (Some(name), Some(c)) = (last_text(&vals), cell.take())
&& !name.starts_with('[')
&& out.len() < MAX_SWATCHES
{
let e = Entry { name, cell: c };
match out.groups.last_mut() {
Some((_, g)) => g.push(e),
None => out.swatches.push(e),
}
}
cell = None;
}
_ => {}
}
vals.clear();
}
out
}
fn push(vals: &mut Vec<V>, v: V) {
if vals.len() < MAX_VALUES {
vals.push(v);
}
}
fn first_text(vals: &[V]) -> Option<String> {
vals.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text)
}
fn last_text(vals: &[V]) -> Option<String> {
vals.iter().rev().find(|v| matches!(v, V::Str(_))).and_then(V::text)
}
#[cfg(test)]
mod tests {
use super::*;
fn color(name: &str, color: Color, global: bool, spot: bool) -> Entry {
Entry { name: name.into(), cell: Cell::Color { color, global, spot } }
}
#[test]
fn a_palette_gives_its_swatches_in_order() {
let p = palette(
b"0 0 Pb\n1 1 1 1 ([Registration]) 0 Xs\n([Registration])\nPc\n\
0 0 0 1 k\n(Black)\nPc\n\
0.1 0.9 0.8 0 (Ink 185) 0 x\n(Ink 185)\nPc\n\
0 0.5 1 0 1 0.5 0 (Brand) 0 1 Xk\n(Brand)\nPc\n\
0.5 0.5 0.5 0 50 40 -20 (Lab ink) 0 2 Xx\n(Lab ink)\nPc\n\
Bb\n2 (Sky) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(Sky)\nPc\n\
(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Dots)\nPc\nPB\n",
);
assert!(p.groups.is_empty());
assert_eq!(
p.swatches,
vec![
color("Black", Color::cmyk(0.0, 0.0, 0.0, 1.0), false, false),
color("Ink 185", Color::cmyk(0.1, 0.9, 0.8, 0.0), true, true),
color("Brand", Color::rgb(1.0, 0.5, 0.0), true, false),
color("Lab ink", Color::lab(50.0, 40.0, -20.0), true, true),
Entry { name: "Sky".into(), cell: Cell::Gradient("Sky".into()) },
Entry { name: "Dots".into(), cell: Cell::Pattern("Dots".into()) },
]
);
}
#[test]
fn colour_groups_hold_the_swatches_after_them() {
let p = palette(
b"0 0 Pb\n0 0 0 0 k\n(White)\nPc\n1 (Greys) 1 Pg\n0 0 0 0.5 k\n(K50)\nPc\n0 (Inks) 1 Pg\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\nPB\n",
);
assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["White"]);
let groups: Vec<(&str, Vec<&str>)> = p.groups.iter().map(|(n, s)| (n.as_str(), s.iter().map(|e| e.name.as_str()).collect())).collect();
assert_eq!(groups, [("Greys", vec!["K50"]), ("Inks", vec!["Red ink"])]);
}
#[test]
fn versioned_swatches_are_read_once() {
let p = palette(
b"0 0 Pb\n%AI17_Begin_Content_if_version_gt:24 15\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\
%AI17_Alternate_Content\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n%AI17_End_Versioned_Content\nPB\n",
);
assert_eq!(p.swatches.len(), 1);
}
#[test]
fn what_it_doesnt_know_leaves_the_other_swatches() {
let p = palette(b"0 0 Pb\n1 2 3 Zz\n(Odd)\nPc\n(no paint) Pc\n0 0 0 1 k\n(Black)\nPc\nPB\n");
assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["Black"]);
}
}
+44 -4
View File
@@ -38,7 +38,10 @@
//! - Type in the legacy format (version 7 specification §15): `type To` … `TO`, read by
//! [`legacy_text`].
//!
//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped;
//! - Swatches: the palette (`%AI5_BeginPalette`), read by [`super::palette`], replaces the
//! default swatches.
//!
//! Sections the reader doesn't need (brushes, symbols, styles, filters) are skipped;
//! type, symbols, fills with a pattern it can't read, placed files without a preview and operators
//! it doesn't know are errors.
@@ -518,6 +521,8 @@ struct Reader<'a> {
mesh: Option<(super::mesh::MeshReader, bool)>,
/// The art styles (`%AI9_BeginArtStyles`), by name.
styles: HashMap<String, super::style::ArtStyle>,
/// The swatches (`%AI5_BeginPalette`).
palette: super::palette::Palette,
/// See [`Structure::restyled`].
restyled: Vec<Rect>,
/// CMYK samples converted, by value.
@@ -599,7 +604,6 @@ const SKIPPED: &[(&str, &str)] = &[
("BeginPluginObject", "EndPluginObject"),
("BeginArtStyles", "EndArtStyles"),
("BeginArtStyleList", "EndArtStyleList"),
("BeginPalette", "EndPalette"),
("BeginSymbolList", "EndSymbolList"),
("BeginTextDocument", "EndTextDocument"),
("BeginEncoding", "EndEncoding"),
@@ -668,6 +672,7 @@ impl<'a> Reader<'a> {
group_ended: false,
layer_state: None,
styles: HashMap::new(),
palette: super::palette::Palette::default(),
restyled: vec![],
mesh: None,
cmyk_cache: HashMap::new(),
@@ -760,6 +765,11 @@ impl<'a> Reader<'a> {
self.styles = super::style::art_styles(section);
return Ok(());
}
if m == "BeginPalette" {
let section = self.section("EndPalette")?;
self.palette = super::palette::palette(section);
return Ok(());
}
if m == "BeginEncoding" {
self.type_state.encoding_comment(line);
}
@@ -1241,7 +1251,7 @@ impl<'a> Reader<'a> {
self.paint(&op, hidden, true)?;
}
// Colours.
"g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" => self.color(w, &vals),
"g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" | "Xs" | "XS" | "Xz" | "XZ" => self.color(w, &vals),
"p" | "P" => self.pattern_paint(w, &vals),
// A pattern's definition ends.
"E" if self.pattern.is_some() => self.done = true,
@@ -1726,7 +1736,9 @@ impl<'a> Reader<'a> {
fn color(&mut self, w: &str, vals: &[V]) {
let Some((p, named)) = paint::color_op(w, vals) else { return };
match p.color() {
// Registration (`Xs`, `Xz`) says nothing of the document's colour model.
let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ");
match p.color().filter(|_| !registration) {
Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1,
Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1,
_ => {}
@@ -2378,6 +2390,34 @@ impl<'a> Reader<'a> {
if let Some(u) = self.ruler_units.and_then(ruler_unit) {
doc.units = u;
}
// The file's own swatches in place of the defaults, in its order and colour groups.
if !self.palette.is_empty() {
let palette = std::mem::take(&mut self.palette);
let swatch = |e: super::palette::Entry| -> Option<Swatch> {
use super::palette::Cell;
let (paint, global, spot) = match e.cell {
Cell::Color { color, global, spot } => (Paint::solid(color), global, spot),
Cell::Gradient(g) => {
let mut gp = vectorcraft_color::gradient::GradientPaint::new(self.gradients.get(&g)?.clone());
gp.swatch = Some(e.name.clone());
(Paint::Gradient(Box::new(gp)), false, false)
}
Cell::Pattern(p) => {
doc.patterns.iter().find(|d| d.name == p)?;
(Paint::Pattern { pattern: p, xf: Affine::IDENTITY }, false, false)
}
};
Some(Swatch { name: e.name, paint, global, spot })
};
let mut swatches = vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }];
swatches.extend(palette.swatches.into_iter().filter_map(swatch));
let groups: Vec<_> = palette
.groups
.into_iter()
.map(|(name, s)| vectorcraft_color::swatch::SwatchGroup { name, swatches: s.into_iter().filter_map(swatch).collect() })
.collect();
(doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into());
}
// The colour operators' definitions first: a gradient, defined before the art, names a
// global process colour as a stop and would otherwise make it an ink (#1771).
for n in self.spots.iter().chain(&self.stop_spots) {
+56 -1
View File
@@ -687,7 +687,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
widgets::subheader(ui, tl!("Swatch Tiles"));
let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), tl!("Find")) } else { String::new() };
let items = entries(app, kind, &query);
let active_swatch = active_swatch(app);
let active_swatch = kept_swatch(app, ui, active_swatch(app));
let focus = focus_swatch(app, ui, active_swatch.as_deref());
let selected = selection(app, ui);
// Without a selection the swatch of the active paint is highlighted.
@@ -773,6 +773,29 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
/// The document and the objects selected in it.
type SelectedArt = (u64, Vec<u64>);
/// The swatch highlighted for the active paint (`active`): with art selected, its swatch; once the
/// art is deselected, still that swatch, until another document shows or the paints for new art
/// change (a swatch or colour picked with nothing selected). Deselecting doesn't jump to the
/// swatch of the paint new art gets (White).
fn kept_swatch(app: &VectorcraftApp, ui: &Ui, active: Option<String>) -> Option<String> {
let Some(st) = app.session.active() else { return active };
let p = &app.session.paint;
// The document, and the paints new art gets, which picking with nothing selected changes.
let defaults = format!("{}|{:?}|{:?}|{}", st.uid, p.fill, p.stroke, app.session.fill_active);
if !st.selection.objects.is_empty() {
set_pstate(ui.ctx(), "swatch-kept", Some((defaults, active.clone())));
return active;
}
match pstate::<Option<(String, Option<String>)>>(ui.ctx(), "swatch-kept") {
Some((d, kept)) if d == defaults => kept,
Some(_) => {
set_pstate(ui.ctx(), "swatch-kept", None::<(String, Option<String>)>);
active
}
None => active,
}
}
/// The swatch the tiles scroll to this frame, once: the active paint's swatch when it changed, or
/// when other art was selected. Selecting other art also clears the panel's own selection, so its
/// swatch is the one highlighted.
@@ -1905,6 +1928,38 @@ mod tests {
assert_eq!(focus_swatch(&app, &egui::Ui::new(ctx.clone(), egui::Id::new("t"), egui::UiBuilder::new()), Some("Last Ink")), None);
}
/// Deselecting art keeps its swatch highlighted (not the White new art gets) until the paints for
/// new art change; selecting other art highlights its swatch.
#[test]
fn deselecting_keeps_the_arts_swatch_highlighted() {
let mut app = app();
app.run("swatch.new", json!({"name": "Ink 185", "color": "#e4002b", "spot": true})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [id], "swatch": "Ink 185"})).unwrap();
app.run("select.none", json!({})).unwrap();
// New art's paints back to the defaults, as for art that came in with a file.
app.run("paint.default", json!({})).unwrap();
let ctx = context();
let ui = egui::Ui::new(ctx.clone(), egui::Id::new("t"), egui::UiBuilder::new());
let shown = |app: &VectorcraftApp| kept_swatch(app, &ui, active_swatch(app));
assert_ne!(shown(&app).as_deref(), Some("Ink 185"), "nothing selected yet: new art's paint");
app.run("select.set", json!({"ids": [id]})).unwrap();
assert_eq!(shown(&app).as_deref(), Some("Ink 185"));
app.run("select.none", json!({})).unwrap();
assert_eq!(shown(&app).as_deref(), Some("Ink 185"), "kept after deselecting");
assert_eq!(shown(&app).as_deref(), Some("Ink 185"), "and on later frames");
// A colour picked with nothing selected is new art's: its swatch shows.
app.run("paint.setFill", json!({"swatch": "Black"})).unwrap();
assert_eq!(shown(&app).as_deref(), Some("Black"));
// Other art selected (a white one) shows its swatch, and keeps it once deselected.
let white = app.run("shape.rectangle", json!({"x": 60, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"ids": [white.clone()], "swatch": "White"})).unwrap();
app.run("select.set", json!({"ids": [white]})).unwrap();
assert_eq!(shown(&app).as_deref(), Some("White"));
app.run("select.none", json!({})).unwrap();
assert_eq!(shown(&app).as_deref(), Some("White"));
}
/// A selected image, or text in a colour no swatch has, highlights no swatch (not None); text in
/// a swatch's colour highlights that swatch.
#[test]