From 91fbb3fed8835d24c7563cfaae51ab14b588f677 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 1 Sep 2026 11:34:43 +0200 Subject: [PATCH] :zap: Reuse cached Skia paragraphs when painting text Add try_paint_from_layout_cache to paint from TextContent.layout when versions match, skipping ParagraphBuilder rebuild and layout on each frame. Wire into the layered text path for plain fills without strokes or effects. --- .../render-wasm/ffi-rendering-subtleties.md | 10 + render-wasm/src/render.rs | 485 ++++++++++-------- render-wasm/src/render/text.rs | 141 ++++- render-wasm/src/shapes/text.rs | 198 ++++++- render-wasm/src/state/shapes_pool.rs | 9 + 5 files changed, 611 insertions(+), 232 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index 7d7d56d7fd..a9006d5996 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -19,6 +19,16 @@ - Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs). +- Plain text fill paint reuses `TextContent.layout` paragraphs when + `has_usable_paint_layout` (paragraphs present + version match; during + interactive transforms rotation/move skips width check via + `modifier_changes_text_layout`, resize falls back to `layout_width` vs + `get_width(selrect.width())`), via `text::try_paint_from_layout_cache`. + The walker computes `text_layout_cache_rotation_only` from `tree` and + passes it into `render_shape`; stroke/shadow paths pass `false`. +- `TextContentLayout` paragraphs are `Rc`-shared on `Clone` so modifier clones + (rotate/pan) keep the paint cache; `needs_update` is paragraphs-empty only. + Decorations are skipped when no span requests underline/strike. - Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is compose+present only. diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 95bb7b0df0..40af6e2e2f 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -29,8 +29,8 @@ pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; use crate::math; use crate::shapes::{ - all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, - Stroke, StrokeKind, TextContent, Type, + all_with_ancestors, modifier_changes_text_layout, radius_to_sigma, Blur, BlurType, Corners, + Fill, Shadow, Shape, SolidColor, Stroke, StrokeKind, Type, }; use crate::state::{ShapesPoolMutRef, ShapesPoolRef}; use crate::tiles::{self, PendingTiles, TileRect}; @@ -291,6 +291,12 @@ impl FocusMode { } } +fn text_layout_cache_rotation_only(tree: ShapesPoolRef, shape: &Shape) -> bool { + tree.get_raw(&shape.id) + .zip(tree.get_layout_modifier(&shape.id)) + .is_some_and(|(base, modifier)| !modifier_changes_text_layout(base, &modifier)) +} + /* * Sort by z_index descending (higher z renders on top). * The sort is stable so if the values are equal the index for the children @@ -1376,6 +1382,7 @@ impl RenderState { parent_shadows: Option>, outset: Option, target_surface: SurfaceId, + text_layout_cache_rotation_only: bool, ) -> Result<()> { #[cfg(feature = "stats")] self.stats.count(shape.id); @@ -1614,141 +1621,38 @@ impl RenderState { s.canvas().concat(&matrix); }); - // Skip the paragraph-cloning `new_bounds` when shape size is unchanged. - let selrect = shape.selrect(); - let stored_bounds = stored_text_content.bounds(); - let bounds_match = (stored_bounds.width() - selrect.width()).abs() < 0.01 - && (stored_bounds.height() - selrect.height()).abs() < 0.01; - let rebound_text_content = if bounds_match { - None - } else { - Some(stored_text_content.new_bounds(selrect)) - }; - let text_content: &TextContent = - rebound_text_content.as_ref().unwrap_or(stored_text_content); - let count_inner_strokes = shape.count_visible_inner_strokes(); - // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. - let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); - let text_stroke_blur_outset = - Stroke::max_bounds_width(shape.visible_strokes(), false); - let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); - let stroke_kinds: Vec = - shape.visible_strokes().rev().map(|s| s.kind).collect(); - let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - text_content, - stroke, - &shape.selrect(), - None, - ) - }) - .unzip(); - if skip_effects { - // Fast path: render fills and strokes only (skip shadows/blur). - text::render( + // Plain fill (no strokes / parent shadows): reuse cached layout + // paragraphs when valid. Skip builder rebuild + Skia layout. + let can_use_layout_cache = !shape.has_visible_strokes() + && parent_shadows.is_none() + && (skip_effects + || (shape.blur.is_none() + && !shape + .drop_shadows_visible() + .any(|s| s.is_perceptible_at_scale(self.get_scale())) + && shape.inner_shadow_paints().is_empty())); + if !(can_use_layout_cache + && text::try_paint_from_layout_cache( Some(self), None, &shape, - &mut paragraph_builders, Some(fills_surface_id), - None, - None, - text_fill_inset, - None, - )?; - - for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list - .iter_mut() - .zip(stroke_opacities.iter()) - .enumerate() - { - if stroke_kinds[i] == StrokeKind::Inner { - let mut fill_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_inner_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - &mut fill_builders, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else if stroke_kinds[i] == StrokeKind::Outer { - text::render_outer_stroke( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - text_stroke_blur_outset, - *layer_opacity, - )?; - } else { - text::render_with_bounds_outset( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - None, - text_stroke_blur_outset, - None, - *layer_opacity, - )?; - } - } - - if shape.has_visible_strokes() && text_content.has_non_ascii() { - let mut emoji_builders = text_content.paragraph_builder_group_opaque(); - let mut deco_builders = - text_content.paragraph_builder_group_from_text(None); - text::render_emoji_overlay( - self, - &shape, - &mut emoji_builders, - &mut deco_builders, - strokes_surface_id, - None, - ); - } - } else { - let shape_scale = self.get_scale(); - let mut drop_shadows = if skip_drop_shadows { - Vec::new() - } else { - shape - .drop_shadows_visible() - .filter(|s| s.is_perceptible_at_scale(shape_scale)) - .map(|shadow| { - let mut paint = skia_safe::Paint::default(); - paint.set_image_filter(shadow.get_drop_shadow_filter()); - paint - }) - .collect() - }; - - if !skip_drop_shadows { - if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { - drop_shadows.extend(inherited_shadows); - } - } - - let inner_shadows = shape.inner_shadow_paints(); - let blur_filter = shape.image_filter(1.); - let mut paragraphs_with_shadows = - text_content.paragraph_builder_group_from_text(Some(true)); - let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( - Vec<_>, - Vec<_>, - ) = shape + text_layout_cache_rotation_only, + )?) + { + let rebound_text_content = + stored_text_content.paint_content_for_selrect(shape.selrect()); + let text_content = rebound_text_content.as_ref(); + let count_inner_strokes = shape.count_visible_inner_strokes(); + // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. + let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); + let text_stroke_blur_outset = + Stroke::max_bounds_width(shape.visible_strokes(), false); + let mut paragraph_builders = + text_content.paragraph_builder_group_from_text(None); + let stroke_kinds: Vec = + shape.visible_strokes().rev().map(|s| s.kind).collect(); + let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape .visible_strokes() .rev() .map(|stroke| { @@ -1756,60 +1660,12 @@ impl RenderState { text_content, stroke, &shape.selrect(), - Some(true), + None, ) }) .unzip(); - - if let Some(parent_shadows) = parent_shadows { - if !skip_drop_shadows { - if !shape.has_visible_strokes() { - for shadow in parent_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(&shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } else { - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &parent_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; - } - } - } else { - // 1. Text drop shadows - if !shape.has_visible_strokes() { - for shadow in &drop_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(shadow), - blur_filter.as_ref(), - None, - None, - )?; - } - } - - // 2. Text fills + if skip_effects { + // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), None, @@ -1817,25 +1673,11 @@ impl RenderState { &mut paragraph_builders, Some(fills_surface_id), None, - blur_filter.as_ref(), + None, text_fill_inset, None, )?; - // 3. Stroke drop shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &drop_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; - - // 4. Stroke fills for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list .iter_mut() .zip(stroke_opacities.iter()) @@ -1851,7 +1693,7 @@ impl RenderState { stroke_paragraphs, &mut fill_builders, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1862,7 +1704,7 @@ impl RenderState { &shape, stroke_paragraphs, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1874,7 +1716,7 @@ impl RenderState { stroke_paragraphs, Some(strokes_surface_id), None, - blur_filter.as_ref(), + None, text_stroke_blur_outset, None, *layer_opacity, @@ -1892,41 +1734,219 @@ impl RenderState { &mut emoji_builders, &mut deco_builders, strokes_surface_id, - blur_filter.as_ref(), + None, ); } + } else { + let shape_scale = self.get_scale(); + let mut drop_shadows = if skip_drop_shadows { + Vec::new() + } else { + shape + .drop_shadows_visible() + .filter(|s| s.is_perceptible_at_scale(shape_scale)) + .map(|shadow| { + let mut paint = skia_safe::Paint::default(); + paint.set_image_filter(shadow.get_drop_shadow_filter()); + paint + }) + .collect() + }; - // 5. Stroke inner shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - Some(innershadows_surface_id), - &inner_shadows, - &blur_filter, - &stroke_kinds, - text_content, - )?; + if !skip_drop_shadows { + if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } + } - // 6. Fill Inner shadows - if !shape.has_visible_strokes() { - for shadow in &inner_shadows { - text::render( - Some(self), - None, + let inner_shadows = shape.inner_shadow_paints(); + let blur_filter = shape.image_filter(1.); + let mut paragraphs_with_shadows = + text_content.paragraph_builder_group_from_text(Some(true)); + let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): ( + Vec<_>, + Vec<_>, + ) = shape + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( + text_content, + stroke, + &shape.selrect(), + Some(true), + ) + }) + .unzip(); + + if let Some(parent_shadows) = parent_shadows { + if !skip_drop_shadows { + if !shape.has_visible_strokes() { + for shadow in parent_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(&shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } else { + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &parent_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + } + } + } else { + // 1. Text drop shadows + if !shape.has_visible_strokes() { + for shadow in &drop_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } + } + + // 2. Text fills + text::render( + Some(self), + None, + &shape, + &mut paragraph_builders, + Some(fills_surface_id), + None, + blur_filter.as_ref(), + text_fill_inset, + None, + )?; + + // 3. Stroke drop shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &drop_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 4. Stroke fills + for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list + .iter_mut() + .zip(stroke_opacities.iter()) + .enumerate() + { + if stroke_kinds[i] == StrokeKind::Inner { + let mut fill_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_inner_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + &mut fill_builders, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else if stroke_kinds[i] == StrokeKind::Outer { + text::render_outer_stroke( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else { + text::render_with_bounds_outset( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + None, + blur_filter.as_ref(), + text_stroke_blur_outset, + None, + *layer_opacity, + )?; + } + } + + if shape.has_visible_strokes() && text_content.has_non_ascii() { + let mut emoji_builders = + text_content.paragraph_builder_group_opaque(); + let mut deco_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_emoji_overlay( + self, &shape, - &mut paragraphs_with_shadows, - Some(innershadows_surface_id), - Some(shadow), + &mut emoji_builders, + &mut deco_builders, + strokes_surface_id, blur_filter.as_ref(), - None, - None, - )?; + ); + } + + // 5. Stroke inner shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + Some(innershadows_surface_id), + &inner_shadows, + &blur_filter, + &stroke_kinds, + text_content, + )?; + + // 6. Fill Inner shadows + if !shape.has_visible_strokes() { + for shadow in &inner_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + Some(innershadows_surface_id), + Some(shadow), + blur_filter.as_ref(), + None, + None, + )?; + } } } } - } + } // end layout-cache miss fallback } _ => { self.surfaces.apply_mut(surface_ids, |s| { @@ -2995,6 +3015,7 @@ impl RenderState { None, None, target_surface, + false, )?; } @@ -3146,6 +3167,7 @@ impl RenderState { Some(vec![new_shadow_paint.clone()]), None, target_surface, + false, ) })?; self.surfaces.canvas(SurfaceId::DropShadows).restore(); @@ -3250,6 +3272,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3293,6 +3316,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3356,6 +3380,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3813,6 +3838,9 @@ impl RenderState { Cow::Borrowed(element) }; + let text_layout_cache_rotation_only = self.options.is_interactive_transform() + && text_layout_cache_rotation_only(tree, element); + self.render_shape( &element_for_inline, clip_bounds.clone(), @@ -3825,6 +3853,7 @@ impl RenderState { None, None, target_surface, + text_layout_cache_rotation_only, )?; self.surfaces diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index e027aebd21..e611f95d86 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -4,7 +4,7 @@ use crate::{ math::Rect, shapes::{ add_text_with_tabs, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, - ParagraphLayout, Stroke, StrokeKind, TextContent, + ParagraphLayout, Stroke, StrokeKind, TextContent, VerticalAlign, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -318,6 +318,141 @@ pub fn render_overlay_emoji( ) } +/// Paint fill glyphs from `TextContent.layout` when the cache is valid. +/// +/// Avoids rebuilding ParagraphBuilders and re-running Skia layout on every +/// paint. Only safe for the plain fill pass (no stroke/shadow-specific builders). +/// Returns `true` when painting was done from cache. +pub fn try_paint_from_layout_cache( + render_state: Option<&mut RenderState>, + canvas: Option<&Canvas>, + shape: &Shape, + surface_id: Option, + layout_cache_rotation_only: bool, +) -> Result { + let text_content = shape.get_text_content(); + let cache_usable = if layout_cache_rotation_only { + text_content.layout_cache_versions_match() + } else { + text_content.has_usable_paint_layout(shape) + }; + if !cache_usable { + return Ok(false); + } + + if let Some(render_state) = render_state { + let target_surface = surface_id.unwrap_or(SurfaceId::Fills); + let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); + paint_from_cached_layout(canvas, shape, text_content); + return Ok(true); + } + + if let Some(canvas) = canvas { + paint_from_cached_layout(canvas, shape, text_content); + return Ok(true); + } + + Ok(false) +} + +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(); + let paragraphs = &text_content.layout.paragraphs; + let draw_decorations = text_content.has_text_decorations(); + + let total_text_height: f32 = paragraphs + .iter() + .filter_map(|group| group.first()) + .map(|p| p.height()) + .sum(); + let vertical_offset = match shape.vertical_align() { + VerticalAlign::Center => (selrect.height() - total_text_height) / 2.0, + VerticalAlign::Bottom => selrect.height() - total_text_height, + _ => 0.0, + }; + + let mut y_accum = base_y + vertical_offset; + for group in paragraphs.iter() { + let Some(paragraph) = group.first() else { + continue; + }; + paragraph.paint(canvas, (x, y_accum)); + if draw_decorations { + paint_decorations_for_paragraph(canvas, paragraph, x, y_accum); + } + y_accum += paragraph.height(); + } +} + +fn paint_decorations_for_paragraph( + canvas: &Canvas, + paragraph: &skia::textlayout::Paragraph, + x: f32, + y_accum: f32, +) { + let line_metrics = paragraph.get_line_metrics(); + for line in &line_metrics { + let style_metrics: Vec<_> = line + .get_style_metrics(line.start_index..line.end_index) + .into_iter() + .collect(); + let line_baseline = y_accum + line.baseline as f32; + let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) = + calculate_decoration_metrics(&style_metrics, line_baseline); + for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() { + let text_style = &style_metric.text_style; + let style_end = style_metrics + .get(i + 1) + .map(|(next_i, _)| *next_i) + .unwrap_or(line.end_index); + let seg_start = (*style_start).max(line.start_index); + let seg_end = style_end.min(line.end_index); + if seg_start >= seg_end { + continue; + } + let rects = paragraph.get_rects_for_range( + seg_start..seg_end, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + let (segment_width, actual_x_offset) = if !rects.is_empty() { + let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum(); + let skia_x_offset = rects + .first() + .map(|r| r.rect.left - line.left as f32) + .unwrap_or(0.0); + (total_width, skia_x_offset) + } else { + (0.0, 0.0) + }; + let text_left = x + line.left as f32 + actual_x_offset; + let text_width = segment_width; + if text_style.decoration().ty == TextDecoration::UNDERLINE { + draw_text_decorations( + canvas, + text_style, + Some(underline_y.unwrap_or(line_baseline)), + max_underline_thickness, + text_left, + text_width, + ); + } + if text_style.decoration().ty == TextDecoration::LINE_THROUGH { + draw_text_decorations( + canvas, + text_style, + Some(strike_y.unwrap_or(line_baseline)), + max_strike_thickness, + text_left, + text_width, + ); + } + } + } +} + #[allow(clippy::too_many_arguments)] fn render_text_on_canvas( canvas: &Canvas, @@ -391,8 +526,6 @@ fn render_text_on_canvas( if blur.is_some() { canvas.restore(); } - - canvas.restore(); } /// Lays out and paints paragraph builders without any layer management. @@ -739,6 +872,8 @@ pub fn render_emoji_overlay( if blur.is_some() { canvas.restore(); } + + canvas.restore(); } fn draw_text( diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index a4b8411afa..89d8ded6ca 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -21,19 +21,57 @@ use skia_safe::{ Contains, }; +use std::borrow::Cow; use std::cell::Cell; use std::collections::HashSet; use std::rc::Rc; use super::FontFamily; use crate::math::Point; -use crate::shapes::{self, merge_fills, Shape, VerticalAlign}; +use crate::shapes::{self, merge_fills, Shape, Type, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; // TODO: maybe move this to the wasm module? pub type ParagraphBuilderGroup = Vec; +/// True when the modifier changes the text layout container (resize), as opposed +/// to rotation/move where glyph layout can be reused. +pub fn modifier_changes_text_layout(base: &Shape, modifier: &Matrix) -> bool { + let Type::Text(text_content) = &base.shape_type else { + return false; + }; + let before = oriented_container_bounds(base); + let after = before.transform(modifier); + match text_content.grow_type() { + GrowType::AutoWidth => !crate::math::is_close_to(before.height(), after.height()), + GrowType::AutoHeight | GrowType::Fixed => { + !crate::math::is_close_to(before.width(), after.width()) + } + } +} + +fn oriented_container_bounds(shape: &Shape) -> Bounds { + let selrect = shape.selrect(); + let mut bounds = Bounds::new( + Point::new(selrect.x(), selrect.y()), + Point::new(selrect.x() + selrect.width(), selrect.y()), + Point::new( + selrect.x() + selrect.width(), + selrect.y() + selrect.height(), + ), + Point::new(selrect.x(), selrect.y() + selrect.height()), + ); + if !shape.transform.is_identity() { + let mut matrix = shape.transform; + let center = shape.center(); + matrix.post_translate(center); + matrix.pre_translate(-center); + bounds.transform_mut(&matrix); + } + bounds +} + #[repr(u8)] #[derive(Debug, PartialEq, Clone, Copy, ToJs)] pub enum GrowType { @@ -399,6 +437,20 @@ impl TextContent { self.bounds } + /// 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. + pub fn paint_content_for_selrect<'a>(&'a self, selrect: Rect) -> Cow<'a, Self> { + let stored_bounds = self.bounds(); + if (stored_bounds.width() - selrect.width()).abs() < 0.01 + && (stored_bounds.height() - selrect.height()).abs() < 0.01 + { + Cow::Borrowed(self) + } else { + Cow::Owned(self.new_bounds(selrect)) + } + } + pub fn set_xywh(&mut self, x: f32, y: f32, w: f32, h: f32) { self.bounds = Rect::from_xywh(x, y, w, h); } @@ -880,6 +932,41 @@ impl TextContent { self.layout.needs_update() } + /// True when cached Skia paragraphs can be painted as-is (no rebuild/layout). + pub fn has_usable_paint_layout(&self, shape: &Shape) -> bool { + if self.layout.needs_update() || self.layout_version != self.content_version { + return false; + } + self.layout_matches_paint_container(shape) + } + + pub(crate) fn layout_cache_versions_match(&self) -> bool { + !self.layout.needs_update() && self.layout_version == self.content_version + } + + pub(crate) fn layout_matches_paint_container(&self, shape: &Shape) -> bool { + if self.grow_type() == GrowType::AutoWidth { + return true; + } + let Some(layout_w) = self.layout_width else { + return false; + }; + let container_w = self.get_width(shape.selrect().width()); + (layout_w - container_w).abs() < f32::EPSILON + } + + /// True when any span requests underline/overline/line-through (custom draw path). + pub fn has_text_decorations(&self) -> bool { + self.paragraphs().iter().any(|paragraph| { + paragraph.children().iter().any(|span| { + matches!( + span.text_decoration, + Some(d) if d != skia::textlayout::TextDecoration::NO_DECORATION + ) + }) + }) + } + pub fn set_layout_from_result( &mut self, result: TextContentLayoutResult, @@ -1881,6 +1968,115 @@ mod tests { assert_eq!(para.char_utf16_len_at(2), 1); } + fn sample_text_content() -> TextContent { + let bounds = Rect::from_xywh(0.0, 0.0, 200.0, 100.0); + let mut content = TextContent::new(bounds, GrowType::Fixed); + content.add_paragraph(test_paragraph(&["hello"])); + content + } + + #[test] + fn has_usable_paint_layout_false_when_paragraphs_empty() { + let content = TextContent::new(Rect::from_xywh(0.0, 0.0, 100.0, 50.0), GrowType::Fixed); + let shape = Shape::new(Uuid::nil()); + assert!(!content.has_usable_paint_layout(&shape)); + } + + #[test] + fn has_usable_paint_layout_false_when_versions_mismatch() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 1; + content.content_version = 2; + let mut shape = Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + assert!(!content.has_usable_paint_layout(&shape)); + } + + #[test] + fn has_usable_paint_layout_true_when_cached_and_versions_match() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 3; + content.content_version = 3; + let mut shape = Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + assert!(content.has_usable_paint_layout(&shape)); + } + + #[test] + fn has_usable_paint_layout_false_when_selrect_width_changed() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 3; + content.content_version = 3; + let mut shape = Shape::new(Uuid::nil()); + shape.set_selrect(0.0, 0.0, 300.0, 100.0); + assert!(!content.has_usable_paint_layout(&shape)); + } + + fn text_shape_with_cached_layout(content: TextContent) -> Shape { + let mut shape = Shape::new(Uuid::nil()); + shape.set_shape_type(shapes::Type::Text(content)); + shape.set_selrect(0.0, 0.0, 200.0, 100.0); + shape + } + + #[test] + fn has_usable_paint_layout_false_when_rotated_and_resized() { + let mut content = sample_text_content(); + content.layout.paragraphs = Rc::new(vec![vec![]]); + content.layout_width = Some(200.0); + content.layout_version = 3; + content.content_version = 3; + let base_shape = text_shape_with_cached_layout(content); + let rotate = Matrix::rotate_deg(45.0); + let resize = Matrix::scale((1.5, 1.0)); + let mut modifier = rotate; + modifier.pre_concat(&resize); + assert!(modifier_changes_text_layout(&base_shape, &modifier)); + } + + #[test] + fn has_text_decorations_detects_underline() { + let mut content = sample_text_content(); + content.paragraphs_mut()[0].children_mut()[0].text_decoration = + Some(skia::textlayout::TextDecoration::UNDERLINE); + assert!(content.has_text_decorations()); + } + + #[test] + fn has_text_decorations_false_for_plain_text() { + let content = sample_text_content(); + assert!(!content.has_text_decorations()); + } + + #[test] + fn paint_content_for_selrect_borrows_when_bounds_match() { + let content = sample_text_content(); + let selrect = Rect::from_xywh(10.0, 20.0, 200.0, 100.0); + match content.paint_content_for_selrect(selrect) { + Cow::Borrowed(_) => {} + Cow::Owned(_) => panic!("expected borrowed content"), + } + } + + #[test] + fn paint_content_for_selrect_rebounds_when_size_differs() { + let content = sample_text_content(); + let selrect = Rect::from_xywh(0.0, 0.0, 300.0, 100.0); + match content.paint_content_for_selrect(selrect) { + Cow::Owned(rebound) => { + assert_eq!(rebound.bounds().width(), 300.0); + assert!(rebound.layout.needs_update()); + } + Cow::Borrowed(_) => panic!("expected rebound content"), + } + } + #[test] fn layout_clone_shares_skia_paragraphs() { let mut layout = TextContentLayout::new(); diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 7fcc9ab642..d09bb109b2 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -153,6 +153,15 @@ impl ShapesPoolImpl { self.modifiers.get(&idx) } + /// Modifier applied to `id`, including one inherited from an ancestor. + pub fn get_layout_modifier(&self, id: &Uuid) -> Option { + if let Some(matrix) = self.get_modifier(id) { + return Some(*matrix); + } + let idx = *self.uuid_to_idx.get(id)?; + self.find_nearest_ancestor_modifier(idx) + } + /// Get a shape by UUID without applying modifiers/structure/scale-content. pub fn get_raw(&self, id: &Uuid) -> Option<&Shape> { let idx = *self.uuid_to_idx.get(id)?;