mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Squash of PR #570 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7e437cb62d
commit
74e72c93f8
14 files changed
+1407
-8
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -621,6 +621,8 @@
|
||||
Plot Style Manager Диспетчер стилів друку
|
||||
Plot Style Manager... Диспетчер стилів друку...
|
||||
Plot Styles... Стилі друку...
|
||||
Plotter Manager Диспетчер плотерів
|
||||
Plotter Manager... Диспетчер плотерів...
|
||||
New CTB Нова CTB
|
||||
New STB Нова STB
|
||||
New style Новий стиль
|
||||
|
||||
|
@@ -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.
|
||||
Reference in new issue
Block a user