From 74e72c93f86efce0a44df42435fa27e8501f7107 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 19:25:00 +0200 Subject: [PATCH] Plotters: device configurations with paper sizes and printable areas (fixes #564) (#570) Squash of PR #570 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- apps/cadcraft-cli/src/main.rs | 2 +- apps/cadcraft-web/src/main.rs | 2 +- apps/cadcraft/src/main.rs | 2 +- crates/doc/src/lib.rs | 2 + crates/doc/src/plotter.rs | 253 +++++++++++ crates/engine/src/cmd/layout.rs | 13 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/plotters.rs | 674 ++++++++++++++++++++++++++++++ crates/engine/tests/plotters.rs | 174 ++++++++ crates/io/src/lib.rs | 1 + crates/io/src/plotter.rs | 166 ++++++++ crates/render/src/paper.rs | 15 +- crates/ui-egui/src/i18n/uk.tsv | 2 + docs/plotters.md | 108 +++++ 14 files changed, 1407 insertions(+), 8 deletions(-) create mode 100644 crates/doc/src/plotter.rs create mode 100644 crates/engine/src/cmd/plotters.rs create mode 100644 crates/engine/tests/plotters.rs create mode 100644 crates/io/src/plotter.rs create mode 100644 docs/plotters.md diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 25099da3..a08e7414 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -166,7 +166,7 @@ fn install_io() { cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), - plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())), + plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), }); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 3d1f4a25..edddc0cf 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -74,7 +74,7 @@ mod web { cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), - plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())), + plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), }); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 79c8b974..4efb8f00 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -95,7 +95,7 @@ pub fn install_io() { cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), - plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())), + plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())), }); cadcraft_engine::cmd::file::set_write_as(|d, name, o| { cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string()) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b8a1592c..9d57324b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -14,6 +14,7 @@ mod gdt; mod header; pub mod library; mod plotstyle; +mod plotter; mod store; mod tables; @@ -28,6 +29,7 @@ pub use extents::{MAX_BLOCK_DEPTH, entity_bounds}; pub use gdt::*; pub use header::{HVal, Header}; pub use plotstyle::*; +pub use plotter::*; pub use store::EntityStore; pub use tables::*; diff --git a/crates/doc/src/plotter.rs b/crates/doc/src/plotter.rs new file mode 100644 index 00000000..abcdb990 --- /dev/null +++ b/crates/doc/src/plotter.rs @@ -0,0 +1,253 @@ +//! Plotter configurations: output devices with their paper sizes and printable areas. +//! +//! A configuration is a file in our own format (`*.plotter.json`, UTF-8 JSON with a format +//! marker, see `docs/plotters.md`). It names the output (PDF, PNG or SVG), the paper sizes the +//! device offers (each with the margins that bound its printable area), the raster resolution +//! and the default orientation. Binary `.pc3` files are not read. + +use serde::{Deserialize, Serialize}; + +/// File-name suffix of plotter configuration files. +pub const PLOTTER_FILE_EXT: &str = ".plotter.json"; +/// Largest plotter configuration file read (bytes). +pub const MAX_PLOTTER_FILE: usize = 1 << 20; +/// Most paper sizes one device keeps. +pub const MAX_DEVICE_PAPERS: usize = 512; +/// Longest device or paper name (characters). +pub const MAX_PLOTTER_NAME: usize = 100; +/// Largest paper side (mm), as for page setups. +pub const MAX_PAPER_MM: f64 = 5080.0; + +const FILE_MARKER: &str = "cadcraftPlotter"; +const FILE_VERSION: u64 = 1; + +/// What a device writes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PlotOutput { + #[default] + Pdf, + Png, + Svg, +} + +impl PlotOutput { + pub fn as_str(self) -> &'static str { + match self { + PlotOutput::Pdf => "pdf", + PlotOutput::Png => "png", + PlotOutput::Svg => "svg", + } + } + /// Parse `pdf`, `png` or `svg` (any case). + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "pdf" => Some(PlotOutput::Pdf), + "png" => Some(PlotOutput::Png), + "svg" => Some(PlotOutput::Svg), + _ => None, + } + } +} + +/// Default sheet orientation of a device. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Orientation { + Portrait, + #[default] + Landscape, +} + +/// A paper size a device offers: size in millimetres (portrait: width <= height after +/// sanitizing) and margins (left, bottom, right, top; mm) bounding the printable area. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DevicePaper { + pub name: String, + pub width: f64, + pub height: f64, + pub margins: [f64; 4], +} + +impl Default for DevicePaper { + fn default() -> Self { + DevicePaper { name: String::new(), width: 210.0, height: 297.0, margins: [0.0; 4] } + } +} + +impl DevicePaper { + /// The paper with hostile values fixed: finite sizes within 1..=[`MAX_PAPER_MM`] mm + /// (portrait), margins 0..=1000 mm that leave a printable area (else none), a name of at + /// most [`MAX_PLOTTER_NAME`] characters. + pub fn sanitized(mut self) -> DevicePaper { + let side = |v: f64| if v.is_finite() { v.clamp(1.0, MAX_PAPER_MM) } else { 1.0 }; + let (w, h) = (side(self.width), side(self.height)); + self.width = w.min(h); + self.height = w.max(h); + for m in &mut self.margins { + *m = if m.is_finite() { m.clamp(0.0, 1000.0) } else { 0.0 }; + } + let [l, b, r, t] = self.margins; + if l + r >= self.width || b + t >= self.height { + self.margins = [0.0; 4]; + } + self.name = clean_name(&self.name); + if self.name.is_empty() { + self.name = format!("User ({:.2} x {:.2} MM)", self.width, self.height); + } + self + } +} + +/// A plotter configuration. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PlotterConfig { + pub name: String, + pub description: String, + pub output: PlotOutput, + /// Raster resolution (dots per inch) for PNG output. + pub resolution: u32, + pub orientation: Orientation, + /// Name of the paper chosen when a page setup switches to this device. + pub default_paper: String, + pub papers: Vec, +} + +impl Default for PlotterConfig { + fn default() -> Self { + PlotterConfig { + name: String::new(), + description: String::new(), + output: PlotOutput::Pdf, + resolution: 300, + orientation: Orientation::Landscape, + default_paper: String::new(), + papers: Vec::new(), + } + } +} + +/// A name trimmed, without control characters, at most [`MAX_PLOTTER_NAME`] characters. +fn clean_name(s: &str) -> String { + s.trim().chars().filter(|c| !c.is_control()).take(MAX_PLOTTER_NAME).collect::().trim().to_string() +} + +/// Names compare ignoring case, spaces, `_` and `-`. +fn key(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').collect::().to_ascii_lowercase() +} + +impl PlotterConfig { + /// The configuration with hostile values fixed: a clean name and description, resolution + /// 50..=2400 dpi, at most [`MAX_DEVICE_PAPERS`] sanitized papers with unique names, and a + /// default paper the device has (the first one when it names none of them). + pub fn sanitized(mut self) -> PlotterConfig { + self.name = clean_name(&self.name); + self.description = self.description.chars().filter(|c| !c.is_control() || *c == '\n').take(1000).collect(); + self.resolution = self.resolution.clamp(50, 2400); + let mut papers: Vec = Vec::new(); + for p in std::mem::take(&mut self.papers).into_iter().take(MAX_DEVICE_PAPERS) { + let p = p.sanitized(); + if !papers.iter().any(|q| key(&q.name) == key(&p.name)) { + papers.push(p); + } + } + self.papers = papers; + self.default_paper = match self.paper(&self.default_paper) { + Some(p) => p.name.clone(), + None => self.papers.first().map(|p| p.name.clone()).unwrap_or_default(), + }; + self + } + + /// The paper called `name` (ignoring case, spaces, `_` and `-`). + pub fn paper(&self, name: &str) -> Option<&DevicePaper> { + let k = key(name); + if k.is_empty() { + return None; + } + self.papers.iter().find(|p| key(&p.name) == k) + } + + /// The paper a page setup gets when it switches to this device. + pub fn default_paper(&self) -> Option<&DevicePaper> { + self.paper(&self.default_paper).or_else(|| self.papers.first()) + } + + /// The configuration (sanitized) as a file in our own format: pretty-printed JSON with a + /// format marker. + pub fn to_file_text(&self) -> String { + let mut v = serde_json::to_value(self.clone().sanitized()).unwrap_or(serde_json::Value::Null); + if let Some(o) = v.as_object_mut() { + o.insert(FILE_MARKER.into(), FILE_VERSION.into()); + } + serde_json::to_string_pretty(&v).unwrap_or_default() + } + + /// Read a file written by [`PlotterConfig::to_file_text`] (sanitized; a name is required). + pub fn from_file_text(text: &str) -> std::result::Result { + if text.len() > MAX_PLOTTER_FILE { + return Err("the plotter configuration file is too large".into()); + } + let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("not a CADCraft plotter configuration: {e}"))?; + match v.get(FILE_MARKER).and_then(serde_json::Value::as_u64) { + Some(FILE_VERSION) => {} + Some(n) => return Err(format!("plotter configuration format version {n} is newer than this CADCraft reads")), + None => return Err(format!("not a CADCraft plotter configuration (no `{FILE_MARKER}`)")), + } + let c: PlotterConfig = serde_json::from_value(v).map_err(|e| format!("bad plotter configuration: {e}"))?; + let c = c.sanitized(); + if c.name.is_empty() { + return Err("the plotter configuration has no name".into()); + } + Ok(c) + } +} + +/// The file name a device is kept under: its name with characters that can't appear in file +/// names replaced by `_`, plus [`PLOTTER_FILE_EXT`]. `None` for an empty name. +pub fn plotter_file_name(name: &str) -> Option { + let base: String = + clean_name(name).chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '(' | ')' | '+' | ',') { c } else { '_' }).collect(); + let base = base.trim().trim_start_matches('.'); + (!base.is_empty()).then(|| format!("{base}{PLOTTER_FILE_EXT}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_and_hostile_values() { + let c = PlotterConfig { + name: "Office A3".into(), + output: PlotOutput::Png, + resolution: 1_000_000, + default_paper: "nope".into(), + papers: vec![ + DevicePaper { name: "A3 borderless".into(), width: 420.0, height: 297.0, margins: [0.0; 4] }, + DevicePaper { name: "a3-BORDERLESS".into(), ..DevicePaper::default() }, + DevicePaper { name: String::new(), width: f64::NAN, height: 1e12, margins: [f64::INFINITY, 9e9, -1.0, 5.0] }, + ], + ..PlotterConfig::default() + }; + let back = PlotterConfig::from_file_text(&c.to_file_text()).unwrap(); + assert_eq!(back.resolution, 2400); + assert_eq!(back.papers.len(), 2, "duplicate names dropped"); + assert_eq!((back.papers[0].width, back.papers[0].height), (297.0, 420.0), "portrait"); + assert_eq!(back.default_paper, "A3 borderless"); + assert_eq!(back.papers[1].width, 1.0); + assert_eq!(back.papers[1].height, MAX_PAPER_MM); + assert_eq!(back.papers[1].margins, [0.0, 1000.0, 0.0, 5.0], "hostile margins clamped"); + let wide = DevicePaper { margins: [150.0, 0.0, 100.0, 0.0], ..DevicePaper::default() }.sanitized(); + assert_eq!(wide.margins, [0.0; 4], "margins wider than the paper are dropped"); + assert!(back.paper("a3 BORDERLESS").is_some()); + assert!(PlotterConfig::from_file_text("{}").is_err()); + assert!(PlotterConfig::from_file_text(r#"{"cadcraftPlotter": 1}"#).is_err(), "needs a name"); + assert!(PlotterConfig::from_file_text(r#"{"cadcraftPlotter": 9, "name": "x"}"#).is_err()); + assert_eq!(plotter_file_name("../a/b:c").as_deref(), Some("___a_b_c.plotter.json")); + assert_eq!(plotter_file_name(" "), None); + } +} diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 426a73b7..bda83d47 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -74,15 +74,15 @@ pub fn specs() -> Vec { CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup) .menu(&["File", "Page Setup Manager..."]) .params( - "{layout?: current, paper?: \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool}", + "{layout?: current, device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's paper | \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool}", ), - CommandSpec::new("plot", "Print...", |s, p| run_plot(s, p, "plot")) + CommandSpec::new("plot", "Print...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "plot")?, "plot")) .menu(&["File", "Print..."]) .key("Cmd+P") .alias(&["print"]) .params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", lineweights?: bool, plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)") .noundo(), - CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, p, "exportpdf")) + CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf")) .menu(&["File", "Export to PDF..."]) .params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, lineweights?, plotArea?, window?} (as PLOT)") .noundo(), @@ -94,6 +94,7 @@ pub fn specs() -> Vec { ] .into_iter() .chain(plotstyle::specs()) + .chain(super::plotters::specs()) .collect() } @@ -620,6 +621,7 @@ fn run_viewport_set(s: &mut Session, p: &Value) -> Result { fn run_pagesetup(s: &mut Session, p: &Value) -> Result { let cmd = "pagesetup"; let keys = [ + "device", "paper", "width", "height", @@ -649,10 +651,13 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result { "layouts": d.layouts.iter().map(|l| json!({"name": l.name, "page": serde_json::to_value(&l.page).unwrap_or(Value::Null)})).collect::>(), "papers": cadcraft_render::PAPER_SIZES.iter().map(|p| p.name).collect::>(), "plotStyleTables": cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES, + "devices": super::plotters::device_names(), })); }; let mut page = s.doc()?.layout(&name).map(|l| l.page.clone()).unwrap_or_default(); - if let Some(paper) = p.get("paper") { + // A plotter's own papers (with their printable areas) come first. + let device_paper = super::plotters::page_device(cmd, p, &mut page)?; + if let Some(paper) = p.get("paper").filter(|_| !device_paper) { let n = paper.as_str().ok_or_else(|| bad(cmd, "`paper` must be a paper name"))?; let ps = cadcraft_render::paper_size(n).ok_or_else(|| { let known: Vec<&str> = cadcraft_render::PAPER_SIZES.iter().filter_map(|p| p.aliases.first().copied()).collect(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4130840d..488ef73b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -27,6 +27,7 @@ mod modify2; mod mtext_opts; mod overkill; mod pline_opts; +pub mod plotters; mod props; pub use props::MatchProps; mod qselect; diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs new file mode 100644 index 00000000..48bedcb3 --- /dev/null +++ b/crates/engine/src/cmd/plotters.rs @@ -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, String>, + pub read: fn(&str) -> std::result::Result, + pub write: fn(&str, &str) -> std::result::Result<(), String>, + pub delete: fn(&str) -> std::result::Result<(), String>, +} + +static STORE: OnceLock = OnceLock::new(); +static MEMORY: Mutex> = 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>, 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 { + 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 { + 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 { + 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 { + let st = store(); + let mut out: Vec = (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 { + let mut v = builtins(); + v.extend(user_devices()); + v +} + +/// Device names, built-in ones first. +pub fn device_names() -> Vec { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 = 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 { + 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::>() })), + "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 { + 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 { + if i == Input::Cancel { + return Ok(Step::Cancel); + } + let t = typed(&i); + let need = |t: Option| 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 = text.split(',').map(|x| crate::units::parse_distance(x.trim())).collect::>>().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) + } + } + } +} diff --git a/crates/engine/tests/plotters.rs b/crates/engine/tests/plotters.rs new file mode 100644 index 00000000..1bc76f34 --- /dev/null +++ b/crates/engine/tests/plotters.rs @@ -0,0 +1,174 @@ +//! Plotter configurations (issue #564): PLOTTERMANAGER keeps user devices in the host's folder, +//! PAGESETUP takes a device and its papers with their printable areas, and PLOT hands the +//! plotter the device's output, paper and margins. + +use cadcraft_doc::{Drawing, Space}; +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, base64_decode, set_io}; +use cadcraft_engine::cmd::plotters::{PlotterStore, set_plotter_store}; +use serde_json::{Value, json}; + +/// The plot hook echoes the options it gets. +fn echo_plot(_: &Drawing, _: &Space, opts: &Value) -> Result, String> { + Ok(opts.to_string().into_bytes()) +} + +fn session() -> Session { + set_plotter_store(PlotterStore::memory()); + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Err("no writer in this test".into()), plot: Some(echo_plot) }); + let mut s = Session::new(); + s.cmdline("line 0,0 100,0").unwrap(); + s.cmdline("").unwrap(); + s +} + +fn names(s: &mut Session) -> Vec { + let r = s.execute("plottermanager", &json!({})).unwrap(); + r["devices"].as_array().unwrap().iter().map(|d| d["name"].as_str().unwrap().to_string()).collect() +} + +/// The options the plot hook got. +fn plotted(r: Value) -> Value { + let bytes = base64_decode(r["data"].as_str().unwrap()).unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +#[test] +fn manage_devices() { + let mut s = session(); + let n = names(&mut s); + assert_eq!(n[..3], ["CADCraft PDF", "CADCraft PNG", "CADCraft SVG"], "built-in devices first"); + let pdf = s.execute("plottermanager", &json!({"action": "get", "name": "cadcraft pdf"})).unwrap(); + assert!(pdf["builtin"].as_bool().unwrap()); + let papers = pdf["papers"].as_array().unwrap(); + assert!(papers.iter().any(|p| p["name"] == "ISO B1 (707.00 x 1000.00 MM)")); + let a4 = papers.iter().find(|p| p["name"] == "ISO A4 (210.00 x 297.00 MM)").unwrap(); + assert_eq!(a4["margins"], json!([5.0, 5.0, 5.0, 5.0])); + let letter = papers.iter().find(|p| p["name"] == "ANSI A (8.50 x 11.00 Inches)").unwrap(); + assert_eq!(letter["margins"], json!([6.35, 6.35, 6.35, 6.35])); + + // A user device: a PNG copy with its own borderless paper. + s.execute("plottermanager", &json!({"action": "new", "name": "Office Raster", "output": "png", "resolution": 150})).unwrap(); + let paper = json!({"name": "A3 borderless", "width": 420, "height": 297, "margins": [0, 0, 0, 0]}); + let r = s.execute("plottermanager", &json!({"action": "set", "name": "office raster", "paper": paper, "defaultPaper": "A3 borderless"})).unwrap(); + assert_eq!(r["defaultPaper"], "A3 borderless"); + assert_eq!(r["resolution"], 150); + assert!(names(&mut s).contains(&"Office Raster".to_string())); + assert!(s.execute("plottermanager", &json!({"action": "new", "name": "OFFICE RASTER"})).is_err(), "duplicate"); + assert!(s.execute("plottermanager", &json!({"action": "set", "name": "CADCraft PDF", "resolution": 100})).is_err(), "built-ins are fixed"); + assert!(s.execute("plottermanager", &json!({"action": "delete", "name": "CADCraft SVG"})).is_err()); + let bad_margins = json!({"name": "x", "width": 100, "height": 100, "margins": [60, 0, 60, 0]}); + assert!(s.execute("plottermanager", &json!({"action": "set", "name": "Office Raster", "paper": bad_margins})).is_err()); + + // Save as text, load under another name, delete. + let text = s.execute("plottermanager", &json!({"action": "save", "name": "Office Raster"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\"cadcraftPlotter\": 1"), "{text}"); + s.execute("plottermanager", &json!({"action": "load", "text": text, "name": "Office Copy"})).unwrap(); + let copy = s.execute("plottermanager", &json!({"action": "get", "name": "Office Copy"})).unwrap(); + assert_eq!(copy["output"], "png"); + assert!(s.execute("plottermanager", &json!({"action": "load", "text": "{}"})).is_err()); + s.execute("plottermanager", &json!({"action": "delete", "name": "office copy"})).unwrap(); + assert!(!names(&mut s).contains(&"Office Copy".to_string())); + assert!(s.execute("plottermanager", &json!({"action": "nope"})).is_err()); +} + +#[test] +fn page_setup_takes_a_device_and_its_papers() { + let mut s = session(); + s.execute("plottermanager", &json!({"action": "new", "name": "Plan Printer", "output": "pdf"})).unwrap(); + let paper = json!({"name": "Strip", "width": 200, "height": 600, "margins": [10, 20, 10, 20]}); + s.execute("plottermanager", &json!({"action": "set", "name": "Plan Printer", "paper": paper})).unwrap(); + let r = s.execute("pagesetup", &json!({"layout": "Layout1", "device": "plan printer", "paper": "strip", "landscape": false})).unwrap(); + assert_eq!(r["page"]["device"], "Plan Printer"); + assert_eq!(r["page"]["paper"], "Strip"); + let page = &s.doc().unwrap().layout("Layout1").unwrap().page; + assert_eq!((page.width_mm, page.height_mm, page.margins_mm), (200.0, 600.0, [10.0, 20.0, 10.0, 20.0])); + // The printable area shown in the layout follows the device paper. + let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout1").unwrap(); + let mm = |v: f64| v * sheet.unit_mm; + let got = [mm(sheet.printable.min.x), mm(sheet.printable.min.y), mm(sheet.printable.max.x), mm(sheet.printable.max.y)]; + assert!(got.iter().zip([10.0, 20.0, 190.0, 580.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?}"); + + // Switching to a built-in device keeps a paper it has, else takes its default (A4). + s.execute("pagesetup", &json!({"layout": "Layout1", "device": "CADCraft PDF"})).unwrap(); + let page = s.doc().unwrap().layout("Layout1").unwrap().page.clone(); + assert_eq!((page.paper.as_str(), page.margins_mm), ("ISO A4 (210.00 x 297.00 MM)", [5.0; 4])); + s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "A3"})).unwrap(); + let page = s.doc().unwrap().layout("Layout1").unwrap().page.clone(); + assert_eq!((page.width_mm, page.height_mm), (297.0, 420.0)); + + assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "device": "Nope"})).is_err()); + assert!( + s.execute("pagesetup", &json!({"layout": "Layout1", "device": "Plan Printer", "paper": "Tabloid Extra"})).is_err(), + "not a paper of the device" + ); + s.execute("pagesetup", &json!({"layout": "Layout1", "device": "None", "paper": "Letter"})).unwrap(); + let page = s.doc().unwrap().layout("Layout1").unwrap().page.clone(); + assert_eq!((page.device.as_str(), page.paper.as_str()), ("None", "ANSI A (8.50 x 11.00 Inches)")); + let report = s.execute("pagesetup", &Value::Null).unwrap(); + assert!(report["devices"].as_array().unwrap().iter().any(|d| d == "Plan Printer")); +} + +#[test] +fn plot_uses_the_device() { + let mut s = session(); + s.execute("plottermanager", &json!({"action": "new", "name": "Sketch PNG", "output": "png", "resolution": 96})).unwrap(); + let o = plotted(s.execute("plot", &json!({"layout": "Model", "device": "Sketch PNG", "paper": "A3"})).unwrap()); + assert_eq!(o["output"], "png"); + assert_eq!(o["resolution"], 96); + assert_eq!((o["width"].as_f64(), o["height"].as_f64()), (Some(297.0), Some(420.0))); + assert_eq!(o["margins"], json!([5.0, 5.0, 5.0, 5.0])); + assert_eq!(o["landscape"], true, "the device's default orientation"); + assert!(o.get("paper").is_none() && o.get("device").is_none()); + // EXPORTPDF always writes PDF. + let o = plotted(s.execute("exportpdf", &json!({"layout": "Model", "device": "Sketch PNG"})).unwrap()); + assert_eq!(o["output"], "pdf"); + // A layout plots with its page setup's device and paper. + s.execute("pagesetup", &json!({"layout": "Layout1", "device": "Sketch PNG", "paper": "A4"})).unwrap(); + let o = plotted(s.execute("plot", &json!({"layout": "Layout1"})).unwrap()); + assert_eq!(o["output"], "png"); + assert!(o.get("width").is_none(), "the page setup already holds the paper"); + // No device: options pass through unchanged. + let o = plotted(s.execute("plot", &json!({"layout": "Model", "paper": "A4"})).unwrap()); + assert!(o.get("output").is_none()); + assert!(s.execute("plot", &json!({"layout": "Model", "device": "Nope"})).is_err()); + assert!(s.execute("plot", &json!({"layout": "Model", "device": "Sketch PNG", "paper": "Nope"})).is_err()); +} + +#[test] +fn typed_plotter_manager() { + let mut s = session(); + for line in ["-plottermanager", "New", "Typed Plotter", "Svg"] { + s.cmdline(line).unwrap(); + } + let d = s.execute("plottermanager", &json!({"action": "get", "name": "Typed Plotter"})).unwrap(); + assert_eq!(d["output"], "svg"); + for line in ["-plottermanager", "Paper", "Typed Plotter", "Card", "100,150"] { + s.cmdline(line).unwrap(); + } + let d = s.execute("plottermanager", &json!({"action": "get", "name": "Typed Plotter"})).unwrap(); + assert!(d["papers"].as_array().unwrap().iter().any(|p| p["name"] == "Card" && p["width"] == 100.0)); + for line in ["-plottermanager", "Delete", "Typed Plotter"] { + s.cmdline(line).unwrap(); + } + assert!(!names(&mut s).contains(&"Typed Plotter".to_string())); +} + +#[test] +fn hostile_names_and_values_are_refused() { + let mut s = session(); + let r = s.execute("plottermanager", &json!({"action": "new", "name": "../../etc/passwd"})).unwrap(); + assert_eq!(r["name"], "../../etc/passwd", "the name is kept; only the file name is cleaned"); + for p in [ + json!({"action": "new", "name": ""}), + json!({"action": "new", "name": "x", "output": "dwf"}), + json!({"action": "new", "name": "y", "resolution": 1e12}), + json!({"action": "set", "name": "../../etc/passwd", "paper": {"name": "p", "width": f64::MAX, "height": 1}}), + json!({"action": "set", "name": "../../etc/passwd", "paper": {"name": "p", "width": -1, "height": 1}}), + json!({"action": "set", "name": "../../etc/passwd", "removePaper": "nope"}), + json!({"action": "load", "text": "x".repeat(2 << 20)}), + json!({"action": "get"}), + ] { + assert!(s.execute("plottermanager", &p).is_err(), "{p}"); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 43e2b4ca..1be631d3 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -13,6 +13,7 @@ mod dxf_table; mod dxf_write; mod dxf_xref; pub mod pdf; +pub mod plotter; pub mod svg; /// Multilines and their styles (MLINE, MLINESTYLE). diff --git a/crates/io/src/plotter.rs b/crates/io/src/plotter.rs new file mode 100644 index 00000000..c77c3802 --- /dev/null +++ b/crates/io/src/plotter.rs @@ -0,0 +1,166 @@ +//! The plot hook the apps install ([`plot`]): PDF, PNG or SVG output of a plotter configuration (`output`), on the +//! device's paper (`width`/`height`, `margins` in mm) at its `resolution`. +//! +//! PDF goes to [`crate::pdf`]. PNG draws the sheet on white paper at the device resolution +//! (capped at [`MAX_PNG_PIXELS`]), with the plot style table and lineweights as PDF uses them. +//! SVG shows the sheet of a layout or the model-space extents. + +use std::sync::Arc; + +use cadcraft_color::Rgb; +use cadcraft_doc::{Drawing, Space}; +use cadcraft_geom::{Bounds2, Vec2}; +use cadcraft_render::{Sheet, paper, raster}; +use serde_json::Value; + +use crate::{IoError, PdfOptions, Result, pdf}; + +/// Largest PNG plot (pixels); larger sheets are drawn at a lower resolution. +pub const MAX_PNG_PIXELS: f64 = 40.0e6; + +/// Plot `space` with JSON options (the engine's `plot` hook): PDF unless `output` is `png` or +/// `svg`. `margins: [l, b, r, t]` (mm) replaces a layout's printable area for this plot. +pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result> { + let margins = margins(opts.get("margins")); + let with_margins; + let d = match (space, margins) { + (Space::Paper(n), Some(m)) => { + let mut c = d.clone(); + if let Some(l) = c.layouts.iter_mut().find(|l| l.name.eq_ignore_ascii_case(n)) { + l.page.margins_mm = m; + } + with_margins = c; + &with_margins + } + _ => d, + }; + match opts.get("output").and_then(Value::as_str).map(str::to_ascii_lowercase).as_deref() { + None | Some("pdf") => pdf::plot(d, space, opts), + Some("png") => png(d, space, opts, margins), + Some("svg") => svg(d, space, opts), + Some(other) => Err(IoError::Unsupported(other.to_string())), + } +} + +/// `[l, b, r, t]` margins in mm (finite, 0–1000), else `None`. +fn margins(v: Option<&Value>) -> Option<[f64; 4]> { + let a: Vec = v?.as_array()?.iter().filter_map(Value::as_f64).collect(); + match a[..] { + [l, b, r, t] if a.iter().all(|x| x.is_finite() && (0.0..=1000.0).contains(x)) => Some([l, b, r, t]), + _ => None, + } +} + +/// The sheet a plot draws on, and whether the drawing is fitted to its printable area (model +/// space, or a layout asked to `fit`). +fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, margins: Option<[f64; 4]>) -> Result<(Sheet, cadcraft_doc::PageSetup)> { + let mut page = pdf::page_for(d, space, o)?; + if let (Space::Model, Some(m)) = (space, margins) { + page.margins_mm = m; + } + Ok((Sheet::from_page(&page, paper::paper_unit_mm(d)), page)) +} + +/// Paper units per drawing unit fitting `b` into the printable area (or the `scale` option). +fn fit_scale(b: &Bounds2, sheet: &Sheet, o: &PdfOptions) -> f64 { + if let Some(s) = o.scale.filter(|s| s.is_finite() && *s > 0.0) + && o.fit == Some(false) + { + return s; + } + if b.is_empty() { + return 1.0; + } + let s = (sheet.printable.width() / b.width().max(1e-12)).min(sheet.printable.height() / b.height().max(1e-12)); + if s.is_finite() && s > 0.0 { s } else { 1.0 } +} + +fn png(d: &Drawing, space: &Space, opts: &Value, margins: Option<[f64; 4]>) -> Result> { + let o = PdfOptions::from_json(opts); + let (sheet, page) = sheet(d, space, &o, margins)?; + let dpi = opts.get("resolution").and_then(Value::as_f64).filter(|r| r.is_finite()).unwrap_or(300.0).clamp(50.0, 2400.0); + // Pixels per paper unit, lowered so the image stays within the caps. + let mut k = sheet.unit_mm / 25.4 * dpi; + let (w, h) = (sheet.size.x * k, sheet.size.y * k); + let shrink = (MAX_PNG_PIXELS / (w * h).max(1.0)).sqrt().min(16384.0 / w.max(h).max(1.0)).min(1.0); + if !(shrink.is_finite() && shrink > 0.0) { + return Err(IoError::Format("the sheet is too large to plot as an image".into())); + } + k *= shrink; + let px = |v: f64| (v * k).round().clamp(1.0, 16384.0) as u32; + let (width, height) = (px(sheet.size.x), px(sheet.size.y)); + let lineweights = o.lineweights.unwrap_or(match space { + Space::Paper(_) => page.lineweights, + Space::Model => true, + }); + let table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(Arc::new); + // Paper units per drawing unit: 1 for a layout at 1:1, the fitted scale for model space. + let fit = matches!(space, Space::Model) || o.fit == Some(true); + let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); + let est = if fit { fit_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, &o) } else { 1.0 }; + // Chord tolerance: a fifth of a pixel. + let tolerance = (0.2 / (k * est)).max(1e-9); + let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, ..cadcraft_render::Options::default() }; + let list = cadcraft_render::build_plot(d, space, &ropts); + let half = Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k); + let view = if fit { + let b = window.unwrap_or(list.bounds); + let s = fit_scale(&b, &sheet, &o); + let from = if b.is_empty() { Vec2::ZERO } else { b.center() }; + // The world point at the image centre, with `from` on the printable area's centre. + let center = from + (half - sheet.printable.center()) / s; + raster::View { center, scale: k * s, width, height } + } else { + raster::View { center: half, scale: k, width, height } + }; + let px_per_mm = if lineweights { (k / sheet.unit_mm) as f32 } else { 0.0 }; + let ro = raster::RasterOptions { background: Rgb(255, 255, 255), hairline: 1.0, px_per_mm, antialias: true }; + raster::render_png(&list, &view, &ro).ok_or_else(|| IoError::Format("render failed".into())) +} + +fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result> { + let o = PdfOptions::from_json(opts); + let window = match space { + Space::Paper(_) => Some(sheet(d, space, &o, None)?.0.bounds()), + Space::Model => o.window.filter(|w| !w.is_empty()), + }; + Ok(crate::svg::export_window(d, space, window).into_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_doc::{Common, EntityKind, Line}; + use cadcraft_geom::Vec3; + use serde_json::json; + + /// Width and height from a PNG's header. + fn png_size(b: &[u8]) -> (u32, u32) { + let be = |i: usize| u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]); + (be(16), be(20)) + } + + fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(100.0, 50.0, 0.0) })).unwrap(); + d + } + + #[test] + fn png_and_svg_devices() { + let d = drawing(); + let a4 = json!({"output": "png", "resolution": 100, "width": 210, "height": 297, "landscape": true, "margins": [5, 5, 5, 5]}); + let png = plot(&d, &Space::Model, &a4).unwrap(); + assert_eq!(png_size(&png), (1169, 827), "A4 landscape at 100 dpi"); + // Huge sheets at a high resolution are capped. + let big = json!({"output": "png", "resolution": 2400, "width": 841, "height": 1189}); + let (w, h) = png_size(&plot(&d, &Space::Model, &big).unwrap()); + assert!(f64::from(w) * f64::from(h) <= MAX_PNG_PIXELS * 1.01 && w <= 16384 && h <= 16384); + let layout = Space::Paper(d.layouts[0].name.clone()); + let svg = String::from_utf8(plot(&d, &layout, &json!({"output": "svg"})).unwrap()).unwrap(); + assert!(svg.starts_with(" [f64; 4] { + if self.imperial { [6.35; 4] } else { [5.0; 4] } + } +} + /// Look up a paper size by canonical name or alias (case- and space-insensitive). pub fn paper_size(name: &str) -> Option<&'static PaperSize> { let norm = |s: &str| s.chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').collect::().to_ascii_lowercase(); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 015e0683..f4d322ac 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -621,6 +621,8 @@ Plot Style Manager Диспетчер стилів друку Plot Style Manager... Диспетчер стилів друку... Plot Styles... Стилі друку... + Plotter Manager Диспетчер плотерів + Plotter Manager... Диспетчер плотерів... New CTB Нова CTB New STB Нова STB New style Новий стиль diff --git a/docs/plotters.md b/docs/plotters.md new file mode 100644 index 00000000..a4c6f4fb --- /dev/null +++ b/docs/plotters.md @@ -0,0 +1,108 @@ +# Plotters + +A plotter (output device) decides what a plot writes and on which paper sizes. Page setups +and plots pick a device and one of its papers. Each paper carries margins, which bound its +printable area. Layouts show the printable area as a dashed outline on the sheet. + +## Devices + +The built-in devices come first: + +| Device | Output | +|---|---| +| `CADCraft PDF` | vector PDF | +| `CADCraft PNG` | raster PNG on white paper at the device resolution (300 dpi) | +| `CADCraft SVG` | vector SVG | + +They offer the standard sizes: + +- ISO 216 A0–A6 and B0–B5 +- ANSI A–E and Legal +- ARCH A–E and E1 + +ISO sheets have 5 mm margins. Inch sheets have quarter-inch (6.35 mm) margins. A page setup +with the device `None` plots to PDF on the page setup's paper, as before. + +User devices are files in our own format, kept in the `plotters` folder of the CADCraft +settings directory: + +| System | Folder | +|---|---| +| Linux and FreeBSD | `$XDG_CONFIG_HOME/cadcraft/plotters` or `~/.config/cadcraft/plotters` | +| macOS | `~/Library/Application Support/CADCraft/plotters` | +| Windows | `%APPDATA%\CADCraft\plotters` | + +`CADCRAFT_CONFIG_DIR` replaces the settings directory. On the web, devices last for the +session unless the host provides storage (`cmd::plotters::set_plotter_store`). Binary `.pc3` +files are not read; we haven't found a public specification of that format. + +## Files + +`.plotter.json`, UTF-8 JSON with a format marker: + +```json +{ + "cadcraftPlotter": 1, + "name": "Office A3", + "description": "Raster proofs", + "output": "png", + "resolution": 150, + "orientation": "landscape", + "defaultPaper": "A3 borderless", + "papers": [ + { "name": "A3 borderless", "width": 297, "height": 420, "margins": [0, 0, 0, 0] }, + { "name": "ISO A4 (210.00 x 297.00 MM)", "width": 210, "height": 297, "margins": [5, 5, 5, 5] } + ] +} +``` + +- `output` is `pdf`, `png` or `svg`. +- `resolution` is in dots per inch (50–2400) and applies to PNG. +- `orientation` (`portrait` or `landscape`) is used when a page setup switches to the device. +- Paper sizes are in millimetres on the portrait sheet. `margins` are left, bottom, right and + top, in mm. + +Reading fixes hostile values: + +- Sizes are kept within 1–5080 mm, and width and height are swapped to portrait. +- Margins are kept within 0–1000 mm. Margins that leave no printable area are dropped. +- Paper names are unique, and a device keeps at most 512 papers. +- A missing or unknown `defaultPaper` becomes the first paper. +- Files over 1 MB are refused. + +In the file name, characters other than letters, digits, spaces and `-_()+,` become `_`. + +## Commands + +- `plottermanager {action, …}` (also **File ▸ Plotter Manager...**) manages the devices: + - `list` (the default) shows the built-in devices first. + - `get {name}` returns a device. + - `new {name, from?, output?, …}` copies `from`, or the built-in device for `output` with + the standard papers. + - `set {name, description?, output?, resolution?, orientation?, defaultPaper?, paper?: {name, + width, height, margins?}, removePaper?}` edits a device. `paper` adds a paper or replaces + the one of that name. + - `delete {name}` removes a device. + - `load {text, name?}` stores a device file's contents. + - `save {name, path?}` writes the file to `path`, or returns it as `text`. + + Built-in devices can't be edited or deleted. +- Typed `PLOTTERMANAGER` or `-PLOTTERMANAGER` prompts for an option: + - `?` lists the devices. + - `New` asks for a name and an output (`Pdf`, `PNg`, `Svg`). + - `Delete` asks for a device. + - `Paper` asks for a device, a paper name and `width,height` in mm, and adds that paper. +- `pagesetup {layout, device, paper?}` sets the layout's device. `paper` names one of the + device's papers, or a standard size by its short name (`A3`, `Letter`). The paper's + margins become the layout's printable area. Without `paper`, a device change keeps the + current paper if the device has it, else takes the device's default paper. A device change + also takes the device's orientation unless `landscape` is given. `device: "None"` clears it. +- `plot {device?, paper?, …}` plots with `device`, or with the layout's page setup device. + - The device sets the output and resolution. + - `paper` (one of the device's papers) sets the size and margins for this plot. + - A model-space plot without `paper` uses the device's default paper and orientation. + - `exportpdf` takes the same options and always writes PDF. + +Model-space PDF plots don't apply the paper's margins yet. They fit the drawing inside the +default margins. PNG plots apply them. SVG plots show a layout's whole sheet, or the +model-space extents.