feat(export): selected-node SVG export with canvas-parity serialization

Add SVG to the property panel export formats (PNG/JPEG/WEBP/SVG).
Selected nodes export as a tightly cropped SVG subtree preserving
ancestor rotation/flip/clip state; with no selection the full page
is serialized. Desktop writes through the save dialog, web reuses
the blob download path.

Serialization matches canvas rendering: tight-bounds path fitting
with non-scaling strokes, user-space gradient endpoints, implicit
strokes for open point paths, intrinsic-size image tiling, rounded
image clipping, per-line text without duplicate ids, and
collision-free defs ids.
This commit is contained in:
Kayshen-X 2026-07-11 18:43:57 +08:00
parent 20122d4b81
commit ba16f755f5
15 changed files with 1378 additions and 125 deletions

View file

@ -81,6 +81,17 @@ pub mod statusbar_state;
pub mod svg_import;
pub mod svg_path_bounds;
mod svg_path_data;
/// Tight source-coordinate bounds for an SVG path-data string.
pub fn svg_path_data_bounds(d: &str) -> Option<(f32, f32, f32, f32)> {
let bounds = svg_path_data::svg_path_bounds(d)?;
Some((
bounds.x as f32,
bounds.y as f32,
bounds.w as f32,
bounds.h as f32,
))
}
pub mod text_edit;
pub mod text_input_focus;
pub mod theme_presets;

View file

@ -37,11 +37,21 @@ enum Segment {
/// the path is local to its own bbox origin.
pub(crate) fn localize_svg_path(d: &str) -> Option<(String, SvgPathBounds)> {
let segments = absolute_segments(d)?;
let bounds = segment_bounds(&segments)?;
let tight = segment_bounds(&segments)?;
let bounds = SvgPathBounds {
x: tight.x.floor(),
y: tight.y.floor(),
w: (tight.x + tight.w - tight.x.floor()).ceil().max(1.0),
h: (tight.y + tight.h - tight.y.floor()).ceil().max(1.0),
};
let local = serialize_local_segments(&segments, bounds.x, bounds.y);
Some((local, bounds))
}
pub(crate) fn svg_path_bounds(d: &str) -> Option<SvgPathBounds> {
segment_bounds(&absolute_segments(d)?)
}
fn absolute_segments(d: &str) -> Option<Vec<Segment>> {
let tokens = tokenize_path(d);
if tokens.is_empty() {
@ -257,10 +267,10 @@ fn segment_bounds(segments: &[Segment]) -> Option<SvgPathBounds> {
return None;
}
Some(SvgPathBounds {
x: min_x.floor(),
y: min_y.floor(),
w: (max_x - min_x.floor()).ceil().max(1.0),
h: (max_y - min_y.floor()).ceil().max(1.0),
x: min_x,
y: min_y,
w: max_x - min_x,
h: max_y - min_y,
})
}

View file

@ -5,10 +5,22 @@
//! from both native and wasm builds; host-specific code decides
//! whether to write the string to disk or download it in the browser.
use crate::layout_scene::{regular_polygon_points, LayoutScene, NodeKind, SceneNode, ScenePage};
use crate::layout_scene::{
regular_polygon_points, LayoutScene, NodeKind, SceneGradient, SceneImageFit, SceneNode,
ScenePage,
};
use crate::{Color, Point2D, Rect};
use std::fmt::Write as _;
mod image_metadata;
mod transform;
use transform::{
apply_ancestor_clip_bounds, apply_clip_bounds, close_ancestor_clip_groups,
emit_affine_group_start, emit_ancestor_clip_defs, emit_ancestor_clip_groups_start,
find_node_with_ancestor_context, node_transform, svg_id, AncestorClip,
};
const MARGIN: f32 = 16.0;
const TEXT_DEFAULT_FONT_SIZE: f32 = 13.0;
const TEXT_DEFAULT_FILL: Color = Color {
@ -23,23 +35,193 @@ pub fn serialize_active_page_svg(scene: &LayoutScene) -> Result<String, String>
let Some(page) = scene.active_page() else {
return Err("no active page".into());
};
let bounds = page_bounds(page).ok_or("nothing to export")?;
let view_x = bounds.origin.x - MARGIN;
let view_y = bounds.origin.y - MARGIN;
let view_w = bounds.size.x + MARGIN * 2.0;
let view_h = bounds.size.y + MARGIN * 2.0;
serialize_svg(
&page.children,
page_bounds(page).ok_or("nothing to export")?,
MARGIN,
glam::Affine2::IDENTITY,
&[],
)
}
/// Serialize one node and its complete subtree, tightly cropped to its
/// painted bounds. The lookup is scoped to the active page.
pub fn serialize_node_svg(scene: &LayoutScene, node_id: &str) -> Result<String, String> {
let page = scene.active_page().ok_or("no active page")?;
let (node, ancestor_xform, ancestor_clips) =
find_node_with_ancestor_context(&page.children, node_id)
.ok_or_else(|| format!("node {node_id} not found on the active page"))?;
let mut acc = BoundsAcc::new();
collect_bounds(node, ancestor_xform, &mut acc);
let painted_bounds = acc
.into_rect()
.ok_or_else(|| format!("node {node_id} paints nothing"))?;
let bounds = apply_ancestor_clip_bounds(painted_bounds, &ancestor_clips)
.ok_or_else(|| format!("node {node_id} paints nothing"))?;
serialize_svg(
std::slice::from_ref(node),
bounds,
0.0,
ancestor_xform,
&ancestor_clips,
)
}
fn serialize_svg(
nodes: &[SceneNode],
bounds: Rect,
margin: f32,
root_xform: glam::Affine2,
ancestor_clips: &[AncestorClip],
) -> Result<String, String> {
let view_x = bounds.origin.x - margin;
let view_y = bounds.origin.y - margin;
let view_w = bounds.size.x + margin * 2.0;
let view_h = bounds.size.y + margin * 2.0;
if view_w <= 0.0 || view_h <= 0.0 {
return Err("nothing to export".into());
}
let mut svg = String::with_capacity(4096);
let _ = write!(
svg,
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{view_x} {view_y} {view_w} {view_h}" width="{view_w}" height="{view_h}">"#
);
for node in &page.children {
emit_defs(&mut svg, nodes, ancestor_clips);
emit_ancestor_clip_groups_start(&mut svg, ancestor_clips);
let wraps_root = root_xform != glam::Affine2::IDENTITY;
if wraps_root {
emit_affine_group_start(&mut svg, root_xform);
}
for node in nodes {
emit_node(&mut svg, node);
}
if wraps_root {
svg.push_str("</g>");
}
close_ancestor_clip_groups(&mut svg, ancestor_clips);
svg.push_str("</svg>");
Ok(svg)
}
fn emit_defs(out: &mut String, nodes: &[SceneNode], ancestor_clips: &[AncestorClip]) {
let mut defs = String::new();
emit_ancestor_clip_defs(&mut defs, ancestor_clips);
for node in nodes {
emit_node_defs(&mut defs, node);
}
if !defs.is_empty() {
out.push_str("<defs>");
out.push_str(&defs);
out.push_str("</defs>");
}
}
fn emit_node_defs(out: &mut String, n: &SceneNode) {
let id = svg_id(&n.id);
if let Some(gradient) = &n.gradient {
emit_gradient(
out,
&format!("gradient-{id}"),
gradient,
normalize_rect(n.bounds),
);
}
let clips_image = n.image_src.is_some() && n.corner_radius > 0.0;
if (n.clip_content || clips_image) && n.bounds.size.x != 0.0 && n.bounds.size.y != 0.0 {
let r = normalize_rect(n.bounds);
let _ = write!(
out,
r#"<clipPath id="clip-{id}"><rect x="{}" y="{}" width="{}" height="{}" rx="{}"/></clipPath>"#,
r.origin.x,
r.origin.y,
r.size.x,
r.size.y,
n.corner_radius.max(0.0),
);
}
if n.image_fit == SceneImageFit::Tile {
if let Some(src) = n.image_src.as_deref() {
emit_image_pattern(out, n, src, &id);
}
}
for child in &n.children {
emit_node_defs(out, child);
}
}
fn emit_gradient(out: &mut String, id: &str, gradient: &SceneGradient, bounds: Rect) {
match gradient {
SceneGradient::Linear {
angle_deg,
opacity,
stops,
} => {
let angle = (angle_deg - 90.0).to_radians();
let cx = bounds.origin.x + bounds.size.x * 0.5;
let cy = bounds.origin.y + bounds.size.y * 0.5;
let dx = angle.cos() * bounds.size.x * 0.5;
let dy = angle.sin() * bounds.size.y * 0.5;
let _ = write!(
out,
r#"<linearGradient id="{id}" gradientUnits="userSpaceOnUse" x1="{}" y1="{}" x2="{}" y2="{}">"#,
cx - dx,
cy - dy,
cx + dx,
cy + dy,
);
emit_stops(out, stops, *opacity);
out.push_str("</linearGradient>");
}
SceneGradient::Radial {
cx,
cy,
radius,
opacity,
stops,
} => {
let center_x = bounds.origin.x + bounds.size.x * cx.clamp(0.0, 1.0);
let center_y = bounds.origin.y + bounds.size.y * cy.clamp(0.0, 1.0);
let outer = (bounds.size.x.max(bounds.size.y) * radius.clamp(0.0, 1.0)).max(0.01);
let _ = write!(
out,
r#"<radialGradient id="{id}" gradientUnits="userSpaceOnUse" cx="{center_x}" cy="{center_y}" r="{outer}">"#
);
emit_stops(out, stops, *opacity);
out.push_str("</radialGradient>");
}
SceneGradient::Mesh {
colors, opacity, ..
} => {
if let Some(color) = colors.first() {
let _ = write!(
out,
r#"<linearGradient id="{id}"><stop offset="0" stop-color="{}" stop-opacity="{}"/><stop offset="1" stop-color="{}" stop-opacity="{}"/></linearGradient>"#,
color_to_rgb(*color),
color.a * opacity,
color_to_rgb(*color),
color.a * opacity
);
}
}
}
}
fn emit_stops(out: &mut String, stops: &[crate::layout_scene::SceneGradientStop], opacity: f32) {
// `SceneGradient::opacity` already includes the node's cumulative
// layer opacity, matching the canvas shader path. Applying
// `SceneNode::opacity` again here or on an enclosing group would
// double-dim gradients and descendants.
for stop in stops {
let _ = write!(
out,
r#"<stop offset="{}" stop-color="{}" stop-opacity="{}"/>"#,
stop.offset.clamp(0.0, 1.0),
color_to_rgb(stop.color),
stop.color.a * opacity.clamp(0.0, 1.0)
);
}
}
fn page_bounds(page: &ScenePage) -> Option<Rect> {
let mut acc = BoundsAcc::new();
for n in &page.children {
@ -87,21 +269,7 @@ fn collect_bounds(n: &SceneNode, parent_xform: glam::Affine2, acc: &mut BoundsAc
if n.hidden {
return;
}
let pivot_rect = n.aggregate_bounds();
let rotate_self =
n.rotation.abs() > f32::EPSILON && (pivot_rect.size.x != 0.0 || pivot_rect.size.y != 0.0);
let local_xform = if rotate_self {
let pivot = glam::Vec2::new(
pivot_rect.origin.x + pivot_rect.size.x * 0.5,
pivot_rect.origin.y + pivot_rect.size.y * 0.5,
);
parent_xform
* glam::Affine2::from_translation(pivot)
* glam::Affine2::from_angle(n.rotation)
* glam::Affine2::from_translation(-pivot)
} else {
parent_xform
};
let local_xform = parent_xform * node_transform(n);
if let Some(local_corners) = own_paint_corners(n) {
for p in local_corners {
let w = local_xform.transform_point2(p);
@ -109,15 +277,20 @@ fn collect_bounds(n: &SceneNode, parent_xform: glam::Affine2, acc: &mut BoundsAc
}
}
if n.clip_content && !n.children.is_empty() && n.bounds.size.x > 0.0 && n.bounds.size.y > 0.0 {
let nr = normalize_rect(n.bounds);
for (x, y) in [
(nr.origin.x, nr.origin.y),
(nr.origin.x + nr.size.x, nr.origin.y),
(nr.origin.x + nr.size.x, nr.origin.y + nr.size.y),
(nr.origin.x, nr.origin.y + nr.size.y),
] {
let w = local_xform.transform_point2(glam::Vec2::new(x, y));
acc.add(w.x, w.y, w.x, w.y);
let mut child_acc = BoundsAcc::new();
for child in &n.children {
collect_bounds(child, local_xform, &mut child_acc);
}
if let Some(visible) = child_acc
.into_rect()
.and_then(|bounds| apply_clip_bounds(bounds, n.bounds, local_xform))
{
acc.add(
visible.origin.x,
visible.origin.y,
visible.origin.x + visible.size.x,
visible.origin.y + visible.size.y,
);
}
return;
}
@ -139,7 +312,7 @@ fn own_paint_corners(n: &SceneNode) -> Option<Vec<glam::Vec2>> {
)
}
NodeKind::Frame => {
if n.fill.is_none() && n.stroke.is_none() {
if n.fill.is_none() && n.gradient.is_none() && n.stroke.is_none() {
return None;
}
let nr = normalize_rect(n.bounds);
@ -163,7 +336,11 @@ fn own_paint_corners(n: &SceneNode) -> Option<Vec<glam::Vec2>> {
)
}
NodeKind::Other(_) => {
if n.fill.is_none() && n.stroke.is_none() {
if n.fill.is_none()
&& n.gradient.is_none()
&& n.stroke.is_none()
&& n.image_src.is_none()
{
return None;
}
let nr = normalize_rect(n.bounds);
@ -188,7 +365,9 @@ fn own_paint_corners(n: &SceneNode) -> Option<Vec<glam::Vec2>> {
)
}
NodeKind::Path => {
if n.svg_path.is_some() && (n.fill.is_some() || n.stroke.is_some()) {
if n.svg_path.is_some()
&& (n.fill.is_some() || n.gradient.is_some() || n.stroke.is_some())
{
let nr = normalize_rect(n.bounds);
return Some(vec![
glam::Vec2::new(nr.origin.x - stroke_pad, nr.origin.y - stroke_pad),
@ -235,13 +414,10 @@ fn emit_node(out: &mut String, n: &SceneNode) {
if n.hidden {
return;
}
let pivot = n.aggregate_bounds();
let needs_g = n.rotation.abs() > f32::EPSILON && (pivot.size.x != 0.0 || pivot.size.y != 0.0);
let local_xform = node_transform(n);
let needs_g = local_xform != glam::Affine2::IDENTITY;
if needs_g {
let cx = pivot.origin.x + pivot.size.x * 0.5;
let cy = pivot.origin.y + pivot.size.y * 0.5;
let deg = n.rotation.to_degrees();
let _ = write!(out, r#"<g transform="rotate({deg} {cx} {cy})">"#);
emit_affine_group_start(out, local_xform);
}
match &n.kind {
NodeKind::Rect | NodeKind::Frame => emit_rect(out, n),
@ -250,18 +426,39 @@ fn emit_node(out: &mut String, n: &SceneNode) {
NodeKind::Line => emit_line(out, n),
NodeKind::Path => emit_path(out, n),
NodeKind::Text => emit_text(out, n),
NodeKind::Group | NodeKind::Other(_) => {}
NodeKind::Group => {}
NodeKind::Other(_) => {
if n.image_src.is_some() {
emit_image(out, n);
} else if n.fill.is_some() || n.gradient.is_some() || n.stroke.is_some() {
emit_rect(out, n);
}
}
}
let clips_children = n.clip_content
&& !n.children.is_empty()
&& n.bounds.size.x != 0.0
&& n.bounds.size.y != 0.0;
if clips_children {
let _ = write!(out, r#"<g clip-path="url(#clip-{})">"#, svg_id(&n.id));
}
for child in &n.children {
emit_node(out, child);
}
if clips_children {
out.push_str("</g>");
}
if needs_g {
out.push_str("</g>");
}
}
fn emit_rect(out: &mut String, n: &SceneNode) {
if n.fill.is_none() && n.stroke.is_none() && !matches!(n.kind, NodeKind::Rect) {
if n.fill.is_none()
&& n.gradient.is_none()
&& n.stroke.is_none()
&& !matches!(n.kind, NodeKind::Rect)
{
return;
}
let r = normalize_rect(n.bounds);
@ -275,7 +472,8 @@ fn emit_rect(out: &mut String, n: &SceneNode) {
};
let _ = write!(
out,
r#"<rect x="{}" y="{}" width="{}" height="{}"{rx}{}/>"#,
r#"<rect id="{}" x="{}" y="{}" width="{}" height="{}"{rx}{}/>"#,
xml_escape(&n.id),
r.origin.x,
r.origin.y,
r.size.x,
@ -293,7 +491,8 @@ fn emit_ellipse(out: &mut String, n: &SceneNode) {
let cy = r.origin.y + r.size.y * 0.5;
let _ = write!(
out,
r#"<ellipse cx="{cx}" cy="{cy}" rx="{}" ry="{}"{}/>"#,
r#"<ellipse id="{}" cx="{cx}" cy="{cy}" rx="{}" ry="{}"{}/>"#,
xml_escape(&n.id),
r.size.x * 0.5,
r.size.y * 0.5,
fill_stroke_attrs(n),
@ -315,7 +514,8 @@ fn emit_polygon(out: &mut String, n: &SceneNode) {
}
let _ = write!(
out,
r#"<polygon points="{point_attr}"{}/>"#,
r#"<polygon id="{}" points="{point_attr}"{}/>"#,
xml_escape(&n.id),
fill_stroke_attrs(n),
);
}
@ -330,7 +530,8 @@ fn emit_line(out: &mut String, n: &SceneNode) {
let y2 = r.origin.y + r.size.y;
let _ = write!(
out,
r#"<line x1="{}" y1="{}" x2="{x2}" y2="{y2}"{}/>"#,
r#"<line id="{}" x1="{}" y1="{}" x2="{x2}" y2="{y2}"{}/>"#,
xml_escape(&n.id),
r.origin.x,
r.origin.y,
stroke_attrs(color, width),
@ -366,38 +567,160 @@ fn emit_text(out: &mut String, n: &SceneNode) {
} else {
String::new()
};
let _ = write!(out, r#"<g id="{}">"#, xml_escape(&n.id));
let mut baseline_y = r.origin.y + base_size + 1.0;
for line in text.split('\n') {
let _ = write!(
out,
r#"<text x="{}" y="{baseline_y}" font-family="system-ui, sans-serif" font-size="{base_size}"{weight_attr}{fill_attr}>{}</text>"#,
r#"<text x="{}" y="{baseline_y}" font-family="{}" font-size="{base_size}"{weight_attr}{fill_attr}{}{}>{}</text>"#,
r.origin.x,
xml_escape(if n.font_family.is_empty() {
"system-ui, sans-serif"
} else {
&n.font_family
}),
if n.italic {
r#" font-style="italic""#
} else {
""
},
text_decoration_attr(n),
xml_escape(line),
);
baseline_y += line_h;
}
out.push_str("</g>");
}
fn emit_path(out: &mut String, n: &SceneNode) {
if n.points.len() < 2 {
return;
}
let (color, width) = match n.stroke {
Some(s) => (s.color, s.width),
None => (n.fill.unwrap_or(Color::BLACK), 1.5),
let (d, transform) = if let Some(d) = &n.svg_path {
(d.clone(), svg_path_fit_transform(d, n.bounds))
} else {
if n.points.len() < 2 {
return;
}
let mut d = String::with_capacity(n.points.len() * 16);
let _ = write!(d, "M{} {}", n.points[0].x, n.points[0].y);
for p in &n.points[1..] {
let _ = write!(d, " L{} {}", p.x, p.y);
}
if n.path_closed {
d.push_str(" Z");
}
(d, String::new())
};
let attrs = if n.svg_path.is_none() && !n.path_closed && n.stroke.is_none() {
let color = n.fill.unwrap_or(Color::BLACK);
format!(r#" fill="none"{}"#, stroke_attrs(color, 1.5))
} else {
fill_stroke_attrs(n)
};
let vector_effect = if n.svg_path.is_some() && n.stroke.is_some() {
r#" vector-effect="non-scaling-stroke""#
} else {
""
};
let mut d = String::with_capacity(n.points.len() * 16);
let _ = write!(d, "M{} {}", n.points[0].x, n.points[0].y);
for p in &n.points[1..] {
let _ = write!(d, " L{} {}", p.x, p.y);
}
let _ = write!(
out,
r#"<path d="{d}" fill="none"{}/>"#,
stroke_attrs(color, width),
r#"<path id="{}" d="{}"{transform}{vector_effect}{}/>"#,
xml_escape(&n.id),
xml_escape(&d),
attrs
);
}
fn svg_path_fit_transform(d: &str, bounds: Rect) -> String {
let rect = normalize_rect(bounds);
let Some((source_x, source_y, source_w, source_h)) = op_editor_core::svg_path_data_bounds(d)
else {
return format!(
r#" transform="translate({} {})""#,
rect.origin.x, rect.origin.y
);
};
let sx = if source_w.abs() > 0.01 {
rect.size.x / source_w
} else {
1.0
};
let sy = if source_h.abs() > 0.01 {
rect.size.y / source_h
} else {
1.0
};
let tx = rect.origin.x - source_x * sx;
let ty = rect.origin.y - source_y * sy;
format!(r#" transform="matrix({sx} 0 0 {sy} {tx} {ty})""#)
}
fn emit_image(out: &mut String, n: &SceneNode) {
let Some(src) = n.image_src.as_deref() else {
return;
};
let r = normalize_rect(n.bounds);
let id = svg_id(&n.id);
let clip = if n.corner_radius > 0.0 {
format!(r#" clip-path="url(#clip-{id})""#)
} else {
String::new()
};
if n.image_fit == SceneImageFit::Tile {
let _ = write!(
out,
r#"<rect id="{}" x="{}" y="{}" width="{}" height="{}" fill="url(#image-pattern-{id})" opacity="{}"{clip}/>"#,
xml_escape(&n.id),
r.origin.x,
r.origin.y,
r.size.x,
r.size.y,
n.opacity.clamp(0.0, 1.0),
);
return;
}
let preserve = match n.image_fit {
SceneImageFit::Fit => "xMidYMid meet",
SceneImageFit::Crop | SceneImageFit::Fill => "xMidYMid slice",
SceneImageFit::Stretch => "none",
SceneImageFit::Tile => unreachable!("tile images return above"),
};
let _ = write!(
out,
r#"<image id="{}" x="{}" y="{}" width="{}" height="{}" href="{}" preserveAspectRatio="{}" opacity="{}"{clip}/>"#,
xml_escape(&n.id),
r.origin.x,
r.origin.y,
r.size.x,
r.size.y,
xml_escape(src),
preserve,
n.opacity.clamp(0.0, 1.0)
);
}
fn emit_image_pattern(out: &mut String, n: &SceneNode, src: &str, id: &str) {
let r = normalize_rect(n.bounds);
// If source bytes are unavailable (for example an uncached remote URL),
// fall back to one bounds-sized cell instead of inventing a repeat scale.
let (tile_w, tile_h) = image_metadata::intrinsic_dimensions(n, src)
.unwrap_or((r.size.x.max(1.0), r.size.y.max(1.0)));
let start_x = r.origin.x + (r.size.x - tile_w) * 0.5;
let start_y = r.origin.y + (r.size.y - tile_h) * 0.5;
let _ = write!(
out,
r#"<pattern id="image-pattern-{id}" patternUnits="userSpaceOnUse" x="{start_x}" y="{start_y}" width="{tile_w}" height="{tile_h}" viewBox="0 0 {tile_w} {tile_h}"><image width="{tile_w}" height="{tile_h}" href="{}" preserveAspectRatio="none"/></pattern>"#,
xml_escape(src),
);
}
fn text_decoration_attr(n: &SceneNode) -> &'static str {
match (n.underline, n.strikethrough) {
(true, true) => r#" text-decoration="underline line-through""#,
(true, false) => r#" text-decoration="underline""#,
(false, true) => r#" text-decoration="line-through""#,
(false, false) => "",
}
}
fn normalize_rect(r: Rect) -> Rect {
let x0 = r.origin.x.min(r.origin.x + r.size.x);
let y0 = r.origin.y.min(r.origin.y + r.size.y);
@ -424,7 +747,9 @@ fn xml_escape(text: &str) -> String {
fn fill_stroke_attrs(n: &SceneNode) -> String {
let mut s = String::new();
if let Some(fill) = n.fill {
if n.gradient.is_some() {
let _ = write!(s, r#" fill="url(#gradient-{})""#, svg_id(&n.id));
} else if let Some(fill) = n.fill {
let _ = write!(s, r#" fill="{}""#, color_to_rgb(fill));
if fill.a < 0.999 {
let _ = write!(s, r#" fill-opacity="{}""#, fill.a);
@ -460,56 +785,5 @@ fn color_to_rgb(c: Color) -> String {
}
#[cfg(test)]
mod tests {
use super::*;
fn scene_with(children: Vec<SceneNode>) -> LayoutScene {
LayoutScene {
pages: vec![ScenePage {
id: "p1".into(),
name: "Page 1".into(),
children,
}],
active_page_index: 0,
}
}
fn filled_rect(id: &str, x: f32, y: f32, w: f32, h: f32, fill: Color) -> SceneNode {
let mut n = SceneNode::leaf(id, NodeKind::Rect);
n.bounds = Rect::xywh(x, y, w, h);
n.fill = Some(fill);
n
}
#[test]
fn active_page_svg_contains_vector_markup() {
let scene = scene_with(vec![filled_rect(
"r1",
5.0,
5.0,
120.0,
60.0,
Color {
r: 0.2,
g: 0.4,
b: 0.6,
a: 1.0,
},
)]);
let body = serialize_active_page_svg(&scene).expect("svg");
assert!(body.starts_with("<svg "), "missing svg root: {body}");
assert!(body.contains("<rect "), "missing rect element: {body}");
assert!(body.contains(r#"width="120""#), "rect width wrong: {body}");
assert!(body.ends_with("</svg>"), "missing svg close: {body}");
}
#[test]
fn active_page_svg_fails_on_empty_scene() {
let scene = scene_with(Vec::new());
let res = serialize_active_page_svg(&scene);
assert!(res.is_err());
assert_eq!(res.unwrap_err(), "nothing to export");
}
}
#[path = "svg_export_tests.rs"]
mod tests;

View file

@ -0,0 +1,100 @@
use crate::layout_scene::{stable_image_source_id, SceneNode};
pub(super) fn intrinsic_dimensions(n: &SceneNode, src: &str) -> Option<(f32, f32)> {
let id = if n.image_src_id == 0 {
stable_image_source_id(src)
} else {
n.image_src_id
};
let bytes = crate::widgets::canvas_viewport_image::image_source_bytes(src, id)?;
encoded_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32))
}
fn encoded_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
png_dimensions(bytes)
.or_else(|| jpeg_dimensions(bytes))
.or_else(|| webp_dimensions(bytes))
}
fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
return None;
}
nonzero_dimensions(
u32::from_be_bytes(bytes[16..20].try_into().ok()?),
u32::from_be_bytes(bytes[20..24].try_into().ok()?),
)
}
fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
if !bytes.starts_with(&[0xff, 0xd8]) {
return None;
}
let mut offset = 2;
while offset + 3 < bytes.len() {
if bytes[offset] != 0xff {
offset += 1;
continue;
}
while offset < bytes.len() && bytes[offset] == 0xff {
offset += 1;
}
let marker = *bytes.get(offset)?;
offset += 1;
if matches!(marker, 0x01 | 0xd0..=0xd9) {
continue;
}
let length = u16::from_be_bytes([*bytes.get(offset)?, *bytes.get(offset + 1)?]) as usize;
if length < 2 || offset + length > bytes.len() {
return None;
}
if is_start_of_frame(marker) && length >= 7 {
let height = u16::from_be_bytes([bytes[offset + 3], bytes[offset + 4]]) as u32;
let width = u16::from_be_bytes([bytes[offset + 5], bytes[offset + 6]]) as u32;
return nonzero_dimensions(width, height);
}
offset += length;
}
None
}
fn is_start_of_frame(marker: u8) -> bool {
matches!(
marker,
0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf
)
}
fn webp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
if bytes.len() < 30 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
return None;
}
match &bytes[12..16] {
b"VP8X" => nonzero_dimensions(
read_u24_le(&bytes[24..27])? + 1,
read_u24_le(&bytes[27..30])? + 1,
),
b"VP8 " if bytes.len() >= 30 && bytes[23..26] == [0x9d, 0x01, 0x2a] => {
let width = u16::from_le_bytes([bytes[26], bytes[27]]) & 0x3fff;
let height = u16::from_le_bytes([bytes[28], bytes[29]]) & 0x3fff;
nonzero_dimensions(width.into(), height.into())
}
b"VP8L" if bytes.len() >= 25 && bytes[20] == 0x2f => {
let bits = u32::from_le_bytes(bytes[21..25].try_into().ok()?);
nonzero_dimensions((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1)
}
_ => None,
}
}
fn read_u24_le(bytes: &[u8]) -> Option<u32> {
Some(
u32::from(*bytes.first()?)
| (u32::from(*bytes.get(1)?) << 8)
| (u32::from(*bytes.get(2)?) << 16),
)
}
fn nonzero_dimensions(width: u32, height: u32) -> Option<(u32, u32)> {
(width > 0 && height > 0).then_some((width, height))
}

View file

@ -0,0 +1,172 @@
use crate::layout_scene::SceneNode;
use crate::{Point2D, Rect};
use std::fmt::Write as _;
#[derive(Clone)]
pub(super) struct AncestorClip {
id: String,
bounds: Rect,
corner_radius: f32,
xform: glam::Affine2,
}
pub(super) fn find_node_with_ancestor_context<'a>(
nodes: &'a [SceneNode],
node_id: &str,
) -> Option<(&'a SceneNode, glam::Affine2, Vec<AncestorClip>)> {
find_node_inner(nodes, node_id, glam::Affine2::IDENTITY, &mut Vec::new())
}
fn find_node_inner<'a>(
nodes: &'a [SceneNode],
node_id: &str,
ancestor_xform: glam::Affine2,
clips: &mut Vec<AncestorClip>,
) -> Option<(&'a SceneNode, glam::Affine2, Vec<AncestorClip>)> {
for node in nodes {
if node.id == node_id {
return Some((node, ancestor_xform, clips.clone()));
}
let child_xform = ancestor_xform * node_transform(node);
let clips_children = node.clip_content
&& !node.children.is_empty()
&& node.bounds.size.x != 0.0
&& node.bounds.size.y != 0.0;
if clips_children {
clips.push(AncestorClip {
id: format!("ancestor-clip-{}", svg_id(&node.id)),
bounds: normalize_rect(node.bounds),
corner_radius: node.corner_radius.max(0.0),
xform: child_xform,
});
}
if let Some(found) = find_node_inner(&node.children, node_id, child_xform, clips) {
return Some(found);
}
if clips_children {
clips.pop();
}
}
None
}
pub(super) fn node_transform(n: &SceneNode) -> glam::Affine2 {
let pivot_rect = n.aggregate_bounds();
if pivot_rect.size.x == 0.0 && pivot_rect.size.y == 0.0 {
return glam::Affine2::IDENTITY;
}
let pivot = glam::Vec2::new(
pivot_rect.origin.x + pivot_rect.size.x * 0.5,
pivot_rect.origin.y + pivot_rect.size.y * 0.5,
);
let around = |inner| {
glam::Affine2::from_translation(pivot) * inner * glam::Affine2::from_translation(-pivot)
};
let flip = around(glam::Affine2::from_scale(glam::Vec2::new(
if n.flip_x { -1.0 } else { 1.0 },
if n.flip_y { -1.0 } else { 1.0 },
)));
let rotation = around(glam::Affine2::from_angle(n.rotation));
flip * rotation
}
pub(super) fn emit_affine_group_start(out: &mut String, xform: glam::Affine2) {
let _ = write!(out, r#"<g transform="{}">"#, matrix_value(xform));
}
pub(super) fn emit_ancestor_clip_defs(out: &mut String, clips: &[AncestorClip]) {
for clip in clips {
let r = clip.bounds;
let _ = write!(
out,
r#"<clipPath id="{}" clipPathUnits="userSpaceOnUse"><rect x="{}" y="{}" width="{}" height="{}" rx="{}" transform="{}"/></clipPath>"#,
clip.id,
r.origin.x,
r.origin.y,
r.size.x,
r.size.y,
clip.corner_radius,
matrix_value(clip.xform),
);
}
}
pub(super) fn emit_ancestor_clip_groups_start(out: &mut String, clips: &[AncestorClip]) {
for clip in clips {
let _ = write!(out, r#"<g clip-path="url(#{})">"#, clip.id);
}
}
pub(super) fn close_ancestor_clip_groups(out: &mut String, clips: &[AncestorClip]) {
for _ in clips {
out.push_str("</g>");
}
}
pub(super) fn apply_ancestor_clip_bounds(mut bounds: Rect, clips: &[AncestorClip]) -> Option<Rect> {
for clip in clips {
bounds = intersect(bounds, transformed_bounds(clip.bounds, clip.xform))?;
}
Some(bounds)
}
pub(super) fn apply_clip_bounds(
bounds: Rect,
clip_bounds: Rect,
xform: glam::Affine2,
) -> Option<Rect> {
intersect(
bounds,
transformed_bounds(normalize_rect(clip_bounds), xform),
)
}
pub(super) fn svg_id(id: &str) -> String {
let mut out = String::with_capacity(2 + id.len() * 2);
out.push_str("n-");
for byte in id.as_bytes() {
let _ = write!(out, "{byte:02x}");
}
out
}
fn matrix_value(xform: glam::Affine2) -> String {
let m = xform.matrix2;
let t = xform.translation;
format!(
"matrix({} {} {} {} {} {})",
m.x_axis.x, m.x_axis.y, m.y_axis.x, m.y_axis.y, t.x, t.y
)
}
fn transformed_bounds(rect: Rect, xform: glam::Affine2) -> Rect {
let points = [
glam::Vec2::new(rect.origin.x, rect.origin.y),
glam::Vec2::new(rect.origin.x + rect.size.x, rect.origin.y),
glam::Vec2::new(rect.origin.x + rect.size.x, rect.origin.y + rect.size.y),
glam::Vec2::new(rect.origin.x, rect.origin.y + rect.size.y),
]
.map(|point| xform.transform_point2(point));
let min_x = points.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
let min_y = points.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
let max_x = points.iter().map(|p| p.x).fold(f32::NEG_INFINITY, f32::max);
let max_y = points.iter().map(|p| p.y).fold(f32::NEG_INFINITY, f32::max);
Rect::xywh(min_x, min_y, max_x - min_x, max_y - min_y)
}
fn intersect(a: Rect, b: Rect) -> Option<Rect> {
let x0 = a.origin.x.max(b.origin.x);
let y0 = a.origin.y.max(b.origin.y);
let x1 = (a.origin.x + a.size.x).min(b.origin.x + b.size.x);
let y1 = (a.origin.y + a.size.y).min(b.origin.y + b.size.y);
(x1 > x0 && y1 > y0).then(|| Rect::xywh(x0, y0, x1 - x0, y1 - y0))
}
fn normalize_rect(r: Rect) -> Rect {
let x0 = r.origin.x.min(r.origin.x + r.size.x);
let y0 = r.origin.y.min(r.origin.y + r.size.y);
Rect {
origin: Point2D::new(x0, y0),
size: Point2D::new(r.size.x.abs(), r.size.y.abs()),
}
}

View file

@ -0,0 +1,608 @@
use super::*;
use crate::layout_scene::{
SceneFillType, SceneGradient, SceneGradientStop, SceneStroke, SceneStrokeAlign,
};
fn scene_with(children: Vec<SceneNode>) -> LayoutScene {
LayoutScene {
pages: vec![ScenePage {
id: "p1".into(),
name: "Page 1".into(),
children,
}],
active_page_index: 0,
}
}
fn filled_rect(id: &str, x: f32, y: f32, w: f32, h: f32, fill: Color) -> SceneNode {
let mut n = SceneNode::leaf(id, NodeKind::Rect);
n.bounds = Rect::xywh(x, y, w, h);
n.fill = Some(fill);
n
}
fn view_box(svg: &str) -> [f32; 4] {
let value = svg
.split_once("viewBox=\"")
.and_then(|(_, rest)| rest.split_once('"'))
.map(|(value, _)| value)
.expect("viewBox attribute");
let values = value
.split_whitespace()
.map(|part| part.parse::<f32>().expect("viewBox number"))
.collect::<Vec<_>>();
values.try_into().expect("four viewBox numbers")
}
#[test]
fn active_page_svg_contains_vector_markup() {
let scene = scene_with(vec![filled_rect(
"r1",
5.0,
5.0,
120.0,
60.0,
Color {
r: 0.2,
g: 0.4,
b: 0.6,
a: 1.0,
},
)]);
let body = serialize_active_page_svg(&scene).expect("svg");
assert!(body.starts_with("<svg "), "missing svg root: {body}");
assert!(body.contains("<rect "), "missing rect element: {body}");
assert!(body.contains(r#"width="120""#), "rect width wrong: {body}");
assert!(body.ends_with("</svg>"), "missing svg close: {body}");
}
#[test]
fn active_page_svg_fails_on_empty_scene() {
assert_eq!(
serialize_active_page_svg(&scene_with(Vec::new())).unwrap_err(),
"nothing to export"
);
}
#[test]
fn selected_node_svg_crops_to_subtree_and_excludes_siblings() {
let mut group = SceneNode::leaf("group", NodeKind::Group);
group
.children
.push(filled_rect("inside", 40.0, 50.0, 20.0, 10.0, Color::RED));
let scene = scene_with(vec![
group,
filled_rect("outside", 500.0, 500.0, 20.0, 20.0, Color::BLACK),
]);
let body = serialize_node_svg(&scene, "group").expect("selected node svg");
assert!(body.contains(r#"viewBox="40 50 20 10""#), "{body}");
assert!(body.contains("inside"), "selected subtree missing: {body}");
assert!(
!body.contains("outside"),
"sibling leaked into export: {body}"
);
}
#[test]
fn svg_emits_gradient_image_clip_and_original_path() {
let mut frame = SceneNode::leaf("frame", NodeKind::Frame);
frame.bounds = Rect::xywh(10.0, 20.0, 120.0, 80.0);
frame.corner_radius = 12.0;
frame.clip_content = true;
frame.fill_type = SceneFillType::LinearGradient;
frame.gradient = Some(SceneGradient::Linear {
angle_deg: 90.0,
opacity: 1.0,
stops: vec![
SceneGradientStop {
offset: 0.0,
color: Color::RED,
},
SceneGradientStop {
offset: 1.0,
color: Color::BLACK,
},
],
});
let mut image = SceneNode::leaf("image", NodeKind::Other("image".into()));
image.bounds = frame.bounds;
image.image_src = Some("data:image/png;base64,AAAA".into());
let mut path = SceneNode::leaf("path", NodeKind::Path);
path.bounds = Rect::xywh(10.0, 20.0, 20.0, 20.0);
path.fill = Some(Color::RED);
path.svg_path = Some("M0 0 C5 0 10 5 20 20 Z".into());
frame.children = vec![image, path];
let body = serialize_node_svg(&scene_with(vec![frame]), "frame").expect("svg");
assert!(body.contains("<linearGradient"), "{body}");
assert!(body.contains("clipPath"), "{body}");
assert!(body.contains(r#"<image id="image""#), "{body}");
assert!(body.contains(r#"d="M0 0 C5 0 10 5 20 20 Z""#), "{body}");
assert!(body.contains(r#"rx="12""#), "{body}");
}
#[test]
fn svg_path_export_matches_canvas_tight_bounds_fitting() {
let mut path = SceneNode::leaf("scaled-path", NodeKind::Path);
path.bounds = Rect::xywh(100.0, 200.0, 1.0, 1.0);
path.fill = Some(Color::RED);
path.svg_path = Some("M0.2 0.1 L0.8 0.9".into());
let body = serialize_node_svg(&scene_with(vec![path]), "scaled-path").expect("svg");
// Intentional canvas parity: native and CanvasKit `fill_svg_path_in_rect`
// fit the path's tight geometry bounds into the full destination rect.
// Therefore a localized 0.2..0.8 inset is deliberately NOT preserved.
assert!(body.contains(r#"d="M0.2 0.1 L0.8 0.9""#), "{body}");
assert!(
body.contains(r#"transform="matrix(1.6666666 0 0 1.25 99.666664 199.875)""#),
"export diverged from canvas tight-bounds fitting: {body}"
);
}
#[test]
fn fitted_svg_path_keeps_stroke_width_in_document_space() {
let mut path = SceneNode::leaf("stroked-scaled-path", NodeKind::Path);
path.bounds = Rect::xywh(100.0, 200.0, 80.0, 40.0);
path.svg_path = Some("M10 20 L30 60".into());
path.stroke = Some(SceneStroke {
color: Color::BLACK,
width: 4.0,
sides: None,
align: SceneStrokeAlign::Center,
});
let body = serialize_node_svg(&scene_with(vec![path]), "stroked-scaled-path").expect("svg");
assert!(body.contains(r#"stroke-width="4""#), "{body}");
assert!(
body.contains(r#"vector-effect="non-scaling-stroke""#),
"{body}"
);
}
#[test]
fn linear_gradient_uses_document_space_endpoints_on_tall_bounds() {
let mut rect = SceneNode::leaf("linear-tall", NodeKind::Rect);
rect.bounds = Rect::xywh(10.0, 20.0, 100.0, 200.0);
rect.gradient = Some(SceneGradient::Linear {
angle_deg: 45.0,
opacity: 1.0,
stops: vec![SceneGradientStop {
offset: 0.0,
color: Color::RED,
}],
});
let body = serialize_node_svg(&scene_with(vec![rect]), "linear-tall").expect("svg");
assert!(
body.contains(r#"<linearGradient id="gradient-n-6c696e6561722d74616c6c" gradientUnits="userSpaceOnUse""#),
"{body}"
);
assert!(
!body.contains(r#"x1="0.""#),
"fractional object-space endpoint leaked: {body}"
);
}
#[test]
fn radial_gradient_uses_document_space_circle_on_tall_bounds() {
let mut rect = SceneNode::leaf("radial-tall", NodeKind::Rect);
rect.bounds = Rect::xywh(10.0, 20.0, 100.0, 200.0);
rect.gradient = Some(SceneGradient::Radial {
cx: 0.5,
cy: 0.5,
radius: 0.5,
opacity: 1.0,
stops: vec![SceneGradientStop {
offset: 0.0,
color: Color::RED,
}],
});
let body = serialize_node_svg(&scene_with(vec![rect]), "radial-tall").expect("svg");
assert!(body.contains(r#"gradientUnits="userSpaceOnUse""#), "{body}");
assert!(body.contains(r#"cx="60" cy="120" r="100""#), "{body}");
}
#[test]
fn gradient_only_frame_emits_its_background_rect() {
let mut frame = SceneNode::leaf("gradient-frame", NodeKind::Frame);
frame.bounds = Rect::xywh(10.0, 20.0, 100.0, 60.0);
frame.gradient = Some(SceneGradient::Linear {
angle_deg: 90.0,
opacity: 1.0,
stops: vec![SceneGradientStop {
offset: 0.0,
color: Color::RED,
}],
});
let body = serialize_node_svg(&scene_with(vec![frame]), "gradient-frame").expect("svg");
assert!(body.contains(r#"<rect id="gradient-frame""#), "{body}");
assert!(body.contains(r#"fill="url(#gradient-"#), "{body}");
}
#[test]
fn resource_ids_do_not_collide_after_svg_encoding() {
let gradient = || SceneGradient::Linear {
angle_deg: 90.0,
opacity: 1.0,
stops: vec![SceneGradientStop {
offset: 0.0,
color: Color::RED,
}],
};
let mut colon = SceneNode::leaf("a:b", NodeKind::Rect);
colon.bounds = Rect::xywh(0.0, 0.0, 10.0, 10.0);
colon.gradient = Some(gradient());
let mut dash = SceneNode::leaf("a-b", NodeKind::Rect);
dash.bounds = Rect::xywh(20.0, 0.0, 10.0, 10.0);
dash.gradient = Some(gradient());
let body = serialize_active_page_svg(&scene_with(vec![colon, dash])).expect("svg");
assert!(body.contains(r#"id="gradient-n-613a62""#), "{body}");
assert!(body.contains(r#"id="gradient-n-612d62""#), "{body}");
assert!(body.contains(r#"fill="url(#gradient-n-613a62)""#), "{body}");
assert!(body.contains(r#"fill="url(#gradient-n-612d62)""#), "{body}");
}
#[test]
fn open_point_path_without_stroke_uses_implicit_fill_color_stroke() {
let mut path = SceneNode::leaf("open-path", NodeKind::Path);
path.points = vec![Point2D::new(1.0, 2.0), Point2D::new(11.0, 12.0)];
path.fill = Some(Color::RED);
let body = serialize_node_svg(&scene_with(vec![path]), "open-path").expect("svg");
assert!(body.contains(r#"fill="none""#), "{body}");
assert!(body.contains(r#"stroke="rgb(255,0,0)""#), "{body}");
assert!(body.contains(r#"stroke-width="1.5""#), "{body}");
}
#[test]
fn tile_image_uses_repeating_svg_pattern() {
let mut image = SceneNode::leaf("tile:image", NodeKind::Other("image".into()));
image.bounds = Rect::xywh(10.0, 20.0, 240.0, 160.0);
// PNG signature + IHDR declaring a 32x18 intrinsic size.
image.image_src = Some("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAS".into());
image.image_fit = crate::layout_scene::SceneImageFit::Tile;
let body = serialize_node_svg(&scene_with(vec![image]), "tile:image").expect("svg");
assert!(
body.contains(r#"<pattern id="image-pattern-n-74696c653a696d616765""#),
"{body}"
);
assert!(body.contains(r#"patternUnits="userSpaceOnUse""#), "{body}");
assert!(
body.contains(r#"x="114" y="91" width="32" height="18""#),
"tile phase or intrinsic dimensions are wrong: {body}"
);
assert!(body.contains(r#"<image width="32" height="18""#), "{body}");
assert!(
body.contains(r#"fill="url(#image-pattern-n-74696c653a696d616765)""#),
"{body}"
);
}
#[test]
fn tile_image_reads_jpeg_and_webp_intrinsic_dimensions() {
for (id, src) in [
(
"jpeg-tile",
"data:image/jpeg;base64,/9j/wAARCAASACADAREAAhEAAxEA/9k=",
),
(
"webp-tile",
"data:image/webp;base64,UklGRh4AAABXRUJQVlA4WAoAAAAAAAAAHwAAEQAA",
),
] {
let mut image = SceneNode::leaf(id, NodeKind::Other("image".into()));
image.bounds = Rect::xywh(0.0, 0.0, 240.0, 160.0);
image.image_src = Some(src.into());
image.image_fit = crate::layout_scene::SceneImageFit::Tile;
let body = serialize_node_svg(&scene_with(vec![image]), id).expect("svg");
assert!(
body.contains(r#"x="104" y="71" width="32" height="18""#),
"{id} intrinsic dimensions or centered phase are wrong: {body}"
);
}
}
#[test]
fn rounded_regular_and_tile_images_reference_rounded_clip_paths() {
let mut regular = SceneNode::leaf("rounded-image", NodeKind::Other("image".into()));
regular.bounds = Rect::xywh(10.0, 20.0, 120.0, 80.0);
regular.corner_radius = 12.0;
regular.image_src = Some("data:image/png;base64,AAAA".into());
let mut tile = SceneNode::leaf("rounded-tile", NodeKind::Other("image".into()));
tile.bounds = Rect::xywh(150.0, 20.0, 120.0, 80.0);
tile.corner_radius = 9.0;
tile.image_src = Some("data:image/png;base64,BBBB".into());
tile.image_fit = crate::layout_scene::SceneImageFit::Tile;
let body = serialize_active_page_svg(&scene_with(vec![regular, tile])).expect("svg");
assert!(
body.contains(
r#"<clipPath id="clip-n-726f756e6465642d696d616765"><rect x="10" y="20" width="120" height="80" rx="12"/></clipPath>"#
),
"{body}"
);
assert!(
body.contains(
r#"<clipPath id="clip-n-726f756e6465642d74696c65"><rect x="150" y="20" width="120" height="80" rx="9"/></clipPath>"#
),
"{body}"
);
assert!(
body.contains(r#"<image id="rounded-image"#)
&& body.contains(r#"clip-path="url(#clip-n-726f756e6465642d696d616765)""#),
"{body}"
);
assert!(
body.contains(r#"<rect id="rounded-tile"#)
&& body.contains(r#"clip-path="url(#clip-n-726f756e6465642d74696c65)""#),
"{body}"
);
}
#[test]
fn multiline_text_emits_node_id_once() {
let mut text = SceneNode::leaf("multiline", NodeKind::Text);
text.bounds = Rect::xywh(0.0, 0.0, 120.0, 60.0);
text.text = Some("first\nsecond".into());
let body = serialize_node_svg(&scene_with(vec![text]), "multiline").expect("svg");
assert_eq!(body.matches(r#"id="multiline""#).count(), 1, "{body}");
assert!(body.contains("first"), "{body}");
assert!(body.contains("second"), "{body}");
}
#[test]
fn gradient_only_svg_path_contributes_painted_bounds() {
let mut path = SceneNode::leaf("gradient-path", NodeKind::Path);
path.bounds = Rect::xywh(30.0, 40.0, 80.0, 60.0);
path.svg_path = Some("M0 0 L10 0 L10 10 Z".into());
path.gradient = Some(SceneGradient::Linear {
angle_deg: 90.0,
opacity: 1.0,
stops: vec![SceneGradientStop {
offset: 0.0,
color: Color::RED,
}],
});
let body = serialize_node_svg(&scene_with(vec![path]), "gradient-path").expect("svg");
assert!(body.contains(r#"viewBox="30 40 80 60""#), "{body}");
assert!(body.contains(r#"fill="url(#gradient-"#), "{body}");
}
#[test]
fn clip_content_wraps_children_but_not_frame_stroke() {
let mut frame = SceneNode::leaf("clipped-frame", NodeKind::Frame);
frame.bounds = Rect::xywh(10.0, 20.0, 100.0, 80.0);
frame.clip_content = true;
frame.stroke = Some(SceneStroke {
color: Color::BLACK,
width: 8.0,
sides: None,
align: SceneStrokeAlign::Center,
});
frame.children.push(filled_rect(
"clipped-child",
0.0,
0.0,
200.0,
200.0,
Color::RED,
));
let body = serialize_node_svg(&scene_with(vec![frame]), "clipped-frame").expect("svg");
let frame_rect = body
.find(r#"<rect id="clipped-frame""#)
.expect("frame rect");
let clip_group = body
.find(r#"<g clip-path="url(#clip-"#)
.expect("clip group");
let child = body
.find(r#"<rect id="clipped-child""#)
.expect("child rect");
assert!(
frame_rect < clip_group,
"frame stroke is inside clip group: {body}"
);
assert!(clip_group < child, "child is outside clip group: {body}");
}
#[test]
fn selected_clipping_node_crops_view_box_to_visible_child_bounds() {
let mut frame = SceneNode::leaf("selected-clip", NodeKind::Frame);
frame.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
frame.clip_content = true;
frame.children.push(filled_rect(
"partially-visible-child",
80.0,
20.0,
60.0,
30.0,
Color::RED,
));
let body = serialize_node_svg(&scene_with(vec![frame]), "selected-clip").expect("svg");
assert!(body.contains(r#"viewBox="80 20 20 30""#), "{body}");
assert!(
body.contains(r#"clip-path="url(#clip-n-73656c65637465642d636c6970)""#),
"{body}"
);
}
#[test]
fn selected_node_svg_preserves_ancestor_rotation_without_exporting_ancestor_or_siblings() {
let mut parent = SceneNode::leaf("rotated-parent", NodeKind::Frame);
parent.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
parent.rotation = std::f32::consts::FRAC_PI_2;
parent.children = vec![
filled_rect("selected-child", 10.0, 20.0, 20.0, 10.0, Color::RED),
filled_rect("sibling", 80.0, 80.0, 10.0, 10.0, Color::BLACK),
];
let body = serialize_node_svg(&scene_with(vec![parent]), "selected-child").expect("svg");
let [x, y, width, height] = view_box(&body);
assert!(
(x - 70.0).abs() < 0.001,
"ancestor rotation missing: {body}"
);
assert!(
(y - 10.0).abs() < 0.001,
"ancestor rotation missing: {body}"
);
assert!((width - 10.0).abs() < 0.001, "rotated width wrong: {body}");
assert!(
(height - 20.0).abs() < 0.001,
"rotated height wrong: {body}"
);
assert!(body.contains(r#"<g transform="matrix("#), "{body}");
assert!(body.contains(r#"id="selected-child""#), "{body}");
assert!(
!body.contains("rotated-parent"),
"ancestor paint leaked: {body}"
);
assert!(!body.contains("sibling"), "sibling leaked: {body}");
}
#[test]
fn selected_node_svg_preserves_ancestor_flip() {
let mut parent = SceneNode::leaf("flipped-parent", NodeKind::Frame);
parent.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
parent.flip_x = true;
parent.children.push(filled_rect(
"flipped-child",
10.0,
20.0,
20.0,
10.0,
Color::RED,
));
let body = serialize_node_svg(&scene_with(vec![parent]), "flipped-child").expect("svg");
let [x, y, width, height] = view_box(&body);
assert!((x - 70.0).abs() < 0.001, "ancestor flip missing: {body}");
assert!((y - 20.0).abs() < 0.001, "flipped y changed: {body}");
assert!((width - 20.0).abs() < 0.001, "flipped width wrong: {body}");
assert!(
(height - 10.0).abs() < 0.001,
"flipped height wrong: {body}"
);
assert!(
body.contains(r#"transform="matrix(-1 0 0 1 100 0)""#),
"{body}"
);
}
#[test]
fn selected_node_svg_preserves_ancestor_content_clip() {
let mut parent = SceneNode::leaf("clip-parent", NodeKind::Frame);
parent.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
parent.clip_content = true;
parent.corner_radius = 12.0;
parent.children.push(filled_rect(
"clipped-selection",
80.0,
20.0,
60.0,
30.0,
Color::RED,
));
let body = serialize_node_svg(&scene_with(vec![parent]), "clipped-selection").expect("svg");
assert!(body.contains(r#"viewBox="80 20 20 30""#), "{body}");
assert!(
body.contains(r#"<clipPath id="ancestor-clip-n-636c69702d706172656e74"#),
"{body}"
);
assert!(
body.contains(r#"clip-path="url(#ancestor-clip-n-636c69702d706172656e74)""#),
"{body}"
);
assert!(body.contains(r#"rx="12""#), "{body}");
assert!(body.contains(r#"id="clipped-selection""#), "{body}");
assert!(!body.contains(r#"id="clip-parent""#), "{body}");
}
#[test]
fn gradient_only_other_node_contributes_bounds_and_emits_rect() {
let mut other = SceneNode::leaf("gradient-other", NodeKind::Other("custom".into()));
other.bounds = Rect::xywh(30.0, 40.0, 80.0, 60.0);
other.gradient = Some(SceneGradient::Linear {
angle_deg: 90.0,
opacity: 1.0,
stops: vec![SceneGradientStop {
offset: 0.0,
color: Color::RED,
}],
});
let body = serialize_node_svg(&scene_with(vec![other]), "gradient-other").expect("svg");
assert!(body.contains(r#"viewBox="30 40 80 60""#), "{body}");
assert!(body.contains(r#"<rect id="gradient-other""#), "{body}");
assert!(body.contains(r#"fill="url(#gradient-"#), "{body}");
}
#[test]
fn resolved_layer_opacity_is_not_applied_twice_to_gradient_stroke_or_descendants() {
let mut frame = SceneNode::leaf("opacity-frame", NodeKind::Frame);
frame.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
// LayoutScene stores cumulative opacity here for raster images.
frame.opacity = 0.5;
frame.gradient = Some(SceneGradient::Linear {
angle_deg: 90.0,
// The loader has already folded the same cumulative node opacity
// into the gradient multiplier used by the canvas shader.
opacity: 0.5,
stops: vec![SceneGradientStop {
offset: 0.0,
color: Color::RED,
}],
});
frame.stroke = Some(SceneStroke {
color: Color::BLACK.with_alpha(0.5),
width: 2.0,
sides: None,
align: SceneStrokeAlign::Center,
});
frame.children.push(filled_rect(
"opacity-child",
10.0,
10.0,
20.0,
20.0,
Color::RED.with_alpha(0.5),
));
let body = serialize_node_svg(&scene_with(vec![frame]), "opacity-frame").expect("svg");
assert!(body.contains(r#"stop-opacity="0.5""#), "{body}");
assert!(body.contains(r#"stroke-opacity="0.5""#), "{body}");
assert!(body.contains(r#"id="opacity-child""#), "{body}");
assert!(body.contains(r#"fill-opacity="0.5""#), "{body}");
assert!(
!body.contains(r#"<g opacity="0.5""#),
"cumulative layer opacity was applied twice: {body}"
);
}

View file

@ -180,7 +180,7 @@ fn is_remote_image_url(src: &str) -> bool {
/// decoded data URLs and host-fetched remote bytes), then an inline
/// `data:` decode; a remote `http(s)` miss is recorded for the host
/// fetcher and paints the placeholder this frame.
fn image_source_bytes(src: &str, image_src_id: u64) -> Option<Arc<[u8]>> {
pub(crate) fn image_source_bytes(src: &str, image_src_id: u64) -> Option<Arc<[u8]>> {
let id = if image_src_id == 0 {
stable_image_source_id(src)
} else {

View file

@ -48,6 +48,8 @@ pub mod property_panel_action;
pub mod property_panel_code;
pub mod property_panel_effects;
pub mod property_panel_export;
#[cfg(test)]
mod property_panel_export_tests;
pub mod property_panel_fill;
mod property_panel_fill_body;
mod property_panel_fill_image_body;

View file

@ -0,0 +1,34 @@
use super::property_panel::{PropertyPanel, PropertyPanelAction};
use super::property_panel_sections as sections;
use super::property_panel_test_support::visible_for;
use crate::Rect;
use op_editor_core::{EditorState, NodeId};
#[test]
fn export_format_picker_lists_svg_after_webp() {
let mut state = EditorState::sample();
state.set_single_selection(NodeId::new("n10"));
state.editor_ui.export_format_picker_open = true;
let panel = PropertyPanel::for_selection(&state).expect("frame panel");
let formats: Vec<_> = sections::action_button_rects_with_fill_picker(
Rect::xywh(0.0, 0.0, 280.0, 1600.0),
visible_for(&panel),
&panel.snapshot.effects,
&panel.snapshot.fills,
false,
0,
false,
false,
false,
true,
false,
)
.into_iter()
.filter_map(|(action, _)| match action {
PropertyPanelAction::SetExportFormat(format) => Some(format),
_ => None,
})
.collect();
assert_eq!(formats, op_editor_core::ExportFormat::ALL[..4]);
}

View file

@ -633,6 +633,7 @@ pub fn action_button_rects_with_fill_picker(
op_editor_core::ExportFormat::Png,
op_editor_core::ExportFormat::Jpeg,
op_editor_core::ExportFormat::Webp,
op_editor_core::ExportFormat::Svg,
];
let count = formats.len() as f32;
let first_row_y = format_rect.origin.y - 4.0 - 6.0 - count * EXPORT_PICKER_ROW_H;

View file

@ -272,8 +272,8 @@ pub fn run_action(
let scene = &scene;
// When exactly one node is selected, raster export
// crops to that layer (TS parity: exportLayerToRaster);
// otherwise the whole active page is exported. SVG /
// PDF always stay page-level.
// otherwise the whole active page is exported. SVG uses
// the same selected-subtree rule; PDF stays page-level.
let single_node: Option<String> = {
let st = host.editor_state();
if st.selection_count() == 1 && st.selection.anchor.is_real() {
@ -294,7 +294,10 @@ pub fn run_action(
Fmt::Png => raster(op_host_services::export::RasterFormat::Png),
Fmt::Jpeg => raster(op_host_services::export::RasterFormat::Jpeg),
Fmt::Webp => raster(op_host_services::export::RasterFormat::Webp),
Fmt::Svg => op_host_services::export::export_svg(scene, &path),
Fmt::Svg => match &single_node {
Some(id) => op_host_services::export::export_node_svg(scene, id, &path),
None => op_host_services::export::export_svg(scene, &path),
},
Fmt::Pdf => op_host_services::export_pdf::export_pdf(scene, &path),
};
if let Err(e) = result {

View file

@ -40,7 +40,7 @@ mod scene_painter;
// → scene convenience `capture()` inside it stays `mcp-debug-tools`-gated.
pub mod screenshot;
pub use export_svg::export_svg;
pub use export_svg::{export_node_svg, export_svg};
pub use scene_painter::{paint_node, paint_nodes};
const MARGIN: f32 = 0.0;

View file

@ -13,6 +13,12 @@ pub fn export_svg(scene: &LayoutScene, target: &StdPath) -> Result<(), String> {
std::fs::write(target, svg).map_err(|e| e.to_string())
}
/// Serialize one node and its subtree to an SVG file at `target`.
pub fn export_node_svg(scene: &LayoutScene, node_id: &str, target: &StdPath) -> Result<(), String> {
let svg = op_editor_ui::svg_export::serialize_node_svg(scene, node_id)?;
std::fs::write(target, svg).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@ -53,4 +59,21 @@ mod tests {
assert!(res.is_err());
assert_eq!(res.unwrap_err(), "nothing to export");
}
#[test]
fn export_node_svg_writes_only_selected_subtree() {
let scene = scene_with(vec![
filled_rect("selected", 5.0, 5.0, 120.0, 60.0, Color::RED),
filled_rect("sibling", 500.0, 500.0, 10.0, 10.0, Color::BLACK),
]);
let tmp =
std::env::temp_dir().join(format!("op-export-node-svg-{}.svg", std::process::id()));
export_node_svg(&scene, "selected", &tmp).expect("selected svg export");
let body = std::fs::read_to_string(&tmp).unwrap();
assert!(body.contains("selected"), "{body}");
assert!(!body.contains("sibling"), "{body}");
let _ = std::fs::remove_file(&tmp);
}
}

View file

@ -74,7 +74,11 @@ pub fn parse_save_response(response: &str) -> Result<SaveResponse, String> {
pub fn export_svg_document(state: &EditorState) -> Result<String, String> {
let scene = op_pen_loader::editor_state_to_layout_scene(state);
op_editor_ui::svg_export::serialize_active_page_svg(&scene)
if state.selection_count() == 1 && state.selection.anchor.is_real() {
op_editor_ui::svg_export::serialize_node_svg(&scene, state.selection.anchor.as_str())
} else {
op_editor_ui::svg_export::serialize_active_page_svg(&scene)
}
}
#[derive(Debug)]

View file

@ -362,3 +362,14 @@ fn export_svg_document_returns_vector_markup() {
);
assert!(svg.ends_with("</svg>"), "missing SVG close: {svg}");
}
#[test]
fn export_svg_document_uses_single_selection() {
let mut state = op_editor_core::EditorState::sample();
state.set_single_selection(op_editor_core::NodeId::new("n11"));
let svg = crate::file_actions::export_svg_document(&state).expect("selected svg");
assert!(svg.contains("n11"), "selected node missing: {svg}");
assert!(!svg.contains("n13"), "unselected sibling leaked: {svg}");
}