🐛 Fix text inner stroke fill artifacts

This commit is contained in:
Alejandro Alonso 2026-06-30 13:58:02 +02:00
parent ca81776d04
commit a726733b9e
4 changed files with 73 additions and 32 deletions

View File

@ -1432,6 +1432,10 @@ impl RenderState {
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 only_inner_strokes = count_inner_strokes > 0
&& shape
.visible_strokes()
.all(|stroke| stroke.kind == StrokeKind::Inner);
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);
@ -1451,17 +1455,19 @@ impl RenderState {
.unzip();
if skip_effects {
// Fast path: render fills and strokes only (skip shadows/blur).
text::render(
Some(self),
None,
&shape,
&mut paragraph_builders,
Some(fills_surface_id),
None,
None,
text_fill_inset,
None,
)?;
if !only_inner_strokes {
text::render(
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()
@ -1483,6 +1489,7 @@ impl RenderState {
None,
text_stroke_blur_outset,
*layer_opacity,
text_fill_inset,
)?;
} else {
text::render_with_bounds_outset(
@ -1573,17 +1580,19 @@ impl RenderState {
}
// 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,
)?;
if !only_inner_strokes {
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(
@ -1620,6 +1629,7 @@ impl RenderState {
blur_filter.as_ref(),
text_stroke_blur_outset,
*layer_opacity,
text_fill_inset,
)?;
} else {
text::render_with_bounds_outset(

View File

@ -178,6 +178,7 @@ pub fn render_text_shadows(
blur_filter.as_ref(),
0.0,
None,
None,
)?;
} else {
text::render(

View File

@ -584,6 +584,7 @@ fn render_inner_stroke_on_canvas(
fill_builders: &mut [Vec<ParagraphBuilder>],
blur: Option<&ImageFilter>,
layer_opacity: Option<f32>,
fill_inset: Option<f32>,
) {
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
@ -623,7 +624,19 @@ fn render_inner_stroke_on_canvas(
dst_over_paint.set_blend_mode(skia::BlendMode::DstOver);
canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint));
paint_text(canvas, shape, fill_builders);
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));
paint_text(canvas, shape, fill_builders);
canvas.restore();
} else {
paint_text(canvas, shape, fill_builders);
}
} else {
paint_text(canvas, shape, fill_builders);
}
canvas.restore(); // DstOver layer
canvas.restore(); // outer layer
@ -650,6 +663,7 @@ pub fn render_inner_stroke(
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
layer_opacity: Option<f32>,
fill_inset: Option<f32>,
) -> Result<()> {
if let Some(render_state) = render_state {
let target_surface = surface_id.unwrap_or(SurfaceId::Fills);
@ -679,6 +693,7 @@ pub fn render_inner_stroke(
fill_builders,
Some(&blur_filter_clone),
layer_opacity,
fill_inset,
);
Ok(())
},
@ -697,6 +712,7 @@ pub fn render_inner_stroke(
fill_builders,
blur,
layer_opacity,
fill_inset,
);
return Ok(());
}
@ -710,6 +726,7 @@ pub fn render_inner_stroke(
fill_builders,
blur,
layer_opacity,
fill_inset,
);
}
Ok(())

View File

@ -153,6 +153,10 @@ impl ShapeRenderer for VectorRenderer<'_> {
let text_content = text_content.new_bounds(shape.selrect());
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let blur_filter = shape.image_filter(1.);
let only_inner_strokes = shape.count_visible_inner_strokes() > 0
&& shape
.visible_strokes()
.all(|stroke| stroke.kind == StrokeKind::Inner);
// Text drop shadows: one filter layer per shadow over fill + stroke
// silhouettes (mirrors GPU `render_text_shadows`).
@ -208,6 +212,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
blur_filter.as_ref(),
0.0,
None,
None,
)?;
} else {
text::render_with_bounds_outset_overlay_emoji(
@ -227,15 +232,17 @@ impl ShapeRenderer for VectorRenderer<'_> {
}
}
text::render_overlay_emoji(
self.canvas,
shape,
&mut paragraph_builders,
None,
blur_filter.as_ref(),
None,
None,
)?;
if !only_inner_strokes {
text::render_overlay_emoji(
self.canvas,
shape,
&mut paragraph_builders,
None,
blur_filter.as_ref(),
None,
None,
)?;
}
// Strokes for text
let stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false);
@ -250,6 +257,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 fill_inset = (shape.count_visible_inner_strokes() > 0).then(|| {
let ctm = self.canvas.local_to_device_as_3x3();
let scale = (ctm.scale_x().powi(2) + ctm.skew_y().powi(2)).sqrt().max(1.0);
1.0 / scale
});
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(
@ -263,6 +275,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
blur_filter.as_ref(),
stroke_blur_outset,
layer_opacity,
fill_inset,
)?;
} else {
text::render_with_bounds_outset_overlay_emoji(