mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
When a document opens with fonts that aren't available, fonts used
only in its symbols included, VectorCraft lists them in a Missing
Fonts dialog after the missing linked file questions, one document at
a time, with Find Fonts and Close. Find Fonts opens Type › Find Font
on the first missing font of the layers' type, to replace it. After
Find Fonts or Close the dialog doesn't open again for that open; when
another dialog replaces it, it opens again once that dialog closes.
Search Fonts Folder (the Fonts folder File › Package writes next to
the document) and Find in Folder… search a folder and its subfolders
for the fonts' files, and Add Fonts copies the files the user chooses
into a Fonts folder next to the preferences. The desktop app and
vectorcraft-cli read that folder with the system's font folders. Type
› Find Font has a Find in Folder… button too when fonts the document
uses are missing. The web app shows no dialog; a status message says
how many fonts aren't available.
- text.missingFonts lists each missing family and style once,
whatever version the document's type names, and leaves out CSS
generic families such as sans-serif. text.findFontFiles searches a
folder on up to 8 threads and returns at once; text.findFontFiles {}
reports the search, and {stop: true} stops it. A search reads only
what the font scan reads of font files (their table directories and
name, fvar and OS/2 tables) and, on macOS, of suitcase fonts, and
lists a file for a font only when the file makes the font resolve
exactly. It does not enter app packages, the Program Files and
ProgramData folders, the system's folders at the root of a volume,
the Shared and Public folders in Users, a home folder's Library,
AppData, Applications, snap and dot folders, a Library folder's app
data folders or the folders the environment names for apps, and it
skips other hidden folders and does not follow links below the
picked folder. It ends after 60 s by default, after 10,000,000
entries listed, with more than 1,000,000 folders waiting, after
10,000 font files read or 1,000 kept, or once every font has a file;
Stop and Close end it too.
- text.addFontFiles copies only files the last search found. Of
those, it copies only regular .ttf, .otf, .ttc, .otc, .woff and
.woff2 files and suitcase fonts with their resource fork, at most
256 MB a file and 1 GB in all. It never replaces a file: a file with
the same contents is kept, and when another file has that name, the
copy gets a number (Name 2.otf). The fonts are then scanned again,
and type set in them redraws.
- ui.missingFontsDialog and ui.findFontsInFolder open the dialog for
agents; docs/control-protocol.md lists its fields.
- File › Package gives a font file whose name has no font extension
the extension that matches its data (.ttf, .otf or .ttc).
- vectorcraft-text adds set_app_font_dir, which system_font_dirs
reads, WantedFonts, which reads a font file's names as the font scan
does, is_font_file and is_suitcase. #762 split its face reader, which
now takes read budgets: file_face_names_within tries the suitcase
reader first, and reader_face_names_within applies the budgets to
each font. vectorcraft-format adds write_new_with.
Docs: docs/mcp.md, docs/control-protocol.md, ROADMAP.
332 lines
15 KiB
Rust
332 lines
15 KiB
Rust
//! `vectorcraft-cli`: VectorCraft from the command line.
|
|
//!
|
|
//! ```text
|
|
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
|
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
|
//! vectorcraft-cli commands
|
|
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--replace-lossy]
|
|
//! vectorcraft-cli info FILE
|
|
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
|
//! vectorcraft-cli perf [--paths 50000]
|
|
//! ```
|
|
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
|
|
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
|
|
#![deny(unsafe_code)]
|
|
|
|
use std::io::Write;
|
|
use std::process::ExitCode;
|
|
|
|
/// `println!` / `print!` that end the program quietly when stdout is closed
|
|
/// (`vectorcraft-cli commands | head`) instead of panicking with "failed printing to stdout:
|
|
/// Broken pipe (os error 32)".
|
|
macro_rules! outln {
|
|
($($arg:tt)*) => {{
|
|
use std::io::Write as _;
|
|
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
|
$crate::stdout_failed(e);
|
|
}
|
|
}};
|
|
}
|
|
macro_rules! out {
|
|
($($arg:tt)*) => {{
|
|
use std::io::Write as _;
|
|
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
|
|
$crate::stdout_failed(e);
|
|
}
|
|
}};
|
|
}
|
|
|
|
/// The desktop app's CoreText font lister, shared: exports and MCP find the same fonts.
|
|
#[cfg(target_os = "macos")]
|
|
#[path = "../../vectorcraft/src/mac_fonts.rs"]
|
|
mod mac_fonts;
|
|
mod perf;
|
|
/// The desktop app's settings folder, shared: exports and MCP read the same Fonts folder.
|
|
#[path = "../../vectorcraft/src/prefs_dir.rs"]
|
|
mod prefs_dir;
|
|
/// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts.
|
|
#[cfg(all(windows, not(target_vendor = "win7")))]
|
|
#[path = "../../vectorcraft/src/system_fonts.rs"]
|
|
mod system_fonts;
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
|
|
|
|
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
|
|
/// ripgrep does; any other write error is reported.
|
|
fn stdout_failed(e: std::io::Error) -> ! {
|
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
|
std::process::exit(0);
|
|
}
|
|
eprintln!("vectorcraft-cli: can't write to stdout: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
const USAGE: &str = "\
|
|
vectorcraft-cli — VectorCraft automation
|
|
|
|
USAGE:
|
|
vectorcraft-cli mcp [--connect ADDR | --headless]
|
|
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
|
|
(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 (any readable format) or start a new document, run commands in
|
|
order, export each FILE in the format its extension picks (see Writable formats). Prints one
|
|
JSON result per step.
|
|
|
|
vectorcraft-cli commands
|
|
Print the command catalogue as JSON.
|
|
|
|
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--replace-lossy]
|
|
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
|
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
|
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
|
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
|
|
SVG's data-* attributes; --outline-text writes SVG text as paths. OUT may not be IN when reading
|
|
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
|
|
--replace-lossy is given.
|
|
|
|
vectorcraft-cli info FILE
|
|
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
|
|
read from its preview and why), title, colour mode, units, artboards, object counts by
|
|
kind, fonts.
|
|
|
|
vectorcraft-cli bench FILE [--size WxH] [--iters N]
|
|
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]
|
|
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a
|
|
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
|
";
|
|
|
|
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
|
fn usage() -> String {
|
|
use vectorcraft_engine::cmd::fileio::{OPEN_EXTS, export_extensions};
|
|
format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().join(", ."))
|
|
}
|
|
|
|
fn main() -> ExitCode {
|
|
// Before any font lookup: the fonts font services load (#579).
|
|
#[cfg(all(windows, not(target_vendor = "win7")))]
|
|
system_fonts::install();
|
|
#[cfg(target_os = "macos")]
|
|
mac_fonts::install();
|
|
prefs_dir::install_fonts();
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
let r = match args.first().map(String::as_str) {
|
|
Some("mcp") => mcp(&args[1..]),
|
|
Some("run") => run(&args[1..]),
|
|
Some("commands") => commands(),
|
|
Some("convert") => convert(&args[1..]),
|
|
Some("info") => info(&args[1..]),
|
|
Some("bench") => bench(&args[1..]),
|
|
Some("perf") => perf::run(&args[1..]),
|
|
Some("-h" | "--help" | "help") | None => {
|
|
out!("{}", usage());
|
|
Ok(())
|
|
}
|
|
Some("-V" | "--version") => {
|
|
outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
|
Ok(())
|
|
}
|
|
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
|
|
};
|
|
match r {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(e) => {
|
|
eprintln!("vectorcraft-cli: {e}");
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mcp(args: &[String]) -> Result<(), String> {
|
|
let mut connect: Option<String> = None;
|
|
let mut headless = false;
|
|
let mut it = args.iter();
|
|
while let Some(a) = it.next() {
|
|
match a.as_str() {
|
|
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?),
|
|
"--headless" => headless = true,
|
|
other => return Err(format!("unknown mcp option `{other}`")),
|
|
}
|
|
}
|
|
if headless && connect.is_some() {
|
|
return Err("use either --connect or --headless".into());
|
|
}
|
|
let backend: Box<dyn Backend> = if headless {
|
|
Box::new(Headless::with_document())
|
|
} else if let Some(addr) = connect {
|
|
// Explicit address: fail loudly if the app isn't there.
|
|
Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?)
|
|
} else {
|
|
match Remote::connect(DEFAULT_ADDR) {
|
|
Ok(r) => Box::new(r),
|
|
Err(_) => Box::new(Headless::with_document()),
|
|
}
|
|
};
|
|
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
|
|
// The binary owns the logger, not the library: installing one here keeps an embedder that
|
|
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
|
|
vectorcraft_mcp::logging::install();
|
|
let stdin = std::io::stdin();
|
|
let stdout = std::io::stdout();
|
|
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn commands() -> Result<(), String> {
|
|
let mut h = Headless::new();
|
|
let v = h.call("engine.commands", json!({}))?;
|
|
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
|
Ok(())
|
|
}
|
|
|
|
fn convert(args: &[String]) -> Result<(), String> {
|
|
let mut files = vec![];
|
|
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy) = (1.0f64, None::<u64>, None::<String>, false, 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 = 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,
|
|
"--replace-lossy" => replace_lossy = true,
|
|
f => files.push(f.to_string()),
|
|
}
|
|
}
|
|
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
|
|
let mut h = Headless::new();
|
|
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
|
|
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
|
// PSD).
|
|
let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
|
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
|
outln!("{r}");
|
|
Ok(())
|
|
}
|
|
|
|
fn info(args: &[String]) -> Result<(), String> {
|
|
let file = args.first().ok_or("info needs a FILE")?;
|
|
let mut h = Headless::new();
|
|
let opened = h.call("app.open", json!({"path": file})).map_err(|e| format!("open {file}: {e}"))?;
|
|
let base = h.call("engine.execute", json!({"command": "file.info", "params": {}}))?;
|
|
let doc = h.session.doc().map_err(|e| e.to_string())?.doc.clone();
|
|
let mut kinds: std::collections::BTreeMap<&'static str, usize> = Default::default();
|
|
doc.walk(|n| *kinds.entry(n.kind_label()).or_default() += 1);
|
|
let fonts = h.call("engine.execute", json!({"command": "text.fonts", "params": {}})).unwrap_or(Value::Null);
|
|
let artboards: Vec<Value> =
|
|
doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect();
|
|
// What didn't come in as it was (an EPS read from its preview says why).
|
|
let v = json!({"file": file, "warnings": opened["warnings"], "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts});
|
|
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
|
Ok(())
|
|
}
|
|
|
|
enum Step {
|
|
Cmd(String, Value),
|
|
Export(String),
|
|
}
|
|
|
|
fn run(args: &[String]) -> Result<(), String> {
|
|
let mut input: Option<String> = None;
|
|
let mut steps: Vec<Step> = vec![];
|
|
let mut scale = 1.0;
|
|
let mut it = args.iter();
|
|
while let Some(a) = it.next() {
|
|
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
|
|
match a.as_str() {
|
|
"--in" | "-i" => input = Some(val("a file")?),
|
|
"--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))),
|
|
"--params" | "-p" => {
|
|
let raw = val("JSON")?;
|
|
let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?;
|
|
if !p.is_object() {
|
|
return Err(format!("--params must be a JSON object, got {raw}"));
|
|
}
|
|
match steps.last_mut() {
|
|
Some(Step::Cmd(_, params)) => *params = p,
|
|
_ => return Err("--params must follow a --cmd".into()),
|
|
}
|
|
}
|
|
"--export" | "-o" => steps.push(Step::Export(val("a file")?)),
|
|
"--scale" | "-s" => {
|
|
let raw = val("a number")?;
|
|
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
|
|
}
|
|
other => return Err(format!("unknown run option `{other}`")),
|
|
}
|
|
}
|
|
|
|
let mut h = Headless::new();
|
|
let mut out = std::io::stdout().lock();
|
|
let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string());
|
|
if let Some(path) = &input {
|
|
let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?;
|
|
emit(json!({"step": "open", "path": path, "result": r}))?;
|
|
} else if !matches!(steps.first(), Some(Step::Cmd(id, _)) if id == "file.new") {
|
|
h.ensure_document();
|
|
}
|
|
for step in steps {
|
|
match step {
|
|
Step::Cmd(id, params) => {
|
|
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
|
|
emit(json!({"step": "cmd", "command": id, "result": r}))?;
|
|
}
|
|
Step::Export(path) => {
|
|
let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?;
|
|
emit(json!({"step": "export", "result": r}))?;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn bench(args: &[String]) -> Result<(), String> {
|
|
let mut file = None;
|
|
let (mut w, mut h, mut iters) = (2880u32, 1800u32, 5u32);
|
|
let mut it = args.iter();
|
|
while let Some(a) = it.next() {
|
|
match a.as_str() {
|
|
"--size" => {
|
|
let v = it.next().ok_or("--size needs WxH")?;
|
|
let (a, b) = v.split_once('x').ok_or("--size needs WxH")?;
|
|
w = a.parse().map_err(|_| "bad width")?;
|
|
h = b.parse().map_err(|_| "bad height")?;
|
|
}
|
|
"--iters" => iters = it.next().and_then(|v| v.parse().ok()).ok_or("--iters needs a number")?,
|
|
f if file.is_none() => file = Some(f.to_string()),
|
|
other => return Err(format!("unknown bench option `{other}`")),
|
|
}
|
|
}
|
|
let file = file.ok_or("bench needs a FILE")?;
|
|
let mut hl = Headless::new();
|
|
hl.call("app.open", json!({ "path": file })).map_err(|e| format!("open {file}: {e}"))?;
|
|
let doc = hl.session.doc().map_err(|e| e.to_string())?.doc.clone();
|
|
let b = doc.artboards.first().map(|a| a.rect).ok_or("document has no artboard")?;
|
|
let z = (w as f64 / b.width()).min(h as f64 / b.height()) * 0.95;
|
|
let view = vectorcraft_geom::Affine::translate((w as f64 / 2.0, h as f64 / 2.0))
|
|
* vectorcraft_geom::Affine::scale(z)
|
|
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
|
|
let opts = vectorcraft_render::RenderOptions::default();
|
|
outln!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
|
for threads in [vectorcraft_render::default_threads(), 0] {
|
|
let mut r = vectorcraft_render::Renderer::new();
|
|
r.threads = threads;
|
|
r.render(&doc, w, h, view, &opts);
|
|
let t = std::time::Instant::now();
|
|
for _ in 0..iters {
|
|
r.render(&doc, w, h, view, &opts);
|
|
}
|
|
outln!(
|
|
" threads {threads}: {:.1} ms/frame (drawn {}, culled {})",
|
|
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
|
|
r.stats.drawn,
|
|
r.stats.culled
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|