mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 12:30:16 -06:00
* Tests remove the temp files they write Every test run left about 22 folders and files in the system temp directory: xref, alias, template, insert, linetype, script and recent-file fixtures, plus the pathless screenshot folder. The PDFBOOK test also wrote its second book to `$TMPDIR/b.pdf`, because `a^b.pdf` splits into `<prefix>-a.pdf` and `b.pdf`, and then removed a file that was never written. Tests that write files now use a `TempDir` guard (`tests/support/temp_dir.rs`, shared by the engine's unit and integration tests and copied for the UI crate). It gives each test its own folder and removes it on drop, even when an assertion fails. The PDFBOOK test also checks that both books land in its folder. The five tests that write pathless screenshots hold a test-only lock, so the shared screenshot folder can be removed once empty without racing another test's write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012FFPi6hJVyaEKV4PyE2fKq * Pathless screenshot cleanup survives failed tests and writes The test lock's guard now empties and removes the per-process screenshot directory when dropped, so a test that panics no longer leaves files behind, and the per-test remove calls are gone. A failed PNG write or flush removes the partial file, since the reply carries no path for anyone else to clean up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: bmh129 <11478682+bmh129@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
765 lines
32 KiB
Rust
765 lines
32 KiB
Rust
//! File menu: new, close, switching drawings. Open/save of DWG/DXF go through the io layer,
|
|
//! registered by the host (desktop/CLI) via [`crate::cmd::file::set_io`]. On the web, where there is
|
|
//! no file system, saved files reach the user through the host's [`crate::cmd::file::set_deliver`].
|
|
|
|
mod pgp;
|
|
mod template;
|
|
|
|
use std::sync::OnceLock;
|
|
|
|
use cadcraft_doc::{Drawing, Format};
|
|
pub use cadcraft_dxf::Version;
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_command, ok, str_param};
|
|
|
|
use crate::{Accept, Input, Prompt, Result, Session, Step};
|
|
|
|
/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT.
|
|
pub use pgp::{MAX_ALIASES, alias_name, command_id};
|
|
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
|
pub use template::{qnew_template, set_qnew_template};
|
|
|
|
/// Largest drawing accepted from a local path, matching the per-file xref limit. A path naming an
|
|
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
|
|
/// through [`crate::capped_read`] rather than buffering to EOF.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const MAX_DRAWING_BYTES: usize = 512 << 20;
|
|
/// Largest `.scr` script accepted from a local path.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const MAX_SCRIPT_BYTES: usize = 64 << 20;
|
|
|
|
/// Plot a space to PDF bytes with JSON options (`{paper?, landscape?, fit?, lineweights?, …}`).
|
|
pub type PlotHook = fn(&Drawing, &cadcraft_doc::Space, &Value) -> std::result::Result<Vec<u8>, String>;
|
|
|
|
/// File format hooks (installed by the app so the engine stays I/O-agnostic and wasm-safe).
|
|
pub struct IoHooks {
|
|
pub read: fn(&[u8], &str) -> std::result::Result<Drawing, String>,
|
|
pub write: fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>,
|
|
/// PLOT / EXPORTPDF (optional: builds without a plotter report "not available").
|
|
pub plot: Option<PlotHook>,
|
|
}
|
|
|
|
static IO: OnceLock<IoHooks> = OnceLock::new();
|
|
|
|
pub fn set_io(h: IoHooks) {
|
|
let _ = IO.set(h);
|
|
}
|
|
pub fn io() -> Option<&'static IoHooks> {
|
|
IO.get()
|
|
}
|
|
|
|
/// An entity as DXF groups and back, for AutoLISP's `entget`, `entmake` and `entmod`
|
|
/// (installed by the app with the DXF reader and writer, a lower layer).
|
|
pub struct EntityCodec {
|
|
/// The entity's type name and the groups of its record, without its `0` group.
|
|
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<cadcraft_dxf::Tag>),
|
|
/// An entity from a type name and groups.
|
|
pub entity: fn(&str, &[cadcraft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
|
|
}
|
|
|
|
static CODEC: OnceLock<EntityCodec> = OnceLock::new();
|
|
|
|
pub fn set_entity_codec(c: EntityCodec) {
|
|
let _ = CODEC.set(c);
|
|
}
|
|
pub fn entity_codec() -> Option<&'static EntityCodec> {
|
|
CODEC.get()
|
|
}
|
|
|
|
/// COMPARE (drawing comparison).
|
|
pub mod compare;
|
|
|
|
/// Hand finished file bytes to the user where there is no file system (the browser: a download).
|
|
/// Arguments: file name, MIME type, bytes. `Ok` only once the delivery was actually started.
|
|
pub type DeliverHook = fn(&str, &str, &[u8]) -> std::result::Result<(), String>;
|
|
|
|
static DELIVER: OnceLock<DeliverHook> = OnceLock::new();
|
|
|
|
/// Install the host's file delivery (the web app: a browser download). Saving uses it on wasm;
|
|
/// anything else that produces a file on the web (plots) can use [`deliver`] too.
|
|
pub fn set_deliver(h: DeliverHook) {
|
|
let _ = DELIVER.set(h);
|
|
}
|
|
pub fn deliver() -> Option<DeliverHook> {
|
|
DELIVER.get().copied()
|
|
}
|
|
|
|
/// How a drawing file is written: its version (`None`: the drawing's own, `$ACADVER`) and
|
|
/// binary instead of ASCII DXF.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub struct SaveAs {
|
|
pub version: Option<Version>,
|
|
pub binary: bool,
|
|
}
|
|
|
|
/// Write a drawing with [`SaveAs`] options (installed by hosts whose writer supports them).
|
|
pub type WriteAsHook = fn(&Drawing, &str, &SaveAs) -> std::result::Result<Vec<u8>, String>;
|
|
|
|
static WRITE_AS: OnceLock<WriteAsHook> = OnceLock::new();
|
|
|
|
/// Install the host's writer for [`SaveAs`] options; saves that ask for none use [`IoHooks::write`].
|
|
pub fn set_write_as(h: WriteAsHook) {
|
|
let _ = WRITE_AS.set(h);
|
|
}
|
|
pub fn write_as() -> Option<WriteAsHook> {
|
|
WRITE_AS.get().copied()
|
|
}
|
|
|
|
/// The versions a drawing can be saved in, for messages ("2000, 2004, …, 2018").
|
|
fn version_list() -> String {
|
|
Version::ALL.iter().map(|v| v.year()).collect::<Vec<_>>().join(", ")
|
|
}
|
|
|
|
/// The `version` and `binary` parameters of a save: a version is a year (2018 or "2018"), "R2018"
|
|
/// or an `$ACADVER` code ("AC1032").
|
|
fn save_options(cmd: &str, p: &Value) -> Result<SaveAs> {
|
|
let version = match p.get("version") {
|
|
None | Some(Value::Null) => None,
|
|
Some(v) => {
|
|
let text = match v {
|
|
Value::String(s) => s.clone(),
|
|
Value::Number(n) => n.to_string(),
|
|
_ => String::new(),
|
|
};
|
|
let name: String = text.chars().take(40).collect();
|
|
Some(Version::parse(&text).ok_or_else(|| bad(cmd, format!("unknown version `{name}` (use {})", version_list())))?)
|
|
}
|
|
};
|
|
let binary = match p.get("binary") {
|
|
None | Some(Value::Null) => false,
|
|
Some(Value::Bool(b)) => *b,
|
|
Some(_) => return Err(bad(cmd, "`binary` must be true or false")),
|
|
};
|
|
Ok(SaveAs { version, binary })
|
|
}
|
|
|
|
/// Whether `path` names a DXF file (or has no extension, which saves as DXF).
|
|
fn is_dxf(path: &str) -> bool {
|
|
Format::from_path(path) == Some(Format::Dxf)
|
|
}
|
|
|
|
/// The drawing written to `path` with `opts`: through [`IoHooks::write`] when no option is set,
|
|
/// else through the [`write_as`] hook.
|
|
fn write_bytes(s: &Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<Vec<u8>> {
|
|
let hooks = io().ok_or_else(|| bad(cmd, "file formats are not available in this build"))?;
|
|
if opts.binary && !is_dxf(path) {
|
|
return Err(bad(cmd, "binary applies to DXF files only"));
|
|
}
|
|
// A drawing file is the drawing outside the Block Editor; an image is what is shown.
|
|
let d: &cadcraft_doc::Drawing = if is_drawing_file(path) { s.state()?.drawing() } else { s.doc()? };
|
|
let r = if *opts == SaveAs::default() {
|
|
(hooks.write)(d, path)
|
|
} else {
|
|
let w = write_as().ok_or_else(|| bad(cmd, "saving at a chosen version or as binary DXF is not available in this build"))?;
|
|
w(d, path, opts)
|
|
};
|
|
r.map_err(|e| bad(cmd, e))
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
CommandSpec::new("new", "New Drawing...", run_new)
|
|
.menu(&["File", "New Drawing..."])
|
|
.key("Cmd+N")
|
|
.alias(&["qnew"])
|
|
.params("{metric?: bool (default MEASUREINIT), template?: path to a .dwt/.dxf template (\".\" for none; default QNEWTEMPLATE)}")
|
|
.enabled(always)
|
|
.noundo(),
|
|
CommandSpec::new("open", "Open...", run_open)
|
|
.menu(&["File", "Open..."])
|
|
.key("Cmd+O")
|
|
.params("{path} | {data: base64, name} → {index, entities?, warnings: what reading left out}")
|
|
.enabled(always)
|
|
.noundo(),
|
|
CommandSpec::new("close", "Close", run_close).menu(&["File", "Close"]).key("Cmd+W").params("{index?}").noundo(),
|
|
CommandSpec::new("closeall", "Close All", run_closeall).menu(&["File", "Close All"]).enabled(always).noundo(),
|
|
CommandSpec::new("qsave", "Save", run_qsave_reporting).menu(&["File", "Save"]).key("Cmd+S").alias(&["save"]).params("{path?}").noundo(),
|
|
CommandSpec::new("saveas", "Save As...", run_saveas_reporting)
|
|
.menu(&["File", "Save As..."])
|
|
.key("Cmd+Shift+S")
|
|
.params("{path, version?: 2000|2004|2007|2010|2013|2018, binary?: bool}")
|
|
.noundo(),
|
|
CommandSpec::new("document.switch", "Switch Drawing", run_switch).params("{index}").enabled(always).noundo(),
|
|
CommandSpec::new("document.bytes", "Drawing as bytes", run_bytes)
|
|
.params("{format?: dxf, version?: 2000…2018, binary?: bool} → {data: base64}")
|
|
.noundo(),
|
|
CommandSpec::new("script", "Run Script...", run_script)
|
|
.menu(&["Tools", "Run Script..."])
|
|
.alias(&["scr"])
|
|
.params("{path: script (.scr) file} | {data: base64} → {lines}")
|
|
.noundo()
|
|
.interactive(|_| Ok(Box::new(ScriptM))),
|
|
]
|
|
}
|
|
|
|
/// SCRIPT: run the lines of a `.scr` file through [`Session::script`] — the same execution the
|
|
/// CLI, MCP and the control channel already use, now reachable from the command line and menus.
|
|
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let text = if let Some(_path) = str_param(p, "path") {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
let path = _path;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
let bytes =
|
|
crate::capped_read::read_capped(std::path::Path::new(path), MAX_SCRIPT_BYTES).map_err(|e| bad("script", format!("{path}: {e}")))?;
|
|
String::from_utf8_lossy(&bytes).into_owned()
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
return Err(bad("script", "paths are not available on the web; pass `data`"));
|
|
} else {
|
|
let data = str_param(p, "data").ok_or_else(|| bad("script", "`path` or `data` (base64) is required"))?;
|
|
let bytes = base64_decode(data).ok_or_else(|| bad("script", "invalid base64"))?;
|
|
String::from_utf8_lossy(&bytes).into_owned()
|
|
};
|
|
let lines = text.lines().count();
|
|
s.script(&text)?;
|
|
Ok(json!({ "lines": lines }))
|
|
}
|
|
|
|
/// SCRIPT typed at the command line or chosen from the menu: asks for the script file by name
|
|
/// (the prompt form; the menu item runs this same machine).
|
|
struct ScriptM;
|
|
|
|
impl Interactive for ScriptM {
|
|
fn name(&self) -> &'static str {
|
|
"SCRIPT"
|
|
}
|
|
fn prompt(&self, _: &Session) -> Prompt {
|
|
Prompt::new("Enter name of script file", Accept::TEXT)
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
if matches!(i, Input::Cancel | Input::Enter) {
|
|
return Ok(Step::Cancel);
|
|
}
|
|
let Input::Text(t) = i else { return Ok(Step::Continue) };
|
|
let t = t.trim();
|
|
if t.is_empty() {
|
|
return Ok(Step::Cancel);
|
|
}
|
|
match run_script(s, &json!({ "path": t })) {
|
|
Ok(_) => Ok(Step::Done),
|
|
Err(EngineError::BadParams { msg, .. }) => {
|
|
s.echo(msg);
|
|
Ok(Step::Done)
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_new(s: &mut Session, p: &Value) -> Result<Value> {
|
|
if let Some(r) = template::run_new(s, p)? {
|
|
return Ok(r);
|
|
}
|
|
let metric = s.settings.measureinit;
|
|
let i = s.new_drawing(bool_or(p, "metric", metric));
|
|
Ok(json!({ "index": i, "title": s.docs.get(i).map(|d| d.title.clone()) }))
|
|
}
|
|
|
|
fn file_name(path: &str) -> String {
|
|
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
|
}
|
|
|
|
/// A file read and decoded but not yet added to a [`Session`]. Reading is the slow part of OPEN and
|
|
/// needs no session, so a host can do it on a worker thread and hand the result to
|
|
/// [`finish_open`] on the thread that owns the session.
|
|
pub struct ReadFile {
|
|
drawing: Drawing,
|
|
binary_dxf: bool,
|
|
name: String,
|
|
path: Option<String>,
|
|
}
|
|
|
|
/// Read and decode `path` (the slow half of `OPEN {path}`). Needs no session; runs anywhere.
|
|
pub fn read_file(path: &std::path::Path) -> std::result::Result<ReadFile, String> {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
let r = std::panic::catch_unwind(|| {
|
|
let shown = path.display();
|
|
// The session keeps paths as text; a name that isn't Unicode could never be saved back to.
|
|
let Some(text_path) = path.to_str() else {
|
|
return Err(bad("open", format!("{shown}: the file name is not valid Unicode, so CADCraft can't save back to it")));
|
|
};
|
|
let bytes = crate::capped_read::read_capped(path, MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{shown}: {e}")))?;
|
|
read_bytes(&bytes, text_path, Some(text_path.to_string()))
|
|
});
|
|
match r {
|
|
Ok(r) => r.map_err(|e| e.to_string()),
|
|
Err(_) => Err(bad("open", format!("{}: the file could not be read", path.display())).to_string()),
|
|
}
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let _ = path;
|
|
Err(bad("open", "paths are not available on the web; pass `data`").to_string())
|
|
}
|
|
}
|
|
|
|
/// Add a [`read_file`] result to the session as the active drawing: the fast half of OPEN.
|
|
pub fn finish_open(s: &mut Session, f: ReadFile) -> Result<Value> {
|
|
let ReadFile { drawing, binary_dxf, name, path } = f;
|
|
let i = add_read(s, drawing, binary_dxf, &name, path);
|
|
super::blocks::xref::after_open(s, i);
|
|
Ok(opened(s, i, true))
|
|
}
|
|
|
|
fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile> {
|
|
let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?;
|
|
let drawing = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
|
|
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
|
|
// asks where to save instead of exporting over the original.
|
|
let path = path.filter(|p| is_drawing_file(p));
|
|
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
|
|
}
|
|
|
|
fn add_read(s: &mut Session, drawing: Drawing, binary_dxf: bool, name: &str, path: Option<String>) -> usize {
|
|
let notes = drawing.read_notes.clone();
|
|
let i = s.open_drawing(drawing, &file_name(name), path);
|
|
if let Some(st) = s.docs.get_mut(i) {
|
|
// QSAVE keeps a binary DXF binary.
|
|
st.binary_dxf = binary_dxf;
|
|
}
|
|
for note in notes {
|
|
s.echo(note);
|
|
}
|
|
i
|
|
}
|
|
|
|
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
|
|
let ReadFile { drawing, binary_dxf, name, path } = read_bytes(bytes, name, path)?;
|
|
Ok(add_read(s, drawing, binary_dxf, &name, path))
|
|
}
|
|
|
|
/// OPEN's reply: the drawing's index and what reading it left out.
|
|
fn opened(s: &Session, i: usize, entities: bool) -> Value {
|
|
let d = s.docs.get(i).map(|st| &st.doc);
|
|
let warnings = d.map(|d| d.read_notes.clone()).unwrap_or_default();
|
|
if entities {
|
|
json!({ "index": i, "entities": d.map(|d| d.entity_count()), "warnings": warnings })
|
|
} else {
|
|
json!({ "index": i, "warnings": warnings })
|
|
}
|
|
}
|
|
|
|
/// Open a drawing from bounded regular-file bytes or base64 data and resolve its external references.
|
|
fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
|
if let Some(_path) = str_param(p, "path") {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
let path = _path;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
let bytes =
|
|
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
|
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
|
|
super::blocks::xref::after_open(s, i);
|
|
return Ok(opened(s, i, true));
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
return Err(bad("open", "paths are not available on the web; pass `data`"));
|
|
}
|
|
let data = str_param(p, "data").ok_or_else(|| bad("open", "`path` or `data` (base64) is required"))?;
|
|
let bytes = base64_decode(data).ok_or_else(|| bad("open", "invalid base64"))?;
|
|
let name = str_param(p, "name").unwrap_or("Drawing.dxf");
|
|
let i = open_bytes(s, &bytes, name, None)?;
|
|
super::blocks::xref::after_open(s, i);
|
|
Ok(opened(s, i, false))
|
|
}
|
|
|
|
/// `close [index]`: cancel the running command, remove the drawing (default: active), keep the active index valid, and bump the document epoch; an unknown index is an error.
|
|
fn run_close(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active);
|
|
if i >= s.docs.len() {
|
|
return Err(bad("close", "no such drawing"));
|
|
}
|
|
s.cancel();
|
|
s.docs.remove(i);
|
|
if i < s.active {
|
|
// An earlier drawing went away: the active one shifted down by one.
|
|
s.active -= 1;
|
|
}
|
|
if s.active >= s.docs.len() {
|
|
s.active = s.docs.len().saturating_sub(1);
|
|
}
|
|
s.bump_doc_epoch();
|
|
ok()
|
|
}
|
|
|
|
/// `closeall`: cancel the running command, remove every drawing, reset the active index to 0 and bump the document epoch.
|
|
fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
s.cancel();
|
|
s.docs.clear();
|
|
s.active = 0;
|
|
s.bump_doc_epoch();
|
|
ok()
|
|
}
|
|
|
|
pub(crate) fn save_to(s: &mut Session, path: &str) -> Result<usize> {
|
|
let opts = SaveAs { version: None, binary: s.state()?.binary_dxf && is_dxf(path) };
|
|
save_as(s, "save", path, &opts)
|
|
}
|
|
|
|
/// Save to `path` with `opts`. A drawing file then keeps the version and binary choice: the
|
|
/// version goes into the drawing's `$ACADVER`, so later saves (QSAVE) write it again.
|
|
fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usize> {
|
|
if s.doc()?.refedit.is_some() {
|
|
return Err(bad(cmd, "A block reference is being edited in place: finish with REFCLOSE (Save or Discard) before saving."));
|
|
}
|
|
let bytes = write_bytes(s, cmd, path, opts)?;
|
|
let n = commit_save(s, path, &bytes, store)?;
|
|
if is_drawing_file(path) {
|
|
let st = s.state_mut()?;
|
|
st.binary_dxf = opts.binary;
|
|
if let Some(v) = opts.version
|
|
&& st.drawing().header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str) != Some(v.code())
|
|
{
|
|
std::sync::Arc::make_mut(st.drawing_arc_mut()).header.set_str("ACADVER", v.code());
|
|
st.saved = st.drawing().clone();
|
|
}
|
|
}
|
|
Ok(n)
|
|
}
|
|
|
|
/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on
|
|
/// the web, which has no file system, a browser download named after `path` via [`deliver`].
|
|
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
|
|
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
|
|
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let name = file_name(path);
|
|
let deliver = deliver().ok_or_else(|| bad("save", "saving files is not available in this build; use document.bytes"))?;
|
|
deliver(&name, "application/octet-stream", bytes).map_err(|e| bad("save", format!("{name}: {e}")))
|
|
}
|
|
}
|
|
|
|
/// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps
|
|
/// its unsaved changes, file name and title. On the web a started download counts as saved: the
|
|
/// browser owns the file from there (we can't see whether the user keeps it), and the name stays so
|
|
/// a later QSAVE downloads the drawing again under it. An image or PDF export (SVG, PNG, PDF) only
|
|
/// stores the file: the drawing keeps its name, title and unsaved changes, so closing still asks.
|
|
fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&str, &[u8]) -> Result<()>) -> Result<usize> {
|
|
s.state()?;
|
|
store(path, bytes)?;
|
|
if !is_drawing_file(path) {
|
|
return Ok(bytes.len());
|
|
}
|
|
let st = s.state_mut()?;
|
|
st.saved = st.drawing().clone();
|
|
st.path = Some(path.to_string());
|
|
st.title = file_name(path);
|
|
// A BEgin group still open when the drawing is saved ends there.
|
|
st.end_undo_group();
|
|
Ok(bytes.len())
|
|
}
|
|
|
|
/// The save result; on the web it also names the file the browser downloaded.
|
|
fn saved(path: &str, bytes: usize) -> Value {
|
|
let mut r = json!({ "path": path, "bytes": bytes });
|
|
if cfg!(target_arch = "wasm32") {
|
|
r["download"] = json!(file_name(path));
|
|
}
|
|
r
|
|
}
|
|
|
|
fn run_save(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let path = match str_param(p, "path") {
|
|
Some(p) => p.to_string(),
|
|
None => s.state()?.path.clone().ok_or_else(|| bad("qsave", "drawing has no file name yet; use saveas {path}"))?,
|
|
};
|
|
let n = save_to(s, &path)?;
|
|
Ok(saved(&path, n))
|
|
}
|
|
|
|
fn run_saveas(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let path = str_param(p, "path").ok_or_else(|| bad("saveas", "`path` is required"))?.to_string();
|
|
let opts = save_options("saveas", p)?;
|
|
let n = save_as(s, "saveas", &path, &opts)?;
|
|
let mut r = saved(&path, n);
|
|
if is_drawing_file(&path) {
|
|
r["version"] = json!(opts.version.unwrap_or_else(|| drawing_version(s, &path)).year());
|
|
r["binary"] = json!(opts.binary);
|
|
}
|
|
Ok(r)
|
|
}
|
|
|
|
/// The version a drawing is saved in at `path` when none is asked for: its `$ACADVER` (2018 by
|
|
/// default), and for DWG at least 2004 (the io layer's rule: R2000 DWG has no true colours).
|
|
fn drawing_version(s: &Session, path: &str) -> Version {
|
|
let v =
|
|
s.doc().ok().and_then(|d| d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver)).unwrap_or_default();
|
|
if is_dxf(path) { v } else { v.max(Version::R2004) }
|
|
}
|
|
|
|
/// `document.switch index`: require the index, cancel the running command, activate that drawing and bump the epoch even if it was already active (screenshot identity).
|
|
fn run_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.switch", "`index` is required"))? as usize;
|
|
if i >= s.docs.len() {
|
|
return Err(bad("document.switch", "no such drawing"));
|
|
}
|
|
s.cancel();
|
|
s.active = i;
|
|
// Bump even when `i` is already active. This is intentionally conservative: the command
|
|
// cancels the active command, and a screenshot client can only see observed state, not
|
|
// whether the switch was a no-op. Rejecting an outstanding capture is safer than returning
|
|
// an image whose queueing identity the client cannot distinguish from a real switch.
|
|
s.bump_doc_epoch();
|
|
ok()
|
|
}
|
|
|
|
fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let fmt = str_param(p, "format").unwrap_or("dxf");
|
|
let opts = save_options("document.bytes", p)?;
|
|
let version = opts.version.unwrap_or_else(|| drawing_version(s, &format!("drawing.{fmt}")));
|
|
let lost = not_saved_as(s.doc()?, version);
|
|
let bytes = write_bytes(s, "document.bytes", &format!("drawing.{fmt}"), &opts)?;
|
|
let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() });
|
|
if drops_unknown(format!("drawing.{fmt}")) {
|
|
report_not_saved(s, &mut r, &lost);
|
|
}
|
|
Ok(r)
|
|
}
|
|
|
|
/// Entities a DXF or DWG file doesn't keep: those of object types CADCraft doesn't model
|
|
/// (3DSOLID, MULTILEADER… read from other programs' files and kept in the drawing, but
|
|
/// not written), counted by DXF type across model space, layouts and block definitions. Saved
|
|
/// at the drawing's own version; see [`not_saved_as`].
|
|
pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap<String, usize> {
|
|
let v = d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver).unwrap_or_default();
|
|
not_saved_as(d, v)
|
|
}
|
|
|
|
/// Solid modeler entities whose ACIS geometry a file of AutoCAD 2013 and later keeps in its data
|
|
/// store section, which is written back with the drawing (`Foreign::data_store`).
|
|
const MODELER_TYPES: [&str; 3] = ["3DSOLID", "REGION", "BODY"];
|
|
|
|
/// [`not_saved`] for a file saved as `version`. From 2013 on, a 3D solid, region or body the data
|
|
/// store has a record for keeps its geometry and is not counted.
|
|
pub fn not_saved_as(d: &Drawing, version: Version) -> std::collections::BTreeMap<String, usize> {
|
|
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
|
let stored = if version >= Version::R2013 { d.foreign.data_store_entities() } else { Default::default() };
|
|
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
|
|
for e in stores.flat_map(|s| s.iter()) {
|
|
if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind {
|
|
if MODELER_TYPES.iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && stored.contains(&e.handle) {
|
|
continue;
|
|
}
|
|
let n = out.entry(u.dxf_type.clone()).or_insert(0);
|
|
*n = n.saturating_add(1);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The command-line warning for [`not_saved`] entities, or `None` when the file keeps everything.
|
|
pub fn not_saved_message(lost: &std::collections::BTreeMap<String, usize>) -> Option<String> {
|
|
if lost.is_empty() {
|
|
return None;
|
|
}
|
|
let list: Vec<String> = lost.iter().map(|(t, n)| format!("{n} {t}")).collect();
|
|
Some(format!("Not saved in the DWG file (its writer can't write these object types yet; DXF keeps them): {}", list.join(", ")))
|
|
}
|
|
|
|
/// Whether saving to `path` leaves out the entities [`not_saved`] counts: DWG does (the DWG
|
|
/// bridge drops types it doesn't know); DXF writes them back as read.
|
|
pub fn drops_unknown(path: impl AsRef<std::path::Path>) -> bool {
|
|
Format::from_path(path).is_some_and(Format::drops_unknown)
|
|
}
|
|
|
|
/// Whether `path` is saved as a drawing (DXF, DWG) rather than exported as an image.
|
|
fn is_drawing_file(path: impl AsRef<std::path::Path>) -> bool {
|
|
Format::from_path(path).is_some_and(Format::is_drawing)
|
|
}
|
|
|
|
/// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`.
|
|
fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap<String, usize>) {
|
|
let Some(msg) = not_saved_message(lost) else { return };
|
|
s.echo(msg);
|
|
if let Some(o) = r.as_object_mut() {
|
|
o.insert("notSaved".into(), json!(lost));
|
|
}
|
|
}
|
|
|
|
/// Save, then report the entities the file couldn't keep (nothing is dropped silently).
|
|
fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result<Value>) -> Result<Value> {
|
|
// What the file will hold depends on the version it is saved as (the data store from 2013).
|
|
let path = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone())).unwrap_or_default();
|
|
let version = save_options("save", p).ok().and_then(|o| o.version).unwrap_or_else(|| drawing_version(s, &path));
|
|
let lost = s.doc().map(|d| not_saved_as(d, version)).unwrap_or_default();
|
|
let mut r = run(s, p)?;
|
|
if r.get("path").and_then(Value::as_str).is_some_and(drops_unknown) {
|
|
report_not_saved(s, &mut r, &lost);
|
|
}
|
|
Ok(r)
|
|
}
|
|
|
|
fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result<Value> {
|
|
save_reporting(s, p, run_save)
|
|
}
|
|
|
|
fn run_saveas_reporting(s: &mut Session, p: &Value) -> Result<Value> {
|
|
save_reporting(s, p, run_saveas)
|
|
}
|
|
|
|
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
|
|
pub fn base64_encode(data: &[u8]) -> String {
|
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
for chunk in data.chunks(3) {
|
|
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
|
|
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
|
|
for i in 0..4 {
|
|
if i <= chunk.len() {
|
|
out.push(char::from(B64[((n >> (18 - 6 * i)) & 63) as usize]));
|
|
} else {
|
|
out.push('=');
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
|
let mut out = Vec::with_capacity(s.len() * 3 / 4);
|
|
let mut buf = 0u32;
|
|
let mut bits = 0;
|
|
for c in s.bytes() {
|
|
let v = match c {
|
|
b'A'..=b'Z' => c - b'A',
|
|
b'a'..=b'z' => c - b'a' + 26,
|
|
b'0'..=b'9' => c - b'0' + 52,
|
|
b'+' | b'-' => 62,
|
|
b'/' | b'_' => 63,
|
|
b'=' | b'\n' | b'\r' | b' ' => continue,
|
|
_ => return None,
|
|
};
|
|
buf = (buf << 6) | u32::from(v);
|
|
bits += 6;
|
|
if bits >= 8 {
|
|
bits -= 8;
|
|
out.push(((buf >> bits) & 0xff) as u8);
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
use crate::temp_dir::TempDir;
|
|
|
|
fn active_title(s: &Session) -> String {
|
|
s.state().unwrap().title.clone()
|
|
}
|
|
|
|
#[test]
|
|
fn script_command_runs_a_scr_file() {
|
|
let dir = TempDir::new("script");
|
|
let path = dir.file("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");
|
|
let mut s = Session::new();
|
|
let r = s.execute("script", &json!({ "path": path })).unwrap();
|
|
assert_eq!(r["lines"], 3);
|
|
assert_eq!(s.state().unwrap().doc.model.len(), 2, "the comment is skipped, circle and rectang draw");
|
|
}
|
|
|
|
#[test]
|
|
fn script_command_reports_a_missing_file() {
|
|
let mut s = Session::new();
|
|
let e = s.execute("script", &json!({ "path": "no/such/script.scr" })).unwrap_err().to_string();
|
|
assert!(e.contains("no/such/script.scr"), "{e}");
|
|
assert_eq!(s.state().unwrap().doc.model.len(), 0);
|
|
}
|
|
|
|
/// A script that runs itself must be refused, not recurse until the stack dies.
|
|
#[test]
|
|
fn a_script_cannot_start_a_script() {
|
|
let dir = TempDir::new("script");
|
|
let inner = dir.file("inner.scr", "circle 0,0 1\n");
|
|
let outer = dir.file("outer.scr", format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));
|
|
let mut s = Session::new();
|
|
let e = s.execute("script", &json!({ "path": outer })).unwrap_err().to_string();
|
|
assert!(e.contains("already running"), "{e}");
|
|
}
|
|
|
|
#[test]
|
|
fn typed_script_prompts_for_the_file_then_runs_or_reports_it() {
|
|
let dir = TempDir::new("script");
|
|
let good = dir.file("typed.scr", "circle 0,0 1\n");
|
|
let mut s = Session::new();
|
|
s.cmdline("script").unwrap();
|
|
assert!(s.running.is_some(), "SCRIPT asks for the file name");
|
|
s.cmdline(&good).unwrap();
|
|
assert!(s.running.is_none());
|
|
assert_eq!(s.state().unwrap().doc.model.len(), 1);
|
|
|
|
// A file that cannot be read: the error is echoed, the command still ends.
|
|
s.cmdline("script").unwrap();
|
|
s.cmdline("no/such/script.scr").unwrap();
|
|
assert!(s.running.is_none());
|
|
assert!(s.log.iter().any(|l| l.contains("no/such/script.scr")), "{:?}", s.log);
|
|
}
|
|
|
|
#[test]
|
|
fn closing_earlier_inactive_drawing_keeps_active_drawing() {
|
|
let mut s = Session::new();
|
|
s.execute("new", &json!({})).unwrap();
|
|
s.execute("new", &json!({})).unwrap();
|
|
s.execute("document.switch", &json!({"index": 1})).unwrap();
|
|
assert_eq!(active_title(&s), "Drawing2");
|
|
s.execute("close", &json!({"index": 0})).unwrap();
|
|
assert_eq!(active_title(&s), "Drawing2");
|
|
assert_eq!(s.docs.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn closing_later_inactive_drawing_keeps_active_drawing() {
|
|
let mut s = Session::new();
|
|
s.execute("new", &json!({})).unwrap();
|
|
s.execute("new", &json!({})).unwrap();
|
|
s.execute("document.switch", &json!({"index": 1})).unwrap();
|
|
s.execute("close", &json!({"index": 2})).unwrap();
|
|
assert_eq!(active_title(&s), "Drawing2");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod pr193_tests {
|
|
use super::*;
|
|
use crate::Session;
|
|
|
|
#[test]
|
|
fn failed_store_keeps_unsaved_changes_and_delivered_save_clears_them() {
|
|
let mut s = Session::new();
|
|
s.cmdline("circle 4,4 1").unwrap();
|
|
let st = s.state().unwrap();
|
|
let title = st.title.clone();
|
|
assert!(st.is_dirty() && st.path.is_none());
|
|
|
|
// The file route is unavailable or fails (the web without a download): no success, still dirty.
|
|
let failed = commit_save(&mut s, "a/Owned.dxf", b"0\nEOF\n", |_, _| Err(bad("save", "no download")));
|
|
assert!(failed.is_err());
|
|
let st = s.state().unwrap();
|
|
assert!(st.is_dirty(), "a save that delivered nothing must keep the unsaved state");
|
|
assert_eq!((st.path.as_deref(), st.title.as_str()), (None, title.as_str()));
|
|
|
|
// The file route took the bytes (written, or a download started): saved under that name.
|
|
let mut got = None;
|
|
let n = commit_save(&mut s, "a/Owned.dxf", b"0\nEOF\n", |p, b| {
|
|
got = Some((p.to_string(), b.len()));
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
assert_eq!((n, got), (6, Some(("a/Owned.dxf".to_string(), 6))));
|
|
let st = s.state().unwrap();
|
|
assert!(!st.is_dirty());
|
|
assert_eq!((st.path.as_deref(), st.title.as_str()), (Some("a/Owned.dxf"), "Owned.dxf"));
|
|
assert_eq!(saved("a/Owned.dxf", n)["bytes"], 6);
|
|
}
|
|
}
|