From 1adbf259f7d0a969e69adbdae865c01308249586 Mon Sep 17 00:00:00 2001 From: Fabian Grajales Date: Fri, 23 Oct 2026 16:06:56 -0500 Subject: [PATCH 1/3] Open .ai files with their swatches: the palette, its colour groups and unused spot colours The editing data's palette (%AI5_BeginPalette) was skipped, so a .ai file opened with the default swatches plus only the spot colours its art used: inks the art doesn't use (a Pantone set kept in the file) and colour groups were lost. The palette is now read in order (process, spot in CMYK, RGB or Lab, and global colours, gradients and patterns, colour groups; versioned content read once) and replaces the default swatches. Xs/Xz (Registration at a tint, as printer's marks use it) are read as the Registration swatch: before, art painted with them stopped the editing data from being read at all. Co-Authored-By: Claude Opus 5.5 --- .../engine/src/cmd/fileio/tests_aiimport.rs | 31 +++ crates/engine/tests/import_fuzz.rs | 24 +- crates/eps/src/import/ai/mod.rs | 1 + crates/eps/src/import/ai/paint.rs | 30 ++- crates/eps/src/import/ai/palette.rs | 216 ++++++++++++++++++ crates/eps/src/import/ai/read.rs | 48 +++- 6 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 crates/eps/src/import/ai/palette.rs diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 0593dbf28..9ee98d960 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -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() { diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 53f812f0a..77d4740db 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -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 { .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] diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai/mod.rs index eb0785553..449eb73c0 100644 --- a/crates/eps/src/import/ai/mod.rs +++ b/crates/eps/src/import/ai/mod.rs @@ -22,6 +22,7 @@ mod lex; mod mesh; mod obj; mod paint; +mod palette; mod read; mod style; diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 14c626026..1a42856b0 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -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)> { "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) -> Option<(Paint, Option)> { 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)> { + 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)> { let nums: Vec = 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(); diff --git a/crates/eps/src/import/ai/palette.rs b/crates/eps/src/import/ai/palette.rs new file mode 100644 index 000000000..4769c4cf4 --- /dev/null +++ b/crates/eps/src/import/ai/palette.rs @@ -0,0 +1,216 @@ +//! 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, + pub groups: Vec<(String, Vec)>, +} + +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::() + } +} + +/// 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 = Vec::new(); + // The paint of the swatch being read. + let mut cell: Option = 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" => { + if let Some(name) = first_text(&vals) { + 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) { + if vals.len() < MAX_VALUES { + vals.push(v); + } +} + +fn first_text(vals: &[V]) -> Option { + vals.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text) +} + +fn last_text(vals: &[V]) -> Option { + 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::>(), ["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::>(), ["Black"]); + } +} diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 99b0eac77..746514e4d 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -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, + /// The swatches (`%AI5_BeginPalette`). + palette: super::palette::Palette, /// See [`Structure::restyled`]. restyled: Vec, /// 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 { + 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) { From bd4804b4daddcf8fef115fe449405a9fc938401d Mon Sep 17 00:00:00 2001 From: Fabian Grajales Date: Fri, 23 Oct 2026 21:00:11 -0500 Subject: [PATCH 2/3] Swatches panel keeps the art's swatch highlighted after deselecting Deselecting made the panel jump to the swatch of the paint new art gets (White). It now keeps the deselected art's swatch until the paints for new art change (a swatch or colour picked with nothing selected) or another document shows; selecting other art highlights its swatch. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/swatches.rs | 57 ++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 511447e65..1083454ca 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -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); +/// 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) -> Option { + 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::)>>(ui.ctx(), "swatch-kept") { + Some((d, kept)) if d == defaults => kept, + Some(_) => { + set_pstate(ui.ctx(), "swatch-kept", None::<(String, Option)>); + 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] From 9090afea1c99662f6ed7cabaa13303578792fea9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 17:46:36 +0700 Subject: [PATCH 3/3] .ai palette: color groups are capped as swatches are --- crates/eps/src/import/ai/palette.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/ai/palette.rs b/crates/eps/src/import/ai/palette.rs index 4769c4cf4..bf659cfe9 100644 --- a/crates/eps/src/import/ai/palette.rs +++ b/crates/eps/src/import/ai/palette.rs @@ -118,7 +118,10 @@ pub(super) fn palette(data: &[u8]) -> Palette { }); } "Pg" => { - if let Some(name) = first_text(&vals) { + // 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())); } }