feat: close six TS-parity gaps in the Rust shell

Closes six verified gaps from the 2026-05-17 TS-vs-Rust gap analysis,
each build- and test-green:

- editor: SetNodeFlip + SetEllipseArc commands (+ set_node_flip /
  set_ellipse_arc MCP tools) — schema already had the fields, only
  the command path was missing.
- export: export_node_raster crops a raster to one node's bbox;
  File -> Export is now selection-aware (single selection -> layer).
- editor: SVG import — hand-rolled parser (shapes + path M/L/H/V/
  C/S/Q/T/Z) in svg_import.rs; cubic curves flatten to dense straight
  anchors at import time so the renderer/pen-tool stay on their 1:1
  straight-segment model. + EditorCommand::ImportSvg + import_svg tool.
- mcp: HTTP transport — mcp_serve::run_http serves MCP over a
  TcpListener (--mcp-http <port> <path>); process_message is shared
  with the stdio path.
- cli: new op-cli crate (binary `op`) — a dependency-free HTTP MCP
  client driving every tool via `op <tool> key=value...`.
- ai: ChatRequest gains thinking/effort fields (ThinkingMode /
  EffortLevel, defaults Adaptive/Low to match the TS runtime config).

MCP tool catalog 77 -> 80. Oversized files split to honour the
800-line cap (svg_import, export, mcp_serve, adapter).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kayshen-X 2026-05-17 13:03:11 +08:00
parent 43a426a6fb
commit d297752aac
22 changed files with 2170 additions and 496 deletions

4
Cargo.lock generated
View file

@ -2287,6 +2287,10 @@ dependencies = [
"op-host-web",
]
[[package]]
name = "op-cli"
version = "0.1.0"
[[package]]
name = "op-codegen"
version = "0.1.0"

View file

@ -145,11 +145,64 @@ pub enum StopReason {
ToolUse,
}
#[derive(Debug, Clone, PartialEq, Eq)]
/// Thinking / reasoning-budget control for a chat turn. `Adaptive`
/// lets the provider decide; `Disabled` suppresses extended thinking;
/// `Enabled` forces it. Mirrors the TS chat panel's thinking-mode
/// selector (`apps/web/.../ai/chat.ts`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ThinkingMode {
#[default]
Adaptive,
Disabled,
Enabled,
}
/// Reasoning-effort hint. Each provider maps it onto its own knob
/// (Claude's thinking-token budget, Codex's `--effort`, …); a
/// provider with no such knob ignores it. The default is `Low`, to
/// match TS `ai-runtime-config.ts::DEFAULT_THINKING_EFFORT`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EffortLevel {
#[default]
Low,
Medium,
High,
Max,
}
impl ThinkingMode {
/// Lowercase wire token (TS parity: `"adaptive"` / `"disabled"` /
/// `"enabled"`).
pub fn as_str(self) -> &'static str {
match self {
ThinkingMode::Adaptive => "adaptive",
ThinkingMode::Disabled => "disabled",
ThinkingMode::Enabled => "enabled",
}
}
}
impl EffortLevel {
/// Lowercase wire token (`"low"` / `"medium"` / `"high"` / `"max"`).
pub fn as_str(self) -> &'static str {
match self {
EffortLevel::Low => "low",
EffortLevel::Medium => "medium",
EffortLevel::High => "high",
EffortLevel::Max => "max",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ChatRequest {
pub system_prompt: String,
pub user_message: String,
pub max_output_tokens: u32,
/// Thinking-mode control for this turn (default `Adaptive`).
pub thinking: ThinkingMode,
/// Reasoning-effort hint for this turn (default `Low`, TS parity).
pub effort: EffortLevel,
}
/// Provider abstraction the widget host calls. Implementations live
@ -244,6 +297,7 @@ mod tests {
system_prompt: String::new(),
user_message: "hi".into(),
max_output_tokens: 1024,
..Default::default()
};
let mut iter = p.send(req);
match iter.next() {
@ -261,4 +315,21 @@ mod tests {
assert_eq!(CliName::ClaudeCode.label(), "Claude Code");
assert_eq!(CliName::OpenCode.label(), "OpenCode");
}
#[test]
fn chat_request_thinking_effort_defaults_and_wire_tokens() {
// A defaulted request reasons adaptively at low effort —
// matching TS `DEFAULT_THINKING_MODE` / `DEFAULT_THINKING_EFFORT`.
let req = ChatRequest::default();
assert_eq!(req.thinking, ThinkingMode::Adaptive);
assert_eq!(req.effort, EffortLevel::Low);
// Wire tokens match the TS chat-request vocabulary.
assert_eq!(ThinkingMode::Adaptive.as_str(), "adaptive");
assert_eq!(ThinkingMode::Disabled.as_str(), "disabled");
assert_eq!(ThinkingMode::Enabled.as_str(), "enabled");
assert_eq!(EffortLevel::Low.as_str(), "low");
assert_eq!(EffortLevel::Medium.as_str(), "medium");
assert_eq!(EffortLevel::High.as_str(), "high");
assert_eq!(EffortLevel::Max.as_str(), "max");
}
}

17
crates/op-cli/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "op-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "OpenPencil `op` CLI — drives the editor via the HTTP MCP transport"
# The binary is `op` (the command users type); the crate is `op-cli`.
[[bin]]
name = "op"
path = "src/main.rs"
# Dependency-free: the CLI hand-rolls the HTTP/1.1 request + JSON-RPC
# body the same way `op-mcp` hand-rolls its wire layer, so `op` builds
# fast and pulls nothing into the workspace graph.
[dependencies]

314
crates/op-cli/src/main.rs Normal file
View file

@ -0,0 +1,314 @@
//! `op` — the OpenPencil command-line tool.
//!
//! A thin client over the HTTP MCP transport (`op-host-desktop
//! --mcp-http <port> <file>`): every `op` invocation maps onto one
//! MCP `tools/call` and prints the JSON-RPC reply. Because the editor
//! already exposes its full editing surface as MCP tools, the generic
//! `op <tool> key=value …` form drives all of them — insert / update /
//! delete / move / design_* / variables / pages / export, etc.
//!
//! Usage:
//! op [--port N] tools list every tool + schema
//! op [--port N] <tool> [key=value …] call one tool
//! op help this message
//!
//! `--port` defaults to 8765; start the server with
//! `op-host-desktop --mcp-http 8765 <file>.op`.
//!
//! Dependency-free on purpose: the HTTP/1.1 request and the JSON-RPC
//! body are hand-rolled, mirroring `op-mcp`'s hand-rolled wire layer.
use std::io::{Read, Write};
/// Default HTTP MCP port — pair with `op-host-desktop --mcp-http 8765`.
const DEFAULT_PORT: u16 = 8765;
const USAGE: &str = "\
op OpenPencil CLI (drives the editor over the HTTP MCP transport)
USAGE:
op [--port N] tools list every MCP tool + input schema
op [--port N] <tool> [key=value ] call one MCP tool with string args
op help show this message
EXAMPLES:
op tools
op insert_node kind=rect name=Box x=10 y=20 width=100 height=60
op set_node_fill_hex node_id=n3 hex=#ff0000
op --port 9001 get_document_info
The server must be running:
op-host-desktop --mcp-http 8765 path/to/file.op";
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
match run(&args) {
Ok(out) => {
println!("{out}");
}
Err(e) => {
eprintln!("op: {e}");
std::process::exit(1);
}
}
}
/// Parse `args`, perform the request, return the text to print.
/// Pure except for [`post`] — the parse/build steps are unit-tested.
fn run(args: &[String]) -> Result<String, String> {
let Parsed { port, command } = parse_args(args)?;
match command {
Command::Help => Ok(USAGE.to_string()),
Command::ToolsList => post(port, &tools_list_body()),
Command::ToolCall { tool, args } => {
post(port, &tool_call_body(&tool, &args_to_json(&args)))
}
}
}
/// Outcome of argument parsing.
struct Parsed {
port: u16,
command: Command,
}
enum Command {
Help,
ToolsList,
ToolCall {
tool: String,
args: Vec<(String, String)>,
},
}
/// Parse `[--port N] <command> …` into a [`Parsed`]. `--port` may
/// appear anywhere before the command.
fn parse_args(args: &[String]) -> Result<Parsed, String> {
let mut port = DEFAULT_PORT;
let mut rest: Vec<&String> = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--port" => {
let raw = args
.get(i + 1)
.ok_or("--port needs a value (e.g. --port 8765)")?;
port = raw
.parse::<u16>()
.map_err(|_| format!("--port must be a u16, got {raw:?}"))?;
i += 2;
}
_ => {
rest.push(&args[i]);
i += 1;
}
}
}
let Some(cmd) = rest.first() else {
return Err(format!("missing command\n\n{USAGE}"));
};
let command = match cmd.as_str() {
"help" | "--help" | "-h" => Command::Help,
"tools" => Command::ToolsList,
tool => {
let mut pairs = Vec::new();
for kv in &rest[1..] {
let (k, v) = kv
.split_once('=')
.ok_or_else(|| format!("argument must be key=value, got {kv:?}"))?;
if k.is_empty() {
return Err(format!("argument has an empty key: {kv:?}"));
}
pairs.push((k.to_string(), v.to_string()));
}
Command::ToolCall {
tool: tool.to_string(),
args: pairs,
}
}
};
Ok(Parsed { port, command })
}
/// JSON-RPC body for `tools/list`.
fn tools_list_body() -> String {
r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#.to_string()
}
/// JSON-RPC body for a `tools/call` of `tool` with the already-built
/// `arguments` object JSON.
fn tool_call_body(tool: &str, args_json: &str) -> String {
format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"{}","arguments":{}}}}}"#,
json_escape(tool),
args_json
)
}
/// Build a JSON object from `key=value` pairs. MCP tool arguments are
/// all string-typed, so every value is emitted as a JSON string.
fn args_to_json(pairs: &[(String, String)]) -> String {
let mut out = String::from("{");
for (i, (k, v)) in pairs.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('"');
out.push_str(&json_escape(k));
out.push_str("\":\"");
out.push_str(&json_escape(v));
out.push('"');
}
out.push('}');
out
}
/// Escape a string for inclusion in a JSON string literal.
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out
}
/// POST `body` to the HTTP MCP server on `127.0.0.1:port` and return
/// the response body (the JSON-RPC reply).
fn post(port: u16, body: &str) -> Result<String, String> {
let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| {
format!(
"cannot reach the editor on 127.0.0.1:{port}: {e}\n\
start it with: op-host-desktop --mcp-http {port} <file>.op"
)
})?;
let request = format!(
"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(request.as_bytes())
.map_err(|e| format!("http write: {e}"))?;
stream.flush().ok();
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|e| format!("http read: {e}"))?;
// Strip the HTTP head — everything past the blank line is the
// JSON-RPC reply.
Ok(match response.split_once("\r\n\r\n") {
Some((_, body)) => body.trim().to_string(),
None => response.trim().to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_escape_handles_quotes_backslash_control() {
assert_eq!(json_escape(r#"a"b\c"#), r#"a\"b\\c"#);
assert_eq!(json_escape("line\nbreak"), "line\\nbreak");
assert_eq!(json_escape("tab\there"), "tab\\there");
assert_eq!(json_escape("\u{0001}"), "\\u0001");
}
#[test]
fn args_to_json_builds_string_valued_object() {
assert_eq!(args_to_json(&[]), "{}");
let pairs = vec![
("kind".to_string(), "rect".to_string()),
("x".to_string(), "10".to_string()),
];
assert_eq!(args_to_json(&pairs), r#"{"kind":"rect","x":"10"}"#);
}
#[test]
fn args_to_json_escapes_values() {
let pairs = vec![("name".to_string(), r#"a"b"#.to_string())];
assert_eq!(args_to_json(&pairs), r#"{"name":"a\"b"}"#);
}
#[test]
fn tool_call_body_wraps_name_and_arguments() {
let body = tool_call_body("insert_node", r#"{"kind":"rect"}"#);
assert!(body.contains(r#""method":"tools/call""#));
assert!(body.contains(r#""name":"insert_node""#));
assert!(body.contains(r#""arguments":{"kind":"rect"}"#));
}
#[test]
fn tools_list_body_is_a_tools_list_request() {
assert!(tools_list_body().contains(r#""method":"tools/list""#));
}
#[test]
fn parse_args_defaults_port_and_reads_tool_call() {
let args = vec![
"insert_node".to_string(),
"kind=rect".to_string(),
"x=10".to_string(),
];
let p = parse_args(&args).expect("parse");
assert_eq!(p.port, DEFAULT_PORT);
match p.command {
Command::ToolCall { tool, args } => {
assert_eq!(tool, "insert_node");
assert_eq!(args.len(), 2);
assert_eq!(args[0], ("kind".to_string(), "rect".to_string()));
}
_ => panic!("expected ToolCall"),
}
}
#[test]
fn parse_args_reads_explicit_port_anywhere() {
let args = vec![
"--port".to_string(),
"9001".to_string(),
"tools".to_string(),
];
let p = parse_args(&args).expect("parse");
assert_eq!(p.port, 9001);
assert!(matches!(p.command, Command::ToolsList));
}
#[test]
fn parse_args_rejects_non_kv_argument() {
let args = vec!["insert_node".to_string(), "bogus".to_string()];
assert!(parse_args(&args).is_err());
}
#[test]
fn parse_args_rejects_bad_port() {
let args = vec![
"--port".to_string(),
"notnum".to_string(),
"tools".to_string(),
];
assert!(parse_args(&args).is_err());
let missing = vec!["--port".to_string()];
assert!(parse_args(&missing).is_err());
}
#[test]
fn parse_args_help_and_empty() {
assert!(matches!(
parse_args(&["help".to_string()]).unwrap().command,
Command::Help
));
assert!(parse_args(&[]).is_err());
}
}

View file

@ -246,4 +246,7 @@ pub enum EditorCommand {
CutSelected,
/// `Cmd+V` — paste the clipboard as top-level siblings.
PasteClipboard { offset_px: i32 },
/// Parse an SVG document + insert the resulting nodes on the
/// active page, offset by `(x, y)` doc-px.
ImportSvg { svg: String, x: i32, y: i32 },
}

View file

@ -359,6 +359,14 @@ impl EditorState {
self.history_push_past(snap);
true
}
EditorCommand::ImportSvg { svg, x, y } => {
let Some(mut next_id) = self.next_node_id_seed() else {
return false;
};
// `import_svg` pushes its own history snapshot when it
// inserts ≥ 1 node.
self.import_svg(&mut next_id, &svg, (x as f64, y as f64)) > 0
}
// --- Tool + viewport + history -------------------------
EditorCommand::SetActiveTool { tool } => {

View file

@ -32,6 +32,8 @@ pub mod rename;
pub mod render_backend;
pub mod selection;
pub mod state;
pub mod svg_import;
pub mod svg_path_bounds;
pub mod tool;
pub mod ui_draft;
pub mod variables;
@ -41,6 +43,8 @@ pub mod walkers;
#[cfg(test)]
mod command_tests;
#[cfg(test)]
mod svg_import_tests;
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests_geometry;

View file

@ -0,0 +1,743 @@
//! SVG import — parse an SVG document into canonical `PenNode`s and
//! insert them onto the active page.
//!
//! TS parity with `apps/web/src/.../svg-parser.ts`. v1 scope:
//!
//! - Shape elements: `<rect>` / `<circle>` / `<ellipse>` / `<line>` /
//! `<polyline>` / `<polygon>`.
//! - `<path>` — the `M L H V C S Q T Z` command subset (absolute +
//! relative). Cubic / quadratic curves keep their bezier handles
//! (`Q`/`T` are promoted to cubics); `A` (elliptical arc) degrades
//! to a straight segment to its endpoint.
//! - `fill` attribute — `#rgb` / `#rrggbb` + a small named-colour
//! table; `none` leaves the node unfilled.
//!
//! Out of scope for v1 (skipped, not an error): `<g>` grouping,
//! `transform` attributes, CSS `<style>`, `<defs>` / gradients,
//! `stroke` styling. Elements are scanned flat, so a shape nested in a
//! `<g>` still imports — just without the group's transform.
//!
//! The parser is hand-rolled (no XML / SVG crate) so `op-editor-core`
//! stays dependency-light + wasm32-clean, matching the hand-rolled
//! JSON parser discipline in `op-mcp`.
use crate::fills::set_primary_fill_hex;
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::{PathNode, PenNode, PenNodeBase, PenPathAnchor};
use jian_ops_schema::sizing::SizingBehavior;
/// One parsed SVG element — tag name + raw attribute pairs.
struct SvgElement {
tag: String,
attrs: Vec<(String, String)>,
}
impl SvgElement {
/// Attribute lookup (first match wins).
fn attr(&self, key: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
/// Attribute parsed as `f64`, defaulting to `0.0` when absent or
/// unparseable.
fn num(&self, key: &str) -> f64 {
self.attr(key)
.and_then(|v| v.trim().parse::<f64>().ok())
.unwrap_or(0.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() {
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);
}
}
}
if built.is_empty() {
return 0;
}
let pre = self.snapshot_for_history();
let count = built.len();
self.active_children_mut().extend(built);
self.history_push_past(pre);
count
}
}
/// Build a `PenNode` from one parsed SVG element. `None` for
/// unsupported tags (`g` / `svg` / `defs` / `style` / …) and for
/// degenerate geometry.
fn element_to_node(el: &SvgElement, id: NodeId, offset: (f64, f64)) -> Option<PenNode> {
let (ox, oy) = offset;
let fill = el.attr("fill").and_then(parse_svg_color);
let mut node = match el.tag.as_str() {
"rect" => {
let (w, h) = (el.num("width"), el.num("height"));
if w <= 0.0 || h <= 0.0 {
return None;
}
build_leaf_node(
"rect",
id.as_str(),
"Rect",
(el.num("x") + ox).round() as i32,
(el.num("y") + oy).round() as i32,
w.round() as i32,
h.round() as i32,
)?
}
"circle" => {
let r = el.num("r");
if r <= 0.0 {
return None;
}
build_leaf_node(
"ellipse",
id.as_str(),
"Ellipse",
(el.num("cx") - r + ox).round() as i32,
(el.num("cy") - r + oy).round() as i32,
(r * 2.0).round() as i32,
(r * 2.0).round() as i32,
)?
}
"ellipse" => {
let (rx, ry) = (el.num("rx"), el.num("ry"));
if rx <= 0.0 || ry <= 0.0 {
return None;
}
build_leaf_node(
"ellipse",
id.as_str(),
"Ellipse",
(el.num("cx") - rx + ox).round() as i32,
(el.num("cy") - ry + oy).round() as i32,
(rx * 2.0).round() as i32,
(ry * 2.0).round() as i32,
)?
}
"line" => {
let p0 = (el.num("x1") + ox, el.num("y1") + oy);
let p1 = (el.num("x2") + ox, el.num("y2") + oy);
path_node_from_anchors(id, "Line", &[p0, p1], false)?
}
"polyline" | "polygon" => {
let pts: Vec<(f64, f64)> = parse_point_list(el.attr("points").unwrap_or(""))
.into_iter()
.map(|(x, y)| (x + ox, y + oy))
.collect();
if pts.len() < 2 {
return None;
}
path_node_from_anchors(id, "Path", &pts, el.tag == "polygon")?
}
"path" => {
let d = el.attr("d")?;
let (anchors, closed) = parse_path_d(d, offset);
if anchors.len() < 2 {
return None;
}
path_node_from_pen_anchors(id, anchors, closed)?
}
// <svg> / <g> / <defs> / <style> / <title> / … — skipped.
_ => return None,
};
if let Some(hex) = fill {
set_primary_fill_hex(&mut node, &hex);
}
Some(node)
}
/// Build a straight-segment `Path` node from doc-space points.
fn path_node_from_anchors(
id: NodeId,
name: &str,
pts: &[(f64, f64)],
closed: bool,
) -> Option<PenNode> {
let anchors: Vec<PenPathAnchor> = pts
.iter()
.map(|&(x, y)| PenPathAnchor {
x,
y,
handle_in: None,
handle_out: None,
point_type: None,
})
.collect();
path_node_from_pen_anchors(id, anchors, closed).map(|mut n| {
n.base_mut().name = Some(name.to_string());
n
})
}
/// Build a `Path` node from ready `PenPathAnchor`s, fitting the base
/// rect to the anchor bounding box.
fn path_node_from_pen_anchors(
id: NodeId,
anchors: Vec<PenPathAnchor>,
closed: bool,
) -> Option<PenNode> {
if anchors.len() < 2 {
return None;
}
let (min_x, min_y, max_x, max_y) = crate::svg_path_bounds::path_anchor_bounds(&anchors, closed);
Some(PenNode::Path(PathNode {
base: PenNodeBase {
id: id.into(),
name: Some("Path".to_string()),
x: Some(min_x),
y: Some(min_y),
..Default::default()
},
icon_id: None,
d: None,
anchors: Some(anchors),
closed: Some(closed),
width: Some(SizingBehavior::Number((max_x - min_x).max(0.0))),
height: Some(SizingBehavior::Number((max_y - min_y).max(0.0))),
fill: None,
stroke: None,
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.
fn parse_svg_elements(svg: &str) -> Vec<SvgElement> {
let bytes = svg.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 svg[i..].starts_with("<!--") {
match svg[i..].find("-->") {
Some(rel) => i += rel + 3,
None => break,
}
continue;
}
// Closing tag / prolog / DOCTYPE — skip to the matching `>`.
if matches!(bytes.get(i + 1), Some(b'/') | Some(b'?') | Some(b'!')) {
match svg[i..].find('>') {
Some(rel) => i += rel + 1,
None => break,
}
continue;
}
// Open / self-closing element: read until the matching `>`,
// honouring quoted attribute values.
let Some(end) = find_tag_end(bytes, i + 1) else {
break;
};
let inner = &svg[i + 1..end];
if let Some(el) = parse_element(inner) {
out.push(el);
}
i = end + 1;
}
out
}
/// Index of the `>` that closes a tag started at `from`, skipping any
/// `>` that sits inside a quoted attribute value.
fn find_tag_end(bytes: &[u8], from: usize) -> Option<usize> {
let mut i = from;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let c = bytes[i];
match quote {
Some(q) if c == q => quote = None,
Some(_) => {}
None => match c {
b'"' | b'\'' => quote = Some(c),
b'>' => return Some(i),
_ => {}
},
}
i += 1;
}
None
}
/// Parse the inside of a tag (`rect x="1" y="2" /`) into tag name +
/// attribute pairs.
fn parse_element(inner: &str) -> Option<SvgElement> {
let trimmed = inner.trim().trim_end_matches('/').trim();
// Tag name runs up to the first whitespace.
let name_end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len());
let tag = trimmed[..name_end].to_ascii_lowercase();
if tag.is_empty() {
return None;
}
Some(SvgElement {
tag,
attrs: parse_attrs(&trimmed[name_end..]),
})
}
/// Parse `key="value"` / `key='value'` pairs from an attribute run.
fn parse_attrs(s: &str) -> Vec<(String, String)> {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
// Skip to a key start.
while i < bytes.len() && (bytes[i].is_ascii_whitespace() || bytes[i] == b'/') {
i += 1;
}
let key_start = i;
while i < bytes.len() && bytes[i] != b'=' && !bytes[i].is_ascii_whitespace() {
i += 1;
}
if key_start == i {
break;
}
let key = s[key_start..i].to_ascii_lowercase();
// Skip whitespace + the `=`.
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] != b'=' {
continue; // valueless attribute — ignore
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let quote = bytes[i];
if quote != b'"' && quote != b'\'' {
continue;
}
i += 1;
let val_start = i;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
let value = s[val_start..i.min(s.len())].to_string();
out.push((key, value));
i += 1;
}
out
}
/// Parse an SVG `points` list (`"1,2 3,4"` / `"1 2 3 4"`).
fn parse_point_list(s: &str) -> Vec<(f64, f64)> {
let nums = scan_numbers(s);
nums.chunks_exact(2).map(|c| (c[0], c[1])).collect()
}
/// Scan every number out of `s`, treating commas + whitespace as
/// separators. Tolerates the SVG quirks: a leading `.`, a `-` that
/// starts a new number, and scientific `e` notation.
fn scan_numbers(s: &str) -> Vec<f64> {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
let c = bytes[i];
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) = s[start..i].parse::<f64>() {
out.push(n);
}
} else {
i += 1;
}
}
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) {
let tokens = tokenize_path(d);
let (ox, oy) = offset;
let mut anchors: Vec<PenPathAnchor> = Vec::new();
let mut closed = false;
// Current pen position, sub-path start, and the last control point
// (for the smooth `S` / `T` reflection).
let (mut cx, mut cy) = (0.0f64, 0.0f64);
let (mut start_x, mut start_y) = (0.0f64, 0.0f64);
let mut last_cubic_ctrl: Option<(f64, f64)> = None;
let mut last_quad_ctrl: Option<(f64, f64)> = None;
let push_anchor = |anchors: &mut Vec<PenPathAnchor>, x: f64, y: f64| {
anchors.push(PenPathAnchor {
x: x + ox,
y: y + oy,
handle_in: None,
handle_out: None,
point_type: None,
});
};
let mut ti = 0usize;
let mut cmd = b' ';
while ti < tokens.len() {
// A token is either a command letter or (when the previous
// command repeats) a fresh number run.
if let PathToken::Cmd(c) = tokens[ti] {
cmd = c;
ti += 1;
}
let rel = cmd.is_ascii_lowercase();
let up = cmd.to_ascii_uppercase();
// Collect the numbers this command consumes.
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,
_ => {
ti += 1;
continue;
}
};
if up == b'Z' {
closed = true;
cx = start_x;
cy = start_y;
last_cubic_ctrl = None;
last_quad_ctrl = None;
continue;
}
let mut args = [0.0f64; 7];
let mut got = 0;
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 {
break; // truncated command — stop
}
match up {
b'M' => {
let (x, y) = abs_pt(rel, cx, cy, args[0], args[1]);
cx = x;
cy = y;
start_x = x;
start_y = y;
push_anchor(&mut anchors, x, y);
cmd = if rel { b'l' } else { b'L' }; // implicit lineto
last_cubic_ctrl = None;
last_quad_ctrl = None;
}
b'L' => {
let (x, y) = abs_pt(rel, cx, cy, args[0], args[1]);
cx = x;
cy = y;
push_anchor(&mut anchors, x, y);
last_cubic_ctrl = None;
last_quad_ctrl = None;
}
b'H' => {
let x = if rel { cx + args[0] } else { args[0] };
cx = x;
push_anchor(&mut anchors, x, cy);
last_cubic_ctrl = None;
last_quad_ctrl = None;
}
b'V' => {
let y = if rel { cy + args[0] } else { args[0] };
cy = y;
push_anchor(&mut anchors, cx, y);
last_cubic_ctrl = None;
last_quad_ctrl = None;
}
b'C' => {
let (c1x, c1y) = abs_pt(rel, cx, cy, args[0], args[1]);
let (c2x, c2y) = abs_pt(rel, cx, cy, args[2], args[3]);
let (x, y) = abs_pt(rel, cx, cy, args[4], args[5]);
emit_cubic(&mut anchors, c1x, c1y, c2x, c2y, x, y, ox, oy);
cx = x;
cy = y;
last_cubic_ctrl = Some((c2x, c2y));
last_quad_ctrl = None;
}
b'S' => {
// Smooth cubic — first control reflects the previous.
let (c1x, c1y) = match last_cubic_ctrl {
Some((px, py)) => (2.0 * cx - px, 2.0 * cy - py),
None => (cx, cy),
};
let (c2x, c2y) = abs_pt(rel, cx, cy, args[0], args[1]);
let (x, y) = abs_pt(rel, cx, cy, args[2], args[3]);
emit_cubic(&mut anchors, c1x, c1y, c2x, c2y, x, y, ox, oy);
cx = x;
cy = y;
last_cubic_ctrl = Some((c2x, c2y));
last_quad_ctrl = None;
}
b'Q' => {
let (qx, qy) = abs_pt(rel, cx, cy, args[0], args[1]);
let (x, y) = abs_pt(rel, cx, cy, args[2], args[3]);
let (c1x, c1y, c2x, c2y) = quad_to_cubic(cx, cy, qx, qy, x, y);
emit_cubic(&mut anchors, c1x, c1y, c2x, c2y, x, y, ox, oy);
cx = x;
cy = y;
last_quad_ctrl = Some((qx, qy));
last_cubic_ctrl = None;
}
b'T' => {
// Smooth quadratic — control reflects the previous.
let (qx, qy) = match last_quad_ctrl {
Some((px, py)) => (2.0 * cx - px, 2.0 * cy - py),
None => (cx, cy),
};
let (x, y) = abs_pt(rel, cx, cy, args[0], args[1]);
let (c1x, c1y, c2x, c2y) = quad_to_cubic(cx, cy, qx, qy, x, y);
emit_cubic(&mut anchors, c1x, c1y, c2x, c2y, x, y, ox, oy);
cx = x;
cy = y;
last_quad_ctrl = Some((qx, qy));
last_cubic_ctrl = None;
}
b'A' => {
// Elliptical arc — v1 degrades to a straight segment to
// the endpoint (args[5], args[6]).
let (x, y) = abs_pt(rel, cx, cy, args[5], args[6]);
cx = x;
cy = y;
push_anchor(&mut anchors, x, y);
last_cubic_ctrl = None;
last_quad_ctrl = None;
}
_ => {}
}
}
(anchors, closed)
}
/// Resolve a possibly-relative point against the current pen pos.
fn abs_pt(rel: bool, cx: f64, cy: f64, x: f64, y: f64) -> (f64, f64) {
if rel {
(cx + x, cy + y)
} else {
(x, y)
}
}
/// Convert a quadratic control point to the two cubic controls.
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),
)
}
/// 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**.
///
/// 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.
fn emit_cubic(
anchors: &mut Vec<PenPathAnchor>,
c1x: f64,
c1y: f64,
c2x: f64,
c2y: f64,
x: f64,
y: f64,
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 (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,
});
}
}
/// 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
}
/// A path-`d` lexer token.
#[derive(Debug, Clone, Copy, PartialEq)]
enum PathToken {
Cmd(u8),
Num(f64),
}
/// Tokenize a path `d` string into command letters + numbers.
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 dch = bytes[i];
if dch.is_ascii_digit() {
i += 1;
} else if dch == b'.' && !seen_dot && !seen_exp {
seen_dot = true;
i += 1;
} else if (dch == b'e' || dch == 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; // comma / whitespace separator
}
}
out
}
/// Parse an SVG `fill` value into a `#rrggbb` hex string. `none` /
/// `transparent` and unparseable values return `None` (no fill).
fn parse_svg_color(raw: &str) -> Option<String> {
let v = raw.trim().to_ascii_lowercase();
if v.is_empty() || v == "none" || v == "transparent" {
return None;
}
if let Some(hex) = v.strip_prefix('#') {
return match hex.len() {
3 => {
let mut out = String::with_capacity(7);
out.push('#');
for ch in hex.chars() {
out.push(ch);
out.push(ch);
}
Some(out)
}
6 | 8 => Some(format!("#{}", &hex[..6])),
_ => None,
};
}
// Minimal named-colour table — the common SVG presentation names.
let named = match v.as_str() {
"black" => "#000000",
"white" => "#ffffff",
"red" => "#ff0000",
"green" => "#008000",
"lime" => "#00ff00",
"blue" => "#0000ff",
"yellow" => "#ffff00",
"cyan" | "aqua" => "#00ffff",
"magenta" | "fuchsia" => "#ff00ff",
"gray" | "grey" => "#808080",
"silver" => "#c0c0c0",
"orange" => "#ffa500",
"purple" => "#800080",
"navy" => "#000080",
"teal" => "#008080",
_ => return None,
};
Some(named.to_string())
}

View file

@ -0,0 +1,160 @@
//! `EditorState::import_svg` tests — split out of `svg_import.rs` to
//! keep both files under the repo's 800-line cap.
#![cfg(test)]
use crate::test_support::state_with;
use jian_ops_schema::node::PenNode;
#[test]
fn imports_basic_shapes() {
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"/>
<ellipse cx="80" cy="80" rx="30" ry="10"/>
</svg>"##;
let mut s = state_with(vec![]);
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] {
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(_)));
}
#[test]
fn imports_offset_translates_nodes() {
let svg = r#"<svg><rect x="0" y="0" width="50" height="50"/></svg>"#;
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] {
PenNode::Rectangle(r) => {
assert_eq!(r.base.x, Some(200.0));
assert_eq!(r.base.y, Some(100.0));
}
other => panic!("expected rect, got {other:?}"),
}
}
#[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.
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] {
PenNode::Path(p) => {
let anchors = p.anchors.as_ref().unwrap();
// M + L + 24 flattened cubic samples = 26 anchors.
assert_eq!(anchors.len(), 26);
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 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] {
PenNode::Path(p) => {
assert_eq!(p.base.y, Some(0.0));
match &p.height {
Some(jian_ops_schema::sizing::SizingBehavior::Number(h)) => {
assert!(
(h - 75.0).abs() < 1e-6,
"frame height should cover the curve peak (~75), got {h}"
);
}
other => panic!("expected numeric height, got {other:?}"),
}
}
other => panic!("expected path, got {other:?}"),
}
}
#[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] {
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));
}
other => panic!("expected path, got {other:?}"),
}
}
#[test]
fn polygon_imports_as_closed_path() {
let svg = r#"<svg><polygon points="0,0 10,0 10,10"/></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] {
PenNode::Path(p) => {
assert_eq!(p.anchors.as_ref().unwrap().len(), 3);
assert_eq!(p.closed, Some(true));
}
other => panic!("expected path, got {other:?}"),
}
}
#[test]
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"/>
<rect x="0" y="0" width="20" height="20"/>
</svg>"#;
let mut s = state_with(vec![]);
let mut next = 1u64;
assert_eq!(s.import_svg(&mut next, svg, (0.0, 0.0)), 1);
}

View file

@ -0,0 +1,110 @@
//! Cubic-Bezier path bounding-box math for SVG import — split out
//! of `svg_import.rs` to keep that file under the 800-line cap.
//!
//! Mirrors `op-pen-loader::path_bounds` (which is private to that
//! crate and not reachable from `op-editor-core`).
use jian_ops_schema::node::PenPathAnchor;
/// Bounding box of a path's anchors **including cubic-bezier curve
/// extrema**. A curve bulges past its anchor points toward the control
/// handles, so an anchor-only box would clip imported curves and give
/// the Path node a too-small frame. Mirrors `op-pen-loader::path_bounds
/// ::path_bounds_from_anchors`. Handles are anchor-relative deltas.
/// Returns `(min_x, min_y, max_x, max_y)`.
pub(crate) fn path_anchor_bounds(anchors: &[PenPathAnchor], closed: bool) -> (f64, f64, f64, f64) {
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 acc = |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 n = anchors.len();
if n == 1 {
acc(anchors[0].x, anchors[0].y);
}
// `n` segments when closed (the last wraps to the first), else
// `n - 1`.
let seg_count = if closed && n > 1 {
n
} else {
n.saturating_sub(1)
};
for i in 0..seg_count {
let from = &anchors[i];
let to = &anchors[(i + 1) % n];
let (p0x, p0y) = (from.x, from.y);
let (p3x, p3y) = (to.x, to.y);
acc(p0x, p0y);
acc(p3x, p3y);
// Reconstruct the control points: handle is a delta from its
// anchor; a missing handle collapses to the anchor (the
// segment is then a straight line, contributing no extrema).
let (p1x, p1y) = match &from.handle_out {
Some(h) => (p0x + h.x, p0y + h.y),
None => (p0x, p0y),
};
let (p2x, p2y) = match &to.handle_in {
Some(h) => (p3x + h.x, p3y + h.y),
None => (p3x, p3y),
};
for t in cubic_extrema_roots(p0x, p1x, p2x, p3x) {
acc(
eval_cubic(p0x, p1x, p2x, p3x, t),
eval_cubic(p0y, p1y, p2y, p3y, t),
);
}
for t in cubic_extrema_roots(p0y, p1y, p2y, p3y) {
acc(
eval_cubic(p0x, p1x, p2x, p3x, t),
eval_cubic(p0y, p1y, p2y, p3y, t),
);
}
}
drop(acc);
if !min_x.is_finite() {
return (0.0, 0.0, 0.0, 0.0);
}
(min_x, min_y, max_x, max_y)
}
/// Real roots of a cubic Bezier's derivative on the open interval
/// `(0, 1)` — the parameter values where the curve reaches an extremum
/// on one axis. Port of `op-pen-loader::path_bounds::
/// cubic_derivative_roots` in `f64`.
fn cubic_extrema_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
}
/// Evaluate one axis of a cubic Bezier at parameter `t`.
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
}

View file

@ -283,6 +283,7 @@ mod tests {
system_prompt: String::new(),
user_message: "hi".into(),
max_output_tokens: 1024,
..Default::default()
};
let deltas: Vec<ChatDelta> = bridge.send(req).collect();
assert!(
@ -317,6 +318,7 @@ mod tests {
system_prompt: String::new(),
user_message: "x".into(),
max_output_tokens: 64,
..Default::default()
})
.collect();
assert!(

View file

@ -145,6 +145,7 @@ pub fn launch_if_pending(host: &mut WidgetHostNative, current: &mut Option<ChatS
system_prompt: String::new(),
user_message: user_text,
max_output_tokens: 4096,
..Default::default()
};
*current = Some(ChatSession::start(provider, req));
true
@ -218,6 +219,7 @@ mod tests {
system_prompt: String::new(),
user_message: "hi".into(),
max_output_tokens: 256,
..Default::default()
},
);
// Drain to completion — poll in a bounded loop so a stuck
@ -246,6 +248,7 @@ mod tests {
system_prompt: String::new(),
user_message: "x".into(),
max_output_tokens: 0,
..Default::default()
},
);
let mut err = None;

View file

@ -742,6 +742,7 @@ mod tests {
system_prompt: String::new(),
user_message: "hi".into(),
max_output_tokens: 64,
..Default::default()
})
.collect();
assert!(

View file

@ -658,270 +658,4 @@ pub(crate) mod test_support {
}
#[cfg(test)]
mod tests {
use super::test_support::{filled_rect, scene_with};
use super::*;
use op_editor_ui::layout_scene::SceneNode;
#[test]
fn raster_format_extension_lookup() {
assert_eq!(RasterFormat::from_extension("png"), Some(RasterFormat::Png));
assert_eq!(
RasterFormat::from_extension("jpg"),
Some(RasterFormat::Jpeg)
);
assert_eq!(
RasterFormat::from_extension("jpeg"),
Some(RasterFormat::Jpeg)
);
assert_eq!(
RasterFormat::from_extension("webp"),
Some(RasterFormat::Webp)
);
assert_eq!(RasterFormat::from_extension("svg"), None);
assert_eq!(RasterFormat::from_extension("gif"), None);
assert_eq!(RasterFormat::from_extension(""), None);
}
#[test]
fn raster_format_jpeg_does_not_support_alpha() {
assert!(RasterFormat::Png.supports_alpha());
assert!(RasterFormat::Webp.supports_alpha());
assert!(!RasterFormat::Jpeg.supports_alpha());
}
#[test]
fn raster_format_quality_matches_ts() {
// TS export-section.tsx: quality = 100 for PNG, 92 for JPEG/WEBP.
assert_eq!(RasterFormat::Png.quality(), 100);
assert_eq!(RasterFormat::Jpeg.quality(), 92);
assert_eq!(RasterFormat::Webp.quality(), 92);
}
#[test]
fn export_raster_writes_png_for_minimal_scene() {
let scene = scene_with(vec![filled_rect(
"n10",
0.0,
0.0,
100.0,
50.0,
Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
},
)]);
let tmp = std::env::temp_dir().join(format!("op-export-test-{}.png", std::process::id()));
let res = export_raster(&scene, &tmp, RasterFormat::Png, 2.0);
assert!(res.is_ok(), "export_raster PNG failed: {res:?}");
let bytes = std::fs::read(&tmp).unwrap();
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
assert_eq!(
&bytes[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_raster_writes_jpeg_with_white_background() {
let scene = scene_with(vec![filled_rect(
"n10",
0.0,
0.0,
80.0,
40.0,
Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
)]);
let tmp = std::env::temp_dir().join(format!("op-export-test-{}.jpg", std::process::id()));
let res = export_raster(&scene, &tmp, RasterFormat::Jpeg, 1.0);
assert!(res.is_ok(), "export_raster JPEG failed: {res:?}");
let bytes = std::fs::read(&tmp).unwrap();
// JPEG SOI marker: FF D8 FF
assert_eq!(&bytes[..3], &[0xFF, 0xD8, 0xFF]);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_raster_scale_clamps_extreme_values() {
let scene = scene_with(vec![filled_rect(
"n10",
0.0,
0.0,
10.0,
10.0,
Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
)]);
// Both extremes should succeed (clamped silently) rather than
// allocating a gigapixel surface or zero-sized output.
let tmp = std::env::temp_dir().join(format!("op-export-clamp-{}.png", std::process::id()));
assert!(export_raster(&scene, &tmp, RasterFormat::Png, 0.001).is_ok());
assert!(export_raster(&scene, &tmp, RasterFormat::Png, 1000.0).is_ok());
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_raster_fails_on_empty_scene() {
let scene = scene_with(Vec::new());
let tmp = std::env::temp_dir().join(format!("op-export-empty-{}.png", std::process::id()));
let res = export_raster(&scene, &tmp, RasterFormat::Png, 1.0);
assert!(res.is_err(), "expected Err on empty scene, got {res:?}");
assert_eq!(res.unwrap_err(), "nothing to export");
}
#[test]
fn export_raster_applies_flex_layout_from_editor_state() {
// A vertical flex frame with a `fill_container`-width child:
// the child's authored width is the collapsed flex token, so
// the resolved width (375 px = the root frame width) only
// appears after jian's flex pass. Export must render the
// RESOLVED geometry — proven here by `page_bounds` over the
// built `LayoutScene` covering the full 375 px root width.
let src = r##"{
"version":"1.0.0",
"pages":[{
"id":"p1","name":"Page 1",
"children":[{
"type":"frame","id":"root","width":375,"height":200,
"layout":"vertical","gap":16,
"children":[
{"type":"rectangle","id":"r1","width":"fill_container","height":40,
"fill":[{"type":"solid","color":"#3366FF"}]}
]
}]
}],
"children":[]
}"##;
let parsed = jian_ops_schema::load_str(src).expect("parse .op fixture");
let state = op_editor_core::EditorState::from_document(parsed.value);
let scene = op_pen_loader::editor_state_to_layout_scene(&state);
// Flex stretched the child to the 375 px root width.
let child = &scene.pages[0].children[0].children[0];
assert_eq!(child.id, "r1");
assert_eq!(
child.bounds.size.x, 375.0,
"fill_container stretched via taffy"
);
// page_bounds covers the resolved 375 px-wide root.
let b = page_bounds(scene.active_page().unwrap()).expect("paintable bounds");
assert_eq!(b.size.x, 375.0, "page bounds reflect resolved layout width");
// And the export succeeds against the layout-resolved scene.
let tmp = std::env::temp_dir().join(format!("op-export-flex-{}.png", std::process::id()));
let res = export_raster(&scene, &tmp, RasterFormat::Png, 1.0);
assert!(res.is_ok(), "flex export failed: {res:?}");
let bytes = std::fs::read(&tmp).unwrap();
assert_eq!(
&bytes[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn page_bounds_covers_layout_resolved_child_geometry() {
use op_editor_ui::layout_scene::NodeKind;
use op_editor_ui::Rect;
// A frame at (10,10) 200x100 with a child the layout pass
// resolved to the frame's full width — page_bounds must cover
// the resolved child bounds, not authored coords.
let mut frame = SceneNode::leaf("frame", NodeKind::Frame);
frame.bounds = Rect::xywh(10.0, 10.0, 200.0, 100.0);
frame.fill = Some(Color {
r: 0.9,
g: 0.9,
b: 0.9,
a: 1.0,
});
let mut child = filled_rect(
"child",
10.0,
10.0,
200.0,
40.0,
Color {
r: 0.1,
g: 0.2,
b: 0.3,
a: 1.0,
},
);
child.bounds = Rect::xywh(10.0, 10.0, 200.0, 40.0);
frame.children = vec![child];
let scene = scene_with(vec![frame]);
let page = scene.active_page().unwrap();
let b = page_bounds(page).expect("page has paintable bounds");
assert_eq!(b.origin.x, 10.0);
assert_eq!(b.origin.y, 10.0);
assert_eq!(b.size.x, 200.0);
assert_eq!(b.size.y, 100.0);
}
#[test]
fn export_node_raster_crops_to_the_named_node() {
// Two side-by-side rects: a 100×50 at origin and a 40×40 far
// away. Exporting only the small node must produce a surface
// cropped to ITS bounds, not the page union.
let grey = Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
};
let scene = scene_with(vec![
filled_rect("big", 0.0, 0.0, 100.0, 50.0, grey),
filled_rect("small", 400.0, 400.0, 40.0, 40.0, grey),
]);
let tmp = std::env::temp_dir().join(format!("op-export-node-{}.png", std::process::id()));
let res = export_node_raster(&scene, "small", &tmp, RasterFormat::Png, 1.0);
assert!(res.is_ok(), "export_node_raster failed: {res:?}");
let bytes = std::fs::read(&tmp).unwrap();
assert_eq!(
&bytes[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
);
// The cropped surface is the 40×40 node + 2×MARGIN, far
// smaller than the ~440 px page union the whole-page export
// would have produced. PNG IHDR carries the dimensions as
// big-endian u32s at byte offsets 16 (width) and 20 (height).
let png_width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let png_height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
let expected = (40.0 + MARGIN * 2.0) as u32;
assert_eq!(png_width, expected);
assert_eq!(png_height, expected);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_node_raster_errors_on_unknown_id() {
let scene = scene_with(vec![filled_rect(
"n10",
0.0,
0.0,
10.0,
10.0,
Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
)]);
let tmp =
std::env::temp_dir().join(format!("op-export-node-miss-{}.png", std::process::id()));
let res = export_node_raster(&scene, "ghost", &tmp, RasterFormat::Png, 1.0);
assert!(res.is_err(), "expected Err on unknown id, got {res:?}");
assert!(res.unwrap_err().contains("not found"));
}
}
mod tests;

View file

@ -0,0 +1,268 @@
//! Raster / SVG / PDF export tests — split out of `export.rs` to
//! keep that file under the 800-line cap. Shared scene-builder
//! helpers live in the sibling `export::test_support` module.
use super::test_support::{filled_rect, scene_with};
use super::*;
use op_editor_ui::layout_scene::SceneNode;
#[test]
fn raster_format_extension_lookup() {
assert_eq!(RasterFormat::from_extension("png"), Some(RasterFormat::Png));
assert_eq!(
RasterFormat::from_extension("jpg"),
Some(RasterFormat::Jpeg)
);
assert_eq!(
RasterFormat::from_extension("jpeg"),
Some(RasterFormat::Jpeg)
);
assert_eq!(
RasterFormat::from_extension("webp"),
Some(RasterFormat::Webp)
);
assert_eq!(RasterFormat::from_extension("svg"), None);
assert_eq!(RasterFormat::from_extension("gif"), None);
assert_eq!(RasterFormat::from_extension(""), None);
}
#[test]
fn raster_format_jpeg_does_not_support_alpha() {
assert!(RasterFormat::Png.supports_alpha());
assert!(RasterFormat::Webp.supports_alpha());
assert!(!RasterFormat::Jpeg.supports_alpha());
}
#[test]
fn raster_format_quality_matches_ts() {
// TS export-section.tsx: quality = 100 for PNG, 92 for JPEG/WEBP.
assert_eq!(RasterFormat::Png.quality(), 100);
assert_eq!(RasterFormat::Jpeg.quality(), 92);
assert_eq!(RasterFormat::Webp.quality(), 92);
}
#[test]
fn export_raster_writes_png_for_minimal_scene() {
let scene = scene_with(vec![filled_rect(
"n10",
0.0,
0.0,
100.0,
50.0,
Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
},
)]);
let tmp = std::env::temp_dir().join(format!("op-export-test-{}.png", std::process::id()));
let res = export_raster(&scene, &tmp, RasterFormat::Png, 2.0);
assert!(res.is_ok(), "export_raster PNG failed: {res:?}");
let bytes = std::fs::read(&tmp).unwrap();
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
assert_eq!(
&bytes[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_raster_writes_jpeg_with_white_background() {
let scene = scene_with(vec![filled_rect(
"n10",
0.0,
0.0,
80.0,
40.0,
Color {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
)]);
let tmp = std::env::temp_dir().join(format!("op-export-test-{}.jpg", std::process::id()));
let res = export_raster(&scene, &tmp, RasterFormat::Jpeg, 1.0);
assert!(res.is_ok(), "export_raster JPEG failed: {res:?}");
let bytes = std::fs::read(&tmp).unwrap();
// JPEG SOI marker: FF D8 FF
assert_eq!(&bytes[..3], &[0xFF, 0xD8, 0xFF]);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_raster_scale_clamps_extreme_values() {
let scene = scene_with(vec![filled_rect(
"n10",
0.0,
0.0,
10.0,
10.0,
Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
)]);
// Both extremes should succeed (clamped silently) rather than
// allocating a gigapixel surface or zero-sized output.
let tmp = std::env::temp_dir().join(format!("op-export-clamp-{}.png", std::process::id()));
assert!(export_raster(&scene, &tmp, RasterFormat::Png, 0.001).is_ok());
assert!(export_raster(&scene, &tmp, RasterFormat::Png, 1000.0).is_ok());
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_raster_fails_on_empty_scene() {
let scene = scene_with(Vec::new());
let tmp = std::env::temp_dir().join(format!("op-export-empty-{}.png", std::process::id()));
let res = export_raster(&scene, &tmp, RasterFormat::Png, 1.0);
assert!(res.is_err(), "expected Err on empty scene, got {res:?}");
assert_eq!(res.unwrap_err(), "nothing to export");
}
#[test]
fn export_raster_applies_flex_layout_from_editor_state() {
// A vertical flex frame with a `fill_container`-width child:
// the child's authored width is the collapsed flex token, so
// the resolved width (375 px = the root frame width) only
// appears after jian's flex pass. Export must render the
// RESOLVED geometry — proven here by `page_bounds` over the
// built `LayoutScene` covering the full 375 px root width.
let src = r##"{
"version":"1.0.0",
"pages":[{
"id":"p1","name":"Page 1",
"children":[{
"type":"frame","id":"root","width":375,"height":200,
"layout":"vertical","gap":16,
"children":[
{"type":"rectangle","id":"r1","width":"fill_container","height":40,
"fill":[{"type":"solid","color":"#3366FF"}]}
]
}]
}],
"children":[]
}"##;
let parsed = jian_ops_schema::load_str(src).expect("parse .op fixture");
let state = op_editor_core::EditorState::from_document(parsed.value);
let scene = op_pen_loader::editor_state_to_layout_scene(&state);
// Flex stretched the child to the 375 px root width.
let child = &scene.pages[0].children[0].children[0];
assert_eq!(child.id, "r1");
assert_eq!(
child.bounds.size.x, 375.0,
"fill_container stretched via taffy"
);
// page_bounds covers the resolved 375 px-wide root.
let b = page_bounds(scene.active_page().unwrap()).expect("paintable bounds");
assert_eq!(b.size.x, 375.0, "page bounds reflect resolved layout width");
// And the export succeeds against the layout-resolved scene.
let tmp = std::env::temp_dir().join(format!("op-export-flex-{}.png", std::process::id()));
let res = export_raster(&scene, &tmp, RasterFormat::Png, 1.0);
assert!(res.is_ok(), "flex export failed: {res:?}");
let bytes = std::fs::read(&tmp).unwrap();
assert_eq!(
&bytes[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn page_bounds_covers_layout_resolved_child_geometry() {
use op_editor_ui::layout_scene::NodeKind;
use op_editor_ui::Rect;
// A frame at (10,10) 200x100 with a child the layout pass
// resolved to the frame's full width — page_bounds must cover
// the resolved child bounds, not authored coords.
let mut frame = SceneNode::leaf("frame", NodeKind::Frame);
frame.bounds = Rect::xywh(10.0, 10.0, 200.0, 100.0);
frame.fill = Some(Color {
r: 0.9,
g: 0.9,
b: 0.9,
a: 1.0,
});
let mut child = filled_rect(
"child",
10.0,
10.0,
200.0,
40.0,
Color {
r: 0.1,
g: 0.2,
b: 0.3,
a: 1.0,
},
);
child.bounds = Rect::xywh(10.0, 10.0, 200.0, 40.0);
frame.children = vec![child];
let scene = scene_with(vec![frame]);
let page = scene.active_page().unwrap();
let b = page_bounds(page).expect("page has paintable bounds");
assert_eq!(b.origin.x, 10.0);
assert_eq!(b.origin.y, 10.0);
assert_eq!(b.size.x, 200.0);
assert_eq!(b.size.y, 100.0);
}
#[test]
fn export_node_raster_crops_to_the_named_node() {
// Two side-by-side rects: a 100×50 at origin and a 40×40 far
// away. Exporting only the small node must produce a surface
// cropped to ITS bounds, not the page union.
let grey = Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
};
let scene = scene_with(vec![
filled_rect("big", 0.0, 0.0, 100.0, 50.0, grey),
filled_rect("small", 400.0, 400.0, 40.0, 40.0, grey),
]);
let tmp = std::env::temp_dir().join(format!("op-export-node-{}.png", std::process::id()));
let res = export_node_raster(&scene, "small", &tmp, RasterFormat::Png, 1.0);
assert!(res.is_ok(), "export_node_raster failed: {res:?}");
let bytes = std::fs::read(&tmp).unwrap();
assert_eq!(
&bytes[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
);
// The cropped surface is the 40×40 node + 2×MARGIN, far
// smaller than the ~440 px page union the whole-page export
// would have produced. PNG IHDR carries the dimensions as
// big-endian u32s at byte offsets 16 (width) and 20 (height).
let png_width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
let png_height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
let expected = (40.0 + MARGIN * 2.0) as u32;
assert_eq!(png_width, expected);
assert_eq!(png_height, expected);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn export_node_raster_errors_on_unknown_id() {
let scene = scene_with(vec![filled_rect(
"n10",
0.0,
0.0,
10.0,
10.0,
Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
)]);
let tmp = std::env::temp_dir().join(format!("op-export-node-miss-{}.png", std::process::id()));
let res = export_node_raster(&scene, "ghost", &tmp, RasterFormat::Png, 1.0);
assert!(res.is_err(), "expected Err on unknown id, got {res:?}");
assert!(res.unwrap_err().contains("not found"));
}

View file

@ -750,6 +750,30 @@ fn main() {
}
}
}
if first == "--mcp-http" {
// `--mcp-http <port> <path>` serves MCP over HTTP instead
// of stdio — for HTTP MCP clients (TS pen-mcp ships a
// Streamable-HTTP transport alongside stdio).
let Some(port_arg) = args.next() else {
eprintln!("openpencil-desktop --mcp-http: missing <port> arg");
std::process::exit(2);
};
let Ok(port) = port_arg.parse::<u16>() else {
eprintln!("openpencil-desktop --mcp-http: <port> must be a u16, got {port_arg:?}");
std::process::exit(2);
};
let Some(path) = args.next() else {
eprintln!("openpencil-desktop --mcp-http: missing <path> arg");
std::process::exit(2);
};
match mcp_serve::run_http(PathBuf::from(path), port) {
Ok(()) => return,
Err(e) => {
eprintln!("openpencil-desktop --mcp-http: {e}");
std::process::exit(1);
}
}
}
// Unknown leading arg → fall through to GUI mode for now
// (a future patch may add `--help` etc).
}

View file

@ -40,21 +40,22 @@ use op_mcp::{
get_active_theme_snapshot, get_canvas_bounds_snapshot, get_component_snapshot,
get_history_depth_snapshot, get_node_children_snapshot, get_node_parent_snapshot,
get_node_snapshot, get_selection_set_snapshot, get_viewport_snapshot, group_selected_snapshot,
insert_node_snapshot, instantiate_component_snapshot, list_components_snapshot,
list_node_kinds_snapshot, list_pages_snapshot, list_variables_snapshot, move_node_snapshot,
nudge_selected_snapshot, paste_clipboard_snapshot, redo_snapshot, rename_component_snapshot,
rename_page_snapshot, rename_variable_snapshot, reorder_page_snapshot,
reorder_selected_snapshot, replace_node_snapshot, run_stdio_with_applier, selection_snapshot,
set_active_axis_value_snapshot, set_active_page_snapshot, set_active_tool_snapshot,
set_ellipse_arc_snapshot, set_node_collapsed_snapshot, set_node_corner_radius_snapshot,
set_node_fill_hex_snapshot, set_node_flip_snapshot, set_node_font_size_snapshot,
set_node_font_weight_snapshot, set_node_hidden_snapshot, set_node_locked_snapshot,
set_node_name_snapshot, set_node_rotation_snapshot, set_node_stroke_hex_snapshot,
set_node_stroke_width_snapshot, set_node_text_snapshot, set_selection_set_snapshot,
set_selection_snapshot, set_variable_boolean_snapshot, set_variable_color_snapshot,
set_variable_number_snapshot, set_variable_string_snapshot, set_viewport_snapshot,
snapshot_layout_snapshot, toggle_node_selection_snapshot, undo_snapshot,
ungroup_selected_snapshot, update_node_snapshot, ToolRegistry,
import_svg_snapshot, insert_node_snapshot, instantiate_component_snapshot,
list_components_snapshot, list_node_kinds_snapshot, list_pages_snapshot,
list_variables_snapshot, move_node_snapshot, nudge_selected_snapshot, paste_clipboard_snapshot,
redo_snapshot, rename_component_snapshot, rename_page_snapshot, rename_variable_snapshot,
reorder_page_snapshot, reorder_selected_snapshot, replace_node_snapshot,
run_stdio_with_applier, selection_snapshot, set_active_axis_value_snapshot,
set_active_page_snapshot, set_active_tool_snapshot, set_ellipse_arc_snapshot,
set_node_collapsed_snapshot, set_node_corner_radius_snapshot, set_node_fill_hex_snapshot,
set_node_flip_snapshot, set_node_font_size_snapshot, set_node_font_weight_snapshot,
set_node_hidden_snapshot, set_node_locked_snapshot, set_node_name_snapshot,
set_node_rotation_snapshot, set_node_stroke_hex_snapshot, set_node_stroke_width_snapshot,
set_node_text_snapshot, set_selection_set_snapshot, set_selection_snapshot,
set_variable_boolean_snapshot, set_variable_color_snapshot, set_variable_number_snapshot,
set_variable_string_snapshot, set_viewport_snapshot, snapshot_layout_snapshot,
toggle_node_selection_snapshot, undo_snapshot, ungroup_selected_snapshot, update_node_snapshot,
ToolRegistry,
};
/// Load a `.op` file into an `EditorState`. The `.op` format is plain
@ -75,17 +76,76 @@ fn save_editor_state(state: &EditorState, path: &std::path::Path) -> Result<(),
std::fs::write(path, json).map_err(|e| format!("write {}: {e}", path.display()))
}
/// Process one JSON-RPC message line against the editor state,
/// returning the response line to send back — `None` for a
/// notification (no response per spec). Shared by the stdio and HTTP
/// transports so both speak an identical protocol.
fn process_message(
state: &mut EditorState,
path: &std::path::Path,
line: &str,
) -> Result<Option<String>, String> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(None);
}
// MCP handshake / discovery methods short-circuit the tool
// dispatcher — detected via a cheap method-field sniff so JSON
// parsing stays confined to the wire parser.
match sniff_method(trimmed).as_deref() {
Some("initialize") => {
return Ok(sniff_id_raw(trimmed).map(|id| initialize_response(&id)));
}
Some("tools/list") => {
return Ok(sniff_id_raw(trimmed).map(|id| tools_list_response(&id)));
}
Some("notifications/initialized") | Some("initialized") => {
return Ok(None); // notification — no response required
}
Some("ping") => {
return Ok(sniff_id_raw(trimmed).map(|id| ping_response(&id)));
}
_ => {}
}
// Fall through: tools/call or legacy direct dispatch. The
// registry snapshots `state` at build time, so it no longer
// borrows it once the applier closure mutates it.
let registry = rebuild_registry(state);
let mut applier_failed: Option<String> = None;
let mut out: Vec<u8> = Vec::new();
{
let mut input = std::io::Cursor::new(line.as_bytes());
run_stdio_with_applier(&registry, &mut input, &mut out, |cmd| {
// `EditorState::apply` runs the pre-validate-then-mutate
// discipline; `false` means the command rejected and the
// document was NOT changed.
if !state.apply(cmd.clone()) {
return false;
}
if let Err(e) = save_editor_state(state, path) {
applier_failed = Some(format!("save failed: {e}"));
return false;
}
true
})
.map_err(|e| format!("dispatch: {e}"))?;
}
if let Some(msg) = applier_failed {
eprintln!("openpencil-desktop mcp: {msg}");
}
let resp = String::from_utf8_lossy(&out).trim().to_string();
Ok((!resp.is_empty()).then_some(resp))
}
/// Run the stdio MCP server against `path`. Returns Ok(()) on EOF,
/// Err on unrecoverable IO. Blocks the calling thread for the
/// lifetime of the stdio connection.
pub fn run(path: PathBuf) -> Result<(), String> {
let mut state = load_editor_state(&path)?;
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let mut reader = BufReader::new(stdin.lock());
let mut writer = BufWriter::new(stdout.lock());
let mut line = String::new();
loop {
line.clear();
@ -95,80 +155,103 @@ pub fn run(path: PathBuf) -> Result<(), String> {
if n == 0 {
return Ok(()); // EOF
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// MCP handshake / discovery methods short-circuit the
// tool dispatcher. Detect them via a cheap method-field
// sniff so we don't need to drag JSON parsing in here —
// shell-core's parser stays the single source for the
// `tools/call` envelope.
let method = sniff_method(trimmed);
match method.as_deref() {
Some("initialize") => {
if let Some(id_raw) = sniff_id_raw(trimmed) {
writeln!(writer, "{}", initialize_response(&id_raw))
.map_err(|e| format!("stdout write: {e}"))?;
writer.flush().map_err(|e| format!("stdout flush: {e}"))?;
}
continue;
}
Some("tools/list") => {
if let Some(id_raw) = sniff_id_raw(trimmed) {
writeln!(writer, "{}", tools_list_response(&id_raw))
.map_err(|e| format!("stdout write: {e}"))?;
writer.flush().map_err(|e| format!("stdout flush: {e}"))?;
}
continue;
}
Some("notifications/initialized") | Some("initialized") => {
// Notification — no response required by spec.
continue;
}
Some("ping") => {
if let Some(id_raw) = sniff_id_raw(trimmed) {
writeln!(writer, "{}", ping_response(&id_raw))
.map_err(|e| format!("stdout write: {e}"))?;
writer.flush().map_err(|e| format!("stdout flush: {e}"))?;
}
continue;
}
_ => {}
}
// Fall through: tools/call or legacy direct dispatch.
let registry = rebuild_registry(&state);
let mut applier_failed: Option<String> = None;
let path_for_save = path.clone();
{
let state_ref = &mut state;
let path_ref = &path_for_save;
let applier_failed_ref = &mut applier_failed;
let mut input = std::io::Cursor::new(line.as_bytes());
run_stdio_with_applier(&registry, &mut input, &mut writer, |cmd| {
// `EditorState::apply` runs the pre-validate-then-mutate
// discipline; `false` means the command rejected and
// the document was NOT changed.
if !state_ref.apply(cmd.clone()) {
return false;
}
if let Err(e) = save_editor_state(state_ref, path_ref) {
*applier_failed_ref = Some(format!("save failed: {e}"));
return false;
}
true
})
.map_err(|e| format!("dispatch: {e}"))?;
}
writer.flush().map_err(|e| format!("stdout flush: {e}"))?;
if let Some(msg) = applier_failed {
eprintln!("openpencil-desktop --mcp: {msg}");
if let Some(resp) = process_message(&mut state, &path, &line)? {
writeln!(writer, "{resp}").map_err(|e| format!("stdout write: {e}"))?;
writer.flush().map_err(|e| format!("stdout flush: {e}"))?;
}
}
}
/// Run the MCP server over HTTP on `127.0.0.1:port`. Each connection
/// carries one JSON-RPC message POSTed to any path; the response is
/// the JSON-RPC reply as `application/json`. A minimal non-streaming
/// Streamable-HTTP transport — enough for HTTP MCP clients that POST
/// one request per connection. Blocks for the listener's lifetime.
pub fn run_http(path: PathBuf, port: u16) -> Result<(), String> {
let mut state = load_editor_state(&path)?;
let listener = std::net::TcpListener::bind(("127.0.0.1", port))
.map_err(|e| format!("bind 127.0.0.1:{port}: {e}"))?;
eprintln!("openpencil-desktop --mcp-http: listening on 127.0.0.1:{port}");
for stream in listener.incoming() {
match stream {
Ok(mut s) => {
if let Err(e) = serve_http_connection(&mut s, &mut state, &path) {
eprintln!("openpencil-desktop --mcp-http: {e}");
}
}
Err(e) => eprintln!("openpencil-desktop --mcp-http: accept: {e}"),
}
}
Ok(())
}
/// Handle one HTTP connection: parse the request, run its JSON-RPC
/// body through [`process_message`], write the JSON-RPC reply back as
/// an `application/json` response. Generic over the stream so it is
/// unit-testable without a real socket.
fn serve_http_connection<S: std::io::Read + std::io::Write>(
stream: &mut S,
state: &mut EditorState,
path: &std::path::Path,
) -> Result<(), String> {
let body = read_http_request_body(stream)?;
let response = process_message(state, path, &body)?.unwrap_or_default();
let http = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
response.len(),
response
);
stream
.write_all(http.as_bytes())
.map_err(|e| format!("http write: {e}"))?;
stream.flush().map_err(|e| format!("http flush: {e}"))
}
/// Read an HTTP request off `stream` and return its body. Reads to
/// the `\r\n\r\n` header terminator, parses `Content-Length`, then
/// reads exactly that many body bytes. The header block is capped so
/// a malformed peer can't exhaust memory.
fn read_http_request_body<S: std::io::Read>(stream: &mut S) -> Result<String, String> {
const MAX_HEADER: usize = 64 * 1024;
const MAX_BODY: usize = 8 * 1024 * 1024;
let mut head: Vec<u8> = Vec::new();
let mut byte = [0u8; 1];
loop {
let n = stream
.read(&mut byte)
.map_err(|e| format!("http read: {e}"))?;
if n == 0 {
return Err("connection closed before headers completed".into());
}
head.push(byte[0]);
if head.ends_with(b"\r\n\r\n") {
break;
}
if head.len() > MAX_HEADER {
return Err("request headers exceed 64 KiB".into());
}
}
let headers = String::from_utf8_lossy(&head);
let content_length = headers
.lines()
.find_map(|l| {
let l = l.trim();
(l.len() >= 15 && l[..15].eq_ignore_ascii_case("content-length:"))
.then(|| l[15..].trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
if content_length > MAX_BODY {
return Err("request body exceeds 8 MiB".into());
}
let mut body = vec![0u8; content_length];
stream
.read_exact(&mut body)
.map_err(|e| format!("http body read: {e}"))?;
Ok(String::from_utf8_lossy(&body).into_owned())
}
/// Re-build the registry against the latest editor state so read-tool
/// snapshots reflect every prior write command's mutations.
fn rebuild_registry(doc: &EditorState) -> ToolRegistry {
@ -227,6 +310,7 @@ fn rebuild_registry(doc: &EditorState) -> ToolRegistry {
r.register(Box::new(set_variable_color_snapshot(doc)));
r.register(Box::new(set_active_axis_value_snapshot(doc)));
r.register(Box::new(insert_node_snapshot()));
r.register(Box::new(import_svg_snapshot()));
r.register(Box::new(update_node_snapshot()));
r.register(Box::new(delete_node_snapshot()));
r.register(Box::new(move_node_snapshot()));
@ -525,6 +609,7 @@ const TOOL_SCHEMAS: &[&str] = &[
r#"{"name":"set_active_axis_value","description":"Pin a theme axis to one of its allowed values.","inputSchema":{"type":"object","properties":{"axis":{"type":"string"},"value":{"type":"string"}},"required":["axis","value"]}}"#,
r#"{"name":"insert_node","description":"Create a new leaf node on the active page.","inputSchema":{"type":"object","properties":{"kind":{"type":"string","enum":["frame","group","rect","ellipse","polygon","line","text","path"]},"name":{"type":"string"},"x":{"type":"string"},"y":{"type":"string"},"width":{"type":"string"},"height":{"type":"string"},"fill_hex":{"type":"string"}},"required":["kind","name","x","y","width","height"]}}"#,
r#"{"name":"update_node","description":"Patch fields on an existing node. Pass any subset of x/y/width/height/name/fill_hex.","inputSchema":{"type":"object","properties":{"node_id":{"type":"string"},"x":{"type":"string"},"y":{"type":"string"},"width":{"type":"string"},"height":{"type":"string"},"name":{"type":"string"},"fill_hex":{"type":"string"}},"required":["node_id"]}}"#,
r#"{"name":"import_svg","description":"Parse an SVG document and insert the resulting nodes on the active page. Supports rect/circle/ellipse/line/polyline/polygon and path (M/L/H/V/C/S/Q/T/Z); <g>/transforms/CSS are skipped. x/y (optional, default 0) offset the imported nodes in doc-px.","inputSchema":{"type":"object","properties":{"svg":{"type":"string","description":"SVG document text"},"x":{"type":"string","description":"i32 doc-px x offset (default 0)"},"y":{"type":"string","description":"i32 doc-px y offset (default 0)"}},"required":["svg"]}}"#,
r#"{"name":"delete_node","description":"Remove a node + descendants from its parent.","inputSchema":{"type":"object","properties":{"node_id":{"type":"string"}},"required":["node_id"]}}"#,
r#"{"name":"move_node","description":"Reparent a node. target_parent_id=0 puts it at the active page root.","inputSchema":{"type":"object","properties":{"node_id":{"type":"string"},"target_parent_id":{"type":"string"}},"required":["node_id","target_parent_id"]}}"#,
r#"{"name":"copy_node","description":"Deep-clone a subtree with fresh ids under a new parent.","inputSchema":{"type":"object","properties":{"node_id":{"type":"string"},"target_parent_id":{"type":"string"}},"required":["node_id","target_parent_id"]}}"#,
@ -532,140 +617,4 @@ const TOOL_SCHEMAS: &[&str] = &[
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sniff_method_walks_top_level() {
assert_eq!(
sniff_method(r#"{"id":1,"method":"initialize","params":{}}"#),
Some("initialize".into())
);
assert_eq!(
sniff_method(r#"{"id":1,"method":"tools/call","params":{"name":"x"}}"#),
Some("tools/call".into())
);
// Nested `method` keys must not shadow the real one.
assert_eq!(
sniff_method(r#"{"id":1,"method":"tools/list","params":{"method":"fake"}}"#),
Some("tools/list".into())
);
assert_eq!(sniff_method("not even json"), None);
}
#[test]
fn sniff_id_raw_preserves_type() {
assert_eq!(sniff_id_raw(r#"{"id":42,"method":"x"}"#), Some("42".into()));
assert_eq!(
sniff_id_raw(r#"{"id":"abc","method":"x"}"#),
Some(r#""abc""#.into())
);
}
#[test]
fn initialize_response_includes_protocol_and_capabilities() {
let r = initialize_response("7");
assert!(r.contains(r#""id":7"#));
assert!(r.contains(r#""protocolVersion""#));
assert!(r.contains(r#""tools""#));
assert!(r.contains(r#""serverInfo""#));
}
#[test]
fn tools_list_response_includes_all_registered_tools() {
let r = tools_list_response("3");
// Exact-count assertion: any tool added without
// updating this test will trip the count first. Codex
// stop-gate: previous `contains`-only checks would have
// silently passed if a new tool slipped into TOOL_SCHEMAS
// without being added to the list below.
assert_eq!(
TOOL_SCHEMAS.len(),
79,
"tools/list catalog count must match the registered tools — add the new tool to this test"
);
for name in [
"get_document_info",
"get_selection",
"get_node",
"list_pages",
"list_variables",
"get_active_theme",
"list_components",
"get_component",
"snapshot_layout",
"get_canvas_bounds",
"find_node_by_name",
"get_node_parent",
"get_node_children",
"count_nodes",
"list_node_kinds",
"get_history_depth",
"get_viewport",
"get_selection_set",
"clear_selection",
"set_selection",
"set_viewport",
"set_node_hidden",
"set_node_locked",
"set_node_collapsed",
"set_active_tool",
"undo",
"redo",
"duplicate_selected",
"delete_selected",
"nudge_selected",
"group_selected",
"ungroup_selected",
"reorder_selected",
"set_node_rotation",
"set_node_text",
"set_node_corner_radius",
"set_node_font_size",
"set_node_font_weight",
"set_node_stroke_hex",
"set_node_stroke_width",
"align_selected",
"set_node_fill_hex",
"set_node_flip",
"set_ellipse_arc",
"set_node_name",
"set_selection_set",
"toggle_node_selection",
"cycle_active_axis_value",
"copy_selected",
"cut_selected",
"paste_clipboard",
"instantiate_component",
"create_component",
"delete_component",
"rename_component",
"set_active_page",
"add_page",
"rename_page",
"delete_page",
"duplicate_page",
"reorder_page",
"set_variable_color",
"set_active_axis_value",
"insert_node",
"update_node",
"delete_node",
"move_node",
"copy_node",
"replace_node",
"batch_design",
"set_variable_number",
"set_variable_string",
"set_variable_boolean",
"create_variable",
"delete_variable",
"rename_variable",
"design_skeleton",
"design_content",
"design_refine",
] {
assert!(r.contains(name), "tools/list must include {name}: {r}");
}
}
}
mod tests;

View file

@ -0,0 +1,203 @@
//! `mcp_serve` tests — split out of `mcp_serve.rs` to keep that
//! file under the 800-line cap.
#![cfg(test)]
use super::*;
#[test]
fn sniff_method_walks_top_level() {
assert_eq!(
sniff_method(r#"{"id":1,"method":"initialize","params":{}}"#),
Some("initialize".into())
);
assert_eq!(
sniff_method(r#"{"id":1,"method":"tools/call","params":{"name":"x"}}"#),
Some("tools/call".into())
);
// Nested `method` keys must not shadow the real one.
assert_eq!(
sniff_method(r#"{"id":1,"method":"tools/list","params":{"method":"fake"}}"#),
Some("tools/list".into())
);
assert_eq!(sniff_method("not even json"), None);
}
#[test]
fn sniff_id_raw_preserves_type() {
assert_eq!(sniff_id_raw(r#"{"id":42,"method":"x"}"#), Some("42".into()));
assert_eq!(
sniff_id_raw(r#"{"id":"abc","method":"x"}"#),
Some(r#""abc""#.into())
);
}
#[test]
fn initialize_response_includes_protocol_and_capabilities() {
let r = initialize_response("7");
assert!(r.contains(r#""id":7"#));
assert!(r.contains(r#""protocolVersion""#));
assert!(r.contains(r#""tools""#));
assert!(r.contains(r#""serverInfo""#));
}
#[test]
fn tools_list_response_includes_all_registered_tools() {
let r = tools_list_response("3");
// Exact-count assertion: any tool added without
// updating this test will trip the count first. Codex
// stop-gate: previous `contains`-only checks would have
// silently passed if a new tool slipped into TOOL_SCHEMAS
// without being added to the list below.
assert_eq!(
TOOL_SCHEMAS.len(),
80,
"tools/list catalog count must match the registered tools — add the new tool to this test"
);
for name in [
"get_document_info",
"get_selection",
"get_node",
"list_pages",
"list_variables",
"get_active_theme",
"list_components",
"get_component",
"snapshot_layout",
"get_canvas_bounds",
"find_node_by_name",
"get_node_parent",
"get_node_children",
"count_nodes",
"list_node_kinds",
"get_history_depth",
"get_viewport",
"get_selection_set",
"clear_selection",
"set_selection",
"set_viewport",
"set_node_hidden",
"set_node_locked",
"set_node_collapsed",
"set_active_tool",
"undo",
"redo",
"duplicate_selected",
"delete_selected",
"nudge_selected",
"group_selected",
"ungroup_selected",
"reorder_selected",
"set_node_rotation",
"set_node_text",
"set_node_corner_radius",
"set_node_font_size",
"set_node_font_weight",
"set_node_stroke_hex",
"set_node_stroke_width",
"align_selected",
"set_node_fill_hex",
"set_node_flip",
"set_ellipse_arc",
"set_node_name",
"set_selection_set",
"toggle_node_selection",
"cycle_active_axis_value",
"copy_selected",
"cut_selected",
"paste_clipboard",
"instantiate_component",
"create_component",
"delete_component",
"rename_component",
"set_active_page",
"add_page",
"rename_page",
"delete_page",
"duplicate_page",
"reorder_page",
"set_variable_color",
"set_active_axis_value",
"insert_node",
"import_svg",
"update_node",
"delete_node",
"move_node",
"copy_node",
"replace_node",
"batch_design",
"set_variable_number",
"set_variable_string",
"set_variable_boolean",
"create_variable",
"delete_variable",
"rename_variable",
"design_skeleton",
"design_content",
"design_refine",
] {
assert!(r.contains(name), "tools/list must include {name}: {r}");
}
}
/// In-memory `Read + Write` stand-in for a `TcpStream` so the HTTP
/// transport can be exercised without a real socket.
struct MockStream {
input: std::io::Cursor<Vec<u8>>,
output: Vec<u8>,
}
impl std::io::Read for MockStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.input.read(buf)
}
}
impl std::io::Write for MockStream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.output.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn http_request_body_reads_exactly_content_length() {
// Trailing bytes past Content-Length must NOT leak into the body.
let body = r#"{"method":"ping"}"#;
let request = format!(
"POST /mcp HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n{body}TRAILING-IGNORED",
body.len()
);
let mut cur = std::io::Cursor::new(request.into_bytes());
assert_eq!(read_http_request_body(&mut cur).unwrap(), body);
}
#[test]
fn http_transport_serves_initialize() {
let rpc = r#"{"jsonrpc":"2.0","id":7,"method":"initialize","params":{}}"#;
let request = format!(
"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: {}\r\n\r\n{rpc}",
rpc.len()
);
let mut stream = MockStream {
input: std::io::Cursor::new(request.into_bytes()),
output: Vec::new(),
};
let mut state = EditorState::new();
serve_http_connection(
&mut stream,
&mut state,
std::path::Path::new("/tmp/unused.op"),
)
.expect("serve_http_connection");
let resp = String::from_utf8(stream.output).unwrap();
assert!(resp.starts_with("HTTP/1.1 200 OK"), "status line: {resp}");
assert!(resp.contains("Content-Type: application/json"));
// The JSON-RPC initialize reply carries the protocol handshake +
// the request id, proving the body round-tripped over HTTP.
assert!(resp.contains(r#""protocolVersion""#), "body: {resp}");
assert!(resp.contains(r#""id":7"#), "body: {resp}");
}

View file

@ -127,10 +127,10 @@ pub use tools::{
NodeRecord, SnapshotLayout, VariableRecord,
};
pub use write_tools::{
copy_node_snapshot, delete_node_snapshot, insert_node_snapshot, move_node_snapshot,
replace_node_snapshot, set_active_axis_value_snapshot, set_variable_color_snapshot,
update_node_snapshot, CopyNode, DeleteNode, InsertNode, MoveNode, ReplaceNode,
SetActiveAxisValue, SetVariableColor, UpdateNode,
copy_node_snapshot, delete_node_snapshot, import_svg_snapshot, insert_node_snapshot,
move_node_snapshot, replace_node_snapshot, set_active_axis_value_snapshot,
set_variable_color_snapshot, update_node_snapshot, CopyNode, DeleteNode, ImportSvg, InsertNode,
MoveNode, ReplaceNode, SetActiveAxisValue, SetVariableColor, UpdateNode,
};
/// JSON-RPC-style request id. Strings + integers both supported by

View file

@ -556,6 +556,55 @@ pub fn set_variable_color_snapshot(state: &EditorState) -> SetVariableColor {
SetVariableColor { known_colors }
}
/// First-party `import_svg` tool — parse an SVG document + insert the
/// resulting nodes on the active page. `x` / `y` (optional, default 0)
/// offset the imported nodes in doc-px.
pub struct ImportSvg;
impl McpTool for ImportSvg {
fn name(&self) -> &str {
"import_svg"
}
fn call(&self, args: &BTreeMap<String, String>) -> ToolOutcome {
let Some(svg) = args.get("svg") else {
return ToolOutcome::Err(
ToolErrorCode::MissingArgument,
"svg is required (an SVG document string)".into(),
);
};
if svg.trim().is_empty() {
return ToolOutcome::Err(
ToolErrorCode::InvalidArgument,
"svg must not be empty".into(),
);
}
// `x` / `y` are optional doc-px offsets — absent ⇒ 0, a
// malformed value rejects so the LLM sees a typed error.
let x = match parse_opt_i32(args, "x") {
Ok(v) => v.unwrap_or(0),
Err(e) => return ToolOutcome::Err(ToolErrorCode::InvalidArgument, format!("x: {e}")),
};
let y = match parse_opt_i32(args, "y") {
Ok(v) => v.unwrap_or(0),
Err(e) => return ToolOutcome::Err(ToolErrorCode::InvalidArgument, format!("y: {e}")),
};
let mut out = BTreeMap::new();
out.insert("wrote".into(), "true".into());
ToolOutcome::OkWithCommand(
out,
EditorCommand::ImportSvg {
svg: svg.clone(),
x,
y,
},
)
}
}
pub fn import_svg_snapshot() -> ImportSvg {
ImportSvg
}
/// `#rgb`, `#rrggbb`, `#rrggbbaa` — requires the leading `#`.
pub(super) fn validate_hex(s: &str) -> bool {
let Some(rest) = s.trim().strip_prefix('#') else {

View file

@ -494,6 +494,11 @@ fn path_to_payload(n: &PathNode) -> NodePayload {
p.fill_type = first_fill_type(n.fill.as_deref());
p.stroke = stroke_to_payload(n.stroke.as_ref());
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.
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 anchor bbox span (max - min) — using

View file

@ -88,6 +88,8 @@ fn path_bounds_include_bezier_curve_extrema() {
}"##;
let r = load(src);
let n = &r.payload.pages[0].children[0];
// `points` stays 1:1 with the two schema anchors; the curve-aware
// bounds come from `absolutize_path_anchors`'s native span.
let p0 = n.points[0];
let p1 = n.points[1];
// x span [0, 100] → sx=1. Anchors keep their x.