mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-01 06:46:50 -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>
809 lines
37 KiB
Rust
809 lines
37 KiB
Rust
//! Moving drawings in and out: EXPORT (DXF, DWG, SVG, PNG or PDF), IMPORT (the objects of another
|
|
//! DXF/DWG file), PREVIEW (a picture of the plot) and PUBLISH (plot several sheets at once).
|
|
//! ATTACH, SHARE and 3DORBIT are registered so the tool bar and command line say they are not
|
|
//! available yet.
|
|
//!
|
|
//! Files go through the host's io hooks ([`super::file::io`]) like OPEN and SAVEAS, so the engine
|
|
//! stays I/O-agnostic; the JSON forms never open dialogs (the UI asks for files when these
|
|
//! commands come from a menu, the tool bar or the command line).
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::sync::Arc;
|
|
|
|
use cadcraft_color::Rgb;
|
|
use cadcraft_doc::{Drawing, Entity, EntityKind, PageSetup, Space};
|
|
use cadcraft_geom::{Bounds2, Mat3, Vec2};
|
|
use cadcraft_render::paper::{self, Sheet};
|
|
use cadcraft_render::raster::{RasterOptions, View};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::file::{base64_decode, base64_encode, io};
|
|
use super::{CommandSpec, bad, point_value, str_param};
|
|
|
|
use crate::{EngineError, Result, Session};
|
|
|
|
/// Formats EXPORT writes (the io layer picks the writer from the extension).
|
|
pub const EXPORT_FORMATS: &[&str] = &["dxf", "dwg", "svg", "png", "pdf"];
|
|
/// Default and largest plot preview width, in pixels.
|
|
const PREVIEW_PX: u64 = 1200;
|
|
const MAX_PREVIEW_PX: u64 = 4096;
|
|
/// Most sheets one PUBLISH plots.
|
|
const MAX_SHEETS: usize = 256;
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
CommandSpec::new("import", "Import...", run_import)
|
|
.menu(&["File", "Import..."])
|
|
.alias(&["imp"])
|
|
.params("{path | data: base64, name?: \"x.dwg\"} (DXF or DWG), at?: [x,y] (default 0,0), scale?: (default 1) → {imported, handles, layers, styles, blocks}"),
|
|
CommandSpec::new("export", "Export...", run_export)
|
|
.menu(&["File", "Export..."])
|
|
.alias(&["exp"])
|
|
.params("{path? (else returns base64 `data`), format?: dxf|dwg|svg|png|pdf (default: the path's extension)} → {format, bytes, path? | data}")
|
|
.noundo(),
|
|
CommandSpec::new("preview", "Plot Preview", run_preview)
|
|
.menu(&["File", "Plot Preview"])
|
|
.alias(&["pre"])
|
|
.params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, plotTransparency?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}")
|
|
.noundo(),
|
|
CommandSpec::new("publish", "Batch Publish...", run_publish)
|
|
.menu(&["File", "Batch Publish..."])
|
|
.params("{path?: \"NAME.pdf\" (writes NAME-<sheet>.pdf per sheet; else returns base64 `data` per sheet), layouts?: [name | \"Model\"] (default: Model if it has objects, then every layout in tab order)} → {sheets: [{layout, bytes, path? | data}]}")
|
|
.noundo(),
|
|
CommandSpec::new("pdfbook", "Sheet Books to PDF...", run_pdfbook)
|
|
.menu(&["File", "Sheet Books to PDF..."])
|
|
.alias(&["printpdf", "pdfb"])
|
|
.params("{path?: \"DIR/NAME^NAME2.pdf\" (default: the drawing's file; a `^` splits the name into one book name per row of frames; else returns base64 `data` per book), names?: [book names], frameHeight?: mm (default 297), unit?: mm per drawing unit (default 1)} → {books: [{name, pages, bytes, path? | data}], skipped}: closed polylines of the frame height in model space are sheet frames; each row (same lower edge, bottom to top) is one PDF, its frames left to right one page each on the paper (ISO A-series, either orientation) they match")
|
|
.noundo(),
|
|
CommandSpec::new("attach", "Attach", |_, _| {
|
|
Err(EngineError::Other("ATTACH is not available yet: raster images and PDF/DWF/DGN underlays can't be shown yet".into()))
|
|
})
|
|
.params("not available yet")
|
|
.noundo(),
|
|
CommandSpec::new("share", "Share", |_, _| {
|
|
Err(EngineError::Other("SHARE is not available yet: there is no online service to share through; EXPORT or PUBLISH the drawing and send the file".into()))
|
|
})
|
|
.params("not available yet")
|
|
.noundo(),
|
|
CommandSpec::new("3dorbit", "Orbit", |_, _| Err(EngineError::Other("3DORBIT is not available yet: CADCraft draws in 2D (plan view) only".into())))
|
|
.alias(&["3do", "orbit"])
|
|
.params("not available yet")
|
|
.noundo(),
|
|
]
|
|
}
|
|
|
|
fn extension(path: &str) -> String {
|
|
std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
|
}
|
|
|
|
/// Write `bytes` to `path` through a temporary file, so a failed write keeps the old file.
|
|
fn write_file(cmd: &str, 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 export write (CWE-59).
|
|
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
deliver_file(cmd, path, bytes, super::file::deliver())
|
|
}
|
|
}
|
|
|
|
/// No file system on the web: hand the bytes to the host's delivery hook (a browser download)
|
|
/// named after `path`, like SAVE and TABLEEXPORT do. EXPORT and PLOT (PUBLISH, EXPORTPDF) use it.
|
|
#[cfg(any(test, target_arch = "wasm32"))]
|
|
pub(super) fn deliver_file(cmd: &str, path: &str, bytes: &[u8], hook: Option<super::file::DeliverHook>) -> Result<()> {
|
|
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
|
let mime = match extension(&name).as_str() {
|
|
"svg" => "image/svg+xml",
|
|
"png" => "image/png",
|
|
"pdf" => "application/pdf",
|
|
_ => "application/octet-stream",
|
|
};
|
|
let deliver = hook.ok_or_else(|| bad(cmd, "files can't be written in this build; omit `path` to get the file as base64 `data`"))?;
|
|
deliver(&name, mime, bytes).map_err(|e| bad(cmd, format!("{name}: {e}")))
|
|
}
|
|
|
|
// ---------- EXPORT ----------
|
|
|
|
fn run_export(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let hooks = io().ok_or_else(|| bad("export", "file formats are not available in this build"))?;
|
|
let path = str_param(p, "path");
|
|
let format = match str_param(p, "format") {
|
|
Some(f) => f.trim().trim_start_matches('.').to_ascii_lowercase(),
|
|
None => path.map(extension).unwrap_or_default(),
|
|
};
|
|
if !EXPORT_FORMATS.contains(&format.as_str()) {
|
|
return Err(bad("export", "`format` must be dxf, dwg, svg, png or pdf (or give a `path` with one of those extensions)"));
|
|
}
|
|
let bytes = (hooks.write)(s.doc()?, &format!("export.{format}")).map_err(|e| bad("export", e))?;
|
|
match path {
|
|
Some(path) => {
|
|
write_file("export", path, &bytes)?;
|
|
Ok(json!({ "path": path, "format": format, "bytes": bytes.len(), "message": format!("Exported to {path}") }))
|
|
}
|
|
None => Ok(json!({ "data": base64_encode(&bytes), "format": format, "bytes": bytes.len() })),
|
|
}
|
|
}
|
|
|
|
// ---------- IMPORT ----------
|
|
|
|
fn run_import(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let hooks = io().ok_or_else(|| bad("import", "file formats are not available in this build"))?;
|
|
let at = match p.get("at") {
|
|
Some(v) => point_value(v).ok_or_else(|| bad("import", "`at` must be a point [x, y]"))?,
|
|
None => Vec2::ZERO,
|
|
};
|
|
let scale = match p.get("scale") {
|
|
Some(v) => v
|
|
.as_f64()
|
|
.filter(|k| k.is_finite() && (1e-9..=1e9).contains(&k.abs()))
|
|
.ok_or_else(|| bad("import", "`scale` must be a non-zero number"))?,
|
|
None => 1.0,
|
|
};
|
|
let (bytes, name) = if let Some(path) = str_param(p, "path") {
|
|
(read_file(path)?, path.to_string())
|
|
} else {
|
|
let data = str_param(p, "data").ok_or_else(|| bad("import", "`path` or `data` (base64) is required"))?;
|
|
(base64_decode(data).ok_or_else(|| bad("import", "invalid base64"))?, str_param(p, "name").unwrap_or("import.dxf").to_string())
|
|
};
|
|
let src = (hooks.read)(&bytes, &name).map_err(|e| bad("import", e))?;
|
|
let file = std::path::Path::new(&name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
|
import_drawing(s, &src, at, scale, &file)
|
|
}
|
|
|
|
/// Copy `src`'s model space into the current space (scaled about the origin, then moved to
|
|
/// `at`), with the layers, linetypes, styles and blocks it needs.
|
|
fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &str) -> Result<Value> {
|
|
let m = Mat3::scale(scale, scale).then(Mat3::translate(at));
|
|
let moved = at != Vec2::ZERO || scale != 1.0;
|
|
let space = s.space();
|
|
let d = s.doc_mut()?;
|
|
let (layers, styles) = merge_tables(d, src);
|
|
let renames = block_names(d, src);
|
|
for (from, to) in &renames {
|
|
let Some(b) = src.blocks.get(from) else { continue };
|
|
let mut nb = (**b).clone();
|
|
nb.name = to.clone();
|
|
nb.entities = Default::default();
|
|
for e in b.entities.iter() {
|
|
let e = adopt(d, e, &renames);
|
|
nb.entities.push(e);
|
|
}
|
|
d.blocks.insert(to.clone(), Arc::new(nb));
|
|
}
|
|
let mut handles = Vec::new();
|
|
for e in src.model.iter() {
|
|
let mut e = adopt(d, e, &renames);
|
|
if moved {
|
|
e.kind.transform(&m);
|
|
}
|
|
d.ensure_layer(&e.common.layer);
|
|
handles.push(e.handle);
|
|
if let Some(st) = d.space_mut(&space) {
|
|
st.push(e);
|
|
}
|
|
}
|
|
s.set_selection(handles.clone());
|
|
Ok(json!({
|
|
"imported": handles.len(),
|
|
"handles": handles.iter().map(|h| h.hex()).collect::<Vec<_>>(),
|
|
"layers": layers,
|
|
"styles": styles,
|
|
"blocks": renames.len(),
|
|
"message": format!("{} objects imported from {file}", handles.len()),
|
|
}))
|
|
}
|
|
|
|
fn read_file(_path: &str) -> Result<Vec<u8>> {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
std::fs::read(_path).map_err(|e| bad("import", format!("{_path}: {e}")))
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
Err(bad("import", "paths are not available on the web; pass `data`"))
|
|
}
|
|
|
|
/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that
|
|
/// exists keeps this drawing's definition). Returns how many layers and how many other records were added.
|
|
fn merge_tables(d: &mut Drawing, src: &Drawing) -> (usize, usize) {
|
|
fn merge<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) -> usize {
|
|
let mut n = 0;
|
|
for t in theirs {
|
|
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
|
|
ours.push(t.clone());
|
|
n += 1;
|
|
}
|
|
}
|
|
n
|
|
}
|
|
let layers = merge(&mut d.layers, &src.layers, |l| &l.name);
|
|
let others = merge(&mut d.linetypes, &src.linetypes, |l| &l.name)
|
|
+ merge(&mut d.text_styles, &src.text_styles, |t| &t.name)
|
|
+ merge(&mut d.dim_styles, &src.dim_styles, |t| &t.name)
|
|
+ merge(&mut d.mleader_styles, &src.mleader_styles, |t| &t.name)
|
|
+ merge(&mut d.table_styles, &src.table_styles, |t| &t.name);
|
|
(layers, others)
|
|
}
|
|
|
|
/// Which source blocks to copy, and under which name: new names keep theirs, generated
|
|
/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing
|
|
/// already has keeps this drawing's definition (not copied).
|
|
fn block_names(d: &Drawing, src: &Drawing) -> BTreeMap<String, String> {
|
|
let mut out = BTreeMap::new();
|
|
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
|
for (name, b) in &src.blocks {
|
|
let upper = name.to_ascii_uppercase();
|
|
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
|
|
continue;
|
|
}
|
|
let anonymous = b.anonymous || name.starts_with('*');
|
|
let to = if !taken.contains(&upper) {
|
|
name.clone()
|
|
} else if anonymous {
|
|
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
|
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) {
|
|
Some(n) => n,
|
|
None => continue,
|
|
}
|
|
} else {
|
|
continue;
|
|
};
|
|
taken.insert(to.to_ascii_uppercase());
|
|
out.insert(name.clone(), to);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and
|
|
/// links to other source objects (associative dimension points) dropped.
|
|
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
|
|
let mut e = e.clone();
|
|
e.handle = d.new_handle();
|
|
match &mut e.kind {
|
|
EntityKind::Insert(i) => {
|
|
if let Some(n) = renames.get(&i.block) {
|
|
i.block = n.clone();
|
|
}
|
|
}
|
|
EntityKind::Dimension(dm) => {
|
|
dm.assoc.clear();
|
|
if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) {
|
|
dm.block = Some(n.clone());
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
e
|
|
}
|
|
|
|
// ---------- PREVIEW ----------
|
|
|
|
/// The space a plot command works on: `layout` ("Model" or a layout name) or the current one.
|
|
fn plot_space(cmd: &str, s: &Session, p: &Value) -> Result<Space> {
|
|
Ok(match str_param(p, "layout") {
|
|
Some(n) if n.eq_ignore_ascii_case("model") => Space::Model,
|
|
Some(n) => Space::Paper(s.doc()?.layout(n).map(|l| l.name.clone()).ok_or_else(|| bad(cmd, format!("no layout `{n}`")))?),
|
|
None => s.space(),
|
|
})
|
|
}
|
|
|
|
/// Paper units per drawing unit, as the PDF plotter computes it.
|
|
fn plot_scale(b: &Bounds2, sheet: &Sheet, fit: bool, scale: Option<f64>) -> f64 {
|
|
if let Some(s) = scale
|
|
&& !fit
|
|
{
|
|
return s;
|
|
}
|
|
if !fit || 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 run_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let space = plot_space("preview", s, p)?;
|
|
let d = s.doc()?;
|
|
let mut page = match space.layout() {
|
|
Some(n) => d.layout(n).map(|l| l.page.clone()).unwrap_or_default(),
|
|
None => {
|
|
// Model space plots on A4 (metric) or ANSI A, like the PDF plotter.
|
|
let mut page = PageSetup::default();
|
|
if paper::paper_unit_mm(d) == 1.0
|
|
&& let Some(a4) = paper::paper_size("A4")
|
|
{
|
|
page.paper = a4.name.into();
|
|
page.width_mm = a4.width_mm;
|
|
page.height_mm = a4.height_mm;
|
|
}
|
|
page
|
|
}
|
|
};
|
|
if let Some(name) = str_param(p, "paper") {
|
|
let ps = paper::paper_size(name).ok_or_else(|| bad("preview", format!("unknown paper size `{name}`")))?;
|
|
page.paper = ps.name.into();
|
|
page.width_mm = ps.width_mm;
|
|
page.height_mm = ps.height_mm;
|
|
}
|
|
if let Some(l) = p.get("landscape").and_then(Value::as_bool) {
|
|
page.landscape = l;
|
|
}
|
|
let sheet = Sheet::from_page(&page, paper::page_unit_mm(d, &page));
|
|
let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true });
|
|
let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency);
|
|
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model));
|
|
let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0);
|
|
let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX);
|
|
let height = ((width as f64) * sheet.size.y / sheet.size.x).round();
|
|
let height = if height.is_finite() { (height as u64).clamp(16, MAX_PREVIEW_PX) } else { width };
|
|
let px_per_paper_unit = width as f64 / sheet.size.x;
|
|
// Curves are flattened to about half a pixel.
|
|
let est = plot_scale(&d.extents(&space), &sheet, fit, scale);
|
|
let tol = 0.5 / (px_per_paper_unit * est);
|
|
let ropts = cadcraft_render::Options {
|
|
tolerance: if tol.is_finite() && tol > 0.0 { tol } else { 0.01 },
|
|
min_dash: 0.0,
|
|
text: true,
|
|
fill: true,
|
|
lineweights,
|
|
transparency,
|
|
..Default::default()
|
|
};
|
|
let list = cadcraft_render::build_plot(d, &space, &ropts);
|
|
let b = list.bounds;
|
|
// Paper point q shows drawing point (q - to) / k + from.
|
|
let (from, k, to) = if fit || space == Space::Model {
|
|
(if b.is_empty() { Vec2::ZERO } else { b.center() }, plot_scale(&b, &sheet, fit, scale), sheet.printable.center())
|
|
} else {
|
|
(Vec2::ZERO, plot_scale(&b, &sheet, fit, scale), Vec2::ZERO)
|
|
};
|
|
let world = Bounds2::new((Vec2::ZERO - to) / k + from, (sheet.size - to) / k + from);
|
|
if ![world.min.x, world.min.y, world.max.x, world.max.y].iter().all(|v| v.is_finite()) {
|
|
return Err(bad("preview", "the drawing is too large to preview at this scale"));
|
|
}
|
|
let (w, h) = (u32::try_from(width).unwrap_or(1), u32::try_from(height).unwrap_or(1));
|
|
let view = View::fit(&world, w, h, 0.0);
|
|
let px_per_mm = (px_per_paper_unit / sheet.unit_mm) as f32;
|
|
let ro = RasterOptions {
|
|
background: Rgb(255, 255, 255),
|
|
hairline: 1.0,
|
|
px_per_mm: if lineweights && px_per_mm.is_finite() { px_per_mm } else { 0.0 },
|
|
antialias: true,
|
|
};
|
|
let png = cadcraft_render::raster::render_png(&list, &view, &ro).ok_or_else(|| bad("preview", "rendering the preview failed"))?;
|
|
let layout = space.tab_name();
|
|
Ok(json!({ "data": base64_encode(&png), "width": w, "height": h, "layout": layout, "paper": page.paper, "landscape": page.landscape }))
|
|
}
|
|
|
|
// ---------- PUBLISH ----------
|
|
|
|
/// A layout name made safe for a file name.
|
|
fn file_part(name: &str) -> String {
|
|
name.chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') { c } else { '_' }).collect()
|
|
}
|
|
|
|
fn run_publish(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let d = s.doc()?;
|
|
let sheets: Vec<String> = match p.get("layouts") {
|
|
Some(v) => {
|
|
let a = v.as_array().ok_or_else(|| bad("publish", "`layouts` must be a list of layout names"))?;
|
|
a.iter()
|
|
.map(|n| {
|
|
let n = n.as_str().ok_or_else(|| bad("publish", "`layouts` must be a list of layout names"))?;
|
|
if n.eq_ignore_ascii_case("model") {
|
|
return Ok("Model".to_string());
|
|
}
|
|
d.layout(n).map(|l| l.name.clone()).ok_or_else(|| bad("publish", format!("no layout `{n}`")))
|
|
})
|
|
.collect::<Result<_>>()?
|
|
}
|
|
None => {
|
|
let mut layouts: Vec<_> = d.layouts.iter().collect();
|
|
layouts.sort_by_key(|l| l.tab_order);
|
|
let model = (!d.model.is_empty()).then(|| "Model".to_string());
|
|
model.into_iter().chain(layouts.iter().map(|l| l.name.clone())).collect()
|
|
}
|
|
};
|
|
if sheets.is_empty() {
|
|
return Err(bad("publish", "there are no sheets to publish"));
|
|
}
|
|
if sheets.len() > MAX_SHEETS {
|
|
return Err(bad("publish", format!("at most {MAX_SHEETS} sheets can be published at once")));
|
|
}
|
|
let base = str_param(p, "path").map(|path| {
|
|
let pb = std::path::Path::new(path);
|
|
let stem = pb.file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Drawing".into());
|
|
(pb.parent().map(std::path::Path::to_path_buf).unwrap_or_default(), stem)
|
|
});
|
|
let mut out = Vec::new();
|
|
for name in &sheets {
|
|
let mut q = json!({ "layout": name });
|
|
if let Some((dir, stem)) = &base {
|
|
q["path"] = json!(dir.join(format!("{stem}-{}.pdf", file_part(name))).to_string_lossy());
|
|
}
|
|
let mut r = s.execute("plot", &q).map_err(|e| bad("publish", format!("{name}: {e}")))?;
|
|
if let Some(o) = r.as_object_mut() {
|
|
o.insert("layout".into(), json!(name));
|
|
}
|
|
out.push(r);
|
|
}
|
|
let message = match &base {
|
|
Some(_) => format!("Published {} sheet(s): {}", out.len(), out.iter().filter_map(|r| r["path"].as_str()).collect::<Vec<_>>().join(", ")),
|
|
None => format!("Published {} sheet(s)", out.len()),
|
|
};
|
|
Ok(json!({ "sheets": out, "message": message }))
|
|
}
|
|
|
|
// ---------- PDFBOOK ----------
|
|
|
|
/// Frame sizes match a paper to this many millimetres.
|
|
const FRAME_TOL_MM: f64 = 0.5;
|
|
/// Most frames one PDFBOOK looks at.
|
|
const MAX_FRAMES: usize = 100_000;
|
|
|
|
/// A sheet frame found in model space.
|
|
struct Frame {
|
|
window: Bounds2,
|
|
paper: &'static str,
|
|
landscape: bool,
|
|
}
|
|
|
|
/// The ISO/ANSI paper a frame of `w` x `h` mm fits (either orientation), and whether it is landscape.
|
|
fn paper_for(w: f64, h: f64) -> Option<(&'static str, bool)> {
|
|
cadcraft_render::PAPER_SIZES.iter().find_map(|p| {
|
|
let (pw, ph) = (p.width_mm, p.height_mm);
|
|
if (pw - w).abs() <= FRAME_TOL_MM && (ph - h).abs() <= FRAME_TOL_MM {
|
|
Some((p.name, false))
|
|
} else if (ph - w).abs() <= FRAME_TOL_MM && (pw - h).abs() <= FRAME_TOL_MM {
|
|
Some((p.name, true))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Sheet frames grouped into books: rows of equal lower edge, bottom to top, each left to right.
|
|
fn frame_books(d: &Drawing, frame_height: f64, unit: f64) -> (Vec<Vec<Frame>>, usize) {
|
|
let mut found: Vec<(i64, f64, Frame)> = Vec::new();
|
|
let mut skipped = 0;
|
|
for e in d.model.iter().take(MAX_FRAMES) {
|
|
let EntityKind::LwPolyline(pl) = &e.kind else { continue };
|
|
if !pl.closed {
|
|
continue;
|
|
}
|
|
let b = cadcraft_doc::entity_bounds(d, e, 0);
|
|
if b.is_empty() || !b.width().is_finite() || !b.height().is_finite() {
|
|
continue;
|
|
}
|
|
let (w, h) = (b.width() * unit, b.height() * unit);
|
|
if (h - frame_height).abs() > 0.0005 {
|
|
continue;
|
|
}
|
|
match paper_for(w, h) {
|
|
Some((paper, landscape)) => {
|
|
let row = (b.min.y * 1000.0).round();
|
|
if row.is_finite() && row.abs() < 9e15 {
|
|
found.push((row as i64, b.min.x, Frame { window: b, paper, landscape }));
|
|
}
|
|
}
|
|
None => skipped += 1,
|
|
}
|
|
}
|
|
found.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
|
let mut books: Vec<Vec<Frame>> = Vec::new();
|
|
let mut last = None;
|
|
for (row, _, f) in found {
|
|
if last != Some(row) {
|
|
books.push(Vec::new());
|
|
last = Some(row);
|
|
}
|
|
if let Some(b) = books.last_mut() {
|
|
b.push(f);
|
|
}
|
|
}
|
|
(books, skipped)
|
|
}
|
|
|
|
fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
|
|
if io().and_then(|h| h.plot).is_none() {
|
|
return Err(bad("pdfbook", "plotting is not available in this build"));
|
|
}
|
|
let num = |k: &str, default: f64| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(default);
|
|
let (frame_height, unit) = (num("frameHeight", 297.0), num("unit", 1.0));
|
|
let (books, skipped) = frame_books(s.doc()?, frame_height, unit);
|
|
if books.is_empty() {
|
|
return Err(bad("pdfbook", format!("no closed polylines {frame_height} mm high (sheet frames) in model space")));
|
|
}
|
|
let target = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone()));
|
|
let pb = target.as_deref().map(std::path::Path::new);
|
|
let dir = pb.and_then(std::path::Path::parent).map(std::path::Path::to_path_buf).unwrap_or_default();
|
|
let stem = pb
|
|
.and_then(std::path::Path::file_stem)
|
|
.map(|x| x.to_string_lossy().to_string())
|
|
.filter(|x| !x.is_empty())
|
|
.unwrap_or_else(|| "Drawing".into());
|
|
let names: Vec<String> = match p.get("names").and_then(Value::as_array) {
|
|
Some(a) => a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect(),
|
|
None => stem.split('^').map(str::to_string).collect(),
|
|
};
|
|
let write = pb.is_some();
|
|
let mut out = Vec::new();
|
|
for (i, frames) in books.iter().enumerate() {
|
|
// A row without its own name is numbered after the drawing.
|
|
let name = names.get(i).map(|n| file_part(n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("{}-{}", file_part(&stem), i + 1));
|
|
let pages: Vec<Value> = frames
|
|
.iter()
|
|
.map(|f| json!({ "window": [[f.window.min.x, f.window.min.y], [f.window.max.x, f.window.max.y]], "paper": f.paper, "landscape": f.landscape }))
|
|
.collect();
|
|
let mut q = json!({ "layout": "Model", "pages": pages, "margins": [0, 0, 0, 0] });
|
|
if write {
|
|
q["path"] = json!(dir.join(format!("{name}.pdf")).to_string_lossy());
|
|
}
|
|
let mut r = s.execute("plot", &q).map_err(|e| bad("pdfbook", format!("{name}: {e}")))?;
|
|
if let Some(o) = r.as_object_mut() {
|
|
o.insert("name".into(), json!(name));
|
|
o.insert("pages".into(), json!(frames.len()));
|
|
}
|
|
out.push(r);
|
|
}
|
|
let message = format!("Plotted {} book(s), {} sheet(s)", out.len(), books.iter().map(Vec::len).sum::<usize>());
|
|
Ok(json!({ "books": out, "skipped": skipped, "message": message }))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use cadcraft_doc::{Block, Circle, Common, Handle, Insert, Layer, Line};
|
|
use cadcraft_geom::Vec3;
|
|
|
|
use super::super::file::{IoHooks, set_io};
|
|
|
|
use super::super::resolve_alias;
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
static DELIVERED: std::sync::Mutex<Vec<(String, String, usize)>> = std::sync::Mutex::new(Vec::new());
|
|
|
|
fn record(name: &str, mime: &str, bytes: &[u8]) -> std::result::Result<(), String> {
|
|
DELIVERED.lock().map_err(|e| e.to_string())?.push((name.into(), mime.into(), bytes.len()));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn web_export_delivers_a_download_instead_of_writing_a_path() {
|
|
deliver_file("export", "Drawing1.svg", b"<svg/>", Some(record)).unwrap();
|
|
deliver_file("export", "dir/Plan.pdf", b"%PDF", Some(record)).unwrap();
|
|
let got = DELIVERED.lock().unwrap().clone();
|
|
assert_eq!(got, [("Drawing1.svg".to_string(), "image/svg+xml".to_string(), 6), ("Plan.pdf".to_string(), "application/pdf".to_string(), 4)]);
|
|
let e = deliver_file("export", "a.svg", b"x", None).unwrap_err().to_string();
|
|
assert!(e.contains("base64"), "{e}");
|
|
}
|
|
|
|
fn fake_plot(_: &Drawing, space: &Space, opts: &Value) -> std::result::Result<Vec<u8>, String> {
|
|
// Same output as the layout tests' stand-in: whichever test installs first wins.
|
|
Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes())
|
|
}
|
|
|
|
/// Install stand-in io hooks; true when they are the ones installed (the hooks are
|
|
/// process-wide, and other tests install writers that always fail).
|
|
fn our_hooks() -> bool {
|
|
set_io(IoHooks {
|
|
read: |_, _| Err("no reader in tests".into()),
|
|
write: |d, name| Ok(if name == "probe.test" { b"exchange".to_vec() } else { format!("{name}:{}", d.model.len()).into_bytes() }),
|
|
plot: Some(fake_plot),
|
|
});
|
|
io().is_some_and(|h| (h.write)(&Drawing::new_imperial(), "probe.test").is_ok_and(|b| b == b"exchange"))
|
|
}
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.cmdline("line 0,0 100,0").unwrap();
|
|
s.cmdline("").unwrap();
|
|
s.cmdline("circle 50,25 20").unwrap();
|
|
s
|
|
}
|
|
|
|
fn temp(name: &str) -> std::path::PathBuf {
|
|
std::env::temp_dir().join(format!("cadcraft-exchange-{}-{name}", std::process::id()))
|
|
}
|
|
|
|
#[test]
|
|
fn export_writes_the_chosen_format_and_is_not_save_as() {
|
|
let ours = our_hooks();
|
|
let mut s = session();
|
|
assert!(s.execute("export", &json!({})).is_err(), "a format or a path is required");
|
|
assert!(s.execute("export", &json!({ "path": "x.txt" })).is_err());
|
|
assert!(s.execute("export", &json!({ "format": "exe" })).is_err());
|
|
if !ours {
|
|
return;
|
|
}
|
|
let r = s.execute("export", &json!({ "format": ".SVG" })).unwrap();
|
|
assert_eq!(r["format"], "svg");
|
|
assert_eq!(base64_decode(r["data"].as_str().unwrap()).unwrap(), b"export.svg:2");
|
|
let path = temp("out.png");
|
|
let ps = path.to_string_lossy().to_string();
|
|
let r = s.execute("export", &json!({ "path": ps })).unwrap();
|
|
assert_eq!(r["format"], "png");
|
|
assert_eq!(std::fs::read(&path).unwrap(), b"export.png:2");
|
|
let _ = std::fs::remove_file(&path);
|
|
let st = s.state().unwrap();
|
|
assert!(st.path.is_none() && st.title == "Drawing1", "the drawing keeps its name");
|
|
}
|
|
|
|
fn line(a: (f64, f64), b: (f64, f64)) -> EntityKind {
|
|
EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })
|
|
}
|
|
|
|
fn block(name: &str, r: f64) -> Arc<Block> {
|
|
let mut b = Block::new(name);
|
|
b.entities.push(Entity::new(Handle(0x100), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: r })));
|
|
Arc::new(b)
|
|
}
|
|
|
|
fn insert(name: &str) -> EntityKind {
|
|
EntityKind::Insert(Insert {
|
|
block: name.into(),
|
|
insert: Vec3::ZERO,
|
|
scale: Vec3::new(1.0, 1.0, 1.0),
|
|
rotation: 0.0,
|
|
attribs: Vec::new(),
|
|
cols: 1,
|
|
rows: 1,
|
|
col_spacing: 0.0,
|
|
row_spacing: 0.0,
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn import_brings_objects_with_their_layers_and_blocks() {
|
|
let mut src = Drawing::new_imperial();
|
|
src.layers.push(Layer::new("WALLS"));
|
|
src.blocks.insert("PART".into(), block("PART", 1.0));
|
|
src.blocks.insert("NEW".into(), block("NEW", 2.0));
|
|
src.blocks.insert("*U1".into(), block("*U1", 3.0));
|
|
let walls = Common { layer: "WALLS".into(), ..Common::default() };
|
|
src.add(&Space::Model, walls, line((0.0, 0.0), (10.0, 0.0))).unwrap();
|
|
for b in ["PART", "NEW", "*U1"] {
|
|
src.add(&Space::Model, Common::default(), insert(b)).unwrap();
|
|
}
|
|
|
|
let mut s = session();
|
|
s.doc_mut().unwrap().blocks.insert("PART".into(), block("PART", 5.0));
|
|
s.doc_mut().unwrap().blocks.insert("*U1".into(), block("*U1", 6.0));
|
|
let r = import_drawing(&mut s, &src, Vec2::new(100.0, 0.0), 2.0, "parts.dxf").unwrap();
|
|
assert_eq!(r["imported"], 4);
|
|
assert_eq!(r["message"], "4 objects imported from parts.dxf");
|
|
let d = s.doc().unwrap();
|
|
assert_eq!(d.model.len(), 6);
|
|
assert!(d.layer("WALLS").is_some());
|
|
let mut handles: Vec<Handle> = d.model.handles();
|
|
handles.sort();
|
|
handles.dedup();
|
|
assert_eq!(handles.len(), 6, "imported objects get handles of their own");
|
|
let imported: Vec<&Entity> = d.model.iter().skip(2).map(|e| &**e).collect();
|
|
match &imported[0].kind {
|
|
EntityKind::Line(l) => {
|
|
assert_eq!((l.a.x, l.b.x), (100.0, 120.0), "scaled about the origin, then moved");
|
|
assert_eq!(imported[0].common.layer, "WALLS");
|
|
}
|
|
k => panic!("expected the line, got {k:?}"),
|
|
}
|
|
let names: Vec<String> =
|
|
imported.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect();
|
|
assert_eq!(names, ["PART", "NEW", "*U2"], "same-named blocks keep ours; generated ones get a new name");
|
|
let radius = |n: &str| match d.block(n).and_then(|b| b.entities.iter().next().map(|e| e.kind.clone())) {
|
|
Some(EntityKind::Circle(c)) => c.radius,
|
|
_ => 0.0,
|
|
};
|
|
assert_eq!((radius("PART"), radius("NEW"), radius("*U1"), radius("*U2")), (5.0, 2.0, 6.0, 3.0));
|
|
assert_eq!(s.selection().len(), 4);
|
|
s.execute("undo", &json!({})).ok();
|
|
|
|
// The JSON form checks its parameters before reading anything.
|
|
our_hooks();
|
|
for p in [json!({}), json!({ "data": "%%" }), json!({ "data": "", "scale": 0 }), json!({ "data": "", "at": "x" })] {
|
|
assert!(s.execute("import", &p).is_err(), "{p}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn preview_draws_the_sheet_as_it_plots() {
|
|
let mut s = session();
|
|
let r = s.execute("preview", &json!({ "width": 400 })).unwrap();
|
|
assert_eq!((r["layout"].as_str(), r["width"].as_u64()), (Some("Model"), Some(400)));
|
|
// Model space plots on ANSI A landscape in an imperial drawing.
|
|
assert_eq!(r["height"], 309);
|
|
let png = base64_decode(r["data"].as_str().unwrap()).unwrap();
|
|
assert!(png.starts_with(b"\x89PNG"));
|
|
assert_eq!(png.get(16..24), Some(&[0, 0, 1, 144, 0, 0, 1, 53][..]), "IHDR is 400 x 309");
|
|
let r = s.execute("preview", &json!({ "layout": "layout1", "paper": "A4", "landscape": true, "width": 600 })).unwrap();
|
|
assert_eq!((r["layout"].as_str(), r["width"].as_u64(), r["height"].as_u64()), (Some("Layout1"), Some(600), Some(424)));
|
|
assert_eq!(r["paper"], "ISO A4 (210.00 x 297.00 MM)");
|
|
assert!(s.execute("preview", &json!({ "layout": "Nope" })).is_err());
|
|
assert!(s.execute("preview", &json!({ "paper": "Nope" })).is_err());
|
|
for p in [json!({ "scale": -1, "fit": false }), json!({ "scale": 1e-300, "fit": false }), json!({ "width": -5 }), json!({ "width": 1e30 })] {
|
|
let _ = s.execute("preview", &p);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn preview_applies_an_explicit_layout_scale() {
|
|
let mut s = Session::new();
|
|
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
|
s.execute("line", &json!({ "points": [[1.0, 1.0], [5.0, 1.0]] })).unwrap();
|
|
let png = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap_or_default().to_string();
|
|
let default = png(&mut s, json!({ "layout": "Layout1", "width": 200 }));
|
|
assert_eq!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 1 })), default, "scale 1 is the 1:1 default");
|
|
assert_ne!(png(&mut s, json!({ "layout": "Layout1", "width": 200, "fit": false, "scale": 0.5 })), default, "scale 0.5 is not 1:1");
|
|
}
|
|
|
|
#[test]
|
|
fn pdfbook_collects_frames_into_one_pdf_per_row() {
|
|
our_hooks();
|
|
if io().and_then(|h| h.plot).is_none() {
|
|
return;
|
|
}
|
|
let mut s = Session::new();
|
|
// Row 1 (y = 0): A4, A3 landscape (drawn right to left); row 2 (y = 400): one A4; a stray rectangle.
|
|
for c in ["pline 210,0 420,0 420,297 210,297 c", "pline 0,0 210,0 210,297 0,297 c", "pline 420,0 840,0 840,297 420,297 c"] {
|
|
s.cmdline(c).unwrap();
|
|
s.cmdline("").unwrap();
|
|
}
|
|
s.cmdline("pline 0,400 210,400 210,697 0,697 c").unwrap();
|
|
s.cmdline("").unwrap();
|
|
s.cmdline("pline 0,900 50,900 50,950 0,950 c").unwrap();
|
|
s.cmdline("").unwrap();
|
|
// `a^b.pdf` names one book per row, both in the folder of the path.
|
|
let dir = crate::temp_dir::TempDir::new("pdfbook");
|
|
let r = s.execute("pdfbook", &json!({ "path": dir.path_str("a^b.pdf") }));
|
|
let r = r.unwrap();
|
|
let books = r["books"].as_array().unwrap();
|
|
assert_eq!(books.len(), 2);
|
|
assert_eq!(books[0]["pages"], 3);
|
|
assert_eq!(books[1]["pages"], 1);
|
|
let first = dir.join("a.pdf");
|
|
let text = String::from_utf8(std::fs::read(&first).unwrap()).unwrap();
|
|
let (a4, a3) = (text.find("ISO A4").unwrap(), text.find("ISO A3").unwrap());
|
|
assert!(a4 < a3, "frames go left to right: {text}");
|
|
assert!(text.contains("\"landscape\":true"));
|
|
assert!(dir.join("b.pdf").is_file(), "the second row's book is written beside the first");
|
|
// No frames: an error, not a panic.
|
|
assert!(Session::new().execute("pdfbook", &json!({})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn publish_plots_every_sheet() {
|
|
our_hooks();
|
|
let mut s = session();
|
|
if io().and_then(|h| h.plot).is_none() {
|
|
return;
|
|
}
|
|
let r = s.execute("publish", &json!({})).unwrap();
|
|
let sheets: Vec<&str> = r["sheets"].as_array().unwrap().iter().filter_map(|x| x["layout"].as_str()).collect();
|
|
assert_eq!(sheets, ["Model", "Layout1", "Layout2"]);
|
|
let base = temp("set.pdf");
|
|
let r = s.execute("publish", &json!({ "path": base.to_string_lossy(), "layouts": ["layout2", "MODEL"] })).unwrap();
|
|
for (sheet, name) in r["sheets"].as_array().unwrap().iter().zip(["Layout2", "Model"]) {
|
|
let path = temp(&format!("set-{name}.pdf"));
|
|
assert_eq!(sheet["path"].as_str(), Some(path.to_string_lossy().as_ref()));
|
|
let text = String::from_utf8(std::fs::read(&path).unwrap()).unwrap();
|
|
assert!(text.starts_with("%PDF") && text.contains(if name == "Model" { "Model" } else { "Paper(\"Layout2\")" }));
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
assert!(r["message"].as_str().unwrap().starts_with("Published 2 sheet(s)"));
|
|
for p in [json!({ "layouts": [] }), json!({ "layouts": ["Nope"] }), json!({ "layouts": "Layout1" }), json!({ "layouts": [1] })] {
|
|
assert!(s.execute("publish", &p).is_err(), "{p}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn attach_share_and_orbit_say_they_are_not_available_yet() {
|
|
let mut s = session();
|
|
for c in ["attach", "share", "3dorbit", "orbit"] {
|
|
let e = s.execute(&resolve_alias(c), &Value::Null).unwrap_err().to_string();
|
|
assert!(e.contains("not available yet"), "{c}: {e}");
|
|
}
|
|
}
|
|
}
|