Lightningbeam/lightningbeam-ui/lightningbeam-core/src/svg_export.rs

672 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! SVG export from a DCEL subgraph.
//!
//! Generates a minimal SVG string containing one `<path>` per filled face,
//! plus stroked edges. Used as the secondary clipboard format for cross-app paste.
use crate::dcel2::{Dcel, FaceId, HalfEdgeId};
use kurbo::CubicBez;
/// Convert a DCEL to an SVG string.
///
/// Each non-unbounded face with a fill color becomes a `<path fill="..."/>`.
/// Each edge with a stroke becomes a `<path stroke="..."/>`.
/// Coordinates are document-space (no transform applied).
pub fn dcel_to_svg(dcel: &Dcel) -> String {
// Compute bounding box from vertex positions.
let mut min_x = f64::MAX;
let mut min_y = f64::MAX;
let mut max_x = f64::MIN;
let mut max_y = f64::MIN;
for v in &dcel.vertices {
if !v.deleted {
min_x = min_x.min(v.position.x);
min_y = min_y.min(v.position.y);
max_x = max_x.max(v.position.x);
max_y = max_y.max(v.position.y);
}
}
if min_x == f64::MAX {
return r#"<svg xmlns="http://www.w3.org/2000/svg"/>"#.to_string();
}
// Add a small margin.
let margin = 2.0;
let vx = min_x - margin;
let vy = min_y - margin;
let vw = (max_x - min_x) + margin * 2.0;
let vh = (max_y - min_y) + margin * 2.0;
let mut svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vx:.3} {vy:.3} {vw:.3} {vh:.3}">"#
);
// Emit filled faces.
for (face_idx, face) in dcel.faces.iter().enumerate() {
if face.deleted || face_idx == 0 {
continue;
}
let fill_color = match &face.fill_color {
Some(c) => format!("rgba({},{},{},{})", c.r, c.g, c.b, c.a as f32 / 255.0),
None => continue,
};
let face_id = FaceId(face_idx as u32);
let path_d = face_boundary_to_svg_path(dcel, face_id);
if path_d.is_empty() {
continue;
}
svg.push_str(&format!(r#"<path fill="{fill_color}" d="{path_d}"/>"#));
}
// Emit stroked edges.
for edge in &dcel.edges {
if edge.deleted {
continue;
}
let (stroke_color, stroke_width) = match (&edge.stroke_color, &edge.stroke_style) {
(Some(c), Some(s)) => (
format!("rgba({},{},{},{})", c.r, c.g, c.b, c.a as f32 / 255.0),
s.width,
),
_ => continue,
};
let path_d = cubic_to_svg_path(&edge.curve);
svg.push_str(&format!(
r#"<path fill="none" stroke="{stroke_color}" stroke-width="{stroke_width:.3}" d="{path_d}"/>"#
));
}
svg.push_str("</svg>");
svg
}
/// Walk a face's outer boundary half-edges and build an SVG path string.
fn face_boundary_to_svg_path(dcel: &Dcel, face_id: FaceId) -> String {
let face = dcel.face(face_id);
let start_he = face.outer_half_edge;
if start_he.is_none() {
return String::new();
}
let mut path = String::new();
let mut first = true;
let mut he_id = start_he;
// Safety limit to prevent infinite loops on malformed DCELs.
let limit = dcel.half_edges.len() + 1;
let mut count = 0;
loop {
if count > limit {
break;
}
count += 1;
let he = dcel.half_edge(he_id);
if he.deleted {
break;
}
let edge = dcel.edge(he.edge);
// Determine curve direction: forward half-edge is half_edges[0].
let curve = if edge.half_edges[0] == he_id {
edge.curve
} else {
// Reverse the cubic bezier.
let c = edge.curve;
CubicBez::new(c.p3, c.p2, c.p1, c.p0)
};
if first {
path.push_str(&format!("M {:.3} {:.3} ", curve.p0.x, curve.p0.y));
first = false;
}
path.push_str(&format!(
"C {:.3} {:.3} {:.3} {:.3} {:.3} {:.3} ",
curve.p1.x, curve.p1.y,
curve.p2.x, curve.p2.y,
curve.p3.x, curve.p3.y,
));
he_id = he.next;
if he_id == start_he {
break;
}
}
if !path.is_empty() {
path.push('Z');
}
// Also handle inner boundaries (holes).
for &inner_he_start in &face.inner_half_edges {
if inner_he_start.is_none() {
continue;
}
let inner = inner_boundary_to_svg_path(dcel, inner_he_start);
if !inner.is_empty() {
path.push(' ');
path.push_str(&inner);
}
}
path
}
fn inner_boundary_to_svg_path(dcel: &Dcel, start_he: HalfEdgeId) -> String {
let mut path = String::new();
let mut first = true;
let mut he_id = start_he;
let limit = dcel.half_edges.len() + 1;
let mut count = 0;
loop {
if count > limit {
break;
}
count += 1;
let he = dcel.half_edge(he_id);
if he.deleted {
break;
}
let edge = dcel.edge(he.edge);
let curve = if edge.half_edges[0] == he_id {
edge.curve
} else {
let c = edge.curve;
CubicBez::new(c.p3, c.p2, c.p1, c.p0)
};
if first {
path.push_str(&format!("M {:.3} {:.3} ", curve.p0.x, curve.p0.y));
first = false;
}
path.push_str(&format!(
"C {:.3} {:.3} {:.3} {:.3} {:.3} {:.3} ",
curve.p1.x, curve.p1.y,
curve.p2.x, curve.p2.y,
curve.p3.x, curve.p3.y,
));
he_id = he.next;
if he_id == start_he {
break;
}
}
if !path.is_empty() {
path.push('Z');
}
path
}
/// Convert a single cubic bezier to an SVG path string.
fn cubic_to_svg_path(curve: &CubicBez) -> String {
format!(
"M {:.3} {:.3} C {:.3} {:.3} {:.3} {:.3} {:.3} {:.3}",
curve.p0.x, curve.p0.y,
curve.p1.x, curve.p1.y,
curve.p2.x, curve.p2.y,
curve.p3.x, curve.p3.y,
)
}
// ===========================================================================
// Document / VectorGraph → SVG (the current model). The functions above target
// the legacy DCEL and are kept only for the clipboard stub.
// ===========================================================================
use crate::document::Document;
use crate::gradient::{GradientExtend, GradientType, ShapeGradient};
use crate::layer::AnyLayer;
use crate::shape::{Cap, FillRule, Join, ShapeColor};
use crate::vector_graph::{FillId, VectorGraph};
use kurbo::{BezPath, PathEl, Rect, Shape};
/// Serialize the document's **vector** content to a standalone SVG string, at document time `time`.
/// Vector layers, groups of them, and text layers (as real glyph outlines) — raster/video/audio/
/// effect layers are skipped (a later pass can rasterize them to `<image>`). Animation is a single
/// static frame at `time`.
pub fn document_to_svg(document: &Document, time: f64) -> String {
let (w, h) = (document.width, document.height);
let mut defs = String::new();
let mut body = String::new();
let mut grad_n = 0usize;
// Opaque background rect (skip if the document background is transparent).
let bg = document.background_color;
if bg.a > 0 {
body.push_str(&format!(
r#"<rect x="0" y="0" width="{w:.3}" height="{h:.3}" {}/>"#,
fill_attrs(&bg)
));
}
for layer in &document.root.children {
layer_to_svg(layer, time, 1.0, &mut body, &mut defs, &mut grad_n);
}
format!(
concat!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{:.0}" height="{:.0}" "#,
r#"viewBox="0 0 {:.3} {:.3}"><defs>{}</defs>{}</svg>"#
),
w, h, w, h, defs, body
)
}
/// Append one layer's SVG. Recurses into groups (`<g>`); other non-vector layer types are skipped.
fn layer_to_svg(layer: &AnyLayer, time: f64, parent_opacity: f64, body: &mut String, defs: &mut String, grad_n: &mut usize) {
match layer {
AnyLayer::Vector(vl) => {
if !vl.layer.visible {
return; // hidden layers are not rendered, so don't export them
}
let opacity = parent_opacity * vl.layer.opacity;
if let Some(graph) = vl.tweened_graph_at(time) {
let wrap = opacity < 0.999;
if wrap {
body.push_str(&format!(r#"<g opacity="{opacity:.4}">"#));
}
vector_graph_to_svg(&graph, body, defs, grad_n);
if wrap {
body.push_str("</g>");
}
}
// NOTE: placed clip instances (nested clips with their own transform) are not yet
// exported — a refinement once loose-geometry export is verified.
}
AnyLayer::Group(g) => {
if !g.layer.visible {
return;
}
// Render children first; only emit the <g> wrapper if it has exportable content
// (avoids empty groups when every child is a non-vector/hidden layer).
let mut inner = String::new();
for child in &g.children {
layer_to_svg(child, time, 1.0, &mut inner, defs, grad_n);
}
if !inner.is_empty() {
let opacity = parent_opacity * g.layer.opacity;
body.push_str(&format!(r#"<g opacity="{opacity:.4}">"#));
body.push_str(&inner);
body.push_str("</g>");
}
}
AnyLayer::Text(tl) => text_layer_to_svg(tl, time, parent_opacity, body),
// Raster/Video/Audio/Effect have no lossless vector representation — skipped this pass.
_ => {}
}
}
/// A skrifa outline pen that appends transformed glyph contours to an SVG path `d` string.
///
/// skrifa emits outline points in y-up pixel space (origin at the glyph baseline); this maps each
/// point into document space: `x = gx + px + skew·py`, `y = gy py` (Y flips, `skew` applies any
/// synthetic-italic slant), where `(gx, gy)` is the glyph's document-space pen position.
struct SvgOutlinePen<'a> {
gx: f64,
gy: f64,
skew: f64,
d: &'a mut String,
}
impl<'a> SvgOutlinePen<'a> {
fn map(&self, px: f32, py: f32) -> (f64, f64) {
let (px, py) = (px as f64, py as f64);
(self.gx + px + self.skew * py, self.gy - py)
}
}
impl skrifa::outline::OutlinePen for SvgOutlinePen<'_> {
fn move_to(&mut self, x: f32, y: f32) {
let (x, y) = self.map(x, y);
self.d.push_str(&format!("M{x:.2} {y:.2}"));
}
fn line_to(&mut self, x: f32, y: f32) {
let (x, y) = self.map(x, y);
self.d.push_str(&format!("L{x:.2} {y:.2}"));
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
let (cx, cy) = self.map(cx, cy);
let (x, y) = self.map(x, y);
self.d.push_str(&format!("Q{cx:.2} {cy:.2} {x:.2} {y:.2}"));
}
fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
let (c0x, c0y) = self.map(c0x, c0y);
let (c1x, c1y) = self.map(c1x, c1y);
let (x, y) = self.map(x, y);
self.d.push_str(&format!("C{c0x:.2} {c0y:.2} {c1x:.2} {c1y:.2} {x:.2} {y:.2}"));
}
fn close(&mut self) {
self.d.push('Z');
}
}
/// Append a text layer's glyphs to `body` as a single filled `<path>` of real glyph outlines
/// (lossless — no font dependency in the SVG). Lays the text out with the same parley path the
/// renderer uses, then extracts each glyph's outline with skrifa. Variable-font axis positions and
/// synthetic-italic skew are honored; synthetic bold is not (rare).
fn text_layer_to_svg(
tl: &crate::text_layer::TextLayer,
time: f64,
parent_opacity: f64,
body: &mut String,
) {
use skrifa::MetadataProvider;
if !tl.layer.visible {
return;
}
let content = tl.content_at(time);
if content.text.is_empty() {
return;
}
let (ox, oy) = (tl.box_origin.x, tl.box_origin.y);
let mut d = String::new();
crate::fonts::with_layout(content, tl.box_width as f32, |layout| {
for line in layout.lines() {
for item in line.items() {
let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue };
let run = glyph_run.run();
let font = run.font();
let font_size = run.font_size();
let skew = run
.synthesis()
.skew()
.map(|angle| (angle as f64).to_radians().tan())
.unwrap_or(0.0);
let Ok(font_ref) = skrifa::FontRef::from_index(font.data.data(), font.index) else {
continue;
};
let outlines = font_ref.outline_glyphs();
// Variable-font axis position for this run (empty for static fonts).
let coords: Vec<skrifa::instance::NormalizedCoord> = run
.normalized_coords()
.iter()
.map(|&c| skrifa::instance::NormalizedCoord::from_bits(c))
.collect();
let location = skrifa::instance::LocationRef::new(&coords);
let size = skrifa::instance::Size::new(font_size);
for g in glyph_run.positioned_glyphs() {
let Some(glyph) = outlines.get(skrifa::GlyphId::new(g.id as u32)) else {
continue;
};
let mut pen = SvgOutlinePen {
gx: ox + g.x as f64,
gy: oy + g.y as f64,
skew,
d: &mut d,
};
let settings = skrifa::outline::DrawSettings::unhinted(size, location);
let _ = glyph.draw(settings, &mut pen);
}
}
}
});
if d.is_empty() {
return;
}
let [r, g, b, a] = content.color;
let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
let fill_opacity = (a as f64 * parent_opacity * tl.layer.opacity).clamp(0.0, 1.0);
body.push_str(&format!(
r#"<path fill="rgb({},{},{})" fill-opacity="{:.4}" fill-rule="nonzero" d="{}"/>"#,
to_u8(r), to_u8(g), to_u8(b), fill_opacity, d
));
}
/// Emit a vector graph's fills (`<path fill>`) and stroked edges (`<path stroke>`) into `body`,
/// accumulating any gradients into `defs`. Geometry is in document space (no per-layer transform).
fn vector_graph_to_svg(graph: &VectorGraph, body: &mut String, defs: &mut String, grad_n: &mut usize) {
// Fills first (drawn under strokes, matching the renderer).
for (i, fill) in graph.fills.iter().enumerate() {
if fill.deleted {
continue;
}
let path = graph.fill_to_bezpath(FillId(i as u32));
let d = bezpath_to_d(&path);
if d.is_empty() {
continue;
}
let rule = match fill.fill_rule {
FillRule::NonZero => "nonzero",
FillRule::EvenOdd => "evenodd",
};
if let Some(grad) = &fill.gradient_fill {
let id = format!("grad{}", *grad_n);
*grad_n += 1;
defs.push_str(&gradient_to_svg(grad, &id, path.bounding_box()));
body.push_str(&format!(r#"<path fill="url(#{id})" fill-rule="{rule}" d="{d}"/>"#));
} else if fill.image_fill.is_some() {
// Image fills need <image>/<pattern> + asset embedding — skipped this (vector-only) pass.
continue;
} else if let Some(c) = &fill.color {
body.push_str(&format!(r#"<path {} fill-rule="{rule}" d="{d}"/>"#, fill_attrs(c)));
}
}
// Strokes: one <path> per stroked edge (each edge may carry its own style).
for edge in &graph.edges {
if edge.deleted {
continue;
}
if let (Some(style), Some(color)) = (&edge.stroke_style, &edge.stroke_color) {
let d = cubic_to_svg_path(&edge.curve);
body.push_str(&format!(
r#"<path fill="none" {} stroke-width="{:.3}" stroke-linecap="{}" stroke-linejoin="{}" stroke-miterlimit="{:.3}" d="{d}"/>"#,
stroke_attrs(color), style.width, cap_str(style.cap), join_str(style.join), style.miter_limit
));
}
}
}
/// `<linearGradient>` / `<radialGradient>` definition matching the renderer's start/end semantics.
fn gradient_to_svg(grad: &ShapeGradient, id: &str, bbox: Rect) -> String {
use kurbo::Point;
// Mirror renderer.rs: explicit world endpoints if present (radial reflects the edge through the
// center so midpoint(start,end) == center), else derive from angle + bbox.
let (start, end) = match (grad.start_world, grad.end_world) {
(Some((sx, sy)), Some((ex, ey))) => match grad.kind {
GradientType::Linear => (Point::new(sx, sy), Point::new(ex, ey)),
GradientType::Radial => (Point::new(2.0 * sx - ex, 2.0 * sy - ey), Point::new(ex, ey)),
},
_ => crate::renderer::gradient_bbox_endpoints(grad.angle, bbox),
};
let stops: String = grad
.stops
.iter()
.map(|s| {
format!(
r##"<stop offset="{:.4}" stop-color="#{:02x}{:02x}{:02x}" stop-opacity="{:.4}"/>"##,
s.position, s.color.r, s.color.g, s.color.b, s.color.a as f32 / 255.0
)
})
.collect();
let spread = match grad.extend {
GradientExtend::Pad => "pad",
GradientExtend::Reflect => "reflect",
GradientExtend::Repeat => "repeat",
};
match grad.kind {
GradientType::Linear => format!(
r#"<linearGradient id="{id}" gradientUnits="userSpaceOnUse" x1="{:.3}" y1="{:.3}" x2="{:.3}" y2="{:.3}" spreadMethod="{spread}">{stops}</linearGradient>"#,
start.x, start.y, end.x, end.y
),
GradientType::Radial => {
let (cx, cy) = ((start.x + end.x) * 0.5, (start.y + end.y) * 0.5);
let r = (((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt()) * 0.5;
format!(
r#"<radialGradient id="{id}" gradientUnits="userSpaceOnUse" cx="{cx:.3}" cy="{cy:.3}" r="{r:.3}" spreadMethod="{spread}">{stops}</radialGradient>"#
)
}
}
}
/// kurbo `BezPath` → SVG path-data string (`M/L/Q/C/Z`).
fn bezpath_to_d(path: &BezPath) -> String {
let mut d = String::new();
for el in path.elements() {
match el {
PathEl::MoveTo(p) => d.push_str(&format!("M{:.3} {:.3} ", p.x, p.y)),
PathEl::LineTo(p) => d.push_str(&format!("L{:.3} {:.3} ", p.x, p.y)),
PathEl::QuadTo(p1, p) => d.push_str(&format!("Q{:.3} {:.3} {:.3} {:.3} ", p1.x, p1.y, p.x, p.y)),
PathEl::CurveTo(p1, p2, p) => d.push_str(&format!(
"C{:.3} {:.3} {:.3} {:.3} {:.3} {:.3} ",
p1.x, p1.y, p2.x, p2.y, p.x, p.y
)),
PathEl::ClosePath => d.push_str("Z "),
}
}
d.trim_end().to_string()
}
// sRGB color → SVG attributes. Hex color + a separate `*-opacity` for max compatibility (Inkscape).
fn fill_attrs(c: &ShapeColor) -> String {
if c.a == 255 {
format!(r##"fill="#{:02x}{:02x}{:02x}""##, c.r, c.g, c.b)
} else {
format!(r##"fill="#{:02x}{:02x}{:02x}" fill-opacity="{:.4}""##, c.r, c.g, c.b, c.a as f32 / 255.0)
}
}
fn stroke_attrs(c: &ShapeColor) -> String {
if c.a == 255 {
format!(r##"stroke="#{:02x}{:02x}{:02x}""##, c.r, c.g, c.b)
} else {
format!(r##"stroke="#{:02x}{:02x}{:02x}" stroke-opacity="{:.4}""##, c.r, c.g, c.b, c.a as f32 / 255.0)
}
}
fn cap_str(cap: Cap) -> &'static str {
match cap {
Cap::Butt => "butt",
Cap::Round => "round",
Cap::Square => "square",
}
}
fn join_str(join: Join) -> &'static str {
match join {
Join::Miter => "miter",
Join::Round => "round",
Join::Bevel => "bevel",
}
}
#[cfg(test)]
mod export_tests {
use super::*;
use crate::shape::{ShapeColor, StrokeStyle};
use crate::vector_graph::{Direction, VectorGraph};
use kurbo::{CubicBez, Point};
fn line(a: Point, b: Point) -> CubicBez {
// Degenerate cubic representing a straight segment (matches our model).
CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b)
}
#[test]
fn solid_triangle_fill_and_stroke() {
let mut g = VectorGraph::new();
let p0 = Point::new(10.0, 10.0);
let p1 = Point::new(90.0, 10.0);
let p2 = Point::new(50.0, 80.0);
let v0 = g.alloc_vertex(p0);
let v1 = g.alloc_vertex(p1);
let v2 = g.alloc_vertex(p2);
let stroke = Some(StrokeStyle { width: 2.0, ..Default::default() });
let scol = Some(ShapeColor::rgb(0, 0, 0));
let e0 = g.alloc_edge(line(p0, p1), v0, v1, stroke.clone(), scol);
let e1 = g.alloc_edge(line(p1, p2), v1, v2, stroke.clone(), scol);
let e2 = g.alloc_edge(line(p2, p0), v2, v0, stroke.clone(), scol);
g.alloc_fill(
vec![(e0, Direction::Forward), (e1, Direction::Forward), (e2, Direction::Forward)],
ShapeColor::rgb(255, 0, 0),
crate::shape::FillRule::NonZero,
);
let mut body = String::new();
let mut defs = String::new();
let mut n = 0;
vector_graph_to_svg(&g, &mut body, &mut defs, &mut n);
assert!(body.contains(r##"fill="#ff0000""##), "fill color missing: {body}");
assert!(body.contains(r#"fill-rule="nonzero""#), "fill-rule missing: {body}");
assert!(body.contains(r#"fill="none""#), "stroke path missing: {body}");
assert!(body.contains(r#"stroke-width="2.000""#), "stroke width missing: {body}");
assert!(defs.is_empty(), "no gradients expected: {defs}");
// 1 fill path + 3 stroked edges = 4 <path> elements.
assert_eq!(body.matches("<path").count(), 4, "{body}");
}
#[test]
fn outline_pen_maps_yflip_and_skew() {
use skrifa::outline::OutlinePen;
let mut d = String::new();
{
let mut pen = SvgOutlinePen { gx: 10.0, gy: 100.0, skew: 0.0, d: &mut d };
pen.move_to(0.0, 0.0); // baseline origin → (10, 100)
pen.line_to(5.0, 20.0); // 20 up → y = 100 20 = 80
pen.close();
}
assert!(d.contains("M10.00 100.00"), "d={d}");
assert!(d.contains("L15.00 80.00"), "d={d}");
assert!(d.ends_with('Z'));
// Synthetic-italic skew shifts x right in proportion to height.
let mut d2 = String::new();
{
let mut pen = SvgOutlinePen { gx: 0.0, gy: 0.0, skew: 0.5, d: &mut d2 };
pen.move_to(0.0, 10.0); // x = 0 + 0.5·10 = 5, y = 10
}
assert!(d2.contains("M5.00 -10.00"), "d={d2}");
}
#[test]
fn text_layer_emits_real_glyph_outlines() {
use crate::text_layer::TextLayer;
let mut tl = TextLayer::new("t", Point::new(20.0, 60.0));
tl.content.text = "Hi".to_string();
tl.content.font_size = 48.0;
tl.content.color = [1.0, 0.0, 0.0, 1.0];
let mut body = String::new();
text_layer_to_svg(&tl, 0.0, 1.0, &mut body);
// Bundled fonts guarantee glyphs → a filled path with actual outline segments.
assert!(body.contains("<path"), "no path emitted: {body}");
assert!(body.contains(r#"fill="rgb(255,0,0)""#), "wrong fill: {body}");
assert!(
body.contains('C') || body.contains('Q') || body.contains('L'),
"path has no outline segments: {body}"
);
assert!(body.len() > 80, "suspiciously short path: {body}");
}
#[test]
fn empty_text_layer_emits_nothing() {
use crate::text_layer::TextLayer;
let tl = TextLayer::new("t", Point::new(0.0, 0.0)); // no text set
let mut body = String::new();
text_layer_to_svg(&tl, 0.0, 1.0, &mut body);
assert!(body.is_empty(), "empty text should emit nothing: {body}");
}
}