mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
43a426a6fb
commit
d297752aac
22 changed files with 2170 additions and 496 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -2287,6 +2287,10 @@ dependencies = [
|
|||
"op-host-web",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "op-cli"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "op-codegen"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -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
17
crates/op-cli/Cargo.toml
Normal 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
314
crates/op-cli/src/main.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
743
crates/op-editor-core/src/svg_import.rs
Normal file
743
crates/op-editor-core/src/svg_import.rs
Normal 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())
|
||||
}
|
||||
160
crates/op-editor-core/src/svg_import_tests.rs
Normal file
160
crates/op-editor-core/src/svg_import_tests.rs
Normal 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);
|
||||
}
|
||||
110
crates/op-editor-core/src/svg_path_bounds.rs
Normal file
110
crates/op-editor-core/src/svg_path_bounds.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -742,6 +742,7 @@ mod tests {
|
|||
system_prompt: String::new(),
|
||||
user_message: "hi".into(),
|
||||
max_output_tokens: 64,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
268
crates/op-host-desktop/src/export/tests.rs
Normal file
268
crates/op-host-desktop/src/export/tests.rs
Normal 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"));
|
||||
}
|
||||
|
|
@ -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).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(®istry, &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(®istry, &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;
|
||||
|
|
|
|||
203
crates/op-host-desktop/src/mcp_serve/tests.rs
Normal file
203
crates/op-host-desktop/src/mcp_serve/tests.rs
Normal 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}");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue