Merge M3/M4 wave 0: neutral labels and brands gate, per-area paint modules, one engine loader/encoder (M3.7, M3.8, M4.10-M4.13)
# Conflicts: # crates/engine/src/cmd/file.rs # crates/mcp/src/headless.rs # crates/mcp/src/tools.rs # crates/ui-egui/src/io.rs
This commit is contained in:
commit
0af6734b70
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 `<mask>` 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 `<mask>` 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 |
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli run [--in file.vectorcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0] [--outline-text]
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
|
|
@ -28,21 +28,23 @@ USAGE:
|
|||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (.vectorcraft/.svg) or start a new document, run commands in
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I] [--outline-text]
|
||||
Open IN (.vectorcraft, .svg, .pdf/.ai, images) and export OUT by extension (.svg, .pdf, .png,
|
||||
.jpg, .webp, .vectorcraft). Live effects are kept; --outline-text writes SVG text as paths.
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
|
||||
Open IN (any readable format) and export OUT by extension (.svg, .pdf, .png, .jpg, .webp,
|
||||
.vectorcraft). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, the other formats the first. Live effects are kept;
|
||||
--outline-text writes SVG text as paths.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts.
|
||||
|
||||
vectorcraft-cli bench FILE [--size WxH] [--iters N]
|
||||
Render FILE (.vectorcraft/.svg) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
vectorcraft-cli perf [--paths N]
|
||||
|
|
@ -50,6 +52,11 @@ USAGE:
|
|||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads.
|
||||
fn usage() -> String {
|
||||
format!("{USAGE}\nReadable formats: .{}\n", vectorcraft_engine::cmd::fileio::OPEN_EXTS.join(", ."))
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
|
|
@ -61,14 +68,14 @@ fn main() -> ExitCode {
|
|||
Some("bench") => bench(&args[1..]),
|
||||
Some("perf") => perf::run(&args[1..]),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
print!("{}", usage());
|
||||
Ok(())
|
||||
}
|
||||
Some("-V" | "--version") => {
|
||||
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")),
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
|
|
@ -119,12 +126,13 @@ fn commands() -> Result<(), String> {
|
|||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let mut files = vec![];
|
||||
let (mut scale, mut artboard, mut outline_text) = (1.0f64, 0u64, false);
|
||||
let (mut scale, mut artboard, mut range, mut outline_text) = (1.0f64, None::<u64>, None::<String>, false);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--scale" | "-s" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
|
||||
"--artboard" | "-a" => artboard = it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?,
|
||||
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
|
||||
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
|
||||
"--outline-text" => outline_text = true,
|
||||
f => files.push(f.to_string()),
|
||||
}
|
||||
|
|
@ -135,7 +143,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
|||
let r = h
|
||||
.call(
|
||||
"engine.execute",
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "outlineText": outline_text}}),
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
|
||||
)
|
||||
.map_err(|e| format!("export {output}: {e}"))?;
|
||||
println!("{r}");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
//! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn ok(args: &[&str]) -> String {
|
||||
let out = Command::new(BIN).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
}
|
||||
|
||||
/// `run` a new `width`×`height` document with a rectangle and export it to `out`.
|
||||
fn make(out: &str, width: u32, height: u32) {
|
||||
ok(&[
|
||||
"run",
|
||||
"--cmd",
|
||||
"file.new",
|
||||
"--params",
|
||||
&format!(r#"{{"width":{width},"height":{height},"artboards":2}}"#),
|
||||
"--cmd",
|
||||
"shape.rectangle",
|
||||
"--params",
|
||||
r#"{"x":1,"y":1,"width":2,"height":1}"#,
|
||||
"--export",
|
||||
out,
|
||||
]);
|
||||
}
|
||||
|
||||
fn info(file: &str) -> Value {
|
||||
serde_json::from_str(&ok(&["info", file])).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_pdf_to_svg_and_info_on_ai() {
|
||||
let pdf = tmp("in.pdf");
|
||||
make(&pdf, 120, 80);
|
||||
let svg = tmp("out.svg");
|
||||
ok(&["convert", &pdf, &svg]);
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
|
||||
|
||||
let ai = tmp("in.ai");
|
||||
std::fs::copy(&pdf, &ai).unwrap();
|
||||
let v = info(&ai);
|
||||
assert_eq!(v["artboards"].as_array().unwrap().len(), 2, "{v}");
|
||||
assert_eq!(v["artboards"][0]["rect"][2], 120.0);
|
||||
// `run --in` takes it too.
|
||||
ok(&["run", "--in", &ai, "--cmd", "document.inspect"]);
|
||||
// A PDF keeps every artboard unless --artboard / --range names some.
|
||||
let all = tmp("all.pdf");
|
||||
ok(&["convert", &ai, &all]);
|
||||
assert_eq!(info(&all)["artboards"].as_array().unwrap().len(), 2);
|
||||
let second = tmp("second.pdf");
|
||||
ok(&["convert", &ai, &second, "--range", "2"]);
|
||||
assert_eq!(info(&second)["artboards"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_open_at_their_pixel_size() {
|
||||
let webp = tmp("tiny.webp");
|
||||
make(&webp, 3, 2);
|
||||
let v = info(&webp);
|
||||
assert_eq!(v["artboards"][0]["rect"], serde_json::json!([0.0, 0.0, 3.0, 2.0]), "{v}");
|
||||
assert_eq!(v["kinds"]["Image"], 1, "{v}");
|
||||
let png = tmp("tiny.png");
|
||||
ok(&["convert", &webp, &png, "--scale", "2"]);
|
||||
assert_eq!(&std::fs::read(&png).unwrap()[16..24], [0, 0, 0, 6, 0, 0, 0, 4], "IHDR 6×4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_readable_formats() {
|
||||
let out = ok(&["--help"]);
|
||||
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
|
||||
}
|
||||
|
|
@ -3,12 +3,12 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
const OPEN_EXTS: &[&str] = &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"];
|
||||
const CANVAS_ID: &str = "vectorcraft_canvas";
|
||||
const LOADING_ID: &str = "vectorcraft_loading";
|
||||
|
||||
|
|
@ -99,7 +99,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
|||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All supported", OPEN_EXTS).pick_file().await else {
|
||||
let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| d.add_filter(name, exts));
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
|
|
@ -125,7 +126,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
|||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(mime_for(&name));
|
||||
opts.set_type(fileio::format_for_name(&name).map_or("application/octet-stream", |f| f.mime));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
|
|
@ -143,14 +144,3 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
|||
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("vectorcraft" | "drawcraft") => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ mod control_server;
|
|||
mod native_menu;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
|
||||
struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
|
@ -98,8 +99,8 @@ fn save_prefs(app: &VectorcraftApp) {
|
|||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("All supported", &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"])
|
||||
fileio::open_filters()
|
||||
.fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(name, exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ impl IccProfile {
|
|||
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
|
||||
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -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<Arc<IccProfile>> {
|
|||
}
|
||||
|
||||
fn cmyk_space(name: &str) -> Result<CmykSpace, CmsError> {
|
||||
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<RgbSpace, CmsError> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<ProfileInfo> {
|
||||
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<ProfileInfo> {
|
||||
let mut v: Vec<ProfileInfo> = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB]
|
||||
let mut v: Vec<ProfileInfo> = 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<String>) -> Result<ProfileInfo, C
|
|||
if p.kind == ProfileKind::Gray {
|
||||
return Err(CmsError::Unsupported("grayscale profiles can't be working spaces yet".into()));
|
||||
}
|
||||
if [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB, GENERIC_CMYK, DEVICE_CMYK].contains(&p.name.as_str()) {
|
||||
let builtin = canonical_name(&p.name);
|
||||
if BUILTIN_RGB.contains(&builtin) || BUILTIN_CMYK.contains(&builtin) {
|
||||
return Err(CmsError::Unsupported(format!("{} clashes with a built-in profile name", p.name)));
|
||||
}
|
||||
if !p.usable() {
|
||||
|
|
@ -257,8 +278,10 @@ impl Default for Cms {
|
|||
}
|
||||
|
||||
impl Cms {
|
||||
/// Profiles named by a legacy name are stored under their current name.
|
||||
pub fn new(settings: &ColorSettings) -> Result<Self, CmsError> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
|
@ -157,10 +157,10 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Value> {
|
|||
/// The document's assigned profiles (`None` = working space).
|
||||
pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option<String>, Option<String>) {
|
||||
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<Value> {
|
||||
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<String>, kind: cms::ProfileKind| -> Result<bool> {
|
||||
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<Value> {
|
|||
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}`")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"document.open",
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64} → {index}; .vectorcraft, .svg, .pdf, .ai (PDF-compatible)",
|
||||
always,
|
||||
open
|
||||
),
|
||||
cmd!("document.save", "Save Document", [], None, "{path?} native .vectorcraft (default: the document's path) → {path}", has_doc, save),
|
||||
cmd!(query "document.serialize", "Serialize Document", [], None, "{format: vectorcraft|svg|pdf|png} → {dataBase64 | text}", has_doc, serialize),
|
||||
cmd!(
|
||||
"document.export",
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{format: svg|png|pdf|jpg|webp|vectorcraft, path, scale?: 1, artboard?: 0, outlineText?: bool (SVG: text as outlines)}",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
cmd!(
|
||||
"document.exportSelection",
|
||||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1} the selected objects cropped to their bounds → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
has_selection,
|
||||
export_selection
|
||||
),
|
||||
cmd!(
|
||||
"file.saveAsTemplate",
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?} a native copy that opens as a new untitled document (no path → {dataBase64})",
|
||||
has_doc,
|
||||
save_template
|
||||
),
|
||||
cmd!(
|
||||
"document.exportForScreens",
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder, artboards?: [index…] (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1, suffix?: \"@2x\"}], prefix?} → {files: [...]}",
|
||||
has_doc,
|
||||
export_for_screens
|
||||
),
|
||||
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch),
|
||||
]
|
||||
}
|
||||
|
||||
fn load(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if vectorcraft_format::is_native_name(&lower) || vectorcraft_format::sniff(bytes) {
|
||||
return vectorcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string()));
|
||||
}
|
||||
if lower.ends_with(".svg") || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
|
||||
let s = std::str::from_utf8(bytes).map_err(|_| EngineError::Other("SVG is not UTF-8".into()))?;
|
||||
let mut d = vectorcraft_svg::import(s).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
return Ok(d);
|
||||
}
|
||||
if lower.ends_with(".pdf") || lower.ends_with(".ai") || bytes.starts_with(b"%PDF") {
|
||||
let r =
|
||||
vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut d = r.document;
|
||||
d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
return Ok(d);
|
||||
}
|
||||
Err(EngineError::Other(format!("unsupported file `{name}`")))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes, path) = match (str_param(p, "path"), str_param(p, "dataBase64")) {
|
||||
(Some(path), _) => (path.to_string(), std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?, Some(path.to_string())),
|
||||
(None, Some(b64)) => (
|
||||
str_param(p, "name").unwrap_or("untitled.vectorcraft").to_string(),
|
||||
vectorcraft_format::base64_decode(b64).ok_or_else(|| bad("document.open", "bad base64"))?,
|
||||
None,
|
||||
),
|
||||
_ => return Err(bad("document.open", "give path or dataBase64")),
|
||||
};
|
||||
let mut doc = load(&name, &bytes)?;
|
||||
let mut native = vectorcraft_format::is_native_name(&name);
|
||||
if doc.template {
|
||||
// A template opens as a new untitled document (saving asks for a new name).
|
||||
doc.template = false;
|
||||
doc.title = s.next_untitled();
|
||||
native = false;
|
||||
}
|
||||
let i = s.add_document(doc, if native { path } else { None });
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path")
|
||||
.map(str::to_string)
|
||||
.or_else(|| s.active().and_then(|d| d.path.clone()))
|
||||
.ok_or_else(|| bad("document.save", "no path"))?;
|
||||
let bytes = vectorcraft_format::save_file(&s.doc()?.doc);
|
||||
std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let st = s.doc_mut()?;
|
||||
st.path = Some(path.clone());
|
||||
st.mark_saved();
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
||||
pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Result<Vec<u8>> {
|
||||
encode_doc(&s.doc()?.doc, format, scale, artboard)
|
||||
}
|
||||
|
||||
fn encode_doc(doc: &vectorcraft_doc::Document, format: &str, scale: f64, artboard: usize) -> Result<Vec<u8>> {
|
||||
encode_doc_with(doc, format, scale, artboard, false)
|
||||
}
|
||||
|
||||
/// `outline_text`: SVG text as glyph outlines (Illustrator's SVG Options → Fonts → Convert to Outlines).
|
||||
fn encode_doc_with(doc: &vectorcraft_doc::Document, format: &str, scale: f64, artboard: usize, outline_text: bool) -> Result<Vec<u8>> {
|
||||
Ok(match format {
|
||||
"vectorcraft" => vectorcraft_format::save_file(doc),
|
||||
"svg" => vectorcraft_svg::export(doc, &vectorcraft_svg::ExportOptions { artboard: Some(artboard), outline_text, ..Default::default() })
|
||||
.into_bytes(),
|
||||
"pdf" => super::rasterfx::export_pdf(doc, &vectorcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?,
|
||||
"png" | "jpg" | "jpeg" | "webp" => {
|
||||
let r = doc.artboards.get(artboard).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let 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<Value> {
|
||||
let f = str_param(p, "format").unwrap_or("vectorcraft");
|
||||
let bytes = encode(s, f, f64_or(p, "scale", 1.0), p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize)?;
|
||||
if f == "svg" {
|
||||
return Ok(json!({ "text": String::from_utf8_lossy(&bytes) }));
|
||||
}
|
||||
Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) }))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("document.export", "missing path"))?;
|
||||
let f = str_param(p, "format")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| "png".into()));
|
||||
let artboard = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let bytes = encode_doc_with(&s.doc()?.doc, &f, f64_or(p, "scale", 1.0), artboard, bool_or(p, "outlineText", false))?;
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len() }))
|
||||
}
|
||||
|
||||
/// File → Export Selection: the selected objects alone, cropped to their visual bounds.
|
||||
fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportSelection";
|
||||
let path = str_param(p, "path");
|
||||
let f = str_param(p, "format")
|
||||
.map(str::to_string)
|
||||
.or_else(|| path.and_then(|p| std::path::Path::new(p).extension()).map(|e| e.to_string_lossy().to_ascii_lowercase()))
|
||||
.unwrap_or_else(|| "png".into());
|
||||
let ids = super::edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<std::sync::Arc<vectorcraft_doc::Node>> = ids.iter().filter_map(|id| st.doc.node(*id).cloned().map(std::sync::Arc::new)).collect();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?;
|
||||
let mut d = (*st.doc).clone();
|
||||
let mut layer = vectorcraft_doc::Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = nodes;
|
||||
}
|
||||
d.layers = vec![std::sync::Arc::new(layer)];
|
||||
let mut ab = d.artboards.first().cloned().ok_or_else(|| bad(C, "the document has no artboard"))?;
|
||||
ab.rect = bounds;
|
||||
ab.name = "Selection".into();
|
||||
d.artboards = vec![ab];
|
||||
let bytes = encode_doc_with(&d, &f, f64_or(p, "scale", 1.0), 0, bool_or(p, "outlineText", false))?;
|
||||
let b = [bounds.x0, bounds.y0, bounds.width(), bounds.height()];
|
||||
match path {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "bounds": b }))
|
||||
}
|
||||
None => Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes), "bounds": b })),
|
||||
}
|
||||
}
|
||||
|
||||
/// File → Save as Template: a native copy flagged so opening it starts a new untitled document.
|
||||
fn save_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut d = (*s.doc()?.doc).clone();
|
||||
d.template = true;
|
||||
let bytes = vectorcraft_format::save_file(&d);
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
None => Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) })),
|
||||
}
|
||||
}
|
||||
|
||||
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
|
||||
let label = str_param(p, "label").unwrap_or("Batch").to_string();
|
||||
s.begin_interaction(&label)?;
|
||||
// Session-level state a step may change (paint defaults, drawing mode) rolls back too.
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone());
|
||||
let mut results = vec![];
|
||||
for c in &cmds {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
if id == "command.batch" {
|
||||
s.cancel_interaction()?;
|
||||
return Err(bad("command.batch", "batches cannot nest"));
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
match s.execute(id, ¶ms) {
|
||||
Ok(v) => results.push(v),
|
||||
Err(e) => {
|
||||
s.cancel_interaction()?;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved;
|
||||
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Commit as one undo step (the interaction's "preview" is the batch itself).
|
||||
if let Some(it) = s.doc_mut()?.interaction.as_mut() {
|
||||
it.preview = Some(("command.batch".into(), p.clone()));
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
Ok(json!({ "results": results }))
|
||||
}
|
||||
|
||||
fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let folder = str_param(p, "folder").ok_or_else(|| bad("document.exportForScreens", "missing folder"))?.to_string();
|
||||
let n = s.doc()?.doc.artboards.len();
|
||||
let boards: Vec<usize> = match p.get("artboards").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_u64).map(|v| v as usize).filter(|i| *i < n).collect(),
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let formats: Vec<(String, f64, String)> = match p.get("formats").and_then(Value::as_array) {
|
||||
Some(a) => a
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let fmt = str_param(f, "format").unwrap_or("png").to_string();
|
||||
let sc = f64_or(f, "scale", 1.0);
|
||||
let suffix = str_param(f, "suffix")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| if (sc - 1.0).abs() < 1e-9 { String::new() } else { format!("@{sc}x") });
|
||||
(fmt, sc, suffix)
|
||||
})
|
||||
.collect(),
|
||||
None => vec![("png".into(), 1.0, String::new())],
|
||||
};
|
||||
let prefix = str_param(p, "prefix").unwrap_or("").to_string();
|
||||
std::fs::create_dir_all(&folder).map_err(|e| EngineError::Other(format!("{folder}: {e}")))?;
|
||||
let mut files = vec![];
|
||||
for b in boards {
|
||||
let name: String =
|
||||
s.doc()?.doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
for (fmt, sc, suffix) in &formats {
|
||||
let bytes = encode(s, fmt, *sc, b)?;
|
||||
let ext = if fmt == "jpeg" { "jpg" } else { fmt.as_str() };
|
||||
let path = format!("{folder}/{prefix}{name}{suffix}.{ext}");
|
||||
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Ok(json!({ "files": files }))
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
//! `command.batch`: several commands as one undo step.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
|
||||
let label = str_param(p, "label").unwrap_or("Batch").to_string();
|
||||
s.begin_interaction(&label)?;
|
||||
// Session-level state a step may change (paint defaults, drawing mode) rolls back too.
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone());
|
||||
let mut results = vec![];
|
||||
for c in &cmds {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
if id == "command.batch" {
|
||||
s.cancel_interaction()?;
|
||||
return Err(bad("command.batch", "batches cannot nest"));
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
match s.execute(id, ¶ms) {
|
||||
Ok(v) => results.push(v),
|
||||
Err(e) => {
|
||||
s.cancel_interaction()?;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved;
|
||||
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Commit as one undo step (the interaction's "preview" is the batch itself).
|
||||
if let Some(it) = s.doc_mut()?.interaction.as_mut() {
|
||||
it.preview = Some(("command.batch".into(), p.clone()));
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
Ok(json!({ "results": results }))
|
||||
}
|
||||
|
|
@ -0,0 +1,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<usize>,
|
||||
pub artboards: Option<Vec<usize>>,
|
||||
pub range: Option<String>,
|
||||
}
|
||||
|
||||
impl ArtboardPick {
|
||||
/// The named artboards (`None`: none named), checked against the document's `count`.
|
||||
pub fn resolve(&self, count: usize) -> std::result::Result<Option<Vec<usize>>, String> {
|
||||
let v = match (&self.range, &self.artboards, self.artboard) {
|
||||
(Some(r), _, _) => parse_range(r, count)?,
|
||||
(None, Some(a), _) => a.clone(),
|
||||
(None, None, Some(a)) => vec![a],
|
||||
(None, None, None) => return Ok(None),
|
||||
};
|
||||
if v.is_empty() {
|
||||
return Err("no artboards named".into());
|
||||
}
|
||||
match v.iter().find(|i| **i >= count) {
|
||||
Some(i) => Err(format!("no artboard {i} (0-based; the document has {count})")),
|
||||
None => Ok(Some(v)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one artboard a single-image format writes (default: the first).
|
||||
pub fn one(&self, count: usize) -> std::result::Result<usize, String> {
|
||||
match self.resolve(count)?.as_deref() {
|
||||
None if count > 0 => Ok(0),
|
||||
None => Err("the document has no artboard".into()),
|
||||
Some([i]) => Ok(*i),
|
||||
Some(_) => Err("this format holds one artboard: export several as PDF or with document.exportForScreens".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct SvgOptions {
|
||||
#[serde(flatten)]
|
||||
boards: ArtboardPick,
|
||||
/// Text as glyph outlines (SVG Options → Fonts → Convert to Outlines).
|
||||
outline_text: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct RasterOptions {
|
||||
#[serde(flatten)]
|
||||
boards: ArtboardPick,
|
||||
scale: Option<f64>,
|
||||
quality: Option<u8>,
|
||||
}
|
||||
|
||||
fn boards<T>(r: std::result::Result<T, String>) -> Result<T> {
|
||||
r.map_err(|e| bad(C, e))
|
||||
}
|
||||
|
||||
fn options<T: DeserializeOwned + Default>(f: &Format, p: &Value) -> Result<T> {
|
||||
if !p.is_object() {
|
||||
return Ok(T::default());
|
||||
}
|
||||
T::deserialize(p).map_err(|e| bad(C, format!("{} options: {e}", f.label)))
|
||||
}
|
||||
|
||||
/// Most pixels a raster export renders (1 GiB of RGBA).
|
||||
const MAX_PIXELS: f64 = 268_435_456.0;
|
||||
|
||||
/// Refuse a raster export the encoder can't write or memory can't hold (instead of writing an
|
||||
/// empty file or aborting).
|
||||
fn check_pixels(f: &Format, w: f64, h: f64) -> Result<()> {
|
||||
// WebP stores sizes in 14 bits, JPEG in 16.
|
||||
let side = if f.id == "webp" { 16383.0 } else { 65535.0 };
|
||||
if w.round() > side || h.round() > side || w * h > MAX_PIXELS {
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"{}×{} px is too large for {} (at most {side} px a side, {MAX_PIXELS} px in all): lower the scale",
|
||||
w.round(),
|
||||
h.round(),
|
||||
f.label
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's
|
||||
/// options from `p` (see `document.formats`). Raster formats leave template layers out.
|
||||
pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let n = doc.artboards.len();
|
||||
Ok(match f.id {
|
||||
"vectorcraft" => vectorcraft_format::save_file(doc),
|
||||
"svg" => {
|
||||
let o: SvgOptions = options(f, p)?;
|
||||
let artboard = if n == 0 { None } else { Some(boards(o.boards.one(n))?) };
|
||||
let opts = vectorcraft_svg::ExportOptions { artboard, outline_text: o.outline_text.unwrap_or(false), ..Default::default() };
|
||||
vectorcraft_svg::export(doc, &opts).into_bytes()
|
||||
}
|
||||
"pdf" => {
|
||||
let o: ArtboardPick = options(f, p)?;
|
||||
let opts = vectorcraft_pdf::PdfOptions { artboards: boards(o.resolve(n))?, ..Default::default() };
|
||||
super::super::rasterfx::export_pdf(doc, &opts).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
}
|
||||
"png" | "jpg" | "webp" => {
|
||||
let o: RasterOptions = options(f, p)?;
|
||||
let region = doc.artboards[boards(o.boards.one(n))?].rect;
|
||||
let scale = o.scale.unwrap_or(1.0).clamp(0.01, 64.0);
|
||||
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))),
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
//! `document.export`, `document.serialize`, `document.exportSelection`, `document.exportForScreens`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::super::*;
|
||||
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, create_dir, encode, writable, write_file, write_or_return};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||
let bytes = encode(&s.doc()?.doc, f.id, p)?;
|
||||
if f.id == "svg" {
|
||||
return Ok(json!({ "text": String::from_utf8_lossy(&bytes) }));
|
||||
}
|
||||
Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes) }))
|
||||
}
|
||||
|
||||
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path");
|
||||
let f = writable("document.export", str_param(p, "format"), path)?;
|
||||
let bytes = encode(&s.doc()?.doc, f.id, p)?;
|
||||
write_or_return(path, &bytes, json!({ "format": f.id }))
|
||||
}
|
||||
|
||||
/// `p` without its artboard choice (for documents made of one synthetic artboard).
|
||||
fn without_artboards(p: &Value) -> Value {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
for k in ARTBOARD_PARAMS {
|
||||
o.remove(k);
|
||||
}
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on
|
||||
/// template layers are guides, not artwork, and are left out.
|
||||
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportSelection";
|
||||
let path = str_param(p, "path");
|
||||
let f = writable(C, str_param(p, "format"), path)?;
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
// On a template layer or sublayer, at any depth.
|
||||
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
|
||||
let nodes: Vec<Arc<Node>> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(C, "select something to export"))?;
|
||||
let mut d = (*st.doc).clone();
|
||||
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = nodes;
|
||||
}
|
||||
d.layers = vec![Arc::new(layer)];
|
||||
let mut ab = d.artboards.first().cloned().ok_or_else(|| bad(C, "the document has no artboard"))?;
|
||||
ab.rect = bounds;
|
||||
ab.name = "Selection".into();
|
||||
d.artboards = vec![ab];
|
||||
let bytes = encode(&d, f.id, &without_artboards(p))?;
|
||||
write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
|
||||
}
|
||||
|
||||
/// One Export for Screens format row: what to write, at which scale, with which file-name suffix.
|
||||
struct ScreenFormat {
|
||||
format: &'static Format,
|
||||
/// The row's own options (scale, quality…) for the encoder.
|
||||
options: Value,
|
||||
suffix: String,
|
||||
}
|
||||
|
||||
fn screen_format(row: &Value) -> Result<ScreenFormat> {
|
||||
const C: &str = "document.exportForScreens";
|
||||
if !row.is_object() {
|
||||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if format.id == "vectorcraft" {
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg or pdf"));
|
||||
}
|
||||
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one
|
||||
// left over from a raster row switched to SVG or PDF).
|
||||
let scale = if format.raster { f64_or(row, "scale", 1.0) } else { 1.0 };
|
||||
let suffix = match str_param(row, "suffix") {
|
||||
Some(s) if !format.raster && is_scale_suffix(s) => String::new(),
|
||||
Some(s) => s.to_string(),
|
||||
None if (scale - 1.0).abs() < 1e-9 => String::new(),
|
||||
None => format!("@{scale}x"),
|
||||
};
|
||||
let mut options = without_artboards(row);
|
||||
if let Some(o) = options.as_object_mut() {
|
||||
o.insert("scale".into(), json!(scale));
|
||||
}
|
||||
Ok(ScreenFormat { format, options, suffix })
|
||||
}
|
||||
|
||||
/// A pixel-density suffix such as `@2x` or `@0.5x`.
|
||||
fn is_scale_suffix(s: &str) -> bool {
|
||||
s.strip_prefix('@').and_then(|s| s.strip_suffix(['x', 'X'])).is_some_and(|n| n.parse::<f64>().is_ok())
|
||||
}
|
||||
|
||||
/// File → Export for Screens: every chosen artboard in every format, one file each (a PDF holds
|
||||
/// its artboard alone). Artboards that share a name get `-2`, `-3`… instead of overwriting.
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportForScreens";
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let n = doc.artboards.len();
|
||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ArtboardPick::default() };
|
||||
let boards = pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
let formats: Vec<ScreenFormat> = match p.get("formats").and_then(Value::as_array) {
|
||||
Some(rows) => rows.iter().map(screen_format).collect::<Result<_>>()?,
|
||||
None => vec![screen_format(&json!({}))?],
|
||||
};
|
||||
let prefix = str_param(p, "prefix").unwrap_or("");
|
||||
let folder = str_param(p, "folder");
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
// Names are compared without case: `Icon` and `icon` are one file on most desktop file systems.
|
||||
let mut names = HashSet::new();
|
||||
let mut written = HashSet::new();
|
||||
let mut files = vec![];
|
||||
for b in boards {
|
||||
let mut base: String = doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() {
|
||||
base = format!("Artboard-{}", b + 1);
|
||||
}
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if names.insert(name.to_lowercase()) {
|
||||
break;
|
||||
}
|
||||
name = format!("{base}-{i}");
|
||||
}
|
||||
for sf in &formats {
|
||||
let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]);
|
||||
if !written.insert(file.to_lowercase()) {
|
||||
continue; // the same file from two identical rows
|
||||
}
|
||||
let mut options = sf.options.clone();
|
||||
options["artboard"] = json!(b);
|
||||
let bytes = encode(&doc, sf.format.id, &options)?;
|
||||
match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{file}");
|
||||
write_file(&path, &bytes)?;
|
||||
files.push(json!(path));
|
||||
}
|
||||
None => files.push(json!({ "name": file, "dataBase64": vectorcraft_format::base64_encode(&bytes) })),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "files": files }))
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
//! `document.open`: every readable format into a new document.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::{Cursor, Read as _};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use super::super::*;
|
||||
use super::{Format, format, format_for_name, read_file};
|
||||
use crate::EngineError;
|
||||
|
||||
/// A file read into a document, with its format and non-fatal import notes.
|
||||
pub struct Loaded {
|
||||
pub doc: Document,
|
||||
pub format: &'static Format,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
|
||||
/// (what browsers, PDF and SVG viewers show).
|
||||
pub struct RasterImage {
|
||||
/// Content key for `Document::images` (identical files share one blob).
|
||||
pub key: String,
|
||||
pub blob: ImageBlob,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> EngineError {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
|
||||
/// The last component of a path or file name.
|
||||
fn file_name(name: &str) -> String {
|
||||
std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string())
|
||||
}
|
||||
|
||||
/// The readable format of a file: from its content (magic bytes) when that tells, else from the
|
||||
/// extension of `name`.
|
||||
pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
let by_name = format_for_name(name).filter(|f| f.read);
|
||||
if vectorcraft_format::sniff(bytes) {
|
||||
return format("vectorcraft");
|
||||
}
|
||||
if bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
return format("svgz");
|
||||
}
|
||||
if bytes.starts_with(b"%PDF") {
|
||||
// .ai and .ait files are PDF inside; the extension keeps the template meaning.
|
||||
return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("pdf"));
|
||||
}
|
||||
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
|
||||
return Some(f);
|
||||
}
|
||||
if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) {
|
||||
return format("svg");
|
||||
}
|
||||
by_name
|
||||
}
|
||||
|
||||
/// The readable raster format behind an `image` crate format.
|
||||
fn image_format(f: image::ImageFormat) -> Option<&'static Format> {
|
||||
f.extensions_str().iter().find_map(|e| format(e)).filter(|f| f.read && f.raster)
|
||||
}
|
||||
|
||||
/// Read a file of any readable format (`name`: its file name or path, for the extension and title).
|
||||
pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
let title = file_name(name);
|
||||
let mut warnings = vec![];
|
||||
let mut doc = match format.id {
|
||||
"vectorcraft" => vectorcraft_format::load(bytes).map_err(err)?,
|
||||
"svg" | "svgz" => {
|
||||
let text: Cow<[u8]> = if format.id == "svgz" { Cow::Owned(gunzip(bytes)?) } else { Cow::Borrowed(bytes) };
|
||||
let text = std::str::from_utf8(&text).map_err(|_| err("SVG is not UTF-8"))?;
|
||||
let (d, w) = vectorcraft_svg::import_with_report(text).map_err(err)?;
|
||||
warnings = w;
|
||||
d
|
||||
}
|
||||
"pdf" | "ai" | "ait" => {
|
||||
let r = vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(err)?;
|
||||
warnings = r.warnings;
|
||||
r.document
|
||||
}
|
||||
_ if format.raster => raster_doc(&title, bytes)?,
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
// Imports are named after the file; a native document keeps its own title (the tab shows the
|
||||
// file name once it has a path).
|
||||
if format.id != "vectorcraft" || doc.title.is_empty() {
|
||||
doc.title = title;
|
||||
}
|
||||
Ok(Loaded { doc, format, warnings })
|
||||
}
|
||||
|
||||
/// Open a file's bytes as the new active document (what `document.open` does) →
|
||||
/// `{index, title, format, warnings}`. `path` is kept for Save only for a native, non-template file.
|
||||
pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings } = load(name, bytes)?;
|
||||
// A template (saved by Save as Template, or an .ait file) opens as a new untitled document.
|
||||
let template = doc.template || format.id == "ait";
|
||||
if template {
|
||||
doc.template = false;
|
||||
doc.title = s.next_untitled();
|
||||
}
|
||||
let keep_path = format.id == "vectorcraft" && !template;
|
||||
let index = s.add_document(doc, path.filter(|_| keep_path));
|
||||
let title = s.documents()[index].title();
|
||||
Ok(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings }))
|
||||
}
|
||||
|
||||
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match (str_param(p, "path"), str_param(p, "dataBase64")) {
|
||||
(Some(path), _) => {
|
||||
let bytes = read_file(path)?;
|
||||
open_bytes(s, path, &bytes, Some(path.to_string()))
|
||||
}
|
||||
(None, Some(b64)) => {
|
||||
let bytes = vectorcraft_format::base64_decode(b64).ok_or_else(|| bad("document.open", "bad base64"))?;
|
||||
open_bytes(s, str_param(p, "name").unwrap_or("Untitled"), &bytes, None)
|
||||
}
|
||||
_ => Err(bad("document.open", "give path, or name and dataBase64")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels).
|
||||
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
|
||||
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
|
||||
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
|
||||
let (bytes, mime, (width, height)) = if matches!(f.id, "png" | "jpg" | "gif" | "webp") {
|
||||
(bytes.to_vec(), f.mime, reader.into_dimensions().map_err(err)?)
|
||||
} else {
|
||||
let img = reader.decode().map_err(err)?.to_rgba8();
|
||||
let size = img.dimensions();
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?;
|
||||
(png, "image/png", size)
|
||||
};
|
||||
if width == 0 || height == 0 {
|
||||
return Err(err("the image is empty"));
|
||||
}
|
||||
Ok(RasterImage { key: format!("img{:016x}", fnv1a(&bytes)), blob: ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) }, width, height })
|
||||
}
|
||||
|
||||
/// An image as a document of its pixel size (1 px = 1 pt), the image named after the file.
|
||||
fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
let RasterImage { key, blob, width, height } = raster_image(bytes)?;
|
||||
let mut d = Document::new(width as f64, height as f64);
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Image(ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link: None }));
|
||||
n.name = Some(name.to_string());
|
||||
d.images.insert(key, blob);
|
||||
d.insert(Some(layer), 0, n).map_err(err)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn gunzip(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).map_err(|e| err(format!("not a valid SVGZ file: {e}")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// FNV-1a: a stable content key for embedded images.
|
||||
fn fnv1a(b: &[u8]) -> u64 {
|
||||
b.iter().fold(0xcbf29ce484222325, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3))
|
||||
}
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
//! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open,
|
||||
//! save, export and serialize by path or bytes (base64), the format table, and `command.batch`.
|
||||
//!
|
||||
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, raster images).
|
||||
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`.
|
||||
//! - `save`: `document.save`, `file.saveAsTemplate`.
|
||||
//!
|
||||
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
|
||||
//! agents query `document.formats`.
|
||||
|
||||
mod batch;
|
||||
mod encode;
|
||||
mod export;
|
||||
mod load;
|
||||
mod save;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, encode};
|
||||
pub use load::{Loaded, RasterImage, detect, load, open_bytes, raster_image};
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"document.open",
|
||||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64} → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
cmd!(
|
||||
"document.save",
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?} native .vectorcraft (default: the document's path) → {path}; a never-saved document without path → {dataBase64} (stays modified)",
|
||||
has_doc,
|
||||
save::save
|
||||
),
|
||||
cmd!(
|
||||
query "document.serialize",
|
||||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|svg|pdf|png|jpg|webp, …the format's options (see document.formats)} → {text} for svg, else {dataBase64}",
|
||||
has_doc,
|
||||
export::serialize
|
||||
),
|
||||
cmd!(
|
||||
"document.export",
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" (1-based; PDF writes one page per artboard, default all; the other formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg)} → {path, format, bytes}; no path → {dataBase64, format, bytes}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
cmd!(
|
||||
"document.exportSelection",
|
||||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
has_selection,
|
||||
export::export_selection
|
||||
),
|
||||
cmd!(
|
||||
"file.saveAsTemplate",
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?} a native copy that opens as a new untitled document (no path → {dataBase64})",
|
||||
has_doc,
|
||||
save::save_template
|
||||
),
|
||||
cmd!(
|
||||
"document.exportForScreens",
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; svg/pdf drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
|
||||
has_doc,
|
||||
export::export_for_screens
|
||||
),
|
||||
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch::batch),
|
||||
cmd!(
|
||||
query "document.formats",
|
||||
"File Formats",
|
||||
[],
|
||||
None,
|
||||
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}}], readable: [id…], writable: [id…], openExtensions: [ext…]}",
|
||||
always,
|
||||
formats
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// One option a format's encoder reads from the export params.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FormatOption {
|
||||
pub name: &'static str,
|
||||
/// JSON type: `number`, `integer`, `boolean`, `string` or `array`.
|
||||
pub ty: &'static str,
|
||||
/// The default as a JSON literal (`"1"`, `"false"`, `"null"`).
|
||||
pub default: &'static str,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
/// One file format: how `document.open` recognises it and what `document.export` writes.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Format {
|
||||
/// The `format` param value (`svg`, `png`, …).
|
||||
pub id: &'static str,
|
||||
/// Open/save dialog filter name.
|
||||
pub label: &'static str,
|
||||
/// Lower-case extensions without the dot; the first is the one exports use.
|
||||
pub extensions: &'static [&'static str],
|
||||
pub mime: &'static str,
|
||||
pub read: bool,
|
||||
pub write: bool,
|
||||
/// Pixels (export `scale` applies) rather than vectors.
|
||||
pub raster: bool,
|
||||
/// Options the encoder reads (writable formats).
|
||||
pub options: &'static [FormatOption],
|
||||
}
|
||||
|
||||
impl Format {
|
||||
pub fn to_json(&self) -> Value {
|
||||
let options: serde_json::Map<String, Value> = self
|
||||
.options
|
||||
.iter()
|
||||
.map(|o| {
|
||||
let default: Value = serde_json::from_str(o.default).unwrap_or(Value::Null);
|
||||
(o.name.to_string(), json!({"type": o.ty, "default": default, "description": o.description}))
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"extensions": self.extensions,
|
||||
"mime": self.mime,
|
||||
"read": self.read,
|
||||
"write": self.write,
|
||||
"raster": self.raster,
|
||||
"options": options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const ARTBOARD: FormatOption = FormatOption {
|
||||
name: "artboard",
|
||||
ty: "integer",
|
||||
default: "0",
|
||||
description: "0-based artboard to export (`artboards: [i]` or `range: \"n\"` naming one artboard work too)",
|
||||
};
|
||||
const ARTBOARDS: FormatOption =
|
||||
FormatOption { name: "artboards", ty: "array", default: "null", description: "0-based artboards, one page each (default: all)" };
|
||||
const RANGE: FormatOption = FormatOption {
|
||||
name: "range",
|
||||
ty: "string",
|
||||
default: "null",
|
||||
description: "1-based artboards such as \"1-3, 5\" (wins over artboards and artboard)",
|
||||
};
|
||||
const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" };
|
||||
const QUALITY: FormatOption = FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 1–100" };
|
||||
const OUTLINE_TEXT: FormatOption =
|
||||
FormatOption { name: "outlineText", ty: "boolean", default: "false", description: "text as glyph outlines (viewable without the fonts)" };
|
||||
|
||||
/// A format `document.open` reads but nothing writes yet.
|
||||
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
|
||||
Format { id, label, extensions, mime, read: true, write: false, raster, options: &[] }
|
||||
}
|
||||
|
||||
/// Every format VectorCraft reads or writes. Append-only: new formats go at the end.
|
||||
pub const FORMATS: &[Format] = &[
|
||||
Format {
|
||||
id: "vectorcraft",
|
||||
label: "VectorCraft",
|
||||
extensions: &[vectorcraft_format::EXTENSION, vectorcraft_format::LEGACY_EXTENSION],
|
||||
mime: "application/json",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[],
|
||||
},
|
||||
Format {
|
||||
id: "svg",
|
||||
label: "SVG",
|
||||
extensions: &["svg"],
|
||||
mime: "image/svg+xml",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[ARTBOARD, OUTLINE_TEXT],
|
||||
},
|
||||
reader("svgz", "SVG Compressed", &["svgz"], "image/svg+xml", false),
|
||||
Format {
|
||||
id: "pdf",
|
||||
label: "PDF",
|
||||
extensions: &["pdf"],
|
||||
mime: "application/pdf",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[ARTBOARD, ARTBOARDS, RANGE],
|
||||
},
|
||||
reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false),
|
||||
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
|
||||
Format { id: "png", label: "PNG", extensions: &["png"], mime: "image/png", read: true, write: true, raster: true, options: &[ARTBOARD, SCALE] },
|
||||
Format {
|
||||
id: "jpg",
|
||||
label: "JPEG",
|
||||
extensions: &["jpg", "jpeg"],
|
||||
mime: "image/jpeg",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, SCALE, QUALITY],
|
||||
},
|
||||
reader("gif", "GIF", &["gif"], "image/gif", true),
|
||||
Format {
|
||||
id: "webp",
|
||||
label: "WebP",
|
||||
extensions: &["webp"],
|
||||
mime: "image/webp",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: &[ARTBOARD, SCALE],
|
||||
},
|
||||
reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true),
|
||||
reader("bmp", "BMP", &["bmp"], "image/bmp", true),
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
pub const OPEN_EXTS: &[&str] =
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
|
||||
|
||||
/// Open-dialog filters: "All readable files" first, then one per readable format.
|
||||
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All readable files", OPEN_EXTS)).chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
|
||||
pub fn format(id_or_ext: &str) -> Option<&'static Format> {
|
||||
let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase();
|
||||
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str())))
|
||||
}
|
||||
|
||||
/// The lower-case extension of a file name or path (empty when it has none).
|
||||
pub fn extension(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The format a file name's extension names.
|
||||
pub fn format_for_name(name: &str) -> Option<&'static Format> {
|
||||
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e))
|
||||
}
|
||||
|
||||
/// The format to write: `format` (an id or extension), else the path's extension, else PNG.
|
||||
pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
|
||||
let f = match (format_param, path.map(extension).filter(|e| !e.is_empty())) {
|
||||
(Some(f), _) => format(f).ok_or_else(|| format!("unknown format `{f}` (see document.formats)"))?,
|
||||
(None, Some(e)) => format(&e).ok_or_else(|| format!("unknown extension `.{e}`: pass `format` (see document.formats)"))?,
|
||||
(None, None) => format("png").expect("png is in FORMATS"),
|
||||
};
|
||||
if f.write { Ok(f) } else { Err(format!("{} files can be opened but not written (see document.formats)", f.label)) }
|
||||
}
|
||||
|
||||
/// [`writable_format`] for command `cmd`.
|
||||
fn writable(cmd: &str, format_param: Option<&str>, path: Option<&str>) -> Result<&'static Format> {
|
||||
writable_format(format_param, path).map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
fn formats(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = |pick: fn(&Format) -> bool| FORMATS.iter().filter(|f| pick(f)).map(|f| f.id).collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"formats": FORMATS.iter().map(Format::to_json).collect::<Vec<_>>(),
|
||||
"readable": ids(|f| f.read),
|
||||
"writable": ids(|f| f.write),
|
||||
"openExtensions": OPEN_EXTS,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- the file system (none on the web, where commands take and return bytes) ----------
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn create_dir(path: &str) -> Result<()> {
|
||||
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn no_fs(path: &str) -> EngineError {
|
||||
EngineError::Other(format!("{path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64)"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_file(path: &str, _: &[u8]) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn create_dir(path: &str) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` → `{path, bytes, …extra}`; with no path → `{dataBase64, bytes, …extra}`.
|
||||
fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result<Value> {
|
||||
let mut out = match path {
|
||||
Some(path) => {
|
||||
write_file(path, bytes)?;
|
||||
json!({ "path": path, "bytes": bytes.len() })
|
||||
}
|
||||
None => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes), "bytes": bytes.len() }),
|
||||
};
|
||||
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
//! Native saves: `document.save` and `file.saveAsTemplate`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::write_or_return;
|
||||
|
||||
pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone()));
|
||||
let bytes = vectorcraft_format::save_file(&s.doc()?.doc);
|
||||
let Some(path) = path else {
|
||||
// Nowhere to save to (web, agents): hand the bytes back; the document stays modified.
|
||||
return write_or_return(None, &bytes, json!({}));
|
||||
};
|
||||
super::write_file(&path, &bytes)?;
|
||||
let st = s.doc_mut()?;
|
||||
st.path = Some(path.clone());
|
||||
st.mark_saved();
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
||||
/// File → Save as Template: a native copy flagged so opening it starts a new untitled document.
|
||||
pub(super) fn save_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut d = (*s.doc()?.doc).clone();
|
||||
d.template = true;
|
||||
write_or_return(str_param(p, "path"), &vectorcraft_format::save_file(&d), json!({}))
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
use std::io::{Cursor, Write as _};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::parse_range;
|
||||
|
||||
fn session(width: f64, height: f64, artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn tmp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-fileio-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
|
||||
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}))
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"))
|
||||
}
|
||||
|
||||
/// A `w`×`h` image of one colour in `format`.
|
||||
fn image_bytes(w: u32, h: u32, format: image::ImageFormat) -> Vec<u8> {
|
||||
let rgba = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 40, 255]));
|
||||
let img = if format == image::ImageFormat::Jpeg {
|
||||
image::DynamicImage::ImageRgb8(image::DynamicImage::ImageRgba8(rgba).to_rgb8())
|
||||
} else {
|
||||
rgba.into()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut out), format).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
match &doc.layers[0].children().unwrap()[0].kind {
|
||||
NodeKind::Image(im) => im.clone(),
|
||||
other => panic!("expected an image, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_every_readable_format() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let native = b64(&s.execute("document.serialize", &json!({})).unwrap());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
let pdf = b64(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap());
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(svg.as_bytes()).unwrap();
|
||||
let svgz = gz.finish().unwrap();
|
||||
|
||||
for (name, bytes, format) in [
|
||||
("a.vectorcraft", native.clone(), "vectorcraft"),
|
||||
("a.drawcraft", native, "vectorcraft"),
|
||||
("a.svg", svg.into_bytes(), "svg"),
|
||||
("a.svgz", svgz, "svgz"),
|
||||
("a.pdf", pdf.clone(), "pdf"),
|
||||
("a.ai", pdf, "ai"),
|
||||
] {
|
||||
let r = open(&mut s, name, &bytes);
|
||||
assert_eq!(r["format"], format, "{name}");
|
||||
assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}");
|
||||
assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through");
|
||||
}
|
||||
for (name, kind) in [
|
||||
("p.png", image::ImageFormat::Png),
|
||||
("p.jpg", image::ImageFormat::Jpeg),
|
||||
("p.gif", image::ImageFormat::Gif),
|
||||
("p.webp", image::ImageFormat::WebP),
|
||||
("p.tif", image::ImageFormat::Tiff),
|
||||
("p.bmp", image::ImageFormat::Bmp),
|
||||
] {
|
||||
let r = open(&mut s, name, &image_bytes(5, 4, kind));
|
||||
assert_eq!(r["format"], format(name.rsplit('.').next().unwrap()).unwrap().id, "{name}");
|
||||
let im = image_of(&s);
|
||||
assert_eq!((im.width, im.height), (5, 4), "{name}");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!((doc.artboards[0].rect.width(), doc.artboards[0].rect.height()), (5.0, 4.0));
|
||||
// Browser-safe formats keep their bytes; TIFF and BMP are stored as PNG.
|
||||
let mime = &doc.images[&im.key].mime;
|
||||
assert_eq!(mime, if name.ends_with("tif") || name.ends_with("bmp") { "image/png" } else { format_for_name(name).unwrap().mime });
|
||||
}
|
||||
assert!(s.execute("document.open", &json!({"name": "x.txt", "dataBase64": "aGVsbG8="})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_opens_at_its_pixel_size() {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, "tiny.webp", &image_bytes(3, 2, image::ImageFormat::WebP));
|
||||
let im = image_of(&s);
|
||||
assert_eq!((im.width, im.height), (3, 2));
|
||||
let ab = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((ab.width(), ab.height()), (3.0, 2.0));
|
||||
let r = raster_image(&image_bytes(3, 2, image::ImageFormat::WebP)).unwrap();
|
||||
assert_eq!((r.width, r.height, r.blob.mime.as_str()), (3, 2, "image/webp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_beats_a_wrong_extension() {
|
||||
let png = image_bytes(2, 2, image::ImageFormat::Png);
|
||||
assert_eq!(detect("photo.jpg", &png).unwrap().id, "png");
|
||||
assert_eq!(detect("noext", b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap().id, "svg");
|
||||
assert_eq!(detect("x.ait", b"%PDF-1.7").unwrap().id, "ait");
|
||||
assert_eq!(detect("x.bin", b"%PDF-1.7").unwrap().id, "pdf");
|
||||
assert!(detect("x.bin", b"nothing").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_open_untitled() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let dir = tmp_dir("tpl");
|
||||
let path = dir.join("t.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
|
||||
let r = s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
assert!(!s.doc().unwrap().doc.template);
|
||||
|
||||
let pdf = b64(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap());
|
||||
let ait = dir.join("t.ait").to_string_lossy().to_string();
|
||||
std::fs::write(&ait, pdf).unwrap();
|
||||
let r = s.execute("document.open", &json!({"path": ait})).unwrap();
|
||||
assert_eq!(r["format"], "ait");
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exts_cover_every_readable_format() {
|
||||
for f in FORMATS.iter().filter(|f| f.read) {
|
||||
for e in f.extensions {
|
||||
assert!(OPEN_EXTS.contains(e), "OPEN_EXTS lacks .{e} ({})", f.label);
|
||||
}
|
||||
}
|
||||
for e in OPEN_EXTS {
|
||||
assert!(format(e).is_some_and(|f| f.read), ".{e} in OPEN_EXTS is no readable format");
|
||||
}
|
||||
let filters: Vec<_> = open_filters().collect();
|
||||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||||
assert_eq!(filters.len(), 1 + FORMATS.iter().filter(|f| f.read).count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_query_lists_readers_writers_and_options() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(ids("writable"), ["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]);
|
||||
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
|
||||
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||
assert_eq!(png["options"]["scale"]["default"], 1);
|
||||
assert_eq!(r["openExtensions"].as_array().unwrap().len(), OPEN_EXTS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_parse_one_based() {
|
||||
assert_eq!(parse_range("1-3, 5", 5).unwrap(), [0, 1, 2, 4]);
|
||||
assert_eq!(parse_range("3-, 1", 4).unwrap(), [2, 3, 0]);
|
||||
assert_eq!(parse_range("-2", 4).unwrap(), [0, 1]);
|
||||
assert_eq!(parse_range("2,2,1\u{2013}2", 4).unwrap(), [1, 0]);
|
||||
for bad in ["", "0", "6", "3-1", "a", "-", "1-9"] {
|
||||
assert!(parse_range(bad, 5).is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pages in a PDF, counted by importing it (one artboard per page).
|
||||
fn pdf_pages(bytes: &[u8]) -> usize {
|
||||
vectorcraft_pdf::import(bytes).unwrap().artboards.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_honours_artboard_and_range() {
|
||||
let mut s = session(100.0, 80.0, 3);
|
||||
let pages = |s: &mut Session, p: Value| pdf_pages(&b64(&s.execute("document.export", &p).unwrap()));
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf"})), 3);
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf", "artboard": 1})), 1);
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf", "artboards": [0, 2]})), 2);
|
||||
assert_eq!(pages(&mut s, json!({"format": "pdf", "range": "2-3"})), 2);
|
||||
assert_eq!(pdf_pages(&b64(&s.execute("document.serialize", &json!({"format": "pdf", "artboard": 2})).unwrap())), 1);
|
||||
assert!(s.execute("document.export", &json!({"format": "pdf", "artboard": 3})).is_err());
|
||||
assert!(s.execute("document.export", &json!({"format": "pdf", "range": "1-4"})).is_err());
|
||||
// Single-image formats take one artboard.
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "range": "1-2"})).is_err());
|
||||
let png = b64(&s.execute("document.export", &json!({"format": "png", "range": "2", "scale": 0.5})).unwrap());
|
||||
assert_eq!(image::load_from_memory(&png).unwrap().width(), 50);
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "scale": "big"})).is_err(), "typed options reject a bad type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_without_path_returns_bytes_and_never_retargets() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let dir = tmp_dir("retarget");
|
||||
let doc_path = dir.join("doc.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("document.save", &json!({"path": doc_path})).unwrap();
|
||||
let copy = dir.join("copy.vectorcraft").to_string_lossy().to_string();
|
||||
let r = s.execute("document.export", &json!({"path": copy})).unwrap();
|
||||
assert_eq!(r["format"], "vectorcraft");
|
||||
assert!(vectorcraft_format::sniff(&std::fs::read(©).unwrap()));
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(doc_path.as_str()), "exporting the native format keeps the document's path");
|
||||
let r = s.execute("document.export", &json!({"format": "jpg"})).unwrap();
|
||||
assert_eq!(&b64(&r)[..2], [0xFF, 0xD8]);
|
||||
assert!(r.get("path").is_none());
|
||||
assert!(s.execute("document.export", &json!({"path": dir.join("x.bmp").to_string_lossy()})).is_err(), "BMP is read-only");
|
||||
// A never-saved document hands its native bytes back.
|
||||
let mut s = session(10.0, 10.0, 1);
|
||||
let r = s.execute("document.save", &json!({})).unwrap();
|
||||
assert!(vectorcraft_format::sniff(&b64(&r)));
|
||||
assert_eq!(s.doc().unwrap().path, None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A red square on a template layer and a blue one on a normal layer above it.
|
||||
fn template_doc() -> (Session, u64, u64) {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let template = s.doc().unwrap().doc.layers[0].id.0;
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let red = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
let blue = s.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap();
|
||||
(s, red, blue)
|
||||
}
|
||||
|
||||
fn has_red(png: &[u8]) -> bool {
|
||||
image::load_from_memory(png).unwrap().to_rgba8().pixels().any(|p| p[0] > 200 && p[2] < 80 && p[3] > 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_exports_leave_template_layers_out() {
|
||||
let (mut s, red, blue) = template_doc();
|
||||
let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap());
|
||||
assert!(!has_red(&png), "no red pixels from the template layer");
|
||||
assert!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(75, 50)[2] > 200, "the normal layer is exported");
|
||||
s.execute("select.set", &json!({"ids": [red, blue]})).unwrap();
|
||||
let r = s.execute("document.exportSelection", &json!({"format": "png"})).unwrap();
|
||||
assert!(!has_red(&b64(&r)));
|
||||
assert!(r["bounds"][0].as_f64().unwrap() > 40.0, "only the blue square (and its stroke) is exported: {}", r["bounds"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_one_page_per_pdf_unique_names() {
|
||||
let mut s = session(100.0, 80.0, 3);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Icon"})).unwrap();
|
||||
let dir = tmp_dir("screens");
|
||||
let folder = dir.to_string_lossy().to_string();
|
||||
let r = s
|
||||
.execute(
|
||||
"document.exportForScreens",
|
||||
&json!({"folder": folder, "formats": [{"format": "pdf"}, {"format": "svg", "scale": 2}, {"format": "svg", "scale": 3}, {"format": "png", "scale": 2}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
|
||||
let names: Vec<&str> = files.iter().map(|f| f.rsplit('/').next().unwrap()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["Icon.pdf", "Icon.svg", "Icon@2x.png", "Icon-2.pdf", "Icon-2.svg", "Icon-2@2x.png", "Artboard-3.pdf", "Artboard-3.svg", "Artboard-3@2x.png"]
|
||||
);
|
||||
for f in files.iter().filter(|f| f.ends_with(".pdf")) {
|
||||
assert_eq!(pdf_pages(&std::fs::read(f).unwrap()), 1, "{f}");
|
||||
}
|
||||
// No folder: the files come back as bytes.
|
||||
let r = s.execute("document.exportForScreens", &json!({"range": "3", "formats": [{"format": "jpg", "scale": 0.5}]})).unwrap();
|
||||
assert_eq!(r["files"][0]["name"], "Artboard-3@0.5x.jpg");
|
||||
assert_eq!(image::load_from_memory(&b64(&r["files"][0])).unwrap().width(), 50);
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "vectorcraft"}]})).is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_sublayers_are_left_out_too() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
let sub = s.execute("layer.newSublayer", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let red = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.ancestry(vectorcraft_doc::NodeId(red)).unwrap().len(), 3, "the square is on a sublayer");
|
||||
assert!(has_red(&b64(&s.execute("document.export", &json!({"format": "png"})).unwrap())));
|
||||
s.execute("layer.setProps", &json!({"id": sub, "template": true})).unwrap();
|
||||
assert!(!has_red(&b64(&s.execute("document.export", &json!({"format": "png"})).unwrap())), "a template sublayer is left out");
|
||||
s.execute("select.set", &json!({"ids": [red]})).unwrap();
|
||||
assert!(s.execute("document.exportSelection", &json!({"format": "png"})).is_err(), "nothing but template art is selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_vector_rows_drop_scale_suffixes_and_names_ignore_case() {
|
||||
let mut s = session(100.0, 80.0, 3);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "icon"})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards[2].name.clear();
|
||||
// The dialog's second row starts as PNG @2x; switched to PDF it keeps that suffix text.
|
||||
let formats =
|
||||
json!([{"format": "png", "scale": 1, "suffix": ""}, {"format": "pdf", "scale": 2, "suffix": "@2x"}, {"format": "svg", "suffix": "-web"}]);
|
||||
let r = s.execute("document.exportForScreens", &json!({"formats": formats})).unwrap();
|
||||
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"Icon.png",
|
||||
"Icon.pdf",
|
||||
"Icon-web.svg",
|
||||
"icon-2.png",
|
||||
"icon-2.pdf",
|
||||
"icon-2-web.svg",
|
||||
"Artboard-3.png",
|
||||
"Artboard-3.pdf",
|
||||
"Artboard-3-web.svg"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_raster_exports_are_refused() {
|
||||
let mut s = session(2000.0, 2000.0, 1);
|
||||
let e = s.execute("document.export", &json!({"format": "webp", "scale": 10})).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "scale": 40})).is_err(), "80000 px a side");
|
||||
}
|
||||
|
|
@ -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<CommandSpec> {
|
||||
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<vectorcraft_geom::Rect>) -> std::result::Result<Paint, String> {
|
||||
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<f32> = 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<Value> {
|
||||
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::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
|
@ -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<Vec<NodeId>> {
|
|||
pub(crate) fn ok() -> Result<Value> {
|
||||
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<Vec<NodeId>> {
|
||||
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<NodeId> = 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<Vec<NodeId>> {
|
||||
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<Vec<usize>, String> {
|
||||
let num = |t: &str, open: usize| -> std::result::Result<usize, String> {
|
||||
let t = t.trim();
|
||||
if t.is_empty() {
|
||||
return Ok(open);
|
||||
}
|
||||
t.parse().ok().filter(|n| (1..=count).contains(n)).ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
|
||||
};
|
||||
let mut out = vec![];
|
||||
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.replace('\u{2013}', "-").split_once('-') {
|
||||
Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")),
|
||||
Some((a, b)) => (num(a, 1)?, num(b, count)?),
|
||||
None => {
|
||||
let n = num(part, 0)?;
|
||||
(n, n)
|
||||
}
|
||||
};
|
||||
if a > b {
|
||||
return Err(format!("range `{s}`: `{part}` runs backwards"));
|
||||
}
|
||||
for i in a - 1..b {
|
||||
if !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(format!("range `{s}` is empty"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
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<Option<Paint>> {
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
/// Leaf objects whose appearance should change for `ids` (groups apply to children).
|
||||
fn paint_targets(s: &Session, p: &Value) -> Result<Vec<vectorcraft_doc::NodeId>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
|||
ok()
|
||||
}
|
||||
|
||||
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Option<Option<Arrowhead>>> {
|
||||
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::<Arrowhead>(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<f64> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
|||
}
|
||||
s.execute("object.setProps", &q)
|
||||
}
|
||||
|
||||
fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
]
|
||||
}
|
||||
|
||||
// ---------- gradient ----------
|
||||
|
||||
/// Leaf paint targets (groups apply to their children), like the Swatches/Color panels.
|
||||
fn leaf_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
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<vectorcraft_geom::Rect>) -> std::result::Result<Paint, String> {
|
||||
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<f32> = 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<Value> {
|
||||
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::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
// ---------- appearance ----------
|
||||
|
||||
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
|
|
@ -298,102 +65,6 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Value> {
|
||||
let names: Vec<String> =
|
||||
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<vectorcraft_color::Color> =
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
|
|
@ -487,66 +158,3 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- 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<Value> {
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Option<Option<Arrowhead>>> {
|
||||
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::<Arrowhead>(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<f64> = 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<Value> {
|
||||
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()
|
||||
}
|
||||
|
|
@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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}))
|
||||
}
|
||||
|
|
@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let names: Vec<String> =
|
||||
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<Color> = 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<Value> {
|
||||
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<Value> {
|
||||
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()
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
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<Value> {
|
|||
|
||||
// ---------- 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<Vec<NodeId>> {
|
||||
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<NodeId> = 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<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
|||
(spec.run)(s, &q)
|
||||
}
|
||||
|
||||
// ---------- gradient tool ----------
|
||||
|
||||
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- artboards ----------
|
||||
|
||||
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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<NodeId> = 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ impl Default for Headless {
|
|||
/// Commands the desktop app adds on top of the engine; the headless backend implements them too so
|
||||
/// `run_command` behaves the same in both modes.
|
||||
const HOST_COMMANDS: &[(&str, &str, &str)] = &[
|
||||
("file.open", "Open…", "{path} open a .vectorcraft or .svg file as a new document"),
|
||||
("file.open", "Open…", "{path} open any readable file (see document.formats) as a new document; templates open untitled"),
|
||||
("file.save", "Save", "{path?} save as .vectorcraft (default: the document's path)"),
|
||||
("file.saveAs", "Save As…", "{path}"),
|
||||
("file.export", "Export…", "{format?: svg|png|vectorcraft, path, scale?, artboard?}"),
|
||||
("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"),
|
||||
("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"),
|
||||
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),
|
||||
];
|
||||
|
||||
|
|
@ -36,14 +37,6 @@ fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
|
|||
p.get(k).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
fn ext_of(path: &str) -> String {
|
||||
std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn pointer_kind(k: &str) -> Option<PointerKind> {
|
||||
Some(match k {
|
||||
"down" => PointerKind::Down,
|
||||
|
|
@ -111,6 +104,7 @@ impl Headless {
|
|||
"file.open" => self.open(params),
|
||||
"file.save" | "file.saveAs" => self.save(params),
|
||||
"file.export" => self.export(params),
|
||||
"file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()),
|
||||
"tool.select" => self.select_tool(params),
|
||||
_ => self.session.execute(id, params).map_err(|e| e.to_string()),
|
||||
}
|
||||
|
|
@ -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<Value, String> {
|
||||
let path = s(p, "path").ok_or("missing `path`")?;
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?;
|
||||
let name = file_name(path);
|
||||
let e = ext_of(path);
|
||||
let mut warnings = vec![];
|
||||
let (doc, keep_path) = if vectorcraft_format::is_native_ext(&e) || vectorcraft_format::sniff(&bytes) {
|
||||
let mut d = vectorcraft_format::load(&bytes).map_err(|e| e.to_string())?;
|
||||
if d.title.is_empty() {
|
||||
d.title = name.clone();
|
||||
}
|
||||
(d, true)
|
||||
} else if e == "svg" || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
|
||||
let (mut d, w) = vectorcraft_svg::import_with_report(text).map_err(|e| e.to_string())?;
|
||||
d.title = name.clone();
|
||||
warnings = w;
|
||||
(d, false)
|
||||
} else if e == "pdf" || e == "ai" || bytes.starts_with(b"%PDF") {
|
||||
// PDF, or the PDF-compatible part of an Illustrator `.ai`, as in the app's File ▸ Open.
|
||||
let r = vectorcraft_pdf::import_with_report(&bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(|e| e.to_string())?;
|
||||
let mut d = r.document;
|
||||
d.title = name.clone();
|
||||
warnings = r.warnings;
|
||||
(d, false)
|
||||
} else {
|
||||
return Err(format!("headless mode can open .vectorcraft, .svg, .pdf and .ai files, not .{e}"));
|
||||
};
|
||||
let index = self.session.add_document(doc, keep_path.then(|| path.to_string()));
|
||||
Ok(json!({"index": index, "title": name, "warnings": warnings}))
|
||||
self.session.execute("document.open", &json!({"path": path})).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `app.save {path?}`: native format; remembers the path and clears the dirty flag.
|
||||
pub fn save(&mut self, p: &Value) -> Result<Value, String> {
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
let path = s(p, "path").map(str::to_string).or_else(|| st.path.clone()).ok_or("missing `path` (document was never saved)")?;
|
||||
let bytes = vectorcraft_format::save_file(&st.doc);
|
||||
std::fs::write(&path, bytes).map_err(|e| format!("write {path}: {e}"))?;
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
st.path = Some(path.clone());
|
||||
st.mark_saved();
|
||||
if s(p, "path").is_none() && st.path.is_none() {
|
||||
return Err("missing `path` (document was never saved)".into());
|
||||
}
|
||||
Ok(json!({"path": path}))
|
||||
self.session.execute("document.save", p).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `app.export {format?, path, scale?, artboard?}`: svg | png | pdf | jpg | webp | vectorcraft (format defaults to the extension).
|
||||
/// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension).
|
||||
pub fn export(&mut self, p: &Value) -> Result<Value, String> {
|
||||
let path = s(p, "path").ok_or("missing `path`")?;
|
||||
let fmt = s(p, "format").map(str::to_ascii_lowercase).unwrap_or_else(|| ext_of(path));
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
let doc = st.doc.clone();
|
||||
let bytes = match fmt.as_str() {
|
||||
"svg" => {
|
||||
let ab = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions { artboard: Some(ab), ..Default::default() }).into_bytes()
|
||||
}
|
||||
"png" => {
|
||||
let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?;
|
||||
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0);
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,3 +29,5 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_fileio;
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
//! File I/O through MCP: every readable format opens headless, and `export` sends one
|
||||
//! `document.export` call whatever the backend.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Backend, Headless, Remote, call_tool, tool_definitions};
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-fileio-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn text(r: &crate::ToolResult) -> Value {
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// A headless session with two artboards of different widths (100 and 40 pt) and a rectangle.
|
||||
fn two_boards() -> Headless {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 50, "artboards": 2}})).unwrap();
|
||||
h.call("engine.execute", json!({"command": "artboard.setProps", "params": {"index": 1, "width": 40}})).unwrap();
|
||||
h.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}})).unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_opens_pdf_ai_and_templates() {
|
||||
let mut h = two_boards();
|
||||
let pdf = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "pdf"}})).unwrap();
|
||||
let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
for name in ["x.pdf", "x.ai"] {
|
||||
let path = tmp(name);
|
||||
std::fs::write(&path, &pdf).unwrap();
|
||||
let r = text(&call_tool(&mut h, "open_file", &json!({"path": path})));
|
||||
assert_eq!(r["title"], name);
|
||||
assert_eq!(h.session.doc().unwrap().doc.artboards.len(), 2, "one artboard per page");
|
||||
}
|
||||
let ait = tmp("x.ait");
|
||||
std::fs::write(&ait, &pdf).unwrap();
|
||||
let r = text(&call_tool(&mut h, "open_file", &json!({"path": ait})));
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
let tpl = tmp("t.vectorcraft");
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tpl}})).unwrap();
|
||||
let r = text(&call_tool(&mut h, "open_file", &json!({"path": tpl})));
|
||||
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
|
||||
assert_eq!(h.session.doc().unwrap().path, None, "Save asks for a new name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_tool_lists_engine_formats_and_options() {
|
||||
let tools = tool_definitions();
|
||||
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]));
|
||||
for k in ["artboard", "range", "options"] {
|
||||
assert!(props.get(k).is_some(), "export takes {k}");
|
||||
}
|
||||
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
|
||||
assert!(open["description"].as_str().unwrap().contains(".ait"));
|
||||
}
|
||||
|
||||
/// A fake app that records `engine.execute` calls and answers each with an empty object.
|
||||
fn recording_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let h = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut out = stream.try_clone().unwrap();
|
||||
let mut seen = vec![];
|
||||
for line in BufReader::new(stream).lines() {
|
||||
let msg: Value = serde_json::from_str(&line.unwrap()).unwrap();
|
||||
writeln!(out, "{}", json!({"id": msg["id"], "ok": true, "result": {}})).unwrap();
|
||||
seen.push(msg);
|
||||
}
|
||||
seen
|
||||
});
|
||||
(addr, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_is_the_same_call_in_the_app_and_headless() {
|
||||
let args = json!({"format": "png", "artboard": 1, "options": {"scale": 2, "artboard": 0, "range": "1-2"}});
|
||||
let (addr, app) = recording_app();
|
||||
let mut remote = Remote::connect(&addr).unwrap();
|
||||
assert!(!call_tool(&mut remote, "export", &args).is_error);
|
||||
drop(remote);
|
||||
let seen = app.join().unwrap();
|
||||
assert_eq!(seen[0]["method"], "engine.execute");
|
||||
assert_eq!(seen[0]["params"], json!({"command": "document.export", "params": {"format": "png", "artboard": 1, "scale": 2}}));
|
||||
|
||||
// Headless runs that very call: artboard 1 (40 pt wide) at scale 2.
|
||||
let mut h = two_boards();
|
||||
let r = text(&call_tool(&mut h, "export", &args));
|
||||
let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(&png[16..20], 80u32.to_be_bytes(), "IHDR width");
|
||||
let direct = h.call("engine.execute", seen[0]["params"].clone()).unwrap();
|
||||
assert_eq!(direct["dataBase64"], r["dataBase64"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_native_keeps_the_document_path() {
|
||||
let mut h = two_boards();
|
||||
let doc = tmp("keep.vectorcraft");
|
||||
h.call("app.save", json!({"path": doc})).unwrap();
|
||||
let copy = tmp("copy.vectorcraft");
|
||||
let r = text(&call_tool(&mut h, "export", &json!({"path": copy})));
|
||||
assert_eq!(r["format"], "vectorcraft");
|
||||
assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str()));
|
||||
// PDF range through the tool: one page.
|
||||
let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf", "range": "2"})));
|
||||
let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(pdf_pages(&pdf), 1);
|
||||
}
|
||||
|
||||
fn pdf_pages(pdf: &[u8]) -> usize {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}}))
|
||||
.unwrap();
|
||||
h.session.doc().unwrap().doc.artboards.len()
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
//! [`Backend`].
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
|
|
@ -246,7 +247,10 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
tool(
|
||||
"open_file",
|
||||
"Open file",
|
||||
"Open a .vectorcraft, .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<Value> {
|
|||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export the active document: svg (artboard viewBox), pdf, png/jpg/webp (rendered artboard) or vectorcraft (native). `selection: true` exports only the selected objects, cropped to their bounds. Live effects are kept (geometry baked, SVG filters for shadows/glows/blur).",
|
||||
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp (one rendered artboard) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
obj(
|
||||
json!({
|
||||
"format": {"type": "string", "enum": ["svg", "png", "pdf", "jpg", "webp", "vectorcraft"], "description": "Default: from the path's extension"},
|
||||
"path": string("Destination file"),
|
||||
"scale": num("PNG pixels per point (default 1)"),
|
||||
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
|
||||
"path": string("Destination file (omit to get dataBase64)"),
|
||||
"scale": num("Raster pixels per point (default 1)"),
|
||||
"artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF default: all)"},
|
||||
"range": string("1-based artboards such as \"1-3, 5\" (PDF: one page each; other formats take one)"),
|
||||
"selection": {"type": "boolean", "description": "Export only the selection, cropped to it"},
|
||||
"outlineText": {"type": "boolean", "description": "SVG: text as outlines (viewable without the fonts)"},
|
||||
"options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg"},
|
||||
}),
|
||||
&["path"],
|
||||
&[],
|
||||
),
|
||||
false,
|
||||
),
|
||||
|
|
@ -302,7 +309,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
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<Value> {
|
|||
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<ToolResult, Str
|
|||
"open_file" => j(b.call("app.open", json!({"path": req_str(a, "path")?}))?),
|
||||
"save_file" => j(b.call("app.save", json!({"path": a.get("path").and_then(Value::as_str)}))?),
|
||||
"export" => {
|
||||
let path = req_str(a, "path")?;
|
||||
let fmt = a
|
||||
.get("format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_ascii_lowercase)
|
||||
.unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default());
|
||||
if !["svg", "png", "pdf", "jpg", "jpeg", "webp", "vectorcraft"].contains(&fmt.as_str()) {
|
||||
return Err(format!("unknown export format `{fmt}` (svg, png, pdf, jpg, webp, vectorcraft)"));
|
||||
// One params object for every backend; the named arguments win over `options` (a named
|
||||
// artboard or range replaces every artboard choice in `options`).
|
||||
let mut params = a.get("options").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
if ["artboard", "range"].iter().any(|k| a.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
for k in ARTBOARD_PARAMS {
|
||||
params.remove(k);
|
||||
}
|
||||
}
|
||||
let scale = a.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
|
||||
if a.get("selection").and_then(Value::as_bool) == Some(true) {
|
||||
return j(exec(
|
||||
b,
|
||||
"document.exportSelection",
|
||||
json!({"path": path, "format": fmt, "scale": scale, "outlineText": a.get("outlineText")}),
|
||||
)?);
|
||||
for k in ["path", "format", "scale", "artboard", "range", "outlineText"] {
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
params.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
if a.get("outlineText").and_then(Value::as_bool) == Some(true) {
|
||||
return j(exec(b, "document.export", json!({"path": path, "format": fmt, "scale": scale, "outlineText": true}))?);
|
||||
}
|
||||
// The desktop app exports native files through Save (which also sets the document path).
|
||||
if fmt == "vectorcraft" && b.has_ui() {
|
||||
return j(b.call("app.save", json!({"path": path}))?);
|
||||
}
|
||||
j(b.call("app.export", json!({"format": fmt, "path": path, "scale": scale}))?)
|
||||
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
|
||||
j(exec(b, cmd, Value::Object(params))?)
|
||||
}
|
||||
"add_text" => j(add_text(b, a)?),
|
||||
"apply_effect" => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Node>) {
|
||||
if !a.visible || f.opts.hidden.contains(&a.id) {
|
||||
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. });
|
||||
if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) {
|
||||
return;
|
||||
}
|
||||
match self.bounds_of(a) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
||||
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<usize> = 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<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
|
||||
let mut boards: Vec<bool> =
|
||||
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<Value> = 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<egui::TextureHandle> {
|
||||
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::<egui::TextureHandle>(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<Value> = 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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<Value, String> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
let params = form::params(d);
|
||||
app.ui.dialog = None;
|
||||
app.run(&cmd, params)
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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<Value, String> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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<Value, String> {
|
||||
let boards: Vec<usize> = 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<String> = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
|
||||
let mut boards: Vec<bool> =
|
||||
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<Value> = 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<egui::TextureHandle> {
|
||||
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::<egui::TextureHandle>(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)
|
||||
}
|
||||
|
|
@ -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}:"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Value, String>;
|
||||
|
||||
/// 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<fn(&mut VectorcraftApp, &egui::Context)>,
|
||||
/// 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<f32>,
|
||||
/// 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<Self> {
|
||||
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;
|
||||
|
|
@ -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<Value, String> {
|
||||
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
|
||||
}
|
||||
|
|
@ -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<Value, String> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<Value, String> {
|
||||
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<Value> = 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
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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<Value, String> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<Value, String> {
|
||||
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))
|
||||
}
|
||||
|
|
@ -1,121 +1,20 @@
|
|||
//! File I/O through the injected services: open, save, export, place.
|
||||
//! File I/O through the injected services (pick, read, write, download); the engine's `fileio`
|
||||
//! decodes and encodes every format.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::{ImageObject, Node, NodeKind};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
fn ext(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Decode an image and make a new document (or place into the active one when `place`).
|
||||
fn image_doc(name: &str, bytes: &[u8]) -> Result<Document, String> {
|
||||
let (w, h) = image_size(bytes)?;
|
||||
let mut d = Document::new(w as f64, h as f64);
|
||||
d.title = name.to_string();
|
||||
let key = format!("img{:016x}", hash(bytes));
|
||||
d.images.insert(key.clone(), ImageBlob { mime: mime_of(name), bytes: Arc::new(bytes.to_vec()) });
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf: Affine::IDENTITY, link: None }));
|
||||
n.name = Some(name.to_string());
|
||||
d.insert(Some(l), 0, n).map_err(|e| e.to_string())?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
pub fn image_size(bytes: &[u8]) -> Result<(u32, u32), String> {
|
||||
// PNG / JPEG / GIF / WebP headers — enough to size a placed image without decoding.
|
||||
if bytes.len() > 24 && &bytes[..8] == b"\x89PNG\r\n\x1a\n" {
|
||||
let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
|
||||
let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
return Ok((w, h));
|
||||
}
|
||||
if bytes.len() > 10 && &bytes[..6] == b"GIF89a" || bytes.len() > 10 && &bytes[..6] == b"GIF87a" {
|
||||
return Ok((u16::from_le_bytes([bytes[6], bytes[7]]) as u32, u16::from_le_bytes([bytes[8], bytes[9]]) as u32));
|
||||
}
|
||||
if bytes.len() > 4 && bytes[0] == 0xFF && bytes[1] == 0xD8 {
|
||||
let mut i = 2;
|
||||
while i + 9 < bytes.len() {
|
||||
if bytes[i] != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let marker = bytes[i + 1];
|
||||
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
|
||||
if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
|
||||
let h = u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]) as u32;
|
||||
let w = u16::from_be_bytes([bytes[i + 7], bytes[i + 8]]) as u32;
|
||||
return Ok((w, h));
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
}
|
||||
Err("unsupported image format".into())
|
||||
}
|
||||
|
||||
fn mime_of(name: &str) -> String {
|
||||
match ext(name).as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn hash(b: &[u8]) -> u64 {
|
||||
// FNV-1a: stable content key for embedded images.
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for x in b {
|
||||
h ^= *x as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Open bytes as a new document.
|
||||
/// Open bytes of any readable format as a new document (templates open untitled).
|
||||
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> {
|
||||
let e = ext(name);
|
||||
let (doc, keep_path) = if vectorcraft_format::is_native_ext(&e) || vectorcraft_format::sniff(bytes) {
|
||||
let mut d = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?;
|
||||
if d.template {
|
||||
// Templates open as a new untitled document.
|
||||
d.template = false;
|
||||
d.title = app.session.next_untitled();
|
||||
(d, false)
|
||||
} else {
|
||||
if d.title.is_empty() {
|
||||
d.title = name.to_string();
|
||||
}
|
||||
(d, true)
|
||||
}
|
||||
} else if e == "svg" || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
|
||||
let s = std::str::from_utf8(bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
|
||||
let (mut d, warnings) = vectorcraft_svg::import_with_report(s).map_err(|e| e.to_string())?;
|
||||
d.title = name.to_string();
|
||||
if !warnings.is_empty() {
|
||||
app.status(format!("Opened with {} warning(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default()));
|
||||
}
|
||||
(d, false)
|
||||
} else if e == "pdf" || e == "ai" || bytes.starts_with(b"%PDF") {
|
||||
let vectorcraft_pdf::ImportReport { document: mut d, warnings } =
|
||||
vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(|e| e.to_string())?;
|
||||
d.title = name.to_string();
|
||||
if !warnings.is_empty() {
|
||||
app.status(format!("Opened with {} note(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default()));
|
||||
}
|
||||
(d, false)
|
||||
} else if ["png", "jpg", "jpeg", "gif", "webp"].contains(&e.as_str()) {
|
||||
(image_doc(name, bytes)?, false)
|
||||
} else {
|
||||
return Err(format!("VectorCraft can't open .{e} files yet"));
|
||||
};
|
||||
app.session.add_document(doc, if keep_path { path } else { None });
|
||||
let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?;
|
||||
app.sync_views();
|
||||
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
|
||||
app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -132,8 +31,7 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> {
|
|||
pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> {
|
||||
let read = app.services.read.as_ref().ok_or("no file reader")?;
|
||||
let bytes = read(path)?;
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string());
|
||||
open_bytes(app, &name, &bytes, Some(path.to_string()))?;
|
||||
open_bytes(app, path, &bytes, Some(path.to_string()))?;
|
||||
note_recent(app, path);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -154,19 +52,25 @@ fn suggested(app: &VectorcraftApp, ext: &str) -> String {
|
|||
format!("{stem}.{ext}")
|
||||
}
|
||||
|
||||
/// `path`, else a suggested name (web download) or one picked in a save dialog.
|
||||
fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Result<String, String> {
|
||||
match path {
|
||||
Some(p) => Ok(p),
|
||||
None if app.services.download.is_some() => Ok(suggested(app, ext)),
|
||||
None => {
|
||||
let s = suggested(app, ext);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or_else(|| "cancelled".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File → Save / Save As (native format).
|
||||
pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool) -> Result<String, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let existing = if save_as { None } else { st.path.clone() };
|
||||
let path = match path.or(existing) {
|
||||
Some(p) => p,
|
||||
None if app.services.download.is_some() => suggested(app, "vectorcraft"),
|
||||
None => {
|
||||
let s = suggested(app, "vectorcraft");
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
|
||||
}
|
||||
};
|
||||
let bytes = vectorcraft_format::save_file(&app.session.active().unwrap().doc);
|
||||
let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?;
|
||||
let doc = app.session.active().ok_or("no document")?.doc.clone();
|
||||
let bytes = fileio::encode(&doc, "vectorcraft", &Value::Null).map_err(|e| e.to_string())?;
|
||||
write_out(app, &path, &bytes)?;
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.path = Some(path.clone());
|
||||
|
|
@ -185,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<String>, scale: f64) -> Result<String, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let doc = st.doc.clone();
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None if app.services.download.is_some() => suggested(app, format),
|
||||
None => {
|
||||
let s = suggested(app, format);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
|
||||
}
|
||||
};
|
||||
let bytes = match format {
|
||||
"svg" => vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions { artboard: Some(0), ..Default::default() }).into_bytes(),
|
||||
"pdf" => vectorcraft_engine::export_pdf(&doc, &vectorcraft_pdf::PdfOptions::default()).map_err(|e| e.to_string())?,
|
||||
"png" | "jpg" | "jpeg" | "webp" => {
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or("no artboard")?;
|
||||
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<String>, params: &Value) -> Result<String, String> {
|
||||
let f = fileio::writable_format(format, path.as_deref())?;
|
||||
let doc = app.session.active().ok_or("no document")?.doc.clone();
|
||||
let path = target_path(app, path, f.extensions[0])?;
|
||||
let bytes = fileio::encode(&doc, f.id, params).map_err(|e| e.to_string())?;
|
||||
write_out(app, &path, &bytes)?;
|
||||
app.status(format!("Exported {path}"));
|
||||
Ok(path)
|
||||
|
|
@ -219,19 +103,12 @@ pub fn export(app: &mut VectorcraftApp, format: &str, path: Option<String>, scal
|
|||
|
||||
/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path
|
||||
/// (Export Selection, Save as Template).
|
||||
pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: serde_json::Value) -> Result<String, String> {
|
||||
let path = match params.get("path").and_then(serde_json::Value::as_str) {
|
||||
Some(p) => p.to_string(),
|
||||
None if app.services.download.is_some() => suggested(app, ext),
|
||||
None => {
|
||||
let s = suggested(app, ext);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&s)).ok_or("cancelled")?
|
||||
}
|
||||
};
|
||||
pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<String, String> {
|
||||
let path = target_path(app, params.get("path").and_then(Value::as_str).map(str::to_string), ext)?;
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("path");
|
||||
if o.get("format").is_none() && id == "document.exportSelection" {
|
||||
let e = std::path::Path::new(&path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| ext.into());
|
||||
let e = Some(fileio::extension(&path)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into());
|
||||
o.insert("format".into(), serde_json::json!(e));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
|
||||
app.session.clipboard = nodes;
|
||||
// Straight to the engine: `app.run` would let the system clipboard replace these nodes.
|
||||
|
|
@ -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<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// An app whose writer records what it is given.
|
||||
fn app() -> (VectorcraftApp, Written) {
|
||||
let written = Written::default();
|
||||
let w = written.clone();
|
||||
let services = Services {
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(VectorcraftApp::new(Session::new(), services), written)
|
||||
}
|
||||
|
||||
fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec<u8> {
|
||||
let v = app.session.execute(cmd, &p).unwrap();
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn image_size(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> (u32, u32) {
|
||||
match &app.session.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Image(im) => (im.width, im.height),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_opens_and_places_at_its_pixel_size() {
|
||||
let (mut app, _) = app();
|
||||
app.session.execute("file.new", &json!({"width": 3, "height": 2})).unwrap();
|
||||
let webp = bytes_of(&mut app, "document.serialize", json!({"format": "webp"}));
|
||||
open_bytes(&mut app, "tiny.webp", &webp, None).unwrap();
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(app.views.len(), 2, "views follow the documents");
|
||||
let first = app.session.doc().unwrap().doc.layers[0].children().unwrap()[0].id;
|
||||
assert_eq!(image_size(&app, first), (3, 2));
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
place_bytes(&mut app, "tiny.webp", &webp).unwrap();
|
||||
let placed = app.session.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(image_size(&app, placed), (3, 2));
|
||||
assert!(place_bytes(&mut app, "x.txt", b"hello").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn templates_open_untitled() {
|
||||
let (mut app, _) = app();
|
||||
app.session.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
|
||||
let pdf = bytes_of(&mut app, "document.serialize", json!({"format": "pdf"}));
|
||||
open_bytes(&mut app, "brochure.ait", &pdf, Some("/tmp/brochure.ait".into())).unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(st.title().starts_with("Untitled-"), "{}", st.title());
|
||||
assert_eq!(st.path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_uses_the_engine_options_and_keeps_the_path() {
|
||||
let (mut app, written) = app();
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 50, "artboards": 2})).unwrap();
|
||||
app.session.execute("artboard.setProps", &json!({"index": 1, "width": 40})).unwrap();
|
||||
app.session.doc_mut().unwrap().path = Some("/tmp/doc.vectorcraft".into());
|
||||
export(&mut app, None, Some("/tmp/b.png".into()), &json!({"artboard": 1, "scale": 2})).unwrap();
|
||||
export(&mut app, None, Some("/tmp/copy.vectorcraft".into()), &Value::Null).unwrap();
|
||||
let w = written.borrow();
|
||||
assert_eq!(&w[0].1[16..20], 80u32.to_be_bytes(), "artboard 1 (40 pt) at scale 2");
|
||||
assert!(vectorcraft_format::sniff(&w[1].1));
|
||||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft"));
|
||||
drop(w);
|
||||
assert!(export(&mut app, None, Some("/tmp/x.bmp".into()), &Value::Null).is_err(), "BMP is read-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_for_screens_with_params_runs_the_engine() {
|
||||
let (mut app, _) = app();
|
||||
app.session.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap();
|
||||
let r = app.run("file.exportForScreens", json!({"range": "2-3", "formats": [{"format": "pdf"}]})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(files[0]["name"], "Artboard-2.pdf");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
app.run("file.exportForScreens", Value::Null).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("exportForScreens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_export_without_path_returns_bytes() {
|
||||
let (mut app, written) = app();
|
||||
app.session.execute("file.new", &json!({"width": 30, "height": 20})).unwrap();
|
||||
let (req, _) = crate::control::ControlRequest::new("app.export", json!({"format": "png", "scale": 2}));
|
||||
let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") };
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let png = vectorcraft_format::base64_decode(r["result"]["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(&png[16..20], 60u32.to_be_bytes(), "30 pt at scale 2");
|
||||
assert!(written.borrow().is_empty(), "no file and no save dialog");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub enum Item {
|
|||
Todo(&'static str, &'static str),
|
||||
Sub(&'static str, Vec<Item>),
|
||||
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 {
|
||||
Item::Sub(label, items)
|
||||
}
|
||||
/// Window → … Libraries until the code-generated libraries land (disabled entries).
|
||||
fn library_placeholders() -> Vec<Item> {
|
||||
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<R
|
|||
None => Err("cancelled".into()),
|
||||
}
|
||||
}
|
||||
"file.export.svg" => io::export(app, "svg", s("path"), 1.0).map(|p| json!({"path": p})),
|
||||
"file.exportForScreens" if p.get("path").is_none() && p.get("scale").is_none() => {
|
||||
"file.export.svg" => io::export(app, Some("svg"), s("path"), p).map(|p| json!({"path": p})),
|
||||
"file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(
|
||||
|
|
@ -241,9 +250,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.export.png" | "file.exportForScreens" => {
|
||||
io::export(app, "png", s("path"), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p}))
|
||||
}
|
||||
"file.exportForScreens" => app.run("document.exportForScreens", p.clone()),
|
||||
"file.export.png" => io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})),
|
||||
"file.documentSetup" => {
|
||||
let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points");
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units})));
|
||||
|
|
@ -457,7 +465,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})),
|
||||
None => Err("no effect applied yet".into()),
|
||||
},
|
||||
"file.export.pdf" => io::export(app, "pdf", s("path"), 1.0).map(|p| json!({"path": p})),
|
||||
"file.export.pdf" => io::export(app, Some("pdf"), s("path"), p).map(|p| json!({"path": p})),
|
||||
"help.about" => {
|
||||
app.ui.about = true;
|
||||
Ok(Value::Null)
|
||||
|
|
@ -746,7 +754,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
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<Item>)> {
|
|||
),
|
||||
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<Item> {
|
|||
.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<Item> {
|
||||
let cat = vectorcraft_effects::effect_catalog();
|
||||
let mut out = vec![
|
||||
|
|
@ -1585,7 +1593,7 @@ fn effect_menu() -> Vec<Item> {
|
|||
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"];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<String, D::Error> {
|
||||
<String as serde::Deserialize>::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<String, String>, key: &str) {
|
|||
|
||||
/// Overrides of a preset (both presets are the registry defaults for now).
|
||||
pub fn preset(name: &str) -> Option<BTreeMap<String, String>> {
|
||||
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<String, String>), 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<Resu
|
|||
sync(&app.ui);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"shortcuts.preset" => 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;
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ pub struct UiState {
|
|||
#[serde(default)]
|
||||
pub shortcut_overrides: std::collections::BTreeMap<String, String>,
|
||||
/// 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")]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
11
docs/mcp.md
11
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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@
|
|||
</developer>
|
||||
<description>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</description>
|
||||
<launchable type="desktop-id">ai.storyteller.vectorcraft.desktop</launchable>
|
||||
|
|
@ -24,19 +26,21 @@
|
|||
<url type="contact">https://discord.gg/artcraft</url>
|
||||
<categories>
|
||||
<category>Graphics</category>
|
||||
<category>RasterGraphics</category>
|
||||
<category>Photography</category>
|
||||
<category>2DGraphics</category>
|
||||
<category>VectorGraphics</category>
|
||||
</categories>
|
||||
<keywords>
|
||||
<keyword>photo</keyword>
|
||||
<keyword>image editor</keyword>
|
||||
<keyword>psd</keyword>
|
||||
<keyword>layers</keyword>
|
||||
<keyword>vector</keyword>
|
||||
<keyword>illustration</keyword>
|
||||
<keyword>svg</keyword>
|
||||
<keyword>pdf</keyword>
|
||||
<keyword>drawing</keyword>
|
||||
<keyword>bezier</keyword>
|
||||
</keywords>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Editing The Great Wave off Kanagawa with type layers and adjustment layers</caption>
|
||||
<image>https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/images/vectorcraft-demo.jpg</image>
|
||||
<caption>Editing the Neon Drive poster with a live glow effect in the Appearance panel</caption>
|
||||
<image>https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/images/shot-1-neon.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<branding>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © the VectorCraft authors. MIT OR Apache-2.0.</string>
|
||||
|
||||
<!-- Vectorcraft's own format. -->
|
||||
<!-- VectorCraft's own format (.drawcraft is its name from before the rename). -->
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
|
@ -63,48 +63,11 @@
|
|||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array><string>pcraft</string></array>
|
||||
<array><string>vectorcraft</string><string>drawcraft</string></array>
|
||||
<key>public.mime-type</key>
|
||||
<array><string>application/x-vectorcraft</string></array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- Types macOS may not know; the system's own declaration wins when it has one. -->
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.adobe.photoshop-large-image</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Photoshop Large Document</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.image</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array><string>psb</string></array>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>ai.storyteller.vectorcraft.qoi</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>QOI Image</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
<string>public.image</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array><string>qoi</string></array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
|
|||
```html
|
||||
<iframe
|
||||
src="https://example.com/vectorcraft/"
|
||||
title="VectorCraft image editor"
|
||||
title="VectorCraft vector illustration app"
|
||||
style="width: 100%; height: 720px; border: 0;"
|
||||
allow="fullscreen; clipboard-read; clipboard-write"
|
||||
allowfullscreen>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
|
||||
wix build vectorcraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
|
||||
|
||||
Per-machine install into Program Files, a Start Menu shortcut, `.pcraft` owned by VectorCraft,
|
||||
and VectorCraft offered under "Open with" for PSD/PSB and the image formats it reads (without
|
||||
taking over the user's default app for them).
|
||||
Per-machine install into Program Files, a Start Menu shortcut and an App Paths entry (so
|
||||
`vectorcraft` starts from the Run dialog), plus vectorcraft-cli.
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package
|
||||
|
|
@ -17,7 +16,7 @@
|
|||
Scope="perMachine"
|
||||
Compressed="yes"
|
||||
Language="1033">
|
||||
<SummaryInformation Description="VectorCraft image editor" Manufacturer="Learning Machines LLC" />
|
||||
<SummaryInformation Description="VectorCraft vector illustration app" Manufacturer="Learning Machines LLC" />
|
||||
<!-- Same-version upgrades let successive pre-releases of one X.Y.Z replace each other
|
||||
(MSI versions are numeric, so 1.2.3-rc.1 and 1.2.3-rc.2 are both 1.2.3). -->
|
||||
<MajorUpgrade AllowSameVersionUpgrades="yes"
|
||||
|
|
@ -45,7 +44,7 @@
|
|||
<Component Id="VectorcraftApp" Guid="0E8C6C5E-DE4A-4E1D-9DB0-44E8E38C2103">
|
||||
<File Id="VectorcraftExe" Source="$(var.BinDir)\vectorcraft.exe" KeyPath="yes">
|
||||
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="VectorCraft"
|
||||
Description="VectorCraft image editor" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
|
||||
Description="VectorCraft vector illustration app" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
|
||||
Icon="VectorcraftIcon" IconIndex="0" />
|
||||
</File>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
//! as `` `path` `` in ASSETS.md. See the asset policy in AGENTS.md.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const ASSET_EXT: &[&str] = &[
|
||||
"png",
|
||||
|
|
@ -51,12 +50,7 @@ pub fn missing(files: &[String], assets_md: &str) -> Vec<String> {
|
|||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let out = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
|
||||
.output()
|
||||
.map_err(|e| format!("git ls-files: {e}"))?;
|
||||
let files: Vec<String> = String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect();
|
||||
let files = crate::repo_files(root)?;
|
||||
let md = std::fs::read_to_string(root.join("ASSETS.md")).map_err(|e| format!("ASSETS.md: {e}"))?;
|
||||
let miss = missing(&files, &md);
|
||||
if miss.is_empty() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,385 @@
|
|||
//! `cargo xtask brands`: no other vendor's product or company names in what users and agents read.
|
||||
//!
|
||||
//! VectorCraft names its own features; where a comparison is needed, text says "the reference
|
||||
//! app". Scanned:
|
||||
//! * string literals in Rust sources (command labels and params docs, `UI_COMMANDS`, menus, panels,
|
||||
//! MCP tool definitions…). Comments and test code (`tests*.rs`, `*_tests.rs`, `tests/` and
|
||||
//! `benches/` folders, items under `#[cfg(test)]`) are skipped;
|
||||
//! * the `description` of every `Cargo.toml`;
|
||||
//! * every text file under `docs/` and `packaging/`.
|
||||
//!
|
||||
//! A line that has to keep an old name (an alias that old files or preferences still use) says
|
||||
//! `brand-ok` in a comment on that line.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Vendor product and company names, lowercase, matched case-insensitively as whole words.
|
||||
const DENY: &[&str] = &[
|
||||
"adobe",
|
||||
"illustrator",
|
||||
"photoshop",
|
||||
"acrobat",
|
||||
"indesign",
|
||||
"lightroom",
|
||||
"dreamweaver",
|
||||
"creative cloud",
|
||||
"typekit",
|
||||
"kuler",
|
||||
"behance",
|
||||
"myriad",
|
||||
"pantone",
|
||||
"aicb",
|
||||
];
|
||||
|
||||
/// Marks a line that keeps a legacy name on purpose.
|
||||
const ALLOW: &str = "brand-ok";
|
||||
|
||||
/// This file lists the names it rejects.
|
||||
const SELF: &str = "xtask/src/brands.rs";
|
||||
|
||||
/// A denied name found in a file.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Hit {
|
||||
pub path: String,
|
||||
/// 1-based.
|
||||
pub line: usize,
|
||||
pub term: &'static str,
|
||||
/// The literal or line it was found in.
|
||||
pub context: String,
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let files = crate::repo_files(root)?;
|
||||
let hits = scan(root, &files);
|
||||
if hits.is_empty() {
|
||||
println!("brands: no vendor names in user-visible text");
|
||||
return Ok(());
|
||||
}
|
||||
let list: Vec<String> = hits.iter().map(|h| format!("{}:{}: \"{}\" in {}", h.path, h.line, h.term, short(&h.context))).collect();
|
||||
Err(format!(
|
||||
"{} vendor name(s) in user-visible text (say \"the reference app\" or name the feature; mark a required legacy alias `{ALLOW}`):\n {}",
|
||||
hits.len(),
|
||||
list.join("\n ")
|
||||
))
|
||||
}
|
||||
|
||||
/// Every hit in `files` (paths relative to `root`).
|
||||
pub fn scan(root: &Path, files: &[String]) -> Vec<Hit> {
|
||||
let mut hits = vec![];
|
||||
for path in files {
|
||||
let Some(kind) = kind_of(path) else { continue };
|
||||
// Binary files (images under docs/) are not text and have nothing to read.
|
||||
let Ok(text) = std::fs::read_to_string(root.join(path)) else { continue };
|
||||
let found = match kind {
|
||||
Kind::Rust => scan_rust(&text),
|
||||
Kind::Manifest => scan_lines(&text, |l| l.trim_start().starts_with("description")),
|
||||
Kind::Text => scan_lines(&text, |_| true),
|
||||
};
|
||||
hits.extend(found.into_iter().map(|(line, term, context)| Hit { path: path.clone(), line, term, context }));
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
Rust,
|
||||
Manifest,
|
||||
Text,
|
||||
}
|
||||
|
||||
fn kind_of(path: &str) -> Option<Kind> {
|
||||
if path.starts_with("docs/") || path.starts_with("packaging/") {
|
||||
Some(Kind::Text)
|
||||
} else if path == "Cargo.toml" || path.ends_with("/Cargo.toml") {
|
||||
Some(Kind::Manifest)
|
||||
} else if path.ends_with(".rs") && ["crates/", "apps/", "xtask/"].iter().any(|d| path.starts_with(d)) && path != SELF && !is_test_path(path) {
|
||||
Some(Kind::Rust)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_test_path(path: &str) -> bool {
|
||||
let mut parts = path.split('/').rev();
|
||||
let file = parts.next().unwrap_or_default();
|
||||
file == "tests.rs" || file.starts_with("tests_") || file.ends_with("_tests.rs") || parts.any(|d| d == "tests" || d == "benches")
|
||||
}
|
||||
|
||||
/// (line, term, line text) for each denied name on the lines `keep` selects.
|
||||
fn scan_lines(text: &str, keep: impl Fn(&str) -> bool) -> Vec<(usize, &'static str, String)> {
|
||||
text.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| keep(l) && !l.contains(ALLOW))
|
||||
.flat_map(|(i, l)| terms_in(l, DENY).into_iter().map(move |t| (i + 1, t, l.trim().to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// (first line, term, literal) for each denied name in a Rust string literal outside comments and
|
||||
/// test code.
|
||||
fn scan_rust(src: &str) -> Vec<(usize, &'static str, String)> {
|
||||
let lines: Vec<&str> = src.lines().collect();
|
||||
let mut out = vec![];
|
||||
for lit in rust_literals(src) {
|
||||
if lines.get(lit.first - 1..lit.last.min(lines.len())).is_some_and(|ls| ls.iter().any(|l| l.contains(ALLOW))) {
|
||||
continue;
|
||||
}
|
||||
for t in terms_in(&lit.text, DENY) {
|
||||
out.push((lit.first, t, lit.text.clone()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `terms` (lowercase) that occur in `text` as whole words, ignoring case.
|
||||
fn terms_in(text: &str, terms: &[&'static str]) -> Vec<&'static str> {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let b = lower.as_bytes();
|
||||
let word = |c: Option<&u8>| c.is_some_and(u8::is_ascii_alphanumeric);
|
||||
terms
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| lower.match_indices(t).any(|(i, _)| !word(i.checked_sub(1).and_then(|j| b.get(j))) && !word(b.get(i + t.len()))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn short(s: &str) -> String {
|
||||
let s = s.replace('\n', " ");
|
||||
match s.char_indices().nth(100) {
|
||||
Some((i, _)) => format!("{}…", &s[..i]),
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Rust string literal: its first and last line (1-based) and its text (escapes as spaces).
|
||||
#[derive(Debug)]
|
||||
struct Lit {
|
||||
first: usize,
|
||||
last: usize,
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// String literals of a Rust source, skipping comments and items under `#[cfg(test)]`.
|
||||
fn rust_literals(src: &str) -> Vec<Lit> {
|
||||
let b = src.as_bytes();
|
||||
let mut out = vec![];
|
||||
let (mut i, mut line) = (0usize, 1usize);
|
||||
let mut depth = 0usize;
|
||||
// `#[cfg(test)]` seen, its item not opened yet; brace depth the test item was opened at.
|
||||
let (mut test_attr, mut test_depth) = (false, None::<usize>);
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'\n' => {
|
||||
line += 1;
|
||||
i += 1;
|
||||
}
|
||||
b'/' if b.get(i + 1) == Some(&b'/') => {
|
||||
while i < b.len() && b[i] != b'\n' {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'/' if b.get(i + 1) == Some(&b'*') => {
|
||||
let mut nest = 0usize;
|
||||
while i < b.len() {
|
||||
if b[i..].starts_with(b"/*") {
|
||||
nest += 1;
|
||||
i += 2;
|
||||
} else if b[i..].starts_with(b"*/") {
|
||||
nest -= 1;
|
||||
i += 2;
|
||||
if nest == 0 {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
line += usize::from(b[i] == b'\n');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
b'"' => {
|
||||
let first = line;
|
||||
let (text, end) = quoted(b, i + 1, &mut line);
|
||||
i = end;
|
||||
if test_depth.is_none() {
|
||||
out.push(Lit { first, last: line, text });
|
||||
}
|
||||
}
|
||||
b'\'' => i = after_char_or_lifetime(src, i),
|
||||
b'#' if b[i..].starts_with(b"#[cfg(test)]") => {
|
||||
test_attr = true;
|
||||
i += "#[cfg(test)]".len();
|
||||
}
|
||||
b'{' => {
|
||||
if test_attr && test_depth.is_none() {
|
||||
test_depth = Some(depth);
|
||||
}
|
||||
test_attr = false;
|
||||
depth += 1;
|
||||
i += 1;
|
||||
}
|
||||
b'}' => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if test_depth == Some(depth) {
|
||||
test_depth = None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
b';' => {
|
||||
test_attr = false;
|
||||
i += 1;
|
||||
}
|
||||
c if c.is_ascii_alphabetic() || c == b'_' => {
|
||||
let start = i;
|
||||
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') {
|
||||
i += 1;
|
||||
}
|
||||
let hashes = b[i..].iter().take_while(|&&h| h == b'#').count();
|
||||
// Raw strings (r"…", r#"…"#, br…, cr…); b"…" and c"…" are read as plain strings next.
|
||||
if matches!(&src[start..i], "r" | "br" | "cr") && b.get(i + hashes) == Some(&b'"') {
|
||||
let first = line;
|
||||
let body = i + hashes + 1;
|
||||
let close: Vec<u8> = std::iter::once(b'"').chain(std::iter::repeat_n(b'#', hashes)).collect();
|
||||
let end = (body..b.len()).find(|&j| b[j..].starts_with(&close)).unwrap_or(b.len());
|
||||
let text = String::from_utf8_lossy(&b[body..end]).into_owned();
|
||||
line += text.matches('\n').count();
|
||||
i = (end + close.len()).min(b.len());
|
||||
if test_depth.is_none() {
|
||||
out.push(Lit { first, last: line, text });
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read a `"…"` literal whose body starts at `i`: (text with escapes as spaces, index after the
|
||||
/// closing quote).
|
||||
fn quoted(b: &[u8], mut i: usize, line: &mut usize) -> (String, usize) {
|
||||
let mut text = Vec::new();
|
||||
while i < b.len() && b[i] != b'"' {
|
||||
if b[i] == b'\\' {
|
||||
match b.get(i + 1) {
|
||||
Some(&c @ (b'"' | b'\\' | b'\'')) => text.push(c),
|
||||
Some(b'\n') => {
|
||||
*line += 1;
|
||||
text.push(b' ');
|
||||
}
|
||||
_ => text.push(b' '),
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
*line += usize::from(b[i] == b'\n');
|
||||
text.push(b[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
(String::from_utf8_lossy(&text).into_owned(), (i + 1).min(b.len()))
|
||||
}
|
||||
|
||||
/// Skip a char literal (`'x'`, `'\n'`, `'\u{…}'`) at `i`, or just the quote of a lifetime/label.
|
||||
fn after_char_or_lifetime(src: &str, i: usize) -> usize {
|
||||
let b = src.as_bytes();
|
||||
if b.get(i + 1) == Some(&b'\\') {
|
||||
let close = (i + 3..b.len()).find(|&j| b[j] == b'\'').unwrap_or(b.len());
|
||||
return (close + 1).min(b.len());
|
||||
}
|
||||
match src[i + 1..].chars().next() {
|
||||
Some(c) if b.get(i + 1 + c.len_utf8()) == Some(&b'\'') => i + 2 + c.len_utf8(),
|
||||
_ => i + 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rust_hits(src: &str) -> Vec<(usize, &'static str)> {
|
||||
scan_rust(src).into_iter().map(|(l, t, _)| (l, t)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_literals_skip_comments_identifiers_tests_and_marked_lines() {
|
||||
let src = r##"// Illustrator in a comment
|
||||
/// Adobe in a doc comment
|
||||
/* block Adobe /* nested */ Photoshop */
|
||||
fn f() { let a = "Made for Illustrator users"; let k = "copyAicb"; let q = '"'; let l: &'static str = r#"raw "Acrobat" text"#; }
|
||||
const OLD: &str = "Illustrator Defaults"; // brand-ok: legacy alias
|
||||
fn g() -> &'static str { "line\nAICB" }
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn t() { let x = "Adobe"; if true { let y = "Photoshop"; } }
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod other;
|
||||
fn h() { let s = b"InDesign"; let m = "multi
|
||||
line Pantone"; }
|
||||
"##;
|
||||
assert_eq!(rust_hits(src), [(4, "illustrator"), (4, "acrobat"), (6, "aicb"), (13, "indesign"), (13, "pantone")]);
|
||||
let lits = rust_literals(src);
|
||||
let multi = lits.iter().find(|l| l.text.contains("Pantone")).unwrap();
|
||||
assert_eq!((multi.first, multi.last), (13, 14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_words_only() {
|
||||
assert_eq!(terms_in("Adobe's app", DENY), ["adobe"]);
|
||||
assert_eq!(terms_in("an ILLUSTRATOR-style UI", DENY), ["illustrator"]);
|
||||
assert!(terms_in("illustrators illustration copyAicb aicbMode", DENY).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_packaging_and_manifests() {
|
||||
let lines = |text: &str, keep: fn(&str) -> bool| -> Vec<(usize, &'static str)> {
|
||||
scan_lines(text, keep).into_iter().map(|(l, t, _)| (l, t)).collect()
|
||||
};
|
||||
assert_eq!(lines("Keywords=vector;illustrator;\n", |_| true), [(1, "illustrator")]);
|
||||
assert_eq!(lines("Like Adobe apps\nnot here: Adobe # brand-ok\n", |_| true), [(1, "adobe")]);
|
||||
let manifest = "name = \"adobe-thing\"\ndescription = \"Illustrator-style UI\"\n";
|
||||
assert_eq!(lines(manifest, |l| l.trim_start().starts_with("description")), [(2, "illustrator")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope() {
|
||||
assert_eq!(kind_of("crates/engine/src/cmd/file.rs"), Some(Kind::Rust));
|
||||
assert_eq!(kind_of("crates/engine/src/tests_file.rs"), None);
|
||||
assert_eq!(kind_of("crates/tools/src/text_tests.rs"), None);
|
||||
assert_eq!(kind_of("crates/color/src/cms/tests.rs"), None);
|
||||
assert_eq!(kind_of("crates/engine/tests/command_sweep.rs"), None);
|
||||
assert_eq!(kind_of(SELF), None);
|
||||
assert_eq!(kind_of("crates/ui-egui/Cargo.toml"), Some(Kind::Manifest));
|
||||
assert_eq!(kind_of("docs/mcp.md"), Some(Kind::Text));
|
||||
assert_eq!(kind_of("packaging/linux/nfpm.yaml"), Some(Kind::Text));
|
||||
for skipped in ["README.md", "ROADMAP.md", "CLAUDE.md", "AGENTS.md", ".github/workflows/release.yml", "ASSETS.md"] {
|
||||
assert_eq!(kind_of(skipped), None, "{skipped}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_strings_fail_the_gate() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-brands-{}", std::process::id()));
|
||||
let file = |p: &str, text: &str| {
|
||||
let path = dir.join(p);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
};
|
||||
file("crates/x/src/lib.rs", "pub const LABEL: &str = \"Export for Illustrator\"; // a comment: Adobe\n");
|
||||
file("docs/x.md", "Works like Photoshop.\n");
|
||||
file("packaging/x.desktop", "Keywords=vector;illustrator;\n");
|
||||
file("README.md", "Adobe trademark notice\n");
|
||||
let files: Vec<String> = ["crates/x/src/lib.rs", "docs/x.md", "packaging/x.desktop", "README.md"].map(String::from).to_vec();
|
||||
let hits = scan(&dir, &files);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let found: Vec<(&str, usize, &str)> = hits.iter().map(|h| (h.path.as_str(), h.line, h.term)).collect();
|
||||
assert_eq!(found, [("crates/x/src/lib.rs", 1, "illustrator"), ("docs/x.md", 1, "photoshop"), ("packaging/x.desktop", 1, "illustrator")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_is_clean() {
|
||||
let root = crate::root();
|
||||
// Outside a git checkout there is no file list to check.
|
||||
let Ok(files) = crate::repo_files(&root) else { return };
|
||||
let hits = scan(&root, &files);
|
||||
assert!(hits.is_empty(), "{hits:#?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -21,8 +21,14 @@ pub fn run(root: &Path) -> Result<(), String> {
|
|||
std::fs::copy(target.join("release/vectorcraft"), app.join("MacOS/VectorCraft")).map_err(|e| format!("copy app: {e}"))?;
|
||||
std::fs::copy(target.join("release/vectorcraft-cli"), app.join("MacOS/vectorcraft-cli")).map_err(|e| format!("copy cli: {e}"))?;
|
||||
std::fs::copy(root.join("assets/app-icon/vectorcraft.icns"), app.join("Resources/VectorCraft.icns")).map_err(|e| format!("copy icon: {e}"))?;
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let plist = format!(
|
||||
std::fs::write(app.join("Info.plist"), info_plist(env!("CARGO_PKG_VERSION"))).map_err(|e| e.to_string())?;
|
||||
println!("built {}", root.join("dist/VectorCraft.app").display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The development bundle's `Info.plist`.
|
||||
fn info_plist(version: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
|
|
@ -39,12 +45,52 @@ pub fn run(root: &Path) -> Result<(), String> {
|
|||
<key>CFBundleDocumentTypes</key><array>
|
||||
<dict><key>CFBundleTypeName</key><string>VectorCraft Document</string><key>CFBundleTypeExtensions</key><array><string>vectorcraft</string></array><key>CFBundleTypeRole</key><string>Editor</string></dict>
|
||||
<dict><key>CFBundleTypeName</key><string>SVG</string><key>CFBundleTypeExtensions</key><array><string>svg</string></array><key>CFBundleTypeRole</key><string>Editor</string></dict>
|
||||
<dict><key>CFBundleTypeName</key><string>PDF / Illustrator</string><key>CFBundleTypeExtensions</key><array><string>pdf</string><string>ai</string></array><key>CFBundleTypeRole</key><string>Viewer</string></dict>
|
||||
<dict><key>CFBundleTypeName</key><string>PDF Document</string><key>CFBundleTypeExtensions</key><array><string>pdf</string><string>ai</string></array><key>CFBundleTypeRole</key><string>Viewer</string></dict>
|
||||
</array>
|
||||
</dict></plist>
|
||||
"#
|
||||
);
|
||||
std::fs::write(app.join("Info.plist"), plist).map_err(|e| e.to_string())?;
|
||||
println!("built {}", root.join("dist/VectorCraft.app").display());
|
||||
Ok(())
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// File types and wording a raster image editor's packaging carries (lowercase).
|
||||
const RASTER_EDITOR: &[&str] = &["pcraft", "psd", "psb", "qoi", "photo", "rastergraphics", "image editor"];
|
||||
|
||||
fn text_files(dir: &Path, out: &mut Vec<(String, String)>) {
|
||||
for e in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
text_files(&p, out);
|
||||
} else if let Ok(t) = std::fs::read_to_string(&p) {
|
||||
out.push((p.display().to_string(), t.to_ascii_lowercase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packaging_describes_a_vector_app() {
|
||||
let mut files = vec![];
|
||||
text_files(&crate::root().join("packaging"), &mut files);
|
||||
assert!(files.len() > 5, "packaging files found");
|
||||
for (path, text) in &files {
|
||||
for w in RASTER_EDITOR {
|
||||
assert!(!text.contains(w), "{path} mentions `{w}`");
|
||||
}
|
||||
}
|
||||
let read = |p: &str| std::fs::read_to_string(crate::root().join(p)).unwrap();
|
||||
let plist = read("packaging/macos/Info.plist.in");
|
||||
assert!(plist.contains("<array><string>vectorcraft</string><string>drawcraft</string></array>"), "native format extensions");
|
||||
assert!(read("packaging/linux/ai.storyteller.vectorcraft.desktop").contains("Categories=Graphics;2DGraphics;VectorGraphics;"));
|
||||
assert!(read("packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in").contains("<category>VectorGraphics</category>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_bundle_plist_names_types_neutrally() {
|
||||
let p = info_plist("1.2.3");
|
||||
assert!(p.contains("<key>CFBundleShortVersionString</key><string>1.2.3</string>"));
|
||||
assert!(p.contains("<string>VectorCraft Document</string>") && p.contains("<string>PDF Document</string>"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
//! invoked through `std::process::Command`.
|
||||
|
||||
mod assets;
|
||||
mod brands;
|
||||
mod bundle;
|
||||
mod ico;
|
||||
mod layers;
|
||||
|
|
@ -18,9 +19,10 @@ usage: cargo xtask <command>
|
|||
|
||||
commands:
|
||||
assets check that every icon/image/font/asset is attributed in ASSETS.md
|
||||
brands check that user-visible text (labels, params docs, MCP, docs, packaging) names no other vendor's products
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
ci fmt --check, clippy -D warnings, test, assets, brands, layers, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
bundle build dist/VectorCraft.app (macOS) with assets/app-icon/vectorcraft.icns
|
||||
|
|
@ -34,6 +36,7 @@ fn main() -> ExitCode {
|
|||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("brands") => brands::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
|
|
@ -62,6 +65,19 @@ pub fn root() -> PathBuf {
|
|||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
|
||||
}
|
||||
|
||||
/// Tracked and untracked (not ignored) files that exist, relative to `root`.
|
||||
pub fn repo_files(root: &Path) -> Result<Vec<String>, String> {
|
||||
let out = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
|
||||
.output()
|
||||
.map_err(|e| format!("git ls-files: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("git ls-files failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect())
|
||||
}
|
||||
|
||||
pub fn cargo() -> Command {
|
||||
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
|
||||
c.current_dir(root());
|
||||
|
|
@ -176,6 +192,7 @@ fn cmd_ci() -> Result<(), String> {
|
|||
}),
|
||||
),
|
||||
("assets", Box::new(|| assets::run(&root()))),
|
||||
("brands", Box::new(|| brands::run(&root()))),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
("wasm", Box::new(cmd_wasm)),
|
||||
];
|
||||
|
|
|
|||
Loading…
Reference in New Issue