mirror of
https://github.com/penpot/penpot.git
synced 2026-07-03 21:05:25 +00:00
🔧 Improve text strokes in texts with emoji (#10519)
This commit is contained in:
parent
f25317ac47
commit
28b5371901
@ -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 |
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user