//! Plotter configurations: output devices with their paper sizes and printable areas. //! //! A configuration is a file in our own format (`*.plotter.json`, UTF-8 JSON with a format //! marker, see `docs/plotters.md`). It names the output (PDF, PNG or SVG), the paper sizes the //! device offers (each with the margins that bound its printable area), the raster resolution //! and the default orientation. Binary `.pc3` files are not read. use serde::{Deserialize, Serialize}; /// File-name suffix of plotter configuration files. pub const PLOTTER_FILE_EXT: &str = ".plotter.json"; /// Largest plotter configuration file read (bytes). pub const MAX_PLOTTER_FILE: usize = 1 << 20; /// Most paper sizes one device keeps. pub const MAX_DEVICE_PAPERS: usize = 512; /// Longest device or paper name (characters). pub const MAX_PLOTTER_NAME: usize = 100; /// Largest paper side (mm), as for page setups. pub const MAX_PAPER_MM: f64 = 5080.0; const FILE_MARKER: &str = "cadcraftPlotter"; const FILE_VERSION: u64 = 1; /// What a device writes. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PlotOutput { #[default] Pdf, Png, Svg, } impl PlotOutput { pub fn as_str(self) -> &'static str { match self { PlotOutput::Pdf => "pdf", PlotOutput::Png => "png", PlotOutput::Svg => "svg", } } /// Parse `pdf`, `png` or `svg` (any case). pub fn parse(s: &str) -> Option { match s.trim().to_ascii_lowercase().as_str() { "pdf" => Some(PlotOutput::Pdf), "png" => Some(PlotOutput::Png), "svg" => Some(PlotOutput::Svg), _ => None, } } } /// Default sheet orientation of a device. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Orientation { Portrait, #[default] Landscape, } /// A paper size a device offers: size in millimetres (portrait: width <= height after /// sanitizing) and margins (left, bottom, right, top; mm) bounding the printable area. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct DevicePaper { pub name: String, pub width: f64, pub height: f64, pub margins: [f64; 4], } impl Default for DevicePaper { fn default() -> Self { DevicePaper { name: String::new(), width: 210.0, height: 297.0, margins: [0.0; 4] } } } impl DevicePaper { /// The paper with hostile values fixed: finite sizes within 1..=[`MAX_PAPER_MM`] mm /// (portrait), margins 0..=1000 mm that leave a printable area (else none), a name of at /// most [`MAX_PLOTTER_NAME`] characters. pub fn sanitized(mut self) -> DevicePaper { let side = |v: f64| if v.is_finite() { v.clamp(1.0, MAX_PAPER_MM) } else { 1.0 }; let (w, h) = (side(self.width), side(self.height)); self.width = w.min(h); self.height = w.max(h); for m in &mut self.margins { *m = if m.is_finite() { m.clamp(0.0, 1000.0) } else { 0.0 }; } let [l, b, r, t] = self.margins; if l + r >= self.width || b + t >= self.height { self.margins = [0.0; 4]; } self.name = clean_name(&self.name); if self.name.is_empty() { self.name = format!("User ({:.2} x {:.2} MM)", self.width, self.height); } self } } /// A plotter configuration. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PlotterConfig { pub name: String, pub description: String, pub output: PlotOutput, /// Raster resolution (dots per inch) for PNG output. pub resolution: u32, pub orientation: Orientation, /// Name of the paper chosen when a page setup switches to this device. pub default_paper: String, pub papers: Vec, } impl Default for PlotterConfig { fn default() -> Self { PlotterConfig { name: String::new(), description: String::new(), output: PlotOutput::Pdf, resolution: 300, orientation: Orientation::Landscape, default_paper: String::new(), papers: Vec::new(), } } } /// A name trimmed, without control characters, at most [`MAX_PLOTTER_NAME`] characters. fn clean_name(s: &str) -> String { s.trim().chars().filter(|c| !c.is_control()).take(MAX_PLOTTER_NAME).collect::().trim().to_string() } /// Names compare ignoring case, spaces, `_` and `-`. fn key(s: &str) -> String { s.chars().filter(|c| !c.is_whitespace() && *c != '_' && *c != '-').collect::().to_ascii_lowercase() } impl PlotterConfig { /// The configuration with hostile values fixed: a clean name and description, resolution /// 50..=2400 dpi, at most [`MAX_DEVICE_PAPERS`] sanitized papers with unique names, and a /// default paper the device has (the first one when it names none of them). pub fn sanitized(mut self) -> PlotterConfig { self.name = clean_name(&self.name); self.description = self.description.chars().filter(|c| !c.is_control() || *c == '\n').take(1000).collect(); self.resolution = self.resolution.clamp(50, 2400); let mut papers: Vec = Vec::new(); for p in std::mem::take(&mut self.papers).into_iter().take(MAX_DEVICE_PAPERS) { let p = p.sanitized(); if !papers.iter().any(|q| key(&q.name) == key(&p.name)) { papers.push(p); } } self.papers = papers; self.default_paper = match self.paper(&self.default_paper) { Some(p) => p.name.clone(), None => self.papers.first().map(|p| p.name.clone()).unwrap_or_default(), }; self } /// The paper called `name` (ignoring case, spaces, `_` and `-`). pub fn paper(&self, name: &str) -> Option<&DevicePaper> { let k = key(name); if k.is_empty() { return None; } self.papers.iter().find(|p| key(&p.name) == k) } /// The paper a page setup gets when it switches to this device. pub fn default_paper(&self) -> Option<&DevicePaper> { self.paper(&self.default_paper).or_else(|| self.papers.first()) } /// The configuration (sanitized) as a file in our own format: pretty-printed JSON with a /// format marker. pub fn to_file_text(&self) -> String { let mut v = serde_json::to_value(self.clone().sanitized()).unwrap_or(serde_json::Value::Null); if let Some(o) = v.as_object_mut() { o.insert(FILE_MARKER.into(), FILE_VERSION.into()); } serde_json::to_string_pretty(&v).unwrap_or_default() } /// Read a file written by [`PlotterConfig::to_file_text`] (sanitized; a name is required). pub fn from_file_text(text: &str) -> std::result::Result { if text.len() > MAX_PLOTTER_FILE { return Err("the plotter configuration file is too large".into()); } let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("not a CADCraft plotter configuration: {e}"))?; match v.get(FILE_MARKER).and_then(serde_json::Value::as_u64) { Some(FILE_VERSION) => {} Some(n) => return Err(format!("plotter configuration format version {n} is newer than this CADCraft reads")), None => return Err(format!("not a CADCraft plotter configuration (no `{FILE_MARKER}`)")), } let c: PlotterConfig = serde_json::from_value(v).map_err(|e| format!("bad plotter configuration: {e}"))?; let c = c.sanitized(); if c.name.is_empty() { return Err("the plotter configuration has no name".into()); } Ok(c) } } /// The file name a device is kept under: its name with characters that can't appear in file /// names replaced by `_`, plus [`PLOTTER_FILE_EXT`]. `None` for an empty name. pub fn plotter_file_name(name: &str) -> Option { let base: String = clean_name(name).chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '(' | ')' | '+' | ',') { c } else { '_' }).collect(); let base = base.trim().trim_start_matches('.'); (!base.is_empty()).then(|| format!("{base}{PLOTTER_FILE_EXT}")) } #[cfg(test)] mod tests { use super::*; #[test] fn round_trip_and_hostile_values() { let c = PlotterConfig { name: "Office A3".into(), output: PlotOutput::Png, resolution: 1_000_000, default_paper: "nope".into(), papers: vec![ DevicePaper { name: "A3 borderless".into(), width: 420.0, height: 297.0, margins: [0.0; 4] }, DevicePaper { name: "a3-BORDERLESS".into(), ..DevicePaper::default() }, DevicePaper { name: String::new(), width: f64::NAN, height: 1e12, margins: [f64::INFINITY, 9e9, -1.0, 5.0] }, ], ..PlotterConfig::default() }; let back = PlotterConfig::from_file_text(&c.to_file_text()).unwrap(); assert_eq!(back.resolution, 2400); assert_eq!(back.papers.len(), 2, "duplicate names dropped"); assert_eq!((back.papers[0].width, back.papers[0].height), (297.0, 420.0), "portrait"); assert_eq!(back.default_paper, "A3 borderless"); assert_eq!(back.papers[1].width, 1.0); assert_eq!(back.papers[1].height, MAX_PAPER_MM); assert_eq!(back.papers[1].margins, [0.0, 1000.0, 0.0, 5.0], "hostile margins clamped"); let wide = DevicePaper { margins: [150.0, 0.0, 100.0, 0.0], ..DevicePaper::default() }.sanitized(); assert_eq!(wide.margins, [0.0; 4], "margins wider than the paper are dropped"); assert!(back.paper("a3 BORDERLESS").is_some()); assert!(PlotterConfig::from_file_text("{}").is_err()); assert!(PlotterConfig::from_file_text(r#"{"cadcraftPlotter": 1}"#).is_err(), "needs a name"); assert!(PlotterConfig::from_file_text(r#"{"cadcraftPlotter": 9, "name": "x"}"#).is_err()); assert_eq!(plotter_file_name("../a/b:c").as_deref(), Some("___a_b_c.plotter.json")); assert_eq!(plotter_file_name(" "), None); } }