From c59bd644b0ff4cea7015291e8200547636ad4873 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Thu, 11 Jun 2026 18:28:16 +0200 Subject: [PATCH] :zap: Cache paragraphs to improve nested frames with texts performance --- render-wasm/src/render/text.rs | 116 +++++++++++++++++++++- render-wasm/src/shapes/text.rs | 173 +++++++++++++++++++++------------ 2 files changed, 226 insertions(+), 63 deletions(-) diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 4f2d521179..3bd8cac2c5 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -3,8 +3,8 @@ use crate::{ error::Result, math::Rect, shapes::{ - calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout, Stroke, - StrokeKind, TextContent, + calculate_text_layout_data, position_cached_paragraphs, set_paint_fill, GrowType, + ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -300,6 +300,118 @@ pub fn render( ) } +pub fn render_fill_cached( + render_state: &mut RenderState, + shape: &Shape, + text_content: &TextContent, + surface_id: Option, + blur: Option<&ImageFilter>, + fill_inset: Option, +) -> Result<()> { + if text_content.layout.paragraphs.is_empty() || text_content.grow_type() == GrowType::AutoWidth + { + let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + return render( + Some(render_state), + None, + shape, + &mut paragraph_builders, + surface_id, + None, + blur, + fill_inset, + None, + ); + } + + let target_surface = surface_id.unwrap_or(SurfaceId::Fills); + let groups = &text_content.layout.paragraphs; + + if let Some(blur_filter) = blur { + let text_bounds = shape + .get_text_content() + .calculate_bounds(shape, false) + .to_rect(); + let bounds = blur_filter.compute_fast_bounds(text_bounds); + if bounds.is_finite() && bounds.width() > 0.0 && bounds.height() > 0.0 { + let blur_filter_clone = blur_filter.clone(); + if filters::render_with_filter_surface( + render_state, + bounds, + target_surface, + |state, temp_surface| { + let temp_canvas = state.surfaces.canvas(temp_surface); + paint_fill_cached_on_canvas( + temp_canvas, + shape, + groups, + Some(&blur_filter_clone), + fill_inset, + ); + Ok(()) + }, + )? { + return Ok(()); + } + } + } + + let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); + paint_fill_cached_on_canvas(canvas, shape, groups, blur, fill_inset); + Ok(()) +} + +fn paint_fill_cached_on_canvas( + canvas: &Canvas, + shape: &Shape, + groups: &[Vec], + blur: Option<&ImageFilter>, + fill_inset: Option, +) { + if let Some(blur_filter) = blur { + let mut blur_paint = Paint::default(); + blur_paint.set_image_filter(blur_filter.clone()); + canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); + } + + if let Some(eps) = fill_inset.filter(|&e| e > 0.0) { + if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { + let mut layer_paint = Paint::default(); + layer_paint.set_image_filter(erode); + canvas.save_layer(&SaveLayerRec::default().paint(&layer_paint)); + draw_cached_fill(canvas, shape, groups); + canvas.restore(); + } else { + draw_cached_fill(canvas, shape, groups); + } + } else { + draw_cached_fill(canvas, shape, groups); + } + + if blur.is_some() { + canvas.restore(); + } + + canvas.restore(); +} + +fn draw_cached_fill(canvas: &Canvas, shape: &Shape, groups: &[Vec]) { + canvas.save_layer(&SaveLayerRec::default()); + for para in position_cached_paragraphs(shape, groups) { + para.paragraph.paint(canvas, (para.x, para.y)); + for deco in ¶.decorations { + draw_text_decorations( + canvas, + &deco.text_style, + Some(deco.y), + deco.thickness, + deco.left, + deco.width, + ); + } + } +} + /// Like [`render`] but rasterizes color emoji as bitmap overlays for PDF/vector export. #[allow(clippy::too_many_arguments)] pub fn render_overlay_emoji( diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 86159ac3df..ec5f5d035b 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1395,6 +1395,13 @@ pub struct ParagraphLayout { pub decorations: Vec, } +pub struct ParagraphLayoutRef<'a> { + pub paragraph: &'a skia::textlayout::Paragraph, + pub x: f32, + pub y: f32, + pub decorations: Vec, +} + #[derive(Debug)] pub struct TextLayoutData { pub position_data: Vec, @@ -1408,6 +1415,110 @@ fn direction_to_int(direction: TextDirection) -> u32 { } } +fn paragraph_decorations( + skia_paragraph: &skia::textlayout::Paragraph, + x: f32, + y_top: f32, +) -> Vec { + let mut decorations = Vec::new(); + let line_metrics = skia_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_top + 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 = skia_paragraph.get_rects_for_range( + seg_start..seg_end, + RectHeightStyle::Tight, + 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; + use skia::textlayout::TextDecoration; + if text_style.decoration().ty == TextDecoration::UNDERLINE { + decorations.push(TextDecorationSegment { + kind: TextDecoration::UNDERLINE, + text_style: (*text_style).clone(), + y: underline_y.unwrap_or(line_baseline), + thickness: max_underline_thickness, + left: text_left, + width: text_width, + }); + } + if text_style.decoration().ty == TextDecoration::LINE_THROUGH { + decorations.push(TextDecorationSegment { + kind: TextDecoration::LINE_THROUGH, + text_style: (*text_style).clone(), + y: strike_y.unwrap_or(line_baseline), + thickness: max_strike_thickness, + left: text_left, + width: text_width, + }); + } + } + } + decorations +} + +pub fn position_cached_paragraphs<'a>( + shape: &Shape, + built_groups: &'a [Vec], +) -> Vec> { + let selrect_height = shape.selrect().height(); + let x = shape.selrect.x(); + let base_y = shape.selrect.y(); + let paragraph_heights: Vec = built_groups + .iter() + .map(|group| group.first().map(|p| p.height()).unwrap_or(0.0)) + .collect(); + let total_text_height: f32 = paragraph_heights.iter().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 layouts: Vec> = Vec::new(); + let mut y_accum = base_y + vertical_offset; + for (i, group) in built_groups.iter().enumerate() { + for paragraph in group.iter() { + let decorations = paragraph_decorations(paragraph, x, y_accum); + layouts.push(ParagraphLayoutRef { + paragraph, + x, + y: y_accum, + decorations, + }); + } + y_accum += paragraph_heights[i]; + } + layouts +} + pub fn calculate_text_layout_data( shape: &Shape, text_content: &TextContent, @@ -1463,67 +1574,7 @@ pub fn calculate_text_layout_data( // For each paragraph in the group (e.g., fill, stroke, etc.) for skia_paragraph in group_paragraphs.into_iter() { // Calculate text decorations for this paragraph - let mut decorations = Vec::new(); - let line_metrics = skia_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 = skia_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; - use skia::textlayout::TextDecoration; - if text_style.decoration().ty == TextDecoration::UNDERLINE { - decorations.push(TextDecorationSegment { - kind: TextDecoration::UNDERLINE, - text_style: (*text_style).clone(), - y: underline_y.unwrap_or(line_baseline), - thickness: max_underline_thickness, - left: text_left, - width: text_width, - }); - } - if text_style.decoration().ty == TextDecoration::LINE_THROUGH { - decorations.push(TextDecorationSegment { - kind: TextDecoration::LINE_THROUGH, - text_style: (*text_style).clone(), - y: strike_y.unwrap_or(line_baseline), - thickness: max_strike_thickness, - left: text_left, - width: text_width, - }); - } - } - } + let decorations = paragraph_decorations(&skia_paragraph, x, y_accum); paragraph_layouts.push(ParagraphLayout { paragraph: skia_paragraph, x,