mirror of
https://github.com/penpot/penpot.git
synced 2026-09-03 18:49:45 +00:00
🐛 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:
parent
03e6f119e5
commit
b92112da54
@ -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));
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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();
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(_)) {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user