From 68a7e534fd0211abb1cfa2d1acab48b01f7a91f6 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 23 May 2026 23:11:38 +0800 Subject: [PATCH 1/5] feat(panels,canvas): editable gradients/effects + SVG/image import + locale-aware dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the gradient + property-panel polish from the previous commit and rounds out two new flows the TS app already has: Gradient stops + effects: - ColorTarget gains GradientStop(i) + EffectColor(i); HSV picker preserves alpha across hue/SV drags so a transparent stop stays transparent. Hex pill stays 6-char; alpha is reattached at commit and the swatch sits on a 2x2 alpha checker so #00000000 reads as transparent rather than empty. - Effects section reflowed into card-style blocks (image #9 spec): title + minus, X/Y and Blur/Spread 2-col grids, color row with swatch + rgba(...) text; clicking the swatch opens an HSV picker bound to that effect index via SetEffectColor. - Press dispatch on both hosts anchors picker overlays at the clicked y so they pop adjacent to the swatch instead of the top. Image + SVG import (toolbar + Fill section "图片" row): - New FileAction::ImportImageOrSvg / PickFillImage; persistence_image pops rfd, decodes raster as data: URL, inserts an Image node or rewrites the selected node's primary fill. - ImageNode actually renders on the canvas: NodePayload + SceneNode carry image_src, canvas_viewport_paint.rs decodes the data URL once and hands raw bytes to RenderBackend::draw_image with a src-hash cache id. Grey placeholder paints only when decode fails so transparent PNGs don't get a grey matte underneath. - SVG import ported to TS-parity (packages/pen-engine svg-parser): recursive tree walk with inherited fill/stroke/style="...", viewBox-aware scaling with maxDim cap, multi-subpath split, raw d preserved on PathNode. Imports land wrapped in a Group named after the source file. Locale-aware first run: - settings_io detects the OS locale (LC_ALL/LANG/LC_MESSAGES with zh-Hans/zh-Hant heuristics) and seeds editor_ui.locale before settings.json is read; persisted user choice still wins. - macOS bundle declares CFBundleLocalizations + AllowMixedLocalizations so NSOpenPanel / NSSavePanel render in the same language as the rest of the chrome. Web host kept exhaustive across the new variants (PickFillImage, OpenEffectColorPicker, ColorTarget::EffectColor, GradientStop). Two new files: persistence_image.rs (file-pick handlers, ≤120 lines) and svg_path_data.rs (path-d tokenizer + bbox + normaliser, split from svg_import.rs to stay under the 800-line cap). 277 op-editor-core tests pass. --- Cargo.lock | 1 + crates/op-editor-core/src/color_picker.rs | 88 +- crates/op-editor-core/src/command.rs | 7 + crates/op-editor-core/src/command_apply.rs | 5 + .../op-editor-core/src/command_node_attrs.rs | 27 + crates/op-editor-core/src/editor_ui_state.rs | 9 + crates/op-editor-core/src/host_support.rs | 95 ++ crates/op-editor-core/src/lib.rs | 1 + crates/op-editor-core/src/mutators.rs | 6 +- crates/op-editor-core/src/svg_import.rs | 846 ++++++++++++++++-- crates/op-editor-core/src/svg_import_tests.rs | 224 ++++- crates/op-editor-core/src/svg_path_data.rs | 468 ++++++++++ crates/op-editor-core/src/ui_draft.rs | 8 +- crates/op-editor-ui/Cargo.toml | 6 + crates/op-editor-ui/src/layout_scene.rs | 11 + .../src/widgets/canvas_viewport_paint.rs | 114 ++- .../op-editor-ui/src/widgets/color_picker.rs | 2 + .../src/widgets/property_panel.rs | 17 +- .../src/widgets/property_panel_action.rs | 8 + .../src/widgets/property_panel_effects.rs | 262 ++++-- .../src/widgets/property_panel_fill.rs | 13 +- .../src/widgets/property_panel_layout.rs | 161 +++- crates/op-host-desktop/Info.plist | 34 + crates/op-host-desktop/src/main.rs | 1 + crates/op-host-desktop/src/persistence.rs | 8 + .../op-host-desktop/src/persistence_image.rs | 142 +++ crates/op-host-desktop/src/settings_io.rs | 64 +- crates/op-host-native/src/backend/skia.rs | 5 +- .../op-host-native/src/widget_host/press.rs | 23 +- .../src/widget_host/press_helpers.rs | 7 +- .../src/widget_host/property_dispatch.rs | 16 + crates/op-host-web/src/backend/mod.rs | 5 +- crates/op-host-web/src/widget_host/press.rs | 22 +- .../src/widget_host/property_dispatch.rs | 27 + crates/op-pen-loader/src/adapter.rs | 12 +- crates/op-pen-loader/src/adapter_tests.rs | 25 +- crates/op-pen-loader/src/layout_scene.rs | 9 +- crates/op-pen-loader/src/payload.rs | 10 + tools/bundle-macos.sh | 20 + 39 files changed, 2505 insertions(+), 304 deletions(-) create mode 100644 crates/op-editor-core/src/svg_path_data.rs create mode 100644 crates/op-host-desktop/Info.plist create mode 100644 crates/op-host-desktop/src/persistence_image.rs diff --git a/Cargo.lock b/Cargo.lock index ce6ecd68..9e12c9f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2572,6 +2572,7 @@ name = "op-editor-ui" version = "0.1.0" dependencies = [ "accesskit", + "base64", "bitflags 2.11.1", "glam", "jian-core", diff --git a/crates/op-editor-core/src/color_picker.rs b/crates/op-editor-core/src/color_picker.rs index d914c8fb..4d0a4968 100644 --- a/crates/op-editor-core/src/color_picker.rs +++ b/crates/op-editor-core/src/color_picker.rs @@ -95,16 +95,19 @@ impl EditorState { ColorTarget::Fill => first_solid_fill_hex(node).map(str::to_string), ColorTarget::Stroke => first_solid_stroke_hex(node).map(str::to_string), ColorTarget::GradientStop(i) => gradient_stop_hex(node, i), + ColorTarget::EffectColor(i) => effect_color_hex(node, i), } .unwrap_or_else(|| "#000000".to_string()); let (h, s, v) = rgb_to_hsv(parse_hex_rgb(¤t_hex).unwrap_or((0.0, 0.0, 0.0))); - // Preserve per-stop alpha across picker edits. Fill / stroke - // ignore alpha (they carry it in a separate opacity input) - // so this only matters for `GradientStop`. - let alpha = if matches!(target, ColorTarget::GradientStop(_)) { - parse_hex_alpha(¤t_hex) - } else { - 1.0 + // Preserve per-stop / per-effect alpha across picker edits. + // Fill / stroke ignore alpha (they carry it in a separate + // opacity input) so this only matters for `GradientStop` + // and `EffectColor`. + let alpha = match target { + ColorTarget::GradientStop(_) | ColorTarget::EffectColor(_) => { + parse_hex_alpha(¤t_hex) + } + _ => 1.0, }; self.ui.pending_color_history = Some(self.snapshot_for_history()); self.ui.color_picker = Some(ColorPickerState { @@ -202,30 +205,34 @@ impl EditorState { self.set_selected_color(false, &hex); } ColorTarget::GradientStop(i) => { - // Splice the picker's preserved alpha back onto the - // RGB hex — the picker has no alpha slider, so the - // stop's authored transparency must round-trip even - // when the user drags hue / saturation / value. - let alpha_u8 = (self - .ui - .color_picker - .as_ref() - .map(|s| s.alpha) - .unwrap_or(1.0) - .clamp(0.0, 1.0) - * 255.0) - .round() as u8; - let hex_with_alpha = if alpha_u8 == 255 { - hex.clone() - } else { - format!("{}{:02X}", hex, alpha_u8) - }; + let hex_with_alpha = splice_alpha(&hex, self.picker_alpha()); let _ = self.set_selected_gradient_stop_hex(i, &hex_with_alpha); } + ColorTarget::EffectColor(i) => { + let hex_with_alpha = splice_alpha(&hex, self.picker_alpha()); + let sel = self.selection.anchor.clone(); + if sel.is_real() { + let _ = self.apply(crate::EditorCommand::SetEffectColor { + node_id: sel, + index: i as u32, + hex: hex_with_alpha, + }); + } + } } true } + /// Read the picker's preserved alpha (0..=1) — defaults to 1.0 + /// when no picker is open. + fn picker_alpha(&self) -> f32 { + self.ui + .color_picker + .as_ref() + .map(|s| s.alpha.clamp(0.0, 1.0)) + .unwrap_or(1.0) + } + /// Set the active drag kind so `apply_cursor_move` can route a /// move event to the right control. pub fn color_picker_set_drag(&mut self, drag: Option) { @@ -265,11 +272,13 @@ impl EditorState { ColorTarget::Fill => first_solid_fill_hex(n).map(str::to_string), ColorTarget::Stroke => first_solid_stroke_hex(n).map(str::to_string), ColorTarget::GradientStop(i) => gradient_stop_hex(n, i), + ColorTarget::EffectColor(i) => effect_color_hex(n, i), }); let after = self.selected_node().and_then(|n| match state.target { ColorTarget::Fill => first_solid_fill_hex(n).map(str::to_string), ColorTarget::Stroke => first_solid_stroke_hex(n).map(str::to_string), ColorTarget::GradientStop(i) => gradient_stop_hex(n, i), + ColorTarget::EffectColor(i) => effect_color_hex(n, i), }); before != after }; @@ -290,14 +299,35 @@ fn scalar_as_hex(s: &jian_ops_schema::variable::VariableScalar) -> Option String { + let a = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8; + if a == 255 { + hex.to_string() + } else { + format!("{}{:02X}", hex, a) + } +} + +/// Read the colour hex of the Shadow effect at `index` on `node`. +/// `None` when the node has no effects, the index is out of range, +/// or the effect isn't a Shadow. +fn effect_color_hex(node: &jian_ops_schema::node::PenNode, index: usize) -> Option { + use jian_ops_schema::style::PenEffect; + let effects = crate::fills::node_effects(node); + match effects.get(index)? { + PenEffect::Shadow(s) => Some(s.color.clone()), + _ => None, + } +} + /// Read one stop's hex from the node's primary gradient body. /// `None` when the first fill isn't a gradient or `index` is out of /// range — the same gating `set_primary_gradient_stop_hex` applies /// on the write path. -fn gradient_stop_hex( - node: &jian_ops_schema::node::PenNode, - index: usize, -) -> Option { +fn gradient_stop_hex(node: &jian_ops_schema::node::PenNode, index: usize) -> Option { use jian_ops_schema::style::PenFill; let fills = crate::fills::node_fills(node)?; let first = fills.first()?; diff --git a/crates/op-editor-core/src/command.rs b/crates/op-editor-core/src/command.rs index e142ec66..c1fcdb57 100644 --- a/crates/op-editor-core/src/command.rs +++ b/crates/op-editor-core/src/command.rs @@ -266,6 +266,13 @@ pub enum EditorCommand { field: EffectField, value: f32, }, + /// Replace the colour of the Shadow effect at `index`. `hex` is + /// canonical `#RRGGBB` or `#RRGGBBAA`. No-op for Blur kinds. + SetEffectColor { + node_id: NodeId, + index: u32, + hex: String, + }, /// Set the active canvas tool. SetActiveTool { tool: String }, /// Undo the last change. diff --git a/crates/op-editor-core/src/command_apply.rs b/crates/op-editor-core/src/command_apply.rs index 561fbe92..ad4e72e9 100644 --- a/crates/op-editor-core/src/command_apply.rs +++ b/crates/op-editor-core/src/command_apply.rs @@ -195,6 +195,11 @@ impl EditorState { field, value, } => self.cmd_set_effect_param(&node_id, index, field, value), + EditorCommand::SetEffectColor { + node_id, + index, + hex, + } => self.cmd_set_effect_color(&node_id, index, &hex), // --- Variables + themes -------------------------------- EditorCommand::SetVariableColor { name, hex } => self.set_variable_color(&name, &hex), diff --git a/crates/op-editor-core/src/command_node_attrs.rs b/crates/op-editor-core/src/command_node_attrs.rs index 593401d0..02234628 100644 --- a/crates/op-editor-core/src/command_node_attrs.rs +++ b/crates/op-editor-core/src/command_node_attrs.rs @@ -448,4 +448,31 @@ impl EditorState { } true } + + /// Replace the colour on the Shadow effect at `index`. Blur + /// kinds carry no colour and silently reject. + pub(crate) fn cmd_set_effect_color(&mut self, node_id: &NodeId, index: u32, hex: &str) -> bool { + if !node_id.is_real() { + return false; + } + let Some(node) = find_node_mut(self.active_children_mut(), node_id) else { + return false; + }; + let Some(slot) = node_effects_slot(node) else { + return false; + }; + let Some(effects) = slot.as_mut() else { + return false; + }; + let Some(effect) = effects.get_mut(index as usize) else { + return false; + }; + match effect { + PenEffect::Shadow(s) => { + s.color = hex.to_string(); + true + } + _ => false, + } + } } diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index 21062e4f..2419c4cb 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -154,6 +154,15 @@ pub enum FileAction { ExportImage, ExportImageConfirm, ImportFigma, + /// User chose `Import image or SVG…` in the toolbar shape picker + /// — host opens a file dialog, then inserts the raster image as a + /// new Image node (or parses the SVG into nodes; SVG path lands + /// as a follow-up). + ImportImageOrSvg, + /// User clicked the `图片` fill body row — host opens a file + /// dialog and writes the chosen image into the selected node's + /// primary fill as `PenFill::Image { url: }`. + PickFillImage, OpenRecent(usize), ClearRecent, } diff --git a/crates/op-editor-core/src/host_support.rs b/crates/op-editor-core/src/host_support.rs index 47e3cc56..e1a5b496 100644 --- a/crates/op-editor-core/src/host_support.rs +++ b/crates/op-editor-core/src/host_support.rs @@ -141,6 +141,101 @@ impl EditorState { Some(id) } + /// Append an Image node centred on the current viewport with a + /// default 300×200 box. `src` is typically a `data:` URL the host + /// already produced from a picked file. Returns the new node id on + /// success; `None` when the id allocator is exhausted. + pub fn insert_image_node_at_viewport(&mut self, name: &str, src: &str) -> Option { + use jian_ops_schema::node::image::ImageNode; + use jian_ops_schema::node::PenNode; + use jian_ops_schema::sizing::SizingBehavior; + const W: f64 = 300.0; + const H: f64 = 200.0; + let pan_x = self.viewport.pan_x as f64; + let pan_y = self.viewport.pan_y as f64; + let zoom = self.viewport.zoom.max(0.001) as f64; + let centre_x = -pan_x / zoom; + let centre_y = -pan_y / zoom; + let safe = self.max_node_id().checked_add(1)?; + let id = NodeId::new(format!("n{}", safe)); + let mut next_id = safe.checked_add(1)?; + let _ = &mut next_id; + self.commit_history(); + let node = PenNode::Image(ImageNode { + base: jian_ops_schema::node::base::PenNodeBase { + id: id.as_str().to_string(), + name: Some(name.to_string()), + x: Some(centre_x - W / 2.0), + y: Some(centre_y - H / 2.0), + ..Default::default() + }, + src: src.to_string(), + object_fit: None, + width: Some(SizingBehavior::Number(W)), + height: Some(SizingBehavior::Number(H)), + corner_radius: None, + effects: None, + exposure: None, + contrast: None, + saturation: None, + temperature: None, + tint: None, + highlights: None, + shadows: None, + image_prompt: None, + image_search_query: None, + state: None, + bindings: None, + events: None, + lifecycle: None, + semantics: None, + gestures: None, + route: None, + }); + self.active_children_mut().push(node); + self.set_single_selection(id.clone()); + Some(id) + } + + /// Replace the selected node's primary fill with an Image fill + /// rooted at `src` (typically a `data:` URL). Existing colour / + /// gradient is overwritten; non-fillable variants reject silently. + /// Returns `true` on success. + pub fn set_selected_fill_image_url(&mut self, src: &str) -> bool { + use jian_ops_schema::style::{ImageFillBody, PenFill}; + let sel = self.selection.anchor.clone(); + if !sel.is_real() || !self.is_editable(&sel) { + return false; + } + let Some(node) = crate::walkers::find_node_mut(self.active_children_mut(), &sel) else { + return false; + }; + let Some(fills) = crate::fills::node_fills_mut(node) else { + return false; + }; + let body = PenFill::Image(ImageFillBody { + url: src.to_string(), + mode: None, + original_size: None, + transform: None, + explain: None, + opacity: None, + exposure: None, + contrast: None, + saturation: None, + temperature: None, + tint: None, + highlights: None, + shadows: None, + }); + if fills.is_empty() { + fills.push(body); + } else { + fills[0] = body; + } + true + } + /// Replace the path nodes `source_ids` on the active page with a /// single new `Path` node whose anchors trace `points`. Used by /// the host's path-boolean op: the skia `Path::op` math lives in diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index bca471c9..f074cf25 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -38,6 +38,7 @@ pub mod selection; pub mod state; pub mod svg_import; pub mod svg_path_bounds; +mod svg_path_data; pub mod tool; pub mod ui_draft; pub mod uikit; diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index e6934bbf..20ada4f6 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -465,11 +465,7 @@ impl EditorState { } PropertyFocus::GradientStopOffset(index) => { // Percent → fraction. Clamp happens inside the setter. - let _ = crate::fills::set_primary_gradient_stop_offset( - node, - index, - value / 100.0, - ); + let _ = crate::fills::set_primary_gradient_stop_offset(node, index, value / 100.0); } // Hex focuses route through the dedicated colour setters // (a typed-in hex is parsed by the host before commit), so diff --git a/crates/op-editor-core/src/svg_import.rs b/crates/op-editor-core/src/svg_import.rs index 2360b2e8..9d418048 100644 --- a/crates/op-editor-core/src/svg_import.rs +++ b/crates/op-editor-core/src/svg_import.rs @@ -26,8 +26,10 @@ use crate::node_id::NodeId; use crate::pen_node_ext::PenNodeExt; use crate::state::EditorState; use crate::{command_node::build_leaf_node, walkers}; +use jian_ops_schema::node::path::PenPathHandle; use jian_ops_schema::node::{PathNode, PenNode, PenNodeBase, PenPathAnchor}; use jian_ops_schema::sizing::SizingBehavior; +use jian_ops_schema::style::{PenFill, PenStroke, SolidFillBody, StrokeThickness}; /// One parsed SVG element — tag name + raw attribute pairs. struct SvgElement { @@ -52,34 +54,132 @@ impl SvgElement { } } +/// Inherited style context — propagates `fill` / `stroke` / +/// `stroke-width` from `` parents to children, mirroring the TS +/// `StyleCtx` (`packages/pen-engine/src/core/svg-parser.ts`). `None` +/// means "no inherited value, use the renderer default". +#[derive(Debug, Clone)] +struct StyleCtx { + fill: Option, + stroke: Option, + stroke_width: f64, +} + +impl Default for StyleCtx { + fn default() -> Self { + Self { + fill: None, + stroke: None, + stroke_width: 1.0, + } + } +} + +/// Tree element with parsed attrs + child elements (the recursive +/// `` case the old flat `parse_svg_elements` could not handle). +struct SvgTree { + tag: String, + attrs: Vec<(String, String)>, + children: Vec, +} + +impl SvgTree { + fn attr(&self, key: &str) -> Option<&str> { + self.attrs + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + #[allow(dead_code)] + fn num(&self, key: &str) -> f64 { + self.attr(key) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0.0) + } +} + +/// Tags ignored verbatim — definitions, gradients, clipping, scripts. +/// Mirrors the TS `SKIP_TAGS` constant. +const SKIP_TAGS: &[&str] = &[ + "defs", + "style", + "title", + "desc", + "metadata", + "clippath", + "mask", + "filter", + "lineargradient", + "radialgradient", + "symbol", + "marker", + "pattern", + "script", + "foreignobject", + "animate", + "animatemotion", + "set", +]; + +/// Default raster output size — matches the TS parser's `maxDim` so +/// imports land at the same on-canvas size as the TS app. +const SVG_MAX_DIM: f64 = 400.0; + impl EditorState { /// Parse `svg` and insert the resulting nodes onto the active page, /// translated by `offset` doc-px. Returns the count of nodes /// inserted; `0` (no history pushed) when the SVG yields nothing. /// One history snapshot is pushed when ≥ 1 node lands. pub fn import_svg(&mut self, next_id: &mut u64, svg: &str, offset: (f64, f64)) -> usize { - let elements = parse_svg_elements(svg); - if elements.is_empty() { + self.import_svg_named(next_id, svg, offset, None) + } + + /// Like [`Self::import_svg`] but uses `name` (typically the file + /// stem) as the wrapping Group's label so the layer panel shows + /// the SVG's filename instead of "Imported SVG". + pub fn import_svg_named( + &mut self, + next_id: &mut u64, + svg: &str, + offset: (f64, f64), + name: Option<&str>, + ) -> usize { + self.import_svg_impl(next_id, svg, offset, name) + } + + fn import_svg_impl( + &mut self, + next_id: &mut u64, + svg: &str, + offset: (f64, f64), + group_name: Option<&str>, + ) -> usize { + // TS-parity pipeline (`packages/pen-engine/src/core/svg-parser.ts`): + // 1. Extract the root `` attrs + compute a viewBox-aware + // scale so a 24×24 icon doesn't render at 24 px. + // 2. Walk the body recursively so `` children inherit + // style + don't escape into siblings. + // 3. Build a node tree; `` becomes a Group with the + // enclosed children. + let (body, root_attrs) = match extract_svg_root(svg) { + Some(p) => p, + None => return 0, + }; + let (scale, root_ctx) = compute_root_scale(&root_attrs); + let tree = parse_svg_tree(body); + if tree.is_empty() { return 0; } - // Seed the id allocator past the live id space so imported - // nodes never collide with existing ones. if let Some(safe) = self.max_node_id().checked_add(1) { *next_id = (*next_id).max(safe); } let mut taken = self.collect_node_ids(); let mut built: Vec = Vec::new(); - for el in &elements { - let Some(id) = walkers::alloc_n_id(next_id, &mut taken) else { - break; // id space exhausted — keep whatever was built - }; - match element_to_node(el, id.clone(), offset) { - Some(node) => built.push(node), - None => { - // Unsupported / empty element — return the id to - // the allocator pool so the next element reuses it. - taken.remove(&id); - } + for el in &tree { + if let Some(node) = + element_to_node_ctx(el, &root_ctx, scale, offset, next_id, &mut taken) + { + built.push(node); } } if built.is_empty() { @@ -87,12 +187,591 @@ impl EditorState { } let pre = self.snapshot_for_history(); let count = built.len(); - self.active_children_mut().extend(built); + // Wrap the imported nodes in a Group so the user can move / + // delete the SVG as a unit instead of `count` flat siblings + // each picking up its own row in the layer panel. Single- + // element SVGs (logos, an `` wrapping one ``) also + // benefit because future grouping ops then have a stable + // container to attach to. + let Some(group_id) = walkers::alloc_n_id(next_id, &mut taken) else { + // Allocator exhausted — fall back to the flat extend so we + // don't drop the user's import on the floor. + self.active_children_mut().extend(built); + self.history_push_past(pre); + return count; + }; + use jian_ops_schema::node::container::ContainerProps; + use jian_ops_schema::node::{GroupNode, PenNode}; + let group = PenNode::Group(GroupNode { + base: jian_ops_schema::node::base::PenNodeBase { + id: group_id.as_str().to_string(), + name: Some( + group_name + .map(|s| s.to_string()) + .unwrap_or_else(|| "Imported SVG".to_string()), + ), + ..Default::default() + }, + container: ContainerProps::default(), + children: Some(built), + state: None, + bindings: None, + events: None, + lifecycle: None, + semantics: None, + gestures: None, + route: None, + }); + self.active_children_mut().push(group); + self.set_single_selection(group_id); self.history_push_past(pre); count } } +/// Pull the root `` open tag from a document. Returns the +/// body between `` and `` plus the parsed root attrs. +/// `None` when the document lacks a balanced `` element — fed +/// to the regex-equivalent walker the way `parseSvgRegex` does in +/// the TS port. +fn extract_svg_root(svg: &str) -> Option<(&str, Vec<(String, String)>)> { + let lower: String = svg.chars().map(|c| c.to_ascii_lowercase()).collect(); + let open = lower.find("` in the document. + let close_marker = lower.rfind("")?; + if close_marker < body_start { + return None; + } + let attrs_str = &svg[after_open..close_of_open].trim_end_matches('/'); + let attrs = parse_attrs(attrs_str); + Some((&svg[body_start..close_marker], attrs)) +} + +/// Compute the viewBox-aware scale factor + seed style context. The +/// TS port caps the longer side at `maxDim` (400 px) and scales +/// every coord uniformly so an icon authored in a 24-unit viewBox +/// lands at 400 px instead of 24 px on the canvas. +fn compute_root_scale(root_attrs: &[(String, String)]) -> (f64, StyleCtx) { + let attr = |key: &str| -> Option<&str> { + root_attrs + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + }; + let view_box = attr("viewBox").or_else(|| attr("viewbox")); + let mut vb_w = 100.0_f64; + let mut vb_h = 100.0_f64; + if let Some(vb) = view_box { + let nums: Vec = vb + .split(|c: char| c.is_whitespace() || c == ',') + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse::().ok()) + .collect(); + if nums.len() >= 4 { + vb_w = nums[2].max(0.001); + vb_h = nums[3].max(0.001); + } + } + let parse_dim = |raw: &str| -> Option { + // Strip a trailing `px` so `width="24px"` round-trips; bail + // on `%` / `em` / `vh` since they need parent context. + let trimmed = raw.trim().trim_end_matches("px"); + if trimmed.is_empty() { + return None; + } + if trimmed.chars().any(|c| !c.is_ascii_digit() && c != '.') { + return None; + } + trimmed.parse::().ok() + }; + let svg_w = attr("width").and_then(parse_dim).unwrap_or(vb_w); + let svg_h = attr("height").and_then(parse_dim).unwrap_or(vb_h); + let mut out_w = svg_w; + let mut out_h = svg_h; + if out_w > SVG_MAX_DIM || out_h > SVG_MAX_DIM { + let s = SVG_MAX_DIM / out_w.max(out_h); + out_w *= s; + out_h *= s; + } + // Children scale by `out / vb` — matches the TS impl exactly. + let scale = (out_w / vb_w).min(out_h / vb_h).max(0.001); + let stroke_w = attr("stroke-width") + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(1.0); + let ctx = StyleCtx { + fill: attr("fill").map(|s| s.to_string()), + stroke: attr("stroke").map(|s| s.to_string()), + stroke_width: stroke_w, + }; + (scale, ctx) +} + +/// Recursive tree walker — depth-tracking version of +/// `parse_svg_elements`. Each opening tag pairs with its matching +/// `` so `` children land under the right parent. Skip +/// tags (`defs` / `style` / …) are filtered out before recursion. +fn parse_svg_tree(body: &str) -> Vec { + let bytes = body.as_bytes(); + let mut out = Vec::new(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] != b'<' { + i += 1; + continue; + } + // Skip comments. + if body[i..].starts_with("") { + Some(rel) => i += rel + 3, + None => break, + } + continue; + } + // Prolog / DOCTYPE / stray closing tag — advance to `>`. + if matches!(bytes.get(i + 1), Some(b'/') | Some(b'?') | Some(b'!')) { + match body[i..].find('>') { + Some(rel) => i += rel + 1, + None => break, + } + continue; + } + let Some(open_end) = find_tag_end(bytes, i + 1) else { + break; + }; + let inner = &body[i + 1..open_end]; + let self_closing = inner.trim_end().ends_with('/'); + let (tag_lower, attrs) = match parse_element(inner) { + Some(el) => (el.tag, el.attrs), + None => { + i = open_end + 1; + continue; + } + }; + if SKIP_TAGS.iter().any(|t| *t == tag_lower) { + // Skip its body too so child shapes inside `` don't + // leak into the import. + i = if self_closing { + open_end + 1 + } else { + skip_until_closing(body, open_end + 1, &tag_lower).unwrap_or(body.len()) + }; + continue; + } + if self_closing { + out.push(SvgTree { + tag: tag_lower, + attrs, + children: Vec::new(), + }); + i = open_end + 1; + continue; + } + let body_start = open_end + 1; + let body_end = find_matching_close(body, body_start, &tag_lower).unwrap_or(body.len()); + let inner_body = &body[body_start..body_end]; + out.push(SvgTree { + tag: tag_lower, + attrs, + children: parse_svg_tree(inner_body), + }); + // Advance past the close tag itself. + i = match body[body_end..].find('>') { + Some(rel) => body_end + rel + 1, + None => body.len(), + }; + } + out +} + +/// Index just past the `` that closes the given open tag at +/// `from`. Handles nested same-name tags so a `` inside a `` +/// pairs with its own close. Returns `None` when no matching close +/// exists (malformed SVG); the caller treats the rest of the body +/// as the element's content. +fn find_matching_close(body: &str, from: usize, tag: &str) -> Option { + let bytes = body.as_bytes(); + let mut depth = 1usize; + let mut i = from; + let open_pat = format!("<{tag}"); + let close_pat = format!(" return None, + (Some(o), None) => (o, false), + (None, Some(c)) => (c, true), + (Some(o), Some(c)) => { + if o < c { + (o, false) + } else { + (c, true) + } + } + }; + // Confirm the match is followed by `>` or whitespace (so + // `` doesn't false-match a `` close). + let abs = i + idx; + let after = abs + + if is_close { + close_pat.len() + } else { + open_pat.len() + }; + let next_char = body.as_bytes().get(after).copied(); + let valid = matches!( + next_char, + Some(b'>') | Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') | Some(b'/') + ); + if !valid { + i = abs + 1; + continue; + } + if is_close { + depth -= 1; + if depth == 0 { + return Some(abs); + } + i = abs + close_pat.len(); + } else { + depth += 1; + i = abs + open_pat.len(); + } + } + None +} + +/// Skip to just past `` for a skip-tag's body. Returns the +/// index after the closing tag's `>`. +fn skip_until_closing(body: &str, from: usize, tag: &str) -> Option { + let close = find_matching_close(body, from, tag)?; + body[close..].find('>').map(|rel| close + rel + 1) +} + +/// Build a `PenNode` for an SVG element with inherited style +/// context + viewBox scaling applied. `` recurses into its +/// children and wraps them in a `PenNode::Group`. +fn element_to_node_ctx( + el: &SvgTree, + parent_ctx: &StyleCtx, + scale: f64, + offset: (f64, f64), + next_id: &mut u64, + taken: &mut std::collections::HashSet, +) -> Option { + let ctx = merge_style_ctx(parent_ctx, &el.attrs); + if el.tag == "g" || el.tag == "svg" { + let mut kids: Vec = Vec::new(); + for child in &el.children { + if let Some(node) = element_to_node_ctx(child, &ctx, scale, offset, next_id, taken) { + kids.push(node); + } + } + if kids.is_empty() { + return None; + } + if kids.len() == 1 { + return Some(kids.into_iter().next().unwrap()); + } + let id = walkers::alloc_n_id(next_id, taken)?; + use jian_ops_schema::node::container::ContainerProps; + use jian_ops_schema::node::GroupNode; + let name = el + .attr("id") + .map(|s| s.to_string()) + .unwrap_or_else(|| "Group".to_string()); + return Some(PenNode::Group(GroupNode { + base: PenNodeBase { + id: id.into(), + name: Some(name), + ..Default::default() + }, + container: ContainerProps::default(), + children: Some(kids), + state: None, + bindings: None, + events: None, + lifecycle: None, + semantics: None, + gestures: None, + route: None, + })); + } + // Convert via the legacy element builder, then scale + apply + // inherited fill/stroke. + let legacy = SvgElement { + tag: el.tag.clone(), + attrs: el.attrs.clone(), + }; + // Scale the geometry by writing scaled values back into the + // element's attrs (so existing `element_to_node` sees scaled + // numbers). For `` we tokenise the `d` string + scale + // every coord — TS parity with `scaleSvgPath`. + let mut scaled = legacy; + apply_scale_to_attrs(&mut scaled, scale); + // Preserve `` as SVG path data. That keeps arcs and + // compound subpaths in the same representation the TS renderer + // sends to CanvasKit. + let fill_hex = resolve_svg_fill_hex(&scaled.attrs, &ctx); + let stroke = resolve_svg_stroke(&scaled.attrs, &ctx, scale); + if scaled.tag == "path" { + if let Some(d) = scaled.attr("d") { + let id = walkers::alloc_n_id(next_id, taken)?; + return path_node_from_svg_d( + id, + el.attr("id").unwrap_or("Path"), + d, + offset, + fill_hex, + stroke, + ); + } + } + let id = walkers::alloc_n_id(next_id, taken)?; + let mut node = element_to_node(&scaled, id, offset)?; + if let Some(stroke) = stroke { + set_node_stroke(&mut node, stroke); + } + if fill_hex.is_none() && scaled.tag != "line" && scaled.tag != "polyline" { + // Explicit fill="none" / fill="transparent" should not leave + // the old element builder's attribute fill behind. + clear_node_fill(&mut node); + } else if let Some(hex) = fill_hex.as_deref() { + set_primary_fill_hex(&mut node, hex); + } + Some(node) +} + +/// Merge `parent` with the element's own `fill` / `stroke` / +/// `stroke-width` (inline `style="..."` takes precedence over the +/// matching attribute, matching `extractStyleOrAttr` in TS). +fn merge_style_ctx(parent: &StyleCtx, attrs: &[(String, String)]) -> StyleCtx { + StyleCtx { + fill: extract_style_or_attr(attrs, "fill").or_else(|| parent.fill.clone()), + stroke: extract_style_or_attr(attrs, "stroke").or_else(|| parent.stroke.clone()), + stroke_width: extract_style_or_attr(attrs, "stroke-width") + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(parent.stroke_width), + } +} + +/// Look up `name` in an inline `style="..."` first, then fall back +/// to the named attribute. Mirrors `extractStyleOrAttr` from the TS +/// regex parser. +fn extract_style_or_attr(attrs: &[(String, String)], name: &str) -> Option { + if let Some(style) = attrs.iter().find(|(k, _)| k == "style").map(|(_, v)| v) { + // Naive CSS-ish split — values can't contain `;` because + // SVG inline styles forbid it. + for pair in style.split(';') { + let trimmed = pair.trim(); + if let Some((k, v)) = trimmed.split_once(':') { + if k.trim().eq_ignore_ascii_case(name) { + return Some(v.trim().to_string()); + } + } + } + } + attrs + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.to_string()) +} + +fn resolve_svg_fill_hex(attrs: &[(String, String)], ctx: &StyleCtx) -> Option { + let raw = extract_style_or_attr(attrs, "fill").or_else(|| ctx.fill.clone()); + match raw.as_deref().map(str::trim) { + None => Some("#000000".to_string()), + Some(v) if v.eq_ignore_ascii_case("none") || v.eq_ignore_ascii_case("transparent") => None, + Some(v) if v.to_ascii_lowercase().starts_with("url(") => Some("#000000".to_string()), + Some(v) => parse_svg_color(v), + } +} + +fn resolve_svg_stroke(attrs: &[(String, String)], ctx: &StyleCtx, scale: f64) -> Option { + let raw = extract_style_or_attr(attrs, "stroke").or_else(|| ctx.stroke.clone())?; + let raw = raw.trim(); + if raw.eq_ignore_ascii_case("none") || raw.to_ascii_lowercase().starts_with("url(") { + return None; + } + let hex = parse_svg_color(raw).unwrap_or_else(|| "#000000".to_string()); + let width = (ctx.stroke_width * scale).max(0.0) as f32; + if width <= 0.0 { + return None; + } + Some(PenStroke { + thickness: StrokeThickness::Uniform(width), + align: None, + join: None, + cap: None, + dash_pattern: None, + dash_offset: None, + fill: Some(vec![solid_fill(&hex)]), + }) +} + +fn solid_fill(hex: &str) -> PenFill { + PenFill::Solid(SolidFillBody { + color: hex.to_string(), + explain: None, + opacity: None, + blend_mode: None, + }) +} + +fn set_node_stroke(node: &mut PenNode, stroke: PenStroke) { + match node { + PenNode::Frame(n) => n.container.stroke = Some(stroke), + PenNode::Group(n) => n.container.stroke = Some(stroke), + PenNode::Rectangle(n) => n.container.stroke = Some(stroke), + PenNode::Ellipse(n) => n.stroke = Some(stroke), + PenNode::Polygon(n) => n.stroke = Some(stroke), + PenNode::Path(n) => n.stroke = Some(stroke), + PenNode::Line(n) => n.stroke = Some(stroke), + PenNode::TextInput(n) => n.stroke = Some(stroke), + PenNode::Text(_) | PenNode::IconFont(_) | PenNode::Image(_) | PenNode::Ref(_) => {} + } +} + +fn clear_node_fill(node: &mut PenNode) { + match node { + PenNode::Frame(n) => n.container.fill = None, + PenNode::Group(n) => n.container.fill = None, + PenNode::Rectangle(n) => n.container.fill = None, + PenNode::Ellipse(n) => n.fill = None, + PenNode::Polygon(n) => n.fill = None, + PenNode::Path(n) => n.fill = None, + PenNode::Text(n) => n.fill = None, + PenNode::TextInput(n) => n.fill = None, + PenNode::IconFont(n) => n.fill = None, + PenNode::Line(_) | PenNode::Image(_) | PenNode::Ref(_) => {} + } +} + +/// Multiply every numeric coord on the element by `scale` so a +/// 24-unit viewBox shape renders at the same final size as the TS +/// app's viewBox-aware path. For `` we tokenise the `d` +/// string + scale every coord; for shape elements we scale +/// `width` / `height` / `x` / `y` / `r` / `cx` / `cy` / `rx` / +/// `ry` / `x1` / `y1` / `x2` / `y2` in place. +fn apply_scale_to_attrs(el: &mut SvgElement, scale: f64) { + if (scale - 1.0).abs() < 1e-6 { + return; + } + if el.tag == "path" { + if let Some(pos) = el.attrs.iter().position(|(k, _)| k == "d") { + let scaled = scale_svg_path(&el.attrs[pos].1, scale); + el.attrs[pos].1 = scaled; + } + return; + } + if el.tag == "polyline" || el.tag == "polygon" { + if let Some(pos) = el.attrs.iter().position(|(k, _)| k == "points") { + let scaled = scale_svg_points(&el.attrs[pos].1, scale); + el.attrs[pos].1 = scaled; + } + return; + } + let scalable: &[&str] = &[ + "x", "y", "width", "height", "r", "rx", "ry", "cx", "cy", "x1", "y1", "x2", "y2", + ]; + for (k, v) in &mut el.attrs { + if !scalable.iter().any(|s| *s == k) { + continue; + } + if let Ok(n) = v.trim().parse::() { + *v = format!("{}", n * scale); + } + } +} + +/// Token-aware scaler — preserves arc `A` flags (rotation + +/// large-arc + sweep are unitless) and scales the rest. Direct port +/// of `scaleSvgPath` from the TS impl. +fn scale_svg_path(d: &str, scale: f64) -> String { + let mut out = String::with_capacity(d.len()); + let mut cmd: char = ' '; + let mut param_idx = 0usize; + let bytes = d.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + let c = bytes[i] as char; + if c.is_ascii_alphabetic() { + cmd = c; + param_idx = 0; + out.push(c); + i += 1; + continue; + } + if c.is_ascii_whitespace() || c == ',' { + out.push(c); + i += 1; + continue; + } + // Read a number (with optional sign / exponent). + let start = i; + if (c == '-' || c == '+') && i + 1 < bytes.len() { + i += 1; + } + while i < bytes.len() { + let cc = bytes[i] as char; + if cc.is_ascii_digit() || cc == '.' { + i += 1; + } else if (cc == 'e' || cc == 'E') + && i + 1 < bytes.len() + && matches!(bytes[i + 1] as char, '-' | '+' | '0'..='9') + { + i += 1; + if matches!(bytes[i] as char, '-' | '+') { + i += 1; + } + } else { + break; + } + } + if start == i { + i += 1; + continue; + } + let tok = &d[start..i]; + let Ok(n) = tok.parse::() else { + out.push_str(tok); + continue; + }; + let upper = cmd.to_ascii_uppercase(); + let scaled = if upper == 'A' { + // 7 params: rx ry rotation large-arc sweep x y + let pos = param_idx % 7; + let should_scale = pos == 0 || pos == 1 || pos == 5 || pos == 6; + if should_scale { + n * scale + } else { + n + } + } else { + n * scale + }; + out.push_str(&format!("{}", scaled)); + param_idx += 1; + } + out +} + +/// Scale a `points="x1,y1 x2,y2 …"` list for `` / +/// ``. Returns the same separator style (`x,y x,y …`). +fn scale_svg_points(s: &str, scale: f64) -> String { + parse_point_list(s) + .into_iter() + .map(|(x, y)| format!("{},{}", x * scale, y * scale)) + .collect::>() + .join(" ") +} + /// Build a `PenNode` from one parsed SVG element. `None` for /// unsupported tags (`g` / `svg` / `defs` / `style` / …) and for /// degenerate geometry. @@ -161,8 +840,13 @@ fn element_to_node(el: &SvgElement, id: NodeId, offset: (f64, f64)) -> Option { + // Multi-subpath handling lives in `element_to_node_ctx` + // where we have the id allocator + can build a Group; + // this legacy single-id path serves only the first subpath + // so existing callers keep working. let d = el.attr("d")?; - let (anchors, closed) = parse_path_d(d, offset); + let mut subpaths = parse_path_d(d, offset); + let (anchors, closed) = subpaths.drain(..).next()?; if anchors.len() < 2 { return None; } @@ -237,9 +921,46 @@ fn path_node_from_pen_anchors( route: None, })) } + +fn path_node_from_svg_d( + id: NodeId, + name: &str, + d: &str, + offset: (f64, f64), + fill_hex: Option, + stroke: Option, +) -> Option { + let (local_d, bounds) = crate::svg_path_data::localize_svg_path(d)?; + Some(PenNode::Path(PathNode { + base: PenNodeBase { + id: id.into(), + name: Some(name.to_string()), + x: Some(bounds.x + offset.0), + y: Some(bounds.y + offset.1), + ..Default::default() + }, + icon_id: None, + d: Some(local_d.clone()), + anchors: None, + closed: Some(local_d.contains('Z') || local_d.contains('z')), + width: Some(SizingBehavior::Number(bounds.w)), + height: Some(SizingBehavior::Number(bounds.h)), + fill: fill_hex.map(|hex| vec![solid_fill(&hex)]), + stroke, + effects: None, + state: None, + bindings: None, + events: None, + lifecycle: None, + semantics: None, + gestures: None, + route: None, + })) +} /// Flat scan of every `` / `` element in `svg`. /// Comments, the XML prolog, closing tags and DOCTYPE are skipped; /// nesting is ignored, so a shape inside a `` is still found. +#[allow(dead_code)] // Kept for tests / fallback callers; the TS-parity tree walker is `parse_svg_tree`. fn parse_svg_elements(svg: &str) -> Vec { let bytes = svg.as_bytes(); let mut out = Vec::new(); @@ -411,12 +1132,16 @@ fn scan_numbers(s: &str) -> Vec { out } -/// Parse an SVG path `d` string into `PenPathAnchor`s. Supports the -/// `M L H V C S Q T Z` commands (absolute + relative). `A` degrades to -/// a straight segment to its endpoint. Returns `(anchors, closed)`. -fn parse_path_d(d: &str, offset: (f64, f64)) -> (Vec, bool) { +/// Parse an SVG path `d` string into a list of subpaths. Each `M` +/// (after the first move) starts a new subpath — SVG's pen-up +/// semantics — so the renderer doesn't draw a stray straight line +/// between disconnected outlines. Returns `Vec<(anchors, closed)>`; +/// supports `M L H V C S Q T Z` (absolute + relative); `A` degrades +/// to a straight segment to its endpoint. +fn parse_path_d(d: &str, offset: (f64, f64)) -> Vec<(Vec, bool)> { let tokens = tokenize_path(d); let (ox, oy) = offset; + let mut subpaths: Vec<(Vec, bool)> = Vec::new(); let mut anchors: Vec = Vec::new(); let mut closed = false; // Current pen position, sub-path start, and the last control point @@ -484,6 +1209,17 @@ fn parse_path_d(d: &str, offset: (f64, f64)) -> (Vec, bool) { } match up { b'M' => { + // Pen-up: a fresh `M` starts a new subpath. Flush the + // current one (when it has ≥ 2 anchors) before starting + // — otherwise multiple `M` commands in a single `d` + // string get fused into one polyline with a stray + // straight line between subpaths. + if anchors.len() >= 2 { + subpaths.push((std::mem::take(&mut anchors), closed)); + } else { + anchors.clear(); + } + closed = false; let (x, y) = abs_pt(rel, cx, cy, args[0], args[1]); cx = x; cy = y; @@ -577,7 +1313,11 @@ fn parse_path_d(d: &str, offset: (f64, f64)) -> (Vec, bool) { _ => {} } } - (anchors, closed) + // Flush the trailing subpath (no terminating `M` to flush it). + if anchors.len() >= 2 { + subpaths.push((anchors, closed)); + } + subpaths } /// Resolve a possibly-relative point against the current pen pos. @@ -599,20 +1339,17 @@ fn quad_to_cubic(x0: f64, y0: f64, qx: f64, qy: f64, x1: f64, y1: f64) -> (f64, ) } -/// Sample count when flattening a cubic-Bezier segment to straight -/// anchors. 24 is visually smooth without bloating the anchor list. -const CUBIC_FLATTEN_STEPS: usize = 24; - -/// Append a cubic-curve segment as **dense straight-line anchors**. +/// Append a cubic-curve segment, **preserving the bezier handles** +/// so the canvas painter's `flatten_path` redraws the smooth curve +/// at paint time. The previous anchor gets `handle_out = c1 − p0`; +/// the new anchor (endpoint) gets `handle_in = c2 − p3`. Both stored +/// as anchor-relative deltas — `path_anchor_bounds` + the layout-scene +/// builder agree on the relative convention. /// -/// The canvas painter, the export renderer and the pen-tool anchor -/// hit-test all model a Path as a straight-segment polyline whose -/// `points` are 1:1 with its anchors. Storing bezier handles instead -/// would either be dropped by the renderer (curve lost) or desync the -/// pen-tool anchor index — so the curve is flattened here, at import -/// time, into `CUBIC_FLATTEN_STEPS` straight anchors that trace it. -/// `c1`/`c2` are the SVG control points, `(x, y)` the endpoint, -/// `(ox, oy)` the document offset; the previous anchor is the start. +/// Earlier this flattened cubics into 24 straight anchors at import +/// time, which dropped curve fidelity entirely. The canvas painter's +/// `flatten_path` already handles handles correctly, so flattening +/// here was both lossy and redundant. // Each control point + endpoint + offset is its own scalar — bundling // them into a struct would only obscure a flat geometric signature. #[allow(clippy::too_many_arguments)] @@ -627,29 +1364,28 @@ fn emit_cubic( ox: f64, oy: f64, ) { - // The start point is the last anchor (already offset-applied). - let Some(&PenPathAnchor { x: p0x, y: p0y, .. }) = anchors.last() else { - return; + let (p0x, p0y) = match anchors.last() { + Some(a) => (a.x, a.y), + None => return, }; - let (p1x, p1y) = (c1x + ox, c1y + oy); - let (p2x, p2y) = (c2x + ox, c2y + oy); - let (p3x, p3y) = (x + ox, y + oy); - for s in 1..=CUBIC_FLATTEN_STEPS { - let t = s as f64 / CUBIC_FLATTEN_STEPS as f64; - anchors.push(PenPathAnchor { - x: cubic_eval(p0x, p1x, p2x, p3x, t), - y: cubic_eval(p0y, p1y, p2y, p3y, t), - handle_in: None, - handle_out: None, - point_type: None, + let p3x = x + ox; + let p3y = y + oy; + if let Some(last) = anchors.last_mut() { + last.handle_out = Some(PenPathHandle { + x: c1x + ox - p0x, + y: c1y + oy - p0y, }); } -} - -/// One axis of a cubic Bezier evaluated at parameter `t`. -fn cubic_eval(p0: f64, p1: f64, p2: f64, p3: f64, t: f64) -> f64 { - let mt = 1.0 - t; - mt * mt * mt * p0 + 3.0 * mt * mt * t * p1 + 3.0 * mt * t * t * p2 + t * t * t * p3 + anchors.push(PenPathAnchor { + x: p3x, + y: p3y, + handle_in: Some(PenPathHandle { + x: c2x + ox - p3x, + y: c2y + oy - p3y, + }), + handle_out: None, + point_type: None, + }); } /// A path-`d` lexer token. diff --git a/crates/op-editor-core/src/svg_import_tests.rs b/crates/op-editor-core/src/svg_import_tests.rs index 8c775b32..a3cb66fe 100644 --- a/crates/op-editor-core/src/svg_import_tests.rs +++ b/crates/op-editor-core/src/svg_import_tests.rs @@ -1,13 +1,35 @@ //! `EditorState::import_svg` tests — split out of `svg_import.rs` to //! keep both files under the repo's 800-line cap. +//! +//! TS-parity import wraps every multi-node SVG in a `Group` (mirrors +//! `parseSvgToNodes`'s `wrapIfMultiple`); single-node imports land +//! flat. Tests honour that convention via the `import_root` helper +//! which transparently unwraps the Group when present. #![cfg(test)] use crate::test_support::state_with; use jian_ops_schema::node::PenNode; +/// Return the imported top-level nodes regardless of whether they +/// were wrapped in a Group (multi-node SVG) or left flat (single +/// node). Mirrors `wrapIfMultiple` in the TS parser. +fn imported_nodes(s: &crate::EditorState) -> Vec<&PenNode> { + let kids = s.active_children(); + if kids.len() == 1 { + if let PenNode::Group(g) = &kids[0] { + if let Some(inner) = g.children.as_ref() { + return inner.iter().collect(); + } + } + } + kids.iter().collect() +} + #[test] fn imports_basic_shapes() { + // No viewBox / width / height → fallback (vb 100×100, width 100, + // height 100, scale = 1.0) so coords pass through unchanged. let svg = r##" @@ -17,18 +39,17 @@ fn imports_basic_shapes() { let mut next = 1u64; let n = s.import_svg(&mut next, svg, (0.0, 0.0)); assert_eq!(n, 3); - assert_eq!(s.active_children().len(), 3); - // The rect kept its position + size. - match &s.active_children()[0] { + let kids = imported_nodes(&s); + assert_eq!(kids.len(), 3); + match kids[0] { PenNode::Rectangle(r) => { assert_eq!(r.base.x, Some(10.0)); assert_eq!(r.base.y, Some(20.0)); } other => panic!("expected rect, got {other:?}"), } - // circle / ellipse imported as Ellipse nodes. - assert!(matches!(&s.active_children()[1], PenNode::Ellipse(_))); - assert!(matches!(&s.active_children()[2], PenNode::Ellipse(_))); + assert!(matches!(kids[1], PenNode::Ellipse(_))); + assert!(matches!(kids[2], PenNode::Ellipse(_))); } #[test] @@ -37,7 +58,8 @@ fn imports_offset_translates_nodes() { let mut s = state_with(vec![]); let mut next = 1u64; assert_eq!(s.import_svg(&mut next, svg, (200.0, 100.0)), 1); - match &s.active_children()[0] { + let kids = imported_nodes(&s); + match kids[0] { PenNode::Rectangle(r) => { assert_eq!(r.base.x, Some(200.0)); assert_eq!(r.base.y, Some(100.0)); @@ -48,30 +70,38 @@ fn imports_offset_translates_nodes() { #[test] fn imports_path_with_lines_and_cubic() { - // A move + line + cubic + close. The cubic is flattened to dense - // straight anchors at import time (no bezier handles) so the - // renderer + pen-tool stay on the straight-segment polyline model. + // SVG path import preserves the source `d` so Canvas/Skia keeps + // cubic commands, arcs and compound subpaths intact. let svg = r#""#; let mut s = state_with(vec![]); let mut next = 1u64; assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1); - match &s.active_children()[0] { + let kids = imported_nodes(&s); + match kids[0] { PenNode::Path(p) => { - let anchors = p.anchors.as_ref().unwrap(); - // M + L + 24 flattened cubic samples = 26 anchors. - assert_eq!(anchors.len(), 26); + let d = p.d.as_deref().expect("path d"); + assert!(d.contains('C'), "cubic command must survive: {d}"); + assert!(p.anchors.as_ref().map_or(true, Vec::is_empty)); assert_eq!(p.closed, Some(true)); - // Flattened — no anchor carries bezier handles, so - // `points` stays 1:1 with anchors for pen-tool editing. - assert!(anchors - .iter() - .all(|a| a.handle_in.is_none() && a.handle_out.is_none())); - // The first two anchors are the M / L endpoints. - assert_eq!((anchors[0].x, anchors[0].y), (0.0, 0.0)); - assert_eq!((anchors[1].x, anchors[1].y), (100.0, 0.0)); - // The last cubic sample (t = 1) is the curve endpoint. - let last = anchors.last().unwrap(); - assert!((last.x - 0.0).abs() < 1e-6 && (last.y - 100.0).abs() < 1e-6); + } + other => panic!("expected path, got {other:?}"), + } +} + +#[test] +fn multiple_subpaths_remain_one_compound_path() { + // `M ... M ...` is SVG pen-up; keeping the source `d` preserves + // both the moveto break and compound fill rules. + let svg = r#""#; + let mut s = state_with(vec![]); + let mut next = 1u64; + assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1); + let kids = imported_nodes(&s); + assert_eq!(kids.len(), 1); + match kids[0] { + PenNode::Path(p) => { + let d = p.d.as_deref().expect("compound d"); + assert_eq!(d.matches('M').count(), 2); } other => panic!("expected path, got {other:?}"), } @@ -79,15 +109,12 @@ fn imports_path_with_lines_and_cubic() { #[test] fn curved_path_frame_covers_the_flattened_curve() { - // A cubic peaking at y = 75 between two y=0 endpoints. Flattening - // samples the curve at t = k/24, and t = 12/24 = 0.5 lands exactly - // on the peak — so the dense anchors' bbox gives the Path a frame - // height of 75, covering the curve rather than the y=0 chord. let svg = r#""#; let mut s = state_with(vec![]); let mut next = 1u64; assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1); - match &s.active_children()[0] { + let kids = imported_nodes(&s); + match kids[0] { PenNode::Path(p) => { assert_eq!(p.base.y, Some(0.0)); match &p.height { @@ -106,16 +133,17 @@ fn curved_path_frame_covers_the_flattened_curve() { #[test] fn relative_path_commands_resolve() { - // m + relative l: pen ends at (10+5, 10+5) = (15,15). let svg = r#""#; let mut s = state_with(vec![]); let mut next = 1u64; assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1); - match &s.active_children()[0] { + let kids = imported_nodes(&s); + match kids[0] { PenNode::Path(p) => { - let a = p.anchors.as_ref().unwrap(); - assert_eq!((a[0].x, a[0].y), (10.0, 10.0)); - assert_eq!((a[1].x, a[1].y), (15.0, 15.0)); + let d = p.d.as_deref().expect("path d"); + assert_eq!(d, "M 0 0 L 5 5"); + assert_eq!(p.base.x, Some(10.0)); + assert_eq!(p.base.y, Some(10.0)); } other => panic!("expected path, got {other:?}"), } @@ -127,7 +155,8 @@ fn polygon_imports_as_closed_path() { let mut s = state_with(vec![]); let mut next = 1u64; assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1); - match &s.active_children()[0] { + let kids = imported_nodes(&s); + match kids[0] { PenNode::Path(p) => { assert_eq!(p.anchors.as_ref().unwrap().len(), 3); assert_eq!(p.closed, Some(true)); @@ -141,14 +170,12 @@ fn empty_or_unsupported_svg_imports_nothing() { let mut s = state_with(vec![]); let mut next = 1u64; assert_eq!(s.import_svg(&mut next, "", (0.0, 0.0)), 0); - // A doc with only a wrapper + no shapes. assert_eq!(s.import_svg(&mut next, "", (0.0, 0.0)), 0); assert_eq!(s.active_children().len(), 0); } #[test] fn degenerate_shapes_are_skipped() { - // Zero-size rect + zero-radius circle contribute nothing. let svg = r#" @@ -158,3 +185,124 @@ fn degenerate_shapes_are_skipped() { let mut next = 1u64; assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1); } + +#[test] +fn viewbox_caps_oversized_svg_at_max_dim() { + // A 1024-unit SVG with no explicit width — viewBox-aware + // sizing caps the output at maxDim (400 px); a unit shape in + // the viewBox lands scaled accordingly. TS parity with + // `parseSvgDimensions`'s `if (outW > maxDim ...)` cap. + let svg = r##""##; + let mut s = state_with(vec![]); + let mut next = 1u64; + assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1); + let kids = imported_nodes(&s); + match kids[0] { + PenNode::Rectangle(r) => { + // scale = 400/1024 ≈ 0.39 → width ≈ 400. + match &r.container.width { + Some(jian_ops_schema::sizing::SizingBehavior::Number(w)) => { + assert!( + (*w - 400.0).abs() < 1.0, + "expected capped width ≈ 400, got {w}" + ); + } + other => panic!("expected numeric width, got {other:?}"), + } + } + other => panic!("expected rect, got {other:?}"), + } +} + +#[test] +fn group_wraps_multi_node_svg() { + // Multi-node SVG must land as a single Group so the layer panel + // shows one row instead of N flat siblings. + let svg = r##" + + + "##; + let mut s = state_with(vec![]); + let mut next = 1u64; + assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 2); + assert_eq!(s.active_children().len(), 1); + match &s.active_children()[0] { + PenNode::Group(g) => { + assert_eq!(g.children.as_ref().unwrap().len(), 2); + } + other => panic!("expected Group wrap, got {other:?}"), + } +} + +#[test] +fn g_children_inherit_parent_fill() { + // `` with a `` that has no `fill` of its + // own — the path must paint blue, mirroring `mergeStyleCtxAttrs`. + let svg = r##" + + + + "##; + let mut s = state_with(vec![]); + let mut next = 1u64; + assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1); + let kids = imported_nodes(&s); + let path = match kids[0] { + PenNode::Path(p) => p, + other => panic!("expected path, got {other:?}"), + }; + let fill = path.fill.as_ref().expect("inherited fill"); + let first = fill.first().expect("at least one fill"); + let hex = match first { + jian_ops_schema::style::PenFill::Solid(b) => &b.color, + other => panic!("expected solid fill, got {other:?}"), + }; + assert!(hex.eq_ignore_ascii_case("#0000ff"), "got {hex}"); +} + +#[test] +fn filled_arc_path_preserves_svg_d_and_default_black_fill() { + let svg = r#" + + "#; + let mut s = state_with(vec![]); + let mut next = 1u64; + assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1); + let kids = imported_nodes(&s); + let path = match kids[0] { + PenNode::Path(p) => p, + other => panic!("expected path, got {other:?}"), + }; + let d = path.d.as_deref().expect("imported SVG path d"); + assert!(d.contains('A'), "arc command must survive, got {d}"); + assert!(path.anchors.as_ref().map_or(true, Vec::is_empty)); + let fill = path.fill.as_ref().expect("default SVG fill"); + let first = fill.first().expect("at least one fill"); + let hex = match first { + jian_ops_schema::style::PenFill::Solid(b) => &b.color, + other => panic!("expected solid fill, got {other:?}"), + }; + assert!(hex.eq_ignore_ascii_case("#000000"), "got {hex}"); +} + +#[test] +fn compound_filled_path_stays_single_path_node() { + let svg = r#" + + "#; + let mut s = state_with(vec![]); + let mut next = 1u64; + assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1); + let kids = imported_nodes(&s); + assert_eq!(kids.len(), 1); + let path = match kids[0] { + PenNode::Path(p) => p, + other => panic!("expected single compound path, got {other:?}"), + }; + let d = path.d.as_deref().expect("compound path d"); + assert_eq!( + d.matches('M').count(), + 2, + "subpaths must stay in one d: {d}" + ); +} diff --git a/crates/op-editor-core/src/svg_path_data.rs b/crates/op-editor-core/src/svg_path_data.rs new file mode 100644 index 00000000..c07d12c3 --- /dev/null +++ b/crates/op-editor-core/src/svg_path_data.rs @@ -0,0 +1,468 @@ +//! SVG path `d` helpers used by import. +//! +//! The editable path model stores anchors. Imported SVG paths need a +//! separate preserve-`d` path so arc commands and compound fill rules +//! keep the same visual result as the TS renderer. + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct SvgPathBounds { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum PathToken { + Cmd(u8), + Num(f64), +} + +#[derive(Debug, Clone, PartialEq)] +enum Segment { + Move(f64, f64), + Line(f64, f64), + HLine(f64), + VLine(f64), + Cubic(f64, f64, f64, f64, f64, f64), + SmoothCubic(f64, f64, f64, f64), + Quad(f64, f64, f64, f64), + SmoothQuad(f64, f64), + Arc(f64, f64, f64, f64, f64, f64, f64), + Close, +} + +/// Convert any supported relative commands to absolute commands, +/// compute a coarse path bbox, then shift absolute coordinates so +/// 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 local = serialize_local_segments(&segments, bounds.x, bounds.y); + Some((local, bounds)) +} + +fn absolute_segments(d: &str) -> Option> { + let tokens = tokenize_path(d); + if tokens.is_empty() { + return None; + } + let mut out = Vec::new(); + let mut ti = 0usize; + let mut cmd = b' '; + let (mut cx, mut cy) = (0.0f64, 0.0f64); + let (mut sx, mut sy) = (0.0f64, 0.0f64); + while ti < tokens.len() { + if let PathToken::Cmd(c) = tokens[ti] { + cmd = c; + ti += 1; + } + let rel = cmd.is_ascii_lowercase(); + let up = cmd.to_ascii_uppercase(); + let need = match up { + b'M' | b'L' | b'T' => 2, + b'H' | b'V' => 1, + b'C' => 6, + b'S' | b'Q' => 4, + b'A' => 7, + b'Z' => 0, + _ => return None, + }; + if up == b'Z' { + out.push(Segment::Close); + cx = sx; + cy = sy; + cmd = b' '; + continue; + } + let mut args = [0.0f64; 7]; + let mut got = 0usize; + while got < need && ti < tokens.len() { + if let PathToken::Num(n) = tokens[ti] { + args[got] = n; + got += 1; + ti += 1; + } else { + break; + } + } + if got < need { + return None; + } + match up { + b'M' => { + let (x, y) = abs_pt(rel, cx, cy, args[0], args[1]); + out.push(Segment::Move(x, y)); + cx = x; + cy = y; + sx = x; + sy = y; + cmd = if rel { b'l' } else { b'L' }; + } + b'L' => { + let (x, y) = abs_pt(rel, cx, cy, args[0], args[1]); + out.push(Segment::Line(x, y)); + cx = x; + cy = y; + } + b'H' => { + let x = if rel { cx + args[0] } else { args[0] }; + out.push(Segment::HLine(x)); + cx = x; + } + b'V' => { + let y = if rel { cy + args[0] } else { args[0] }; + out.push(Segment::VLine(y)); + cy = y; + } + b'C' => { + let (x1, y1) = abs_pt(rel, cx, cy, args[0], args[1]); + let (x2, y2) = abs_pt(rel, cx, cy, args[2], args[3]); + let (x, y) = abs_pt(rel, cx, cy, args[4], args[5]); + out.push(Segment::Cubic(x1, y1, x2, y2, x, y)); + cx = x; + cy = y; + } + b'S' => { + let (x2, y2) = abs_pt(rel, cx, cy, args[0], args[1]); + let (x, y) = abs_pt(rel, cx, cy, args[2], args[3]); + out.push(Segment::SmoothCubic(x2, y2, x, y)); + cx = x; + cy = y; + } + b'Q' => { + let (x1, y1) = abs_pt(rel, cx, cy, args[0], args[1]); + let (x, y) = abs_pt(rel, cx, cy, args[2], args[3]); + out.push(Segment::Quad(x1, y1, x, y)); + cx = x; + cy = y; + } + b'T' => { + let (x, y) = abs_pt(rel, cx, cy, args[0], args[1]); + out.push(Segment::SmoothQuad(x, y)); + cx = x; + cy = y; + } + b'A' => { + let (x, y) = abs_pt(rel, cx, cy, args[5], args[6]); + out.push(Segment::Arc( + args[0], args[1], args[2], args[3], args[4], x, y, + )); + cx = x; + cy = y; + } + _ => return None, + } + } + Some(out) +} + +fn segment_bounds(segments: &[Segment]) -> Option { + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + let mut include = |x: f64, y: f64| { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + }; + let (mut cx, mut cy) = (0.0f64, 0.0f64); + let (mut sx, mut sy) = (0.0f64, 0.0f64); + let mut last_cubic_ctrl: Option<(f64, f64)> = None; + let mut last_quad_ctrl: Option<(f64, f64)> = None; + for seg in segments { + match *seg { + Segment::Move(x, y) => { + include(x, y); + cx = x; + cy = y; + sx = x; + sy = y; + last_cubic_ctrl = None; + last_quad_ctrl = None; + } + Segment::Line(x, y) => { + include(x, y); + cx = x; + cy = y; + last_cubic_ctrl = None; + last_quad_ctrl = None; + } + Segment::HLine(x) => { + include(x, cy); + cx = x; + last_cubic_ctrl = None; + last_quad_ctrl = None; + } + Segment::VLine(y) => { + include(cx, y); + cy = y; + last_cubic_ctrl = None; + last_quad_ctrl = None; + } + Segment::Cubic(x1, y1, x2, y2, x, y) => { + include_cubic_bounds(&mut include, cx, cy, x1, y1, x2, y2, x, y); + cx = x; + cy = y; + last_cubic_ctrl = Some((x2, y2)); + last_quad_ctrl = None; + } + Segment::SmoothCubic(x2, y2, x, y) => { + let (x1, y1) = last_cubic_ctrl + .map(|(px, py)| (2.0 * cx - px, 2.0 * cy - py)) + .unwrap_or((cx, cy)); + include_cubic_bounds(&mut include, cx, cy, x1, y1, x2, y2, x, y); + cx = x; + cy = y; + last_cubic_ctrl = Some((x2, y2)); + last_quad_ctrl = None; + } + Segment::Quad(qx, qy, x, y) => { + let (x1, y1, x2, y2) = quad_to_cubic(cx, cy, qx, qy, x, y); + include_cubic_bounds(&mut include, cx, cy, x1, y1, x2, y2, x, y); + cx = x; + cy = y; + last_quad_ctrl = Some((qx, qy)); + last_cubic_ctrl = None; + } + Segment::SmoothQuad(x, y) => { + let (qx, qy) = last_quad_ctrl + .map(|(px, py)| (2.0 * cx - px, 2.0 * cy - py)) + .unwrap_or((cx, cy)); + let (x1, y1, x2, y2) = quad_to_cubic(cx, cy, qx, qy, x, y); + include_cubic_bounds(&mut include, cx, cy, x1, y1, x2, y2, x, y); + cx = x; + cy = y; + last_quad_ctrl = Some((qx, qy)); + last_cubic_ctrl = None; + } + Segment::Arc(_, _, _, _, _, x, y) => { + include(x, y); + cx = x; + cy = y; + last_cubic_ctrl = None; + last_quad_ctrl = None; + } + Segment::Close => { + cx = sx; + cy = sy; + last_cubic_ctrl = None; + last_quad_ctrl = None; + } + } + } + if !min_x.is_finite() { + 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), + }) +} + +fn include_cubic_bounds( + include: &mut impl FnMut(f64, f64), + p0x: f64, + p0y: f64, + p1x: f64, + p1y: f64, + p2x: f64, + p2y: f64, + p3x: f64, + p3y: f64, +) { + include(p0x, p0y); + include(p3x, p3y); + for t in cubic_derivative_roots(p0x, p1x, p2x, p3x) { + include( + eval_cubic(p0x, p1x, p2x, p3x, t), + eval_cubic(p0y, p1y, p2y, p3y, t), + ); + } + for t in cubic_derivative_roots(p0y, p1y, p2y, p3y) { + include( + eval_cubic(p0x, p1x, p2x, p3x, t), + eval_cubic(p0y, p1y, p2y, p3y, t), + ); + } +} + +fn quad_to_cubic(x0: f64, y0: f64, qx: f64, qy: f64, x1: f64, y1: f64) -> (f64, f64, f64, f64) { + ( + x0 + 2.0 / 3.0 * (qx - x0), + y0 + 2.0 / 3.0 * (qy - y0), + x1 + 2.0 / 3.0 * (qx - x1), + y1 + 2.0 / 3.0 * (qy - y1), + ) +} + +fn cubic_derivative_roots(p0: f64, p1: f64, p2: f64, p3: f64) -> Vec { + const EPS: f64 = 1e-9; + let a = -p0 + 3.0 * p1 - 3.0 * p2 + p3; + let b = 2.0 * (p0 - 2.0 * p1 + p2); + let c = -p0 + p1; + let in_unit = |t: f64| t > 0.0 && t < 1.0; + if a.abs() <= EPS { + if b.abs() <= EPS { + return Vec::new(); + } + let t = -c / b; + return if in_unit(t) { vec![t] } else { Vec::new() }; + } + let disc = b * b - 4.0 * a * c; + if disc < 0.0 { + return Vec::new(); + } + let s = disc.sqrt(); + let mut out = Vec::with_capacity(2); + for t in [(-b + s) / (2.0 * a), (-b - s) / (2.0 * a)] { + if in_unit(t) { + out.push(t); + } + } + out +} + +fn eval_cubic(p0: f64, p1: f64, p2: f64, p3: f64, t: f64) -> f64 { + let mt = 1.0 - t; + mt * mt * mt * p0 + 3.0 * mt * mt * t * p1 + 3.0 * mt * t * t * p2 + t * t * t * p3 +} + +fn serialize_local_segments(segments: &[Segment], ox: f64, oy: f64) -> String { + let mut out = String::new(); + for seg in segments { + if !out.is_empty() { + out.push(' '); + } + match *seg { + Segment::Move(x, y) => write_pair(&mut out, 'M', x - ox, y - oy), + Segment::Line(x, y) => write_pair(&mut out, 'L', x - ox, y - oy), + Segment::HLine(x) => write_one(&mut out, 'H', x - ox), + Segment::VLine(y) => write_one(&mut out, 'V', y - oy), + Segment::Cubic(x1, y1, x2, y2, x, y) => { + out.push('C'); + push_num(&mut out, x1 - ox); + push_num(&mut out, y1 - oy); + push_num(&mut out, x2 - ox); + push_num(&mut out, y2 - oy); + push_num(&mut out, x - ox); + push_num(&mut out, y - oy); + } + Segment::SmoothCubic(x2, y2, x, y) => { + out.push('S'); + push_num(&mut out, x2 - ox); + push_num(&mut out, y2 - oy); + push_num(&mut out, x - ox); + push_num(&mut out, y - oy); + } + Segment::Quad(x1, y1, x, y) => { + out.push('Q'); + push_num(&mut out, x1 - ox); + push_num(&mut out, y1 - oy); + push_num(&mut out, x - ox); + push_num(&mut out, y - oy); + } + Segment::SmoothQuad(x, y) => write_pair(&mut out, 'T', x - ox, y - oy), + Segment::Arc(rx, ry, rot, large, sweep, x, y) => { + out.push('A'); + push_num(&mut out, rx); + push_num(&mut out, ry); + push_num(&mut out, rot); + push_num(&mut out, large); + push_num(&mut out, sweep); + push_num(&mut out, x - ox); + push_num(&mut out, y - oy); + } + Segment::Close => out.push('Z'), + } + } + out +} + +fn write_pair(out: &mut String, cmd: char, x: f64, y: f64) { + out.push(cmd); + push_num(out, x); + push_num(out, y); +} + +fn write_one(out: &mut String, cmd: char, n: f64) { + out.push(cmd); + push_num(out, n); +} + +fn push_num(out: &mut String, n: f64) { + out.push(' '); + let n = if n.abs() < 1e-9 { 0.0 } else { n }; + out.push_str( + &format!("{n:.6}") + .trim_end_matches('0') + .trim_end_matches('.'), + ); +} + +fn abs_pt(rel: bool, cx: f64, cy: f64, x: f64, y: f64) -> (f64, f64) { + if rel { + (cx + x, cy + y) + } else { + (x, y) + } +} + +fn tokenize_path(d: &str) -> Vec { + let bytes = d.as_bytes(); + let mut out = Vec::new(); + let mut i = 0usize; + while i < bytes.len() { + let c = bytes[i]; + if c.is_ascii_alphabetic() { + out.push(PathToken::Cmd(c)); + i += 1; + } else if c == b'-' || c == b'+' || c == b'.' || c.is_ascii_digit() { + let start = i; + i += 1; + let mut seen_dot = c == b'.'; + let mut seen_exp = false; + while i < bytes.len() { + let d = bytes[i]; + if d.is_ascii_digit() { + i += 1; + } else if d == b'.' && !seen_dot && !seen_exp { + seen_dot = true; + i += 1; + } else if (d == b'e' || d == b'E') && !seen_exp { + seen_exp = true; + i += 1; + if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'+') { + i += 1; + } + } else { + break; + } + } + if let Ok(n) = d[start..i].parse::() { + out.push(PathToken::Num(n)); + } + } else { + i += 1; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::localize_svg_path; + + #[test] + fn preserves_arc_and_compound_moves() { + let (d, bounds) = localize_svg_path("M10 50 A40 40 0 0 1 90 50 Z M5 5 H15").expect("path"); + assert!(d.contains('A')); + assert_eq!(d.matches('M').count(), 2); + assert_eq!(bounds.x, 5.0); + assert_eq!(bounds.y, 5.0); + } +} diff --git a/crates/op-editor-core/src/ui_draft.rs b/crates/op-editor-core/src/ui_draft.rs index bfb852a7..ebdd23b0 100644 --- a/crates/op-editor-core/src/ui_draft.rs +++ b/crates/op-editor-core/src/ui_draft.rs @@ -60,9 +60,7 @@ impl PropertyFocus { pub fn is_hex(self) -> bool { matches!( self, - PropertyFocus::FillHex - | PropertyFocus::StrokeHex - | PropertyFocus::GradientStopHex(_) + PropertyFocus::FillHex | PropertyFocus::StrokeHex | PropertyFocus::GradientStopHex(_) ) } @@ -92,6 +90,10 @@ pub enum ColorTarget { /// One stop on the primary gradient body — indexed against /// `stops`. Out-of-range writes are silently dropped at commit. GradientStop(usize), + /// The colour of the visual effect at `index` (Shadow only). + /// Out-of-range writes silently drop at commit; the picker stays + /// open against a missing target so the user can re-pick. + EffectColor(usize), } /// What an inline rename / context action is acting on. Ported from diff --git a/crates/op-editor-ui/Cargo.toml b/crates/op-editor-ui/Cargo.toml index 1a63dc71..14528766 100644 --- a/crates/op-editor-ui/Cargo.toml +++ b/crates/op-editor-ui/Cargo.toml @@ -25,3 +25,9 @@ jian-ops-schema = { path = "../../vendor/jian/crates/jian-ops-schema" } # i18n string tables + the canonical editor state model. op-i18n = { path = "../op-i18n" } op-editor-core = { path = "../op-editor-core" } + +# Decode `data:image/...;base64,...` URLs in the canvas painter so a +# pasted / picked image lands on the canvas instead of the grey +# placeholder. Workspace pinned via the same `base64` op-host-desktop +# uses for the file picker. +base64 = "0.22" diff --git a/crates/op-editor-ui/src/layout_scene.rs b/crates/op-editor-ui/src/layout_scene.rs index 30cc36ce..083e1c55 100644 --- a/crates/op-editor-ui/src/layout_scene.rs +++ b/crates/op-editor-ui/src/layout_scene.rs @@ -263,6 +263,9 @@ pub struct SceneNode { pub path_anchors: Vec, /// Whether a `Path` node is closed (last anchor links to first). pub path_closed: bool, + /// Preserved SVG path data for imported Path nodes. Coordinates + /// are local doc-px relative to `bounds.origin`. + pub svg_path: Option, /// Ellipse arc start angle in degrees. `None` = full ellipse. pub arc_start_angle: Option, /// Ellipse arc sweep angle in degrees. `None` = full ellipse. @@ -270,6 +273,12 @@ pub struct SceneNode { /// Ellipse donut-hole radius (0.0..=1.0 fraction). `None` / 0 = /// solid. pub arc_inner_radius: Option, + /// Image source for nodes that paint a bitmap (`PenNode::Image`). + /// Carries the canonical schema's `src` field verbatim — usually + /// a `data:image/...;base64,...` URL produced by the host's file + /// picker, or a plain file path / remote URL on documents that + /// reference external media. `None` for non-image nodes. + pub image_src: Option, /// Drop-shadow / effects painted behind the node's fill. pub effects: Vec, /// Whether the node (and its subtree) is hidden — the painter @@ -347,9 +356,11 @@ impl SceneNode { points: Vec::new(), path_anchors: Vec::new(), path_closed: false, + svg_path: None, arc_start_angle: None, arc_sweep_angle: None, arc_inner_radius: None, + image_src: None, effects: Vec::new(), hidden: false, locked: false, diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs index 1c7a8aee..03733dc3 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -270,7 +270,16 @@ pub fn paint_node( } } NodeKind::Rect => { - paint_fill_then_stroke(cx, node, world_rect, zoom, node.fill); + // Image nodes land as `kind="rect"` (the loader rewrites + // their variant so non-image paths keep working). When a + // `src` is carried, paint the bitmap; the grey `fill` + // remains as the placeholder visible while the decoder + // is missing the bytes (corrupt URL / unsupported codec). + if let Some(src) = node.image_src.as_deref() { + paint_image_node(cx, node, world_rect, zoom, src); + } else { + paint_fill_then_stroke(cx, node, world_rect, zoom, node.fill); + } } NodeKind::Ellipse => { paint_ellipse(cx, node, world_rect, zoom); @@ -313,6 +322,13 @@ pub fn paint_node( cx.backend.stroke_line(from, to, color, width); } NodeKind::Path => { + if let Some(d) = node.svg_path.as_deref() { + paint_svg_path_node(cx, node, world_rect, zoom, d); + if rotated { + cx.backend.restore(); + } + return; + } let to_world = |p: Point2D| -> Point2D { Point2D::new( viewport_origin.x + p.x * zoom, @@ -358,8 +374,104 @@ pub fn paint_node( } } +fn paint_svg_path_node( + cx: &mut PaintCx<'_>, + node: &SceneNode, + world_rect: Rect, + zoom: f32, + d: &str, +) { + if let Some(fill) = node.fill { + cx.backend + .fill_svg_path(d, world_rect.origin, zoom, 1.0, fill); + } + if let Some(stroke) = node.stroke { + cx.backend.stroke_svg_path( + d, + world_rect.origin, + 24.0 * zoom, + stroke.color, + stroke.width * zoom, + ); + } +} + /// Paint a Text `SceneNode` — wrapped or single-line text plus the /// edit caret when the node is the one being edited. +/// Decode an inline-base64 `data:image/...;base64,...` URL into the +/// raw image bytes the backend's `draw_image` decoder expects. Returns +/// `None` for any URL that isn't an inline base64 payload (file paths, +/// remote URLs, malformed strings) — those paths are deferred to a +/// future loader. +fn data_url_bytes(src: &str) -> Option> { + let after_scheme = src.strip_prefix("data:")?; + let comma = after_scheme.find(',')?; + let meta = &after_scheme[..comma]; + let payload = &after_scheme[comma + 1..]; + if !meta.contains(";base64") { + return None; + } + // The base64 alphabet is ASCII; strip any embedded whitespace + // (line breaks in a wrapped data URL) before decode. + let clean: String = payload + .chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect(); + use base64::engine::general_purpose::STANDARD as B64; + use base64::Engine as _; + B64.decode(clean.as_bytes()).ok() +} + +/// Hash a string into a stable u64 — drives the backend's image +/// decode cache so the same `src` doesn't re-decode every frame. +fn src_hash(src: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + src.hash(&mut h); + h.finish() +} + +/// Paint a raster image inside `world_rect`. Decodes the data URL +/// once, hands raw bytes + a stable id to the backend cache, then +/// strokes the corner-radius outline on top so a per-corner radius +/// authored on the schema still reads. +fn paint_image_node( + cx: &mut PaintCx<'_>, + node: &SceneNode, + world_rect: Rect, + zoom: f32, + src: &str, +) { + let bytes = data_url_bytes(src); + let r = node.corner_radius * zoom; + let use_round = r > 0.5; + // Only paint the grey placeholder when the URL can't be decoded + // — painting it under a transparent raster would leave a grey + // matte bleeding through the alpha channel. + if bytes.is_none() { + if let Some(fill) = node.fill { + if use_round { + cx.backend.fill_round_rect(world_rect, r, fill); + } else { + cx.backend.fill_rect(world_rect, fill); + } + } + } + if let Some(bytes) = bytes { + let id = src_hash(src); + cx.backend.draw_image(world_rect, id, &bytes); + } + if let Some(stroke) = node.stroke { + let width = stroke.width * zoom; + if use_round { + cx.backend + .stroke_round_rect(world_rect, r, stroke.color, width); + } else { + cx.backend.stroke_rect(world_rect, stroke.color, width); + } + } +} + fn paint_text_node( cx: &mut PaintCx<'_>, node: &SceneNode, diff --git a/crates/op-editor-ui/src/widgets/color_picker.rs b/crates/op-editor-ui/src/widgets/color_picker.rs index 37e957fe..6e99e4e4 100644 --- a/crates/op-editor-ui/src/widgets/color_picker.rs +++ b/crates/op-editor-ui/src/widgets/color_picker.rs @@ -158,6 +158,7 @@ fn paint_picker(cx: &mut PaintCx<'_>, theme: &Theme, state: &ColorPickerState, p ColorTarget::Fill => "Fill", ColorTarget::Stroke => "Stroke", ColorTarget::GradientStop(_) => "Gradient Stop", + ColorTarget::EffectColor(_) => "Effect Color", }; let title_layout = TextLayout::single_run( title, @@ -505,5 +506,6 @@ pub fn target_label(t: ColorTarget) -> &'static str { ColorTarget::Fill => "Fill", ColorTarget::Stroke => "Stroke", ColorTarget::GradientStop(_) => "Gradient Stop", + ColorTarget::EffectColor(_) => "Effect Color", } } diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index 5ff4f2cc..9276b3c7 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -162,6 +162,11 @@ pub struct EffectSummary { pub offset_y: f32, pub blur: f32, pub spread: f32, + /// Effect colour — Shadow carries an authored hex string; the + /// blur kinds don't have a colour field, so paint reads + /// `Color::TRANSPARENT` (and the colour row is hidden by the + /// effects-section painter when alpha is zero). + pub color: Color, } impl EffectSummary { @@ -188,6 +193,12 @@ impl EffectSummary { offset_y: s.offset_y, blur: s.blur, spread: s.spread, + color: color_from_hex(&s.color).unwrap_or(Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.25, + }), }, PenEffect::Blur(b) => EffectSummary { kind: EffectKind::Blur, @@ -195,6 +206,7 @@ impl EffectSummary { offset_y: 0.0, blur: b.radius, spread: 0.0, + color: Color::TRANSPARENT, }, PenEffect::BackgroundBlur(b) => EffectSummary { kind: EffectKind::BackgroundBlur, @@ -202,6 +214,7 @@ impl EffectSummary { offset_y: 0.0, blur: b.radius, spread: 0.0, + color: Color::TRANSPARENT, }, } } @@ -307,9 +320,7 @@ impl NodeSnapshot { /// fills — the Fill section uses that to hide the angle row. fn gradient_angle_of(node: &PenNode) -> Option { use jian_ops_schema::style::PenFill; - match op_editor_core::fills::node_fills(node) - .and_then(|f| f.first())? - { + match op_editor_core::fills::node_fills(node).and_then(|f| f.first())? { PenFill::LinearGradient(body) => Some(body.angle.unwrap_or(0.0)), _ => None, } diff --git a/crates/op-editor-ui/src/widgets/property_panel_action.rs b/crates/op-editor-ui/src/widgets/property_panel_action.rs index d4c58b6e..3c0ae807 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_action.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_action.rs @@ -64,4 +64,12 @@ pub enum PropertyPanelAction { field: op_editor_core::EffectField, value: f32, }, + /// User clicked the colour swatch on a Shadow effect's colour + /// row — host opens the HSV picker bound to + /// `effect[index].color`. + OpenEffectColorPicker(usize), + /// User clicked the `图片` fill body row — host opens an image + /// file picker (rfd) and writes the chosen file into the selected + /// node's primary fill as `PenFill::Image { url: }`. + PickFillImage, } diff --git a/crates/op-editor-ui/src/widgets/property_panel_effects.rs b/crates/op-editor-ui/src/widgets/property_panel_effects.rs index ef6fcca9..6539c239 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_effects.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_effects.rs @@ -1,25 +1,28 @@ //! Effects-section paint helpers for [`crate::widgets::PropertyPanel`]. -//! Split out of `property_panel_sections.rs` to honor the 800-line -//! file ceiling. Each effect block paints a type row plus one -//! parameter row per editable scalar field. +//! +//! Card-style layout (one card per effect): title row with the +//! `投影` label + remove `—` glyph, a 2-column grid of editable +//! parameter inputs, and an optional colour row (`颜色` label + +//! swatch + `rgba(...)` text). Geometry is shared with +//! `property_panel_layout` so paint + hit-test never drift. use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::property_panel::EffectSummary; use crate::widgets::property_panel_inputs::{ - paint_section_divider, paint_section_label_with_add, to_jian_color, INPUT_HEIGHT, INPUT_RADIUS, - PAD_X, SECTION_GAP, + paint_section_divider, paint_section_label_with_add, to_jian_color, INPUT_RADIUS, PAD_X, + SECTION_GAP, }; use crate::widgets::property_panel_layout::{ - effect_param_fields, effect_param_value_rect, EFFECT_PARAM_ROW_HEIGHT, EFFECT_ROW_HEIGHT, + effect_block_height, effect_color_rect, effect_has_color_row, effect_param_fields, + effect_param_rect, effect_param_row_count, EFFECT_CARD_GAP, EFFECT_CARD_PAD, + EFFECT_TITLE_ROW_HEIGHT, }; use crate::widgets::property_panel_sections::{EditContext, PropertyLabels}; use crate::widgets::PaintCx; -use crate::{Point2D, Rect, TextLayout}; +use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::editor_ui_state::EffectParamFocus; -// ── Effects section ─────────────────────────────────────────────── - // Paint-context + geometry args threaded through; a struct adds no gain. #[allow(clippy::too_many_arguments)] pub fn paint_effects_section( @@ -38,42 +41,98 @@ pub fn paint_effects_section( row_y += 8.0; } else { for (ei, eff) in effects.iter().enumerate() { - paint_effect_row(cx, theme, eff, x, row_y, width); - row_y += EFFECT_ROW_HEIGHT; - for &(field, label) in effect_param_fields(eff.kind) { - let focused = effect_focus == Some(EffectParamFocus { effect: ei, field }); - let caret = if focused && edit.caret_blink_on() { - Some(edit.caret.min(edit.draft.len())) - } else { - None - }; - paint_effect_param_row( - cx, - theme, - label, - eff.param_value(field), - focused, - edit.draft, - caret, - x, - row_y, - width, - ); - row_y += EFFECT_PARAM_ROW_HEIGHT; - } + paint_effect_card(cx, theme, eff, ei, edit, effect_focus, x, row_y, width); + row_y += effect_block_height(eff.kind) + EFFECT_CARD_GAP; } } paint_section_divider(cx, theme, x, row_y, width); row_y + SECTION_GAP } -/// Paint one effect-parameter row: `