🎉 Improve text inner stroke rendering

This commit is contained in:
Elena Torro
2026-03-31 13:00:30 +02:00
committed by Belén Albeza
parent cbe3a3f33e
commit 68760c8e26
4 changed files with 330 additions and 113 deletions

View File

@@ -26,7 +26,7 @@ use crate::error::{Error, Result};
use crate::performance;
use crate::shapes::{
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
Stroke, Type,
Stroke, StrokeKind, Type,
};
use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
use crate::tiles::{self, PendingTiles, TileRect};
@@ -1030,6 +1030,8 @@ impl RenderState {
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<StrokeKind> =
shape.visible_strokes().rev().map(|s| s.kind).collect();
let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape
.visible_strokes()
.rev()
@@ -1038,7 +1040,6 @@ impl RenderState {
&text_content,
stroke,
&shape.selrect(),
count_inner_strokes,
None,
)
})
@@ -1057,22 +1058,41 @@ impl RenderState {
None,
)?;
for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list
for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list
.iter_mut()
.zip(stroke_opacities.iter())
.enumerate()
{
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 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),
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,
)?;
}
}
} else {
let mut drop_shadows = shape.drop_shadow_paints();
@@ -1096,7 +1116,6 @@ impl RenderState {
&text_content,
stroke,
&shape.selrect(),
count_inner_strokes,
Some(true),
)
})
@@ -1126,6 +1145,8 @@ impl RenderState {
text_drop_shadows_surface_id.into(),
&parent_shadows,
&blur_filter,
&stroke_kinds,
&text_content,
)?;
}
} else {
@@ -1168,25 +1189,47 @@ impl RenderState {
text_drop_shadows_surface_id.into(),
&drop_shadows,
&blur_filter,
&stroke_kinds,
&text_content,
)?;
// 4. Stroke fills
for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list
for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list
.iter_mut()
.zip(stroke_opacities.iter())
.enumerate()
{
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 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),
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,
)?;
}
}
// 5. Stroke inner shadows
@@ -1198,6 +1241,8 @@ impl RenderState {
Some(innershadows_surface_id),
&inner_shadows,
&blur_filter,
&stroke_kinds,
&text_content,
)?;
// 6. Fill Inner shadows

View File

@@ -1,6 +1,6 @@
use super::{RenderState, SurfaceId};
use crate::render::strokes;
use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, Type};
use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, StrokeKind, TextContent, Type};
use skia_safe::{canvas::SaveLayerRec, Paint, Path};
use crate::error::Result;
@@ -127,6 +127,7 @@ fn render_shadow_paint(
}
}
#[allow(clippy::too_many_arguments)]
pub fn render_text_shadows(
render_state: &mut RenderState,
shape: &Shape,
@@ -135,6 +136,8 @@ pub fn render_text_shadows(
surface_id: Option<SurfaceId>,
shadows: &[Paint],
blur_filter: &Option<skia_safe::ImageFilter>,
stroke_kinds: &[StrokeKind],
text_content: &TextContent,
) -> Result<()> {
if stroke_paragraphs_group.is_empty() {
return Ok(());
@@ -160,18 +163,35 @@ pub fn render_text_shadows(
None,
)?;
for stroke_paragraphs in stroke_paragraphs_group.iter_mut() {
text::render(
None,
Some(canvas),
shape,
stroke_paragraphs,
surface_id,
None,
blur_filter.as_ref(),
None,
None,
)?;
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,
blur_filter.as_ref(),
0.0,
None,
)?;
} else {
text::render(
None,
Some(canvas),
shape,
stroke_paragraphs,
surface_id,
None,
blur_filter.as_ref(),
None,
None,
)?;
}
}
canvas.restore();

View File

@@ -3,8 +3,8 @@ use crate::{
error::Result,
math::Rect,
shapes::{
calculate_position_data, calculate_text_layout_data, merge_fills, set_paint_fill,
ParagraphBuilderGroup, Stroke, StrokeKind, TextContent,
calculate_position_data, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup,
Stroke, StrokeKind, TextContent,
},
utils::{get_fallback_fonts, get_font_collection},
};
@@ -19,7 +19,6 @@ pub fn stroke_paragraph_builder_group_from_text(
text_content: &TextContent,
stroke: &Stroke,
bounds: &Rect,
count_inner_strokes: usize,
use_shadow: Option<bool>,
) -> (Vec<ParagraphBuilderGroup>, Option<f32>) {
let fallback_fonts = get_fallback_fonts();
@@ -33,14 +32,8 @@ pub fn stroke_paragraph_builder_group_from_text(
std::collections::HashMap::new();
for span in paragraph.children().iter() {
let text_paint: skia_safe::Handle<_> = merge_fills(span.fills(), *bounds);
let (stroke_paints, stroke_layer_opacity) = get_text_stroke_paints(
stroke,
bounds,
&text_paint,
count_inner_strokes,
remove_stroke_alpha,
);
let (stroke_paints, stroke_layer_opacity) =
get_text_stroke_paints(stroke, bounds, remove_stroke_alpha);
if group_layer_opacity.is_none() {
group_layer_opacity = stroke_layer_opacity;
@@ -79,8 +72,6 @@ pub fn stroke_paragraph_builder_group_from_text(
fn get_text_stroke_paints(
stroke: &Stroke,
bounds: &Rect,
text_paint: &Paint,
count_inner_strokes: usize,
remove_stroke_alpha: bool,
) -> (Vec<Paint>, Option<f32>) {
let mut paints = Vec::new();
@@ -104,56 +95,19 @@ fn get_text_stroke_paints(
match stroke.kind {
StrokeKind::Inner => {
let shader = text_paint.shader();
let mut is_opaque = true;
if let Some(shader) = shader {
is_opaque = shader.is_opaque();
}
if is_opaque && count_inner_strokes == 1 {
let mut paint = text_paint.clone();
paint.set_style(skia::PaintStyle::Fill);
paint.set_anti_alias(true);
paints.push(paint);
let mut paint = skia::Paint::default();
paint.set_style(skia::PaintStyle::Stroke);
paint.set_blend_mode(skia::BlendMode::SrcIn);
paint.set_anti_alias(true);
paint.set_stroke_width(stroke.width * 2.0);
fill_for_paint(&mut paint);
paints.push(paint);
} else {
let mut paint = skia::Paint::default();
if remove_stroke_alpha {
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
} else {
paint = text_paint.clone();
if needs_opacity_layer {
let opaque_fill = stroke.fill.with_full_opacity();
set_paint_fill(&mut paint, &opaque_fill, bounds, false);
} else {
set_paint_fill(&mut paint, &stroke.fill, bounds, false);
}
}
paint.set_style(skia::PaintStyle::Fill);
paint.set_anti_alias(false);
paints.push(paint);
let mut paint = skia::Paint::default();
let image_filter =
skia_safe::image_filters::erode((stroke.width, stroke.width), None, None);
paint.set_image_filter(image_filter);
paint.set_anti_alias(false);
// 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);
paint.set_stroke_width(stroke.width * 2.0);
if remove_stroke_alpha {
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
paint.set_blend_mode(skia::BlendMode::DstOut);
paints.push(paint);
} else {
fill_for_paint(&mut paint);
}
paints.push(paint);
}
StrokeKind::Center => {
let mut paint = skia::Paint::default();
@@ -330,25 +284,16 @@ fn render_text_on_canvas(
canvas.restore();
}
fn draw_text(
/// Lays out and paints paragraph builders without any layer management.
fn paint_text(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
layer_opacity: Option<f32>,
) {
let text_content = shape.get_text_content();
let layout_info =
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity);
let layer_rec = SaveLayerRec::default().paint(&opacity_paint);
canvas.save_layer(&layer_rec);
} else {
canvas.save_layer(&SaveLayerRec::default());
}
for para in &layout_info.paragraphs {
para.paragraph.paint(canvas, (para.x, para.y));
for deco in &para.decorations {
@@ -364,6 +309,178 @@ fn draw_text(
}
}
fn draw_text(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
layer_opacity: Option<f32>,
) {
if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default();
opacity_paint.set_alpha_f(opacity);
let layer_rec = SaveLayerRec::default().paint(&opacity_paint);
canvas.save_layer(&layer_rec);
} else {
canvas.save_layer(&SaveLayerRec::default());
}
paint_text(canvas, shape, paragraph_builder_groups);
}
/// Renders an inner stroke using mask + SrcIn + DstOver layer structure.
///
/// 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
/// restore
/// restore
/// saveLayer(DstOver) — fill behind the stroke
/// paint fill
/// restore
/// restore
#[allow(clippy::too_many_arguments)]
fn render_inner_stroke_on_canvas(
canvas: &Canvas,
shape: &Shape,
mask_builders: &mut [Vec<ParagraphBuilder>],
stroke_builders: &mut [Vec<ParagraphBuilder>],
fill_builders: &mut [Vec<ParagraphBuilder>],
blur: Option<&ImageFilter>,
layer_opacity: Option<f32>,
) {
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));
}
// 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));
// Draw stroke
paint_text(canvas, shape, stroke_builders);
canvas.restore(); // SrcIn layer
canvas.restore(); // mask group layer
// Fill with DstOver (behind the stroke result)
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);
canvas.restore(); // DstOver layer
canvas.restore(); // outer layer
if layer_opacity.is_some() {
canvas.restore(); // opacity layer
}
if blur.is_some() {
canvas.restore(); // blur layer
}
}
/// Public API for rendering inner strokes with mask+SrcIn+DstOver approach.
#[allow(clippy::too_many_arguments)]
pub fn render_inner_stroke(
render_state: Option<&mut RenderState>,
canvas: Option<&Canvas>,
shape: &Shape,
mask_builders: &mut [Vec<ParagraphBuilder>],
stroke_builders: &mut [Vec<ParagraphBuilder>],
fill_builders: &mut [Vec<ParagraphBuilder>],
surface_id: Option<SurfaceId>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
layer_opacity: Option<f32>,
) -> Result<()> {
if let Some(render_state) = render_state {
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
if let Some(blur_filter) = blur {
let mut text_bounds = shape
.get_text_content()
.calculate_bounds(shape, false)
.to_rect();
if stroke_bounds_outset > 0.0 {
text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset));
}
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);
render_inner_stroke_on_canvas(
temp_canvas,
shape,
mask_builders,
stroke_builders,
fill_builders,
Some(&blur_filter_clone),
layer_opacity,
);
Ok(())
},
)? {
return Ok(());
}
}
}
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
render_inner_stroke_on_canvas(
canvas,
shape,
mask_builders,
stroke_builders,
fill_builders,
blur,
layer_opacity,
);
return Ok(());
}
if let Some(canvas) = canvas {
render_inner_stroke_on_canvas(
canvas,
shape,
mask_builders,
stroke_builders,
fill_builders,
blur,
layer_opacity,
);
}
Ok(())
}
fn draw_text_decorations(
canvas: &Canvas,
text_style: &TextStyle,

View File

@@ -605,6 +605,40 @@ impl TextContent {
paragraph_group
}
/// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
/// Used as a clip mask for inner stroke rendering.
pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> {
let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts();
let mut paragraph_group = Vec::new();
for paragraph in self.paragraphs() {
let paragraph_style = paragraph.paragraph_to_style();
let mut builder = ParagraphBuilder::new(&paragraph_style, fonts);
let mut has_text = false;
for span in paragraph.children() {
let text_style = span.to_style(
&self.bounds(),
fallback_fonts,
true, // always opaque
paragraph.line_height(),
);
let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
}
builder.push_style(&text_style);
builder.add_text(&text);
}
if !has_text {
builder.add_text(" ");
}
paragraph_group.push(vec![builder]);
}
paragraph_group
}
/// Performs an Auto Width text layout.
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
let mut paragraph_builders = self.paragraph_builder_group_from_text(None);
@@ -1094,6 +1128,7 @@ impl TextSpan {
self.text = text;
}
#[allow(dead_code)]
pub fn fills(&self) -> &[shapes::Fill] {
&self.fills
}