mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 20:40:30 -06:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664837df89 | ||
|
|
2c953e38a3 | ||
|
|
2f8f186188 | ||
|
|
6f0f1fefa8 |
Generated
+14
-12
@@ -631,6 +631,7 @@ dependencies = [
|
||||
"log",
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
@@ -694,15 +695,6 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.17.0"
|
||||
@@ -710,12 +702,12 @@ dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"craft-docpath",
|
||||
"craft-dxf",
|
||||
"libc",
|
||||
"log",
|
||||
"rstar",
|
||||
@@ -728,8 +720,8 @@ dependencies = [
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-geom",
|
||||
"craft-dxf",
|
||||
"encoding_rs",
|
||||
"harfrust 0.14.0",
|
||||
"icu_segmenter",
|
||||
@@ -752,10 +744,10 @@ dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
"cadcraft-dwg",
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"craft-dxf",
|
||||
"craft-pdf",
|
||||
"image",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -1150,6 +1142,16 @@ name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-dxf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=006e4df225fca57e95ead0fb1f91920900798a99#006e4df225fca57e95ead0fb1f91920900798a99"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@ repository = "https://github.com/storytold/cadcraft"
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
cadcraft-geom = { path = "crates/geom" }
|
||||
cadcraft-dxf = { path = "crates/dxf" }
|
||||
# DXF tags: the shared reader and writer (moved from crates/dxf; craft-libs#791).
|
||||
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "006e4df225fca57e95ead0fb1f91920900798a99" }
|
||||
cadcraft-dwg = { path = "crates/dwg" }
|
||||
cadcraft-color = { path = "crates/color" }
|
||||
cadcraft-doc = { path = "crates/doc" }
|
||||
|
||||
@@ -55,7 +55,11 @@ muda = { version = "0.21", default-features = false }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
|
||||
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
|
||||
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
# `rwh_06`: the window handle a file dialog shown as a sheet attaches to (src/sheet.rs).
|
||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
||||
# Waits for the answer of a file dialog shown as a sheet (src/sheet.rs). MIT OR Apache-2.0, already
|
||||
# in Cargo.lock through eframe.
|
||||
pollster = "1"
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
+40
-27
@@ -23,13 +23,15 @@ mod launch_args;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod sheet;
|
||||
mod spacemouse;
|
||||
mod system_animations;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::switch::Switch;
|
||||
use cadcraft_ui_egui::{CadApp, Services, i18n};
|
||||
use cadcraft_ui_egui::{CadApp, Pick, Services, i18n};
|
||||
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
@@ -159,39 +161,44 @@ pub fn install_io() {
|
||||
|
||||
fn services() -> 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())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str| {
|
||||
filters_for_save(name)
|
||||
.iter()
|
||||
.fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(i18n::t(label), &[*ext]))
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_open: Some(Box::new(|| file_dialog(&Pick::Open).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
pick_save: Some(Box::new(|name: &str| file_dialog(&Pick::Save(name.into())).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
// macOS shows the dialogs as sheets (`sheet::start_pick`), set once the window exists.
|
||||
start_pick: None,
|
||||
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
|
||||
pick_lisp: Some(Box::new(|dir: &str| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_lisp: Some(Box::new(|dir: &str| file_dialog(&Pick::Lisp(dir.into())).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
no_file_system: false,
|
||||
pick_pgp: Some(Box::new(|dir: Option<&str>| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("Alias files (PGP)"), &["pgp"]);
|
||||
let dialog = match dir {
|
||||
Some(dir) => dialog.set_directory(dir),
|
||||
None => dialog,
|
||||
};
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
file_dialog(&Pick::Pgp(dir.unwrap_or_default().into())).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file types `pick` offers: (label, extensions). Shared by the blocking dialogs and the
|
||||
/// macOS sheets.
|
||||
fn filters(pick: &Pick) -> Vec<(&'static str, Vec<&'static str>)> {
|
||||
match pick {
|
||||
Pick::Open => vec![
|
||||
(i18n::t("Drawings (DWG, DXF)"), vec!["dwg", "dxf", "dwt"]),
|
||||
(i18n::t("SVG drawings (SVG, SVGZ)"), vec!["svg", "svgz"]),
|
||||
(i18n::t("All files"), vec!["*"]),
|
||||
],
|
||||
Pick::Save(name) => filters_for_save(name).iter().map(|(label, ext)| (i18n::t(label), vec![*ext])).collect(),
|
||||
Pick::Lisp(_) => vec![(i18n::t("AutoLISP (LSP)"), vec!["lsp"]), (i18n::t("All files"), vec!["*"])],
|
||||
Pick::Pgp(_) => vec![(i18n::t("Alias files (PGP)"), vec!["pgp"])],
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking dialog for `pick` (Windows, Linux, FreeBSD).
|
||||
fn file_dialog(pick: &Pick) -> rfd::FileDialog {
|
||||
let dialog = filters(pick).iter().fold(rfd::FileDialog::new(), |d, (label, exts)| d.add_filter(*label, exts));
|
||||
match pick {
|
||||
Pick::Save(name) => dialog.set_file_name(name),
|
||||
Pick::Lisp(dir) | Pick::Pgp(dir) if !dir.is_empty() => dialog.set_directory(dir),
|
||||
_ => dialog,
|
||||
}
|
||||
}
|
||||
|
||||
/// File types for the Save dialog, the suggested name's type first: Windows adds the first
|
||||
/// filter's extension, and macOS accepts only listed extensions, replacing any other with the
|
||||
/// first one. DWG must be listed for a `.dwg` name to survive, and a drawing opened from DWG
|
||||
@@ -361,6 +368,12 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = CadApp::new(Session::empty(), services());
|
||||
// File dialogs are sheets on macOS: a modal loop inside the event handler crashes the
|
||||
// app when the dialog is resized (#1527).
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(window) = cc.winit_window().cloned() {
|
||||
app.services.start_pick = Some(sheet::start_pick(window, cc.egui_ctx.clone()));
|
||||
}
|
||||
let (workspace, restored, workspace_error) = workspace::Workspace::load(vars.config_dir.as_deref());
|
||||
if let Some(ui) = restored {
|
||||
app.ui = ui;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! macOS file dialogs as sheets. Shown in line, or from a helper thread that rfd hands to the main
|
||||
//! thread's modal loop, a panel runs AppKit's modal event loop; resizing it sends window events
|
||||
//! back into winit's event handler and winit aborts the app (#1527). A sheet returns at once and
|
||||
//! the window keeps running; a thread of its own waits for the answer.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_ui_egui::{Pick, StartPick};
|
||||
|
||||
type Answer = std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>;
|
||||
|
||||
/// `Services::start_pick`: shows `pick` as a sheet on `window`; `ui` is woken when it answers.
|
||||
pub fn start_pick(window: Arc<winit::window::Window>, ui: egui::Context) -> StartPick {
|
||||
Box::new(move |pick, done| {
|
||||
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<Answer>();
|
||||
let ui = ui.clone();
|
||||
// The thread first, so the answer of a sheet once shown always has somewhere to go. If it
|
||||
// cannot start, `done` is dropped unanswered and the app says the dialog could not be
|
||||
// shown.
|
||||
let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
|
||||
if let Ok(answer) = sheet_rx.recv() {
|
||||
done(pollster::block_on(answer));
|
||||
ui.request_repaint();
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("file dialog: {e}");
|
||||
return;
|
||||
}
|
||||
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
|
||||
let dialog =
|
||||
crate::filters(&pick).iter().fold(rfd::AsyncFileDialog::new().set_parent(&*window), |d, (label, exts)| d.add_filter(*label, exts));
|
||||
let path = |f: rfd::FileHandle| f.path().to_string_lossy().to_string();
|
||||
let answer: Answer = match pick {
|
||||
Pick::Save(name) => {
|
||||
let picked = dialog.set_file_name(name).save_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
Pick::Lisp(dir) | Pick::Pgp(dir) => {
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
Pick::Open => {
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(path) })
|
||||
}
|
||||
};
|
||||
let _ = sheet_tx.send(answer);
|
||||
})
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "cadcraft-dxf"
|
||||
description = "Standalone DXF tag reader/writer (ASCII and binary, pre-2007 code pages), from the published DXF reference"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
@@ -1,398 +0,0 @@
|
||||
//! Text decoding for DXF files.
|
||||
//!
|
||||
//! R2007 (AC1021) and later DXF files are UTF-8. Older ones store text in the code page named by
|
||||
//! the `$DWGCODEPAGE` header variable (`ANSI_1252`, `ANSI_932`, `DOS850`, …); characters outside
|
||||
//! it are written as `\U+XXXX` (any character) or `\M+nXXXX` (a double-byte character of an East
|
||||
//! Asian code page) escapes, which [`crate::parse`] decodes after the bytes.
|
||||
//!
|
||||
//! The Windows, ISO and East Asian code pages are decoded with `encoding_rs`; DOS 437, 850 and
|
||||
//! 852 with the tables below (the upper halves of the IBM PC code pages; the lower halves are
|
||||
//! ASCII). Decoding never fails: a byte sequence that is invalid in the code page (a truncated
|
||||
//! double-byte character, an unassigned byte) becomes U+FFFD and the bytes after it decode
|
||||
//! normally, so line ends and the group structure survive.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use encoding_rs::Encoding;
|
||||
|
||||
/// How the bytes of a code page become text.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum Decoder {
|
||||
Utf8,
|
||||
Encoding(&'static Encoding),
|
||||
/// Single-byte DOS code page: ASCII, then these 128 characters for bytes 0x80–0xFF.
|
||||
Table(&'static [char; 128]),
|
||||
}
|
||||
|
||||
/// The decoder for a `$DWGCODEPAGE` value (case-insensitive), or `None` when CADCraft doesn't
|
||||
/// know the code page.
|
||||
pub(crate) fn decoder(code_page: &str) -> Option<Decoder> {
|
||||
use encoding_rs as e;
|
||||
let name = code_page.trim().to_ascii_uppercase().replace(['-', ' '], "_");
|
||||
let enc = match name.as_str() {
|
||||
"UTF8" | "UTF_8" | "UNICODE" => return Some(Decoder::Utf8),
|
||||
"ASCII" | "ANSI_1252" | "ISO8859_1" | "ISO_8859_1" => e::WINDOWS_1252,
|
||||
"ANSI_874" => e::WINDOWS_874,
|
||||
"ANSI_932" | "DOS932" | "SHIFT_JIS" => e::SHIFT_JIS,
|
||||
"ANSI_936" | "GB2312" | "GBK" => e::GBK,
|
||||
"ANSI_949" | "KSC5601" | "KOREAN" => e::EUC_KR,
|
||||
"ANSI_950" | "BIG5" => e::BIG5,
|
||||
"ANSI_1250" => e::WINDOWS_1250,
|
||||
"ANSI_1251" => e::WINDOWS_1251,
|
||||
"ANSI_1253" => e::WINDOWS_1253,
|
||||
"ANSI_1254" | "ISO8859_9" | "ISO_8859_9" => e::WINDOWS_1254,
|
||||
"ANSI_1255" => e::WINDOWS_1255,
|
||||
"ANSI_1256" => e::WINDOWS_1256,
|
||||
"ANSI_1257" => e::WINDOWS_1257,
|
||||
"ANSI_1258" => e::WINDOWS_1258,
|
||||
"ISO8859_2" | "ISO_8859_2" => e::ISO_8859_2,
|
||||
"ISO8859_3" | "ISO_8859_3" => e::ISO_8859_3,
|
||||
"ISO8859_4" | "ISO_8859_4" => e::ISO_8859_4,
|
||||
"ISO8859_5" | "ISO_8859_5" => e::ISO_8859_5,
|
||||
"ISO8859_6" | "ISO_8859_6" => e::ISO_8859_6,
|
||||
"ISO8859_7" | "ISO_8859_7" => e::ISO_8859_7,
|
||||
"ISO8859_8" | "ISO_8859_8" => e::ISO_8859_8,
|
||||
"DOS866" => e::IBM866,
|
||||
"MACINTOSH" | "MAC_ROMAN" => e::MACINTOSH,
|
||||
"DOS437" => return Some(Decoder::Table(&DOS437)),
|
||||
"DOS850" => return Some(Decoder::Table(&DOS850)),
|
||||
"DOS852" => return Some(Decoder::Table(&DOS852)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Decoder::Encoding(enc))
|
||||
}
|
||||
|
||||
/// Decode `bytes` from the code page `code_page` names (a `$DWGCODEPAGE` value such as
|
||||
/// `ANSI_1251`), or `None` when CADCraft doesn't know that code page. Invalid sequences become
|
||||
/// U+FFFD; escapes (`\U+XXXX`, `\M+nXXXX`) are left as they are.
|
||||
pub fn decode_code_page(bytes: &[u8], code_page: &str) -> Option<String> {
|
||||
decoder(code_page).map(|d| decode(bytes, d).into_owned())
|
||||
}
|
||||
|
||||
/// The code page a pre-2007 file's text is written in, or `None` for pure ASCII with escapes:
|
||||
/// when CADCraft doesn't know `code_page`, and for `ASCII` (the reader takes such files as
|
||||
/// Windows-1252, but a writer must keep them ASCII) and `UTF8`/`UNICODE`, which no pre-2007
|
||||
/// reader expects.
|
||||
pub(crate) fn encoder(code_page: &str) -> Option<Decoder> {
|
||||
if code_page.trim().eq_ignore_ascii_case("ASCII") {
|
||||
return None;
|
||||
}
|
||||
decoder(code_page).filter(|d| !matches!(d, Decoder::Utf8))
|
||||
}
|
||||
|
||||
/// Append the bytes of the non-ASCII character `c` in the code page `d`, and return true, when
|
||||
/// the code page has it; otherwise append nothing and return false (the caller escapes it).
|
||||
pub(crate) fn encode_char(c: char, d: Decoder, out: &mut Vec<u8>) -> bool {
|
||||
let mut buf = [0; 4];
|
||||
match d {
|
||||
// Not a pre-2007 code page ([`encoder`] never gives it): escaped.
|
||||
Decoder::Utf8 => return false,
|
||||
Decoder::Encoding(e) => {
|
||||
let (bytes, _, unmappable) = e.encode(c.encode_utf8(&mut buf));
|
||||
// A code page byte that is ASCII would read back as another character, and a NUL,
|
||||
// CR or LF would break the group, so only all-high-bit sequences or proper double-byte
|
||||
// characters are kept (their trail byte may be ASCII, as in Shift-JIS).
|
||||
if unmappable || bytes.is_empty() || !bytes.first().is_some_and(|b| *b >= 0x80) || bytes.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) {
|
||||
return false;
|
||||
}
|
||||
out.extend_from_slice(&bytes);
|
||||
}
|
||||
Decoder::Table(t) => {
|
||||
let Some(i) = t.iter().position(|&x| x == c).and_then(|i| u8::try_from(i).ok()) else { return false };
|
||||
out.push(0x80 | i);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Decode bytes with a decoder. UTF-8 that isn't valid falls back to Windows-1252.
|
||||
pub(crate) fn decode(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
|
||||
match d {
|
||||
Decoder::Utf8 => match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => encoding_rs::WINDOWS_1252.decode_without_bom_handling(bytes).0,
|
||||
},
|
||||
Decoder::Encoding(e) => e.decode_without_bom_handling(bytes).0,
|
||||
Decoder::Table(t) => match std::str::from_utf8(bytes) {
|
||||
Ok(s) if s.is_ascii() => Cow::Borrowed(s),
|
||||
_ => Cow::Owned(
|
||||
bytes
|
||||
.iter()
|
||||
.map(|&b| if b.is_ascii() { char::from(b) } else { t.get(usize::from(b & 0x7F)).copied().unwrap_or('\u{FFFD}') })
|
||||
.collect(),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The `$DWGCODEPAGE` value of an ASCII DXF file: the line two lines after a `$DWGCODEPAGE`
|
||||
/// line (the group code `3` between them), looked for in the HEADER section only (the scan stops
|
||||
/// at the first `ENDSEC`). Works on the raw bytes, before they are decoded.
|
||||
fn ascii_code_page(bytes: &[u8]) -> Option<&[u8]> {
|
||||
let mut lines = bytes.split(|&b| b == b'\n' || b == b'\r').map(<[u8]>::trim_ascii).filter(|l| !l.is_empty());
|
||||
while let Some(line) = lines.next() {
|
||||
if line == b"ENDSEC" {
|
||||
return None;
|
||||
}
|
||||
if line.eq_ignore_ascii_case(b"$DWGCODEPAGE") {
|
||||
return match (lines.next(), lines.next()) {
|
||||
(Some(b"3"), Some(value)) => Some(value),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
/// True for the multi-byte East Asian code pages (Shift-JIS, GBK, EUC-KR/Wansung, Big5).
|
||||
/// Their short double-byte strings are often also valid UTF-8, so UTF-8 is only trusted for
|
||||
/// a whole file there (see [`decode_ascii_file`] and [`BinaryText`]).
|
||||
fn is_east_asian(self) -> bool {
|
||||
use encoding_rs as e;
|
||||
matches!(self, Decoder::Encoding(x) if x == e::SHIFT_JIS || x == e::GBK || x == e::EUC_KR || x == e::BIG5)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one line of an ASCII DXF file: valid UTF-8 is read as UTF-8 (R2007+ files, pure-ASCII
|
||||
/// text, and older files from writers that ignored the code page); anything else comes from the
|
||||
/// code page `d`, so one stray byte spoils one line, not the whole file. Binary strings don't use
|
||||
/// this rule: a pre-R2007 binary file has no way to escape to UTF-8, so its strings are read
|
||||
/// from the code page (see [`BinaryText::string`]).
|
||||
pub(crate) fn decode_text(bytes: &[u8], d: Decoder) -> Cow<'_, str> {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => decode(bytes, d),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a whole ASCII DXF file. A file that is valid UTF-8 throughout is UTF-8. Otherwise, for
|
||||
/// a single-byte code page, each line is decoded on its own (see [`decode_text`]): lines that
|
||||
/// aren't valid UTF-8 come from the file's `$DWGCODEPAGE`, or from Windows-1252 (the usual
|
||||
/// `ANSI_1252`) when the header has none or names a code page CADCraft doesn't know, which is
|
||||
/// logged as a warning. For a multi-byte East Asian code page the whole file is decoded from
|
||||
/// that code page, because a short double-byte string (two half-width katakana, many kanji
|
||||
/// pairs) can be valid UTF-8 by accident and must not be misread line by line.
|
||||
pub(crate) fn decode_ascii_file(bytes: &[u8]) -> Cow<'_, str> {
|
||||
if let Ok(s) = std::str::from_utf8(bytes) {
|
||||
return Cow::Borrowed(s);
|
||||
}
|
||||
let d = match ascii_code_page(bytes) {
|
||||
Some(name) => {
|
||||
let name = String::from_utf8_lossy(name);
|
||||
decoder(&name).unwrap_or_else(|| {
|
||||
log::warn!("DXF: unknown code page `{name}`; reading the text as ANSI_1252");
|
||||
Decoder::Utf8
|
||||
})
|
||||
}
|
||||
None => Decoder::Utf8,
|
||||
};
|
||||
if d.is_east_asian() {
|
||||
return decode(bytes, d);
|
||||
}
|
||||
// Split after each line terminator (CR, LF or CRLF are all ASCII, so they never occur inside
|
||||
// a multi-byte character of the supported code pages) and decode each line on its own.
|
||||
let mut out = String::with_capacity(bytes.len());
|
||||
for line in bytes.split_inclusive(|&b| b == b'\n' || b == b'\r') {
|
||||
out.push_str(&decode_text(line, d));
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The text state of a binary DXF file, learnt from the header as the tags go by: `$ACADVER`
|
||||
/// (R2007+ strings are UTF-8) and `$DWGCODEPAGE` (the code page of older files). Both come
|
||||
/// first in the HEADER section and are ASCII, so every later string is decoded right.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct BinaryText {
|
||||
/// The header variable whose value comes next (after a group 9).
|
||||
var: Option<HeaderVar>,
|
||||
utf8: bool,
|
||||
code_page: Option<Decoder>,
|
||||
/// Decode every string of an East Asian code page file from the code page, never as UTF-8
|
||||
/// (the second pass of [`BinaryText::needs_code_page_pass`]).
|
||||
code_page_only: bool,
|
||||
/// Non-ASCII strings seen under an East Asian code page: valid UTF-8 / not valid UTF-8.
|
||||
ea_utf8: bool,
|
||||
ea_other: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum HeaderVar {
|
||||
AcadVer,
|
||||
CodePage,
|
||||
}
|
||||
|
||||
impl BinaryText {
|
||||
/// The state for the second pass over a file whose strings are a mix of valid UTF-8 and
|
||||
/// not: every string of an East Asian code page is decoded from that code page.
|
||||
pub(crate) fn code_page_pass() -> Self {
|
||||
Self { code_page_only: true, ..Self::default() }
|
||||
}
|
||||
|
||||
/// True when the file declared a multi-byte East Asian code page and its non-ASCII strings are
|
||||
/// not all valid UTF-8 although some are: those can be short double-byte strings that look
|
||||
/// like UTF-8 by accident, so the file must be parsed again with [`Self::code_page_pass`]. (A
|
||||
/// file whose strings are all valid UTF-8 is UTF-8; one with none is plainly the code page.)
|
||||
pub(crate) fn needs_code_page_pass(&self) -> bool {
|
||||
!self.code_page_only && self.ea_utf8 && self.ea_other
|
||||
}
|
||||
|
||||
/// Decode one string value of group `code` and update the state from it. Strings of R2007+
|
||||
/// files, and of files without a code page, are UTF-8 when valid (else Windows-1252); a
|
||||
/// pre-R2007 file's strings are in its code page, which a binary DXF cannot escape to, so
|
||||
/// they are read from the code page even when their bytes are valid UTF-8 by accident —
|
||||
/// except under an East Asian code page, where a file of all-valid-UTF-8 strings is taken
|
||||
/// as UTF-8 (see [`Self::needs_code_page_pass`]).
|
||||
pub(crate) fn string(&mut self, code: i32, raw: &[u8]) -> String {
|
||||
let d = if self.utf8 { Decoder::Utf8 } else { self.code_page.unwrap_or(Decoder::Utf8) };
|
||||
let s = if d.is_east_asian() && !self.code_page_only {
|
||||
if !raw.is_ascii() {
|
||||
if std::str::from_utf8(raw).is_ok() {
|
||||
self.ea_utf8 = true;
|
||||
} else {
|
||||
self.ea_other = true;
|
||||
}
|
||||
}
|
||||
decode_text(raw, d).into_owned()
|
||||
} else {
|
||||
decode(raw, d).into_owned()
|
||||
};
|
||||
match (code, self.var.take()) {
|
||||
(9, _) => {
|
||||
self.var = match s.trim() {
|
||||
"$ACADVER" => Some(HeaderVar::AcadVer),
|
||||
"$DWGCODEPAGE" => Some(HeaderVar::CodePage),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
(1, Some(HeaderVar::AcadVer)) => self.utf8 = is_utf8_version(&s),
|
||||
(3, Some(HeaderVar::CodePage)) => {
|
||||
self.code_page = decoder(&s);
|
||||
if self.code_page.is_none() {
|
||||
log::warn!("DXF: unknown code page `{}`; reading the text as ANSI_1252", s.trim());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// True for an `$ACADVER` of R2007 (AC1021) or later, whose text is UTF-8.
|
||||
fn is_utf8_version(acadver: &str) -> bool {
|
||||
acadver.trim().strip_prefix("AC").and_then(|n| n.parse::<u32>().ok()).is_some_and(|n| n >= 1021)
|
||||
}
|
||||
|
||||
/// The double-byte code page of a `\M+n` escape: 1 Japanese (Shift-JIS), 2 Traditional Chinese
|
||||
/// (Big5), 3 Korean (Wansung), 5 Simplified Chinese (GB 2312). 4 (Korean Johab) is not
|
||||
/// supported, so its escapes stay literal.
|
||||
pub(crate) fn mif_encoding(n: char) -> Option<&'static Encoding> {
|
||||
match n {
|
||||
'1' => Some(encoding_rs::SHIFT_JIS),
|
||||
'2' => Some(encoding_rs::BIG5),
|
||||
'3' => Some(encoding_rs::EUC_KR),
|
||||
'5' => Some(encoding_rs::GBK),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Generated from the IBM PC code page definitions (bytes 0x80–0xFF in order).
|
||||
const DOS437: [char; 128] = [
|
||||
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}', '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}',
|
||||
'\u{00EE}', '\u{00EC}', '\u{00C4}', '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}', '\u{00F9}',
|
||||
'\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00A2}', '\u{00A3}', '\u{00A5}', '\u{20A7}', '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
|
||||
'\u{00F1}', '\u{00D1}', '\u{00AA}', '\u{00BA}', '\u{00BF}', '\u{2310}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}', '\u{00BB}',
|
||||
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{2561}', '\u{2562}', '\u{2556}', '\u{2555}', '\u{2563}', '\u{2551}', '\u{2557}',
|
||||
'\u{255D}', '\u{255C}', '\u{255B}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{255E}', '\u{255F}',
|
||||
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{2567}', '\u{2568}', '\u{2564}', '\u{2565}', '\u{2559}',
|
||||
'\u{2558}', '\u{2552}', '\u{2553}', '\u{256B}', '\u{256A}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{258C}', '\u{2590}', '\u{2580}',
|
||||
'\u{03B1}', '\u{00DF}', '\u{0393}', '\u{03C0}', '\u{03A3}', '\u{03C3}', '\u{00B5}', '\u{03C4}', '\u{03A6}', '\u{0398}', '\u{03A9}', '\u{03B4}',
|
||||
'\u{221E}', '\u{03C6}', '\u{03B5}', '\u{2229}', '\u{2261}', '\u{00B1}', '\u{2265}', '\u{2264}', '\u{2320}', '\u{2321}', '\u{00F7}', '\u{2248}',
|
||||
'\u{00B0}', '\u{2219}', '\u{00B7}', '\u{221A}', '\u{207F}', '\u{00B2}', '\u{25A0}', '\u{00A0}',
|
||||
];
|
||||
const DOS850: [char; 128] = [
|
||||
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{00E0}', '\u{00E5}', '\u{00E7}', '\u{00EA}', '\u{00EB}', '\u{00E8}', '\u{00EF}',
|
||||
'\u{00EE}', '\u{00EC}', '\u{00C4}', '\u{00C5}', '\u{00C9}', '\u{00E6}', '\u{00C6}', '\u{00F4}', '\u{00F6}', '\u{00F2}', '\u{00FB}', '\u{00F9}',
|
||||
'\u{00FF}', '\u{00D6}', '\u{00DC}', '\u{00F8}', '\u{00A3}', '\u{00D8}', '\u{00D7}', '\u{0192}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
|
||||
'\u{00F1}', '\u{00D1}', '\u{00AA}', '\u{00BA}', '\u{00BF}', '\u{00AE}', '\u{00AC}', '\u{00BD}', '\u{00BC}', '\u{00A1}', '\u{00AB}', '\u{00BB}',
|
||||
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{00C1}', '\u{00C2}', '\u{00C0}', '\u{00A9}', '\u{2563}', '\u{2551}', '\u{2557}',
|
||||
'\u{255D}', '\u{00A2}', '\u{00A5}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{00E3}', '\u{00C3}',
|
||||
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{00A4}', '\u{00F0}', '\u{00D0}', '\u{00CA}', '\u{00CB}',
|
||||
'\u{00C8}', '\u{0131}', '\u{00CD}', '\u{00CE}', '\u{00CF}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{00A6}', '\u{00CC}', '\u{2580}',
|
||||
'\u{00D3}', '\u{00DF}', '\u{00D4}', '\u{00D2}', '\u{00F5}', '\u{00D5}', '\u{00B5}', '\u{00FE}', '\u{00DE}', '\u{00DA}', '\u{00DB}', '\u{00D9}',
|
||||
'\u{00FD}', '\u{00DD}', '\u{00AF}', '\u{00B4}', '\u{00AD}', '\u{00B1}', '\u{2017}', '\u{00BE}', '\u{00B6}', '\u{00A7}', '\u{00F7}', '\u{00B8}',
|
||||
'\u{00B0}', '\u{00A8}', '\u{00B7}', '\u{00B9}', '\u{00B3}', '\u{00B2}', '\u{25A0}', '\u{00A0}',
|
||||
];
|
||||
const DOS852: [char; 128] = [
|
||||
'\u{00C7}', '\u{00FC}', '\u{00E9}', '\u{00E2}', '\u{00E4}', '\u{016F}', '\u{0107}', '\u{00E7}', '\u{0142}', '\u{00EB}', '\u{0150}', '\u{0151}',
|
||||
'\u{00EE}', '\u{0179}', '\u{00C4}', '\u{0106}', '\u{00C9}', '\u{0139}', '\u{013A}', '\u{00F4}', '\u{00F6}', '\u{013D}', '\u{013E}', '\u{015A}',
|
||||
'\u{015B}', '\u{00D6}', '\u{00DC}', '\u{0164}', '\u{0165}', '\u{0141}', '\u{00D7}', '\u{010D}', '\u{00E1}', '\u{00ED}', '\u{00F3}', '\u{00FA}',
|
||||
'\u{0104}', '\u{0105}', '\u{017D}', '\u{017E}', '\u{0118}', '\u{0119}', '\u{00AC}', '\u{017A}', '\u{010C}', '\u{015F}', '\u{00AB}', '\u{00BB}',
|
||||
'\u{2591}', '\u{2592}', '\u{2593}', '\u{2502}', '\u{2524}', '\u{00C1}', '\u{00C2}', '\u{011A}', '\u{015E}', '\u{2563}', '\u{2551}', '\u{2557}',
|
||||
'\u{255D}', '\u{017B}', '\u{017C}', '\u{2510}', '\u{2514}', '\u{2534}', '\u{252C}', '\u{251C}', '\u{2500}', '\u{253C}', '\u{0102}', '\u{0103}',
|
||||
'\u{255A}', '\u{2554}', '\u{2569}', '\u{2566}', '\u{2560}', '\u{2550}', '\u{256C}', '\u{00A4}', '\u{0111}', '\u{0110}', '\u{010E}', '\u{00CB}',
|
||||
'\u{010F}', '\u{0147}', '\u{00CD}', '\u{00CE}', '\u{011B}', '\u{2518}', '\u{250C}', '\u{2588}', '\u{2584}', '\u{0162}', '\u{016E}', '\u{2580}',
|
||||
'\u{00D3}', '\u{00DF}', '\u{00D4}', '\u{0143}', '\u{0144}', '\u{0148}', '\u{0160}', '\u{0161}', '\u{0154}', '\u{00DA}', '\u{0155}', '\u{0170}',
|
||||
'\u{00FD}', '\u{00DD}', '\u{0163}', '\u{00B4}', '\u{00AD}', '\u{02DD}', '\u{02DB}', '\u{02C7}', '\u{02D8}', '\u{00A7}', '\u{00F7}', '\u{00B8}',
|
||||
'\u{00B0}', '\u{00A8}', '\u{02D9}', '\u{0171}', '\u{0158}', '\u{0159}', '\u{25A0}', '\u{00A0}',
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn code_page_names() {
|
||||
let d = |bytes: &[u8], cp: &str| decode_code_page(bytes, cp);
|
||||
assert_eq!(d(b"\xCF\xF0\xE8\xE2\xE5\xF2", "ANSI_1251").as_deref(), Some("Привет"));
|
||||
assert_eq!(d(b"\xCF\xF0\xE8\xE2\xE5\xF2", " ansi_1251 ").as_deref(), Some("Привет"));
|
||||
assert_eq!(d(b"\x90\x7D\x96\xCA", "ANSI_932").as_deref(), Some("図面"));
|
||||
assert_eq!(d(b"\xCD\xBC\xB2\xE3", "GB2312").as_deref(), Some("图层"));
|
||||
assert_eq!(d(b"\xB9\xCF\xBC\x68", "BIG5").as_deref(), Some("圖層"));
|
||||
assert_eq!(d(b"\xB5\xB5\xB8\xE9", "ANSI_949").as_deref(), Some("도면"));
|
||||
assert_eq!(d(b"\xD3\xF7\xDD\xE4\xE9\xEF", "ANSI_1253").as_deref(), Some("Σχέδιο"));
|
||||
assert_eq!(d(b"\xC0\xD2\xBE", "ANSI_874").as_deref(), Some("ภาพ"));
|
||||
assert_eq!(d(b"\x80", "ANSI_1252").as_deref(), Some("€"));
|
||||
// DOS 437 and 850 agree on letters but differ on 0x9B (¢ / ø).
|
||||
assert_eq!(d(b"Gr\x94\xE1e \x9B", "DOS437").as_deref(), Some("Größe ¢"));
|
||||
assert_eq!(d(b"Gr\x94\xE1e \x9B", "dos850").as_deref(), Some("Größe ø"));
|
||||
assert_eq!(d(b"\xBD\xA2\x88w", "DOS852").as_deref(), Some("Żółw"));
|
||||
assert_eq!(d(b"\x91\xAB\xAE\xA9", "DOS866").as_deref(), Some("Слой"));
|
||||
assert_eq!(d("é".as_bytes(), "UTF8").as_deref(), Some("é"));
|
||||
assert_eq!(d(b"x", "ANSI_1361"), None);
|
||||
assert_eq!(d(b"x", "EBCDIC"), None);
|
||||
assert_eq!(d(b"x", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sequences_become_replacement_characters() {
|
||||
// A Shift-JIS lead byte cut off by the line end keeps the line end.
|
||||
assert_eq!(decode_code_page(b"\x90\n1\n", "ANSI_932").as_deref(), Some("\u{FFFD}\n1\n"));
|
||||
assert_eq!(decode_code_page(b"\x90", "ANSI_932").as_deref(), Some("\u{FFFD}"));
|
||||
assert_eq!(decode_code_page(b"\xFF\xFF", "ANSI_936").as_deref(), Some("\u{FFFD}\u{FFFD}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_scan() {
|
||||
let head = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1251\r\n0\nENDSEC\n";
|
||||
assert_eq!(ascii_code_page(head), Some(&b"ANSI_1251"[..]));
|
||||
// Only the HEADER section counts.
|
||||
assert_eq!(ascii_code_page(b"0\nSECTION\n2\nHEADER\n0\nENDSEC\n9\n$DWGCODEPAGE\n3\nANSI_1251\n"), None);
|
||||
// Wrong group code, or the file ends early.
|
||||
assert_eq!(ascii_code_page(b"9\n$DWGCODEPAGE\n1\nANSI_1251\n"), None);
|
||||
assert_eq!(ascii_code_page(b"9\n$DWGCODEPAGE\n3"), None);
|
||||
assert!(is_utf8_version("AC1021") && is_utf8_version(" AC1032 "));
|
||||
assert!(!is_utf8_version("AC1018") && !is_utf8_version("AC1009") && !is_utf8_version("garbage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mif_escapes() {
|
||||
let one = |s: &str| crate::parse(format!("1\n{s}\n0\nEOF\n").as_bytes()).unwrap()[0].str();
|
||||
assert_eq!(one("\\M+5BCFE\\M+1907D\\M+2B9CF\\M+3B5B5!"), "件図圖도!");
|
||||
assert_eq!(one("\\M+5BCFE\\U+0021"), "件!");
|
||||
// Johab, unknown code pages, bad hex, short escapes and unassigned codes stay literal.
|
||||
for s in ["\\M+4ABCD", "\\M+9BCFE", "\\M+5GGGG", "\\M+5BC", "\\M+", "\\M+50041", "\\M+5FFFF"] {
|
||||
assert_eq!(one(s), s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,925 +0,0 @@
|
||||
//! A standalone DXF tag reader and writer.
|
||||
//!
|
||||
//! DXF is a sequence of (group code, value) pairs. This crate parses ASCII and binary DXF into a
|
||||
//! flat list of [`Tag`]s, splits them into sections and entities, and writes tags back. Mapping
|
||||
//! tags to a drawing model is left to the caller. Written from Autodesk's published DXF
|
||||
//! Reference; depends on nothing else in the workspace.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
mod version;
|
||||
pub use version::Version;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DxfError {
|
||||
#[error("not a DXF file")]
|
||||
NotDxf,
|
||||
#[error("line {line}: bad group code `{text}`")]
|
||||
BadCode { line: usize, text: String },
|
||||
#[error("unexpected end of file")]
|
||||
Eof,
|
||||
/// More group codes than [`MAX_TAGS`]: `tags` is the count found (or the count read when the
|
||||
/// limit was reached), `limit` the maximum.
|
||||
#[error("drawing too large: {tags} DXF group codes, more than the {limit} CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)")]
|
||||
TooLarge { tags: usize, limit: usize },
|
||||
/// The memory for `tags` group codes couldn't be had: an error, not the process ending.
|
||||
#[error("not enough memory to read this drawing's {tags} DXF group codes")]
|
||||
OutOfMemory { tags: usize },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DxfError>;
|
||||
|
||||
/// A typed group value.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Value {
|
||||
Str(String),
|
||||
Real(f64),
|
||||
Int(i64),
|
||||
Bool(bool),
|
||||
/// Binary chunk (310–319, 1004) as hex text.
|
||||
Hex(String),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn as_str(&self) -> String {
|
||||
match self {
|
||||
Value::Str(s) | Value::Hex(s) => s.clone(),
|
||||
Value::Real(r) => format_real(*r),
|
||||
Value::Int(i) => i.to_string(),
|
||||
Value::Bool(b) => i64::from(*b).to_string(),
|
||||
}
|
||||
}
|
||||
pub fn as_f64(&self) -> f64 {
|
||||
match self {
|
||||
Value::Real(r) if r.is_finite() => *r,
|
||||
Value::Int(i) => *i as f64,
|
||||
Value::Str(s) => s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(0.0),
|
||||
Value::Bool(b) => f64::from(u8::from(*b)),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
pub fn as_i64(&self) -> i64 {
|
||||
match self {
|
||||
Value::Int(i) => *i,
|
||||
Value::Real(r) if r.is_finite() => *r as i64,
|
||||
Value::Str(s) => {
|
||||
s.trim().parse::<i64>().unwrap_or_else(|_| s.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| f as i64))
|
||||
}
|
||||
Value::Bool(b) => i64::from(*b),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Tag {
|
||||
pub code: i32,
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
impl Tag {
|
||||
pub fn new(code: i32, value: Value) -> Self {
|
||||
Tag { code, value }
|
||||
}
|
||||
pub fn s(code: i32, v: impl Into<String>) -> Self {
|
||||
Tag { code, value: Value::Str(v.into()) }
|
||||
}
|
||||
pub fn f(code: i32, v: f64) -> Self {
|
||||
Tag { code, value: Value::Real(if v.is_finite() { v } else { 0.0 }) }
|
||||
}
|
||||
pub fn i(code: i32, v: i64) -> Self {
|
||||
Tag { code, value: Value::Int(v) }
|
||||
}
|
||||
pub fn str(&self) -> String {
|
||||
self.value.as_str()
|
||||
}
|
||||
pub fn f64(&self) -> f64 {
|
||||
self.value.as_f64()
|
||||
}
|
||||
pub fn i64(&self) -> i64 {
|
||||
self.value.as_i64()
|
||||
}
|
||||
}
|
||||
|
||||
/// The value type for a group code (DXF Reference, "Group Code Value Types").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Str,
|
||||
Real,
|
||||
Int16,
|
||||
Int32,
|
||||
Int64,
|
||||
Bool,
|
||||
Hex,
|
||||
}
|
||||
|
||||
pub fn kind_of(code: i32) -> Kind {
|
||||
match code {
|
||||
0..=9 => Kind::Str,
|
||||
10..=59 => Kind::Real,
|
||||
60..=79 => Kind::Int16,
|
||||
90..=99 => Kind::Int32,
|
||||
100..=109 => Kind::Str,
|
||||
110..=149 => Kind::Real,
|
||||
160..=169 => Kind::Int64,
|
||||
170..=179 => Kind::Int16,
|
||||
210..=239 => Kind::Real,
|
||||
270..=289 => Kind::Int16,
|
||||
290..=299 => Kind::Bool,
|
||||
300..=309 => Kind::Str,
|
||||
310..=319 => Kind::Hex,
|
||||
320..=369 => Kind::Str,
|
||||
370..=389 => Kind::Int16,
|
||||
390..=399 => Kind::Str,
|
||||
400..=409 => Kind::Int16,
|
||||
410..=419 => Kind::Str,
|
||||
420..=429 => Kind::Int32,
|
||||
430..=439 => Kind::Str,
|
||||
440..=449 => Kind::Int32,
|
||||
450..=459 => Kind::Int32,
|
||||
460..=469 => Kind::Real,
|
||||
470..=479 => Kind::Str,
|
||||
480..=481 => Kind::Str,
|
||||
999 => Kind::Str,
|
||||
1000..=1003 => Kind::Str,
|
||||
1004 => Kind::Hex,
|
||||
1005..=1009 => Kind::Str,
|
||||
1010..=1059 => Kind::Real,
|
||||
1060..=1070 => Kind::Int16,
|
||||
1071 => Kind::Int32,
|
||||
_ => Kind::Str,
|
||||
}
|
||||
}
|
||||
|
||||
fn typed(code: i32, text: &str) -> Value {
|
||||
let t = text.trim();
|
||||
match kind_of(code) {
|
||||
Kind::Real => t.parse::<f64>().ok().filter(|v| v.is_finite()).map(Value::Real).unwrap_or(Value::Real(0.0)),
|
||||
Kind::Int16 | Kind::Int32 | Kind::Int64 => {
|
||||
// A float spelling ("1e3", "12.0") is accepted when finite, clamped to what the
|
||||
// group's width holds, signed or unsigned (files write 16-bit flags up to 65535, as
|
||||
// the integer spelling reads them); NaN, inf and text become 0.
|
||||
let (lo, hi) = match kind_of(code) {
|
||||
Kind::Int16 => (i64::from(i16::MIN), i64::from(u16::MAX)),
|
||||
Kind::Int32 => (i64::from(i32::MIN), i64::from(u32::MAX)),
|
||||
_ => (i64::MIN, i64::MAX),
|
||||
};
|
||||
t.parse::<i64>()
|
||||
.map(Value::Int)
|
||||
.unwrap_or_else(|_| Value::Int(t.parse::<f64>().ok().filter(|f| f.is_finite()).map_or(0, |f| (f as i64).clamp(lo, hi))))
|
||||
}
|
||||
Kind::Bool => Value::Bool(t.parse::<i64>().unwrap_or(0) != 0),
|
||||
Kind::Hex => Value::Hex(normalize_hex(t)),
|
||||
Kind::Str => Value::Str(text.trim_end_matches(['\r', '\n']).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";
|
||||
/// Most group codes read from one file by default. A `Tag` takes 40 bytes plus its text, so this
|
||||
/// bounds the tag list to about 4 GB plus text. 50 million rejected large DWGs that converted fine
|
||||
/// (a 172 MB drawing has 56 million, issue #1948). The file-size limits bound memory too: 512 MB
|
||||
/// for a DXF opened directly, 1 GB for a DWG's DXF rendition. Set `CADCRAFT_MAX_DXF_TAGS` to
|
||||
/// raise or lower it.
|
||||
pub const MAX_TAGS: usize = 100_000_000;
|
||||
|
||||
/// Environment variable overriding [`MAX_TAGS`] (a positive integer).
|
||||
pub const MAX_TAGS_ENV: &str = "CADCRAFT_MAX_DXF_TAGS";
|
||||
|
||||
/// The group-code limit in effect: [`MAX_TAGS_ENV`] when set to a positive integer, else
|
||||
/// [`MAX_TAGS`].
|
||||
pub fn max_tags() -> usize {
|
||||
max_tags_from(std::env::var(MAX_TAGS_ENV).ok().as_deref())
|
||||
}
|
||||
|
||||
fn max_tags_from(v: Option<&str>) -> usize {
|
||||
v.and_then(|s| s.trim().replace('_', "").parse::<usize>().ok()).filter(|&n| n > 0).unwrap_or(MAX_TAGS)
|
||||
}
|
||||
|
||||
mod codepage;
|
||||
pub use codepage::decode_code_page;
|
||||
|
||||
/// Parse a DXF file (ASCII or binary).
|
||||
pub fn parse(bytes: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_with_limit(bytes, max_tags())
|
||||
}
|
||||
|
||||
/// [`parse`] with an explicit group-code limit. For ASCII DXF the limit is checked up front from
|
||||
/// the line count, before the text is decoded or any tag allocated, so an oversized file fails in
|
||||
/// milliseconds instead of after it has filled memory.
|
||||
pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) {
|
||||
return parse_binary_with_limit(body, max_tags);
|
||||
}
|
||||
let bytes = bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes);
|
||||
// Count nonblank group-code/value pairs (LF, CRLF and a lone CR each end a line), stopping at
|
||||
// EOF just as the parser does. Blank lines do not count toward the limit.
|
||||
let mut lines = byte_lines(bytes);
|
||||
let mut tags = 0;
|
||||
while let Some(code) = lines.next() {
|
||||
let code = code.trim_ascii();
|
||||
if code.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = lines.next() else { break };
|
||||
tags += 1;
|
||||
if code == b"0" && value.trim_ascii() == b"EOF" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags > max_tags {
|
||||
return Err(DxfError::TooLarge { tags, limit: max_tags });
|
||||
}
|
||||
parse_ascii_counted(&codepage::decode_ascii_file(bytes), max_tags, tags)
|
||||
}
|
||||
|
||||
/// The four hex digits of a `\U+XXXX` escape starting at `chars[i]`, if there is one.
|
||||
fn escape_at(chars: &[char], i: usize) -> Option<u32> {
|
||||
if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'U') || chars.get(i + 2) != Some(&'+') {
|
||||
return None;
|
||||
}
|
||||
let hex = chars.get(i + 3..i + 7)?;
|
||||
if !hex.iter().all(char::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
hex.iter().try_fold(0u32, |acc, c| Some(acc * 16 + c.to_digit(16)?))
|
||||
}
|
||||
|
||||
/// The character of a `\M+nXXXX` escape starting at `chars[i]`, if there is one: `n` names a
|
||||
/// double-byte code page and `XXXX` is the character's two bytes in hex.
|
||||
pub fn mif_escape_at(chars: &[char], i: usize) -> Option<char> {
|
||||
if chars.get(i) != Some(&'\\') || chars.get(i + 1) != Some(&'M') || chars.get(i + 2) != Some(&'+') {
|
||||
return None;
|
||||
}
|
||||
let enc = codepage::mif_encoding(*chars.get(i + 3)?)?;
|
||||
let hex = chars.get(i + 4..i + 8)?;
|
||||
if !hex.iter().all(char::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
let code = hex.iter().try_fold(0u16, |acc, c| Some(acc * 16 + u16::try_from(c.to_digit(16)?).ok()?))?;
|
||||
let bytes = code.to_be_bytes();
|
||||
let text = enc.decode_without_bom_handling_and_without_replacement(&bytes)?;
|
||||
let mut it = text.chars();
|
||||
it.next().filter(|_| it.next().is_none())
|
||||
}
|
||||
|
||||
/// Unescape the `\U+XXXX` sequences pre-R2007 DXF uses for characters outside the file's code
|
||||
/// page (four hex digits, one UTF-16 code unit; characters above U+FFFF are a surrogate pair of
|
||||
/// escapes), and the `\M+nXXXX` sequences for double-byte East Asian characters. Malformed
|
||||
/// escapes, unpaired surrogates and unknown `\M+` code pages are kept as literal text.
|
||||
fn unescape_unicode(s: &str) -> String {
|
||||
if !s.contains("\\U+") && !s.contains("\\M+") {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
if let Some(ch) = mif_escape_at(&chars, i) {
|
||||
out.push(ch);
|
||||
i += 8;
|
||||
continue;
|
||||
}
|
||||
if let Some(unit) = escape_at(&chars, i) {
|
||||
if let Some(ch) = char::from_u32(unit) {
|
||||
out.push(ch);
|
||||
i += 7;
|
||||
continue;
|
||||
}
|
||||
if (0xD800..0xDC00).contains(&unit)
|
||||
&& let Some(low) = escape_at(&chars, i + 7).filter(|u| (0xDC00..0xE000).contains(u))
|
||||
&& let Some(ch) = char::from_u32(0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00))
|
||||
{
|
||||
out.push(ch);
|
||||
i += 14;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The lines of an ASCII DXF text: LF, CRLF and a lone CR (old Mac files) all end a line, like
|
||||
/// [`str::lines`] otherwise (no empty line after a final terminator).
|
||||
fn ascii_lines(text: &str) -> impl Iterator<Item = &str> {
|
||||
let mut rest = text;
|
||||
std::iter::from_fn(move || {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.find(['\r', '\n']).unwrap_or(rest.len());
|
||||
let line = rest.get(..end).unwrap_or_default();
|
||||
let after = rest.get(end..).unwrap_or_default();
|
||||
rest = after.strip_prefix("\r\n").or_else(|| after.strip_prefix(['\r', '\n'])).unwrap_or(after);
|
||||
Some(line)
|
||||
})
|
||||
}
|
||||
|
||||
/// Byte-level twin of [`ascii_lines`] for the up-front limit check, so the count matches the parser.
|
||||
fn byte_lines(mut rest: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
std::iter::from_fn(move || {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(rest.len());
|
||||
let line = &rest[..end];
|
||||
let after = &rest[end..];
|
||||
rest = match after {
|
||||
[b'\r', b'\n', tail @ ..] | [b'\r' | b'\n', tail @ ..] => tail,
|
||||
_ => after,
|
||||
};
|
||||
Some(line)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
|
||||
parse_ascii_counted(text, max_tags(), 0)
|
||||
}
|
||||
|
||||
/// [`parse_ascii`] of a file known to hold `expected` group codes. Their list is reserved first,
|
||||
/// with an allocation that can fail: where the system refuses that much memory, opening the file
|
||||
/// is an error instead of the end of the app. Growing the list instead could also briefly take
|
||||
/// half as much again.
|
||||
#[allow(clippy::while_let_loop)]
|
||||
fn parse_ascii_counted(text: &str, max_tags: usize, expected: usize) -> Result<Vec<Tag>> {
|
||||
let mut tags = Vec::new();
|
||||
tags.try_reserve_exact(expected.min(max_tags)).map_err(|_| DxfError::OutOfMemory { tags: expected })?;
|
||||
let mut lines = ascii_lines(text.strip_prefix('\u{feff}').unwrap_or(text)).enumerate();
|
||||
loop {
|
||||
let Some((ln, code_line)) = lines.next() else { break };
|
||||
let ct = code_line.trim();
|
||||
if ct.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let code: i32 = ct.parse().map_err(|_| DxfError::BadCode { line: ln + 1, text: ct.chars().take(40).collect() })?;
|
||||
let Some((_, val)) = lines.next() else { return Err(DxfError::Eof) };
|
||||
let v = typed(code, val);
|
||||
let v = match v {
|
||||
Value::Str(s) => Value::Str(unescape_unicode(&s)),
|
||||
o => o,
|
||||
};
|
||||
tags.push(Tag { code, value: v });
|
||||
if tags.len() > max_tags {
|
||||
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
|
||||
}
|
||||
if code == 0 && tags.last().is_some_and(|t| t.str().trim_end() == "EOF") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags.is_empty() {
|
||||
return Err(DxfError::NotDxf);
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
struct Cur<'a> {
|
||||
b: &'a [u8],
|
||||
p: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cur<'a> {
|
||||
fn take(&mut self, n: usize) -> Result<&[u8]> {
|
||||
let end = self.p.checked_add(n).ok_or(DxfError::Eof)?;
|
||||
let s = self.b.get(self.p..end).ok_or(DxfError::Eof)?;
|
||||
self.p = end;
|
||||
Ok(s)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8> {
|
||||
Ok(*self.take(1)?.first().ok_or(DxfError::Eof)?)
|
||||
}
|
||||
fn i16(&mut self) -> Result<i16> {
|
||||
let s = self.take(2)?;
|
||||
Ok(i16::from_le_bytes([s[0], s[1]]))
|
||||
}
|
||||
fn i32(&mut self) -> Result<i32> {
|
||||
let s = self.take(4)?;
|
||||
Ok(i32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
}
|
||||
fn i64(&mut self) -> Result<i64> {
|
||||
let s = self.take(8)?;
|
||||
let mut a = [0u8; 8];
|
||||
a.copy_from_slice(s);
|
||||
Ok(i64::from_le_bytes(a))
|
||||
}
|
||||
fn f64(&mut self) -> Result<f64> {
|
||||
let s = self.take(8)?;
|
||||
let mut a = [0u8; 8];
|
||||
a.copy_from_slice(s);
|
||||
Ok(f64::from_le_bytes(a))
|
||||
}
|
||||
/// The bytes of a zero-terminated string (without the zero), decoded by the caller.
|
||||
fn cstr(&mut self) -> Result<&'a [u8]> {
|
||||
let start = self.p;
|
||||
while self.p < self.b.len() && self.b.get(self.p) != Some(&0) {
|
||||
self.p += 1;
|
||||
}
|
||||
let s = self.b.get(start..self.p).unwrap_or(&[]);
|
||||
if self.p >= self.b.len() {
|
||||
return Err(DxfError::Eof);
|
||||
}
|
||||
self.p += 1;
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary DXF (R13+: 2-byte group codes).
|
||||
pub fn parse_binary(b: &[u8]) -> Result<Vec<Tag>> {
|
||||
parse_binary_with_limit(b, max_tags())
|
||||
}
|
||||
|
||||
fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
|
||||
let mut text = codepage::BinaryText::default();
|
||||
let tags = parse_binary_pass(b, max_tags, &mut text)?;
|
||||
if !text.needs_code_page_pass() {
|
||||
return Ok(tags);
|
||||
}
|
||||
// The first pass's tags go before the second pass runs: both held at once would double
|
||||
// the peak near the tag limit.
|
||||
drop(tags);
|
||||
parse_binary_pass(b, max_tags, &mut codepage::BinaryText::code_page_pass())
|
||||
}
|
||||
|
||||
fn parse_binary_pass(b: &[u8], max_tags: usize, text: &mut codepage::BinaryText) -> Result<Vec<Tag>> {
|
||||
let mut c = Cur { b, p: 0 };
|
||||
let mut tags = Vec::new();
|
||||
while c.p < b.len() {
|
||||
let mut code = i32::from(c.i16()?);
|
||||
if code == 255 {
|
||||
code = i32::from(c.i16()?);
|
||||
}
|
||||
let v = match kind_of(code) {
|
||||
Kind::Str => Value::Str(unescape_unicode(&text.string(code, c.cstr()?))),
|
||||
Kind::Real => Value::Real(Some(c.f64()?).filter(|v| v.is_finite()).unwrap_or(0.0)),
|
||||
Kind::Int16 => Value::Int(i64::from(c.i16()?)),
|
||||
Kind::Int32 => Value::Int(i64::from(c.i32()?)),
|
||||
Kind::Int64 => Value::Int(c.i64()?),
|
||||
Kind::Bool => Value::Bool(c.u8()? != 0),
|
||||
Kind::Hex => {
|
||||
let n = usize::from(c.u8()?);
|
||||
let bytes = c.take(n)?;
|
||||
let mut s = String::with_capacity(n * 2);
|
||||
for x in bytes {
|
||||
let _ = write!(s, "{x:02X}");
|
||||
}
|
||||
Value::Hex(s)
|
||||
}
|
||||
};
|
||||
let eof = code == 0 && matches!(&v, Value::Str(s) if s.trim_end() == "EOF");
|
||||
tags.push(Tag { code, value: v });
|
||||
if tags.len() > max_tags {
|
||||
return Err(DxfError::TooLarge { tags: tags.len(), limit: max_tags });
|
||||
}
|
||||
if eof {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if tags.is_empty() {
|
||||
return Err(DxfError::NotDxf);
|
||||
}
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Format a real the way DXF writers usually do (shortest round-trip, with a decimal point).
|
||||
pub fn format_real(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0.0".into();
|
||||
}
|
||||
let s = format!("{v}");
|
||||
if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") { s } else { format!("{s}.0") }
|
||||
}
|
||||
|
||||
/// Write tags as ASCII DXF (CRLF line ends, codes right-aligned to 3 as is conventional).
|
||||
///
|
||||
/// The output is pure ASCII: non-ASCII characters in strings are written as `\U+XXXX` escapes,
|
||||
/// the pre-R2007 convention, so it is correct whatever `$DWGCODEPAGE` the header declares.
|
||||
pub fn write_ascii(tags: &[Tag]) -> String {
|
||||
write_ascii_as(tags, false)
|
||||
}
|
||||
|
||||
/// Write tags as ASCII DXF with text in UTF-8 (`utf8`, the R2007+ convention: non-ASCII
|
||||
/// characters as they are) or escaped as `\U+XXXX` (older versions, see [`write_ascii`]). Line
|
||||
/// breaks inside strings become spaces either way.
|
||||
pub fn write_ascii_as(tags: &[Tag], utf8: bool) -> String {
|
||||
let bytes = ascii(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) });
|
||||
// Both forms are UTF-8 (the escaped one is ASCII).
|
||||
String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
|
||||
}
|
||||
|
||||
/// Write tags as pre-R2007 ASCII DXF with text in the code page `code_page` names (a
|
||||
/// `$DWGCODEPAGE` value such as `ANSI_1252`), as the DXF Reference describes for those versions:
|
||||
/// a character the code page has is written as its bytes, any other as a `\U+XXXX` escape. A
|
||||
/// code page CADCraft doesn't know, `ASCII`, and `UTF8`/`UNICODE` (not a pre-2007 code page)
|
||||
/// give [`write_ascii`]'s pure ASCII output. So does a file whose code page bytes happen to be
|
||||
/// valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, U+0472), since readers, ours included, take an ASCII
|
||||
/// DXF that is valid UTF-8 as UTF-8.
|
||||
pub fn write_ascii_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
|
||||
let bytes = ascii(tags, Strings::Escaped(codepage::encoder(code_page)));
|
||||
if !bytes.is_ascii() && std::str::from_utf8(&bytes).is_ok() {
|
||||
return ascii(tags, Strings::Escaped(None));
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// How a writer stores string values.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Strings {
|
||||
/// As they are (R2007+).
|
||||
Utf8,
|
||||
/// ASCII as it is, other characters in this code page when it has them, otherwise as
|
||||
/// `\U+XXXX` escapes (before R2007).
|
||||
Escaped(Option<codepage::Decoder>),
|
||||
}
|
||||
|
||||
fn ascii(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(tags.len() * 16);
|
||||
for t in tags {
|
||||
let _ = write!(out, "{:>3}\r\n", t.code);
|
||||
match &t.value {
|
||||
Value::Str(s) => push_string(s, strings, &mut out),
|
||||
Value::Real(r) => out.extend_from_slice(format_real(*r).as_bytes()),
|
||||
Value::Int(i) => out.extend_from_slice(i.to_string().as_bytes()),
|
||||
Value::Bool(b) => out.extend_from_slice(i64::from(*b).to_string().as_bytes()),
|
||||
Value::Hex(h) => {
|
||||
// The DXF Reference limits a binary chunk line to 254 hex digits (127 bytes);
|
||||
// longer data continues in further groups of the same code. Only hex digits are
|
||||
// written, so a hostile value can't inject tags.
|
||||
let digits = normalize_hex(h);
|
||||
let mut chunks: Vec<&str> = digits.as_bytes().chunks(254).map(|c| std::str::from_utf8(c).unwrap_or_default()).collect();
|
||||
if chunks.is_empty() {
|
||||
chunks.push("");
|
||||
}
|
||||
for (n, chunk) in chunks.into_iter().enumerate() {
|
||||
if n > 0 {
|
||||
let _ = write!(out, "\r\n{:>3}\r\n", t.code);
|
||||
}
|
||||
out.extend_from_slice(chunk.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Write tags as binary DXF (DXF Reference, "Binary DXF Files", R13+ layout): the sentinel,
|
||||
/// then per tag a 2-byte little-endian group code and the value in the binary form of the code's
|
||||
/// [`Kind`]: a zero-terminated string, an 8-byte real, a 2-, 4- or 8-byte integer, a 1-byte
|
||||
/// boolean, or a binary chunk (one length byte, then up to 255 bytes; longer data is split into
|
||||
/// several groups of the same code). Strings are UTF-8 when `utf8` (R2007+), otherwise escaped as
|
||||
/// in [`write_ascii`]. A tag's value is converted to its code's kind, so a number written as text
|
||||
/// is stored as a number; a code outside the 16-bit range is skipped.
|
||||
pub fn write_binary(tags: &[Tag], utf8: bool) -> Vec<u8> {
|
||||
binary(tags, if utf8 { Strings::Utf8 } else { Strings::Escaped(None) })
|
||||
}
|
||||
|
||||
/// Write tags as pre-R2007 binary DXF with text in the code page `code_page` names, as
|
||||
/// [`write_ascii_in`] does.
|
||||
pub fn write_binary_in(tags: &[Tag], code_page: &str) -> Vec<u8> {
|
||||
binary(tags, Strings::Escaped(codepage::encoder(code_page)))
|
||||
}
|
||||
|
||||
fn binary(tags: &[Tag], strings: Strings) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(BINARY_SENTINEL.len() + tags.len() * 12);
|
||||
out.extend_from_slice(BINARY_SENTINEL);
|
||||
for t in tags {
|
||||
let Ok(code) = i16::try_from(t.code) else { continue };
|
||||
let code_bytes = code.to_le_bytes();
|
||||
match kind_of(t.code) {
|
||||
Kind::Str => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
let s = match &t.value {
|
||||
Value::Str(s) => s.clone(),
|
||||
v => v.as_str(),
|
||||
};
|
||||
let mut text = Vec::with_capacity(s.len());
|
||||
push_string(&s, strings, &mut text);
|
||||
out.extend(text.into_iter().filter(|&b| b != 0));
|
||||
out.push(0);
|
||||
}
|
||||
Kind::Real => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&t.value.as_f64().to_le_bytes());
|
||||
}
|
||||
Kind::Int16 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&wrap_i16(t.value.as_i64()));
|
||||
}
|
||||
Kind::Int32 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&wrap_i32(t.value.as_i64()));
|
||||
}
|
||||
Kind::Int64 => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.extend_from_slice(&t.value.as_i64().to_le_bytes());
|
||||
}
|
||||
Kind::Bool => {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.push(u8::from(t.value.as_i64() != 0));
|
||||
}
|
||||
Kind::Hex => {
|
||||
let bytes = hex_bytes(&t.value.as_str());
|
||||
// An empty chunk is still one group, as in ASCII.
|
||||
let mut chunks: Vec<&[u8]> = bytes.chunks(255).collect();
|
||||
if chunks.is_empty() {
|
||||
chunks.push(&[]);
|
||||
}
|
||||
for chunk in chunks {
|
||||
out.extend_from_slice(&code_bytes);
|
||||
out.push(u8::try_from(chunk.len()).unwrap_or(u8::MAX));
|
||||
out.extend_from_slice(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Hex text cleaned up: only the hex digits are kept, and an odd trailing digit is padded with
|
||||
/// `0` (so the bytes before it are never shifted). Used by the reader and both writers.
|
||||
fn normalize_hex(s: &str) -> String {
|
||||
let mut out: String = s.chars().filter(char::is_ascii_hexdigit).collect();
|
||||
if out.len() % 2 == 1 {
|
||||
out.push('0');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bytes of hex text, two digits each (see [`normalize_hex`]).
|
||||
fn hex_bytes(s: &str) -> Vec<u8> {
|
||||
let digits: Vec<u8> = normalize_hex(s).bytes().filter_map(|b| char::from(b).to_digit(16).and_then(|d| u8::try_from(d).ok())).collect();
|
||||
digits.as_chunks::<2>().0.iter().map(|[hi, lo]| (hi << 4) | lo).collect()
|
||||
}
|
||||
|
||||
/// A 16- or 32-bit integer for binary DXF: values that fit the unsigned width wrap into the
|
||||
/// signed one (65535 is -1, as writers that store unsigned flags expect); anything else clamps.
|
||||
fn wrap_i16(v: i64) -> [u8; 2] {
|
||||
match u16::try_from(v) {
|
||||
Ok(u) => u.to_le_bytes(),
|
||||
Err(_) => i16::try_from(v.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default().to_le_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_i32(v: i64) -> [u8; 4] {
|
||||
match u32::try_from(v) {
|
||||
Ok(u) => u.to_le_bytes(),
|
||||
Err(_) => i32::try_from(v.clamp(i64::from(i32::MIN), i64::from(i32::MAX))).unwrap_or_default().to_le_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` are a binary DXF file (they start with the binary sentinel).
|
||||
pub fn is_binary(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(BINARY_SENTINEL)
|
||||
}
|
||||
|
||||
/// A string value as `strings` stores it. Line breaks become spaces. Before R2007 the text is
|
||||
/// in the `$DWGCODEPAGE` code page: ASCII stays as it is, a character the code page has becomes
|
||||
/// its bytes, and every other one `\U+XXXX` (a surrogate pair of escapes above U+FFFF). Without
|
||||
/// a code page the result is ASCII, so it is valid in any code page. In every form a backslash
|
||||
/// that would start a `\U+` or `\M+` sequence is written as `\U+005C`, so the reader gives back
|
||||
/// the literal text (#2050). So is a double-byte character whose trail byte is 0x5C (`\`) in that
|
||||
/// place, such as Shift-JIS `表` or Big5 `許`: CADCraft decodes the code page before it looks for
|
||||
/// escapes, but a reader that looks first would take that byte and the `U+` after it for one.
|
||||
fn push_string(s: &str, strings: Strings, out: &mut Vec<u8>) {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let escape_at = |j: usize| matches!(chars.get(j), Some('U' | 'M')) && chars.get(j + 1) == Some(&'+');
|
||||
for (i, &c) in chars.iter().enumerate() {
|
||||
match (c, strings) {
|
||||
('\r' | '\n', _) => out.push(b' '),
|
||||
('\\', _) if escape_at(i + 1) => {
|
||||
out.extend_from_slice(b"\\U+005C");
|
||||
}
|
||||
(c, Strings::Utf8) => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
|
||||
(c, _) if c.is_ascii() => out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()),
|
||||
(c, Strings::Escaped(Some(d))) if code_page_bytes(c, d, escape_at(i + 1), out) => {}
|
||||
(c, Strings::Escaped(_)) => {
|
||||
let mut units = [0u16; 2];
|
||||
for u in c.encode_utf16(&mut units) {
|
||||
let _ = write!(out, "\\U+{u:04X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `c` in the code page `d`, if it has it, unless it is a double-byte character whose trail byte
|
||||
/// is 0x5C and an escape's `U+` or `M+` follows (`before_escape`): see [`push_string`].
|
||||
fn code_page_bytes(c: char, d: codepage::Decoder, before_escape: bool, out: &mut Vec<u8>) -> bool {
|
||||
let start = out.len();
|
||||
if !codepage::encode_char(c, d, out) {
|
||||
return false;
|
||||
}
|
||||
if before_escape && out.len() - start > 1 && out.last() == Some(&b'\\') {
|
||||
out.truncate(start);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A section: `0 SECTION / 2 NAME ... 0 ENDSEC`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Section {
|
||||
pub name: String,
|
||||
pub tags: Vec<Tag>,
|
||||
}
|
||||
|
||||
/// Split a tag stream into sections.
|
||||
pub fn sections(tags: &[Tag]) -> Vec<Section> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < tags.len() {
|
||||
let is_sec = tags.get(i).is_some_and(|t| t.code == 0 && t.str().trim_end() == "SECTION");
|
||||
if is_sec {
|
||||
let name = tags.get(i + 1).filter(|t| t.code == 2).map(|t| t.str().trim_end().to_string()).unwrap_or_default();
|
||||
let start = i + 2;
|
||||
let mut j = start;
|
||||
while j < tags.len() && !(tags.get(j).is_some_and(|t| t.code == 0 && t.str().trim_end() == "ENDSEC")) {
|
||||
j += 1;
|
||||
}
|
||||
out.push(Section { name, tags: tags.get(start..j).unwrap_or(&[]).to_vec() });
|
||||
i = j + 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a run of tags into records starting at each code-0 tag: (type, tags after the 0).
|
||||
pub fn records(tags: &[Tag]) -> Vec<(String, Vec<Tag>)> {
|
||||
let mut out: Vec<(String, Vec<Tag>)> = Vec::new();
|
||||
for t in tags {
|
||||
if t.code == 0 {
|
||||
out.push((t.str(), Vec::new()));
|
||||
} else if let Some(last) = out.last_mut() {
|
||||
last.1.push(t.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn literal_escape_text_roundtrips() {
|
||||
for utf8 in [false, true] {
|
||||
for text in ["\\U+00E9", "\\M+183A4F", "a\\nb \\P", "\\\\U+00E9", "\\U+005C"] {
|
||||
let tags = vec![Tag::s(8, text)];
|
||||
let back = parse_ascii(&write_ascii_as(&tags, utf8)).unwrap();
|
||||
assert_eq!(back, tags, "utf8={utf8} {text}");
|
||||
let back = parse_binary(&write_binary(&tags, utf8)).unwrap();
|
||||
assert_eq!(back[1..], tags[..], "binary utf8={utf8} {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_do_not_consume_group_code_limit() {
|
||||
let tags = parse_with_limit(b"0\nEOF\n\n\n", 1).expect("one group code should fit a limit of one");
|
||||
assert_eq!(tags, vec![Tag::s(0, "EOF")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_roundtrip() {
|
||||
let tags = vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "LINE"),
|
||||
Tag::s(8, "0"),
|
||||
Tag::f(10, 1.5),
|
||||
Tag::f(20, 2.0),
|
||||
Tag::i(62, 1),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "EOF"),
|
||||
];
|
||||
let text = write_ascii(&tags);
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back, tags);
|
||||
let secs = sections(&back);
|
||||
assert_eq!(secs.len(), 1);
|
||||
assert_eq!(secs[0].name, "ENTITIES");
|
||||
let recs = records(&secs[0].tags);
|
||||
assert_eq!(recs[0].0, "LINE");
|
||||
assert_eq!(recs[0].1.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_parse() {
|
||||
let mut b = BINARY_SENTINEL.to_vec();
|
||||
let push_str = |b: &mut Vec<u8>, code: i16, s: &str| {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s.as_bytes());
|
||||
b.push(0);
|
||||
};
|
||||
push_str(&mut b, 0, "LINE");
|
||||
b.extend_from_slice(&10i16.to_le_bytes());
|
||||
b.extend_from_slice(&3.25f64.to_le_bytes());
|
||||
b.extend_from_slice(&62i16.to_le_bytes());
|
||||
b.extend_from_slice(&5i16.to_le_bytes());
|
||||
push_str(&mut b, 0, "EOF");
|
||||
let tags = parse(&b).unwrap();
|
||||
assert_eq!(tags[1], Tag::f(10, 3.25));
|
||||
assert_eq!(tags[2], Tag::i(62, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_inputs() {
|
||||
assert!(parse(b"").is_err());
|
||||
assert!(parse(b"abc\nxyz\n").is_err());
|
||||
assert!(parse(b"0\n").is_err());
|
||||
let mut b = BINARY_SENTINEL.to_vec();
|
||||
b.extend_from_slice(&10i16.to_le_bytes());
|
||||
b.extend_from_slice(&[1, 2, 3]);
|
||||
assert!(parse(&b).is_err());
|
||||
// Bad numbers become 0 rather than failing.
|
||||
let t = parse(b"10\nnotanumber\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].f64(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escapes_and_latin1() {
|
||||
let t = parse(b"1\nCaf\\U+00E9\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].str(), "Café");
|
||||
let t = parse(b"1\nCaf\xe9\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].str(), "Café");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surrogate_pair_escapes() {
|
||||
let t = parse(b"1\n\\U+D83D\\U+DE00 \\u+56fe\\U+53f7\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].str(), "😀 \\u+56fe号");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_escapes_stay_literal() {
|
||||
for s in [
|
||||
"\\U+",
|
||||
"\\U",
|
||||
"\\",
|
||||
"a\\U+ZZZZ",
|
||||
"\\U+12",
|
||||
"\\U+D800",
|
||||
"\\U+DC00",
|
||||
"\\U+D800\\U+0041",
|
||||
"\\U+D83D\\U+DE",
|
||||
"\\U++123",
|
||||
"\\U+-123",
|
||||
"x\\U+12\u{e9}4",
|
||||
] {
|
||||
let t = parse(format!("1\n{s}\n0\nEOF\n").as_bytes()).unwrap();
|
||||
let expect = if s == "\\U+D800\\U+0041" { "\\U+D800A" } else { s };
|
||||
assert_eq!(t[0].str(), expect, "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_non_ascii_as_escapes() {
|
||||
let tags = vec![Tag::s(1, "图号 İø 😀\r\nend"), Tag::s(1000, "Ø"), Tag::s(0, "EOF")];
|
||||
let text = write_ascii(&tags);
|
||||
assert!(text.is_ascii());
|
||||
assert!(text.contains("\\U+56FE\\U+53F7 \\U+00C4\\U+00B0\\U+00F8 \\U+D83D\\U+DE00 end\r\n"));
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back[0].str(), "图号 İø 😀 end");
|
||||
assert_eq!(back[1].str(), "Ø");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_strings_unescape() {
|
||||
let mut b = BINARY_SENTINEL.to_vec();
|
||||
b.extend_from_slice(&1i16.to_le_bytes());
|
||||
b.extend_from_slice(b"\\U+56FE\\U+53F7\0");
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF\0");
|
||||
assert_eq!(parse(&b).unwrap()[0].str(), "图号");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod limit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tag_limit_defaults_and_env_override() {
|
||||
assert_eq!(max_tags_from(None), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("0")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some("abc")), MAX_TAGS);
|
||||
assert_eq!(max_tags_from(Some(" 400_000_000 ")), 400_000_000);
|
||||
// Issue #1948: the default must admit a DXF rendition well above 50 million group codes.
|
||||
const { assert!(MAX_TAGS >= 100_000_000) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tag_list_is_reserved_once_and_a_refusal_is_an_error() {
|
||||
let tags = parse_ascii_counted("0\nSECTION\n0\nENDSEC\n0\nEOF\n", MAX_TAGS, 3).unwrap();
|
||||
assert_eq!((tags.len(), tags.capacity()), (3, 3));
|
||||
let e = parse_ascii_counted("0\nEOF\n", usize::MAX, usize::MAX).unwrap_err();
|
||||
assert!(matches!(e, DxfError::OutOfMemory { .. }), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
//! The DXF/DWG file versions CADCraft writes (DXF Reference, HEADER `$ACADVER`).
|
||||
|
||||
/// A file version CADCraft can save: the AutoCAD release that introduced the format. Each format
|
||||
/// also serves the releases after it (2018 is still AutoCAD's current format).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Version {
|
||||
/// AutoCAD 2000/2002 (AC1015).
|
||||
R2000,
|
||||
/// AutoCAD 2004–2006 (AC1018).
|
||||
R2004,
|
||||
/// AutoCAD 2007–2009 (AC1021): the first version whose DXF text is UTF-8.
|
||||
R2007,
|
||||
/// AutoCAD 2010–2012 (AC1024).
|
||||
R2010,
|
||||
/// AutoCAD 2013–2017 (AC1027).
|
||||
R2013,
|
||||
/// AutoCAD 2018 and later (AC1032), the default.
|
||||
#[default]
|
||||
R2018,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
/// Every version, oldest first.
|
||||
pub const ALL: [Version; 6] = [Version::R2000, Version::R2004, Version::R2007, Version::R2010, Version::R2013, Version::R2018];
|
||||
|
||||
/// The `$ACADVER` code ("AC1032").
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Version::R2000 => "AC1015",
|
||||
Version::R2004 => "AC1018",
|
||||
Version::R2007 => "AC1021",
|
||||
Version::R2010 => "AC1024",
|
||||
Version::R2013 => "AC1027",
|
||||
Version::R2018 => "AC1032",
|
||||
}
|
||||
}
|
||||
|
||||
/// The release year that names the version ("2018").
|
||||
pub fn year(self) -> &'static str {
|
||||
match self {
|
||||
Version::R2000 => "2000",
|
||||
Version::R2004 => "2004",
|
||||
Version::R2007 => "2007",
|
||||
Version::R2010 => "2010",
|
||||
Version::R2013 => "2013",
|
||||
Version::R2018 => "2018",
|
||||
}
|
||||
}
|
||||
|
||||
/// A version named by its year ("2018", "R2018") or its `$ACADVER` code ("AC1032"), any case.
|
||||
pub fn parse(s: &str) -> Option<Version> {
|
||||
let s = s.trim();
|
||||
let year = s.strip_prefix(['R', 'r']).unwrap_or(s);
|
||||
Version::ALL.into_iter().find(|v| v.year() == year || v.code().eq_ignore_ascii_case(s))
|
||||
}
|
||||
|
||||
/// The version to save a file of `$ACADVER` `code` in: the same version when CADCraft writes
|
||||
/// it, else the nearest one it does (R2000 for older files, 2018 for newer ones); `None` when
|
||||
/// the code isn't a version code at all.
|
||||
pub fn for_acadver(code: &str) -> Option<Version> {
|
||||
let code = code.trim();
|
||||
if let Some(v) = Version::ALL.into_iter().find(|v| v.code().eq_ignore_ascii_case(code)) {
|
||||
return Some(v);
|
||||
}
|
||||
code.get(..2).filter(|p| p.eq_ignore_ascii_case("AC"))?;
|
||||
let n = code.get(2..).filter(|d| d.bytes().all(|b| b.is_ascii_digit())).and_then(|d| d.parse::<u32>().ok())?;
|
||||
Some(match n {
|
||||
..1018 => Version::R2000,
|
||||
1018..1021 => Version::R2004,
|
||||
1021..1024 => Version::R2007,
|
||||
1024..1027 => Version::R2010,
|
||||
1027..1032 => Version::R2013,
|
||||
_ => Version::R2018,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether text in this version's DXF is UTF-8 (2007 and later). Older versions keep text in
|
||||
/// the `$DWGCODEPAGE` code page, which CADCraft avoids by escaping non-ASCII as `\U+XXXX`.
|
||||
pub fn utf8(self) -> bool {
|
||||
self >= Version::R2007
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Version {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.year())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_years_and_codes() {
|
||||
assert_eq!(Version::parse("2018"), Some(Version::R2018));
|
||||
assert_eq!(Version::parse(" r2004 "), Some(Version::R2004));
|
||||
assert_eq!(Version::parse("ac1021"), Some(Version::R2007));
|
||||
assert_eq!(Version::parse("2005"), None);
|
||||
assert_eq!(Version::parse(""), None);
|
||||
for v in Version::ALL {
|
||||
assert_eq!(Version::parse(v.code()), Some(v));
|
||||
assert_eq!(Version::parse(v.year()), Some(v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_versions_map_to_the_nearest_writable_one() {
|
||||
assert_eq!(Version::for_acadver("AC1009"), Some(Version::R2000));
|
||||
assert_eq!(Version::for_acadver("AC1014"), Some(Version::R2000));
|
||||
assert_eq!(Version::for_acadver("AC1024"), Some(Version::R2010));
|
||||
assert_eq!(Version::for_acadver("AC1035"), Some(Version::R2018));
|
||||
assert_eq!(Version::for_acadver("MC0.0"), None);
|
||||
assert_eq!(Version::for_acadver("AC"), None);
|
||||
assert_eq!(Version::for_acadver("AC99999999999999999999"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped.
|
||||
|
||||
const BODY: &str = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n";
|
||||
|
||||
#[test]
|
||||
fn utf8_bom_bytes_are_skipped() {
|
||||
let plain = cadcraft_dxf::parse(BODY.as_bytes()).expect("plain parses");
|
||||
let mut bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
bytes.extend_from_slice(BODY.as_bytes());
|
||||
let with_bom = cadcraft_dxf::parse(&bytes);
|
||||
assert!(with_bom.is_ok(), "{with_bom:?}");
|
||||
assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bom_text_is_skipped() {
|
||||
let text = format!("\u{feff}{BODY}");
|
||||
assert!(cadcraft_dxf::parse_ascii(&text).is_ok());
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! Binary DXF writing (#421): every value kind round-trips through `write_binary` and `parse`,
|
||||
//! and hostile binary input never panics.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, is_binary, parse, write_ascii_as, write_binary};
|
||||
|
||||
fn sample() -> Vec<Tag> {
|
||||
vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "TEXT"),
|
||||
Tag::s(5, "1F"),
|
||||
Tag::f(10, -3.25),
|
||||
Tag::i(62, 5),
|
||||
Tag::i(90, 70_000),
|
||||
Tag::i(160, 1 << 40),
|
||||
Tag::new(290, Value::Bool(true)),
|
||||
Tag::new(310, Value::Hex("00FFA5".into())),
|
||||
Tag::s(1, "图号 Café 😀"),
|
||||
Tag::i(1071, -2),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "EOF"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_roundtrips_every_value_kind() {
|
||||
for utf8 in [false, true] {
|
||||
let bytes = write_binary(&sample(), utf8);
|
||||
assert!(is_binary(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap(), sample(), "utf8 = {utf8}");
|
||||
}
|
||||
// Pre-2007 strings are escaped to ASCII, 2007+ ones are UTF-8.
|
||||
let old = write_binary(&sample(), false);
|
||||
assert!(old.windows(7).any(|w| w == b"\\U+56FE"));
|
||||
let new = write_binary(&sample(), true);
|
||||
assert!(new.windows("图号".len()).any(|w| w == "图号".as_bytes()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_converts_values_to_their_codes_kind() {
|
||||
// A number held as text at a numeric code, an out-of-range 16-bit value, a long chunk.
|
||||
let long = "AB".repeat(300);
|
||||
let tags = vec![Tag::s(70, "12"), Tag::i(62, 1 << 20), Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")];
|
||||
let back = parse(&write_binary(&tags, true)).unwrap();
|
||||
assert_eq!(back[0], Tag::i(70, 12));
|
||||
assert_eq!(back[1], Tag::i(62, i64::from(i16::MAX)));
|
||||
assert_eq!(back[2].code, 310);
|
||||
assert_eq!(back[3].code, 310, "300 bytes are split into chunks of at most 255");
|
||||
assert_eq!(back[2].str().len() + back[3].str().len(), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_ascii_keeps_text_and_drops_line_breaks() {
|
||||
let text = write_ascii_as(&[Tag::s(1, "图号\r\nÄ"), Tag::s(0, "EOF")], true);
|
||||
assert!(text.contains("图号 Ä\r\n"));
|
||||
assert_eq!(parse(text.as_bytes()).unwrap()[0].str(), "图号 Ä");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_binary_input_never_panics() {
|
||||
let good = write_binary(&sample(), true);
|
||||
// Every truncation, and every single-byte corruption of the body.
|
||||
for n in 0..good.len() {
|
||||
let _ = parse(good.get(..n).unwrap());
|
||||
}
|
||||
for i in 22..good.len() {
|
||||
for b in [0u8, 0x7f, 0xff] {
|
||||
let mut bad = good.clone();
|
||||
bad[i] = b;
|
||||
let _ = parse(&bad);
|
||||
}
|
||||
}
|
||||
// A sentinel and nothing else is not a drawing.
|
||||
assert!(parse(b"AutoCAD Binary DXF\r\n\x1a\0").is_err());
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Pre-R2007 DXF text is written in the `$DWGCODEPAGE` code page: characters it has as their
|
||||
//! bytes, the others as `\U+XXXX` escapes; every form reads back to the same text.
|
||||
|
||||
use cadcraft_dxf::{Tag, parse, write_ascii, write_ascii_in, write_binary_in};
|
||||
|
||||
fn file(code_page: &str, text: &str) -> Vec<Tag> {
|
||||
vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "HEADER"),
|
||||
Tag::s(9, "$ACADVER"),
|
||||
Tag::s(1, "AC1015"),
|
||||
Tag::s(9, "$DWGCODEPAGE"),
|
||||
Tag::s(3, code_page),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
Tag::s(0, "TEXT"),
|
||||
Tag::s(1, text),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "EOF"),
|
||||
]
|
||||
}
|
||||
|
||||
fn contains(bytes: &[u8], part: &[u8]) -> bool {
|
||||
bytes.windows(part.len()).any(|w| w == part)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_written_in_the_code_page() {
|
||||
// (code page, text, the bytes written for it)
|
||||
let cases: [(&str, &str, &[u8]); 6] = [
|
||||
("ANSI_1252", "Ação € 图", b"A\xE7\xE3o \x80 \\U+56FE"),
|
||||
("ANSI_1251", "Слой é", b"\xD1\xEB\xEE\xE9 \\U+00E9"),
|
||||
("ANSI_932", "表図面 é", b"\x95\x5C\x90\x7D\x96\xCA \\U+00E9"),
|
||||
("DOS850", "Größe €", b"Gr\x94\xE1e \\U+20AC"),
|
||||
("ANSI_1252", "😀", b"\\U+D83D\\U+DE00"),
|
||||
// A code page CADCraft doesn't know: everything non-ASCII is escaped.
|
||||
("ANSI_1361", "Ação", b"A\\U+00E7\\U+00E3o"),
|
||||
];
|
||||
for (cp, text, raw) in cases {
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, raw), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a known code page, and for `ASCII`, `UTF8` and `UNICODE` headers, the output is what
|
||||
/// `write_ascii` writes (pure ASCII with escapes), line breaks included.
|
||||
#[test]
|
||||
fn unknown_ascii_and_utf8_code_pages_are_pure_ascii() {
|
||||
for cp in ["EBCDIC", "ASCII", "ascii ", "UTF8", "UNICODE"] {
|
||||
let tags = file(cp, "Ação\r\nfim");
|
||||
let bytes = write_ascii_in(&tags, cp);
|
||||
assert!(bytes.is_ascii(), "{cp}");
|
||||
assert_eq!(bytes, write_ascii(&tags).into_bytes(), "{cp}");
|
||||
assert!(contains(&bytes, b"A\\U+00E7\\U+00E3o fim"), "{cp}");
|
||||
assert!(write_binary_in(&tags, cp).iter().all(u8::is_ascii), "{cp} binary");
|
||||
}
|
||||
}
|
||||
|
||||
/// Code page bytes that happen to form valid UTF-8 (`СІ` in ANSI_1251 is D1 B2, which is U+0472
|
||||
/// in UTF-8) would be read back as UTF-8 from an ASCII file, so that file is written with
|
||||
/// escapes. Binary DXF reads strings by the header's code page and keeps the bytes.
|
||||
#[test]
|
||||
fn code_page_bytes_that_are_valid_utf8_are_escaped_in_ascii() {
|
||||
let tags = file("ANSI_1251", "СІ");
|
||||
let ascii = write_ascii_in(&tags, "ANSI_1251");
|
||||
assert!(ascii.is_ascii(), "{:?}", String::from_utf8_lossy(&ascii));
|
||||
assert!(contains(&ascii, b"\\U+0421\\U+0406"));
|
||||
assert_eq!(parse(&ascii).unwrap()[10].str(), "СІ");
|
||||
let binary = write_binary_in(&tags, "ANSI_1251");
|
||||
assert!(contains(&binary, b"\xD1\xB2"));
|
||||
assert_eq!(parse(&binary).unwrap()[10].str(), "СІ");
|
||||
// Bytes that are not valid UTF-8 stay as they are.
|
||||
assert!(contains(&write_ascii_in(&file("ANSI_1251", "Слой"), "ANSI_1251"), b"\xD1\xEB\xEE\xE9"));
|
||||
}
|
||||
|
||||
/// A literal `\U+` or `\M+` in the text (#2050) reads back as written alongside code page bytes,
|
||||
/// in ASCII and binary, including an ASCII file that falls back to escapes because its bytes
|
||||
/// would read as UTF-8.
|
||||
#[test]
|
||||
fn literal_escapes_survive_code_page_text() {
|
||||
for (cp, text) in
|
||||
[("ANSI_1252", "Ação \\U+00E9 \\M+183A4F"), ("ANSI_1251", "Слой \\U+0041"), ("ANSI_1251", "СІ \\U+0041"), ("ANSI_932", "表 \\M+1")]
|
||||
{
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, b"\\U+005C"), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-byte code pages whose trail byte can be 0x5C (`\\`): Shift-JIS `表` (95 5C) and `ソ`
|
||||
/// (83 5C), Big5 `許` (B3 5C), GBK `乗` (81 5C). Right before `U+` or `M+`, such a character is
|
||||
/// written as an escape, so even a reader that looks for escapes before it decodes the code page
|
||||
/// reads it right. Elsewhere it stays as its bytes. Both forms read back as written.
|
||||
#[test]
|
||||
fn a_trail_byte_backslash_never_starts_an_escape() {
|
||||
let cases: [(&str, &str, &[u8], &[u8]); 4] = [
|
||||
("ANSI_932", "表U+0041 表示", b"\\U+8868U+0041 \x95\x5C\x8E\xA6", b"\x95\x5CU+"),
|
||||
("ANSI_932", "ソM+1A2B", b"\\U+30BDM+1A2B", b"\x83\x5CM+"),
|
||||
("ANSI_950", "許U+0041", b"\\U+8A31U+0041", b"\xB3\x5CU+"),
|
||||
("ANSI_936", "乗U+0041", b"\\U+4E57U+0041", b"\x81\x5CU+"),
|
||||
];
|
||||
for (cp, text, written, never) in cases {
|
||||
let tags = file(cp, text);
|
||||
for (bytes, what) in [(write_ascii_in(&tags, cp), "ASCII"), (write_binary_in(&tags, cp), "binary")] {
|
||||
assert!(contains(&bytes, written), "{cp} {what}: {:?}", String::from_utf8_lossy(&bytes));
|
||||
assert!(!contains(&bytes, never), "{cp} {what}: a trail byte starts an escape");
|
||||
assert_eq!(parse(&bytes).unwrap()[10].str(), text, "{cp} {what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//! Small independent byte fixtures for DXF tag types, text encoding, framing and failures.
|
||||
//! Handwritten examples, not files from a CAD application or generated by our writer.
|
||||
//! Background on the tag model and file structure:
|
||||
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/dxftags.html
|
||||
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/filestructure.html
|
||||
//! ASCII spacing is our writer's documented convention; binary fixtures specify actual bytes.
|
||||
|
||||
use cadcraft_dxf::{DxfError, Tag, Value, parse, parse_with_limit, write_ascii_as, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
|
||||
fn fixtures() -> Vec<(Tag, &'static [u8], &'static [u8])> {
|
||||
vec![
|
||||
(Tag::s(1, " A "), b" 1\r\n A \r\n", b"\x01\0 A \0"),
|
||||
(Tag::s(1, ""), b" 1\r\n\r\n", b"\x01\0\0"),
|
||||
(Tag::s(105, "AB12"), b"105\r\nAB12\r\n", b"\x69\0AB12\0"),
|
||||
(Tag::new(10, Value::Real(-3.25)), b" 10\r\n-3.25\r\n", b"\x0a\0\0\0\0\0\0\0\x0a\xc0"),
|
||||
(Tag::i(70, -32768), b" 70\r\n-32768\r\n", b"\x46\0\0\x80"),
|
||||
(Tag::i(90, -2147483648), b" 90\r\n-2147483648\r\n", b"\x5a\0\0\0\0\x80"),
|
||||
(Tag::i(160, 9223372036854775807), b"160\r\n9223372036854775807\r\n", b"\xa0\0\xff\xff\xff\xff\xff\xff\xff\x7f"),
|
||||
(Tag::new(290, Value::Bool(false)), b"290\r\n0\r\n", b"\x22\x01\0"),
|
||||
(Tag::new(299, Value::Bool(true)), b"299\r\n1\r\n", b"\x2b\x01\x01"),
|
||||
(Tag::new(310, Value::Hex("00FF80".into())), b"310\r\n00FF80\r\n", b"\x36\x01\x03\0\xff\x80"),
|
||||
(Tag::new(1004, Value::Hex(String::new())), b"1004\r\n\r\n", b"\xec\x03\0"),
|
||||
(Tag::i(1070, -2), b"1070\r\n-2\r\n", b"\x2e\x04\xfe\xff"),
|
||||
(Tag::i(1071, 65536), b"1071\r\n65536\r\n", b"\x2f\x04\0\0\x01\0"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_independently_authored_ascii_and_binary_values() {
|
||||
for (expected, ascii, binary) in fixtures() {
|
||||
assert_eq!(parse(ascii).unwrap(), std::slice::from_ref(&expected), "ASCII code {}", expected.code);
|
||||
let bytes = [SENTINEL, binary].concat();
|
||||
assert_eq!(parse(&bytes).unwrap(), std::slice::from_ref(&expected), "binary code {}", expected.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_independently_specified_ascii_and_binary_bytes() {
|
||||
for (tag, ascii, binary) in fixtures() {
|
||||
for utf8 in [false, true] {
|
||||
let tags = std::slice::from_ref(&tag);
|
||||
assert_eq!(write_ascii_as(tags, utf8).as_bytes(), ascii, "ASCII code {}", tag.code);
|
||||
assert_eq!(write_binary(tags, utf8), [SENTINEL, binary].concat(), "binary code {}", tag.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_code_ranges_have_independent_binary_widths() {
|
||||
// Specification ranges and widths, never kind_of(): a shared reader/writer mistake
|
||||
// can round-trip every boundary value while still using the wrong number of bytes.
|
||||
let widths = [
|
||||
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], 2, i64::from(i16::MIN), i64::from(i16::MAX)),
|
||||
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], 4, i64::from(i32::MIN), i64::from(i32::MAX)),
|
||||
(std::iter::once(160..=169).collect(), 8, i64::MIN, i64::MAX),
|
||||
];
|
||||
for (ranges, width, min, max) in widths {
|
||||
for code in ranges.into_iter().flatten() {
|
||||
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
|
||||
// Binary DXF uses a little-endian 16-bit group code and a fixed-width,
|
||||
// little-endian two's-complement integer. Encoding is version-independent.
|
||||
let code_bytes = u16::try_from(code).unwrap().to_le_bytes();
|
||||
let value_bytes = value.to_le_bytes();
|
||||
let expected = [SENTINEL, &code_bytes, &value_bytes[..width]].concat();
|
||||
let tags = [Tag::i(code, value)];
|
||||
for utf8 in [false, true] {
|
||||
assert_eq!(write_binary(&tags, utf8), expected, "code={code} value={value} utf8={utf8}");
|
||||
}
|
||||
assert_eq!(parse(&expected).unwrap(), tags, "code={code} value={value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_encoding_has_independent_wire_expectations() {
|
||||
let tags = [Tag::s(1, "é😀")];
|
||||
let escaped = b"\\U+00E9\\U+D83D\\U+DE00";
|
||||
let utf8 = b"\xc3\xa9\xf0\x9f\x98\x80";
|
||||
for (unicode, text) in [(false, escaped.as_slice()), (true, utf8.as_slice())] {
|
||||
let ascii = [b" 1\r\n".as_slice(), text, b"\r\n"].concat();
|
||||
let binary = [SENTINEL, b"\x01\0", text, b"\0"].concat();
|
||||
assert_eq!(write_ascii_as(&tags, unicode).as_bytes(), ascii, "utf8={unicode}");
|
||||
assert_eq!(write_binary(&tags, unicode), binary, "utf8={unicode}");
|
||||
assert_eq!(parse(&ascii).unwrap(), tags);
|
||||
assert_eq!(parse(&binary).unwrap(), tags);
|
||||
}
|
||||
// Pre-2007 code-page bytes are independent of the writer, which emits Unicode escapes.
|
||||
let ascii = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1252\n0\nENDSEC\n1\n\x80\xe9\n0\nEOF\n";
|
||||
let binary = [
|
||||
SENTINEL,
|
||||
b"\0\0SECTION\0\x02\0HEADER\0\x09\0$ACADVER\0\x01\0AC1015\0\x09\0$DWGCODEPAGE\0\x03\0ANSI_1252\0\0\0ENDSEC\0\x01\0\x80\xe9\0\0\0EOF\0",
|
||||
]
|
||||
.concat();
|
||||
for bytes in [ascii.as_slice(), binary.as_slice()] {
|
||||
let tags = parse(bytes).unwrap();
|
||||
assert_eq!(tags[7], Tag::s(1, "ێ"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_accepts_line_endings_padding_and_no_final_newline() {
|
||||
let expected = [Tag::i(70, -7), Tag::s(1, " text "), Tag::s(0, "EOF")];
|
||||
for newline in ["\n", "\r\n"] {
|
||||
for final_newline in [false, true] {
|
||||
let mut text = [" 70 ", " -7 ", "1", " text ", "0", "EOF"].join(newline);
|
||||
if final_newline {
|
||||
text.push_str(newline);
|
||||
}
|
||||
assert_eq!(parse(text.as_bytes()).unwrap(), expected, "newline={newline:?} final={final_newline}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_truncated_values_are_errors_not_panics() {
|
||||
assert!(matches!(parse(b""), Err(DxfError::NotDxf)));
|
||||
assert!(matches!(parse(b"oops\nvalue\n"), Err(DxfError::BadCode { line: 1, .. })));
|
||||
assert!(matches!(parse(b"10\n"), Err(DxfError::Eof)));
|
||||
assert!(matches!(parse(SENTINEL), Err(DxfError::NotDxf)));
|
||||
for (_, _, binary) in fixtures() {
|
||||
// Every cut inside the first record is an error, unlike cuts between complete records:
|
||||
// the tag API deliberately accepts streams without an EOF record.
|
||||
for cut in 1..binary.len() {
|
||||
let bytes = [SENTINEL, &binary[..cut]].concat();
|
||||
assert!(matches!(parse(&bytes), Err(DxfError::Eof)), "binary={binary:?} cut={cut}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_limit_accepts_the_exact_boundary_and_rejects_one_less() {
|
||||
let ascii = b"1\nA\n70\n-7\n0\nEOF\n";
|
||||
let binary = [SENTINEL, b"\x01\0A\0\x46\0\xf9\xff\0\0EOF\0"].concat();
|
||||
for bytes in [ascii.as_slice(), binary.as_slice()] {
|
||||
for limit in 0..3 {
|
||||
assert!(matches!(parse_with_limit(bytes, limit), Err(DxfError::TooLarge { limit: actual, .. }) if actual == limit));
|
||||
}
|
||||
for limit in [3, 4] {
|
||||
assert_eq!(parse_with_limit(bytes, limit).unwrap(), [Tag::s(1, "A"), Tag::i(70, -7), Tag::s(0, "EOF")]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
//! Hostile-input hardening of the DXF reader and writer (#1025): one regression test per finding.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, parse, parse_with_limit, sections, write_ascii, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
|
||||
fn bin_with(f: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
|
||||
let mut b = SENTINEL.to_vec();
|
||||
f(&mut b);
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF\0");
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_reals_that_are_not_finite_become_zero() {
|
||||
for bits in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let b = bin_with(|b| {
|
||||
b.extend_from_slice(&10i16.to_le_bytes());
|
||||
b.extend_from_slice(&bits.to_le_bytes());
|
||||
});
|
||||
let tags = parse(&b).unwrap();
|
||||
assert_eq!(tags[0], Tag::f(10, 0.0), "{bits}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_accepts_bom_and_cr_only_line_ends() {
|
||||
let want = vec![Tag::s(0, "SECTION"), Tag::s(2, "ENTITIES"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")];
|
||||
let text = "0\rSECTION\r2\rENTITIES\r0\rENDSEC\r0\rEOF\r";
|
||||
assert_eq!(parse(text.as_bytes()).unwrap(), want);
|
||||
let mut bom = b"\xEF\xBB\xBF".to_vec();
|
||||
bom.extend_from_slice(text.replace('\r', "\r\n").as_bytes());
|
||||
assert_eq!(parse(&bom).unwrap(), want);
|
||||
// Mixed terminators.
|
||||
assert_eq!(parse(b"0\r\nSECTION\r2\nENTITIES\n0\rENDSEC\r\n0\nEOF").unwrap(), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cr_only_files_count_against_the_tag_limit() {
|
||||
let mut text = String::new();
|
||||
for _ in 0..50 {
|
||||
text.push_str("999\rcomment\r");
|
||||
}
|
||||
text.push_str("0\rEOF\r");
|
||||
assert!(parse_with_limit(text.as_bytes(), 10).is_err());
|
||||
assert_eq!(parse_with_limit(text.as_bytes(), 100).unwrap().len(), 51);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bad_byte_spoils_one_line_not_the_file() {
|
||||
// Header says ANSI_1251; one line is UTF-8, one is Windows-1251, one has a stray byte.
|
||||
let mut b = b"9\n$DWGCODEPAGE\n3\nANSI_1251\n1\n".to_vec();
|
||||
b.extend_from_slice("Größe".as_bytes());
|
||||
b.extend_from_slice(b"\n1\n\xCF\xF0\xE8\xE2\xE5\xF2\n0\nEOF\n");
|
||||
let t = parse(&b).unwrap();
|
||||
assert_eq!(t[1].str(), "ANSI_1251");
|
||||
assert_eq!(t[2].str(), "Größe");
|
||||
assert_eq!(t[3].str(), "Привет");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_uses_utf8_per_line_and_binary_stays_in_the_code_page() {
|
||||
// ANSI_1252 file with UTF-8 bytes in one string and a Latin-1 byte in another. The ASCII
|
||||
// reader takes a line that is valid UTF-8 as UTF-8; the binary reader reads every string
|
||||
// from the code page, because a pre-R2007 binary file has no way to escape to UTF-8.
|
||||
let utf8 = "Größe".as_bytes();
|
||||
let latin = b"Caf\xE9";
|
||||
let mut a = b"9\n$DWGCODEPAGE\n3\nANSI_1252\n1\n".to_vec();
|
||||
a.extend_from_slice(utf8);
|
||||
a.extend_from_slice(b"\n1\n");
|
||||
a.extend_from_slice(latin);
|
||||
a.extend_from_slice(b"\n0\nEOF\n");
|
||||
let from_ascii = parse(&a).unwrap();
|
||||
let b = bin_with(|b| {
|
||||
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, b"ANSI_1252"), (1, utf8), (1, latin)] {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s);
|
||||
b.push(0);
|
||||
}
|
||||
});
|
||||
let from_binary = parse(&b).unwrap();
|
||||
assert_eq!(from_ascii[2].str(), "Größe");
|
||||
assert_eq!(from_ascii[3].str(), "Café");
|
||||
assert_eq!(from_binary[2].str(), "Größe");
|
||||
assert_eq!(from_binary[3].str(), "Café");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_spaces_keep_sections_and_eof() {
|
||||
let t = parse(b"0\nSECTION \n2\nENTITIES \n0\nLINE\n0\nENDSEC \n0\nEOF \n0\nSECTION\n2\nX\n0\nENDSEC\n").unwrap();
|
||||
// The EOF with a trailing space still ends the file.
|
||||
assert_eq!(t.len(), 5);
|
||||
let s = sections(&t);
|
||||
assert_eq!(s.len(), 1);
|
||||
assert_eq!(s[0].name, "ENTITIES");
|
||||
assert_eq!(s[0].tags.len(), 1);
|
||||
// Binary EOF too.
|
||||
let mut b = SENTINEL.to_vec();
|
||||
b.extend_from_slice(&0i16.to_le_bytes());
|
||||
b.extend_from_slice(b"EOF \0");
|
||||
b.extend_from_slice(&1i16.to_le_bytes());
|
||||
b.extend_from_slice(b"after\0");
|
||||
assert_eq!(parse(&b).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_groups_reject_non_finite_and_clamp() {
|
||||
let t = parse(b"70\ninf\n70\nNaN\n70\n1e30\n70\n-1e30\n90\n1e30\n160\n1e30\n160\n-inf\n62\n12.0\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0], Tag::i(70, 0));
|
||||
assert_eq!(t[1], Tag::i(70, 0));
|
||||
// Clamped to what the group's width holds, unsigned values included (the binary writer
|
||||
// keeps up to 65535 and 4294967295 as their bit patterns).
|
||||
assert_eq!(t[2], Tag::i(70, i64::from(u16::MAX)));
|
||||
assert_eq!(t[3], Tag::i(70, i64::from(i16::MIN)));
|
||||
assert_eq!(t[4], Tag::i(90, i64::from(u32::MAX)));
|
||||
assert_eq!(t[5], Tag::i(160, i64::MAX));
|
||||
assert_eq!(t[6], Tag::i(160, 0));
|
||||
assert_eq!(t[7], Tag::i(62, 12));
|
||||
assert_eq!(Value::Str("inf".into()).as_i64(), 0);
|
||||
}
|
||||
|
||||
/// An unsigned value spelled as a float reads as the same spelled as an integer (#1026 review):
|
||||
/// `65535.0` is 65535 like `65535`, not 32767.
|
||||
#[test]
|
||||
fn unsigned_values_read_the_same_in_either_spelling() {
|
||||
let t = parse(b"70\n65535.0\n70\n65535\n90\n4294967295.0\n90\n4294967295\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0], t[1]);
|
||||
assert_eq!(t[0], Tag::i(70, 65535));
|
||||
assert_eq!(t[2], t[3]);
|
||||
assert_eq!(t[2], Tag::i(90, 4_294_967_295));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_groups_pad_odd_digits_and_drop_junk() {
|
||||
let t = parse(b"310\nABC\n310\nA-B zC1\n310\n\n1004\n0aF\n0\nEOF\n").unwrap();
|
||||
assert_eq!(t[0].value, Value::Hex("ABC0".into()));
|
||||
assert_eq!(t[1].value, Value::Hex("ABC1".into()));
|
||||
assert_eq!(t[2].value, Value::Hex(String::new()));
|
||||
assert_eq!(t[3].value, Value::Hex("0aF0".into()));
|
||||
// The binary writer agrees with the reader: the odd digit is padded, nothing shifts.
|
||||
let back = parse(&write_binary(&[Tag::new(310, Value::Hex("ABC".into())), Tag::s(0, "EOF")], true)).unwrap();
|
||||
assert_eq!(back[0].value, Value::Hex("ABC0".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_hex_cannot_inject_tags_and_is_split_at_254_digits() {
|
||||
let evil = Tag::new(310, Value::Hex("AB\r\n0\r\nEOF".into()));
|
||||
let text = write_ascii(&[evil, Tag::s(0, "EOF")]);
|
||||
let back = parse(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.len(), 2, "{text:?}");
|
||||
assert_eq!(back[0].value, Value::Hex("AB0EF0".into()));
|
||||
let long = "AB".repeat(300);
|
||||
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(long)), Tag::s(0, "EOF")]).as_bytes()).unwrap();
|
||||
let sizes: Vec<usize> = back.iter().filter(|t| t.code == 310).map(|t| t.str().len()).collect();
|
||||
assert_eq!(sizes, vec![254, 254, 92]);
|
||||
// An empty chunk is still one group.
|
||||
let back = parse(write_ascii(&[Tag::new(310, Value::Hex(String::new())), Tag::s(0, "EOF")]).as_bytes()).unwrap();
|
||||
assert_eq!(back[0].code, 310);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_sixteen_and_thirty_two_bit_values_wrap_in_range_and_clamp_outside() {
|
||||
let tags = [
|
||||
Tag::i(70, 65535),
|
||||
Tag::i(70, 40000),
|
||||
Tag::i(70, 70000),
|
||||
Tag::i(70, -40000),
|
||||
Tag::i(90, 0xFFFF_FFFF),
|
||||
Tag::i(90, 1 << 40),
|
||||
Tag::i(90, -(1 << 40)),
|
||||
Tag::s(0, "EOF"),
|
||||
];
|
||||
let back = parse(&write_binary(&tags, true)).unwrap();
|
||||
assert_eq!(back[0], Tag::i(70, -1));
|
||||
assert_eq!(back[1], Tag::i(70, 40000 - 65536));
|
||||
assert_eq!(back[2], Tag::i(70, i64::from(i16::MAX)));
|
||||
assert_eq!(back[3], Tag::i(70, i64::from(i16::MIN)));
|
||||
assert_eq!(back[4], Tag::i(90, -1));
|
||||
assert_eq!(back[5], Tag::i(90, i64::from(i32::MAX)));
|
||||
assert_eq!(back[6], Tag::i(90, i64::from(i32::MIN)));
|
||||
}
|
||||
|
||||
/// Strings of a `$DWGCODEPAGE`-declared file as ASCII text and as a binary file.
|
||||
fn both_forms(code_page: &str, strings: &[&[u8]]) -> [Vec<Tag>; 2] {
|
||||
let mut a = format!("9\n$DWGCODEPAGE\n3\n{code_page}\n").into_bytes();
|
||||
for s in strings {
|
||||
a.extend_from_slice(b"1\n");
|
||||
a.extend_from_slice(s);
|
||||
a.push(b'\n');
|
||||
}
|
||||
a.extend_from_slice(b"0\nEOF\n");
|
||||
let b = bin_with(|b| {
|
||||
for (code, s) in [(9i16, &b"$DWGCODEPAGE"[..]), (3, code_page.as_bytes())].into_iter().chain(strings.iter().map(|s| (1, *s))) {
|
||||
b.extend_from_slice(&code.to_le_bytes());
|
||||
b.extend_from_slice(s);
|
||||
b.push(0);
|
||||
}
|
||||
});
|
||||
[parse(&a).unwrap(), parse(&b).unwrap()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_short_strings_that_look_like_utf8_stay_in_the_code_page() {
|
||||
// C3 B1 is valid UTF-8 ("ñ") but is also two half-width katakana in Shift-JIS; the second
|
||||
// string is plainly Shift-JIS, so the file is not UTF-8.
|
||||
for t in both_forms("ANSI_932", &[b"\xC3\xB1", b"\x90\x7D\x96\xCA"]) {
|
||||
assert_eq!(t[2].str(), "\u{FF83}\u{FF71}");
|
||||
assert_eq!(t[3].str(), "図面");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_file_that_is_all_utf8_reads_as_utf8() {
|
||||
for t in both_forms("ANSI_932", &["図面".as_bytes(), b"\xC3\xB1", b"plain"]) {
|
||||
assert_eq!(t[2].str(), "図面");
|
||||
assert_eq!(t[3].str(), "ñ");
|
||||
assert_eq!(t[4].str(), "plain");
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
//! Hex payload conformance is distinct from binary framing capacity: DXF groups 310–319 and
|
||||
//! 1004 specify at most 127 payload bytes per chunk, even though a length byte represents 255.
|
||||
//! Inspect writer bytes without our parser; accept any compliant partition preserving payload,
|
||||
//! code and following-record framing. Oversized binary read fixtures test tolerance only.
|
||||
//! Pending writer regressions extend the chunk-size finding in issue #1025.
|
||||
//! Public DXF Reference, Group Codes in Numerical Order and Binary DXF Files:
|
||||
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-3F0380A5-1C15-464D-BC66-2C5F094BCFB9.htm
|
||||
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-FC1C3C69-DBC2-49E4-893A-000D6538C0FE.htm
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, parse, write_ascii_as, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
const FOLLOWING_BINARY: &[u8] = b"\x46\0\xf9\xff"; // group 70, signed i16 -7
|
||||
|
||||
fn payload(length: usize) -> Vec<u8> {
|
||||
(0..length).map(|index| u8::try_from(index * 37 % 256).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02X}")).collect()
|
||||
}
|
||||
|
||||
fn input(code: i32, bytes: &[u8]) -> [Tag; 2] {
|
||||
[Tag::new(code, Value::Hex(hex(bytes))), Tag::i(70, -7)]
|
||||
}
|
||||
|
||||
fn check_ascii(code: i32, bytes: &[u8], utf8: bool) {
|
||||
let wire = write_ascii_as(&input(code, bytes), utf8);
|
||||
let lines: Vec<_> = wire.lines().collect();
|
||||
let (chunks, following) = lines.split_at(lines.len().checked_sub(2).unwrap());
|
||||
assert_eq!(following[0].trim(), "70");
|
||||
assert_eq!(following[1], "-7");
|
||||
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
|
||||
let (records, remainder) = chunks.as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "incomplete group/value pair");
|
||||
let context = format!("ASCII code={code} bytes={} utf8={utf8}", bytes.len());
|
||||
let mut back = Vec::new();
|
||||
for record in records {
|
||||
assert_eq!(record[0].trim().parse::<i32>().unwrap(), code, "{context}");
|
||||
let text = record[1];
|
||||
assert!(text.len() <= 254, "{context}: {} hex characters exceed 127 payload bytes", text.len());
|
||||
assert!(text.bytes().all(|b| b.is_ascii_hexdigit()), "{context}: invalid hex");
|
||||
let (pairs, remainder) = text.as_bytes().as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "{context}: partial byte");
|
||||
back.extend(pairs.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()));
|
||||
}
|
||||
assert_eq!(back, bytes, "{context}: complete payload and byte order");
|
||||
}
|
||||
|
||||
fn check_binary(code: i32, bytes: &[u8], utf8: bool) {
|
||||
let wire = write_binary(&input(code, bytes), utf8);
|
||||
let mut chunks = wire.strip_prefix(SENTINEL).unwrap().strip_suffix(FOLLOWING_BINARY).unwrap();
|
||||
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
|
||||
let context = format!("binary code={code} bytes={} utf8={utf8}", bytes.len());
|
||||
let mut back = Vec::new();
|
||||
while !chunks.is_empty() {
|
||||
let (&[lo, hi, length], rest) = chunks.split_first_chunk::<3>().unwrap();
|
||||
assert_eq!(i32::from(u16::from_le_bytes([lo, hi])), code, "{context}");
|
||||
assert!(length <= 127, "{context}: {length} bytes exceed the documented group limit");
|
||||
let (chunk, next) = rest.split_at(usize::from(length));
|
||||
back.extend_from_slice(chunk);
|
||||
chunks = next;
|
||||
}
|
||||
assert_eq!(back, bytes, "{context}: complete payload and byte order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conforming_payloads_have_independent_ascii_and_binary_wire_checks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [0, 1, 2, 126, 127] {
|
||||
for utf8 in [false, true] {
|
||||
let bytes = payload(length);
|
||||
check_ascii(code, &bytes, utf8);
|
||||
check_binary(code, &bytes, utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_reader_tolerates_the_full_length_byte_domain() {
|
||||
// This is deliberately permissive input, not an assertion that oversized chunks conform
|
||||
// or that the writer should produce them. The following record catches framing mistakes.
|
||||
for code in (310_u16..=319).chain([1004]) {
|
||||
for length in 0..=u8::MAX {
|
||||
let bytes = payload(usize::from(length));
|
||||
let mut wire = SENTINEL.to_vec();
|
||||
wire.extend_from_slice(&code.to_le_bytes());
|
||||
wire.push(length);
|
||||
wire.extend_from_slice(&bytes);
|
||||
wire.extend_from_slice(FOLLOWING_BINARY);
|
||||
assert_eq!(parse(&wire).unwrap(), input(i32::from(code), &bytes), "code={code} length={length}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed ASCII DXF writer nonconformance: hex payloads above 127 bytes are emitted as one oversized chunk"]
|
||||
fn ascii_writer_splits_large_payloads_into_conforming_chunks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [128, 254, 255, 256, 1024] {
|
||||
for utf8 in [false, true] {
|
||||
check_ascii(code, &payload(length), utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed binary DXF writer nonconformance: 255-byte partitioning exceeds the documented 127-byte group limit"]
|
||||
fn binary_writer_splits_large_payloads_into_conforming_chunks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [128, 254, 255, 256, 1024] {
|
||||
for utf8 in [false, true] {
|
||||
check_binary(code, &payload(length), utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
//! Deterministic tag preservation through the public byte API, in both encodings and every
|
||||
//! supported version. Inputs have the group code's type and representable values; lossy
|
||||
//! coercions are not accepted as round-trip equality. Real values must retain their bits.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, Version, is_binary, parse, write_ascii_as, write_binary};
|
||||
|
||||
fn check(case: &str, body: &[Tag]) {
|
||||
for version in Version::ALL {
|
||||
let mut original = vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "HEADER"),
|
||||
Tag::s(9, "$ACADVER"),
|
||||
Tag::s(1, version.code()),
|
||||
Tag::s(9, "$DWGCODEPAGE"),
|
||||
Tag::s(3, "ANSI_1252"),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
];
|
||||
original.extend_from_slice(body);
|
||||
original.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
|
||||
for binary in [false, true] {
|
||||
let mut current = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{case} · {version} · binary={binary} · pass={pass}");
|
||||
let bytes = if binary { write_binary(¤t, version.utf8()) } else { write_ascii_as(¤t, version.utf8()).into_bytes() };
|
||||
assert_eq!(is_binary(&bytes), binary, "{context}");
|
||||
let back = parse(&bytes).unwrap_or_else(|e| panic!("{context}: {e}"));
|
||||
assert_eq!(back.len(), original.len(), "{context}: tag count");
|
||||
for (index, (expected, actual)) in original.iter().zip(&back).enumerate() {
|
||||
assert_eq!(actual, expected, "{context}: tag[{index}]");
|
||||
if let (Value::Real(a), Value::Real(b)) = (&expected.value, &actual.value) {
|
||||
assert_eq!(b.to_bits(), a.to_bits(), "{context}: tag[{index}] real bits");
|
||||
}
|
||||
}
|
||||
assert_eq!(back, current, "{context}: no additional changes on resave");
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_width_boundaries() {
|
||||
// Explicit reference ranges, not obtained from the codec's kind_of().
|
||||
let widths = [
|
||||
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], i64::from(i16::MIN), i64::from(i16::MAX)),
|
||||
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], i64::from(i32::MIN), i64::from(i32::MAX)),
|
||||
(std::iter::once(160..=169).collect(), i64::MIN, i64::MAX),
|
||||
];
|
||||
for (ranges, min, max) in widths {
|
||||
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
|
||||
let tags: Vec<_> = ranges.iter().cloned().flatten().map(|code| Tag::i(code, value)).collect();
|
||||
check(&format!("integer/{min}..{max}/{value}"), &tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_reals_keep_all_bits() {
|
||||
let values = [
|
||||
0.0,
|
||||
-0.0,
|
||||
f64::from_bits(1),
|
||||
-f64::from_bits(1),
|
||||
f64::MIN_POSITIVE,
|
||||
f64::MIN_POSITIVE.next_down(),
|
||||
0.1,
|
||||
-3.25,
|
||||
1.0_f64.next_down(),
|
||||
1.0,
|
||||
1.0_f64.next_up(),
|
||||
1e-100,
|
||||
-1e100,
|
||||
f64::MAX,
|
||||
f64::MIN,
|
||||
];
|
||||
for value in values {
|
||||
let tags: Vec<_> =
|
||||
[10..=59, 110..=149, 210..=239, 460..=469, 1010..=1059].into_iter().flatten().map(|code| Tag::new(code, Value::Real(value))).collect();
|
||||
check(&format!("real/{:016X}", value.to_bits()), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans() {
|
||||
for value in [false, true] {
|
||||
let tags: Vec<_> = (290..=299).map(|code| Tag::new(code, Value::Bool(value))).collect();
|
||||
check(&format!("bool/{value}"), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_preserve_whitespace_unicode_and_literal_escapes() {
|
||||
// No CR/LF or NUL: those are line/string delimiters, not losslessly writable payloads.
|
||||
let values = [
|
||||
("empty", ""),
|
||||
("spaces", " leading and trailing "),
|
||||
("tabs", "\tindented\t"),
|
||||
("unicode", "Café Ø 图号 Україна 😀 e\u{301}"),
|
||||
("literal-escapes", r"\U+0041 \M+183A4 \U+D83D\U+DE00 \\U+005C \P %%c"),
|
||||
("malformed-escapes", r"\U+ \U+ZZZZ \U+D800 \U+DC00 \M+9FFFF"),
|
||||
];
|
||||
for (case, value) in values {
|
||||
let tags: Vec<_> =
|
||||
[0..=9, 100..=100, 102..=102, 105..=105, 300..=309, 320..=369, 410..=419, 430..=439, 470..=479, 480..=481, 1000..=1003, 1005..=1005]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|code| Tag::s(code, value))
|
||||
.collect();
|
||||
check(case, &tags);
|
||||
}
|
||||
for length in [1, 254, 255, 256, 1024] {
|
||||
check(&format!("string/length={length}"), &[Tag::s(1, "x".repeat(length))]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_chunks_keep_bytes_and_order() {
|
||||
// DXF groups 310–319 and 1004 permit 127 payload bytes per chunk. A binary length
|
||||
// byte can represent more, but that is reader tolerance, not writer conformance.
|
||||
// Larger payloads need chunk-size/reassembly checks rather than exact tag equality;
|
||||
// see hex_chunks.rs for the independent wire checks and pending writer regressions.
|
||||
for length in [0, 1, 2, 126, 127] {
|
||||
let hex: String = (0..length).map(|i| format!("{:02X}", (i * 37) % 256)).collect();
|
||||
let tags: Vec<_> = (310..=319).chain([1004]).map(|code| Tag::new(code, Value::Hex(hex.clone()))).collect();
|
||||
check(&format!("hex/bytes={length}"), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_values_and_repeated_codes_keep_order() {
|
||||
check(
|
||||
"mixed/repeated-codes",
|
||||
&[
|
||||
Tag::s(0, "TEST"),
|
||||
Tag::f(10, 1.25),
|
||||
Tag::s(1, "first"),
|
||||
Tag::f(10, -2.5),
|
||||
Tag::i(70, -7),
|
||||
Tag::s(1, "second"),
|
||||
Tag::new(290, Value::Bool(true)),
|
||||
Tag::new(310, Value::Hex("00FF80".into())),
|
||||
Tag::i(160, i64::MAX),
|
||||
Tag::s(1, ""),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ workspace = true
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-color = { workspace = true }
|
||||
cadcraft-doc = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
craft-dxf = { workspace = true }
|
||||
cadcraft-constraints = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
|
||||
@@ -8,7 +8,7 @@ mod template;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use cadcraft_doc::{Drawing, Format};
|
||||
pub use cadcraft_dxf::Version;
|
||||
pub use craft_dxf::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Interactive, always, bad, bool_or, find_command, ok, str_param};
|
||||
@@ -53,9 +53,9 @@ pub fn io() -> Option<&'static IoHooks> {
|
||||
/// (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>),
|
||||
pub groups: fn(&Drawing, &cadcraft_doc::Entity) -> (String, Vec<craft_dxf::Tag>),
|
||||
/// An entity from a type name and groups.
|
||||
pub entity: fn(&str, &[cadcraft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
|
||||
pub entity: fn(&str, &[craft_dxf::Tag]) -> Option<(cadcraft_doc::Common, cadcraft_doc::EntityKind)>,
|
||||
}
|
||||
|
||||
static CODEC: OnceLock<EntityCodec> = OnceLock::new();
|
||||
@@ -340,7 +340,7 @@ fn read_bytes(bytes: &[u8], name: &str, path: Option<String>) -> Result<ReadFile
|
||||
let dir = if dir.as_os_str().is_empty() { std::path::Path::new(".") } else { dir };
|
||||
cadcraft_fonts::shx::add_drawing_dir(&std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()));
|
||||
}
|
||||
Ok(ReadFile { drawing, binary_dxf: cadcraft_dxf::is_binary(bytes), name: name.to_string(), path })
|
||||
Ok(ReadFile { drawing, binary_dxf: craft_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 {
|
||||
@@ -503,7 +503,7 @@ pub fn backup_path(path: &std::path::Path) -> std::path::PathBuf {
|
||||
pub fn looks_like_drawing(bytes: &[u8]) -> bool {
|
||||
// DWG: a version tag like `AC1032` (R12 is `AC1009`).
|
||||
let dwg = bytes.len() >= 6 && bytes.starts_with(b"AC") && bytes[2..6].iter().all(|b| b.is_ascii_digit());
|
||||
if dwg || cadcraft_dxf::is_binary(bytes) {
|
||||
if dwg || craft_dxf::is_binary(bytes) {
|
||||
return true;
|
||||
}
|
||||
// ASCII DXF: optional comments (999), then group 0 SECTION.
|
||||
@@ -533,7 +533,7 @@ pub fn completeness(head: &[u8], tail: &[u8], len: u64) -> Option<bool> {
|
||||
// Too short for any DWG's header: cut off, not unknown.
|
||||
return if head.len() < 256 { Some(false) } else { dwg_complete(head, len) };
|
||||
}
|
||||
if cadcraft_dxf::is_binary(head) {
|
||||
if craft_dxf::is_binary(head) {
|
||||
return Some(tail.ends_with(b"EOF\0"));
|
||||
}
|
||||
let text = String::from_utf8_lossy(tail);
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cadcraft_doc::{Entity, Handle, Space};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec2;
|
||||
use craft_dxf::Tag;
|
||||
|
||||
use super::LispError;
|
||||
use super::builtins::args_err;
|
||||
@@ -248,20 +248,20 @@ fn convert_angles(kind: &str, tags: &mut [Tag], f: fn(f64) -> f64) {
|
||||
continue;
|
||||
}
|
||||
let v = match t.value {
|
||||
cadcraft_dxf::Value::Real(r) => r,
|
||||
cadcraft_dxf::Value::Int(i) => i as f64,
|
||||
craft_dxf::Value::Real(r) => r,
|
||||
craft_dxf::Value::Int(i) => i as f64,
|
||||
_ => continue,
|
||||
};
|
||||
t.value = cadcraft_dxf::Value::Real(f(v));
|
||||
t.value = craft_dxf::Value::Real(f(v));
|
||||
}
|
||||
}
|
||||
|
||||
fn tag_value(t: &Tag) -> Value {
|
||||
match &t.value {
|
||||
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => Value::str(s),
|
||||
cadcraft_dxf::Value::Real(r) => Value::Real(*r),
|
||||
cadcraft_dxf::Value::Int(i) => Value::Int(i32::try_from(*i).unwrap_or(if *i < 0 { i32::MIN } else { i32::MAX })),
|
||||
cadcraft_dxf::Value::Bool(b) => Value::Int(i32::from(*b)),
|
||||
craft_dxf::Value::Str(s) | craft_dxf::Value::Hex(s) => Value::str(s),
|
||||
craft_dxf::Value::Real(r) => Value::Real(*r),
|
||||
craft_dxf::Value::Int(i) => Value::Int(i32::try_from(*i).unwrap_or(if *i < 0 { i32::MIN } else { i32::MAX })),
|
||||
craft_dxf::Value::Bool(b) => Value::Int(i32::from(*b)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ fn groups(d: &cadcraft_engine::doc::Drawing, e: &Entity) -> (String, Vec<(i32, V
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let v = match t.value {
|
||||
cadcraft_dxf::Value::Str(s) | cadcraft_dxf::Value::Hex(s) => json!(s),
|
||||
cadcraft_dxf::Value::Real(r) => json!(r),
|
||||
cadcraft_dxf::Value::Int(i) => json!(i),
|
||||
cadcraft_dxf::Value::Bool(b) => json!(i32::from(b)),
|
||||
craft_dxf::Value::Str(s) | craft_dxf::Value::Hex(s) => json!(s),
|
||||
craft_dxf::Value::Real(r) => json!(r),
|
||||
craft_dxf::Value::Int(i) => json!(i),
|
||||
craft_dxf::Value::Bool(b) => json!(i32::from(b)),
|
||||
};
|
||||
(t.code, v)
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
craft-dxf = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
|
||||
@@ -259,7 +259,7 @@ pub(crate) fn unescape_codes(s: &str) -> std::borrow::Cow<'_, str> {
|
||||
continue;
|
||||
}
|
||||
} else if n == Some('M') && plus == Some('+') && chars.get(i + 3).is_some_and(char::is_ascii_digit) && hex4(&chars, i + 4).is_some() {
|
||||
push_literal(&mut out, cadcraft_dxf::mif_escape_at(&chars, i).unwrap_or('\u{fffd}'));
|
||||
push_literal(&mut out, craft_dxf::mif_escape_at(&chars, i).unwrap_or('\u{fffd}'));
|
||||
i += 8;
|
||||
continue;
|
||||
}
|
||||
@@ -904,7 +904,7 @@ mod tests {
|
||||
assert_eq!(plain_mtext("\\H2.5x;Big"), "Big");
|
||||
assert_eq!(plain_mtext("unterminated \\H2"), "unterminated ");
|
||||
assert_eq!(plain_mtext("\\U+0041B \\U+00E8"), "AB è");
|
||||
assert_eq!(plain_mtext("\\U+12"), "", "a short escape is a code with an argument");
|
||||
assert_eq!(plain_mtext("\\U+12"), "+12", "a short escape loses only the backslash and the code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -928,6 +928,8 @@ mod tests {
|
||||
assert_eq!(plain_mtext("\\M+5BCFE x;y"), "\u{4ef6} x;y");
|
||||
// Malformed escapes lose only the backslash and the code.
|
||||
assert_eq!(plain_mtext("a\\U+12 b"), "a+12 b");
|
||||
assert_eq!(lay("a\\U+12 b", 0.0).lines, vec!["a+12 b"]);
|
||||
assert_eq!(lay("\\pxql;a\\U+12 b", 0.0).lines, vec!["a+12 b"]);
|
||||
assert_eq!(plain_mtext("a\\M+ b"), "a+ b");
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ workspace = true
|
||||
cadcraft-geom = { workspace = true }
|
||||
cadcraft-color = { workspace = true }
|
||||
cadcraft-doc = { workspace = true }
|
||||
cadcraft-dxf = { workspace = true }
|
||||
craft-dxf = { workspace = true }
|
||||
cadcraft-dwg = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
cadcraft-fonts = { workspace = true }
|
||||
|
||||
@@ -13,8 +13,8 @@ use std::collections::HashMap;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{ArrayParams, AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric, PlotStyleTable};
|
||||
use cadcraft_dxf::{Tag, Version};
|
||||
use cadcraft_render::Arrowhead;
|
||||
use craft_dxf::{Tag, Version};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, EntityStore, Handle, Image};
|
||||
use cadcraft_dxf::{Section, Tag};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use craft_dxf::{Section, Tag};
|
||||
|
||||
/// Most clip boundary vertices kept per image (hostile files).
|
||||
const MAX_CLIP: usize = 100_000;
|
||||
|
||||
@@ -13,8 +13,8 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Attrib, Drawing, Entity, EntityKind, Handle, Insert, MLeader, MLeaderStyle, MText};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec3;
|
||||
use craft_dxf::Tag;
|
||||
|
||||
/// Most leader roots (landings) read per multileader.
|
||||
const MAX_ROOTS: usize = 256;
|
||||
|
||||
@@ -12,8 +12,8 @@ use cadcraft_doc::{
|
||||
Drawing, Entity, EntityKind, EntityStore, MAX_MLINE_CUTS, MAX_MLINE_ELEMENTS, MAX_MLINE_VERTICES, MLine, MLineCaps, MLineCut, MLineElement,
|
||||
MLineJust, MLineStyle, MLineVertex, STANDARD_MLINE_STYLE,
|
||||
};
|
||||
use cadcraft_dxf::{Section, Tag};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use craft_dxf::{Section, Tag};
|
||||
|
||||
/// Most groups 41 kept per element and vertex (start, then break pairs).
|
||||
const MAX_PARAMS: usize = 2 * MAX_MLINE_CUTS + 2;
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::foreign::{Foreign, ForeignExtra, ForeignKey, ForeignObject, class_group, class_index, class_number};
|
||||
use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, RawTag, Unknown};
|
||||
use cadcraft_dxf::{Section, Tag};
|
||||
use craft_dxf::{Section, Tag};
|
||||
|
||||
/// Named object dictionary entries CADCraft writes itself from its model (plus every key that
|
||||
/// starts with `CADCRAFT`): they are read into the model, not kept raw. A change that starts
|
||||
@@ -71,7 +71,7 @@ fn raw(t: &Tag) -> RawTag {
|
||||
/// A kept group with the value type its code has (so binary output encodes it right); text that
|
||||
/// doesn't parse as that type stays text.
|
||||
fn tag(r: &RawTag) -> Tag {
|
||||
use cadcraft_dxf::{Kind, Value, kind_of};
|
||||
use craft_dxf::{Kind, Value, kind_of};
|
||||
let v = r.value.trim();
|
||||
let value = match kind_of(r.code) {
|
||||
Kind::Real => v.parse::<f64>().ok().filter(|x| x.is_finite()).map(Value::Real),
|
||||
|
||||
@@ -9,8 +9,8 @@ use cadcraft_doc::{
|
||||
Linetype, Lineweight, LwPolyline, MAX_FRAME_CHARS, MLeaderStyle, MText, MultilineAttrib, NamedView, PageSetup, Point, Polyline3d, RawTag,
|
||||
RayLine, Solid, Table, TableCell, Text, TextStyle, Tolerance, Transparency, Ucs, Unknown, VAlign, Viewport, VportConfig, VportTile, Wipeout,
|
||||
};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::{PolyVertex, Spline, Vec2, Vec3};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
@@ -84,7 +84,7 @@ type LayoutDef = (String, u32, String, PageSetup, Option<(Vec2, Vec2)>);
|
||||
|
||||
/// Import DXF sections while sharing one materialized-table-cell budget across blocks and entity sections.
|
||||
fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
|
||||
let tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?;
|
||||
let tags = craft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?;
|
||||
let secs = sections(&tags);
|
||||
if secs.is_empty() {
|
||||
return Err(IoError::Format("no sections found".into()));
|
||||
|
||||
@@ -420,7 +420,7 @@ pub(super) fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table {
|
||||
|
||||
/// The plot style names of a file: the entries of the `ACAD_PLOTSTYLENAME` dictionary of the
|
||||
/// named object dictionary (the first object), by placeholder handle.
|
||||
pub(super) fn plot_style_names(secs: &[cadcraft_dxf::Section]) -> HashMap<String, String> {
|
||||
pub(super) fn plot_style_names(secs: &[craft_dxf::Section]) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
let Some(objects) = secs.iter().find(|s| s.name == "OBJECTS") else { return out };
|
||||
let recs = records(&objects.tags);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! `^2` in `10^2` as a control character.
|
||||
|
||||
use cadcraft_doc::{MText, MTextColumnType, MTextColumns};
|
||||
use cadcraft_dxf::Tag;
|
||||
use craft_dxf::Tag;
|
||||
|
||||
/// Upper bound on column heights read from a file.
|
||||
const MAX_COLUMNS: usize = 1_000;
|
||||
|
||||
@@ -33,9 +33,9 @@ pub(super) fn header(tags: &[Tag], d: &mut Drawing) {
|
||||
[x, y, z] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), z.f64())),
|
||||
[x, y] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), 0.0)),
|
||||
[v] => match &v.value {
|
||||
cadcraft_dxf::Value::Real(r) => HVal::Real(*r),
|
||||
cadcraft_dxf::Value::Int(n) => HVal::Int(*n),
|
||||
cadcraft_dxf::Value::Bool(b) => HVal::Int(i64::from(*b)),
|
||||
craft_dxf::Value::Real(r) => HVal::Real(*r),
|
||||
craft_dxf::Value::Int(n) => HVal::Int(*n),
|
||||
craft_dxf::Value::Bool(b) => HVal::Int(i64::from(*b)),
|
||||
other => HVal::Str(other.as_str()),
|
||||
},
|
||||
_ => continue,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! kept as unknown objects.
|
||||
|
||||
use cadcraft_doc::{EntityKind, HatchLoop, LwPolyline, MAX_REGION_LOOPS, Region};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use craft_dxf::Tag;
|
||||
|
||||
use crate::dxf_ext::{APP, xdata, xdata_list};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{CellBorder, CellStyle, DataLink, Lineweight, MAX_CELL_STYLES, MAX_TABLE_PARTS, Table, TableBreak, TableStyle};
|
||||
use cadcraft_dxf::Tag;
|
||||
use craft_dxf::Tag;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dxf_ext::{APP, xdata};
|
||||
|
||||
@@ -11,8 +11,8 @@ use cadcraft_doc::{
|
||||
HVal, Handle, Hatch, Insert, Layout, Leader, Line, Lineweight, LwPolyline, MLeader, MLeaderStyle, MText, PageSetup, Point, Solid, Space, Table,
|
||||
TableStyle, Text, Transparency, VAlign, VportConfig, VportTile,
|
||||
};
|
||||
use cadcraft_dxf::{Tag, Version};
|
||||
use cadcraft_geom::{Bounds2, Vec2, Vec3};
|
||||
use craft_dxf::{Tag, Version};
|
||||
|
||||
use crate::dxf_ext::{self, DimVal, K};
|
||||
|
||||
@@ -111,7 +111,7 @@ impl W {
|
||||
/// Write a drawing as ASCII DXF.
|
||||
/// The drawing as R2000 ASCII DXF.
|
||||
pub fn write(d: &Drawing) -> String {
|
||||
cadcraft_dxf::write_ascii(&tags(d, Version::R2000))
|
||||
craft_dxf::write_ascii(&tags(d, Version::R2000))
|
||||
}
|
||||
|
||||
/// The drawing's DXF tags, declaring `version` in `$ACADVER`.
|
||||
@@ -124,7 +124,7 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
pub(crate) fn dwg_bridge(d: &Drawing, dwg: Version) -> String {
|
||||
let mut tags = tags_for(d, Version::R2000, dwg, false);
|
||||
bridge_markers(&mut tags);
|
||||
cadcraft_dxf::write_ascii(&tags)
|
||||
craft_dxf::write_ascii(&tags)
|
||||
}
|
||||
|
||||
/// Proxy type names and lookup-parameter subclass names must match the bridge reader's spelling.
|
||||
|
||||
@@ -144,6 +144,6 @@ pub(super) fn header_vars(w: &mut W, d: &Drawing) {
|
||||
/// from 2007 (UTF-8 text), `ANSI_1252`.
|
||||
pub(crate) fn code_page(d: &Drawing, version: Version) -> String {
|
||||
let read = d.header.get("DWGCODEPAGE").and_then(HVal::as_str).map(str::trim).unwrap_or_default();
|
||||
let known = read.is_ascii() && read.len() <= 32 && cadcraft_dxf::decode_code_page(b"", read).is_some();
|
||||
let known = read.is_ascii() && read.len() <= 32 && craft_dxf::decode_code_page(b"", read).is_some();
|
||||
if version < Version::R2007 && known { read.to_string() } else { "ANSI_1252".into() }
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use cadcraft_geom::Bounds2;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use cadcraft_doc::Format;
|
||||
pub use cadcraft_dxf::Version;
|
||||
pub use craft_dxf::Version;
|
||||
pub use dxf_read::entity_from_groups;
|
||||
pub use dxf_write::entity_groups;
|
||||
pub use pdf::{PdfOptions, pdf, plot};
|
||||
@@ -114,10 +114,10 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
|
||||
// Before 2007 the text is in the code page the header declares.
|
||||
let code_page = dxf_write::code_page(d, version);
|
||||
Ok(match (opts.binary, version.utf8()) {
|
||||
(true, true) => cadcraft_dxf::write_binary(&tags, true),
|
||||
(true, false) => cadcraft_dxf::write_binary_in(&tags, &code_page),
|
||||
(false, true) => cadcraft_dxf::write_ascii_as(&tags, true).into_bytes(),
|
||||
(false, false) => cadcraft_dxf::write_ascii_in(&tags, &code_page),
|
||||
(true, true) => craft_dxf::write_binary(&tags, true),
|
||||
(true, false) => craft_dxf::write_binary_in(&tags, &code_page),
|
||||
(false, true) => craft_dxf::write_ascii_as(&tags, true).into_bytes(),
|
||||
(false, false) => craft_dxf::write_ascii_in(&tags, &code_page),
|
||||
})
|
||||
}
|
||||
// The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without
|
||||
|
||||
@@ -39,7 +39,7 @@ fn bump(m: &mut BTreeMap<String, usize>, k: &str) {
|
||||
|
||||
/// A layout's own paper-space viewport (VIEWPORT with ID 1, group 69): part of the layout, not
|
||||
/// an entity of its own for CADCraft.
|
||||
fn is_overall_viewport(t: &str, g: &[cadcraft_dxf::Tag]) -> bool {
|
||||
fn is_overall_viewport(t: &str, g: &[craft_dxf::Tag]) -> bool {
|
||||
t == "VIEWPORT" && g.iter().any(|x| x.code == 69 && x.i64() == 1)
|
||||
}
|
||||
|
||||
@@ -50,14 +50,14 @@ fn is_space_block(name: &str) -> bool {
|
||||
|
||||
/// Count what a DXF file (ASCII or binary) holds; `None` when it can't be parsed.
|
||||
pub fn census(dxf: &[u8]) -> Option<Census> {
|
||||
let tags = cadcraft_dxf::parse(dxf).ok()?;
|
||||
let tags = craft_dxf::parse(dxf).ok()?;
|
||||
let mut c = Census::default();
|
||||
// Blocks CADCraft generates (tables' blocks, standard arrowheads nothing inserts) are left out
|
||||
// on read and rebuilt on save: counted apart.
|
||||
let mut table_blocks: HashSet<String> = HashSet::new();
|
||||
let mut inserted: HashSet<String> = HashSet::new();
|
||||
let mut blocks: Vec<(String, BTreeMap<String, usize>)> = Vec::new();
|
||||
let mut note_refs = |t: &str, g: &[cadcraft_dxf::Tag]| {
|
||||
let mut note_refs = |t: &str, g: &[craft_dxf::Tag]| {
|
||||
let name = || g.iter().find(|x| x.code == 2).map(|x| x.str().trim().to_ascii_uppercase());
|
||||
match t {
|
||||
"ACAD_TABLE" => table_blocks.extend(name()),
|
||||
@@ -65,8 +65,8 @@ pub fn census(dxf: &[u8]) -> Option<Census> {
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
for s in cadcraft_dxf::sections(&tags) {
|
||||
let records = cadcraft_dxf::records(&s.tags);
|
||||
for s in craft_dxf::sections(&tags) {
|
||||
let records = craft_dxf::records(&s.tags);
|
||||
match s.name.trim().to_ascii_uppercase().as_str() {
|
||||
"ENTITIES" => {
|
||||
for (t, g) in &records {
|
||||
@@ -82,7 +82,7 @@ pub fn census(dxf: &[u8]) -> Option<Census> {
|
||||
note_refs(t, g);
|
||||
match t.as_str() {
|
||||
"BLOCK" => {
|
||||
let name = g.iter().find(|x| x.code == 2).map(cadcraft_dxf::Tag::str).unwrap_or_default();
|
||||
let name = g.iter().find(|x| x.code == 2).map(craft_dxf::Tag::str).unwrap_or_default();
|
||||
space = is_space_block(&name);
|
||||
if !space {
|
||||
blocks.push((name.trim().to_ascii_uppercase(), BTreeMap::new()));
|
||||
|
||||
@@ -1483,7 +1483,7 @@ fn ac1024_dwg_with_many_entities_opens() {
|
||||
let n = 30_000;
|
||||
let dwg = ac1024_dwg(n);
|
||||
let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&dxf).unwrap().len();
|
||||
let tags = craft_dxf::parse(&dxf).unwrap().len();
|
||||
assert!(dxf.len() < dwg.len() * 20, "DXF {} bytes from {} DWG bytes", dxf.len(), dwg.len());
|
||||
assert!(tags < dwg.len(), "{tags} group codes from {} DWG bytes", dwg.len());
|
||||
let d = read(&dwg, "big.dwg").unwrap();
|
||||
@@ -1512,7 +1512,7 @@ fn oversized_dwg_and_dxf_are_rejected_early() {
|
||||
|
||||
// Lines that aren't even group codes: the size is reported before anything is parsed.
|
||||
let text = "not a group code\nx\n".repeat(60);
|
||||
let e = cadcraft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err();
|
||||
let e = craft_dxf::parse_with_limit(text.as_bytes(), 50).unwrap_err();
|
||||
assert_eq!(e.to_string(), "drawing too large: 60 DXF group codes, more than the 50 CADCraft reads (set CADCRAFT_MAX_DXF_TAGS to read more)");
|
||||
}
|
||||
|
||||
@@ -1608,7 +1608,7 @@ fn dimension_associativity_roundtrips() {
|
||||
|
||||
// Without CADCraft's xdata (a file from another writer), the standard DIMASSOC objects
|
||||
// still give the extension-line links.
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let mut stripped = Vec::new();
|
||||
let mut skipping = false;
|
||||
for t in tags {
|
||||
@@ -1621,7 +1621,7 @@ fn dimension_associativity_roundtrips() {
|
||||
stripped.push(t);
|
||||
}
|
||||
}
|
||||
let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
|
||||
let foreign = read_dxf(craft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
|
||||
assert_eq!(assoc_of(&foreign, hl), lin.assoc);
|
||||
let fa = assoc_of(&foreign, ha);
|
||||
assert_eq!(fa.len(), 2);
|
||||
@@ -1697,7 +1697,7 @@ fn polyline_vertex_dimensions_round_trip_by_segment() {
|
||||
];
|
||||
let hp = d.add(&Space::Model, c(), EntityKind::Dimension(on_pl.clone())).unwrap();
|
||||
let text = write_dxf(&d);
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(text.as_bytes()).unwrap();
|
||||
// Each reference to the polyline: (72, 73, 91).
|
||||
let refs: Vec<(i64, i64, i64)> = tags
|
||||
.iter()
|
||||
@@ -1726,13 +1726,13 @@ fn polyline_vertex_dimensions_round_trip_by_segment() {
|
||||
in_ref = t.str() == "AcDbOsnapPointRef";
|
||||
}
|
||||
if in_ref && matches!(t.code, 10 | 20) {
|
||||
t = cadcraft_dxf::Tag::new(t.code, cadcraft_dxf::Value::Real(0.0));
|
||||
t = craft_dxf::Tag::new(t.code, craft_dxf::Value::Real(0.0));
|
||||
}
|
||||
if !skipping {
|
||||
stripped.push(t);
|
||||
}
|
||||
}
|
||||
let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
|
||||
let foreign = read_dxf(craft_dxf::write_ascii(&stripped).as_bytes()).unwrap();
|
||||
match &foreign.entity(hp).unwrap().kind {
|
||||
EntityKind::Dimension(x) => assert_eq!(x.assoc, on_pl.assoc),
|
||||
_ => panic!(),
|
||||
@@ -2041,7 +2041,7 @@ fn non_ascii_text_is_escaped_for_r2000_and_roundtrips() {
|
||||
let d = intl_sample();
|
||||
let text = write_dxf(&d);
|
||||
assert!(text.is_ascii(), "an AC1015 DXF must not contain UTF-8 bytes");
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let header = |name: &str| tags.windows(2).find(|w| w[0].code == 9 && w[0].str() == name).map(|w| w[1].str());
|
||||
assert_eq!(header("$ACADVER").as_deref(), Some("AC1015"));
|
||||
assert_eq!(header("$DWGCODEPAGE").as_deref(), Some("ANSI_1252"));
|
||||
|
||||
@@ -173,16 +173,16 @@ fn cyclic_hard_owners_with_a_live_holder_stay_kept() {
|
||||
let bytes = write(&drawing, name).unwrap();
|
||||
drawing = read(&bytes, name).unwrap();
|
||||
let text = write_dxf(&drawing);
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let objects = cadcraft_dxf::sections(&tags).into_iter().find(|s| s.name == "OBJECTS").unwrap();
|
||||
let records = cadcraft_dxf::records(&objects.tags);
|
||||
let tags = craft_dxf::parse(text.as_bytes()).unwrap();
|
||||
let objects = craft_dxf::sections(&tags).into_iter().find(|s| s.name == "OBJECTS").unwrap();
|
||||
let records = craft_dxf::records(&objects.tags);
|
||||
let record = |label: &str| records.iter().find(|(_, tags)| tags.iter().any(|t| t.code == 1 && t.str() == label)).unwrap().1.clone();
|
||||
let first = record("first");
|
||||
let second = record("second");
|
||||
let holder = record("holder");
|
||||
let child = record("child");
|
||||
assert!(!records.iter().any(|(_, tags)| tags.iter().any(|t| t.code == 1 && t.str() == "stray")), "{name}");
|
||||
let handle = |tags: &[cadcraft_dxf::Tag]| tags.iter().find(|t| t.code == 5).unwrap().str();
|
||||
let handle = |tags: &[craft_dxf::Tag]| tags.iter().find(|t| t.code == 5).unwrap().str();
|
||||
assert!(first.iter().any(|t| t.code == 360 && t.str() == handle(&second)), "{name}");
|
||||
assert!(second.iter().any(|t| t.code == 360 && t.str() == handle(&first)), "{name}");
|
||||
assert!(first.iter().any(|t| t.code == 360 && t.str() == handle(&child)), "{name}");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Snap/grid spacing uses legacy HEADER variables and the current *ACTIVE VPORT (#607).
|
||||
|
||||
use cadcraft_doc::{Drawing, HVal};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
fn header(snap: Vec2, grid: Vec2) -> Vec<Tag> {
|
||||
let mut tags = Vec::new();
|
||||
@@ -24,7 +24,7 @@ fn read(header: &[Tag], vports: &[Vec<Tag>]) -> Drawing {
|
||||
tags.extend_from_slice(vp);
|
||||
}
|
||||
tags.extend([Tag::s(0, "ENDTAB"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
|
||||
cadcraft_io::read_dxf(cadcraft_dxf::write_ascii(&tags).as_bytes()).unwrap()
|
||||
cadcraft_io::read_dxf(craft_dxf::write_ascii(&tags).as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
fn assert_units(d: &Drawing, snap: Vec2, grid: Vec2) {
|
||||
@@ -43,7 +43,7 @@ fn writes_spacing_to_header_and_active_vport_and_roundtrips() {
|
||||
d.header.set("SNAPUNIT", HVal::Point(snap.to3(0.0)));
|
||||
d.header.set("GRIDUNIT", HVal::Point(grid.to3(0.0)));
|
||||
let text = cadcraft_io::write_dxf(&d);
|
||||
let tags = cadcraft_dxf::parse_ascii(&text).unwrap();
|
||||
let tags = craft_dxf::parse_ascii(&text).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let header = §ions.iter().find(|s| s.name == "HEADER").unwrap().tags;
|
||||
for (name, unit) in [("$SNAPUNIT", snap), ("$GRIDUNIT", grid)] {
|
||||
|
||||
@@ -62,7 +62,7 @@ fn opts(version: Version, binary: bool) -> SaveOptions {
|
||||
}
|
||||
|
||||
fn acadver(bytes: &[u8]) -> String {
|
||||
let tags = cadcraft_dxf::parse(bytes).unwrap();
|
||||
let tags = craft_dxf::parse(bytes).unwrap();
|
||||
tags.windows(2).find(|w| w[0].code == 9 && w[0].str() == "$ACADVER").map(|w| w[1].str()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ fn dxf_at_every_version_reads_back_the_same() {
|
||||
let bytes = write_with(&d, "x.dxf", &opts(v, binary)).unwrap();
|
||||
let what = format!("{v} binary={binary}");
|
||||
assert_eq!(acadver(&bytes), v.code(), "{what}");
|
||||
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{what}");
|
||||
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{what}");
|
||||
let raw = bytes.windows("图号".len()).any(|w| w == "图号".as_bytes());
|
||||
assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, the code page before");
|
||||
if !v.utf8() {
|
||||
@@ -109,11 +109,11 @@ fn class_records_follow_the_version() {
|
||||
let d = sample();
|
||||
for v in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let tags = cadcraft_dxf::parse(&write_with(&d, "x.dxf", &opts(v, binary)).unwrap()).unwrap();
|
||||
let classes: Vec<Vec<i32>> = cadcraft_dxf::sections(&tags)
|
||||
let tags = craft_dxf::parse(&write_with(&d, "x.dxf", &opts(v, binary)).unwrap()).unwrap();
|
||||
let classes: Vec<Vec<i32>> = craft_dxf::sections(&tags)
|
||||
.into_iter()
|
||||
.filter(|s| s.name == "CLASSES")
|
||||
.flat_map(|s| cadcraft_dxf::records(&s.tags))
|
||||
.flat_map(|s| craft_dxf::records(&s.tags))
|
||||
.filter(|(kind, _)| kind == "CLASS")
|
||||
.map(|(_, t)| t.iter().map(|x| x.code).collect())
|
||||
.collect();
|
||||
@@ -134,7 +134,7 @@ fn r2000_ascii_is_unchanged() {
|
||||
let saved = write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap();
|
||||
let escaped = write_dxf(&d);
|
||||
assert_ne!(saved, escaped.as_bytes(), "Café is written in ANSI_1252");
|
||||
assert_eq!(cadcraft_dxf::parse(&saved).unwrap(), cadcraft_dxf::parse(escaped.as_bytes()).unwrap());
|
||||
assert_eq!(craft_dxf::parse(&saved).unwrap(), craft_dxf::parse(escaped.as_bytes()).unwrap());
|
||||
}
|
||||
|
||||
/// Without a version asked for: 2018 for a new drawing, otherwise the file's own version (the
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! don't panic.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, Helix, HelixConstrain, Space};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
fn helices(d: &Drawing) -> Vec<Helix> {
|
||||
d.model
|
||||
@@ -65,7 +65,7 @@ fn helix_survives_save_and_reopen() {
|
||||
assert_eq!(back.model.iter().next().unwrap().common.layer, "Springs");
|
||||
|
||||
// The file: a spline whose last control point is the helix's end, the helix data, a CLASS.
|
||||
let tags = cadcraft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let secs = sections(&tags);
|
||||
let recs = |name: &str| records(&secs.iter().find(|s| s.name == name).unwrap().tags);
|
||||
let ents = recs("ENTITIES");
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Common, DimStyle, Drawing, EntityKind, Handle, Leader, Lineweight, Space};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::Tag;
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
@@ -93,7 +93,7 @@ fn missing_z_defaults_only_the_corresponding_vertex() -> TestResult {
|
||||
expected.push(Vec3::new(p.x, p.y, if has_z { p.z } else { 0.0 }));
|
||||
}
|
||||
tags.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
|
||||
let dxf = cadcraft_dxf::write_ascii(&tags);
|
||||
let dxf = craft_dxf::write_ascii(&tags);
|
||||
let d = read(dxf.as_bytes(), "missing-z.dxf")?;
|
||||
let e = d.entity(Handle(0x10)).ok_or("LEADER missing")?;
|
||||
let EntityKind::Leader(l) = &e.kind else { return Err("expected LEADER".into()) };
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, MLine, MLineCaps, MLineCut, MLineElement, MLineJust, MLineStyle, Space};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec2;
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
fn mlines(d: &Drawing) -> Vec<MLine> {
|
||||
d.model
|
||||
@@ -84,7 +84,7 @@ fn mlines_and_styles_survive_save_and_reopen() {
|
||||
assert_eq!(back.header.f64("CMLSCALE", 0.0), 2.5);
|
||||
|
||||
// The file links up as the DXF Reference describes.
|
||||
let tags = cadcraft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let secs = sections(&tags);
|
||||
let recs = |name: &str| records(&secs.iter().find(|s| s.name == name).unwrap().tags);
|
||||
let val = |t: &[Tag], c: i32| t.iter().find(|g| g.code == c).map(Tag::str).unwrap_or_default();
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! 75/76/78/79/48/49/50, tabs as `^I`, carets as `^ `.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, MText, MTextColumnType, MTextColumns, Space};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, entity_from_groups, read_dxf, write_dxf, write_with};
|
||||
use craft_dxf::Tag;
|
||||
|
||||
fn mtext(contents: &str, rotation: f64, columns: Option<MTextColumns>) -> MText {
|
||||
MText {
|
||||
@@ -246,8 +246,8 @@ fn markers_inside_an_application_group_do_not_end_the_entity_groups() {
|
||||
|
||||
// ---- #2248: R2018 embedded column object ----
|
||||
|
||||
fn embedded_tags(count: i64, width: f64, gutter: f64, heights: &[f64]) -> Vec<cadcraft_dxf::Tag> {
|
||||
use cadcraft_dxf::Tag;
|
||||
fn embedded_tags(count: i64, width: f64, gutter: f64, heights: &[f64]) -> Vec<craft_dxf::Tag> {
|
||||
use craft_dxf::Tag;
|
||||
let mut v = vec![
|
||||
Tag::s(8, "0"),
|
||||
Tag::f(10, 10.0),
|
||||
@@ -281,7 +281,7 @@ fn embedded_tags(count: i64, width: f64, gutter: f64, heights: &[f64]) -> Vec<ca
|
||||
v
|
||||
}
|
||||
|
||||
fn mtext_from_groups(tags: &[cadcraft_dxf::Tag]) -> (Common, MText) {
|
||||
fn mtext_from_groups(tags: &[craft_dxf::Tag]) -> (Common, MText) {
|
||||
let Some((common, EntityKind::MText(t))) = entity_from_groups("MTEXT", tags) else { panic!("MTEXT was not read") };
|
||||
(common, t)
|
||||
}
|
||||
@@ -322,7 +322,7 @@ fn embedded_column_height_is_the_entitys_defined_height() {
|
||||
// As ezdxf and AutoCAD write static columns: the height in the entity's 46, none in the object.
|
||||
let mut tags = embedded_tags(3, 12.5, 2.5, &[]);
|
||||
let at = tags.iter().position(|t| t.code == 101).unwrap();
|
||||
tags.insert(at, cadcraft_dxf::Tag::f(46, 20.0));
|
||||
tags.insert(at, craft_dxf::Tag::f(46, 20.0));
|
||||
let (_, t) = mtext_from_groups(&tags);
|
||||
assert_eq!(t.columns.map(|c| c.height), Some(20.0));
|
||||
}
|
||||
@@ -355,10 +355,10 @@ fn dynamic_auto_height_embedded_count_comes_from_the_total_width() {
|
||||
let at = tags.iter().position(|t| t.code == 101).unwrap();
|
||||
for t in &mut tags[at..] {
|
||||
*t = match t.code {
|
||||
42 => cadcraft_dxf::Tag::f(42, 175.0),
|
||||
71 => cadcraft_dxf::Tag::i(71, 2),
|
||||
72 => cadcraft_dxf::Tag::i(72, 0),
|
||||
73 => cadcraft_dxf::Tag::i(73, 1),
|
||||
42 => craft_dxf::Tag::f(42, 175.0),
|
||||
71 => craft_dxf::Tag::i(71, 2),
|
||||
72 => craft_dxf::Tag::i(72, 0),
|
||||
73 => craft_dxf::Tag::i(73, 1),
|
||||
_ => t.clone(),
|
||||
};
|
||||
}
|
||||
@@ -373,13 +373,13 @@ fn dynamic_auto_height_count_comes_from_the_widths_when_72_is_zero() {
|
||||
// A dynamic auto-height object stores 0 in 72; the count is implied by the total width
|
||||
// (42 = n * 44 + (n - 1) * 45): (175 + 12.5) / (50 + 12.5) = 3.
|
||||
let mut tags = embedded_tags(0, 50.0, 12.5, &[]);
|
||||
let mut set = |code: i32, v: cadcraft_dxf::Tag| {
|
||||
let mut set = |code: i32, v: craft_dxf::Tag| {
|
||||
let at = tags.iter().rposition(|t| t.code == code).unwrap();
|
||||
tags[at] = v;
|
||||
};
|
||||
set(71, cadcraft_dxf::Tag::i(71, 2));
|
||||
set(73, cadcraft_dxf::Tag::i(73, 1));
|
||||
set(42, cadcraft_dxf::Tag::f(42, 175.0));
|
||||
set(71, craft_dxf::Tag::i(71, 2));
|
||||
set(73, craft_dxf::Tag::i(73, 1));
|
||||
set(42, craft_dxf::Tag::f(42, 175.0));
|
||||
let (_, t) = mtext_from_groups(&tags);
|
||||
let c = t.columns.expect("columns");
|
||||
assert_eq!((c.kind, c.auto_height, c.count), (MTextColumnType::Dynamic, true, 3));
|
||||
@@ -392,10 +392,10 @@ fn embedded_column_width_wins_over_the_entitys_width() {
|
||||
// what the R2018 object gets back (#2467 review).
|
||||
let mut tags = embedded_tags(3, 50.0, 12.5, &[20.0, 20.0, 20.0]);
|
||||
let own_41 = tags.iter().position(|t| t.code == 41).unwrap();
|
||||
tags[own_41] = cadcraft_dxf::Tag::f(41, 175.0);
|
||||
tags[own_41] = craft_dxf::Tag::f(41, 175.0);
|
||||
for code in [40, 42] {
|
||||
let at = tags.iter().rposition(|t| t.code == code).unwrap();
|
||||
tags[at] = cadcraft_dxf::Tag::f(code, 175.0);
|
||||
tags[at] = craft_dxf::Tag::f(code, 175.0);
|
||||
}
|
||||
let (_, t) = mtext_from_groups(&tags);
|
||||
assert_eq!(t.width, 50.0, "the column width, not the total");
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! RASTERVARIABLES and dictionaries the DXF Reference describes.
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind, Image};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec2;
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
/// A small DXF as another program writes it: a line, an image on layer `Pics` pointing at an
|
||||
/// IMAGEDEF named `logo`, and an MLINE without vertices.
|
||||
@@ -114,7 +114,7 @@ fn images(d: &Drawing) -> Vec<Image> {
|
||||
|
||||
/// The records of a section of a DXF text.
|
||||
fn section(dxf: &str, name: &str) -> Vec<(String, Vec<Tag>)> {
|
||||
let tags = cadcraft_dxf::parse(dxf.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(dxf.as_bytes()).unwrap();
|
||||
let s = sections(&tags).into_iter().find(|s| s.name == name).unwrap();
|
||||
records(&s.tags)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ fn fonts(d: &Drawing) -> Vec<(String, String)> {
|
||||
fn style_files(dxf: &str) -> Vec<(String, String)> {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
let mut style = false;
|
||||
for t in cadcraft_dxf::parse(dxf.as_bytes()).unwrap() {
|
||||
for t in craft_dxf::parse(dxf.as_bytes()).unwrap() {
|
||||
match t.code {
|
||||
0 => style = t.str() == "STYLE",
|
||||
2 if style => out.push((t.str(), String::new())),
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! Foreign REGIONs stay unknown objects as before; hostile xdata doesn't panic.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, HatchLoop, Region, Space};
|
||||
use cadcraft_dxf::{records, sections};
|
||||
use cadcraft_geom::{PolyVertex, Vec2};
|
||||
use craft_dxf::{records, sections};
|
||||
|
||||
fn regions(d: &Drawing) -> Vec<Region> {
|
||||
d.model
|
||||
@@ -56,7 +56,7 @@ fn region_with_islands_survives_save_and_reopen() {
|
||||
assert!(matches!(back.entity(plain).unwrap().kind, EntityKind::LwPolyline(_)));
|
||||
|
||||
// Other readers see four closed polylines on the region's layer.
|
||||
let tags = cadcraft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let tags = craft_dxf::parse(saved.as_bytes()).unwrap();
|
||||
let ents = records(§ions(&tags).into_iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
let polys: Vec<_> = ents.iter().filter(|(k, _)| k == "LWPOLYLINE").collect();
|
||||
assert_eq!(polys.len(), 5);
|
||||
|
||||
@@ -47,7 +47,7 @@ fn edit_primary(drawing: &mut Drawing, edit: impl Fn(&mut Attrib)) {
|
||||
fn reopen(drawing: &Drawing, version: Version, binary: bool, context: &str) -> Drawing {
|
||||
let options = SaveOptions { version: Some(version), binary };
|
||||
let bytes = cadcraft_io::write_with(drawing, "attributes.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}"));
|
||||
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
let back = cadcraft_io::read(&bytes, "attributes.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}"));
|
||||
assert_eq!(cadcraft_io::save_version(&back), version, "{context}: version");
|
||||
assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes);
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, Layer, Lineweight, Transparency};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
fn fixture(properties: &str) -> Drawing {
|
||||
let records = [
|
||||
@@ -45,7 +45,7 @@ fn output(common: &Common, version: Version, binary: bool) -> Vec<(String, Vec<T
|
||||
drawing.model.modify(handle, |e| e.common = common.clone());
|
||||
}
|
||||
let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(entities.iter().map(|(kind, _)| kind.as_str()).collect::<Vec<_>>(), ["LINE", "CIRCLE", "TEXT"]);
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Circle, Common, Drawing, Entity, EntityKind, HAlign, Handle, Line, Space, Text, VAlign};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
use super::compare;
|
||||
|
||||
@@ -88,7 +88,7 @@ fn writes_standard_entity_groups_not_just_self_readable_data() {
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let bytes = write_with(&model(), "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(entities.len(), expected.len());
|
||||
@@ -161,7 +161,7 @@ fn writes_large_handles_as_exact_hex_groups() {
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(entities.len(), LARGE_HANDLES.len());
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
use super::super::{cases, compare};
|
||||
use cadcraft_doc::{Arc, Common, Ellipse, EntityKind, LwPolyline, MText, Point, RayLine};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
struct Fixture {
|
||||
wire_type: &'static str,
|
||||
@@ -121,7 +121,7 @@ fn writes_extended_entities_with_independent_groups() {
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let bytes = write_with(&source.drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let records = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(records.len(), 1);
|
||||
|
||||
@@ -39,7 +39,7 @@ fn check_version_with(case: &str, original: &Drawing, expected: &Drawing, versio
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{case} · {version} · binary={binary} · pass={pass}");
|
||||
let bytes = write_with(¤t, "memory.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}"));
|
||||
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
let back = read(&bytes, "memory.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}"));
|
||||
assert_eq!(save_version(&back), version, "{context}: version");
|
||||
assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes);
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Drawing, Layer, TextStyle};
|
||||
use cadcraft_dxf::{Tag, Value, records, sections};
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::{Tag, Value, records, sections};
|
||||
|
||||
fn layers() -> Vec<(Layer, i64)> {
|
||||
let mut cases = Vec::new();
|
||||
@@ -59,7 +59,7 @@ fn fixture(table: &str, body: &str) -> Drawing {
|
||||
|
||||
fn output(drawing: &Drawing, table: &str, version: Version, binary: bool) -> Vec<Tag> {
|
||||
let bytes = write_with(drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let tables = records(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags);
|
||||
let matching: Vec<_> = tables.into_iter().filter(|(kind, groups)| kind == table && groups.contains(&Tag::s(2, "Flags"))).collect();
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! DWG may explicitly refuse an unregistered class; a successful save must not silently drop it.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Line, RawTag, Unknown};
|
||||
use cadcraft_dxf::{records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use craft_dxf::{records, sections};
|
||||
|
||||
fn drawing() -> Drawing {
|
||||
let mut d = super::cases::drawing();
|
||||
@@ -55,7 +55,7 @@ fn preserved(original: &Drawing, back: &Drawing, context: &str) {
|
||||
}
|
||||
|
||||
fn dxf_owner(bytes: &[u8]) {
|
||||
let tags = cadcraft_dxf::parse(bytes).unwrap();
|
||||
let tags = craft_dxf::parse(bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let tables = records(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags);
|
||||
let (_, model) = tables
|
||||
@@ -79,7 +79,7 @@ pub(super) fn check(path: &str, version: Version, binary: bool) {
|
||||
Err(error) => panic!("{context}: {error}"),
|
||||
};
|
||||
if path.ends_with(".dxf") {
|
||||
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
assert_eq!(craft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
dxf_owner(&bytes);
|
||||
} else {
|
||||
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityKind};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_io::{SaveOptions, Version, read_dxf, write_dxf, write_with};
|
||||
use craft_dxf::{Tag, records, sections};
|
||||
|
||||
/// DXF text from lines of `code value` (the value may be empty).
|
||||
fn dxf(src: &str) -> String {
|
||||
@@ -565,7 +565,7 @@ struct Out {
|
||||
|
||||
impl Out {
|
||||
fn new(text: &str) -> Out {
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse written DXF");
|
||||
let tags = craft_dxf::parse(text.as_bytes()).expect("parse written DXF");
|
||||
let mut recs = Vec::new();
|
||||
for s in sections(&tags) {
|
||||
for (k, t) in records(&s.tags) {
|
||||
@@ -898,7 +898,7 @@ fn class_records_have_instance_counts_from_2004_only() {
|
||||
// Binary files the same way: none at R2000, the count at 2018.
|
||||
let acme = |version: Version| {
|
||||
let bytes = write_with(&d, "x.dxf", &SaveOptions { version: Some(version), binary: true }).expect("save binary");
|
||||
let tags = cadcraft_dxf::parse(&bytes).expect("parse binary");
|
||||
let tags = craft_dxf::parse(&bytes).expect("parse binary");
|
||||
let class = sections(&tags)
|
||||
.into_iter()
|
||||
.filter(|sec| sec.name == "CLASSES")
|
||||
|
||||
@@ -76,8 +76,8 @@ fn multileader_styles_and_current_style_round_trip() {
|
||||
d.mleader_styles.push(callout);
|
||||
d.header.set_str("CMLEADERSTYLE", "Callout");
|
||||
let text = write_dxf(&d);
|
||||
let tags = cadcraft_dxf::parse(text.as_bytes()).expect("parse written DXF");
|
||||
let header = cadcraft_dxf::sections(&tags).into_iter().find(|s| s.name == "HEADER").expect("HEADER");
|
||||
let tags = craft_dxf::parse(text.as_bytes()).expect("parse written DXF");
|
||||
let header = craft_dxf::sections(&tags).into_iter().find(|s| s.name == "HEADER").expect("HEADER");
|
||||
assert!(header.tags.iter().all(|t| t.code != 9 || t.str() != "$CMLEADERSTYLE"));
|
||||
let r = read_dxf(text.as_bytes()).expect("reopen");
|
||||
assert_eq!(r.mleader_styles, d.mleader_styles);
|
||||
|
||||
@@ -114,7 +114,7 @@ fn entget_shows_plain_carets_and_entmake_takes_them() {
|
||||
// entmod of entget changes nothing; entmake takes a plain string.
|
||||
let (_, back) = cadcraft_io::entity_from_groups(&kind, &groups).unwrap();
|
||||
assert_eq!(strings(&back), ["x^ y 10^2"]);
|
||||
let made = [cadcraft_dxf::Tag::s(1, "p^ q")];
|
||||
let made = [craft_dxf::Tag::s(1, "p^ q")];
|
||||
assert_eq!(strings(&cadcraft_io::entity_from_groups("TEXT", &made).unwrap().1), ["p^ q"]);
|
||||
// Every kind, and a DIMENSION's style name (also group 3) as it is.
|
||||
let d = drawing();
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, ClipBoundary, Common, Drawing, Entity, EntityKind, Handle, Insert, Line, Space};
|
||||
use cadcraft_dxf::Version;
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use cadcraft_io::{SaveOptions, read, write_with};
|
||||
use craft_dxf::Version;
|
||||
|
||||
fn drawing() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
|
||||
@@ -7,9 +7,9 @@ use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::*;
|
||||
use cadcraft_dxf::{Tag, parse_ascii, records, sections, write_ascii};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{read_dxf, write_dxf};
|
||||
use craft_dxf::{Tag, parse_ascii, records, sections, write_ascii};
|
||||
|
||||
fn roundtrip(d: &Drawing) -> Drawing {
|
||||
read_dxf(write_dxf(d).as_bytes()).expect("reopen")
|
||||
|
||||
@@ -46,10 +46,10 @@ fn check(bytes: &[u8], name: &str) -> cadcraft_doc::Drawing {
|
||||
.unwrap();
|
||||
assert!(unknown.tags.iter().any(|t| t.code == 340 && t.value == line.hex()));
|
||||
let dxf = if name.ends_with(".dwg") { cadcraft_dwg::dwg_to_dxf(bytes).unwrap() } else { bytes.to_vec() };
|
||||
let tags = cadcraft_dxf::parse(&dxf).unwrap();
|
||||
let sections = cadcraft_dxf::sections(&tags);
|
||||
let tags = craft_dxf::parse(&dxf).unwrap();
|
||||
let sections = craft_dxf::sections(&tags);
|
||||
let classes = sections.iter().find(|s| s.name == "CLASSES").unwrap();
|
||||
let classes = cadcraft_dxf::records(&classes.tags);
|
||||
let classes = craft_dxf::records(&classes.tags);
|
||||
let decoded = acadrust::DxfReader::from_reader(Cursor::new(dxf)).unwrap().read().unwrap();
|
||||
let proxy = decoded
|
||||
.entities()
|
||||
@@ -119,15 +119,15 @@ fn duplicate_class_dxf() -> Vec<u8> {
|
||||
fn duplicate_names_keep_distinct_classes_for_entities_and_dictionary_objects() {
|
||||
let source = duplicate_class_dxf();
|
||||
for binary in [false, true] {
|
||||
let input = if binary { cadcraft_dxf::write_binary(&cadcraft_dxf::parse(&source).unwrap(), true) } else { source.clone() };
|
||||
let input = if binary { craft_dxf::write_binary(&craft_dxf::parse(&source).unwrap(), true) } else { source.clone() };
|
||||
let mut drawing = cadcraft_io::read(&input, "source.dxf").unwrap();
|
||||
for _ in 0..3 {
|
||||
for name in ["copy.dxf", "copy.dwg"] {
|
||||
let bytes = cadcraft_io::write_with(&drawing, name, &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let dxf = if name.ends_with(".dwg") { cadcraft_dwg::dwg_to_dxf(&bytes).unwrap() } else { bytes.clone() };
|
||||
let tags = cadcraft_dxf::parse(&dxf).unwrap();
|
||||
let sections = cadcraft_dxf::sections(&tags);
|
||||
let classes = cadcraft_dxf::records(§ions.iter().find(|s| s.name == "CLASSES").unwrap().tags);
|
||||
let tags = craft_dxf::parse(&dxf).unwrap();
|
||||
let sections = craft_dxf::sections(&tags);
|
||||
let classes = craft_dxf::records(§ions.iter().find(|s| s.name == "CLASSES").unwrap().tags);
|
||||
let custom: Vec<_> = classes.iter().filter(|(_, t)| t.iter().any(|t| t.code == 1 && t.str() == "CUSTOM")).collect();
|
||||
assert_eq!(custom.len(), 3);
|
||||
for (class, (cpp, flags)) in custom.iter().zip([("First", 1025), ("Second", 128), ("Object", 17)]) {
|
||||
@@ -137,7 +137,7 @@ fn duplicate_names_keep_distinct_classes_for_entities_and_dictionary_objects() {
|
||||
assert!(class.1.iter().any(|t| t.code == 280 && t.i64() == 1));
|
||||
}
|
||||
let records: Vec<_> =
|
||||
sections.iter().flat_map(|s| cadcraft_dxf::records(&s.tags)).filter(|(kind, _)| kind.starts_with("ACAD_PROXY_")).collect();
|
||||
sections.iter().flat_map(|s| craft_dxf::records(&s.tags)).filter(|(kind, _)| kind.starts_with("ACAD_PROXY_")).collect();
|
||||
assert_eq!(records.len(), 3);
|
||||
let mut identities = Vec::new();
|
||||
for (_, tags) in &records {
|
||||
@@ -216,16 +216,13 @@ fn proxy_spelling_is_preserved_in_dxf_and_normalized_only_for_dwg() {
|
||||
let drawing = cadcraft_io::read(dxf.as_bytes(), "source.dxf").unwrap();
|
||||
assert_eq!(drawing.entity_count(), 3);
|
||||
let out = cadcraft_io::write_with(&drawing, "copy.dxf", &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&out).unwrap();
|
||||
let tags = craft_dxf::parse(&out).unwrap();
|
||||
assert!(tags.iter().any(|t| t.code == 0 && t.str() == entity));
|
||||
let bytes = cadcraft_io::write_with(&drawing, "copy.dwg", &SaveOptions { version: Some(Version::R2018), ..Default::default() }).unwrap();
|
||||
let decoded = cadcraft_dwg::dwg_to_dxf(&bytes).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&decoded).unwrap();
|
||||
let proxies: Vec<_> = cadcraft_dxf::sections(&tags)
|
||||
.iter()
|
||||
.flat_map(|s| cadcraft_dxf::records(&s.tags))
|
||||
.filter(|(kind, _)| kind.starts_with("ACAD_PROXY_"))
|
||||
.collect();
|
||||
let tags = craft_dxf::parse(&decoded).unwrap();
|
||||
let proxies: Vec<_> =
|
||||
craft_dxf::sections(&tags).iter().flat_map(|s| craft_dxf::records(&s.tags)).filter(|(kind, _)| kind.starts_with("ACAD_PROXY_")).collect();
|
||||
assert_eq!(proxies.len(), 3);
|
||||
for (_, tags) in proxies {
|
||||
assert!(tags.iter().any(|t| t.code == 310 && t.str() == "A580"));
|
||||
@@ -239,7 +236,7 @@ fn proxy_spelling_is_preserved_in_dxf_and_normalized_only_for_dwg() {
|
||||
/// Exercise the actual ASCII bridge, which writes Unicode as CIF escapes.
|
||||
#[test]
|
||||
fn unicode_class_identity_survives_the_ascii_bridge() {
|
||||
let mut tags = cadcraft_dxf::parse(&duplicate_class_dxf()).unwrap();
|
||||
let mut tags = craft_dxf::parse(&duplicate_class_dxf()).unwrap();
|
||||
for tag in &mut tags {
|
||||
let text = tag.str();
|
||||
let replacement = match (tag.code, text.as_str()) {
|
||||
@@ -251,10 +248,10 @@ fn unicode_class_identity_survives_the_ascii_bridge() {
|
||||
_ => None,
|
||||
};
|
||||
if let Some(text) = replacement {
|
||||
*tag = cadcraft_dxf::Tag::s(tag.code, text);
|
||||
*tag = craft_dxf::Tag::s(tag.code, text);
|
||||
}
|
||||
}
|
||||
let text = cadcraft_dxf::write_ascii(&tags);
|
||||
let text = craft_dxf::write_ascii(&tags);
|
||||
assert!(text.contains("\\U+"));
|
||||
let mut drawing = cadcraft_io::read(text.as_bytes(), "unicode.dxf").unwrap();
|
||||
for _ in 0..2 {
|
||||
|
||||
@@ -217,15 +217,14 @@ pub fn import(app: &mut CadApp, path: &str) -> Result<(), String> {
|
||||
/// then load it. A cancelled picker is a no-op; without a native picker (the web) the error names
|
||||
/// the ALIASEDIT `text` form, since the web has no file system to read a path from.
|
||||
pub fn pick_and_import(app: &mut CadApp) -> Result<(), String> {
|
||||
let dir = app.ui.last_pgp_dir.clone();
|
||||
let picked = app.services.pick_pgp.as_ref().map(|pick| pick(dir.as_deref()));
|
||||
match picked {
|
||||
let dir = app.ui.last_pgp_dir.clone().unwrap_or_default();
|
||||
// Through `pick_for`, so macOS shows it as a sheet (#1527); aliases belong to no drawing.
|
||||
match app.pick_for(crate::Pick::Pgp(dir), false, Box::new(|app, path| import(app, &path).map(|()| serde_json::Value::Null))) {
|
||||
None => Err(refused(
|
||||
app,
|
||||
crate::tl!("Import: there is no file dialog here; run ALIASEDIT with the file's contents using its text parameter.").to_string(),
|
||||
)),
|
||||
Some(None) => Ok(()),
|
||||
Some(Some(path)) => import(app, &path),
|
||||
Some(r) => r.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +449,39 @@ mod tests {
|
||||
let _ = app.run("aliasedit", json!({ "clear": true }));
|
||||
}
|
||||
|
||||
/// Where the host shows dialogs without waiting (macOS sheets, #1527), Import… does not run
|
||||
/// the blocking picker; the file loads once the sheet answers.
|
||||
#[test]
|
||||
fn import_waits_for_a_sheet_instead_of_blocking() {
|
||||
type Done = Box<dyn FnOnce(Option<String>) + Send>;
|
||||
let _guard = alone();
|
||||
let dir = temp_dir("sheet");
|
||||
let path = dir.join("aliases.pgp");
|
||||
std::fs::write(&path, "XXS, *LINE\n").unwrap_or_default();
|
||||
let asked = Rc::new(RefCell::new(Vec::new()));
|
||||
let answer: Rc<RefCell<Option<Done>>> = Rc::default();
|
||||
let (log, slot) = (Rc::clone(&asked), Rc::clone(&answer));
|
||||
let services = Services {
|
||||
pick_pgp: Some(Box::new(|_: Option<&str>| panic!("the blocking picker ran"))),
|
||||
start_pick: Some(Box::new(move |pick, done| {
|
||||
log.borrow_mut().push(pick);
|
||||
*slot.borrow_mut() = Some(done);
|
||||
})),
|
||||
..Services::default()
|
||||
};
|
||||
let mut app = CadApp::new(Session::new(), services);
|
||||
open(&mut app);
|
||||
|
||||
pick_and_import(&mut app).expect("the sheet starts");
|
||||
assert!(matches!(asked.borrow().as_slice(), [crate::Pick::Pgp(_)]));
|
||||
answer.borrow_mut().take().expect("a sheet was started")(Some(path.to_string_lossy().to_string()));
|
||||
app.finish_pick(&egui::Context::default());
|
||||
|
||||
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
|
||||
assert_eq!(st.user.get("xxs").map(String::as_str), Some("line"));
|
||||
clear(&mut app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_loads_the_aliases_shows_the_path_and_warnings() {
|
||||
let _guard = alone();
|
||||
|
||||
@@ -88,6 +88,11 @@ impl CadApp {
|
||||
/// Answer the open close prompt (no-op without one).
|
||||
pub fn answer_close(&mut self, choice: Choice) {
|
||||
let Some(uid) = self.closing.as_ref().and_then(|c| c.asking) else { return };
|
||||
// Its Save dialog is open (macOS): Save again or Don't Save would race it.
|
||||
if choice != Choice::Cancel && self.pending_pick.as_ref().is_some_and(|p| p.for_close) {
|
||||
self.set_status("Answer the Save dialog first.");
|
||||
return;
|
||||
}
|
||||
let i = match (choice, self.doc_index(uid)) {
|
||||
(Choice::Cancel, _) => {
|
||||
self.closing = None;
|
||||
@@ -114,26 +119,53 @@ impl CadApp {
|
||||
// The Block Editor's unsaved changes go into the block first (BSAVE), then the
|
||||
// drawing is saved with them.
|
||||
let r = if self.session.state().is_ok_and(|st| st.block_unsaved()) { self.run("bsave", json!({})) } else { Ok(Value::Null) };
|
||||
let waiting = self.pending_pick.is_some();
|
||||
let r = r.and_then(|_| self.run("qsave", Value::Null));
|
||||
let saved = r.is_ok() && self.doc_index(uid).and_then(|i| self.session.docs.get(i)).is_some_and(|d| !d.is_dirty());
|
||||
match (saved, self.doc_index(uid)) {
|
||||
(true, Some(i)) => {
|
||||
self.close_front(i);
|
||||
self.advance_close();
|
||||
}
|
||||
_ => {
|
||||
// Not saved (picker cancelled, save failed or unavailable): keep the
|
||||
// drawing and its edits, and stop closing.
|
||||
self.closing = None;
|
||||
let title = self.doc_index(uid).and_then(|i| self.session.docs.get(i)).map(|d| d.title.clone()).unwrap_or_default();
|
||||
let msg = match r {
|
||||
Err(e) => format!("{title} was not saved ({e}); it stays open."),
|
||||
Ok(_) => format!("{title} was not saved; it stays open."),
|
||||
};
|
||||
self.session.echo(msg.clone());
|
||||
self.set_status(msg);
|
||||
}
|
||||
// The Save dialog opened without waiting (macOS): closing goes on once it has
|
||||
// saved; cancelling it leaves the prompt up.
|
||||
// The dialog is bound to this drawing: if it is closed meanwhile, nothing is
|
||||
// saved and the prompt goes on with the next drawing.
|
||||
if !waiting && let Some(mut pending) = self.pending_pick.take() {
|
||||
let then = pending.then;
|
||||
pending.for_close = true;
|
||||
pending.then = Box::new(move |app: &mut CadApp, path| {
|
||||
let r = then(app, path);
|
||||
// Unless the prompt was answered otherwise meanwhile.
|
||||
if app.closing.as_ref().and_then(|c| c.asking) == Some(uid) {
|
||||
// It reports the outcome itself; an error returned here would
|
||||
// replace its message in the status bar.
|
||||
app.close_after_save(uid, r);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
r
|
||||
});
|
||||
self.pending_pick = Some(pending);
|
||||
return;
|
||||
}
|
||||
self.close_after_save(uid, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Go on closing after the drawing `uid` was asked to save with result `r`.
|
||||
fn close_after_save(&mut self, uid: u64, r: Result<Value, String>) {
|
||||
let saved = r.is_ok() && self.doc_index(uid).and_then(|i| self.session.docs.get(i)).is_some_and(|d| !d.is_dirty());
|
||||
match (saved, self.doc_index(uid)) {
|
||||
(true, Some(i)) => {
|
||||
self.close_front(i);
|
||||
self.advance_close();
|
||||
}
|
||||
_ => {
|
||||
// Not saved (picker cancelled, save failed or unavailable): keep the
|
||||
// drawing and its edits, and stop closing.
|
||||
self.closing = None;
|
||||
let title = self.doc_index(uid).and_then(|i| self.session.docs.get(i)).map(|d| d.title.clone()).unwrap_or_default();
|
||||
let msg = match r {
|
||||
Err(e) => format!("{title} was not saved ({e}); it stays open."),
|
||||
Ok(_) => format!("{title} was not saved; it stays open."),
|
||||
};
|
||||
self.session.echo(msg.clone());
|
||||
self.set_status(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +296,44 @@ mod tests {
|
||||
app.session.docs.iter().map(|d| d.title.clone()).collect()
|
||||
}
|
||||
|
||||
type Done = Box<dyn FnOnce(Option<String>) + Send>;
|
||||
|
||||
/// The host shows its Save dialog without waiting (macOS): the callback that answers it.
|
||||
fn deferred_save(app: &mut CadApp) -> std::rc::Rc<std::cell::RefCell<Option<Done>>> {
|
||||
let slot: std::rc::Rc<std::cell::RefCell<Option<Done>>> = std::rc::Rc::default();
|
||||
let s = slot.clone();
|
||||
app.services = Services {
|
||||
pick_save: Some(Box::new(|_: &str| None)),
|
||||
start_pick: Some(Box::new(move |_, done| *s.borrow_mut() = Some(done))),
|
||||
..Services::default()
|
||||
};
|
||||
slot
|
||||
}
|
||||
|
||||
/// While the prompt's Save dialog is open, Don't Save (or Save again) does not race it; when
|
||||
/// the save then fails, the prompt's own "was not saved" message is what the status bar shows.
|
||||
#[test]
|
||||
fn the_close_prompt_waits_for_its_save_dialog() {
|
||||
let mut app = app_with(&["A", "B"]);
|
||||
let answer = deferred_save(&mut app);
|
||||
dirty(&mut app, 0);
|
||||
app.request_close(Some(0));
|
||||
app.answer_close(Choice::Save);
|
||||
assert!(answer.borrow().is_some(), "the Save dialog is open");
|
||||
app.answer_close(Choice::Discard);
|
||||
app.answer_close(Choice::Save);
|
||||
assert_eq!(titles(&app), ["A", "B"], "Don't Save waits for the Save dialog");
|
||||
assert_eq!(app.close_prompt(), Some("A"));
|
||||
|
||||
let nowhere = std::env::temp_dir().join(format!("cadcraft-no-such-dir-{}", std::process::id())).join("A.dxf");
|
||||
answer.borrow_mut().take().unwrap()(Some(nowhere.to_string_lossy().to_string()));
|
||||
app.finish_pick(&egui::Context::default());
|
||||
assert_eq!(titles(&app), ["A", "B"], "not saved, so not closed");
|
||||
assert!(app.session.docs[0].is_dirty());
|
||||
let status = app.status.as_ref().map(|(s, _)| s.as_str()).unwrap_or_default();
|
||||
assert!(status.starts_with("A was not saved") && status.ends_with("it stays open."), "{status}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_drawing_closes_without_prompt() {
|
||||
let mut app = app_with(&["A", "B"]);
|
||||
|
||||
+176
-23
@@ -7,7 +7,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use egui::TextureHandle;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::{CadApp, Pick};
|
||||
|
||||
/// Bumped each time PREVIEW is started, so the window renders the drawing as it is now.
|
||||
static PREVIEW_GEN: AtomicU32 = AtomicU32::new(0);
|
||||
@@ -23,27 +23,15 @@ pub fn route(app: &mut CadApp, id: &str) -> Result<Value, String> {
|
||||
app.ui.dialog = Some("preview".into());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"import" | "imp" => {
|
||||
let Some(pick) = app.services.pick_open.as_ref() else { return no_picker(app, "IMPORT", r#"import {"path": "parts.dxf"}"#) };
|
||||
match pick() {
|
||||
Some(path) => app.run("import", json!({ "path": path })),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
"publish" => {
|
||||
let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "PUBLISH", "publish {}") };
|
||||
match pick(&format!("{stem}.pdf")) {
|
||||
Some(path) => app.run("publish", json!({ "path": path })),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "EXPORT", r#"export {"format": "svg"}"#) };
|
||||
match pick(&format!("{stem}.svg")) {
|
||||
Some(path) => app.run("export", json!({ "path": path })),
|
||||
None => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
"import" | "imp" => app
|
||||
.pick_then(Pick::Open, |app, path| app.run("import", json!({ "path": path })))
|
||||
.unwrap_or_else(|| no_picker(app, "IMPORT", r#"import {"path": "parts.dxf"}"#)),
|
||||
"publish" => app
|
||||
.pick_then(Pick::Save(format!("{stem}.pdf")), |app, path| app.run("publish", json!({ "path": path })))
|
||||
.unwrap_or_else(|| no_picker(app, "PUBLISH", "publish {}")),
|
||||
_ => app
|
||||
.pick_then(Pick::Save(format!("{stem}.svg")), |app, path| app.run("export", json!({ "path": path })))
|
||||
.unwrap_or_else(|| no_picker(app, "EXPORT", r#"export {"format": "svg"}"#)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +149,7 @@ mod tests {
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{CadApp, Services};
|
||||
use crate::{CadApp, Pick, Services};
|
||||
|
||||
/// An app whose file dialogs record what they were asked and cancel.
|
||||
fn app() -> (CadApp, Rc<RefCell<Vec<String>>>) {
|
||||
@@ -207,4 +195,169 @@ mod tests {
|
||||
let e = app.run("share", json!({})).unwrap_err();
|
||||
assert!(e.contains("not available yet"), "{e}");
|
||||
}
|
||||
|
||||
type Done = Box<dyn FnOnce(Option<String>) + Send>;
|
||||
|
||||
/// An app whose host shows its dialogs without waiting (macOS, #1527): the dialogs asked
|
||||
/// for, and the callback that answers the open one.
|
||||
fn deferred_app() -> (CadApp, Rc<RefCell<Vec<Pick>>>, Rc<RefCell<Option<Done>>>) {
|
||||
let asked = Rc::new(RefCell::new(Vec::new()));
|
||||
let answer: Rc<RefCell<Option<Done>>> = Rc::default();
|
||||
let (log, slot) = (asked.clone(), answer.clone());
|
||||
let services = Services {
|
||||
pick_open: Some(Box::new(|| None)),
|
||||
pick_save: Some(Box::new(|_: &str| None)),
|
||||
pick_lisp: Some(Box::new(|_: &str| None)),
|
||||
start_pick: Some(Box::new(move |pick, done| {
|
||||
log.borrow_mut().push(pick);
|
||||
*slot.borrow_mut() = Some(done);
|
||||
})),
|
||||
..Services::default()
|
||||
};
|
||||
(CadApp::new(Session::new(), services), asked, answer)
|
||||
}
|
||||
|
||||
fn status(app: &CadApp) -> &str {
|
||||
app.status.as_ref().map(|(s, _)| s.as_str()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn uid(app: &CadApp) -> u64 {
|
||||
app.session.state().unwrap().uid
|
||||
}
|
||||
|
||||
/// Where the host shows its dialogs without waiting (macOS, #1527), the command acts on the
|
||||
/// path in a later frame, and no second dialog opens while one is up.
|
||||
#[test]
|
||||
fn a_deferred_dialog_acts_when_its_answer_arrives() {
|
||||
let (mut app, asked, answer) = deferred_app();
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
// Cancelled: nothing happens, and the next command may ask again.
|
||||
app.start("export");
|
||||
assert_eq!(*asked.borrow(), [Pick::Save("Drawing1.svg".into())]);
|
||||
answer.borrow_mut().take().unwrap()(None);
|
||||
app.finish_pick(&ctx);
|
||||
assert!(app.pending_pick.is_none());
|
||||
|
||||
app.start("imp");
|
||||
app.finish_pick(&ctx);
|
||||
assert!(app.pending_pick.is_some() && app.status.is_none(), "nothing runs before the answer");
|
||||
// A second dialog is refused, and says so: in the status bar for a typed or menu
|
||||
// command, and as an error for a programmatic call.
|
||||
app.start("publish");
|
||||
assert!(status(&app).contains("already open"), "{:?}", app.status);
|
||||
let e = app.run("ui.open", json!(null)).unwrap_err();
|
||||
assert!(e.contains("already open"), "{e}");
|
||||
assert_eq!(asked.borrow().len(), 2, "no second dialog while one is open");
|
||||
app.status = None;
|
||||
let missing = std::env::temp_dir().join(format!("cadcraft-missing-{}.dxf", std::process::id()));
|
||||
answer.borrow_mut().take().unwrap()(Some(missing.to_string_lossy().to_string()));
|
||||
app.finish_pick(&ctx);
|
||||
assert!(app.pending_pick.is_none());
|
||||
assert!(app.status.is_some(), "IMPORT ran with the picked file and reported its error");
|
||||
}
|
||||
|
||||
/// A dialog's answer acts on the drawing that asked for it, even when another drawing was
|
||||
/// made active before it arrived (a control-channel `document.switch` runs before the answer
|
||||
/// in the same frame): Save As must not write the other drawing to the chosen file.
|
||||
#[test]
|
||||
fn a_deferred_dialog_acts_on_the_drawing_that_asked() {
|
||||
let (mut app, _, answer) = deferred_app();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("new", json!({})).unwrap();
|
||||
app.run("document.switch", json!({ "index": 0 })).unwrap();
|
||||
let first = uid(&app);
|
||||
let acted_on = Rc::new(RefCell::new(None));
|
||||
let seen = acted_on.clone();
|
||||
let r = app.pick_then(Pick::Save("Drawing1.dxf".into()), move |app, path| {
|
||||
*seen.borrow_mut() = Some((uid(app), path));
|
||||
Ok(json!(null))
|
||||
});
|
||||
assert_eq!(r, Some(Ok(json!(null))));
|
||||
|
||||
app.run("document.switch", json!({ "index": 1 })).unwrap();
|
||||
assert_ne!(uid(&app), first);
|
||||
answer.borrow_mut().take().unwrap()(Some("a.dxf".into()));
|
||||
app.finish_pick(&ctx);
|
||||
assert_eq!(*acted_on.borrow(), Some((first, "a.dxf".to_string())));
|
||||
assert_eq!(app.session.active, 0, "the drawing that asked is shown again");
|
||||
}
|
||||
|
||||
/// The drawing that asked was closed before the answer: nothing is done, and the status bar
|
||||
/// says so. A dialog that opens a drawing (OPEN, NEW from a template) still acts.
|
||||
#[test]
|
||||
fn a_deferred_dialog_for_a_closed_drawing_does_nothing() {
|
||||
let (mut app, _, answer) = deferred_app();
|
||||
let ctx = egui::Context::default();
|
||||
app.run("new", json!({})).unwrap();
|
||||
let ran = Rc::new(RefCell::new(0));
|
||||
let r1 = ran.clone();
|
||||
let _ = app.pick_then(Pick::Save("Drawing2.svg".into()), move |_, _| {
|
||||
*r1.borrow_mut() += 1;
|
||||
Ok(json!(null))
|
||||
});
|
||||
app.run("close", json!({ "index": 1 })).unwrap();
|
||||
answer.borrow_mut().take().unwrap()(Some("b.svg".into()));
|
||||
app.finish_pick(&ctx);
|
||||
assert_eq!(*ran.borrow(), 0, "the closed drawing's export did not run on another drawing");
|
||||
assert!(app.pending_pick.is_none());
|
||||
assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("closed") && s.contains("b.svg")), "{:?}", app.status);
|
||||
|
||||
app.run("new", json!({})).unwrap();
|
||||
let r2 = ran.clone();
|
||||
let _ = app.pick_then_open(Pick::Open, move |_, _| {
|
||||
*r2.borrow_mut() += 1;
|
||||
Ok(json!(null))
|
||||
});
|
||||
app.run("close", json!({ "index": 1 })).unwrap();
|
||||
answer.borrow_mut().take().unwrap()(Some("c.dxf".into()));
|
||||
app.finish_pick(&ctx);
|
||||
assert_eq!(*ran.borrow(), 1, "OPEN goes on whichever drawing closed meanwhile");
|
||||
}
|
||||
|
||||
/// The host dropped the answer (its dialog thread could not start): the status bar says
|
||||
/// the dialog could not be shown, unlike a cancelled dialog, and the next one may open.
|
||||
#[test]
|
||||
fn a_dialog_that_never_answers_is_reported() {
|
||||
let services =
|
||||
Services { pick_save: Some(Box::new(|_: &str| None)), start_pick: Some(Box::new(|_, done| drop(done))), ..Services::default() };
|
||||
let mut app = CadApp::new(Session::new(), services);
|
||||
app.start("export");
|
||||
app.finish_pick(&egui::Context::default());
|
||||
assert!(app.pending_pick.is_none());
|
||||
assert!(status(&app).contains("could not be shown"), "{:?}", app.status);
|
||||
}
|
||||
|
||||
/// With no drawing open, a dialog bound to the active drawing is not shown: its answer could
|
||||
/// otherwise save or export a drawing opened while it was up.
|
||||
#[test]
|
||||
fn a_bound_dialog_needs_an_open_drawing() {
|
||||
let (mut app, asked, _) = deferred_app();
|
||||
app.run("close", json!({ "index": 0 })).unwrap();
|
||||
assert!(app.session.state().is_err());
|
||||
app.start("ui.saveas");
|
||||
let r = app.run("ui.saveas", json!(null));
|
||||
assert!(asked.borrow().is_empty(), "no dialog");
|
||||
assert!(app.pending_pick.is_none());
|
||||
assert!(r.is_err() && app.status.is_some(), "{r:?} {:?}", app.status);
|
||||
// OPEN needs no drawing.
|
||||
app.start("ui.open");
|
||||
assert_eq!(*asked.borrow(), [Pick::Open]);
|
||||
}
|
||||
|
||||
/// A command started while APPLOAD's dialog was open (a control request, say) keeps
|
||||
/// running; APPLOAD is refused and says to run it again.
|
||||
#[test]
|
||||
fn appload_does_not_cancel_a_command_started_meanwhile() {
|
||||
let (mut app, asked, answer) = deferred_app();
|
||||
let ctx = egui::Context::default();
|
||||
app.start("appload");
|
||||
assert!(matches!(asked.borrow().as_slice(), [Pick::Lisp(_)]));
|
||||
app.start("line");
|
||||
assert!(app.session.prompting());
|
||||
answer.borrow_mut().take().unwrap()(Some("routine.lsp".into()));
|
||||
app.finish_pick(&ctx);
|
||||
assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("line"), "LINE was not cancelled");
|
||||
assert!(status(&app).starts_with("APPLOAD: another command started"), "{:?}", app.status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,12 @@ impl Default for UiState {
|
||||
pub struct Services {
|
||||
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
/// Where set, the file dialogs are shown with this instead of calling `pick_open` /
|
||||
/// `pick_save` / `pick_lisp` (which must still be set, to say which dialogs there are), and
|
||||
/// the answer is acted on in a later frame ([`CadApp::pick_then`]). macOS needs it: a panel run modally
|
||||
/// inside the window's event handler gets the window events of its own resizing sent back
|
||||
/// into that handler, and winit aborts the app (#1527).
|
||||
pub start_pick: Option<StartPick>,
|
||||
/// Pick a `.lsp` file for APPLOAD, starting in the given folder (empty for the default one).
|
||||
/// `None` where there is no native file dialog: the desktop without one asks for the name,
|
||||
/// and the web (no file system) says to drop the file (`no_file_system`).
|
||||
@@ -238,6 +244,36 @@ pub struct Services {
|
||||
pub pick_pgp: Option<Box<dyn Fn(Option<&str>) -> Option<String>>>,
|
||||
}
|
||||
|
||||
/// The file dialog to show: Open, Save with the suggested file name, APPLOAD's `.lsp` picker or
|
||||
/// ALIASEDIT's `.pgp` picker, starting in the given folder (empty for the default one).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Pick {
|
||||
Open,
|
||||
Save(String),
|
||||
Lisp(String),
|
||||
Pgp(String),
|
||||
}
|
||||
|
||||
/// Shows a file dialog without waiting for it and calls the callback (from any thread) with the
|
||||
/// chosen path, or `None` when the dialog is cancelled ([`Services::start_pick`]).
|
||||
pub type StartPick = Box<dyn Fn(Pick, Box<dyn FnOnce(Option<String>) + Send>)>;
|
||||
|
||||
/// What to do with the path a file dialog chose ([`CadApp::pick_then`]).
|
||||
type PickDone = Box<dyn FnOnce(&mut CadApp, String) -> Result<Value, String>>;
|
||||
|
||||
/// A file dialog shown with [`Services::start_pick`] that has not answered yet.
|
||||
struct PendingPick {
|
||||
answer: Receiver<Option<String>>,
|
||||
/// The drawing (its `uid`) the answer is for: Save As writes that drawing, export and plot
|
||||
/// read it, and so on, whichever drawing is active when the answer arrives. `None` for the
|
||||
/// dialogs that open a drawing (Open, New from Template, Recover).
|
||||
doc: Option<u64>,
|
||||
/// The close prompt's Save is waiting for this dialog: its other answers wait too
|
||||
/// ([`closing`]).
|
||||
for_close: bool,
|
||||
then: PickDone,
|
||||
}
|
||||
|
||||
pub struct CadApp {
|
||||
pub system_languages: Vec<String>,
|
||||
/// Whether the operating system shows animations, as the host reads it; `None` when the OS
|
||||
@@ -329,6 +365,8 @@ pub struct CadApp {
|
||||
pub hatch_dlg: Option<hatchdlg::HatchDlgState>,
|
||||
/// The Edit Block Definition dialog ([`bedit`]).
|
||||
pub bedit: bedit::State,
|
||||
/// A file dialog shown with [`Services::start_pick`]: its answer, and what to do with it.
|
||||
pending_pick: Option<PendingPick>,
|
||||
/// The startup update check and its prompt (desktop; [`updates_ui`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub updates_rt: updates_ui::Runtime,
|
||||
@@ -389,9 +427,139 @@ impl CadApp {
|
||||
bedit: bedit::State::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime::default(),
|
||||
pending_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for a file with the host's dialog (`pick`) and run `then` with the chosen path, on the
|
||||
/// drawing that is active now. `None` when the host has no dialog of that kind. Cancelling
|
||||
/// gives `Ok(Null)`. With a synchronous picker the result is `then`'s; with
|
||||
/// [`Services::start_pick`] the dialog opens and this returns `Ok(Null)` at once: `then` runs
|
||||
/// in a later frame ([`Self::logic`]) with that drawing made active again (if something else
|
||||
/// was switched to meanwhile, such as a control-channel `document.switch`), and not at all if
|
||||
/// the drawing was closed. Its error, if any, shows in the status bar. With no drawing open
|
||||
/// it refuses without showing a dialog. While one dialog is open, no other is shown: the
|
||||
/// request is refused with an error, also shown in the status bar.
|
||||
pub fn pick_then(
|
||||
&mut self,
|
||||
pick: Pick,
|
||||
then: impl FnOnce(&mut CadApp, String) -> Result<Value, String> + 'static,
|
||||
) -> Option<Result<Value, String>> {
|
||||
self.pick_for(pick, true, Box::new(then))
|
||||
}
|
||||
|
||||
/// [`Self::pick_then`] for a dialog that opens a drawing (Open, New from Template, Recover):
|
||||
/// `then` does not act on the drawing that was active, so it runs whatever was switched to
|
||||
/// or closed meanwhile.
|
||||
pub fn pick_then_open(
|
||||
&mut self,
|
||||
pick: Pick,
|
||||
then: impl FnOnce(&mut CadApp, String) -> Result<Value, String> + 'static,
|
||||
) -> Option<Result<Value, String>> {
|
||||
self.pick_for(pick, false, Box::new(then))
|
||||
}
|
||||
|
||||
/// Show the dialog `pick` for `then`, bound to the active drawing when `bound`.
|
||||
fn pick_for(&mut self, pick: Pick, bound: bool, then: PickDone) -> Option<Result<Value, String>> {
|
||||
let available = match pick {
|
||||
Pick::Open => self.services.pick_open.is_some(),
|
||||
Pick::Save(_) => self.services.pick_save.is_some(),
|
||||
Pick::Lisp(_) => self.services.pick_lisp.is_some(),
|
||||
Pick::Pgp(_) => self.services.pick_pgp.is_some(),
|
||||
};
|
||||
if !available {
|
||||
return None;
|
||||
}
|
||||
if self.pending_pick.is_some() {
|
||||
return Some(Err(self.refuse_pick("A file dialog is already open; answer it first.".into())));
|
||||
}
|
||||
// A bound answer must not land on a drawing created or opened while the dialog is up.
|
||||
let doc = match (bound, self.session.state()) {
|
||||
(false, _) => None,
|
||||
(true, Ok(st)) => Some(st.uid),
|
||||
(true, Err(e)) => {
|
||||
let why = e.to_string();
|
||||
return Some(Err(self.refuse_pick(why)));
|
||||
}
|
||||
};
|
||||
if let Some(start) = &self.services.start_pick {
|
||||
let (tx, answer) = std::sync::mpsc::channel();
|
||||
start(
|
||||
pick,
|
||||
Box::new(move |path| {
|
||||
let _ = tx.send(path);
|
||||
}),
|
||||
);
|
||||
self.pending_pick = Some(PendingPick { answer, doc, for_close: false, then });
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let path = match &pick {
|
||||
Pick::Open => self.services.pick_open.as_ref().and_then(|f| f()),
|
||||
Pick::Save(name) => self.services.pick_save.as_ref().and_then(|f| f(name)),
|
||||
Pick::Lisp(dir) => self.services.pick_lisp.as_ref().and_then(|f| f(dir)),
|
||||
Pick::Pgp(dir) => self.services.pick_pgp.as_ref().and_then(|f| f(Some(dir.as_str()).filter(|d| !d.is_empty()))),
|
||||
};
|
||||
Some(match path {
|
||||
Some(path) => then(self, path),
|
||||
None => Ok(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
/// A file dialog that is not shown: the reason goes to the status bar too, since a typed or
|
||||
/// menu command's result is not shown ([`Self::start`]).
|
||||
fn refuse_pick(&mut self, why: String) -> String {
|
||||
self.set_status(why.clone());
|
||||
why
|
||||
}
|
||||
|
||||
/// Start the interactive command `id` with a dialog's answer. `was_prompting`: whether a
|
||||
/// command was running when the dialog was asked for. Starting cancels the running command,
|
||||
/// so a command started while the dialog was open (a control request, say) is kept and this
|
||||
/// one refused instead; one that was already running is cancelled as when the dialog answers
|
||||
/// at once.
|
||||
pub fn start_after_pick(&mut self, id: &str, was_prompting: bool) -> Result<(), String> {
|
||||
if !was_prompting && self.session.prompting() {
|
||||
return Err(format!("{}: another command started while the file dialog was open; run it again.", id.to_ascii_uppercase()));
|
||||
}
|
||||
self.session.start(id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Act on the answer of a dialog started with [`Services::start_pick`], once it arrives.
|
||||
fn finish_pick(&mut self, ctx: &egui::Context) {
|
||||
let Some(pending) = &self.pending_pick else { return };
|
||||
match pending.answer.try_recv() {
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(100)),
|
||||
Ok(None) => self.pending_pick = None,
|
||||
// The host dropped the answer without giving one: the dialog never opened (its
|
||||
// thread could not start) or failed.
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
self.pending_pick = None;
|
||||
self.set_status("The file dialog could not be shown.");
|
||||
}
|
||||
Ok(Some(path)) => {
|
||||
if let Some(PendingPick { doc, then, .. }) = self.pending_pick.take()
|
||||
&& let Err(e) = self.make_active(doc, &path).and_then(|()| then(self, path))
|
||||
{
|
||||
self.set_status(e);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the drawing `uid` active again for the answer `path` of a dialog it asked for, or
|
||||
/// refuse when it was closed meanwhile.
|
||||
fn make_active(&mut self, uid: Option<u64>, path: &str) -> Result<(), String> {
|
||||
let Some(uid) = uid else { return Ok(()) };
|
||||
let Some(i) = self.session.docs.iter().position(|d| d.uid == uid) else {
|
||||
return Err(format!("The drawing was closed before the file dialog answered; nothing was done with {path}."));
|
||||
};
|
||||
if self.session.active != i {
|
||||
self.run("document.switch", json!({ "index": i }))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The interface language shown: the chosen one, or English when this computer has no font
|
||||
/// for it (Chinese or Korean without a suitable font would be boxes).
|
||||
pub fn language(&self) -> &'static str {
|
||||
@@ -702,6 +870,7 @@ impl CadApp {
|
||||
// Consume-based queues: each item is handled once, whichever callback delivers it. A
|
||||
// reply arriving during a later pass is never starved until the next frame.
|
||||
self.drain_control(ctx);
|
||||
self.finish_pick(ctx);
|
||||
self.finish_opening();
|
||||
// Finishing an open switches to the new drawing.
|
||||
capture::note_identity(self, ctx);
|
||||
|
||||
@@ -11,9 +11,9 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use egui::{RichText, Sense, Stroke, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::parametric::expr_field;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{CadApp, Pick};
|
||||
|
||||
/// The Block Attribute Manager (BATTMAN).
|
||||
mod battman;
|
||||
@@ -121,6 +121,7 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
let library: Vec<String> = cadcraft_doc::library::standard_linetypes().into_iter().map(|l| l.name).filter(|n| d.linetype(n).is_none()).collect();
|
||||
let mut sel = temp::<String>(ctx, "ltmgr_sel").filter(|s| rows.iter().any(|r| r.0.eq_ignore_ascii_case(s))).unwrap_or_else(|| current.clone());
|
||||
let mut action = None;
|
||||
let mut pick_lin = false;
|
||||
egui::Window::new("Linetype Manager").open(open).default_size(vec2(520.0, 360.0)).resizable(true).show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
craft_ui::ComboBox::from_id_salt("ltmgr_load").selected_text("Load...").width(110.0).show_ui(ui, |ui| {
|
||||
@@ -135,12 +136,10 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
}
|
||||
}
|
||||
// The user's own .lin file (where the host has a file picker).
|
||||
if let Some(pick) = app.services.pick_open.as_ref() {
|
||||
if app.services.pick_open.is_some() {
|
||||
ui.separator();
|
||||
if ui.selectable_label(false, "From File...").clicked()
|
||||
&& let Some(path) = pick()
|
||||
{
|
||||
action = Some(("linetype", json!({ "load": "*", "file": path })));
|
||||
if ui.selectable_label(false, "From File...").clicked() {
|
||||
pick_lin = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -187,6 +186,10 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
});
|
||||
});
|
||||
set_temp(ctx, "ltmgr_sel", sel);
|
||||
// The user's .lin file, asked for once the window is drawn. Errors are echoed by `run`.
|
||||
if pick_lin {
|
||||
let _ = app.pick_then(Pick::Open, |app, path| app.run("linetype", json!({ "load": "*", "file": path })));
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
@@ -1038,33 +1041,46 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
return action;
|
||||
}
|
||||
if ok {
|
||||
let mut params = plot_params(&st, None);
|
||||
if let Some(pick) = app.services.pick_save.as_ref() {
|
||||
let params = plot_params(&st, None);
|
||||
let target = st.target.clone();
|
||||
if app.services.pick_save.is_some() {
|
||||
let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();
|
||||
let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty());
|
||||
let suggested = format!("{}-{}.{ext}", stem.unwrap_or_else(|| "Drawing".into()), st.target);
|
||||
// Cancelling the file dialog keeps the Plot dialog open with its settings.
|
||||
let Some(path) = pick(&suggested) else { return action };
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
}
|
||||
}
|
||||
*open = false;
|
||||
match app.run("plot", params) {
|
||||
Ok(r) => {
|
||||
let msg = match r.get("path").and_then(Value::as_str) {
|
||||
Some(path) => format!("Plotted {} to {path}", st.target),
|
||||
None => format!("Plotted {} ({} bytes of PDF; there is no file dialog here)", st.target, r["bytes"]),
|
||||
};
|
||||
app.session.echo(msg);
|
||||
}
|
||||
// `run` already echoed the error; keep the dialog open to fix the settings.
|
||||
Err(_) => *open = true,
|
||||
let _ = app.pick_then(Pick::Save(suggested), move |app, path| {
|
||||
let mut params = params;
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
}
|
||||
run_plot(app, params, &target);
|
||||
Ok(Value::Null)
|
||||
});
|
||||
} else {
|
||||
run_plot(app, params, &target);
|
||||
}
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
/// Plot with the Plot dialog's settings: it closes, and opens again when the plot fails.
|
||||
fn run_plot(app: &mut CadApp, params: Value, target: &str) {
|
||||
if app.ui.dialog.as_deref() == Some("plot") {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
match app.run("plot", params) {
|
||||
Ok(r) => {
|
||||
let msg = match r.get("path").and_then(Value::as_str) {
|
||||
Some(path) => format!("Plotted {target} to {path}"),
|
||||
None => format!("Plotted {target} ({} bytes of PDF; there is no file dialog here)", r["bytes"]),
|
||||
};
|
||||
app.session.echo(msg);
|
||||
}
|
||||
// `run` already echoed the error; keep the dialog open to fix the settings.
|
||||
Err(_) => app.ui.dialog = Some("plot".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet in the chosen orientation with its printable area, and its size.
|
||||
fn sheet_sketch(ui: &mut egui::Ui, page: &cadcraft_doc::PageSetup, t: &Tokens) {
|
||||
let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };
|
||||
|
||||
+35
-39
@@ -3,7 +3,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::{CadApp, Pick};
|
||||
|
||||
/// The top-level menus, in order.
|
||||
pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools", "Draw", "Dimension", "Modify", "Window", "Help"];
|
||||
@@ -405,13 +405,12 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let r = match id {
|
||||
"ui.open" | "open" if no_path => {
|
||||
let picked = app.services.pick_open.as_ref().and_then(|f| f());
|
||||
if let Some(p) = picked {
|
||||
"ui.open" | "open" if no_path => app
|
||||
.pick_then_open(Pick::Open, |app, p| {
|
||||
app.open_path(std::path::Path::new(&p));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap_or(Ok(Value::Null)),
|
||||
// APPLOAD typed or chosen from a menu: the `.lsp` file picker, starting where the last
|
||||
// one came from. JSON calls carry `path` and never open one. A picked file is loaded as a
|
||||
// typed name is, through the interactive command, so a routine it starts that asks for a
|
||||
@@ -423,57 +422,54 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
||||
app.set_status(hint);
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let pick = app.services.pick_lisp.as_ref()?;
|
||||
let dir = app.ui.last_lisp_dir.clone().unwrap_or_default();
|
||||
let Some(path) = pick(&dir) else { return Some(Ok(Value::Null)) };
|
||||
if let Some(dir) = std::path::Path::new(&path).parent().and_then(std::path::Path::to_str).filter(|d| !d.is_empty()) {
|
||||
app.ui.last_lisp_dir = Some(dir.to_string());
|
||||
}
|
||||
if let Err(e) = app.session.start("appload") {
|
||||
return Some(Err(e.to_string()));
|
||||
}
|
||||
app.cmdline(&path);
|
||||
Ok(Value::Null)
|
||||
let was_prompting = app.session.prompting();
|
||||
return app.pick_then(Pick::Lisp(dir), move |app, path| {
|
||||
if let Some(dir) = std::path::Path::new(&path).parent().and_then(std::path::Path::to_str).filter(|d| !d.is_empty()) {
|
||||
app.ui.last_lisp_dir = Some(dir.to_string());
|
||||
}
|
||||
app.start_after_pick("appload", was_prompting)?;
|
||||
app.cmdline(&path);
|
||||
Ok(Value::Null)
|
||||
});
|
||||
}
|
||||
// Pick a template (.dwt or .dxf) and start a new drawing from it.
|
||||
"ui.newtemplate" => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
return Some(app.session.execute("new", &json!({ "template": p })).map_err(|e| e.to_string()));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"ui.newtemplate" => app
|
||||
.pick_then_open(Pick::Open, |app, p| app.session.execute("new", &json!({ "template": p })).map_err(|e| e.to_string()))
|
||||
.unwrap_or(Ok(Value::Null)),
|
||||
// RECOVER from the menu or typed: pick the file. Without a picker the engine asks for the
|
||||
// file name on the command line.
|
||||
"recover" if no_path && app.services.pick_open.is_some() => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
"recover" if no_path && app.services.pick_open.is_some() => app
|
||||
.pick_then_open(Pick::Open, |app, p| {
|
||||
let r = app.run("recover", json!({ "path": p }));
|
||||
app.opened(r);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap_or(Ok(Value::Null)),
|
||||
"ui.saveas" | "saveas" if no_path => {
|
||||
let title = app.session.state().map(|s| s.title.clone()).ok();
|
||||
let name = app.ui.save_format.suggested_name(title.as_deref());
|
||||
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name)) {
|
||||
return Some(app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string()));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
// Bound to this drawing: the answer saves it, not whichever drawing is active then.
|
||||
app.pick_then(Pick::Save(name), |app, p| {
|
||||
app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string())
|
||||
})
|
||||
.unwrap_or(Ok(Value::Null))
|
||||
}
|
||||
"qsave" if no_path => return user_qsave(app),
|
||||
// EXPORTPDF from the menu or typed: ask where to save the PDF (PLOT and PRINT open the Plot
|
||||
// dialog, `managers::route`). JSON calls (scripts, control channel, MCP) always carry
|
||||
// params, never get here and never open a dialog.
|
||||
"exportpdf" if params.is_null() && app.session.state().is_ok() => {
|
||||
let pick = app.services.pick_save.as_ref()?;
|
||||
let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();
|
||||
let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty());
|
||||
let Some(path) = pick(&format!("{}.pdf", stem.unwrap_or_else(|| "Drawing".into()))) else { return Some(Ok(Value::Null)) };
|
||||
let r = app.session.execute("exportpdf", &json!({ "path": path })).map_err(|e| e.to_string());
|
||||
app.session.echo(match &r {
|
||||
Ok(_) => format!("Plotted to {path}"),
|
||||
Err(e) => e.clone(),
|
||||
return app.pick_then(Pick::Save(format!("{}.pdf", stem.unwrap_or_else(|| "Drawing".into()))), |app, path| {
|
||||
let r = app.session.execute("exportpdf", &json!({ "path": path })).map_err(|e| e.to_string());
|
||||
app.session.echo(match &r {
|
||||
Ok(_) => format!("Plotted to {path}"),
|
||||
Err(e) => e.clone(),
|
||||
});
|
||||
r
|
||||
});
|
||||
r
|
||||
}
|
||||
"ui.sample" => {
|
||||
let d = cadcraft_engine::sample::default_sample();
|
||||
|
||||
+13
-14
@@ -6,8 +6,8 @@
|
||||
use egui::RichText;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::docking::Panel;
|
||||
use crate::{CadApp, Pick};
|
||||
|
||||
/// XREF typed or chosen from a menu opens this palette; XATTACH there asks for the file first
|
||||
/// (with the host's file picker, where there is one). JSON calls (with parameters) run the
|
||||
@@ -19,19 +19,18 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
|
||||
match cadcraft_engine::cmd::resolve_alias(id).as_str() {
|
||||
"xref" => Some(open_palette(app)),
|
||||
"xattach" => {
|
||||
let picked = app.services.pick_open.as_ref().and_then(|f| f())?;
|
||||
let path = relative_to_host(app, &picked);
|
||||
if let Err(e) = app.session.start("xattach") {
|
||||
return Some(Err(e.to_string()));
|
||||
}
|
||||
// Submit the picked filename as literal text, not through the command line: a path is
|
||||
// attacker-controlled (the file picker returns whatever the user selected), and
|
||||
// `cmdline` interprets command syntax, so a name like `'closeall` would run an engine
|
||||
// command instead of naming the file (CWE-74).
|
||||
if let Err(e) = app.session.input(cadcraft_engine::Input::Text(path)) {
|
||||
return Some(Err(e.to_string()));
|
||||
}
|
||||
Some(Ok(Value::Null))
|
||||
app.services.pick_open.as_ref()?;
|
||||
let was_prompting = app.session.prompting();
|
||||
app.pick_then(Pick::Open, move |app, picked| {
|
||||
let path = relative_to_host(app, &picked);
|
||||
app.start_after_pick("xattach", was_prompting)?;
|
||||
// Submit the picked filename as literal text, not through the command line: a
|
||||
// path is attacker-controlled (the file picker returns whatever the user
|
||||
// selected), and `cmdline` interprets command syntax, so a name like `'closeall`
|
||||
// would run an engine command instead of naming the file (CWE-74).
|
||||
app.session.input(cadcraft_engine::Input::Text(path)).map_err(|e| e.to_string())?;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ below, and ≈ 20–40 h for 3D exchange formats that wait for 3D modelling.
|
||||
|
||||
## How this was measured
|
||||
|
||||
- CADCraft: read from `crates/dxf`, `crates/io` (`dxf_read.rs`, `dxf_write.rs`, `pdf.rs`,
|
||||
- CADCraft: read from `craft-dxf` (craft-libs), `crates/io` (`dxf_read.rs`, `dxf_write.rs`, `pdf.rs`,
|
||||
`svg.rs`, `tests.rs`) and `crates/dwg` on 2026-10-10. Nothing was run (the measurement pass
|
||||
did no builds).
|
||||
- AutoCAD: the formats in its Open/Save As/Import/Export/Attach/Plot dialogs, from Autodesk's
|
||||
|
||||
@@ -34,7 +34,6 @@ impl Class {
|
||||
/// prefix.
|
||||
pub const TABLE: &[(&str, Class)] = &[
|
||||
("geom", Class::Layer(0)),
|
||||
("dxf", Class::Standalone),
|
||||
("dwg", Class::Standalone),
|
||||
("color", Class::Layer(0)),
|
||||
("doc", Class::Layer(1)),
|
||||
|
||||
Reference in New Issue
Block a user