diff --git a/Cargo.lock b/Cargo.lock index 6e9ffddb..68b19674 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -660,6 +660,7 @@ dependencies = [ "cadcraft-geom", "cadcraft-render", "miniz_oxide 0.8.9", + "quick-xml", "serde", "serde_json", "thiserror 2.0.21", diff --git a/Cargo.toml b/Cargo.toml index d3290623..fe72ec0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,8 @@ harfrust = "0.12" unicode-bidi = "0.3.18" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } encoding_rs = "0.8.35" +# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones) +quick-xml = "0.41" # ui craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index af577448..00f72ce7 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -119,6 +119,7 @@ fn services() -> Services { pick_open: Some(Box::new(|| { rfd::FileDialog::new() .add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"]) + .add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"]) .add_filter(i18n::t("All files"), &["*"]) .pick_file() .map(|p| p.to_string_lossy().to_string()) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7a83a689..a57b2532 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -171,6 +171,9 @@ fn file_name(path: &str) -> String { pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option) -> Result { let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?; let d = (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)); let i = s.open_drawing(d, &file_name(name), path); if let Some(st) = s.docs.get_mut(i) { // QSAVE keeps a binary DXF binary. diff --git a/crates/engine/tests/open_svg.rs b/crates/engine/tests/open_svg.rs new file mode 100644 index 00000000..96f35665 --- /dev/null +++ b/crates/engine/tests/open_svg.rs @@ -0,0 +1,30 @@ +//! Opening a file that isn't a drawing (SVG, read into entities): the drawing gets no file name, +//! so QSAVE asks for one instead of writing CADCraft's SVG export over the original. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use serde_json::json; + +#[test] +fn an_opened_svg_is_not_saved_over() { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"exported".to_vec()), plot: None }); + let dir = std::env::temp_dir().join(format!("cadcraft-open-svg-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let svg = dir.join("Logo.svg"); + std::fs::write(&svg, b"").unwrap(); + let mut s = Session::empty(); + s.execute("open", &json!({"path": svg.to_string_lossy()})).unwrap(); + let st = s.state().unwrap(); + assert_eq!(st.title, "Logo.svg"); + assert_eq!(st.path, None); + assert!(s.execute("qsave", &json!({})).is_err(), "QSAVE asks for a file name"); + assert_eq!(std::fs::read(&svg).unwrap(), b"", "the original is untouched"); + + // A drawing file keeps its path. + let dxf = dir.join("Plan.dxf"); + std::fs::write(&dxf, b"0\nEOF\n").unwrap(); + s.execute("open", &json!({"path": dxf.to_string_lossy()})).unwrap(); + assert_eq!(s.state().unwrap().path.as_deref(), Some(dxf.to_string_lossy().as_ref())); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 55579c82..9c04fe6b 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -20,3 +20,4 @@ thiserror = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } miniz_oxide = "0.8" +quick-xml = { workspace = true } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index cb726051..a7a2ed59 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -1,4 +1,4 @@ -//! CADCraft file formats: DXF read/write, SVG, PNG and PDF export (plotting). +//! CADCraft file formats: DXF/DWG read, SVG and SVGZ import, SVG/PNG/PDF export (plotting). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -15,6 +15,7 @@ mod dxf_xref; pub mod pdf; pub mod plotter; pub mod svg; +mod svg_read; /// Multilines and their styles (MLINE, MLINESTYLE). mod dxf_mline; @@ -54,13 +55,15 @@ fn ext(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() } -/// Read a drawing; the format comes from the content (DXF ASCII/binary) or the name. +/// Read a drawing; the format comes from the content (DXF ASCII/binary, DWG) or the name +/// (SVG and gzip-compressed SVGZ). pub fn read(bytes: &[u8], name: &str) -> Result { if cadcraft_dwg::is_dwg(bytes) { let dxf = cadcraft_dwg::dwg_to_dxf(bytes).map_err(IoError::Format)?; return dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)"))); } match ext(name).as_str() { + "svg" | "svgz" => svg_read::read(bytes), "dxf" | "" => dxf_read::read(bytes), e if bytes.len() > 4 => dxf_read::read(bytes).map_err(|_| IoError::Unsupported(e.to_string())), e => Err(IoError::Unsupported(e.to_string())), diff --git a/crates/io/src/svg_read.rs b/crates/io/src/svg_read.rs new file mode 100644 index 00000000..419c7039 --- /dev/null +++ b/crates/io/src/svg_read.rs @@ -0,0 +1,945 @@ +//! SVG → Drawing: the static W3C SVG subset that drawing tools emit. +//! +//! Reads shapes (`rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`), `path` +//! outlines (`M L H V C S Q T A Z`, absolute and relative), `g` grouping with `transform` +//! attributes, `viewBox` and unit-bearing `width`/`height`, and Inkscape layers +//! (`inkscape:groupmode="layer"`). Geometry becomes centre-line entities in millimetres with +//! the page's top-left corner at the origin and Y pointing up — where a laser or pen job +//! wants it. +//! +//! Not read (each is a follow-up): CSS class rules (`