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..bf659cfe9 --- /dev/null +++ b/crates/eps/src/import/ai/palette.rs @@ -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, + 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" => { + // 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) { + 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) { 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]