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 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 13:25:00 -04:00
1 parent 7e437cb62d
commit 74e72c93f8
14 files changed
+1407 -8

No files matched your search

+1 -1
View File
@@ -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())
+1 -1
View File
@@ -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())
+1 -1
View File
@@ -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())
+2
View File
@@ -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::*;
+253
View File
@@ -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<PlotOutput> {
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<DevicePaper>,
}
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::<String>().trim().to_string()
}
/// Names compare ignoring case, spaces, `_` and `-`.
fn key(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').collect::<String>().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<DevicePaper> = 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<PlotterConfig, String> {
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<String> {
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);
}
}
+9 -4
View File
@@ -74,15 +74,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
]
.into_iter()
.chain(plotstyle::specs())
.chain(super::plotters::specs())
.collect()
}
@@ -620,6 +621,7 @@ fn run_viewport_set(s: &mut Session, p: &Value) -> Result<Value> {
fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "pagesetup";
let keys = [
"device",
"paper",
"width",
"height",
@@ -649,10 +651,13 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {
"layouts": d.layouts.iter().map(|l| json!({"name": l.name, "page": serde_json::to_value(&l.page).unwrap_or(Value::Null)})).collect::<Vec<_>>(),
"papers": cadcraft_render::PAPER_SIZES.iter().map(|p| p.name).collect::<Vec<_>>(),
"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();
+1
View File
@@ -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;
+674
View File
@@ -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)
}
}
}
}
+174
View File
@@ -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<Vec<u8>, 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<String> {
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}");
}
}
+1
View File
@@ -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).
+166
View File
@@ -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<Vec<u8>> {
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<f64> = 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<Vec<u8>> {
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<Vec<u8>> {
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("<svg"));
assert!(plot(&d, &layout, &json!({"output": "png", "resolution": f64::NAN, "margins": [1, 2, 3]})).is_ok());
assert!(plot(&d, &Space::Model, &json!({"output": "dwf"})).is_err());
assert!(plot(&d, &Space::Model, &json!({})).unwrap().starts_with(b"%PDF"));
}
}
+14 -1
View File
@@ -26,7 +26,7 @@ const fn inch(name: &'static str, aliases: &'static [&'static str], w: f64, h: f
PaperSize { name, aliases, width_mm: w * 25.4, height_mm: h * 25.4, imperial: true }
}
/// Common ISO and ANSI/ARCH sheet sizes.
/// Common ISO (A0–A6, B0–B5) and ANSI/ARCH sheet sizes (ISO 216, ANSI/ASME Y14.1).
pub const PAPER_SIZES: &[PaperSize] = &[
iso("ISO A0 (841.00 x 1189.00 MM)", &["A0", "ISO A0"], 841.0, 1189.0),
iso("ISO A1 (594.00 x 841.00 MM)", &["A1", "ISO A1"], 594.0, 841.0),
@@ -34,6 +34,11 @@ pub const PAPER_SIZES: &[PaperSize] = &[
iso("ISO A3 (297.00 x 420.00 MM)", &["A3", "ISO A3"], 297.0, 420.0),
iso("ISO A4 (210.00 x 297.00 MM)", &["A4", "ISO A4"], 210.0, 297.0),
iso("ISO A5 (148.00 x 210.00 MM)", &["A5", "ISO A5"], 148.0, 210.0),
iso("ISO A6 (105.00 x 148.00 MM)", &["A6", "ISO A6"], 105.0, 148.0),
iso("ISO B0 (1000.00 x 1414.00 MM)", &["B0", "ISO B0"], 1000.0, 1414.0),
iso("ISO B1 (707.00 x 1000.00 MM)", &["B1", "ISO B1"], 707.0, 1000.0),
iso("ISO B2 (500.00 x 707.00 MM)", &["B2", "ISO B2"], 500.0, 707.0),
iso("ISO B3 (353.00 x 500.00 MM)", &["B3", "ISO B3"], 353.0, 500.0),
iso("ISO B4 (250.00 x 353.00 MM)", &["B4", "ISO B4"], 250.0, 353.0),
iso("ISO B5 (176.00 x 250.00 MM)", &["B5", "ISO B5"], 176.0, 250.0),
inch("ANSI A (8.50 x 11.00 Inches)", &["ANSI A", "Letter", "A-size"], 8.5, 11.0),
@@ -50,6 +55,14 @@ pub const PAPER_SIZES: &[PaperSize] = &[
inch("ARCH E (36.00 x 48.00 Inches)", &["ARCH E"], 36.0, 48.0),
];
impl PaperSize {
/// Margins (left, bottom, right, top; mm) of the printable area the built-in plot devices
/// give this size: 5 mm on ISO sheets, a quarter inch on inch sheets.
pub fn margins_mm(&self) -> [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::<String>().to_ascii_lowercase();
+2
View File
@@ -621,6 +621,8 @@
Plot Style Manager Диспетчер стилів друку
Plot Style Manager... Диспетчер стилів друку...
Plot Styles... Стилі друку...
Plotter Manager Диспетчер плотерів
Plotter Manager... Диспетчер плотерів...
New CTB Нова CTB
New STB Нова STB
New style Новий стиль
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
621
622
623
624
625
626
627
628
+108
View File
@@ -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
`<name>.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.