Merge wp/M1: one engine loader/encoder for every frontend and export correctness (M4.12, M4.13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
fb8c41d8e3
|
|
@ -4128,6 +4128,8 @@ dependencies = [
|
|||
name = "vectorcraft-engine"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
|
|||
|
|
@ -41,12 +41,13 @@ proptest = "1"
|
|||
|
||||
# rendering / formats
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
linesweeper = "0.4"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli run [--in file.vectorcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0] [--outline-text]
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
|
|
@ -28,21 +28,23 @@ USAGE:
|
|||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (.vectorcraft/.svg) or start a new document, run commands in
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I] [--outline-text]
|
||||
Open IN (.vectorcraft, .svg, .pdf/.ai, images) and export OUT by extension (.svg, .pdf, .png,
|
||||
.jpg, .webp, .vectorcraft). Live effects are kept; --outline-text writes SVG text as paths.
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
|
||||
Open IN (any readable format) and export OUT by extension (.svg, .pdf, .png, .jpg, .webp,
|
||||
.vectorcraft). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, the other formats the first. Live effects are kept;
|
||||
--outline-text writes SVG text as paths.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts.
|
||||
|
||||
vectorcraft-cli bench FILE [--size WxH] [--iters N]
|
||||
Render FILE (.vectorcraft/.svg) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
vectorcraft-cli perf [--paths N]
|
||||
|
|
@ -50,6 +52,11 @@ USAGE:
|
|||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads.
|
||||
fn usage() -> String {
|
||||
format!("{USAGE}\nReadable formats: .{}\n", vectorcraft_engine::cmd::fileio::OPEN_EXTS.join(", ."))
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
|
|
@ -61,14 +68,14 @@ fn main() -> ExitCode {
|
|||
Some("bench") => bench(&args[1..]),
|
||||
Some("perf") => perf::run(&args[1..]),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
print!("{}", usage());
|
||||
Ok(())
|
||||
}
|
||||
Some("-V" | "--version") => {
|
||||
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")),
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
|
|
@ -119,12 +126,13 @@ fn commands() -> Result<(), String> {
|
|||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let mut files = vec![];
|
||||
let (mut scale, mut artboard, mut outline_text) = (1.0f64, 0u64, false);
|
||||
let (mut scale, mut artboard, mut range, mut outline_text) = (1.0f64, None::<u64>, None::<String>, false);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--scale" | "-s" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
|
||||
"--artboard" | "-a" => artboard = it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?,
|
||||
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
|
||||
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
|
||||
"--outline-text" => outline_text = true,
|
||||
f => files.push(f.to_string()),
|
||||
}
|
||||
|
|
@ -135,7 +143,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
|||
let r = h
|
||||
.call(
|
||||
"engine.execute",
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "outlineText": outline_text}}),
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
|
||||
)
|
||||
.map_err(|e| format!("export {output}: {e}"))?;
|
||||
println!("{r}");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
//! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn ok(args: &[&str]) -> String {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
}
|
||||
|
||||
/// `run` a new `width`×`height` document with a rectangle and export it to `out`.
|
||||
fn make(out: &str, width: u32, height: u32) {
|
||||
ok(&[
|
||||
"run",
|
||||
"--cmd",
|
||||
"file.new",
|
||||
"--params",
|
||||
&format!(r#"{{"width":{width},"height":{height},"artboards":2}}"#),
|
||||
"--cmd",
|
||||
"shape.rectangle",
|
||||
"--params",
|
||||
r#"{"x":1,"y":1,"width":2,"height":1}"#,
|
||||
"--export",
|
||||
out,
|
||||
]);
|
||||
}
|
||||
|
||||
fn info(file: &str) -> Value {
|
||||
serde_json::from_str(&ok(&["info", file])).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_pdf_to_svg_and_info_on_ai() {
|
||||
let pdf = tmp("in.pdf");
|
||||
make(&pdf, 120, 80);
|
||||
let svg = tmp("out.svg");
|
||||
ok(&["convert", &pdf, &svg]);
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
|
||||
|
||||
let ai = tmp("in.ai");
|
||||
std::fs::copy(&pdf, &ai).unwrap();
|
||||
let v = info(&ai);
|
||||
assert_eq!(v["artboards"].as_array().unwrap().len(), 2, "{v}");
|
||||
assert_eq!(v["artboards"][0]["rect"][2], 120.0);
|
||||
// `run --in` takes it too.
|
||||
ok(&["run", "--in", &ai, "--cmd", "document.inspect"]);
|
||||
// A PDF keeps every artboard unless --artboard / --range names some.
|
||||
let all = tmp("all.pdf");
|
||||
ok(&["convert", &ai, &all]);
|
||||
assert_eq!(info(&all)["artboards"].as_array().unwrap().len(), 2);
|
||||
let second = tmp("second.pdf");
|
||||
ok(&["convert", &ai, &second, "--range", "2"]);
|
||||
assert_eq!(info(&second)["artboards"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_open_at_their_pixel_size() {
|
||||
let webp = tmp("tiny.webp");
|
||||
make(&webp, 3, 2);
|
||||
let v = info(&webp);
|
||||
assert_eq!(v["artboards"][0]["rect"], serde_json::json!([0.0, 0.0, 3.0, 2.0]), "{v}");
|
||||
assert_eq!(v["kinds"]["Image"], 1, "{v}");
|
||||
let png = tmp("tiny.png");
|
||||
ok(&["convert", &webp, &png, "--scale", "2"]);
|
||||
assert_eq!(&std::fs::read(&png).unwrap()[16..24], [0, 0, 0, 6, 0, 0, 0, 4], "IHDR 6×4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_readable_formats() {
|
||||
let out = ok(&["--help"]);
|
||||
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
|
||||
}
|
||||
|
|
@ -3,12 +3,12 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
const OPEN_EXTS: &[&str] = &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"];
|
||||
const CANVAS_ID: &str = "vectorcraft_canvas";
|
||||
const LOADING_ID: &str = "vectorcraft_loading";
|
||||
|
||||
|
|
@ -99,7 +99,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
|||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All supported", OPEN_EXTS).pick_file().await else {
|
||||
let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| d.add_filter(name, exts));
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
|
|
@ -125,7 +126,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
|||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(mime_for(&name));
|
||||
opts.set_type(fileio::format_for_name(&name).map_or("application/octet-stream", |f| f.mime));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
|
|
@ -143,14 +144,3 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
|||
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("vectorcraft" | "drawcraft") => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ mod control_server;
|
|||
mod native_menu;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
|
||||
struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
|
@ -98,8 +99,8 @@ fn save_prefs(app: &VectorcraftApp) {
|
|||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("All supported", &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"])
|
||||
fileio::open_filters()
|
||||
.fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(name, exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ vectorcraft-trace = { workspace = true }
|
|||
vectorcraft-svg = { workspace = true }
|
||||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-pdf = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,282 +0,0 @@
|
|||
//! Document I/O and transactions: open/save/export by path or bytes (base64), and `command.batch`.
|
||||
//!
|
||||
//! These live in the engine so every frontend (UI, CLI, MCP headless) shares them.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"document.open",
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64} → {index}; .vectorcraft, .svg, .pdf, .ai (PDF-compatible)",
|
||||
always,
|
||||
open
|
||||
),
|
||||
cmd!("document.save", "Save Document", [], None, "{path?} native .vectorcraft (default: the document's path) → {path}", has_doc, save),
|
||||
cmd!(query "document.serialize", "Serialize Document", [], None, "{format: vectorcraft|svg|pdf|png} → {dataBase64 | text}", has_doc, serialize),
|
||||
cmd!(
|
||||
"document.export",
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{format: svg|png|pdf|jpg|webp|vectorcraft, path, scale?: 1, artboard?: 0, outlineText?: bool (SVG: text as outlines)}",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
cmd!(
|
||||
"document.exportSelection",
|
||||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1} the selected objects cropped to their bounds → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
has_selection,
|
||||
export_selection
|
||||
),
|
||||
cmd!(
|
||||
"file.saveAsTemplate",
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?} a native copy that opens as a new untitled document (no path → {dataBase64})",
|
||||
has_doc,
|
||||
save_template
|
||||
),
|
||||
cmd!(
|
||||
"document.exportForScreens",
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder, artboards?: [index…] (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1, suffix?: \"@2x\"}], prefix?} → {files: [...]}",
|
||||
has_doc,
|
||||
export_for_screens
|
||||
),
|
||||
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch),
|
||||
]
|
||||
}
|
||||
|
||||
fn load(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if vectorcraft_format::is_native_name(&lower) || vectorcraft_format::sniff(bytes) {
|
||||
return vectorcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string()));
|
||||
}
|
||||
if lower.ends_with(".svg") || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
|
||||
let s = std::str::from_utf8(bytes).map_err(|_| EngineError::Other("SVG is not UTF-8".into()))?;
|
||||
let mut d = vectorcraft_svg::import(s).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
return Ok(d);
|
||||
}
|
||||
if lower.ends_with(".pdf") || lower.ends_with(".ai") || bytes.starts_with(b"%PDF") {
|
||||
let r =
|
||||
vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut d = r.document;
|
||||
d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
return Ok(d);
|
||||
}
|
||||
Err(EngineError::Other(format!("unsupported file `{name}`")))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes, path) = match (str_param(p, "path"), str_param(p, "dataBase64")) {
|
||||
(Some(path), _) => (path.to_string(), std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?, Some(path.to_string())),
|
||||
(None, Some(b64)) => (
|
||||
str_param(p, "name").unwrap_or("untitled.vectorcraft").to_string(),
|
||||
vectorcraft_format::base64_decode(b64).ok_or_else(|| bad("document.open", "bad base64"))?,
|
||||
None,
|
||||
),
|
||||
_ => return Err(bad("document.open", "give path or dataBase64")),
|
||||
};
|
||||
let mut doc = load(&name, &bytes)?;
|
||||
let mut native = vectorcraft_format::is_native_name(&name);
|
||||
if doc.template {
|
||||
// A template opens as a new untitled document (saving asks for a new name).
|
||||
doc.template = false;
|
||||
doc.title = s.next_untitled();
|
||||
native = false;
|
||||
}
|
||||
let i = s.add_document(doc, if native { path } else { None });
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path")
|
||||
.map(str::to_string)
|
||||
.or_else(|| s.active().and_then(|d| d.path.clone()))
|
||||
.ok_or_else(|| bad("document.save", "no path"))?;
|
||||
let bytes = vectorcraft_format::save_file(&s.doc()?.doc);
|
||||
std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let st = s.doc_mut()?;
|
||||
st.path = Some(path.clone());
|
||||
st.mark_saved();
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
||||
pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Result<Vec<u8>> {
|
||||
encode_doc(&s.doc()?.doc, format, scale, artboard)
|
||||
}
|
||||
|
||||
fn encode_doc(doc: &vectorcraft_doc::Document, format: &str, scale: f64, artboard: usize) -> Result<Vec<u8>> {
|
||||
encode_doc_with(doc, format, scale, artboard, false)
|
||||
}
|
||||
|
||||
/// `outline_text`: SVG text as glyph outlines (Illustrator's SVG Options → Fonts → Convert to Outlines).
|
||||
fn encode_doc_with(doc: &vectorcraft_doc::Document, format: &str, scale: f64, artboard: usize, outline_text: bool) -> Result<Vec<u8>> {
|
||||
Ok(match format {
|
||||
"vectorcraft" => vectorcraft_format::save_file(doc),
|
||||
"svg" => vectorcraft_svg::export(doc, &vectorcraft_svg::ExportOptions { artboard: Some(artboard), outline_text, ..Default::default() })
|
||||
.into_bytes(),
|
||||
"pdf" => super::rasterfx::export_pdf(doc, &vectorcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?,
|
||||
"png" | "jpg" | "jpeg" | "webp" => {
|
||||
let r = doc.artboards.get(artboard).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let img = vectorcraft_render::Renderer::new().render_region(doc, r, scale.clamp(0.01, 64.0), format != "png" && format != "webp");
|
||||
match format {
|
||||
"png" => img.to_png(),
|
||||
"webp" => img.to_webp(),
|
||||
_ => img.to_jpeg(90),
|
||||
}
|
||||
}
|
||||
other => return Err(EngineError::Other(format!("unknown format `{other}`"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = str_param(p, "format").unwrap_or("vectorcraft");
|
||||
let bytes = encode(s, f, f64_or(p, "scale", 1.0), p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize)?;
|
||||
if f == "svg" {
|
||||
return Ok(json!({ "text": String::from_utf8_lossy(&bytes) }));
|
||||
}
|
||||
Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) }))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("document.export", "missing path"))?;
|
||||
let f = str_param(p, "format")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| "png".into()));
|
||||
let artboard = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let bytes = encode_doc_with(&s.doc()?.doc, &f, f64_or(p, "scale", 1.0), artboard, bool_or(p, "outlineText", false))?;
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len() }))
|
||||
}
|
||||
|
||||
/// File → Export Selection: the selected objects alone, cropped to their visual bounds.
|
||||
fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportSelection";
|
||||
let path = str_param(p, "path");
|
||||
let f = str_param(p, "format")
|
||||
.map(str::to_string)
|
||||
.or_else(|| path.and_then(|p| std::path::Path::new(p).extension()).map(|e| e.to_string_lossy().to_ascii_lowercase()))
|
||||
.unwrap_or_else(|| "png".into());
|
||||
let ids = super::edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<std::sync::Arc<vectorcraft_doc::Node>> = ids.iter().filter_map(|id| st.doc.node(*id).cloned().map(std::sync::Arc::new)).collect();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?;
|
||||
let mut d = (*st.doc).clone();
|
||||
let mut layer = vectorcraft_doc::Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = nodes;
|
||||
}
|
||||
d.layers = vec![std::sync::Arc::new(layer)];
|
||||
let mut ab = d.artboards.first().cloned().ok_or_else(|| bad(C, "the document has no artboard"))?;
|
||||
ab.rect = bounds;
|
||||
ab.name = "Selection".into();
|
||||
d.artboards = vec![ab];
|
||||
let bytes = encode_doc_with(&d, &f, f64_or(p, "scale", 1.0), 0, bool_or(p, "outlineText", false))?;
|
||||
let b = [bounds.x0, bounds.y0, bounds.width(), bounds.height()];
|
||||
match path {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "bounds": b }))
|
||||
}
|
||||
None => Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes), "bounds": b })),
|
||||
}
|
||||
}
|
||||
|
||||
/// File → Save as Template: a native copy flagged so opening it starts a new untitled document.
|
||||
fn save_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut d = (*s.doc()?.doc).clone();
|
||||
d.template = true;
|
||||
let bytes = vectorcraft_format::save_file(&d);
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
None => Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) })),
|
||||
}
|
||||
}
|
||||
|
||||
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
|
||||
let label = str_param(p, "label").unwrap_or("Batch").to_string();
|
||||
s.begin_interaction(&label)?;
|
||||
// Session-level state a step may change (paint defaults, drawing mode) rolls back too.
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone());
|
||||
let mut results = vec![];
|
||||
for c in &cmds {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
if id == "command.batch" {
|
||||
s.cancel_interaction()?;
|
||||
return Err(bad("command.batch", "batches cannot nest"));
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
match s.execute(id, ¶ms) {
|
||||
Ok(v) => results.push(v),
|
||||
Err(e) => {
|
||||
s.cancel_interaction()?;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved;
|
||||
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Commit as one undo step (the interaction's "preview" is the batch itself).
|
||||
if let Some(it) = s.doc_mut()?.interaction.as_mut() {
|
||||
it.preview = Some(("command.batch".into(), p.clone()));
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
Ok(json!({ "results": results }))
|
||||
}
|
||||
|
||||
fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad("document.exportForScreens", "missing folder"))?.to_string();
|
||||
let n = s.doc()?.doc.artboards.len();
|
||||
let boards: Vec<usize> = match p.get("artboards").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_u64).map(|v| v as usize).filter(|i| *i < n).collect(),
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let formats: Vec<(String, f64, String)> = match p.get("formats").and_then(Value::as_array) {
|
||||
Some(a) => a
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let fmt = str_param(f, "format").unwrap_or("png").to_string();
|
||||
let sc = f64_or(f, "scale", 1.0);
|
||||
let suffix = str_param(f, "suffix")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| if (sc - 1.0).abs() < 1e-9 { String::new() } else { format!("@{sc}x") });
|
||||
(fmt, sc, suffix)
|
||||
})
|
||||
.collect(),
|
||||
None => vec![("png".into(), 1.0, String::new())],
|
||||
};
|
||||
let prefix = str_param(p, "prefix").unwrap_or("").to_string();
|
||||
std::fs::create_dir_all(&folder).map_err(|e| EngineError::Other(format!("{folder}: {e}")))?;
|
||||
let mut files = vec![];
|
||||
for b in boards {
|
||||
let name: String =
|
||||
s.doc()?.doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
for (fmt, sc, suffix) in &formats {
|
||||
let bytes = encode(s, fmt, *sc, b)?;
|
||||
let ext = if fmt == "jpeg" { "jpg" } else { fmt.as_str() };
|
||||
let path = format!("{folder}/{prefix}{name}{suffix}.{ext}");
|
||||
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Ok(json!({ "files": files }))
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
//! `command.batch`: several commands as one undo step.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
|
||||
let label = str_param(p, "label").unwrap_or("Batch").to_string();
|
||||
s.begin_interaction(&label)?;
|
||||
// Session-level state a step may change (paint defaults, drawing mode) rolls back too.
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone());
|
||||
let mut results = vec![];
|
||||
for c in &cmds {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
if id == "command.batch" {
|
||||
s.cancel_interaction()?;
|
||||
return Err(bad("command.batch", "batches cannot nest"));
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
match s.execute(id, ¶ms) {
|
||||
Ok(v) => results.push(v),
|
||||
Err(e) => {
|
||||
s.cancel_interaction()?;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved;
|
||||
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Commit as one undo step (the interaction's "preview" is the batch itself).
|
||||
if let Some(it) = s.doc_mut()?.interaction.as_mut() {
|
||||
it.preview = Some(("command.batch".into(), p.clone()));
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
Ok(json!({ "results": results }))
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
//! One encoder per writable format. Each format's options are parsed, typed, from the export params
|
||||
//! (unknown keys are ignored, so one params object can carry the options of several formats).
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
use super::super::*;
|
||||
use super::Format;
|
||||
use crate::EngineError;
|
||||
|
||||
const C: &str = "document.export";
|
||||
|
||||
/// The params that pick artboards (the fields of [`ArtboardPick`]).
|
||||
pub const ARTBOARD_PARAMS: [&str; 3] = ["artboard", "artboards", "range"];
|
||||
|
||||
/// Which artboards an export covers: `range` (`"1-3, 5"`, 1-based) wins over `artboards`
|
||||
/// (0-based), which wins over `artboard`.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ArtboardPick {
|
||||
pub artboard: Option<usize>,
|
||||
pub artboards: Option<Vec<usize>>,
|
||||
pub range: Option<String>,
|
||||
}
|
||||
|
||||
impl ArtboardPick {
|
||||
/// The named artboards (`None`: none named), checked against the document's `count`.
|
||||
pub fn resolve(&self, count: usize) -> std::result::Result<Option<Vec<usize>>, String> {
|
||||
let v = match (&self.range, &self.artboards, self.artboard) {
|
||||
(Some(r), _, _) => parse_range(r, count)?,
|
||||
(None, Some(a), _) => a.clone(),
|
||||
(None, None, Some(a)) => vec![a],
|
||||
(None, None, None) => return Ok(None),
|
||||
};
|
||||
if v.is_empty() {
|
||||
return Err("no artboards named".into());
|
||||
}
|
||||
match v.iter().find(|i| **i >= count) {
|
||||
Some(i) => Err(format!("no artboard {i} (0-based; the document has {count})")),
|
||||
None => Ok(Some(v)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one artboard a single-image format writes (default: the first).
|
||||
pub fn one(&self, count: usize) -> std::result::Result<usize, String> {
|
||||
match self.resolve(count)?.as_deref() {
|
||||
None if count > 0 => Ok(0),
|
||||
None => Err("the document has no artboard".into()),
|
||||
Some([i]) => Ok(*i),
|
||||
Some(_) => Err("this format holds one artboard: export several as PDF or with document.exportForScreens".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct SvgOptions {
|
||||
#[serde(flatten)]
|
||||
boards: ArtboardPick,
|
||||
/// Text as glyph outlines (SVG Options → Fonts → Convert to Outlines).
|
||||
outline_text: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct RasterOptions {
|
||||
#[serde(flatten)]
|
||||
boards: ArtboardPick,
|
||||
scale: Option<f64>,
|
||||
quality: Option<u8>,
|
||||
}
|
||||
|
||||
fn boards<T>(r: std::result::Result<T, String>) -> Result<T> {
|
||||
r.map_err(|e| bad(C, e))
|
||||
}
|
||||
|
||||
fn options<T: DeserializeOwned + Default>(f: &Format, p: &Value) -> Result<T> {
|
||||
if !p.is_object() {
|
||||
return Ok(T::default());
|
||||
}
|
||||
T::deserialize(p).map_err(|e| bad(C, format!("{} options: {e}", f.label)))
|
||||
}
|
||||
|
||||
/// Most pixels a raster export renders (1 GiB of RGBA).
|
||||
const MAX_PIXELS: f64 = 268_435_456.0;
|
||||
|
||||
/// Refuse a raster export the encoder can't write or memory can't hold (instead of writing an
|
||||
/// empty file or aborting).
|
||||
fn check_pixels(f: &Format, w: f64, h: f64) -> Result<()> {
|
||||
// WebP stores sizes in 14 bits, JPEG in 16.
|
||||
let side = if f.id == "webp" { 16383.0 } else { 65535.0 };
|
||||
if w.round() > side || h.round() > side || w * h > MAX_PIXELS {
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"{}×{} px is too large for {} (at most {side} px a side, {MAX_PIXELS} px in all): lower the scale",
|
||||
w.round(),
|
||||
h.round(),
|
||||
f.label
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's
|
||||
/// options from `p` (see `document.formats`). Raster formats leave template layers out.
|
||||
pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let n = doc.artboards.len();
|
||||
Ok(match f.id {
|
||||
"vectorcraft" => vectorcraft_format::save_file(doc),
|
||||
"svg" => {
|
||||
let o: SvgOptions = options(f, p)?;
|
||||
let artboard = if n == 0 { None } else { Some(boards(o.boards.one(n))?) };
|
||||
let opts = vectorcraft_svg::ExportOptions { artboard, outline_text: o.outline_text.unwrap_or(false), ..Default::default() };
|
||||
vectorcraft_svg::export(doc, &opts).into_bytes()
|
||||
}
|
||||
"pdf" => {
|
||||
let o: ArtboardPick = options(f, p)?;
|
||||
let opts = vectorcraft_pdf::PdfOptions { artboards: boards(o.resolve(n))?, ..Default::default() };
|
||||
super::super::rasterfx::export_pdf(doc, &opts).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
}
|
||||
"png" | "jpg" | "webp" => {
|
||||
let o: RasterOptions = options(f, p)?;
|
||||
let region = doc.artboards[boards(o.boards.one(n))?].rect;
|
||||
let scale = o.scale.unwrap_or(1.0).clamp(0.01, 64.0);
|
||||
check_pixels(f, region.width() * scale, region.height() * scale)?;
|
||||
let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, f.id == "jpg");
|
||||
match f.id {
|
||||
"png" => img.to_png(),
|
||||
"webp" => img.to_webp(),
|
||||
_ => img.to_jpeg(o.quality.unwrap_or(90)),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(C, format!("no encoder for {} yet", f.label))),
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::super::*;
|
||||
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, create_dir, encode, writable, write_file, write_or_return};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||
let bytes = encode(&s.doc()?.doc, f.id, p)?;
|
||||
if f.id == "svg" {
|
||||
return Ok(json!({ "text": String::from_utf8_lossy(&bytes) }));
|
||||
}
|
||||
Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) }))
|
||||
}
|
||||
|
||||
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path");
|
||||
let f = writable("document.export", str_param(p, "format"), path)?;
|
||||
let bytes = encode(&s.doc()?.doc, f.id, p)?;
|
||||
write_or_return(path, &bytes, json!({ "format": f.id }))
|
||||
}
|
||||
|
||||
/// `p` without its artboard choice (for documents made of one synthetic artboard).
|
||||
fn without_artboards(p: &Value) -> Value {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
for k in ARTBOARD_PARAMS {
|
||||
o.remove(k);
|
||||
}
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on
|
||||
/// template layers are guides, not artwork, and are left out.
|
||||
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportSelection";
|
||||
let path = str_param(p, "path");
|
||||
let f = writable(C, str_param(p, "format"), path)?;
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
// On a template layer or sublayer, at any depth.
|
||||
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
|
||||
let nodes: Vec<Arc<Node>> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?;
|
||||
let mut d = (*st.doc).clone();
|
||||
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = nodes;
|
||||
}
|
||||
d.layers = vec![Arc::new(layer)];
|
||||
let mut ab = d.artboards.first().cloned().ok_or_else(|| bad(C, "the document has no artboard"))?;
|
||||
ab.rect = bounds;
|
||||
ab.name = "Selection".into();
|
||||
d.artboards = vec![ab];
|
||||
let bytes = encode(&d, f.id, &without_artboards(p))?;
|
||||
write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
|
||||
}
|
||||
|
||||
/// One Export for Screens format row: what to write, at which scale, with which file-name suffix.
|
||||
struct ScreenFormat {
|
||||
format: &'static Format,
|
||||
/// The row's own options (scale, quality…) for the encoder.
|
||||
options: Value,
|
||||
suffix: String,
|
||||
}
|
||||
|
||||
fn screen_format(row: &Value) -> Result<ScreenFormat> {
|
||||
const C: &str = "document.exportForScreens";
|
||||
if !row.is_object() {
|
||||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if format.id == "vectorcraft" {
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg or pdf"));
|
||||
}
|
||||
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one
|
||||
// left over from a raster row switched to SVG or PDF).
|
||||
let scale = if format.raster { f64_or(row, "scale", 1.0) } else { 1.0 };
|
||||
let suffix = match str_param(row, "suffix") {
|
||||
Some(s) if !format.raster && is_scale_suffix(s) => String::new(),
|
||||
Some(s) => s.to_string(),
|
||||
None if (scale - 1.0).abs() < 1e-9 => String::new(),
|
||||
None => format!("@{scale}x"),
|
||||
};
|
||||
let mut options = without_artboards(row);
|
||||
if let Some(o) = options.as_object_mut() {
|
||||
o.insert("scale".into(), json!(scale));
|
||||
}
|
||||
Ok(ScreenFormat { format, options, suffix })
|
||||
}
|
||||
|
||||
/// A pixel-density suffix such as `@2x` or `@0.5x`.
|
||||
fn is_scale_suffix(s: &str) -> bool {
|
||||
s.strip_prefix('@').and_then(|s| s.strip_suffix(['x', 'X'])).is_some_and(|n| n.parse::<f64>().is_ok())
|
||||
}
|
||||
|
||||
/// File → Export for Screens: every chosen artboard in every format, one file each (a PDF holds
|
||||
/// its artboard alone). Artboards that share a name get `-2`, `-3`… instead of overwriting.
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportForScreens";
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let n = doc.artboards.len();
|
||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ArtboardPick::default() };
|
||||
let boards = pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
let formats: Vec<ScreenFormat> = match p.get("formats").and_then(Value::as_array) {
|
||||
Some(rows) => rows.iter().map(screen_format).collect::<Result<_>>()?,
|
||||
None => vec![screen_format(&json!({}))?],
|
||||
};
|
||||
let prefix = str_param(p, "prefix").unwrap_or("");
|
||||
let folder = str_param(p, "folder");
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
// Names are compared without case: `Icon` and `icon` are one file on most desktop file systems.
|
||||
let mut names = HashSet::new();
|
||||
let mut written = HashSet::new();
|
||||
let mut files = vec![];
|
||||
for b in boards {
|
||||
let mut base: String = doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() {
|
||||
base = format!("Artboard-{}", b + 1);
|
||||
}
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if names.insert(name.to_lowercase()) {
|
||||
break;
|
||||
}
|
||||
name = format!("{base}-{i}");
|
||||
}
|
||||
for sf in &formats {
|
||||
let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]);
|
||||
if !written.insert(file.to_lowercase()) {
|
||||
continue; // the same file from two identical rows
|
||||
}
|
||||
let mut options = sf.options.clone();
|
||||
options["artboard"] = json!(b);
|
||||
let bytes = encode(&doc, sf.format.id, &options)?;
|
||||
match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{file}");
|
||||
write_file(&path, &bytes)?;
|
||||
files.push(json!(path));
|
||||
}
|
||||
None => files.push(json!({ "name": file, "dataBase64": vectorcraft_format::base64_encode(&bytes) })),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "files": files }))
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
//! `document.open`: every readable format into a new document.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::{Cursor, Read as _};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use super::super::*;
|
||||
use super::{Format, format, format_for_name, read_file};
|
||||
use crate::EngineError;
|
||||
|
||||
/// A file read into a document, with its format and non-fatal import notes.
|
||||
pub struct Loaded {
|
||||
pub doc: Document,
|
||||
pub format: &'static Format,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
|
||||
/// (what browsers, PDF and SVG viewers show).
|
||||
pub struct RasterImage {
|
||||
/// Content key for `Document::images` (identical files share one blob).
|
||||
pub key: String,
|
||||
pub blob: ImageBlob,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> EngineError {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
|
||||
/// The last component of a path or file name.
|
||||
fn file_name(name: &str) -> String {
|
||||
std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string())
|
||||
}
|
||||
|
||||
/// The readable format of a file: from its content (magic bytes) when that tells, else from the
|
||||
/// extension of `name`.
|
||||
pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
let by_name = format_for_name(name).filter(|f| f.read);
|
||||
if vectorcraft_format::sniff(bytes) {
|
||||
return format("vectorcraft");
|
||||
}
|
||||
if bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
return format("svgz");
|
||||
}
|
||||
if bytes.starts_with(b"%PDF") {
|
||||
// .ai and .ait files are PDF inside; the extension keeps the template meaning.
|
||||
return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("pdf"));
|
||||
}
|
||||
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
|
||||
return Some(f);
|
||||
}
|
||||
if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) {
|
||||
return format("svg");
|
||||
}
|
||||
by_name
|
||||
}
|
||||
|
||||
/// The readable raster format behind an `image` crate format.
|
||||
fn image_format(f: image::ImageFormat) -> Option<&'static Format> {
|
||||
f.extensions_str().iter().find_map(|e| format(e)).filter(|f| f.read && f.raster)
|
||||
}
|
||||
|
||||
/// Read a file of any readable format (`name`: its file name or path, for the extension and title).
|
||||
pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
let title = file_name(name);
|
||||
let mut warnings = vec![];
|
||||
let mut doc = match format.id {
|
||||
"vectorcraft" => vectorcraft_format::load(bytes).map_err(err)?,
|
||||
"svg" | "svgz" => {
|
||||
let text: Cow<[u8]> = if format.id == "svgz" { Cow::Owned(gunzip(bytes)?) } else { Cow::Borrowed(bytes) };
|
||||
let text = std::str::from_utf8(&text).map_err(|_| err("SVG is not UTF-8"))?;
|
||||
let (d, w) = vectorcraft_svg::import_with_report(text).map_err(err)?;
|
||||
warnings = w;
|
||||
d
|
||||
}
|
||||
"pdf" | "ai" | "ait" => {
|
||||
let r = vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(err)?;
|
||||
warnings = r.warnings;
|
||||
r.document
|
||||
}
|
||||
_ if format.raster => raster_doc(&title, bytes)?,
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
// Imports are named after the file; a native document keeps its own title (the tab shows the
|
||||
// file name once it has a path).
|
||||
if format.id != "vectorcraft" || doc.title.is_empty() {
|
||||
doc.title = title;
|
||||
}
|
||||
Ok(Loaded { doc, format, warnings })
|
||||
}
|
||||
|
||||
/// Open a file's bytes as the new active document (what `document.open` does) →
|
||||
/// `{index, title, format, warnings}`. `path` is kept for Save only for a native, non-template file.
|
||||
pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings } = load(name, bytes)?;
|
||||
// A template (saved by Save as Template, or an .ait file) opens as a new untitled document.
|
||||
let template = doc.template || format.id == "ait";
|
||||
if template {
|
||||
doc.template = false;
|
||||
doc.title = s.next_untitled();
|
||||
}
|
||||
let keep_path = format.id == "vectorcraft" && !template;
|
||||
let index = s.add_document(doc, path.filter(|_| keep_path));
|
||||
let title = s.documents()[index].title();
|
||||
Ok(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings }))
|
||||
}
|
||||
|
||||
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match (str_param(p, "path"), str_param(p, "dataBase64")) {
|
||||
(Some(path), _) => {
|
||||
let bytes = read_file(path)?;
|
||||
open_bytes(s, path, &bytes, Some(path.to_string()))
|
||||
}
|
||||
(None, Some(b64)) => {
|
||||
let bytes = vectorcraft_format::base64_decode(b64).ok_or_else(|| bad("document.open", "bad base64"))?;
|
||||
open_bytes(s, str_param(p, "name").unwrap_or("Untitled"), &bytes, None)
|
||||
}
|
||||
_ => Err(bad("document.open", "give path, or name and dataBase64")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels).
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
|
||||
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
|
||||
let (bytes, mime, (width, height)) = if matches!(f.id, "png" | "jpg" | "gif" | "webp") {
|
||||
(bytes.to_vec(), f.mime, reader.into_dimensions().map_err(err)?)
|
||||
} else {
|
||||
let img = reader.decode().map_err(err)?.to_rgba8();
|
||||
let size = img.dimensions();
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
|
||||
(png, "image/png", size)
|
||||
};
|
||||
if width == 0 || height == 0 {
|
||||
return Err(err("the image is empty"));
|
||||
}
|
||||
Ok(RasterImage { key: format!("img{:016x}", fnv1a(&bytes)), blob: ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) }, width, height })
|
||||
}
|
||||
|
||||
/// An image as a document of its pixel size (1 px = 1 pt), the image named after the file.
|
||||
fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
let RasterImage { key, blob, width, height } = raster_image(bytes)?;
|
||||
let mut d = Document::new(width as f64, height as f64);
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None }));
|
||||
n.name = Some(name.to_string());
|
||||
d.images.insert(key, blob);
|
||||
d.insert(Some(layer), 0, n).map_err(err)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn gunzip(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).map_err(|e| err(format!("not a valid SVGZ file: {e}")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// FNV-1a: a stable content key for embedded images.
|
||||
fn fnv1a(b: &[u8]) -> u64 {
|
||||
b.iter().fold(0xcbf29ce484222325, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3))
|
||||
}
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
//! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open,
|
||||
//! save, export and serialize by path or bytes (base64), the format table, and `command.batch`.
|
||||
//!
|
||||
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, raster images).
|
||||
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`.
|
||||
//! - `save`: `document.save`, `file.saveAsTemplate`.
|
||||
//!
|
||||
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
|
||||
//! agents query `document.formats`.
|
||||
|
||||
mod batch;
|
||||
mod encode;
|
||||
mod export;
|
||||
mod load;
|
||||
mod save;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, encode};
|
||||
pub use load::{Loaded, RasterImage, detect, load, open_bytes, raster_image};
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"document.open",
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64} → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
cmd!(
|
||||
"document.save",
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?} native .vectorcraft (default: the document's path) → {path}; a never-saved document without path → {dataBase64} (stays modified)",
|
||||
has_doc,
|
||||
save::save
|
||||
),
|
||||
cmd!(
|
||||
query "document.serialize",
|
||||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|svg|pdf|png|jpg|webp, …the format's options (see document.formats)} → {text} for svg, else {dataBase64}",
|
||||
has_doc,
|
||||
export::serialize
|
||||
),
|
||||
cmd!(
|
||||
"document.export",
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" (1-based; PDF writes one page per artboard, default all; the other formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg)} → {path, format, bytes}; no path → {dataBase64, format, bytes}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
cmd!(
|
||||
"document.exportSelection",
|
||||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
has_selection,
|
||||
export::export_selection
|
||||
),
|
||||
cmd!(
|
||||
"file.saveAsTemplate",
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?} a native copy that opens as a new untitled document (no path → {dataBase64})",
|
||||
has_doc,
|
||||
save::save_template
|
||||
),
|
||||
cmd!(
|
||||
"document.exportForScreens",
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; svg/pdf drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
|
||||
has_doc,
|
||||
export::export_for_screens
|
||||
),
|
||||
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch::batch),
|
||||
cmd!(
|
||||
query "document.formats",
|
||||
"File Formats",
|
||||
[],
|
||||
None,
|
||||
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}}], readable: [id…], writable: [id…], openExtensions: [ext…]}",
|
||||
always,
|
||||
formats
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// One option a format's encoder reads from the export params.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FormatOption {
|
||||
pub name: &'static str,
|
||||
/// JSON type: `number`, `integer`, `boolean`, `string` or `array`.
|
||||
pub ty: &'static str,
|
||||
/// The default as a JSON literal (`"1"`, `"false"`, `"null"`).
|
||||
pub default: &'static str,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
/// One file format: how `document.open` recognises it and what `document.export` writes.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Format {
|
||||
/// The `format` param value (`svg`, `png`, …).
|
||||
pub id: &'static str,
|
||||
/// Open/save dialog filter name.
|
||||
pub label: &'static str,
|
||||
/// Lower-case extensions without the dot; the first is the one exports use.
|
||||
pub extensions: &'static [&'static str],
|
||||
pub mime: &'static str,
|
||||
pub read: bool,
|
||||
pub write: bool,
|
||||
/// Pixels (export `scale` applies) rather than vectors.
|
||||
pub raster: bool,
|
||||
/// Options the encoder reads (writable formats).
|
||||
pub options: &'static [FormatOption],
|
||||
}
|
||||
|
||||
impl Format {
|
||||
pub fn to_json(&self) -> Value {
|
||||
let options: serde_json::Map<String, Value> = self
|
||||
.options
|
||||
.iter()
|
||||
.map(|o| {
|
||||
let default: Value = serde_json::from_str(o.default).unwrap_or(Value::Null);
|
||||
(o.name.to_string(), json!({"type": o.ty, "default": default, "description": o.description}))
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"extensions": self.extensions,
|
||||
"mime": self.mime,
|
||||
"read": self.read,
|
||||
"write": self.write,
|
||||
"raster": self.raster,
|
||||
"options": options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const ARTBOARD: FormatOption = FormatOption {
|
||||
name: "artboard",
|
||||
ty: "integer",
|
||||
default: "0",
|
||||
description: "0-based artboard to export (`artboards: [i]` or `range: \"n\"` naming one artboard work too)",
|
||||
};
|
||||
const ARTBOARDS: FormatOption =
|
||||
FormatOption { name: "artboards", ty: "array", default: "null", description: "0-based artboards, one page each (default: all)" };
|
||||
const RANGE: FormatOption = FormatOption {
|
||||
name: "range",
|
||||
ty: "string",
|
||||
default: "null",
|
||||
description: "1-based artboards such as \"1-3, 5\" (wins over artboards and artboard)",
|
||||
};
|
||||
const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" };
|
||||
const QUALITY: FormatOption = FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 1–100" };
|
||||
const OUTLINE_TEXT: FormatOption =
|
||||
FormatOption { name: "outlineText", ty: "boolean", default: "false", description: "text as glyph outlines (viewable without the fonts)" };
|
||||
|
||||
/// A format `document.open` reads but nothing writes yet.
|
||||
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
|
||||
Format { id, label, extensions, mime, read: true, write: false, raster, options: &[] }
|
||||
}
|
||||
|
||||
/// Every format VectorCraft reads or writes. Append-only: new formats go at the end.
|
||||
pub const FORMATS: &[Format] = &[
|
||||
Format {
|
||||
id: "vectorcraft",
|
||||
label: "VectorCraft",
|
||||
extensions: &[vectorcraft_format::EXTENSION, vectorcraft_format::LEGACY_EXTENSION],
|
||||
mime: "application/json",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[],
|
||||
},
|
||||
Format {
|
||||
id: "svg",
|
||||
label: "SVG",
|
||||
extensions: &["svg"],
|
||||
mime: "image/svg+xml",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[ARTBOARD, OUTLINE_TEXT],
|
||||
},
|
||||
reader("svgz", "SVG Compressed", &["svgz"], "image/svg+xml", false),
|
||||
Format {
|
||||
id: "pdf",
|
||||
label: "PDF",
|
||||
extensions: &["pdf"],
|
||||
mime: "application/pdf",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE],
|
||||
},
|
||||
reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false),
|
||||
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
|
||||
Format { id: "png", label: "PNG", extensions: &["png"], mime: "image/png", read: true, write: true, raster: true, options: &[ARTBOARD, SCALE] },
|
||||
Format {
|
||||
id: "jpg",
|
||||
label: "JPEG",
|
||||
extensions: &["jpg", "jpeg"],
|
||||
mime: "image/jpeg",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, SCALE, QUALITY],
|
||||
},
|
||||
reader("gif", "GIF", &["gif"], "image/gif", true),
|
||||
Format {
|
||||
id: "webp",
|
||||
label: "WebP",
|
||||
extensions: &["webp"],
|
||||
mime: "image/webp",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, SCALE],
|
||||
},
|
||||
reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true),
|
||||
reader("bmp", "BMP", &["bmp"], "image/bmp", true),
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
pub const OPEN_EXTS: &[&str] =
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
|
||||
|
||||
/// Open-dialog filters: "All readable files" first, then one per readable format.
|
||||
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All readable files", OPEN_EXTS)).chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
|
||||
pub fn format(id_or_ext: &str) -> Option<&'static Format> {
|
||||
let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase();
|
||||
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str())))
|
||||
}
|
||||
|
||||
/// The lower-case extension of a file name or path (empty when it has none).
|
||||
pub fn extension(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The format a file name's extension names.
|
||||
pub fn format_for_name(name: &str) -> Option<&'static Format> {
|
||||
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e))
|
||||
}
|
||||
|
||||
/// The format to write: `format` (an id or extension), else the path's extension, else PNG.
|
||||
pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
|
||||
let f = match (format_param, path.map(extension).filter(|e| !e.is_empty())) {
|
||||
(Some(f), _) => format(f).ok_or_else(|| format!("unknown format `{f}` (see document.formats)"))?,
|
||||
(None, Some(e)) => format(&e).ok_or_else(|| format!("unknown extension `.{e}`: pass `format` (see document.formats)"))?,
|
||||
(None, None) => format("png").expect("png is in FORMATS"),
|
||||
};
|
||||
if f.write { Ok(f) } else { Err(format!("{} files can be opened but not written (see document.formats)", f.label)) }
|
||||
}
|
||||
|
||||
/// [`writable_format`] for command `cmd`.
|
||||
fn writable(cmd: &str, format_param: Option<&str>, path: Option<&str>) -> Result<&'static Format> {
|
||||
writable_format(format_param, path).map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
fn formats(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = |pick: fn(&Format) -> bool| FORMATS.iter().filter(|f| pick(f)).map(|f| f.id).collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"formats": FORMATS.iter().map(Format::to_json).collect::<Vec<_>>(),
|
||||
"readable": ids(|f| f.read),
|
||||
"writable": ids(|f| f.write),
|
||||
"openExtensions": OPEN_EXTS,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- the file system (none on the web, where commands take and return bytes) ----------
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn create_dir(path: &str) -> Result<()> {
|
||||
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn no_fs(path: &str) -> EngineError {
|
||||
EngineError::Other(format!("{path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64)"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_file(path: &str, _: &[u8]) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn create_dir(path: &str) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` → `{path, bytes, …extra}`; with no path → `{dataBase64, bytes, …extra}`.
|
||||
fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result<Value> {
|
||||
let mut out = match path {
|
||||
Some(path) => {
|
||||
write_file(path, bytes)?;
|
||||
json!({ "path": path, "bytes": bytes.len() })
|
||||
}
|
||||
None => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes), "bytes": bytes.len() }),
|
||||
};
|
||||
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
//! Native saves: `document.save` and `file.saveAsTemplate`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::write_or_return;
|
||||
|
||||
pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone()));
|
||||
let bytes = vectorcraft_format::save_file(&s.doc()?.doc);
|
||||
let Some(path) = path else {
|
||||
// Nowhere to save to (web, agents): hand the bytes back; the document stays modified.
|
||||
return write_or_return(None, &bytes, json!({}));
|
||||
};
|
||||
super::write_file(&path, &bytes)?;
|
||||
let st = s.doc_mut()?;
|
||||
st.path = Some(path.clone());
|
||||
st.mark_saved();
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
||||
/// File → Save as Template: a native copy flagged so opening it starts a new untitled document.
|
||||
pub(super) fn save_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut d = (*s.doc()?.doc).clone();
|
||||
d.template = true;
|
||||
write_or_return(str_param(p, "path"), &vectorcraft_format::save_file(&d), json!({}))
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
use std::io::{Cursor, Write as _};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::parse_range;
|
||||
|
||||
fn session(width: f64, height: f64, artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn tmp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-fileio-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
|
||||
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}))
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||
}
|
||||
|
||||
/// A `w`×`h` image of one colour in `format`.
|
||||
fn image_bytes(w: u32, h: u32, format: image::ImageFormat) -> Vec<u8> {
|
||||
let rgba = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 40, 255]));
|
||||
let img = if format == image::ImageFormat::Jpeg {
|
||||
image::DynamicImage::ImageRgb8(image::DynamicImage::ImageRgba8(rgba).to_rgb8())
|
||||
} else {
|
||||
rgba.into()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut out), format).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
match &doc.layers[0].children().unwrap()[0].kind {
|
||||
NodeKind::Image(im) => im.clone(),
|
||||
other => panic!("expected an image, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_every_readable_format() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let native = b64(&s.execute("document.serialize", &json!({})).unwrap());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
let pdf = b64(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap());
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(svg.as_bytes()).unwrap();
|
||||
let svgz = gz.finish().unwrap();
|
||||
|
||||
for (name, bytes, format) in [
|
||||
("a.vectorcraft", native.clone(), "vectorcraft"),
|
||||
("a.drawcraft", native, "vectorcraft"),
|
||||
("a.svg", svg.into_bytes(), "svg"),
|
||||
("a.svgz", svgz, "svgz"),
|
||||
("a.pdf", pdf.clone(), "pdf"),
|
||||
("a.ai", pdf, "ai"),
|
||||
] {
|
||||
let r = open(&mut s, name, &bytes);
|
||||
assert_eq!(r["format"], format, "{name}");
|
||||
assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}");
|
||||
assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through");
|
||||
}
|
||||
for (name, kind) in [
|
||||
("p.png", image::ImageFormat::Png),
|
||||
("p.jpg", image::ImageFormat::Jpeg),
|
||||
("p.gif", image::ImageFormat::Gif),
|
||||
("p.webp", image::ImageFormat::WebP),
|
||||
("p.tif", image::ImageFormat::Tiff),
|
||||
("p.bmp", image::ImageFormat::Bmp),
|
||||
] {
|
||||
let r = open(&mut s, name, &image_bytes(5, 4, kind));
|
||||
assert_eq!(r["format"], format(name.rsplit('.').next().unwrap()).unwrap().id, "{name}");
|
||||
let im = image_of(&s);
|
||||
assert_eq!((im.width, im.height), (5, 4), "{name}");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!((doc.artboards[0].rect.width(), doc.artboards[0].rect.height()), (5.0, 4.0));
|
||||
// Browser-safe formats keep their bytes; TIFF and BMP are stored as PNG.
|
||||
let mime = &doc.images[&im.key].mime;
|
||||
assert_eq!(mime, if name.ends_with("tif") || name.ends_with("bmp") { "image/png" } else { format_for_name(name).unwrap().mime });
|
||||
}
|
||||
assert!(s.execute("document.open", &json!({"name": "x.txt", "dataBase64": "aGVsbG8="})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_opens_at_its_pixel_size() {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "tiny.webp", &image_bytes(3, 2, image::ImageFormat::WebP));
|
||||
let im = image_of(&s);
|
||||
assert_eq!((im.width, im.height), (3, 2));
|
||||
let ab = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((ab.width(), ab.height()), (3.0, 2.0));
|
||||
let r = raster_image(&image_bytes(3, 2, image::ImageFormat::WebP)).unwrap();
|
||||
assert_eq!((r.width, r.height, r.blob.mime.as_str()), (3, 2, "image/webp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_beats_a_wrong_extension() {
|
||||
let png = image_bytes(2, 2, image::ImageFormat::Png);
|
||||
assert_eq!(detect("photo.jpg", &png).unwrap().id, "png");
|
||||
assert_eq!(detect("noext", b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap().id, "svg");
|
||||
assert_eq!(detect("x.ait", b"%PDF-1.7").unwrap().id, "ait");
|
||||
assert_eq!(detect("x.bin", b"%PDF-1.7").unwrap().id, "pdf");
|
||||
assert!(detect("x.bin", b"nothing").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_open_untitled() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let dir = tmp_dir("tpl");
|
||||
let path = dir.join("t.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
|
||||
let r = s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
assert!(!s.doc().unwrap().doc.template);
|
||||
|
||||
let pdf = b64(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap());
|
||||
let ait = dir.join("t.ait").to_string_lossy().to_string();
|
||||
std::fs::write(&ait, pdf).unwrap();
|
||||
let r = s.execute("document.open", &json!({"path": ait})).unwrap();
|
||||
assert_eq!(r["format"], "ait");
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exts_cover_every_readable_format() {
|
||||
for f in FORMATS.iter().filter(|f| f.read) {
|
||||
for e in f.extensions {
|
||||
assert!(OPEN_EXTS.contains(e), "OPEN_EXTS lacks .{e} ({})", f.label);
|
||||
}
|
||||
}
|
||||
for e in OPEN_EXTS {
|
||||
assert!(format(e).is_some_and(|f| f.read), ".{e} in OPEN_EXTS is no readable format");
|
||||
}
|
||||
let filters: Vec<_> = open_filters().collect();
|
||||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||||
assert_eq!(filters.len(), 1 + FORMATS.iter().filter(|f| f.read).count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_query_lists_readers_writers_and_options() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(ids("writable"), ["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]);
|
||||
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
|
||||
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||
assert_eq!(png["options"]["scale"]["default"], 1);
|
||||
assert_eq!(r["openExtensions"].as_array().unwrap().len(), OPEN_EXTS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_parse_one_based() {
|
||||
assert_eq!(parse_range("1-3, 5", 5).unwrap(), [0, 1, 2, 4]);
|
||||
assert_eq!(parse_range("3-, 1", 4).unwrap(), [2, 3, 0]);
|
||||
assert_eq!(parse_range("-2", 4).unwrap(), [0, 1]);
|
||||
assert_eq!(parse_range("2,2,1\u{2013}2", 4).unwrap(), [1, 0]);
|
||||
for bad in ["", "0", "6", "3-1", "a", "-", "1-9"] {
|
||||
assert!(parse_range(bad, 5).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pages in a PDF, counted by importing it (one artboard per page).
|
||||
fn pdf_pages(bytes: &[u8]) -> usize {
|
||||
vectorcraft_pdf::import(bytes).unwrap().artboards.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_honours_artboard_and_range() {
|
||||
let mut s = session(100.0, 80.0, 3);
|
||||
let pages = |s: &mut Session, p: Value| pdf_pages(&b64(&s.execute("document.export", &p).unwrap()));
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf"})), 3);
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf", "artboard": 1})), 1);
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf", "artboards": [0, 2]})), 2);
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf", "range": "2-3"})), 2);
|
||||
assert_eq!(pdf_pages(&b64(&s.execute("document.serialize", &json!({"format": "pdf", "artboard": 2})).unwrap())), 1);
|
||||
assert!(s.execute("document.export", &json!({"format": "pdf", "artboard": 3})).is_err());
|
||||
assert!(s.execute("document.export", &json!({"format": "pdf", "range": "1-4"})).is_err());
|
||||
// Single-image formats take one artboard.
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "range": "1-2"})).is_err());
|
||||
let png = b64(&s.execute("document.export", &json!({"format": "png", "range": "2", "scale": 0.5})).unwrap());
|
||||
assert_eq!(image::load_from_memory(&png).unwrap().width(), 50);
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "scale": "big"})).is_err(), "typed options reject a bad type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_without_path_returns_bytes_and_never_retargets() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let dir = tmp_dir("retarget");
|
||||
let doc_path = dir.join("doc.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("document.save", &json!({"path": doc_path})).unwrap();
|
||||
let copy = dir.join("copy.vectorcraft").to_string_lossy().to_string();
|
||||
let r = s.execute("document.export", &json!({"path": copy})).unwrap();
|
||||
assert_eq!(r["format"], "vectorcraft");
|
||||
assert!(vectorcraft_format::sniff(&std::fs::read(©).unwrap()));
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(doc_path.as_str()), "exporting the native format keeps the document's path");
|
||||
let r = s.execute("document.export", &json!({"format": "jpg"})).unwrap();
|
||||
assert_eq!(&b64(&r)[..2], [0xFF, 0xD8]);
|
||||
assert!(r.get("path").is_none());
|
||||
assert!(s.execute("document.export", &json!({"path": dir.join("x.bmp").to_string_lossy()})).is_err(), "BMP is read-only");
|
||||
// A never-saved document hands its native bytes back.
|
||||
let mut s = session(10.0, 10.0, 1);
|
||||
let r = s.execute("document.save", &json!({})).unwrap();
|
||||
assert!(vectorcraft_format::sniff(&b64(&r)));
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A red square on a template layer and a blue one on a normal layer above it.
|
||||
fn template_doc() -> (Session, u64, u64) {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let template = s.doc().unwrap().doc.layers[0].id.0;
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let red = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
let blue = s.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap();
|
||||
(s, red, blue)
|
||||
}
|
||||
|
||||
fn has_red(png: &[u8]) -> bool {
|
||||
image::load_from_memory(png).unwrap().to_rgba8().pixels().any(|p| p[0] > 200 && p[2] < 80 && p[3] > 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_exports_leave_template_layers_out() {
|
||||
let (mut s, red, blue) = template_doc();
|
||||
let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap());
|
||||
assert!(!has_red(&png), "no red pixels from the template layer");
|
||||
assert!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(75, 50)[2] > 200, "the normal layer is exported");
|
||||
s.execute("select.set", &json!({"ids": [red, blue]})).unwrap();
|
||||
let r = s.execute("document.exportSelection", &json!({"format": "png"})).unwrap();
|
||||
assert!(!has_red(&b64(&r)));
|
||||
assert!(r["bounds"][0].as_f64().unwrap() > 40.0, "only the blue square (and its stroke) is exported: {}", r["bounds"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_one_page_per_pdf_unique_names() {
|
||||
let mut s = session(100.0, 80.0, 3);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Icon"})).unwrap();
|
||||
let dir = tmp_dir("screens");
|
||||
let folder = dir.to_string_lossy().to_string();
|
||||
let r = s
|
||||
.execute(
|
||||
"document.exportForScreens",
|
||||
&json!({"folder": folder, "formats": [{"format": "pdf"}, {"format": "svg", "scale": 2}, {"format": "svg", "scale": 3}, {"format": "png", "scale": 2}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
|
||||
let names: Vec<&str> = files.iter().map(|f| f.rsplit('/').next().unwrap()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["Icon.pdf", "Icon.svg", "Icon@2x.png", "Icon-2.pdf", "Icon-2.svg", "Icon-2@2x.png", "Artboard-3.pdf", "Artboard-3.svg", "Artboard-3@2x.png"]
|
||||
);
|
||||
for f in files.iter().filter(|f| f.ends_with(".pdf")) {
|
||||
assert_eq!(pdf_pages(&std::fs::read(f).unwrap()), 1, "{f}");
|
||||
}
|
||||
// No folder: the files come back as bytes.
|
||||
let r = s.execute("document.exportForScreens", &json!({"range": "3", "formats": [{"format": "jpg", "scale": 0.5}]})).unwrap();
|
||||
assert_eq!(r["files"][0]["name"], "Artboard-3@0.5x.jpg");
|
||||
assert_eq!(image::load_from_memory(&b64(&r["files"][0])).unwrap().width(), 50);
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_sublayers_are_left_out_too() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let sub = s.execute("layer.newSublayer", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let red = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.ancestry(vectorcraft_doc::NodeId(red)).unwrap().len(), 3, "the square is on a sublayer");
|
||||
assert!(has_red(&b64(&s.execute("document.export", &json!({"format": "png"})).unwrap())));
|
||||
s.execute("layer.setProps", &json!({"id": sub, "template": true})).unwrap();
|
||||
assert!(!has_red(&b64(&s.execute("document.export", &json!({"format": "png"})).unwrap())), "a template sublayer is left out");
|
||||
s.execute("select.set", &json!({"ids": [red]})).unwrap();
|
||||
assert!(s.execute("document.exportSelection", &json!({"format": "png"})).is_err(), "nothing but template art is selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_vector_rows_drop_scale_suffixes_and_names_ignore_case() {
|
||||
let mut s = session(100.0, 80.0, 3);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "icon"})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards[2].name.clear();
|
||||
// The dialog's second row starts as PNG @2x; switched to PDF it keeps that suffix text.
|
||||
let formats =
|
||||
json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "pdf", "scale": 2, "suffix": "@2x"}, {"format": "svg", "suffix": "-web"}]);
|
||||
let r = s.execute("document.exportForScreens", &json!({"formats": formats})).unwrap();
|
||||
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"Icon.png",
|
||||
"Icon.pdf",
|
||||
"Icon-web.svg",
|
||||
"icon-2.png",
|
||||
"icon-2.pdf",
|
||||
"icon-2-web.svg",
|
||||
"Artboard-3.png",
|
||||
"Artboard-3.pdf",
|
||||
"Artboard-3-web.svg"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_raster_exports_are_refused() {
|
||||
let mut s = session(2000.0, 2000.0, 1);
|
||||
let e = s.execute("document.export", &json!({"format": "webp", "scale": 10})).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "scale": 40})).is_err(), "80000 px a side");
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ mod docmenu;
|
|||
mod draw2;
|
||||
mod edit;
|
||||
mod effectcmd;
|
||||
mod file;
|
||||
pub mod fileio;
|
||||
mod fonts;
|
||||
mod gradient;
|
||||
pub(crate) mod graph;
|
||||
|
|
@ -146,7 +146,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(edit::specs());
|
||||
v.extend(clipboard::specs());
|
||||
v.extend(recolor::specs());
|
||||
v.extend(file::specs());
|
||||
v.extend(fileio::specs());
|
||||
v.extend(create::specs());
|
||||
v.extend(object::specs());
|
||||
v.extend(path::specs());
|
||||
|
|
@ -307,3 +307,38 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
|||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
/// A 1-based page/artboard range such as `"1-3, 5"` → 0-based indices in the order given (repeats
|
||||
/// dropped). Each number must lie in `1..=count`; `"3-"` runs to the last one, `"-2"` from the first.
|
||||
pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usize>, String> {
|
||||
let num = |t: &str, open: usize| -> std::result::Result<usize, String> {
|
||||
let t = t.trim();
|
||||
if t.is_empty() {
|
||||
return Ok(open);
|
||||
}
|
||||
t.parse().ok().filter(|n| (1..=count).contains(n)).ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
|
||||
};
|
||||
let mut out = vec![];
|
||||
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.replace('\u{2013}', "-").split_once('-') {
|
||||
Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")),
|
||||
Some((a, b)) => (num(a, 1)?, num(b, count)?),
|
||||
None => {
|
||||
let n = num(part, 0)?;
|
||||
(n, n)
|
||||
}
|
||||
};
|
||||
if a > b {
|
||||
return Err(format!("range `{s}`: `{part}` runs backwards"));
|
||||
}
|
||||
for i in a - 1..b {
|
||||
if !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(format!("range `{s}` is empty"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ impl Default for Headless {
|
|||
/// Commands the desktop app adds on top of the engine; the headless backend implements them too so
|
||||
/// `run_command` behaves the same in both modes.
|
||||
const HOST_COMMANDS: &[(&str, &str, &str)] = &[
|
||||
("file.open", "Open…", "{path} open a .vectorcraft or .svg file as a new document"),
|
||||
("file.open", "Open…", "{path} open any readable file (see document.formats) as a new document; templates open untitled"),
|
||||
("file.save", "Save", "{path?} save as .vectorcraft (default: the document's path)"),
|
||||
("file.saveAs", "Save As…", "{path}"),
|
||||
("file.export", "Export…", "{format?: svg|png|vectorcraft, path, scale?, artboard?}"),
|
||||
("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"),
|
||||
("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"),
|
||||
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),
|
||||
];
|
||||
|
||||
|
|
@ -36,14 +37,6 @@ fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
|
|||
p.get(k).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
fn ext_of(path: &str) -> String {
|
||||
std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn pointer_kind(k: &str) -> Option<PointerKind> {
|
||||
Some(match k {
|
||||
"down" => PointerKind::Down,
|
||||
|
|
@ -111,6 +104,7 @@ impl Headless {
|
|||
"file.open" => self.open(params),
|
||||
"file.save" | "file.saveAs" => self.save(params),
|
||||
"file.export" => self.export(params),
|
||||
"file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()),
|
||||
"tool.select" => self.select_tool(params),
|
||||
_ => self.session.execute(id, params).map_err(|e| e.to_string()),
|
||||
}
|
||||
|
|
@ -215,72 +209,24 @@ impl Headless {
|
|||
}
|
||||
}
|
||||
|
||||
/// `app.open {path}`: `.vectorcraft` or `.svg` as a new active document.
|
||||
/// `app.open {path}`: any readable file as a new active document (`document.open`).
|
||||
pub fn open(&mut self, p: &Value) -> Result<Value, String> {
|
||||
let path = s(p, "path").ok_or("missing `path`")?;
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?;
|
||||
let name = file_name(path);
|
||||
let e = ext_of(path);
|
||||
let mut warnings = vec![];
|
||||
let (doc, keep_path) = if vectorcraft_format::is_native_ext(&e) || vectorcraft_format::sniff(&bytes) {
|
||||
let mut d = vectorcraft_format::load(&bytes).map_err(|e| e.to_string())?;
|
||||
if d.title.is_empty() {
|
||||
d.title = name.clone();
|
||||
}
|
||||
(d, true)
|
||||
} else if e == "svg" || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
|
||||
let (mut d, w) = vectorcraft_svg::import_with_report(text).map_err(|e| e.to_string())?;
|
||||
d.title = name.clone();
|
||||
warnings = w;
|
||||
(d, false)
|
||||
} else {
|
||||
return Err(format!("headless mode can open .vectorcraft and .svg files, not .{e}"));
|
||||
};
|
||||
let index = self.session.add_document(doc, keep_path.then(|| path.to_string()));
|
||||
Ok(json!({"index": index, "title": name, "warnings": warnings}))
|
||||
self.session.execute("document.open", &json!({"path": path})).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `app.save {path?}`: native format; remembers the path and clears the dirty flag.
|
||||
pub fn save(&mut self, p: &Value) -> Result<Value, String> {
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
let path = s(p, "path").map(str::to_string).or_else(|| st.path.clone()).ok_or("missing `path` (document was never saved)")?;
|
||||
let bytes = vectorcraft_format::save_file(&st.doc);
|
||||
std::fs::write(&path, bytes).map_err(|e| format!("write {path}: {e}"))?;
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
st.path = Some(path.clone());
|
||||
st.mark_saved();
|
||||
if s(p, "path").is_none() && st.path.is_none() {
|
||||
return Err("missing `path` (document was never saved)".into());
|
||||
}
|
||||
Ok(json!({"path": path}))
|
||||
self.session.execute("document.save", p).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `app.export {format?, path, scale?, artboard?}`: svg | png | pdf | jpg | webp | vectorcraft (format defaults to the extension).
|
||||
/// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension).
|
||||
pub fn export(&mut self, p: &Value) -> Result<Value, String> {
|
||||
let path = s(p, "path").ok_or("missing `path`")?;
|
||||
let fmt = s(p, "format").map(str::to_ascii_lowercase).unwrap_or_else(|| ext_of(path));
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
let doc = st.doc.clone();
|
||||
let bytes = match fmt.as_str() {
|
||||
"svg" => {
|
||||
let ab = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions { artboard: Some(ab), ..Default::default() }).into_bytes()
|
||||
}
|
||||
"png" => {
|
||||
let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?;
|
||||
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0);
|
||||
self.renderer.render_region(&doc, r, scale, false).to_png()
|
||||
}
|
||||
"vectorcraft" => vectorcraft_format::save_file(&doc),
|
||||
// pdf, jpg, webp…: the engine's exporter (same bytes as the app).
|
||||
other => {
|
||||
let params = json!({"path": path, "format": other, "scale": p.get("scale"), "artboard": p.get("artboard")});
|
||||
let r = self.session.execute("document.export", ¶ms).map_err(|e| e.to_string())?;
|
||||
return Ok(json!({"path": path, "format": fmt, "bytes": r["bytes"]}));
|
||||
}
|
||||
};
|
||||
std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?;
|
||||
Ok(json!({"path": path, "format": fmt, "bytes": bytes.len()}))
|
||||
self.session.execute("document.export", p).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,3 +29,5 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_fileio;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
//! File I/O through MCP: every readable format opens headless, and `export` sends one
|
||||
//! `document.export` call whatever the backend.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Backend, Headless, Remote, call_tool, tool_definitions};
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-fileio-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn text(r: &crate::ToolResult) -> Value {
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// A headless session with two artboards of different widths (100 and 40 pt) and a rectangle.
|
||||
fn two_boards() -> Headless {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 50, "artboards": 2}})).unwrap();
|
||||
h.call("engine.execute", json!({"command": "artboard.setProps", "params": {"index": 1, "width": 40}})).unwrap();
|
||||
h.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}})).unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_opens_pdf_ai_and_templates() {
|
||||
let mut h = two_boards();
|
||||
let pdf = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "pdf"}})).unwrap();
|
||||
let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
for name in ["x.pdf", "x.ai"] {
|
||||
let path = tmp(name);
|
||||
std::fs::write(&path, &pdf).unwrap();
|
||||
let r = text(&call_tool(&mut h, "open_file", &json!({"path": path})));
|
||||
assert_eq!(r["title"], name);
|
||||
assert_eq!(h.session.doc().unwrap().doc.artboards.len(), 2, "one artboard per page");
|
||||
}
|
||||
let ait = tmp("x.ait");
|
||||
std::fs::write(&ait, &pdf).unwrap();
|
||||
let r = text(&call_tool(&mut h, "open_file", &json!({"path": ait})));
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
let tpl = tmp("t.vectorcraft");
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tpl}})).unwrap();
|
||||
let r = text(&call_tool(&mut h, "open_file", &json!({"path": tpl})));
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(h.session.doc().unwrap().path, None, "Save asks for a new name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_tool_lists_engine_formats_and_options() {
|
||||
let tools = tool_definitions();
|
||||
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]));
|
||||
for k in ["artboard", "range", "options"] {
|
||||
assert!(props.get(k).is_some(), "export takes {k}");
|
||||
}
|
||||
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
|
||||
assert!(open["description"].as_str().unwrap().contains(".ait"));
|
||||
}
|
||||
|
||||
/// A fake app that records `engine.execute` calls and answers each with an empty object.
|
||||
fn recording_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let h = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut out = stream.try_clone().unwrap();
|
||||
let mut seen = vec![];
|
||||
for line in BufReader::new(stream).lines() {
|
||||
let msg: Value = serde_json::from_str(&line.unwrap()).unwrap();
|
||||
writeln!(out, "{}", json!({"id": msg["id"], "ok": true, "result": {}})).unwrap();
|
||||
seen.push(msg);
|
||||
}
|
||||
seen
|
||||
});
|
||||
(addr, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_is_the_same_call_in_the_app_and_headless() {
|
||||
let args = json!({"format": "png", "artboard": 1, "options": {"scale": 2, "artboard": 0, "range": "1-2"}});
|
||||
let (addr, app) = recording_app();
|
||||
let mut remote = Remote::connect(&addr).unwrap();
|
||||
assert!(!call_tool(&mut remote, "export", &args).is_error);
|
||||
drop(remote);
|
||||
let seen = app.join().unwrap();
|
||||
assert_eq!(seen[0]["method"], "engine.execute");
|
||||
assert_eq!(seen[0]["params"], json!({"command": "document.export", "params": {"format": "png", "artboard": 1, "scale": 2}}));
|
||||
|
||||
// Headless runs that very call: artboard 1 (40 pt wide) at scale 2.
|
||||
let mut h = two_boards();
|
||||
let r = text(&call_tool(&mut h, "export", &args));
|
||||
let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(&png[16..20], 80u32.to_be_bytes(), "IHDR width");
|
||||
let direct = h.call("engine.execute", seen[0]["params"].clone()).unwrap();
|
||||
assert_eq!(direct["dataBase64"], r["dataBase64"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_native_keeps_the_document_path() {
|
||||
let mut h = two_boards();
|
||||
let doc = tmp("keep.vectorcraft");
|
||||
h.call("app.save", json!({"path": doc})).unwrap();
|
||||
let copy = tmp("copy.vectorcraft");
|
||||
let r = text(&call_tool(&mut h, "export", &json!({"path": copy})));
|
||||
assert_eq!(r["format"], "vectorcraft");
|
||||
assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str()));
|
||||
// PDF range through the tool: one page.
|
||||
let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf", "range": "2"})));
|
||||
let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(pdf_pages(&pdf), 1);
|
||||
}
|
||||
|
||||
fn pdf_pages(pdf: &[u8]) -> usize {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}}))
|
||||
.unwrap();
|
||||
h.session.doc().unwrap().doc.artboards.len()
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
//! [`Backend`].
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
|
|
@ -246,7 +247,10 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
tool(
|
||||
"open_file",
|
||||
"Open file",
|
||||
"Open a .vectorcraft or .svg file as a new, active document.",
|
||||
&format!(
|
||||
"Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document.",
|
||||
OPEN_EXTS.join(", .")
|
||||
),
|
||||
obj(json!({"path": string("File path")}), &["path"]),
|
||||
false,
|
||||
),
|
||||
|
|
@ -260,16 +264,19 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export the active document: svg (artboard viewBox), pdf, png/jpg/webp (rendered artboard) or vectorcraft (native). `selection: true` exports only the selected objects, cropped to their bounds. Live effects are kept (geometry baked, SVG filters for shadows/glows/blur).",
|
||||
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp (one rendered artboard) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
obj(
|
||||
json!({
|
||||
"format": {"type": "string", "enum": ["svg", "png", "pdf", "jpg", "webp", "vectorcraft"], "description": "Default: from the path's extension"},
|
||||
"path": string("Destination file"),
|
||||
"scale": num("PNG pixels per point (default 1)"),
|
||||
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
|
||||
"path": string("Destination file (omit to get dataBase64)"),
|
||||
"scale": num("Raster pixels per point (default 1)"),
|
||||
"artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF default: all)"},
|
||||
"range": string("1-based artboards such as \"1-3, 5\" (PDF: one page each; other formats take one)"),
|
||||
"selection": {"type": "boolean", "description": "Export only the selection, cropped to it"},
|
||||
"outlineText": {"type": "boolean", "description": "SVG: text as outlines (viewable without the fonts)"},
|
||||
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg"},
|
||||
}),
|
||||
&["path"],
|
||||
&[],
|
||||
),
|
||||
false,
|
||||
),
|
||||
|
|
@ -729,31 +736,21 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
|||
"open_file" => j(b.call("app.open", json!({"path": req_str(a, "path")?}))?),
|
||||
"save_file" => j(b.call("app.save", json!({"path": a.get("path").and_then(Value::as_str)}))?),
|
||||
"export" => {
|
||||
let path = req_str(a, "path")?;
|
||||
let fmt = a
|
||||
.get("format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default());
|
||||
if !["svg", "png", "pdf", "jpg", "jpeg", "webp", "vectorcraft"].contains(&fmt.as_str()) {
|
||||
return Err(format!("unknown export format `{fmt}` (svg, png, pdf, jpg, webp, vectorcraft)"));
|
||||
// One params object for every backend; the named arguments win over `options` (a named
|
||||
// artboard or range replaces every artboard choice in `options`).
|
||||
let mut params = a.get("options").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
if ["artboard", "range"].iter().any(|k| a.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
for k in ARTBOARD_PARAMS {
|
||||
params.remove(k);
|
||||
}
|
||||
}
|
||||
let scale = a.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
|
||||
if a.get("selection").and_then(Value::as_bool) == Some(true) {
|
||||
return j(exec(
|
||||
b,
|
||||
"document.exportSelection",
|
||||
json!({"path": path, "format": fmt, "scale": scale, "outlineText": a.get("outlineText")}),
|
||||
)?);
|
||||
for k in ["path", "format", "scale", "artboard", "range", "outlineText"] {
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
params.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
if a.get("outlineText").and_then(Value::as_bool) == Some(true) {
|
||||
return j(exec(b, "document.export", json!({"path": path, "format": fmt, "scale": scale, "outlineText": true}))?);
|
||||
}
|
||||
// The desktop app exports native files through Save (which also sets the document path).
|
||||
if fmt == "vectorcraft" && b.has_ui() {
|
||||
return j(b.call("app.save", json!({"path": path}))?);
|
||||
}
|
||||
j(b.call("app.export", json!({"format": fmt, "path": path, "scale": scale}))?)
|
||||
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
|
||||
j(exec(b, cmd, Value::Object(params))?)
|
||||
}
|
||||
"add_text" => j(add_text(b, a)?),
|
||||
"apply_effect" => {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ pub struct RenderOptions {
|
|||
pub overprint_preview: bool,
|
||||
/// Trim View: clip the artwork to the artboards (nothing on the pasteboard is drawn).
|
||||
pub trim: bool,
|
||||
/// Leave template layers out (exports and thumbnails: templates are guides, not artwork).
|
||||
pub skip_templates: bool,
|
||||
}
|
||||
|
||||
impl Default for RenderOptions {
|
||||
|
|
@ -61,6 +63,7 @@ impl Default for RenderOptions {
|
|||
proof: None,
|
||||
overprint_preview: false,
|
||||
trim: false,
|
||||
skip_templates: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -327,12 +330,13 @@ impl Renderer {
|
|||
Rendered { width: w as u32, height: h as u32, pixels }
|
||||
}
|
||||
|
||||
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white.
|
||||
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white,
|
||||
/// as exported: template layers are left out.
|
||||
pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered {
|
||||
let w = (region.width() * scale).round().max(1.0) as u32;
|
||||
let h = (region.height() * scale).round().max(1.0) as u32;
|
||||
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
|
||||
let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), ..Default::default() };
|
||||
let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), skip_templates: true, ..Default::default() };
|
||||
self.render(doc, w, h, view, &opts)
|
||||
}
|
||||
|
||||
|
|
@ -402,7 +406,8 @@ impl Renderer {
|
|||
}
|
||||
|
||||
fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
|
||||
if !a.visible || f.opts.hidden.contains(&a.id) {
|
||||
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. });
|
||||
if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) {
|
||||
return;
|
||||
}
|
||||
match self.bounds_of(a) {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@
|
|||
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
|
||||
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}`
|
||||
//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG)
|
||||
//! - `app.open {path}` / `app.save {path?}` / `app.export {format, path, scale?}` / `app.quit`
|
||||
//! - `app.open {path}` (any readable format) / `app.save {path?}` / `app.quit`
|
||||
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
|
||||
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
|
||||
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
|
||||
//! the host; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless)
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
|
|
@ -294,13 +296,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
}
|
||||
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
|
||||
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
|
||||
"app.export" => {
|
||||
let f = s("format").unwrap_or("png");
|
||||
wrap(
|
||||
crate::io::export(app, f, s("path").map(str::to_string), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0))
|
||||
.map(|p| json!({"path": p})),
|
||||
)
|
||||
}
|
||||
"app.export" => match s("path") {
|
||||
Some(path) => wrap(crate::io::export(app, s("format"), Some(path.to_string()), p).map(|p| json!({"path": p}))),
|
||||
// No save dialog for an agent: the bytes come back, as in headless mode.
|
||||
None => wrap(app.run("document.export", p.clone())),
|
||||
},
|
||||
"app.quit" => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ok(Value::Null)
|
||||
|
|
|
|||
|
|
@ -1,121 +1,20 @@
|
|||
//! File I/O through the injected services: open, save, export, place.
|
||||
//! File I/O through the injected services (pick, read, write, download); the engine's `fileio`
|
||||
//! decodes and encodes every format.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::{ImageObject, Node, NodeKind};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
fn ext(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Decode an image and make a new document (or place into the active one when `place`).
|
||||
fn image_doc(name: &str, bytes: &[u8]) -> Result<Document, String> {
|
||||
let (w, h) = image_size(bytes)?;
|
||||
let mut d = Document::new(w as f64, h as f64);
|
||||
d.title = name.to_string();
|
||||
let key = format!("img{:016x}", hash(bytes));
|
||||
d.images.insert(key.clone(), ImageBlob { mime: mime_of(name), bytes: Arc::new(bytes.to_vec()) });
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf: Affine::IDENTITY, link: None }));
|
||||
n.name = Some(name.to_string());
|
||||
d.insert(Some(l), 0, n).map_err(|e| e.to_string())?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
pub fn image_size(bytes: &[u8]) -> Result<(u32, u32), String> {
|
||||
// PNG / JPEG / GIF / WebP headers — enough to size a placed image without decoding.
|
||||
if bytes.len() > 24 && &bytes[..8] == b"\x89PNG\r\n\x1a\n" {
|
||||
let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
|
||||
let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
return Ok((w, h));
|
||||
}
|
||||
if bytes.len() > 10 && &bytes[..6] == b"GIF89a" || bytes.len() > 10 && &bytes[..6] == b"GIF87a" {
|
||||
return Ok((u16::from_le_bytes([bytes[6], bytes[7]]) as u32, u16::from_le_bytes([bytes[8], bytes[9]]) as u32));
|
||||
}
|
||||
if bytes.len() > 4 && bytes[0] == 0xFF && bytes[1] == 0xD8 {
|
||||
let mut i = 2;
|
||||
while i + 9 < bytes.len() {
|
||||
if bytes[i] != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let marker = bytes[i + 1];
|
||||
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
|
||||
if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
|
||||
let h = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32;
|
||||
let w = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32;
|
||||
return Ok((w, h));
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
}
|
||||
Err("unsupported image format".into())
|
||||
}
|
||||
|
||||
fn mime_of(name: &str) -> String {
|
||||
match ext(name).as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn hash(b: &[u8]) -> u64 {
|
||||
// FNV-1a: stable content key for embedded images.
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for x in b {
|
||||
h ^= *x as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Open bytes as a new document.
|
||||
/// Open bytes of any readable format as a new document (templates open untitled).
|
||||
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> {
|
||||
let e = ext(name);
|
||||
let (doc, keep_path) = if vectorcraft_format::is_native_ext(&e) || vectorcraft_format::sniff(bytes) {
|
||||
let mut d = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?;
|
||||
if d.template {
|
||||
// Templates open as a new untitled document.
|
||||
d.template = false;
|
||||
d.title = app.session.next_untitled();
|
||||
(d, false)
|
||||
} else {
|
||||
if d.title.is_empty() {
|
||||
d.title = name.to_string();
|
||||
}
|
||||
(d, true)
|
||||
}
|
||||
} else if e == "svg" || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
|
||||
let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
|
||||
let (mut d, warnings) = vectorcraft_svg::import_with_report(s).map_err(|e| e.to_string())?;
|
||||
d.title = name.to_string();
|
||||
if !warnings.is_empty() {
|
||||
app.status(format!("Opened with {} warning(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default()));
|
||||
}
|
||||
(d, false)
|
||||
} else if e == "pdf" || e == "ai" || bytes.starts_with(b"%PDF") {
|
||||
let vectorcraft_pdf::ImportReport { document: mut d, warnings } =
|
||||
vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(|e| e.to_string())?;
|
||||
d.title = name.to_string();
|
||||
if !warnings.is_empty() {
|
||||
app.status(format!("Opened with {} note(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default()));
|
||||
}
|
||||
(d, false)
|
||||
} else if ["png", "jpg", "jpeg", "gif", "webp"].contains(&e.as_str()) {
|
||||
(image_doc(name, bytes)?, false)
|
||||
} else {
|
||||
return Err(format!("VectorCraft can't open .{e} files yet"));
|
||||
};
|
||||
app.session.add_document(doc, if keep_path { path } else { None });
|
||||
let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?;
|
||||
app.sync_views();
|
||||
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
|
||||
app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -132,8 +31,7 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> {
|
|||
pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> {
|
||||
let read = app.services.read.as_ref().ok_or("no file reader")?;
|
||||
let bytes = read(path)?;
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string());
|
||||
open_bytes(app, &name, &bytes, Some(path.to_string()))?;
|
||||
open_bytes(app, path, &bytes, Some(path.to_string()))?;
|
||||
note_recent(app, path);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -154,19 +52,25 @@ fn suggested(app: &VectorcraftApp, ext: &str) -> String {
|
|||
format!("{stem}.{ext}")
|
||||
}
|
||||
|
||||
/// `path`, else a suggested name (web download) or one picked in a save dialog.
|
||||
fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Result<String, String> {
|
||||
match path {
|
||||
Some(p) => Ok(p),
|
||||
None if app.services.download.is_some() => Ok(suggested(app, ext)),
|
||||
None => {
|
||||
let s = suggested(app, ext);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or_else(|| "cancelled".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File → Save / Save As (native format).
|
||||
pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool) -> Result<String, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let existing = if save_as { None } else { st.path.clone() };
|
||||
let path = match path.or(existing) {
|
||||
Some(p) => p,
|
||||
None if app.services.download.is_some() => suggested(app, "vectorcraft"),
|
||||
None => {
|
||||
let s = suggested(app, "vectorcraft");
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
|
||||
}
|
||||
};
|
||||
let bytes = vectorcraft_format::save_file(&app.session.active().unwrap().doc);
|
||||
let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?;
|
||||
let doc = app.session.active().ok_or("no document")?.doc.clone();
|
||||
let bytes = fileio::encode(&doc, "vectorcraft", &Value::Null).map_err(|e| e.to_string())?;
|
||||
write_out(app, &path, &bytes)?;
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.path = Some(path.clone());
|
||||
|
|
@ -185,32 +89,13 @@ pub fn note_recent(app: &mut VectorcraftApp, path: &str) {
|
|||
r.truncate((app.session.prefs.recent_files_count as usize).clamp(1, 10));
|
||||
}
|
||||
|
||||
/// Export the document as SVG / PDF / PNG / JPEG / WebP.
|
||||
pub fn export(app: &mut VectorcraftApp, format: &str, path: Option<String>, scale: f64) -> Result<String, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let doc = st.doc.clone();
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None if app.services.download.is_some() => suggested(app, format),
|
||||
None => {
|
||||
let s = suggested(app, format);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
|
||||
}
|
||||
};
|
||||
let bytes = match format {
|
||||
"svg" => vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions { artboard: Some(0), ..Default::default() }).into_bytes(),
|
||||
"pdf" => vectorcraft_engine::export_pdf(&doc, &vectorcraft_pdf::PdfOptions::default()).map_err(|e| e.to_string())?,
|
||||
"png" | "jpg" | "jpeg" | "webp" => {
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or("no artboard")?;
|
||||
let img = app.canvas.renderer.render_region(&doc, r, scale, format == "jpg" || format == "jpeg");
|
||||
match format {
|
||||
"png" => img.to_png(),
|
||||
"webp" => img.to_webp(),
|
||||
_ => img.to_jpeg(90),
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown export format `{other}`")),
|
||||
};
|
||||
/// Export the active document in `format` (default: the path's extension, else PNG) with the
|
||||
/// `document.export` options in `params` (artboard, range, scale…). The document keeps its path.
|
||||
pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<String>, params: &Value) -> Result<String, String> {
|
||||
let f = fileio::writable_format(format, path.as_deref())?;
|
||||
let doc = app.session.active().ok_or("no document")?.doc.clone();
|
||||
let path = target_path(app, path, f.extensions[0])?;
|
||||
let bytes = fileio::encode(&doc, f.id, params).map_err(|e| e.to_string())?;
|
||||
write_out(app, &path, &bytes)?;
|
||||
app.status(format!("Exported {path}"));
|
||||
Ok(path)
|
||||
|
|
@ -218,19 +103,12 @@ pub fn export(app: &mut VectorcraftApp, format: &str, path: Option<String>, scal
|
|||
|
||||
/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path
|
||||
/// (Export Selection, Save as Template).
|
||||
pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: serde_json::Value) -> Result<String, String> {
|
||||
let path = match params.get("path").and_then(serde_json::Value::as_str) {
|
||||
Some(p) => p.to_string(),
|
||||
None if app.services.download.is_some() => suggested(app, ext),
|
||||
None => {
|
||||
let s = suggested(app, ext);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
|
||||
}
|
||||
};
|
||||
pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<String, String> {
|
||||
let path = target_path(app, params.get("path").and_then(Value::as_str).map(str::to_string), ext)?;
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("path");
|
||||
if o.get("format").is_none() && id == "document.exportSelection" {
|
||||
let e = std::path::Path::new(&path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| ext.into());
|
||||
let e = Some(fileio::extension(&path)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into());
|
||||
o.insert("format".into(), serde_json::json!(e));
|
||||
}
|
||||
}
|
||||
|
|
@ -243,10 +121,12 @@ pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut pa
|
|||
|
||||
/// File → Place… (embed an image or SVG into the active document).
|
||||
pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let e = ext(name);
|
||||
if e == "svg" {
|
||||
let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
|
||||
let src = vectorcraft_svg::import(s).map_err(|e| e.to_string())?;
|
||||
let f = fileio::detect(name, bytes).ok_or_else(|| format!("can't place `{name}`: not a format VectorCraft reads"))?;
|
||||
if !f.raster {
|
||||
if !matches!(f.id, "svg" | "svgz") {
|
||||
return Err(format!("Place doesn't take {} files yet", f.label));
|
||||
}
|
||||
let src = fileio::load(name, bytes).map_err(|e| e.to_string())?.doc;
|
||||
let nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
|
||||
app.session.clipboard = nodes;
|
||||
// Straight to the engine: `app.run` would let the system clipboard replace these nodes.
|
||||
|
|
@ -254,19 +134,16 @@ pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result
|
|||
app.sync_views();
|
||||
return Ok(());
|
||||
}
|
||||
let (w, h) = image_size(bytes)?;
|
||||
let key = format!("img{:016x}", hash(bytes));
|
||||
let fileio::RasterImage { key, blob, width: w, height: h } = fileio::raster_image(bytes).map_err(|e| e.to_string())?;
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let ab = st.doc.artboards.first().map(|a| a.rect).unwrap_or_default();
|
||||
let s = (ab.width() / w as f64).min(ab.height() / h as f64).min(1.0);
|
||||
let xf = Affine::translate((ab.center().x - w as f64 * s / 2.0, ab.center().y - h as f64 * s / 2.0)) * Affine::scale(s);
|
||||
let mime = mime_of(name);
|
||||
let bytes = Arc::new(bytes.to_vec());
|
||||
let parent = st.insertion_parent();
|
||||
let name = name.to_string();
|
||||
app.session
|
||||
.edit("Place", |d, sel| {
|
||||
d.images.insert(key.clone(), ImageBlob { mime, bytes });
|
||||
d.images.insert(key.clone(), blob);
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None }));
|
||||
n.name = Some(name);
|
||||
|
|
@ -276,3 +153,112 @@ pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result
|
|||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
|
||||
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// An app whose writer records what it is given.
|
||||
fn app() -> (VectorcraftApp, Written) {
|
||||
let written = Written::default();
|
||||
let w = written.clone();
|
||||
let services = Services {
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(VectorcraftApp::new(Session::new(), services), written)
|
||||
}
|
||||
|
||||
fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec<u8> {
|
||||
let v = app.session.execute(cmd, &p).unwrap();
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn image_size(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> (u32, u32) {
|
||||
match &app.session.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Image(im) => (im.width, im.height),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_opens_and_places_at_its_pixel_size() {
|
||||
let (mut app, _) = app();
|
||||
app.session.execute("file.new", &json!({"width": 3, "height": 2})).unwrap();
|
||||
let webp = bytes_of(&mut app, "document.serialize", json!({"format": "webp"}));
|
||||
open_bytes(&mut app, "tiny.webp", &webp, None).unwrap();
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(app.views.len(), 2, "views follow the documents");
|
||||
let first = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].id;
|
||||
assert_eq!(image_size(&app, first), (3, 2));
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
place_bytes(&mut app, "tiny.webp", &webp).unwrap();
|
||||
let placed = app.session.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(image_size(&app, placed), (3, 2));
|
||||
assert!(place_bytes(&mut app, "x.txt", b"hello").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_open_untitled() {
|
||||
let (mut app, _) = app();
|
||||
app.session.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
|
||||
let pdf = bytes_of(&mut app, "document.serialize", json!({"format": "pdf"}));
|
||||
open_bytes(&mut app, "brochure.ait", &pdf, Some("/tmp/brochure.ait".into())).unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(st.title().starts_with("Untitled-"), "{}", st.title());
|
||||
assert_eq!(st.path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_uses_the_engine_options_and_keeps_the_path() {
|
||||
let (mut app, written) = app();
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 50, "artboards": 2})).unwrap();
|
||||
app.session.execute("artboard.setProps", &json!({"index": 1, "width": 40})).unwrap();
|
||||
app.session.doc_mut().unwrap().path = Some("/tmp/doc.vectorcraft".into());
|
||||
export(&mut app, None, Some("/tmp/b.png".into()), &json!({"artboard": 1, "scale": 2})).unwrap();
|
||||
export(&mut app, None, Some("/tmp/copy.vectorcraft".into()), &Value::Null).unwrap();
|
||||
let w = written.borrow();
|
||||
assert_eq!(&w[0].1[16..20], 80u32.to_be_bytes(), "artboard 1 (40 pt) at scale 2");
|
||||
assert!(vectorcraft_format::sniff(&w[1].1));
|
||||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft"));
|
||||
drop(w);
|
||||
assert!(export(&mut app, None, Some("/tmp/x.bmp".into()), &Value::Null).is_err(), "BMP is read-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_with_params_runs_the_engine() {
|
||||
let (mut app, _) = app();
|
||||
app.session.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap();
|
||||
let r = app.run("file.exportForScreens", json!({"range": "2-3", "formats": [{"format": "pdf"}]})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(files[0]["name"], "Artboard-2.pdf");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
app.run("file.exportForScreens", Value::Null).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("exportForScreens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_export_without_path_returns_bytes() {
|
||||
let (mut app, written) = app();
|
||||
app.session.execute("file.new", &json!({"width": 30, "height": 20})).unwrap();
|
||||
let (req, _) = crate::control::ControlRequest::new("app.export", json!({"format": "png", "scale": 2}));
|
||||
let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") };
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let png = vectorcraft_format::base64_decode(r["result"]["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(&png[16..20], 60u32.to_be_bytes(), "30 pt at scale 2");
|
||||
assert!(written.borrow().is_empty(), "no file and no save dialog");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,9 +87,9 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
("file.clearRecent", "Clear Recent Files", "", "{}"),
|
||||
("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"),
|
||||
("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"),
|
||||
("file.export.svg", "Export As SVG…", "", "{path?}"),
|
||||
("file.export.png", "Export As PNG…", "", "{path?, scale?: 1}"),
|
||||
("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{path?, scale?}"),
|
||||
("file.export.svg", "Export As SVG…", "", "{path?, artboard?, outlineText?} (document.export options)"),
|
||||
("file.export.png", "Export As PNG…", "", "{path?, scale?: 1, artboard?} (document.export options)"),
|
||||
("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{} opens the dialog; with params = document.exportForScreens"),
|
||||
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
|
||||
("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"),
|
||||
("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"),
|
||||
|
|
@ -156,7 +156,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
("ui.paramDialog", "Command Dialog", "", "{command, label?, params} open a parameter dialog for any command"),
|
||||
("ui.recolorDialog", "Recolor Artwork…", "", "{} open Recolor Artwork (engine: recolor.colors / recolor.apply)"),
|
||||
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
|
||||
("file.export.pdf", "Save as PDF…", "", "{path?}"),
|
||||
("file.export.pdf", "Save as PDF…", "", "{path?, artboard? | artboards? | range?: \"1-3, 5\"} (document.export options)"),
|
||||
("help.about", "About VectorCraft", "", "{}"),
|
||||
("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"),
|
||||
("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"),
|
||||
|
|
@ -240,8 +240,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
None => Err("cancelled".into()),
|
||||
}
|
||||
}
|
||||
"file.export.svg" => io::export(app, "svg", s("path"), 1.0).map(|p| json!({"path": p})),
|
||||
"file.exportForScreens" if p.get("path").is_none() && p.get("scale").is_none() => {
|
||||
"file.export.svg" => io::export(app, Some("svg"), s("path"), p).map(|p| json!({"path": p})),
|
||||
"file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(
|
||||
|
|
@ -250,9 +250,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.export.png" | "file.exportForScreens" => {
|
||||
io::export(app, "png", s("path"), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p}))
|
||||
}
|
||||
"file.exportForScreens" => app.run("document.exportForScreens", p.clone()),
|
||||
"file.export.png" => io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})),
|
||||
"file.documentSetup" => {
|
||||
let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points");
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units})));
|
||||
|
|
@ -466,7 +465,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})),
|
||||
None => Err("no effect applied yet".into()),
|
||||
},
|
||||
"file.export.pdf" => io::export(app, "pdf", s("path"), 1.0).map(|p| json!({"path": p})),
|
||||
"file.export.pdf" => io::export(app, Some("pdf"), s("path"), p).map(|p| json!({"path": p})),
|
||||
"help.about" => {
|
||||
app.ui.about = true;
|
||||
Ok(Value::Null)
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@
|
|||
| `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
|
||||
| `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) |
|
||||
| `ui.resize` / `ui.focus` | | |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{format, path, scale?}` | `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
||||
|
|
|
|||
|
|
@ -57,9 +57,9 @@ or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"
|
|||
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
|
||||
| `open_panel` | `{panel}` | Remote only. |
|
||||
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). |
|
||||
| `open_file` | `{path}` | Opens `.vectorcraft` or `.svg` as a new active document. |
|
||||
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. `run_command document.formats` lists the formats. |
|
||||
| `save_file` | `{path?}` | Saves in the native `.vectorcraft` format. |
|
||||
| `export` | `{path, format?, scale?, selection?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft`. When `format` is omitted, it comes from the path's extension. `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Live effects are kept. |
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
|
||||
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
|
||||
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |
|
||||
|
|
@ -130,5 +130,6 @@ vectorcraft-cli run --cmd file.new --params '{"width":800,"height":600}' \
|
|||
```
|
||||
|
||||
`run` prints one JSON line per step (`open`, `cmd`, `export`) and exits non-zero on the first failure. `--params`
|
||||
applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export`
|
||||
and `tool.select`.
|
||||
applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export`,
|
||||
`file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads
|
||||
(`vectorcraft-cli --help` lists them).
|
||||
|
|
|
|||
Loading…
Reference in New Issue