mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 04:19:59 -06:00
172 lines
7.9 KiB
Rust
172 lines
7.9 KiB
Rust
//! 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: `<layout>.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<String> {
|
|
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<Drawing> {
|
|
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<u32> = 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<String> = 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::<Vec<_>>()),
|
|
_ => 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<Value> {
|
|
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
|
|
// `<path>.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"));
|
|
}
|
|
}
|
|
}
|