From 754051c26a50ba90d3e3a450fc75751dc025f218 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 20:39:59 -0700 Subject: [PATCH] Fix #4036: a .ai swatch library without layers loads its palette, not the default swatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .ai file whose editing data has no layers, as swatch libraries are saved, opens from its PDF part, and the PDF part has the default swatches. layered_ai applied the editing data's palette only when it built the document from the layers, so Other Library… listed the default swatches for such a library, and File › Open gave the file the default swatches as well. A .ai whose layers can't be used (an image too large for the editing data's reader, or a read error) lost its palette the same way, and so did an EPS that opens as its page. The reader now reads the palette on its own (ai::palette, which shares palette_swatches with the layer reader), and its swatches and color groups replace the default swatches in those three cases. Spot and pattern swatches of the PDF part's art that the palette does not have follow the palette's swatches. Fixes #4100. Part of #3349. --- .../engine/src/cmd/fileio/tests_aiimport.rs | 47 +++++++++++- crates/eps/src/import/ai.rs | 2 +- crates/eps/src/import/ai/read.rs | 2 +- crates/eps/src/import/ai/read/document.rs | 71 ++++++++++++------- crates/eps/src/import/native/layers.rs | 28 +++++++- crates/eps/src/import/tests_native.rs | 36 ++++++++++ 6 files changed, 153 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 9ee98d960..3f0dce5c1 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -82,16 +82,18 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() { assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]); } +/// A palette of three swatches and a color group `Inks` holding a fourth. +const INKS_PALETTE: &str = "%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"; + /// 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 data = ai::editing_data(200.0, 100.0, &format!("{INKS_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}"); @@ -113,6 +115,45 @@ fn an_ai_file_opens_with_the_swatches_of_its_palette() { ); } +/// A `.ai` whose editing data has a palette and no layers, as a swatch library is saved, opens as its +/// PDF part with the swatches and color groups of its palette in place of the default swatches. +#[test] +fn an_ai_file_without_layers_opens_with_the_swatches_of_its_palette() { + let data = ai::editing_data(200.0, 100.0, INKS_PALETTE); + 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; + let swatches: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(swatches, ["[None]", "White", "Pink ink", "Red ink"], "{r}"); + 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)); +} + +/// Other Library… on a `.ai` swatch library (a palette and no layers) gives the library the swatches +/// of its palette, a gradient swatch among them, not the default swatches. +#[test] +fn a_swatch_library_in_a_ai_file_without_layers_loads_its_palette() { + let gradient = + "%AI5_BeginGradient: (Sky)\n(Sky) 0 2 Bd\n[\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n%_0 0 0 1 0 0 0 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let palette = INKS_PALETTE.replace("0 0 Pb\n", "0 0 Pb\nBb\n2 (Sky) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(Sky)\nPc\n"); + let data = ai::editing_data(200.0, 100.0, &format!("{gradient}{palette}")); + let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"); + let mut s = Session::new(); + let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "inks.ai"}); + let r = s.execute("swatch.library.load", &p).unwrap(); + assert_eq!(r["count"], json!(5), "{r}"); + let lib = s.execute("swatch.library.get", &json!({"library": r["library"]})).unwrap(); + let swatches: Vec<&str> = lib["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); + assert_eq!(swatches, ["Sky", "White", "Pink ink", "Red ink", "Yellow ink"], "{lib}"); + assert_eq!(lib["groups"][0]["name"], json!("Inks"), "{lib}"); + let doc = s.execute("document.open", &json!({"name": "inks.ai", "dataBase64": p["dataBase64"]})); + assert!(doc.is_ok(), "{doc:?}"); + let sky = s.doc().unwrap().doc.swatch("Sky").map(|w| w.paint.clone()); + assert!(matches!(sky, Some(vectorcraft_color::Paint::Gradient(_))), "{sky:?}"); +} + /// 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/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index e71fc7db4..eaa8d167a 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -33,7 +33,7 @@ use super::lex::find; use crate::ps; pub(crate) use frames::text_frames; -pub use read::{IMAGE_TOO_LARGE, Structure, artboards, read, variables}; +pub use read::{IMAGE_TOO_LARGE, Structure, artboards, palette, read, variables}; pub(crate) use read::{slot_frame, slot_of}; #[cfg(test)] diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 0b1000f67..96903f641 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -74,7 +74,7 @@ mod ops; mod types; use art::preview_png; -pub use document::{VariableLibrary, artboards, variables}; +pub use document::{VariableLibrary, artboards, palette, variables}; pub(crate) use document::{slot_frame, slot_of}; pub use types::IMAGE_TOO_LARGE; use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster}; diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index e78d91c82..9fa54cf8e 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -72,6 +72,17 @@ fn artboard_array(data: &[u8]) -> Option<&[u8]> { } } +/// The swatches and color groups of the palette of the editing data `data`, for a file whose +/// layers aren't read: the swatches its PDF part has are the defaults (#4036). They come as +/// [`read`] makes them, with pattern swatches only for the patterns in `patterns`; `None` when the +/// palette is empty or reading stops before it. +pub fn palette(data: &[u8], patterns: &[PatternDef]) -> Option<(Vec, Vec)> { + let mut r = Reader::new(data); + // The palette and the gradients it names come before the art, so an error in the art leaves them read. + let _ = r.run(); + r.palette_swatches(patterns) +} + /// [`artboards`] as far as reading `data` from its start gets: those read before an error, and /// those of a `/Document` still open when it stopped (its art can be what stopped it). fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { @@ -290,6 +301,40 @@ impl<'a> Reader<'a> { if self.pattern.is_some() { FLIP } else { self.ensure_space() } } + /// The swatches of the palette, `[None]` first, in its order, and its color groups; a gradient + /// swatch only when its gradient was read, and a pattern swatch only for a pattern in + /// `patterns`. `None` when the palette is empty. + fn palette_swatches(&mut self, patterns: &[PatternDef]) -> Option<(Vec, Vec)> { + if self.palette.is_empty() { + return None; + } + let palette = std::mem::take(&mut self.palette); + let swatch = |e: crate::import::ai::palette::Entry| -> Option { + use crate::import::ai::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) => { + 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(); + Some((swatches, groups)) + } + pub(super) fn finish(mut self) -> Result { if !self.unknown.is_empty() { let ops: Vec = self.unknown.iter().map(|o| format!("`{o}`")).collect(); @@ -349,31 +394,7 @@ impl<'a> Reader<'a> { 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: crate::import::ai::palette::Entry| -> Option { - use crate::import::ai::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(); + if let Some((swatches, groups)) = self.palette_swatches(&doc.patterns) { (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); } // The colour operators' definitions first: a gradient, defined before the art, names a diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 4566afe0d..0582bf074 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -6,10 +6,11 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// its program in the legacy format, see [`editing_copy`]): its layers (the /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it -/// has one that can't be used, `visible` with a warning that says why. +/// has one that can't be used, `visible` with a warning that says why, and with the swatches of +/// the editing copy's palette ([`use_palette`]) when it could be read. pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { let Some(data) = editing_copy(ps) else { return visible }; - let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, trust, 1.0)); + let result = data.as_deref().map_err(Clone::clone).and_then(|data| build(data, Some(&visible.document), Page::Art, false, trust, 1.0)); match result { Ok((document, mut warnings)) => { for w in &visible.warnings { @@ -21,6 +22,9 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im } Err(why) => { let mut v = visible; + if let Ok(data) = &data { + use_palette(data, &mut v.document); + } v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); v } @@ -35,7 +39,8 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im /// notes, or a note saying why the layers weren't read when the editing data can't be decompressed /// or its layers can't be read. The flag returned is `true` when the document is the one read from /// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing -/// data was decompressed but its layers can't be read. `outlined`: the PDF part's type was read as +/// data was decompressed but its layers can't be read, and its swatches those of the editing data's +/// palette ([`use_palette`]) when it was decompressed. `outlined`: the PDF part's type was read as /// outlines (a file whose type shows then comes in as its PDF part). /// `trust`: layers that draw differently from the PDF part are used all the same, with a warning. /// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`, @@ -52,12 +57,15 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl Err(why) => return (visible, unread(warnings, &why), false), }; if !has_layers(&data) { + let mut visible = visible; + use_palette(&data, &mut visible); return (visible, warnings, false); } // The PDF part, its pages placed and named as the editing data's artboards are, when the // layers can't be used (`why`). let from_pdf_part = |mut visible: Document, warnings: Vec, why: &str| { place_pages(&ai::artboards(&data), &mut visible, unit); + use_palette(&data, &mut visible); let mut all = unread(warnings, why); all.extend(ai::variables(&data).note()); (visible, all, false) @@ -78,6 +86,20 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl } } +/// `doc` (a `.ai` read as its PDF part, in place of its layers) with the swatches and color groups +/// of the palette of its editing data `data` in place of the default ones, as [`build`] gives a +/// document read from the layers. The spot and pattern swatches of the PDF part's art that the +/// palette doesn't have follow the palette's swatches. Left as it is when the palette is empty. +fn use_palette(data: &[u8], doc: &mut Document) { + let Some((swatches, groups)) = ai::palette(data, &doc.patterns) else { return }; + let named = |name: &str| swatches.iter().chain(groups.iter().flat_map(|g| &g.swatches)).any(|s| s.name == name); + let art: Vec<_> = + doc.swatches.iter().filter(|s| (s.spot || matches!(s.paint, vectorcraft_color::Paint::Pattern { .. })) && !named(&s.name)).cloned().collect(); + let mut swatches = swatches; + swatches.extend(art); + (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); +} + /// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as /// `boards` (the artboards of its editing data, in art space) name them, and moved, with the art on /// each, to where they are, so the artboards keep their names and layout when the layers can't be diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 4087b2fd9..fd3bf9a9b 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -266,6 +266,42 @@ fn an_ai_file_with_an_image_too_large_for_its_editing_data_comes_in_as_its_pdf_p assert!(read, "{warnings:?}"); } +/// A palette of `[None]` and White, and a color group `Inks` holding a spot color, Red ink. +const PALETTE: &str = + "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n1 (Inks) 1 Pg\n0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\nPB\n%AI5_EndPalette\n"; + +/// A `.ai` that comes in as its PDF part, whose layers can't be used, still gets the swatches and +/// color groups of its editing data's palette in place of the defaults, and keeps the inks its PDF +/// part's art paints with that the palette doesn't have. +#[test] +fn an_ai_file_that_comes_in_as_its_pdf_part_keeps_the_swatches_of_its_palette() { + let image = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] 6000 6000 0 Xh\n\ + [ 1 0 0 1 0 100 ] 0 0 6000 6000 6000 6000 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN"; + let art = native(&format!("{PALETTE}{}", layer("Texture", true, &format!("{}\n{image}", square(10, 10))))); + let mut part = pdf_part(10.0, 86.0); + let gold = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.2, 0.9, 0.1)); + part.swatches.push(vectorcraft_color::swatch::Swatch { name: "Gold ink".into(), paint: gold, global: true, spot: true }); + let (d, warnings, read) = crate::layered_ai(&private(&art), part, vec![], false, true, 1.0); + assert!(!read, "the PDF part is used: {warnings:?}"); + let swatches: Vec<&str> = d.swatches.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(swatches, ["[None]", "White", "Gold ink"], "{warnings:?}"); + let groups: Vec<&str> = d.swatch_groups.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(groups, ["Inks"]); + assert!(d.swatch("Red ink").is_some_and(|s| s.spot)); +} + +/// An EPS whose editing data can't be used, a palette with no layers among others, comes in as its +/// page with the swatches and color groups of that palette in place of the defaults. +#[test] +fn an_eps_that_comes_in_as_its_page_keeps_the_swatches_of_its_palette() { + let r = import(&eps(&page_square(10, 10), &native(PALETTE))).unwrap(); + assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings); + let swatches: Vec<&str> = r.document.swatches.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(swatches, ["[None]", "White"], "{:?}", r.warnings); + let groups: Vec<&str> = r.document.swatch_groups.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(groups, ["Inks"]); +} + #[test] fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() { // A square on the artboard, and one above it (the artboard is y 0 to 100 here).