diff --git a/Cargo.lock b/Cargo.lock index ec97e895..473150b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4128,6 +4128,8 @@ dependencies = [ name = "vectorcraft-engine" version = "0.1.1" dependencies = [ + "flate2", + "image", "kurbo 0.13.1", "log", "proptest", @@ -4185,7 +4187,6 @@ dependencies = [ "vectorcraft-engine", "vectorcraft-format", "vectorcraft-geom", - "vectorcraft-pdf", "vectorcraft-render", "vectorcraft-svg", "vectorcraft-testkit", diff --git a/Cargo.toml b/Cargo.toml index 53988b86..30a0ec15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/ROADMAP.md b/ROADMAP.md index 869db662..2f1b9a35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-02._ +_Last updated: 2026-10-04._ ## Where we are @@ -61,8 +61,8 @@ _Last updated: 2026-10-02._ | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode (click the mask thumbnail; live update) done; freeform gradients pending | 18–27 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); Save / Don't Save / Cancel before closing modified documents; PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 paint command modules and dialogs split per area for parallel work; neutral labels and a `cargo xtask brands` CI gate for user-visible strings; panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode (click the mask thumbnail; live update) done; freeform gradients pending | 18–27 | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 one engine loader/encoder for every frontend (desktop, web, CLI, control channel, headless MCP open native/SVG/SVGZ/PDF/.ai/.ait/PNG/JPEG/GIF/WebP/TIFF/BMP; `document.formats`; MCP `export` takes artboard/range/options; one PDF page per artboard in Export for Screens; template layers left out of raster exports); Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); Save / Don't Save / Cancel before closing modified documents; PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index ef5f34a1..6c6c2f26 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -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 = 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::, None::, 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}"); diff --git a/apps/vectorcraft-cli/tests/fileio.rs b/apps/vectorcraft-cli/tests/fileio.rs new file mode 100644 index 00000000..e95f36ab --- /dev/null +++ b/apps/vectorcraft-cli/tests/fileio.rs @@ -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(")>>>; -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", - } -} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 7c0ab7d2..9ae909c4 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -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); @@ -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()) })), diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 565ae0b7..53efb67a 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -125,7 +125,7 @@ impl IccProfile { /// Built-in RGB working spaces from `moxcms`'s standard primaries. pub fn builtin_rgb(name: &str) -> Option { let p = match name { - super::ADOBE_RGB => ColorProfile::new_adobe_rgb(), + super::WIDE_GAMUT_RGB => ColorProfile::new_adobe_rgb(), super::DISPLAY_P3 => ColorProfile::new_display_p3(), super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(), super::SRGB => ColorProfile::new_srgb(), diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 9fc84d1b..7960d44f 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -1,8 +1,8 @@ //! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents, //! gamut checks and soft-proof transforms. //! -//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Adobe RGB (1998) -//! compatible, Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB. +//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB +//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB. //! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model //! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers); //! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles). @@ -25,7 +25,8 @@ pub use lab::{Lab, delta_e76, delta_e2000}; use crate::Color; pub const SRGB: &str = "sRGB IEC61966-2.1"; -pub const ADOBE_RGB: &str = "Adobe RGB (1998) compatible"; +/// The wide-gamut RGB space with the 1998 primaries (γ 2.2, D65). +pub const WIDE_GAMUT_RGB: &str = "Wide Gamut RGB (1998 primaries)"; pub const DISPLAY_P3: &str = "Display P3"; pub const PROPHOTO_RGB: &str = "ProPhoto RGB"; /// Our parametric press model (see [`generic`]). Not an Adobe/ECI profile. @@ -33,6 +34,14 @@ pub const GENERIC_CMYK: &str = "VectorCraft Generic CMYK (SWOP-like)"; /// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers. pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)"; +/// Built-in RGB and CMYK working spaces, in menu order. +const BUILTIN_RGB: [&str; 4] = [SRGB, WIDE_GAMUT_RGB, DISPLAY_P3, PROPHOTO_RGB]; +const BUILTIN_CMYK: [&str; 2] = [GENERIC_CMYK, DEVICE_CMYK]; + +/// Names earlier versions gave built-in profiles: (old name, current name). Settings, documents +/// and commands that still use an old name get the current profile. +pub const LEGACY_NAMES: &[(&str, &str)] = &[("Adobe RGB (1998) compatible", WIDE_GAMUT_RGB)]; // brand-ok: legacy alias + /// ΔE2000 above which a colour counts as out of the CMYK gamut (the gamut warning). pub const GAMUT_THRESHOLD: f32 = 2.0; @@ -169,7 +178,7 @@ fn user_profile(name: &str) -> Option> { } fn cmyk_space(name: &str) -> Result { - match name { + match canonical_name(name) { GENERIC_CMYK => Ok(CmykSpace::Generic(generic())), DEVICE_CMYK => Ok(CmykSpace::Device), _ => match user_profile(name) { @@ -194,12 +203,23 @@ fn rgb_space(name: &str) -> Result { } } +/// The current name of a profile: names earlier versions used resolve to today's name. +pub fn canonical_name(name: &str) -> &str { + LEGACY_NAMES.iter().find(|(old, _)| *old == name).map_or(name, |(_, new)| new) +} + +/// An available profile by its current or legacy name. +pub fn profile(name: &str) -> Option { + let name = canonical_name(name); + profiles().into_iter().find(|p| p.name == name) +} + /// Every profile available (built-in first, then user-loaded). pub fn profiles() -> Vec { - let mut v: Vec = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB] + let mut v: Vec = BUILTIN_RGB .iter() .map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true }) - .chain([GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true })) + .chain(BUILTIN_CMYK.iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true })) .collect(); for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() { v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false }); @@ -214,7 +234,8 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result Result { - Ok(Self { settings: settings.clone(), rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)? }) + let settings = ColorSettings { rgb: canonical_name(&settings.rgb).into(), cmyk: canonical_name(&settings.cmyk).into(), ..settings.clone() }; + Ok(Self { rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)?, settings }) } pub fn settings(&self) -> &ColorSettings { &self.settings diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 4e4f1d8b..a5b211ca 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -138,11 +138,11 @@ fn convert_between_models() { #[test] fn wide_gamut_working_space() { - let c = Cms::new(&ColorSettings { rgb: ADOBE_RGB.into(), ..Default::default() }).unwrap(); + let c = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap(); assert!(!c.rgb_is_srgb()); let grey = c.rgb_to_srgb([0.5, 0.5, 0.5]); assert!(de(grey, [0.5, 0.5, 0.5]) < 1.5, "{grey:?}"); - // Adobe RGB green is outside sRGB: it clips to (nearly) full sRGB green. + // Wide-gamut green is outside sRGB: it clips to (nearly) full sRGB green. let g = c.rgb_to_srgb([0.0, 1.0, 0.0]); assert!(g[1] > 0.95 && g[0] < 0.1, "{g:?}"); let back = c.srgb_to_rgb(c.rgb_to_srgb([0.4, 0.5, 0.3])); @@ -150,6 +150,21 @@ fn wide_gamut_working_space() { assert!(matches!(Cms::new(&ColorSettings { rgb: "Nope".into(), ..Default::default() }), Err(CmsError::UnknownProfile(_)))); } +#[test] +fn legacy_profile_name_resolves() { + let (old, _) = LEGACY_NAMES[0]; + assert_eq!(canonical_name(old), WIDE_GAMUT_RGB); + assert_eq!(canonical_name(SRGB), SRGB); + assert_eq!(profile(old).map(|p| (p.name, p.kind, p.builtin)), Some((WIDE_GAMUT_RGB.to_string(), ProfileKind::Rgb, true))); + assert!(profile("Nope").is_none()); + assert!(profiles().iter().all(|p| p.name != old), "only the current name is listed"); + // Settings saved with the old name load as the current profile, under its current name. + let c = Cms::new(&ColorSettings { rgb: old.into(), ..Default::default() }).unwrap(); + assert_eq!(c.settings().rgb, WIDE_GAMUT_RGB); + let same = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap(); + assert_eq!(c.rgb_to_srgb([0.0, 1.0, 0.0]), same.rgb_to_srgb([0.0, 1.0, 0.0])); +} + #[test] fn proof_lut_matches_direct_proof() { let c = cms(); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 287920b9..06901e21 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs new file mode 100644 index 00000000..10f24bad --- /dev/null +++ b/crates/engine/src/cmd/appearance.rs @@ -0,0 +1,244 @@ +//! The Appearance panel: fill and stroke items, live effects, Clear / Reduce to Basic, and the +//! Eyedropper's appearance copy. + +use serde_json::{Value, json}; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeKind, StrokeLayer}; + +use super::edit::selected_roots; +use super::paint::paint_from; +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!("appearance.addFill", "Add New Fill", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, true)), + cmd!("appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, false)), + cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{}", has_selection, clear_appearance), + cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{}", has_selection, reduce_basic), + cmd!( + "appearance.setItem", + "Appearance Item", + [], + None, + "{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack", + has_selection, + set_item + ), + cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item), + cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect), + cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index}", has_selection, duplicate_item), + cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to} (paint-order indices)", has_selection, move_item), + cmd!( + "appearance.copyFrom", + "Eyedropper", + [], + None, + "{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults", + has_doc, + copy_from + ), + ] +} + +fn add_item(s: &mut Session, fill: bool) -> Result { + let ids = paint_targets(s, &json!({}))?; + s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + let item = if fill { + AppearanceItem::Fill(FillLayer::new(n.appearance.fill_paint())) + } else { + AppearanceItem::Stroke(StrokeLayer::new(n.appearance.stroke_paint(), n.appearance.stroke_width().max(1.0))) + }; + n.appearance.items.push(item); + } + } + Ok(()) + })?; + ok() +} + +fn clear_appearance(s: &mut Session, _: &Value) -> Result { + let ids = paint_targets(s, &json!({}))?; + s.edit("Clear Appearance", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0); + } + } + Ok(()) + })?; + ok() +} + +fn reduce_basic(s: &mut Session, _: &Value) -> Result { + let ids = paint_targets(s, &json!({}))?; + s.edit("Reduce to Basic Appearance", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + let f = n.appearance.fill_paint(); + let st = n.appearance.stroke().cloned(); + n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] }; + if let Some(mut st) = st { + st.effects.clear(); + st.opacity = 1.0; + st.blend = BlendMode::Normal; + n.appearance.items.push(AppearanceItem::Stroke(st)); + } + } + } + Ok(()) + })?; + ok() +} + +fn set_item(s: &mut Session, p: &Value) -> Result { + let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.setItem", "missing index"))? as usize; + let paint = paint_from(s, p)?; + let ids = paint_targets(s, p)?; + let blend = str_param(p, "blend").and_then(BlendMode::parse); + s.edit("Appearance", |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + let Some(item) = n.appearance.items.get_mut(idx) else { continue }; + let (pp, op, bl, vis) = match item { + AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible), + AppearanceItem::Stroke(st) => { + if let Some(w) = p.get("weight").and_then(Value::as_f64) { + st.width = w.max(0.0); + } + (&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible) + } + }; + if let Some(pa) = &paint { + *pp = pa.clone(); + } + if let Some(o) = p.get("opacity").and_then(Value::as_f64) { + *op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32; + } + if let Some(b) = blend { + *bl = b; + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + *vis = v; + } + } + Ok(()) + })?; + ok() +} + +fn remove_item(s: &mut Session, p: &Value) -> Result { + let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.removeItem", "missing index"))? as usize; + let ids = paint_targets(s, p)?; + s.edit("Remove Item", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) + && idx < n.appearance.items.len() + { + n.appearance.items.remove(idx); + } + } + Ok(()) + })?; + ok() +} + +fn add_effect(s: &mut Session, p: &Value) -> Result { + let id = str_param(p, "effect").ok_or_else(|| bad("appearance.addEffect", "missing effect id"))?.to_string(); + let params = p.get("params").cloned().unwrap_or(json!({})); + let ids = targets(s, p)?; + s.edit("Add Effect", |d, _| { + for nid in &ids { + if let Some(n) = d.node_mut(*nid) { + n.appearance.effects.push(vectorcraft_doc::Effect { id: id.clone(), params: params.clone(), visible: true }); + } + } + Ok(()) + })?; + ok() +} + +fn duplicate_item(s: &mut Session, p: &Value) -> Result { + let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.duplicateItem", "missing index"))? as usize; + let ids = paint_targets(s, p)?; + s.edit("Duplicate Item", |d, _| { + let mut any = false; + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + if let Some(item) = n.appearance.items.get(idx).cloned() { + n.appearance.items.insert(idx + 1, item); + any = true; + } + } + if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) } + })?; + ok() +} + +fn move_item(s: &mut Session, p: &Value) -> Result { + const C: &str = "appearance.moveItem"; + let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing from"))? as usize; + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize; + let ids = paint_targets(s, p)?; + s.edit("Reorder Appearance", |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + let items = &mut n.appearance.items; + if from >= items.len() { + return Err(bad(C, format!("no item at index {from}"))); + } + let it = items.remove(from); + let to = to.min(items.len()); + items.insert(to, it); + } + Ok(()) + })?; + ok() +} + +fn copy_from(s: &mut Session, p: &Value) -> Result { + let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?; + let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?; + let appearance = match &src.kind { + NodeKind::Text(t) => match t.runs.first() { + Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), + None => src.appearance.clone(), + }, + _ => src.appearance.clone(), + }; + s.paint.fill = appearance.fill_paint(); + s.paint.stroke = appearance.stroke_paint(); + if appearance.stroke().is_some() { + s.paint.stroke_width = appearance.stroke_width(); + } + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + let mut targets = leaf_targets(s, &ids)?; + targets.retain(|id| *id != src_id); + if targets.is_empty() { + return Ok(json!({ "ids": [] })); + } + let (opacity, blend) = (src.opacity, src.blend); + s.edit("Eyedropper", |d, _| { + for id in &targets { + let Some(n) = d.node_mut(*id) else { continue }; + n.opacity = opacity; + n.blend = blend; + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + r.style.fill = appearance.fill_paint(); + r.style.stroke = appearance.stroke_paint(); + r.style.stroke_width = appearance.stroke_width(); + } + continue; + } + n.appearance = appearance.clone(); + } + Ok(()) + })?; + Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) +} diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index be83a99e..230ed000 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -157,10 +157,10 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { let mut st = cms::active_settings(); let before = st.clone(); if let Some(v) = str_param(p, "rgb") { - st.rgb = v.to_string(); + st.rgb = cms::canonical_name(v).to_string(); } if let Some(v) = str_param(p, "cmyk") { - st.cmyk = v.to_string(); + st.cmyk = cms::canonical_name(v).to_string(); } if let Some(i) = intent_param(p, C)? { st.intent = i; @@ -189,24 +189,23 @@ fn load_profile(_: &mut Session, _: &Value) -> Result { /// The document's assigned profiles (`None` = working space). pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option, Option) { let o = d.unknown.get(PROFILES_KEY); - let get = |k: &str| o.and_then(|o| o.get(k)).and_then(Value::as_str).map(str::to_string); + let get = |k: &str| o.and_then(|o| o.get(k)).and_then(Value::as_str).map(|n| cms::canonical_name(n).to_string()); (get("rgb"), get("cmyk")) } fn assign_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.assignProfile"; let (mut rgb, mut cmyk) = doc_profiles(&s.doc()?.doc); - let known = cms::profiles(); let pick = |key: &str, cur: &mut Option, kind: cms::ProfileKind| -> Result { match p.get(key) { None => Ok(false), Some(Value::Null) => Ok(cur.take().is_some()), Some(Value::String(n)) => { - if !known.iter().any(|k| &k.name == n && k.kind == kind) { + let Some(info) = cms::profile(n).filter(|k| k.kind == kind) else { return Err(bad(C, format!("unknown {key} profile `{n}`"))); - } - let changed = cur.as_deref() != Some(n.as_str()); - *cur = Some(n.clone()); + }; + let changed = cur.as_deref() != Some(info.name.as_str()); + *cur = Some(info.name); Ok(changed) } Some(_) => Err(bad(C, format!("`{key}` must be a profile name or null"))), @@ -363,7 +362,7 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result { if let Some(t) = str_param(p, "target") { let t = ProofTarget::parse(t).ok_or_else(|| bad(C, format!("unknown proof target `{t}`")))?; if let ProofTarget::Cmyk(name) = &t - && !cms::profiles().iter().any(|k| &k.name == name && k.kind == cms::ProfileKind::Cmyk) + && !cms::profile(name).is_some_and(|k| k.kind == cms::ProfileKind::Cmyk) { return Err(bad(C, format!("unknown CMYK profile `{name}`"))); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs deleted file mode 100644 index 8ede9b52..00000000 --- a/crates/engine/src/cmd/file.rs +++ /dev/null @@ -1,284 +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 { - 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 { - 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" Result { - 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 { - 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> { - encode_doc(&s.doc()?.doc, format, scale, artboard) -} - -fn encode_doc(doc: &vectorcraft_doc::Document, format: &str, scale: f64, artboard: usize) -> Result> { - 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> { - 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 scale = scale.clamp(0.01, 64.0); - vectorcraft_render::raster_size(r, scale).map_err(EngineError::Other)?; - let img = vectorcraft_render::Renderer::new().render_region(doc, r, scale, 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 { - 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 { - 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 { - 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> = 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 { - 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 { - 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 { - 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 = 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 })) -} diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs new file mode 100644 index 00000000..108e0ffd --- /dev/null +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -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 { + 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 })) +} diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs new file mode 100644 index 00000000..07f1fd26 --- /dev/null +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -0,0 +1,141 @@ +//! 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, + pub artboards: Option>, + pub range: Option, +} + +impl ArtboardPick { + /// The named artboards (`None`: none named), checked against the document's `count`. + pub fn resolve(&self, count: usize) -> std::result::Result>, 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 { + 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, +} + +#[derive(Default, Deserialize)] +#[serde(default)] +struct RasterOptions { + #[serde(flatten)] + boards: ArtboardPick, + scale: Option, + quality: Option, +} + +fn boards(r: std::result::Result) -> Result { + r.map_err(|e| bad(C, e)) +} + +fn options(f: &Format, p: &Value) -> Result { + 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> { + 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); + vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; + 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))), + }) +} diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs new file mode 100644 index 00000000..566dd4cf --- /dev/null +++ b/crates/engine/src/cmd/fileio/export.rs @@ -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 { + 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 { + 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 { + 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> = 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 { + 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::().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 { + 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 = match p.get("formats").and_then(Value::as_array) { + Some(rows) => rows.iter().map(screen_format).collect::>()?, + 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 })) +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs new file mode 100644 index 00000000..65a06f26 --- /dev/null +++ b/crates/engine/src/cmd/fileio/load.rs @@ -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, +} + +/// 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 { + 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) -> Result { + 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 { + 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 { + 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 { + 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> { + 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)) +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs new file mode 100644 index 00000000..b8505e5c --- /dev/null +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -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 { + 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 = 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 { + 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 { + let ids = |pick: fn(&Format) -> bool| FORMATS.iter().filter(|f| pick(f)).map(|f| f.id).collect::>(); + Ok(json!({ + "formats": FORMATS.iter().map(Format::to_json).collect::>(), + "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> { + 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> { + 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 { + 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; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs new file mode 100644 index 00000000..8400b2c0 --- /dev/null +++ b/crates/engine/src/cmd/fileio/save.rs @@ -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 { + 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 { + let mut d = (*s.doc()?.doc).clone(); + d.template = true; + write_or_return(str_param(p, "path"), &vectorcraft_format::save_file(&d), json!({})) +} diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs new file mode 100644 index 00000000..ea31f52b --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -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 { + 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 { + 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"").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::>(); + 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 = 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"); +} diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs new file mode 100644 index 00000000..01b0d0e7 --- /dev/null +++ b/crates/engine/src/cmd/gradient.rs @@ -0,0 +1,205 @@ +//! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector. + +use serde_json::{Value, json}; +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::NodeKind; + +use super::edit::selected_roots; +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!( + "paint.editGradient", + "Gradient", + ["Window", "Gradient"], + None, + "{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + has_doc, + edit_gradient + ), + cmd!( + "paint.setGradientGeom", + "Gradient Vector", + [], + None, + "{start: [x,y], end: [x,y], ids?, stroke?: bool} set the gradient vector (solid paints become the default gradient)", + has_doc, + set_gradient_geom + ), + ] +} + +/// Apply the gradient edits in `p` to `paint` (pure; unit-tested). +pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option) -> std::result::Result { + let mut gp = match paint { + Paint::Gradient(g) => (**g).clone(), + _ => GradientPaint::new(Gradient::default()), + }; + if let Some(k) = str_param(p, "kind") { + let kind = match k.to_ascii_lowercase().as_str() { + "linear" => GradientKind::Linear, + "radial" => GradientKind::Radial, + "freeform" => GradientKind::Freeform, + _ => return Err(format!("unknown gradient kind `{k}`")), + }; + if kind != gp.gradient.kind { + gp.gradient.kind = kind; + gp.geom = None; + } + } + if let Some(stops) = p.get("stops") { + let arr = stops.as_array().ok_or("`stops` must be an array")?; + let mut out = Vec::with_capacity(arr.len()); + for st in arr { + let offset = st.get("offset").and_then(Value::as_f64).ok_or("stop needs `offset`")?; + let color = st.get("color").and_then(color_value).ok_or("stop needs a valid `color`")?; + let opacity = st.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0); + let midpoint = st.get("midpoint").and_then(Value::as_f64).unwrap_or(0.5); + out.push(GradientStop { + offset: offset.clamp(0.0, 1.0) as f32, + color, + opacity: opacity.clamp(0.0, 1.0) as f32, + midpoint: midpoint.clamp(0.13, 0.87) as f32, + }); + } + if out.len() < 2 { + return Err("a gradient needs at least two stops".into()); + } + gp.gradient.stops = out; + gp.gradient.sort(); + gp.swatch = None; + } + if bool_or(p, "reverse", false) { + gp.gradient.reverse(); + // Midpoints belong to the segment to the right; mirror them. + let n = gp.gradient.stops.len(); + let mids: Vec = gp.gradient.stops.iter().map(|s| s.midpoint).collect(); + for i in 0..n { + gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 }; + } + } + if let Some(a) = p.get("angle").and_then(Value::as_f64) { + let a = ((a + 180.0).rem_euclid(360.0)) - 180.0; + gp.angle = a; + if let Some(g) = &mut gp.geom { + let r = a.to_radians(); + let dir = vectorcraft_geom::Vec2::new(r.cos(), -r.sin()); + if gp.gradient.kind == GradientKind::Radial { + g.end = g.start + dir * g.length(); + } else { + let c = g.start.midpoint(g.end); + let half = g.length() / 2.0; + g.start = c - dir * half; + g.end = c + dir * half; + } + } + } + if let Some(asp) = p.get("aspect").and_then(Value::as_f64) { + let asp = (asp / 100.0).clamp(0.005, 327.67); + if gp.geom.is_none() + && let Some(b) = bounds + { + gp.geom = Some(gp.resolve(b)); + } + if let Some(g) = &mut gp.geom { + g.aspect = asp; + } + } + Ok(Paint::Gradient(Box::new(gp))) +} + +fn edit_gradient(s: &mut Session, p: &Value) -> Result { + const C: &str = "paint.editGradient"; + let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); + let ids = paint_targets(s, p)?; + // Validate against the defaults first so bad params fail without touching the document. + let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }; + let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?; + if ids.is_empty() { + if stroke { + s.paint.stroke = new_default; + } else { + s.paint.fill = new_default; + } + return Ok(json!({"ids": []})); + } + let mut err = None; + s.edit("Gradient", |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill }; + match apply_gradient_edit(cur, p, None) { + Ok(np) => *cur = np, + Err(e) => err = Some(e), + } + } + continue; + } + let b = n.geometric_bounds(); + let ap = &mut n.appearance; + let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() }; + match apply_gradient_edit(&cur, p, b) { + Ok(np) => { + if stroke { + ap.set_stroke(np) + } else { + ap.set_fill(np) + } + } + Err(e) => err = Some(e), + } + } + match err.take() { + Some(e) => Err(bad(C, e)), + None => Ok(()), + } + })?; + if stroke { + s.paint.stroke = new_default; + } else { + s.paint.fill = new_default; + } + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) +} + +fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { + let start = point_param(p, "start").ok_or_else(|| bad("paint.setGradientGeom", "missing start [x,y]"))?; + let end = point_param(p, "end").ok_or_else(|| bad("paint.setGradientGeom", "missing end [x,y]"))?; + let stroke = bool_or(p, "stroke", false); + let ids = match ids_param(p, "ids") { + Some(v) => v, + None => selected_roots(s)?, + }; + let targets = leaf_targets(s, &ids)?; + if targets.is_empty() { + return Err(EngineError::Other("nothing selected".into())); + } + s.edit("Gradient", |d, _| { + for id in &targets { + let Some(n) = d.node_mut(*id) else { continue }; + if matches!(n.kind, NodeKind::Text(_)) { + continue; + } + let cur = if stroke { n.appearance.stroke_paint() } else { n.appearance.fill_paint() }; + let mut gp = match cur { + Paint::Gradient(g) => *g, + _ => GradientPaint::new(Gradient::default()), + }; + let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0); + gp.geom = Some(GradientGeom { start, end, aspect }); + gp.angle = GradientGeom { start, end, aspect }.angle_deg(); + let paint = Paint::Gradient(Box::new(gp)); + if stroke { + n.appearance.set_stroke(paint); + } else { + n.appearance.set_fill(paint); + } + } + Ok(()) + })?; + Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 375fb7da..11231ac4 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,5 +1,6 @@ //! The command registry. Ids follow Illustrator's menu structure. +mod appearance; mod brushsym; mod buildcmds; pub mod clipboard; @@ -12,8 +13,9 @@ mod docmenu; mod draw2; mod edit; mod effectcmd; -mod file; +pub mod fileio; mod fonts; +mod gradient; pub(crate) mod graph; pub mod help; mod layer; @@ -31,6 +33,9 @@ pub mod prefscmds; pub mod rasterfx; mod recolor; mod select; +mod stroke; +mod style; +mod swatch; pub(crate) mod tabs; mod textedit; pub mod textstyles; @@ -141,13 +146,20 @@ 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()); v.extend(select::specs()); v.extend(wand::specs()); v.extend(paint::specs()); + // Split out of `paint` (M3.8); kept next to it so lists in registry order (the command + // palette, `engine.commands`) show them where they always were. + v.extend(stroke::specs()); + v.extend(appearance::specs()); + v.extend(style::specs()); + v.extend(swatch::specs()); + v.extend(gradient::specs()); v.extend(opacitymask::specs()); v.extend(layer::specs()); v.extend(draw2::specs()); @@ -260,3 +272,73 @@ pub(crate) fn targets(s: &Session, p: &Value) -> Result> { pub(crate) fn ok() -> Result { Ok(Value::Null) } + +/// Leaves whose appearance a paint command changes: groups and layers expand to their contents, +/// and compound paths own their children's appearance. +pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result> { + use vectorcraft_doc::NodeKind; + let d = &s.doc()?.doc; + let mut out = vec![]; + for id in ids { + let Some(n) = d.node(*id) else { continue }; + match &n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| { + if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) { + out.push(c.id) + } + }), + _ => out.push(*id), + } + } + let comp: Vec = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect(); + out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c))); + Ok(out) +} + +/// [`leaf_targets`] of a command's [`targets`] (`ids`, `id` or the selection). +pub(crate) fn paint_targets(s: &Session, p: &Value) -> Result> { + leaf_targets(s, &targets(s, p)?) +} + +/// `base` if `taken` doesn't claim it, else the first free "base 2", "base 3", … +pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { + if !taken(base) { + return base.to_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, String> { + let num = |t: &str, open: usize| -> std::result::Result { + 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) +} diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 4aecee2d..712d0af5 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1,8 +1,8 @@ -//! Fill/stroke, Stroke panel, Appearance, Transparency, Swatches, Graphic Styles. +//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel. use serde_json::{Value, json}; -use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, FillLayer, LineCap, LineJoin, NodeKind, StrokeAlign, StrokeLayer}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; +use vectorcraft_doc::{Appearance, NodeKind}; use super::*; use crate::EngineError; @@ -29,30 +29,6 @@ pub fn specs() -> Vec { let f = s.fill_active; set_paint(s, &json!({"none": true}), f) }), - cmd!( - "stroke.set", - "Stroke Options", - ["Window", "Stroke"], - None, - "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", - has_doc, - stroke_set - ), - cmd!("appearance.addFill", "Add New Fill", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, true)), - cmd!("appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, false)), - cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{}", has_selection, clear_appearance), - cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{}", has_selection, reduce_basic), - cmd!( - "appearance.setItem", - "Appearance Item", - [], - None, - "{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack", - has_selection, - set_item - ), - cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item), - cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect), cmd!( "transparency.set", "Transparency", @@ -62,10 +38,6 @@ pub fn specs() -> Vec { has_selection, transparency ), - cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply), - cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new), - cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new), - cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete), ] } @@ -120,28 +92,6 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { Ok(None) } -/// Leaf objects whose appearance should change for `ids` (groups apply to children). -fn paint_targets(s: &Session, p: &Value) -> Result> { - let ids = targets(s, p)?; - let d = &s.doc()?.doc; - let mut out = vec![]; - for id in ids { - let Some(n) = d.node(id) else { continue }; - match &n.kind { - NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| { - if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) { - out.push(c.id) - } - }), - _ => out.push(id), - } - } - // Don't recurse into compound children (the compound owns the appearance). - let comp: Vec<_> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect(); - out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c))); - Ok(out) -} - fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { let paint = paint_from(s, p)?.ok_or_else(|| bad("paint.setFill", "give color, none, swatch or gradient"))?; if fill { @@ -220,206 +170,6 @@ fn default_paint(s: &mut Session, _: &Value) -> Result { ok() } -fn stroke_set(s: &mut Session, p: &Value) -> Result { - if let Some(w) = p.get("weight").and_then(Value::as_f64) { - s.paint.stroke_width = w.max(0.0); - } - let ids = paint_targets(s, p)?; - if ids.is_empty() { - return ok(); - } - let arrow = |k: &str| -> Result>> { - match p.get(k) { - None => Ok(None), - Some(Value::Null) => Ok(Some(None)), - Some(Value::String(n)) if n == "none" => Ok(Some(None)), - Some(v) => { - serde_json::from_value::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}"))) - } - } - }; - let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?); - s.edit("Stroke", |d, _| { - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - if n.appearance.stroke().is_none() { - n.appearance.set_stroke(Paint::solid(Color::BLACK)); - } - let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap(); - if let Some(w) = p.get("weight").and_then(Value::as_f64) { - st.width = w.max(0.0); - } - match str_param(p, "cap") { - Some("round") => st.cap = LineCap::Round, - Some("square") | Some("projecting") => st.cap = LineCap::Square, - Some("butt") => st.cap = LineCap::Butt, - _ => {} - } - match str_param(p, "join") { - Some("round") => st.join = LineJoin::Round, - Some("bevel") => st.join = LineJoin::Bevel, - Some("miter") => st.join = LineJoin::Miter, - _ => {} - } - if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) { - st.miter_limit = m.clamp(1.0, 500.0); - } - match str_param(p, "align") { - Some("inside") => st.align = StrokeAlign::Inside, - Some("outside") => st.align = StrokeAlign::Outside, - Some("center") => st.align = StrokeAlign::Center, - _ => {} - } - match p.get("dash") { - Some(Value::Null) => st.dash = None, - Some(Value::Array(a)) => { - let pattern: Vec = a.iter().filter_map(Value::as_f64).collect(); - st.dash = if pattern.is_empty() { - None - } else { - Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) }) - }; - } - _ => {} - } - if let Some(a) = sa { - st.start_arrow = a; - } - if let Some(a) = ea { - st.end_arrow = a; - } - match str_param(p, "profile") { - Some("uniform") => st.profile = None, - Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()), - Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()), - Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()), - _ => {} - } - } - Ok(()) - })?; - ok() -} - -fn add_item(s: &mut Session, fill: bool) -> Result { - let ids = paint_targets(s, &json!({}))?; - s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - let item = if fill { - AppearanceItem::Fill(FillLayer::new(n.appearance.fill_paint())) - } else { - AppearanceItem::Stroke(StrokeLayer::new(n.appearance.stroke_paint(), n.appearance.stroke_width().max(1.0))) - }; - n.appearance.items.push(item); - } - } - Ok(()) - })?; - ok() -} - -fn clear_appearance(s: &mut Session, _: &Value) -> Result { - let ids = paint_targets(s, &json!({}))?; - s.edit("Clear Appearance", |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0); - } - } - Ok(()) - })?; - ok() -} - -fn reduce_basic(s: &mut Session, _: &Value) -> Result { - let ids = paint_targets(s, &json!({}))?; - s.edit("Reduce to Basic Appearance", |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - let f = n.appearance.fill_paint(); - let st = n.appearance.stroke().cloned(); - n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] }; - if let Some(mut st) = st { - st.effects.clear(); - st.opacity = 1.0; - st.blend = BlendMode::Normal; - n.appearance.items.push(AppearanceItem::Stroke(st)); - } - } - } - Ok(()) - })?; - ok() -} - -fn set_item(s: &mut Session, p: &Value) -> Result { - let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.setItem", "missing index"))? as usize; - let paint = paint_from(s, p)?; - let ids = paint_targets(s, p)?; - let blend = str_param(p, "blend").and_then(BlendMode::parse); - s.edit("Appearance", |d, _| { - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - let Some(item) = n.appearance.items.get_mut(idx) else { continue }; - let (pp, op, bl, vis) = match item { - AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible), - AppearanceItem::Stroke(st) => { - if let Some(w) = p.get("weight").and_then(Value::as_f64) { - st.width = w.max(0.0); - } - (&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible) - } - }; - if let Some(pa) = &paint { - *pp = pa.clone(); - } - if let Some(o) = p.get("opacity").and_then(Value::as_f64) { - *op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32; - } - if let Some(b) = blend { - *bl = b; - } - if let Some(v) = p.get("visible").and_then(Value::as_bool) { - *vis = v; - } - } - Ok(()) - })?; - ok() -} - -fn remove_item(s: &mut Session, p: &Value) -> Result { - let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.removeItem", "missing index"))? as usize; - let ids = paint_targets(s, p)?; - s.edit("Remove Item", |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) - && idx < n.appearance.items.len() - { - n.appearance.items.remove(idx); - } - } - Ok(()) - })?; - ok() -} - -fn add_effect(s: &mut Session, p: &Value) -> Result { - let id = str_param(p, "effect").ok_or_else(|| bad("appearance.addEffect", "missing effect id"))?.to_string(); - let params = p.get("params").cloned().unwrap_or(json!({})); - let ids = targets(s, p)?; - s.edit("Add Effect", |d, _| { - for nid in &ids { - if let Some(n) = d.node_mut(*nid) { - n.appearance.effects.push(vectorcraft_doc::Effect { id: id.clone(), params: params.clone(), visible: true }); - } - } - Ok(()) - })?; - ok() -} - fn transparency(s: &mut Session, p: &Value) -> Result { let mut q = p.clone(); if let Some(o) = p.get("opacity").and_then(Value::as_f64) { @@ -427,72 +177,3 @@ fn transparency(s: &mut Session, p: &Value) -> Result { } s.execute("object.setProps", &q) } - -fn style_apply(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?; - let ap = s - .doc()? - .doc - .graphic_styles - .iter() - .find(|g| g.name == name) - .map(|g| g.appearance.clone()) - .ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; - let ids = paint_targets(s, p)?; - s.edit("Apply Graphic Style", |d, _| { - for id in &ids { - if let Some(n) = d.node_mut(*id) { - n.appearance = ap.clone(); - } - } - Ok(()) - })?; - ok() -} - -fn style_new(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let id = *st.selection.objects.first().unwrap(); - let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default(); - let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1)); - s.edit("New Graphic Style", |d, _| { - d.graphic_styles.push(vectorcraft_doc::GraphicStyle { name: name.clone(), appearance: ap }); - Ok(()) - })?; - Ok(json!({ "name": name })) -} - -fn swatch_new(s: &mut Session, p: &Value) -> Result { - let paint = match paint_from(s, p)? { - Some(x) => x, - None => s.paint.fill.clone(), - }; - let name = match str_param(p, "name") { - Some(n) => n.to_string(), - None => match &paint { - Paint::Solid { color, .. } => { - let [r, g, b] = color.to_rgb(); - format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()) - } - _ => format!("Swatch {}", s.doc()?.doc.swatches.len() + 1), - }, - }; - let global = bool_or(p, "global", false); - s.edit("New Swatch", |d, _| { - d.swatches.push(vectorcraft_color::Swatch { name: name.clone(), paint, global, spot: false }); - Ok(()) - })?; - Ok(json!({ "name": name })) -} - -fn swatch_delete(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("swatch.delete", "missing name"))?.to_string(); - s.edit("Delete Swatch", |d, _| { - d.swatches.retain(|sw| sw.name != name); - for g in &mut d.swatch_groups { - g.swatches.retain(|sw| sw.name != name); - } - Ok(()) - })?; - ok() -} diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f2e1c90b..14d02b9a 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -1,36 +1,14 @@ -//! Commands the panels need beyond the core set: in-place gradient editing (Gradient panel), -//! appearance item duplicate/reorder, artboard reorder/duplicate, swatch groups, graphic style -//! management and the advanced Character/Paragraph attributes. +//! Commands the panels need beyond the core set: artboard reorder/duplicate and the advanced +//! Character/Paragraph attributes. use serde_json::{Value, json}; -use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint, SwatchGroup}; -use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_doc::NodeKind; use super::*; use crate::EngineError; pub fn specs() -> Vec { vec![ - cmd!( - "paint.editGradient", - "Gradient", - ["Window", "Gradient"], - None, - "{stroke?: bool (default: the active proxy), kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1, midpoint? 0..1}], angle?: deg, aspect?: %, reverse?: bool, ids?} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", - has_doc, - edit_gradient - ), - cmd!( - "stroke.setAdvanced", - "Stroke Options", - [], - None, - "{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, ids?} Stroke panel extras", - has_doc, - stroke_advanced - ), - cmd!("appearance.duplicateItem", "Duplicate Item", ["Window", "Appearance"], None, "{index}", has_selection, duplicate_item), - cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to} (paint-order indices)", has_selection, move_item), cmd!("artboard.reorder", "Move Artboard Up/Down", [], None, "{index, to} change an artboard's number", has_doc, artboard_reorder), cmd!( "artboard.duplicate", @@ -41,19 +19,6 @@ pub fn specs() -> Vec { has_doc, artboard_duplicate ), - cmd!( - "swatch.newGroup", - "New Color Group", - ["Window", "Swatches"], - None, - "{name?, swatches?: [names] (moved into the group), colors?: [colour] (added as new swatches)}", - has_doc, - swatch_new_group - ), - cmd!("swatch.duplicate", "Duplicate Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_duplicate), - cmd!("swatch.sortByName", "Sort by Name", ["Window", "Swatches"], None, "{}", has_doc, swatch_sort), - cmd!("graphicStyle.delete", "Delete Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_delete), - cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate), cmd!( "text.setFormat", "Character / Paragraph", @@ -66,204 +31,6 @@ pub fn specs() -> Vec { ] } -// ---------- gradient ---------- - -/// Leaf paint targets (groups apply to their children), like the Swatches/Color panels. -fn leaf_targets(s: &Session, p: &Value) -> Result> { - let ids = targets(s, p)?; - let d = &s.doc()?.doc; - let mut out = vec![]; - for id in ids { - let Some(n) = d.node(id) else { continue }; - match &n.kind { - NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| { - if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) { - out.push(c.id) - } - }), - _ => out.push(id), - } - } - let comp: Vec<_> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect(); - out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c))); - Ok(out) -} - -/// Apply the gradient edits in `p` to `paint` (pure; unit-tested). -pub(crate) fn apply_gradient_edit(paint: &Paint, p: &Value, bounds: Option) -> std::result::Result { - let mut gp = match paint { - Paint::Gradient(g) => (**g).clone(), - _ => GradientPaint::new(Gradient::default()), - }; - if let Some(k) = str_param(p, "kind") { - let kind = match k.to_ascii_lowercase().as_str() { - "linear" => GradientKind::Linear, - "radial" => GradientKind::Radial, - "freeform" => GradientKind::Freeform, - _ => return Err(format!("unknown gradient kind `{k}`")), - }; - if kind != gp.gradient.kind { - gp.gradient.kind = kind; - gp.geom = None; - } - } - if let Some(stops) = p.get("stops") { - let arr = stops.as_array().ok_or("`stops` must be an array")?; - let mut out = Vec::with_capacity(arr.len()); - for st in arr { - let offset = st.get("offset").and_then(Value::as_f64).ok_or("stop needs `offset`")?; - let color = st.get("color").and_then(color_value).ok_or("stop needs a valid `color`")?; - let opacity = st.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }).unwrap_or(1.0); - let midpoint = st.get("midpoint").and_then(Value::as_f64).unwrap_or(0.5); - out.push(GradientStop { - offset: offset.clamp(0.0, 1.0) as f32, - color, - opacity: opacity.clamp(0.0, 1.0) as f32, - midpoint: midpoint.clamp(0.13, 0.87) as f32, - }); - } - if out.len() < 2 { - return Err("a gradient needs at least two stops".into()); - } - gp.gradient.stops = out; - gp.gradient.sort(); - gp.swatch = None; - } - if bool_or(p, "reverse", false) { - gp.gradient.reverse(); - // Midpoints belong to the segment to the right; mirror them. - let n = gp.gradient.stops.len(); - let mids: Vec = gp.gradient.stops.iter().map(|s| s.midpoint).collect(); - for i in 0..n { - gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 }; - } - } - if let Some(a) = p.get("angle").and_then(Value::as_f64) { - let a = ((a + 180.0).rem_euclid(360.0)) - 180.0; - gp.angle = a; - if let Some(g) = &mut gp.geom { - let r = a.to_radians(); - let dir = vectorcraft_geom::Vec2::new(r.cos(), -r.sin()); - if gp.gradient.kind == GradientKind::Radial { - g.end = g.start + dir * g.length(); - } else { - let c = g.start.midpoint(g.end); - let half = g.length() / 2.0; - g.start = c - dir * half; - g.end = c + dir * half; - } - } - } - if let Some(asp) = p.get("aspect").and_then(Value::as_f64) { - let asp = (asp / 100.0).clamp(0.005, 327.67); - if gp.geom.is_none() - && let Some(b) = bounds - { - gp.geom = Some(gp.resolve(b)); - } - if let Some(g) = &mut gp.geom { - g.aspect = asp; - } - } - Ok(Paint::Gradient(Box::new(gp))) -} - -fn edit_gradient(s: &mut Session, p: &Value) -> Result { - const C: &str = "paint.editGradient"; - let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); - let ids = leaf_targets(s, p)?; - // Validate against the defaults first so bad params fail without touching the document. - let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() }; - let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?; - if ids.is_empty() { - if stroke { - s.paint.stroke = new_default; - } else { - s.paint.fill = new_default; - } - return Ok(json!({"ids": []})); - } - let mut err = None; - s.edit("Gradient", |d, _| { - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - if let NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - let cur = if stroke { &mut r.style.stroke } else { &mut r.style.fill }; - match apply_gradient_edit(cur, p, None) { - Ok(np) => *cur = np, - Err(e) => err = Some(e), - } - } - continue; - } - let b = n.geometric_bounds(); - let ap = &mut n.appearance; - let cur = if stroke { ap.stroke_paint() } else { ap.fill_paint() }; - match apply_gradient_edit(&cur, p, b) { - Ok(np) => { - if stroke { - ap.set_stroke(np) - } else { - ap.set_fill(np) - } - } - Err(e) => err = Some(e), - } - } - match err.take() { - Some(e) => Err(bad(C, e)), - None => Ok(()), - } - })?; - if stroke { - s.paint.stroke = new_default; - } else { - s.paint.fill = new_default; - } - Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) -} - -// ---------- appearance ---------- - -fn duplicate_item(s: &mut Session, p: &Value) -> Result { - let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.duplicateItem", "missing index"))? as usize; - let ids = leaf_targets(s, p)?; - s.edit("Duplicate Item", |d, _| { - let mut any = false; - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - if let Some(item) = n.appearance.items.get(idx).cloned() { - n.appearance.items.insert(idx + 1, item); - any = true; - } - } - if any { Ok(()) } else { Err(bad("appearance.duplicateItem", format!("no item at index {idx}"))) } - })?; - ok() -} - -fn move_item(s: &mut Session, p: &Value) -> Result { - const C: &str = "appearance.moveItem"; - let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing from"))? as usize; - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize; - let ids = leaf_targets(s, p)?; - s.edit("Reorder Appearance", |d, _| { - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - let items = &mut n.appearance.items; - if from >= items.len() { - return Err(bad(C, format!("no item at index {from}"))); - } - let it = items.remove(from); - let to = to.min(items.len()); - items.insert(to, it); - } - Ok(()) - })?; - ok() -} - // ---------- artboards ---------- fn artboard_reorder(s: &mut Session, p: &Value) -> Result { @@ -298,102 +65,6 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { Ok(json!({"index": index})) } -// ---------- swatches ---------- - -fn unique_name(d: &Document, base: &str) -> String { - let exists = |n: &str| d.swatch(n).is_some() || d.swatch_groups.iter().any(|g| g.name == n); - if !exists(base) { - return base.to_string(); - } - (2..).map(|i| format!("{base} {i}")).find(|n| !exists(n)).unwrap_or_else(|| base.to_string()) -} - -fn swatch_new_group(s: &mut Session, p: &Value) -> Result { - let names: Vec = - p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); - let colors: Vec = - p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default(); - let requested = str_param(p, "name").map(str::to_string); - let name = s.edit("New Color Group", |d, _| { - let name = unique_name(d, requested.as_deref().unwrap_or("Color Group")); - let mut group = SwatchGroup { name: name.clone(), swatches: vec![] }; - for n in &names { - if let Some(pos) = d.swatches.iter().position(|sw| &sw.name == n) { - group.swatches.push(d.swatches.remove(pos)); - } - } - for c in &colors { - let [r, g, b] = c.to_rgb(); - let nm = format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()); - group.swatches.push(vectorcraft_color::Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false }); - } - d.swatch_groups.push(group); - Ok(name) - })?; - Ok(json!({"name": name})) -} - -fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string(); - let new = s.edit("Duplicate Swatch", |d, _| { - let src = d.swatch(&name).cloned().ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?; - let nm = unique_name(d, &format!("{name} copy")); - let copy = vectorcraft_color::Swatch { name: nm.clone(), ..src }; - if let Some(pos) = d.swatches.iter().position(|sw| sw.name == name) { - d.swatches.insert(pos + 1, copy); - } else if let Some(g) = d.swatch_groups.iter_mut().find(|g| g.swatches.iter().any(|sw| sw.name == name)) { - let pos = g.swatches.iter().position(|sw| sw.name == name).unwrap_or(0); - g.swatches.insert(pos + 1, copy); - } - Ok(nm) - })?; - Ok(json!({"name": new})) -} - -fn swatch_sort(s: &mut Session, _: &Value) -> Result { - s.edit("Sort Swatches", |d, _| { - // [None] and other bracketed specials stay first, like Illustrator. - let key = |sw: &vectorcraft_color::Swatch| (!sw.name.starts_with('['), sw.name.to_lowercase()); - d.swatches.sort_by_key(key); - for g in &mut d.swatch_groups { - g.swatches.sort_by_key(key); - } - Ok(()) - })?; - ok() -} - -// ---------- graphic styles ---------- - -fn style_delete(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.delete", "missing name"))?.to_string(); - s.edit("Delete Graphic Style", |d, _| { - let before = d.graphic_styles.len(); - d.graphic_styles.retain(|g| g.name != name); - if d.graphic_styles.len() == before { Err(EngineError::Other(format!("no graphic style `{name}`"))) } else { Ok(()) } - })?; - ok() -} - -fn style_duplicate(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.duplicate", "missing name"))?.to_string(); - let new = s.edit("Duplicate Graphic Style", |d, _| { - let pos = d.graphic_styles.iter().position(|g| g.name == name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; - let mut g = d.graphic_styles[pos].clone(); - let base = format!("{name} copy"); - let mut nm = base.clone(); - let mut i = 2; - while d.graphic_styles.iter().any(|x| x.name == nm) { - nm = format!("{base} {i}"); - i += 1; - } - g.name = nm.clone(); - d.graphic_styles.insert(pos + 1, g); - Ok(nm) - })?; - Ok(json!({"name": new})) -} - // ---------- text ---------- fn set_format(s: &mut Session, p: &Value) -> Result { @@ -487,66 +158,3 @@ fn set_format(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } - -// ---------- stroke extras ---------- - -/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths). -pub(crate) fn flip_profile(p: &vectorcraft_doc::WidthProfile, along: bool) -> vectorcraft_doc::WidthProfile { - let mut pts: Vec<(f64, f64, f64)> = - if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() }; - pts.sort_by(|a, b| a.0.total_cmp(&b.0)); - vectorcraft_doc::WidthProfile { points: pts } -} - -fn stroke_advanced(s: &mut Session, p: &Value) -> Result { - const C: &str = "stroke.setAdvanced"; - let scale = match p.get("arrowScale") { - None => None, - Some(v) => { - let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(C, "arrowScale must be [start, end]"))?; - let f = |i: usize| a[i].as_f64().map(|x| x.clamp(1.0, 1000.0)); - Some((f(0).ok_or_else(|| bad(C, "bad arrowScale"))?, f(1).ok_or_else(|| bad(C, "bad arrowScale"))?)) - } - }; - let flip = match str_param(p, "flipProfile") { - None => None, - Some("along") => Some(true), - Some("across") => Some(false), - Some(o) => return Err(bad(C, format!("flipProfile must be along|across, got {o}"))), - }; - let swap = bool_or(p, "swapArrows", false); - let brush = match p.get("brush") { - None => None, - Some(Value::Null) => Some(None), - Some(Value::String(b)) => Some(Some(b.clone())), - Some(_) => return Err(bad(C, "brush must be a name or null")), - }; - if scale.is_none() && flip.is_none() && !swap && brush.is_none() { - return Err(bad(C, "nothing to change")); - } - let ids = leaf_targets(s, p)?; - if ids.is_empty() { - return ok(); - } - s.edit("Stroke", |d, _| { - for id in &ids { - let Some(n) = d.node_mut(*id) else { continue }; - let Some(st) = n.appearance.stroke_mut() else { continue }; - if let Some(sc) = scale { - st.arrow_scale = sc; - } - if swap { - std::mem::swap(&mut st.start_arrow, &mut st.end_arrow); - st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0); - } - if let (Some(along), Some(pr)) = (flip, &st.profile) { - st.profile = Some(flip_profile(pr, along)); - } - if let Some(b) = &brush { - st.brush = b.clone(); - } - } - Ok(()) - })?; - ok() -} diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 1cdbe340..4b65b3f3 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -269,12 +269,12 @@ pub const PREF_SPECS: &[PrefSpec] = &[ // Clipboard Handling p!("copyAsSvg", "Clipboard Handling", "On Copy", "Include SVG Code", bool), p!("copyAsPdf", "Clipboard Handling", "On Copy", "PDF", bool), - p!("copyAicb", "Clipboard Handling", "On Copy", "AICB (no transparency support)", bool), + p!("copyAicb", "Clipboard Handling", "On Copy", "Legacy vector clipboard (no transparency)", bool), p!( "aicbMode", "Clipboard Handling", "On Copy", - "AICB", + "Legacy vector clipboard", choice(&[("preservePaths", "Preserve Paths"), ("preserveAppearance", "Preserve Appearance and Overprints")]) ), p!( diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs new file mode 100644 index 00000000..c97ace47 --- /dev/null +++ b/crates/engine/src/cmd/stroke.rs @@ -0,0 +1,172 @@ +//! The Stroke panel: weight, cap, join, alignment, dashes, arrowheads, width profiles and brushes. + +use serde_json::Value; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer}; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "stroke.set", + "Stroke Options", + ["Window", "Stroke"], + None, + "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", + has_doc, + stroke_set + ), + cmd!( + "stroke.setAdvanced", + "Stroke Options", + [], + None, + "{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, ids?} Stroke panel extras", + has_doc, + stroke_advanced + ), + ] +} + +fn stroke_set(s: &mut Session, p: &Value) -> Result { + if let Some(w) = p.get("weight").and_then(Value::as_f64) { + s.paint.stroke_width = w.max(0.0); + } + let ids = paint_targets(s, p)?; + if ids.is_empty() { + return ok(); + } + let arrow = |k: &str| -> Result>> { + match p.get(k) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(Value::String(n)) if n == "none" => Ok(Some(None)), + Some(v) => { + serde_json::from_value::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}"))) + } + } + }; + let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?); + s.edit("Stroke", |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + if n.appearance.stroke().is_none() { + n.appearance.set_stroke(Paint::solid(Color::BLACK)); + } + let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap(); + if let Some(w) = p.get("weight").and_then(Value::as_f64) { + st.width = w.max(0.0); + } + match str_param(p, "cap") { + Some("round") => st.cap = LineCap::Round, + Some("square") | Some("projecting") => st.cap = LineCap::Square, + Some("butt") => st.cap = LineCap::Butt, + _ => {} + } + match str_param(p, "join") { + Some("round") => st.join = LineJoin::Round, + Some("bevel") => st.join = LineJoin::Bevel, + Some("miter") => st.join = LineJoin::Miter, + _ => {} + } + if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) { + st.miter_limit = m.clamp(1.0, 500.0); + } + match str_param(p, "align") { + Some("inside") => st.align = StrokeAlign::Inside, + Some("outside") => st.align = StrokeAlign::Outside, + Some("center") => st.align = StrokeAlign::Center, + _ => {} + } + match p.get("dash") { + Some(Value::Null) => st.dash = None, + Some(Value::Array(a)) => { + let pattern: Vec = a.iter().filter_map(Value::as_f64).collect(); + st.dash = if pattern.is_empty() { + None + } else { + Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) }) + }; + } + _ => {} + } + if let Some(a) = sa { + st.start_arrow = a; + } + if let Some(a) = ea { + st.end_arrow = a; + } + match str_param(p, "profile") { + Some("uniform") => st.profile = None, + Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()), + Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()), + Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()), + _ => {} + } + } + Ok(()) + })?; + ok() +} + +/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths). +pub(crate) fn flip_profile(p: &vectorcraft_doc::WidthProfile, along: bool) -> vectorcraft_doc::WidthProfile { + let mut pts: Vec<(f64, f64, f64)> = + if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() }; + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + vectorcraft_doc::WidthProfile { points: pts } +} + +fn stroke_advanced(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.setAdvanced"; + let scale = match p.get("arrowScale") { + None => None, + Some(v) => { + let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(C, "arrowScale must be [start, end]"))?; + let f = |i: usize| a[i].as_f64().map(|x| x.clamp(1.0, 1000.0)); + Some((f(0).ok_or_else(|| bad(C, "bad arrowScale"))?, f(1).ok_or_else(|| bad(C, "bad arrowScale"))?)) + } + }; + let flip = match str_param(p, "flipProfile") { + None => None, + Some("along") => Some(true), + Some("across") => Some(false), + Some(o) => return Err(bad(C, format!("flipProfile must be along|across, got {o}"))), + }; + let swap = bool_or(p, "swapArrows", false); + let brush = match p.get("brush") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(b)) => Some(Some(b.clone())), + Some(_) => return Err(bad(C, "brush must be a name or null")), + }; + if scale.is_none() && flip.is_none() && !swap && brush.is_none() { + return Err(bad(C, "nothing to change")); + } + let ids = paint_targets(s, p)?; + if ids.is_empty() { + return ok(); + } + s.edit("Stroke", |d, _| { + for id in &ids { + let Some(n) = d.node_mut(*id) else { continue }; + let Some(st) = n.appearance.stroke_mut() else { continue }; + if let Some(sc) = scale { + st.arrow_scale = sc; + } + if swap { + std::mem::swap(&mut st.start_arrow, &mut st.end_arrow); + st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0); + } + if let (Some(along), Some(pr)) = (flip, &st.profile) { + st.profile = Some(flip_profile(pr, along)); + } + if let Some(b) = &brush { + st.brush = b.clone(); + } + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs new file mode 100644 index 00000000..6e8d80b0 --- /dev/null +++ b/crates/engine/src/cmd/style.rs @@ -0,0 +1,72 @@ +//! The Graphic Styles panel: apply, new, delete and duplicate. + +use serde_json::{Value, json}; + +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply), + cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new), + cmd!("graphicStyle.delete", "Delete Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_delete), + cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate), + ] +} + +fn style_apply(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?; + let ap = s + .doc()? + .doc + .graphic_styles + .iter() + .find(|g| g.name == name) + .map(|g| g.appearance.clone()) + .ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; + let ids = paint_targets(s, p)?; + s.edit("Apply Graphic Style", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.appearance = ap.clone(); + } + } + Ok(()) + })?; + ok() +} + +fn style_new(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = *st.selection.objects.first().unwrap(); + let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default(); + let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1)); + s.edit("New Graphic Style", |d, _| { + d.graphic_styles.push(vectorcraft_doc::GraphicStyle { name: name.clone(), appearance: ap }); + Ok(()) + })?; + Ok(json!({ "name": name })) +} + +fn style_delete(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.delete", "missing name"))?.to_string(); + s.edit("Delete Graphic Style", |d, _| { + let before = d.graphic_styles.len(); + d.graphic_styles.retain(|g| g.name != name); + if d.graphic_styles.len() == before { Err(EngineError::Other(format!("no graphic style `{name}`"))) } else { Ok(()) } + })?; + ok() +} + +fn style_duplicate(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.duplicate", "missing name"))?.to_string(); + let new = s.edit("Duplicate Graphic Style", |d, _| { + let pos = d.graphic_styles.iter().position(|g| g.name == name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?; + let mut g = d.graphic_styles[pos].clone(); + let nm = unique_name(&format!("{name} copy"), |n| d.graphic_styles.iter().any(|x| x.name == n)); + g.name = nm.clone(); + d.graphic_styles.insert(pos + 1, g); + Ok(nm) + })?; + Ok(json!({"name": new})) +} diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs new file mode 100644 index 00000000..7b5793d9 --- /dev/null +++ b/crates/engine/src/cmd/swatch.rs @@ -0,0 +1,122 @@ +//! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup}; +use vectorcraft_doc::Document; + +use super::paint::paint_from; +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new), + cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete), + cmd!( + "swatch.newGroup", + "New Color Group", + ["Window", "Swatches"], + None, + "{name?, swatches?: [names] (moved into the group), colors?: [colour] (added as new swatches)}", + has_doc, + swatch_new_group + ), + cmd!("swatch.duplicate", "Duplicate Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_duplicate), + cmd!("swatch.sortByName", "Sort by Name", ["Window", "Swatches"], None, "{}", has_doc, swatch_sort), + ] +} + +fn swatch_new(s: &mut Session, p: &Value) -> Result { + let paint = match paint_from(s, p)? { + Some(x) => x, + None => s.paint.fill.clone(), + }; + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => match &paint { + Paint::Solid { color, .. } => rgb_name(*color), + _ => format!("Swatch {}", s.doc()?.doc.swatches.len() + 1), + }, + }; + let global = bool_or(p, "global", false); + s.edit("New Swatch", |d, _| { + d.swatches.push(Swatch { name: name.clone(), paint, global, spot: false }); + Ok(()) + })?; + Ok(json!({ "name": name })) +} + +fn swatch_delete(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("swatch.delete", "missing name"))?.to_string(); + s.edit("Delete Swatch", |d, _| { + d.swatches.retain(|sw| sw.name != name); + for g in &mut d.swatch_groups { + g.swatches.retain(|sw| sw.name != name); + } + Ok(()) + })?; + ok() +} + +/// The default name of a new solid swatch: its 8-bit RGB values. +fn rgb_name(c: Color) -> String { + let [r, g, b] = c.to_rgb(); + format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round()) +} + +/// A swatch or colour-group name not used yet in `d`. +fn free_name(d: &Document, base: &str) -> String { + unique_name(base, |n| d.swatch(n).is_some() || d.swatch_groups.iter().any(|g| g.name == n)) +} + +fn swatch_new_group(s: &mut Session, p: &Value) -> Result { + let names: Vec = + p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + let colors: Vec = p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default(); + let requested = str_param(p, "name").map(str::to_string); + let name = s.edit("New Color Group", |d, _| { + let name = free_name(d, requested.as_deref().unwrap_or("Color Group")); + let mut group = SwatchGroup { name: name.clone(), swatches: vec![] }; + for n in &names { + if let Some(pos) = d.swatches.iter().position(|sw| &sw.name == n) { + group.swatches.push(d.swatches.remove(pos)); + } + } + for c in &colors { + group.swatches.push(Swatch { name: rgb_name(*c), paint: Paint::solid(*c), global: false, spot: false }); + } + d.swatch_groups.push(group); + Ok(name) + })?; + Ok(json!({"name": name})) +} + +fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string(); + let new = s.edit("Duplicate Swatch", |d, _| { + let src = d.swatch(&name).cloned().ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?; + let nm = free_name(d, &format!("{name} copy")); + let copy = Swatch { name: nm.clone(), ..src }; + if let Some(pos) = d.swatches.iter().position(|sw| sw.name == name) { + d.swatches.insert(pos + 1, copy); + } else if let Some(g) = d.swatch_groups.iter_mut().find(|g| g.swatches.iter().any(|sw| sw.name == name)) { + let pos = g.swatches.iter().position(|sw| sw.name == name).unwrap_or(0); + g.swatches.insert(pos + 1, copy); + } + Ok(nm) + })?; + Ok(json!({"name": new})) +} + +fn swatch_sort(s: &mut Session, _: &Value) -> Result { + s.edit("Sort Swatches", |d, _| { + // [None] and other bracketed specials stay first, like the reference app. + let key = |sw: &Swatch| (!sw.name.starts_with('['), sw.name.to_lowercase()); + d.swatches.sort_by_key(key); + for g in &mut d.swatch_groups { + g.swatches.sort_by_key(key); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 571c74df..02ef3cff 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped, like Illustrator) → {id}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", has_doc, create_in_path ), diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 99419723..a0d45261 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -1,9 +1,9 @@ -//! Commands backing transform/utility tools: free distort, eyedropper, gradient vector, artboard move. +//! Commands backing transform/utility tools: free distort, eyedropper colour sampling, artboard move. use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{Gradient, GradientGeom, GradientPaint, Paint}; +use vectorcraft_color::Paint; use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; @@ -22,15 +22,6 @@ pub fn specs() -> Vec { has_doc, distort ), - cmd!( - "appearance.copyFrom", - "Eyedropper", - [], - None, - "{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults", - has_doc, - copy_from - ), cmd!( "paint.sampleColor", "Sample Color", @@ -40,15 +31,6 @@ pub fn specs() -> Vec { has_doc, sample_color ), - cmd!( - "paint.setGradientGeom", - "Gradient Vector", - [], - None, - "{start: [x,y], end: [x,y], ids?, stroke?: bool} set the gradient vector (solid paints become the default gradient)", - has_doc, - set_gradient_geom - ), cmd!( "artboard.move", "Move Artboard", @@ -190,72 +172,6 @@ fn distort(s: &mut Session, p: &Value) -> Result { // ---------- eyedropper ---------- -/// Leaves whose appearance a paint command changes (groups expand to their contents; compound -/// paths own their children's appearance). -fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result> { - let d = &s.doc()?.doc; - let mut out = vec![]; - for id in ids { - let Some(n) = d.node(*id) else { continue }; - match &n.kind { - NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| { - if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) { - out.push(c.id) - } - }), - _ => out.push(*id), - } - } - let comp: Vec = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect(); - out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c))); - Ok(out) -} - -fn copy_from(s: &mut Session, p: &Value) -> Result { - let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?; - let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?; - let appearance = match &src.kind { - NodeKind::Text(t) => match t.runs.first() { - Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), - None => src.appearance.clone(), - }, - _ => src.appearance.clone(), - }; - s.paint.fill = appearance.fill_paint(); - s.paint.stroke = appearance.stroke_paint(); - if appearance.stroke().is_some() { - s.paint.stroke_width = appearance.stroke_width(); - } - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, - }; - let mut targets = leaf_targets(s, &ids)?; - targets.retain(|id| *id != src_id); - if targets.is_empty() { - return Ok(json!({ "ids": [] })); - } - let (opacity, blend) = (src.opacity, src.blend); - s.edit("Eyedropper", |d, _| { - for id in &targets { - let Some(n) = d.node_mut(*id) else { continue }; - n.opacity = opacity; - n.blend = blend; - if let NodeKind::Text(t) = &mut n.kind { - for r in &mut t.runs { - r.style.fill = appearance.fill_paint(); - r.style.stroke = appearance.stroke_paint(); - r.style.stroke_width = appearance.stroke_width(); - } - continue; - } - n.appearance = appearance.clone(); - } - Ok(()) - })?; - Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) -} - fn sample_color(s: &mut Session, p: &Value) -> Result { let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); let c = p.get("color").ok_or_else(|| bad("paint.sampleColor", "missing color"))?; @@ -268,46 +184,6 @@ fn sample_color(s: &mut Session, p: &Value) -> Result { (spec.run)(s, &q) } -// ---------- gradient tool ---------- - -fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { - let start = point_param(p, "start").ok_or_else(|| bad("paint.setGradientGeom", "missing start [x,y]"))?; - let end = point_param(p, "end").ok_or_else(|| bad("paint.setGradientGeom", "missing end [x,y]"))?; - let stroke = bool_or(p, "stroke", false); - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, - }; - let targets = leaf_targets(s, &ids)?; - if targets.is_empty() { - return Err(EngineError::Other("nothing selected".into())); - } - s.edit("Gradient", |d, _| { - for id in &targets { - let Some(n) = d.node_mut(*id) else { continue }; - if matches!(n.kind, NodeKind::Text(_)) { - continue; - } - let cur = if stroke { n.appearance.stroke_paint() } else { n.appearance.fill_paint() }; - let mut gp = match cur { - Paint::Gradient(g) => *g, - _ => GradientPaint::new(Gradient::default()), - }; - let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0); - gp.geom = Some(GradientGeom { start, end, aspect }); - gp.angle = GradientGeom { start, end, aspect }.angle_deg(); - let paint = Paint::Gradient(Box::new(gp)); - if stroke { - n.appearance.set_stroke(paint); - } else { - n.appearance.set_fill(paint); - } - } - Ok(()) - })?; - Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) -} - // ---------- artboards ---------- fn artboard_move(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f62d5973..020464db 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -711,6 +711,8 @@ mod tests_brushsym; #[cfg(test)] mod tests_build; #[cfg(test)] +mod tests_cmdsplit; +#[cfg(test)] mod tests_colormgmt; #[cfg(test)] mod tests_distort; diff --git a/crates/engine/src/tests_cmdsplit.rs b/crates/engine/src/tests_cmdsplit.rs new file mode 100644 index 00000000..12e7f56d --- /dev/null +++ b/crates/engine/src/tests_cmdsplit.rs @@ -0,0 +1,106 @@ +//! The split command modules (`cmd/{appearance,gradient,stroke,style,swatch}.rs`) and the shared +//! helpers in `cmd/mod.rs` (`leaf_targets`, `paint_targets`, `unique_name`). + +use serde_json::json; + +use super::*; +use crate::cmd::{leaf_targets, paint_targets, unique_name}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn id_of(v: &serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +#[test] +fn moved_commands_keep_their_ids() { + let ids = [ + "appearance.addFill", + "appearance.addStroke", + "appearance.clear", + "appearance.reduceToBasic", + "appearance.setItem", + "appearance.removeItem", + "appearance.addEffect", + "appearance.duplicateItem", + "appearance.moveItem", + "appearance.copyFrom", + "paint.editGradient", + "paint.setGradientGeom", + "stroke.set", + "stroke.setAdvanced", + "graphicStyle.apply", + "graphicStyle.new", + "graphicStyle.delete", + "graphicStyle.duplicate", + "swatch.new", + "swatch.delete", + "swatch.newGroup", + "swatch.duplicate", + "swatch.sortByName", + ]; + let pos = |id: &str| command_specs().iter().position(|c| c.id == id).unwrap_or_else(|| panic!("{id}")); + let (paint_end, after_paint) = (pos("transparency.set"), pos("transparency.makeOpacityMask")); + for id in ids { + assert_eq!(command_specs().iter().filter(|c| c.id == id).count(), 1, "{id}"); + // Registered right after `paint` (where they lived), so the command palette, which lists + // the first matches in registry order, still shows them (e.g. Stroke Options for "stroke"). + assert!((paint_end..after_paint).contains(&pos(id)), "{id}"); + } +} + +#[test] +fn unique_name_appends_the_first_free_number() { + let taken = ["A", "A 2", "A 3"]; + assert_eq!(unique_name("B", |n| taken.contains(&n)), "B"); + assert_eq!(unique_name("A", |n| taken.contains(&n)), "A 4"); + assert_eq!(unique_name("A 2", |n| taken.contains(&n)), "A 2 2"); +} + +#[test] +fn leaf_targets_expand_groups_and_stop_at_compound_paths() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 30.0)); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + assert_eq!(leaf_targets(&s, &[g]).unwrap(), vec![a, b]); + let (c, d) = (rect(&mut s, 60.0), rect(&mut s, 90.0)); + s.execute("select.set", &json!({"ids": [c.0, d.0]})).unwrap(); + let comp = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + assert_eq!(leaf_targets(&s, &[comp]).unwrap(), vec![comp]); + // `paint_targets` reads `ids`, then `id`, then the selection. + assert_eq!(paint_targets(&s, &json!({"ids": [g.0]})).unwrap(), vec![a, b]); + assert_eq!(paint_targets(&s, &json!({"id": comp.0})).unwrap(), vec![comp]); + s.execute("select.set", &json!({"ids": [g.0, comp.0]})).unwrap(); + assert_eq!(paint_targets(&s, &json!({})).unwrap(), vec![a, b, comp]); +} + +#[test] +fn duplicate_names_stay_unique() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Sky", "color": "#3399ff"})).unwrap(); + assert_eq!(s.execute("swatch.duplicate", &json!({"name": "Sky"})).unwrap()["name"], "Sky copy"); + assert_eq!(s.execute("swatch.duplicate", &json!({"name": "Sky"})).unwrap()["name"], "Sky copy 2"); + // Colour-group names share the swatch namespace. + assert_eq!(s.execute("swatch.newGroup", &json!({"name": "Sky"})).unwrap()["name"], "Sky 2"); + // New swatches from a colour are named by their RGB values (also inside new groups). + assert_eq!(s.execute("swatch.new", &json!({"color": "#ff8000"})).unwrap()["name"], "R=255 G=128 B=0"); + s.execute("swatch.newGroup", &json!({"name": "G", "colors": ["#00ff00"]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.swatch_groups.iter().find(|g| g.name == "G").unwrap().swatches[0].name, "R=0 G=255 B=0"); + + let id = rect(&mut s, 0.0); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graphicStyle.new", &json!({"name": "Look"})).unwrap(); + assert_eq!(s.execute("graphicStyle.duplicate", &json!({"name": "Look"})).unwrap()["name"], "Look copy"); + assert_eq!(s.execute("graphicStyle.duplicate", &json!({"name": "Look"})).unwrap()["name"], "Look copy 2"); +} diff --git a/crates/engine/src/tests_colormgmt.rs b/crates/engine/src/tests_colormgmt.rs index 02f25c27..42906c58 100644 --- a/crates/engine/src/tests_colormgmt.rs +++ b/crates/engine/src/tests_colormgmt.rs @@ -13,7 +13,7 @@ use vectorcraft_render::{RenderOptions, Renderer}; use super::*; -/// Tests that touch the process-wide proof view or colour settings run one at a time. +/// Every test here reads or changes the process-wide proof view or colour settings: one at a time. static GLOBAL: Mutex<()> = Mutex::new(()); fn session() -> Session { @@ -45,6 +45,7 @@ fn render(doc: &Document, opts: RenderOptions) -> vectorcraft_render::Rendered { #[test] fn document_mode_converts_through_cms() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#ff0000")); let r = s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap(); @@ -63,6 +64,7 @@ fn document_mode_converts_through_cms() { #[test] fn document_mode_conversion_is_idempotent() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#336699")); rect(&mut s, 20.0, 0.0, 10.0, 10.0, json!({"gray": 40})); @@ -79,6 +81,7 @@ fn document_mode_conversion_is_idempotent() { #[test] fn rgb_cmyk_rgb_document_roundtrip_within_tolerance() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let cols = ["#cc9980", "#808080", "#4d7359", "#e6d94d"]; let ids: Vec = cols.iter().enumerate().map(|(i, h)| rect(&mut s, i as f64 * 20.0, 0.0, 10.0, 10.0, json!(h))).collect(); @@ -95,6 +98,7 @@ fn rgb_cmyk_rgb_document_roundtrip_within_tolerance() { #[test] fn swatch_links_survive_mode_conversion() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); s.execute("swatch.new", &json!({"name": "Brand", "color": "#2a6fb0", "global": true})).unwrap(); let a = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); @@ -109,6 +113,7 @@ fn swatch_links_survive_mode_conversion() { #[test] fn gamut_check_flags_unprintable_colours() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#0000ff")); rect(&mut s, 20.0, 0.0, 10.0, 10.0, json!("#cc9980")); @@ -124,6 +129,7 @@ fn gamut_check_flags_unprintable_colours() { #[test] fn convert_query_and_intents() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let q = |s: &mut Session, intent: &str| s.execute("color.convert", &json!({"color": "#0000ff", "to": "cmyk", "intent": intent})).unwrap(); let p = q(&mut s, "perceptual"); @@ -170,6 +176,7 @@ fn color_settings_lists_profiles_and_validates() { #[test] fn proof_colors_simulate_press_on_screen() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!("#0000ff")); let doc = s.doc().unwrap().doc.clone(); @@ -186,6 +193,7 @@ fn proof_colors_simulate_press_on_screen() { #[test] fn separation_plates_render_ink_coverage() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); rect(&mut s, 0.0, 0.0, 50.0, 100.0, json!({"c": 100, "m": 0, "y": 0, "k": 0})); rect(&mut s, 50.0, 0.0, 50.0, 100.0, json!({"c": 0, "m": 50, "y": 0, "k": 0})); @@ -223,6 +231,7 @@ fn separation_plates_render_ink_coverage() { #[test] fn rgb_art_separates_with_gcr() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!("#000000")); let doc = s.doc().unwrap().doc.clone(); @@ -274,6 +283,7 @@ fn spot_colours_get_their_own_plate() { #[test] fn overprint_black_multiplies_in_preview() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!({"c": 100, "m": 0, "y": 0, "k": 0})); let k = rect(&mut s, 25.0, 25.0, 50.0, 50.0, json!({"c": 0, "m": 0, "y": 0, "k": 100})); @@ -304,6 +314,8 @@ fn view_toggles_redraw_without_dirtying() { assert_eq!(r["target"], "deuteranopia"); assert_eq!(vectorcraft_render::proof::active_proof().unwrap().target, ProofTarget::Deuteranopia); assert!(s.execute("view.proofSetup", &json!({"target": "cmyk:Nope"})).is_err()); + assert!(s.execute("view.proofSetup", &json!({"target": format!("cmyk:{}", cms::WIDE_GAMUT_RGB)})).is_err(), "an RGB profile"); + s.execute("view.proofSetup", &json!({"target": format!("cmyk:{}", cms::DEVICE_CMYK)})).unwrap(); s.execute("view.proofSetup", &json!({"target": "workingCmyk", "intent": "relative"})).unwrap(); let r = s.execute("view.proofColors", &json!({"on": false})).unwrap(); assert_eq!(r["proofColors"], false); @@ -325,6 +337,7 @@ fn pdf_text(doc: &Document) -> String { #[test] fn pdf_cmyk_document_writes_device_cmyk() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#ff0000")); let rgb_pdf = pdf_text(&s.doc().unwrap().doc); @@ -356,6 +369,7 @@ fn pdf_spot_colour_is_a_separation() { #[test] fn intents_reach_the_document_conversion() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let mut a = session(); rect(&mut a, 0.0, 0.0, 10.0, 10.0, json!("#00ff00")); let mut b = session(); @@ -377,3 +391,24 @@ fn intents_reach_the_document_conversion() { assert_ne!(first(&a), first(&b)); let _ = Intent::Perceptual; } + +#[test] +fn legacy_profile_name_resolves_in_commands_and_files() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let (old, _) = cms::LEGACY_NAMES[0]; + let mut s = session(); + let before = cms::active_settings(); + let r = s.execute("edit.colorSettings", &json!({"rgb": old})).unwrap(); + assert_eq!(r["rgb"], cms::WIDE_GAMUT_RGB); + assert_eq!(cms::active_settings().rgb, cms::WIDE_GAMUT_RGB); + let names: Vec<&str> = r["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert!(names.contains(&cms::WIDE_GAMUT_RGB) && !names.contains(&old)); + let r = s.execute("edit.assignProfile", &json!({"rgb": old})).unwrap(); + assert_eq!(r["rgb"], cms::WIDE_GAMUT_RGB, "stored under the current name"); + // A document saved by an older version names the profile the old way. + let mut d = (*s.doc().unwrap().doc).clone(); + d.unknown.insert(cmd::colormgmt::PROFILES_KEY.into(), json!({"rgb": old, "cmyk": null})); + let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); + assert_eq!(cmd::colormgmt::doc_profiles(&back), (Some(cms::WIDE_GAMUT_RGB.to_string()), None)); + cms::set_active(&before).unwrap(); +} diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index 2f44d0f1..6b573ac5 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -1,4 +1,4 @@ -//! Panel-support commands (`cmd/panelcmds.rs`). +//! Panel-support commands (`cmd/{panelcmds,gradient,appearance,stroke,swatch,style}.rs`). use serde_json::json; use vectorcraft_color::{GradientKind, Paint}; diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 419150c0..7234d53e 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -12,7 +12,6 @@ workspace = true vectorcraft-engine = { workspace = true } vectorcraft-render = { workspace = true } vectorcraft-svg = { workspace = true } -vectorcraft-pdf = { workspace = true } vectorcraft-format = { workspace = true } vectorcraft-tools = { workspace = true } vectorcraft-geom = { workspace = true } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index d18d403d..5bb73181 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -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 { 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()), } @@ -216,80 +210,24 @@ impl Headless { } } - /// `app.open {path}`: `.vectorcraft`, `.svg`, `.pdf` or PDF-compatible `.ai` 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 { 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" Result { 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 { - 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); - vectorcraft_render::raster_size(r, scale)?; - 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()) } } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 74e751ab..38c06092 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -29,3 +29,5 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979"; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_fileio; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 57ebb88c..6b168481 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -18,7 +18,7 @@ const INVALID_PARAMS: i64 = -32602; const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; -const INSTRUCTIONS: &str = "VectorCraft is a vector illustration app (an Illustrator clone). Coordinates are points in \ +const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are points in \ document space (y down, origin at the first artboard's top-left; a new document is 612×792). Draw with draw_shape / \ draw_path, change colours with set_paint, look with screenshot and inspect_document. Every menu action is a command: \ find it with list_commands and run it with run_command. New objects become the selection, and most commands act on the \ diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs new file mode 100644 index 00000000..8b35f91d --- /dev/null +++ b/crates/mcp/src/tests_fileio.rs @@ -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>) { + 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() +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 34c10bb6..5cd9b125 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -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 { tool( "open_file", "Open file", - "Open a .vectorcraft, .svg, .pdf or PDF-compatible .ai 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 { 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::>(), "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, ), @@ -302,7 +309,7 @@ pub fn tool_definitions() -> Vec { obj( json!({ "effect": {"type": "string", "description": "Effect id (see the catalogue)"}, - "params": {"type": "object", "description": "Effect parameters; missing keys take Illustrator's dialog defaults"}, + "params": {"type": "object", "description": "Effect parameters; missing keys take the dialog defaults"}, "ids": {"type": "array", "items": {"type": "integer"}}, }), &[], @@ -347,7 +354,7 @@ pub fn tool_definitions() -> Vec { tool( "create_graph", "Create graph", - "Create a graph (Illustrator graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.", + "Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.", obj( json!({ "type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]}, @@ -729,31 +736,21 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result 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" => { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 889f2224..ed602a51 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -21,13 +21,13 @@ use vectorcraft_doc::Document; /// PDF standard / version to target. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Compatibility { - /// Acrobat 5 (PDF 1.4). + /// PDF 1.4. Pdf14, - /// Acrobat 6 (PDF 1.5). + /// PDF 1.5. Pdf15, - /// Acrobat 7 (PDF 1.6). + /// PDF 1.6. Pdf16, - /// Acrobat 8+ (PDF 1.7). The default. + /// PDF 1.7. The default. #[default] Pdf17, /// PDF 2.0. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fa3bf2d2..c28279cd 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -73,6 +73,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 { @@ -86,6 +88,7 @@ impl Default for RenderOptions { proof: None, overprint_preview: false, trim: false, + skip_templates: false, } } } @@ -352,12 +355,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) } @@ -427,7 +431,8 @@ impl Renderer { } fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { - 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) { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 7de7893d..1d56029b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-ui-egui" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft egui frontend (Illustrator-style UI); swappable — nothing below depends on it" +description = "VectorCraft egui frontend (menus, panels, tools and canvas); swappable — nothing below depends on it" [lints] workspace = true diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b2866024..a5e0e33b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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; @@ -298,13 +300,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) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs deleted file mode 100644 index 8c50b682..00000000 --- a/crates/ui-egui/src/dialogs.rs +++ /dev/null @@ -1,702 +0,0 @@ -//! Modal dialogs. Fields live in `UiState::dialog` (string/number JSON) so agents can fill them -//! through `ui.dialog.set` and press OK with `ui.dialog.confirm`. - -use serde_json::{Value, json}; - -use crate::state::Dialog; -use crate::theme::{self, Tokens}; -use crate::{VectorcraftApp, widgets}; - -/// A click with a shape tool opens its size dialog. -pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { - let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0); - let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0); - let d = match kind { - "rectangle" | "ellipse" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt"})), - "roundedRectangle" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "radius": "12 pt"})), - "polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})), - "star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})), - "lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})), - // Graph tool click: the graph's size. - "graph" => Dialog::new( - "command", - json!({"__command": "graph.create", "__label": "Graph", "type": p.get("type").cloned().unwrap_or(json!("column")), "x": x, "y": y, "width": 200, "height": 150}), - ), - // After drawing a graph: Graph Data (CSV: header row of series, then category, values…). - "graphData" => match app.session.execute("graph.setData", &json!({})) { - Ok(v) => Dialog::new("command", json!({"__command": "graph.setData", "__label": "Graph Data", "csv": v["csv"]})), - Err(_) => return, - }, - // Flare Tool Options (Center / Halo / Rays / Rings), applied through the generic command dialog. - "flare" => Dialog::new( - "command", - json!({"__command": "shape.flare", "__label": "Flare Tool Options", "cx": x, "cy": y, "diameter": 100, "opacity": 50, "brightness": 30, - "growth": 20, "fuzziness": 50, "rays": 15, "longest": 300, "rayFuzziness": 100, "pathLength": 300, "rings": 10, "largest": 50, "direction": 45}), - ), - "rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => { - let mut base = match kind { - "rotate" => json!({"angle": 0}), - "reflect" => json!({"axis": "vertical"}), - "scale" => json!({"sx": 100, "sy": 100, "uniform": true}), - "shear" => json!({"angle": 0, "axis": "horizontal"}), - _ => json!({}), - }; - if let (Some(b), Some(o)) = (base.as_object_mut(), p.as_object()) { - for (k, v) in o { - b.insert(k.clone(), v.clone()); - } - } - Dialog::new(kind, base) - } - _ => return, - }; - app.ui.dialog = Some(d); -} - -/// Pass a tool-chosen reference point (origin) through to transform commands. -fn origin_params(d: &Dialog, mut p: Value) -> Value { - if let Some(o) = d.fields.get("origin") { - p["origin"] = o.clone(); - } - p -} - -/// Effect parameters from the dialog fields (drop UI-only keys). -fn effect_params(d: &Dialog) -> Value { - Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect()) -} - -/// Generic editor for an effect dialog: numbers, booleans, strings and colours. -fn effect_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let t = Tokens::get(ui.ctx()); - let mut changed = false; - egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - let keys: Vec<(String, Value)> = - d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect(); - for (k, v) in keys { - ui.label(egui::RichText::new(humanize(&k)).color(t.text)); - match v { - Value::Number(n) => { - let mut x = n.as_f64().unwrap_or(0.0); - let speed = if x.abs() > 20.0 { 1.0 } else { 0.1 }; - if ui.add(egui::DragValue::new(&mut x).speed(speed).max_decimals(2)).changed() { - d.fields.insert(k, json!(x)); - changed = true; - } - } - Value::Bool(mut b) => { - if ui.checkbox(&mut b, "").changed() { - d.fields.insert(k, json!(b)); - changed = true; - } - } - Value::String(mut s) if s.contains('\n') => { - // Multi-line values (Graph Data CSV) get a text area. - if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() { - d.fields.insert(k, json!(s)); - changed = true; - } - } - Value::String(mut s) => { - if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() { - d.fields.insert(k, json!(s)); - changed = true; - } - } - other => { - ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0)); - } - } - ui.end_row(); - } - }); - changed -} - -fn title(kind: &str) -> &'static str { - match kind { - "newDocument" => "New Document", - "rectangle" => "Rectangle", - "roundedRectangle" => "Rounded Rectangle", - "ellipse" => "Ellipse", - "polygon" => "Polygon", - "star" => "Star", - "lineSegment" => "Line Segment Tool Options", - "move" => "Move", - "rotate" => "Rotate", - "scale" => "Scale", - "reflect" => "Reflect", - "shear" => "Shear", - "average" => "Average", - "offsetPath" => "Offset Path", - "simplify" => "Simplify", - "splitIntoGrid" => "Split Into Grid", - "documentSetup" => "Document Setup", - "preferences" => "Preferences", - "shortcuts" => "Keyboard Shortcuts", - "artboardOptions" => "Artboard Options", - "allTools" => "All Tools", - "exportForScreens" => "Export for Screens", - "recolor" => "Recolor Artwork", - crate::unsaved::KIND => "Save Changes", - _ => "Dialog", - } -} - -/// Apply the open dialog (OK). -pub fn confirm(app: &mut VectorcraftApp) -> Result { - let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; - match d.kind.as_str() { - "preferences" => return crate::prefs_dialog::confirm(app), - "shortcuts" => return crate::shortcut_editor::confirm(app), - "newWorkspace" | "manageWorkspaces" => return crate::workspaces::confirm(app), - "findFont" => return crate::find_font::confirm(app), - crate::unsaved::KIND => return crate::unsaved::confirm(app), - _ => {} - } - let copy = d.bool("copy"); - if d.kind == "recolor" { - let map = recolor_map(&d); - app.ui.dialog = None; - if app.session.in_interaction() { - let _ = app.session.preview("recolor.apply", &json!({"map": map})); - return app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()); - } - return app.run("recolor.apply", json!({"map": map})); - } - if d.kind == "exportForScreens" { - let boards: Vec = d - .fields - .get("boards") - .and_then(Value::as_array) - .map(|a| a.iter().enumerate().filter(|(_, b)| b.as_bool() == Some(true)).map(|(i, _)| i).collect()) - .unwrap_or_default(); - let params = json!({"folder": d.str("folder"), "artboards": boards, "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), "prefix": d.str("prefix")}); - app.ui.dialog = None; - let r = app.run("document.exportForScreens", params); - if let Ok(v) = &r { - let n = v["files"].as_array().map(|a| a.len()).unwrap_or(0); - app.status(format!("Exported {n} file(s) to {}", d.str("folder"))); - } - return r; - } - if d.kind == "command" { - let cmd = d.str("__command"); - let params = effect_params(&d); - app.ui.dialog = None; - return app.run(&cmd, params); - } - if d.kind == "effect" { - let effect = d.str("__effect"); - let params = effect_params(&d); - let r = if app.session.in_interaction() { - // Live preview already applied: keep it (the interaction commits as one undo step). - let _ = app.session.preview("effect.apply", &json!({"effect": effect, "params": params})); - app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()) - } else { - app.run("effect.apply", json!({"effect": effect, "params": params})) - }; - app.last_effect = Some((effect, params)); - app.ui.dialog = None; - return r; - } - let r = match d.kind.as_str() { - "newDocument" => { - let r = app.run("file.new", json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()})); - if r.is_ok() { - app.ui.dialog = None; - } - return r; - } - "rectangle" | "roundedRectangle" => app.run("shape.rectangle", json!({"x": d.f64("x", 0.0), "y": d.f64("y", 0.0), "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)})), - "ellipse" => app.run("shape.ellipse", json!({"x": d.f64("x", 0.0), "y": d.f64("y", 0.0), "width": d.f64("width", 100.0), "height": d.f64("height", 100.0)})), - "polygon" => app.run("shape.polygon", json!({"cx": d.f64("x", 0.0), "cy": d.f64("y", 0.0), "radius": d.f64("radius", 50.0), "sides": d.f64("sides", 6.0) as u64})), - "star" => app.run("shape.star", json!({"cx": d.f64("x", 0.0), "cy": d.f64("y", 0.0), "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64})), - "lineSegment" => { - let (x, y, l, a) = (d.f64("x", 0.0), d.f64("y", 0.0), d.f64("length", 100.0), d.f64("angle", 0.0).to_radians()); - app.run("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()})) - } - "move" => app.run("object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})), - "rotate" => app.run("object.rotate", origin_params(&d, json!({"angle": d.f64("angle", 0.0), "copy": copy}))), - "scale" => { - let sx = d.f64("sx", 100.0); - let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) }; - app.run("object.scale", origin_params(&d, json!({"sx": sx, "sy": sy, "copy": copy}))) - } - "reflect" => app.run("object.reflect", origin_params(&d, json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy}))), - "shear" => app.run("object.shear", origin_params(&d, json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy}))), - "artboardOptions" => { - let mut p = serde_json::Value::Object(d.fields.clone()); - for k in ["x", "y", "width", "height"] { - if d.fields.contains_key(k) { - p[k] = json!(d.f64(k, 0.0)); - } - } - app.run("artboard.setProps", p) - } - "average" => app.run("path.average", json!({"axis": d.str("axis")})), - "offsetPath" => app.run("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)})), - "simplify" => app.run("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})), - "splitIntoGrid" => app.run("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})), - "documentSetup" => app.run("document.setUnits", json!({"units": d.str("units")})), - _ => Ok(Value::Null), - }; - app.ui.dialog = None; - r -} - -fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(label).color(t.text_dim)); - let mut s = d.str(key); - let r = egui::Frame::NONE - .fill(t.input) - .stroke(egui::Stroke::new(1.0, t.input_border)) - .corner_radius(egui::CornerRadius::same(3)) - .inner_margin(egui::Margin::symmetric(6, 3)) - .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0))); - if r.inner.changed() { - d.fields.insert(key.into(), Value::String(s)); - } - ui.end_row(); -} - -fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { - let mut b = d.bool(key); - if ui.checkbox(&mut b, label).changed() { - d.fields.insert(key.into(), Value::Bool(b)); - } -} - -pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { - if app.ui.about { - let mut open = true; - egui::Window::new("About VectorCraft") - .collapsible(false) - .resizable(false) - .open(&mut open) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18))) - .show(ctx, |ui| { - ui.set_width(380.0); - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::hover()); - crate::brand::paint_mark(ui.painter(), r); - ui.vertical(|ui| { - ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0))); - ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION"))); - }); - }); - ui.add_space(12.0); - crate::community::links(app, ui); - ui.add_space(12.0); - ui.label( - egui::RichText::new( - "Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft.", - ) - .size(11.0), - ); - }); - app.ui.about = open; - } - let Some(mut d) = app.ui.dialog.clone() else { return }; - match d.kind.as_str() { - "preferences" => return crate::prefs_dialog::show(app, ctx), - "shortcuts" => return crate::shortcut_editor::show(app, ctx), - "newWorkspace" | "manageWorkspaces" => return crate::workspaces::show(app, ctx), - "findFont" => return crate::find_font::show(app, ctx), - _ => {} - } - let t = Tokens::get(ctx); - let mut ok = false; - let mut cancel = false; - let mut discard = false; - egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| { - // Illustrator's dialogs are modal but don't dim the canvas (previews stay readable). - ui.allocate_rect(ctx.content_rect(), egui::Sense::click()); - }); - egui::Window::new(title(&d.kind)) - .id(egui::Id::new("dialog")) - .order(egui::Order::Foreground) - .collapsible(false) - .resizable(false) - .title_bar(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0]) - .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22))) - .show(ctx, |ui| { - ui.set_min_width(if d.kind == "newDocument" { 560.0 } else { 320.0 }); - if d.kind == crate::unsaved::KIND { - // Long document names wrap instead of widening the dialog. - ui.set_max_width(420.0); - } - let heading = match d.kind.as_str() { - "effect" | "command" => d.str("__label"), - crate::unsaved::KIND => format!("Do you want to save the changes you made to “{}”?", d.str("name")), - k => title(k).to_string(), - }; - ui.label(egui::RichText::new(heading).font(theme::semibold(16.0)).color(t.text)); - ui.add_space(12.0); - match d.kind.as_str() { - crate::unsaved::KIND => { - ui.label(egui::RichText::new("Your changes will be lost if you don't save them.").color(t.text_dim)); - } - "exportForScreens" => export_for_screens_ui(app, ui, &mut d), - "recolor" => { - if recolor_ui(ui, &mut d) || !app.session.in_interaction() { - let _ = app.session.begin_interaction("Recolor Artwork"); - let _ = app.session.preview("recolor.apply", &json!({"map": recolor_map(&d)})); - } - } - "command" => { - effect_fields(ui, &mut d); - } - "effect" => { - let changed = effect_fields(ui, &mut d); - ui.add_space(6.0); - let mut pv = d.bool("preview"); - let pv_changed = ui.checkbox(&mut pv, "Preview").changed(); - d.fields.insert("preview".into(), json!(pv)); - if pv && (changed || pv_changed || !app.session.in_interaction()) { - let label = d.str("__label"); - let _ = app.session.begin_interaction(&label); - let _ = app.session.preview("effect.apply", &json!({"effect": d.str("__effect"), "params": effect_params(&d)})); - } else if !pv && pv_changed { - let _ = app.session.cancel_interaction(); - } - } - "newDocument" => new_document(ui, &mut d), - "allTools" => { - for g in vectorcraft_tools::TOOL_GROUPS { - ui.horizontal_wrapped(|ui| { - for tool in g.iter() { - if ui.button(tool.label.trim_end_matches(" Tool")).clicked() { - app.select_tool(tool.id); - cancel = true; - } - } - }); - } - } - _ => { - egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { - let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - for (k, v) in keys { - if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() { - continue; - } - field(ui, &mut d, &k, &humanize(&k)); - } - }); - if matches!(d.kind.as_str(), "move" | "rotate" | "scale" | "reflect" | "shear") { - ui.add_space(6.0); - if d.kind == "scale" { - check(ui, &mut d, "uniform", "Uniform"); - } - check(ui, &mut d, "copy", "Copy (make a transformed copy)"); - } - } - } - ui.add_space(16.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let label = match d.kind.as_str() { - "newDocument" => "Create", - "exportForScreens" => "Export Artboard", - crate::unsaved::KIND => "Save", - _ => "OK", - }; - if d.kind != "allTools" && widgets::primary_button(ui, label).clicked() { - ok = true; - } - ui.add_space(8.0); - if widgets::secondary_button(ui, if d.kind == "allTools" { "Close" } else { "Cancel" }).clicked() { - cancel = true; - } - if d.kind == crate::unsaved::KIND { - ui.add_space(28.0); - discard = widgets::secondary_button(ui, "Don't Save").clicked(); - } - }); - }); - if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && d.kind != "allTools" { - ok = true; - } - if discard { - d.fields.insert("discard".into(), json!(true)); - ok = true; - } - let is_effect = d.kind == "effect" || d.kind == "recolor"; - app.ui.dialog = Some(d); - if cancel { - if is_effect { - let _ = app.session.cancel_interaction(); - } - app.ui.dialog = None; - } else if ok && let Err(e) = confirm(app) { - app.status(e); - } -} - -fn humanize(k: &str) -> String { - let mut s = String::new(); - for (i, c) in k.chars().enumerate() { - if i == 0 { - s.extend(c.to_uppercase()); - } else if c.is_uppercase() { - s.push(' '); - s.push(c); - } else { - s.push(c); - } - } - match s.as_str() { - "Dx" => "Horizontal".into(), - "Dy" => "Vertical".into(), - "Sx" => "Horizontal %".into(), - "Sy" => "Vertical %".into(), - "Radius1" => "Radius 1".into(), - "Radius2" => "Radius 2".into(), - _ => format!("{s}:"), - } -} - -fn new_document(ui: &mut egui::Ui, d: &mut Dialog) { - let t = Tokens::get(ui.ctx()); - let presets: [(&str, &str, &str, &str); 8] = [ - ("Letter", "612 pt", "792 pt", "Points"), - ("Legal", "612 pt", "1008 pt", "Points"), - ("Tabloid", "792 pt", "1224 pt", "Points"), - ("A4", "595.28 pt", "841.89 pt", "Points"), - ("A3", "841.89 pt", "1190.55 pt", "Points"), - ("Web 1920", "1920 px", "1080 px", "Pixels"), - ("iPhone", "390 px", "844 px", "Pixels"), - ("Square Post", "1080 px", "1080 px", "Pixels"), - ]; - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.set_width(300.0); - ui.label(egui::RichText::new("Presets").color(t.text_dim)); - ui.horizontal_wrapped(|ui| { - for (name, w, h, u) in presets { - let sel = d.str("preset") == name; - if ui.selectable_label(sel, name).clicked() { - d.fields.insert("preset".into(), json!(name)); - d.fields.insert("width".into(), json!(w)); - d.fields.insert("height".into(), json!(h)); - d.fields.insert("units".into(), json!(u)); - } - } - }); - }); - ui.separator(); - ui.vertical(|ui| { - ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5))); - egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { - field(ui, d, "name", "Name:"); - field(ui, d, "width", "Width:"); - field(ui, d, "height", "Height:"); - field(ui, d, "artboards", "Artboards:"); - field(ui, d, "colorMode", "Color Mode:"); - }); - }); - }); -} - -/// Export for Screens: artboard picker (thumbnails + checkboxes), format/scale rows, destination. -fn export_for_screens_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { - let t = Tokens::get(ui.ctx()); - let names: Vec = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); - let mut boards: Vec = - d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default(); - boards.resize(names.len(), true); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(260.0); - ui.label(egui::RichText::new("Artboards").color(t.text)); - ui.horizontal(|ui| { - if ui.small_button("Select All").clicked() { - boards.iter_mut().for_each(|b| *b = true); - } - if ui.small_button("Clear").clicked() { - boards.iter_mut().for_each(|b| *b = false); - } - }); - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (i, name) in names.iter().enumerate() { - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); - match artboard_thumb(app, ui.ctx(), i) { - Some(tex) => { - let sz = tex.size_vec2(); - let s = (46.0 / sz.x.max(sz.y)).min(1.0); - let ir = egui::Rect::from_center_size(r.center(), sz * s); - ui.painter().image( - tex.id(), - ir, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - None => { - ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); - } - } - ui.checkbox(&mut boards[i], name); - }); - } - }); - }); - let (sep, _) = ui.allocate_exact_size(egui::vec2(9.0, 360.0), egui::Sense::hover()); - ui.painter().line_segment([sep.center_top(), sep.center_bottom()], egui::Stroke::new(1.0, t.divider)); - ui.vertical(|ui| { - ui.set_width(330.0); - ui.label(egui::RichText::new("Export to").color(t.text)); - let mut folder = d.str("folder"); - if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0)).changed() { - d.fields.insert("folder".into(), json!(folder)); - } - ui.add_space(8.0); - ui.label(egui::RichText::new("Formats").color(t.text)); - let mut formats: Vec = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); - let mut remove = None; - egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - ui.label(egui::RichText::new("Scale").color(t.text_dim)); - ui.label(egui::RichText::new("Suffix").color(t.text_dim)); - ui.label(egui::RichText::new("Format").color(t.text_dim)); - ui.label(""); - ui.end_row(); - for (i, f) in formats.iter_mut().enumerate() { - let mut sc = f["scale"].as_f64().unwrap_or(1.0); - if ui.add(egui::DragValue::new(&mut sc).range(0.1..=10.0).speed(0.5).suffix("x")).changed() { - f["scale"] = json!(sc); - } - let mut suffix = f["suffix"].as_str().unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut suffix).desired_width(60.0)).changed() { - f["suffix"] = json!(suffix); - } - let cur = f["format"].as_str().unwrap_or("png").to_string(); - egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| { - for fm in ["png", "jpg", "webp", "svg", "pdf"] { - if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() { - f["format"] = json!(fm); - } - } - }); - if ui.small_button("×").clicked() { - remove = Some(i); - } - ui.end_row(); - } - }); - if let Some(i) = remove { - formats.remove(i); - } - if ui.button("+ Add Scale").clicked() { - let next = formats.len() as f64 + 1.0; - formats.push(json!({"format": "png", "scale": next, "suffix": format!("@{next}x")})); - } - d.fields.insert("formats".into(), Value::Array(formats)); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("Prefix").color(t.text_dim)); - let mut prefix = d.str("prefix"); - if ui.add(egui::TextEdit::singleline(&mut prefix).desired_width(120.0)).changed() { - d.fields.insert("prefix".into(), json!(prefix)); - } - }); - }); - }); - d.fields.insert("boards".into(), json!(boards)); -} - -/// A small cached rendering of artboard `i` (keyed by document revision). -fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option { - let st = app.session.active()?; - let key = egui::Id::new(("ab-thumb", i, st.revision)); - if let Some(t) = ctx.data(|d| d.get_temp::(key)) { - return Some(t); - } - let doc = st.doc.clone(); - let r = doc.artboards.get(i)?.rect; - let scale = 92.0 / r.width().max(r.height()).max(1.0); - let img = app.canvas.renderer.render_region(&doc, r, scale, true); - let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = ctx.load_texture(format!("ab-thumb-{i}"), color, egui::TextureOptions::LINEAR); - ctx.data_mut(|d| d.insert_temp(key, tex.clone())); - Some(tex) -} - -fn recolor_map(d: &Dialog) -> Value { - let mut m = serde_json::Map::new(); - for p in d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default() { - if let (Some(a), Some(b)) = (p.get(0).and_then(Value::as_str), p.get(1).and_then(Value::as_str)) { - m.insert(a.to_string(), json!(b)); - } - } - Value::Object(m) -} - -/// Recolor Artwork: current → new colour rows, harmony rules, randomize. Returns true when changed. -fn recolor_ui(ui: &mut egui::Ui, d: &mut Dialog) -> bool { - use vectorcraft_color::Color; - let t = Tokens::get(ui.ctx()); - let mut pairs: Vec = d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default(); - let mut changed = false; - let chip = |ui: &mut egui::Ui, hex: &str| { - let (r, _) = ui.allocate_exact_size(egui::vec2(34.0, 22.0), egui::Sense::hover()); - let c = Color::from_hex(hex).unwrap_or(Color::BLACK).to_rgba8(1.0); - ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2])); - ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); - }; - ui.horizontal(|ui| { - ui.label(egui::RichText::new("Current Colors").color(t.text_dim)); - ui.add_space(70.0); - ui.label(egui::RichText::new("New").color(t.text_dim)); - }); - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for p in pairs.iter_mut() { - let from = p[0].as_str().unwrap_or("#000000").to_string(); - let mut to = p[1].as_str().unwrap_or("#000000").to_string(); - ui.horizontal(|ui| { - chip(ui, &from); - ui.label(egui::RichText::new("→").color(t.text_dim)); - chip(ui, &to); - if ui.add(egui::TextEdit::singleline(&mut to).desired_width(80.0)).changed() && Color::from_hex(&to).is_some() { - p[1] = json!(to); - changed = true; - } - }); - } - }); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Randomize").clicked() { - // Deterministic scramble of hues (seeded by the colour itself), keeping brightness. - for (i, p) in pairs.iter_mut().enumerate() { - let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK); - let [h, s, v] = c.to_hsb(); - let nh = (h + 97.0 * (i as f32 + 1.0) + 41.0) % 360.0; - p[1] = json!(Color::from_hsb(nh, s.max(0.35), v.max(0.3)).to_hex()); - } - changed = true; - } - for (label, deg) in [("Complement", 180.0), ("Triad", 120.0), ("Analogous", 30.0)] { - if ui.button(label).clicked() { - for (i, p) in pairs.iter_mut().enumerate() { - let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK); - let [h, s, v] = c.to_hsb(); - let shift = if label == "Complement" { deg } else { deg * (i as f32 + 1.0) }; - p[1] = json!(Color::from_hsb(h + shift, s, v).to_hex()); - } - changed = true; - } - } - if ui.button("Reset").clicked() { - for p in pairs.iter_mut() { - p[1] = p[0].clone(); - } - changed = true; - } - }); - d.fields.insert("pairs".into(), Value::Array(pairs)); - changed -} diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs new file mode 100644 index 00000000..4fc1392e --- /dev/null +++ b/crates/ui-egui/src/dialogs/about.rs @@ -0,0 +1,39 @@ +//! Help → About VectorCraft. + +use crate::VectorcraftApp; +use crate::theme; + +/// The About window, while `UiState::about` is set. +pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + if !app.ui.about { + return; + } + let mut open = true; + egui::Window::new("About VectorCraft") + .collapsible(false) + .resizable(false) + .open(&mut open) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18))) + .show(ctx, |ui| { + ui.set_width(380.0); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::hover()); + crate::brand::paint_mark(ui.painter(), r); + ui.vertical(|ui| { + ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0))); + ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION"))); + }); + }); + ui.add_space(12.0); + crate::community::links(app, ui); + ui.add_space(12.0); + ui.label( + egui::RichText::new( + "Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft.", + ) + .size(11.0), + ); + }); + app.ui.about = open; +} diff --git a/crates/ui-egui/src/dialogs/all_tools.rs b/crates/ui-egui/src/dialogs/all_tools.rs new file mode 100644 index 00000000..a9f6dd76 --- /dev/null +++ b/crates/ui-egui/src/dialogs/all_tools.rs @@ -0,0 +1,22 @@ +//! The All Tools drawer: every tool as a button; picking one selects it and closes the drawer. + +use super::DialogSpec; +use crate::VectorcraftApp; +use crate::state::Dialog; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "All Tools".into(), body, ok: None, ..DialogSpec::FORM }; + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> bool { + let mut picked = false; + for g in vectorcraft_tools::TOOL_GROUPS { + ui.horizontal_wrapped(|ui| { + for tool in g.iter() { + if ui.button(tool.label.trim_end_matches(" Tool")).clicked() { + app.select_tool(tool.id); + picked = true; + } + } + }); + } + picked +} diff --git a/crates/ui-egui/src/dialogs/artboard_options.rs b/crates/ui-egui/src/dialogs/artboard_options.rs new file mode 100644 index 00000000..b47c92b7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/artboard_options.rs @@ -0,0 +1,20 @@ +//! Artboard Options (double-click with the Artboard tool): the artboard's fields, sizes parsed +//! with units. + +use serde_json::{Value, json}; + +use super::{DialogSpec, run_and_close}; +use crate::VectorcraftApp; +use crate::state::Dialog; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Artboard Options".into(), confirm, ..DialogSpec::FORM }; + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut p = Value::Object(d.fields.clone()); + for k in ["x", "y", "width", "height"] { + if d.fields.contains_key(k) { + p[k] = json!(d.f64(k, 0.0)); + } + } + run_and_close(app, "artboard.setProps", p) +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs new file mode 100644 index 00000000..d732cda7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/command.rs @@ -0,0 +1,26 @@ +//! The generic parameter dialog (`ui.paramDialog`): edits a command's parameters (`__command`, +//! headed `__label`) and runs it on OK. + +use serde_json::Value; + +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::state::Dialog; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |d| d.str("__label"), + body: |_, ui, d| { + form::param_fields(ui, d); + false + }, + confirm, + ..DialogSpec::FORM +}; + +/// Closes before running, so a dialog the command opens stays open. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let cmd = d.str("__command"); + let params = form::params(d); + app.ui.dialog = None; + app.run(&cmd, params) +} diff --git a/crates/ui-egui/src/dialogs/document_setup.rs b/crates/ui-egui/src/dialogs/document_setup.rs new file mode 100644 index 00000000..273a5bb7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/document_setup.rs @@ -0,0 +1,11 @@ +//! File → Document Setup: the document's ruler units. + +use serde_json::json; + +use super::{DialogSpec, run_and_close}; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Document Setup".into(), + confirm: |app, d| run_and_close(app, "document.setUnits", json!({"units": d.str("units")})), + ..DialogSpec::FORM +}; diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs new file mode 100644 index 00000000..99fe895d --- /dev/null +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -0,0 +1,41 @@ +//! Live effect dialogs (Effect menu): the effect's parameters (`__effect`, headed `__label`) with a +//! live Preview that OK keeps as one undo step and Cancel rolls back. + +use serde_json::{Value, json}; + +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::state::Dialog; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM }; + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let changed = form::param_fields(ui, d); + ui.add_space(6.0); + let mut pv = d.bool("preview"); + let pv_changed = ui.checkbox(&mut pv, "Preview").changed(); + d.fields.insert("preview".into(), json!(pv)); + if pv && (changed || pv_changed || !app.session.in_interaction()) { + let label = d.str("__label"); + let _ = app.session.begin_interaction(&label); + let _ = app.session.preview("effect.apply", &json!({"effect": d.str("__effect"), "params": form::params(d)})); + } else if !pv && pv_changed { + let _ = app.session.cancel_interaction(); + } + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let effect = d.str("__effect"); + let params = form::params(d); + let r = if app.session.in_interaction() { + // Live preview already applied: keep it (the interaction commits as one undo step). + let _ = app.session.preview("effect.apply", &json!({"effect": effect, "params": params})); + app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()) + } else { + app.run("effect.apply", json!({"effect": effect, "params": params})) + }; + app.last_effect = Some((effect, params)); + app.ui.dialog = None; + r +} diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs new file mode 100644 index 00000000..02104954 --- /dev/null +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -0,0 +1,154 @@ +//! File → Export → Export for Screens: artboards (thumbnails and checkboxes), scale/format rows, +//! destination folder and file-name prefix. + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Export for Screens".into(), body, confirm, ok: Some("Export Artboard"), ..DialogSpec::FORM }; + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let boards: Vec = d + .fields + .get("boards") + .and_then(Value::as_array) + .map(|a| a.iter().enumerate().filter(|(_, b)| b.as_bool() == Some(true)).map(|(i, _)| i).collect()) + .unwrap_or_default(); + let params = json!({"folder": d.str("folder"), "artboards": boards, "formats": d.fields.get("formats").cloned().unwrap_or(json!([])), "prefix": d.str("prefix")}); + app.ui.dialog = None; + let r = app.run("document.exportForScreens", params); + if let Ok(v) = &r { + let n = v["files"].as_array().map(|a| a.len()).unwrap_or(0); + app.status(format!("Exported {n} file(s) to {}", d.str("folder"))); + } + r +} + +/// Export for Screens: artboard picker (thumbnails + checkboxes), format/scale rows, destination. +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let names: Vec = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); + let mut boards: Vec = + d.fields.get("boards").and_then(Value::as_array).map(|a| a.iter().map(|b| b.as_bool().unwrap_or(false)).collect()).unwrap_or_default(); + boards.resize(names.len(), true); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(260.0); + ui.label(egui::RichText::new("Artboards").color(t.text)); + ui.horizontal(|ui| { + if ui.small_button("Select All").clicked() { + boards.iter_mut().for_each(|b| *b = true); + } + if ui.small_button("Clear").clicked() { + boards.iter_mut().for_each(|b| *b = false); + } + }); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (i, name) in names.iter().enumerate() { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); + match artboard_thumb(app, ui.ctx(), i) { + Some(tex) => { + let sz = tex.size_vec2(); + let s = (46.0 / sz.x.max(sz.y)).min(1.0); + let ir = egui::Rect::from_center_size(r.center(), sz * s); + ui.painter().image( + tex.id(), + ir, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + None => { + ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); + } + } + ui.checkbox(&mut boards[i], name); + }); + } + }); + }); + let (sep, _) = ui.allocate_exact_size(egui::vec2(9.0, 360.0), egui::Sense::hover()); + ui.painter().line_segment([sep.center_top(), sep.center_bottom()], egui::Stroke::new(1.0, t.divider)); + ui.vertical(|ui| { + ui.set_width(330.0); + ui.label(egui::RichText::new("Export to").color(t.text)); + let mut folder = d.str("folder"); + if ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0)).changed() { + d.fields.insert("folder".into(), json!(folder)); + } + ui.add_space(8.0); + ui.label(egui::RichText::new("Formats").color(t.text)); + let mut formats: Vec = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut remove = None; + egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { + ui.label(egui::RichText::new("Scale").color(t.text_dim)); + ui.label(egui::RichText::new("Suffix").color(t.text_dim)); + ui.label(egui::RichText::new("Format").color(t.text_dim)); + ui.label(""); + ui.end_row(); + for (i, f) in formats.iter_mut().enumerate() { + let mut sc = f["scale"].as_f64().unwrap_or(1.0); + if ui.add(egui::DragValue::new(&mut sc).range(0.1..=10.0).speed(0.5).suffix("x")).changed() { + f["scale"] = json!(sc); + } + let mut suffix = f["suffix"].as_str().unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut suffix).desired_width(60.0)).changed() { + f["suffix"] = json!(suffix); + } + let cur = f["format"].as_str().unwrap_or("png").to_string(); + egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| { + for fm in ["png", "jpg", "webp", "svg", "pdf"] { + if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() { + f["format"] = json!(fm); + } + } + }); + if ui.small_button("×").clicked() { + remove = Some(i); + } + ui.end_row(); + } + }); + if let Some(i) = remove { + formats.remove(i); + } + if ui.button("+ Add Scale").clicked() { + let next = formats.len() as f64 + 1.0; + formats.push(json!({"format": "png", "scale": next, "suffix": format!("@{next}x")})); + } + d.fields.insert("formats".into(), Value::Array(formats)); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Prefix").color(t.text_dim)); + let mut prefix = d.str("prefix"); + if ui.add(egui::TextEdit::singleline(&mut prefix).desired_width(120.0)).changed() { + d.fields.insert("prefix".into(), json!(prefix)); + } + }); + }); + }); + d.fields.insert("boards".into(), json!(boards)); + false +} + +/// A small cached rendering of artboard `i` (keyed by document revision). +fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option { + let st = app.session.active()?; + let key = egui::Id::new(("ab-thumb", i, st.revision)); + if let Some(t) = ctx.data(|d| d.get_temp::(key)) { + return Some(t); + } + let doc = st.doc.clone(); + let r = doc.artboards.get(i)?.rect; + let scale = 92.0 / r.width().max(r.height()).max(1.0); + let img = app.canvas.renderer.render_region(&doc, r, scale, true); + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let tex = ctx.load_texture(format!("ab-thumb-{i}"), color, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(key, tex.clone())); + Some(tex) +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs new file mode 100644 index 00000000..d30b461a --- /dev/null +++ b/crates/ui-egui/src/dialogs/form.rs @@ -0,0 +1,122 @@ +//! Field widgets shared by the dialogs: labelled text fields, checkboxes, the generic field grid +//! and the typed parameter editor of the command and effect dialogs. + +use serde_json::{Value, json}; + +use crate::state::Dialog; +use crate::theme::Tokens; + +/// A labelled text field bound to `d.fields[key]` (one grid row). +pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(label).color(t.text_dim)); + let mut s = d.str(key); + let r = egui::Frame::NONE + .fill(t.input) + .stroke(egui::Stroke::new(1.0, t.input_border)) + .corner_radius(egui::CornerRadius::same(3)) + .inner_margin(egui::Margin::symmetric(6, 3)) + .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0))); + if r.inner.changed() { + d.fields.insert(key.into(), Value::String(s)); + } + ui.end_row(); +} + +/// A checkbox bound to `d.fields[key]`. +pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + let mut b = d.bool(key); + if ui.checkbox(&mut b, label).changed() { + d.fields.insert(key.into(), Value::Bool(b)); + } +} + +/// The generic dialog body: a text field per non-boolean value (positions and indices hidden). +pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog) { + egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + for (k, v) in keys { + if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() { + continue; + } + field(ui, d, &k, &humanize(&k)); + } + }); +} + +/// Command/effect parameters from the dialog fields (drop UI-only keys). +pub(super) fn params(d: &Dialog) -> Value { + Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect()) +} + +/// Generic editor for command/effect parameters: numbers, booleans, strings and colours. +/// Returns true when a value changed. +pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let mut changed = false; + egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + let keys: Vec<(String, Value)> = + d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect(); + for (k, v) in keys { + ui.label(egui::RichText::new(humanize(&k)).color(t.text)); + match v { + Value::Number(n) => { + let mut x = n.as_f64().unwrap_or(0.0); + let speed = if x.abs() > 20.0 { 1.0 } else { 0.1 }; + if ui.add(egui::DragValue::new(&mut x).speed(speed).max_decimals(2)).changed() { + d.fields.insert(k, json!(x)); + changed = true; + } + } + Value::Bool(mut b) => { + if ui.checkbox(&mut b, "").changed() { + d.fields.insert(k, json!(b)); + changed = true; + } + } + Value::String(mut s) if s.contains('\n') => { + // Multi-line values (Graph Data CSV) get a text area. + if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() { + d.fields.insert(k, json!(s)); + changed = true; + } + } + Value::String(mut s) => { + if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() { + d.fields.insert(k, json!(s)); + changed = true; + } + } + other => { + ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0)); + } + } + ui.end_row(); + } + }); + changed +} + +/// A field label from its camelCase key (`miterLimit` → "Miter Limit:"). +pub(super) fn humanize(k: &str) -> String { + let mut s = String::new(); + for (i, c) in k.chars().enumerate() { + if i == 0 { + s.extend(c.to_uppercase()); + } else if c.is_uppercase() { + s.push(' '); + s.push(c); + } else { + s.push(c); + } + } + match s.as_str() { + "Dx" => "Horizontal".into(), + "Dy" => "Vertical".into(), + "Sx" => "Horizontal %".into(), + "Sy" => "Vertical %".into(), + "Radius1" => "Radius 1".into(), + "Radius2" => "Radius 2".into(), + _ => format!("{s}:"), + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs new file mode 100644 index 00000000..9532e989 --- /dev/null +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -0,0 +1,219 @@ +//! Modal dialogs. Fields live in `UiState::dialog` (string/number JSON) so agents can fill them +//! through `ui.dialog.set` and press OK with `ui.dialog.confirm`. +//! +//! Each dialog is a file here exporting a [`DialogSpec`] (heading, body, OK action, buttons) plus +//! a row in the `registry!` below that maps its `Dialog::kind` strings to it. Dialogs inside the +//! shared modal frame only supply a body and a confirm action; a few (Preferences, Keyboard +//! Shortcuts, Workspaces, Find Font) draw their own window. + +mod about; +mod all_tools; +mod artboard_options; +mod command; +mod document_setup; +mod effect; +mod export_for_screens; +mod form; +mod new_document; +mod path_ops; +mod recolor; +mod save_changes; +mod shapes; +mod tools; +mod transform; + +use serde_json::{Value, json}; + +pub use tools::open_tool_dialog; + +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; + +type DialogResult = Result; + +/// How a dialog draws and applies itself. Specs start from [`DialogSpec::FORM`] and override what +/// differs. +pub(crate) struct DialogSpec { + /// Draws its own window instead of the shared frame (the frame fields below are then unused). + pub window: Option, + /// The heading (and window title). + pub heading: fn(&Dialog) -> String, + /// Draws the fields. Returns true to close the dialog as Cancel would. + pub body: fn(&mut VectorcraftApp, &mut egui::Ui, &mut Dialog) -> bool, + /// OK, Enter or `ui.dialog.confirm`: applies the dialog and closes it when done. + pub confirm: fn(&mut VectorcraftApp, &Dialog) -> DialogResult, + /// The OK button's label. None: no OK button, Enter does nothing and Cancel reads "Close". + pub ok: Option<&'static str>, + /// An extra button left of Cancel that sets `discard: true` and confirms ("Don't Save"). + pub discard: Option<&'static str>, + pub min_width: f32, + pub max_width: Option, + /// The body runs a live preview interaction that Cancel rolls back. + pub preview: bool, +} + +impl DialogSpec { + /// A text field per value; OK just closes. Also the fallback for unregistered kinds. + pub const FORM: Self = Self { + window: None, + heading: |_| "Dialog".into(), + body: |_, ui, d| { + form::grid(ui, d); + false + }, + confirm: |app, _| { + app.ui.dialog = None; + Ok(Value::Null) + }, + ok: Some("OK"), + discard: None, + min_width: 320.0, + max_width: None, + preview: false, + }; + + /// A dialog that draws its own window and confirms through its module. + const fn window(show: fn(&mut VectorcraftApp, &egui::Context), confirm: fn(&mut VectorcraftApp, &Dialog) -> DialogResult) -> Self { + Self { window: Some(show), confirm, ..Self::FORM } + } +} + +/// The dialog registry: one row per dialog with its [`DialogKind`] variant, the `Dialog::kind` +/// strings it handles and its [`DialogSpec`]. Adding a dialog is a new file plus one row. +macro_rules! registry { + ($($variant:ident: [$($kind:pat),+] => $spec:expr,)+) => { + /// Every registered dialog. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum DialogKind { + $($variant,)+ + } + + impl DialogKind { + /// The dialog handling a `Dialog::kind` string. + pub fn of(kind: &str) -> Option { + match kind { + $($($kind)|+ => Some(Self::$variant),)+ + _ => None, + } + } + + fn spec(self) -> &'static DialogSpec { + match self { + $(Self::$variant => { + static SPEC: DialogSpec = $spec; + &SPEC + })+ + } + } + } + }; +} + +registry! { + NewDocument: ["newDocument"] => new_document::SPEC, + Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment"] => shapes::SPEC, + Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC, + PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC, + DocumentSetup: ["documentSetup"] => document_setup::SPEC, + ArtboardOptions: ["artboardOptions"] => artboard_options::SPEC, + AllTools: ["allTools"] => all_tools::SPEC, + ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC, + Recolor: ["recolor"] => recolor::SPEC, + Command: ["command"] => command::SPEC, + Effect: ["effect"] => effect::SPEC, + SaveChanges: [crate::unsaved::KIND] => save_changes::SPEC, + Preferences: ["preferences"] => DialogSpec::window(crate::prefs_dialog::show, |app, _| crate::prefs_dialog::confirm(app)), + Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)), + Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)), + FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)), +} + +/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). +fn spec(kind: &str) -> &'static DialogSpec { + static FALLBACK: DialogSpec = DialogSpec::FORM; + DialogKind::of(kind).map_or(&FALLBACK, DialogKind::spec) +} + +/// Run a command and close the dialog (whether or not the command succeeded). +fn run_and_close(app: &mut VectorcraftApp, id: &str, params: Value) -> DialogResult { + let r = app.run(id, params); + app.ui.dialog = None; + r +} + +/// Apply the open dialog (OK). +pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { + let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; + (spec(&d.kind).confirm)(app, &d) +} + +pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + about::show(app, ctx); + let Some(mut d) = app.ui.dialog.clone() else { return }; + let spec = spec(&d.kind); + if let Some(window) = spec.window { + return window(app, ctx); + } + let t = Tokens::get(ctx); + let mut ok = false; + let mut cancel = false; + let mut discard = false; + egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| { + // Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app). + ui.allocate_rect(ctx.content_rect(), egui::Sense::click()); + }); + let heading = (spec.heading)(&d); + egui::Window::new(heading.as_str()) + .id(egui::Id::new("dialog")) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .title_bar(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0]) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22))) + .show(ctx, |ui| { + ui.set_min_width(spec.min_width); + if let Some(w) = spec.max_width { + ui.set_max_width(w); + } + ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text)); + ui.add_space(12.0); + cancel = (spec.body)(app, ui, &mut d); + ui.add_space(16.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(label) = spec.ok + && widgets::primary_button(ui, label).clicked() + { + ok = true; + } + ui.add_space(8.0); + if widgets::secondary_button(ui, if spec.ok.is_some() { "Cancel" } else { "Close" }).clicked() { + cancel = true; + } + if let Some(label) = spec.discard { + ui.add_space(28.0); + discard = widgets::secondary_button(ui, label).clicked(); + } + }); + }); + if spec.ok.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { + ok = true; + } + if discard { + d.fields.insert("discard".into(), json!(true)); + ok = true; + } + app.ui.dialog = Some(d); + if cancel { + if spec.preview { + let _ = app.session.cancel_interaction(); + } + app.ui.dialog = None; + } else if ok && let Err(e) = confirm(app) { + app.status(e); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs new file mode 100644 index 00000000..ef4e549f --- /dev/null +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -0,0 +1,66 @@ +//! File → New: presets and the document details (name, size, artboards, colour mode). + +use serde_json::{Value, json}; + +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "New Document".into(), body, confirm, ok: Some("Create"), min_width: 560.0, ..DialogSpec::FORM }; + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let presets: [(&str, &str, &str, &str); 8] = [ + ("Letter", "612 pt", "792 pt", "Points"), + ("Legal", "612 pt", "1008 pt", "Points"), + ("Tabloid", "792 pt", "1224 pt", "Points"), + ("A4", "595.28 pt", "841.89 pt", "Points"), + ("A3", "841.89 pt", "1190.55 pt", "Points"), + ("Web 1920", "1920 px", "1080 px", "Pixels"), + ("Phone 390×844", "390 px", "844 px", "Pixels"), + ("Square Post", "1080 px", "1080 px", "Pixels"), + ]; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(300.0); + ui.label(egui::RichText::new("Presets").color(t.text_dim)); + ui.horizontal_wrapped(|ui| { + for (name, w, h, u) in presets { + let sel = d.str("preset") == name; + if ui.selectable_label(sel, name).clicked() { + d.fields.insert("preset".into(), json!(name)); + d.fields.insert("width".into(), json!(w)); + d.fields.insert("height".into(), json!(h)); + d.fields.insert("units".into(), json!(u)); + } + } + }); + }); + ui.separator(); + ui.vertical(|ui| { + ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5))); + egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + form::field(ui, d, "name", "Name:"); + form::field(ui, d, "width", "Width:"); + form::field(ui, d, "height", "Height:"); + form::field(ui, d, "artboards", "Artboards:"); + form::field(ui, d, "colorMode", "Color Mode:"); + }); + }); + }); + false +} + +/// Create the document; the dialog stays open when that fails (bad size, …). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let r = app.run( + "file.new", + json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()}), + ); + if r.is_ok() { + app.ui.dialog = None; + } + r +} diff --git a/crates/ui-egui/src/dialogs/path_ops.rs b/crates/ui-egui/src/dialogs/path_ops.rs new file mode 100644 index 00000000..24b600f5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/path_ops.rs @@ -0,0 +1,30 @@ +//! Path dialogs: Average, Offset Path, Simplify and Split Into Grid. + +use serde_json::{Value, json}; + +use super::{DialogSpec, run_and_close}; +use crate::VectorcraftApp; +use crate::state::Dialog; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM }; + +fn title(kind: &str) -> &'static str { + match kind { + "average" => "Average", + "offsetPath" => "Offset Path", + "simplify" => "Simplify", + _ => "Split Into Grid", + } +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (id, params) = match d.kind.as_str() { + "average" => ("path.average", json!({"axis": d.str("axis")})), + "offsetPath" => { + ("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)})) + } + "simplify" => ("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})), + _ => ("object.path.splitIntoGrid", json!({"rows": d.f64("rows", 2.0), "columns": d.f64("columns", 2.0), "gutter": d.f64("gutter", 12.0)})), + }; + run_and_close(app, id, params) +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs new file mode 100644 index 00000000..35c22b5a --- /dev/null +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -0,0 +1,105 @@ +//! Edit → Edit Colors → Recolor Artwork: current → new colour pairs, previewed live. + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Recolor Artwork".into(), body, confirm, preview: true, ..DialogSpec::FORM }; + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + if editor(ui, d) || !app.session.in_interaction() { + let _ = app.session.begin_interaction("Recolor Artwork"); + let _ = app.session.preview("recolor.apply", &json!({"map": recolor_map(d)})); + } + false +} + +/// Keep the previewed recolour as one undo step (or apply it when no preview is running). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let map = recolor_map(d); + app.ui.dialog = None; + if app.session.in_interaction() { + let _ = app.session.preview("recolor.apply", &json!({"map": map})); + return app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()); + } + app.run("recolor.apply", json!({"map": map})) +} + +fn recolor_map(d: &Dialog) -> Value { + let mut m = serde_json::Map::new(); + for p in d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default() { + if let (Some(a), Some(b)) = (p.get(0).and_then(Value::as_str), p.get(1).and_then(Value::as_str)) { + m.insert(a.to_string(), json!(b)); + } + } + Value::Object(m) +} + +/// Current → new colour rows, harmony rules, randomize. Returns true when changed. +fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool { + use vectorcraft_color::Color; + let t = Tokens::get(ui.ctx()); + let mut pairs: Vec = d.fields.get("pairs").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut changed = false; + let chip = |ui: &mut egui::Ui, hex: &str| { + let (r, _) = ui.allocate_exact_size(egui::vec2(34.0, 22.0), egui::Sense::hover()); + let c = Color::from_hex(hex).unwrap_or(Color::BLACK).to_rgba8(1.0); + ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2])); + ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + }; + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Current Colors").color(t.text_dim)); + ui.add_space(70.0); + ui.label(egui::RichText::new("New").color(t.text_dim)); + }); + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for p in pairs.iter_mut() { + let from = p[0].as_str().unwrap_or("#000000").to_string(); + let mut to = p[1].as_str().unwrap_or("#000000").to_string(); + ui.horizontal(|ui| { + chip(ui, &from); + ui.label(egui::RichText::new("→").color(t.text_dim)); + chip(ui, &to); + if ui.add(egui::TextEdit::singleline(&mut to).desired_width(80.0)).changed() && Color::from_hex(&to).is_some() { + p[1] = json!(to); + changed = true; + } + }); + } + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Randomize").clicked() { + // Deterministic scramble of hues (seeded by the colour itself), keeping brightness. + for (i, p) in pairs.iter_mut().enumerate() { + let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK); + let [h, s, v] = c.to_hsb(); + let nh = (h + 97.0 * (i as f32 + 1.0) + 41.0) % 360.0; + p[1] = json!(Color::from_hsb(nh, s.max(0.35), v.max(0.3)).to_hex()); + } + changed = true; + } + for (label, deg) in [("Complement", 180.0), ("Triad", 120.0), ("Analogous", 30.0)] { + if ui.button(label).clicked() { + for (i, p) in pairs.iter_mut().enumerate() { + let c = Color::from_hex(p[0].as_str().unwrap_or("#000")).unwrap_or(Color::BLACK); + let [h, s, v] = c.to_hsb(); + let shift = if label == "Complement" { deg } else { deg * (i as f32 + 1.0) }; + p[1] = json!(Color::from_hsb(h + shift, s, v).to_hex()); + } + changed = true; + } + } + if ui.button("Reset").clicked() { + for p in pairs.iter_mut() { + p[1] = p[0].clone(); + } + changed = true; + } + }); + d.fields.insert("pairs".into(), Value::Array(pairs)); + changed +} diff --git a/crates/ui-egui/src/dialogs/save_changes.rs b/crates/ui-egui/src/dialogs/save_changes.rs new file mode 100644 index 00000000..7f50e027 --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_changes.rs @@ -0,0 +1,19 @@ +//! Save Changes (closing a modified document): Save / Don't Save / Cancel. The flow lives in +//! [`crate::unsaved`]; this is its face in the shared dialog frame. + +use super::DialogSpec; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |d| format!("Do you want to save the changes you made to “{}”?", d.str("name")), + body: |_, ui, _| { + let t = crate::theme::Tokens::get(ui.ctx()); + ui.label(egui::RichText::new("Your changes will be lost if you don't save them.").color(t.text_dim)); + false + }, + confirm: |app, _| crate::unsaved::confirm(app), + ok: Some("Save"), + discard: Some("Don't Save"), + // Long document names wrap instead of widening the dialog. + max_width: Some(420.0), + ..DialogSpec::FORM +}; diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs new file mode 100644 index 00000000..c1ca0c7e --- /dev/null +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -0,0 +1,42 @@ +//! Shape size dialogs (a click with a shape tool): Rectangle, Rounded Rectangle, Ellipse, Polygon, +//! Star and Line Segment Tool Options. + +use serde_json::{Value, json}; + +use super::{DialogSpec, run_and_close}; +use crate::VectorcraftApp; +use crate::state::Dialog; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), confirm, ..DialogSpec::FORM }; + +fn title(kind: &str) -> &'static str { + match kind { + "rectangle" => "Rectangle", + "roundedRectangle" => "Rounded Rectangle", + "ellipse" => "Ellipse", + "polygon" => "Polygon", + "star" => "Star", + _ => "Line Segment Tool Options", + } +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0)); + let (id, params) = match d.kind.as_str() { + "ellipse" => ("shape.ellipse", json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0)})), + "polygon" => ("shape.polygon", json!({"cx": x, "cy": y, "radius": d.f64("radius", 50.0), "sides": d.f64("sides", 6.0) as u64})), + "star" => ( + "shape.star", + json!({"cx": x, "cy": y, "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64}), + ), + "lineSegment" => { + let (l, a) = (d.f64("length", 100.0), d.f64("angle", 0.0).to_radians()); + ("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()})) + } + _ => ( + "shape.rectangle", + json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)}), + ), + }; + run_and_close(app, id, params) +} diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs new file mode 100644 index 00000000..5c42eb2a --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -0,0 +1,165 @@ +//! The dialog registry and the shared frame, drawn headlessly. + +use serde_json::json; +use vectorcraft_engine::Session; + +use super::*; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app +} + +/// One headless frame of the dialog layer. +fn frame(app: &mut VectorcraftApp, input: egui::RawInput) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(input, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn enter() -> egui::RawInput { + let key = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + egui::RawInput { events: vec![key], ..Default::default() } +} + +fn open(app: &mut VectorcraftApp, kind: &str, fields: serde_json::Value) { + app.ui.dialog = Some(Dialog::new(kind, fields)); +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() +} + +#[test] +fn kinds_map_to_their_dialogs() { + for (kind, want) in [ + ("newDocument", DialogKind::NewDocument), + ("roundedRectangle", DialogKind::Shape), + ("lineSegment", DialogKind::Shape), + ("shear", DialogKind::Transform), + ("splitIntoGrid", DialogKind::PathOp), + ("artboardOptions", DialogKind::ArtboardOptions), + ("command", DialogKind::Command), + ("effect", DialogKind::Effect), + (crate::unsaved::KIND, DialogKind::SaveChanges), + ("manageWorkspaces", DialogKind::Workspaces), + ("findFont", DialogKind::FindFont), + ] { + assert_eq!(DialogKind::of(kind), Some(want), "{kind}"); + } + assert_eq!(DialogKind::of("nope"), None); + assert!(spec("allTools").ok.is_none()); + assert!(spec("effect").preview && spec("recolor").preview && !spec("move").preview); + assert!(spec("preferences").window.is_some() && spec("rectangle").window.is_none()); +} + +#[test] +fn every_dialog_draws() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let openers: [(&str, serde_json::Value); 8] = [ + ("file.newDialog", json!({})), + ("file.documentSetup", json!({})), + ("file.exportForScreens", json!({})), + ("ui.recolorDialog", json!({})), + ("ui.paramDialog", json!({"command": "object.path.simplify", "label": "Simplify", "params": {"tolerance": 2, "csv": "a\nb"}})), + ("effect.dialog", json!({"effect": "distort.roughen"})), + ("edit.preferences", json!({})), + ("type.findFont", json!({})), + ]; + for (id, p) in openers { + app.run(id, p).unwrap(); + assert!(app.ui.dialog.is_some(), "{id} opened nothing"); + frame(&mut app, Default::default()); + app.ui.dialog = None; + let _ = app.session.cancel_interaction(); + } + for kind in + ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "rotate", "reflect", "scale", "shear", "artboardOptions"] + { + open_tool_dialog(&mut app, kind, json!({"x": 5, "y": 5, "index": 0})); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == kind), "{kind}"); + frame(&mut app, Default::default()); + } + for (kind, fields) in [ + ("move", json!({"dx": "0 pt", "dy": "0 pt"})), + ("average", json!({"axis": "both"})), + ("allTools", json!({})), + (crate::unsaved::KIND, json!({"index": 0, "name": "Untitled-1", "then": "close"})), + ("newWorkspace", json!({"name": "Mine"})), + ("shortcuts", json!({})), + ("unregistered", json!({"value": "1"})), + ] { + open(&mut app, kind, fields); + frame(&mut app, Default::default()); + } +} + +#[test] +fn enter_confirms_through_the_dialog_spec() { + let mut app = app(); + open_tool_dialog(&mut app, "rectangle", json!({"x": 10, "y": 20})); + frame(&mut app, enter()); + assert!(app.ui.dialog.is_none()); + assert_eq!(objects(&app), 1); + // All Tools has no OK: Enter leaves it open. + open(&mut app, "allTools", json!({})); + frame(&mut app, enter()); + assert!(app.ui.dialog.is_some()); + // An unregistered kind still closes on OK without running anything. + open(&mut app, "unregistered", json!({})); + assert_eq!(confirm(&mut app), Ok(serde_json::Value::Null)); + assert!(app.ui.dialog.is_none()); + assert_eq!(confirm(&mut app), Err("no dialog open".into())); +} + +#[test] +fn confirm_runs_each_dialogs_command() { + let mut app = app(); + open(&mut app, "star", json!({"x": 50, "y": 50, "radius1": "40 pt", "radius2": "20 pt", "points": 6})); + confirm(&mut app).unwrap(); + open(&mut app, "lineSegment", json!({"x": 0, "y": 0, "length": "30 pt", "angle": 90})); + confirm(&mut app).unwrap(); + assert_eq!(objects(&app), 2); + app.run("select.all", json!({})).unwrap(); + // Scale with Uniform uses the horizontal value for both axes; Copy keeps the original. + open(&mut app, "scale", json!({"sx": 200, "sy": 50, "uniform": true, "copy": true})); + confirm(&mut app).unwrap(); + assert_eq!(objects(&app), 4); + // The parameter dialog closes before its command runs, then runs it with the edited values. + app.run("ui.paramDialog", json!({"command": "view.saved.new", "label": "New View", "params": {"name": "Close-up"}})).unwrap(); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.views.last().map(|v| v.name.as_str()), Some("Close-up")); + // New Document creates the document and closes. + let docs = app.session.documents().len(); + open( + &mut app, + "newDocument", + json!({"width": "300 pt", "height": "200 pt", "units": "Points", "name": "Card", "artboards": 1, "colorMode": "RGB"}), + ); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.documents().len(), docs + 1); +} + +#[test] +fn effect_preview_is_kept_as_one_undo_step() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); + frame(&mut app, Default::default()); + assert!(app.session.in_interaction(), "the preview runs as an interaction"); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert_eq!(app.last_effect.as_ref().map(|e| e.0.as_str()), Some("distort.roughen")); + assert_ne!(app.session.doc().unwrap().doc, before); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); +} diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs new file mode 100644 index 00000000..0016f60a --- /dev/null +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -0,0 +1,53 @@ +//! Dialogs a tool click opens: shape sizes, transform values, graph size and data, flare options +//! and artboard options. + +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::state::Dialog; + +/// A click with a shape tool opens its size dialog. +pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { + let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0); + let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0); + let d = match kind { + "rectangle" | "ellipse" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt"})), + "roundedRectangle" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "radius": "12 pt"})), + "polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})), + "star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})), + "lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})), + // Graph tool click: the graph's size. + "graph" => Dialog::new( + "command", + json!({"__command": "graph.create", "__label": "Graph", "type": p.get("type").cloned().unwrap_or(json!("column")), "x": x, "y": y, "width": 200, "height": 150}), + ), + // After drawing a graph: Graph Data (CSV: header row of series, then category, values…). + "graphData" => match app.session.execute("graph.setData", &json!({})) { + Ok(v) => Dialog::new("command", json!({"__command": "graph.setData", "__label": "Graph Data", "csv": v["csv"]})), + Err(_) => return, + }, + // Flare Tool Options (Center / Halo / Rays / Rings), applied through the generic command dialog. + "flare" => Dialog::new( + "command", + json!({"__command": "shape.flare", "__label": "Flare Tool Options", "cx": x, "cy": y, "diameter": 100, "opacity": 50, "brightness": 30, + "growth": 20, "fuzziness": 50, "rays": 15, "longest": 300, "rayFuzziness": 100, "pathLength": 300, "rings": 10, "largest": 50, "direction": 45}), + ), + "rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => { + let mut base = match kind { + "rotate" => json!({"angle": 0}), + "reflect" => json!({"axis": "vertical"}), + "scale" => json!({"sx": 100, "sy": 100, "uniform": true}), + "shear" => json!({"angle": 0, "axis": "horizontal"}), + _ => json!({}), + }; + if let (Some(b), Some(o)) = (base.as_object_mut(), p.as_object()) { + for (k, v) in o { + b.insert(k.clone(), v.clone()); + } + } + Dialog::new(kind, base) + } + _ => return, + }; + app.ui.dialog = Some(d); +} diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs new file mode 100644 index 00000000..40986887 --- /dev/null +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -0,0 +1,53 @@ +//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Copy, and Uniform for Scale). + +use serde_json::{Value, json}; + +use super::{DialogSpec, form, run_and_close}; +use crate::VectorcraftApp; +use crate::state::Dialog; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, ..DialogSpec::FORM }; + +fn title(kind: &str) -> &'static str { + match kind { + "move" => "Move", + "rotate" => "Rotate", + "scale" => "Scale", + "reflect" => "Reflect", + _ => "Shear", + } +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + form::grid(ui, d); + ui.add_space(6.0); + if d.kind == "scale" { + form::check(ui, d, "uniform", "Uniform"); + } + form::check(ui, d, "copy", "Copy (make a transformed copy)"); + false +} + +/// Pass a tool-chosen reference point (origin) through to transform commands. +fn origin_params(d: &Dialog, mut p: Value) -> Value { + if let Some(o) = d.fields.get("origin") { + p["origin"] = o.clone(); + } + p +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let copy = d.bool("copy"); + let (id, params) = match d.kind.as_str() { + "move" => return run_and_close(app, "object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})), + "rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy})), + "scale" => { + let sx = d.f64("sx", 100.0); + let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) }; + ("object.scale", json!({"sx": sx, "sy": sy, "copy": copy})) + } + "reflect" => ("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy})), + _ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy})), + }; + run_and_close(app, id, origin_params(d, params)) +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 91ed761d..8dde2d9f 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -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 { - 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) -> 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" 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, ext: &str) -> Result { + 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, save_as: bool) -> Result { 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,33 +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, scale: f64) -> Result { - 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")?; - vectorcraft_render::raster_size(r, scale)?; - 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, params: &Value) -> Result { + 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) @@ -219,19 +103,12 @@ pub fn export(app: &mut VectorcraftApp, format: &str, path: Option, 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 { - 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 { + 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)); } } @@ -244,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 = 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. @@ -255,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); @@ -277,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)>>>; + + /// 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 { + 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"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6495d65d..08a13ff2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,9 @@ pub mod unsaved; pub mod widgets; pub mod workspaces; +#[cfg(test)] +mod tests_labels; + use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e1fc19d7..7c61cf88 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -20,7 +20,7 @@ pub enum Item { Todo(&'static str, &'static str), Sub(&'static str, Vec), Sep, - /// Section header (disabled label, e.g. "Illustrator Effects"). + /// Section header (disabled label, e.g. "Vector Effects"). Header(&'static str), } @@ -39,6 +39,10 @@ fn todos(label: &'static str, sc: &'static str) -> Item { fn sub(label: &'static str, items: Vec) -> Item { Item::Sub(label, items) } +/// Window → … Libraries until the code-generated libraries land (disabled entries). +fn library_placeholders() -> Vec { + vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")] +} use Item::Sep; /// UI-level commands: (id, label, shortcut, params doc). @@ -83,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)"), @@ -94,7 +98,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("shortcuts.list", "List Keyboard Shortcuts", "", "{query?} → [{id, label, group, shortcut, default, overridden}]"), ("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "", "{} → [{shortcut, ids}]"), ("shortcuts.reset", "Reset Keyboard Shortcuts", "", "{}"), - ("shortcuts.preset", "Keyboard Shortcut Set", "", "{name: \"VectorCraft Defaults\" | \"Illustrator Defaults\"}"), + ( + "shortcuts.preset", + "Keyboard Shortcut Set", + "", + "{name: \"VectorCraft Defaults\" | \"Classic Defaults\"} (names of earlier versions are accepted)", + ), ("shortcuts.export", "Export Keyboard Shortcuts…", "", "{path?}"), ("shortcuts.import", "Import Keyboard Shortcuts…", "", "{path? | data?}"), ("view.outline", "Outline", "Cmd+Y", "{} toggle Outline/Preview"), @@ -147,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", "{}"), @@ -231,8 +240,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option 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( @@ -241,9 +250,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - 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}))); @@ -457,7 +465,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option 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) @@ -746,7 +754,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { Sep, todo("Transparency Flattener Presets…"), todo("Print Presets…"), - todo("Adobe PDF Presets…"), + todo("PDF Presets…"), cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})), Sep, c("Color Settings…", "edit.colorSettings"), @@ -1218,10 +1226,10 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ), todo("Variables"), Sep, - sub("Brush Libraries", vec![todo("Arrows"), todo("Artistic"), todo("Borders"), todo("Decorative")]), - sub("Graphic Style Libraries", vec![todo("Additive"), todo("Artistic Effects"), todo("Buttons and Rollovers")]), - sub("Swatch Libraries", vec![todo("Art History"), todo("Celebration"), todo("Color Properties"), todo("Nature"), todo("Web")]), - sub("Symbol Libraries", vec![todo("Arrows"), todo("Charts"), todo("Web Buttons and Bars")]), + sub("Brush Libraries", library_placeholders()), + sub("Graphic Style Libraries", library_placeholders()), + sub("Swatch Libraries", library_placeholders()), + sub("Symbol Libraries", library_placeholders()), ], ), ( @@ -1576,7 +1584,7 @@ fn font_items() -> Vec { .collect() } -/// The Effect menu, built from the effects catalogue (Illustrator Effects), plus raster effects. +/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects. fn effect_menu() -> Vec { let cat = vectorcraft_effects::effect_catalog(); let mut out = vec![ @@ -1585,7 +1593,7 @@ fn effect_menu() -> Vec { Sep, c("Document Raster Effects Settings…", "document.rasterEffectsSettings"), Sep, - Item::Header("Illustrator Effects"), + Item::Header("Vector Effects"), ]; // Submenus in Illustrator's order. let order = ["3D and Materials", "Convert to Shape", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"]; diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index adc7973a..a789e066 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -157,9 +157,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { widgets::dim_label(ui, "Stroke:"); for (icon, tip) in [ - ("dc-stroke-center", "Apply gradient within stroke"), - ("dc-stroke-outside", "Apply gradient along stroke (on the roadmap)"), - ("dc-stroke-inside", "Apply gradient across stroke (on the roadmap)"), + ("dc-stroke-center", "Gradient inside stroke"), + ("dc-stroke-outside", "Gradient along stroke (on the roadmap)"), + ("dc-stroke-inside", "Gradient across stroke (on the roadmap)"), ] { widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0); } diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index a0c932a5..90b456c5 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -14,8 +14,7 @@ pub const WEIGHT_PRESETS: [f64; 22] = [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; /// Width profile presets: (id for `stroke.set`, label). -pub const PROFILES: [(&str, &str); 4] = - [("uniform", "Uniform"), ("lens", "Width Profile 1"), ("taperStart", "Width Profile 4"), ("taperEnd", "Width Profile 5")]; +pub const PROFILES: [(&str, &str); 4] = [("uniform", "Uniform"), ("lens", "Lens"), ("taperStart", "Taper Start"), ("taperEnd", "Taper End")]; /// Which preset a stroke's profile is (None profile = uniform). pub fn profile_id(p: Option<&WidthProfile>) -> &'static str { @@ -165,19 +164,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.add_space((ui.available_width() - 56.0).max(0.0)); let on = dash.is_some(); - if widgets::icon_button_enabled(ui, "dc-dash-exact", "Preserves exact dash and gap lengths", on && !align_corners, on, 24.0).clicked() { + if widgets::icon_button_enabled(ui, "dc-dash-exact", "Exact dash lengths", on && !align_corners, on, 24.0).clicked() { set(app, json!({"dash": dash_pattern(&fields), "alignDashes": false})); } - if widgets::icon_button_enabled( - ui, - "dc-dash-align", - "Aligns dashes to corners and path ends, adjusting lengths to fit", - on && align_corners, - on, - 24.0, - ) - .clicked() - { + if widgets::icon_button_enabled(ui, "dc-dash-align", "Fit dashes to corners and ends", on && align_corners, on, 24.0).clicked() { set(app, json!({"dash": dash_pattern(&fields), "alignDashes": true})); } }); @@ -242,8 +232,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); ui.horizontal(|ui| { row_label(ui, "Align:"); - widgets::icon_button_enabled(ui, "dc-cap-square", "Extend arrow tip beyond end of path (on the roadmap)", true, false, 22.0); - widgets::icon_button_enabled(ui, "dc-cap-butt", "Place arrow tip at end of path (on the roadmap)", false, false, 22.0); + widgets::icon_button_enabled(ui, "dc-cap-square", "Tip extends past the end (on the roadmap)", true, false, 22.0); + widgets::icon_button_enabled(ui, "dc-cap-butt", "Tip on the end point (on the roadmap)", false, false, 22.0); }); widgets::divider(ui); // Profile. diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index ab9165de..2320f8f7 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -431,7 +431,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { menu_item(ui, "Spot Colors…", false, false); ui.separator(); menu_item(ui, "Open Swatch Library", false, false); - menu_item(ui, "Export Swatches Library as ASE…", false, false); + menu_item(ui, "Save Swatch Library…", false, false); } #[cfg(test)] diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 6f9381ca..e145c7de 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -17,8 +17,20 @@ use crate::state::{Dialog, UiState}; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, menus, widgets}; -pub const PRESETS: &[&str] = &["VectorCraft Defaults", "Illustrator Defaults"]; +pub const PRESETS: &[&str] = &["VectorCraft Defaults", "Classic Defaults"]; pub const CUSTOM: &str = "Custom"; +/// Set names earlier versions saved: (old name, current name). +pub const LEGACY_PRESETS: &[(&str, &str)] = &[("Illustrator Defaults", "Classic Defaults")]; // brand-ok: legacy preference value + +/// The current name of a shortcut set: names earlier versions saved map to today's. +pub fn set_name(name: &str) -> &str { + LEGACY_PRESETS.iter().find(|(old, _)| *old == name).map_or(name, |(_, new)| new) +} + +/// Serde reader for a saved set name (UI preferences written by earlier versions). +pub fn deserialize_set_name<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + ::deserialize(d).map(|s| set_name(&s).to_string()) +} /// Bumped whenever overrides or the workspace list change (the native menu rebuilds on it). pub static GENERATION: AtomicU64 = AtomicU64::new(0); @@ -281,7 +293,7 @@ pub fn reset_one(overrides: &mut BTreeMap, key: &str) { /// Overrides of a preset (both presets are the registry defaults for now). pub fn preset(name: &str) -> Option> { - PRESETS.contains(&name).then(BTreeMap::new) + PRESETS.contains(&set_name(name)).then(BTreeMap::new) } /// Export format: `{"format": "vectorcraft-shortcuts", "set": name, "overrides": {...}}`. @@ -299,7 +311,7 @@ pub fn import_json(v: &Value) -> Result<(String, BTreeMap), Stri } out.insert(k.clone(), normalize(s).unwrap_or_default()); } - Ok((v.get("set").and_then(Value::as_str).unwrap_or(CUSTOM).to_string(), out)) + Ok((v.get("set").and_then(Value::as_str).map_or(CUSTOM, set_name).to_string(), out)) } // ---------- UI commands ---------- @@ -370,7 +382,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option match s("name").as_deref().and_then(|n| preset(n).map(|p| (n.to_string(), p))) { + "shortcuts.preset" => match s("name").as_deref().map(set_name).and_then(|n| preset(n).map(|p| (n.to_string(), p))) { Some((n, p)) => { app.ui.shortcut_overrides = p; app.ui.shortcut_set = n; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 72c26630..5cdbbcdd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -229,7 +229,7 @@ pub struct UiState { #[serde(default)] pub shortcut_overrides: std::collections::BTreeMap, /// Name of the shortcut set (preset or "Custom"). - #[serde(default = "default_shortcut_set")] + #[serde(default = "default_shortcut_set", deserialize_with = "crate::shortcut_editor::deserialize_set_name")] pub shortcut_set: String, /// Current workspace (Window → Workspace). #[serde(default = "default_workspace")] diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs new file mode 100644 index 00000000..f17a0d6a --- /dev/null +++ b/crates/ui-egui/src/tests_labels.rs @@ -0,0 +1,107 @@ +//! Neutral wording in menus and panels: what users read names VectorCraft's own features. + +use egui::epaint::Shape; +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::VectorcraftApp; +use crate::menus::{self, Item}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app +} + +/// Every string painted by `f` in one headless frame, one per line. +fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> String { + fn collect(s: &Shape, out: &mut String) { + match s { + Shape::Text(t) => { + out.push_str(t.galley.text()); + out.push('\n'); + } + Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)); + out.textures_delta.clear(); + let mut text = String::new(); + for c in &out.shapes { + collect(&c.shape, &mut text); + } + text +} + +#[test] +fn effect_menu_has_a_vector_effects_section() { + let tree = menus::menu_tree(); + let (_, effect) = tree.iter().find(|(t, _)| *t == "Effect").expect("Effect menu"); + assert!(effect.iter().any(|i| matches!(i, Item::Header("Vector Effects")))); +} + +#[test] +fn library_submenus_are_disabled_placeholders() { + let app = app(); + let entries = menus::menu_entries(&app); + for lib in ["Brush Libraries", "Graphic Style Libraries", "Swatch Libraries", "Symbol Libraries"] { + let items: Vec<_> = entries.iter().filter(|e| e.path == ["Window", lib]).collect(); + let labels: Vec<&str> = items.iter().map(|e| e.label.as_str()).collect(); + assert_eq!(labels, ["Built-in Libraries", "User Defined", "Other Library…"], "{lib}"); + assert!(items.iter().all(|e| !e.enabled && e.command.is_none()), "{lib}"); + } +} + +#[test] +fn stroke_profiles_have_descriptive_names() { + let labels: Vec<&str> = crate::panels::stroke::PROFILES.iter().map(|p| p.1).collect(); + assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End"]); +} + +#[test] +fn swatches_menu_offers_save_swatch_library() { + let mut app = app(); + let text = painted_text(&mut app, crate::panels::swatches::menu); + assert!(text.contains("Save Swatch Library…"), "{text}"); + assert!(!text.contains("ASE"), "{text}"); +} + +#[test] +fn edit_menu_lists_pdf_presets() { + let app = app(); + assert!(menus::menu_entries(&app).iter().any(|e| e.path == ["Edit"] && e.label == "PDF Presets…" && !e.enabled)); +} + +#[test] +fn clipboard_preferences_name_the_legacy_format_neutrally() { + let label = |key: &str| vectorcraft_engine::cmd::prefscmds::PREF_SPECS.iter().find(|p| p.key == key).unwrap().label; + assert_eq!(label("copyAicb"), "Legacy vector clipboard (no transparency)"); + assert_eq!(label("aicbMode"), "Legacy vector clipboard"); +} + +#[test] +fn shortcut_set_old_name_is_accepted() { + use crate::shortcut_editor::{PRESETS, import_json}; + let (old, _) = crate::shortcut_editor::LEGACY_PRESETS[0]; + assert_eq!(PRESETS, ["VectorCraft Defaults", "Classic Defaults"]); + // UI preferences saved by an earlier version load (and save again) under the current name. + let ui: crate::state::UiState = serde_json::from_value(json!({ "shortcut_set": old })).unwrap(); + assert_eq!(ui.shortcut_set, "Classic Defaults"); + let saved = serde_json::to_value(&ui).unwrap(); + assert_eq!(saved["shortcut_set"], "Classic Defaults"); + let back: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(back.shortcut_set, "Classic Defaults"); + let default: crate::state::UiState = serde_json::from_value(json!({})).unwrap(); + assert_eq!(default.shortcut_set, PRESETS[0]); + // The command and imported sets take the old name too. + let mut app = app(); + app.run("shortcuts.preset", json!({ "name": old })).unwrap(); + assert_eq!(app.ui.shortcut_set, "Classic Defaults"); + app.run("shortcuts.preset", json!({ "name": "VectorCraft Defaults" })).unwrap(); + assert_eq!(app.ui.shortcut_set, "VectorCraft Defaults"); + assert!(app.run("shortcuts.preset", json!({ "name": "Nope" })).is_err()); + let (set, _) = import_json(&json!({ "set": old, "overrides": {} })).unwrap(); + assert_eq!(set, "Classic Defaults"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a858ef14..9f7d6c78 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 | diff --git a/docs/development.md b/docs/development.md index d77f990f..7fe3f9a3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -23,3 +23,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop: - **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox. (The app's synchronous drop path is compiled out on wasm32.) - **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`. - Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter). + +## Vendor names gate + +`cargo xtask brands` (part of `cargo xtask ci`) fails when user-visible text names another vendor's products or company: string literals in Rust sources (command labels and params docs, menus, panels, MCP tool definitions), `Cargo.toml` descriptions and every text file under `docs/` and `packaging/`. Comments and test code are not checked. Say "the reference app" or name the feature itself. A line that must keep an old name, such as an alias that files or preferences from earlier versions still use, carries a `brand-ok` comment. diff --git a/docs/mcp.md b/docs/mcp.md index 305924c6..50b51256 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -57,14 +57,14 @@ 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. | | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | -| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types. Edit later with `graph.setData` / `graph.setType` via `run_command`. | +| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | Nine graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | | `undo` / `redo` | `{}` | | @@ -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). diff --git a/packaging/linux/ai.storyteller.vectorcraft.desktop b/packaging/linux/ai.storyteller.vectorcraft.desktop index 82999eb0..af345530 100644 --- a/packaging/linux/ai.storyteller.vectorcraft.desktop +++ b/packaging/linux/ai.storyteller.vectorcraft.desktop @@ -10,5 +10,5 @@ Terminal=false StartupNotify=true StartupWMClass=ai.storyteller.vectorcraft Categories=Graphics;2DGraphics;VectorGraphics; -Keywords=vector;illustration;svg;pdf;drawing;bezier;illustrator; +Keywords=vector;illustration;svg;pdf;drawing;bezier; MimeType=image/svg+xml;application/pdf; diff --git a/packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in b/packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in index ade490d3..a19681a9 100644 --- a/packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in @@ -12,7 +12,9 @@

- VectorCraft is a vector graphics editor. Draw and edit vector illustrations, SVG and PDF + VectorCraft is an open-source vector illustration app. Draw with pen, shape and brush tools, + style art with fills, strokes, gradients and live effects, lay out artboards and type, and + open and save SVG and PDF.

ai.storyteller.vectorcraft.desktop @@ -24,19 +26,21 @@ https://discord.gg/artcraft Graphics - RasterGraphics - Photography + 2DGraphics + VectorGraphics - photo - image editor - psd - layers + vector + illustration + svg + pdf + drawing + bezier - Editing The Great Wave off Kanagawa with type layers and adjustment layers - https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/images/vectorcraft-demo.jpg + Editing the Neon Drive poster with a live glow effect in the Appearance panel + https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/images/shot-1-neon.png diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index 572ed6fa..5661d843 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -17,9 +17,9 @@ vendor: Learning Machines LLC homepage: https://getartcraft.com/apps/vectorcraft license: MIT OR Apache-2.0 description: |- - Open-source, native image editor that works the way Photoshop users expect. - Layers, masks, adjustment layers, layer styles, type and brushes; opens and - saves layered PSD/PSB files; 8/16/32-bit documents; GPU compositing (wgpu). + Open-source, native vector illustration app. + Pen, shape and brush tools, artboards, type, gradients, symbols and live + effects; opens and saves SVG and PDF. Includes vectorcraft-cli, a headless converter and command runner. # The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 7df0f93b..c6eda447 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -45,7 +45,7 @@ NSHumanReadableCopyright Copyright © the VectorCraft authors. MIT OR Apache-2.0. - + UTExportedTypeDeclarations @@ -63,48 +63,11 @@ UTTypeTagSpecification public.filename-extension - pcraft + vectorcraftdrawcraft public.mime-type application/x-vectorcraft - - UTImportedTypeDeclarations - - - UTTypeIdentifier - com.adobe.photoshop-large-image - UTTypeDescription - Photoshop Large Document - UTTypeConformsTo - - public.data - public.image - - UTTypeTagSpecification - - public.filename-extension - psb - - - - UTTypeIdentifier - ai.storyteller.vectorcraft.qoi - UTTypeDescription - QOI Image - UTTypeConformsTo - - public.data - public.image - - UTTypeTagSpecification - - public.filename-extension - qoi - - - - diff --git a/packaging/web/README.md b/packaging/web/README.md index b39ec0e9..bfb81ca3 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -55,7 +55,7 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo ```html