Merge remote-tracking branch 'origin/main' into pr1678

# Conflicts:
#	apps/designcraft-cli/src/main.rs
This commit is contained in:
Brandon Thomas
2026-10-24 10:03:25 -04:00
147 changed files with 9030 additions and 2163 deletions
+35 -30
View File
@@ -39,7 +39,7 @@ use designcraft_engine::Session;
use serde_json::{Value, json};
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
@@ -124,7 +124,7 @@ fn main() -> ExitCode {
Some("bench") => report(perf::bench(&args[1..])),
Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))),
Some("links") => {
use designcraft_engine::links::*;
use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE};
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");
ExitCode::SUCCESS
}
@@ -264,7 +264,7 @@ impl Export {
/// A name with another extension is an error: it used to get a PNG of the page. One with no
/// extension still does (`--export /dev/stdout`).
fn of(out: &str) -> Result<Self, String> {
let ext = std::path::Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_lowercase);
let ext = std::path::Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_lowercase);
Ok(match ext.as_deref() {
None | Some("png" | "jpg" | "jpeg") => Export::Image,
Some("pdf") => Export::Pdf,
@@ -346,33 +346,7 @@ fn run(args: &[String]) -> Result<(), String> {
}
"--export" => {
let out = val()?;
match Export::of(&out)? {
Export::Image => export(&mut s, &out, page, scale)?,
Export::Pdf => {
let mut p = pdf_opts.clone();
p["path"] = json!(out);
let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
}
Export::Document => {
s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("saved {out}");
}
Export::Pptx => {
let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
}
Export::Command(cmd) => {
let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
}
}
handle_export(&mut s, &out, page, scale, &pdf_opts)?;
}
"--all-pages" => {
let dir = val()?;
@@ -388,6 +362,37 @@ fn run(args: &[String]) -> Result<(), String> {
Ok(())
}
fn handle_export(s: &mut Session, out: &str, page: usize, scale: f64, pdf_opts: &Value) -> Result<(), String> {
match Export::of(out)? {
Export::Image => export(s, out, page, scale)?,
Export::Pdf => {
let mut p = pdf_opts.clone();
p["path"] = json!(out);
let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
}
Export::Document => {
s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("saved {out}");
}
Export::Pptx => {
let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]);
for w in r["warnings"].as_array().into_iter().flatten() {
eprintln!("warning: {}", w.as_str().unwrap_or_default());
}
}
Export::Command(cmd) => {
let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?;
eprintln!("wrote {out} ({} bytes)", r["bytes"]);
}
}
Ok(())
}
/// A page as PNG, or as JPEG for `.jpg` and `.jpeg`.
fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> {
let st = s.doc().map_err(|e| e.to_string())?;
+2 -2
View File
@@ -335,7 +335,7 @@ fn row(rows: &mut Vec<Row>, name: impl Into<String>, ms: f64, budget: Option<f64
/// Measure `doc`. `runs` = repetitions per row (median).
fn measure(doc: &Document, runs: usize) -> Result<Vec<Row>, String> {
let mut rows = vec![];
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let chars: usize = doc.stories.values().map(|s| s.text.len()).sum();
// Budget: 250 ms per 300k characters (MT), scaled to this document.
let compose_budget = 250.0 * (chars as f64 / 300_000.0).max(1.0);
@@ -532,7 +532,7 @@ fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> {
}
fn header() -> (usize, bool) {
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let load = load_average();
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
outln!(