From b92112da542b8373f367d338b56e0b1385283acb Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 2 Sep 2026 13:40:05 +0200 Subject: [PATCH] :bug: 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 --- render-wasm/src/render/svg/fixtures.rs | 44 ++++++++++-- render-wasm/src/render/svg/frames.rs | 4 +- render-wasm/src/render/svg/mod.rs | 2 +- ...orts_a_rect_with_multiple_solid_fills.snap | 9 +++ ...xports_text_with_multiple_solid_fills.snap | 13 ++++ render-wasm/src/render/svg/tests.rs | 67 ++++++++++++++++++- render-wasm/src/render/text.rs | 13 +++- render-wasm/src/render/vector.rs | 24 +++++-- render-wasm/src/shapes/text.rs | 65 +++++++++++++++--- 9 files changed, 213 insertions(+), 28 deletions(-) create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index 1359cbd29a..46729b94b0 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -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, ) { 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, ) { 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)); } diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs index f060a66ce6..fead20bbf4 100644 --- a/render-wasm/src/render/svg/frames.rs +++ b/render-wasm/src/render/svg/frames.rs @@ -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(); } diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index b7b6e9c29a..e992b1ad66 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -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(); } diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap new file mode 100644 index 0000000000..e8f99d11a0 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_rect_with_multiple_solid_fills.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap new file mode 100644 index 0000000000..6a2f690ee4 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_with_multiple_solid_fills.snap @@ -0,0 +1,13 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + HOLA + + + HOLA + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 4076f27719..5ed0fc81c3 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -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(); diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 291768f8d9..89c232be69 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -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. diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 269abd76aa..b19117b9d4 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -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(_)) { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 3bc29e96fd..61dfd5d15b 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -707,13 +707,32 @@ impl TextContent { &self, use_shadow: Option, ) -> Vec { - 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 { - 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 { + self.paragraph_builders(None, false, None, Some(layer_from_bottom)) } fn paragraph_builders( @@ -721,6 +740,7 @@ impl TextContent { use_shadow: Option, opaque: bool, align_override: Option, + fill_layer: Option, ) -> Vec { 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, + remove_alpha: bool, + paragraph_line_height: f32, + fill_layer_from_bottom: Option, + ) -> 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);