From c5897bc50ad51d4bb6471f791e7d539922459c93 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 2 Sep 2026 16:17:06 +0200 Subject: [PATCH] :zap: Paint plain text directly onto Current (#11355) * :recycle: Share text layout paragraphs across modifier clones Store Skia paragraphs in Rc so TextContentLayout::clone keeps the cached layout for rotate/pan modifiers. Add layout.clear() and treat needs_update as paragraphs-empty only. * :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. * :zap: Paint plain text directly onto Current Extend can_render_directly for stroke-free text and skip the empty save_layer in draw_text when no stroke-group opacity is set. Plain text paints into Current without the Fills/Strokes blit. --- .../render-wasm/ffi-rendering-subtleties.md | 14 + render-wasm/src/render.rs | 555 ++++++++++-------- render-wasm/src/render/text.rs | 153 ++++- render-wasm/src/shapes/text.rs | 238 +++++++- render-wasm/src/state/shapes_pool.rs | 9 + render-wasm/src/state/text_editor.rs | 9 +- render-wasm/src/wasm/text_editor.rs | 9 +- 7 files changed, 714 insertions(+), 273 deletions(-) diff --git a/.serena/memories/render-wasm/ffi-rendering-subtleties.md b/.serena/memories/render-wasm/ffi-rendering-subtleties.md index 7d7d56d7fd..dadbae5c2d 100644 --- a/.serena/memories/render-wasm/ffi-rendering-subtleties.md +++ b/.serena/memories/render-wasm/ffi-rendering-subtleties.md @@ -19,6 +19,20 @@ - 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). +- `can_render_directly` paints onto Current (no Fills/Strokes blit) for plain geometry and + for stroke-free text (SrcOver, no blur/shadows). Multi-style text is fine: span styles + live in Paragraph `TextStyle`s. Text skips the `nested_fills` guard (fills are on spans). + `draw_text` only `save_layer`s when stroke-group opacity is set; plain fill paint is direct. +- 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 b5769f9c35..932e8f80a4 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 @@ -1345,6 +1351,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); @@ -1405,6 +1412,17 @@ impl RenderState { // Stroke-only (fills_none) can go direct: empty fills are a no-op and // strokes paint into Current. Large files need mid-walk GPU drains so // release builds do not backlog a huge ops buffer in one Partial. + // + // Plain text (no strokes / effects) also paints into Current: span styles + // live in Skia Paragraph TextStyles, so multi-style text is fine. + // Text skips the nested_fills guard because fills are on spans, not + // shape.fills. Strokes stay layered (masking needs save_layers). + let is_direct_geometry = matches!( + shape.shape_type, + Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) + ) && !(shape.fills.is_empty() && has_nested_fills); + let is_direct_text = + matches!(shape.shape_type, Type::Text(_)) && !shape.has_visible_strokes(); let can_render_directly = apply_to_current_surface && offset.is_none() && parent_shadows.is_none() @@ -1415,11 +1433,7 @@ impl RenderState { && shape.background_blur.is_none() && !has_inherited_blur && !shadows_need_layered - && matches!( - shape.shape_type, - Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Frame(_) - ) - && !(shape.fills.is_empty() && has_nested_fills) + && (is_direct_geometry || is_direct_text) && target_surface != SurfaceId::Export; if can_render_directly { @@ -1448,21 +1462,50 @@ impl RenderState { }); } - fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; + if let Type::Text(stored_text_content) = &shape.shape_type { + self.tile_atlas_flushed = true; - // Clipped frames draw strokes in render_shape_exit over children. - let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content; - if !skip_strokes { - // Pass strokes in natural order; stroke merging handles top-most ordering internally. - let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); - strokes::render( - self, + if !text::try_paint_from_layout_cache( + Some(self), + None, shape, - &visible_strokes, Some(target_surface), - antialias, - outset, - )?; + 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 mut paragraph_builders = + text_content.paragraph_builder_group_from_text(None); + text::render( + Some(self), + None, + shape, + &mut paragraph_builders, + Some(target_surface), + None, + None, + None, + None, + )?; + } + } else { + fills::render(self, shape, &shape.fills, antialias, target_surface, None)?; + + // Clipped frames draw strokes in render_shape_exit over children. + let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content; + if !skip_strokes { + // Pass strokes in natural order; stroke merging handles top-most ordering internally. + let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); + strokes::render( + self, + shape, + &visible_strokes, + Some(target_surface), + antialias, + outset, + )?; + } } self.surfaces.apply_mut(target_surface as u32, |s| { @@ -1583,141 +1626,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| { @@ -1725,60 +1665,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, @@ -1786,25 +1678,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()) @@ -1820,7 +1698,7 @@ impl RenderState { stroke_paragraphs, &mut fill_builders, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1831,7 +1709,7 @@ impl RenderState { &shape, stroke_paragraphs, Some(strokes_surface_id), - blur_filter.as_ref(), + None, text_stroke_blur_outset, *layer_opacity, )?; @@ -1843,7 +1721,7 @@ impl RenderState { stroke_paragraphs, Some(strokes_surface_id), None, - blur_filter.as_ref(), + None, text_stroke_blur_outset, None, *layer_opacity, @@ -1861,41 +1739,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| { @@ -2964,6 +3020,7 @@ impl RenderState { None, None, target_surface, + false, )?; } @@ -3115,6 +3172,7 @@ impl RenderState { Some(vec![new_shadow_paint.clone()]), None, target_surface, + false, ) })?; self.surfaces.canvas(SurfaceId::DropShadows).restore(); @@ -3219,6 +3277,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3262,6 +3321,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3325,6 +3385,7 @@ impl RenderState { None, Some(shadow.spread), target_surface, + false, ) })?; @@ -3782,6 +3843,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(), @@ -3794,6 +3858,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 89c232be69..ef41565af7 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, @@ -331,6 +466,8 @@ fn render_text_on_canvas( ) { let layer_bounds = shape.layer_bounds(); + // Layer stack is managed here (blur / shadow / inset). `draw_text` is + // self-contained and only opens a layer when stroke-group opacity needs it. if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); blur_paint.set_image_filter(blur_filter.clone()); @@ -391,8 +528,6 @@ fn render_text_on_canvas( if blur.is_some() { canvas.restore(); } - - canvas.restore(); } /// Paints text fill for vector SVG export. Skips `save_layer` wrappers that @@ -766,20 +901,22 @@ fn draw_text( layer_opacity: Option, overlay_emoji: bool, ) { - let layer_bounds = shape.layer_bounds(); - + // Multi-style spans are already encoded in each ParagraphBuilder's + // TextStyles; paragraph.paint handles them without an isolation layer. + // Only open a save_layer when stroke-group opacity must composite as one. if let Some(opacity) = layer_opacity { + let layer_bounds = shape.layer_bounds(); let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); let layer_rec = SaveLayerRec::default() .bounds(&layer_bounds) .paint(&opacity_paint); canvas.save_layer(&layer_rec); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); + canvas.restore(); } else { - canvas.save_layer(&SaveLayerRec::default().bounds(&layer_bounds)); + paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } - - paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } /// Renders a text stroke masked to the glyph shape. diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 61dfd5d15b..0a64ee037a 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -21,18 +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 { @@ -196,7 +235,9 @@ struct CachedExtrect { #[derive(Debug)] pub struct TextContentLayout { pub paragraph_builders: Vec, - pub paragraphs: Vec>, + /// Shared across shape clones (e.g. modifier transforms) so rotation/pan + /// can paint without rebuilding Skia layout. Cleared builders on clone are OK. + pub paragraphs: Rc>>, cached_extrect: Cell>, } @@ -210,8 +251,8 @@ impl Clone for TextContentLayout { fn clone(&self) -> Self { Self { paragraph_builders: vec![], - paragraphs: vec![], - cached_extrect: Cell::new(None), + paragraphs: Rc::clone(&self.paragraphs), + cached_extrect: Cell::new(self.cached_extrect.get()), } } } @@ -226,7 +267,7 @@ impl TextContentLayout { pub fn new() -> Self { Self { paragraph_builders: vec![], - paragraphs: vec![], + paragraphs: Rc::new(Vec::new()), cached_extrect: Cell::new(None), } } @@ -237,12 +278,18 @@ impl TextContentLayout { paragraphs: Vec>, ) { self.paragraph_builders = paragraph_builders; - self.paragraphs = paragraphs; + self.paragraphs = Rc::new(paragraphs); + self.cached_extrect.set(None); + } + + pub fn clear(&mut self) { + self.paragraph_builders.clear(); + self.paragraphs = Rc::new(Vec::new()); self.cached_extrect.set(None); } pub fn needs_update(&self) -> bool { - self.paragraph_builders.is_empty() || self.paragraphs.is_empty() + self.paragraphs.is_empty() } } @@ -390,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); } @@ -471,7 +532,7 @@ impl TextContent { let mut has_lines = false; let mut y_accum = base_y + vertical_offset; - for group in paragraphs { + for group in paragraphs.iter() { if let Some(paragraph) = group.first() { let line_metrics = paragraph.get_line_metrics(); for line in &line_metrics { @@ -892,6 +953,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, @@ -1918,4 +2014,130 @@ mod tests { assert_eq!(para.char_utf16_len_at(1), 2); 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(); + layout.paragraphs = Rc::new(vec![vec![]]); + let cloned = layout.clone(); + assert!(Rc::ptr_eq(&layout.paragraphs, &cloned.paragraphs)); + assert!(cloned.paragraph_builders.is_empty()); + } + + #[test] + fn layout_clear_empties_paragraphs() { + let mut layout = TextContentLayout::new(); + layout.paragraphs = Rc::new(vec![vec![]]); + layout.clear(); + assert!(layout.needs_update()); + } } 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)?; diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index ecb792f775..0c14c69ae0 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -796,8 +796,7 @@ impl TextEditorState { } } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); @@ -822,8 +821,7 @@ impl TextEditorState { self.selection.set_caret(clamped); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); @@ -844,8 +842,7 @@ impl TextEditorState { self.selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index d7134b3bc2..87e0c61899 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -396,8 +396,7 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { get_text_editor_state().selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); @@ -448,8 +447,7 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { let cursor = get_text_editor_state().selection.focus; text_helpers::insert_text_with_newlines(text_content, &cursor, &text); - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); @@ -517,8 +515,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { get_text_editor_state().selection.set_caret(new_cursor); } - text_content.layout.paragraphs.clear(); - text_content.layout.paragraph_builders.clear(); + text_content.layout.clear(); get_text_editor_state().reset_blink(); get_text_editor_state().push_event(TextEditorEvent::ContentChanged);