Alejandro Alonso 89a17df403
Add basic WASM SVG export path (#10891)
Introduce a minimal vector SVG exporter via render-wasm: solid fills,
native opacity/blend wrappers, frame clip-content, FFI/frontend wiring,
insta snapshots, and a visual preview gallery. Defer save_layer
workarounds (shadows, blur, masks, special strokes) to follow-up PRs.
2026-08-31 16:06:38 +02:00

239 lines
7.7 KiB
Rust

use skia_safe::{self as skia, Paint};
use crate::shapes::{Shape, Type};
use crate::state::ShapesPoolRef;
use crate::render::vector::draw_shape_geometry;
// Skia's SVG backend (`SkSVGDevice`) silently drops everything drawn inside a
// `save_layer`, so composite effects rendered with `save_layer` (opacity,
// blend mode, …) vanish in SVG.
//
// Instead of one canvas, the SVG path composes the document itself: leaf
// content is drawn into short-lived `skia::svg::Canvas` fragments (real
// `<path>`/`<text>`/… vector markup), and composite effects become native SVG
// `<g>` wrappers (`opacity`, `mix-blend-mode`, `clip-path`).
/// Accumulates the SVG document body while drawing.
pub(crate) struct SvgLayerCanvas {
pub(super) scale: f32,
page_rect: skia::Rect,
tx: f32,
ty: f32,
pub(super) out: String,
pub(super) defs: String,
pending: Option<skia::svg::Canvas>,
next_id: usize,
frag_no: usize,
}
impl SvgLayerCanvas {
pub(super) fn new(scale: f32, page_rect: skia::Rect, tx: f32, ty: f32) -> Self {
Self {
scale,
page_rect,
tx,
ty,
out: String::new(),
defs: String::new(),
pending: None,
next_id: 0,
frag_no: 0,
}
}
pub(super) fn unique(&mut self, prefix: &str) -> String {
let id = format!("{prefix}{}", self.next_id);
self.next_id += 1;
id
}
/// Creates a fragment canvas configured with the page transform
/// (scale + translate to the export bounds).
pub(super) fn new_fragment(&self) -> skia::svg::Canvas {
let canvas = skia::svg::Canvas::new(self.page_rect, None);
{
let cv: &skia::Canvas = &canvas;
cv.scale((self.scale, self.scale));
cv.translate((self.tx, self.ty));
}
canvas
}
/// Returns the current leaf-drawing canvas, creating a fragment if needed.
pub(crate) fn canvas(&mut self) -> &skia::Canvas {
if self.pending.is_none() {
self.pending = Some(self.new_fragment());
}
self.pending.as_deref().unwrap()
}
/// Finalizes the pending fragment and appends its markup to `out`.
pub(super) fn flush(&mut self) {
let Some(canvas) = self.pending.take() else {
return;
};
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
if inner.trim().is_empty() {
return;
}
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
self.out.push_str(&remap_ids(inner, &prefix));
}
pub(super) fn open_group(&mut self, attrs: &str) {
self.flush();
self.out.push_str("<g ");
self.out.push_str(attrs);
self.out.push('>');
}
pub(super) fn close_group(&mut self) {
self.flush();
self.out.push_str("</g>");
}
/// Emits a `<clipPath>` from a shape's geometry (in device/page space).
///
/// A mask can be a group too. Since a group has no geometry of its own, we
/// recurse into its descendants and accumulate their geometry.
pub(super) fn push_clip_path(&mut self, id: &str, shape: &Shape, tree: ShapesPoolRef) {
let canvas = self.new_fragment();
{
let cv: &skia::Canvas = &canvas;
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_color(skia::Color::BLACK);
draw_clip_geometry(cv, shape, tree, &paint);
}
self.finish_clip_path_fragment(id, canvas);
}
/// Finalizes a fragment canvas as a `<clipPath>` def.
pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) {
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
let geometry = remap_ids(inner, &prefix);
self.defs.push_str(&format!(
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
));
}
}
/// Draws a clip geometry into `cv` (already set up with the page transform).
fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) {
if let Type::Group(_) = &shape.shape_type {
for child_id in shape.children_ids_iter_forward(true) {
if let Some(child) = tree.get(child_id) {
draw_clip_geometry(cv, child, tree, paint);
}
}
return;
}
cv.save();
cv.concat(&shape.centered_transform());
draw_shape_geometry(cv, shape, paint);
cv.restore();
}
/// Builds the `<g>` attribute string for a shape's composite effects (opacity,
/// blend mode). Returns `None` when the shape needs no wrapper.
///
/// Layer blur / shadows are intentionally omitted here — they need native SVG
/// filter re-emission to survive `SkSVGDevice` and land in later PRs.
pub(super) fn effect_attrs(element: &Shape) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
let opacity = element.opacity();
if opacity < 1.0 {
parts.push(format!("opacity=\"{opacity}\""));
}
if let Some(css) = blend_css(element.blend_mode().0) {
parts.push(format!("style=\"mix-blend-mode:{css}\""));
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
/// Maps a Skia blend mode to its CSS `mix-blend-mode` keyword. Returns `None`
/// for `SrcOver` (normal) and modes without a CSS equivalent.
fn blend_css(mode: skia::BlendMode) -> Option<&'static str> {
use skia::BlendMode::*;
Some(match mode {
Multiply => "multiply",
Screen => "screen",
Overlay => "overlay",
Darken => "darken",
Lighten => "lighten",
ColorDodge => "color-dodge",
ColorBurn => "color-burn",
HardLight => "hard-light",
SoftLight => "soft-light",
Difference => "difference",
Exclusion => "exclusion",
Hue => "hue",
Saturation => "saturation",
Color => "color",
Luminosity => "luminosity",
_ => return None,
})
}
/// Returns the inner body of a Skia SVG document (everything between the
/// opening `<svg …>` tag and the closing `</svg>`).
fn extract_inner_svg(doc: &str) -> &str {
let start = doc
.find("<svg")
.and_then(|s| doc[s..].find('>').map(|e| s + e + 1));
let end = doc.rfind("</svg>");
match (start, end) {
(Some(s), Some(e)) if s <= e => &doc[s..e],
_ => "",
}
}
/// Prefixes every id defined in a fragment (and its `url(#…)` / `#…`
/// references) so ids stay unique once fragments are merged into one document.
fn remap_ids(body: &str, prefix: &str) -> String {
let needle = "id=\"";
let mut ids: Vec<&str> = Vec::new();
let mut offset = 0;
while let Some(pos) = body[offset..].find(needle) {
let start = offset + pos + needle.len();
let Some(end_rel) = body[start..].find('"') else {
break;
};
let id = &body[start..start + end_rel];
if !id.is_empty() {
ids.push(id);
}
offset = start + end_rel + 1;
}
ids.sort_unstable();
ids.dedup();
// Longest-first so a shorter id can't collide inside a longer one.
ids.sort_by_key(|b| std::cmp::Reverse(b.len()));
let mut out = body.to_string();
for id in ids {
let new_id = format!("{prefix}{id}");
out = out.replace(&format!("id=\"{id}\""), &format!("id=\"{new_id}\""));
out = out.replace(&format!("url(#{id})"), &format!("url(#{new_id})"));
out = out.replace(&format!("=\"#{id}\""), &format!("=\"#{new_id}\""));
}
out
}