mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
Merge branch 'v0.8.0-new' of github.com:ZSeven-W/openpencil into v0.8.0-new
This commit is contained in:
commit
845e5ae7b5
40 changed files with 2556 additions and 289 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2572,6 +2572,7 @@ name = "op-editor-ui"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"base64",
|
||||
"bitflags 2.11.1",
|
||||
"glam",
|
||||
"jian-core",
|
||||
|
|
|
|||
|
|
@ -95,16 +95,19 @@ impl EditorState {
|
|||
ColorTarget::Fill => first_solid_fill_hex(node).map(str::to_string),
|
||||
ColorTarget::Stroke => first_solid_stroke_hex(node).map(str::to_string),
|
||||
ColorTarget::GradientStop(i) => gradient_stop_hex(node, i),
|
||||
ColorTarget::EffectColor(i) => effect_color_hex(node, i),
|
||||
}
|
||||
.unwrap_or_else(|| "#000000".to_string());
|
||||
let (h, s, v) = rgb_to_hsv(parse_hex_rgb(¤t_hex).unwrap_or((0.0, 0.0, 0.0)));
|
||||
// Preserve per-stop alpha across picker edits. Fill / stroke
|
||||
// ignore alpha (they carry it in a separate opacity input)
|
||||
// so this only matters for `GradientStop`.
|
||||
let alpha = if matches!(target, ColorTarget::GradientStop(_)) {
|
||||
parse_hex_alpha(¤t_hex)
|
||||
} else {
|
||||
1.0
|
||||
// Preserve per-stop / per-effect alpha across picker edits.
|
||||
// Fill / stroke ignore alpha (they carry it in a separate
|
||||
// opacity input) so this only matters for `GradientStop`
|
||||
// and `EffectColor`.
|
||||
let alpha = match target {
|
||||
ColorTarget::GradientStop(_) | ColorTarget::EffectColor(_) => {
|
||||
parse_hex_alpha(¤t_hex)
|
||||
}
|
||||
_ => 1.0,
|
||||
};
|
||||
self.ui.pending_color_history = Some(self.snapshot_for_history());
|
||||
self.ui.color_picker = Some(ColorPickerState {
|
||||
|
|
@ -202,30 +205,34 @@ impl EditorState {
|
|||
self.set_selected_color(false, &hex);
|
||||
}
|
||||
ColorTarget::GradientStop(i) => {
|
||||
// Splice the picker's preserved alpha back onto the
|
||||
// RGB hex — the picker has no alpha slider, so the
|
||||
// stop's authored transparency must round-trip even
|
||||
// when the user drags hue / saturation / value.
|
||||
let alpha_u8 = (self
|
||||
.ui
|
||||
.color_picker
|
||||
.as_ref()
|
||||
.map(|s| s.alpha)
|
||||
.unwrap_or(1.0)
|
||||
.clamp(0.0, 1.0)
|
||||
* 255.0)
|
||||
.round() as u8;
|
||||
let hex_with_alpha = if alpha_u8 == 255 {
|
||||
hex.clone()
|
||||
} else {
|
||||
format!("{}{:02X}", hex, alpha_u8)
|
||||
};
|
||||
let hex_with_alpha = splice_alpha(&hex, self.picker_alpha());
|
||||
let _ = self.set_selected_gradient_stop_hex(i, &hex_with_alpha);
|
||||
}
|
||||
ColorTarget::EffectColor(i) => {
|
||||
let hex_with_alpha = splice_alpha(&hex, self.picker_alpha());
|
||||
let sel = self.selection.anchor.clone();
|
||||
if sel.is_real() {
|
||||
let _ = self.apply(crate::EditorCommand::SetEffectColor {
|
||||
node_id: sel,
|
||||
index: i as u32,
|
||||
hex: hex_with_alpha,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Read the picker's preserved alpha (0..=1) — defaults to 1.0
|
||||
/// when no picker is open.
|
||||
fn picker_alpha(&self) -> f32 {
|
||||
self.ui
|
||||
.color_picker
|
||||
.as_ref()
|
||||
.map(|s| s.alpha.clamp(0.0, 1.0))
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Set the active drag kind so `apply_cursor_move` can route a
|
||||
/// move event to the right control.
|
||||
pub fn color_picker_set_drag(&mut self, drag: Option<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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
459
crates/op-editor-core/src/svg_path_data.rs
Normal file
459
crates/op-editor-core/src/svg_path_data.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
34
crates/op-host-desktop/Info.plist
Normal file
34
crates/op-host-desktop/Info.plist
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
142
crates/op-host-desktop/src/persistence_image.rs
Normal file
142
crates/op-host-desktop/src/persistence_image.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue