From a48ab7f8c6377f938bae4ee91212eb122a4360b4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 08:20:33 -0700 Subject: [PATCH 1/2] Fix #3325: art nested in Affinity text is clipped to the characters An Affinity text object whose children are nested in it (an image dragged into artistic text, for example) opened as a plain group, so the image was drawn over the text. The text now becomes a clip group whose clipping path is the type, as a shape with children already does: the nested art shows only inside the characters, with the characters' fill behind it and their stroke over it. --- crates/affinity/README.md | 2 +- crates/affinity/tests/support/text_fixture.rs | 32 +++++++++++-------- .../engine/src/cmd/fileio/affinity/nodes.rs | 17 ++++++---- crates/engine/tests/affinity_text.rs | 28 ++++++++++++++++ 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 62e2974d2..f0339d698 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -17,7 +17,7 @@ Affinity files. | Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) | | Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths | | Compound shapes (`Comp`, add and subtract) | one even-odd path | -| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) | +| A shape, curve or text with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) | | Vector masks (`AdCh` curves and shapes) | clip group | | Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask | | Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) | diff --git a/crates/affinity/tests/support/text_fixture.rs b/crates/affinity/tests/support/text_fixture.rs index 574a0ca38..01baa067e 100644 --- a/crates/affinity/tests/support/text_fixture.rs +++ b/crates/affinity/tests/support/text_fixture.rs @@ -98,20 +98,26 @@ pub fn utf8(text: &str) -> F { } pub fn document(blocks: Vec, frame: bool, dpi: f64, transform: [f64; 6]) -> Vec { - let text = F::Obj( - tag(if frame { b"TxtF" } else { b"TxtA" }), - vec![ - (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])), - ( - tag(b"TxtH"), - F::Obj( - tag(if frame { b"FrFr" } else { b"ArFr" }), - vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))], - ), + document_with_children(blocks, frame, dpi, transform, vec![]) +} + +/// [`document`] whose text holds `children` (objects nested inside the text). +pub fn document_with_children(blocks: Vec, frame: bool, dpi: f64, transform: [f64; 6], children: Vec) -> Vec { + let mut fields = vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])), + ( + tag(b"TxtH"), + F::Obj( + tag(if frame { b"FrFr" } else { b"ArFr" }), + vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))], ), - (tag(b"Xfrm"), F::F64s(transform.to_vec())), - ], - ); + ), + (tag(b"Xfrm"), F::F64s(transform.to_vec())), + ]; + if !children.is_empty() { + fields.push((tag(b"Chld"), F::Shared(children))); + } + let text = F::Obj(tag(if frame { b"TxtF" } else { b"TxtA" }), fields); let mut root = vec![ (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(dpi))])), ( diff --git a/crates/engine/src/cmd/fileio/affinity/nodes.rs b/crates/engine/src/cmd/fileio/affinity/nodes.rs index 0375ff39e..2e9334227 100644 --- a/crates/engine/src/cmd/fileio/affinity/nodes.rs +++ b/crates/engine/src/cmd/fileio/affinity/nodes.rs @@ -186,14 +186,19 @@ impl Builder { return Some(Arc::new(g)); }; self.outline_text(&n.effects, &mut text); - let mut node = self.base(n, NodeKind::Text(Box::new(text))); - node.opacity *= alpha; - if !children.is_empty() { - let mut kids = vec![Arc::new(node)]; + if children.is_empty() { + let mut node = self.base(n, NodeKind::Text(Box::new(text))); + node.opacity *= alpha; + node + } else { + // Text clips its children as a shape does: the type is the clipping path, its + // characters' fill behind the clipped art and their stroke over it. + let mut clip = Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(text))); + clip.opacity = alpha; + let mut kids = vec![Arc::new(clip)]; kids.extend(children); - node = Node::group(self.doc.alloc_id(), kids); + self.base(n, NodeKind::Group { children: kids, clip: true }) } - node } Kind::Table(t) => { let mut kids = self.table(t, to_doc); diff --git a/crates/engine/tests/affinity_text.rs b/crates/engine/tests/affinity_text.rs index 42224bfcb..d9530b652 100644 --- a/crates/engine/tests/affinity_text.rs +++ b/crates/engine/tests/affinity_text.rs @@ -88,6 +88,34 @@ fn frame_type_keeps_dimensions_alignment_and_unit_conversion() { assert_eq!(frame.bounds().unwrap().height(), 50.0); } +#[test] +fn objects_nested_in_text_are_clipped_by_the_type() { + let ellipse = F::Obj( + tag(b"ShpN"), + vec![ + (tag(b"Desc"), F::Str("Inside".into())), + (tag(b"Shpe"), F::Obj(tag(b"ShpE"), vec![])), + (tag(b"ShpB"), F::F64s(vec![10.0, 20.0, 110.0, 70.0])), + (tag(b"BFFl"), F::Shared(vec![F::Obj(tag(b"FDsc"), vec![(tag(b"FDeF"), solid(1.0))])])), + ], + ); + let bytes = document_with_children( + vec![block(utf8("TEXT"), vec![run(4, Some(attrs("Clip", "", 400, false, 40.0)))], &[])], + false, + 72.0, + IDENTITY, + vec![ellipse], + ); + let loaded = fileio::load("nested.af", &bytes).unwrap(); + let NodeKind::Layer { children, .. } = &loaded.doc.layers[0].kind else { panic!("{:?}", loaded.doc.layers[0].kind) }; + let NodeKind::Group { children: clipped, clip: true } = &children[0].kind else { panic!("{:?}", children[0].kind) }; + let [text, inside] = clipped.as_slice() else { panic!("{clipped:?}") }; + let NodeKind::Text(t) = &text.kind else { panic!("{:?}", text.kind) }; + assert_eq!(t.runs[0].text, "TEXT"); + assert_eq!(inside.name.as_deref(), Some("Inside")); + assert!(matches!(inside.kind, NodeKind::Path { clipping: false, .. }), "{:?}", inside.kind); +} + #[test] fn a_frame_too_short_for_its_first_line_is_made_taller_with_a_warning() { // #1423: a title taller than its frame (60 pt type in a 50 pt frame) drew nothing. From e8b1f1187d08f6e5d129c10ce583e4b1fd7fee6c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 09:20:19 -0700 Subject: [PATCH 2/2] Tests: a pixel layer nested in Affinity text stays an image inside the clip objects_nested_in_text_are_clipped_by_the_type now also nests a 4 x 4 Rstr pixel layer under the TxtA and checks that it is read as an image in the clipping group, next to the shape. --- crates/engine/tests/affinity_text.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/affinity_text.rs b/crates/engine/tests/affinity_text.rs index d9530b652..104625c7c 100644 --- a/crates/engine/tests/affinity_text.rs +++ b/crates/engine/tests/affinity_text.rs @@ -99,21 +99,32 @@ fn objects_nested_in_text_are_clipped_by_the_type() { (tag(b"BFFl"), F::Shared(vec![F::Obj(tag(b"FDsc"), vec![(tag(b"FDeF"), solid(1.0))])])), ], ); + // A 4 × 4 RGBA pixel layer of one flat colour (tile state 2: every sample 255). + let mut bitmap = vec![(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"BmpW"), F::I32(4)), (tag(b"BmpH"), F::I32(4))]; + for c in 1..=4 { + bitmap.push((tag(&[b'T', b'W', b'i', b'0' + c]), F::I32(1))); + bitmap.push((tag(&[b'T', b'H', b'i', b'0' + c]), F::I32(1))); + bitmap.push((tag(&[b'S', b't', b'a', b'0' + c]), F::U8s(vec![2]))); + } + let pixels = F::Obj(tag(b"Rstr"), vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)), (tag(b"Desc"), F::Str("Photo".into()))]); let bytes = document_with_children( vec![block(utf8("TEXT"), vec![run(4, Some(attrs("Clip", "", 400, false, 40.0)))], &[])], false, 72.0, IDENTITY, - vec![ellipse], + vec![ellipse, pixels], ); let loaded = fileio::load("nested.af", &bytes).unwrap(); let NodeKind::Layer { children, .. } = &loaded.doc.layers[0].kind else { panic!("{:?}", loaded.doc.layers[0].kind) }; let NodeKind::Group { children: clipped, clip: true } = &children[0].kind else { panic!("{:?}", children[0].kind) }; - let [text, inside] = clipped.as_slice() else { panic!("{clipped:?}") }; + let [text, inside, photo] = clipped.as_slice() else { panic!("{clipped:?}") }; let NodeKind::Text(t) = &text.kind else { panic!("{:?}", text.kind) }; assert_eq!(t.runs[0].text, "TEXT"); assert_eq!(inside.name.as_deref(), Some("Inside")); assert!(matches!(inside.kind, NodeKind::Path { clipping: false, .. }), "{:?}", inside.kind); + assert_eq!(photo.name.as_deref(), Some("Photo")); + let NodeKind::Image(image) = &photo.kind else { panic!("{:?}", photo.kind) }; + assert_eq!((image.width, image.height), (4, 4)); } #[test]