From a9b49f716b503c28e5273372317fa703b59680d9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 12:34:21 -0800 Subject: [PATCH] .ai import: images, meshes and blends keep the knockout shape flag An `Xy` whose shape operand is set now reaches an image (embedded or linked) and a gradient mesh when it is written before the object, and a mesh or blend when it is written after the object's end. A live blend takes the flag from its drawn steps. Before, the reader dropped the flag on these objects, so an image saved with Opacity & Mask Define Knockout Shape lost it on reopening, with no warning. A styled object takes the flag written on its drawn look. For a text object, the flag goes to the object when the look draws only its type, and otherwise to the group that holds the look and the type. An opaque look that draws more than the type and sets the flag now goes into that group as well. --- crates/eps/src/ai_editing/tests.rs | 24 +++++++ crates/eps/src/import/ai/read.rs | 2 +- crates/eps/src/import/ai/read/art.rs | 3 + crates/eps/src/import/ai/read/containers.rs | 5 +- crates/eps/src/import/ai/read/ops.rs | 20 +++--- crates/eps/src/import/ai/tests.rs | 74 +++++++++++++++++++++ 6 files changed, 116 insertions(+), 12 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 5a12c77dc..c3d3036e1 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -2682,6 +2682,30 @@ fn images_come_back_with_their_pixels_and_places() { assert_eq!(lines(&back).len(), lines(&doc).len(), "{:?}", lines(&back)); } +/// An image whose opacity defines the knockout shape comes back with it, embedded or linked. The +/// image after it and their layer do not take it. +#[test] +fn an_images_knockout_shape_comes_back() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let at = |x: f64| Affine::new([10.0, 0.0, 0.0, 10.0, x, 10.0]); + let mut shaped = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), at(10.0), None); + (shaped.opacity, shaped.knockout_shape) = (0.5, true); + doc.insert(Some(layer), usize::MAX, shaped).unwrap(); + let plain = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), at(50.0), None); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + let mut link = vectorcraft_doc::LinkInfo::new("/Users/someone/Job/art/pic.png"); + link.relative = Some("art/pic.png".into()); + let mut linked = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), at(90.0), Some(link)); + linked.knockout_shape = true; + doc.insert(Some(layer), usize::MAX, linked).unwrap(); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + let got: Vec<_> = art.iter().map(|n| (matches!(n.kind, NodeKind::Image(_)), n.opacity, n.knockout_shape)).collect(); + assert_eq!(got, [(true, 0.5, true), (true, 1.0, false), (true, 1.0, true)]); + assert_eq!((back.layers[0].opacity, back.layers[0].knockout_shape), (1.0, false)); +} + #[test] fn images_are_written_again_as_the_same_bytes() { let mut doc = images_doc(); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 226224271..78c8a83bc 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -495,7 +495,7 @@ struct Reader<'a> { layer_state: Option<(f32, BlendMode, bool, Knockout, bool)>, /// The state a mesh or blend had before an `Xy` right after its end, which is the layer's /// instead when the layer's `LB` follows rather than their own `6 () XW`. - mesh_undo: Option<(f32, BlendMode, bool, Knockout)>, + mesh_undo: Option<(f32, BlendMode, bool, Knockout, bool)>, /// The state a layer's last group or compound path had before an `Xy` right after its end, /// which is the layer's instead when the layer's own `7 () XW` follows. container_undo: Option<(f32, BlendMode, bool, Knockout, bool)>, diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index e62ae347b..b1b860a25 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -319,6 +319,7 @@ impl<'a> Reader<'a> { let mut node = self.new_node(NodeKind::Mesh(one), &gs); node.opacity = gs.opacity; node.blend = gs.blend; + node.knockout_shape = gs.knockout_shape; self.add(node, hidden)?; self.mesh_ended = true; return Ok(()); @@ -335,6 +336,7 @@ impl<'a> Reader<'a> { }; node.opacity = gs.opacity; node.blend = gs.blend; + node.knockout_shape = gs.knockout_shape; self.add(node, hidden)?; self.mesh_ended = true; Ok(()) @@ -701,6 +703,7 @@ impl<'a> Reader<'a> { let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs); node.opacity = gs.opacity; node.blend = gs.blend; + node.knockout_shape = gs.knockout_shape; node.extras_mut().mask = mask.map(Box::new); node.extras_mut().colorize = colorize.map(Box::new); self.add(node, hidden)?; diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index f6ac4a00a..35c6bf7de 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -312,7 +312,7 @@ impl<'a> Reader<'a> { if let (Some(_), Some(before)) = (state, self.mesh_undo.take()) && let Some(mesh) = f.children.last_mut().filter(|n| matches!(n.kind, NodeKind::Mesh(_) | NodeKind::Blend { .. })) { - (mesh.opacity, mesh.blend, mesh.isolate, mesh.knockout) = before; + (mesh.opacity, mesh.blend, mesh.isolate, mesh.knockout, mesh.knockout_shape) = before; } let Kind::Layer(a) = f.kind else { return Ok(()) }; let (children, clip) = self.clip_first(f.children, &f.clips, false, self.nesting() + 1)?; @@ -760,7 +760,8 @@ impl<'a> Reader<'a> { // did (they were counted as read). let mut blend = Node::new(self.doc.alloc_id(), kind); (blend.name, blend.visible, blend.locked) = (steps.name.take(), steps.visible, steps.locked); - (blend.opacity, blend.blend, blend.isolate, blend.knockout) = (steps.opacity, steps.blend, steps.isolate, steps.knockout); + (blend.opacity, blend.blend, blend.isolate, blend.knockout, blend.knockout_shape) = + (steps.opacity, steps.blend, steps.isolate, steps.knockout, steps.knockout_shape); // Its note, URL and data (object tags) too. blend.extras_mut().attrs = steps.extras_mut().attrs.take(); f.children.push(blend); diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 6fb67257c..4d2ce92c3 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -623,8 +623,8 @@ impl<'a> Reader<'a> { self.last_object().filter(|n| (mesh_ended && matches!(n.kind, NodeKind::Mesh(_))) || matches!(n.kind, NodeKind::Blend { .. })) { // The mesh's or blend's, or the layer's when it is the layer's last object: kept for either. - let before = (n.opacity, n.blend, n.isolate, n.knockout); - (n.opacity, n.blend, n.isolate, n.knockout) = (gs.opacity, gs.blend, gs.isolate, gs.knockout); + let before = (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape); + (n.opacity, n.blend, n.isolate, n.knockout, n.knockout_shape) = (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape); self.mesh_undo = Some(before); self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape)); } else if let Some(n) = self.last_object().filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Compound { .. })) { @@ -763,7 +763,8 @@ impl<'a> Reader<'a> { } object.appearance = appearance.into(); // The object's transparency is written on its look, after the look's group ends. - (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); + (object.opacity, object.blend, object.isolate, object.knockout, object.knockout_shape) = + (look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape); // Now the object itself, not a copy an enclosing look's `strip` would remove. self.hidden_ids.remove(&object.id); // Linked to the style when the style list has its name ([`Reader::finish`]). @@ -784,8 +785,9 @@ impl<'a> Reader<'a> { // A text object stays the type it is: over what else its look draws, or in place of a // look that was only its type. Its transparency is written on its look. let empty = look.children().is_some_and(|c| c.is_empty()); - if empty || (look.opacity >= 1.0 && look.blend == BlendMode::Normal && !look.isolate) { - (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); + if empty || (look.opacity >= 1.0 && look.blend == BlendMode::Normal && !look.isolate && !look.knockout_shape) { + (object.opacity, object.blend, object.isolate, object.knockout, object.knockout_shape) = + (look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape); if empty { *look = object; } else { @@ -795,11 +797,11 @@ impl<'a> Reader<'a> { } // The look and the type go into a group that has the transparency. let Some(mut look) = f.children.pop() else { return }; - let state = (look.opacity, look.blend, look.isolate, look.knockout); - (look.opacity, look.blend, look.isolate, look.knockout) = (1.0, BlendMode::Normal, false, Knockout::Neutral); - (object.opacity, object.blend, object.isolate) = (1.0, BlendMode::Normal, false); + let state = (look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape); + (look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) = (1.0, BlendMode::Normal, false, Knockout::Neutral, false); + (object.opacity, object.blend, object.isolate, object.knockout_shape) = (1.0, BlendMode::Normal, false, false); let mut group = Node::group(self.doc.alloc_id(), vec![Arc::new(look), Arc::new(object)]); - (group.opacity, group.blend, group.isolate, group.knockout) = state; + (group.opacity, group.blend, group.isolate, group.knockout, group.knockout_shape) = state; self.text_looks.insert(group.id); if let Some(f) = self.frames.last_mut() { f.children.push(group); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c6e91e3c0..04493dcb2 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1569,6 +1569,29 @@ fn transparency_of_objects_and_groups() { assert_eq!((a[2].opacity, a[2].blend), (1.0, BlendMode::Normal), "the next object's state isn't the group's"); } +/// A mesh's opacity defines the knockout shape when an `Xy` says so before it or right after its +/// end, whether its patches make one mesh or a group of patches. An `Xy` that is the layer's leaves +/// the mesh's as it was. +#[test] +fn a_gradient_mesh_keeps_its_knockout_shape() { + let one = straight_mesh(2, 2, (10.0, 10.0)); + let patches = one.replace("[2 2] /Size X#\n", ""); + let shape = "0 0.5 0 0 1 Xy\n"; + for (mesh, is_mesh) in [(&one, true), (&patches, false)] { + let doc = read_ok(&layer(&format!("{shape}{mesh}"))).doc; + assert_eq!(matches!(art(&doc)[0].kind, NodeKind::Mesh(_)), is_mesh, "{:?}", art(&doc)[0].kind); + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].knockout_shape), (0.5, true), "{is_mesh}"); + } + let doc = read_ok(&layer(&format!("{one}{shape}0 0 Xd\n6 () XW\n"))).doc; + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].knockout_shape), (0.5, true), "written after its end"); + let doc = read_ok(&layer(&format!("{shape}{one}1 0.35 0 0 0 Xy\n0 0 Xd\n7 () XW\n"))).doc; + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].knockout_shape), (0.5, true), "the mesh's own"); + assert_eq!((doc.layers[0].opacity, doc.layers[0].knockout_shape), (0.35, false), "the layer's own"); + let doc = read_ok(&layer(&format!("{one}1 0.35 0 0 1 Xy\n0 0 Xd\n7 () XW\n"))).doc; + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].knockout_shape), (1.0, false), "the mesh's own"); + assert_eq!((doc.layers[0].opacity, doc.layers[0].knockout_shape), (0.35, true), "the layer's own"); +} + #[test] fn a_gradient_mesh_takes_the_transparency_written_after_it() { // GUARDS: a mesh's own state (`Xy`) comes right after its `/End X!`, before `Xd` and `XW`, as a @@ -5741,6 +5764,30 @@ fn a_styled_object_keeps_the_transparency_written_on_its_look() { assert_eq!(a.len(), 1, "{a:#?}"); assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0))); assert_eq!((a[0].opacity, a[0].blend), (0.6, BlendMode::Multiply)); + assert!(!a[0].knockout_shape); + let r = read_ok(&format!("{ART_STYLES}{}", layer(&art_.replace("1 0.6 0 2 0 Xy", "1 0.6 0 2 1 Xy")))); + assert!(art(&r.doc)[0].knockout_shape, "written on the look"); +} + +/// The knockout shape flag written on a text object's look goes to the object, or, where the look +/// draws more than the type, opaque or not, to the group that holds the look and the object. +#[test] +fn a_text_objects_look_gives_it_the_knockout_shape() { + let look = "0 Ae\nu\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nU\n1 0.2 0 1 1 Xy\n0 0 Xd\n6 () XW\n"; + let own = "%_/AI11Text :\n%_0 /FreeUndo ,\n%_0 /FrameIndex ,\n%_0 /StoryIndex ,\n%_;\n"; + let doc = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n"))).doc; + let a = art(&doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!((a[0].opacity, a[0].knockout_shape), (0.2, true)); + let drawing = look.replace("U\n", &format!("{RED}{}f\nU\n", rect_path(0.0, 0.0, 10.0, 10.0))); + for (state, opacity) in [("1 0.2 0 1 1 Xy", 0.2), ("0 1 0 1 1 Xy", 1.0)] { + let drawn = drawing.replace("1 0.2 0 1 1 Xy", state); + let doc = read_ok(&layer(&format!("{drawn}{own}1 (Style) XW\n"))).doc; + let a = art(&doc); + assert_eq!(a.len(), 1, "{state}: {a:#?}"); + assert_eq!((a[0].opacity, a[0].knockout_shape), (opacity, true), "{state}"); + assert!(a[0].children().unwrap().iter().all(|n| !n.knockout_shape), "{state}: {a:#?}"); + } } /// An envelope is written on `%_` lines as a group holding the mesh that shapes it, with its @@ -6954,6 +7001,33 @@ fn a_blend_it_cant_read_keeps_its_drawn_steps() { } } +/// A blend's opacity defines the knockout shape when the state block after its record says so, +/// live or as its drawn steps. An `Xy` that is the layer's leaves the blend's as it was. +#[test] +fn a_blend_keeps_its_knockout_shape() { + let straight = "%_10 50 m\n%_110 50 L\n"; + let keys = [Some("A"), Some("B")]; + let record = blend_record(&['A', 'B']); + let garbled = "(Adobe Path Blends) 1 0 16 XP\n%00112233445566778899AABBCCDDEEFF\n".to_string(); + for (record, live) in [(&record, true), (&garbled, false)] { + let body = format!("{}0 0.6 0 1 1 Xy\n0 0 Xd\n6 () XW\n", blend_file(straight, keys, record)); + let doc = read_ok(&layer(&body)).doc; + assert_eq!(matches!(art(&doc)[0].kind, NodeKind::Blend { .. }), live, "{:?}", art(&doc)[0].kind); + assert!(art(&doc)[0].knockout_shape, "live: {live}"); + } + let blend = blend_file(straight, keys, &record); + // Written after its drawn steps, before the record. + let doc = read_ok(&layer(&blend.replace("\nU\n9 () XW\n0 Ae\n", "\nU\n0 0.6 0 1 1 Xy\n9 () XW\n0 Ae\n"))).doc; + assert!(matches!(art(&doc)[0].kind, NodeKind::Blend { .. }), "{:?}", art(&doc)[0].kind); + assert_eq!((art(&doc)[0].opacity, art(&doc)[0].knockout_shape), (0.6, true)); + let doc = read_ok(&layer(&format!("{blend}0 0.6 0 1 1 Xy\n1 0.35 0 0 0 Xy\n0 0 Xd\n7 () XW\n"))).doc; + assert!(art(&doc)[0].knockout_shape, "the blend's own"); + assert!(!doc.layers[0].knockout_shape, "the layer's own"); + let doc = read_ok(&layer(&format!("{blend}1 0.35 0 0 1 Xy\n0 0 Xd\n7 () XW\n"))).doc; + assert!(!art(&doc)[0].knockout_shape, "the blend's own"); + assert!(doc.layers[0].knockout_shape, "the layer's own"); +} + /// A blend's transparency (here knockout on, as blends are written) is the state block after its /// record, as a group's follows its end: the live blend's, or its drawn steps' where they stay. It /// is the blend's own, not the next object's.