//! LAYOUT SAveas: a layout written to a drawing template file (`.dwt`, or a `.dwg` or `.dxf`). //! //! The template holds the layout's page setup and paper space objects, viewports included, and //! no model space objects and no other layout. Named objects the layout does not use (blocks, //! layers, linetypes, text, dimension, multileader, multiline and table styles, empty groups) //! are left out, as PURGE All removes them; layers the layout's viewports freeze or recolor stay. //! The open drawing is not changed. use std::collections::BTreeSet; use cadcraft_doc::{Common, Drawing, Entity, EntityKind, EntityStore, Point}; use cadcraft_geom::Vec3; use serde_json::{Value, json}; use super::super::file::{drops_unknown, io, not_saved, not_saved_message}; use super::super::{bad, str_param}; use super::layout_name; use super::prompts::current_layout; use crate::{Result, Session}; /// File types a layout template is written as; a path without an extension gets `.dwt`. const TEMPLATE_TYPES: [&str; 3] = ["dwt", "dwg", "dxf"]; /// The most PURGE All passes run on a template; they stop at the first pass that purges nothing. const MAX_PURGE_PASSES: usize = 64; /// The file a SAveas typed at the command line suggests: `.dwt` beside the drawing. pub(super) fn default_path(s: &Session, layout: &str) -> String { // A layout name from a file may hold path separators or `..`; the suggestion is one file name // beside the drawing, so those become `_`. let name: String = layout.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect(); // Windows reserves device names (`CON`, `NUL`, `COM1`...) with any extension, so prefix them. let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase(); let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL") || (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && base.ends_with(|c: char| ('1'..='9').contains(&c))); let file = if reserved { format!("_{name}.dwt") } else { format!("{name}.dwt") }; match s.state().ok().and_then(|st| st.path.clone()) { Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(), None => file, } } /// `path` with `.dwt` added when it has no extension; an error for any other file type. fn template_path(path: &str) -> Result { let path = path.trim(); if path.is_empty() { return Err(bad("layout.saveas", "`path` is empty")); } match std::path::Path::new(path).extension() { None => Ok(format!("{path}.dwt")), Some(e) if TEMPLATE_TYPES.iter().any(|t| e.eq_ignore_ascii_case(t)) => Ok(path.to_string()), Some(_) => Err(bad("layout.saveas", "a layout template is saved as a .dwt, .dwg or .dxf file")), } } /// The drawing a template of `layout` holds: `d` with that layout alone, an empty model space, /// and without the named objects nothing left in it uses. fn template_of(d: &Drawing, layout: &str) -> Result { let mut t = d.clone(); t.layouts.retain(|l| l.name == layout); t.active_layout = Some(layout.to_string()); t.model = EntityStore::new(); t.hidden_objects.clear(); let gone: Vec = t.constraints.iter().filter(|c| c.refs.iter().any(|r| t.entity(r.handle).is_none())).map(|c| c.id).collect(); t.constraints.retain(|c| !gone.contains(&c.id)); // Layers the layout's viewports freeze or recolor stay, with those settings. PURGE counts // only objects, so each such layer gets a point in model space while PURGE runs. let named: BTreeSet = t .layouts .iter() .flat_map(|l| l.entities.iter()) .filter_map(|e| match &e.kind { EntityKind::Viewport(v) => Some(v.frozen_layers.iter().cloned().chain(v.layer_colors.iter().map(|(n, _)| n.clone())).collect::>()), _ => None, }) .flatten() .collect(); for layer in named { let handle = t.new_handle(); t.model.push(Entity { handle, common: Common { layer, ..Common::default() }, kind: EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 }) }); } // PURGE runs on a session of its own, so the open drawing keeps everything. let mut scratch = Session::empty(); scratch.open_drawing(t, layout, None); for _ in 0..MAX_PURGE_PASSES { let r = super::super::blocks::purge::run(&mut scratch, &json!({ "type": "all" }))?; let purged = r.get("purged").and_then(Value::as_object).is_some_and(|o| o.values().any(|v| v.as_array().is_some_and(|a| !a.is_empty()))); if !purged { break; } } let st = scratch.docs.pop().ok_or_else(|| bad("layout.saveas", "the template could not be made"))?; let mut t = std::sync::Arc::unwrap_or_clone(st.doc); t.model = EntityStore::new(); Ok(t) } /// `layout.saveas {name?, path}`: write a layout as a drawing template. pub(super) fn run_saveas(s: &mut Session, p: &Value) -> Result { let path = template_path(str_param(p, "path").ok_or_else(|| bad("layout.saveas", "`path` is required"))?)?; if s.doc()?.refedit.is_some() { return Err(bad("layout.saveas", "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving.")); } let name = match str_param(p, "name") { Some(n) => n.to_string(), None => current_layout(s).ok_or_else(|| bad("layout.saveas", "the drawing has no layout"))?, }; // A file is the drawing outside the Block Editor. let d = s.state()?.drawing(); let name = layout_name("layout.saveas", d, &name)?; let t = template_of(d, &name)?; let hooks = io().ok_or_else(|| bad("layout.saveas", "file formats are not available in this build"))?; let bytes = (hooks.write)(&t, &path).map_err(|e| bad("layout.saveas", e))?; let folder = super::super::file::new_folder(&path); store(&path, &bytes)?; let mut r = json!({ "name": name, "path": path, "bytes": bytes.len() }); super::super::file::report_new_folder(s, &mut r, folder); let lost = not_saved(&t); if drops_unknown(&path) && let Some(msg) = not_saved_message(&lost) { s.echo(msg); if let Some(o) = r.as_object_mut() { o.insert("notSaved".into(), json!(lost)); } } Ok(r) } /// Write the template: natively to `path` through a temporary file; on the web as a download. fn store(path: &str, bytes: &[u8]) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] { // Exclusively create a fresh temporary file and rename it into place so a planted // `.cadcraft-tmp` symlink cannot redirect the write (CWE-59). crate::atomic_write::write_atomic_making_folders(std::path::Path::new(path), bytes).map_err(|e| bad("layout.saveas", format!("{path}: {e}"))) } #[cfg(target_arch = "wasm32")] { super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver()) } } #[cfg(test)] mod default_path_tests { use super::*; /// A layout name with separators suggests a file beside the drawing, not a path elsewhere. #[test] fn layout_name_is_one_file_name() { let s = Session::empty(); for name in ["../../etc/x", "a/b", "a\\b", "C:x"] { let p = default_path(&s, name); assert!(!p.contains('/') && !p.contains('\\') && !p.contains(':'), "{p}"); assert!(p.ends_with(".dwt")); } assert_eq!(default_path(&s, "Layout1"), "Layout1.dwt"); } /// Windows device names are prefixed so the suggestion is never a device. #[test] fn reserved_device_names_are_prefixed() { let s = Session::empty(); for name in ["CON", "nul", "Com1", "LPT9", "aux.x"] { assert_eq!(default_path(&s, name), format!("_{name}.dwt")); } for name in ["COM0", "CONSOLE", "COM10"] { assert_eq!(default_path(&s, name), format!("{name}.dwt")); } } }