From d499fb25da48b13dd7c7ef05329ab43c7a0796a3 Mon Sep 17 00:00:00 2001 From: el-j Date: Sun, 8 Nov 2026 12:47:45 +0100 Subject: [PATCH] Fix #6205: support Registration and named swatches in AI art styles Resolve missing Registration (Xs, XS, Xz, XZ) and named color (Xx, XX) operators in AI ArtStyle filter definitions so objects styled with Registration swatches correctly resolve their paint instead of falling back to the previously set graphics state color. Also exclude Registration paints from color-model inference when read via Xx/XX, and correctly extract tint (penultimate operand) for name-free Xz. --- crates/eps/src/import/ai/paint.rs | 61 +++++++++++++++++++++++-- crates/eps/src/import/ai/read/ops.rs | 2 +- crates/eps/src/import/ai/style.rs | 6 ++- crates/eps/src/import/ai/style/tests.rs | 50 ++++++++++++++++++++ crates/eps/src/import/ai/tests.rs | 24 ++++++++++ 5 files changed, 135 insertions(+), 8 deletions(-) diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 178238dc1..8c863ba99 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -67,20 +67,29 @@ 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), + "xs" | "xz" => registration(op, vals), _ => None, } } +fn is_registration(name: &str) -> bool { + matches!(name, vectorcraft_color::swatch::REGISTRATION | "Registration" | "/All" | "All" | "/[Registration]") +} + /// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab); /// `rgb` forces RGB or CMYK. fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { - let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?; + let at = vals.iter().rposition(|v| matches!(v, V::Str(_) | V::Name(_)))?; let name = vals.get(at)?.text()?; let before: Vec = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect(); let after: Vec = vals.get(at + 1..)?.iter().filter_map(V::num).map(|v| v as f32).collect(); let unit = |v: f32| v.clamp(0.0, 1.0); let tint = 1.0 - unit(*after.first()?); + if is_registration(&name) { + let full = Color::cmyk(1.0, 1.0, 1.0, 1.0); + let paint = Paint::Solid { color: full.tinted(tint), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint }; + return Some((paint, None)); + } let n = before.len(); let kind = after.get(1).copied(); let color = if rgb.unwrap_or(kind == Some(1.0)) { @@ -102,9 +111,20 @@ fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { /// `… (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; +fn registration(op: &str, vals: &[V]) -> Option<(Paint, Option)> { + let at = vals.iter().rposition(|v| matches!(v, V::Str(_) | V::Name(_))); + let tint = if let Some(i) = at { + 1.0 - vals.get(i + 1).and_then(V::num).unwrap_or(0.0).clamp(0.0, 1.0) as f32 + } else { + let mut nums = vals.iter().rev().filter_map(V::num); + let t = if op.eq_ignore_ascii_case("xz") { + let _type = nums.next(); + nums.next().unwrap_or(0.0) + } else { + nums.next().unwrap_or(0.0) + }; + 1.0 - t.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)) } @@ -512,6 +532,29 @@ mod tests { 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)); + let mut v = nums(&[1.0, 1.0, 1.0, 1.0]); + v.extend([V::Str(b"[Registration]".to_vec()), V::Num(1.0)]); + assert!( + matches!(color_op("Xs", &v), Some((Paint::Solid { tint, color, .. }, None)) if tint == 0.0 && color == Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0)) + ); + let mut v = nums(&[1.0, 1.0, 1.0, 1.0]); + v.extend([V::Name("All".into()), V::Num(1.0)]); + assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if tint == 0.0)); + let mut v = nums(&[1.0, 1.0, 1.0, 1.0]); + v.extend([V::Str(b"[Registration]".to_vec()), V::Num(0.2), V::Num(0.0)]); + let (p, named) = color_op("Xx", &v).unwrap(); + assert!(named.is_none()); + assert!(matches!(p, Paint::Solid { swatch: Some(s), tint, .. } if s == "[Registration]" && (tint - 0.8).abs() < 1e-6)); + let mut v = nums(&[1.0, 1.0, 1.0, 1.0]); + v.extend([V::Name("All".into()), V::Num(0.5), V::Num(0.0)]); + let (p, named) = color_op("Xx", &v).unwrap(); + assert!(named.is_none()); + assert!(matches!(p, Paint::Solid { swatch: Some(s), tint, .. } if s == "[Registration]" && (tint - 0.5).abs() < 1e-6)); + // Name-free Xs and Xz operands + let v = nums(&[1.0, 1.0, 1.0, 1.0, 0.3]); + assert!(matches!(color_op("Xs", &v), Some((Paint::Solid { tint, .. }, None)) if (tint - 0.7).abs() < 1e-6)); + let v = nums(&[1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.2, 1.0]); + assert!(matches!(color_op("Xz", &v), Some((Paint::Solid { tint, .. }, None)) if (tint - 0.8).abs() < 1e-6)); } #[test] @@ -528,6 +571,14 @@ mod tests { assert_eq!(named.name, "MySpot"); assert!((named.tint - 0.6).abs() < 1e-6); assert!(matches!(p, Paint::Solid { swatch: Some(_), .. })); + let mut v_name = nums(&[0.0, 0.5, 1.0, 0.0]); + v_name.push(V::Name("MySpot".into())); + v_name.push(V::Num(0.4)); + let (p, named) = color_op("x", &v_name).unwrap(); + let named = named.unwrap(); + assert_eq!(named.name, "MySpot"); + assert!((named.tint - 0.6).abs() < 1e-6); + assert!(matches!(p, Paint::Solid { swatch: Some(_), .. })); assert!(color_op("k", &nums(&[1.0])).is_none()); // A global process colour: CMYK (type 0) or RGB (type 1), not an ink. let mut v = nums(&[0.0, 0.0, 0.0, 1.0]); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index d9ddedc92..d5d28aab6 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -523,7 +523,7 @@ impl<'a> Reader<'a> { fn color(&mut self, w: &str, vals: &[V]) { let Some((p, named)) = paint::color_op(w, vals) else { return }; // Registration (`Xs`, `Xz`) says nothing of the document's colour model. - let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ"); + let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ") || p.is_registration(); match p.color().filter(|_| !registration) { Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1, Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1, diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index a23101e26..857837afe 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -59,7 +59,7 @@ use vectorcraft_effects::is_pathfinder; use super::brush::PathBrush; use super::lex::{Lexer, Tok}; -use super::obj::V; +use super::obj::{self, V}; use super::paint; const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files @@ -432,6 +432,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { b"FillStyle" => style_entry = Some(Kind::Fill), b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other), b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text), + b"All" | b"[Registration]" | b"Registration" => operands.push(V::Name(obj::text(n))), b"CompoundFilter" => pending = Some(true), b"BasicFilter" => pending = Some(false), b"Part" => in_basic = false, @@ -605,7 +606,8 @@ fn block_data(lex: &mut Lexer, encoded: &[u8]) -> Option> { fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { let last = vals.iter().rev().find_map(V::num); match op { - "Xa" | "k" | "g" | "x" | "Xk" | "XA" | "K" | "G" | "X" | "XK" => { + "Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" | "xa" | "xx" | "xk" | "xs" + | "xz" => { let Some((p, _)) = paint::color_op(op, vals) else { return }; if op.ends_with(|c: char| c.is_ascii_lowercase()) { f.fill = Some(p); diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 0845ca0d9..ae79a345e 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1,4 +1,5 @@ use super::*; +use vectorcraft_color::Color; #[test] fn a_variable_width_stroke_keeps_its_profile() { @@ -1393,3 +1394,52 @@ fn a_style_is_read_as_a_writer_checks_it() { let _ = read(junk, Some(&own)); } } + +#[test] +fn art_styles_read_registration_and_named_colors() { + let reg_0 = fill("1 1 1 1 ([Registration]) 1 Xs"); + let reg_100 = fill("1 1 1 1 ([Registration]) 0 Xs"); + let reg_name = fill("1 1 1 1 /All 1 Xs"); + let named_spot = fill("0 0.5 1 0 (Shemen) 0 0 Xx"); + let stroke_reg = basic( + "Stroke Style Filter", + "/StrokeStyle : 0 R\n1 1 1 1 ([Registration]) 1 XS\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n", + ); + let data = format!( + "/KnownStyle :\n(Reg0) /Name ,\n/ActiveStyle :\n{}\n\ + /KnownStyle :\n(Reg100) /Name ,\n/ActiveStyle :\n{}\n\ + /KnownStyle :\n(RegName) /Name ,\n/ActiveStyle :\n{}\n\ + /KnownStyle :\n(Shemen) /Name ,\n/ActiveStyle :\n{}\n\ + /KnownStyle :\n(StrokeReg) /Name ,\n/ActiveStyle :\n{}\n", + compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_0])]), + compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_100])]), + compound("Chain Style Filter", &[compound("Stack Style Filter", &[reg_name])]), + compound("Chain Style Filter", &[compound("Stack Style Filter", &[named_spot])]), + compound("Chain Style Filter", &[compound("Stack Style Filter", &[stroke_reg])]), + ); + let styles = art_styles(data.as_bytes()); + let st0 = &styles["Reg0"]; + assert!(st0.plain); + let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st0.items.as_slice() else { panic!("{st0:?}") }; + assert_eq!((swatch.as_deref(), *tint, *color), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0, Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0))); + + let st100 = &styles["Reg100"]; + assert!(st100.plain); + let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st100.items.as_slice() else { panic!("{st100:?}") }; + assert_eq!((swatch.as_deref(), *tint, *color), (Some(vectorcraft_color::swatch::REGISTRATION), 1.0, Color::cmyk(1.0, 1.0, 1.0, 1.0))); + + let st_name = &styles["RegName"]; + assert!(st_name.plain); + let [StyleItem::Fill(Paint::Solid { swatch, tint, .. })] = st_name.items.as_slice() else { panic!("{st_name:?}") }; + assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0)); + + let st_spot = &styles["Shemen"]; + assert!(st_spot.plain); + let [StyleItem::Fill(Paint::Solid { swatch, tint, color })] = st_spot.items.as_slice() else { panic!("{st_spot:?}") }; + assert_eq!((swatch.as_deref(), *tint, *color), (Some("Shemen"), 1.0, Color::cmyk(0.0, 0.5, 1.0, 0.0))); + + let st_stroke = &styles["StrokeReg"]; + assert!(st_stroke.plain); + let [StyleItem::Stroke { paint: Paint::Solid { swatch, tint, .. }, .. }] = st_stroke.items.as_slice() else { panic!("{st_stroke:?}") }; + assert_eq!((swatch.as_deref(), *tint), (Some(vectorcraft_color::swatch::REGISTRATION), 0.0)); +} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 0ae436f72..e5c927670 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -6069,6 +6069,30 @@ fn an_object_whose_transparency_differs_from_its_listed_style_is_not_linked() { assert_eq!(a[0].extras().graphic_style, None, "the style has full opacity"); } +#[test] +fn ai_import_registration_style_does_not_carry_over_previous_color_6205() { + // Regression test for #6205: + // A background shape sets a spot colour ("Shemen"). + // A following object has an art style with [Registration] at 0% tint. + // The object must resolve the Registration style ([Registration] 0% / #ffffff) + // rather than falling back to the prior spot color. + let defs = [fill_style("RegWhite", "1 1 1 1 ([Registration]) 1 Xs\n")]; + let background = format!("0 0.67 0.73 0.26 (Shemen) 0 0 Xx\n{}f\n", rect_path(0.0, 0.0, 100.0, 100.0)); + let styled_obj = styled_square("RegWhite"); + let content = format!("{background}{styled_obj}"); + let r = read_ok(&format!("{}{}", styles_and_list(&defs, &["RegWhite"]), layer(&content))); + let a = art(&r.doc); + assert_eq!(a.len(), 2, "{a:#?}"); + // First object is background in Shemen spot + let Paint::Solid { swatch: s0, .. } = fill(&a[0]) else { panic!("{:#?}", a[0]) }; + assert_eq!(s0.as_deref(), Some("Shemen")); + // Second object is styled in RegWhite: [Registration] at 0% tint (#ffffff) + let Paint::Solid { swatch, tint, color } = fill(&a[1]) else { panic!("{:#?}", a[1]) }; + assert_eq!(swatch.as_deref(), Some(vectorcraft_color::swatch::REGISTRATION)); + assert_eq!(tint, 0.0); + assert_eq!(color, Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(0.0)); +} + #[test] fn a_listed_style_keeps_its_transparency_and_links_objects_that_have_it() { let blend = "/BasicFilter :\n(Blend Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n\