From 7f7c4cf9ad3e94a671e73ef67037b5af58303bb3 Mon Sep 17 00:00:00 2001 From: M Balazs PC Date: Sat, 7 Nov 2026 04:08:22 +0100 Subject: [PATCH] .ai import: test every paint mask art takes, and that it stays in the mask The mask test now checks the whole fill and stroke (colour, overprint, width, cap, join, miter, dash) and the fill rule against the owner's, and that an object after the mask's dictionary paints with the owner's paint, not the paint the mask's art set. open_mask_object gets a doc comment. --- crates/eps/src/import/ai/read/ops.rs | 2 ++ crates/eps/src/import/ai/tests.rs | 48 ++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 34f579bf7..d9ddedc92 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -43,6 +43,8 @@ impl<'a> Reader<'a> { } } + /// `X=` in a `/Mask` dictionary: the opacity mask's art starts, read until its `X+` + /// ([`Self::end_mask`]). fn open_mask_object(&mut self, hidden: bool) -> Result<(), String> { if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { // `X=` also introduces legacy text frame art, read by `text_frames`. diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 17423b46b..0ae436f72 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7627,20 +7627,48 @@ fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() { /// Mask art that sets no paint of its own paints with the paint before its dictionary: the writer /// leaves out paint that doesn't change. In a user's file, white letters were masked by a rectangle /// with no colour, which came in black and hid them. +/// The unread pattern flags it also takes aren't covered here: an owner painted with a pattern the +/// file doesn't have is unread itself, which hides whether its mask art is. #[test] fn opacity_mask_art_without_its_own_paint_takes_the_paint_before_its_dictionary() { - let owner = format!("0 0 0 0 k\n0 1 0 0 K\n2 w\n{}B\n", rect_path(10.0, 20.0, 50.0, 60.0)); - let mask = format!( - "%_/ArtDictionary :\n%_/Mask :\n%_X=\n{}%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n", - rect_path(10.0, 20.0, 30.0, 60.0).lines().map(|l| format!("%_{l}\n")).collect::() + "%_B\n" - ); - let r = read_ok(&layer(&format!("{owner}{mask}"))); + let paint = "0 0 0 0 k\n1 O\n0 1 0 0 K\n1 R\n2 w\n1 J\n1 j\n4 M\n[3 1] 0 d\n1 XR\n"; + let owner = format!("{paint}{}B\n", rect_path(10.0, 20.0, 50.0, 60.0)); + let hidden_rect = rect_path(10.0, 20.0, 30.0, 60.0).lines().map(|l| format!("%_{l}\n")).collect::(); + let mask = |own_paint: &str| { + format!("%_/ArtDictionary :\n%_/Mask :\n%_X=\n{own_paint}{hidden_rect}%_B\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_; (AI9_Mask) ,\n%_;\n") + }; + let fill_layer = |n: &Node| n.appearance.fill().cloned(); + let stroke_layer = |n: &Node| n.appearance.stroke().cloned(); + let rule = |n: &Node| match &n.kind { + NodeKind::Path { rule, .. } => Some(*rule), + _ => None, + }; + + let r = read_ok(&layer(&format!("{owner}{}", mask("")))); let a = art(&r.doc); let m = a[0].extras().mask.as_ref().unwrap(); - assert_eq!(fill(&m.art), fill(&a[0])); - let stroke = |n: &Node| n.appearance.stroke().map(|s| (s.paint.clone(), s.width)); - assert_eq!(stroke(&m.art), stroke(&a[0])); - assert!(stroke(&m.art).is_some_and(|(_, w)| w == 2.0)); + // The owner has the paint set, none of it the default, so equal layers mean it was taken. + let (f, s) = (fill_layer(&a[0]).unwrap(), stroke_layer(&a[0]).unwrap()); + assert!(f.overprint && s.overprint); + assert_eq!((s.width, s.cap, s.join, s.miter_limit), (2.0, LineCap::Round, LineJoin::Round, 4.0)); + assert_eq!(s.dash.as_ref().map(|d| (d.pattern.clone(), d.offset)), Some((vec![3.0, 1.0], 0.0))); + assert_eq!(rule(&a[0]), Some(vectorcraft_geom::FillRule::EvenOdd)); + assert_eq!(fill_layer(&m.art), Some(f)); + assert_eq!(stroke_layer(&m.art), Some(s)); + assert_eq!(rule(&m.art), rule(&a[0])); + + // Paint the mask's art sets for itself stays in the mask: an object after the dictionary that + // sets none paints with the owner's. + let own = "%_1 g\n%_0 O\n%_0 G\n%_0 R\n%_5 w\n%_0 J\n%_0 j\n%_10 M\n%_[] 0 d\n%_0 XR\n"; + let following = format!("{}B\n", rect_path(70.0, 20.0, 80.0, 30.0)); + let r = read_ok(&layer(&format!("{owner}{}{following}", mask(own)))); + let a = art(&r.doc); + assert_eq!(a.len(), 2); + let m = a[0].extras().mask.as_ref().unwrap(); + assert_ne!(stroke_layer(&m.art), stroke_layer(&a[0]), "the mask's art has its own paint"); + assert_eq!(fill_layer(&a[1]), fill_layer(&a[0])); + assert_eq!(stroke_layer(&a[1]), stroke_layer(&a[0])); + assert_eq!(rule(&a[1]), rule(&a[0])); } /// An opacity mask's art of one shown object is that object; other art is read into a group.