Merge branch 'v0.8.0-new' of github.com:ZSeven-W/openpencil into v0.8.0-new

This commit is contained in:
Fini 2026-05-24 00:39:09 +08:00
commit 845e5ae7b5
40 changed files with 2556 additions and 289 deletions

1
Cargo.lock generated
View file

@ -2572,6 +2572,7 @@ name = "op-editor-ui"
version = "0.1.0"
dependencies = [
"accesskit",
"base64",
"bitflags 2.11.1",
"glam",
"jian-core",

View file

@ -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(&current_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(&current_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(&current_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<ColorPickerDrag>) {
@ -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,6 +299,30 @@ fn scalar_as_hex(s: &jian_ops_schema::variable::VariableScalar) -> Option<String
}
}
/// Re-attach an alpha (0..=1) to a `#RRGGBB` hex. When the alpha
/// would round to fully opaque the 6-char form is preserved so the
/// canonical schema stays compact.
fn splice_alpha(hex: &str, alpha: f32) -> 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<String> {
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

View file

@ -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.

View file

@ -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),

View file

@ -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,
}
}
}

View file

@ -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: <data-url> }`.
PickFillImage,
OpenRecent(usize),
ClearRecent,
}

View file

@ -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<NodeId> {
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

View file

@ -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;

View file

@ -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 `<g>` 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<String>,
stroke: Option<String>,
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
/// `<g>` case the old flat `parse_svg_elements` could not handle).
struct SvgTree {
tag: String,
attrs: Vec<(String, String)>,
children: Vec<SvgTree>,
}
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::<f64>().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 `<svg>` attrs + compute a viewBox-aware
// scale so a 24×24 icon doesn't render at 24 px.
// 2. Walk the body recursively so `<g>` children inherit
// style + don't escape into siblings.
// 3. Build a node tree; `<g>` 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<PenNode> = 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 `<svg>` wrapping one `<path>`) 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 `<svg …>` open tag from a document. Returns the
/// body between `<svg …>` and `</svg>` plus the parsed root attrs.
/// `None` when the document lacks a balanced `<svg>` 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("<svg")?;
let after_open = open + 4;
let bytes = svg.as_bytes();
let close_of_open = find_tag_end(bytes, after_open)?;
let body_start = close_of_open + 1;
// Closing tag is the last `</svg>` in the document.
let close_marker = lower.rfind("</svg>")?;
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<f64> = vb
.split(|c: char| c.is_whitespace() || c == ',')
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<f64>().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<f64> {
// 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::<f64>().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::<f64>().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
/// `</tag>` so `<g>` children land under the right parent. Skip
/// tags (`defs` / `style` / …) are filtered out before recursion.
fn parse_svg_tree(body: &str) -> Vec<SvgTree> {
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("<!--") {
match body[i..].find("-->") {
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 `<defs>` 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 `</tag>` that closes the given open tag at
/// `from`. Handles nested same-name tags so a `<g>` inside a `<g>`
/// 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<usize> {
let bytes = body.as_bytes();
let mut depth = 1usize;
let mut i = from;
let open_pat = format!("<{tag}");
let close_pat = format!("</{tag}");
while i < bytes.len() {
let rest = &body[i..];
let lower = rest.to_ascii_lowercase();
let next_open = lower.find(&open_pat);
let next_close = lower.find(&close_pat);
let (idx, is_close) = match (next_open, next_close) {
(None, None) => 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
// `<rectangle>` doesn't false-match a `<rect>` 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 `</tag>` 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<usize> {
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. `<g>` 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<NodeId>,
) -> Option<PenNode> {
let ctx = merge_style_ctx(parent_ctx, &el.attrs);
if el.tag == "g" || el.tag == "svg" {
let mut kids: Vec<PenNode> = 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 `<path>` we tokenise the `d` string + scale
// every coord — TS parity with `scaleSvgPath`.
let mut scaled = legacy;
apply_scale_to_attrs(&mut scaled, scale);
// Preserve `<path d>` 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::<f64>().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<String> {
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<String> {
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<PenStroke> {
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 `<path>` 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::<f64>() {
*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::<f64>() 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 `<polyline>` /
/// `<polygon>`. 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::<Vec<_>>()
.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<Pe
path_node_from_anchors(id, "Path", &pts, el.tag == "polygon")?
}
"path" => {
// 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<String>,
stroke: Option<PenStroke>,
) -> Option<PenNode> {
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 `<tag …>` / `<tag … />` element in `svg`.
/// Comments, the XML prolog, closing tags and DOCTYPE are skipped;
/// nesting is ignored, so a shape inside a `<g>` 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<SvgElement> {
let bytes = svg.as_bytes();
let mut out = Vec::new();
@ -411,12 +1132,16 @@ fn scan_numbers(s: &str) -> Vec<f64> {
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<PenPathAnchor>, 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<PenPathAnchor>, bool)> {
let tokens = tokenize_path(d);
let (ox, oy) = offset;
let mut subpaths: Vec<(Vec<PenPathAnchor>, bool)> = Vec::new();
let mut anchors: Vec<PenPathAnchor> = 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<PenPathAnchor>, 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<PenPathAnchor>, 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.

View file

@ -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##"<svg xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="20" width="100" height="40" fill="#ff0000"/>
<circle cx="50" cy="50" r="25"/>
@ -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#"<svg><path d="M0 0 L100 0 C100 50 50 100 0 100 Z"/></svg>"#;
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().is_none_or(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#"<svg><path d="M0 0 L10 0 M50 50 L60 50"/></svg>"#;
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#"<svg><path d="M0 0 C0 100 100 100 100 0"/></svg>"#;
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#"<svg><path d="m10 10 l5 5"/></svg>"#;
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 <g> wrapper + no shapes.
assert_eq!(s.import_svg(&mut next, "<svg><g></g></svg>", (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#"<svg>
<rect x="0" y="0" width="0" height="40"/>
<circle cx="5" cy="5" r="0"/>
@ -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##"<svg viewBox="0 0 1024 1024"><rect x="0" y="0" width="1024" height="1024" fill="#000"/></svg>"##;
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##"<svg>
<rect x="0" y="0" width="10" height="10"/>
<rect x="20" y="0" width="10" height="10"/>
</svg>"##;
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() {
// `<g fill="#0000ff">` with a `<path>` that has no `fill` of its
// own — the path must paint blue, mirroring `mergeStyleCtxAttrs`.
let svg = r##"<svg>
<g fill="#0000ff">
<path d="M0 0 L10 0 L10 10 Z"/>
</g>
</svg>"##;
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#"<svg viewBox="0 0 100 100" width="100" height="100">
<path d="M10 50 A40 40 0 0 1 90 50 Z"/>
</svg>"#;
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().is_none_or(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#"<svg viewBox="0 0 20 20" width="20" height="20">
<path d="M0 0 H20 V20 H0 Z M5 5 H15 V15 H5 Z"/>
</svg>"#;
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}"
);
}

View file

@ -0,0 +1,459 @@
//! 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<Vec<Segment>> {
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<SvgPathBounds> {
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), points: [(f64, f64); 4]) {
let [(p0x, p0y), (p1x, p1y), (p2x, p2y), (p3x, p3y)] = points;
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<f64> {
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<PathToken> {
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::<f64>() {
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);
}
}

View file

@ -90,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

View file

@ -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"

View file

@ -263,6 +263,9 @@ pub struct SceneNode {
pub path_anchors: Vec<SceneAnchor>,
/// 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<String>,
/// Ellipse arc start angle in degrees. `None` = full ellipse.
pub arc_start_angle: Option<f32>,
/// 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<f32>,
/// 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<String>,
/// Drop-shadow / effects painted behind the node's fill.
pub effects: Vec<Effect>,
/// 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,

View file

@ -253,7 +253,16 @@ pub fn paint_node(
match &node.kind {
NodeKind::Frame => {
paint_fill_then_stroke(cx, node, world_rect, zoom, node.fill);
// Image-fill Frames paint the bitmap behind their
// children; gradient + solid fall back to the shared
// fill/stroke painter. Without this branch a Frame whose
// primary fill is `PenFill::Image { url }` only shows the
// grey placeholder + its children, never the image.
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);
}
for child in &node.children {
paint_node(cx, child, viewport_origin, zoom, edit_caret.clone(), cull);
}
@ -270,10 +279,31 @@ 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);
if let Some(src) = node.image_src.as_deref() {
// Image-fill ellipse: paint the bitmap clipped to the
// ellipse silhouette via skia's `clip_oval`-style
// approximation (no native clip_oval on the trait, so
// fall back to the rect-clip path the painter has).
paint_image_node(cx, node, world_rect, zoom, src);
if let Some(stroke) = node.stroke {
cx.backend
.stroke_oval(world_rect, stroke.color, stroke.width * zoom);
}
} else {
paint_ellipse(cx, node, world_rect, zoom);
}
}
NodeKind::Polygon => {
// Default triangle: top-centre, bottom-left, bottom-right.
@ -287,7 +317,13 @@ pub fn paint_node(
Point2D::new(left_x, bottom_y),
Point2D::new(right_x, bottom_y),
];
if let Some(fill) = node.fill {
// Image fills paint the bitmap in the AABB underneath the
// polygon outline; the triangle silhouette is then drawn
// by the stroke. A perfect clip-to-polygon path lands when
// `RenderBackend` grows a polygon-clip primitive.
if let Some(src) = node.image_src.as_deref() {
paint_image_node(cx, node, world_rect, zoom, src);
} else if let Some(fill) = node.fill {
cx.backend.fill_polygon(&pts, fill);
}
if let Some(stroke) = node.stroke {
@ -313,6 +349,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 +401,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<Vec<u8>> {
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,

View file

@ -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",
}
}

View file

@ -57,9 +57,9 @@ impl FigmaImportModal {
if rect_contains(close_rect(panel), point) {
return FigmaImportHit::Close;
}
// Drop zone is paint-only until the `.fig` parser lands —
// returning `DropZone` would surface a stub picker that the
// backend can't actually consume.
if rect_contains(drop_zone_rect(panel), point) {
return FigmaImportHit::DropZone;
}
FigmaImportHit::Inside
}
}
@ -92,14 +92,10 @@ fn rect_contains(r: Rect, p: Point2D) -> bool {
}
fn t(locale: Locale, key: &str) -> &'static str {
// Drop-zone copy is intentionally non-actionable: the .fig parser
// isn't wired yet, so promising "drop a file here / or click to
// browse" would lie. The "drop" key resolves to a "not yet wired"
// line so the affordance reads as informational.
match key {
"title" => op_i18n::translate(locale, "figma.title"),
"drop" => op_i18n::translate(locale, "figma.importNotWired"),
"browse" => op_i18n::translate(locale, "figma.comingSoon"),
"drop" => op_i18n::translate(locale, "figma.dropFile"),
"browse" => op_i18n::translate(locale, "figma.orBrowse"),
"footer" => op_i18n::translate(locale, "figma.exportTip"),
_ => "",
}
@ -225,3 +221,21 @@ fn to_jian(c: Color) -> jian_core::scene::Color {
}
jian_core::scene::Color::rgba(ch(c.r), ch(c.g), ch(c.b), ch(c.a))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clicking_drop_zone_requests_figma_import() {
let modal = FigmaImportModal::for_editor(&EditorState::new());
let panel = modal.rect(800.0, 600.0);
let drop = drop_zone_rect(panel);
let point = Point2D::new(
drop.origin.x + drop.size.x / 2.0,
drop.origin.y + drop.size.y / 2.0,
);
assert_eq!(modal.hit_test(panel, point), FigmaImportHit::DropZone);
}
}

View file

@ -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,
},
}
}

View file

@ -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: <data-url> }`.
PickFillImage,
}

View file

@ -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,99 @@ 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: `<label> [value] [] [+]`. The
/// value box is click-to-type; when `focused` it shows the live
/// `draft` + caret instead of the committed `value`. The ""/"+"
/// stepper rects must match `action_button_rects`'s `AdjustEffectParam`
/// rects exactly so paint + hit-test agree.
#[allow(clippy::too_many_arguments)]
fn paint_effect_param_row(
fn paint_effect_card(
cx: &mut PaintCx<'_>,
theme: &Theme,
eff: &EffectSummary,
effect_index: usize,
edit: &EditContext<'_>,
effect_focus: Option<EffectParamFocus>,
x: f32,
y: f32,
width: f32,
) {
let card_x = x + PAD_X;
let card_w = width - PAD_X * 2.0;
let card_rect = Rect {
origin: Point2D::new(card_x, y),
size: Point2D::new(card_w, effect_block_height(eff.kind)),
};
cx.backend
.fill_round_rect(card_rect, INPUT_RADIUS, theme.muted);
cx.backend
.stroke_round_rect(card_rect, INPUT_RADIUS, theme.border, 1.0);
// Title row — effect-kind label on the left, remove `—` on the right.
let title = TextLayout::single_run(
eff.kind.label(),
"system-ui",
12.0,
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&title,
Point2D::new(card_x + EFFECT_CARD_PAD + 4.0, y + 18.0),
);
draw_icon(
cx.backend,
Icon::Minus,
Point2D::new(
card_x + card_w - EFFECT_CARD_PAD - 16.0,
y + (EFFECT_TITLE_ROW_HEIGHT - 14.0) / 2.0,
),
14.0,
theme.muted_foreground,
1.4,
);
// Parameter grid.
let card_inner_y = y + EFFECT_CARD_PAD;
for (i, &(field, label)) in effect_param_fields(eff.kind).iter().enumerate() {
let col = i % 2;
let row = i / 2;
let rect = effect_param_rect(card_x, card_inner_y, card_w, col, row);
let focused = effect_focus
== Some(EffectParamFocus {
effect: effect_index,
field,
});
let caret = if focused && edit.caret_blink_on() {
Some(edit.caret.min(edit.draft.len()))
} else {
None
};
paint_param_input(
cx,
theme,
label,
eff.param_value(field),
focused,
edit.draft,
caret,
rect,
);
}
// Colour row (Shadow only).
if effect_has_color_row(eff.kind) {
let row_count = effect_param_row_count(eff.kind);
let cr = effect_color_rect(card_x, card_inner_y, card_w, row_count);
paint_effect_color_row(cx, theme, eff.color, cr);
}
}
#[allow(clippy::too_many_arguments)]
fn paint_param_input(
cx: &mut PaintCx<'_>,
theme: &Theme,
label: &str,
@ -81,30 +141,39 @@ fn paint_effect_param_row(
focused: bool,
draft: &str,
caret: Option<usize>,
x: f32,
y: f32,
width: f32,
rect: Rect,
) {
cx.backend
.fill_round_rect(rect, INPUT_RADIUS, theme.background);
if focused {
cx.backend
.stroke_round_rect(rect, INPUT_RADIUS, theme.primary, 1.5);
} else {
cx.backend
.stroke_round_rect(rect, INPUT_RADIUS, theme.border, 1.0);
}
// Label sits on the left at the muted-foreground colour; the
// editable value follows it left-aligned (matches Figma + the
// image-spec'd "X 4" pattern).
let label_layout = TextLayout::single_run(
label,
"system-ui",
11.0,
12.0,
to_jian_color(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
let label_x = rect.origin.x + 10.0;
let baseline_y = rect.origin.y + 19.0;
cx.backend
.draw_text(&label_layout, Point2D::new(x + PAD_X + 4.0, y + 15.0));
// Editable value box — shows the live draft while focused.
let box_rect = effect_param_value_rect(x, y, width);
cx.backend
.fill_round_rect(box_rect, INPUT_RADIUS, theme.muted);
if focused {
cx.backend
.stroke_round_rect(box_rect, INPUT_RADIUS, theme.primary, 1.5);
}
let value_owned = format!("{value:.0}");
let text = if focused { draft } else { value_owned.as_str() };
let text_x = box_rect.origin.x + 10.0;
.draw_text(&label_layout, Point2D::new(label_x, baseline_y));
let label_w = cx.backend.measure_text(label, 12.0);
let value_text_owned = format!("{value:.0}");
let text: &str = if focused {
draft
} else {
value_text_owned.as_str()
};
let value_x = label_x + label_w + 8.0;
let value_layout = TextLayout::single_run(
text,
"system-ui",
@ -112,65 +181,61 @@ fn paint_effect_param_row(
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&value_layout,
Point2D::new(text_x, box_rect.origin.y + 16.0),
);
cx.backend
.draw_text(&value_layout, Point2D::new(value_x, baseline_y));
if let Some(pos) = caret {
let caret_w = cx.backend.measure_text(&text[..pos.min(text.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(text_x + caret_w, box_rect.origin.y + 4.0),
size: Point2D::new(1.5, box_rect.size.y - 8.0),
origin: Point2D::new(value_x + caret_w, rect.origin.y + 6.0),
size: Point2D::new(1.5, rect.size.y - 12.0),
},
theme.foreground,
);
}
// "" then "+" — geometry mirrors the `AdjustEffectParam` rects.
for (icon, off) in [(Icon::Minus, 48.0_f32), (Icon::Plus, 22.0_f32)] {
let r = Rect {
origin: Point2D::new(x + width - PAD_X - off, y + 3.0),
size: Point2D::new(22.0, INPUT_HEIGHT - 6.0),
};
cx.backend.fill_round_rect(r, 6.0, theme.muted);
draw_icon(
cx.backend,
icon,
Point2D::new(r.origin.x + 5.0, r.origin.y + (r.size.y - 12.0) / 2.0),
12.0,
theme.foreground,
1.4,
);
}
}
/// Paint one effect row — the effect-type label on the left + a
/// right-aligned "✕" remove glyph. The "✕" hit rect is emitted by
/// `action_button_rects` as `RemoveEffect(index)`, so the glyph
/// position here must match that rect.
fn paint_effect_row(
cx: &mut PaintCx<'_>,
theme: &Theme,
eff: &EffectSummary,
x: f32,
y: f32,
width: f32,
) {
let label = TextLayout::single_run(
eff.kind.label(),
fn paint_effect_color_row(cx: &mut PaintCx<'_>, theme: &Theme, color: Color, rect: Rect) {
cx.backend
.fill_round_rect(rect, INPUT_RADIUS, theme.background);
cx.backend
.stroke_round_rect(rect, INPUT_RADIUS, theme.border, 1.0);
let label_layout = TextLayout::single_run(
"颜色",
"system-ui",
12.0,
to_jian_color(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
let label_x = rect.origin.x + 10.0;
let baseline_y = rect.origin.y + 19.0;
cx.backend
.draw_text(&label_layout, Point2D::new(label_x, baseline_y));
// Colour swatch — same alpha-checker treatment as gradient stops
// so a translucent shadow colour reads correctly.
let swatch = Rect {
origin: Point2D::new(rect.origin.x + 38.0, rect.origin.y + 7.0),
size: Point2D::new(16.0, 16.0),
};
super::property_panel_fill::paint_alpha_checker_public(cx, swatch, 3.0);
cx.backend.fill_round_rect(swatch, 3.0, color);
// `rgba(r,g,b,a)` text after the swatch — matches the spec.
let text = format!(
"rgba({},{},{},{:.2})",
(color.r.clamp(0.0, 1.0) * 255.0).round() as u8,
(color.g.clamp(0.0, 1.0) * 255.0).round() as u8,
(color.b.clamp(0.0, 1.0) * 255.0).round() as u8,
color.a.clamp(0.0, 1.0)
);
let value_layout = TextLayout::single_run(
&text,
"system-ui",
12.0,
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&label, Point2D::new(x + PAD_X, y + 15.0));
draw_icon(
cx.backend,
Icon::Close,
Point2D::new(x + width - PAD_X - 14.0, y + 3.0),
14.0,
theme.muted_foreground,
1.4,
cx.backend.draw_text(
&value_layout,
Point2D::new(rect.origin.x + 62.0, baseline_y),
);
}

View file

@ -620,6 +620,13 @@ pub fn stop_hex_rgb_only(hex: &str) -> String {
format!("#{}", body.to_uppercase())
}
/// Public re-export so sibling widget modules (e.g. the effects
/// section's colour row) can reuse the same alpha-aware swatch
/// background without duplicating the geometry.
pub fn paint_alpha_checker_public(cx: &mut PaintCx<'_>, rect: Rect, radius: f32) {
paint_alpha_checker(cx, rect, radius);
}
/// Paint a 2×2 light/dark checker behind a colour swatch so a
/// partially or fully transparent fill reads as "transparent"
/// instead of looking like the input pill's background. The

View file

@ -154,12 +154,24 @@ impl VisibleSections {
};
}
/// Height (px) of an effect's header row — the type label + "✕".
pub const EFFECT_ROW_HEIGHT: f32 = INPUT_HEIGHT + 4.0;
/// Height (px) of an effect card's title row — `投影` label on
/// the left + remove `—` icon on the right.
pub const EFFECT_TITLE_ROW_HEIGHT: f32 = INPUT_HEIGHT;
/// Height (px) of one effect-parameter row — label + value + the
/// "" / "+" steppers.
pub const EFFECT_PARAM_ROW_HEIGHT: f32 = INPUT_HEIGHT + 2.0;
/// Height (px) of one effect-parameter input row inside the
/// 2-column grid. Sits below the title row.
pub const EFFECT_PARAM_ROW_HEIGHT: f32 = INPUT_HEIGHT + 4.0;
/// Vertical padding above + below the card body inside the
/// outlined card.
pub const EFFECT_CARD_PAD: f32 = 6.0;
/// Gap between stacked effect cards in the section.
pub const EFFECT_CARD_GAP: f32 = 6.0;
/// Height (px) of an effect's header row. Kept under the legacy
/// name so existing tests / hit-test callers compile.
pub const EFFECT_ROW_HEIGHT: f32 = EFFECT_TITLE_ROW_HEIGHT;
/// Doc-px a single "" / "+" stepper click moves an effect parameter.
pub const EFFECT_PARAM_STEP: f32 = 1.0;
@ -179,21 +191,65 @@ pub fn effect_param_fields(kind: EffectKind) -> &'static [(EffectField, &'static
}
}
/// On-screen rect of an effect-parameter's editable value box — the
/// click target that focuses it for keyboard entry. Shared by the
/// action-rect walker (hit-test) and `paint_effect_param_row`
/// (paint) so the two never drift. `y` is the param row's top.
pub fn effect_param_value_rect(x: f32, y: f32, width: f32) -> Rect {
/// On-screen rect of one effect-parameter input box inside the
/// card grid. Cards lay parameters out in a 2-column grid below
/// the title row; `col` is 0 (left) or 1 (right), `row` is 0-based
/// from the first param row. `card_x` / `card_w` are the card's
/// outer geometry.
pub fn effect_param_rect(card_x: f32, card_y: f32, card_w: f32, col: usize, row: usize) -> Rect {
let inner_x = card_x + EFFECT_CARD_PAD;
let inner_w = card_w - EFFECT_CARD_PAD * 2.0;
let col_w = (inner_w - 6.0) / 2.0;
let col_x = inner_x + col as f32 * (col_w + 6.0);
let row_y = card_y + EFFECT_TITLE_ROW_HEIGHT + row as f32 * EFFECT_PARAM_ROW_HEIGHT;
Rect {
origin: Point2D::new(x + width - PAD_X - 104.0, y + 3.0),
size: Point2D::new(52.0, INPUT_HEIGHT - 6.0),
origin: Point2D::new(col_x, row_y),
size: Point2D::new(col_w, INPUT_HEIGHT),
}
}
/// Total height one effect block consumes — its header row plus one
/// row per editable parameter.
/// Colour row rect inside an effect card — full-width pill that
/// paints the colour swatch + `rgba(...)` text and acts as the
/// click target for the gradient-stop-style colour picker.
pub fn effect_color_rect(card_x: f32, card_y: f32, card_w: f32, param_rows: usize) -> Rect {
let inner_x = card_x + EFFECT_CARD_PAD;
let inner_w = card_w - EFFECT_CARD_PAD * 2.0;
let row_y = card_y + EFFECT_TITLE_ROW_HEIGHT + param_rows as f32 * EFFECT_PARAM_ROW_HEIGHT;
Rect {
origin: Point2D::new(inner_x, row_y),
size: Point2D::new(inner_w, INPUT_HEIGHT),
}
}
/// Legacy single-input rect — preserved for callers that still
/// reach for the old layout (effects-section tests). Returns the
/// left column of the new grid layout.
pub fn effect_param_value_rect(x: f32, y: f32, width: f32) -> Rect {
effect_param_rect(x, y - EFFECT_TITLE_ROW_HEIGHT, width, 0, 0)
}
/// Number of 2-column grid rows the card's editable params occupy:
/// `ceil(field_count / 2)`. Shadow has 4 → 2 rows; Blur/BG-Blur
/// have 1 → 1 row.
pub fn effect_param_row_count(kind: EffectKind) -> usize {
effect_param_fields(kind).len().div_ceil(2)
}
/// Whether the card has a colour row (only Shadow does today).
pub fn effect_has_color_row(kind: EffectKind) -> bool {
matches!(kind, EffectKind::Shadow)
}
/// Total height one effect card consumes — title + param grid +
/// optional colour row + top/bottom card padding.
pub fn effect_block_height(kind: EffectKind) -> f32 {
EFFECT_ROW_HEIGHT + effect_param_fields(kind).len() as f32 * EFFECT_PARAM_ROW_HEIGHT
let rows = effect_param_row_count(kind) as f32 * EFFECT_PARAM_ROW_HEIGHT;
let colour = if effect_has_color_row(kind) {
EFFECT_PARAM_ROW_HEIGHT
} else {
0.0
};
EFFECT_TITLE_ROW_HEIGHT + rows + colour + EFFECT_CARD_PAD * 2.0
}
/// Total vertical space the Effects section consumes: the section
@ -434,6 +490,18 @@ pub fn action_button_rects_with_fill_picker(
},
));
}
if visible.fill_type == FillType::Image {
// Whole image-fill row opens the file picker — there's no
// other affordance inside it.
let usable_w = w - PAD_X * 2.0;
out.push((
PropertyPanelAction::PickFillImage,
Rect {
origin: Point2D::new(x0 + PAD_X, y),
size: Point2D::new(usable_w, INPUT_HEIGHT),
},
));
}
// Gradient stop swatches — each row's 16 px swatch opens the
// picker on that specific stop, matching the solid fill
// affordance.
@ -488,52 +556,51 @@ pub fn action_button_rects_with_fill_picker(
out.push((PropertyPanelAction::AddEffect, plus));
y += SECTION_HEADER_HEIGHT;
for (ei, eff) in effects.iter().enumerate() {
// "✕" on the effect's header row → RemoveEffect.
// Card outer rect — used to anchor the title-row remove
// glyph (top-right) and the 2-column param grid below.
let card_x = x0 + PAD_X;
let card_w = w - PAD_X * 2.0;
// Remove (`—`) glyph in the title row's right corner.
out.push((
PropertyPanelAction::RemoveEffect(ei),
Rect {
origin: Point2D::new(x0 + w - PAD_X - 20.0, y + 2.0),
size: Point2D::new(20.0, INPUT_HEIGHT),
origin: Point2D::new(card_x + card_w - EFFECT_CARD_PAD - 18.0, y + 4.0),
size: Point2D::new(20.0, INPUT_HEIGHT - 4.0),
},
));
// One ""/"+" stepper pair per editable parameter row.
let mut py = y + EFFECT_ROW_HEIGHT;
for &(field, _) in effect_param_fields(eff.kind) {
// 2-column param grid — each cell is a focusable input.
let card_inner_y = y + EFFECT_CARD_PAD;
for (i, &(field, _)) in effect_param_fields(eff.kind).iter().enumerate() {
let col = i % 2;
let row = i / 2;
let cur = eff.param_value(field);
out.push((
PropertyPanelAction::AdjustEffectParam {
effect: ei,
field,
new_value: cur - EFFECT_PARAM_STEP,
},
Rect {
origin: Point2D::new(x0 + w - PAD_X - 48.0, py + 3.0),
size: Point2D::new(22.0, INPUT_HEIGHT - 6.0),
},
));
out.push((
PropertyPanelAction::AdjustEffectParam {
effect: ei,
field,
new_value: cur + EFFECT_PARAM_STEP,
},
Rect {
origin: Point2D::new(x0 + w - PAD_X - 22.0, py + 3.0),
size: Point2D::new(22.0, INPUT_HEIGHT - 6.0),
},
));
// The value box — click to type a value directly.
out.push((
PropertyPanelAction::FocusEffectParam {
effect: ei,
field,
value: cur,
},
effect_param_value_rect(x0, py, w),
effect_param_rect(card_x, card_inner_y, card_w, col, row),
));
py += EFFECT_PARAM_ROW_HEIGHT;
}
y += effect_block_height(eff.kind);
// Color row — emits the same picker action as a gradient
// stop swatch, but indexed by the effect; see
// `EffectColor` outcome path on the host. Wired only for
// Shadow today (Blur kinds carry no colour).
if effect_has_color_row(eff.kind) {
let row_count = effect_param_row_count(eff.kind);
let cr = effect_color_rect(card_x, card_inner_y, card_w, row_count);
// Swatch sub-rect on the left of the color row — clicks
// open a colour picker scoped to `effect[ei].color`.
out.push((
PropertyPanelAction::OpenEffectColorPicker(ei),
Rect {
origin: Point2D::new(cr.origin.x + 32.0, cr.origin.y),
size: Point2D::new(28.0, cr.size.y),
},
));
}
y += effect_block_height(eff.kind) + EFFECT_CARD_GAP;
}
if effects.is_empty() {
y += 8.0;

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
cargo-bundle merges this file into the generated OpenPencil.app
Info.plist. The app UI is localized in Rust rather than .lproj
resources, so let AppKit use the user's preferred language for
native panels such as NSOpenPanel / NSSavePanel.
-->
<plist version="1.0">
<dict>
<key>CFBundleAllowMixedLocalizations</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh-Hans</string>
<string>zh-Hant</string>
<string>ja</string>
<string>ko</string>
<string>fr</string>
<string>es</string>
<string>de</string>
<string>pt</string>
<string>ru</string>
<string>hi</string>
<string>tr</string>
<string>th</string>
<string>vi</string>
<string>id</string>
</array>
</dict>
</plist>

View file

@ -28,6 +28,7 @@ mod mcp_serve;
mod menu;
mod model_discovery;
mod persistence;
mod persistence_image;
mod settings_io;
mod update_check;
mod window_state;

View file

@ -555,6 +555,14 @@ pub fn run_action(
}
}
}
FileAction::ImportImageOrSvg => {
crate::persistence_image::handle_import_image_or_svg(host);
ActionOutcome::Noop
}
FileAction::PickFillImage => {
crate::persistence_image::handle_pick_fill_image(host);
ActionOutcome::Noop
}
}
}

View file

@ -0,0 +1,142 @@
//! Image / SVG import handlers for [`super::persistence::run_action`].
//!
//! Split out of `persistence.rs` so that file stays under the 800-line
//! cap. Two entry points:
//!
//! - [`handle_import_image_or_svg`] — toolbar shape-picker action:
//! pops `rfd::FileDialog`, decodes the file, and inserts a new
//! Image node centred on the viewport. SVG files land as a raster
//! placeholder for now (a proper SVG-to-path parser is a follow-up).
//! - [`handle_pick_fill_image`] — Fill section "图片" body row:
//! pops the same dialog and writes the chosen image into the
//! selected node's primary fill as
//! `PenFill::Image { url: <data-url> }`.
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use op_host_native::widget_host::WidgetHostNative;
use std::path::Path;
/// File extensions the import dialog accepts.
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg"];
/// Pop a file dialog scoped to image / SVG extensions, returning the
/// chosen path. `None` when the user cancelled.
fn pick_image_path(host: &WidgetHostNative) -> Option<std::path::PathBuf> {
let title = op_i18n::translate(
host.editor_state().editor_ui.locale,
"dialog.pickerOpenTitle",
);
rfd::FileDialog::new()
.set_title(title)
.add_filter("Images / SVG", IMAGE_EXTENSIONS)
.pick_file()
}
/// Read `path` and encode it as a `data:` URL so the canvas painter +
/// renderer can resolve the image without re-reading from disk. SVG
/// files get the `image/svg+xml` MIME, everything else picks from a
/// small extension table — falling back to `application/octet-stream`
/// so an unknown extension still round-trips.
fn read_as_data_url(path: &Path) -> std::io::Result<String> {
let bytes = std::fs::read(path)?;
let mime = mime_for(path);
let encoded = B64.encode(&bytes);
Ok(format!("data:{};base64,{}", mime, encoded))
}
fn mime_for(path: &Path) -> &'static str {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
_ => "application/octet-stream",
}
}
/// Handle the toolbar shape-picker's `Import image or SVG…` action.
///
/// SVG files are parsed into editable path / shape nodes via
/// `EditorState::import_svg` (TS parity with `parseSvgToNodes`);
/// raster formats land as a single `ImageNode` carrying the file as
/// a `data:` URL. On cancel: silent no-op. On read error: log.
pub fn handle_import_image_or_svg(host: &mut WidgetHostNative) {
let Some(path) = pick_image_path(host) else {
return;
};
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
if ext.as_deref() == Some("svg") {
let svg = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
eprintln!("[import-svg] {}: {e}", path.display());
return;
}
};
// Centre roughly at viewport — the SVG's authored coords
// shift by the offset.
let pan_x = host.editor_state().viewport.pan_x as f64;
let pan_y = host.editor_state().viewport.pan_y as f64;
let zoom = (host.editor_state().viewport.zoom as f64).max(0.001);
let centre_x = -pan_x / zoom;
let centre_y = -pan_y / zoom;
let mut next_id = 0u64;
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string);
let count = host.editor_state_mut().import_svg_named(
&mut next_id,
&svg,
(centre_x - 200.0, centre_y - 150.0),
stem.as_deref(),
);
if count == 0 {
eprintln!("[import-svg] {} yielded no nodes", path.display());
}
host.mark_editor_state_dirty();
return;
}
let url = match read_as_data_url(&path) {
Ok(u) => u,
Err(e) => {
eprintln!("[import-image] {}: {e}", path.display());
return;
}
};
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Image")
.to_string();
let _ = host
.editor_state_mut()
.insert_image_node_at_viewport(&name, &url);
host.mark_editor_state_dirty();
}
/// Handle the Fill section's `图片` body click. Writes the picked
/// image into the selected node's first `PenFill` as `Image { url }`.
pub fn handle_pick_fill_image(host: &mut WidgetHostNative) {
let Some(path) = pick_image_path(host) else {
return;
};
let url = match read_as_data_url(&path) {
Ok(u) => u,
Err(e) => {
eprintln!("[fill-image] {}: {e}", path.display());
return;
}
};
let _ = host.editor_state_mut().set_selected_fill_image_url(&url);
host.mark_editor_state_dirty();
}

View file

@ -121,10 +121,19 @@ fn apply_payload(state: &mut EditorState, payload: SettingsPayload) {
if let Some(s) = payload.theme.as_deref() {
eui.theme_mode = str_to_theme(s);
}
if let Some(s) = payload.locale.as_deref() {
if let Some(loc) = str_to_locale(s) {
eui.locale = loc;
}
// Locale precedence: persisted user choice > detected system
// locale > the EditorState default (EnUs). Without this fallback
// a fresh install on a Chinese system would still pop English
// dialogs / chrome until the user manually picked a locale.
if let Some(s) =
payload
.locale
.as_deref()
.and_then(|s| if s.is_empty() { None } else { str_to_locale(s) })
{
eui.locale = s;
} else if let Some(detected) = detect_system_locale() {
eui.locale = detected;
}
if let Some(port) = payload.mcp_port {
eui.agent_settings.mcp_server.port = port.max(1024);
@ -179,6 +188,13 @@ pub fn touch_recent(host: &mut WidgetHostNative, path: &std::path::Path) {
/// Best-effort load. Returns silently on missing file / parse error.
pub fn load(state: &mut EditorState) {
// Seed the locale from the OS BEFORE the settings file is read.
// `apply_payload`'s persisted-locale arm overrides this when a
// saved choice exists; first-run / missing-file lands the
// detected locale instead of leaving the EnUs default.
if let Some(detected) = detect_system_locale() {
state.editor_ui.locale = detected;
}
let Some(path) = settings_path() else { return };
let Ok(bytes) = std::fs::read(&path) else {
return;
@ -189,6 +205,46 @@ pub fn load(state: &mut EditorState) {
apply_payload(state, payload);
}
/// Read the host OS's preferred locale (env-var driven, no extra
/// crate dependency) and map it onto the supported [`Locale`] set.
/// Returns `None` when nothing resolves so the caller can keep its
/// fallback. Order matches POSIX precedence: `LC_ALL` overrides
/// `LANG` which overrides `LC_MESSAGES`.
fn detect_system_locale() -> Option<Locale> {
for var in ["LC_ALL", "LANG", "LC_MESSAGES"] {
let Ok(raw) = std::env::var(var) else {
continue;
};
if let Some(loc) = locale_from_tag(&raw) {
return Some(loc);
}
}
None
}
/// Parse a POSIX / IETF locale tag (`zh_CN.UTF-8`, `zh-CN`,
/// `pt_BR`, `en`, …) onto the supported `Locale` set. Falls back to
/// the language subtag when the full tag is unknown so `pt_BR` still
/// lands `Locale::Pt` rather than rejecting.
fn locale_from_tag(raw: &str) -> Option<Locale> {
let tag = raw.split('.').next().unwrap_or(raw).replace('_', "-");
// Try the full tag first (handles `zh-CN` / `zh-TW`); fall back
// to the language subtag (`zh-CN` → `zh`).
if let Some(loc) = str_to_locale(&tag) {
return Some(loc);
}
// Heuristic: zh-Hans → zh-CN, zh-Hant → zh-TW.
let lower = tag.to_ascii_lowercase();
if lower.starts_with("zh") {
if lower.contains("hant") || lower.contains("tw") || lower.contains("hk") {
return Some(Locale::ZhTw);
}
return Some(Locale::ZhCn);
}
let lang = tag.split('-').next().unwrap_or(&tag);
str_to_locale(lang)
}
/// Best-effort save. Returns silently on IO failure.
pub fn save(state: &EditorState) {
let Some(path) = settings_path() else { return };

View file

@ -602,7 +602,10 @@ impl NativeBackend {
let s = size / viewbox;
let mut matrix = skia_safe::Matrix::new_identity();
matrix.set_scale_translate((s, s), (top_left.x, top_left.y));
let path = path.with_transform(&matrix);
let mut path = path.with_transform(&matrix);
if d.matches(['Z', 'z']).count() > 1 {
path.set_fill_type(skia_safe::PathFillType::EvenOdd);
}
let mut paint = skia_safe::Paint::new(jian_color_to_color4f(color), None);
paint.set_anti_alias(true);
canvas.draw_path(&path, &paint);

View file

@ -121,6 +121,7 @@ impl<'a> RenderBackend for NativeFrameBackend<'a> {
);
}
#[allow(clippy::too_many_arguments)]
fn fill_round_rect_radial_gradient(
&mut self,
rect: Rect,

View file

@ -248,9 +248,15 @@ impl WidgetHostNative {
self.editor_state.editor_ui.shape_tool = ec_tool;
self.editor_state.tool = ec_tool;
}
ShapeChoice::OpenIconPicker | ShapeChoice::ImportImageOrSvg => {
// Host-side dispatch lands when the icon
// picker / file dialog widgets ship.
ShapeChoice::OpenIconPicker => {
// Icon picker not wired yet — closes dropdown only.
}
ShapeChoice::ImportImageOrSvg => {
// Queue a file action so the press handler
// (which knows about `rfd::FileDialog`) can
// pop the picker after the click frame.
self.editor_state.editor_ui.pending_file_action =
Some(op_editor_core::editor_ui_state::FileAction::ImportImageOrSvg);
}
}
self.editor_state.editor_ui.shape_picker_open = false;
@ -413,6 +419,17 @@ impl WidgetHostNative {
.editor_state
.open_color_picker(super::press_helpers::color_target(target), y);
self.mark_dirty();
} else if let op_editor_ui::widgets::PropertyPanelAction::OpenEffectColorPicker(
index,
) = action
{
// Anchor the picker at the clicked swatch so it
// pops next to the row, not at the panel top.
let _ = self.editor_state.open_color_picker(
op_editor_core::ui_draft::ColorTarget::EffectColor(index),
y,
);
self.mark_dirty();
} else {
self.apply_property_action(action);
}

View file

@ -194,5 +194,8 @@ pub(in crate::widget_host) fn color_target(
op_editor_core::ColorTarget::GradientStop(i) => {
op_editor_core::ui_draft::ColorTarget::GradientStop(i)
}
op_editor_core::ColorTarget::EffectColor(i) => {
op_editor_core::ui_draft::ColorTarget::EffectColor(i)
}
}
}

View file

@ -134,6 +134,19 @@ impl WidgetHostNative {
self.editor_state.editor_ui.effect_param_focus =
Some(op_editor_core::editor_ui_state::EffectParamFocus { effect, field });
}
A::OpenEffectColorPicker(index) => {
let _ = self.editor_state.open_color_picker(
op_editor_core::ui_draft::ColorTarget::EffectColor(index),
0.0,
);
}
A::PickFillImage => {
// Queue the file dialog — the desktop runner pops it
// on the next frame and writes the chosen image into
// the selected node's primary fill.
self.editor_state.editor_ui.pending_file_action =
Some(op_editor_core::editor_ui_state::FileAction::PickFillImage);
}
}
self.mark_dirty();
}
@ -607,5 +620,8 @@ fn color_target(t: op_editor_core::ColorTarget) -> op_editor_core::ui_draft::Col
op_editor_core::ColorTarget::GradientStop(i) => {
op_editor_core::ui_draft::ColorTarget::GradientStop(i)
}
op_editor_core::ColorTarget::EffectColor(i) => {
op_editor_core::ui_draft::ColorTarget::EffectColor(i)
}
}
}

View file

@ -356,7 +356,10 @@ impl RenderBackend for WebBackend {
let s = size / viewbox;
let mut matrix = skia_safe::Matrix::new_identity();
matrix.set_scale_translate((s, s), (top_left.x, top_left.y));
let path = path.with_transform(&matrix);
let mut path = path.with_transform(&matrix);
if d.matches(['Z', 'z']).count() > 1 {
path.set_fill_type(skia_safe::PathFillType::EvenOdd);
}
let mut paint = skia_safe::Paint::new(
skia_safe::Color4f::new(color.r, color.g, color.b, color.a),
None,

View file

@ -274,7 +274,27 @@ impl WidgetHost {
),
};
if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) {
self.apply_property_action(action);
// Anchor the colour picker at the clicked y so it
// pops next to the swatch row, not at the panel top.
if let op_editor_ui::widgets::PropertyPanelAction::OpenColorPicker(target) = action
{
let _ = self.editor_state.open_color_picker(
super::property_dispatch::color_target_public(target),
y,
);
self.mark_dirty();
} else if let op_editor_ui::widgets::PropertyPanelAction::OpenEffectColorPicker(
index,
) = action
{
let _ = self.editor_state.open_color_picker(
op_editor_core::ui_draft::ColorTarget::EffectColor(index),
y,
);
self.mark_dirty();
} else {
self.apply_property_action(action);
}
return true;
}
}

View file

@ -116,15 +116,42 @@ impl WidgetHost {
// commit, or Escape out. The `` / `+` steppers
// (`AdjustEffectParam`) remain the web edit path.
}
A::OpenEffectColorPicker(index) => {
let _ = self.editor_state.open_color_picker(
op_editor_core::ui_draft::ColorTarget::EffectColor(index),
0.0,
);
}
A::PickFillImage => {
// Web file-picker path lands later; the wasm shell
// has no rfd / native dialog so this is a no-op for
// now. A future implementation would surface a
// `<input type="file">` via the JS bridge.
}
}
self.mark_dirty();
}
}
/// Public alias for [`color_target`] — used by the press dispatch
/// in `press.rs` so it can anchor the colour picker at the clicked
/// y instead of always passing `0.0`.
pub(in crate::widget_host) fn color_target_public(
t: op_editor_core::ColorTarget,
) -> op_editor_core::ui_draft::ColorTarget {
color_target(t)
}
/// Translate a shell-core `ColorTarget` into op-editor-core's.
fn color_target(t: op_editor_core::ColorTarget) -> op_editor_core::ui_draft::ColorTarget {
match t {
op_editor_core::ColorTarget::Fill => op_editor_core::ui_draft::ColorTarget::Fill,
op_editor_core::ColorTarget::Stroke => op_editor_core::ui_draft::ColorTarget::Stroke,
op_editor_core::ColorTarget::GradientStop(i) => {
op_editor_core::ui_draft::ColorTarget::GradientStop(i)
}
op_editor_core::ColorTarget::EffectColor(i) => {
op_editor_core::ui_draft::ColorTarget::EffectColor(i)
}
}
}

View file

@ -530,12 +530,13 @@ fn path_to_payload(n: &PathNode) -> NodePayload {
p.h = sizing_to_f32(&n.height);
assign_first_fill(&mut p, n.fill.as_deref());
p.stroke = stroke_to_payload(n.stroke.as_ref());
p.svg_path = n.d.clone();
if let Some(anchors) = &n.anchors {
// `points` is the path's anchor polyline — kept 1:1 with the
// schema anchors so the pen-tool anchor hit-test (which maps a
// `points` index straight onto an anchor index) stays correct.
// SVG-imported curves arrive pre-flattened to dense straight
// anchors, so this faithfully traces them without bezier data.
// Editable paths trace their anchors here. Imported SVG
// paths that preserve `d` paint through `svg_path` instead.
p.points = anchors.iter().map(|a| [a.x as f32, a.y as f32]).collect();
// Anchor-bounded path: when width/height weren't authored,
// derive size from the handle-aware anchor bounds (cubic
@ -625,6 +626,11 @@ fn image_to_payload(n: &ImageNode) -> NodePayload {
} else if let Some(CornerRadius::PerCorner(corners)) = &n.corner_radius {
p.corner_radius = corners[0] as f32;
}
// Carry the image source so the canvas painter can decode +
// draw the bitmap. `fill` stays at a neutral grey so the
// placeholder reads correctly when the bytes fail to decode
// (corrupt url / unsupported codec).
p.image_src = Some(n.src.clone());
p.fill = Some([0.85, 0.86, 0.88, 1.0]);
p.name = if n.base.name.as_deref().unwrap_or("").is_empty() {
format!("Image ({})", short_src(&n.src))
@ -678,10 +684,12 @@ fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload {
points: Vec::new(),
path_anchors: Vec::new(),
path_closed: false,
svg_path: None,
font_size: 0.0,
font_weight: 0,
text_wrap: false,
effects: Vec::new(),
image_src: None,
children: Vec::new(),
}
}
@ -710,6 +718,29 @@ fn assign_first_fill(p: &mut NodePayload, fills: Option<&[PenFill]>) {
p.fill = first_solid_color(fills);
p.fill_type = first_fill_type(fills);
p.gradient = first_gradient(fills);
// Image fills: surface the URL so the canvas painter can decode
// + draw the bitmap. Without this the painter only sees the
// grey placeholder `fill` and the image never appears, even
// though `set_selected_fill_image_url` already wrote the URL
// into the canonical `PenFill::Image { url }` slot.
if let Some(url) = first_image_fill_url(fills) {
p.image_src = Some(url);
}
}
/// Read the first fill's image URL when it is a `PenFill::Image`.
/// `None` for any other variant (or empty url) so non-image fills
/// keep their solid / gradient paint paths.
fn first_image_fill_url(fills: Option<&[PenFill]>) -> Option<String> {
let body = fills?.first().and_then(|f| match f {
PenFill::Image(b) => Some(b),
_ => None,
})?;
if body.url.trim().is_empty() {
None
} else {
Some(body.url.clone())
}
}
/// Read the first fill as a resolved [`GradientPayload`] — `None`

View file

@ -129,6 +129,22 @@ fn path_anchors_honor_nonzero_minima_and_scale() {
assert_eq!(n.points[1], [250.0, 360.0], "scaled to width/height");
}
#[test]
fn path_d_carries_to_payload_for_svg_painting() {
let src = r##"{
"version":"1.0.0","pages":[{"id":"p","name":"P","children":[
{"type":"path","id":"arc","x":10,"y":20,"width":80,"height":40,
"d":"M0 20 A40 40 0 0 1 80 20 Z",
"fill":[{"type":"solid","color":"#000000"}]}
]}],"children":[]
}"##;
let r = load(src);
let n = &r.payload.pages[0].children[0];
assert_eq!(n.svg_path.as_deref(), Some("M0 20 A40 40 0 0 1 80 20 Z"));
assert!(n.points.is_empty());
assert!(n.path_anchors.is_empty());
}
#[test]
fn shape_inside_offset_root_inherits_canvas_offset() {
// The zero-size shape fallback used to keep `(x, y)` from

View file

@ -121,9 +121,11 @@ fn node_payload_to_scene(node: &NodePayload, var_table: &VariableTable) -> Scene
.collect(),
path_anchors: node.path_anchors.iter().map(anchor_to_scene).collect(),
path_closed: node.path_closed,
svg_path: node.svg_path.clone(),
arc_start_angle: node.arc_start_angle,
arc_sweep_angle: node.arc_sweep_angle,
arc_inner_radius: node.arc_inner_radius,
image_src: node.image_src.clone(),
effects: crate::effects::effects_from_payload_ref(&node.effects),
hidden: node.hidden,
locked: node.locked,

View file

@ -88,6 +88,10 @@ pub struct NodePayload {
/// back to the first).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub path_closed: bool,
/// Preserved SVG path data for imported path nodes. Coordinates
/// are local doc-px relative to the node origin.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub svg_path: Option<String>,
/// Text size in doc-px. 0 = use the renderer's default 13 px.
/// Text-only.
#[serde(default)]
@ -100,6 +104,12 @@ pub struct NodePayload {
/// Drop-shadow effects.
#[serde(default)]
pub effects: Vec<crate::effects::ShadowPayload>,
/// Source URL (`data:image/...;base64,...` or file path) when the
/// node is an `Image` — the canvas painter decodes the inline
/// bytes and draws them with `RenderBackend::draw_image`. `None`
/// for non-image nodes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_src: Option<String>,
#[serde(default)]
pub children: Vec<NodePayload>,
}

View file

@ -36,6 +36,26 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleDisplayName</key><string>OpenPencil</string>
<key>CFBundleExecutable</key><string>openpencil-desktop</string>
<key>CFBundleIdentifier</key><string>com.zseven-w.openpencil</string>
<key>CFBundleAllowMixedLocalizations</key><true/>
<key>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh-Hans</string>
<string>zh-Hant</string>
<string>ja</string>
<string>ko</string>
<string>fr</string>
<string>es</string>
<string>de</string>
<string>pt</string>
<string>ru</string>
<string>hi</string>
<string>tr</string>
<string>th</string>
<string>vi</string>
<string>id</string>
</array>
<key>CFBundleIconFile</key><string>icon</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>