diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index b2948f823e..6b8e50a4da 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -358,8 +358,19 @@ pub fn try_paint_from_layout_cache( fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextContent) { let selrect = shape.selrect(); - let x = selrect.x(); - let base_y = selrect.y(); + // Absolute image/gradient shaders were baked at `layout_paint_origin`. Paint + // glyphs in that space and translate the canvas so both track selrect moves + // without rebuilding Skia paragraphs. + let anchor = text_content.cached_layout_paint_anchor(&selrect); + let offset = text_content.cached_layout_paint_offset(&selrect); + let needs_translate = offset.x.abs() > f32::EPSILON || offset.y.abs() > f32::EPSILON; + if needs_translate { + canvas.save(); + canvas.translate((offset.x, offset.y)); + } + + let x = anchor.x; + let base_y = anchor.y; let paragraphs = &text_content.layout.paragraphs; let draw_decorations = text_content.has_text_decorations(); @@ -389,6 +400,10 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC } y_accum += paragraph.height(); } + + if needs_translate { + canvas.restore(); + } } #[allow(clippy::too_many_arguments)] diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index bb3c74f7f0..cb45ba7df3 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -403,8 +403,8 @@ impl Shape { self.invalidate_extrect(); self.selrect.set_ltrb(left, top, right, bottom); if let Type::Text(ref mut text) = self.shape_type { + // `update_layout` syncs bounds via set_xywh before baking fill paints. text.update_layout(self.selrect); - text.set_xywh(left, top, self.selrect.width(), self.selrect.height()); } } diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index f8009e91be..8f6d8de294 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -420,6 +420,10 @@ pub struct TextContent { content_version: u64, layout_version: u64, layout_width: Option, + /// Canvas origin used when absolute fill shaders (image/gradient) were baked + /// into cached Skia paragraphs. Kept across move clones so paint can + /// translate glyphs + shaders together. See `cached_layout_paint_offset`. + layout_paint_origin: Option, } impl PartialEq for TextContent { @@ -443,6 +447,7 @@ impl TextContent { content_version: 0, layout_version: 0, layout_width: None, + layout_paint_origin: None, } } @@ -458,6 +463,7 @@ impl TextContent { content_version: 0, layout_version: 0, layout_width: None, + layout_paint_origin: None, } } @@ -465,6 +471,23 @@ impl TextContent { self.bounds } + /// Anchor used when painting from the layout cache. Absolute image/gradient + /// shaders were built in this coordinate space; paint glyphs here and apply + /// [`cached_layout_paint_offset`] on the canvas so both move together. + pub fn cached_layout_paint_anchor(&self, selrect: &Rect) -> Point { + self.layout_paint_origin + .unwrap_or_else(|| Point::new(selrect.x(), selrect.y())) + } + + /// Canvas translation from the baked paint origin to the current selrect. + /// Zero when there is no recorded origin (fall back to painting at selrect). + pub fn cached_layout_paint_offset(&self, selrect: &Rect) -> Point { + match self.layout_paint_origin { + Some(origin) => Point::new(selrect.x() - origin.x, selrect.y() - origin.y), + None => Point::new(0.0, 0.0), + } + } + /// Text content for paint when [`Rect`] size may differ from stored bounds /// (e.g. modifier transform). Reuses `self` when width/height match; otherwise /// clones paragraphs into a rebound copy with an empty layout cache. @@ -1130,10 +1153,14 @@ impl TextContent { self.layout.set(result.0, result.1); self.size .copy_finite_size(result.2, default_width, default_height); + // Paragraph paints (incl. absolute image/gradient shaders) were built + // against `self.bounds()` in `paragraph_builder_group_from_text`. + self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y())); } pub fn force_next_layout_update(&mut self) { self.layout_width = None; + self.layout_paint_origin = None; self.layout.cached_extrect.set(None); // Bump the content version so update_layout can't early-return: auto-width // shapes always match their container and clearing the cache above doesn't @@ -1142,6 +1169,10 @@ impl TextContent { } pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize { + // Keep bounds in sync before building paints so absolute fill shaders + // match the container we are laying out for. + self.set_xywh(selrect.x(), selrect.y(), selrect.width(), selrect.height()); + // Auto-width ignores selrect width so get-text-dimensions can reuse the cached layout. let layout_matches_container = self.grow_type() == GrowType::AutoWidth || self @@ -1319,6 +1350,7 @@ impl Default for TextContent { content_version: 0, layout_version: 0, layout_width: None, + layout_paint_origin: None, } } } @@ -2225,6 +2257,86 @@ mod tests { } } + #[test] + fn layout_paint_origin_set_when_layout_result_applied() { + let mut content = sample_text_content(); + content.set_xywh(40.0, 60.0, 200.0, 100.0); + let empty = + TextContentLayoutResult(vec![], vec![], TextContentSize::new_with_size(200.0, 100.0)); + content.set_layout_from_result(empty, 200.0, 100.0); + let selrect = Rect::from_xywh(40.0, 60.0, 200.0, 100.0); + assert_eq!( + content.cached_layout_paint_anchor(&selrect), + Point::new(40.0, 60.0) + ); + assert_eq!( + content.cached_layout_paint_offset(&selrect), + Point::new(0.0, 0.0) + ); + } + + #[test] + fn cached_layout_paint_offset_tracks_selrect_move() { + let mut content = sample_text_content(); + content.layout_paint_origin = Some(Point::new(10.0, 20.0)); + // Simulate a move clone: bounds follow the new selrect, origin stays. + content.set_xywh(110.0, 220.0, 200.0, 100.0); + let selrect = Rect::from_xywh(110.0, 220.0, 200.0, 100.0); + let offset = content.cached_layout_paint_offset(&selrect); + assert_eq!(offset, Point::new(100.0, 200.0)); + assert_eq!( + content.cached_layout_paint_anchor(&selrect), + Point::new(10.0, 20.0) + ); + } + + #[test] + fn cached_layout_paint_offset_zero_without_origin() { + let content = sample_text_content(); + let selrect = Rect::from_xywh(50.0, 75.0, 200.0, 100.0); + assert_eq!( + content.cached_layout_paint_offset(&selrect), + Point::new(0.0, 0.0) + ); + assert_eq!( + content.cached_layout_paint_anchor(&selrect), + Point::new(50.0, 75.0) + ); + } + + #[test] + fn layout_paint_origin_survives_bounds_transform_on_clone() { + let mut content = sample_text_content(); + content.set_xywh(10.0, 20.0, 200.0, 100.0); + content.layout_paint_origin = Some(Point::new(10.0, 20.0)); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 1; + content.content_version = 1; + + let mut moved = content.clone(); + let mut move_matrix = Matrix::new_identity(); + move_matrix.set_translate_x(50.0); + move_matrix.set_translate_y(30.0); + moved.transform(&move_matrix); + + assert_eq!(moved.bounds().x(), 60.0); + assert_eq!(moved.bounds().y(), 50.0); + assert!(Rc::ptr_eq( + &content.layout.paragraphs, + &moved.layout.paragraphs + )); + let selrect = Rect::from_xywh(60.0, 50.0, 200.0, 100.0); + assert_eq!( + moved.cached_layout_paint_anchor(&selrect), + Point::new(10.0, 20.0) + ); + assert_eq!( + moved.cached_layout_paint_offset(&selrect), + Point::new(50.0, 30.0) + ); + } + #[test] fn layout_clone_shares_skia_paragraphs() { let mut layout = TextContentLayout::new();