diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js b/frontend/playwright/ui/render-wasm-specs/texts.spec.js index 23a4701bbd..b474c7ca05 100644 --- a/frontend/playwright/ui/render-wasm-specs/texts.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/texts.spec.js @@ -209,7 +209,10 @@ test("Renders a file with emoji and text decoration", async ({ page }) => { pageId: "82d128e1-d3b1-80a5-8006-ae60fedcd5e8", }); await workspace.waitForFirstRenderWithoutUI(); - await expect(workspace.canvas).toHaveScreenshot(); + await expect(workspace.canvas).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0.1, + }); }); test("Renders a file with multiple emoji", async ({ page }) => { diff --git a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png index c2ed2f2321..70ba1f8cbf 100644 Binary files a/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png and b/frontend/playwright/ui/render-wasm-specs/texts.spec.js-snapshots/Renders-a-file-with-emoji-and-text-decoration-1.png differ diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 53d5b65312..a75d2fcb8e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1026,10 +1026,8 @@ impl RenderState { .as_ref() .ok_or(Error::CriticalError("Current tile not found".to_string()))?; - // Force pending GPU work (text glyph-atlas uploads) - if !self.tile_atlas_flushed { + if self.tile_atlas_flushed { crate::get_gpu_state().context.flush_and_submit(); - self.tile_atlas_flushed = true; } self.surfaces.draw_current_tile_into_tile_atlas( @@ -1415,6 +1413,8 @@ impl RenderState { } Type::Text(stored_text_content) => { + self.tile_atlas_flushed = true; + self.surfaces.apply_mut(surface_ids, |s| { s.canvas().concat(&matrix); }); @@ -1471,14 +1471,12 @@ impl RenderState { .enumerate() { if stroke_kinds[i] == StrokeKind::Inner { - let mut mask_builders = text_content.paragraph_builder_group_opaque(); let mut fill_builders = text_content.paragraph_builder_group_from_text(None); text::render_inner_stroke( Some(self), None, &shape, - &mut mask_builders, stroke_paragraphs, &mut fill_builders, Some(strokes_surface_id), @@ -1486,6 +1484,17 @@ impl RenderState { 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), @@ -1501,6 +1510,20 @@ impl RenderState { )?; } } + + 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 mut drop_shadows = shape.drop_shadow_paints(); @@ -1607,15 +1630,12 @@ impl RenderState { .enumerate() { if stroke_kinds[i] == StrokeKind::Inner { - let mut mask_builders = - text_content.paragraph_builder_group_opaque(); let mut fill_builders = text_content.paragraph_builder_group_from_text(None); text::render_inner_stroke( Some(self), None, &shape, - &mut mask_builders, stroke_paragraphs, &mut fill_builders, Some(strokes_surface_id), @@ -1623,6 +1643,17 @@ impl RenderState { 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), @@ -1639,6 +1670,20 @@ impl RenderState { } } + 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, + blur_filter.as_ref(), + ); + } + // 5. Stroke inner shadows shadows::render_text_shadows( self, @@ -2092,7 +2137,6 @@ impl RenderState { self.surfaces.atlas.set_doc_bounds(doc_bounds); self.cache_cleared_this_render = false; - self.tile_atlas_flushed = false; let preserve_target = self.preserve_target_during_render; self.preserve_target_during_render = false; @@ -3575,6 +3619,7 @@ impl RenderState { // a resumed-from-yield case rather than a genuinely // empty tile. self.current_tile_had_shapes = false; + self.tile_atlas_flushed = false; let viewer_masked_pass = self.viewer_masked_pass(); diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index 6965f16ed0..c3de172ad8 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -165,13 +165,11 @@ pub fn render_text_shadows( for (i, stroke_paragraphs) in stroke_paragraphs_group.iter_mut().enumerate() { if i < stroke_kinds.len() && stroke_kinds[i] == StrokeKind::Inner { - let mut mask_builders = text_content.paragraph_builder_group_opaque(); let mut fill_builders = text_content.paragraph_builder_group_from_text(Some(true)); text::render_inner_stroke( None, Some(canvas), shape, - &mut mask_builders, stroke_paragraphs, &mut fill_builders, surface_id, @@ -179,6 +177,17 @@ pub fn render_text_shadows( 0.0, None, )?; + } else if i < stroke_kinds.len() && stroke_kinds[i] == StrokeKind::Outer { + text::render_outer_stroke( + None, + Some(canvas), + shape, + stroke_paragraphs, + surface_id, + blur_filter.as_ref(), + 0.0, + None, + )?; } else { text::render( None, diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index d70993e6b9..d972e69813 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -95,8 +95,6 @@ fn get_text_stroke_paints( match stroke.kind { StrokeKind::Inner => { - // Just the stroke paint — mask+SrcIn+DstOver layering is handled - // by render_inner_stroke_on_canvas. let mut paint = skia::Paint::default(); paint.set_style(skia::PaintStyle::Stroke); paint.set_anti_alias(true); @@ -120,17 +118,14 @@ fn get_text_stroke_paints( StrokeKind::Outer => { let mut paint = skia::Paint::default(); paint.set_style(skia::PaintStyle::Stroke); - paint.set_blend_mode(skia::BlendMode::DstOver); paint.set_anti_alias(true); paint.set_stroke_width(stroke.width * 2.0); - fill_for_paint(&mut paint); - paints.push(paint); - - let mut paint = skia::Paint::default(); - paint.set_style(skia::PaintStyle::Fill); - paint.set_blend_mode(skia::BlendMode::Clear); - paint.set_color(skia::Color::TRANSPARENT); - paint.set_anti_alias(true); + if remove_stroke_alpha { + paint.set_color(skia::Color::BLACK); + paint.set_alpha(255); + } else { + fill_for_paint(&mut paint); + } paints.push(paint); } } @@ -408,10 +403,10 @@ fn paint_text_with_emoji_overlay( overlay_emoji: bool, ) { let text_content = shape.get_text_content(); - let layout_info = + let mut layout_info = calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); - for para in &layout_info.paragraphs { + for para in &mut layout_info.paragraphs { para.paragraph.paint(canvas, (para.x, para.y)); if overlay_emoji { @@ -431,11 +426,48 @@ fn paint_text_with_emoji_overlay( } } +fn paragraph_has_emoji(paragraph: &mut skia::textlayout::Paragraph) -> bool { + let line_bounds: Vec<(usize, usize)> = paragraph + .get_line_metrics() + .iter() + .map(|l| (l.start_index, l.end_index)) + .collect(); + line_bounds + .into_iter() + .any(|(start, end)| !line_emoji_ranges(paragraph, start, end).is_empty()) +} + +fn line_emoji_ranges( + paragraph: &mut skia::textlayout::Paragraph, + line_start: usize, + line_end: usize, +) -> Vec<(usize, usize)> { + let mut merged: Vec<(usize, usize)> = Vec::new(); + for idx in line_start..line_end { + let font = paragraph.get_font_at_utf16_offset(idx); + let normalized = font + .typeface() + .family_name() + .to_lowercase() + .replace(' ', "-"); + if !normalized.contains(DEFAULT_EMOJI_FONT) { + continue; + } + + match merged.last_mut() { + Some(last) if last.1 == idx => last.1 = idx + 1, + _ => merged.push((idx, idx + 1)), + } + } + + merged +} + /// Rasterizes color emoji runs as bitmap overlays. Skia's PDF backend can't /// embed COLR/CBDT color glyphs, so each emoji is drawn to a raster surface and /// blitted; `paragraph.paint()` already wrote placeholder glyphs (keeps text /// selectable). -fn paint_emoji_overlay(canvas: &Canvas, para: &ParagraphLayout) { +fn paint_emoji_overlay(canvas: &Canvas, para: &mut ParagraphLayout) { let line_metrics = para.paragraph.get_line_metrics(); // Rasterize at TARGET_DPI relative to the emoji's on-page size (72 user @@ -450,45 +482,18 @@ fn paint_emoji_overlay(canvas: &Canvas, para: &ParagraphLayout) { let sy = (ctm.skew_x().powi(2) + ctm.scale_y().powi(2)).sqrt(); let output_scale = sx.max(sy).max(1.0); - for line in &line_metrics { - let style_runs = line.get_style_metrics(line.start_index..line.end_index); + let line_bounds: Vec<(usize, usize)> = line_metrics + .iter() + .map(|l| (l.start_index, l.end_index)) + .collect(); + drop(line_metrics); - // Build a list of (start, end, is_emoji) for each style run. - let mut run_info: Vec<(usize, usize, bool)> = Vec::new(); - for (i, (start_idx, _style_metric)) in style_runs.iter().enumerate() { - let end_idx = style_runs.get(i + 1).map_or(line.end_index, |next| next.0); - if *start_idx >= end_idx { - continue; - } - - let font = para.paragraph.get_font_at(*start_idx); - let family_name = font.typeface().family_name(); - - let normalized = family_name.to_lowercase().replace(' ', "-"); - let is_emoji = normalized.contains(DEFAULT_EMOJI_FONT); - run_info.push((*start_idx, end_idx, is_emoji)); - } - - // Merge consecutive emoji runs: Skia splits ZWJ sequences (e.g. 👩🏿‍🚀) - // per codepoint, but `get_rects_for_range` needs the full cluster range. - let mut merged_emoji_ranges: Vec<(usize, usize)> = Vec::new(); - for &(start, end, is_emoji) in &run_info { - if is_emoji { - if let Some(last) = merged_emoji_ranges.last_mut() { - if last.1 == start { - // Extend the previous range - last.1 = end; - continue; - } - } - merged_emoji_ranges.push((start, end)); - } - } - - for (range_start, range_end) in &merged_emoji_ranges { + for (line_start, line_end) in line_bounds { + for (range_start, range_end) in line_emoji_ranges(&mut para.paragraph, line_start, line_end) + { // Get the bounding rects for this (possibly merged) emoji run let rects = para.paragraph.get_rects_for_range( - *range_start..*range_end, + range_start..range_end, skia::textlayout::RectHeightStyle::Tight, skia::textlayout::RectWidthStyle::Tight, ); @@ -542,6 +547,162 @@ fn paint_emoji_overlay(canvas: &Canvas, para: &ParagraphLayout) { } } +fn draw_decoration_stroke( + canvas: &Canvas, + kind: StrokeKind, + stroke_paint: &Paint, + bar: skia::Rect, +) { + if kind == StrokeKind::Center { + canvas.draw_rect(bar, stroke_paint); + return; + } + + let blend = if kind == StrokeKind::Inner { + skia::BlendMode::SrcIn + } else { + skia::BlendMode::SrcOut + }; + + canvas.save_layer(&SaveLayerRec::default()); + let mut mask_paint = Paint::default(); + mask_paint.set_color(skia::Color::BLACK); + mask_paint.set_anti_alias(true); + canvas.draw_rect(bar, &mask_paint); + + let mut blend_paint = Paint::default(); + blend_paint.set_blend_mode(blend); + canvas.save_layer(&SaveLayerRec::default().paint(&blend_paint)); + canvas.draw_rect(bar, stroke_paint); + canvas.restore(); + canvas.restore(); +} + +fn paint_emoji_opaque( + canvas: &Canvas, + emoji_para: &mut ParagraphLayout, + deco_para: &ParagraphLayout, + stroke_decos: &[(StrokeKind, Paint)], +) { + let line_bounds: Vec<(usize, usize)> = emoji_para + .paragraph + .get_line_metrics() + .iter() + .map(|l| (l.start_index, l.end_index)) + .collect(); + + let mut clip = skia::PathBuilder::new(); + let mut has_emoji = false; + for (line_start, line_end) in line_bounds { + for (range_start, range_end) in + line_emoji_ranges(&mut emoji_para.paragraph, line_start, line_end) + { + let rects = emoji_para.paragraph.get_rects_for_range( + range_start..range_end, + skia::textlayout::RectHeightStyle::Tight, + skia::textlayout::RectWidthStyle::Tight, + ); + + for text_box in &rects { + let r = &text_box.rect; + if r.width() <= 0.0 || r.height() <= 0.0 { + continue; + } + clip.add_rect( + skia::Rect::from_xywh( + emoji_para.x + r.left, + emoji_para.y + r.top, + r.width(), + r.height(), + ), + None, + None, + ); + has_emoji = true; + } + } + } + + if !has_emoji { + return; + } + + canvas.save(); + canvas.clip_path(&clip.detach(), skia::ClipOp::Intersect, true); + emoji_para + .paragraph + .paint(canvas, (emoji_para.x, emoji_para.y)); + + for deco in &deco_para.decorations { + draw_text_decorations( + canvas, + &deco.text_style, + Some(deco.y), + deco.thickness, + deco.left, + deco.width, + ); + let r = decoration_rect(deco.y, deco.thickness, deco.left, deco.width); + for (kind, paint) in stroke_decos { + draw_decoration_stroke(canvas, *kind, paint, r); + } + } + canvas.restore(); +} + +pub fn render_emoji_overlay( + render_state: &mut RenderState, + shape: &Shape, + emoji_builders: &mut [Vec], + deco_builders: &mut [Vec], + surface_id: SurfaceId, + blur: Option<&ImageFilter>, +) { + let text_content = shape.get_text_content(); + let mut emoji_layout = calculate_text_layout_data(shape, text_content, emoji_builders, true); + + if !emoji_layout + .paragraphs + .iter_mut() + .any(|para| paragraph_has_emoji(&mut para.paragraph)) + { + return; + } + + let selrect = shape.selrect(); + let mut stroke_decos: Vec<(StrokeKind, Paint)> = Vec::new(); + for stroke in shape.visible_strokes().rev() { + let (paints, opacity) = get_text_stroke_paints(stroke, &selrect, false); + for mut paint in paints { + if let Some(opacity) = opacity { + paint.set_alpha_f(opacity); + } + stroke_decos.push((stroke.kind, paint)); + } + } + + let deco_layout = calculate_text_layout_data(shape, text_content, deco_builders, true); + let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); + + 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)); + } + + for (emoji_para, deco_para) in emoji_layout + .paragraphs + .iter_mut() + .zip(deco_layout.paragraphs.iter()) + { + paint_emoji_opaque(canvas, emoji_para, deco_para, &stroke_decos); + } + + if blur.is_some() { + canvas.restore(); + } +} + fn draw_text( canvas: &Canvas, shape: &Shape, @@ -561,27 +722,22 @@ fn draw_text( paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, overlay_emoji); } -/// Renders an inner stroke using mask + SrcIn + DstOver layer structure. +/// Renders a text stroke masked to the glyph shape. /// -/// Layer structure: -/// saveLayer() — outer layer -/// saveLayer() — mask group (isolation) -/// paint mask — opaque fill as clip mask -/// saveLayer(SrcIn) — clips stroke to mask shape -/// paint stroke -/// saveLayer(DstOver) — fill behind the stroke -/// paint fill -/// restore -/// restore -/// restore -/// restore +/// `stroke_mask_blend` selects which side of the glyph the stroke keeps: +/// `SrcIn` for inner strokes (stroke clipped to the glyph), `SrcOut` for outer +/// strokes (stroke kept outside the glyph). When `fill_builders` is provided +/// (inner strokes) the fill is composited with `DstOver` *inside* the masked +/// layer so its anti-aliased edge aligns with the stroke — no seam at the glyph +/// boundary. Outer strokes pass `None` (fill is drawn separately). #[allow(clippy::too_many_arguments)] -fn render_inner_stroke_on_canvas( +fn render_masked_stroke_on_canvas( canvas: &Canvas, shape: &Shape, mask_builders: &mut [Vec], stroke_builders: &mut [Vec], - fill_builders: &mut [Vec], + fill_builders: Option<&mut [Vec]>, + stroke_mask_blend: skia::BlendMode, blur: Option<&ImageFilter>, layer_opacity: Option, ) { @@ -591,60 +747,59 @@ fn render_inner_stroke_on_canvas( canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); } - // Opacity layer wraps the entire composition if let Some(opacity) = layer_opacity { let mut opacity_paint = Paint::default(); opacity_paint.set_alpha_f(opacity); canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint)); } - // Outer layer canvas.save_layer(&SaveLayerRec::default()); - // Mask group layer (isolates mask from parent surface content) canvas.save_layer(&SaveLayerRec::default()); - // Draw opaque mask (full alpha text shape) paint_text(canvas, shape, mask_builders); - // SrcIn layer — only keeps stroke pixels where mask has alpha - let mut src_in_paint = Paint::default(); - src_in_paint.set_blend_mode(skia::BlendMode::SrcIn); - canvas.save_layer(&SaveLayerRec::default().paint(&src_in_paint)); + let mut stroke_paint = Paint::default(); + stroke_paint.set_blend_mode(stroke_mask_blend); + canvas.save_layer(&SaveLayerRec::default().paint(&stroke_paint)); - // Draw stroke paint_text(canvas, shape, stroke_builders); - // Fill with DstOver (behind the stroke, inside SrcIn) - let mut dst_over_paint = Paint::default(); - dst_over_paint.set_blend_mode(skia::BlendMode::DstOver); - canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint)); + // Fill with DstOver behind the stroke, inside the masked layer so the fill's + // anti-aliased edge aligns with the stroke (no seam at the glyph edge). + // Outer strokes have no fill here (`None`). + if let Some(fill_builders) = fill_builders { + let mut dst_over_paint = Paint::default(); + dst_over_paint.set_blend_mode(skia::BlendMode::DstOver); + canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint)); - paint_text(canvas, shape, fill_builders); + paint_text(canvas, shape, fill_builders); - canvas.restore(); // DstOver layer - canvas.restore(); // SrcIn layer + canvas.restore(); // DstOver layer + } + + canvas.restore(); // SrcIn / SrcOut layer canvas.restore(); // mask group layer canvas.restore(); // outer layer if layer_opacity.is_some() { - canvas.restore(); // opacity layer + canvas.restore(); } if blur.is_some() { - canvas.restore(); // blur layer + canvas.restore(); } } -/// Public API for rendering inner strokes with mask+SrcIn+DstOver approach. #[allow(clippy::too_many_arguments)] -pub fn render_inner_stroke( +fn render_masked_stroke( render_state: Option<&mut RenderState>, canvas: Option<&Canvas>, shape: &Shape, mask_builders: &mut [Vec], stroke_builders: &mut [Vec], - fill_builders: &mut [Vec], + mut fill_builders: Option<&mut [Vec]>, + stroke_mask_blend: skia::BlendMode, surface_id: Option, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, @@ -664,18 +819,20 @@ pub fn render_inner_stroke( 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(); + let fill_builders = &mut fill_builders; if filters::render_with_filter_surface( render_state, bounds, target_surface, |state, temp_surface| { let temp_canvas = state.surfaces.canvas(temp_surface); - render_inner_stroke_on_canvas( + render_masked_stroke_on_canvas( temp_canvas, shape, mask_builders, stroke_builders, - fill_builders, + fill_builders.as_deref_mut(), + stroke_mask_blend, Some(&blur_filter_clone), layer_opacity, ); @@ -688,12 +845,13 @@ pub fn render_inner_stroke( } let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); - render_inner_stroke_on_canvas( + render_masked_stroke_on_canvas( canvas, shape, mask_builders, stroke_builders, - fill_builders, + fill_builders.as_deref_mut(), + stroke_mask_blend, blur, layer_opacity, ); @@ -701,12 +859,13 @@ pub fn render_inner_stroke( } if let Some(canvas) = canvas { - render_inner_stroke_on_canvas( + render_masked_stroke_on_canvas( canvas, shape, mask_builders, stroke_builders, fill_builders, + stroke_mask_blend, blur, layer_opacity, ); @@ -714,6 +873,70 @@ pub fn render_inner_stroke( Ok(()) } +#[allow(clippy::too_many_arguments)] +pub fn render_inner_stroke( + render_state: Option<&mut RenderState>, + canvas: Option<&Canvas>, + shape: &Shape, + stroke_builders: &mut [Vec], + fill_builders: &mut [Vec], + surface_id: Option, + blur: Option<&ImageFilter>, + stroke_bounds_outset: f32, + layer_opacity: Option, +) -> Result<()> { + let mut mask_builders = shape.get_text_content().paragraph_builder_group_opaque(); + render_masked_stroke( + render_state, + canvas, + shape, + &mut mask_builders, + stroke_builders, + Some(fill_builders), + skia::BlendMode::SrcIn, + surface_id, + blur, + stroke_bounds_outset, + layer_opacity, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn render_outer_stroke( + render_state: Option<&mut RenderState>, + canvas: Option<&Canvas>, + shape: &Shape, + stroke_builders: &mut [Vec], + surface_id: Option, + blur: Option<&ImageFilter>, + stroke_bounds_outset: f32, + layer_opacity: Option, +) -> Result<()> { + let mut mask_builders = shape.get_text_content().paragraph_builder_group_opaque(); + render_masked_stroke( + render_state, + canvas, + shape, + &mut mask_builders, + stroke_builders, + None, + skia::BlendMode::SrcOut, + surface_id, + blur, + stroke_bounds_outset, + layer_opacity, + ) +} + +fn decoration_rect(y: f32, thickness: f32, text_left: f32, text_width: f32) -> skia_safe::Rect { + skia_safe::Rect::new( + text_left, + y - thickness / 2.0, + text_left + text_width, + y + thickness / 2.0, + ) +} + fn draw_text_decorations( canvas: &Canvas, text_style: &TextStyle, @@ -723,12 +946,7 @@ fn draw_text_decorations( text_width: f32, ) { if let Some(y) = y { - let r = skia_safe::Rect::new( - text_left, - y - thickness / 2.0, - text_left + text_width, - y + thickness / 2.0, - ); + let r = decoration_rect(y, thickness, text_left, text_width); let mut decoration_paint = text_style.foreground(); decoration_paint.set_anti_alias(true); canvas.draw_rect(r, &decoration_paint); diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 721a02fbc5..04d91552d9 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -194,14 +194,12 @@ impl ShapeRenderer for VectorRenderer<'_> { for (kind, stroke_paragraphs) in &mut stroke_shadow_groups { if *kind == StrokeKind::Inner { // Inner stroke masked by the glyph fill (outset 0 here). - let mut mask_builders = text_content.paragraph_builder_group_opaque(); let mut fill_builders = text_content.paragraph_builder_group_from_text(Some(true)); text::render_inner_stroke( None, Some(self.canvas), shape, - &mut mask_builders, stroke_paragraphs, &mut fill_builders, None, @@ -209,6 +207,17 @@ impl ShapeRenderer for VectorRenderer<'_> { 0.0, None, )?; + } else if *kind == StrokeKind::Outer { + text::render_outer_stroke( + None, + Some(self.canvas), + shape, + stroke_paragraphs, + None, + blur_filter.as_ref(), + 0.0, + None, + )?; } else { text::render_with_bounds_outset_overlay_emoji( self.canvas, @@ -250,13 +259,11 @@ impl ShapeRenderer for VectorRenderer<'_> { ); if stroke.render_kind(false) == StrokeKind::Inner { // Inner text stroke: clip to the glyph fill, else it bleeds out. - let mut mask_builders = text_content.paragraph_builder_group_opaque(); let mut fill_builders = text_content.paragraph_builder_group_from_text(None); text::render_inner_stroke( None, Some(self.canvas), shape, - &mut mask_builders, &mut stroke_paragraphs, &mut fill_builders, None, @@ -264,6 +271,17 @@ impl ShapeRenderer for VectorRenderer<'_> { stroke_blur_outset, layer_opacity, )?; + } else if stroke.render_kind(false) == StrokeKind::Outer { + text::render_outer_stroke( + None, + Some(self.canvas), + shape, + &mut stroke_paragraphs, + None, + blur_filter.as_ref(), + stroke_blur_outset, + layer_opacity, + )?; } else { text::render_with_bounds_outset_overlay_emoji( self.canvas, diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 484acf09f3..1ba1381172 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -400,6 +400,13 @@ impl TextContent { &self.paragraphs } + pub fn has_non_ascii(&self) -> bool { + self.paragraphs + .iter() + .flat_map(|p| p.children()) + .any(|span| !span.text.is_ascii()) + } + pub fn paragraphs_mut(&mut self) -> &mut Vec { self.content_version = self.content_version.wrapping_add(1); &mut self.paragraphs