DXF/DWG at a chosen version 2000–2018 and binary DXF (part of #421) (#426)

Squash of PR #426 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:19:18 -04:00
1 parent 475a70e573
commit 139d98bf6f
8 files changed
+608 -18

No files matched your search

+104 -8
View File
@@ -64,9 +64,11 @@ mod native {
if bytes.len() > limits.max_dwg_bytes {
return Err(format!("DWG: the file is {}, larger than the {} CADCraft opens", mb(bytes.len() as u64), mb(limits.max_dwg_bytes as u64)));
}
let doc = std::panic::catch_unwind(|| acadrust::DwgReader::from_stream(Cursor::new(bytes)).read())
let mut doc = std::panic::catch_unwind(|| acadrust::DwgReader::from_stream(Cursor::new(bytes)).read())
.map_err(|_| "the DWG reader failed on this file".to_string())?
.map_err(|e| format!("DWG: {e}"))?;
table_merges(&mut doc);
table_style_rows(&mut doc, false);
let mut out = CappedWriter { buf: Vec::new(), limit: limits.max_dxf_bytes, exceeded: false };
let written = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DxfWriter::new(&doc).write_to_writer(&mut out)))
.map_err(|_| "the DWG→DXF conversion failed on this file".to_string())?;
@@ -82,6 +84,65 @@ mod native {
Ok(add_header_vars(out.buf, &doc.header))
}
/// Merged table cells onto the cells. acadrust 0.6.3 reads a 2010+ (AC1024+) DWG table's
/// merged cells into the table's `merged_ranges` only, while its DXF writer writes each cell's
/// own merge groups (173, 175, 176), so without this the merges would be lost. Tables whose
/// cells already carry merges are left alone. Each range's first cell gets the span; the
/// cells it covers are marked merged with no span of their own, as CADCraft's DXF writer does.
fn table_merges(doc: &mut acadrust::CadDocument) {
for e in doc.entities_mut() {
let acadrust::entities::EntityType::Table(t) = e else { continue };
if t.merged_ranges.is_empty() || t.rows.iter().flat_map(|r| &r.cells).any(|c| c.merge_width > 1 || c.merge_height > 1) {
continue;
}
let (rows, cols) = (t.rows.len(), t.columns.len());
// A real table has at most one merge per cell; more ranges than that come from a
// damaged file and would only make this rows × cols × ranges.
let max_ranges = rows.saturating_mul(cols).min(1 << 16);
for r in t.merged_ranges.iter().take(max_ranges).cloned().collect::<Vec<_>>() {
// Clamp to the table: the ranges come from the file.
let (bottom, right) = (r.bottom_row.min(rows.saturating_sub(1)), r.right_col.min(cols.saturating_sub(1)));
if r.top_row > bottom || r.left_col > right || rows == 0 || cols == 0 || (r.top_row, r.left_col) == (bottom, right) {
continue;
}
let span = |n: usize| i32::try_from(n + 1).unwrap_or(i32::MAX);
for (ri, row) in t.rows.iter_mut().enumerate().take(bottom + 1).skip(r.top_row) {
for (ci, cell) in row.cells.iter_mut().enumerate().take(right + 1).skip(r.left_col) {
cell.merged = 1;
let first = (ri, ci) == (r.top_row, r.left_col);
cell.merge_width = if first { span(right - r.left_col) } else { 0 };
cell.merge_height = if first { span(bottom - r.top_row) } else { 0 };
}
}
}
}
}
/// Table styles' title and header suppression between the two places acadrust 0.6.3 keeps
/// it. 2010+ (AC1024+) DWG stores it only in the style's flags (title 1, header 2), while
/// acadrust's DXF reader and writer use the `title_suppressed`/`header_suppressed` fields
/// (groups 280, 281). `to_flags` copies the fields into the flags before a DWG is written;
/// otherwise the flags are copied into the fields after one is read. Older versions store
/// both and are left alone.
fn table_style_rows(doc: &mut acadrust::CadDocument, to_flags: bool) {
use acadrust::objects::{ObjectType, TableStyleFlags};
if doc.version < acadrust::DxfVersion::AC1024 {
return;
}
for o in doc.objects.values_mut() {
let ObjectType::TableStyle(s) = o else { continue };
for (bit, suppressed) in
[(TableStyleFlags::TITLE_SUPPRESSED, &mut s.title_suppressed), (TableStyleFlags::HEADER_SUPPRESSED, &mut s.header_suppressed)]
{
if to_flags {
s.flags.set(bit, *suppressed);
} else {
*suppressed = s.flags.contains(bit);
}
}
}
}
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
/// the HEADER section: (name, group code, value).
fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] {
@@ -146,22 +207,53 @@ mod native {
/// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004
/// (AC1018).
pub fn dxf_to_dwg(dxf: &[u8]) -> Result<Vec<u8>, String> {
convert(dxf, None)
}
/// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"),
/// whatever version the DXF declares.
pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
let v = match version.trim().to_ascii_uppercase().as_str() {
"AC1015" => acadrust::DxfVersion::AC1015,
"AC1018" => acadrust::DxfVersion::AC1018,
"AC1021" => acadrust::DxfVersion::AC1021,
"AC1024" => acadrust::DxfVersion::AC1024,
"AC1027" => acadrust::DxfVersion::AC1027,
"AC1032" => acadrust::DxfVersion::AC1032,
_ => return Err(super::unsupported(version)),
};
convert(dxf, Some(v))
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
let data = dxf.to_vec();
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
.map_err(|e| format!("DXF: {e}"))?;
// Write at least AutoCAD 2004 (AC1018) DWG: R2000 DWG has no true colours, transparency,
// table styles or gradients, and readers can't tell its layouts apart reliably. The DXF
// stays R2000; only the DWG is newer.
if doc.version < MIN_DWG_VERSION {
doc.version = MIN_DWG_VERSION;
match version {
Some(v) => doc.version = v,
// Without a version asked for, write at least AutoCAD 2004 (AC1018) DWG: R2000 DWG
// has no true colours, transparency, table styles or gradients. The DXF stays R2000;
// only the DWG is newer.
None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION,
None => {}
}
acadrust::DwgWriter::write_to_vec(&doc).map_err(|e| format!("DWG write: {e}"))
table_style_rows(&mut doc, true);
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
}
}
#[cfg(not(target_arch = "wasm32"))]
pub use native::{dwg_to_dxf, dwg_to_dxf_with, dxf_to_dwg};
pub use native::{dwg_to_dxf, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
/// The error for a DWG version CADCraft can't write.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn unsupported(version: &str) -> String {
let list: Vec<&str> = VERSIONS.iter().map(|(_, year)| *year).collect();
format!("DWG version `{version}` can't be written (CADCraft writes {})", list.join(", "))
}
#[cfg(target_arch = "wasm32")]
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
@@ -175,6 +267,10 @@ pub fn dwg_to_dxf_with(_bytes: &[u8], _limits: Limits) -> Result<Vec<u8>, String
pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
Err("DWG files can't be written in the web build yet; save as DXF".into())
}
#[cfg(target_arch = "wasm32")]
pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
Err("DWG files can't be written in the web build yet; save as DXF".into())
}
#[cfg(test)]
mod tests {
+94
View File
@@ -9,6 +9,9 @@
use std::fmt::Write as _;
mod version;
pub use version::Version;
#[derive(Debug, thiserror::Error)]
pub enum DxfError {
#[error("not a DXF file")]
@@ -359,6 +362,9 @@ fn parse_binary_with_limit(b: &[u8], max_tags: usize) -> Result<Vec<Tag>> {
break;
}
}
if tags.is_empty() {
return Err(DxfError::NotDxf);
}
Ok(tags)
}
@@ -376,10 +382,18 @@ pub fn format_real(v: f64) -> String {
/// 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 mut out = String::with_capacity(tags.len() * 16);
for t in tags {
let _ = write!(out, "{:>3}\r\n", t.code);
match &t.value {
Value::Str(s) if utf8 => out.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c })),
Value::Str(s) => escape_unicode(s, &mut out),
Value::Real(r) => out.push_str(&format_real(*r)),
Value::Int(i) => out.push_str(&i.to_string()),
@@ -391,6 +405,86 @@ pub fn write_ascii(tags: &[Tag]) -> String {
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> {
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 = String::with_capacity(s.len());
if utf8 {
text.extend(s.chars().map(|c| if matches!(c, '\r' | '\n') { ' ' } else { c }));
} else {
escape_unicode(&s, &mut text);
}
out.extend(text.bytes().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);
let v = t.value.as_i64().clamp(i64::from(i16::MIN), i64::from(i16::MAX));
out.extend_from_slice(&i16::try_from(v).unwrap_or_default().to_le_bytes());
}
Kind::Int32 => {
out.extend_from_slice(&code_bytes);
let v = t.value.as_i64().clamp(i64::from(i32::MIN), i64::from(i32::MAX));
out.extend_from_slice(&i32::try_from(v).unwrap_or_default().to_le_bytes());
}
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
}
/// The bytes of hex text, two digits each; anything that isn't a pair of hex digits is skipped.
fn hex_bytes(s: &str) -> Vec<u8> {
let digits: Vec<u8> = 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()
}
/// 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)
}
/// Escape a string for a pre-R2007 DXF, whose text is in the `$DWGCODEPAGE` code page: ASCII
/// stays as it is and every other character becomes `\U+XXXX` (a surrogate pair of escapes
/// above U+FFFF), so the bytes are valid in any code page. Line breaks become spaces.
+117
View File
@@ -0,0 +1,117 @@
//! 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);
}
}
+75
View File
@@ -0,0 +1,75 @@
//! 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());
}
+12 -4
View File
@@ -1,10 +1,12 @@
//! Drawing → DXF (AC1015 / R2000 structure, readable by every DXF consumer since 2000).
//! Drawing → DXF (AC1015 / R2000 structure, readable by every DXF consumer since 2000). Later
//! versions keep that structure (the groups they add are optional, DXF Reference) and differ in
//! `$ACADVER` and, from 2007, in UTF-8 text; the caller picks the text encoding.
use std::collections::HashMap;
use cadcraft_color::Color;
use cadcraft_doc::*;
use cadcraft_dxf::Tag;
use cadcraft_dxf::{Tag, Version};
use cadcraft_geom::{Bounds2, Vec2, Vec3};
use crate::dxf_ext::{self, DimVal, K};
@@ -1226,7 +1228,13 @@ fn record_head(w: &mut W, kind: &str, owner: &str, subclass: &str) -> String {
}
/// 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))
}
/// The drawing's DXF tags, declaring `version` in `$ACADVER`.
pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
// Structural handles start above every entity handle.
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100) + 0x1000, pstyles: HashMap::new() };
// Pre-allocate handles for block records.
@@ -1377,7 +1385,7 @@ pub fn write(d: &Drawing) -> String {
w.s(0, "SECTION");
w.s(2, "HEADER");
w.s(9, "$ACADVER");
w.s(1, "AC1015");
w.s(1, version.code());
w.s(9, "$DWGCODEPAGE");
w.s(3, "ANSI_1252");
let ext = d.extents(&Space::Model);
@@ -2025,7 +2033,7 @@ pub fn write(d: &Drawing) -> String {
*t = Tag::s(5, seed);
}
mark_xrefs(&mut w.t, d);
cadcraft_dxf::write_ascii(&w.t)
w.t
}
/// Xref blocks (DXF Reference, BLOCK): bit 4 of group 70 and the external drawing's path in
+45 -3
View File
@@ -12,8 +12,24 @@ pub mod svg;
use cadcraft_doc::{Drawing, Space};
use cadcraft_geom::Bounds2;
pub use cadcraft_dxf::Version;
pub use pdf::{PdfOptions, pdf, plot};
/// How a drawing is saved as DXF or DWG.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SaveOptions {
/// The file version; `None` saves in the drawing's own version ([`save_version`]).
pub version: Option<Version>,
/// Binary instead of ASCII DXF (DXF only; DWG is always binary).
pub binary: bool,
}
/// The version a drawing is saved in when none is asked for: the version of the file it was
/// read from (`$ACADVER`, the nearest one CADCraft writes), and 2018 for a new drawing.
pub fn save_version(d: &Drawing) -> Version {
d.header.get("ACADVER").and_then(cadcraft_doc::HVal::as_str).and_then(Version::for_acadver).unwrap_or_default()
}
#[derive(Debug, thiserror::Error)]
pub enum IoError {
#[error("{0}")]
@@ -42,11 +58,37 @@ pub fn read(bytes: &[u8], name: &str) -> Result<Drawing> {
}
/// Write a drawing in the format chosen by the name's extension. Images (PNG, SVG, PDF) show
/// model space fitted to its extents.
/// model space fitted to its extents. DXF and DWG are written in the drawing's version
/// ([`save_version`]), DXF as ASCII.
pub fn write(d: &Drawing, name: &str) -> Result<Vec<u8>> {
write_framed(d, name, None)
}
/// Like [`write`], with the DXF/DWG version and binary DXF chosen by `opts`. Binary applies to
/// DXF only; asking for it with another format is an error.
pub fn write_with(d: &Drawing, name: &str, opts: &SaveOptions) -> Result<Vec<u8>> {
let version = opts.version.unwrap_or_else(|| save_version(d));
match ext(name).as_str() {
"dxf" | "" => {
let tags = dxf_write::tags(d, version);
Ok(if opts.binary {
cadcraft_dxf::write_binary(&tags, version.utf8())
} else {
cadcraft_dxf::write_ascii_as(&tags, version.utf8()).into_bytes()
})
}
// The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without
// one, at least 2004: R2000 DWG has no true colours, transparency, table styles or
// gradients, so a drawing from an R2000 DXF doesn't lose them by default.
"dwg" if !opts.binary => {
let version = opts.version.unwrap_or_else(|| version.max(Version::R2004));
cadcraft_dwg::dxf_to_dwg_as(dxf_write::write(d).as_bytes(), version.code()).map_err(IoError::Format)
}
e if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{e}`"))),
_ => write_framed(d, name, None),
}
}
/// Like [`write`], but image formats (PNG, SVG, PDF) show `window` (a model-space rectangle in
/// drawing units, centred and scaled to fit the image without distortion) instead of the
/// extents. Formats that are not images (DXF, DWG) ignore the window. The window is checked with
@@ -58,8 +100,7 @@ pub fn write_framed(d: &Drawing, name: &str, window: Option<Bounds2>) -> Result<
check_window(w)?;
}
match ext(name).as_str() {
"dxf" | "" => Ok(dxf_write::write(d).into_bytes()),
"dwg" => cadcraft_dwg::dxf_to_dwg(dxf_write::write(d).as_bytes()).map_err(IoError::Format),
"dxf" | "" | "dwg" => write_with(d, name, &SaveOptions::default()),
"svg" => Ok(svg::export_window(d, &Space::Model, window).into_bytes()),
"png" => png_framed(d, &Space::Model, 2400, 1600, window),
"pdf" => pdf::pdf(d, &Space::Model, &PdfOptions { compress: true, window, ..PdfOptions::default() }),
@@ -120,6 +161,7 @@ pub fn png_view(extents: &Bounds2, width: u32, height: u32, window: Option<Bound
pub fn read_dxf(bytes: &[u8]) -> Result<Drawing> {
dxf_read::read(bytes)
}
/// The drawing as R2000 ASCII DXF, whatever its own version ([`write_with`] chooses one).
pub fn write_dxf(d: &Drawing) -> String {
dxf_write::write(d)
}
+3 -3
View File
@@ -56,8 +56,8 @@ fn viewport_ids(l: &Layout) -> Vec<u32> {
l.entities.iter().filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some(v.id) } else { None }).collect()
}
/// True colours (entity and layer) and entity transparency: the DWG is written as AutoCAD 2004
/// (R2000 DWG has neither), while the DXF stays R2000.
/// True colours (entity and layer) and entity transparency: a new drawing's DWG is written as
/// AutoCAD 2018 (R2000 DWG has neither), while the DXF the bridge reads stays R2000.
#[test]
fn dwg_keeps_true_colours_and_transparency() {
let mut d = Drawing::new_metric();
@@ -69,7 +69,7 @@ fn dwg_keeps_true_colours_and_transparency() {
);
let dwg = cadcraft_io::write(&d, "x.dwg").unwrap();
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1018"));
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1032"));
let header = records(&cadcraft_io::write_dxf(&d), "SECTION");
assert_eq!(header.first().and_then(|h| group(h, "1")), Some("AC1015"), "the DXF stays R2000");
+158
View File
@@ -0,0 +1,158 @@
//! Saving a drawing as DXF or DWG at a chosen version (2000–2018) and as binary DXF (#421).
use cadcraft_color::{Color, Rgb};
use cadcraft_doc::*;
use cadcraft_geom::Vec3;
use cadcraft_io::{SaveOptions, Version, read, save_version, write, write_dxf, write_with};
const INTL: &str = "图号 Café Ø 😀";
fn text(value: &str) -> EntityKind {
EntityKind::Text(Text {
insert: Vec3::new(1.0, 1.0, 0.0),
align_pt: None,
height: 2.5,
value: value.into(),
rotation: 0.0,
width_factor: 1.0,
oblique: 0.0,
style: "Standard".into(),
halign: HAlign::Left,
valign: VAlign::Baseline,
})
}
fn table() -> Table {
let cell = |t: &str| TableCell { text: t.into(), merged: None };
let mut cells = vec![vec![cell("Title"), cell(""), cell("")], vec![cell("A"), cell("B"), cell("C")], vec![cell("1"), cell("2"), cell("3")]];
cells[0][0].merged = Some((1, 3));
Table {
insert: Vec3::new(5.0, 20.0, 0.0),
col_widths: vec![1.5, 2.5, 4.0],
row_heights: vec![0.5, 0.4, 0.4],
cells,
style: "Standard".into(),
text_height: 0.2,
title: true,
header: true,
}
}
fn sample() -> Drawing {
let mut d = Drawing::new_metric();
d.layers.push(Layer { name: "Fancy".into(), color: Color::True(Rgb(12, 200, 99)), ..Layer::default() });
let fancy = Common { layer: "Fancy".into(), color: Color::True(Rgb(1, 2, 3)), transparency: Transparency::Percent(40), ..Common::default() };
d.add(&Space::Model, fancy, EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 5.0, 0.0) })).unwrap();
d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(3.0, 4.0, 0.0), radius: 2.0 })).unwrap();
d.add(&Space::Model, Common::default(), text(INTL)).unwrap();
d.add(&Space::Model, Common::default(), EntityKind::Table(table())).unwrap();
d.table_styles.push(sched());
d
}
fn sched() -> TableStyle {
TableStyle { name: "Sched".into(), text_height: 2.5, margin: 1.0, title: false, header: false }
}
fn opts(version: Version, binary: bool) -> SaveOptions {
SaveOptions { version: Some(version), binary }
}
fn acadver(bytes: &[u8]) -> String {
let tags = cadcraft_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()
}
fn first_text(d: &Drawing) -> Option<String> {
d.model.iter().find_map(|e| if let EntityKind::Text(t) = &e.kind { Some(t.value.clone()) } else { None })
}
fn first_table(d: &Drawing) -> Option<Table> {
d.model.iter().find_map(|e| if let EntityKind::Table(t) = &e.kind { Some(t.clone()) } else { None })
}
/// ASCII and binary DXF at every version declare it, encode text the version's way and read
/// back to the same drawing as the R2000 file.
#[test]
fn dxf_at_every_version_reads_back_the_same() {
let d = sample();
let reference = write_dxf(&read(write_dxf(&d).as_bytes(), "x.dxf").unwrap());
for v in Version::ALL {
for binary in [false, true] {
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}");
let raw = bytes.windows("图号".len()).any(|w| w == "图号".as_bytes());
assert_eq!(raw, v.utf8(), "{what}: UTF-8 text from 2007, \\U+ escapes before");
if !v.utf8() && !binary {
assert!(bytes.is_ascii(), "{what}");
}
let back = read(&bytes, "x.dxf").unwrap();
assert_eq!(first_text(&back).as_deref(), Some(INTL), "{what}");
assert_eq!(save_version(&back), v, "{what}: the file's version is remembered");
assert_eq!(write_dxf(&back), reference, "{what}");
}
}
}
/// R2000 ASCII is exactly the R2000 DXF CADCraft always wrote.
#[test]
fn r2000_ascii_is_unchanged() {
let d = sample();
assert_eq!(write_with(&d, "x.dxf", &opts(Version::R2000, false)).unwrap(), write_dxf(&d).into_bytes());
}
/// Without a version asked for: 2018 for a new drawing, otherwise the file's own version (the
/// nearest one CADCraft writes for older or newer files).
#[test]
fn default_version_is_the_drawings_own() {
let d = sample();
assert_eq!(acadver(&write(&d, "x.dxf").unwrap()), "AC1032");
for (file, saved) in [("AC1015", "AC1015"), ("AC1024", "AC1024"), ("AC1009", "AC1015"), ("AC1040", "AC1032")] {
let mut d = sample();
d.header.set_str("ACADVER", file);
assert_eq!(acadver(&write(&d, "x.dxf").unwrap()), saved, "{file}");
}
let from_r2000 = read(write_dxf(&d).as_bytes(), "x.dxf").unwrap();
assert_eq!(save_version(&from_r2000), Version::R2000);
}
#[test]
fn binary_is_for_dxf_only() {
assert!(write_with(&sample(), "x.dwg", &opts(Version::R2018, true)).is_err());
assert!(write_with(&sample(), "x.svg", &SaveOptions { version: None, binary: true }).is_err());
}
/// DWG at every version is that version and keeps the drawing: merged table cells, and from
/// 2004 true colours, transparency and table styles (R2000 DWG has none of those).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_at_every_version_reads_back() {
let d = sample();
for v in Version::ALL {
let dwg = write_with(&d, "x.dwg", &opts(v, false)).unwrap();
assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some(v.code()), "{v}");
let back = read(&dwg, "x.dwg").unwrap();
assert_eq!(save_version(&back), v, "{v}");
assert_eq!(back.model.len(), d.model.len(), "{v}");
assert_eq!(first_text(&back).as_deref(), Some(INTL), "{v}");
assert_eq!(first_table(&back).map(|t| t.cells), Some(table().cells), "{v}: merged cells kept");
if v >= Version::R2004 {
let line = back.model.iter().find(|e| matches!(e.kind, EntityKind::Line(_))).unwrap();
assert_eq!(line.common.color, Color::True(Rgb(1, 2, 3)), "{v}");
assert_eq!(line.common.transparency, Transparency::Percent(40), "{v}");
assert_eq!(back.table_styles.iter().find(|s| s.name == "Sched"), Some(&sched()), "{v}");
}
}
}
/// A drawing from an R2000 file saves as 2004 DWG unless 2000 is asked for: R2000 DWG would
/// lose its true colours.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dwg_default_is_at_least_2004() {
let from_r2000 = read(write_dxf(&sample()).as_bytes(), "x.dxf").unwrap();
assert_eq!(cadcraft_dwg::version(&write(&from_r2000, "x.dwg").unwrap()).as_deref(), Some("AC1018"));
assert_eq!(cadcraft_dwg::version(&write(&sample(), "x.dwg").unwrap()).as_deref(), Some("AC1032"));
}