🐛 Export multiple solid fills to WASM SVG (#11467)

Shapes and text with stacked solid fills were exported without fill
attributes because merge_fills builds a Skia shader that SkSVGDevice
does not serialize. Draw each fill layer separately for SVG export,
bottom to top, matching the classic exporter.

Closes #11466
This commit is contained in:
Alejandro Alonso 2026-09-02 13:40:05 +02:00 committed by GitHub
parent 03e6f119e5
commit b92112da54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 213 additions and 28 deletions

View File

@ -34,12 +34,29 @@ pub(super) fn add_solid_rect(
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
color: skia::Color,
) {
add_rect_with_fills(
pool,
id,
parent,
(l, t, r, b),
vec![Fill::Solid(SolidColor(color))],
);
}
/// Adds a rectangle with the given fill stack (bottom → top).
pub(super) fn add_rect_with_fills(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
fills: Vec<Fill>,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Rect(Rect::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
shape.set_fills(fills);
}
/// Adds a solid-filled frame (board) to the pool.
@ -84,6 +101,25 @@ pub(super) fn add_solid_text(
text: &str,
font_size: f32,
fill: skia::Color,
) {
add_text_with_fills(
pool,
id,
(l, t, r, b),
text,
font_size,
vec![Fill::Solid(SolidColor(fill))],
);
}
/// Adds a single-line text shape with the given fill stack (top → bottom).
pub(super) fn add_text_with_fills(
pool: &mut ShapesPool,
id: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
text: &str,
font_size: f32,
fills: Vec<Fill>,
) {
let bounds = skia::Rect::from_ltrb(l, t, r, b);
let mut content = TextContent::new(bounds, GrowType::Fixed);
@ -99,7 +135,7 @@ pub(super) fn add_solid_text(
TextDirection::LTR,
400,
Uuid::nil(),
vec![Fill::Solid(SolidColor(fill))],
fills,
);
content.add_paragraph(Paragraph::new(
TextAlign::Left,
@ -113,10 +149,6 @@ pub(super) fn add_solid_text(
let shape = pool.add_shape(id);
shape.set_parent(Uuid::nil());
// Set the selrect before the text type: `set_selrect` on a text shape
// eagerly relayouts (needing the font collection), which isn't available
// until the export installs it. The render recomputes text layout from the
// selrect anyway.
shape.set_selrect(l, t, r, b);
shape.set_shape_type(Type::Text(content));
}

View File

@ -34,7 +34,7 @@ pub(super) fn render_frame(
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fills(element, &element.fills)?;
canvas.restore();
}
@ -51,7 +51,7 @@ pub(super) fn render_frame(
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_strokes(element, &visible_strokes)?;
canvas.restore();
}

View File

@ -194,7 +194,7 @@ fn render_leaf(
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
render_leaf_content(&mut renderer, element)?;
canvas.restore();
}

View File

@ -0,0 +1,9 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80">
<rect fill="#003FFF" width="100" height="80"/>
<rect fill="#F50000" fill-opacity="0.50196081" width="100" height="80"/>
</svg>

View File

@ -0,0 +1,13 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style></defs>
<text fill="#003FFF" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
<text fill="#F50000" fill-opacity="0.50196081" font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</svg>

View File

@ -1,11 +1,44 @@
use super::fixtures::*;
use crate::shapes::BlendMode;
use crate::shapes::{BlendMode, Fill, SolidColor};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
use skia_safe as skia;
#[test]
fn exports_a_rect_with_multiple_solid_fills() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_rect_with_fills(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
vec![
// fills[0] is topmost in Penpot (red 50%).
Fill::Solid(SolidColor(skia::Color::from_argb(128, 245, 0, 0))),
// fills[1] is underneath (blue 100%).
Fill::Solid(SolidColor(skia::Color::from_rgb(0, 63, 255))),
],
);
let svg = render(&pool, id);
assert!(
svg.matches("fill=\"#").count() >= 2,
"each solid fill must emit a fill attribute: {svg}"
);
let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\"");
let red_pos = svg.to_ascii_lowercase().find("fill=\"#f50000\"");
assert!(blue_pos.is_some(), "missing bottom blue fill: {svg}");
assert!(red_pos.is_some(), "missing top red fill: {svg}");
assert!(
blue_pos.unwrap() < red_pos.unwrap(),
"bottom fill must appear before top fill in SVG: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_solid_rect() {
let mut pool = ShapesPool::new();
@ -175,6 +208,38 @@ fn exports_an_unclipped_frame_with_overflowing_child() {
insta::assert_snapshot!(svg);
}
#[test]
fn exports_text_with_multiple_solid_fills() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_text_with_fills(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
vec![
Fill::Solid(SolidColor(skia::Color::from_argb(128, 245, 0, 0))),
Fill::Solid(SolidColor(skia::Color::from_rgb(0, 63, 255))),
],
);
let svg = render(&pool, id);
assert!(
svg.matches("fill=\"#").count() >= 2,
"each solid fill must emit a fill attribute: {svg}"
);
let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\"");
let red_pos = svg.to_ascii_lowercase().find("fill=\"#f50000\"");
assert!(blue_pos.is_some(), "missing bottom blue fill: {svg}");
assert!(red_pos.is_some(), "missing top red fill: {svg}");
assert!(
blue_pos.unwrap() < red_pos.unwrap(),
"bottom fill must appear before top fill in SVG: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_solid_text_with_font_face() {
let mut pool = ShapesPool::new();

View File

@ -400,8 +400,17 @@ fn render_text_on_canvas(
pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) {
let text_content = shape.get_text_content();
let text_content = text_content.new_bounds(shape.selrect());
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false);
let max_layers = text_content.max_fill_layers();
if max_layers == 0 {
return;
}
// Each fill layer is painted separately so SkSVGDevice can emit `fill`
// attributes (merged shaders are dropped). Bottom layer first.
for layer in 0..max_layers {
let mut paragraph_builders = text_content.paragraph_builder_group_for_fill_layer(layer);
paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false);
}
}
/// Lays out and paints paragraph builders without any layer management.

View File

@ -22,14 +22,24 @@ pub(super) struct VectorRenderer<'a> {
canvas: &'a Canvas,
shared: &'a mut RenderResources,
scale: f32,
/// When `true`, multiple fills are composited into a single shader (PDF).
/// When `false`, each fill is drawn separately so SkSVGDevice can emit
/// `fill` attributes (SVG export).
compose_fills: bool,
}
impl<'a> VectorRenderer<'a> {
pub fn new(canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32) -> Self {
pub fn new(
canvas: &'a Canvas,
shared: &'a mut RenderResources,
scale: f32,
compose_fills: bool,
) -> Self {
Self {
canvas,
shared,
scale,
compose_fills,
}
}
}
@ -40,9 +50,9 @@ impl ShapeRenderer for VectorRenderer<'_> {
return Ok(());
}
// Handle image fills individually
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
if has_image_fills {
if !self.compose_fills || has_image_fills {
// fills[0] is the topmost layer; draw bottom → top (matches GPU + classic SVG).
for fill in fills.iter().rev() {
match fill {
Fill::Image(image_fill) => {
@ -845,7 +855,7 @@ fn render_frame(
if !element.fills.is_empty() {
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
let mut renderer = VectorRenderer::new(canvas, shared, scale, true);
renderer.draw_fills(element, &element.fills)?;
renderer.draw_fill_inner_shadows(element)?;
canvas.restore();
@ -862,7 +872,7 @@ fn render_frame(
if !visible_strokes.is_empty() {
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
let mut renderer = VectorRenderer::new(canvas, shared, scale, true);
renderer.draw_strokes(element, &visible_strokes)?;
canvas.restore();
}
@ -900,7 +910,7 @@ fn render_container_drop_shadows(
);
if draw_fills && !element.fills.is_empty() {
let mut renderer = VectorRenderer::new(canvas, shared, scale);
let mut renderer = VectorRenderer::new(canvas, shared, scale, true);
renderer.draw_fills(element, &element.fills)?;
}
@ -943,7 +953,7 @@ fn render_leaf(
canvas.save_layer(&layer_rec);
}
let mut renderer = VectorRenderer::new(canvas, shared, scale);
let mut renderer = VectorRenderer::new(canvas, shared, scale, true);
// Layer blur (non-text shapes)
let blur_layer = if !matches!(element.shape_type, Type::Text(_)) {

View File

@ -707,13 +707,32 @@ impl TextContent {
&self,
use_shadow: Option<bool>,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(use_shadow, false, None)
self.paragraph_builders(use_shadow, false, None, None)
}
/// 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> {
self.paragraph_builders(None, true, None)
self.paragraph_builders(None, true, None, None)
}
/// Maximum number of stacked fills across every span in this text block.
pub fn max_fill_layers(&self) -> usize {
self.paragraphs()
.iter()
.flat_map(|p| p.children())
.map(|s| s.fills.len())
.max()
.unwrap_or(0)
}
/// Builds paragraph builders that paint a single fill layer per span for SVG
/// export. `layer_from_bottom` is 0 for the bottommost fill (fills[last]).
pub fn paragraph_builder_group_for_fill_layer(
&self,
layer_from_bottom: usize,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(None, false, None, Some(layer_from_bottom))
}
fn paragraph_builders(
@ -721,6 +740,7 @@ impl TextContent {
use_shadow: Option<bool>,
opaque: bool,
align_override: Option<skia::textlayout::TextAlign>,
fill_layer: Option<usize>,
) -> Vec<ParagraphBuilderGroup> {
let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts();
@ -736,11 +756,12 @@ impl TextContent {
for span in paragraph.children() {
let remove_alpha =
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
let text_style = span.to_style(
let text_style = span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
remove_alpha,
paragraph.line_height(),
fill_layer,
);
let text: String = span.apply_text_transform();
if !text.is_empty() {
@ -762,7 +783,7 @@ impl TextContent {
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
// Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container.
let mut measure_builders =
self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left));
self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None);
let normalized_line_height =
calculate_normalized_line_height(&mut measure_builders, f32::MAX);
@ -1349,15 +1370,41 @@ impl TextSpan {
remove_alpha: bool,
paragraph_line_height: f32,
) -> skia::textlayout::TextStyle {
let mut style = skia::textlayout::TextStyle::default();
let mut paint = paint::Paint::default();
self.to_style_with_paint(
content_bounds,
fallback_fonts,
remove_alpha,
paragraph_line_height,
None,
)
}
if remove_alpha {
fn to_style_with_paint(
&self,
content_bounds: &Rect,
fallback_fonts: &HashSet<String>,
remove_alpha: bool,
paragraph_line_height: f32,
fill_layer_from_bottom: Option<usize>,
) -> skia::textlayout::TextStyle {
let mut style = skia::textlayout::TextStyle::default();
let paint = if remove_alpha {
let mut paint = paint::Paint::default();
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
paint
} else if let Some(layer) = fill_layer_from_bottom {
if layer < self.fills.len() {
let fill_idx = self.fills.len() - 1 - layer;
self.fills[fill_idx].to_paint(content_bounds, true)
} else {
let mut paint = paint::Paint::default();
paint.set_color(skia::Color::TRANSPARENT);
paint
}
} else {
paint = merge_fills(&self.fills, *content_bounds);
}
merge_fills(&self.fills, *content_bounds)
};
let max_line_height = f32::max(paragraph_line_height, self.line_height);
style.set_height(max_line_height);