diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 42912e7d5..fced4bcb3 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -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; diff --git a/crates/op-editor-core/src/svg_path_data.rs b/crates/op-editor-core/src/svg_path_data.rs index f5a869081..234090bb5 100644 --- a/crates/op-editor-core/src/svg_path_data.rs +++ b/crates/op-editor-core/src/svg_path_data.rs @@ -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 { + segment_bounds(&absolute_segments(d)?) +} + fn absolute_segments(d: &str) -> Option> { let tokens = tokenize_path(d); if tokens.is_empty() { @@ -257,10 +267,10 @@ fn segment_bounds(segments: &[Segment]) -> Option { 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, }) } diff --git a/crates/op-editor-ui/src/svg_export.rs b/crates/op-editor-ui/src/svg_export.rs index f924412d8..7061c5bd7 100644 --- a/crates/op-editor-ui/src/svg_export.rs +++ b/crates/op-editor-ui/src/svg_export.rs @@ -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 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 { + 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 { + 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#""# ); - 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(""); + } + close_ancestor_clip_groups(&mut svg, ancestor_clips); svg.push_str(""); 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(""); + out.push_str(&defs); + out.push_str(""); + } +} + +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#""#, + 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#""#, + cx - dx, + cy - dy, + cx + dx, + cy + dy, + ); + emit_stops(out, stops, *opacity); + out.push_str(""); + } + 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#""# + ); + emit_stops(out, stops, *opacity); + out.push_str(""); + } + SceneGradient::Mesh { + colors, opacity, .. + } => { + if let Some(color) = colors.first() { + let _ = write!( + out, + r#""#, + 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.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 { 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> { ) } 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> { ) } 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> { ) } 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#""#); + 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#""#, svg_id(&n.id)); } for child in &n.children { emit_node(out, child); } + if clips_children { + out.push_str(""); + } if needs_g { out.push_str(""); } } 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#""#, + r#""#, + 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#""#, + r#""#, + 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#""#, + r#""#, + 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#""#, + r#""#, + 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#""#, 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#"{}"#, + r#"{}"#, 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(""); } 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#""#, - stroke_attrs(color, width), + r#""#, + 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#""#, + 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#""#, + 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#""#, + 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) -> 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(""), "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; diff --git a/crates/op-editor-ui/src/svg_export/image_metadata.rs b/crates/op-editor-ui/src/svg_export/image_metadata.rs new file mode 100644 index 000000000..1740b4795 --- /dev/null +++ b/crates/op-editor-ui/src/svg_export/image_metadata.rs @@ -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 { + 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)) +} diff --git a/crates/op-editor-ui/src/svg_export/transform.rs b/crates/op-editor-ui/src/svg_export/transform.rs new file mode 100644 index 000000000..fe4be3ae9 --- /dev/null +++ b/crates/op-editor-ui/src/svg_export/transform.rs @@ -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)> { + 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, +) -> Option<(&'a SceneNode, glam::Affine2, Vec)> { + 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#""#, 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#""#, + 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#""#, clip.id); + } +} + +pub(super) fn close_ancestor_clip_groups(out: &mut String, clips: &[AncestorClip]) { + for _ in clips { + out.push_str(""); + } +} + +pub(super) fn apply_ancestor_clip_bounds(mut bounds: Rect, clips: &[AncestorClip]) -> Option { + 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 { + 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 { + 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()), + } +} diff --git a/crates/op-editor-ui/src/svg_export_tests.rs b/crates/op-editor-ui/src/svg_export_tests.rs new file mode 100644 index 000000000..ccf317cc6 --- /dev/null +++ b/crates/op-editor-ui/src/svg_export_tests.rs @@ -0,0 +1,608 @@ +use super::*; +use crate::layout_scene::{ + SceneFillType, SceneGradient, SceneGradientStop, SceneStroke, SceneStrokeAlign, +}; + +fn scene_with(children: Vec) -> 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::().expect("viewBox number")) + .collect::>(); + 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(""), "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(""# + ), + "{body}" + ); + assert!( + body.contains( + r#""# + ), + "{body}" + ); + assert!( + body.contains(r#" 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> { +pub(crate) fn image_source_bytes(src: &str, image_src_id: u64) -> Option> { let id = if image_src_id == 0 { stable_image_source_id(src) } else { diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index e73a9f2ff..bc170c0c2 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -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; diff --git a/crates/op-editor-ui/src/widgets/property_panel_export_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_export_tests.rs new file mode 100644 index 000000000..162004f52 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/property_panel_export_tests.rs @@ -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]); +} diff --git a/crates/op-editor-ui/src/widgets/property_panel_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_layout.rs index 6faf091e4..8ea07628b 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_layout.rs @@ -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; diff --git a/crates/op-host-desktop/src/persistence.rs b/crates/op-host-desktop/src/persistence.rs index d091751c0..e60b5c206 100644 --- a/crates/op-host-desktop/src/persistence.rs +++ b/crates/op-host-desktop/src/persistence.rs @@ -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 = { 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 { diff --git a/crates/op-host-services/src/export.rs b/crates/op-host-services/src/export.rs index 657241d88..763bbe4a2 100644 --- a/crates/op-host-services/src/export.rs +++ b/crates/op-host-services/src/export.rs @@ -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; diff --git a/crates/op-host-services/src/export/export_svg.rs b/crates/op-host-services/src/export/export_svg.rs index 0ce73decc..3b7a71e51 100644 --- a/crates/op-host-services/src/export/export_svg.rs +++ b/crates/op-host-services/src/export/export_svg.rs @@ -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); + } } diff --git a/crates/op-host-web/src/file_actions.rs b/crates/op-host-web/src/file_actions.rs index 5ddb36e33..12117dcbf 100644 --- a/crates/op-host-web/src/file_actions.rs +++ b/crates/op-host-web/src/file_actions.rs @@ -74,7 +74,11 @@ pub fn parse_save_response(response: &str) -> Result { pub fn export_svg_document(state: &EditorState) -> Result { 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)] diff --git a/crates/op-host-web/src/widget_host/io_tests.rs b/crates/op-host-web/src/widget_host/io_tests.rs index 07a58eba8..9263f08df 100644 --- a/crates/op-host-web/src/widget_host/io_tests.rs +++ b/crates/op-host-web/src/widget_host/io_tests.rs @@ -362,3 +362,14 @@ fn export_svg_document_returns_vector_markup() { ); assert!(svg.ends_with(""), "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}"); +}