🔧 Improve text strokes in texts with emoji (#10519)

This commit is contained in:
Elena Torró 2026-07-01 18:10:46 +02:00 committed by GitHub
parent f25317ac47
commit 28b5371901
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 413 additions and 113 deletions

View File

@ -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 }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 168 KiB

View File

@ -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();

View File

@ -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,

View File

@ -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<ParagraphBuilder>],
deco_builders: &mut [Vec<ParagraphBuilder>],
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<ParagraphBuilder>],
stroke_builders: &mut [Vec<ParagraphBuilder>],
fill_builders: &mut [Vec<ParagraphBuilder>],
fill_builders: Option<&mut [Vec<ParagraphBuilder>]>,
stroke_mask_blend: skia::BlendMode,
blur: Option<&ImageFilter>,
layer_opacity: Option<f32>,
) {
@ -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<ParagraphBuilder>],
stroke_builders: &mut [Vec<ParagraphBuilder>],
fill_builders: &mut [Vec<ParagraphBuilder>],
mut fill_builders: Option<&mut [Vec<ParagraphBuilder>]>,
stroke_mask_blend: skia::BlendMode,
surface_id: Option<SurfaceId>,
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<ParagraphBuilder>],
fill_builders: &mut [Vec<ParagraphBuilder>],
surface_id: Option<SurfaceId>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
layer_opacity: Option<f32>,
) -> 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<ParagraphBuilder>],
surface_id: Option<SurfaceId>,
blur: Option<&ImageFilter>,
stroke_bounds_outset: f32,
layer_opacity: Option<f32>,
) -> 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);

View File

@ -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,

View File

@ -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<Paragraph> {
self.content_version = self.content_version.wrapping_add(1);
&mut self.paragraphs