use skia_safe::{self as skia}; use std::collections::HashSet; use crate::error::Result; use crate::math::Bounds; use crate::shapes::{Shape, Type}; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; use super::shape_renderer::ShapeRenderer; use super::vector::VectorRenderer; use super::RenderResources; /// Collects the registered font aliases used by every text span in the subtree /// rooted at `id`, so the exporter can emit `@font-face` rules for them. fn collect_font_aliases(tree: ShapesPoolRef, id: &Uuid, out: &mut HashSet) { let Some(shape) = tree.get(id) else { return; }; if let Type::Text(_) = &shape.shape_type { for paragraph in shape.get_text_content().paragraphs() { for span in paragraph.children() { out.insert(format!("{}", span.font_family)); } } } for child_id in shape.children_ids_iter_forward(true) { collect_font_aliases(tree, child_id, out); } } /// Bounds for the root `` width, height, and viewBox. /// /// Text uses [`Shape::layer_bounds`]: glyph metrics, at least the `selrect`, and /// room for strokes/shadows/blur. Other shapes use `extrect` for overflow. fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect { if matches!(shape.shape_type, Type::Text(_)) { let mut bounds = Bounds::from_rect(&shape.layer_bounds()); if !shape.transform.is_identity() { let mut matrix = shape.transform; let center = shape.center(); matrix.post_translate(center); matrix.pre_translate(-center); bounds.transform_mut(&matrix); } bounds.to_rect() } else { shape.extrect(tree, scale) } } /// Renders a shape tree to an SVG document and returns the raw SVG bytes. /// /// Dedicated vector-SVG render path. Leaf content (paths, fills, …) is emitted /// as real SVG markup via short-lived Skia SVG canvases, while composite /// effects that `SkSVGDevice` would drop (`save_layer` opacity / blend) are /// composed as native SVG `` wrappers. Frame `clip content` uses a native /// ``. /// /// Layer blur and drop/inner shadows are re-emitted as a native SVG `` /// wrapper. Masked groups use a native alpha ``. Background blur uses an /// XHTML `foreignObject` with CSS `backdrop-filter` (browser rendering). /// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines; /// image-filled strokes use a linked `` clipped to the stroke; /// text strokes use `` / glyph clipPath / inverse glyph mask, and /// image-filled text strokes use a linked `` under a stroke luminance mask. pub fn render_to_svg( shared: &mut RenderResources, id: &Uuid, tree: ShapesPoolRef, scale: f32, ) -> Result> { render_tree_to_svg(shared, id, tree, scale) } /// Core SVG export, kept as a separate entry so headless native tests can call /// it with a GPU-free [`RenderResources`]. pub(crate) fn render_tree_to_svg( shared: &mut RenderResources, id: &Uuid, tree: ShapesPoolRef, scale: f32, ) -> Result> { let shape = tree .get(id) .ok_or_else(|| crate::error::Error::CriticalError("Shape not found for SVG".to_string()))?; // Use the extended rect so unclipped frames grow to include overflowing // children (and leaf effects like shadows when those land). Clipped frames // still resolve to roughly their selrect because `extrect` skips joining // children when `clip_content` is on. let bounds = svg_page_bounds(shape, tree, scale); let page_w = bounds.width() * scale; let page_h = bounds.height() * scale; let rect = skia::Rect::from_xywh(0., 0., page_w, page_h); let (defs, body) = render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?; let mut aliases = HashSet::new(); collect_font_aliases(tree, id, &mut aliases); let font_css = shared.fonts.font_face_css_for_aliases(&aliases); let mut out = String::with_capacity(body.len() + defs.len() + font_css.len() + 256); out.push_str("\n"); out.push_str(&format!( "" )); if !font_css.is_empty() || !defs.is_empty() { out.push_str(""); if !font_css.is_empty() { out.push_str(&format!( "" )); } out.push_str(&defs); out.push_str(""); } out.push_str(&body); out.push_str(""); Ok(out.into_bytes()) } mod background_blur; mod document; mod frames; mod groups; mod images; mod text; use background_blur::emit_background_blur; use document::SvgLayerCanvas; use frames::render_frame; use groups::render_group; use text::{render_text_fill, render_text_strokes}; use crate::shapes::Shadow; use document::{ content_effect_attrs, effect_attrs, opacity_blend_attrs, push_container_drop_filter, push_text_silhouette_spread_filter, shape_with_selrect_outset, }; use images::{emit_fills, emit_strokes}; /// Renders `id`'s subtree to an SVG body, returning `(defs, body)`. fn render_body( shared: &mut RenderResources, id: &Uuid, tree: ShapesPoolRef, scale: f32, page_rect: skia::Rect, tx: f32, ty: f32, ) -> Result<(String, String)> { let mut builder = SvgLayerCanvas::new(scale, page_rect, tx, ty); render_tree(&mut builder, shared, id, tree, scale)?; builder.flush(); Ok((builder.defs, builder.out)) } fn render_tree( builder: &mut SvgLayerCanvas, shared: &mut RenderResources, id: &Uuid, tree: ShapesPoolRef, scale: f32, ) -> Result<()> { let Some(element) = tree.get(id) else { return Ok(()); }; if element.hidden { return Ok(()); } match &element.shape_type { Type::Group(_) => render_group(builder, shared, element, tree, scale), Type::Frame(_) => render_frame(builder, shared, element, tree, scale), Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) | Type::Text(_) | Type::SVGRaw(_) => render_leaf(builder, shared, element, tree, scale), } } fn render_leaf( builder: &mut SvgLayerCanvas, shared: &mut RenderResources, element: &Shape, tree: ShapesPoolRef, scale: f32, ) -> Result<()> { // Text keeps the classic leaf filter graph (GPU dilates glyph alpha). Geometry // leaves match container drops: geometric offset+spread silhouette, then content. if matches!(element.shape_type, Type::Text(_)) { return render_leaf_text(builder, shared, element, scale); } emit_background_blur(builder, element, scale)?; let composite = opacity_blend_attrs(element); if let Some(attrs) = &composite { builder.open_group(attrs); } // Own drop silhouettes: GPU paints geometric spread/offset (avoids // feMorphology rounding on circles). Filter only blurs/tints. if !builder.suppress_filters { let drops: Vec = element.drop_shadows_visible().copied().collect(); for shadow in &drops { let id = push_container_drop_filter(builder, shadow); builder.open_group(&format!("filter=\"url(#{id})\"")); let prev_suppress = builder.suppress_filters; let prev_spread = builder.silhouette_spread; let prev_offset = builder.silhouette_offset; builder.suppress_filters = true; builder.silhouette_spread = shadow.spread; builder.silhouette_offset = shadow.offset; render_leaf_geometry(builder, shared, element, tree, scale)?; builder.silhouette_offset = prev_offset; builder.silhouette_spread = prev_spread; builder.suppress_filters = prev_suppress; builder.close_group(); } } let effects = content_effect_attrs(builder, element); if let Some(attrs) = &effects { builder.open_group(attrs); } render_leaf_geometry(builder, shared, element, tree, scale)?; if effects.is_some() { builder.close_group(); } if composite.is_some() { builder.close_group(); } Ok(()) } fn render_leaf_text( builder: &mut SvgLayerCanvas, shared: &mut RenderResources, element: &Shape, scale: f32, ) -> Result<()> { emit_background_blur(builder, element, scale)?; let effects = effect_attrs(builder, element); if let Some(attrs) = &effects { builder.open_group(attrs); } let spread = builder.silhouette_spread; let draw_matrix = builder.silhouette_draw_matrix(element); // See `push_text_silhouette_spread_filter`: morph-before-blur approx of // GPU dilate(drop_shadow) for inherited container spread. let morph_id = push_text_silhouette_spread_filter(builder, spread); if let Some(id) = &morph_id { builder.open_group(&format!("filter=\"url(#{id})\"")); } render_text_fill(builder, shared, element, draw_matrix)?; render_text_strokes(builder, shared, element, draw_matrix)?; if morph_id.is_some() { builder.close_group(); } if effects.is_some() { builder.close_group(); } Ok(()) } fn render_leaf_geometry( builder: &mut SvgLayerCanvas, shared: &mut RenderResources, element: &Shape, tree: ShapesPoolRef, scale: f32, ) -> Result<()> { let spread = builder.silhouette_spread; // Spread outsets fills only (GPU). Rect/Frame strokes ignore outset. let fill_shape = shape_with_selrect_outset(element, spread); // Always from the original element (not outset selrect) so the pivot // matches content; offset comes from the silhouette pass. let draw_matrix = builder.silhouette_draw_matrix(element); if matches!(element.shape_type, Type::SVGRaw(_)) { let canvas = builder.canvas(); canvas.save(); canvas.concat(&draw_matrix); let mut renderer = VectorRenderer::new(canvas, shared, scale, false); renderer.draw_svg(element)?; canvas.restore(); return Ok(()); } let fills = builder.effective_fills_owned(&fill_shape); emit_fills( builder, shared, &fill_shape, &fills, tree, scale, Some(draw_matrix), )?; // Stroke geometry stays on the original selrect (GPU Rect/Frame // drop-shadow outset is a no-op for single strokes). let visible_strokes: Vec<_> = element.visible_strokes().collect(); if !visible_strokes.is_empty() { emit_strokes( builder, shared, element, &visible_strokes, scale, Some(draw_matrix), )?; } Ok(()) } // =========================================================================== // Tests // =========================================================================== // // Fast, headless native tests (`cargo test --bin render_wasm`) for the SVG // exporter. They bypass the GPU/browser stack: shapes are built into a // `ShapesPool` and rendered through [`render_tree_to_svg`] with a headless // [`RenderResources`]. Output is checked with `insta` snapshots. // // To (re)generate snapshots after a deliberate change: // cargo insta test --accept --bin render_wasm #[cfg(test)] mod fixtures; #[cfg(test)] mod tests;