mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-11 16:32:22 -06:00
Squash of PR #570 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7e437cb62d
commit
74e72c93f8
@@ -0,0 +1,674 @@
|
||||
//! Plotter configurations: PLOTTERMANAGER / -PLOTTERMANAGER, and the device and paper a page
|
||||
//! setup or a plot uses.
|
||||
//!
|
||||
//! The built-in devices (`CADCraft PDF`, `CADCraft PNG`, `CADCraft SVG`) come first. They offer
|
||||
//! the standard ISO and ANSI/ARCH paper sizes with 5 mm (ISO) or quarter-inch (inch sizes)
|
||||
//! margins. User devices are `*.plotter.json` files ([`cadcraft_doc::PlotterConfig`],
|
||||
//! `docs/plotters.md`) in a folder the host provides through [`set_plotter_store`]. Without
|
||||
//! one, native builds use the `plotters` folder of the CADCraft settings directory and the web
|
||||
//! keeps them for the session.
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use cadcraft_doc::{DevicePaper, Orientation, PageSetup, PlotOutput, PlotterConfig};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Session, Step};
|
||||
|
||||
/// Where plotter configuration files live. Names are file names ending in
|
||||
/// [`cadcraft_doc::PLOTTER_FILE_EXT`]; the engine never passes paths.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PlotterStore {
|
||||
/// File names in the folder.
|
||||
pub list: fn() -> std::result::Result<Vec<String>, String>,
|
||||
pub read: fn(&str) -> std::result::Result<String, String>,
|
||||
pub write: fn(&str, &str) -> std::result::Result<(), String>,
|
||||
pub delete: fn(&str) -> std::result::Result<(), String>,
|
||||
}
|
||||
|
||||
static STORE: OnceLock<PlotterStore> = OnceLock::new();
|
||||
static MEMORY: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Install the host's plotter folder (first call wins).
|
||||
pub fn set_plotter_store(s: PlotterStore) {
|
||||
let _ = STORE.set(s);
|
||||
}
|
||||
|
||||
impl PlotterStore {
|
||||
/// A folder kept in memory for the life of the process.
|
||||
pub fn memory() -> PlotterStore {
|
||||
fn lock() -> std::result::Result<std::sync::MutexGuard<'static, Vec<(String, String)>>, String> {
|
||||
MEMORY.lock().map_err(|_| "the plotter folder is unavailable".to_string())
|
||||
}
|
||||
PlotterStore {
|
||||
list: || Ok(lock()?.iter().map(|(n, _)| n.clone()).collect()),
|
||||
read: |n| lock()?.iter().find(|(f, _)| f == n).map(|(_, t)| t.clone()).ok_or_else(|| format!("{n}: no such file")),
|
||||
write: |n, t| {
|
||||
let mut m = lock()?;
|
||||
m.retain(|(f, _)| f != n);
|
||||
m.push((n.to_string(), t.to_string()));
|
||||
Ok(())
|
||||
},
|
||||
delete: |n| {
|
||||
let mut m = lock()?;
|
||||
let before = m.len();
|
||||
m.retain(|(f, _)| f != n);
|
||||
if m.len() == before { Err(format!("{n}: no such file")) } else { Ok(()) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The `plotters` folder of the CADCraft settings directory (`CADCRAFT_CONFIG_DIR`, else
|
||||
/// the platform's settings directory).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn settings_folder() -> PlotterStore {
|
||||
fn dir() -> std::result::Result<std::path::PathBuf, String> {
|
||||
use std::path::PathBuf;
|
||||
let get = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let root = match std::env::var_os("CADCRAFT_CONFIG_DIR") {
|
||||
Some(r) if !r.is_empty() => Some(PathBuf::from(r)),
|
||||
Some(_) => None,
|
||||
None => match std::env::consts::OS {
|
||||
"macos" => get("HOME").map(|h| h.join("Library/Application Support/CADCraft")),
|
||||
"windows" => get("APPDATA").map(|r| r.join("CADCraft")),
|
||||
_ => get("XDG_CONFIG_HOME").map(|r| r.join("cadcraft")).or_else(|| get("HOME").map(|h| h.join(".config/cadcraft"))),
|
||||
},
|
||||
};
|
||||
root.map(|r| r.join("plotters")).ok_or_else(|| "there is no settings folder for plotter configurations".to_string())
|
||||
}
|
||||
/// A plain file name of ours (no separators, no parent references).
|
||||
fn file(n: &str) -> std::result::Result<std::path::PathBuf, String> {
|
||||
let ok = n.ends_with(cadcraft_doc::PLOTTER_FILE_EXT) && !n.starts_with('.') && !n.contains(['/', '\\', ':']);
|
||||
if !ok {
|
||||
return Err(format!("`{n}` is not a plotter configuration file name"));
|
||||
}
|
||||
Ok(dir()?.join(n))
|
||||
}
|
||||
PlotterStore {
|
||||
list: || {
|
||||
let rd = match std::fs::read_dir(dir()?) {
|
||||
Ok(rd) => rd,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
Ok(rd
|
||||
.filter_map(|e| e.ok()?.file_name().into_string().ok())
|
||||
.filter(|n| n.ends_with(cadcraft_doc::PLOTTER_FILE_EXT))
|
||||
.take(MAX_DEVICES)
|
||||
.collect())
|
||||
},
|
||||
read: |n| {
|
||||
let p = file(n)?;
|
||||
let len = std::fs::metadata(&p).map_err(|e| format!("{n}: {e}"))?.len();
|
||||
if len > cadcraft_doc::MAX_PLOTTER_FILE as u64 {
|
||||
return Err(format!("{n}: the plotter configuration file is too large"));
|
||||
}
|
||||
std::fs::read_to_string(&p).map_err(|e| format!("{n}: {e}"))
|
||||
},
|
||||
write: |n, t| {
|
||||
let p = file(n)?;
|
||||
std::fs::create_dir_all(dir()?).map_err(|e| e.to_string())?;
|
||||
let tmp = p.with_extension("json.cadcraft-tmp");
|
||||
std::fs::write(&tmp, t).map_err(|e| format!("{n}: {e}"))?;
|
||||
std::fs::rename(&tmp, &p).map_err(|e| format!("{n}: {e}"))
|
||||
},
|
||||
delete: |n| std::fs::remove_file(file(n)?).map_err(|e| format!("{n}: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> PlotterStore {
|
||||
*STORE.get_or_init(|| {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
PlotterStore::settings_folder()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
PlotterStore::memory()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Most user devices listed.
|
||||
const MAX_DEVICES: usize = 1000;
|
||||
|
||||
/// The built-in devices: name, output, description.
|
||||
const BUILTIN: [(&str, PlotOutput, &str); 3] = [
|
||||
("CADCraft PDF", PlotOutput::Pdf, "Vector PDF"),
|
||||
("CADCraft PNG", PlotOutput::Png, "Raster PNG image"),
|
||||
("CADCraft SVG", PlotOutput::Svg, "Vector SVG image"),
|
||||
];
|
||||
|
||||
/// A page setup's "no device" name.
|
||||
const NO_DEVICE: &str = "None";
|
||||
|
||||
fn builtin(name: &str, output: PlotOutput, description: &str) -> PlotterConfig {
|
||||
let papers = cadcraft_render::PAPER_SIZES
|
||||
.iter()
|
||||
.map(|p| DevicePaper { name: p.name.into(), width: p.width_mm, height: p.height_mm, margins: p.margins_mm() })
|
||||
.collect();
|
||||
let default_paper = cadcraft_render::paper_size("A4").map(|p| p.name.to_string()).unwrap_or_default();
|
||||
PlotterConfig {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
output,
|
||||
resolution: 300,
|
||||
orientation: Orientation::Landscape,
|
||||
default_paper,
|
||||
papers,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtins() -> Vec<PlotterConfig> {
|
||||
BUILTIN.iter().map(|(n, o, d)| builtin(n, *o, d)).collect()
|
||||
}
|
||||
|
||||
fn is_builtin(name: &str) -> bool {
|
||||
BUILTIN.iter().any(|(n, _, _)| n.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// The user's devices (unreadable files are skipped), sorted by name.
|
||||
fn user_devices() -> Vec<PlotterConfig> {
|
||||
let st = store();
|
||||
let mut out: Vec<PlotterConfig> = (st.list)()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.take(MAX_DEVICES)
|
||||
.filter_map(|f| PlotterConfig::from_file_text(&(st.read)(f).ok()?).ok())
|
||||
.filter(|c| !is_builtin(&c.name))
|
||||
.collect();
|
||||
out.sort_by_key(|c| c.name.to_lowercase());
|
||||
out.dedup_by(|a, b| a.name.eq_ignore_ascii_case(&b.name));
|
||||
out
|
||||
}
|
||||
|
||||
/// Every device: the built-in ones first, then the user's.
|
||||
pub fn devices() -> Vec<PlotterConfig> {
|
||||
let mut v = builtins();
|
||||
v.extend(user_devices());
|
||||
v
|
||||
}
|
||||
|
||||
/// Device names, built-in ones first.
|
||||
pub fn device_names() -> Vec<String> {
|
||||
devices().into_iter().map(|c| c.name).collect()
|
||||
}
|
||||
|
||||
/// The device called `name` (any case; a `.plotter.json` suffix is ignored).
|
||||
pub fn find_device(name: &str) -> Option<PlotterConfig> {
|
||||
let n = name.trim();
|
||||
let n = n.strip_suffix(cadcraft_doc::PLOTTER_FILE_EXT).unwrap_or(n);
|
||||
if n.is_empty() || n.eq_ignore_ascii_case(NO_DEVICE) {
|
||||
return None;
|
||||
}
|
||||
if let Some((b, o, d)) = BUILTIN.iter().find(|(b, _, _)| b.eq_ignore_ascii_case(n)) {
|
||||
return Some(builtin(b, *o, d));
|
||||
}
|
||||
let st = store();
|
||||
let direct = cadcraft_doc::plotter_file_name(n).and_then(|f| PlotterConfig::from_file_text(&(st.read)(&f).ok()?).ok());
|
||||
direct.filter(|c| c.name.eq_ignore_ascii_case(n)).or_else(|| user_devices().into_iter().find(|c| c.name.eq_ignore_ascii_case(n)))
|
||||
}
|
||||
|
||||
/// The paper of `dev` called `name`, also by a standard size's short name (`A3`, `Letter`…).
|
||||
fn device_paper<'a>(dev: &'a PlotterConfig, name: &str) -> Option<&'a DevicePaper> {
|
||||
dev.paper(name).or_else(|| dev.paper(cadcraft_render::paper_size(name)?.name))
|
||||
}
|
||||
|
||||
fn set_paper(page: &mut PageSetup, p: &DevicePaper) {
|
||||
page.paper = p.name.clone();
|
||||
page.width_mm = p.width;
|
||||
page.height_mm = p.height;
|
||||
page.margins_mm = p.margins;
|
||||
}
|
||||
|
||||
fn unknown_device(cmd: &str, name: &str) -> EngineError {
|
||||
bad(cmd, format!("unknown plotter `{name}` (devices: {})", device_names().join(", ")))
|
||||
}
|
||||
|
||||
/// PAGESETUP's `device` and the device's papers. Sets `page.device` from `device` (`"None"`:
|
||||
/// no device) and, from the page's device, the paper: `paper` when the device has it, else on a
|
||||
/// device change the current paper if the device has it or the device's default paper, with the
|
||||
/// paper's printable area. A device change also takes the device's default orientation unless
|
||||
/// `landscape` is given. Returns whether the paper was set here (else `paper`, if any, is a
|
||||
/// standard size for the caller to look up).
|
||||
pub(crate) fn page_device(cmd: &str, p: &Value, page: &mut PageSetup) -> Result<bool> {
|
||||
let given = match p.get("device") {
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| bad(cmd, "`device` must be a plotter name"))?.trim()),
|
||||
None => None,
|
||||
};
|
||||
let dev = match given {
|
||||
Some(n) if n.is_empty() || n.eq_ignore_ascii_case(NO_DEVICE) => {
|
||||
page.device = NO_DEVICE.into();
|
||||
return Ok(false);
|
||||
}
|
||||
Some(n) => Some(find_device(n).ok_or_else(|| unknown_device(cmd, n))?),
|
||||
None => find_device(&page.device),
|
||||
};
|
||||
let Some(dev) = dev else { return Ok(false) };
|
||||
let switched = !page.device.eq_ignore_ascii_case(&dev.name);
|
||||
page.device = dev.name.clone();
|
||||
if switched && p.get("landscape").is_none() {
|
||||
page.landscape = dev.orientation == Orientation::Landscape;
|
||||
}
|
||||
if let Some(name) = p.get("paper") {
|
||||
let name = name.as_str().ok_or_else(|| bad(cmd, "`paper` must be a paper name"))?;
|
||||
return match device_paper(&dev, name) {
|
||||
Some(pp) => {
|
||||
set_paper(page, pp);
|
||||
Ok(true)
|
||||
}
|
||||
None if given.is_some() => {
|
||||
let known: Vec<&str> = dev.papers.iter().map(|x| x.name.as_str()).take(50).collect();
|
||||
Err(bad(cmd, format!("plotter `{}` has no paper `{name}` (papers: {})", dev.name, known.join(", "))))
|
||||
}
|
||||
None => Ok(false),
|
||||
};
|
||||
}
|
||||
if given.is_some() {
|
||||
if let Some(pp) = device_paper(&dev, &page.paper).or_else(|| dev.default_paper()) {
|
||||
set_paper(page, pp);
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// The options PLOT / EXPORTPDF hand the plotter with `device` and the device's papers
|
||||
/// resolved: `device` (else the layout's page setup device) picks the output (`output`: pdf,
|
||||
/// png or svg; EXPORTPDF always writes PDF) and `resolution`; a paper of the device becomes
|
||||
/// `width`/`height`/`margins` (mm) and, when not given, the device's orientation. Plots
|
||||
/// without a device are unchanged.
|
||||
pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let given = str_param(p, "device").map(str::trim);
|
||||
let layout = match str_param(p, "layout") {
|
||||
Some(n) if n.eq_ignore_ascii_case("model") => None,
|
||||
Some(n) => s.doc()?.layout(n).map(|l| l.page.clone()),
|
||||
None => match s.space() {
|
||||
cadcraft_doc::Space::Paper(n) => s.doc()?.layout(&n).map(|l| l.page.clone()),
|
||||
cadcraft_doc::Space::Model => None,
|
||||
},
|
||||
};
|
||||
let dev = match given {
|
||||
Some(n) if n.is_empty() || n.eq_ignore_ascii_case(NO_DEVICE) => None,
|
||||
Some(n) => Some(find_device(n).ok_or_else(|| unknown_device(cmd, n))?),
|
||||
None => layout.as_ref().and_then(|pg| find_device(&pg.device)),
|
||||
};
|
||||
let mut out = if p.is_object() { p.clone() } else { json!({}) };
|
||||
let (Some(dev), Some(o)) = (dev, out.as_object_mut()) else { return Ok(out) };
|
||||
o.remove("device");
|
||||
let output = if cmd == "exportpdf" { PlotOutput::Pdf } else { dev.output };
|
||||
o.insert("output".into(), json!(output.as_str()));
|
||||
o.insert("resolution".into(), json!(dev.resolution));
|
||||
let paper = match str_param(p, "paper") {
|
||||
Some(name) => match device_paper(&dev, name) {
|
||||
Some(pp) => Some(pp),
|
||||
None => {
|
||||
let known: Vec<&str> = dev.papers.iter().map(|x| x.name.as_str()).take(50).collect();
|
||||
return Err(bad(cmd, format!("plotter `{}` has no paper `{name}` (papers: {})", dev.name, known.join(", "))));
|
||||
}
|
||||
},
|
||||
// A layout keeps its page setup's paper; model space takes the device's default.
|
||||
None if layout.is_none() => dev.default_paper(),
|
||||
None => None,
|
||||
};
|
||||
if let Some(pp) = paper {
|
||||
o.remove("paper");
|
||||
o.insert("width".into(), json!(pp.width));
|
||||
o.insert("height".into(), json!(pp.height));
|
||||
o.insert("margins".into(), json!(pp.margins));
|
||||
if p.get("landscape").is_none() && layout.is_none() {
|
||||
o.insert("landscape".into(), json!(dev.orientation == Orientation::Landscape));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------- PLOTTERMANAGER ----------
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("plottermanager", "Plotter Manager...", run_plottermanager)
|
||||
.menu(&["File", "Plotter Manager..."])
|
||||
.params(
|
||||
"{action?: list (default)|get|new|set|delete|load|save, name?: device, from?: device to copy (new), output?: pdf|png|svg, description?, resolution?: dpi, orientation?: portrait|landscape, defaultPaper?, paper?: {name, width, height (mm), margins?: [l,b,r,t] mm} (set: add or replace), removePaper?: name (set), text? (load), path? (save: native only; without it returns `text`)} (typed: option prompts)",
|
||||
)
|
||||
.enabled(always)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(PlotterManagerM::default()))),
|
||||
CommandSpec::new("-plottermanager", "Plotter Manager", run_plottermanager)
|
||||
.params("as PLOTTERMANAGER (typed: option prompts)")
|
||||
.enabled(always)
|
||||
.noundo()
|
||||
.interactive(|_| Ok(Box::new(PlotterManagerM::default()))),
|
||||
]
|
||||
}
|
||||
|
||||
fn summary(c: &PlotterConfig) -> Value {
|
||||
json!({
|
||||
"name": c.name,
|
||||
"description": c.description,
|
||||
"output": c.output.as_str(),
|
||||
"builtin": is_builtin(&c.name),
|
||||
"papers": c.papers.len(),
|
||||
"defaultPaper": c.default_paper,
|
||||
})
|
||||
}
|
||||
|
||||
fn device_name(cmd: &str, p: &Value) -> Result<String> {
|
||||
let n = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`name` (a plotter name) is required"))?;
|
||||
Ok(n.chars().take(cadcraft_doc::MAX_PLOTTER_NAME).collect())
|
||||
}
|
||||
|
||||
/// Write a user device to the store.
|
||||
fn save_device(cmd: &str, c: &PlotterConfig) -> Result<()> {
|
||||
let file = cadcraft_doc::plotter_file_name(&c.name).ok_or_else(|| bad(cmd, "the plotter has no name"))?;
|
||||
(store().write)(&file, &c.to_file_text()).map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
fn mm_param(cmd: &str, v: &Value, key: &str) -> Result<Option<f64>> {
|
||||
match v.get(key) {
|
||||
None => Ok(None),
|
||||
Some(x) => {
|
||||
let f = x.as_f64().filter(|f| f.is_finite() && *f > 0.0 && *f <= cadcraft_doc::MAX_PAPER_MM);
|
||||
f.map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a size in mm, up to {}", cadcraft_doc::MAX_PAPER_MM)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the editable fields of `p` to `c`.
|
||||
fn edit_device(cmd: &str, c: &mut PlotterConfig, p: &Value) -> Result<()> {
|
||||
if let Some(d) = str_param(p, "description") {
|
||||
c.description = d.chars().take(1000).collect();
|
||||
}
|
||||
if let Some(o) = p.get("output") {
|
||||
c.output = o.as_str().and_then(PlotOutput::parse).ok_or_else(|| bad(cmd, "`output` must be pdf, png or svg"))?;
|
||||
}
|
||||
if let Some(r) = p.get("resolution") {
|
||||
let r = r.as_u64().filter(|r| (50..=2400).contains(r)).ok_or_else(|| bad(cmd, "`resolution` must be 50-2400 dpi"))?;
|
||||
c.resolution = u32::try_from(r).unwrap_or(300);
|
||||
}
|
||||
if let Some(o) = p.get("orientation") {
|
||||
c.orientation = match o.as_str().map(|s| s.trim().to_ascii_lowercase()).as_deref() {
|
||||
Some("portrait") => Orientation::Portrait,
|
||||
Some("landscape") => Orientation::Landscape,
|
||||
_ => return Err(bad(cmd, "`orientation` must be portrait or landscape")),
|
||||
};
|
||||
}
|
||||
if let Some(rm) = str_param(p, "removePaper") {
|
||||
let before = c.papers.len();
|
||||
let target = c.paper(rm).map(|x| x.name.clone());
|
||||
c.papers.retain(|x| Some(&x.name) != target.as_ref());
|
||||
if c.papers.len() == before {
|
||||
return Err(bad(cmd, format!("`{}` has no paper `{rm}`", c.name)));
|
||||
}
|
||||
}
|
||||
if let Some(pv) = p.get("paper") {
|
||||
let name = pv.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty());
|
||||
let name = name.ok_or_else(|| bad(cmd, "`paper.name` is required"))?;
|
||||
let existing = c.paper(name).cloned();
|
||||
let (w, h) = match (mm_param(cmd, pv, "width")?, mm_param(cmd, pv, "height")?, &existing) {
|
||||
(Some(w), Some(h), _) => (w, h),
|
||||
(None, None, Some(e)) => (e.width, e.height),
|
||||
_ => return Err(bad(cmd, "`paper.width` and `paper.height` (mm) are both required")),
|
||||
};
|
||||
let margins = match pv.get("margins") {
|
||||
None => existing.as_ref().map(|e| e.margins).unwrap_or([0.0; 4]),
|
||||
Some(m) => {
|
||||
let a: Vec<f64> = m.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
match a[..] {
|
||||
[l, b, r, t] if a.iter().all(|v| v.is_finite() && (0.0..=1000.0).contains(v)) && l + r < w.min(h) && b + t < w.max(h) => {
|
||||
[l, b, r, t]
|
||||
}
|
||||
_ => return Err(bad(cmd, "`paper.margins` must be [left, bottom, right, top] in mm, leaving a printable area")),
|
||||
}
|
||||
}
|
||||
};
|
||||
let new = DevicePaper { name: name.into(), width: w, height: h, margins };
|
||||
match existing.and_then(|e| c.papers.iter().position(|x| x.name == e.name)) {
|
||||
Some(i) => {
|
||||
if let Some(slot) = c.papers.get_mut(i) {
|
||||
*slot = new;
|
||||
}
|
||||
}
|
||||
None if c.papers.len() >= cadcraft_doc::MAX_DEVICE_PAPERS => return Err(bad(cmd, "the plotter has too many papers")),
|
||||
None => c.papers.push(new),
|
||||
}
|
||||
}
|
||||
if let Some(d) = str_param(p, "defaultPaper") {
|
||||
let n = c.paper(d).map(|x| x.name.clone()).ok_or_else(|| bad(cmd, format!("`{}` has no paper `{d}`", c.name)))?;
|
||||
c.default_paper = n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_plottermanager(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "plottermanager";
|
||||
let action = str_param(p, "action").unwrap_or("list").trim().to_ascii_lowercase();
|
||||
match action.as_str() {
|
||||
"list" => Ok(json!({ "devices": devices().iter().map(summary).collect::<Vec<_>>() })),
|
||||
"get" => {
|
||||
let n = device_name(cmd, p)?;
|
||||
let c = find_device(&n).ok_or_else(|| unknown_device(cmd, &n))?;
|
||||
let mut v = serde_json::to_value(&c).unwrap_or(Value::Null);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("builtin".into(), json!(is_builtin(&c.name)));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
"new" => {
|
||||
let name = device_name(cmd, p)?;
|
||||
if find_device(&name).is_some() {
|
||||
return Err(bad(cmd, format!("plotter `{name}` already exists")));
|
||||
}
|
||||
let file = cadcraft_doc::plotter_file_name(&name).ok_or_else(|| bad(cmd, "`name` is empty"))?;
|
||||
if (store().list)().unwrap_or_default().iter().any(|f| f.eq_ignore_ascii_case(&file)) {
|
||||
return Err(bad(cmd, format!("a plotter file `{file}` already exists")));
|
||||
}
|
||||
let mut c = match str_param(p, "from") {
|
||||
Some(f) => find_device(f).ok_or_else(|| unknown_device(cmd, f))?,
|
||||
None => {
|
||||
// A copy of the built-in device for the output: the standard papers.
|
||||
let output = match p.get("output") {
|
||||
Some(o) => o.as_str().and_then(PlotOutput::parse).ok_or_else(|| bad(cmd, "`output` must be pdf, png or svg"))?,
|
||||
None => PlotOutput::Pdf,
|
||||
};
|
||||
let (b, o, _) = BUILTIN.iter().find(|(_, o, _)| *o == output).copied().unwrap_or(("CADCraft PDF", PlotOutput::Pdf, ""));
|
||||
builtin(b, o, "")
|
||||
}
|
||||
};
|
||||
c.name = name;
|
||||
c.description = String::new();
|
||||
edit_device(cmd, &mut c, p)?;
|
||||
let c = c.sanitized();
|
||||
save_device(cmd, &c)?;
|
||||
Ok(summary(&c))
|
||||
}
|
||||
"set" => {
|
||||
let name = device_name(cmd, p)?;
|
||||
if is_builtin(&name) {
|
||||
return Err(bad(cmd, format!("`{name}` is built in: make a copy with action new, from `{name}`")));
|
||||
}
|
||||
let mut c = find_device(&name).ok_or_else(|| unknown_device(cmd, &name))?;
|
||||
edit_device(cmd, &mut c, p)?;
|
||||
if c.papers.is_empty() {
|
||||
return Err(bad(cmd, "a plotter needs at least one paper"));
|
||||
}
|
||||
let c = c.sanitized();
|
||||
save_device(cmd, &c)?;
|
||||
Ok(serde_json::to_value(&c).unwrap_or(Value::Null))
|
||||
}
|
||||
"delete" => {
|
||||
let name = device_name(cmd, p)?;
|
||||
if is_builtin(&name) {
|
||||
return Err(bad(cmd, format!("`{name}` is built in and can't be deleted")));
|
||||
}
|
||||
let c = find_device(&name).ok_or_else(|| unknown_device(cmd, &name))?;
|
||||
let file = cadcraft_doc::plotter_file_name(&c.name).ok_or_else(|| bad(cmd, "the plotter has no name"))?;
|
||||
(store().delete)(&file).map_err(|e| bad(cmd, e))?;
|
||||
Ok(json!({ "deleted": c.name }))
|
||||
}
|
||||
"load" => {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad(cmd, "`text` (the file's contents) is required"))?;
|
||||
let mut c = PlotterConfig::from_file_text(text).map_err(|e| bad(cmd, e))?;
|
||||
if let Some(n) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
c.name = n.chars().take(cadcraft_doc::MAX_PLOTTER_NAME).collect();
|
||||
}
|
||||
if is_builtin(&c.name) {
|
||||
return Err(bad(cmd, format!("`{}` is a built-in plotter's name: pass another `name`", c.name)));
|
||||
}
|
||||
if c.papers.is_empty() {
|
||||
return Err(bad(cmd, "the plotter configuration has no papers"));
|
||||
}
|
||||
save_device(cmd, &c)?;
|
||||
Ok(summary(&c))
|
||||
}
|
||||
"save" => {
|
||||
let name = device_name(cmd, p)?;
|
||||
let text = find_device(&name).ok_or_else(|| unknown_device(cmd, &name))?.to_file_text();
|
||||
match str_param(p, "path") {
|
||||
None => Ok(json!({ "name": name, "text": text })),
|
||||
Some(_path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::write(_path, &text).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
Ok(json!({ "name": name, "path": _path }))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad(cmd, "paths are not available on the web; omit `path` to get the file as `text`"))
|
||||
}
|
||||
}
|
||||
}
|
||||
other => Err(bad(cmd, format!("unknown action `{other}` (list, get, new, set, delete, load, save)"))),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- -PLOTTERMANAGER prompts ----------
|
||||
|
||||
const OPTIONS: &[&str] = &["?", "New", "Delete", "Paper"];
|
||||
const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true };
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Option,
|
||||
NewName,
|
||||
NewOutput(String),
|
||||
Delete,
|
||||
PaperDevice,
|
||||
PaperName(String),
|
||||
PaperSize(String, String),
|
||||
}
|
||||
|
||||
/// PLOTTERMANAGER typed: list the devices (`?`), create one (`New`: name, output), delete one
|
||||
/// or add a paper size to one (`Paper`: device, paper name, `width,height` in mm).
|
||||
#[derive(Default)]
|
||||
pub struct PlotterManagerM {
|
||||
stage: Stage,
|
||||
}
|
||||
|
||||
fn typed(i: &Input) -> Option<String> {
|
||||
match i {
|
||||
Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A command refusal as a re-prompt message.
|
||||
fn retry(e: EngineError) -> EngineError {
|
||||
match e {
|
||||
EngineError::BadParams { msg, .. } => EngineError::Other(msg),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for PlotterManagerM {
|
||||
fn name(&self) -> &'static str {
|
||||
"PLOTTERMANAGER"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match &self.stage {
|
||||
Stage::Option => Prompt::new("Enter a plotter option", KW).kw(OPTIONS).default("?"),
|
||||
Stage::NewName => Prompt::new("Enter a name for the new plotter", Accept::TEXT),
|
||||
Stage::NewOutput(_) => Prompt::new("Enter the output", KW).kw(&["Pdf", "PNg", "Svg"]).default("Pdf"),
|
||||
Stage::Delete => Prompt::new("Enter the plotter to delete", Accept::TEXT),
|
||||
Stage::PaperDevice => Prompt::new("Enter the plotter to add a paper size to", Accept::TEXT),
|
||||
Stage::PaperName(_) => Prompt::new("Enter the paper name", Accept::TEXT),
|
||||
Stage::PaperSize(..) => Prompt::new("Enter the paper width,height in mm", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if i == Input::Cancel {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
let t = typed(&i);
|
||||
let need = |t: Option<String>| t.ok_or_else(|| EngineError::Other("Requires a name.".into()));
|
||||
match self.stage.clone() {
|
||||
Stage::Option => {
|
||||
let k = match i {
|
||||
Input::Enter => "?".to_string(),
|
||||
Input::Keyword(k) => k,
|
||||
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
};
|
||||
self.stage = match k.as_str() {
|
||||
"New" => Stage::NewName,
|
||||
"Delete" => Stage::Delete,
|
||||
"Paper" => Stage::PaperDevice,
|
||||
_ => {
|
||||
for c in devices() {
|
||||
let kind = if is_builtin(&c.name) { "built-in" } else { "user" };
|
||||
s.echo(format!("{} ({}, {kind}, {} paper sizes)", c.name, c.output.as_str().to_uppercase(), c.papers.len()));
|
||||
}
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::NewName => {
|
||||
let n = need(t)?;
|
||||
if find_device(&n).is_some() {
|
||||
return Err(EngineError::Other(format!("Plotter \"{n}\" already exists.")));
|
||||
}
|
||||
self.stage = Stage::NewOutput(n);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::NewOutput(name) => {
|
||||
let output = t.unwrap_or_else(|| "pdf".into());
|
||||
run_plottermanager(s, &json!({ "action": "new", "name": name, "output": output })).map_err(retry)?;
|
||||
s.echo(format!("Plotter \"{name}\" created."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Stage::Delete => {
|
||||
let n = need(t)?;
|
||||
let r = run_plottermanager(s, &json!({ "action": "delete", "name": n })).map_err(retry)?;
|
||||
s.echo(format!("Plotter \"{}\" deleted.", r.get("deleted").and_then(Value::as_str).unwrap_or_default()));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Stage::PaperDevice => {
|
||||
let n = need(t)?;
|
||||
let c = find_device(&n).ok_or_else(|| EngineError::Other(format!("Plotter \"{n}\" not found.")))?;
|
||||
if is_builtin(&c.name) {
|
||||
return Err(EngineError::Other(format!("\"{}\" is built in; create a new plotter first.", c.name)));
|
||||
}
|
||||
self.stage = Stage::PaperName(c.name);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::PaperName(dev) => {
|
||||
self.stage = Stage::PaperSize(dev, need(t)?);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::PaperSize(dev, paper) => {
|
||||
let text = need(t)?;
|
||||
let nums: Vec<f64> = text.split(',').map(|x| crate::units::parse_distance(x.trim())).collect::<Option<Vec<_>>>().unwrap_or_default();
|
||||
let [w, h] = nums[..] else {
|
||||
return Err(EngineError::Other("Enter width,height in mm.".into()));
|
||||
};
|
||||
let p = json!({ "action": "set", "name": dev, "paper": { "name": paper, "width": w, "height": h } });
|
||||
run_plottermanager(s, &p).map_err(retry)?;
|
||||
s.echo(format!("Paper \"{paper}\" added to \"{dev}\"."));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user