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:
parent
20122d4b81
commit
ba16f755f5
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
100
crates/op-editor-ui/src/svg_export/image_metadata.rs
Normal file
100
crates/op-editor-ui/src/svg_export/image_metadata.rs
Normal 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))
|
||||
}
|
||||
172
crates/op-editor-ui/src/svg_export/transform.rs
Normal file
172
crates/op-editor-ui/src/svg_export/transform.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
608
crates/op-editor-ui/src/svg_export_tests.rs
Normal file
608
crates/op-editor-ui/src/svg_export_tests.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue