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.