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).