From f1fd255120faf9dfc3b9ce497d75ff80acc20a9a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:41:28 -0800 Subject: [PATCH] .ai import: version 7 palettes keep their swatches without names Version 7 files write each palette swatch as its paint and `Pc`, with no name. The reader only kept a swatch with a name before `Pc`, so such a palette gave no swatches and the document got the default set. A swatch without a name is now named by its spot color, gradient or pattern, or by its values in the format the swatch import already uses for unnamed colors ("Gray K=25", "C=25 M=0 Y=0 K=0"). `Pn` and a `Pc` with no paint before it add nothing. Named swatches keep their names. An operator the reader does not know, or a `Pg`, now ends the paint before it, so a later `(name) Pc` no longer takes that paint. The swatch limit is kept with a running count instead of summing every group at each `Pc`. --- crates/engine/tests/import_fuzz.rs | 12 +- crates/eps/src/import/ai/palette.rs | 123 +++++++++++++++++---- crates/eps/src/import/tests_illustrator.rs | 37 +++++++ docs/file-format-parity.md | 2 + 4 files changed, 149 insertions(+), 25 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 19c6ef93a..931963de3 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2623,11 +2623,12 @@ fn ascii85(data: &[u8]) -> String { 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\ +/// isn't defined and a pattern, a colour group, versioned content read once, and the None swatch +/// and a swatch without a name, as version 7 files write them. +const PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\nPn\nPc\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"; + %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\n0.75 g\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 { @@ -2697,8 +2698,9 @@ fn the_editing_fixture_reads_its_palette() { 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)]); + let groups: Vec<(&str, Vec<&str>)> = + doc.swatch_groups.iter().map(|g| (g.name.as_str(), g.swatches.iter().map(|s| s.name.as_str()).collect())).collect(); + assert_eq!(groups, [("Greys", vec!["K50", "Gray K=25"])], "the swatch without a name is named by its values"); } /// The fuzzed editing data, as it is, reads its CMYK image with its ink amounts and its alpha diff --git a/crates/eps/src/import/ai/palette.rs b/crates/eps/src/import/ai/palette.rs index 43955d5fc..9a4ba72eb 100644 --- a/crates/eps/src/import/ai/palette.rs +++ b/crates/eps/src/import/ai/palette.rs @@ -2,10 +2,13 @@ //! `%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. +//! - Each swatch is its paint, then an optional `(name)`, then `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. +//! - A swatch without a name (version 7 files write none) is named by its spot colour, gradient or +//! pattern, or by its values as [`color_name`] gives them. `Pn` (the None swatch) and a `Pc` with +//! no paint before it add nothing: the document has `[None]` already. //! - `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 @@ -13,9 +16,14 @@ //! 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. +//! it still come in, and a paint before it makes no swatch. +//! +//! "AI7 spec": Adobe Illustrator File Format Specification, version 7.0 (1998), listed by PRONOM at +//! https://www.nationalarchives.gov.uk/PRONOM/fmt/423; §11 documents the palette operators, and its +//! example writes the swatches without names. use vectorcraft_color::Color; +use vectorcraft_doc::swatches::color_name; use super::lex::{Lexer, Tok}; use super::obj::V; @@ -55,9 +63,6 @@ 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. @@ -65,8 +70,10 @@ 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; + // The paint of the swatch being read, with the name it gets when `Pc` gives none. + let mut cell: Option = None; + // How many swatches have been read. + let mut count = 0usize; // Inside a gradient's `Bb` … `BB`. let mut in_gradient = false; // How deep in versioned content, and the depth of the block whose later alternatives (which @@ -111,19 +118,20 @@ pub(super) fn palette(data: &[u8]) -> Palette { } "Bg" if in_gradient => { if let Some(name) = last_text(&vals) { - cell = Some(Cell::Gradient(name)); + cell = Some(Entry { cell: Cell::Gradient(name.clone()), name }); } } "BB" => in_gradient = false, - "p" => cell = first_text(&vals).map(Cell::Pattern), + "p" => cell = first_text(&vals).map(|name| Entry { cell: Cell::Pattern(name.clone()), name }), "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 }), + Some(Named { name, color, spot, .. }) => Some(Entry { name, cell: Cell::Color { color, global: true, spot } }), + None => p.color().map(|color| Entry { name: color_name(color), cell: Cell::Color { color, global: false, spot: false } }), }); } "Pg" => { + cell = None; // 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 @@ -132,19 +140,27 @@ pub(super) fn palette(data: &[u8]) -> Palette { } } "Pc" => { - if let (Some(name), Some(c)) = (last_text(&vals), cell.take()) - && !name.starts_with('[') - && out.len() < MAX_SWATCHES + // The name before `Pc`, or the one the paint gives; `[Registration]` and the other + // built-in swatches every document has are left out. + let name = last_text(&vals); + if let Some(mut e) = cell.take() + && !name.as_ref().is_some_and(|n| n.starts_with('[')) + && count < MAX_SWATCHES { - let e = Entry { name, cell: c }; + if let Some(name) = name { + e.name = name; + } + count += 1; match out.groups.last_mut() { Some((_, g)) => g.push(e), None => out.swatches.push(e), } } - cell = None; } - _ => {} + // A gradient's other operators (its focal point, its matrices) are part of its paint. + _ if in_gradient => {} + // Anything else ends the paint before it, which no `Pc` then makes a swatch of. + _ => cell = None, } vals.clear(); } @@ -235,4 +251,71 @@ mod tests { 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"]); } + + /// An operator it doesn't know after a paint ends that paint: the `Pc` after it makes no swatch. + #[test] + fn a_paint_before_an_unknown_operator_makes_no_swatch() { + let p = palette(b"0 0 Pb\n0 1 1 0 k\nZz\n(Odd)\nPc\n0.5 g\n1 (Group) 1 Pg\nPc\n0 0 0 1 k\n(Black)\nPc\nPB\n"); + assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::>(), Vec::<&str>::new()); + 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, [("Group", vec!["Black"])]); + } + + /// The palette of the AI7 spec's example (§11): `Pn`, then each swatch's paint and `Pc` with no + /// name, then a last `Pc`. Each swatch is named by its spot colour or gradient, or by its values; + /// `1 g` is white (§5.4.1). + #[test] + fn swatches_without_names_are_named_by_their_paint() { + let p = palette( + b"0 0 Pb\nPn\nPc\n1 g\nPc\n0 g\nPc\n0 0 0 0 k\nPc\n0.75 g\nPc\n\ + Bb\n2 (B&W) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\nPc\n0.25 0 0 0 k\nPc\n\ + 0 1 1 0 (Spot A) 0 x\nPc\n0.75 g\nPc\n(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\nPc\nPc\nPB\n", + ); + assert!(p.groups.is_empty()); + assert_eq!( + p.swatches, + vec![ + color("Gray K=0", Color::gray(0.0), false, false), + color("Gray K=100", Color::gray(1.0), false, false), + color("C=0 M=0 Y=0 K=0", Color::cmyk(0.0, 0.0, 0.0, 0.0), false, false), + color("Gray K=25", Color::gray(0.25), false, false), + Entry { name: "B&W".into(), cell: Cell::Gradient("B&W".into()) }, + color("C=25 M=0 Y=0 K=0", Color::cmyk(0.25, 0.0, 0.0, 0.0), false, false), + color("Spot A", Color::cmyk(0.0, 1.0, 1.0, 0.0), true, true), + color("Gray K=25", Color::gray(0.25), false, false), + Entry { name: "Dots".into(), cell: Cell::Pattern("Dots".into()) }, + ] + ); + // RGB and global process colours too. + let p = palette(b"0 0 Pb\n0 0 0 0 1 0.5 0 Xa\nPc\n0 0.5 1 0 1 0.5 0 (Brand) 0 1 Xk\nPc\nPB\n"); + assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::>(), ["R=255 G=128 B=0", "Brand"]); + } + + /// Named and unnamed swatches in one palette: the named ones keep their names, and built-in + /// swatches are still left out. + #[test] + fn named_swatches_keep_their_names_beside_unnamed_ones() { + let p = palette( + b"0 0 Pb\n1 1 1 1 ([Registration]) 0 Xs\n([Registration])\nPc\n0 0 0 1 k\n(Black)\nPc\n0 0 0 0.5 k\nPc\n\ + 1 (Greys) 1 Pg\n0.4 g\n(Mid)\nPc\n0.4 g\nPc\nPB\n", + ); + assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::>(), ["Black", "C=0 M=0 Y=0 K=50"]); + 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!["Mid", "Gray K=60"])]); + } + + /// No more than `MAX_SWATCHES` swatches, counted across the colour groups. + #[test] + fn swatches_are_capped_across_groups() { + let mut data = b"0 0 Pb\n".to_vec(); + for i in 0..MAX_SWATCHES / 1000 + 1 { + data.extend(format!("1 (G{i}) 1 Pg\n").as_bytes()); + data.extend("0.5 g\nPc\n".repeat(1000).as_bytes()); + } + data.extend(b"PB\n"); + let p = palette(&data); + assert_eq!(p.groups.len(), MAX_SWATCHES / 1000 + 1); + assert_eq!(p.swatches.len() + p.groups.iter().map(|(_, s)| s.len()).sum::(), MAX_SWATCHES); + assert!(p.groups.last().is_some_and(|(_, s)| s.is_empty())); + } } diff --git a/crates/eps/src/import/tests_illustrator.rs b/crates/eps/src/import/tests_illustrator.rs index 19c79deeb..7543e5158 100644 --- a/crates/eps/src/import/tests_illustrator.rs +++ b/crates/eps/src/import/tests_illustrator.rs @@ -3,6 +3,7 @@ use std::sync::Arc; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::clipnest::MAX_NEST; use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind}; use vectorcraft_geom::Rect; @@ -26,6 +27,16 @@ fn ai(header: &str, ugroup: &str, body: &str) -> Vec { /// published specification documents; see the `import` module docs). const AI8: &str = "%%Creator: Adobe Illustrator(R) 8.0\n%AI5_FileFormat 4.0\n"; +/// The editing text of a version 7 file (`%AI5_FileFormat 3`), no prolog: `setup` in its setup, +/// then `body`. +fn stream7(setup: &str, body: &str) -> Vec { + format!( + "%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 100 100\n%AI5_FileFormat 3\n%AI3_Cropmarks: 0 0 100 100\n%%EndComments\n\ + %%BeginProlog\n%%EndProlog\n%%BeginSetup\n{setup}%%EndSetup\n{body}%%PageTrailer\n%%Trailer\n%%EOF\n" + ) + .into_bytes() +} + /// A triangle filled at `x`. fn tri(x: u32) -> String { format!("{x} {x} m {} {x} L {} {} L f", x + 5, x + 5, x + 5) @@ -209,6 +220,32 @@ fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() { assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1")); } +/// A version 7 palette writes no swatch names (the AI7 spec's §11 example): its swatches come in +/// named by their paint, in its order, in place of the defaults. +#[test] +fn a_version_7_palette_keeps_its_swatches_without_names() { + let setup = "%AI5_BeginGradient: (B&W)\n(B&W) 0 2 Bd\n[\n%_1 0 50 0 Bs\n%_0 0 50 100 Bs\nBD\n%AI5_EndGradient\n\ + %AI5_BeginPalette\n0 0 Pb\nPn\nPc\n1 g\nPc\n0 g\nPc\n0 0 0 0 k\nPc\n0.75 g\nPc\n\ + Bb\n2 (B&W) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\nPc\n0.25 0 0 0 k\nPc\n0 1 1 0 (Spot A) 0 x\nPc\n0.75 g\nPc\nPc\nPB\n%AI5_EndPalette\n"; + let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n(Art) Ln\n0 A\n0 Xw\n0 0 0 1 k\n{}\nLB\n%AI5_EndLayer--\n", tri(10)); + let r = import(&stream7(setup, &body)).unwrap(); + let d = &r.document; + assert_eq!(d.layers.len(), 1, "{:?}", r.warnings); + let names: Vec<&str> = d.swatches.iter().map(|s| s.name.as_str()).collect(); + assert_eq!( + names, + ["[None]", "Gray K=0", "Gray K=100", "C=0 M=0 Y=0 K=0", "Gray K=25", "B&W", "C=25 M=0 Y=0 K=0", "Spot A", "Gray K=25"], + "{:?}", + r.warnings + ); + assert!(d.swatch_groups.is_empty()); + let spot = d.swatch("Spot A").unwrap(); + assert!(spot.spot && spot.global, "{spot:?}"); + assert!(d.swatches.iter().filter(|s| s.name != "Spot A").all(|s| !s.spot && !s.global)); + assert!(matches!(&d.swatch("B&W").unwrap().paint, Paint::Gradient(g) if g.swatch.as_deref() == Some("B&W"))); + assert_eq!(d.swatch("Gray K=0").unwrap().paint.color(), Some(Color::gray(0.0)), "1 g is white"); +} + #[test] fn unbalanced_and_runaway_groups_are_read_safely() { // Ends without a beginning end nothing; groups left open hold the rest. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index de7e1cfce..c868252dd 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,6 +49,8 @@ private editing data. VectorCraft: A PDF-compatible `.ai` opens on its PDF part's trim boxes (its artboards), the bleed boxes round them becoming the document's bleed, so art in the bleed shows outside the artboard, within the bleed guide (#1266). A PDF saved with its editing data (`AIPDFPrivateData`) opens the same way. + The swatches of a version 7 `.ai` palette, which carry no names, come in named by their spot color, gradient or + values. - **Writes** a PDF-compatible `.ai` (Save As › `ai`): Illustrator opens it as plain art. It never writes Illustrator's private editing data, which is undocumented. - **Writes** a legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), `aiVersion: "7"`): the